diff --git a/gitnexus/src/core/ingestion/languages/dart/captures.ts b/gitnexus/src/core/ingestion/languages/dart/captures.ts index e10b1cc375..21d14c7b43 100644 --- a/gitnexus/src/core/ingestion/languages/dart/captures.ts +++ b/gitnexus/src/core/ingestion/languages/dart/captures.ts @@ -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 = [ @@ -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) }); } } diff --git a/gitnexus/src/core/ingestion/languages/dart/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/dart/scope-resolver.ts index c705738100..22e1171d12 100644 --- a/gitnexus/src/core/ingestion/languages/dart/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/dart/scope-resolver.ts @@ -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 { @@ -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); diff --git a/gitnexus/src/core/ingestion/languages/ruby/captures.ts b/gitnexus/src/core/ingestion/languages/ruby/captures.ts index 7f5d98f91e..c072b84d86 100644 --- a/gitnexus/src/core/ingestion/languages/ruby/captures.ts +++ b/gitnexus/src/core/ingestion/languages/ruby/captures.ts @@ -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 = new Set(['include', 'extend', 'prepend']); @@ -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), }); @@ -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), }); diff --git a/gitnexus/src/core/ingestion/languages/ruby/import-target.ts b/gitnexus/src/core/ingestion/languages/ruby/import-target.ts index a8dcd323c6..a31f75febc 100644 --- a/gitnexus/src/core/ingestion/languages/ruby/import-target.ts +++ b/gitnexus/src/core/ingestion/languages/ruby/import-target.ts @@ -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; @@ -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('/') diff --git a/gitnexus/src/core/ingestion/languages/ruby/interpret.ts b/gitnexus/src/core/ingestion/languages/ruby/interpret.ts index 2cee5a1d2f..e329e19f45 100644 --- a/gitnexus/src/core/ingestion/languages/ruby/interpret.ts +++ b/gitnexus/src/core/ingestion/languages/ruby/interpret.ts @@ -1,4 +1,5 @@ import type { CaptureMatch, ParsedImport, ParsedTypeBinding, TypeRef } from 'gitnexus-shared'; +import { isHeritageMarker } from '../../utils/heritage-marker.js'; // ─── interpretImport ────────────────────────────────────────────────────── @@ -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 }; } diff --git a/gitnexus/src/core/ingestion/languages/ruby/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/ruby/scope-resolver.ts index afefb0c9d3..df137038ee 100644 --- a/gitnexus/src/core/ingestion/languages/ruby/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/ruby/scope-resolver.ts @@ -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, @@ -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!); @@ -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!); diff --git a/gitnexus/src/core/ingestion/utils/heritage-marker.ts b/gitnexus/src/core/ingestion/utils/heritage-marker.ts new file mode 100644 index 0000000000..322244a39a --- /dev/null +++ b/gitnexus/src/core/ingestion/utils/heritage-marker.ts @@ -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 = { + 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 `::...`. 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); +} diff --git a/gitnexus/test/unit/ingestion/heritage-marker.test.ts b/gitnexus/test/unit/ingestion/heritage-marker.test.ts new file mode 100644 index 0000000000..57d0a25d84 --- /dev/null +++ b/gitnexus/test/unit/ingestion/heritage-marker.test.ts @@ -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); + }); +});