GitNexus/gitnexus/test/integration/resolvers/rust.test.ts
Gergő Magyar 9c24e3459e
Some checks are pending
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
fix(rust): qualify items by their enclosing mod chain so same-named ones stay distinct (#2742) (#2745)
* fix(rust): let the qualified-call filter see inline modules

The negative filter added in #2741 builds its set of known module names from
FILE PATHS, so an inline `mod x { … }` — which appears in no path — was absent
from it. Every module-qualified call into an inline module was therefore
rejected before any candidate channel ran, which is a hole in that optimisation
rather than in the resolution logic it guards.

The per-pass index now unions the file-derived names with inline module names
taken from the scope model: a `mod` declaration binds a `Namespace` def locally
in the declaring scope, and that binding is the only place an inline module's
name exists. Collected in the same walk that already builds the module → scope
map, so it costs no extra pass.

Found while fixing #2742, where a correctly resolved call into `mod inner { … }`
still could not reach its target.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J5nWP4BFeBSjbQ3uqAjtxV

* fix(scope-resolution): try the namespace-prefixed node key before the bare one

`resolveDefGraphId` looked up the plain `qualifiedName` key first and only
retried with the `namespacePrefix`-qualified key afterwards. For the defs that
carry a prefix the qualified name is a bare TAIL, so the plain key happily
matched a same-named item at a different namespace depth in the same file and
returned it before the more specific retry was ever reached.

The namespace-prefixed key is strictly the more specific of the two, so it is
now tried first. Where no such node exists the lookup falls through to exactly
the previous order, which keeps the #1982 behaviour this retry was added for.

Without this, a call into `mod inner { fn dispatch }` resolved to the correct
definition and then mapped it onto the crate-root `fn dispatch` node — the
self-loop #2742 describes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J5nWP4BFeBSjbQ3uqAjtxV

* fix(rust): qualify items by their enclosing mod chain so same-named ones stay distinct (#2742)

Node identity is `<label>:<file>:<qualifiedName>` and carried no module path, so
an inline `mod inner { fn dispatch }` and a crate-root `fn dispatch` in the same
file collapsed onto `Function:<file>:dispatch`, first-wins. Resolution already
picked the right definition — the target simply was not representable, so a
correct resolution still rendered as a self-loop and `impact` reported the real
callee as unreached.

The mechanism already existed: `qualifyRustImplTargetByModScope` has walked
`mod_item` ancestors for impl targets since #1982. Generalised to
`qualifyByEnclosingModScope` and applied to free items, so
`mod inner { fn dispatch }` becomes `Function:<file>:inner.dispatch`. Keyed
purely on the `mod_item` node type, exactly as the impl qualifier already was,
so it is a no-op for every language whose grammar has no such node.

Two constraints found by tests rather than by reading, both now encoded:

  - The helper normalised `::` to `.` unconditionally. With no enclosing `mod`
    that rewrote a top-level `impl a::Inner` from `a::Inner` to `a.Inner` and
    moved its node id away from the one the HAS_METHOD owner edge emits,
    breaking the #1975 scoped-impl ownership. It now returns raw text untouched
    when there are no mod segments, which also makes the change strictly
    additive for every id that has no enclosing module.

  - Qualification is scoped to items with no enclosing class/impl. A method
    already carries its owner's name, and that owner's id is mod-scoped by the
    impl qualifier, so qualifying the method again breaks the same byte-for-byte
    agreement. Same-named methods on same-named types in sibling modules
    therefore still collapse — a narrower residual than the free-item case fixed
    here, and one belonging to the owner edge rather than to this path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J5nWP4BFeBSjbQ3uqAjtxV

* fix(storage): bump schema versions for the mod-qualified Rust node ids (#2742)

`INCREMENTAL_SCHEMA_VERSION` 24 -> 25 and `SCHEMA_BUMP` 31 -> 32.

Node ids change for every Rust item inside any `mod` block, and
`#[cfg(test)] mod tests` makes that close to every Rust repository. A pre-v25
index therefore holds ids an incremental top-up cannot reconcile — the old nodes
would simply be stranded — so the reuse gate has to force a full re-analyze. The
qualified name is computed in the parse worker, so a warm parse cache would
likewise replay the old unqualified ids and keep the collapse.

This branch originally claimed v24; #2708 took that number and merged first, so
it is renumbered to v25 here. That is exactly the collision the v29 note in
parse-cache.ts warns about, and re-checking against origin/main at rebase time
rather than at branch time is what caught it. #2708 did not touch `SCHEMA_BUMP`,
so 32 is free.

The version-pin test moves with the bump by design, including the new pre-v25
row in the reuse-gate table.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J5nWP4BFeBSjbQ3uqAjtxV

* fix(rust): stop mod-qualifying container ids while their owner edges stay bare (#2745 review)

#2742 re-keyed Rust node ids by the enclosing `mod` chain. The mint moved; the
owner-edge anchor did not. `findEnclosingClassInfo` mints a member's owner id
from the container's BARE `nameNode.text` and only follows a qualified shape when
the provider sets `classExtractor.qualifiedNodeId`, which Rust does not.

So every `struct` / `trait` / `enum` / `impl` declared directly inside a `mod`
got a node id that none of its member edges pointed at. Five lines of idiomatic
Rust were enough:

    pub mod engine { pub struct Config { pub retries: usize } }

    NODE     Struct:src/lib.rs:engine.Config
    DANGLING HAS_PROPERTY Struct:src/lib.rs:Config -> Property:src/lib.rs:Config.retries

The rows are discarded by the IGNORE_ERRORS COPY retry, so the struct silently
lost every field. A trait impl inside a `mod` additionally dropped its
METHOD_IMPLEMENTS edge outright.

The same gap put `impl a::Inner` inside a `mod` back on the #1975 rake that
`qualifyByEnclosingModScope`'s own docblock warns about. The impl-target branch
deliberately fires only for an UNSCOPED `type_identifier`; the new gate had no
such restriction and picked up the scoped targets that branch had just excluded,
minting `Impl:<file>:outer.a.Inner` against an anchor still reading
`Impl:<file>🅰️:Inner`.

The member side was already excluded via `!enclosingClassInfo`. This adds the
owner side, gated on `MEMBER_OWNER_NODE_TYPES` — derived from
`CLASS_CONTAINER_TYPES`, which is already the single source of "this node type
owns member edges" and already carries an INVARIANT note binding it to
`CONTAINER_TYPE_TO_LABEL`. A language adding a container therefore cannot gain a
mismatched id shape here without also failing that invariant. Keyed purely on
tree-sitter node types, so no language name enters shared ingestion.

`union_item` is listed too: its fields are captured as Property but it is not a
recognized owner, so they carry no HAS_PROPERTY edge and cannot dangle — it is
here so a union's id keeps the same shape as the struct beside it.

Containers still collapse across sibling modules, exactly as before this fix.
That residual belongs to the owner edge, and is not worked around here.

Regression tests use the UNFILTERED `findDanglingEdges(result)`. Every other
dangling assertion in `rust.test.ts` passes `['HAS_METHOD']`, which is precisely
why the HAS_PROPERTY breakage shipped with a green suite. They assert the NODE
id rather than only the edge's anchor, because the anchor was already bare while
the bug was live — an edge-only assertion passes in both builds. All four fail
when the new gate clause alone is reverted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019GvVxWt1ShhEP8CiMj3b6Z

* fix(rust): resolve modules nested inside an inline mod (#2745 review)

The #2730 self-loop survived one `mod` deeper:

    pub mod outer {
        pub mod tools { pub fn dispatch() {} }
        pub fn dispatch() { tools::dispatch(); }
    }

    CALLS outer.dispatch -> outer.dispatch          <- the #2730 symptom
    NODE  outer.tools.dispatch                      <- correct target, unlinked

Two gates were blind to a nested inline module, so the hook refused and the shared
lexical tier bound the call to the enclosing same-name `dispatch`:

`knownModuleNames` was collected by walking `moduleScopeByFile`, which maps a file
to its ROOT `Module` scope only. A `mod` nested inside an inline `mod` binds in the
parent module's scope, so the walk saw depth-1 inline modules and missed every
nested one — `tools` never entered the set and the negative filter rejected the
qualifier before any candidate ran.

`declaresSubmodule` had the same root-only assumption, so even with the name known
the candidate `outer::tools` was never yielded.

Both now read the def index. Names come from every `Namespace` def; inline module
PATHS are derived from the members' `namespacePrefix` rather than from the `mod`
defs, because a `mod` def carries no nesting information of its own — inside
`mod outer { mod tools { … } }` the inner def is `qualifiedName: 'tools'` with NO
`namespacePrefix`, while every def within it is stamped `outer.tools`. A
`Namespace` scope also owns its OWN def rather than its children's, so the scope
tree cannot answer this either: the `mod outer` scope lists `outer`, never `tools`.

Restricted to non-empty prefixes, so this stays a DECLARATION check. Including
file-derived modules would let an undeclared or `cfg`-gated file on disk outrank a
real `use` binding — the regression #2741's review already fixed once. File-backed
submodules therefore keep going through the binding check.

A module with no defs at all is absent from the set, which is harmless: it has no
member for a qualified call to resolve to.

Cost is one pass over an already-resident def index, memoized per resolution pass
on the existing WeakMap — the same order of work as the binding walk it replaces,
and it subsumes it. `isLocalNamespaceBinding` was going to single-source the
duplicated "locally declared submodule" predicate the review flagged; deriving
paths from members removed the second copy outright instead.

Regression fixture covers depth 2 and depth 3, so the fix is depth-agnostic rather
than depth-2 special-cased.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019GvVxWt1ShhEP8CiMj3b6Z

* fix(rust): let an imported type outrank a same-named module (#2745 review)

Widening the negative filter to inline `mod` names let a type-qualified call
through whenever a module happened to share the type's name. The
crate-root-relative candidate then captured it:

    // src/lib.rs
    pub mod Buffer { pub fn with_capacity() -> usize { 111 } }
    // src/b.rs
    use crate::c::Buffer;                        // the real target lives in c.rs
    pub fn call() -> usize { Buffer::with_capacity() }

    base: (no CALLS edge — unresolved)
    PR:   CALLS b::call -> Function:src/lib.rs:Buffer.with_capacity   <- fabricated

`ids.ts` states the doctrine this broke: a missing edge is the correct failure
direction for a graph whose consumers include `impact`; a fabricated caller is not.
The base produced the missing edge and the PR produced the fabricated one.

That third candidate is the loosest of the three — a guess at a crate-root-relative
path the caller never wrote, kept for 2015-edition style. In Rust 2018 a bare first
segment resolves in the CALLER's module, so a local binding for that segment
settles the question: it is now skipped when the head names anything non-module in
the caller's own module. Candidates 1 and 2 are untouched, and they run first, so
the legitimate `use crate::tools;` path is unaffected.

The binding lookup goes through `lookupBindingsAt`. A first attempt read
`Scope.bindings` directly and the guard never fired: a `use` binding is finalize
OUTPUT and absent from the scope's own local table, which is exactly the
imported-type case being guarded. Contract I8 in `contract/scope-resolver.ts`
requires that channel anyway.

The regression test asserts the forbidden TARGET rather than an empty edge set, and
separately asserts the module member still exists as a node — otherwise the test
would pass just as well if the call went unresolved for some unrelated reason, or
if the module node disappeared entirely.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019GvVxWt1ShhEP8CiMj3b6Z

* fix(scope-resolution): try the namespace-prefixed name on the TAGGED keys too (#2745 review)

`resolveDefGraphId` gained a namespace-prefixed retry for the plain qualified key
in this PR, but the five tagged keys above it — template constraints, parameter
types, parameter shape, arity, template arguments — kept composing from the bare
`qualifiedName`.

For a namespace- or `mod`-qualified def those keys are simply dead:
`node-lookup.ts` registers them under the QUALIFIED name (`inner.dispatch#0`)
while this side built `dispatch#0`. The keys exist to separate overloads, so a
mod-scoped overload set was relying on whichever later key happened to catch it.

Verified as a miss rather than a mis-hit before changing anything — an end-to-end
run with a crate-root decoy of the same name and arity binds correctly — so this
is hygiene, not a live bug. Worth doing while the code is open rather than leaving
five keys dead and the behaviour dependent on fallback order.

Both name forms now go through one `lookupTagged` helper, most specific first, so
a sixth tagged key cannot be added with the bare form only. That also removes the
five hand-repeated `qualifiedKey(...)` / `nodeLookup.get(...)` pairs.

Also pins the C++ `EXTENDS` retarget this PR's reorder produces.
`cpp-two-phase-dependent-base-cross-ns-deep` declares a global `Inner` decoy
alongside `ns:🅰️🅱️:Inner`; the base's `qualifiedName` is a bare `Inner` with the
path on `namespacePrefix`, so only the prefixed key separates them, and only if it
runs first. The improvement was riding unasserted in a Rust-scoped PR.

The captures golden covers every `rust-*` fixture, so the three fixtures added by
this review series drift it; regenerated with UPDATE_GOLDEN=1.

Verified: 785 tests across cpp / csharp / rust resolvers and the
callable-id-lockstep unit test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019GvVxWt1ShhEP8CiMj3b6Z

* fix(rust): keep a mod declared inside a fn from hoisting above the callable (#2745 review)

`fn wrapper() { mod helper { fn dispatch } }` minted
`Function:<file>:helper.wrapper.dispatch@2:8` — the mod segment composed OUTSIDE
the enclosing-callable prefix, inverting the real nesting.

Nothing dangled: the `@line:col` suffix already makes a function-local callable's
id unique, which is also why the mod segment adds no identity in this position. The
path simply read as a lie about the source. It is now skipped rather than
reordered — interleaving two qualifier passes to fix the order would be real
machinery for a shape whose ids are already unique.

Also folds in the three documentation and structure findings from the same review:

- The 4-clause gate is extracted to a named `qualifiesByEnclosingModScope`, matching
  the two conditions directly above it in the same function, which were already
  named consts.
- `qualifyByEnclosingModScope`'s docblock documented only the impl-target contract
  even though the generalized name has had a second, looser caller since #2742. It
  now states both, and says which gate belongs to which — that gap is what let the
  #1975 scoped-impl regression through in the first place.
- The "cheap rejection BEFORE any index work" comment was no longer true:
  `passIndexFor` walks the def index on its first call in a pass. Corrected rather
  than left to mislead the next reader into thinking the filter is free. What it
  still buys — skipping the per-site candidate search, the part that scales with
  the workspace — is stated instead.

Verified: 279 tests across the Rust resolver suite and the Rust scope-resolution
unit tests. Captures golden regenerated for the extended fixture.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019GvVxWt1ShhEP8CiMj3b6Z

* test(storage): move main's SCHEMA_BUMP pin to 32 for the mod-qualified ids (#2745 review)

`#2736` added a pin asserting `SCHEMA_BUMP === 31` on main, which arrived on this
branch through the merge of main while `0062a5c2` had already bumped the constant
to 32. Neither side conflicted textually — the pin and the constant live in
different files — so the merge was clean and the test failed instead.

That is the pin working as designed: it exists so a bump cannot ride along
unnoticed, and this is the fifth time a SCHEMA_BUMP collision has been caught by a
guard rather than by review. Updated to 32 with the reason recorded inline.

`INCREMENTAL_SCHEMA_VERSION` needs no second bump: 25 was introduced by this
unmerged branch, so no released index carries it, and its own pin in
`call-summary-schema-version.test.ts` is already consistent.

Verified: 119 tests across the parse-cache, schema-version, incremental-orchestration
and the two identity suites that arrived with the merge.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019GvVxWt1ShhEP8CiMj3b6Z

* test(bench): rebaseline the Rust capture fingerprint for the three new fixtures (#2745 review)

CI caught what I missed:

    [scope-capture --check] FAIL: rust: capture fingerprint drift
      (got 05acbaca..., expected 90fda086...)   fixture_count 202

`bench/scope-capture` fingerprints the whole `rust-*` fixture corpus, so the three
fixtures added by this review series drift it. I rebaselined the
`rust-captures-golden` snapshot and stopped there — a new fixture is a call site of
BOTH, and updating only one is how this reached CI red.

This is the same class as PR #2743's headline finding, from the other direction: an
id-shape change makes every synthetic corpus a call site, and the author fixed the
unit-test fixture and missed the bench. Here it is a fixture-count change rather
than an id-shape change, and the review that flagged the #2743 lead as "REFUTED,
bench/ has no Rust node-id corpus" was right about node ids and wrong about the
corpus fingerprint. Noted for the next author in the baseline entry itself.

Verified as pure corpus growth rather than a capture-logic shift: removing ONLY the
three new fixture directories and re-running reproduces the prior fingerprint
exactly (196 fixtures, capture_groups_fp 3432), and restoring them gives the new
one (202, 3556). `emitRustScopeCaptures` is untouched by this series. Scaling 1.022
local / 1.057 CI, well inside the 1.5 budget.

`bench/python-scope` globs `python-*` only and is unaffected; no other bench walks
the Rust corpus. `--check` now PASSes for all 15 languages.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019GvVxWt1ShhEP8CiMj3b6Z

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 17:08:00 +01:00

3008 lines
123 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,
type RelEdge,
} 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']);
});
});
// ---------------------------------------------------------------------------
// #2604: trait-object (&dyn Trait) receiver dispatch
// ---------------------------------------------------------------------------
describe('Rust dyn trait-object dispatch (#2604)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'rust-dyn-trait-object'), () => {});
}, 60000);
it('detects Impl1 struct and Behaviour trait', () => {
expect(getNodesByLabel(result, 'Struct')).toContain('Impl1');
expect(getNodesByLabel(result, 'Trait')).toContain('Behaviour');
});
it('emits exactly one CALLS edge from calls_via_dyn(b: &dyn Behaviour) to trait_target', () => {
const calls = getRelationships(result, 'CALLS');
const dynCalls = calls.filter(
(c) => c.source === 'calls_via_dyn' && c.target === 'trait_target',
);
expect(dynCalls.length).toBe(1);
});
});
// ---------------------------------------------------------------------------
// 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);
});
});
// ---------------------------------------------------------------------------
// #2514: duplicate type names must stay ambiguous regardless of duplicate
// count or file order. The range-binding prepass used Map presence as an
// ambiguity toggle (has→delete / else→set), so a 3rd same-named definition
// re-inserted a resolvable — and wrong — cross-file type (the last-scanned
// file's). The fix latches ambiguity in a separate Set: once a name has two
// definitions it never resolves again.
//
// Observable: for-loop `for item in make() { item.save(); }` where each
// `make()` (or each `Config` field) lives in its own file with no `use`
// import, so the receiver type can only come from the global range-binding
// map. A cross-file `save`/`run` CALLS edge means the name resolved.
// ---------------------------------------------------------------------------
describe('Rust duplicate-name ambiguity latch (#2514)', () => {
// Cross-file receiver-method CALLS edges emitted from the fixture driver fn.
const receiverCalls = (result: PipelineResult, source: string, method: string): RelEdge[] =>
getRelationships(result, 'CALLS').filter((c) => c.source === source && c.target === method);
// --- return-type registry (allReturnTypes) ---
describe('two same-named fns with different return types', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'rust-dup-return-2'), () => {});
}, 60000);
it('suppresses cross-file return-type inference — item.save() does not resolve', () => {
expect(receiverCalls(result, 'drive', 'save')).toEqual([]);
});
});
describe('three same-named fns with different return types', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'rust-dup-return-3'), () => {});
}, 60000);
it('still suppresses inference — the 3rd duplicate does not restore a binding', () => {
expect(receiverCalls(result, 'drive', 'save')).toEqual([]);
});
});
describe('three same-named fns, permuted input file order', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'rust-dup-return-3-reordered'),
() => {},
);
}, 60000);
it('resolution is independent of file order — still no edge', () => {
expect(receiverCalls(result, 'drive', 'save')).toEqual([]);
});
});
describe('unique fn still infers normally (over-suppression guard)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'rust-unique-return'), () => {});
}, 60000);
it('resolves item.save() to User#save via cross-file return type', () => {
const edges = receiverCalls(result, 'drive', 'save');
expect(edges.length).toBe(1);
expect(edges[0]).toMatchObject({ source: 'drive', target: 'save', targetLabel: 'Function' });
expect(edges[0].targetFilePath).toContain('t_a.rs');
});
});
// --- field-type registry (allFieldTypes) via struct destructuring ---
describe('two same-named structs with conflicting field types', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'rust-dup-fields-2'), () => {});
}, 60000);
it('suppresses global field-type inference — db.run() does not resolve', () => {
expect(receiverCalls(result, 'use_it', 'run')).toEqual([]);
});
});
describe('three same-named structs with conflicting field types', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'rust-dup-fields-3'), () => {});
}, 60000);
it('still suppresses field inference — the 3rd duplicate does not restore', () => {
expect(receiverCalls(result, 'use_it', 'run')).toEqual([]);
});
});
});
// ---------------------------------------------------------------------------
// #2514 follow-up: when a `use` import disambiguates one of several same-named
// definitions, the type must resolve to THAT definition (like the compiler),
// not stay ambiguous. The bare-name map is ambiguous, but the call site's
// import pins a single defining file, so range-binding reads that definition's
// FULL return/field type — recovering generic element types the bare-name map
// would have lost. Genuinely-ambiguous (no-import) duplicates still stay
// unresolved (covered by the #2514 block above).
// ---------------------------------------------------------------------------
describe('Rust import-disambiguated duplicate resolution (#2514 follow-up)', () => {
describe('for-loop over an imported generic-returning duplicate fn', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'rust-import-dup-return'), () => {});
}, 60000);
it('resolves item.save() to the imported definition in t_b (Repo), not ambiguous', () => {
const edges = getRelationships(result, 'CALLS').filter(
(c) => c.source === 'drive' && c.target === 'save',
);
expect(edges.length).toBe(1);
expect(edges[0]).toMatchObject({ source: 'drive', target: 'save', targetLabel: 'Function' });
expect(edges[0].targetFilePath).toContain('t_b.rs');
});
});
describe('struct destructuring of an imported duplicate struct', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'rust-import-dup-fields'), () => {});
}, 60000);
it('resolves db.run() to the imported definition in t_b (DbB) via its field type', () => {
const edges = getRelationships(result, 'CALLS').filter(
(c) => c.source === 'use_it' && c.target === 'run',
);
expect(edges.length).toBe(1);
expect(edges[0]).toMatchObject({ source: 'use_it', target: 'run', targetLabel: 'Function' });
expect(edges[0].targetFilePath).toContain('t_b.rs');
});
});
describe('aliased import (`use t_b::make as mk`) still resolves the definition', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'rust-import-alias-return'), () => {});
}, 60000);
it('keys on the definition name, not the alias — item.save() resolves to t_b (Repo)', () => {
const edges = getRelationships(result, 'CALLS').filter(
(c) => c.source === 'drive' && c.target === 'save',
);
expect(edges.length).toBe(1);
expect(edges[0]).toMatchObject({ source: 'drive', target: 'save', targetLabel: 'Function' });
expect(edges[0].targetFilePath).toContain('t_b.rs');
});
});
describe('single glob import (`use t_b::*`) resolves the one globbed definition', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'rust-import-glob-return'), () => {});
}, 60000);
it('resolves item.save() to t_b (Repo) via the one glob-target that defines it', () => {
const edges = getRelationships(result, 'CALLS').filter(
(c) => c.source === 'drive' && c.target === 'save',
);
expect(edges.length).toBe(1);
expect(edges[0]).toMatchObject({ source: 'drive', target: 'save', targetLabel: 'Function' });
expect(edges[0].targetFilePath).toContain('t_b.rs');
});
});
describe('two glob imports that both export the name stay ambiguous', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'rust-import-glob-ambiguous'),
() => {},
);
}, 60000);
it('leaves item.save() unresolved when two `use x::*` both define make', () => {
const edges = getRelationships(result, 'CALLS').filter(
(c) => c.source === 'drive' && c.target === 'save',
);
expect(edges).toEqual([]);
});
});
describe('a local definition shadows a glob import', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'rust-import-glob-local-shadows'),
() => {},
);
}, 60000);
it('resolves item.save() to the local make in main.rs, not the glob target', () => {
const edges = getRelationships(result, 'CALLS').filter(
(c) => c.source === 'drive' && c.target === 'save',
);
expect(edges.length).toBe(1);
expect(edges[0]).toMatchObject({ source: 'drive', target: 'save', targetLabel: 'Function' });
expect(edges[0].targetFilePath).toContain('main.rs');
});
});
});
// ---------------------------------------------------------------------------
// #2730 — a module-qualified call must not bind to a same-named local fn
//
// `tools::dispatch(...)` is captured as a FREE call named `dispatch`. Before
// the fix the qualifier was discarded, so the scope-chain walk bound the bare
// tail to the ENCLOSING same-named wrapper and emitted a self-loop — the real
// cross-module edge never existed, and `impact` on the callee reported the
// production caller as absent (risk LOW, 0 affected processes) while still
// labelling itself `epistemic: "exact"`.
// ---------------------------------------------------------------------------
describe('Rust module-qualified free calls (#2730)', () => {
describe('flat src/ layout', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'rust-2730-samename-wrapper'),
() => {},
);
}, 60000);
it('binds tools::dispatch to tools.rs, not to the same-named wrapper (use ::{self})', () => {
const edges = getRelationships(result, 'CALLS').filter(
(c) => c.sourceFilePath === 'src/sched.rs' && c.source === 'dispatch',
);
expect(edges.length).toBe(1);
expect(edges[0]).toMatchObject({
source: 'dispatch',
target: 'dispatch',
targetFilePath: 'src/tools.rs',
});
expect(edges[0].rel.reason).toBe('import-resolved');
});
it('binds tools::dispatch through a bare `mod tools;` with no use binding', () => {
const edges = getRelationships(result, 'CALLS').filter(
(c) => c.sourceFilePath === 'src/main.rs' && c.source === 'dispatch',
);
expect(edges.length).toBe(1);
expect(edges[0]).toMatchObject({ target: 'dispatch', targetFilePath: 'src/tools.rs' });
});
it('binds a fully path-qualified crate::tools::dispatch to tools.rs', () => {
const edges = getRelationships(result, 'CALLS').filter((c) => c.source === 'crate_qualified');
expect(edges.length).toBe(1);
expect(edges[0]).toMatchObject({ target: 'dispatch', targetFilePath: 'src/tools.rs' });
});
it('leaves genuinely unqualified calls on the lexical scope chain', () => {
const edges = getRelationships(result, 'CALLS').filter((c) => c.source === 'run');
expect(edges.length).toBe(1);
expect(edges[0]).toMatchObject({ target: 'dispatch', targetFilePath: 'src/sched.rs' });
expect(edges[0].rel.reason).toBe('local-call');
});
});
describe('cargo workspace crates/<name>/src layout', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'rust-2730-crate-layout'), () => {});
}, 60000);
it('resolves crate::tools through the use edge when no sibling file matches the path', () => {
const edges = getRelationships(result, 'CALLS').filter(
(c) => c.sourceFilePath === 'crates/noob/src/agent/sched.rs' && c.source === 'dispatch',
);
expect(edges.length).toBe(1);
expect(edges[0]).toMatchObject({
target: 'dispatch',
targetFilePath: 'crates/noob/src/tools/mod.rs',
});
});
it('keeps the unqualified sibling call local', () => {
const edges = getRelationships(result, 'CALLS').filter((c) => c.source === 'execute');
expect(edges.length).toBe(1);
expect(edges[0].targetFilePath).toBe('crates/noob/src/agent/sched.rs');
});
});
});
// ---------------------------------------------------------------------------
// #2730 — path resolution over the module tree (rustc semantics)
//
// The leading segments of a path name MODULES, resolved in the type namespace,
// so a same-named `fn` (value namespace) can never shadow them. `crate::`,
// `self::` and `super::` are prefix transforms on the calling module, and the
// final segment is a member of the resolved module — including members it only
// re-exports.
// ---------------------------------------------------------------------------
describe('Rust qualified paths resolve against the module tree (#2730)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'rust-2730-gaps'), () => {});
}, 60000);
it('resolves a multi-segment path a::b::dispatch() past a same-named local fn', () => {
const edges = getRelationships(result, 'CALLS').filter((c) => c.source === 'nested');
expect(edges.length).toBe(1);
expect(edges[0]).toMatchObject({ target: 'dispatch', targetFilePath: 'src/a/b.rs' });
});
it('resolves super::dispatch() to the parent module, not the caller file', () => {
const edges = getRelationships(result, 'CALLS').filter((c) => c.source === 'go');
expect(edges.length).toBe(1);
expect(edges[0]).toMatchObject({ target: 'dispatch', targetFilePath: 'src/a/mod.rs' });
});
it('ignores a function-local fn of the same name in the target module (H3)', () => {
const edges = getRelationships(result, 'CALLS').filter((c) => c.source === 'via_reexport');
expect(edges.length).toBe(1);
expect(edges[0]).toMatchObject({ target: 'dispatch', targetFilePath: 'src/tools.rs' });
});
it('follows a `pub use` re-export through to the original definition', () => {
const edges = getRelationships(result, 'CALLS').filter((c) => c.source === 'via_reexport');
expect(edges.length).toBe(1);
expect(edges[0]).toMatchObject({ target: 'dispatch', targetFilePath: 'src/tools.rs' });
});
it('does not bind a leading :: path into the local module of the same name', () => {
// `::tools::dispatch()` names an EXTERN crate. GitNexus does not model extern
// crates, so the qualified tier must refuse; whatever the unchanged lexical
// tier then does is out of scope here. What must NOT happen is this binding
// to the local `tools` module as though the `::` were absent.
const edges = getRelationships(result, 'CALLS').filter((c) => c.source === 'via_extern');
expect(edges.filter((c) => c.targetFilePath === 'src/tools.rs')).toEqual([]);
});
it('does not treat a private `use` as a re-export', () => {
const edges = getRelationships(result, 'CALLS').filter((c) => c.source === 'via_private');
expect(edges).toEqual([]);
});
it('keeps every qualified target distinct from the crate-root fn of the same name', () => {
const targets = getRelationships(result, 'CALLS')
.filter((c) => ['nested', 'go', 'via_reexport'].includes(c.source))
.map((c) => c.targetFilePath)
.sort();
expect(targets).toEqual(['src/a/b.rs', 'src/a/mod.rs', 'src/tools.rs']);
});
});
// ---------------------------------------------------------------------------
// #2730 review H1 — module identity carries the crate.
//
// A cargo workspace routinely gives several members the same internal module
// name. With identity by path segments alone, `crates/alpha/src/tools.rs` and
// `crates/beta/src/tools.rs` were the SAME module: where only one defined the
// member the call bound across crates, and where both did the lookup tied and
// refused — handing the site back to the lexical walk that reinstates the very
// self-loop #2730 is about. Rust has no implicit cross-crate paths, so two
// modules in different crates are never the same module.
// ---------------------------------------------------------------------------
describe('Rust qualified calls stay inside their own crate (#2730 review H1)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'rust-2730-workspace-crates'), () => {});
}, 60000);
it('binds alpha::sched::dispatch to alpha tools, not beta', () => {
const edges = getRelationships(result, 'CALLS').filter(
(c) => c.sourceFilePath === 'crates/alpha/src/sched.rs' && c.source === 'dispatch',
);
expect(edges.length).toBe(1);
expect(edges[0]).toMatchObject({
target: 'dispatch',
targetFilePath: 'crates/alpha/src/tools.rs',
});
});
it('binds beta::sched::dispatch to beta tools, not alpha', () => {
const edges = getRelationships(result, 'CALLS').filter(
(c) => c.sourceFilePath === 'crates/beta/src/sched.rs' && c.source === 'dispatch',
);
expect(edges.length).toBe(1);
expect(edges[0]).toMatchObject({
target: 'dispatch',
targetFilePath: 'crates/beta/src/tools.rs',
});
});
it('emits no self-loop in either crate', () => {
const selfLoops = getRelationships(result, 'CALLS').filter(
(c) =>
c.source === 'dispatch' && c.target === 'dispatch' && c.sourceFilePath === c.targetFilePath,
);
expect(selfLoops).toEqual([]);
});
});
// ---------------------------------------------------------------------------
// #2730 review H2 — a `use` binding must name a MODULE, not a type.
//
// Import resolution deliberately strips a trailing symbol segment when probing
// for a file, so `use crate::client::ClientBuilder;` also resolves to
// `client/mod.rs`. Taking that at face value made the imported TYPE look like
// the module `client`, and `ClientBuilder::new()` bound to an unrelated
// module-level `new` instead of the associated function.
// ---------------------------------------------------------------------------
describe('Rust type-qualified calls are not treated as module paths (#2730 review H2)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'rust-2730-type-qualified'), () => {});
}, 60000);
it('does not bind ClientBuilder::new() to the module-level new', () => {
const edges = getRelationships(result, 'CALLS').filter(
(c) => c.source === 'build' && c.target === 'new',
);
const moduleLevel = edges.filter((c) => c.targetLabel === 'Function');
expect(moduleLevel).toEqual([]);
});
it('still resolves a genuine module qualifier', () => {
const edges = getRelationships(result, 'CALLS').filter((c) => c.source === 'via_module');
expect(edges.length).toBe(1);
expect(edges[0]).toMatchObject({ target: 'new', targetFilePath: 'src/client/mod.rs' });
});
});
// ---------------------------------------------------------------------------
// #2742 — same-named items at different module depths are distinct nodes.
//
// Node identity was `<label>:<file>:<qualifiedName>` with no module path, so an
// inline `mod inner { fn dispatch }` and a crate-root `fn dispatch` in the same
// file collapsed onto one node, first-wins. Resolution already picked the right
// definition; the target simply was not representable, so a correct resolution
// still rendered as a self-loop and `impact` reported the real callee unreached.
// ---------------------------------------------------------------------------
describe('Rust items are qualified by their enclosing mod chain (#2742)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'rust-2730-gaps'), () => {});
}, 60000);
it('gives an inline-mod member its own node, distinct from the crate-root item', () => {
const ids: string[] = [];
result.graph.forEachNode((n) => {
if (n.label === 'Function' && n.properties.name === 'dispatch') ids.push(n.id);
});
expect(ids).toContain('Function:src/main.rs:inner.dispatch');
expect(ids).toContain('Function:src/main.rs:dispatch');
});
it('resolves inner::dispatch() to the inline member, not back to the caller', () => {
const edges = getRelationships(result, 'CALLS').filter(
(c) => c.sourceFilePath === 'src/main.rs' && c.source === 'dispatch',
);
expect(edges.length).toBe(1);
expect(edges[0].rel.targetId).toBe('Function:src/main.rs:inner.dispatch');
});
// Fixture-wide rather than pinned to one edge, because the #2730 symptom is a
// CLASS of mis-binding: any qualified call whose leading segment names an
// inline module can land back on the enclosing same-name item. The preceding
// test pins the one target we know regressed; this one fails if the same fault
// reappears through any other path in the fixture. `rust-2730-gaps` contains no
// self-recursive function, so an empty result is the correct invariant — adding
// one to the fixture means narrowing this filter, not deleting the test.
it('emits no self-loop for the inline-mod wrapper', () => {
const selfLoops = getRelationships(result, 'CALLS').filter(
(c) => c.rel.sourceId === c.rel.targetId,
);
expect(selfLoops).toEqual([]);
});
});
// ---------------------------------------------------------------------------
// #2745 review — mod-qualifying a node id must not orphan its member edges.
//
// The mint (parse-worker) and the owner-edge anchor (findEnclosingClassInfo) are
// computed independently. #2742 re-keyed ids by the enclosing `mod` chain but the
// anchor is minted from the container's BARE name and only follows a qualified
// shape when the provider sets `classExtractor.qualifiedNodeId`, which Rust does
// not. Every `struct` / `trait` / `enum` / `impl` inside a `mod` therefore had a
// node id no member edge pointed at, and the rows were dropped at COPY time.
//
// Guarded here with the UNFILTERED findDanglingEdges. Every other dangling
// assertion in this file passes `['HAS_METHOD']`, which is exactly why the
// HAS_PROPERTY breakage shipped green.
// ---------------------------------------------------------------------------
describe('Rust containers inside a mod keep their member edges (#2745 review)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'rust-2742-mod-members'), () => {});
}, 60000);
it('leaves no dangling edge of ANY type', () => {
expect(findDanglingEdges(result)).toEqual([]);
});
// Asserts the NODE id, not just the edge's anchor: the anchor was already bare
// while the bug was live, so an edge-only assertion passes in both builds. The
// half that moved is the mint.
it('keeps a mod-scoped struct and its field on one agreed id', () => {
const structIds: string[] = [];
result.graph.forEachNode((n) => {
if (n.label === 'Struct' && n.properties.name === 'Config') structIds.push(n.id);
});
expect(structIds).toEqual(['Struct:src/main.rs:Config']);
const hasProperty = getRelationships(result, 'HAS_PROPERTY').filter(
(e) => e.target === 'retries',
);
expect(hasProperty).toMatchObject([
{
rel: {
sourceId: 'Struct:src/main.rs:Config',
targetId: 'Property:src/main.rs:Config.retries',
},
},
]);
});
it('keeps a scoped inherent-impl target inside a mod at its raw path (#1975)', () => {
const implIds: string[] = [];
result.graph.forEachNode((n) => {
if (n.label === 'Impl') implIds.push(n.id);
});
expect(implIds).toEqual(['Impl:src/main.rs:a::Inner']);
const hasMethod = getRelationships(result, 'HAS_METHOD').filter((e) => e.target === 'helper');
expect(hasMethod).toMatchObject([{ rel: { sourceId: 'Impl:src/main.rs:a::Inner' } }]);
});
it('still links a trait impl declared inside a mod to the trait method', () => {
const implementsEdges = getRelationships(result, 'METHOD_IMPLEMENTS');
expect(implementsEdges).toMatchObject([
{
rel: {
sourceId: 'Function:src/main.rs:Config.go#0',
targetId: 'Function:src/main.rs:Runner.go#0',
},
},
]);
});
});
// ---------------------------------------------------------------------------
// #2745 review — the #2730 self-loop survived one `mod` deeper.
//
// A `mod` nested inside an inline `mod` binds in the PARENT module's scope.
// `declaresSubmodule` looked it up through `moduleScopeByFile`, which maps a file
// to its root `Module` scope only, so a nested inline module was invisible: the
// candidate was never yielded, the hook refused, and the shared lexical tier bound
// `tools::dispatch()` to the enclosing same-name `dispatch`.
//
// Inline module paths are now derived from the MEMBERS' `namespacePrefix` — a `mod`
// def carries no nesting information of its own, and a `Namespace` scope owns its
// own def rather than its children's, so neither channel could answer this.
// ---------------------------------------------------------------------------
describe('Rust nested inline modules resolve (#2745 review)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'rust-2742-nested-mods'), () => {});
}, 60000);
it('resolves a nested inline module member instead of the enclosing same-name fn', () => {
const edges = getRelationships(result, 'CALLS').filter(
(c) => c.rel.sourceId === 'Function:src/main.rs:outer.dispatch',
);
expect(edges).toMatchObject([
{ rel: { targetId: 'Function:src/main.rs:outer.tools.dispatch' } },
]);
});
it('resolves three levels deep', () => {
const midToDeep = getRelationships(result, 'CALLS').filter(
(c) => c.rel.sourceId === 'Function:src/main.rs:a.b.mid',
);
expect(midToDeep).toMatchObject([{ rel: { targetId: 'Function:src/main.rs:a.b.c.deep' } }]);
});
it('emits no self-loop anywhere in the nested fixture', () => {
const selfLoops = getRelationships(result, 'CALLS').filter(
(c) => c.rel.sourceId === c.rel.targetId,
);
expect(selfLoops).toEqual([]);
});
// A `mod` inside a `fn` is already position-qualified by the enclosing-callable
// pass, so prepending the mod segment placed it OUTSIDE the callable and the id
// read as `helper.wrapper.dispatch@L:C` — the inverse of the real nesting.
it('does not hoist a mod declared inside a fn above the callable', () => {
const ids: string[] = [];
result.graph.forEachNode((n) => {
if (n.label === 'Function' && n.properties.name === 'dispatch') ids.push(n.id);
});
// Prefix rather than an exact `@line:col`, so adding a line to the fixture
// above does not turn this into a coordinate-maintenance test.
expect(
ids.filter((id) => id.startsWith('Function:src/main.rs:wrapper.dispatch@')),
).toHaveLength(1);
expect(ids.filter((id) => id.includes(':helper.'))).toEqual([]);
});
});
// ---------------------------------------------------------------------------
// #2745 review — an imported type outranks a same-named module at the crate root.
//
// Widening the negative filter to inline `mod` names let a type-qualified call
// through when a module happened to share the type's name. The crate-root-relative
// candidate — the loosest one, a guess at a path the caller never wrote — then
// captured it, producing an edge to a callee the source does not name. The base
// emitted no edge, which per the doctrine quoted in `ids.ts` is the correct failure
// direction: a missing edge is recoverable, a fabricated caller misleads `impact`.
// ---------------------------------------------------------------------------
describe('Rust type-qualified calls outrank a same-named module (#2745 review)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'rust-2742-type-vs-module'), () => {});
}, 60000);
it('never binds an imported type to the crate-root module of the same name', () => {
const fromCall = getRelationships(result, 'CALLS').filter(
(c) => c.rel.sourceId === 'Function:src/b.rs:call',
);
const targets = fromCall.map((c) => c.rel.targetId);
expect(targets).not.toContain('Function:src/lib.rs:Buffer.with_capacity');
});
it('leaves the module member itself intact as a node', () => {
const ids: string[] = [];
result.graph.forEachNode((n) => {
if (n.label === 'Function' && n.properties.name === 'with_capacity') ids.push(n.id);
});
expect(ids).toContain('Function:src/lib.rs:Buffer.with_capacity');
});
});