diff --git a/gitnexus/bench/scope-capture/baselines.json b/gitnexus/bench/scope-capture/baselines.json index 0f484db28c..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", @@ -37,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/src/core/ingestion/tree-sitter-queries.ts b/gitnexus/src/core/ingestion/tree-sitter-queries.ts index b9f8f3c658..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 @@ -1396,10 +1406,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/src/core/ingestion/utils/ast-helpers.ts b/gitnexus/src/core/ingestion/utils/ast-helpers.ts index 85526721be..f7e7917ea6 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,22 @@ 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/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/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/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..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/helpers.ts b/gitnexus/test/integration/resolvers/helpers.ts index 2060ff3e71..b45de25e8c 100644 --- a/gitnexus/test/integration/resolvers/helpers.ts +++ b/gitnexus/test/integration/resolvers/helpers.ts @@ -564,6 +564,43 @@ 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..cd0ea77485 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,81 @@ 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/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', () => { + const classes = getNodesByLabelFull(result, 'Class'); + expect(classes.some((c) => c.properties.qualifiedName === 'Foo.Bar')).toBe(true); + }); + + // R1: deep chain Outer::Middle::Inner → qualifiedName Outer.Middle.Inner. + pit('materializes a Class node for class Outer::Middle::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('Baz::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'); + }); +}); + +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); + }); +}); 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); + }); +});