/**
 * Benchmark Regression Guard
 *
 * Reads the embedded JSON data from each self-generated benchmark report
 * (build, query, incremental) and asserts that the latest entry for each
 * engine has not regressed beyond the allowed threshold compared to the
 * previous release.
 *
 * This test runs in CI on every PR — it catches the kind of silent 100%+
 * regressions that slipped through in v3.0.1–3.4.0.
 */

import fs from 'node:fs';
import path from 'node:path';
import { describe, expect, test } from 'vitest';

// ── Configuration ────────────────────────────────────────────────────────

/**
 * Maximum allowed regression (as a fraction, e.g. 0.25 = 25%).
 *
 * Why 25%: The report script warns at 15%, but timing benchmarks have
 * natural variance from CI runner load, GC pauses, etc. 25% filters
 * noise while still catching the catastrophic regressions we've seen
 * historically (100%–220%). Tune this down as benchmarks stabilize.
 *
 * Genuinely high-variance sub-30ms metrics get a wider tolerance via
 * `NOISY_METRICS` below — see that set's docstring for rationale.
 */
const REGRESSION_THRESHOLD = 0.25;

/**
 * Wider regression threshold applied to metrics in NOISY_METRICS.
 *
 * Sub-30ms timing metrics (no-op rebuild, 1-file rebuild, fnDeps depth 1)
 * routinely jitter ±10ms from CI runner load, GC pauses, and OS scheduling,
 * which translates to ±50%+ on small absolute numbers. The MIN_ABSOLUTE_DELTA
 * floor (10ms) filters trivial noise but cannot distinguish a 10–14ms
 * "real" jitter event from a regression on these specific metrics.
 *
 * Keeping the global threshold at 25% means a regression in the 30–100ms
 * range is still caught (e.g. 50ms→63ms = +26%, flagged), while sub-30ms
 * metrics in this set get the wider 50% allowance.
 */
const NOISY_METRIC_THRESHOLD = 0.5;

/**
 * Metric labels treated as high-variance and given the NOISY_METRIC_THRESHOLD
 * tolerance instead of the default REGRESSION_THRESHOLD. Add a metric here
 * only when its baseline is consistently sub-30ms and CI variance has been
 * empirically shown to exceed 25%.
 *
 * - `fnDeps depth 1`: native baseline 28.7ms (v3.9.6). The fn_deps Rust
 *   implementation, fnDepsData JS wrapper, and DB schema/indexes are all
 *   byte-for-byte unchanged since v3.9.6 (verified by `git log v3.9.6..HEAD`
 *   on crates/codegraph-core/src/read_queries.rs, src/domain/analysis/
 *   dependencies.ts, src/db/, crates/codegraph-core/src/native_db.rs).
 *   CI consistently measures +40–60% on this sub-30ms metric while the
 *   absolute delta (~13ms) is at the noise floor for shared runners.
 *   Methodology already discards 3 warmup runs (#1077). Same pattern as
 *   No-op rebuild and 1-file rebuild — sub-30ms baseline amplified by
 *   ±10ms runner jitter into a percentage swing that looks like regression.
 */
const NOISY_METRICS = new Set<string>(['No-op rebuild', '1-file rebuild', 'fnDeps depth 1']);

/**
 * Minimum absolute delta required before a regression is flagged.
 * Small measurements fluctuate heavily from CI runner load, GC, and
 * OS scheduling jitter — a 13ms→19ms jump is +46% but only 6ms of noise.
 * This floor prevents false positives on inherently noisy metrics.
 *
 * Applied to all numeric metrics (timing in ms, sizes in bytes, counts).
 * For timing metrics the 10-unit floor filters sub-10ms jitter; for byte
 * or count metrics the floor is effectively a no-op since deltas are
 * orders of magnitude larger.
 */
const MIN_ABSOLUTE_DELTA = 10;

/**
 * Versions to skip entirely from regression comparisons.
 *
 * - v3.8.0: benchmarks produced with broken native build orchestrator (#804)
 *   that dropped 12.6% of edges, making build times and query latencies
 *   appear artificially low.
 * v3.8.1 was previously skipped (assumed inflated by per-call NAPI overhead
 * in BFS), but v3.9.0 post-fix data shows equivalent queryTimeMs (~30ms),
 * proving v3.8.1 measurements were not inflated. Un-skipped to provide a
 * valid baseline for v3.9.0 comparisons.
 *
 * These entries are skipped whether they appear as the latest or baseline.
 */
const SKIP_VERSIONS = new Set(['3.8.0']);

