fix(ingestion): qualify nested-type node identity for C++/Ruby (#1978)

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) <noreply@anthropic.com>
This commit is contained in:
Gergo Magyar 2026-06-02 23:10:09 +00:00
parent 5f0d690c60
commit dc122897a9
14 changed files with 410 additions and 35 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<string, FieldInfo> | 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 #<arity> 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);

View file

@ -0,0 +1,11 @@
struct Outer {
struct Inner {
void from_outer() {}
int outer_field;
};
};
struct Other {
struct Inner {
void from_other() {}
};
};

View file

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

View file

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

View file

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

View file

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

View file

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