From ab956f113c034c2a130d888d31233d1ecaa9603a Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 10 Apr 2026 10:29:31 +0100 Subject: [PATCH] feat(SM-15): Wire BindingAccumulator into processCallsFromExtracted for cross-file return type propagation (#763) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Initial plan * Initial setup - Phase 9 BindingAccumulator cross-file return type wiring Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/7cee6490-090d-4714-8cb5-a704168ff47a * feat(SM-15): wire BindingAccumulator into processCallsFromExtracted for Phase 9 cross-file return type propagation Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/7cee6490-090d-4714-8cb5-a704168ff47a * fix(SM-15): address all PR #763 review findings Performance (R1) - Changed _fileScopeByFile from Map to Map>. fileScopeGet(filePath, name) is now O(1) — replaces the O(n) linear scan + defensive-copy alloc that ran once per ConstructorBinding entry. fileScopeEntries() reconstructs tuples from Map.entries() for backward compat. - Updated finalize() dev-mode invariant to compare deduplicated Map size rather than raw array length (Map.set deduplicates same-name). Lifecycle (R2) - Documented that Phase 9 intentionally reads pre-finalize because finalize() cannot move before both the worker consumer (line 984) AND the sequential-path writer (line 1061). Pre-finalize reads are safe because finalize() is write-lock-only with no side effects. Replaced the ambiguous "populated but not yet finalized" comment with the full lifecycle ordering explanation. Sequential-path parity (R3) - Wired bindingAccumulator into processCalls at line 797 (sequential path) so verifyConstructorBindings gets the Phase 9 fallback. - Added bindingAccumulator parameter to processAssignmentsFromExtracted signature and wired it at the pipeline.ts call site (line 1026). - Both paths now produce identical Phase 9 behavior for the same code. Tracking comments (R4) - Added "Overlapping mechanism (N of 3)" cross-references at: 1. buildImportedReturnTypes (~line 109) 2. collectExportedBindings (~line 168) 3. Phase 9 fallback in verifyConstructorBindings (~line 563) Each links to the other two and notes future unification. Language coverage (R5) - Added 5 new Phase 9 integration test suites in cross-file-binding.test.ts: JavaScript, C++, C#, PHP, Ruby. Each uses the existing fixture directories and asserts getUser() → User → user.save() resolves. Total cross-file binding tests: 52 (was 37). Quality asymmetry (R6) - Added inline comment at the Phase 9 fallback noting worker-path entries are Tier 0/1 only and that binding accuracy is structurally lower for large repos where the worker path dominates. Tests (+21 new) - 6 fileScopeGet unit tests (happy path, unknown file/name, mixed scopes, post-dispose, duplicate varName last-write-wins) - 15 integration tests across 5 new language suites Verification - tsc --noEmit clean - 3147 unit tests pass (+6 new) - 52 cross-file binding integration tests pass (+15 new) - 1766 resolver integration tests pass - Zero regressions Plan: docs/plans/2026-04-10-001-fix-sm15-review-findings-plan.md Review: https://github.com/abhigyanpatwari/GitNexus/pull/763#issuecomment-4220354242 * fix(SM-15): gate accumulator fallback on resolution tier and fix sequential file-order dependency Two Codex adversarial reviews identified medium-severity bugs in the Phase 9 BindingAccumulator fallback: 1. Local-first violation: the fallback fired regardless of whether ctx.resolve() found same-file candidates, letting an imported callee shadow a local one and produce false CALLS edges. Fixed by gating on tiered.tier !== 'same-file' and callableDefs.length <= 1. 2. Sequential file-order dependency: processCalls flushed and verified per-file, so consumer files processed before their providers missed accumulator bindings. Fixed by splitting into a flush pre-pass (all files) then a resolution loop, mirroring the worker path's "all appends before any reads" pattern. Also adds 11 consumer-before-provider integration test fixtures (one per supported language) and 4 unit tests for tier gating edge cases. * refactor(SM-15): eliminate duplicated prepare logic in processCalls two-pass split Replace the duplicated pre-pass + legacy-path code (parse → query → heritage → TypeEnv → exports) with a single preparation loop followed by a resolution loop. Both paths now share the same preparation code — the only conditional is the accumulator flush. Side benefit: globalParentMap is now fully populated before any resolution runs, improving cross-file isSubclassOf accuracy regardless of file order. Net -118 lines (226 removed, 108 added). * fix(SM-15): address PR #763 third-pass review findings 1. Update stale dispose() JSDoc — remove forward-reference to Phase 9 wiring that is now complete; document actual consumers. 2. Add processAssignmentsFromExtracted Phase 9 unit test — verifies the accumulator fallback produces ACCESSES write edges when the SymbolTable has no returnType for the callee. --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Gergo Magyar --- AGENTS.md | 2 +- CLAUDE.md | 102 ++- gitnexus/package-lock.json | 1 + .../src/core/ingestion/binding-accumulator.ts | 93 +-- gitnexus/src/core/ingestion/call-processor.ts | 155 ++++- gitnexus/src/core/ingestion/pipeline.ts | 31 +- .../a_consumer/main.cpp | 6 + .../b_provider/provider.cpp | 7 + .../b_provider/provider.h | 8 + .../AConsumer/Program.cs | 13 + .../BProvider/User.cs | 7 + .../BProvider/UserFactory.cs | 7 + .../ConsumerBeforeProvider.csproj | 5 + .../go-consumer-before-provider/app/main.go | 8 + .../go-consumer-before-provider/go.mod | 3 + .../models/user.go | 9 + .../app/AConsumer.java | 10 + .../models/BProvider.java | 7 + .../models/User.java | 5 + .../js-consumer-before-provider/a-consumer.js | 10 + .../js-consumer-before-provider/b-provider.js | 7 + .../app/AConsumer.kt | 10 + .../models/BProvider.kt | 7 + .../app/AConsumer.php | 12 + .../app/BProvider.php | 11 + .../composer.json | 7 + .../src/a_consumer.py | 5 + .../src/b_provider.py | 6 + .../rb-consumer-before-provider/a_consumer.rb | 6 + .../models/b_user.rb | 4 + .../models/b_user_factory.rb | 7 + .../src/a_consumer.rs | 6 + .../src/b_provider.rs | 9 + .../rs-consumer-before-provider/src/main.rs | 2 + .../src/a-consumer.ts | 10 + .../src/b-provider.ts | 7 + .../integration/cross-file-binding.test.ts | 578 +++++++++++++++++ .../test/unit/binding-accumulator.test.ts | 48 ++ gitnexus/test/unit/call-processor.test.ts | 596 ++++++++++++++++++ 39 files changed, 1759 insertions(+), 68 deletions(-) create mode 100644 gitnexus/test/fixtures/cross-file-binding/cpp-consumer-before-provider/a_consumer/main.cpp create mode 100644 gitnexus/test/fixtures/cross-file-binding/cpp-consumer-before-provider/b_provider/provider.cpp create mode 100644 gitnexus/test/fixtures/cross-file-binding/cpp-consumer-before-provider/b_provider/provider.h create mode 100644 gitnexus/test/fixtures/cross-file-binding/csharp-consumer-before-provider/AConsumer/Program.cs create mode 100644 gitnexus/test/fixtures/cross-file-binding/csharp-consumer-before-provider/BProvider/User.cs create mode 100644 gitnexus/test/fixtures/cross-file-binding/csharp-consumer-before-provider/BProvider/UserFactory.cs create mode 100644 gitnexus/test/fixtures/cross-file-binding/csharp-consumer-before-provider/ConsumerBeforeProvider.csproj create mode 100644 gitnexus/test/fixtures/cross-file-binding/go-consumer-before-provider/app/main.go create mode 100644 gitnexus/test/fixtures/cross-file-binding/go-consumer-before-provider/go.mod create mode 100644 gitnexus/test/fixtures/cross-file-binding/go-consumer-before-provider/models/user.go create mode 100644 gitnexus/test/fixtures/cross-file-binding/java-consumer-before-provider/app/AConsumer.java create mode 100644 gitnexus/test/fixtures/cross-file-binding/java-consumer-before-provider/models/BProvider.java create mode 100644 gitnexus/test/fixtures/cross-file-binding/java-consumer-before-provider/models/User.java create mode 100644 gitnexus/test/fixtures/cross-file-binding/js-consumer-before-provider/a-consumer.js create mode 100644 gitnexus/test/fixtures/cross-file-binding/js-consumer-before-provider/b-provider.js create mode 100644 gitnexus/test/fixtures/cross-file-binding/kotlin-consumer-before-provider/app/AConsumer.kt create mode 100644 gitnexus/test/fixtures/cross-file-binding/kotlin-consumer-before-provider/models/BProvider.kt create mode 100644 gitnexus/test/fixtures/cross-file-binding/php-consumer-before-provider/app/AConsumer.php create mode 100644 gitnexus/test/fixtures/cross-file-binding/php-consumer-before-provider/app/BProvider.php create mode 100644 gitnexus/test/fixtures/cross-file-binding/php-consumer-before-provider/composer.json create mode 100644 gitnexus/test/fixtures/cross-file-binding/py-consumer-before-provider/src/a_consumer.py create mode 100644 gitnexus/test/fixtures/cross-file-binding/py-consumer-before-provider/src/b_provider.py create mode 100644 gitnexus/test/fixtures/cross-file-binding/rb-consumer-before-provider/a_consumer.rb create mode 100644 gitnexus/test/fixtures/cross-file-binding/rb-consumer-before-provider/models/b_user.rb create mode 100644 gitnexus/test/fixtures/cross-file-binding/rb-consumer-before-provider/models/b_user_factory.rb create mode 100644 gitnexus/test/fixtures/cross-file-binding/rs-consumer-before-provider/src/a_consumer.rs create mode 100644 gitnexus/test/fixtures/cross-file-binding/rs-consumer-before-provider/src/b_provider.rs create mode 100644 gitnexus/test/fixtures/cross-file-binding/rs-consumer-before-provider/src/main.rs create mode 100644 gitnexus/test/fixtures/cross-file-binding/ts-consumer-before-provider/src/a-consumer.ts create mode 100644 gitnexus/test/fixtures/cross-file-binding/ts-consumer-before-provider/src/b-provider.ts diff --git a/AGENTS.md b/AGENTS.md index 09c3eeb12..e6cefed11 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -63,7 +63,7 @@ Generic “core standards” playbooks are often long and stack-specific. For th # GitNexus — Code Intelligence -This project is indexed by GitNexus as **GitNexus** (3298 symbols, 7954 relationships, 185 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. +This project is indexed by GitNexus as **GitNexus** (3883 symbols, 9861 relationships, 225 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. > If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. diff --git a/CLAUDE.md b/CLAUDE.md index 27ab5de74..7b0f175b1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -49,7 +49,107 @@ If always-on instructions grow, load deep conventions via conditional reads (e.g ## GitNexus rules -GitNexus MCP rules are in the `` … `` block in **[AGENTS.md](AGENTS.md)** — load that section when working with MCP tools or the graph index. +GitNexus MCP rules are in the ` +# GitNexus — Code Intelligence + +This project is indexed by GitNexus as **GitNexus** (3883 symbols, 9861 relationships, 225 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. + +> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. + +## Always Do + +- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user. +- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows. +- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits. +- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance. +- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`. + +## When Debugging + +1. `gitnexus_query({query: ""})` — find execution flows related to the issue +2. `gitnexus_context({name: ""})` — see all callers, callees, and process participation +3. `READ gitnexus://repo/GitNexus/process/{processName}` — trace the full execution flow step by step +4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed + +## When Refactoring + +- **Renaming**: MUST use `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` first. Review the preview — graph edits are safe, text_search edits need manual review. Then run with `dry_run: false`. +- **Extracting/Splitting**: MUST run `gitnexus_context({name: "target"})` to see all incoming/outgoing refs, then `gitnexus_impact({target: "target", direction: "upstream"})` to find all external callers before moving code. +- After any refactor: run `gitnexus_detect_changes({scope: "all"})` to verify only expected files changed. + +## Never Do + +- NEVER edit a function, class, or method without first running `gitnexus_impact` on it. +- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis. +- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph. +- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope. + +## Tools Quick Reference + +| Tool | When to use | Command | +|------|-------------|---------| +| `query` | Find code by concept | `gitnexus_query({query: "auth validation"})` | +| `context` | 360-degree view of one symbol | `gitnexus_context({name: "validateUser"})` | +| `impact` | Blast radius before editing | `gitnexus_impact({target: "X", direction: "upstream"})` | +| `detect_changes` | Pre-commit scope check | `gitnexus_detect_changes({scope: "staged"})` | +| `rename` | Safe multi-file rename | `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` | +| `cypher` | Custom graph queries | `gitnexus_cypher({query: "MATCH ..."})` | + +## Impact Risk Levels + +| Depth | Meaning | Action | +|-------|---------|--------| +| d=1 | WILL BREAK — direct callers/importers | MUST update these | +| d=2 | LIKELY AFFECTED — indirect deps | Should test | +| d=3 | MAY NEED TESTING — transitive | Test if critical path | + +## Resources + +| Resource | Use for | +|----------|---------| +| `gitnexus://repo/GitNexus/context` | Codebase overview, check index freshness | +| `gitnexus://repo/GitNexus/clusters` | All functional areas | +| `gitnexus://repo/GitNexus/processes` | All execution flows | +| `gitnexus://repo/GitNexus/process/{name}` | Step-by-step execution trace | + +## Self-Check Before Finishing + +Before completing any code modification task, verify: +1. `gitnexus_impact` was run for all modified symbols +2. No HIGH/CRITICAL risk warnings were ignored +3. `gitnexus_detect_changes()` confirms changes match expected scope +4. All d=1 (WILL BREAK) dependents were updated + +## Keeping the Index Fresh + +After committing code changes, the GitNexus index becomes stale. Re-run analyze to update it: + +```bash +npx gitnexus analyze +``` + +If the index previously included embeddings, preserve them by adding `--embeddings`: + +```bash +npx gitnexus analyze --embeddings +``` + +To check whether embeddings exist, inspect `.gitnexus/meta.json` — the `stats.embeddings` field shows the count (0 means no embeddings). **Running analyze without `--embeddings` will delete any previously generated embeddings.** + +> Claude Code users: A PostToolUse hook handles this automatically after `git commit` and `git merge`. + +## CLI + +| Task | Read this skill file | +|------|---------------------| +| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` | +| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` | +| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` | +| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` | +| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` | +| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` | + +` block in **[AGENTS.md](AGENTS.md)** — load that section when working with MCP tools or the graph index. # GitNexus — Code Intelligence diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index 9abd15b0e..3736d0c46 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -17,6 +17,7 @@ "commander": "^12.0.0", "cors": "^2.8.5", "express": "^4.19.2", + "gitnexus-shared": "file:../gitnexus-shared", "glob": "^11.0.0", "graphology": "^0.25.4", "graphology-indices": "^0.17.0", diff --git a/gitnexus/src/core/ingestion/binding-accumulator.ts b/gitnexus/src/core/ingestion/binding-accumulator.ts index 3e7563623..0f7199b2b 100644 --- a/gitnexus/src/core/ingestion/binding-accumulator.ts +++ b/gitnexus/src/core/ingestion/binding-accumulator.ts @@ -139,18 +139,24 @@ const ENTRY_OVERHEAD = 64; // bytes per entry (object overhead + property refs) const MAP_ENTRY_OVERHEAD = 80; // bytes per file entry in the map export class BindingAccumulator { - // Storage is split into two parallel maps so fileScopeEntries() is - // O(n_file_scope) instead of O(n_total). + // Storage is split into two parallel maps so file-scope reads are fast. // - _allByFile holds every BindingEntry (used by getFile, memory estimate). - // - _fileScopeByFile caches the flat [varName, typeName] view of the - // `scope === ''` subset, populated at insert time so reads are O(1) map - // lookup + O(n_file_scope) array return. Both maps carry the same key - // set modulo the `scope === ''` precondition: _allByFile has a key as - // soon as any entry is appended; _fileScopeByFile only has a key once a - // file-scope entry arrives. Code that iterates via files() uses - // _allByFile so files with only function-scope entries remain visible. + // - _fileScopeByFile is a nested Map> for + // O(1) point-lookup via fileScopeGet(). For iteration-based consumers + // (enrichExportedTypeMap), fileScopeEntries() iterates the inner Map. + // Both maps carry the same key set modulo the `scope === ''` precondition: + // _allByFile has a key as soon as any entry is appended; _fileScopeByFile + // only has a key once a file-scope entry arrives. Code that iterates via + // files() uses _allByFile so files with only function-scope entries + // remain visible. + // + // Note: Map.set semantics mean a duplicate varName for the same file + // overwrites the previous value (last-write-wins). This is the correct + // behavior — duplicate top-level bindings in the same file shouldn't + // happen in well-formed source, and if they do the last declaration + // is typically the one the compiler sees. private readonly _allByFile = new Map(); - private readonly _fileScopeByFile = new Map(); + private readonly _fileScopeByFile = new Map>(); private _totalBindings = 0; private _finalized = false; private _disposed = false; @@ -202,15 +208,16 @@ export class BindingAccumulator { } else { this._allByFile.set(filePath, entries.slice()); } - // File-scope fast-path store. Populated lazily on first file-scope entry. - let existingFileScope = this._fileScopeByFile.get(filePath); + // File-scope fast-path store (nested Map for O(1) point-lookup via fileScopeGet). + // Populated lazily on first file-scope entry per file. + let fileScopeMap = this._fileScopeByFile.get(filePath); for (const e of entries) { if (e.scope === '') { - if (existingFileScope === undefined) { - existingFileScope = []; - this._fileScopeByFile.set(filePath, existingFileScope); + if (fileScopeMap === undefined) { + fileScopeMap = new Map(); + this._fileScopeByFile.set(filePath, fileScopeMap); } - existingFileScope.push([e.varName, e.typeName]); + fileScopeMap.set(e.varName, e.typeName); } } this._totalBindings += entries.length; @@ -225,7 +232,7 @@ export class BindingAccumulator { // indicate a bug in `appendFile()` where one map was updated but // not the other. if (process.env.NODE_ENV !== 'production' && !this._finalized) { - for (const [filePath, fileScopeTuples] of this._fileScopeByFile) { + for (const [filePath, fileScopeMap] of this._fileScopeByFile) { const allEntries = this._allByFile.get(filePath); if (allEntries === undefined) { throw new Error( @@ -233,12 +240,16 @@ export class BindingAccumulator { `but no _allByFile entry`, ); } - const projectedCount = allEntries.filter((e) => e.scope === '').length; - if (projectedCount !== fileScopeTuples.length) { + // Count unique file-scope varNames in _allByFile (to match Map dedup + // semantics in _fileScopeByFile where Map.set deduplicates same-name). + const projectedNames = new Set( + allEntries.filter((e) => e.scope === '').map((e) => e.varName), + ); + if (projectedNames.size !== fileScopeMap.size) { throw new Error( `[BindingAccumulator] storage split drift: file ${filePath} has ` + - `${fileScopeTuples.length} file-scope tuples but ${projectedCount} file-scope ` + - `entries in _allByFile`, + `${fileScopeMap.size} file-scope names in Map but ${projectedNames.size} unique ` + + `file-scope varNames in _allByFile`, ); } } @@ -265,12 +276,11 @@ export class BindingAccumulator { * **after** `finalize()`, subsequent `appendFile` calls throw the existing * "finalized" error. * - * Lifecycle note: the pipeline disposes the accumulator after the - * ExportedTypeMap enrichment loop consumes its file-scope entries, so - * the heap is released before Phase 14 (`runCrossFileBindingPropagation`) - * and `runGraphAnalysisPhases` begin their long-running work. When Phase 9 - * wires a consumer into that stage, the dispose call should move later in - * the pipeline or be removed entirely. + * Lifecycle note: the pipeline disposes the accumulator after both Phase 9 + * consumers (`processCallsFromExtracted`, `processAssignmentsFromExtracted`) + * and the ExportedTypeMap enrichment loop have completed, so the heap is + * released before Phase 14 (`runCrossFileBindingPropagation`) and + * `runGraphAnalysisPhases` begin their long-running work. */ dispose(): void { this._allByFile.clear(); @@ -286,21 +296,30 @@ export class BindingAccumulator { /** * Get only scope='' (file-level) entries as [varName, typeName] tuples. - * Backward-compatible with the old workerTypeEnvBindings pattern. + * For iteration-based consumers (e.g., `enrichExportedTypeMap`). * Returns an empty array for an unknown file. * - * O(1) map lookup + O(n_file_scope) defensive-copy construction — does - * NOT walk function-scope entries. See the `_fileScopeByFile` field - * comment for the storage split rationale. + * O(1) map lookup + O(n_file_scope) tuple reconstruction from the inner + * Map. Does NOT walk function-scope entries. * - * The return value is a shallow copy; mutating it does not affect - * subsequent reads or internal state. This encapsulation guard prevents - * a Phase 9 consumer from accidentally corrupting the accumulator via - * `acc.fileScopeEntries(p).push(...)` or similar. + * For point-lookup consumers (e.g., Phase 9 fallback), prefer + * `fileScopeGet(filePath, name)` — O(1) with no allocation. */ fileScopeEntries(filePath: string): readonly (readonly [string, string])[] { - const cached = this._fileScopeByFile.get(filePath); - return cached ? cached.slice() : []; + const map = this._fileScopeByFile.get(filePath); + return map ? [...map.entries()] : []; + } + + /** + * O(1) point-lookup for a single file-scope binding by (filePath, name). + * Returns the typeName if found, `undefined` otherwise. + * + * This is the preferred lookup path for Phase 9 consumers that resolve + * a single callee's return type — avoids the O(n_file_scope) iteration + * and defensive-copy allocation of `fileScopeEntries()`. + */ + fileScopeGet(filePath: string, name: string): string | undefined { + return this._fileScopeByFile.get(filePath)?.get(name); } /** Iterate over all file paths in insertion order. */ diff --git a/gitnexus/src/core/ingestion/call-processor.ts b/gitnexus/src/core/ingestion/call-processor.ts index bd49f4d09..755ec1e29 100644 --- a/gitnexus/src/core/ingestion/call-processor.ts +++ b/gitnexus/src/core/ingestion/call-processor.ts @@ -105,7 +105,13 @@ const MAX_EXPORTS_PER_FILE = 500; const MAX_TYPE_NAME_LENGTH = 256; /** Build a map of imported callee names → return types for cross-file call-result binding. - * Consulted ONLY when SymbolTable has no unambiguous local match (local-first principle). */ + * Consulted ONLY when SymbolTable has no unambiguous local match (local-first principle). + * + * Overlapping mechanism (1 of 3): this is the SymbolTable-backed path. + * See also: + * 2. collectExportedBindings (~line 168) / enrichExportedTypeMap — TypeEnv + graph isExported + * 3. Phase 9 fallback in verifyConstructorBindings (~line 563) — namedImportMap + BindingAccumulator + * A future cleanup should merge these into a single resolution pass. */ export function buildImportedReturnTypes( filePath: string, namedImportMap: ReadonlyMap< @@ -163,8 +169,13 @@ export function buildImportedRawReturnTypes( * quality enrichment"). Both sites populate the same map with subtly * different export-check semantics — this site uses SymbolTable + * graph lookup, the worker loop uses three-candidate-ID graph lookup. - * They must stay in sync until Phase 9 unifies them. If you edit one, - * check the other. */ + * They must stay in sync until unified. If you edit one, check the other. + * + * Overlapping mechanism (2 of 3): this is the TypeEnv + graph isExported path. + * See also: + * 1. buildImportedReturnTypes (~line 109) — namedImportMap + SymbolTable + * 3. Phase 9 fallback in verifyConstructorBindings (~line 563) — namedImportMap + BindingAccumulator + * A future cleanup should merge these into a single resolution pass. */ function collectExportedBindings( typeEnv: { fileScope(): ReadonlyMap }, filePath: string, @@ -512,6 +523,7 @@ const verifyConstructorBindings = ( filePath: string, ctx: ResolutionContext, graph?: KnowledgeGraph, + bindingAccumulator?: BindingAccumulator, ): Map => { const verified = new Map(); @@ -548,12 +560,60 @@ const verifyConstructorBindings = ( } } + let typeName: string | undefined; if (callableDefs && callableDefs.length === 1 && callableDefs[0].returnType) { - const typeName = extractReturnTypeName(callableDefs[0].returnType); - if (typeName) { - verified.set(receiverKey(scope, varName), typeName); + typeName = extractReturnTypeName(callableDefs[0].returnType); + } + + // Phase 9: BindingAccumulator fallback for cross-file return types. + // Used when the SymbolTable has no return type for a cross-file callee + // (e.g., a return type that TypeEnv resolved via fixpoint in the source + // file but was not stored as a SymbolTable returnType annotation). + // namedImportMap tells us which source file exported the callee so we + // can look up its file-scope binding via the O(1) fileScopeGet method. + // + // Tier gating: only fall back to the accumulator when resolution is + // unambiguously import-scoped or global. When tiered.tier is 'same-file', + // the local definition is authoritative even without a return type + // annotation — using the accumulator here would let an imported callee + // with the same name shadow the local one, producing false CALLS edges. + // When multiple callable candidates exist, the accumulator would pick + // arbitrarily — skip to avoid fabricated edges. + // + // Quality note: worker-path accumulator entries are Tier 0/1 only + // (annotation-declared + same-file constructor inference) — see the + // BindingAccumulator class JSDoc. For large repos where the worker + // path dominates, Phase 9 binding accuracy is structurally lower + // than for sequential-path repos where Tier 2 cross-file propagation + // is available. + // + // Overlapping mechanism note: this is one of three cross-file + // return-type resolution paths in the codebase: + // 1. buildImportedReturnTypes (~line 109) — namedImportMap + + // SymbolTable.lookupExactFull (structure-processor captured) + // 2. collectExportedBindings (~line 168) / enrichExportedTypeMap + // — TypeEnv + graph isExported flag + // 3. This fallback — namedImportMap + BindingAccumulator + // A future cleanup should merge these into a single resolution pass. + const shouldFallback = + tiered?.tier !== 'same-file' && (!callableDefs || callableDefs.length <= 1); + if (!typeName && bindingAccumulator && shouldFallback) { + const namedImports = ctx.namedImportMap.get(filePath); + const importBinding = namedImports?.get(calleeName); + if (importBinding) { + const rawType = bindingAccumulator.fileScopeGet( + importBinding.sourcePath, + importBinding.exportedName, + ); + if (rawType) { + typeName = extractReturnTypeName(rawType); + } } } + + if (typeName) { + verified.set(receiverKey(scope, varName), typeName); + } } } @@ -640,10 +700,29 @@ export const processCalls = async ( const logSkipped = isVerboseIngestionEnabled(); const skippedByLang = logSkipped ? new Map() : null; + // ── Prepare-then-resolve: single preparation loop, deferred resolution ── + // All files are prepared (parse → query → heritage → TypeEnv) in one loop, + // then resolved (verifyConstructorBindings → call edges) in a second loop. + // This ensures: + // 1. When bindingAccumulator is present, ALL files flush their TypeEnv + // bindings before ANY verifyConstructorBindings reads — fixing the + // consumer-before-provider ordering bug on the sequential path. + // 2. globalParentMap is fully populated before resolution, improving + // cross-file isSubclassOf accuracy regardless of file order. + // For the sequential path (<15 files), buffering per-file state is negligible. + interface PreparedFile { + file: { path: string; content: string }; + language: SupportedLanguages; + provider: ReturnType; + tree: ReturnType; + matches: ReturnType; + parentMap: ReadonlyMap; + typeEnv: ReturnType; + } + const prepared: PreparedFile[] = []; + for (let i = 0; i < files.length; i++) { const file = files[i]; - enclosingFnExtractCache.clear(); - onProgress?.(i + 1, files.length); if (i % 20 === 0) await yieldToEventLoop(); const language = getLanguageFromFilename(file.path); @@ -673,18 +752,17 @@ export const processCalls = async ( astCache.set(file.path, tree); } - let query; let matches; try { - const language = parser.getLanguage(); - query = new Parser.Query(language, queryStr); + const lang = parser.getLanguage(); + const query = new Parser.Query(lang, queryStr); matches = query.matches(tree.rootNode); } catch (queryError) { console.warn(`Query error for ${file.path}:`, queryError); continue; } - // Pre-pass: extract heritage from query matches to build parentMap for buildTypeEnv. + // Extract heritage from query matches to build parentMap for buildTypeEnv. // Heritage-processor runs in PARALLEL, so graph edges don't exist when buildTypeEnv runs. const fileParentMap = new Map(); for (const match of matches) { @@ -707,7 +785,6 @@ export const processCalls = async ( } const parentMap: ReadonlyMap = fileParentMap; // Merge per-file heritage into globalParentMap for cross-file isSubclassOf lookups. - // Uses a parallel Set (globalParentSeen) for O(1) deduplication instead of O(n) includes(). for (const [cls, parents] of fileParentMap) { let global = globalParentMap.get(cls); let seen = globalParentSeen.get(cls); @@ -743,19 +820,35 @@ export const processCalls = async ( const fileExports = collectExportedBindings(typeEnv, file.path, ctx.symbols, graph); if (fileExports) exportedTypeMap.set(file.path, fileExports); } - // Flush file-scope bindings into the accumulator. `flush()` is narrowed - // to iterate only FILE_SCOPE entries (type-env.ts) — function-scope - // bindings are dropped at the flush boundary until a Phase 9 consumer - // lands. See type-env.ts::flush() JSDoc for the dual-site reversion - // checklist (this sequential path + the worker path in parse-worker.ts). if (bindingAccumulator) { typeEnv.flush(file.path, bindingAccumulator); } + + prepared.push({ file, language, provider, tree, matches, parentMap, typeEnv }); + } + + // ── Resolution loop: verify constructor bindings and resolve calls ── + // The accumulator (if present) is now fully populated from the preparation + // loop above, so verifyConstructorBindings sees all provider bindings + // regardless of file processing order. + for (let i = 0; i < prepared.length; i++) { + const { file, language, provider, tree, matches, parentMap, typeEnv } = prepared[i]; + + enclosingFnExtractCache.clear(); + onProgress?.(i + 1, files.length); + if (i % 20 === 0) await yieldToEventLoop(); + const callRouter = provider.callRouter; const verifiedReceivers = typeEnv.constructorBindings.length > 0 - ? verifyConstructorBindings(typeEnv.constructorBindings, file.path, ctx) + ? verifyConstructorBindings( + typeEnv.constructorBindings, + file.path, + ctx, + undefined, // graph not available on the sequential path here + bindingAccumulator, // Phase 9 fallback — same as worker path (R3 parity) + ) : new Map(); const receiverIndex = buildReceiverTypeIndex(verifiedReceivers); @@ -2474,6 +2567,12 @@ const walkMixedChain = ( /** * Fast path: resolve pre-extracted call sites from workers. * No AST parsing — workers already extracted calledName + sourceId. + * + * @param bindingAccumulator Phase 9: optional accumulator carrying file-scope + * TypeEnv bindings from all worker-processed files. When the SymbolTable has + * no return type for a cross-file callee, `verifyConstructorBindings` falls + * back to the accumulator via `namedImportMap` to bind the variable to the + * callee's resolved type (e.g. `var x = getUser()` → `x: User`). */ export const processCallsFromExtracted = async ( graph: KnowledgeGraph, @@ -2482,6 +2581,7 @@ export const processCallsFromExtracted = async ( onProgress?: (current: number, total: number) => void, constructorBindings?: FileConstructorBindings[], heritageMap?: HeritageMap, + bindingAccumulator?: BindingAccumulator, ) => { // Scope-aware receiver types: keyed by filePath → "funcName\0varName" → typeName. // The scope dimension prevents collisions when two functions in the same file @@ -2489,7 +2589,13 @@ export const processCallsFromExtracted = async ( const fileReceiverTypes = new Map(); if (constructorBindings) { for (const { filePath, bindings } of constructorBindings) { - const verified = verifyConstructorBindings(bindings, filePath, ctx, graph); + const verified = verifyConstructorBindings( + bindings, + filePath, + ctx, + graph, + bindingAccumulator, + ); if (verified.size > 0) { fileReceiverTypes.set(filePath, buildReceiverTypeIndex(verified)); } @@ -2687,12 +2793,19 @@ export const processAssignmentsFromExtracted = ( assignments: ExtractedAssignment[], ctx: ResolutionContext, constructorBindings?: FileConstructorBindings[], + bindingAccumulator?: BindingAccumulator, ): void => { // Build per-file receiver type indexes from verified constructor bindings const fileReceiverTypes = new Map(); if (constructorBindings) { for (const { filePath, bindings } of constructorBindings) { - const verified = verifyConstructorBindings(bindings, filePath, ctx, graph); + const verified = verifyConstructorBindings( + bindings, + filePath, + ctx, + graph, + bindingAccumulator, + ); if (verified.size > 0) { fileReceiverTypes.set(filePath, buildReceiverTypeIndex(verified)); } diff --git a/gitnexus/src/core/ingestion/pipeline.ts b/gitnexus/src/core/ingestion/pipeline.ts index 9a521e7c5..9c754ed2f 100644 --- a/gitnexus/src/core/ingestion/pipeline.ts +++ b/gitnexus/src/core/ingestion/pipeline.ts @@ -1000,6 +1000,19 @@ async function runChunkedParseAndResolve( }, deferredConstructorBindings.length > 0 ? deferredConstructorBindings : undefined, fullWorkerHeritageMap, + // Phase 9: pass the accumulator so processCallsFromExtracted can fall back + // to file-scope TypeEnv bindings when the SymbolTable lacks a return type + // for a cross-file callee (e.g. var x = getUser() → x: User). + // + // Lifecycle ordering: the accumulator is populated but NOT yet finalized + // at this seam. finalize() is called later (after the sequential-path + // processCalls which also appends via typeEnv.flush()). Moving finalize() + // before this call would break sequential-path repos. Pre-finalize reads + // are safe because finalize() is a write-lock-only operation with no side + // effects on stored data. All worker-path appendFile calls complete in the + // chunk loop above, so every worker-contributed binding is available via + // fileScopeGet(). + bindingAccumulator, ); } @@ -1009,6 +1022,7 @@ async function runChunkedParseAndResolve( deferredAssignments, ctx, deferredConstructorBindings.length > 0 ? deferredConstructorBindings : undefined, + bindingAccumulator, // Phase 9 fallback parity with processCallsFromExtracted (R3) ); } } finally { @@ -1759,14 +1773,15 @@ export const runPipelineFromRepo = async ( } } - // Release the accumulator's heap footprint now. The ExportedTypeMap - // enrichment loop above is the only current consumer, and the dev - // telemetry log just captured peak state. Phase 14 and - // runGraphAnalysisPhases do not read the accumulator today — keeping - // it alive through those long-running phases pins heap for no reason. - // When Phase 9 wires a consumer into runCrossFileBindingPropagation, - // move this dispose() call to after that consumer completes or delete - // it entirely if the consumer takes lifecycle ownership. + // Release the accumulator's heap footprint now. Both consumers of the + // accumulator have completed: + // 1. ExportedTypeMap enrichment loop (enrichExportedTypeMap, above). + // 2. Phase 9: processCallsFromExtracted in runChunkedParseAndResolve, + // which uses the accumulator as a BindingAccumulator fallback for + // cross-file return types when the SymbolTable has no returnType. + // Phase 14 (runCrossFileBindingPropagation) and runGraphAnalysisPhases + // do not read the accumulator — keeping it alive through those long- + // running phases pins heap for no reason. bindingAccumulator.dispose(); // Happy-path dispose completed — clear the cleanup ref so the catch // handler doesn't attempt a second (harmless but noisy) dispose if a diff --git a/gitnexus/test/fixtures/cross-file-binding/cpp-consumer-before-provider/a_consumer/main.cpp b/gitnexus/test/fixtures/cross-file-binding/cpp-consumer-before-provider/a_consumer/main.cpp new file mode 100644 index 000000000..56d13b285 --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/cpp-consumer-before-provider/a_consumer/main.cpp @@ -0,0 +1,6 @@ +#include "../b_provider/provider.h" + +void process() { + User user = get_user(); + user.save(); +} diff --git a/gitnexus/test/fixtures/cross-file-binding/cpp-consumer-before-provider/b_provider/provider.cpp b/gitnexus/test/fixtures/cross-file-binding/cpp-consumer-before-provider/b_provider/provider.cpp new file mode 100644 index 000000000..155d05003 --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/cpp-consumer-before-provider/b_provider/provider.cpp @@ -0,0 +1,7 @@ +#include "provider.h" + +void User::save() {} + +User get_user() { + return User(); +} diff --git a/gitnexus/test/fixtures/cross-file-binding/cpp-consumer-before-provider/b_provider/provider.h b/gitnexus/test/fixtures/cross-file-binding/cpp-consumer-before-provider/b_provider/provider.h new file mode 100644 index 000000000..1d434b7f5 --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/cpp-consumer-before-provider/b_provider/provider.h @@ -0,0 +1,8 @@ +#pragma once + +class User { +public: + void save(); +}; + +User get_user(); diff --git a/gitnexus/test/fixtures/cross-file-binding/csharp-consumer-before-provider/AConsumer/Program.cs b/gitnexus/test/fixtures/cross-file-binding/csharp-consumer-before-provider/AConsumer/Program.cs new file mode 100644 index 000000000..c03a9605a --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/csharp-consumer-before-provider/AConsumer/Program.cs @@ -0,0 +1,13 @@ +using static ConsumerBeforeProvider.BProvider.UserFactory; + +namespace ConsumerBeforeProvider.AConsumer +{ + public class Program + { + public void Run() + { + var u = GetUser(); + u.Save(); + } + } +} diff --git a/gitnexus/test/fixtures/cross-file-binding/csharp-consumer-before-provider/BProvider/User.cs b/gitnexus/test/fixtures/cross-file-binding/csharp-consumer-before-provider/BProvider/User.cs new file mode 100644 index 000000000..4ed71631e --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/csharp-consumer-before-provider/BProvider/User.cs @@ -0,0 +1,7 @@ +namespace ConsumerBeforeProvider.BProvider +{ + public class User + { + public void Save() {} + } +} diff --git a/gitnexus/test/fixtures/cross-file-binding/csharp-consumer-before-provider/BProvider/UserFactory.cs b/gitnexus/test/fixtures/cross-file-binding/csharp-consumer-before-provider/BProvider/UserFactory.cs new file mode 100644 index 000000000..3c66c511a --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/csharp-consumer-before-provider/BProvider/UserFactory.cs @@ -0,0 +1,7 @@ +namespace ConsumerBeforeProvider.BProvider +{ + public static class UserFactory + { + public static User GetUser() { return new User(); } + } +} diff --git a/gitnexus/test/fixtures/cross-file-binding/csharp-consumer-before-provider/ConsumerBeforeProvider.csproj b/gitnexus/test/fixtures/cross-file-binding/csharp-consumer-before-provider/ConsumerBeforeProvider.csproj new file mode 100644 index 000000000..75251fdad --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/csharp-consumer-before-provider/ConsumerBeforeProvider.csproj @@ -0,0 +1,5 @@ + + + ConsumerBeforeProvider + + diff --git a/gitnexus/test/fixtures/cross-file-binding/go-consumer-before-provider/app/main.go b/gitnexus/test/fixtures/cross-file-binding/go-consumer-before-provider/app/main.go new file mode 100644 index 000000000..0951c398d --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/go-consumer-before-provider/app/main.go @@ -0,0 +1,8 @@ +package main + +import "go-consumer-before-provider/models" + +func main() { + user := models.GetUser() + user.Save() +} diff --git a/gitnexus/test/fixtures/cross-file-binding/go-consumer-before-provider/go.mod b/gitnexus/test/fixtures/cross-file-binding/go-consumer-before-provider/go.mod new file mode 100644 index 000000000..00cc50274 --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/go-consumer-before-provider/go.mod @@ -0,0 +1,3 @@ +module go-consumer-before-provider + +go 1.21 diff --git a/gitnexus/test/fixtures/cross-file-binding/go-consumer-before-provider/models/user.go b/gitnexus/test/fixtures/cross-file-binding/go-consumer-before-provider/models/user.go new file mode 100644 index 000000000..e17ca9635 --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/go-consumer-before-provider/models/user.go @@ -0,0 +1,9 @@ +package models + +type User struct{} + +func (u User) Save() {} + +func GetUser() User { + return User{} +} diff --git a/gitnexus/test/fixtures/cross-file-binding/java-consumer-before-provider/app/AConsumer.java b/gitnexus/test/fixtures/cross-file-binding/java-consumer-before-provider/app/AConsumer.java new file mode 100644 index 000000000..37a8d8fd1 --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/java-consumer-before-provider/app/AConsumer.java @@ -0,0 +1,10 @@ +package app; + +import static models.BProvider.getUser; + +public class AConsumer { + public void run() { + var u = getUser(); + u.save(); + } +} diff --git a/gitnexus/test/fixtures/cross-file-binding/java-consumer-before-provider/models/BProvider.java b/gitnexus/test/fixtures/cross-file-binding/java-consumer-before-provider/models/BProvider.java new file mode 100644 index 000000000..5a8ac7d73 --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/java-consumer-before-provider/models/BProvider.java @@ -0,0 +1,7 @@ +package models; + +public class BProvider { + public static User getUser() { + return new User(); + } +} diff --git a/gitnexus/test/fixtures/cross-file-binding/java-consumer-before-provider/models/User.java b/gitnexus/test/fixtures/cross-file-binding/java-consumer-before-provider/models/User.java new file mode 100644 index 000000000..4bb729993 --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/java-consumer-before-provider/models/User.java @@ -0,0 +1,5 @@ +package models; + +public class User { + public void save() {} +} diff --git a/gitnexus/test/fixtures/cross-file-binding/js-consumer-before-provider/a-consumer.js b/gitnexus/test/fixtures/cross-file-binding/js-consumer-before-provider/a-consumer.js new file mode 100644 index 000000000..14b939ba1 --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/js-consumer-before-provider/a-consumer.js @@ -0,0 +1,10 @@ +// File starts with 'a-' to sort alphabetically before 'b-provider.js'. +// In the sequential path, this file is processed first. Without the +// two-pass fix, the accumulator wouldn't have b-provider's bindings +// when this file's verifyConstructorBindings runs. +import { getUser } from './b-provider'; + +export function main() { + const u = getUser(); + u.save(); +} diff --git a/gitnexus/test/fixtures/cross-file-binding/js-consumer-before-provider/b-provider.js b/gitnexus/test/fixtures/cross-file-binding/js-consumer-before-provider/b-provider.js new file mode 100644 index 000000000..99c85948a --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/js-consumer-before-provider/b-provider.js @@ -0,0 +1,7 @@ +export class User { + save() {} +} + +export function getUser() { + return new User(); +} diff --git a/gitnexus/test/fixtures/cross-file-binding/kotlin-consumer-before-provider/app/AConsumer.kt b/gitnexus/test/fixtures/cross-file-binding/kotlin-consumer-before-provider/app/AConsumer.kt new file mode 100644 index 000000000..9f82882a8 --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/kotlin-consumer-before-provider/app/AConsumer.kt @@ -0,0 +1,10 @@ +package app + +import models.getUser + +class AConsumer { + fun run() { + val u = getUser() + u.save() + } +} diff --git a/gitnexus/test/fixtures/cross-file-binding/kotlin-consumer-before-provider/models/BProvider.kt b/gitnexus/test/fixtures/cross-file-binding/kotlin-consumer-before-provider/models/BProvider.kt new file mode 100644 index 000000000..c343056fd --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/kotlin-consumer-before-provider/models/BProvider.kt @@ -0,0 +1,7 @@ +package models + +class User { + fun save() {} +} + +fun getUser(): User = User() diff --git a/gitnexus/test/fixtures/cross-file-binding/php-consumer-before-provider/app/AConsumer.php b/gitnexus/test/fixtures/cross-file-binding/php-consumer-before-provider/app/AConsumer.php new file mode 100644 index 000000000..dd44bf692 --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/php-consumer-before-provider/app/AConsumer.php @@ -0,0 +1,12 @@ +save(); + } +} diff --git a/gitnexus/test/fixtures/cross-file-binding/php-consumer-before-provider/app/BProvider.php b/gitnexus/test/fixtures/cross-file-binding/php-consumer-before-provider/app/BProvider.php new file mode 100644 index 000000000..9eadcb83c --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/php-consumer-before-provider/app/BProvider.php @@ -0,0 +1,11 @@ + User: + return User() diff --git a/gitnexus/test/fixtures/cross-file-binding/rb-consumer-before-provider/a_consumer.rb b/gitnexus/test/fixtures/cross-file-binding/rb-consumer-before-provider/a_consumer.rb new file mode 100644 index 000000000..ed92cf60c --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/rb-consumer-before-provider/a_consumer.rb @@ -0,0 +1,6 @@ +require_relative 'models/b_user_factory' + +def process + user = UserFactory.get_user + user.save +end diff --git a/gitnexus/test/fixtures/cross-file-binding/rb-consumer-before-provider/models/b_user.rb b/gitnexus/test/fixtures/cross-file-binding/rb-consumer-before-provider/models/b_user.rb new file mode 100644 index 000000000..a139ede47 --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/rb-consumer-before-provider/models/b_user.rb @@ -0,0 +1,4 @@ +class User + def save + end +end diff --git a/gitnexus/test/fixtures/cross-file-binding/rb-consumer-before-provider/models/b_user_factory.rb b/gitnexus/test/fixtures/cross-file-binding/rb-consumer-before-provider/models/b_user_factory.rb new file mode 100644 index 000000000..e7c80feff --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/rb-consumer-before-provider/models/b_user_factory.rb @@ -0,0 +1,7 @@ +require_relative 'b_user' + +class UserFactory + def self.get_user + User.new + end +end diff --git a/gitnexus/test/fixtures/cross-file-binding/rs-consumer-before-provider/src/a_consumer.rs b/gitnexus/test/fixtures/cross-file-binding/rs-consumer-before-provider/src/a_consumer.rs new file mode 100644 index 000000000..aa6cd1809 --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/rs-consumer-before-provider/src/a_consumer.rs @@ -0,0 +1,6 @@ +use crate::b_provider::get_user; + +pub fn process() { + let u = get_user(); + u.save(); +} diff --git a/gitnexus/test/fixtures/cross-file-binding/rs-consumer-before-provider/src/b_provider.rs b/gitnexus/test/fixtures/cross-file-binding/rs-consumer-before-provider/src/b_provider.rs new file mode 100644 index 000000000..7b20e072e --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/rs-consumer-before-provider/src/b_provider.rs @@ -0,0 +1,9 @@ +pub struct User; + +impl User { + pub fn save(&self) {} +} + +pub fn get_user() -> User { + User +} diff --git a/gitnexus/test/fixtures/cross-file-binding/rs-consumer-before-provider/src/main.rs b/gitnexus/test/fixtures/cross-file-binding/rs-consumer-before-provider/src/main.rs new file mode 100644 index 000000000..eac6f55f1 --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/rs-consumer-before-provider/src/main.rs @@ -0,0 +1,2 @@ +mod a_consumer; +mod b_provider; diff --git a/gitnexus/test/fixtures/cross-file-binding/ts-consumer-before-provider/src/a-consumer.ts b/gitnexus/test/fixtures/cross-file-binding/ts-consumer-before-provider/src/a-consumer.ts new file mode 100644 index 000000000..d6d5f86c1 --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/ts-consumer-before-provider/src/a-consumer.ts @@ -0,0 +1,10 @@ +// File starts with 'a-' to sort alphabetically before 'b-provider.ts'. +// In the sequential path, this file is processed first. Without the +// two-pass fix, the accumulator wouldn't have b-provider's bindings +// when this file's verifyConstructorBindings runs. +import { getUser } from './b-provider'; + +export function main() { + const x = getUser(); + x.save(); +} diff --git a/gitnexus/test/fixtures/cross-file-binding/ts-consumer-before-provider/src/b-provider.ts b/gitnexus/test/fixtures/cross-file-binding/ts-consumer-before-provider/src/b-provider.ts new file mode 100644 index 000000000..58beea790 --- /dev/null +++ b/gitnexus/test/fixtures/cross-file-binding/ts-consumer-before-provider/src/b-provider.ts @@ -0,0 +1,7 @@ +export class User { + save(): void {} +} + +export function getUser(): User { + return new User(); +} diff --git a/gitnexus/test/integration/cross-file-binding.test.ts b/gitnexus/test/integration/cross-file-binding.test.ts index edae951c0..33063f94f 100644 --- a/gitnexus/test/integration/cross-file-binding.test.ts +++ b/gitnexus/test/integration/cross-file-binding.test.ts @@ -210,3 +210,581 @@ describe('Cross-File Binding Propagation: TypeScript circular imports', () => { expect(paths.some((p) => p.includes('b.ts') && p.includes('a.ts'))).toBe(true); }); }); + +// --------------------------------------------------------------------------- +// SM-15 / Phase 9: Cross-file call-result variable binding — multi-language +// +// Each suite below loads a multi-file fixture where: +// - File A defines a factory function getUser() / get_user() → User +// - File B imports that function, calls `u = getUser()`, then calls u.save() +// +// The acceptance criteria: u.save() / u.save() / u.get_name() must resolve +// to the correct User method via cross-file call-result variable binding. +// These tests cover both the SymbolTable path (languages with explicit return +// type annotations) and validate that the Phase 9 BindingAccumulator wiring +// does not break existing behavior. +// --------------------------------------------------------------------------- + +describe('Phase 9 — Cross-File Call-Result Binding: Java', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(CROSS_FILE_FIXTURES, 'java-cross-file'), () => {}); + }, 60000); + + it('detects User class with save and getName methods', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Method')).toContain('save'); + expect(getNodesByLabel(result, 'Method')).toContain('getName'); + }); + + it('detects getUser factory and run method', () => { + expect(getNodesByLabel(result, 'Method')).toContain('getUser'); + expect(getNodesByLabel(result, 'Method')).toContain('run'); + }); + + it('resolves user.save() in run() to User#save via cross-file return type', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find( + (c) => c.target === 'save' && c.source === 'run' && c.targetFilePath.includes('User'), + ); + expect(saveCall).toBeDefined(); + }); + + it('resolves user.getName() in run() to User#getName via cross-file return type', () => { + const calls = getRelationships(result, 'CALLS'); + const getNameCall = calls.find( + (c) => c.target === 'getName' && c.source === 'run' && c.targetFilePath.includes('User'), + ); + expect(getNameCall).toBeDefined(); + }); +}); + +describe('Phase 9 — Cross-File Call-Result Binding: Python', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(CROSS_FILE_FIXTURES, 'py-cross-file'), () => {}); + }, 60000); + + it('detects User class with save and get_name methods', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + // Python tree-sitter captures all function_definitions as Function, including methods + expect(getNodesByLabel(result, 'Function')).toContain('save'); + expect(getNodesByLabel(result, 'Function')).toContain('get_name'); + }); + + it('detects get_user function and run function', () => { + expect(getNodesByLabel(result, 'Function')).toContain('get_user'); + expect(getNodesByLabel(result, 'Function')).toContain('run'); + }); + + it('resolves u.save() in run() to User#save via cross-file return type', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find( + (c) => c.target === 'save' && c.source === 'run' && c.targetFilePath.includes('models'), + ); + expect(saveCall).toBeDefined(); + }); + + it('resolves u.get_name() in run() to User#get_name via cross-file return type', () => { + const calls = getRelationships(result, 'CALLS'); + const getNameCall = calls.find( + (c) => c.target === 'get_name' && c.source === 'run' && c.targetFilePath.includes('models'), + ); + expect(getNameCall).toBeDefined(); + }); +}); + +describe('Phase 9 — Cross-File Call-Result Binding: Go', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(CROSS_FILE_FIXTURES, 'go-cross-file'), () => {}); + }, 60000); + + it('detects User struct with Save and GetName methods', () => { + expect(getNodesByLabel(result, 'Struct')).toContain('User'); + expect(getNodesByLabel(result, 'Method')).toContain('Save'); + expect(getNodesByLabel(result, 'Method')).toContain('GetName'); + }); + + it('detects GetUser function and main function', () => { + expect(getNodesByLabel(result, 'Function')).toContain('GetUser'); + expect(getNodesByLabel(result, 'Function')).toContain('main'); + }); + + it('resolves user.Save() in main() to User#Save via cross-file return type', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find( + (c) => c.target === 'Save' && c.source === 'main' && c.targetFilePath.includes('models'), + ); + expect(saveCall).toBeDefined(); + }); +}); + +describe('Phase 9 — Cross-File Call-Result Binding: Kotlin', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(CROSS_FILE_FIXTURES, 'kotlin-cross-file'), + () => {}, + ); + }, 60000); + + it('detects User class with save and getName methods', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Method')).toContain('save'); + expect(getNodesByLabel(result, 'Method')).toContain('getName'); + }); + + it('detects getUser function and run method', () => { + expect(getNodesByLabel(result, 'Function')).toContain('getUser'); + expect(getNodesByLabel(result, 'Method')).toContain('run'); + }); + + it('resolves u.save() in run() to User#save via cross-file return type', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find( + (c) => c.target === 'save' && c.source === 'run' && c.targetFilePath.includes('User'), + ); + expect(saveCall).toBeDefined(); + }); +}); + +describe('Phase 9 — Cross-File Call-Result Binding: Rust', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(CROSS_FILE_FIXTURES, 'rs-cross-file'), () => {}); + }, 60000); + + it('detects User struct with save and get_name methods', () => { + expect(getNodesByLabel(result, 'Struct')).toContain('User'); + // Rust tree-sitter captures impl fns as Function nodes + expect(getNodesByLabel(result, 'Function')).toContain('save'); + expect(getNodesByLabel(result, 'Function')).toContain('get_name'); + }); + + it('detects get_user function and process function', () => { + expect(getNodesByLabel(result, 'Function')).toContain('get_user'); + expect(getNodesByLabel(result, 'Function')).toContain('process'); + }); + + it('resolves u.save() in process() to User#save via cross-file return type', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find( + (c) => c.target === 'save' && c.source === 'process' && c.targetFilePath.includes('models'), + ); + expect(saveCall).toBeDefined(); + }); +}); + +// ── R5: Missing language coverage (PR #763 review finding #5) ──────────── + +describe('Phase 9 — Cross-File Call-Result Binding: JavaScript', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(CROSS_FILE_FIXTURES, 'js-cross-file'), () => {}); + }, 60000); + + it('detects User class with save and getName methods', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Method')).toContain('save'); + expect(getNodesByLabel(result, 'Method')).toContain('getName'); + }); + + it('detects getUser factory and run function', () => { + expect(getNodesByLabel(result, 'Function')).toContain('getUser'); + expect(getNodesByLabel(result, 'Function')).toContain('run'); + }); + + it('resolves u.save() in run() to User#save via cross-file return type', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find( + (c) => c.target === 'save' && c.source === 'run' && c.targetFilePath.includes('models'), + ); + expect(saveCall).toBeDefined(); + }); +}); + +describe('Phase 9 — Cross-File Call-Result Binding: C++', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(CROSS_FILE_FIXTURES, 'cpp-cross-file'), () => {}); + }, 60000); + + it('detects User class with save and get_name methods', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Method')).toContain('save'); + expect(getNodesByLabel(result, 'Method')).toContain('get_name'); + }); + + it('detects get_user factory function', () => { + expect(getNodesByLabel(result, 'Function')).toContain('get_user'); + }); + + it('resolves user.save() in process() to User#save via cross-file return type', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find( + (c) => c.target === 'save' && c.source === 'process' && c.targetFilePath.includes('user'), + ); + expect(saveCall).toBeDefined(); + }); +}); + +describe('Phase 9 — Cross-File Call-Result Binding: C#', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(CROSS_FILE_FIXTURES, 'csharp-cross-file'), + () => {}, + ); + }, 60000); + + it('detects User class with Save and GetName methods', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Method')).toContain('Save'); + expect(getNodesByLabel(result, 'Method')).toContain('GetName'); + }); + + it('detects GetUser factory and Run method', () => { + expect(getNodesByLabel(result, 'Method')).toContain('GetUser'); + expect(getNodesByLabel(result, 'Method')).toContain('Run'); + }); + + it('resolves u.Save() in Run() to User#Save via cross-file return type', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find( + (c) => c.target === 'Save' && c.source === 'Run' && c.targetFilePath.includes('User'), + ); + expect(saveCall).toBeDefined(); + }); +}); + +describe('Phase 9 — Cross-File Call-Result Binding: PHP', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(CROSS_FILE_FIXTURES, 'php-cross-file'), () => {}); + }, 60000); + + it('detects User class with save and getName methods', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Method')).toContain('save'); + expect(getNodesByLabel(result, 'Method')).toContain('getName'); + }); + + it('detects getUser factory function', () => { + expect(getNodesByLabel(result, 'Function')).toContain('getUser'); + }); + + it('resolves $u->save() in run() to User#save via cross-file return type', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find( + (c) => c.target === 'save' && c.source === 'run' && c.targetFilePath.includes('User'), + ); + expect(saveCall).toBeDefined(); + }); +}); + +describe('Phase 9 — Cross-File Call-Result Binding: Ruby', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(CROSS_FILE_FIXTURES, 'rb-cross-file'), () => {}); + }, 60000); + + it('detects User class with save and get_name methods', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Method')).toContain('save'); + expect(getNodesByLabel(result, 'Method')).toContain('get_name'); + }); + + it('detects get_user factory method', () => { + expect(getNodesByLabel(result, 'Method')).toContain('get_user'); + }); + + it('resolves user.save in process() to User#save via cross-file return type', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find( + (c) => c.target === 'save' && c.source === 'process' && c.targetFilePath.includes('models'), + ); + expect(saveCall).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Note: shadowed import tier gating is tested at the unit level +// (call-processor.test.ts "Phase 9 tier gating" tests) because the scenario +// requires invalid TypeScript (same name imported and locally defined). +// --------------------------------------------------------------------------- + +// --------------------------------------------------------------------------- +// Regression: consumer file processed before provider in sequential path +// a-consumer.ts (alphabetically first) imports getUser from b-provider.ts. +// Without the two-pass flush fix, the accumulator wouldn't have b-provider's +// bindings when a-consumer's verifyConstructorBindings runs. +// --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// Consumer-before-provider regression tests (sequential ordering fix) +// +// Each language fixture has a consumer file that sorts alphabetically before +// the provider file. In the sequential path, the consumer is processed first. +// The two-pass flush ensures the accumulator has provider bindings before +// verifyConstructorBindings runs for the consumer. +// --------------------------------------------------------------------------- + +describe('Consumer-Before-Provider: TypeScript', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(CROSS_FILE_FIXTURES, 'ts-consumer-before-provider'), + () => {}, + ); + }, 60000); + + it('detects User class and save method from provider', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Method')).toContain('save'); + }); + + it('resolves x.save() to User#save despite consumer sorted before provider', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find( + (c) => c.target === 'save' && c.source === 'main' && c.targetFilePath.includes('b-provider'), + ); + expect(saveCall).toBeDefined(); + }); +}); + +describe('Consumer-Before-Provider: JavaScript', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(CROSS_FILE_FIXTURES, 'js-consumer-before-provider'), + () => {}, + ); + }, 60000); + + it('detects User class and save method', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Method')).toContain('save'); + }); + + it('resolves u.save() in main() to User#save', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find( + (c) => c.target === 'save' && c.source === 'main' && c.targetFilePath.includes('b-provider'), + ); + expect(saveCall).toBeDefined(); + }); +}); + +describe('Consumer-Before-Provider: Python', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(CROSS_FILE_FIXTURES, 'py-consumer-before-provider'), + () => {}, + ); + }, 60000); + + it('detects User class and save function', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + // Python tree-sitter captures all function_definitions as Function, including methods + expect(getNodesByLabel(result, 'Function')).toContain('save'); + }); + + it('resolves u.save() in main() to User#save', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find( + (c) => c.target === 'save' && c.source === 'main' && c.targetFilePath.includes('b_provider'), + ); + expect(saveCall).toBeDefined(); + }); +}); + +describe('Consumer-Before-Provider: Java', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(CROSS_FILE_FIXTURES, 'java-consumer-before-provider'), + () => {}, + ); + }, 60000); + + it('detects User class and save method', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Method')).toContain('save'); + }); + + it('resolves user.save() in run() to User#save', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find((c) => c.target === 'save' && c.source === 'run'); + expect(saveCall).toBeDefined(); + }); +}); + +describe('Consumer-Before-Provider: Go', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(CROSS_FILE_FIXTURES, 'go-consumer-before-provider'), + () => {}, + ); + }, 60000); + + it('detects User struct and Save method', () => { + expect(getNodesByLabel(result, 'Struct')).toContain('User'); + expect(getNodesByLabel(result, 'Method')).toContain('Save'); + }); + + it('resolves user.Save() in main() to User#Save', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find((c) => c.target === 'Save' && c.source === 'main'); + expect(saveCall).toBeDefined(); + }); +}); + +describe('Consumer-Before-Provider: C++', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(CROSS_FILE_FIXTURES, 'cpp-consumer-before-provider'), + () => {}, + ); + }, 60000); + + it('detects User class and save method', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Method')).toContain('save'); + }); + + it('resolves user.save() in process() to User#save', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find((c) => c.target === 'save' && c.source === 'process'); + expect(saveCall).toBeDefined(); + }); +}); + +describe('Consumer-Before-Provider: C#', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(CROSS_FILE_FIXTURES, 'csharp-consumer-before-provider'), + () => {}, + ); + }, 60000); + + it('detects User class and Save method', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Method')).toContain('Save'); + }); + + it('resolves u.Save() in Run() to User#Save', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find((c) => c.target === 'Save' && c.source === 'Run'); + expect(saveCall).toBeDefined(); + }); +}); + +describe('Consumer-Before-Provider: Kotlin', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(CROSS_FILE_FIXTURES, 'kotlin-consumer-before-provider'), + () => {}, + ); + }, 60000); + + it('detects User class and save method', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Method')).toContain('save'); + }); + + it('resolves u.save() in run() to User#save', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find((c) => c.target === 'save' && c.source === 'run'); + expect(saveCall).toBeDefined(); + }); +}); + +describe('Consumer-Before-Provider: PHP', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(CROSS_FILE_FIXTURES, 'php-consumer-before-provider'), + () => {}, + ); + }, 60000); + + it('detects User class and save method', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Method')).toContain('save'); + }); + + it('resolves $u->save() in run() to User#save', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find((c) => c.target === 'save' && c.source === 'run'); + expect(saveCall).toBeDefined(); + }); +}); + +describe('Consumer-Before-Provider: Ruby', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(CROSS_FILE_FIXTURES, 'rb-consumer-before-provider'), + () => {}, + ); + }, 60000); + + it('detects User class and save method', () => { + expect(getNodesByLabel(result, 'Class')).toContain('User'); + expect(getNodesByLabel(result, 'Method')).toContain('save'); + }); + + it('resolves user.save in process() to User#save', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find((c) => c.target === 'save' && c.source === 'process'); + expect(saveCall).toBeDefined(); + }); +}); + +describe('Consumer-Before-Provider: Rust', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(CROSS_FILE_FIXTURES, 'rs-consumer-before-provider'), + () => {}, + ); + }, 60000); + + it('detects User struct and save function', () => { + expect(getNodesByLabel(result, 'Struct')).toContain('User'); + // Rust tree-sitter captures impl fns as Function nodes + expect(getNodesByLabel(result, 'Function')).toContain('save'); + }); + + it('resolves u.save() in process() to User#save', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find((c) => c.target === 'save' && c.source === 'process'); + expect(saveCall).toBeDefined(); + }); +}); diff --git a/gitnexus/test/unit/binding-accumulator.test.ts b/gitnexus/test/unit/binding-accumulator.test.ts index be394d987..95b1ddef8 100644 --- a/gitnexus/test/unit/binding-accumulator.test.ts +++ b/gitnexus/test/unit/binding-accumulator.test.ts @@ -511,6 +511,54 @@ describe('BindingAccumulator', () => { // state. Idempotent and orthogonal to finalize(). // ------------------------------------------------------------------------- + describe('fileScopeGet (O(1) point lookup)', () => { + it('returns the typeName for a known file-scope binding', () => { + const acc = new BindingAccumulator(); + acc.appendFile('src/api.ts', [ + { scope: '', varName: 'getUser', typeName: 'User' }, + { scope: '', varName: 'getPost', typeName: 'Post' }, + ]); + expect(acc.fileScopeGet('src/api.ts', 'getUser')).toBe('User'); + expect(acc.fileScopeGet('src/api.ts', 'getPost')).toBe('Post'); + }); + + it('returns undefined for an unknown file', () => { + const acc = new BindingAccumulator(); + expect(acc.fileScopeGet('nonexistent.ts', 'x')).toBeUndefined(); + }); + + it('returns undefined for an unknown name in a known file', () => { + const acc = new BindingAccumulator(); + acc.appendFile('src/api.ts', [{ scope: '', varName: 'getUser', typeName: 'User' }]); + expect(acc.fileScopeGet('src/api.ts', 'missing')).toBeUndefined(); + }); + + it('ignores function-scope entries', () => { + const acc = new BindingAccumulator(); + acc.appendFile('src/service.ts', [ + { scope: 'handler@10', varName: 'localDb', typeName: 'Database' }, + { scope: '', varName: 'config', typeName: 'Config' }, + ]); + // Only file-scope entries are indexed by fileScopeGet. + expect(acc.fileScopeGet('src/service.ts', 'config')).toBe('Config'); + expect(acc.fileScopeGet('src/service.ts', 'localDb')).toBeUndefined(); + }); + + it('returns undefined after dispose', () => { + const acc = new BindingAccumulator(); + acc.appendFile('src/api.ts', [{ scope: '', varName: 'getUser', typeName: 'User' }]); + acc.dispose(); + expect(acc.fileScopeGet('src/api.ts', 'getUser')).toBeUndefined(); + }); + + it('last-write-wins for duplicate varNames in the same file', () => { + const acc = new BindingAccumulator(); + acc.appendFile('src/api.ts', [{ scope: '', varName: 'getUser', typeName: 'OldType' }]); + acc.appendFile('src/api.ts', [{ scope: '', varName: 'getUser', typeName: 'NewType' }]); + expect(acc.fileScopeGet('src/api.ts', 'getUser')).toBe('NewType'); + }); + }); + describe('dispose', () => { it('empties all read methods after dispose', () => { const acc = new BindingAccumulator(); diff --git a/gitnexus/test/unit/call-processor.test.ts b/gitnexus/test/unit/call-processor.test.ts index b0c2b04eb..a0754082d 100644 --- a/gitnexus/test/unit/call-processor.test.ts +++ b/gitnexus/test/unit/call-processor.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { processCalls, processCallsFromExtracted, + processAssignmentsFromExtracted, seedCrossFileReceiverTypes, extractConsumerAccessedKeys, processNextjsFetchRoutes, @@ -14,7 +15,9 @@ import { type ResolutionContext, } from '../../src/core/ingestion/resolution-context.js'; import { createKnowledgeGraph } from '../../src/core/graph/graph.js'; +import { BindingAccumulator } from '../../src/core/ingestion/binding-accumulator.js'; import type { + ExtractedAssignment, ExtractedCall, ExtractedFetchCall, ExtractedHeritage, @@ -574,6 +577,543 @@ describe('processCallsFromExtracted', () => { 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.symbols.add('src/api.ts', 'getUser', 'Function:src/api.ts:getUser', 'Function', { + // No returnType provided — simulates a structure-processor gap + }); + ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', { + 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.symbols.add('src/api.ts', 'getUser', 'Function:src/api.ts:getUser', 'Function', { + returnType: 'User', + }); + ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', { + 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.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', { + 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.symbols.add('src/other.ts', 'OtherClass', 'Class:src/other.ts:OtherClass', 'Class'); + ctx.symbols.add('src/other.ts', 'save', 'Method:src/other.ts:save', 'Method', { + 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 type from accumulator', async () => { + // Accumulator stores raw type with Promise wrapper — extractReturnTypeName should unwrap it. + ctx.symbols.add('src/api.ts', 'fetchUser', 'Function:src/api.ts:fetchUser', 'Function'); + ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', { + 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 as type — should be unwrapped + acc.appendFile('src/api.ts', [{ scope: '', varName: 'fetchUser', typeName: 'Promise' }]); + + 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.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.symbols.add('src/api.ts', 'getUser', 'Function:src/api.ts:getUser', 'Function'); + ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', { + 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.symbols.add('src/consumer.ts', 'getUser', 'Function:src/consumer.ts:getUser', 'Function'); + ctx.symbols.add('src/api.ts', 'getUser', 'Function:src/api.ts:getUser', 'Function'); + // Place User and save in non-imported files so import-scoped member-call resolution + // can't resolve save without a receiver type. + ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', { + ownerId: 'Class:src/models.ts:User', + }); + ctx.symbols.add('src/other.ts', 'OtherClass', 'Class:src/other.ts:OtherClass', 'Class'); + ctx.symbols.add('src/other.ts', 'save', 'Method:src/other.ts:save', 'Method', { + 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.symbols.add('src/api-v1.ts', 'getUser', 'Function:src/api-v1.ts:getUser', 'Function'); + ctx.symbols.add('src/api-v2.ts', 'getUser', 'Function:src/api-v2.ts:getUser', 'Function'); + ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', { + ownerId: 'Class:src/models.ts:User', + }); + ctx.symbols.add('src/other.ts', 'OtherClass', 'Class:src/other.ts:OtherClass', 'Class'); + ctx.symbols.add('src/other.ts', 'save', 'Method:src/other.ts:save', 'Method', { + ownerId: 'Class:src/other.ts:OtherClass', + }); + // BadType has no methods — if the accumulator wrongly types x as BadType, + // the receiver type is set but save won't resolve at all. + ctx.symbols.add('src/bad.ts', 'BadType', 'Class:src/bad.ts:BadType', 'Class'); + ctx.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.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', { + 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.symbols.add('src/consumer.ts', 'getUser', 'Function:src/consumer.ts:getUser', 'Function'); + ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', { + ownerId: 'Class:src/models.ts:User', + }); + // Add a second 'save' so fuzzy lookup is ambiguous without receiver type + ctx.symbols.add('src/other.ts', 'OtherClass', 'Class:src/other.ts:OtherClass', 'Class'); + ctx.symbols.add('src/other.ts', 'save', 'Method:src/other.ts:save', 'Method', { + 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 () => { @@ -1954,3 +2494,59 @@ describe('processCalls — D0 MRO fast path (SM-10)', () => { expect(userSave).toBeUndefined(); }); }); + +// ---- processAssignmentsFromExtracted: Phase 9 accumulator fallback ---- + +describe('processAssignmentsFromExtracted', () => { + let graph: ReturnType; + 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.symbols.add('src/api.ts', 'getUser', 'Function:src/api.ts:getUser', 'Function'); + ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class'); + ctx.symbols.add('src/models.ts', 'address', 'Property:src/models.ts:address', 'Property', { + ownerId: 'Class:src/models.ts:User', + }); + ctx.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'); + }); +});