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
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) });
}
}
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
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
14 changes: 7 additions & 7 deletions gitnexus/src/core/ingestion/languages/ruby/scope-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,7 @@ 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';

const HERITAGE_PREFIX = '__heritage__:';
const PROPERTY_PREFIX = '__property__:';
import { decodeMarker } from '../../utils/heritage-marker.js';

function emitRubyMixinEdges(
graph: KnowledgeGraph,
Expand Down Expand Up @@ -59,8 +57,9 @@ 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!);
Expand Down Expand Up @@ -95,8 +94,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
58 changes: 58 additions & 0 deletions gitnexus/src/core/ingestion/utils/heritage-marker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/**
* #1994: shared codec for the synthetic `__heritage__:` / `__property__:` import
* markers used by the Ruby and Dart scope resolvers to carry side-effect facts
* (mixin includes, attr_accessor properties) through the import channel. Both
* languages share the exact `':'`-delimited wire format, so a single encode/decode
* pair removes the per-site hand-rolled string handling that produced the #1981
* edge-drop. Language-NEUTRAL — keyed only on the literal prefixes; no provider
* branching belongs here.
*/
export type MarkerKind = 'heritage' | 'property';

const PREFIX_BY_KIND: Record<MarkerKind, string> = {
heritage: '__heritage__:',
property: '__property__:',
};

export const HERITAGE_MARKER_PREFIX = PREFIX_BY_KIND.heritage;
export const PROPERTY_MARKER_PREFIX = PREFIX_BY_KIND.property;

/**
* Build a marker string `<prefix><field>:<field>:...`. The `':'` delimiter IS the
* wire format, so a field that itself contains `':'` is structurally invalid and
* THROWS — callers must pre-normalize colon-bearing values (e.g. a qualified mixin
* arg `Outer::Mixin` → `Outer.Mixin`). This makes the #1981 silent edge-drop a
* loud failure instead.
*/
export function encodeMarker(kind: MarkerKind, fields: readonly string[]): string {
for (const field of fields) {
if (field.includes(':')) {
throw new Error(
`encodeMarker: field "${field}" contains the ':' delimiter; normalize it before encoding`,
);
}
}
return PREFIX_BY_KIND[kind] + fields.join(':');
}

/**
* Parse a marker string back into its kind + positional fields, or `null` if `raw`
* is not a marker. Mirrors the historical `slice(PREFIX.length).split(':')`.
*/
export function decodeMarker(raw: string): { kind: MarkerKind; fields: string[] } | null {
if (raw.startsWith(PREFIX_BY_KIND.heritage)) {
return { kind: 'heritage', fields: raw.slice(PREFIX_BY_KIND.heritage.length).split(':') };
}
if (raw.startsWith(PREFIX_BY_KIND.property)) {
return { kind: 'property', fields: raw.slice(PREFIX_BY_KIND.property.length).split(':') };
}
return null;
}

/**
* True if `raw` is a synthetic heritage/property marker — exactly the prior
* `startsWith('__heritage__:') || startsWith('__property__:')` pair.
*/
export function isHeritageMarker(raw: string): boolean {
return raw.startsWith(PREFIX_BY_KIND.heritage) || raw.startsWith(PREFIX_BY_KIND.property);
}
51 changes: 51 additions & 0 deletions gitnexus/test/unit/ingestion/heritage-marker.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { describe, it, expect } from 'vitest';
import {
encodeMarker,
decodeMarker,
isHeritageMarker,
HERITAGE_MARKER_PREFIX,
PROPERTY_MARKER_PREFIX,
} from '../../../src/core/ingestion/utils/heritage-marker.js';

describe('heritage-marker codec (#1994)', () => {
it('encodes the exact Ruby/Dart wire format (byte-identical to the hand-rolled markers)', () => {
expect(encodeMarker('heritage', ['include', 'Loggable', 'App.S'])).toBe(
'__heritage__:include:Loggable:App.S',
);
expect(encodeMarker('property', ['attr_accessor', 'radius', 'Shapes.Circle'])).toBe(
'__property__:attr_accessor:radius:Shapes.Circle',
);
expect(HERITAGE_MARKER_PREFIX).toBe('__heritage__:');
expect(PROPERTY_MARKER_PREFIX).toBe('__property__:');
});

it('round-trips encode → decode for both kinds', () => {
const heritage = encodeMarker('heritage', ['with', 'Logger', 'Service']);
expect(decodeMarker(heritage)).toEqual({
kind: 'heritage',
fields: ['with', 'Logger', 'Service'],
});
const property = encodeMarker('property', ['attr_reader', 'name', 'User']);
expect(decodeMarker(property)).toEqual({
kind: 'property',
fields: ['attr_reader', 'name', 'User'],
});
});

it('throws on a colon-bearing field (the wire format reserves ":" as the delimiter)', () => {
expect(() => encodeMarker('heritage', ['include', 'Outer::Mixin', 'User'])).toThrow(/':'/);
});

it('decodeMarker returns null for non-markers', () => {
expect(decodeMarker('./relative/path')).toBeNull();
expect(decodeMarker('package:foo/bar.dart')).toBeNull();
expect(decodeMarker('')).toBeNull();
});

it('isHeritageMarker matches exactly the prior startsWith pair', () => {
expect(isHeritageMarker('__heritage__:include:M:C')).toBe(true);
expect(isHeritageMarker('__property__:attr:p:C')).toBe(true);
expect(isHeritageMarker('Serializable')).toBe(false);
expect(isHeritageMarker('dart:core')).toBe(false);
});
});
Loading