Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
dc12289
fix(ingestion): qualify nested-type node identity for C++/Ruby (#1978)
magyargergo Jun 2, 2026
ddc31ba
test(ingestion): scope #1978 resolver tests to registry-primary leg; …
magyargergo Jun 3, 2026
e264162
fix(test): satisfy CI for the new #1978 fixtures (format + golden + f…
magyargergo Jun 3, 2026
093ff48
Merge branch 'main' into fix/cpp-rust-qualified-node-identity
magyargergo Jun 3, 2026
35a37d9
refactor(ingestion): extract shared qualified-name normalizer (#1982)
magyargergo Jun 3, 2026
16883f2
fix(ingestion): resolve same-tail C++ nested-type heritage to the cor…
magyargergo Jun 3, 2026
bb84ccb
fix(ingestion): resolve same-tail Ruby mixin/attr_accessor owners to …
magyargergo Jun 3, 2026
cd8eb47
test(ingestion): rebaseline #1982 golden/fingerprint + lint/format sweep
magyargergo Jun 3, 2026
c079797
Merge origin/main into fix/cpp-rust-qualified-node-identity
magyargergo Jun 3, 2026
2fea67b
Merge remote-tracking branch 'origin/fix/cpp-rust-qualified-node-iden…
magyargergo Jun 3, 2026
b9286bb
Merge branch 'main' into fix/cpp-rust-qualified-node-identity
magyargergo Jun 3, 2026
3a0ed3e
fix(ingestion): resolve nested Ruby mixin included by short name (#1982)
magyargergo Jun 3, 2026
bd7eff1
fix(ingestion): normalize qualified Ruby mixin arg in heritage marker…
magyargergo Jun 3, 2026
1874516
fix(ingestion): resolve C++ same-tail nested heritage inside a namesp…
magyargergo Jun 3, 2026
1b6ce84
test(ingestion): worker-path parity for Ruby mixin IMPLEMENTS + C++ D…
magyargergo Jun 3, 2026
bc4a560
fix(ingestion): distinct Rust same-tail nested-mod inherent-impl owne…
magyargergo Jun 3, 2026
e83858f
refactor(ingestion): single qualified-name normalizer + module-scoped…
magyargergo Jun 3, 2026
a4ccbb5
perf+fix(ingestion): single enclosing-class walk + root-anchored base…
magyargergo Jun 3, 2026
7a74bdb
test(ingestion): rebaseline ruby+cpp scope-capture fingerprints for n…
magyargergo Jun 3, 2026
684203c
style(ingestion): prettier-wrap ruby resolver test call (#1982)
magyargergo Jun 3, 2026
08092d1
Merge remote-tracking branch 'origin/main' into pr-1981-impl
magyargergo Jun 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions gitnexus/bench/scope-capture/baselines.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "931bf7af55dc1480d1a5d3c479ea3803003a6a2e2c4406447bd96f3e312e88de",
"fingerprint": "a59156a63f89364fde16e2c6c238f696fc9e66328c49e5b235ac88b662d279b3",
"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).",
Expand All @@ -28,7 +28,7 @@
"scaling_budget": 1.5
},
"rust": {
"fingerprint": "3c4b8e0a707299cc5db0af2528c72a99457859104589a7ef3cd1f377da01793e",
"fingerprint": "30224e2590064745548bc1d623811ae5d37227618854788695442a0acf1898fb",
"scaling_budget": 1.5,
"_rebaselined": "#1956 tri-review U1: rust-qualified-trait fixture (scoped + generic-of-scoped impl trait paths); bareTypeIdentifier now resolves scoped_type_identifier bases by their name: tail (additive, no existing-fixture drift); linear (~1.04).",
"_note": "#1975: + rust-scoped-impl fixture (impl a::Inner / b::Inner inherent scoped impls). Pure fixture-corpus drift — the fix is the legacy @definition.impl scoped arm + findEnclosingClassInfo inherent-impl scoped target, NOT the rust scope-extractor; existing fixtures' captures byte-identical. fixture_count 120->121."
Expand All @@ -39,10 +39,10 @@
"_rebaselined": "#1956: heritage-bearing scale source (class extends Base + use trait); both forms gated at scale; linear (~1.04)."
},
"ruby": {
"fingerprint": "ee81145cf0af796878e8e048192b87c8c8dc445a3e3fcdff6c6e26c179e97232",
"fingerprint": "17782d4a8697f7bd80a25075bebec8ba97c6717b8014ce5b1fb8548a4073e1c8",
"scaling_budget": 1.5,
"_rebaselined": "#1956 synth-widening: + ruby-qualified-base fixture; synth now reduces a scope_resolution superclass (class C < Mod::Super) to its trailing constant (matching the #1940 legacy leg), at parity. Linear (~1.03). (Earlier #1956: heritage-bearing scale source.)",
"_note": "F62: + scope_resolution class/module declaration captures — fixture count 78→81, fingerprint drift expected. #1975: + ruby-tail-collision fixture (Foo::Bar vs Baz::Bar stay distinct nodes) — pure fixture-corpus drift, scope-extractor captures unchanged; 81→82."
"_note": "F62: + scope_resolution class/module declaration captures — fixture count 78→81, fingerprint drift expected. #1975: + ruby-tail-collision fixture (Foo::Bar vs Baz::Bar stay distinct nodes) — pure fixture-corpus drift, scope-extractor captures unchanged; 81→82. #1978: + ruby-nested-tail-collision (same-tail Inner under Outer/Other modules + Shapes.Circle attr_accessor) — pure fixture-corpus drift, scope-extractor captures unchanged; 82→83."
},
"swift": {
"fingerprint": "53325c6345161c5a495f997297af5a24fb718fd3e6647040160f8ab2a2c8e4c0",
Expand Down
13 changes: 12 additions & 1 deletion gitnexus/src/core/ingestion/call-processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
magyargergo marked this conversation as resolved.
// same-tail nested types in one TU stay distinct instead of silently merging.
qualifiedNodeId: true,
Comment thread
magyargergo marked this conversation as resolved.
extractName: (node) => {
const nameNode = node.childForFieldName?.('name');
if (!nameNode) return undefined;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
1 change: 1 addition & 0 deletions gitnexus/src/core/ingestion/class-extractors/generic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ export function createClassExtractor(config: ClassExtractionConfig): ClassExtrac

return {
language: config.language,
qualifiedNodeId: config.qualifiedNodeId ?? false,

isTypeDeclaration(node: SyntaxNode): boolean {
return typeDeclarationSet.has(node.type);
Expand Down
15 changes: 15 additions & 0 deletions gitnexus/src/core/ingestion/class-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
Expand Down
55 changes: 43 additions & 12 deletions gitnexus/src/core/ingestion/parsing-processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -297,10 +297,16 @@ const cachedFindEnclosingClassInfo = (
node: SyntaxNode,
filePath: string,
resolveEnclosingOwner?: (node: SyntaxNode) => SyntaxNode | null,
getQualifiedOwnerName?: (node: SyntaxNode, simpleName: string) => string | null,
): EnclosingClassInfo | null => {
const cached = classInfoCache.get(node);
if (cached !== undefined) return cached;
const result = findEnclosingClassInfo(node, filePath, resolveEnclosingOwner);
const result = findEnclosingClassInfo(
node,
filePath,
resolveEnclosingOwner,
getQualifiedOwnerName,
);
classInfoCache.set(node, result);
return result;
};
Expand Down Expand Up @@ -602,24 +608,55 @@ const processParsingSequential = async (
nodeLabel === 'Constructor' ||
nodeLabel === 'Property' ||
nodeLabel === 'Function';
// #1978: when the language opts into qualified node ids, thread the
// class-extractor's qualifier into the enclosing-owner walk so a nested
// member resolves to its owner's *qualified* id (Outer.Inner) — matching
// the qualified class node id computed below. Gated on the flag, so the
// owner walk and its cache entry are byte-identical when the flag is off.
const getQualifiedOwnerName =
provider.classExtractor?.qualifiedNodeId === true
? (node: SyntaxNode, simpleName: string): string | null =>
provider.classExtractor!.extractQualifiedName(node, simpleName)
: undefined;
const enclosingClassInfo = needsOwner
? cachedFindEnclosingClassInfo(
nameNode || definitionNodeForRange,
file.path,
provider.resolveEnclosingOwner,
getQualifiedOwnerName,
)
: null;
const enclosingClassId = enclosingClassInfo?.classId ?? null;
const enclosingClassId =
enclosingClassInfo?.qualifiedClassId ?? enclosingClassInfo?.classId ?? null;
const objectLiteralOwnerInfo =
!enclosingClassId && nodeLabel === 'Method' && definitionNode
? findObjectLiteralBindingInfo(definitionNode, file.path)
: null;

// #1978: a class-like node opts into a fully-qualified node id (Outer.Inner)
// when the language enables qualifiedNodeId, so same-tail nested types in one
// file stay distinct. Hoisted ABOVE the node-id/qualifiedName use below and
// derived from the SAME extractQualifiedName the owner edge uses, so the
// member's owner id and the class node id agree. The order is load-bearing.
const classNodeForSymbol = definitionNodeForRange || definitionNode || nameNode;
const qualifiedTypeName =
extractedClassSymbol?.qualifiedName ??
(classNodeForSymbol && provider.classExtractor?.isTypeDeclaration(classNodeForSymbol)
? (provider.classExtractor.extractQualifiedName(classNodeForSymbol, nodeName) ?? nodeName)
: undefined);

// Qualify method/property IDs with enclosing class name to avoid collisions
// e.g. "Method:animal.dart:Animal.speak" vs "Method:animal.dart:Dog.speak"
const qualifiedName = enclosingClassInfo
? `${enclosingClassInfo.className}.${nodeName}`
: nodeName;
// e.g. "Method:animal.dart:Animal.speak" vs "Method:animal.dart:Dog.speak".
// Class-like nodes use their own fully-qualified path as the id key when the
// language enables qualifiedNodeId (#1978); everything else is unchanged.
const qualifiedName =
isClassLikeLabel &&
provider.classExtractor?.qualifiedNodeId === true &&
qualifiedTypeName !== undefined
? qualifiedTypeName
: enclosingClassInfo
? `${enclosingClassInfo.className}.${nodeName}`
: nodeName;

// Extract method metadata for Function/Method/Constructor nodes BEFORE generating
// the node ID — parameterCount is needed to disambiguate overloaded methods.
Expand Down Expand Up @@ -778,12 +815,6 @@ const processParsingSequential = async (
nodeLabel,
`${file.path}:${qualifiedName}${classTemplateTag}${arityTag}${constraintsTag}${parameterShapeTag}`,
);
const classNodeForSymbol = definitionNodeForRange || definitionNode || nameNode;
const qualifiedTypeName =
extractedClassSymbol?.qualifiedName ??
(classNodeForSymbol && provider.classExtractor?.isTypeDeclaration(classNodeForSymbol)
? (provider.classExtractor.extractQualifiedName(classNodeForSymbol, nodeName) ?? nodeName)
: undefined);
const frameworkHint = definitionNode
? detectFrameworkFromAST(language, (definitionNode.text || '').slice(0, 300))
: null;
Expand Down
39 changes: 39 additions & 0 deletions gitnexus/src/core/ingestion/utils/ast-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,15 @@ export function getLabelFromCaptures(
export interface EnclosingClassInfo {
classId: string; // e.g. "Class:animal.dart:Animal"
className: string; // e.g. "Animal"
/**
* The owner node id keyed by the enclosing type's FULLY-QUALIFIED path
* (e.g. "Class:file:Outer.Inner"), present only when the language opts into
* `qualifiedNodeId` AND the enclosing type is actually nested (#1978).
* Consumers building HAS_METHOD/HAS_PROPERTY owner edges use this in
* preference to `classId` so the edge source matches the qualified class
* node id. When absent, `classId` (the simple-tail key) is unchanged.
*/
qualifiedClassId?: string;
}

/** Walk up AST to find enclosing class/struct/interface/impl, return its ID and name.
Expand All @@ -345,6 +354,16 @@ export const findEnclosingClassInfo = (
node: SyntaxNode,
filePath: string,
resolveEnclosingOwner?: (node: SyntaxNode) => SyntaxNode | null,
/**
* Optional (#1978): returns the enclosing type's fully-qualified name
* (e.g. "Outer.Inner") for a type-declaration container, or null. Callers
* pass `classExtractor.extractQualifiedName` ONLY when the language's
* `qualifiedNodeId` flag is on — so when omitted, behavior is byte-identical
* to before (qualifiedClassId stays undefined). Used by the standard
* class-container branch to compute `qualifiedClassId` from the SAME function
* the node-id is built from, guaranteeing owner-id == node-id by construction.
*/
getQualifiedOwnerName?: (node: SyntaxNode, simpleName: string) => string | null,
): EnclosingClassInfo | null => {
let current = node.parent;
let iterations = 0;
Expand Down Expand Up @@ -485,9 +504,29 @@ export const findEnclosingClassInfo = (
templateArguments !== undefined
? `${stripTemplateArguments(nameNode.text)}${templateArgumentsIdTag(templateArguments)}`
: nameNode.text;
// #1978: when the language opts into qualified node ids, key the owner
// edge by the enclosing type's qualified path (e.g. "Outer.Inner") so it
// matches the qualified class node id. Derived from the SAME
// extractQualifiedName the node-id uses → agree by construction. Only set
// when actually nested (qualified !== simple); top-level types are
// unchanged. (Go receiver / Rust impl branches return earlier and are
// intentionally untouched here.)
const qualifiedOwnerName = getQualifiedOwnerName?.(current, nameNode.text);
const qualifiedClassId =
qualifiedOwnerName != null && qualifiedOwnerName !== nameNode.text
? generateId(
label,
`${filePath}:${
templateArguments !== undefined
? `${stripTemplateArguments(qualifiedOwnerName)}${templateArgumentsIdTag(templateArguments)}`
: qualifiedOwnerName
}`,
)
: undefined;
return {
classId: generateId(label, `${filePath}:${classIdName}`),
className: nameNode.text,
...(qualifiedClassId !== undefined ? { qualifiedClassId } : {}),
};
}
}
Expand Down
Loading
Loading