mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-29 06:32:28 +00:00
fix(ingestion): fully-qualified nested-type identity for C++/Ruby — structure (#1978) + resolution (#1982) (#1981)
* 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> * 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> * 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> * 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> * 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> * 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> * 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> * fix(ingestion): resolve nested Ruby mixin included by short name (#1982) emitRubyMixinEdges keyed graphIdByName by the full def.qualifiedName on the owner side, but the __heritage__ marker carries the mixin target as the bare written name (arg.text). A nested mixin module included by its short name (include Loggable where it is App::Loggable) missed the full-qn map and its IMPLEMENTS edge was silently dropped (0 dangling, undetectable). The shipped same-tail fixture used only top-level mixin modules, so CI stayed green. Add a secondary simple-tail fallback map consulted only when the full-qn mixin lookup misses; owner lookups stay full-qn so same-tail owner disambiguation is preserved. Characterization test + fixture (registry-primary only); golden regenerated additively. Addresses PR #1981 review (4417182679) P1. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(ingestion): normalize qualified Ruby mixin arg in heritage marker (#1982) `include Outer::Mixin` embedded the raw `Outer::Mixin` into the ':'-delimited __heritage__ marker, so the `::` collided with the field separator and emitRubyMixinEdges mis-split it (className became empty), dropping the IMPLEMENTS edge. Normalize the mixin arg via splitQualifiedName(...).join('.') before emit so the marker carries the dotted form, which both parses correctly and matches the mixin def's qualifiedName. Simple names are unchanged (no golden drift). Addresses PR #1981 review (4417182679) secondary R2. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(ingestion): resolve C++ same-tail nested heritage inside a namespace (#1982) A namespace-nested C++ type's scope-model qualifiedName carried its enclosing CLASS chain (A.Inner) but dropped the enclosing NAMESPACE, while the structure-phase graph node is keyed by the full path (NS.A.Inner). resolveDefGraphId's qualifiedKey therefore missed and fell back to simpleKey('Inner'), collapsing same-tail nested bases across sibling namespace members — DB : B::Inner pointed at NS.A.Inner. The shipped fixture was top-level only, so it could not catch this. Fix without disturbing the qualifiedName-keyed resolution index (an earlier attempt that rewrote qualifiedName regressed brace-init / UDC / two-phase namespace resolution): tagNamespacePrefixes records each namespace-nested def's enclosing-namespace prefix on a sidecar field, and resolveDefGraphId retries the node lookup with the namespace-prefixed key before the simpleKey fallback. The helper is language-agnostic (acts only on Namespace scopes) and opt-in — only the C++ provider calls it. Namespaced fixture + sequential & worker tests (registry-primary only). All 280 cpp resolver tests pass; tsc clean. Addresses PR #1981 review (4417182679) P2. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(ingestion): worker-path parity for Ruby mixin IMPLEMENTS + C++ DerivedA (#1982) The Ruby worker-path parity block asserted only attr_accessor (HAS_PROPERTY); add an IMPLEMENTS assertion so a dropped/cross-wired mixin owner on the worker path is caught (the __heritage__ marker owner must survive serialization). The C++ worker heritage block asserted only DerivedB; add a DerivedA assertion with a toHaveLength(1) duplicate guard. Registry-primary only. Addresses PR #1981 review (4417182679) test-coverage gap. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(ingestion): distinct Rust same-tail nested-mod inherent-impl ownership (#1982) Rust methods live in `impl Inner` blocks, and findEnclosingClassInfo keyed the inherent-impl owner by the target's RAW tail (`Impl:lib.rs:Inner`), so two same-tail `impl Inner` blocks under different mods (mod outer / mod other) collapsed onto ONE Impl node and their methods cross-wired. The shipped fixture test for this was skipped/deferred. Qualify an UNSCOPED inherent-impl target by its enclosing `mod_item` scope (`outer.Inner`) in BOTH the owner walk (ast-helpers.qualifyRustImplTargetByModScope) and the Impl-node materialization (parsing-processor + parse-worker, lockstep) so the owner edge and node id agree byte-for-byte. Gated on the Impl label + impl_item + an unscoped type_identifier target — Rust-impl-exclusive, so C++/Ruby and the rust captures golden are untouched; a SCOPED `impl a::Inner` keeps its full raw text (#1975, unchanged). The previously-skipped distinct-ownership test is now active and passing; rust 170/170, cpp+ruby+golden 437/437, tsc clean. Done in-PR at maintainer request (was deferred as a follow-up). Addresses PR #1981 review (4417182679) test-coverage gap R7. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(ingestion): single qualified-name normalizer + module-scoped Ruby PROPERTY_PREFIX (#1982) Replace cpp/captures.ts's parallel normalizeCppNamespaceQName with the shared normalizeQualifiedName (behaviorally equivalent for C++ qualified-identifier inputs: '::'->'.' with leading/trailing-:: handling; no interior whitespace reaches it). Promote Ruby's PROPERTY_PREFIX to module scope alongside HERITAGE_PREFIX (was function-local — asymmetric with no behavioral effect). Maintainability only; cpp+ruby resolver suites 428/428, tsc clean. Addresses PR #1981 review (4417182679) maintainability item. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * perf+fix(ingestion): single enclosing-class walk + root-anchored base guard (#1982) U7 (perf): preEmitInheritanceEdges resolved the deriving class AND resolveQualifiedInheritanceBase re-walked findEnclosingClassDef for the same site. Resolve callerClass once and thread it into resolveInheritanceBaseInScope -> resolveQualifiedInheritanceBase -> enclosingScopeSegments, so the enclosing class is walked once per qualified site. Add a 'program' early-exit to buildEnclosingQualifiedName (ruby/captures.ts). Behavior-preserving. U8 (P3): a root-anchored C++ base ": ::A::Inner" names the GLOBAL type, but resolveQualifiedInheritanceBase prepended the deriving class's enclosing segments and could mis-bind to an enclosing-relative same-path type. Detect the leading "::" on the raw qualifier and try only the root-anchored key. Discriminating fixture + test (registry-primary only). cpp+ruby+rust resolver suites 599/599; tsc clean. Addresses PR #1981 review (4417182679) perf + P3 items. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(ingestion): rebaseline ruby+cpp scope-capture fingerprints for new #1982 fixtures The four new fixtures (ruby-nested-mixin-shortname, ruby-qualified-mixin, cpp-namespaced-collision, cpp-global-base-anchor) grow the lang-resolution corpus, drifting the ruby and cpp order-independent capture fingerprints. Verified purely additive: the ruby captures golden shows only the two new fixtures added (existing byte-identical), and removing the two cpp fixtures reverts the cpp fingerprint to the prior baseline (so the U3/U6/U8 code changes are scope-resolution / behavior-preserving, not capture-emission). measure.mjs --check PASS (14 languages). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * style(ingestion): prettier-wrap ruby resolver test call (#1982) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a9af96e70c
commit
7e4d93ac11
32 changed files with 1355 additions and 80 deletions
|
|
@ -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;
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<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 `''`.
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,34 @@ 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));
|
||||
}
|
||||
// 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) {
|
||||
|
|
|
|||
|
|
@ -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<string, string>();
|
||||
// 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<string, string>();
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<string> = new Set<string>([
|
|||
'@type-binding.name',
|
||||
'@type-binding.type',
|
||||
'@reference.name',
|
||||
'@reference.qualified-name',
|
||||
'@reference.receiver',
|
||||
'@reference.operator',
|
||||
'@reference.arity',
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<ScopeId, ParsedFile['scopes'][number]>();
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
43
gitnexus/src/core/ingestion/utils/qualified-name.ts
Normal file
43
gitnexus/src/core/ingestion/utils/qualified-name.ts
Normal file
|
|
@ -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) : [];
|
||||
};
|
||||
|
|
@ -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<string, FieldInfo> | 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 #<arity> 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);
|
||||
|
||||
|
|
|
|||
21
gitnexus/test/fixtures/lang-resolution/cpp-global-base-anchor/main.cpp
vendored
Normal file
21
gitnexus/test/fixtures/lang-resolution/cpp-global-base-anchor/main.cpp
vendored
Normal file
|
|
@ -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
|
||||
23
gitnexus/test/fixtures/lang-resolution/cpp-namespaced-collision/main.cpp
vendored
Normal file
23
gitnexus/test/fixtures/lang-resolution/cpp-namespaced-collision/main.cpp
vendored
Normal file
|
|
@ -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
|
||||
15
gitnexus/test/fixtures/lang-resolution/cpp-nested-tail-collision/shapes.cpp
vendored
Normal file
15
gitnexus/test/fixtures/lang-resolution/cpp-nested-tail-collision/shapes.cpp
vendored
Normal file
|
|
@ -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 {};
|
||||
19
gitnexus/test/fixtures/lang-resolution/ruby-nested-mixin-shortname/app.rb
vendored
Normal file
19
gitnexus/test/fixtures/lang-resolution/ruby-nested-mixin-shortname/app.rb
vendored
Normal file
|
|
@ -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
|
||||
25
gitnexus/test/fixtures/lang-resolution/ruby-nested-tail-collision/nested.rb
vendored
Normal file
25
gitnexus/test/fixtures/lang-resolution/ruby-nested-tail-collision/nested.rb
vendored
Normal file
|
|
@ -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
|
||||
14
gitnexus/test/fixtures/lang-resolution/ruby-qualified-mixin/app.rb
vendored
Normal file
14
gitnexus/test/fixtures/lang-resolution/ruby-qualified-mixin/app.rb
vendored
Normal file
|
|
@ -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
|
||||
12
gitnexus/test/fixtures/lang-resolution/rust-nested-tail-collision/lib.rs
vendored
Normal file
12
gitnexus/test/fixtures/lang-resolution/rust-nested-tail-collision/lib.rs
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
pub mod outer {
|
||||
pub struct Inner;
|
||||
impl Inner {
|
||||
pub fn from_outer(&self) {}
|
||||
}
|
||||
}
|
||||
pub mod other {
|
||||
pub struct Inner;
|
||||
impl Inner {
|
||||
pub fn from_other(&self) {}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -271,8 +271,32 @@ 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)',
|
||||
// #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<string>([
|
||||
// Swift scope-resolution achieves 77/77 baseline parity. The tests
|
||||
|
|
@ -497,6 +521,38 @@ 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)',
|
||||
// #1982 follow-up: namespaced same-tail nested heritage. The fix
|
||||
// (tagNamespacePrefixes + the resolveDefGraphId namespace-prefixed retry)
|
||||
// lives in the registry-primary scope-resolution bridge; the legacy DAG does
|
||||
// not use it, so these are registry-primary-only by design.
|
||||
'resolves NS::DA : A::Inner → EXTENDS the NS.A.Inner node',
|
||||
'resolves NS::DB : B::Inner → EXTENDS the NS.B.Inner node (not NS.A.Inner)',
|
||||
'genuinely used the worker pool for the namespaced fixture',
|
||||
'resolves NS::DA / NS::DB to their own namespaced base on the worker path',
|
||||
// #1982 follow-up: C++ worker-path DerivedA parity + duplicate guard.
|
||||
'resolves DerivedA : Outer::Inner → EXTENDS Outer.Inner on the worker path (parity + no duplicate)',
|
||||
// #1982 follow-up: root-anchored base must not bind to an enclosing-relative
|
||||
// type (resolveQualifiedInheritanceBase leading-:: guard). Registry-primary only.
|
||||
'resolves Outer::Wrap::D : ::A::Inner → EXTENDS the GLOBAL A.Inner (not Wrap.A.Inner)',
|
||||
]),
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1508,3 +1508,223 @@ 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);
|
||||
},
|
||||
);
|
||||
|
||||
// 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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
//
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue