GitNexus/gitnexus/shadow-parity-dashboard/index.html
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

291 lines
8.1 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>GitNexus — Shadow Parity Dashboard</title>
<!--
Static dashboard for the RFC #909 shadow-mode parity report.
Reads `latest.json` from this directory and renders a per-language
parity table. Zero build step, zero runtime dependencies — a
single file that any browser or file:// context can open.
Usage:
# from repo root, after a shadow-mode run
cp .gitnexus/shadow-parity/latest.json gitnexus/shadow-parity-dashboard/
open gitnexus/shadow-parity-dashboard/index.html
CI artifact wiring (follow-up): the CI job publishes a snapshot
of this directory + latest.json as a downloadable bundle per run.
-->
<style>
:root {
color-scheme: light dark;
--fg: #1f2937;
--fg-muted: #6b7280;
--bg: #ffffff;
--bg-muted: #f9fafb;
--border: #e5e7eb;
--good: #16a34a;
--warn: #d97706;
--bad: #dc2626;
--primary-tag-legacy: #7c3aed;
--primary-tag-registry: #0ea5e9;
}
@media (prefers-color-scheme: dark) {
:root {
--fg: #e5e7eb;
--fg-muted: #9ca3af;
--bg: #111827;
--bg-muted: #1f2937;
--border: #374151;
}
}
html,
body {
margin: 0;
padding: 0;
background: var(--bg);
color: var(--fg);
font:
14px/1.45 system-ui,
-apple-system,
sans-serif;
}
main {
max-width: 1200px;
margin: 0 auto;
padding: 24px 16px;
}
h1 {
font-size: 20px;
margin: 0 0 4px;
}
.meta {
color: var(--fg-muted);
font-size: 12px;
margin-bottom: 20px;
}
.cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 10px;
margin-bottom: 20px;
}
.card {
border: 1px solid var(--border);
border-radius: 6px;
padding: 10px 12px;
background: var(--bg-muted);
}
.card .k {
color: var(--fg-muted);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.card .v {
font-size: 20px;
font-weight: 600;
}
table {
width: 100%;
border-collapse: collapse;
font-variant-numeric: tabular-nums;
}
th,
td {
padding: 6px 10px;
text-align: right;
border-bottom: 1px solid var(--border);
}
th:first-child,
td:first-child {
text-align: left;
}
thead th {
font-weight: 600;
color: var(--fg-muted);
font-size: 12px;
background: var(--bg-muted);
}
tbody tr:hover {
background: var(--bg-muted);
}
.parity {
font-weight: 600;
}
.parity.good {
color: var(--good);
}
.parity.warn {
color: var(--warn);
}
.parity.bad {
color: var(--bad);
}
.tag {
display: inline-block;
padding: 1px 6px;
border-radius: 10px;
font-size: 10px;
margin-left: 6px;
color: white;
}
.tag.legacy {
background: var(--primary-tag-legacy);
}
.tag.registry {
background: var(--primary-tag-registry);
}
.empty {
padding: 40px;
text-align: center;
color: var(--fg-muted);
}
code {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
background: var(--bg-muted);
padding: 1px 4px;
border-radius: 3px;
}
</style>
</head>
<body>
<main>
<h1>Shadow Parity — RFC #909</h1>
<div class="meta" id="meta">loading <code>latest.json</code></div>
<div class="cards" id="cards"></div>
<table id="per-language">
<thead>
<tr>
<th>Language</th>
<th>Total</th>
<th>Agree</th>
<th>Only legacy</th>
<th>Only new</th>
<th>Disagree</th>
<th>Both empty</th>
<th>Parity</th>
</tr>
</thead>
<tbody></tbody>
</table>
<div id="empty" class="empty" style="display: none">
No records yet. Enable <code>GITNEXUS_SHADOW_MODE=1</code> and run ingestion to populate.
</div>
</main>
<script>
/* global fetch, document */
(async function () {
const tbody = document.querySelector('#per-language tbody');
const cards = document.getElementById('cards');
const meta = document.getElementById('meta');
const empty = document.getElementById('empty');
const table = document.getElementById('per-language');
let payload;
try {
const r = await fetch('./latest.json', { cache: 'no-store' });
if (!r.ok) throw new Error('HTTP ' + r.status);
payload = await r.json();
} catch (err) {
meta.textContent = 'Failed to load latest.json: ' + err.message;
table.style.display = 'none';
empty.style.display = 'block';
return;
}
const primary = payload.primaryByLanguage || {};
const report = payload.report || {};
const perLang = report.perLanguage || [];
const overall = report.overall || {};
meta.textContent =
'Run ' +
payload.runId +
' — generated ' +
payload.generatedAt +
' (schema v' +
payload.schemaVersion +
')';
// Overall summary cards.
cards.innerHTML = '';
const overallParity = overall.parity !== undefined ? overall.parity : 0;
cards.appendChild(makeCard('Total calls', overall.totalCalls ?? 0));
cards.appendChild(makeCard('Both agree', overall.bothAgree ?? 0));
cards.appendChild(makeCard('Disagree', overall.bothDisagree ?? 0));
cards.appendChild(makeCard('Overall parity', formatPct(overallParity)));
if (!perLang.length) {
table.style.display = 'none';
empty.style.display = 'block';
return;
}
for (const row of perLang) {
const tr = document.createElement('tr');
const primaryTag = primary[row.language];
const tag = primaryTag
? '<span class="tag ' + primaryTag + '">primary: ' + primaryTag + '</span>'
: '';
const parityClass = parityClassFor(row.parity);
tr.innerHTML =
'<td>' +
escape(row.language) +
tag +
'</td>' +
'<td>' +
row.totalCalls +
'</td>' +
'<td>' +
row.bothAgree +
'</td>' +
'<td>' +
row.onlyLegacy +
'</td>' +
'<td>' +
row.onlyNew +
'</td>' +
'<td>' +
row.bothDisagree +
'</td>' +
'<td>' +
row.bothEmpty +
'</td>' +
'<td class="parity ' +
parityClass +
'">' +
formatPct(row.parity) +
'</td>';
tbody.appendChild(tr);
}
function makeCard(k, v) {
const div = document.createElement('div');
div.className = 'card';
div.innerHTML =
'<div class="k">' + escape(k) + '</div><div class="v">' + escape(String(v)) + '</div>';
return div;
}
function formatPct(x) {
if (typeof x !== 'number' || !isFinite(x)) return '—';
return (x * 100).toFixed(1) + '%';
}
function parityClassFor(x) {
if (typeof x !== 'number') return '';
if (x >= 0.95) return 'good';
if (x >= 0.8) return 'warn';
return 'bad';
}
function escape(s) {
return String(s).replace(/[&<>"']/g, function (c) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
});
}
})();
</script>
</body>
</html>