Compare commits

...
Author SHA1 Message Date
Gergo MagyarandClaude Opus 4.8 cd8eb4757c test(ingestion): rebaseline #1982 golden/fingerprint + lint/format sweep
Cross-cutting verification artifacts for the #1982 same-tail resolution fix:
- ruby capture golden regenerated: ONLY the ruby-nested-tail-collision fixture
  drifts (+10 capture groups from its new include/attr_accessor + the now
  full-qualified __heritage__/__property__ marker owner). All other ruby fixtures
  byte-identical (proves the owner-qualification is localized to nested owners).
- bench/scope-capture/baselines.json: rebaseline cpp + ruby fingerprints (the only
  two that drift; 12 other languages byte-identical). cpp = additive
  @reference.qualified-name capture; ruby = the localized owner change. Provenance
  notes record both. scaling linear (~1.0), 14/14 PASS.
- generic.ts: drop the now-unused normalizeQualifiedName import (lint error).
- walkers.ts / ruby.test.ts: prettier formatting.

Verified: cpp 278/278 + ruby 142/142 (registry-primary), both legacy legs clean
(skips registry-primary-only assertions), go/java/csharp 542 (cross-language
regression — the qualified-first branch is gated on rawQualifiedName, set only by
C++, so non-C++ inheritance resolution is unchanged). tsc + eslint(0 errors) + prettier clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 07:48:45 +00:00
Gergo MagyarandClaude Opus 4.8 bb84ccb27e fix(ingestion): resolve same-tail Ruby mixin/attr_accessor owners to the correct qualified node (#1982)
emitRubyMixinEdges keyed its owner map by the SIMPLE tail (def.qualifiedName
split-popped) with last-wins, and the __heritage__/__property__ markers carried
only the immediate owner name — so `module Outer; class Inner` and
`module Other; class Inner` collapsed onto one `Inner` key and cross-wired their
include/attr_accessor edges onto whichever Inner was processed last.

Fix (lockstep, full-qualified):
- ruby/captures.ts: build the marker owner from the FULL enclosing class/module
  chain (buildEnclosingQualifiedName walks all ancestors, normalizing the compact
  `class Outer::Inner` scope_resolution form via the shared splitQualifiedName) so
  the marker owner byte-matches the resolution def's qualifiedName.
- ruby/scope-resolver.ts: key graphIdByName by the full def.qualifiedName instead
  of the simple tail. Top-level owners/mixins are unchanged (full == simple).

Registry-primary ruby.test.ts 142/142 incl. a new worker-path block (the deferred
note's duplicate-edge concern: markers survive worker serialization, exactly one
HAS_PROPERTY per attr). Legacy leg unaffected (136 pass / 6 skip) — new assertions
registry-primary-only via helpers.ts. tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 07:37:02 +00:00
Gergo MagyarandClaude Opus 4.8 16883f2d79 fix(ingestion): resolve same-tail C++ nested-type heritage to the correct qualified node (#1982)
Registry-primary C++ inheritance (preEmitInheritanceEdges -> resolveInheritanceBaseInScope)
resolved a same-tail nested base by its SIMPLE TAIL with first-wins, so
`struct DerivedB : Other::Inner` mis-resolved EXTENDS to Outer.Inner (the wrong
sibling; 0 dangling, so undetected). The namespace qualifier was discarded at the
C++ inheritance capture.

Fix (additive, qualified-first):
- ReferenceSite gains an optional `rawQualifiedName`; the C++ inheritance capture
  emits `@reference.qualified-name` (qualifier-preserving, template-stripped:
  Other::Inner, ns::Base<T> -> ns::Base) only when the base is qualified, registered
  as a sub-tag so it can't shadow the `@reference.inherits` anchor.
- resolveInheritanceBaseInScope resolves the qualifier against the full-path
  QualifiedNameIndex FIRST (which already carries Outer.Inner / Other.Inner keys from
  the structure phase), with progressive-prefix lookup for relative bases and
  refuse-on-tie, falling through to the existing simple-tail walk on miss — so
  unqualified bases and the single-candidate cross-file case are unchanged.

Registry-primary cpp.test.ts 278/278 (incl. worker-path: rawQualifiedName survives
worker serialization). Legacy leg unaffected (207 pass / 71 skip) — the new
resolution-side assertions are registry-primary-only via helpers.ts. tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 07:27:58 +00:00
Gergo MagyarandClaude Opus 4.8 35a37d9240 refactor(ingestion): extract shared qualified-name normalizer (#1982)
Move normalizeQualifiedName/splitQualifiedName out of class-extractors/
generic.ts into utils/qualified-name.ts so the structure-phase
buildQualifiedName, the scope-resolution inheritance resolver, and the
per-language capture emitters can all key against ONE normalizer. A raw
'::' qualifier must normalize to the exact '.'-joined key the
QualifiedNameIndex already holds, or the qualified lookup silently misses
(the #1982 resolution-side foundation). Pure relocation — byte-identical
function bodies; tsc clean; existing C++ nested-collision tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 06:49:43 +00:00
Gergo MagyarandClaude Opus 4.8 e2641628c7 fix(test): satisfy CI for the new #1978 fixtures (format + golden + fingerprint)
Adding the {cpp,ruby,rust}-nested-tail-collision fixtures changed the
lang-resolution corpus, which the scope-capture golden snapshots and the
fingerprint baselines gate on. These are pure fixture-corpus additions —
#1978 does not touch the scope-capture phase (captures.ts / emit*ScopeCaptures
are unchanged). Verified: the regenerated ruby/rust golden diffs are
additive-only (no existing fixture's capture digest changed), so the cpp/ruby/
rust fingerprint drift is solely the new fixtures.

- prettier --write test/integration/resolvers/{ruby,rust}.test.ts
- regenerate ruby/rust captures-golden snapshots (UPDATE_GOLDEN=1; +1 fixture each)
- rebaseline cpp/ruby/rust scope-capture fingerprints (bench/scope-capture/baselines.json)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 03:20:18 +00:00
Gergo MagyarandClaude Opus 4.8 ddc31ba72e test(ingestion): scope #1978 resolver tests to registry-primary leg; fix lint
- helpers.ts: exclude the new #1978 C++/Ruby resolver tests from the legacy
  parity leg (LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES). They PASS on legacy
  too — the fix lives in the SHARED structure phase, not the legacy resolution
  path — so this is a deliberate registry-primary-only scoping (not a legacy
  gap), keeping the legacy path untouched and uncoupled from the new
  node-identity behavior.
- rust.test.ts: drop the `eslint-disable vitest/no-disabled-tests` directive.
  That rule isn't configured in this repo, so eslint errored "Definition for
  rule 'vitest/no-disabled-tests' was not found" and failed `quality / lint`.
  The describe.skip needs no disable directive.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 02:44:41 +00:00
Gergo MagyarandClaude Opus 4.8 dc122897a9 fix(ingestion): qualify nested-type node identity for C++/Ruby (#1978)
Nested types sharing a tail name in one file — C++ `Outer::Inner` vs
`Other::Inner`, Ruby `Outer::Inner` vs `Other::Inner` modules — silently merged
into a single graph node keyed by the simple tail (`Struct:file:Inner`),
cross-wiring their methods/properties onto one owner.

Key class-like type nodes (Class/Struct/Interface/Enum/Record) by their
normalized fully-qualified path (`Struct:file:Outer.Inner`) instead of the
simple name. Gated per-language by a new `qualifiedNodeId` config flag
(default false → byte-identical for every other language); enabled here for
C++ and Ruby.

- class-types.ts / generic.ts: `qualifiedNodeId` flag on ClassExtractor + config
- ast-helpers.ts: findEnclosingClassInfo gains an optional getQualifiedOwnerName
  hook + EnclosingClassInfo.qualifiedClassId, so member-owner edges resolve to
  the qualified class node id (owner id == node id by construction)
- parsing-processor.ts + parse-worker.ts: flag-gated qualified node-id + owner
  edges on both the sequential and worker parse paths (incl. routed properties)
- call-processor.ts: same qualifier in the routed-property pre-pass (lockstep
  with the worker `kind === 'properties'` block)
- configs/c-cpp.ts, configs/ruby.ts: qualifiedNodeId: true

Method/Property node ids stay simple-qualified; only type nodes get the
qualified id.

Deferred to a resolution-side follow-up: Ruby SAME-TAIL routed-property/mixin
owner identity under registry-primary (`emitRubyMixinEdges` keys owners by the
simple tail name, last-wins); and Rust inherent-impl methods (impl_item is not
a typeDeclaration — its #1978 test is describe.skip).

Tests: same-tail collision fixtures + #1978 resolver tests for C++/Ruby
(positive owner identity, R7), a worker-path parity block, and an unambiguous
nested attr_accessor case; the C++ #1975 out-of-line test updated to assert
qualified-id distinctness (forward-decl + out-of-line now unify). Verified
green on both parity legs, the worker path, and tsc.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 23:10:09 +00:00
26 changed files with 823 additions and 63 deletions
@@ -54,6 +54,18 @@ export type CallForm = 'free' | 'member' | 'constructor' | 'index';
export interface ReferenceSite {
/** The name being referenced (e.g., `'save'`, `'User'`, `'count'`). */
readonly name: string;
/**
* Optional raw, qualified form of the referenced name when the source wrote
* a qualified path (e.g. a C++ base `struct D : Other::Inner` yields
* `'Other::Inner'`). `name` keeps the simple tail (`'Inner'`) for the existing
* scope-chain contract; resolution normalizes this via `normalizeQualifiedName`
* and resolves it against the full-path `QualifiedNameIndex` BEFORE the
* simple-tail walk, so a same-tail nested base resolves to the correct
* sibling instead of the first-inserted one (issue #1982). Populated only by
* per-language captures that emit `@reference.qualified-name`; absent
* otherwise, in which case resolution is unchanged.
*/
readonly rawQualifiedName?: string;
/** Source-text range of this reference. */
readonly atRange: Range;
/**
+5 -5
View File
@@ -16,11 +16,11 @@
"_added": "#1956: c added to the scope-capture bench (was UNBENCHED). C has no inheritance \u2014 flat scale source. Adding it exposed + fixed a pre-existing O(n^2) findNodeAtRange root-walk in c/captures.ts (threaded c.node, byte-identical over c-* fixtures); scaling 3.475 -> 0.96."
},
"cpp": {
"fingerprint": "931bf7af55dc1480d1a5d3c479ea3803003a6a2e2c4406447bd96f3e312e88de",
"fingerprint": "2f517381e8b03db221d13f65d5e485d021c17a2f7fa684446e09f33d78c3adb9",
"scaling_budget": 1.5,
"_added": "#1956: cpp added to the scope-capture bench (was UNBENCHED). Heritage-bearing scale source (: public Base, public Mixin) drives emitCppInheritanceCaptures at scale. Adding it exposed + fixed a pre-existing O(n^2) findNodeAtRange root-walk in cpp/captures.ts (~12 sites, threaded c.node, byte-identical over 263 cpp-* fixtures); scaling 2.30 -> 1.12.",
"_rebaselined": "#1965 / #1923 F4: uninitialized non-leading multi-declarators now emit @declaration.variable captures; cpp-adl-inner-callable-outer-noncallable data::Pair a, b adds the legitimate fixture drift. Linear (~1.06).",
"_note": "#1975: + cpp-out-of-line-class fixture (out-of-line struct Outer::Inner / Other::Inner). Pure fixture-corpus drift — the fix is the legacy structure-query qualified_identifier arm, NOT the cpp scope-extractor; existing fixtures' captures byte-identical. fixture_count 263->265."
"_note": "#1975: + cpp-out-of-line-class fixture (out-of-line struct Outer::Inner / Other::Inner). Pure fixture-corpus drift — the fix is the legacy structure-query qualified_identifier arm, NOT the cpp scope-extractor; existing fixtures' captures byte-identical. fixture_count 263->265. #1982: cpp-nested-tail-collision gains qualified heritage (struct DerivedA : Outer::Inner, struct DerivedB : Other::Inner) AND emitCppInheritanceCaptures now emits an ADDITIVE @reference.qualified-name capture on QUALIFIED bases (drives the qualified-first inheritance resolver). Purely additive — existing captures unchanged; same fixture file, no new fixture."
},
"csharp": {
"_rebaselined": "#1956 synth-widening: + csharp-qualified-base fixture; the synth now walks record_declaration + struct_declaration base_lists and handles alias_qualified_name (matching the #1940 legacy leg), so record/struct heritage now emits. csharp-record-base gains a record inherits capture. (record->record SAME-namespace EXTENDS is a separate registry resolution gap, tracked as follow-up.) Linear (~1.00). (Earlier #1956: heritage-bearing scale source.)",
@@ -28,7 +28,7 @@
"scaling_budget": 1.5
},
"rust": {
"fingerprint": "3c4b8e0a707299cc5db0af2528c72a99457859104589a7ef3cd1f377da01793e",
"fingerprint": "30224e2590064745548bc1d623811ae5d37227618854788695442a0acf1898fb",
"scaling_budget": 1.5,
"_rebaselined": "#1956 tri-review U1: rust-qualified-trait fixture (scoped + generic-of-scoped impl trait paths); bareTypeIdentifier now resolves scoped_type_identifier bases by their name: tail (additive, no existing-fixture drift); linear (~1.04).",
"_note": "#1975: + rust-scoped-impl fixture (impl a::Inner / b::Inner inherent scoped impls). Pure fixture-corpus drift — the fix is the legacy @definition.impl scoped arm + findEnclosingClassInfo inherent-impl scoped target, NOT the rust scope-extractor; existing fixtures' captures byte-identical. fixture_count 120->121."
@@ -39,10 +39,10 @@
"_rebaselined": "#1956: heritage-bearing scale source (class extends Base + use trait); both forms gated at scale; linear (~1.04)."
},
"ruby": {
"fingerprint": "ee81145cf0af796878e8e048192b87c8c8dc445a3e3fcdff6c6e26c179e97232",
"fingerprint": "011c0533318ff61d99267d501866af8a616b1c212050628339064d8c149aafbf",
"scaling_budget": 1.5,
"_rebaselined": "#1956 synth-widening: + ruby-qualified-base fixture; synth now reduces a scope_resolution superclass (class C < Mod::Super) to its trailing constant (matching the #1940 legacy leg), at parity. Linear (~1.03). (Earlier #1956: heritage-bearing scale source.)",
"_note": "F62: + scope_resolution class/module declaration captures — fixture count 78→81, fingerprint drift expected. #1975: + ruby-tail-collision fixture (Foo::Bar vs Baz::Bar stay distinct nodes) — pure fixture-corpus drift, scope-extractor captures unchanged; 81→82."
"_note": "F62: + scope_resolution class/module declaration captures — fixture count 78→81, fingerprint drift expected. #1975: + ruby-tail-collision fixture (Foo::Bar vs Baz::Bar stay distinct nodes) — pure fixture-corpus drift, scope-extractor captures unchanged; 81→82. #1978: + ruby-nested-tail-collision (same-tail Inner under Outer/Other modules + Shapes.Circle attr_accessor) — pure fixture-corpus drift, scope-extractor captures unchanged; 82→83. #1982: that fixture gains same-tail include + attr_accessor, AND emitRubyScopeCaptures now emits the FULL qualified owner in __heritage__/__property__ markers (buildEnclosingQualifiedName) — a CODE change LOCALIZED to nested owners: the golden shows ONLY this fixture drifts (+10 capture groups), all other ruby fixtures byte-identical, and 142/142 resolver tests pass. fixture_count 83 (same file)."
},
"swift": {
"fingerprint": "53325c6345161c5a495f997297af5a24fb718fd3e6647040160f8ab2a2c8e4c0",
+12 -1
View File
@@ -966,12 +966,23 @@ export const processCalls = async (
const routed = callRouter(callNameNode.text, captureMap['call']);
if (!routed || routed.kind !== 'properties') return;
// #1978: thread the qualifier so a routed property's owner edge points at
// the *qualified* nested-class node (Shapes.Circle) instead of a now-nonexistent
// simple `Class:file:Circle` id. Gated on the flag → byte-identical when off.
// MUST stay in lockstep with the worker `kind === 'properties'` block.
const propGetQualifiedOwnerName =
provider.classExtractor?.qualifiedNodeId === true
? (node: SyntaxNode, simpleName: string): string | null =>
provider.classExtractor!.extractQualifiedName(node, simpleName)
: undefined;
const propEnclosingInfo = findEnclosingClassInfo(
captureMap['call'],
file.path,
provider.resolveEnclosingOwner,
propGetQualifiedOwnerName,
);
const propEnclosingClassId = propEnclosingInfo?.classId ?? null;
const propEnclosingClassId =
propEnclosingInfo?.qualifiedClassId ?? propEnclosingInfo?.classId ?? null;
// Enrich routed properties with FieldExtractor metadata so types
// discovered from constructor assignments (e.g. `@address = Address.new`)
@@ -46,6 +46,9 @@ export const cppClassConfig: ClassExtractionConfig = {
language: SupportedLanguages.CPlusPlus,
typeDeclarationNodes: ['class_specifier', 'struct_specifier', 'enum_specifier'],
ancestorScopeNodeTypes: ['namespace_definition', 'class_specifier', 'struct_specifier'],
// #1978: key nested-type nodes by their fully-qualified path (Outer.Inner) so
// same-tail nested types in one TU stay distinct instead of silently merging.
qualifiedNodeId: true,
extractName: (node) => {
const nameNode = node.childForFieldName?.('name');
if (!nameNode) return undefined;
@@ -7,4 +7,7 @@ export const rubyClassConfig: ClassExtractionConfig = {
language: SupportedLanguages.Ruby,
typeDeclarationNodes: ['class'],
ancestorScopeNodeTypes: ['module', 'class'],
// #1978: key nested-type nodes by their fully-qualified path (Outer.Inner) so
// same-tail classes nested under different modules stay distinct.
qualifiedNodeId: true,
};
@@ -6,6 +6,7 @@ import type {
ClassLikeNodeLabel,
ExtractedClassSymbol,
} from '../class-types.js';
import { splitQualifiedName } from '../utils/qualified-name.js';
const DEFAULT_SCOPE_NAME_NODE_TYPES = new Set([
'nested_namespace_specifier',
@@ -58,20 +59,6 @@ const CLASS_LIKE_LABELS = new Set<ClassLikeNodeLabel>([
'Record',
]);
const normalizeQualifiedName = (value: string): string =>
value
.replace(/\s+/g, '')
.replace(/^::/, '')
.replace(/::/g, '.')
.replace(/\\/g, '.')
.replace(/\.+/g, '.')
.replace(/^\.+|\.+$/g, '');
const splitQualifiedName = (value: string): string[] => {
const normalized = normalizeQualifiedName(value);
return normalized ? normalized.split('.').filter(Boolean) : [];
};
const extractScopeSegmentsFromNode = (
scopeNode: SyntaxNode,
scopeNameNodeTypes: ReadonlySet<string>,
@@ -165,6 +152,7 @@ export function createClassExtractor(config: ClassExtractionConfig): ClassExtrac
return {
language: config.language,
qualifiedNodeId: config.qualifiedNodeId ?? false,
isTypeDeclaration(node: SyntaxNode): boolean {
return typeDeclarationSet.has(node.type);
@@ -28,6 +28,13 @@ export interface ClassCaptureContext {
*/
export interface ClassExtractor {
language: SupportedLanguages;
/**
* When true, this language's nested-type graph nodes are keyed by their
* fully-qualified path (e.g. `Class:file:Outer.Inner`) instead of the simple
* tail name, so same-tail nested types in one file stay distinct (#1978).
* Surfaced from `ClassExtractionConfig.qualifiedNodeId`.
*/
readonly qualifiedNodeId: boolean;
isTypeDeclaration(node: SyntaxNode): boolean;
extract(
node: SyntaxNode,
@@ -48,6 +55,14 @@ export interface ClassExtractionConfig {
typeDeclarationNodes: string[];
fileScopeNodeTypes?: string[];
ancestorScopeNodeTypes?: string[];
/**
* Opt-in (#1978): key this language's nested-type graph nodes (and their
* member-owner edges) by the fully-qualified path instead of the simple tail
* name, so same-tail nested types in one file stop colliding. Default false.
* Requires `ancestorScopeNodeTypes` to be set so `buildQualifiedName` can walk
* the scope chain.
*/
qualifiedNodeId?: boolean;
scopeNameNodeTypes?: string[];
extractName?: (node: SyntaxNode) => string | undefined;
extractType?: (node: SyntaxNode) => ClassLikeNodeLabel | undefined;
@@ -515,9 +515,24 @@ function emitCppInheritanceCaptures(root: SyntaxNode, out: CaptureMatch[], fileP
}
const baseName = extractBaseLookupName(base.node);
if (baseName.length === 0) continue;
// Preserve the qualified form (`Other::Inner`, template-stripped) when the
// source wrote one, so a same-tail nested base resolves to the matching
// qualified node instead of the first-inserted same-tail one (#1982). The
// bare `@reference.name` stays the V1 simple-name contract; the qualifier
// is an additive sidecar resolution tries first (see resolveInheritanceBaseInScope).
const qualifiedBaseName = extractQualifiedBaseName(base.node);
out.push({
'@reference.inherits': nodeToCapture('@reference.inherits', base.node),
'@reference.name': syntheticCapture('@reference.name', base.node, baseName),
...(qualifiedBaseName.length > 0 && qualifiedBaseName !== baseName
? {
'@reference.qualified-name': syntheticCapture(
'@reference.qualified-name',
base.node,
qualifiedBaseName,
),
}
: {}),
});
}
}
@@ -738,6 +753,41 @@ function extractBaseLookupName(baseNode: SyntaxNode): string {
return '';
}
/**
* Like `extractBaseLookupName` but PRESERVES the namespace/class qualifier
* (`Other::Inner`, `ns::v1::Base`) while stripping template arguments
* (`ns::Base<T>` → `ns::Base`). Returns `''` for shapes it can't qualify, and
* returns the bare name unchanged for an unqualified base (the emit site then
* skips the sidecar capture). Powers `@reference.qualified-name` so #1982
* resolution can pick the matching same-tail nested base via the full-path
* QualifiedNameIndex instead of the first-inserted same-tail sibling.
*/
function extractQualifiedBaseName(baseNode: SyntaxNode): string {
if (baseNode.type === 'template_type') {
const nameNode = baseNode.childForFieldName('name');
return nameNode !== null ? extractQualifiedBaseName(nameNode) : '';
}
if (baseNode.type === 'qualified_identifier') {
// No template args anywhere → the raw text already IS the qualified name.
if (!baseNode.text.includes('<')) return baseNode.text;
// Template args present: reconstruct scope::name, recursing to strip them.
const scopeNode = baseNode.childForFieldName('scope');
const nameNode = baseNode.childForFieldName('name');
const left = scopeNode !== null ? extractQualifiedBaseName(scopeNode) : '';
const right = nameNode !== null ? extractQualifiedBaseName(nameNode) : '';
if (left.length > 0 && right.length > 0) return `${left}::${right}`;
return right.length > 0 ? right : left;
}
if (
baseNode.type === 'namespace_identifier' ||
baseNode.type === 'type_identifier' ||
baseNode.type === 'identifier'
) {
return baseNode.text;
}
return '';
}
/** Extract the syntactic namespace qualifier from a base class node.
* For `detail::Inner<T>`, returns `'detail'`.
* For unqualified bases (`Inner<T>`, `Base<int>`), returns `''`.
@@ -12,6 +12,7 @@ import { recordRubyCacheHit, recordRubyCacheMiss } from './cache-stats.js';
import { synthesizeRubyReceiverBinding, findEnclosingClassOrModule } from './receiver-binding.js';
import { getTreeSitterBufferSize } from '../../constants.js';
import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js';
import { splitQualifiedName } from '../../utils/qualified-name.js';
const FUNCTION_NODE_TYPES = ['method', 'singleton_method'] as const;
const HERITAGE_CALL_NAMES: ReadonlySet<string> = new Set(['include', 'extend', 'prepend']);
@@ -21,6 +22,30 @@ const ATTR_CALL_NAMES: ReadonlySet<string> = new Set([
'attr_writer',
]);
/**
* Build the full `.`-joined qualified owner name for a heritage/attr call by
* walking ALL enclosing class/module ancestors (not just the immediate one),
* so a same-tail nested owner (`module Outer; class Inner`) is keyed by its
* full path `Outer.Inner` instead of the bare tail `Inner` — which otherwise
* collapses both same-tail owners onto one `__heritage__`/`__property__` marker
* key (last-wins) and cross-wires their mixin / attr_accessor edges (#1982).
* Handles the compact `class Outer::Inner` form (name is a `scope_resolution`)
* via the shared normalizer, so the marker owner byte-matches the resolution
* def's `qualifiedName`. Returns undefined when there is no enclosing class/module.
*/
function buildEnclosingQualifiedName(callNode: SyntaxNode): string | undefined {
const segments: string[] = [];
let current: SyntaxNode | null = callNode.parent;
while (current !== null) {
if (current.type === 'class' || current.type === 'module') {
const nameNode = current.childForFieldName('name');
if (nameNode !== null) segments.unshift(...splitQualifiedName(nameNode.text));
}
current = current.parent;
}
return segments.length > 0 ? segments.join('.') : undefined;
}
export function emitRubyScopeCaptures(
sourceText: string,
_filePath: string,
@@ -144,8 +169,7 @@ export function emitRubyScopeCaptures(
if (HERITAGE_CALL_NAMES.has(callName)) {
const callNode = nodeIfType(nodeMap['@reference.call.free'], 'call');
if (callNode !== null) {
const enclosing = findEnclosingClassOrModule(callNode);
const ownerName = enclosing?.childForFieldName('name')?.text;
const ownerName = buildEnclosingQualifiedName(callNode);
if (ownerName) {
const argList = callNode.childForFieldName('arguments');
if (argList !== null) {
@@ -178,8 +202,7 @@ export function emitRubyScopeCaptures(
if (ATTR_CALL_NAMES.has(callName)) {
const callNode = nodeIfType(nodeMap['@reference.call.free'], 'call');
if (callNode !== null) {
const enclosing = findEnclosingClassOrModule(callNode);
const ownerName = enclosing?.childForFieldName('name')?.text;
const ownerName = buildEnclosingQualifiedName(callNode);
if (ownerName) {
const argList = callNode.childForFieldName('arguments');
if (argList !== null) {
@@ -23,8 +23,13 @@ function emitRubyMixinEdges(
if (!isClassLike(def.type)) continue;
const graphId = resolveDefGraphId(parsed.filePath, def, nodeLookup);
if (graphId !== undefined) {
const simpleName = def.qualifiedName?.split('.').pop() ?? def.qualifiedName ?? '';
graphIdByName.set(simpleName, graphId);
// Key by the FULL qualified name (`Outer.Inner`), NOT the simple tail.
// Same-tail nested classes (`Outer::Inner` + `Other::Inner`) otherwise
// collapse onto one `Inner` key (last-wins) and cross-wire their mixin /
// attr_accessor owners (#1982). The `__heritage__`/`__property__` markers
// carry the full qualified owner name in lockstep (see ruby/captures.ts).
const fullName = def.qualifiedName ?? '';
if (fullName.length > 0) graphIdByName.set(fullName, graphId);
}
}
}
@@ -297,10 +297,16 @@ const cachedFindEnclosingClassInfo = (
node: SyntaxNode,
filePath: string,
resolveEnclosingOwner?: (node: SyntaxNode) => SyntaxNode | null,
getQualifiedOwnerName?: (node: SyntaxNode, simpleName: string) => string | null,
): EnclosingClassInfo | null => {
const cached = classInfoCache.get(node);
if (cached !== undefined) return cached;
const result = findEnclosingClassInfo(node, filePath, resolveEnclosingOwner);
const result = findEnclosingClassInfo(
node,
filePath,
resolveEnclosingOwner,
getQualifiedOwnerName,
);
classInfoCache.set(node, result);
return result;
};
@@ -602,24 +608,55 @@ const processParsingSequential = async (
nodeLabel === 'Constructor' ||
nodeLabel === 'Property' ||
nodeLabel === 'Function';
// #1978: when the language opts into qualified node ids, thread the
// class-extractor's qualifier into the enclosing-owner walk so a nested
// member resolves to its owner's *qualified* id (Outer.Inner) — matching
// the qualified class node id computed below. Gated on the flag, so the
// owner walk and its cache entry are byte-identical when the flag is off.
const getQualifiedOwnerName =
provider.classExtractor?.qualifiedNodeId === true
? (node: SyntaxNode, simpleName: string): string | null =>
provider.classExtractor!.extractQualifiedName(node, simpleName)
: undefined;
const enclosingClassInfo = needsOwner
? cachedFindEnclosingClassInfo(
nameNode || definitionNodeForRange,
file.path,
provider.resolveEnclosingOwner,
getQualifiedOwnerName,
)
: null;
const enclosingClassId = enclosingClassInfo?.classId ?? null;
const enclosingClassId =
enclosingClassInfo?.qualifiedClassId ?? enclosingClassInfo?.classId ?? null;
const objectLiteralOwnerInfo =
!enclosingClassId && nodeLabel === 'Method' && definitionNode
? findObjectLiteralBindingInfo(definitionNode, file.path)
: null;
// #1978: a class-like node opts into a fully-qualified node id (Outer.Inner)
// when the language enables qualifiedNodeId, so same-tail nested types in one
// file stay distinct. Hoisted ABOVE the node-id/qualifiedName use below and
// derived from the SAME extractQualifiedName the owner edge uses, so the
// member's owner id and the class node id agree. The order is load-bearing.
const classNodeForSymbol = definitionNodeForRange || definitionNode || nameNode;
const qualifiedTypeName =
extractedClassSymbol?.qualifiedName ??
(classNodeForSymbol && provider.classExtractor?.isTypeDeclaration(classNodeForSymbol)
? (provider.classExtractor.extractQualifiedName(classNodeForSymbol, nodeName) ?? nodeName)
: undefined);
// Qualify method/property IDs with enclosing class name to avoid collisions
// e.g. "Method:animal.dart:Animal.speak" vs "Method:animal.dart:Dog.speak"
const qualifiedName = enclosingClassInfo
? `${enclosingClassInfo.className}.${nodeName}`
: nodeName;
// e.g. "Method:animal.dart:Animal.speak" vs "Method:animal.dart:Dog.speak".
// Class-like nodes use their own fully-qualified path as the id key when the
// language enables qualifiedNodeId (#1978); everything else is unchanged.
const qualifiedName =
isClassLikeLabel &&
provider.classExtractor?.qualifiedNodeId === true &&
qualifiedTypeName !== undefined
? qualifiedTypeName
: enclosingClassInfo
? `${enclosingClassInfo.className}.${nodeName}`
: nodeName;
// Extract method metadata for Function/Method/Constructor nodes BEFORE generating
// the node ID — parameterCount is needed to disambiguate overloaded methods.
@@ -778,12 +815,6 @@ const processParsingSequential = async (
nodeLabel,
`${file.path}:${qualifiedName}${classTemplateTag}${arityTag}${constraintsTag}${parameterShapeTag}`,
);
const classNodeForSymbol = definitionNodeForRange || definitionNode || nameNode;
const qualifiedTypeName =
extractedClassSymbol?.qualifiedName ??
(classNodeForSymbol && provider.classExtractor?.isTypeDeclaration(classNodeForSymbol)
? (provider.classExtractor.extractQualifiedName(classNodeForSymbol, nodeName) ?? nodeName)
: undefined);
const frameworkHint = definitionNode
? detectFrameworkFromAST(language, (definitionNode.text || '').slice(0, 300))
: null;
@@ -991,6 +991,11 @@ function pass5CollectReferences(
if (kind === undefined) continue;
const nameCap = match['@reference.name'] ?? anchor;
// Optional qualified form of the reference (e.g. a C++ base `Other::Inner`),
// threaded to resolution so a same-tail nested base resolves to the correct
// sibling via the full-path QualifiedNameIndex before the simple-tail walk
// (#1982). Absent for unqualified references — resolution stays unchanged.
const qualifiedCap = match['@reference.qualified-name'];
const inScopeId = positionIndex.atPosition(
filePath,
anchor.range.startLine,
@@ -1014,6 +1019,9 @@ function pass5CollectReferences(
atRange: anchor.range,
inScope: inScopeId,
kind,
...(qualifiedCap?.text !== undefined && qualifiedCap.text.length > 0
? { rawQualifiedName: qualifiedCap.text }
: {}),
...(callForm !== undefined ? { callForm } : {}),
...(explicitReceiver !== undefined ? { explicitReceiver } : {}),
...(arity !== undefined ? { arity } : {}),
@@ -1133,6 +1141,7 @@ const KNOWN_SUB_TAGS: ReadonlySet<string> = new Set<string>([
'@type-binding.name',
'@type-binding.type',
'@reference.name',
'@reference.qualified-name',
'@reference.receiver',
'@reference.operator',
'@reference.arity',
@@ -131,7 +131,12 @@ function preEmitInheritanceEdges(
handledSites.add(siteKey);
}
const targetDef = resolveInheritanceBaseInScope(site.inScope, site.name, scopes);
const targetDef = resolveInheritanceBaseInScope(
site.inScope,
site.name,
scopes,
site.rawQualifiedName,
);
if (targetDef === undefined) continue;
const callerClass = findEnclosingClassDef(site.inScope, scopes);
@@ -24,6 +24,7 @@ import type { BindingRef, ParsedFile, ScopeId, SymbolDefinition, TypeRef } from
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
import type { SemanticModel } from '../../model/semantic-model.js';
import type { WorkspaceResolutionIndex } from '../workspace-index.js';
import { normalizeQualifiedName } from '../../utils/qualified-name.js';
const EMPTY_BINDINGS: readonly BindingRef[] = Object.freeze([]);
@@ -309,13 +310,85 @@ export function resolveInheritanceBaseInScope(
startScope: ScopeId,
baseName: string,
scopes: ScopeResolutionIndexes,
rawQualifiedName?: string,
): SymbolDefinition | undefined {
// #1982: when the source wrote a qualified base (`Other::Inner`), resolve it
// against the full-path QualifiedNameIndex FIRST, so a same-tail nested base
// binds to the matching sibling instead of the first-inserted one that the
// simple-tail scope walk picks. Falls through to the existing walk when the
// base is unqualified, unknown, or the qualified lookup can't pick a unique
// winner — so unqualified bases and the cross-file single-candidate case are
// unchanged.
if (rawQualifiedName !== undefined) {
const qualified = resolveQualifiedInheritanceBase(startScope, rawQualifiedName, scopes);
if (qualified !== undefined) return qualified;
}
return (
findClassBindingInScope(startScope, baseName, scopes) ??
resolveAmbiguousInheritanceBaseViaImports(startScope, baseName, scopes)
);
}
/**
* Resolve a qualified inheritance base (`Other::Inner`, `ns::Base`) against the
* full-path `QualifiedNameIndex` (keyed by `def.qualifiedName`, which carries
* the promoted dotted path post-`populateOwners`). Tries the referencing site's
* enclosing-scope segments as progressive prefixes (longest first) before the
* root-anchored qualifier, so a *relative* base like `Outer::Inner` written
* inside `namespace NS` resolves to the root-anchored key `NS.Outer.Inner`.
* Returns a unique class-like def, or `undefined` when the base is unqualified,
* unknown, or genuinely ambiguous at a key (refuse-on-tie — never guess; a
* wrong EXTENDS edge silently corrupts impact analysis).
*/
function resolveQualifiedInheritanceBase(
startScope: ScopeId,
rawQualifiedName: string,
scopes: ScopeResolutionIndexes,
): SymbolDefinition | undefined {
const normalized = normalizeQualifiedName(rawQualifiedName);
// No qualifier after normalization → nothing the simple-tail walk doesn't do.
if (normalized.length === 0 || !normalized.includes('.')) return undefined;
const enclosing = enclosingScopeSegments(startScope, scopes);
// Candidate keys: longest enclosing prefix first, then the root-anchored form.
const keys: string[] = [];
for (let i = enclosing.length; i >= 1; i--) {
keys.push([...enclosing.slice(0, i), normalized].join('.'));
}
keys.push(normalized);
for (const key of keys) {
const ids = scopes.qualifiedNames.get(key);
if (ids.length === 0) continue;
let unique: SymbolDefinition | undefined;
let count = 0;
for (const id of ids) {
const def = scopes.defs.get(id);
if (def !== undefined && isClassLike(def.type)) {
unique = def;
count++;
}
}
if (count === 1) return unique;
if (count > 1) return undefined; // genuine tie at this key → refuse, don't guess
}
return undefined;
}
/**
* Enclosing scope segments of an inheritance site, derived from the deriving
* (child) class def's `qualifiedName` minus its own tail. For child
* `NS.Other.Derived` this is `['NS', 'Other']`; empty for a file-scope child.
* Used to build progressive-prefix lookup keys for relative qualified bases.
*/
function enclosingScopeSegments(startScope: ScopeId, scopes: ScopeResolutionIndexes): string[] {
const child = findEnclosingClassDef(startScope, scopes);
const q = child?.qualifiedName;
if (q === undefined || q.length === 0) return [];
const segs = q.split('.').filter(Boolean);
return segs.slice(0, -1);
}
/**
* Import/include-aware disambiguation for an *ambiguous* class-like base
* name. Engages ONLY as a fallback after `findClassBindingInScope` has
@@ -321,6 +321,15 @@ export function getLabelFromCaptures(
export interface EnclosingClassInfo {
classId: string; // e.g. "Class:animal.dart:Animal"
className: string; // e.g. "Animal"
/**
* The owner node id keyed by the enclosing type's FULLY-QUALIFIED path
* (e.g. "Class:file:Outer.Inner"), present only when the language opts into
* `qualifiedNodeId` AND the enclosing type is actually nested (#1978).
* Consumers building HAS_METHOD/HAS_PROPERTY owner edges use this in
* preference to `classId` so the edge source matches the qualified class
* node id. When absent, `classId` (the simple-tail key) is unchanged.
*/
qualifiedClassId?: string;
}
/** Walk up AST to find enclosing class/struct/interface/impl, return its ID and name.
@@ -345,6 +354,16 @@ export const findEnclosingClassInfo = (
node: SyntaxNode,
filePath: string,
resolveEnclosingOwner?: (node: SyntaxNode) => SyntaxNode | null,
/**
* Optional (#1978): returns the enclosing type's fully-qualified name
* (e.g. "Outer.Inner") for a type-declaration container, or null. Callers
* pass `classExtractor.extractQualifiedName` ONLY when the language's
* `qualifiedNodeId` flag is on — so when omitted, behavior is byte-identical
* to before (qualifiedClassId stays undefined). Used by the standard
* class-container branch to compute `qualifiedClassId` from the SAME function
* the node-id is built from, guaranteeing owner-id == node-id by construction.
*/
getQualifiedOwnerName?: (node: SyntaxNode, simpleName: string) => string | null,
): EnclosingClassInfo | null => {
let current = node.parent;
let iterations = 0;
@@ -485,9 +504,29 @@ export const findEnclosingClassInfo = (
templateArguments !== undefined
? `${stripTemplateArguments(nameNode.text)}${templateArgumentsIdTag(templateArguments)}`
: nameNode.text;
// #1978: when the language opts into qualified node ids, key the owner
// edge by the enclosing type's qualified path (e.g. "Outer.Inner") so it
// matches the qualified class node id. Derived from the SAME
// extractQualifiedName the node-id uses → agree by construction. Only set
// when actually nested (qualified !== simple); top-level types are
// unchanged. (Go receiver / Rust impl branches return earlier and are
// intentionally untouched here.)
const qualifiedOwnerName = getQualifiedOwnerName?.(current, nameNode.text);
const qualifiedClassId =
qualifiedOwnerName != null && qualifiedOwnerName !== nameNode.text
? generateId(
label,
`${filePath}:${
templateArguments !== undefined
? `${stripTemplateArguments(qualifiedOwnerName)}${templateArgumentsIdTag(templateArguments)}`
: qualifiedOwnerName
}`,
)
: undefined;
return {
classId: generateId(label, `${filePath}:${classIdName}`),
className: nameNode.text,
...(qualifiedClassId !== undefined ? { qualifiedClassId } : {}),
};
}
}
@@ -0,0 +1,43 @@
/**
* Shared qualified-name normalization.
*
* One canonical transform from a raw, language-specific qualified name
* (`Other::Inner`, `pkg\Sub\Type`, ` A . B `) to the `.`-joined form the
* graph and the `QualifiedNameIndex` are keyed by (`Other.Inner`, `pkg.Sub.Type`,
* `A.B`). Extracted from `class-extractors/generic.ts` so the structure-phase
* `buildQualifiedName`, the scope-resolution inheritance resolver, and the
* per-language capture emitters all key against ONE normalizer — a raw `::`
* qualifier must normalize to the exact key the index already holds, or the
* qualified lookup silently misses (issue #1982).
*
* Do NOT confuse with `heritage-extractors/supertype-alternation.ts`'s
* `simplifyRawName`, which collapses a qualified name to its LAST segment
* (`Other::Inner` → `Inner`) — that is a tail extractor, not a normalizer, and
* using it as a lookup key guarantees a miss.
*
* Pure string functions; no AST or tree-sitter dependency.
*/
/**
* Normalize a raw qualified name to the `.`-joined canonical form:
* strips whitespace, converts `::` and `\` separators to `.`, collapses
* repeated dots, and trims leading/trailing dots.
*/
export const normalizeQualifiedName = (value: string): string =>
value
.replace(/\s+/g, '')
.replace(/^::/, '')
.replace(/::/g, '.')
.replace(/\\/g, '.')
.replace(/\.+/g, '.')
.replace(/^\.+|\.+$/g, '');
/**
* Split a raw qualified name into its normalized, non-empty segments
* (`Other::Inner` → `['Other', 'Inner']`). Returns `[]` for an empty or
* separator-only input.
*/
export const splitQualifiedName = (value: string): string[] => {
const normalized = normalizeQualifiedName(value);
return normalized ? normalized.split('.').filter(Boolean) : [];
};
@@ -753,11 +753,17 @@ const cachedFindEnclosingClassInfo = (
node: SyntaxNode,
filePath: string,
resolveEnclosingOwner?: (node: SyntaxNode) => SyntaxNode | null,
getQualifiedOwnerName?: (node: SyntaxNode, simpleName: string) => string | null,
): EnclosingClassInfo | null => {
const cached = classIdCache.get(node);
if (cached !== undefined) return cached;
const result = findEnclosingClassInfo(node, filePath, resolveEnclosingOwner);
const result = findEnclosingClassInfo(
node,
filePath,
resolveEnclosingOwner,
getQualifiedOwnerName,
);
classIdCache.set(node, result);
return result;
};
@@ -1517,12 +1523,23 @@ const processFileGroup = (
}
if (routed.kind === 'properties') {
// #1978: thread the qualifier so a routed property's owner edge
// points at the *qualified* nested-class node (Outer.Inner) rather
// than a now-nonexistent simple `Class:file:Inner` id. Gated on the
// flag → byte-identical when off. Mirrors the main owner path.
const propGetQualifiedOwnerName =
provider.classExtractor?.qualifiedNodeId === true
? (node: SyntaxNode, simpleName: string): string | null =>
provider.classExtractor!.extractQualifiedName(node, simpleName)
: undefined;
const propEnclosingInfo = cachedFindEnclosingClassInfo(
captureMap['call'],
file.path,
provider.resolveEnclosingOwner,
propGetQualifiedOwnerName,
);
const propEnclosingClassId = propEnclosingInfo?.classId ?? null;
const propEnclosingClassId =
propEnclosingInfo?.qualifiedClassId ?? propEnclosingInfo?.classId ?? null;
// Enrich routed properties with FieldExtractor metadata
let routedFieldMap: Map<string, FieldInfo> | undefined;
if (provider.fieldExtractor && typeEnv) {
@@ -1803,23 +1820,51 @@ const processFileGroup = (
nodeLabel === 'Constructor' ||
nodeLabel === 'Property' ||
nodeLabel === 'Function';
// #1978: thread the class-extractor's qualifier into the owner walk when the
// language opts into qualified node ids, so a nested member's owner resolves
// to the *qualified* class id (Outer.Inner). Gated on the flag → byte-identical
// when off. Mirrors parsing-processor.ts.
const getQualifiedOwnerName =
provider.classExtractor?.qualifiedNodeId === true
? (node: SyntaxNode, simpleName: string): string | null =>
provider.classExtractor!.extractQualifiedName(node, simpleName)
: undefined;
const enclosingClassInfo = needsOwner
? cachedFindEnclosingClassInfo(
nameNode || definitionNode,
file.path,
provider.resolveEnclosingOwner,
getQualifiedOwnerName,
)
: null;
const enclosingClassId = enclosingClassInfo?.classId ?? null;
const enclosingClassId =
enclosingClassInfo?.qualifiedClassId ?? enclosingClassInfo?.classId ?? null;
const objectLiteralOwnerInfo =
!enclosingClassId && nodeLabel === 'Method' && definitionNode
? findObjectLiteralBindingInfo(definitionNode, file.path)
: null;
// Qualify method/property IDs with enclosing class name to avoid collisions
const qualifiedName = enclosingClassInfo
? `${enclosingClassInfo.className}.${nodeName}`
: nodeName;
// #1978: hoisted ABOVE qualifiedName/node-id (load-bearing order) so a
// class-like node can key its id by its fully-qualified path. Derived from
// the SAME extractQualifiedName the owner edge uses → owner id == node id.
const classNodeForSymbol = definitionNode || nameNode;
const qualifiedTypeName =
extractedClassSymbol?.qualifiedName ??
(classNodeForSymbol && provider.classExtractor?.isTypeDeclaration(classNodeForSymbol)
? (provider.classExtractor.extractQualifiedName(classNodeForSymbol, nodeName) ?? nodeName)
: undefined);
// Qualify method/property IDs with enclosing class name to avoid collisions.
// Class-like nodes use their own fully-qualified path as the id key when the
// language enables qualifiedNodeId (#1978); everything else is unchanged.
const qualifiedName =
isClassLikeLabel &&
provider.classExtractor?.qualifiedNodeId === true &&
qualifiedTypeName !== undefined
? qualifiedTypeName
: enclosingClassInfo
? `${enclosingClassInfo.className}.${nodeName}`
: nodeName;
// Extract method metadata BEFORE generating node ID — parameterCount is needed
// to disambiguate overloaded methods via #<arity> suffix in the ID.
@@ -1922,12 +1967,6 @@ const processFileGroup = (
nodeLabel,
`${file.path}:${qualifiedName}${classTemplateTag}${arityTag}${parameterShapeTag}`,
);
const classNodeForSymbol = definitionNode || nameNode;
const qualifiedTypeName =
extractedClassSymbol?.qualifiedName ??
(classNodeForSymbol && provider.classExtractor?.isTypeDeclaration(classNodeForSymbol)
? (provider.classExtractor.extractQualifiedName(classNodeForSymbol, nodeName) ?? nodeName)
: undefined);
const description = provider.descriptionExtractor?.(nodeLabel, nodeName, captureMap);
@@ -0,0 +1,15 @@
struct Outer {
struct Inner {
void from_outer() {}
int outer_field;
};
};
struct Other {
struct Inner {
void from_other() {}
};
};
// #1982 same-tail heritage: each base is fully qualified, so the EXTENDS edge
// must resolve to the matching nested node, not the first-inserted same-tail one.
struct DerivedA : Outer::Inner {};
struct DerivedB : Other::Inner {};
@@ -0,0 +1,25 @@
module OuterMix; end
module OtherMix; end
module Outer
class Inner
include OuterMix
attr_accessor :outer_attr
def from_outer; end
end
end
module Other
class Inner
include OtherMix
attr_accessor :other_attr
def from_other; end
end
end
# Unambiguous nested class (no same-tail sibling): exercises the routed-property
# (attr_accessor) owner path, which must resolve to the QUALIFIED owner and not
# dangle under qualifiedNodeId. Same-tail routed-property owner identity is a
# separate resolution-side concern (see ruby.test.ts).
module Shapes
class Circle
attr_accessor :radius
end
end
@@ -0,0 +1,12 @@
pub mod outer {
pub struct Inner;
impl Inner {
pub fn from_outer(&self) {}
}
}
pub mod other {
pub struct Inner;
impl Inner {
pub fn from_other(&self) {}
}
}
@@ -207,6 +207,10 @@
"captureGroups": 16,
"digest": "34e07387fece6c1d2deb49c39fc2bfe0badfe8015dd1f7ae956d57ac98322a1d"
},
"ruby-nested-tail-collision/nested.rb": {
"captureGroups": 31,
"digest": "c48ebe5516a0faf50effbad0a19fe29be70c371b50ba9d6fa6ae3f6f708b3a4e"
},
"ruby-overload-dispatch/lib/app.rb": {
"captureGroups": 10,
"digest": "288d5386cf37fb76b52a94bc7da6bf8e7843830ebbb01fcd8100d1590c0e3f72"
@@ -303,6 +303,10 @@
"captureGroups": 18,
"digest": "3326eb4f82b1559b6afec497dc52cab734e6f3209501a4bd982bf5eab9ec6dba"
},
"rust-nested-tail-collision/lib.rs": {
"captureGroups": 17,
"digest": "2fc1fe1eb4e8727a89ab283ae34a0ae8df0c421551a7bd5e6e7ffb9d4aa54189"
},
"rust-nullable-receiver/src/main.rs": {
"captureGroups": 37,
"digest": "283d8606eb837f2a9e5fdf95a30e3da5b73d4c14d74b94deb03e924dd2b2fde1"
+154 -9
View File
@@ -3733,12 +3733,16 @@ describe('C++ SFINAE filter — arity gate runs before constraint filter', () =>
// ---------------------------------------------------------------------------
// Out-of-line nested definitions — method ownership + collision (issue #1975)
//
// `struct Outer::Inner { ... }` (name = qualified_identifier) now materializes a
// node keyed by the full scoped text, so its methods own through a real node.
// Crucially, a same-tail type in another scope (Other::Inner) stays a DISTINCT
// node — no merge, no method mis-attribution. (A redundant forward-decl node
// `Inner` also exists; the pre-existing inline same-tail node collision is
// tracked separately in #1978.)
// `struct Outer::Inner { ... }` (name = qualified_identifier) and its in-class
// forward declaration `struct Outer { struct Inner; }` are the SAME type. Once
// qualified node ids are on (#1978), both key to one canonical node whose
// qualifiedName is the normalized scope path `Outer.Inner` — so the forward
// decl and the out-of-line definition correctly UNIFY instead of producing two
// redundant nodes (the pre-#1978 base kept them separate). Crucially, a
// same-tail type in another scope (`Other::Inner`) stays a DISTINCT node — no
// merge, no method mis-attribution. Owner identity is asserted on the
// qualifiedName + distinct node id (the real key), not the simple `name`
// (which is just the tail `Inner` for both, by design).
// ---------------------------------------------------------------------------
describe('C++ out-of-line nested definitions — ownership + collision (issue #1975)', () => {
@@ -3760,8 +3764,149 @@ describe('C++ out-of-line nested definitions — ownership + collision (issue #1
const other = hasMethod.find((e) => e.target === 'from_other');
expect(outer).toBeDefined();
expect(other).toBeDefined();
expect(outer!.source).toBe('Outer::Inner');
expect(other!.source).toBe('Other::Inner');
expect(outer!.source).not.toBe(other!.source);
const ownerQn = (e: typeof outer) =>
result.graph.getNode(e!.rel.sourceId)?.properties.qualifiedName;
expect(ownerQn(outer)).toBe('Outer.Inner');
expect(ownerQn(other)).toBe('Other.Inner');
expect(outer!.rel.sourceId).not.toBe(other!.rel.sourceId);
// Discriminator: with qualifiedNodeId ON the owner node id is keyed by the
// NORMALIZED dotted path (Struct:...:Outer.Inner); with the fix OFF the
// out-of-line node is keyed by the raw scoped text (...:Outer::Inner). The
// `qualifiedName` PROPERTY is normalized either way, so assert on the id to
// actually prove the fix is engaged (test-soundness, workflow finding #5).
expect(outer!.rel.sourceId).toContain('Outer.Inner');
expect(outer!.rel.sourceId).not.toContain('::');
expect(other!.rel.sourceId).not.toContain('::');
});
});
// ---------------------------------------------------------------------------
// Inline nested same-tail collision — distinct qualified nodes (issue #1978)
//
// `struct Outer { struct Inner {...} }` + `struct Other { struct Inner {...} }`
// must materialize TWO distinct Struct nodes (qn Outer.Inner vs Other.Inner),
// each owning its own method/field. On the pre-fix base both Inner structs
// merge into one simple-keyed node and the methods cross-wire (dangling:0 but
// wrong). Asserts positive owner-identity via the resolved node's qualifiedName,
// not just dangle-free (R7). Distinct from the #1977 out-of-line case above.
// ---------------------------------------------------------------------------
describe('C++ inline nested same-tail collision — distinct qualified nodes (issue #1978)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'cpp-nested-tail-collision'), () => {});
}, 60000);
it('materializes Outer.Inner and Other.Inner as two distinct Struct nodes', () => {
const qns = getNodesByLabelFull(result, 'Struct')
.map((n) => n.properties.qualifiedName)
.filter((q) => q === 'Outer.Inner' || q === 'Other.Inner')
.sort();
expect(qns).toEqual(['Other.Inner', 'Outer.Inner']);
});
it('owns from_outer / from_other through their OWN distinct node (positive identity, R7)', () => {
expect(findDanglingEdges(result, ['HAS_METHOD', 'HAS_PROPERTY'])).toEqual([]);
const hm = getRelationships(result, 'HAS_METHOD');
const ownerQn = (target: string) => {
const e = hm.find((x) => x.target === target);
expect(e, `HAS_METHOD -> ${target}`).toBeDefined();
return result.graph.getNode(e!.rel.sourceId)?.properties.qualifiedName;
};
expect(ownerQn('from_outer')).toBe('Outer.Inner');
expect(ownerQn('from_other')).toBe('Other.Inner');
});
it('owns outer_field under Outer.Inner (struct field via the main HAS_PROPERTY path)', () => {
const hp = getRelationships(result, 'HAS_PROPERTY');
const e = hp.find((x) => x.target === 'outer_field');
expect(e).toBeDefined();
expect(result.graph.getNode(e!.rel.sourceId)?.properties.qualifiedName).toBe('Outer.Inner');
});
});
// Same collision fixture, forced through the WORKER pool (parse-worker.ts) rather
// than the sequential parsing-processor.ts. Production parses repos >= 15 files via
// the pool, so the qualified node-id + owner-edge logic must hold on BOTH paths
// (workflow finding #4: the #1978 fixtures otherwise only exercise the sequential
// path). Asserts worker == sequential for the distinct-node + owner outcome.
describe('C++ inline nested same-tail collision — worker path parity (issue #1978)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'cpp-nested-tail-collision'), () => {}, {
// Force the worker-pool gate low so the 1-file fixture engages the pool.
workerThresholdsForTest: { minFiles: 1, minBytes: 1 },
workerPoolSize: 2,
});
}, 120000);
it('genuinely used the worker pool (guards against silent sequential fallback)', () => {
expect(result.usedWorkerPool).toBe(true);
});
it('materializes two distinct Struct nodes and owns each method correctly (R7)', () => {
const qns = getNodesByLabelFull(result, 'Struct')
.map((n) => n.properties.qualifiedName)
.filter((q) => q === 'Outer.Inner' || q === 'Other.Inner')
.sort();
expect(qns).toEqual(['Other.Inner', 'Outer.Inner']);
expect(findDanglingEdges(result, ['HAS_METHOD', 'HAS_PROPERTY'])).toEqual([]);
const hm = getRelationships(result, 'HAS_METHOD');
const ownerQn = (target: string) =>
result.graph.getNode(hm.find((x) => x.target === target)!.rel.sourceId)?.properties
.qualifiedName;
expect(ownerQn('from_outer')).toBe('Outer.Inner');
expect(ownerQn('from_other')).toBe('Other.Inner');
});
it('resolves DerivedB : Other::Inner → EXTENDS Other.Inner on the worker path (#1982: rawQualifiedName survives worker serialization)', () => {
const e = getRelationships(result, 'EXTENDS').find(
(x) => result.graph.getNode(x.rel.sourceId)?.properties.qualifiedName === 'DerivedB',
);
expect(e, 'DerivedB EXTENDS edge (worker path)').toBeDefined();
expect(e!.rel.targetId).toContain('Other.Inner');
expect(e!.rel.targetId).not.toContain('Outer.Inner');
});
});
// ---------------------------------------------------------------------------
// Inline nested same-tail HERITAGE — qualified base resolution (issue #1982)
//
// `struct DerivedA : Outer::Inner` + `struct DerivedB : Other::Inner` must each
// resolve EXTENDS to the MATCHING nested node. On the registry-primary base the
// qualifier is discarded (cpp/captures.ts emits the bare tail `Inner`), so
// resolveInheritanceBaseInScope sees an ambiguous same-tail base. Asserts the
// resolved EXTENDS endpoint's id contains the right qn (KTD-4: assert on the
// node id, not the property). Registry-primary only (legacy leg expected-fail).
// ---------------------------------------------------------------------------
describe('C++ inline nested same-tail heritage — qualified base (issue #1982)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'cpp-nested-tail-collision'), () => {});
}, 60000);
const extendsTargetIdOf = (childQn: string): string | undefined => {
const ext = getRelationships(result, 'EXTENDS');
const e = ext.find(
(x) => result.graph.getNode(x.rel.sourceId)?.properties.qualifiedName === childQn,
);
return e?.rel.targetId;
};
it('resolves DerivedA : Outer::Inner → EXTENDS the Outer.Inner node', () => {
const tid = extendsTargetIdOf('DerivedA');
expect(tid, 'DerivedA EXTENDS endpoint').toBeDefined();
expect(tid).toContain('Outer.Inner');
expect(tid).not.toContain('Other.Inner');
});
it('resolves DerivedB : Other::Inner → EXTENDS the Other.Inner node (not Outer.Inner)', () => {
const tid = extendsTargetIdOf('DerivedB');
expect(tid, 'DerivedB EXTENDS endpoint').toBeDefined();
expect(tid).toContain('Other.Inner');
expect(tid).not.toContain('Outer.Inner');
});
});
+36 -2
View File
@@ -255,8 +255,23 @@ const LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES: Readonly<Record<string, Readonly
ruby: new Set<string>([
// Ruby scope-resolution currently achieves 89/127 parity.
// Tests listed here are scope-resolver-only correctness wins
// (pass under registry-primary, fail under legacy). Currently
// empty — all 127 tests pass under legacy mode.
// (pass under registry-primary, fail under legacy).
//
// #1978 qualified nested-type node identity. NOTE: these PASS under the
// legacy leg too — the fix is in the SHARED structure phase, not the legacy
// resolution path. They are excluded here by policy to keep the #1978
// assertions registry-primary-only and avoid coupling the legacy parity leg
// to the new node-identity behavior.
'owns from_outer / from_other through distinct Outer.Inner / Other.Inner nodes (R7)',
'owns radius (attr_accessor) under the qualified Shapes.Circle node, no dangling (R7)',
// #1982 RESOLUTION-side same-tail owner identity. The registry-primary
// emitRubyMixinEdges bridge keys its owner map by full qualifiedName and the
// captures emit the full enclosing-scope owner; the legacy DAG does not use
// that bridge, so these are registry-primary-only by design.
'owns outer_attr / other_attr under their OWN qualified Inner node (same-tail attr_accessor, R7)',
'routes include OuterMix / OtherMix to their OWN qualified Inner owner (same-tail mixin, R7)',
'genuinely used the worker pool for the same-tail Ruby fixture',
'owns outer_attr / other_attr under their OWN qualified Inner node on the worker path (no duplicate, R7)',
]),
swift: new Set<string>([
// Swift scope-resolution achieves 77/77 baseline parity. The tests
@@ -481,6 +496,25 @@ const LEGACY_RESOLVER_PARITY_EXPECTED_FAILURES: Readonly<Record<string, Readonly
// sidecars and scope-resolver overload narrowing. The legacy DAG does not
// rank function-template shapes, so it leaves the call unresolved.
'pick(T*) wins over pick(T) for pointer arguments',
// #1978 qualified nested-type node identity. NOTE: unlike the entries above,
// these PASS under the legacy leg too — the fix is in the SHARED structure
// phase, not the legacy resolution path, so the legacy DAG is untouched and
// still produces the qualified nodes. They are excluded here by policy to
// keep the #1978 assertions registry-primary-only and avoid coupling the
// legacy parity leg to the new node-identity behavior.
'materializes Outer.Inner and Other.Inner as two distinct Struct nodes',
'owns from_outer / from_other through their OWN distinct node (positive identity, R7)',
'owns outer_field under Outer.Inner (struct field via the main HAS_PROPERTY path)',
'genuinely used the worker pool (guards against silent sequential fallback)',
'materializes two distinct Struct nodes and owns each method correctly (R7)',
// #1982 RESOLUTION-side same-tail heritage. Unlike the structure-phase
// entries above, these exercise the registry-primary inheritance resolver
// (preEmitInheritanceEdges → resolveInheritanceBaseInScope qualified-first),
// which the legacy DAG does not use — so they are registry-primary-only by
// design and skipped on the legacy leg per the #1978/#1982 policy.
'resolves DerivedA : Outer::Inner → EXTENDS the Outer.Inner node',
'resolves DerivedB : Other::Inner → EXTENDS the Other.Inner node (not Outer.Inner)',
'resolves DerivedB : Other::Inner → EXTENDS Other.Inner on the worker path (#1982: rawQualifiedName survives worker serialization)',
]),
};
@@ -1508,3 +1508,135 @@ describe('Ruby cross-namespace tail collision — distinct nodes (issue #1975)',
expect(hasMethod.some((e) => e.target === 'from_baz' && e.sourceLabel === 'Class')).toBe(true);
});
});
// ---------------------------------------------------------------------------
// Inline module-nested same-tail collision — distinct nodes (issue #1978)
//
// `module Outer; class Inner; end; end` + `module Other; class Inner; end; end`
// must own their methods through TWO distinct Class nodes (qn Outer.Inner vs
// Other.Inner). On the pre-fix base both Inner classes merge into one
// simple-keyed node and from_outer/from_other cross-wire (dangling:0 but wrong).
// Asserts positive owner-identity by the resolved node's qualifiedName (R7).
// (Distinct from the compact `Foo::Bar` collision block above, which #1977 fixed.)
// ---------------------------------------------------------------------------
describe('Ruby inline module-nested same-tail collision — distinct nodes (issue #1978)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'ruby-nested-tail-collision'), () => {});
}, 60000);
pit('owns from_outer / from_other through distinct Outer.Inner / Other.Inner nodes (R7)', () => {
expect(findDanglingEdges(result, ['HAS_METHOD'])).toEqual([]);
const hm = getRelationships(result, 'HAS_METHOD');
const ownerQn = (target: string) => {
const e = hm.find((x) => x.target === target);
expect(e, `HAS_METHOD -> ${target}`).toBeDefined();
return result.graph.getNode(e!.rel.sourceId)?.properties.qualifiedName;
};
expect(ownerQn('from_outer')).toBe('Outer.Inner');
expect(ownerQn('from_other')).toBe('Other.Inner');
});
// attr_accessor routes through the property-registration pre-pass — a SEPARATE
// code path from `def` methods: call-processor.ts (sequential/legacy) and the
// parse-worker `kind === 'properties'` block (worker). Under qualifiedNodeId the
// owner must resolve to the QUALIFIED class node (Shapes.Circle); the pre-fix
// simple `Class:f.rb:Circle` no longer exists and would dangle. Exercised here
// on an UNAMBIGUOUS nested class (no same-tail sibling) so the assertion is
// exact on both legs.
//
// NOTE: exact owner identity for a routed property under SAME-TAIL nested types
// (e.g. two `Inner` classes) is a separate resolution-side concern — the
// registry-primary `emitRubyMixinEdges` bridge resolves the owner by simple
// tail name (last-wins) and the worker path can emit a duplicate cross-wired
// edge. That is deferred to the #1978 resolution-side follow-up; the
// structure-phase HAS_METHOD ownership above is already exact on both legs.
pit(
'owns radius (attr_accessor) under the qualified Shapes.Circle node, no dangling (R7)',
() => {
expect(findDanglingEdges(result, ['HAS_PROPERTY'])).toEqual([]);
const hp = getRelationships(result, 'HAS_PROPERTY');
const e = hp.find((x) => x.target === 'radius');
expect(e, 'HAS_PROPERTY -> radius').toBeDefined();
expect(result.graph.getNode(e!.rel.sourceId)?.properties.qualifiedName).toBe('Shapes.Circle');
},
);
// #1982 resolution-side: SAME-TAIL routed-property owner identity. The
// pre-fix emitRubyMixinEdges keys its owner map by simple tail (last-wins),
// so outer_attr / other_attr both attach to whichever `Inner` was processed
// last. Asserts each routes to its OWN qualified node by qualifiedName, with
// exactly one (non-duplicated) edge. Registry-primary only.
pit(
'owns outer_attr / other_attr under their OWN qualified Inner node (same-tail attr_accessor, R7)',
() => {
const hp = getRelationships(result, 'HAS_PROPERTY');
const ownerQnOf = (prop: string) => {
const e = hp.find((x) => x.target === prop);
expect(e, `HAS_PROPERTY -> ${prop}`).toBeDefined();
return result.graph.getNode(e!.rel.sourceId)?.properties.qualifiedName;
};
expect(ownerQnOf('outer_attr')).toBe('Outer.Inner');
expect(ownerQnOf('other_attr')).toBe('Other.Inner');
expect(hp.filter((x) => x.target === 'outer_attr')).toHaveLength(1);
expect(hp.filter((x) => x.target === 'other_attr')).toHaveLength(1);
},
);
// #1982 resolution-side: SAME-TAIL mixin owner identity (IMPLEMENTS).
pit(
'routes include OuterMix / OtherMix to their OWN qualified Inner owner (same-tail mixin, R7)',
() => {
const impl = getRelationships(result, 'IMPLEMENTS');
const ownerQnOfMixin = (mixinName: string) => {
const e = impl.find((x) => x.target === mixinName);
expect(e, `IMPLEMENTS -> ${mixinName}`).toBeDefined();
return result.graph.getNode(e!.rel.sourceId)?.properties.qualifiedName;
};
expect(ownerQnOfMixin('OuterMix')).toBe('Outer.Inner');
expect(ownerQnOfMixin('OtherMix')).toBe('Other.Inner');
},
);
});
// Same fixture through the WORKER pool. The deferred note flagged that the worker
// path could emit a DUPLICATE cross-wired same-tail owner edge (the worker emits
// the __property__/__heritage__ markers, which must now carry the full qualified
// owner). Asserts worker == sequential: each attr owns its OWN qualified node with
// exactly one edge (#1982 R7). Registry-primary only.
describe('Ruby inline module-nested same-tail collision — worker path parity (issue #1982)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'ruby-nested-tail-collision'),
() => {},
{
workerThresholdsForTest: { minFiles: 1, minBytes: 1 },
workerPoolSize: 2,
},
);
}, 120000);
pit('genuinely used the worker pool for the same-tail Ruby fixture', () => {
expect(result.usedWorkerPool).toBe(true);
});
pit(
'owns outer_attr / other_attr under their OWN qualified Inner node on the worker path (no duplicate, R7)',
() => {
const hp = getRelationships(result, 'HAS_PROPERTY');
const ownerQnOf = (prop: string) => {
const e = hp.find((x) => x.target === prop);
expect(e, `HAS_PROPERTY -> ${prop}`).toBeDefined();
return result.graph.getNode(e!.rel.sourceId)?.properties.qualifiedName;
};
expect(ownerQnOf('outer_attr')).toBe('Outer.Inner');
expect(ownerQnOf('other_attr')).toBe('Other.Inner');
expect(hp.filter((x) => x.target === 'outer_attr')).toHaveLength(1);
expect(hp.filter((x) => x.target === 'other_attr')).toHaveLength(1);
},
);
});
@@ -2047,3 +2047,43 @@ describe('Rust scoped inherent impl — ownership + collision (issue #1975)', ()
expect(fromA!.source).not.toBe(fromB!.source);
});
});
// ---------------------------------------------------------------------------
// Inline mod-nested same-tail collision — distinct nodes (issue #1978)
//
// `mod outer { struct Inner; impl Inner }` + `mod other { struct Inner; impl Inner }`
// must own their methods through TWO distinct nodes. On the pre-fix base both
// `Inner` structs merge into one simple-keyed node and from_outer/from_other
// cross-wire onto it (dangling:0 but wrong). Asserts the two methods resolve to
// DISTINCT owner node ids (R7), not just dangle-free.
//
// DEFERRED (skip): the generic qualifiedNodeId mechanism (#1978) qualifies
// class-like *type declarations* via the class-extractor. Rust methods live in
// `impl Inner` blocks, and the inherent-impl owner branch in ast-helpers keys
// the Impl node by the impl target's RAW text ("Inner") and returns BEFORE the
// generic qualified-owner path — so it can't reuse `extractQualifiedName` (an
// `impl_item` isn't a typeDeclaration). Qualifying the impl target by its
// enclosing `mod` scope, plus matching it on the registry-primary graph bridge,
// is separate machinery tracked as a follow-up. C++/Ruby land first (KTD-6).
// ---------------------------------------------------------------------------
// Skipped: Rust inherent-impl ownership is deferred to the resolution-side
// follow-up (see the comment block above). Tracked in the #1978 follow-up issue.
describe.skip('Rust inline mod-nested same-tail collision — distinct nodes (issue #1978)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(path.join(FIXTURES, 'rust-nested-tail-collision'), () => {});
}, 60000);
it('owns from_outer / from_other through distinct nodes (no merge, no mis-attribution)', () => {
expect(findDanglingEdges(result, ['HAS_METHOD'])).toEqual([]);
const hm = getRelationships(result, 'HAS_METHOD');
const a = hm.find((e) => e.target === 'from_outer');
const b = hm.find((e) => e.target === 'from_other');
expect(a).toBeDefined();
expect(b).toBeDefined();
// The two same-tail `Inner` methods must NOT share one owner node id.
expect(a!.rel.sourceId).not.toBe(b!.rel.sourceId);
});
});