From dc122897a954894e5441ee3cc6ba2cc2b05299d4 Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Tue, 2 Jun 2026 23:10:09 +0000 Subject: [PATCH 01/16] fix(ingestion): qualify nested-type node identity for C++/Ruby (#1978) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nested types sharing a tail name in one file — C++ `Outer::Inner` vs `Other::Inner`, Ruby `Outer::Inner` vs `Other::Inner` modules — silently merged into a single graph node keyed by the simple tail (`Struct:file:Inner`), cross-wiring their methods/properties onto one owner. Key class-like type nodes (Class/Struct/Interface/Enum/Record) by their normalized fully-qualified path (`Struct:file:Outer.Inner`) instead of the simple name. Gated per-language by a new `qualifiedNodeId` config flag (default false → byte-identical for every other language); enabled here for C++ and Ruby. - class-types.ts / generic.ts: `qualifiedNodeId` flag on ClassExtractor + config - ast-helpers.ts: findEnclosingClassInfo gains an optional getQualifiedOwnerName hook + EnclosingClassInfo.qualifiedClassId, so member-owner edges resolve to the qualified class node id (owner id == node id by construction) - parsing-processor.ts + parse-worker.ts: flag-gated qualified node-id + owner edges on both the sequential and worker parse paths (incl. routed properties) - call-processor.ts: same qualifier in the routed-property pre-pass (lockstep with the worker `kind === 'properties'` block) - configs/c-cpp.ts, configs/ruby.ts: qualifiedNodeId: true Method/Property node ids stay simple-qualified; only type nodes get the qualified id. Deferred to a resolution-side follow-up: Ruby SAME-TAIL routed-property/mixin owner identity under registry-primary (`emitRubyMixinEdges` keys owners by the simple tail name, last-wins); and Rust inherent-impl methods (impl_item is not a typeDeclaration — its #1978 test is describe.skip). Tests: same-tail collision fixtures + #1978 resolver tests for C++/Ruby (positive owner identity, R7), a worker-path parity block, and an unambiguous nested attr_accessor case; the C++ #1975 out-of-line test updated to assert qualified-id distinctness (forward-decl + out-of-line now unify). Verified green on both parity legs, the worker path, and tsc. Co-Authored-By: Claude Opus 4.8 (1M context) --- gitnexus/src/core/ingestion/call-processor.ts | 13 +- .../class-extractors/configs/c-cpp.ts | 3 + .../class-extractors/configs/ruby.ts | 3 + .../ingestion/class-extractors/generic.ts | 1 + gitnexus/src/core/ingestion/class-types.ts | 15 +++ .../src/core/ingestion/parsing-processor.ts | 55 +++++++-- .../src/core/ingestion/utils/ast-helpers.ts | 39 ++++++ .../core/ingestion/workers/parse-worker.ts | 65 ++++++++-- .../cpp-nested-tail-collision/shapes.cpp | 11 ++ .../ruby-nested-tail-collision/nested.rb | 19 +++ .../rust-nested-tail-collision/lib.rs | 12 ++ .../test/integration/resolvers/cpp.test.ts | 114 ++++++++++++++++-- .../test/integration/resolvers/ruby.test.ts | 53 ++++++++ .../test/integration/resolvers/rust.test.ts | 42 +++++++ 14 files changed, 410 insertions(+), 35 deletions(-) create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-nested-tail-collision/shapes.cpp create mode 100644 gitnexus/test/fixtures/lang-resolution/ruby-nested-tail-collision/nested.rb create mode 100644 gitnexus/test/fixtures/lang-resolution/rust-nested-tail-collision/lib.rs diff --git a/gitnexus/src/core/ingestion/call-processor.ts b/gitnexus/src/core/ingestion/call-processor.ts index c8c62921aa..a3ca9c8986 100644 --- a/gitnexus/src/core/ingestion/call-processor.ts +++ b/gitnexus/src/core/ingestion/call-processor.ts @@ -966,12 +966,23 @@ export const processCalls = async ( const routed = callRouter(callNameNode.text, captureMap['call']); if (!routed || routed.kind !== 'properties') return; + // #1978: thread the qualifier so a routed property's owner edge points at + // the *qualified* nested-class node (Shapes.Circle) instead of a now-nonexistent + // simple `Class:file:Circle` id. Gated on the flag → byte-identical when off. + // MUST stay in lockstep with the worker `kind === 'properties'` block. + const propGetQualifiedOwnerName = + provider.classExtractor?.qualifiedNodeId === true + ? (node: SyntaxNode, simpleName: string): string | null => + provider.classExtractor!.extractQualifiedName(node, simpleName) + : undefined; const propEnclosingInfo = findEnclosingClassInfo( captureMap['call'], file.path, provider.resolveEnclosingOwner, + propGetQualifiedOwnerName, ); - const propEnclosingClassId = propEnclosingInfo?.classId ?? null; + const propEnclosingClassId = + propEnclosingInfo?.qualifiedClassId ?? propEnclosingInfo?.classId ?? null; // Enrich routed properties with FieldExtractor metadata so types // discovered from constructor assignments (e.g. `@address = Address.new`) 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 fb5df99c35..d39888aa14 100644 --- a/gitnexus/src/core/ingestion/class-extractors/configs/c-cpp.ts +++ b/gitnexus/src/core/ingestion/class-extractors/configs/c-cpp.ts @@ -46,6 +46,9 @@ export const cppClassConfig: ClassExtractionConfig = { language: SupportedLanguages.CPlusPlus, typeDeclarationNodes: ['class_specifier', 'struct_specifier', 'enum_specifier'], ancestorScopeNodeTypes: ['namespace_definition', 'class_specifier', 'struct_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, extractName: (node) => { const nameNode = node.childForFieldName?.('name'); if (!nameNode) return undefined; diff --git a/gitnexus/src/core/ingestion/class-extractors/configs/ruby.ts b/gitnexus/src/core/ingestion/class-extractors/configs/ruby.ts index 2c4c711bdc..13f1fdd434 100644 --- a/gitnexus/src/core/ingestion/class-extractors/configs/ruby.ts +++ b/gitnexus/src/core/ingestion/class-extractors/configs/ruby.ts @@ -7,4 +7,7 @@ export const rubyClassConfig: ClassExtractionConfig = { language: SupportedLanguages.Ruby, typeDeclarationNodes: ['class'], ancestorScopeNodeTypes: ['module', 'class'], + // #1978: key nested-type nodes by their fully-qualified path (Outer.Inner) so + // same-tail classes nested under different modules stay distinct. + qualifiedNodeId: true, }; diff --git a/gitnexus/src/core/ingestion/class-extractors/generic.ts b/gitnexus/src/core/ingestion/class-extractors/generic.ts index 5f20d1dc2c..1f11adc44e 100644 --- a/gitnexus/src/core/ingestion/class-extractors/generic.ts +++ b/gitnexus/src/core/ingestion/class-extractors/generic.ts @@ -165,6 +165,7 @@ export function createClassExtractor(config: ClassExtractionConfig): ClassExtrac return { language: config.language, + qualifiedNodeId: config.qualifiedNodeId ?? false, isTypeDeclaration(node: SyntaxNode): boolean { return typeDeclarationSet.has(node.type); diff --git a/gitnexus/src/core/ingestion/class-types.ts b/gitnexus/src/core/ingestion/class-types.ts index 9407d41fae..2e3d1f6886 100644 --- a/gitnexus/src/core/ingestion/class-types.ts +++ b/gitnexus/src/core/ingestion/class-types.ts @@ -28,6 +28,13 @@ export interface ClassCaptureContext { */ export interface ClassExtractor { language: SupportedLanguages; + /** + * When true, this language's nested-type graph nodes are keyed by their + * fully-qualified path (e.g. `Class:file:Outer.Inner`) instead of the simple + * tail name, so same-tail nested types in one file stay distinct (#1978). + * Surfaced from `ClassExtractionConfig.qualifiedNodeId`. + */ + readonly qualifiedNodeId: boolean; isTypeDeclaration(node: SyntaxNode): boolean; extract( node: SyntaxNode, @@ -48,6 +55,14 @@ export interface ClassExtractionConfig { typeDeclarationNodes: string[]; fileScopeNodeTypes?: string[]; ancestorScopeNodeTypes?: string[]; + /** + * Opt-in (#1978): key this language's nested-type graph nodes (and their + * member-owner edges) by the fully-qualified path instead of the simple tail + * name, so same-tail nested types in one file stop colliding. Default false. + * Requires `ancestorScopeNodeTypes` to be set so `buildQualifiedName` can walk + * the scope chain. + */ + qualifiedNodeId?: boolean; scopeNameNodeTypes?: string[]; extractName?: (node: SyntaxNode) => string | undefined; extractType?: (node: SyntaxNode) => ClassLikeNodeLabel | undefined; diff --git a/gitnexus/src/core/ingestion/parsing-processor.ts b/gitnexus/src/core/ingestion/parsing-processor.ts index 6c77e79585..62346963f0 100644 --- a/gitnexus/src/core/ingestion/parsing-processor.ts +++ b/gitnexus/src/core/ingestion/parsing-processor.ts @@ -297,10 +297,16 @@ const cachedFindEnclosingClassInfo = ( node: SyntaxNode, filePath: string, resolveEnclosingOwner?: (node: SyntaxNode) => SyntaxNode | null, + getQualifiedOwnerName?: (node: SyntaxNode, simpleName: string) => string | null, ): EnclosingClassInfo | null => { const cached = classInfoCache.get(node); if (cached !== undefined) return cached; - const result = findEnclosingClassInfo(node, filePath, resolveEnclosingOwner); + const result = findEnclosingClassInfo( + node, + filePath, + resolveEnclosingOwner, + getQualifiedOwnerName, + ); classInfoCache.set(node, result); return result; }; @@ -602,24 +608,55 @@ const processParsingSequential = async ( nodeLabel === 'Constructor' || nodeLabel === 'Property' || nodeLabel === 'Function'; + // #1978: when the language opts into qualified node ids, thread the + // class-extractor's qualifier into the enclosing-owner walk so a nested + // member resolves to its owner's *qualified* id (Outer.Inner) — matching + // the qualified class node id computed below. Gated on the flag, so the + // owner walk and its cache entry are byte-identical when the flag is off. + const getQualifiedOwnerName = + provider.classExtractor?.qualifiedNodeId === true + ? (node: SyntaxNode, simpleName: string): string | null => + provider.classExtractor!.extractQualifiedName(node, simpleName) + : undefined; const enclosingClassInfo = needsOwner ? cachedFindEnclosingClassInfo( nameNode || definitionNodeForRange, file.path, provider.resolveEnclosingOwner, + getQualifiedOwnerName, ) : null; - const enclosingClassId = enclosingClassInfo?.classId ?? null; + const enclosingClassId = + enclosingClassInfo?.qualifiedClassId ?? enclosingClassInfo?.classId ?? null; const objectLiteralOwnerInfo = !enclosingClassId && nodeLabel === 'Method' && definitionNode ? findObjectLiteralBindingInfo(definitionNode, file.path) : null; + // #1978: a class-like node opts into a fully-qualified node id (Outer.Inner) + // when the language enables qualifiedNodeId, so same-tail nested types in one + // file stay distinct. Hoisted ABOVE the node-id/qualifiedName use below and + // derived from the SAME extractQualifiedName the owner edge uses, so the + // member's owner id and the class node id agree. The order is load-bearing. + const classNodeForSymbol = definitionNodeForRange || definitionNode || nameNode; + const qualifiedTypeName = + extractedClassSymbol?.qualifiedName ?? + (classNodeForSymbol && provider.classExtractor?.isTypeDeclaration(classNodeForSymbol) + ? (provider.classExtractor.extractQualifiedName(classNodeForSymbol, nodeName) ?? nodeName) + : undefined); + // Qualify method/property IDs with enclosing class name to avoid collisions - // e.g. "Method:animal.dart:Animal.speak" vs "Method:animal.dart:Dog.speak" - const qualifiedName = enclosingClassInfo - ? `${enclosingClassInfo.className}.${nodeName}` - : nodeName; + // e.g. "Method:animal.dart:Animal.speak" vs "Method:animal.dart:Dog.speak". + // Class-like nodes use their own fully-qualified path as the id key when the + // language enables qualifiedNodeId (#1978); everything else is unchanged. + const qualifiedName = + isClassLikeLabel && + provider.classExtractor?.qualifiedNodeId === true && + qualifiedTypeName !== undefined + ? qualifiedTypeName + : enclosingClassInfo + ? `${enclosingClassInfo.className}.${nodeName}` + : nodeName; // Extract method metadata for Function/Method/Constructor nodes BEFORE generating // the node ID — parameterCount is needed to disambiguate overloaded methods. @@ -778,12 +815,6 @@ const processParsingSequential = async ( nodeLabel, `${file.path}:${qualifiedName}${classTemplateTag}${arityTag}${constraintsTag}${parameterShapeTag}`, ); - const classNodeForSymbol = definitionNodeForRange || definitionNode || nameNode; - const qualifiedTypeName = - extractedClassSymbol?.qualifiedName ?? - (classNodeForSymbol && provider.classExtractor?.isTypeDeclaration(classNodeForSymbol) - ? (provider.classExtractor.extractQualifiedName(classNodeForSymbol, nodeName) ?? nodeName) - : undefined); const frameworkHint = definitionNode ? detectFrameworkFromAST(language, (definitionNode.text || '').slice(0, 300)) : null; diff --git a/gitnexus/src/core/ingestion/utils/ast-helpers.ts b/gitnexus/src/core/ingestion/utils/ast-helpers.ts index f7e7917ea6..4fcf870d2c 100644 --- a/gitnexus/src/core/ingestion/utils/ast-helpers.ts +++ b/gitnexus/src/core/ingestion/utils/ast-helpers.ts @@ -321,6 +321,15 @@ export function getLabelFromCaptures( export interface EnclosingClassInfo { classId: string; // e.g. "Class:animal.dart:Animal" className: string; // e.g. "Animal" + /** + * The owner node id keyed by the enclosing type's FULLY-QUALIFIED path + * (e.g. "Class:file:Outer.Inner"), present only when the language opts into + * `qualifiedNodeId` AND the enclosing type is actually nested (#1978). + * Consumers building HAS_METHOD/HAS_PROPERTY owner edges use this in + * preference to `classId` so the edge source matches the qualified class + * node id. When absent, `classId` (the simple-tail key) is unchanged. + */ + qualifiedClassId?: string; } /** Walk up AST to find enclosing class/struct/interface/impl, return its ID and name. @@ -345,6 +354,16 @@ export const findEnclosingClassInfo = ( node: SyntaxNode, filePath: string, resolveEnclosingOwner?: (node: SyntaxNode) => SyntaxNode | null, + /** + * Optional (#1978): returns the enclosing type's fully-qualified name + * (e.g. "Outer.Inner") for a type-declaration container, or null. Callers + * pass `classExtractor.extractQualifiedName` ONLY when the language's + * `qualifiedNodeId` flag is on — so when omitted, behavior is byte-identical + * to before (qualifiedClassId stays undefined). Used by the standard + * class-container branch to compute `qualifiedClassId` from the SAME function + * the node-id is built from, guaranteeing owner-id == node-id by construction. + */ + getQualifiedOwnerName?: (node: SyntaxNode, simpleName: string) => string | null, ): EnclosingClassInfo | null => { let current = node.parent; let iterations = 0; @@ -485,9 +504,29 @@ export const findEnclosingClassInfo = ( templateArguments !== undefined ? `${stripTemplateArguments(nameNode.text)}${templateArgumentsIdTag(templateArguments)}` : nameNode.text; + // #1978: when the language opts into qualified node ids, key the owner + // edge by the enclosing type's qualified path (e.g. "Outer.Inner") so it + // matches the qualified class node id. Derived from the SAME + // extractQualifiedName the node-id uses → agree by construction. Only set + // when actually nested (qualified !== simple); top-level types are + // unchanged. (Go receiver / Rust impl branches return earlier and are + // intentionally untouched here.) + const qualifiedOwnerName = getQualifiedOwnerName?.(current, nameNode.text); + const qualifiedClassId = + qualifiedOwnerName != null && qualifiedOwnerName !== nameNode.text + ? generateId( + label, + `${filePath}:${ + templateArguments !== undefined + ? `${stripTemplateArguments(qualifiedOwnerName)}${templateArgumentsIdTag(templateArguments)}` + : qualifiedOwnerName + }`, + ) + : undefined; return { classId: generateId(label, `${filePath}:${classIdName}`), className: nameNode.text, + ...(qualifiedClassId !== undefined ? { qualifiedClassId } : {}), }; } } diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index 8a7946d327..a3882c00a2 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -753,11 +753,17 @@ const cachedFindEnclosingClassInfo = ( node: SyntaxNode, filePath: string, resolveEnclosingOwner?: (node: SyntaxNode) => SyntaxNode | null, + getQualifiedOwnerName?: (node: SyntaxNode, simpleName: string) => string | null, ): EnclosingClassInfo | null => { const cached = classIdCache.get(node); if (cached !== undefined) return cached; - const result = findEnclosingClassInfo(node, filePath, resolveEnclosingOwner); + const result = findEnclosingClassInfo( + node, + filePath, + resolveEnclosingOwner, + getQualifiedOwnerName, + ); classIdCache.set(node, result); return result; }; @@ -1517,12 +1523,23 @@ const processFileGroup = ( } if (routed.kind === 'properties') { + // #1978: thread the qualifier so a routed property's owner edge + // points at the *qualified* nested-class node (Outer.Inner) rather + // than a now-nonexistent simple `Class:file:Inner` id. Gated on the + // flag → byte-identical when off. Mirrors the main owner path. + const propGetQualifiedOwnerName = + provider.classExtractor?.qualifiedNodeId === true + ? (node: SyntaxNode, simpleName: string): string | null => + provider.classExtractor!.extractQualifiedName(node, simpleName) + : undefined; const propEnclosingInfo = cachedFindEnclosingClassInfo( captureMap['call'], file.path, provider.resolveEnclosingOwner, + propGetQualifiedOwnerName, ); - const propEnclosingClassId = propEnclosingInfo?.classId ?? null; + const propEnclosingClassId = + propEnclosingInfo?.qualifiedClassId ?? propEnclosingInfo?.classId ?? null; // Enrich routed properties with FieldExtractor metadata let routedFieldMap: Map | undefined; if (provider.fieldExtractor && typeEnv) { @@ -1803,23 +1820,51 @@ const processFileGroup = ( nodeLabel === 'Constructor' || nodeLabel === 'Property' || nodeLabel === 'Function'; + // #1978: thread the class-extractor's qualifier into the owner walk when the + // language opts into qualified node ids, so a nested member's owner resolves + // to the *qualified* class id (Outer.Inner). Gated on the flag → byte-identical + // when off. Mirrors parsing-processor.ts. + const getQualifiedOwnerName = + provider.classExtractor?.qualifiedNodeId === true + ? (node: SyntaxNode, simpleName: string): string | null => + provider.classExtractor!.extractQualifiedName(node, simpleName) + : undefined; const enclosingClassInfo = needsOwner ? cachedFindEnclosingClassInfo( nameNode || definitionNode, file.path, provider.resolveEnclosingOwner, + getQualifiedOwnerName, ) : null; - const enclosingClassId = enclosingClassInfo?.classId ?? null; + const enclosingClassId = + enclosingClassInfo?.qualifiedClassId ?? enclosingClassInfo?.classId ?? null; const objectLiteralOwnerInfo = !enclosingClassId && nodeLabel === 'Method' && definitionNode ? findObjectLiteralBindingInfo(definitionNode, file.path) : null; - // Qualify method/property IDs with enclosing class name to avoid collisions - const qualifiedName = enclosingClassInfo - ? `${enclosingClassInfo.className}.${nodeName}` - : nodeName; + // #1978: hoisted ABOVE qualifiedName/node-id (load-bearing order) so a + // class-like node can key its id by its fully-qualified path. Derived from + // the SAME extractQualifiedName the owner edge uses → owner id == node id. + const classNodeForSymbol = definitionNode || nameNode; + const qualifiedTypeName = + extractedClassSymbol?.qualifiedName ?? + (classNodeForSymbol && provider.classExtractor?.isTypeDeclaration(classNodeForSymbol) + ? (provider.classExtractor.extractQualifiedName(classNodeForSymbol, nodeName) ?? nodeName) + : undefined); + + // Qualify method/property IDs with enclosing class name to avoid collisions. + // Class-like nodes use their own fully-qualified path as the id key when the + // language enables qualifiedNodeId (#1978); everything else is unchanged. + const qualifiedName = + isClassLikeLabel && + provider.classExtractor?.qualifiedNodeId === true && + qualifiedTypeName !== undefined + ? qualifiedTypeName + : enclosingClassInfo + ? `${enclosingClassInfo.className}.${nodeName}` + : nodeName; // Extract method metadata BEFORE generating node ID — parameterCount is needed // to disambiguate overloaded methods via # suffix in the ID. @@ -1922,12 +1967,6 @@ const processFileGroup = ( nodeLabel, `${file.path}:${qualifiedName}${classTemplateTag}${arityTag}${parameterShapeTag}`, ); - const classNodeForSymbol = definitionNode || nameNode; - const qualifiedTypeName = - extractedClassSymbol?.qualifiedName ?? - (classNodeForSymbol && provider.classExtractor?.isTypeDeclaration(classNodeForSymbol) - ? (provider.classExtractor.extractQualifiedName(classNodeForSymbol, nodeName) ?? nodeName) - : undefined); const description = provider.descriptionExtractor?.(nodeLabel, nodeName, captureMap); diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-nested-tail-collision/shapes.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-nested-tail-collision/shapes.cpp new file mode 100644 index 0000000000..515a3cbc52 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-nested-tail-collision/shapes.cpp @@ -0,0 +1,11 @@ +struct Outer { + struct Inner { + void from_outer() {} + int outer_field; + }; +}; +struct Other { + struct Inner { + void from_other() {} + }; +}; diff --git a/gitnexus/test/fixtures/lang-resolution/ruby-nested-tail-collision/nested.rb b/gitnexus/test/fixtures/lang-resolution/ruby-nested-tail-collision/nested.rb new file mode 100644 index 0000000000..94937fed58 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ruby-nested-tail-collision/nested.rb @@ -0,0 +1,19 @@ +module Outer + class Inner + def from_outer; end + end +end +module Other + class Inner + def from_other; end + end +end +# Unambiguous nested class (no same-tail sibling): exercises the routed-property +# (attr_accessor) owner path, which must resolve to the QUALIFIED owner and not +# dangle under qualifiedNodeId. Same-tail routed-property owner identity is a +# separate resolution-side concern (see ruby.test.ts). +module Shapes + class Circle + attr_accessor :radius + end +end diff --git a/gitnexus/test/fixtures/lang-resolution/rust-nested-tail-collision/lib.rs b/gitnexus/test/fixtures/lang-resolution/rust-nested-tail-collision/lib.rs new file mode 100644 index 0000000000..e2675a8398 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-nested-tail-collision/lib.rs @@ -0,0 +1,12 @@ +pub mod outer { + pub struct Inner; + impl Inner { + pub fn from_outer(&self) {} + } +} +pub mod other { + pub struct Inner; + impl Inner { + pub fn from_other(&self) {} + } +} diff --git a/gitnexus/test/integration/resolvers/cpp.test.ts b/gitnexus/test/integration/resolvers/cpp.test.ts index da62a243a5..e2d140885d 100644 --- a/gitnexus/test/integration/resolvers/cpp.test.ts +++ b/gitnexus/test/integration/resolvers/cpp.test.ts @@ -3733,12 +3733,16 @@ describe('C++ SFINAE filter — arity gate runs before constraint filter', () => // --------------------------------------------------------------------------- // 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.) +// `struct Outer::Inner { ... }` (name = qualified_identifier) and its in-class +// forward declaration `struct Outer { struct Inner; }` are the SAME type. Once +// qualified node ids are on (#1978), both key to one canonical node whose +// qualifiedName is the normalized scope path `Outer.Inner` — so the forward +// decl and the out-of-line definition correctly UNIFY instead of producing two +// redundant nodes (the pre-#1978 base kept them separate). Crucially, a +// same-tail type in another scope (`Other::Inner`) stays a DISTINCT node — no +// merge, no method mis-attribution. Owner identity is asserted on the +// qualifiedName + distinct node id (the real key), not the simple `name` +// (which is just the tail `Inner` for both, by design). // --------------------------------------------------------------------------- describe('C++ out-of-line nested definitions — ownership + collision (issue #1975)', () => { @@ -3760,8 +3764,100 @@ describe('C++ out-of-line nested definitions — ownership + collision (issue #1 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); + const ownerQn = (e: typeof outer) => + result.graph.getNode(e!.rel.sourceId)?.properties.qualifiedName; + expect(ownerQn(outer)).toBe('Outer.Inner'); + expect(ownerQn(other)).toBe('Other.Inner'); + expect(outer!.rel.sourceId).not.toBe(other!.rel.sourceId); + // Discriminator: with qualifiedNodeId ON the owner node id is keyed by the + // NORMALIZED dotted path (Struct:...:Outer.Inner); with the fix OFF the + // out-of-line node is keyed by the raw scoped text (...:Outer::Inner). The + // `qualifiedName` PROPERTY is normalized either way, so assert on the id to + // actually prove the fix is engaged (test-soundness, workflow finding #5). + expect(outer!.rel.sourceId).toContain('Outer.Inner'); + expect(outer!.rel.sourceId).not.toContain('::'); + expect(other!.rel.sourceId).not.toContain('::'); + }); +}); + +// --------------------------------------------------------------------------- +// Inline nested same-tail collision — distinct qualified nodes (issue #1978) +// +// `struct Outer { struct Inner {...} }` + `struct Other { struct Inner {...} }` +// must materialize TWO distinct Struct nodes (qn Outer.Inner vs Other.Inner), +// each owning its own method/field. On the pre-fix base both Inner structs +// merge into one simple-keyed node and the methods cross-wire (dangling:0 but +// wrong). Asserts positive owner-identity via the resolved node's qualifiedName, +// not just dangle-free (R7). Distinct from the #1977 out-of-line case above. +// --------------------------------------------------------------------------- + +describe('C++ inline nested same-tail collision — distinct qualified nodes (issue #1978)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'cpp-nested-tail-collision'), () => {}); + }, 60000); + + it('materializes Outer.Inner and Other.Inner as two distinct Struct nodes', () => { + const qns = getNodesByLabelFull(result, 'Struct') + .map((n) => n.properties.qualifiedName) + .filter((q) => q === 'Outer.Inner' || q === 'Other.Inner') + .sort(); + expect(qns).toEqual(['Other.Inner', 'Outer.Inner']); + }); + + it('owns from_outer / from_other through their OWN distinct node (positive identity, R7)', () => { + expect(findDanglingEdges(result, ['HAS_METHOD', 'HAS_PROPERTY'])).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_outer')).toBe('Outer.Inner'); + expect(ownerQn('from_other')).toBe('Other.Inner'); + }); + + it('owns outer_field under Outer.Inner (struct field via the main HAS_PROPERTY path)', () => { + const hp = getRelationships(result, 'HAS_PROPERTY'); + const e = hp.find((x) => x.target === 'outer_field'); + expect(e).toBeDefined(); + expect(result.graph.getNode(e!.rel.sourceId)?.properties.qualifiedName).toBe('Outer.Inner'); + }); +}); + +// Same collision fixture, forced through the WORKER pool (parse-worker.ts) rather +// than the sequential parsing-processor.ts. Production parses repos >= 15 files via +// the pool, so the qualified node-id + owner-edge logic must hold on BOTH paths +// (workflow finding #4: the #1978 fixtures otherwise only exercise the sequential +// path). Asserts worker == sequential for the distinct-node + owner outcome. +describe('C++ inline nested same-tail collision — worker path parity (issue #1978)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'cpp-nested-tail-collision'), () => {}, { + // Force the worker-pool gate low so the 1-file fixture engages the pool. + workerThresholdsForTest: { minFiles: 1, minBytes: 1 }, + workerPoolSize: 2, + }); + }, 120000); + + it('genuinely used the worker pool (guards against silent sequential fallback)', () => { + expect(result.usedWorkerPool).toBe(true); + }); + + it('materializes two distinct Struct nodes and owns each method correctly (R7)', () => { + const qns = getNodesByLabelFull(result, 'Struct') + .map((n) => n.properties.qualifiedName) + .filter((q) => q === 'Outer.Inner' || q === 'Other.Inner') + .sort(); + expect(qns).toEqual(['Other.Inner', 'Outer.Inner']); + expect(findDanglingEdges(result, ['HAS_METHOD', 'HAS_PROPERTY'])).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_outer')).toBe('Outer.Inner'); + expect(ownerQn('from_other')).toBe('Other.Inner'); }); }); diff --git a/gitnexus/test/integration/resolvers/ruby.test.ts b/gitnexus/test/integration/resolvers/ruby.test.ts index cd0ea77485..08c31d3d77 100644 --- a/gitnexus/test/integration/resolvers/ruby.test.ts +++ b/gitnexus/test/integration/resolvers/ruby.test.ts @@ -1508,3 +1508,56 @@ describe('Ruby cross-namespace tail collision — distinct nodes (issue #1975)', expect(hasMethod.some((e) => e.target === 'from_baz' && e.sourceLabel === 'Class')).toBe(true); }); }); + +// --------------------------------------------------------------------------- +// Inline module-nested same-tail collision — distinct nodes (issue #1978) +// +// `module Outer; class Inner; end; end` + `module Other; class Inner; end; end` +// must own their methods through TWO distinct Class nodes (qn Outer.Inner vs +// Other.Inner). On the pre-fix base both Inner classes merge into one +// simple-keyed node and from_outer/from_other cross-wire (dangling:0 but wrong). +// Asserts positive owner-identity by the resolved node's qualifiedName (R7). +// (Distinct from the compact `Foo::Bar` collision block above, which #1977 fixed.) +// --------------------------------------------------------------------------- + +describe('Ruby inline module-nested same-tail collision — distinct nodes (issue #1978)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-nested-tail-collision'), () => {}); + }, 60000); + + pit('owns from_outer / from_other through distinct Outer.Inner / Other.Inner nodes (R7)', () => { + 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_outer')).toBe('Outer.Inner'); + expect(ownerQn('from_other')).toBe('Other.Inner'); + }); + + // attr_accessor routes through the property-registration pre-pass — a SEPARATE + // code path from `def` methods: call-processor.ts (sequential/legacy) and the + // parse-worker `kind === 'properties'` block (worker). Under qualifiedNodeId the + // owner must resolve to the QUALIFIED class node (Shapes.Circle); the pre-fix + // simple `Class:f.rb:Circle` no longer exists and would dangle. Exercised here + // on an UNAMBIGUOUS nested class (no same-tail sibling) so the assertion is + // exact on both legs. + // + // NOTE: exact owner identity for a routed property under SAME-TAIL nested types + // (e.g. two `Inner` classes) is a separate resolution-side concern — the + // registry-primary `emitRubyMixinEdges` bridge resolves the owner by simple + // tail name (last-wins) and the worker path can emit a duplicate cross-wired + // edge. That is deferred to the #1978 resolution-side follow-up; the + // structure-phase HAS_METHOD ownership above is already exact on both legs. + pit('owns radius (attr_accessor) under the qualified Shapes.Circle node, no dangling (R7)', () => { + expect(findDanglingEdges(result, ['HAS_PROPERTY'])).toEqual([]); + const hp = getRelationships(result, 'HAS_PROPERTY'); + const e = hp.find((x) => x.target === 'radius'); + expect(e, 'HAS_PROPERTY -> radius').toBeDefined(); + expect(result.graph.getNode(e!.rel.sourceId)?.properties.qualifiedName).toBe('Shapes.Circle'); + }); +}); diff --git a/gitnexus/test/integration/resolvers/rust.test.ts b/gitnexus/test/integration/resolvers/rust.test.ts index bcfa4200d9..715295d4a5 100644 --- a/gitnexus/test/integration/resolvers/rust.test.ts +++ b/gitnexus/test/integration/resolvers/rust.test.ts @@ -2047,3 +2047,45 @@ describe('Rust scoped inherent impl — ownership + collision (issue #1975)', () expect(fromA!.source).not.toBe(fromB!.source); }); }); + +// --------------------------------------------------------------------------- +// Inline mod-nested same-tail collision — distinct nodes (issue #1978) +// +// `mod outer { struct Inner; impl Inner }` + `mod other { struct Inner; impl Inner }` +// must own their methods through TWO distinct nodes. On the pre-fix base both +// `Inner` structs merge into one simple-keyed node and from_outer/from_other +// cross-wire onto it (dangling:0 but wrong). Asserts the two methods resolve to +// DISTINCT owner node ids (R7), not just dangle-free. +// +// DEFERRED (skip): the generic qualifiedNodeId mechanism (#1978) qualifies +// class-like *type declarations* via the class-extractor. Rust methods live in +// `impl Inner` blocks, and the inherent-impl owner branch in ast-helpers keys +// the Impl node by the impl target's RAW text ("Inner") and returns BEFORE the +// generic qualified-owner path — so it can't reuse `extractQualifiedName` (an +// `impl_item` isn't a typeDeclaration). Qualifying the impl target by its +// enclosing `mod` scope, plus matching it on the registry-primary graph bridge, +// is separate machinery tracked as a follow-up. C++/Ruby land first (KTD-6). +// --------------------------------------------------------------------------- + +// eslint-disable-next-line vitest/no-disabled-tests -- deferred follow-up (see above) +describe.skip('Rust inline mod-nested same-tail collision — distinct nodes (issue #1978)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'rust-nested-tail-collision'), + () => {}, + ); + }, 60000); + + it('owns from_outer / from_other through distinct nodes (no merge, no mis-attribution)', () => { + expect(findDanglingEdges(result, ['HAS_METHOD'])).toEqual([]); + const hm = getRelationships(result, 'HAS_METHOD'); + const a = hm.find((e) => e.target === 'from_outer'); + const b = hm.find((e) => e.target === 'from_other'); + expect(a).toBeDefined(); + expect(b).toBeDefined(); + // The two same-tail `Inner` methods must NOT share one owner node id. + expect(a!.rel.sourceId).not.toBe(b!.rel.sourceId); + }); +}); From ddc31ba72ef4b287023a0c2cd59b63763324e950 Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Wed, 3 Jun 2026 02:44:41 +0000 Subject: [PATCH 02/16] test(ingestion): scope #1978 resolver tests to registry-primary leg; fix lint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - helpers.ts: exclude the new #1978 C++/Ruby resolver tests from the legacy parity leg (LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES). They PASS on legacy too — the fix lives in the SHARED structure phase, not the legacy resolution path — so this is a deliberate registry-primary-only scoping (not a legacy gap), keeping the legacy path untouched and uncoupled from the new node-identity behavior. - rust.test.ts: drop the `eslint-disable vitest/no-disabled-tests` directive. That rule isn't configured in this repo, so eslint errored "Definition for rule 'vitest/no-disabled-tests' was not found" and failed `quality / lint`. The describe.skip needs no disable directive. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../test/integration/resolvers/helpers.ts | 22 +++++++++++++++++-- .../test/integration/resolvers/rust.test.ts | 3 ++- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/gitnexus/test/integration/resolvers/helpers.ts b/gitnexus/test/integration/resolvers/helpers.ts index b45de25e8c..b351846ec3 100644 --- a/gitnexus/test/integration/resolvers/helpers.ts +++ b/gitnexus/test/integration/resolvers/helpers.ts @@ -255,8 +255,15 @@ const LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES: Readonly([ // Ruby scope-resolution currently achieves 89/127 parity. // Tests listed here are scope-resolver-only correctness wins - // (pass under registry-primary, fail under legacy). Currently - // empty — all 127 tests pass under legacy mode. + // (pass under registry-primary, fail under legacy). + // + // #1978 qualified nested-type node identity. NOTE: these PASS under the + // legacy leg too — the fix is in the SHARED structure phase, not the legacy + // resolution path. They are excluded here by policy to keep the #1978 + // assertions registry-primary-only and avoid coupling the legacy parity leg + // to the new node-identity behavior. + 'owns from_outer / from_other through distinct Outer.Inner / Other.Inner nodes (R7)', + 'owns radius (attr_accessor) under the qualified Shapes.Circle node, no dangling (R7)', ]), swift: new Set([ // Swift scope-resolution achieves 77/77 baseline parity. The tests @@ -481,6 +488,17 @@ const LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES: Readonly { let result: PipelineResult; From e2641628c7bb2dca7b6a9d47e808eecd81f9ca71 Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Wed, 3 Jun 2026 03:20:18 +0000 Subject: [PATCH 03/16] fix(test): satisfy CI for the new #1978 fixtures (format + golden + fingerprint) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adding the {cpp,ruby,rust}-nested-tail-collision fixtures changed the lang-resolution corpus, which the scope-capture golden snapshots and the fingerprint baselines gate on. These are pure fixture-corpus additions — #1978 does not touch the scope-capture phase (captures.ts / emit*ScopeCaptures are unchanged). Verified: the regenerated ruby/rust golden diffs are additive-only (no existing fixture's capture digest changed), so the cpp/ruby/ rust fingerprint drift is solely the new fixtures. - prettier --write test/integration/resolvers/{ruby,rust}.test.ts - regenerate ruby/rust captures-golden snapshots (UPDATE_GOLDEN=1; +1 fixture each) - rebaseline cpp/ruby/rust scope-capture fingerprints (bench/scope-capture/baselines.json) Co-Authored-By: Claude Opus 4.8 (1M context) --- gitnexus/bench/scope-capture/baselines.json | 8 ++++---- .../ruby-captures-golden/expected-captures.json | 4 ++++ .../rust-captures-golden/expected-captures.json | 4 ++++ .../test/integration/resolvers/ruby.test.ts | 17 ++++++++++------- .../test/integration/resolvers/rust.test.ts | 5 +---- 5 files changed, 23 insertions(+), 15 deletions(-) diff --git a/gitnexus/bench/scope-capture/baselines.json b/gitnexus/bench/scope-capture/baselines.json index e9d6fb1e4b..ca5cdb99ec 100644 --- a/gitnexus/bench/scope-capture/baselines.json +++ b/gitnexus/bench/scope-capture/baselines.json @@ -16,7 +16,7 @@ "_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": "931bf7af55dc1480d1a5d3c479ea3803003a6a2e2c4406447bd96f3e312e88de", + "fingerprint": "a59156a63f89364fde16e2c6c238f696fc9e66328c49e5b235ac88b662d279b3", "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).", @@ -28,7 +28,7 @@ "scaling_budget": 1.5 }, "rust": { - "fingerprint": "3c4b8e0a707299cc5db0af2528c72a99457859104589a7ef3cd1f377da01793e", + "fingerprint": "30224e2590064745548bc1d623811ae5d37227618854788695442a0acf1898fb", "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).", "_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." @@ -39,10 +39,10 @@ "_rebaselined": "#1956: heritage-bearing scale source (class extends Base + use trait); both forms gated at scale; linear (~1.04)." }, "ruby": { - "fingerprint": "ee81145cf0af796878e8e048192b87c8c8dc445a3e3fcdff6c6e26c179e97232", + "fingerprint": "17782d4a8697f7bd80a25075bebec8ba97c6717b8014ce5b1fb8548a4073e1c8", "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. #1975: + ruby-tail-collision fixture (Foo::Bar vs Baz::Bar stay distinct nodes) — pure fixture-corpus drift, scope-extractor captures unchanged; 81→82." + "_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. #1978: + ruby-nested-tail-collision (same-tail Inner under Outer/Other modules + Shapes.Circle attr_accessor) — pure fixture-corpus drift, scope-extractor captures unchanged; 82→83." }, "swift": { "fingerprint": "53325c6345161c5a495f997297af5a24fb718fd3e6647040160f8ab2a2c8e4c0", diff --git a/gitnexus/test/fixtures/ruby-captures-golden/expected-captures.json b/gitnexus/test/fixtures/ruby-captures-golden/expected-captures.json index 006142b14b..2f0399819c 100644 --- a/gitnexus/test/fixtures/ruby-captures-golden/expected-captures.json +++ b/gitnexus/test/fixtures/ruby-captures-golden/expected-captures.json @@ -207,6 +207,10 @@ "captureGroups": 16, "digest": "34e07387fece6c1d2deb49c39fc2bfe0badfe8015dd1f7ae956d57ac98322a1d" }, + "ruby-nested-tail-collision/nested.rb": { + "captureGroups": 21, + "digest": "48a7c1268b9542cf6d1eed5f6bd5dd68964cc608eb7f73953811d7f29ec35917" + }, "ruby-overload-dispatch/lib/app.rb": { "captureGroups": 10, "digest": "288d5386cf37fb76b52a94bc7da6bf8e7843830ebbb01fcd8100d1590c0e3f72" diff --git a/gitnexus/test/fixtures/rust-captures-golden/expected-captures.json b/gitnexus/test/fixtures/rust-captures-golden/expected-captures.json index 23aefaf5a7..cfc84c2aaa 100644 --- a/gitnexus/test/fixtures/rust-captures-golden/expected-captures.json +++ b/gitnexus/test/fixtures/rust-captures-golden/expected-captures.json @@ -303,6 +303,10 @@ "captureGroups": 18, "digest": "3326eb4f82b1559b6afec497dc52cab734e6f3209501a4bd982bf5eab9ec6dba" }, + "rust-nested-tail-collision/lib.rs": { + "captureGroups": 17, + "digest": "2fc1fe1eb4e8727a89ab283ae34a0ae8df0c421551a7bd5e6e7ffb9d4aa54189" + }, "rust-nullable-receiver/src/main.rs": { "captureGroups": 37, "digest": "283d8606eb837f2a9e5fdf95a30e3da5b73d4c14d74b94deb03e924dd2b2fde1" diff --git a/gitnexus/test/integration/resolvers/ruby.test.ts b/gitnexus/test/integration/resolvers/ruby.test.ts index 08c31d3d77..0dc2d5d7ad 100644 --- a/gitnexus/test/integration/resolvers/ruby.test.ts +++ b/gitnexus/test/integration/resolvers/ruby.test.ts @@ -1553,11 +1553,14 @@ describe('Ruby inline module-nested same-tail collision — distinct nodes (issu // tail name (last-wins) and the worker path can emit a duplicate cross-wired // edge. That is deferred to the #1978 resolution-side follow-up; the // structure-phase HAS_METHOD ownership above is already exact on both legs. - pit('owns radius (attr_accessor) under the qualified Shapes.Circle node, no dangling (R7)', () => { - expect(findDanglingEdges(result, ['HAS_PROPERTY'])).toEqual([]); - const hp = getRelationships(result, 'HAS_PROPERTY'); - const e = hp.find((x) => x.target === 'radius'); - expect(e, 'HAS_PROPERTY -> radius').toBeDefined(); - expect(result.graph.getNode(e!.rel.sourceId)?.properties.qualifiedName).toBe('Shapes.Circle'); - }); + pit( + 'owns radius (attr_accessor) under the qualified Shapes.Circle node, no dangling (R7)', + () => { + expect(findDanglingEdges(result, ['HAS_PROPERTY'])).toEqual([]); + const hp = getRelationships(result, 'HAS_PROPERTY'); + const e = hp.find((x) => x.target === 'radius'); + expect(e, 'HAS_PROPERTY -> radius').toBeDefined(); + expect(result.graph.getNode(e!.rel.sourceId)?.properties.qualifiedName).toBe('Shapes.Circle'); + }, + ); }); diff --git a/gitnexus/test/integration/resolvers/rust.test.ts b/gitnexus/test/integration/resolvers/rust.test.ts index 75853d7e6c..58a0b4ecfc 100644 --- a/gitnexus/test/integration/resolvers/rust.test.ts +++ b/gitnexus/test/integration/resolvers/rust.test.ts @@ -2073,10 +2073,7 @@ describe.skip('Rust inline mod-nested same-tail collision — distinct nodes (is let result: PipelineResult; beforeAll(async () => { - result = await runPipelineFromRepo( - path.join(FIXTURES, 'rust-nested-tail-collision'), - () => {}, - ); + result = await runPipelineFromRepo(path.join(FIXTURES, 'rust-nested-tail-collision'), () => {}); }, 60000); it('owns from_outer / from_other through distinct nodes (no merge, no mis-attribution)', () => { From 35a37d9240dc5786a45b4be8ab89b11bbb4eefe5 Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Wed, 3 Jun 2026 06:49:43 +0000 Subject: [PATCH 04/16] refactor(ingestion): extract shared qualified-name normalizer (#1982) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move normalizeQualifiedName/splitQualifiedName out of class-extractors/ generic.ts into utils/qualified-name.ts so the structure-phase buildQualifiedName, the scope-resolution inheritance resolver, and the per-language capture emitters can all key against ONE normalizer. A raw '::' qualifier must normalize to the exact '.'-joined key the QualifiedNameIndex already holds, or the qualified lookup silently misses (the #1982 resolution-side foundation). Pure relocation — byte-identical function bodies; tsc clean; existing C++ nested-collision tests green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ingestion/class-extractors/generic.ts | 15 +------ .../core/ingestion/utils/qualified-name.ts | 43 +++++++++++++++++++ 2 files changed, 44 insertions(+), 14 deletions(-) create mode 100644 gitnexus/src/core/ingestion/utils/qualified-name.ts diff --git a/gitnexus/src/core/ingestion/class-extractors/generic.ts b/gitnexus/src/core/ingestion/class-extractors/generic.ts index 1f11adc44e..7ed86a934a 100644 --- a/gitnexus/src/core/ingestion/class-extractors/generic.ts +++ b/gitnexus/src/core/ingestion/class-extractors/generic.ts @@ -6,6 +6,7 @@ import type { ClassLikeNodeLabel, ExtractedClassSymbol, } from '../class-types.js'; +import { normalizeQualifiedName, splitQualifiedName } from '../utils/qualified-name.js'; const DEFAULT_SCOPE_NAME_NODE_TYPES = new Set([ 'nested_namespace_specifier', @@ -58,20 +59,6 @@ const CLASS_LIKE_LABELS = new Set([ 'Record', ]); -const normalizeQualifiedName = (value: string): string => - value - .replace(/\s+/g, '') - .replace(/^::/, '') - .replace(/::/g, '.') - .replace(/\\/g, '.') - .replace(/\.+/g, '.') - .replace(/^\.+|\.+$/g, ''); - -const splitQualifiedName = (value: string): string[] => { - const normalized = normalizeQualifiedName(value); - return normalized ? normalized.split('.').filter(Boolean) : []; -}; - const extractScopeSegmentsFromNode = ( scopeNode: SyntaxNode, scopeNameNodeTypes: ReadonlySet, diff --git a/gitnexus/src/core/ingestion/utils/qualified-name.ts b/gitnexus/src/core/ingestion/utils/qualified-name.ts new file mode 100644 index 0000000000..dcd3d5ffb1 --- /dev/null +++ b/gitnexus/src/core/ingestion/utils/qualified-name.ts @@ -0,0 +1,43 @@ +/** + * Shared qualified-name normalization. + * + * One canonical transform from a raw, language-specific qualified name + * (`Other::Inner`, `pkg\Sub\Type`, ` A . B `) to the `.`-joined form the + * graph and the `QualifiedNameIndex` are keyed by (`Other.Inner`, `pkg.Sub.Type`, + * `A.B`). Extracted from `class-extractors/generic.ts` so the structure-phase + * `buildQualifiedName`, the scope-resolution inheritance resolver, and the + * per-language capture emitters all key against ONE normalizer — a raw `::` + * qualifier must normalize to the exact key the index already holds, or the + * qualified lookup silently misses (issue #1982). + * + * Do NOT confuse with `heritage-extractors/supertype-alternation.ts`'s + * `simplifyRawName`, which collapses a qualified name to its LAST segment + * (`Other::Inner` → `Inner`) — that is a tail extractor, not a normalizer, and + * using it as a lookup key guarantees a miss. + * + * Pure string functions; no AST or tree-sitter dependency. + */ + +/** + * Normalize a raw qualified name to the `.`-joined canonical form: + * strips whitespace, converts `::` and `\` separators to `.`, collapses + * repeated dots, and trims leading/trailing dots. + */ +export const normalizeQualifiedName = (value: string): string => + value + .replace(/\s+/g, '') + .replace(/^::/, '') + .replace(/::/g, '.') + .replace(/\\/g, '.') + .replace(/\.+/g, '.') + .replace(/^\.+|\.+$/g, ''); + +/** + * Split a raw qualified name into its normalized, non-empty segments + * (`Other::Inner` → `['Other', 'Inner']`). Returns `[]` for an empty or + * separator-only input. + */ +export const splitQualifiedName = (value: string): string[] => { + const normalized = normalizeQualifiedName(value); + return normalized ? normalized.split('.').filter(Boolean) : []; +}; From 16883f2d7911bd2f9aa823003fe05971398ac65b Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Wed, 3 Jun 2026 07:27:58 +0000 Subject: [PATCH 05/16] fix(ingestion): resolve same-tail C++ nested-type heritage to the correct qualified node (#1982) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Registry-primary C++ inheritance (preEmitInheritanceEdges -> resolveInheritanceBaseInScope) resolved a same-tail nested base by its SIMPLE TAIL with first-wins, so `struct DerivedB : Other::Inner` mis-resolved EXTENDS to Outer.Inner (the wrong sibling; 0 dangling, so undetected). The namespace qualifier was discarded at the C++ inheritance capture. Fix (additive, qualified-first): - ReferenceSite gains an optional `rawQualifiedName`; the C++ inheritance capture emits `@reference.qualified-name` (qualifier-preserving, template-stripped: Other::Inner, ns::Base -> ns::Base) only when the base is qualified, registered as a sub-tag so it can't shadow the `@reference.inherits` anchor. - resolveInheritanceBaseInScope resolves the qualifier against the full-path QualifiedNameIndex FIRST (which already carries Outer.Inner / Other.Inner keys from the structure phase), with progressive-prefix lookup for relative bases and refuse-on-tie, falling through to the existing simple-tail walk on miss — so unqualified bases and the single-candidate cross-file case are unchanged. Registry-primary cpp.test.ts 278/278 (incl. worker-path: rawQualifiedName survives worker serialization). Legacy leg unaffected (207 pass / 71 skip) — the new resolution-side assertions are registry-primary-only via helpers.ts. tsc clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/scope-resolution/reference-site.ts | 12 +++ .../core/ingestion/languages/cpp/captures.ts | 50 ++++++++++++ .../src/core/ingestion/scope-extractor.ts | 9 +++ .../scope-resolution/pipeline/run.ts | 7 +- .../scope-resolution/scope/walkers.ts | 76 +++++++++++++++++++ .../cpp-nested-tail-collision/shapes.cpp | 4 + .../test/integration/resolvers/cpp.test.ts | 49 ++++++++++++ .../test/integration/resolvers/helpers.ts | 8 ++ 8 files changed, 214 insertions(+), 1 deletion(-) diff --git a/gitnexus-shared/src/scope-resolution/reference-site.ts b/gitnexus-shared/src/scope-resolution/reference-site.ts index 58fabd4277..931b12835f 100644 --- a/gitnexus-shared/src/scope-resolution/reference-site.ts +++ b/gitnexus-shared/src/scope-resolution/reference-site.ts @@ -54,6 +54,18 @@ export type CallForm = 'free' | 'member' | 'constructor' | 'index'; export interface ReferenceSite { /** The name being referenced (e.g., `'save'`, `'User'`, `'count'`). */ readonly name: string; + /** + * Optional raw, qualified form of the referenced name when the source wrote + * a qualified path (e.g. a C++ base `struct D : Other::Inner` yields + * `'Other::Inner'`). `name` keeps the simple tail (`'Inner'`) for the existing + * scope-chain contract; resolution normalizes this via `normalizeQualifiedName` + * and resolves it against the full-path `QualifiedNameIndex` BEFORE the + * simple-tail walk, so a same-tail nested base resolves to the correct + * sibling instead of the first-inserted one (issue #1982). Populated only by + * per-language captures that emit `@reference.qualified-name`; absent + * otherwise, in which case resolution is unchanged. + */ + readonly rawQualifiedName?: string; /** Source-text range of this reference. */ readonly atRange: Range; /** diff --git a/gitnexus/src/core/ingestion/languages/cpp/captures.ts b/gitnexus/src/core/ingestion/languages/cpp/captures.ts index de52fee8ae..7922e4d334 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/captures.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/captures.ts @@ -515,9 +515,24 @@ function emitCppInheritanceCaptures(root: SyntaxNode, out: CaptureMatch[], fileP } const baseName = extractBaseLookupName(base.node); if (baseName.length === 0) continue; + // Preserve the qualified form (`Other::Inner`, template-stripped) when the + // source wrote one, so a same-tail nested base resolves to the matching + // qualified node instead of the first-inserted same-tail one (#1982). The + // bare `@reference.name` stays the V1 simple-name contract; the qualifier + // is an additive sidecar resolution tries first (see resolveInheritanceBaseInScope). + const qualifiedBaseName = extractQualifiedBaseName(base.node); out.push({ '@reference.inherits': nodeToCapture('@reference.inherits', base.node), '@reference.name': syntheticCapture('@reference.name', base.node, baseName), + ...(qualifiedBaseName.length > 0 && qualifiedBaseName !== baseName + ? { + '@reference.qualified-name': syntheticCapture( + '@reference.qualified-name', + base.node, + qualifiedBaseName, + ), + } + : {}), }); } } @@ -738,6 +753,41 @@ function extractBaseLookupName(baseNode: SyntaxNode): string { return ''; } +/** + * Like `extractBaseLookupName` but PRESERVES the namespace/class qualifier + * (`Other::Inner`, `ns::v1::Base`) while stripping template arguments + * (`ns::Base` → `ns::Base`). Returns `''` for shapes it can't qualify, and + * returns the bare name unchanged for an unqualified base (the emit site then + * skips the sidecar capture). Powers `@reference.qualified-name` so #1982 + * resolution can pick the matching same-tail nested base via the full-path + * QualifiedNameIndex instead of the first-inserted same-tail sibling. + */ +function extractQualifiedBaseName(baseNode: SyntaxNode): string { + if (baseNode.type === 'template_type') { + const nameNode = baseNode.childForFieldName('name'); + return nameNode !== null ? extractQualifiedBaseName(nameNode) : ''; + } + if (baseNode.type === 'qualified_identifier') { + // No template args anywhere → the raw text already IS the qualified name. + if (!baseNode.text.includes('<')) return baseNode.text; + // Template args present: reconstruct scope::name, recursing to strip them. + const scopeNode = baseNode.childForFieldName('scope'); + const nameNode = baseNode.childForFieldName('name'); + const left = scopeNode !== null ? extractQualifiedBaseName(scopeNode) : ''; + const right = nameNode !== null ? extractQualifiedBaseName(nameNode) : ''; + if (left.length > 0 && right.length > 0) return `${left}::${right}`; + return right.length > 0 ? right : left; + } + if ( + baseNode.type === 'namespace_identifier' || + baseNode.type === 'type_identifier' || + baseNode.type === 'identifier' + ) { + return baseNode.text; + } + return ''; +} + /** Extract the syntactic namespace qualifier from a base class node. * For `detail::Inner`, returns `'detail'`. * For unqualified bases (`Inner`, `Base`), returns `''`. diff --git a/gitnexus/src/core/ingestion/scope-extractor.ts b/gitnexus/src/core/ingestion/scope-extractor.ts index 31a59d2f5c..a09a65eeed 100644 --- a/gitnexus/src/core/ingestion/scope-extractor.ts +++ b/gitnexus/src/core/ingestion/scope-extractor.ts @@ -991,6 +991,11 @@ function pass5CollectReferences( if (kind === undefined) continue; const nameCap = match['@reference.name'] ?? anchor; + // Optional qualified form of the reference (e.g. a C++ base `Other::Inner`), + // threaded to resolution so a same-tail nested base resolves to the correct + // sibling via the full-path QualifiedNameIndex before the simple-tail walk + // (#1982). Absent for unqualified references — resolution stays unchanged. + const qualifiedCap = match['@reference.qualified-name']; const inScopeId = positionIndex.atPosition( filePath, anchor.range.startLine, @@ -1014,6 +1019,9 @@ function pass5CollectReferences( atRange: anchor.range, inScope: inScopeId, kind, + ...(qualifiedCap?.text !== undefined && qualifiedCap.text.length > 0 + ? { rawQualifiedName: qualifiedCap.text } + : {}), ...(callForm !== undefined ? { callForm } : {}), ...(explicitReceiver !== undefined ? { explicitReceiver } : {}), ...(arity !== undefined ? { arity } : {}), @@ -1133,6 +1141,7 @@ const KNOWN_SUB_TAGS: ReadonlySet = new Set([ '@type-binding.name', '@type-binding.type', '@reference.name', + '@reference.qualified-name', '@reference.receiver', '@reference.operator', '@reference.arity', diff --git a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts index 7d45f60cd2..7b7b304f66 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts @@ -131,7 +131,12 @@ function preEmitInheritanceEdges( handledSites.add(siteKey); } - const targetDef = resolveInheritanceBaseInScope(site.inScope, site.name, scopes); + const targetDef = resolveInheritanceBaseInScope( + site.inScope, + site.name, + scopes, + site.rawQualifiedName, + ); if (targetDef === undefined) continue; const callerClass = findEnclosingClassDef(site.inScope, scopes); diff --git a/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts b/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts index f36de06b20..41c531f09f 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts @@ -24,6 +24,7 @@ import type { BindingRef, ParsedFile, ScopeId, SymbolDefinition, TypeRef } from import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; import type { SemanticModel } from '../../model/semantic-model.js'; import type { WorkspaceResolutionIndex } from '../workspace-index.js'; +import { normalizeQualifiedName } from '../../utils/qualified-name.js'; const EMPTY_BINDINGS: readonly BindingRef[] = Object.freeze([]); @@ -309,13 +310,88 @@ export function resolveInheritanceBaseInScope( startScope: ScopeId, baseName: string, scopes: ScopeResolutionIndexes, + rawQualifiedName?: string, ): SymbolDefinition | undefined { + // #1982: when the source wrote a qualified base (`Other::Inner`), resolve it + // against the full-path QualifiedNameIndex FIRST, so a same-tail nested base + // binds to the matching sibling instead of the first-inserted one that the + // simple-tail scope walk picks. Falls through to the existing walk when the + // base is unqualified, unknown, or the qualified lookup can't pick a unique + // winner — so unqualified bases and the cross-file single-candidate case are + // unchanged. + if (rawQualifiedName !== undefined) { + const qualified = resolveQualifiedInheritanceBase(startScope, rawQualifiedName, scopes); + if (qualified !== undefined) return qualified; + } return ( findClassBindingInScope(startScope, baseName, scopes) ?? resolveAmbiguousInheritanceBaseViaImports(startScope, baseName, scopes) ); } +/** + * Resolve a qualified inheritance base (`Other::Inner`, `ns::Base`) against the + * full-path `QualifiedNameIndex` (keyed by `def.qualifiedName`, which carries + * the promoted dotted path post-`populateOwners`). Tries the referencing site's + * enclosing-scope segments as progressive prefixes (longest first) before the + * root-anchored qualifier, so a *relative* base like `Outer::Inner` written + * inside `namespace NS` resolves to the root-anchored key `NS.Outer.Inner`. + * Returns a unique class-like def, or `undefined` when the base is unqualified, + * unknown, or genuinely ambiguous at a key (refuse-on-tie — never guess; a + * wrong EXTENDS edge silently corrupts impact analysis). + */ +function resolveQualifiedInheritanceBase( + startScope: ScopeId, + rawQualifiedName: string, + scopes: ScopeResolutionIndexes, +): SymbolDefinition | undefined { + const normalized = normalizeQualifiedName(rawQualifiedName); + // No qualifier after normalization → nothing the simple-tail walk doesn't do. + if (normalized.length === 0 || !normalized.includes('.')) return undefined; + + const enclosing = enclosingScopeSegments(startScope, scopes); + // Candidate keys: longest enclosing prefix first, then the root-anchored form. + const keys: string[] = []; + for (let i = enclosing.length; i >= 1; i--) { + keys.push([...enclosing.slice(0, i), normalized].join('.')); + } + keys.push(normalized); + + for (const key of keys) { + const ids = scopes.qualifiedNames.get(key); + if (ids.length === 0) continue; + let unique: SymbolDefinition | undefined; + let count = 0; + for (const id of ids) { + const def = scopes.defs.get(id); + if (def !== undefined && isClassLike(def.type)) { + unique = def; + count++; + } + } + if (count === 1) return unique; + if (count > 1) return undefined; // genuine tie at this key → refuse, don't guess + } + return undefined; +} + +/** + * Enclosing scope segments of an inheritance site, derived from the deriving + * (child) class def's `qualifiedName` minus its own tail. For child + * `NS.Other.Derived` this is `['NS', 'Other']`; empty for a file-scope child. + * Used to build progressive-prefix lookup keys for relative qualified bases. + */ +function enclosingScopeSegments( + startScope: ScopeId, + scopes: ScopeResolutionIndexes, +): string[] { + const child = findEnclosingClassDef(startScope, scopes); + const q = child?.qualifiedName; + if (q === undefined || q.length === 0) return []; + const segs = q.split('.').filter(Boolean); + return segs.slice(0, -1); +} + /** * Import/include-aware disambiguation for an *ambiguous* class-like base * name. Engages ONLY as a fallback after `findClassBindingInScope` has diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-nested-tail-collision/shapes.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-nested-tail-collision/shapes.cpp index 515a3cbc52..be0cf45c93 100644 --- a/gitnexus/test/fixtures/lang-resolution/cpp-nested-tail-collision/shapes.cpp +++ b/gitnexus/test/fixtures/lang-resolution/cpp-nested-tail-collision/shapes.cpp @@ -9,3 +9,7 @@ struct Other { void from_other() {} }; }; +// #1982 same-tail heritage: each base is fully qualified, so the EXTENDS edge +// must resolve to the matching nested node, not the first-inserted same-tail one. +struct DerivedA : Outer::Inner {}; +struct DerivedB : Other::Inner {}; diff --git a/gitnexus/test/integration/resolvers/cpp.test.ts b/gitnexus/test/integration/resolvers/cpp.test.ts index e2d140885d..68ca375070 100644 --- a/gitnexus/test/integration/resolvers/cpp.test.ts +++ b/gitnexus/test/integration/resolvers/cpp.test.ts @@ -3860,4 +3860,53 @@ describe('C++ inline nested same-tail collision — worker path parity (issue #1 expect(ownerQn('from_outer')).toBe('Outer.Inner'); expect(ownerQn('from_other')).toBe('Other.Inner'); }); + + it('resolves DerivedB : Other::Inner → EXTENDS Other.Inner on the worker path (#1982: rawQualifiedName survives worker serialization)', () => { + const e = getRelationships(result, 'EXTENDS').find( + (x) => result.graph.getNode(x.rel.sourceId)?.properties.qualifiedName === 'DerivedB', + ); + expect(e, 'DerivedB EXTENDS edge (worker path)').toBeDefined(); + expect(e!.rel.targetId).toContain('Other.Inner'); + expect(e!.rel.targetId).not.toContain('Outer.Inner'); + }); +}); + +// --------------------------------------------------------------------------- +// Inline nested same-tail HERITAGE — qualified base resolution (issue #1982) +// +// `struct DerivedA : Outer::Inner` + `struct DerivedB : Other::Inner` must each +// resolve EXTENDS to the MATCHING nested node. On the registry-primary base the +// qualifier is discarded (cpp/captures.ts emits the bare tail `Inner`), so +// resolveInheritanceBaseInScope sees an ambiguous same-tail base. Asserts the +// resolved EXTENDS endpoint's id contains the right qn (KTD-4: assert on the +// node id, not the property). Registry-primary only (legacy leg expected-fail). +// --------------------------------------------------------------------------- +describe('C++ inline nested same-tail heritage — qualified base (issue #1982)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'cpp-nested-tail-collision'), () => {}); + }, 60000); + + const extendsTargetIdOf = (childQn: string): string | undefined => { + const ext = getRelationships(result, 'EXTENDS'); + const e = ext.find( + (x) => result.graph.getNode(x.rel.sourceId)?.properties.qualifiedName === childQn, + ); + return e?.rel.targetId; + }; + + it('resolves DerivedA : Outer::Inner → EXTENDS the Outer.Inner node', () => { + const tid = extendsTargetIdOf('DerivedA'); + expect(tid, 'DerivedA EXTENDS endpoint').toBeDefined(); + expect(tid).toContain('Outer.Inner'); + expect(tid).not.toContain('Other.Inner'); + }); + + it('resolves DerivedB : Other::Inner → EXTENDS the Other.Inner node (not Outer.Inner)', () => { + const tid = extendsTargetIdOf('DerivedB'); + expect(tid, 'DerivedB EXTENDS endpoint').toBeDefined(); + expect(tid).toContain('Other.Inner'); + expect(tid).not.toContain('Outer.Inner'); + }); }); diff --git a/gitnexus/test/integration/resolvers/helpers.ts b/gitnexus/test/integration/resolvers/helpers.ts index b351846ec3..e33e8e9d72 100644 --- a/gitnexus/test/integration/resolvers/helpers.ts +++ b/gitnexus/test/integration/resolvers/helpers.ts @@ -499,6 +499,14 @@ const LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES: Readonly Date: Wed, 3 Jun 2026 07:37:02 +0000 Subject: [PATCH 06/16] fix(ingestion): resolve same-tail Ruby mixin/attr_accessor owners to the correct qualified node (#1982) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit emitRubyMixinEdges keyed its owner map by the SIMPLE tail (def.qualifiedName split-popped) with last-wins, and the __heritage__/__property__ markers carried only the immediate owner name — so `module Outer; class Inner` and `module Other; class Inner` collapsed onto one `Inner` key and cross-wired their include/attr_accessor edges onto whichever Inner was processed last. Fix (lockstep, full-qualified): - ruby/captures.ts: build the marker owner from the FULL enclosing class/module chain (buildEnclosingQualifiedName walks all ancestors, normalizing the compact `class Outer::Inner` scope_resolution form via the shared splitQualifiedName) so the marker owner byte-matches the resolution def's qualifiedName. - ruby/scope-resolver.ts: key graphIdByName by the full def.qualifiedName instead of the simple tail. Top-level owners/mixins are unchanged (full == simple). Registry-primary ruby.test.ts 142/142 incl. a new worker-path block (the deferred note's duplicate-edge concern: markers survive worker serialization, exactly one HAS_PROPERTY per attr). Legacy leg unaffected (136 pass / 6 skip) — new assertions registry-primary-only via helpers.ts. tsc clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../core/ingestion/languages/ruby/captures.ts | 31 +++++++-- .../languages/ruby/scope-resolver.ts | 9 ++- .../ruby-nested-tail-collision/nested.rb | 6 ++ .../test/integration/resolvers/helpers.ts | 8 +++ .../test/integration/resolvers/ruby.test.ts | 63 +++++++++++++++++++ 5 files changed, 111 insertions(+), 6 deletions(-) diff --git a/gitnexus/src/core/ingestion/languages/ruby/captures.ts b/gitnexus/src/core/ingestion/languages/ruby/captures.ts index 376ebc546c..2b68b71d6a 100644 --- a/gitnexus/src/core/ingestion/languages/ruby/captures.ts +++ b/gitnexus/src/core/ingestion/languages/ruby/captures.ts @@ -12,6 +12,7 @@ import { recordRubyCacheHit, recordRubyCacheMiss } from './cache-stats.js'; import { synthesizeRubyReceiverBinding, findEnclosingClassOrModule } from './receiver-binding.js'; import { getTreeSitterBufferSize } from '../../constants.js'; import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js'; +import { splitQualifiedName } from '../../utils/qualified-name.js'; const FUNCTION_NODE_TYPES = ['method', 'singleton_method'] as const; const HERITAGE_CALL_NAMES: ReadonlySet = new Set(['include', 'extend', 'prepend']); @@ -21,6 +22,30 @@ const ATTR_CALL_NAMES: ReadonlySet = new Set([ 'attr_writer', ]); +/** + * Build the full `.`-joined qualified owner name for a heritage/attr call by + * walking ALL enclosing class/module ancestors (not just the immediate one), + * so a same-tail nested owner (`module Outer; class Inner`) is keyed by its + * full path `Outer.Inner` instead of the bare tail `Inner` — which otherwise + * collapses both same-tail owners onto one `__heritage__`/`__property__` marker + * key (last-wins) and cross-wires their mixin / attr_accessor edges (#1982). + * Handles the compact `class Outer::Inner` form (name is a `scope_resolution`) + * via the shared normalizer, so the marker owner byte-matches the resolution + * def's `qualifiedName`. Returns undefined when there is no enclosing class/module. + */ +function buildEnclosingQualifiedName(callNode: SyntaxNode): string | undefined { + const segments: string[] = []; + let current: SyntaxNode | null = callNode.parent; + while (current !== null) { + if (current.type === 'class' || current.type === 'module') { + const nameNode = current.childForFieldName('name'); + if (nameNode !== null) segments.unshift(...splitQualifiedName(nameNode.text)); + } + current = current.parent; + } + return segments.length > 0 ? segments.join('.') : undefined; +} + export function emitRubyScopeCaptures( sourceText: string, _filePath: string, @@ -144,8 +169,7 @@ export function emitRubyScopeCaptures( if (HERITAGE_CALL_NAMES.has(callName)) { const callNode = nodeIfType(nodeMap['@reference.call.free'], 'call'); if (callNode !== null) { - const enclosing = findEnclosingClassOrModule(callNode); - const ownerName = enclosing?.childForFieldName('name')?.text; + const ownerName = buildEnclosingQualifiedName(callNode); if (ownerName) { const argList = callNode.childForFieldName('arguments'); if (argList !== null) { @@ -178,8 +202,7 @@ export function emitRubyScopeCaptures( if (ATTR_CALL_NAMES.has(callName)) { const callNode = nodeIfType(nodeMap['@reference.call.free'], 'call'); if (callNode !== null) { - const enclosing = findEnclosingClassOrModule(callNode); - const ownerName = enclosing?.childForFieldName('name')?.text; + const ownerName = buildEnclosingQualifiedName(callNode); if (ownerName) { const argList = callNode.childForFieldName('arguments'); if (argList !== null) { diff --git a/gitnexus/src/core/ingestion/languages/ruby/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/ruby/scope-resolver.ts index b10c78b743..712858ff34 100644 --- a/gitnexus/src/core/ingestion/languages/ruby/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/ruby/scope-resolver.ts @@ -23,8 +23,13 @@ function emitRubyMixinEdges( if (!isClassLike(def.type)) continue; const graphId = resolveDefGraphId(parsed.filePath, def, nodeLookup); if (graphId !== undefined) { - const simpleName = def.qualifiedName?.split('.').pop() ?? def.qualifiedName ?? ''; - graphIdByName.set(simpleName, graphId); + // Key by the FULL qualified name (`Outer.Inner`), NOT the simple tail. + // Same-tail nested classes (`Outer::Inner` + `Other::Inner`) otherwise + // collapse onto one `Inner` key (last-wins) and cross-wire their mixin / + // attr_accessor owners (#1982). The `__heritage__`/`__property__` markers + // carry the full qualified owner name in lockstep (see ruby/captures.ts). + const fullName = def.qualifiedName ?? ''; + if (fullName.length > 0) graphIdByName.set(fullName, graphId); } } } diff --git a/gitnexus/test/fixtures/lang-resolution/ruby-nested-tail-collision/nested.rb b/gitnexus/test/fixtures/lang-resolution/ruby-nested-tail-collision/nested.rb index 94937fed58..11eff83548 100644 --- a/gitnexus/test/fixtures/lang-resolution/ruby-nested-tail-collision/nested.rb +++ b/gitnexus/test/fixtures/lang-resolution/ruby-nested-tail-collision/nested.rb @@ -1,10 +1,16 @@ +module OuterMix; end +module OtherMix; end module Outer class Inner + include OuterMix + attr_accessor :outer_attr def from_outer; end end end module Other class Inner + include OtherMix + attr_accessor :other_attr def from_other; end end end diff --git a/gitnexus/test/integration/resolvers/helpers.ts b/gitnexus/test/integration/resolvers/helpers.ts index e33e8e9d72..78b8f0c2a3 100644 --- a/gitnexus/test/integration/resolvers/helpers.ts +++ b/gitnexus/test/integration/resolvers/helpers.ts @@ -264,6 +264,14 @@ const LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES: Readonly([ // Swift scope-resolution achieves 77/77 baseline parity. The tests diff --git a/gitnexus/test/integration/resolvers/ruby.test.ts b/gitnexus/test/integration/resolvers/ruby.test.ts index 0dc2d5d7ad..c325a13817 100644 --- a/gitnexus/test/integration/resolvers/ruby.test.ts +++ b/gitnexus/test/integration/resolvers/ruby.test.ts @@ -1563,4 +1563,67 @@ describe('Ruby inline module-nested same-tail collision — distinct nodes (issu expect(result.graph.getNode(e!.rel.sourceId)?.properties.qualifiedName).toBe('Shapes.Circle'); }, ); + + // #1982 resolution-side: SAME-TAIL routed-property owner identity. The + // pre-fix emitRubyMixinEdges keys its owner map by simple tail (last-wins), + // so outer_attr / other_attr both attach to whichever `Inner` was processed + // last. Asserts each routes to its OWN qualified node by qualifiedName, with + // exactly one (non-duplicated) edge. Registry-primary only. + pit('owns outer_attr / other_attr under their OWN qualified Inner node (same-tail attr_accessor, R7)', () => { + const hp = getRelationships(result, 'HAS_PROPERTY'); + const ownerQnOf = (prop: string) => { + const e = hp.find((x) => x.target === prop); + expect(e, `HAS_PROPERTY -> ${prop}`).toBeDefined(); + return result.graph.getNode(e!.rel.sourceId)?.properties.qualifiedName; + }; + expect(ownerQnOf('outer_attr')).toBe('Outer.Inner'); + expect(ownerQnOf('other_attr')).toBe('Other.Inner'); + expect(hp.filter((x) => x.target === 'outer_attr')).toHaveLength(1); + expect(hp.filter((x) => x.target === 'other_attr')).toHaveLength(1); + }); + + // #1982 resolution-side: SAME-TAIL mixin owner identity (IMPLEMENTS). + pit('routes include OuterMix / OtherMix to their OWN qualified Inner owner (same-tail mixin, R7)', () => { + const impl = getRelationships(result, 'IMPLEMENTS'); + const ownerQnOfMixin = (mixinName: string) => { + const e = impl.find((x) => x.target === mixinName); + expect(e, `IMPLEMENTS -> ${mixinName}`).toBeDefined(); + return result.graph.getNode(e!.rel.sourceId)?.properties.qualifiedName; + }; + expect(ownerQnOfMixin('OuterMix')).toBe('Outer.Inner'); + expect(ownerQnOfMixin('OtherMix')).toBe('Other.Inner'); + }); +}); + +// Same fixture through the WORKER pool. The deferred note flagged that the worker +// path could emit a DUPLICATE cross-wired same-tail owner edge (the worker emits +// the __property__/__heritage__ markers, which must now carry the full qualified +// owner). Asserts worker == sequential: each attr owns its OWN qualified node with +// exactly one edge (#1982 R7). Registry-primary only. +describe('Ruby inline module-nested same-tail collision — worker path parity (issue #1982)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-nested-tail-collision'), () => {}, { + workerThresholdsForTest: { minFiles: 1, minBytes: 1 }, + workerPoolSize: 2, + }); + }, 120000); + + pit('genuinely used the worker pool for the same-tail Ruby fixture', () => { + expect(result.usedWorkerPool).toBe(true); + }); + + pit('owns outer_attr / other_attr under their OWN qualified Inner node on the worker path (no duplicate, R7)', () => { + const hp = getRelationships(result, 'HAS_PROPERTY'); + const ownerQnOf = (prop: string) => { + const e = hp.find((x) => x.target === prop); + expect(e, `HAS_PROPERTY -> ${prop}`).toBeDefined(); + return result.graph.getNode(e!.rel.sourceId)?.properties.qualifiedName; + }; + expect(ownerQnOf('outer_attr')).toBe('Outer.Inner'); + expect(ownerQnOf('other_attr')).toBe('Other.Inner'); + expect(hp.filter((x) => x.target === 'outer_attr')).toHaveLength(1); + expect(hp.filter((x) => x.target === 'other_attr')).toHaveLength(1); + }); }); From cd8eb4757c5c8075e02fc04e342547ecb4ac5166 Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Wed, 3 Jun 2026 07:48:45 +0000 Subject: [PATCH 07/16] test(ingestion): rebaseline #1982 golden/fingerprint + lint/format sweep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cross-cutting verification artifacts for the #1982 same-tail resolution fix: - ruby capture golden regenerated: ONLY the ruby-nested-tail-collision fixture drifts (+10 capture groups from its new include/attr_accessor + the now full-qualified __heritage__/__property__ marker owner). All other ruby fixtures byte-identical (proves the owner-qualification is localized to nested owners). - bench/scope-capture/baselines.json: rebaseline cpp + ruby fingerprints (the only two that drift; 12 other languages byte-identical). cpp = additive @reference.qualified-name capture; ruby = the localized owner change. Provenance notes record both. scaling linear (~1.0), 14/14 PASS. - generic.ts: drop the now-unused normalizeQualifiedName import (lint error). - walkers.ts / ruby.test.ts: prettier formatting. Verified: cpp 278/278 + ruby 142/142 (registry-primary), both legacy legs clean (skips registry-primary-only assertions), go/java/csharp 542 (cross-language regression — the qualified-first branch is gated on rawQualifiedName, set only by C++, so non-C++ inheritance resolution is unchanged). tsc + eslint(0 errors) + prettier clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- gitnexus/bench/scope-capture/baselines.json | 8 +- .../ingestion/class-extractors/generic.ts | 2 +- .../scope-resolution/scope/walkers.ts | 5 +- .../expected-captures.json | 4 +- .../test/integration/resolvers/ruby.test.ts | 89 +++++++++++-------- 5 files changed, 59 insertions(+), 49 deletions(-) diff --git a/gitnexus/bench/scope-capture/baselines.json b/gitnexus/bench/scope-capture/baselines.json index ca5cdb99ec..4944d7b940 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": "a59156a63f89364fde16e2c6c238f696fc9e66328c49e5b235ac88b662d279b3", + "fingerprint": "2f517381e8b03db221d13f65d5e485d021c17a2f7fa684446e09f33d78c3adb9", "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 (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." + "_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. #1982: cpp-nested-tail-collision gains qualified heritage (struct DerivedA : Outer::Inner, struct DerivedB : Other::Inner) AND emitCppInheritanceCaptures now emits an ADDITIVE @reference.qualified-name capture on QUALIFIED bases (drives the qualified-first inheritance resolver). Purely additive — existing captures unchanged; same fixture file, no new fixture." }, "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.)", @@ -39,10 +39,10 @@ "_rebaselined": "#1956: heritage-bearing scale source (class extends Base + use trait); both forms gated at scale; linear (~1.04)." }, "ruby": { - "fingerprint": "17782d4a8697f7bd80a25075bebec8ba97c6717b8014ce5b1fb8548a4073e1c8", + "fingerprint": "011c0533318ff61d99267d501866af8a616b1c212050628339064d8c149aafbf", "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. #1975: + ruby-tail-collision fixture (Foo::Bar vs Baz::Bar stay distinct nodes) — pure fixture-corpus drift, scope-extractor captures unchanged; 81→82. #1978: + ruby-nested-tail-collision (same-tail Inner under Outer/Other modules + Shapes.Circle attr_accessor) — pure fixture-corpus drift, scope-extractor captures unchanged; 82→83." + "_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. #1978: + ruby-nested-tail-collision (same-tail Inner under Outer/Other modules + Shapes.Circle attr_accessor) — pure fixture-corpus drift, scope-extractor captures unchanged; 82→83. #1982: that fixture gains same-tail include + attr_accessor, AND emitRubyScopeCaptures now emits the FULL qualified owner in __heritage__/__property__ markers (buildEnclosingQualifiedName) — a CODE change LOCALIZED to nested owners: the golden shows ONLY this fixture drifts (+10 capture groups), all other ruby fixtures byte-identical, and 142/142 resolver tests pass. fixture_count 83 (same file)." }, "swift": { "fingerprint": "53325c6345161c5a495f997297af5a24fb718fd3e6647040160f8ab2a2c8e4c0", diff --git a/gitnexus/src/core/ingestion/class-extractors/generic.ts b/gitnexus/src/core/ingestion/class-extractors/generic.ts index 7ed86a934a..6cfc7a3c08 100644 --- a/gitnexus/src/core/ingestion/class-extractors/generic.ts +++ b/gitnexus/src/core/ingestion/class-extractors/generic.ts @@ -6,7 +6,7 @@ import type { ClassLikeNodeLabel, ExtractedClassSymbol, } from '../class-types.js'; -import { normalizeQualifiedName, splitQualifiedName } from '../utils/qualified-name.js'; +import { splitQualifiedName } from '../utils/qualified-name.js'; const DEFAULT_SCOPE_NAME_NODE_TYPES = new Set([ 'nested_namespace_specifier', diff --git a/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts b/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts index 41c531f09f..29fe2b8ac5 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts @@ -381,10 +381,7 @@ function resolveQualifiedInheritanceBase( * `NS.Other.Derived` this is `['NS', 'Other']`; empty for a file-scope child. * Used to build progressive-prefix lookup keys for relative qualified bases. */ -function enclosingScopeSegments( - startScope: ScopeId, - scopes: ScopeResolutionIndexes, -): string[] { +function enclosingScopeSegments(startScope: ScopeId, scopes: ScopeResolutionIndexes): string[] { const child = findEnclosingClassDef(startScope, scopes); const q = child?.qualifiedName; if (q === undefined || q.length === 0) return []; diff --git a/gitnexus/test/fixtures/ruby-captures-golden/expected-captures.json b/gitnexus/test/fixtures/ruby-captures-golden/expected-captures.json index 2f0399819c..fb838f769c 100644 --- a/gitnexus/test/fixtures/ruby-captures-golden/expected-captures.json +++ b/gitnexus/test/fixtures/ruby-captures-golden/expected-captures.json @@ -208,8 +208,8 @@ "digest": "34e07387fece6c1d2deb49c39fc2bfe0badfe8015dd1f7ae956d57ac98322a1d" }, "ruby-nested-tail-collision/nested.rb": { - "captureGroups": 21, - "digest": "48a7c1268b9542cf6d1eed5f6bd5dd68964cc608eb7f73953811d7f29ec35917" + "captureGroups": 31, + "digest": "c48ebe5516a0faf50effbad0a19fe29be70c371b50ba9d6fa6ae3f6f708b3a4e" }, "ruby-overload-dispatch/lib/app.rb": { "captureGroups": 10, diff --git a/gitnexus/test/integration/resolvers/ruby.test.ts b/gitnexus/test/integration/resolvers/ruby.test.ts index c325a13817..e79d2134e0 100644 --- a/gitnexus/test/integration/resolvers/ruby.test.ts +++ b/gitnexus/test/integration/resolvers/ruby.test.ts @@ -1569,30 +1569,36 @@ describe('Ruby inline module-nested same-tail collision — distinct nodes (issu // so outer_attr / other_attr both attach to whichever `Inner` was processed // last. Asserts each routes to its OWN qualified node by qualifiedName, with // exactly one (non-duplicated) edge. Registry-primary only. - pit('owns outer_attr / other_attr under their OWN qualified Inner node (same-tail attr_accessor, R7)', () => { - const hp = getRelationships(result, 'HAS_PROPERTY'); - const ownerQnOf = (prop: string) => { - const e = hp.find((x) => x.target === prop); - expect(e, `HAS_PROPERTY -> ${prop}`).toBeDefined(); - return result.graph.getNode(e!.rel.sourceId)?.properties.qualifiedName; - }; - expect(ownerQnOf('outer_attr')).toBe('Outer.Inner'); - expect(ownerQnOf('other_attr')).toBe('Other.Inner'); - expect(hp.filter((x) => x.target === 'outer_attr')).toHaveLength(1); - expect(hp.filter((x) => x.target === 'other_attr')).toHaveLength(1); - }); + pit( + 'owns outer_attr / other_attr under their OWN qualified Inner node (same-tail attr_accessor, R7)', + () => { + const hp = getRelationships(result, 'HAS_PROPERTY'); + const ownerQnOf = (prop: string) => { + const e = hp.find((x) => x.target === prop); + expect(e, `HAS_PROPERTY -> ${prop}`).toBeDefined(); + return result.graph.getNode(e!.rel.sourceId)?.properties.qualifiedName; + }; + expect(ownerQnOf('outer_attr')).toBe('Outer.Inner'); + expect(ownerQnOf('other_attr')).toBe('Other.Inner'); + expect(hp.filter((x) => x.target === 'outer_attr')).toHaveLength(1); + expect(hp.filter((x) => x.target === 'other_attr')).toHaveLength(1); + }, + ); // #1982 resolution-side: SAME-TAIL mixin owner identity (IMPLEMENTS). - pit('routes include OuterMix / OtherMix to their OWN qualified Inner owner (same-tail mixin, R7)', () => { - const impl = getRelationships(result, 'IMPLEMENTS'); - const ownerQnOfMixin = (mixinName: string) => { - const e = impl.find((x) => x.target === mixinName); - expect(e, `IMPLEMENTS -> ${mixinName}`).toBeDefined(); - return result.graph.getNode(e!.rel.sourceId)?.properties.qualifiedName; - }; - expect(ownerQnOfMixin('OuterMix')).toBe('Outer.Inner'); - expect(ownerQnOfMixin('OtherMix')).toBe('Other.Inner'); - }); + pit( + 'routes include OuterMix / OtherMix to their OWN qualified Inner owner (same-tail mixin, R7)', + () => { + const impl = getRelationships(result, 'IMPLEMENTS'); + const ownerQnOfMixin = (mixinName: string) => { + const e = impl.find((x) => x.target === mixinName); + expect(e, `IMPLEMENTS -> ${mixinName}`).toBeDefined(); + return result.graph.getNode(e!.rel.sourceId)?.properties.qualifiedName; + }; + expect(ownerQnOfMixin('OuterMix')).toBe('Outer.Inner'); + expect(ownerQnOfMixin('OtherMix')).toBe('Other.Inner'); + }, + ); }); // Same fixture through the WORKER pool. The deferred note flagged that the worker @@ -1604,26 +1610,33 @@ describe('Ruby inline module-nested same-tail collision — worker path parity ( let result: PipelineResult; beforeAll(async () => { - result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-nested-tail-collision'), () => {}, { - workerThresholdsForTest: { minFiles: 1, minBytes: 1 }, - workerPoolSize: 2, - }); + result = await runPipelineFromRepo( + path.join(FIXTURES, 'ruby-nested-tail-collision'), + () => {}, + { + workerThresholdsForTest: { minFiles: 1, minBytes: 1 }, + workerPoolSize: 2, + }, + ); }, 120000); pit('genuinely used the worker pool for the same-tail Ruby fixture', () => { expect(result.usedWorkerPool).toBe(true); }); - pit('owns outer_attr / other_attr under their OWN qualified Inner node on the worker path (no duplicate, R7)', () => { - const hp = getRelationships(result, 'HAS_PROPERTY'); - const ownerQnOf = (prop: string) => { - const e = hp.find((x) => x.target === prop); - expect(e, `HAS_PROPERTY -> ${prop}`).toBeDefined(); - return result.graph.getNode(e!.rel.sourceId)?.properties.qualifiedName; - }; - expect(ownerQnOf('outer_attr')).toBe('Outer.Inner'); - expect(ownerQnOf('other_attr')).toBe('Other.Inner'); - expect(hp.filter((x) => x.target === 'outer_attr')).toHaveLength(1); - expect(hp.filter((x) => x.target === 'other_attr')).toHaveLength(1); - }); + pit( + 'owns outer_attr / other_attr under their OWN qualified Inner node on the worker path (no duplicate, R7)', + () => { + const hp = getRelationships(result, 'HAS_PROPERTY'); + const ownerQnOf = (prop: string) => { + const e = hp.find((x) => x.target === prop); + expect(e, `HAS_PROPERTY -> ${prop}`).toBeDefined(); + return result.graph.getNode(e!.rel.sourceId)?.properties.qualifiedName; + }; + expect(ownerQnOf('outer_attr')).toBe('Outer.Inner'); + expect(ownerQnOf('other_attr')).toBe('Other.Inner'); + expect(hp.filter((x) => x.target === 'outer_attr')).toHaveLength(1); + expect(hp.filter((x) => x.target === 'other_attr')).toHaveLength(1); + }, + ); }); From 3a0ed3e74e57037cd225d16a8aa14dad6194f0e7 Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Wed, 3 Jun 2026 10:35:06 +0000 Subject: [PATCH 08/16] fix(ingestion): resolve nested Ruby mixin included by short name (#1982) emitRubyMixinEdges keyed graphIdByName by the full def.qualifiedName on the owner side, but the __heritage__ marker carries the mixin target as the bare written name (arg.text). A nested mixin module included by its short name (include Loggable where it is App::Loggable) missed the full-qn map and its IMPLEMENTS edge was silently dropped (0 dangling, undetectable). The shipped same-tail fixture used only top-level mixin modules, so CI stayed green. Add a secondary simple-tail fallback map consulted only when the full-qn mixin lookup misses; owner lookups stay full-qn so same-tail owner disambiguation is preserved. Characterization test + fixture (registry-primary only); golden regenerated additively. Addresses PR #1981 review (4417182679) P1. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../languages/ruby/scope-resolver.ts | 20 +++++++++- .../ruby-nested-mixin-shortname/app.rb | 19 ++++++++++ .../expected-captures.json | 4 ++ .../test/integration/resolvers/helpers.ts | 4 ++ .../test/integration/resolvers/ruby.test.ts | 38 +++++++++++++++++++ 5 files changed, 83 insertions(+), 2 deletions(-) create mode 100644 gitnexus/test/fixtures/lang-resolution/ruby-nested-mixin-shortname/app.rb diff --git a/gitnexus/src/core/ingestion/languages/ruby/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/ruby/scope-resolver.ts index 712858ff34..e9802435a3 100644 --- a/gitnexus/src/core/ingestion/languages/ruby/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/ruby/scope-resolver.ts @@ -18,6 +18,15 @@ function emitRubyMixinEdges( nodeLookup: GraphNodeLookup, ): void { const graphIdByName = new Map(); + // Secondary tail -> graphId map (first-wins). The `__heritage__` marker carries + // the mixin TARGET as the bare written name (`arg.text`, e.g. `Loggable`), not + // its full qualifiedName, so a nested mixin module included by its short name + // (`include Loggable` where it is `App::Loggable`) misses the full-qn map and + // its IMPLEMENTS edge is silently dropped (#1982 follow-up). The tail fallback + // recovers it. OWNER (`className`) lookups stay full-qn only, preserving + // same-tail owner disambiguation; only the under-qualified mixin reference + // falls back, and a genuine same-tail mixin tie there resolves first-wins. + const graphIdByTail = new Map(); for (const parsed of parsedFiles) { for (const def of parsed.localDefs) { if (!isClassLike(def.type)) continue; @@ -29,7 +38,12 @@ function emitRubyMixinEdges( // attr_accessor owners (#1982). The `__heritage__`/`__property__` markers // carry the full qualified owner name in lockstep (see ruby/captures.ts). const fullName = def.qualifiedName ?? ''; - if (fullName.length > 0) graphIdByName.set(fullName, graphId); + if (fullName.length > 0) { + graphIdByName.set(fullName, graphId); + const dot = fullName.lastIndexOf('.'); + const tail = dot === -1 ? fullName : fullName.slice(dot + 1); + if (tail.length > 0 && !graphIdByTail.has(tail)) graphIdByTail.set(tail, graphId); + } } } } @@ -49,7 +63,9 @@ function emitRubyMixinEdges( if (parts.length < 3) continue; const [kind, mixinName, className] = parts; const classGraphId = graphIdByName.get(className!); - const mixinGraphId = graphIdByName.get(mixinName!); + // Owner stays full-qn; the mixin target may be written by short name and + // miss the full-qn map, so fall back to the simple-tail map (#1982). + const mixinGraphId = graphIdByName.get(mixinName!) ?? graphIdByTail.get(mixinName!); if (classGraphId === undefined || mixinGraphId === undefined) continue; const edgeKey = `${classGraphId}->${mixinGraphId}:${kind}`; if (emitted.has(edgeKey)) continue; diff --git a/gitnexus/test/fixtures/lang-resolution/ruby-nested-mixin-shortname/app.rb b/gitnexus/test/fixtures/lang-resolution/ruby-nested-mixin-shortname/app.rb new file mode 100644 index 0000000000..77a4e84fde --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ruby-nested-mixin-shortname/app.rb @@ -0,0 +1,19 @@ +# Nested mixin module included by its SHORT name (#1982 follow-up). +# +# `Loggable` is nested in `App` (qualifiedName `App.Loggable`), but is included +# by its bare short name `Loggable` from a sibling class inside the same module. +# The structure phase materializes a distinct `App.Loggable` node, but the +# resolution-side mixin lookup keys `graphIdByName` by FULL qualifiedName while +# the `__heritage__` marker carries the bare `arg.text` (`Loggable`) — so the +# IMPLEMENTS edge is silently dropped (0 dangling edges, undetectable). The +# shipped same-tail fixture only uses TOP-LEVEL mixin modules, where the full +# qualifiedName equals the bare name, so it cannot catch this. +module App + module Loggable + def log; end + end + + class Service + include Loggable + end +end diff --git a/gitnexus/test/fixtures/ruby-captures-golden/expected-captures.json b/gitnexus/test/fixtures/ruby-captures-golden/expected-captures.json index fb838f769c..475f8b3b8f 100644 --- a/gitnexus/test/fixtures/ruby-captures-golden/expected-captures.json +++ b/gitnexus/test/fixtures/ruby-captures-golden/expected-captures.json @@ -207,6 +207,10 @@ "captureGroups": 16, "digest": "34e07387fece6c1d2deb49c39fc2bfe0badfe8015dd1f7ae956d57ac98322a1d" }, + "ruby-nested-mixin-shortname/app.rb": { + "captureGroups": 11, + "digest": "dfa494facc56b5e07a12befc77cd1e3788f0494f1373960cd9fe88715750e590" + }, "ruby-nested-tail-collision/nested.rb": { "captureGroups": 31, "digest": "c48ebe5516a0faf50effbad0a19fe29be70c371b50ba9d6fa6ae3f6f708b3a4e" diff --git a/gitnexus/test/integration/resolvers/helpers.ts b/gitnexus/test/integration/resolvers/helpers.ts index 2c69d4b4ec..90018659f4 100644 --- a/gitnexus/test/integration/resolvers/helpers.ts +++ b/gitnexus/test/integration/resolvers/helpers.ts @@ -288,6 +288,10 @@ const LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES: Readonly App.Loggable for a short-name nested mixin (R1)', ]), swift: new Set([ // Swift scope-resolution achieves 77/77 baseline parity. The tests diff --git a/gitnexus/test/integration/resolvers/ruby.test.ts b/gitnexus/test/integration/resolvers/ruby.test.ts index e79d2134e0..0fa9bb1413 100644 --- a/gitnexus/test/integration/resolvers/ruby.test.ts +++ b/gitnexus/test/integration/resolvers/ruby.test.ts @@ -1640,3 +1640,41 @@ describe('Ruby inline module-nested same-tail collision — worker path parity ( }, ); }); + +// --------------------------------------------------------------------------- +// Nested mixin included by SHORT name — IMPLEMENTS edge must not drop (#1982). +// +// `module App; module Loggable; end; class Service; include Loggable; end; end` +// — `Loggable` is nested (qn App.Loggable) but included by its bare short name. +// The structure phase materializes a distinct App.Loggable node, but +// emitRubyMixinEdges keys graphIdByName by FULL qualifiedName while the +// __heritage__ marker carries the bare arg.text ('Loggable'), so the +// mixin-target lookup missed and the IMPLEMENTS edge was silently dropped +// (0 dangling, undetectable). The shipped same-tail fixture only uses TOP-LEVEL +// mixin modules (full qn == bare name), so it cannot catch this. Asserts the +// edge exists and resolves by NODE ID (KTD3 — not the normalized qualifiedName +// property). Registry-primary only (emitRubyMixinEdges is the registry bridge). +// --------------------------------------------------------------------------- + +describe('Ruby nested mixin by short name — IMPLEMENTS not dropped (issue #1982)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-nested-mixin-shortname'), () => {}); + }, 60000); + + pit('emits App.Service -IMPLEMENTS-> App.Loggable for a short-name nested mixin (R1)', () => { + expect(findDanglingEdges(result, ['IMPLEMENTS'])).toEqual([]); + const impl = getRelationships(result, 'IMPLEMENTS'); + const e = impl.find((x) => x.target === 'Loggable'); + expect(e, 'IMPLEMENTS -> Loggable (nested mixin by short name)').toBeDefined(); + // KTD3: discriminate on the resolved node id, not the normalized property. + // The owner resolves to the QUALIFIED `App.Service` class node — the pre-fix + // bug dropped the edge entirely, so its presence + qualified owner is the + // discriminator. (The mixin module is a Trait node keyed by its simple name + // `Loggable`; Trait-node qualification under same-tail modules is a separate + // structure-phase concern, deferred.) + expect(e!.rel.sourceId).toContain('App.Service'); + expect(e!.rel.targetId).toContain('Loggable'); + }); +}); From bd7eff1f68da1d3780439a52d9dcf04e66077fc7 Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Wed, 3 Jun 2026 11:34:04 +0000 Subject: [PATCH 09/16] fix(ingestion): normalize qualified Ruby mixin arg in heritage marker (#1982) `include Outer::Mixin` embedded the raw `Outer::Mixin` into the ':'-delimited __heritage__ marker, so the `::` collided with the field separator and emitRubyMixinEdges mis-split it (className became empty), dropping the IMPLEMENTS edge. Normalize the mixin arg via splitQualifiedName(...).join('.') before emit so the marker carries the dotted form, which both parses correctly and matches the mixin def's qualifiedName. Simple names are unchanged (no golden drift). Addresses PR #1981 review (4417182679) secondary R2. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../core/ingestion/languages/ruby/captures.ts | 11 +++++-- .../ruby-qualified-mixin/app.rb | 14 +++++++++ .../expected-captures.json | 4 +++ .../test/integration/resolvers/helpers.ts | 3 ++ .../test/integration/resolvers/ruby.test.ts | 29 +++++++++++++++++++ 5 files changed, 59 insertions(+), 2 deletions(-) create mode 100644 gitnexus/test/fixtures/lang-resolution/ruby-qualified-mixin/app.rb diff --git a/gitnexus/src/core/ingestion/languages/ruby/captures.ts b/gitnexus/src/core/ingestion/languages/ruby/captures.ts index 2b68b71d6a..fe9502051b 100644 --- a/gitnexus/src/core/ingestion/languages/ruby/captures.ts +++ b/gitnexus/src/core/ingestion/languages/ruby/captures.ts @@ -176,15 +176,22 @@ export function emitRubyScopeCaptures( for (let ai = 0; ai < argList.namedChildCount; ai++) { const arg = argList.namedChild(ai); if (arg !== null && (arg.type === 'constant' || arg.type === 'scope_resolution')) { + // Normalize a qualified mixin arg (`Outer::Mixin`) to its dotted + // form (`Outer.Mixin`) BEFORE embedding it in the ':'-delimited + // __heritage__ marker: the raw `::` collides with the marker's `:` + // field separator and emitRubyMixinEdges mis-splits it, dropping + // the edge (#1982). The dotted form also matches the mixin def's + // qualifiedName key for resolution. Simple names are unchanged. + const mixinName = splitQualifiedName(arg.text).join('.'); out.push({ '@import.statement': grouped['@reference.call.free']!, '@import.kind': syntheticCapture('@import.kind', callNode, 'namespace'), '@import.source': syntheticCapture( '@import.source', callNode, - `__heritage__:${callName}:${arg.text}:${ownerName}`, + `__heritage__:${callName}:${mixinName}:${ownerName}`, ), - '@import.name': syntheticCapture('@import.name', callNode, arg.text), + '@import.name': syntheticCapture('@import.name', callNode, mixinName), }); } } diff --git a/gitnexus/test/fixtures/lang-resolution/ruby-qualified-mixin/app.rb b/gitnexus/test/fixtures/lang-resolution/ruby-qualified-mixin/app.rb new file mode 100644 index 0000000000..a847ef5eee --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ruby-qualified-mixin/app.rb @@ -0,0 +1,14 @@ +# Qualified mixin argument (`include Outer::Mixin`) — the `::` in arg.text +# collided with the ':'-delimited __heritage__ marker field separator and the +# IMPLEMENTS edge was silently dropped (#1982 follow-up). The marker now embeds +# the dotted form (`Outer.Mixin`) so the split parses correctly and the lookup +# matches the mixin def's qualifiedName. +module Outer + module Mixin + def mixed; end + end +end + +class Consumer + include Outer::Mixin +end diff --git a/gitnexus/test/fixtures/ruby-captures-golden/expected-captures.json b/gitnexus/test/fixtures/ruby-captures-golden/expected-captures.json index 475f8b3b8f..72c44d05ad 100644 --- a/gitnexus/test/fixtures/ruby-captures-golden/expected-captures.json +++ b/gitnexus/test/fixtures/ruby-captures-golden/expected-captures.json @@ -243,6 +243,10 @@ "captureGroups": 18, "digest": "f81f06be06d013a08a5c9b730a79494251f102f48ca4c25bf0bbd4a2cdcd889e" }, + "ruby-qualified-mixin/app.rb": { + "captureGroups": 11, + "digest": "30e3ff7538ab8cea9e5bd9c47c6275aa653b110720f24ee08d7fe19fc5c78952" + }, "ruby-qualified-types/lib/admin/user.rb": { "captureGroups": 8, "digest": "1bceb829c2429e1415c296ea97a2475203c3cbf69c07123186f4c277a44b2f9f" diff --git a/gitnexus/test/integration/resolvers/helpers.ts b/gitnexus/test/integration/resolvers/helpers.ts index 90018659f4..e0b41d6fcc 100644 --- a/gitnexus/test/integration/resolvers/helpers.ts +++ b/gitnexus/test/integration/resolvers/helpers.ts @@ -292,6 +292,9 @@ const LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES: Readonly App.Loggable for a short-name nested mixin (R1)', + // #1982 follow-up: a qualified mixin arg (`include Outer::Mixin`) must not be + // corrupted by the ':'-delimited __heritage__ marker. Registry-primary only. + 'emits Consumer -IMPLEMENTS-> Outer.Mixin for include Outer::Mixin (R2)', ]), swift: new Set([ // Swift scope-resolution achieves 77/77 baseline parity. The tests diff --git a/gitnexus/test/integration/resolvers/ruby.test.ts b/gitnexus/test/integration/resolvers/ruby.test.ts index 0fa9bb1413..a75c4e2a59 100644 --- a/gitnexus/test/integration/resolvers/ruby.test.ts +++ b/gitnexus/test/integration/resolvers/ruby.test.ts @@ -1678,3 +1678,32 @@ describe('Ruby nested mixin by short name — IMPLEMENTS not dropped (issue #198 expect(e!.rel.targetId).toContain('Loggable'); }); }); + +// --------------------------------------------------------------------------- +// Qualified mixin argument — `::` must not corrupt the __heritage__ marker (#1982). +// +// `class Consumer; include Outer::Mixin; end` — the `::` in `arg.text` +// (`Outer::Mixin`) collided with the ':'-delimited __heritage__ marker field +// separator (`__heritage__:include:Outer::Mixin:Consumer`), so emitRubyMixinEdges +// mis-split it and dropped the edge. The marker now embeds the dotted form +// (`Outer.Mixin`), which both parses correctly and matches the mixin def's +// qualifiedName. Registry-primary only. +// --------------------------------------------------------------------------- + +describe('Ruby qualified mixin arg — IMPLEMENTS not corrupted by :: (issue #1982)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-qualified-mixin'), () => {}); + }, 60000); + + pit('emits Consumer -IMPLEMENTS-> Outer.Mixin for include Outer::Mixin (R2)', () => { + expect(findDanglingEdges(result, ['IMPLEMENTS'])).toEqual([]); + const impl = getRelationships(result, 'IMPLEMENTS'); + const e = impl.find((x) => x.target === 'Mixin'); + expect(e, 'IMPLEMENTS -> Mixin (qualified mixin arg)').toBeDefined(); + // KTD3: discriminate on the resolved node id (the pre-fix bug dropped the edge). + expect(e!.rel.sourceId).toContain('Consumer'); + expect(e!.rel.targetId).toContain('Mixin'); + }); +}); From 1874516db5da61608733839cc2068d455515f2e9 Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Wed, 3 Jun 2026 11:58:11 +0000 Subject: [PATCH 10/16] fix(ingestion): resolve C++ same-tail nested heritage inside a namespace (#1982) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A namespace-nested C++ type's scope-model qualifiedName carried its enclosing CLASS chain (A.Inner) but dropped the enclosing NAMESPACE, while the structure-phase graph node is keyed by the full path (NS.A.Inner). resolveDefGraphId's qualifiedKey therefore missed and fell back to simpleKey('Inner'), collapsing same-tail nested bases across sibling namespace members — DB : B::Inner pointed at NS.A.Inner. The shipped fixture was top-level only, so it could not catch this. Fix without disturbing the qualifiedName-keyed resolution index (an earlier attempt that rewrote qualifiedName regressed brace-init / UDC / two-phase namespace resolution): tagNamespacePrefixes records each namespace-nested def's enclosing-namespace prefix on a sidecar field, and resolveDefGraphId retries the node lookup with the namespace-prefixed key before the simpleKey fallback. The helper is language-agnostic (acts only on Namespace scopes) and opt-in — only the C++ provider calls it. Namespaced fixture + sequential & worker tests (registry-primary only). All 280 cpp resolver tests pass; tsc clean. Addresses PR #1981 review (4417182679) P2. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ingestion/languages/cpp/scope-resolver.ts | 10 ++- .../scope-resolution/graph-bridge/ids.ts | 11 +++ .../scope-resolution/scope/walkers.ts | 58 ++++++++++++++ .../cpp-namespaced-collision/main.cpp | 23 ++++++ .../test/integration/resolvers/cpp.test.ts | 78 +++++++++++++++++++ .../test/integration/resolvers/helpers.ts | 8 ++ 6 files changed, 187 insertions(+), 1 deletion(-) create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-namespaced-collision/main.cpp diff --git a/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts index 40ff978d6d..459b313c6f 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts @@ -5,7 +5,10 @@ import { } from '../../scope-resolution/scope/walkers.js'; import { SupportedLanguages } from 'gitnexus-shared'; import { buildMro, defaultLinearize } from '../../scope-resolution/passes/mro.js'; -import { populateClassOwnedMembers } from '../../scope-resolution/scope/walkers.js'; +import { + populateClassOwnedMembers, + tagNamespacePrefixes, +} from '../../scope-resolution/scope/walkers.js'; import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js'; import { cppProvider } from '../c-cpp.js'; import { cppArityCompatibility } from './arity.js'; @@ -102,6 +105,11 @@ export const cppScopeResolver: ScopeResolver = { populateOwners: (parsed: ParsedFile) => { populateClassOwnedMembers(parsed); + // #1982: tag namespace-nested defs with their enclosing-namespace prefix so + // resolveDefGraphId can map them to the namespace-qualified structure-phase + // node (`NS.A.Inner`) instead of collapsing same-tail nested bases via the + // simpleKey fallback. Does NOT change qualifiedName (resolution unaffected). + tagNamespacePrefixes(parsed); // Resolve inline- and anonymous-namespace ranges (recorded at capture // time) to ScopeIds BEFORE `populateCppNonGloballyVisible` runs, so // both exemptions see the populated Sets. 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 94eec67c03..25927a579b 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ids.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ids.ts @@ -143,6 +143,17 @@ export function resolveDefGraphId( } const qualifiedHit = nodeLookup.get(qualifiedKey(filePath, def.type, qn)); if (qualifiedHit !== undefined) return qualifiedHit; + // #1982: some scope-extractors qualify a type by its enclosing CLASS chain + // (`A.Inner`) but drop the enclosing NAMESPACE, while the structure-phase + // node is keyed by the full path (`NS.A.Inner`). Retry with the + // 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; + if (nsPrefix !== undefined && nsPrefix.length > 0) { + const nsHit = nodeLookup.get(qualifiedKey(filePath, def.type, `${nsPrefix}.${qn}`)); + if (nsHit !== undefined) return nsHit; + } } const simpleName = qn.lastIndexOf('.') === -1 ? qn : qn.slice(qn.lastIndexOf('.') + 1); return nodeLookup.get(simpleKey(filePath, simpleName)); diff --git a/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts b/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts index 29fe2b8ac5..fcef4b7028 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts @@ -779,6 +779,64 @@ export function populateClassOwnedMembers(parsed: ParsedFile): void { } } +/** + * Tag every def declared inside one or more `Namespace` scopes with its + * enclosing-namespace path (`NS`, `Outer.Inner`) on a sidecar `namespacePrefix` + * field — WITHOUT touching `qualifiedName`. + * + * Some scope-extractors qualify a nested type by its enclosing CLASS chain + * (`A.Inner`) but drop the enclosing NAMESPACE, while the structure phase keys + * the graph node by the full path (`NS.A.Inner`). `resolveDefGraphId` reads this + * tag to retry the node lookup with the namespace-prefixed key before the + * simple-name fallback, so same-tail nested bases don't collapse across sibling + * namespace members (#1982). `qualifiedName` is deliberately left unchanged, so + * the `qualifiedName`-keyed resolution index and existing namespace resolution + * (brace-init, UDC ranking, two-phase lookup) are untouched. + * + * Language-agnostic: it acts only on `Namespace`-kind scopes (a namespace-free + * language is a no-op) and is opt-in per provider (call after `populateOwners`). + * Namespace segments are taken as each namespace def's own tail, so it composes + * for nested namespaces regardless of whether the inner namespace's name is + * stored simple or already dotted. Skips defs already carrying the prefix. + */ +export function tagNamespacePrefixes(parsed: ParsedFile): void { + const scopesById = new Map(); + for (const scope of parsed.scopes) scopesById.set(scope.id, scope); + + // Enclosing-namespace prefix for a scope: the dotted path of each ancestor + // Namespace scope's name, outermost-first (`['Outer','Inner'] → 'Outer.Inner'`). + const namespacePrefixOf = (scope: ParsedFile['scopes'][number]): string => { + const segments: string[] = []; + let parentId = scope.parent; + while (parentId !== null) { + const parent = scopesById.get(parentId); + if (parent === undefined) break; + if (parent.kind === 'Namespace') { + const nsDef = parent.ownedDefs.find((d) => d.type === 'Namespace'); + const nsQ = nsDef?.qualifiedName; + if (nsQ !== undefined && nsQ.length > 0) { + const dot = nsQ.lastIndexOf('.'); + segments.unshift(dot === -1 ? nsQ : nsQ.slice(dot + 1)); + } + } + parentId = parent.parent; + } + return segments.join('.'); + }; + + for (const scope of parsed.scopes) { + if (scope.kind === 'Namespace') continue; + const prefix = namespacePrefixOf(scope); + if (prefix.length === 0) continue; + for (const def of scope.ownedDefs) { + 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; + } + } +} + /** * Walk a scope chain upward looking for the innermost enclosing * Class scope and return that class's def. Used by per-language diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-namespaced-collision/main.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-namespaced-collision/main.cpp new file mode 100644 index 0000000000..8b67362683 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-namespaced-collision/main.cpp @@ -0,0 +1,23 @@ +// Same-tail nested heritage INSIDE a namespace (#1982 follow-up). +// +// `NS::A::Inner` and `NS::B::Inner` are distinct nested types. The structure +// phase materializes distinct `NS.A.Inner` / `NS.B.Inner` graph nodes, but the +// scope-resolution model dropped the namespace from def.qualifiedName +// (`A.Inner` not `NS.A.Inner`), so resolveDefGraphId missed the namespaced node +// key and fell back to simpleKey('Inner'), collapsing both bases — DB lost its +// EXTENDS edge. The shipped same-tail fixture is top-level only (no namespace), +// so it cannot catch this. +namespace NS { +struct A { + struct Inner { + void from_a() {} + }; +}; +struct B { + struct Inner { + void from_b() {} + }; +}; +struct DA : A::Inner {}; +struct DB : B::Inner {}; +} // namespace NS diff --git a/gitnexus/test/integration/resolvers/cpp.test.ts b/gitnexus/test/integration/resolvers/cpp.test.ts index 68ca375070..6333c54762 100644 --- a/gitnexus/test/integration/resolvers/cpp.test.ts +++ b/gitnexus/test/integration/resolvers/cpp.test.ts @@ -3910,3 +3910,81 @@ describe('C++ inline nested same-tail heritage — qualified base (issue #1982)' expect(tid).not.toContain('Outer.Inner'); }); }); + +// --------------------------------------------------------------------------- +// Namespaced same-tail nested heritage — qualified base resolution (issue #1982) +// +// `namespace NS { struct A{struct Inner{};}; struct B{struct Inner{};}; +// struct DA:A::Inner{}; struct DB:B::Inner{}; }` — the bases NS::A::Inner and +// NS::B::Inner are namespace-nested. The structure phase materializes distinct +// NS.A.Inner / NS.B.Inner nodes, but the scope-model def.qualifiedName dropped +// the namespace (`A.Inner` not `NS.A.Inner`), so resolveDefGraphId missed the +// namespaced node key and the simpleKey('Inner') fallback collapsed both bases — +// DB's EXTENDS pointed at NS.A.Inner. Asserts each Derived EXTENDS its own +// namespaced base by NODE ID (KTD3). Registry-primary only. +// --------------------------------------------------------------------------- + +describe('C++ namespaced same-tail nested heritage — qualified base (issue #1982)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'cpp-namespaced-collision'), () => {}); + }, 60000); + + const extendsTargetIdOf = (childQn: string): string | undefined => { + const ext = getRelationships(result, 'EXTENDS'); + const e = ext.find( + (x) => result.graph.getNode(x.rel.sourceId)?.properties.qualifiedName === childQn, + ); + return e?.rel.targetId; + }; + + it('resolves NS::DA : A::Inner → EXTENDS the NS.A.Inner node', () => { + const tid = extendsTargetIdOf('NS.DA'); + expect(tid, 'NS.DA EXTENDS endpoint').toBeDefined(); + expect(tid).toContain('NS.A.Inner'); + expect(tid).not.toContain('NS.B.Inner'); + }); + + it('resolves NS::DB : B::Inner → EXTENDS the NS.B.Inner node (not NS.A.Inner)', () => { + const tid = extendsTargetIdOf('NS.DB'); + expect(tid, 'NS.DB EXTENDS endpoint').toBeDefined(); + expect(tid).toContain('NS.B.Inner'); + expect(tid).not.toContain('NS.A.Inner'); + }); +}); + +// Same namespaced fixture through the WORKER pool — the namespacePrefix tag is +// applied during main-process scope-resolution (after worker parse/merge), so +// the fix must hold on both paths. Registry-primary only. +describe('C++ namespaced same-tail nested heritage — worker path parity (issue #1982)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'cpp-namespaced-collision'), () => {}, { + workerThresholdsForTest: { minFiles: 1, minBytes: 1 }, + workerPoolSize: 2, + }); + }, 120000); + + it('genuinely used the worker pool for the namespaced fixture', () => { + expect(result.usedWorkerPool).toBe(true); + }); + + it('resolves NS::DA / NS::DB to their own namespaced base on the worker path', () => { + const extendsTargetIdOf = (childQn: string): string | undefined => { + const ext = getRelationships(result, 'EXTENDS'); + const e = ext.find( + (x) => result.graph.getNode(x.rel.sourceId)?.properties.qualifiedName === childQn, + ); + return e?.rel.targetId; + }; + const da = extendsTargetIdOf('NS.DA'); + const db = extendsTargetIdOf('NS.DB'); + expect(da, 'NS.DA EXTENDS (worker)').toBeDefined(); + expect(db, 'NS.DB EXTENDS (worker)').toBeDefined(); + expect(da).toContain('NS.A.Inner'); + expect(db).toContain('NS.B.Inner'); + expect(db).not.toContain('NS.A.Inner'); + }); +}); diff --git a/gitnexus/test/integration/resolvers/helpers.ts b/gitnexus/test/integration/resolvers/helpers.ts index e0b41d6fcc..5864b7730d 100644 --- a/gitnexus/test/integration/resolvers/helpers.ts +++ b/gitnexus/test/integration/resolvers/helpers.ts @@ -538,6 +538,14 @@ const LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES: Readonly Date: Wed, 3 Jun 2026 12:00:28 +0000 Subject: [PATCH 11/16] test(ingestion): worker-path parity for Ruby mixin IMPLEMENTS + C++ DerivedA (#1982) The Ruby worker-path parity block asserted only attr_accessor (HAS_PROPERTY); add an IMPLEMENTS assertion so a dropped/cross-wired mixin owner on the worker path is caught (the __heritage__ marker owner must survive serialization). The C++ worker heritage block asserted only DerivedB; add a DerivedA assertion with a toHaveLength(1) duplicate guard. Registry-primary only. Addresses PR #1981 review (4417182679) test-coverage gap. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../test/integration/resolvers/cpp.test.ts | 9 +++++++++ gitnexus/test/integration/resolvers/helpers.ts | 4 ++++ .../test/integration/resolvers/ruby.test.ts | 18 ++++++++++++++++++ 3 files changed, 31 insertions(+) diff --git a/gitnexus/test/integration/resolvers/cpp.test.ts b/gitnexus/test/integration/resolvers/cpp.test.ts index 6333c54762..cbf03f5e7d 100644 --- a/gitnexus/test/integration/resolvers/cpp.test.ts +++ b/gitnexus/test/integration/resolvers/cpp.test.ts @@ -3869,6 +3869,15 @@ describe('C++ inline nested same-tail collision — worker path parity (issue #1 expect(e!.rel.targetId).toContain('Other.Inner'); expect(e!.rel.targetId).not.toContain('Outer.Inner'); }); + + it('resolves DerivedA : Outer::Inner → EXTENDS Outer.Inner on the worker path (parity + no duplicate)', () => { + const edges = getRelationships(result, 'EXTENDS').filter( + (x) => result.graph.getNode(x.rel.sourceId)?.properties.qualifiedName === 'DerivedA', + ); + expect(edges, 'DerivedA EXTENDS edges (worker path)').toHaveLength(1); + expect(edges[0]!.rel.targetId).toContain('Outer.Inner'); + expect(edges[0]!.rel.targetId).not.toContain('Other.Inner'); + }); }); // --------------------------------------------------------------------------- diff --git a/gitnexus/test/integration/resolvers/helpers.ts b/gitnexus/test/integration/resolvers/helpers.ts index 5864b7730d..5c3594d575 100644 --- a/gitnexus/test/integration/resolvers/helpers.ts +++ b/gitnexus/test/integration/resolvers/helpers.ts @@ -295,6 +295,8 @@ const LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES: Readonly Outer.Mixin for include Outer::Mixin (R2)', + // #1982 follow-up: worker-path mixin (IMPLEMENTS) parity. Registry-primary only. + 'routes include OuterMix / OtherMix to their OWN qualified Inner owner on the worker path (IMPLEMENTS, R7)', ]), swift: new Set([ // Swift scope-resolution achieves 77/77 baseline parity. The tests @@ -546,6 +548,8 @@ const LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES: Readonly x.target === 'other_attr')).toHaveLength(1); }, ); + + // Worker-path parity for the MIXIN (IMPLEMENTS) path — the __heritage__ marker + // owner must survive worker serialization (not only attr_accessor / HAS_PROPERTY). + pit( + 'routes include OuterMix / OtherMix to their OWN qualified Inner owner on the worker path (IMPLEMENTS, R7)', + () => { + const impl = getRelationships(result, 'IMPLEMENTS'); + const ownerQnOfMixin = (mixinName: string) => { + const e = impl.find((x) => x.target === mixinName); + expect(e, `IMPLEMENTS -> ${mixinName}`).toBeDefined(); + return result.graph.getNode(e!.rel.sourceId)?.properties.qualifiedName; + }; + expect(ownerQnOfMixin('OuterMix')).toBe('Outer.Inner'); + expect(ownerQnOfMixin('OtherMix')).toBe('Other.Inner'); + expect(impl.filter((x) => x.target === 'OuterMix')).toHaveLength(1); + expect(impl.filter((x) => x.target === 'OtherMix')).toHaveLength(1); + }, + ); }); // --------------------------------------------------------------------------- From bc4a560d05170ecc87da6d1ff36ac80254322a83 Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Wed, 3 Jun 2026 12:43:58 +0000 Subject: [PATCH 12/16] fix(ingestion): distinct Rust same-tail nested-mod inherent-impl ownership (#1982) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rust methods live in `impl Inner` blocks, and findEnclosingClassInfo keyed the inherent-impl owner by the target's RAW tail (`Impl:lib.rs:Inner`), so two same-tail `impl Inner` blocks under different mods (mod outer / mod other) collapsed onto ONE Impl node and their methods cross-wired. The shipped fixture test for this was skipped/deferred. Qualify an UNSCOPED inherent-impl target by its enclosing `mod_item` scope (`outer.Inner`) in BOTH the owner walk (ast-helpers.qualifyRustImplTargetByModScope) and the Impl-node materialization (parsing-processor + parse-worker, lockstep) so the owner edge and node id agree byte-for-byte. Gated on the Impl label + impl_item + an unscoped type_identifier target — Rust-impl-exclusive, so C++/Ruby and the rust captures golden are untouched; a SCOPED `impl a::Inner` keeps its full raw text (#1975, unchanged). The previously-skipped distinct-ownership test is now active and passing; rust 170/170, cpp+ruby+golden 437/437, tsc clean. Done in-PR at maintainer request (was deferred as a follow-up). Addresses PR #1981 review (4417182679) test-coverage gap R7. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/core/ingestion/parsing-processor.ts | 31 +++++++++--- .../src/core/ingestion/utils/ast-helpers.ts | 50 +++++++++++++++++-- .../core/ingestion/workers/parse-worker.ts | 27 +++++++--- .../test/integration/resolvers/rust.test.ts | 22 +++++--- 4 files changed, 104 insertions(+), 26 deletions(-) diff --git a/gitnexus/src/core/ingestion/parsing-processor.ts b/gitnexus/src/core/ingestion/parsing-processor.ts index 62346963f0..b2b7f10184 100644 --- a/gitnexus/src/core/ingestion/parsing-processor.ts +++ b/gitnexus/src/core/ingestion/parsing-processor.ts @@ -18,6 +18,7 @@ import { findObjectLiteralBindingInfo, getLabelFromCaptures, isSuppressedConcreteTypedefDuplicate, + qualifyRustImplTargetByModScope, CLASS_CONTAINER_TYPES, type SyntaxNode, type EnclosingClassInfo, @@ -649,14 +650,30 @@ const processParsingSequential = async ( // e.g. "Method:animal.dart:Animal.speak" vs "Method:animal.dart:Dog.speak". // Class-like nodes use their own fully-qualified path as the id key when the // language enables qualifiedNodeId (#1978); everything else is unchanged. + // #1982: a Rust inherent-impl node is keyed by its target's RAW tail by + // default, so two bare same-tail impls under different mods collapse onto + // one Impl node. For an UNSCOPED bare target (type_identifier), qualify the + // Impl node id by the enclosing `mod_item` scope — byte-identical to the + // owner-walk id (ast-helpers `findEnclosingClassInfo`), so HAS_METHOD stays + // anchored. SCOPED targets (`impl a::Inner`) keep their full raw text and + // are NOT routed here (#1975). + const rustImplQualifiedName = + nodeLabel === 'Impl' && + definitionNode?.type === 'impl_item' && + nameNode?.type === 'type_identifier' + ? qualifyRustImplTargetByModScope(definitionNode, nodeName) + : undefined; + const qualifiedName = - isClassLikeLabel && - provider.classExtractor?.qualifiedNodeId === true && - qualifiedTypeName !== undefined - ? qualifiedTypeName - : enclosingClassInfo - ? `${enclosingClassInfo.className}.${nodeName}` - : nodeName; + rustImplQualifiedName !== undefined + ? rustImplQualifiedName + : isClassLikeLabel && + provider.classExtractor?.qualifiedNodeId === true && + qualifiedTypeName !== undefined + ? qualifiedTypeName + : enclosingClassInfo + ? `${enclosingClassInfo.className}.${nodeName}` + : nodeName; // Extract method metadata for Function/Method/Constructor nodes BEFORE generating // the node ID — parameterCount is needed to disambiguate overloaded methods. diff --git a/gitnexus/src/core/ingestion/utils/ast-helpers.ts b/gitnexus/src/core/ingestion/utils/ast-helpers.ts index 4fcf870d2c..52d28169f7 100644 --- a/gitnexus/src/core/ingestion/utils/ast-helpers.ts +++ b/gitnexus/src/core/ingestion/utils/ast-helpers.ts @@ -7,10 +7,42 @@ import { stripTemplateArguments, templateArgumentsIdTag, } from './template-arguments.js'; +import { splitQualifiedName } from './qualified-name.js'; /** Tree-sitter AST node. Re-exported for use across ingestion modules. */ export type SyntaxNode = Parser.SyntaxNode; +/** + * Qualify a Rust inherent-impl target (`impl Inner { ... }`) by its enclosing + * `mod_item` scope, so a bare same-tail target nested under different modules + * resolves to a DISTINCT path (`outer.Inner` vs `other.Inner`) — the #1982 + * follow-up to #1975. Walks `mod_item` ancestors (outermost → innermost) and + * joins them with the normalized raw target via the shared `splitQualifiedName`. + * A top-level `impl Inner` (no enclosing mod) returns the bare target unchanged. + * Keyed purely on tree-sitter node types (no language name), matching the + * inherent-impl branch in `findEnclosingClassInfo`; the caller restricts this to + * UNSCOPED targets (`type_identifier`) so a SCOPED `impl a::Inner` keeps its full + * raw text (#1975). The Impl-node materialization in parsing-processor / + * parse-worker mirrors this so the owner edge and node id agree byte-for-byte. + */ +export const qualifyRustImplTargetByModScope = ( + implNode: SyntaxNode, + rawTargetText: string, +): string => { + const modSegments: string[] = []; + let current = implNode.parent; + while (current) { + if (current.type === 'mod_item') { + const nameNode = + current.childForFieldName?.('name') ?? + current.children?.find((c: SyntaxNode) => c.type === 'identifier'); + if (nameNode) modSegments.unshift(nameNode.text); + } + current = current.parent; + } + return [...modSegments, ...splitQualifiedName(rawTargetText)].filter(Boolean).join('.'); +}; + /** * Ordered list of definition capture keys for tree-sitter query matches. * Used to extract the definition node from a capture map. @@ -463,16 +495,24 @@ export const findEnclosingClassInfo = ( }; } } - // 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). + // Inherent impl target. + // - SCOPED (`impl a::Inner`, scoped_type_identifier): key by FULL text, + // matching the @definition.impl scoped arm (#1975). UNCHANGED. + // - UNSCOPED (`impl Inner`, type_identifier): qualify by the enclosing + // `mod_item` scope (`outer.Inner`) so two same-tail bare impls under + // different mods own through DISTINCT nodes. The Impl-node + // materialization (parsing-processor / parse-worker) mirrors this, so + // the owner id == the Impl node id byte-for-byte (#1982). const firstType = children.find( (c: SyntaxNode) => c.type === 'type_identifier' || c.type === 'scoped_type_identifier', ); if (firstType) { + const ownerKey = + firstType.type === 'type_identifier' + ? qualifyRustImplTargetByModScope(current, firstType.text) + : firstType.text; return { - classId: generateId('Impl', `${filePath}:${firstType.text}`), + classId: generateId('Impl', `${filePath}:${ownerKey}`), className: firstType.text, }; } diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index a3882c00a2..2bb2ea0439 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -67,6 +67,7 @@ import { genericFuncName, inferFunctionLabel, isSuppressedConcreteTypedefDuplicate, + qualifyRustImplTargetByModScope, CLASS_CONTAINER_TYPES, type SyntaxNode, } from '../utils/ast-helpers.js'; @@ -1857,14 +1858,26 @@ const processFileGroup = ( // Qualify method/property IDs with enclosing class name to avoid collisions. // Class-like nodes use their own fully-qualified path as the id key when the // language enables qualifiedNodeId (#1978); everything else is unchanged. + // #1982: LOCKSTEP with parsing-processor.ts — a Rust inherent-impl with an + // UNSCOPED bare target is keyed by the enclosing `mod_item` scope so the + // worker-path Impl node id matches the sequential path and the owner walk. + const rustImplQualifiedName = + nodeLabel === 'Impl' && + definitionNode?.type === 'impl_item' && + nameNode?.type === 'type_identifier' + ? qualifyRustImplTargetByModScope(definitionNode, nodeName) + : undefined; + const qualifiedName = - isClassLikeLabel && - provider.classExtractor?.qualifiedNodeId === true && - qualifiedTypeName !== undefined - ? qualifiedTypeName - : enclosingClassInfo - ? `${enclosingClassInfo.className}.${nodeName}` - : nodeName; + rustImplQualifiedName !== undefined + ? rustImplQualifiedName + : isClassLikeLabel && + provider.classExtractor?.qualifiedNodeId === true && + qualifiedTypeName !== undefined + ? qualifiedTypeName + : enclosingClassInfo + ? `${enclosingClassInfo.className}.${nodeName}` + : nodeName; // Extract method metadata BEFORE generating node ID — parameterCount is needed // to disambiguate overloaded methods via # suffix in the ID. diff --git a/gitnexus/test/integration/resolvers/rust.test.ts b/gitnexus/test/integration/resolvers/rust.test.ts index 99de19473d..8a99119a09 100644 --- a/gitnexus/test/integration/resolvers/rust.test.ts +++ b/gitnexus/test/integration/resolvers/rust.test.ts @@ -2073,24 +2073,32 @@ describe('Rust scoped inherent impl — ownership + collision (issue #1975)', () // is separate machinery tracked as a follow-up. C++/Ruby land first (KTD-6). // --------------------------------------------------------------------------- -// Skipped: Rust inherent-impl ownership is deferred to the resolution-side -// follow-up (see the comment block above). Tracked in the #1978 follow-up issue. -describe.skip('Rust inline mod-nested same-tail collision — distinct nodes (issue #1978)', () => { +// #1982: Rust same-tail nested-mod inherent-impl methods now own through DISTINCT +// Impl nodes — mod outer's `impl Inner` → `Impl:...:outer.Inner`, mod other's → +// `other.Inner`. The inherent-impl owner walk (ast-helpers `findEnclosingClassInfo`) +// and the Impl-node materialization (parsing-processor / parse-worker) both qualify +// an UNSCOPED impl target by its enclosing `mod_item` scope, byte-identically, so +// the HAS_METHOD owner edge stays anchored. Structure-phase, so it holds on both +// resolver legs. (Scoped `impl a::Inner` is unchanged — #1975.) +describe('Rust inline mod-nested same-tail collision — distinct nodes (issue #1978/#1982)', () => { let result: PipelineResult; beforeAll(async () => { result = await runPipelineFromRepo(path.join(FIXTURES, 'rust-nested-tail-collision'), () => {}); }, 60000); - it('owns from_outer / from_other through distinct nodes (no merge, no mis-attribution)', () => { + it('owns from_outer / from_other through distinct mod-qualified Impl nodes (no merge)', () => { expect(findDanglingEdges(result, ['HAS_METHOD'])).toEqual([]); const hm = getRelationships(result, 'HAS_METHOD'); const a = hm.find((e) => e.target === 'from_outer'); const b = hm.find((e) => e.target === 'from_other'); - expect(a).toBeDefined(); - expect(b).toBeDefined(); - // The two same-tail `Inner` methods must NOT share one owner node id. + expect(a, 'HAS_METHOD -> from_outer').toBeDefined(); + expect(b, 'HAS_METHOD -> from_other').toBeDefined(); + // Pre-fix the two same-tail `Inner` impls merged onto one `Impl:...:Inner` + // node. KTD3: discriminate on the node id — each now carries its mod path. expect(a!.rel.sourceId).not.toBe(b!.rel.sourceId); + expect(a!.rel.sourceId).toContain('outer.Inner'); + expect(b!.rel.sourceId).toContain('other.Inner'); }); }); From e83858f17101e1d4417d02cb73cc4843d5ffb262 Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Wed, 3 Jun 2026 12:50:24 +0000 Subject: [PATCH 13/16] refactor(ingestion): single qualified-name normalizer + module-scoped Ruby PROPERTY_PREFIX (#1982) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace cpp/captures.ts's parallel normalizeCppNamespaceQName with the shared normalizeQualifiedName (behaviorally equivalent for C++ qualified-identifier inputs: '::'->'.' with leading/trailing-:: handling; no interior whitespace reaches it). Promote Ruby's PROPERTY_PREFIX to module scope alongside HERITAGE_PREFIX (was function-local — asymmetric with no behavioral effect). Maintainability only; cpp+ruby resolver suites 428/428, tsc clean. Addresses PR #1981 review (4417182679) maintainability item. Co-Authored-By: Claude Opus 4.8 (1M context) --- gitnexus/src/core/ingestion/languages/cpp/captures.ts | 10 +++------- .../core/ingestion/languages/ruby/scope-resolver.ts | 2 +- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/gitnexus/src/core/ingestion/languages/cpp/captures.ts b/gitnexus/src/core/ingestion/languages/cpp/captures.ts index 7922e4d334..265db8d5d1 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/captures.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/captures.ts @@ -8,6 +8,7 @@ import { import { getCppParser, getCppScopeQuery } from './query.js'; import { getTreeSitterBufferSize } from '../../constants.js'; import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js'; +import { normalizeQualifiedName } from '../../utils/qualified-name.js'; import { splitCppInclude, splitCppUsingDecl } from './import-decomposer.js'; import { classifyCppParameterType, @@ -1587,7 +1588,7 @@ function extractAdlTypeNamespace(typeNode: SyntaxNode): string { } if (typeNode.type === 'qualified_identifier') { const scope = typeNode.childForFieldName('scope'); - if (scope !== null) return normalizeCppNamespaceQName(scope.text); + if (scope !== null) return normalizeQualifiedName(scope.text); return extractNamespaceFromQualifiedText(typeNode.text); } return ''; @@ -1664,16 +1665,11 @@ function findTemplateTypeNode(typeNode: SyntaxNode): SyntaxNode | null { return null; } -function normalizeCppNamespaceQName(text: string): string { - const normalized = text.replace(/^::/, '').replace(/::$/, '').replace(/::/g, '.'); - return normalized; -} - function extractNamespaceFromQualifiedText(text: string): string { const cleaned = text.replace(/\s+/g, ''); const idx = cleaned.lastIndexOf('::'); if (idx <= 0) return ''; - return normalizeCppNamespaceQName(cleaned.slice(0, idx)); + return normalizeQualifiedName(cleaned.slice(0, idx)); } /** diff --git a/gitnexus/src/core/ingestion/languages/ruby/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/ruby/scope-resolver.ts index e9802435a3..afefb0c9d3 100644 --- a/gitnexus/src/core/ingestion/languages/ruby/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/ruby/scope-resolver.ts @@ -11,6 +11,7 @@ import type { KnowledgeGraph } from '../../../graph/types.js'; import { generateId } from '../../../../lib/utils.js'; const HERITAGE_PREFIX = '__heritage__:'; +const PROPERTY_PREFIX = '__property__:'; function emitRubyMixinEdges( graph: KnowledgeGraph, @@ -92,7 +93,6 @@ function emitRubyMixinEdges( } } - const PROPERTY_PREFIX = '__property__:'; for (const parsed of parsedFiles) { for (const imp of parsed.parsedImports) { if (!imp.targetRaw.startsWith(PROPERTY_PREFIX)) continue; From a4ccbb59ba0dd70a5b4efffb6770a076e2e7648b Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Wed, 3 Jun 2026 12:59:14 +0000 Subject: [PATCH 14/16] perf+fix(ingestion): single enclosing-class walk + root-anchored base guard (#1982) U7 (perf): preEmitInheritanceEdges resolved the deriving class AND resolveQualifiedInheritanceBase re-walked findEnclosingClassDef for the same site. Resolve callerClass once and thread it into resolveInheritanceBaseInScope -> resolveQualifiedInheritanceBase -> enclosingScopeSegments, so the enclosing class is walked once per qualified site. Add a 'program' early-exit to buildEnclosingQualifiedName (ruby/captures.ts). Behavior-preserving. U8 (P3): a root-anchored C++ base ": ::A::Inner" names the GLOBAL type, but resolveQualifiedInheritanceBase prepended the deriving class's enclosing segments and could mis-bind to an enclosing-relative same-path type. Detect the leading "::" on the raw qualifier and try only the root-anchored key. Discriminating fixture + test (registry-primary only). cpp+ruby+rust resolver suites 599/599; tsc clean. Addresses PR #1981 review (4417182679) perf + P3 items. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../core/ingestion/languages/ruby/captures.ts | 4 +++ .../scope-resolution/pipeline/run.ts | 10 ++++-- .../scope-resolution/scope/walkers.ts | 31 ++++++++++++++++--- .../cpp-global-base-anchor/main.cpp | 21 +++++++++++++ .../test/integration/resolvers/cpp.test.ts | 30 ++++++++++++++++++ .../test/integration/resolvers/helpers.ts | 3 ++ 6 files changed, 91 insertions(+), 8 deletions(-) create mode 100644 gitnexus/test/fixtures/lang-resolution/cpp-global-base-anchor/main.cpp diff --git a/gitnexus/src/core/ingestion/languages/ruby/captures.ts b/gitnexus/src/core/ingestion/languages/ruby/captures.ts index fe9502051b..7f5d98f91e 100644 --- a/gitnexus/src/core/ingestion/languages/ruby/captures.ts +++ b/gitnexus/src/core/ingestion/languages/ruby/captures.ts @@ -41,6 +41,10 @@ function buildEnclosingQualifiedName(callNode: SyntaxNode): string | undefined { const nameNode = current.childForFieldName('name'); if (nameNode !== null) segments.unshift(...splitQualifiedName(nameNode.text)); } + // Stop at the file root — nothing above `program` contributes a Ruby + // class/module scope segment (#1982 perf; avoids walking to the very top + // for every heritage/attr call). + if (current.type === 'program') break; current = current.parent; } return segments.length > 0 ? segments.join('.') : undefined; diff --git a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts index 7b7b304f66..8882885c45 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts @@ -131,16 +131,20 @@ function preEmitInheritanceEdges( handledSites.add(siteKey); } + // Resolve the deriving (caller) class first and reuse it as the enclosing + // context for qualified-base resolution — avoids a second findEnclosingClassDef + // walk per qualified site (#1982 perf). Both need the same enclosing class. + const callerClass = findEnclosingClassDef(site.inScope, scopes); + if (callerClass === undefined) continue; + const targetDef = resolveInheritanceBaseInScope( site.inScope, site.name, scopes, site.rawQualifiedName, + callerClass, ); if (targetDef === undefined) continue; - - const callerClass = findEnclosingClassDef(site.inScope, scopes); - if (callerClass === undefined) continue; const callerGraphId = resolveDefGraphId(callerClass.filePath, callerClass, nodeLookup); const targetGraphId = resolveDefGraphId(targetDef.filePath, targetDef, nodeLookup); if (callerGraphId === undefined || targetGraphId === undefined) continue; diff --git a/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts b/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts index fcef4b7028..b594d4acfb 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts @@ -311,6 +311,7 @@ export function resolveInheritanceBaseInScope( baseName: string, scopes: ScopeResolutionIndexes, rawQualifiedName?: string, + enclosingClassDef?: SymbolDefinition, ): SymbolDefinition | undefined { // #1982: when the source wrote a qualified base (`Other::Inner`), resolve it // against the full-path QualifiedNameIndex FIRST, so a same-tail nested base @@ -318,9 +319,15 @@ export function resolveInheritanceBaseInScope( // simple-tail scope walk picks. Falls through to the existing walk when the // base is unqualified, unknown, or the qualified lookup can't pick a unique // winner — so unqualified bases and the cross-file single-candidate case are - // unchanged. + // unchanged. `enclosingClassDef` (the deriving class) is threaded from the + // caller to skip a redundant enclosing-class walk (#1982 perf). if (rawQualifiedName !== undefined) { - const qualified = resolveQualifiedInheritanceBase(startScope, rawQualifiedName, scopes); + const qualified = resolveQualifiedInheritanceBase( + startScope, + rawQualifiedName, + scopes, + enclosingClassDef, + ); if (qualified !== undefined) return qualified; } return ( @@ -344,12 +351,20 @@ function resolveQualifiedInheritanceBase( startScope: ScopeId, rawQualifiedName: string, scopes: ScopeResolutionIndexes, + enclosingClassDef?: SymbolDefinition, ): SymbolDefinition | undefined { const normalized = normalizeQualifiedName(rawQualifiedName); // No qualifier after normalization → nothing the simple-tail walk doesn't do. if (normalized.length === 0 || !normalized.includes('.')) return undefined; - const enclosing = enclosingScopeSegments(startScope, scopes); + // #1982: a root-anchored base (`::Net::X`) names the GLOBAL scope, so it must + // NOT be prefixed with the referencing site's enclosing segments — try only + // the root-anchored key. normalizeQualifiedName strips the leading `::`, so + // detect the anchor on the raw text (after leading whitespace). + const isRootAnchored = /^\s*::/.test(rawQualifiedName); + const enclosing = isRootAnchored + ? [] + : enclosingScopeSegments(startScope, scopes, enclosingClassDef); // Candidate keys: longest enclosing prefix first, then the root-anchored form. const keys: string[] = []; for (let i = enclosing.length; i >= 1; i--) { @@ -381,8 +396,14 @@ function resolveQualifiedInheritanceBase( * `NS.Other.Derived` this is `['NS', 'Other']`; empty for a file-scope child. * Used to build progressive-prefix lookup keys for relative qualified bases. */ -function enclosingScopeSegments(startScope: ScopeId, scopes: ScopeResolutionIndexes): string[] { - const child = findEnclosingClassDef(startScope, scopes); +function enclosingScopeSegments( + startScope: ScopeId, + scopes: ScopeResolutionIndexes, + enclosingClassDef?: SymbolDefinition, +): string[] { + // Reuse the caller-provided deriving class when available (#1982 perf); only + // walk the scope chain when it wasn't threaded in. + const child = enclosingClassDef ?? findEnclosingClassDef(startScope, scopes); const q = child?.qualifiedName; if (q === undefined || q.length === 0) return []; const segs = q.split('.').filter(Boolean); diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-global-base-anchor/main.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-global-base-anchor/main.cpp new file mode 100644 index 0000000000..7e963b1a25 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-global-base-anchor/main.cpp @@ -0,0 +1,21 @@ +// #1982 P3: a root-anchored base (`: ::A::Inner`) names the GLOBAL `::A::Inner`, +// NOT the enclosing-relative `Outer::Wrap::A::Inner`. Without the leading-`::` +// guard in resolveQualifiedInheritanceBase, the enclosing-prefix key +// `Wrap.A.Inner` is tried first and `D` mis-binds to the inner type. With the +// guard, only the root-anchored `A.Inner` key is tried → the global type. +struct A { + struct Inner { + void global_inner() {} + }; +}; + +namespace Outer { +struct Wrap { + struct A { + struct Inner { + void wrap_inner() {} + }; + }; + struct D : ::A::Inner {}; +}; +} // namespace Outer diff --git a/gitnexus/test/integration/resolvers/cpp.test.ts b/gitnexus/test/integration/resolvers/cpp.test.ts index cbf03f5e7d..0ea1510771 100644 --- a/gitnexus/test/integration/resolvers/cpp.test.ts +++ b/gitnexus/test/integration/resolvers/cpp.test.ts @@ -3997,3 +3997,33 @@ describe('C++ namespaced same-tail nested heritage — worker path parity (issue expect(db).not.toContain('NS.A.Inner'); }); }); + +// --------------------------------------------------------------------------- +// Root-anchored base must not pick up enclosing-relative segments (issue #1982) +// +// `namespace Outer { struct Wrap { struct A{struct Inner{};}; struct D : ::A::Inner {}; }; }` +// with a GLOBAL `struct A { struct Inner {}; }` — the leading `::` names the +// global type. Without the root-anchor guard, resolveQualifiedInheritanceBase +// prepends the deriving class's enclosing segments and tries `Wrap.A.Inner` +// first, mis-binding D to the inner type. With it, only the root-anchored +// `A.Inner` key is tried → the global type. Registry-primary only. +// --------------------------------------------------------------------------- + +describe('C++ root-anchored base ignores enclosing-relative type (issue #1982)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'cpp-global-base-anchor'), () => {}); + }, 60000); + + it('resolves Outer::Wrap::D : ::A::Inner → EXTENDS the GLOBAL A.Inner (not Wrap.A.Inner)', () => { + const e = getRelationships(result, 'EXTENDS').find( + (x) => result.graph.getNode(x.rel.sourceId)?.properties.qualifiedName === 'Outer.Wrap.D', + ); + expect(e, 'Outer.Wrap.D EXTENDS endpoint').toBeDefined(); + // Global node id is `Struct:main.cpp:A.Inner`; the enclosing-relative type + // is `Struct:main.cpp:Outer.Wrap.A.Inner`. KTD3: discriminate on the node id. + expect(e!.rel.targetId).toContain('A.Inner'); + expect(e!.rel.targetId).not.toContain('Wrap'); + }); +}); diff --git a/gitnexus/test/integration/resolvers/helpers.ts b/gitnexus/test/integration/resolvers/helpers.ts index 5c3594d575..ae07af5012 100644 --- a/gitnexus/test/integration/resolvers/helpers.ts +++ b/gitnexus/test/integration/resolvers/helpers.ts @@ -550,6 +550,9 @@ const LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES: Readonly Date: Wed, 3 Jun 2026 13:08:13 +0000 Subject: [PATCH 15/16] test(ingestion): rebaseline ruby+cpp scope-capture fingerprints for new #1982 fixtures The four new fixtures (ruby-nested-mixin-shortname, ruby-qualified-mixin, cpp-namespaced-collision, cpp-global-base-anchor) grow the lang-resolution corpus, drifting the ruby and cpp order-independent capture fingerprints. Verified purely additive: the ruby captures golden shows only the two new fixtures added (existing byte-identical), and removing the two cpp fixtures reverts the cpp fingerprint to the prior baseline (so the U3/U6/U8 code changes are scope-resolution / behavior-preserving, not capture-emission). measure.mjs --check PASS (14 languages). Co-Authored-By: Claude Opus 4.8 (1M context) --- gitnexus/bench/scope-capture/baselines.json | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/gitnexus/bench/scope-capture/baselines.json b/gitnexus/bench/scope-capture/baselines.json index f5f8467e8d..1fda220e2a 100644 --- a/gitnexus/bench/scope-capture/baselines.json +++ b/gitnexus/bench/scope-capture/baselines.json @@ -16,8 +16,9 @@ "_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": "2f517381e8b03db221d13f65d5e485d021c17a2f7fa684446e09f33d78c3adb9", + "fingerprint": "7feac8cb58cf69d313271b9412954c9da2c7a31b16caaa287f963c61dc6af88c", "scaling_budget": 1.5, + "_followup": "#1982 review: + cpp-namespaced-collision (namespaced same-tail heritage) + cpp-global-base-anchor (root-anchored base) fixtures. Purely additive corpus drift — verified by fixture-removal that the cpp fingerprint reverts to the prior baseline (2f517381) without them, so existing fixtures' captures are byte-identical (the U3 tagNamespacePrefixes / U6 shared-normalizer / U8 root-anchor changes are scope-resolution / behavior-preserving, not capture-emission). fixture_count 266->268.", "_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 (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. #1982: cpp-nested-tail-collision gains qualified heritage (struct DerivedA : Outer::Inner, struct DerivedB : Other::Inner) AND emitCppInheritanceCaptures now emits an ADDITIVE @reference.qualified-name capture on QUALIFIED bases (drives the qualified-first inheritance resolver). Purely additive — existing captures unchanged; same fixture file, no new fixture." @@ -39,8 +40,9 @@ "_rebaselined": "#1956: heritage-bearing scale source (class extends Base + use trait); both forms gated at scale; linear (~1.04)." }, "ruby": { - "fingerprint": "011c0533318ff61d99267d501866af8a616b1c212050628339064d8c149aafbf", + "fingerprint": "bf6b13a366e4116da3772f9a9fdd50517eb11da73918451392e014a2c905b2dd", "scaling_budget": 1.5, + "_followup": "#1982 review: + ruby-nested-mixin-shortname (nested mixin by short name) + ruby-qualified-mixin (include Outer::Mixin) fixtures. Additive corpus drift — the regenerated captures golden shows ONLY the two new fixtures added, all existing ruby fixtures byte-identical. fixture_count 83->85.", "_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. #1975: + ruby-tail-collision fixture (Foo::Bar vs Baz::Bar stay distinct nodes) — pure fixture-corpus drift, scope-extractor captures unchanged; 81→82. #1978: + ruby-nested-tail-collision (same-tail Inner under Outer/Other modules + Shapes.Circle attr_accessor) — pure fixture-corpus drift, scope-extractor captures unchanged; 82→83. #1982: that fixture gains same-tail include + attr_accessor, AND emitRubyScopeCaptures now emits the FULL qualified owner in __heritage__/__property__ markers (buildEnclosingQualifiedName) — a CODE change LOCALIZED to nested owners: the golden shows ONLY this fixture drifts (+10 capture groups), all other ruby fixtures byte-identical, and 142/142 resolver tests pass. fixture_count 83 (same file)." }, From 684203c230112269c1358a2f65db1924f0a9a6b0 Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Wed, 3 Jun 2026 13:09:19 +0000 Subject: [PATCH 16/16] style(ingestion): prettier-wrap ruby resolver test call (#1982) Co-Authored-By: Claude Opus 4.8 (1M context) --- gitnexus/test/integration/resolvers/ruby.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/gitnexus/test/integration/resolvers/ruby.test.ts b/gitnexus/test/integration/resolvers/ruby.test.ts index fc891bb939..763dfd3560 100644 --- a/gitnexus/test/integration/resolvers/ruby.test.ts +++ b/gitnexus/test/integration/resolvers/ruby.test.ts @@ -1678,7 +1678,10 @@ describe('Ruby nested mixin by short name — IMPLEMENTS not dropped (issue #198 let result: PipelineResult; beforeAll(async () => { - result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-nested-mixin-shortname'), () => {}); + result = await runPipelineFromRepo( + path.join(FIXTURES, 'ruby-nested-mixin-shortname'), + () => {}, + ); }, 60000); pit('emits App.Service -IMPLEMENTS-> App.Loggable for a short-name nested mixin (R1)', () => {