Replaces the scaffold stubs with working pure-logic implementations plus
unit-test coverage for both functions. Unblocks Ring 2 PKG #923 (shadow
harness) to consume a concrete library instead of throwing scaffolds.
gitnexus-shared/src/scope-resolution/shadow/diff.ts
`diffResolutions(callsite, legacy, newResult): ShadowDiff`
- [0] on each side is the top match
- both empty → 'both-empty', delta []
- legacy empty only → 'only-new', delta = new top evidence
- new empty only → 'only-legacy', delta = legacy top evidence
- same top nodeId → 'both-agree', delta []
- different nodeIds → 'both-disagree',
delta = symmetric difference of evidence kinds
(legacy-only first in input order, then new-only)
Evidence identity is `ResolutionEvidence.kind` — weight/note differences
for the same kind do NOT produce delta entries. Rationale: the aggregator
wants to know which *signals* explain a disagreement, not fluctuations
in calibration values.
gitnexus-shared/src/scope-resolution/shadow/aggregate.ts
`aggregateDiffs(diffs, now?): ShadowParityReport`
- buckets by `SupportedLanguages`
- tallies agreements, evidence-breakdown (divergences only — agree and
empty rows do not contribute)
- parity = bothAgree / (totalCalls - bothEmpty), yields 0 (not NaN)
when the denominator is 0
- perLanguage sorted alphabetically by enum value for stable output
- evidenceBreakdown internally sorted by kind for stable output
- overall = column-wise sum across languages
- `now` parameter makes generatedAt deterministic in tests
gitnexus-shared/src/index.ts
Re-exports the full shadow API: diffResolutions, aggregateDiffs, and all
their types (ShadowAgreement, ShadowCallsite, ShadowDiff,
LanguageParityRow, ShadowParityReport).
gitnexus/test/unit/shadow/diff.test.ts (13 tests)
- 5 agreement outcomes
- symmetric-by-kind evidence delta (disjoint, overlapping, fully-overlapping)
- weight-only differences produce no delta
- top-match only (ignores indices beyond [0])
- callsite passthrough
- delta ordering (legacy-only first, input order preserved)
gitnexus/test/unit/shadow/aggregate.test.ts (9 tests)
- empty input
- single language, all agree / mixed / all empty
- multi-language bucketing + overall sum
- alphabetical language sort
- evidence breakdown scope
- determinism via injected `now` + JSON round-trip identity
Verification:
- gitnexus-shared + gitnexus build clean (tsc + scripts/build.js)
- test/unit/shadow: 22/22 pass
- test/unit/model + test/unit/shadow combined: 106/106 pass
- No runtime behavior changes (shadow is invoked by #923, not yet wired)
Stacked on main (af1d278a). Depends on types from #910 (merged).
Unblocks: #923 (Ring 2 PKG — shadow harness wiring) — concrete library
to consume instead of scaffold stubs.
Plan: docs/plans/2026-04-18-001-refactor-911-senior-hooks-redesign-plan.md
is about #911; #918's scope is the scaffold+fill-in described in the PR
description of #951.
127 lines
5.1 KiB
TypeScript
127 lines
5.1 KiB
TypeScript
/**
|
|
* 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];
|
|
}
|