mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-02 02:11:29 +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>
434 lines
16 KiB
TypeScript
434 lines
16 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
import { EventEmitter } from 'events';
|
|
import fs from 'fs';
|
|
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';
|
|
|
|
/**
|
|
* Unit tests for RelPairRouter (#2203 U2) — the production per-pair emit path.
|
|
*
|
|
* Mirrors test/unit/rel-csv-split.test.ts: drives the router with an injected
|
|
* mock WriteStream factory so the error, backpressure, and teardown paths are
|
|
* exercised without LadybugDB or real disk streams. These paths are otherwise
|
|
* unreachable in the integration suite (which only hits the no-backpressure
|
|
* happy path), so this is the coverage for the router's failure modes.
|
|
*/
|
|
|
|
// Controllable backpressure + error injection (same shape as the split oracle's mock).
|
|
class MockWriteStream extends EventEmitter {
|
|
public chunks: string[] = [];
|
|
public destroyed = false;
|
|
public ended = false;
|
|
public blocked = false;
|
|
public maxDrainListenersSeen = 0;
|
|
// State flags + events so `stream/promises.finished(ws)` (used by the
|
|
// router's close()) resolves against this mock instead of hanging.
|
|
public writable = true;
|
|
public writableEnded = false;
|
|
public writableFinished = false;
|
|
|
|
write(chunk: string): boolean {
|
|
this.chunks.push(chunk);
|
|
const count = this.listenerCount('drain');
|
|
if (count > this.maxDrainListenersSeen) this.maxDrainListenersSeen = count;
|
|
return !this.blocked;
|
|
}
|
|
|
|
end(cb?: (err?: Error) => void): this {
|
|
this.ended = true;
|
|
this.writableEnded = true;
|
|
this.writableFinished = true;
|
|
this.writable = false;
|
|
if (cb) cb();
|
|
queueMicrotask(() => {
|
|
this.emit('finish');
|
|
this.emit('close');
|
|
});
|
|
return this;
|
|
}
|
|
|
|
destroy(): this {
|
|
this.destroyed = true;
|
|
return this;
|
|
}
|
|
|
|
unblock(): void {
|
|
this.blocked = false;
|
|
this.emit('drain');
|
|
}
|
|
|
|
triggerError(err: Error): void {
|
|
this.emit('error', err);
|
|
}
|
|
}
|
|
|
|
const HEADER = '"from","to","type","confidence","reason","step"';
|
|
const VALID = new Set<string>(['File', 'Function', 'Community', 'Process']);
|
|
const DECLARED = new Set<string>(['File|Function', 'Function|Function', 'Community|Community']);
|
|
|
|
const row = (from: string, to: string, type = 'CALLS'): string =>
|
|
`"${from}","${to}","${type}",1.0,"auto",0`;
|
|
|
|
function mockFactory(streams: MockWriteStream[], opts?: { blocked?: boolean }) {
|
|
return (() => {
|
|
const ws = new MockWriteStream();
|
|
if (opts?.blocked) ws.blocked = true;
|
|
streams.push(ws);
|
|
return ws;
|
|
}) as unknown as (filePath: string) => import('fs').WriteStream;
|
|
}
|
|
|
|
let tmpDir: string;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rel-pair-routing-test-'));
|
|
});
|
|
|
|
afterEach(() => {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
|
|
});
|
|
|
|
describe('getNodeLabel', () => {
|
|
it('maps comm_/proc_ prefixes and otherwise splits on the first colon', () => {
|
|
expect(getNodeLabel('comm_42')).toBe('Community');
|
|
expect(getNodeLabel('proc_7')).toBe('Process');
|
|
expect(getNodeLabel('Function:src/a.ts:f:1')).toBe('Function');
|
|
expect(getNodeLabel('File:src/a.ts')).toBe('File');
|
|
});
|
|
});
|
|
|
|
/**
|
|
* `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(
|
|
parseRelationSchemaPairs(`
|
|
CREATE REL TABLE CodeRelation(
|
|
FROM Class TO CodeElement,
|
|
FROM \`Enum\` TO \`TypeAlias\`,
|
|
type STRING
|
|
)
|
|
`),
|
|
).toEqual(new Set(['Class|CodeElement', 'Enum|TypeAlias']));
|
|
});
|
|
});
|
|
|
|
/**
|
|
* 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), 'CALLS');
|
|
if (p) await p;
|
|
};
|
|
await route('File:a', 'Function:a:f:1');
|
|
await route('File:a', 'Function:a:g:2'); // same pair
|
|
await route('Function:a:f:1', 'Function:a:g:2'); // different pair
|
|
await route('Bogus:x', 'File:a'); // invalid FROM label → skipped
|
|
await route('File:a', 'Bogus:y'); // invalid TO label → skipped (other branch)
|
|
await router.close();
|
|
|
|
expect(router.skipped).toBe(2);
|
|
expect(router.total).toBe(3);
|
|
expect([...router.byPair.keys()].sort()).toEqual(['File|Function', 'Function|Function']);
|
|
expect(router.byPair.get('File|Function')!.rows).toBe(2);
|
|
// Header is the first chunk written to each pair stream.
|
|
expect(streams[0].chunks[0]).toBe(HEADER + '\n');
|
|
expect(streams.every((s) => s.ended)).toBe(true);
|
|
});
|
|
|
|
it('rejects a valid-label pair that is absent from the relation schema', () => {
|
|
const streams: MockWriteStream[] = [];
|
|
const router = new RelPairRouter(tmpDir, HEADER, VALID, DECLARED, mockFactory(streams));
|
|
|
|
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);
|
|
});
|
|
|
|
it('returns a drain promise under backpressure and completes once unblocked', async () => {
|
|
const streams: MockWriteStream[] = [];
|
|
const router = new RelPairRouter(
|
|
tmpDir,
|
|
HEADER,
|
|
VALID,
|
|
DECLARED,
|
|
mockFactory(streams, { blocked: true }),
|
|
);
|
|
|
|
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;
|
|
|
|
expect(streams[0].maxDrainListenersSeen).toBeLessThanOrEqual(1);
|
|
expect(streams[0].chunks[0]).toBe(HEADER + '\n');
|
|
expect(router.total).toBe(1);
|
|
});
|
|
|
|
it('on a stream error: route() throws the real error, lastError exposes it, close() rejects + destroys', async () => {
|
|
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'),
|
|
'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'), 'DEFINES'),
|
|
).toThrow('EMFILE');
|
|
expect(router.lastError).toBe(err);
|
|
await expect(router.close()).rejects.toThrow('EMFILE');
|
|
expect(streams[0].destroyed).toBe(true);
|
|
});
|
|
|
|
it('destroy() tears down every open pair stream', async () => {
|
|
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'), 'DEFINES');
|
|
if (a) await a;
|
|
const b = router.route(
|
|
'Community:1',
|
|
'Community:2',
|
|
row('Community:1', 'Community:2'),
|
|
'RELATED_TO',
|
|
);
|
|
if (b) await b;
|
|
|
|
router.destroy();
|
|
expect(streams.length).toBe(2);
|
|
expect(streams.every((s) => s.destroyed)).toBe(true);
|
|
});
|
|
});
|