fix(cpp): resolve cross-namespace same-tail inheritance bases bridge-held (#1993) (#2005)

* fix(cpp): resolve cross-namespace same-tail inheritance bases bridge-held (#1993)

PR #1981's bridge fixed within-namespace same-tail heritage (NS::A::Inner vs NS::B::Inner). The residual: a cross-namespace same-tail base (NS1::A::Inner vs NS2::A::Inner) both key the namespace-omitted `A.Inner` in the qualifiedNames index, so resolveQualifiedInheritanceBase couldn't pick a winner and the deriving classes cross-wired (DB's EXTENDS bound to NS1's A::Inner).

Fixed bridge-held via the existing `namespacePrefix` sidecar — no qualifiedName invariant flip, no resolution-index re-keying: (1) tagNamespacePrefixes also tags defs declared directly in a namespace (the deriving NS1::DA), composed identically to the class-nested path; (2) resolveQualifiedInheritanceBase breaks a same-tail tie by preferring the candidate whose namespacePrefix matches the deriving class's. Two-phase lookup, UDC, brace-init, file-local linkage untouched (def.qualifiedName + index keys unchanged).

New cpp-cross-namespace-same-tail fixture + registry-primary test (in the cpp parity expected-failures). Verified: cpp suite 287/287 primary, 209 + 78 skips legacy — no regression; tsc + prettier clean.

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

* test(cpp): worker-path parity for #1993 cross-namespace tie-break + correct narrative

Add the missing parse-worker.ts parity describe for the #1993 cross-namespace
same-tail heritage tie-break, mirroring the #1982/#1995 worker siblings
(workerThresholdsForTest minFiles:1/minBytes:1, workerPoolSize:2, usedWorkerPool
guard, and the same NS1.DA→NS1.A.Inner / NS2.DB→NS2.A.Inner base assertions), and
register both worker test names in LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES['cpp']
(registry-primary-only, like the sequential entry). Closes the DoD sequential≡worker
gap flagged in the tri-review of PR #2005.

Also correct the fixture/test narrative: the pre-fix failure is a CROSS-WIRE (DB's
EXTENDS binds NS1::A::Inner via the refuse-on-tie scope-walk fallback), not a silent
miss — the empirical pre-fix run shows the edge exists but points at the wrong target.

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

* refactor(scope-resolution): type the namespacePrefix sidecar; regen cpp bench baseline (#1993)

F4 follow-up to #1993: declare `namespacePrefix?: string` on SymbolDefinition
(gitnexus-shared) and drop the six `as { namespacePrefix?: string }` casts in
walkers.ts / graph-bridge/ids.ts that #1993 introduced. Pure type-level — the `as`
assertions erase at compile time, runtime is byte-identical, and the field stays a
sidecar (no graph-node identity; the qualifiedName-keyed index is untouched).

Also regenerate the cpp scope-capture bench baseline: rebased onto main (now
carrying #1995's cpp fixtures), #1993 adds cpp-cross-namespace-same-tail, growing
the cpp-* corpus 272->273 and drifting the fingerprint d63ded6->6d6207ae. Pure
fixture-corpus drift — no scope-extractor change.

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 10:34:56 +01:00 committed by GitHub
parent e316222cd5
commit 9f3bcee7fc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 158 additions and 5 deletions

View file

@ -59,4 +59,13 @@ export interface SymbolDefinition {
isExplicit?: boolean; isExplicit?: boolean;
/** Links Method/Constructor/Property to owning Class/Struct/Trait nodeId */ /** Links Method/Constructor/Property to owning Class/Struct/Trait nodeId */
ownerId?: string; ownerId?: string;
/** #1982/#1993: bridge-held enclosing-namespace path (e.g. `NS1`, `Outer.Inner`)
* tagged during the C++ resolution phase. Lets the graph bridge retry a
* namespace-prefixed node-lookup key and lets the qualified-base resolver
* break same-tail cross-namespace inheritance ties. A deliberate sidecar,
* separate from `qualifiedName`: it does NOT participate in graph node
* identity (node keys derive from filePath/type/qualifiedName) and leaves the
* qualifiedName-keyed resolution index untouched. Absent for the common case
* (non-namespace-nested defs and all non-C++ languages). */
namespacePrefix?: string;
} }

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." "_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": { "cpp": {
"fingerprint": "d63ded6251a89d42cc63941ac3fdb093bf5b59ae483b135786766f925cdc91c5", "fingerprint": "6d6207ae1df3943c5fae28983e0c294e55225456e7cf39af1d46fda21b6787c4",
"scaling_budget": 1.5, "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.", "_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, 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." "_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. #1993: + cpp-cross-namespace-same-tail fixture — pure fixture-corpus drift; fixture_count 272->273, fingerprint d63ded6->6d6207ae."
}, },
"csharp": { "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.)", "_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

@ -80,6 +80,8 @@ export function resolveDefGraphId(
parameterTypeClasses?: readonly ParameterTypeClass[]; parameterTypeClasses?: readonly ParameterTypeClass[];
templateArguments?: readonly string[]; templateArguments?: readonly string[];
templateConstraints?: unknown; templateConstraints?: unknown;
/** #1982 bridge-held namespace path; see `SymbolDefinition.namespacePrefix`. */
namespacePrefix?: string;
}, },
nodeLookup: GraphNodeLookup, nodeLookup: GraphNodeLookup,
): string | undefined { ): string | undefined {
@ -149,7 +151,7 @@ export function resolveDefGraphId(
// namespace-prefixed key (tagged by `tagNamespacePrefixes`) BEFORE the // namespace-prefixed key (tagged by `tagNamespacePrefixes`) BEFORE the
// simple-name fallback, so same-tail nested bases don't collapse across // simple-name fallback, so same-tail nested bases don't collapse across
// sibling namespace members via `simpleKey`. // sibling namespace members via `simpleKey`.
const nsPrefix = (def as { namespacePrefix?: string }).namespacePrefix; const nsPrefix = def.namespacePrefix;
if (nsPrefix !== undefined && nsPrefix.length > 0) { if (nsPrefix !== undefined && nsPrefix.length > 0) {
const nsHit = nodeLookup.get(qualifiedKey(filePath, def.type, `${nsPrefix}.${qn}`)); const nsHit = nodeLookup.get(qualifiedKey(filePath, def.type, `${nsPrefix}.${qn}`));
if (nsHit !== undefined) return nsHit; if (nsHit !== undefined) return nsHit;

View file

@ -385,7 +385,27 @@ function resolveQualifiedInheritanceBase(
} }
} }
if (count === 1) return unique; if (count === 1) return unique;
if (count > 1) return undefined; // genuine tie at this key → refuse, don't guess if (count > 1) {
// #1993: same-tail bases collide at this namespace-omitted key (`NS1::A::Inner`
// and `NS2::A::Inner` both key `A.Inner`). Break the tie with the bridge's
// `namespacePrefix` sidecar — prefer the candidate in the SAME enclosing
// namespace as the deriving class. Bridge-held: `def.qualifiedName` and the
// index keys are untouched; still refuse when the sidecar can't pick a unique.
const childPrefix = enclosingClassDef?.namespacePrefix;
if (childPrefix !== undefined && childPrefix.length > 0) {
let nsUnique: SymbolDefinition | undefined;
let nsCount = 0;
for (const id of ids) {
const def = scopes.defs.get(id);
if (def !== undefined && isClassLike(def.type) && def.namespacePrefix === childPrefix) {
nsUnique = def;
nsCount++;
}
}
if (nsCount === 1) return nsUnique;
}
return undefined; // genuine tie → refuse, don't guess
}
} }
return undefined; return undefined;
} }
@ -853,7 +873,32 @@ export function tagNamespacePrefixes(parsed: ParsedFile): void {
const q = def.qualifiedName; const q = def.qualifiedName;
if (q === undefined || q.length === 0) continue; if (q === undefined || q.length === 0) continue;
if (q === prefix || q.startsWith(`${prefix}.`)) continue; // already namespaced if (q === prefix || q.startsWith(`${prefix}.`)) continue; // already namespaced
(def as { namespacePrefix?: string }).namespacePrefix = prefix; def.namespacePrefix = prefix;
}
}
// #1993: also tag defs declared DIRECTLY in a Namespace scope with that
// namespace's OWN full path. The loop above only reaches class-nested defs
// (`A::Inner`); a deriving class like `NS1::DA` lives in the namespace scope and
// is skipped, so it would carry no prefix and a same-tail cross-namespace base
// tie (`NS1::A::Inner` vs `NS2::A::Inner`) could not be broken by the deriving
// side. Composed identically to the class-nested path (enclosing tails + own
// tail) so the two agree; still sidecar-only (`qualifiedName` untouched).
for (const scope of parsed.scopes) {
if (scope.kind !== 'Namespace') continue;
const ownNsDef = scope.ownedDefs.find((d) => d.type === 'Namespace');
const ownQ = ownNsDef?.qualifiedName;
if (ownQ === undefined || ownQ.length === 0) continue;
const ownTail = ownQ.slice(ownQ.lastIndexOf('.') + 1);
const parentPrefix = namespacePrefixOf(scope);
const fullPrefix = parentPrefix.length > 0 ? `${parentPrefix}.${ownTail}` : ownTail;
for (const def of scope.ownedDefs) {
if (def.type === 'Namespace') continue;
const q = def.qualifiedName;
if (q === undefined || q.length === 0) continue;
if (q === fullPrefix || q.startsWith(`${fullPrefix}.`)) continue; // already namespaced
if (def.namespacePrefix !== undefined) continue;
def.namespacePrefix = fullPrefix;
} }
} }
} }