/**
 * Known regressions that are already documented with root-cause analysis
 * and tracked in issues. These metric+version pairs are excluded from
 * the regression guard to avoid blocking benchmark data PRs while the
 * underlying issue is being fixed.
 *
 * Format: "version:metric-label" (must match the label passed to checkRegression).
 * Resolution keys use: "version:resolution <lang> precision" or "version:resolution <lang> recall".
 *
 * The `version` is the release where the regression was first observed.
 * When the per-PR gate runs `dev` against that release as baseline, the
 * exemption applies via the baseline-version fallback in assertNoRegressions
 * (and the resolution loop) — so a single `3.9.6:Foo` entry covers both
 * `3.9.6 vs 3.9.5` and every subsequent `dev vs 3.9.6` comparison until
 * the next release clears the regression and the entry is pruned.
 *
 * Entries fire only when `latest.version` matches the prefix (or, for `dev`
 * latest, when `previous.version` matches via the baseline fallback). Once
 * a version is no longer the latest in committed history and no longer the
 * baseline used for `dev` comparisons, its entries become dead weight and
 * should be removed (last pruned: 3.9.0/3.9.1/3.9.2).
 *
 * - 3.9.6:Build ms/file / 3.9.6:No-op rebuild — WASM full build regressed
 *   (#1036) when PR #1016 expanded AST_TYPE_MAPS from 3 to 23 languages,
 *   causing zero-AST-row files to return `astNodes: undefined` and trigger
 *   a full-corpus re-parse. Fixed by PR #1038. Benchmarks captured before
 *   the fix landed; will reclear in v3.9.7+ data.
 *
 * - 3.9.6:Query time — native query benchmark sample-noise blip (29.4 → 47ms)
 *   above the natural variance of the small target set. Not reproducible
 *   locally (~30ms steady-state); will be re-validated on v3.9.7+ data.
 *
 * - 3.9.6:resolution haskell precision/recall — Haskell AST visitor walked
 *   `astTypeMap` with bracket-notation lookup, so node type `constructor`
 *   (Haskell sum-types: Left, Right) resolved to `Object.prototype.constructor`
 *   instead of `undefined`. The Object() function landed in the astNodes row
 *   and crashed the worker boundary with "function Object() could not be
 *   cloned", skipping every Haskell file with constructors. Fixed by gating
 *   with `Object.hasOwn` (#1039). Benchmarks captured before the fix landed;
 *   will reclear in v3.9.7+ data.
 *
 * - 3.10.0:No-op rebuild — small (~3–7ms / ~25–35% local) real regression
 *   amplified by CI variance on a sub-30ms metric. Verified by running
 *   the v3.9.6 source+binary against this repo: steady-state 14–19ms vs
 *   HEAD's 18–22ms. The JS-side fast-skip pre-flight (#1064) was *not*
 *   the cause — disabling it leaves the orchestrator-only path at the
 *   same ~20ms. Likely contributors: the extra is_supported_extension
 *   filter in detect_removed_files (#1070), the larger file_hashes row
 *   set after #1069 stores symbol-less files, and tree-sitter 0.24→0.25.
 *   Each is a few hundred microseconds; aggregated they explain the
 *   local delta. CI's +120% reflects sub-30ms metric noise floor on
 *   shared runners — not a 2x slowdown.
 *
 * - 3.10.0:1-file rebuild — same CI-amplification pattern on a sub-100ms
 *   metric. Local steady-state is ~60ms (PR #1085 brought it down from
 *   ~108ms by skipping unnecessary backfill on clean incrementals); CI
 *   measures 75–82ms = local + ~20ms shared-runner overhead. Against the
 *   v3.9.6 baseline of 54ms that's +44–52% on CI, fluctuating across runs
 *   (run 25624120076: 79ms / +46%; run 25627318198: 82ms / +52%). The
 *   underlying perf work is real and incremental-benchmark.ts now uses
 *   warmup + 5 samples to reduce variance, but a 50% threshold on a
 *   sub-100ms metric is still razor-thin. Exempt this release; remove
 *   once 3.11.0+ data confirms CI numbers stabilize under the new
 *   methodology.
 *
 * - 3.10.0:fnDeps depth 1 — CI variance on a sub-30ms metric (24.7ms native
 *   baseline). The fn_deps Rust implementation, fnDepsData JS wrapper, and
 *   DB schema/indexes are all byte-for-byte unchanged since v3.9.6 (already
 *   documented in NOISY_METRICS above). Measured runs land at +40–60%
 *   typically (within the 50% NOISY_METRIC_THRESHOLD), but the per-PR gate
 *   has seen outliers up to +82% (run 25708925467: 24.7 → 44.9ms) on
 *   shared runners — the ~20ms absolute delta is the runner noise floor.
 *   Exempt this release; remove once 3.11.0+ data confirms stabilization
 *   under the warmup + 5-sample methodology already applied to incremental
 *   benchmarks.
 *
 * - 3.10.0:fnDeps depth 3 — same CI-variance pattern as fnDeps depth 1, just
 *   one depth-level deeper. WASM baseline is 33ms (sub-30ms range when CI
 *   jitter is included). The fn_deps codepath is depth-agnostic — same Rust
 *   implementation, same JS wrapper, same DB indexes — so a deviation at
 *   depth 3 but not depth 1/5 indicates per-run runner noise, not a
 *   structural regression. Observed +32% (33 → 43.4ms) on run 25790873005,
 *   absolute delta 10.4ms exactly at the MIN_ABSOLUTE_DELTA floor. Exempt
 *   this release; remove once 3.11.0+ data confirms stabilization.
 *
 * - 3.10.0:Query time — methodology artifact, not a real regression. The
 *   metric was a single-shot cold call to `fnDepsData('buildGraph', dbPath)`
 *   with no warmup, no median, and `noTests: false` — so it captured ~65ms
 *   of NAPI/rusqlite/OS-page-cache init plus the cost of walking through
 *   fixture files added by new language extractors. Local v3.9.6 vs HEAD
 *   on the same corpus measured 78.8ms vs 67.5ms single-shot (HEAD faster),
 *   while the warmed `queries.fnDepsMs` in the same benchmark showed 4.0ms
 *   vs 2.8ms — confirming no underlying regression. Methodology fixed in
 *   #1113: queryTimeMs now uses 3 warmup runs + median of 5 with
 *   `noTests: true`, matching query-benchmark.ts hygiene. Exemption kept
 *   in place until 3.11.0+ data captures the new steady-state under the
 *   updated methodology (expected ~36ms native on this corpus); remove
 *   the entry then.
 *
 * - 3.10.0:fnDeps depth 5 — same cause as Query time above. Merging main
 *   into #1102 added the Erlang extractor (#1103) on top of the existing
 *   Solidity (#1100) + R (#1102) growth, expanding `buildGraph`'s
 *   depth-5 transitive callee fan-out by another step. The depth-5 walk
 *   amplifies any base-graph growth quadratically (each new node adds
 *   its own depth-5 subtree). +31% over the 25% threshold maps to the
 *   ~33→43ms swing on a sub-50ms metric. Tracked in #1113 alongside
 *   Query time; remove both once 3.11.0+ data confirms the new steady-state.
 *
 * - 3.10.0:fnDeps depth 3 — same cause as depth 1 and depth 5. Merging main
 *   into #1101 (Groovy) layered the Groovy extractor on top of the
 *   Solidity + R + Erlang growth that already inflated the depth-1 and
 *   depth-5 baselines. The depth-3 walk sits between those two and
 *   regresses for the same reason: the self-build benchmark's
 *   `buildGraph` callee graph grew, so every fnDeps depth walks a larger
 *   transitive set. +88% over the 25% threshold on a sub-50ms metric
 *   (24.3 → 45.6ms) is consistent with the other depths. Tracked in #1113
 *   alongside depth 1, depth 5, and Query time; remove all four once
 *   3.11.0+ data confirms the new steady-state.
 *
 * - 3.10.0:DB bytes/file — one-time per-file methodology shift introduced
 *   by #1134, which excludes `tests/benchmarks/resolution/fixtures/**`
 *   from the dogfooding `buildGraph` sweep so heavyweight new grammars
 *   (e.g. Verilog #1107) no longer inflate timing. The 3.10.0 baseline
 *   was measured with fixtures in the corpus (~745 files); dev now
 *   measures the codegraph source alone (~607 files). DB content is
 *   dominated by `src/`, so total bytes stay roughly constant while the
 *   denominator drops, inflating `dbSizeBytes/file` from 41614 → ~52211
 *   (+25%, exactly at the threshold). This is the same shape as the old
 *   `3.10.0:Full build` exemption (now removed because Full build absolute
 *   timing actually improved) — a measurement-scope artifact, not a real
 *   regression in the schema or extraction layer. Exempt this release;
 *   remove once 3.11.0+ data is captured under the post-#1134 methodology.
 */
