GitNexus/gitnexus/test/unit/scope-resolution/shadow-harness.test.ts
Gergő Magyar e2ba4a04c9
feat(ingestion): shadow-mode parity harness + static dashboard (#923, RFC #909 Ring 2 PKG) (#972)
* feat(ingestion): shadow-mode parity harness + static dashboard (#923, RFC #909 Ring 2 PKG)

Side-car observability for the RFC #909 registry rollout. Callers that
dual-run legacy-DAG + `Registry.lookup` feed their result pairs into
the harness; the harness diffs each pair via shared `diffResolutions`
(#918), aggregates via `aggregateDiffs`, and persists a per-language
parity report that the static dashboard can render offline.

## Shipped

### `gitnexus/src/core/ingestion/shadow-harness.ts` (new)

```ts
createShadowHarness(): ShadowHarness
```

API:
  - `enabled` — `true` iff `GITNEXUS_SHADOW_MODE` is truthy at
    construction. Captured once; later env-var mutations don't flip it.
  - `record({ language, callsite, legacy, newResult, primary })` —
    accumulator. No-op when `enabled === false` (near-zero overhead).
  - `size()` — diagnostic counter.
  - `snapshot(now?)` — deterministic `ShadowParityReport` from the
    accumulated diffs.
  - `persist(outputDir, now?)` — writes BOTH a timestamped
    `<runId>.json` and a `latest.json` pointer. Creates outputDir if
    absent. Returns the per-run file path.
  - `clear()` — resets the accumulator; preserves `enabled`.

Activation: `GITNEXUS_SHADOW_MODE` accepts `'true'` / `'1'` / `'yes'`
(case-insensitive, trimmed); same truthy convention as
`REGISTRY_PRIMARY_<LANG>` from #924. Typos → disabled (fail-safe).

Persisted payload (`PersistedShadowReport`) is schema-versioned (`v1`):

```jsonc
{
  "schemaVersion": 1,
  "runId": "YYYYMMDD-HHMMSS-xxxxxxxx",
  "generatedAt": "ISO 8601",
  "primaryByLanguage": { "python": "legacy", ... },
  "report": { /* ShadowParityReport from #918 aggregateDiffs */ }
}
```

`runId` prefix is the timestamp so files sort chronologically; the
entropy suffix prevents collisions within a clock-second.

### `gitnexus/shadow-parity-dashboard/index.html` (new)

Minimal static dashboard — one HTML file, zero build step, zero runtime
deps. Fetches `./latest.json` and renders:

  - Overall summary cards (total calls, both agree, disagree, overall parity %)
  - Per-language table: language tag ("primary: legacy" / "primary:
    registry" pill) + total / agree / only-legacy / only-new / disagree
    / both-empty / parity%
  - Parity cells colored by threshold: ≥95% green, ≥80% amber, <80% red
  - Light / dark via `prefers-color-scheme`
  - Empty-state message when no records yet

File-serving is static: `cp .gitnexus/shadow-parity/latest.json
gitnexus/shadow-parity-dashboard/` + open in a browser.

## Tests (14, all passing)

  - **Flag detection** (5): default off · truthy variants case-insensitive ·
    falsy / typo → off · record() is no-op when disabled · env flip
    AFTER construction doesn't enable (constructed-once semantics)
  - **Record + snapshot** (4): multi-language accumulation ·
    per-language rows with correct outcomes · snapshot determinism ·
    `clear()` resets accumulator + `primaryByLanguage`
  - **Persistence** (5): mkdir-p on missing outputDir · per-run +
    latest.json match byte-for-byte · schema v1 payload shape ·
    runId timestamp prefix sorts chronologically · empty report
    persists gracefully

Tests use a per-test tmpdir (`fs.mkdtemp`), cleaned in `afterEach`,
so parallel vitest runs don't collide. `GITNEXUS_SHADOW_MODE` is
saved + restored per-test.

## What's deliberately NOT in this PR (call-out in harness docstring)

  - **Dual-run dispatch.** The harness is a side-car — it does NOT
    invoke either resolution path. Call-processor integration that
    actually runs both legacy + registry paths lands as a follow-up.
    Without that integration, `record()` is never called in production
    today. The harness is tested in isolation with synthetic inputs.
  - **CI artifact publishing.** Config work to upload
    `latest.json` + the dashboard HTML per CI run. Tracked separately;
    the harness + dashboard are ready when the CI job wires in.
  - **Fixture-level drill-down.** The issue mentions per-fixture AST
    snippet + evidence trace drill-down. MVP dashboard shows per-language
    rows only; drill-down extends the static JSON format + the dashboard
    JS in a focused follow-up.

## Verification

  - `tsc --noEmit` clean (both `gitnexus-shared` and `gitnexus`)
  - 14/14 new tests pass
  - Full scope-resolution / shadow / model / flag suite: **335/335 pass**

## Part of

  - Parent: #909
  - Depends on (code): #917 (registries), #918 (diff + aggregate)
  - Unblocks Ring 3 language flips: the parity dashboard becomes the
    checkpoint before flipping `REGISTRY_PRIMARY_<LANG>=true` for a
    language — once per-language parity stabilizes, the flip ships.

* chore: prettier format on shadow-parity-dashboard index.html
2026-04-18 21:30:43 +01:00

290 lines
9.6 KiB
TypeScript

/**
* Unit tests for `shadow-harness` (RFC #909 Ring 2 PKG #923).
*
* Covers flag detection, record accumulation, aggregation, and JSON
* persistence (real fs in a per-test tmpdir).
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as fsp from 'node:fs/promises';
import * as os from 'node:os';
import * as path from 'node:path';
import {
EvidenceWeights,
SupportedLanguages,
type Resolution,
type ShadowCallsite,
type SymbolDefinition,
} from 'gitnexus-shared';
import {
createShadowHarness,
type PersistedShadowReport,
type ShadowHarness,
} from '../../../src/core/ingestion/shadow-harness.js';
// ─── Env isolation — GITNEXUS_SHADOW_MODE bleeds between tests otherwise ──
let savedEnv: string | undefined;
beforeEach(() => {
savedEnv = process.env['GITNEXUS_SHADOW_MODE'];
delete process.env['GITNEXUS_SHADOW_MODE'];
});
afterEach(() => {
if (savedEnv === undefined) delete process.env['GITNEXUS_SHADOW_MODE'];
else process.env['GITNEXUS_SHADOW_MODE'] = savedEnv;
});
// ─── Fixture helpers ──────────────────────────────────────────────────────
const callsite = (filePath = 'a.ts', line = 1): ShadowCallsite => ({
filePath,
range: { startLine: line, startCol: 0, endLine: line, endCol: 10 },
});
const def = (nodeId: string): SymbolDefinition => ({
nodeId,
filePath: 'x.ts',
type: 'Class',
});
const resolution = (nodeId: string): Resolution => ({
def: def(nodeId),
confidence: EvidenceWeights.local,
evidence: [{ kind: 'local', weight: EvidenceWeights.local }],
});
function enable(): void {
process.env['GITNEXUS_SHADOW_MODE'] = 'true';
}
function freshHarness(): ShadowHarness {
return createShadowHarness();
}
// ─── Flag detection ───────────────────────────────────────────────────────
describe('createShadowHarness: enabled flag', () => {
it('is disabled by default (no env var set)', () => {
expect(freshHarness().enabled).toBe(false);
});
it("is enabled when GITNEXUS_SHADOW_MODE is 'true' / '1' / 'yes' / case-insensitive", () => {
for (const value of ['true', '1', 'yes', 'TRUE', ' Yes ']) {
process.env['GITNEXUS_SHADOW_MODE'] = value;
expect(freshHarness().enabled).toBe(true);
}
});
it('stays disabled for falsy-looking or typo values', () => {
for (const value of ['', 'false', '0', 'off', 'tru']) {
process.env['GITNEXUS_SHADOW_MODE'] = value;
expect(freshHarness().enabled).toBe(false);
}
});
it('record() is a no-op when disabled', () => {
const h = freshHarness(); // disabled
h.record({
language: SupportedLanguages.Python,
callsite: callsite(),
legacy: [resolution('def:a')],
newResult: [resolution('def:a')],
primary: 'legacy',
});
expect(h.size()).toBe(0);
});
it('does NOT re-check the env var per call (constructed-once semantics)', () => {
const h = freshHarness(); // disabled at construction
process.env['GITNEXUS_SHADOW_MODE'] = 'true'; // flip AFTER construction
h.record({
language: SupportedLanguages.Python,
callsite: callsite(),
legacy: [resolution('def:a')],
newResult: [resolution('def:a')],
primary: 'legacy',
});
// Still disabled — the harness captured its `enabled` at construction.
expect(h.size()).toBe(0);
});
});
// ─── Record + snapshot ────────────────────────────────────────────────────
describe('record + snapshot', () => {
it('accumulates records across languages', () => {
enable();
const h = freshHarness();
h.record({
language: SupportedLanguages.Python,
callsite: callsite(),
legacy: [resolution('def:a')],
newResult: [resolution('def:a')],
primary: 'legacy',
});
h.record({
language: SupportedLanguages.TypeScript,
callsite: callsite('b.ts'),
legacy: [resolution('def:b')],
newResult: [],
primary: 'registry',
});
expect(h.size()).toBe(2);
});
it('snapshot reports per-language rows with correct outcomes', () => {
enable();
const h = freshHarness();
h.record({
language: SupportedLanguages.Python,
callsite: callsite(),
legacy: [resolution('def:a')],
newResult: [resolution('def:a')],
primary: 'legacy',
});
h.record({
language: SupportedLanguages.Python,
callsite: callsite('a.py', 2),
legacy: [resolution('def:b')],
newResult: [],
primary: 'legacy',
});
const report = h.snapshot(new Date('2026-04-18T00:00:00Z'));
expect(report.perLanguage).toHaveLength(1);
const py = report.perLanguage[0]!;
expect(py.language).toBe(SupportedLanguages.Python);
expect(py.totalCalls).toBe(2);
expect(py.bothAgree).toBe(1);
expect(py.onlyLegacy).toBe(1);
});
it('snapshot is deterministic across repeated calls', () => {
enable();
const h = freshHarness();
h.record({
language: SupportedLanguages.Python,
callsite: callsite(),
legacy: [resolution('def:a')],
newResult: [resolution('def:a')],
primary: 'legacy',
});
const now = new Date('2026-04-18T12:00:00Z');
const a = h.snapshot(now);
const b = h.snapshot(now);
expect(JSON.stringify(a)).toBe(JSON.stringify(b));
});
it('clear() resets the accumulator and primaryByLanguage', async () => {
enable();
const h = freshHarness();
h.record({
language: SupportedLanguages.Python,
callsite: callsite(),
legacy: [resolution('def:a')],
newResult: [resolution('def:a')],
primary: 'registry',
});
expect(h.size()).toBe(1);
h.clear();
expect(h.size()).toBe(0);
// Verify primary is also cleared: persist after a fresh record with a
// different primary should reflect the new value only.
h.record({
language: SupportedLanguages.Python,
callsite: callsite('a.py'),
legacy: [resolution('def:a')],
newResult: [resolution('def:a')],
primary: 'legacy',
});
const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'gn-sh-clear-'));
try {
await h.persist(dir);
const payload = JSON.parse(fs.readFileSync(path.join(dir, 'latest.json'), 'utf8'));
expect(payload.primaryByLanguage.python).toBe('legacy');
} finally {
await fsp.rm(dir, { recursive: true, force: true });
}
});
});
// ─── Persistence ──────────────────────────────────────────────────────────
describe('persist', () => {
let tmpDir: string;
beforeEach(async () => {
tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'gn-shadow-harness-'));
});
afterEach(async () => {
await fsp.rm(tmpDir, { recursive: true, force: true });
});
it('creates outputDir if it does not exist', async () => {
enable();
const h = freshHarness();
const nested = path.join(tmpDir, 'nested', 'a', 'b');
await h.persist(nested);
expect(fs.existsSync(nested)).toBe(true);
expect(fs.existsSync(path.join(nested, 'latest.json'))).toBe(true);
});
it('writes BOTH a timestamped file and latest.json with the same payload', async () => {
enable();
const h = freshHarness();
h.record({
language: SupportedLanguages.Python,
callsite: callsite(),
legacy: [resolution('def:a')],
newResult: [resolution('def:a')],
primary: 'legacy',
});
const perRunPath = await h.persist(tmpDir, new Date('2026-04-18T12:34:56Z'));
const latestPath = path.join(tmpDir, 'latest.json');
expect(fs.existsSync(perRunPath)).toBe(true);
expect(fs.existsSync(latestPath)).toBe(true);
expect(fs.readFileSync(perRunPath, 'utf8')).toBe(fs.readFileSync(latestPath, 'utf8'));
});
it('persisted payload matches the schema v1 shape', async () => {
enable();
const h = freshHarness();
h.record({
language: SupportedLanguages.TypeScript,
callsite: callsite('a.ts'),
legacy: [resolution('def:a')],
newResult: [resolution('def:a')],
primary: 'registry',
});
const now = new Date('2026-04-18T00:00:00Z');
await h.persist(tmpDir, now);
const payload: PersistedShadowReport = JSON.parse(
fs.readFileSync(path.join(tmpDir, 'latest.json'), 'utf8'),
);
expect(payload.schemaVersion).toBe(1);
expect(payload.runId).toMatch(/^\d{8}-\d{6}-[0-9a-f]{8}$/);
expect(payload.generatedAt).toBe('2026-04-18T00:00:00.000Z');
expect(payload.primaryByLanguage.typescript).toBe('registry');
expect(payload.report.overall.totalCalls).toBe(1);
expect(payload.report.overall.bothAgree).toBe(1);
});
it('runId embeds the run timestamp for chronological sorting', async () => {
enable();
const h1 = freshHarness();
const h2 = freshHarness();
const p1 = await h1.persist(tmpDir, new Date('2026-04-18T00:00:00Z'));
const p2 = await h2.persist(tmpDir, new Date('2026-04-18T01:00:00Z'));
// Timestamp prefix means the second file sorts after the first.
expect(path.basename(p2) > path.basename(p1)).toBe(true);
});
it('persists an empty report gracefully (no records, no error)', async () => {
enable();
const h = freshHarness();
await h.persist(tmpDir);
const payload = JSON.parse(fs.readFileSync(path.join(tmpDir, 'latest.json'), 'utf8'));
expect(payload.report.overall.totalCalls).toBe(0);
expect(payload.report.perLanguage).toEqual([]);
});
});