refactor(ingestion): delete shadow-mode parity harness (RING4-3, #944) (#2071)

Ring 4 retires the legacy call-resolution DAG. With the legacy resolver
gone (RING4-1 #942, RING4-2 #943), shadow mode has nothing to dual-run
against, so the remaining shadow-mode artifacts are dead code.

- Delete gitnexus-shared/src/scope-resolution/shadow/{diff,aggregate}.ts
  (pure parity comparison logic) and its gitnexus-shared barrel exports.
- Delete the static parity dashboard (gitnexus/shadow-parity-dashboard/),
  which also removes the last GITNEXUS_SHADOW_MODE reference in the repo.
- Delete the shadow-mode unit tests (gitnexus/test/unit/shadow/).
- Scrub stale doc comments referencing the shadow harness / parity
  dashboard / removed legacy run (csharp/php/python/typescript index.ts,
  evidence.ts, module-scope-index.ts).

Already removed by RING4-1/-2 (verified): the shadow harness source and
GITNEXUS_SHADOW_MODE env handling; no CI job published dashboard artifacts.

Historical parity records preserved per acceptance: the CHANGELOG entry
(#918, #923, #951, #972) and the ci.yml RING4-1 note remain. Last
documented parity state is that historical coverage — no live
.gitnexus/shadow-parity/ run data exists in-tree (runtime output only).

Closes #944.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gergő Magyar 2026-06-07 20:06:42 +01:00 • committed by GitHub
parent f0c292f9e7
commit 4fc2ffa5d0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 15 additions and 1062 deletions

View file

@ -183,13 +183,3 @@ export {
stripGitSuffix,
} from './integrations/understand-quickly.js';
export type { UqDispatchPayload } from './integrations/understand-quickly.js';
// Shadow-mode diff + aggregation (RFC §6.3; Ring 2 SHARED #918)
export { diffResolutions } from './scope-resolution/shadow/diff.js';
export type {
ShadowAgreement,
ShadowCallsite,
ShadowDiff,
} from './scope-resolution/shadow/diff.js';
export { aggregateDiffs } from './scope-resolution/shadow/aggregate.js';
export type { LanguageParityRow, ShadowParityReport } from './scope-resolution/shadow/aggregate.js';

View file

@ -8,8 +8,7 @@
*
* Part of RFC #909 Ring 2 SHARED — #913.
*
* Consumed by: #915 (SCC finalize link pass), #923 (shadow harness when
* resolving callsite file → enclosing module).
* Consumed by: #915 (SCC finalize link pass).
*/
import type { ScopeId } from './types.js';

View file

@ -57,8 +57,7 @@ export interface RawSignals {
*
* Emission order mirrors the `EvidenceWeights` layout: where-found →
* type-binding → corroborators → arity → degraded. Stable order makes
* the per-signal contributions easy to reason about in tests and in the
* shadow-mode parity dashboard.
* the per-signal contributions easy to reason about in tests.
*/
export function composeEvidence(signals: RawSignals): readonly ResolutionEvidence[] {
const out: ResolutionEvidence[] = [];
@ -141,7 +140,7 @@ export function composeEvidence(signals: RawSignals): readonly ResolutionEvidenc
/**
* Sum evidence weights and clamp to `[0, 1]`. Separate from `composeEvidence`
* so tests and the parity dashboard can inspect the raw evidence list.
* so tests can inspect the raw evidence list.
*/
export function confidenceFromEvidence(evidence: readonly ResolutionEvidence[]): number {
let sum = 0;

View file

@ -1,188 +0,0 @@
/**
* Shadow-mode aggregation — per-language parity %, per-evidence-kind
* breakdown of divergences. Consumed by the parity dashboard (RING2-PKG-5).
*
* Pure functions; no I/O. The harness persists per-run JSON; the dashboard
* reads `.gitnexus/shadow-parity/latest.json` and renders.
*
* Related types — `ShadowAgreement`, `ShadowCallsite`, `ShadowDiff` — are
* defined alongside `diffResolutions` in `./diff.ts` and re-exported
* through the top-level `gitnexus-shared` barrel. Consumers import all
* three from `gitnexus-shared`, not from this module.
*
* Part of RFC #909 Ring 2 SHARED — #918.
*/
import type { SupportedLanguages } from '../../languages.js';
import type { ResolutionEvidence } from '../types.js';
import type { ShadowAgreement, ShadowDiff } from './diff.js';
// ─── Aggregated report shape ────────────────────────────────────────────────
export interface LanguageParityRow {
readonly language: SupportedLanguages;
readonly totalCalls: number;
readonly bothAgree: number;
readonly onlyLegacy: number;
readonly onlyNew: number;
readonly bothDisagree: number;
readonly bothEmpty: number;
/**
* Fraction in [0, 1]. Numerator = `bothAgree`; denominator = "calls where
* at least one side resolved" = `totalCalls - bothEmpty`.
*
* When the denominator is 0 (all calls for this language were
* `both-empty`), returns 0. Callers rendering the dashboard should treat
* a 0 parity alongside `totalCalls === bothEmpty` as "no signal" rather
* than "total disagreement".
*/
readonly parity: number;
/**
* Divergence signals broken down by `ResolutionEvidence.kind`. Sourced
* from `ShadowDiff.evidenceDelta` on non-agreeing rows only — `both-agree`
* and `both-empty` do not contribute.
*/
readonly evidenceBreakdown: ReadonlyMap<ResolutionEvidence['kind'], number>;
}
export interface ShadowParityReport {
readonly generatedAt: string; // ISO 8601
readonly perLanguage: readonly LanguageParityRow[];
readonly overall: Omit<LanguageParityRow, 'language' | 'evidenceBreakdown'>;
}
// ─── Public API ─────────────────────────────────────────────────────────────
/**
* Aggregate a stream of `ShadowDiff` records into a `ShadowParityReport`,
* bucketed by language. Pure function.
*
* - `perLanguage` rows are sorted alphabetically by `SupportedLanguages`
* value for stable JSON output (the dashboard reads
* `.gitnexus/shadow-parity/latest.json` and diffing snapshots is useful).
* - `overall` is the column-wise sum across languages.
* - `generatedAt` is injected via the `now` parameter so tests stay
* deterministic; production callers let it default to `new Date()`.
*/
export function aggregateDiffs(
diffs: readonly { readonly language: SupportedLanguages; readonly diff: ShadowDiff }[],
now: Date = new Date(),
): ShadowParityReport {
const perLanguageMap = new Map<SupportedLanguages, MutableCounts>();
for (const { language, diff } of diffs) {
let counts = perLanguageMap.get(language);
if (!counts) {
counts = makeEmptyCounts();
perLanguageMap.set(language, counts);
}
tallyDiff(counts, diff);
}
const perLanguage: LanguageParityRow[] = Array.from(perLanguageMap.entries())
.map(([language, counts]) => buildRow(language, counts))
.sort((a, b) => a.language.localeCompare(b.language));
const overall = buildOverallRow(perLanguage);
return {
generatedAt: now.toISOString(),
perLanguage,
overall,
};
}
// ─── Internal helpers ───────────────────────────────────────────────────────
interface MutableCounts {
totalCalls: number;
bothAgree: number;
onlyLegacy: number;
onlyNew: number;
bothDisagree: number;
bothEmpty: number;
evidenceBreakdown: Map<ResolutionEvidence['kind'], number>;
}
function makeEmptyCounts(): MutableCounts {
return {
totalCalls: 0,
bothAgree: 0,
onlyLegacy: 0,
onlyNew: 0,
bothDisagree: 0,
bothEmpty: 0,
evidenceBreakdown: new Map(),
};
}
function tallyDiff(counts: MutableCounts, diff: ShadowDiff): void {
counts.totalCalls += 1;
incrementAgreement(counts, diff.agreement);
if (diff.agreement === 'both-agree' || diff.agreement === 'both-empty') return;
for (const ev of diff.evidenceDelta) {
counts.evidenceBreakdown.set(ev.kind, (counts.evidenceBreakdown.get(ev.kind) ?? 0) + 1);
}
}
function incrementAgreement(counts: MutableCounts, agreement: ShadowAgreement): void {
switch (agreement) {
case 'both-agree':
counts.bothAgree += 1;
return;
case 'only-legacy':
counts.onlyLegacy += 1;
return;
case 'only-new':
counts.onlyNew += 1;
return;
case 'both-disagree':
counts.bothDisagree += 1;
return;
case 'both-empty':
counts.bothEmpty += 1;
return;
}
}
function buildRow(language: SupportedLanguages, counts: MutableCounts): LanguageParityRow {
const resolved = counts.totalCalls - counts.bothEmpty;
const parity = resolved > 0 ? counts.bothAgree / resolved : 0;
return {
language,
totalCalls: counts.totalCalls,
bothAgree: counts.bothAgree,
onlyLegacy: counts.onlyLegacy,
onlyNew: counts.onlyNew,
bothDisagree: counts.bothDisagree,
bothEmpty: counts.bothEmpty,
parity,
// Freeze via `new Map` on a sorted-kind copy so downstream consumers
// can't mutate the aggregator's internal state.
evidenceBreakdown: new Map(
Array.from(counts.evidenceBreakdown.entries()).sort(([a], [b]) => a.localeCompare(b)),
),
};
}
function buildOverallRow(
perLanguage: readonly LanguageParityRow[],
): Omit<LanguageParityRow, 'language' | 'evidenceBreakdown'> {
let totalCalls = 0;
let bothAgree = 0;
let onlyLegacy = 0;
let onlyNew = 0;
let bothDisagree = 0;
let bothEmpty = 0;
for (const row of perLanguage) {
totalCalls += row.totalCalls;
bothAgree += row.bothAgree;
onlyLegacy += row.onlyLegacy;
onlyNew += row.onlyNew;
bothDisagree += row.bothDisagree;
bothEmpty += row.bothEmpty;
}
const resolved = totalCalls - bothEmpty;
const parity = resolved > 0 ? bothAgree / resolved : 0;
return { totalCalls, bothAgree, onlyLegacy, onlyNew, bothDisagree, bothEmpty, parity };
}

View file

@ -1,126 +0,0 @@
/**
* Shadow-mode diff logic — RFC §6.3.
*
* Pure comparison logic for shadow mode. Takes two `Resolution[]` (legacy
* DAG result + new scope-based registry result) and produces a structured
* diff record for the parity dashboard.
*
* Consumed by the Ring 2 PKG shadow harness (#923), which dual-runs each
* call through legacy + new paths, diffs results, and persists per-run JSON
* for the parity dashboard.
*
* Part of RFC #909 Ring 2 SHARED — #918.
*/
import type { Resolution, ResolutionEvidence } from '../types.js';
// ─── Diff record shape ──────────────────────────────────────────────────────
export type ShadowAgreement =
| 'both-agree' // top match identical (same DefId)
| 'only-legacy' // legacy resolved; new did not
| 'only-new' // new resolved; legacy did not
| 'both-disagree' // both resolved, but to different targets
| 'both-empty'; // both returned empty
export interface ShadowDiff {
readonly callsite: ShadowCallsite;
readonly legacy: Resolution | null;
readonly newResult: Resolution | null;
readonly agreement: ShadowAgreement;
/**
* Symmetric difference of the two top resolutions' `evidence` arrays,
* keyed on `ResolutionEvidence.kind`.
*
* - For `'both-agree'` and `'both-empty'` agreements, always empty.
* - For `'both-disagree'`, contains evidence kinds present on exactly one
* side (not in both).
* - For `'only-legacy'`, contains all of legacy's top evidence.
* - For `'only-new'`, contains all of new's top evidence.
*/
readonly evidenceDelta: readonly ResolutionEvidence[];
}
export interface ShadowCallsite {
readonly filePath: string;
readonly line: number;
readonly col: number;
readonly calledName: string;
}
// ─── Public API ─────────────────────────────────────────────────────────────
/**
* Compare two `Resolution[]` arrays (top matches at `[0]`) and produce a
* `ShadowDiff`. Pure function.
*
* Agreement rules:
* - both arrays empty → `'both-empty'`, `evidenceDelta: []`
* - legacy empty, new non-empty → `'only-new'`, `evidenceDelta` = new's top evidence
* - legacy non-empty, new empty → `'only-legacy'`, `evidenceDelta` = legacy's top evidence
* - both non-empty, same top `def.nodeId` → `'both-agree'`, `evidenceDelta: []`
* - both non-empty, different top `def.nodeId` → `'both-disagree'`,
* `evidenceDelta` = symmetric difference by `ResolutionEvidence.kind`
* (first occurrence of a kind-only-on-legacy then kind-only-on-new; order
* preserved from input arrays)
*
* Evidence-delta rationale: callers aggregating divergences want to know
* which signal kinds explain a disagreement. Keying on `kind` (not full
* equality over `weight`/`note`) avoids spurious deltas when the same
* signal fires with slightly different calibration weights on each side.
*/
export function diffResolutions(
callsite: ShadowCallsite,
legacy: readonly Resolution[],
newResult: readonly Resolution[],
): ShadowDiff {
const legacyTop: Resolution | null = legacy.length > 0 ? legacy[0] : null;
const newTop: Resolution | null = newResult.length > 0 ? newResult[0] : null;
const agreement: ShadowAgreement = (() => {
if (legacyTop === null && newTop === null) return 'both-empty';
if (legacyTop === null) return 'only-new';
if (newTop === null) return 'only-legacy';
return legacyTop.def.nodeId === newTop.def.nodeId ? 'both-agree' : 'both-disagree';
})();
const evidenceDelta = computeEvidenceDelta(legacyTop, newTop, agreement);
return {
callsite,
legacy: legacyTop,
newResult: newTop,
agreement,
evidenceDelta,
};
}
// ─── Internal helpers ───────────────────────────────────────────────────────
/**
* Symmetric difference of two evidence arrays, keyed on
* `ResolutionEvidence.kind`. Preserves input order: legacy-only signals
* first (in legacy's original order), then new-only signals (in new's order).
*
* For `'both-agree'` / `'both-empty'` the delta is empty by contract. For
* `'only-legacy'` / `'only-new'` one side's evidence is the delta (nothing to
* subtract against).
*/
function computeEvidenceDelta(
legacy: Resolution | null,
newResult: Resolution | null,
agreement: ShadowAgreement,
): readonly ResolutionEvidence[] {
if (agreement === 'both-agree' || agreement === 'both-empty') return [];
if (agreement === 'only-legacy') return legacy!.evidence;
if (agreement === 'only-new') return newResult!.evidence;
// both-disagree: symmetric difference keyed on `kind`
const legacyKinds = new Set(legacy!.evidence.map((e) => e.kind));
const newKinds = new Set(newResult!.evidence.map((e) => e.kind));
const onlyInLegacy = legacy!.evidence.filter((e) => !newKinds.has(e.kind));
const onlyInNew = newResult!.evidence.filter((e) => !legacyKinds.has(e.kind));
return [...onlyInLegacy, ...onlyInNew];
}

View file

@ -1,291 +0,0 @@
<!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>

View file

@ -68,10 +68,9 @@
* `using static X = Y.Z;`, attributes, and preprocessor-gated
* declarations are all recognized correctly.
*
* Shadow-harness corpus parity is the authoritative signal for which
* of these matter in practice. The CI parity gate blocks any PR that
* regresses either the legacy or registry-primary run of
* `test/integration/resolvers/csharp.test.ts`.
* The `test/integration/resolvers/csharp.test.ts` resolver suite is the
* authoritative signal for which of these matter in practice; it runs in
* the standard CI test workflow, so a regression blocks the merge.
*/
export { emitCsharpScopeCaptures } from './captures.js';

View file

@ -54,10 +54,9 @@
* 6. **Intersection types in parameters** — `T&U $param` takes the first
* named part (`T`). This matches the legacy type-extractor's behavior.
*
* Shadow-harness corpus parity is the authoritative signal for which of
* these matter in practice. The CI parity gate blocks any PR that regresses
* either the legacy or registry-primary run of
* `test/integration/resolvers/php.test.ts`.
* The `test/integration/resolvers/php.test.ts` resolver suite is the
* authoritative signal for which of these matter in practice; it runs in
* the standard CI test workflow, so a regression blocks the merge.
*/
export { emitPhpScopeCaptures } from './captures.js';

View file

@ -66,10 +66,9 @@
* site where the enclosing class can't be statically determined
* is left unresolved.
*
* Shadow-harness corpus parity is the authoritative signal for which
* of these matter in practice. The CI parity gate blocks any PR that
* regresses either the legacy or registry-primary run of
* `test/integration/resolvers/python.test.ts`.
* The `test/integration/resolvers/python.test.ts` resolver suite is the
* authoritative signal for which of these matter in practice; it runs in
* the standard CI test workflow, so a regression blocks the merge.
*/
export { emitPythonScopeCaptures } from './captures.js';

View file

@ -80,10 +80,9 @@
* identifiers are narrowed (`user instanceof User`). Member paths
* such as `user.address instanceof Address` remain unresolved.
*
* Shadow-harness corpus parity on `test/integration/resolvers/
* typescript.test.ts` is the authoritative signal for which of these
* matter in practice. The CI parity gate blocks any PR that regresses
* either the legacy or registry-primary run.
* The `test/integration/resolvers/typescript.test.ts` resolver suite is
* the authoritative signal for which of these matter in practice; it runs
* in the standard CI test workflow, so a regression blocks the merge.
*/
export { emitTsScopeCaptures } from './captures.js';

View file

@ -1,234 +0,0 @@
/**
* Unit tests for `aggregateDiffs` (RFC #909 Ring 2 SHARED #918).
*
* Covers bucketing by language, parity math (incl. zero-resolved edge),
* evidence-kind breakdown, and stable sort order on the output rows.
*/
import { describe, it, expect } from 'vitest';
import {
aggregateDiffs,
SupportedLanguages,
type LanguageParityRow,
type ResolutionEvidence,
type ShadowAgreement,
type ShadowDiff,
} from 'gitnexus-shared';
// ─── Fixtures ───────────────────────────────────────────────────────────────
const FIXED_NOW = new Date('2026-04-18T12:00:00.000Z');
const makeDiff = (
agreement: ShadowAgreement,
evidenceKinds: readonly ResolutionEvidence['kind'][] = [],
): ShadowDiff => ({
callsite: { filePath: 'src/x.ts', line: 1, col: 0, calledName: 'foo' },
legacy: null,
newResult: null,
agreement,
evidenceDelta: evidenceKinds.map((kind) => ({ kind, weight: 0.3 })),
});
const entry = (language: SupportedLanguages, diff: ShadowDiff) => ({ language, diff });
const findRow = (
rows: readonly LanguageParityRow[],
language: SupportedLanguages,
): LanguageParityRow => {
const row = rows.find((r) => r.language === language);
if (!row) throw new Error(`no row for ${language}`);
return row;
};
// ─── Empty input ────────────────────────────────────────────────────────────
describe('aggregateDiffs — empty input', () => {
it('returns empty perLanguage, zeroed overall, generatedAt populated', () => {
const report = aggregateDiffs([], FIXED_NOW);
expect(report.perLanguage).toEqual([]);
expect(report.overall).toEqual({
totalCalls: 0,
bothAgree: 0,
onlyLegacy: 0,
onlyNew: 0,
bothDisagree: 0,
bothEmpty: 0,
parity: 0,
});
expect(report.generatedAt).toBe('2026-04-18T12:00:00.000Z');
});
});
// ─── Single language, single outcome ────────────────────────────────────────
describe('aggregateDiffs — single language', () => {
it('all both-agree → parity = 1.0', () => {
const diffs = [
entry(SupportedLanguages.Python, makeDiff('both-agree')),
entry(SupportedLanguages.Python, makeDiff('both-agree')),
entry(SupportedLanguages.Python, makeDiff('both-agree')),
];
const report = aggregateDiffs(diffs, FIXED_NOW);
expect(report.perLanguage).toHaveLength(1);
const row = findRow(report.perLanguage, SupportedLanguages.Python);
expect(row).toMatchObject({
language: SupportedLanguages.Python,
totalCalls: 3,
bothAgree: 3,
onlyLegacy: 0,
onlyNew: 0,
bothDisagree: 0,
bothEmpty: 0,
parity: 1,
});
});
it('mixed outcomes → parity excludes both-empty from denominator', () => {
const diffs = [
entry(SupportedLanguages.TypeScript, makeDiff('both-agree')),
entry(SupportedLanguages.TypeScript, makeDiff('both-agree')),
entry(SupportedLanguages.TypeScript, makeDiff('only-legacy', ['global-name'])),
entry(SupportedLanguages.TypeScript, makeDiff('only-new', ['local'])),
entry(SupportedLanguages.TypeScript, makeDiff('both-disagree', ['import'])),
entry(SupportedLanguages.TypeScript, makeDiff('both-empty')),
entry(SupportedLanguages.TypeScript, makeDiff('both-empty')),
];
const report = aggregateDiffs(diffs, FIXED_NOW);
const row = findRow(report.perLanguage, SupportedLanguages.TypeScript);
expect(row.totalCalls).toBe(7);
expect(row.bothAgree).toBe(2);
expect(row.onlyLegacy).toBe(1);
expect(row.onlyNew).toBe(1);
expect(row.bothDisagree).toBe(1);
expect(row.bothEmpty).toBe(2);
// parity = bothAgree / (totalCalls - bothEmpty) = 2 / (7 - 2) = 0.4
expect(row.parity).toBeCloseTo(0.4, 10);
});
it('all both-empty → parity = 0 (not NaN)', () => {
const diffs = [
entry(SupportedLanguages.Java, makeDiff('both-empty')),
entry(SupportedLanguages.Java, makeDiff('both-empty')),
];
const report = aggregateDiffs(diffs, FIXED_NOW);
const row = findRow(report.perLanguage, SupportedLanguages.Java);
expect(row.totalCalls).toBe(2);
expect(row.bothEmpty).toBe(2);
expect(row.parity).toBe(0);
expect(Number.isNaN(row.parity)).toBe(false);
});
});
// ─── Multi-language ─────────────────────────────────────────────────────────
describe('aggregateDiffs — multiple languages', () => {
it('buckets rows by language and sums overall column-wise', () => {
const diffs = [
entry(SupportedLanguages.Python, makeDiff('both-agree')),
entry(SupportedLanguages.Python, makeDiff('both-disagree', ['local'])),
entry(SupportedLanguages.Ruby, makeDiff('both-agree')),
entry(SupportedLanguages.Ruby, makeDiff('both-agree')),
entry(SupportedLanguages.Ruby, makeDiff('only-new', ['type-binding'])),
];
const report = aggregateDiffs(diffs, FIXED_NOW);
expect(report.perLanguage).toHaveLength(2);
const python = findRow(report.perLanguage, SupportedLanguages.Python);
expect(python.totalCalls).toBe(2);
expect(python.bothAgree).toBe(1);
expect(python.bothDisagree).toBe(1);
expect(python.parity).toBe(0.5);
const ruby = findRow(report.perLanguage, SupportedLanguages.Ruby);
expect(ruby.totalCalls).toBe(3);
expect(ruby.bothAgree).toBe(2);
expect(ruby.onlyNew).toBe(1);
expect(ruby.parity).toBeCloseTo(2 / 3, 10);
expect(report.overall).toEqual({
totalCalls: 5,
bothAgree: 3,
onlyLegacy: 0,
onlyNew: 1,
bothDisagree: 1,
bothEmpty: 0,
parity: 3 / 5,
});
});
it('perLanguage rows are sorted alphabetically by language value for stable output', () => {
const diffs = [
entry(SupportedLanguages.TypeScript, makeDiff('both-agree')),
entry(SupportedLanguages.C, makeDiff('both-agree')),
entry(SupportedLanguages.Python, makeDiff('both-agree')),
entry(SupportedLanguages.Java, makeDiff('both-agree')),
];
const report = aggregateDiffs(diffs, FIXED_NOW);
const ordered = report.perLanguage.map((r) => r.language);
// Alphabetical by enum VALUE: 'c' < 'java' < 'python' < 'typescript'
expect(ordered).toEqual([
SupportedLanguages.C,
SupportedLanguages.Java,
SupportedLanguages.Python,
SupportedLanguages.TypeScript,
]);
});
});
// ─── Evidence breakdown ─────────────────────────────────────────────────────
describe('aggregateDiffs — evidence breakdown', () => {
it('counts divergence evidence kinds across non-agreeing rows only', () => {
const diffs = [
entry(SupportedLanguages.Go, makeDiff('both-disagree', ['import', 'owner-match'])),
entry(SupportedLanguages.Go, makeDiff('only-legacy', ['import', 'global-name'])),
entry(SupportedLanguages.Go, makeDiff('only-new', ['local'])),
// both-agree contributes 0 to evidence breakdown regardless of any attached evidence
entry(SupportedLanguages.Go, makeDiff('both-agree', ['import'])),
// both-empty also contributes 0
entry(SupportedLanguages.Go, makeDiff('both-empty')),
];
const report = aggregateDiffs(diffs, FIXED_NOW);
const row = findRow(report.perLanguage, SupportedLanguages.Go);
expect(Array.from(row.evidenceBreakdown.entries())).toEqual([
['global-name', 1],
['import', 2],
['local', 1],
['owner-match', 1],
]);
});
it('emits empty evidenceBreakdown when all calls agree or are empty', () => {
const diffs = [
entry(SupportedLanguages.Rust, makeDiff('both-agree')),
entry(SupportedLanguages.Rust, makeDiff('both-empty')),
];
const report = aggregateDiffs(diffs, FIXED_NOW);
const row = findRow(report.perLanguage, SupportedLanguages.Rust);
expect(row.evidenceBreakdown.size).toBe(0);
});
});
// ─── Determinism ────────────────────────────────────────────────────────────
describe('aggregateDiffs — determinism', () => {
it('injected `now` is used verbatim for generatedAt', () => {
const t = new Date('2030-01-01T00:00:00.000Z');
const report = aggregateDiffs([], t);
expect(report.generatedAt).toBe('2030-01-01T00:00:00.000Z');
});
it('same input produces byte-identical JSON (stable keys + sort)', () => {
const diffs = [
entry(SupportedLanguages.Python, makeDiff('both-disagree', ['local', 'import'])),
entry(SupportedLanguages.Java, makeDiff('both-agree')),
];
const a = aggregateDiffs(diffs, FIXED_NOW);
const b = aggregateDiffs(diffs, FIXED_NOW);
// Round-trip through JSON to drop Map identity and force structural comparison.
const toJson = (r: typeof a): string =>
JSON.stringify(r, (_key, v: unknown) => (v instanceof Map ? Object.fromEntries(v) : v));
expect(toJson(a)).toBe(toJson(b));
});
});

View file

@ -1,192 +0,0 @@
/**
* Unit tests for `diffResolutions` (RFC #909 Ring 2 SHARED #918).
*
* Pins the 5 `ShadowAgreement` outcomes and the symmetric-by-kind evidence-
* delta contract. Inputs are pure data fixtures — no real pipeline state.
*/
import { describe, it, expect } from 'vitest';
import {
diffResolutions,
type Resolution,
type ResolutionEvidence,
type ShadowCallsite,
type SymbolDefinition,
} from 'gitnexus-shared';
// ─── Fixtures ───────────────────────────────────────────────────────────────
const callsite: ShadowCallsite = {
filePath: 'src/app.ts',
line: 42,
col: 8,
calledName: 'save',
};
const makeDef = (nodeId: string): SymbolDefinition => ({
nodeId,
filePath: 'src/models.ts',
type: 'Method',
});
const makeEvidence = (kind: ResolutionEvidence['kind'], weight = 0.5): ResolutionEvidence => ({
kind,
weight,
});
const makeResolution = (
nodeId: string,
evidenceKinds: readonly ResolutionEvidence['kind'][],
): Resolution => ({
def: makeDef(nodeId),
confidence: Math.min(1, evidenceKinds.length * 0.3),
evidence: evidenceKinds.map((k) => makeEvidence(k)),
});
// ─── Agreement outcomes ─────────────────────────────────────────────────────
describe('diffResolutions — agreement outcomes', () => {
it("both arrays empty → 'both-empty' with no evidence delta", () => {
const result = diffResolutions(callsite, [], []);
expect(result.agreement).toBe('both-empty');
expect(result.evidenceDelta).toEqual([]);
expect(result.legacy).toBeNull();
expect(result.newResult).toBeNull();
});
it("identical top DefIds → 'both-agree' with empty evidence delta", () => {
const legacy = [makeResolution('def:User.save', ['local', 'owner-match'])];
const next = [makeResolution('def:User.save', ['local', 'kind-match'])];
const result = diffResolutions(callsite, legacy, next);
expect(result.agreement).toBe('both-agree');
expect(result.evidenceDelta).toEqual([]);
expect(result.legacy).toBe(legacy[0]);
expect(result.newResult).toBe(next[0]);
});
it("legacy empty, new non-empty → 'only-new' with new's evidence as delta", () => {
const next = [makeResolution('def:User.save', ['local', 'owner-match'])];
const result = diffResolutions(callsite, [], next);
expect(result.agreement).toBe('only-new');
expect(result.evidenceDelta).toEqual(next[0].evidence);
expect(result.legacy).toBeNull();
expect(result.newResult).toBe(next[0]);
});
it("legacy non-empty, new empty → 'only-legacy' with legacy's evidence as delta", () => {
const legacy = [makeResolution('def:User.save', ['global-name'])];
const result = diffResolutions(callsite, legacy, []);
expect(result.agreement).toBe('only-legacy');
expect(result.evidenceDelta).toEqual(legacy[0].evidence);
expect(result.legacy).toBe(legacy[0]);
expect(result.newResult).toBeNull();
});
it("different top DefIds → 'both-disagree'", () => {
const legacy = [makeResolution('def:ModelA.save', ['global-name'])];
const next = [makeResolution('def:ModelB.save', ['local'])];
const result = diffResolutions(callsite, legacy, next);
expect(result.agreement).toBe('both-disagree');
expect(result.legacy).toBe(legacy[0]);
expect(result.newResult).toBe(next[0]);
});
});
// ─── Evidence delta — symmetric difference by `kind` ────────────────────────
describe('diffResolutions — evidence delta (symmetric-by-kind)', () => {
it("'both-disagree' with disjoint evidence → delta contains both sides' kinds", () => {
const legacy = [makeResolution('def:A', ['global-name'])];
const next = [makeResolution('def:B', ['local', 'owner-match'])];
const result = diffResolutions(callsite, legacy, next);
expect(result.evidenceDelta.map((e) => e.kind)).toEqual([
'global-name',
'local',
'owner-match',
]);
});
it("'both-disagree' with overlapping kinds → overlapping kinds removed from delta", () => {
const legacy = [makeResolution('def:A', ['local', 'scope-chain', 'global-name'])];
const next = [makeResolution('def:B', ['local', 'import', 'owner-match'])];
const result = diffResolutions(callsite, legacy, next);
// 'local' is on both sides → dropped
// Remaining: legacy-only ['scope-chain', 'global-name'], then new-only ['import', 'owner-match']
expect(result.evidenceDelta.map((e) => e.kind)).toEqual([
'scope-chain',
'global-name',
'import',
'owner-match',
]);
});
it("'both-disagree' with fully overlapping kinds → empty evidence delta", () => {
const legacy = [makeResolution('def:A', ['local', 'owner-match'])];
const next = [makeResolution('def:B', ['owner-match', 'local'])];
const result = diffResolutions(callsite, legacy, next);
// Same kind set, different order → symmetric difference is empty
expect(result.evidenceDelta).toEqual([]);
expect(result.agreement).toBe('both-disagree'); // agreement still disagrees because nodeIds differ
});
it('differing weights on the same kind → NOT a delta (keyed on kind only)', () => {
const legacy = [
{
def: makeDef('def:A'),
confidence: 0.9,
evidence: [{ kind: 'local' as const, weight: 0.55 }],
},
];
const next = [
{
def: makeDef('def:B'),
confidence: 0.1,
evidence: [{ kind: 'local' as const, weight: 0.25 }],
},
];
const result = diffResolutions(callsite, legacy, next);
expect(result.agreement).toBe('both-disagree');
expect(result.evidenceDelta).toEqual([]);
});
});
// ─── Metadata + ordering ────────────────────────────────────────────────────
describe('diffResolutions — metadata + ordering', () => {
it('ignores resolutions beyond index 0 (top match only)', () => {
const legacy = [
makeResolution('def:User.save', ['local']),
makeResolution('def:other', ['global-name']),
];
const next = [
makeResolution('def:User.save', ['local']),
// The 2nd entry is here to verify index-0 isolation — the only kind
// requirement is that it be a valid `ResolutionEvidence.kind` so the
// fixture is type-correct. `'global-name'` is a real kind that
// `diffResolutions` never treats specially.
makeResolution('def:yet-another', ['global-name']),
];
const result = diffResolutions(callsite, legacy, next);
expect(result.agreement).toBe('both-agree');
});
it('preserves callsite verbatim', () => {
const result = diffResolutions(callsite, [], []);
expect(result.callsite).toBe(callsite);
});
it("'both-disagree' delta order: legacy-only first (input order), then new-only", () => {
const legacy = [makeResolution('def:A', ['owner-match', 'scope-chain', 'kind-match'])];
const next = [makeResolution('def:B', ['import', 'owner-match', 'arity-match'])];
const result = diffResolutions(callsite, legacy, next);
// 'owner-match' overlaps → dropped
// legacy-only in original order: ['scope-chain', 'kind-match']
// then new-only in original order: ['import', 'arity-match']
expect(result.evidenceDelta.map((e) => e.kind)).toEqual([
'scope-chain',
'kind-match',
'import',
'arity-match',
]);
});
});