mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-08 22:22:52 +00:00
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:
parent
5f0d690c60
commit
dc122897a9
14 changed files with 410 additions and 35 deletions
|
|
@ -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`)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
11
gitnexus/test/fixtures/lang-resolution/cpp-nested-tail-collision/shapes.cpp
vendored
Normal file
11
gitnexus/test/fixtures/lang-resolution/cpp-nested-tail-collision/shapes.cpp
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
struct Outer {
|
||||
struct Inner {
|
||||
void from_outer() {}
|
||||
int outer_field;
|
||||
};
|
||||
};
|
||||
struct Other {
|
||||
struct Inner {
|
||||
void from_other() {}
|
||||
};
|
||||
};
|
||||
19
gitnexus/test/fixtures/lang-resolution/ruby-nested-tail-collision/nested.rb
vendored
Normal file
19
gitnexus/test/fixtures/lang-resolution/ruby-nested-tail-collision/nested.rb
vendored
Normal 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
|
||||
12
gitnexus/test/fixtures/lang-resolution/rust-nested-tail-collision/lib.rs
vendored
Normal file
12
gitnexus/test/fixtures/lang-resolution/rust-nested-tail-collision/lib.rs
vendored
Normal 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) {}
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue