GitNexus/gitnexus/test/unit/call-processor.test.ts
Copilot a94d6ef80b
Some checks are pending
CI / Save PR Metadata (push) Blocked by required conditions
CI / quality (push) Waiting to run
CI / tests (push) Waiting to run
CI / e2e (push) Waiting to run
CI / CI Gate (push) Blocked by required conditions
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>
2026-04-12 01:06:55 +01:00

3011 lines
107 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
processCalls,
processCallsFromExtracted,
processAssignmentsFromExtracted,
seedCrossFileReceiverTypes,
extractConsumerAccessedKeys,
processNextjsFetchRoutes,
} from '../../src/core/ingestion/call-processor.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/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,
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>;
let ctx: ResolutionContext;
beforeEach(() => {
graph = createKnowledgeGraph();
ctx = createResolutionContext();
});
it('creates CALLS relationship for same-file resolution', async () => {
ctx.model.symbols.add('src/index.ts', 'helper', 'Function:src/index.ts:helper', 'Function');
const calls: ExtractedCall[] = [
{
filePath: 'src/index.ts',
calledName: 'helper',
sourceId: 'Function:src/index.ts:main',
},
];
await processCallsFromExtracted(graph, calls, ctx);
const rels = graph.relationships.filter((r) => r.type === 'CALLS');
expect(rels).toHaveLength(1);
expect(rels[0].sourceId).toBe('Function:src/index.ts:main');
expect(rels[0].targetId).toBe('Function:src/index.ts:helper');
expect(rels[0].confidence).toBe(0.95);
expect(rels[0].reason).toBe('same-file');
});
it('creates CALLS relationship for import-resolved resolution', async () => {
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[] = [
{
filePath: 'src/index.ts',
calledName: 'format',
sourceId: 'Function:src/index.ts:main',
},
];
await processCallsFromExtracted(graph, calls, ctx);
const rels = graph.relationships.filter((r) => r.type === 'CALLS');
expect(rels).toHaveLength(1);
expect(rels[0].confidence).toBe(0.9);
expect(rels[0].reason).toBe('import-resolved');
});
it('resolves unique global symbol with moderate confidence', async () => {
ctx.model.symbols.add(
'src/other.ts',
'uniqueFunc',
'Function:src/other.ts:uniqueFunc',
'Function',
);
const calls: ExtractedCall[] = [
{
filePath: 'src/index.ts',
calledName: 'uniqueFunc',
sourceId: 'Function:src/index.ts:main',
},
];
await processCallsFromExtracted(graph, calls, ctx);
const rels = graph.relationships.filter((r) => r.type === 'CALLS');
expect(rels).toHaveLength(1);
expect(rels[0].confidence).toBe(0.5);
expect(rels[0].reason).toBe('global');
});
it('refuses ambiguous global symbols — no CALLS edge created', async () => {
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[] = [
{
filePath: 'src/index.ts',
calledName: 'render',
sourceId: 'Function:src/index.ts:main',
},
];
await processCallsFromExtracted(graph, calls, ctx);
const rels = graph.relationships.filter((r) => r.type === 'CALLS');
expect(rels).toHaveLength(0);
});
it('skips unresolvable calls', async () => {
const calls: ExtractedCall[] = [
{
filePath: 'src/index.ts',
calledName: 'nonExistent',
sourceId: 'Function:src/index.ts:main',
},
];
await processCallsFromExtracted(graph, calls, ctx);
expect(graph.relationshipCount).toBe(0);
});
it('refuses non-callable symbols even when the name resolves', async () => {
ctx.model.symbols.add('src/index.ts', 'Widget', 'Class:src/index.ts:Widget', 'Class');
const calls: ExtractedCall[] = [
{
filePath: 'src/index.ts',
calledName: 'Widget',
sourceId: 'Function:src/index.ts:main',
},
];
await processCallsFromExtracted(graph, calls, ctx);
expect(graph.relationshipCount).toBe(0);
});
it('refuses CALLS edges to Interface symbols', async () => {
ctx.model.symbols.add(
'src/types.ts',
'Serializable',
'Interface:src/types.ts:Serializable',
'Interface',
);
ctx.importMap.set('src/index.ts', new Set(['src/types.ts']));
const calls: ExtractedCall[] = [
{
filePath: 'src/index.ts',
calledName: 'Serializable',
sourceId: 'Function:src/index.ts:main',
},
];
await processCallsFromExtracted(graph, calls, ctx);
expect(graph.relationships.filter((r) => r.type === 'CALLS')).toHaveLength(0);
});
it('refuses CALLS edges to Enum symbols', async () => {
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[] = [
{
filePath: 'src/index.ts',
calledName: 'Status',
sourceId: 'Function:src/index.ts:main',
},
];
await processCallsFromExtracted(graph, calls, ctx);
expect(graph.relationships.filter((r) => r.type === 'CALLS')).toHaveLength(0);
});
it('prefers same-file over import-resolved', async () => {
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[] = [
{
filePath: 'src/index.ts',
calledName: 'render',
sourceId: 'Function:src/index.ts:main',
},
];
await processCallsFromExtracted(graph, calls, ctx);
const rels = graph.relationships.filter((r) => r.type === 'CALLS');
expect(rels).toHaveLength(1);
expect(rels[0].targetId).toBe('Function:src/index.ts:render');
expect(rels[0].reason).toBe('same-file');
});
it('handles multiple calls from the same file', async () => {
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' },
{ filePath: 'src/index.ts', calledName: 'bar', sourceId: 'Function:src/index.ts:main' },
];
await processCallsFromExtracted(graph, calls, ctx);
expect(graph.relationships.filter((r) => r.type === 'CALLS')).toHaveLength(2);
});
it('uses arity to disambiguate import-scoped callable candidates', async () => {
ctx.model.symbols.add('src/logger.ts', 'log', 'Function:src/logger.ts:log', 'Function', {
parameterCount: 0,
});
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']));
const calls: ExtractedCall[] = [
{
filePath: 'src/index.ts',
calledName: 'log',
sourceId: 'Function:src/index.ts:main',
argCount: 1,
},
];
await processCallsFromExtracted(graph, calls, ctx);
const rels = graph.relationships.filter((r) => r.type === 'CALLS');
expect(rels).toHaveLength(1);
expect(rels[0].targetId).toBe('Function:src/formatter.ts:log');
expect(rels[0].reason).toBe('import-resolved');
});
it('refuses ambiguous call targets when arity does not produce a unique match', async () => {
ctx.model.symbols.add('src/logger.ts', 'log', 'Function:src/logger.ts:log', 'Function', {
parameterCount: 1,
});
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']));
const calls: ExtractedCall[] = [
{
filePath: 'src/index.ts',
calledName: 'log',
sourceId: 'Function:src/index.ts:main',
argCount: 1,
},
];
await processCallsFromExtracted(graph, calls, ctx);
expect(graph.relationships.filter((r) => r.type === 'CALLS')).toHaveLength(0);
});
it('calls progress callback', async () => {
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' },
];
const onProgress = vi.fn();
await processCallsFromExtracted(graph, calls, ctx, onProgress);
expect(onProgress).toHaveBeenCalledWith(1, 1);
});
it('handles empty calls array', async () => {
await processCallsFromExtracted(graph, [], ctx);
expect(graph.relationshipCount).toBe(0);
});
// ---- Constructor-aware resolution (Phase 2) ----
it('resolves constructor call to Class when no Constructor node exists', async () => {
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[] = [
{
filePath: 'src/index.ts',
calledName: 'User',
sourceId: 'Function:src/index.ts:main',
callForm: 'constructor',
},
];
await processCallsFromExtracted(graph, calls, ctx);
const rels = graph.relationships.filter((r) => r.type === 'CALLS');
expect(rels).toHaveLength(1);
expect(rels[0].targetId).toBe('Class:src/models.ts:User');
expect(rels[0].reason).toBe('import-resolved');
});
it('resolves constructor call to Constructor node over Class node', async () => {
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[] = [
{
filePath: 'src/index.ts',
calledName: 'User',
sourceId: 'Function:src/index.ts:main',
argCount: 1,
callForm: 'constructor',
},
];
await processCallsFromExtracted(graph, calls, ctx);
const rels = graph.relationships.filter((r) => r.type === 'CALLS');
expect(rels).toHaveLength(1);
expect(rels[0].targetId).toBe('Constructor:src/models.ts:User');
});
it('refuses Class target without callForm=constructor (existing behavior)', async () => {
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[] = [
{
filePath: 'src/index.ts',
calledName: 'User',
sourceId: 'Function:src/index.ts:main',
},
];
await processCallsFromExtracted(graph, calls, ctx);
const rels = graph.relationships.filter((r) => r.type === 'CALLS');
expect(rels).toHaveLength(0);
});
it('constructor call falls back to callable types when no Constructor/Class found', async () => {
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[] = [
{
filePath: 'src/index.ts',
calledName: 'Widget',
sourceId: 'Function:src/index.ts:main',
callForm: 'constructor',
},
];
await processCallsFromExtracted(graph, calls, ctx);
const rels = graph.relationships.filter((r) => r.type === 'CALLS');
expect(rels).toHaveLength(1);
expect(rels[0].targetId).toBe('Function:src/utils.ts:Widget');
});
it('constructor arity filtering narrows overloaded constructors', async () => {
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[] = [
{
filePath: 'src/index.ts',
calledName: 'User',
sourceId: 'Function:src/index.ts:main',
argCount: 2,
callForm: 'constructor',
},
];
await processCallsFromExtracted(graph, calls, ctx);
const rels = graph.relationships.filter((r) => r.type === 'CALLS');
expect(rels).toHaveLength(1);
expect(rels[0].targetId).toBe('Constructor:src/models.ts:User(2)');
});
it('cannot discriminate same-arity overloads by parameter type (known limitation)', async () => {
ctx.model.symbols.add('src/UserDao.ts', 'save', 'Function:src/UserDao.ts:save', 'Function', {
parameterCount: 1,
});
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']));
const calls: ExtractedCall[] = [
{
filePath: 'src/index.ts',
calledName: 'save',
sourceId: 'Function:src/index.ts:main',
argCount: 1,
},
];
await processCallsFromExtracted(graph, calls, ctx);
const rels = graph.relationships.filter((r) => r.type === 'CALLS');
expect(rels).toHaveLength(0);
});
// ---- Return type inference (Phase 4) ----
it('return type inference: binds variable to return type of callee', async () => {
// getUser() returns User, and User has a save() method
ctx.model.symbols.add('src/utils.ts', 'getUser', 'Function:src/utils.ts:getUser', 'Function', {
returnType: 'User',
});
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']));
// Binding: user = getUser() — getUser is not a class, so constructor path fails,
// but return type inference should kick in
const constructorBindings: FileConstructorBindings[] = [
{
filePath: 'src/index.ts',
bindings: [{ scope: 'main@0', varName: 'user', calleeName: 'getUser' }],
},
];
const calls: ExtractedCall[] = [
{
filePath: 'src/index.ts',
calledName: 'save',
sourceId: 'Function:src/index.ts:main',
receiverName: 'user',
callForm: 'member',
},
];
await processCallsFromExtracted(graph, calls, ctx, undefined, constructorBindings);
const rels = graph.relationships.filter((r) => r.type === 'CALLS');
expect(rels).toHaveLength(1);
expect(rels[0].targetId).toBe('Method:src/models.ts:save');
});
it('return type inference: unwraps Promise<User> to User', async () => {
ctx.model.symbols.add('src/api.ts', 'fetchUser', 'Function:src/api.ts:fetchUser', 'Function', {
returnType: 'Promise<User>',
});
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']));
const constructorBindings: FileConstructorBindings[] = [
{
filePath: 'src/index.ts',
bindings: [{ scope: 'main@0', varName: 'user', calleeName: 'fetchUser' }],
},
];
const calls: ExtractedCall[] = [
{
filePath: 'src/index.ts',
calledName: 'save',
sourceId: 'Function:src/index.ts:main',
receiverName: 'user',
callForm: 'member',
},
];
await processCallsFromExtracted(graph, calls, ctx, undefined, constructorBindings);
const rels = graph.relationships.filter((r) => r.type === 'CALLS');
expect(rels).toHaveLength(1);
expect(rels[0].targetId).toBe('Method:src/models.ts:save');
});
it('return type inference: skips when return type is primitive', async () => {
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[] = [
{
filePath: 'src/index.ts',
bindings: [{ scope: 'main@0', varName: 'count', calleeName: 'getCount' }],
},
];
const calls: ExtractedCall[] = [
{
filePath: 'src/index.ts',
calledName: 'toString',
sourceId: 'Function:src/index.ts:main',
receiverName: 'count',
callForm: 'member',
},
];
await processCallsFromExtracted(graph, calls, ctx, undefined, constructorBindings);
// No binding should be created for primitive return types
const rels = graph.relationships.filter((r) => r.type === 'CALLS');
expect(rels).toHaveLength(0);
});
it('return type inference: skips ambiguous callees (multiple definitions)', async () => {
ctx.model.symbols.add('src/a.ts', 'getData', 'Function:src/a.ts:getData', 'Function', {
returnType: 'User',
});
ctx.model.symbols.add('src/b.ts', 'getData', 'Function:src/b.ts:getData', 'Function', {
returnType: 'Repo',
});
const constructorBindings: FileConstructorBindings[] = [
{
filePath: 'src/index.ts',
bindings: [{ scope: 'main@0', varName: 'data', calleeName: 'getData' }],
},
];
const calls: ExtractedCall[] = [
{
filePath: 'src/index.ts',
calledName: 'save',
sourceId: 'Function:src/index.ts:main',
receiverName: 'data',
callForm: 'member',
},
];
await processCallsFromExtracted(graph, calls, ctx, undefined, constructorBindings);
// Ambiguous callee — don't guess
const rels = graph.relationships.filter((r) => r.type === 'CALLS');
expect(rels).toHaveLength(0);
});
it('return type inference: prefers constructor binding over return type', async () => {
// If the callee IS a class, constructor binding wins (existing behavior)
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']));
const constructorBindings: FileConstructorBindings[] = [
{
filePath: 'src/index.ts',
bindings: [{ scope: 'main@0', varName: 'user', calleeName: 'User' }],
},
];
const calls: ExtractedCall[] = [
{
filePath: 'src/index.ts',
calledName: 'save',
sourceId: 'Function:src/index.ts:main',
receiverName: 'user',
callForm: 'member',
},
];
await processCallsFromExtracted(graph, calls, ctx, undefined, constructorBindings);
const rels = graph.relationships.filter((r) => r.type === 'CALLS');
expect(rels).toHaveLength(1);
expect(rels[0].targetId).toBe('Method:src/models.ts:save');
});
// ---- Phase 9: BindingAccumulator fallback for cross-file return types ----
it('Phase 9: BindingAccumulator fallback — binds variable to return type when SymbolTable has no returnType', async () => {
// 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.model.symbols.add('src/api.ts', 'getUser', 'Function:src/api.ts:getUser', 'Function', {
// No returnType provided — simulates a structure-processor gap
});
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']));
// namedImportMap: consumer.ts imports { getUser } from src/api.ts
ctx.namedImportMap.set(
'src/consumer.ts',
new Map([['getUser', { sourcePath: 'src/api.ts', exportedName: 'getUser' }]]),
);
// BindingAccumulator carries the TypeEnv-resolved binding from src/api.ts
const acc = new BindingAccumulator();
acc.appendFile('src/api.ts', [{ scope: '', varName: 'getUser', typeName: 'User' }]);
const constructorBindings: FileConstructorBindings[] = [
{
filePath: 'src/consumer.ts',
bindings: [{ scope: 'main@0', varName: 'x', calleeName: 'getUser' }],
},
];
const calls: ExtractedCall[] = [
{
filePath: 'src/consumer.ts',
calledName: 'save',
sourceId: 'Function:src/consumer.ts:main',
receiverName: 'x',
callForm: 'member',
},
];
await processCallsFromExtracted(
graph,
calls,
ctx,
undefined,
constructorBindings,
undefined,
acc,
);
const rels = graph.relationships.filter((r) => r.type === 'CALLS');
expect(rels).toHaveLength(1);
expect(rels[0].targetId).toBe('Method:src/models.ts:save');
});
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.model.symbols.add('src/api.ts', 'getUser', 'Function:src/api.ts:getUser', 'Function', {
returnType: 'User',
});
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']));
ctx.namedImportMap.set(
'src/consumer.ts',
new Map([['getUser', { sourcePath: 'src/api.ts', exportedName: 'getUser' }]]),
);
// Accumulator has a conflicting (wrong) type — should be ignored
const acc = new BindingAccumulator();
acc.appendFile('src/api.ts', [{ scope: '', varName: 'getUser', typeName: 'WrongType' }]);
const constructorBindings: FileConstructorBindings[] = [
{
filePath: 'src/consumer.ts',
bindings: [{ scope: 'main@0', varName: 'x', calleeName: 'getUser' }],
},
];
const calls: ExtractedCall[] = [
{
filePath: 'src/consumer.ts',
calledName: 'save',
sourceId: 'Function:src/consumer.ts:main',
receiverName: 'x',
callForm: 'member',
},
];
await processCallsFromExtracted(
graph,
calls,
ctx,
undefined,
constructorBindings,
undefined,
acc,
);
// Should resolve via SymbolTable (User#save), not the wrong accumulator type
const rels = graph.relationships.filter((r) => r.type === 'CALLS');
expect(rels).toHaveLength(1);
expect(rels[0].targetId).toBe('Method:src/models.ts:save');
});
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.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
const acc = new BindingAccumulator();
acc.appendFile('src/api.ts', [{ scope: '', varName: 'getUser', typeName: 'User' }]);
const constructorBindings: FileConstructorBindings[] = [
{
filePath: 'src/consumer.ts',
bindings: [{ scope: 'main@0', varName: 'x', calleeName: 'getUser' }],
},
];
// Use a method name that is owned by User (requires receiver type resolution)
// 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.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',
});
const calls: ExtractedCall[] = [
{
filePath: 'src/consumer.ts',
calledName: 'save',
sourceId: 'Function:src/consumer.ts:main',
receiverName: 'x',
callForm: 'member',
},
];
await processCallsFromExtracted(
graph,
calls,
ctx,
undefined,
constructorBindings,
undefined,
acc,
);
// Without accumulator fallback (no namedImportMap entry), x is untyped.
// Two methods named 'save' from unrelated types — fuzzy lookup is ambiguous → no edge.
const rels = graph.relationships.filter((r) => r.type === 'CALLS');
expect(rels).toHaveLength(0);
});
it('Phase 9: BindingAccumulator fallback — unwraps Promise<User> type from accumulator', async () => {
// Accumulator stores raw type with Promise wrapper — extractReturnTypeName should unwrap it.
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']));
ctx.namedImportMap.set(
'src/consumer.ts',
new Map([['fetchUser', { sourcePath: 'src/api.ts', exportedName: 'fetchUser' }]]),
);
const acc = new BindingAccumulator();
// Accumulator stores raw Promise<User> as type — should be unwrapped
acc.appendFile('src/api.ts', [{ scope: '', varName: 'fetchUser', typeName: 'Promise<User>' }]);
const constructorBindings: FileConstructorBindings[] = [
{
filePath: 'src/consumer.ts',
bindings: [{ scope: 'main@0', varName: 'x', calleeName: 'fetchUser' }],
},
];
const calls: ExtractedCall[] = [
{
filePath: 'src/consumer.ts',
calledName: 'save',
sourceId: 'Function:src/consumer.ts:main',
receiverName: 'x',
callForm: 'member',
},
];
await processCallsFromExtracted(
graph,
calls,
ctx,
undefined,
constructorBindings,
undefined,
acc,
);
const rels = graph.relationships.filter((r) => r.type === 'CALLS');
expect(rels).toHaveLength(1);
expect(rels[0].targetId).toBe('Method:src/models.ts:save');
});
it('Phase 9: BindingAccumulator fallback — skips primitive types from accumulator', async () => {
// Accumulator stores a primitive type — should not create a CALLS edge.
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',
new Map([['getCount', { sourcePath: 'src/api.ts', exportedName: 'getCount' }]]),
);
const acc = new BindingAccumulator();
acc.appendFile('src/api.ts', [{ scope: '', varName: 'getCount', typeName: 'number' }]);
const constructorBindings: FileConstructorBindings[] = [
{
filePath: 'src/consumer.ts',
bindings: [{ scope: 'main@0', varName: 'count', calleeName: 'getCount' }],
},
];
const calls: ExtractedCall[] = [
{
filePath: 'src/consumer.ts',
calledName: 'toString',
sourceId: 'Function:src/consumer.ts:main',
receiverName: 'count',
callForm: 'member',
},
];
await processCallsFromExtracted(
graph,
calls,
ctx,
undefined,
constructorBindings,
undefined,
acc,
);
// Primitive type — no CALLS edge
const rels = graph.relationships.filter((r) => r.type === 'CALLS');
expect(rels).toHaveLength(0);
});
it('Phase 9: BindingAccumulator fallback — handles aliased import (localName ≠ exportedName)', async () => {
// import { getUser as fetchUser } from './api' — namedImportMap maps localName to exportedName
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']));
// Local alias: fetchUser → api.ts:getUser
ctx.namedImportMap.set(
'src/consumer.ts',
new Map([['fetchUser', { sourcePath: 'src/api.ts', exportedName: 'getUser' }]]),
);
const acc = new BindingAccumulator();
acc.appendFile('src/api.ts', [{ scope: '', varName: 'getUser', typeName: 'User' }]);
const constructorBindings: FileConstructorBindings[] = [
{
filePath: 'src/consumer.ts',
// calleeName is the LOCAL alias used at the call site
bindings: [{ scope: 'main@0', varName: 'x', calleeName: 'fetchUser' }],
},
];
const calls: ExtractedCall[] = [
{
filePath: 'src/consumer.ts',
calledName: 'save',
sourceId: 'Function:src/consumer.ts:main',
receiverName: 'x',
callForm: 'member',
},
];
await processCallsFromExtracted(
graph,
calls,
ctx,
undefined,
constructorBindings,
undefined,
acc,
);
const rels = graph.relationships.filter((r) => r.type === 'CALLS');
expect(rels).toHaveLength(1);
expect(rels[0].targetId).toBe('Method:src/models.ts:save');
});
// ---- Phase 9: Tier gating — accumulator fallback respects resolution tiers ----
it('Phase 9 tier gating: same-file callable shadows imported callee — fallback skipped', async () => {
// consumer.ts defines a local getUser() AND imports getUser from api.ts.
// 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.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.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.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.
ctx.importMap.set('src/consumer.ts', new Set(['src/api.ts']));
ctx.namedImportMap.set(
'src/consumer.ts',
new Map([['getUser', { sourcePath: 'src/api.ts', exportedName: 'getUser' }]]),
);
const acc = new BindingAccumulator();
acc.appendFile('src/api.ts', [{ scope: '', varName: 'getUser', typeName: 'User' }]);
const constructorBindings: FileConstructorBindings[] = [
{
filePath: 'src/consumer.ts',
bindings: [{ scope: 'main@0', varName: 'x', calleeName: 'getUser' }],
},
];
const calls: ExtractedCall[] = [
{
filePath: 'src/consumer.ts',
calledName: 'save',
sourceId: 'Function:src/consumer.ts:main',
receiverName: 'x',
callForm: 'member',
},
];
await processCallsFromExtracted(
graph,
calls,
ctx,
undefined,
constructorBindings,
undefined,
acc,
);
// Fallback must NOT fire — local getUser shadows imported getUser (tier: same-file).
// Without a receiver type, member-call 'save' is ambiguous globally → no edge.
const rels = graph.relationships.filter((r) => r.type === 'CALLS');
expect(rels).toHaveLength(0);
});
it('Phase 9 tier gating: multiple callable candidates — fallback skipped', async () => {
// Two functions named getUser in different imported files — resolution is ambiguous
// (multiple candidates at 'import-scoped' tier). The accumulator carries a WRONG type
// (BadType). If the fallback fires, x gets typed as BadType and x.save() looks for
// BadType.save — which doesn't exist → 0 edges. If the fallback is correctly blocked,
// 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.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.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.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']),
);
ctx.namedImportMap.set(
'src/consumer.ts',
new Map([['getUser', { sourcePath: 'src/api-v1.ts', exportedName: 'getUser' }]]),
);
// Accumulator carries WRONG type — proves gating blocks the fallback
const acc = new BindingAccumulator();
acc.appendFile('src/api-v1.ts', [{ scope: '', varName: 'getUser', typeName: 'BadType' }]);
const constructorBindings: FileConstructorBindings[] = [
{
filePath: 'src/consumer.ts',
bindings: [{ scope: 'main@0', varName: 'x', calleeName: 'getUser' }],
},
];
const calls: ExtractedCall[] = [
{
filePath: 'src/consumer.ts',
calledName: 'save',
sourceId: 'Function:src/consumer.ts:main',
receiverName: 'x',
callForm: 'member',
},
];
await processCallsFromExtracted(
graph,
calls,
ctx,
undefined,
constructorBindings,
undefined,
acc,
);
// If gating works: x has no receiver type, save may or may not resolve via
// import scope (separate mechanism). Key assertion: BadType never appears
// as an ACCESSES target — proving the accumulator's wrong type did not leak.
const accesses = graph.relationships.filter(
(r) => r.type === 'ACCESSES' && r.targetId === 'Class:src/bad.ts:BadType',
);
expect(accesses).toHaveLength(0);
});
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.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']));
ctx.namedImportMap.set(
'src/consumer.ts',
new Map([['getUser', { sourcePath: 'src/api.ts', exportedName: 'getUser' }]]),
);
const acc = new BindingAccumulator();
acc.appendFile('src/api.ts', [{ scope: '', varName: 'getUser', typeName: 'User' }]);
const constructorBindings: FileConstructorBindings[] = [
{
filePath: 'src/consumer.ts',
bindings: [{ scope: 'main@0', varName: 'x', calleeName: 'getUser' }],
},
];
const calls: ExtractedCall[] = [
{
filePath: 'src/consumer.ts',
calledName: 'save',
sourceId: 'Function:src/consumer.ts:main',
receiverName: 'x',
callForm: 'member',
},
];
await processCallsFromExtracted(
graph,
calls,
ctx,
undefined,
constructorBindings,
undefined,
acc,
);
// No SymbolTable entry at all → tiered is null, fallback fires via accumulator.
const rels = graph.relationships.filter((r) => r.type === 'CALLS');
expect(rels).toHaveLength(1);
expect(rels[0].targetId).toBe('Method:src/models.ts:save');
});
it('Phase 9 tier gating: single same-file callable without returnType — fallback skipped', async () => {
// 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.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.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',
});
const acc = new BindingAccumulator();
acc.appendFile('src/api.ts', [{ scope: '', varName: 'getUser', typeName: 'User' }]);
const constructorBindings: FileConstructorBindings[] = [
{
filePath: 'src/consumer.ts',
bindings: [{ scope: 'main@0', varName: 'x', calleeName: 'getUser' }],
},
];
const calls: ExtractedCall[] = [
{
filePath: 'src/consumer.ts',
calledName: 'save',
sourceId: 'Function:src/consumer.ts:main',
receiverName: 'x',
callForm: 'member',
},
];
await processCallsFromExtracted(
graph,
calls,
ctx,
undefined,
constructorBindings,
undefined,
acc,
);
// Same-file callable — local is authoritative even without annotation.
// Fuzzy 'save' lookup is ambiguous → no edge.
const rels = graph.relationships.filter((r) => r.type === 'CALLS');
expect(rels).toHaveLength(0);
});
// ---- Scope-aware constructor bindings (Phase 3) ----
it('receiverKey collision: same method name in different classes does not collide', async () => {
// 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.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']));
ctx.importMap.set('src/models/Repo.ts', new Set(['src/db/Cache.ts']));
// Two bindings: both enclosing scope is named "save" but at different startIndexes
const constructorBindings: FileConstructorBindings[] = [
{
filePath: 'src/models/User.ts',
bindings: [
// save@100: inside User.save(), db = new Database()
{ scope: 'save@100', varName: 'db', calleeName: 'Database' },
],
},
{
filePath: 'src/models/Repo.ts',
bindings: [
// save@200: inside Repo.save(), db = new Cache()
{ scope: 'save@200', varName: 'db', calleeName: 'Cache' },
],
},
];
const calls: ExtractedCall[] = [
{
filePath: 'src/models/User.ts',
calledName: 'query',
sourceId: 'Method:src/models/User.ts:save',
receiverName: 'db',
callForm: 'member',
},
{
filePath: 'src/models/Repo.ts',
calledName: 'query',
sourceId: 'Method:src/models/Repo.ts:save',
receiverName: 'db',
callForm: 'member',
},
];
await processCallsFromExtracted(graph, calls, ctx, undefined, constructorBindings);
const rels = graph.relationships.filter((r) => r.type === 'CALLS');
expect(rels).toHaveLength(2);
const userQueryRel = rels.find((r) => r.sourceId === 'Method:src/models/User.ts:save');
const repoQueryRel = rels.find((r) => r.sourceId === 'Method:src/models/Repo.ts:save');
expect(userQueryRel?.targetId).toBe('Method:src/db/Database.ts:query');
expect(repoQueryRel?.targetId).toBe('Method:src/db/Cache.ts:query');
});
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.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[] = [
{
filePath: 'src/service.ts',
bindings: [
{ scope: 'save@10', varName: 'db', calleeName: 'Database' },
{ scope: 'save@50', varName: 'db', calleeName: 'Database' },
],
},
];
const calls: ExtractedCall[] = [
{
filePath: 'src/service.ts',
calledName: 'query',
sourceId: 'Method:src/service.ts:save',
receiverName: 'db',
callForm: 'member',
},
];
await processCallsFromExtracted(graph, calls, ctx, undefined, constructorBindings);
const rels = graph.relationships.filter((r) => r.type === 'CALLS');
expect(rels).toHaveLength(1);
expect(rels[0].targetId).toBe('Method:src/db/Database.ts:query');
});
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.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']));
const constructorBindings: FileConstructorBindings[] = [
{
filePath: 'src/service.ts',
bindings: [
{ scope: 'save@10', varName: 'db', calleeName: 'Database' },
{ scope: 'save@50', varName: 'db', calleeName: 'Cache' },
],
},
];
const calls: ExtractedCall[] = [
{
filePath: 'src/service.ts',
calledName: 'query',
sourceId: 'Method:src/service.ts:save',
receiverName: 'db',
callForm: 'member',
},
];
await processCallsFromExtracted(graph, calls, ctx, undefined, constructorBindings);
// Ambiguous — different types for same funcName+varName, should not emit a CALLS edge
const rels = graph.relationships.filter((r) => r.type === 'CALLS');
expect(rels).toHaveLength(0);
});
it('scope-aware bindings: same varName in different functions resolves to correct type', async () => {
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[] = [
{
filePath: 'src/index.ts',
bindings: [
{ scope: 'processUser@12', varName: 'obj', calleeName: 'User' },
{ scope: 'processRepo@89', varName: 'obj', calleeName: 'Repo' },
],
},
];
const calls: ExtractedCall[] = [
{
filePath: 'src/index.ts',
calledName: 'save',
sourceId: 'Function:src/index.ts:processUser',
receiverName: 'obj',
callForm: 'member',
},
{
filePath: 'src/index.ts',
calledName: 'save',
sourceId: 'Function:src/index.ts:processRepo',
receiverName: 'obj',
callForm: 'member',
},
];
await processCallsFromExtracted(graph, calls, ctx, undefined, constructorBindings);
const rels = graph.relationships.filter((r) => r.type === 'CALLS');
expect(rels).toHaveLength(2);
// Both calls should resolve, each with the correct receiver type from their scope
// (the important thing is they don't collide — without scope awareness,
// last-write-wins would give both calls the same receiver type)
expect(rels[0].sourceId).toBe('Function:src/index.ts:processUser');
expect(rels[1].sourceId).toBe('Function:src/index.ts:processRepo');
});
});
describe('processCalls — Phase P class lookup fallback', () => {
let graph: ReturnType<typeof createKnowledgeGraph>;
let ctx: ResolutionContext;
beforeEach(() => {
graph = createKnowledgeGraph();
ctx = createResolutionContext();
});
it('uses lookupClassByName to override interface receiver types for cross-file virtual dispatch', async () => {
const appFile = 'services/App.java';
const contractFile = 'models/Pet.java';
const dogFile = 'models/Dog.java';
const petId = 'Interface:models/Pet.java:Pet';
const dogId = 'Class:models/Dog.java:Dog';
const fetchBallId = 'Method:models/Dog.java:fetchBall';
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]));
// 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,
[
{
path: appFile,
content: `
package services;
import models.Pet;
import models.Dog;
class App {
void run() {
Pet pet = new Dog();
pet.fetchBall();
}
}
`,
},
],
createASTCache(),
ctx,
);
const fetchBallCalls = graph.relationships.filter(
(r) => r.type === 'CALLS' && r.targetId === fetchBallId,
);
expect(fetchBallCalls).toHaveLength(1);
expect(classLookupSpy).toHaveBeenCalledWith('Dog');
expect(classLookupSpy).toHaveBeenCalledWith('Pet');
});
it('does not override when the constructor type is not indexed as class-like', async () => {
const appFile = 'services/App.java';
const contractFile = 'models/Pet.java';
const dogFile = 'models/Dog.java';
const otherDogFile = 'models/OtherDog.java';
const petId = 'Interface:models/Pet.java:Pet';
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.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]));
// 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,
[
{
path: appFile,
content: `
package services;
import models.Pet;
import models.Dog;
class App {
void run() {
Pet pet = new Dog();
pet.fetchBall();
}
}
`,
},
],
createASTCache(),
ctx,
);
const fetchBallCalls = graph.relationships.filter(
(r) => r.type === 'CALLS' && r.targetId === 'Method:models/Dog.java:fetchBall',
);
expect(fetchBallCalls).toHaveLength(0);
expect(classLookupSpy).toHaveBeenCalledWith('Dog');
expect(classLookupSpy).not.toHaveBeenCalledWith('Pet');
});
});
describe('extractReturnTypeName', () => {
it('extracts simple type name', () => {
expect(extractReturnTypeName('User')).toBe('User');
});
it('unwraps Promise<User>', () => {
expect(extractReturnTypeName('Promise<User>')).toBe('User');
});
it('unwraps Option<User>', () => {
expect(extractReturnTypeName('Option<User>')).toBe('User');
});
it('unwraps Result<User, Error> to first type arg', () => {
expect(extractReturnTypeName('Result<User, Error>')).toBe('User');
});
it('strips nullable union: User | null', () => {
expect(extractReturnTypeName('User | null')).toBe('User');
});
it('strips nullable union: User | undefined', () => {
expect(extractReturnTypeName('User | undefined')).toBe('User');
});
it('strips nullable suffix: User?', () => {
expect(extractReturnTypeName('User?')).toBe('User');
});
it('strips Go pointer: *User', () => {
expect(extractReturnTypeName('*User')).toBe('User');
});
it('strips Rust reference: &User', () => {
expect(extractReturnTypeName('&User')).toBe('User');
});
it('strips Rust mutable reference: &mut User', () => {
expect(extractReturnTypeName('&mut User')).toBe('User');
});
it('returns undefined for primitives', () => {
expect(extractReturnTypeName('string')).toBeUndefined();
expect(extractReturnTypeName('number')).toBeUndefined();
expect(extractReturnTypeName('boolean')).toBeUndefined();
expect(extractReturnTypeName('void')).toBeUndefined();
expect(extractReturnTypeName('int')).toBeUndefined();
});
it('returns undefined for genuine union types', () => {
expect(extractReturnTypeName('User | Repo')).toBeUndefined();
});
it('returns undefined for empty string', () => {
expect(extractReturnTypeName('')).toBeUndefined();
});
it('extracts qualified type: models.User → User', () => {
expect(extractReturnTypeName('models.User')).toBe('User');
});
it('handles non-wrapper generics: Map<K, V> → Map', () => {
expect(extractReturnTypeName('Map<string, User>')).toBe('Map');
});
it('handles nested wrapper: Promise<Option<User>>', () => {
// Promise<Option<User>> → unwrap Promise → Option<User> → unwrap Option → User
expect(extractReturnTypeName('Promise<Option<User>>')).toBe('User');
});
it('returns base type for collection generics (not unwrapped)', () => {
expect(extractReturnTypeName('Vec<User>')).toBe('Vec');
expect(extractReturnTypeName('List<User>')).toBe('List');
expect(extractReturnTypeName('Array<User>')).toBe('Array');
expect(extractReturnTypeName('Set<User>')).toBe('Set');
expect(extractReturnTypeName('ArrayList<User>')).toBe('ArrayList');
});
it('unwraps Optional<User>', () => {
expect(extractReturnTypeName('Optional<User>')).toBe('User');
});
it('extracts Ruby :: qualified type: Models::User → User', () => {
expect(extractReturnTypeName('Models::User')).toBe('User');
});
it('extracts C++ :: qualified type: ns::HttpClient → HttpClient', () => {
expect(extractReturnTypeName('ns::HttpClient')).toBe('HttpClient');
});
it('extracts deep :: qualified type: crate::models::User → User', () => {
expect(extractReturnTypeName('crate::models::User')).toBe('User');
});
it('extracts mixed qualifier: ns.module::User → User', () => {
expect(extractReturnTypeName('ns.module::User')).toBe('User');
});
it('returns undefined for lowercase :: qualified: std::vector', () => {
expect(extractReturnTypeName('std::vector')).toBeUndefined();
});
it('extracts deep dot-qualified: com.example.models.User → User', () => {
expect(extractReturnTypeName('com.example.models.User')).toBe('User');
});
it('unwraps wrapper over non-wrapper generic: Promise<Map<string, User>> → Map', () => {
// Promise is a wrapper — unwrap it to get Map<string, User>.
// Map is not a wrapper, so return its base type: Map.
expect(extractReturnTypeName('Promise<Map<string, User>>')).toBe('Map');
});
it('unwraps doubly-nested wrapper: Future<Result<User, Error>> → User', () => {
// Future → unwrap → Result<User, Error>; Result → unwrap first arg → User
expect(extractReturnTypeName('Future<Result<User, Error>>')).toBe('User');
});
it('unwraps CompletableFuture<Optional<User>> → User', () => {
// CompletableFuture → unwrap → Optional<User>; Optional → unwrap → User
expect(extractReturnTypeName('CompletableFuture<Optional<User>>')).toBe('User');
});
// Rust smart pointer unwrapping
it('unwraps Rc<User> → User', () => {
expect(extractReturnTypeName('Rc<User>')).toBe('User');
});
it('unwraps Arc<User> → User', () => {
expect(extractReturnTypeName('Arc<User>')).toBe('User');
});
it('unwraps Weak<User> → User', () => {
expect(extractReturnTypeName('Weak<User>')).toBe('User');
});
it('unwraps MutexGuard<User> → User', () => {
expect(extractReturnTypeName('MutexGuard<User>')).toBe('User');
});
it('unwraps RwLockReadGuard<User> → User', () => {
expect(extractReturnTypeName('RwLockReadGuard<User>')).toBe('User');
});
it('unwraps Cow<User> → User', () => {
expect(extractReturnTypeName('Cow<User>')).toBe('User');
});
// Nested: Arc<Option<User>> → User (double unwrap)
it('unwraps Arc<Option<User>> → User', () => {
expect(extractReturnTypeName('Arc<Option<User>>')).toBe('User');
});
// NOT unwrapped (containers/wrappers not in set)
it('does not unwrap Mutex<User> (not a Deref wrapper)', () => {
expect(extractReturnTypeName('Mutex<User>')).toBe('Mutex');
});
// Rust lifetime parameters in wrapper generics
it("skips lifetime in Ref<'_, User> → User", () => {
expect(extractReturnTypeName("Ref<'_, User>")).toBe('User');
});
it("skips lifetime in RefMut<'a, User> → User", () => {
expect(extractReturnTypeName("RefMut<'a, User>")).toBe('User');
});
it("skips lifetime in MutexGuard<'_, User> → User", () => {
expect(extractReturnTypeName("MutexGuard<'_, User>")).toBe('User');
});
it('returns undefined for lowercase non-class types', () => {
expect(extractReturnTypeName('error')).toBeUndefined();
});
it('extracts PHP backslash-namespaced type: \\App\\Models\\User → User', () => {
expect(extractReturnTypeName('\\App\\Models\\User')).toBe('User');
});
it('extracts PHP single-segment namespace: \\User → User', () => {
expect(extractReturnTypeName('\\User')).toBe('User');
});
it('extracts PHP deep namespace: \\Vendor\\Package\\Sub\\Client → Client', () => {
expect(extractReturnTypeName('\\Vendor\\Package\\Sub\\Client')).toBe('Client');
});
it('returns undefined for bare wrapper type names without generic arguments', () => {
expect(extractReturnTypeName('Task')).toBeUndefined();
expect(extractReturnTypeName('Promise')).toBeUndefined();
expect(extractReturnTypeName('Future')).toBeUndefined();
expect(extractReturnTypeName('Option')).toBeUndefined();
expect(extractReturnTypeName('Result')).toBeUndefined();
expect(extractReturnTypeName('Observable')).toBeUndefined();
expect(extractReturnTypeName('ValueTask')).toBeUndefined();
expect(extractReturnTypeName('CompletableFuture')).toBeUndefined();
expect(extractReturnTypeName('Optional')).toBeUndefined();
});
// ---- Length caps (Phase 6) ----
it('pre-cap: returns undefined when raw input exceeds 2048 characters', () => {
const longInput = 'A'.repeat(2049);
expect(extractReturnTypeName(longInput)).toBeUndefined();
});
it('pre-cap: accepts raw input at exactly 2048 characters (boundary)', () => {
// A 2048-char string of uppercase letters passes the pre-cap gate.
// It won't match as a valid identifier (too long for post-cap), so the
// result is undefined — but the pre-cap itself does NOT reject it.
// We test this by verifying a 2048-char type that WOULD be valid in all
// other respects is still returned as undefined (post-cap rejects it).
const atLimit = 'U' + 'x'.repeat(2047); // 2048 chars, starts with uppercase
// Post-cap (512) will reject this, but the pre-cap should not fire.
// The important assertion: no throw and the result is undefined from post-cap.
expect(extractReturnTypeName(atLimit)).toBeUndefined();
});
it('pre-cap: accepts inputs shorter than 2048 characters without rejection', () => {
// 'User' is well under 2048 — should resolve normally.
expect(extractReturnTypeName('User')).toBe('User');
});
it('post-cap: returns undefined when extracted type name exceeds 512 characters', () => {
// Construct a raw string that is under the 2048-char pre-cap but produces
// a final identifier longer than 512 characters after extraction.
// A bare uppercase identifier of 513 chars satisfies all rules except post-cap.
const longTypeName = 'U' + 'x'.repeat(512); // 513 chars, starts with uppercase
expect(extractReturnTypeName(longTypeName)).toBeUndefined();
});
it('post-cap: accepts extracted type name at exactly 512 characters (boundary)', () => {
// 512-char identifier should pass the post-cap check (> 512 rejects, not >=).
const atLimit = 'U' + 'x'.repeat(511); // exactly 512 chars
expect(extractReturnTypeName(atLimit)).toBe(atLimit);
});
it('post-cap: accepts normal short type names well under 512 characters', () => {
expect(extractReturnTypeName('HttpClient')).toBe('HttpClient');
expect(extractReturnTypeName('UserService')).toBe('UserService');
});
});
describe('seedCrossFileReceiverTypes', () => {
it('single-hop: imported receiver gets type from ExportedTypeMap', () => {
const calls: ExtractedCall[] = [
{
filePath: 'src/service.ts',
calledName: 'save',
sourceId: 'Function:src/service.ts:run',
receiverName: 'repo',
callForm: 'member',
},
];
const namedImportMap = new Map([
[
'src/service.ts',
new Map([['repo', { sourcePath: 'src/models/repo.ts', exportedName: 'repo' }]]),
],
]);
const exportedTypeMap = new Map([['src/models/repo.ts', new Map([['repo', 'Repo']])]]);
const { enrichedCount } = seedCrossFileReceiverTypes(calls, namedImportMap, exportedTypeMap);
expect(enrichedCount).toBe(1);
expect(calls[0].receiverTypeName).toBe('Repo');
});
it('no-op when receiverTypeName already exists', () => {
const calls: ExtractedCall[] = [
{
filePath: 'src/service.ts',
calledName: 'save',
sourceId: 'Function:src/service.ts:run',
receiverName: 'repo',
receiverTypeName: 'AlreadyKnown',
callForm: 'member',
},
];
const namedImportMap = new Map([
[
'src/service.ts',
new Map([['repo', { sourcePath: 'src/models/repo.ts', exportedName: 'repo' }]]),
],
]);
const exportedTypeMap = new Map([['src/models/repo.ts', new Map([['repo', 'Repo']])]]);
const { enrichedCount } = seedCrossFileReceiverTypes(calls, namedImportMap, exportedTypeMap);
expect(enrichedCount).toBe(0);
expect(calls[0].receiverTypeName).toBe('AlreadyKnown');
});
it('no-op for free function calls (callForm !== member)', () => {
const calls: ExtractedCall[] = [
{
filePath: 'src/service.ts',
calledName: 'doSomething',
sourceId: 'Function:src/service.ts:run',
receiverName: 'repo',
callForm: 'free',
},
];
const namedImportMap = new Map([
[
'src/service.ts',
new Map([['repo', { sourcePath: 'src/models/repo.ts', exportedName: 'repo' }]]),
],
]);
const exportedTypeMap = new Map([['src/models/repo.ts', new Map([['repo', 'Repo']])]]);
const { enrichedCount } = seedCrossFileReceiverTypes(calls, namedImportMap, exportedTypeMap);
expect(enrichedCount).toBe(0);
expect(calls[0].receiverTypeName).toBeUndefined();
});
it('aliased imports: local name maps to exported name via binding', () => {
const calls: ExtractedCall[] = [
{
filePath: 'src/controller.ts',
calledName: 'find',
sourceId: 'Function:src/controller.ts:handle',
receiverName: 'myRepo',
callForm: 'member',
},
];
// import { repoInstance as myRepo } from 'src/models/repo.ts'
const namedImportMap = new Map([
[
'src/controller.ts',
new Map([['myRepo', { sourcePath: 'src/models/repo.ts', exportedName: 'repoInstance' }]]),
],
]);
const exportedTypeMap = new Map([['src/models/repo.ts', new Map([['repoInstance', 'Repo']])]]);
const { enrichedCount } = seedCrossFileReceiverTypes(calls, namedImportMap, exportedTypeMap);
expect(enrichedCount).toBe(1);
expect(calls[0].receiverTypeName).toBe('Repo');
});
it('early exit when maps are empty', () => {
const calls: ExtractedCall[] = [
{
filePath: 'src/service.ts',
calledName: 'save',
sourceId: 'Function:src/service.ts:run',
receiverName: 'repo',
callForm: 'member',
},
];
const { enrichedCount: countA } = seedCrossFileReceiverTypes(
calls,
new Map(),
new Map([['src/models/repo.ts', new Map([['repo', 'Repo']])]]),
);
expect(countA).toBe(0);
const { enrichedCount: countB } = seedCrossFileReceiverTypes(
calls,
new Map([
[
'src/service.ts',
new Map([['repo', { sourcePath: 'src/models/repo.ts', exportedName: 'repo' }]]),
],
]),
new Map(),
);
expect(countB).toBe(0);
expect(calls[0].receiverTypeName).toBeUndefined();
});
it('no mutation when no matching exports found', () => {
const calls: ExtractedCall[] = [
{
filePath: 'src/service.ts',
calledName: 'save',
sourceId: 'Function:src/service.ts:run',
receiverName: 'repo',
callForm: 'member',
},
];
// namedImportMap has the file, but exportedTypeMap has no entry for the source path
const namedImportMap = new Map([
[
'src/service.ts',
new Map([['repo', { sourcePath: 'src/models/repo.ts', exportedName: 'repo' }]]),
],
]);
const exportedTypeMap = new Map([['src/other-file.ts', new Map([['something', 'OtherType']])]]);
const { enrichedCount } = seedCrossFileReceiverTypes(calls, namedImportMap, exportedTypeMap);
expect(enrichedCount).toBe(0);
expect(calls[0].receiverTypeName).toBeUndefined();
});
});
describe('extractConsumerAccessedKeys', () => {
it('extracts keys from destructuring after .json()', () => {
const content = `
const response = await fetch('/api/grants');
const { data, pagination, error } = await response.json();
`;
const keys = extractConsumerAccessedKeys(content);
expect(keys).toContain('data');
expect(keys).toContain('pagination');
expect(keys).toContain('error');
});
it('extracts keys from destructuring of data variable', () => {
const content = `
const data = await response.json();
const { items, total } = data;
`;
const keys = extractConsumerAccessedKeys(content);
expect(keys).toContain('items');
expect(keys).toContain('total');
});
it('extracts keys from property access on data variable', () => {
const content = `
const data = await response.json();
console.log(data.items);
renderPagination(data.totalPages);
`;
const keys = extractConsumerAccessedKeys(content);
expect(keys).toContain('items');
expect(keys).toContain('totalPages');
});
it('extracts keys from optional chaining', () => {
const content = `
const result = await fetchData();
const items = result?.items;
const count = result?.count;
`;
const keys = extractConsumerAccessedKeys(content);
expect(keys).toContain('items');
expect(keys).toContain('count');
});
it('skips common method names like .json(), .map(), .filter()', () => {
const content = `
const data = await response.json();
data.items.map(x => x.name);
data.items.filter(x => x.active);
`;
const keys = extractConsumerAccessedKeys(content);
expect(keys).toContain('items');
expect(keys).not.toContain('json');
expect(keys).not.toContain('map');
expect(keys).not.toContain('filter');
});
it('returns empty array when no property accesses found', () => {
const content = `
function unrelated() {
console.log('hello');
}
`;
const keys = extractConsumerAccessedKeys(content);
expect(keys).toHaveLength(0);
});
it('handles renamed destructuring bindings', () => {
const content = `
const { data: myData, error: err } = await res.json();
`;
const keys = extractConsumerAccessedKeys(content);
expect(keys).toContain('data');
expect(keys).toContain('error');
// Should extract the original key names, not the aliases
expect(keys).not.toContain('myData');
expect(keys).not.toContain('err');
});
it('deduplicates keys accessed multiple times', () => {
const content = `
const { data } = await res.json();
console.log(data.items);
render(data.items);
`;
const keys = extractConsumerAccessedKeys(content);
const dataCount = keys.filter((k) => k === 'data').length;
expect(dataCount).toBe(1);
});
});
describe('processNextjsFetchRoutes', () => {
let graph: ReturnType<typeof createKnowledgeGraph>;
beforeEach(() => {
graph = createKnowledgeGraph();
});
it('creates FETCHES edge with basic reason when no consumer contents', () => {
// Add a File node for the consumer
graph.addNode({
id: 'File:src/page.tsx',
label: 'File',
properties: { name: 'src/page.tsx', filePath: 'src/page.tsx' },
});
const fetchCalls: ExtractedFetchCall[] = [
{ filePath: 'src/page.tsx', fetchURL: '/api/grants', lineNumber: 10 },
];
const routeRegistry = new Map([['/api/grants', 'src/app/api/grants/route.ts']]);
processNextjsFetchRoutes(graph, fetchCalls, routeRegistry);
const rels = graph.relationships.filter((r) => r.type === 'FETCHES');
expect(rels).toHaveLength(1);
expect(rels[0].reason).toBe('fetch-url-match');
});
it('creates FETCHES edge with accessed keys in reason when consumer contents provided', () => {
graph.addNode({
id: 'File:src/page.tsx',
label: 'File',
properties: { name: 'src/page.tsx', filePath: 'src/page.tsx' },
});
const fetchCalls: ExtractedFetchCall[] = [
{ filePath: 'src/page.tsx', fetchURL: '/api/grants', lineNumber: 10 },
];
const routeRegistry = new Map([['/api/grants', 'src/app/api/grants/route.ts']]);
const consumerContents = new Map([
[
'src/page.tsx',
`
const res = await fetch('/api/grants');
const { data, pagination } = await res.json();
console.log(data.items);
`,
],
]);
processNextjsFetchRoutes(graph, fetchCalls, routeRegistry, consumerContents);
const rels = graph.relationships.filter((r) => r.type === 'FETCHES');
expect(rels).toHaveLength(1);
expect(rels[0].reason).toMatch(/^fetch-url-match\|keys:/);
// Should contain the destructured keys
expect(rels[0].reason).toContain('data');
expect(rels[0].reason).toContain('pagination');
});
it('falls back to basic reason when consumer file has no property accesses', () => {
graph.addNode({
id: 'File:src/page.tsx',
label: 'File',
properties: { name: 'src/page.tsx', filePath: 'src/page.tsx' },
});
const fetchCalls: ExtractedFetchCall[] = [
{ filePath: 'src/page.tsx', fetchURL: '/api/grants', lineNumber: 10 },
];
const routeRegistry = new Map([['/api/grants', 'src/app/api/grants/route.ts']]);
const consumerContents = new Map([
[
'src/page.tsx',
`
// This file just fetches without accessing properties
await fetch('/api/grants');
`,
],
]);
processNextjsFetchRoutes(graph, fetchCalls, routeRegistry, consumerContents);
const rels = graph.relationships.filter((r) => r.type === 'FETCHES');
expect(rels).toHaveLength(1);
expect(rels[0].reason).toBe('fetch-url-match');
});
it('encodes fetch count in reason when consumer fetches multiple routes', () => {
graph.addNode({
id: 'File:src/dashboard.tsx',
label: 'File',
properties: { name: 'src/dashboard.tsx', filePath: 'src/dashboard.tsx' },
});
const fetchCalls: ExtractedFetchCall[] = [
{ filePath: 'src/dashboard.tsx', fetchURL: '/api/grants', lineNumber: 10 },
{ filePath: 'src/dashboard.tsx', fetchURL: '/api/users', lineNumber: 20 },
];
const routeRegistry = new Map([
['/api/grants', 'src/app/api/grants/route.ts'],
['/api/users', 'src/app/api/users/route.ts'],
]);
const consumerContents = new Map([
[
'src/dashboard.tsx',
`
const { data, pagination } = await grantsRes.json();
const { users } = await usersRes.json();
`,
],
]);
processNextjsFetchRoutes(graph, fetchCalls, routeRegistry, consumerContents);
const rels = graph.relationships.filter((r) => r.type === 'FETCHES');
expect(rels).toHaveLength(2);
// Both edges should have |fetches:2 suffix
for (const rel of rels) {
expect(rel.reason).toContain('|fetches:2');
expect(rel.reason).toMatch(/^fetch-url-match\|keys:[^|]+\|fetches:2$/);
}
});
it('does not encode fetch count when consumer fetches only one route', () => {
graph.addNode({
id: 'File:src/page.tsx',
label: 'File',
properties: { name: 'src/page.tsx', filePath: 'src/page.tsx' },
});
const fetchCalls: ExtractedFetchCall[] = [
{ filePath: 'src/page.tsx', fetchURL: '/api/grants', lineNumber: 10 },
];
const routeRegistry = new Map([
['/api/grants', 'src/app/api/grants/route.ts'],
['/api/users', 'src/app/api/users/route.ts'],
]);
const consumerContents = new Map([['src/page.tsx', `const { data } = await res.json();`]]);
processNextjsFetchRoutes(graph, fetchCalls, routeRegistry, consumerContents);
const rels = graph.relationships.filter((r) => r.type === 'FETCHES');
expect(rels).toHaveLength(1);
expect(rels[0].reason).not.toContain('|fetches:');
});
});
describe('processCallsFromExtracted — interface dispatch', () => {
let graph: ReturnType<typeof createKnowledgeGraph>;
let ctx: ResolutionContext;
beforeEach(() => {
graph = createKnowledgeGraph();
ctx = createResolutionContext();
const ifaceFile = 'contracts/Action.java';
const runnerFile = 'runner.java';
const implA = 'impl/A.java';
const implB = 'impl/B.java';
const actionIfaceId = 'Interface:contracts/Action.java:Action';
const ifaceExecuteId = 'Method:contracts/Action.java:execute';
const implAExecuteId = 'Method:impl/A.java:execute';
const implBExecuteId = 'Method:impl/B.java:execute';
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({
id: 'Function:runner.java:run',
label: 'Function',
properties: { name: 'run', filePath: runnerFile },
});
graph.addNode({
id: actionIfaceId,
label: 'Interface',
properties: { name: 'Action', filePath: ifaceFile },
});
graph.addNode({
id: ifaceExecuteId,
label: 'Method',
properties: { name: 'execute', filePath: ifaceFile },
});
graph.addNode({
id: implAExecuteId,
label: 'Method',
properties: { name: 'execute', filePath: implA },
});
graph.addNode({
id: implBExecuteId,
label: 'Method',
properties: { name: 'execute', filePath: implB },
});
});
it('adds CALLS to interface method plus lower-confidence edges to implementing methods', async () => {
const heritage: ExtractedHeritage[] = [
{ filePath: 'impl/A.java', className: 'A', parentName: 'Action', kind: 'implements' },
{ filePath: 'impl/B.java', className: 'B', parentName: 'Action', kind: 'implements' },
];
// Need class symbols for heritage map to resolve implementors
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[] = [
{
filePath: 'runner.java',
calledName: 'execute',
sourceId: 'Function:runner.java:run',
callForm: 'member',
receiverName: 'action',
receiverTypeName: 'Action',
},
];
await processCallsFromExtracted(graph, calls, ctx, undefined, undefined, heritageMap);
const rels = graph.relationships.filter((r) => r.type === 'CALLS');
expect(rels).toHaveLength(3);
const primary = rels.find((r) => r.targetId === 'Method:contracts/Action.java:execute');
const toA = rels.find((r) => r.targetId === 'Method:impl/A.java:execute');
const toB = rels.find((r) => r.targetId === 'Method:impl/B.java:execute');
expect(primary).toBeDefined();
expect(primary!.confidence).toBeGreaterThan(0.7);
expect(toA?.confidence).toBe(0.7);
expect(toA?.reason).toBe('interface-dispatch');
expect(toB?.confidence).toBe(0.7);
expect(toB?.reason).toBe('interface-dispatch');
});
});
// ---------------------------------------------------------------------------
// SM-10: D0 MRO fast path in resolveCallTarget
// ---------------------------------------------------------------------------
describe('processCalls — D0 MRO fast path (SM-10)', () => {
let graph: ReturnType<typeof createKnowledgeGraph>;
let ctx: ResolutionContext;
beforeEach(() => {
graph = createKnowledgeGraph();
ctx = createResolutionContext();
});
const setupChildParent = () => {
const parentFile = 'src/models/Parent.java';
const childFile = 'src/models/Child.java';
const appFile = 'src/services/App.java';
const parentId = 'class:models/Parent.java:Parent';
const childId = 'class:models/Child.java:Child';
const parentMethodId = 'method:models/Parent.java:parentMethod';
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',
});
ctx.importMap.set(appFile, new Set([childFile, parentFile]));
return { parentFile, childFile, appFile, parentId, childId, parentMethodId };
};
it('D0 hit: child.parentMethod() resolves via MRO walk when heritageMap is provided', async () => {
const { parentMethodId, appFile, parentFile, childFile } = setupChildParent();
const heritage: ExtractedHeritage[] = [
{
filePath: childFile,
className: 'Child',
parentName: 'Parent',
kind: 'extends',
},
];
const heritageMap = buildHeritageMap(heritage, ctx);
await processCalls(
graph,
[
{
path: parentFile,
content:
'package models;\npublic class Parent {\n public String parentMethod() { return ""; }\n}\n',
},
{
path: childFile,
content: 'package models;\npublic class Child extends Parent {}\n',
},
{
path: appFile,
content:
'package services;\nimport models.Child;\npublic class App {\n public void run() {\n Child c = new Child();\n c.parentMethod();\n }\n}\n',
},
],
createASTCache(),
ctx,
undefined,
undefined,
undefined,
undefined,
undefined,
heritageMap,
);
const parentMethodCalls = graph.relationships.filter(
(r) => r.type === 'CALLS' && r.targetId === parentMethodId,
);
expect(parentMethodCalls).toHaveLength(1);
});
it('D0 miss: heritageMap provided but method not in MRO chain falls through to D1-D4', async () => {
// 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.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]));
// Empty heritage — no ancestry for Obj, so the MRO walk yields no parents.
const heritageMap = buildHeritageMap([], ctx);
const calls: ExtractedCall[] = [
{
filePath: appFile,
calledName: 'doWork',
sourceId: 'method:services/App.java:run',
argCount: 0,
callForm: 'member',
receiverTypeName: 'Obj',
},
];
await processCallsFromExtracted(graph, calls, ctx, undefined, undefined, heritageMap);
const doWorkCalls = graph.relationships.filter(
(r) => r.type === 'CALLS' && r.targetId === doWorkId,
);
expect(doWorkCalls).toHaveLength(1);
});
it('no heritageMap: inherited methods are unresolvable (null-routed, not false-positive)', async () => {
// Without a HeritageMap, the resolver cannot know that Parent.parentMethod
// belongs to Child's ancestry. The old D1-D4 tail-return would silently
// pick the lone fuzzy candidate and emit a CALLS edge — but that was an
// accidental match that happened to line up because `parentMethod`
// was unique in the global index.
//
// After the R3 tail-return tightening (PR #744 Codex review), member
// calls whose D1-D4 narrowing produces zero file-matched and zero
// owner-matched candidates null-route instead of falling through.
// The test now asserts the honest answer: without heritage information,
// we cannot attribute `c.parentMethod()` to `Parent` and therefore
// emit no edge.
//
// In the real ingestion pipeline, heritageMap is always threaded
// through, so this scenario is only reachable in tests that explicitly
// omit it. Keeping the test confirms the null-route behavior and
// documents the invariant "no heritage → no inherited-method edges".
const { parentMethodId, appFile, parentFile, childFile } = setupChildParent();
await processCalls(
graph,
[
{
path: parentFile,
content:
'package models;\npublic class Parent {\n public String parentMethod() { return ""; }\n}\n',
},
{
path: childFile,
content: 'package models;\npublic class Child extends Parent {}\n',
},
{
path: appFile,
content:
'package services;\nimport models.Child;\npublic class App {\n public void run() {\n Child c = new Child();\n c.parentMethod();\n }\n}\n',
},
],
createASTCache(),
ctx,
// no heritageMap — D0 MRO walk is unavailable, D1-D4 receiver filtering
// also cannot link c.parentMethod() to Parent, so no edge is emitted.
);
const parentMethodCalls = graph.relationships.filter(
(r) => r.type === 'CALLS' && r.targetId === parentMethodId,
);
expect(parentMethodCalls).toHaveLength(0);
});
it('overloadHints guard: D0 skipped so literal-inferred overload disambiguation picks the right overload', async () => {
// Java sequential path: processCalls auto-generates `overloadHints` for
// languages whose provider exposes `inferLiteralType` (Java/Kotlin/C#/C++).
// When two overloads share the same return type, lookupMethodByOwner
// returns defs[0] (the first-added overload) regardless of argument
// types. Without the D0 guard this would mis-resolve `o.method("hello")`
// to method(int). With the guard, D0 is skipped because overloadHints
// is present, and the literal-inferred overload path in D2-D4+E picks
// method(String) correctly.
const classFile = 'src/models/Obj.java';
const appFile = 'src/services/App.java';
const classId = 'class:models/Obj.java:Obj';
const methodIntId = 'method:models/Obj.java:method(int)';
const methodStringId = 'method:models/Obj.java:method(String)';
ctx.model.symbols.add(classFile, 'Obj', classId, 'Class');
// int overload added FIRST so lookupMethodByOwner would return it.
ctx.model.symbols.add(classFile, 'method', methodIntId, 'Method', {
ownerId: classId,
returnType: 'String',
parameterCount: 1,
parameterTypes: ['int'],
});
ctx.model.symbols.add(classFile, 'method', methodStringId, 'Method', {
ownerId: classId,
returnType: 'String',
parameterCount: 1,
parameterTypes: ['String'],
});
ctx.importMap.set(appFile, new Set([classFile]));
const heritageMap = buildHeritageMap([], ctx);
await processCalls(
graph,
[
{
path: classFile,
content:
'package models;\npublic class Obj {\n public String method(int x) { return ""; }\n public String method(String s) { return ""; }\n}\n',
},
{
path: appFile,
content:
'package services;\nimport models.Obj;\npublic class App {\n public void run() {\n Obj o = new Obj();\n o.method("hello");\n }\n}\n',
},
],
createASTCache(),
ctx,
undefined,
undefined,
undefined,
undefined,
undefined,
heritageMap,
);
// Exactly one resolved call, and it must target the String overload.
const methodCalls = graph.relationships.filter(
(r) => r.type === 'CALLS' && (r.targetId === methodIntId || r.targetId === methodStringId),
);
expect(methodCalls).toHaveLength(1);
expect(methodCalls[0].targetId).toBe(methodStringId);
});
it('preComputedArgTypes guard: D0 skipped so arg-type disambiguation picks the right overload', async () => {
// Two overloads of the same method with identical return types live on
// the same owner class. Without the D0 guard, lookupMethodByOwner would
// return defs[0] (the first overload added) regardless of argument types,
// silently mis-resolving an `obj.method("hello")` call to method(int).
// With the guard, preComputedArgTypes forces D0 to be skipped and D2-D4+E
// disambiguates by parameter type.
const classFile = 'src/models/Obj.java';
const appFile = 'src/services/App.java';
const classId = 'class:models/Obj.java:Obj';
const methodIntId = 'method:models/Obj.java:method(int)';
const methodStringId = 'method:models/Obj.java:method(String)';
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.model.symbols.add(classFile, 'method', methodIntId, 'Method', {
ownerId: classId,
returnType: 'String',
parameterCount: 1,
parameterTypes: ['int'],
});
ctx.model.symbols.add(classFile, 'method', methodStringId, 'Method', {
ownerId: classId,
returnType: 'String',
parameterCount: 1,
parameterTypes: ['String'],
});
ctx.importMap.set(appFile, new Set([classFile]));
const heritageMap = buildHeritageMap([], ctx);
const calls: ExtractedCall[] = [
{
filePath: appFile,
calledName: 'method',
sourceId: 'method:services/App.java:run',
argCount: 1,
callForm: 'member',
receiverTypeName: 'Obj',
argTypes: ['String'],
},
];
await processCallsFromExtracted(graph, calls, ctx, undefined, undefined, heritageMap);
const methodCalls = graph.relationships.filter((r) => r.type === 'CALLS');
// Exactly one resolved call, and it must target the String overload —
// NOT the int overload that lookupMethodByOwner would have returned.
expect(methodCalls).toHaveLength(1);
expect(methodCalls[0].targetId).toBe(methodStringId);
});
it('module-alias guard: D0 skipped when receiverName matches an active module alias', async () => {
// Setup: two files each define a class named User with a method save().
// The caller has a Python-style module alias `import auth_mod as auth`,
// so auth.User().save() must resolve to auth_mod.py, NOT user_mod.py.
// D0 would call ctx.resolve('User') and could pick the wrong file; the
// alias guard must short-circuit D0 so the alias-filtered D1-D4 path
// runs and picks the correct file.
const authModFile = 'auth_mod.py';
const userModFile = 'user_mod.py';
const appFile = 'app.py';
const authUserId = 'class:auth_mod.py:User';
const userUserId = 'class:user_mod.py:User';
const authSaveId = 'method:auth_mod.py:save';
const userSaveId = 'method:user_mod.py:save';
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.model.symbols.add(userModFile, 'save', userSaveId, 'Method', {
ownerId: userUserId,
returnType: 'bool',
});
// Register the module alias: in app.py, `auth` points to auth_mod.py.
const aliasMap = new Map<string, string>([['auth', authModFile]]);
ctx.moduleAliasMap.set(appFile, aliasMap);
ctx.importMap.set(appFile, new Set([authModFile]));
const heritageMap = buildHeritageMap([], ctx);
await processCalls(
graph,
[
{
path: authModFile,
content: 'class User:\n def save(self):\n return True\n',
},
{
path: userModFile,
content: 'class User:\n def save(self):\n return True\n',
},
{
path: appFile,
content:
'import auth_mod as auth\n\ndef run():\n user = auth.User()\n user.save()\n',
},
],
createASTCache(),
ctx,
undefined,
undefined,
undefined,
undefined,
undefined,
heritageMap,
);
// save() must resolve to auth_mod.py, NOT user_mod.py.
const authSave = graph.relationships.find(
(r) => r.type === 'CALLS' && r.targetId === authSaveId,
);
const userSave = graph.relationships.find(
(r) => r.type === 'CALLS' && r.targetId === userSaveId,
);
expect(authSave).toBeDefined();
expect(userSave).toBeUndefined();
});
it('module-alias guard (real homonym): both files imported, alias narrows typed member call to aliased file', async () => {
// When both homonym files are imported by the caller, import-scoped
// tiering no longer narrows the tiered pool — the dispatcher sees two
// `save` candidates. Module-alias narrowing is the only remaining
// disambiguation signal. The typed-member branch must consult the alias
// map (as a guarded fallback after owner/file-scoped resolvers fail) or
// null-route silently.
const authModFile = 'src/auth_mod.py';
const userModFile = 'src/user_mod.py';
const appFile = 'src/app.py';
const authUserId = 'class:src/auth_mod.py:User';
const userUserId = 'class:src/user_mod.py:User';
const authSaveId = 'method:src/auth_mod.py:save';
const userSaveId = 'method:src/user_mod.py:save';
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.model.symbols.add(userModFile, 'save', userSaveId, 'Method', {
ownerId: userUserId,
returnType: 'bool',
});
// BOTH files imported by app.py — creates real ambiguity in tiered pool.
ctx.importMap.set(appFile, new Set([authModFile, userModFile]));
// Alias: `auth` points to auth_mod.py.
ctx.moduleAliasMap.set(appFile, new Map([['auth', authModFile]]));
// Call `auth.User.save(user)` — receiverName is `auth` (matches alias),
// receiverTypeName is `User` (the class). This is the class-as-receiver
// static-style pattern parse-worker emits when it sees `auth.User.save(x)`.
const calls: ExtractedCall[] = [
{
filePath: appFile,
calledName: 'save',
sourceId: 'Function:src/app.py:run',
argCount: 1,
callForm: 'member',
receiverName: 'auth',
receiverTypeName: 'User',
},
];
await processCallsFromExtracted(graph, calls, ctx);
const rels = graph.relationships.filter((r) => r.type === 'CALLS');
// Module alias narrows to auth_mod.py. Without it the dispatcher would
// null-route because both User classes own a `save` method and there's
// no heritage or overload signal to pick between them.
expect(rels).toHaveLength(1);
expect(rels[0].targetId).toBe(authSaveId);
});
it('owner-scoped wins over alias narrowing: unique owner-scoped answer beats coincidental alias on unrelated file', async () => {
// Receiver type `User` has exactly one definition, in models.py. Module
// alias `auth → auth.py` exists (because the caller also imports auth.py
// for its own reasons), and auth.py contains an unrelated `Widget` class
// with a homonym `save` method. The caller has `receiverName='auth'`
// (e.g., a local variable coincidentally named `auth`),
// `receiverTypeName='User'`. Owner-scoped resolution must win — alias
// narrowing must not short-circuit a unique correct answer with an
// unrelated homonym from the aliased file.
const modelsFile = 'src/models.py';
const authFile = 'src/auth.py';
const appFile = 'src/app.py';
const modelsUserId = 'class:src/models.py:User';
const authWidgetId = 'class:src/auth.py:Widget';
const modelsSaveId = 'method:src/models.py:User:save';
const authSaveId = 'method:src/auth.py:Widget:save';
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.model.symbols.add(authFile, 'save', authSaveId, 'Method', {
ownerId: authWidgetId,
returnType: 'None',
});
ctx.importMap.set(appFile, new Set([modelsFile, authFile]));
ctx.moduleAliasMap.set(appFile, new Map([['auth', authFile]]));
const calls: ExtractedCall[] = [
{
filePath: appFile,
calledName: 'save',
sourceId: 'Function:src/app.py:run',
argCount: 1,
callForm: 'member',
receiverName: 'auth',
receiverTypeName: 'User',
},
];
await processCallsFromExtracted(graph, calls, ctx);
const rels = graph.relationships.filter((r) => r.type === 'CALLS');
// Owner-scoped runs first and uniquely resolves User.save to models.py.
// Alias narrowing never fires because the scoped resolver already won.
expect(rels).toHaveLength(1);
expect(rels[0].targetId).toBe(modelsSaveId);
});
it('alias narrowing rejects unrelated target type: null-route when alias file does not hold receiver type', async () => {
// Receiver type `User` lives only in models.py, but has no `save` method
// defined. Alias `auth → auth.py`, and auth.py contains an unrelated
// `Widget.save`. Owner-scoped and file-scoped resolvers return null (no
// save on User). Without the type-file verification guard, alias
// narrowing would pick auth.py's `Widget.save` — a cross-type false
// positive. With the guard, auth.py is not in the receiver type's
// defining-files set (which is {models.py}), so alias narrowing bails
// and SM-10 R3 null-routes.
const modelsFile = 'src/models.py';
const authFile = 'src/auth.py';
const appFile = 'src/app.py';
const modelsUserId = 'class:src/models.py:User';
const authWidgetId = 'class:src/auth.py:Widget';
const authSaveId = 'method:src/auth.py:Widget:save';
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.model.symbols.add(authFile, 'save', authSaveId, 'Method', {
ownerId: authWidgetId,
returnType: 'None',
});
ctx.importMap.set(appFile, new Set([modelsFile, authFile]));
ctx.moduleAliasMap.set(appFile, new Map([['auth', authFile]]));
const calls: ExtractedCall[] = [
{
filePath: appFile,
calledName: 'save',
sourceId: 'Function:src/app.py:run',
argCount: 1,
callForm: 'member',
receiverName: 'auth',
receiverTypeName: 'User',
},
];
await processCallsFromExtracted(graph, calls, ctx);
const rels = graph.relationships.filter((r) => r.type === 'CALLS');
// Null-route: no CALLS edge. The type-file guard prevented the alias
// from leaking auth.py's Widget.save onto a User-typed receiver.
expect(rels).toHaveLength(0);
});
it('alias fallthrough: receiverName not in alias map falls through to owner-scoped resolver', async () => {
// Receiver variable `user` does NOT match any alias entry (alias only
// covers `auth`). Owner-scoped resolution must run to completion and
// pick models.py's User.save — the alias helper's early-bail must not
// interfere with unrelated typed member calls. This exercises the 99%
// hot path where alias narrowing is irrelevant.
const modelsFile = 'src/models.py';
const authFile = 'src/auth.py';
const appFile = 'src/app.py';
const modelsUserId = 'class:src/models.py:User';
const modelsSaveId = 'method:src/models.py:User:save';
ctx.model.symbols.add(modelsFile, 'User', modelsUserId, 'Class');
ctx.model.symbols.add(modelsFile, 'save', modelsSaveId, 'Method', {
ownerId: modelsUserId,
returnType: 'None',
});
ctx.importMap.set(appFile, new Set([modelsFile, authFile]));
ctx.moduleAliasMap.set(appFile, new Map([['auth', authFile]]));
const calls: ExtractedCall[] = [
{
filePath: appFile,
calledName: 'save',
sourceId: 'Function:src/app.py:run',
argCount: 0,
callForm: 'member',
receiverName: 'user', // NOT 'auth' — no alias match
receiverTypeName: 'User',
},
];
await processCallsFromExtracted(graph, calls, ctx);
const rels = graph.relationships.filter((r) => r.type === 'CALLS');
expect(rels).toHaveLength(1);
expect(rels[0].targetId).toBe(modelsSaveId);
});
it('alias fallthrough: alias target file has no matching method falls through to owner-scoped', async () => {
// Alias `auth → empty.py` where empty.py exists in the import map but
// has no `save` method at all. Owner-scoped finds models.py's User.save
// uniquely. Even if the type-file guard let alias narrowing fire (it
// won't, because empty.py isn't in the receiver type's files), the
// helper would return null and resolution must still succeed.
const modelsFile = 'src/models.py';
const emptyFile = 'src/empty.py';
const appFile = 'src/app.py';
const modelsUserId = 'class:src/models.py:User';
const modelsSaveId = 'method:src/models.py:User:save';
ctx.model.symbols.add(modelsFile, 'User', modelsUserId, 'Class');
ctx.model.symbols.add(modelsFile, 'save', modelsSaveId, 'Method', {
ownerId: modelsUserId,
returnType: 'None',
});
// empty.py: no symbols at all.
ctx.importMap.set(appFile, new Set([modelsFile, emptyFile]));
ctx.moduleAliasMap.set(appFile, new Map([['auth', emptyFile]]));
const calls: ExtractedCall[] = [
{
filePath: appFile,
calledName: 'save',
sourceId: 'Function:src/app.py:run',
argCount: 0,
callForm: 'member',
receiverName: 'auth',
receiverTypeName: 'User',
},
];
await processCallsFromExtracted(graph, calls, ctx);
const rels = graph.relationships.filter((r) => r.type === 'CALLS');
expect(rels).toHaveLength(1);
expect(rels[0].targetId).toBe(modelsSaveId);
});
it('constructor overload disambiguation: same-arity ownerless constructors picked via preComputedArgTypes', async () => {
// When two homonym constructors across different files have the same
// arity but different parameter types, `resolveStaticCall` correctly
// bails (step 3 ambiguity → step 4 bail because the tiered pool contains
// Constructor nodes). Step 4.5 then runs overload/arg-type disambiguation
// on the constructor-filtered pool, picking the string overload when the
// caller supplies matching `argTypes` / `preComputedArgTypes`.
const userFile = 'src/models/User.ts';
const repoFile = 'src/models/Repo.ts';
const appFile = 'src/app.ts';
const userClassId = 'Class:src/models/User.ts:User';
const repoClassId = 'Class:src/models/Repo.ts:User';
const userCtorId = 'Constructor:src/models/User.ts:User(string)';
const repoCtorId = 'Constructor:src/models/Repo.ts:User(number)';
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.model.symbols.add(repoFile, 'User', repoCtorId, 'Constructor', {
ownerId: repoClassId,
parameterCount: 1,
parameterTypes: ['number'],
});
ctx.importMap.set(appFile, new Set([userFile, repoFile]));
const calls: ExtractedCall[] = [
{
filePath: appFile,
calledName: 'User',
sourceId: 'Function:src/app.ts:main',
argCount: 1,
callForm: 'constructor',
argTypes: ['string'],
},
];
await processCallsFromExtracted(graph, calls, ctx);
const rels = graph.relationships.filter((r) => r.type === 'CALLS');
expect(rels).toHaveLength(1);
expect(rels[0].targetId).toBe(userCtorId);
});
it('constructor overload disambiguation: null-routes when disambiguation cannot pick unique survivor', async () => {
// Control test for Finding 2 fix: when `preComputedArgTypes` does not
// match any candidate uniquely, the dispatcher must null-route rather
// than pick arbitrarily. Preserves SM-10 R3.
const userFile = 'src/models/User.ts';
const repoFile = 'src/models/Repo.ts';
const appFile = 'src/app.ts';
const userClassId = 'Class:src/models/User.ts:User';
const repoClassId = 'Class:src/models/Repo.ts:User';
const userCtorId = 'Constructor:src/models/User.ts:User(string)';
const repoCtorId = 'Constructor:src/models/Repo.ts:User(string)';
ctx.model.symbols.add(userFile, 'User', userClassId, 'Class');
ctx.model.symbols.add(repoFile, 'User', repoClassId, 'Class');
// Both constructors take `string` — genuinely ambiguous.
ctx.model.symbols.add(userFile, 'User', userCtorId, 'Constructor', {
ownerId: userClassId,
parameterCount: 1,
parameterTypes: ['string'],
});
ctx.model.symbols.add(repoFile, 'User', repoCtorId, 'Constructor', {
ownerId: repoClassId,
parameterCount: 1,
parameterTypes: ['string'],
});
ctx.importMap.set(appFile, new Set([userFile, repoFile]));
const calls: ExtractedCall[] = [
{
filePath: appFile,
calledName: 'User',
sourceId: 'Function:src/app.ts:main',
argCount: 1,
callForm: 'constructor',
argTypes: ['string'],
},
];
await processCallsFromExtracted(graph, calls, ctx);
const rels = graph.relationships.filter((r) => r.type === 'CALLS');
expect(rels).toHaveLength(0);
});
});
// ---- processAssignmentsFromExtracted: Phase 9 accumulator fallback ----
describe('processAssignmentsFromExtracted', () => {
let graph: ReturnType<typeof createKnowledgeGraph>;
let ctx: ResolutionContext;
beforeEach(() => {
graph = createKnowledgeGraph();
ctx = createResolutionContext();
});
it('Phase 9: accumulator fallback resolves receiver type for ACCESSES write edge', () => {
// getUser is in the SymbolTable WITHOUT a returnType. The accumulator
// 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.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',
new Map([['getUser', { sourcePath: 'src/api.ts', exportedName: 'getUser' }]]),
);
const acc = new BindingAccumulator();
acc.appendFile('src/api.ts', [{ scope: '', varName: 'getUser', typeName: 'User' }]);
const constructorBindings: FileConstructorBindings[] = [
{
filePath: 'src/consumer.ts',
bindings: [{ scope: 'main@0', varName: 'x', calleeName: 'getUser' }],
},
];
const assignments: ExtractedAssignment[] = [
{
filePath: 'src/consumer.ts',
sourceId: 'Function:src/consumer.ts:main',
receiverText: 'x',
propertyName: 'address',
},
];
processAssignmentsFromExtracted(graph, assignments, ctx, constructorBindings, acc);
const accesses = graph.relationships.filter(
(r) => r.type === 'ACCESSES' && r.reason === 'write',
);
expect(accesses).toHaveLength(1);
expect(accesses[0].targetId).toBe('Property:src/models.ts:address');
});
});
// ---- D2 widen: module-alias + lookupCallableByName resolves method in aliased file ----
describe('D2 widen path: lookupCallableByName via module alias', () => {
let graph: ReturnType<typeof createKnowledgeGraph>;
let ctx: ResolutionContext;
beforeEach(() => {
graph = createKnowledgeGraph();
ctx = createResolutionContext();
});
it('resolves method via module alias widen using lookupCallableByName', async () => {
// Python pattern: `import auth; auth.login()` — auth is a 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.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.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']]));
const calls: ExtractedCall[] = [
{
filePath: 'src/consumer.py',
calledName: 'login',
sourceId: 'Function:src/consumer.py:main',
receiverName: 'auth',
callForm: 'member',
},
];
await processCallsFromExtracted(graph, calls, ctx);
const rels = graph.relationships.filter((r) => r.type === 'CALLS');
expect(rels).toHaveLength(1);
// Should resolve to auth.py's login, NOT consumer.py's same-file shadow
expect(rels[0].targetId).toBe('Function:src/auth.py:login');
});
});