mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
* 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 in81daf370e(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…), and81daf370etouches 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:
parent
74409a37f6
commit
010a7d806a
32 changed files with 2278 additions and 303 deletions
100
gitnexus/bench/schema-pairs/README.md
Normal file
100
gitnexus/bench/schema-pairs/README.md
Normal 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.93–1.05× | 0.98–1.17× |
|
||||
| 641 | 1.22–1.43× | 1.11–1.23× |
|
||||
| 786 | 1.52–1.75× | 1.19–1.31× |
|
||||
| 1024 | 2.03–2.34× | 1.31–1.57× |
|
||||
|
||||
Production's 450 came out _faster_ than 332 on three of the four runs, so at this
|
||||
size the pair count is inside run-to-run noise. Everything past ~640 is not.
|
||||
**Quote the range, not a single run** — one run is not evidence here.
|
||||
|
||||
## What it measures
|
||||
|
||||
For each pair-set size it builds a fresh database with all 32 node tables, a
|
||||
`CodeRelation` table declaring exactly that many FROM/TO pairs, and **identical
|
||||
data**, then times two query shapes over 40 anchors × 15 reps (median):
|
||||
|
||||
- **`untyped_ms_<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.
|
||||
4
gitnexus/bench/schema-pairs/baselines.json
Normal file
4
gitnexus/bench/schema-pairs/baselines.json
Normal 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
|
||||
}
|
||||
357
gitnexus/bench/schema-pairs/measure.mjs
Normal file
357
gitnexus/bench/schema-pairs/measure.mjs
Normal 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`);
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`),
|
||||
|
|
|
|||
|
|
@ -24,8 +24,8 @@
|
|||
* `storage/parsedfile-store.ts`.
|
||||
*
|
||||
* Byte-identity (issue acceptance): the sink reuses the SAME shared row
|
||||
* builders (`buildBasicBlockRow`, `buildRelRow`) and label derivation
|
||||
* (`getNodeLabel`) as `streamAllCSVsToDisk`, so the streamed CSV line SET is
|
||||
* builders (`buildBasicBlockRow`, `buildRelRow`) and pair classification
|
||||
* (`relPairKeyFor`) as `streamAllCSVsToDisk`, so the streamed CSV line SET is
|
||||
* identical to the whole-graph emit's, and the bulk COPY loads the same rows →
|
||||
* the persisted graph is SET-identical and DB-identical. The guarantee is
|
||||
* set-level, not byte-level on the CSV file: the sink streams rows in emit
|
||||
|
|
@ -54,9 +54,14 @@ import {
|
|||
buildBasicBlockRow,
|
||||
buildRelRow,
|
||||
} from './csv-generator.js';
|
||||
import { assertDeclaredPair, getNodeLabel } from './rel-pair-routing.js';
|
||||
import {
|
||||
VALID_NODE_TABLES,
|
||||
assertDeclaredPair,
|
||||
relPairKeyFor,
|
||||
splitRelPairKey,
|
||||
} from './rel-pair-routing.js';
|
||||
import { DEFAULT_EMIT_CHUNK_ROWS, SyncCsvWriter } from './sync-csv-writer.js';
|
||||
import { NODE_TABLES, type NodeTableName } from './schema.js';
|
||||
import { type NodeTableName } from './schema.js';
|
||||
|
||||
/**
|
||||
* PDG edge types streamed per-file (all intra-block BasicBlock→BasicBlock).
|
||||
|
|
@ -98,7 +103,6 @@ export interface PdgEmitManifest {
|
|||
* `--pdg` emit, then {@link finalize} once after the last language.
|
||||
*/
|
||||
export class PdgEmitSink implements KnowledgeGraph {
|
||||
private readonly validTables: Set<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`),
|
||||
|
|
|
|||
|
|
@ -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 FROM→TO 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);
|
||||
|
|
|
|||
|
|
@ -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 332→450 growth
|
||||
* (118 pairs, of which these ~47 are a part) at 0.93–1.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.93–1.05× 641 → 1.22–1.43× 786 → 1.52–1.75× 1024 → 2.03–2.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.22–1.43× on anchored queries, where production's 450 is inside noise — so
|
||||
* that trade buys ~43 fewer hand-written lines for a real ~22–43% 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,
|
||||
|
|
|
|||
|
|
@ -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 (0–60%) ────────────────────────────────
|
||||
const pipelineResult = await runPipelineFromRepo(
|
||||
repoPath,
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
48
gitnexus/test/fixtures/lang-resolution/cobol-declaratives/ERRDEMO.cbl
vendored
Normal file
48
gitnexus/test/fixtures/lang-resolution/cobol-declaratives/ERRDEMO.cbl
vendored
Normal 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.
|
||||
22
gitnexus/test/fixtures/lang-resolution/mcp-tool-class/server.py
vendored
Normal file
22
gitnexus/test/fixtures/lang-resolution/mcp-tool-class/server.py
vendored
Normal 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)
|
||||
30
gitnexus/test/fixtures/lang-resolution/spring-conditional-app/java/AppAutoConfiguration.java
vendored
Normal file
30
gitnexus/test/fixtures/lang-resolution/spring-conditional-app/java/AppAutoConfiguration.java
vendored
Normal 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();
|
||||
}
|
||||
}
|
||||
5
gitnexus/test/fixtures/lang-resolution/spring-conditional-app/java/PaymentService.java
vendored
Normal file
5
gitnexus/test/fixtures/lang-resolution/spring-conditional-app/java/PaymentService.java
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
package com.example;
|
||||
|
||||
public class PaymentService {
|
||||
public void pay() {}
|
||||
}
|
||||
20
gitnexus/test/fixtures/lang-resolution/spring-conditional-app/kotlin/KotlinAutoConfiguration.kt
vendored
Normal file
20
gitnexus/test/fixtures/lang-resolution/spring-conditional-app/kotlin/KotlinAutoConfiguration.kt
vendored
Normal 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()
|
||||
}
|
||||
29
gitnexus/test/fixtures/lang-resolution/vue-basic/OptionsHost.vue
vendored
Normal file
29
gitnexus/test/fixtures/lang-resolution/vue-basic/OptionsHost.vue
vendored
Normal 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>
|
||||
180
gitnexus/test/integration/structural-pair-coverage.test.ts
Normal file
180
gitnexus/test/integration/structural-pair-coverage.test.ts
Normal 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: [] });
|
||||
},
|
||||
);
|
||||
});
|
||||
169
gitnexus/test/unit/analyze-undeclared-pair-error.test.ts
Normal file
169
gitnexus/test/unit/analyze-undeclared-pair-error.test.ts
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
/**
|
||||
* Tests for the undeclared FROM→TO 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -147,7 +147,7 @@ describe('GraphEmitSink routing', () => {
|
|||
reason: 'direct',
|
||||
};
|
||||
expect(() => sink.addRelationship(undeclared)).toThrow(
|
||||
/Relationship label pair Static→Static is not declared/,
|
||||
/Relationship label pair Static → Static is not declared/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -95,7 +95,7 @@ describe('PdgEmitSink — routing', () => {
|
|||
reason: 'seq',
|
||||
};
|
||||
expect(() => sink.addRelationship(undeclared)).toThrow(
|
||||
/Relationship label pair Static→Static is not declared/,
|
||||
/Relationship label pair Static → Static is not declared/,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
211
gitnexus/test/unit/schema-pair-coverage.test.ts
Normal file
211
gitnexus/test/unit/schema-pair-coverage.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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)', () => {
|
||||
|
|
|
|||
110
gitnexus/test/unit/stream-graph-emit-force-ordering.test.ts
Normal file
110
gitnexus/test/unit/stream-graph-emit-force-ordering.test.ts
Normal 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);
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue