mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
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:
parent
6147579e54
commit
ab956f113c
39 changed files with 1759 additions and 68 deletions
|
|
@ -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
102
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 `<!-- 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
|
||||
|
|
|
|||
1
gitnexus/package-lock.json
generated
1
gitnexus/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
6
gitnexus/test/fixtures/cross-file-binding/cpp-consumer-before-provider/a_consumer/main.cpp
vendored
Normal file
6
gitnexus/test/fixtures/cross-file-binding/cpp-consumer-before-provider/a_consumer/main.cpp
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
#include "../b_provider/provider.h"
|
||||
|
||||
void process() {
|
||||
User user = get_user();
|
||||
user.save();
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
#include "provider.h"
|
||||
|
||||
void User::save() {}
|
||||
|
||||
User get_user() {
|
||||
return User();
|
||||
}
|
||||
8
gitnexus/test/fixtures/cross-file-binding/cpp-consumer-before-provider/b_provider/provider.h
vendored
Normal file
8
gitnexus/test/fixtures/cross-file-binding/cpp-consumer-before-provider/b_provider/provider.h
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
#pragma once
|
||||
|
||||
class User {
|
||||
public:
|
||||
void save();
|
||||
};
|
||||
|
||||
User get_user();
|
||||
13
gitnexus/test/fixtures/cross-file-binding/csharp-consumer-before-provider/AConsumer/Program.cs
vendored
Normal file
13
gitnexus/test/fixtures/cross-file-binding/csharp-consumer-before-provider/AConsumer/Program.cs
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
using static ConsumerBeforeProvider.BProvider.UserFactory;
|
||||
|
||||
namespace ConsumerBeforeProvider.AConsumer
|
||||
{
|
||||
public class Program
|
||||
{
|
||||
public void Run()
|
||||
{
|
||||
var u = GetUser();
|
||||
u.Save();
|
||||
}
|
||||
}
|
||||
}
|
||||
7
gitnexus/test/fixtures/cross-file-binding/csharp-consumer-before-provider/BProvider/User.cs
vendored
Normal file
7
gitnexus/test/fixtures/cross-file-binding/csharp-consumer-before-provider/BProvider/User.cs
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
namespace ConsumerBeforeProvider.BProvider
|
||||
{
|
||||
public class User
|
||||
{
|
||||
public void Save() {}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
namespace ConsumerBeforeProvider.BProvider
|
||||
{
|
||||
public static class UserFactory
|
||||
{
|
||||
public static User GetUser() { return new User(); }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<RootNamespace>ConsumerBeforeProvider</RootNamespace>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
8
gitnexus/test/fixtures/cross-file-binding/go-consumer-before-provider/app/main.go
vendored
Normal file
8
gitnexus/test/fixtures/cross-file-binding/go-consumer-before-provider/app/main.go
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
package main
|
||||
|
||||
import "go-consumer-before-provider/models"
|
||||
|
||||
func main() {
|
||||
user := models.GetUser()
|
||||
user.Save()
|
||||
}
|
||||
3
gitnexus/test/fixtures/cross-file-binding/go-consumer-before-provider/go.mod
vendored
Normal file
3
gitnexus/test/fixtures/cross-file-binding/go-consumer-before-provider/go.mod
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
module go-consumer-before-provider
|
||||
|
||||
go 1.21
|
||||
9
gitnexus/test/fixtures/cross-file-binding/go-consumer-before-provider/models/user.go
vendored
Normal file
9
gitnexus/test/fixtures/cross-file-binding/go-consumer-before-provider/models/user.go
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
package models
|
||||
|
||||
type User struct{}
|
||||
|
||||
func (u User) Save() {}
|
||||
|
||||
func GetUser() User {
|
||||
return User{}
|
||||
}
|
||||
10
gitnexus/test/fixtures/cross-file-binding/java-consumer-before-provider/app/AConsumer.java
vendored
Normal file
10
gitnexus/test/fixtures/cross-file-binding/java-consumer-before-provider/app/AConsumer.java
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
package app;
|
||||
|
||||
import static models.BProvider.getUser;
|
||||
|
||||
public class AConsumer {
|
||||
public void run() {
|
||||
var u = getUser();
|
||||
u.save();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package models;
|
||||
|
||||
public class BProvider {
|
||||
public static User getUser() {
|
||||
return new User();
|
||||
}
|
||||
}
|
||||
5
gitnexus/test/fixtures/cross-file-binding/java-consumer-before-provider/models/User.java
vendored
Normal file
5
gitnexus/test/fixtures/cross-file-binding/java-consumer-before-provider/models/User.java
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
package models;
|
||||
|
||||
public class User {
|
||||
public void save() {}
|
||||
}
|
||||
10
gitnexus/test/fixtures/cross-file-binding/js-consumer-before-provider/a-consumer.js
vendored
Normal file
10
gitnexus/test/fixtures/cross-file-binding/js-consumer-before-provider/a-consumer.js
vendored
Normal 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();
|
||||
}
|
||||
7
gitnexus/test/fixtures/cross-file-binding/js-consumer-before-provider/b-provider.js
vendored
Normal file
7
gitnexus/test/fixtures/cross-file-binding/js-consumer-before-provider/b-provider.js
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
export class User {
|
||||
save() {}
|
||||
}
|
||||
|
||||
export function getUser() {
|
||||
return new User();
|
||||
}
|
||||
10
gitnexus/test/fixtures/cross-file-binding/kotlin-consumer-before-provider/app/AConsumer.kt
vendored
Normal file
10
gitnexus/test/fixtures/cross-file-binding/kotlin-consumer-before-provider/app/AConsumer.kt
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
package app
|
||||
|
||||
import models.getUser
|
||||
|
||||
class AConsumer {
|
||||
fun run() {
|
||||
val u = getUser()
|
||||
u.save()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package models
|
||||
|
||||
class User {
|
||||
fun save() {}
|
||||
}
|
||||
|
||||
fun getUser(): User = User()
|
||||
12
gitnexus/test/fixtures/cross-file-binding/php-consumer-before-provider/app/AConsumer.php
vendored
Normal file
12
gitnexus/test/fixtures/cross-file-binding/php-consumer-before-provider/app/AConsumer.php
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<?php
|
||||
|
||||
namespace App;
|
||||
|
||||
use function App\Models\getUser;
|
||||
|
||||
class AConsumer {
|
||||
public function run(): void {
|
||||
$u = getUser();
|
||||
$u->save();
|
||||
}
|
||||
}
|
||||
11
gitnexus/test/fixtures/cross-file-binding/php-consumer-before-provider/app/BProvider.php
vendored
Normal file
11
gitnexus/test/fixtures/cross-file-binding/php-consumer-before-provider/app/BProvider.php
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
class User {
|
||||
public function save(): void {}
|
||||
}
|
||||
|
||||
function getUser(): User {
|
||||
return new User();
|
||||
}
|
||||
7
gitnexus/test/fixtures/cross-file-binding/php-consumer-before-provider/composer.json
vendored
Normal file
7
gitnexus/test/fixtures/cross-file-binding/php-consumer-before-provider/composer.json
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"App\\": "app/"
|
||||
}
|
||||
}
|
||||
}
|
||||
5
gitnexus/test/fixtures/cross-file-binding/py-consumer-before-provider/src/a_consumer.py
vendored
Normal file
5
gitnexus/test/fixtures/cross-file-binding/py-consumer-before-provider/src/a_consumer.py
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
from b_provider import get_user
|
||||
|
||||
def main():
|
||||
u = get_user()
|
||||
u.save()
|
||||
6
gitnexus/test/fixtures/cross-file-binding/py-consumer-before-provider/src/b_provider.py
vendored
Normal file
6
gitnexus/test/fixtures/cross-file-binding/py-consumer-before-provider/src/b_provider.py
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
class User:
|
||||
def save(self):
|
||||
pass
|
||||
|
||||
def get_user() -> User:
|
||||
return User()
|
||||
6
gitnexus/test/fixtures/cross-file-binding/rb-consumer-before-provider/a_consumer.rb
vendored
Normal file
6
gitnexus/test/fixtures/cross-file-binding/rb-consumer-before-provider/a_consumer.rb
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
require_relative 'models/b_user_factory'
|
||||
|
||||
def process
|
||||
user = UserFactory.get_user
|
||||
user.save
|
||||
end
|
||||
4
gitnexus/test/fixtures/cross-file-binding/rb-consumer-before-provider/models/b_user.rb
vendored
Normal file
4
gitnexus/test/fixtures/cross-file-binding/rb-consumer-before-provider/models/b_user.rb
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
class User
|
||||
def save
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
require_relative 'b_user'
|
||||
|
||||
class UserFactory
|
||||
def self.get_user
|
||||
User.new
|
||||
end
|
||||
end
|
||||
6
gitnexus/test/fixtures/cross-file-binding/rs-consumer-before-provider/src/a_consumer.rs
vendored
Normal file
6
gitnexus/test/fixtures/cross-file-binding/rs-consumer-before-provider/src/a_consumer.rs
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
use crate::b_provider::get_user;
|
||||
|
||||
pub fn process() {
|
||||
let u = get_user();
|
||||
u.save();
|
||||
}
|
||||
9
gitnexus/test/fixtures/cross-file-binding/rs-consumer-before-provider/src/b_provider.rs
vendored
Normal file
9
gitnexus/test/fixtures/cross-file-binding/rs-consumer-before-provider/src/b_provider.rs
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
pub struct User;
|
||||
|
||||
impl User {
|
||||
pub fn save(&self) {}
|
||||
}
|
||||
|
||||
pub fn get_user() -> User {
|
||||
User
|
||||
}
|
||||
2
gitnexus/test/fixtures/cross-file-binding/rs-consumer-before-provider/src/main.rs
vendored
Normal file
2
gitnexus/test/fixtures/cross-file-binding/rs-consumer-before-provider/src/main.rs
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
mod a_consumer;
|
||||
mod b_provider;
|
||||
10
gitnexus/test/fixtures/cross-file-binding/ts-consumer-before-provider/src/a-consumer.ts
vendored
Normal file
10
gitnexus/test/fixtures/cross-file-binding/ts-consumer-before-provider/src/a-consumer.ts
vendored
Normal 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();
|
||||
}
|
||||
7
gitnexus/test/fixtures/cross-file-binding/ts-consumer-before-provider/src/b-provider.ts
vendored
Normal file
7
gitnexus/test/fixtures/cross-file-binding/ts-consumer-before-provider/src/b-provider.ts
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
export class User {
|
||||
save(): void {}
|
||||
}
|
||||
|
||||
export function getUser(): User {
|
||||
return new User();
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue