diff --git a/gitnexus-shared/src/scope-resolution/reference-site.ts b/gitnexus-shared/src/scope-resolution/reference-site.ts index 7b7b8be8e5..b80ed83948 100644 --- a/gitnexus-shared/src/scope-resolution/reference-site.ts +++ b/gitnexus-shared/src/scope-resolution/reference-site.ts @@ -59,6 +59,18 @@ export type CallForm = 'free' | 'member' | 'constructor' | 'index'; export interface ReferenceSite { /** The name being referenced (e.g., `'save'`, `'User'`, `'count'`). */ readonly name: string; + /** + * Optional raw, qualified form of the referenced name when the source wrote + * a qualified path (e.g. a C++ base `struct D : Other::Inner` yields + * `'Other::Inner'`). `name` keeps the simple tail (`'Inner'`) for the existing + * scope-chain contract; resolution normalizes this via `normalizeQualifiedName` + * and resolves it against the full-path `QualifiedNameIndex` BEFORE the + * simple-tail walk, so a same-tail nested base resolves to the correct + * sibling instead of the first-inserted one (issue #1982). Populated only by + * per-language captures that emit `@reference.qualified-name`; absent + * otherwise, in which case resolution is unchanged. + */ + readonly rawQualifiedName?: string; /** Source-text range of this reference. */ readonly atRange: Range; /** diff --git a/gitnexus/bench/scope-capture/baselines.json b/gitnexus/bench/scope-capture/baselines.json index e65799f803..555b8e1f7c 100644 --- a/gitnexus/bench/scope-capture/baselines.json +++ b/gitnexus/bench/scope-capture/baselines.json @@ -16,7 +16,7 @@ "_added": "#1956: c added to the scope-capture bench (was UNBENCHED). C has no inheritance \u2014 flat scale source. Adding it exposed + fixed a pre-existing O(n^2) findNodeAtRange root-walk in c/captures.ts (threaded c.node, byte-identical over c-* fixtures); scaling 3.475 -> 0.96." }, "cpp": { - "fingerprint": "e21e05c92870b82468b5d73f04d205b6aafad4143331cf718131f0517ba34e0a", + "fingerprint": "538e8beebf0a69f6170dff452da3f98046a08cbe8b098b3c9943c4a8a79d2e22", "scaling_budget": 1.5, "_added": "#1956: cpp added to the scope-capture bench (was UNBENCHED). Heritage-bearing scale source (: public Base, public Mixin) drives emitCppInheritanceCaptures at scale. Adding it exposed + fixed a pre-existing O(n^2) findNodeAtRange root-walk in cpp/captures.ts (~12 sites, threaded c.node, byte-identical over 263 cpp-* fixtures); scaling 2.30 -> 1.12.", "_rebaselined": "#1965 / #1923 F4: uninitialized non-leading multi-declarators now emit @declaration.variable captures; cpp-adl-inner-callable-outer-noncallable data::Pair a, b adds the legitimate fixture drift. Linear (~1.06).", @@ -28,7 +28,7 @@ "scaling_budget": 1.5 }, "rust": { - "fingerprint": "a5fdff2cf427504e33e66d0221b3ad62739c64bd0898e1dafedc15dbbe347b4d", + "fingerprint": "56ffc1c069af10cac3c82a32f3d148322ea570e116ebaae67315445f05407fef", "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). #1975: + rust-scoped-impl fixture (impl a::Inner / b::Inner inherent scoped impls) — legacy @definition.impl scoped arm + findEnclosingClassInfo inherent-impl scoped target; rust scope-extractor captures byte-identical.", "_note": "PR #1934: F66/F68 let-binding pattern narrowing; F71 union (Struct-labeled, now materialized via legacy @definition.struct + resolvable); F72 macro FULLY WIRED — @declaration.macro/@reference.macro + MacroRegistry → USES edges to Macro nodes (never a same-named fn). + rust-macro / rust-union fixtures and merged with origin/main #1975 rust-scoped-impl; fingerprint re-baselined (scaling ~0.99, fixture_count 126)." @@ -39,7 +39,7 @@ "_rebaselined": "#1956: heritage-bearing scale source (class extends Base + use trait); both forms gated at scale; linear (~1.04)." }, "ruby": { - "fingerprint": "ee81145cf0af796878e8e048192b87c8c8dc445a3e3fcdff6c6e26c179e97232", + "fingerprint": "bf6b13a366e4116da3772f9a9fdd50517eb11da73918451392e014a2c905b2dd", "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." diff --git a/gitnexus/src/core/ingestion/call-processor.ts b/gitnexus/src/core/ingestion/call-processor.ts index c8c62921aa..a3ca9c8986 100644 --- a/gitnexus/src/core/ingestion/call-processor.ts +++ b/gitnexus/src/core/ingestion/call-processor.ts @@ -966,12 +966,23 @@ export const processCalls = async ( const routed = callRouter(callNameNode.text, captureMap['call']); if (!routed || routed.kind !== 'properties') return; + // #1978: thread the qualifier so a routed property's owner edge points at + // the *qualified* nested-class node (Shapes.Circle) instead of a now-nonexistent + // simple `Class:file:Circle` id. Gated on the flag → byte-identical when off. + // MUST stay in lockstep with the worker `kind === 'properties'` block. + const propGetQualifiedOwnerName = + provider.classExtractor?.qualifiedNodeId === true + ? (node: SyntaxNode, simpleName: string): string | null => + provider.classExtractor!.extractQualifiedName(node, simpleName) + : undefined; const propEnclosingInfo = findEnclosingClassInfo( captureMap['call'], file.path, provider.resolveEnclosingOwner, + propGetQualifiedOwnerName, ); - const propEnclosingClassId = propEnclosingInfo?.classId ?? null; + const propEnclosingClassId = + propEnclosingInfo?.qualifiedClassId ?? propEnclosingInfo?.classId ?? null; // Enrich routed properties with FieldExtractor metadata so types // discovered from constructor assignments (e.g. `@address = Address.new`) diff --git a/gitnexus/src/core/ingestion/class-extractors/configs/c-cpp.ts b/gitnexus/src/core/ingestion/class-extractors/configs/c-cpp.ts index fb5df99c35..d39888aa14 100644 --- a/gitnexus/src/core/ingestion/class-extractors/configs/c-cpp.ts +++ b/gitnexus/src/core/ingestion/class-extractors/configs/c-cpp.ts @@ -46,6 +46,9 @@ export const cppClassConfig: ClassExtractionConfig = { language: SupportedLanguages.CPlusPlus, typeDeclarationNodes: ['class_specifier', 'struct_specifier', 'enum_specifier'], ancestorScopeNodeTypes: ['namespace_definition', 'class_specifier', 'struct_specifier'], + // #1978: key nested-type nodes by their fully-qualified path (Outer.Inner) so + // same-tail nested types in one TU stay distinct instead of silently merging. + qualifiedNodeId: true, extractName: (node) => { const nameNode = node.childForFieldName?.('name'); if (!nameNode) return undefined; diff --git a/gitnexus/src/core/ingestion/class-extractors/configs/ruby.ts b/gitnexus/src/core/ingestion/class-extractors/configs/ruby.ts index 2c4c711bdc..13f1fdd434 100644 --- a/gitnexus/src/core/ingestion/class-extractors/configs/ruby.ts +++ b/gitnexus/src/core/ingestion/class-extractors/configs/ruby.ts @@ -7,4 +7,7 @@ export const rubyClassConfig: ClassExtractionConfig = { language: SupportedLanguages.Ruby, typeDeclarationNodes: ['class'], ancestorScopeNodeTypes: ['module', 'class'], + // #1978: key nested-type nodes by their fully-qualified path (Outer.Inner) so + // same-tail classes nested under different modules stay distinct. + qualifiedNodeId: true, }; diff --git a/gitnexus/src/core/ingestion/class-extractors/generic.ts b/gitnexus/src/core/ingestion/class-extractors/generic.ts index 5f20d1dc2c..6cfc7a3c08 100644 --- a/gitnexus/src/core/ingestion/class-extractors/generic.ts +++ b/gitnexus/src/core/ingestion/class-extractors/generic.ts @@ -6,6 +6,7 @@ import type { ClassLikeNodeLabel, ExtractedClassSymbol, } from '../class-types.js'; +import { splitQualifiedName } from '../utils/qualified-name.js'; const DEFAULT_SCOPE_NAME_NODE_TYPES = new Set([ 'nested_namespace_specifier', @@ -58,20 +59,6 @@ const CLASS_LIKE_LABELS = new Set([ 'Record', ]); -const normalizeQualifiedName = (value: string): string => - value - .replace(/\s+/g, '') - .replace(/^::/, '') - .replace(/::/g, '.') - .replace(/\\/g, '.') - .replace(/\.+/g, '.') - .replace(/^\.+|\.+$/g, ''); - -const splitQualifiedName = (value: string): string[] => { - const normalized = normalizeQualifiedName(value); - return normalized ? normalized.split('.').filter(Boolean) : []; -}; - const extractScopeSegmentsFromNode = ( scopeNode: SyntaxNode, scopeNameNodeTypes: ReadonlySet, @@ -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); diff --git a/gitnexus/src/core/ingestion/class-types.ts b/gitnexus/src/core/ingestion/class-types.ts index 9407d41fae..2e3d1f6886 100644 --- a/gitnexus/src/core/ingestion/class-types.ts +++ b/gitnexus/src/core/ingestion/class-types.ts @@ -28,6 +28,13 @@ export interface ClassCaptureContext { */ export interface ClassExtractor { language: SupportedLanguages; + /** + * When true, this language's nested-type graph nodes are keyed by their + * fully-qualified path (e.g. `Class:file:Outer.Inner`) instead of the simple + * tail name, so same-tail nested types in one file stay distinct (#1978). + * Surfaced from `ClassExtractionConfig.qualifiedNodeId`. + */ + readonly qualifiedNodeId: boolean; isTypeDeclaration(node: SyntaxNode): boolean; extract( node: SyntaxNode, @@ -48,6 +55,14 @@ export interface ClassExtractionConfig { typeDeclarationNodes: string[]; fileScopeNodeTypes?: string[]; ancestorScopeNodeTypes?: string[]; + /** + * Opt-in (#1978): key this language's nested-type graph nodes (and their + * member-owner edges) by the fully-qualified path instead of the simple tail + * name, so same-tail nested types in one file stop colliding. Default false. + * Requires `ancestorScopeNodeTypes` to be set so `buildQualifiedName` can walk + * the scope chain. + */ + qualifiedNodeId?: boolean; scopeNameNodeTypes?: string[]; extractName?: (node: SyntaxNode) => string | undefined; extractType?: (node: SyntaxNode) => ClassLikeNodeLabel | undefined; diff --git a/gitnexus/src/core/ingestion/languages/cpp/captures.ts b/gitnexus/src/core/ingestion/languages/cpp/captures.ts index de52fee8ae..265db8d5d1 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/captures.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/captures.ts @@ -8,6 +8,7 @@ import { import { getCppParser, getCppScopeQuery } from './query.js'; import { getTreeSitterBufferSize } from '../../constants.js'; import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js'; +import { normalizeQualifiedName } from '../../utils/qualified-name.js'; import { splitCppInclude, splitCppUsingDecl } from './import-decomposer.js'; import { classifyCppParameterType, @@ -515,9 +516,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 +754,41 @@ function extractBaseLookupName(baseNode: SyntaxNode): string { return ''; } +/** + * Like `extractBaseLookupName` but PRESERVES the namespace/class qualifier + * (`Other::Inner`, `ns::v1::Base`) while stripping template arguments + * (`ns::Base` → `ns::Base`). Returns `''` for shapes it can't qualify, and + * returns the bare name unchanged for an unqualified base (the emit site then + * skips the sidecar capture). Powers `@reference.qualified-name` so #1982 + * resolution can pick the matching same-tail nested base via the full-path + * QualifiedNameIndex instead of the first-inserted same-tail sibling. + */ +function extractQualifiedBaseName(baseNode: SyntaxNode): string { + if (baseNode.type === 'template_type') { + const nameNode = baseNode.childForFieldName('name'); + return nameNode !== null ? extractQualifiedBaseName(nameNode) : ''; + } + if (baseNode.type === 'qualified_identifier') { + // No template args anywhere → the raw text already IS the qualified name. + if (!baseNode.text.includes('<')) return baseNode.text; + // Template args present: reconstruct scope::name, recursing to strip them. + const scopeNode = baseNode.childForFieldName('scope'); + const nameNode = baseNode.childForFieldName('name'); + const left = scopeNode !== null ? extractQualifiedBaseName(scopeNode) : ''; + const right = nameNode !== null ? extractQualifiedBaseName(nameNode) : ''; + if (left.length > 0 && right.length > 0) return `${left}::${right}`; + return right.length > 0 ? right : left; + } + if ( + baseNode.type === 'namespace_identifier' || + baseNode.type === 'type_identifier' || + baseNode.type === 'identifier' + ) { + return baseNode.text; + } + return ''; +} + /** Extract the syntactic namespace qualifier from a base class node. * For `detail::Inner`, returns `'detail'`. * For unqualified bases (`Inner`, `Base`), returns `''`. @@ -1537,7 +1588,7 @@ function extractAdlTypeNamespace(typeNode: SyntaxNode): string { } if (typeNode.type === 'qualified_identifier') { const scope = typeNode.childForFieldName('scope'); - if (scope !== null) return normalizeCppNamespaceQName(scope.text); + if (scope !== null) return normalizeQualifiedName(scope.text); return extractNamespaceFromQualifiedText(typeNode.text); } return ''; @@ -1614,16 +1665,11 @@ function findTemplateTypeNode(typeNode: SyntaxNode): SyntaxNode | null { return null; } -function normalizeCppNamespaceQName(text: string): string { - const normalized = text.replace(/^::/, '').replace(/::$/, '').replace(/::/g, '.'); - return normalized; -} - function extractNamespaceFromQualifiedText(text: string): string { const cleaned = text.replace(/\s+/g, ''); const idx = cleaned.lastIndexOf('::'); if (idx <= 0) return ''; - return normalizeCppNamespaceQName(cleaned.slice(0, idx)); + return normalizeQualifiedName(cleaned.slice(0, idx)); } /** diff --git a/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts index 40ff978d6d..459b313c6f 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts @@ -5,7 +5,10 @@ import { } from '../../scope-resolution/scope/walkers.js'; import { SupportedLanguages } from 'gitnexus-shared'; import { buildMro, defaultLinearize } from '../../scope-resolution/passes/mro.js'; -import { populateClassOwnedMembers } from '../../scope-resolution/scope/walkers.js'; +import { + populateClassOwnedMembers, + tagNamespacePrefixes, +} from '../../scope-resolution/scope/walkers.js'; import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js'; import { cppProvider } from '../c-cpp.js'; import { cppArityCompatibility } from './arity.js'; @@ -102,6 +105,11 @@ export const cppScopeResolver: ScopeResolver = { populateOwners: (parsed: ParsedFile) => { populateClassOwnedMembers(parsed); + // #1982: tag namespace-nested defs with their enclosing-namespace prefix so + // resolveDefGraphId can map them to the namespace-qualified structure-phase + // node (`NS.A.Inner`) instead of collapsing same-tail nested bases via the + // simpleKey fallback. Does NOT change qualifiedName (resolution unaffected). + tagNamespacePrefixes(parsed); // Resolve inline- and anonymous-namespace ranges (recorded at capture // time) to ScopeIds BEFORE `populateCppNonGloballyVisible` runs, so // both exemptions see the populated Sets. diff --git a/gitnexus/src/core/ingestion/languages/ruby/captures.ts b/gitnexus/src/core/ingestion/languages/ruby/captures.ts index 376ebc546c..7f5d98f91e 100644 --- a/gitnexus/src/core/ingestion/languages/ruby/captures.ts +++ b/gitnexus/src/core/ingestion/languages/ruby/captures.ts @@ -12,6 +12,7 @@ import { recordRubyCacheHit, recordRubyCacheMiss } from './cache-stats.js'; import { synthesizeRubyReceiverBinding, findEnclosingClassOrModule } from './receiver-binding.js'; import { getTreeSitterBufferSize } from '../../constants.js'; import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js'; +import { splitQualifiedName } from '../../utils/qualified-name.js'; const FUNCTION_NODE_TYPES = ['method', 'singleton_method'] as const; const HERITAGE_CALL_NAMES: ReadonlySet = new Set(['include', 'extend', 'prepend']); @@ -21,6 +22,34 @@ const ATTR_CALL_NAMES: ReadonlySet = new Set([ 'attr_writer', ]); +/** + * Build the full `.`-joined qualified owner name for a heritage/attr call by + * walking ALL enclosing class/module ancestors (not just the immediate one), + * so a same-tail nested owner (`module Outer; class Inner`) is keyed by its + * full path `Outer.Inner` instead of the bare tail `Inner` — which otherwise + * collapses both same-tail owners onto one `__heritage__`/`__property__` marker + * key (last-wins) and cross-wires their mixin / attr_accessor edges (#1982). + * Handles the compact `class Outer::Inner` form (name is a `scope_resolution`) + * via the shared normalizer, so the marker owner byte-matches the resolution + * def's `qualifiedName`. Returns undefined when there is no enclosing class/module. + */ +function buildEnclosingQualifiedName(callNode: SyntaxNode): string | undefined { + const segments: string[] = []; + let current: SyntaxNode | null = callNode.parent; + while (current !== null) { + if (current.type === 'class' || current.type === 'module') { + const nameNode = current.childForFieldName('name'); + if (nameNode !== null) segments.unshift(...splitQualifiedName(nameNode.text)); + } + // Stop at the file root — nothing above `program` contributes a Ruby + // class/module scope segment (#1982 perf; avoids walking to the very top + // for every heritage/attr call). + if (current.type === 'program') break; + current = current.parent; + } + return segments.length > 0 ? segments.join('.') : undefined; +} + export function emitRubyScopeCaptures( sourceText: string, _filePath: string, @@ -144,23 +173,29 @@ 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) { for (let ai = 0; ai < argList.namedChildCount; ai++) { const arg = argList.namedChild(ai); if (arg !== null && (arg.type === 'constant' || arg.type === 'scope_resolution')) { + // Normalize a qualified mixin arg (`Outer::Mixin`) to its dotted + // form (`Outer.Mixin`) BEFORE embedding it in the ':'-delimited + // __heritage__ marker: the raw `::` collides with the marker's `:` + // field separator and emitRubyMixinEdges mis-splits it, dropping + // the edge (#1982). The dotted form also matches the mixin def's + // qualifiedName key for resolution. Simple names are unchanged. + const mixinName = splitQualifiedName(arg.text).join('.'); out.push({ '@import.statement': grouped['@reference.call.free']!, '@import.kind': syntheticCapture('@import.kind', callNode, 'namespace'), '@import.source': syntheticCapture( '@import.source', callNode, - `__heritage__:${callName}:${arg.text}:${ownerName}`, + `__heritage__:${callName}:${mixinName}:${ownerName}`, ), - '@import.name': syntheticCapture('@import.name', callNode, arg.text), + '@import.name': syntheticCapture('@import.name', callNode, mixinName), }); } } @@ -178,8 +213,7 @@ export function emitRubyScopeCaptures( if (ATTR_CALL_NAMES.has(callName)) { const callNode = nodeIfType(nodeMap['@reference.call.free'], 'call'); if (callNode !== null) { - const enclosing = findEnclosingClassOrModule(callNode); - const ownerName = enclosing?.childForFieldName('name')?.text; + const ownerName = buildEnclosingQualifiedName(callNode); if (ownerName) { const argList = callNode.childForFieldName('arguments'); if (argList !== null) { diff --git a/gitnexus/src/core/ingestion/languages/ruby/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/ruby/scope-resolver.ts index b10c78b743..afefb0c9d3 100644 --- a/gitnexus/src/core/ingestion/languages/ruby/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/ruby/scope-resolver.ts @@ -11,6 +11,7 @@ import type { KnowledgeGraph } from '../../../graph/types.js'; import { generateId } from '../../../../lib/utils.js'; const HERITAGE_PREFIX = '__heritage__:'; +const PROPERTY_PREFIX = '__property__:'; function emitRubyMixinEdges( graph: KnowledgeGraph, @@ -18,13 +19,32 @@ function emitRubyMixinEdges( nodeLookup: GraphNodeLookup, ): void { const graphIdByName = new Map(); + // Secondary tail -> graphId map (first-wins). The `__heritage__` marker carries + // the mixin TARGET as the bare written name (`arg.text`, e.g. `Loggable`), not + // its full qualifiedName, so a nested mixin module included by its short name + // (`include Loggable` where it is `App::Loggable`) misses the full-qn map and + // its IMPLEMENTS edge is silently dropped (#1982 follow-up). The tail fallback + // recovers it. OWNER (`className`) lookups stay full-qn only, preserving + // same-tail owner disambiguation; only the under-qualified mixin reference + // falls back, and a genuine same-tail mixin tie there resolves first-wins. + const graphIdByTail = new Map(); for (const parsed of parsedFiles) { for (const def of parsed.localDefs) { if (!isClassLike(def.type)) continue; 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); + const dot = fullName.lastIndexOf('.'); + const tail = dot === -1 ? fullName : fullName.slice(dot + 1); + if (tail.length > 0 && !graphIdByTail.has(tail)) graphIdByTail.set(tail, graphId); + } } } } @@ -44,7 +64,9 @@ function emitRubyMixinEdges( if (parts.length < 3) continue; const [kind, mixinName, className] = parts; const classGraphId = graphIdByName.get(className!); - const mixinGraphId = graphIdByName.get(mixinName!); + // Owner stays full-qn; the mixin target may be written by short name and + // miss the full-qn map, so fall back to the simple-tail map (#1982). + const mixinGraphId = graphIdByName.get(mixinName!) ?? graphIdByTail.get(mixinName!); if (classGraphId === undefined || mixinGraphId === undefined) continue; const edgeKey = `${classGraphId}->${mixinGraphId}:${kind}`; if (emitted.has(edgeKey)) continue; @@ -71,7 +93,6 @@ function emitRubyMixinEdges( } } - const PROPERTY_PREFIX = '__property__:'; for (const parsed of parsedFiles) { for (const imp of parsed.parsedImports) { if (!imp.targetRaw.startsWith(PROPERTY_PREFIX)) continue; diff --git a/gitnexus/src/core/ingestion/parsing-processor.ts b/gitnexus/src/core/ingestion/parsing-processor.ts index 6c77e79585..b2b7f10184 100644 --- a/gitnexus/src/core/ingestion/parsing-processor.ts +++ b/gitnexus/src/core/ingestion/parsing-processor.ts @@ -18,6 +18,7 @@ import { findObjectLiteralBindingInfo, getLabelFromCaptures, isSuppressedConcreteTypedefDuplicate, + qualifyRustImplTargetByModScope, CLASS_CONTAINER_TYPES, type SyntaxNode, type EnclosingClassInfo, @@ -297,10 +298,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 +609,71 @@ 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. + // #1982: a Rust inherent-impl node is keyed by its target's RAW tail by + // default, so two bare same-tail impls under different mods collapse onto + // one Impl node. For an UNSCOPED bare target (type_identifier), qualify the + // Impl node id by the enclosing `mod_item` scope — byte-identical to the + // owner-walk id (ast-helpers `findEnclosingClassInfo`), so HAS_METHOD stays + // anchored. SCOPED targets (`impl a::Inner`) keep their full raw text and + // are NOT routed here (#1975). + const rustImplQualifiedName = + nodeLabel === 'Impl' && + definitionNode?.type === 'impl_item' && + nameNode?.type === 'type_identifier' + ? qualifyRustImplTargetByModScope(definitionNode, nodeName) + : undefined; + + const qualifiedName = + rustImplQualifiedName !== undefined + ? rustImplQualifiedName + : isClassLikeLabel && + provider.classExtractor?.qualifiedNodeId === true && + qualifiedTypeName !== undefined + ? qualifiedTypeName + : enclosingClassInfo + ? `${enclosingClassInfo.className}.${nodeName}` + : nodeName; // Extract method metadata for Function/Method/Constructor nodes BEFORE generating // the node ID — parameterCount is needed to disambiguate overloaded methods. @@ -778,12 +832,6 @@ const processParsingSequential = async ( nodeLabel, `${file.path}:${qualifiedName}${classTemplateTag}${arityTag}${constraintsTag}${parameterShapeTag}`, ); - const classNodeForSymbol = definitionNodeForRange || definitionNode || nameNode; - const qualifiedTypeName = - extractedClassSymbol?.qualifiedName ?? - (classNodeForSymbol && provider.classExtractor?.isTypeDeclaration(classNodeForSymbol) - ? (provider.classExtractor.extractQualifiedName(classNodeForSymbol, nodeName) ?? nodeName) - : undefined); const frameworkHint = definitionNode ? detectFrameworkFromAST(language, (definitionNode.text || '').slice(0, 300)) : null; diff --git a/gitnexus/src/core/ingestion/scope-extractor.ts b/gitnexus/src/core/ingestion/scope-extractor.ts index 446ab75fec..e5b9a71ace 100644 --- a/gitnexus/src/core/ingestion/scope-extractor.ts +++ b/gitnexus/src/core/ingestion/scope-extractor.ts @@ -993,6 +993,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, @@ -1016,6 +1021,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 } : {}), @@ -1137,6 +1145,7 @@ const KNOWN_SUB_TAGS: ReadonlySet = new Set([ '@type-binding.name', '@type-binding.type', '@reference.name', + '@reference.qualified-name', '@reference.receiver', '@reference.operator', '@reference.arity', diff --git a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ids.ts b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ids.ts index 94eec67c03..25927a579b 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ids.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ids.ts @@ -143,6 +143,17 @@ export function resolveDefGraphId( } const qualifiedHit = nodeLookup.get(qualifiedKey(filePath, def.type, qn)); if (qualifiedHit !== undefined) return qualifiedHit; + // #1982: some scope-extractors qualify a type by its enclosing CLASS chain + // (`A.Inner`) but drop the enclosing NAMESPACE, while the structure-phase + // node is keyed by the full path (`NS.A.Inner`). Retry with the + // namespace-prefixed key (tagged by `tagNamespacePrefixes`) BEFORE the + // simple-name fallback, so same-tail nested bases don't collapse across + // sibling namespace members via `simpleKey`. + const nsPrefix = (def as { namespacePrefix?: string }).namespacePrefix; + if (nsPrefix !== undefined && nsPrefix.length > 0) { + const nsHit = nodeLookup.get(qualifiedKey(filePath, def.type, `${nsPrefix}.${qn}`)); + if (nsHit !== undefined) return nsHit; + } } const simpleName = qn.lastIndexOf('.') === -1 ? qn : qn.slice(qn.lastIndexOf('.') + 1); return nodeLookup.get(simpleKey(filePath, simpleName)); diff --git a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts index 7d45f60cd2..8882885c45 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts @@ -131,11 +131,20 @@ function preEmitInheritanceEdges( handledSites.add(siteKey); } - const targetDef = resolveInheritanceBaseInScope(site.inScope, site.name, scopes); - if (targetDef === undefined) continue; - + // Resolve the deriving (caller) class first and reuse it as the enclosing + // context for qualified-base resolution — avoids a second findEnclosingClassDef + // walk per qualified site (#1982 perf). Both need the same enclosing class. const callerClass = findEnclosingClassDef(site.inScope, scopes); if (callerClass === undefined) continue; + + const targetDef = resolveInheritanceBaseInScope( + site.inScope, + site.name, + scopes, + site.rawQualifiedName, + callerClass, + ); + if (targetDef === undefined) continue; const callerGraphId = resolveDefGraphId(callerClass.filePath, callerClass, nodeLookup); const targetGraphId = resolveDefGraphId(targetDef.filePath, targetDef, nodeLookup); if (callerGraphId === undefined || targetGraphId === undefined) continue; diff --git a/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts b/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts index f36de06b20..b594d4acfb 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/scope/walkers.ts @@ -24,6 +24,7 @@ import type { BindingRef, ParsedFile, ScopeId, SymbolDefinition, TypeRef } from import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; import type { SemanticModel } from '../../model/semantic-model.js'; import type { WorkspaceResolutionIndex } from '../workspace-index.js'; +import { normalizeQualifiedName } from '../../utils/qualified-name.js'; const EMPTY_BINDINGS: readonly BindingRef[] = Object.freeze([]); @@ -309,13 +310,106 @@ export function resolveInheritanceBaseInScope( startScope: ScopeId, baseName: string, scopes: ScopeResolutionIndexes, + rawQualifiedName?: string, + enclosingClassDef?: SymbolDefinition, ): SymbolDefinition | undefined { + // #1982: when the source wrote a qualified base (`Other::Inner`), resolve it + // against the full-path QualifiedNameIndex FIRST, so a same-tail nested base + // 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. `enclosingClassDef` (the deriving class) is threaded from the + // caller to skip a redundant enclosing-class walk (#1982 perf). + if (rawQualifiedName !== undefined) { + const qualified = resolveQualifiedInheritanceBase( + startScope, + rawQualifiedName, + scopes, + enclosingClassDef, + ); + 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, + enclosingClassDef?: SymbolDefinition, +): SymbolDefinition | undefined { + const normalized = normalizeQualifiedName(rawQualifiedName); + // No qualifier after normalization → nothing the simple-tail walk doesn't do. + if (normalized.length === 0 || !normalized.includes('.')) return undefined; + + // #1982: a root-anchored base (`::Net::X`) names the GLOBAL scope, so it must + // NOT be prefixed with the referencing site's enclosing segments — try only + // the root-anchored key. normalizeQualifiedName strips the leading `::`, so + // detect the anchor on the raw text (after leading whitespace). + const isRootAnchored = /^\s*::/.test(rawQualifiedName); + const enclosing = isRootAnchored + ? [] + : enclosingScopeSegments(startScope, scopes, enclosingClassDef); + // Candidate keys: longest enclosing prefix first, then the root-anchored form. + const keys: string[] = []; + for (let i = enclosing.length; i >= 1; i--) { + 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, + enclosingClassDef?: SymbolDefinition, +): string[] { + // Reuse the caller-provided deriving class when available (#1982 perf); only + // walk the scope chain when it wasn't threaded in. + const child = enclosingClassDef ?? findEnclosingClassDef(startScope, scopes); + const q = child?.qualifiedName; + if (q === undefined || q.length === 0) return []; + const segs = q.split('.').filter(Boolean); + return segs.slice(0, -1); +} + /** * Import/include-aware disambiguation for an *ambiguous* class-like base * name. Engages ONLY as a fallback after `findClassBindingInScope` has @@ -706,6 +800,64 @@ export function populateClassOwnedMembers(parsed: ParsedFile): void { } } +/** + * Tag every def declared inside one or more `Namespace` scopes with its + * enclosing-namespace path (`NS`, `Outer.Inner`) on a sidecar `namespacePrefix` + * field — WITHOUT touching `qualifiedName`. + * + * Some scope-extractors qualify a nested type by its enclosing CLASS chain + * (`A.Inner`) but drop the enclosing NAMESPACE, while the structure phase keys + * the graph node by the full path (`NS.A.Inner`). `resolveDefGraphId` reads this + * tag to retry the node lookup with the namespace-prefixed key before the + * simple-name fallback, so same-tail nested bases don't collapse across sibling + * namespace members (#1982). `qualifiedName` is deliberately left unchanged, so + * the `qualifiedName`-keyed resolution index and existing namespace resolution + * (brace-init, UDC ranking, two-phase lookup) are untouched. + * + * Language-agnostic: it acts only on `Namespace`-kind scopes (a namespace-free + * language is a no-op) and is opt-in per provider (call after `populateOwners`). + * Namespace segments are taken as each namespace def's own tail, so it composes + * for nested namespaces regardless of whether the inner namespace's name is + * stored simple or already dotted. Skips defs already carrying the prefix. + */ +export function tagNamespacePrefixes(parsed: ParsedFile): void { + const scopesById = new Map(); + for (const scope of parsed.scopes) scopesById.set(scope.id, scope); + + // Enclosing-namespace prefix for a scope: the dotted path of each ancestor + // Namespace scope's name, outermost-first (`['Outer','Inner'] → 'Outer.Inner'`). + const namespacePrefixOf = (scope: ParsedFile['scopes'][number]): string => { + const segments: string[] = []; + let parentId = scope.parent; + while (parentId !== null) { + const parent = scopesById.get(parentId); + if (parent === undefined) break; + if (parent.kind === 'Namespace') { + const nsDef = parent.ownedDefs.find((d) => d.type === 'Namespace'); + const nsQ = nsDef?.qualifiedName; + if (nsQ !== undefined && nsQ.length > 0) { + const dot = nsQ.lastIndexOf('.'); + segments.unshift(dot === -1 ? nsQ : nsQ.slice(dot + 1)); + } + } + parentId = parent.parent; + } + return segments.join('.'); + }; + + for (const scope of parsed.scopes) { + if (scope.kind === 'Namespace') continue; + const prefix = namespacePrefixOf(scope); + if (prefix.length === 0) continue; + for (const def of scope.ownedDefs) { + const q = def.qualifiedName; + if (q === undefined || q.length === 0) continue; + if (q === prefix || q.startsWith(`${prefix}.`)) continue; // already namespaced + (def as { namespacePrefix?: string }).namespacePrefix = prefix; + } + } +} + /** * Walk a scope chain upward looking for the innermost enclosing * Class scope and return that class's def. Used by per-language diff --git a/gitnexus/src/core/ingestion/utils/ast-helpers.ts b/gitnexus/src/core/ingestion/utils/ast-helpers.ts index f7e7917ea6..52d28169f7 100644 --- a/gitnexus/src/core/ingestion/utils/ast-helpers.ts +++ b/gitnexus/src/core/ingestion/utils/ast-helpers.ts @@ -7,10 +7,42 @@ import { stripTemplateArguments, templateArgumentsIdTag, } from './template-arguments.js'; +import { splitQualifiedName } from './qualified-name.js'; /** Tree-sitter AST node. Re-exported for use across ingestion modules. */ export type SyntaxNode = Parser.SyntaxNode; +/** + * Qualify a Rust inherent-impl target (`impl Inner { ... }`) by its enclosing + * `mod_item` scope, so a bare same-tail target nested under different modules + * resolves to a DISTINCT path (`outer.Inner` vs `other.Inner`) — the #1982 + * follow-up to #1975. Walks `mod_item` ancestors (outermost → innermost) and + * joins them with the normalized raw target via the shared `splitQualifiedName`. + * A top-level `impl Inner` (no enclosing mod) returns the bare target unchanged. + * Keyed purely on tree-sitter node types (no language name), matching the + * inherent-impl branch in `findEnclosingClassInfo`; the caller restricts this to + * UNSCOPED targets (`type_identifier`) so a SCOPED `impl a::Inner` keeps its full + * raw text (#1975). The Impl-node materialization in parsing-processor / + * parse-worker mirrors this so the owner edge and node id agree byte-for-byte. + */ +export const qualifyRustImplTargetByModScope = ( + implNode: SyntaxNode, + rawTargetText: string, +): string => { + const modSegments: string[] = []; + let current = implNode.parent; + while (current) { + if (current.type === 'mod_item') { + const nameNode = + current.childForFieldName?.('name') ?? + current.children?.find((c: SyntaxNode) => c.type === 'identifier'); + if (nameNode) modSegments.unshift(nameNode.text); + } + current = current.parent; + } + return [...modSegments, ...splitQualifiedName(rawTargetText)].filter(Boolean).join('.'); +}; + /** * Ordered list of definition capture keys for tree-sitter query matches. * Used to extract the definition node from a capture map. @@ -321,6 +353,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 +386,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; @@ -444,16 +495,24 @@ export const findEnclosingClassInfo = ( }; } } - // Inherent impl target. Accept a scoped path (`impl a::Inner { ... }`) and - // key the Impl node by its FULL text — matching the @definition.impl - // scoped arm — so methods own through a node that exists and stays - // distinct from a same-tail type in another module (#1975). + // Inherent impl target. + // - SCOPED (`impl a::Inner`, scoped_type_identifier): key by FULL text, + // matching the @definition.impl scoped arm (#1975). UNCHANGED. + // - UNSCOPED (`impl Inner`, type_identifier): qualify by the enclosing + // `mod_item` scope (`outer.Inner`) so two same-tail bare impls under + // different mods own through DISTINCT nodes. The Impl-node + // materialization (parsing-processor / parse-worker) mirrors this, so + // the owner id == the Impl node id byte-for-byte (#1982). const firstType = children.find( (c: SyntaxNode) => c.type === 'type_identifier' || c.type === 'scoped_type_identifier', ); if (firstType) { + const ownerKey = + firstType.type === 'type_identifier' + ? qualifyRustImplTargetByModScope(current, firstType.text) + : firstType.text; return { - classId: generateId('Impl', `${filePath}:${firstType.text}`), + classId: generateId('Impl', `${filePath}:${ownerKey}`), className: firstType.text, }; } @@ -485,9 +544,29 @@ export const findEnclosingClassInfo = ( templateArguments !== undefined ? `${stripTemplateArguments(nameNode.text)}${templateArgumentsIdTag(templateArguments)}` : nameNode.text; + // #1978: when the language opts into qualified node ids, key the owner + // edge by the enclosing type's qualified path (e.g. "Outer.Inner") so it + // matches the qualified class node id. Derived from the SAME + // extractQualifiedName the node-id uses → agree by construction. Only set + // when actually nested (qualified !== simple); top-level types are + // unchanged. (Go receiver / Rust impl branches return earlier and are + // intentionally untouched here.) + const qualifiedOwnerName = getQualifiedOwnerName?.(current, nameNode.text); + const qualifiedClassId = + qualifiedOwnerName != null && qualifiedOwnerName !== nameNode.text + ? generateId( + label, + `${filePath}:${ + templateArguments !== undefined + ? `${stripTemplateArguments(qualifiedOwnerName)}${templateArgumentsIdTag(templateArguments)}` + : qualifiedOwnerName + }`, + ) + : undefined; return { classId: generateId(label, `${filePath}:${classIdName}`), className: nameNode.text, + ...(qualifiedClassId !== undefined ? { qualifiedClassId } : {}), }; } } diff --git a/gitnexus/src/core/ingestion/utils/qualified-name.ts b/gitnexus/src/core/ingestion/utils/qualified-name.ts new file mode 100644 index 0000000000..dcd3d5ffb1 --- /dev/null +++ b/gitnexus/src/core/ingestion/utils/qualified-name.ts @@ -0,0 +1,43 @@ +/** + * Shared qualified-name normalization. + * + * One canonical transform from a raw, language-specific qualified name + * (`Other::Inner`, `pkg\Sub\Type`, ` A . B `) to the `.`-joined form the + * graph and the `QualifiedNameIndex` are keyed by (`Other.Inner`, `pkg.Sub.Type`, + * `A.B`). Extracted from `class-extractors/generic.ts` so the structure-phase + * `buildQualifiedName`, the scope-resolution inheritance resolver, and the + * per-language capture emitters all key against ONE normalizer — a raw `::` + * qualifier must normalize to the exact key the index already holds, or the + * qualified lookup silently misses (issue #1982). + * + * Do NOT confuse with `heritage-extractors/supertype-alternation.ts`'s + * `simplifyRawName`, which collapses a qualified name to its LAST segment + * (`Other::Inner` → `Inner`) — that is a tail extractor, not a normalizer, and + * using it as a lookup key guarantees a miss. + * + * Pure string functions; no AST or tree-sitter dependency. + */ + +/** + * Normalize a raw qualified name to the `.`-joined canonical form: + * strips whitespace, converts `::` and `\` separators to `.`, collapses + * repeated dots, and trims leading/trailing dots. + */ +export const normalizeQualifiedName = (value: string): string => + value + .replace(/\s+/g, '') + .replace(/^::/, '') + .replace(/::/g, '.') + .replace(/\\/g, '.') + .replace(/\.+/g, '.') + .replace(/^\.+|\.+$/g, ''); + +/** + * Split a raw qualified name into its normalized, non-empty segments + * (`Other::Inner` → `['Other', 'Inner']`). Returns `[]` for an empty or + * separator-only input. + */ +export const splitQualifiedName = (value: string): string[] => { + const normalized = normalizeQualifiedName(value); + return normalized ? normalized.split('.').filter(Boolean) : []; +}; diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index 8a7946d327..2bb2ea0439 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -67,6 +67,7 @@ import { genericFuncName, inferFunctionLabel, isSuppressedConcreteTypedefDuplicate, + qualifyRustImplTargetByModScope, CLASS_CONTAINER_TYPES, type SyntaxNode, } from '../utils/ast-helpers.js'; @@ -753,11 +754,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 +1524,23 @@ const processFileGroup = ( } if (routed.kind === 'properties') { + // #1978: thread the qualifier so a routed property's owner edge + // points at the *qualified* nested-class node (Outer.Inner) rather + // than a now-nonexistent simple `Class:file:Inner` id. Gated on the + // flag → byte-identical when off. Mirrors the main owner path. + const propGetQualifiedOwnerName = + provider.classExtractor?.qualifiedNodeId === true + ? (node: SyntaxNode, simpleName: string): string | null => + provider.classExtractor!.extractQualifiedName(node, simpleName) + : undefined; const propEnclosingInfo = cachedFindEnclosingClassInfo( captureMap['call'], file.path, provider.resolveEnclosingOwner, + propGetQualifiedOwnerName, ); - const propEnclosingClassId = propEnclosingInfo?.classId ?? null; + const propEnclosingClassId = + propEnclosingInfo?.qualifiedClassId ?? propEnclosingInfo?.classId ?? null; // Enrich routed properties with FieldExtractor metadata let routedFieldMap: Map | undefined; if (provider.fieldExtractor && typeEnv) { @@ -1803,23 +1821,63 @@ 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. + // #1982: LOCKSTEP with parsing-processor.ts — a Rust inherent-impl with an + // UNSCOPED bare target is keyed by the enclosing `mod_item` scope so the + // worker-path Impl node id matches the sequential path and the owner walk. + const rustImplQualifiedName = + nodeLabel === 'Impl' && + definitionNode?.type === 'impl_item' && + nameNode?.type === 'type_identifier' + ? qualifyRustImplTargetByModScope(definitionNode, nodeName) + : undefined; + + const qualifiedName = + rustImplQualifiedName !== undefined + ? rustImplQualifiedName + : isClassLikeLabel && + provider.classExtractor?.qualifiedNodeId === true && + qualifiedTypeName !== undefined + ? qualifiedTypeName + : enclosingClassInfo + ? `${enclosingClassInfo.className}.${nodeName}` + : nodeName; // Extract method metadata BEFORE generating node ID — parameterCount is needed // to disambiguate overloaded methods via # suffix in the ID. @@ -1922,12 +1980,6 @@ const processFileGroup = ( nodeLabel, `${file.path}:${qualifiedName}${classTemplateTag}${arityTag}${parameterShapeTag}`, ); - const classNodeForSymbol = definitionNode || nameNode; - const qualifiedTypeName = - extractedClassSymbol?.qualifiedName ?? - (classNodeForSymbol && provider.classExtractor?.isTypeDeclaration(classNodeForSymbol) - ? (provider.classExtractor.extractQualifiedName(classNodeForSymbol, nodeName) ?? nodeName) - : undefined); const description = provider.descriptionExtractor?.(nodeLabel, nodeName, captureMap); diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-global-base-anchor/main.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-global-base-anchor/main.cpp new file mode 100644 index 0000000000..7e963b1a25 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-global-base-anchor/main.cpp @@ -0,0 +1,21 @@ +// #1982 P3: a root-anchored base (`: ::A::Inner`) names the GLOBAL `::A::Inner`, +// NOT the enclosing-relative `Outer::Wrap::A::Inner`. Without the leading-`::` +// guard in resolveQualifiedInheritanceBase, the enclosing-prefix key +// `Wrap.A.Inner` is tried first and `D` mis-binds to the inner type. With the +// guard, only the root-anchored `A.Inner` key is tried → the global type. +struct A { + struct Inner { + void global_inner() {} + }; +}; + +namespace Outer { +struct Wrap { + struct A { + struct Inner { + void wrap_inner() {} + }; + }; + struct D : ::A::Inner {}; +}; +} // namespace Outer diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-namespaced-collision/main.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-namespaced-collision/main.cpp new file mode 100644 index 0000000000..8b67362683 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-namespaced-collision/main.cpp @@ -0,0 +1,23 @@ +// Same-tail nested heritage INSIDE a namespace (#1982 follow-up). +// +// `NS::A::Inner` and `NS::B::Inner` are distinct nested types. The structure +// phase materializes distinct `NS.A.Inner` / `NS.B.Inner` graph nodes, but the +// scope-resolution model dropped the namespace from def.qualifiedName +// (`A.Inner` not `NS.A.Inner`), so resolveDefGraphId missed the namespaced node +// key and fell back to simpleKey('Inner'), collapsing both bases — DB lost its +// EXTENDS edge. The shipped same-tail fixture is top-level only (no namespace), +// so it cannot catch this. +namespace NS { +struct A { + struct Inner { + void from_a() {} + }; +}; +struct B { + struct Inner { + void from_b() {} + }; +}; +struct DA : A::Inner {}; +struct DB : B::Inner {}; +} // namespace NS diff --git a/gitnexus/test/fixtures/lang-resolution/cpp-nested-tail-collision/shapes.cpp b/gitnexus/test/fixtures/lang-resolution/cpp-nested-tail-collision/shapes.cpp new file mode 100644 index 0000000000..be0cf45c93 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/cpp-nested-tail-collision/shapes.cpp @@ -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 {}; diff --git a/gitnexus/test/fixtures/lang-resolution/ruby-nested-mixin-shortname/app.rb b/gitnexus/test/fixtures/lang-resolution/ruby-nested-mixin-shortname/app.rb new file mode 100644 index 0000000000..77a4e84fde --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ruby-nested-mixin-shortname/app.rb @@ -0,0 +1,19 @@ +# Nested mixin module included by its SHORT name (#1982 follow-up). +# +# `Loggable` is nested in `App` (qualifiedName `App.Loggable`), but is included +# by its bare short name `Loggable` from a sibling class inside the same module. +# The structure phase materializes a distinct `App.Loggable` node, but the +# resolution-side mixin lookup keys `graphIdByName` by FULL qualifiedName while +# the `__heritage__` marker carries the bare `arg.text` (`Loggable`) — so the +# IMPLEMENTS edge is silently dropped (0 dangling edges, undetectable). The +# shipped same-tail fixture only uses TOP-LEVEL mixin modules, where the full +# qualifiedName equals the bare name, so it cannot catch this. +module App + module Loggable + def log; end + end + + class Service + include Loggable + end +end diff --git a/gitnexus/test/fixtures/lang-resolution/ruby-nested-tail-collision/nested.rb b/gitnexus/test/fixtures/lang-resolution/ruby-nested-tail-collision/nested.rb new file mode 100644 index 0000000000..11eff83548 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ruby-nested-tail-collision/nested.rb @@ -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 diff --git a/gitnexus/test/fixtures/lang-resolution/ruby-qualified-mixin/app.rb b/gitnexus/test/fixtures/lang-resolution/ruby-qualified-mixin/app.rb new file mode 100644 index 0000000000..a847ef5eee --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/ruby-qualified-mixin/app.rb @@ -0,0 +1,14 @@ +# Qualified mixin argument (`include Outer::Mixin`) — the `::` in arg.text +# collided with the ':'-delimited __heritage__ marker field separator and the +# IMPLEMENTS edge was silently dropped (#1982 follow-up). The marker now embeds +# the dotted form (`Outer.Mixin`) so the split parses correctly and the lookup +# matches the mixin def's qualifiedName. +module Outer + module Mixin + def mixed; end + end +end + +class Consumer + include Outer::Mixin +end diff --git a/gitnexus/test/fixtures/lang-resolution/rust-nested-tail-collision/lib.rs b/gitnexus/test/fixtures/lang-resolution/rust-nested-tail-collision/lib.rs new file mode 100644 index 0000000000..e2675a8398 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/rust-nested-tail-collision/lib.rs @@ -0,0 +1,12 @@ +pub mod outer { + pub struct Inner; + impl Inner { + pub fn from_outer(&self) {} + } +} +pub mod other { + pub struct Inner; + impl Inner { + pub fn from_other(&self) {} + } +} diff --git a/gitnexus/test/fixtures/ruby-captures-golden/expected-captures.json b/gitnexus/test/fixtures/ruby-captures-golden/expected-captures.json index 006142b14b..72c44d05ad 100644 --- a/gitnexus/test/fixtures/ruby-captures-golden/expected-captures.json +++ b/gitnexus/test/fixtures/ruby-captures-golden/expected-captures.json @@ -207,6 +207,14 @@ "captureGroups": 16, "digest": "34e07387fece6c1d2deb49c39fc2bfe0badfe8015dd1f7ae956d57ac98322a1d" }, + "ruby-nested-mixin-shortname/app.rb": { + "captureGroups": 11, + "digest": "dfa494facc56b5e07a12befc77cd1e3788f0494f1373960cd9fe88715750e590" + }, + "ruby-nested-tail-collision/nested.rb": { + "captureGroups": 31, + "digest": "c48ebe5516a0faf50effbad0a19fe29be70c371b50ba9d6fa6ae3f6f708b3a4e" + }, "ruby-overload-dispatch/lib/app.rb": { "captureGroups": 10, "digest": "288d5386cf37fb76b52a94bc7da6bf8e7843830ebbb01fcd8100d1590c0e3f72" @@ -235,6 +243,10 @@ "captureGroups": 18, "digest": "f81f06be06d013a08a5c9b730a79494251f102f48ca4c25bf0bbd4a2cdcd889e" }, + "ruby-qualified-mixin/app.rb": { + "captureGroups": 11, + "digest": "30e3ff7538ab8cea9e5bd9c47c6275aa653b110720f24ee08d7fe19fc5c78952" + }, "ruby-qualified-types/lib/admin/user.rb": { "captureGroups": 8, "digest": "1bceb829c2429e1415c296ea97a2475203c3cbf69c07123186f4c277a44b2f9f" diff --git a/gitnexus/test/fixtures/rust-captures-golden/expected-captures.json b/gitnexus/test/fixtures/rust-captures-golden/expected-captures.json index 9a89f154ce..e9a735553b 100644 --- a/gitnexus/test/fixtures/rust-captures-golden/expected-captures.json +++ b/gitnexus/test/fixtures/rust-captures-golden/expected-captures.json @@ -319,6 +319,10 @@ "captureGroups": 18, "digest": "3326eb4f82b1559b6afec497dc52cab734e6f3209501a4bd982bf5eab9ec6dba" }, + "rust-nested-tail-collision/lib.rs": { + "captureGroups": 17, + "digest": "2fc1fe1eb4e8727a89ab283ae34a0ae8df0c421551a7bd5e6e7ffb9d4aa54189" + }, "rust-nullable-receiver/src/main.rs": { "captureGroups": 37, "digest": "283d8606eb837f2a9e5fdf95a30e3da5b73d4c14d74b94deb03e924dd2b2fde1" diff --git a/gitnexus/test/integration/resolvers/cpp.test.ts b/gitnexus/test/integration/resolvers/cpp.test.ts index 3463ceec1e..5b9dc58cad 100644 --- a/gitnexus/test/integration/resolvers/cpp.test.ts +++ b/gitnexus/test/integration/resolvers/cpp.test.ts @@ -3768,12 +3768,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)', () => { @@ -3795,8 +3799,266 @@ 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'); + }); + + it('resolves DerivedA : Outer::Inner → EXTENDS Outer.Inner on the worker path (parity + no duplicate)', () => { + const edges = getRelationships(result, 'EXTENDS').filter( + (x) => result.graph.getNode(x.rel.sourceId)?.properties.qualifiedName === 'DerivedA', + ); + expect(edges, 'DerivedA EXTENDS edges (worker path)').toHaveLength(1); + expect(edges[0]!.rel.targetId).toContain('Outer.Inner'); + expect(edges[0]!.rel.targetId).not.toContain('Other.Inner'); + }); +}); + +// --------------------------------------------------------------------------- +// 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'); + }); +}); + +// --------------------------------------------------------------------------- +// Namespaced same-tail nested heritage — qualified base resolution (issue #1982) +// +// `namespace NS { struct A{struct Inner{};}; struct B{struct Inner{};}; +// struct DA:A::Inner{}; struct DB:B::Inner{}; }` — the bases NS::A::Inner and +// NS::B::Inner are namespace-nested. The structure phase materializes distinct +// NS.A.Inner / NS.B.Inner nodes, but the scope-model def.qualifiedName dropped +// the namespace (`A.Inner` not `NS.A.Inner`), so resolveDefGraphId missed the +// namespaced node key and the simpleKey('Inner') fallback collapsed both bases — +// DB's EXTENDS pointed at NS.A.Inner. Asserts each Derived EXTENDS its own +// namespaced base by NODE ID (KTD3). Registry-primary only. +// --------------------------------------------------------------------------- + +describe('C++ namespaced same-tail nested heritage — qualified base (issue #1982)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'cpp-namespaced-collision'), () => {}); + }, 60000); + + const extendsTargetIdOf = (childQn: string): string | undefined => { + const ext = getRelationships(result, 'EXTENDS'); + const e = ext.find( + (x) => result.graph.getNode(x.rel.sourceId)?.properties.qualifiedName === childQn, + ); + return e?.rel.targetId; + }; + + it('resolves NS::DA : A::Inner → EXTENDS the NS.A.Inner node', () => { + const tid = extendsTargetIdOf('NS.DA'); + expect(tid, 'NS.DA EXTENDS endpoint').toBeDefined(); + expect(tid).toContain('NS.A.Inner'); + expect(tid).not.toContain('NS.B.Inner'); + }); + + it('resolves NS::DB : B::Inner → EXTENDS the NS.B.Inner node (not NS.A.Inner)', () => { + const tid = extendsTargetIdOf('NS.DB'); + expect(tid, 'NS.DB EXTENDS endpoint').toBeDefined(); + expect(tid).toContain('NS.B.Inner'); + expect(tid).not.toContain('NS.A.Inner'); + }); +}); + +// Same namespaced fixture through the WORKER pool — the namespacePrefix tag is +// applied during main-process scope-resolution (after worker parse/merge), so +// the fix must hold on both paths. Registry-primary only. +describe('C++ namespaced same-tail nested heritage — worker path parity (issue #1982)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'cpp-namespaced-collision'), () => {}, { + workerThresholdsForTest: { minFiles: 1, minBytes: 1 }, + workerPoolSize: 2, + }); + }, 120000); + + it('genuinely used the worker pool for the namespaced fixture', () => { + expect(result.usedWorkerPool).toBe(true); + }); + + it('resolves NS::DA / NS::DB to their own namespaced base on the worker path', () => { + const extendsTargetIdOf = (childQn: string): string | undefined => { + const ext = getRelationships(result, 'EXTENDS'); + const e = ext.find( + (x) => result.graph.getNode(x.rel.sourceId)?.properties.qualifiedName === childQn, + ); + return e?.rel.targetId; + }; + const da = extendsTargetIdOf('NS.DA'); + const db = extendsTargetIdOf('NS.DB'); + expect(da, 'NS.DA EXTENDS (worker)').toBeDefined(); + expect(db, 'NS.DB EXTENDS (worker)').toBeDefined(); + expect(da).toContain('NS.A.Inner'); + expect(db).toContain('NS.B.Inner'); + expect(db).not.toContain('NS.A.Inner'); + }); +}); + +// --------------------------------------------------------------------------- +// Root-anchored base must not pick up enclosing-relative segments (issue #1982) +// +// `namespace Outer { struct Wrap { struct A{struct Inner{};}; struct D : ::A::Inner {}; }; }` +// with a GLOBAL `struct A { struct Inner {}; }` — the leading `::` names the +// global type. Without the root-anchor guard, resolveQualifiedInheritanceBase +// prepends the deriving class's enclosing segments and tries `Wrap.A.Inner` +// first, mis-binding D to the inner type. With it, only the root-anchored +// `A.Inner` key is tried → the global type. Registry-primary only. +// --------------------------------------------------------------------------- + +describe('C++ root-anchored base ignores enclosing-relative type (issue #1982)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'cpp-global-base-anchor'), () => {}); + }, 60000); + + it('resolves Outer::Wrap::D : ::A::Inner → EXTENDS the GLOBAL A.Inner (not Wrap.A.Inner)', () => { + const e = getRelationships(result, 'EXTENDS').find( + (x) => result.graph.getNode(x.rel.sourceId)?.properties.qualifiedName === 'Outer.Wrap.D', + ); + expect(e, 'Outer.Wrap.D EXTENDS endpoint').toBeDefined(); + // Global node id is `Struct:main.cpp:A.Inner`; the enclosing-relative type + // is `Struct:main.cpp:Outer.Wrap.A.Inner`. KTD3: discriminate on the node id. + expect(e!.rel.targetId).toContain('A.Inner'); + expect(e!.rel.targetId).not.toContain('Wrap'); }); }); diff --git a/gitnexus/test/integration/resolvers/helpers.ts b/gitnexus/test/integration/resolvers/helpers.ts index fe0410572b..ae07af5012 100644 --- a/gitnexus/test/integration/resolvers/helpers.ts +++ b/gitnexus/test/integration/resolvers/helpers.ts @@ -271,8 +271,32 @@ const LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES: Readonly([ // Ruby scope-resolution currently achieves 89/127 parity. // Tests listed here are scope-resolver-only correctness wins - // (pass under registry-primary, fail under legacy). Currently - // empty — all 127 tests pass under legacy mode. + // (pass under registry-primary, fail under legacy). + // + // #1978 qualified nested-type node identity. NOTE: these PASS under the + // legacy leg too — the fix is in the SHARED structure phase, not the legacy + // resolution path. They are excluded here by policy to keep the #1978 + // assertions registry-primary-only and avoid coupling the legacy parity leg + // to the new node-identity behavior. + 'owns from_outer / from_other through distinct Outer.Inner / Other.Inner nodes (R7)', + 'owns radius (attr_accessor) under the qualified Shapes.Circle node, no dangling (R7)', + // #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)', + // #1982 follow-up: a nested mixin included by short name must not drop its + // IMPLEMENTS edge. The fix (graphIdByTail fallback in emitRubyMixinEdges) is + // registry-primary only; the legacy DAG does not use that bridge. + 'emits App.Service -IMPLEMENTS-> App.Loggable for a short-name nested mixin (R1)', + // #1982 follow-up: a qualified mixin arg (`include Outer::Mixin`) must not be + // corrupted by the ':'-delimited __heritage__ marker. Registry-primary only. + 'emits Consumer -IMPLEMENTS-> Outer.Mixin for include Outer::Mixin (R2)', + // #1982 follow-up: worker-path mixin (IMPLEMENTS) parity. Registry-primary only. + 'routes include OuterMix / OtherMix to their OWN qualified Inner owner on the worker path (IMPLEMENTS, R7)', ]), swift: new Set([ // Swift scope-resolution achieves 77/77 baseline parity. The tests @@ -497,6 +521,38 @@ const LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES: Readonly 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); + }, + ); + + // Worker-path parity for the MIXIN (IMPLEMENTS) path — the __heritage__ marker + // owner must survive worker serialization (not only attr_accessor / HAS_PROPERTY). + pit( + 'routes include OuterMix / OtherMix to their OWN qualified Inner owner on the worker path (IMPLEMENTS, R7)', + () => { + const impl = getRelationships(result, 'IMPLEMENTS'); + const ownerQnOfMixin = (mixinName: string) => { + const e = impl.find((x) => x.target === mixinName); + expect(e, `IMPLEMENTS -> ${mixinName}`).toBeDefined(); + return result.graph.getNode(e!.rel.sourceId)?.properties.qualifiedName; + }; + expect(ownerQnOfMixin('OuterMix')).toBe('Outer.Inner'); + expect(ownerQnOfMixin('OtherMix')).toBe('Other.Inner'); + expect(impl.filter((x) => x.target === 'OuterMix')).toHaveLength(1); + expect(impl.filter((x) => x.target === 'OtherMix')).toHaveLength(1); + }, + ); +}); + +// --------------------------------------------------------------------------- +// Nested mixin included by SHORT name — IMPLEMENTS edge must not drop (#1982). +// +// `module App; module Loggable; end; class Service; include Loggable; end; end` +// — `Loggable` is nested (qn App.Loggable) but included by its bare short name. +// The structure phase materializes a distinct App.Loggable node, but +// emitRubyMixinEdges keys graphIdByName by FULL qualifiedName while the +// __heritage__ marker carries the bare arg.text ('Loggable'), so the +// mixin-target lookup missed and the IMPLEMENTS edge was silently dropped +// (0 dangling, undetectable). The shipped same-tail fixture only uses TOP-LEVEL +// mixin modules (full qn == bare name), so it cannot catch this. Asserts the +// edge exists and resolves by NODE ID (KTD3 — not the normalized qualifiedName +// property). Registry-primary only (emitRubyMixinEdges is the registry bridge). +// --------------------------------------------------------------------------- + +describe('Ruby nested mixin by short name — IMPLEMENTS not dropped (issue #1982)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo( + path.join(FIXTURES, 'ruby-nested-mixin-shortname'), + () => {}, + ); + }, 60000); + + pit('emits App.Service -IMPLEMENTS-> App.Loggable for a short-name nested mixin (R1)', () => { + expect(findDanglingEdges(result, ['IMPLEMENTS'])).toEqual([]); + const impl = getRelationships(result, 'IMPLEMENTS'); + const e = impl.find((x) => x.target === 'Loggable'); + expect(e, 'IMPLEMENTS -> Loggable (nested mixin by short name)').toBeDefined(); + // KTD3: discriminate on the resolved node id, not the normalized property. + // The owner resolves to the QUALIFIED `App.Service` class node — the pre-fix + // bug dropped the edge entirely, so its presence + qualified owner is the + // discriminator. (The mixin module is a Trait node keyed by its simple name + // `Loggable`; Trait-node qualification under same-tail modules is a separate + // structure-phase concern, deferred.) + expect(e!.rel.sourceId).toContain('App.Service'); + expect(e!.rel.targetId).toContain('Loggable'); + }); +}); + +// --------------------------------------------------------------------------- +// Qualified mixin argument — `::` must not corrupt the __heritage__ marker (#1982). +// +// `class Consumer; include Outer::Mixin; end` — the `::` in `arg.text` +// (`Outer::Mixin`) collided with the ':'-delimited __heritage__ marker field +// separator (`__heritage__:include:Outer::Mixin:Consumer`), so emitRubyMixinEdges +// mis-split it and dropped the edge. The marker now embeds the dotted form +// (`Outer.Mixin`), which both parses correctly and matches the mixin def's +// qualifiedName. Registry-primary only. +// --------------------------------------------------------------------------- + +describe('Ruby qualified mixin arg — IMPLEMENTS not corrupted by :: (issue #1982)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-qualified-mixin'), () => {}); + }, 60000); + + pit('emits Consumer -IMPLEMENTS-> Outer.Mixin for include Outer::Mixin (R2)', () => { + expect(findDanglingEdges(result, ['IMPLEMENTS'])).toEqual([]); + const impl = getRelationships(result, 'IMPLEMENTS'); + const e = impl.find((x) => x.target === 'Mixin'); + expect(e, 'IMPLEMENTS -> Mixin (qualified mixin arg)').toBeDefined(); + // KTD3: discriminate on the resolved node id (the pre-fix bug dropped the edge). + expect(e!.rel.sourceId).toContain('Consumer'); + expect(e!.rel.targetId).toContain('Mixin'); + }); +}); diff --git a/gitnexus/test/integration/resolvers/rust.test.ts b/gitnexus/test/integration/resolvers/rust.test.ts index 91af9af7a1..8a99119a09 100644 --- a/gitnexus/test/integration/resolvers/rust.test.ts +++ b/gitnexus/test/integration/resolvers/rust.test.ts @@ -2054,6 +2054,54 @@ describe('Rust scoped inherent impl — ownership + collision (issue #1975)', () }); }); +// --------------------------------------------------------------------------- +// 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). +// --------------------------------------------------------------------------- + +// #1982: Rust same-tail nested-mod inherent-impl methods now own through DISTINCT +// Impl nodes — mod outer's `impl Inner` → `Impl:...:outer.Inner`, mod other's → +// `other.Inner`. The inherent-impl owner walk (ast-helpers `findEnclosingClassInfo`) +// and the Impl-node materialization (parsing-processor / parse-worker) both qualify +// an UNSCOPED impl target by its enclosing `mod_item` scope, byte-identically, so +// the HAS_METHOD owner edge stays anchored. Structure-phase, so it holds on both +// resolver legs. (Scoped `impl a::Inner` is unchanged — #1975.) +describe('Rust inline mod-nested same-tail collision — distinct nodes (issue #1978/#1982)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'rust-nested-tail-collision'), () => {}); + }, 60000); + + it('owns from_outer / from_other through distinct mod-qualified Impl nodes (no merge)', () => { + expect(findDanglingEdges(result, ['HAS_METHOD'])).toEqual([]); + const hm = getRelationships(result, 'HAS_METHOD'); + const a = hm.find((e) => e.target === 'from_outer'); + const b = hm.find((e) => e.target === 'from_other'); + expect(a, 'HAS_METHOD -> from_outer').toBeDefined(); + expect(b, 'HAS_METHOD -> from_other').toBeDefined(); + // Pre-fix the two same-tail `Inner` impls merged onto one `Impl:...:Inner` + // node. KTD3: discriminate on the node id — each now carries its mod path. + expect(a!.rel.sourceId).not.toBe(b!.rel.sourceId); + expect(a!.rel.sourceId).toContain('outer.Inner'); + expect(b!.rel.sourceId).toContain('other.Inner'); + }); +}); + // --------------------------------------------------------------------------- // F71 — union declarations resolve as Struct nodes (issue #1934) //