const KNOWN_REGRESSIONS = new Set([
  '3.9.6:Build ms/file',
  '3.9.6:No-op rebuild',
  '3.9.6:Query time',
  '3.9.6:resolution haskell precision',
  '3.9.6:resolution haskell recall',
  '3.10.0:No-op rebuild',
  '3.10.0:1-file rebuild',
  '3.10.0:fnDeps depth 1',
  '3.10.0:fnDeps depth 3',
  '3.10.0:fnDeps depth 5',
  '3.10.0:Query time',
  '3.10.0:DB bytes/file',
]);

/**
 * Maximum minor-version gap allowed for comparison. When the nearest
 * usable baseline is more than MAX_VERSION_GAP minor versions away,
 * the comparison is skipped — feature additions (new analysis phases,
 * more languages, deeper extraction) make cross-gap comparisons unreliable.
 */
const MAX_VERSION_GAP = 3;

// ── Helpers ──────────────────────────────────────────────────────────────

const ROOT = path.resolve(__dirname, '..', '..');
const BENCHMARKS_DIR = path.join(ROOT, 'generated', 'benchmarks');

interface RegressionCheck {
  label: string;
  current: number;
  previous: number;
  pctChange: number;
}

/**
 * Extract the JSON array from an HTML comment in a markdown file.
 * Each report embeds its historical data in a comment like:
 *   <!-- BENCHMARK_DATA [...] -->
 */
