fix(schema): declare the full scope-resolution relation cross product (#2792) (#2793)

* fix(schema): declare the full scope-resolution relation cross product (#2792)

`RELATION_SCHEMA` was hand-listed, and every prior fix added only the
FROM/TO pair named in a crash report — `Const→Method` in #2769, the
Swift/Rust member pairs before it. So `analyze` kept aborting at
`assertDeclaredPair` on the next codebase whose edges happened to land on
a different pair; #2792 reports `Class→Variable` on Java.

Audit the surface instead of the symptom. `buildGraphNodeLookup` skips
any node whose label is not in `isLinkableLabel`, so the lookup holds
only linkable-labelled nodes — and both endpoints of every graph-bridge
edge resolve through that lookup. The emittable surface is therefore
exactly:

  FROM  LINKABLE_LABELS + File   (the module-level caller fallback)
  TO    LINKABLE_LABELS + CALL_TARGET_TYPES

`isCallerAnchorLabel` is a strict subset of linkable and contributes
nothing on top. `CALL_TARGET_TYPES` contributes `Delegate`, which
`tryEmitEdgeWithExplicitTargetId` can emit without going through the
lookup at all.

Generate that 14x14 block into the DDL rather than listing it: 223 -> 322
declared pairs, and no future pair from these sets can be missing by
construction. The containment/inheritance/DI/route/cluster/PDG pairs stay
hand-declared — no single predicate describes them.

Both label sets live in the ingestion layer, which `core/lbug` must not
import, so schema.ts carries twin lists. test/unit/schema-pair-coverage.ts
derives the requirement from the originals and fails CI when either set
grows without the pairs landing here — the piecemeal loop this fix ends.

Measured before widening: at 322 pairs the cost is inside noise
(1.09s vs 1.12s per 300 anchored queries on a 32-table DB), but the full
32x32 cross product is ~1.8x on untyped-endpoint anchored queries. The
audited subset is the right scope, not "declare everything".

INCREMENTAL_SCHEMA_VERSION 34 -> 35: LadybugDB fixes endpoint pairs when
the rel table is created, so a pre-v35 database physically cannot store
these edges.

Closes #2792

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(schema): declare the non-bridge structural pairs COBOL and Vue emit

The generated scope-resolution block closed the half of RELATION_SCHEMA a
label predicate can describe. The hand-declared half was still stale: with
#2791's Function->Variable fix applied, `analyze` continued to abort on this
repo's own test/fixtures/lang-resolution with

  Relationship label pair Module→Property is not declared

A full sweep (assertDeclaredPair patched to log-and-skip, run over the whole
fixture corpus) found 13 undeclared pairs over 106 edges. This branch already
covered 3 of them via the cross product; the remaining 10 come from emitters
outside the graph bridge:

  - cobol-processor.ts mints Module / Namespace / Record / Property /
    CodeElement and wires them with CONTAINS, CALLS and ACCESSES (9 pairs)
  - vue-sfc-extractor.ts emits BINDS_EVENT_HANDLER from a handler Function to
    the child component's File, the only edge whose target is a File (1 pair)

CodeElement, Namespace, Record and File are in neither scope-bridge label set,
so neither the generated block nor schema-pair-coverage.test.ts can reach them.

Adds test/integration/structural-pair-coverage.test.ts, which derives the
requirement from a corpus instead of a predicate: it runs the real pipeline
over the non-bridge fixtures and requires every FROM/TO pair they produce to
be declared. Mutation-checked — dropping `FROM Function TO File` fails it with
exactly Function|File.

Verified: cobol-app, vue-basic and php-transitive-traits now index instead of
aborting; the full lang-resolution corpus completes at 10,876 nodes / 18,517
edges; scrypster/muninndb at 0b7a4272 (the #2789 repro) completes at 20,069
nodes / 71,580 edges, matching #2791 exactly, so this supersedes that PR.

* refactor(test): simplify the structural pair coverage guard

Cleanup pass over the previous commit. No behaviour change to the schema.

- reuse `FIXTURES` and `runPipelineFromRepo` from resolvers/helpers.ts instead
  of re-deriving the fixture root and importing pipeline.js directly
- gate on `distWorkerExists()` like every other integration test that passes
  `workerUrlForTest`, so a missing dist skips rather than fails
- run the three fixtures with `it.concurrent.each`; they share nothing and the
  cost is almost all worker spawn plus grammar load, which overlaps well
  (tests phase 21-24s -> 5.6s measured)
- replace the sentinel-in-a-Set filter with a plain `.filter()` chain, matching
  the sibling unit test, and move the declared/table lookups off the per-edge
  path onto the deduped set
- move the pure string pin out of the integration tier into
  schema-pair-coverage.test.ts, where the identical construct already lives, so
  it needs no build and survives fixture deletion
- trim the schema and test prose that restated the code, and correct the
  BINDS_EVENT_HANDLER attribution: it is emitted by
  languages/vue/scope-resolver.ts, not vue-sfc-extractor.ts
- amend the v35 comment to mention the 10 structural pairs it now also stamps

Still mutation-checked: dropping `FROM Function TO File` now fails both the
integration sweep and the unit pin with exactly Function|File. 89 tests green.

* fix(schema): generate the attachment pair surface and close four analyze aborts

Review of the generated scope-bridge cross product found four `analyze`
hard-aborts still live at head, each reproduced end-to-end on the default
user path (`analyze --index-only --skip-git`):

  Method→Annotation   Spring `@Bean` + `@ConditionalOnMissingBean` (Java + Kotlin)
  Method→File         Vue Options-API `methods:` handler bound to a child event
  Namespace→Record    COBOL `DECLARATIVES` / `USE AFTER STANDARD ERROR ON <file>`
  Class→Tool          `@mcp.tool()` applied to a class

All four are pre-existing on main, and both existing guards were structurally
blind to them: the unit guard derives from LINKABLE_LABELS ∪ CALL_TARGET_TYPES
(none of Annotation/Tool/Record/File-as-target is a member) and the corpus
guard ran three fixtures that exercise none of these emitters. All 16 tests
passed while all four crashes were live.

The PR's model — "bridge endpoint × structural endpoint" — does not fit:
Namespace→Record is structural on both sides. The property that does hold is
that the ANCHOR is a lookup result, not a literal at the emit site, so the
emitter cannot constrain its label. That gives a second closed-form rule:

  DEFINITION_ANCHOR_LABELS × ATTACHMENT_TARGET_LABELS

DEFINITION_ANCHOR_LABELS is derived from NODE_TABLES by subtraction, so a new
node table joins automatically. 332 → 450 declared pairs.

Sized against a committed harness (gitnexus/bench/schema-pairs), real
@ladybugdb/core, identical data: 450 costs 0.93–1.05× of 332 on untyped-endpoint
anchored queries — inside noise — versus 1.22–1.43× at 641 and 2.03–2.34× at
1024. The harness reproduces the known #2792 cliff, which is what makes the 450
figure trustworthy.

Also in this change:

- Delete the 161 hand-declared pairs the rules already generate (233 → 72).
  The declared set is byte-identical at 450; those lines were load-bearing
  shadow, because the generator suppresses anything already declared
  structurally, so narrowing a rule later would silently keep pairs alive.
  A new guard fails CI if a hand-declared pair is ever re-added inside a rule.
- Import LINKABLE_LABELS / CALL_TARGET_TYPES instead of hand-copying them.
  The twins' stated justification ("the ingestion layer must not be imported
  here") is false: csv-generator.ts and lbug-adapter.ts, siblings in the same
  directory, already do, and no rule in AGENTS.md / ARCHITECTURE.md /
  CONTRIBUTING.md / GUARDRAILS.md states otherwise.
- Resolve `resolveStreamGraphEmit` after the guards that rebind `options.force`,
  not at function entry. It gates on `force`, and every freshness guard runs
  ~360 lines later, so the v34→v35 bump would have pushed every existing index
  down the non-streamed emit path — losing the #2680 memory streaming added for
  the #2649 kernel-scale OOM, for exactly the population most likely to be
  memory-constrained.
- `UndeclaredRelationPairError` now carries the relationship type, both node ids
  and the source file, with a matching CLI branch. The old message named only
  the abstract label pair, which a user could not act on. Found through the
  cause chain, since pipeline-phases/runner.ts rewraps every phase failure.
- Share one classifier (`relPairKeyFor`) across the router, both emit sinks and
  the corpus guard, which previously hand-mirrored the router's skip rule; one
  cause-chain walker in lib/utils.ts; one exported pair-matching regex.
- Corpus guard: four new fixtures reproducing the aborts, per-fixture sentinel
  pairs so a fixture that stops emitting fails loudly instead of passing
  vacuously on an empty graph.

The per-edge path stays allocation-free: the failure context is passed
positionally and the message is built only inside the throw.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0182jkjQqzACkJKYw4MLDnhX

* test(bench): re-baseline the COBOL capture fingerprint for the new fixture

`bench/scope-capture` globs `lang-resolution/cobol-*`, so the
`cobol-declaratives` fixture added in 81daf370e (to reproduce the
`Namespace→Record` analyze abort) joined that corpus and shifted the
fingerprint — 14 → 15 files.

Verified corpus-only, not a capture change: with that one fixture moved
aside the fingerprint is byte-identical to the prior baseline
(d45bb091…), and 81daf370e touches no COBOL capture code. The new value
reproduces CI's reported hash exactly. Scaling 0.677 < 1.5 budget.

`bench/scope-capture/measure.mjs --check` → PASS (15 languages).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0182jkjQqzACkJKYw4MLDnhX

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gergő Magyar 2026-08-02 16:26:25 +01:00 committed by GitHub
parent 74409a37f6
commit 010a7d806a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 2278 additions and 303 deletions

View file

@ -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.931.05× | 0.981.17× |
| 641 | 1.221.43× | 1.111.23× |
| 786 | 1.521.75× | 1.191.31× |
| 1024 | 2.032.34× | 1.311.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_<size>`** — `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_<size>`** — `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_<size>`** — `untyped_ms_<size> / 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.

View file

@ -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
}

View file

@ -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`);

View file

@ -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 <file>), 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",

View file

@ -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).

View file

@ -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

View file

@ -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<NodeLabel> = new Set<NodeLabel>([
'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(

View file

@ -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<NodeLabel> = new Set<NodeLabel>([
'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);
}

View file

@ -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<string>(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

View file

@ -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<string>;
private readonly relWriters = new Map<string, SyncCsvWriter>();
/**
* 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<string>(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`),

View file

@ -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 BasicBlockBasicBlock).
@ -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<string>;
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<string>(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`),

View file

@ -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<string> = new Set<string>(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>,
): 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 <label> TO <label>` clauses of a
* relationship DDL. Capture group 1 is the FROM label, group 2 the TO label;
* backticks quote schema labels and are not part of the graph label.
*
* THE SINGLE SOURCE OF TRUTH for that pattern. `parseRelationSchemaPairs`
* below builds its pair set from it, and `test/unit/schema-pair-coverage.test.ts`
* counts raw `FROM…TO` occurrences with it to catch a pair DUPLICATED in the
* DDL (a duplicate makes LadybugDB reject `CREATE REL TABLE`, killing every
* `analyze` strictly worse than one missing pair). That guard used to inline
* its own copy of the regex: the two matched identically, so it worked, but any
* widening here (dotted identifiers, `IF NOT EXISTS`, a multi-target
* `FROM x TO y, z` form) would have silently degraded it to the tautology
* `declared.size === declared.size`. Consume this factory instead of
* re-inlining a copy.
*
* A FACTORY, not a shared `RegExp`: a module-level `/g` regex carries
* `lastIndex` between calls, so one consumer's `exec`/`test` would corrupt
* everyone else's next match. Each call returns a private instance.
*/
export const createRelationPairMatcher = (): RegExp =>
/\bFROM\s+`?([A-Za-z][A-Za-z0-9_]*)`?\s+TO\s+`?([A-Za-z][A-Za-z0-9_]*)`?/g;
/**
* Extract the FROMTO pairs accepted by a relationship DDL.
*
* This belongs at the routing boundary: schema.ts owns the DDL, while the CSV
* router owns the fail-fast check that prevents writing a pair LadybugDB cannot
* COPY. Backticks quote schema labels and are not part of the graph label.
* COPY.
*/
export const parseRelationSchemaPairs = (relationSchema: string): ReadonlySet<string> =>
new Set(
[
...relationSchema.matchAll(
/\bFROM\s+`?([A-Za-z][A-Za-z0-9_]*)`?\s+TO\s+`?([A-Za-z][A-Za-z0-9_]*)`?/g,
),
].map((match) => `${match[1]}|${match[2]}`),
[...relationSchema.matchAll(createRelationPairMatcher())].map(
(match) => `${match[1]}|${match[2]}`,
),
);
export interface RelPairMeta {
@ -69,6 +160,108 @@ export interface RelPairMeta {
rows: number;
}
/**
* Best-effort source file for a graph node id.
*
* Node ids are `<Label>:<repo-relative path>:…` (`comm_*` / `proc_*` synthetic
* ids carry no file). Repo-relative paths are POSIX-normalized and never carry
* a drive letter, so the segment after the label is the file path. Returns
* `undefined` rather than guessing when the id has no path segment a wrong
* file in a crash message is worse than none.
*/
const deriveNodeFilePath = (nodeId: string): string | undefined => {
if (nodeId.startsWith('comm_') || nodeId.startsWith('proc_')) return undefined;
const filePath = nodeId.split(':')[1];
return filePath === undefined || filePath === '' ? undefined : filePath;
};
/** Where a user files the missing pair. Part of the message — see below. */
const UNDECLARED_PAIR_ISSUE_URL = 'https://github.com/abhigyanpatwari/GitNexus/issues/new';
/**
* An edge whose endpoint-label pair is absent from the relationship DDL.
*
* Carries the context the emit call site already has relationship type, both
* node ids, and the source file derived from them so a user whose `analyze`
* just died mid-run can see WHICH of their files produced the edge and file a
* bug report that names the missing pair. The abstract label pair alone is
* unactionable outside GitNexus's own source (#2789).
*
* Classify by TYPE (`err instanceof UndeclaredRelationPairError`, or
* {@link findUndeclaredRelationPairError} when the error may be wrapped in a
* phase `cause` chain) the repo norm from #2385 never by message text.
*
* THE MESSAGE IS THE ONLY RENDERING. It carries the five context fields AND
* the two actionable next steps (report the pair; `.gitnexusignore` the file to
* finish the rest of the index), because `gitnexus serve` forwards nothing but
* `err.message` over worker IPC anything a consumer re-renders from the
* structured fields instead is invisible to a serve-hosted user. The CLI
* branch in `cli/analyze.ts` therefore prints this message indented and adds
* only the machine-readable `cliError` fields, the same idiom `LbugWipeError`
* uses there. It used to re-render the five fields with its own wording; the
* two copies had already drifted on the pair separator, the no-file text and
* the closing sentence within a single PR, and each had its own pinning test.
*/
export class UndeclaredRelationPairError extends Error {
/** `From|To` label pair, exactly as keyed against the declared-pair set. */
readonly pairKey: string;
/** Relationship type of the edge that could not be routed (e.g. `CALLS`). */
readonly relationType: string;
readonly fromId: string;
readonly toId: string;
/** Source file derived from the node ids; `undefined` for synthetic ids. */
readonly sourceFile: string | undefined;
constructor(pairKey: string, relationType: string, fromId: string, toId: string) {
const sourceFile = deriveNodeFilePath(fromId) ?? deriveNodeFilePath(toId);
const [fromLabel, toLabel] = splitRelPairKey(pairKey);
super(
`GitNexus extracted a relationship its own database schema cannot store.\n` +
`Relationship label pair ${fromLabel}${toLabel} is not declared in the ` +
`LadybugDB relation schema.\n` +
` relationship type: ${relationType}\n` +
` from node: ${fromId}\n` +
` to node: ${toId}\n` +
` source file: ${sourceFile ?? '(none — synthetic node id)'}\n` +
`This is a gap in GitNexus's own relation schema, not a problem with the ` +
`analyzed code, and re-running the analysis will fail in exactly the same place.\n` +
`Suggestions:\n` +
` 1. Report the missing pair so it can be declared:\n` +
` ${UNDECLARED_PAIR_ISSUE_URL}\n` +
` Include the label pair, the relationship type, and the source file above.\n` +
` 2. To finish indexing the rest meanwhile, add that file (or its directory)\n` +
` to .gitnexusignore and re-run.`,
);
this.name = 'UndeclaredRelationPairError';
this.pairKey = pairKey;
this.relationType = relationType;
this.fromId = fromId;
this.toId = toId;
this.sourceFile = sourceFile;
}
}
/**
* Find an {@link UndeclaredRelationPairError} in `err` or its `cause` chain.
*
* The guard throws deep inside an ingestion phase, and the phase runner rewraps
* every phase failure as `new Error("Phase 'X' failed: …", { cause })` so a
* bare `instanceof` at the CLI boundary would miss it and fall through to the
* generic stack dump.
*
* The traversal and its depth bound come from `lib/utils.ts` rather than being
* re-rolled here: this was the fourth hand-written copy in the repo and the
* only one that used `depth <= MAX` (six levels) while claiming to mirror
* `cli/analyze.ts`'s `depth < 5`.
*/
export const findUndeclaredRelationPairError = (
err: unknown,
): UndeclaredRelationPairError | undefined =>
findInCauseChain(
err,
(e): e is UndeclaredRelationPairError => e instanceof UndeclaredRelationPairError,
);
/**
* Fail fast on an endpoint-label pair absent from the relationship DDL, the
* same guard `RelPairRouter.route` applies to the whole-graph emit. Exported
@ -77,18 +270,27 @@ export interface RelPairMeta {
* 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.
* Takes the already-built `From|To` pairKey (from {@link relPairKeyFor})
* 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. The error
* splits it back apart with {@link splitRelPairKey}, which only the throw path
* reaches. The edge context is passed POSITIONALLY for the same reason: a
* `{ relationType, fromId, toId }` context object would allocate on every
* edge, including the ~1M that never fail.
*
* The success path must stay allocation-free: no object literal, no template
* string, no closure, no `Error` constructed before the failure branch.
*/
export const assertDeclaredPair = (pairKey: string, declaredPairs: ReadonlySet<string>): void => {
export const assertDeclaredPair = (
pairKey: string,
declaredPairs: ReadonlySet<string>,
relationType: string,
fromId: string,
toId: string,
): void => {
if (!declaredPairs.has(pairKey)) {
throw new Error(
`Relationship label pair ${pairKey.replaceAll('|', '→')} is not declared in the LadybugDB relation schema`,
);
throw new UndeclaredRelationPairError(pairKey, relationType, fromId, toId);
}
};
@ -110,7 +312,7 @@ export class RelPairRouter {
constructor(
private readonly csvDir: string,
private readonly header: string,
private readonly validTables: Set<string>,
private readonly validTables: ReadonlySet<string>,
private readonly declaredPairs: ReadonlySet<string>,
private readonly wsFactory: WriteStreamFactory = (p) => createWriteStream(p, 'utf-8'),
) {}
@ -135,23 +337,25 @@ export class RelPairRouter {
* Returns `void` on the synchronous hot path; a `Promise<void>` only when a
* stream signals backpressure (or a new pair's header does) the caller
* awaits the promise before routing the next edge.
*
* `relType` is not used for routing it is carried purely so an undeclared
* pair can name the offending relationship in its error (the row is already
* CSV-escaped by then, so the type is not recoverable from it).
*/
route(fromId: string, toId: string, row: string): void | Promise<void> {
route(fromId: string, toId: string, row: string, relType: string): void | Promise<void> {
if (this.streamError) throw this.streamError;
const fromLabel = getNodeLabel(fromId);
const toLabel = getNodeLabel(toId);
if (!this.validTables.has(fromLabel) || !this.validTables.has(toLabel)) {
const pairKey = relPairKeyFor(fromId, toId, this.validTables);
if (pairKey === undefined) {
this.skipped++;
return;
}
const pairKey = `${fromLabel}|${toLabel}`;
assertDeclaredPair(pairKey, this.declaredPairs);
assertDeclaredPair(pairKey, this.declaredPairs, relType, fromId, toId);
const ws = this.streams.get(pairKey);
if (ws === undefined) {
// First edge for this pair: open the stream, write header + row.
return this.openAndWrite(pairKey, fromLabel, toLabel, row);
return this.openAndWrite(pairKey, row);
}
this.byPair.get(pairKey)!.rows++;
@ -161,12 +365,9 @@ export class RelPairRouter {
}
}
private async openAndWrite(
pairKey: string,
fromLabel: string,
toLabel: string,
row: string,
): Promise<void> {
/** Cold: runs once per pair, so decoding the key back is free here. */
private async openAndWrite(pairKey: string, row: string): Promise<void> {
const [fromLabel, toLabel] = splitRelPairKey(pairKey);
const csvPath = path.join(this.csvDir, `rel_${fromLabel}_${toLabel}.csv`);
const ws = this.wsFactory(csvPath);
ws.on('error', this.markError);

View file

@ -11,6 +11,10 @@
// Import from shared package (single source of truth) — used in DDL templates below
import { NODE_TABLES, REL_TABLE_NAME, REL_TYPES, EMBEDDING_TABLE_NAME } from 'gitnexus-shared';
import type { NodeLabel, NodeTableName } from 'gitnexus-shared';
import { parseRelationSchemaPairs } from './rel-pair-routing.js';
import { LINKABLE_LABELS } from '../ingestion/scope-resolution/graph-bridge/node-lookup.js';
import { CALL_TARGET_TYPES } from '../ingestion/model/symbol-table.js';
// Re-export so downstream consumers keep the same import path
export { NODE_TABLES, REL_TABLE_NAME, REL_TYPES, EMBEDDING_TABLE_NAME };
export type { NodeTableName, RelType } from 'gitnexus-shared';
@ -248,92 +252,214 @@ CREATE NODE TABLE BasicBlock (
// Single table with 'type' property - connects all node tables
// ============================================================================
export const RELATION_SCHEMA = `
CREATE REL TABLE ${REL_TABLE_NAME} (
FROM File TO File,
FROM File TO Folder,
FROM File TO Function,
FROM File TO Class,
FROM File TO Interface,
FROM File TO Method,
/**
* Labels the scope-resolution graph bridge can put on the SOURCE side of a
* CALLS / ACCESSES / USES / EXTENDS edge: everything `buildGraphNodeLookup`
* registers (`LINKABLE_LABELS`), plus the `File` node `resolveCallerGraphId`
* falls back to for a module-level call site.
*
* Imported from the ingestion layer, NOT re-listed here: a hand-copied twin is
* pure drift risk, since a label added to it (or dropped from the original) is
* invisible to every guard. `csv-generator.ts` and `lbug-adapter.ts`, both
* siblings in this directory, already import from `../ingestion/`.
*
* The cost is that importing this module pulls five ingestion modules into the
* runtime closure. `gitnexus-web` does not depend on this package at all (only
* on `gitnexus-shared`), so nothing here reaches a browser bundle. The MCP
* server still pays it, though: `local-backend.ts` no longer imports this
* module directly (its two embedding constants come from `gitnexus-shared`),
* but pool-adapter -> lbug-adapter -> csv-generator reaches it anyway.
*/
const SCOPE_BRIDGE_SOURCE_LABELS: readonly NodeLabel[] = ['File', ...LINKABLE_LABELS];
/**
* Labels the bridge can put on the TARGET side: `LINKABLE_LABELS` again (every
* `resolveDefGraphId` hit), plus `CALL_TARGET_TYPES`
* `tryEmitEdgeWithExplicitTargetId` bypasses the lookup and emits such a def's
* own node id, and C# `Delegate` is in that set without being linkable.
*
* Both sets are `NodeLabel`-typed rather than `NodeTableName`-typed because
* that is what the originals carry, and `NodeLabel` is the wider union it
* admits five labels with no node table (`Project`, `Package`, `Decorator`,
* `Import`, `Type`). A label from that gap would emit DDL naming a table that
* does not exist, so `test/unit/schema-pair-coverage.test.ts` asserts every
* declared endpoint against `NODE_TABLES`. The hand-written sets below take the
* narrower `NodeTableName` constraint, where a typo is the actual risk.
*/
const SCOPE_BRIDGE_TARGET_LABELS: ReadonlySet<NodeLabel> = new Set<NodeLabel>([
...LINKABLE_LABELS,
...CALL_TARGET_TYPES,
]);
/**
* Node tables that are NOT definitions.
*
* - `Community` / `Process` are analysis overlays synthesized after ingestion;
* nothing is ever attached to one, they are only attached TO.
* - `Route` / `Tool` are framework overlays. They do source exactly two edges
* `ENTRY_POINT_OF` to a `Process` (`pipeline-phases/processes.ts`) but
* that emitter hard-codes both labels as literals in one file rather than
* resolving an anchor through a lookup, so those two pairs stay in
* {@link STRUCTURAL_PAIR_DDL}. Admitting them as anchors would mint twelve
* further pairs (`Route→Annotation`, `Tool→Record`, ) no emitter can reach.
* - `Folder` is a filesystem container (`Folder→Folder` / `Folder→File` only).
* - `BasicBlock` is the PDG substrate (`BasicBlock→BasicBlock` only; measured
* over 300k PDG edges, no other pair is emitted).
*/
const NON_DEFINITION_LABELS: readonly NodeTableName[] = [
'Community',
'Process',
'Route',
'Tool',
'Folder',
'BasicBlock',
];
/**
* Every label a DEFINITION node can carry derived from `NODE_TABLES` by
* subtraction so a new node table joins this set automatically and only an
* explicit entry above can keep it out.
*/
const DEFINITION_ANCHOR_LABELS: readonly NodeTableName[] = NODE_TABLES.filter(
(label) => !NON_DEFINITION_LABELS.includes(label),
);
/**
* Labels whose nodes are minted OUTSIDE the scope-resolution bridge, by a
* phase or framework emitter, and then hung off whichever definition node that
* emitter happened to resolve. For most of them the anchor is a LOOKUP RESULT,
* so its label is not constrained by the emitter which is exactly why
* hand-listing these pairs has crashed `analyze` four separate times:
*
* | target | emitter | anchor comes from |
* |--------------|------------------------------------------------|---------------------------------------|
* | `Annotation` | `frameworks/spring/conditionals.ts` CONDITIONAL_ON | `resolveDefGraphId` / `resolveCallerGraphId` |
* | `Community` | `pipeline-phases/communities.ts` MEMBER_OF | Leiden membership, `isCommunitySymbol`-gated |
* | `Process` | `pipeline-phases/processes.ts` STEP_IN_PROCESS | trace step node |
* | `Route` | `pipeline-phases/routes.ts` HANDLES_ROUTE | `generateId('File', handlerPath)` a literal |
* | `Tool` | `pipeline-phases/tools.ts` HANDLES_TOOL | `handlerNodeId` whatever definition the decorator sat on |
* | `File` | `languages/vue/scope-resolver.ts` BINDS_EVENT_HANDLER | handler node |
* | `Record` | `cobol-processor.ts` × 8 external-resource sites | `scopedCallerLookup` |
*
* The four reproduced hard-aborts are one cell of this table each:
* `Method→Annotation` (Spring `@Bean` + `@ConditionalOnMissingBean`),
* `Method→File` (Vue Options-API handler), `Namespace→Record` (COBOL
* `DECLARATIVES`), `Class→Tool` (`@mcp.tool()` on a class). Declaring
* {@link DEFINITION_ANCHOR_LABELS} × this set covers all four plus every
* sibling the same emitters can reach.
*
* TWO TARGETS ARE LABEL-GATED TODAY, and the cross product over-declares for
* them ON PURPOSE (~47 of the 182 attachment pairs are unreachable right now):
* - `Community` `isCommunitySymbol` (`community-processor.ts`) admits only
* `Function` / `Class` / `Method` / `Interface` as members, so the other 22
* anchors cannot source a MEMBER_OF edge until that predicate widens.
* - `Route` HANDLES_ROUTE sources `generateId('File', handlerPath)`, a
* literal `File`, so every non-`File` anchor is headroom.
*
* Those pairs stay declared because the two sides of the error are not
* symmetric: an UNDECLARED pair makes LadybugDB reject the edge and aborts
* `analyze` outright on a user's repo, while an unused DECLARED pair costs
* almost nothing `bench/schema-pairs` measures the whole 332450 growth
* (118 pairs, of which these ~47 are a part) at 0.931.05×, i.e. inside
* run-to-run noise.
* Every one of the four aborts above came from re-narrowing a set to what one
* predicate looked like it allowed so a reading of `isCommunitySymbol` is not
* grounds to shrink this. Widening either predicate is then a no-op here.
*
* `Route` / `Tool` being excluded as ANCHORS (see {@link NON_DEFINITION_LABELS})
* is likewise a SIZE choice, not something derived from a rule: they do source
* `ENTRY_POINT_OF`, and admitting them would mint twelve further pairs no
* emitter can currently reach.
*
* Sized deliberately: this rule brings the DDL to 450 pairs. `bench/schema-pairs`
* measures it against real `@ladybugdb/core` with identical data untyped-endpoint
* anchored queries (`MATCH (a {id: $id})-[r:CodeRelation]->(b)`, the shape
* `impact` / `context` / `detect_changes` issue), relative to the 332-pair
* hand-list this replaced. Four runs on the same box:
*
* 450 0.931.05× 641 1.221.43× 786 1.521.75× 1024 2.032.34×
*
* 450 is inside run-to-run noise (it came out FASTER than 332 on three of the
* four runs); everything past ~640 is not. The knee sits just above 450, so the containment half below
* stays hand-declared rather than being folded into a third cross product.
* Re-run that bench and quote the range not one run before proposing one.
*/
const ATTACHMENT_TARGET_LABELS: readonly NodeTableName[] = [
'Annotation',
'Community',
'Process',
'Route',
'Tool',
'File',
'Record',
];
/**
* The 72 pairs NEITHER rule above generates everything left after the two
* cross products are subtracted. Carried by CONTAINMENT, inheritance, imports
* and DI: a container label crossed with a contained label. No predicate
* describes that surface (any container can hold any definition).
*
* What survives here is characteristic, not arbitrary. Almost all of it is a
* TARGET no rule reaches `CodeElement`, `Impl`, `Namespace`, `Template`,
* `TypeAlias`, `Typedef`, `Union`, `Static`, `Section`, `Folder` are in neither
* `SCOPE_BRIDGE_TARGET_LABELS` nor {@link ATTACHMENT_TARGET_LABELS} plus the
* `Impl|*` and `Template|*` member rows (Rust `impl`/`trait` bodies, C++
* templates), the two `Route|Process` / `Tool|Process` entry points whose
* emitter names both labels as literals, and `BasicBlock|BasicBlock`, the PDG
* substrate.
*
* NOTHING A RULE ALREADY COVERS BELONGS HERE. `generatedRelationPairs` skips
* any pair present in this block, so a redundant line does not merely duplicate
* it SUPPRESSES generation, and later narrowing a rule would silently keep
* that pair alive with no test failing. 161 such lines were deleted from this
* block (the DDL's pair set is unchanged: they moved into the generated half);
* `test/unit/schema-pair-coverage.test.ts` now fails if one comes back.
*
* Folding this remainder into a third cross product
* (`DEFINITION_ANCHOR_LABELS × {CodeElement, Section, Typedef, Union,
* Namespace, Impl, TypeAlias, Static, Template}`) would take the table to 641
* pairs and leave only ~29 lines here. `bench/schema-pairs` measures 641 at
* 1.221.43× on anchored queries, where production's 450 is inside noise so
* that trade buys ~43 fewer hand-written lines for a real ~2243% on the query
* shape `impact` uses, which is why it is deferred rather than taken.
*
* Exported so `test/unit/schema-pair-coverage.test.ts` can subtract it and
* assert the GENERATED region of the DDL for exact equality against the two
* rules, rather than one-directional containment.
* `test/integration/structural-pair-coverage.test.ts` guards this half from a
* corpus that is the guard, not this comment.
*/
export const STRUCTURAL_PAIR_DDL = ` FROM File TO Folder,
FROM File TO CodeElement,
FROM File TO \`Struct\`,
FROM File TO \`Enum\`,
FROM File TO \`Macro\`,
FROM File TO \`Typedef\`,
FROM File TO \`Union\`,
FROM File TO \`Namespace\`,
FROM File TO \`Trait\`,
FROM File TO \`Impl\`,
FROM File TO \`TypeAlias\`,
FROM File TO \`Const\`,
FROM File TO \`Static\`,
FROM File TO \`Variable\`,
FROM File TO \`Property\`,
FROM File TO \`Record\`,
FROM File TO \`Delegate\`,
FROM File TO \`Annotation\`,
FROM File TO \`Constructor\`,
FROM File TO \`Template\`,
FROM File TO \`Module\`,
FROM File TO Section,
FROM Folder TO Folder,
FROM Folder TO File,
FROM Function TO Function,
FROM Function TO Method,
FROM Function TO Class,
FROM Function TO Community,
FROM Function TO \`Macro\`,
FROM Function TO \`Struct\`,
FROM Function TO \`Template\`,
FROM Function TO \`Enum\`,
FROM Function TO \`Namespace\`,
FROM Function TO \`TypeAlias\`,
FROM Function TO \`Module\`,
FROM Function TO \`Impl\`,
FROM Function TO Interface,
FROM Function TO \`Constructor\`,
FROM Function TO \`Const\`,
FROM Function TO \`Typedef\`,
FROM Function TO \`Union\`,
FROM Function TO \`Property\`,
FROM Function TO CodeElement,
FROM Class TO Method,
FROM Class TO Function,
FROM Class TO Class,
FROM Class TO Interface,
FROM Class TO Community,
FROM Class TO \`Template\`,
FROM Class TO \`TypeAlias\`,
FROM Class TO \`Struct\`,
FROM Class TO \`Enum\`,
FROM Class TO \`Annotation\`,
FROM Class TO \`Constructor\`,
FROM Class TO \`Trait\`,
FROM Class TO \`Macro\`,
FROM Class TO \`Impl\`,
FROM Class TO \`Union\`,
FROM Class TO \`Namespace\`,
FROM Class TO \`Typedef\`,
FROM Class TO \`Property\`,
FROM Class TO CodeElement,
FROM Method TO Function,
FROM Method TO Method,
FROM Method TO Class,
FROM Method TO Community,
FROM Method TO \`Template\`,
FROM Method TO \`Struct\`,
FROM Method TO \`TypeAlias\`,
FROM Method TO \`Enum\`,
FROM Method TO \`Macro\`,
FROM Method TO \`Namespace\`,
FROM Method TO \`Module\`,
FROM Method TO \`Impl\`,
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,
@ -345,134 +471,90 @@ CREATE REL TABLE ${REL_TABLE_NAME} (
FROM \`Template\` TO \`Macro\`,
FROM \`Template\` TO Interface,
FROM \`Template\` TO \`Constructor\`,
FROM \`Module\` TO \`Module\`,
FROM \`Module\` TO CodeElement,
FROM \`Module\` TO \`Namespace\`,
FROM \`Namespace\` TO Function,
FROM CodeElement TO CodeElement,
FROM CodeElement TO \`Module\`,
FROM CodeElement TO \`Property\`,
FROM Section TO Section,
FROM Section TO File,
FROM File TO Route,
FROM Function TO Route,
FROM Method TO Route,
FROM File TO Tool,
FROM Function TO Tool,
FROM Method TO Tool,
FROM CodeElement TO Community,
FROM Interface TO Community,
FROM Interface TO Function,
FROM Interface TO Method,
FROM Interface TO Class,
FROM Interface TO Interface,
FROM Interface TO CodeElement,
FROM Interface TO \`TypeAlias\`,
FROM Interface TO \`Struct\`,
FROM Interface TO \`Constructor\`,
FROM Interface TO \`Property\`,
FROM \`Struct\` TO Community,
FROM \`Struct\` TO \`Trait\`,
FROM \`Struct\` TO \`Struct\`,
FROM \`Struct\` TO Class,
FROM \`Struct\` TO \`Enum\`,
FROM \`Struct\` TO Function,
FROM \`Struct\` TO Method,
FROM \`Struct\` TO Interface,
FROM \`Struct\` TO \`Constructor\`,
FROM \`Struct\` TO \`Property\`,
FROM \`Enum\` TO \`Enum\`,
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,
FROM \`Module\` TO Function,
FROM \`Module\` TO Method,
FROM \`Typedef\` TO Community,
FROM \`Union\` TO Community,
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,
FROM \`Impl\` TO \`Trait\`,
FROM \`Impl\` TO \`Struct\`,
FROM \`Impl\` TO \`Impl\`,
FROM \`TypeAlias\` TO Community,
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\`,
FROM \`Record\` TO Community,
FROM \`Delegate\` TO Community,
FROM \`Annotation\` TO Community,
FROM \`Constructor\` TO Community,
FROM \`Constructor\` TO Interface,
FROM \`Constructor\` TO Class,
FROM \`Constructor\` TO Method,
FROM \`Constructor\` TO Function,
FROM \`Constructor\` TO \`Constructor\`,
FROM \`Constructor\` TO \`Struct\`,
FROM \`Constructor\` TO \`Macro\`,
FROM \`Constructor\` TO \`Template\`,
FROM \`Constructor\` TO \`TypeAlias\`,
FROM \`Constructor\` TO \`Enum\`,
FROM \`Constructor\` TO \`Annotation\`,
FROM \`Constructor\` TO \`Impl\`,
FROM \`Constructor\` TO \`Namespace\`,
FROM \`Constructor\` TO \`Module\`,
FROM \`Constructor\` TO \`Property\`,
FROM \`Constructor\` TO \`Typedef\`,
FROM \`Template\` TO Community,
FROM \`Module\` TO Community,
FROM Function TO Process,
FROM Method TO Process,
FROM Class TO Process,
FROM Interface TO Process,
FROM \`Struct\` TO Process,
FROM \`Constructor\` TO Process,
FROM \`Module\` TO Process,
FROM \`Macro\` TO Process,
FROM \`Impl\` TO Process,
FROM \`Typedef\` TO Process,
FROM \`TypeAlias\` TO Process,
FROM \`Enum\` TO Process,
FROM \`Union\` TO Process,
FROM \`Namespace\` TO Process,
FROM \`Trait\` TO Process,
FROM \`Const\` TO Process,
FROM \`Static\` TO Process,
FROM \`Variable\` TO Process,
FROM \`Property\` TO Process,
FROM \`Record\` TO Process,
FROM \`Delegate\` TO Process,
FROM \`Annotation\` TO Process,
FROM \`Template\` TO Process,
FROM CodeElement TO Process,
FROM Route TO Process,
FROM Tool TO Process,
FROM BasicBlock TO BasicBlock,
FROM BasicBlock TO BasicBlock`;
/**
* The generated half of the DDL one ` FROM \`x\` TO \`y\`` line per pair of
* the two cross products below.
*
* 1. SCOPE BRIDGE `SCOPE_BRIDGE_SOURCE_LABELS × SCOPE_BRIDGE_TARGET_LABELS`.
* Those sets ARE the bridge's emit surface: `buildGraphNodeLookup` holds
* only `LINKABLE_LABELS`, so every id `resolveDefGraphId` returns wears one
* of those labels (#2792).
* 2. ATTACHMENT `DEFINITION_ANCHOR_LABELS × ATTACHMENT_TARGET_LABELS`, the
* phase/framework overlays hung off a resolved anchor (#2793).
*
* Generated rather than hand-listed because in both families the endpoint
* labels are LOOKUP RESULTS, not literals at the emit site so any pair drawn
* from the sets can reach `assertDeclaredPair`, and an undeclared one aborts
* `analyze` outright on whichever codebase happens to produce it. Every
* hand-listed fix so far declared only the pair in the stack trace and left the
* rest of its family missing: `Const→Method` (#2781), `Class→Variable` (#2792),
* `Interface→CodeElement` (#2416), then `Method→Annotation` / `Method→File` /
* `Namespace→Record` / `Class→Tool` (#2793) four more from three different
* emitters, all live at once.
*
* A `Set` guards the emit against a DUPLICATED pair the asymmetric failure
* mode `rel-pair-routing.ts` documents at length: a duplicate makes LadybugDB
* reject `CREATE REL TABLE` and kills EVERY `analyze`, where a missing pair only
* kills the codebases that emit it. The two target sets are disjoint TODAY, so
* nothing is deduped in practice; the Set is here so that moving one label
* between the rules can never cause it. Pairs already in
* {@link STRUCTURAL_PAIR_DDL} are skipped for the same reason.
*/
const generatedPairDdl = (): string => {
const structural = parseRelationSchemaPairs(STRUCTURAL_PAIR_DDL);
const seen = new Set<string>();
const lines: string[] = [];
const add = (from: NodeLabel, to: NodeLabel): void => {
const pairKey = `${from}|${to}`;
if (structural.has(pairKey) || seen.has(pairKey)) return;
seen.add(pairKey);
lines.push(` FROM \`${from}\` TO \`${to}\``);
};
for (const from of SCOPE_BRIDGE_SOURCE_LABELS) {
for (const to of SCOPE_BRIDGE_TARGET_LABELS) add(from, to);
}
for (const from of DEFINITION_ANCHOR_LABELS) {
for (const to of ATTACHMENT_TARGET_LABELS) add(from, to);
}
return lines.join(',\n');
};
export const RELATION_SCHEMA = `
CREATE REL TABLE ${REL_TABLE_NAME} (
${STRUCTURAL_PAIR_DDL},
${generatedPairDdl()},
type STRING,
confidence DOUBLE,
reason STRING,

View file

@ -893,10 +893,6 @@ async function runFullAnalysisInner(
const progress = (phase: string, percent: number, message: string) =>
callbacks.onProgress(phase, percent, message);
// Streamed structural emit (#2680), resolved once so the pipeline flag and the
// CSV-dir resolution below cannot disagree.
const streamGraphEmitActive = resolveStreamGraphEmit(options);
// FTS-config validation and the degraded-parse counter reset happen in the
// `runFullAnalysis` wrapper (before the lock is taken).
@ -1504,6 +1500,22 @@ async function runFullAnalysisInner(
// in-place (cache hits leave entries unchanged; misses add new ones).
const parseCache = await loadParseCache(storagePath);
// Streamed structural emit (#2680). Resolved ONCE, so the pipeline flag and
// the CSV-dir resolution below cannot disagree — and resolved HERE, not at
// function entry, because the POSITION is load-bearing: the gate is
// `options.force`, and every freshness guard above REBINDS `options` with
// `force: true` (embedding-checkpoint drop, dirty-flag recovery, pdg-mode
// flip, schema-version bump, analysis-feature drift, runner-identity change,
// CJK-mode change). Resolving before them froze the answer at `false` for
// every rebuild they trigger — including the whole-fleet rebuild an
// INCREMENTAL_SCHEMA_VERSION bump forces on every existing index at once,
// which is exactly when the #2649 memory relief matters most. So this MUST
// stay below the last guard that can set `force` and above its first use.
// (The post-pipeline analysis-feature re-check can also set `force`, but the
// pipeline has already run by then; that run emits non-streamed, precisely as
// `resolveStreamPdgEmit` — read fresh at the same point — behaves.)
const streamGraphEmitActive = resolveStreamGraphEmit(options);
// ── Phase 1: Full Pipeline (060%) ────────────────────────────────
const pipelineResult = await runPipelineFromRepo(
repoPath,

View file

@ -2,6 +2,60 @@ export const generateId = (label: string, name: string): string => {
return `${label}:${name}`;
};
/**
* How many links of an `Error.cause` chain any walker below visits, counting
* the head. THE bound hand-rolled copies had drifted to three different
* numbers with three different loop conditions (`depth < 5` in two places,
* `depth <= 5` in a third, i.e. six levels).
*
* The bound exists purely so a cyclic chain (`a.cause = b; b.cause = a`)
* cannot loop forever. Real chains are one or two links deep the ingestion
* phase runner wraps a phase failure once as
* `new Error("Phase 'X' failed: …", { cause })` so five leaves ample
* headroom for future nesting.
*/
export const CAUSE_CHAIN_MAX_DEPTH = 5;
/**
* Walk `err` and its `cause` chain, head first, yielding each `Error` link.
*
* Stops at the first non-`Error` link (a `cause` may legally be any value, and
* a non-Error carries no further `cause` worth following) and after
* `maxDepth` links. Non-`Error` input yields nothing.
*
* THE single cause-chain traversal. Consume this (or {@link findInCauseChain})
* rather than re-rolling the `for (let depth = 0; …; current = current.cause)`
* loop: every hand-rolled copy has to re-decide the bound and the loop
* condition, and they did not agree.
*/
export function* causeChain(err: unknown, maxDepth = CAUSE_CHAIN_MAX_DEPTH): Generator<Error> {
let current: unknown = err;
for (let depth = 0; depth < maxDepth && current instanceof Error; depth++) {
yield current;
current = (current as { cause?: unknown }).cause;
}
}
/**
* Find the first link of `err`'s cause chain that `match` accepts.
*
* Load-bearing for CLI error classification: the ingestion phase runner
* rewraps every phase failure as `new Error("Phase 'X' failed: …", { cause })`,
* so a bare `instanceof` at the CLI boundary misses the real error entirely
* and falls through to a generic stack dump. Classify by TYPE through this
* helper (the repo norm from #2385), never by message text.
*/
export function findInCauseChain<T>(
err: unknown,
match: (e: unknown) => e is T,
maxDepth: number = CAUSE_CHAIN_MAX_DEPTH,
): T | undefined {
for (const link of causeChain(err, maxDepth)) {
if (match(link)) return link;
}
return undefined;
}
/**
* Drop a Windows extended-length (`\\?\`) prefix from a path (#2667).
*

View file

@ -61,6 +61,13 @@ import {
rankExactEmbeddingRows,
type ExactEmbeddingRow,
} from '../../core/embeddings/exact-search.js';
// These are two bare string constants, but `schema.ts` builds its DDL from
// `LINKABLE_LABELS` / `CALL_TARGET_TYPES` and so pulls five ingestion modules
// into the runtime closure (~70ms on a cold MCP start). Re-homing them in
// `gitnexus-shared` does NOT fix that on its own: `pool-adapter.js` below
// reaches `schema.ts` anyway via pool-adapter -> lbug-adapter -> csv-generator,
// all value imports. Cutting `csv-generator` (analyze-only code the MCP server
// never runs) out of the adapter chain is the change that would make it real.
import { EMBEDDING_TABLE_NAME, EMBEDDING_INDEX_NAME } from '../../core/lbug/schema.js';
import { getExactScanLimit } from '../../core/platform/capabilities.js';
import { PhaseTimer } from '../../core/search/phase-timer.js';

View file

@ -687,8 +687,28 @@ export interface RepoMeta {
* Numbered 34, not 33: `main` took 33 for Spring AOP (#2416) mid-flight, landing
* on exactly this branch's number the seventh collision in this series and the
* first exact clash. Re-check against origin/main before merge.
*
* v35: the relation DDL is GENERATED from two closed-form rules instead of the
* pairs someone happened to hit 223 450 declared pairs (#2792, #2793).
* Rule 1, the scope-resolution bridge: `LINKABLE_LABELS` + the `File` caller
* fallback, crossed with `LINKABLE_LABELS` + `CALL_TARGET_TYPES`. Rule 2, the
* phase/framework overlays: every definition label (`NODE_TABLES` minus
* Community/Process/Route/Tool/Folder/BasicBlock) crossed with the labels those
* emitters mint and hang off a resolved anchor Annotation, Community,
* Process, Route, Tool, File, Record. In both families the endpoint labels are
* LOOKUP RESULTS, not literals at the emit site, so hand-listing could only
* ever declare the pair in the latest stack trace: v32, v33 and #2781 were each
* that same piecemeal fix, and `analyze` kept aborting at `assertDeclaredPair`
* on the next codebase with a different edge shape (`Class→Variable` on Java
* initializers, then `Method→Annotation` on Spring `@Bean`, `Method→File` on a
* Vue Options-API handler, `Namespace→Record` on COBOL `DECLARATIVES`, and
* `Class→Tool` on `@mcp.tool()` applied to a class four at once, from three
* different emitters). What remains hand-declared is only the containment /
* inheritance / import surface, which no label predicate describes and which a
* corpus test guards instead. A pre-v35 database physically lacks all of these
* from-to pairs, so force a full re-analyze.
*/
export const INCREMENTAL_SCHEMA_VERSION = 34;
export const INCREMENTAL_SCHEMA_VERSION = 35;
export interface IndexedRepo {
repoPath: string;

View file

@ -0,0 +1,48 @@
*****************************************************************
* DECLARATIVES + USE AFTER STANDARD ERROR.
*
* `cobol-processor.ts` turns each DECLARATIVES handler into an
* ACCESSES edge from the handler SECTION's `Namespace` node to a
* synthesized `Record` node for the file the USE clause names --
* the `Namespace -> Record` FROM/TO pair. Both endpoints are
* structural (neither label is in `LINKABLE_LABELS`), so no
* scope-bridge cross product reaches it.
*****************************************************************
IDENTIFICATION DIVISION.
PROGRAM-ID. ERRDEMO.
ENVIRONMENT DIVISION.
INPUT-OUTPUT SECTION.
FILE-CONTROL.
SELECT CUSTOMER-FILE ASSIGN TO "CUST.DAT"
ORGANIZATION IS SEQUENTIAL.
SELECT AUDIT-FILE ASSIGN TO "AUDIT.DAT"
ORGANIZATION IS SEQUENTIAL.
DATA DIVISION.
FILE SECTION.
FD CUSTOMER-FILE.
01 CUSTOMER-REC.
05 CUST-ID PIC X(10).
05 CUST-BALANCE PIC 9(7)V99.
FD AUDIT-FILE.
01 AUDIT-REC.
05 AUDIT-TEXT PIC X(60).
WORKING-STORAGE SECTION.
01 WS-EOF PIC X VALUE "N".
PROCEDURE DIVISION.
DECLARATIVES.
CUSTOMER-ERR-HANDLER SECTION.
USE AFTER STANDARD ERROR ON CUSTOMER-FILE.
CUSTOMER-ERR-PARA.
DISPLAY "CUSTOMER IO ERROR".
AUDIT-ERR-HANDLER SECTION.
USE AFTER STANDARD ERROR ON AUDIT-FILE.
AUDIT-ERR-PARA.
DISPLAY "AUDIT IO ERROR".
END DECLARATIVES.
MAIN-SECTION SECTION.
MAIN-PARA.
OPEN INPUT CUSTOMER-FILE
OPEN OUTPUT AUDIT-FILE
CLOSE CUSTOMER-FILE
CLOSE AUDIT-FILE
STOP RUN.

View file

@ -0,0 +1,22 @@
"""`@mcp.tool()` applied to a CLASS rather than a function.
`pipeline-phases/tools.ts` hangs the HANDLES_TOOL edge off whatever node the
tool decorator sat on (`handlerNodeId`), so a class-decorated tool produces a
`Class -> Tool` edge. Every other tool fixture decorates a function or falls
back to the file, so `Function -> Tool` / `File -> Tool` are the only pairs
they exercise.
"""
from mcp import tool
def _render(payload: dict) -> str:
return str(payload)
@mcp.tool()
class WeatherTool:
"""Class-based MCP tool."""
def run(self, payload: dict) -> str:
return _render(payload)

View file

@ -0,0 +1,30 @@
package com.example;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* `frameworks/spring/conditionals.ts` mints one `Annotation` node per Spring
* condition and links its OWNER to it with a CONDITIONAL_ON edge. When the
* owner is an `@Bean` factory METHOD (rather than the `@Configuration` class),
* the edge is `Method -> Annotation` the source label comes from the
* scope-resolution bridge, the target is a structural `Annotation` node that
* is in neither scope-bridge label set.
*/
@Configuration
public class AppAutoConfiguration {
@Bean
@ConditionalOnMissingBean
public PaymentService paymentService() {
return new PaymentService();
}
@Bean
@ConditionalOnProperty(prefix = "billing", name = "enabled", havingValue = "true")
public PaymentService fallbackPaymentService() {
return new PaymentService();
}
}

View file

@ -0,0 +1,5 @@
package com.example;
public class PaymentService {
public void pay() {}
}

View file

@ -0,0 +1,20 @@
package com.example.kotlin
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
class ReportService
/**
* Kotlin twin of the Java `@Bean` + `@ConditionalOnMissingBean` case the same
* `Method -> Annotation` pair, reached through the Kotlin conditional metadata
* adapter instead of the Java one.
*/
@Configuration
class KotlinAutoConfiguration {
@Bean
@ConditionalOnMissingBean
fun reportService(): ReportService = ReportService()
}

View file

@ -0,0 +1,29 @@
<template>
<div class="options-host">
<Button variant="secondary" @click="onOptionsClick">
Options click
</Button>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import Button from './components/Button.vue';
/**
* Options-API host: the `@click` handler lives in `methods:`, so the graph node
* for it is a `Method`, not a `Function`. The BINDS_EVENT_HANDLER edge Vue's
* scope resolver emits for a component-element binding therefore has a `Method`
* source and a `File` target the `Method→File` pair. The `<script setup>`
* sibling (App.vue) only ever produces `Function→File`.
*/
export default defineComponent({
name: 'OptionsHost',
components: { Button },
methods: {
onOptionsClick() {
return 'options-clicked';
},
},
});
</script>

View file

@ -0,0 +1,180 @@
/**
* Corpus-derived coverage for the HAND-DECLARED half of `RELATION_SCHEMA`.
*
* `test/unit/schema-pair-coverage.test.ts` derives its requirement from
* schema.ts's two rules the scope-resolution bridge cross product, and
* `DEFINITION_ANCHOR_LABELS × ATTACHMENT_TARGET_LABELS` for the framework and
* pipeline-phase overlays so both generated halves are covered there. What
* neither rule can reach is `STRUCTURAL_PAIR_DDL`: the containment, inheritance
* and import pairs BETWEEN TWO DEFINITION LABELS. Eleven node tables are absent
* from every rule's target side (`CodeElement`, `Impl`, `Namespace`,
* `Template`, `TypeAlias`, `Typedef`, `Union`, `Static`, `Section`, `Folder`,
* and the PDG-only `BasicBlock`), so a pair pointing at one is hand-declared or
* it does not exist. No predicate describes that surface any container can
* hold any definition so this asks the emitters directly: run the real
* pipeline and require every FROM/TO pair it produces to be declared.
*
* Each entry also pins the pair it exists to guard. Without that the suite is
* vacuous: `undeclared` is derived from what the pipeline emitted, so a fixture
* that stopped emitting renamed directory, grammar that failed to load,
* swallowed parse error yields an empty set and passes green while guarding
* nothing. `sentinels` turns each case from "nothing undeclared" into "this
* emitter still fires, and everything it emits is declared".
*
* Coverage is bounded by `NON_BRIDGE_CORPUS`: a sample, not a proof. A
* language whose fixture is absent is unguarded, so a new structural emitter
* should land with an entry here.
*
* Deliberately isolated from the resolver suites that already build three of
* these graphs (`resolvers/cobol.test.ts`, `resolvers/vue.test.ts`,
* `resolvers/php.test.ts`) see NOTE below the corpus before re-raising that.
*/
import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
import path from 'path';
import { NODE_TABLES } from 'gitnexus-shared';
import { FIXTURES, runPipelineFromRepo } from './resolvers/helpers.js';
import { RELATION_SCHEMA } from '../../src/core/lbug/schema.js';
import { parseRelationSchemaPairs, relPairKeyFor } from '../../src/core/lbug/rel-pair-routing.js';
import { DIST_WORKER_URL, distWorkerExists } from '../helpers/worker-parse.js';
vi.setConfig({ testTimeout: 180_000 });
const describeIfWorkerBuilt = distWorkerExists() ? describe : describe.skip;
type CorpusEntry = {
/** Fixture directory under `test/fixtures/lang-resolution`. */
readonly fixture: string;
/**
* The emitter this fixture exists to exercise, short enough that vitest does
* not truncate it out of the case title (~36 chars).
*/
readonly emitter: string;
/**
* FROM|TO pairs the fixture MUST still emit. These are the anti-vacuity
* check: they fail loudly when the fixture stops reaching the emitter,
* which is the failure mode "no undeclared pairs" cannot see.
*/
readonly sentinels: readonly string[];
};
/**
* Fixtures chosen to reach a structural emitter that no other suite drives.
*
* The sentinels are the anti-vacuity anchor: each names an emitter that must
* still fire. Two of them (`CodeElement|Property`, `Module|Namespace`) are also
* the only pairs here that no generated rule can reach; the other nine are
* rule-derived, and stay because a rule DECLARING a pair says nothing about
* whether any emitter still PRODUCES it which is the failure this corpus
* exists to catch.
*/
const NON_BRIDGE_CORPUS = [
{
// `cobol-processor.ts`: CONTAINS/CALLS/ACCESSES over Module / Namespace /
// Record / Property / CodeElement.
fixture: 'cobol-app',
emitter: 'cobol-processor containment',
sentinels: ['CodeElement|Property', 'Module|Namespace', 'Module|Record', 'Record|Record'],
},
{
// Same processor, DECLARATIVES section: the USE-procedure Namespace
// ACCESSES a file Record.
fixture: 'cobol-declaratives',
emitter: 'cobol-processor DECLARATIVES',
sentinels: ['Namespace|Record'],
},
{
// `languages/vue/scope-resolver.ts`: the only edge whose target is a
// `File`. Function→File from `<script setup>` (App.vue), Method→File from
// the Options-API `methods:` host (OptionsHost.vue).
fixture: 'vue-basic',
emitter: 'vue BINDS_EVENT_HANDLER',
sentinels: ['Function|File', 'Method|File'],
},
{
// The inheritance pass, including trait-to-trait IMPLEMENTS.
fixture: 'php-transitive-traits',
emitter: 'inheritance-pass IMPLEMENTS',
sentinels: ['Class|Trait', 'Trait|Trait'],
},
{
// `frameworks/spring/conditionals.ts`: @ConditionalOn* on a @Bean method,
// in both Java and Kotlin.
fixture: 'spring-conditional-app',
emitter: 'spring CONDITIONAL_ON',
sentinels: ['Method|Annotation'],
},
{
// `pipeline-phases/tools.ts`: a class-based MCP tool handler.
fixture: 'mcp-tool-class',
emitter: 'tools-phase HANDLES_TOOL',
sentinels: ['Class|Tool'],
},
] as const satisfies readonly CorpusEntry[];
/*
* NOTE why this suite runs its own pipelines instead of reusing the resolver
* suites' graphs (measured, not assumed):
*
* 1. Vitest runs with `pool: 'forks'` and default isolation, so every test
* FILE gets its own child process. A fixture-keyed result cache in
* `resolvers/helpers.ts` would be per-file module state and would share
* nothing across files zero saving.
* 2. The graphs are not interchangeable. `resolvers/cobol.test.ts` builds
* cobol-app with `{ skipGraphPhases: true }`, which drops the phase-emitted
* edges (`Function|Process`, `Function|Community`, and the whole class of
* pair `mcp-tool-class` exists to guard: HANDLES_TOOL is a pipeline phase).
* Asserting there would silently cover LESS surface than here.
* 3. Half the corpus has no existing home anyway, and the cases run
* concurrently, so they overlap into roughly one fixture's wall time: all
* 6 measured ~6s of test time against the ~16s this file spends on
* transform+import before the first case starts. Hosting only the 3
* homeless ones measured ~5s a ~1s saving that still cannot remove a
* file, so it cannot remove that ~16s.
*/
const DECLARED = parseRelationSchemaPairs(RELATION_SCHEMA);
const VALID_TABLES = new Set<string>(NODE_TABLES);
// Cold worker-pool startups otherwise flake against the 5s default ready budget
// on a loaded runner, failing for reasons unrelated to the schema (#1741).
// Safe under `it.concurrent`: env stubs are process-global, but `pool: 'forks'`
// gives this file its own process and every case wants the same value.
beforeAll(() => vi.stubEnv('GITNEXUS_WORKER_READY_TIMEOUT_MS', '60000'));
afterAll(() => vi.unstubAllEnvs());
/**
* The FROM/TO pairs a fixture emits, deduped.
*
* Classifies through `relPairKeyFor` the same call the emit path routes with
* so this guard and the router cannot disagree about which edges are skipped.
* It keys off the node IDS, which is what `assertDeclaredPair` actually sees,
* not the node's `label` field.
*/
const pairsEmittedBy = async (fixture: string): Promise<Set<string>> => {
const result = await runPipelineFromRepo(path.join(FIXTURES, fixture), () => {}, {
workerPoolSize: 1,
workerUrlForTest: DIST_WORKER_URL,
});
const emitted = new Set<string>();
for (const rel of result.graph.iterRelationships()) {
const pair = relPairKeyFor(rel.sourceId, rel.targetId, VALID_TABLES);
if (pair !== undefined) emitted.add(pair);
}
return emitted;
};
describeIfWorkerBuilt('RELATION_SCHEMA covers the non-bridge emitters', () => {
// Concurrent because the cases share nothing but cost ~5s each serially,
// almost all of it worker spawn and grammar load, which overlaps well.
it.concurrent.each(NON_BRIDGE_CORPUS)(
'$fixture emits only declared FROM/TO pairs, and still reaches $emitter',
async ({ fixture, sentinels }) => {
const emitted = await pairsEmittedBy(fixture);
// Sorted so a failure is stable and names the pair to declare.
expect({
undeclaredPairs: [...emitted].filter((pair) => !DECLARED.has(pair)).sort(),
missingSentinelPairs: sentinels.filter((pair) => !emitted.has(pair)),
}).toEqual({ undeclaredPairs: [], missingSentinelPairs: [] });
},
);
});

View file

@ -0,0 +1,169 @@
/**
* Tests for the undeclared FROMTO label-pair failure path in the
* `analyzeCommand` CLI (#2789).
*
* `assertDeclaredPair` aborts the run when an extracted edge's endpoint-label
* pair is missing from GitNexus's own relation DDL deliberately, because the
* alternative is a late `COPY` failure that silently drops edges. Before this
* branch existed the user got `Analysis failed` plus a stack trace through
* GitNexus internals: no file, no relationship, and an implicit "try again"
* that can never work. The CLI must instead name the pair, the relationship
* type and the offending file, and point at an issue report.
*
* The CLI does NOT compose that text: it prints `err.message` indented (the
* `LbugWipeError` idiom in the same catch block), because the message is
* self-contained `gitnexus serve` forwards only `err.message` over worker
* IPC, so anything rendered here instead would be invisible to serve users.
* The needles below are therefore the SAME strings
* `test/unit/rel-pair-routing.test.ts` pins on the message itself.
*
* Mirrors analyze-http-endpoint-error.test.ts:
* - vi.mock the heavy dependencies so no real DB / git is touched
* - drive `analyzeCommand` with a mocked `runFullAnalysis` that rejects
* - assert on process.exitCode and the captured logger records
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
const runFullAnalysisMock = vi.fn();
vi.mock('../../src/core/run-analyze.js', () => ({
runFullAnalysis: runFullAnalysisMock,
}));
vi.mock('../../src/core/lbug/lbug-adapter.js', () => ({
closeLbug: vi.fn(async () => undefined),
closeLbugBeforeExit: vi.fn(async () => undefined),
isLbugReady: vi.fn(() => false),
LbugWipeError: class LbugWipeError extends Error {},
}));
vi.mock('../../src/storage/repo-manager.js', () => ({
getStoragePaths: vi.fn(() => ({ storagePath: '.gitnexus', lbugPath: '.gitnexus/lbug' })),
getGlobalRegistryPath: vi.fn(() => 'registry.json'),
RegistryNameCollisionError: class RegistryNameCollisionError extends Error {},
AnalysisNotFinalizedError: class AnalysisNotFinalizedError extends Error {},
assertAnalysisFinalized: vi.fn(async () => undefined),
}));
vi.mock('../../src/storage/git.js', () => ({
getGitRoot: vi.fn(() => '/repo'),
hasGitDir: vi.fn(() => true),
}));
vi.mock('../../src/core/ingestion/utils/max-file-size.js', () => ({
getMaxFileSizeBannerMessage: vi.fn(() => null),
}));
// analyze.ts imports isHfDownloadFailure from hf-env.js — mock it to break the
// transitive gitnexus-shared chain (same reason as the sibling suite).
vi.mock('../../src/core/embeddings/hf-env.js', () => ({
isHfDownloadFailure: vi.fn(() => false),
}));
const PAIR_ERROR_ARGS = [
'Method|Annotation',
'ANNOTATED_BY',
'Method:src/main/java/app/BeanConfig.java:BeanConfig.dataSource#42',
'Annotation:src/main/java/app/BeanConfig.java:ConditionalOnMissingBean',
] as const;
describe('analyzeCommand undeclared relation-pair handling (#2789)', () => {
beforeEach(() => {
vi.resetModules();
runFullAnalysisMock.mockReset();
process.exitCode = undefined;
process.env.NODE_OPTIONS = `${process.env.NODE_OPTIONS ?? ''} --max-old-space-size=8192`.trim();
});
it('renders an actionable schema-gap message naming the pair, relationship, ids and file', async () => {
const { UndeclaredRelationPairError } = await import('../../src/core/lbug/rel-pair-routing.js');
runFullAnalysisMock.mockRejectedValue(new UndeclaredRelationPairError(...PAIR_ERROR_ARGS));
const { _captureLogger } = await import('../../src/core/logger.js');
const cap = _captureLogger();
const { analyzeCommand } = await import('../../src/cli/analyze.js');
await analyzeCommand(undefined, {});
expect(process.exitCode).toBe(1);
const record = cap.records().find((r) => r.recoveryHint === 'undeclared-relation-pair');
cap.restore();
expect(record).toMatchObject({
recoveryHint: 'undeclared-relation-pair',
labelPair: 'Method|Annotation',
relationType: 'ANNOTATED_BY',
sourceFile: 'src/main/java/app/BeanConfig.java',
});
// The CLI renders `err.message` indented (the `LbugWipeError` idiom) rather
// than re-formatting the structured fields, so these needles are the ONE
// wording — `test/unit/rel-pair-routing.test.ts` pins the same strings on
// the message itself and a reword updates one place, not two.
// Filter-to-empty, not an array of booleans: the failure output NAMES the
// missing string instead of making you count positions.
const text = typeof record?.msg === 'string' ? record.msg : '';
const required = [
'Method → Annotation',
'ANNOTATED_BY',
'src/main/java/app/BeanConfig.java',
'Method:src/main/java/app/BeanConfig.java:BeanConfig.dataSource#42',
'Annotation:src/main/java/app/BeanConfig.java:ConditionalOnMissingBean',
// Names it as a GitNexus gap, tells the user a re-run is pointless, and
// points at both actionable next steps.
"gap in GitNexus's own relation schema",
're-running the analysis will fail',
'https://github.com/abhigyanpatwari/GitNexus/issues/new',
'.gitnexusignore',
];
expect(required.filter((needle) => !text.includes(needle))).toEqual([]);
});
it('still fires when the ingestion phase runner has rewrapped it as a cause', async () => {
// The guard throws inside an emit phase, and the phase runner rewraps every
// phase failure as `new Error("Phase 'X' failed: …", { cause })` — a bare
// instanceof check at the CLI boundary would miss it entirely.
const { UndeclaredRelationPairError } = await import('../../src/core/lbug/rel-pair-routing.js');
const original = new UndeclaredRelationPairError(...PAIR_ERROR_ARGS);
// The wrapper deliberately does NOT interpolate the cause's message: the
// branch must render `undeclaredPair.message`, i.e. the message of the link
// it FOUND in the chain, not the outer wrapper's message.
runFullAnalysisMock.mockRejectedValue(
new Error(`Phase 'graph-emit' failed`, { cause: original }),
);
const { _captureLogger } = await import('../../src/core/logger.js');
const cap = _captureLogger();
const { analyzeCommand } = await import('../../src/cli/analyze.js');
await analyzeCommand(undefined, {});
expect(process.exitCode).toBe(1);
const records = cap.records();
cap.restore();
const record = records.find((r) => r.recoveryHint === 'undeclared-relation-pair');
expect(record).toMatchObject({ labelPair: 'Method|Annotation' });
const text = typeof record?.msg === 'string' ? record.msg : '';
expect(
['Method → Annotation', '.gitnexusignore'].filter((needle) => !text.includes(needle)),
).toEqual([]);
// The generic large-repo / module-not-found guidance must not also appear.
expect(records.some((r) => r.recoveryHint === 'large-repo')).toBe(false);
expect(records.some((r) => r.recoveryHint === 'module-not-found')).toBe(false);
});
it('does not claim an unrelated failure', async () => {
runFullAnalysisMock.mockRejectedValue(new Error('LadybugDB write failed'));
const { _captureLogger } = await import('../../src/core/logger.js');
const cap = _captureLogger();
const { analyzeCommand } = await import('../../src/cli/analyze.js');
await analyzeCommand(undefined, {});
expect(process.exitCode).toBe(1);
const records = cap.records();
cap.restore();
expect(records.some((r) => r.recoveryHint === 'undeclared-relation-pair')).toBe(false);
});
});

View file

@ -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 34 (Spring AOP relation pairs #2416, then receiver-chain wire format v2)', () => {
it('INCREMENTAL_SCHEMA_VERSION is bumped to 35 (receiver-chain wire format v2, then the full scope-resolution relation cross product #2792)', () => {
// 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(34);
expect(INCREMENTAL_SCHEMA_VERSION).toBe(35);
});
it('a pre-current stamp fails the `=== INCREMENTAL_SCHEMA_VERSION` reuse gate → forces full re-analyze', () => {
@ -223,7 +223,11 @@ describe('CALL_SUMMARY incremental reuse gate (U-C5)', () => {
// would silently fall back to the text cascade for every chain-carrying
// site → must NOT reuse.
expect(passesReuseGate(33)).toBe(false);
// A pre-v35 (v34) index was created against a relation DDL missing 99 of the
// scope-resolution FROM/TO pairs (#2792) — LadybugDB fixes endpoint pairs at
// CREATE time, so those edges cannot be written into it at all.
expect(passesReuseGate(34)).toBe(false);
// The current stamp passes the gate (incremental top-up eligible).
expect(passesReuseGate(34)).toBe(true);
expect(passesReuseGate(35)).toBe(true);
});
});

View file

@ -147,7 +147,7 @@ describe('GraphEmitSink routing', () => {
reason: 'direct',
};
expect(() => sink.addRelationship(undeclared)).toThrow(
/Relationship label pair StaticStatic is not declared/,
/Relationship label pair StaticStatic is not declared/,
);
});
});

View file

@ -95,7 +95,7 @@ describe('PdgEmitSink — routing', () => {
reason: 'seq',
};
expect(() => sink.addRelationship(undeclared)).toThrow(
/Relationship label pair StaticStatic is not declared/,
/Relationship label pair StaticStatic is not declared/,
);
});

View file

@ -5,8 +5,14 @@ import path from 'path';
import os from 'os';
import {
RelPairRouter,
UndeclaredRelationPairError,
assertDeclaredPair,
createRelationPairMatcher,
findUndeclaredRelationPairError,
getNodeLabel,
parseRelationSchemaPairs,
relPairKeyFor,
splitRelPairKey,
} from '../../src/core/lbug/rel-pair-routing.js';
/**
@ -102,6 +108,47 @@ describe('getNodeLabel', () => {
});
});
/**
* `relPairKeyFor` is the ONE classifier `RelPairRouter.route`,
* `GraphEmitSink.addRelationship`, `PdgEmitSink.addRelationship` and the
* `structural-pair-coverage` corpus guard all route through. Each used to
* inline the same three lines; the corpus guard's docblock said it "mirrors
* RelPairRouter.route", which meant a change to the skip rule here would leave
* the guard classifying by the old rule green while `analyze` aborts.
*/
describe('relPairKeyFor', () => {
const VALID_PAIR_TABLES = new Set(['File', 'Function', 'Community']);
it('keys an edge whose endpoints are both node tables, and skips one that is not', () => {
expect(relPairKeyFor('File:src/a.ts', 'Function:src/a.ts:f:1', VALID_PAIR_TABLES)).toBe(
'File|Function',
);
// Synthetic ids still classify through getNodeLabel's prefix rules.
expect(relPairKeyFor('comm_1', 'comm_2', VALID_PAIR_TABLES)).toBe('Community|Community');
// `undefined` = SKIP, on either endpoint. Every caller drops the edge.
expect(
relPairKeyFor('Bogus:src/a.ts', 'Function:src/a.ts:f:1', VALID_PAIR_TABLES),
).toBeUndefined();
expect(relPairKeyFor('File:src/a.ts', 'Bogus:src/a.ts', VALID_PAIR_TABLES)).toBeUndefined();
});
it('agrees with the labels getNodeLabel derives (no second derivation rule)', () => {
const from = 'File:src/a.ts';
const to = 'Function:src/a.ts:f:1';
expect(relPairKeyFor(from, to, VALID_PAIR_TABLES)).toBe(
`${getNodeLabel(from)}|${getNodeLabel(to)}`,
);
});
it('round-trips through splitRelPairKey, the only sanctioned decoder', () => {
const key = relPairKeyFor('File:src/a.ts', 'Function:src/a.ts:f:1', VALID_PAIR_TABLES);
expect(splitRelPairKey(key ?? '')).toEqual(['File', 'Function']);
// `|` cannot occur inside a NODE_TABLES identifier, so the FIRST `|` is
// always the separator — that invariant is what makes decoding safe.
expect(splitRelPairKey('BasicBlock|BasicBlock')).toEqual(['BasicBlock', 'BasicBlock']);
});
});
describe('parseRelationSchemaPairs', () => {
it('extracts plain and quoted FROM→TO labels for router validation', () => {
expect(
@ -116,13 +163,166 @@ describe('parseRelationSchemaPairs', () => {
});
});
/**
* The `FROM…TO` pattern is exported so `test/unit/schema-pair-coverage.test.ts`
* can COUNT raw occurrences with the very regex the parser de-duplicates with
* (count > set size a pair is duplicated in the DDL LadybugDB rejects
* `CREATE REL TABLE` and every `analyze` dies). While that guard inlined its own
* copy, a widening on either side would have degraded it to
* `declared.size === declared.size` with nothing failing. These tests pin both
* halves of the coupling: the factory's freshness contract, and the exact pair
* set the shared pattern produces for the widening-adjacent DDL shapes.
*/
describe('createRelationPairMatcher', () => {
it('returns a fresh global matcher per call so lastIndex cannot leak between consumers', () => {
const first = createRelationPairMatcher();
const second = createRelationPairMatcher();
expect(first).not.toBe(second);
expect([first.global, first.lastIndex, second.lastIndex]).toEqual([true, 0, 0]);
first.exec('FROM Class TO CodeElement');
// The used instance advanced; a newly built one is still at the start.
expect([first.lastIndex > 0, createRelationPairMatcher().lastIndex]).toEqual([true, 0]);
});
it('is the pattern parseRelationSchemaPairs itself uses (no re-inlined copy)', () => {
// Each shape is either a form the pattern accepts today or a widening the
// finding calls out (dotted identifier, multi-target `FROM x TO y, z`).
const ddlShapes = [
'FROM Class TO CodeElement',
'FROM `Enum` TO `TypeAlias`',
'CREATE REL TABLE IF NOT EXISTS CodeRelation(FROM A TO B, FROM A TO B, type STRING)',
'FROM ns.Class TO Other',
'FROM A TO B, C',
];
const viaMatcher = ddlShapes.map((ddl) =>
[...ddl.matchAll(createRelationPairMatcher())].map((m) => `${m[1]}|${m[2]}`),
);
// Pins what the shared pattern matches. Widening the exported matcher
// without updating the duplicate-count guard's expectations fails here.
expect(viaMatcher).toEqual([
['Class|CodeElement'],
['Enum|TypeAlias'],
['A|B', 'A|B'], // duplicate survives the raw count; the parser dedups it
[], // dotted identifiers are NOT matched today
['A|B'], // multi-target: only the first target is matched today
]);
// Re-inlining a DIFFERENT regex inside parseRelationSchemaPairs breaks this.
expect(ddlShapes.map((ddl) => [...parseRelationSchemaPairs(ddl)])).toEqual(
viaMatcher.map((pairs) => [...new Set(pairs)]),
);
});
});
describe('assertDeclaredPair', () => {
const DECLARED_ONE = new Set<string>(['Function|Function']);
it('passes a declared pair through and throws a typed error for an undeclared one', () => {
expect(
assertDeclaredPair(
'Function|Function',
DECLARED_ONE,
'CALLS',
'Function:src/a.ts:f:1',
'Function:src/a.ts:g:2',
),
).toBeUndefined();
expect(() =>
assertDeclaredPair(
'Method|Annotation',
DECLARED_ONE,
'ANNOTATED_BY',
'Method:src/app/Config.java:Config.dataSource#12',
'Annotation:src/app/Config.java:ConditionalOnMissingBean',
),
).toThrow(UndeclaredRelationPairError);
});
it('carries the pair, relationship type, both node ids and the source file (#2789)', () => {
const thrown = (() => {
try {
assertDeclaredPair(
'Method|Annotation',
DECLARED_ONE,
'ANNOTATED_BY',
'Method:src/app/Config.java:Config.dataSource#12',
'Annotation:src/app/Config.java:ConditionalOnMissingBean',
);
return undefined;
} catch (err) {
return err;
}
})();
expect(thrown).toBeInstanceOf(UndeclaredRelationPairError);
expect(thrown).toMatchObject({
name: 'UndeclaredRelationPairError',
pairKey: 'Method|Annotation',
relationType: 'ANNOTATED_BY',
fromId: 'Method:src/app/Config.java:Config.dataSource#12',
toId: 'Annotation:src/app/Config.java:ConditionalOnMissingBean',
sourceFile: 'src/app/Config.java',
});
// Everything a bug report needs must also survive in the message alone:
// `gitnexus serve` forwards nothing but `err.message` over worker IPC, so
// this message is the ONLY rendering — `cli/analyze.ts` prints it verbatim
// rather than re-formatting the structured fields into a second copy.
// Filter-to-empty rather than an array of booleans: the failure output
// NAMES the missing string instead of making you count `true`s.
const message = (thrown as UndeclaredRelationPairError).message;
const required = [
'Method → Annotation is not declared in the LadybugDB relation schema',
'ANNOTATED_BY',
'Method:src/app/Config.java:Config.dataSource#12',
'Annotation:src/app/Config.java:ConditionalOnMissingBean',
'src/app/Config.java',
// The two ACTIONABLE items. They live in the message, not in the CLI
// branch, so a `gitnexus serve` user gets them too.
'https://github.com/abhigyanpatwari/GitNexus/issues/new',
'.gitnexusignore',
"gap in GitNexus's own relation schema",
're-running the analysis will fail in exactly the same place',
];
expect(required.filter((needle) => !message.includes(needle))).toEqual([]);
});
it('reports no source file for synthetic community/process ids instead of guessing', () => {
const err = new UndeclaredRelationPairError(
'Community|Process',
'BELONGS_TO',
'comm_4',
'proc_7',
);
expect(err.sourceFile).toBeUndefined();
expect(['(none — synthetic node id)'].filter((n) => !err.message.includes(n))).toEqual([]);
});
it("is findable through the phase runner's cause chain", () => {
const original = new UndeclaredRelationPairError(
'Method|Annotation',
'ANNOTATED_BY',
'Method:src/app/Config.java:Config.dataSource#12',
'Annotation:src/app/Config.java:ConditionalOnMissingBean',
);
const wrapped = new Error("Phase 'graph-emit' failed: …", {
cause: new Error('emit failed', { cause: original }),
});
expect(findUndeclaredRelationPairError(wrapped)).toBe(original);
expect(findUndeclaredRelationPairError(original)).toBe(original);
expect(findUndeclaredRelationPairError(new Error('unrelated'))).toBeUndefined();
expect(findUndeclaredRelationPairError('not an error')).toBeUndefined();
});
});
describe('RelPairRouter', () => {
it('routes valid edges to per-pair files (header first) and skips invalid-label edges', async () => {
const streams: MockWriteStream[] = [];
const router = new RelPairRouter(tmpDir, HEADER, VALID, DECLARED, mockFactory(streams));
const route = async (from: string, to: string) => {
const p = router.route(from, to, row(from, to));
const p = router.route(from, to, row(from, to), 'CALLS');
if (p) await p;
};
await route('File:a', 'Function:a:f:1');
@ -145,9 +345,20 @@ describe('RelPairRouter', () => {
const streams: MockWriteStream[] = [];
const router = new RelPairRouter(tmpDir, HEADER, VALID, DECLARED, mockFactory(streams));
expect(() => router.route('File:a', 'Community:1', row('File:a', 'Community:1'))).toThrow(
'File→Community is not declared in the LadybugDB relation schema',
);
const route = () =>
router.route(
'File:src/a.ts',
'Community:1',
row('File:src/a.ts', 'Community:1', 'DEFINES'),
'DEFINES',
);
expect(route).toThrow('File → Community is not declared in the LadybugDB relation schema');
// The row is already CSV-escaped here, so the router must forward the edge
// context itself — otherwise the crash names only the abstract label pair.
expect(route).toThrow(UndeclaredRelationPairError);
expect(route).toThrow(/DEFINES/);
expect(route).toThrow(/File:src\/a\.ts/);
expect(route).toThrow(/Community:1/);
expect(streams).toHaveLength(0);
expect(router.skipped).toBe(0);
expect(router.total).toBe(0);
@ -163,7 +374,12 @@ describe('RelPairRouter', () => {
mockFactory(streams, { blocked: true }),
);
const pending = router.route('File:a', 'Function:a:f:1', row('File:a', 'Function:a:f:1'));
const pending = router.route(
'File:a',
'Function:a:f:1',
row('File:a', 'Function:a:f:1'),
'DEFINES',
);
expect(pending).toBeInstanceOf(Promise); // header write hit backpressure
streams[0].unblock();
await pending;
@ -177,16 +393,21 @@ describe('RelPairRouter', () => {
const streams: MockWriteStream[] = [];
const router = new RelPairRouter(tmpDir, HEADER, VALID, DECLARED, mockFactory(streams));
const first = router.route('File:a', 'Function:a:f:1', row('File:a', 'Function:a:f:1'));
const first = router.route(
'File:a',
'Function:a:f:1',
row('File:a', 'Function:a:f:1'),
'DEFINES',
);
if (first) await first;
const err = new Error('EMFILE: too many open files');
streams[0].triggerError(err);
// The next route surfaces the REAL error, not a generic AbortError.
expect(() => router.route('File:a', 'Function:a:g:2', row('File:a', 'Function:a:g:2'))).toThrow(
'EMFILE',
);
expect(() =>
router.route('File:a', 'Function:a:g:2', row('File:a', 'Function:a:g:2'), 'DEFINES'),
).toThrow('EMFILE');
expect(router.lastError).toBe(err);
await expect(router.close()).rejects.toThrow('EMFILE');
expect(streams[0].destroyed).toBe(true);
@ -196,9 +417,14 @@ describe('RelPairRouter', () => {
const streams: MockWriteStream[] = [];
const router = new RelPairRouter(tmpDir, HEADER, VALID, DECLARED, mockFactory(streams));
const a = router.route('File:a', 'Function:a:f:1', row('File:a', 'Function:a:f:1'));
const a = router.route('File:a', 'Function:a:f:1', row('File:a', 'Function:a:f:1'), 'DEFINES');
if (a) await a;
const b = router.route('Community:1', 'Community:2', row('Community:1', 'Community:2'));
const b = router.route(
'Community:1',
'Community:2',
row('Community:1', 'Community:2'),
'RELATED_TO',
);
if (b) await b;
router.destroy();

View file

@ -0,0 +1,211 @@
import { describe, it, expect } from 'vitest';
import type { NodeLabel, NodeTableName } from 'gitnexus-shared';
import { NODE_TABLES } from 'gitnexus-shared';
import { RELATION_SCHEMA, STRUCTURAL_PAIR_DDL } from '../../src/core/lbug/schema.js';
import {
createRelationPairMatcher,
parseRelationSchemaPairs,
} from '../../src/core/lbug/rel-pair-routing.js';
import { LINKABLE_LABELS } from '../../src/core/ingestion/scope-resolution/graph-bridge/node-lookup.js';
import { CALLER_ANCHOR_LABELS } from '../../src/core/ingestion/scope-resolution/graph-bridge/ids.js';
import { CALL_TARGET_TYPES } from '../../src/core/ingestion/model/symbol-table.js';
/**
* `RELATION_SCHEMA` is generated from two cross products plus one hand-written
* block. This file guards the generated half; a pair drawn from either rule and
* absent from the DDL does not degrade `assertDeclaredPair` throws and
* `analyze` dies mid-phase on whichever codebase first produces it. Failing
* here means fixing the rule, not the assertion.
*
* WHAT SCHEMA.TS NO LONGER RISKS. The DDL used to carry hand-copied twins of
* `LINKABLE_LABELS` / `CALL_TARGET_TYPES`. Those are deleted schema.ts now
* imports the originals so two whole failure modes are structurally
* impossible rather than merely asserted: a label present only in the twin, and
* a label removed from the original while the DDL keeps its pairs. Neither was
* catchable before (a twin-only `Class|Record` left every assertion green).
*
* WHAT STILL NEEDS ASSERTING, and is:
* - the rules themselves the constants below are a deliberate PIN of
* schema.ts's two rules, recomputed here from `LINKABLE_LABELS`,
* `CALL_TARGET_TYPES` and `NODE_TABLES`. Widening a rule in schema.ts alone
* fails `generated region matches …`, so the widening has to be stated
* twice, on purpose.
* - the generated region for EXACT equality, not containment so a pair
* hand-added to the generated half (the reflex that produced #2781, #2792
* and #2793) fails just as loudly as a missing one.
* - `STRUCTURAL_PAIR_DDL` carrying nothing a rule already generates. Every
* other assertion here subtracts `structural` from BOTH sides, so a
* redundant hand-declaration was invisible to all of them; `no hand-declared
* pair ` below is the one that sees it.
*
* WHAT THIS FILE CANNOT SEE: a pair hand-added to {@link STRUCTURAL_PAIR_DDL}
* that NEITHER rule covers the ~72 containment/inheritance/import pairs
* between two definition labels. That surface has no predicate, so it is
* bounded by a corpus instead, in
* `test/integration/structural-pair-coverage.test.ts`.
*/
/** Rule 1 — the scope-resolution graph bridge (#2792). */
const scopeBridgePairs = (): readonly string[] => {
const sources: NodeLabel[] = ['File', ...LINKABLE_LABELS];
const targets = new Set<NodeLabel>([...LINKABLE_LABELS, ...CALL_TARGET_TYPES]);
return sources.flatMap((from) => [...targets].map((to) => `${from}|${to}`));
};
/**
* Pin of `NON_DEFINITION_LABELS` the node tables schema.ts refuses as an
* attachment anchor. `Route` / `Tool` are here despite sourcing `ENTRY_POINT_OF`
* to a `Process`: that emitter names both labels as literals, so those two pairs
* are hand-declared rather than generated.
*/
const NON_DEFINITION_LABELS: readonly NodeTableName[] = [
'Community',
'Process',
'Route',
'Tool',
'Folder',
'BasicBlock',
];
/**
* Pin of `ATTACHMENT_TARGET_LABELS` labels minted outside the bridge by a
* phase/framework emitter and hung off whichever definition node that emitter
* resolved. For most of them the anchor is a lookup result, so its label is
* unconstrained. `Community` and `Route` are the exceptions and over-declare on
* purpose (their anchors are label-gated today see schema.ts); keeping them in
* the cross product is deliberate headroom, so do NOT narrow this pin to make an
* assertion smaller.
*/
const ATTACHMENT_TARGET_LABELS: readonly NodeTableName[] = [
'Annotation',
'Community',
'Process',
'Route',
'Tool',
'File',
'Record',
];
/** Rule 2 — phase/framework overlays hung off a resolved anchor (#2793). */
const attachmentPairs = (): readonly string[] => {
const anchors = NODE_TABLES.filter((label) => !NON_DEFINITION_LABELS.includes(label));
return anchors.flatMap((from) => ATTACHMENT_TARGET_LABELS.map((to) => `${from}|${to}`));
};
describe('RELATION_SCHEMA pair coverage', () => {
const declared = parseRelationSchemaPairs(RELATION_SCHEMA);
const structural = parseRelationSchemaPairs(STRUCTURAL_PAIR_DDL);
it('declares every pair the scope-resolution bridge can emit', () => {
const missing = scopeBridgePairs()
.filter((pair) => !declared.has(pair))
.sort();
expect(missing).toEqual([]);
});
it('declares every pair a phase/framework overlay can attach', () => {
// `Method|Annotation` (Spring @Bean + @ConditionalOnMissingBean),
// `Method|File` (Vue Options-API handler), `Namespace|Record` (COBOL
// DECLARATIVES) and `Class|Tool` (@mcp.tool() on a class) were all live
// aborts at PR #2793's head, from three different emitters.
const missing = attachmentPairs()
.filter((pair) => !declared.has(pair))
.sort();
expect(missing).toEqual([]);
});
it('declares no hand-declared pair that a rule already generates', () => {
// The guard against the exact failure mode this PR exists to eliminate, and
// the ONLY one that can see it. `generatedPairDdl` skips any pair
// already in STRUCTURAL_PAIR_DDL, so a redundant hand-declaration is not an
// inert duplicate — it SUPPRESSES generation. Narrowing a rule later would
// then silently keep that pair alive, and every other assertion in this file
// subtracts `structural` from both sides, so none of them would notice.
// (The integration corpus only checks emitted ⊆ declared — blind to an
// EXCESS declaration by construction.) 161 such lines were deleted when this
// guard went in; the DDL's pair set did not change, they moved into the
// generated half. Failing here means deleting the line, not widening this.
const allRulePairs = new Set([...scopeBridgePairs(), ...attachmentPairs()]);
expect([...structural].filter((pair) => allRulePairs.has(pair))).toEqual([]);
});
it('generated region matches the two rules exactly, with nothing extra', () => {
// Equality, not containment: a pair hand-added to the generated half would
// pass a subset check while quietly re-establishing the hand-list this PR
// replaced. Both directions are load-bearing.
const generated = [...declared].filter((pair) => !structural.has(pair)).sort();
const expected = [...new Set([...scopeBridgePairs(), ...attachmentPairs()])]
.filter((pair) => !structural.has(pair))
.sort();
expect(generated).toEqual(expected);
});
it('keeps caller anchors a subset of linkable labels', () => {
// A caller anchor outside the lookup's label set can never resolve to an
// id, so `resolveCallerGraphId` would silently climb past it to the File
// fallback and attribute the call to the module.
const unlinkable = [...CALLER_ANCHOR_LABELS].filter((label) => !LINKABLE_LABELS.has(label));
expect(unlinkable).toEqual([]);
});
it('names only real node tables on both endpoints', () => {
const tables = new Set<string>(NODE_TABLES);
const unknown = [...declared]
.flatMap((pair) => pair.split('|'))
.filter((label) => !tables.has(label))
.sort();
expect(unknown).toEqual([]);
});
it('declares each pair exactly once', () => {
// LadybugDB rejects a duplicated FROM/TO pair in the DDL, which would take
// out every `analyze` rather than one codebase's edge shape. Counts raw
// occurrences with the SAME matcher `parseRelationSchemaPairs` dedups
// through — re-inlining a copy of that regex would let any widening of it
// degrade this into the tautology `declared.size === declared.size`.
const occurrences = [...RELATION_SCHEMA.matchAll(createRelationPairMatcher())].length;
expect(occurrences).toBe(declared.size);
});
it('declares the pairs from the reported analyze crashes', () => {
// Java static/field initializer referencing a Variable (#2792); Vue/JS
// `const obj = { method() {} }` receiver (#2781); then the four #2793
// aborts, each reproduced on the default `analyze` path against its own
// fixture under `test/fixtures/lang-resolution/`.
const reported = [
'Class|Variable',
'Const|Method',
'Method|Annotation',
'Method|File',
'Namespace|Record',
'Class|Tool',
];
expect(reported.filter((pair) => !declared.has(pair))).toEqual([]);
});
it('declares the non-bridge structural pairs (#2789)', () => {
// COBOL containment/call/access. Every pair listed here is outside BOTH
// rules above, so no derived requirement can reach it and it must stay
// hand-declared in STRUCTURAL_PAIR_DDL;
// `test/integration/structural-pair-coverage.test.ts` guards them from a
// corpus. Pinned here too so deleting a fixture there cannot silently drop
// the guard, and so the cheap check does not need a build.
//
// #2789's original list also named `CodeElement|Record`, `Function|File`,
// `Module|Record` and `Record|Record`. Those are rule-2 pairs — `Record`
// and `File` are both in ATTACHMENT_TARGET_LABELS — so `declares every pair
// a phase/framework overlay can attach` already covers them, and repeating
// them here would have asserted the opposite of what the comment claims.
const nonBridge = [
'CodeElement|CodeElement',
'CodeElement|Module',
'CodeElement|Property',
'Module|CodeElement',
'Module|Namespace',
'Namespace|Function',
];
const allRulePairs = new Set([...scopeBridgePairs(), ...attachmentPairs()]);
expect(nonBridge.filter((pair) => allRulePairs.has(pair))).toEqual([]);
expect(nonBridge.filter((pair) => !structural.has(pair))).toEqual([]);
});
});

View file

@ -188,22 +188,31 @@ describe('LadybugDB Schema', () => {
expect(RELATION_SCHEMA).toContain('step INT32');
});
// These four go through `parseRelationSchemaPairs`, not a raw substring:
// each of their pairs is now emitted by a cross product rather than
// hand-written, and the generated half backticks EVERY label
// (`FROM \`Function\` TO \`Function\``) while the hand-written half
// backticks only multi-language names. Asserting the runtime's own parse
// keeps them about the pair being declared, which is what LadybugDB
// enforces — a cosmetic DDL formatting change cannot fail them.
it('connects Function to Function (CALLS)', () => {
expect(RELATION_SCHEMA).toContain('FROM Function TO Function');
expect(parseRelationSchemaPairs(RELATION_SCHEMA).has('Function|Function')).toBe(true);
});
it('connects File to Function (CONTAINS/DEFINES)', () => {
expect(RELATION_SCHEMA).toContain('FROM File TO Function');
expect(parseRelationSchemaPairs(RELATION_SCHEMA).has('File|Function')).toBe(true);
});
it('connects symbols to Community (MEMBER_OF)', () => {
expect(RELATION_SCHEMA).toContain('FROM Function TO Community');
expect(RELATION_SCHEMA).toContain('FROM Class TO Community');
const declaredPairs = parseRelationSchemaPairs(RELATION_SCHEMA);
expect(declaredPairs.has('Function|Community')).toBe(true);
expect(declaredPairs.has('Class|Community')).toBe(true);
});
it('connects symbols to Process (STEP_IN_PROCESS)', () => {
expect(RELATION_SCHEMA).toContain('FROM Function TO Process');
expect(RELATION_SCHEMA).toContain('FROM Method TO Process');
const declaredPairs = parseRelationSchemaPairs(RELATION_SCHEMA);
expect(declaredPairs.has('Function|Process')).toBe(true);
expect(declaredPairs.has('Method|Process')).toBe(true);
});
it('connects BasicBlock to BasicBlock (taint/PDG substrate edges, #2080)', () => {

View file

@ -0,0 +1,110 @@
/**
* Streamed structural emit must be resolved AFTER the guards that force a full
* rebuild (#2680 / PR #2793).
*
* `resolveStreamGraphEmit` gates on `options.force`, which several freshness
* guards rebind long after function entry see the comment at the
* `resolveStreamGraphEmit` call in `run-analyze.ts` for the full list.
* Resolving at entry froze it `false` for every rebuild they trigger, so the
* pipeline took the in-memory emit path exactly when the #2649 memory relief
* matters most and a schema bump makes EVERY existing index take that path on
* its next `analyze`.
*
* The seam: mock `runPipelineFromRepo` so it records the `PipelineOptions` the
* orchestrator actually built and then rejects. That asserts the real wiring
* (`streamGraphEmit` + `graphEmitCsvDir` as handed to the pipeline) rather than
* re-testing the pure resolver, which `stream-graph-emit-config.test.ts`
* already covers. Everything after the pipeline call is out of scope, so the
* mock's rejection is the intended end of the run.
*/
import { describe, it, expect, vi, afterEach } from 'vitest';
import fsp from 'node:fs/promises';
import path from 'node:path';
import {
getStoragePaths,
saveMeta,
INCREMENTAL_SCHEMA_VERSION,
} from '../../src/storage/repo-manager.js';
import { createTempDir } from '../helpers/test-db.js';
type PipelineModule = typeof import('../../src/core/ingestion/pipeline.js');
type CapturedPipelineOptions = NonNullable<Parameters<PipelineModule['runPipelineFromRepo']>[2]>;
/** Sentinel: the pipeline was reached, and the run ends there by design. */
const PIPELINE_REACHED = 'stream-graph-emit-ordering: pipeline reached';
const captured = vi.hoisted(() => ({ options: [] as unknown[] }));
vi.mock('../../src/core/ingestion/pipeline.js', async (importOriginal) => {
const actual = await importOriginal<PipelineModule>();
return {
...actual,
runPipelineFromRepo: (
_repoPath: string,
_onProgress: unknown,
options: unknown,
): Promise<never> => {
captured.options.push(options);
return Promise.reject(new Error(PIPELINE_REACHED));
},
};
});
afterEach(() => {
captured.options.length = 0;
vi.unstubAllEnvs();
});
describe('streamGraphEmit is resolved after the force-mutating freshness guards', () => {
it('arms streaming for the rebuild an INCREMENTAL_SCHEMA_VERSION bump forces', async () => {
// Pin the escape hatch ON so the assertion cannot be moved by ambient env.
// Before the fix this changed nothing: `force` was still unset at the entry
// read, and the `force !== true` short-circuit precedes the env lookup.
vi.stubEnv('GITNEXUS_STREAM_GRAPH_EMIT', '1');
const tmpRepo = await createTempDir('gitnexus-stream-order-');
const repoPath = tmpRepo.dbPath;
try {
const { metaPath } = getStoragePaths(repoPath);
const metaDir = path.dirname(metaPath);
await fsp.mkdir(metaDir, { recursive: true });
// An index stamped by the PREVIOUS schema — what every already-indexed
// repo looks like on its first analyze after the bump.
await saveMeta(metaDir, {
repoPath,
lastCommit: '',
indexedAt: new Date(0).toISOString(),
schemaVersion: INCREMENTAL_SCHEMA_VERSION - 1,
fileHashes: { 'src/a.ts': 'stale-hash' },
});
const { runFullAnalysis } = await import('../../src/core/run-analyze.js');
const logs: string[] = [];
// NOTE: no `force` from the caller — the rebuild is entirely guard-driven,
// which is the whole point.
await expect(
runFullAnalysis(
repoPath,
{ skipAgentsMd: true },
{ onProgress: () => {}, onLog: (m: string) => logs.push(m) },
),
).rejects.toThrow(PIPELINE_REACHED);
// The schema-version guard is what supplied `force` on this run.
expect(logs.filter((m) => m.includes('index schema changed'))).toHaveLength(1);
expect(captured.options).toHaveLength(1);
const pipelineOptions = captured.options[0] as CapturedPipelineOptions;
// The regression: pre-fix this was `false` / `undefined`, and the run
// built the whole relationship set in memory.
expect(pipelineOptions).toMatchObject({ streamGraphEmit: true });
// The paired CSV dir must be armed with it — the two are resolved from one
// value precisely so they cannot disagree.
expect(typeof pipelineOptions.graphEmitCsvDir).toBe('string');
} finally {
await tmpRepo.cleanup();
}
}, 120_000);
});