Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
9 changes: 9 additions & 0 deletions gitnexus-shared/src/scope-resolution/symbol-definition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,4 +59,13 @@ export interface SymbolDefinition {
isExplicit?: boolean;
/** Links Method/Constructor/Property to owning Class/Struct/Trait nodeId */
ownerId?: string;
/** #1982/#1993: bridge-held enclosing-namespace path (e.g. `NS1`, `Outer.Inner`)
* tagged during the C++ resolution phase. Lets the graph bridge retry a
* namespace-prefixed node-lookup key and lets the qualified-base resolver
* break same-tail cross-namespace inheritance ties. A deliberate sidecar,
* separate from `qualifiedName`: it does NOT participate in graph node
* identity (node keys derive from filePath/type/qualifiedName) and leaves the
* qualifiedName-keyed resolution index untouched. Absent for the common case
* (non-namespace-nested defs and all non-C++ languages). */
namespacePrefix?: string;
}
25 changes: 24 additions & 1 deletion gitnexus/src/core/ingestion/class-extractors/configs/c-cpp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,33 @@ export const cClassConfig: ClassExtractionConfig = {
export const cppClassConfig: ClassExtractionConfig = {
language: SupportedLanguages.CPlusPlus,
typeDeclarationNodes: ['class_specifier', 'struct_specifier', 'enum_specifier'],
ancestorScopeNodeTypes: ['namespace_definition', 'class_specifier', 'struct_specifier'],
// #1995: `union_specifier` is included so a type nested in a NAMED union
// (`union U1 { struct Inner {...} }`) qualifies as `U1.Inner`. Anonymous unions
// have no `name` child → extractScopeSegmentsFromNode returns [] → they correctly
// contribute nothing (members inject into the enclosing scope). C uses the
// separate cClassConfig (no qualifiedNodeId), so it is intentionally untouched.
ancestorScopeNodeTypes: [
'namespace_definition',
'class_specifier',
'struct_specifier',
'union_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,
// #1995: anonymous namespaces have no `name` child, so the generic scope walker
// drops them (empty segment) and two `namespace { struct Inner {} }` blocks in one
// TU collapse onto a single `Inner` node. Give each anonymous namespace_definition
// a deterministic per-block discriminator (its start byte — stable across the
// sequential and worker full-file parses) so the nested types stay distinct.
// Returning `undefined` for every other scope — named namespaces (incl. `inline
// namespace`), classes, structs, named unions — falls through to the default
// name-based extraction, leaving them unchanged. Anonymous UNIONS are not matched
// here (members inject into the enclosing scope), so they keep yielding [].
extractScopeSegments: (node) =>
node.type === 'namespace_definition' && !node.childForFieldName?.('name')
? [`@anon${node.startIndex}`]
: undefined,
extractName: (node) => {
const nameNode = node.childForFieldName?.('name');
if (!nameNode) return undefined;
Expand Down
8 changes: 8 additions & 0 deletions gitnexus/src/core/ingestion/class-extractors/generic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,14 @@ export function createClassExtractor(config: ClassExtractionConfig): ClassExtrac
return extract(node, { name: simpleName })?.qualifiedName ?? null;
},

// #1991: qualify a non-typeDeclaration scope node (e.g. a Ruby `module` → Trait)
// by the same ancestor-scope walk the node-id path uses, so two same-tail nested
// mixin modules stay distinct. extract()/extractQualifiedName cannot be reused —
// they bail on non-typeDeclarations (a module is not in typeDeclarationNodes).
qualifyScopeName(node: SyntaxNode, simpleName: string): string {
return buildQualifiedName(node, simpleName);
},

shouldSkipClassCapture(context): boolean {
return config.shouldSkipClassCapture?.(context) ?? false;
},
Expand Down
8 changes: 8 additions & 0 deletions gitnexus/src/core/ingestion/class-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,14 @@ export interface ClassExtractor {
},
): ExtractedClassSymbol | null;
extractQualifiedName(node: SyntaxNode, simpleName: string): string | null;
/**
* #1991: qualify a scope-defining node that maps to a class-like registry label
* (e.g. a Ruby `module` → Trait) but is NOT a typeDeclaration, so it cannot go
* through extract()/extractQualifiedName (which bail on non-typeDeclarations).
* Walks the same ancestor scopes as the node-id path. Optional — only providers
* that materialize such nodes implement it.
*/
qualifyScopeName?(node: SyntaxNode, simpleName: string): string;
shouldSkipClassCapture?(
context: ClassCaptureContext & { nodeLabel: ClassLikeNodeLabel },
): boolean;
Expand Down
4 changes: 2 additions & 2 deletions gitnexus/src/core/ingestion/languages/dart/captures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ import { getDartParser, getDartScopeQuery } from './query.js';
import { recordCacheHit, recordCacheMiss } from './cache-stats.js';
import { getTreeSitterBufferSize } from '../../constants.js';
import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js';
import { DART_HERITAGE_PREFIX } from './interpret.js';
import { encodeMarker } from '../../utils/heritage-marker.js';
import { DART_BUILT_INS } from './built-ins.js';

const FUNCTION_DECL_TAGS = [
Expand Down Expand Up @@ -491,7 +491,7 @@ function emitHeritageMarkers(
for (let i = 0; i < container.namedChildCount; i++) {
const c = container.namedChild(i);
if (c === null || c.type !== 'type_identifier') continue;
const payload = `${DART_HERITAGE_PREFIX}${kind}:${c.text}:${className}`;
const payload = encodeMarker('heritage', [kind, c.text, className]);
out.push({ '@import.heritage': syntheticCapture('@import.heritage', c, payload) });
}
}
7 changes: 5 additions & 2 deletions gitnexus/src/core/ingestion/languages/dart/interpret.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,13 @@
*/

import type { CaptureMatch, ParsedImport, ParsedTypeBinding, TypeRef } from 'gitnexus-shared';
import { HERITAGE_MARKER_PREFIX } from '../../utils/heritage-marker.js';

/** Marker prefix carried on a side-effect `ParsedImport.targetRaw` for
* `implements`/`with` heritage, consumed by `emitDartHeritageEdges`. */
export const DART_HERITAGE_PREFIX = '__heritage__:';
* `implements`/`with` heritage, consumed by `emitDartHeritageEdges`. Aliased to
* the shared codec prefix (#1994) so the Dart wire prefix has a single source of
* truth and cannot desync from `encodeMarker`/`decodeMarker`. */
export const DART_HERITAGE_PREFIX = HERITAGE_MARKER_PREFIX;

function stripQuotes(s: string): string {
return s.replace(/^['"]|['"]$/g, '');
Expand Down
14 changes: 6 additions & 8 deletions gitnexus/src/core/ingestion/languages/dart/scope-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,8 @@ import type { KnowledgeGraph } from '../../../graph/types.js';
import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js';
import { generateId } from '../../../../lib/utils.js';
import { dartProvider } from '../dart.js';
import {
dartArityCompatibility,
dartMergeBindings,
resolveDartImportTarget,
DART_HERITAGE_PREFIX,
} from './index.js';
import { dartArityCompatibility, dartMergeBindings, resolveDartImportTarget } from './index.js';
import { decodeMarker } from '../../utils/heritage-marker.js';
import { expandDartWildcardNames } from './expand-wildcards.js';

interface ClassDefRef {
Expand Down Expand Up @@ -109,8 +105,10 @@ function emitDartHeritageEdges(
for (const parsed of parsedFiles) {
for (const imp of parsed.parsedImports) {
const raw = imp.targetRaw;
if (typeof raw !== 'string' || !raw.startsWith(DART_HERITAGE_PREFIX)) continue;
const parts = raw.slice(DART_HERITAGE_PREFIX.length).split(':');
if (typeof raw !== 'string') continue;
const decoded = decodeMarker(raw);
if (decoded?.kind !== 'heritage') continue;
const parts = decoded.fields;
if (parts.length < 3) continue;
const [kind, baseName, childName] = parts;
const childId = pickClassByName(childName!, parsed.filePath, defsByName);
Expand Down
5 changes: 3 additions & 2 deletions gitnexus/src/core/ingestion/languages/ruby/captures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { synthesizeRubyReceiverBinding, findEnclosingClassOrModule } from './rec
import { getTreeSitterBufferSize } from '../../constants.js';
import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js';
import { splitQualifiedName } from '../../utils/qualified-name.js';
import { encodeMarker } from '../../utils/heritage-marker.js';

const FUNCTION_NODE_TYPES = ['method', 'singleton_method'] as const;
const HERITAGE_CALL_NAMES: ReadonlySet<string> = new Set(['include', 'extend', 'prepend']);
Expand Down Expand Up @@ -193,7 +194,7 @@ export function emitRubyScopeCaptures(
'@import.source': syntheticCapture(
'@import.source',
callNode,
`__heritage__:${callName}:${mixinName}:${ownerName}`,
encodeMarker('heritage', [callName, mixinName, ownerName]),
),
'@import.name': syntheticCapture('@import.name', callNode, mixinName),
});
Expand Down Expand Up @@ -227,7 +228,7 @@ export function emitRubyScopeCaptures(
'@import.source': syntheticCapture(
'@import.source',
callNode,
`__property__:${callName}:${propName}:${ownerName}`,
encodeMarker('property', [callName, propName, ownerName]),
),
'@import.name': syntheticCapture('@import.name', callNode, propName),
});
Expand Down
3 changes: 2 additions & 1 deletion gitnexus/src/core/ingestion/languages/ruby/import-target.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

import { resolveRubyImportInternal } from '../../import-resolvers/ruby.js';
import { buildSuffixIndex } from '../../import-resolvers/utils.js';
import { isHeritageMarker } from '../../utils/heritage-marker.js';

export interface RubyResolveContext {
readonly fromFile: string;
Expand Down Expand Up @@ -37,7 +38,7 @@ export function resolveRubyImportTarget(
_resolutionConfig?: unknown,
): string | readonly string[] | null {
if (!targetRaw) return null;
if (targetRaw.startsWith('__heritage__:') || targetRaw.startsWith('__property__:')) return null;
if (isHeritageMarker(targetRaw)) return null;

const fromNormalized = fromFile.replace(/\\/g, '/');
const fromDir = fromNormalized.includes('/')
Expand Down
3 changes: 2 additions & 1 deletion gitnexus/src/core/ingestion/languages/ruby/interpret.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { CaptureMatch, ParsedImport, ParsedTypeBinding, TypeRef } from 'gitnexus-shared';
import { isHeritageMarker } from '../../utils/heritage-marker.js';

// ─── interpretImport ──────────────────────────────────────────────────────

Expand All @@ -21,7 +22,7 @@ export function interpretRubyImport(captures: CaptureMatch): ParsedImport | null

// Heritage-encoded imports (__heritage__:include:Serializable:User)
// are stored as namespace imports so emitHeritageEdges can read them.
if (source.startsWith('__heritage__:') || source.startsWith('__property__:')) {
if (isHeritageMarker(source)) {
const name = captures['@import.name']?.text ?? source;
return { kind: 'namespace', localName: name, importedName: name, targetRaw: source };
}
Expand Down
70 changes: 51 additions & 19 deletions gitnexus/src/core/ingestion/languages/ruby/scope-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,25 +9,44 @@ import { resolveDefGraphId } from '../../scope-resolution/graph-bridge/ids.js';
import type { GraphNodeLookup } from '../../scope-resolution/graph-bridge/node-lookup.js';
import type { KnowledgeGraph } from '../../../graph/types.js';
import { generateId } from '../../../../lib/utils.js';
import { decodeMarker } from '../../utils/heritage-marker.js';

const HERITAGE_PREFIX = '__heritage__:';
const PROPERTY_PREFIX = '__property__:';
/**
* #1991: resolve a BARE mixin reference (`include Loggable`) to a nested module by
* the INCLUDING class's lexical scope — Ruby looks up a constant in the innermost
* enclosing scope first. For owner `App.S`, try `App.Loggable`, then walk outward.
* Returns undefined if no enclosing-scope-qualified module exists.
*/
function qualifyMixinByOwnerScope(
mixinName: string,
ownerName: string,
graphIdByName: ReadonlyMap<string, string>,
): string | undefined {
let prefix = ownerName;
let dot = prefix.lastIndexOf('.');
while (dot !== -1) {
prefix = prefix.slice(0, dot);
const g = graphIdByName.get(`${prefix}.${mixinName}`);
if (g !== undefined) return g;
dot = prefix.lastIndexOf('.');
}
return undefined;
}

function emitRubyMixinEdges(
graph: KnowledgeGraph,
parsedFiles: readonly ParsedFile[],
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>();
// Secondary tail -> graphId map. 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 misses the
// full-qn map. We first resolve it lexically by the including class's enclosing
// scope (`qualifyMixinByOwnerScope`); this tail map is the last resort. A genuine
// same-tail collision is mapped to `null` so we REFUSE to guess (#1991) rather
// than the old first-wins, which cross-wired App::Loggable / Web::Loggable.
const graphIdByTail = new Map<string, string | null>();
for (const parsed of parsedFiles) {
for (const def of parsed.localDefs) {
if (!isClassLike(def.type)) continue;
Expand All @@ -43,7 +62,12 @@ function emitRubyMixinEdges(
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);
if (tail.length > 0) {
const existingTail = graphIdByTail.get(tail);
if (existingTail === undefined) graphIdByTail.set(tail, graphId);
else if (existingTail !== null && existingTail !== graphId)
graphIdByTail.set(tail, null); // same-tail collision — refuse to guess
}
}
}
}
Expand All @@ -59,14 +83,21 @@ function emitRubyMixinEdges(

for (const parsed of parsedFiles) {
for (const imp of parsed.parsedImports) {
if (!imp.targetRaw.startsWith(HERITAGE_PREFIX)) continue;
const parts = imp.targetRaw.slice(HERITAGE_PREFIX.length).split(':');
const decoded = decodeMarker(imp.targetRaw);
if (decoded?.kind !== 'heritage') continue;
const parts = decoded.fields;
if (parts.length < 3) continue;
const [kind, mixinName, className] = parts;
const classGraphId = graphIdByName.get(className!);
// 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!);
// Owner stays full-qn. The mixin target may be written by short name and miss
// the full-qn map; resolve it lexically by the including class's enclosing
// scope (`App::S` + `Loggable` -> `App::Loggable`), then fall back to the tail
// map ONLY when unambiguous — never first-wins on a collision (#1982/#1991).
const mixinGraphId =
graphIdByName.get(mixinName!) ??
qualifyMixinByOwnerScope(mixinName!, className!, graphIdByName) ??
graphIdByTail.get(mixinName!) ??
undefined;
if (classGraphId === undefined || mixinGraphId === undefined) continue;
const edgeKey = `${classGraphId}->${mixinGraphId}:${kind}`;
if (emitted.has(edgeKey)) continue;
Expand Down Expand Up @@ -95,8 +126,9 @@ function emitRubyMixinEdges(

for (const parsed of parsedFiles) {
for (const imp of parsed.parsedImports) {
if (!imp.targetRaw.startsWith(PROPERTY_PREFIX)) continue;
const parts = imp.targetRaw.slice(PROPERTY_PREFIX.length).split(':');
const decoded = decodeMarker(imp.targetRaw);
if (decoded?.kind !== 'property') continue;
const parts = decoded.fields;
if (parts.length < 3) continue;
const [_attrKind, propName, className] = parts;
const classGraphId = graphIdByName.get(className!);
Expand Down
25 changes: 22 additions & 3 deletions gitnexus/src/core/ingestion/parsing-processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
findObjectLiteralBindingInfo,
getLabelFromCaptures,
isSuppressedConcreteTypedefDuplicate,
isQualifiableScopeLabel,
qualifyRustImplTargetByModScope,
CLASS_CONTAINER_TYPES,
type SyntaxNode,
Expand Down Expand Up @@ -617,7 +618,13 @@ const processParsingSequential = async (
const getQualifiedOwnerName =
provider.classExtractor?.qualifiedNodeId === true
? (node: SyntaxNode, simpleName: string): string | null =>
provider.classExtractor!.extractQualifiedName(node, simpleName)
// #1991: a Ruby `module` owner is not a typeDeclaration, so
// extractQualifiedName returns null; fall back to the scope walk so a
// method inside a nested module owns through the SAME qualified Trait
// id its node uses (App.Loggable), not a dangling bare id.
provider.classExtractor!.extractQualifiedName(node, simpleName) ??
provider.classExtractor!.qualifyScopeName?.(node, simpleName) ??
null
: undefined;
const enclosingClassInfo = needsOwner
? cachedFindEnclosingClassInfo(
Expand All @@ -644,7 +651,16 @@ const processParsingSequential = async (
extractedClassSymbol?.qualifiedName ??
(classNodeForSymbol && provider.classExtractor?.isTypeDeclaration(classNodeForSymbol)
? (provider.classExtractor.extractQualifiedName(classNodeForSymbol, nodeName) ?? nodeName)
: undefined);
: // #1991: a Ruby `module` maps to Trait (class-like registry) but is not a
// typeDeclaration, so extractQualifiedName bails. Qualify it via the scope
// walk so two same-tail nested mixin modules get distinct ids. Gated on
// qualifiedNodeId, so languages without the flag are unaffected.
isQualifiableScopeLabel(nodeLabel) &&
provider.classExtractor?.qualifiedNodeId === true &&
classNodeForSymbol
? (provider.classExtractor.qualifyScopeName?.(classNodeForSymbol, nodeName) ??
undefined)
: 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".
Expand All @@ -667,7 +683,10 @@ const processParsingSequential = async (
const qualifiedName =
rustImplQualifiedName !== undefined
? rustImplQualifiedName
: isClassLikeLabel &&
: // #1991: include Trait so a Ruby mixin module's qualified scope id keys
// the node, mirroring the class-like path (qualifiedTypeName is computed
// for Trait above).
(isClassLikeLabel || isQualifiableScopeLabel(nodeLabel)) &&
provider.classExtractor?.qualifiedNodeId === true &&
qualifiedTypeName !== undefined
? qualifiedTypeName
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@ export function resolveDefGraphId(
parameterTypeClasses?: readonly ParameterTypeClass[];
templateArguments?: readonly string[];
templateConstraints?: unknown;
/** #1982 bridge-held namespace path; see `SymbolDefinition.namespacePrefix`. */
namespacePrefix?: string;
},
nodeLookup: GraphNodeLookup,
): string | undefined {
Expand Down Expand Up @@ -149,7 +151,7 @@ export function resolveDefGraphId(
// 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;
const nsPrefix = def.namespacePrefix;
if (nsPrefix !== undefined && nsPrefix.length > 0) {
const nsHit = nodeLookup.get(qualifiedKey(filePath, def.type, `${nsPrefix}.${qn}`));
if (nsHit !== undefined) return nsHit;
Expand Down
Loading
Loading