function extractJsonData<T>(filePath: string, marker: string): T[] {
  if (!fs.existsSync(filePath)) return [];
  const content = fs.readFileSync(filePath, 'utf8');
  const re = new RegExp(`<!--\\s*${marker}\\s*([\\s\\S]*?)\\s*-->`);
  const match = content.match(re);
  if (!match) return [];
  try {
    return JSON.parse(match[1]);
  } catch (err) {
    console.error(
      `[regression-guard] Failed to parse JSON from ${filePath} (marker: ${marker}):`,
      err,
    );
    return [];
  }
}

/**
 * Parse a semver string into [major, minor, patch].
 */
function parseSemver(v: string): [number, number, number] | null {
  const m = v.match(/^(\d+)\.(\d+)\.(\d+)/);
  return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
}

/**
 * Count the minor-version distance between two semver strings.
 * Returns Infinity for unparseable versions.
 */
function minorGap(a: string, b: string): number {
  const sa = parseSemver(a);
  const sb = parseSemver(b);
  if (!sa || !sb) return Infinity;
  return Math.abs(sa[0] * 100 + sa[1] - (sb[0] * 100 + sb[1]));
}

/**
 * Count the effective version gap between two versions, including
 * skipped versions between them.  When multiple intermediate versions
 * are in SKIP_VERSIONS (e.g. 3.8.0 and 3.8.1), the comparison spans
 * a larger real gap than the raw minor-version distance suggests.
 * Adding skipped-version count to the minor gap prevents comparing
 * across feature-expansion boundaries where intermediate baselines
 * were invalidated.
 */
function effectiveGap(a: string, b: string): number {
  const raw = minorGap(a, b);
  if (raw === Infinity) return Infinity;
  const sa = parseSemver(a);
  const sb = parseSemver(b);
  if (!sa || !sb) return Infinity;
  const [lo, hi] = [a, b].sort((x, y) => {
    const px = parseSemver(x)!;
    const py = parseSemver(y)!;
    return px[0] * 10000 + px[1] * 100 + px[2] - (py[0] * 10000 + py[1] * 100 + py[2]);
  });
  const loSv = parseSemver(lo)!;
  const hiSv = parseSemver(hi)!;
  const loVal = loSv[0] * 10000 + loSv[1] * 100 + loSv[2];
  const hiVal = hiSv[0] * 10000 + hiSv[1] * 100 + hiSv[2];
  // Count distinct skipped versions that fall between lo and hi
  const skippedBetween = new Set(
    [...SKIP_VERSIONS].filter((v) => {
      const sv = parseSemver(v);
      if (!sv) return false;
      const val = sv[0] * 10000 + sv[1] * 100 + sv[2];
      return val > loVal && val < hiVal;
    }),
  );
  return raw + skippedBetween.size;
}

/**
 * Find the latest entry for a given engine, then the next non-dev
 * entry with data for that engine (the "previous release").
 */
