Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd8eb4757c | ||
|
|
bb84ccb27e | ||
|
|
16883f2d79 | ||
|
|
35a37d9240 | ||
|
|
e2641628c7 | ||
|
|
ddc31ba72e | ||
|
|
dc122897a9 |
@@ -54,6 +54,18 @@ export type CallForm = 'free' | 'member' | 'constructor' | 'index';
|
||||
export interface ReferenceSite {
|
||||
/** The name being referenced (e.g., `'save'`, `'User'`, `'count'`). */
|
||||
readonly name: string;
|
||||
/**
|
||||
* Optional raw, qualified form of the referenced name when the source wrote
|
||||
* a qualified path (e.g. a C++ base `struct D : Other::Inner` yields
|
||||
* `'Other::Inner'`). `name` keeps the simple tail (`'Inner'`) for the existing
|
||||
* scope-chain contract; resolution normalizes this via `normalizeQualifiedName`
|
||||
* and resolves it against the full-path `QualifiedNameIndex` BEFORE the
|
||||
* simple-tail walk, so a same-tail nested base resolves to the correct
|
||||
* sibling instead of the first-inserted one (issue #1982). Populated only by
|
||||
* per-language captures that emit `@reference.qualified-name`; absent
|
||||
* otherwise, in which case resolution is unchanged.
|
||||
*/
|
||||
readonly rawQualifiedName?: string;
|
||||
/** Source-text range of this reference. */
|
||||
readonly atRange: Range;
|
||||
/**
|
||||
|
||||
@@ -16,11 +16,11 @@
|
||||
"_added": "#1956: c added to the scope-capture bench (was UNBENCHED). C has no inheritance \u2014 flat scale source. Adding it exposed + fixed a pre-existing O(n^2) findNodeAtRange root-walk in c/captures.ts (threaded c.node, byte-identical over c-* fixtures); scaling 3.475 -> 0.96."
|
||||
},
|
||||
"cpp": {
|
||||
"fingerprint": "931bf7af55dc1480d1a5d3c479ea3803003a6a2e2c4406447bd96f3e312e88de",
|
||||
"fingerprint": "2f517381e8b03db221d13f65d5e485d021c17a2f7fa684446e09f33d78c3adb9",
|
||||
"scaling_budget": 1.5,
|
||||
"_added": "#1956: cpp added to the scope-capture bench (was UNBENCHED). Heritage-bearing scale source (: public Base, public Mixin) drives emitCppInheritanceCaptures at scale. Adding it exposed + fixed a pre-existing O(n^2) findNodeAtRange root-walk in cpp/captures.ts (~12 sites, threaded c.node, byte-identical over 263 cpp-* fixtures); scaling 2.30 -> 1.12.",
|
||||
"_rebaselined": "#1965 / #1923 F4: uninitialized non-leading multi-declarators now emit @declaration.variable captures; cpp-adl-inner-callable-outer-noncallable data::Pair a, b adds the legitimate fixture drift. Linear (~1.06).",
|
||||
"_note": "#1975: + cpp-out-of-line-class fixture (out-of-line struct Outer::Inner / Other::Inner). Pure fixture-corpus drift — the fix is the legacy structure-query qualified_identifier arm, NOT the cpp scope-extractor; existing fixtures' captures byte-identical. fixture_count 263->265."
|
||||
"_note": "#1975: + cpp-out-of-line-class fixture (out-of-line struct Outer::Inner / Other::Inner). Pure fixture-corpus drift — the fix is the legacy structure-query qualified_identifier arm, NOT the cpp scope-extractor; existing fixtures' captures byte-identical. fixture_count 263->265. #1982: cpp-nested-tail-collision gains qualified heritage (struct DerivedA : Outer::Inner, struct DerivedB : Other::Inner) AND emitCppInheritanceCaptures now emits an ADDITIVE @reference.qualified-name capture on QUALIFIED bases (drives the qualified-first inheritance resolver). Purely additive — existing captures unchanged; same fixture file, no new fixture."
|
||||
},
|
||||
"csharp": {
|
||||
"_rebaselined": "#1956 synth-widening: + csharp-qualified-base fixture; the synth now walks record_declaration + struct_declaration base_lists and handles alias_qualified_name (matching the #1940 legacy leg), so record/struct heritage now emits. csharp-record-base gains a record inherits capture. (record->record SAME-namespace EXTENDS is a separate registry resolution gap, tracked as follow-up.) Linear (~1.00). (Earlier #1956: heritage-bearing scale source.)",
|
||||
@@ -28,7 +28,7 @@
|
||||
"scaling_budget": 1.5
|
||||
},
|
||||
"rust": {
|
||||
"fingerprint": "3c4b8e0a707299cc5db0af2528c72a99457859104589a7ef3cd1f377da01793e",
|
||||
"fingerprint": "30224e2590064745548bc1d623811ae5d37227618854788695442a0acf1898fb",
|
||||
"scaling_budget": 1.5,
|
||||
"_rebaselined": "#1956 tri-review U1: rust-qualified-trait fixture (scoped + generic-of-scoped impl trait paths); bareTypeIdentifier now resolves scoped_type_identifier bases by their name: tail (additive, no existing-fixture drift); linear (~1.04).",
|
||||
"_note": "#1975: + rust-scoped-impl fixture (impl a::Inner / b::Inner inherent scoped impls). Pure fixture-corpus drift — the fix is the legacy @definition.impl scoped arm + findEnclosingClassInfo inherent-impl scoped target, NOT the rust scope-extractor; existing fixtures' captures byte-identical. fixture_count 120->121."
|
||||
@@ -39,10 +39,10 @@
|
||||
"_rebaselined": "#1956: heritage-bearing scale source (class extends Base + use trait); both forms gated at scale; linear (~1.04)."
|
||||
},
|
||||
"ruby": {
|
||||
"fingerprint": "ee81145cf0af796878e8e048192b87c8c8dc445a3e3fcdff6c6e26c179e97232",
|
||||
"fingerprint": "011c0533318ff61d99267d501866af8a616b1c212050628339064d8c149aafbf",
|
||||
"scaling_budget": 1.5,
|
||||
"_rebaselined": "#1956 synth-widening: + ruby-qualified-base fixture; synth now reduces a scope_resolution superclass (class C < Mod::Super) to its trailing constant (matching the #1940 legacy leg), at parity. Linear (~1.03). (Earlier #1956: heritage-bearing scale source.)",
|
||||
"_note": "F62: + scope_resolution class/module declaration captures — fixture count 78→81, fingerprint drift expected. #1975: + ruby-tail-collision fixture (Foo::Bar vs Baz::Bar stay distinct nodes) — pure fixture-corpus drift, scope-extractor captures unchanged; 81→82."
|
||||
"_note": "F62: + scope_resolution class/module declaration captures — fixture count 78→81, fingerprint drift expected. #1975: + ruby-tail-collision fixture (Foo::Bar vs Baz::Bar stay distinct nodes) — pure fixture-corpus drift, scope-extractor captures unchanged; 81→82. #1978: + ruby-nested-tail-collision (same-tail Inner under Outer/Other modules + Shapes.Circle attr_accessor) — pure fixture-corpus drift, scope-extractor captures unchanged; 82→83. #1982: that fixture gains same-tail include + attr_accessor, AND emitRubyScopeCaptures now emits the FULL qualified owner in __heritage__/__property__ markers (buildEnclosingQualifiedName) — a CODE change LOCALIZED to nested owners: the golden shows ONLY this fixture drifts (+10 capture groups), all other ruby fixtures byte-identical, and 142/142 resolver tests pass. fixture_count 83 (same file)."
|
||||
},
|
||||
"swift": {
|
||||
"fingerprint": "53325c6345161c5a495f997297af5a24fb718fd3e6647040160f8ab2a2c8e4c0",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
ClassLikeNodeLabel,
|
||||
ExtractedClassSymbol,
|
||||
} from '../class-types.js';
|
||||
import { splitQualifiedName } from '../utils/qualified-name.js';
|
||||
|
||||
const DEFAULT_SCOPE_NAME_NODE_TYPES = new Set([
|
||||
'nested_namespace_specifier',
|
||||
@@ -58,20 +59,6 @@ const CLASS_LIKE_LABELS = new Set<ClassLikeNodeLabel>([
|
||||
'Record',
|
||||
]);
|
||||
|
||||
const normalizeQualifiedName = (value: string): string =>
|
||||
value
|
||||
.replace(/\s+/g, '')
|
||||
.replace(/^::/, '')
|
||||
.replace(/::/g, '.')
|
||||
.replace(/\\/g, '.')
|
||||
.replace(/\.+/g, '.')
|
||||
.replace(/^\.+|\.+$/g, '');
|
||||
|
||||
const splitQualifiedName = (value: string): string[] => {
|
||||
const normalized = normalizeQualifiedName(value);
|
||||
return normalized ? normalized.split('.').filter(Boolean) : [];
|
||||
};
|
||||
|
||||
const extractScopeSegmentsFromNode = (
|
||||
scopeNode: SyntaxNode,
|
||||
scopeNameNodeTypes: ReadonlySet<string>,
|
||||
@@ -165,6 +152,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;
|
||||
|
||||
@@ -515,9 +515,24 @@ function emitCppInheritanceCaptures(root: SyntaxNode, out: CaptureMatch[], fileP
|
||||
}
|
||||
const baseName = extractBaseLookupName(base.node);
|
||||
if (baseName.length === 0) continue;
|
||||
// Preserve the qualified form (`Other::Inner`, template-stripped) when the
|
||||
// source wrote one, so a same-tail nested base resolves to the matching
|
||||
// qualified node instead of the first-inserted same-tail one (#1982). The
|
||||
// bare `@reference.name` stays the V1 simple-name contract; the qualifier
|
||||
// is an additive sidecar resolution tries first (see resolveInheritanceBaseInScope).
|
||||
const qualifiedBaseName = extractQualifiedBaseName(base.node);
|
||||
out.push({
|
||||
'@reference.inherits': nodeToCapture('@reference.inherits', base.node),
|
||||
'@reference.name': syntheticCapture('@reference.name', base.node, baseName),
|
||||
...(qualifiedBaseName.length > 0 && qualifiedBaseName !== baseName
|
||||
? {
|
||||
'@reference.qualified-name': syntheticCapture(
|
||||
'@reference.qualified-name',
|
||||
base.node,
|
||||
qualifiedBaseName,
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -738,6 +753,41 @@ function extractBaseLookupName(baseNode: SyntaxNode): string {
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Like `extractBaseLookupName` but PRESERVES the namespace/class qualifier
|
||||
* (`Other::Inner`, `ns::v1::Base`) while stripping template arguments
|
||||
* (`ns::Base<T>` → `ns::Base`). Returns `''` for shapes it can't qualify, and
|
||||
* returns the bare name unchanged for an unqualified base (the emit site then
|
||||
* skips the sidecar capture). Powers `@reference.qualified-name` so #1982
|
||||
* resolution can pick the matching same-tail nested base via the full-path
|
||||
* QualifiedNameIndex instead of the first-inserted same-tail sibling.
|
||||
*/
|
||||
function extractQualifiedBaseName(baseNode: SyntaxNode): string {
|
||||
if (baseNode.type === 'template_type') {
|
||||
const nameNode = baseNode.childForFieldName('name');
|
||||
return nameNode !== null ? extractQualifiedBaseName(nameNode) : '';
|
||||
}
|
||||
if (baseNode.type === 'qualified_identifier') {
|
||||
// No template args anywhere → the raw text already IS the qualified name.
|
||||
if (!baseNode.text.includes('<')) return baseNode.text;
|
||||
// Template args present: reconstruct scope::name, recursing to strip them.
|
||||
const scopeNode = baseNode.childForFieldName('scope');
|
||||
const nameNode = baseNode.childForFieldName('name');
|
||||
const left = scopeNode !== null ? extractQualifiedBaseName(scopeNode) : '';
|
||||
const right = nameNode !== null ? extractQualifiedBaseName(nameNode) : '';
|
||||
if (left.length > 0 && right.length > 0) return `${left}::${right}`;
|
||||
return right.length > 0 ? right : left;
|
||||
}
|
||||
if (
|
||||
baseNode.type === 'namespace_identifier' ||
|
||||
baseNode.type === 'type_identifier' ||
|
||||
baseNode.type === 'identifier'
|
||||
) {
|
||||
return baseNode.text;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/** Extract the syntactic namespace qualifier from a base class node.
|
||||
* For `detail::Inner<T>`, returns `'detail'`.
|
||||
* For unqualified bases (`Inner<T>`, `Base<int>`), returns `''`.
|
||||
|
||||
@@ -12,6 +12,7 @@ import { recordRubyCacheHit, recordRubyCacheMiss } from './cache-stats.js';
|
||||
import { synthesizeRubyReceiverBinding, findEnclosingClassOrModule } from './receiver-binding.js';
|
||||
import { getTreeSitterBufferSize } from '../../constants.js';
|
||||
import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js';
|
||||
import { splitQualifiedName } from '../../utils/qualified-name.js';
|
||||
|
||||
const FUNCTION_NODE_TYPES = ['method', 'singleton_method'] as const;
|
||||
const HERITAGE_CALL_NAMES: ReadonlySet<string> = new Set(['include', 'extend', 'prepend']);
|
||||
@@ -21,6 +22,30 @@ const ATTR_CALL_NAMES: ReadonlySet<string> = new Set([
|
||||
'attr_writer',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Build the full `.`-joined qualified owner name for a heritage/attr call by
|
||||
* walking ALL enclosing class/module ancestors (not just the immediate one),
|
||||
* so a same-tail nested owner (`module Outer; class Inner`) is keyed by its
|
||||
* full path `Outer.Inner` instead of the bare tail `Inner` — which otherwise
|
||||
* collapses both same-tail owners onto one `__heritage__`/`__property__` marker
|
||||
* key (last-wins) and cross-wires their mixin / attr_accessor edges (#1982).
|
||||
* Handles the compact `class Outer::Inner` form (name is a `scope_resolution`)
|
||||
* via the shared normalizer, so the marker owner byte-matches the resolution
|
||||
* def's `qualifiedName`. Returns undefined when there is no enclosing class/module.
|
||||
*/
|
||||
function buildEnclosingQualifiedName(callNode: SyntaxNode): string | undefined {
|
||||
const segments: string[] = [];
|
||||
let current: SyntaxNode | null = callNode.parent;
|
||||
while (current !== null) {
|
||||
if (current.type === 'class' || current.type === 'module') {
|
||||
const nameNode = current.childForFieldName('name');
|
||||
if (nameNode !== null) segments.unshift(...splitQualifiedName(nameNode.text));
|
||||
}
|
||||
current = current.parent;
|
||||
}
|
||||
return segments.length > 0 ? segments.join('.') : undefined;
|
||||
}
|
||||
|
||||
export function emitRubyScopeCaptures(
|
||||
sourceText: string,
|
||||
_filePath: string,
|
||||
@@ -144,8 +169,7 @@ export function emitRubyScopeCaptures(
|
||||
if (HERITAGE_CALL_NAMES.has(callName)) {
|
||||
const callNode = nodeIfType(nodeMap['@reference.call.free'], 'call');
|
||||
if (callNode !== null) {
|
||||
const enclosing = findEnclosingClassOrModule(callNode);
|
||||
const ownerName = enclosing?.childForFieldName('name')?.text;
|
||||
const ownerName = buildEnclosingQualifiedName(callNode);
|
||||
if (ownerName) {
|
||||
const argList = callNode.childForFieldName('arguments');
|
||||
if (argList !== null) {
|
||||
@@ -178,8 +202,7 @@ export function emitRubyScopeCaptures(
|
||||
if (ATTR_CALL_NAMES.has(callName)) {
|
||||
const callNode = nodeIfType(nodeMap['@reference.call.free'], 'call');
|
||||
if (callNode !== null) {
|
||||
const enclosing = findEnclosingClassOrModule(callNode);
|
||||
const ownerName = enclosing?.childForFieldName('name')?.text;
|
||||
const ownerName = buildEnclosingQualifiedName(callNode);
|
||||
if (ownerName) {
|
||||
const argList = callNode.childForFieldName('arguments');
|
||||
if (argList !== null) {
|
||||
|
||||
@@ -23,8 +23,13 @@ function emitRubyMixinEdges(
|
||||
if (!isClassLike(def.type)) continue;
|
||||
const graphId = resolveDefGraphId(parsed.filePath, def, nodeLookup);
|
||||
if (graphId !== undefined) {
|
||||
const simpleName = def.qualifiedName?.split('.').pop() ?? def.qualifiedName ?? '';
|
||||
graphIdByName.set(simpleName, graphId);
|
||||
// Key by the FULL qualified name (`Outer.Inner`), NOT the simple tail.
|
||||
// Same-tail nested classes (`Outer::Inner` + `Other::Inner`) otherwise
|
||||
// collapse onto one `Inner` key (last-wins) and cross-wire their mixin /
|
||||
// attr_accessor owners (#1982). The `__heritage__`/`__property__` markers
|
||||
// carry the full qualified owner name in lockstep (see ruby/captures.ts).
|
||||
const fullName = def.qualifiedName ?? '';
|
||||
if (fullName.length > 0) graphIdByName.set(fullName, graphId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -991,6 +991,11 @@ function pass5CollectReferences(
|
||||
if (kind === undefined) continue;
|
||||
|
||||
const nameCap = match['@reference.name'] ?? anchor;
|
||||
// Optional qualified form of the reference (e.g. a C++ base `Other::Inner`),
|
||||
// threaded to resolution so a same-tail nested base resolves to the correct
|
||||
// sibling via the full-path QualifiedNameIndex before the simple-tail walk
|
||||
// (#1982). Absent for unqualified references — resolution stays unchanged.
|
||||
const qualifiedCap = match['@reference.qualified-name'];
|
||||
const inScopeId = positionIndex.atPosition(
|
||||
filePath,
|
||||
anchor.range.startLine,
|
||||
@@ -1014,6 +1019,9 @@ function pass5CollectReferences(
|
||||
atRange: anchor.range,
|
||||
inScope: inScopeId,
|
||||
kind,
|
||||
...(qualifiedCap?.text !== undefined && qualifiedCap.text.length > 0
|
||||
? { rawQualifiedName: qualifiedCap.text }
|
||||
: {}),
|
||||
...(callForm !== undefined ? { callForm } : {}),
|
||||
...(explicitReceiver !== undefined ? { explicitReceiver } : {}),
|
||||
...(arity !== undefined ? { arity } : {}),
|
||||
@@ -1133,6 +1141,7 @@ const KNOWN_SUB_TAGS: ReadonlySet<string> = new Set<string>([
|
||||
'@type-binding.name',
|
||||
'@type-binding.type',
|
||||
'@reference.name',
|
||||
'@reference.qualified-name',
|
||||
'@reference.receiver',
|
||||
'@reference.operator',
|
||||
'@reference.arity',
|
||||
|
||||
@@ -131,7 +131,12 @@ function preEmitInheritanceEdges(
|
||||
handledSites.add(siteKey);
|
||||
}
|
||||
|
||||
const targetDef = resolveInheritanceBaseInScope(site.inScope, site.name, scopes);
|
||||
const targetDef = resolveInheritanceBaseInScope(
|
||||
site.inScope,
|
||||
site.name,
|
||||
scopes,
|
||||
site.rawQualifiedName,
|
||||
);
|
||||
if (targetDef === undefined) continue;
|
||||
|
||||
const callerClass = findEnclosingClassDef(site.inScope, scopes);
|
||||
|
||||
@@ -24,6 +24,7 @@ import type { BindingRef, ParsedFile, ScopeId, SymbolDefinition, TypeRef } from
|
||||
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
|
||||
import type { SemanticModel } from '../../model/semantic-model.js';
|
||||
import type { WorkspaceResolutionIndex } from '../workspace-index.js';
|
||||
import { normalizeQualifiedName } from '../../utils/qualified-name.js';
|
||||
|
||||
const EMPTY_BINDINGS: readonly BindingRef[] = Object.freeze([]);
|
||||
|
||||
@@ -309,13 +310,85 @@ export function resolveInheritanceBaseInScope(
|
||||
startScope: ScopeId,
|
||||
baseName: string,
|
||||
scopes: ScopeResolutionIndexes,
|
||||
rawQualifiedName?: string,
|
||||
): SymbolDefinition | undefined {
|
||||
// #1982: when the source wrote a qualified base (`Other::Inner`), resolve it
|
||||
// against the full-path QualifiedNameIndex FIRST, so a same-tail nested base
|
||||
// binds to the matching sibling instead of the first-inserted one that the
|
||||
// simple-tail scope walk picks. Falls through to the existing walk when the
|
||||
// base is unqualified, unknown, or the qualified lookup can't pick a unique
|
||||
// winner — so unqualified bases and the cross-file single-candidate case are
|
||||
// unchanged.
|
||||
if (rawQualifiedName !== undefined) {
|
||||
const qualified = resolveQualifiedInheritanceBase(startScope, rawQualifiedName, scopes);
|
||||
if (qualified !== undefined) return qualified;
|
||||
}
|
||||
return (
|
||||
findClassBindingInScope(startScope, baseName, scopes) ??
|
||||
resolveAmbiguousInheritanceBaseViaImports(startScope, baseName, scopes)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a qualified inheritance base (`Other::Inner`, `ns::Base`) against the
|
||||
* full-path `QualifiedNameIndex` (keyed by `def.qualifiedName`, which carries
|
||||
* the promoted dotted path post-`populateOwners`). Tries the referencing site's
|
||||
* enclosing-scope segments as progressive prefixes (longest first) before the
|
||||
* root-anchored qualifier, so a *relative* base like `Outer::Inner` written
|
||||
* inside `namespace NS` resolves to the root-anchored key `NS.Outer.Inner`.
|
||||
* Returns a unique class-like def, or `undefined` when the base is unqualified,
|
||||
* unknown, or genuinely ambiguous at a key (refuse-on-tie — never guess; a
|
||||
* wrong EXTENDS edge silently corrupts impact analysis).
|
||||
*/
|
||||
function resolveQualifiedInheritanceBase(
|
||||
startScope: ScopeId,
|
||||
rawQualifiedName: string,
|
||||
scopes: ScopeResolutionIndexes,
|
||||
): SymbolDefinition | undefined {
|
||||
const normalized = normalizeQualifiedName(rawQualifiedName);
|
||||
// No qualifier after normalization → nothing the simple-tail walk doesn't do.
|
||||
if (normalized.length === 0 || !normalized.includes('.')) return undefined;
|
||||
|
||||
const enclosing = enclosingScopeSegments(startScope, scopes);
|
||||
// Candidate keys: longest enclosing prefix first, then the root-anchored form.
|
||||
const keys: string[] = [];
|
||||
for (let i = enclosing.length; i >= 1; i--) {
|
||||
keys.push([...enclosing.slice(0, i), normalized].join('.'));
|
||||
}
|
||||
keys.push(normalized);
|
||||
|
||||
for (const key of keys) {
|
||||
const ids = scopes.qualifiedNames.get(key);
|
||||
if (ids.length === 0) continue;
|
||||
let unique: SymbolDefinition | undefined;
|
||||
let count = 0;
|
||||
for (const id of ids) {
|
||||
const def = scopes.defs.get(id);
|
||||
if (def !== undefined && isClassLike(def.type)) {
|
||||
unique = def;
|
||||
count++;
|
||||
}
|
||||
}
|
||||
if (count === 1) return unique;
|
||||
if (count > 1) return undefined; // genuine tie at this key → refuse, don't guess
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enclosing scope segments of an inheritance site, derived from the deriving
|
||||
* (child) class def's `qualifiedName` minus its own tail. For child
|
||||
* `NS.Other.Derived` this is `['NS', 'Other']`; empty for a file-scope child.
|
||||
* Used to build progressive-prefix lookup keys for relative qualified bases.
|
||||
*/
|
||||
function enclosingScopeSegments(startScope: ScopeId, scopes: ScopeResolutionIndexes): string[] {
|
||||
const child = findEnclosingClassDef(startScope, scopes);
|
||||
const q = child?.qualifiedName;
|
||||
if (q === undefined || q.length === 0) return [];
|
||||
const segs = q.split('.').filter(Boolean);
|
||||
return segs.slice(0, -1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Import/include-aware disambiguation for an *ambiguous* class-like base
|
||||
* name. Engages ONLY as a fallback after `findClassBindingInScope` has
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Shared qualified-name normalization.
|
||||
*
|
||||
* One canonical transform from a raw, language-specific qualified name
|
||||
* (`Other::Inner`, `pkg\Sub\Type`, ` A . B `) to the `.`-joined form the
|
||||
* graph and the `QualifiedNameIndex` are keyed by (`Other.Inner`, `pkg.Sub.Type`,
|
||||
* `A.B`). Extracted from `class-extractors/generic.ts` so the structure-phase
|
||||
* `buildQualifiedName`, the scope-resolution inheritance resolver, and the
|
||||
* per-language capture emitters all key against ONE normalizer — a raw `::`
|
||||
* qualifier must normalize to the exact key the index already holds, or the
|
||||
* qualified lookup silently misses (issue #1982).
|
||||
*
|
||||
* Do NOT confuse with `heritage-extractors/supertype-alternation.ts`'s
|
||||
* `simplifyRawName`, which collapses a qualified name to its LAST segment
|
||||
* (`Other::Inner` → `Inner`) — that is a tail extractor, not a normalizer, and
|
||||
* using it as a lookup key guarantees a miss.
|
||||
*
|
||||
* Pure string functions; no AST or tree-sitter dependency.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Normalize a raw qualified name to the `.`-joined canonical form:
|
||||
* strips whitespace, converts `::` and `\` separators to `.`, collapses
|
||||
* repeated dots, and trims leading/trailing dots.
|
||||
*/
|
||||
export const normalizeQualifiedName = (value: string): string =>
|
||||
value
|
||||
.replace(/\s+/g, '')
|
||||
.replace(/^::/, '')
|
||||
.replace(/::/g, '.')
|
||||
.replace(/\\/g, '.')
|
||||
.replace(/\.+/g, '.')
|
||||
.replace(/^\.+|\.+$/g, '');
|
||||
|
||||
/**
|
||||
* Split a raw qualified name into its normalized, non-empty segments
|
||||
* (`Other::Inner` → `['Other', 'Inner']`). Returns `[]` for an empty or
|
||||
* separator-only input.
|
||||
*/
|
||||
export const splitQualifiedName = (value: string): string[] => {
|
||||
const normalized = normalizeQualifiedName(value);
|
||||
return normalized ? normalized.split('.').filter(Boolean) : [];
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
struct Outer {
|
||||
struct Inner {
|
||||
void from_outer() {}
|
||||
int outer_field;
|
||||
};
|
||||
};
|
||||
struct Other {
|
||||
struct Inner {
|
||||
void from_other() {}
|
||||
};
|
||||
};
|
||||
// #1982 same-tail heritage: each base is fully qualified, so the EXTENDS edge
|
||||
// must resolve to the matching nested node, not the first-inserted same-tail one.
|
||||
struct DerivedA : Outer::Inner {};
|
||||
struct DerivedB : Other::Inner {};
|
||||
@@ -0,0 +1,25 @@
|
||||
module OuterMix; end
|
||||
module OtherMix; end
|
||||
module Outer
|
||||
class Inner
|
||||
include OuterMix
|
||||
attr_accessor :outer_attr
|
||||
def from_outer; end
|
||||
end
|
||||
end
|
||||
module Other
|
||||
class Inner
|
||||
include OtherMix
|
||||
attr_accessor :other_attr
|
||||
def from_other; end
|
||||
end
|
||||
end
|
||||
# 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
|
||||
@@ -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) {}
|
||||
}
|
||||
}
|
||||
@@ -207,6 +207,10 @@
|
||||
"captureGroups": 16,
|
||||
"digest": "34e07387fece6c1d2deb49c39fc2bfe0badfe8015dd1f7ae956d57ac98322a1d"
|
||||
},
|
||||
"ruby-nested-tail-collision/nested.rb": {
|
||||
"captureGroups": 31,
|
||||
"digest": "c48ebe5516a0faf50effbad0a19fe29be70c371b50ba9d6fa6ae3f6f708b3a4e"
|
||||
},
|
||||
"ruby-overload-dispatch/lib/app.rb": {
|
||||
"captureGroups": 10,
|
||||
"digest": "288d5386cf37fb76b52a94bc7da6bf8e7843830ebbb01fcd8100d1590c0e3f72"
|
||||
|
||||
@@ -303,6 +303,10 @@
|
||||
"captureGroups": 18,
|
||||
"digest": "3326eb4f82b1559b6afec497dc52cab734e6f3209501a4bd982bf5eab9ec6dba"
|
||||
},
|
||||
"rust-nested-tail-collision/lib.rs": {
|
||||
"captureGroups": 17,
|
||||
"digest": "2fc1fe1eb4e8727a89ab283ae34a0ae8df0c421551a7bd5e6e7ffb9d4aa54189"
|
||||
},
|
||||
"rust-nullable-receiver/src/main.rs": {
|
||||
"captureGroups": 37,
|
||||
"digest": "283d8606eb837f2a9e5fdf95a30e3da5b73d4c14d74b94deb03e924dd2b2fde1"
|
||||
|
||||
@@ -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,149 @@ 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');
|
||||
});
|
||||
|
||||
it('resolves DerivedB : Other::Inner → EXTENDS Other.Inner on the worker path (#1982: rawQualifiedName survives worker serialization)', () => {
|
||||
const e = getRelationships(result, 'EXTENDS').find(
|
||||
(x) => result.graph.getNode(x.rel.sourceId)?.properties.qualifiedName === 'DerivedB',
|
||||
);
|
||||
expect(e, 'DerivedB EXTENDS edge (worker path)').toBeDefined();
|
||||
expect(e!.rel.targetId).toContain('Other.Inner');
|
||||
expect(e!.rel.targetId).not.toContain('Outer.Inner');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inline nested same-tail HERITAGE — qualified base resolution (issue #1982)
|
||||
//
|
||||
// `struct DerivedA : Outer::Inner` + `struct DerivedB : Other::Inner` must each
|
||||
// resolve EXTENDS to the MATCHING nested node. On the registry-primary base the
|
||||
// qualifier is discarded (cpp/captures.ts emits the bare tail `Inner`), so
|
||||
// resolveInheritanceBaseInScope sees an ambiguous same-tail base. Asserts the
|
||||
// resolved EXTENDS endpoint's id contains the right qn (KTD-4: assert on the
|
||||
// node id, not the property). Registry-primary only (legacy leg expected-fail).
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('C++ inline nested same-tail heritage — qualified base (issue #1982)', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(path.join(FIXTURES, 'cpp-nested-tail-collision'), () => {});
|
||||
}, 60000);
|
||||
|
||||
const extendsTargetIdOf = (childQn: string): string | undefined => {
|
||||
const ext = getRelationships(result, 'EXTENDS');
|
||||
const e = ext.find(
|
||||
(x) => result.graph.getNode(x.rel.sourceId)?.properties.qualifiedName === childQn,
|
||||
);
|
||||
return e?.rel.targetId;
|
||||
};
|
||||
|
||||
it('resolves DerivedA : Outer::Inner → EXTENDS the Outer.Inner node', () => {
|
||||
const tid = extendsTargetIdOf('DerivedA');
|
||||
expect(tid, 'DerivedA EXTENDS endpoint').toBeDefined();
|
||||
expect(tid).toContain('Outer.Inner');
|
||||
expect(tid).not.toContain('Other.Inner');
|
||||
});
|
||||
|
||||
it('resolves DerivedB : Other::Inner → EXTENDS the Other.Inner node (not Outer.Inner)', () => {
|
||||
const tid = extendsTargetIdOf('DerivedB');
|
||||
expect(tid, 'DerivedB EXTENDS endpoint').toBeDefined();
|
||||
expect(tid).toContain('Other.Inner');
|
||||
expect(tid).not.toContain('Outer.Inner');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -255,8 +255,23 @@ const LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES: Readonly<Record<string, Readonly
|
||||
ruby: new Set<string>([
|
||||
// Ruby scope-resolution currently achieves 89/127 parity.
|
||||
// Tests listed here are scope-resolver-only correctness wins
|
||||
// (pass under registry-primary, fail under legacy). Currently
|
||||
// empty — all 127 tests pass under legacy mode.
|
||||
// (pass under registry-primary, fail under legacy).
|
||||
//
|
||||
// #1978 qualified nested-type node identity. NOTE: these PASS under the
|
||||
// legacy leg too — the fix is in the SHARED structure phase, not the legacy
|
||||
// resolution path. They are excluded here by policy to keep the #1978
|
||||
// assertions registry-primary-only and avoid coupling the legacy parity leg
|
||||
// to the new node-identity behavior.
|
||||
'owns from_outer / from_other through distinct Outer.Inner / Other.Inner nodes (R7)',
|
||||
'owns radius (attr_accessor) under the qualified Shapes.Circle node, no dangling (R7)',
|
||||
// #1982 RESOLUTION-side same-tail owner identity. The registry-primary
|
||||
// emitRubyMixinEdges bridge keys its owner map by full qualifiedName and the
|
||||
// captures emit the full enclosing-scope owner; the legacy DAG does not use
|
||||
// that bridge, so these are registry-primary-only by design.
|
||||
'owns outer_attr / other_attr under their OWN qualified Inner node (same-tail attr_accessor, R7)',
|
||||
'routes include OuterMix / OtherMix to their OWN qualified Inner owner (same-tail mixin, R7)',
|
||||
'genuinely used the worker pool for the same-tail Ruby fixture',
|
||||
'owns outer_attr / other_attr under their OWN qualified Inner node on the worker path (no duplicate, R7)',
|
||||
]),
|
||||
swift: new Set<string>([
|
||||
// Swift scope-resolution achieves 77/77 baseline parity. The tests
|
||||
@@ -481,6 +496,25 @@ const LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES: Readonly<Record<string, Readonly
|
||||
// sidecars and scope-resolver overload narrowing. The legacy DAG does not
|
||||
// rank function-template shapes, so it leaves the call unresolved.
|
||||
'pick(T*) wins over pick(T) for pointer arguments',
|
||||
// #1978 qualified nested-type node identity. NOTE: unlike the entries above,
|
||||
// these PASS under the legacy leg too — the fix is in the SHARED structure
|
||||
// phase, not the legacy resolution path, so the legacy DAG is untouched and
|
||||
// still produces the qualified nodes. They are excluded here by policy to
|
||||
// keep the #1978 assertions registry-primary-only and avoid coupling the
|
||||
// legacy parity leg to the new node-identity behavior.
|
||||
'materializes Outer.Inner and Other.Inner as two distinct Struct nodes',
|
||||
'owns from_outer / from_other through their OWN distinct node (positive identity, R7)',
|
||||
'owns outer_field under Outer.Inner (struct field via the main HAS_PROPERTY path)',
|
||||
'genuinely used the worker pool (guards against silent sequential fallback)',
|
||||
'materializes two distinct Struct nodes and owns each method correctly (R7)',
|
||||
// #1982 RESOLUTION-side same-tail heritage. Unlike the structure-phase
|
||||
// entries above, these exercise the registry-primary inheritance resolver
|
||||
// (preEmitInheritanceEdges → resolveInheritanceBaseInScope qualified-first),
|
||||
// which the legacy DAG does not use — so they are registry-primary-only by
|
||||
// design and skipped on the legacy leg per the #1978/#1982 policy.
|
||||
'resolves DerivedA : Outer::Inner → EXTENDS the Outer.Inner node',
|
||||
'resolves DerivedB : Other::Inner → EXTENDS the Other.Inner node (not Outer.Inner)',
|
||||
'resolves DerivedB : Other::Inner → EXTENDS Other.Inner on the worker path (#1982: rawQualifiedName survives worker serialization)',
|
||||
]),
|
||||
};
|
||||
|
||||
|
||||
@@ -1508,3 +1508,135 @@ 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');
|
||||
},
|
||||
);
|
||||
|
||||
// #1982 resolution-side: SAME-TAIL routed-property owner identity. The
|
||||
// pre-fix emitRubyMixinEdges keys its owner map by simple tail (last-wins),
|
||||
// so outer_attr / other_attr both attach to whichever `Inner` was processed
|
||||
// last. Asserts each routes to its OWN qualified node by qualifiedName, with
|
||||
// exactly one (non-duplicated) edge. Registry-primary only.
|
||||
pit(
|
||||
'owns outer_attr / other_attr under their OWN qualified Inner node (same-tail attr_accessor, R7)',
|
||||
() => {
|
||||
const hp = getRelationships(result, 'HAS_PROPERTY');
|
||||
const ownerQnOf = (prop: string) => {
|
||||
const e = hp.find((x) => x.target === prop);
|
||||
expect(e, `HAS_PROPERTY -> ${prop}`).toBeDefined();
|
||||
return result.graph.getNode(e!.rel.sourceId)?.properties.qualifiedName;
|
||||
};
|
||||
expect(ownerQnOf('outer_attr')).toBe('Outer.Inner');
|
||||
expect(ownerQnOf('other_attr')).toBe('Other.Inner');
|
||||
expect(hp.filter((x) => x.target === 'outer_attr')).toHaveLength(1);
|
||||
expect(hp.filter((x) => x.target === 'other_attr')).toHaveLength(1);
|
||||
},
|
||||
);
|
||||
|
||||
// #1982 resolution-side: SAME-TAIL mixin owner identity (IMPLEMENTS).
|
||||
pit(
|
||||
'routes include OuterMix / OtherMix to their OWN qualified Inner owner (same-tail mixin, R7)',
|
||||
() => {
|
||||
const impl = getRelationships(result, 'IMPLEMENTS');
|
||||
const ownerQnOfMixin = (mixinName: string) => {
|
||||
const e = impl.find((x) => x.target === mixinName);
|
||||
expect(e, `IMPLEMENTS -> ${mixinName}`).toBeDefined();
|
||||
return result.graph.getNode(e!.rel.sourceId)?.properties.qualifiedName;
|
||||
};
|
||||
expect(ownerQnOfMixin('OuterMix')).toBe('Outer.Inner');
|
||||
expect(ownerQnOfMixin('OtherMix')).toBe('Other.Inner');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// Same fixture through the WORKER pool. The deferred note flagged that the worker
|
||||
// path could emit a DUPLICATE cross-wired same-tail owner edge (the worker emits
|
||||
// the __property__/__heritage__ markers, which must now carry the full qualified
|
||||
// owner). Asserts worker == sequential: each attr owns its OWN qualified node with
|
||||
// exactly one edge (#1982 R7). Registry-primary only.
|
||||
describe('Ruby inline module-nested same-tail collision — worker path parity (issue #1982)', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(
|
||||
path.join(FIXTURES, 'ruby-nested-tail-collision'),
|
||||
() => {},
|
||||
{
|
||||
workerThresholdsForTest: { minFiles: 1, minBytes: 1 },
|
||||
workerPoolSize: 2,
|
||||
},
|
||||
);
|
||||
}, 120000);
|
||||
|
||||
pit('genuinely used the worker pool for the same-tail Ruby fixture', () => {
|
||||
expect(result.usedWorkerPool).toBe(true);
|
||||
});
|
||||
|
||||
pit(
|
||||
'owns outer_attr / other_attr under their OWN qualified Inner node on the worker path (no duplicate, R7)',
|
||||
() => {
|
||||
const hp = getRelationships(result, 'HAS_PROPERTY');
|
||||
const ownerQnOf = (prop: string) => {
|
||||
const e = hp.find((x) => x.target === prop);
|
||||
expect(e, `HAS_PROPERTY -> ${prop}`).toBeDefined();
|
||||
return result.graph.getNode(e!.rel.sourceId)?.properties.qualifiedName;
|
||||
};
|
||||
expect(ownerQnOf('outer_attr')).toBe('Outer.Inner');
|
||||
expect(ownerQnOf('other_attr')).toBe('Other.Inner');
|
||||
expect(hp.filter((x) => x.target === 'outer_attr')).toHaveLength(1);
|
||||
expect(hp.filter((x) => x.target === 'other_attr')).toHaveLength(1);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -2047,3 +2047,43 @@ 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).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Skipped: Rust inherent-impl ownership is deferred to the resolution-side
|
||||
// follow-up (see the comment block above). Tracked in the #1978 follow-up issue.
|
||||
describe.skip('Rust inline mod-nested same-tail collision — distinct nodes (issue #1978)', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user