diff --git a/gitnexus/bench/scope-capture/baselines.json b/gitnexus/bench/scope-capture/baselines.json index bb3e59556..4990a2097 100644 --- a/gitnexus/bench/scope-capture/baselines.json +++ b/gitnexus/bench/scope-capture/baselines.json @@ -16,11 +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": "538e8beebf0a69f6170dff452da3f98046a08cbe8b098b3c9943c4a8a79d2e22", + "fingerprint": "d63ded6251a89d42cc63941ac3fdb093bf5b59ae483b135786766f925cdc91c5", "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, fixture_count 263->265. #1990: + cpp-adl-ns-plus-hidden-friend-same-name fixture (ADL hidden-friend + namespace-callable merge parity test). Pure fixture-corpus drift — no scope-extractor change; existing fixtures' captures byte-identical. fixture_count 265->267." + "_note": "#1975: + cpp-out-of-line-class fixture, fixture_count 263->265. #1990: + cpp-adl-ns-plus-hidden-friend-same-name fixture (ADL hidden-friend + namespace-callable merge parity test). Pure fixture-corpus drift — no scope-extractor change; existing fixtures' captures byte-identical. fixture_count 265->267. #1995: + cpp-union-nested-tail-collision and cpp-anon-ns-tail-collision fixtures — pure fixture-corpus drift; fixture_count 270->272, fingerprint 538e8be->d63ded6." }, "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/class-extractors/configs/c-cpp.ts b/gitnexus/src/core/ingestion/class-extractors/configs/c-cpp.ts index d39888aa1..41a448306 100644 --- a/gitnexus/src/core/ingestion/class-extractors/configs/c-cpp.ts +++ b/gitnexus/src/core/ingestion/class-extractors/configs/c-cpp.ts @@ -45,10 +45,33 @@ export const cClassConfig: ClassExtractionConfig = { export const cppClassConfig: ClassExtractionConfig = { language: SupportedLanguages.CPlusPlus, typeDeclarationNodes: ['class_specifier', 'struct_specifier', 'enum_specifier'], - ancestorScopeNodeTypes: ['namespace_definition', 'class_specifier', 'struct_specifier'], + // #1995: `union_specifier` is included so a type nested in a NAMED union + // (`union U1 { struct Inner {...} }`) qualifies as `U1.Inner`. Anonymous unions + // have no `name` child → extractScopeSegmentsFromNode returns [] → they correctly + // contribute nothing (members inject into the enclosing scope). C uses the + // separate cClassConfig (no qualifiedNodeId), so it is intentionally untouched. + ancestorScopeNodeTypes: [ + 'namespace_definition', + 'class_specifier', + 'struct_specifier', + 'union_specifier', + ], // #1978: key nested-type nodes by their fully-qualified path (Outer.Inner) so // same-tail nested types in one TU stay distinct instead of silently merging. qualifiedNodeId: true, + // #1995: anonymous namespaces have no `name` child, so the generic scope walker + // drops them (empty segment) and two `namespace { struct Inner {} }` blocks in one + // TU collapse onto a single `Inner` node. Give each anonymous namespace_definition + // a deterministic per-block discriminator (its start byte — stable across the + // sequential and worker full-file parses) so the nested types stay distinct. + // Returning `undefined` for every other scope — named namespaces (incl. `inline + // namespace`), classes, structs, named unions — falls through to the default + // name-based extraction, leaving them unchanged. Anonymous UNIONS are not matched + // here (members inject into the enclosing scope), so they keep yielding []. + extractScopeSegments: (node) => + node.type === 'namespace_definition' && !node.childForFieldName?.('name') + ? [`@anon${node.startIndex}`] + : undefined, extractName: (node) => { const nameNode = node.childForFieldName?.('name'); if (!nameNode) return undefined; diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-anon-ns-tail-collision/main.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-anon-ns-tail-collision/main.cpp new file mode 100644 index 000000000..47250e0b4 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-anon-ns-tail-collision/main.cpp @@ -0,0 +1,17 @@ +// Same-tail structs in sibling ANONYMOUS namespaces (#1995). +// +// An anonymous `namespace { }` is a namespace_definition with no `name` child, so +// extractScopeSegmentsFromNode returns [] and both `Inner` structs qualified to the +// bare `Inner` and merged onto one node — from_anon_a / from_anon_b cross-wired. A +// deterministic per-block discriminator (derived from the namespace node's start +// byte) keeps the two blocks' types distinct. +namespace { +struct Inner { + void from_anon_a() {} +}; +} +namespace { +struct Inner { + void from_anon_b() {} +}; +} diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-union-nested-tail-collision/main.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-union-nested-tail-collision/main.cpp new file mode 100644 index 000000000..063da384f --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-union-nested-tail-collision/main.cpp @@ -0,0 +1,17 @@ +// Same-tail structs nested in sibling NAMED unions (#1995). +// +// `union_specifier` was omitted from cppClassConfig.ancestorScopeNodeTypes, so a +// struct nested in `union U1` and one nested in `union U2` both qualified to the +// bare `Inner` and merged onto ONE Struct:...:Inner node — from_u1 / from_u2 +// cross-wired (dangling:0 but wrong). With the union scope qualified they must +// materialize distinct `U1.Inner` / `U2.Inner` nodes. +union U1 { + struct Inner { + void from_u1() {} + }; +}; +union U2 { + struct Inner { + void from_u2() {} + }; +}; diff --git a/gitnexus/test/integration/resolvers/cpp.test.ts b/gitnexus/test/integration/resolvers/cpp.test.ts index 5b9dc58ca..0b3ac2a29 100644 --- a/gitnexus/test/integration/resolvers/cpp.test.ts +++ b/gitnexus/test/integration/resolvers/cpp.test.ts @@ -3915,6 +3915,150 @@ describe('C++ inline nested same-tail collision — worker path parity (issue #1 }); }); +// --------------------------------------------------------------------------- +// Named-union nested same-tail collision — distinct qualified nodes (issue #1995) +// +// `union U1 { struct Inner {...} }` + `union U2 { struct Inner {...} }` must +// materialize TWO distinct Struct nodes (qn U1.Inner / U2.Inner). `union_specifier` +// was missing from cppClassConfig.ancestorScopeNodeTypes, so both Inner structs +// qualified to the bare `Inner` and merged (dangling:0 but wrong). Mirrors the +// #1978 inline-collision template; positive owner-identity, not just dangle-free. +// --------------------------------------------------------------------------- + +describe('C++ named-union nested same-tail collision — distinct qualified nodes (issue #1995)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'cpp-union-nested-tail-collision'), + () => {}, + ); + }, 60000); + + it('materializes U1.Inner and U2.Inner as two distinct Struct nodes [#1995-union]', () => { + const qns = getNodesByLabelFull(result, 'Struct') + .map((n) => n.properties.qualifiedName) + .filter((q) => q === 'U1.Inner' || q === 'U2.Inner') + .sort(); + expect(qns).toEqual(['U1.Inner', 'U2.Inner']); + }); + + it('owns from_u1 / from_u2 through their OWN distinct node (positive identity) [#1995-union]', () => { + expect(findDanglingEdges(result, ['HAS_METHOD'])).toEqual([]); + const hm = getRelationships(result, 'HAS_METHOD'); + const ownerQn = (target: string) => { + const e = hm.find((x) => x.target === target); + expect(e, `HAS_METHOD -> ${target}`).toBeDefined(); + return result.graph.getNode(e!.rel.sourceId)?.properties.qualifiedName; + }; + expect(ownerQn('from_u1')).toBe('U1.Inner'); + expect(ownerQn('from_u2')).toBe('U2.Inner'); + }); +}); + +// Worker-path parity for the named-union collision (parse-worker.ts must qualify +// the union scope byte-identically to the sequential parser). +describe('C++ named-union nested same-tail collision — worker path parity (issue #1995)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'cpp-union-nested-tail-collision'), + () => {}, + { workerThresholdsForTest: { minFiles: 1, minBytes: 1 }, workerPoolSize: 2 }, + ); + }, 120000); + + it('genuinely used the worker pool [#1995-union]', () => { + expect(result.usedWorkerPool).toBe(true); + }); + + it('materializes U1.Inner / U2.Inner and owns each method on the worker path [#1995-union]', () => { + const qns = getNodesByLabelFull(result, 'Struct') + .map((n) => n.properties.qualifiedName) + .filter((q) => q === 'U1.Inner' || q === 'U2.Inner') + .sort(); + expect(qns).toEqual(['U1.Inner', 'U2.Inner']); + expect(findDanglingEdges(result, ['HAS_METHOD'])).toEqual([]); + const hm = getRelationships(result, 'HAS_METHOD'); + const ownerQn = (target: string) => + result.graph.getNode(hm.find((x) => x.target === target)!.rel.sourceId)?.properties + .qualifiedName; + expect(ownerQn('from_u1')).toBe('U1.Inner'); + expect(ownerQn('from_u2')).toBe('U2.Inner'); + }); +}); + +// --------------------------------------------------------------------------- +// Anonymous-namespace nested same-tail collision — distinct nodes (issue #1995) +// +// Two `namespace { struct Inner {...} }` blocks must materialize TWO distinct +// Struct nodes. An anonymous namespace_definition has no `name` child, so both +// Inner structs qualified to the bare `Inner` and merged. A C++ extractScopeSegments +// override gives each anon block a deterministic start-byte discriminator. The +// discriminator value is not portable, so assert on node DISTINCTNESS (count==2 / +// distinct owner ids), never a literal qualifiedName. +// --------------------------------------------------------------------------- + +describe('C++ anonymous-namespace nested same-tail collision — distinct nodes (issue #1995)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'cpp-anon-ns-tail-collision'), () => {}); + }, 60000); + + it('materializes two distinct Struct Inner nodes (one per anon namespace) [#1995-anon]', () => { + const innerQns = getNodesByLabelFull(result, 'Struct') + .map((n) => n.properties.qualifiedName) + .filter((q): q is string => typeof q === 'string' && q.endsWith('Inner')); + // Start-byte discriminator → assert DISTINCTNESS, not a literal value. Pre-fix + // both Inner structs merge onto one bare `Inner` node (set size 1). + expect(new Set(innerQns).size).toBe(2); + }); + + it('owns from_anon_a / from_anon_b through DISTINCT nodes (no merge) [#1995-anon]', () => { + expect(findDanglingEdges(result, ['HAS_METHOD'])).toEqual([]); + const hm = getRelationships(result, 'HAS_METHOD'); + const a = hm.find((x) => x.target === 'from_anon_a'); + const b = hm.find((x) => x.target === 'from_anon_b'); + expect(a, 'HAS_METHOD -> from_anon_a').toBeDefined(); + expect(b, 'HAS_METHOD -> from_anon_b').toBeDefined(); + expect(a!.rel.sourceId).not.toBe(b!.rel.sourceId); + }); +}); + +// Worker-path parity for the anonymous-namespace collision: the start-byte +// discriminator must be deterministic across the worker's full-file parse. +describe('C++ anonymous-namespace nested same-tail collision — worker path parity (issue #1995)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'cpp-anon-ns-tail-collision'), + () => {}, + { workerThresholdsForTest: { minFiles: 1, minBytes: 1 }, workerPoolSize: 2 }, + ); + }, 120000); + + it('genuinely used the worker pool [#1995-anon]', () => { + expect(result.usedWorkerPool).toBe(true); + }); + + it('materializes two distinct anon Inner nodes and owns each method on the worker path [#1995-anon]', () => { + const innerQns = getNodesByLabelFull(result, 'Struct') + .map((n) => n.properties.qualifiedName) + .filter((q): q is string => typeof q === 'string' && q.endsWith('Inner')); + expect(new Set(innerQns).size).toBe(2); + expect(findDanglingEdges(result, ['HAS_METHOD'])).toEqual([]); + const hm = getRelationships(result, 'HAS_METHOD'); + const a = hm.find((x) => x.target === 'from_anon_a'); + const b = hm.find((x) => x.target === 'from_anon_b'); + expect(a, 'HAS_METHOD -> from_anon_a').toBeDefined(); + expect(b, 'HAS_METHOD -> from_anon_b').toBeDefined(); + expect(a!.rel.sourceId).not.toBe(b!.rel.sourceId); + }); +}); + // --------------------------------------------------------------------------- // Inline nested same-tail HERITAGE — qualified base resolution (issue #1982) //