Extract registries into model/ module with SemanticModel interface (#786)

* Initial plan

* feat(SM-20): extract registries into model/ module with SemanticModel interface

- Create model/type-registry.ts — TypeRegistry interface + factory
- Create model/method-registry.ts — MethodRegistry interface + factory
- Create model/field-registry.ts — FieldRegistry interface + factory
- Create model/semantic-model.ts — SemanticModel interface + factory
- Create model/heritage-map.ts — re-export HeritageMap types
- Create model/binding-accumulator.ts — re-export BindingAccumulator types
- Create model/resolve.ts — move lookupMethodByOwnerWithMRO from call-processor
- Update symbol-table.ts — delegate to SemanticModel for registry ops
- Update call-processor.ts — re-export lookupMethodByOwnerWithMRO from model/resolve

No circular dependencies: model/resolve.ts does NOT import resolution-context.ts.
All 775 related unit tests pass with no regressions.

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/27ad2975-1a31-4f50-815b-178ee8a95277

* fix: clarify re-export comment per code review feedback

Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/27ad2975-1a31-4f50-815b-178ee8a95277

* refactor(SM-20): wire up SemanticModel as first-class resolution input

PR #786 extracted TypeRegistry/MethodRegistry/FieldRegistry into model/
behind SemanticModel, but consumers still routed through SymbolTable
delegates. This change completes Phase 6 of the fuzzy-lookup elimination
roadmap by making call-processor, resolution-context, type-env, and
heritage-map query the model directly via `table.model.{types,methods,fields}`.

Also absorbs the open PR #786 review findings so the branch lands clean:
- Removed duplicate JSDoc block on lookupMethodByOwner (symbol-table.ts)
- Added model/index.ts barrel for the public model/ surface
- Fixed O(n) buildParentMapFromHeritage BFS via head-pointer queue
- Clarified re-export facade framing on binding-accumulator.ts and
  heritage-map.ts inside model/
- Refined @internal JSDoc on lookupMethodByOwnerWithMRO

Changes:
- symbol-table.ts: expose `readonly model: SemanticModel` on the
  SymbolTable interface. SymbolTable delegate wrappers (lookupClassByName
  etc.) stay as thin pass-throughs for backward compat; deletion is a
  follow-up once all internal callers are migrated.
- model/resolve.ts: lookupMethodByOwnerWithMRO now takes SemanticModel
  instead of SymbolTable, removing the last SymbolTable import from the
  model/ module. Preserves circular-dependency firewall.
- call-processor.ts: 6 call sites in D0 member resolution, field
  resolution, ctor override, and ctor disambiguation migrated to
  model.types/methods/fields.
- resolution-context.ts: tier 3 class+impl lookup migrated.
- type-env.ts: 5 sites across lookupClassDefsByName, resolveFieldType,
  and resolveMethodReturnType migrated.
- heritage-map.ts: parent/child class-name resolution migrated.

Tests:
- symbol-table.test.ts: +10 parity and feeding-audit tests covering
  every model.{types,methods,fields} path (Class, Method, Property,
  Impl, Function-with-ownerId, Property-without-ownerId skip, arity
  filtering, clear cascade).
- call-processor.test.ts: classLookupSpy now targets
  ctx.symbols.model.types since the wrapper is bypassed.
- type-env.test.ts: createMockSymbolTable and the destructured-call
  makeSymbolTable helpers gained a model shim that forwards to the
  (possibly overridden) top-level lookup stubs.

Validation: full suite 5603 passed / 159 skipped, resolver integration
suite (19 files, 1766 tests) clean, tsc --noEmit clean.

* refactor(SM-21): invert ownership — SemanticModel contains SymbolTable

Follow-up to SM-20. Previously SymbolTable owned a `model` subfield;
this commit turns the ownership direction around so the SemanticModel
is the top-level container and SymbolTable is nested as `.symbols`:

    SemanticModel (top-level, passed everywhere)
      ├── types   (TypeRegistry)
      ├── methods (MethodRegistry)
      ├── fields  (FieldRegistry)
      └── symbols (SymbolTable — file-indexed + callable-name index)

The owner-scoped registries live directly on the model; file and
callable-name lookups go through `.symbols`. Consumers receive a
`SemanticModel` and reach into the appropriate field — no more
`table.model.types.X` double-hop.

Core changes:
- symbol-table.ts: createSymbolTable now takes injected
  TypeRegistry/MethodRegistry/FieldRegistry via a SymbolTableDeps
  argument. When omitted (test fallback), it creates standalone
  registries locally and clears them in clear() — production callers
  always inject. The five registry convenience delegates
  (lookupClassByName, lookupMethodByOwner, lookupFieldByOwner,
  lookupClassByQualifiedName, lookupImplByName) remain as thin
  forwards to the injected registries so standalone SymbolTable use
  (chiefly tests) stays ergonomic.
- model/semantic-model.ts: createSemanticModel() now creates the
  three registries AND a SymbolTable wired to them, exposing the
  SymbolTable as `.symbols`. clear() cascades through all four.
- resolution-context.ts: `readonly symbols: SymbolTable` field is
  replaced with `readonly model: SemanticModel`. Internal factory
  builds a SemanticModel and keeps a local `symbols` alias for
  backward-compatible inner body.

Consumer migrations (src/):
- call-processor.ts: ctx.symbols.add/.lookupExactAll/
  .lookupCallableByName → ctx.model.symbols.*; ctx.symbols.model.X →
  ctx.model.X. buildTypeEnv option key renamed symbolTable → model.
- type-env.ts: symbolTable parameter renamed model (type
  SemanticModel), all internal call sites rewritten to use
  model.types.*, model.methods.*, model.fields.*,
  model.symbols.lookupExactAll / .lookupCallableByName.
- heritage-map.ts: 2 class-lookup sites migrated.
- pipeline.ts: ctx.symbols → ctx.model.symbols throughout.

Test migrations:
- symbol-table.test.ts: parity tests (which validated the old
  table.model.X hop) replaced with direct SemanticModel coverage via
  createSemanticModel(). New tests exercise types/methods/fields/
  symbols feeding end-to-end.
- type-env.test.ts: createMockSymbolTable rebuilt as a
  SemanticModel-shaped mock that still accepts the legacy flat
  override bag for backward compat; inline `makeSymbolTable` helpers
  for destructured-call and importedReturnTypes suites rewritten to
  match the new shape; buildTypeEnv options `symbolTable: X` and
  `{ symbolTable }` shorthand renamed to `model:`; one real
  createSymbolTable-based test rewritten to use createSemanticModel.
- call-processor.test.ts, heritage-map.test.ts,
  heritage-processor.test.ts, symbol-resolver.test.ts: bulk sed
  `ctx.symbols.` → `ctx.model.symbols.`. call-processor.test.ts spy
  updated to target `ctx.model.types.lookupClassByName`.

Validation: full test suite 5589 passed / 169 skipped / 0 failed;
tsc --noEmit clean; pre-commit eslint + prettier + typecheck all
green. CLAUDE.md / AGENTS.md stats bumped from an earlier `npx
gitnexus analyze` refresh (3965 symbols / 10012 edges / 243 flows).

* refactor(SM-22/SM-23): dispatch table + DAG rearchitecture

SM-22: Extract registration dispatch table into model/registration-table.ts.
Replaces the if/else ladder inside SymbolTable.add() with an O(1)
Map<NodeLabel, RoutingDecision> fan-out. SemanticModel wires the table
per-instance so hooks close over the correct registries.

SM-23: DAG rearchitecture. symbol-table.ts is now a pure 2-index leaf
(fileIndex + callableByName) with zero imports from model/. All
type/method/field routing lives in the model/ layer. Tests migrated to
createSemanticModel() + model.symbols access pattern.

Tests: 5632 passed, 0 failures.

* refactor: delete dead code (skipCallableIndex + model/ facades)

Removes the unused skipCallableIndex flag from the registration dispatch
table and deletes two facade files that had zero consumers.

skipCallableIndex was declared on RoutingDecision and populated for all
10 entries but never read at runtime — semantic-model.ts explicitly
documented that the flag was NOT consulted. The callable-index gate
lives inside SymbolTable.add() via CALLABLE_TYPES.has(type), which is
the single source of truth. Deleting the flag keeps SymbolTable as the
sole decision point and removes documentation-as-data.

model/binding-accumulator.ts and model/heritage-map.ts were facade
pass-throughs of their parent-directory counterparts. Grep confirms no
consumer imports either from the model/ path — all usage goes through
../binding-accumulator.js and ../heritage-map.js directly. model/index.ts
was the only "user" and re-exported them with a note about unifying the
import boundary, but that boundary has no actual consumers today.

Resolves review findings M-01 and M-03 from
.context/compound-engineering/ce-review/20260411-144641-59605d93/maintainability.json

Tests: 5631 passed, 0 failures (1 less than pre-Unit-1: the
skipCallableIndex-specific assertion was removed).

* refactor: remove lookupMethodByOwnerWithMRO backward-compat shim

call-processor.ts re-exported lookupMethodByOwnerWithMRO from
./model/resolve.js as a backward-compat shim for symbol-table.test.ts.
The function already lives in model/resolve.ts and is re-exported
properly from model/index.ts (the barrel) — the call-processor shim
was a duplicate export path with no durable reason to exist.

Migrated the test import from call-processor.js to model/index.js
(the canonical barrel). Deleted the re-export statement and the stale
"re-exported for backward compatibility" comment block. Hoisted the
remaining import to the top of the file with the other imports; the
bottom-of-file position was a relic of the shim pattern.

Resolves review finding M-02 from
.context/compound-engineering/ce-review/20260411-144641-59605d93/maintainability.json

Tests: 5631 passed, 0 failures.

* refactor: harden registration dispatch runtime safety

Two hardening changes in semantic-model.ts, both closing silent-failure
paths in the SM-series dispatcher-bypass failure mode.

1. model.symbols.clear() now cascades to the owner-scoped registries.
   Previously, the SymbolTable facade exposed rawSymbols.clear directly,
   which only emptied fileIndex + callableByName — the types/methods/
   fields registries stayed populated. Any caller holding a SymbolTable
   reference that invoked .clear() left the model in a split state where
   subsequent .add() calls double-registered in the registries. No
   current caller exercises this path, but it was a latent phantom-
   resolution risk that didn't belong in a public API. Extracted the
   cascade into a single cascadeClear closure wired into both
   model.clear() and the facade's clear field.

2. runExhaustivenessGuard now throws instead of console.warn on drift.
   The production short-circuit via NODE_ENV === 'production' is
   preserved, so real users never see the throw — but CI and dev runs
   now fail loudly if a NodeLabel is added to gitnexus-shared without
   being placed in one of the three registration-table allowlists. The
   previous warn-only behavior was silent in test output volume; SM-19
   already documented dispatcher-bypass as the dominant silent-failure
   mode in this codebase.

Test-first: added test/unit/model/semantic-model.test.ts covering
model.symbols.clear() cascade (4 registries × clear = 4 tests), the
existing model.clear() cascade (regression guard), and a happy-path
construction test that verifies the current allowlists have zero drift.

Resolves correctness P2 finding (symbols.clear() partial clear),
correctness P3 (exhaustiveness warn-only), and kieran-typescript KT-03
(same exhaustiveness finding, agreement boost).

Tests: 5638 passed (+7 new), 0 failures.

* docs: fix stale JSDoc references in resolveStaticCall

call-processor.ts:2215-2216 referenced SymbolTable.lookupClassByName
and SymbolTable.lookupMethodByOwner via {@link}. Both methods were
removed from SymbolTable during SM-20 — they now live on TypeRegistry
and MethodRegistry respectively, accessible via model.types and
model.methods.

Other SymbolTable.* references in the codebase (lookupExactFull, add,
lookupCallableByName in call-processor.ts:593, symbol-table.ts:86,
type-extractors/types.ts:57) target methods that are still on
SymbolTable and remain valid.

Resolves correctness P3 and kieran-typescript KT-02 (same finding,
agreement boost).

* refactor: deduplicate ALL_NODE_LABELS constant

ALL_NODE_LABELS was private in semantic-model.ts and duplicated
verbatim in registration-table.test.ts. Two hardcoded lists meant a
new NodeLabel added to gitnexus-shared could land in one copy but not
the other, silently drifting the exhaustiveness invariant.

Exported ALL_NODE_LABELS from semantic-model.ts, re-exported through
model/index.ts for barrel consistency, and switched the test to import
it instead of redeclaring. The explanatory comment now describes the
single-source-of-truth contract.

Resolves maintainability M-04.

Tests: 5638 passed, 0 failures.

* refactor: add compile-time NodeLabel exhaustiveness check

The runtime exhaustiveness guard in semantic-model.ts caught drift at
test time. Added a type-level check in registration-table.ts that
catches drift at BUILD time — if a new NodeLabel is added to
gitnexus-shared without being classified into one of the three
allowlists, TypeScript fails the _exhaustiveCheck assignment and
names the missing label.

The runtime guard stays as belt-and-suspenders: if a future contributor
bypasses the type check with @ts-ignore, the runtime guard still fires
in dev/test.

Implementation: converted the three allowlist Set<NodeLabel> initializers
to use `as const` tuples, then derived a union type from the tuples and
asserted `Exclude<NodeLabel, union> extends never`. Zero runtime impact
— the exported Sets are unchanged, Map.get hot-path performance is
unchanged, the test API is unchanged.

Resolves kieran-typescript KT-04.

Tests: 21/21 registration-table tests pass with zero modifications.

* refactor(test): restore type safety to createMockSymbolTable

createMockSymbolTable was widened to (overrides: any = {}): any with an
eslint-disable-next-line, and every buildTypeEnv call site passed the
mock as `model: mockSymbolTable as any`. The widening masked silent
false-green tests: buildTypeEnv accesses model.types/methods/fields,
and a flat any-typed override could silently return undefined from a
path that TypeScript should have caught at compile time.

Defined LegacyMockOverrides interface with typed stubs for each method
the mock can override (SymbolTable reads + TypeRegistry/MethodRegistry/
FieldRegistry lookups). Return type is now SemanticModel, so the mock
object is compile-checked against the real interface — a missing
registry method is a type error, not a silent runtime undefined.

Removed the eslint-disable and all 9 `as any` casts at call sites
(lines 1287, 1300, 1307, 2124, 2138, 5823, 5835, 5850, 5870). The
mock's return value now flows through buildTypeEnv's typed `model`
option without coercion.

Resolves kieran-typescript KT-01 and testing gap TG-02. This was the
highest-value cleanup in the plan — the only finding representing real
hidden test weakness.

Tests: 360 passed | 7 skipped (type-env.test.ts), typecheck clean.

* test: close coverage gaps in model/ registries

Added direct unit tests for the three owner-scoped registries that
previously had only transitive coverage via symbol-table.test.ts and
registration-table.test.ts. These new tests pin behaviors that were
flagged by the testing reviewer as untested or undertested.

method-registry.test.ts (14 tests):
- T-01: arity-fallback branch — when argCount matches no overload,
  fall back to the full pool so fuzzy resolution still has candidates.
  Previously untested and would have returned undefined instead of
  a valid candidate if the branch regressed.
- T-02: requiredParameterCount range filtering — methods with default
  parameters accept any argCount in [requiredParameterCount,
  parameterCount]. Previously untested at the registry level.
- Variadic fallback (parameterCount=undefined is retained during arity
  narrowing, bypassing range check).
- Return-type dedup paths: shared returnType → first wins, differing
  returnTypes → undefined, firstReturnType=undefined → undefined,
  single-overload skips dedup entirely.

type-registry.test.ts (9 tests):
- classByName homonym accumulation (two User classes in different
  packages both returned).
- classByQualifiedName disambiguation — same simple name, different
  FQNs resolve independently.
- Partial classes with identical simple + qualified name accumulate
  in both indexes.
- registerImpl stores Rust impl blocks separately from classes.
- Multiple impl blocks per type accumulate.

field-registry.test.ts (6 tests):
- register/lookup round-trip, owner-scope isolation, last-wins on
  duplicate key (flat map, not overload list).
- clear + re-register round-trip.

Extended symbol-table.test.ts cascade test (renamed from "both
registries" to "all three registries and the nested symbol table") to
also assert model.methods and model.fields are cleared — the test
name previously implied full coverage but only asserted types + symbols.

Resolves testing findings T-01, T-02, T-03, T-05.

Tests: 5667 passed (+29 new), 0 failures.

* refactor(test): replace brittle reference-equality tests + add intent comments

Two cleanups flagged as low-severity P3 by the testing reviewer:

1. registration-table.test.ts: Replaced three reference-equality tests
   (hook identity via toBe) with behavioral tests that survive a future
   refactor to per-label closures. The new "class-like behavior group"
   describe iterates Class/Struct/Interface/Enum/Record/Trait and
   verifies each one writes to types.registerClass. Same pattern for
   Method/Constructor. A separate "behavior group isolation" describe
   verifies class-like hooks don't leak into methods/fields and Impl
   never pollutes registerClass. Strictly more coverage than the
   reference-equality tests provided and implementation-independent.

2. symbol-resolver.test.ts: Added a comment above the lookupExactFull
   and SM-16: getFiles() describes explaining why they intentionally
   use createSymbolTable() directly instead of createSemanticModel().
   The DAG leaf-only behaviors they test do not involve registries, so
   testing the bare SymbolTable keeps the unit isolated. Prevents a
   future reader from "fixing" the inconsistency.

3. qualified-class-lookups.test.ts: Added a comment above
   `const symbolTable = model.symbols` explaining that processParsing
   writes still reach the owner-scoped registries via SemanticModel's
   fan-out — the alias is convenience, not a leaf in isolation.

Resolves testing T-04, kieran-typescript KT-05, kieran-typescript KT-06.

Tests: affected files all green (112 passed in registration-table +
symbol-resolver + qualified-class-lookups).

* refactor(model): collapse RoutingDecision wrapper and trim barrel surface

Two cleanups against the advanced-review findings on post-Unit-9 state:

S2 (cross-reviewer agreement — architecture-strategist + code-simplicity):
Delete the RoutingDecision single-field wrapper interface. Post-Unit-1
it held exactly one field (hook: RegistrationHook) and added pure
ceremony at every call site — `dispatchTable.get(key)!.hook(name, def)`
vs the now-direct `dispatchTable.get(key)!(name, def)`. Change the Map
type from Map<NodeLabel, RoutingDecision> to Map<NodeLabel,
RegistrationHook>, drop the interface, and update 17 test call sites.

A3 (architecture-strategist): Trim model/index.ts barrel surface.
createRegistrationTable, RegistrationHook, and RegistrationTableDeps
were re-exported from the barrel despite having zero legitimate
consumers outside model/ itself. The only callers (semantic-model.ts
and registration-table.test.ts) import directly from
./registration-table.js. Barrel exposure invited external callers to
construct orphan dispatch tables with independent registries,
weakening the SM-21 ownership inversion where SemanticModel is the
composition root. Kept CALLABLE_ONLY_LABELS, INERT_LABELS,
DISPATCH_LABELS exported since those remain useful for downstream
resolution logic and have no construction risk.

Resolves review findings:
- S2 (code-simplicity P3, 0.85) + architecture-strategist residual
- A3 (architecture-strategist P3, 0.82)

Tests: 5674 passed, 0 failures. Typecheck clean.

* refactor(model): replace runtime exhaustiveness guard with compile-time bijection

Replace the three-layer drift protection (hardcoded ALL_NODE_LABELS
array + 3 tuple consts + _ExhaustiveLabelCheck type + runExhaustivenessGuard
runtime + CI taxonomy test) with a single Record<NodeLabel, LabelBehavior>
map that structurally proves every invariant at compile time.

## Before

- ALL_NODE_LABELS hardcoded in semantic-model.ts (36 entries, could drift)
- DISPATCH_LABELS_TUPLE / CALLABLE_ONLY_LABELS_TUPLE / INERT_LABELS_TUPLE
  private tuples (36 more entries total, could overlap or miss)
- _ClassifiedLabel / _UncoveredLabel type-level check (caught missing
  labels but NOT duplicates across tuples)
- runExhaustivenessGuard runtime throw (only defense against duplicates)
- NodeLabel taxonomy coverage test in CI (same check as runtime guard)

Four defenses for invariants that the type system can express directly.

## After

```ts
type LabelBehavior = 'dispatch' | 'callable-only' | 'inert';

const LABEL_BEHAVIOR = {
  Class: 'dispatch',
  // ...36 entries...
  Tool: 'inert',
} as const satisfies Record<NodeLabel, LabelBehavior>;
```

The `as const satisfies Record<NodeLabel, LabelBehavior>` combo enforces:

1. **Every NodeLabel must be a key** — Record requires all K keys.
   Adding a NodeLabel to gitnexus-shared without classifying it here
   fails with "Property 'X' is missing in type ..." naming the drifted label.
2. **No non-NodeLabel keys allowed** — `satisfies` with object literals
   triggers excess-property checking. A typo'd key fails to compile.
3. **No duplicate classification** — impossible by construction; object
   keys are unique at the source level.
4. **Valid category** — LabelBehavior is a narrow union, typos caught.

`ALL_NODE_LABELS`, `DISPATCH_LABELS`, `CALLABLE_ONLY_LABELS`, and
`INERT_LABELS` are now derived via `Object.keys(LABEL_BEHAVIOR)` and
`filter(l => LABEL_BEHAVIOR[l] === ...)` — single source of truth,
structurally impossible to drift.

## Deleted

- runExhaustivenessGuard() function in semantic-model.ts (~18 lines)
- ALL_NODE_LABELS hardcoded array in semantic-model.ts (~38 lines)
- DISPATCH_LABELS_TUPLE / CALLABLE_ONLY_LABELS_TUPLE / INERT_LABELS_TUPLE
  private consts in registration-table.ts (~30 lines)
- _ClassifiedLabel / _UncoveredLabel / _exhaustiveCheck type machinery
  (~20 lines)

## Kept named proofs: none

The `as const satisfies` on the object literal already catches all four
drift modes. Named type-level proofs (_MissingFromMap / _ExtraKeysInMap)
are pure duplication and were removed per review.

## Also in this commit

- S6: trim wrappedAdd narration comments in semantic-model.ts
  (Step 1/2/3 block comments removed; kept the Function+ownerId WHY note)
- A3: tighten model/index.ts barrel — createRegistrationTable,
  RegistrationHook, RegistrationTableDeps remain direct-imports only;
  ALL_NODE_LABELS and LabelBehavior re-exported from the new home in
  registration-table.ts

## Resolves

- Advanced-review S4 (runtime guard per-call cost) — guard no longer exists
- Advanced-review S1 (tuple three-defenses indirection) — single Record replaces all tuples
- Correctness P3 (exhaustiveness warns-only) — structurally impossible to drift
- Unit 6 type-level check — subsumed by the Record type
- Unit 3 runtime throw — no longer needed

Tests: 5674 passed, 0 failures. Typecheck clean.

* test(model): delete duplicate closure-isolation spy tests

S5 (code-simplicity P3): The 'closure isolation — each hook can only
write to its registry' describe block duplicated the 'behavior group
isolation' block's coverage via a different mechanism.

Behavioral tests (lines 151-174, kept):
  table.get('Class')!('User', def);
  expect(deps.methods.lookupMethodByOwner('unrelated', 'User')).toBeUndefined();
  expect(deps.fields.lookupFieldByOwner('unrelated', 'User')).toBeUndefined();

Spy tests (deleted, ~55 lines):
  vi.spyOn(deps.methods, 'register')
  table.get('Class')!('User', def);
  expect(methodsSpy).not.toHaveBeenCalled();

Both assert the same invariant — classHook does not touch the methods or
fields registries. The behavioral form observes the END STATE of the
registry (lookup returns undefined), which is the actual contract.
The spy form asserts the IMPLEMENTATION (a specific method was not
called), which couples to internal wiring — a refactor to a different
register function name would break the spy test while the behavioral
test would still pass.

Also dropped the now-unused `vi` import from vitest.

Tests: 24/24 registration-table.test.ts pass (-4 from spy deletion).

* refactor(model): compile-time cross-invariant between CLASS_TYPES and dispatch classHook

A1 (architecture-strategist P2, 0.90): CLASS_TYPES in symbol-table.ts
and the class-like entries of the dispatch table were two independent
hardcoded sets. Adding a new class-like label (e.g. Swift 'Extension')
to one but not the other would silently degrade qualifiedName
population — the symptom is subtle (partial qualified-name lookups)
and no test asserted the co-extensive invariant.

Fixed with a single source of truth and a two-layer compile-time
enforcement:

## symbol-table.ts

- Add `CLASS_TYPES_TUPLE` as `readonly [...] as const satisfies
  readonly NodeLabel[]`. The `satisfies` forces every tuple entry to
  be a valid NodeLabel at compile time.
- Export derived type `ClassLikeLabel = typeof CLASS_TYPES_TUPLE[number]`.
- Derive `CLASS_TYPES` Set from the tuple — same runtime shape as
  before, now typed `ReadonlySet<NodeLabel>`.

## registration-table.ts

- Import `CLASS_TYPES_TUPLE` and `ClassLikeLabel` from symbol-table.ts.
- Narrow the `satisfies` on `LABEL_BEHAVIOR` via intersection:
      Record<NodeLabel, LabelBehavior> & Record<ClassLikeLabel, 'dispatch'>
  This forces every class-like label to have value 'dispatch' at
  compile time. Adding a label to CLASS_TYPES_TUPLE without
  classifying it as dispatch in LABEL_BEHAVIOR fails to compile with
  a type error naming the drifted label.
- Build the class-like entries of the dispatch Map by iterating
  `CLASS_TYPES_TUPLE` at factory time. Adding a label to the tuple
  automatically wires it to classHook — no second place to update.

## What the design prevents

1. Drift scenario A (A1 original): 'Extension' added to CLASS_TYPES_TUPLE
   but not to LABEL_BEHAVIOR → compile error on LABEL_BEHAVIOR's
   satisfies.
2. Drift scenario B: 'Extension' added to CLASS_TYPES_TUPLE but not
   wired to classHook → impossible because the Map is derived from the
   tuple.
3. Drift scenario C: class-like label classified as something other
   than 'dispatch' in LABEL_BEHAVIOR → compile error on the narrowed
   intersection.

Runtime behavior unchanged: same 6 labels in CLASS_TYPES, same 6
class-like entries in the dispatch Map. Tests pin the behavior via
the existing behavior-group tests in registration-table.test.ts.

DAG unchanged: registration-table.ts already imported from symbol-table.ts
(the allowed upward direction). symbol-table.ts still imports nothing
from model/.

Tests: 5670 passed, 0 failures. Typecheck clean.

* test(field-extraction): use SemanticModel facade instead of raw SymbolTable

A6 (architecture-strategist P3, 0.85): field-extraction.test.ts created
its FieldExtractorContext fixture with `symbolTable: createSymbolTable()` —
a raw SymbolTable leaf, not the facade. In production, the context's
symbolTable field is always `model.symbols` (the SemanticModel-wrapped
facade where .add() dispatches through the owner-scoped registries).

The current field extractors don't call symbolTable.add() at all, so
this change is behavior-neutral today. The value is architectural
consistency — matching the test fixture to the production shape
prevents silent drift if a future field extractor starts registering
dynamically-discovered properties via the context. Without the fix,
such writes would hit the raw leaf and skip the fan-out, and tests
would pass even though the symptom (empty FieldRegistry) would
manifest in production.

Tests: 50/50 field-extraction.test.ts pass. Production tsc --noEmit
clean. Test-tsconfig error count unchanged (634 pre-existing errors
in unrelated test files, out of scope).

* refactor(A5): decouple model/resolve.ts from language registry

Move the MroStrategy type into gitnexus-shared and replace the
language: SupportedLanguages parameter on lookupMethodByOwnerWithMRO
with a direct mroStrategy: MroStrategy literal. Callers derive the
strategy from their language provider before invoking the resolver.

model/resolve.ts no longer imports from ../languages/index.js, so the
model/ layer is free of cross-layer coupling with the language
registry — this closes finding A5 from the SM-20/21/22/23 advanced
review (plan 006).

* feat(A4): add MethodRegistry.lookupMethodByName flat-by-name index

Add a secondary `methodsByName: Map<string, SymbolDefinition[]>` index
on MethodRegistry that returns every method with a given unqualified
name, accumulated across owners and overloads. The new index shares
SymbolDefinition references with methodByOwner — no duplication.

This is step 1 of the A4 double-index removal (plan 006). Tier 3
global resolution will switch to this index in Unit 3 so Method and
Constructor can be removed from CALLABLE_TYPES in Unit 4.

* refactor(A4): extend Tier 3 + memberCallByFile to consult method registry

Add model.methods.lookupMethodByName to Tier 3 global resolution in
resolution-context.ts and to the callable-pool build in
call-processor.ts (resolveMemberCallByFile + D2 widen path).

Intentionally behavior-preserving: Method and Constructor are still
in CALLABLE_TYPES so the new lookup returns identical candidates that
already reach Tier 3 through callableByName. Both paths dedup by
nodeId during this intermediate state — Unit 4 shrinks CALLABLE_TYPES
and the dedup is removed.

Part of plan 006 A4 step 2.

* refactor(A4): shrink CALLABLE_TYPES to free callables only

CALLABLE_TYPES = {Function, Macro, Delegate}. Method and Constructor
are no longer double-indexed in callableByName — they reach resolvers
through model.methods.lookupMethodByName instead.

Companion changes:
- Introduce CALL_TARGET_TYPES = CALLABLE_TYPES ∪ {Method, Constructor}
  for the resolver's kind filter (filterCallableCandidates,
  countCallableCandidates). Separates registration semantics (narrow)
  from the resolver's acceptable-target set (wide).
- type-env.ts for-loop return-type inference consults both indexes,
  treating the union as the authoritative call pool.
- resolveMemberCallByFile + D2 widen path keep the nodeId dedup in
  place: Python/Rust/Kotlin class methods emitted as Function+ownerId
  still land in both indexes until Unit 5 unblocks the normalization.
- Tier 3 global resolution (resolution-context.ts) keeps the same
  dedup for the same reason.

Test updates reflect the new contract: Method/Constructor live in
methodsByName, not callableByName. Orphan Method-without-ownerId now
lives only in the file index (no registry coverage).

Part of plan 006 — closes A4 for strictly-labeled methods. Python/
Rust/Kotlin Function+ownerId normalization is tracked as Unit 5
(blocked).

* refactor: rename CALLABLE_TYPES → FREE_CALLABLE_TYPES

Pure rename. The constant's meaning changed in Unit 4 (free callables
only — no methods, no constructors) so the name now reflects that
scope: "callables that have no owner scope". Updates the constant
declaration and every consumer in src/ and test/.

Closes plan 006 Unit 6.

* refactor(A2): strict SymbolTableReader (pure reads) + SymbolTableWriter (+add)

Split the SymbolTable interface into three strictly layered surfaces:

- SymbolTableReader: lookups + iteration. NO add, NO clear. Holders
  cannot mutate the table in any way.
- SymbolTableWriter extends Reader: + add. NO clear. Holders can
  register new symbols but cannot trigger a leaf-index reset.
- InternalSymbolTable (private, not exported): + clear. The cascading
  reset capability is reachable only through createSymbolTable's
  return type, held exclusively by SemanticModel.rawSymbols.

SemanticModel.symbols is now typed as SymbolTableWriter — external
consumers (workers, processors, pipelines) can register symbols and
query them, but cannot reach .clear(). The A2 LSP fix holds: callers
holding any public reference cannot desync the leaf indexes from the
owner-scoped registries.

Delete the transitional `type SymbolTable = SymbolTableReader` alias
and migrate every consumer (src + test) to the explicit names:
- Field and parameter annotations use SymbolTableReader by default;
  only code that calls .add() uses SymbolTableWriter.
- parsing-processor (workers + sequential paths) takes
  SymbolTableWriter so it can register extracted symbols.
- field-types, call-processor, named-binding-processor,
  workers/parse-worker: use SymbolTableReader (query-only).
- Tests: drop the stale `clear` fields from mock factories and
  migrate the semantic-model cascade tests from the removed
  model.symbols.clear() path to model.clear().

Closes plan 006 Unit 7. Industry sources: TypeScript compiler API
builder pattern, Salsa ParallelDatabase, .NET IReadOnlyList. See the
a2-lsp-clear-contract-research artifact for full citations.

* feat(A2): add SemanticModel.resetFileIndex() partial-reset entry point

Add a named method that clears only the leaf file and callable
indexes without cascading to the three owner-scoped registries
(types, methods, fields). Replaces the rare partial-reset use case
that was previously reachable via the now-removed symbols.clear()
path from A2 (plan 006 Unit 7).

JSDoc makes the semantic difference with model.clear() explicit so
future readers don't have to guess which method to call for a given
reingestion scenario.

Test-first: three scenarios cover the partial-vs-full semantics,
re-add after reset, and idempotency.

Closes plan 006 Unit 8.

* docs(S7): trim registration-table module JSDoc

Remove the ~24 lines of design-provenance citations from the module
JSDoc. The rust-analyzer, TypeScript-compiler, and Fowler references
are preserved in git history via the original SM-22 commits and in
plan 006 Unit 9.

Keep the ownership diagram, behavior-group table, and the
'How to add a new NodeLabel' checklist — those are load-bearing for
future contributors.

Closes plan 006 Unit 9 (S7 advanced-review finding).

* test(S3): migrate type-env.test.ts off LegacyMockOverrides

Replace the createMockSymbolTable bridge and LegacyMockOverrides
interface with real createSemanticModel() + add() calls across all
14 call sites. Where a test needs a specific registry lookup that
can't be pre-populated cleanly, use vi.spyOn on the real registry
instead.

Pattern breakdown:
- Pattern A (pre-populate via model.symbols.add): 13 sites
- Pattern B (vi.spyOn on registry lookup): 1 site

Deletes LegacyMockOverrides + createMockSymbolTable entirely. The
real MethodRegistry arity/returnType semantics match the hand-rolled
mock behavior in every migrated case, and no 'as any' casts remain
in the file.

Closes plan 006 Unit 10 (S3 advanced-review finding).

* refactor: remove unused MroStrategy type exports from language-provider and resolve modules

* refactor: relocate symbol-table, heritage-map, resolution-context into model/

Use git mv so blame and history follow each file:
- gitnexus/src/core/ingestion/symbol-table.ts → model/symbol-table.ts
- gitnexus/src/core/ingestion/heritage-map.ts → model/heritage-map.ts
- gitnexus/src/core/ingestion/resolution-context.ts → model/resolution-context.ts

These three files are part of the SemanticModel layer (file/callable
indexes, heritage parent map, tiered resolver) and now sit alongside
the registries they collaborate with. Updates every consumer import
path across src/ and test/ to the new locations.

* refactor(model): enforce pure-leaf DAG + delete legacy re-exports

model/ is now a pure leaf: zero upward imports and zero compat
shims in its parent processors. Completes the DAG cleanup started
in the previous commit.

1. walkBindingChain — moved into model/resolution-context.ts;
   named-binding-processor.ts deleted.

2. NamedImportMap + NamedImportBinding + isFileInPackageDir —
   moved into model/resolution-context.ts. Every consumer now
   imports from the canonical location directly. Legacy re-exports
   in import-processor.ts deleted.

3. c3Linearize + gatherAncestors — moved into model/resolve.ts.
   mro-processor.ts imports them back for computeMRO. Legacy
   c3Linearize re-export from mro-processor.ts deleted.

4. ExtractedHeritage type — moved into model/heritage-map.ts.
   call-processor.ts, parsing-processor.ts, pipeline.ts,
   heritage-processor.ts, and the test files now import it from
   the canonical location. Legacy re-exports in parse-worker.ts
   and heritage-processor.ts deleted.

5. resolveExtendsType — rewritten in model/heritage-map.ts to
   take an explicit HeritageResolutionStrategy (A5-style DI).
   buildHeritageMap accepts an optional getHeritageStrategy
   callback; production uses getHeritageStrategyForLanguage from
   heritage-processor.ts. Legacy resolveExtendsType re-export
   from heritage-processor.ts deleted.

Verified:
- grep 'from "..' gitnexus/src/core/ingestion/model → empty
- grep 'Re-export for legacy' gitnexus/src/core/ingestion → empty
- npx tsc --noEmit → clean
- npx vitest run → 5686 passing

* docs(model): strip phase/plan references from module comments

Remove SM-20/21/22/23, A2/A4/A5, plan 006, Unit N labels and historical
phrasing ("previously", "legacy", "model-leaf DAG cleanup") from all 10
files in src/core/ingestion/model/. Preserve domain vocabulary (Tier
1/2/3), invariants, and caveats — only the plan archaeology is gone.

* refactor(model): tighten interface segregation + compile-time invariants

Apply four gated findings from branch-wide code review:

- SemanticModel.symbols now typed as SymbolTableReader; MutableSemanticModel
  widens it back to SymbolTableWriter. ResolutionContext.model is typed as
  MutableSemanticModel since it owns the lifecycle. Resolvers that only
  query symbols can annotate their own fields as SemanticModel to drop
  write access at the type level.

- Lookup methods (lookupExactAll, lookupCallableByName, lookupClassByName,
  lookupClassByQualifiedName, lookupImplByName) now return
  readonly SymbolDefinition[]. The returned arrays are live views into
  the internal indexes; the readonly marker prevents accidental caller
  mutation. walkBindingChain return type narrowed to match.

- FREE_CALLABLE_TUPLE + FreeCallableLabel exported from symbol-table.ts
  as the single source of truth for free-callable labels. LABEL_BEHAVIOR
  now satisfies Record<FreeCallableLabel, 'callable-only'> as a second
  cross-invariant alongside Record<ClassLikeLabel, 'dispatch'>. Adding a
  label to the tuple without classifying it as 'callable-only' fails at
  build time. CALLABLE_ONLY_LABELS is now a re-export alias of
  FREE_CALLABLE_TYPES so the two sets cannot drift.

- walkBindingChain fast-exits before allocating its cycle-detection Set
  when the caller's file has no named bindings. Skips ~200k transient
  Set allocations per large-repo resolution pass.

Also fixes five stale comments flagged by the review: duplicate JSDoc
block on RegistrationHook merged; resolve.ts "delegates to mro-processor"
direction corrected; RegistrationTableDeps JSDoc names
createRegistrationTable (not createSymbolTable); mro-processor.ts
"re-exported at top" stale comment removed; gatherAncestors export
comment matches reality.

tsc --noEmit clean, full test suite green (5786 tests).

* refactor(model): resolve four deferred P2 review findings

Address the four gated items from the branch-wide review that needed
design decisions before applying:

F#3 — Method/Constructor without ownerId fallback to callable index.
The dispatch hook silently skips owner-scoped labels that lack an owner
(an extractor contract violation — AST-degraded parse, or a buggy
language extractor). Pre-dispatch-table code let such defs fall through
to callableByName and stay reachable at Tier 3 global resolution. This
restores that fallback in SymbolTable.add so orphaned Methods and
Constructors don't silently vanish. Property deliberately does NOT
participate in the fallback to avoid polluting common names like
id / name / type.

F#4 — Delete MutableSemanticModel.resetFileIndex. The method had zero
production callers (only three tests), documented a "rare partial-
reingestion flow" that was never implemented, and contained the
adversarial-reviewer's double-populate trap: calling resetFileIndex
followed by re-adding the same class symbol would push a duplicate
SymbolDefinition into TypeRegistry.classByName without ever clearing
the first one. If incremental reingestion is ever needed, it can be
designed properly with per-file TypeRegistry invalidation. For now,
deleting the footgun is safer than documenting it.

F#5 — Compile-time dispatch-table completeness check. `LABEL_BEHAVIOR`
already enforces "every NodeLabel is classified" via
`Record<NodeLabel, LabelBehavior>`, but the dispatch-table factory
populated its Map with manual `table.set(...)` calls that TypeScript
could not correlate back to the `'dispatch'` classification. Add a
type-level `DispatchLabel` extracted from `LABEL_BEHAVIOR` via a
conditional mapped type, and build the table from an object literal
that satisfies `Record<DispatchLabel, RegistrationHook>`. Adding a new
dispatch-classified label without wiring it to a hook now fails the
build with a named-key error — no more silent no-op hooks.

F#7 — Tier 3 dedup fast-path via MethodRegistry.hasFunctionMethods.
The Set-based dedup between callableDefs and methodDefs is only needed
when a Python/Rust/Kotlin class method (emitted as Function+ownerId by
the worker) lands in both indexes. For TS/Java/C#/C++/Ruby-only repos
— where the two indexes are disjoint by construction — the dedup was
pure overhead on every global-tier hit. MethodRegistry now tracks
whether any Function-typed def was ever registered, and resolution-
context branches Tier 3 into a concat-only fast path when that flag
is false. Slow path with dedup survives unchanged for mixed-language
repos.

New tests pin the invariants: hasFunctionMethods flag transitions,
Method/Constructor orphan fallback, Property non-fallback, and the
MethodRegistry clear() reset. Full test suite green (5756 tests).

* refactor(model): close remaining P3 review findings + coverage gaps

Address the remaining review items in one batch.

Production refactors:

- Rename classHook → classLikeHook (M05). The hook handles Class /
  Struct / Interface / Enum / Record / Trait; the vocabulary used in
  surrounding docs and the behavior-group table is "class-like". The
  rename makes the code match the taxonomy without forcing readers
  through a mental glossary.

- Extract MAX_BINDING_CHAIN_DEPTH constant in resolution-context.ts
  and document it as a known silent false-negative source (ADV-003).
  Five hops cover the common TypeScript monorepo pattern; raising the
  cap is a one-line change if a real repo exceeds it. walkBindingChain
  consumes the constant so the 5 magic number no longer floats free.

- Replace defs.filter() allocation in MethodRegistry.lookupMethodByOwner
  with a two-pass streaming count + conditional materialization
  (PERF-04). Pure-match and pure-reject arity paths now skip the
  filtered-array allocation entirely; only the discriminating case
  (at least one match AND at least one rejection) pays it.

- Rewrite NOOP_SYMBOL_TABLE in parse-worker.ts and NOOP_SYMBOL_TABLE_SEQ
  in parsing-processor.ts to implement all six SymbolTableReader
  methods (ADV-005). The `as unknown as SymbolTableReader` cast is
  removed in favor of a direct SymbolTableReader annotation, so future
  additions to the interface surface as compile errors on the stubs
  instead of silently falling through.

- type-env.ts getCallableUnionCount and getFirstCallable now take
  `model: SemanticModel` as an explicit argument instead of reaching
  into the enclosing `model!` non-null assertion (KT-003). Callers
  enter via an `if (model)` guard and pass the narrowed reference, so
  the non-null precondition is visible at the type level and the
  closures cannot be accidentally extracted into a context without
  the guard.

- Tier 3 dedup in resolution-context.ts now covers all four index reads
  (classDefs, implDefs, callableDefs, methodDefs) via a pushUnique
  helper (C-03). Previously classDefs and implDefs were spread directly
  without dedup; any theoretical nodeId collision would have produced
  duplicates in globalDefs.

Test infrastructure:

- Extract makeDef / makeMethod factory helpers into
  test/unit/model/helpers.ts (T-07). The four registry/table test
  files now import the shared helper and specialize with overrides,
  removing ~25 lines of duplicated boilerplate and creating a single
  point of maintenance.

New test coverage:

- T-01: c3 BFS fallback — cyclic Python hierarchy that fails c3
  linearization and must fall back to heritageMap.getAncestors() BFS
  order. Added to the lookupMethodByOwnerWithMRO describe block.

- T-02: Tier 2a-named precedence — verifies the binding chain walker
  fires before Tier 2a import-scoped when an aliased import
  `import { User as U } from B` competes with a raw same-name Tier 2a
  hit. Also pins Tier 1 same-file precedence over Tier 2a-named.

- T-03: Tier 3 Function+ownerId dedup — end-to-end test that a Python
  class method emitted as `Function + ownerId` yields exactly ONE Tier
  3 candidate (not two). Companion test pins the fast-path branch for
  hasFunctionMethods === false repos.

- T-06: walkBindingChain guards — circular re-export detection,
  depth-cap exceeded drop, and boundary case at exactly
  MAX_BINDING_CHAIN_DEPTH hops resolving successfully.

All tests added to a new test/unit/model/resolution-context.test.ts
dedicated to ResolutionContext.resolve() tier-precedence invariants.

Full suite: 5708 passing (minus the known Windows LBUG lock flake
that passes in isolation).

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Gergo Magyar <gergomagyar@icloud.com>
This commit is contained in:
Copilot 2026-04-12 01:06:55 +01:00 • committed by GitHub
parent 4d55333d1c
commit 109d8ef4f5
45 changed files with 4780 additions and 2037 deletions

View file

@ -63,7 +63,7 @@ Generic “core standards” playbooks are often long and stack-specific. For th
<!-- gitnexus:start -->
# GitNexus — Code Intelligence
This project is indexed by GitNexus as **GitNexus** (3883 symbols, 9861 relationships, 225 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
This project is indexed by GitNexus as **GitNexus** (3975 symbols, 10043 relationships, 245 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.

View file

@ -52,7 +52,7 @@ If always-on instructions grow, load deep conventions via conditional reads (e.g
GitNexus MCP rules are in the `<!-- gitnexus:start -->
# GitNexus — Code Intelligence
This project is indexed by GitNexus as **GitNexus** (3883 symbols, 9861 relationships, 225 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
This project is indexed by GitNexus as **GitNexus** (3975 symbols, 10043 relationships, 245 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.

View file

@ -19,6 +19,7 @@ export type { NodeTableName, RelType } from './lbug/schema-constants.js';
// Language support
export { SupportedLanguages } from './languages.js';
export { getLanguageFromFilename, getSyntaxLanguageFromFilename } from './language-detection.js';
export type { MroStrategy } from './mro-strategy.js';
// Pipeline progress
export type { PipelinePhase, PipelineProgress } from './pipeline.js';

View file

@ -0,0 +1,23 @@
/**
* MRO (Method Resolution Order) strategy — shared between CLI and any
* future consumer that reasons about multiple-inheritance semantics.
*
* Lives in `gitnexus-shared` so the low-level resolution module
* (`core/ingestion/model/resolve.ts`) does not need to import from
* `languages/` — keeping the `model/` layer free of language-registry
* coupling.
*
* Strategy semantics:
* - `first-wins`: BFS ancestor walk, first match wins (default).
* - `leftmost-base`: BFS ancestor walk, leftmost base wins (C++).
* - `c3`: C3-linearized ancestor order, first match wins (Python).
* - `implements-split`: BFS walk, first match wins (Java/C#/Kotlin) — full
* interface-default ambiguity is handled at graph level.
* - `qualified-syntax`: No auto-resolution (Rust — requires `<T as Trait>::m`).
*/
export type MroStrategy =
| 'first-wins'
| 'c3'
| 'leftmost-base'
| 'implements-split'
| 'qualified-syntax';

View file

@ -1,11 +1,11 @@
import { KnowledgeGraph } from '../graph/types.js';
import { ASTCache } from './ast-cache.js';
import type { SymbolDefinition, SymbolTable } from './symbol-table.js';
import { CLASS_TYPES, CALLABLE_TYPES } from './symbol-table.js';
import type { SymbolDefinition, SymbolTableReader } from './model/symbol-table.js';
import { CLASS_TYPES, CALL_TARGET_TYPES } from './model/symbol-table.js';
import Parser from 'tree-sitter';
import type { ResolutionContext } from './resolution-context.js';
import { TIER_CONFIDENCE, type ResolutionTier } from './resolution-context.js';
import type { TieredCandidates } from './resolution-context.js';
import type { ResolutionContext } from './model/resolution-context.js';
import { TIER_CONFIDENCE, type ResolutionTier } from './model/resolution-context.js';
import type { TieredCandidates } from './model/resolution-context.js';
import { isLanguageAvailable, loadParser, loadLanguage } from '../tree-sitter/parser-loader.js';
import { getProvider } from './languages/index.js';
import { generateId } from '../../lib/utils.js';
@ -32,24 +32,24 @@ import {
} from './utils/call-analysis.js';
import { buildTypeEnv, isSubclassOf } from './type-env.js';
import type { ConstructorBinding, TypeEnvironment } from './type-env.js';
import type { HeritageMap } from './heritage-map.js';
import { c3Linearize } from './mro-processor.js';
import type { HeritageMap } from './model/heritage-map.js';
import type { BindingAccumulator } from './binding-accumulator.js';
import { getTreeSitterBufferSize } from './constants.js';
import type {
ExtractedCall,
ExtractedAssignment,
ExtractedHeritage,
ExtractedRoute,
ExtractedFetchCall,
FileConstructorBindings,
} from './workers/parse-worker.js';
import type { ExtractedHeritage } from './model/heritage-map.js';
import { normalizeFetchURL, routeMatches } from './route-extractors/nextjs.js';
import { extractTemplateComponents } from './vue-sfc-extractor.js';
import { extractReturnTypeName, stripNullable } from './type-extractors/shared.js';
import type { LiteralTypeInferrer } from './type-extractors/types.js';
import type { SyntaxNode } from './utils/ast-helpers.js';
import { extractParsedCallSite } from './call-sites/extract-language-call-site.js';
import { lookupMethodByOwnerWithMRO } from './model/resolve.js';
/** Per-file resolved type bindings for exported symbols.
* Populated during call processing, consumed by Phase 14 re-resolution pass. */
@ -204,7 +204,7 @@ function collectExportedBindings(
* exported symbols that have callables with known return types. */
export function buildExportedTypeMapFromGraph(
graph: KnowledgeGraph,
symbolTable: SymbolTable,
symbolTable: SymbolTableReader,
): ExportedTypeMap {
const result: ExportedTypeMap = new Map();
graph.forEachNode((node) => {
@ -652,7 +652,7 @@ function findInterfaceDispatchTargets(
const results: ResolveResult[] = [];
for (const implFile of implFiles) {
const methods = ctx.symbols.lookupExactAll(implFile, calledName);
const methods = ctx.model.symbols.lookupExactAll(implFile, calledName);
for (const method of methods) {
if (method.nodeId !== primaryNodeId) {
results.push({
@ -808,7 +808,7 @@ export const processCalls = async (
const importedReturnTypes = importedReturnTypesMap?.get(file.path);
const importedRawReturnTypes = importedRawReturnTypesMap?.get(file.path);
const typeEnv = buildTypeEnv(tree, language, {
symbolTable: ctx.symbols,
model: ctx.model,
parentMap,
importedBindings,
importedReturnTypes,
@ -817,7 +817,7 @@ export const processCalls = async (
extractFunctionName: provider?.methodExtractor?.extractFunctionName,
});
if (typeEnv && exportedTypeMap) {
const fileExports = collectExportedBindings(typeEnv, file.path, ctx.symbols, graph);
const fileExports = collectExportedBindings(typeEnv, file.path, ctx.model.symbols, graph);
if (fileExports) exportedTypeMap.set(file.path, fileExports);
}
if (bindingAccumulator) {
@ -1021,7 +1021,7 @@ export const processCalls = async (
description: item.accessorType,
},
});
ctx.symbols.add(file.path, item.propName, nodeId, 'Property', {
ctx.model.symbols.add(file.path, item.propName, nodeId, 'Property', {
...(propEnclosingClassId ? { ownerId: propEnclosingClassId } : {}),
...(item.declaredType ? { declaredType: item.declaredType } : {}),
});
@ -1093,8 +1093,8 @@ export const processCalls = async (
if (
isSubclassOf(ctorType, receiverTypeName, parentMap) ||
isSubclassOf(ctorType, receiverTypeName, globalParentMap) ||
(ctx.symbols.lookupClassByName(ctorType).length > 0 &&
ctx.symbols.lookupClassByName(receiverTypeName).length > 0)
(ctx.model.types.lookupClassByName(ctorType).length > 0 &&
ctx.model.types.lookupClassByName(receiverTypeName).length > 0)
) {
receiverTypeName = ctorType;
}
@ -1299,7 +1299,7 @@ export const processCalls = async (
return collectedHeritage;
};
// CALLABLE_TYPES imported from symbol-table.ts — single source of truth.
// FREE_CALLABLE_TYPES imported from symbol-table.ts — single source of truth.
const CONSTRUCTOR_TARGET_TYPES = new Set(['Constructor', 'Class', 'Struct', 'Record']);
@ -1320,10 +1320,14 @@ const filterCallableCandidates = (
} else {
const types = candidates.filter((c) => CONSTRUCTOR_TARGET_TYPES.has(c.type));
kindFiltered =
types.length > 0 ? types : candidates.filter((c) => CALLABLE_TYPES.has(c.type));
types.length > 0 ? types : candidates.filter((c) => CALL_TARGET_TYPES.has(c.type));
}
} else {
kindFiltered = candidates.filter((c) => CALLABLE_TYPES.has(c.type));
// CALL_TARGET_TYPES (not FREE_CALLABLE_TYPES) — the post-A4 filter must
// also admit Method and Constructor candidates, which are now unioned
// into the pool from `model.methods.lookupMethodByName` rather than
// `symbols.lookupCallableByName`.
kindFiltered = candidates.filter((c) => CALL_TARGET_TYPES.has(c.type));
}
if (kindFiltered.length === 0) return [];
@ -1360,7 +1364,7 @@ const countCallableCandidates = (
const typeOk =
callForm === 'constructor'
? CONSTRUCTOR_TARGET_TYPES.has(c.type)
: CALLABLE_TYPES.has(c.type);
: CALL_TARGET_TYPES.has(c.type);
if (!typeOk) continue;
// Arity filter
if (
@ -1573,11 +1577,27 @@ const resolveModuleAliasedCall = (
);
}
if (filtered.length === 0) {
// Widen to global callable index scoped to the aliased module file.
// Widen to global callable+method indexes scoped to the aliased module
// file. Function+ownerId (Python/Rust/Kotlin) is still routed to both
// indexes until Unit 5 unblocks, so dedup by nodeId.
const cacheKey = `${call.calledName}\0${moduleFile}`;
let defs = widenCache?.get(cacheKey);
if (!defs) {
defs = ctx.symbols.lookupCallableByName(call.calledName);
const rawCallable = ctx.model.symbols.lookupCallableByName(call.calledName);
const rawMethods = ctx.model.methods.lookupMethodByName(call.calledName);
const widenCombined: SymbolDefinition[] = [];
const widenSeen = new Set<string>();
for (const d of rawCallable) {
if (widenSeen.has(d.nodeId)) continue;
widenSeen.add(d.nodeId);
widenCombined.push(d);
}
for (const d of rawMethods) {
if (widenSeen.has(d.nodeId)) continue;
widenSeen.add(d.nodeId);
widenCombined.push(d);
}
defs = widenCombined;
widenCache?.set(cacheKey, defs);
}
filtered = filterCallableCandidates(defs, call.argCount, call.callForm).filter(
@ -1618,11 +1638,26 @@ const resolveMemberCallByFile = (
const typeNodeIds = new Set(typeResolved.candidates.map((d) => d.nodeId));
const typeFiles = new Set(typeResolved.candidates.map((d) => d.filePath));
const methodPool = filterCallableCandidates(
ctx.symbols.lookupCallableByName(calledName),
argCount,
callForm,
);
// A4 (plan 006, Unit 4): consult both indexes. Strictly-labeled
// Method/Constructor are disjoint, but Function+ownerId (Python/Rust/
// Kotlin) is routed into BOTH indexes by `wrappedAdd` until Unit 5
// unblocks — dedup by nodeId so overload disambiguation doesn't see
// phantom duplicates.
const rawCallablePool = ctx.model.symbols.lookupCallableByName(calledName);
const rawMethodPool = ctx.model.methods.lookupMethodByName(calledName);
const combinedPool: SymbolDefinition[] = [];
const combinedSeen = new Set<string>();
for (const def of rawCallablePool) {
if (combinedSeen.has(def.nodeId)) continue;
combinedSeen.add(def.nodeId);
combinedPool.push(def);
}
for (const def of rawMethodPool) {
if (combinedSeen.has(def.nodeId)) continue;
combinedSeen.add(def.nodeId);
combinedPool.push(def);
}
const methodPool = filterCallableCandidates(combinedPool, argCount, callForm);
const fileFiltered = methodPool.filter((c) => typeFiles.has(c.filePath));
if (fileFiltered.length === 1) {
return toResolveResult(fileFiltered[0], typeResolved.tier);
@ -1951,7 +1986,7 @@ const resolveFieldOwnership = (
const classDef = typeResolved.candidates.find((d) => CLASS_LIKE_TYPES.has(d.type));
if (!classDef) return undefined;
return ctx.symbols.lookupFieldByOwner(classDef.nodeId, fieldName) ?? undefined;
return ctx.model.fields.lookupFieldByOwner(classDef.nodeId, fieldName) ?? undefined;
};
/**
@ -1987,10 +2022,12 @@ const resolveMethodByOwner = (
const typeResolved = ctx.resolve(receiverTypeName, filePath);
if (!typeResolved) return undefined;
// MRO walking needs a language hint; compute once and reuse for every candidate.
// Unknown extension → fall back to plain direct lookup (D1-D4 still runs on miss).
// MRO walking needs a language hint so we can derive the per-language
// strategy; compute it once and reuse for every candidate. Unknown
// extension → fall back to plain direct lookup (D1-D4 still runs on miss).
const language = heritageMap ? getLanguageFromFilename(filePath) : null;
const canWalkMRO = heritageMap != null && language != null;
const mroStrategy = language != null ? getProvider(language).mroStrategy : null;
const canWalkMRO = heritageMap != null && mroStrategy != null;
// Iterate all class-like candidates tracking the first unambiguous hit.
// Zero-allocation fast path: the common case is exactly one class candidate,
@ -2014,11 +2051,11 @@ const resolveMethodByOwner = (
candidate.nodeId,
methodName,
heritageMap,
ctx.symbols,
language,
ctx.model,
mroStrategy,
argCount,
)
: ctx.symbols.lookupMethodByOwner(candidate.nodeId, methodName, argCount);
: ctx.model.methods.lookupMethodByOwner(candidate.nodeId, methodName, argCount);
if (!def) continue;
if (!firstDef) {
firstDef = def;
@ -2212,8 +2249,8 @@ export const resolveFreeCall = (
* Resolve a constructor or static call using class-scoped lookup (no fuzzy lookup).
* Used for `new User()` / `User()` calls where the calledName targets a class.
*
* Uses {@link SymbolTable.lookupClassByName} for O(1) class lookup and
* {@link SymbolTable.lookupMethodByOwner} for constructor resolution.
* Uses {@link TypeRegistry.lookupClassByName} for O(1) class lookup and
* {@link MethodRegistry.lookupMethodByOwner} for constructor resolution.
* {@link resolveCallTarget} delegates here for constructor and free-form calls
* that target a class.
*
@ -2265,7 +2302,7 @@ export const resolveStaticCall = (
// is supplied, the caller has already paid for the tiered lookup, so this
// pre-check still prevents the class-candidate filter + lookupMethodByOwner
// loop from running on obviously non-class targets.
const allClasses = ctx.symbols.lookupClassByName(className);
const allClasses = ctx.model.types.lookupClassByName(className);
if (allClasses.length === 0) return null;
// 2. Scope via ctx.resolve for import-tier information. Reuse the caller's
@ -2296,7 +2333,7 @@ export const resolveStaticCall = (
let firstDef: SymbolDefinition | undefined;
let ambiguous = false;
for (const candidate of classCandidates) {
const def = ctx.symbols.lookupMethodByOwner(candidate.nodeId, className, argCount);
const def = ctx.model.methods.lookupMethodByOwner(candidate.nodeId, className, argCount);
if (!def || def.type !== 'Constructor') continue;
if (!firstDef) {
firstDef = def;
@ -2372,138 +2409,6 @@ export const resolveStaticCall = (
return null;
};
// ---------------------------------------------------------------------------
// MRO-aware method resolution via HeritageMap (SM-9)
// ---------------------------------------------------------------------------
/**
* Per-HeritageMap cache of C3 linearization results keyed by owner nodeId.
*
* HeritageMap instances are immutable after construction, so C3 output is
* stable for the lifetime of a HeritageMap. WeakMap lets the cache auto-drain
* when the HeritageMap is garbage collected (end of ingestion run), so we
* never need to manually invalidate it.
*
* `null` is a sentinel for "C3 failed for this owner" (cyclic or inconsistent
* hierarchy) so we don't re-run the expensive linearization repeatedly.
*/
const c3LinearizationCache = new WeakMap<HeritageMap, Map<string, readonly string[] | null>>();
const getCachedC3Linearization = (
ownerNodeId: string,
heritageMap: HeritageMap,
): readonly string[] | null => {
let perHmCache = c3LinearizationCache.get(heritageMap);
if (!perHmCache) {
perHmCache = new Map();
c3LinearizationCache.set(heritageMap, perHmCache);
}
const cached = perHmCache.get(ownerNodeId);
if (cached !== undefined) return cached;
const parentMap = buildParentMapFromHeritage(ownerNodeId, heritageMap);
const result = c3Linearize(ownerNodeId, parentMap, new Map()) ?? null;
perHmCache.set(ownerNodeId, result);
return result;
};
/**
* Build a parentMap from HeritageMap for use with c3Linearize.
* Traverses the parent chain starting from startNodeId, collecting all
* parent→children relationships into a Map<string, string[]>.
*/
const buildParentMapFromHeritage = (
startNodeId: string,
heritageMap: HeritageMap,
): Map<string, string[]> => {
const parentMap = new Map<string, string[]>();
const visited = new Set<string>();
const queue = [startNodeId];
while (queue.length > 0) {
const nodeId = queue.shift()!;
if (visited.has(nodeId)) continue;
visited.add(nodeId);
const parents = heritageMap.getParents(nodeId);
if (parents.length > 0) {
parentMap.set(nodeId, parents);
for (const p of parents) {
if (!visited.has(p)) queue.push(p);
}
}
}
return parentMap;
};
/**
* Look up a method on an owner class, walking the parent chain via HeritageMap
* when the method isn't found on the direct owner.
*
* Respects the 5 per-language MRO strategies:
* - `first-wins`: BFS ancestor walk, first match wins (default)
* - `leftmost-base`: BFS ancestor walk, leftmost base in declaration order wins (C++);
* HeritageMap preserves insertion order matching source declaration,
* so BFS order is equivalent to leftmost-base semantics
* - `c3`: C3-linearized ancestor order, first match wins (Python)
* - `implements-split`: BFS ancestor walk, first match wins (Java/C#) —
* full ambiguity detection for multiple interface defaults
* is handled by computeMRO at graph level
* - `qualified-syntax`: No auto-resolution (Rust) — returns undefined
*
* Delegates to mro-processor.ts c3Linearize for C3 strategy.
*
* @internal Exported only to enable unit testing in isolation. The proper
* entry point for callers outside this module is {@link resolveMethodByOwner},
* which handles receiver-type resolution before delegating here.
*/
export const lookupMethodByOwnerWithMRO = (
ownerNodeId: string,
methodName: string,
heritageMap: HeritageMap,
symbols: SymbolTable,
language: SupportedLanguages,
argCount?: number,
): SymbolDefinition | undefined => {
// Direct lookup first (child override — no walk needed).
// argCount is threaded through so arity-differing overloads on the direct
// owner can be disambiguated before the MRO walk starts.
const direct = symbols.lookupMethodByOwner(ownerNodeId, methodName, argCount);
if (direct) return direct;
const strategy = getProvider(language).mroStrategy;
// Rust: requires qualified syntax (<Type as Trait>::method), no auto-resolution
if (strategy === 'qualified-syntax') return undefined;
// Determine ancestor walk order based on MRO strategy.
// readonly to accept the cached (frozen) c3 linearization without copying.
let ancestors: readonly string[];
if (strategy === 'c3') {
// Delegate to mro-processor.ts C3 linearization (memoized per HeritageMap
// so repeated calls for the same owner within an ingestion run reuse the
// linearization instead of rebuilding the parent map and re-running C3).
// c3Linearize returns ancestors only (excludes the owner itself),
// matching heritageMap.getAncestors() semantics.
const c3Result = getCachedC3Linearization(ownerNodeId, heritageMap);
// Fall back to BFS order if C3 fails (cyclic or inconsistent hierarchy).
// Note: BFS order may not preserve Python MRO semantics in these edge
// cases, but cyclic/inconsistent hierarchies are invalid in Python anyway.
ancestors = c3Result ?? heritageMap.getAncestors(ownerNodeId);
} else {
// first-wins, leftmost-base, implements-split: BFS order via HeritageMap
ancestors = heritageMap.getAncestors(ownerNodeId);
}
// Walk ancestors in MRO order — first match wins.
// argCount narrows overloaded ancestors the same way as the direct lookup.
for (const ancestorId of ancestors) {
const method = symbols.lookupMethodByOwner(ancestorId, methodName, argCount);
if (method) return method;
}
return undefined;
};
/**
* Create a deduplicated ACCESSES edge emitter for a single source node.
* Each (sourceId, fieldNodeId) pair is emitted at most once per source.

View file

@ -1,7 +1,7 @@
// gitnexus/src/core/ingestion/field-types.ts
import type { TypeEnvironment } from './type-env.js';
import type { SymbolTable } from './symbol-table.js';
import type { SymbolTableReader } from './model/symbol-table.js';
import { SupportedLanguages } from 'gitnexus-shared';
/**
@ -57,7 +57,7 @@ export interface FieldExtractorContext {
/** Type environment for resolution */
typeEnv: TypeEnvironment;
/** Symbol table for FQN lookups */
symbolTable: SymbolTable;
symbolTable: SymbolTableReader;
/** Current file path */
filePath: string;
/** Language ID */

View file

@ -19,47 +19,34 @@ import { ASTCache } from './ast-cache.js';
import Parser from 'tree-sitter';
import { isLanguageAvailable, loadParser, loadLanguage } from '../tree-sitter/parser-loader.js';
import { generateId } from '../../lib/utils.js';
import { getLanguageFromFilename } from 'gitnexus-shared';
import { getLanguageFromFilename, type SupportedLanguages } from 'gitnexus-shared';
import { isVerboseIngestionEnabled } from './utils/verbose.js';
import { yieldToEventLoop } from './utils/event-loop.js';
import { SupportedLanguages } from 'gitnexus-shared';
import { getProvider } from './languages/index.js';
import { getTreeSitterBufferSize } from './constants.js';
import type { ExtractedHeritage } from './workers/parse-worker.js';
import type { ResolutionContext } from './resolution-context.js';
import { TIER_CONFIDENCE } from './resolution-context.js';
import type {
ExtractedHeritage,
HeritageResolutionStrategy,
HeritageStrategyLookup,
} from './model/heritage-map.js';
import { resolveExtendsType } from './model/heritage-map.js';
import type { ResolutionContext } from './model/resolution-context.js';
import { TIER_CONFIDENCE } from './model/resolution-context.js';
/**
* Determine whether a heritage.extends capture is actually an IMPLEMENTS relationship.
* Uses the symbol table first (authoritative — Tier 1); falls back to provider-defined
* heuristics for external symbols not present in the graph:
* - interfaceNamePattern: matched against parent name (e.g., /^I[A-Z]/ for C#/Java)
* - heritageDefaultEdge: 'IMPLEMENTS' causes all unresolved parents to map to IMPLEMENTS
* - All others: default EXTENDS
* Derive the heritage-resolution strategy for a language from its
* `LanguageProvider`. This is the production wiring that `buildHeritageMap`
* and the standalone `resolveExtendsType` call site use — the model layer
* itself stays unaware of the provider registry.
*/
/** Exported for implementor-map construction (C#/Java: `extends` rows in base_list may be interfaces). */
export const resolveExtendsType = (
parentName: string,
currentFilePath: string,
ctx: ResolutionContext,
language: SupportedLanguages,
): { type: 'EXTENDS' | 'IMPLEMENTS'; idPrefix: string } => {
const resolved = ctx.resolve(parentName, currentFilePath);
if (resolved && resolved.candidates.length > 0) {
const isInterface = resolved.candidates[0].type === 'Interface';
return isInterface
? { type: 'IMPLEMENTS', idPrefix: 'Interface' }
: { type: 'EXTENDS', idPrefix: 'Class' };
}
// Unresolved symbol — fall back to provider-defined heuristics
const provider = getProvider(language);
if (provider.interfaceNamePattern?.test(parentName)) {
return { type: 'IMPLEMENTS', idPrefix: 'Interface' };
}
if (provider.heritageDefaultEdge === 'IMPLEMENTS') {
return { type: 'IMPLEMENTS', idPrefix: 'Interface' };
}
return { type: 'EXTENDS', idPrefix: 'Class' };
export const getHeritageStrategyForLanguage: HeritageStrategyLookup = (
lang: SupportedLanguages,
): HeritageResolutionStrategy => {
const provider = getProvider(lang);
return {
interfaceNamePattern: provider.interfaceNamePattern,
defaultEdge: provider.heritageDefaultEdge ?? 'EXTENDS',
};
};
/**
@ -180,7 +167,7 @@ export const processHeritage = async (
parentClassName,
file.path,
ctx,
language,
getHeritageStrategyForLanguage(language),
);
const child = resolveHeritageId(
@ -296,7 +283,7 @@ export const processHeritageFromExtracted = async (
h.parentName,
h.filePath,
ctx,
fileLanguage,
getHeritageStrategyForLanguage(fileLanguage),
);
const child = resolveHeritageId(

View file

@ -12,7 +12,11 @@ import type { ExtractedImport } from './workers/parse-worker.js';
import { getTreeSitterBufferSize } from './constants.js';
import { loadImportConfigs } from './language-config.js';
import { buildSuffixIndex } from './import-resolvers/utils.js';
import type { ResolutionContext, ModuleAliasMap } from './resolution-context.js';
import type {
ResolutionContext,
ModuleAliasMap,
NamedImportMap,
} from './model/resolution-context.js';
import type {
ImportResult,
ResolveCtx,
@ -61,30 +65,6 @@ function wireImplicitImports(
// Avoids expanding every Go package import into N individual ImportMap edges.
export type PackageMap = Map<string, Set<string>>;
// Type: Map<ImportingFilePath, Map<LocalName, {sourcePath, exportedName}>>
// Tracks which specific names a file imports from which sources (TS/Python only).
// Used to tighten Tier 2a resolution: `import { User } from './models'`
// means only `User` (not `Repo`) is visible from models.ts via this import.
// Stores both the resolved source path and the original exported name so that
// aliased imports (`import { User as U }`) can resolve U → User in the source file.
export interface NamedImportBinding {
sourcePath: string;
exportedName: string;
}
export type NamedImportMap = Map<string, Map<string, NamedImportBinding>>;
/**
* Check if a file path is directly inside a package directory identified by its suffix.
* Used by the symbol resolver for Go and C# directory-level import matching.
*/
export function isFileInPackageDir(filePath: string, dirSuffix: string): boolean {
// Prepend '/' so paths like "internal/auth/service.go" match suffix "/internal/auth/"
const normalized = '/' + filePath.replace(/\\/g, '/');
if (!normalized.includes(dirSuffix)) return false;
const afterDir = normalized.substring(normalized.indexOf(dirSuffix) + dirSuffix.length);
return !afterDir.includes('/');
}
// ImportResolutionContext is defined in ./import-resolvers/types.ts — re-exported here for consumers.
export function buildImportResolutionContext(allPaths: string[]): ImportResolutionContext {

View file

@ -9,7 +9,7 @@
* so adding a language to the enum without creating a provider is a compiler error.
*/
import type { SupportedLanguages } from 'gitnexus-shared';
import type { SupportedLanguages, MroStrategy } from 'gitnexus-shared';
import type { LanguageTypeConfig } from './type-extractors/types.js';
import type { CallRouter } from './call-routing.js';
import type { ClassExtractor } from './class-types.js';
@ -26,13 +26,10 @@ import type { NodeLabel } from 'gitnexus-shared';
export type CaptureMap = Record<string, SyntaxNode | undefined>;
// ── Strategy tag types ─────────────────────────────────────────────────────
/** MRO strategy for multiple inheritance resolution. */
export type MroStrategy =
| 'first-wins'
| 'c3'
| 'leftmost-base'
| 'implements-split'
| 'qualified-syntax';
// NOTE: `MroStrategy` is defined in `gitnexus-shared` and re-exported above
// so `core/ingestion/model/resolve.ts` can consume it without importing from
// this file (which would pull in the full language-registry dependency graph).
/** How a language handles imports — determines wildcard synthesis behavior. */
export type ImportSemantics = 'named' | 'wildcard' | 'namespace';

View file

@ -0,0 +1,53 @@
/**
* Field Registry
*
* Owner-scoped field/property index extracted from SymbolTable.
* Stores Property symbols keyed by `ownerNodeId\0fieldName` for O(1) lookup.
*/
import type { SymbolDefinition } from './symbol-table.js';
// ---------------------------------------------------------------------------
// Public read-only interface
// ---------------------------------------------------------------------------
export interface FieldRegistry {
/** Look up a field/property by its owning class nodeId and field name. */
lookupFieldByOwner(ownerNodeId: string, fieldName: string): SymbolDefinition | undefined;
}
// ---------------------------------------------------------------------------
// Mutable interface (used internally by SymbolTable.add / clear)
// ---------------------------------------------------------------------------
export interface MutableFieldRegistry extends FieldRegistry {
/** Register a field/property under its owner. */
register(ownerNodeId: string, fieldName: string, def: SymbolDefinition): void;
/** Clear all entries. */
clear(): void;
}
// ---------------------------------------------------------------------------
// Factory
// ---------------------------------------------------------------------------
export const createFieldRegistry = (): MutableFieldRegistry => {
const fieldByOwner = new Map<string, SymbolDefinition>();
const lookupFieldByOwner = (
ownerNodeId: string,
fieldName: string,
): SymbolDefinition | undefined => {
return fieldByOwner.get(`${ownerNodeId}\0${fieldName}`);
};
const register = (ownerNodeId: string, fieldName: string, def: SymbolDefinition): void => {
fieldByOwner.set(`${ownerNodeId}\0${fieldName}`, def);
};
const clear = (): void => {
fieldByOwner.clear();
};
return { lookupFieldByOwner, register, clear };
};

View file

@ -7,16 +7,78 @@
* resolves type names to nodeIds via `lookupClassByName`, NOT graph-edge
* queries.
*
* Combines two previously separate concerns:
* Combines two concerns:
* 1. **Parent/ancestor lookup** (MRO-aware method resolution)
* 2. **Implementor lookup** (interface dispatch — which files contain
* classes implementing a given interface)
*/
import type { ExtractedHeritage } from './workers/parse-worker.js';
import type { ResolutionContext } from './resolution-context.js';
import { getLanguageFromFilename } from 'gitnexus-shared';
import { resolveExtendsType } from './heritage-processor.js';
import { getLanguageFromFilename, type SupportedLanguages } from 'gitnexus-shared';
// ---------------------------------------------------------------------------
// ExtractedHeritage — the shape produced by the parse worker / heritage
// extractor. Defined here so `model/` has no upward imports; consumers
// import this type from the model module.
// ---------------------------------------------------------------------------
export interface ExtractedHeritage {
filePath: string;
className: string;
parentName: string;
/** 'extends' | 'implements' | 'trait-impl' | 'include' | 'extend' | 'prepend' */
kind: string;
}
// ---------------------------------------------------------------------------
// Heritage resolution strategy (the per-language knobs that drive
// `resolveExtendsType` below). Pulled out as an explicit strategy object so
// the model layer depends on a plain data shape rather than on the language
// provider registry.
// ---------------------------------------------------------------------------
export interface HeritageResolutionStrategy {
/** If set and the parent name matches, force IMPLEMENTS even when the
* symbol is unresolved (e.g. `/^I[A-Z]/` for C# / Java). */
readonly interfaceNamePattern?: RegExp;
/** Fallback edge for unresolved parents when the name pattern doesn't
* match (Swift uses 'IMPLEMENTS' for protocol conformance). */
readonly defaultEdge: 'EXTENDS' | 'IMPLEMENTS';
}
/** Callback used by `buildHeritageMap` to look up the resolution strategy
* for a given language. Injected by callers so the model module doesn't
* depend on `../languages/index.js`. */
export type HeritageStrategyLookup = (lang: SupportedLanguages) => HeritageResolutionStrategy;
/**
* Determine whether a heritage.extends capture is actually an IMPLEMENTS
* relationship. Consults the symbol table first (authoritative — Tier 1 /
* Tier 2 resolution); falls back to the injected {@link HeritageResolutionStrategy}
* heuristics for external symbols not present in the graph.
*/
export const resolveExtendsType = (
parentName: string,
currentFilePath: string,
ctx: ResolutionContext,
strategy: HeritageResolutionStrategy,
): { type: 'EXTENDS' | 'IMPLEMENTS'; idPrefix: string } => {
const resolved = ctx.resolve(parentName, currentFilePath);
if (resolved && resolved.candidates.length > 0) {
const isInterface = resolved.candidates[0].type === 'Interface';
return isInterface
? { type: 'IMPLEMENTS', idPrefix: 'Interface' }
: { type: 'EXTENDS', idPrefix: 'Class' };
}
// Unresolved symbol — fall back to strategy heuristics.
if (strategy.interfaceNamePattern?.test(parentName)) {
return { type: 'IMPLEMENTS', idPrefix: 'Interface' };
}
if (strategy.defaultEdge === 'IMPLEMENTS') {
return { type: 'IMPLEMENTS', idPrefix: 'Interface' };
}
return { type: 'EXTENDS', idPrefix: 'Class' };
};
// ---------------------------------------------------------------------------
// Public types
@ -41,6 +103,12 @@ export interface HeritageMap {
/** Shared empty set returned when no implementors are found. */
const EMPTY_SET: ReadonlySet<string> = new Set();
/** Default strategy used when `buildHeritageMap` is called without an
* explicit `getHeritageStrategy` callback — the fallback for a language
* whose provider sets no interface-name pattern and no non-default
* `heritageDefaultEdge`. */
const DEFAULT_HERITAGE_STRATEGY: HeritageResolutionStrategy = { defaultEdge: 'EXTENDS' };
// ---------------------------------------------------------------------------
// Builder
// ---------------------------------------------------------------------------
@ -49,18 +117,18 @@ const EMPTY_SET: ReadonlySet<string> = new Set();
* Build a HeritageMap from accumulated ExtractedHeritage records.
*
* Resolves class/interface/struct/trait names to nodeIds via
* `ctx.symbols.lookupClassByName`. When a name resolves to multiple
* `ctx.model.types.lookupClassByName`. When a name resolves to multiple
* candidates, all are recorded (partial-class / cross-file scenario).
* Unresolvable names are silently skipped — a missing parent is better
* than a wrong edge.
*
* Also builds the implementor index (interface name → implementing file
* paths) that was previously maintained by `buildImplementorMap` in
* call-processor.ts.
* paths) used by interface-dispatch in call resolution.
*/
export const buildHeritageMap = (
heritage: readonly ExtractedHeritage[],
ctx: ResolutionContext,
getHeritageStrategy?: HeritageStrategyLookup,
): HeritageMap => {
// childNodeId → Set<parentNodeId> (Set to deduplicate cross-chunk duplicates)
const directParents = new Map<string, Set<string>>();
@ -70,8 +138,8 @@ export const buildHeritageMap = (
for (const h of heritage) {
// ── Parent lookup (nodeId-based) ────────────────────────────────
const childDefs = ctx.symbols.lookupClassByName(h.className);
const parentDefs = ctx.symbols.lookupClassByName(h.parentName);
const childDefs = ctx.model.types.lookupClassByName(h.className);
const parentDefs = ctx.model.types.lookupClassByName(h.parentName);
if (childDefs.length > 0 && parentDefs.length > 0) {
for (const child of childDefs) {
@ -99,16 +167,15 @@ export const buildHeritageMap = (
//
// Known limitation: `getImplementorFiles` is keyed by interface **name**
// (string), so two interfaces with the same unqualified name in different
// packages (e.g. `pkgA.IRepository` vs `pkgB.IRepository`) collide. This
// matches the behavior of the prior standalone `ImplementorMap` and is
// not a regression introduced by this consolidation.
// packages (e.g. `pkgA.IRepository` vs `pkgB.IRepository`) collide.
let isImpl = false;
if (h.kind === 'implements') {
isImpl = true;
} else if (h.kind === 'extends') {
const lang = getLanguageFromFilename(h.filePath);
if (lang) {
const { type } = resolveExtendsType(h.parentName, h.filePath, ctx, lang);
const strategy = getHeritageStrategy?.(lang) ?? DEFAULT_HERITAGE_STRATEGY;
const { type } = resolveExtendsType(h.parentName, h.filePath, ctx, strategy);
isImpl = type === 'IMPLEMENTS';
}
}

View file

@ -0,0 +1,88 @@
/**
* Semantic Model — public module surface.
*
* Barrel re-export for the `model/` module. Consumers outside `model/`
* should import from this file rather than reaching into individual
* registry files.
*
* The model is owner-scoped type/method/field knowledge layered above
* `SymbolTable`. File-indexed and name-keyed callable lookups stay in
* `SymbolTable` by design.
*/
// Unified semantic model (factory + interfaces). SemanticModel is the
// top-level container and owns the file/callable SymbolTable as a
// nested `symbols` field.
export {
type SemanticModel,
type MutableSemanticModel,
createSemanticModel,
} from './semantic-model.js';
// SymbolTable is exclusively owned by SemanticModel. Re-exported here
// for the rare caller that needs the file/callable interface in
// isolation (e.g. tests).
export {
type SymbolTableReader,
type SymbolTableWriter,
createSymbolTable,
} from './symbol-table.js';
// Type registry (classes, structs, interfaces, enums, records, impls)
export {
type TypeRegistry,
type MutableTypeRegistry,
createTypeRegistry,
} from './type-registry.js';
// Method registry (owner-scoped methods with arity-aware overload lookup)
export {
type MethodRegistry,
type MutableMethodRegistry,
createMethodRegistry,
} from './method-registry.js';
// Field registry (owner-scoped fields/properties)
export {
type FieldRegistry,
type MutableFieldRegistry,
createFieldRegistry,
} from './field-registry.js';
// MRO-aware method resolution (C3, first-wins, leftmost-base, implements-split,
// qualified-syntax). Pure function that depends only on the model + HeritageMap.
// `MroStrategy` itself lives in `gitnexus-shared`; re-exported here for
// consumers that reach model behavior through the barrel.
export { lookupMethodByOwnerWithMRO } from './resolve.js';
// Named-import types and package-dir helper. Re-exported so barrel
// consumers don't need to reach into a specific model file.
export {
type NamedImportBinding,
type NamedImportMap,
isFileInPackageDir,
} from './resolution-context.js';
// Heritage types. `buildHeritageMap` + `resolveExtendsType` are exported
// directly from `heritage-map.ts` and are not re-surfaced here to keep
// the barrel narrow.
export {
type ExtractedHeritage,
type HeritageResolutionStrategy,
type HeritageStrategyLookup,
} from './heritage-map.js';
// Behavior-grouped dispatch table for SymbolTable.add() routing.
// See registration-table.ts module JSDoc for the behavior group taxonomy
// and "how to add a new NodeLabel" checklist.
// NOTE: createRegistrationTable, RegistrationHook, and RegistrationTableDeps
// are deliberately NOT re-exported here — they are factory internals of
// SemanticModel and should only be imported directly from registration-table.js
// by semantic-model.ts and the registration-table.test.ts file.
export {
CALLABLE_ONLY_LABELS,
INERT_LABELS,
DISPATCH_LABELS,
ALL_NODE_LABELS,
type LabelBehavior,
} from './registration-table.js';

View file

@ -0,0 +1,204 @@
/**
* Method Registry
*
* Owner-scoped method index extracted from SymbolTable.
* Stores Method/Constructor/Function-with-ownerId symbols keyed by
* `ownerNodeId\0methodName` for O(1) lookup. Supports overloads
* (array values) and arity-based filtering.
*/
import type { SymbolDefinition } from './symbol-table.js';
// ---------------------------------------------------------------------------
// Public read-only interface
// ---------------------------------------------------------------------------
export interface MethodRegistry {
/**
* Look up a method by owner class + name, optionally filtered by arity.
*
* When `argCount` is provided, overloads whose parameter count doesn't
* accommodate the call's argument count are filtered out before the
* returnType dedup runs. This lets D0 (`resolveMemberCall`) disambiguate
* arity-differing overloads (e.g. C++ `greet()` vs `greet(string)`) that
* would otherwise collide on the shared `ownerId + methodName` key.
*
* Same-arity, same-returnType overloads (e.g. `save(int)` vs `save(String)`,
* both returning `void`) still collapse to the first match — callers must
* gate D0 on overload concern before invoking this function for that case.
*/
lookupMethodByOwner(
ownerNodeId: string,
methodName: string,
argCount?: number,
): SymbolDefinition | undefined;
/**
* Flat-by-name lookup across all owners. Returns every method registered
* with the given unqualified name, in registration order, accumulated
* across owners and overloads.
*
* Required by Tier 3 global resolution: Method and Constructor do not
* land in `SymbolTable.callableByName`, so Tier 3 reaches them through
* this flat index instead. Returns `[]` on miss — never `undefined` —
* so callers can concatenate without null checks.
*
* Reference identity: each returned def is the same object reference
* stored under `lookupMethodByOwner`, so a method symbol occupies one
* allocation reachable from two indexes.
*/
lookupMethodByName(name: string): readonly SymbolDefinition[];
/**
* True iff at least one registered def has `type === 'Function'` — i.e.,
* a Python/Rust/Kotlin class method emitted by the worker as
* `Function + ownerId` rather than as a strict `Method` label. Such defs
* are double-indexed: they land in `SymbolTable.callableByName` (via the
* Function callable-index gate) AND in this registry (via the
* dispatch-key normalization in `wrappedAdd`). Tier 3 resolution must
* then dedup the two indexes by nodeId.
*
* When this flag is false, the callable and method indexes are
* guaranteed disjoint and Tier 3 can skip the dedup pass entirely.
* The flag is monotonic (false→true once, never back) for the lifetime
* of the MethodRegistry.
*/
readonly hasFunctionMethods: boolean;
}
// ---------------------------------------------------------------------------
// Mutable interface (used internally by SymbolTable.add / clear)
// ---------------------------------------------------------------------------
export interface MutableMethodRegistry extends MethodRegistry {
/** Register a method under its owner. Supports multiple overloads. */
register(ownerNodeId: string, methodName: string, def: SymbolDefinition): void;
/** Clear all entries. */
clear(): void;
}
// ---------------------------------------------------------------------------
// Factory
// ---------------------------------------------------------------------------
export const createMethodRegistry = (): MutableMethodRegistry => {
const methodByOwner = new Map<string, SymbolDefinition[]>();
// Secondary flat-by-name index. Values are the SAME SymbolDefinition
// references stored under `methodByOwner` — no copy, just a second key.
// Populated in lockstep by `register()` and emptied by `clear()`.
const methodsByName = new Map<string, SymbolDefinition[]>();
const EMPTY: readonly SymbolDefinition[] = Object.freeze([]);
// Set once when a Function+ownerId def lands here, powers the Tier 3
// dedup fast-path. Monotonic: never unset except on `clear()`.
let hasFunctionMethodsFlag = false;
const lookupMethodByOwner = (
ownerNodeId: string,
methodName: string,
argCount?: number,
): SymbolDefinition | undefined => {
const defs = methodByOwner.get(`${ownerNodeId}\0${methodName}`);
if (!defs || defs.length === 0) return undefined;
// Arity narrowing: when an argCount is provided and there are multiple
// overloads, keep only those whose parameterCount can accommodate the
// call. This resolves arity-differing overloads (e.g. C++ `greet()` vs
// `greet(string)`) that share the same `ownerId + methodName` key.
//
// Candidates with `parameterCount === undefined` (extractor didn't
// populate the count — typically variadic or unknown) are retained
// conservatively so that legitimate variadic matches still resolve.
//
// Streaming loop avoids allocating a filtered array on the common
// "arity selects 0 or 1 match" path. We scan once, count arity
// matches, and only materialize a narrowed array if at least one
// match was found and at least one non-match exists. If arity rules
// out every candidate, fall back to the unfiltered set so the
// caller's fuzzy path still has something to work with.
let pool: readonly SymbolDefinition[] = defs;
if (argCount !== undefined && defs.length > 1) {
let matchedCount = 0;
let rejectedCount = 0;
for (const d of defs) {
if (d.parameterCount === undefined) {
matchedCount++;
continue;
}
const min = d.requiredParameterCount ?? d.parameterCount;
if (argCount >= min && argCount <= d.parameterCount) matchedCount++;
else rejectedCount++;
}
// Only narrow when the filter actually discriminates: at least one
// match AND at least one rejection. Pure-match and pure-reject
// paths both keep the unfiltered pool (the latter because fallback
// semantics demand it).
if (matchedCount > 0 && rejectedCount > 0) {
const arityMatched: SymbolDefinition[] = [];
for (const d of defs) {
if (d.parameterCount === undefined) {
arityMatched.push(d);
continue;
}
const min = d.requiredParameterCount ?? d.parameterCount;
if (argCount >= min && argCount <= d.parameterCount) arityMatched.push(d);
}
pool = arityMatched;
}
}
if (pool.length === 1) return pool[0];
// Multiple overloads after arity narrowing: return first if all share
// the same defined returnType (safe for chain resolution), undefined if
// return types differ (truly ambiguous — can't determine which overload).
const firstReturnType = pool[0].returnType;
if (firstReturnType === undefined) return undefined;
for (let i = 1; i < pool.length; i++) {
if (pool[i].returnType !== firstReturnType) return undefined;
}
return pool[0];
};
const lookupMethodByName = (name: string): readonly SymbolDefinition[] => {
return methodsByName.get(name) ?? EMPTY;
};
const register = (ownerNodeId: string, methodName: string, def: SymbolDefinition): void => {
const key = `${ownerNodeId}\0${methodName}`;
const existing = methodByOwner.get(key);
if (existing) {
existing.push(def);
} else {
methodByOwner.set(key, [def]);
}
const byName = methodsByName.get(methodName);
if (byName) {
byName.push(def);
} else {
methodsByName.set(methodName, [def]);
}
// A `Function`-typed def reaching MethodRegistry means the worker
// emitted a Python/Rust/Kotlin class method as `Function + ownerId`.
// It was already written into `SymbolTable.callableByName` by the
// upstream Function callable-index gate, so the two indexes are no
// longer disjoint for this registry's lifetime — Tier 3 must dedup.
if (!hasFunctionMethodsFlag && def.type === 'Function') {
hasFunctionMethodsFlag = true;
}
};
const clear = (): void => {
methodByOwner.clear();
methodsByName.clear();
hasFunctionMethodsFlag = false;
};
return {
lookupMethodByOwner,
lookupMethodByName,
register,
clear,
get hasFunctionMethods() {
return hasFunctionMethodsFlag;
},
};
};

View file

@ -0,0 +1,333 @@
/**
* Registration Dispatch Table
*
* Behavior-grouped O(1) dispatch table for routing `SymbolTable.add()`
* registrations into the semantic registries. Replaces the cascading
* `if/else` ladder in `symbol-table.ts` with a `Map<NodeLabel, RoutingDecision>`
* whose entries point to closure-captured hooks.
*
* ## Ownership diagram
*
* SemanticModel
* ├── types (TypeRegistry) ← classLikeHook / implHook write here
* ├── methods (MethodRegistry) ← methodHook writes here
* ├── fields (FieldRegistry) ← propertyHook writes here
* └── symbols (SymbolTable) ← owns fileIndex + callableByName,
* calls dispatch() in add()
*
* ## Behavior groups (5 hooks, 13 table entries)
*
* | Group | NodeLabel values | Hook | Skip callable? |
* |---------------|---------------------------------------------------|--------------|----------------|
* | class-like | Class, Struct, Interface, Enum, Record, Trait | classLikeHook | no |
* | method-like | Method, Constructor | methodHook | no |
* | property | Property | propertyHook | YES |
* | impl-block | Impl | implHook | no |
* | callable-only | Function, Macro, Delegate | (no entry) | no |
*
* Every other `NodeLabel` is "inert" — reached by `fileIndex` only. No
* specialized registry, no callable index append.
*
* ## How to add a new NodeLabel
*
* 1. Add the variant to the `NodeLabel` union in `gitnexus-shared/src/graph/types.ts`.
* 2. Decide which behavior group it belongs to by asking "which lookups must
* return this symbol?" (not "what language feature is it?"). A new Swift
* `Extension` is class-like if you want owner-scoped method lookup on it;
* a new Kotlin `Object` is class-like for the same reason.
* 3. Either:
* - Add a table entry here pointing at one of the existing hooks, OR
* - Add it to `CALLABLE_ONLY_LABELS` if it is a free callable, OR
* - Add it to `INERT_LABELS` if it's metadata-only (File, Folder, Decorator,
* etc.) — never queried via owner/class lookups.
* 4. If none of the above fit — the new kind needs a brand-new registry —
* design the registry first in `model/`, then add a new hook closure
* and table entries. Update `DISPATCH_LABELS` / the exhaustiveness guard
* accordingly.
*
* The runtime exhaustiveness guard in `symbol-table.ts` will warn if a
* `NodeLabel` is missing from all three sets.
*/
import type { NodeLabel } from 'gitnexus-shared';
import type { SymbolDefinition, ClassLikeLabel, FreeCallableLabel } from './symbol-table.js';
import { FREE_CALLABLE_TYPES } from './symbol-table.js';
import type { MutableTypeRegistry } from './type-registry.js';
import type { MutableMethodRegistry } from './method-registry.js';
import type { MutableFieldRegistry } from './field-registry.js';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
/**
* Registration hook — a pure side-effectful function closed over a
* specific registry. Performs the specialized registry write into the
* appropriate owner-scoped registry for one NodeLabel.
*
* Closure capture is the isolation mechanism: `propertyHook` literally
* cannot call `types.registerClass` because its closure does not hold
* a reference to `types`. This is the runtime half of the principle of
* least authority — the compile-time half is enforced by TypeScript.
*
* The callable-index gate lives inside `SymbolTable.add()` via the
* `FREE_CALLABLE_TYPES` allowlist — the dispatch table does not
* participate in that decision.
*/
export type RegistrationHook = (name: string, def: SymbolDefinition) => void;
/**
* Dependencies required to build the dispatch table. Matches the shape
* that `createSemanticModel()` passes into `createRegistrationTable()`.
*/
export interface RegistrationTableDeps {
readonly types: MutableTypeRegistry;
readonly methods: MutableMethodRegistry;
readonly fields: MutableFieldRegistry;
}
// ---------------------------------------------------------------------------
// Single source of truth: NodeLabel → behavior category
// ---------------------------------------------------------------------------
/**
* Behavior category for a NodeLabel during ingestion. Determines which
* registry (if any) receives the symbol write during `SymbolTable.add()`:
*
* - `dispatch` — owner-scoped registry write via the dispatch table
* (Class/Struct/Interface/Enum/Record/Trait → types.registerClass,
* Method/Constructor → methods.register,
* Property → fields.register,
* Impl → types.registerImpl)
* - `callable-only` — no specialized registry; symbol appears in
* `callableByName` via `SymbolTable.add()`'s
* FREE_CALLABLE_TYPES gate (Function/Macro/Delegate)
* - `inert` — no registry, no callable index; file-index only
* (metadata / structural nodes like Project, Module,
* Import, Decorator, etc.)
*
* `Function` has a twist: `Function`-with-`ownerId` (Python `def` in a
* class body, Rust trait method, Kotlin companion method) is pre-normalized
* to `Method` in `createSemanticModel`'s `wrappedAdd` before dispatch lookup,
* so only free functions actually flow through the callable-only path.
*/
export type LabelBehavior = 'dispatch' | 'callable-only' | 'inert';
/**
* **Single source of truth** for NodeLabel classification. Every NodeLabel
* has exactly one behavior category — enforced at compile time by the
* `as const satisfies Record<NodeLabel, LabelBehavior>` combo:
*
* - **Completeness** — `Record<NodeLabel, LabelBehavior>` requires every
* NodeLabel to be a key. Missing a label fails to compile with
* "Property 'X' is missing in type ..." naming the drifted label.
* - **No extras** — `satisfies` performs excess-property checking on
* object literals, so a non-NodeLabel string key fails to compile.
* - **No duplicates** — object keys are unique by construction. A label
* cannot be classified into two categories by accident.
* - **Valid values** — `LabelBehavior` is a narrow union, so a typo in
* the category name fails to compile.
*
* Adding a new NodeLabel to `gitnexus-shared`: TypeScript will flag this
* file as incomplete. Add the new label with its behavior category and
* the three `*_LABELS` Sets + `ALL_NODE_LABELS` array below are derived
* automatically — no separate list to update, no runtime drift detection
* needed.
*
* NOTE: `Type` and `CodeElement` are inert wrappers for language features
* that don't yet have a dedicated registry (typedefs, synthesized dynamic
* calls). If future work needs owner-scoped lookup for them, change their
* category to `'dispatch'` and add a hook in `createRegistrationTable`.
* Do not special-case them inside `SymbolTable.add()`.
*/
const LABEL_BEHAVIOR = {
// dispatch — owner-scoped registry writes
Class: 'dispatch',
Struct: 'dispatch',
Interface: 'dispatch',
Enum: 'dispatch',
Record: 'dispatch',
Trait: 'dispatch',
Method: 'dispatch',
Constructor: 'dispatch',
Property: 'dispatch',
Impl: 'dispatch',
// callable-only — file index + callableByName, no owner scope
Function: 'callable-only',
Macro: 'callable-only',
Delegate: 'callable-only',
// inert — file index only
Project: 'inert',
Package: 'inert',
Module: 'inert',
Folder: 'inert',
File: 'inert',
Variable: 'inert',
Decorator: 'inert',
Import: 'inert',
Type: 'inert',
CodeElement: 'inert',
Community: 'inert',
Process: 'inert',
Typedef: 'inert',
Union: 'inert',
Namespace: 'inert',
TypeAlias: 'inert',
Const: 'inert',
Static: 'inert',
Annotation: 'inert',
Template: 'inert',
Section: 'inert',
Route: 'inert',
Tool: 'inert',
} as const satisfies Record<NodeLabel, LabelBehavior> &
// Cross-invariant 1 — every class-like label (participates in
// qualifiedName fallback in `SymbolTable.add()`) MUST be classified as
// 'dispatch'. Adding a label to `CLASS_TYPES_TUPLE` without classifying
// it as 'dispatch' fails with a type error naming the drifted label.
Record<ClassLikeLabel, 'dispatch'> &
// Cross-invariant 2 — every free-callable label (gate in
// `SymbolTable.add()` via `FREE_CALLABLE_TYPES`) MUST be classified as
// 'callable-only'. Adding a label to `FREE_CALLABLE_TUPLE` without
// classifying it as 'callable-only' fails with a type error naming the
// drifted label.
Record<FreeCallableLabel, 'callable-only'>;
// ---------------------------------------------------------------------------
// Derived runtime collections — all keyed off LABEL_BEHAVIOR
// ---------------------------------------------------------------------------
/**
* All known NodeLabels, derived from the keys of `LABEL_BEHAVIOR`. The
* `satisfies Record<NodeLabel, LabelBehavior>` bijection above proves
* that `Object.keys(LABEL_BEHAVIOR)` is exactly the NodeLabel set —
* the cast to `NodeLabel[]` is sound, not a type-system bypass.
*
* Consumers (e.g., the semantic-model barrel re-export for tests) can
* rely on this list being complete by construction. No runtime drift
* check is needed or possible — the type system is the proof.
*/
export const ALL_NODE_LABELS: readonly NodeLabel[] = Object.keys(LABEL_BEHAVIOR) as NodeLabel[];
const labelsWithBehavior = (behavior: LabelBehavior): NodeLabel[] =>
ALL_NODE_LABELS.filter((label) => LABEL_BEHAVIOR[label] === behavior);
/**
* NodeLabel values that are free callables — appear in `callableByName`
* but have no owner-scoped specialized registry. Alias of
* {@link FREE_CALLABLE_TYPES} exported here for taxonomy-test use. The
* compile-time cross-invariant on `LABEL_BEHAVIOR` above guarantees the
* alias and the LABEL_BEHAVIOR `callable-only` classification cannot
* drift.
*/
export const CALLABLE_ONLY_LABELS: ReadonlySet<NodeLabel> = FREE_CALLABLE_TYPES;
/**
* NodeLabel values that touch only the file index — no specialized
* registry, no callable index.
*/
export const INERT_LABELS: ReadonlySet<NodeLabel> = new Set(labelsWithBehavior('inert'));
/**
* NodeLabel values that have a dispatch table entry. `createRegistrationTable`
* below must provide a hook for exactly this set — the test file's behavior-
* group tests and the integration tests pin the hook↔label correspondence.
*/
export const DISPATCH_LABELS: ReadonlySet<NodeLabel> = new Set(labelsWithBehavior('dispatch'));
/**
* Type-level extraction of every label classified as `'dispatch'` in
* {@link LABEL_BEHAVIOR}. Used by {@link createRegistrationTable} as the
* key set of its internal object literal, so the `satisfies
* Record<DispatchLabel, RegistrationHook>` check fails at build time if
* a dispatch-classified label is missing a hook, or a hook is wired to
* a non-dispatch label. This closes the last compile-time gap between
* `LABEL_BEHAVIOR` and the dispatch table.
*/
type DispatchLabel = {
[K in NodeLabel]: (typeof LABEL_BEHAVIOR)[K] extends 'dispatch' ? K : never;
}[NodeLabel];
// ---------------------------------------------------------------------------
// Factory
// ---------------------------------------------------------------------------
/**
* Build the dispatch table. Must be called once per `createSymbolTable`
* invocation so each hook closes over that SymbolTable's injected
* registries. Reusing a single module-level instance would cause hooks
* to write into the wrong SemanticModel.
*/
export const createRegistrationTable = (
deps: RegistrationTableDeps,
): Map<NodeLabel, RegistrationHook> => {
const { types, methods, fields } = deps;
// Hook 1: class-like — Class, Struct, Interface, Enum, Record, Trait.
// Shared reference — six table entries point at this one closure.
const classLikeHook: RegistrationHook = (name, def) => {
const qualifiedKey = def.qualifiedName ?? name;
types.registerClass(name, qualifiedKey, def);
};
// Hook 2: method-like — Method, Constructor. Silently skipped if the
// caller did not provide an ownerId (Property without ownerId is
// treated the same way).
const methodHook: RegistrationHook = (name, def) => {
if (def.ownerId) {
methods.register(def.ownerId, name, def);
}
};
// Hook 3: property — Property. Silently skipped without ownerId.
// Property is not in `FREE_CALLABLE_TYPES`, so `SymbolTable.add()` already
// excludes it from `callableByName`; common property names like
// `id` / `name` / `type` never pollute the callable index.
const propertyHook: RegistrationHook = (name, def) => {
if (def.ownerId) {
fields.register(def.ownerId, name, def);
}
};
// Hook 4: impl-block — Rust `impl` blocks. Kept separate from classLikeHook
// because heritage resolution must not treat Impls as class candidates
// (an Impl is not a parent type, it's an ancillary dispatch table).
const implHook: RegistrationHook = (name, def) => {
types.registerImpl(name, def);
};
// Single source of truth for the label → hook mapping. The
// `satisfies Record<DispatchLabel, RegistrationHook>` intersection
// fails at build time if (a) any label classified as 'dispatch' in
// `LABEL_BEHAVIOR` is missing here, or (b) any key here is not
// classified as 'dispatch'. This is the compile-time twin of the
// runtime taxonomy — no drift possible.
const dispatchByLabel = {
// class-like — six labels share the single `classLikeHook` closure,
// kept in lockstep with `CLASS_TYPES_TUPLE` via the
// `Record<ClassLikeLabel, 'dispatch'>` cross-invariant on
// `LABEL_BEHAVIOR`.
Class: classLikeHook,
Struct: classLikeHook,
Interface: classLikeHook,
Enum: classLikeHook,
Record: classLikeHook,
Trait: classLikeHook,
// method-like — routed via dispatch-key normalization in
// `wrappedAdd` so Function+ownerId also reaches `methodHook`.
Method: methodHook,
Constructor: methodHook,
// property — callable-index exclusion is enforced by
// `SymbolTable.add()` (Property is not in `FREE_CALLABLE_TYPES`).
Property: propertyHook,
// impl-block — Rust `impl` blocks. Separate from classLikeHook because
// heritage resolution must not treat Impls as class candidates.
Impl: implHook,
} as const satisfies Record<DispatchLabel, RegistrationHook>;
return new Map<NodeLabel, RegistrationHook>(
Object.entries(dispatchByLabel) as [NodeLabel, RegistrationHook][],
);
};

View file

@ -1,9 +1,7 @@
/**
* Resolution Context
*
* Single implementation of tiered name resolution. Replaces the duplicated
* tier-selection logic previously split between symbol-resolver.ts and
* call-processor.ts.
* Single implementation of tiered name resolution.
*
* Resolution tiers (highest confidence first):
* 1. Same file (lookupExactAll — authoritative)
@ -20,11 +18,112 @@
* (three O(1) index lookups with a narrow, type-specific result set).
*/
import type { SymbolTable, SymbolDefinition } from './symbol-table.js';
import { createSymbolTable } from './symbol-table.js';
import type { NamedImportMap } from './import-processor.js';
import { isFileInPackageDir } from './import-processor.js';
import { walkBindingChain } from './named-binding-processor.js';
import type { SymbolDefinition, SymbolTableReader } from './symbol-table.js';
import type { MutableSemanticModel } from './semantic-model.js';
import { createSemanticModel } from './semantic-model.js';
// ---------------------------------------------------------------------------
// Named-import types — describe how a file imports specific names from a
// source file. Consumed by the Tier 2a-named binding-chain walker below.
// ---------------------------------------------------------------------------
/**
* A single named binding in a source file (e.g. `import { User as U }`).
* Stores both the resolved source path and the original exported name so
* that aliased imports can resolve U → User in the source file.
*/
export interface NamedImportBinding {
sourcePath: string;
exportedName: string;
}
/**
* Map<ImportingFilePath, Map<LocalName, NamedImportBinding>>.
*
* Tracks which specific names a file imports from which sources (TS / Python
* / Rust / Java-static / ...). Used to tighten Tier 2a resolution:
* `import { User } from './models'` means only `User` (not `Repo`) is
* visible from models.ts via this import.
*/
export type NamedImportMap = Map<string, Map<string, NamedImportBinding>>;
/**
* Check if a file path is directly inside a package directory identified by
* its suffix. Used by Tier 2b package-scoped resolution (Go / C#).
*/
export function isFileInPackageDir(filePath: string, dirSuffix: string): boolean {
// Prepend '/' so paths like "internal/auth/service.go" match suffix "/internal/auth/"
const normalized = '/' + filePath.replace(/\\/g, '/');
if (!normalized.includes(dirSuffix)) return false;
const afterDir = normalized.substring(normalized.indexOf(dirSuffix) + dirSuffix.length);
return !afterDir.includes('/');
}
/** Maximum re-export hops walkBindingChain will follow before giving up.
* A hard cap is needed to defend against pathological cycles that slip
* past the `visited` Set (e.g. a binding chain whose key is equal by
* string value but visits distinct modules). Five hops covers the
* common TypeScript monorepo pattern (component → pkg/index →
* packages/index → root/index → types/index). Chains longer than this
* fall through to Tier 2a-import / Tier 2b / Tier 3 resolution, which
* is a silent false-negative that the caller may or may not recover
* from. If a real repo hits this limit, raise it — there is no
* correctness reason to keep it at exactly 5. */
const MAX_BINDING_CHAIN_DEPTH = 5;
/**
* Walk a named-binding re-export chain through NamedImportMap.
*
* When file A imports { User } from B, and B re-exports { User } from C,
* the NamedImportMap for A points to B, but B has no User definition.
* This function follows the chain: A → B → C until a definition is found.
*
* Returns the definitions found at the end of the chain, or null if the
* chain breaks (missing binding, circular reference, or
* {@link MAX_BINDING_CHAIN_DEPTH} exceeded). Internal to
* resolution-context — not exported from the model barrel.
*/
function walkBindingChain(
name: string,
currentFilePath: string,
symbolTable: SymbolTableReader,
namedImportMap: NamedImportMap,
): readonly SymbolDefinition[] | null {
// Fast exit: most files have no named imports at all. Skip the Set
// allocation + loop entry on the common empty-binding path so resolve()
// stays allocation-free for the typical call site.
const firstBindings = namedImportMap.get(currentFilePath);
if (!firstBindings) return null;
const firstBinding = firstBindings.get(name);
if (!firstBinding) return null;
let lookupFile = currentFilePath;
let lookupName = name;
const visited = new Set<string>();
for (let depth = 0; depth < MAX_BINDING_CHAIN_DEPTH; depth++) {
const bindings = depth === 0 ? firstBindings : namedImportMap.get(lookupFile);
if (!bindings) return null;
const binding = depth === 0 ? firstBinding : bindings.get(lookupName);
if (!binding) return null;
const key = `${binding.sourcePath}:${binding.exportedName}`;
if (visited.has(key)) return null; // circular
visited.add(key);
const targetName = binding.exportedName;
const resolvedDefs = symbolTable.lookupExactAll(binding.sourcePath, targetName);
if (resolvedDefs.length > 0) return resolvedDefs;
// No definition in source file → follow re-export chain
lookupFile = binding.sourcePath;
lookupName = targetName;
}
return null;
}
/** Resolution tier for tracking, logging, and test assertions. */
export type ResolutionTier = 'same-file' | 'import-scoped' | 'global';
@ -59,8 +158,13 @@ export interface ResolutionContext {
resolve(name: string, fromFile: string): TieredCandidates | null;
// --- Data access (for pipeline wiring, not resolution) ---
/** Symbol table — used by parsing-processor to populate symbols. */
readonly symbols: SymbolTable;
/** Semantic model — the top-level container for types, methods, fields,
* and the nested file/callable SymbolTable. Typed as
* {@link MutableSemanticModel} because `ResolutionContext` is the
* lifecycle owner — the pipeline registers symbols through it during
* the fan-out phase. Resolvers that only query should annotate their
* own fields as {@link SemanticModel} to drop write access. */
readonly model: MutableSemanticModel;
/** Raw maps — used by import-processor to populate import data. */
readonly importMap: ImportMap;
readonly packageMap: PackageMap;
@ -86,7 +190,8 @@ export interface ResolutionContext {
}
export const createResolutionContext = (): ResolutionContext => {
const symbols = createSymbolTable();
const model = createSemanticModel();
const symbols = model.symbols;
const importMap: ImportMap = new Map();
const packageMap: PackageMap = new Map();
const namedImportMap: NamedImportMap = new Map();
@ -194,27 +299,75 @@ export const createResolutionContext = (): ResolutionContext => {
// Tier 3: Global — targeted O(1) index lookups for each symbol category.
// Class-like symbols (Class, Struct, Interface, Enum, Record, Trait) are
// covered by lookupClassByName; Rust impl blocks by lookupImplByName
// (separate to avoid polluting heritage resolution); callables (Function,
// Method, Constructor, Macro, Delegate) by lookupCallableByName.
// The three indexes cover disjoint symbol types so no dedup is needed.
// Consumers must check candidates.length and refuse ambiguous matches.
// (separate to avoid polluting heritage resolution); free callables
// (Function, Macro, Delegate) by lookupCallableByName; owner-scoped
// methods and constructors by `model.methods.lookupMethodByName`.
//
// FREE_CALLABLE_TYPES excludes Method/Constructor, so strictly-labeled
// methods are disjoint between the two indexes.
//
// Partial-state caveat: Python/Rust/Kotlin class methods are emitted
// as Function + ownerId — `rawSymbols.add` routes them through both
// the Function callable index AND, via the dispatch-key normalization
// in `wrappedAdd`, the method registry. The same `SymbolDefinition`
// reference lands in both `callableDefs` and `methodDefs`, so the
// Set-based dedup below is required.
//
// Known exclusion: TypeAlias, Const, and Variable are NOT reachable at
// Tier 3 — they don't belong to any of the three indexes. In practice
// they were never useful as Tier 3 candidates: TypeAlias is not a call
// target, Const/Variable are resolved via import or same-file tiers.
// If a future language needs them at Tier 3, add a dedicated index.
// Macro (C/C++) and Delegate (C#) ARE included in the callable index
// Tier 3 — they don't belong to any of the indexes. TypeAlias is not
// a call target; Const/Variable are resolved via import or same-file
// tiers. Macro (C/C++) and Delegate (C#) stay in the callable index
// since call-processor.ts treats them as callable targets.
const classDefs = symbols.lookupClassByName(name);
const implDefs = symbols.lookupImplByName(name);
const classDefs = model.types.lookupClassByName(name);
const implDefs = model.types.lookupImplByName(name);
const callableDefs = symbols.lookupCallableByName(name);
const methodDefs = model.methods.lookupMethodByName(name);
if (classDefs.length === 0 && implDefs.length === 0 && callableDefs.length === 0) {
if (
classDefs.length === 0 &&
implDefs.length === 0 &&
callableDefs.length === 0 &&
methodDefs.length === 0
) {
tierMiss++;
return null;
}
const globalDefs = [...classDefs, ...implDefs, ...callableDefs];
// Fast path: if no `Function + ownerId` class method was ever
// registered into the method registry (the only source of
// cross-index duplication), the callable and method indexes are
// guaranteed disjoint and we can concat without dedup.
if (!model.methods.hasFunctionMethods) {
const globalDefs: SymbolDefinition[] = [
...classDefs,
...implDefs,
...callableDefs,
...methodDefs,
];
tierGlobal++;
return { candidates: globalDefs, tier: 'global' };
}
// Slow path: dedup by nodeId because the same SymbolDefinition
// reference can land in both `callableDefs` (via the Function
// callable-index gate) and `methodDefs` (via the dispatch-key
// normalization routing Function+ownerId into MethodRegistry).
// Dedup covers all four index reads so any nodeId overlap (even
// theoretical ones between classDefs/implDefs) is caught.
const globalDefs: SymbolDefinition[] = [];
const seen = new Set<string>();
const pushUnique = (pool: readonly SymbolDefinition[]): void => {
for (const def of pool) {
if (seen.has(def.nodeId)) continue;
seen.add(def.nodeId);
globalDefs.push(def);
}
};
pushUnique(classDefs);
pushUnique(implDefs);
pushUnique(callableDefs);
pushUnique(methodDefs);
tierGlobal++;
return { candidates: globalDefs, tier: 'global' };
};
@ -271,7 +424,7 @@ export const createResolutionContext = (): ResolutionContext => {
});
const clear = (): void => {
symbols.clear();
model.clear();
importMap.clear();
packageMap.clear();
namedImportMap.clear();
@ -288,7 +441,7 @@ export const createResolutionContext = (): ResolutionContext => {
return {
resolve,
symbols,
model,
importMap,
packageMap,
namedImportMap,

View file

@ -0,0 +1,284 @@
/**
* Deterministic Resolution Functions
*
* Pure functions that resolve methods across the inheritance hierarchy
* using only the SemanticModel registries and HeritageMap — NO dependency
* on resolution-context.ts (circular dependency risk).
*/
import type { SymbolDefinition } from './symbol-table.js';
import type { SemanticModel } from './semantic-model.js';
import type { HeritageMap } from './heritage-map.js';
import type { MroStrategy } from 'gitnexus-shared';
// ---------------------------------------------------------------------------
// MRO primitives.
//
// `c3Linearize` and its BFS helper `gatherAncestors` live here so the model
// layer stays a pure leaf — mro-processor.ts (graph-level MRO emission)
// imports `c3Linearize` from this file.
// ---------------------------------------------------------------------------
/**
* Gather all ancestor IDs in BFS / topological order.
* Returns the linearized list of ancestor IDs (excluding the class itself).
*/
function gatherAncestors(classId: string, parentMap: Map<string, string[]>): string[] {
const visited = new Set<string>();
const order: string[] = [];
const queue: string[] = [...(parentMap.get(classId) ?? [])];
while (queue.length > 0) {
const id = queue.shift()!;
if (visited.has(id)) continue;
visited.add(id);
order.push(id);
const grandparents = parentMap.get(id);
if (grandparents) {
for (const gp of grandparents) {
if (!visited.has(gp)) queue.push(gp);
}
}
}
return order;
}
/**
* Compute C3 linearization for a class given a parentMap.
* Returns an array of ancestor IDs in C3 order (excluding the class itself),
* or null if linearization fails (inconsistent or cyclic hierarchy).
*
* Used internally by `lookupMethodByOwnerWithMRO` for the Python MRO
* strategy and re-exported for mro-processor.ts (graph-level MRO emission).
*/
export function c3Linearize(
classId: string,
parentMap: Map<string, string[]>,
cache: Map<string, string[] | null>,
inProgress?: Set<string>,
): string[] | null {
if (cache.has(classId)) return cache.get(classId)!;
// Cycle detection: if we're already computing this class, the hierarchy is cyclic
const visiting = inProgress ?? new Set<string>();
if (visiting.has(classId)) {
cache.set(classId, null);
return null;
}
visiting.add(classId);
const directParents = parentMap.get(classId);
if (!directParents || directParents.length === 0) {
visiting.delete(classId);
cache.set(classId, []);
return [];
}
// Compute linearization for each parent first
const parentLinearizations: string[][] = [];
for (const pid of directParents) {
const pLin = c3Linearize(pid, parentMap, cache, visiting);
if (pLin === null) {
visiting.delete(classId);
cache.set(classId, null);
return null;
}
parentLinearizations.push([pid, ...pLin]);
}
// Add the direct parents list as the final sequence
const sequences = [...parentLinearizations, [...directParents]];
const result: string[] = [];
while (sequences.some((s) => s.length > 0)) {
// Find a good head: one that doesn't appear in the tail of any other sequence
let head: string | null = null;
for (const seq of sequences) {
if (seq.length === 0) continue;
const candidate = seq[0];
const inTail = sequences.some(
(other) => other.length > 1 && other.indexOf(candidate, 1) !== -1,
);
if (!inTail) {
head = candidate;
break;
}
}
if (head === null) {
// Inconsistent hierarchy
visiting.delete(classId);
cache.set(classId, null);
return null;
}
result.push(head);
// Remove the chosen head from all sequences
for (const seq of sequences) {
if (seq.length > 0 && seq[0] === head) {
seq.shift();
}
}
}
visiting.delete(classId);
cache.set(classId, result);
return result;
}
// `gatherAncestors` is exported so mro-processor.ts can reuse the same
// BFS traversal for graph-level MRO emission.
export { gatherAncestors };
// ---------------------------------------------------------------------------
// C3 linearization cache (per HeritageMap, auto-drained via WeakMap)
// ---------------------------------------------------------------------------
/**
* Per-HeritageMap cache of C3 linearization results keyed by owner nodeId.
*
* HeritageMap instances are immutable after construction, so C3 output is
* stable for the lifetime of a HeritageMap. WeakMap lets the cache auto-drain
* when the HeritageMap is garbage collected (end of ingestion run), so we
* never need to manually invalidate it.
*
* `null` is a sentinel for "C3 failed for this owner" (cyclic or inconsistent
* hierarchy) so we don't re-run the expensive linearization repeatedly.
*/
const c3LinearizationCache = new WeakMap<HeritageMap, Map<string, readonly string[] | null>>();
const getCachedC3Linearization = (
ownerNodeId: string,
heritageMap: HeritageMap,
): readonly string[] | null => {
let perHmCache = c3LinearizationCache.get(heritageMap);
if (!perHmCache) {
perHmCache = new Map();
c3LinearizationCache.set(heritageMap, perHmCache);
}
const cached = perHmCache.get(ownerNodeId);
if (cached !== undefined) return cached;
const parentMap = buildParentMapFromHeritage(ownerNodeId, heritageMap);
const result = c3Linearize(ownerNodeId, parentMap, new Map()) ?? null;
perHmCache.set(ownerNodeId, result);
return result;
};
// ---------------------------------------------------------------------------
// Heritage → parentMap conversion
// ---------------------------------------------------------------------------
/**
* Build a parentMap from HeritageMap for use with c3Linearize.
* Traverses the parent chain starting from startNodeId, collecting all
* parent→children relationships into a Map<string, string[]>.
*
* Uses a head-pointer BFS (queue[head++]) instead of Array.shift() to avoid
* O(n) per-dequeue re-indexing. For wide/shallow hierarchies common in
* large Java/C# codebases this keeps the walk linear in ancestor count.
*/
const buildParentMapFromHeritage = (
startNodeId: string,
heritageMap: HeritageMap,
): Map<string, string[]> => {
const parentMap = new Map<string, string[]>();
const visited = new Set<string>();
const queue: string[] = [startNodeId];
let head = 0;
while (head < queue.length) {
const nodeId = queue[head++]!;
if (visited.has(nodeId)) continue;
visited.add(nodeId);
const parents = heritageMap.getParents(nodeId);
if (parents.length > 0) {
parentMap.set(nodeId, parents);
for (const p of parents) {
if (!visited.has(p)) queue.push(p);
}
}
}
return parentMap;
};
// ---------------------------------------------------------------------------
// MRO-aware method lookup
// ---------------------------------------------------------------------------
/**
* Look up a method on an owner class, walking the parent chain via HeritageMap
* when the method isn't found on the direct owner.
*
* Respects the 5 per-language MRO strategies:
* - `first-wins`: BFS ancestor walk, first match wins (default)
* - `leftmost-base`: BFS ancestor walk, leftmost base in declaration order wins (C++);
* HeritageMap preserves insertion order matching source declaration,
* so BFS order is equivalent to leftmost-base semantics
* - `c3`: C3-linearized ancestor order, first match wins (Python)
* - `implements-split`: BFS ancestor walk, first match wins (Java/C#) —
* full ambiguity detection for multiple interface defaults
* is handled by computeMRO at graph level
* - `qualified-syntax`: No auto-resolution (Rust) — returns undefined
*
* Uses the `c3Linearize` defined in this file (also consumed by
* mro-processor.ts for graph-level MRO emission) for the `c3` strategy.
*
* Depends only on {@link SemanticModel} + {@link HeritageMap} + an
* {@link MroStrategy} literal — NO dependency on SymbolTable, the language
* registry, or resolution-context, which keeps the `model/` module free of
* cross-layer imports. Callers derive the strategy from their language
* provider before invoking this function.
*
* @internal This is the low-level MRO walker. Exported so call-processor's
* higher-level resolvers (and unit tests) can invoke it directly. Callers
* outside `core/ingestion/` should use the higher-level resolvers in
* call-processor.ts instead of depending on this function.
*/
export const lookupMethodByOwnerWithMRO = (
ownerNodeId: string,
methodName: string,
heritageMap: HeritageMap,
model: SemanticModel,
strategy: MroStrategy,
argCount?: number,
): SymbolDefinition | undefined => {
// Direct lookup first (child override — no walk needed).
// argCount is threaded through so arity-differing overloads on the direct
// owner can be disambiguated before the MRO walk starts.
const direct = model.methods.lookupMethodByOwner(ownerNodeId, methodName, argCount);
if (direct) return direct;
// Rust: requires qualified syntax (<Type as Trait>::method), no auto-resolution
if (strategy === 'qualified-syntax') return undefined;
// Determine ancestor walk order based on MRO strategy.
// readonly to accept the cached (frozen) c3 linearization without copying.
let ancestors: readonly string[];
if (strategy === 'c3') {
// C3 linearization (memoized per HeritageMap
// so repeated calls for the same owner within an ingestion run reuse the
// linearization instead of rebuilding the parent map and re-running C3).
// c3Linearize returns ancestors only (excludes the owner itself),
// matching heritageMap.getAncestors() semantics.
const c3Result = getCachedC3Linearization(ownerNodeId, heritageMap);
// Fall back to BFS order if C3 fails (cyclic or inconsistent hierarchy).
// Note: BFS order may not preserve Python MRO semantics in these edge
// cases, but cyclic/inconsistent hierarchies are invalid in Python anyway.
ancestors = c3Result ?? heritageMap.getAncestors(ownerNodeId);
} else {
// first-wins, leftmost-base, implements-split: BFS order via HeritageMap
ancestors = heritageMap.getAncestors(ownerNodeId);
}
// Walk ancestors in MRO order — first match wins.
// argCount narrows overloaded ancestors the same way as the direct lookup.
for (const ancestorId of ancestors) {
const method = model.methods.lookupMethodByOwner(ancestorId, methodName, argCount);
if (method) return method;
}
return undefined;
};

View file

@ -0,0 +1,193 @@
/**
* Semantic Model
*
* Top-level orchestrator for all resolution-time data. Owns:
*
* - Three owner-scoped registries (types, methods, fields)
* - A nested SymbolTable (file + callable name indexes) wrapped so
* that `add()` fans out into the registries via the dispatch table
*
* ## DAG direction
*
* gitnexus-shared (NodeLabel) — leaf
* ↑
* symbol-table.ts — pure file/callable index
* ↑
* model/type-registry / method-registry / field-registry
* ↑
* model/registration-table.ts — dispatch table factory
* ↑
* model/semantic-model.ts — THIS FILE (orchestrator)
* ↑
* resolve.ts, call-processor.ts, resolution-context.ts, ...
*
* `symbol-table.ts` is a leaf — it never imports from `./model/`. This
* file (semantic-model.ts) is the ONLY place where SymbolTable and the
* owner-scoped registries are composed. Upstream consumers pass around
* the `SemanticModel` interface and reach into `.symbols` for file-scoped
* operations or `.types` / `.methods` / `.fields` for owner-scoped ones.
*
* ## Fan-out via wrapped add()
*
* `createSemanticModel()` creates a pure SymbolTable, creates the three
* registries, builds a dispatch table via `createRegistrationTable`, and
* exposes a SymbolTable-shaped façade whose `add()`:
*
* 1. Calls `rawSymbols.add()` — writes the fileIndex + callable index
* and returns the fully-built `SymbolDefinition`.
* 2. Runs pre-dispatch normalization (`Function`-with-`ownerId` routes
* as `Method`).
* 3. Looks up the dispatch table and invokes the hook, which writes to
* the appropriate owner-scoped registry.
*
* The wrapper is the only place where the two layers are combined. A
* direct `createSymbolTable()` caller (e.g. an isolated unit test) gets
* the pure, registry-free behavior — no surprises, no hidden side
* effects.
*/
import type { NodeLabel } from 'gitnexus-shared';
import type { TypeRegistry, MutableTypeRegistry } from './type-registry.js';
import type { MethodRegistry, MutableMethodRegistry } from './method-registry.js';
import type { FieldRegistry, MutableFieldRegistry } from './field-registry.js';
import { createTypeRegistry } from './type-registry.js';
import { createMethodRegistry } from './method-registry.js';
import { createFieldRegistry } from './field-registry.js';
import type {
SymbolTableReader,
SymbolTableWriter,
SymbolDefinition,
AddMetadata,
} from './symbol-table.js';
import { createSymbolTable } from './symbol-table.js';
import { createRegistrationTable } from './registration-table.js';
// ---------------------------------------------------------------------------
// Public read-only interface
// ---------------------------------------------------------------------------
/**
* Aggregated read-only view of the semantic registries plus the nested
* file/callable SymbolTable.
*
* `symbols` is typed as {@link SymbolTableReader} — consumers can query
* symbols but cannot register new ones or trigger a reset. Callers that
* need to register symbols or reset state must hold a
* {@link MutableSemanticModel} reference instead, which widens
* `symbols` back to {@link SymbolTableWriter} and adds `clear()` on the
* model itself.
*
* This segregation is the runtime half of the principle of least
* authority: a resolver that receives `SemanticModel` physically cannot
* mutate the index, so it cannot desync the leaf from the owner-scoped
* registries even accidentally.
*/
export interface SemanticModel {
readonly types: TypeRegistry;
readonly methods: MethodRegistry;
readonly fields: FieldRegistry;
readonly symbols: SymbolTableReader;
}
// ---------------------------------------------------------------------------
// Mutable interface
// ---------------------------------------------------------------------------
/** Mutable variant — exposes the MutableX registries, a Writer-typed
* `symbols` facade, and a full-cascade reset. This is the interface
* held by the lifecycle owner (pipeline, resolution-context); resolvers
* that only query should hold the narrower {@link SemanticModel}. */
export interface MutableSemanticModel extends SemanticModel {
readonly types: MutableTypeRegistry;
readonly methods: MutableMethodRegistry;
readonly fields: MutableFieldRegistry;
readonly symbols: SymbolTableWriter;
/** Clear all registries AND the nested SymbolTable. */
clear(): void;
}
// ---------------------------------------------------------------------------
// Factory
// ---------------------------------------------------------------------------
//
// NodeLabel taxonomy drift detection lives in `registration-table.ts` as a
// pure compile-time check — the `LABEL_BEHAVIOR` map is
// `Record<NodeLabel, LabelBehavior>` with `as const satisfies`, which proves
// coverage, uniqueness, and no-extra-keys at build time. No runtime guard
// is needed because drift is structurally impossible in the source.
export const createSemanticModel = (): MutableSemanticModel => {
// 1. Create the pure, registry-unaware SymbolTable leaf.
// rawSymbols is the only handle in the codebase whose type (the
// internal createSymbolTable return) includes `.clear()`. cascadeClear
// below reaches it here; no external caller receives this variable.
const rawSymbols = createSymbolTable();
// 2. Create the three owner-scoped registries.
const types = createTypeRegistry();
const methods = createMethodRegistry();
const fields = createFieldRegistry();
// 3. Build the dispatch table, closed over THIS instance's registries.
const dispatchTable = createRegistrationTable({ types, methods, fields });
// 4. Wrap rawSymbols so `add()` fans out into the registries via the
// dispatch table. See module JSDoc for the three-step contract.
const wrappedAdd = (
filePath: string,
name: string,
nodeId: string,
type: NodeLabel,
metadata?: AddMetadata,
): SymbolDefinition => {
const def = rawSymbols.add(filePath, name, nodeId, type, metadata);
// Function-with-ownerId (Python `def` in a class body, Rust trait
// method, Kotlin companion method) routes as Method. Keeps the
// dispatch table single-purpose.
const dispatchKey: NodeLabel =
type === 'Function' && metadata?.ownerId !== undefined ? 'Method' : type;
const hook = dispatchTable.get(dispatchKey);
if (hook) {
hook(name, def);
}
return def;
};
// Cascade clear: single source of truth for "reset the entire model".
// Wired into both `model.clear()` AND `model.symbols.clear()` so that a
// caller holding only a SymbolTable reference can't leave the
// owner-scoped registries populated while the file/callable indexes go
// empty (the phantom-resolution failure mode).
const cascadeClear = (): void => {
types.clear();
methods.clear();
fields.clear();
rawSymbols.clear();
};
// Writer-typed facade: exposes reads + add, but NO `clear` field.
// Callers holding a `SemanticModel.symbols` reference cannot desync
// the leaf indexes from the owner-scoped registries. Consumers that
// only query should widen their annotation to SymbolTableReader for
// least-authority clarity.
const symbols: SymbolTableWriter = {
add: wrappedAdd,
lookupExact: rawSymbols.lookupExact,
lookupExactFull: rawSymbols.lookupExactFull,
lookupExactAll: rawSymbols.lookupExactAll,
lookupCallableByName: rawSymbols.lookupCallableByName,
getFiles: rawSymbols.getFiles,
getStats: rawSymbols.getStats,
};
return {
types,
methods,
fields,
symbols,
clear: cascadeClear,
};
};

View file

@ -0,0 +1,381 @@
/**
* Symbol Table — file-indexed + callable-name symbol storage.
*
* This module is a PURE LEAF in the ingestion DAG. It owns two orthogonal
* O(1) indexes:
*
* 1. fileIndex — Map<filePath, Map<name, SymbolDefinition[]>>
* for same-file lookups (Tier 1 resolution)
* 2. callableByName — Map<name, SymbolDefinition[]>
* for name-keyed callable lookups (Tier 3 widen)
*
* SymbolTable deliberately knows NOTHING about the owner-scoped registries
* (types, methods, fields) that sit above it in the DAG. Those registries
* live in `model/` and depend on SymbolTable, not the other way around.
* {@link createSemanticModel} composes this pure SymbolTable with the
* registries and wraps `add()` to fan out registrations into both layers.
*
* DAG direction (strictly enforced):
*
* gitnexus-shared (NodeLabel) — leaf type
* ↑
* symbol-table.ts — THIS FILE (pure storage)
* ↑
* model/type-registry.ts, method-registry.ts, field-registry.ts
* ↑
* model/registration-table.ts — dispatch table factory
* ↑
* model/semantic-model.ts — orchestrator, wraps add()
* ↑
* model/resolve.ts, call-processor.ts, resolution-context.ts, ...
*
* No arrow ever points downward from this file. If you are tempted to
* import from `./model/` here, you are going the wrong way — move the
* logic up the DAG instead.
*/
import type { NodeLabel } from 'gitnexus-shared';
/**
* Class-like NodeLabels — used for qualifiedName fallback inside
* `SymbolTable.add()` and (via import into `model/registration-table.ts`)
* as the single source of truth for which labels route to classHook
* in the dispatch table.
*
* Exported as a `readonly` tuple so that `typeof CLASS_TYPES_TUPLE[number]`
* yields a precise literal union (`ClassLikeLabel`). The model layer
* imports this tuple and uses `Record<ClassLikeLabel, 'dispatch'>` in a
* `satisfies` intersection to enforce at COMPILE TIME that every label
* listed here is also classified as dispatch in `LABEL_BEHAVIOR`. Adding
* a new class-like label to this tuple without updating `LABEL_BEHAVIOR`
* fails TypeScript.
*
* Traits are class-like for heritage resolution: PHP `use Trait;`, Rust
* `impl Trait for Struct`, and Scala traits all contribute methods to the
* hierarchy of their using/implementing type.
*/
export const CLASS_TYPES_TUPLE = [
'Class',
'Struct',
'Interface',
'Enum',
'Record',
'Trait',
] as const satisfies readonly NodeLabel[];
export type ClassLikeLabel = (typeof CLASS_TYPES_TUPLE)[number];
export const CLASS_TYPES: ReadonlySet<NodeLabel> = new Set(CLASS_TYPES_TUPLE);
/** Free-callable labels — single source of truth for "callables that have
* NO owner scope". Methods and constructors are owner-scoped and live in
* `MethodRegistry` — Tier 3 reaches them via
* `model.methods.lookupMethodByName`. See `resolution-context.ts` Tier 3
* for how both indexes are consulted together.
*
* Exported as a `readonly` tuple so that `typeof FREE_CALLABLE_TUPLE[number]`
* yields a precise literal union (`FreeCallableLabel`). `registration-table.ts`
* imports this type and uses `Record<FreeCallableLabel, 'callable-only'>` in
* a `satisfies` intersection to enforce at COMPILE TIME that every label
* listed here is also classified as `callable-only` in `LABEL_BEHAVIOR`.
* Adding a label to this tuple without updating `LABEL_BEHAVIOR` fails
* TypeScript.
*
* Partial-state caveat: Python/Rust/Kotlin class methods are emitted by
* the worker as `Function` + `ownerId` (not `Method`), so they still land
* here via the `Function` entry. Collapsing those three languages onto the
* `Method` label is pending a `def.type` preservation decision.
*/
export const FREE_CALLABLE_TUPLE = [
'Function',
'Macro', // C/C++
'Delegate', // C#
] as const satisfies readonly NodeLabel[];
export type FreeCallableLabel = (typeof FREE_CALLABLE_TUPLE)[number];
export const FREE_CALLABLE_TYPES: ReadonlySet<NodeLabel> = new Set(FREE_CALLABLE_TUPLE);
/** Symbol types that can be the TARGET of a call in the resolver's kind
* filter — superset of {@link FREE_CALLABLE_TYPES} that also admits
* owner-scoped methods and constructors pulled in from `MethodRegistry`.
*
* Why the split: `FREE_CALLABLE_TYPES` now has a narrow meaning (free
* callables indexed in `callableByName`), but call resolution still
* needs to accept Method and Constructor candidates once they have been
* unioned in from `model.methods.lookupMethodByName`. The resolver uses
* this constant for kind filtering in
* `filterCallableCandidates` / `countCallableCandidates`.
*/
export const CALL_TARGET_TYPES: ReadonlySet<NodeLabel> = new Set<NodeLabel>([
...FREE_CALLABLE_TYPES,
'Method',
'Constructor',
]);
export interface SymbolDefinition {
nodeId: string;
filePath: string;
type: NodeLabel;
/** Canonical dot-separated qualified type name for class-like symbols
* (e.g. `App.Models.User`). Falls back to the simple symbol name when no
* package/namespace/module scope exists or no explicit qualified metadata is provided. */
qualifiedName?: string;
parameterCount?: number;
/** Number of required (non-optional, non-default) parameters.
* Enables range-based arity filtering: argCount >= requiredParameterCount && argCount <= parameterCount. */
requiredParameterCount?: number;
/** Per-parameter type names for overload disambiguation (e.g. ['int', 'String']).
* Populated when parameter types are resolvable from AST (any typed language). */
parameterTypes?: string[];
/** Raw return type text extracted from AST (e.g. 'User', 'Promise<User>') */
returnType?: string;
/** Declared type for non-callable symbols — fields/properties (e.g. 'Address', 'List<User>') */
declaredType?: string;
/** Links Method/Constructor/Property to owning Class/Struct/Trait nodeId */
ownerId?: string;
}
/**
* Optional metadata accepted by {@link SymbolTable.add}. Kept as a separate
* type alias so callers and wrappers can share the same shape.
*/
export interface AddMetadata {
parameterCount?: number;
requiredParameterCount?: number;
parameterTypes?: string[];
returnType?: string;
declaredType?: string;
ownerId?: string;
qualifiedName?: string;
}
/**
* Pure read-only view over the file and callable indexes. Does NOT
* include `add()` or `clear()`.
*
* Used by consumers that only query symbols (resolvers, type-env, field
* extractors). The interface is strictly observational — holding a
* `SymbolTableReader` cannot mutate the table in any way.
*
* For consumers that also need to register symbols, use
* {@link SymbolTableWriter}, which extends this interface with `add()`.
* Neither interface exposes `clear()` — that capability lives on the
* internal factory return type and is reachable only inside
* `SemanticModel` via `rawSymbols`.
*
* Segregating the observer contract from the mutation contract means
* callers holding only a Reader can never desync the model.
*/
export interface SymbolTableReader {
/**
* High Confidence: Look for a symbol specifically inside a file.
* Returns the Node ID if found.
*/
lookupExact: (filePath: string, name: string) => string | undefined;
/**
* High Confidence: Look for a symbol in a specific file, returning full definition.
* Returns first matching definition — use lookupExactAll for overloaded methods.
*/
lookupExactFull: (filePath: string, name: string) => SymbolDefinition | undefined;
/**
* High Confidence: Look for ALL symbols with this name in a specific file.
* Returns all definitions, including overloaded methods with the same name.
* The returned array is a view into the live internal index — callers
* MUST NOT mutate it. Use `readonly` to enforce this at the type level.
*/
lookupExactAll: (filePath: string, name: string) => readonly SymbolDefinition[];
/**
* Look up callable symbols (Function, Macro, Delegate) by name.
* O(1) via dedicated eagerly-populated index keyed by symbol name.
* Returned array is a view into the live index — do not mutate.
*/
lookupCallableByName: (name: string) => readonly SymbolDefinition[];
/**
* Iterate all indexed file paths.
* Used by Tier 2b (package-scoped) resolution to walk files matching a
* package directory suffix without a global name scan.
*/
getFiles: () => IterableIterator<string>;
/**
* Debugging: See how many files are tracked.
*/
getStats: () => {
fileCount: number;
};
}
/**
* Writer view — reads + symbol registration. Does NOT include `clear()`.
*
* `MutableSemanticModel.symbols` is typed as this interface, so the
* lifecycle owner can register symbols and query them. Full-model
* resets flow through `model.clear()`.
*
* The cascading `clear()` capability lives exclusively on the internal
* factory return type ({@link createSymbolTable}) — a private handle
* held only by `SemanticModel` via `rawSymbols`.
*/
export interface SymbolTableWriter extends SymbolTableReader {
/**
* Register a symbol in the file and (if callable) name-keyed indexes.
*
* Returns the constructed {@link SymbolDefinition} so higher-layer
* wrappers (e.g. `createSemanticModel`) can reuse it without rebuilding
* the def. This keeps the fan-out in one allocation.
*/
add: (
filePath: string,
name: string,
nodeId: string,
type: NodeLabel,
metadata?: AddMetadata,
) => SymbolDefinition;
}
/**
* Internal return type for {@link createSymbolTable} — extends the
* writer with `clear()`. This capability is intentionally NOT exported
* as a named interface; consumers should hold a `SymbolTableReader` or
* `SymbolTableWriter` instead.
*
* `SemanticModel`'s constructor is the only caller of `createSymbolTable`,
* and it retains the returned handle as the private `rawSymbols`
* reference so `cascadeClear` can reach `clear()`. Every other consumer
* receives the narrower `SymbolTableWriter` facade on `model.symbols`.
*/
interface InternalSymbolTable extends SymbolTableWriter {
/**
* Cleanup memory. Clears only the file and callable indexes owned here —
* owner-scoped registries are cleared by their respective owners via
* `model.clear()`.
*/
clear: () => void;
}
export const createSymbolTable = (): InternalSymbolTable => {
// 1. File-Specific Index — stores full SymbolDefinition(s) for O(1) lookup.
// Structure: FilePath -> (SymbolName -> SymbolDefinition[])
// Array allows overloaded methods (same name, different signatures) to coexist.
const fileIndex = new Map<string, Map<string, SymbolDefinition[]>>();
// 2. Eagerly-populated Callable Index — maintained on add().
// Structure: SymbolName -> [Callable Definitions]
// Only Function, Method, Constructor, Macro, Delegate symbols are indexed.
const callableByName = new Map<string, SymbolDefinition[]>();
const add = (
filePath: string,
name: string,
nodeId: string,
type: NodeLabel,
metadata?: AddMetadata,
): SymbolDefinition => {
const qualifiedName = CLASS_TYPES.has(type)
? (metadata?.qualifiedName ?? name)
: metadata?.qualifiedName;
const def: SymbolDefinition = {
nodeId,
filePath,
type,
...(qualifiedName !== undefined ? { qualifiedName } : {}),
...(metadata?.parameterCount !== undefined
? { parameterCount: metadata.parameterCount }
: {}),
...(metadata?.requiredParameterCount !== undefined
? { requiredParameterCount: metadata.requiredParameterCount }
: {}),
...(metadata?.parameterTypes !== undefined
? { parameterTypes: metadata.parameterTypes }
: {}),
...(metadata?.returnType !== undefined ? { returnType: metadata.returnType } : {}),
...(metadata?.declaredType !== undefined ? { declaredType: metadata.declaredType } : {}),
...(metadata?.ownerId !== undefined ? { ownerId: metadata.ownerId } : {}),
};
// A. File Index — unconditional.
if (!fileIndex.has(filePath)) {
fileIndex.set(filePath, new Map());
}
const fileMap = fileIndex.get(filePath)!;
if (!fileMap.has(name)) {
fileMap.set(name, [def]);
} else {
fileMap.get(name)!.push(def);
}
// B. Callable Index — gated by FREE_CALLABLE_TYPES.
// Note: Property is NOT in FREE_CALLABLE_TYPES, so it never lands here.
// This is the single source of truth for callable-index membership;
// the higher-layer dispatch table only decides owner-scoped routing.
//
// Fallback: `Method` or `Constructor` without an `ownerId` is an
// extractor contract violation (AST-degraded parse, or a buggy
// language extractor). The owner-scoped dispatch hook silently
// skips such defs because it has no owner to key them under, so
// without this fallback they would be invisible at Tier 3 global
// resolution. Route them through `callableByName` so they remain
// reachable by name — matching pre-dispatch-table behavior.
const isOrphanedOwnerScoped =
(type === 'Method' || type === 'Constructor') && metadata?.ownerId === undefined;
if (FREE_CALLABLE_TYPES.has(type) || isOrphanedOwnerScoped) {
const existing = callableByName.get(name);
if (existing) {
existing.push(def);
} else {
callableByName.set(name, [def]);
}
}
return def;
};
const lookupExact = (filePath: string, name: string): string | undefined => {
const defs = fileIndex.get(filePath)?.get(name);
return defs?.[0]?.nodeId;
};
const lookupExactFull = (filePath: string, name: string): SymbolDefinition | undefined => {
const defs = fileIndex.get(filePath)?.get(name);
return defs?.[0];
};
const lookupExactAll = (filePath: string, name: string): SymbolDefinition[] => {
return fileIndex.get(filePath)?.get(name) ?? [];
};
const lookupCallableByName = (name: string): SymbolDefinition[] => {
return callableByName.get(name) ?? [];
};
/** Returns a live iterator over all indexed file paths (fileIndex.keys()).
* The iterator is invalidated if add() changes fileIndex.size during
* iteration (ES2015 Map spec). Safe in the current pipeline because all
* symbols are added before resolution begins. */
const getFiles = (): IterableIterator<string> => fileIndex.keys();
const getStats = () => ({
fileCount: fileIndex.size,
});
const clear = () => {
fileIndex.clear();
callableByName.clear();
};
return {
add,
lookupExact,
lookupExactFull,
lookupExactAll,
lookupCallableByName,
getFiles,
getStats,
clear,
};
};

View file

@ -0,0 +1,113 @@
/**
* Type Registry
*
* Class/struct/interface index extracted from SymbolTable.
* Eagerly-populated indexes keyed by symbol name and qualified name.
* Also includes a separate index for Rust Impl blocks.
*/
import type { SymbolDefinition } from './symbol-table.js';
// ---------------------------------------------------------------------------
// Public read-only interface
// ---------------------------------------------------------------------------
export interface TypeRegistry {
/**
* Look up class-like definitions (Class, Struct, Interface, Enum, Record, Trait)
* by simple name. Returns all matching definitions across files
* (e.g. partial classes). Returned array is a view into the live
* internal index — do not mutate.
*/
lookupClassByName(name: string): readonly SymbolDefinition[];
/**
* Look up class-like definitions by canonical qualified name.
* Qualified names are normalized to dot-separated scope segments across languages,
* e.g. `App.Models.User`, `com.example.User`, or `Admin.User`.
* Returned array is a view into the live index — do not mutate.
*/
lookupClassByQualifiedName(qualifiedName: string): readonly SymbolDefinition[];
/**
* Look up Impl nodes by name. Used by Tier 3 resolution to include Rust
* impl blocks alongside class-like candidates.
* Returned array is a view into the live index — do not mutate.
*/
lookupImplByName(name: string): readonly SymbolDefinition[];
}
// ---------------------------------------------------------------------------
// Mutable interface (used internally by SymbolTable.add / clear)
// ---------------------------------------------------------------------------
export interface MutableTypeRegistry extends TypeRegistry {
/** Register a class-like type by name and qualified name. */
registerClass(name: string, qualifiedName: string, def: SymbolDefinition): void;
/** Register a Rust Impl block by name. */
registerImpl(name: string, def: SymbolDefinition): void;
/** Clear all entries. */
clear(): void;
}
// ---------------------------------------------------------------------------
// Factory
// ---------------------------------------------------------------------------
export const createTypeRegistry = (): MutableTypeRegistry => {
const classByName = new Map<string, SymbolDefinition[]>();
const classByQualifiedName = new Map<string, SymbolDefinition[]>();
const implByName = new Map<string, SymbolDefinition[]>();
const lookupClassByName = (name: string): SymbolDefinition[] => {
return classByName.get(name) ?? [];
};
const lookupClassByQualifiedName = (qualifiedName: string): SymbolDefinition[] => {
return classByQualifiedName.get(qualifiedName) ?? [];
};
const lookupImplByName = (name: string): SymbolDefinition[] => {
return implByName.get(name) ?? [];
};
const registerClass = (name: string, qualifiedName: string, def: SymbolDefinition): void => {
const existing = classByName.get(name);
if (existing) {
existing.push(def);
} else {
classByName.set(name, [def]);
}
const qualifiedMatches = classByQualifiedName.get(qualifiedName);
if (qualifiedMatches) {
qualifiedMatches.push(def);
} else {
classByQualifiedName.set(qualifiedName, [def]);
}
};
const registerImpl = (name: string, def: SymbolDefinition): void => {
const existing = implByName.get(name);
if (existing) {
existing.push(def);
} else {
implByName.set(name, [def]);
}
};
const clear = (): void => {
classByName.clear();
classByQualifiedName.clear();
implByName.clear();
};
return {
lookupClassByName,
lookupClassByQualifiedName,
lookupImplByName,
registerClass,
registerImpl,
clear,
};
};

View file

@ -23,6 +23,7 @@ import { KnowledgeGraph } from '../graph/types.js';
import { generateId } from '../../lib/utils.js';
import { SupportedLanguages } from 'gitnexus-shared';
import { getProvider } from './languages/index.js';
import { c3Linearize, gatherAncestors } from './model/resolve.js';
// ---------------------------------------------------------------------------
// Public types
@ -93,115 +94,9 @@ function buildAdjacency(graph: KnowledgeGraph) {
return { parentMap, methodMap, parentEdgeType };
}
/**
* Gather all ancestor IDs in BFS / topological order.
* Returns the linearized list of ancestor IDs (excluding the class itself).
*/
function gatherAncestors(classId: string, parentMap: Map<string, string[]>): string[] {
const visited = new Set<string>();
const order: string[] = [];
const queue: string[] = [...(parentMap.get(classId) ?? [])];
while (queue.length > 0) {
const id = queue.shift()!;
if (visited.has(id)) continue;
visited.add(id);
order.push(id);
const grandparents = parentMap.get(id);
if (grandparents) {
for (const gp of grandparents) {
if (!visited.has(gp)) queue.push(gp);
}
}
}
return order;
}
// ---------------------------------------------------------------------------
// C3 linearization (Python MRO)
// ---------------------------------------------------------------------------
/**
* Compute C3 linearization for a class given a parentMap.
* Returns an array of ancestor IDs in C3 order (excluding the class itself),
* or null if linearization fails (inconsistent or cyclic hierarchy).
*/
export function c3Linearize(
classId: string,
parentMap: Map<string, string[]>,
cache: Map<string, string[] | null>,
inProgress?: Set<string>,
): string[] | null {
if (cache.has(classId)) return cache.get(classId)!;
// Cycle detection: if we're already computing this class, the hierarchy is cyclic
const visiting = inProgress ?? new Set<string>();
if (visiting.has(classId)) {
cache.set(classId, null);
return null;
}
visiting.add(classId);
const directParents = parentMap.get(classId);
if (!directParents || directParents.length === 0) {
visiting.delete(classId);
cache.set(classId, []);
return [];
}
// Compute linearization for each parent first
const parentLinearizations: string[][] = [];
for (const pid of directParents) {
const pLin = c3Linearize(pid, parentMap, cache, visiting);
if (pLin === null) {
visiting.delete(classId);
cache.set(classId, null);
return null;
}
parentLinearizations.push([pid, ...pLin]);
}
// Add the direct parents list as the final sequence
const sequences = [...parentLinearizations, [...directParents]];
const result: string[] = [];
while (sequences.some((s) => s.length > 0)) {
// Find a good head: one that doesn't appear in the tail of any other sequence
let head: string | null = null;
for (const seq of sequences) {
if (seq.length === 0) continue;
const candidate = seq[0];
const inTail = sequences.some(
(other) => other.length > 1 && other.indexOf(candidate, 1) !== -1,
);
if (!inTail) {
head = candidate;
break;
}
}
if (head === null) {
// Inconsistent hierarchy
visiting.delete(classId);
cache.set(classId, null);
return null;
}
result.push(head);
// Remove the chosen head from all sequences
for (const seq of sequences) {
if (seq.length > 0 && seq[0] === head) {
seq.shift();
}
}
}
visiting.delete(classId);
cache.set(classId, result);
return result;
}
// `gatherAncestors` and `c3Linearize` live in `./model/resolve.ts` and
// are imported at the top of this file for internal use by `computeMRO`
// and the method-override edge emitter.
// ---------------------------------------------------------------------------
// Language-specific resolution

View file

@ -1,47 +0,0 @@
import type { SymbolTable, SymbolDefinition } from './symbol-table.js';
import type { NamedImportMap } from './import-processor.js';
/**
* Walk a named-binding re-export chain through NamedImportMap.
*
* When file A imports { User } from B, and B re-exports { User } from C,
* the NamedImportMap for A points to B, but B has no User definition.
* This function follows the chain: A→B→C until a definition is found.
*
* Returns the definitions found at the end of the chain, or null if the
* chain breaks (missing binding, circular reference, or depth exceeded).
* Max depth 5 to prevent infinite loops.
*/
export function walkBindingChain(
name: string,
currentFilePath: string,
symbolTable: SymbolTable,
namedImportMap: NamedImportMap,
): SymbolDefinition[] | null {
let lookupFile = currentFilePath;
let lookupName = name;
const visited = new Set<string>();
for (let depth = 0; depth < 5; depth++) {
const bindings = namedImportMap.get(lookupFile);
if (!bindings) return null;
const binding = bindings.get(lookupName);
if (!binding) return null;
const key = `${binding.sourcePath}:${binding.exportedName}`;
if (visited.has(key)) return null; // circular
visited.add(key);
const targetName = binding.exportedName;
const resolvedDefs = symbolTable.lookupExactAll(binding.sourcePath, targetName);
if (resolvedDefs.length > 0) return resolvedDefs;
// No definition in source file → follow re-export chain
lookupFile = binding.sourcePath;
lookupName = targetName;
}
return null;
}

View file

@ -4,7 +4,9 @@ import Parser from 'tree-sitter';
import { loadParser, loadLanguage, isLanguageAvailable } from '../tree-sitter/parser-loader.js';
import { getProvider } from './languages/index.js';
import { generateId } from '../../lib/utils.js';
import type { SymbolTable } from './symbol-table.js';
import type { SymbolTableReader, SymbolTableWriter } from './model/symbol-table.js';
// SymbolTableReader is used for the FieldExtractorContext stub; the
// parsing functions themselves need Writer because they call .add().
import { ASTCache } from './ast-cache.js';
import { getLanguageFromFilename, SupportedLanguages } from 'gitnexus-shared';
import { extractVueScript, isVueSetupTopLevel } from './vue-sfc-extractor.js';
@ -36,7 +38,6 @@ import type {
ExtractedImport,
ExtractedCall,
ExtractedAssignment,
ExtractedHeritage,
ExtractedRoute,
ExtractedFetchCall,
ExtractedDecoratorRoute,
@ -45,6 +46,7 @@ import type {
FileScopeBindings,
ExtractedORMQuery,
} from './workers/parse-worker.js';
import type { ExtractedHeritage } from './model/heritage-map.js';
import { getTreeSitterBufferSize, TREE_SITTER_MAX_BUFFER } from './constants.js';
export type FileProgressCallback = (current: number, total: number, filePath: string) => void;
@ -70,7 +72,7 @@ export interface WorkerExtractedData {
const processParsingWithWorkers = async (
graph: KnowledgeGraph,
files: { path: string; content: string }[],
symbolTable: SymbolTable,
symbolTable: SymbolTableWriter,
astCache: ASTCache,
workerPool: WorkerPool,
onFileProgress?: FileProgressCallback,
@ -250,13 +252,19 @@ function seqFindEnclosingClassNode(node: SyntaxNode): SyntaxNode | null {
return null;
}
/** Minimal no-op SymbolTable stub for FieldExtractorContext (sequential path has a real
* SymbolTable, but it's incomplete at this stage — use the stub for safety). */
const NOOP_SYMBOL_TABLE_SEQ = {
lookupExactAll: () => [],
/** Minimal no-op SymbolTable stub for FieldExtractorContext (sequential
* path has a real SymbolTable, but it's incomplete at this stage — use
* the stub for safety). Implements the full {@link SymbolTableReader}
* surface so future extractor additions don't silently fall off an
* `as unknown as` cast. */
const NOOP_SYMBOL_TABLE_SEQ: SymbolTableReader = {
lookupExact: () => undefined,
lookupExactFull: () => undefined,
} as unknown as SymbolTable;
lookupExactAll: () => [],
lookupCallableByName: () => [],
getFiles: () => [][Symbol.iterator](),
getStats: () => ({ fileCount: 0 }),
};
function seqGetFieldInfo(
classNode: SyntaxNode,
@ -278,7 +286,7 @@ function seqGetFieldInfo(
const processParsingSequential = async (
graph: KnowledgeGraph,
files: { path: string; content: string }[],
symbolTable: SymbolTable,
symbolTable: SymbolTableWriter,
astCache: ASTCache,
onFileProgress?: FileProgressCallback,
) => {
@ -650,7 +658,7 @@ const processParsingSequential = async (
export const processParsing = async (
graph: KnowledgeGraph,
files: { path: string; content: string }[],
symbolTable: SymbolTable,
symbolTable: SymbolTableWriter,
astCache: ASTCache,
onFileProgress?: FileProgressCallback,
workerPool?: WorkerPool,

View file

@ -27,7 +27,7 @@ import {
type ExportedTypeMap,
buildExportedTypeMapFromGraph,
} from './call-processor.js';
import { buildHeritageMap } from './heritage-map.js';
import { buildHeritageMap } from './model/heritage-map.js';
import { nextjsFileToRouteURL, normalizeFetchURL } from './route-extractors/nextjs.js';
import { expoFileToRouteURL } from './route-extractors/expo.js';
import { phpFileToRouteURL } from './route-extractors/php.js';
@ -47,21 +47,22 @@ import type {
ExtractedCall,
ExtractedDecoratorRoute,
ExtractedFetchCall,
ExtractedHeritage,
ExtractedORMQuery,
ExtractedRoute,
ExtractedToolDef,
FileConstructorBindings,
} from './workers/parse-worker.js';
import type { ExtractedHeritage } from './model/heritage-map.js';
import {
processHeritage,
processHeritageFromExtracted,
extractExtractedHeritageFromFiles,
getHeritageStrategyForLanguage,
} from './heritage-processor.js';
import { computeMRO } from './mro-processor.js';
import { processCommunities } from './community-processor.js';
import { processProcesses } from './process-processor.js';
import { createResolutionContext } from './resolution-context.js';
import { createResolutionContext } from './model/resolution-context.js';
import { createASTCache } from './ast-cache.js';
import { type PipelineProgress, getLanguageFromFilename } from 'gitnexus-shared';
import { PipelineResult } from '../../types/pipeline.js';
@ -334,7 +335,7 @@ async function runCrossFileBindingPropagation(
// For the worker path, buildTypeEnv runs inside workers without SymbolTable,
// so exported bindings must be collected from graph + SymbolTable in main thread.
if (exportedTypeMap.size === 0 && graph.nodeCount > 0) {
const graphExports = buildExportedTypeMapFromGraph(graph, ctx.symbols);
const graphExports = buildExportedTypeMapFromGraph(graph, ctx.model.symbols);
for (const [fp, exports] of graphExports) exportedTypeMap.set(fp, exports);
}
@ -361,7 +362,7 @@ async function runCrossFileBindingPropagation(
filesWithGaps++;
break;
}
const def = ctx.symbols.lookupExactFull(binding.sourcePath, binding.exportedName);
const def = ctx.model.symbols.lookupExactFull(binding.sourcePath, binding.exportedName);
if (def?.returnType) {
filesWithGaps++;
break;
@ -413,11 +414,15 @@ async function runCrossFileBindingPropagation(
}
}
const importedReturns = buildImportedReturnTypes(filePath, ctx.namedImportMap, ctx.symbols);
const importedReturns = buildImportedReturnTypes(
filePath,
ctx.namedImportMap,
ctx.model.symbols,
);
const importedRawReturns = buildImportedRawReturnTypes(
filePath,
ctx.namedImportMap,
ctx.symbols,
ctx.model.symbols,
);
if (seeded.size === 0 && importedReturns.size === 0) continue;
if (!allPathSet.has(filePath)) continue;
@ -657,7 +662,7 @@ async function runChunkedParseAndResolve(
allORMQueries: ExtractedORMQuery[];
bindingAccumulator: BindingAccumulator;
}> {
const symbolTable = ctx.symbols;
const symbolTable = ctx.model.symbols;
const parseableScanned = scannedFiles.filter((f) => {
const lang = getLanguageFromFilename(f.path);
@ -978,7 +983,9 @@ async function runChunkedParseAndResolve(
// Build unified HeritageMap (parent lookup + implementor index) after all chunks.
const fullWorkerHeritageMap =
deferredWorkerHeritage.length > 0 ? buildHeritageMap(deferredWorkerHeritage, ctx) : undefined;
deferredWorkerHeritage.length > 0
? buildHeritageMap(deferredWorkerHeritage, ctx, getHeritageStrategyForLanguage)
: undefined;
if (deferredWorkerCalls.length > 0) {
await processCallsFromExtracted(
@ -1058,7 +1065,9 @@ async function runChunkedParseAndResolve(
}
// Build unified HeritageMap from all sequential heritage (parent lookup + implementor index).
const sequentialHeritageMap =
allSequentialHeritage.length > 0 ? buildHeritageMap(allSequentialHeritage, ctx) : undefined;
allSequentialHeritage.length > 0
? buildHeritageMap(allSequentialHeritage, ctx, getHeritageStrategyForLanguage)
: undefined;
// Pass 2: Process calls, heritage edges, fetch calls, and ORM queries per chunk.
// Reuse the file contents cached in Pass 1 instead of re-reading from disk.

View file

@ -1,439 +0,0 @@
import type { NodeLabel } from 'gitnexus-shared';
export const CLASS_TYPES = new Set([
'Class',
'Struct',
'Interface',
'Enum',
'Record',
// Traits are class-like for heritage resolution: PHP `use Trait;`, Rust
// `impl Trait for Struct`, and Scala traits all contribute methods to the
// hierarchy of their using/implementing type. Including Trait here lets
// buildHeritageMap resolve `h.parentName` to a Trait nodeId so the MRO
// walker can visit the trait and find its methods.
'Trait',
]);
/** Callable symbol types indexed in callableByName for Tier 3 resolution
* and D2 widen in call-processor.ts. Single source of truth — do not
* duplicate this set elsewhere. */
export const CALLABLE_TYPES = new Set([
'Function',
'Method',
'Constructor',
'Macro', // C/C++
'Delegate', // C#
]);
export interface SymbolDefinition {
nodeId: string;
filePath: string;
type: NodeLabel;
/** Canonical dot-separated qualified type name for class-like symbols
* (e.g. `App.Models.User`). Falls back to the simple symbol name when no
* package/namespace/module scope exists or no explicit qualified metadata is provided. */
qualifiedName?: string;
parameterCount?: number;
/** Number of required (non-optional, non-default) parameters.
* Enables range-based arity filtering: argCount >= requiredParameterCount && argCount <= parameterCount. */
requiredParameterCount?: number;
/** Per-parameter type names for overload disambiguation (e.g. ['int', 'String']).
* Populated when parameter types are resolvable from AST (any typed language).
* Used for disambiguation in overloading languages (Java, Kotlin, C#, C++). */
parameterTypes?: string[];
/** Raw return type text extracted from AST (e.g. 'User', 'Promise<User>') */
returnType?: string;
/** Declared type for non-callable symbols — fields/properties (e.g. 'Address', 'List<User>') */
declaredType?: string;
/** Links Method/Constructor/Property to owning Class/Struct/Trait nodeId */
ownerId?: string;
}
export interface SymbolTable {
/**
* Register a new symbol definition
*/
add: (
filePath: string,
name: string,
nodeId: string,
type: NodeLabel,
metadata?: {
parameterCount?: number;
requiredParameterCount?: number;
parameterTypes?: string[];
returnType?: string;
declaredType?: string;
ownerId?: string;
qualifiedName?: string;
},
) => void;
/**
* High Confidence: Look for a symbol specifically inside a file
* Returns the Node ID if found
*/
lookupExact: (filePath: string, name: string) => string | undefined;
/**
* High Confidence: Look for a symbol in a specific file, returning full definition.
* Includes type information needed for heritage resolution (Class vs Interface).
* Returns first matching definition — use lookupExactAll for overloaded methods.
*/
lookupExactFull: (filePath: string, name: string) => SymbolDefinition | undefined;
/**
* High Confidence: Look for ALL symbols with this name in a specific file.
* Returns all definitions, including overloaded methods with the same name.
* Used by resolution-context to pass all same-file overloads to candidate filtering.
*/
lookupExactAll: (filePath: string, name: string) => SymbolDefinition[];
/**
* Look up callable symbols (Function, Method, Constructor, Macro, Delegate) by name.
* O(1) via dedicated eagerly-populated index keyed by symbol name.
* Used by Tier 3 resolution and ReturnTypeLookup to resolve callee → return type.
*/
lookupCallableByName: (name: string) => SymbolDefinition[];
/**
* Look up a field/property by its owning class nodeId and field name.
* O(1) via dedicated eagerly-populated index keyed by `ownerNodeId\0fieldName`.
* Returns undefined when no matching property exists or the owner is ambiguous.
*/
lookupFieldByOwner: (ownerNodeId: string, fieldName: string) => SymbolDefinition | undefined;
/**
* Look up a method by its owning class nodeId and method name.
* O(1) via dedicated eagerly-populated index keyed by `ownerNodeId\0methodName`.
* For overloaded methods (same owner + name): returns the first match when all
* overloads share the same returnType, undefined when return types differ (ambiguous).
* Used by walkMixedChain for deterministic cross-class chain resolution.
*/
/**
* Lookup a method by owner class + name, optionally filtered by arity.
*
* When `argCount` is provided, overloads whose parameter count doesn't
* accommodate the call's argument count are filtered out before the
* returnType dedup runs. This lets D0 (`resolveMemberCall`) disambiguate
* arity-differing overloads (e.g. C++ `greet()` vs `greet(string)`) that
* would otherwise collide on the shared `ownerId + methodName` key.
*
* Same-arity, same-returnType overloads (e.g. `save(int)` vs `save(String)`,
* both returning `void`) still collapse to the first match — callers must
* gate D0 on overload concern before invoking this function for that case.
*/
lookupMethodByOwner: (
ownerNodeId: string,
methodName: string,
argCount?: number,
) => SymbolDefinition | undefined;
/**
* Look up class-like definitions (Class, Struct, Interface, Enum, Record) by name.
* O(1) via dedicated eagerly-populated index keyed by symbol name.
* Returns all matching definitions across files (e.g. partial classes).
* Used by Phase 1 semantic-model tasks to replace filtered global lookups.
*/
lookupClassByName: (name: string) => SymbolDefinition[];
/**
* Look up class-like definitions by canonical qualified name.
* Qualified names are normalized to dot-separated scope segments across languages,
* e.g. `App.Models.User`, `com.example.User`, or `Admin.User`.
* Top-level class-like symbols with no explicit scope are indexed under their simple name.
*/
lookupClassByQualifiedName: (qualifiedName: string) => SymbolDefinition[];
/**
* Look up Impl nodes by name.
* O(1) via dedicated eagerly-populated index keyed by symbol name.
* Used by Tier 3 resolution to include Rust impl blocks alongside
* class-like candidates so method lookups on `impl User { fn save() }` work
* correctly (Rust methods are indexed under the Impl nodeId, not the Struct).
*/
lookupImplByName: (name: string) => SymbolDefinition[];
/**
* Iterate all indexed file paths.
* Used by Tier 2b (package-scoped) resolution to walk files matching a
* package directory suffix without a global name scan.
*/
getFiles: () => IterableIterator<string>;
/**
* Debugging: See how many symbols are tracked
*/
getStats: () => {
fileCount: number;
};
/**
* Cleanup memory
*/
clear: () => void;
}
export const createSymbolTable = (): SymbolTable => {
// 1. File-Specific Index — stores full SymbolDefinition(s) for O(1) lookup.
// Structure: FilePath -> (SymbolName -> SymbolDefinition[])
// Array allows overloaded methods (same name, different signatures) to coexist.
const fileIndex = new Map<string, Map<string, SymbolDefinition[]>>();
// 2. Eagerly-populated Callable Index — maintained on add().
// Structure: SymbolName -> [Callable Definitions]
// Only Function, Method, Constructor, Macro, Delegate symbols are indexed.
const callableByName = new Map<string, SymbolDefinition[]>();
// 3. Eagerly-populated Field/Property Index — keyed by "ownerNodeId\0fieldName".
// Only Property symbols with ownerId and declaredType are indexed.
const fieldByOwner = new Map<string, SymbolDefinition>();
// 4. Eagerly-populated Method Index — keyed by "ownerNodeId\0methodName".
// Method symbols with ownerId are indexed. Supports overloads (array values).
const methodByOwner = new Map<string, SymbolDefinition[]>();
// 5. Eagerly-populated Class-type Index — keyed by symbol name.
// Only Class, Struct, Interface, Enum, Record symbols are indexed.
const classByName = new Map<string, SymbolDefinition[]>();
const classByQualifiedName = new Map<string, SymbolDefinition[]>();
// 6. Eagerly-populated Impl Index — keyed by symbol name.
// Rust impl blocks (type 'Impl') are stored here to keep them out of
// classByName (which drives heritage resolution) while still being
// reachable from Tier 3 resolution for method lookup.
const implByName = new Map<string, SymbolDefinition[]>();
// Use the module-level CALLABLE_TYPES constant (exported for call-processor.ts).
const add = (
filePath: string,
name: string,
nodeId: string,
type: NodeLabel,
metadata?: {
parameterCount?: number;
requiredParameterCount?: number;
parameterTypes?: string[];
returnType?: string;
declaredType?: string;
ownerId?: string;
qualifiedName?: string;
},
) => {
const qualifiedName = CLASS_TYPES.has(type)
? (metadata?.qualifiedName ?? name)
: metadata?.qualifiedName;
const def: SymbolDefinition = {
nodeId,
filePath,
type,
...(qualifiedName !== undefined ? { qualifiedName } : {}),
...(metadata?.parameterCount !== undefined
? { parameterCount: metadata.parameterCount }
: {}),
...(metadata?.requiredParameterCount !== undefined
? { requiredParameterCount: metadata.requiredParameterCount }
: {}),
...(metadata?.parameterTypes !== undefined
? { parameterTypes: metadata.parameterTypes }
: {}),
...(metadata?.returnType !== undefined ? { returnType: metadata.returnType } : {}),
...(metadata?.declaredType !== undefined ? { declaredType: metadata.declaredType } : {}),
...(metadata?.ownerId !== undefined ? { ownerId: metadata.ownerId } : {}),
};
// A. Add to File Index (shared reference — zero additional memory)
if (!fileIndex.has(filePath)) {
fileIndex.set(filePath, new Map());
}
const fileMap = fileIndex.get(filePath)!;
if (!fileMap.has(name)) {
fileMap.set(name, [def]);
} else {
fileMap.get(name)!.push(def);
}
// B. Properties go to fieldByOwner index only — skip other indexes to prevent
// namespace pollution for common names like 'id', 'name', 'type'.
// Index ALL properties (even without declaredType) so write-access tracking
// can resolve field ownership for dynamically-typed languages (Ruby, JS).
if (type === 'Property' && metadata?.ownerId) {
fieldByOwner.set(`${metadata.ownerId}\0${name}`, def);
// Still add to fileIndex above (for lookupExact), but skip other indexes
return;
}
// C. Methods, constructors, and ownerId-bound Functions go to
// methodByOwner index.
//
// Some language extractors emit class methods as `Function` with an
// `ownerId` — notably Python (`def method(self):` inside a class body),
// Rust trait methods, and Kotlin object/companion methods. Treating
// `Function` with ownerId the same as `Method` here makes D0
// (`resolveMemberCall`) work uniformly across all supported languages
// instead of silently falling through to D1-D4 widening.
if ((type === 'Method' || type === 'Constructor' || type === 'Function') && metadata?.ownerId) {
const key = `${metadata.ownerId}\0${name}`;
const existing = methodByOwner.get(key);
if (existing) {
existing.push(def);
} else {
methodByOwner.set(key, [def]);
}
}
// C2. Class-like types go to classByName index.
if (CLASS_TYPES.has(type)) {
const existing = classByName.get(name);
if (existing) {
existing.push(def);
} else {
classByName.set(name, [def]);
}
const qualifiedKey = qualifiedName ?? name;
const qualifiedMatches = classByQualifiedName.get(qualifiedKey);
if (qualifiedMatches) {
qualifiedMatches.push(def);
} else {
classByQualifiedName.set(qualifiedKey, [def]);
}
}
// C3. Rust Impl blocks go to implByName (separate from classByName to avoid
// polluting heritage resolution with Impl nodes as parent candidates).
if (type === 'Impl') {
const existing = implByName.get(name);
if (existing) {
existing.push(def);
} else {
implByName.set(name, [def]);
}
}
// D. Eagerly maintain callable index (like classByName, implByName).
if (CALLABLE_TYPES.has(type)) {
const existing = callableByName.get(name);
if (existing) {
existing.push(def);
} else {
callableByName.set(name, [def]);
}
}
};
const lookupExact = (filePath: string, name: string): string | undefined => {
const defs = fileIndex.get(filePath)?.get(name);
return defs?.[0]?.nodeId;
};
const lookupExactFull = (filePath: string, name: string): SymbolDefinition | undefined => {
const defs = fileIndex.get(filePath)?.get(name);
return defs?.[0];
};
const lookupExactAll = (filePath: string, name: string): SymbolDefinition[] => {
return fileIndex.get(filePath)?.get(name) ?? [];
};
const lookupCallableByName = (name: string): SymbolDefinition[] => {
return callableByName.get(name) ?? [];
};
const lookupFieldByOwner = (
ownerNodeId: string,
fieldName: string,
): SymbolDefinition | undefined => {
return fieldByOwner.get(`${ownerNodeId}\0${fieldName}`);
};
const lookupMethodByOwner = (
ownerNodeId: string,
methodName: string,
argCount?: number,
): SymbolDefinition | undefined => {
const defs = methodByOwner.get(`${ownerNodeId}\0${methodName}`);
if (!defs || defs.length === 0) return undefined;
// Arity narrowing: when an argCount is provided and there are multiple
// overloads, keep only those whose parameterCount can accommodate the
// call. This resolves arity-differing overloads (e.g. C++ `greet()` vs
// `greet(string)`) that share the same `ownerId + methodName` key.
//
// Candidates with `parameterCount === undefined` (extractor didn't
// populate the count — typically variadic or unknown) are retained
// conservatively so that legitimate variadic matches still resolve.
let pool = defs;
if (argCount !== undefined && defs.length > 1) {
const arityMatched = defs.filter((d) => {
if (d.parameterCount === undefined) return true;
const min = d.requiredParameterCount ?? d.parameterCount;
return argCount >= min && argCount <= d.parameterCount;
});
// Only adopt the arity-narrowed pool when it found matches; if arity
// rules out every candidate, fall back to the unfiltered set so the
// caller's fuzzy path still has something to work with.
if (arityMatched.length > 0) pool = arityMatched;
}
if (pool.length === 1) return pool[0];
// Multiple overloads after arity narrowing: return first if all share
// the same defined returnType (safe for chain resolution), undefined if
// return types differ (truly ambiguous — can't determine which overload).
const firstReturnType = pool[0].returnType;
if (firstReturnType === undefined) return undefined;
for (let i = 1; i < pool.length; i++) {
if (pool[i].returnType !== firstReturnType) return undefined;
}
return pool[0];
};
const lookupClassByName = (name: string): SymbolDefinition[] => {
return classByName.get(name) ?? [];
};
const lookupClassByQualifiedName = (qualifiedName: string): SymbolDefinition[] => {
return classByQualifiedName.get(qualifiedName) ?? [];
};
const lookupImplByName = (name: string): SymbolDefinition[] => {
return implByName.get(name) ?? [];
};
/** Returns a live iterator over all indexed file paths (fileIndex.keys()).
* The iterator is invalidated if add() changes fileIndex.size during
* iteration (ES2015 Map spec). Safe in the current pipeline because all
* symbols are added before resolution begins. */
const getFiles = (): IterableIterator<string> => fileIndex.keys();
const getStats = () => ({
fileCount: fileIndex.size,
});
const clear = () => {
fileIndex.clear();
callableByName.clear();
fieldByOwner.clear();
methodByOwner.clear();
classByName.clear();
classByQualifiedName.clear();
implByName.clear();
};
return {
add,
lookupExact,
lookupExactFull,
lookupExactAll,
lookupCallableByName,
lookupFieldByOwner,
lookupMethodByOwner,
lookupClassByName,
lookupClassByQualifiedName,
lookupImplByName,
getFiles,
getStats,
clear,
};
};

View file

@ -21,7 +21,7 @@ import {
stripNullable,
extractReturnTypeName,
} from './type-extractors/shared.js';
import type { SymbolTable } from './symbol-table.js';
import type { SemanticModel } from './model/semantic-model.js';
import type { NodeLabel } from 'gitnexus-shared';
/**
@ -416,11 +416,8 @@ const findEnclosingScopeKey = (
* Only `.has()` is exposed — the SymbolTable doesn't support iteration.
* Results are memoized to avoid redundant class-index scans across declarations.
*/
const createClassNameLookup = (
localNames: Set<string>,
symbolTable?: SymbolTable,
): ClassNameLookup => {
if (!symbolTable) return localNames;
const createClassNameLookup = (localNames: Set<string>, model?: SemanticModel): ClassNameLookup => {
if (!model) return localNames;
const memo = new Map<string, boolean>();
return {
@ -428,7 +425,7 @@ const createClassNameLookup = (
if (localNames.has(name)) return true;
const cached = memo.get(name);
if (cached !== undefined) return cached;
const result = symbolTable
const result = model.types
.lookupClassByName(name)
.some((def) => def.type === 'Class' || def.type === 'Enum' || def.type === 'Struct');
memo.set(name, result);
@ -481,20 +478,20 @@ const CLASS_LIKE_TYPES = new Set(['Class', 'Struct', 'Interface']);
type ClassDefRef = { nodeId: string; type: string; filePath: string };
const lookupClassDefsByName = (
symbolTable: SymbolTable,
model: SemanticModel,
name: string,
allowedTypes: ReadonlySet<string> = CLASS_LIKE_TYPES,
): ClassDefRef[] => symbolTable.lookupClassByName(name).filter((d) => allowedTypes.has(d.type));
): ClassDefRef[] => model.types.lookupClassByName(name).filter((d) => allowedTypes.has(d.type));
/** Memoize class definition lookups during fixpoint iteration.
* SymbolTable is immutable during type resolution, so results never change.
* Eliminates redundant array allocations + filter scans across iterations. */
const createClassDefCache = (symbolTable?: SymbolTable) => {
const createClassDefCache = (model?: SemanticModel) => {
const cache = new Map<string, ClassDefRef[]>();
return (typeName: string) => {
let result = cache.get(typeName);
if (result === undefined) {
result = symbolTable ? lookupClassDefsByName(symbolTable, typeName) : [];
result = model ? lookupClassDefsByName(model, typeName) : [];
cache.set(typeName, result);
}
return result;
@ -615,22 +612,22 @@ const resolveFieldType = (
receiver: string,
field: string,
scopeEnv: ReadonlyMap<string, string>,
symbolTable?: SymbolTable,
model?: SemanticModel,
getClassDefs?: (typeName: string) => ClassDefRef[],
parentMap?: ReadonlyMap<string, readonly string[]>,
): string | undefined => {
if (!symbolTable) return undefined;
if (!model) return undefined;
const receiverType = scopeEnv.get(receiver);
if (!receiverType) return undefined;
const lookup = getClassDefs ?? ((name: string) => lookupClassDefsByName(symbolTable, name));
const lookup = getClassDefs ?? ((name: string) => lookupClassDefsByName(model, name));
const classDefs = lookup(receiverType);
if (classDefs.length !== 1) return undefined;
// Direct lookup first
const fieldDef = symbolTable.lookupFieldByOwner(classDefs[0].nodeId, field);
const fieldDef = model.fields.lookupFieldByOwner(classDefs[0].nodeId, field);
if (fieldDef?.declaredType) return extractReturnTypeName(fieldDef.declaredType);
// MRO parent chain walking on miss
const inherited = walkParentChain(receiverType, parentMap, lookup, (nodeId) => {
const f = symbolTable.lookupFieldByOwner(nodeId, field);
const f = model.fields.lookupFieldByOwner(nodeId, field);
return f?.declaredType ? extractReturnTypeName(f.declaredType) : undefined;
});
return inherited;
@ -644,30 +641,30 @@ const resolveMethodReturnType = (
receiver: string,
method: string,
scopeEnv: ReadonlyMap<string, string>,
symbolTable?: SymbolTable,
model?: SemanticModel,
getClassDefs?: (typeName: string) => ClassDefRef[],
parentMap?: ReadonlyMap<string, readonly string[]>,
): string | undefined => {
if (!symbolTable) return undefined;
if (!model) return undefined;
let receiverType = scopeEnv.get(receiver);
// When substituteThisReceiver replaced $this/self with the enclosing class name,
// the receiver IS the type — look it up directly as a class name.
if (!receiverType) {
const lookup = getClassDefs ?? ((name: string) => lookupClassDefsByName(symbolTable, name));
const lookup = getClassDefs ?? ((name: string) => lookupClassDefsByName(model, name));
if (lookup(receiver).length > 0) receiverType = receiver;
}
if (!receiverType) return undefined;
const lookup = getClassDefs ?? ((name: string) => lookupClassDefsByName(symbolTable, name));
const lookup = getClassDefs ?? ((name: string) => lookupClassDefsByName(model, name));
const classDefs = lookup(receiverType);
if (classDefs.length === 0) return undefined;
// Direct lookup first
const directMethodLookups = classDefs.map((d) => ({
classDef: d,
methodDef: symbolTable.lookupMethodByOwner(d.nodeId, method),
methodDef: model.methods.lookupMethodByOwner(d.nodeId, method),
}));
const hasAmbiguousDirectLookup = directMethodLookups.some(({ classDef, methodDef }) => {
if (methodDef) return false;
return symbolTable
return model.symbols
.lookupExactAll(classDef.filePath, method)
.some((d) => d.ownerId === classDef.nodeId);
});
@ -681,7 +678,7 @@ const resolveMethodReturnType = (
// MRO parent chain walking on miss
if (methods.length === 0) {
const inherited = walkParentChain(receiverType, parentMap, lookup, (nodeId) => {
const parentMethod = symbolTable.lookupMethodByOwner(nodeId, method);
const parentMethod = model.methods.lookupMethodByOwner(nodeId, method);
if (!parentMethod?.returnType) return undefined;
return extractReturnTypeName(parentMethod.returnType);
});
@ -707,11 +704,11 @@ const resolveFixpointBindings = (
pendingItems: Array<{ scope: string } & PendingAssignment>,
env: TypeEnv,
returnTypeLookup: ReturnTypeLookup,
symbolTable?: SymbolTable,
model?: SemanticModel,
parentMap?: ReadonlyMap<string, readonly string[]>,
): void => {
if (pendingItems.length === 0) return;
const getClassDefs = createClassDefCache(symbolTable);
const getClassDefs = createClassDefCache(model);
const resolved = new Set<number>();
for (let iter = 0; iter < MAX_FIXPOINT_ITERATIONS; iter++) {
let changed = false;
@ -740,7 +737,7 @@ const resolveFixpointBindings = (
item.receiver,
item.field,
scopeEnv,
symbolTable,
model,
getClassDefs,
parentMap,
);
@ -750,7 +747,7 @@ const resolveFixpointBindings = (
item.receiver,
item.method,
scopeEnv,
symbolTable,
model,
getClassDefs,
parentMap,
);
@ -785,7 +782,7 @@ const resolveFixpointBindings = (
* Uses an options object to allow future extensions without positional parameter sprawl.
*/
export interface BuildTypeEnvOptions {
symbolTable?: SymbolTable;
model?: SemanticModel;
parentMap?: ReadonlyMap<string, readonly string[]>;
/** Pre-resolved bindings from upstream files (Phase 14).
* Seeded into FILE_SCOPE after walk() for names with no local binding.
@ -837,7 +834,7 @@ export const buildTypeEnv = (
enclosingClassNameCache.clear();
enclosingParentClassNameCache.clear();
const symbolTable = options?.symbolTable;
const model = options?.model;
const parentMap = options?.parentMap;
const extractFuncNameHook = options?.extractFunctionName;
const env: TypeEnv = new Map();
@ -848,7 +845,7 @@ export const buildTypeEnv = (
// e.g., `Animal a = new Dog()` → constructorTypeMap.set('func@42\0a', 'Dog')
const constructorTypeMap = new Map<string, string>();
const localClassNames = new Set<string>();
const classNames = createClassNameLookup(localClassNames, symbolTable);
const classNames = createClassNameLookup(localClassNames, model);
const provider = getProvider(language);
const config = provider.typeConfig;
const bindings: ConstructorBinding[] = [];
@ -856,29 +853,47 @@ export const buildTypeEnv = (
// Build ReturnTypeLookup: SymbolTable is authoritative when it has an unambiguous match.
// Cross-file importedReturnTypes are consulted ONLY when SymbolTable has 0 matches.
// Ambiguous (2+) → undefined, no cross-file fallback (conservative, local-first principle).
// Post-A4 Unit 4: callableByName no longer holds Method/Constructor, so
// for-loop binding inference must also consult methodsByName to find
// return types on class methods (e.g. `user.getItems()` iteration).
// Take `model` as an explicit argument so the non-null precondition
// is visible at the type level. Callers must enter these via an
// `if (model)` guard on their side and pass the narrowed reference.
const getCallableUnionCount = (m: SemanticModel, callee: string): number => {
return (
m.symbols.lookupCallableByName(callee).length + m.methods.lookupMethodByName(callee).length
);
};
const getFirstCallable = (m: SemanticModel, callee: string) => {
const free = m.symbols.lookupCallableByName(callee);
if (free.length > 0) return free[0];
const methods = m.methods.lookupMethodByName(callee);
return methods.length > 0 ? methods[0] : undefined;
};
const returnTypeLookup: ReturnTypeLookup = {
lookupReturnType(callee: string): string | undefined {
// SymbolTable is authoritative when it has an unambiguous match
if (symbolTable) {
if (model) {
if (provider.isBuiltInName(callee)) return undefined;
const callables = symbolTable.lookupCallableByName(callee);
if (callables.length === 1) {
const rawReturn = callables[0].returnType;
const count = getCallableUnionCount(model, callee);
if (count === 1) {
const rawReturn = getFirstCallable(model, callee)?.returnType;
if (rawReturn) return extractReturnTypeName(rawReturn);
}
// Ambiguous (2+) → return undefined (conservative, no cross-file fallback)
if (callables.length > 1) return undefined;
if (count > 1) return undefined;
}
// No match (0 results or no symbolTable) → fall back to cross-file
return options?.importedReturnTypes?.get(callee);
},
lookupRawReturnType(callee: string): string | undefined {
if (symbolTable) {
if (model) {
if (provider.isBuiltInName(callee)) return undefined;
const callables = symbolTable.lookupCallableByName(callee);
if (callables.length === 1) return callables[0].returnType;
const count = getCallableUnionCount(model, callee);
if (count === 1) return getFirstCallable(model, callee)?.returnType;
// Ambiguous (2+) → return undefined (conservative, no cross-file fallback)
if (callables.length > 1) return undefined;
if (count > 1) return undefined;
}
// Cross-file fallback uses importedRawReturnTypes (raw declared types, e.g., 'User[]')
// NOT importedReturnTypes (which contains processed/simple types via extractReturnTypeName)
@ -1229,7 +1244,7 @@ export const buildTypeEnv = (
seedImportedBindings(env, options.importedBindings);
}
resolveFixpointBindings(pendingItems, env, returnTypeLookup, symbolTable, parentMap);
resolveFixpointBindings(pendingItems, env, returnTypeLookup, model, parentMap);
// Post-fixpoint for-loop replay (Phase 10 / ex-9B loop-fixpoint bridge):
// For-loop nodes whose iterables were unresolved at walk-time may now be
@ -1256,7 +1271,7 @@ export const buildTypeEnv = (
return scopeEnv && !scopeEnv.has(item.lhs);
});
if (unresolvedBefore.length > 0) {
resolveFixpointBindings(unresolvedBefore, env, returnTypeLookup, symbolTable);
resolveFixpointBindings(unresolvedBefore, env, returnTypeLookup, model);
}
}

View file

@ -15,7 +15,8 @@ import { createRequire } from 'node:module';
import { SupportedLanguages } from 'gitnexus-shared';
import { getProvider } from '../languages/index.js';
import { getTreeSitterBufferSize, TREE_SITTER_MAX_BUFFER } from '../constants.js';
import type { SymbolTable } from '../symbol-table.js';
import type { SymbolTableReader } from '../model/symbol-table.js';
import type { ExtractedHeritage } from '../model/heritage-map.js';
/** Language grammar type accepted by Parser.setLanguage(). */
type TreeSitterLanguage = Parameters<typeof Parser.prototype.setLanguage>[0];
@ -181,13 +182,8 @@ export interface ExtractedAssignment {
receiverTypeName?: string;
}
export interface ExtractedHeritage {
filePath: string;
className: string;
parentName: string;
/** 'extends' | 'implements' | 'trait-impl' | 'include' | 'extend' | 'prepend' */
kind: string;
}
// `ExtractedHeritage` now lives in `../model/heritage-map.ts` and is
// re-exported at the top of this file.
export interface ExtractedRoute {
filePath: string;
@ -459,14 +455,20 @@ function findClassNodeByQualifiedName(node: SyntaxNode): SyntaxNode | null {
/**
* Minimal no-op SymbolTable stub for FieldExtractorContext in the worker.
* Field extraction only uses symbolTable.lookupExactAll for optional type resolution —
* returning [] causes the extractor to use the raw type string, which is fine for us.
* Field extraction only uses symbolTable.lookupExactAll for optional type
* resolution — returning [] causes the extractor to use the raw type
* string, which is fine for us. Every other method is a no-op so the
* stub remains safe if a future FieldExtractor consults it through the
* full {@link SymbolTableReader} surface.
*/
const NOOP_SYMBOL_TABLE = {
lookupExactAll: () => [],
const NOOP_SYMBOL_TABLE: SymbolTableReader = {
lookupExact: () => undefined,
lookupExactFull: () => undefined,
} as unknown as SymbolTable;
lookupExactAll: () => [],
lookupCallableByName: () => [],
getFiles: () => [][Symbol.iterator](),
getStats: () => ({ fileCount: 0 }),
};
/**
* Get (or extract and cache) field info for a class node.

View file

@ -8,7 +8,7 @@ import {
} from '../../src/core/ingestion/filesystem-walker.js';
import { processParsing } from '../../src/core/ingestion/parsing-processor.js';
import { createKnowledgeGraph } from '../../src/core/graph/graph.js';
import { createSymbolTable } from '../../src/core/ingestion/symbol-table.js';
import { createSymbolTable } from '../../src/core/ingestion/model/symbol-table.js';
import { createASTCache } from '../../src/core/ingestion/ast-cache.js';
import { isLanguageAvailable } from '../../src/core/tree-sitter/parser-loader.js';
import { SupportedLanguages } from '../../src/config/supported-languages.js';

View file

@ -1,13 +1,18 @@
import { describe, expect, it } from 'vitest';
import { createASTCache } from '../../src/core/ingestion/ast-cache.js';
import { processParsing } from '../../src/core/ingestion/parsing-processor.js';
import { createSymbolTable } from '../../src/core/ingestion/symbol-table.js';
import { createSemanticModel } from '../../src/core/ingestion/model/semantic-model.js';
import { createKnowledgeGraph } from '../../src/core/graph/graph.js';
describe('qualified class lookups', () => {
it('derives canonical dot-separated names from namespaces, packages, and modules', async () => {
const graph = createKnowledgeGraph();
const symbolTable = createSymbolTable();
const model = createSemanticModel();
// model.symbols is the SymbolTable leaf that processParsing writes into.
// Fan-out writes still reach model.types / model.methods / model.fields
// via SemanticModel's wrappedAdd — this alias is purely for convenience
// at call sites that want the SymbolTable-shaped interface.
const symbolTable = model.symbols;
const astCache = createASTCache();
await processParsing(
@ -34,33 +39,38 @@ describe('qualified class lookups', () => {
astCache,
);
const userMatches = symbolTable.lookupClassByName('User');
const userMatches = model.types.lookupClassByName('User');
expect(userMatches).toHaveLength(3);
expect(userMatches.map((match) => match.qualifiedName).sort()).toEqual(
['Admin.User', 'Data.Auth.User', 'Services.Auth.User'].sort(),
);
const servicesUser = symbolTable.lookupClassByQualifiedName('Services.Auth.User');
const servicesUser = model.types.lookupClassByQualifiedName('Services.Auth.User');
expect(servicesUser).toHaveLength(1);
expect(servicesUser[0].filePath).toBe('src/Services/User.cs');
expect(servicesUser[0].qualifiedName).toBe('Services.Auth.User');
const dataUser = symbolTable.lookupClassByQualifiedName('Data.Auth.User');
const dataUser = model.types.lookupClassByQualifiedName('Data.Auth.User');
expect(dataUser).toHaveLength(1);
expect(dataUser[0].filePath).toBe('src/Data/User.cs');
const javaConfig = symbolTable.lookupClassByQualifiedName('com.example.models.Config');
const javaConfig = model.types.lookupClassByQualifiedName('com.example.models.Config');
expect(javaConfig).toHaveLength(1);
expect(javaConfig[0].qualifiedName).toBe('com.example.models.Config');
const rubyUser = symbolTable.lookupClassByQualifiedName('Admin.User');
const rubyUser = model.types.lookupClassByQualifiedName('Admin.User');
expect(rubyUser).toHaveLength(1);
expect(rubyUser[0].qualifiedName).toBe('Admin.User');
});
it('falls back to the simple name for top-level class-like symbols', async () => {
const graph = createKnowledgeGraph();
const symbolTable = createSymbolTable();
const model = createSemanticModel();
// model.symbols is the SymbolTable leaf that processParsing writes into.
// Fan-out writes still reach model.types / model.methods / model.fields
// via SemanticModel's wrappedAdd — this alias is purely for convenience
// at call sites that want the SymbolTable-shaped interface.
const symbolTable = model.symbols;
const astCache = createASTCache();
await processParsing(
@ -70,11 +80,11 @@ describe('qualified class lookups', () => {
astCache,
);
const simpleMatches = symbolTable.lookupClassByName('User');
const simpleMatches = model.types.lookupClassByName('User');
expect(simpleMatches).toHaveLength(1);
expect(simpleMatches[0].qualifiedName).toBe('User');
const matches = symbolTable.lookupClassByQualifiedName('User');
const matches = model.types.lookupClassByQualifiedName('User');
expect(matches).toHaveLength(1);
expect(matches[0].qualifiedName).toBe('User');
});

View file

@ -4,7 +4,7 @@ import {
extractReceiverName,
} from '../../src/core/ingestion/utils/call-analysis.js';
import type { SyntaxNode } from '../../src/core/ingestion/utils/ast-helpers.js';
import { createSymbolTable } from '../../src/core/ingestion/symbol-table.js';
import { createSymbolTable } from '../../src/core/ingestion/model/symbol-table.js';
import Parser from 'tree-sitter';
import TypeScript from 'tree-sitter-typescript';
import Python from 'tree-sitter-python';
@ -452,9 +452,13 @@ describe('ownerId on SymbolDefinition', () => {
expect(def!.ownerId).toBeUndefined();
});
it('propagates ownerId through lookupCallableByName', () => {
it('propagates ownerId through a free Function registration', () => {
// Post-A4 Unit 4, Method is no longer in FREE_CALLABLE_TYPES so this test
// exercises ownerId propagation through the free-callable index using
// a Function label. Method-with-ownerId propagation is covered via
// methodsByName in method-registry.test.ts.
const st = createSymbolTable();
st.add('src/foo.ts', 'save', 'Method:src/foo.ts:save', 'Method', {
st.add('src/foo.ts', 'save', 'Function:src/foo.ts:save', 'Function', {
ownerId: 'Class:src/foo.ts:User',
});

View file

@ -7,22 +7,22 @@ import {
extractConsumerAccessedKeys,
processNextjsFetchRoutes,
} from '../../src/core/ingestion/call-processor.js';
import { buildHeritageMap } from '../../src/core/ingestion/heritage-map.js';
import { buildHeritageMap } from '../../src/core/ingestion/model/heritage-map.js';
import { createASTCache } from '../../src/core/ingestion/ast-cache.js';
import { extractReturnTypeName } from '../../src/core/ingestion/type-extractors/shared.js';
import {
createResolutionContext,
type ResolutionContext,
} from '../../src/core/ingestion/resolution-context.js';
} from '../../src/core/ingestion/model/resolution-context.js';
import { createKnowledgeGraph } from '../../src/core/graph/graph.js';
import { BindingAccumulator } from '../../src/core/ingestion/binding-accumulator.js';
import type {
ExtractedAssignment,
ExtractedCall,
ExtractedFetchCall,
ExtractedHeritage,
FileConstructorBindings,
} from '../../src/core/ingestion/workers/parse-worker.js';
import type { ExtractedHeritage } from '../../src/core/ingestion/model/heritage-map.js';
describe('processCallsFromExtracted', () => {
let graph: ReturnType<typeof createKnowledgeGraph>;
@ -34,7 +34,7 @@ describe('processCallsFromExtracted', () => {
});
it('creates CALLS relationship for same-file resolution', async () => {
ctx.symbols.add('src/index.ts', 'helper', 'Function:src/index.ts:helper', 'Function');
ctx.model.symbols.add('src/index.ts', 'helper', 'Function:src/index.ts:helper', 'Function');
const calls: ExtractedCall[] = [
{
@ -55,7 +55,7 @@ describe('processCallsFromExtracted', () => {
});
it('creates CALLS relationship for import-resolved resolution', async () => {
ctx.symbols.add('src/utils.ts', 'format', 'Function:src/utils.ts:format', 'Function');
ctx.model.symbols.add('src/utils.ts', 'format', 'Function:src/utils.ts:format', 'Function');
ctx.importMap.set('src/index.ts', new Set(['src/utils.ts']));
const calls: ExtractedCall[] = [
@ -75,7 +75,12 @@ describe('processCallsFromExtracted', () => {
});
it('resolves unique global symbol with moderate confidence', async () => {
ctx.symbols.add('src/other.ts', 'uniqueFunc', 'Function:src/other.ts:uniqueFunc', 'Function');
ctx.model.symbols.add(
'src/other.ts',
'uniqueFunc',
'Function:src/other.ts:uniqueFunc',
'Function',
);
const calls: ExtractedCall[] = [
{
@ -94,8 +99,8 @@ describe('processCallsFromExtracted', () => {
});
it('refuses ambiguous global symbols — no CALLS edge created', async () => {
ctx.symbols.add('src/a.ts', 'render', 'Function:src/a.ts:render', 'Function');
ctx.symbols.add('src/b.ts', 'render', 'Function:src/b.ts:render', 'Function');
ctx.model.symbols.add('src/a.ts', 'render', 'Function:src/a.ts:render', 'Function');
ctx.model.symbols.add('src/b.ts', 'render', 'Function:src/b.ts:render', 'Function');
const calls: ExtractedCall[] = [
{
@ -125,7 +130,7 @@ describe('processCallsFromExtracted', () => {
});
it('refuses non-callable symbols even when the name resolves', async () => {
ctx.symbols.add('src/index.ts', 'Widget', 'Class:src/index.ts:Widget', 'Class');
ctx.model.symbols.add('src/index.ts', 'Widget', 'Class:src/index.ts:Widget', 'Class');
const calls: ExtractedCall[] = [
{
@ -140,7 +145,7 @@ describe('processCallsFromExtracted', () => {
});
it('refuses CALLS edges to Interface symbols', async () => {
ctx.symbols.add(
ctx.model.symbols.add(
'src/types.ts',
'Serializable',
'Interface:src/types.ts:Serializable',
@ -161,7 +166,7 @@ describe('processCallsFromExtracted', () => {
});
it('refuses CALLS edges to Enum symbols', async () => {
ctx.symbols.add('src/status.ts', 'Status', 'Enum:src/status.ts:Status', 'Enum');
ctx.model.symbols.add('src/status.ts', 'Status', 'Enum:src/status.ts:Status', 'Enum');
ctx.importMap.set('src/index.ts', new Set(['src/status.ts']));
const calls: ExtractedCall[] = [
@ -177,8 +182,8 @@ describe('processCallsFromExtracted', () => {
});
it('prefers same-file over import-resolved', async () => {
ctx.symbols.add('src/index.ts', 'render', 'Function:src/index.ts:render', 'Function');
ctx.symbols.add('src/utils.ts', 'render', 'Function:src/utils.ts:render', 'Function');
ctx.model.symbols.add('src/index.ts', 'render', 'Function:src/index.ts:render', 'Function');
ctx.model.symbols.add('src/utils.ts', 'render', 'Function:src/utils.ts:render', 'Function');
ctx.importMap.set('src/index.ts', new Set(['src/utils.ts']));
const calls: ExtractedCall[] = [
@ -198,8 +203,8 @@ describe('processCallsFromExtracted', () => {
});
it('handles multiple calls from the same file', async () => {
ctx.symbols.add('src/index.ts', 'foo', 'Function:src/index.ts:foo', 'Function');
ctx.symbols.add('src/index.ts', 'bar', 'Function:src/index.ts:bar', 'Function');
ctx.model.symbols.add('src/index.ts', 'foo', 'Function:src/index.ts:foo', 'Function');
ctx.model.symbols.add('src/index.ts', 'bar', 'Function:src/index.ts:bar', 'Function');
const calls: ExtractedCall[] = [
{ filePath: 'src/index.ts', calledName: 'foo', sourceId: 'Function:src/index.ts:main' },
@ -211,10 +216,10 @@ describe('processCallsFromExtracted', () => {
});
it('uses arity to disambiguate import-scoped callable candidates', async () => {
ctx.symbols.add('src/logger.ts', 'log', 'Function:src/logger.ts:log', 'Function', {
ctx.model.symbols.add('src/logger.ts', 'log', 'Function:src/logger.ts:log', 'Function', {
parameterCount: 0,
});
ctx.symbols.add('src/formatter.ts', 'log', 'Function:src/formatter.ts:log', 'Function', {
ctx.model.symbols.add('src/formatter.ts', 'log', 'Function:src/formatter.ts:log', 'Function', {
parameterCount: 1,
});
ctx.importMap.set('src/index.ts', new Set(['src/logger.ts', 'src/formatter.ts']));
@ -237,10 +242,10 @@ describe('processCallsFromExtracted', () => {
});
it('refuses ambiguous call targets when arity does not produce a unique match', async () => {
ctx.symbols.add('src/logger.ts', 'log', 'Function:src/logger.ts:log', 'Function', {
ctx.model.symbols.add('src/logger.ts', 'log', 'Function:src/logger.ts:log', 'Function', {
parameterCount: 1,
});
ctx.symbols.add('src/formatter.ts', 'log', 'Function:src/formatter.ts:log', 'Function', {
ctx.model.symbols.add('src/formatter.ts', 'log', 'Function:src/formatter.ts:log', 'Function', {
parameterCount: 1,
});
ctx.importMap.set('src/index.ts', new Set(['src/logger.ts', 'src/formatter.ts']));
@ -259,7 +264,7 @@ describe('processCallsFromExtracted', () => {
});
it('calls progress callback', async () => {
ctx.symbols.add('src/index.ts', 'foo', 'Function:src/index.ts:foo', 'Function');
ctx.model.symbols.add('src/index.ts', 'foo', 'Function:src/index.ts:foo', 'Function');
const calls: ExtractedCall[] = [
{ filePath: 'src/index.ts', calledName: 'foo', sourceId: 'Function:src/index.ts:main' },
@ -279,7 +284,7 @@ describe('processCallsFromExtracted', () => {
// ---- Constructor-aware resolution (Phase 2) ----
it('resolves constructor call to Class when no Constructor node exists', async () => {
ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class');
ctx.model.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class');
ctx.importMap.set('src/index.ts', new Set(['src/models.ts']));
const calls: ExtractedCall[] = [
@ -300,10 +305,16 @@ describe('processCallsFromExtracted', () => {
});
it('resolves constructor call to Constructor node over Class node', async () => {
ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class');
ctx.symbols.add('src/models.ts', 'User', 'Constructor:src/models.ts:User', 'Constructor', {
parameterCount: 1,
});
ctx.model.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class');
ctx.model.symbols.add(
'src/models.ts',
'User',
'Constructor:src/models.ts:User',
'Constructor',
{
parameterCount: 1,
},
);
ctx.importMap.set('src/index.ts', new Set(['src/models.ts']));
const calls: ExtractedCall[] = [
@ -324,7 +335,7 @@ describe('processCallsFromExtracted', () => {
});
it('refuses Class target without callForm=constructor (existing behavior)', async () => {
ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class');
ctx.model.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class');
ctx.importMap.set('src/index.ts', new Set(['src/models.ts']));
const calls: ExtractedCall[] = [
@ -342,7 +353,7 @@ describe('processCallsFromExtracted', () => {
});
it('constructor call falls back to callable types when no Constructor/Class found', async () => {
ctx.symbols.add('src/utils.ts', 'Widget', 'Function:src/utils.ts:Widget', 'Function');
ctx.model.symbols.add('src/utils.ts', 'Widget', 'Function:src/utils.ts:Widget', 'Function');
ctx.importMap.set('src/index.ts', new Set(['src/utils.ts']));
const calls: ExtractedCall[] = [
@ -362,12 +373,24 @@ describe('processCallsFromExtracted', () => {
});
it('constructor arity filtering narrows overloaded constructors', async () => {
ctx.symbols.add('src/models.ts', 'User', 'Constructor:src/models.ts:User(0)', 'Constructor', {
parameterCount: 0,
});
ctx.symbols.add('src/models.ts', 'User', 'Constructor:src/models.ts:User(2)', 'Constructor', {
parameterCount: 2,
});
ctx.model.symbols.add(
'src/models.ts',
'User',
'Constructor:src/models.ts:User(0)',
'Constructor',
{
parameterCount: 0,
},
);
ctx.model.symbols.add(
'src/models.ts',
'User',
'Constructor:src/models.ts:User(2)',
'Constructor',
{
parameterCount: 2,
},
);
ctx.importMap.set('src/index.ts', new Set(['src/models.ts']));
const calls: ExtractedCall[] = [
@ -388,10 +411,10 @@ describe('processCallsFromExtracted', () => {
});
it('cannot discriminate same-arity overloads by parameter type (known limitation)', async () => {
ctx.symbols.add('src/UserDao.ts', 'save', 'Function:src/UserDao.ts:save', 'Function', {
ctx.model.symbols.add('src/UserDao.ts', 'save', 'Function:src/UserDao.ts:save', 'Function', {
parameterCount: 1,
});
ctx.symbols.add('src/RepoDao.ts', 'save', 'Function:src/RepoDao.ts:save', 'Function', {
ctx.model.symbols.add('src/RepoDao.ts', 'save', 'Function:src/RepoDao.ts:save', 'Function', {
parameterCount: 1,
});
ctx.importMap.set('src/index.ts', new Set(['src/UserDao.ts', 'src/RepoDao.ts']));
@ -414,11 +437,11 @@ describe('processCallsFromExtracted', () => {
it('return type inference: binds variable to return type of callee', async () => {
// getUser() returns User, and User has a save() method
ctx.symbols.add('src/utils.ts', 'getUser', 'Function:src/utils.ts:getUser', 'Function', {
ctx.model.symbols.add('src/utils.ts', 'getUser', 'Function:src/utils.ts:getUser', 'Function', {
returnType: 'User',
});
ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class');
ctx.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', {
ctx.model.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class');
ctx.model.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', {
ownerId: 'Class:src/models.ts:User',
});
ctx.importMap.set('src/index.ts', new Set(['src/utils.ts', 'src/models.ts']));
@ -450,11 +473,11 @@ describe('processCallsFromExtracted', () => {
});
it('return type inference: unwraps Promise<User> to User', async () => {
ctx.symbols.add('src/api.ts', 'fetchUser', 'Function:src/api.ts:fetchUser', 'Function', {
ctx.model.symbols.add('src/api.ts', 'fetchUser', 'Function:src/api.ts:fetchUser', 'Function', {
returnType: 'Promise<User>',
});
ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class');
ctx.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', {
ctx.model.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class');
ctx.model.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', {
ownerId: 'Class:src/models.ts:User',
});
ctx.importMap.set('src/index.ts', new Set(['src/api.ts', 'src/models.ts']));
@ -484,9 +507,15 @@ describe('processCallsFromExtracted', () => {
});
it('return type inference: skips when return type is primitive', async () => {
ctx.symbols.add('src/utils.ts', 'getCount', 'Function:src/utils.ts:getCount', 'Function', {
returnType: 'number',
});
ctx.model.symbols.add(
'src/utils.ts',
'getCount',
'Function:src/utils.ts:getCount',
'Function',
{
returnType: 'number',
},
);
ctx.importMap.set('src/index.ts', new Set(['src/utils.ts']));
const constructorBindings: FileConstructorBindings[] = [
@ -514,10 +543,10 @@ describe('processCallsFromExtracted', () => {
});
it('return type inference: skips ambiguous callees (multiple definitions)', async () => {
ctx.symbols.add('src/a.ts', 'getData', 'Function:src/a.ts:getData', 'Function', {
ctx.model.symbols.add('src/a.ts', 'getData', 'Function:src/a.ts:getData', 'Function', {
returnType: 'User',
});
ctx.symbols.add('src/b.ts', 'getData', 'Function:src/b.ts:getData', 'Function', {
ctx.model.symbols.add('src/b.ts', 'getData', 'Function:src/b.ts:getData', 'Function', {
returnType: 'Repo',
});
@ -547,8 +576,8 @@ describe('processCallsFromExtracted', () => {
it('return type inference: prefers constructor binding over return type', async () => {
// If the callee IS a class, constructor binding wins (existing behavior)
ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class');
ctx.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', {
ctx.model.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class');
ctx.model.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', {
ownerId: 'Class:src/models.ts:User',
});
ctx.importMap.set('src/index.ts', new Set(['src/models.ts']));
@ -583,11 +612,11 @@ describe('processCallsFromExtracted', () => {
// getUser is in the SymbolTable but WITHOUT a returnType (e.g., inferred return type
// that the structure processor did not capture). The BindingAccumulator for
// src/api.ts has getUser → User as a file-scope binding.
ctx.symbols.add('src/api.ts', 'getUser', 'Function:src/api.ts:getUser', 'Function', {
ctx.model.symbols.add('src/api.ts', 'getUser', 'Function:src/api.ts:getUser', 'Function', {
// No returnType provided — simulates a structure-processor gap
});
ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class');
ctx.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', {
ctx.model.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class');
ctx.model.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', {
ownerId: 'Class:src/models.ts:User',
});
ctx.importMap.set('src/consumer.ts', new Set(['src/api.ts', 'src/models.ts']));
@ -635,11 +664,11 @@ describe('processCallsFromExtracted', () => {
it('Phase 9: BindingAccumulator fallback — SymbolTable return type takes precedence', async () => {
// When the SymbolTable DOES have a returnType, the accumulator should not override it.
ctx.symbols.add('src/api.ts', 'getUser', 'Function:src/api.ts:getUser', 'Function', {
ctx.model.symbols.add('src/api.ts', 'getUser', 'Function:src/api.ts:getUser', 'Function', {
returnType: 'User',
});
ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class');
ctx.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', {
ctx.model.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class');
ctx.model.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', {
ownerId: 'Class:src/models.ts:User',
});
ctx.importMap.set('src/consumer.ts', new Set(['src/api.ts', 'src/models.ts']));
@ -688,8 +717,8 @@ describe('processCallsFromExtracted', () => {
it('Phase 9: BindingAccumulator fallback — skips when callee not in namedImportMap', async () => {
// Callee is not tracked in namedImportMap (e.g. a local function), so accumulator
// lookup is skipped. No CALLS edge expected since there is no binding source.
ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class');
ctx.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', {
ctx.model.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class');
ctx.model.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', {
ownerId: 'Class:src/models.ts:User',
});
// No namedImportMap entry for getUser
@ -708,8 +737,8 @@ describe('processCallsFromExtracted', () => {
// but also exists on multiple types so fuzzy lookup is ambiguous without a
// receiver type. Add a second owner so that unconstrained fuzzy lookup won't
// match unambiguously.
ctx.symbols.add('src/other.ts', 'OtherClass', 'Class:src/other.ts:OtherClass', 'Class');
ctx.symbols.add('src/other.ts', 'save', 'Method:src/other.ts:save', 'Method', {
ctx.model.symbols.add('src/other.ts', 'OtherClass', 'Class:src/other.ts:OtherClass', 'Class');
ctx.model.symbols.add('src/other.ts', 'save', 'Method:src/other.ts:save', 'Method', {
ownerId: 'Class:src/other.ts:OtherClass',
});
@ -741,9 +770,9 @@ describe('processCallsFromExtracted', () => {
it('Phase 9: BindingAccumulator fallback — unwraps Promise<User> type from accumulator', async () => {
// Accumulator stores raw type with Promise wrapper — extractReturnTypeName should unwrap it.
ctx.symbols.add('src/api.ts', 'fetchUser', 'Function:src/api.ts:fetchUser', 'Function');
ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class');
ctx.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', {
ctx.model.symbols.add('src/api.ts', 'fetchUser', 'Function:src/api.ts:fetchUser', 'Function');
ctx.model.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class');
ctx.model.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', {
ownerId: 'Class:src/models.ts:User',
});
ctx.importMap.set('src/consumer.ts', new Set(['src/api.ts', 'src/models.ts']));
@ -790,7 +819,7 @@ describe('processCallsFromExtracted', () => {
it('Phase 9: BindingAccumulator fallback — skips primitive types from accumulator', async () => {
// Accumulator stores a primitive type — should not create a CALLS edge.
ctx.symbols.add('src/api.ts', 'getCount', 'Function:src/api.ts:getCount', 'Function');
ctx.model.symbols.add('src/api.ts', 'getCount', 'Function:src/api.ts:getCount', 'Function');
ctx.importMap.set('src/consumer.ts', new Set(['src/api.ts']));
ctx.namedImportMap.set(
'src/consumer.ts',
@ -834,9 +863,9 @@ describe('processCallsFromExtracted', () => {
it('Phase 9: BindingAccumulator fallback — handles aliased import (localName ≠ exportedName)', async () => {
// import { getUser as fetchUser } from './api' — namedImportMap maps localName to exportedName
ctx.symbols.add('src/api.ts', 'getUser', 'Function:src/api.ts:getUser', 'Function');
ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class');
ctx.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', {
ctx.model.symbols.add('src/api.ts', 'getUser', 'Function:src/api.ts:getUser', 'Function');
ctx.model.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class');
ctx.model.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', {
ownerId: 'Class:src/models.ts:User',
});
ctx.importMap.set('src/consumer.ts', new Set(['src/api.ts', 'src/models.ts']));
@ -889,16 +918,21 @@ describe('processCallsFromExtracted', () => {
// The local definition has no returnType annotation. The accumulator has
// getUser → User from api.ts. The fallback must NOT fire because the
// same-file definition is authoritative (tier: 'same-file').
ctx.symbols.add('src/consumer.ts', 'getUser', 'Function:src/consumer.ts:getUser', 'Function');
ctx.symbols.add('src/api.ts', 'getUser', 'Function:src/api.ts:getUser', 'Function');
ctx.model.symbols.add(
'src/consumer.ts',
'getUser',
'Function:src/consumer.ts:getUser',
'Function',
);
ctx.model.symbols.add('src/api.ts', 'getUser', 'Function:src/api.ts:getUser', 'Function');
// Place User and save in non-imported files so import-scoped member-call resolution
// can't resolve save without a receiver type.
ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class');
ctx.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', {
ctx.model.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class');
ctx.model.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', {
ownerId: 'Class:src/models.ts:User',
});
ctx.symbols.add('src/other.ts', 'OtherClass', 'Class:src/other.ts:OtherClass', 'Class');
ctx.symbols.add('src/other.ts', 'save', 'Method:src/other.ts:save', 'Method', {
ctx.model.symbols.add('src/other.ts', 'OtherClass', 'Class:src/other.ts:OtherClass', 'Class');
ctx.model.symbols.add('src/other.ts', 'save', 'Method:src/other.ts:save', 'Method', {
ownerId: 'Class:src/other.ts:OtherClass',
});
// Only import api.ts — NOT models.ts, so save can't be found via import scope.
@ -952,19 +986,19 @@ describe('processCallsFromExtracted', () => {
// x has no receiver type at all, and save is ambiguous (two owners) → 0 edges.
// Either way, no CALLS edge. But we verify the accumulator's wrong type did NOT leak
// by checking that no ACCESSES edge to BadType is created.
ctx.symbols.add('src/api-v1.ts', 'getUser', 'Function:src/api-v1.ts:getUser', 'Function');
ctx.symbols.add('src/api-v2.ts', 'getUser', 'Function:src/api-v2.ts:getUser', 'Function');
ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class');
ctx.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', {
ctx.model.symbols.add('src/api-v1.ts', 'getUser', 'Function:src/api-v1.ts:getUser', 'Function');
ctx.model.symbols.add('src/api-v2.ts', 'getUser', 'Function:src/api-v2.ts:getUser', 'Function');
ctx.model.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class');
ctx.model.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', {
ownerId: 'Class:src/models.ts:User',
});
ctx.symbols.add('src/other.ts', 'OtherClass', 'Class:src/other.ts:OtherClass', 'Class');
ctx.symbols.add('src/other.ts', 'save', 'Method:src/other.ts:save', 'Method', {
ctx.model.symbols.add('src/other.ts', 'OtherClass', 'Class:src/other.ts:OtherClass', 'Class');
ctx.model.symbols.add('src/other.ts', 'save', 'Method:src/other.ts:save', 'Method', {
ownerId: 'Class:src/other.ts:OtherClass',
});
// BadType has no methods — if the accumulator wrongly types x as BadType,
// the receiver type is set but save won't resolve at all.
ctx.symbols.add('src/bad.ts', 'BadType', 'Class:src/bad.ts:BadType', 'Class');
ctx.model.symbols.add('src/bad.ts', 'BadType', 'Class:src/bad.ts:BadType', 'Class');
ctx.importMap.set(
'src/consumer.ts',
new Set(['src/api-v1.ts', 'src/api-v2.ts', 'src/models.ts']),
@ -1017,8 +1051,8 @@ describe('processCallsFromExtracted', () => {
it('Phase 9 tier gating: no callable candidates but named import — fallback fires', async () => {
// getUser is not in the SymbolTable at all (e.g. definition not parsed).
// namedImportMap has the import, accumulator has the type. Fallback should fire.
ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class');
ctx.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', {
ctx.model.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class');
ctx.model.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', {
ownerId: 'Class:src/models.ts:User',
});
ctx.importMap.set('src/consumer.ts', new Set(['src/api.ts', 'src/models.ts']));
@ -1067,14 +1101,19 @@ describe('processCallsFromExtracted', () => {
// consumer.ts has a local getUser() without returnType annotation.
// No import of getUser exists. The accumulator has getUser → User from api.ts.
// Tier is 'same-file' so fallback must NOT fire.
ctx.symbols.add('src/consumer.ts', 'getUser', 'Function:src/consumer.ts:getUser', 'Function');
ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class');
ctx.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', {
ctx.model.symbols.add(
'src/consumer.ts',
'getUser',
'Function:src/consumer.ts:getUser',
'Function',
);
ctx.model.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class');
ctx.model.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', {
ownerId: 'Class:src/models.ts:User',
});
// Add a second 'save' so fuzzy lookup is ambiguous without receiver type
ctx.symbols.add('src/other.ts', 'OtherClass', 'Class:src/other.ts:OtherClass', 'Class');
ctx.symbols.add('src/other.ts', 'save', 'Method:src/other.ts:save', 'Method', {
ctx.model.symbols.add('src/other.ts', 'OtherClass', 'Class:src/other.ts:OtherClass', 'Class');
ctx.model.symbols.add('src/other.ts', 'save', 'Method:src/other.ts:save', 'Method', {
ownerId: 'Class:src/other.ts:OtherClass',
});
@ -1120,12 +1159,23 @@ describe('processCallsFromExtracted', () => {
// User.save@100 and Repo.save@200 are two methods named "save" in different classes.
// Each has a local variable "db" pointing to a different type.
// Without @startIndex in the key, the second binding would overwrite the first.
ctx.symbols.add('src/db/Database.ts', 'Database', 'Class:src/db/Database.ts:Database', 'Class');
ctx.symbols.add('src/db/Cache.ts', 'Cache', 'Class:src/db/Cache.ts:Cache', 'Class');
ctx.symbols.add('src/db/Database.ts', 'query', 'Method:src/db/Database.ts:query', 'Method', {
ownerId: 'Class:src/db/Database.ts:Database',
});
ctx.symbols.add('src/db/Cache.ts', 'query', 'Method:src/db/Cache.ts:query', 'Method', {
ctx.model.symbols.add(
'src/db/Database.ts',
'Database',
'Class:src/db/Database.ts:Database',
'Class',
);
ctx.model.symbols.add('src/db/Cache.ts', 'Cache', 'Class:src/db/Cache.ts:Cache', 'Class');
ctx.model.symbols.add(
'src/db/Database.ts',
'query',
'Method:src/db/Database.ts:query',
'Method',
{
ownerId: 'Class:src/db/Database.ts:Database',
},
);
ctx.model.symbols.add('src/db/Cache.ts', 'query', 'Method:src/db/Cache.ts:query', 'Method', {
ownerId: 'Class:src/db/Cache.ts:Cache',
});
ctx.importMap.set('src/models/User.ts', new Set(['src/db/Database.ts']));
@ -1178,10 +1228,21 @@ describe('processCallsFromExtracted', () => {
it('receiverKey collision: same scope funcName + same varName + same type resolves (non-ambiguous)', async () => {
// Two save@* scopes both bind "db" to the same type — not ambiguous, should resolve.
ctx.symbols.add('src/db/Database.ts', 'Database', 'Class:src/db/Database.ts:Database', 'Class');
ctx.symbols.add('src/db/Database.ts', 'query', 'Method:src/db/Database.ts:query', 'Method', {
ownerId: 'Class:src/db/Database.ts:Database',
});
ctx.model.symbols.add(
'src/db/Database.ts',
'Database',
'Class:src/db/Database.ts:Database',
'Class',
);
ctx.model.symbols.add(
'src/db/Database.ts',
'query',
'Method:src/db/Database.ts:query',
'Method',
{
ownerId: 'Class:src/db/Database.ts:Database',
},
);
ctx.importMap.set('src/service.ts', new Set(['src/db/Database.ts']));
const constructorBindings: FileConstructorBindings[] = [
@ -1213,12 +1274,23 @@ describe('processCallsFromExtracted', () => {
it('receiverKey collision: same scope funcName + same varName + different types → ambiguous, no CALLS edge', async () => {
// Two save@* scopes in the same file bind "db" to different types — truly ambiguous.
ctx.symbols.add('src/db/Database.ts', 'Database', 'Class:src/db/Database.ts:Database', 'Class');
ctx.symbols.add('src/db/Cache.ts', 'Cache', 'Class:src/db/Cache.ts:Cache', 'Class');
ctx.symbols.add('src/db/Database.ts', 'query', 'Method:src/db/Database.ts:query', 'Method', {
ownerId: 'Class:src/db/Database.ts:Database',
});
ctx.symbols.add('src/db/Cache.ts', 'query', 'Method:src/db/Cache.ts:query', 'Method', {
ctx.model.symbols.add(
'src/db/Database.ts',
'Database',
'Class:src/db/Database.ts:Database',
'Class',
);
ctx.model.symbols.add('src/db/Cache.ts', 'Cache', 'Class:src/db/Cache.ts:Cache', 'Class');
ctx.model.symbols.add(
'src/db/Database.ts',
'query',
'Method:src/db/Database.ts:query',
'Method',
{
ownerId: 'Class:src/db/Database.ts:Database',
},
);
ctx.model.symbols.add('src/db/Cache.ts', 'query', 'Method:src/db/Cache.ts:query', 'Method', {
ownerId: 'Class:src/db/Cache.ts:Cache',
});
ctx.importMap.set('src/service.ts', new Set(['src/db/Database.ts', 'src/db/Cache.ts']));
@ -1251,9 +1323,9 @@ describe('processCallsFromExtracted', () => {
});
it('scope-aware bindings: same varName in different functions resolves to correct type', async () => {
ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class');
ctx.symbols.add('src/models.ts', 'Repo', 'Class:src/models.ts:Repo', 'Class');
ctx.symbols.add('src/models.ts', 'save', 'Function:src/models.ts:save', 'Function');
ctx.model.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class');
ctx.model.symbols.add('src/models.ts', 'Repo', 'Class:src/models.ts:Repo', 'Class');
ctx.model.symbols.add('src/models.ts', 'save', 'Function:src/models.ts:save', 'Function');
ctx.importMap.set('src/index.ts', new Set(['src/models.ts']));
const constructorBindings: FileConstructorBindings[] = [
@ -1312,12 +1384,16 @@ describe('processCalls — Phase P class lookup fallback', () => {
const dogId = 'Class:models/Dog.java:Dog';
const fetchBallId = 'Method:models/Dog.java:fetchBall';
ctx.symbols.add(contractFile, 'Pet', petId, 'Interface');
ctx.symbols.add(dogFile, 'Dog', dogId, 'Class');
ctx.symbols.add(dogFile, 'fetchBall', fetchBallId, 'Method', { ownerId: dogId });
ctx.model.symbols.add(contractFile, 'Pet', petId, 'Interface');
ctx.model.symbols.add(dogFile, 'Dog', dogId, 'Class');
ctx.model.symbols.add(dogFile, 'fetchBall', fetchBallId, 'Method', { ownerId: dogId });
ctx.importMap.set(appFile, new Set([contractFile, dogFile]));
const classLookupSpy = vi.spyOn(ctx.symbols, 'lookupClassByName');
// SM-20 wire-up: resolveMemberCall's constructor-override branch queries
// the model directly (ctx.model.types.lookupClassByName), not the
// legacy SymbolTable wrapper. Spy on the model method to preserve the
// test's intent: verify which class names are looked up during override.
const classLookupSpy = vi.spyOn(ctx.model.types, 'lookupClassByName');
await processCalls(
graph,
@ -1358,16 +1434,26 @@ class App {
const otherDogFile = 'models/OtherDog.java';
const petId = 'Interface:models/Pet.java:Pet';
ctx.symbols.add(contractFile, 'Pet', petId, 'Interface');
ctx.symbols.add(dogFile, 'fetchBall', 'Method:models/Dog.java:fetchBall', 'Method', {
ctx.model.symbols.add(contractFile, 'Pet', petId, 'Interface');
ctx.model.symbols.add(dogFile, 'fetchBall', 'Method:models/Dog.java:fetchBall', 'Method', {
ownerId: 'Class:models/Dog.java:Dog',
});
ctx.symbols.add(otherDogFile, 'fetchBall', 'Method:models/OtherDog.java:fetchBall', 'Method', {
ownerId: 'Class:models/OtherDog.java:OtherDog',
});
ctx.model.symbols.add(
otherDogFile,
'fetchBall',
'Method:models/OtherDog.java:fetchBall',
'Method',
{
ownerId: 'Class:models/OtherDog.java:OtherDog',
},
);
ctx.importMap.set(appFile, new Set([contractFile, dogFile, otherDogFile]));
const classLookupSpy = vi.spyOn(ctx.symbols, 'lookupClassByName');
// SM-20 wire-up: resolveMemberCall's constructor-override branch queries
// the model directly (ctx.model.types.lookupClassByName), not the
// legacy SymbolTable wrapper. Spy on the model method to preserve the
// test's intent: verify which class names are looked up during override.
const classLookupSpy = vi.spyOn(ctx.model.types, 'lookupClassByName');
await processCalls(
graph,
@ -2061,10 +2147,12 @@ describe('processCallsFromExtracted — interface dispatch', () => {
const implAExecuteId = 'Method:impl/A.java:execute';
const implBExecuteId = 'Method:impl/B.java:execute';
ctx.symbols.add(ifaceFile, 'Action', actionIfaceId, 'Interface');
ctx.symbols.add(ifaceFile, 'execute', ifaceExecuteId, 'Method', { ownerId: actionIfaceId });
ctx.symbols.add(implA, 'execute', implAExecuteId, 'Method');
ctx.symbols.add(implB, 'execute', implBExecuteId, 'Method');
ctx.model.symbols.add(ifaceFile, 'Action', actionIfaceId, 'Interface');
ctx.model.symbols.add(ifaceFile, 'execute', ifaceExecuteId, 'Method', {
ownerId: actionIfaceId,
});
ctx.model.symbols.add(implA, 'execute', implAExecuteId, 'Method');
ctx.model.symbols.add(implB, 'execute', implBExecuteId, 'Method');
ctx.importMap.set(runnerFile, new Set([ifaceFile]));
graph.addNode({
@ -2100,8 +2188,8 @@ describe('processCallsFromExtracted — interface dispatch', () => {
{ filePath: 'impl/B.java', className: 'B', parentName: 'Action', kind: 'implements' },
];
// Need class symbols for heritage map to resolve implementors
ctx.symbols.add('impl/A.java', 'A', 'Class:impl/A.java:A', 'Class');
ctx.symbols.add('impl/B.java', 'B', 'Class:impl/B.java:B', 'Class');
ctx.model.symbols.add('impl/A.java', 'A', 'Class:impl/A.java:A', 'Class');
ctx.model.symbols.add('impl/B.java', 'B', 'Class:impl/B.java:B', 'Class');
const heritageMap = buildHeritageMap(heritage, ctx);
const calls: ExtractedCall[] = [
@ -2153,9 +2241,9 @@ describe('processCalls — D0 MRO fast path (SM-10)', () => {
const childId = 'class:models/Child.java:Child';
const parentMethodId = 'method:models/Parent.java:parentMethod';
ctx.symbols.add(parentFile, 'Parent', parentId, 'Class');
ctx.symbols.add(childFile, 'Child', childId, 'Class');
ctx.symbols.add(parentFile, 'parentMethod', parentMethodId, 'Method', {
ctx.model.symbols.add(parentFile, 'Parent', parentId, 'Class');
ctx.model.symbols.add(childFile, 'Child', childId, 'Class');
ctx.model.symbols.add(parentFile, 'parentMethod', parentMethodId, 'Method', {
ownerId: parentId,
returnType: 'String',
});
@ -2211,26 +2299,28 @@ describe('processCalls — D0 MRO fast path (SM-10)', () => {
});
it('D0 miss: heritageMap provided but method not in MRO chain falls through to D1-D4', async () => {
// Setup: Class Obj has a method `doWork` that is findable via tiered
// resolution (import-scoped lookup), but intentionally NOT registered in
// methodByOwner (no `ownerId` property). heritageMap is provided but has
// no ancestry entry for class:Obj. Expected flow:
// D0: lookupMethodByOwner(classId, 'doWork') → undefined
// heritageMap.getAncestors(classId) → []
// lookupMethodByOwnerWithMRO returns undefined → D0 miss
// D1-D4: receiver type resolves to Obj; D2 widens via lookupCallableByName;
// D3 file-filter picks the only candidate in Obj's file.
// Setup: Class Obj exists in the same file as a `doWork` Method. The
// Method is registered under a DIFFERENT ownerId (`class:OtherOwner`)
// so lookupMethodByOwner('class:Obj', 'doWork') misses on the direct
// lookup. heritageMap is empty for class:Obj, so MRO walk yields no
// parents. Expected flow:
// D0: lookupMethodByOwner + MRO walk both miss → D0 fallthrough
// D1-D4: receiver type resolves to Obj; D3 file-filter picks the
// `doWork` candidate via its co-located file path.
// Guarantees D0 miss does not swallow the call — D1-D4 still runs.
const classFile = 'src/models/Obj.java';
const appFile = 'src/services/App.java';
const classId = 'class:models/Obj.java:Obj';
const doWorkId = 'method:models/Obj.java:doWork';
ctx.symbols.add(classFile, 'Obj', classId, 'Class');
// Intentionally omit ownerId so methodByOwner has no entry — forces D0 miss.
ctx.symbols.add(classFile, 'doWork', doWorkId, 'Method', {
ctx.model.symbols.add(classFile, 'Obj', classId, 'Class');
// Post-A4: Method+ownerId routes through methodsByName. Using a
// different ownerId than the receiver type forces the direct
// lookupMethodByOwner miss that the test exercises.
ctx.model.symbols.add(classFile, 'doWork', doWorkId, 'Method', {
returnType: 'void',
parameterCount: 0,
ownerId: 'class:models/Obj.java:OtherOwner',
});
ctx.importMap.set(appFile, new Set([classFile]));
@ -2321,15 +2411,15 @@ describe('processCalls — D0 MRO fast path (SM-10)', () => {
const methodIntId = 'method:models/Obj.java:method(int)';
const methodStringId = 'method:models/Obj.java:method(String)';
ctx.symbols.add(classFile, 'Obj', classId, 'Class');
ctx.model.symbols.add(classFile, 'Obj', classId, 'Class');
// int overload added FIRST so lookupMethodByOwner would return it.
ctx.symbols.add(classFile, 'method', methodIntId, 'Method', {
ctx.model.symbols.add(classFile, 'method', methodIntId, 'Method', {
ownerId: classId,
returnType: 'String',
parameterCount: 1,
parameterTypes: ['int'],
});
ctx.symbols.add(classFile, 'method', methodStringId, 'Method', {
ctx.model.symbols.add(classFile, 'method', methodStringId, 'Method', {
ownerId: classId,
returnType: 'String',
parameterCount: 1,
@ -2384,16 +2474,16 @@ describe('processCalls — D0 MRO fast path (SM-10)', () => {
const methodIntId = 'method:models/Obj.java:method(int)';
const methodStringId = 'method:models/Obj.java:method(String)';
ctx.symbols.add(classFile, 'Obj', classId, 'Class');
ctx.model.symbols.add(classFile, 'Obj', classId, 'Class');
// int overload added FIRST — without the guard this would be returned by
// lookupMethodByOwner's same-return-type fast path.
ctx.symbols.add(classFile, 'method', methodIntId, 'Method', {
ctx.model.symbols.add(classFile, 'method', methodIntId, 'Method', {
ownerId: classId,
returnType: 'String',
parameterCount: 1,
parameterTypes: ['int'],
});
ctx.symbols.add(classFile, 'method', methodStringId, 'Method', {
ctx.model.symbols.add(classFile, 'method', methodStringId, 'Method', {
ownerId: classId,
returnType: 'String',
parameterCount: 1,
@ -2439,13 +2529,13 @@ describe('processCalls — D0 MRO fast path (SM-10)', () => {
const authSaveId = 'method:auth_mod.py:save';
const userSaveId = 'method:user_mod.py:save';
ctx.symbols.add(authModFile, 'User', authUserId, 'Class');
ctx.symbols.add(userModFile, 'User', userUserId, 'Class');
ctx.symbols.add(authModFile, 'save', authSaveId, 'Method', {
ctx.model.symbols.add(authModFile, 'User', authUserId, 'Class');
ctx.model.symbols.add(userModFile, 'User', userUserId, 'Class');
ctx.model.symbols.add(authModFile, 'save', authSaveId, 'Method', {
ownerId: authUserId,
returnType: 'bool',
});
ctx.symbols.add(userModFile, 'save', userSaveId, 'Method', {
ctx.model.symbols.add(userModFile, 'save', userSaveId, 'Method', {
ownerId: userUserId,
returnType: 'bool',
});
@ -2509,13 +2599,13 @@ describe('processCalls — D0 MRO fast path (SM-10)', () => {
const authSaveId = 'method:src/auth_mod.py:save';
const userSaveId = 'method:src/user_mod.py:save';
ctx.symbols.add(authModFile, 'User', authUserId, 'Class');
ctx.symbols.add(userModFile, 'User', userUserId, 'Class');
ctx.symbols.add(authModFile, 'save', authSaveId, 'Method', {
ctx.model.symbols.add(authModFile, 'User', authUserId, 'Class');
ctx.model.symbols.add(userModFile, 'User', userUserId, 'Class');
ctx.model.symbols.add(authModFile, 'save', authSaveId, 'Method', {
ownerId: authUserId,
returnType: 'bool',
});
ctx.symbols.add(userModFile, 'save', userSaveId, 'Method', {
ctx.model.symbols.add(userModFile, 'save', userSaveId, 'Method', {
ownerId: userUserId,
returnType: 'bool',
});
@ -2566,13 +2656,13 @@ describe('processCalls — D0 MRO fast path (SM-10)', () => {
const modelsSaveId = 'method:src/models.py:User:save';
const authSaveId = 'method:src/auth.py:Widget:save';
ctx.symbols.add(modelsFile, 'User', modelsUserId, 'Class');
ctx.symbols.add(authFile, 'Widget', authWidgetId, 'Class');
ctx.symbols.add(modelsFile, 'save', modelsSaveId, 'Method', {
ctx.model.symbols.add(modelsFile, 'User', modelsUserId, 'Class');
ctx.model.symbols.add(authFile, 'Widget', authWidgetId, 'Class');
ctx.model.symbols.add(modelsFile, 'save', modelsSaveId, 'Method', {
ownerId: modelsUserId,
returnType: 'None',
});
ctx.symbols.add(authFile, 'save', authSaveId, 'Method', {
ctx.model.symbols.add(authFile, 'save', authSaveId, 'Method', {
ownerId: authWidgetId,
returnType: 'None',
});
@ -2616,10 +2706,10 @@ describe('processCalls — D0 MRO fast path (SM-10)', () => {
const authWidgetId = 'class:src/auth.py:Widget';
const authSaveId = 'method:src/auth.py:Widget:save';
ctx.symbols.add(modelsFile, 'User', modelsUserId, 'Class');
ctx.symbols.add(authFile, 'Widget', authWidgetId, 'Class');
ctx.model.symbols.add(modelsFile, 'User', modelsUserId, 'Class');
ctx.model.symbols.add(authFile, 'Widget', authWidgetId, 'Class');
// NO save on User — deliberately absent to force null-route.
ctx.symbols.add(authFile, 'save', authSaveId, 'Method', {
ctx.model.symbols.add(authFile, 'save', authSaveId, 'Method', {
ownerId: authWidgetId,
returnType: 'None',
});
@ -2658,8 +2748,8 @@ describe('processCalls — D0 MRO fast path (SM-10)', () => {
const modelsUserId = 'class:src/models.py:User';
const modelsSaveId = 'method:src/models.py:User:save';
ctx.symbols.add(modelsFile, 'User', modelsUserId, 'Class');
ctx.symbols.add(modelsFile, 'save', modelsSaveId, 'Method', {
ctx.model.symbols.add(modelsFile, 'User', modelsUserId, 'Class');
ctx.model.symbols.add(modelsFile, 'save', modelsSaveId, 'Method', {
ownerId: modelsUserId,
returnType: 'None',
});
@ -2697,8 +2787,8 @@ describe('processCalls — D0 MRO fast path (SM-10)', () => {
const modelsUserId = 'class:src/models.py:User';
const modelsSaveId = 'method:src/models.py:User:save';
ctx.symbols.add(modelsFile, 'User', modelsUserId, 'Class');
ctx.symbols.add(modelsFile, 'save', modelsSaveId, 'Method', {
ctx.model.symbols.add(modelsFile, 'User', modelsUserId, 'Class');
ctx.model.symbols.add(modelsFile, 'save', modelsSaveId, 'Method', {
ownerId: modelsUserId,
returnType: 'None',
});
@ -2740,14 +2830,14 @@ describe('processCalls — D0 MRO fast path (SM-10)', () => {
const userCtorId = 'Constructor:src/models/User.ts:User(string)';
const repoCtorId = 'Constructor:src/models/Repo.ts:User(number)';
ctx.symbols.add(userFile, 'User', userClassId, 'Class');
ctx.symbols.add(repoFile, 'User', repoClassId, 'Class');
ctx.symbols.add(userFile, 'User', userCtorId, 'Constructor', {
ctx.model.symbols.add(userFile, 'User', userClassId, 'Class');
ctx.model.symbols.add(repoFile, 'User', repoClassId, 'Class');
ctx.model.symbols.add(userFile, 'User', userCtorId, 'Constructor', {
ownerId: userClassId,
parameterCount: 1,
parameterTypes: ['string'],
});
ctx.symbols.add(repoFile, 'User', repoCtorId, 'Constructor', {
ctx.model.symbols.add(repoFile, 'User', repoCtorId, 'Constructor', {
ownerId: repoClassId,
parameterCount: 1,
parameterTypes: ['number'],
@ -2784,15 +2874,15 @@ describe('processCalls — D0 MRO fast path (SM-10)', () => {
const userCtorId = 'Constructor:src/models/User.ts:User(string)';
const repoCtorId = 'Constructor:src/models/Repo.ts:User(string)';
ctx.symbols.add(userFile, 'User', userClassId, 'Class');
ctx.symbols.add(repoFile, 'User', repoClassId, 'Class');
ctx.model.symbols.add(userFile, 'User', userClassId, 'Class');
ctx.model.symbols.add(repoFile, 'User', repoClassId, 'Class');
// Both constructors take `string` — genuinely ambiguous.
ctx.symbols.add(userFile, 'User', userCtorId, 'Constructor', {
ctx.model.symbols.add(userFile, 'User', userCtorId, 'Constructor', {
ownerId: userClassId,
parameterCount: 1,
parameterTypes: ['string'],
});
ctx.symbols.add(repoFile, 'User', repoCtorId, 'Constructor', {
ctx.model.symbols.add(repoFile, 'User', repoCtorId, 'Constructor', {
ownerId: repoClassId,
parameterCount: 1,
parameterTypes: ['string'],
@ -2833,11 +2923,17 @@ describe('processAssignmentsFromExtracted', () => {
// carries getUser → User from the source file. The constructor binding
// binds x = getUser(). The assignment x.address = value should produce
// an ACCESSES write edge to User.address via the accumulator fallback.
ctx.symbols.add('src/api.ts', 'getUser', 'Function:src/api.ts:getUser', 'Function');
ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class');
ctx.symbols.add('src/models.ts', 'address', 'Property:src/models.ts:address', 'Property', {
ownerId: 'Class:src/models.ts:User',
});
ctx.model.symbols.add('src/api.ts', 'getUser', 'Function:src/api.ts:getUser', 'Function');
ctx.model.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class');
ctx.model.symbols.add(
'src/models.ts',
'address',
'Property:src/models.ts:address',
'Property',
{
ownerId: 'Class:src/models.ts:User',
},
);
ctx.importMap.set('src/consumer.ts', new Set(['src/api.ts', 'src/models.ts']));
ctx.namedImportMap.set(
'src/consumer.ts',
@ -2889,9 +2985,9 @@ describe('D2 widen path: lookupCallableByName via module alias', () => {
// pointing to auth.py. login() is defined only in auth.py (not imported
// by consumer.py). The D2 widen path should find login via the global
// callable index filtered to the aliased module file.
ctx.symbols.add('src/auth.py', 'login', 'Function:src/auth.py:login', 'Function');
ctx.model.symbols.add('src/auth.py', 'login', 'Function:src/auth.py:login', 'Function');
// Consumer has a same-file function that shadows 'login' at Tier 1
ctx.symbols.add('src/consumer.py', 'login', 'Function:src/consumer.py:login', 'Function');
ctx.model.symbols.add('src/consumer.py', 'login', 'Function:src/consumer.py:login', 'Function');
// Module alias: consumer.py → auth → src/auth.py
ctx.moduleAliasMap.set('src/consumer.py', new Map([['auth', 'src/auth.py']]));

View file

@ -8,7 +8,7 @@ import { cppConfig } from '../../src/core/ingestion/field-extractors/configs/c-c
import { rubyConfig } from '../../src/core/ingestion/field-extractors/configs/ruby.js';
import type { FieldExtractorContext } from '../../src/core/ingestion/field-types.js';
import type { TypeEnvironment } from '../../src/core/ingestion/type-env.js';
import { createSymbolTable } from '../../src/core/ingestion/symbol-table.js';
import { createSemanticModel } from '../../src/core/ingestion/model/semantic-model.js';
import Parser from 'tree-sitter';
import TypeScript from 'tree-sitter-typescript';
import Python from 'tree-sitter-python';
@ -26,7 +26,13 @@ const parse = (code: string) => {
return parser.parse(code);
};
// Mock context for tests
// Mock context for tests. symbolTable comes from createSemanticModel().symbols
// (the facade) rather than createSymbolTable() (the raw leaf) — this mirrors
// production, where FieldExtractorContext always receives the SemanticModel-
// wrapped facade so any .add() write dispatches through the owner-scoped
// registries. No current field extractor calls symbolTable.add(), but
// matching the production shape prevents silent drift if a future extractor
// starts registering dynamically-discovered properties.
const createMockContext = (): FieldExtractorContext => ({
typeEnv: {
lookup: () => undefined,
@ -35,7 +41,7 @@ const createMockContext = (): FieldExtractorContext => ({
allScopes: () => new Map(),
constructorTypeMap: new Map(),
} as TypeEnvironment,
symbolTable: createSymbolTable(),
symbolTable: createSemanticModel().symbols,
filePath: 'test.ts',
language: SupportedLanguages.TypeScript,
});

View file

@ -1,10 +1,11 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { buildHeritageMap } from '../../src/core/ingestion/heritage-map.js';
import { buildHeritageMap } from '../../src/core/ingestion/model/heritage-map.js';
import {
createResolutionContext,
type ResolutionContext,
} from '../../src/core/ingestion/resolution-context.js';
import type { ExtractedHeritage } from '../../src/core/ingestion/workers/parse-worker.js';
} from '../../src/core/ingestion/model/resolution-context.js';
import type { ExtractedHeritage } from '../../src/core/ingestion/model/heritage-map.js';
import { getHeritageStrategyForLanguage } from '../../src/core/ingestion/heritage-processor.js';
describe('buildHeritageMap', () => {
let ctx: ResolutionContext;
@ -17,8 +18,8 @@ describe('buildHeritageMap', () => {
describe('getParents', () => {
it('returns direct parents for a single extends relationship', () => {
ctx.symbols.add('src/child.ts', 'Child', 'class:Child', 'Class');
ctx.symbols.add('src/parent.ts', 'Parent', 'class:Parent', 'Class');
ctx.model.symbols.add('src/child.ts', 'Child', 'class:Child', 'Class');
ctx.model.symbols.add('src/parent.ts', 'Parent', 'class:Parent', 'Class');
const heritage: ExtractedHeritage[] = [
{ filePath: 'src/child.ts', className: 'Child', parentName: 'Parent', kind: 'extends' },
@ -29,8 +30,8 @@ describe('buildHeritageMap', () => {
});
it('returns direct parents for implements relationship', () => {
ctx.symbols.add('src/service.ts', 'Service', 'class:Service', 'Class');
ctx.symbols.add('src/iface.ts', 'IService', 'iface:IService', 'Interface');
ctx.model.symbols.add('src/service.ts', 'Service', 'class:Service', 'Class');
ctx.model.symbols.add('src/iface.ts', 'IService', 'iface:IService', 'Interface');
const heritage: ExtractedHeritage[] = [
{
@ -46,8 +47,8 @@ describe('buildHeritageMap', () => {
});
it('returns direct parents for trait-impl relationship', () => {
ctx.symbols.add('src/point.rs', 'Point', 'struct:Point', 'Struct');
ctx.symbols.add('src/display.rs', 'Display', 'trait:Display', 'Interface');
ctx.model.symbols.add('src/point.rs', 'Point', 'struct:Point', 'Struct');
ctx.model.symbols.add('src/display.rs', 'Display', 'trait:Display', 'Interface');
const heritage: ExtractedHeritage[] = [
{
@ -63,9 +64,14 @@ describe('buildHeritageMap', () => {
});
it('returns multiple parents when class extends and implements', () => {
ctx.symbols.add('src/admin.ts', 'Admin', 'class:Admin', 'Class');
ctx.symbols.add('src/user.ts', 'User', 'class:User', 'Class');
ctx.symbols.add('src/serializable.ts', 'Serializable', 'iface:Serializable', 'Interface');
ctx.model.symbols.add('src/admin.ts', 'Admin', 'class:Admin', 'Class');
ctx.model.symbols.add('src/user.ts', 'User', 'class:User', 'Class');
ctx.model.symbols.add(
'src/serializable.ts',
'Serializable',
'iface:Serializable',
'Interface',
);
const heritage: ExtractedHeritage[] = [
{ filePath: 'src/admin.ts', className: 'Admin', parentName: 'User', kind: 'extends' },
@ -90,7 +96,7 @@ describe('buildHeritageMap', () => {
});
it('skips heritage records where child class is not in symbol table', () => {
ctx.symbols.add('src/parent.ts', 'Parent', 'class:Parent', 'Class');
ctx.model.symbols.add('src/parent.ts', 'Parent', 'class:Parent', 'Class');
const heritage: ExtractedHeritage[] = [
{
@ -107,7 +113,7 @@ describe('buildHeritageMap', () => {
});
it('skips heritage records where parent class is not in symbol table', () => {
ctx.symbols.add('src/child.ts', 'Child', 'class:Child', 'Class');
ctx.model.symbols.add('src/child.ts', 'Child', 'class:Child', 'Class');
const heritage: ExtractedHeritage[] = [
{
@ -123,7 +129,7 @@ describe('buildHeritageMap', () => {
});
it('skips self-references', () => {
ctx.symbols.add('src/a.ts', 'A', 'class:A', 'Class');
ctx.model.symbols.add('src/a.ts', 'A', 'class:A', 'Class');
const heritage: ExtractedHeritage[] = [
{ filePath: 'src/a.ts', className: 'A', parentName: 'A', kind: 'extends' },
@ -134,8 +140,8 @@ describe('buildHeritageMap', () => {
});
it('deduplicates cross-chunk duplicates', () => {
ctx.symbols.add('src/child.ts', 'Child', 'class:Child', 'Class');
ctx.symbols.add('src/parent.ts', 'Parent', 'class:Parent', 'Class');
ctx.model.symbols.add('src/child.ts', 'Child', 'class:Child', 'Class');
ctx.model.symbols.add('src/parent.ts', 'Parent', 'class:Parent', 'Class');
const heritage: ExtractedHeritage[] = [
{ filePath: 'src/child.ts', className: 'Child', parentName: 'Parent', kind: 'extends' },
@ -151,9 +157,9 @@ describe('buildHeritageMap', () => {
describe('getAncestors', () => {
it('returns full ancestor chain for multi-level inheritance', () => {
ctx.symbols.add('src/c.ts', 'C', 'class:C', 'Class');
ctx.symbols.add('src/b.ts', 'B', 'class:B', 'Class');
ctx.symbols.add('src/a.ts', 'A', 'class:A', 'Class');
ctx.model.symbols.add('src/c.ts', 'C', 'class:C', 'Class');
ctx.model.symbols.add('src/b.ts', 'B', 'class:B', 'Class');
ctx.model.symbols.add('src/a.ts', 'A', 'class:A', 'Class');
const heritage: ExtractedHeritage[] = [
{ filePath: 'src/c.ts', className: 'C', parentName: 'B', kind: 'extends' },
@ -173,10 +179,10 @@ describe('buildHeritageMap', () => {
// B C
// \ /
// D
ctx.symbols.add('src/a.ts', 'A', 'class:A', 'Class');
ctx.symbols.add('src/b.ts', 'B', 'class:B', 'Class');
ctx.symbols.add('src/c.ts', 'C', 'class:C', 'Class');
ctx.symbols.add('src/d.ts', 'D', 'class:D', 'Class');
ctx.model.symbols.add('src/a.ts', 'A', 'class:A', 'Class');
ctx.model.symbols.add('src/b.ts', 'B', 'class:B', 'Class');
ctx.model.symbols.add('src/c.ts', 'C', 'class:C', 'Class');
ctx.model.symbols.add('src/d.ts', 'D', 'class:D', 'Class');
const heritage: ExtractedHeritage[] = [
{ filePath: 'src/d.ts', className: 'D', parentName: 'B', kind: 'extends' },
@ -194,8 +200,8 @@ describe('buildHeritageMap', () => {
});
it('protects against cycles', () => {
ctx.symbols.add('src/a.ts', 'A', 'class:A', 'Class');
ctx.symbols.add('src/b.ts', 'B', 'class:B', 'Class');
ctx.model.symbols.add('src/a.ts', 'A', 'class:A', 'Class');
ctx.model.symbols.add('src/b.ts', 'B', 'class:B', 'Class');
const heritage: ExtractedHeritage[] = [
{ filePath: 'src/a.ts', className: 'A', parentName: 'B', kind: 'extends' },
@ -212,9 +218,9 @@ describe('buildHeritageMap', () => {
});
it('protects against multi-node cycles (A→B→C→A)', () => {
ctx.symbols.add('src/a.ts', 'A', 'class:A', 'Class');
ctx.symbols.add('src/b.ts', 'B', 'class:B', 'Class');
ctx.symbols.add('src/c.ts', 'C', 'class:C', 'Class');
ctx.model.symbols.add('src/a.ts', 'A', 'class:A', 'Class');
ctx.model.symbols.add('src/b.ts', 'B', 'class:B', 'Class');
ctx.model.symbols.add('src/c.ts', 'C', 'class:C', 'Class');
// A → B → C → A (3-node cycle)
const heritage: ExtractedHeritage[] = [
@ -232,7 +238,7 @@ describe('buildHeritageMap', () => {
});
it('returns empty array for node with no parents', () => {
ctx.symbols.add('src/a.ts', 'A', 'class:A', 'Class');
ctx.model.symbols.add('src/a.ts', 'A', 'class:A', 'Class');
const map = buildHeritageMap([], ctx);
expect(map.getAncestors('class:A')).toEqual([]);
@ -249,9 +255,9 @@ describe('buildHeritageMap', () => {
for (let i = 0; i < 40; i++) {
const childName = `Level${i}`;
const parentName = `Level${i + 1}`;
ctx.symbols.add(`src/${childName}.ts`, childName, `class:${childName}`, 'Class');
ctx.model.symbols.add(`src/${childName}.ts`, childName, `class:${childName}`, 'Class');
if (i === 39) {
ctx.symbols.add(`src/${parentName}.ts`, parentName, `class:${parentName}`, 'Class');
ctx.model.symbols.add(`src/${parentName}.ts`, parentName, `class:${parentName}`, 'Class');
}
heritage.push({
filePath: `src/${childName}.ts`,
@ -289,9 +295,9 @@ describe('buildHeritageMap', () => {
describe('getImplementorFiles', () => {
it('records direct implements edges per interface name', () => {
ctx.symbols.add('a.java', 'C', 'class:C', 'Class');
ctx.symbols.add('b.java', 'D', 'class:D', 'Class');
ctx.symbols.add('iface.java', 'Runnable', 'iface:Runnable', 'Interface');
ctx.model.symbols.add('a.java', 'C', 'class:C', 'Class');
ctx.model.symbols.add('b.java', 'D', 'class:D', 'Class');
ctx.model.symbols.add('iface.java', 'Runnable', 'iface:Runnable', 'Interface');
const heritage: ExtractedHeritage[] = [
{ filePath: 'a.java', className: 'C', parentName: 'Runnable', kind: 'implements' },
@ -302,9 +308,9 @@ describe('buildHeritageMap', () => {
});
it('only records implementors for interface parents, not class parents', () => {
ctx.symbols.add('a.java', 'C', 'class:C', 'Class');
ctx.symbols.add('base.java', 'Base', 'class:Base', 'Class');
ctx.symbols.add('iface.java', 'I', 'iface:I', 'Interface');
ctx.model.symbols.add('a.java', 'C', 'class:C', 'Class');
ctx.model.symbols.add('base.java', 'Base', 'class:Base', 'Class');
ctx.model.symbols.add('iface.java', 'I', 'iface:I', 'Interface');
const heritage: ExtractedHeritage[] = [
{ filePath: 'a.java', className: 'C', parentName: 'Base', kind: 'extends' },
@ -326,7 +332,7 @@ describe('buildHeritageMap', () => {
// Only the child class is registered; the parent interface has no symbol.
// resolveExtendsType must fall through to the provider heuristic and
// classify `IDisposable` as IMPLEMENTS.
ctx.symbols.add('src/Service.cs', 'Service', 'class:Service', 'Class');
ctx.model.symbols.add('src/Service.cs', 'Service', 'class:Service', 'Class');
const heritage: ExtractedHeritage[] = [
{
@ -336,14 +342,14 @@ describe('buildHeritageMap', () => {
kind: 'extends',
},
];
const map = buildHeritageMap(heritage, ctx);
const map = buildHeritageMap(heritage, ctx, getHeritageStrategyForLanguage);
expect(map.getImplementorFiles('IDisposable')).toEqual(new Set(['src/Service.cs']));
});
it('records Swift extends→IMPLEMENTS via heritageDefaultEdge when parent is unresolved', () => {
// Swift provider has heritageDefaultEdge: 'IMPLEMENTS'.
// Unresolved parents should default to IMPLEMENTS (protocol conformance).
ctx.symbols.add('src/MyView.swift', 'MyView', 'class:MyView', 'Class');
ctx.model.symbols.add('src/MyView.swift', 'MyView', 'class:MyView', 'Class');
const heritage: ExtractedHeritage[] = [
{
@ -353,7 +359,7 @@ describe('buildHeritageMap', () => {
kind: 'extends',
},
];
const map = buildHeritageMap(heritage, ctx);
const map = buildHeritageMap(heritage, ctx, getHeritageStrategyForLanguage);
expect(map.getImplementorFiles('SomeProtocol')).toEqual(new Set(['src/MyView.swift']));
});
@ -361,8 +367,8 @@ describe('buildHeritageMap', () => {
// Java/C# path: when ctx.resolve finds a matching symbol whose type is
// Interface, resolveExtendsType returns IMPLEMENTS via the symbol lookup
// (not the interfaceNamePattern fallback).
ctx.symbols.add('src/Impl.java', 'Impl', 'class:Impl', 'Class');
ctx.symbols.add('src/MyContract.java', 'MyContract', 'iface:MyContract', 'Interface');
ctx.model.symbols.add('src/Impl.java', 'Impl', 'class:Impl', 'Class');
ctx.model.symbols.add('src/MyContract.java', 'MyContract', 'iface:MyContract', 'Interface');
const heritage: ExtractedHeritage[] = [
{
@ -377,8 +383,8 @@ describe('buildHeritageMap', () => {
});
it('records Kotlin implements edges', () => {
ctx.symbols.add('src/Impl.kt', 'Impl', 'class:Impl', 'Class');
ctx.symbols.add('src/Iface.kt', 'Iface', 'iface:Iface', 'Interface');
ctx.model.symbols.add('src/Impl.kt', 'Impl', 'class:Impl', 'Class');
ctx.model.symbols.add('src/Iface.kt', 'Iface', 'iface:Iface', 'Interface');
const heritage: ExtractedHeritage[] = [
{
@ -393,8 +399,8 @@ describe('buildHeritageMap', () => {
});
it('records TypeScript implements edges', () => {
ctx.symbols.add('src/Service.ts', 'UserService', 'class:UserService', 'Class');
ctx.symbols.add('src/IService.ts', 'IUserService', 'iface:IUserService', 'Interface');
ctx.model.symbols.add('src/Service.ts', 'UserService', 'class:UserService', 'Class');
ctx.model.symbols.add('src/IService.ts', 'IUserService', 'iface:IUserService', 'Interface');
const heritage: ExtractedHeritage[] = [
{
@ -409,8 +415,8 @@ describe('buildHeritageMap', () => {
});
it('records PHP implements edges', () => {
ctx.symbols.add('src/Impl.php', 'Impl', 'class:Impl', 'Class');
ctx.symbols.add('src/Iface.php', 'Iface', 'iface:Iface', 'Interface');
ctx.model.symbols.add('src/Impl.php', 'Impl', 'class:Impl', 'Class');
ctx.model.symbols.add('src/Iface.php', 'Iface', 'iface:Iface', 'Interface');
const heritage: ExtractedHeritage[] = [
{
@ -427,8 +433,8 @@ describe('buildHeritageMap', () => {
it('does not record Rust trait-impl entries in the implementor index', () => {
// Documented limitation: trait-impl is intentionally not added to the
// implementor index — interface dispatch does not traverse trait objects.
ctx.symbols.add('src/point.rs', 'Point', 'struct:Point', 'Struct');
ctx.symbols.add('src/display.rs', 'Display', 'trait:Display', 'Interface');
ctx.model.symbols.add('src/point.rs', 'Point', 'struct:Point', 'Struct');
ctx.model.symbols.add('src/display.rs', 'Display', 'trait:Display', 'Interface');
const heritage: ExtractedHeritage[] = [
{
@ -445,9 +451,9 @@ describe('buildHeritageMap', () => {
});
it('heritage merged across chunks matches single-pass (chunk-order invariant)', () => {
ctx.symbols.add('a.java', 'A', 'class:A', 'Class');
ctx.symbols.add('b.java', 'B', 'class:B', 'Class');
ctx.symbols.add('iface.java', 'Iface', 'iface:Iface', 'Interface');
ctx.model.symbols.add('a.java', 'A', 'class:A', 'Class');
ctx.model.symbols.add('b.java', 'B', 'class:B', 'Class');
ctx.model.symbols.add('iface.java', 'Iface', 'iface:Iface', 'Interface');
const chunk1: ExtractedHeritage[] = [
{ filePath: 'a.java', className: 'A', parentName: 'Iface', kind: 'implements' },
@ -464,10 +470,10 @@ describe('buildHeritageMap', () => {
describe('chunk-order invariant', () => {
it('produces same result regardless of heritage record order', () => {
ctx.symbols.add('src/d.ts', 'D', 'class:D', 'Class');
ctx.symbols.add('src/c.ts', 'C', 'class:C', 'Class');
ctx.symbols.add('src/b.ts', 'B', 'class:B', 'Class');
ctx.symbols.add('src/a.ts', 'A', 'class:A', 'Class');
ctx.model.symbols.add('src/d.ts', 'D', 'class:D', 'Class');
ctx.model.symbols.add('src/c.ts', 'C', 'class:C', 'Class');
ctx.model.symbols.add('src/b.ts', 'B', 'class:B', 'Class');
ctx.model.symbols.add('src/a.ts', 'A', 'class:A', 'Class');
const heritage1: ExtractedHeritage[] = [
{ filePath: 'src/d.ts', className: 'D', parentName: 'C', kind: 'extends' },

View file

@ -4,8 +4,8 @@ import { createKnowledgeGraph } from '../../src/core/graph/graph.js';
import {
createResolutionContext,
type ResolutionContext,
} from '../../src/core/ingestion/resolution-context.js';
import type { ExtractedHeritage } from '../../src/core/ingestion/workers/parse-worker.js';
} from '../../src/core/ingestion/model/resolution-context.js';
import type { ExtractedHeritage } from '../../src/core/ingestion/model/heritage-map.js';
describe('processHeritageFromExtracted', () => {
let graph: ReturnType<typeof createKnowledgeGraph>;
@ -18,8 +18,8 @@ describe('processHeritageFromExtracted', () => {
describe('extends', () => {
it('creates EXTENDS relationship between classes', async () => {
ctx.symbols.add('src/admin.ts', 'AdminUser', 'Class:src/admin.ts:AdminUser', 'Class');
ctx.symbols.add('src/user.ts', 'User', 'Class:src/user.ts:User', 'Class');
ctx.model.symbols.add('src/admin.ts', 'AdminUser', 'Class:src/admin.ts:AdminUser', 'Class');
ctx.model.symbols.add('src/user.ts', 'User', 'Class:src/user.ts:User', 'Class');
const heritage: ExtractedHeritage[] = [
{
@ -58,7 +58,7 @@ describe('processHeritageFromExtracted', () => {
});
it('skips self-inheritance', async () => {
ctx.symbols.add('src/a.ts', 'Foo', 'Class:src/a.ts:Foo', 'Class');
ctx.model.symbols.add('src/a.ts', 'Foo', 'Class:src/a.ts:Foo', 'Class');
const heritage: ExtractedHeritage[] = [
{
@ -76,8 +76,13 @@ describe('processHeritageFromExtracted', () => {
describe('implements', () => {
it('creates IMPLEMENTS relationship', async () => {
ctx.symbols.add('src/service.ts', 'UserService', 'Class:src/service.ts:UserService', 'Class');
ctx.symbols.add(
ctx.model.symbols.add(
'src/service.ts',
'UserService',
'Class:src/service.ts:UserService',
'Class',
);
ctx.model.symbols.add(
'src/interfaces.ts',
'IService',
'Interface:src/interfaces.ts:IService',
@ -103,8 +108,8 @@ describe('processHeritageFromExtracted', () => {
describe('trait-impl (Rust)', () => {
it('creates IMPLEMENTS relationship for trait impl', async () => {
ctx.symbols.add('src/point.rs', 'Point', 'Struct:src/point.rs:Point', 'Struct');
ctx.symbols.add('src/display.rs', 'Display', 'Trait:src/display.rs:Display', 'Trait');
ctx.model.symbols.add('src/point.rs', 'Point', 'Struct:src/point.rs:Point', 'Struct');
ctx.model.symbols.add('src/display.rs', 'Display', 'Trait:src/display.rs:Display', 'Trait');
const heritage: ExtractedHeritage[] = [
{
@ -125,8 +130,13 @@ describe('processHeritageFromExtracted', () => {
describe('C# interface resolution from extends captures', () => {
it('emits IMPLEMENTS when parent is an Interface in symbol table', async () => {
ctx.symbols.add('src/Service.cs', 'UserService', 'Class:src/Service.cs:UserService', 'Class');
ctx.symbols.add(
ctx.model.symbols.add(
'src/Service.cs',
'UserService',
'Class:src/Service.cs:UserService',
'Class',
);
ctx.model.symbols.add(
'src/IService.cs',
'IService',
'Interface:src/IService.cs:IService',
@ -153,8 +163,8 @@ describe('processHeritageFromExtracted', () => {
});
it('emits EXTENDS when parent is a Class in symbol table', async () => {
ctx.symbols.add('src/Admin.cs', 'AdminUser', 'Class:src/Admin.cs:AdminUser', 'Class');
ctx.symbols.add('src/User.cs', 'User', 'Class:src/User.cs:User', 'Class');
ctx.model.symbols.add('src/Admin.cs', 'AdminUser', 'Class:src/Admin.cs:AdminUser', 'Class');
ctx.model.symbols.add('src/User.cs', 'User', 'Class:src/User.cs:User', 'Class');
const heritage: ExtractedHeritage[] = [
{
@ -246,15 +256,20 @@ describe('processHeritageFromExtracted', () => {
});
it('handles mixed class + interface base_list from C#', async () => {
ctx.symbols.add('src/Repo.cs', 'UserRepo', 'Class:src/Repo.cs:UserRepo', 'Class');
ctx.symbols.add('src/Base.cs', 'BaseRepository', 'Class:src/Base.cs:BaseRepository', 'Class');
ctx.symbols.add(
ctx.model.symbols.add('src/Repo.cs', 'UserRepo', 'Class:src/Repo.cs:UserRepo', 'Class');
ctx.model.symbols.add(
'src/Base.cs',
'BaseRepository',
'Class:src/Base.cs:BaseRepository',
'Class',
);
ctx.model.symbols.add(
'src/IRepo.cs',
'IRepository',
'Interface:src/IRepo.cs:IRepository',
'Interface',
);
ctx.symbols.add(
ctx.model.symbols.add(
'src/IDisp.cs',
'IDisposable',
'Interface:src/IDisp.cs:IDisposable',
@ -314,7 +329,7 @@ describe('processHeritageFromExtracted', () => {
it('still uses symbol table authoritatively for Swift (Tier 1 takes precedence)', async () => {
// When the parent is in the symbol table as a Class, EXTENDS wins even in Swift
ctx.symbols.add('src/Animal.swift', 'Animal', 'Class:src/Animal.swift:Animal', 'Class');
ctx.model.symbols.add('src/Animal.swift', 'Animal', 'Class:src/Animal.swift:Animal', 'Class');
const heritage: ExtractedHeritage[] = [
{

View file

@ -1,7 +1,7 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { buildImportResolutionContext } from '../../src/core/ingestion/import-processor.js';
import type { ImportResolutionContext } from '../../src/core/ingestion/import-resolvers/types.js';
import { createResolutionContext } from '../../src/core/ingestion/resolution-context.js';
import { createResolutionContext } from '../../src/core/ingestion/model/resolution-context.js';
describe('ResolutionContext.importMap', () => {
it('creates an empty Map', () => {

View file

@ -0,0 +1,74 @@
/**
* Unit tests for FieldRegistry (SM-20).
*
* FieldRegistry is the simplest of the three owner-scoped registries —
* one flat Map keyed on `ownerNodeId\0fieldName`. These tests pin the
* basic register/lookup/clear contract and the owner-scope isolation.
*/
import { describe, it, expect } from 'vitest';
import { createFieldRegistry } from '../../../src/core/ingestion/model/field-registry.js';
import type { SymbolDefinition } from '../../../src/core/ingestion/model/symbol-table.js';
import { makeDef as makeBaseDef } from './helpers.js';
const makeDef = (overrides: Partial<SymbolDefinition> = {}): SymbolDefinition =>
makeBaseDef({ nodeId: 'prop:test', type: 'Property', ...overrides });
describe('FieldRegistry', () => {
it('lookupFieldByOwner returns undefined when the registry is empty', () => {
const reg = createFieldRegistry();
expect(reg.lookupFieldByOwner('class:User', 'name')).toBeUndefined();
});
it('register + lookup round-trips the exact def reference', () => {
const reg = createFieldRegistry();
const def = makeDef({ nodeId: 'prop:User.name', declaredType: 'string' });
reg.register('class:User', 'name', def);
expect(reg.lookupFieldByOwner('class:User', 'name')).toBe(def);
});
it('isolates fields by ownerNodeId — same field name on two classes does not collide', () => {
const reg = createFieldRegistry();
const userName = makeDef({ nodeId: 'prop:User.name' });
const orderName = makeDef({ nodeId: 'prop:Order.name' });
reg.register('class:User', 'name', userName);
reg.register('class:Order', 'name', orderName);
expect(reg.lookupFieldByOwner('class:User', 'name')?.nodeId).toBe('prop:User.name');
expect(reg.lookupFieldByOwner('class:Order', 'name')?.nodeId).toBe('prop:Order.name');
});
it('last-wins on duplicate (ownerNodeId, fieldName) — registry is flat, not an overload list', () => {
const reg = createFieldRegistry();
const first = makeDef({ nodeId: 'prop:User.name#first' });
const second = makeDef({ nodeId: 'prop:User.name#second' });
reg.register('class:User', 'name', first);
reg.register('class:User', 'name', second);
expect(reg.lookupFieldByOwner('class:User', 'name')?.nodeId).toBe('prop:User.name#second');
});
it('clear() empties the registry', () => {
const reg = createFieldRegistry();
reg.register('class:User', 'name', makeDef());
reg.register('class:Order', 'total', makeDef());
reg.clear();
expect(reg.lookupFieldByOwner('class:User', 'name')).toBeUndefined();
expect(reg.lookupFieldByOwner('class:Order', 'total')).toBeUndefined();
});
it('allows re-registration after clear', () => {
const reg = createFieldRegistry();
reg.register('class:User', 'name', makeDef({ nodeId: 'prop:first' }));
reg.clear();
reg.register('class:User', 'name', makeDef({ nodeId: 'prop:second' }));
expect(reg.lookupFieldByOwner('class:User', 'name')?.nodeId).toBe('prop:second');
});
});

View file

@ -0,0 +1,27 @@
/**
* Shared test helpers for the model/ unit tests.
*
* Keep this file minimal — just the factory functions that every
* registry/table test needs. Anything domain-specific belongs in the
* test file that uses it.
*/
import type { SymbolDefinition } from '../../../src/core/ingestion/model/symbol-table.js';
/**
* Build a {@link SymbolDefinition} with sensible defaults. Every field
* is overridable. Defaults produce a Method-typed def so the caller
* only has to override for other shapes.
*/
export const makeDef = (overrides: Partial<SymbolDefinition> = {}): SymbolDefinition => ({
nodeId: 'def:test',
filePath: 'src/test.ts',
type: 'Method',
...overrides,
});
/**
* Alias for {@link makeDef} kept for readability in method-registry
* tests where "makeMethod" reads more naturally at the call site.
*/
export const makeMethod = makeDef;

View file

@ -0,0 +1,374 @@
/**
* Unit tests for MethodRegistry (SM-20).
*
* MethodRegistry is the most complex of the three owner-scoped registries
* because it supports C++/Java/C# overloads. Lookup does two layers of
* narrowing after the primary `ownerNodeId + methodName` key match:
*
* 1. Arity filter: when `argCount` is provided and there are multiple
* overloads, keep only those whose parameterCount range can match.
* Variadic candidates (`parameterCount === undefined`) are retained.
* If arity excludes EVERY candidate, fall back to the full pool so
* fuzzy resolution still has something to work with (the "arity
* fallback" branch — flagged as an untested branch by the testing
* reviewer).
*
* 2. Return-type dedup: among the remaining candidates, if every def
* shares the same defined returnType, return the first. If return
* types differ, return undefined (truly ambiguous).
*/
import { describe, it, expect } from 'vitest';
import { createMethodRegistry } from '../../../src/core/ingestion/model/method-registry.js';
import { makeMethod } from './helpers.js';
describe('MethodRegistry — basic lookup', () => {
it('returns undefined when the registry is empty', () => {
const reg = createMethodRegistry();
expect(reg.lookupMethodByOwner('class:User', 'save')).toBeUndefined();
});
it('register + lookup round-trips the def reference', () => {
const reg = createMethodRegistry();
const def = makeMethod({ nodeId: 'method:User.save' });
reg.register('class:User', 'save', def);
expect(reg.lookupMethodByOwner('class:User', 'save')).toBe(def);
});
it('isolates methods by ownerNodeId — same method name on two classes does not collide', () => {
const reg = createMethodRegistry();
const userSave = makeMethod({ nodeId: 'method:User.save' });
const orderSave = makeMethod({ nodeId: 'method:Order.save' });
reg.register('class:User', 'save', userSave);
reg.register('class:Order', 'save', orderSave);
expect(reg.lookupMethodByOwner('class:User', 'save')?.nodeId).toBe('method:User.save');
expect(reg.lookupMethodByOwner('class:Order', 'save')?.nodeId).toBe('method:Order.save');
});
});
describe('MethodRegistry — arity narrowing', () => {
it('narrows overloads by parameterCount when argCount is provided', () => {
const reg = createMethodRegistry();
const greetEmpty = makeMethod({ nodeId: 'method:greet#0', parameterCount: 0 });
const greetString = makeMethod({
nodeId: 'method:greet#1',
parameterCount: 1,
returnType: 'void',
});
reg.register('class:User', 'greet', greetEmpty);
reg.register('class:User', 'greet', greetString);
// argCount 0 matches only the 0-arg overload
expect(reg.lookupMethodByOwner('class:User', 'greet', 0)?.nodeId).toBe('method:greet#0');
// argCount 1 matches only the 1-arg overload
expect(reg.lookupMethodByOwner('class:User', 'greet', 1)?.nodeId).toBe('method:greet#1');
});
it('arity fallback — when no overload matches argCount, returns from the full pool (testing reviewer T-01)', () => {
// This is the explicit arity-fallback branch flagged as untested.
// Without the fallback, `save(1)` / `save(2)` with argCount=3 would
// return undefined. With the fallback, it returns one of them so the
// caller's fuzzy resolution path can still make progress.
const reg = createMethodRegistry();
const save1 = makeMethod({ nodeId: 'method:save#1', parameterCount: 1, returnType: 'void' });
const save2 = makeMethod({ nodeId: 'method:save#2', parameterCount: 2, returnType: 'void' });
reg.register('class:User', 'save', save1);
reg.register('class:User', 'save', save2);
// argCount 3 matches neither; fallback returns one of them (first
// wins because both share the same returnType 'void').
const result = reg.lookupMethodByOwner('class:User', 'save', 3);
expect(result).toBeDefined();
expect(result?.nodeId).toBe('method:save#1');
});
it('requiredParameterCount range — argCount between required and total is accepted (testing reviewer T-02)', () => {
// Default parameters: `bar(a, b=1, c=2)` has requiredParameterCount: 1,
// parameterCount: 3. Calls with argCount 1, 2, and 3 must all match.
const reg = createMethodRegistry();
const bar = makeMethod({
nodeId: 'method:bar',
parameterCount: 3,
requiredParameterCount: 1,
returnType: 'int',
});
// Add a second overload so arity filtering engages (defs.length > 1).
const barOther = makeMethod({
nodeId: 'method:bar#other',
parameterCount: 5,
requiredParameterCount: 5,
returnType: 'int',
});
reg.register('class:Calc', 'bar', bar);
reg.register('class:Calc', 'bar', barOther);
expect(reg.lookupMethodByOwner('class:Calc', 'bar', 1)?.nodeId).toBe('method:bar');
expect(reg.lookupMethodByOwner('class:Calc', 'bar', 2)?.nodeId).toBe('method:bar');
expect(reg.lookupMethodByOwner('class:Calc', 'bar', 3)?.nodeId).toBe('method:bar');
// argCount 5 matches the second overload only
expect(reg.lookupMethodByOwner('class:Calc', 'bar', 5)?.nodeId).toBe('method:bar#other');
});
it('variadic fallback — defs with parameterCount=undefined are retained during arity narrowing', () => {
const reg = createMethodRegistry();
const fixed = makeMethod({
nodeId: 'method:print#fixed',
parameterCount: 1,
returnType: 'void',
});
const variadic = makeMethod({
nodeId: 'method:print#variadic',
parameterCount: undefined,
returnType: 'void',
});
reg.register('class:Logger', 'print', fixed);
reg.register('class:Logger', 'print', variadic);
// argCount 5 excludes fixed (5 > parameterCount 1) but retains
// variadic (parameterCount=undefined bypasses the range check).
// Result: variadic is the only surviving candidate.
const result = reg.lookupMethodByOwner('class:Logger', 'print', 5);
expect(result?.nodeId).toBe('method:print#variadic');
});
it('variadic + matching fixed — argCount in fixed range keeps both, first wins on shared returnType', () => {
const reg = createMethodRegistry();
const fixed = makeMethod({
nodeId: 'method:print#fixed',
parameterCount: 2,
returnType: 'void',
});
const variadic = makeMethod({
nodeId: 'method:print#variadic',
parameterCount: undefined,
returnType: 'void',
});
reg.register('class:Logger', 'print', fixed);
reg.register('class:Logger', 'print', variadic);
// argCount 2 satisfies fixed's range AND keeps variadic.
// Both share returnType 'void', so first-registered wins.
const result = reg.lookupMethodByOwner('class:Logger', 'print', 2);
expect(result?.nodeId).toBe('method:print#fixed');
});
});
describe('MethodRegistry — return-type dedup', () => {
it('returns first when all overloads share the same returnType', () => {
const reg = createMethodRegistry();
const a = makeMethod({ nodeId: 'method:a', parameterCount: 1, returnType: 'int' });
const b = makeMethod({ nodeId: 'method:b', parameterCount: 1, returnType: 'int' });
reg.register('class:X', 'compute', a);
reg.register('class:X', 'compute', b);
// Two overloads with same arity & same returnType → first wins
expect(reg.lookupMethodByOwner('class:X', 'compute', 1)?.nodeId).toBe('method:a');
});
it('returns undefined when overloads differ in returnType (truly ambiguous)', () => {
const reg = createMethodRegistry();
const intVersion = makeMethod({
nodeId: 'method:int',
parameterCount: 1,
returnType: 'int',
});
const stringVersion = makeMethod({
nodeId: 'method:string',
parameterCount: 1,
returnType: 'string',
});
reg.register('class:X', 'compute', intVersion);
reg.register('class:X', 'compute', stringVersion);
// Same arity, different returnType → undefined (ambiguous)
expect(reg.lookupMethodByOwner('class:X', 'compute', 1)).toBeUndefined();
});
it('returns undefined when firstReturnType is itself undefined', () => {
const reg = createMethodRegistry();
const a = makeMethod({ nodeId: 'method:a', parameterCount: 1, returnType: undefined });
const b = makeMethod({ nodeId: 'method:b', parameterCount: 1, returnType: 'int' });
reg.register('class:X', 'compute', a);
reg.register('class:X', 'compute', b);
// First def has no declared returnType → bail out as undefined
expect(reg.lookupMethodByOwner('class:X', 'compute', 1)).toBeUndefined();
});
it('single-overload methods skip the dedup path', () => {
const reg = createMethodRegistry();
reg.register(
'class:X',
'only',
makeMethod({ nodeId: 'method:only', parameterCount: 1, returnType: undefined }),
);
// Only one candidate → returned directly regardless of returnType
expect(reg.lookupMethodByOwner('class:X', 'only', 1)?.nodeId).toBe('method:only');
});
});
describe('MethodRegistry — clear()', () => {
it('empties the registry', () => {
const reg = createMethodRegistry();
reg.register('class:User', 'save', makeMethod());
reg.register('class:Order', 'update', makeMethod());
reg.clear();
expect(reg.lookupMethodByOwner('class:User', 'save')).toBeUndefined();
expect(reg.lookupMethodByOwner('class:Order', 'update')).toBeUndefined();
});
it('allows re-registration after clear', () => {
const reg = createMethodRegistry();
reg.register('class:User', 'save', makeMethod({ nodeId: 'method:first' }));
reg.clear();
reg.register('class:User', 'save', makeMethod({ nodeId: 'method:second' }));
expect(reg.lookupMethodByOwner('class:User', 'save')?.nodeId).toBe('method:second');
});
});
// ---------------------------------------------------------------------------
// lookupMethodByName — flat-by-name secondary index (A4 / plan 006)
// ---------------------------------------------------------------------------
describe('MethodRegistry — lookupMethodByName', () => {
it('returns an empty array when no method with that name is registered', () => {
const reg = createMethodRegistry();
expect(reg.lookupMethodByName('save')).toEqual([]);
});
it('returns a singleton array after one registration', () => {
const reg = createMethodRegistry();
const def = makeMethod({ nodeId: 'method:User.save' });
reg.register('class:User', 'save', def);
const result = reg.lookupMethodByName('save');
expect(result).toHaveLength(1);
expect(result[0]).toBe(def);
});
it('accumulates homonym registrations across different owners in order', () => {
const reg = createMethodRegistry();
const userSave = makeMethod({ nodeId: 'method:User.save' });
const orderSave = makeMethod({ nodeId: 'method:Order.save' });
reg.register('class:User', 'save', userSave);
reg.register('class:Order', 'save', orderSave);
const result = reg.lookupMethodByName('save');
expect(result).toHaveLength(2);
expect(result).toEqual([userSave, orderSave]);
});
it('accumulates overloads under the same owner', () => {
const reg = createMethodRegistry();
const overload1 = makeMethod({ nodeId: 'method:User.save#0', parameterCount: 0 });
const overload2 = makeMethod({ nodeId: 'method:User.save#1', parameterCount: 1 });
reg.register('class:User', 'save', overload1);
reg.register('class:User', 'save', overload2);
const result = reg.lookupMethodByName('save');
expect(result).toHaveLength(2);
expect(result).toEqual([overload1, overload2]);
});
it('returns an empty array after clear()', () => {
const reg = createMethodRegistry();
reg.register('class:User', 'save', makeMethod({ nodeId: 'method:old' }));
reg.clear();
expect(reg.lookupMethodByName('save')).toEqual([]);
});
it('re-registering after clear only returns post-clear defs', () => {
const reg = createMethodRegistry();
reg.register('class:User', 'save', makeMethod({ nodeId: 'method:old' }));
reg.clear();
const fresh = makeMethod({ nodeId: 'method:fresh' });
reg.register('class:User', 'save', fresh);
const result = reg.lookupMethodByName('save');
expect(result).toHaveLength(1);
expect(result[0]).toBe(fresh);
});
it('returns the same SymbolDefinition reference as lookupMethodByOwner (dual-index identity)', () => {
const reg = createMethodRegistry();
const def = makeMethod({ nodeId: 'method:User.save' });
reg.register('class:User', 'save', def);
const byOwner = reg.lookupMethodByOwner('class:User', 'save');
const byName = reg.lookupMethodByName('save');
expect(byName).toHaveLength(1);
expect(Object.is(byName[0], byOwner)).toBe(true);
});
it('does not return methods with different names', () => {
const reg = createMethodRegistry();
reg.register('class:User', 'save', makeMethod({ nodeId: 'method:User.save' }));
reg.register('class:User', 'load', makeMethod({ nodeId: 'method:User.load' }));
expect(reg.lookupMethodByName('save')).toHaveLength(1);
expect(reg.lookupMethodByName('load')).toHaveLength(1);
expect(reg.lookupMethodByName('missing')).toEqual([]);
});
});
describe('hasFunctionMethods flag', () => {
it('is false for a fresh registry', () => {
const reg = createMethodRegistry();
expect(reg.hasFunctionMethods).toBe(false);
});
it('stays false after registering only strict-Method defs', () => {
const reg = createMethodRegistry();
reg.register('class:User', 'save', makeMethod({ nodeId: 'method:User.save', type: 'Method' }));
reg.register(
'class:User',
'load',
makeMethod({ nodeId: 'method:User.load', type: 'Constructor' }),
);
expect(reg.hasFunctionMethods).toBe(false);
});
it('flips to true when a Function-typed def (Python/Rust/Kotlin class method) is registered', () => {
const reg = createMethodRegistry();
reg.register('class:User', 'greet', makeMethod({ nodeId: 'fn:User.greet', type: 'Function' }));
expect(reg.hasFunctionMethods).toBe(true);
});
it('stays true after further strict-Method registrations', () => {
const reg = createMethodRegistry();
reg.register('class:User', 'greet', makeMethod({ nodeId: 'fn:User.greet', type: 'Function' }));
reg.register('class:Dog', 'bark', makeMethod({ nodeId: 'method:Dog.bark', type: 'Method' }));
expect(reg.hasFunctionMethods).toBe(true);
});
it('resets to false after clear()', () => {
const reg = createMethodRegistry();
reg.register('class:User', 'greet', makeMethod({ nodeId: 'fn:User.greet', type: 'Function' }));
expect(reg.hasFunctionMethods).toBe(true);
reg.clear();
expect(reg.hasFunctionMethods).toBe(false);
});
});

View file

@ -0,0 +1,267 @@
import { describe, it, expect } from 'vitest';
import {
createRegistrationTable,
CALLABLE_ONLY_LABELS,
INERT_LABELS,
DISPATCH_LABELS,
} from '../../../src/core/ingestion/model/registration-table.js';
import { createTypeRegistry } from '../../../src/core/ingestion/model/type-registry.js';
import { createMethodRegistry } from '../../../src/core/ingestion/model/method-registry.js';
import { createFieldRegistry } from '../../../src/core/ingestion/model/field-registry.js';
import { ALL_NODE_LABELS } from '../../../src/core/ingestion/model/index.js';
import type { SymbolDefinition } from '../../../src/core/ingestion/model/symbol-table.js';
import { makeDef as makeBaseDef } from './helpers.js';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const makeDeps = () => ({
types: createTypeRegistry(),
methods: createMethodRegistry(),
fields: createFieldRegistry(),
});
const makeDef = (overrides: Partial<SymbolDefinition> = {}): SymbolDefinition =>
makeBaseDef({ nodeId: 'node:test', type: 'Class', ...overrides });
// ---------------------------------------------------------------------------
// Basic factory + table shape
// ---------------------------------------------------------------------------
describe('createRegistrationTable', () => {
it('returns a Map with one entry per DISPATCH_LABELS value', () => {
const table = createRegistrationTable(makeDeps());
expect(table.size).toBe(DISPATCH_LABELS.size);
for (const label of DISPATCH_LABELS) {
expect(table.has(label)).toBe(true);
}
});
it('every DISPATCH_LABELS entry maps to a hook function', () => {
const table = createRegistrationTable(makeDeps());
for (const [, hook] of table) {
expect(typeof hook).toBe('function');
}
});
});
// ---------------------------------------------------------------------------
// Kind taxonomy exhaustiveness
// ---------------------------------------------------------------------------
describe('NodeLabel taxonomy coverage', () => {
// ALL_NODE_LABELS is imported from model/index.ts (re-exported from
// semantic-model.ts) so that the production list and the test list
// cannot drift. If the shared NodeLabel union gains a new member, add
// it to the single list in semantic-model.ts AND to one of the
// registration-table allowlists in the same commit.
it('every NodeLabel appears in exactly one of DISPATCH / CALLABLE_ONLY / INERT', () => {
for (const label of ALL_NODE_LABELS) {
const inDispatch = DISPATCH_LABELS.has(label);
const inCallableOnly = CALLABLE_ONLY_LABELS.has(label);
const inInert = INERT_LABELS.has(label);
const count = Number(inDispatch) + Number(inCallableOnly) + Number(inInert);
expect(count, `label ${label} must be in exactly one category`).toBe(1);
}
});
it('CALLABLE_ONLY_LABELS includes Function, Macro, Delegate', () => {
expect(CALLABLE_ONLY_LABELS.has('Function')).toBe(true);
expect(CALLABLE_ONLY_LABELS.has('Macro')).toBe(true);
expect(CALLABLE_ONLY_LABELS.has('Delegate')).toBe(true);
});
it('DISPATCH_LABELS includes all 10 routed kinds', () => {
const expected = [
'Class',
'Struct',
'Interface',
'Enum',
'Record',
'Trait',
'Method',
'Constructor',
'Property',
'Impl',
] as const;
for (const label of expected) {
expect(DISPATCH_LABELS.has(label)).toBe(true);
}
expect(DISPATCH_LABELS.size).toBe(expected.length);
});
it('INERT_LABELS includes metadata-only node kinds', () => {
expect(INERT_LABELS.has('File')).toBe(true);
expect(INERT_LABELS.has('Folder')).toBe(true);
expect(INERT_LABELS.has('Namespace')).toBe(true);
expect(INERT_LABELS.has('Variable')).toBe(true);
expect(INERT_LABELS.has('Import')).toBe(true);
});
});
// ---------------------------------------------------------------------------
// Behavior group coverage — every label in a behavior group routes to the
// group's registry write, regardless of how hooks are implemented (shared
// closure, per-label closure, etc.). These tests survive an internal
// refactor to per-label closures for tracing/metrics — unlike
// reference-equality assertions on the hook functions themselves.
// ---------------------------------------------------------------------------
describe('class-like behavior group — all 6 labels route to types.registerClass', () => {
const CLASS_LIKE_LABELS = ['Class', 'Struct', 'Interface', 'Enum', 'Record', 'Trait'] as const;
for (const label of CLASS_LIKE_LABELS) {
it(`${label} writes to types.registerClass`, () => {
const deps = makeDeps();
const table = createRegistrationTable(deps);
const def = makeDef({
nodeId: `${label.toLowerCase()}:User`,
type: label,
qualifiedName: `app.User`,
});
table.get(label)!('User', def);
expect(deps.types.lookupClassByName('User')).toHaveLength(1);
});
}
});
describe('method-like behavior group — Method and Constructor route to methods.register', () => {
for (const label of ['Method', 'Constructor'] as const) {
it(`${label} writes to methods.register when ownerId is set`, () => {
const deps = makeDeps();
const table = createRegistrationTable(deps);
const def = makeDef({
nodeId: `${label.toLowerCase()}:save`,
type: label,
ownerId: 'class:User',
});
table.get(label)!('save', def);
expect(deps.methods.lookupMethodByOwner('class:User', 'save')?.nodeId).toBe(
`${label.toLowerCase()}:save`,
);
});
}
});
describe('behavior group isolation', () => {
it('class-like hooks never touch methods or fields', () => {
const deps = makeDeps();
const table = createRegistrationTable(deps);
const def = makeDef({
nodeId: 'class:User',
type: 'Class',
ownerId: 'unrelated',
});
table.get('Class')!('User', def);
// No method or field registered — class hook is isolated to types.
expect(deps.methods.lookupMethodByOwner('unrelated', 'User')).toBeUndefined();
expect(deps.fields.lookupFieldByOwner('unrelated', 'User')).toBeUndefined();
});
it('Impl hooks write to types.registerImpl, never to types.registerClass', () => {
const deps = makeDeps();
const table = createRegistrationTable(deps);
const def = makeDef({ nodeId: 'impl:User', type: 'Impl' });
table.get('Impl')!('User', def);
expect(deps.types.lookupImplByName('User')).toHaveLength(1);
expect(deps.types.lookupClassByName('User')).toHaveLength(0);
});
});
// ---------------------------------------------------------------------------
// End-to-end hook behavior with real registries
// ---------------------------------------------------------------------------
describe('hook behavior (real registries, no mocks)', () => {
it('classLikeHook writes to types.registerClass', () => {
const deps = makeDeps();
const table = createRegistrationTable(deps);
const def = makeDef({ nodeId: 'class:User', type: 'Class', qualifiedName: 'app.User' });
table.get('Class')!('User', def);
expect(deps.types.lookupClassByName('User')).toHaveLength(1);
expect(deps.types.lookupClassByQualifiedName('app.User')).toHaveLength(1);
});
it('classLikeHook falls back to the simple name when qualifiedName is absent', () => {
const deps = makeDeps();
const table = createRegistrationTable(deps);
const def = makeDef({ nodeId: 'class:User', type: 'Class' });
table.get('Class')!('User', def);
expect(deps.types.lookupClassByQualifiedName('User')).toHaveLength(1);
});
it('methodHook writes to methods.register when ownerId is set', () => {
const deps = makeDeps();
const table = createRegistrationTable(deps);
const def = makeDef({
nodeId: 'mtd:save',
type: 'Method',
ownerId: 'class:User',
});
table.get('Method')!('save', def);
expect(deps.methods.lookupMethodByOwner('class:User', 'save')?.nodeId).toBe('mtd:save');
});
it('methodHook silently skips registration when ownerId is missing', () => {
const deps = makeDeps();
const table = createRegistrationTable(deps);
const def = makeDef({ nodeId: 'mtd:free', type: 'Method' });
table.get('Method')!('free', def);
expect(deps.methods.lookupMethodByOwner('', 'free')).toBeUndefined();
});
it('propertyHook writes to fields.register when ownerId is set', () => {
const deps = makeDeps();
const table = createRegistrationTable(deps);
const def = makeDef({
nodeId: 'prop:name',
type: 'Property',
ownerId: 'class:User',
declaredType: 'string',
});
table.get('Property')!('name', def);
expect(deps.fields.lookupFieldByOwner('class:User', 'name')?.nodeId).toBe('prop:name');
});
it('propertyHook silently skips registration when ownerId is missing', () => {
const deps = makeDeps();
const table = createRegistrationTable(deps);
const def = makeDef({ nodeId: 'prop:orphan', type: 'Property' });
table.get('Property')!('orphan', def);
expect(deps.fields.lookupFieldByOwner('', 'orphan')).toBeUndefined();
});
it('implHook writes to types.registerImpl, NOT types.registerClass', () => {
const deps = makeDeps();
const table = createRegistrationTable(deps);
const def = makeDef({ nodeId: 'impl:User', type: 'Impl' });
table.get('Impl')!('User', def);
expect(deps.types.lookupImplByName('User')).toHaveLength(1);
// Critical: Impl must not pollute classByName — heritage resolution
// would otherwise treat an Impl as a parent type candidate.
expect(deps.types.lookupClassByName('User')).toHaveLength(0);
});
});
// ---------------------------------------------------------------------------
// Factory-per-instance isolation
// ---------------------------------------------------------------------------
describe('factory-per-instance isolation', () => {
it('two independent tables write to their own registries only', () => {
const depsA = makeDeps();
const depsB = makeDeps();
const tableA = createRegistrationTable(depsA);
const tableB = createRegistrationTable(depsB);
tableA.get('Class')!('UserA', makeDef({ nodeId: 'class:UserA', type: 'Class' }));
tableB.get('Class')!('UserB', makeDef({ nodeId: 'class:UserB', type: 'Class' }));
expect(depsA.types.lookupClassByName('UserA')).toHaveLength(1);
expect(depsA.types.lookupClassByName('UserB')).toHaveLength(0);
expect(depsB.types.lookupClassByName('UserB')).toHaveLength(1);
expect(depsB.types.lookupClassByName('UserA')).toHaveLength(0);
});
});

View file

@ -0,0 +1,173 @@
/**
* Unit tests for `ResolutionContext.resolve()` — the tiered name
* resolution that backs call-processor's Tier 1 / 2a-named / 2a / 2b / 3
* pipeline. These tests pin invariants that TypeScript cannot prove at
* build time: tier precedence, cross-index dedup, and the
* walkBindingChain cycle/depth guards.
*/
import { describe, it, expect } from 'vitest';
import { createResolutionContext } from '../../../src/core/ingestion/model/resolution-context.js';
describe('ResolutionContext.resolve() — tier precedence', () => {
it('Tier 2a-named binding chain takes precedence over Tier 2a import-scoped', () => {
// Setup: A imports { User as U } from B. B defines both User (the
// real one) and U (an unrelated same-name symbol). A resolve('U') in
// file A must prefer the aliased binding chain (U → User in B),
// NOT the raw Tier 2a lookup that would find B's own 'U'.
const ctx = createResolutionContext();
ctx.model.symbols.add('src/b.ts', 'User', 'class:User', 'Class');
ctx.model.symbols.add('src/b.ts', 'U', 'class:U_decoy', 'Class');
// Register the import A → B and the aliased binding A.U → B.User.
ctx.importMap.set('src/a.ts', new Set(['src/b.ts']));
const aliasBindings = new Map();
aliasBindings.set('U', { sourcePath: 'src/b.ts', exportedName: 'User' });
ctx.namedImportMap.set('src/a.ts', aliasBindings);
const result = ctx.resolve('U', 'src/a.ts');
expect(result).not.toBeNull();
expect(result!.tier).toBe('import-scoped');
// The named-binding chain resolves U → User, not U → U_decoy.
expect(result!.candidates.map((c) => c.nodeId)).toEqual(['class:User']);
});
it('Tier 1 (same-file) beats Tier 2a even when an aliased import exists', () => {
// Belt-and-suspenders check: if the caller's own file has a matching
// symbol, it wins — aliased bindings only fire when Tier 1 misses.
const ctx = createResolutionContext();
ctx.model.symbols.add('src/a.ts', 'U', 'fn:local:U', 'Function');
ctx.model.symbols.add('src/b.ts', 'User', 'class:User', 'Class');
const aliasBindings = new Map();
aliasBindings.set('U', { sourcePath: 'src/b.ts', exportedName: 'User' });
ctx.namedImportMap.set('src/a.ts', aliasBindings);
const result = ctx.resolve('U', 'src/a.ts');
expect(result!.tier).toBe('same-file');
expect(result!.candidates[0].nodeId).toBe('fn:local:U');
});
});
describe('ResolutionContext.resolve() — Tier 3 dedup for Function+ownerId', () => {
it('Python/Rust/Kotlin class methods emitted as Function+ownerId land in only one Tier 3 result', () => {
// Simulate the Python worker path: a class method is emitted with
// type='Function' and ownerId set. `rawSymbols.add` lands it in
// callableByName (via the Function callable-index gate) AND
// `wrappedAdd` normalizes the dispatch key to 'Method' so it also
// lands in methodRegistry. The same SymbolDefinition reference is
// reachable via two Tier 3 lookups.
const ctx = createResolutionContext();
ctx.model.symbols.add('src/user.py', 'User', 'class:User', 'Class');
ctx.model.symbols.add('src/user.py', 'greet', 'fn:User.greet', 'Function', {
ownerId: 'class:User',
returnType: 'str',
});
// Sanity check the setup: the same def is in both indexes.
expect(ctx.model.symbols.lookupCallableByName('greet')).toHaveLength(1);
expect(ctx.model.methods.lookupMethodByName('greet')).toHaveLength(1);
expect(ctx.model.methods.hasFunctionMethods).toBe(true);
// Resolve a free 'greet' call from an unrelated file — Tier 1 / 2a /
// 2b all miss, so Tier 3 fires. The dedup pass must collapse the
// two index hits into a single candidate.
const result = ctx.resolve('greet', 'src/caller.py');
expect(result).not.toBeNull();
expect(result!.tier).toBe('global');
expect(result!.candidates).toHaveLength(1);
expect(result!.candidates[0].nodeId).toBe('fn:User.greet');
});
it('Tier 3 fast path fires when no Function+ownerId was ever registered', () => {
// Pure TypeScript-style: methods are emitted as strict Method labels,
// so callableByName and methodRegistry are disjoint and the dedup
// fast path can concat without a Set allocation.
const ctx = createResolutionContext();
ctx.model.symbols.add('src/user.ts', 'User', 'class:User', 'Class');
ctx.model.symbols.add('src/user.ts', 'greet', 'method:User.greet', 'Method', {
ownerId: 'class:User',
returnType: 'string',
});
ctx.model.symbols.add('src/utils.ts', 'greet', 'fn:utils.greet', 'Function');
expect(ctx.model.methods.hasFunctionMethods).toBe(false);
// Tier 3 for 'greet' from an unrelated file returns both the free
// function and the class method; neither overlaps so no dedup.
const result = ctx.resolve('greet', 'src/caller.ts');
expect(result).not.toBeNull();
expect(result!.tier).toBe('global');
expect(result!.candidates.map((c) => c.nodeId).sort()).toEqual([
'fn:utils.greet',
'method:User.greet',
]);
});
});
describe('ResolutionContext.resolve() — walkBindingChain guards', () => {
it('circular re-export returns null (cycle detection fires)', () => {
// A imports { X } from B, B re-exports { X } from A.
// walkBindingChain must detect the cycle via the visited Set and
// return null instead of looping until depth exceeded.
const ctx = createResolutionContext();
// Intentionally leave X undefined in both files — the walker only
// follows re-export edges, not definitions.
const aBindings = new Map();
aBindings.set('X', { sourcePath: 'src/b.ts', exportedName: 'X' });
ctx.namedImportMap.set('src/a.ts', aBindings);
const bBindings = new Map();
bBindings.set('X', { sourcePath: 'src/a.ts', exportedName: 'X' });
ctx.namedImportMap.set('src/b.ts', bBindings);
const result = ctx.resolve('X', 'src/a.ts');
// No definition anywhere in the chain → Tier 2a-named returns null,
// nothing else matches, overall result is null.
expect(result).toBeNull();
});
it('chain deeper than MAX_BINDING_CHAIN_DEPTH drops the named-binding path', () => {
// Build a six-hop re-export chain where every hop just forwards the
// binding. walkBindingChain iterates 5 times and hits the depth cap
// before the sixth hop, returning null. No other tier can resolve
// 'X' either (no X is registered anywhere), so the overall
// `ctx.resolve` call returns null.
const ctx = createResolutionContext();
const chain = [
'src/a.ts',
'src/b.ts',
'src/c.ts',
'src/d.ts',
'src/e.ts',
'src/f.ts',
'src/g.ts',
];
for (let i = 0; i < chain.length - 1; i++) {
const bindings = new Map();
bindings.set('X', { sourcePath: chain[i + 1], exportedName: 'X' });
ctx.namedImportMap.set(chain[i], bindings);
}
// No symbol registered in any file — the chain walk is the only
// possible resolution path, and the depth cap silently kills it.
const result = ctx.resolve('X', 'src/a.ts');
expect(result).toBeNull();
});
it('chain of exactly five hops resolves successfully at the boundary', () => {
// Five hops from A is exactly MAX_BINDING_CHAIN_DEPTH — the final
// lookup on the fifth hop must succeed.
const ctx = createResolutionContext();
ctx.model.symbols.add('src/e.ts', 'X', 'class:X', 'Class');
const chain = ['src/a.ts', 'src/b.ts', 'src/c.ts', 'src/d.ts', 'src/e.ts'];
for (let i = 0; i < chain.length - 1; i++) {
const bindings = new Map();
bindings.set('X', { sourcePath: chain[i + 1], exportedName: 'X' });
ctx.namedImportMap.set(chain[i], bindings);
}
const result = ctx.resolve('X', 'src/a.ts');
expect(result).not.toBeNull();
expect(result!.tier).toBe('import-scoped');
expect(result!.candidates[0].nodeId).toBe('class:X');
});
});

View file

@ -0,0 +1,124 @@
/**
* Unit tests for SemanticModel factory and lifecycle.
*
* Focused on behaviors that are NOT covered by the transitive
* ingestion-pipeline tests in symbol-table.test.ts:
*
* 1. model.clear() must cascade to all four stores (types, methods,
* fields, rawSymbols). Post-A2 (plan 006 Unit 7), this is the only
* path that resets the leaf AND the registries. External consumers
* hold a SymbolTableReader which has no `clear()` method, so the
* phantom-resolution failure mode is statically impossible.
*
* 2. createSemanticModel() must construct successfully against the
* real ALL_NODE_LABELS and current registration-table allowlists.
* A failure here means the dev-time exhaustiveness guard is
* flagging real drift that needs a registration-table fix.
*/
import { describe, it, expect } from 'vitest';
import { createSemanticModel } from '../../../src/core/ingestion/model/semantic-model.js';
describe('createSemanticModel', () => {
it('constructs successfully — no drift between ALL_NODE_LABELS and the registration-table allowlists', () => {
expect(() => createSemanticModel()).not.toThrow();
});
});
describe('model.clear() cascade (A2 / Unit 7)', () => {
it('clears the type registry', () => {
const model = createSemanticModel();
model.symbols.add('src/user.ts', 'User', 'class:User', 'Class');
expect(model.types.lookupClassByName('User')).toHaveLength(1);
model.clear();
expect(model.types.lookupClassByName('User')).toHaveLength(0);
});
it('clears the field registry', () => {
const model = createSemanticModel();
model.symbols.add('src/user.ts', 'User', 'class:User', 'Class');
model.symbols.add('src/user.ts', 'name', 'prop:User.name', 'Property', {
ownerId: 'class:User',
declaredType: 'string',
});
expect(model.fields.lookupFieldByOwner('class:User', 'name')).toBeDefined();
model.clear();
expect(model.fields.lookupFieldByOwner('class:User', 'name')).toBeUndefined();
});
it('clears the method registry', () => {
const model = createSemanticModel();
model.symbols.add('src/user.ts', 'User', 'class:User', 'Class');
model.symbols.add('src/user.ts', 'greet', 'method:User.greet', 'Method', {
ownerId: 'class:User',
});
expect(model.methods.lookupMethodByOwner('class:User', 'greet')).toBeDefined();
model.clear();
expect(model.methods.lookupMethodByOwner('class:User', 'greet')).toBeUndefined();
});
it('clears the file and callable indexes', () => {
const model = createSemanticModel();
model.symbols.add('src/utils.ts', 'format', 'fn:format', 'Function');
expect(model.symbols.lookupCallableByName('format')).toHaveLength(1);
expect(Array.from(model.symbols.getFiles())).toContain('src/utils.ts');
model.clear();
expect(model.symbols.lookupCallableByName('format')).toHaveLength(0);
expect(Array.from(model.symbols.getFiles())).not.toContain('src/utils.ts');
});
it('is idempotent — calling twice leaves every store empty', () => {
const model = createSemanticModel();
model.symbols.add('src/user.ts', 'User', 'class:User', 'Class');
model.symbols.add('src/user.ts', 'name', 'prop:User.name', 'Property', {
ownerId: 'class:User',
});
model.clear();
model.clear();
expect(model.types.lookupClassByName('User')).toHaveLength(0);
expect(model.fields.lookupFieldByOwner('class:User', 'name')).toBeUndefined();
expect(model.symbols.lookupCallableByName('User')).toHaveLength(0);
});
it('post-A2: model.symbols exposes no clear() method', () => {
// Static guarantee enforced by the SymbolTableReader interface — this
// runtime assertion documents the contract.
const model = createSemanticModel();
expect('clear' in model.symbols).toBe(false);
});
});
describe('model.clear() cascade', () => {
it('clears every store — types, methods, fields, symbols', () => {
const model = createSemanticModel();
model.symbols.add('src/user.ts', 'User', 'class:User', 'Class');
model.symbols.add('src/user.ts', 'name', 'prop:User.name', 'Property', {
ownerId: 'class:User',
});
model.symbols.add('src/user.ts', 'greet', 'method:User.greet', 'Method', {
ownerId: 'class:User',
});
model.clear();
expect(model.types.lookupClassByName('User')).toHaveLength(0);
expect(model.fields.lookupFieldByOwner('class:User', 'name')).toBeUndefined();
expect(model.methods.lookupMethodByOwner('class:User', 'greet')).toBeUndefined();
expect(model.symbols.lookupCallableByName('User')).toHaveLength(0);
expect(Array.from(model.symbols.getFiles())).not.toContain('src/user.ts');
});
});

View file

@ -0,0 +1,146 @@
/**
* Unit tests for TypeRegistry (SM-20).
*
* TypeRegistry owns three indexes: classByName (simple name → defs),
* classByQualifiedName (FQN → defs), and implByName (Rust impl blocks).
* All three use array values to support homonym classes across files
* (e.g. two `User` classes in different packages) and Rust's multiple
* impl blocks per type.
*/
import { describe, it, expect } from 'vitest';
import { createTypeRegistry } from '../../../src/core/ingestion/model/type-registry.js';
import type { SymbolDefinition } from '../../../src/core/ingestion/model/symbol-table.js';
import { makeDef as makeBaseDef } from './helpers.js';
const makeDef = (overrides: Partial<SymbolDefinition> = {}): SymbolDefinition =>
makeBaseDef({ nodeId: 'class:test', type: 'Class', ...overrides });
describe('TypeRegistry — classByName lookup', () => {
it('returns an empty array when the class is not registered', () => {
const reg = createTypeRegistry();
expect(reg.lookupClassByName('Nonexistent')).toEqual([]);
});
it('returns the def reference after register', () => {
const reg = createTypeRegistry();
const def = makeDef({ nodeId: 'class:User' });
reg.registerClass('User', 'app.User', def);
expect(reg.lookupClassByName('User')).toEqual([def]);
});
it('accumulates homonym classes across files — second register appends, does not clobber', () => {
const reg = createTypeRegistry();
const userApp = makeDef({ nodeId: 'class:app.User', filePath: 'src/app/user.ts' });
const userAdmin = makeDef({ nodeId: 'class:admin.User', filePath: 'src/admin/user.ts' });
reg.registerClass('User', 'app.User', userApp);
reg.registerClass('User', 'admin.User', userAdmin);
const result = reg.lookupClassByName('User');
expect(result).toHaveLength(2);
expect(result.map((d) => d.nodeId)).toEqual(['class:app.User', 'class:admin.User']);
});
});
describe('TypeRegistry — classByQualifiedName lookup', () => {
it('returns empty when the FQN is not registered', () => {
const reg = createTypeRegistry();
expect(reg.lookupClassByQualifiedName('app.User')).toEqual([]);
});
it('returns the def after register', () => {
const reg = createTypeRegistry();
const def = makeDef({ nodeId: 'class:app.User' });
reg.registerClass('User', 'app.User', def);
expect(reg.lookupClassByQualifiedName('app.User')).toEqual([def]);
});
it('disambiguates homonym classes — same simple name, different FQNs resolve independently', () => {
const reg = createTypeRegistry();
const userApp = makeDef({ nodeId: 'class:app.User' });
const userAdmin = makeDef({ nodeId: 'class:admin.User' });
reg.registerClass('User', 'app.User', userApp);
reg.registerClass('User', 'admin.User', userAdmin);
// Simple name returns both; qualified lookups split cleanly.
expect(reg.lookupClassByName('User')).toHaveLength(2);
expect(reg.lookupClassByQualifiedName('app.User')).toEqual([userApp]);
expect(reg.lookupClassByQualifiedName('admin.User')).toEqual([userAdmin]);
});
it('partial classes — two defs with the same FQN accumulate in both indexes', () => {
// C#-style partial classes: same simple and qualified name in different
// files. Both classByName and classByQualifiedName should return both.
const reg = createTypeRegistry();
const partialA = makeDef({ nodeId: 'class:User#a', filePath: 'src/User.Core.cs' });
const partialB = makeDef({ nodeId: 'class:User#b', filePath: 'src/User.Api.cs' });
reg.registerClass('User', 'app.User', partialA);
reg.registerClass('User', 'app.User', partialB);
expect(reg.lookupClassByName('User')).toHaveLength(2);
expect(reg.lookupClassByQualifiedName('app.User')).toHaveLength(2);
});
});
describe('TypeRegistry — implByName (Rust impl blocks)', () => {
it('returns empty when no impls registered', () => {
const reg = createTypeRegistry();
expect(reg.lookupImplByName('User')).toEqual([]);
});
it('registerImpl stores Rust impl blocks separately from classes', () => {
const reg = createTypeRegistry();
const userClass = makeDef({ nodeId: 'class:User', type: 'Struct' });
const userImpl = makeDef({ nodeId: 'impl:User', type: 'Impl' });
reg.registerClass('User', 'crate::User', userClass);
reg.registerImpl('User', userImpl);
expect(reg.lookupClassByName('User')).toEqual([userClass]);
expect(reg.lookupImplByName('User')).toEqual([userImpl]);
});
it('accumulates multiple impl blocks for the same type (Rust allows several)', () => {
const reg = createTypeRegistry();
const implA = makeDef({ nodeId: 'impl:User#inherent', type: 'Impl' });
const implB = makeDef({ nodeId: 'impl:User#Display', type: 'Impl' });
reg.registerImpl('User', implA);
reg.registerImpl('User', implB);
const impls = reg.lookupImplByName('User');
expect(impls).toHaveLength(2);
expect(impls.map((d) => d.nodeId)).toEqual(['impl:User#inherent', 'impl:User#Display']);
});
});
describe('TypeRegistry — clear()', () => {
it('empties all three indexes', () => {
const reg = createTypeRegistry();
reg.registerClass('User', 'app.User', makeDef());
reg.registerImpl('User', makeDef({ type: 'Impl' }));
reg.clear();
expect(reg.lookupClassByName('User')).toEqual([]);
expect(reg.lookupClassByQualifiedName('app.User')).toEqual([]);
expect(reg.lookupImplByName('User')).toEqual([]);
});
it('allows re-registration after clear', () => {
const reg = createTypeRegistry();
reg.registerClass('User', 'app.User', makeDef({ nodeId: 'class:first' }));
reg.clear();
reg.registerClass('User', 'app.User', makeDef({ nodeId: 'class:second' }));
expect(reg.lookupClassByName('User')).toHaveLength(1);
expect(reg.lookupClassByName('User')[0].nodeId).toBe('class:second');
});
});

View file

@ -14,7 +14,7 @@ import { createASTCache } from '../../src/core/ingestion/ast-cache.js';
import { processImports } from '../../src/core/ingestion/import-processor.js';
import { processCalls } from '../../src/core/ingestion/call-processor.js';
import { processHeritage } from '../../src/core/ingestion/heritage-processor.js';
import { createResolutionContext } from '../../src/core/ingestion/resolution-context.js';
import { createResolutionContext } from '../../src/core/ingestion/model/resolution-context.js';
import * as parserLoader from '../../src/core/tree-sitter/parser-loader.js';
describe('sequential native parser availability', () => {

View file

@ -2,9 +2,10 @@ import { describe, it, expect, beforeEach } from 'vitest';
import {
createResolutionContext,
type ResolutionContext,
} from '../../src/core/ingestion/resolution-context.js';
import { createSymbolTable } from '../../src/core/ingestion/symbol-table.js';
import { isFileInPackageDir } from '../../src/core/ingestion/import-processor.js';
} from '../../src/core/ingestion/model/resolution-context.js';
import { createSymbolTable } from '../../src/core/ingestion/model/symbol-table.js';
import { createSemanticModel } from '../../src/core/ingestion/model/semantic-model.js';
import { isFileInPackageDir } from '../../src/core/ingestion/model/resolution-context.js';
/** Helper: resolve to single best definition (refuses ambiguous global) */
const resolveOne = (ctx: ResolutionContext, name: string, fromFile: string) => {
@ -35,7 +36,7 @@ describe('ResolutionContext.resolve — resolveSymbol compatibility', () => {
describe('Tier 1: Same-file resolution', () => {
it('resolves symbol defined in the same file', () => {
ctx.symbols.add('src/models/user.ts', 'User', 'Class:src/models/user.ts:User', 'Class');
ctx.model.symbols.add('src/models/user.ts', 'User', 'Class:src/models/user.ts:User', 'Class');
const result = resolveOne(ctx, 'User', 'src/models/user.ts');
@ -46,8 +47,8 @@ describe('ResolutionContext.resolve — resolveSymbol compatibility', () => {
});
it('prefers same-file over imported definition', () => {
ctx.symbols.add('src/local.ts', 'Config', 'Class:src/local.ts:Config', 'Class');
ctx.symbols.add('src/shared.ts', 'Config', 'Class:src/shared.ts:Config', 'Class');
ctx.model.symbols.add('src/local.ts', 'Config', 'Class:src/local.ts:Config', 'Class');
ctx.model.symbols.add('src/shared.ts', 'Config', 'Class:src/shared.ts:Config', 'Class');
ctx.importMap.set('src/local.ts', new Set(['src/shared.ts']));
const result = resolveOne(ctx, 'Config', 'src/local.ts');
@ -59,7 +60,7 @@ describe('ResolutionContext.resolve — resolveSymbol compatibility', () => {
describe('Tier 2: Import-scoped resolution', () => {
it('resolves symbol from an imported file', () => {
ctx.symbols.add(
ctx.model.symbols.add(
'src/services/auth.ts',
'AuthService',
'Class:src/services/auth.ts:AuthService',
@ -75,13 +76,13 @@ describe('ResolutionContext.resolve — resolveSymbol compatibility', () => {
});
it('prefers imported definition over non-imported with same name', () => {
ctx.symbols.add(
ctx.model.symbols.add(
'src/services/logger.ts',
'Logger',
'Class:src/services/logger.ts:Logger',
'Class',
);
ctx.symbols.add(
ctx.model.symbols.add(
'src/testing/mock-logger.ts',
'Logger',
'Class:src/testing/mock-logger.ts:Logger',
@ -96,7 +97,7 @@ describe('ResolutionContext.resolve — resolveSymbol compatibility', () => {
});
it('handles file with no imports — unique global falls through', () => {
ctx.symbols.add('src/utils.ts', 'Helper', 'Class:src/utils.ts:Helper', 'Class');
ctx.model.symbols.add('src/utils.ts', 'Helper', 'Class:src/utils.ts:Helper', 'Class');
const result = resolveOne(ctx, 'Helper', 'src/app.ts');
@ -107,7 +108,7 @@ describe('ResolutionContext.resolve — resolveSymbol compatibility', () => {
describe('Tier 3: Global resolution', () => {
it('resolves unique global when not in imports', () => {
ctx.symbols.add(
ctx.model.symbols.add(
'src/external/base.ts',
'BaseModel',
'Class:src/external/base.ts:BaseModel',
@ -122,8 +123,8 @@ describe('ResolutionContext.resolve — resolveSymbol compatibility', () => {
});
it('refuses ambiguous global — returns null when multiple candidates exist', () => {
ctx.symbols.add('src/a.ts', 'Config', 'Class:src/a.ts:Config', 'Class');
ctx.symbols.add('src/b.ts', 'Config', 'Class:src/b.ts:Config', 'Class');
ctx.model.symbols.add('src/a.ts', 'Config', 'Class:src/a.ts:Config', 'Class');
ctx.model.symbols.add('src/b.ts', 'Config', 'Class:src/b.ts:Config', 'Class');
const result = resolveOne(ctx, 'Config', 'src/other.ts');
@ -131,8 +132,8 @@ describe('ResolutionContext.resolve — resolveSymbol compatibility', () => {
});
it('ctx.resolve returns all candidates at global tier (consumers decide)', () => {
ctx.symbols.add('src/a.ts', 'Config', 'Class:src/a.ts:Config', 'Class');
ctx.symbols.add('src/b.ts', 'Config', 'Class:src/b.ts:Config', 'Class');
ctx.model.symbols.add('src/a.ts', 'Config', 'Class:src/a.ts:Config', 'Class');
ctx.model.symbols.add('src/b.ts', 'Config', 'Class:src/b.ts:Config', 'Class');
const tiered = ctx.resolve('Config', 'src/other.ts');
@ -156,7 +157,7 @@ describe('ResolutionContext.resolve — resolveSymbol compatibility', () => {
describe('type preservation', () => {
it('preserves Interface type for heritage resolution', () => {
ctx.symbols.add(
ctx.model.symbols.add(
'src/interfaces.ts',
'ILogger',
'Interface:src/interfaces.ts:ILogger',
@ -170,7 +171,7 @@ describe('ResolutionContext.resolve — resolveSymbol compatibility', () => {
});
it('preserves Class type for heritage resolution', () => {
ctx.symbols.add('src/base.ts', 'BaseService', 'Class:src/base.ts:BaseService', 'Class');
ctx.model.symbols.add('src/base.ts', 'BaseService', 'Class:src/base.ts:BaseService', 'Class');
ctx.importMap.set('src/app.ts', new Set(['src/base.ts']));
const result = resolveOne(ctx, 'BaseService', 'src/app.ts');
@ -181,13 +182,13 @@ describe('ResolutionContext.resolve — resolveSymbol compatibility', () => {
describe('heritage-specific scenarios', () => {
it('resolves C# interface vs class ambiguity via imports', () => {
ctx.symbols.add(
ctx.model.symbols.add(
'src/logging/ilogger.cs',
'ILogger',
'Interface:src/logging/ilogger.cs:ILogger',
'Interface',
);
ctx.symbols.add(
ctx.model.symbols.add(
'src/testing/ilogger.cs',
'ILogger',
'Class:src/testing/ilogger.cs:ILogger',
@ -202,13 +203,13 @@ describe('ResolutionContext.resolve — resolveSymbol compatibility', () => {
});
it('resolves parent class from imported file for extends', () => {
ctx.symbols.add(
ctx.model.symbols.add(
'src/api/controller.ts',
'UserController',
'Class:src/api/controller.ts:UserController',
'Class',
);
ctx.symbols.add(
ctx.model.symbols.add(
'src/base/controller.ts',
'BaseController',
'Class:src/base/controller.ts:BaseController',
@ -231,7 +232,7 @@ describe('ResolutionContext.resolve — tier metadata', () => {
});
it('returns same-file tier for Tier 1 match', () => {
ctx.symbols.add('src/a.ts', 'Foo', 'Class:src/a.ts:Foo', 'Class');
ctx.model.symbols.add('src/a.ts', 'Foo', 'Class:src/a.ts:Foo', 'Class');
const result = resolveInternal(ctx, 'Foo', 'src/a.ts');
@ -242,8 +243,8 @@ describe('ResolutionContext.resolve — tier metadata', () => {
});
it('returns import-scoped tier for Tier 2 match', () => {
ctx.symbols.add('src/logger.ts', 'Logger', 'Class:src/logger.ts:Logger', 'Class');
ctx.symbols.add('src/mock.ts', 'Logger', 'Class:src/mock.ts:Logger', 'Class');
ctx.model.symbols.add('src/logger.ts', 'Logger', 'Class:src/logger.ts:Logger', 'Class');
ctx.model.symbols.add('src/mock.ts', 'Logger', 'Class:src/mock.ts:Logger', 'Class');
ctx.importMap.set('src/app.ts', new Set(['src/logger.ts']));
const result = resolveInternal(ctx, 'Logger', 'src/app.ts');
@ -253,7 +254,7 @@ describe('ResolutionContext.resolve — tier metadata', () => {
});
it('returns global tier for Tier 3 match', () => {
ctx.symbols.add('src/only.ts', 'Singleton', 'Class:src/only.ts:Singleton', 'Class');
ctx.model.symbols.add('src/only.ts', 'Singleton', 'Class:src/only.ts:Singleton', 'Class');
const result = resolveInternal(ctx, 'Singleton', 'src/other.ts');
@ -263,8 +264,8 @@ describe('ResolutionContext.resolve — tier metadata', () => {
});
it('returns null for ambiguous global — refuses to guess', () => {
ctx.symbols.add('src/a.ts', 'Config', 'Class:src/a.ts:Config', 'Class');
ctx.symbols.add('src/b.ts', 'Config', 'Class:src/b.ts:Config', 'Class');
ctx.model.symbols.add('src/a.ts', 'Config', 'Class:src/a.ts:Config', 'Class');
ctx.model.symbols.add('src/b.ts', 'Config', 'Class:src/b.ts:Config', 'Class');
const result = resolveInternal(ctx, 'Config', 'src/other.ts');
@ -277,8 +278,8 @@ describe('ResolutionContext.resolve — tier metadata', () => {
});
it('Tier 1 wins over Tier 2 — same-file takes priority', () => {
ctx.symbols.add('src/app.ts', 'Util', 'Function:src/app.ts:Util', 'Function');
ctx.symbols.add('src/lib.ts', 'Util', 'Function:src/lib.ts:Util', 'Function');
ctx.model.symbols.add('src/app.ts', 'Util', 'Function:src/app.ts:Util', 'Function');
ctx.model.symbols.add('src/lib.ts', 'Util', 'Function:src/lib.ts:Util', 'Function');
ctx.importMap.set('src/app.ts', new Set(['src/lib.ts']));
const result = resolveInternal(ctx, 'Util', 'src/app.ts');
@ -296,13 +297,13 @@ describe('negative tests — ambiguous refusal per language family', () => {
});
it('TS/JS: two Logger definitions with no import → returns null', () => {
ctx.symbols.add(
ctx.model.symbols.add(
'src/services/logger.ts',
'Logger',
'Class:src/services/logger.ts:Logger',
'Class',
);
ctx.symbols.add(
ctx.model.symbols.add(
'src/testing/logger.ts',
'Logger',
'Class:src/testing/logger.ts:Logger',
@ -314,13 +315,13 @@ describe('negative tests — ambiguous refusal per language family', () => {
});
it('Java: same-named class in different packages, no import → returns null', () => {
ctx.symbols.add(
ctx.model.symbols.add(
'com/example/models/User.java',
'User',
'Class:com/example/models/User.java:User',
'Class',
);
ctx.symbols.add(
ctx.model.symbols.add(
'com/example/dto/User.java',
'User',
'Class:com/example/dto/User.java:User',
@ -332,8 +333,8 @@ describe('negative tests — ambiguous refusal per language family', () => {
});
it('C/C++: type defined in transitively-included header → returns null (not reachable via direct import)', () => {
ctx.symbols.add('src/c.h', 'Widget', 'Struct:src/c.h:Widget', 'Struct');
ctx.symbols.add('src/d.h', 'Widget', 'Struct:src/d.h:Widget', 'Struct');
ctx.model.symbols.add('src/c.h', 'Widget', 'Struct:src/c.h:Widget', 'Struct');
ctx.model.symbols.add('src/d.h', 'Widget', 'Struct:src/d.h:Widget', 'Struct');
ctx.importMap.set('src/a.c', new Set(['src/b.h']));
const result = resolveOne(ctx, 'Widget', 'src/a.c');
@ -341,13 +342,13 @@ describe('negative tests — ambiguous refusal per language family', () => {
});
it('C#: two IService interfaces in different namespaces, no import → returns null', () => {
ctx.symbols.add(
ctx.model.symbols.add(
'src/Services/IService.cs',
'IService',
'Interface:src/Services/IService.cs:IService',
'Interface',
);
ctx.symbols.add(
ctx.model.symbols.add(
'src/Testing/IService.cs',
'IService',
'Interface:src/Testing/IService.cs:IService',
@ -367,13 +368,13 @@ describe('heritage false-positive guard', () => {
});
it('null from resolve prevents false edge — generateId fallback produces synthetic ID, not wrong match', () => {
ctx.symbols.add(
ctx.model.symbols.add(
'src/api/base.ts',
'BaseController',
'Class:src/api/base.ts:BaseController',
'Class',
);
ctx.symbols.add(
ctx.model.symbols.add(
'src/testing/base.ts',
'BaseController',
'Class:src/testing/base.ts:BaseController',
@ -390,6 +391,14 @@ describe('heritage false-positive guard', () => {
});
});
// These two describe blocks (`lookupExactFull` and `SM-16: SymbolTable.getFiles()`)
// intentionally use `createSymbolTable()` directly instead of going through
// `createSemanticModel()`. The behaviors under test belong to the pure DAG
// leaf — file/callable indexes, getFiles iterator — and do not involve the
// owner-scoped registries. Testing them on the bare leaf keeps the unit
// isolated. Do not migrate these blocks to createSemanticModel() "for
// consistency" — that would add unused registry setup and weaken the
// isolation property.
describe('lookupExactFull', () => {
it('returns full SymbolDefinition for same-file lookup via O(1) direct storage', () => {
const symbolTable = createSymbolTable();
@ -476,7 +485,7 @@ describe('Tier 2b: PackageMap resolution (Go)', () => {
});
it('resolves symbol via PackageMap when not in ImportMap', () => {
ctx.symbols.add(
ctx.model.symbols.add(
'internal/auth/handler.go',
'HandleLogin',
'Function:internal/auth/handler.go:HandleLogin',
@ -492,7 +501,7 @@ describe('Tier 2b: PackageMap resolution (Go)', () => {
});
it('does not resolve symbol from wrong package', () => {
ctx.symbols.add(
ctx.model.symbols.add(
'internal/db/connection.go',
'Connect',
'Function:internal/db/connection.go:Connect',
@ -508,13 +517,13 @@ describe('Tier 2b: PackageMap resolution (Go)', () => {
});
it('Tier 2a (ImportMap) takes precedence over Tier 2b (PackageMap)', () => {
ctx.symbols.add(
ctx.model.symbols.add(
'internal/auth/handler.go',
'Validate',
'Function:internal/auth/handler.go:Validate',
'Function',
);
ctx.symbols.add(
ctx.model.symbols.add(
'internal/db/validator.go',
'Validate',
'Function:internal/db/validator.go:Validate',
@ -532,13 +541,13 @@ describe('Tier 2b: PackageMap resolution (Go)', () => {
});
it('resolves both symbols in same imported package', () => {
ctx.symbols.add(
ctx.model.symbols.add(
'internal/auth/handler.go',
'Run',
'Function:internal/auth/handler.go:Run',
'Function',
);
ctx.symbols.add(
ctx.model.symbols.add(
'internal/auth/worker.go',
'Run',
'Function:internal/auth/worker.go:Run',
@ -554,13 +563,18 @@ describe('Tier 2b: PackageMap resolution (Go)', () => {
});
it('returns global without packageMap when ambiguous', () => {
ctx.symbols.add(
ctx.model.symbols.add(
'internal/auth/handler.go',
'X',
'Function:internal/auth/handler.go:X',
'Function',
);
ctx.symbols.add('internal/db/handler.go', 'X', 'Function:internal/db/handler.go:X', 'Function');
ctx.model.symbols.add(
'internal/db/handler.go',
'X',
'Function:internal/db/handler.go:X',
'Function',
);
const result = resolveInternal(ctx, 'X', 'cmd/main.go');
@ -577,7 +591,7 @@ describe('per-file cache', () => {
});
it('caches results per file', () => {
ctx.symbols.add('src/a.ts', 'Foo', 'Class:src/a.ts:Foo', 'Class');
ctx.model.symbols.add('src/a.ts', 'Foo', 'Class:src/a.ts:Foo', 'Class');
ctx.enableCache('src/a.ts');
const r1 = ctx.resolve('Foo', 'src/a.ts');
@ -591,7 +605,7 @@ describe('per-file cache', () => {
});
it('resolve works without cache enabled', () => {
ctx.symbols.add('src/a.ts', 'Foo', 'Class:src/a.ts:Foo', 'Class');
ctx.model.symbols.add('src/a.ts', 'Foo', 'Class:src/a.ts:Foo', 'Class');
const result = ctx.resolve('Foo', 'src/a.ts');
@ -601,7 +615,7 @@ describe('per-file cache', () => {
});
it('cache does not leak across files', () => {
ctx.symbols.add('src/a.ts', 'Foo', 'Class:src/a.ts:Foo', 'Class');
ctx.model.symbols.add('src/a.ts', 'Foo', 'Class:src/a.ts:Foo', 'Class');
ctx.enableCache('src/a.ts');
ctx.resolve('Foo', 'src/a.ts'); // cached for a.ts
@ -627,8 +641,8 @@ describe('SM-16: Tier 2a — iterate importedFiles with lookupExactAll', () => {
});
it('collects definitions from all imported files', () => {
ctx.symbols.add('src/a.ts', 'Widget', 'Class:src/a.ts:Widget', 'Class');
ctx.symbols.add('src/b.ts', 'Widget', 'Class:src/b.ts:Widget', 'Class');
ctx.model.symbols.add('src/a.ts', 'Widget', 'Class:src/a.ts:Widget', 'Class');
ctx.model.symbols.add('src/b.ts', 'Widget', 'Class:src/b.ts:Widget', 'Class');
ctx.importMap.set('src/app.ts', new Set(['src/a.ts', 'src/b.ts']));
const result = ctx.resolve('Widget', 'src/app.ts');
@ -640,8 +654,8 @@ describe('SM-16: Tier 2a — iterate importedFiles with lookupExactAll', () => {
});
it('skips files with no matching symbol — no false positives', () => {
ctx.symbols.add('src/a.ts', 'Widget', 'Class:src/a.ts:Widget', 'Class');
ctx.symbols.add('src/b.ts', 'Button', 'Class:src/b.ts:Button', 'Class');
ctx.model.symbols.add('src/a.ts', 'Widget', 'Class:src/a.ts:Widget', 'Class');
ctx.model.symbols.add('src/b.ts', 'Button', 'Class:src/b.ts:Button', 'Class');
ctx.importMap.set('src/app.ts', new Set(['src/a.ts', 'src/b.ts']));
const result = ctx.resolve('Widget', 'src/app.ts');
@ -652,8 +666,8 @@ describe('SM-16: Tier 2a — iterate importedFiles with lookupExactAll', () => {
it('returns all overloads from a single imported file', () => {
// Same-name method overloads in one file
ctx.symbols.add('src/math.ts', 'add', 'fn:math:add:0', 'Function', { parameterCount: 1 });
ctx.symbols.add('src/math.ts', 'add', 'fn:math:add:2', 'Function', { parameterCount: 2 });
ctx.model.symbols.add('src/math.ts', 'add', 'fn:math:add:0', 'Function', { parameterCount: 1 });
ctx.model.symbols.add('src/math.ts', 'add', 'fn:math:add:2', 'Function', { parameterCount: 2 });
ctx.importMap.set('src/app.ts', new Set(['src/math.ts']));
const result = ctx.resolve('add', 'src/app.ts');
@ -663,7 +677,7 @@ describe('SM-16: Tier 2a — iterate importedFiles with lookupExactAll', () => {
});
it('Java: resolves class from import via lookupExactAll per file', () => {
ctx.symbols.add(
ctx.model.symbols.add(
'com/example/models/User.java',
'User',
'Class:com/example/models/User.java:User',
@ -681,7 +695,7 @@ describe('SM-16: Tier 2a — iterate importedFiles with lookupExactAll', () => {
});
it('Python: resolves function from imported module file', () => {
ctx.symbols.add('models.py', 'User', 'Class:models.py:User', 'Class');
ctx.model.symbols.add('models.py', 'User', 'Class:models.py:User', 'Class');
ctx.importMap.set('app.py', new Set(['models.py']));
const result = ctx.resolve('User', 'app.py');
@ -691,7 +705,7 @@ describe('SM-16: Tier 2a — iterate importedFiles with lookupExactAll', () => {
});
it('C#: resolves interface from imported file', () => {
ctx.symbols.add(
ctx.model.symbols.add(
'src/Services/IService.cs',
'IService',
'Interface:src/Services/IService.cs:IService',
@ -707,7 +721,7 @@ describe('SM-16: Tier 2a — iterate importedFiles with lookupExactAll', () => {
it('TypeScript: resolves re-exported class via named binding chain', () => {
// index.ts re-exports User from models.ts
ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class');
ctx.model.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class');
ctx.namedImportMap.set(
'src/index.ts',
new Map([['User', { sourcePath: 'src/models.ts', exportedName: 'User' }]]),
@ -733,13 +747,13 @@ describe('SM-16: Tier 2b — iterate getFiles() + isFileInPackageDir', () => {
});
it('Go: resolves symbol in package dir via file iteration', () => {
ctx.symbols.add(
ctx.model.symbols.add(
'internal/auth/handler.go',
'Authenticate',
'Function:internal/auth/handler.go:Authenticate',
'Function',
);
ctx.symbols.add(
ctx.model.symbols.add(
'internal/db/repo.go',
'Authenticate',
'Function:internal/db/repo.go:Authenticate',
@ -755,8 +769,13 @@ describe('SM-16: Tier 2b — iterate getFiles() + isFileInPackageDir', () => {
});
it('C#: resolves class from namespace directory', () => {
ctx.symbols.add('MyApp/Models/User.cs', 'User', 'Class:MyApp/Models/User.cs:User', 'Class');
ctx.symbols.add('MyApp/Other/User.cs', 'User', 'Class:MyApp/Other/User.cs:User', 'Class');
ctx.model.symbols.add(
'MyApp/Models/User.cs',
'User',
'Class:MyApp/Models/User.cs:User',
'Class',
);
ctx.model.symbols.add('MyApp/Other/User.cs', 'User', 'Class:MyApp/Other/User.cs:User', 'Class');
ctx.packageMap.set('MyApp/Controllers/UserController.cs', new Set(['/MyApp/Models/']));
const result = ctx.resolve('User', 'MyApp/Controllers/UserController.cs');
@ -767,13 +786,13 @@ describe('SM-16: Tier 2b — iterate getFiles() + isFileInPackageDir', () => {
});
it('Tier 2a (ImportMap) still takes precedence over Tier 2b (PackageMap)', () => {
ctx.symbols.add(
ctx.model.symbols.add(
'internal/auth/handler.go',
'Validate',
'Function:internal/auth/handler.go:Validate',
'Function',
);
ctx.symbols.add(
ctx.model.symbols.add(
'internal/db/validator.go',
'Validate',
'Function:internal/db/validator.go:Validate',
@ -797,7 +816,7 @@ describe('SM-16: Tier 3 global — lookupClassByName + lookupImplByName + lookup
});
it('returns class-like symbol (Class) at global tier', () => {
ctx.symbols.add('src/user.ts', 'User', 'Class:src/user.ts:User', 'Class');
ctx.model.symbols.add('src/user.ts', 'User', 'Class:src/user.ts:User', 'Class');
const result = ctx.resolve('User', 'src/app.ts');
@ -806,7 +825,12 @@ describe('SM-16: Tier 3 global — lookupClassByName + lookupImplByName + lookup
});
it('returns callable symbol (Function) at global tier', () => {
ctx.symbols.add('src/utils.ts', 'parseDate', 'Function:src/utils.ts:parseDate', 'Function');
ctx.model.symbols.add(
'src/utils.ts',
'parseDate',
'Function:src/utils.ts:parseDate',
'Function',
);
const result = ctx.resolve('parseDate', 'src/app.ts');
@ -815,8 +839,8 @@ describe('SM-16: Tier 3 global — lookupClassByName + lookupImplByName + lookup
});
it('returns both Class and Function with the same name at global tier', () => {
ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class');
ctx.symbols.add('src/factories.ts', 'User', 'Function:src/factories.ts:User', 'Function');
ctx.model.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class');
ctx.model.symbols.add('src/factories.ts', 'User', 'Function:src/factories.ts:User', 'Function');
const result = ctx.resolve('User', 'src/app.ts');
@ -827,8 +851,8 @@ describe('SM-16: Tier 3 global — lookupClassByName + lookupImplByName + lookup
});
it('Rust: returns Impl node at global tier (needed for method resolution)', () => {
ctx.symbols.add('src/user.rs', 'User', 'Struct:src/user.rs:User', 'Struct');
ctx.symbols.add('src/user.rs', 'User', 'Impl:src/user.rs:User', 'Impl');
ctx.model.symbols.add('src/user.rs', 'User', 'Struct:src/user.rs:User', 'Struct');
ctx.model.symbols.add('src/user.rs', 'User', 'Impl:src/user.rs:User', 'Impl');
const result = ctx.resolve('User', 'src/main.rs');
@ -839,22 +863,24 @@ describe('SM-16: Tier 3 global — lookupClassByName + lookupImplByName + lookup
});
it('Rust: Impl is separate from Class-like types — does not affect heritage (lookupClassByName)', () => {
const table = createSymbolTable();
table.add('src/user.rs', 'User', 'Struct:src/user.rs:User', 'Struct');
table.add('src/user.rs', 'User', 'Impl:src/user.rs:User', 'Impl');
// SM-23 DAG: registry lookups go through SemanticModel; SymbolTable
// is a pure leaf with no registry knowledge.
const model = createSemanticModel();
model.symbols.add('src/user.rs', 'User', 'Struct:src/user.rs:User', 'Struct');
model.symbols.add('src/user.rs', 'User', 'Impl:src/user.rs:User', 'Impl');
// lookupClassByName excludes Impl (preserves heritage resolution correctness)
const classDefs = table.lookupClassByName('User');
const classDefs = model.types.lookupClassByName('User');
expect(classDefs.map((d) => d.type)).toEqual(['Struct']);
// lookupImplByName returns only Impl nodes
const implDefs = table.lookupImplByName('User');
const implDefs = model.types.lookupImplByName('User');
expect(implDefs.map((d) => d.type)).toEqual(['Impl']);
});
it('ambiguous global returns all candidates (consumers decide)', () => {
ctx.symbols.add('src/a.ts', 'Config', 'Class:src/a.ts:Config', 'Class');
ctx.symbols.add('src/b.ts', 'Config', 'Class:src/b.ts:Config', 'Class');
ctx.model.symbols.add('src/a.ts', 'Config', 'Class:src/a.ts:Config', 'Class');
ctx.model.symbols.add('src/b.ts', 'Config', 'Class:src/b.ts:Config', 'Class');
const result = ctx.resolve('Config', 'src/other.ts');
@ -862,13 +888,30 @@ describe('SM-16: Tier 3 global — lookupClassByName + lookupImplByName + lookup
expect(result!.candidates.length).toBe(2);
});
it('A4 intermediate: Method reachable via both callable and method indexes dedups to one Tier 3 candidate', () => {
// A method with an owner lands in callableByName (because Method is
// still in FREE_CALLABLE_TYPES during the Unit 3 intermediate state) AND in
// methodsByName (because A4 Unit 2 dual-indexes every method
// registration). Tier 3 must dedup by nodeId so consumers see each
// method exactly once.
ctx.model.symbols.add('src/user.ts', 'save', 'Method:src/user.ts:User.save', 'Method', {
ownerId: 'Class:src/user.ts:User',
});
const result = ctx.resolve('save', 'src/app.ts');
expect(result!.tier).toBe('global');
const nodeIds = result!.candidates.map((c) => c.nodeId);
expect(nodeIds).toEqual(['Method:src/user.ts:User.save']);
});
it('returns null when no symbol exists at any tier', () => {
const result = ctx.resolve('NonExistent', 'src/app.ts');
expect(result).toBeNull();
});
it('TypeScript: resolves Enum at global tier', () => {
ctx.symbols.add('src/status.ts', 'Status', 'Enum:src/status.ts:Status', 'Enum');
ctx.model.symbols.add('src/status.ts', 'Status', 'Enum:src/status.ts:Status', 'Enum');
const result = ctx.resolve('Status', 'src/app.ts');
@ -877,7 +920,7 @@ describe('SM-16: Tier 3 global — lookupClassByName + lookupImplByName + lookup
});
it('Kotlin: resolves data class (Record) at global tier', () => {
ctx.symbols.add('src/User.kt', 'User', 'Record:src/User.kt:User', 'Record');
ctx.model.symbols.add('src/User.kt', 'User', 'Record:src/User.kt:User', 'Record');
const result = ctx.resolve('User', 'src/Main.kt');
@ -886,7 +929,12 @@ describe('SM-16: Tier 3 global — lookupClassByName + lookupImplByName + lookup
});
it('PHP: resolves Trait at global tier', () => {
ctx.symbols.add('src/Loggable.php', 'Loggable', 'Trait:src/Loggable.php:Loggable', 'Trait');
ctx.model.symbols.add(
'src/Loggable.php',
'Loggable',
'Trait:src/Loggable.php:Loggable',
'Trait',
);
const result = ctx.resolve('Loggable', 'src/App.php');
@ -895,7 +943,7 @@ describe('SM-16: Tier 3 global — lookupClassByName + lookupImplByName + lookup
});
it('Java: resolves Interface at global tier', () => {
ctx.symbols.add(
ctx.model.symbols.add(
'com/example/IService.java',
'IService',
'Interface:com/example/IService.java:IService',
@ -909,7 +957,7 @@ describe('SM-16: Tier 3 global — lookupClassByName + lookupImplByName + lookup
});
it('Go: resolves Struct at global tier', () => {
ctx.symbols.add(
ctx.model.symbols.add(
'internal/model/user.go',
'User',
'Struct:internal/model/user.go:User',
@ -954,7 +1002,7 @@ describe('SM-16: SymbolTable.getFiles()', () => {
describe('SM-16: walkBindingChain — no allDefs parameter', () => {
it('resolves non-aliased import via lookupExactAll at depth=0', () => {
const ctx = createResolutionContext();
ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class');
ctx.model.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class');
ctx.namedImportMap.set(
'src/app.ts',
new Map([['User', { sourcePath: 'src/models.ts', exportedName: 'User' }]]),
@ -968,7 +1016,7 @@ describe('SM-16: walkBindingChain — no allDefs parameter', () => {
it('resolves aliased import (U → User) via chain walk', () => {
const ctx = createResolutionContext();
ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class');
ctx.model.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class');
ctx.namedImportMap.set(
'src/app.ts',
new Map([['U', { sourcePath: 'src/models.ts', exportedName: 'User' }]]),
@ -982,7 +1030,7 @@ describe('SM-16: walkBindingChain — no allDefs parameter', () => {
it('follows re-export chain A → B → C', () => {
const ctx = createResolutionContext();
ctx.symbols.add('src/models.ts', 'Widget', 'Class:src/models.ts:Widget', 'Class');
ctx.model.symbols.add('src/models.ts', 'Widget', 'Class:src/models.ts:Widget', 'Class');
// B re-exports Widget from C
ctx.namedImportMap.set(
'src/index.ts',
@ -1011,26 +1059,31 @@ describe('SM-16: Tier 3 — TypeAlias, Const, Variable are NOT returned', () =>
});
it('TypeAlias is not reachable at Tier 3', () => {
ctx.symbols.add('src/types.ts', 'Handler', 'TypeAlias:src/types.ts:Handler', 'TypeAlias');
ctx.model.symbols.add('src/types.ts', 'Handler', 'TypeAlias:src/types.ts:Handler', 'TypeAlias');
const result = ctx.resolve('Handler', 'src/app.ts');
expect(result).toBeNull();
});
it('Const is not reachable at Tier 3', () => {
ctx.symbols.add('src/config.ts', 'MAX_RETRIES', 'Const:src/config.ts:MAX_RETRIES', 'Const');
ctx.model.symbols.add(
'src/config.ts',
'MAX_RETRIES',
'Const:src/config.ts:MAX_RETRIES',
'Const',
);
const result = ctx.resolve('MAX_RETRIES', 'src/app.ts');
expect(result).toBeNull();
});
it('Variable is not reachable at Tier 3', () => {
ctx.symbols.add('src/state.ts', 'counter', 'Variable:src/state.ts:counter', 'Variable');
ctx.model.symbols.add('src/state.ts', 'counter', 'Variable:src/state.ts:counter', 'Variable');
const result = ctx.resolve('counter', 'src/app.ts');
expect(result).toBeNull();
});
it('Class-like and callable ARE reachable at Tier 3 (control)', () => {
ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class');
ctx.symbols.add('src/utils.ts', 'getUser', 'Function:src/utils.ts:getUser', 'Function');
ctx.model.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class');
ctx.model.symbols.add('src/utils.ts', 'getUser', 'Function:src/utils.ts:getUser', 'Function');
const classResult = ctx.resolve('User', 'src/app.ts');
expect(classResult).not.toBeNull();
@ -1042,7 +1095,7 @@ describe('SM-16: Tier 3 — TypeAlias, Const, Variable are NOT returned', () =>
});
it('Macro (C/C++) is reachable at Tier 3 via callable index', () => {
ctx.symbols.add('src/macros.h', 'ASSERT', 'Macro:src/macros.h:ASSERT', 'Macro');
ctx.model.symbols.add('src/macros.h', 'ASSERT', 'Macro:src/macros.h:ASSERT', 'Macro');
const result = ctx.resolve('ASSERT', 'src/main.c');
expect(result).not.toBeNull();
expect(result!.tier).toBe('global');
@ -1050,7 +1103,7 @@ describe('SM-16: Tier 3 — TypeAlias, Const, Variable are NOT returned', () =>
});
it('Delegate (C#) is reachable at Tier 3 via callable index', () => {
ctx.symbols.add('src/Events.cs', 'OnClick', 'Delegate:src/Events.cs:OnClick', 'Delegate');
ctx.model.symbols.add('src/Events.cs', 'OnClick', 'Delegate:src/Events.cs:OnClick', 'Delegate');
const result = ctx.resolve('OnClick', 'src/App.cs');
expect(result).not.toBeNull();
expect(result!.tier).toBe('global');
@ -1064,7 +1117,7 @@ describe('SM-16: Tier 2b — packageDirIndex picks up symbols added after clear(
it('resolves newly added symbol after clear() resets the index', () => {
const ctx = createResolutionContext();
// Initial setup: one symbol in package dir
ctx.symbols.add('pkg/models/user.go', 'User', 'Struct:pkg/models/user.go:User', 'Struct');
ctx.model.symbols.add('pkg/models/user.go', 'User', 'Struct:pkg/models/user.go:User', 'Struct');
ctx.packageMap.set('cmd/main.go', new Set(['/pkg/models/']));
// Prime the packageDirIndex via a Tier 2b resolution
@ -1075,8 +1128,13 @@ describe('SM-16: Tier 2b — packageDirIndex picks up symbols added after clear(
ctx.clear();
// Re-add symbols with a NEW file in the package dir
ctx.symbols.add('pkg/models/user.go', 'User', 'Struct:pkg/models/user.go:User', 'Struct');
ctx.symbols.add('pkg/models/order.go', 'Order', 'Struct:pkg/models/order.go:Order', 'Struct');
ctx.model.symbols.add('pkg/models/user.go', 'User', 'Struct:pkg/models/user.go:User', 'Struct');
ctx.model.symbols.add(
'pkg/models/order.go',
'Order',
'Struct:pkg/models/order.go:Order',
'Struct',
);
ctx.packageMap.set('cmd/main.go', new Set(['/pkg/models/']));
// The new symbol must be visible — packageDirIndex was invalidated by clear()
@ -1092,8 +1150,8 @@ describe('SM-16: Tier 2b — packageDirIndex picks up symbols added after clear(
describe('SM-16: Tier 2b — Rust package-scoped resolution', () => {
it('resolves struct in package dir via Tier 2b', () => {
const ctx = createResolutionContext();
ctx.symbols.add('src/models/user.rs', 'User', 'Struct:src/models/user.rs:User', 'Struct');
ctx.symbols.add('src/other/user.rs', 'User', 'Struct:src/other/user.rs:User', 'Struct');
ctx.model.symbols.add('src/models/user.rs', 'User', 'Struct:src/models/user.rs:User', 'Struct');
ctx.model.symbols.add('src/other/user.rs', 'User', 'Struct:src/other/user.rs:User', 'Struct');
ctx.packageMap.set('src/main.rs', new Set(['/src/models/']));
const result = ctx.resolve('User', 'src/main.rs');
@ -1106,8 +1164,18 @@ describe('SM-16: Tier 2b — Rust package-scoped resolution', () => {
describe('SM-16: Tier 2b — Kotlin package-scoped resolution', () => {
it('resolves class in package dir via Tier 2b', () => {
const ctx = createResolutionContext();
ctx.symbols.add('com/app/models/User.kt', 'User', 'Class:com/app/models/User.kt:User', 'Class');
ctx.symbols.add('com/app/other/User.kt', 'User', 'Class:com/app/other/User.kt:User', 'Class');
ctx.model.symbols.add(
'com/app/models/User.kt',
'User',
'Class:com/app/models/User.kt:User',
'Class',
);
ctx.model.symbols.add(
'com/app/other/User.kt',
'User',
'Class:com/app/other/User.kt:User',
'Class',
);
ctx.packageMap.set('com/app/Main.kt', new Set(['/com/app/models/']));
const result = ctx.resolve('User', 'com/app/Main.kt');
@ -1120,8 +1188,8 @@ describe('SM-16: Tier 2b — Kotlin package-scoped resolution', () => {
describe('SM-16: Tier 2b — PHP namespace directory resolution', () => {
it('resolves class in namespace dir via Tier 2b', () => {
const ctx = createResolutionContext();
ctx.symbols.add('app/Models/User.php', 'User', 'Class:app/Models/User.php:User', 'Class');
ctx.symbols.add('app/Other/User.php', 'User', 'Class:app/Other/User.php:User', 'Class');
ctx.model.symbols.add('app/Models/User.php', 'User', 'Class:app/Models/User.php:User', 'Class');
ctx.model.symbols.add('app/Other/User.php', 'User', 'Class:app/Other/User.php:User', 'Class');
ctx.packageMap.set('app/Controllers/UserController.php', new Set(['/app/Models/']));
const result = ctx.resolve('User', 'app/Controllers/UserController.php');

File diff suppressed because it is too large Load diff

View file

@ -1,11 +1,11 @@
import { describe, it, expect, vi } from 'vitest';
import { buildTypeEnv, type TypeEnvironment } from '../../src/core/ingestion/type-env.js';
import { BindingAccumulator } from '../../src/core/ingestion/binding-accumulator.js';
import { type SymbolDefinition } from '../../src/core/ingestion/model/symbol-table.js';
import {
createSymbolTable,
type SymbolDefinition,
type SymbolTable,
} from '../../src/core/ingestion/symbol-table.js';
createSemanticModel,
type SemanticModel,
} from '../../src/core/ingestion/model/semantic-model.js';
import {
stripNullable,
extractSimpleTypeName,
@ -78,25 +78,6 @@ function flatSize(typeEnv: TypeEnvironment): number {
return count;
}
const createMockSymbolTable = (overrides: Partial<SymbolTable> = {}): SymbolTable => ({
add: () => {},
lookupExact: () => undefined,
lookupExactFull: () => undefined,
lookupExactAll: () => [],
lookupCallableByName: () => [],
lookupFieldByOwner: () => undefined,
lookupMethodByOwner: () => undefined,
lookupClassByName: () => [],
lookupClassByQualifiedName: () => [],
lookupImplByName: () => [],
getFiles: () => [][Symbol.iterator](),
getStats: () => ({
fileCount: 0,
}),
clear: () => {},
...overrides,
});
const createClassDef = (
name: string,
type: SymbolDefinition['type'] = 'Class',
@ -1191,29 +1172,44 @@ class RepoService {
});
describe('destructured call results', () => {
// Minimal mock SymbolTable for call-result return type lookup
// Minimal mock SemanticModel for call-result return type lookup
// (SM-21 inversion — buildTypeEnv takes a SemanticModel via `model:`).
const makeSymbolTable = (callables: Array<{ name: string; returnType?: string }>) => ({
lookupCallableByName: (name: string) =>
callables
.filter((c) => c.name === name)
.map((c) => ({
nodeId: 'n1',
filePath: 'src.ts',
type: 'Function' as const,
returnType: c.returnType,
})),
lookupClassByName: () => [],
lookupExact: () => undefined,
lookupExactFull: () => undefined,
add: () => {},
getStats: () => ({ fileCount: 0 }),
clear: () => {},
types: {
lookupClassByName: () => [],
lookupClassByQualifiedName: () => [],
lookupImplByName: () => [],
},
methods: {
lookupMethodByOwner: () => undefined,
lookupMethodByName: () => [],
},
fields: {
lookupFieldByOwner: () => undefined,
},
symbols: {
add: () => {},
lookupExact: () => undefined,
lookupExactFull: () => undefined,
lookupExactAll: () => [],
lookupCallableByName: (name: string) =>
callables
.filter((c) => c.name === name)
.map((c) => ({
nodeId: 'n1',
filePath: 'src.ts',
type: 'Function' as const,
returnType: c.returnType,
})),
getFiles: () => [][Symbol.iterator](),
getStats: () => ({ fileCount: 0 }),
},
});
it('emits callResult + fieldAccess items for const { x } = fn()', () => {
const symbolTable = makeSymbolTable([{ name: 'getUser', returnType: 'User' }]);
const tree = parse('const { name } = getUser();', TypeScript.typescript);
const typeEnv = buildTypeEnv(tree, 'typescript', { symbolTable: symbolTable as any });
const typeEnv = buildTypeEnv(tree, 'typescript', { model: symbolTable });
// callResult resolves __destr_getUser_N → User
// fieldAccess resolves name via User's properties (no Property nodes in mock → undefined)
// But the callResult itself IS emitted — verify constructorBindings is still empty
@ -1226,14 +1222,14 @@ class RepoService {
'async function f() { const { data } = await fetchData(); }',
TypeScript.typescript,
);
const typeEnv = buildTypeEnv(tree, 'typescript', { symbolTable: symbolTable as any });
const typeEnv = buildTypeEnv(tree, 'typescript', { model: symbolTable });
expect(typeEnv.constructorBindings).toEqual([]);
});
it('gracefully handles no return type (composable without annotation)', () => {
const symbolTable = makeSymbolTable([{ name: 'useUserRole' }]); // no returnType
const tree = parse('const { isMaker } = useUserRole();', TypeScript.typescript);
const typeEnv = buildTypeEnv(tree, 'typescript', { symbolTable: symbolTable as any });
const typeEnv = buildTypeEnv(tree, 'typescript', { model: symbolTable });
// No return type → callResult unresolved → fieldAccess unresolved
expect(flatGet(typeEnv, 'isMaker')).toBeUndefined();
});
@ -2045,17 +2041,10 @@ class RepoService {
`,
Kotlin,
);
// User is NOT defined in this file, but SymbolTable knows it's a Class
const mockSymbolTable = {
lookupClassByName: (name: string) =>
name === 'User' ? [{ nodeId: 'n1', filePath: 'models.kt', type: 'Class' }] : [],
lookupExact: () => undefined,
lookupExactFull: () => undefined,
add: () => {},
getStats: () => ({ fileCount: 0 }),
clear: () => {},
};
const typeEnv = buildTypeEnv(tree, 'kotlin', { symbolTable: mockSymbolTable as any });
// User is NOT defined in this file, but SemanticModel knows it's a Class
const model = createSemanticModel();
model.symbols.add('models.kt', 'User', 'n1', 'Class');
const typeEnv = buildTypeEnv(tree, 'kotlin', { model });
expect(flatGet(typeEnv, 'user')).toBe('User');
});
@ -2068,17 +2057,8 @@ class RepoService {
`,
Kotlin,
);
const mockSymbolTable = {
lookupClassByName: () => [],
lookupCallableByName: () => [],
lookupFieldByOwner: () => undefined,
lookupExact: () => undefined,
lookupExactFull: () => undefined,
add: () => {},
getStats: () => ({ fileCount: 0 }),
clear: () => {},
};
const typeEnv = buildTypeEnv(tree, 'kotlin', { symbolTable: mockSymbolTable as any });
const model = createSemanticModel();
const typeEnv = buildTypeEnv(tree, 'kotlin', { model });
expect(flatGet(typeEnv, 'result')).toBeUndefined();
});
@ -2144,10 +2124,15 @@ def main():
});
describe('lookupClassByName regression coverage', () => {
const makeClassLookupTable = (classDefs: Record<string, SymbolDefinition[]>) =>
createMockSymbolTable({
lookupClassByName: (name: string) => classDefs[name] ?? [],
});
const makeClassLookupTable = (
classDefs: Record<string, SymbolDefinition[]>,
): SemanticModel => {
const model = createSemanticModel();
vi.spyOn(model.types, 'lookupClassByName').mockImplementation(
(name: string) => classDefs[name] ?? [],
);
return model;
};
it('Python cross-file constructor inference uses lookupClassByName', () => {
const tree = parse(
@ -2158,7 +2143,7 @@ def main():
Python,
);
const typeEnv = buildTypeEnv(tree, 'python', {
symbolTable: makeClassLookupTable({
model: makeClassLookupTable({
User: [createClassDef('User', 'Class', 'models.py')],
}),
});
@ -2174,7 +2159,7 @@ def main():
Python,
);
const typeEnv = buildTypeEnv(tree, 'python', {
symbolTable: makeClassLookupTable({}),
model: makeClassLookupTable({}),
});
expect(flatGet(typeEnv, 'result')).toBeUndefined();
});
@ -2188,7 +2173,7 @@ def main():
Python,
);
const typeEnv = buildTypeEnv(tree, 'python', {
symbolTable: makeClassLookupTable({
model: makeClassLookupTable({
User: [createClassDef('User', 'Class', 'models.py')],
}),
});
@ -2205,7 +2190,7 @@ void run() {
CPP,
);
const typeEnv = buildTypeEnv(tree, 'cpp', {
symbolTable: makeClassLookupTable({
model: makeClassLookupTable({
User: [createClassDef('User', 'Class', 'models.h')],
}),
});
@ -2222,7 +2207,7 @@ void run() {
CPP,
);
const typeEnv = buildTypeEnv(tree, 'cpp', {
symbolTable: makeClassLookupTable({}),
model: makeClassLookupTable({}),
});
expect(flatGet(typeEnv, 'result')).toBeUndefined();
});
@ -2237,7 +2222,7 @@ end
Ruby,
);
const typeEnv = buildTypeEnv(tree, 'ruby', {
symbolTable: makeClassLookupTable({
model: makeClassLookupTable({
User: [createClassDef('User', 'Class', 'models/user.rb')],
}),
});
@ -2254,7 +2239,7 @@ end
Ruby,
);
const typeEnv = buildTypeEnv(tree, 'ruby', {
symbolTable: makeClassLookupTable({
model: makeClassLookupTable({
UserService: [createClassDef('UserService', 'Class', 'models/user_service.rb')],
}),
});
@ -2271,7 +2256,7 @@ end
Ruby,
);
const typeEnv = buildTypeEnv(tree, 'ruby', {
symbolTable: makeClassLookupTable({}),
model: makeClassLookupTable({}),
});
expect(flatGet(typeEnv, 'result')).toBeUndefined();
});
@ -2286,7 +2271,7 @@ void run() {
`,
);
const typeEnv = buildTypeEnv(tree, 'dart', {
symbolTable: makeClassLookupTable({
model: makeClassLookupTable({
User: [createClassDef('User', 'Class', 'models.dart')],
}),
});
@ -2302,7 +2287,7 @@ void run() {
`,
);
const typeEnv = buildTypeEnv(tree, 'dart', {
symbolTable: makeClassLookupTable({
model: makeClassLookupTable({
User: [createClassDef('User', 'Class', 'models.dart')],
}),
});
@ -2318,7 +2303,7 @@ void run() {
`,
);
const typeEnv = buildTypeEnv(tree, 'dart', {
symbolTable: makeClassLookupTable({}),
model: makeClassLookupTable({}),
});
expect(flatGet(typeEnv, 'result')).toBeUndefined();
});
@ -2334,7 +2319,7 @@ fn run() {
Rust,
);
const typeEnv = buildTypeEnv(tree, 'rust', {
symbolTable: makeClassLookupTable({
model: makeClassLookupTable({
UserService: [createClassDef('UserService', 'Struct', 'models.rs')],
}),
});
@ -2351,7 +2336,7 @@ fn run() {
Rust,
);
const typeEnv = buildTypeEnv(tree, 'rust', {
symbolTable: makeClassLookupTable({}),
model: makeClassLookupTable({}),
});
expect(flatGet(typeEnv, 'value')).toBeUndefined();
});
@ -2366,7 +2351,7 @@ func run() {
`,
);
const typeEnv = buildTypeEnv(tree, 'swift', {
symbolTable: makeClassLookupTable({
model: makeClassLookupTable({
User: [createClassDef('User', 'Class', 'Models/User.swift')],
}),
});
@ -2382,7 +2367,7 @@ func run() {
`,
);
const typeEnv = buildTypeEnv(tree, 'swift', {
symbolTable: makeClassLookupTable({
model: makeClassLookupTable({
User: [createClassDef('User', 'Class', 'Models/User.swift')],
}),
});
@ -2398,7 +2383,7 @@ func run() {
`,
);
const typeEnv = buildTypeEnv(tree, 'swift', {
symbolTable: makeClassLookupTable({}),
model: makeClassLookupTable({}),
});
expect(flatGet(typeEnv, 'result')).toBeUndefined();
});
@ -2413,20 +2398,13 @@ function process(user: User) {
`,
TypeScript.typescript,
);
const symbolTable = createMockSymbolTable({
lookupClassByName: (name: string) =>
name === 'User' ? [createClassDef('User', 'Class', 'models.ts')] : [],
lookupFieldByOwner: (ownerNodeId: string, fieldName: string) =>
ownerNodeId === 'class:User' && fieldName === 'address'
? {
nodeId: 'prop:User:address',
filePath: 'models.ts',
type: 'Property' as const,
declaredType: 'Address',
}
: undefined,
const model = createSemanticModel();
model.symbols.add('models.ts', 'User', 'class:User', 'Class');
model.symbols.add('models.ts', 'address', 'prop:User:address', 'Property', {
ownerId: 'class:User',
declaredType: 'Address',
});
const typeEnv = buildTypeEnv(tree, 'typescript', { symbolTable });
const typeEnv = buildTypeEnv(tree, 'typescript', { model });
expect(flatGet(typeEnv, 'addr')).toBe('Address');
});
@ -2439,10 +2417,8 @@ function process(user: User) {
`,
TypeScript.typescript,
);
const symbolTable = createMockSymbolTable({
lookupClassByName: () => [],
});
const typeEnv = buildTypeEnv(tree, 'typescript', { symbolTable });
const model = createSemanticModel();
const typeEnv = buildTypeEnv(tree, 'typescript', { model });
expect(flatGet(typeEnv, 'addr')).toBeUndefined();
});
@ -2455,23 +2431,14 @@ function process(repo: Repo) {
`,
TypeScript.typescript,
);
const lookupCallableByName = vi.fn(() => []);
const symbolTable = createMockSymbolTable({
lookupClassByName: (name: string) =>
name === 'Repo' ? [createClassDef('Repo', 'Class', 'models.ts')] : [],
lookupMethodByOwner: (ownerNodeId: string, methodName: string) =>
ownerNodeId === 'class:Repo' && methodName === 'getProfile'
? {
nodeId: 'method:Repo:getProfile',
filePath: 'models.ts',
type: 'Method',
ownerId: 'class:Repo',
returnType: 'Profile',
}
: undefined,
lookupCallableByName,
const model = createSemanticModel();
model.symbols.add('models.ts', 'Repo', 'class:Repo', 'Class');
model.symbols.add('models.ts', 'getProfile', 'method:Repo:getProfile', 'Method', {
ownerId: 'class:Repo',
returnType: 'Profile',
});
const typeEnv = buildTypeEnv(tree, 'typescript', { symbolTable });
const lookupCallableByName = vi.spyOn(model.symbols, 'lookupCallableByName');
const typeEnv = buildTypeEnv(tree, 'typescript', { model });
expect(flatGet(typeEnv, 'profile')).toBe('Profile');
expect(lookupCallableByName).not.toHaveBeenCalledWith('getProfile');
});
@ -2485,27 +2452,16 @@ function process(repo: Repo) {
`,
TypeScript.typescript,
);
const lookupCallableByName = vi.fn(() => []);
const symbolTable = createMockSymbolTable({
lookupClassByName: (name: string) => {
if (name === 'Repo') return [createClassDef('Repo', 'Class', 'models.ts')];
if (name === 'BaseRepo') return [createClassDef('BaseRepo', 'Class', 'base.ts')];
return [];
},
lookupMethodByOwner: (ownerNodeId: string, methodName: string) =>
ownerNodeId === 'class:BaseRepo' && methodName === 'getProfile'
? {
nodeId: 'method:BaseRepo:getProfile',
filePath: 'base.ts',
type: 'Method',
ownerId: 'class:BaseRepo',
returnType: 'Profile',
}
: undefined,
lookupCallableByName,
const model = createSemanticModel();
model.symbols.add('models.ts', 'Repo', 'class:Repo', 'Class');
model.symbols.add('base.ts', 'BaseRepo', 'class:BaseRepo', 'Class');
model.symbols.add('base.ts', 'getProfile', 'method:BaseRepo:getProfile', 'Method', {
ownerId: 'class:BaseRepo',
returnType: 'Profile',
});
const lookupCallableByName = vi.spyOn(model.symbols, 'lookupCallableByName');
const typeEnv = buildTypeEnv(tree, 'typescript', {
symbolTable,
model,
parentMap: new Map([['Repo', ['BaseRepo']]]),
});
expect(flatGet(typeEnv, 'profile')).toBe('Profile');
@ -2521,32 +2477,15 @@ function process(repo: Repo) {
`,
TypeScript.typescript,
);
const lookupCallableByName = vi.fn(() => []);
const symbolTable = createMockSymbolTable({
lookupClassByName: (name: string) =>
name === 'Repo'
? [
createClassDef('Repo', 'Class', 'models-a.ts'),
{
...createClassDef('Repo', 'Class', 'models-b.ts'),
nodeId: 'class:Repo:partial',
},
]
: [],
lookupMethodByOwner: (ownerNodeId: string, methodName: string) =>
ownerNodeId === 'class:Repo:partial' && methodName === 'getProfile'
? {
nodeId: 'method:Repo:getProfile',
filePath: 'models-b.ts',
type: 'Method',
ownerId: 'class:Repo:partial',
returnType: 'Profile',
}
: undefined,
lookupExactAll: () => [],
lookupCallableByName,
const model = createSemanticModel();
model.symbols.add('models-a.ts', 'Repo', 'class:Repo', 'Class');
model.symbols.add('models-b.ts', 'Repo', 'class:Repo:partial', 'Class');
model.symbols.add('models-b.ts', 'getProfile', 'method:Repo:getProfile', 'Method', {
ownerId: 'class:Repo:partial',
returnType: 'Profile',
});
const typeEnv = buildTypeEnv(tree, 'typescript', { symbolTable });
const lookupCallableByName = vi.spyOn(model.symbols, 'lookupCallableByName');
const typeEnv = buildTypeEnv(tree, 'typescript', { model });
expect(flatGet(typeEnv, 'profile')).toBe('Profile');
expect(lookupCallableByName).not.toHaveBeenCalledWith('getProfile');
});
@ -2560,33 +2499,17 @@ function process(repo: Repo) {
`,
TypeScript.typescript,
);
const lookupCallableByName = vi.fn(() => []);
const symbolTable = createMockSymbolTable({
lookupClassByName: (name: string) => {
if (name === 'Repo') {
return [
createClassDef('Repo', 'Class', 'models-a.ts'),
{ ...createClassDef('Repo', 'Class', 'models-b.ts'), nodeId: 'class:Repo:partial' },
];
}
if (name === 'BaseRepo') return [createClassDef('BaseRepo', 'Class', 'base.ts')];
return [];
},
lookupMethodByOwner: (ownerNodeId: string, methodName: string) =>
ownerNodeId === 'class:BaseRepo' && methodName === 'getProfile'
? {
nodeId: 'method:BaseRepo:getProfile',
filePath: 'base.ts',
type: 'Method',
ownerId: 'class:BaseRepo',
returnType: 'Profile',
}
: undefined,
lookupExactAll: () => [],
lookupCallableByName,
const model = createSemanticModel();
model.symbols.add('models-a.ts', 'Repo', 'class:Repo', 'Class');
model.symbols.add('models-b.ts', 'Repo', 'class:Repo:partial', 'Class');
model.symbols.add('base.ts', 'BaseRepo', 'class:BaseRepo', 'Class');
model.symbols.add('base.ts', 'getProfile', 'method:BaseRepo:getProfile', 'Method', {
ownerId: 'class:BaseRepo',
returnType: 'Profile',
});
const lookupCallableByName = vi.spyOn(model.symbols, 'lookupCallableByName');
const typeEnv = buildTypeEnv(tree, 'typescript', {
symbolTable,
model,
parentMap: new Map([['Repo', ['BaseRepo']]]),
});
expect(flatGet(typeEnv, 'profile')).toBe('Profile');
@ -2602,44 +2525,19 @@ function process(repo: Repo) {
`,
TypeScript.typescript,
);
const lookupCallableByName = vi.fn(() => []);
const symbolTable = createMockSymbolTable({
lookupClassByName: (name: string) =>
name === 'Repo'
? [
createClassDef('Repo', 'Class', 'models-a.ts'),
{
...createClassDef('Repo', 'Class', 'models-b.ts'),
nodeId: 'class:Repo:partial',
},
]
: [],
lookupMethodByOwner: (ownerNodeId: string, methodName: string) => {
if (methodName !== 'getProfile') return undefined;
if (ownerNodeId === 'class:Repo') {
return {
nodeId: 'method:Repo:getProfile#a',
filePath: 'models-a.ts',
type: 'Method',
ownerId: 'class:Repo',
returnType: 'Profile',
};
}
if (ownerNodeId === 'class:Repo:partial') {
return {
nodeId: 'method:Repo:getProfile#b',
filePath: 'models-b.ts',
type: 'Method',
ownerId: 'class:Repo:partial',
returnType: 'Profile',
};
}
return undefined;
},
lookupExactAll: () => [],
lookupCallableByName,
const model = createSemanticModel();
model.symbols.add('models-a.ts', 'Repo', 'class:Repo', 'Class');
model.symbols.add('models-b.ts', 'Repo', 'class:Repo:partial', 'Class');
model.symbols.add('models-a.ts', 'getProfile', 'method:Repo:getProfile#a', 'Method', {
ownerId: 'class:Repo',
returnType: 'Profile',
});
const typeEnv = buildTypeEnv(tree, 'typescript', { symbolTable });
model.symbols.add('models-b.ts', 'getProfile', 'method:Repo:getProfile#b', 'Method', {
ownerId: 'class:Repo:partial',
returnType: 'Profile',
});
const lookupCallableByName = vi.spyOn(model.symbols, 'lookupCallableByName');
const typeEnv = buildTypeEnv(tree, 'typescript', { model });
expect(flatGet(typeEnv, 'profile')).toBeUndefined();
expect(lookupCallableByName).not.toHaveBeenCalledWith('getProfile');
});
@ -2653,42 +2551,18 @@ function process(repo: Repo) {
`,
TypeScript.typescript,
);
const lookupCallableByName = vi.fn(() => []);
const symbolTable = createMockSymbolTable({
lookupClassByName: (name: string) =>
name === 'Repo' ? [createClassDef('Repo', 'Class', 'models.ts')] : [],
lookupMethodByOwner: (ownerNodeId: string, methodName: string) =>
ownerNodeId === 'class:Repo' && methodName === 'getProfile'
? {
nodeId: 'method:Repo:getProfile#1',
filePath: 'models.ts',
type: 'Method',
ownerId: 'class:Repo',
returnType: 'Profile',
}
: undefined,
lookupExactAll: (filePath: string, name: string) =>
filePath === 'models.ts' && name === 'getProfile'
? [
{
nodeId: 'method:Repo:getProfile#1',
filePath: 'models.ts',
type: 'Method',
ownerId: 'class:Repo',
returnType: 'Profile',
},
{
nodeId: 'method:Repo:getProfile#2',
filePath: 'models.ts',
type: 'Method',
ownerId: 'class:Repo',
returnType: 'Profile',
},
]
: [],
lookupCallableByName,
const model = createSemanticModel();
model.symbols.add('models.ts', 'Repo', 'class:Repo', 'Class');
model.symbols.add('models.ts', 'getProfile', 'method:Repo:getProfile#1', 'Method', {
ownerId: 'class:Repo',
returnType: 'Profile',
});
const typeEnv = buildTypeEnv(tree, 'typescript', { symbolTable });
model.symbols.add('models.ts', 'getProfile', 'method:Repo:getProfile#2', 'Method', {
ownerId: 'class:Repo',
returnType: 'Profile',
});
const lookupCallableByName = vi.spyOn(model.symbols, 'lookupCallableByName');
const typeEnv = buildTypeEnv(tree, 'typescript', { model });
expect(flatGet(typeEnv, 'profile')).toBe('Profile');
expect(lookupCallableByName).not.toHaveBeenCalledWith('getProfile');
});
@ -2702,46 +2576,24 @@ function process(repo: Repo) {
`,
TypeScript.typescript,
);
const lookupCallableByName = vi.fn(() => []);
const symbolTable = createMockSymbolTable({
lookupClassByName: (name: string) => {
if (name === 'Repo') return [createClassDef('Repo', 'Class', 'models.ts')];
if (name === 'BaseRepo') return [createClassDef('BaseRepo', 'Class', 'base.ts')];
return [];
},
lookupMethodByOwner: (ownerNodeId: string, methodName: string) =>
ownerNodeId === 'class:BaseRepo' && methodName === 'getProfile'
? {
nodeId: 'method:BaseRepo:getProfile',
filePath: 'base.ts',
type: 'Method',
ownerId: 'class:BaseRepo',
returnType: 'Profile',
}
: undefined,
lookupExactAll: (filePath: string, name: string) =>
filePath === 'models.ts' && name === 'getProfile'
? [
{
nodeId: 'method:Repo:getProfile#1',
filePath: 'models.ts',
type: 'Method',
ownerId: 'class:Repo',
returnType: 'User',
},
{
nodeId: 'method:Repo:getProfile#2',
filePath: 'models.ts',
type: 'Method',
ownerId: 'class:Repo',
returnType: 'Admin',
},
]
: [],
lookupCallableByName,
const model = createSemanticModel();
model.symbols.add('models.ts', 'Repo', 'class:Repo', 'Class');
model.symbols.add('base.ts', 'BaseRepo', 'class:BaseRepo', 'Class');
model.symbols.add('base.ts', 'getProfile', 'method:BaseRepo:getProfile', 'Method', {
ownerId: 'class:BaseRepo',
returnType: 'Profile',
});
model.symbols.add('models.ts', 'getProfile', 'method:Repo:getProfile#1', 'Method', {
ownerId: 'class:Repo',
returnType: 'User',
});
model.symbols.add('models.ts', 'getProfile', 'method:Repo:getProfile#2', 'Method', {
ownerId: 'class:Repo',
returnType: 'Admin',
});
const lookupCallableByName = vi.spyOn(model.symbols, 'lookupCallableByName');
const typeEnv = buildTypeEnv(tree, 'typescript', {
symbolTable,
model,
parentMap: new Map([['Repo', ['BaseRepo']]]),
});
expect(flatGet(typeEnv, 'profile')).toBeUndefined();
@ -2757,46 +2609,20 @@ function process(repo: Repo) {
`,
TypeScript.typescript,
);
const lookupCallableByName = vi.fn(() => []);
const symbolTable = createMockSymbolTable({
lookupClassByName: (name: string) => {
if (name === 'Repo') return [createClassDef('Repo', 'Class', 'models.ts')];
if (name === 'BaseRepo') return [createClassDef('BaseRepo', 'Class', 'base.ts')];
return [];
},
lookupMethodByOwner: (ownerNodeId: string, methodName: string) =>
ownerNodeId === 'class:BaseRepo' && methodName === 'getProfile'
? {
nodeId: 'method:BaseRepo:getProfile#1',
filePath: 'base.ts',
type: 'Method',
ownerId: 'class:BaseRepo',
returnType: 'Profile',
}
: undefined,
lookupExactAll: (filePath: string, name: string) =>
filePath === 'base.ts' && name === 'getProfile'
? [
{
nodeId: 'method:BaseRepo:getProfile#1',
filePath: 'base.ts',
type: 'Method',
ownerId: 'class:BaseRepo',
returnType: 'Profile',
},
{
nodeId: 'method:BaseRepo:getProfile#2',
filePath: 'base.ts',
type: 'Method',
ownerId: 'class:BaseRepo',
returnType: 'Profile',
},
]
: [],
lookupCallableByName,
const model = createSemanticModel();
model.symbols.add('models.ts', 'Repo', 'class:Repo', 'Class');
model.symbols.add('base.ts', 'BaseRepo', 'class:BaseRepo', 'Class');
model.symbols.add('base.ts', 'getProfile', 'method:BaseRepo:getProfile#1', 'Method', {
ownerId: 'class:BaseRepo',
returnType: 'Profile',
});
model.symbols.add('base.ts', 'getProfile', 'method:BaseRepo:getProfile#2', 'Method', {
ownerId: 'class:BaseRepo',
returnType: 'Profile',
});
const lookupCallableByName = vi.spyOn(model.symbols, 'lookupCallableByName');
const typeEnv = buildTypeEnv(tree, 'typescript', {
symbolTable,
model,
parentMap: new Map([['Repo', ['BaseRepo']]]),
});
expect(flatGet(typeEnv, 'profile')).toBe('Profile');
@ -2812,22 +2638,24 @@ function process(repo: Repo) {
`,
TypeScript.typescript,
);
const symbolTable = createSymbolTable();
symbolTable.add('models.ts', 'Repo', 'class:Repo', 'Class');
symbolTable.add('base.ts', 'BaseRepo', 'class:BaseRepo', 'Class');
symbolTable.add('base.ts', 'getProfile', 'method:BaseRepo:getProfile#1', 'Method', {
// SM-21: construct a real SemanticModel and feed it via
// model.symbols.add so the nested registries are populated.
const model = createSemanticModel();
model.symbols.add('models.ts', 'Repo', 'class:Repo', 'Class');
model.symbols.add('base.ts', 'BaseRepo', 'class:BaseRepo', 'Class');
model.symbols.add('base.ts', 'getProfile', 'method:BaseRepo:getProfile#1', 'Method', {
ownerId: 'class:BaseRepo',
parameterCount: 1,
returnType: 'User',
});
symbolTable.add('base.ts', 'getProfile', 'method:BaseRepo:getProfile#2', 'Method', {
model.symbols.add('base.ts', 'getProfile', 'method:BaseRepo:getProfile#2', 'Method', {
ownerId: 'class:BaseRepo',
parameterCount: 2,
returnType: 'Admin',
});
const lookupCallableByName = vi.spyOn(symbolTable, 'lookupCallableByName');
const lookupCallableByName = vi.spyOn(model.symbols, 'lookupCallableByName');
const typeEnv = buildTypeEnv(tree, 'typescript', {
symbolTable,
model,
parentMap: new Map([['Repo', ['BaseRepo']]]),
});
expect(flatGet(typeEnv, 'profile')).toBeUndefined();
@ -5740,24 +5568,20 @@ function process() {
});
describe('importedReturnTypes (Phase 14 E3)', () => {
// Minimal mock SymbolTable that returns a known callable
const makeSymbolTable = (callables: Array<{ name: string; returnType?: string }>) => ({
lookupCallableByName: (name: string) =>
callables
.filter((c) => c.name === name)
.map((c) => ({
nodeId: 'n1',
filePath: 'src.ts',
type: 'Function' as const,
returnType: c.returnType,
})),
lookupClassByName: () => [],
lookupExact: () => undefined,
lookupExactFull: () => undefined,
add: () => {},
getStats: () => ({ fileCount: 0 }),
clear: () => {},
});
// Minimal real SemanticModel populated via model.symbols.add so that
// lookupCallableByName returns Function symbols with the requested
// return types.
const makeSymbolTable = (
callables: Array<{ name: string; returnType?: string }>,
): SemanticModel => {
const model = createSemanticModel();
callables.forEach((c, idx) => {
model.symbols.add('src.ts', c.name, `n${idx}`, 'Function', {
returnType: c.returnType,
});
});
return model;
};
it('SymbolTable has unambiguous match → uses it, ignores cross-file', () => {
// SymbolTable knows getConfig() returns Config (SymbolType)
@ -5765,7 +5589,7 @@ function process() {
const symbolTable = makeSymbolTable([{ name: 'getConfig', returnType: 'Config' }]);
const tree = parse('const c = getConfig();', TypeScript.typescript);
const typeEnv = buildTypeEnv(tree, 'typescript', {
symbolTable: symbolTable as any,
model: symbolTable,
importedReturnTypes: new Map([['getConfig', 'WrongType']]),
});
// SymbolTable result (Config) wins over cross-file fallback (WrongType)
@ -5777,7 +5601,7 @@ function process() {
const symbolTable = makeSymbolTable([]);
const tree = parse('const c = getConfig();', TypeScript.typescript);
const typeEnv = buildTypeEnv(tree, 'typescript', {
symbolTable: symbolTable as any,
model: symbolTable,
importedReturnTypes: new Map([['getConfig', 'Config']]),
});
// Cross-file fallback provides Config
@ -5792,7 +5616,7 @@ function process() {
]);
const tree = parse('const r = process();', TypeScript.typescript);
const typeEnv = buildTypeEnv(tree, 'typescript', {
symbolTable: symbolTable as any,
model: symbolTable,
importedReturnTypes: new Map([['process', 'User']]),
});
// Ambiguous → conservative → no binding produced
@ -5812,7 +5636,7 @@ function process() {
const symbolTable = makeSymbolTable([{ name: 'getUser', returnType: 'User' }]);
const tree = parse('const u = getUser();', TypeScript.typescript);
const typeEnv = buildTypeEnv(tree, 'typescript', {
symbolTable: symbolTable as any,
model: symbolTable,
importedReturnTypes: new Map([['getUser', 'CrossFileUser']]),
});
// SymbolTable result (User) wins