diff --git a/gitnexus/src/core/lbug/csv-generator.ts b/gitnexus/src/core/lbug/csv-generator.ts index 18f91e723..68a66aa0e 100644 --- a/gitnexus/src/core/lbug/csv-generator.ts +++ b/gitnexus/src/core/lbug/csv-generator.ts @@ -23,7 +23,10 @@ import { parseTruthyEnv } from '../ingestion/utils/env.js'; import { SYMBOL_NODE_LABELS } from '../ingestion/utils/symbol-labels.js'; import { applyCjkSegmentationIfEnabled } from '../search/cjk-segmentation.js'; -const DECLARED_RELATION_PAIRS = parseRelationSchemaPairs(RELATION_SCHEMA); +/** Computed once — `RELATION_SCHEMA` is a static template literal. Exported so + * the streamed sinks (`GraphEmitSink`, `PdgEmitSink`) share this parse + * instead of each re-deriving it from the same DDL string. */ +export const DECLARED_RELATION_PAIRS = parseRelationSchemaPairs(RELATION_SCHEMA); /** * Deterministic output ordering — optional (out-of-core / windowed-resolve diff --git a/gitnexus/src/core/lbug/graph-emit-sink.ts b/gitnexus/src/core/lbug/graph-emit-sink.ts index 4e1845d71..c03bacf01 100644 --- a/gitnexus/src/core/lbug/graph-emit-sink.ts +++ b/gitnexus/src/core/lbug/graph-emit-sink.ts @@ -95,8 +95,8 @@ import fs from 'fs'; import path from 'path'; import type { GraphNode, GraphRelationship, RelationshipType } from 'gitnexus-shared'; import type { KnowledgeGraph } from '../graph/types.js'; -import { REL_CSV_HEADER, buildRelRow } from './csv-generator.js'; -import { getNodeLabel } from './rel-pair-routing.js'; +import { DECLARED_RELATION_PAIRS, REL_CSV_HEADER, buildRelRow } from './csv-generator.js'; +import { assertDeclaredPair, getNodeLabel } from './rel-pair-routing.js'; import { NODE_TABLES } from './schema.js'; import { DEFAULT_EMIT_CHUNK_ROWS, SyncCsvWriter } from './sync-csv-writer.js'; @@ -412,6 +412,7 @@ export class GraphEmitSink implements KnowledgeGraph, GraphEmitControl { if (!this.validTables.has(fromLabel) || !this.validTables.has(toLabel)) return; const pairKey = `${fromLabel}|${toLabel}`; + assertDeclaredPair(pairKey, DECLARED_RELATION_PAIRS); let writer = this.relWriters.get(pairKey); if (writer === undefined) { try { diff --git a/gitnexus/src/core/lbug/pdg-emit-sink.ts b/gitnexus/src/core/lbug/pdg-emit-sink.ts index 79f8b899f..cc5560d2a 100644 --- a/gitnexus/src/core/lbug/pdg-emit-sink.ts +++ b/gitnexus/src/core/lbug/pdg-emit-sink.ts @@ -49,11 +49,12 @@ import type { GraphNode, GraphRelationship, RelationshipType } from 'gitnexus-sh import type { KnowledgeGraph } from '../graph/types.js'; import { BASICBLOCK_CSV_HEADER, + DECLARED_RELATION_PAIRS, REL_CSV_HEADER, buildBasicBlockRow, buildRelRow, } from './csv-generator.js'; -import { getNodeLabel } from './rel-pair-routing.js'; +import { assertDeclaredPair, getNodeLabel } from './rel-pair-routing.js'; import { DEFAULT_EMIT_CHUNK_ROWS, SyncCsvWriter } from './sync-csv-writer.js'; import { NODE_TABLES, type NodeTableName } from './schema.js'; @@ -165,6 +166,7 @@ export class PdgEmitSink implements KnowledgeGraph { // `RelPairRouter` exactly so the streamed set matches the whole-graph set. if (!this.validTables.has(fromLabel) || !this.validTables.has(toLabel)) return; const pairKey = `${fromLabel}|${toLabel}`; + assertDeclaredPair(pairKey, DECLARED_RELATION_PAIRS); let writer = this.relWriters.get(pairKey); if (writer === undefined) { try { diff --git a/gitnexus/src/core/lbug/rel-pair-routing.ts b/gitnexus/src/core/lbug/rel-pair-routing.ts index 9729b5bec..5a77cec0b 100644 --- a/gitnexus/src/core/lbug/rel-pair-routing.ts +++ b/gitnexus/src/core/lbug/rel-pair-routing.ts @@ -69,6 +69,29 @@ export interface RelPairMeta { rows: number; } +/** + * Fail fast on an endpoint-label pair absent from the relationship DDL, the + * same guard `RelPairRouter.route` applies to the whole-graph emit. Exported + * so the streamed sinks (`GraphEmitSink`, `PdgEmitSink`) can apply it too — + * without this, an undeclared pair on a streaming run reaches `COPY`, fails + * the bulk insert, and is silently dropped by the per-edge fallback instead + * of failing loudly like the non-streaming path does. + * + * Takes the already-built `From|To` pairKey rather than the two labels — every + * caller needs that same key immediately after for its own Map/stream lookup, + * and this is on the per-edge hot path, so building it twice would be a + * needless allocation per edge. `|` cannot appear inside a label (node labels + * are `NODE_TABLES` identifiers), so splitting it back apart for the error + * message is safe. + */ +export const assertDeclaredPair = (pairKey: string, declaredPairs: ReadonlySet): void => { + if (!declaredPairs.has(pairKey)) { + throw new Error( + `Relationship label pair ${pairKey.replaceAll('|', '→')} is not declared in the LadybugDB relation schema`, + ); + } +}; + /** * Routes already-escaped relationship CSV rows to per-FROM→TO-label-pair * files. Filters edges whose endpoint labels are not valid node tables @@ -124,11 +147,7 @@ export class RelPairRouter { } const pairKey = `${fromLabel}|${toLabel}`; - if (!this.declaredPairs.has(pairKey)) { - throw new Error( - `Relationship label pair ${fromLabel}→${toLabel} is not declared in the LadybugDB relation schema`, - ); - } + assertDeclaredPair(pairKey, this.declaredPairs); const ws = this.streams.get(pairKey); if (ws === undefined) { // First edge for this pair: open the stream, write header + row. diff --git a/gitnexus/src/core/lbug/schema.ts b/gitnexus/src/core/lbug/schema.ts index 5803776fa..92aceae1f 100644 --- a/gitnexus/src/core/lbug/schema.ts +++ b/gitnexus/src/core/lbug/schema.ts @@ -332,6 +332,8 @@ CREATE REL TABLE ${REL_TABLE_NAME} ( FROM Method TO Interface, FROM Method TO \`Constructor\`, FROM Method TO \`Property\`, + FROM Method TO \`Variable\`, + FROM Method TO \`Const\`, FROM Method TO CodeElement, FROM \`Template\` TO \`Template\`, FROM \`Template\` TO Function, @@ -376,6 +378,12 @@ CREATE REL TABLE ${REL_TABLE_NAME} ( FROM \`Enum\` TO Community, FROM \`Enum\` TO Class, FROM \`Enum\` TO Interface, + FROM \`Enum\` TO Function, + FROM \`Enum\` TO Method, + FROM \`Enum\` TO \`Struct\`, + FROM \`Enum\` TO \`Constructor\`, + FROM \`Enum\` TO \`Property\`, + FROM \`Enum\` TO \`TypeAlias\`, FROM \`Macro\` TO Community, FROM \`Macro\` TO Function, FROM \`Macro\` TO Method, @@ -386,10 +394,12 @@ CREATE REL TABLE ${REL_TABLE_NAME} ( FROM \`Namespace\` TO Community, FROM \`Namespace\` TO \`Struct\`, FROM \`Trait\` TO Method, + FROM \`Trait\` TO Function, FROM \`Trait\` TO \`Constructor\`, FROM \`Trait\` TO \`Property\`, FROM \`Trait\` TO Community, FROM \`Impl\` TO Method, + FROM \`Impl\` TO Function, FROM \`Impl\` TO \`Constructor\`, FROM \`Impl\` TO \`Property\`, FROM \`Impl\` TO Community, @@ -400,10 +410,16 @@ CREATE REL TABLE ${REL_TABLE_NAME} ( FROM \`TypeAlias\` TO \`Trait\`, FROM \`TypeAlias\` TO Class, FROM \`Const\` TO Community, + FROM \`Const\` TO Method, FROM \`Static\` TO Community, FROM \`Variable\` TO Community, + FROM \`Variable\` TO Method, FROM \`Property\` TO Community, FROM \`Property\` TO \`Property\`, + FROM \`Property\` TO Class, + FROM \`Property\` TO \`Enum\`, + FROM \`Property\` TO Function, + FROM \`Property\` TO \`Struct\`, FROM \`Record\` TO Method, FROM \`Record\` TO \`Constructor\`, FROM \`Record\` TO \`Property\`, diff --git a/gitnexus/src/storage/repo-manager.ts b/gitnexus/src/storage/repo-manager.ts index 805b28ce5..afe51a1fb 100644 --- a/gitnexus/src/storage/repo-manager.ts +++ b/gitnexus/src/storage/repo-manager.ts @@ -645,8 +645,31 @@ export interface RepoMeta { * as namespace edges (#2746), enabling qualified constructor and method CALLS * edges. Pre-v31 indexes retain the old package-target/missing-edge graph for * unchanged files through the reuse gate. Force a full re-analyze. + * + * v32: the relation DDL (the single shared `CodeRelation` REL TABLE) gains + * sixteen FROM/TO pairs carried by `HAS_METHOD`/`HAS_PROPERTY` and + * scope-resolution edges: Enum→{Function, Method, Struct, Constructor, + * Property, TypeAlias}, Property→{Class, Enum, Function, Struct}, + * Method→{Variable, Const}, Trait→Function, Impl→Function, Const→Method and + * Variable→Method. The Enum/Property set was observed on Swift (enums carry + * computed properties, methods, initializers and nested types) and is also + * reached by Java/PHP enum members; Trait/Impl→Function covers a Rust + * `impl`/`trait` method, which is minted as a `Function` node, not `Method`; + * Const/Variable→Method and its sibling Method→Const cover a JS/TS object + * literal's shorthand methods, whose owner is labelled `Const`/`Variable`. A + * pre-v32 database physically lacks these from-to pairs — see + * `assertDeclaredPair` (rel-pair-routing.ts) for why an incremental top-up + * fails loudly on one path and silently on the other. Force a full re-analyze. + * + * (This shipped as v31 on its own branch; `main` took 31 for #2746 first, so + * it is renumbered here. Re-check both constants against origin/main + * immediately before merging — this is the sixth time that collision has + * bitten. If this change is ever reverted, do not free 32 for reuse — the + * reuse gate is exact equality, so an index already stamped 32 would satisfy + * it against a differently-shaped reverted DB. Start the next allocation at + * 33 instead.) */ -export const INCREMENTAL_SCHEMA_VERSION = 31; +export const INCREMENTAL_SCHEMA_VERSION = 32; export interface IndexedRepo { repoPath: string; diff --git a/gitnexus/test/unit/call-summary-schema-version.test.ts b/gitnexus/test/unit/call-summary-schema-version.test.ts index 1adbb2a7e..bd35a8032 100644 --- a/gitnexus/test/unit/call-summary-schema-version.test.ts +++ b/gitnexus/test/unit/call-summary-schema-version.test.ts @@ -73,12 +73,12 @@ describe('CALL_SUMMARY relation-type exclusion (U-C1)', () => { }); describe('CALL_SUMMARY incremental reuse gate (U-C5)', () => { - it('INCREMENTAL_SCHEMA_VERSION is bumped to 31 (Python module-import resolution, #2746)', () => { + it('INCREMENTAL_SCHEMA_VERSION is bumped to 32 (Rust/Swift/JS-TS member-containment DDL, #2769)', () => { // Moves with every bump BY DESIGN — that is the point of pinning it. A // change that alters emitted ids or edges without bumping would otherwise // ship silently, and an existing index would keep serving the old graph // through the reuse gate below. - expect(INCREMENTAL_SCHEMA_VERSION).toBe(31); + expect(INCREMENTAL_SCHEMA_VERSION).toBe(32); }); it('a pre-current stamp fails the `=== INCREMENTAL_SCHEMA_VERSION` reuse gate → forces full re-analyze', () => { @@ -208,7 +208,12 @@ describe('CALL_SUMMARY incremental reuse gate (U-C5)', () => { // A pre-v31 (v30) index treats `from pkg import models` as a named package // import, so unchanged files retain the old missing qualified CALLS edges. expect(passesReuseGate(30)).toBe(false); + // A pre-v32 (v31) index predates the Rust impl/trait, JS/TS object-literal + // and Swift member-containment relation pairs (#2769), so an incremental + // top-up emitting one of those edges would fail the bulk COPY (or silently + // drop it on the streamed path) → must NOT reuse. + expect(passesReuseGate(31)).toBe(false); // The current stamp passes the gate (incremental top-up eligible). - expect(passesReuseGate(31)).toBe(true); + expect(passesReuseGate(32)).toBe(true); }); }); diff --git a/gitnexus/test/unit/lbug/graph-emit-sink.test.ts b/gitnexus/test/unit/lbug/graph-emit-sink.test.ts index 7f051ce53..a315f7f86 100644 --- a/gitnexus/test/unit/lbug/graph-emit-sink.test.ts +++ b/gitnexus/test/unit/lbug/graph-emit-sink.test.ts @@ -127,6 +127,29 @@ describe('GraphEmitSink routing', () => { expect(manifest).toMatchObject({ totalRows: 0 }); expect(real.relationshipCount).toBe(0); }); + + it('throws rather than silently dropping an undeclared endpoint-label pair (#2769)', () => { + // Before #2769's fix, this pair reached COPY, failed the bulk insert, and + // the per-edge fallback swallowed the failure into `catch {}` — the run + // still exited 0 with the edge silently missing. Both endpoints are valid + // node tables (so the validTables gate above does not catch it); the pair + // itself is simply absent from RELATION_SCHEMA. + const real = createKnowledgeGraph(); + const sink = new GraphEmitSink(real, csvDir); + sink.beginStreaming(); + + const undeclared: GraphRelationship = { + id: 'CALLS:Static:a->Static:b', + sourceId: 'Static:src/a.ts:A', + targetId: 'Static:src/a.ts:B', + type: 'CALLS', + confidence: 1, + reason: 'direct', + }; + expect(() => sink.addRelationship(undeclared)).toThrow( + /Relationship label pair Static→Static is not declared/, + ); + }); }); describe('GraphEmitSink arming', () => { diff --git a/gitnexus/test/unit/lbug/pdg-emit-sink.test.ts b/gitnexus/test/unit/lbug/pdg-emit-sink.test.ts index 7d4699529..1a63e165c 100644 --- a/gitnexus/test/unit/lbug/pdg-emit-sink.test.ts +++ b/gitnexus/test/unit/lbug/pdg-emit-sink.test.ts @@ -78,6 +78,27 @@ describe('PdgEmitSink — routing', () => { sink.finalize(); }); + it('throws rather than silently dropping an undeclared endpoint-label pair (#2769)', () => { + // PDG edges are always BasicBlock|BasicBlock in production, but the sink + // applies no declared-pair gate of its own — this pins that the shared + // `assertDeclaredPair` guard (also wired into GraphEmitSink) still fires + // here rather than reaching COPY and silently dropping the edge. + const real = createKnowledgeGraph(); + const sink = new PdgEmitSink(real, path.join(tmpRoot, 'pdg-csv')); + + const undeclared: GraphRelationship = { + id: 'CFG:Static:a->Static:b', + sourceId: 'Static:src/a.ts:A', + targetId: 'Static:src/a.ts:B', + type: 'CFG', + confidence: 1, + reason: 'seq', + }; + expect(() => sink.addRelationship(undeclared)).toThrow( + /Relationship label pair Static→Static is not declared/, + ); + }); + it('delegates structural nodes, CALLS, and the whole-program TAINT_PATH edge to the real graph', () => { const real = createKnowledgeGraph(); const sink = new PdgEmitSink(real, path.join(tmpRoot, 'pdg-csv')); diff --git a/gitnexus/test/unit/schema.test.ts b/gitnexus/test/unit/schema.test.ts index 43e6cb7ba..b4c2cd04c 100644 --- a/gitnexus/test/unit/schema.test.ts +++ b/gitnexus/test/unit/schema.test.ts @@ -22,6 +22,7 @@ import { EMBEDDING_SCHEMA, CREATE_VECTOR_INDEX_QUERY, } from '../../src/core/lbug/schema.js'; +import { parseRelationSchemaPairs } from '../../src/core/lbug/rel-pair-routing.js'; describe('LadybugDB Schema', () => { describe('NODE_TABLES', () => { @@ -212,32 +213,70 @@ describe('LadybugDB Schema', () => { expect(RELATION_SCHEMA).toContain('FROM Class TO CodeElement'); }); + it('declares the Swift enum/property member-containment pairs (#2769)', () => { + // Asserted through the runtime's own parser, not raw strings, so a + // cosmetic DDL formatting change cannot fail this without a semantic + // change (see PR #2769 review Finding 7). The Rust impl/trait and JS/TS + // object-literal pairs get their own tests below — narrower and named + // for the specific abort each one fixes. + const declaredPairs = parseRelationSchemaPairs(RELATION_SCHEMA); + const memberPairs = [ + // Swift enum members (also reached by Java/PHP enum methods) + 'Enum|Function', + 'Enum|Method', + 'Enum|Struct', + 'Enum|Constructor', + 'Enum|Property', + 'Enum|TypeAlias', + 'Property|Class', + 'Property|Enum', + 'Property|Function', + 'Property|Struct', + 'Method|Variable', + 'Method|Const', + ]; + + for (const pair of memberPairs) { + expect(declaredPairs.has(pair)).toBe(true); + } + }); + it('has all FROM/TO pairs needed for HAS_METHOD edges', () => { // HAS_METHOD sources: Class, Interface, Struct, Trait, Impl, Record // HAS_METHOD targets: Method, Constructor (Property is now HAS_PROPERTY) + const declaredPairs = parseRelationSchemaPairs(RELATION_SCHEMA); const sources = ['Class', 'Interface']; const backtickSources = ['Struct', 'Trait', 'Impl', 'Record']; const targets = ['Method']; const backtickTargets = ['Constructor']; - // Non-backtick source → non-backtick target - for (const src of sources) { - for (const tgt of targets) { - expect(RELATION_SCHEMA).toContain(`FROM ${src} TO ${tgt}`); - } - for (const tgt of backtickTargets) { - expect(RELATION_SCHEMA).toContain(`FROM ${src} TO \`${tgt}\``); + for (const src of [...sources, ...backtickSources]) { + for (const tgt of [...targets, ...backtickTargets]) { + expect(declaredPairs.has(`${src}|${tgt}`)).toBe(true); } } + }); - // Backtick source → all targets - for (const src of backtickSources) { - for (const tgt of targets) { - expect(RELATION_SCHEMA).toContain(`FROM \`${src}\` TO ${tgt}`); - } - for (const tgt of backtickTargets) { - expect(RELATION_SCHEMA).toContain(`FROM \`${src}\` TO \`${tgt}\``); - } + it('has FROM/TO pairs for HAS_METHOD edges whose member is minted as Function, not Method (#2769)', () => { + // A Rust `impl`/`trait` method is minted as a `Function` node, not + // `Method` (tree-sitter-queries.ts), so `Impl|Method`/`Trait|Method` + // never fire for it — this was the reproduced abort on any Rust repo + // with an `impl` or `trait` block (PR #2769 review Finding 1). + const declaredPairs = parseRelationSchemaPairs(RELATION_SCHEMA); + for (const pair of ['Trait|Function', 'Impl|Function']) { + expect(declaredPairs.has(pair)).toBe(true); + } + }); + + it('has FROM/TO pairs for HAS_METHOD edges owned by a JS/TS object-literal binding (#2769)', () => { + // A `const x = { m() {} }` / `var x = { m() {} }` owner is labelled + // verbatim `Const`/`Variable` (ast-helpers.ts), and its shorthand + // method is a `Method` node — this was the second reproduced abort, + // hit on ordinary TS/JS code including GitNexus's own source tree + // (PR #2769 review Finding 1). + const declaredPairs = parseRelationSchemaPairs(RELATION_SCHEMA); + for (const pair of ['Const|Method', 'Variable|Method']) { + expect(declaredPairs.has(pair)).toBe(true); } }); });