Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions gitnexus/bench/scope-capture/baselines.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,31 +16,33 @@
"_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.)",
"fingerprint": "68ef32c126d5c6de5d8184c6ad0a6104043036daf9805947db8b21741b883f43",
"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",
"scaling_budget": 1.5,
"_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",
Expand Down
21 changes: 21 additions & 0 deletions gitnexus/src/core/ingestion/tree-sitter-queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
15 changes: 12 additions & 3 deletions gitnexus/src/core/ingestion/utils/ast-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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}`),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
struct Outer { struct Inner; };
struct Other { struct Inner; };

struct Outer::Inner {
void from_outer() {}
};

struct Other::Inner {
void from_other() {}
};
Original file line number Diff line number Diff line change
@@ -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
14 changes: 14 additions & 0 deletions gitnexus/test/fixtures/lang-resolution/rust-scoped-impl/lib.rs
Original file line number Diff line number Diff line change
@@ -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) {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
37 changes: 37 additions & 0 deletions gitnexus/test/integration/resolvers/cpp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
getNodesByLabel,
getNodesByLabelFull,
getResolutionOutcomes,
findDanglingEdges,
edgeSet,
runPipelineFromRepo,
createResolverParityIt,
Expand Down Expand Up @@ -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);
});
});
37 changes: 37 additions & 0 deletions gitnexus/test/integration/resolvers/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
79 changes: 79 additions & 0 deletions gitnexus/test/integration/resolvers/ruby.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
getRelationships,
getNodesByLabel,
getNodesByLabelFull,
findDanglingEdges,
edgeSet,
runPipelineFromRepo,
type PipelineResult,
Expand Down Expand Up @@ -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);
});
});
Loading
Loading