diff --git a/gitnexus/src/core/ingestion/call-processor.ts b/gitnexus/src/core/ingestion/call-processor.ts index c8c62921a..a3ca9c898 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 fb5df99c3..d39888aa1 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 2c4c711bd..13f1fdd43 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 5f20d1dc2..1f11adc44 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 9407d41fa..2e3d1f688 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 6c77e7958..62346963f 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 f7e7917ea..4fcf870d2 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 8a7946d32..a3882c00a 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 000000000..515a3cbc5 --- /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 000000000..94937fed5 --- /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 000000000..e2675a839 --- /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 da62a243a..e2d140885 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 cd0ea7748..08c31d3d7 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 bcfa4200d..715295d4a 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); + }); +});