fix(rust): qualify generic/lifetime impl methods by the implementing type, not the trait (#1588) (#1596)

Fixes #1588.

## What was wrong

The receiver of an `impl` block — the name that qualifies its methods, owns the `contains` edge, and sources the `implements` edge — was found positionally: the **last bare `type_identifier` child** of the `impl_item`. That works for `impl Source for FileSource`. But once the implementing type carries parameters it parses as a `generic_type`, and the only bare identifier left is the **trait's**:

```rust
impl Source for FileSource         →  FileSource::read   ✓
impl<T> Source for BufSource<T>    →  Source::read       ✗  (should be BufSource::read)
impl<'a> Iterator for Parents<'a>  →  Iterator::next     ✗
impl Trait for &Foo                →  Trait::method      ✗
```

Two consequences, both reproduced on `main`:

- `BufSource::read` did not exist in the graph, so `resolveMethodOnType("BufSource", "read")` and "who calls `BufSource::read`" had no answer, and every generic implementation of a trait collapsed onto the same trait-qualified name.
- Because the impl's method carried the trait's qualified name, the interface-impl synthesizer treated the impl **body** as a second trait declaration and emitted a dispatch edge from it (`Source::read -> FileSource::read`, registered at the generic impl's line — a body of `{ 0 }` containing no call at all).

The native kernel (`rustlang.rs`) mirrored the positional rule deliberately, bug-for-bug, to hold byte-parity with the TS walker — its header said "preserve, never fix via the grammar's trait:/type: fields". So the fix has to land on both sides at once.

## What this does

Both extractors now read the grammar's **named fields** instead of scanning children. One shared rule (`rustImplTypeName` in `languages/rust.ts`, `impl_type_name` in the kernel), applied to `impl_item.type`:

| implementing type | node | receiver |
|---|---|---|
| `Foo` | `type_identifier` | `Foo` |
| `Foo<T>` / `Foo<'a>` | `generic_type` → its `type` field | `Foo` |
| `m::Foo` | `scoped_type_identifier` → its `name` field | `Foo` (was: no receiver) |
| `&Foo` / `&'a mut Foo` | `reference_type` → its `type` field | `Foo` |
| `(A, B)`, `dyn Tr`, `*const T`, `u32`, fn types | anything else | none — extracted as plain functions, exactly as before |

The `implements` back-reference reads `impl_item.trait` (full text, so `fmt::Display` and `From<u32>` keep their spelling) and bails when the field is absent (inherent impl). Everything else — the no-scope impl quirk, the source-order `contains` owner scan, method extraction — is untouched; the `contains` edge simply lands on the implementing type now instead of the trait.

The kernel header comment, the parity test's description, and the two design docs that documented the quirk as "preserve" are updated to say what changed.

## Measured on ripgrep (110 `.rs` files, `main` build vs this branch)

| | main | this PR |
|---|---|---|
| nodes / methods | 4029 / 2202 | 4029 / 2202 |
| impl methods qualified by a **trait** name (node outside that trait's extent) | 61 | **0** |
| `Iterator::*` methods | 2 | 0 |
| duplicate method qualified names | 77 | 42 |
| synthesized `interface-impl` edges originating **outside** any trait declaration (the phantom fan-outs) | 38 | **0** |
| synthesized `interface-impl` edges originating at a real trait declaration | 33 | **52** |
| plain (non-heuristic) `calls` edges | 9098 | 9098 |

So the synthesizer lost every phantom edge and *gained* 19 legitimate fan-outs to implementations it could not previously see as implementations. `contains` edges went 5237 → 5224: the 13 removed were trait→impl-method edges produced by the mis-qualification.

The issue's repro now gives `BufSource::read` at line 12, `BufSource -> Source`, and both synthesized edges registered at the declaration (line 2) — identical on the kernel path and with `CODEGRAPH_KERNEL=0`. (The remaining `UsesFile::go -> BufSource::read` exact-match guess there is the separate `self.field.method()` receiver problem, #1585, which stacks on this.)

## Tests

- `__tests__/extraction.test.ts` (Rust Extraction): method qualified names for generic / lifetime / reference / scoped / generic-trait impls; the trait's qualified name names exactly one node; `implements` refs come from the implementing type for every shape; the `contains` edge lands on the type; tuple / `dyn` impls keep producing plain functions with no `implements` ref.
- `__tests__/resolution.test.ts` (end-to-end): `Source::read` names only the declaration; dispatch fans out to **both** `FileSource::read` and `BufSource::read`, every synthesized edge registered at line 2; neither impl body sprouts a synthesized call.
- `__tests__/fixtures/kernel-parity/torture.rs` grows all the new impl shapes; `kernel-rustlang-parity` (LF + CRLF) passes against the rebuilt kernel.
- `CODEGRAPH_KERNEL_EXPECT=1 npx vitest run __tests__/kernel-*.test.ts` — all 15 suites, 147 tests pass.
- Full `npm test`: 3180 passed, 9 skipped, 1 failed — `mcp-daemon.test.ts > daemon idle-times-out after the last client disconnects`, a 30 s timing test that passed on re-run in isolation (the machine was running four parallel suites and kernel builds at the time); unrelated to extraction.

Re-index after upgrading to pick up the corrected names.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01LxZj6W6Y1SHXwvpT3uwJpK
This commit is contained in:
Colby Mchenry
2026-08-26 10:36:53 -05:00
committed by GitHub
parent 44e1812d3b
commit 12f7a59f26
10 changed files with 345 additions and 129 deletions
+105
View File
@@ -1131,6 +1131,111 @@ impl Cache for MyCache {
expect(implRef?.fromNodeId).toBe(myCacheNode?.id);
});
it('qualifies methods of a generic or lifetime impl by the implementing type, not the trait (#1588)', () => {
const code = `
pub trait Source {
fn read(&mut self) -> usize;
}
pub struct FileSource { pub n: usize }
impl Source for FileSource {
fn read(&mut self) -> usize { self.n }
}
pub struct BufSource<T> { pub inner: T }
impl<T> Source for BufSource<T> {
fn read(&mut self) -> usize { 0 }
}
pub struct Parents<'a> { cur: &'a u32 }
impl<'a> Iterator for Parents<'a> {
type Item = u32;
fn next(&mut self) -> Option<u32> { None }
}
pub struct Wrapper { pub n: usize }
impl Source for &Wrapper {
fn read(&mut self) -> usize { 1 }
}
pub mod m { pub struct Scoped { pub n: usize } }
impl Source for m::Scoped {
fn read(&mut self) -> usize { 2 }
}
pub struct Own { pub n: usize }
impl From<u32> for Own {
fn from(n: u32) -> Self { Own { n: n as usize } }
}
`;
const result = extractFromSource('src.rs', code);
// Every impl method is qualified by the IMPLEMENTING type. Before, a
// parameterized implementing type (`BufSource<T>`, `Parents<'a>`, `&Wrapper`)
// left the trait's identifier as the only bare type_identifier child of the
// impl, so those methods were recorded as `Source::read` / `Iterator::next`.
const methodQns = result.nodes
.filter((n) => n.kind === 'method')
.map((n) => n.qualifiedName)
.sort();
expect(methodQns).toEqual([
'BufSource::read',
'FileSource::read',
'Own::from',
'Parents::next',
'Scoped::read',
'Source::read',
'Wrapper::read',
]);
// The trait's qualified name now names exactly one node: its declaration.
const traitRead = result.nodes.filter((n) => n.qualifiedName === 'Source::read');
expect(traitRead).toHaveLength(1);
expect(traitRead[0]!.startLine).toBe(3);
// The implements back-reference comes FROM the implementing type's node
// for every impl shape, named by the trait's full text.
const implementsFrom = (typeName: string): string[] => {
const typeNode = result.nodes.find((n) => n.name === typeName && n.kind === 'struct');
expect(typeNode, typeName).toBeDefined();
return result.unresolvedReferences
.filter((r) => r.referenceKind === 'implements' && r.fromNodeId === typeNode!.id)
.map((r) => r.referenceName);
};
expect(implementsFrom('FileSource')).toEqual(['Source']);
expect(implementsFrom('BufSource')).toEqual(['Source']);
expect(implementsFrom('Parents')).toEqual(['Iterator']);
expect(implementsFrom('Wrapper')).toEqual(['Source']);
expect(implementsFrom('Scoped')).toEqual(['Source']);
expect(implementsFrom('Own')).toEqual(['From<u32>']);
// …and the owner `contains` edge lands on the implementing type too.
const buf = result.nodes.find((n) => n.name === 'BufSource' && n.kind === 'struct')!;
const bufRead = result.nodes.find((n) => n.qualifiedName === 'BufSource::read')!;
expect(
result.edges.some((e) => e.kind === 'contains' && e.source === buf.id && e.target === bufRead.id)
).toBe(true);
});
it('gives no receiver to an impl whose target names no single type', () => {
// A tuple / `dyn Trait` / primitive implementing type has no struct to
// hang the methods off, so they are extracted as plain functions — the
// pre-#1588 behavior for these shapes, minus the trait mis-qualification.
const code = `
pub trait Base { fn id(&self) -> u32; }
impl Base for (u32, u32) {
fn id(&self) -> u32 { 0 }
}
impl Base for dyn Base {
fn id(&self) -> u32 { 1 }
}
`;
const result = extractFromSource('src.rs', code);
const ids = result.nodes.filter((n) => n.name === 'id');
expect(ids.map((n) => n.qualifiedName).sort()).toEqual(['Base::id', 'id', 'id']);
expect(ids.filter((n) => n.kind === 'function')).toHaveLength(2);
expect(result.unresolvedReferences.filter((r) => r.referenceKind === 'implements')).toHaveLength(0);
});
it('should extract trait supertraits as extends references', () => {
const code = `
pub trait Display {}
@@ -107,6 +107,71 @@ impl Render for Container<u32> {
fn render(&self) {}
}
/// Receiver = the impl_item's `type` field (#1588): generic, lifetime,
/// reference, scoped, and generic-trait impls all qualify by the TYPE.
pub trait Source {
fn read(&mut self) -> usize;
}
pub struct FileSource {
pub n: usize,
}
impl Source for FileSource {
fn read(&mut self) -> usize {
self.n
}
}
pub struct BufSource<T> {
pub inner: T,
}
impl<T> Source for BufSource<T> {
fn read(&mut self) -> usize {
0
}
}
pub struct Parents<'a> {
cur: &'a u32,
}
impl<'a> Iterator for Parents<'a> {
type Item = u32;
fn next(&mut self) -> Option<u32> {
None
}
}
impl<T: Clone> Container<T> {
fn dup(&self) -> T {
self.item.clone()
}
}
impl Base for &Widget {}
impl<T> Render for &mut BufSource<T> {
fn render(&self) {}
}
impl Base for self::Deep {}
impl From<u32> for FileSource {
fn from(n: u32) -> Self {
FileSource { n: n as usize }
}
}
impl Base for (u32, u32) {}
impl Render for dyn Base {
fn render(&self) {}
}
impl Base for u32 {}
impl Later {
fn touch(&self) {}
}
+2 -2
View File
@@ -4,8 +4,8 @@
* Asserts the native walker (codegraph-kernel/src/rustlang.rs) produces the
* SAME ExtractionResult as the wasm TreeSitterExtractor — nodes, edges, and
* unresolved refs compared as canonicalized multisets — over the checked-in
* torture fixture (torture.rs: impl/trait quirks incl. the
* `impl Trait for Generic<T>` trait-receiver bug, unit-struct skip, phantom
* torture fixture (torture.rs: impl/trait quirks incl. generic / lifetime /
* reference / scoped / generic-trait impl receivers (#1588), unit-struct skip, phantom
* const identifiers, use-binding refs incl. nested groups + wildcard-emits-
* nothing, chained-call re-encode, turbofish, Rocket route macros body-only,
* fn-ref shapes, value-ref shadowing, attribute-broken docstrings, dead-code
+53
View File
@@ -1119,6 +1119,59 @@ impl Describe for Ctl { fn describe(&self) -> String { "ctl".into() } }
).toBe('interface-impl');
});
it('qualifies a generic impl by its type, so trait dispatch reaches it and no edge is invented from its body (#1588)', async () => {
// `impl<T> Source for BufSource<T>`: the implementing type parses as a
// generic_type, so the old positional receiver scan picked the TRAIT.
// The impl's `read` was recorded as `Source::read` — unaddressable as
// `BufSource::read` — and, carrying the trait's name, the interface-impl
// synthesizer treated its body (`{ 0 }`, no call at all) as a second
// declaration and gave it a dispatch edge to FileSource's implementation.
fs.writeFileSync(
path.join(tempDir, 'lib.rs'),
`pub trait Source {
fn read(&mut self) -> usize;
}
pub struct FileSource { pub n: usize }
impl Source for FileSource {
fn read(&mut self) -> usize { self.n }
}
pub struct BufSource<T> { pub inner: T }
impl<T> Source for BufSource<T> {
fn read(&mut self) -> usize { 0 }
}
`
);
cg = await CodeGraph.init(tempDir, { index: true });
const methods = cg.getNodesByKind('method');
const traitDecls = methods.filter((n) => n.qualifiedName === 'Source::read');
expect(traitDecls, 'only the declaration carries the trait-qualified name').toHaveLength(1);
const traitMethod = traitDecls[0]!;
expect(traitMethod.startLine).toBe(2);
const fileImpl = methods.find((n) => n.qualifiedName === 'FileSource::read');
const bufImpl = methods.find((n) => n.qualifiedName === 'BufSource::read');
expect(fileImpl).toBeDefined();
expect(bufImpl, 'the generic impl is addressable by its type').toBeDefined();
const synth = (id: string) =>
cg.getOutgoingEdges(id).filter((e) => e.kind === 'calls' && e.provenance === 'heuristic');
// Dispatch fans out from the declaration to BOTH implementations…
const fromTrait = synth(traitMethod.id);
expect(new Set(fromTrait.map((e) => e.target))).toEqual(new Set([fileImpl!.id, bufImpl!.id]));
for (const e of fromTrait) {
expect(
(e.metadata as { synthesizedBy?: string } | undefined)?.synthesizedBy
).toBe('interface-impl');
expect(e.line, 'registered at the declaration, never at an impl body').toBe(2);
}
// …and neither implementation body sprouts a synthesized call of its own.
expect(synth(fileImpl!.id)).toHaveLength(0);
expect(synth(bufImpl!.id)).toHaveLength(0);
});
it('records instantiates for C++ stack/brace construction, targeting the class (#1035)', async () => {
// `Calculator calc(0)` (direct-init) and `Widget w{1, 2}` (brace-init)
// carry the constructor args directly on the declarator — there's no