diff --git a/gitnexus-shared/src/index.ts b/gitnexus-shared/src/index.ts index d732d2633f..1b1d9c9a9f 100644 --- a/gitnexus-shared/src/index.ts +++ b/gitnexus-shared/src/index.ts @@ -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'; diff --git a/gitnexus-shared/src/scope-resolution/module-scope-index.ts b/gitnexus-shared/src/scope-resolution/module-scope-index.ts index a57c02d27b..bb1b4bab2f 100644 --- a/gitnexus-shared/src/scope-resolution/module-scope-index.ts +++ b/gitnexus-shared/src/scope-resolution/module-scope-index.ts @@ -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'; diff --git a/gitnexus-shared/src/scope-resolution/registries/evidence.ts b/gitnexus-shared/src/scope-resolution/registries/evidence.ts index cabeb6a95f..855d1f15ab 100644 --- a/gitnexus-shared/src/scope-resolution/registries/evidence.ts +++ b/gitnexus-shared/src/scope-resolution/registries/evidence.ts @@ -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; diff --git a/gitnexus-shared/src/scope-resolution/shadow/aggregate.ts b/gitnexus-shared/src/scope-resolution/shadow/aggregate.ts deleted file mode 100644 index 27c24ff926..0000000000 --- a/gitnexus-shared/src/scope-resolution/shadow/aggregate.ts +++ /dev/null @@ -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; -} - -export interface ShadowParityReport { - readonly generatedAt: string; // ISO 8601 - readonly perLanguage: readonly LanguageParityRow[]; - readonly overall: Omit; -} - -// ─── 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(); - - 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; -} - -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 { - 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 }; -} diff --git a/gitnexus-shared/src/scope-resolution/shadow/diff.ts b/gitnexus-shared/src/scope-resolution/shadow/diff.ts deleted file mode 100644 index a1c8755c6c..0000000000 --- a/gitnexus-shared/src/scope-resolution/shadow/diff.ts +++ /dev/null @@ -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]; -} diff --git a/gitnexus/shadow-parity-dashboard/index.html b/gitnexus/shadow-parity-dashboard/index.html deleted file mode 100644 index 104d7b0260..0000000000 --- a/gitnexus/shadow-parity-dashboard/index.html +++ /dev/null @@ -1,291 +0,0 @@ - - - - - - GitNexus — Shadow Parity Dashboard - - - - -
-

Shadow Parity — RFC #909

-
loading latest.json
-
- - - - - - - - - - - - - - -
LanguageTotalAgreeOnly legacyOnly newDisagreeBoth emptyParity
- -
- - - diff --git a/gitnexus/src/core/ingestion/languages/csharp/index.ts b/gitnexus/src/core/ingestion/languages/csharp/index.ts index 9f06ce87d2..5e8fa99ea0 100644 --- a/gitnexus/src/core/ingestion/languages/csharp/index.ts +++ b/gitnexus/src/core/ingestion/languages/csharp/index.ts @@ -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'; diff --git a/gitnexus/src/core/ingestion/languages/php/index.ts b/gitnexus/src/core/ingestion/languages/php/index.ts index 9b549bc3d5..de7b6345b9 100644 --- a/gitnexus/src/core/ingestion/languages/php/index.ts +++ b/gitnexus/src/core/ingestion/languages/php/index.ts @@ -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'; diff --git a/gitnexus/src/core/ingestion/languages/python/index.ts b/gitnexus/src/core/ingestion/languages/python/index.ts index 166c3a7aee..dc7e84f647 100644 --- a/gitnexus/src/core/ingestion/languages/python/index.ts +++ b/gitnexus/src/core/ingestion/languages/python/index.ts @@ -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'; diff --git a/gitnexus/src/core/ingestion/languages/typescript/index.ts b/gitnexus/src/core/ingestion/languages/typescript/index.ts index cb4bd40234..120566f488 100644 --- a/gitnexus/src/core/ingestion/languages/typescript/index.ts +++ b/gitnexus/src/core/ingestion/languages/typescript/index.ts @@ -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'; diff --git a/gitnexus/test/unit/shadow/aggregate.test.ts b/gitnexus/test/unit/shadow/aggregate.test.ts deleted file mode 100644 index 00fb446527..0000000000 --- a/gitnexus/test/unit/shadow/aggregate.test.ts +++ /dev/null @@ -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)); - }); -}); diff --git a/gitnexus/test/unit/shadow/diff.test.ts b/gitnexus/test/unit/shadow/diff.test.ts deleted file mode 100644 index e9baa606fa..0000000000 --- a/gitnexus/test/unit/shadow/diff.test.ts +++ /dev/null @@ -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', - ]); - }); -});