mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 22:14:25 +01:00
* test: turn git auto-maintenance off in free fixtures, with a runner backstop git 2.29+ starts a detached `git maintenance run --auto` after every commit; it creates and deletes .git/objects/maintenance.lock after the commit returns. Diagnosed on47d639a(git 2.55.0): - gstack-docs-candidate (ledger 37742176952/16): 4/150 replicated snapshots saw the .git listing change, every one `-objects/maintenance.lock`; 0/150 with the keys. repo() now sets maintenance.auto=false and gc.auto=0. - plan-design-sdk-fixture (ledger 37807608021/6): under commit load 18/150 runs failed the collector check; in every one Bun's rmSync(recursive, force) returned without throwing and left the scratch repo behind. 0/100 with the keys in the fixture's replacement git env, which never sees the runner's. - qa-functional-observer (ledger 37800857251/4): under load 6/200 observer setups threw ENOENT lstat .git/objects/maintenance.lock; 0/400 with the keys in the inherited env. The observer and its fixture (22 paid cases) are not edited; the runner backstop covers them. runFreeShard appends the two keys after any inherited GIT_CONFIG_* pairs of each shard child (a malformed inherited count is left for git to reject). A GIT_TRACE commit shows 3 maintenance lines with defaults and 0 in the runner child env; the new test pins that and the env merge cases. Failure messages now name the changed .git entries and surviving fixture directories; no assertion changed. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: synchronize the zombie publication-lock test on a framed PID line Ledger 38153371796/3 and 38161906980/3: line 69 `existsSync(ready)` failed. The test polled 300 x 10 ms for the Python parent's readiness file, then another 300 x 10 ms of /proc for state Z; under shard load the fixed count expired. existsSync also raced the write (an empty file read as pid 0). The parent now waits for its child with os.waitid(P_PID, WEXITED|WNOWAIT), which leaves it unreaped in state Z, and prints the PID with flush=True. The test reads exactly one newline-framed line from the parent's stdout under the test's own (unchanged) 10 s timeout, rejects a malformed or non-positive PID, and makes one /proc read that must show Z. Early exit, a malformed line or a timeout reports the parent's exit status and stderr tail; the owned parent is always killed in cleanup. Intentional assertion replacement: the readiness-file existence check and the polling loop are replaced by the framed-line requirement (a positive PID) and one /proc read; `expect(state).toBe('Z')` and every lock-recovery assertion are unchanged. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: stop the next-version smoke test from calling live GitHub Ledger 37804770972/9: "CLI runs against real repo and emits parseable JSON" failed with `JSON Parse error: Unexpected EOF` after 30032 ms. The smoke test ran the real CLI against live `gh`; bin/gstack-next-version's per-PR `gh api` calls run one after another (spawnSync inside its async workers), so 27 open PRs passed the 30 s spawn timeout and stdout was empty. Measured with a delayed fake gh: 8 PRs x 0.5 s = 4108 ms; 27 PRs x 1.2 s = 32637 ms. The CLI defect is filed as a P1 follow-up, not fixed here. The test keeps the real repository and the real CLI and puts a named fake gh first on PATH. It logs every argv, answers exactly the calls the CLI makes (auth status only when origin is not github.com, pr view exit 1, canned pr list, repo view and contents with one same-owner PR claiming 1.6.4.0) and exits 2 with a FAKE_GH_UNHANDLED marker for anything else; glab exits 1. The test now checks the exit code with stderr before JSON.parse, the exact call set, no marker, no glab call, host github, offline false, no fallback, no warnings, the canned claim, and version 1.6.5.0. Every existing assertion is kept; the spawn timeout is unchanged. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(test-health): report the Windows wall over PR runs and name the seed's real prerequisite free-wall read windows-free-tests.yml with `branch: 'main'`, but that workflow runs only on pull requests and workflow_dispatch, so the "main" runs it averaged were dispatch duration recordings. It now uses listWorkflowRuns' existing `event: 'pull_request'` filter and prints the population inline: `windows-free-tests (successful PR runs) 7.1 min (23 runs)`, or `no successful PR runs in window`. The free-tests half is byte-identical. The CEO-10 fix text stops offering a "CI recording" (none exists for the Linux seed) and names UBICLOUD_API_KEY; test:ubicloud --record-durations copies the seed back into the checkout, and contributors without the key leave the refresh to a maintainer. docs/TESTING_INTERNALS.md drops "or a CI recording" in the same change. Intentional pin change: test/test-health-report.test.ts free-wall value `windows-free-tests 0.0 min (1 runs)` -> `windows-free-tests (successful PR runs) 0.0 min (1 runs)`; the fixture now carries a PR run and an excluded dispatch run, plus a new case for the empty and mixed populations. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(timeline-read): reject missing and unparseable --since/--branch values `--since` and `--branch` read an unbound $2 under `set -u` and crashed with exit 1 when given no value, and an unparseable --since ("yesterday", "2 weeks") silently returned unfiltered history because the Bun regex was unanchored and fell through. Unknown arguments were silently ignored. --since is now parsed once in bash (no `date -d`, no bash-4 features) into a cutoff in epoch ms, and the Bun regex is deleted. Accepted forms: "<N> minute|hour|day|week|month[s] ago" (case-insensitive, as before), "<N>h|d|w" (the form gstack-global-discover documents) and ISO dates YYYY-MM-DD or YYYY-MM-DDTHH:MM:SSZ (UTC). In #1723's shape, before any read: --since must be a relative time like "7 days ago" (got no value); accepted: ... --since must be a relative time like "7 days ago" (got 'yesterday'); accepted: ... --branch must be a branch name (got flag '--limit') with "got an empty value" for ''. Unknown arguments exit 2 with the usage line; --help/-h prints it and exits 0. Tests extend the --limit table in test/timeline.test.ts and run every case on empty and populated history, plus each accepted --since form filtering old events; the quoted-branch and no-filter tests are unchanged. Behavior change: unrecognized arguments (previously ignored) now exit 2. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: record the due audit follow-ups with verdicts, and file wave-2 follow-ups docs/test-audit-2026-10.md gains a dated 2026-10-15 section from a no-spend `bun run test:health --since-days 7`, with the 2026-10-12 figures from #3154 kept as history and a verdict per target: full-fallback 45.9% (open; the audit's next step is touchfile mappings for bin/, lib/, hosts/, make-pdf/), spend per merged PR $73.52 (verdict unavailable: the pre-change baseline, recomputed with collectHealth at now=2026-10-04 behind an upper-bound client, holds no trial-outcomes artifacts because the recorder shipped with the audit), cancelled slice-minutes 1,545/week, push-to-verdict p50 11.4 min (missed against 3-5 min), flaky-pass 6.1% (missed; this wave repairs it). Census reliability is not due until 2026-10-26; the interim figure is recorded and the repair stays with #3153/#3200. TODOS.md: one appended dated section (P1 gstack-next-version serial fetch with the 4160 ms measurement, P1 standing seed recorder, P1 full-fallback/spend, P1 dated flake follow-ups, P2 createQAFunctionalFixture maintenance keys, P2 diagnostic-mode HOME/state-root artifacts). The timeline-read bullet was deleted with its fix. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * chore(release): tentative stamp v1.91.105.0 with its CHANGELOG entry Stamp-at-merge policy (.gstack/ship-policy.json): the branch carries a tentative PATCH stamp and its CHANGELOG section directly under `# Changelog`; the queue restamps at merge time. Base47d639ais 1.91.101.0, but the live slot scan shows 1.91.102.0 (#3190), 1.91.103.0 (#3200) and 1.91.104.0 (#3209) claimed, so this branch takes 1.91.105.0. Written with `gstack-restamp --version 1.91.105.0 --base main --expect-base47d639a--allow-repo-commands` (VERSION, package.json's npm translation, the agents digest via the policy's release tool, and the CHANGELOG header). * test(seed): refresh the free-suite duration seed from a Ubicloud recording (#3154) Recorded with `bun run test:ubicloud --record-durations` (standard-16, eu-central-h1) on this branch, which equals origin/main47d639aplus this wave; the recorder exited 0 and wrote 1219 durations (1055 before, 164 new, none removed; serial 2439 s -> 3202 s). `test:health` CEO-10 now reports 0 unseeded files. Two earlier recordings did not count: one failed on an unrelated fixed-sleep race in test/plan-seed-submission.test.ts (filed P2, not edited here), the other failed free-seed-ratchet because it ran against the first attempt's pulled seed. No file was excluded. free-seed-ratchet: three files are over the 60 s ceiling in this recording and are allowlisted with a split todo (setup-install-registry 82 s, host-renders-prune 64 s, setup-codex-scope 60.4 s); the split follow-up and the plan-seed-submission race are filed in the appended TODOS section. The ratchet test now names scripts/free-test-seed-allowlist.json literally (a pin on SEED_ALLOWLIST_FILE): pregate's lanes check maps data files to tests through string literals, and the allowlist had no lane (PREGATE_NO_LANE). Additive assertion; nothing weakened. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: give the git-maintenance backstop test a Windows-safe empty global config windows-free-shard (2) on #3212 (run 38832498861) failed two cases in test/test-free-shards-git-maintenance.test.ts, both serially reproduced by the retry pass: Git for Windows rejects GIT_CONFIG_GLOBAL=os.devNull (`fatal: unable to access '\\.\nul': Invalid argument`), so the malformed-count case saw that error instead of git's GIT_CONFIG_COUNT rejection, and the traced commit's `git init` exited nonzero. Point GIT_CONFIG_GLOBAL at a file that does not exist in the scratch HOME, as test/ceo-plan-mode-fixture.test.ts does; git reads a missing global config as empty on every platform. No assertion changed. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
411 lines
26 KiB
TypeScript
411 lines
26 KiB
TypeScript
#!/usr/bin/env bun
|
|
/**
|
|
* test:health — the audit's success metrics as one command, read from CI
|
|
* history (scripts/lib/ci-history.ts, the same reader pass-rates uses) plus
|
|
* the local tree. Every metric prints its counting rule; a metric whose input
|
|
* cannot be read prints "unavailable: <reason>; next: <step>", never 0.
|
|
*
|
|
* Enforced checks (`--enforce`): a free test file that flakes in more than 5%
|
|
* of main free-tests runs over a window of at least 20 runs (W7a), and more
|
|
* than 5 free test files missing from scripts/free-test-durations.json
|
|
* (CEO-10). Without `--enforce` the command always exits 0; an unavailable
|
|
* check never fails it. Needs `gh` with read access to the repository.
|
|
*
|
|
* Usage:
|
|
* bun run test:health [--since-days 7] [--json] [--enforce] [--summary <file>]
|
|
* [--repo owner/name] [--complexity-base <ref>]
|
|
* bun run test:health flake-summary <dir> # job-summary table of downloaded flake-ledger-* artifacts
|
|
*/
|
|
import * as fs from 'node:fs';
|
|
import * as path from 'node:path';
|
|
import { spawnSync } from 'node:child_process';
|
|
import { GH_CLIENT, downloadRunArtifacts, formatFlakeSummary, historyFetcher, listRunJobs, listWorkflowRuns, readCiFlakeLedgers,
|
|
readFlakeLedgerDir, repoSlug, unavailableReason, type CiRun, type FlakeWindow, type GhClient } from './lib/ci-history';
|
|
import { analyzePassRates, readTrialOutcomeDir, type TrialRecord } from './eval-flake-rank';
|
|
import { collectFreeTestFiles, loadFreeTestDurations, unseededFreeFiles } from './test-free-shards';
|
|
import { EVAL_POLICY } from '../test/helpers/periodic-exclude-data';
|
|
import { resolveStateRoot } from '../lib/state-root';
|
|
|
|
const ROOT = path.resolve(import.meta.dir, '..');
|
|
export const UNSEEDED_LIMIT = 5;
|
|
export const COMPLEXITY_PATHS = ['scripts', 'test/helpers', '.github/workflows'] as const;
|
|
|
|
export interface Metric { id: string; title: string; rule: string; value?: string; detail?: string[]; unavailable?: string }
|
|
export interface Check { id: string; title: string; status: 'pass' | 'fail' | 'unavailable'; detail: string; fix: string }
|
|
export type IssueAction = 'upsert' | 'keep-open' | 'close';
|
|
export interface HealthReport { repo: string; sinceDays: number; generatedAt: string; metrics: Metric[]; checks: Check[]; issueAction: IssueAction }
|
|
|
|
export interface HealthInputs {
|
|
client: GhClient;
|
|
repo: string;
|
|
now: number;
|
|
sinceDays: number;
|
|
cacheDir: string;
|
|
root?: string;
|
|
complexityBase?: string;
|
|
}
|
|
|
|
// --- Pure arithmetic ---
|
|
|
|
export function percentile(values: number[], p: number): number | null {
|
|
if (values.length === 0) return null;
|
|
const sorted = [...values].sort((a, b) => a - b);
|
|
return sorted[Math.min(sorted.length - 1, Math.max(0, Math.ceil(p * sorted.length) - 1))]!;
|
|
}
|
|
|
|
function choose(n: number, k: number): number {
|
|
let out = 1;
|
|
for (let i = 1; i <= k; i++) out = (out * (n - k + i)) / i;
|
|
return out;
|
|
}
|
|
|
|
/** P(fewer than k of n independent trials pass) when one trial fails with probability `failRate`. */
|
|
export function panelRedProbability(failRate: number, n: number, k: number): number {
|
|
let red = 0;
|
|
for (let passes = 0; passes < k; passes++) red += choose(n, passes) * (1 - failRate) ** passes * failRate ** (n - passes);
|
|
return red;
|
|
}
|
|
|
|
export interface CensusStats {
|
|
runs: number;
|
|
trials: number;
|
|
failed: number;
|
|
byKind: Record<string, { trials: number; failed: number }>;
|
|
byClass: Record<string, number>;
|
|
/** P(lane red) with each case's pooled trial failure rate, under the recorded panels. */
|
|
redInForce: number;
|
|
/** The same evidence with every case judged on one trial. */
|
|
redSingleTrial: number;
|
|
}
|
|
|
|
/**
|
|
* CEO-06/22 census arithmetic over one lane's trial records. Every observed
|
|
* failure is treated as nondeterminism, so both red probabilities are upper
|
|
* bounds for known-good inputs; they are reported separately from the raw
|
|
* trial failure rate so a verdict-policy change cannot read as a reliability gain.
|
|
*/
|
|
export function censusStats(records: TrialRecord[], runIds: string[]): CensusStats {
|
|
const scored = records.filter(record => record.outcome !== 'skipped');
|
|
const byKind: CensusStats['byKind'] = {};
|
|
const byClass: Record<string, number> = {};
|
|
const perCase = new Map<string, { trials: number; failed: number; n: number; k: number }>();
|
|
for (const record of scored) {
|
|
const failed = record.outcome === 'failed';
|
|
const kind = (byKind[record.kind] ??= { trials: 0, failed: 0 });
|
|
kind.trials += 1;
|
|
if (failed) { kind.failed += 1; byClass[record.failure_class ?? 'unknown'] = (byClass[record.failure_class ?? 'unknown'] ?? 0) + 1; }
|
|
const slot = perCase.get(record.case) ?? { trials: 0, failed: 0, n: 1, k: 1 };
|
|
slot.trials += 1;
|
|
if (failed) slot.failed += 1;
|
|
if (record.panel.n > slot.n) { slot.n = record.panel.n; slot.k = record.panel.k; }
|
|
perCase.set(record.case, slot);
|
|
}
|
|
let greenInForce = 1, greenSingle = 1;
|
|
for (const slot of perCase.values()) {
|
|
const p = slot.failed / slot.trials;
|
|
greenInForce *= 1 - panelRedProbability(p, slot.n, slot.k);
|
|
greenSingle *= 1 - p;
|
|
}
|
|
return { runs: runIds.length, trials: scored.length, failed: scored.filter(record => record.outcome === 'failed').length,
|
|
byKind, byClass, redInForce: 1 - greenInForce, redSingleTrial: 1 - greenSingle };
|
|
}
|
|
|
|
const pct = (value: number) => `${(Math.round(value * 1000) / 10).toFixed(1)}%`;
|
|
const minutes = (ms: number | null) => ms === null ? 'n/a' : `${(ms / 60_000).toFixed(1)} min`;
|
|
const span = (from: string | null, to: string | null) => from && to ? Math.max(0, Date.parse(to) - Date.parse(from)) : null;
|
|
|
|
/** DX-9: close only when every enforced check has evidence and passes; missing evidence keeps the issue open. */
|
|
export function issueActionFor(checks: Check[]): IssueAction {
|
|
if (checks.some(check => check.status === 'fail')) return 'upsert';
|
|
if (checks.some(check => check.status === 'unavailable')) return 'keep-open';
|
|
return 'close';
|
|
}
|
|
|
|
// --- Checks ---
|
|
|
|
export function flakeCheck(window: FlakeWindow | Error): Check {
|
|
const base = { id: 'flaky-files', title: 'Free-suite flaky files (W7a)',
|
|
fix: 'Fix the race at source (docs/TESTING_INTERNALS.md#flake-ledger); never raise the timeout or add a retry. Reproduce with `bun test <file>` under load.' };
|
|
if (window instanceof Error) return { ...base, status: 'unavailable', detail: `unavailable: ${unavailableReason(window)}` };
|
|
const failing = window.files.filter(file => file.failing);
|
|
if (!window.enforceable) {
|
|
return { ...base, status: 'pass', detail: `report-only: ${window.runs} main run(s) since ${window.since.slice(0, 10)} (< 20 needed to enforce)` };
|
|
}
|
|
if (failing.length === 0) return { ...base, status: 'pass', detail: `no file above 5% over ${window.runs} main runs` };
|
|
return { ...base, status: 'fail',
|
|
detail: failing.map(file => `${file.file}: flaky in ${file.flakyRuns}/${window.runs} main runs (${pct(file.rate)} > 5%)`).join('; ') };
|
|
}
|
|
|
|
export function unseededCheck(root: string): Check {
|
|
const base = { id: 'unseeded-free-files', title: 'Free test files missing from the duration seed (CEO-10)',
|
|
fix: 'Refresh the seed with UBICLOUD_API_KEY set: `bun run test:ubicloud --record-durations` times every free file on a CI-mirrored VM and copies scripts/free-test-durations.json back into this checkout; commit it (docs/TESTING_INTERNALS.md#free-suite-duration-seed). Without UBICLOUD_API_KEY, leave the refresh to a maintainer: a 4-core laptop or sandbox recording skips or slows browser and display tests, so it is not accepted.' };
|
|
const durations = loadFreeTestDurations(root);
|
|
if (!durations) return { ...base, status: 'unavailable', detail: 'unavailable: scripts/free-test-durations.json is missing or unreadable; next: refresh the seed' };
|
|
const unseeded = unseededFreeFiles(collectFreeTestFiles(root), durations);
|
|
const shown = unseeded.slice(0, 10).join(', ') + (unseeded.length > 10 ? `, +${unseeded.length - 10} more` : '');
|
|
if (unseeded.length > UNSEEDED_LIMIT) return { ...base, status: 'fail', detail: `stale seed: ${unseeded.length} unseeded file(s) > ${UNSEEDED_LIMIT}: ${shown}` };
|
|
return { ...base, status: 'pass', detail: `${unseeded.length} unseeded file(s) (limit ${UNSEEDED_LIMIT})${unseeded.length ? `: ${shown}` : ''}` };
|
|
}
|
|
|
|
// --- Metric collection ---
|
|
|
|
function attempt<T>(fn: () => T): T | Error {
|
|
try { return fn(); } catch (error) { return error instanceof Error ? error : new Error(String(error)); }
|
|
}
|
|
|
|
function metric(id: string, title: string, rule: string, compute: () => { value: string; detail?: string[] }): Metric {
|
|
const result = attempt(compute);
|
|
if (result instanceof Error) return { id, title, rule, unavailable: unavailableReason(result) };
|
|
return { id, title, rule, ...result };
|
|
}
|
|
|
|
function records(dirs: string[]): TrialRecord[] {
|
|
return dirs.flatMap(dir => readTrialOutcomeDir(dir).records);
|
|
}
|
|
|
|
function readJson(file: string): any {
|
|
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
}
|
|
|
|
/** Net LOC under COMPLEXITY_PATHS and the package-script count at one ref. */
|
|
export function complexityAt(root: string, ref: string): { loc: number; scripts: number } {
|
|
const grep = spawnSync('git', ['grep', '-c', '-I', '', ref, '--', ...COMPLEXITY_PATHS], { cwd: root, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, timeout: 60_000 });
|
|
if (grep.status !== 0 && grep.status !== 1) throw new Error(`git grep ${ref} failed: ${grep.stderr.trim()}`);
|
|
const loc = grep.stdout.split('\n').filter(Boolean).reduce((sum, line) => sum + Number(line.slice(line.lastIndexOf(':') + 1) || 0), 0);
|
|
const pkg = spawnSync('git', ['show', `${ref}:package.json`], { cwd: root, encoding: 'utf8', timeout: 10_000 });
|
|
if (pkg.status !== 0) throw new Error(`git show ${ref}:package.json failed: ${pkg.stderr.trim()}`);
|
|
return { loc, scripts: Object.keys(JSON.parse(pkg.stdout).scripts ?? {}).length };
|
|
}
|
|
|
|
export function collectHealth(inputs: HealthInputs): HealthReport {
|
|
const { client, repo, now, sinceDays, cacheDir } = inputs;
|
|
const root = inputs.root ?? ROOT;
|
|
const since = new Date(now - sinceDays * 86_400_000).toISOString();
|
|
const fetcher = historyFetcher(client);
|
|
const artifactDirs = (run: CiRun, match: (name: string) => boolean) =>
|
|
downloadRunArtifacts({ repo, run, cacheDir, fetcher, match, maxBytes: 64 * 1024 * 1024 });
|
|
const prRuns = attempt(() => listWorkflowRuns(client, repo, 'evals.yml', { since, event: 'pull_request' }));
|
|
const need = <T>(value: T | Error): T => { if (value instanceof Error) throw value; return value; };
|
|
const prRecords = attempt(() => need(prRuns).filter(run => run.conclusion !== 'cancelled')
|
|
.map(run => ({ run, trials: records(artifactDirs(run, name => name.startsWith('trial-outcomes'))) })));
|
|
const metrics: Metric[] = [];
|
|
|
|
metrics.push(metric('pr-fallback-rate', 'PR full-fallback rate', 'share of evals.yml pull_request runs whose paid-plan manifest has prCoverage.mode = full-fallback', () => {
|
|
const modes = new Map<string, number>();
|
|
for (const run of need(prRuns)) {
|
|
const manifest = artifactDirs(run, name => name === 'paid-plan').flatMap(dir => [path.join(dir, 'manifest.json')]).find(file => fs.existsSync(file));
|
|
if (!manifest) continue;
|
|
const mode = String(readJson(manifest).prCoverage?.mode ?? 'none');
|
|
modes.set(mode, (modes.get(mode) ?? 0) + 1);
|
|
}
|
|
const total = [...modes.values()].reduce((a, b) => a + b, 0);
|
|
if (total === 0) throw new Error('no paid-plan manifests in the window');
|
|
return { value: `${pct((modes.get('full-fallback') ?? 0) / total)} (${modes.get('full-fallback') ?? 0}/${total} runs)`,
|
|
detail: [...modes].map(([mode, count]) => `${mode}: ${count}`) };
|
|
}));
|
|
|
|
metrics.push(metric('pr-reused-trials', 'Reused PR trial records', 'trial-outcomes records with execution = reused across non-cancelled PR runs', () => {
|
|
const all = need(prRecords).flatMap(entry => entry.trials);
|
|
const reused = all.filter(record => record.execution === 'reused').length;
|
|
return { value: `${reused} of ${all.length} trial records` };
|
|
}));
|
|
|
|
metrics.push(metric('pr-spend', 'Recorded PR-lane spend', 'sum of executed trial cost_usd in PR runs; per merged PR = / PRs merged in the window; per validated revision = / distinct head SHAs with a success or failure verdict', () => {
|
|
const entries = need(prRecords);
|
|
const spend = entries.flatMap(entry => entry.trials).filter(record => record.execution === 'executed')
|
|
.reduce((sum, record) => sum + record.cost_usd, 0);
|
|
const revisions = new Set(entries.filter(entry => entry.run.conclusion === 'success' || entry.run.conclusion === 'failure').map(entry => entry.run.sha)).size;
|
|
const merged = listMergedPulls(client, repo, since);
|
|
return { value: `$${spend.toFixed(2)} recorded`, detail: [
|
|
`per merged PR: ${merged ? `$${(spend / merged).toFixed(2)} (${merged} merged)` : 'n/a (0 merged)'}`,
|
|
`per validated revision: ${revisions ? `$${(spend / revisions).toFixed(2)} (${revisions} revisions)` : 'n/a'}`] };
|
|
}));
|
|
|
|
metrics.push(metric('cancelled-slice-minutes', 'Cancelled slice-minutes per week', 'wall minutes of eval-slices jobs in cancelled evals.yml PR runs, scaled to 7 days', () => {
|
|
const cancelled = need(prRuns).filter(run => run.conclusion === 'cancelled');
|
|
const total = cancelled.flatMap(run => listRunJobs(client, repo, run.id, run.attempt))
|
|
.filter(job => job.name.startsWith('eval-slices')).reduce((sum, job) => sum + (span(job.startedAt, job.completedAt) ?? 0), 0);
|
|
return { value: `${(total / 60_000 * 7 / sinceDays).toFixed(0)} min/week (${cancelled.length} cancelled run(s))` };
|
|
}));
|
|
|
|
metrics.push(metric('push-to-verdict', 'PR lane push-to-verdict', 'created_at to updated_at of evals.yml pull_request runs that reached success or failure (includes queue and debounce)', () => {
|
|
const walls = need(prRuns).filter(run => run.conclusion === 'success' || run.conclusion === 'failure').map(run => span(run.createdAt, run.updatedAt)!);
|
|
if (walls.length === 0) throw new Error('no PR run reached a verdict in the window');
|
|
return { value: `p50 ${minutes(percentile(walls, 0.5))}, p95 ${minutes(percentile(walls, 0.95))} (${walls.length} runs)` };
|
|
}));
|
|
|
|
metrics.push(...censusMetrics(inputs, artifactDirs));
|
|
|
|
const flakes = attempt(() => readCiFlakeLedgers({ client, repo, sinceDays, now, cacheDir }));
|
|
metrics.push(metric('free-flaky-pass-rate', 'Free-suite flaky-pass rate', 'share of main free-tests runs (success or failure) with at least one flaky-pass ledger entry; per-file rate = runs with that file / runs', () => {
|
|
const window = need(flakes);
|
|
return { value: `${pct(window.runs ? window.flakyRuns / window.runs : 0)} (${window.flakyRuns}/${window.runs} main runs since ${window.since.slice(0, 10)} needed the retry pass; ${window.files.length} file(s))`,
|
|
detail: window.files.slice(0, 15).map(file => `${file.file}: ${file.flakyRuns}/${window.runs} runs (${pct(file.rate)})${file.failing ? ' FAIL > 5%' : ''}`) };
|
|
}));
|
|
|
|
metrics.push(metric('free-wall', 'Free-suite and Windows wall', 'p50 of run_started_at to updated_at for successful main runs of free-tests.yml and successful pull_request runs of windows-free-tests.yml (its workflow_dispatch duration recordings excluded)', () => {
|
|
const walls = (workflow: string, filter: { branch?: string; event?: string }) =>
|
|
listWorkflowRuns(client, repo, workflow, { since, ...filter }).filter(run => run.conclusion === 'success').map(run => span(run.startedAt, run.updatedAt)!);
|
|
const free = walls('free-tests.yml', { branch: 'main' });
|
|
const windows = walls('windows-free-tests.yml', { event: 'pull_request' });
|
|
const windowsValue = windows.length ? `${minutes(percentile(windows, 0.5))} (${windows.length} runs)` : 'no successful PR runs in window';
|
|
return { value: `free-tests ${minutes(percentile(free, 0.5))} (${free.length} runs); windows-free-tests (successful PR runs) ${windowsValue}` };
|
|
}));
|
|
|
|
metrics.push(metric('free-serial', 'Free-suite serial time', 'sum of scripts/free-test-durations.json (the committed seed)', () => {
|
|
const durations = loadFreeTestDurations(root);
|
|
if (!durations) throw new Error('scripts/free-test-durations.json is missing or unreadable');
|
|
return { value: `${Math.round(Object.values(durations).reduce((a, b) => a + b, 0) / 1000)} s over ${Object.keys(durations).length} files` };
|
|
}));
|
|
|
|
metrics.push(metric('net-complexity', 'Net complexity (CEO-32, reported, no target)', `lines under ${COMPLEXITY_PATHS.join(', ')} and package.json script count, HEAD vs --complexity-base`, () => {
|
|
const head = complexityAt(root, 'HEAD');
|
|
if (!inputs.complexityBase) return { value: `HEAD: ${head.loc} lines, ${head.scripts} package scripts (pass --complexity-base <ref> for the delta)` };
|
|
const base = complexityAt(root, inputs.complexityBase);
|
|
const sign = (n: number) => (n >= 0 ? `+${n}` : `${n}`);
|
|
return { value: `${sign(head.loc - base.loc)} lines (${base.loc} -> ${head.loc}), ${sign(head.scripts - base.scripts)} package scripts (${base.scripts} -> ${head.scripts}) vs ${inputs.complexityBase}` };
|
|
}));
|
|
|
|
const checks = [flakeCheck(flakes), unseededCheck(root)];
|
|
return { repo, sinceDays, generatedAt: new Date(now).toISOString(), metrics, checks, issueAction: issueActionFor(checks) };
|
|
}
|
|
|
|
function listMergedPulls(client: GhClient, repo: string, since: string): number {
|
|
let merged = 0;
|
|
for (let page = 1; page <= 10; page++) {
|
|
const pulls = client.getJson(`repos/${repo}/pulls?state=closed&base=main&sort=updated&direction=desc&per_page=100&page=${page}`) as any[];
|
|
if (!Array.isArray(pulls)) throw new Error('pulls listing returned no array');
|
|
merged += pulls.filter(pull => pull.merged_at && String(pull.merged_at) >= since).length;
|
|
if (pulls.length < 100 || pulls.every(pull => String(pull.updated_at) < since)) break;
|
|
}
|
|
return merged;
|
|
}
|
|
|
|
/**
|
|
* Census metrics from main's evals-periodic history (scheduled runs plus main
|
|
* dispatches, the W4b weekly history): CEO-06/22 per lane over the trailing 4
|
|
* scheduled censuses, the INFRA re-dispatch count, slice wall, and DX-12
|
|
* quarantine evidence (qualifying vs required trials) over the last 10 runs.
|
|
*/
|
|
function censusMetrics(inputs: HealthInputs, artifactDirs: (run: CiRun, match: (name: string) => boolean) => string[]): Metric[] {
|
|
const { client, repo, now } = inputs;
|
|
const history = attempt(() => listWorkflowRuns(client, repo, 'evals-periodic.yml',
|
|
{ since: new Date(now - Math.max(inputs.sinceDays, 84) * 86_400_000).toISOString(), branch: 'main' })
|
|
.filter(run => run.event === 'schedule' || run.event === 'workflow_dispatch'));
|
|
const need = <T>(value: T | Error): T => { if (value instanceof Error) throw value; return value; };
|
|
const censuses = attempt(() => {
|
|
const out: { run: CiRun; trials: TrialRecord[] }[] = [];
|
|
for (const run of need(history)) {
|
|
if (out.length === 4) break;
|
|
const trials = records(artifactDirs(run, name => name.startsWith('trial-outcomes')));
|
|
if (trials.length) out.push({ run, trials });
|
|
}
|
|
return out;
|
|
});
|
|
const out: Metric[] = [];
|
|
for (const lane of ['periodic', 'gate'] as const) {
|
|
out.push(metric(`census-${lane}`, `${lane === 'gate' ? 'Gate' : 'Periodic'} census reliability (CEO-06/22)`,
|
|
'trailing 4 main censuses with trial outcomes (scheduled runs and main dispatches): expected failed trials per run, raw trial failure rate per kind and class, and P(lane red) from pooled per-case rates under the recorded panels vs single-trial aggregation (every failure treated as nondeterminism: upper bounds for known-good inputs)', () => {
|
|
const runs = need(censuses);
|
|
const laneRuns = runs.filter(entry => entry.trials.some(record => (record.lane ?? '').startsWith(`${lane}/`)));
|
|
if (laneRuns.length === 0) throw new Error(`no main census with ${lane} trial outcomes in the last 84 days`);
|
|
const stats = censusStats(laneRuns.flatMap(entry => entry.trials).filter(record => (record.lane ?? '').startsWith(`${lane}/`)), laneRuns.map(entry => `${entry.run.id}`));
|
|
return { value: `${(stats.failed / stats.runs).toFixed(2)} expected failed trials/run (${stats.failed}/${stats.trials} over ${stats.runs} run(s)); P(red) ${pct(stats.redInForce)} in force, ${pct(stats.redSingleTrial)} single-trial`,
|
|
detail: [...Object.entries(stats.byKind).map(([kind, slot]) => `kind ${kind}: ${slot.failed}/${slot.trials} failed (${pct(slot.failed / Math.max(1, slot.trials))})`),
|
|
...Object.entries(stats.byClass).map(([cls, count]) => `class ${cls}: ${count}`)] };
|
|
}));
|
|
}
|
|
out.push(metric('census-redispatch', 'INFRA re-dispatches (ENG-10)', 'trailing 4 main census runs whose redispatch job succeeded; longest paid slice job wall in the same runs', () => {
|
|
const runs = need(history).slice(0, 4);
|
|
const jobs = runs.map(run => listRunJobs(client, repo, run.id, run.attempt));
|
|
const redispatched = jobs.filter(list => list.some(job => job.name === 'redispatch' && job.conclusion === 'success')).length;
|
|
const walls = jobs.flat().filter(job => /^(eval-slices|eval-codex-slices|gate-census)/.test(job.name)).map(job => span(job.startedAt, job.completedAt) ?? 0);
|
|
return { value: `${redispatched} of ${runs.length} census(es) re-dispatched; longest paid slice job ${minutes(walls.length ? Math.max(...walls) : null)}` };
|
|
}));
|
|
out.push(metric('quarantine-evidence', 'Quarantine evidence (DX-12)', `blocking cases with >= ${EVAL_POLICY.quarantine.entry.minTrials} qualifying v${EVAL_POLICY.version} trials on their current series, over the last 10 main census runs`, () => {
|
|
const runs = need(history).slice(0, 10);
|
|
const report = analyzePassRates(runs.flatMap(run => records(artifactDirs(run, name => name.startsWith('trial-outcomes')))), { weeklyRuns: runs.map(run => run.createdAt) });
|
|
const blocking = report.cases.filter(entry => entry.tier === 'gate' || entry.tier === 'periodic');
|
|
const sufficient = blocking.filter(entry => (entry.current?.trials ?? 0) >= EVAL_POLICY.quarantine.entry.minTrials);
|
|
const labels = new Map<string, number>();
|
|
for (const entry of sufficient) labels.set(entry.label, (labels.get(entry.label) ?? 0) + 1);
|
|
return { value: `${sufficient.length} of ${blocking.length} blocking case(s) have enough evidence; ${blocking.length - sufficient.length} insufficient evidence (not the same as healthy)`,
|
|
detail: [...[...labels].map(([label, count]) => `${label}: ${count}`),
|
|
...(report.newerPolicyTrials ? [`ignored ${report.newerPolicyTrials} newer-policy trial(s)`] : []),
|
|
...(report.olderPolicyTrials ? [`${report.olderPolicyTrials} older-policy trial(s) display only`] : [])] };
|
|
}));
|
|
return out;
|
|
}
|
|
|
|
// --- Rendering ---
|
|
|
|
export function formatHealth(report: HealthReport): string {
|
|
const lines = [`test:health ${report.repo}, last ${report.sinceDays} day(s), ${report.generatedAt}`];
|
|
for (const entry of report.metrics) {
|
|
lines.push(`- ${entry.title}: ${entry.unavailable ? `unavailable: ${entry.unavailable}` : entry.value}`);
|
|
lines.push(` rule: ${entry.rule}`);
|
|
for (const detail of entry.detail ?? []) lines.push(` ${detail}`);
|
|
}
|
|
lines.push('Enforced checks:');
|
|
for (const check of report.checks) {
|
|
lines.push(`- [${check.status.toUpperCase()}] ${check.title}: ${check.detail}`);
|
|
if (check.status !== 'pass') lines.push(` fix: ${check.fix}`);
|
|
}
|
|
const unavailable = report.checks.filter(check => check.status === 'unavailable').map(check => check.id);
|
|
if (unavailable.length) lines.push(`Missing inputs (an unavailable check never fails the run, and keeps a tracking issue open): ${unavailable.join(', ')}`);
|
|
return lines.join('\n');
|
|
}
|
|
|
|
export function formatHealthMarkdown(report: HealthReport): string {
|
|
return ['## test:health', '', '```', formatHealth(report), '```', ''].join('\n');
|
|
}
|
|
|
|
export const HELP = `Usage: bun run test:health [options]
|
|
bun run test:health flake-summary <dir>
|
|
|
|
Prints the test/eval/CI success metrics from GitHub Actions history (needs gh
|
|
with read access to the repository; \`gh auth status\` checks it). A metric whose
|
|
input cannot be read prints "unavailable: <reason>; next: <step>", never 0.
|
|
|
|
Options:
|
|
--since-days <n> window in days (default 7)
|
|
--enforce exit 1 when an enforced check fails (flaky file > 5% of
|
|
>= 20 main free-tests runs; > 5 unseeded free files);
|
|
without it the command always exits 0
|
|
--json machine-readable report (includes issueAction)
|
|
--summary <file> append a markdown report (job summary / issue body)
|
|
--repo <owner/name> repository (default: origin)
|
|
--complexity-base <ref> report the net-complexity delta against <ref>
|
|
--help this text
|
|
|
|
flake-summary <dir> markdown table of the flake-ledger-* artifacts under <dir>`;
|
|
|
|
if (import.meta.main) {
|
|
const argv = process.argv.slice(2);
|
|
if (argv.includes('--help') || argv.includes('-h')) { console.log(HELP); process.exit(0); }
|
|
if (argv[0] === 'flake-summary') {
|
|
if (!argv[1]) { console.error('usage: bun run test:health flake-summary <dir>'); process.exit(2); }
|
|
console.log(formatFlakeSummary(readFlakeLedgerDir(argv[1])));
|
|
process.exit(0);
|
|
}
|
|
const valued = new Set(['--since-days', '--summary', '--repo', '--complexity-base']);
|
|
const known = new Set([...valued, '--enforce', '--json']);
|
|
for (let index = 0; index < argv.length; index++) {
|
|
if (!known.has(argv[index]!)) { console.error(`test:health: unknown argument ${argv[index]}\n\n${HELP}`); process.exit(2); }
|
|
if (valued.has(argv[index]!)) {
|
|
if (argv[index + 1] === undefined || argv[index + 1]!.startsWith('--')) { console.error(`test:health: ${argv[index]} needs a value`); process.exit(2); }
|
|
index++;
|
|
}
|
|
}
|
|
const flag = (name: string) => { const index = argv.indexOf(name); return index === -1 ? undefined : argv[index + 1]; };
|
|
const sinceDays = Number(flag('--since-days') ?? 7);
|
|
if (!Number.isInteger(sinceDays) || sinceDays < 1) { console.error('test:health: --since-days must be a positive integer'); process.exit(2); }
|
|
const repo = flag('--repo') ?? repoSlug(ROOT);
|
|
const cacheDir = path.join(path.resolve(resolveStateRoot()), 'test-health-cache', repo.replace('/', '-'));
|
|
const report = collectHealth({ client: GH_CLIENT, repo, now: Date.now(), sinceDays, cacheDir, complexityBase: flag('--complexity-base') });
|
|
console.log(argv.includes('--json') ? JSON.stringify(report, null, 2) : formatHealth(report));
|
|
const summary = flag('--summary');
|
|
if (summary) fs.appendFileSync(summary, formatHealthMarkdown(report));
|
|
if (argv.includes('--enforce') && report.checks.some(check => check.status === 'fail')) process.exit(1);
|
|
}
|