feat(SM-15): Wire BindingAccumulator into processCallsFromExtracted for cross-file return type propagation (#763)

* 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<string, [string,string][]> to
  Map<string, Map<string,string>>. 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 <gergomagyar@icloud.com>
This commit is contained in:
Copilot 2026-04-10 10:29:31 +01:00 committed by GitHub
parent 6147579e54
commit ab956f113c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 1759 additions and 68 deletions

View file

@ -63,7 +63,7 @@ Generic “core standards” playbooks are often long and stack-specific. For th
<!-- gitnexus:start -->
# GitNexus — Code Intelligence
This project is indexed by GitNexus as **GitNexus** (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.

102
CLAUDE.md
View file

@ -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 `<!-- gitnexus:start -->``<!-- gitnexus:end -->` 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:start -->
# GitNexus — Code Intelligence
This project is indexed by GitNexus as **GitNexus** (3883 symbols, 9861 relationships, 225 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> 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: "<error or symptom>"})` — find execution flows related to the issue
2. `gitnexus_context({name: "<suspect function>"})` — 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` |
<!-- gitnexus:end -->` block in **[AGENTS.md](AGENTS.md)** — load that section when working with MCP tools or the graph index.
<!-- gitnexus:start -->
# GitNexus — Code Intelligence

View file

@ -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",

View file

@ -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<filePath, Map<varName, typeName>> 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<string, BindingEntry[]>();
private readonly _fileScopeByFile = new Map<string, [string, string][]>();
private readonly _fileScopeByFile = new Map<string, Map<string, string>>();
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. */

View file

@ -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<string, string> },
filePath: string,
@ -512,6 +523,7 @@ const verifyConstructorBindings = (
filePath: string,
ctx: ResolutionContext,
graph?: KnowledgeGraph,
bindingAccumulator?: BindingAccumulator,
): Map<string, string> => {
const verified = new Map<string, string>();
@ -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<string, number>() : 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<typeof getProvider>;
tree: ReturnType<typeof parser.parse>;
matches: ReturnType<Parser.Query['matches']>;
parentMap: ReadonlyMap<string, readonly string[]>;
typeEnv: ReturnType<typeof buildTypeEnv>;
}
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<string, string[]>();
for (const match of matches) {
@ -707,7 +785,6 @@ export const processCalls = async (
}
const parentMap: ReadonlyMap<string, readonly string[]> = 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<string, string>();
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<string, ReceiverTypeIndex>();
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<string, ReceiverTypeIndex>();
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));
}

View file

@ -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

View file

@ -0,0 +1,6 @@
#include "../b_provider/provider.h"
void process() {
User user = get_user();
user.save();
}

View file

@ -0,0 +1,7 @@
#include "provider.h"
void User::save() {}
User get_user() {
return User();
}

View file

@ -0,0 +1,8 @@
#pragma once
class User {
public:
void save();
};
User get_user();

View file

@ -0,0 +1,13 @@
using static ConsumerBeforeProvider.BProvider.UserFactory;
namespace ConsumerBeforeProvider.AConsumer
{
public class Program
{
public void Run()
{
var u = GetUser();
u.Save();
}
}
}

View file

@ -0,0 +1,7 @@
namespace ConsumerBeforeProvider.BProvider
{
public class User
{
public void Save() {}
}
}

View file

@ -0,0 +1,7 @@
namespace ConsumerBeforeProvider.BProvider
{
public static class UserFactory
{
public static User GetUser() { return new User(); }
}
}

View file

@ -0,0 +1,5 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>ConsumerBeforeProvider</RootNamespace>
</PropertyGroup>
</Project>

View file

@ -0,0 +1,8 @@
package main
import "go-consumer-before-provider/models"
func main() {
user := models.GetUser()
user.Save()
}

View file

@ -0,0 +1,3 @@
module go-consumer-before-provider
go 1.21

View file

@ -0,0 +1,9 @@
package models
type User struct{}
func (u User) Save() {}
func GetUser() User {
return User{}
}

View file

@ -0,0 +1,10 @@
package app;
import static models.BProvider.getUser;
public class AConsumer {
public void run() {
var u = getUser();
u.save();
}
}

View file

@ -0,0 +1,7 @@
package models;
public class BProvider {
public static User getUser() {
return new User();
}
}

View file

@ -0,0 +1,5 @@
package models;
public class User {
public void save() {}
}

View file

@ -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();
}

View file

@ -0,0 +1,7 @@
export class User {
save() {}
}
export function getUser() {
return new User();
}

View file

@ -0,0 +1,10 @@
package app
import models.getUser
class AConsumer {
fun run() {
val u = getUser()
u.save()
}
}

View file

@ -0,0 +1,7 @@
package models
class User {
fun save() {}
}
fun getUser(): User = User()

View file

@ -0,0 +1,12 @@
<?php
namespace App;
use function App\Models\getUser;
class AConsumer {
public function run(): void {
$u = getUser();
$u->save();
}
}

View file

@ -0,0 +1,11 @@
<?php
namespace App\Models;
class User {
public function save(): void {}
}
function getUser(): User {
return new User();
}

View file

@ -0,0 +1,7 @@
{
"autoload": {
"psr-4": {
"App\\": "app/"
}
}
}

View file

@ -0,0 +1,5 @@
from b_provider import get_user
def main():
u = get_user()
u.save()

View file

@ -0,0 +1,6 @@
class User:
def save(self):
pass
def get_user() -> User:
return User()

View file

@ -0,0 +1,6 @@
require_relative 'models/b_user_factory'
def process
user = UserFactory.get_user
user.save
end

View file

@ -0,0 +1,4 @@
class User
def save
end
end

View file

@ -0,0 +1,7 @@
require_relative 'b_user'
class UserFactory
def self.get_user
User.new
end
end

View file

@ -0,0 +1,6 @@
use crate::b_provider::get_user;
pub fn process() {
let u = get_user();
u.save();
}

View file

@ -0,0 +1,9 @@
pub struct User;
impl User {
pub fn save(&self) {}
}
pub fn get_user() -> User {
User
}

View file

@ -0,0 +1,2 @@
mod a_consumer;
mod b_provider;

View file

@ -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();
}

View file

@ -0,0 +1,7 @@
export class User {
save(): void {}
}
export function getUser(): User {
return new User();
}

View file

@ -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();
});
});

View file

@ -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();

View file

@ -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<User> type from accumulator', async () => {
// Accumulator stores raw type with Promise wrapper — extractReturnTypeName should unwrap it.
ctx.symbols.add('src/api.ts', 'fetchUser', 'Function:src/api.ts:fetchUser', 'Function');
ctx.symbols.add('src/models.ts', 'User', 'Class:src/models.ts:User', 'Class');
ctx.symbols.add('src/models.ts', 'save', 'Method:src/models.ts:save', 'Method', {
ownerId: 'Class:src/models.ts:User',
});
ctx.importMap.set('src/consumer.ts', new Set(['src/api.ts', 'src/models.ts']));
ctx.namedImportMap.set(
'src/consumer.ts',
new Map([['fetchUser', { sourcePath: 'src/api.ts', exportedName: 'fetchUser' }]]),
);
const acc = new BindingAccumulator();
// Accumulator stores raw Promise<User> as type — should be unwrapped
acc.appendFile('src/api.ts', [{ scope: '', varName: 'fetchUser', typeName: 'Promise<User>' }]);
const constructorBindings: FileConstructorBindings[] = [
{
filePath: 'src/consumer.ts',
bindings: [{ scope: 'main@0', varName: 'x', calleeName: 'fetchUser' }],
},
];
const calls: ExtractedCall[] = [
{
filePath: 'src/consumer.ts',
calledName: 'save',
sourceId: 'Function:src/consumer.ts:main',
receiverName: 'x',
callForm: 'member',
},
];
await processCallsFromExtracted(
graph,
calls,
ctx,
undefined,
constructorBindings,
undefined,
acc,
);
const rels = graph.relationships.filter((r) => r.type === 'CALLS');
expect(rels).toHaveLength(1);
expect(rels[0].targetId).toBe('Method:src/models.ts:save');
});
it('Phase 9: BindingAccumulator fallback — skips primitive types from accumulator', async () => {
// Accumulator stores a primitive type — should not create a CALLS edge.
ctx.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<typeof createKnowledgeGraph>;
let ctx: ResolutionContext;
beforeEach(() => {
graph = createKnowledgeGraph();
ctx = createResolutionContext();
});
it('Phase 9: accumulator fallback resolves receiver type for ACCESSES write edge', () => {
// getUser is in the SymbolTable WITHOUT a returnType. The accumulator
// carries getUser → User from the source file. The constructor binding
// binds x = getUser(). The assignment x.address = value should produce
// an ACCESSES write edge to User.address via the accumulator fallback.
ctx.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');
});
});