From cb17aeeba6b1bf5dd53619b13c660489bceace31 Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Tue, 2 Jun 2026 14:24:34 +0000 Subject: [PATCH 1/8] test(ingestion): failing target tests + graph-integrity helper for scoped-declaration nodes (U1, #1975) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds findDanglingEdges() and pipeline-level tests asserting that Ruby namespaced class/module declarations materialize a Class/Trait node with a resolving HAS_METHOD edge. Red by design on the pre-fix base (5 failing) — the fix lands in U2 (shared core) + U3 (Ruby enablement). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../test/integration/resolvers/helpers.ts | 32 +++++++++++++ .../test/integration/resolvers/ruby.test.ts | 48 +++++++++++++++++++ 2 files changed, 80 insertions(+) diff --git a/gitnexus/test/integration/resolvers/helpers.ts b/gitnexus/test/integration/resolvers/helpers.ts index ddc4a96d33..592911c8b1 100644 --- a/gitnexus/test/integration/resolvers/helpers.ts +++ b/gitnexus/test/integration/resolvers/helpers.ts @@ -536,6 +536,38 @@ export function getResolutionOutcomes(result: PipelineResult) { return result.resolutionOutcomes ?? []; } +/** + * Relationships whose source or target id does not resolve to a live graph node. + * A non-empty result means the graph has dangling edges (an endpoint that was + * never materialized) — e.g. a HAS_METHOD edge owned by a class node that the + * structure phase failed to create. Pass `types` to scope the check to specific + * relationship types (e.g. `['HAS_METHOD']`). + */ +export function findDanglingEdges( + result: PipelineResult, + types?: string[], +): Array<{ type: string; sourceId: string; targetId: string; missing: 'source' | 'target' | 'both' }> { + const out: Array<{ + type: string; + sourceId: string; + targetId: string; + missing: 'source' | 'target' | 'both'; + }> = []; + for (const rel of result.graph.iterRelationships()) { + if (types && !types.includes(rel.type)) continue; + const src = result.graph.getNode(rel.sourceId); + const tgt = result.graph.getNode(rel.targetId); + if (src && tgt) continue; + out.push({ + type: rel.type, + sourceId: rel.sourceId, + targetId: rel.targetId, + missing: !src && !tgt ? 'both' : !src ? 'source' : 'target', + }); + } + return out; +} + export function getNodesByLabel(result: PipelineResult, label: string): string[] { const names: string[] = []; result.graph.forEachNode((n) => { diff --git a/gitnexus/test/integration/resolvers/ruby.test.ts b/gitnexus/test/integration/resolvers/ruby.test.ts index 6f7c4fac09..6bc2ebe64c 100644 --- a/gitnexus/test/integration/resolvers/ruby.test.ts +++ b/gitnexus/test/integration/resolvers/ruby.test.ts @@ -12,6 +12,7 @@ import { getRelationships, getNodesByLabel, getNodesByLabelFull, + findDanglingEdges, edgeSet, runPipelineFromRepo, type PipelineResult, @@ -1429,3 +1430,50 @@ describe('Ruby Child extends Parent — inherited method resolution (SM-9)', () expect(parentMethodCall!.source).toBe('run'); }); }); + +// --------------------------------------------------------------------------- +// Namespaced class/module declarations — GRAPH NODE materialization (issue #1975) +// +// Follow-up to PR #1972 (F62): the scope query captures the tail constant for +// `class Foo::Bar` / `module Baz::Qux`, but the legacy structure query never +// matched the scope_resolution name, so no Class/Trait node was created and the +// declaration's methods got dangling HAS_METHOD edges. These pipeline-level +// tests assert the target behavior (a real node + a resolving HAS_METHOD edge). +// They fail on the pre-fix base — see plan docs/plans/2026-06-02-002-*. +// --------------------------------------------------------------------------- + +describe('Ruby namespaced class/module definitions — graph nodes (issue #1975)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-namespaced'), () => {}); + }, 60000); + + // R1: a Class node is materialized for the namespaced class. + pit('materializes a Class node for class Foo::Bar', () => { + expect(getNodesByLabel(result, 'Class')).toContain('Bar'); + }); + + // R1: deep chain resolves to the trailing constant. + pit('materializes a Class node for class Outer::Middle::Inner', () => { + expect(getNodesByLabel(result, 'Class')).toContain('Inner'); + }); + + // R1: module → Trait (Ruby modules are relabeled Trait for class-like lookup). + pit('materializes a Trait node for module Baz::Qux', () => { + expect(getNodesByLabel(result, 'Trait')).toContain('Qux'); + }); + + // R2: methods of namespaced declarations must not produce dangling HAS_METHOD edges. + pit('emits no dangling HAS_METHOD edges for namespaced declarations', () => { + expect(findDanglingEdges(result, ['HAS_METHOD'])).toEqual([]); + }); + + // R2: the method resolves to a real owner node (not an 'unknown' dangling source). + pit('owns bar_method under a resolving namespaced class node', () => { + const hasMethod = getRelationships(result, 'HAS_METHOD'); + const edge = hasMethod.find((e) => e.target === 'bar_method'); + expect(edge).toBeDefined(); + expect(edge!.sourceLabel).toBe('Class'); + }); +}); From acb2bfa464fd827d47b5d4643bda310f638c0768 Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Tue, 2 Jun 2026 15:05:31 +0000 Subject: [PATCH 2/8] fix(ingestion): materialize graph nodes for Ruby namespaced class/module declarations (U2/U3, #1975) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Widen the Ruby legacy structure query so `class Foo::Bar` / `module Baz::Qux` (name field is a scope_resolution node) match @definition.class/.module as separate top-level patterns. The node is keyed by its full scoped name, which matches the HAS_METHOD owner id that findEnclosingClassInfo derives from the same name field — so the previously-dangling ownership edges now resolve, and distinct namespaces (Foo::Bar vs Baz::Bar) stay distinct nodes (no collision). No change to findEnclosingClassInfo (zero call-resolution blast radius) and no scope-extractor/golden/bench impact — the fix is purely the legacy structure query gate. Finalizes the U1 target assertions to the qualified-name identity. Validated: 134/134 Ruby resolver tests pass on BOTH legs; tsc --noEmit clean; dangling HAS_METHOD edges on the ruby-namespaced fixture drop from 3 to 0. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/core/ingestion/tree-sitter-queries.ts | 11 +++++++++++ gitnexus/test/integration/resolvers/ruby.test.ts | 15 ++++++++++----- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/gitnexus/src/core/ingestion/tree-sitter-queries.ts b/gitnexus/src/core/ingestion/tree-sitter-queries.ts index b9f8f3c658..8db03da352 100644 --- a/gitnexus/src/core/ingestion/tree-sitter-queries.ts +++ b/gitnexus/src/core/ingestion/tree-sitter-queries.ts @@ -1396,10 +1396,21 @@ export const RUBY_QUERIES = ` (module name: (constant) @name) @definition.module +; Namespaced module: module Baz::Qux (name field is a scope_resolution node). +; Separate top-level pattern (not a [...] alternation) so neither branch is +; silently dropped — see #1975. The full scope_resolution text keys the node so +; it matches the HAS_METHOD owner id derived from the same name field. +(module + name: (scope_resolution) @name) @definition.module + ; ── Classes ────────────────────────────────────────────────────────────────── (class name: (constant) @name) @definition.class +; Namespaced class: class Foo::Bar (name field is a scope_resolution node). +(class + name: (scope_resolution) @name) @definition.class + ; ── Instance methods ───────────────────────────────────────────────────────── (method name: (identifier) @name) @definition.method diff --git a/gitnexus/test/integration/resolvers/ruby.test.ts b/gitnexus/test/integration/resolvers/ruby.test.ts index 6bc2ebe64c..5a37cc4fe9 100644 --- a/gitnexus/test/integration/resolvers/ruby.test.ts +++ b/gitnexus/test/integration/resolvers/ruby.test.ts @@ -1449,19 +1449,24 @@ describe('Ruby namespaced class/module definitions — graph nodes (issue #1975) result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-namespaced'), () => {}); }, 60000); - // R1: a Class node is materialized for the namespaced class. + // R1/R3: a distinct Class node is materialized for the namespaced class, + // keyed by its full scoped name (so Foo::Bar and Baz::Bar never collide). + // The node id matches the HAS_METHOD owner id derived from the same name field; + // qualifiedName carries the dotted path (Foo.Bar). pit('materializes a Class node for class Foo::Bar', () => { - expect(getNodesByLabel(result, 'Class')).toContain('Bar'); + const classes = getNodesByLabelFull(result, 'Class'); + expect(classes.some((c) => c.properties.qualifiedName === 'Foo.Bar')).toBe(true); }); - // R1: deep chain resolves to the trailing constant. + // R1: deep chain Outer::Middle::Inner → qualifiedName Outer.Middle.Inner. pit('materializes a Class node for class Outer::Middle::Inner', () => { - expect(getNodesByLabel(result, 'Class')).toContain('Inner'); + const classes = getNodesByLabelFull(result, 'Class'); + expect(classes.some((c) => c.properties.qualifiedName === 'Outer.Middle.Inner')).toBe(true); }); // R1: module → Trait (Ruby modules are relabeled Trait for class-like lookup). pit('materializes a Trait node for module Baz::Qux', () => { - expect(getNodesByLabel(result, 'Trait')).toContain('Qux'); + expect(getNodesByLabel(result, 'Trait')).toContain('Baz::Qux'); }); // R2: methods of namespaced declarations must not produce dangling HAS_METHOD edges. From ef703673a77ee34828a89180105a8bb3ed3d8e3d Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Tue, 2 Jun 2026 15:34:26 +0000 Subject: [PATCH 3/8] fix(ingestion): resolve C++ out-of-line nested definition method ownership (U4, #1975) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For an out-of-line `struct Outer::Inner { ... }`, the container name is a qualified_identifier, so findEnclosingClassInfo derived the owner id from the full `Outer::Inner` text — but the type is keyed by its in-class declaration (the nested `Inner` node), leaving the method's HAS_METHOD edge dangling. Reduce a qualified_identifier container name to its tail segment for the owner id/name, matching how inline nested definitions are already keyed. Node-type scoped, so Ruby's scope_resolution names stay full (distinct-by-namespace) and no language is named in shared code. Only out-of-line-def methods (already dangling) change behavior — zero impact on bare classes or call resolution. Validated: C++ 268/268 default leg, 205+63-skip legacy leg, no regression; 2 new target tests pass both legs; Ruby namespaced tests still pass; tsc clean; scope-capture bench rebaselined (cpp +cpp-out-of-line-class fixture) — --check PASS (13 langs). Dangling HAS_METHOD on the new fixture: 1 -> 0. Co-Authored-By: Claude Opus 4.8 (1M context) --- gitnexus/bench/scope-capture/baselines.json | 5 +-- .../src/core/ingestion/utils/ast-helpers.ts | 21 +++++++++--- .../cpp-out-of-line-class/shapes.cpp | 12 +++++++ .../test/integration/resolvers/cpp.test.ts | 34 +++++++++++++++++++ 4 files changed, 66 insertions(+), 6 deletions(-) create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-out-of-line-class/shapes.cpp diff --git a/gitnexus/bench/scope-capture/baselines.json b/gitnexus/bench/scope-capture/baselines.json index a51cce1097..1af88ab865 100644 --- a/gitnexus/bench/scope-capture/baselines.json +++ b/gitnexus/bench/scope-capture/baselines.json @@ -16,10 +16,11 @@ "_added": "#1956: c added to the scope-capture bench (was UNBENCHED). C has no inheritance — flat scale source. Adding it exposed + fixed a pre-existing O(n^2) findNodeAtRange root-walk in c/captures.ts (threaded c.node, byte-identical over c-* fixtures); scaling 3.475 -> 0.96." }, "cpp": { - "fingerprint": "4022f436885d15fd2d419e38e0674115e1c5daa7dcb9578de2633160bed94446", + "fingerprint": "ff71df8fddcfe6f99860342e08f3a354cb696529bc570ed27cb443302b34001d", "scaling_budget": 1.5, "_added": "#1956: cpp added to the scope-capture bench (was UNBENCHED). Heritage-bearing scale source (: public Base, public Mixin) drives emitCppInheritanceCaptures at scale. Adding it exposed + fixed a pre-existing O(n^2) findNodeAtRange root-walk in cpp/captures.ts (~12 sites, threaded c.node, byte-identical over 263 cpp-* fixtures); scaling 2.30 -> 1.12.", - "_rebaselined": "#1965 / #1923 F4: uninitialized non-leading multi-declarators now emit @declaration.variable captures; cpp-adl-inner-callable-outer-noncallable data::Pair a, b adds the legitimate fixture drift. Linear (~1.06)." + "_rebaselined": "#1965 / #1923 F4: uninitialized non-leading multi-declarators now emit @declaration.variable captures; cpp-adl-inner-callable-outer-noncallable data::Pair a, b adds the legitimate fixture drift. Linear (~1.06).", + "_note": "#1975: + cpp-out-of-line-class fixture (out-of-line struct Outer::Inner definition). Pure fixture-corpus drift — the fix is in findEnclosingClassInfo owner derivation (legacy path), NOT the cpp scope-extractor; existing fixtures' captures are byte-identical. fixture_count 263->265." }, "csharp": { "_rebaselined": "#1956 synth-widening: + csharp-qualified-base fixture; the synth now walks record_declaration + struct_declaration base_lists and handles alias_qualified_name (matching the #1940 legacy leg), so record/struct heritage now emits. csharp-record-base gains a record inherits capture. (record->record SAME-namespace EXTENDS is a separate registry resolution gap, tracked as follow-up.) Linear (~1.00). (Earlier #1956: heritage-bearing scale source.)", diff --git a/gitnexus/src/core/ingestion/utils/ast-helpers.ts b/gitnexus/src/core/ingestion/utils/ast-helpers.ts index 85526721be..c13e277210 100644 --- a/gitnexus/src/core/ingestion/utils/ast-helpers.ts +++ b/gitnexus/src/core/ingestion/utils/ast-helpers.ts @@ -471,14 +471,27 @@ export const findEnclosingClassInfo = ( ) { label = 'Interface'; } - const templateArguments = extractTemplateArguments(nameNode.text); + // C++ out-of-line definitions name the container with a qualified_identifier + // (e.g. `struct Outer::Inner { ... }`). The type itself is keyed by its + // trailing name — the nested `Inner` node from its in-class declaration — + // exactly as an inline nested definition would be. Reduce the owner to that + // tail segment so member edges resolve to the real node instead of a + // `Outer::Inner` owner id that was never materialized (#1975). + // Ruby's `scope_resolution` name is intentionally left full: a compact + // `class Foo::Bar` has no in-class declaration, so its node IS keyed by the + // full scoped name (and must stay distinct from a separate `Baz::Bar`). + const ownerNameNode = + nameNode.type === 'qualified_identifier' + ? (nameNode.childForFieldName?.('name') ?? nameNode) + : nameNode; + const templateArguments = extractTemplateArguments(ownerNameNode.text); const classIdName = templateArguments !== undefined - ? `${stripTemplateArguments(nameNode.text)}${templateArgumentsIdTag(templateArguments)}` - : nameNode.text; + ? `${stripTemplateArguments(ownerNameNode.text)}${templateArgumentsIdTag(templateArguments)}` + : ownerNameNode.text; return { classId: generateId(label, `${filePath}:${classIdName}`), - className: nameNode.text, + className: ownerNameNode.text, }; } } diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-out-of-line-class/shapes.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-out-of-line-class/shapes.cpp new file mode 100644 index 0000000000..54aac6e8ae --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-out-of-line-class/shapes.cpp @@ -0,0 +1,12 @@ +struct Outer { + struct Inner; +}; + +struct Outer::Inner { + void inner_method() {} +}; + +void use() { + Outer::Inner i; + i.inner_method(); +} diff --git a/gitnexus/test/integration/resolvers/cpp.test.ts b/gitnexus/test/integration/resolvers/cpp.test.ts index 43fcfd7386..dfaab70bb1 100644 --- a/gitnexus/test/integration/resolvers/cpp.test.ts +++ b/gitnexus/test/integration/resolvers/cpp.test.ts @@ -10,6 +10,7 @@ import { getNodesByLabel, getNodesByLabelFull, getResolutionOutcomes, + findDanglingEdges, edgeSet, runPipelineFromRepo, createResolverParityIt, @@ -3728,3 +3729,36 @@ describe('C++ SFINAE filter — arity gate runs before constraint filter', () => expect(calls.length).toBe(1); }); }); + +// --------------------------------------------------------------------------- +// Out-of-line nested definitions — HAS_METHOD owner resolution (issue #1975) +// +// `struct Outer::Inner { ... }` names its container with a qualified_identifier. +// The type is keyed by its in-class declaration (the nested `Inner` node), so +// the out-of-line definition's methods must own through that node — not a +// `Outer::Inner` owner id that is never materialized. Pre-fix this produced a +// dangling HAS_METHOD edge. +// --------------------------------------------------------------------------- + +describe('C++ out-of-line nested class/struct definitions (issue #1975)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'cpp-out-of-line-class'), () => {}); + }, 60000); + + it('keeps the nested type as a single node (no Outer::Inner duplicate)', () => { + const inners = getNodesByLabelFull(result, 'Struct').filter( + (n) => n.properties.qualifiedName === 'Outer.Inner', + ); + expect(inners.length).toBe(1); + }); + + it('owns the out-of-line method through the nested struct node (no dangling edge)', () => { + expect(findDanglingEdges(result, ['HAS_METHOD'])).toEqual([]); + const hasMethod = getRelationships(result, 'HAS_METHOD'); + const edge = hasMethod.find((e) => e.target === 'inner_method'); + expect(edge).toBeDefined(); + expect(edge!.sourceLabel).toBe('Struct'); + }); +}); From b5c519f21e01a8177c691cbef48e264ba1b75577 Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Tue, 2 Jun 2026 15:42:57 +0000 Subject: [PATCH 4/8] fix(ingestion): resolve Rust scoped impl-target method ownership (U5, #1975) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `impl path::Type` and `impl Trait for path::Type` name the target with a scoped_type_identifier. Two coordinated fixes: - findEnclosingClassInfo: reduce a scoped_type_identifier impl target to its trailing type name (both the trait-impl `for` branch and the inherent branch), matching the type's own tail-keyed declaration. - tree-sitter-queries: add a @definition.impl arm for scoped inherent impls so the Impl node is materialized (keyed by the same tail) instead of missing. Together the trait-impl method owns through the real Struct node and the inherent-impl method owns through a real Impl node — no dangling edges. Rust's scoped_type_identifier has a name: field, so the tail extraction is exact. Validated: Rust 163/163 on BOTH legs, no regression; new target test passes; C++/Ruby suites unaffected; tsc clean; scope-capture bench rebaselined (rust +rust-scoped-impl fixture) — --check PASS (13 langs). Dangling 1 -> 0. Co-Authored-By: Claude Opus 4.8 (1M context) --- gitnexus/bench/scope-capture/baselines.json | 5 +-- .../src/core/ingestion/tree-sitter-queries.ts | 4 +++ .../src/core/ingestion/utils/ast-helpers.ts | 25 +++++++++++--- .../lang-resolution/rust-scoped-impl/lib.rs | 15 ++++++++ .../test/integration/resolvers/rust.test.ts | 34 +++++++++++++++++++ 5 files changed, 76 insertions(+), 7 deletions(-) create mode 100644 gitnexus/test/fixtures/lang-resolution/rust-scoped-impl/lib.rs diff --git a/gitnexus/bench/scope-capture/baselines.json b/gitnexus/bench/scope-capture/baselines.json index 1af88ab865..31c1cea69d 100644 --- a/gitnexus/bench/scope-capture/baselines.json +++ b/gitnexus/bench/scope-capture/baselines.json @@ -28,9 +28,10 @@ "scaling_budget": 1.5 }, "rust": { - "fingerprint": "2ffad4ba7b1d2eb1ac407cb6d75d0eb98cbc1878260dbdfe982c0fc925b2d00c", + "fingerprint": "9e387d0801fe76faee92e0394f6d4dda75b5e59d12090d51fb8a92d89572ee7a", "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)." + "_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).", + "_note": "#1975: + rust-scoped-impl fixture (impl path::Type + impl Trait for path::Type). Pure fixture-corpus drift — the fix is the @definition.impl scoped arm + findEnclosingClassInfo owner reduction (legacy path), NOT the rust scope-extractor; existing fixtures' captures byte-identical. fixture_count 120->121." }, "php": { "fingerprint": "f9c8eaf6d1084f9b95a9fb97ccce5e618a24d936c85fb8af4b96c73a560f7a7f", diff --git a/gitnexus/src/core/ingestion/tree-sitter-queries.ts b/gitnexus/src/core/ingestion/tree-sitter-queries.ts index 8db03da352..43c61d5960 100644 --- a/gitnexus/src/core/ingestion/tree-sitter-queries.ts +++ b/gitnexus/src/core/ingestion/tree-sitter-queries.ts @@ -1213,6 +1213,10 @@ export const RUST_QUERIES = ` (trait_item name: (type_identifier) @name) @definition.trait (impl_item type: (type_identifier) @name !trait) @definition.impl (impl_item type: (generic_type type: (type_identifier) @name) !trait) @definition.impl +; Scoped inherent impl: impl path::Type { ... }. Key the Impl node by the +; trailing type name so the method owner (reduced to the same tail in +; findEnclosingClassInfo) resolves instead of dangling (#1975). +(impl_item type: (scoped_type_identifier name: (type_identifier) @name) !trait) @definition.impl (mod_item name: (identifier) @name) @definition.module ; Type aliases, const, static, macros diff --git a/gitnexus/src/core/ingestion/utils/ast-helpers.ts b/gitnexus/src/core/ingestion/utils/ast-helpers.ts index c13e277210..d4a194367e 100644 --- a/gitnexus/src/core/ingestion/utils/ast-helpers.ts +++ b/gitnexus/src/core/ingestion/utils/ast-helpers.ts @@ -424,6 +424,16 @@ export const findEnclosingClassInfo = ( // If modifying this block, update the other location too. if (current.type === 'impl_item') { const children = current.children ?? []; + // A scoped impl target (`impl path::Type` / `impl Trait for path::Type`) + // names the type with a scoped_type_identifier, but the type's own + // declaration is keyed by its trailing name — reduce to that tail so the + // method owns through the real node instead of a `path::Type` id that was + // never materialized (#1975). Mirrors the qualified_identifier handling + // above and rust.ts:extractOwnerName. + const implTargetName = (n: SyntaxNode): string => + n.type === 'scoped_type_identifier' + ? (n.childForFieldName?.('name')?.text ?? n.text) + : n.text; const forIdx = children.findIndex((c: SyntaxNode) => c.text === 'for'); if (forIdx !== -1) { const nameNode = children @@ -435,17 +445,22 @@ export const findEnclosingClassInfo = ( c.type === 'identifier', ); if (nameNode) { + const name = implTargetName(nameNode); return { - classId: generateId('Struct', `${filePath}:${nameNode.text}`), - className: nameNode.text, + classId: generateId('Struct', `${filePath}:${name}`), + className: name, }; } } - const firstType = children.find((c: SyntaxNode) => c.type === 'type_identifier'); + const firstType = children.find( + (c: SyntaxNode) => + c.type === 'type_identifier' || c.type === 'scoped_type_identifier', + ); if (firstType) { + const name = implTargetName(firstType); return { - classId: generateId('Impl', `${filePath}:${firstType.text}`), - className: firstType.text, + classId: generateId('Impl', `${filePath}:${name}`), + className: name, }; } } diff --git a/gitnexus/test/fixtures/lang-resolution/rust-scoped-impl/lib.rs b/gitnexus/test/fixtures/lang-resolution/rust-scoped-impl/lib.rs new file mode 100644 index 0000000000..f6f7d3d5b8 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-scoped-impl/lib.rs @@ -0,0 +1,15 @@ +pub mod outer { + pub struct Inner; +} + +impl outer::Inner { + pub fn inner_method(&self) {} +} + +pub trait Speak { + fn speak(&self); +} + +impl Speak for outer::Inner { + fn speak(&self) {} +} diff --git a/gitnexus/test/integration/resolvers/rust.test.ts b/gitnexus/test/integration/resolvers/rust.test.ts index b2f7de90cd..f5a8de8085 100644 --- a/gitnexus/test/integration/resolvers/rust.test.ts +++ b/gitnexus/test/integration/resolvers/rust.test.ts @@ -9,6 +9,7 @@ import { getRelationships, getNodesByLabel, getNodesByLabelFull, + findDanglingEdges, edgeSet, runPipelineFromRepo, type PipelineResult, @@ -2012,3 +2013,36 @@ describe('Rust Child extends Parent — qualified-syntax MRO (SM-11)', () => { expect(traitCall).toBeUndefined(); }); }); + +// --------------------------------------------------------------------------- +// Scoped impl targets — method ownership (issue #1975) +// +// `impl path::Type { ... }` and `impl Trait for path::Type { ... }` name the +// target with a scoped_type_identifier. The owner is reduced to the trailing +// type name (matching the type's own declaration), and an inherent scoped impl +// now materializes its Impl node — so methods own through a real node instead +// of a dangling `path::Type` owner id. +// --------------------------------------------------------------------------- + +describe('Rust scoped impl targets (issue #1975)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'rust-scoped-impl'), () => {}); + }, 60000); + + it('owns inherent + trait-impl methods of a scoped target with no dangling edges', () => { + expect(findDanglingEdges(result, ['HAS_METHOD'])).toEqual([]); + const hasMethod = getRelationships(result, 'HAS_METHOD'); + // inherent impl: impl outer::Inner { fn inner_method } + expect(hasMethod.some((e) => e.target === 'inner_method' && e.sourceLabel !== 'unknown')).toBe( + true, + ); + // trait impl: impl Speak for outer::Inner — speak owned by the Inner struct node + expect( + hasMethod.some( + (e) => e.target === 'speak' && e.source === 'Inner' && e.sourceLabel === 'Struct', + ), + ).toBe(true); + }); +}); From 94e9fefb4a60271cf0202d1cbc67c1b6ba9b2db5 Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Tue, 2 Jun 2026 15:48:45 +0000 Subject: [PATCH 5/8] test(ingestion): cross-namespace collision test + regenerate ruby/rust captures goldens (U6, #1975) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add ruby-tail-collision fixture + test: Foo::Bar and Baz::Bar share the tail 'Bar' but must stay two distinct Class nodes (locks the KTD-2 anti-collision guarantee from full-scoped-name keying). No dangling, no cross-wiring. - Regenerate the ruby + rust captures goldens for the fixtures added in U3-U6 (ruby-tail-collision, rust-scoped-impl). Both diffs are additive-only — a single new entry each, existing entries byte-identical (no capture-logic drift; the fixes are in the legacy structure query + findEnclosingClassInfo, not the scope-extractor). - Re-baseline the ruby scope-capture fingerprint (81->82 fixtures). N/A-language verification: C#/Java/PHP have no class-declaration scoped-name gap and show no regression (606 passed; the 2 C# worker-pool failures are the known worktree 'parse-worker.js not built' limitation, unrelated to this change). Co-Authored-By: Claude Opus 4.8 (1M context) --- gitnexus/bench/scope-capture/baselines.json | 4 +-- .../ruby-tail-collision/collision.rb | 10 +++++++ .../expected-captures.json | 4 +++ .../expected-captures.json | 4 +++ .../test/integration/resolvers/ruby.test.ts | 26 +++++++++++++++++++ 5 files changed, 46 insertions(+), 2 deletions(-) create mode 100644 gitnexus/test/fixtures/lang-resolution/ruby-tail-collision/collision.rb diff --git a/gitnexus/bench/scope-capture/baselines.json b/gitnexus/bench/scope-capture/baselines.json index 31c1cea69d..7227a845fb 100644 --- a/gitnexus/bench/scope-capture/baselines.json +++ b/gitnexus/bench/scope-capture/baselines.json @@ -39,10 +39,10 @@ "_rebaselined": "#1956: heritage-bearing scale source (class extends Base + use trait); both forms gated at scale; linear (~1.04)." }, "ruby": { - "fingerprint": "c3e9eec6ed152eae1f7d9759c08041d7d6230a4c532ec07d33f3c9f1ff7b9588", + "fingerprint": "ee81145cf0af796878e8e048192b87c8c8dc445a3e3fcdff6c6e26c179e97232", "scaling_budget": 1.5, "_rebaselined": "#1956 synth-widening: + ruby-qualified-base fixture; synth now reduces a scope_resolution superclass (class C < Mod::Super) to its trailing constant (matching the #1940 legacy leg), at parity. Linear (~1.03). (Earlier #1956: heritage-bearing scale source.)", - "_note": "F62: + scope_resolution class/module declaration captures — fixture count 78→81, fingerprint drift expected." + "_note": "F62: + scope_resolution class/module declaration captures — fixture count 78→81, fingerprint drift expected. #1975: + ruby-tail-collision fixture (Foo::Bar vs Baz::Bar stay distinct nodes) — pure fixture-corpus drift, scope-extractor captures unchanged; 81→82." }, "swift": { "fingerprint": "53325c6345161c5a495f997297af5a24fb718fd3e6647040160f8ab2a2c8e4c0", diff --git a/gitnexus/test/fixtures/lang-resolution/ruby-tail-collision/collision.rb b/gitnexus/test/fixtures/lang-resolution/ruby-tail-collision/collision.rb new file mode 100644 index 0000000000..e82c918787 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ruby-tail-collision/collision.rb @@ -0,0 +1,10 @@ +module Foo; end +module Baz; end + +class Foo::Bar + def from_foo; end +end + +class Baz::Bar + def from_baz; end +end diff --git a/gitnexus/test/fixtures/ruby-captures-golden/expected-captures.json b/gitnexus/test/fixtures/ruby-captures-golden/expected-captures.json index a7ed5142a9..006142b14b 100644 --- a/gitnexus/test/fixtures/ruby-captures-golden/expected-captures.json +++ b/gitnexus/test/fixtures/ruby-captures-golden/expected-captures.json @@ -295,6 +295,10 @@ "captureGroups": 9, "digest": "73c8b1725670e841d01fefa807b6148017e181e6bb34d8f5110d970f4292eaff" }, + "ruby-tail-collision/collision.rb": { + "captureGroups": 15, + "digest": "c071370701e4d8d4046ea2466192648cf72352cfb66e2e08ad15e320e850c683" + }, "ruby-write-access/models.rb": { "captureGroups": 13, "digest": "106fe23a380801055a2ef642f0b92d4552ce074321ebe831601f2cb89a8d8529" diff --git a/gitnexus/test/fixtures/rust-captures-golden/expected-captures.json b/gitnexus/test/fixtures/rust-captures-golden/expected-captures.json index 187ade0a72..f2b3fed6fa 100644 --- a/gitnexus/test/fixtures/rust-captures-golden/expected-captures.json +++ b/gitnexus/test/fixtures/rust-captures-golden/expected-captures.json @@ -391,6 +391,10 @@ "captureGroups": 17, "digest": "0e3826200f2e6f5b948313369e85ee3a08f18bc8d51fbd1f7283b19a8e01aac8" }, + "rust-scoped-impl/lib.rs": { + "captureGroups": 17, + "digest": "c3e924746642b92fa2364d9d72ec145b14caff5bef72b2f0bf17eb939a67802f" + }, "rust-scoped-multi-file/src/main.rs": { "captureGroups": 17, "digest": "cbb0ad90a6a6ddcb71afb98a98a172bede7f5c06b5c1d31484a11315a264b311" diff --git a/gitnexus/test/integration/resolvers/ruby.test.ts b/gitnexus/test/integration/resolvers/ruby.test.ts index 5a37cc4fe9..cd0ea77485 100644 --- a/gitnexus/test/integration/resolvers/ruby.test.ts +++ b/gitnexus/test/integration/resolvers/ruby.test.ts @@ -1482,3 +1482,29 @@ describe('Ruby namespaced class/module definitions — graph nodes (issue #1975) expect(edge!.sourceLabel).toBe('Class'); }); }); + +describe('Ruby cross-namespace tail collision — distinct nodes (issue #1975)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-tail-collision'), () => {}); + }, 60000); + + // R3: Foo::Bar and Baz::Bar share the tail `Bar` but must NOT merge — keying by + // the full scoped name keeps them two distinct Class nodes. + pit('keeps Foo::Bar and Baz::Bar as two distinct Class nodes', () => { + const qns = getNodesByLabelFull(result, 'Class') + .map((c) => c.properties.qualifiedName) + .filter((q) => q === 'Foo.Bar' || q === 'Baz.Bar') + .sort(); + expect(qns).toEqual(['Baz.Bar', 'Foo.Bar']); + }); + + // R2/R3: each namespaced class owns its own method through a resolving node. + pit('owns each method under its own namespaced class (no dangling, no cross-wire)', () => { + expect(findDanglingEdges(result, ['HAS_METHOD'])).toEqual([]); + const hasMethod = getRelationships(result, 'HAS_METHOD'); + expect(hasMethod.some((e) => e.target === 'from_foo' && e.sourceLabel === 'Class')).toBe(true); + expect(hasMethod.some((e) => e.target === 'from_baz' && e.sourceLabel === 'Class')).toBe(true); + }); +}); From 6b31d9bad6961b5b3aaf49f2ddcb3c6418a3e81e Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Tue, 2 Jun 2026 16:49:30 +0000 Subject: [PATCH 6/8] revert(ingestion): drop C++/Rust scoped-owner reduction; ship Ruby-only (#1975) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The self-tri-review of PR #1977 (review 4411683756) found — and reproduced — that the C++/Rust tail-reduction in findEnclosingClassInfo collides same-tail types declared in the same file (struct Outer::Inner + struct Other::Inner -> one Struct:Inner node, methods silently mis-attributed; same-named members merge). Root cause is pre-existing: GitNexus keys nested-type nodes by their tail name within a file, so even plain inline same-tail nested types already merge. A correct fix needs fully-qualified nested-type node identity — a broad change deferred to #1978. This reverts the C++ (qualified_identifier) and Rust (scoped_type_identifier impl) owner reductions in ast-helpers.ts, the Rust @definition.impl scoped arm, and the cpp/rust fixtures+tests+golden+bench entries. The Ruby fix is unaffected (it keys the node by the full scoped text — no collision) and stays: namespaced class/module node materialization + the cross-namespace collision test. Validated Ruby-only: 136/136 both legs; ruby+rust captures goldens 19/19; bench --check PASS (14 langs); tsc clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- gitnexus/bench/scope-capture/baselines.json | 10 ++-- .../src/core/ingestion/tree-sitter-queries.ts | 4 -- .../src/core/ingestion/utils/ast-helpers.ts | 46 ++++--------------- .../cpp-out-of-line-class/shapes.cpp | 12 ----- .../lang-resolution/rust-scoped-impl/lib.rs | 15 ------ .../expected-captures.json | 4 -- .../test/integration/resolvers/cpp.test.ts | 34 -------------- .../test/integration/resolvers/rust.test.ts | 34 -------------- 8 files changed, 13 insertions(+), 146 deletions(-) delete mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-out-of-line-class/shapes.cpp delete mode 100644 gitnexus/test/fixtures/lang-resolution/rust-scoped-impl/lib.rs diff --git a/gitnexus/bench/scope-capture/baselines.json b/gitnexus/bench/scope-capture/baselines.json index 281eddbba6..a278dd80fa 100644 --- a/gitnexus/bench/scope-capture/baselines.json +++ b/gitnexus/bench/scope-capture/baselines.json @@ -16,11 +16,10 @@ "_added": "#1956: c added to the scope-capture bench (was UNBENCHED). C has no inheritance \u2014 flat scale source. Adding it exposed + fixed a pre-existing O(n^2) findNodeAtRange root-walk in c/captures.ts (threaded c.node, byte-identical over c-* fixtures); scaling 3.475 -> 0.96." }, "cpp": { - "fingerprint": "ff71df8fddcfe6f99860342e08f3a354cb696529bc570ed27cb443302b34001d", + "fingerprint": "4022f436885d15fd2d419e38e0674115e1c5daa7dcb9578de2633160bed94446", "scaling_budget": 1.5, "_added": "#1956: cpp added to the scope-capture bench (was UNBENCHED). Heritage-bearing scale source (: public Base, public Mixin) drives emitCppInheritanceCaptures at scale. Adding it exposed + fixed a pre-existing O(n^2) findNodeAtRange root-walk in cpp/captures.ts (~12 sites, threaded c.node, byte-identical over 263 cpp-* fixtures); scaling 2.30 -> 1.12.", - "_rebaselined": "#1965 / #1923 F4: uninitialized non-leading multi-declarators now emit @declaration.variable captures; cpp-adl-inner-callable-outer-noncallable data::Pair a, b adds the legitimate fixture drift. Linear (~1.06).", - "_note": "#1975: + cpp-out-of-line-class fixture (out-of-line struct Outer::Inner definition). Pure fixture-corpus drift — the fix is in findEnclosingClassInfo owner derivation (legacy path), NOT the cpp scope-extractor; existing fixtures' captures are byte-identical. fixture_count 263->265." + "_rebaselined": "#1965 / #1923 F4: uninitialized non-leading multi-declarators now emit @declaration.variable captures; cpp-adl-inner-callable-outer-noncallable data::Pair a, b adds the legitimate fixture drift. Linear (~1.06)." }, "csharp": { "_rebaselined": "#1956 synth-widening: + csharp-qualified-base fixture; the synth now walks record_declaration + struct_declaration base_lists and handles alias_qualified_name (matching the #1940 legacy leg), so record/struct heritage now emits. csharp-record-base gains a record inherits capture. (record->record SAME-namespace EXTENDS is a separate registry resolution gap, tracked as follow-up.) Linear (~1.00). (Earlier #1956: heritage-bearing scale source.)", @@ -28,10 +27,9 @@ "scaling_budget": 1.5 }, "rust": { - "fingerprint": "9e387d0801fe76faee92e0394f6d4dda75b5e59d12090d51fb8a92d89572ee7a", + "fingerprint": "2ffad4ba7b1d2eb1ac407cb6d75d0eb98cbc1878260dbdfe982c0fc925b2d00c", "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).", - "_note": "#1975: + rust-scoped-impl fixture (impl path::Type + impl Trait for path::Type). Pure fixture-corpus drift — the fix is the @definition.impl scoped arm + findEnclosingClassInfo owner reduction (legacy path), NOT the rust scope-extractor; existing fixtures' captures byte-identical. fixture_count 120->121." + "_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)." }, "php": { "fingerprint": "f9c8eaf6d1084f9b95a9fb97ccce5e618a24d936c85fb8af4b96c73a560f7a7f", diff --git a/gitnexus/src/core/ingestion/tree-sitter-queries.ts b/gitnexus/src/core/ingestion/tree-sitter-queries.ts index 43c61d5960..8db03da352 100644 --- a/gitnexus/src/core/ingestion/tree-sitter-queries.ts +++ b/gitnexus/src/core/ingestion/tree-sitter-queries.ts @@ -1213,10 +1213,6 @@ export const RUST_QUERIES = ` (trait_item name: (type_identifier) @name) @definition.trait (impl_item type: (type_identifier) @name !trait) @definition.impl (impl_item type: (generic_type type: (type_identifier) @name) !trait) @definition.impl -; Scoped inherent impl: impl path::Type { ... }. Key the Impl node by the -; trailing type name so the method owner (reduced to the same tail in -; findEnclosingClassInfo) resolves instead of dangling (#1975). -(impl_item type: (scoped_type_identifier name: (type_identifier) @name) !trait) @definition.impl (mod_item name: (identifier) @name) @definition.module ; Type aliases, const, static, macros diff --git a/gitnexus/src/core/ingestion/utils/ast-helpers.ts b/gitnexus/src/core/ingestion/utils/ast-helpers.ts index d4a194367e..85526721be 100644 --- a/gitnexus/src/core/ingestion/utils/ast-helpers.ts +++ b/gitnexus/src/core/ingestion/utils/ast-helpers.ts @@ -424,16 +424,6 @@ export const findEnclosingClassInfo = ( // If modifying this block, update the other location too. if (current.type === 'impl_item') { const children = current.children ?? []; - // A scoped impl target (`impl path::Type` / `impl Trait for path::Type`) - // names the type with a scoped_type_identifier, but the type's own - // declaration is keyed by its trailing name — reduce to that tail so the - // method owns through the real node instead of a `path::Type` id that was - // never materialized (#1975). Mirrors the qualified_identifier handling - // above and rust.ts:extractOwnerName. - const implTargetName = (n: SyntaxNode): string => - n.type === 'scoped_type_identifier' - ? (n.childForFieldName?.('name')?.text ?? n.text) - : n.text; const forIdx = children.findIndex((c: SyntaxNode) => c.text === 'for'); if (forIdx !== -1) { const nameNode = children @@ -445,22 +435,17 @@ export const findEnclosingClassInfo = ( c.type === 'identifier', ); if (nameNode) { - const name = implTargetName(nameNode); return { - classId: generateId('Struct', `${filePath}:${name}`), - className: name, + classId: generateId('Struct', `${filePath}:${nameNode.text}`), + className: nameNode.text, }; } } - const firstType = children.find( - (c: SyntaxNode) => - c.type === 'type_identifier' || c.type === 'scoped_type_identifier', - ); + const firstType = children.find((c: SyntaxNode) => c.type === 'type_identifier'); if (firstType) { - const name = implTargetName(firstType); return { - classId: generateId('Impl', `${filePath}:${name}`), - className: name, + classId: generateId('Impl', `${filePath}:${firstType.text}`), + className: firstType.text, }; } } @@ -486,27 +471,14 @@ export const findEnclosingClassInfo = ( ) { label = 'Interface'; } - // C++ out-of-line definitions name the container with a qualified_identifier - // (e.g. `struct Outer::Inner { ... }`). The type itself is keyed by its - // trailing name — the nested `Inner` node from its in-class declaration — - // exactly as an inline nested definition would be. Reduce the owner to that - // tail segment so member edges resolve to the real node instead of a - // `Outer::Inner` owner id that was never materialized (#1975). - // Ruby's `scope_resolution` name is intentionally left full: a compact - // `class Foo::Bar` has no in-class declaration, so its node IS keyed by the - // full scoped name (and must stay distinct from a separate `Baz::Bar`). - const ownerNameNode = - nameNode.type === 'qualified_identifier' - ? (nameNode.childForFieldName?.('name') ?? nameNode) - : nameNode; - const templateArguments = extractTemplateArguments(ownerNameNode.text); + const templateArguments = extractTemplateArguments(nameNode.text); const classIdName = templateArguments !== undefined - ? `${stripTemplateArguments(ownerNameNode.text)}${templateArgumentsIdTag(templateArguments)}` - : ownerNameNode.text; + ? `${stripTemplateArguments(nameNode.text)}${templateArgumentsIdTag(templateArguments)}` + : nameNode.text; return { classId: generateId(label, `${filePath}:${classIdName}`), - className: ownerNameNode.text, + className: nameNode.text, }; } } diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-out-of-line-class/shapes.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-out-of-line-class/shapes.cpp deleted file mode 100644 index 54aac6e8ae..0000000000 --- a/gitnexus/test/fixtures/lang-resolution/cpp-out-of-line-class/shapes.cpp +++ /dev/null @@ -1,12 +0,0 @@ -struct Outer { - struct Inner; -}; - -struct Outer::Inner { - void inner_method() {} -}; - -void use() { - Outer::Inner i; - i.inner_method(); -} diff --git a/gitnexus/test/fixtures/lang-resolution/rust-scoped-impl/lib.rs b/gitnexus/test/fixtures/lang-resolution/rust-scoped-impl/lib.rs deleted file mode 100644 index f6f7d3d5b8..0000000000 --- a/gitnexus/test/fixtures/lang-resolution/rust-scoped-impl/lib.rs +++ /dev/null @@ -1,15 +0,0 @@ -pub mod outer { - pub struct Inner; -} - -impl outer::Inner { - pub fn inner_method(&self) {} -} - -pub trait Speak { - fn speak(&self); -} - -impl Speak for outer::Inner { - fn speak(&self) {} -} diff --git a/gitnexus/test/fixtures/rust-captures-golden/expected-captures.json b/gitnexus/test/fixtures/rust-captures-golden/expected-captures.json index f2b3fed6fa..187ade0a72 100644 --- a/gitnexus/test/fixtures/rust-captures-golden/expected-captures.json +++ b/gitnexus/test/fixtures/rust-captures-golden/expected-captures.json @@ -391,10 +391,6 @@ "captureGroups": 17, "digest": "0e3826200f2e6f5b948313369e85ee3a08f18bc8d51fbd1f7283b19a8e01aac8" }, - "rust-scoped-impl/lib.rs": { - "captureGroups": 17, - "digest": "c3e924746642b92fa2364d9d72ec145b14caff5bef72b2f0bf17eb939a67802f" - }, "rust-scoped-multi-file/src/main.rs": { "captureGroups": 17, "digest": "cbb0ad90a6a6ddcb71afb98a98a172bede7f5c06b5c1d31484a11315a264b311" diff --git a/gitnexus/test/integration/resolvers/cpp.test.ts b/gitnexus/test/integration/resolvers/cpp.test.ts index dfaab70bb1..43fcfd7386 100644 --- a/gitnexus/test/integration/resolvers/cpp.test.ts +++ b/gitnexus/test/integration/resolvers/cpp.test.ts @@ -10,7 +10,6 @@ import { getNodesByLabel, getNodesByLabelFull, getResolutionOutcomes, - findDanglingEdges, edgeSet, runPipelineFromRepo, createResolverParityIt, @@ -3729,36 +3728,3 @@ describe('C++ SFINAE filter — arity gate runs before constraint filter', () => expect(calls.length).toBe(1); }); }); - -// --------------------------------------------------------------------------- -// Out-of-line nested definitions — HAS_METHOD owner resolution (issue #1975) -// -// `struct Outer::Inner { ... }` names its container with a qualified_identifier. -// The type is keyed by its in-class declaration (the nested `Inner` node), so -// the out-of-line definition's methods must own through that node — not a -// `Outer::Inner` owner id that is never materialized. Pre-fix this produced a -// dangling HAS_METHOD edge. -// --------------------------------------------------------------------------- - -describe('C++ out-of-line nested class/struct definitions (issue #1975)', () => { - let result: PipelineResult; - - beforeAll(async () => { - result = await runPipelineFromRepo(path.join(FIXTURES, 'cpp-out-of-line-class'), () => {}); - }, 60000); - - it('keeps the nested type as a single node (no Outer::Inner duplicate)', () => { - const inners = getNodesByLabelFull(result, 'Struct').filter( - (n) => n.properties.qualifiedName === 'Outer.Inner', - ); - expect(inners.length).toBe(1); - }); - - it('owns the out-of-line method through the nested struct node (no dangling edge)', () => { - expect(findDanglingEdges(result, ['HAS_METHOD'])).toEqual([]); - const hasMethod = getRelationships(result, 'HAS_METHOD'); - const edge = hasMethod.find((e) => e.target === 'inner_method'); - expect(edge).toBeDefined(); - expect(edge!.sourceLabel).toBe('Struct'); - }); -}); diff --git a/gitnexus/test/integration/resolvers/rust.test.ts b/gitnexus/test/integration/resolvers/rust.test.ts index f5a8de8085..b2f7de90cd 100644 --- a/gitnexus/test/integration/resolvers/rust.test.ts +++ b/gitnexus/test/integration/resolvers/rust.test.ts @@ -9,7 +9,6 @@ import { getRelationships, getNodesByLabel, getNodesByLabelFull, - findDanglingEdges, edgeSet, runPipelineFromRepo, type PipelineResult, @@ -2013,36 +2012,3 @@ describe('Rust Child extends Parent — qualified-syntax MRO (SM-11)', () => { expect(traitCall).toBeUndefined(); }); }); - -// --------------------------------------------------------------------------- -// Scoped impl targets — method ownership (issue #1975) -// -// `impl path::Type { ... }` and `impl Trait for path::Type { ... }` name the -// target with a scoped_type_identifier. The owner is reduced to the trailing -// type name (matching the type's own declaration), and an inherent scoped impl -// now materializes its Impl node — so methods own through a real node instead -// of a dangling `path::Type` owner id. -// --------------------------------------------------------------------------- - -describe('Rust scoped impl targets (issue #1975)', () => { - let result: PipelineResult; - - beforeAll(async () => { - result = await runPipelineFromRepo(path.join(FIXTURES, 'rust-scoped-impl'), () => {}); - }, 60000); - - it('owns inherent + trait-impl methods of a scoped target with no dangling edges', () => { - expect(findDanglingEdges(result, ['HAS_METHOD'])).toEqual([]); - const hasMethod = getRelationships(result, 'HAS_METHOD'); - // inherent impl: impl outer::Inner { fn inner_method } - expect(hasMethod.some((e) => e.target === 'inner_method' && e.sourceLabel !== 'unknown')).toBe( - true, - ); - // trait impl: impl Speak for outer::Inner — speak owned by the Inner struct node - expect( - hasMethod.some( - (e) => e.target === 'speak' && e.source === 'Inner' && e.sourceLabel === 'Struct', - ), - ).toBe(true); - }); -}); From 6cb7406e156c7d9a842655bca02ba65de8764e7f Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Tue, 2 Jun 2026 17:30:55 +0000 Subject: [PATCH 7/8] fix(ingestion): collision-safe C++/Rust scoped-declaration node ownership (#1975) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-introduces the C++/Rust fix the tri-review reverted, using a collision-safe approach instead of owner tail-reduction (which merged same-tail types in one file). Key the scoped DECLARATION's node by its full qualified text so it matches the owner id and stays distinct from a same-tail type elsewhere: - C++: widen the legacy structure query to materialize a node for out-of-line defs (class/struct Outer::Inner — name is qualified_identifier), keyed by the full text. No findEnclosingClassInfo change needed — BASE already derives the full-text owner, which now matches. Outer::Inner and Other::Inner stay distinct; 3-level A::B::C resolves. (A redundant forward-decl node remains.) - Rust: @definition.impl arm for scoped inherent impls (keyed full) + findEnclosingClassInfo inherent-impl branch accepts scoped_type_identifier with full text. impl a::Inner and impl b::Inner stay distinct. Collision-aware fixtures + positive owner-identity assertions (per the tri-review) replace the single-type fixtures. Deferred to #1978: Rust trait impls on a scoped struct path (impl T for a::Inner) and the pre-existing inline same-tail node collision — both need qualified struct-node identity. Validated: Ruby 136/136, C++/Rust 434/434 both legs (371+63-skip legacy); ruby+rust captures goldens 19/19 (additive); bench --check PASS (14 langs); tsc clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- gitnexus/bench/scope-capture/baselines.json | 10 +++-- .../src/core/ingestion/tree-sitter-queries.ts | 10 +++++ .../src/core/ingestion/utils/ast-helpers.ts | 16 ++++++-- .../cpp-out-of-line-class/shapes.cpp | 10 +++++ .../lang-resolution/rust-scoped-impl/lib.rs | 14 +++++++ .../expected-captures.json | 4 ++ .../test/integration/resolvers/cpp.test.ts | 37 +++++++++++++++++++ .../test/integration/resolvers/rust.test.ts | 35 ++++++++++++++++++ 8 files changed, 129 insertions(+), 7 deletions(-) create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-out-of-line-class/shapes.cpp create mode 100644 gitnexus/test/fixtures/lang-resolution/rust-scoped-impl/lib.rs diff --git a/gitnexus/bench/scope-capture/baselines.json b/gitnexus/bench/scope-capture/baselines.json index a278dd80fa..e9d6fb1e4b 100644 --- a/gitnexus/bench/scope-capture/baselines.json +++ b/gitnexus/bench/scope-capture/baselines.json @@ -16,10 +16,11 @@ "_added": "#1956: c added to the scope-capture bench (was UNBENCHED). C has no inheritance \u2014 flat scale source. Adding it exposed + fixed a pre-existing O(n^2) findNodeAtRange root-walk in c/captures.ts (threaded c.node, byte-identical over c-* fixtures); scaling 3.475 -> 0.96." }, "cpp": { - "fingerprint": "4022f436885d15fd2d419e38e0674115e1c5daa7dcb9578de2633160bed94446", + "fingerprint": "931bf7af55dc1480d1a5d3c479ea3803003a6a2e2c4406447bd96f3e312e88de", "scaling_budget": 1.5, "_added": "#1956: cpp added to the scope-capture bench (was UNBENCHED). Heritage-bearing scale source (: public Base, public Mixin) drives emitCppInheritanceCaptures at scale. Adding it exposed + fixed a pre-existing O(n^2) findNodeAtRange root-walk in cpp/captures.ts (~12 sites, threaded c.node, byte-identical over 263 cpp-* fixtures); scaling 2.30 -> 1.12.", - "_rebaselined": "#1965 / #1923 F4: uninitialized non-leading multi-declarators now emit @declaration.variable captures; cpp-adl-inner-callable-outer-noncallable data::Pair a, b adds the legitimate fixture drift. Linear (~1.06)." + "_rebaselined": "#1965 / #1923 F4: uninitialized non-leading multi-declarators now emit @declaration.variable captures; cpp-adl-inner-callable-outer-noncallable data::Pair a, b adds the legitimate fixture drift. Linear (~1.06).", + "_note": "#1975: + cpp-out-of-line-class fixture (out-of-line struct Outer::Inner / Other::Inner). Pure fixture-corpus drift — the fix is the legacy structure-query qualified_identifier arm, NOT the cpp scope-extractor; existing fixtures' captures byte-identical. fixture_count 263->265." }, "csharp": { "_rebaselined": "#1956 synth-widening: + csharp-qualified-base fixture; the synth now walks record_declaration + struct_declaration base_lists and handles alias_qualified_name (matching the #1940 legacy leg), so record/struct heritage now emits. csharp-record-base gains a record inherits capture. (record->record SAME-namespace EXTENDS is a separate registry resolution gap, tracked as follow-up.) Linear (~1.00). (Earlier #1956: heritage-bearing scale source.)", @@ -27,9 +28,10 @@ "scaling_budget": 1.5 }, "rust": { - "fingerprint": "2ffad4ba7b1d2eb1ac407cb6d75d0eb98cbc1878260dbdfe982c0fc925b2d00c", + "fingerprint": "3c4b8e0a707299cc5db0af2528c72a99457859104589a7ef3cd1f377da01793e", "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)." + "_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).", + "_note": "#1975: + rust-scoped-impl fixture (impl a::Inner / b::Inner inherent scoped impls). Pure fixture-corpus drift — the fix is the legacy @definition.impl scoped arm + findEnclosingClassInfo inherent-impl scoped target, NOT the rust scope-extractor; existing fixtures' captures byte-identical. fixture_count 120->121." }, "php": { "fingerprint": "f9c8eaf6d1084f9b95a9fb97ccce5e618a24d936c85fb8af4b96c73a560f7a7f", diff --git a/gitnexus/src/core/ingestion/tree-sitter-queries.ts b/gitnexus/src/core/ingestion/tree-sitter-queries.ts index 8db03da352..91881f4af4 100644 --- a/gitnexus/src/core/ingestion/tree-sitter-queries.ts +++ b/gitnexus/src/core/ingestion/tree-sitter-queries.ts @@ -980,6 +980,12 @@ export const CPP_QUERIES = ` name: (template_type (type_identifier) @name (template_argument_list) @template-arguments)) @definition.class +; Out-of-line nested definition: class Outer::Inner { ... } / struct Outer::Inner { ... }. +; Key the node by the full qualified_identifier text so the def materializes a +; node that matches the HAS_METHOD owner id (also the full qualified text) and +; stays distinct from a same-tail type in another scope (#1975, #1978). +(class_specifier name: (qualified_identifier) @name) @definition.class +(struct_specifier name: (qualified_identifier) @name) @definition.struct (struct_specifier name: (type_identifier) @name) @definition.struct (struct_specifier name: (template_type @@ -1213,6 +1219,10 @@ export const RUST_QUERIES = ` (trait_item name: (type_identifier) @name) @definition.trait (impl_item type: (type_identifier) @name !trait) @definition.impl (impl_item type: (generic_type type: (type_identifier) @name) !trait) @definition.impl +; Scoped inherent impl: impl path::Type { ... }. Key the Impl node by the full +; scoped_type_identifier text so it matches the owner id (also full text) and +; stays distinct from a same-tail type in another module (#1975). +(impl_item type: (scoped_type_identifier) @name !trait) @definition.impl (mod_item name: (identifier) @name) @definition.module ; Type aliases, const, static, macros diff --git a/gitnexus/src/core/ingestion/utils/ast-helpers.ts b/gitnexus/src/core/ingestion/utils/ast-helpers.ts index 85526721be..16cdb7f3d4 100644 --- a/gitnexus/src/core/ingestion/utils/ast-helpers.ts +++ b/gitnexus/src/core/ingestion/utils/ast-helpers.ts @@ -420,8 +420,8 @@ export const findEnclosingClassInfo = ( } // Rust impl_item: for `impl Trait for Struct {}`, pick the type after `for` - // NOTE: This impl_item ownership logic is duplicated in rust.ts:extractOwnerName. - // If modifying this block, update the other location too. + // NOTE: This impl_item ownership logic is mirrored in + // method-extractors/configs/rust.ts (extractOwnerName, metadata only). if (current.type === 'impl_item') { const children = current.children ?? []; const forIdx = children.findIndex((c: SyntaxNode) => c.text === 'for'); @@ -435,13 +435,23 @@ export const findEnclosingClassInfo = ( c.type === 'identifier', ); if (nameNode) { + // `for` target keeps its raw text. A scoped path (impl T for a::Inner) + // therefore owns through `a::Inner`, which only resolves once the + // referenced struct is keyed by its qualified path — deferred to #1978. return { classId: generateId('Struct', `${filePath}:${nameNode.text}`), className: nameNode.text, }; } } - const firstType = children.find((c: SyntaxNode) => c.type === 'type_identifier'); + // Inherent impl target. Accept a scoped path (`impl a::Inner { ... }`) and + // key the Impl node by its FULL text — matching the @definition.impl + // scoped arm — so methods own through a node that exists and stays + // distinct from a same-tail type in another module (#1975). + const firstType = children.find( + (c: SyntaxNode) => + c.type === 'type_identifier' || c.type === 'scoped_type_identifier', + ); if (firstType) { return { classId: generateId('Impl', `${filePath}:${firstType.text}`), diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-out-of-line-class/shapes.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-out-of-line-class/shapes.cpp new file mode 100644 index 0000000000..ddc33534dc --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-out-of-line-class/shapes.cpp @@ -0,0 +1,10 @@ +struct Outer { struct Inner; }; +struct Other { struct Inner; }; + +struct Outer::Inner { + void from_outer() {} +}; + +struct Other::Inner { + void from_other() {} +}; diff --git a/gitnexus/test/fixtures/lang-resolution/rust-scoped-impl/lib.rs b/gitnexus/test/fixtures/lang-resolution/rust-scoped-impl/lib.rs new file mode 100644 index 0000000000..5fe977d154 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-scoped-impl/lib.rs @@ -0,0 +1,14 @@ +pub mod a { + pub struct Inner; +} +pub mod b { + pub struct Inner; +} + +impl a::Inner { + pub fn from_a(&self) {} +} + +impl b::Inner { + pub fn from_b(&self) {} +} diff --git a/gitnexus/test/fixtures/rust-captures-golden/expected-captures.json b/gitnexus/test/fixtures/rust-captures-golden/expected-captures.json index 187ade0a72..23aefaf5a7 100644 --- a/gitnexus/test/fixtures/rust-captures-golden/expected-captures.json +++ b/gitnexus/test/fixtures/rust-captures-golden/expected-captures.json @@ -391,6 +391,10 @@ "captureGroups": 17, "digest": "0e3826200f2e6f5b948313369e85ee3a08f18bc8d51fbd1f7283b19a8e01aac8" }, + "rust-scoped-impl/lib.rs": { + "captureGroups": 17, + "digest": "7bae61e3bde8ce20eade29ce06b13f0a57b7da631d81604218bd07b881a7b754" + }, "rust-scoped-multi-file/src/main.rs": { "captureGroups": 17, "digest": "cbb0ad90a6a6ddcb71afb98a98a172bede7f5c06b5c1d31484a11315a264b311" diff --git a/gitnexus/test/integration/resolvers/cpp.test.ts b/gitnexus/test/integration/resolvers/cpp.test.ts index 43fcfd7386..da62a243a5 100644 --- a/gitnexus/test/integration/resolvers/cpp.test.ts +++ b/gitnexus/test/integration/resolvers/cpp.test.ts @@ -10,6 +10,7 @@ import { getNodesByLabel, getNodesByLabelFull, getResolutionOutcomes, + findDanglingEdges, edgeSet, runPipelineFromRepo, createResolverParityIt, @@ -3728,3 +3729,39 @@ describe('C++ SFINAE filter — arity gate runs before constraint filter', () => expect(calls.length).toBe(1); }); }); + +// --------------------------------------------------------------------------- +// Out-of-line nested definitions — method ownership + collision (issue #1975) +// +// `struct Outer::Inner { ... }` (name = qualified_identifier) now materializes a +// node keyed by the full scoped text, so its methods own through a real node. +// Crucially, a same-tail type in another scope (Other::Inner) stays a DISTINCT +// node — no merge, no method mis-attribution. (A redundant forward-decl node +// `Inner` also exists; the pre-existing inline same-tail node collision is +// tracked separately in #1978.) +// --------------------------------------------------------------------------- + +describe('C++ out-of-line nested definitions — ownership + collision (issue #1975)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'cpp-out-of-line-class'), () => {}); + }, 60000); + + it('owns each out-of-line method with no dangling HAS_METHOD edges', () => { + expect(findDanglingEdges(result, ['HAS_METHOD'])).toEqual([]); + }); + + // R3: same-tail types in different scopes must NOT merge — each method owns + // through its own distinct node (positive owner-identity, not just dangle-free). + it('keeps Outer::Inner and Other::Inner distinct (no cross-wired methods)', () => { + const hasMethod = getRelationships(result, 'HAS_METHOD'); + const outer = hasMethod.find((e) => e.target === 'from_outer'); + const other = hasMethod.find((e) => e.target === 'from_other'); + expect(outer).toBeDefined(); + expect(other).toBeDefined(); + expect(outer!.source).toBe('Outer::Inner'); + expect(other!.source).toBe('Other::Inner'); + expect(outer!.source).not.toBe(other!.source); + }); +}); diff --git a/gitnexus/test/integration/resolvers/rust.test.ts b/gitnexus/test/integration/resolvers/rust.test.ts index b2f7de90cd..bcfa4200d9 100644 --- a/gitnexus/test/integration/resolvers/rust.test.ts +++ b/gitnexus/test/integration/resolvers/rust.test.ts @@ -9,6 +9,7 @@ import { getRelationships, getNodesByLabel, getNodesByLabelFull, + findDanglingEdges, edgeSet, runPipelineFromRepo, type PipelineResult, @@ -2012,3 +2013,37 @@ describe('Rust Child extends Parent — qualified-syntax MRO (SM-11)', () => { 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); + }); +}); From be189a6c4109dd578e3d249a9f3702258939b96c Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Tue, 2 Jun 2026 17:39:34 +0000 Subject: [PATCH 8/8] chore(format): apply prettier to scoped-declaration changes (#1975) Co-Authored-By: Claude Opus 4.8 (1M context) --- gitnexus/src/core/ingestion/utils/ast-helpers.ts | 3 +-- gitnexus/test/integration/resolvers/helpers.ts | 7 ++++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/gitnexus/src/core/ingestion/utils/ast-helpers.ts b/gitnexus/src/core/ingestion/utils/ast-helpers.ts index 16cdb7f3d4..f7e7917ea6 100644 --- a/gitnexus/src/core/ingestion/utils/ast-helpers.ts +++ b/gitnexus/src/core/ingestion/utils/ast-helpers.ts @@ -449,8 +449,7 @@ export const findEnclosingClassInfo = ( // scoped arm — so methods own through a node that exists and stays // distinct from a same-tail type in another module (#1975). const firstType = children.find( - (c: SyntaxNode) => - c.type === 'type_identifier' || c.type === 'scoped_type_identifier', + (c: SyntaxNode) => c.type === 'type_identifier' || c.type === 'scoped_type_identifier', ); if (firstType) { return { diff --git a/gitnexus/test/integration/resolvers/helpers.ts b/gitnexus/test/integration/resolvers/helpers.ts index 4352760e96..b45de25e8c 100644 --- a/gitnexus/test/integration/resolvers/helpers.ts +++ b/gitnexus/test/integration/resolvers/helpers.ts @@ -574,7 +574,12 @@ export function getResolutionOutcomes(result: PipelineResult) { export function findDanglingEdges( result: PipelineResult, types?: string[], -): Array<{ type: string; sourceId: string; targetId: string; missing: 'source' | 'target' | 'both' }> { +): Array<{ + type: string; + sourceId: string; + targetId: string; + missing: 'source' | 'target' | 'both'; +}> { const out: Array<{ type: string; sourceId: string;