diff --git a/gitnexus/bench/schema-pairs/README.md b/gitnexus/bench/schema-pairs/README.md new file mode 100644 index 000000000..29594b9d7 --- /dev/null +++ b/gitnexus/bench/schema-pairs/README.md @@ -0,0 +1,100 @@ +# Schema pair-set bench (#2793) + +What a bigger `CodeRelation` FROM/TO pair set costs at query time, measured +against a real `@ladybugdb/core` database. + +```bash +# from gitnexus/ +node --import tsx bench/schema-pairs/measure.mjs # print one JSON line per size + a summary +node --import tsx bench/schema-pairs/measure.mjs --check # gate vs baselines.json +``` + +## Why it exists + +`src/core/lbug/schema.ts` generates its relation pairs from two cross products, +and declines to add a third one **on the strength of a number** — roughly 1.04× +at 450 declared pairs, 1.6× at 786, 2.1× at 1024. That measurement used to live +in a scratch directory, so nobody proposing a third rule could re-run it. This +harness is that measurement, committed — and it reproduces those figures. + +Run it before widening a rule, and quote the new ratio in the review. + +Observed on the reference box, **four runs** (ratios vs the 332-pair list): + +| pairs | untyped | typed (floor) | +| ----- | ---------- | ------------- | +| 332 | 1.00× | 1.00× | +| 450 | 0.93–1.05× | 0.98–1.17× | +| 641 | 1.22–1.43× | 1.11–1.23× | +| 786 | 1.52–1.75× | 1.19–1.31× | +| 1024 | 2.03–2.34× | 1.31–1.57× | + +Production's 450 came out _faster_ than 332 on three of the four runs, so at this +size the pair count is inside run-to-run noise. Everything past ~640 is not. +**Quote the range, not a single run** — one run is not evidence here. + +## What it measures + +For each pair-set size it builds a fresh database with all 32 node tables, a +`CodeRelation` table declaring exactly that many FROM/TO pairs, and **identical +data**, then times two query shapes over 40 anchors × 15 reps (median): + +- **`untyped_ms_`** — `MATCH (a {id: $id})-[r:CodeRelation]->(b)`. Neither + endpoint is labelled, so LadybugDB must treat every declared pair as a + candidate. This is the shape `impact`, `context` and `detect_changes` issue + when they walk out from one node id, and the only one whose plan depends on + how many pairs the table declares. +- **`typed_ms_`** — `MATCH (a:Function {…})-[r]->(b:Function)`, the lower + bound. Both endpoints labelled prunes the plan to a single pair, so this was + expected to be flat in the pair count. **It is not** — up to 1.17× at 450 and + 1.57× at 1024 — so a declared-but-unused pair costs something even when the + planner never considers it. `typed_ratio_*` is therefore the floor, not a noise + control; the real cost of widening sits between it and `ratio_*`. A run where + `typed_ratio` moves _more_ than `ratio` is noise-dominated and should be + rerun. +- **`ratio_`** — `untyped_ms_ / untyped_ms_332`. `ratio_450` is the + figure `schema.ts` quotes. + +### Sizes + +The pair set is a prefix of a fixed 32×32 (`NODE_TABLES`²) enumeration, so each +size is a strict superset of the smaller ones. The four pairs the synthetic data +uses are pinned to the front, so **the same rows are reachable by the same query +at every size** — the only variable is how many unused pairs are declared. The +harness fails if the row counts ever differ across sizes. + +| size | what it is | +| ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 332 | the pre-#2792 hand-written list — the reference for every ratio | +| 450 | production today (two cross products + 72 hand-declared pairs) | +| 641 | the third cross product `schema.ts` defers (`DEFINITION_ANCHOR_LABELS × {CodeElement, Section, Typedef, Union, Namespace, Impl, TypeAlias, Static, Template}`), which would leave ~29 hand-declared lines | +| 786 | the size an earlier revision of that comment attributed to the third rule — it is 641; kept as a measured waypoint | +| 1024 | the full cross product, the ceiling | + +## Correctness gate + +Before timing anything, the harness round-trips the **real** `SCHEMA_QUERIES` +through a real database and asserts that `CALL SHOW_CONNECTION('CodeRelation')` +reports exactly the pairs `parseRelationSchemaPairs` finds in `RELATION_SCHEMA`. + +No magic number is baked in: the invariant is that the DDL LadybugDB _accepted_ +carries the pair set our own parser believes it declares. The absolute count is +reported as `declared_pairs`. A pair declared twice would not reach this check at +all — LadybugDB rejects the `CREATE REL TABLE` outright, which is why a duplicate +kills every `analyze` rather than one repository's. + +## What it does NOT measure + +- **Ingest / `COPY` cost.** Pair-set size also multiplies the number of per-pair + CSVs the emitter routes to (`src/core/lbug/rel-pair-routing.ts`); that cost is + covered by `bench/emit-persistence`. +- **At-scale absolute numbers.** Row counts here are small and deliberately + constant. The ratios are the signal; the milliseconds are box-specific. + +## Regenerating the baseline + +`baselines.json` holds one budget, `ratio_450_budget` — the ceiling on what +production's own pair count may cost relative to the 332-pair hand-list it +replaced. Re-run without `--check` **several times** and copy the top of the +observed `ratio_450` range plus headroom — the spread between runs on this box +is wider than the effect being measured at 450, so a single run cannot set it. diff --git a/gitnexus/bench/schema-pairs/baselines.json b/gitnexus/bench/schema-pairs/baselines.json new file mode 100644 index 000000000..41a12d485 --- /dev/null +++ b/gitnexus/bench/schema-pairs/baselines.json @@ -0,0 +1,4 @@ +{ + "_comment": "ratio_450_budget — ceiling on what production's 450-pair set may cost on untyped-endpoint anchored queries, relative to the 332-pair hand-list it replaced. Observed 0.94x and 1.05x across two runs on the reference box (i.e. inside run-to-run noise; it came out faster than 332 once). The budget carries headroom for that spread — compare typed_ratio_450 (1.10-1.17x) for this box's floor. Raise it only with a measured range, never a single run.", + "ratio_450_budget": 1.3 +} diff --git a/gitnexus/bench/schema-pairs/measure.mjs b/gitnexus/bench/schema-pairs/measure.mjs new file mode 100644 index 000000000..59fe92db7 --- /dev/null +++ b/gitnexus/bench/schema-pairs/measure.mjs @@ -0,0 +1,357 @@ +/** + * What a bigger `CodeRelation` FROM/TO pair set costs at query time (#2793). + * + * `src/core/lbug/schema.ts` declares its relation pairs from two cross products + * plus a small hand-written remainder, and it justifies NOT adding a third cross + * product with a number: anchored queries cost ~1.04× at 450 declared pairs but + * 1.6× at 786 and 2.1× at 1024. That measurement previously lived in a scratch + * directory, so the claim could not be re-checked when someone proposed + * widening a rule. This is it, committed. + * + * WHAT IT MEASURES. Against a real `@ladybugdb/core` database, with byte-identical + * DATA at every size, it times the query shape whose plan actually depends on the + * declared pair set: + * + * MATCH (a {id: $id})-[r:CodeRelation]->(b) RETURN b.id + * + * Neither endpoint is labelled, so LadybugDB must consider every declared + * FROM/TO pair as a candidate — this is the shape `impact`, `context` and + * `detect_changes` all issue when they walk out from one node id. + * + * A LABEL-typed query (`MATCH (a:Function)-[r]->(b:Function)`) is measured + * alongside it as the LOWER BOUND. Its plan prunes to a single pair, so it was + * expected to be flat in the pair count — it is NOT. Measured here it reaches + * 1.17× at 450 and 1.57× at 1024 against the same 332-pair reference, i.e. a + * declared-but-unused pair costs something even when the planner never + * considers it (per-pair catalog/storage overhead the query pays regardless). + * So `typed_ratio_*` is not a noise control: it is the floor, and the true cost + * of a wider pair set lies between it and `ratio_*`. Treat any run where + * `typed_ratio` moves MORE than `ratio` as noise-dominated. + * + * SIZES. The pair set is a prefix of a fixed 32×32 (`NODE_TABLES`²) enumeration + * so every size is a strict SUPERSET of the smaller ones, and the four pairs the + * data actually uses are pinned first — so the same rows are reachable by the + * same query at every size, and the only variable is how many UNUSED pairs the + * table declares: + * - 332 — the pre-#2792 hand-written list (the historical baseline); + * - 450 — production today (two cross products + 72 hand-declared); + * - 641 — the third cross product schema.ts defers + * (`DEFINITION_ANCHOR_LABELS × {CodeElement, Section, Typedef, Union, + * Namespace, Impl, TypeAlias, Static, Template}`), which would leave + * only ~29 hand-declared lines; + * - 786 — the size an earlier revision of that comment attributed to the + * third rule (it is 641; 786 is kept as a measured waypoint); + * - 1024 — the full cross product, the ceiling. + * + * Ratios are reported against 332, the smallest size — `ratio_450` is the + * number schema.ts quotes. + * + * CORRECTNESS GATE. Before timing anything it round-trips the REAL + * `SCHEMA_QUERIES` through a real database and asserts that + * `CALL SHOW_CONNECTION('CodeRelation')` reports exactly the pairs + * `parseRelationSchemaPairs` finds in `RELATION_SCHEMA`. That is the invariant + * that matters and it needs no magic number: it proves the DDL LadybugDB + * ACCEPTED carries the pair set our own parser believes it declares. (A + * duplicated FROM/TO would not even get this far — LadybugDB rejects the + * `CREATE REL TABLE` outright, which is why that failure kills every `analyze`.) + * The absolute count is reported as `declared_pairs` for the record. + * + * Build-free: imports the `.ts` sources through tsx. + * + * node --import tsx bench/schema-pairs/measure.mjs # print JSON lines + * node --import tsx bench/schema-pairs/measure.mjs --check # gate vs baselines.json + * + * `--check` fails if the correctness gate breaks, or if `ratio_450` exceeds its + * budget — i.e. if production's own pair count starts costing materially more + * than the hand-written list it replaced. + */ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { NODE_TABLES } from 'gitnexus-shared'; +import { + NODE_SCHEMA_QUERIES, + RELATION_SCHEMA, + REL_TABLE_NAME, +} from '../../src/core/lbug/schema.ts'; +import { parseRelationSchemaPairs } from '../../src/core/lbug/rel-pair-routing.ts'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const BASELINE_PATH = path.resolve(__dirname, 'baselines.json'); + +const lbug = (await import('@ladybugdb/core')).default; + +// ---- sizes + the pair enumeration every size is a prefix of ---- + +const SIZES = [332, 450, 641, 786, 1024]; +const REFERENCE_SIZE = 332; // ratios are relative to this +const PRODUCTION_SIZE = 450; // the size schema.ts ships + +// The four pairs the synthetic data uses. Pinned to the FRONT of the +// enumeration so they are declared at every size — otherwise a smaller pair set +// would simply carry fewer rows and the comparison would measure data volume, +// not pair-set size. +const DATA_PAIRS = [ + ['File', 'Function'], + ['Function', 'Function'], + ['Function', 'Class'], + ['Class', 'Method'], +]; + +const pairKey = ([from, to]) => `${from}|${to}`; + +// NODE_TABLES² in declaration order, data pairs first, deduped. 32² = 1024. +const PAIR_UNIVERSE = (() => { + const seen = new Set(DATA_PAIRS.map(pairKey)); + const all = [...DATA_PAIRS]; + for (const from of NODE_TABLES) { + for (const to of NODE_TABLES) { + const key = `${from}|${to}`; + if (seen.has(key)) continue; + seen.add(key); + all.push([from, to]); + } + } + return all; +})(); + +if (PAIR_UNIVERSE.length !== NODE_TABLES.length ** 2) { + throw new Error( + `bench: pair universe is ${PAIR_UNIVERSE.length}, expected ${NODE_TABLES.length ** 2} ` + + `(NODE_TABLES changed — update SIZES, the 1024 ceiling is no longer the ceiling)`, + ); +} +for (const size of SIZES) { + if (size > PAIR_UNIVERSE.length) { + throw new Error(`bench: size ${size} exceeds the ${PAIR_UNIVERSE.length}-pair universe`); + } +} + +const relTableDdlFor = (size) => { + const pairs = PAIR_UNIVERSE.slice(0, size).map(([from, to]) => ` FROM \`${from}\` TO \`${to}\``); + return `CREATE REL TABLE ${REL_TABLE_NAME} (\n${pairs.join(',\n')},\n type STRING,\n confidence DOUBLE,\n reason STRING,\n step INT32\n)`; +}; + +// ---- synthetic data (identical at every size) ---- + +const FILES = 20; +const FNS_PER_FILE = 8; +const CLASSES = 40; +const METHODS_PER_CLASS = 4; +const CALLS_PER_FN = 3; +const REPS = 15; // median over reps +const ANCHORS = 40; // distinct anchor ids queried per rep + +// Batched with UNWIND rather than one statement per row: per-statement overhead +// dwarfs the insert itself here, and load time is not what this bench measures. +function dataStatements() { + const stmts = []; + const fnIds = []; + const classIds = []; + const methodIds = []; + const fileIds = []; + for (let f = 0; f < FILES; f++) fileIds.push(`file-${f}`); + for (let f = 0; f < FILES; f++) { + for (let i = 0; i < FNS_PER_FILE; i++) fnIds.push(`fn-${f}-${i}`); + } + for (let c = 0; c < CLASSES; c++) { + classIds.push(`cls-${c}`); + for (let m = 0; m < METHODS_PER_CLASS; m++) methodIds.push(`m-${c}-${m}`); + } + + const nodeBatch = (label, ids) => + `UNWIND [${ids.map((id) => `{id: '${id}'}`).join(', ')}] AS r ` + + `CREATE (:\`${label}\` {id: r.id, name: r.id, filePath: 'bench.ts'})`; + stmts.push(nodeBatch('File', fileIds)); + stmts.push(nodeBatch('Function', fnIds)); + stmts.push(nodeBatch('Class', classIds)); + stmts.push(nodeBatch('Method', methodIds)); + + const relBatch = (fromLabel, toLabel, type, edges) => + `UNWIND [${edges.map(([f, t]) => `{f: '${f}', t: '${t}'}`).join(', ')}] AS e ` + + `MATCH (a:\`${fromLabel}\` {id: e.f}), (b:\`${toLabel}\` {id: e.t}) ` + + `CREATE (a)-[:${REL_TABLE_NAME} {type: '${type}', confidence: 1.0, reason: 'bench', step: 0}]->(b)`; + + const contains = []; + for (let f = 0; f < FILES; f++) { + for (let i = 0; i < FNS_PER_FILE; i++) contains.push([`file-${f}`, `fn-${f}-${i}`]); + } + stmts.push(relBatch('File', 'Function', 'CONTAINS', contains)); + + // Function→Function calls: each fn calls the next CALLS_PER_FN, wrapping. + const calls = []; + for (let i = 0; i < fnIds.length; i++) { + for (let k = 1; k <= CALLS_PER_FN; k++) calls.push([fnIds[i], fnIds[(i + k) % fnIds.length]]); + } + stmts.push(relBatch('Function', 'Function', 'CALLS', calls)); + + const uses = fnIds.map((id, i) => [id, classIds[i % classIds.length]]); + stmts.push(relBatch('Function', 'Class', 'USES', uses)); + + const hasMethod = []; + for (let c = 0; c < CLASSES; c++) { + for (let m = 0; m < METHODS_PER_CLASS; m++) hasMethod.push([`cls-${c}`, `m-${c}-${m}`]); + } + stmts.push(relBatch('Class', 'Method', 'HAS_METHOD', hasMethod)); + + // Anchors: functions, which have out-edges on two distinct declared pairs. + return { stmts, anchors: fnIds.slice(0, ANCHORS) }; +} + +const { stmts: DATA_STATEMENTS, anchors: ANCHOR_IDS } = dataStatements(); + +// ---- timing ---- + +const median = (xs) => { + const s = [...xs].sort((a, b) => a - b); + const m = Math.floor(s.length / 2); + return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2; +}; + +const withDb = async (fn) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-bench-pairs-')); + const db = new lbug.Database(path.join(dir, 'db')); + const conn = new lbug.Connection(db); + try { + return await fn(conn); + } finally { + await conn.close().catch(() => {}); + await db.close?.().catch?.(() => {}); + fs.rmSync(dir, { recursive: true, force: true }); + } +}; + +async function runAll(conn, statements) { + for (const s of statements) await conn.query(s); +} + +// The measured shape: BOTH endpoints untyped, anchored by id. LadybugDB must +// consider every declared FROM/TO pair as a candidate. +const UNTYPED_QUERY = (id) => + `MATCH (a {id: '${id}'})-[r:${REL_TABLE_NAME}]->(b) RETURN b.id AS id, r.type AS type`; +// The lower bound: both endpoints labelled, so the planner prunes to one pair. +// Still not flat in the pair count (see the header) — an unused declared pair +// costs something even when the plan never touches it. +const TYPED_QUERY = (id) => + `MATCH (a:Function {id: '${id}'})-[r:${REL_TABLE_NAME}]->(b:Function) RETURN b.id AS id`; + +async function timeQueries(conn, build) { + // Warm: run the whole anchor sweep once uncounted (plan cache + page cache). + for (const id of ANCHOR_IDS) await (await conn.query(build(id))).getAll(); + const samples = []; + let rows = 0; + for (let rep = 0; rep < REPS; rep++) { + const start = process.hrtime.bigint(); + let n = 0; + for (const id of ANCHOR_IDS) n += (await (await conn.query(build(id))).getAll()).length; + samples.push(Number(process.hrtime.bigint() - start) / 1e6); + rows = n; + } + return { ms: median(samples), rows }; +} + +async function measureSize(size) { + return withDb(async (conn) => { + for (const q of NODE_SCHEMA_QUERIES) await conn.query(q); + await conn.query(relTableDdlFor(size)); + await runAll(conn, DATA_STATEMENTS); + const untyped = await timeQueries(conn, UNTYPED_QUERY); + const typed = await timeQueries(conn, TYPED_QUERY); + return { + pairs: size, + untyped_ms: Number(untyped.ms.toFixed(3)), + untyped_rows: untyped.rows, + typed_ms: Number(typed.ms.toFixed(3)), + typed_rows: typed.rows, + }; + }); +} + +// ---- correctness gate: the REAL schema, round-tripped ---- + +async function verifyRealSchema() { + return withDb(async (conn) => { + for (const q of NODE_SCHEMA_QUERIES) await conn.query(q); + // If RELATION_SCHEMA declared a pair twice, LadybugDB rejects this outright + // — the failure mode that kills every `analyze`, not just one repo's. + await conn.query(RELATION_SCHEMA); + const res = await conn.query(`CALL SHOW_CONNECTION('${REL_TABLE_NAME}') RETURN *`); + const rows = await res.getAll(); + const actual = new Set( + rows.map( + (r) => + `${r['source table name'] ?? r.source}|${r['destination table name'] ?? r.destination}`, + ), + ); + const expected = parseRelationSchemaPairs(RELATION_SCHEMA); + const missing = [...expected].filter((p) => !actual.has(p)).sort(); + const extra = [...actual].filter((p) => !expected.has(p)).sort(); + return { declared_pairs: expected.size, db_pairs: actual.size, missing, extra }; + }); +} + +// ---- run ---- + +const CHECK = process.argv.includes('--check'); +const failures = []; + +const verified = await verifyRealSchema(); +if (verified.missing.length > 0 || verified.extra.length > 0) { + failures.push( + `RELATION_SCHEMA round-trip mismatch: ${verified.missing.length} pair(s) parsed but absent ` + + `from SHOW_CONNECTION (${verified.missing.slice(0, 5).join(', ')}), ${verified.extra.length} ` + + `present in the DB but unparsed (${verified.extra.slice(0, 5).join(', ')})`, + ); +} + +const results = []; +for (const size of SIZES) results.push(await measureSize(size)); + +const reference = results.find((r) => r.pairs === REFERENCE_SIZE); +const summary = { + ...verified, + missing: undefined, + extra: undefined, + reference_pairs: REFERENCE_SIZE, +}; +for (const r of results) { + summary[`untyped_ms_${r.pairs}`] = r.untyped_ms; + summary[`typed_ms_${r.pairs}`] = r.typed_ms; + summary[`ratio_${r.pairs}`] = Number((r.untyped_ms / reference.untyped_ms).toFixed(3)); + summary[`typed_ratio_${r.pairs}`] = Number((r.typed_ms / reference.typed_ms).toFixed(3)); +} + +// Row counts must be identical at every size — otherwise the sizes are not +// carrying the same data and the ratios mean nothing. +const rowShapes = new Set(results.map((r) => `${r.untyped_rows}/${r.typed_rows}`)); +if (rowShapes.size !== 1) { + failures.push( + `row counts differ across pair-set sizes (${[...rowShapes].join(' vs ')}) — the data pins ` + + `in DATA_PAIRS are not holding, so the ratios compare different graphs`, + ); +} + +if (!CHECK) { + for (const r of results) process.stdout.write(JSON.stringify(r) + '\n'); + process.stdout.write(JSON.stringify(summary) + '\n'); +} else { + const baselines = JSON.parse(fs.readFileSync(BASELINE_PATH, 'utf8')); + const budget = baselines[`ratio_${PRODUCTION_SIZE}_budget`]; + if (budget !== undefined && summary[`ratio_${PRODUCTION_SIZE}`] >= budget) { + failures.push( + `production pair set (${PRODUCTION_SIZE}) costs ${summary[`ratio_${PRODUCTION_SIZE}`]}× vs ` + + `${REFERENCE_SIZE} pairs, >= budget ${budget} (untyped ${reference.untyped_ms}ms -> ` + + `${summary[`untyped_ms_${PRODUCTION_SIZE}`]}ms; typed control ` + + `${summary[`typed_ratio_${PRODUCTION_SIZE}`]}×)`, + ); + } + process.stdout.write(JSON.stringify(summary) + '\n'); +} + +if (failures.length > 0) { + for (const f of failures) process.stderr.write(`[schema-pairs] FAIL: ${f}\n`); + process.exit(1); +} +if (CHECK) process.stderr.write(`[schema-pairs --check] PASS (${results.length} sizes)\n`); diff --git a/gitnexus/bench/scope-capture/baselines.json b/gitnexus/bench/scope-capture/baselines.json index 8b712832c..84a235786 100644 --- a/gitnexus/bench/scope-capture/baselines.json +++ b/gitnexus/bench/scope-capture/baselines.json @@ -14,10 +14,11 @@ "_rebaselined_2766_callee_position_marker": "#2766 review fix: a call's callee selector is no longer DROPPED at capture. An earlier commit on this branch dropped it outright, which also deleted the genuine field read on a func-typed struct field (`h.dep.Work()` where `Work func() error`) - callback/hook/mock structs lost their only ACCESSES evidence. The match is now emitted carrying `@reference.callee-position`, and the phantom is suppressed at EMIT by the resolved target's kind instead. Go only: the other 14 languages' fingerprints are byte-identical, which is the check that this is not a cross-language capture change. Prior 7bb524a32a2eed57a15b454e3a33480e92a496c683e6856ef02179693c0e02e3 -> e47302079e17a5e73711bbed5416557b49327cb67e4932008700ec6b8fb468b3; scaling 1.001 < 1.5; fixtures 102 (unchanged), capture_groups_fp 2103." }, "cobol": { - "fingerprint": "d45bb091b0893d0de4fae2486b31ba21719c9377bf35a0908fd3a36fa1c3bf4e", + "fingerprint": "c8c00b56a7da24e04080eb885714fbbf45e3903324f0cf9df0754f5b5a92e3aa", "scaling_budget": 1.5, "_rebaselined_callable_flow_2522_followup": "PR #2522 follow-up: COBOL procedure-pointer callable flow facts; multi-topic extraction now consumes each grouped scope/declaration match once instead of requiring a duplicate declaration-only match. Prior 68ee0e95eb9f86f2d92ca35f730f4c2d4d83abc1b5241ae767ff3437780ec8d1 -> d45bb091b0893d0de4fae2486b31ba21719c9377bf35a0908fd3a36fa1c3bf4e; scaling 0.853 < 1.5.", - "_note": "Updated for F17-F23 fixes (P2: TIMES guard, ADD GIVING, SQL AS alias). See PR #1959." + "_note": "Updated for F17-F23 fixes (P2: TIMES guard, ADD GIVING, SQL AS alias). See PR #1959.", + "_rebaselined_2793_declaratives": "PR #2793: corpus-only re-baseline. `cobol-declaratives` was added to test/fixtures/lang-resolution to reproduce the `Namespace\u2192Record` analyze abort (DECLARATIVES / USE AFTER STANDARD ERROR ON ), and this bench globs `lang-resolution/cobol-*`, so the corpus grew 14 -> 15 files. Verified capture-neutral: with that one fixture moved aside the fingerprint is byte-identical to the prior d45bb091b0893d0de4fae2486b31ba21719c9377bf35a0908fd3a36fa1c3bf4e. No COBOL capture code changed in that PR. Scaling 0.677 < 1.5." }, "c": { "fingerprint": "3418cded9f7072152f68992f0a426f43ae7d9d553579a47075fc0cab185848a5", diff --git a/gitnexus/src/cli/analyze.ts b/gitnexus/src/cli/analyze.ts index 9475ee25f..6851c2478 100644 --- a/gitnexus/src/cli/analyze.ts +++ b/gitnexus/src/cli/analyze.ts @@ -15,6 +15,8 @@ import v8 from 'v8'; import cliProgress from 'cli-progress'; import { isLbugReady, LbugWipeError } from '../core/lbug/lbug-adapter.js'; import { boundedCheckpointBeforeExit } from '../core/lbug/shutdown-helpers.js'; +import { findUndeclaredRelationPairError } from '../core/lbug/rel-pair-routing.js'; +import { causeChain } from '../lib/utils.js'; import { getOsPageSize, isLbugCheckpointIoError, @@ -101,15 +103,13 @@ const writeFatalToStderr = (label: string, err: unknown): void => { // #2068) is only reachable via `.cause`. Without this the user sees the // wrapper's main-thread stack and never the real frame. `cause.stack` already // begins with the cause's message, so we print the stack alone (not message + - // stack) to avoid repeating it. Depth-bounded so a cyclic `cause` can't loop - // (the phase runner wraps one level; the bound leaves headroom for future - // nesting); uses realStderrWrite so the redirected console.error's ANSI - // clear-line wrapping can't erase it (#1169). - const MAX_CAUSE_DEPTH = 5; - let cause: unknown = isErr ? (err as { cause?: unknown }).cause : undefined; - for (let depth = 0; depth < MAX_CAUSE_DEPTH && cause instanceof Error; depth++) { + // stack) to avoid repeating it. `causeChain` owns the traversal and the depth + // bound that stops a cyclic `cause` looping — this used to be one of four + // hand-rolled copies that had already drifted apart on both. Uses + // realStderrWrite so the redirected console.error's ANSI clear-line wrapping + // can't erase it (#1169). The head is skipped: it was just printed above. + for (const cause of causeChain(isErr ? (err as { cause?: unknown }).cause : undefined)) { realStderrWrite(`\n Caused by: ${cause.stack ?? cause.message}\n`); - cause = (cause as { cause?: unknown }).cause; } }; @@ -1717,6 +1717,36 @@ const analyzeCommandImpl = async ( return; } + // An extracted edge whose FROM→TO label pair is missing from GitNexus's own + // relation DDL (#2789). `assertDeclaredPair` aborts the run rather than let + // the bulk COPY fail late and silently drop the edge, so the user sees a + // mid-run crash inside GitNexus internals with nothing to act on. Name the + // pair, the relationship and the file that produced it, and say plainly that + // a re-run cannot help — this is deterministic for the same input. + // Checked by TYPE (repo norm, #2385) BEFORE the message-text heuristics + // below, and through the `cause` chain because the ingestion phase runner + // rewraps every phase failure as `Phase 'X' failed: …`. + const undeclaredPair = findUndeclaredRelationPairError(err); + if (undeclaredPair !== undefined) { + // Render the error's OWN message indented — same idiom as the + // `LbugWipeError` and page-size branches below. `UndeclaredRelationPairError` + // builds a fully self-contained message (pair, relationship type, both node + // ids, source file, issue URL, `.gitnexusignore` workaround) precisely + // because `gitnexus serve` forwards only `err.message` over worker IPC. + // Re-rendering those fields here would be a second copy of one string, free + // to drift from the first — and the actionable half would reach CLI users + // only. `undeclaredPair.message`, not the outer `msg`: the real error may be + // several `cause` levels below the phase wrapper `msg` came from. + cliError(` ${undeclaredPair.message.replace(/\n/g, '\n ')}\n`, { + recoveryHint: 'undeclared-relation-pair', + labelPair: undeclaredPair.pairKey, + relationType: undeclaredPair.relationType, + sourceFile: undeclaredPair.sourceFile, + }); + process.exitCode = 1; + return; + } + // WAL corruption — the index file is unreadable. Give a clear recovery // path without a confusing stack trace (the native error message alone // is enough signal). diff --git a/gitnexus/src/cli/cli-message.ts b/gitnexus/src/cli/cli-message.ts index 61b65dd00..b9b509b52 100644 --- a/gitnexus/src/cli/cli-message.ts +++ b/gitnexus/src/cli/cli-message.ts @@ -60,7 +60,8 @@ export type RecoveryHint = | 'module-not-found' | 'gitnexusrc-invalid' | 'default-branch-invalid' - | 'index-lock-timeout'; + | 'index-lock-timeout' + | 'undeclared-relation-pair'; /** * Common shape for the optional structured-field bag passed to diff --git a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ids.ts b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ids.ts index abe8b48e4..6f942d88b 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ids.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/ids.ts @@ -54,17 +54,19 @@ import { definitionIdPosition } from '../utils/definition-id.js'; * restricted to function/class-likes, those calls correctly fall * through to the File-node fallback at the bottom of the walk. */ +export const CALLER_ANCHOR_LABELS: ReadonlySet = new Set([ + 'Function', + 'Method', + 'Constructor', + 'Module', + 'Class', + 'Interface', + 'Struct', + 'Enum', +]); + function isCallerAnchorLabel(label: NodeLabel): boolean { - return ( - label === 'Function' || - label === 'Method' || - label === 'Constructor' || - label === 'Module' || - label === 'Class' || - label === 'Interface' || - label === 'Struct' || - label === 'Enum' - ); + return CALLER_ANCHOR_LABELS.has(label); } function rangeContainsPoint( diff --git a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/node-lookup.ts b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/node-lookup.ts index 1c6b94410..a8f90393a 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/node-lookup.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/graph-bridge/node-lookup.ts @@ -244,38 +244,53 @@ export function buildGraphNodeLookup(graph: KnowledgeGraph): GraphNodeLookup { return lookup; } +/** + * Every label {@link buildGraphNodeLookup} registers — and therefore the ONLY + * labels `resolveDefGraphId` can ever return an id for. Both endpoints of every + * scope-resolution edge come from that lookup (the one exception is the File + * fallback in `resolveCallerGraphId`), so this set defines the whole FROM/TO + * surface those edges can produce. + * + * That makes it load-bearing for the LadybugDB relation DDL: a label added here + * without the matching `FROM x TO y` pairs in `RELATION_SCHEMA` crashes + * `analyze` at `assertDeclaredPair` on whichever codebase first emits the pair + * (#2792). `test/unit/schema-pair-coverage.test.ts` derives the required pairs + * from this set and fails in CI instead. + */ +export const LINKABLE_LABELS: ReadonlySet = new Set([ + 'Function', + 'Method', + 'Constructor', + // Program-like module declarations are provider-gated callable-value + // targets and need the same def→graph bridge. + 'Module', + 'Class', + 'Interface', + 'Struct', + 'Enum', + // Trait nodes are linkable so MRO builders can bridge PHP/Rust trait + // defs between scope-resolution DefIds and the graph's node ids. + // IMPLEMENTS edges from classes to traits are otherwise invisible to + // the scope-resolution MRO pass. + 'Trait', + // Variable / Property are linkable too — receiver-bound write/read + // ACCESSES edges target field nodes (e.g. `user.name = "x"` → + // ACCESSES edge to User's `name` Variable/Property node). + 'Variable', + 'Property', + // Const is linkable so the value-receiver-owner bridge in + // `receiver-bound-calls.ts` Case 5 can translate the scope-resolution + // `Variable` def for `export const fooService = {...}` to the canonical + // `Const:filePath:name` graph node id, against which object-literal + // method symbols register their `ownerId` (PR #1718 / issue #1358). + 'Const', + // Macro nodes are linkable so a macro invocation (`log!(…)`) resolved + // via `MacroRegistry` can bridge its scope-resolution `Macro` def to + // the legacy `@definition.macro` graph node and emit the `USES` edge + // (Rust #1934 F72; also covers C/C++ `#define` macro defs). + 'Macro', +]); + export function isLinkableLabel(label: NodeLabel): boolean { - return ( - label === 'Function' || - label === 'Method' || - label === 'Constructor' || - // Program-like module declarations are provider-gated callable-value - // targets and need the same def→graph bridge. - label === 'Module' || - label === 'Class' || - label === 'Interface' || - label === 'Struct' || - label === 'Enum' || - // Trait nodes are linkable so MRO builders can bridge PHP/Rust trait - // defs between scope-resolution DefIds and the graph's node ids. - // IMPLEMENTS edges from classes to traits are otherwise invisible to - // the scope-resolution MRO pass. - label === 'Trait' || - // Variable / Property are linkable too — receiver-bound write/read - // ACCESSES edges target field nodes (e.g. `user.name = "x"` → - // ACCESSES edge to User's `name` Variable/Property node). - label === 'Variable' || - label === 'Property' || - // Const is linkable so the value-receiver-owner bridge in - // `receiver-bound-calls.ts` Case 5 can translate the scope-resolution - // `Variable` def for `export const fooService = {...}` to the canonical - // `Const:filePath:name` graph node id, against which object-literal - // method symbols register their `ownerId` (PR #1718 / issue #1358). - label === 'Const' || - // Macro nodes are linkable so a macro invocation (`log!(…)`) resolved - // via `MacroRegistry` can bridge its scope-resolution `Macro` def to - // the legacy `@definition.macro` graph node and emit the `USES` edge - // (Rust #1934 F72; also covers C/C++ `#define` macro defs). - label === 'Macro' - ); + return LINKABLE_LABELS.has(label); } diff --git a/gitnexus/src/core/lbug/csv-generator.ts b/gitnexus/src/core/lbug/csv-generator.ts index 68a66aa0e..4444a9687 100644 --- a/gitnexus/src/core/lbug/csv-generator.ts +++ b/gitnexus/src/core/lbug/csv-generator.ts @@ -17,8 +17,8 @@ import { createWriteStream, WriteStream } from 'fs'; import path from 'path'; import type { GraphNode, GraphRelationship } from 'gitnexus-shared'; import { KnowledgeGraph } from '../graph/types.js'; -import { NodeTableName, NODE_TABLES, RELATION_SCHEMA } from './schema.js'; -import { parseRelationSchemaPairs, RelPairRouter } from './rel-pair-routing.js'; +import { NodeTableName, RELATION_SCHEMA } from './schema.js'; +import { VALID_NODE_TABLES, parseRelationSchemaPairs, RelPairRouter } from './rel-pair-routing.js'; import { parseTruthyEnv } from '../ingestion/utils/env.js'; import { SYMBOL_NODE_LABELS } from '../ingestion/utils/symbol-labels.js'; import { applyCjkSegmentationIfEnabled } from '../search/cjk-segmentation.js'; @@ -793,13 +793,13 @@ export const streamAllCSVsToDisk = async ( const relRouter = new RelPairRouter( csvDir, REL_CSV_HEADER, - new Set(NODE_TABLES), + VALID_NODE_TABLES, DECLARED_RELATION_PAIRS, ); try { let emitted = 0; for (const rel of orderedRelationships(graph, sortOutput)) { - const pending = relRouter.route(rel.sourceId, rel.targetId, buildRelRow(rel)); + const pending = relRouter.route(rel.sourceId, rel.targetId, buildRelRow(rel), rel.type); if (pending) await pending; // Periodically hand the event loop back so the overlapped node COPY and // write-stream drains run instead of starving behind this synchronous diff --git a/gitnexus/src/core/lbug/graph-emit-sink.ts b/gitnexus/src/core/lbug/graph-emit-sink.ts index a0491ab14..f1527c6d2 100644 --- a/gitnexus/src/core/lbug/graph-emit-sink.ts +++ b/gitnexus/src/core/lbug/graph-emit-sink.ts @@ -85,9 +85,10 @@ * ## Correctness contract * * Structural sibling of {@link PdgEmitSink}, and reuses its row builder - * (`buildRelRow`), header (`REL_CSV_HEADER`), label derivation (`getNodeLabel`) - * and `RelPairRouter` validity check, so the streamed row SET equals the - * whole-graph emit's and the bulk COPY loads the same rows. Set-level, not + * (`buildRelRow`), header (`REL_CSV_HEADER`) and pair classification + * (`relPairKeyFor`, which is also what `RelPairRouter` routes and skips by), so + * the streamed row SET equals the whole-graph emit's and the bulk COPY loads + * the same rows. Set-level, not * byte-level: rows stream in emit order and are not re-sorted under * `GITNEXUS_SORT_GRAPH_OUTPUT`. */ @@ -96,8 +97,12 @@ import path from 'path'; import type { GraphNode, GraphRelationship, RelationshipType } from 'gitnexus-shared'; import type { KnowledgeGraph } from '../graph/types.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 { + VALID_NODE_TABLES, + assertDeclaredPair, + relPairKeyFor, + splitRelPairKey, +} from './rel-pair-routing.js'; import { DEFAULT_EMIT_CHUNK_ROWS, SyncCsvWriter } from './sync-csv-writer.js'; /** @@ -229,7 +234,6 @@ export class StreamedRelationshipRemovalError extends Error { * {@link finalize} once after the pipeline, before `loadGraphToLbug`. */ export class GraphEmitSink implements KnowledgeGraph, GraphEmitControl { - private readonly validTables: Set; private readonly relWriters = new Map(); /** * Ids of relationships already streamed. `KnowledgeGraph.addRelationship` @@ -303,7 +307,6 @@ export class GraphEmitSink implements KnowledgeGraph, GraphEmitControl { private readonly csvDir: string, private readonly chunkRows: number = DEFAULT_EMIT_CHUNK_ROWS, ) { - this.validTables = new Set(NODE_TABLES as readonly string[]); // Own directory, distinct from the PDG sink's: PdgEmitSink wipes and // recreates its dir on construction and opens with O_EXCL, so a shared dir // would destroy the other sink's manifest on a combined --pdg run. @@ -407,16 +410,24 @@ export class GraphEmitSink implements KnowledgeGraph, GraphEmitControl { } // Mirror KnowledgeGraph.addRelationship's first-writer-wins dedup. - const fromLabel = getNodeLabel(relationship.sourceId); - const toLabel = getNodeLabel(relationship.targetId); - // Skip edges whose endpoint labels are not valid node tables — mirrors - // `RelPairRouter` exactly so the streamed set matches the whole-graph set. - if (!this.validTables.has(fromLabel) || !this.validTables.has(toLabel)) return; + // Classify + skip via the SHARED `relPairKeyFor`, not a local copy of its + // three lines, so the streamed set cannot drift from the whole-graph set + // `RelPairRouter` produces. `undefined` = an endpoint label is not a node + // table, so the edge is dropped exactly as the router drops it. + const pairKey = relPairKeyFor(relationship.sourceId, relationship.targetId, VALID_NODE_TABLES); + if (pairKey === undefined) return; - const pairKey = `${fromLabel}|${toLabel}`; - assertDeclaredPair(pairKey, DECLARED_RELATION_PAIRS); + assertDeclaredPair( + pairKey, + DECLARED_RELATION_PAIRS, + relationship.type, + relationship.sourceId, + relationship.targetId, + ); let writer = this.relWriters.get(pairKey); if (writer === undefined) { + // Cold: once per pair, so decoding the key back into its labels is free. + const [fromLabel, toLabel] = splitRelPairKey(pairKey); try { writer = new SyncCsvWriter( path.join(this.csvDir, `rel_${fromLabel}_${toLabel}.csv`), diff --git a/gitnexus/src/core/lbug/pdg-emit-sink.ts b/gitnexus/src/core/lbug/pdg-emit-sink.ts index cc5560d2a..78d337816 100644 --- a/gitnexus/src/core/lbug/pdg-emit-sink.ts +++ b/gitnexus/src/core/lbug/pdg-emit-sink.ts @@ -24,8 +24,8 @@ * `storage/parsedfile-store.ts`. * * Byte-identity (issue acceptance): the sink reuses the SAME shared row - * builders (`buildBasicBlockRow`, `buildRelRow`) and label derivation - * (`getNodeLabel`) as `streamAllCSVsToDisk`, so the streamed CSV line SET is + * builders (`buildBasicBlockRow`, `buildRelRow`) and pair classification + * (`relPairKeyFor`) as `streamAllCSVsToDisk`, so the streamed CSV line SET is * identical to the whole-graph emit's, and the bulk COPY loads the same rows → * the persisted graph is SET-identical and DB-identical. The guarantee is * set-level, not byte-level on the CSV file: the sink streams rows in emit @@ -54,9 +54,14 @@ import { buildBasicBlockRow, buildRelRow, } from './csv-generator.js'; -import { assertDeclaredPair, getNodeLabel } from './rel-pair-routing.js'; +import { + VALID_NODE_TABLES, + assertDeclaredPair, + relPairKeyFor, + splitRelPairKey, +} from './rel-pair-routing.js'; import { DEFAULT_EMIT_CHUNK_ROWS, SyncCsvWriter } from './sync-csv-writer.js'; -import { NODE_TABLES, type NodeTableName } from './schema.js'; +import { type NodeTableName } from './schema.js'; /** * PDG edge types streamed per-file (all intra-block BasicBlock→BasicBlock). @@ -98,7 +103,6 @@ export interface PdgEmitManifest { * `--pdg` emit, then {@link finalize} once after the last language. */ export class PdgEmitSink implements KnowledgeGraph { - private readonly validTables: Set; private bbWriter: SyncCsvWriter | undefined; /** pairKey (`From|To`) → writer. PDG edges are all `BasicBlock|BasicBlock`, * but the map keeps the sink general and the manifest pair-keyed. */ @@ -129,7 +133,6 @@ export class PdgEmitSink implements KnowledgeGraph { private readonly pdgCsvDir: string, private readonly chunkRows: number = DEFAULT_PDG_EMIT_CHUNK_ROWS, ) { - this.validTables = new Set(NODE_TABLES as readonly string[]); // Clear any streamed CSVs left by a previous (possibly crashed) run so a // later COPY never picks up stale rows. fs.rmSync(pdgCsvDir, { recursive: true, force: true }); @@ -160,15 +163,27 @@ export class PdgEmitSink implements KnowledgeGraph { addRelationship(relationship: GraphRelationship): void { if (PDG_EDGE_TYPES.has(relationship.type)) { - const fromLabel = getNodeLabel(relationship.sourceId); - const toLabel = getNodeLabel(relationship.targetId); - // Skip edges whose endpoint labels are not valid node tables — mirrors - // `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); + // Classify + skip via the SHARED `relPairKeyFor`, not a local copy of its + // three lines, so the streamed set cannot drift from the whole-graph set + // `RelPairRouter` produces. `undefined` = an endpoint label is not a node + // table, so the edge is dropped exactly as the router drops it. + const pairKey = relPairKeyFor( + relationship.sourceId, + relationship.targetId, + VALID_NODE_TABLES, + ); + if (pairKey === undefined) return; + assertDeclaredPair( + pairKey, + DECLARED_RELATION_PAIRS, + relationship.type, + relationship.sourceId, + relationship.targetId, + ); let writer = this.relWriters.get(pairKey); if (writer === undefined) { + // Cold: once per pair, so decoding the key back into labels is free. + const [fromLabel, toLabel] = splitRelPairKey(pairKey); try { writer = new SyncCsvWriter( path.join(this.pdgCsvDir, `rel_${fromLabel}_${toLabel}.csv`), diff --git a/gitnexus/src/core/lbug/rel-pair-routing.ts b/gitnexus/src/core/lbug/rel-pair-routing.ts index 5a77cec0b..56b95d0aa 100644 --- a/gitnexus/src/core/lbug/rel-pair-routing.ts +++ b/gitnexus/src/core/lbug/rel-pair-routing.ts @@ -31,10 +31,29 @@ import path from 'path'; import { createWriteStream, type WriteStream } from 'fs'; import { once } from 'events'; import { finished } from 'stream/promises'; +import { NODE_TABLES } from 'gitnexus-shared'; +import { findInCauseChain } from '../../lib/utils.js'; /** Injectable for tests (backpressure/error simulation), mirroring split. */ export type WriteStreamFactory = (filePath: string) => WriteStream; +/** + * Every label LadybugDB has a node table for — the filter that decides whether + * an edge is routable at all. + * + * ONE shared instance, deliberately. `RelPairRouter`, `GraphEmitSink` and + * `PdgEmitSink` each used to build their own `new Set(NODE_TABLES)`; three + * copies of the same immutable set are three chances to seed one of them from + * a different source. Typed `ReadonlySet` because that — not `Object.freeze`, + * which does not touch a Set's internal slots — is what actually stops a + * consumer mutating the shared instance. + * + * Imported straight from `gitnexus-shared` rather than `./schema.js`: schema.ts + * imports `parseRelationSchemaPairs` from this module, so the reverse import + * would close a cycle. + */ +export const VALID_NODE_TABLES: ReadonlySet = new Set(NODE_TABLES); + /** * Derive a node's table label from its graph id. Matches the legacy * `getNodeLabel` that lived inline in `loadGraphToLbug`: @@ -48,20 +67,92 @@ export const getNodeLabel = (nodeId: string): string => { return nodeId.split(':')[0]; }; +/** + * Classify one edge into its `From|To` pair key, or `undefined` when the edge + * must be SKIPPED because an endpoint's label is not a real node table. + * + * THE single definition of "which pair does this edge belong to, and is it + * routable at all". `RelPairRouter.route`, `GraphEmitSink.addRelationship`, + * `PdgEmitSink.addRelationship` and the `structural-pair-coverage` corpus guard + * each used to inline the same three lines (label both ends → drop if either + * label is not a node table → join with `|`). The corpus guard's docblock said + * it "mirrors `RelPairRouter.route`" — a mirror is a drift marker: change the + * skip rule here and the guard would keep classifying by the old one, report + * green, and let `analyze` abort on a pair it had already declared covered. + * + * HOT PATH — called once per edge (~1M on a large repo). Returns the key + * string (which every caller needs anyway for its own Map lookup) rather than + * a `{ pairKey, fromLabel, toLabel }` object or a tuple, so the success path + * allocates nothing beyond what `getNodeLabel` already did. Callers that need + * the two labels back — only when opening a new pair's CSV, once per pair — + * decode the key with {@link splitRelPairKey}. + */ +export const relPairKeyFor = ( + fromId: string, + toId: string, + validTables: ReadonlySet, +): string | undefined => { + const fromLabel = getNodeLabel(fromId); + const toLabel = getNodeLabel(toId); + if (!validTables.has(fromLabel) || !validTables.has(toLabel)) return undefined; + return `${fromLabel}|${toLabel}`; +}; + +/** + * Decode a `From|To` pair key back into its two labels. + * + * Safe because `|` cannot occur inside a node label: every label is a + * `NODE_TABLES` identifier (`[A-Za-z][A-Za-z0-9_]*`), so the FIRST `|` is + * always the separator. That invariant was documented in one comment and + * enforced nowhere while every consumer re-derived it with a bare + * `key.split('|')`. + * + * DECODE ONLY — there is deliberately no matching `encode` helper. The key is + * built once per edge inside {@link relPairKeyFor} (~1M edges on a large + * repo), where a function call is a real regression risk; every decode site is + * cold by construction (once per pair when its CSV is opened, or on the + * throw path of {@link assertDeclaredPair}). + */ +export const splitRelPairKey = (key: string): readonly [from: string, to: string] => { + const sep = key.indexOf('|'); + return sep < 0 ? [key, ''] : [key.slice(0, sep), key.slice(sep + 1)]; +}; + +/** + * Build a fresh matcher for the `FROM