fix(cpp): distinct nodes for union- and anonymous-namespace-nested same-tail types (#1995) (#2004)

* fix(cpp): qualify types nested in a named union by their union scope (#1995)

`union_specifier` was missing from cppClassConfig.ancestorScopeNodeTypes, so a struct nested in `union U1` and one in `union U2` both qualified to the bare `Inner` and merged onto one Struct:...:Inner node — from_u1/from_u2 cross-wired (invisible to findDanglingEdges). Adding `union_specifier` lets buildQualifiedName pick up the named union's `name` segment, materializing distinct `U1.Inner` / `U2.Inner` nodes. Anonymous unions have no `name` child and correctly contribute nothing (members inject into the enclosing scope); the separate C config is untouched. New fixture + positive-identity tests (sequential + worker, both legs).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cpp): distinct nodes for anonymous-namespace-nested same-tail types (#1995)

An anonymous `namespace { }` is a namespace_definition with no `name` child, so the scope walker dropped it (empty segment) and two `namespace { struct Inner {} }` blocks in one TU collapsed onto a single `Inner` node — from_anon_a/from_anon_b cross-wired. A C++ `extractScopeSegments` override (the first consumer of the existing config hook) gives each anonymous namespace a deterministic per-block discriminator from its start byte, keeping the nested types distinct. Named scopes (incl. `inline namespace`) and anonymous unions are unaffected. Deterministic across the sequential and worker full-file parses. New fixture + tests assert node DISTINCTNESS (count==2 / distinct owners), not the non-portable discriminator value.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(cpp): regenerate cpp scope-capture bench baseline for #1995 fixtures

Rebased onto main (which now carries #1992 + its rust baseline). #1995 adds the
cpp-union-nested-tail-collision and cpp-anon-ns-tail-collision fixtures, growing
the cpp-* corpus 270->272 and drifting the order-independent fingerprint
(538e8be -> d63ded6). Pure fixture-corpus drift — no scope-extractor change;
existing fixtures' captures byte-identical. (cpp has no captures-golden gate, so
only the bench baseline needs regenerating.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gergő Magyar 2026-06-04 09:58:26 +01:00 committed by GitHub
parent 16014657c8
commit e316222cd5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 204 additions and 3 deletions

View file

@ -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.)",

View file

@ -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;

View file

@ -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() {}
};
}

View file

@ -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() {}
};
};

View file

@ -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)
//