diff --git a/gitnexus/bench/scope-capture/baselines.json b/gitnexus/bench/scope-capture/baselines.json index 555b8e1f7c..bb3e59556b 100644 --- a/gitnexus/bench/scope-capture/baselines.json +++ b/gitnexus/bench/scope-capture/baselines.json @@ -28,10 +28,10 @@ "scaling_budget": 1.5 }, "rust": { - "fingerprint": "56ffc1c069af10cac3c82a32f3d148322ea570e116ebaae67315445f05407fef", + "fingerprint": "b00aea0f2dbff6a77d3aa709f7f90e8a70649f7e789a8de725d9b1958ebe12bc", "scaling_budget": 1.5, "_rebaselined": "#1956 tri-review U1: rust-qualified-trait fixture (scoped + generic-of-scoped impl trait paths); bareTypeIdentifier now resolves scoped_type_identifier bases by their name: tail (additive, no existing-fixture drift); linear (~1.04). #1975: + rust-scoped-impl fixture (impl a::Inner / b::Inner inherent scoped impls) — legacy @definition.impl scoped arm + findEnclosingClassInfo inherent-impl scoped target; rust scope-extractor captures byte-identical.", - "_note": "PR #1934: F66/F68 let-binding pattern narrowing; F71 union (Struct-labeled, now materialized via legacy @definition.struct + resolvable); F72 macro FULLY WIRED — @declaration.macro/@reference.macro + MacroRegistry → USES edges to Macro nodes (never a same-named fn). + rust-macro / rust-union fixtures and merged with origin/main #1975 rust-scoped-impl; fingerprint re-baselined (scaling ~0.99, fixture_count 126)." + "_note": "PR #1934: F66/F68 let-binding pattern narrowing; F71 union (Struct-labeled, now materialized via legacy @definition.struct + resolvable); F72 macro FULLY WIRED — @declaration.macro/@reference.macro + MacroRegistry → USES edges to Macro nodes (never a same-named fn). + rust-macro / rust-union fixtures and merged with origin/main #1975 rust-scoped-impl; fingerprint re-baselined (scaling ~0.99, fixture_count 126). #1992: + rust-nested-tail-collision-generic and rust-generic-impl-same-method-name (F3) fixtures — pure fixture-corpus drift, no scope-extractor change; fixture_count 127->129, fingerprint 56ffc1c0->b00aea0f." }, "php": { "fingerprint": "f9c8eaf6d1084f9b95a9fb97ccce5e618a24d936c85fb8af4b96c73a560f7a7f", diff --git a/gitnexus/src/core/ingestion/utils/ast-helpers.ts b/gitnexus/src/core/ingestion/utils/ast-helpers.ts index 52d28169f7..54f2bae7bd 100644 --- a/gitnexus/src/core/ingestion/utils/ast-helpers.ts +++ b/gitnexus/src/core/ingestion/utils/ast-helpers.ts @@ -503,18 +503,50 @@ export const findEnclosingClassInfo = ( // different mods own through DISTINCT nodes. The Impl-node // materialization (parsing-processor / parse-worker) mirrors this, so // the owner id == the Impl node id byte-for-byte (#1982). - const firstType = children.find( - (c: SyntaxNode) => c.type === 'type_identifier' || c.type === 'scoped_type_identifier', + // - GENERIC (`impl Inner`, generic_type): the @definition.impl + // node is materialized only when the generic base is a bare + // `type_identifier` (tree-sitter-queries.ts), qualified the same way — + // so drill into the base and mirror that gate, keeping the owner id == + // the node id byte-for-byte (#1992). A generic over a SCOPED base + // (`impl a::Inner`) materializes NO node, so it must produce NO + // owner (the method orphans — scoped-generic deferred, #1992). + const implTarget = children.find( + (c: SyntaxNode) => + c.type === 'type_identifier' || + c.type === 'scoped_type_identifier' || + c.type === 'generic_type', ); - if (firstType) { - const ownerKey = - firstType.type === 'type_identifier' - ? qualifyRustImplTargetByModScope(current, firstType.text) - : firstType.text; - return { - classId: generateId('Impl', `${filePath}:${ownerKey}`), - className: firstType.text, - }; + if (implTarget) { + const baseType = + implTarget.type === 'generic_type' + ? (implTarget.childForFieldName?.('type') ?? null) + : implTarget; + if (baseType?.type === 'type_identifier') { + // Bare target (`impl Inner` or `impl Inner`): qualify by mod scope. + // #1992 follow-up: qualify `className` too (not just `classId`). The + // method node id is keyed `${className}.${name}`, so a bare tail collapses + // two same-tail bare impls that ALSO share a method name (`a::Inner::m` + + // `b::Inner::m` both → `Inner.m`) onto one Method node (graph addNode is + // first-write-wins). Qualifying className → `a.Inner.m` / `b.Inner.m` keeps + // them distinct. Symmetric: the call-resolution fallback rebuilds the same + // `${className}.${name}` from the same enclosing-impl walk, so def and call + // ids still agree. Owner edge anchors on `classId` (already qualified). + const qualified = qualifyRustImplTargetByModScope(current, baseType.text); + return { + classId: generateId('Impl', `${filePath}:${qualified}`), + className: qualified, + }; + } + if (baseType?.type === 'scoped_type_identifier' && implTarget.type !== 'generic_type') { + // Top-level scoped `impl a::Inner`: key by full raw text (#1975). + return { + classId: generateId('Impl', `${filePath}:${baseType.text}`), + className: baseType.text, + }; + } + // generic-over-scoped (`impl a::Inner`) and any other base: fall + // through with no owner — no @definition.impl node exists, so attributing + // a method to a synthesized id would orphan it against a phantom owner. } } diff --git a/gitnexus/test/fixtures/lang-resolution/rust-generic-impl-same-method-name/lib.rs b/gitnexus/test/fixtures/lang-resolution/rust-generic-impl-same-method-name/lib.rs new file mode 100644 index 0000000000..ea52dda5b6 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-generic-impl-same-method-name/lib.rs @@ -0,0 +1,24 @@ +// #1992 follow-up (F3): two same-tail generic inherent impls that ALSO share a +// method name. Pre-fix the Method node id keys `${className}.${name}` with the +// BARE tail (`Inner.m`), so `a::Inner::m` and `b::Inner::m` collapse onto ONE +// Method node (graph addNode is first-write-wins). Both HAS_METHOD edges then +// point at the survivor, silently losing the second method. Qualifying +// `className` (`a.Inner` / `b.Inner`) keys them as `a.Inner.m` / `b.Inner.m`, so +// BOTH Method nodes survive and each owns through its own mod-qualified Impl node. +pub mod a { + pub struct Inner { + v: T, + } + impl Inner { + pub fn m(&self) {} + } +} + +pub mod b { + pub struct Inner { + v: T, + } + impl Inner { + pub fn m(&self) {} + } +} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-nested-tail-collision-generic/lib.rs b/gitnexus/test/fixtures/lang-resolution/rust-nested-tail-collision-generic/lib.rs new file mode 100644 index 0000000000..2a1afe8230 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-nested-tail-collision-generic/lib.rs @@ -0,0 +1,30 @@ +// #1992: GENERIC inherent-impl ownership. Two same-tail `Inner` types under +// sibling mods, each with a generic inherent impl `impl Inner`. Their +// methods must own through DISTINCT mod-qualified Impl nodes (`a.Inner` / +// `b.Inner`), not orphan to File. +pub mod a { + pub struct Inner { v: T } + impl Inner { + pub fn fa(&self) {} + } +} + +pub mod b { + pub struct Inner { v: T } + impl Inner { + pub fn fb(&self) {} + } +} + +// Scoped-generic inherent impl: `impl crate::c::Scoped` is a `generic_type` +// wrapping a `scoped_type_identifier`. tree-sitter-queries materializes NO +// @definition.impl node for this shape, so `fd` must stay orphaned (scoped-generic +// deferred, #1992) — the owner walk must NOT mint a phantom `c.Scoped` owner. +pub mod c { + pub struct Scoped { v: T } +} +pub mod d { + impl crate::c::Scoped { + pub fn fd(&self) {} + } +} diff --git a/gitnexus/test/fixtures/rust-captures-golden/expected-captures.json b/gitnexus/test/fixtures/rust-captures-golden/expected-captures.json index e9a735553b..dcfebe997a 100644 --- a/gitnexus/test/fixtures/rust-captures-golden/expected-captures.json +++ b/gitnexus/test/fixtures/rust-captures-golden/expected-captures.json @@ -219,6 +219,10 @@ "captureGroups": 9, "digest": "a8562a331eb945b4c099a7a9ec6c9b5eed8ff603c9359897b0445ce316a1424e" }, + "rust-generic-impl-same-method-name/lib.rs": { + "captureGroups": 19, + "digest": "494ee20e8d5ebff07a8358938b706e15a48c9a9fb21e0ce361c2a8c391080352" + }, "rust-grouped-imports/src/helpers/mod.rs": { "captureGroups": 14, "digest": "32bc4f57a1dc0ffe8e9cbf0f0d6ce2ac5b1f2f93e2e3de3f217c92636480de63" @@ -319,6 +323,10 @@ "captureGroups": 18, "digest": "3326eb4f82b1559b6afec497dc52cab734e6f3209501a4bd982bf5eab9ec6dba" }, + "rust-nested-tail-collision-generic/lib.rs": { + "captureGroups": 29, + "digest": "1bfdaaf207a83924fc25d2eec0a47e5807754bbd0de499b81adea48342c6e687" + }, "rust-nested-tail-collision/lib.rs": { "captureGroups": 17, "digest": "2fc1fe1eb4e8727a89ab283ae34a0ae8df0c421551a7bd5e6e7ffb9d4aa54189" diff --git a/gitnexus/test/integration/resolvers/rust.test.ts b/gitnexus/test/integration/resolvers/rust.test.ts index 8a99119a09..750940a906 100644 --- a/gitnexus/test/integration/resolvers/rust.test.ts +++ b/gitnexus/test/integration/resolvers/rust.test.ts @@ -2102,6 +2102,164 @@ describe('Rust inline mod-nested same-tail collision — distinct nodes (issue # }); }); +// --------------------------------------------------------------------------- +// #1992: GENERIC inherent-impl ownership — `impl Inner` 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 Inner`) 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 crate::c::Scoped` 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'), + () => {}, + { workerThresholdsForTest: { minFiles: 1, minBytes: 1 }, 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'), + () => {}, + { workerThresholdsForTest: { minFiles: 1, minBytes: 1 }, 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) //