function findLatestPair<T extends { version: string }>(
  history: T[],
  hasEngine: (entry: T) => boolean,
): { latest: T; previous: T } | null {
  // Try each candidate as "latest", starting from the most recent.
  // If the latest entry has no valid baseline within the effective gap,
  // fall through to the next candidate — this ensures we always find
  // the most recent *comparable* pair rather than giving up when the
  // newest entry spans a large feature-expansion gap.
  for (let latestIdx = 0; latestIdx < history.length; latestIdx++) {
    if (SKIP_VERSIONS.has(history[latestIdx].version)) continue;
    if (!hasEngine(history[latestIdx])) continue;

    const latestVersion = history[latestIdx].version;
    // 'dev' represents the current PR build (rolling entry — see
    // scripts/update-benchmark-report.ts). It has no parseable semver,
    // so effectiveGap('dev', anyRelease) returns Infinity — without this
    // bypass, the gap check below would skip dev entirely and the loop
    // would silently fall through to compare two real releases instead
    // of dev vs the latest release, defeating the per-PR gate.
    const isDevLatest = latestVersion === 'dev';

    // Find previous non-dev entry with data for this engine, skipping
    // versions with known unreliable benchmark data and versions that
    // are too far apart for meaningful comparison.  The effective gap
    // includes skipped versions between the pair — when intermediate
    // releases are in SKIP_VERSIONS, the real distance is larger than
    // the raw minor-version count.
    for (let i = latestIdx + 1; i < history.length; i++) {
      const entry = history[i];
      if (entry.version === 'dev') continue;
      if (SKIP_VERSIONS.has(entry.version)) continue;
      if (!hasEngine(entry)) continue;
      // Skip the gap check when comparing dev → release: dev is always
      // the current build, so the most recent comparable release is the
      // correct baseline regardless of feature-expansion distance.
      if (!isDevLatest && effectiveGap(latestVersion, entry.version) > MAX_VERSION_GAP) continue;
      return { latest: history[latestIdx], previous: entry };
    }
    // No valid baseline for this latest — try the next candidate
  }
  return null; // No suitable pair found anywhere in the history
}

/**
 * Assert that a history array is sorted newest-first (index 0 = most recent).
 * The comparison logic depends on this ordering — if violated, the guard would
 * silently compare wrong pairs and miss real regressions.
 */
function assertNewestFirst<T extends { date?: string }>(history: T[], label: string): void {
  const dated = history.filter(
    (e): e is T & { date: string } => typeof e.date === 'string' && e.date.length > 0,
  );
  if (dated.length >= 2) {
    expect(
      new Date(dated[0].date) >= new Date(dated[1].date),
      `${label} history must be sorted newest-first (index 0 = latest)`,
    ).toBe(true);
  }
}

/**
 * Assert that a metric has not regressed beyond the threshold.
 * Only checks metrics where higher = worse (timing, sizes).
 */
function checkRegression(
  label: string,
  current: number | null | undefined,
  previous: number | null | undefined,
): RegressionCheck | null {
  if (current == null || previous == null || previous === 0) return null;
  const absDelta = current - previous;
  if (absDelta < MIN_ABSOLUTE_DELTA) return null; // below noise floor
  const pctChange = absDelta / previous;
  return { label, current, previous, pctChange };
}

function thresholdFor(label: string): number {
  return NOISY_METRICS.has(label) ? NOISY_METRIC_THRESHOLD : REGRESSION_THRESHOLD;
}

function assertNoRegressions(
  checks: (RegressionCheck | null)[],
  version?: string,
  baselineVersion?: string,
) {
  const real = checks.filter(Boolean) as RegressionCheck[];
  const regressions = real.filter((c) => {
    if (c.pctChange <= thresholdFor(c.label)) return false;
    if (version && KNOWN_REGRESSIONS.has(`${version}:${c.label}`)) return false;
    // When `latest` is the rolling 'dev' build, KNOWN_REGRESSIONS entries
    // are anchored to the release where the regression was first observed
    // (e.g. '3.9.6:No-op rebuild'), not to 'dev'. Fall back to the baseline
    // version so a regression introduced before release N stays exempt for
    // every PR comparing dev → N until release N+1 clears it.
    if (
      version === 'dev' &&
      baselineVersion &&
      KNOWN_REGRESSIONS.has(`${baselineVersion}:${c.label}`)
    ) {
      return false;
    }
    return true;
  });

  if (regressions.length > 0) {
    const details = regressions
      .map(
        (r) =>
          `  ${r.label}: ${r.previous} → ${r.current} (+${Math.round(r.pctChange * 100)}%, threshold ${Math.round(thresholdFor(r.label) * 100)}%)`,
      )
      .join('\n');
    expect.fail(`Benchmark regressions exceed threshold:\n${details}`);
  }
}

// ── Build benchmark data types ───────────────────────────────────────────

interface BuildEngine {
  buildTimeMs: number;
  queryTimeMs: number;
  dbSizeBytes: number;
  perFile: {
    buildTimeMs: number;
    nodes: number;
    edges: number;
    dbSizeBytes: number;
  };
  noopRebuildMs?: number;
  oneFileRebuildMs?: number;
}

