mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-05 08:06:02 +00:00
* 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>
3011 lines
107 KiB
TypeScript
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');
|
|
});
|
|
});
|