mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-22 00:31:17 +00:00
* 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
290 lines
9.6 KiB
TypeScript
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([]);
|
|
});
|
|
});
|