From 565f2bfae772544dbc9fbfab9924ae4c0803ba98 Mon Sep 17 00:00:00 2001 From: Marcin Polewski Date: Sat, 31 Oct 2026 16:55:20 -0600 Subject: [PATCH 01/10] Plan step 1: Fail the smoke check when a committed baseline is missing --- evals/smoke.ts | 13 ++++++++----- test/smoke-eval.test.ts | 21 +++++++++++++++++++++ 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/evals/smoke.ts b/evals/smoke.ts index a428bf7..dcf8683 100644 --- a/evals/smoke.ts +++ b/evals/smoke.ts @@ -3,8 +3,9 @@ // node evals/smoke.ts check [--baseline ] // Compares the run with evals/baseline-smoke.json in eval:compare's table and fails when caught drops by more than // 2, when a must-not-flag concern was raised, or when the two cover different cases. With no baseline yet it fails -// only on a must-not-flag concern, with a notice. Writes the table to $GITHUB_STEP_SUMMARY (stdout outside -// Actions), and the run, trimmed to what compare reads, to proposed-baseline-smoke.json beside it. +// only on a must-not-flag concern, with a notice. Once one was committed, a missing one fails, naming the commit +// that deleted it. Writes the table to $GITHUB_STEP_SUMMARY (stdout outside Actions), and the run, trimmed to what +// compare reads, to proposed-baseline-smoke.json beside it. // node evals/smoke.ts scan // Fails when a file under holds one of the job's secrets or anything shaped like a credential, before the // workflow uploads the folder: artifacts on a public repository are public. @@ -40,7 +41,7 @@ function runTable(run: EvalOutput): string { // Model output reaches the summary only inside a code block, which a run of backticks could otherwise close. const fenced = (text: string) => `\`\`\`text\n${text.replace(/`{3,}/g, (ticks) => "'".repeat(ticks.length)).trimEnd()}\n\`\`\`\n`; -export function smokeCheck(run: EvalOutput, baseline: EvalOutput | undefined): SmokeVerdict { +export function smokeCheck(run: EvalOutput, baseline: EvalOutput | undefined, deletedIn?: string): SmokeVerdict { const failures: string[] = []; const notices: string[] = []; for (const c of run.cases) { @@ -56,6 +57,8 @@ export function smokeCheck(run: EvalOutput, baseline: EvalOutput | undefined): S const drop = baseline.summary.caught - run.summary.caught; if (drop > noiseLimit) failures.push(`Caught dropped by ${drop}, from ${baseline.summary.caught}/${baseline.summary.expected} to ${run.summary.caught}/${run.summary.expected}: more than the ${noiseLimit} a run varies by.`); } + } else if (deletedIn) { + failures.push(`${baselineFile} is missing, but commit ${deletedIn} deleted it: restore it, or adopt a new baseline from this run's ${proposedName} in a pull request.`); } else { notices.push(`There is no ${baselineFile} yet, so caught wasn't compared. This run is the proposed baseline: ${proposedName} in the smoke-eval artifact. The README's Smoke eval section says how to adopt it.`); } @@ -121,9 +124,9 @@ export function baselinePathFrom(argv: readonly string[], root: string): string } // Reads the run and the baseline, a missing one as none, checks them, and writes the proposed baseline beside the run. -export function checkFiles(runFile: string, baselinePath: string): SmokeVerdict { +export function checkFiles(runFile: string, baselinePath: string, deletedIn?: string): SmokeVerdict { const run = readOutput(runFile); - const verdict = smokeCheck(run, readBaseline(baselinePath)); + const verdict = smokeCheck(run, readBaseline(baselinePath), deletedIn); writeFileSync(path.join(path.dirname(runFile), proposedName), `${JSON.stringify(trimmed(run), null, 2)}\n`); return verdict; } diff --git a/test/smoke-eval.test.ts b/test/smoke-eval.test.ts index f2a6e4e..00d0fe6 100644 --- a/test/smoke-eval.test.ts +++ b/test/smoke-eval.test.ts @@ -193,6 +193,13 @@ test('smokeCheck passes with a notice and the run alone when there is no baselin assert.match(verdict.markdown, /Caught 1\/4; must-not violations 0; review cost \$2\.00, with judges \$2\.50\./); }); +test('smokeCheck fails when the baseline was committed once and is missing now', () => { + const verdict = smokeCheck(smokeOutput([smokeCase('must-not', 0, 0), smokeCase('large', 4, 4)]), undefined, 'abc1234'); + assert.deepEqual(verdict.failures, ["evals/baseline-smoke.json is missing, but commit abc1234 deleted it: restore it, or adopt a new baseline from this run's proposed-baseline-smoke.json in a pull request."]); + assert.deepEqual(verdict.notices, []); + assert.match(verdict.markdown, /\*\*Failed\.\*\*/); +}); + test('smokeCheck keeps model text inside its code block', () => { const run = smokeOutput([smokeCase('must-not', 0, 0, ['```\n# Injected']), smokeCase('large', 4, 4), smokeCase('small', 2, 2)]); const { markdown } = smokeCheck(run, baseline); @@ -286,6 +293,20 @@ test('checkFiles with no baseline file records no failure and writes the propose } }); +test('checkFiles fails a run whose missing baseline a commit deleted, and still writes the proposed baseline', () => { + const dir = mkdtempSync(path.join(tmpdir(), 'smoke-check-')); + try { + const runFile = path.join(dir, 'run.json'); + writeFileSync(runFile, JSON.stringify(regressed)); + const verdict = checkFiles(runFile, path.join(dir, 'missing-baseline.json'), 'abc1234'); + assert.equal(verdict.failures.length, 1); + assert.match(verdict.failures[0] ?? '', /commit abc1234 deleted it/); + assert.ok(existsSync(path.join(dir, proposedName))); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + test('checkFiles records a failure for a run that regressed from the baseline file, and none for a run that matches it', () => { const dir = mkdtempSync(path.join(tmpdir(), 'smoke-check-')); try { From 2d3f8cf1e91e5e7c59d11cb9d9aeb23b93a2627f Mon Sep 17 00:00:00 2001 From: Marcin Polewski Date: Sat, 31 Oct 2026 16:56:03 -0600 Subject: [PATCH 02/10] Plan step 2: Read the baseline's history in the smoke check --- evals/smoke.ts | 16 ++++++++++++++-- test/smoke-eval.test.ts | 35 +++++++++++++++++++++++++++++++++-- 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/evals/smoke.ts b/evals/smoke.ts index dcf8683..21afb20 100644 --- a/evals/smoke.ts +++ b/evals/smoke.ts @@ -10,7 +10,8 @@ // Fails when a file under holds one of the job's secrets or anything shaped like a credential, before the // workflow uploads the folder: artifacts on a public repository are public. -import { appendFileSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { appendFileSync, existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { assertNoSecrets } from '../src/outputs.ts'; @@ -131,13 +132,24 @@ export function checkFiles(runFile: string, baselinePath: string, deletedIn?: st return verdict; } +// The last commit that changed baselineFile in root's history, or undefined when none did. A shallow checkout can't +// tell, so it throws rather than pass a deleted baseline. +export function lastBaselineCommit(root: string): string | undefined { + const git = (...args: string[]) => execFileSync('git', ['-C', root, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); + if (git('rev-parse', '--is-shallow-repository') === 'true') throw new Error(`The checkout is shallow, so it can't tell whether ${baselineFile} was deleted; check out with fetch-depth: 0.`); + return git('log', '-1', '--format=%h', '--', baselineFile) || undefined; +} + function main(): void { const [command, target] = process.argv.slice(2); const annotate = (kind: 'notice' | 'error', message: string) => console.log(process.env['GITHUB_ACTIONS'] === 'true' ? `::${kind} title=Smoke eval::${commandData(message)}` : `${kind}: ${message}`); if (command === 'check' && target) { const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); - const verdict = checkFiles(path.resolve(target), baselinePathFrom(process.argv, root)); + const baselinePath = baselinePathFrom(process.argv, root); + // Only the committed baseline has a history; a --baseline file is the caller's. + const deletedIn = baselinePath === path.join(root, baselineFile) && !existsSync(baselinePath) ? lastBaselineCommit(root) : undefined; + const verdict = checkFiles(path.resolve(target), baselinePath, deletedIn); const summaryFile = process.env['GITHUB_STEP_SUMMARY']; if (summaryFile) appendFileSync(summaryFile, verdict.markdown); else process.stdout.write(verdict.markdown); diff --git a/test/smoke-eval.test.ts b/test/smoke-eval.test.ts index 00d0fe6..8bbbc6f 100644 --- a/test/smoke-eval.test.ts +++ b/test/smoke-eval.test.ts @@ -1,14 +1,15 @@ import assert from 'node:assert/strict'; -import { spawnSync } from 'node:child_process'; +import { execFileSync, spawnSync } from 'node:child_process'; import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { test } from 'node:test'; +import { pathToFileURL } from 'node:url'; import type { EvalCase } from '../evals/cases.ts'; import type { EvalOutput } from '../evals/compare.ts'; import type { CasePrecision, RealityJudge } from '../evals/precision.ts'; import { capOutcome, caseIssues, casePrecision, casePullRequest, maxCostFrom, parseMaxCost, parsePrecision, runCapped, selectCases, tallyCase, type CaseTally } from '../evals/run.ts'; -import { baselinePathFrom, checkFiles, commandData, proposedName, scanDir, smokeCheck, trimmed } from '../evals/smoke.ts'; +import { baselinePathFrom, checkFiles, commandData, lastBaselineCommit, proposedName, scanDir, smokeCheck, trimmed } from '../evals/smoke.ts'; import type { CallRecord } from '../src/agent.ts'; import type { Finding } from '../src/review.ts'; import { SecretFoundError } from '../src/outputs.ts'; @@ -307,6 +308,36 @@ test('checkFiles fails a run whose missing baseline a commit deleted, and still } }); +// Runs git, since what the check reads is a real repository's history. +test('lastBaselineCommit names the commit that deleted the baseline, none before one was committed, and fails on a shallow checkout', () => { + const dir = mkdtempSync(path.join(tmpdir(), 'smoke-history-')); + // Without the machine's own git configuration, so a default branch name, hooks or signing can't change the result. + const git = (cwd: string, ...args: string[]) => execFileSync('git', ['-C', cwd, '-c', 'user.name=t', '-c', 'user.email=t@example.com', '-c', 'commit.gpgsign=false', '-c', 'init.defaultBranch=main', ...args], { + encoding: 'utf8', + env: { PATH: process.env['PATH'] ?? '', ...(process.env['SystemRoot'] ? { SystemRoot: process.env['SystemRoot'] } : {}), HOME: dir, USERPROFILE: dir, XDG_CONFIG_HOME: dir, GIT_CONFIG_NOSYSTEM: '1' }, + }); + try { + const repo = path.join(dir, 'repo'); + mkdirSync(path.join(repo, 'evals'), { recursive: true }); + git(repo, 'init', '-q'); + writeFileSync(path.join(repo, 'README.md'), 'r\n'); + git(repo, 'add', '.'); + git(repo, 'commit', '-q', '-m', 'first'); + assert.equal(lastBaselineCommit(repo), undefined); + writeFileSync(path.join(repo, 'evals', 'baseline-smoke.json'), '{}\n'); + git(repo, 'add', '.'); + git(repo, 'commit', '-q', '-m', 'adopt'); + git(repo, 'rm', '-q', 'evals/baseline-smoke.json'); + git(repo, 'commit', '-q', '-m', 'delete'); + assert.equal(lastBaselineCommit(repo), git(repo, 'rev-parse', '--short', 'HEAD').trim()); + const shallow = path.join(dir, 'shallow'); + git(dir, 'clone', '-q', '--depth', '1', pathToFileURL(repo).href, shallow); + assert.throws(() => lastBaselineCommit(shallow), /The checkout is shallow/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + test('checkFiles records a failure for a run that regressed from the baseline file, and none for a run that matches it', () => { const dir = mkdtempSync(path.join(tmpdir(), 'smoke-check-')); try { From a871ff36b064bca8e8cd21bbfa9bceb77853283c Mon Sep 17 00:00:00 2001 From: Marcin Polewski Date: Sat, 31 Oct 2026 16:56:41 -0600 Subject: [PATCH 03/10] Plan step 3: Check out the full history and test the workflow's order --- .github/workflows/smoke-eval.yml | 5 +++- README.md | 2 +- test/smoke-eval.test.ts | 46 ++++++++++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 2 deletions(-) diff --git a/.github/workflows/smoke-eval.yml b/.github/workflows/smoke-eval.yml index 8a2acf6..03c5cef 100644 --- a/.github/workflows/smoke-eval.yml +++ b/.github/workflows/smoke-eval.yml @@ -3,7 +3,8 @@ # # It runs the smoke subset in evals/cases.ts with the production guidance, stops and fails past $8, and compares the run # with evals/baseline-smoke.json in the job summary: it fails when caught drops by more than 2 or the must-not case is -# flagged. With no baseline yet, the run is uploaded as the proposed one and the job passes with a notice. +# flagged. Until a baseline is first committed, the run is uploaded as the proposed one and the job passes with a notice; +# once one has been, a run without it fails. # # It uses the organization's Bedrock key in the code-review environment, which only the default branch can use. Never # add a pull_request trigger: the key would then run code from any pull request. @@ -41,6 +42,8 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + # The full history, so the check can tell a deleted baseline from one never committed. + fetch-depth: 0 persist-credentials: false - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: diff --git a/README.md b/README.md index 9aac71d..af33ecc 100644 --- a/README.md +++ b/README.md @@ -488,7 +488,7 @@ Each case's pull request is built as the review step builds it: `author` and `au **Capping the cost.** `--max-cost ` stops a run once its cost, judges included, passes that many dollars: the case that crossed it finishes, the rest don't start, the output covers the cases that ran, and the run fails. `--subset ` runs a named set of cases from `evals/cases.ts` instead of `--only id,id`; a name that matches no case fails the run. -**Smoke eval.** The [Smoke eval](.github/workflows/smoke-eval.yml) workflow runs on every push to `main` that changes `prompts/`, `reviewers/`, `src/config.ts` or `guidance/`, and by hand (`gh workflow run smoke-eval.yml`). It runs in the `code-review` environment with the organization's Bedrock key, so it never runs on a pull request. It runs the `smoke` subset, 4 cases with the must-not case and one large diff, with the production guidance, `--precision off` and `--max-cost 8`. `node evals/smoke.ts check` then compares the run with `evals/baseline-smoke.json`, posts the comparison to the job summary, and fails when caught drops by more than 2, when the must-not case is flagged, or when the two cover different cases. `evals/out` is scanned for secrets before it's uploaded as the `smoke-eval` artifact, since artifacts on a public repository are public. With no baseline yet, the run passes with a notice, failing only on a flagged must-not case. To adopt a run as the baseline, the first time or after an intended change in what the smoke cases catch or a change to the subset, take `proposed-baseline-smoke.json` from that run's artifact, which is the run without its agent calls and raw findings, and commit it in a pull request: +**Smoke eval.** The [Smoke eval](.github/workflows/smoke-eval.yml) workflow runs on every push to `main` that changes `prompts/`, `reviewers/`, `src/config.ts` or `guidance/`, and by hand (`gh workflow run smoke-eval.yml`). It runs in the `code-review` environment with the organization's Bedrock key, so it never runs on a pull request. It runs the `smoke` subset, 4 cases with the must-not case and one large diff, with the production guidance, `--precision off` and `--max-cost 8`. `node evals/smoke.ts check` then compares the run with `evals/baseline-smoke.json`, posts the comparison to the job summary, and fails when caught drops by more than 2, when the must-not case is flagged, or when the two cover different cases. `evals/out` is scanned for secrets before it's uploaded as the `smoke-eval` artifact, since artifacts on a public repository are public. Until `evals/baseline-smoke.json` is first committed, the run passes with a notice, failing only on a flagged must-not case. Once it has been, a run without it fails and names the commit that deleted it, so deleting the file can't turn the gate off; the workflow checks out the full history so the check can tell, and the check fails on a shallow checkout. To adopt a run as the baseline, the first time or after an intended change in what the smoke cases catch or a change to the subset, take `proposed-baseline-smoke.json` from that run's artifact, which is the run without its agent calls and raw findings, and commit it in a pull request: ```powershell gh run download -n smoke-eval -D evals/out/smoke diff --git a/test/smoke-eval.test.ts b/test/smoke-eval.test.ts index 8bbbc6f..9100c72 100644 --- a/test/smoke-eval.test.ts +++ b/test/smoke-eval.test.ts @@ -390,6 +390,52 @@ test('the smoke eval workflow runs only on main and by hand, in the code-review for (const line of uses) assert.match(line, /^uses: [\w.-]+\/[\w.-]+@[0-9a-f]{40} # v\d+\.\d+\.\d+$/, line); }); +// The steps of the smoke eval workflow, read just far enough to see each step's name, id, if, uses and run. +interface WorkflowStep { name: string; id: string; if: string; uses: string; run: string; text: string } +function workflowSteps(): WorkflowStep[] { + const lines = readFileSync(path.join(root, '.github', 'workflows', 'smoke-eval.yml'), 'utf8').replace(/\r\n/g, '\n').split('\n'); + const steps: WorkflowStep[] = []; + for (const line of lines.slice(lines.indexOf(' steps:') + 1)) { + if (line.startsWith(' - ')) steps.push({ name: '', id: '', if: '', uses: '', run: '', text: '' }); + const step = steps.at(-1); + if (!step || line.trimStart().startsWith('#')) continue; + step.text += `${line}\n`; + const field = /^ {6}(?:- | {2})([\w-]+): ?(.*)$/.exec(line); + if (!field) continue; + const [, key = '', value = ''] = field; + if (key === 'name' || key === 'id' || key === 'if' || key === 'uses' || key === 'run') step[key] = value; + } + return steps; +} + +test('the smoke eval workflow uploads the outputs only after the secret scan passes', () => { + const steps = workflowSteps(); + const scan = steps.findIndex((s) => s.id === 'scan'); + const upload = steps.findIndex((s) => s.uses.startsWith('actions/upload-artifact@')); + assert.ok(scan >= 0 && upload >= 0, 'a scan step and an upload step'); + assert.ok(scan < upload, 'the scan runs before the upload'); + assert.equal(steps[scan]!.if, '${{ !cancelled() }}'); + assert.equal(steps[scan]!.run, 'node evals/smoke.ts scan evals/out'); + assert.ok(steps[scan]!.text.includes('AWS_BEARER_TOKEN_BEDROCK: ${{ secrets.AWS_BEARER_TOKEN_BEDROCK }}'), 'the scan knows the Bedrock key'); + assert.equal(steps[upload]!.if, "${{ !cancelled() && steps.scan.outcome == 'success' }}"); + assert.ok(steps[upload]!.text.includes('path: evals/out/'), 'the upload takes the folder the scan read'); + assert.equal(upload, steps.length - 1, 'nothing runs after the upload'); +}); + +test('the smoke eval workflow fails the job when the run or the check fails, and checks out the full history', () => { + const steps = workflowSteps(); + for (const step of steps) assert.doesNotMatch(step.text, /continue-on-error/, step.name || step.uses || step.run); + const run = steps.findIndex((s) => s.name === 'Run the smoke subset'); + const compare = steps.findIndex((s) => s.name === 'Compare with the baseline'); + assert.ok(run >= 0 && compare > run, 'the check runs after the run'); + assert.equal(steps[run]!.if, ''); + assert.ok(steps[run]!.run.includes('--label "$LABEL"'), steps[run]!.run); + assert.equal(steps[compare]!.if, ''); + assert.equal(steps[compare]!.run, 'node evals/smoke.ts check "evals/out/$LABEL.json"'); + assert.ok(steps[0]!.uses.startsWith('actions/checkout@'), steps[0]!.uses); + assert.ok(steps[0]!.text.includes('fetch-depth: 0'), 'the full history'); +}); + const evalCase: EvalCase = { id: 'x', repository: 'o/r', pullRequest: 5, title: 'T', body: 'Closes #3 and fixes #4', base: 'b'.repeat(40), head: 'h'.repeat(40), expect: [], mustNotFlag: [], source: '', From 123b14f34ab09152ae7992cd8c15692dbade42a6 Mon Sep 17 00:00:00 2001 From: Marcin Polewski Date: Sat, 31 Oct 2026 16:57:10 -0600 Subject: [PATCH 04/10] Plan step 4: Write the eval output before failing a capped run, in a tested function --- evals/run.ts | 14 +++++++++++--- test/smoke-eval.test.ts | 23 ++++++++++++++++++++++- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/evals/run.ts b/evals/run.ts index 95c4b3d..827293a 100644 --- a/evals/run.ts +++ b/evals/run.ts @@ -262,6 +262,16 @@ export function tallyCase( // How the output names a finding that raised a concern. const raisedLabel = (finding: Finding & { reviewers?: string[] }) => `${finding.file}:${finding.line} (${(finding.reviewers ?? []).join(', ')})`; +// Writes the run's output to /