diff --git a/gitnexus-shared/src/scope-resolution/symbol-definition.ts b/gitnexus-shared/src/scope-resolution/symbol-definition.ts index d1b30abcc..06814db3c 100644 --- a/gitnexus-shared/src/scope-resolution/symbol-definition.ts +++ b/gitnexus-shared/src/scope-resolution/symbol-definition.ts @@ -59,4 +59,13 @@ export interface SymbolDefinition { isExplicit?: boolean; /** Links Method/Constructor/Property to owning Class/Struct/Trait nodeId */ 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; } diff --git a/gitnexus/bench/scope-capture/baselines.json b/gitnexus/bench/scope-capture/baselines.json index 4990a2097..cef734fa8 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": "d63ded6251a89d42cc63941ac3fdb093bf5b59ae483b135786766f925cdc91c5", + "fingerprint": "6d6207ae1df3943c5fae28983e0c294e55225456e7cf39af1d46fda21b6787c4", "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. #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": { "_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/scope-resolution/graph-bridge/ids.ts b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ids.ts index 25927a579..92f3f2d8b 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ids.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ids.ts @@ -80,6 +80,8 @@ export function resolveDefGraphId( parameterTypeClasses?: readonly ParameterTypeClass[]; templateArguments?: readonly string[]; templateConstraints?: unknown; + /** #1982 bridge-held namespace path; see `SymbolDefinition.namespacePrefix`. */ + namespacePrefix?: string; }, nodeLookup: GraphNodeLookup, ): string | undefined { @@ -149,7 +151,7 @@ export function resolveDefGraphId( // namespace-prefixed key (tagged by `tagNamespacePrefixes`) BEFORE the // simple-name fallback, so same-tail nested bases don't collapse across // sibling namespace members via `simpleKey`. - const nsPrefix = (def as { namespacePrefix?: string }).namespacePrefix; + const nsPrefix = def.namespacePrefix; if (nsPrefix !== undefined && nsPrefix.length > 0) { const nsHit = nodeLookup.get(qualifiedKey(filePath, def.type, `${nsPrefix}.${qn}`)); if (nsHit !== undefined) return nsHit; diff --git a/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts b/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts index b594d4acf..23cc0aa8e 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts @@ -385,7 +385,27 @@ function resolveQualifiedInheritanceBase( } } 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; } @@ -853,7 +873,32 @@ export function tagNamespacePrefixes(parsed: ParsedFile): void { const q = def.qualifiedName; if (q === undefined || q.length === 0) continue; 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; } } } diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-cross-namespace-same-tail/main.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-cross-namespace-same-tail/main.cpp new file mode 100644 index 000000000..f16b783be --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-cross-namespace-same-tail/main.cpp @@ -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 diff --git a/gitnexus/test/integration/resolvers/cpp.test.ts b/gitnexus/test/integration/resolvers/cpp.test.ts index 0b3ac2a29..a57177848 100644 --- a/gitnexus/test/integration/resolvers/cpp.test.ts +++ b/gitnexus/test/integration/resolvers/cpp.test.ts @@ -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. // --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// 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)', () => { let result: PipelineResult; diff --git a/gitnexus/test/integration/resolvers/helpers.ts b/gitnexus/test/integration/resolvers/helpers.ts index 09aa997eb..c778d2fab 100644 --- a/gitnexus/test/integration/resolvers/helpers.ts +++ b/gitnexus/test/integration/resolvers/helpers.ts @@ -621,6 +621,13 @@ const LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES: Readonly