interface BuildEntry {
  version: string;
  date: string;
  files: number;
  native?: BuildEngine | null;
  wasm?: BuildEngine | null;
}

// ── Query benchmark data types ───────────────────────────────────────────

interface QueryEngine {
  fnDeps: { depth1Ms: number; depth3Ms: number; depth5Ms: number };
  fnImpact: { depth1Ms: number; depth3Ms: number; depth5Ms: number };
  diffImpact: { latencyMs: number };
}

interface QueryEntry {
  version: string;
  date: string;
  native?: QueryEngine | null;
  wasm?: QueryEngine | null;
}

// ── Incremental benchmark data types ─────────────────────────────────────

interface IncrementalEngine {
  fullBuildMs: number;
  noopRebuildMs: number;
  oneFileRebuildMs: number;
}

interface IncrementalEntry {
  version: string;
  date: string;
  files: number;
  native?: IncrementalEngine | null;
  wasm?: IncrementalEngine | null;
  resolve?: {
    nativeBatchMs: number;
    jsFallbackMs: number;
  };
}

// ── Tests ────────────────────────────────────────────────────────────────

// Release-blocking gate: runs pre-publish (after fresh benchmark numbers are
// written by the pre-publish-benchmark job in .github/workflows/publish.yml)
// and during local invocations of `npm run test:regression-guard`. Skipped
// in the default `npm test` run so docs commits that merge already-recorded
// regressed history into main don't trigger false failures — by then the
// release has already passed the gate.
const RUN_REGRESSION_GUARD = process.env.RUN_REGRESSION_GUARD === '1';