View file

@ -0,0 +1,23 @@
// Cross-namespace same-tail nested heritage (#1993).
//
// NS1::A::Inner and NS2::A::Inner are distinct nested types whose scope-model
// def.qualifiedName both drops the enclosing namespace and reads `A.Inner`. They
// collide in the qualifiedNames resolution index, so resolveQualifiedInheritanceBase
// hit refuse-on-tie and the scope-walk fallback first-won to NS1's Inner — DB
// CROSS-WIRED its EXTENDS to NS1::A::Inner (DA resolved correctly only by that
// first-wins luck). The cross-wire still lands on a real node, so findDanglingEdges
// stays blind to it. The `namespacePrefix` sidecar breaks the tie (bridge-held):
// DA's enclosing namespace NS1 selects NS1::A::Inner.
namespace NS1 {
struct A {
struct Inner {};
};
struct DA : A::Inner {};
} // namespace NS1
namespace NS2 {
struct A {
struct Inner {};
};
struct DB : A::Inner {};
} // namespace NS2

View file

@ -4188,6 +4188,73 @@ describe('C++ namespaced same-tail nested heritage — worker path parity (issue
// `A.Inner` key is tried → the global type. Registry-primary only. // `A.Inner` key is tried → the global type. Registry-primary only.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Cross-namespace same-tail nested heritage — bridge-held tie-break (issue #1993)
//
// NS1::A::Inner and NS2::A::Inner both key the namespace-omitted `A.Inner` in the
// qualifiedNames index, so resolveQualifiedInheritanceBase refused-on-tie and the
// scope-walk fallback first-wins to NS1's Inner — DB CROSS-WIRES its EXTENDS to
// NS1::A::Inner (DA resolves correctly only by that first-wins luck). The cross-wire
// still resolves to a real node, so findDanglingEdges can't catch it, and the #1982
// bridge can't reach it either (it rescues the structure-phase node lookup, not the
// resolution-index tie). The `namespacePrefix` sidecar breaks the tie: DA's enclosing
// namespace NS1 selects NS1::A::Inner. Bridge-held — def.qualifiedName and the index
// keys are unchanged. Registry-primary only (the qualified-base resolver is the bridge).
// ---------------------------------------------------------------------------
describe('C++ cross-namespace same-tail nested heritage — bridge-held tie-break (issue #1993)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'cpp-cross-namespace-same-tail'),
() => {},
);
}, 60000);
it('routes NS1.DA EXTENDS NS1.A.Inner and NS2.DB EXTENDS NS2.A.Inner (no cross-ns tie)', () => {
const extendsEdges = getRelationships(result, 'EXTENDS');
const baseQnOf = (derivedQn: string) => {
const e = extendsEdges.find(
(x) => result.graph.getNode(x.rel.sourceId)?.properties.qualifiedName === derivedQn,
);
expect(e, `EXTENDS from ${derivedQn}`).toBeDefined();
return result.graph.getNode(e!.rel.targetId)?.properties.qualifiedName;
};
expect(baseQnOf('NS1.DA')).toBe('NS1.A.Inner');
expect(baseQnOf('NS2.DB')).toBe('NS2.A.Inner');
});
});
describe('C++ cross-namespace same-tail nested heritage — worker path parity (issue #1993)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'cpp-cross-namespace-same-tail'),
() => {},
{ workerThresholdsForTest: { minFiles: 1, minBytes: 1 }, workerPoolSize: 2 },
);
}, 120000);
it('genuinely used the worker pool for the cross-namespace fixture', () => {
expect(result.usedWorkerPool).toBe(true);
});
it('routes NS1.DA / NS2.DB to their own namespaced base on the worker path (no cross-ns tie)', () => {
const extendsEdges = getRelationships(result, 'EXTENDS');
const baseQnOf = (derivedQn: string) => {
const e = extendsEdges.find(
(x) => result.graph.getNode(x.rel.sourceId)?.properties.qualifiedName === derivedQn,
);
expect(e, `EXTENDS from ${derivedQn} (worker)`).toBeDefined();
return result.graph.getNode(e!.rel.targetId)?.properties.qualifiedName;
};
expect(baseQnOf('NS1.DA')).toBe('NS1.A.Inner');
expect(baseQnOf('NS2.DB')).toBe('NS2.A.Inner');
});
});
describe('C++ root-anchored base ignores enclosing-relative type (issue #1982)', () => { describe('C++ root-anchored base ignores enclosing-relative type (issue #1982)', () => {
let result: PipelineResult; let result: PipelineResult;

View file

@ -621,6 +621,13 @@ const LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES: Readonly<Record<string, Readonly
'resolves NS::DB : B::Inner → EXTENDS the NS.B.Inner node (not NS.A.Inner)', 'resolves NS::DB : B::Inner → EXTENDS the NS.B.Inner node (not NS.A.Inner)',
'genuinely used the worker pool for the namespaced fixture', 'genuinely used the worker pool for the namespaced fixture',
'resolves NS::DA / NS::DB to their own namespaced base on the worker path', 'resolves NS::DA / NS::DB to their own namespaced base on the worker path',
// #1993: cross-namespace same-tail base, broken by the namespacePrefix sidecar
// tie-break in the registry-primary resolveQualifiedInheritanceBase. Legacy only.
'routes NS1.DA EXTENDS NS1.A.Inner and NS2.DB EXTENDS NS2.A.Inner (no cross-ns tie)',
// #1993 worker-path parity (mirrors the sequential entry above): the sidecar
// tie-break must resolve identically on the parse-worker path. Registry-primary only.
'genuinely used the worker pool for the cross-namespace fixture',
'routes NS1.DA / NS2.DB to their own namespaced base on the worker path (no cross-ns tie)',
// #1982 follow-up: C++ worker-path DerivedA parity + duplicate guard. // #1982 follow-up: C++ worker-path DerivedA parity + duplicate guard.
'resolves DerivedA : Outer::Inner → EXTENDS Outer.Inner on the worker path (parity + no duplicate)', 'resolves DerivedA : Outer::Inner → EXTENDS Outer.Inner on the worker path (parity + no duplicate)',
// #1982 follow-up: root-anchored base must not bind to an enclosing-relative // #1982 follow-up: root-anchored base must not bind to an enclosing-relative