GitNexus/gitnexus/test/integration/resolvers/rust.test.ts
Gergő Magyar 95f87fc12a
perf(ingestion): Linux-kernel-scale analysis — worker-pool parse + finalize O(n²) + scope-resolution memory wall (#1983) (#2038)
* fix(ingestion): reduce parse-phase memory for huge repos (#1983)

Stop retaining full parse-cache chunks in RAM alongside the merged graph,
slim on-disk shards, defer worker ParsedFile emission for scope-resolver
languages, and add GITNEXUS_DEBUG_HEAP probes for OOM diagnosis.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(ingestion): address #2038 tri-review findings (parse-phase memory)

Resolves the confirmed review findings on PR #2038:

- P1: thread exportedTypeMap through the sequential parse path
  (processParsingSequential) so a no-worker run over a partially-warm
  cache no longer silently drops the sequential-miss files' exported
  types. Cache hits made exportedTypeMap.size > 0, suppressing the
  end-of-loop buildExportedTypeMapFromGraph rebuild, but the sequential
  path never populated the map. Regression test added (fails on the
  pre-fix tree, passes after) plus a fully-sequential differential oracle.
- P2: saveParseCache builds its on-disk index from hashes actually
  written/copied (writtenKeys), never a usedKeys hash whose shard write
  or copy was skipped — no more phantom index entries.
- P2: add a unit test asserting SCOPE_RESOLUTION_LANGUAGES stays in sync
  with SCOPE_RESOLVERS (asymmetric drift would lose a language's ParsedFile).
- Backfill cache coverage: loadParseCacheChunk missing/corrupt -> undefined,
  pruneCache onDiskKeys branch, slim preserves nodes, saveParseCache
  copy-evicted-shard round-trip.
- Cleanups: single-source heap-probe gating via isDebugHeapEnabled();
  hoist the per-chunk mkdir in persistParseCacheChunk behind a
  process-scoped Set; gate COBOL's unused worker-side ParsedFile
  extraction (graph nodes still come from cobolPhase) while keeping
  fileCount/progress unconditional.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(ingestion): remove dead worker-side ParsedFile extraction

After #2038 gated worker `ParsedFile` emission behind `!isScopeResolutionLanguage(language)`, and with all 16 SupportedLanguages registered in SCOPE_RESOLVERS, that gate was structurally always true — the worker already produced no ParsedFiles and scope-resolution re-extracts each file from source on the main thread (run.ts). Remove the now-dead machinery:

- Drop both worker `extractParsedFile` call-sites (tree-sitter processFileGroup + the standalone-provider branch) and the `result.parsedFiles.push`. The standalone branch keeps fileCount/onFileProcessed per file. `result.parsedFiles` stays declared but empty (field removal deferred).
- Remove the now-orphaned `scopeSourceKind` var + `ScopeCaptureSourceKind`/`extractParsedFile`/`isScopeResolutionLanguage` imports.
- Delete the consumerless `migrated-languages.ts` (isScopeResolutionLanguage + SCOPE_RESOLUTION_LANGUAGES) and its drift-guard test — parse-worker was their only importer. Also improves AGENTS.md "shared ingestion code must not name languages" compliance.

`extractParsedFile` and the scope-extractor-bridge stay (scope-resolution/run.ts + Vue resolver use them). Behavior-preserving: worker-sequential-parity passes before and after; tsc/eslint clean; no baseline/golden drift.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(ingestion): worker-pool-only parsing; remove sequential parser (#1983)

Completes the #1983 huge-repo parse-OOM effort by making the worker pool
GitNexus's sole parse path.

Parallel serialization (the perf core): workers serialize their ParsedFiles to
a disk store in parallel and stream them back to scope-resolution, so the main
thread no longer re-parses every file (the tree-sitter native-memory leak that
caused the OOM). Adds chunk merge-pipelining + work-proportional chunk sizing so
the pool stays saturated.

Remove the sequential parser: `--workers 0`, `GITNEXUS_WORKER_POOL_SIZE=0`, and
`skipWorkers` now hard-error (no silent degrade — #1741); the small-repo
threshold no longer selects an in-process path; pool creation stays lazy /
cache-miss-gated so warm all-hit runs never spawn workers.

Worker-path parity fixes — removing sequential surfaced two pre-existing gaps
that tiny-fixture tests had masked by running below the worker threshold, both
fixed by carrying per-file metadata as DATA across the worker boundary (never
re-parsing on the main thread, preserving the OOM fix):
  - C++: templateConstraints wired into worker node identity (SFINAE overload
    disambiguation) + ADL / inline-namespace capture side-channel serialized
    onto the ParsedFile.
  - Kotlin: companion-scope side-channel serialized the same way (companion /
    static dispatch).

Validation: tsc + build clean; full suite green (10,190 pass — the only
deterministic failures were the now-fixed C++/Kotlin worker-path gaps; the 2
remaining full-run failures are pre-existing load flakiness, green in
isolation); cpp-pipeline benchmark stays linear on a 1-worker pool.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(ingestion): wire C static-linkage side-channel + ADL O(1) collect + tri-review cleanups (#1983)

Follow-up to the worker-pool-only refactor, from a tri-review of the parse path.

- C static-linkage side-channel (P1): cProvider had no collect/applyCaptureSideChannel,
  so on the now-sole worker path C `static` file-local marks were lost across the worker
  boundary -> false cross-file CALLS edges + over-broad #include wildcard visibility on
  every C analysis (the Linux kernel is C). Mirror the C++/Kotlin wiring: serialize
  `staticNames` per file onto ParsedFile.captureSideChannel and restore it on the main
  thread (no re-parse). + a worker-path regression test (the existing c-static-isolation
  fixture passed vacuously — its collision resolves via #include before the global
  free-call fallback ever consults static-linkage).

- captureSideChannel `kind` discriminant: add `kind:'cpp'`/`kind:'c'` tags + guards
  (Kotlin already had one) now that C/C++/Kotlin share the single generic field.

- Perf: collectCppAdlSideChannel scanned the whole argInfoBySite/noAdlSites maps per file
  (O(F^2) per sub-batch, ~100M parseSiteKey calls at kernel scale). Add per-filePath
  lockstep indexes -> O(1) collect; serialized snapshot byte-identical.

- Cleanups: inline the one-line processParsingWithWorkers wrapper into processParsing;
  drop the always-empty WorkerExtractedData.calls/assignments/constructorBindings fields;
  remove the voided astCache param from processParsing; refresh stale "sequential
  fallback" JSDoc.

Validation: tsc + build clean; cpp 297/297, c 8/8 (incl. the new worker-path
static-linkage guard), typescript + parsedfile-store green; cpp ADL benchmark stays linear.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* perf(scope-resolution): index C/C++ #include resolution in finalize (O(n²)→O(n))

Kernel-scale C/C++ analysis ground in finalizeScopeModel because three
per-#include operations each did a full O(F) scan with no index — the
finalize O(n²) that surfaced once the #1983 parse-phase OOM was fixed:

- expand{C,Cpp}WildcardNames: parsedFiles.find() per wildcard edge → O(R·F)
- resolveImportTarget: new Set(allFilePaths) rebuilt per #include
- resolveCImportTarget: suffix-match scanned all workspace paths

Each is replaced with a WeakMap-per-pass index keyed on the stable
parsedFiles/allFilePaths references that scope-resolution run.ts passes
once per pass:

- Map<ScopeId,ParsedFile> for wildcard expansion (c/static-linkage.ts +
  cpp/file-local-linkage.ts)
- memoized augmented header set (c/scope-resolver.ts + cpp/scope-resolver.ts)
- basename-bucketed suffix index in resolveCImportTarget (c/import-target.ts),
  shared by C and C++ since resolveCppImportTarget delegates to it

Collapses the C/C++ finalize from O(R·F) to O(R+F). Pure-perf, byte-identical
edge output: 962 targeted tests green (490 C + 472 C/C++ scope-resolution);
the basename index preserves the exact endsWith('/'+target) match and the
fewest-path-components-then-lexicographic tie-break.

The kernel's ~25-30k .h headers are classified C++, so both providers must
be fixed. Proven on the Linux kernel: the C finalize completed
(sr-post-finalize lang=c → sr-end lang=c), which the pre-fix run never
reached in 16+ min of grinding.

Build-independent follow-ups (separate from this finalize fix), documented
for later: emitFreeCallFallback same-name buckets (emit phase),
buildGraphNodeLookup + precount global setup, the ParsedFile store-load,
the dart/go/ruby expand-wildcards .find siblings, and the ~26GB
scope-resolution memory floor (full kernel completion needs >~40GB RAM).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(bench): regenerate C scope-capture baseline for the #1983 c-static-linkage-worker fixture

bench/scope-capture/measure.mjs fingerprints emitCScopeCaptures over the
lang-resolution/c-* fixture corpus. The #1983 PR added the
c-static-linkage-worker fixture (caller.c/lib.c/lib.h/local.c — the
worker-path static-linkage side-channel test) but did not regenerate the C
baseline, so `--check` has been red on this branch (main, lacking the
fixture, still matches 0de009b).

Pure fixture-corpus drift — no c/captures.ts or query change branch-vs-main,
existing fixtures' captures byte-identical (c-captures.test.ts 45/45),
scaling stays linear (~0.97). Regenerated: 0de009b -> 39f3a83. Bench now
PASS (14 languages). Unrelated to the finalize O(n²) fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* perf(scope-resolution): lower kernel-scale resident memory floor + setup cost

Reduce the scope-resolution resident-memory floor and setup throughput on
huge repos (Linux kernel), the wall that remains after #1983 (parse OOM) and
the finalize O(n^2) fix (b71c77b8). Five units; all preserve byte-identical
edge output (C fixture 177n/255e + c/cpp/cross-file/php/static-linkage suites
green, 619 tests).

U1 (src/cli/analyze.ts): RAM-aware auto heap-cap. Replace the hardcoded
16384MB cap with computeHeapCapMb = max(16384, floor(0.75*effectiveRAM)),
where effectiveRAM = min(os.totalmem(), process.constrainedMemory()) with the
unconstrained-sentinel guard. Add --max-semi-space-size=128 on the respawn.
A user-supplied NODE_OPTIONS heap still wins (no re-exec). Verified: 23973MB
on a 31964MB box, 16384 floor on small machines, cgroup-aware, sentinel safe.

U2 (src/storage/parsedfile-store.ts, .../pipeline/phase.ts): export forceGc()
and call it at the per-language eviction boundary, so a finished language's
ParsedFiles are reclaimed before the next language's store-load instead of
collected lazily under the next pass's allocation pressure (which at cap>=RAM
degrades into swap-thrash). Measured on a real drivers/net/ethernet run:
C 2113->894MB and C++ 1754->1057MB reclaimed at the boundary (no fragmentation
defeat). Answers the plan's Open Question 1.

U3 (src/storage/parsedfile-store.ts): intern def objects by nodeId in the load
reviver so a SymbolDefinition's three serialized copies (localDefs /
scope.ownedDefs / scope.bindings[].def) collapse to one shared object on load.
Per-shard def pool (a def's copies are shard-local). Measured ~42% off the
def-object retained heap (3->1; 1.8M->600k distinct objects on 600k defs).

U4 (.../passes/free-call-fallback.ts): memoize pickUniqueGlobalCallable's
post-filter candidate list per (name, callerFilePath), only when no per-caller
visibility filter applies (the list is then a pure function of name+file), so
repeated free calls of one name from a file reuse the same-name-bucket scan
instead of re-walking a potentially huge bucket per site. The cached array is
read-only-consumed by the .filter()-based arity/overload narrowers. Exported
pickUniqueGlobalCallable + buildGlobalCallableIndex and added an equivalence
test (memoized == un-memoized reference for every (name, file, arity),
including warm-cache repeats and cross-file file-local exclusion).

U5 (.../pipeline/phase.ts): replace the O(L*F) per-language precount + repeated
scannedFiles.filter() with a single O(F) partition-by-language pass; bracket
buildGraphNodeLookup with scope-setup-nodeLookup heap probes so the long setup
is no longer silent.

Plan: docs/plans/2026-06-06-001-perf-kernel-scope-resolution-memory-plan.md
(U6 out-of-core global index deferred). Note: the kernel's full C++ pass floor
(~20k headers + the 8.8GB graph) likely still exceeds 24GB by itself, which is
why U6 remains the only unit that clears the wall.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(test): match OOM-guidance e2e assertions to the U1 reworded hint

The analyze-heap-oom-e2e real-child-OOM test still asserted the pre-U1
wording ('...out of memory.' + a hardcoded 24576 cap). U1 reworded the hint
to mention the auto heap-cap and use a <MB> placeholder, so the three
toContain substrings no longer matched (the assertion at line 62 failed on
all platforms). Update them to the current message. The unit twin
(analyze-heap-respawn) was already updated in 85bfc216; this integration
test was missed by the targeted local run.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* perf(lbug): U6a — deterministic id-sorted graph output behind GITNEXUS_SORT_GRAPH_OUTPUT

First increment of U6 (out-of-core scope-resolution). Adds an optional
deterministic ordering of node + relationship CSV rows by their unique graph
id, behind GITNEXUS_SORT_GRAPH_OUTPUT (default OFF = today's graph-insertion
order, byte-identical — the iterator is returned untouched). With the flag ON
the CSV becomes a pure function of the node/edge SET rather than of emit order.

This is the structural enabler for the windowed/out-of-core resolve (U6b-U6d):
csv-generator.ts:518 currently iterates graph.iterRelationships() in insertion
order with NO terminal sort, so any deviation from parsedFiles-order emit would
change bytes. With U6a on, a windowed emit need only reproduce the same edge
SET, not the global insertion order — removing the single largest byte-identical
hazard from every later windowing step.

Verified: default off keeps the existing csv-pipeline suite byte-identical; on,
node rows are id-sorted and output is independent of graph insertion order
(set-build) with the same node/edge set.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* perf(storage): U6d foundation — disk-backed scope store + lazy ScopeTree

Adds scope-index-store.ts: persistScopeShards (per-file scope shards via the
proven mapReplacer + def-interning reviver) + DiskBackedScopeTree, a lazy
ScopeTree that serves getScope from a bounded LRU of decoded shards plus a small
resident skeleton (scopeId -> {shard, childIds, parent}). Exports
makeInterningReviver from parsedfile-store for reuse.

This is the contained, highest-risk mechanism of U6d (out-of-core scope
resolution): the emit passes reach the heavy per-Scope binding payload
(~17-20GB on the kernel) ONLY through scopeTree.getScope (a point lookup) and
getChildren — they never read parsed.scopes directly — so moving that payload to
disk behind getScope is transparent. Every consumer reads a Scope BY VALUE, so a
value-faithful disk round-trip is byte-identical to resolution.

Proven in isolation: DiskBackedScopeTree is value-identical to buildScopeTree
for getScope/getChildren/getParent/getAncestors/has/size across multiple files
and after LRU eviction, and preserves the def-identity collapse (ownedDefs[i]
=== binding.def). Nothing wires it yet (the resolution-pipeline integration is
the next increment) — zero production impact; default off.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* perf(scope-resolution): U6d integration — seal scopeTree to disk before emit (GITNEXUS_DISK_SCOPE_INDEX)

Wires the U6d out-of-core scope index into the live pipeline behind
GITNEXUS_DISK_SCOPE_INDEX (default OFF = byte-identical). When on:

- finalize-orchestrator builds a TransitionalScopeTree (validated, fully
  resident) instead of buildScopeTree, so finalize/propagate/resolve are
  unchanged.
- After resolve, before emit, run.ts seals it: persists the scopes to a
  file-sharded scope-index-store, swaps the model's scopeTree to disk-backed
  serving from the inside (the frozen bundle can't be reassigned, but the
  wrapper nulls its own resident backing), and drops the heavy Scope.bindings
  payload from all THREE holders — the model's tree (seal), the caller's
  preExtractedParsedFiles, and run.ts's own parsedFiles (scope-stripped copies
  for emit). Emit reads scopes only via scopeTree.getScope (a point lookup,
  now disk-backed + LRU) — verified it never reads parsed.scopes.

Purpose: lower the per-language resident PEAK (kernel C pass ~20→~12 GB by
moving the ~8-9 GB scope payload to disk) so the analysis fits on smaller-RAM
machines. At >=24 GB the full kernel already fits with U1-U5 (U2's 8.7 GB
inter-language forceGc reclaim keeps each pass under cap) — empirically
confirmed — so this is the sub-24 GB lever, not needed at 24 GB.

Byte-identical evidence: DiskBackedScopeTree/TransitionalScopeTree return
value-identical scopes vs buildScopeTree (getScope/getChildren/getParent/
getAncestors, across files + after LRU eviction + post-seal); emit reads only
getScope + referenceSites; flag-off (394 tests) and flag-on-resident (91 tests)
resolver suites stay green; an end-to-end A/B on a 212-file C+cpp+rust subset
produced identical 17,444 nodes / 31,343 edges with the seal firing per language
(c: 410→141 MB reclaimed). Kernel-scale peak-drop measurement pending the
in-flight verdict run freeing memory.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* perf(scope-resolution): U6d — id-back workspaceIndex so the disk seal can reclaim scopes

The kernel run revealed the contained scopeTree seal didn't lower the heap:
WorkspaceResolutionIndex held Scope OBJECTS (classScopeByDefId / moduleScopeByFile),
built from every ParsedFile and live through emit, so the ~28k module + class
scopes stayed pinned past the seal (sr-seal-pre 17,583 -> sr-seal-post 17,771 MB,
no drop). It was the sole residual Scope-object holder (SemanticModel holds none).

Fix: classScopeByDefId / moduleScopeByFile become id-backed ScopeByKeyView
instances — a ReadonlyMap<K, Scope> facade over a K->ScopeId map + the scopeTree,
whose .get fetches via scopeTree.getScope(id). The index now pins only ids, so
once the tree seals to disk the scopes become collectible. Byte-identical: the
view returns the same Scope the resident tree holds (or a value-identical revived
one in disk mode), and iteration keeps the old insertion order. buildWorkspace
ResolutionIndex takes an optional scopeTree (live pipeline passes it); without it
(unit tests) the legacy direct Scope-object maps are returned unchanged.

Verified byte-identical: 733 tests across workspace-index / imported-return-types
/ c / cpp / cross-file / go / java. Kernel peak-drop re-measurement to follow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* perf(scope-resolution): U6d — precompute exportedCallableByName (fix disk-getScope thrash)

The workspaceIndex id-backing freed the kernel scopes but exposed a throughput
collapse: findExportedDefByName's workspace fallback (walkers.ts:1019) scanned
EVERY module scope's bindings per unresolved free call, and under the U6d
disk-backed scopeTree each module-scope access faulted a shard in from disk —
lib ON went ~1min -> ~7.5min.

Fix: precompute the fallback result once into
WorkspaceResolutionIndex.exportedCallableByName (simpleName -> first module-local
callable def, first-file-wins — the exact semantics the scan returned), built
from the resident module-scope bindings at index-build time. findExportedDefByName
now does an O(1) lookup with zero disk reads.

Result: lib ON ~7.5min -> 21s (cache-warm), byte-identical 17,444/31,343; 758
tests green across workspace-index + c/cpp/cross-file/go/python.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: rename cryptic U-unit codes to descriptive names in comments

The plan-unit shorthand (U3/U4/U6a/U6d/...) was meaningless in the code.
Renamed in comments + test descriptions (no behavior change, byte-identical):
  out-of-core scope index   (was U6)
  deterministic output      (was U6a)
  disk-backed scope seal    (was U6d)
  def-object interning      (was U3)
  free-call candidate cache (was U4)
Also renamed throughout the PR title/summary. Pushed commit messages keep
their original U-codes as historical record.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(ingestion): durable ParsedFile shards for warm-cache coverage (#2038)

On a warm re-analyze where every chunk is a parse-cache HIT, no parse worker
runs, the run-scoped ParsedFile store is cleared at parse start, and the cached
ParseWorkerResult carries no ParsedFiles (the worker writes them to the store
and empties them from the message). Scope-resolution then found an empty store
and fell back to main-thread extractParsedFile — re-opening the #1983
tree-sitter native-leak OOM the disk store closes (abhigyanpatwari review on
parse-cache.ts).

Fix: workers ALSO write their ParsedFiles to a durable, content-addressed store
(parsedfile-cache/) keyed by chunk hash, mirroring the parse cache's lifecycle
(version-gated by PARSE_CACHE_VERSION, pruned in lockstep to the surviving
keys). On a warm hit the chunk's durable shards are byte-COPIED into the
run-scoped store (no re-parse, no re-serialize -> byte-identical), so
scope-resolution streams them exactly as on a cold run. A coherence gate
re-dispatches the worker whenever a cached chunk's durable shards are missing
(migration / pruned / version-stale) -- never the main-thread extract.

- worker-pool/parse-worker: thread chunkHash through dispatch->job->flush
  (incl. split/requeue) so the worker tags its durable shard by content
- parsedfile-store: durable persist / restore / index / prune API (sibling
  dir, never cleared per run); content-addressing makes stale reuse impossible
- parse-impl: load durable index, gate the cache hit on durable coverage,
  restore on hit, dispatch chunkHash on miss
- run-analyze: prune+save the durable store to the parse cache's surviving keys
- saveParseCache returns its written keys (the durable keepKeys)

Verified on linux/lib: warm preExtractedHits = full coverage (520/207/1, zero
main-thread re-parse), byte-identical cold==warm (17,456n/31,353e), warm 8.5x
faster. New two-run + mixed-mode + coherence-gate regression test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(ingestion): clear stale scope-index-store shards on each seal (#2038)

The disk-backed scope index writes sequential s<n>.json shards into a shared
<storagePath>/scope-index-store/ dir, with the index resetting per
persistScopeShards call. A seal that writes fewer shards than a previous one
(a later language with fewer files, or a re-run of a shrunken repo) left stale
tail shards on disk indefinitely -- never read by the disk-backed tree, but
multi-GB on kernel-scale repos.

Add clearScopeIndexStore() and clear at the start of persistScopeShards: the
previously sealed language has finished emit and been released before the next
seal runs, so its DiskBackedScopeTree never reads those shards again. Unit
tests: a stale prior-run shard is removed, a fewer-files re-seal leaves no tail
shards, and the helper is idempotent.

Addresses abhigyanpatwari review on run.ts (disk hygiene for the
GITNEXUS_DISK_SCOPE_INDEX path).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 22:46:34 +01:00

2327 lines
94 KiB
TypeScript

/**
* Rust: trait implementations + ambiguous module import disambiguation
*/
import { describe, it, expect, beforeAll } from 'vitest';
import path from 'path';
import {
FIXTURES,
CROSS_FILE_FIXTURES,
getRelationships,
getNodesByLabel,
getNodesByLabelFull,
findDanglingEdges,
edgeSet,
runPipelineFromRepo,
type PipelineResult,
} from './helpers.js';
// ---------------------------------------------------------------------------
// Heritage: trait implementations
// ---------------------------------------------------------------------------
describe('Rust trait implementation resolution', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'rust-traits'), () => {});
}, 60000);
it('detects exactly 1 struct and 2 traits', () => {
expect(getNodesByLabel(result, 'Struct')).toEqual(['Button']);
expect(getNodesByLabel(result, 'Trait')).toEqual(['Clickable', 'Drawable']);
});
it('emits exactly 2 IMPLEMENTS edges with reason trait-impl', () => {
const implements_ = getRelationships(result, 'IMPLEMENTS');
expect(implements_.length).toBe(2);
expect(edgeSet(implements_)).toEqual(['Button → Clickable', 'Button → Drawable']);
for (const edge of implements_) {
expect(edge.rel.reason).toBe('trait-impl');
}
});
it('does not emit any EXTENDS edges for trait impls', () => {
const extends_ = getRelationships(result, 'EXTENDS');
expect(extends_.length).toBe(0);
});
it('resolves exactly 1 IMPORTS edge: main.rs → button.rs', () => {
const imports = getRelationships(result, 'IMPORTS');
expect(imports.length).toBe(1);
expect(imports[0].source).toBe('main.rs');
expect(imports[0].target).toBe('button.rs');
});
it('detects 2 modules and functions (trait signatures + impls)', () => {
expect(getNodesByLabel(result, 'Module')).toEqual(['impls', 'traits']);
const fns = getNodesByLabel(result, 'Function');
// With function_signature_item captured, trait abstract methods AND their
// concrete impls both appear (distinct qualified IDs, same name)
expect(fns).toContain('main');
expect(fns).toContain('draw');
expect(fns).toContain('is_enabled');
expect(fns).toContain('on_click');
expect(fns).toContain('resize');
// draw/is_enabled/on_click/resize appear twice (trait + impl)
expect(fns.filter((n) => n === 'draw')).toHaveLength(2);
});
it('no OVERRIDES edges target Property nodes', () => {
const overrides = getRelationships(result, 'METHOD_OVERRIDES');
for (const edge of overrides) {
const target = result.graph.getNode(edge.rel.targetId);
expect(target).toBeDefined();
expect(target!.label).not.toBe('Property');
}
});
});
// ---------------------------------------------------------------------------
// Cross-module collision (#1951 review): two `struct User` in separate modules,
// each `impl Drawable`. The legacy global last-write-wins simple-name index
// collapsed both impl sites onto ONE `User`, sourcing one (or both) edges from
// the wrong module's struct. Scope-aware resolution sources each edge from the
// `User` defined in that impl's own module, so BOTH edges are present and
// correctly sourced.
// ---------------------------------------------------------------------------
describe('Rust cross-module trait-impl collision resolution (#1951)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'rust-cross-module-collision'),
() => {},
);
}, 60000);
it('detects 2 User structs in separate modules and 1 Drawable trait', () => {
const structs: string[] = [];
result.graph.forEachNode((n) => {
if (n.label === 'Struct') structs.push(`${n.properties.name}@${n.properties.filePath}`);
});
const users = structs.filter((s) => s.startsWith('User@')).sort();
expect(users).toEqual(['User@src/a.rs', 'User@src/b.rs']);
expect(getNodesByLabel(result, 'Trait')).toEqual(['Drawable']);
});
it('emits one IMPLEMENTS edge per module, each sourced from its OWN User', () => {
const implements_ = getRelationships(result, 'IMPLEMENTS');
expect(implements_.length).toBe(2);
expect(edgeSet(implements_)).toEqual(['User → Drawable', 'User → Drawable']);
// The fix: each edge sources from the User in its own module — not a single
// last-write-wins struct. Before the fix, both edges collapsed onto one file.
const sourceFiles = implements_.map((e) => e.sourceFilePath).sort();
expect(sourceFiles).toEqual(['src/a.rs', 'src/b.rs']);
for (const edge of implements_) {
expect(edge.rel.reason).toBe('trait-impl');
expect(edge.targetFilePath).toBe('src/traits.rs');
}
});
});
// ---------------------------------------------------------------------------
// Qualified/scoped trait paths (#1956 tri-review U1): `impl crate::traits::Foo
// for S` and `impl crate::traits::Wrapped<T> for S`. The base is a
// `scoped_type_identifier` (or a generic_type wrapping one). The synth
// (rust/captures.ts `bareTypeIdentifier`) resolves it by its trailing bare name
// (KTD-1). The traits are unique, so resolution is unambiguous. (Ambiguous
// scoped bases reuse the same refuse-on-ambiguity path as bare names, already
// covered by rust-cross-module-collision / rust-ambiguous; that path is
// intentionally not added to this fixture.) Scope-resolution owns these edges
// since #942.
// ---------------------------------------------------------------------------
describe('Rust qualified/scoped trait-impl resolution (#1956 U1)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'rust-qualified-trait'), () => {});
}, 60000);
it('detects the structs and traits', () => {
expect(getNodesByLabel(result, 'Struct')).toEqual(['Gadget', 'Widget']);
expect(getNodesByLabel(result, 'Trait')).toEqual(['Drawable', 'Wrapped']);
});
it('emits IMPLEMENTS edges for qualified and qualified-generic trait paths', () => {
const implements_ = getRelationships(result, 'IMPLEMENTS');
// `impl crate::traits::Drawable for Widget` (scoped) and
// `impl crate::traits::Wrapped<u32> for Gadget` (generic-of-scoped) both
// resolve by their trailing bare name.
expect(edgeSet(implements_)).toEqual(['Gadget → Wrapped', 'Widget → Drawable']);
for (const edge of implements_) {
expect(edge.rel.reason).toBe('trait-impl');
}
});
it('sources each edge from its struct file and targets the trait module', () => {
const implements_ = getRelationships(result, 'IMPLEMENTS');
for (const edge of implements_) {
expect(edge.sourceFilePath).toBe('src/widget.rs');
expect(edge.targetFilePath).toBe('src/traits.rs');
}
});
it('does not emit EXTENDS edges (Rust trait impls are IMPLEMENTS)', () => {
expect(getRelationships(result, 'EXTENDS').length).toBe(0);
});
});
// ---------------------------------------------------------------------------
// Ambiguous: Handler struct in two modules, crate:: import disambiguates
// ---------------------------------------------------------------------------
describe('Rust ambiguous symbol resolution', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'rust-ambiguous'), () => {});
}, 60000);
it('detects 2 Handler structs in separate modules', () => {
const structs: string[] = [];
result.graph.forEachNode((n) => {
if (n.label === 'Struct') structs.push(`${n.properties.name}@${n.properties.filePath}`);
});
const handlers = structs.filter((s) => s.startsWith('Handler@'));
expect(handlers.length).toBe(2);
expect(handlers.some((h) => h.includes('src/models/'))).toBe(true);
expect(handlers.some((h) => h.includes('src/other/'))).toBe(true);
});
it('import resolves to src/models/mod.rs (not src/other/mod.rs)', () => {
const imports = getRelationships(result, 'IMPORTS');
const modelsImport = imports.find((e) => e.targetFilePath.includes('models'));
expect(modelsImport).toBeDefined();
expect(modelsImport!.targetFilePath).toBe('src/models/mod.rs');
});
it('no import edge to src/other/', () => {
const imports = getRelationships(result, 'IMPORTS');
for (const imp of imports) {
expect(imp.targetFilePath).not.toMatch(/src\/other\//);
}
});
});
describe('Rust call resolution with arity filtering', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'rust-calls'), () => {});
}, 60000);
it('resolves main → write_audit to src/onearg/mod.rs via arity narrowing', () => {
const calls = getRelationships(result, 'CALLS');
expect(calls.length).toBe(1);
expect(calls[0].source).toBe('main');
expect(calls[0].target).toBe('write_audit');
expect(calls[0].targetFilePath).toBe('src/onearg/mod.rs');
expect(calls[0].rel.reason).toBe('import-resolved');
});
});
// ---------------------------------------------------------------------------
// Member-call resolution: obj.method() resolves through pipeline
// ---------------------------------------------------------------------------
describe('Rust member-call resolution', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'rust-member-calls'), () => {});
}, 60000);
it('resolves process_user → save as a member call on User', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find((c) => c.target === 'save');
expect(saveCall).toBeDefined();
expect(saveCall!.source).toBe('process_user');
expect(saveCall!.targetFilePath).toBe('src/user.rs');
});
it('detects User struct and save function (Rust impl fns are Function nodes)', () => {
const structs: string[] = [];
result.graph.forEachNode((n) => {
if (n.label === 'Struct') structs.push(n.properties.name);
});
expect(structs).toContain('User');
// Rust tree-sitter captures all function_item as Function, including impl methods
expect(getNodesByLabel(result, 'Function')).toContain('save');
});
});
// ---------------------------------------------------------------------------
// Struct literal resolution: User { ... } resolves to Struct node
// ---------------------------------------------------------------------------
describe('Rust struct literal resolution', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'rust-struct-literals'), () => {});
}, 60000);
it('resolves User { ... } as a CALLS edge to the User struct', () => {
const calls = getRelationships(result, 'CALLS');
const ctorCall = calls.find((c) => c.target === 'User');
expect(ctorCall).toBeDefined();
expect(ctorCall!.source).toBe('process_user');
expect(ctorCall!.targetLabel).toBe('Struct');
expect(ctorCall!.targetFilePath).toBe('user.rs');
expect(ctorCall!.rel.reason).toBe('import-resolved');
});
it('also resolves user.save() as a member call', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find((c) => c.target === 'save');
expect(saveCall).toBeDefined();
expect(saveCall!.source).toBe('process_user');
});
it('detects User struct and process_user function', () => {
const structs: string[] = [];
result.graph.forEachNode((n) => {
if (n.label === 'Struct') structs.push(n.properties.name);
});
expect(structs).toContain('User');
expect(getNodesByLabel(result, 'Function')).toContain('process_user');
});
});
// ---------------------------------------------------------------------------
// Receiver-constrained resolution: typed variables disambiguate same-named methods
// ---------------------------------------------------------------------------
describe('Rust receiver-constrained resolution', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'rust-receiver-resolution'), () => {});
}, 60000);
it('detects User and Repo structs, both with save functions', () => {
const structs: string[] = [];
result.graph.forEachNode((n) => {
if (n.label === 'Struct') structs.push(n.properties.name);
});
expect(structs).toContain('User');
expect(structs).toContain('Repo');
// Rust tree-sitter captures impl fns as Function nodes
const saveFns = getNodesByLabel(result, 'Function').filter((m) => m === 'save');
expect(saveFns.length).toBe(2);
});
it('resolves user.save() to User.save and repo.save() to Repo.save via receiver typing', () => {
const calls = getRelationships(result, 'CALLS');
const saveCalls = calls.filter((c) => c.target === 'save');
expect(saveCalls.length).toBe(2);
const userSave = saveCalls.find((c) => c.targetFilePath === 'src/user.rs');
const repoSave = saveCalls.find((c) => c.targetFilePath === 'src/repo.rs');
expect(userSave).toBeDefined();
expect(repoSave).toBeDefined();
expect(userSave!.source).toBe('process_entities');
expect(repoSave!.source).toBe('process_entities');
});
});
// ---------------------------------------------------------------------------
// Alias import resolution: use crate::models::User as U resolves U → User
// ---------------------------------------------------------------------------
describe('Rust alias import resolution', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'rust-alias-imports'), () => {});
}, 60000);
it('detects User and Repo structs with their methods', () => {
const structs: string[] = [];
result.graph.forEachNode((n) => {
if (n.label === 'Struct') structs.push(n.properties.name);
});
expect(structs).toContain('User');
expect(structs).toContain('Repo');
expect(getNodesByLabel(result, 'Function')).toContain('save');
expect(getNodesByLabel(result, 'Function')).toContain('persist');
});
it('resolves u.save() to src/models.rs and r.persist() to src/models.rs via alias', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find((c) => c.target === 'save');
const persistCall = calls.find((c) => c.target === 'persist');
expect(saveCall).toBeDefined();
expect(saveCall!.source).toBe('main');
expect(saveCall!.targetFilePath).toBe('src/models.rs');
expect(persistCall).toBeDefined();
expect(persistCall!.source).toBe('main');
expect(persistCall!.targetFilePath).toBe('src/models.rs');
});
it('emits exactly 1 IMPORTS edge: src/main.rs → src/models.rs', () => {
const imports = getRelationships(result, 'IMPORTS');
expect(imports.length).toBe(1);
expect(imports[0].sourceFilePath).toBe('src/main.rs');
expect(imports[0].targetFilePath).toBe('src/models.rs');
});
});
// ---------------------------------------------------------------------------
// Local shadow: same-file definition takes priority over imported name
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Re-export chain: pub use in mod.rs followed through to definition file
// ---------------------------------------------------------------------------
describe('Rust re-export chain resolution', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'rust-reexport-chain'), () => {});
}, 60000);
it('detects Handler struct in handler.rs', () => {
const structs: string[] = [];
result.graph.forEachNode((n) => {
if (n.label === 'Struct') structs.push(`${n.properties.name}@${n.properties.filePath}`);
});
expect(structs).toContain('Handler@src/models/handler.rs');
});
it('resolves Handler { ... } to src/models/handler.rs via re-export chain, not mod.rs', () => {
const calls = getRelationships(result, 'CALLS');
const ctorCall = calls.find((c) => c.target === 'Handler');
expect(ctorCall).toBeDefined();
expect(ctorCall!.source).toBe('main');
expect(ctorCall!.targetLabel).toBe('Struct');
expect(ctorCall!.targetFilePath).toBe('src/models/handler.rs');
});
it('resolves h.process() to src/models/handler.rs', () => {
const calls = getRelationships(result, 'CALLS');
const processCall = calls.find((c) => c.target === 'process');
expect(processCall).toBeDefined();
expect(processCall!.source).toBe('main');
expect(processCall!.targetFilePath).toBe('src/models/handler.rs');
});
});
// ---------------------------------------------------------------------------
// Local shadow: same-file definition takes priority over imported name
// ---------------------------------------------------------------------------
describe('Rust local definition shadows import', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'rust-local-shadow'), () => {});
}, 60000);
it('resolves run → save to same-file definition, not the imported one', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find((c) => c.target === 'save' && c.source === 'run');
expect(saveCall).toBeDefined();
expect(saveCall!.targetFilePath).toBe('src/main.rs');
});
it('does NOT resolve save to utils.rs', () => {
const calls = getRelationships(result, 'CALLS');
const saveToUtils = calls.find(
(c) => c.target === 'save' && c.targetFilePath === 'src/utils.rs',
);
expect(saveToUtils).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// Grouped imports: use crate::helpers::{func_a, func_b}
// Verifies no spurious binding for the path prefix (e.g. "helpers")
// ---------------------------------------------------------------------------
describe('Rust grouped import resolution', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'rust-grouped-imports'), () => {});
}, 60000);
it('resolves main → format_name to src/helpers/mod.rs', () => {
const calls = getRelationships(result, 'CALLS');
const call = calls.find((c) => c.target === 'format_name');
expect(call).toBeDefined();
expect(call!.source).toBe('main');
expect(call!.targetFilePath).toBe('src/helpers/mod.rs');
expect(call!.rel.reason).toBe('import-resolved');
});
it('resolves main → validate_email to src/helpers/mod.rs', () => {
const calls = getRelationships(result, 'CALLS');
const call = calls.find((c) => c.target === 'validate_email');
expect(call).toBeDefined();
expect(call!.source).toBe('main');
expect(call!.targetFilePath).toBe('src/helpers/mod.rs');
expect(call!.rel.reason).toBe('import-resolved');
});
it('does not create a spurious CALLS edge for the path prefix "helpers"', () => {
const calls = getRelationships(result, 'CALLS');
const spurious = calls.find((c) => c.target === 'helpers' || c.source === 'helpers');
expect(spurious).toBeUndefined();
});
it('emits exactly 1 IMPORTS edge: main.rs → helpers/mod.rs', () => {
const imports = getRelationships(result, 'IMPORTS');
expect(imports.length).toBe(1);
expect(imports[0].source).toBe('main.rs');
expect(imports[0].target).toBe('mod.rs');
expect(imports[0].targetFilePath).toBe('src/helpers/mod.rs');
});
});
// ---------------------------------------------------------------------------
// Scoped grouped imports with multi-file resolution:
// use crate::models::{User, Repo} where User and Repo are in separate files.
// Verifies IMPORTS edges are created for each file AND namedImportMap entries
// match bindings to files by basename.
// ---------------------------------------------------------------------------
describe('Rust scoped grouped imports (multi-file)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'rust-scoped-multi-file'), () => {});
}, 60000);
it('detects User and Repo structs', () => {
const classes = getNodesByLabel(result, 'Struct');
expect(classes).toContain('User');
expect(classes).toContain('Repo');
});
it('emits IMPORTS edge from main.rs to models/mod.rs', () => {
const imports = getRelationships(result, 'IMPORTS');
const edge = imports.find(
(e) => e.sourceFilePath.includes('main') && e.targetFilePath.includes('models'),
);
expect(edge).toBeDefined();
});
it('resolves user.save() call to User#save in models/user.rs', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(
(c) => c.target === 'save' && c.source === 'main' && c.targetFilePath.includes('user'),
);
expect(saveCall).toBeDefined();
});
it('resolves repo.clone_repo() call to Repo#clone_repo in models/repo.rs', () => {
const calls = getRelationships(result, 'CALLS');
const cloneCall = calls.find(
(c) => c.target === 'clone_repo' && c.source === 'main' && c.targetFilePath.includes('repo'),
);
expect(cloneCall).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// Constructor-inferred type resolution: let user = User::new(); user.save()
// Rust scoped_identifier constructor pattern (no explicit type annotations)
// ---------------------------------------------------------------------------
describe('Rust constructor-inferred type resolution', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'rust-constructor-type-inference'),
() => {},
);
}, 60000);
it('detects User and Repo structs, both with save methods', () => {
expect(getNodesByLabel(result, 'Struct')).toContain('User');
expect(getNodesByLabel(result, 'Struct')).toContain('Repo');
const saveFns = getNodesByLabel(result, 'Function').filter((m) => m === 'save');
expect(saveFns.length).toBe(2);
});
it('resolves user.save() to src/user.rs via constructor-inferred type', () => {
const calls = getRelationships(result, 'CALLS');
const userSave = calls.find((c) => c.target === 'save' && c.targetFilePath === 'src/user.rs');
expect(userSave).toBeDefined();
expect(userSave!.source).toBe('process_entities');
});
it('resolves repo.save() to src/repo.rs via constructor-inferred type', () => {
const calls = getRelationships(result, 'CALLS');
const repoSave = calls.find((c) => c.target === 'save' && c.targetFilePath === 'src/repo.rs');
expect(repoSave).toBeDefined();
expect(repoSave!.source).toBe('process_entities');
});
it('emits exactly 2 save() CALLS edges (one per receiver type)', () => {
const calls = getRelationships(result, 'CALLS');
const saveCalls = calls.filter((c) => c.target === 'save');
expect(saveCalls.length).toBe(2);
});
});
// ---------------------------------------------------------------------------
// self.save() resolves to enclosing impl's own save method
// ---------------------------------------------------------------------------
describe('Rust self resolution', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'rust-self-this-resolution'), () => {});
}, 60000);
it('detects User and Repo structs, each with a save function', () => {
expect(getNodesByLabel(result, 'Struct')).toContain('User');
expect(getNodesByLabel(result, 'Struct')).toContain('Repo');
const saveFns = getNodesByLabel(result, 'Function').filter((m) => m === 'save');
expect(saveFns.length).toBe(2);
});
it('resolves self.save() inside User::process to User::save, not Repo::save', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find((c) => c.target === 'save' && c.source === 'process');
expect(saveCall).toBeDefined();
expect(saveCall!.targetFilePath).toBe('src/user.rs');
});
});
// ---------------------------------------------------------------------------
// Trait impl emits IMPLEMENTS edge
// ---------------------------------------------------------------------------
describe('Rust parent resolution (trait impl)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'rust-parent-resolution'), () => {});
}, 60000);
it('detects User struct and Serializable trait', () => {
expect(getNodesByLabel(result, 'Struct')).toContain('User');
expect(getNodesByLabel(result, 'Trait')).toContain('Serializable');
});
it('emits IMPLEMENTS edge: User → Serializable (trait impl)', () => {
const implements_ = getRelationships(result, 'IMPLEMENTS');
expect(implements_.length).toBe(1);
expect(implements_[0].source).toBe('User');
expect(implements_[0].target).toBe('Serializable');
expect(implements_[0].rel.reason).toBe('trait-impl');
});
it('no EXTENDS edges (Rust has no class inheritance)', () => {
const extends_ = getRelationships(result, 'EXTENDS');
expect(extends_.length).toBe(0);
});
});
// ---------------------------------------------------------------------------
// Struct literal inference: let user = User { ... }; user.save()
// ---------------------------------------------------------------------------
describe('Rust struct literal type inference', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'rust-struct-literal-inference'),
() => {},
);
}, 60000);
it('resolves user.save() via struct literal inference (User { ... })', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find((c) => c.target === 'save' && c.targetFilePath === 'models.rs');
expect(saveCall).toBeDefined();
expect(saveCall!.source).toBe('main');
});
it('resolves config.validate() via struct literal inference (Config { ... })', () => {
const calls = getRelationships(result, 'CALLS');
const validateCall = calls.find(
(c) => c.target === 'validate' && c.targetFilePath === 'models.rs',
);
expect(validateCall).toBeDefined();
expect(validateCall!.source).toBe('main');
});
});
// ---------------------------------------------------------------------------
// Rust Self {} struct literal: Self resolves to enclosing impl type
// ---------------------------------------------------------------------------
describe('Rust Self {} struct literal resolution', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'rust-self-struct-literal'), () => {});
}, 60000);
it('resolves fresh.validate() inside impl User via Self {} inference', () => {
const calls = getRelationships(result, 'CALLS');
const validateCall = calls.find((c) => c.target === 'validate' && c.source === 'blank');
expect(validateCall).toBeDefined();
expect(validateCall!.targetFilePath).toBe('models.rs');
});
});
// ---------------------------------------------------------------------------
// if let / while let: captured_pattern type extraction
// Extracts type from `user @ User { .. }` patterns in if-let/while-let
// ---------------------------------------------------------------------------
describe('Rust if-let captured_pattern type resolution', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'rust-if-let'), () => {});
}, 60000);
it('detects User and Config structs with their methods', () => {
expect(getNodesByLabel(result, 'Struct')).toContain('User');
expect(getNodesByLabel(result, 'Struct')).toContain('Config');
expect(getNodesByLabel(result, 'Function')).toContain('save');
expect(getNodesByLabel(result, 'Function')).toContain('validate');
});
it('resolves user.save() inside if-let via captured_pattern binding', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find((c) => c.target === 'save' && c.source === 'process_if_let');
expect(saveCall).toBeDefined();
expect(saveCall!.targetFilePath).toBe('models.rs');
});
it('resolves cfg.validate() inside while-let via captured_pattern binding', () => {
const calls = getRelationships(result, 'CALLS');
const validateCall = calls.find(
(c) => c.target === 'validate' && c.source === 'process_while_let',
);
expect(validateCall).toBeDefined();
expect(validateCall!.targetFilePath).toBe('models.rs');
});
});
// ---------------------------------------------------------------------------
// Return type inference: let user = get_user("alice"); user.save()
// Plain function call (no ::new) with no type annotation
// ---------------------------------------------------------------------------
describe('Rust return type inference', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'rust-return-type'), () => {});
}, 60000);
it('detects User struct and get_user + save functions', () => {
expect(getNodesByLabel(result, 'Struct')).toContain('User');
expect(getNodesByLabel(result, 'Function')).toContain('get_user');
expect(getNodesByLabel(result, 'Function')).toContain('save');
});
it('resolves main → get_user as a CALLS edge to src/models.rs', () => {
const calls = getRelationships(result, 'CALLS');
const getUserCall = calls.find((c) => c.target === 'get_user' && c.source === 'main');
expect(getUserCall).toBeDefined();
expect(getUserCall!.targetFilePath).toBe('src/models.rs');
});
it('resolves user.save() to src/models.rs via return-type-inferred binding', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find((c) => c.target === 'save' && c.source === 'main');
expect(saveCall).toBeDefined();
expect(saveCall!.targetFilePath).toBe('src/models.rs');
});
});
// ---------------------------------------------------------------------------
// Return-type inference with competing methods:
// Two structs both have save(), factory functions disambiguate via return type
// ---------------------------------------------------------------------------
describe('Rust return-type inference via function return type', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'rust-return-type-inference'), () => {});
}, 60000);
it('resolves user.save() to models.rs User#save via return type of get_user()', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(
(c) =>
c.target === 'save' && c.source === 'process_user' && c.targetFilePath.includes('models'),
);
expect(saveCall).toBeDefined();
});
it('user.save() does NOT resolve to Repo#save', () => {
const calls = getRelationships(result, 'CALLS');
const wrongSave = calls.find((c) => c.target === 'save' && c.source === 'process_user');
// Should resolve to exactly one target — if it resolves at all, check it's the right one
if (wrongSave) {
expect(wrongSave.targetFilePath).toContain('models');
}
});
it('resolves repo.save() to models.rs Repo#save via return type of get_repo()', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(
(c) =>
c.target === 'save' && c.source === 'process_repo' && c.targetFilePath.includes('models'),
);
expect(saveCall).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// Rust ::default() constructor resolution — scanner exclusion
// ---------------------------------------------------------------------------
describe('Rust ::default() constructor resolution', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'rust-default-constructor'), () => {});
}, 60000);
it('detects User and Repo structs', () => {
const structs = getNodesByLabel(result, 'Struct');
expect(structs).toContain('User');
expect(structs).toContain('Repo');
});
it('detects save methods on both structs', () => {
const methods = [...getNodesByLabel(result, 'Function'), ...getNodesByLabel(result, 'Method')];
expect(methods.filter((m: string) => m === 'save').length).toBe(2);
});
it('resolves user.save() in process_with_new() via User::new() constructor', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(
(c) =>
c.target === 'save' &&
c.source === 'process_with_new' &&
c.targetFilePath.includes('user.rs'),
);
expect(saveCall).toBeDefined();
});
it('resolves user.save() in process_with_default() via User::default() constructor', () => {
// User::default() should be resolved by extractInitializer (Tier 1),
// NOT by the scanner — the scanner excludes ::default() to avoid
// wasted cross-file lookups on the broadly-implemented Default trait
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(
(c) =>
c.target === 'save' &&
c.source === 'process_with_default' &&
c.targetFilePath.includes('user.rs'),
);
expect(saveCall).toBeDefined();
});
it('disambiguates repo.save() in process_with_default() to Repo#save', () => {
const calls = getRelationships(result, 'CALLS');
const repoSave = calls.find(
(c) =>
c.target === 'save' &&
c.source === 'process_with_default' &&
c.targetFilePath.includes('repo.rs'),
);
expect(repoSave).toBeDefined();
});
it('does NOT cross-contaminate (user.save() does not resolve to Repo#save)', () => {
const calls = getRelationships(result, 'CALLS');
// In process_with_new: user.save() should go to user.rs, not repo.rs
const wrongCall = calls.find(
(c) =>
c.target === 'save' &&
c.source === 'process_with_new' &&
c.targetFilePath.includes('repo.rs'),
);
// Either undefined (correctly disambiguated) or present (both resolved) — no single wrong one
if (wrongCall) {
// If both are present, there should also be a correct one
const correctCall = calls.find(
(c) =>
c.target === 'save' &&
c.source === 'process_with_new' &&
c.targetFilePath.includes('user.rs'),
);
expect(correctCall).toBeDefined();
}
});
});
// ---------------------------------------------------------------------------
// Rust async .await constructor binding resolution
// Verifies that `let user = create_user().await` correctly unwraps the
// await_expression to find the call_expression underneath, producing a
// constructor binding that enables receiver-based disambiguation of user.save().
// ---------------------------------------------------------------------------
describe('Rust async .await constructor binding resolution', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'rust-async-binding'), () => {});
}, 60000);
it('detects User and Repo structs', () => {
const structs = getNodesByLabel(result, 'Struct');
expect(structs).toContain('User');
expect(structs).toContain('Repo');
});
it('detects save methods in separate files', () => {
const methods = [...getNodesByLabel(result, 'Function'), ...getNodesByLabel(result, 'Method')];
expect(methods.filter((m: string) => m === 'save').length).toBe(2);
});
it('resolves user.save() after .await to user.rs via return type of get_user()', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(
(c) =>
c.target === 'save' && c.source === 'process_user' && c.targetFilePath.includes('user'),
);
expect(saveCall).toBeDefined();
});
it('user.save() does NOT resolve to Repo#save in repo.rs', () => {
const calls = getRelationships(result, 'CALLS');
const wrongSave = calls.find(
(c) =>
c.target === 'save' && c.source === 'process_user' && c.targetFilePath.includes('repo'),
);
expect(wrongSave).toBeUndefined();
});
it('resolves repo.save() after .await to repo.rs via return type of get_repo()', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(
(c) =>
c.target === 'save' && c.source === 'process_repo' && c.targetFilePath.includes('repo'),
);
expect(saveCall).toBeDefined();
});
it('repo.save() does NOT resolve to User#save in user.rs', () => {
const calls = getRelationships(result, 'CALLS');
const wrongSave = calls.find(
(c) =>
c.target === 'save' && c.source === 'process_repo' && c.targetFilePath.includes('user'),
);
expect(wrongSave).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// Nullable receiver: let user: Option<User> = find_user(); user.unwrap().save()
// Rust Option<User> — stripNullable unwraps Option wrapper to inner type.
// ---------------------------------------------------------------------------
describe('Rust nullable receiver resolution (Option<T>)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'rust-nullable-receiver'), () => {});
}, 60000);
it('detects User and Repo structs, both with save functions', () => {
expect(getNodesByLabel(result, 'Struct')).toContain('User');
expect(getNodesByLabel(result, 'Struct')).toContain('Repo');
const saveFns = getNodesByLabel(result, 'Function').filter((m) => m === 'save');
expect(saveFns.length).toBe(2);
});
it('resolves user.unwrap().save() to User#save via Option<User> unwrapping', () => {
const calls = getRelationships(result, 'CALLS');
const userSave = calls.find(
(c) =>
c.target === 'save' &&
c.source === 'process_entities' &&
c.targetFilePath?.includes('user'),
);
expect(userSave).toBeDefined();
});
it('resolves repo.unwrap().save() to Repo#save via Option<Repo> unwrapping', () => {
const calls = getRelationships(result, 'CALLS');
const repoSave = calls.find(
(c) =>
c.target === 'save' &&
c.source === 'process_entities' &&
c.targetFilePath?.includes('repo'),
);
expect(repoSave).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// Assignment chain propagation (Phase 4.3)
// ---------------------------------------------------------------------------
describe('Rust assignment chain propagation', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'rust-assignment-chain'), () => {});
}, 60000);
it('detects User and Repo structs each with a save function', () => {
expect(getNodesByLabel(result, 'Struct')).toContain('User');
expect(getNodesByLabel(result, 'Struct')).toContain('Repo');
const saveFns = getNodesByLabel(result, 'Function').filter((m) => m === 'save');
expect(saveFns.length).toBe(2);
});
it('resolves alias.save() to User#save via assignment chain', () => {
const calls = getRelationships(result, 'CALLS');
const userSave = calls.find(
(c) =>
c.target === 'save' &&
c.source === 'process_entities' &&
c.targetFilePath?.includes('user.rs'),
);
expect(userSave).toBeDefined();
});
it('resolves r_alias.save() to Repo#save via assignment chain', () => {
const calls = getRelationships(result, 'CALLS');
const repoSave = calls.find(
(c) =>
c.target === 'save' &&
c.source === 'process_entities' &&
c.targetFilePath?.includes('repo.rs'),
);
expect(repoSave).toBeDefined();
});
it('alias.save() does NOT resolve to Repo#save', () => {
const calls = getRelationships(result, 'CALLS');
const saveCalls = calls.filter((c) => c.target === 'save' && c.source === 'process_entities');
expect(saveCalls.filter((c) => c.targetFilePath?.includes('user.rs')).length).toBe(1);
expect(saveCalls.filter((c) => c.targetFilePath?.includes('repo.rs')).length).toBe(1);
});
});
// ---------------------------------------------------------------------------
// Rust Option<User> receiver resolution — extractSimpleTypeName unwraps
// Option<User> to "User" via NULLABLE_WRAPPER_TYPES. The variable declared
// as Option<User> now stores "User" in TypeEnv, enabling direct receiver
// disambiguation without chained .unwrap() inference.
// ---------------------------------------------------------------------------
describe('Rust Option<User> receiver resolution via wrapper unwrapping', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'rust-option-receiver'), () => {});
}, 60000);
it('detects User and Repo structs each with a save function', () => {
expect(getNodesByLabel(result, 'Struct')).toContain('User');
expect(getNodesByLabel(result, 'Struct')).toContain('Repo');
const saveFns = getNodesByLabel(result, 'Function').filter((m) => m === 'save');
expect(saveFns.length).toBe(2);
});
it('resolves alias.save() to User#save via Option<User> → assignment chain', () => {
const calls = getRelationships(result, 'CALLS');
const userSave = calls.find(
(c) =>
c.target === 'save' &&
c.source === 'process_entities' &&
c.targetFilePath?.includes('user.rs'),
);
expect(userSave).toBeDefined();
});
it('resolves repo.save() to Repo#save alongside Option usage', () => {
const calls = getRelationships(result, 'CALLS');
const repoSave = calls.find(
(c) =>
c.target === 'save' &&
c.source === 'process_entities' &&
c.targetFilePath?.includes('repo.rs'),
);
expect(repoSave).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// if let Some(user) = opt — Phase 5.2 pattern binding: unwrap Option<T>
// `opt: Option<User>` → Option<User> is stored as "User" in TypeEnv via
// NULLABLE_WRAPPER_TYPES. extractPatternBinding maps `user` → "User".
// Disambiguation: User.save vs Repo.save — only User.save should be called.
// ---------------------------------------------------------------------------
describe('Rust if-let Some(x) = opt pattern binding (Phase 5.2)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'rust-if-let-unwrap'), () => {});
}, 60000);
it('detects User and Repo structs each with a save function', () => {
expect(getNodesByLabel(result, 'Struct')).toContain('User');
expect(getNodesByLabel(result, 'Struct')).toContain('Repo');
const saveFns = getNodesByLabel(result, 'Function').filter((f) => f === 'save');
expect(saveFns.length).toBe(2);
});
it('resolves user.save() inside if-let Some(user) = opt to User#save', () => {
const calls = getRelationships(result, 'CALLS');
const userSave = calls.find(
(c) => c.target === 'save' && c.source === 'process' && c.targetFilePath?.includes('user.rs'),
);
expect(userSave).toBeDefined();
});
it('does NOT resolve user.save() to Repo#save', () => {
const calls = getRelationships(result, 'CALLS');
const repoSave = calls.find(
(c) => c.target === 'save' && c.source === 'process' && c.targetFilePath?.includes('repo.rs'),
);
expect(repoSave).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// Rust if-let Err(e) = res pattern binding (Phase 5 review fix)
// Result<User, AppError> → Err(e) should type e as AppError (typeArgs[1]).
// Also tests Ok(user) in the same fixture to verify both arms work.
// ---------------------------------------------------------------------------
describe('Rust if-let Err(e) pattern binding (Phase 5 review fix)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'rust-err-unwrap'), () => {});
}, 60000);
it('detects User and AppError structs', () => {
const structs = getNodesByLabel(result, 'Struct');
expect(structs).toContain('User');
expect(structs).toContain('AppError');
});
it('resolves e.report() inside if-let Err(e) to AppError#report', () => {
const calls = getRelationships(result, 'CALLS');
const reportCall = calls.find(
(c) =>
c.target === 'report' &&
c.source === 'handle_err' &&
c.targetFilePath?.includes('error.rs'),
);
expect(reportCall).toBeDefined();
});
it('resolves user.save() inside if-let Ok(user) to User#save', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(
(c) =>
c.target === 'save' && c.source === 'handle_ok' && c.targetFilePath?.includes('user.rs'),
);
expect(saveCall).toBeDefined();
});
it('does NOT resolve e.report() to User#save (no cross-contamination)', () => {
const calls = getRelationships(result, 'CALLS');
const wrongCall = calls.find((c) => c.target === 'save' && c.source === 'handle_err');
expect(wrongCall).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// Chained method calls: svc.get_user().save()
// Tests that Rust chain call resolution correctly infers the intermediate
// receiver type from get_user()'s return type and resolves save() to User.
// ---------------------------------------------------------------------------
describe('Rust chained method call resolution', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'rust-chain-call'), () => {});
}, 60000);
it('detects User and Repo structs, and UserService', () => {
expect(getNodesByLabel(result, 'Struct')).toContain('User');
expect(getNodesByLabel(result, 'Struct')).toContain('Repo');
expect(getNodesByLabel(result, 'Struct')).toContain('UserService');
});
it('detects get_user and save functions', () => {
const fns = getNodesByLabel(result, 'Function');
expect(fns).toContain('get_user');
expect(fns).toContain('save');
});
it('resolves svc.get_user().save() to User#save via chain resolution', () => {
const calls = getRelationships(result, 'CALLS');
const userSave = calls.find(
(c) =>
c.target === 'save' && c.source === 'process_user' && c.targetFilePath?.includes('user.rs'),
);
expect(userSave).toBeDefined();
});
it('does NOT resolve svc.get_user().save() to Repo#save', () => {
const calls = getRelationships(result, 'CALLS');
const repoSave = calls.find(
(c) =>
c.target === 'save' && c.source === 'process_user' && c.targetFilePath?.includes('repo.rs'),
);
expect(repoSave).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// Rust for-loop Tier 1c: for user in &users with Vec<User> parameter
// ---------------------------------------------------------------------------
describe('Rust for-loop type resolution (Tier 1c)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'rust-for-loop'), () => {});
}, 60000);
it('detects User and Repo structs with save functions', () => {
expect(getNodesByLabel(result, 'Struct')).toContain('User');
expect(getNodesByLabel(result, 'Struct')).toContain('Repo');
const saveFns = getNodesByLabel(result, 'Function').filter((f) => f === 'save');
expect(saveFns.length).toBe(2);
});
it('resolves user.save() in for-loop to User#save via Tier 1c', () => {
const calls = getRelationships(result, 'CALLS');
const userSave = calls.find(
(c) =>
c.target === 'save' &&
c.source === 'process_users' &&
c.targetFilePath?.includes('user.rs'),
);
expect(userSave).toBeDefined();
});
it('does NOT resolve user.save() to Repo#save (negative)', () => {
const calls = getRelationships(result, 'CALLS');
const wrongSave = calls.find(
(c) =>
c.target === 'save' &&
c.source === 'process_users' &&
c.targetFilePath?.includes('repo.rs'),
);
expect(wrongSave).toBeUndefined();
});
it('resolves repo.save() in for-loop to Repo#save via Tier 1c', () => {
const calls = getRelationships(result, 'CALLS');
const repoSave = calls.find(
(c) =>
c.target === 'save' &&
c.source === 'process_repos' &&
c.targetFilePath?.includes('repo.rs'),
);
expect(repoSave).toBeDefined();
});
it('does NOT resolve repo.save() to User#save (negative)', () => {
const calls = getRelationships(result, 'CALLS');
const wrongSave = calls.find(
(c) =>
c.target === 'save' &&
c.source === 'process_repos' &&
c.targetFilePath?.includes('user.rs'),
);
expect(wrongSave).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// Rust match arm: match opt { Some(user) => user.save() }
// ---------------------------------------------------------------------------
describe('Rust match arm type resolution', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'rust-match-unwrap'), () => {});
}, 60000);
it('detects User and Repo structs with save functions', () => {
expect(getNodesByLabel(result, 'Struct')).toContain('User');
expect(getNodesByLabel(result, 'Struct')).toContain('Repo');
const saveFns = getNodesByLabel(result, 'Function').filter((f) => f === 'save');
expect(saveFns.length).toBe(2);
});
it('resolves user.save() inside match Some(user) to User#save', () => {
const calls = getRelationships(result, 'CALLS');
const userSave = calls.find(
(c) => c.target === 'save' && c.source === 'process' && c.targetFilePath?.includes('user.rs'),
);
expect(userSave).toBeDefined();
});
it('does NOT resolve user.save() in match to Repo#save (negative)', () => {
const calls = getRelationships(result, 'CALLS');
const wrongSave = calls.find(
(c) => c.target === 'save' && c.source === 'process' && c.targetFilePath?.includes('repo.rs'),
);
expect(wrongSave).toBeUndefined();
});
it('resolves repo.save() inside if-let Ok(repo) to Repo#save', () => {
const calls = getRelationships(result, 'CALLS');
const repoSave = calls.find(
(c) => c.target === 'save' && c.source === 'check' && c.targetFilePath?.includes('repo.rs'),
);
expect(repoSave).toBeDefined();
});
it('does NOT resolve repo.save() in if-let to User#save (negative)', () => {
const calls = getRelationships(result, 'CALLS');
const wrongSave = calls.find(
(c) => c.target === 'save' && c.source === 'check' && c.targetFilePath?.includes('user.rs'),
);
expect(wrongSave).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// for user in users.iter() — call_expression iterable resolution
// ---------------------------------------------------------------------------
describe('Rust .iter() for-loop call_expression resolution', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'rust-iter-for-loop'), () => {});
}, 60000);
it('detects User and Repo structs with save functions', () => {
expect(getNodesByLabel(result, 'Struct')).toContain('User');
expect(getNodesByLabel(result, 'Struct')).toContain('Repo');
const saveFns = getNodesByLabel(result, 'Function').filter((f) => f === 'save');
expect(saveFns.length).toBe(2);
});
it('resolves user.save() via users.iter() to User#save', () => {
const calls = getRelationships(result, 'CALLS');
const userSave = calls.find(
(c) =>
c.target === 'save' &&
c.source === 'process_users' &&
c.targetFilePath?.includes('user.rs'),
);
expect(userSave).toBeDefined();
});
it('resolves repo.save() via repos.into_iter() to Repo#save', () => {
const calls = getRelationships(result, 'CALLS');
const repoSave = calls.find(
(c) =>
c.target === 'save' &&
c.source === 'process_repos' &&
c.targetFilePath?.includes('repo.rs'),
);
expect(repoSave).toBeDefined();
});
it('does NOT cross-resolve user.save() to Repo#save (negative)', () => {
const calls = getRelationships(result, 'CALLS');
const wrongSave = calls.find(
(c) =>
c.target === 'save' &&
c.source === 'process_users' &&
c.targetFilePath?.includes('repo.rs'),
);
expect(wrongSave).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// for user in get_users() — direct call_expression iterable resolution
// Phase 7.3: unlike rust-iter-for-loop (typed variable .iter()), this tests
// iterating over a function call's return value directly.
// ---------------------------------------------------------------------------
describe('Rust for-loop direct call_expression iterable resolution (Phase 7.3)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'rust-for-call-expr'), () => {});
}, 60000);
it('detects User and Repo structs with competing save functions', () => {
expect(getNodesByLabel(result, 'Struct')).toContain('User');
expect(getNodesByLabel(result, 'Struct')).toContain('Repo');
const saveFns = getNodesByLabel(result, 'Function').filter((f) => f === 'save');
expect(saveFns.length).toBe(2);
});
it('resolves user.save() in for-loop over get_users() to User#save', () => {
const calls = getRelationships(result, 'CALLS');
const userSave = calls.find(
(c) =>
c.target === 'save' &&
c.source === 'process_users' &&
c.targetFilePath?.includes('user.rs'),
);
expect(userSave).toBeDefined();
});
it('resolves repo.save() in for-loop over get_repos() to Repo#save', () => {
const calls = getRelationships(result, 'CALLS');
const repoSave = calls.find(
(c) =>
c.target === 'save' &&
c.source === 'process_repos' &&
c.targetFilePath?.includes('repo.rs'),
);
expect(repoSave).toBeDefined();
});
it('does NOT resolve user.save() to Repo#save (negative)', () => {
const calls = getRelationships(result, 'CALLS');
const wrongSave = calls.find(
(c) =>
c.target === 'save' &&
c.source === 'process_users' &&
c.targetFilePath?.includes('repo.rs'),
);
expect(wrongSave).toBeUndefined();
});
it('does NOT resolve repo.save() to User#save (negative)', () => {
const calls = getRelationships(result, 'CALLS');
const wrongSave = calls.find(
(c) =>
c.target === 'save' &&
c.source === 'process_repos' &&
c.targetFilePath?.includes('user.rs'),
);
expect(wrongSave).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// Phase 8: Field/property type resolution — struct field capture
// ---------------------------------------------------------------------------
describe('Field type resolution (Rust)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'rust-field-types'), () => {});
}, 60000);
it('detects structs: Address, User', () => {
expect(getNodesByLabel(result, 'Struct')).toEqual(['Address', 'User']);
});
it('detects Property nodes for Rust struct fields', () => {
const properties = getNodesByLabel(result, 'Property');
expect(properties).toContain('address');
expect(properties).toContain('name');
expect(properties).toContain('city');
});
it('emits HAS_PROPERTY edges linking fields to structs', () => {
const propEdges = getRelationships(result, 'HAS_PROPERTY');
expect(propEdges.length).toBe(3);
expect(edgeSet(propEdges)).toContain('User → name');
expect(edgeSet(propEdges)).toContain('User → address');
expect(edgeSet(propEdges)).toContain('Address → city');
});
it('resolves user.address.save() → Address#save via field type', () => {
const calls = getRelationships(result, 'CALLS');
const saveCalls = calls.filter((e) => e.target === 'save' && e.source === 'process_user');
expect(saveCalls.length).toBe(1);
expect(saveCalls[0].targetFilePath).toContain('models');
});
it('populates field metadata (visibility, isReadonly, declaredType) on Property nodes', () => {
const properties = getNodesByLabelFull(result, 'Property');
const city = properties.find((p) => p.name === 'city');
expect(city).toBeDefined();
expect(city!.properties.visibility).toBe('public');
expect(city!.properties.isStatic).toBe(false);
expect(city!.properties.isReadonly).toBe(true);
expect(city!.properties.declaredType).toBe('String');
const addr = properties.find((p) => p.name === 'address');
expect(addr).toBeDefined();
expect(addr!.properties.visibility).toBe('public');
expect(addr!.properties.isReadonly).toBe(true);
expect(addr!.properties.declaredType).toBe('Address');
});
});
// ---------------------------------------------------------------------------
// Phase 8B: Deep field chain resolution (3-level)
// ---------------------------------------------------------------------------
describe('Deep field chain resolution (Rust)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'rust-deep-field-chain'), () => {});
}, 60000);
it('detects structs: Address, City, User', () => {
expect(getNodesByLabel(result, 'Struct')).toEqual(['Address', 'City', 'User']);
});
it('detects Property nodes for Rust struct fields', () => {
const properties = getNodesByLabel(result, 'Property');
expect(properties).toContain('address');
expect(properties).toContain('city');
expect(properties).toContain('zip_code');
});
it('emits HAS_PROPERTY edges for nested type chain', () => {
const propEdges = getRelationships(result, 'HAS_PROPERTY');
expect(propEdges.length).toBe(5);
expect(edgeSet(propEdges)).toContain('User → name');
expect(edgeSet(propEdges)).toContain('User → address');
expect(edgeSet(propEdges)).toContain('Address → city');
expect(edgeSet(propEdges)).toContain('Address → street');
expect(edgeSet(propEdges)).toContain('City → zip_code');
});
it('resolves 2-level chain: user.address.save() → Address#save', () => {
const calls = getRelationships(result, 'CALLS');
const saveCalls = calls.filter((e) => e.target === 'save' && e.source === 'process_user');
const addressSave = saveCalls.find((e) => e.targetFilePath.includes('models'));
expect(addressSave).toBeDefined();
});
it('resolves 3-level chain: user.address.city.get_name() → City#get_name', () => {
const calls = getRelationships(result, 'CALLS');
const getNameCalls = calls.filter(
(e) => e.target === 'get_name' && e.source === 'process_user',
);
const cityGetName = getNameCalls.find((e) => e.targetFilePath.includes('models'));
expect(cityGetName).toBeDefined();
});
});
// ACCESSES write edges from assignment expressions
// ---------------------------------------------------------------------------
describe('Write access tracking (Rust)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'rust-write-access'), () => {});
}, 60000);
it('emits ACCESSES write edges for field assignments', () => {
const accesses = getRelationships(result, 'ACCESSES');
const writes = accesses.filter((e) => e.rel.reason === 'write');
expect(writes.length).toBe(3);
const fieldNames = writes.map((e) => e.target);
expect(fieldNames).toContain('name');
expect(fieldNames).toContain('address');
expect(fieldNames).toContain('score');
const sources = writes.map((e) => e.source);
expect(sources).toContain('update_user');
});
it('write ACCESSES edges have confidence 1.0', () => {
const accesses = getRelationships(result, 'ACCESSES');
const writes = accesses.filter((e) => e.rel.reason === 'write');
for (const edge of writes) {
expect(edge.rel.confidence).toBe(1.0);
}
});
it('emits ACCESSES write edge for compound assignment', () => {
const accesses = getRelationships(result, 'ACCESSES');
const writes = accesses.filter((e) => e.rel.reason === 'write');
const scoreWrite = writes.find((e) => e.target === 'score');
expect(scoreWrite).toBeDefined();
expect(scoreWrite!.source).toBe('update_user');
});
});
// ---------------------------------------------------------------------------
// Call-result variable binding (Phase 9): let user = get_user(); user.save()
// ---------------------------------------------------------------------------
describe('Rust call-result variable binding (Tier 2b)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'rust-call-result-binding'), () => {});
}, 60000);
it('resolves user.save() to User#save via call-result binding', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(
(c) =>
c.target === 'save' && c.source === 'process_user' && c.targetFilePath.includes('models'),
);
expect(saveCall).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// Method chain binding (Phase 9C): get_user() → .address → .get_city() → .save()
// ---------------------------------------------------------------------------
describe('Rust method chain binding via unified fixpoint (Phase 9C)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'rust-method-chain-binding'), () => {});
}, 60000);
it('resolves city.save() to City#save via method chain', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(
(c) =>
c.target === 'save' && c.source === 'process_chain' && c.targetFilePath.includes('models'),
);
expect(saveCall).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// Phase A: Rust struct_pattern destructuring — let Point { x, y } = p
// Each field emits a fieldAccess PendingAssignment; fixpoint resolves x/y → Vec2
// ---------------------------------------------------------------------------
describe('Rust struct_pattern destructuring resolution (Phase A)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'rust-struct-destructuring'), () => {});
}, 60000);
it('detects Point and Vec2 structs', () => {
const classes = getNodesByLabel(result, 'Struct');
expect(classes).toContain('Point');
expect(classes).toContain('Vec2');
});
it('resolves x.save() to Vec2#save via struct destructuring', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(
(c) => c.target === 'save' && c.source === 'process' && c.targetFilePath.includes('vec2'),
);
expect(saveCall).toBeDefined();
});
it('resolves both x.save() and y.save() — emits at least 1 CALLS to Vec2#save', () => {
const calls = getRelationships(result, 'CALLS');
const saveCalls = calls.filter((c) => c.target === 'save' && c.targetFilePath.includes('vec2'));
// Both x and y are Vec2 — the same function, so calls may deduplicate to 1
expect(saveCalls.length).toBeGreaterThanOrEqual(1);
});
});
// ---------------------------------------------------------------------------
// Phase 14: Cross-file binding propagation
// src/models.rs exports User struct with save() and get_name() methods
// src/factory.rs exports get_user() -> User (uses crate::models::User)
// src/main.rs uses crate::factory::get_user, calls u.save() / u.get_name()
// → u is typed User via cross-file return type propagation
// ---------------------------------------------------------------------------
describe('Rust cross-file binding propagation', () => {
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');
expect(getNodesByLabel(result, 'Function')).toContain('save');
expect(getNodesByLabel(result, 'Function')).toContain('get_name');
});
it('detects get_user and process functions', () => {
expect(getNodesByLabel(result, 'Function')).toContain('get_user');
expect(getNodesByLabel(result, 'Function')).toContain('process');
});
it('emits IMPORTS edge from main.rs to factory.rs', () => {
const imports = getRelationships(result, 'IMPORTS');
const edge = imports.find(
(e) => e.sourceFilePath.includes('main') && e.targetFilePath.includes('factory'),
);
expect(edge).toBeDefined();
});
it('resolves u.save() in process() to User#save via cross-file return type propagation', () => {
const calls = getRelationships(result, 'CALLS');
const saveCall = calls.find(
(c) => c.target === 'save' && c.source === 'process' && c.targetFilePath.includes('models'),
);
expect(saveCall).toBeDefined();
});
it('resolves u.get_name() in process() to User#get_name via cross-file return type propagation', () => {
const calls = getRelationships(result, 'CALLS');
const getNameCall = calls.find(
(c) =>
c.target === 'get_name' && c.source === 'process' && c.targetFilePath.includes('models'),
);
expect(getNameCall).toBeDefined();
});
it('emits HAS_METHOD edges linking save and get_name to User', () => {
const hasMethod = getRelationships(result, 'HAS_METHOD');
const saveEdge = hasMethod.find((e) => e.source === 'User' && e.target === 'save');
const getNameEdge = hasMethod.find((e) => e.source === 'User' && e.target === 'get_name');
expect(saveEdge).toBeDefined();
expect(getNameEdge).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// Method enrichment: trait vs inherent impl, isAbstract, isStatic, annotations
// ---------------------------------------------------------------------------
describe('Rust method enrichment (trait + inherent impl)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'rust-method-enrichment'), () => {});
}, 60000);
it('detects Dog struct and Animal trait', () => {
expect(getNodesByLabel(result, 'Struct')).toContain('Dog');
expect(getNodesByLabel(result, 'Trait')).toContain('Animal');
});
it('emits IMPLEMENTS edge from Dog to Animal', () => {
const implements_ = getRelationships(result, 'IMPLEMENTS');
const edge = implements_.find((e) => e.source === 'Dog' && e.target === 'Animal');
expect(edge).toBeDefined();
});
it('emits HAS_METHOD edges for all Dog methods (trait + inherent)', () => {
const hasMethod = getRelationships(result, 'HAS_METHOD');
const dogMethods = hasMethod
.filter((e) => e.source === 'Dog')
.map((e) => e.target)
.sort();
expect(dogMethods).toContain('speak');
expect(dogMethods).toContain('fetch');
expect(dogMethods).toContain('new');
expect(dogMethods).toContain('wag');
});
it('emits HAS_METHOD edges for Animal trait methods (abstract + default)', () => {
const hasMethod = getRelationships(result, 'HAS_METHOD');
const traitMethods = hasMethod
.filter((e) => e.source === 'Animal')
.map((e) => e.target)
.sort();
expect(traitMethods).toContain('breathe');
// With function_signature_item query, abstract speak is also captured
expect(traitMethods).toContain('speak');
});
// With the function_signature_item query, abstract trait speak IS captured.
// Due to ID collision (both trait and impl speak share Function:src/lib.rs:speak),
// only the first-processed node survives — the abstract one from the trait.
// TODO: Phase 2 (qualified IDs) will disambiguate both nodes.
it('captures abstract trait speak via function_signature_item query', () => {
const methods = getNodesByLabelFull(result, 'Function');
const traitSpeak = methods.find(
(m) => m.name === 'speak' && m.properties.filePath?.includes('lib.rs'),
);
expect(traitSpeak).toBeDefined();
expect(traitSpeak!.properties.isAbstract).toBe(true);
});
it('marks trait default method breathe as isAbstract=false', () => {
const methods = getNodesByLabelFull(result, 'Function');
const breathe = methods.find(
(m) => m.name === 'breathe' && m.properties.filePath?.includes('lib.rs'),
);
expect(breathe).toBeDefined();
expect(breathe!.properties.isAbstract).toBe(false);
});
it('marks Dog::new() as isStatic=true (no self parameter)', () => {
const methods = getNodesByLabelFull(result, 'Function');
const newFn = methods.find(
(m) => m.name === 'new' && m.properties.filePath?.includes('lib.rs'),
);
expect(newFn).toBeDefined();
expect(newFn!.properties.isStatic).toBe(true);
});
it('records parameterTypes for fetch(&self, item: &str)', () => {
const methods = getNodesByLabelFull(result, 'Function');
const fetchFn = methods.find(
(m) => m.name === 'fetch' && m.properties.filePath?.includes('lib.rs'),
);
expect(fetchFn).toBeDefined();
expect(fetchFn!.properties.parameterTypes).toContain('str');
});
it('records #[inline] annotation on wag()', () => {
const methods = getNodesByLabelFull(result, 'Function');
const wagFn = methods.find(
(m) => m.name === 'wag' && m.properties.filePath?.includes('lib.rs'),
);
expect(wagFn).toBeDefined();
expect(wagFn!.properties.annotations).toContain('#[inline]');
});
it('uses Impl source label for HAS_METHOD edges from inherent impl', () => {
const hasMethod = getRelationships(result, 'HAS_METHOD');
// Dog inherent impl (plain `impl Dog {}`) → Impl label
const dogImplEdges = hasMethod.filter(
(e) =>
e.source === 'Dog' && (e.target === 'new' || e.target === 'wag' || e.target === 'fetch'),
);
for (const edge of dogImplEdges) {
expect(edge.sourceLabel).toBe('Impl');
}
});
it('resolves main.rs calls: Dog::new(), dog.speak(), dog.fetch()', () => {
const calls = getRelationships(result, 'CALLS');
const mainCalls = calls.filter((c) => c.source === 'main');
const newCall = mainCalls.find((c) => c.target === 'new');
const speakCall = mainCalls.find((c) => c.target === 'speak');
const fetchCall = mainCalls.find((c) => c.target === 'fetch');
expect(newCall).toBeDefined();
expect(speakCall).toBeDefined();
expect(fetchCall).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// Abstract dispatch: trait required vs default methods, IMPLEMENTS + HAS_METHOD
// ---------------------------------------------------------------------------
describe('Rust abstract dispatch (Repository trait)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'rust-abstract-dispatch'), () => {});
}, 60000);
it('detects SqlRepo struct and Repository trait', () => {
expect(getNodesByLabel(result, 'Struct')).toContain('SqlRepo');
expect(getNodesByLabel(result, 'Trait')).toContain('Repository');
});
it('emits IMPLEMENTS edge from SqlRepo to Repository', () => {
const implements_ = getRelationships(result, 'IMPLEMENTS');
const edge = implements_.find((e) => e.source === 'SqlRepo' && e.target === 'Repository');
expect(edge).toBeDefined();
});
it('emits HAS_METHOD edge for Repository default method count', () => {
const hasMethod = getRelationships(result, 'HAS_METHOD');
const traitMethods = hasMethod
.filter((e) => e.source === 'Repository')
.map((e) => e.target)
.sort();
// Only default (non-abstract) methods get HAS_METHOD on the trait itself
expect(traitMethods).toContain('count');
});
it('emits HAS_METHOD edges linking find and save to SqlRepo (not Repository)', () => {
const hasMethod = getRelationships(result, 'HAS_METHOD');
const sqlRepoMethods = hasMethod
.filter((e) => e.source === 'SqlRepo')
.map((e) => e.target)
.sort();
// impl Repository for SqlRepo methods should be owned by SqlRepo (concrete type)
expect(sqlRepoMethods).toContain('find');
expect(sqlRepoMethods).toContain('save');
});
it('uses Struct source label for HAS_METHOD edges from trait impl (impl Trait for Struct)', () => {
const hasMethod = getRelationships(result, 'HAS_METHOD');
// impl Repository for SqlRepo → Struct label (no Impl node for trait impls)
const sqlRepoEdges = hasMethod.filter(
(e) => e.source === 'SqlRepo' && (e.target === 'find' || e.target === 'save'),
);
for (const edge of sqlRepoEdges) {
expect(edge.sourceLabel).toBe('Struct');
}
});
it('uses Trait source label for HAS_METHOD edge on Repository default method', () => {
const hasMethod = getRelationships(result, 'HAS_METHOD');
const traitCount = hasMethod.find((e) => e.source === 'Repository' && e.target === 'count');
expect(traitCount).toBeDefined();
expect(traitCount!.sourceLabel).toBe('Trait');
});
it('marks trait find/save as isAbstract=true and impl find/save as isAbstract=false', () => {
const methods = getNodesByLabelFull(result, 'Function');
// With qualified IDs, both abstract (trait) and concrete (impl) find/save exist
const abstractFind = methods.find((m) => m.name === 'find' && m.properties.isAbstract === true);
const concreteFind = methods.find(
(m) => m.name === 'find' && m.properties.isAbstract === false,
);
const abstractSave = methods.find((m) => m.name === 'save' && m.properties.isAbstract === true);
const concreteSave = methods.find(
(m) => m.name === 'save' && m.properties.isAbstract === false,
);
expect(abstractFind).toBeDefined();
expect(concreteFind).toBeDefined();
expect(abstractSave).toBeDefined();
expect(concreteSave).toBeDefined();
});
it('marks default trait method count as isAbstract=false', () => {
const methods = getNodesByLabelFull(result, 'Function');
const countFn = methods.find(
(m) => m.name === 'count' && m.properties.filePath?.includes('lib.rs'),
);
expect(countFn).toBeDefined();
expect(countFn!.properties.isAbstract).toBe(false);
});
it('records parameterTypes for find(&self, id: i32)', () => {
const methods = getNodesByLabelFull(result, 'Function');
const findFn = methods.find(
(m) => m.name === 'find' && m.properties.filePath?.includes('lib.rs'),
);
expect(findFn).toBeDefined();
expect(findFn!.properties.parameterTypes).toContain('i32');
});
it('records parameterTypes for save(&self, entity: &str)', () => {
const methods = getNodesByLabelFull(result, 'Function');
const saveFn = methods.find(
(m) => m.name === 'save' && m.properties.filePath?.includes('lib.rs'),
);
expect(saveFn).toBeDefined();
expect(saveFn!.properties.parameterTypes).toContain('str');
});
it('resolves process() calls: repo.find(), repo.save(), repo.count()', () => {
const calls = getRelationships(result, 'CALLS');
const processCalls = calls.filter((c) => c.source === 'process');
const findCall = processCalls.find((c) => c.target === 'find');
const saveCall = processCalls.find((c) => c.target === 'save');
const countCall = processCalls.find((c) => c.target === 'count');
expect(findCall).toBeDefined();
expect(saveCall).toBeDefined();
expect(countCall).toBeDefined();
});
it('emits METHOD_IMPLEMENTS edges from SqlRepo impl methods → Repository trait methods', () => {
const mi = getRelationships(result, 'METHOD_IMPLEMENTS');
// find and save are required trait methods; count has a default impl so no METHOD_IMPLEMENTS
const libEdges = mi.filter((e) => e.sourceFilePath.includes('lib.rs'));
expect(libEdges.length).toBe(2);
const names = libEdges.map((e) => e.source).sort();
expect(names).toEqual(['find', 'save']);
});
});
// ---------------------------------------------------------------------------
// SM-11: Rust Child extends Parent — qualified-syntax MRO
//
// Companion integration test for the unit-level Rust qualified-syntax tests
// in symbol-table.test.ts. Validates end-to-end that:
//
// 1. Direct `impl` methods on a struct resolve through the owner-scoped
// path — the positive control.
//
// 2. Trait-inherited default methods are NOT reachable via direct
// `obj.trait_method()` syntax. Rust requires the trait to be in scope
// and uses qualified syntax for trait dispatch; the resolver correctly
// treats direct member calls as opaque to trait ancestry.
//
// Previously this case emitted a false-positive CALLS edge via the
// permissive tail-return in the legacy resolver — Codex review finding
// R3 (PR #744). It is now null-routed when receiver filtering produces
// zero matches on both file and owner dimensions.
// ---------------------------------------------------------------------------
describe('Rust Child extends Parent — qualified-syntax MRO (SM-11)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'rust-child-extends-parent'), () => {});
}, 60000);
it('detects Child struct and Parent trait', () => {
const structs = getNodesByLabel(result, 'Struct');
expect(structs).toContain('Child');
const traits = getNodesByLabel(result, 'Trait');
expect(traits).toContain('Parent');
});
it('resolves c.own_method() to Child::own_method via D0 owner-scoped path', () => {
// Direct impl method — D0 short-circuits to lookupMethodByOwner which
// returns Child::own_method without falling through to D1-D4 fuzzy.
const calls = getRelationships(result, 'CALLS');
const ownCall = calls.find(
(c) =>
c.target === 'own_method' && c.source === 'run' && c.targetFilePath.includes('child.rs'),
);
expect(ownCall).toBeDefined();
});
it('does NOT resolve c.trait_only() to Parent::trait_only via direct member call', () => {
// Qualified-syntax MRO: direct member calls on structs do not walk trait
// ancestry. `c.trait_only()` must null-route because `trait_only` is
// defined on the trait, not on the Child struct.
//
// The tail-return tightening (R3) is what makes this assertion testable:
// before the fix, the resolver would fall through the fuzzy tiers (zero
// file matches, zero owner matches) and silently pick the single fuzzy
// candidate as a false-positive edge.
const calls = getRelationships(result, 'CALLS');
const traitCall = calls.find(
(c) =>
c.target === 'trait_only' && c.source === 'run' && c.targetFilePath.includes('parent.rs'),
);
expect(traitCall).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// Scoped inherent impl targets — ownership + collision (issue #1975)
//
// `impl a::Inner { ... }` (scoped_type_identifier target) now materializes an
// Impl node keyed by the full scoped text, so its methods own through a real
// node. A same-tail target in another module (`impl b::Inner`) stays a DISTINCT
// Impl node — no merge, no mis-attribution. (Trait impls on a scoped struct path
// — `impl T for a::Inner` — need qualified struct-node identity, deferred to #1978.)
// ---------------------------------------------------------------------------
describe('Rust scoped inherent impl — ownership + collision (issue #1975)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'rust-scoped-impl'), () => {});
}, 60000);
it('owns each scoped inherent-impl method with no dangling HAS_METHOD edges', () => {
expect(findDanglingEdges(result, ['HAS_METHOD'])).toEqual([]);
});
// R3: a::Inner and b::Inner share a tail but must own through distinct Impl nodes.
it('keeps a::Inner and b::Inner impls distinct (no cross-wired methods)', () => {
const hasMethod = getRelationships(result, 'HAS_METHOD');
const fromA = hasMethod.find((e) => e.target === 'from_a');
const fromB = hasMethod.find((e) => e.target === 'from_b');
expect(fromA).toBeDefined();
expect(fromB).toBeDefined();
expect(fromA!.source).toBe('a::Inner');
expect(fromB!.source).toBe('b::Inner');
expect(fromA!.source).not.toBe(fromB!.source);
});
});
// ---------------------------------------------------------------------------
// Inline mod-nested same-tail collision — distinct nodes (issue #1978)
//
// `mod outer { struct Inner; impl Inner }` + `mod other { struct Inner; impl Inner }`
// must own their methods through TWO distinct nodes. On the pre-fix base both
// `Inner` structs merge into one simple-keyed node and from_outer/from_other
// cross-wire onto it (dangling:0 but wrong). Asserts the two methods resolve to
// DISTINCT owner node ids (R7), not just dangle-free.
//
// DEFERRED (skip): the generic qualifiedNodeId mechanism (#1978) qualifies
// class-like *type declarations* via the class-extractor. Rust methods live in
// `impl Inner` blocks, and the inherent-impl owner branch in ast-helpers keys
// the Impl node by the impl target's RAW text ("Inner") and returns BEFORE the
// generic qualified-owner path — so it can't reuse `extractQualifiedName` (an
// `impl_item` isn't a typeDeclaration). Qualifying the impl target by its
// enclosing `mod` scope, plus matching it on the registry-primary graph bridge,
// is separate machinery tracked as a follow-up. C++/Ruby land first (KTD-6).
// ---------------------------------------------------------------------------
// #1982: Rust same-tail nested-mod inherent-impl methods now own through DISTINCT
// Impl nodes — mod outer's `impl Inner` → `Impl:...:outer.Inner`, mod other's →
// `other.Inner`. The inherent-impl owner walk (ast-helpers `findEnclosingClassInfo`)
// and the Impl-node materialization (parsing-processor / parse-worker) both qualify
// an UNSCOPED impl target by its enclosing `mod_item` scope, byte-identically, so
// the HAS_METHOD owner edge stays anchored. Structure-phase, so it holds on both
// resolver legs. (Scoped `impl a::Inner` is unchanged — #1975.)
describe('Rust inline mod-nested same-tail collision — distinct nodes (issue #1978/#1982)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'rust-nested-tail-collision'), () => {});
}, 60000);
it('owns from_outer / from_other through distinct mod-qualified Impl nodes (no merge)', () => {
expect(findDanglingEdges(result, ['HAS_METHOD'])).toEqual([]);
const hm = getRelationships(result, 'HAS_METHOD');
const a = hm.find((e) => e.target === 'from_outer');
const b = hm.find((e) => e.target === 'from_other');
expect(a, 'HAS_METHOD -> from_outer').toBeDefined();
expect(b, 'HAS_METHOD -> from_other').toBeDefined();
// Pre-fix the two same-tail `Inner` impls merged onto one `Impl:...:Inner`
// node. KTD3: discriminate on the node id — each now carries its mod path.
expect(a!.rel.sourceId).not.toBe(b!.rel.sourceId);
expect(a!.rel.sourceId).toContain('outer.Inner');
expect(b!.rel.sourceId).toContain('other.Inner');
});
});
// ---------------------------------------------------------------------------
// #1992: GENERIC inherent-impl ownership — `impl<T> Inner<T>` methods own through
// the mod-qualified Impl node, not orphaned to File.
//
// PR #1981 / `bc4a560d` qualified the UNSCOPED bare `impl Inner` target. A GENERIC
// inherent-impl target (`impl<T> Inner<T>`) is a `generic_type` node, which the
// inherent-impl owner walk (ast-helpers `findEnclosingClassInfo`) did not match —
// so the walk returned null and the method got `File -> DEFINES` with NO HAS_METHOD
// (orphaned; invisible to findDanglingEdges). The Impl NODE was already correctly
// mod-qualified (the @name capture drills into the inner type_identifier,
// tree-sitter-queries.ts), so the fix is owner-walk-only and the owner id == the
// node id (`a.Inner` / `b.Inner`) by construction. Holds on both resolver legs
// (structure-phase).
// ---------------------------------------------------------------------------
describe('Rust generic inherent-impl same-tail ownership — distinct nodes (issue #1992)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'rust-nested-tail-collision-generic'),
() => {},
);
}, 60000);
it('owns fa / fb through distinct mod-qualified Impl nodes (generic impl, no orphan)', () => {
const hm = getRelationships(result, 'HAS_METHOD');
const a = hm.find((e) => e.target === 'fa');
const b = hm.find((e) => e.target === 'fb');
// Pre-fix the generic-impl owner walk returns null, so fa/fb orphan to File
// (File -> DEFINES, no HAS_METHOD) — toBeDefined() fails on the pre-fix base.
expect(a, 'HAS_METHOD -> fa').toBeDefined();
expect(b, 'HAS_METHOD -> fb').toBeDefined();
// Owner id is the mod-qualified Impl node, byte-identical to the node id.
expect(a!.rel.sourceId).not.toBe(b!.rel.sourceId);
expect(a!.rel.sourceId).toContain('a.Inner');
expect(b!.rel.sourceId).toContain('b.Inner');
expect(findDanglingEdges(result, ['HAS_METHOD'])).toEqual([]);
});
// R6: scoped-generic `impl<T> crate::c::Scoped<T>` materializes no Impl node, so
// `fd` must NOT own through a phantom `c.Scoped` node — it stays orphaned
// (deferred). Guards against the owner walk minting an owner id for an
// unmaterialized node.
it('does not mint a phantom owner for a scoped-generic impl (fd orphaned, deferred)', () => {
const hm = getRelationships(result, 'HAS_METHOD');
expect(hm.find((e) => e.target === 'fd')).toBeUndefined();
});
});
// Same fixture forced through the WORKER pool (parse-worker.ts). The inherent-impl
// owner walk is shared structure-phase logic, so generic-impl ownership must hold
// on BOTH the sequential and worker paths.
describe('Rust generic inherent-impl ownership — worker path parity (issue #1992)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'rust-nested-tail-collision-generic'),
() => {},
{ workerPoolSize: 2 },
);
}, 120000);
it('genuinely used the worker pool', () => {
expect(result.usedWorkerPool).toBe(true);
});
it('owns fa / fb through distinct mod-qualified Impl nodes on the worker path', () => {
const hm = getRelationships(result, 'HAS_METHOD');
const a = hm.find((e) => e.target === 'fa');
const b = hm.find((e) => e.target === 'fb');
expect(a, 'HAS_METHOD -> fa').toBeDefined();
expect(b, 'HAS_METHOD -> fb').toBeDefined();
expect(a!.rel.sourceId).not.toBe(b!.rel.sourceId);
expect(a!.rel.sourceId).toContain('a.Inner');
expect(b!.rel.sourceId).toContain('b.Inner');
expect(findDanglingEdges(result, ['HAS_METHOD'])).toEqual([]);
});
});
// ---------------------------------------------------------------------------
// F3 (#1992 follow-up) — same-tail generic impls that ALSO share a method name
// must materialize DISTINCT method (Function) nodes.
//
// `${className}.${methodName}` keys the method node id (Rust `fn`s carry the
// `Function` label). Before this fix the bare inherent-impl arm set `className` to
// the bare tail (`Inner`), so two same-tail generic impls under sibling mods that
// each define `fn m` both keyed `Function:…:Inner.m#0` and collapsed onto ONE node
// (graph addNode is first-write-wins) — the second `m` was silently dropped and
// both HAS_METHOD edges targeted the survivor. The owner `classId` was already
// mod-qualified, so HAS_METHOD *sources* stayed distinct, which masked the
// collision (sourceId-only assertions passed). Qualifying `className`
// (`a.Inner` / `b.Inner`) keys `a.Inner.m` / `b.Inner.m`, so both nodes survive
// with distinct ids. Structure-phase, so it holds on both resolver legs and the
// worker path.
// ---------------------------------------------------------------------------
describe('Rust same-tail generic impls with shared method name — distinct nodes (issue #1992)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'rust-generic-impl-same-method-name'),
() => {},
);
}, 60000);
it('materializes two distinct `m` method nodes (no first-write-wins collapse)', () => {
// Pre-fix: only one `m` Function node survives (the second is dropped on the
// colliding id) — length is 1, so toBe(2) fails on the pre-fix base.
const methods = getNodesByLabel(result, 'Function').filter((n) => n === 'm');
expect(methods.length).toBe(2);
});
it('owns each `m` through its own mod-qualified Impl node (distinct source AND target)', () => {
const hm = getRelationships(result, 'HAS_METHOD').filter((e) => e.target === 'm');
expect(hm.length).toBe(2);
// Owner edges were always distinct (classId is mod-qualified)…
expect(hm[0].rel.sourceId).not.toBe(hm[1].rel.sourceId);
const sources = [hm[0].rel.sourceId, hm[1].rel.sourceId].sort();
expect(sources[0]).toContain('a.Inner');
expect(sources[1]).toContain('b.Inner');
// …but the TARGET node collapsed pre-fix — this is the F3 assertion.
expect(hm[0].rel.targetId).not.toBe(hm[1].rel.targetId);
expect(findDanglingEdges(result, ['HAS_METHOD'])).toEqual([]);
});
});
// Same fixture forced through the WORKER pool — the impl owner walk + node-id
// keying is shared structure-phase logic, so the distinct-node guarantee must hold
// on the worker path too (parse-worker.ts mirrors parsing-processor.ts).
describe('Rust same-tail generic impls with shared method name — worker path parity (issue #1992)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'rust-generic-impl-same-method-name'),
() => {},
{ workerPoolSize: 2 },
);
}, 120000);
it('genuinely used the worker pool', () => {
expect(result.usedWorkerPool).toBe(true);
});
it('materializes two distinct `m` method nodes on the worker path', () => {
const methods = getNodesByLabel(result, 'Function').filter((n) => n === 'm');
expect(methods.length).toBe(2);
const hm = getRelationships(result, 'HAS_METHOD').filter((e) => e.target === 'm');
expect(hm.length).toBe(2);
expect(hm[0].rel.sourceId).not.toBe(hm[1].rel.sourceId);
expect(hm[0].rel.targetId).not.toBe(hm[1].rel.targetId);
expect(findDanglingEdges(result, ['HAS_METHOD'])).toEqual([]);
});
});
// ---------------------------------------------------------------------------
// F71 — union declarations resolve as Struct nodes (issue #1934)
//
// A `union` is deliberately captured as a Struct-labeled node (see the
// rationale in languages/rust/query.ts): every resolution gate includes
// Struct but excludes Union, so a Union-labeled node would be an unresolvable
// orphan. These pipeline-level assertions pin BOTH that the node is labeled
// Struct AND that it is genuinely resolvable (the union literal is a real
// constructor).
// ---------------------------------------------------------------------------
describe('Rust union resolution (issue #1934 F71)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'rust-union'), () => {});
}, 60000);
it('captures the union as a Struct node named MyUnion (not Union)', () => {
expect(getNodesByLabel(result, 'Struct')).toContain('MyUnion');
expect(getNodesByLabel(result, 'Union')).toEqual([]);
});
it('resolves the union literal MyUnion { .. } as a CALLS edge to the Struct', () => {
const calls = getRelationships(result, 'CALLS');
const ctor = calls.find((e) => e.source === 'make' && e.target === 'MyUnion');
expect(ctor).toBeDefined();
expect(ctor!.targetLabel).toBe('Struct');
});
});
// ---------------------------------------------------------------------------
// F72 — macro invocations resolve to their definition (issue #1934)
//
// A `macro_rules! greet` invocation (`greet!(...)`) resolves via the
// MacroRegistry to the Macro node, emitting a USES edge — NEVER a CALLS
// edge, and NEVER binding to a same-named free function `fn greet`. Macro
// resolution is owned by scope-resolution (the legacy DAG, removed in #942,
// did not resolve macros).
// ---------------------------------------------------------------------------
describe('Rust macro resolution (issue #1934 F72)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'rust-macro'), () => {});
}, 60000);
it('materializes both a Macro and a same-named Function node', () => {
expect(getNodesByLabel(result, 'Macro')).toContain('greet');
expect(getNodesByLabel(result, 'Function')).toContain('greet');
});
it('resolves greet!(..) as a USES edge to the Macro (not the Function)', () => {
const uses = getRelationships(result, 'USES');
const macroUse = uses.find((e) => e.source === 'run' && e.target === 'greet');
expect(macroUse).toBeDefined();
expect(macroUse!.targetLabel).toBe('Macro');
});
it('does NOT emit a CALLS edge from the macro invocation to fn greet', () => {
const calls = getRelationships(result, 'CALLS');
// The only run -> greet CALLS edge is the genuine fn call; it must target
// the Function, and there must be exactly one (the macro adds no CALLS).
const greetCalls = calls.filter((e) => e.source === 'run' && e.target === 'greet');
expect(greetCalls.length).toBe(1);
expect(greetCalls[0].targetLabel).toBe('Function');
// And no CALLS edge anywhere targets the Macro node.
expect(calls.every((e) => e.targetLabel !== 'Macro')).toBe(true);
});
});