describe.runIf(RUN_REGRESSION_GUARD)('Benchmark regression guard', () => {
  const buildHistory = extractJsonData<BuildEntry>(
    path.join(BENCHMARKS_DIR, 'BUILD-BENCHMARKS.md'),
    'BENCHMARK_DATA',
  );
  const queryHistory = extractJsonData<QueryEntry>(
    path.join(BENCHMARKS_DIR, 'QUERY-BENCHMARKS.md'),
    'QUERY_BENCHMARK_DATA',
  );
  const incrementalHistory = extractJsonData<IncrementalEntry>(
    path.join(BENCHMARKS_DIR, 'INCREMENTAL-BENCHMARKS.md'),
    'INCREMENTAL_BENCHMARK_DATA',
  );

  // Warn when KNOWN_REGRESSIONS entries are stale (more than 1 minor version
  // behind the current package version).  This makes the stale-exemption
  // problem self-detecting rather than requiring manual bookkeeping.
  test('KNOWN_REGRESSIONS entries are not stale', () => {
    // eslint-disable-next-line @typescript-eslint/no-require-imports
    const pkgVersion: string = JSON.parse(
      fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8'),
    ).version;
    const stale: string[] = [];
    for (const entry of KNOWN_REGRESSIONS) {
      const colonIdx = entry.indexOf(':');
      if (colonIdx === -1) continue;
      const entryVersion = entry.slice(0, colonIdx);
      const gap = minorGap(entryVersion, pkgVersion);
      if (gap > 1) {
        stale.push(
          `${entry} (version ${entryVersion} is ${gap} minor versions behind ${pkgVersion})`,
        );
      }
    }
    if (stale.length > 0) {
      console.warn(
        `[regression-guard] Stale KNOWN_REGRESSIONS entries — remove after verifying corrected data:\n  ${stale.join('\n  ')}`,
      );
    }
    expect(
      stale.length,
      `KNOWN_REGRESSIONS has ${stale.length} stale entries (>1 minor version behind ${pkgVersion}). ` +
        `Remove them after verifying the corrected benchmark data has landed:\n  ${stale.join('\n  ')}`,
    ).toBe(0);
  });

  // Validate newest-first ordering assumption for all history arrays
  test('build history is sorted newest-first', () => {
    assertNewestFirst(buildHistory, 'Build benchmark');
  });
  test('query history is sorted newest-first', () => {
    assertNewestFirst(queryHistory, 'Query benchmark');
  });
  test('incremental history is sorted newest-first', () => {
    assertNewestFirst(incrementalHistory, 'Incremental benchmark');
  });

  describe('build benchmarks', () => {
    for (const engineKey of ['native', 'wasm'] as const) {
      const pair = findLatestPair(buildHistory, (e) => e[engineKey] != null);
      if (!pair) continue;

      const { latest, previous } = pair;
      const cur = latest[engineKey]!;
      const prev = previous[engineKey]!;

      test(`${engineKey} engine — ${latest.version} vs ${previous.version}`, () => {
        assertNoRegressions(
          [
            checkRegression(`Build ms/file`, cur.perFile.buildTimeMs, prev.perFile.buildTimeMs),
            checkRegression(`Query time`, cur.queryTimeMs, prev.queryTimeMs),
            checkRegression(`DB bytes/file`, cur.perFile.dbSizeBytes, prev.perFile.dbSizeBytes),
            checkRegression(`No-op rebuild`, cur.noopRebuildMs, prev.noopRebuildMs),
            checkRegression(`1-file rebuild`, cur.oneFileRebuildMs, prev.oneFileRebuildMs),
          ],
          latest.version,
          previous.version,
        );
      });
    }

    test('has at least one engine to compare', () => {
      const hasAny = ['native', 'wasm'].some(
        (ek) => findLatestPair(buildHistory, (e) => e[ek as keyof BuildEntry] != null) != null,
      );
      expect(hasAny, 'No build benchmark data with ≥2 entries to compare').toBe(true);
    });
  });

  describe('query benchmarks', () => {
    for (const engineKey of ['native', 'wasm'] as const) {
      const pair = findLatestPair(queryHistory, (e) => e[engineKey] != null);
      if (!pair) continue;

      const { latest, previous } = pair;
      const cur = latest[engineKey]!;
      const prev = previous[engineKey]!;

      test(`${engineKey} engine — ${latest.version} vs ${previous.version}`, () => {
        assertNoRegressions(
          [
            checkRegression(`fnDeps depth 1`, cur.fnDeps.depth1Ms, prev.fnDeps.depth1Ms),
            checkRegression(`fnDeps depth 3`, cur.fnDeps.depth3Ms, prev.fnDeps.depth3Ms),
            checkRegression(`fnDeps depth 5`, cur.fnDeps.depth5Ms, prev.fnDeps.depth5Ms),
            checkRegression(`fnImpact depth 1`, cur.fnImpact.depth1Ms, prev.fnImpact.depth1Ms),
            checkRegression(`fnImpact depth 3`, cur.fnImpact.depth3Ms, prev.fnImpact.depth3Ms),
            checkRegression(`fnImpact depth 5`, cur.fnImpact.depth5Ms, prev.fnImpact.depth5Ms),
            checkRegression(
              `diffImpact latency`,
              cur.diffImpact.latencyMs,
              prev.diffImpact.latencyMs,
            ),
          ],
          latest.version,
          previous.version,
        );
      });
    }

    test('has at least one engine to compare', () => {
      const hasAny = ['native', 'wasm'].some(
        (ek) => findLatestPair(queryHistory, (e) => e[ek as keyof QueryEntry] != null) != null,
      );
      expect(hasAny, 'No query benchmark data with ≥2 entries to compare').toBe(true);
    });
  });

  describe('incremental benchmarks', () => {
    for (const engineKey of ['native', 'wasm'] as const) {
      const pair = findLatestPair(incrementalHistory, (e) => e[engineKey] != null);
      if (!pair) continue;

      const { latest, previous } = pair;
      const cur = latest[engineKey]!;
      const prev = previous[engineKey]!;

      test(`${engineKey} engine — ${latest.version} vs ${previous.version}`, () => {
        assertNoRegressions(
          [
            checkRegression(`Full build`, cur.fullBuildMs, prev.fullBuildMs),
            checkRegression(`No-op rebuild`, cur.noopRebuildMs, prev.noopRebuildMs),
            checkRegression(`1-file rebuild`, cur.oneFileRebuildMs, prev.oneFileRebuildMs),
          ],
          latest.version,
          previous.version,
        );
      });
    }

    // Resolve benchmarks (not engine-specific). Keep `dev` in the candidate
    // pool so the per-PR gate (which produces a `dev` resolve entry) covers
    // import resolution; previously the filter dropped dev outright, leaving
    // nativeBatchMs / jsFallbackMs blind to PR-introduced regressions.
    const resolvePair = findLatestPair(
      incrementalHistory.filter((e) => e.resolve != null),
      (e) => e.resolve != null,
    );
    if (resolvePair) {
      const { latest: latestRes, previous: previousRes } = resolvePair;
      test(`import resolution — ${latestRes.version} vs ${previousRes.version}`, () => {
        const cur = latestRes.resolve!;
        const prev = previousRes.resolve!;
        assertNoRegressions(
          [
            checkRegression(`Native batch resolve`, cur.nativeBatchMs, prev.nativeBatchMs),
            checkRegression(`JS fallback resolve`, cur.jsFallbackMs, prev.jsFallbackMs),
          ],
          latestRes.version,
          previousRes.version,
        );
      });
    }

    test('has at least one engine to compare', () => {
      const hasAny = ['native', 'wasm'].some(
        (ek) =>
          findLatestPair(incrementalHistory, (e) => e[ek as keyof IncrementalEntry] != null) !=
          null,
      );
      expect(hasAny, 'No incremental benchmark data with ≥2 entries to compare').toBe(true);
    });

    test('has resolve data to compare', () => {
      expect(
        resolvePair != null,
        'No import-resolution benchmark data with ≥2 comparable entries',
      ).toBe(true);
    });
  });

  describe('resolution benchmarks', () => {
    /**
     * Resolution precision/recall regression thresholds.
     * These are percentage-point drops (not relative %) because resolution
     * metrics are bounded [0, 1] and small absolute drops matter.
     *
     * Precision >5pp drop and recall >10pp drop are flagged.
     * Recall has a wider threshold because it's more volatile — adding new
     * expected edges to fixtures can temporarily lower recall.
     *
     * SYNC: These must match PRECISION_DROP_THRESHOLD / RECALL_DROP_THRESHOLD
     * in scripts/update-benchmark-report.ts (the ::warning annotation side).
     */
    const PRECISION_DROP_PP = 0.05;
    const RECALL_DROP_PP = 0.1;

    interface ResolutionLang {
      precision: number;
      recall: number;
      truePositives: number;
      falsePositives: number;
      falseNegatives: number;
      totalResolved: number;
      totalExpected: number;
    }

    interface BuildEntryWithResolution extends BuildEntry {
      resolution?: Record<string, ResolutionLang>;
    }

    // buildHistory already parsed BUILD-BENCHMARKS.md with the same marker;
    // widen the type instead of re-reading the file.
    const fullHistory = buildHistory as BuildEntryWithResolution[];

    const resolutionPair = findLatestPair(fullHistory, (e) => e.resolution != null);

    if (resolutionPair) {
      const { latest: latestRes, previous: previousRes } = resolutionPair;

      test(`resolution — ${latestRes.version} vs ${previousRes.version}`, () => {
        const curRes = latestRes.resolution!;
        const prevRes = previousRes.resolution!;
        const regressions: string[] = [];

        for (const lang of Object.keys(curRes)) {
          const cur = curRes[lang];
          const prv = prevRes[lang];
          if (!cur || !prv) continue;

          // When latest is 'dev' (per-PR build), KNOWN_REGRESSIONS keys
          // are anchored to the baseline release where the regression was
          // first observed, not to 'dev' — fall back to previousRes.version.
          const isDev = latestRes.version === 'dev';

          const precDrop = prv.precision - cur.precision;
          if (precDrop > PRECISION_DROP_PP) {
            const key = `${latestRes.version}:resolution ${lang} precision`;
            const fallbackKey = `${previousRes.version}:resolution ${lang} precision`;
            const isKnown =
              KNOWN_REGRESSIONS.has(key) || (isDev && KNOWN_REGRESSIONS.has(fallbackKey));
            if (!isKnown) {
              regressions.push(
                `  ${lang} precision: ${(prv.precision * 100).toFixed(1)}% → ${(cur.precision * 100).toFixed(1)}% (−${(precDrop * 100).toFixed(1)}pp, threshold ${(PRECISION_DROP_PP * 100).toFixed(0)}pp)`,
              );
            }
          }

          const recDrop = prv.recall - cur.recall;
          if (recDrop > RECALL_DROP_PP) {
            const key = `${latestRes.version}:resolution ${lang} recall`;
            const fallbackKey = `${previousRes.version}:resolution ${lang} recall`;
            const isKnown =
              KNOWN_REGRESSIONS.has(key) || (isDev && KNOWN_REGRESSIONS.has(fallbackKey));
            if (!isKnown) {
              regressions.push(
                `  ${lang} recall: ${(prv.recall * 100).toFixed(1)}% → ${(cur.recall * 100).toFixed(1)}% (−${(recDrop * 100).toFixed(1)}pp, threshold ${(RECALL_DROP_PP * 100).toFixed(0)}pp)`,
              );
            }
          }
        }

        if (regressions.length > 0) {
          expect.fail(`Resolution precision/recall regressions:\n${regressions.join('\n')}`);
        }
      });
    }

    test('has resolution data to compare', () => {
      expect(
        resolutionPair != null,
        'No resolution benchmark data with ≥2 non-dev entries to compare',
      ).toBe(true);
    });
  });
});
