The fix must make a test fail first: reverts your source changes, runs the changed tests and requires a failure, restores (verified by hash) and requires a…

█▀█ █▀█ █▀█ █ █ █▀▀ ▄▄ █ ▀█▀ ▀▀
█▀▀ █▀▄ █▄█ ▀▄▀ ██▄ █ █ ▀▀▀▀▀▀▀▀ THE FIX MUST MAKE
▄▀▀▀▀▄ A TEST FAIL FIRST
▄▀▀ ▀▀▄

A test that passes with the fix removed proves nothing. prove-it puts your changed source files back to how they were at the merge base, runs the changed tests and requires them to fail, puts your files back (checked by hash), runs the tests again and requires them to pass.
The problem, in one line: 64 memory notes in the study behind this library were verification traps: gates that ran zero times, "proofs" that checked nothing, and tests that passed with the fix removed.
/plugin marketplace add pourya7/claude-code-mods
/plugin install prove-it@claude-code-mods
Then set testCommand in /config (for example npm test -- or pytest) and run /prove.
HEAD and the base option (by default origin's default branch, then origin/main, origin/master, main, master). Changed files are git diff --no-renames --name-only <merge base> (tracked, committed or not) plus untracked, not-ignored files. With --no-renames, a renamed file counts as its old path deleted and its new path added, so the run without the fix has the old file back. Files matching testGlobs are the tests. The tracked rest (or only those matching sourceGlobs) is the fix. Untracked files only ever count as tests.prove-it.lock in the repository's git dir (made with mkdir, so only one proof gets it), and removes it when the proof ends. A second proof of the same working tree, from this session or another, stops with a proof is already running in this repository before it touches anything. Each changed source file is copied with cp -p to $TMPDIR/prove-it-<time>/<path>, and each copy is checked against the original with git hash-object. If any copy does not match, prove-it stops before it touches anything.git show <base>:<path>. A file the fix added is removed, and a file the fix deleted comes back. Then the test command runs with the changed test files as arguments. It must fail (exit code not 0).RESTORE FAILED and tells you where the copies are.prove-it never runs git stash, git checkout or git restore, and it never deletes the copies in the temp dir.
| Verdict | Meaning | Gate |
|---|---|---|
PROVEN ★ | The tests fail without the fix and pass with it. | lets through |
NOT PROVEN | The tests pass without the fix, so they do not test it. | refuses |
BROKEN | The tests fail with the fix. | refuses |
NO TESTS CHANGED | Source changed but no test file did. | refuses |
TESTS PASS | Only tests changed and they pass. There is no fix to revert. | lets through |
NOTHING TO PROVE | Nothing changed against the base. | lets through |
ERROR | git or the test command could not run. Your files were restored. | refuses |
RESTORE FAILED | A restored file did not match its copy. The copies are kept in the temp dir. | refuses |
| Command | What it does |
|---|---|
/prove or /prove run | Runs the proof now and replies with the verdict, the files and the last lines of both runs. The model reads the same reply. In claude -p "/prove" it exits 0 when the gate would let the change through, 1 otherwise. |
/prove status | Shows the last proof without running anything. |
/prove skip | Lets the next git push or gh pr create through without a proof, once. |
With gate on, a Bash call that runs git push or gh pr create (or its alias gh pr new), in the main session or a subagent, runs the proof first. The usual spellings are caught: git's global options (git -C dir push, git --git-dir .git push), gh -R owner/repo pr create, wrappers such as command, env, sudo, time and nohup, a path such as /usr/bin/git, line continuations, bash -c "...", eval '...', $(...) and backticks. Other quoted text is ignored, so echo "git push" is not gated. The gate is a guard rail, not a security boundary: a git alias, a script that pushes, or a command line built at run time gets past it. A refused call returns the verdict, the reason and the way out to the model (prove-it refused git push: NOT PROVEN. ...), and you get a toast. If the change has not moved since a proof with the same result (same HEAD, same diff, same untracked tests, same test command), that proof is reused, so a push followed by a PR create runs the tests once. An ERROR or RESTORE FAILED proof is never reused.
Set them in /config or under pluginConfigs.prove-it.options in settings.
| Option | Default | Meaning |
|---|---|---|
testCommand | (empty) | The command that runs tests, split on blanks (quotes group) and run without a shell at the repository root, with the changed test files appended. Empty means /prove says to set it. |
testGlobs | **/*.test.*, **/*_test.*, **/test_*.py, tests/** | Comma-separated globs, relative to the repository root, that mark a changed file as a test. * stays inside one folder, ** crosses folders. |
sourceGlobs | (empty) | Comma-separated globs for the files that make up the fix. Empty means every changed tracked file that is not a test. Use it to leave docs and config alone, e.g. src/, lib/. |
base | (empty) | The ref the change is measured against. Empty means detect it (see above). |
gate | false | Run the proof before git push and gh pr create and refuse them unless the change is proven. |
timeoutSeconds | 300 | How long each test run may take before it counts as an ERROR. At most 600. |
In the terminal the sprites are drawn in PICO-8 colours: a gold star for PROVEN ★, a red cross for NOT PROVEN and BROKEN, a test tube bubbling red while the proof runs, and a cracked tube spilling orange for ERROR. This text capture loses the colours.
The band above the prompt while the tests run:
▶ PROVING... PROVE-IT
▀▀▀▀ 1 WITHOUT THE FIX ◆
▄▀▀▄▀ 2 WITH THE FIX ·
▄▀▄▀▄▄▀▄ YOUR FILES COME BACK EITHER WAY
▀▀▀▀▀▀▀▀
The band after a proof:
▶ PROVEN ★ PROVE-IT
▀▀ WITHOUT THE FIX × FAIL 1
▀▀▀▀▀▀▀▀ WITH THE FIX ★ PASS
▄▀▀▀▀▄ 1 SOURCE · 1 TEST · BASE a1b2c3d
▄▀▀ ▀▀▄ [ OK ] [ AGAIN ]
▶ NOT PROVEN PROVE-IT
▀▀▄ ▄▀▀ WITHOUT THE FIX ★ PASS
▀▀▀▀▀▀ WITH THE FIX ★ PASS
▄▀▀▀▀▄ 2 SOURCE · 1 TEST · BASE a1b2c3d
▀▀▀ ▀▀▀ [ OK ] [ AGAIN ]
OK hides the band until the next proof. AGAIN runs the proof again.
The /prove reply:
PROVE-IT ▸ PROVEN ★
The changed tests fail without the fix and pass with it.
fix: src/add.ts
tests: src/add.test.ts
without the fix: FAIL (exit 1)
1 failing
adds: expected 3, got -1
with the fix: PASS
1 passing
base: a1b2c3d
The status line reads PROVE-IT ★ PROVEN, PROVE-IT ▸ NOT PROVEN, PROVE-IT ▸ PROVING 1/2 while it runs, or PROVE-IT ▸ GATE ARMED before the first proof when the gate is on. · GATE is added after a verdict while the gate is on.
| Network | Runs processes | Files | Calls a model | Auto-submits prompts | Data leaving the machine |
|---|---|---|---|---|---|
| None of its own. | Only on /prove, AGAIN, or a gated git push / gh pr create: git (rev-parse, symbolic-ref, merge-base, diff, ls-files, show, hash-object), mkdir and rmdir of the lock folder in the git dir, mkdir -p and cp -p into the temp dir, cp -p back, rm -f on a changed source file the fix added (after its copy is checked) or that the fix deleted (after the run without the fix), and your testCommand, twice. | Reads the TMPDIR variable. Writes the base versions of the changed source files into your working tree for the run without the fix, then copies your versions back and checks them by hash. Writes copies of those files to $TMPDIR/prove-it-<time>/ and never deletes them. Makes and removes the empty folder prove-it.lock in the git dir while a proof runs. Keeps the last proof in session state. | No | No | None of its own. Your test command does whatever it does. |
INTERRUPTED · YOUR FILES ARE BACK and keeps the band and status line up until the restore is done. The restore is quick, but it can take a moment after the session carries on, so wait for the toast before you edit a changed file. The test command itself cannot be stopped from a plugin and runs on to its end or its timeout, now against your restored files. A proof started with AGAIN has no interrupt.$TMPDIR/prove-it-<time>/ folder; prove-it does not put them back on its own. The lock folder stays behind too, so the next proof refuses to run and says where to look; put your files back, then remove prove-it.lock from the git dir (git rev-parse --absolute-git-dir prints it).without the fix lines if that matters.go test) needs a small wrapper script as testCommand.git show, so a changed binary source file may not revert byte for byte. Your own version is always restored from the byte-for-byte copy and checked by hash.cd ../other && git push is checked against the session's repository, not ../other.hooks/register.tsx 268 lines1// prove-it: the fix must make a test fail first. /prove reverts the changed
2// sources to the merge base, requires the changed tests to fail, restores the
3// sources (verified by hash) and requires them to pass. With the gate on, the
4// same proof runs before git push and gh pr create.
5import { atom, read, update } from 'claude-code'
6import type { EngineInterface, Register } from 'claude-code'
7
8import type { ProvePhase, ProveProof } from '../types'
9import { DEFAULT_TEST_GLOBS, parseGlobList, splitCommand } from './files'
10import { CRACKED, CROSS, FLASK, PALETTE, STAR, pixelRows } from './pixels'
11import type { Run } from './pixels'
12import { INTERRUPTED, emptyProof, runProof, survey } from './prove'
13import type { Host, ProveSettings } from './prove'
14import { denyText, gateAllows, gatedCommand, replyText, statusLine, verdictLabel } from './verdict'
15
16type Dollar = EngineInterface
17
18const MAX_TIMEOUT_SECONDS = 600
19const DEFAULT_TIMEOUT_SECONDS = 300
20
21const proofAtom = atom({ plugin: 'prove-it', key: 'proof' } as const, null as ProveProof | null)
22const phaseAtom = atom({ plugin: 'prove-it', key: 'phase' } as const, 'idle' as ProvePhase)
23/** Who holds the run: '' when nobody does, else the holder's own token. */
24const ownerAtom = atom({ plugin: 'prove-it', key: 'owner' } as const, '')
25const skipAtom = atom({ plugin: 'prove-it', key: 'skipNext' } as const, false)
26const hiddenAtom = atom({ plugin: 'prove-it', key: 'isBandHidden' } as const, false)
27
28type Options = Record<string, unknown>
29
30const timeoutSecondsOf = (value: unknown): number => {
31 const seconds = typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : DEFAULT_TIMEOUT_SECONDS
32 return Math.min(MAX_TIMEOUT_SECONDS, seconds)
33}
34
35const settingsOf = (options: Options, tmpDir: string): ProveSettings => {
36 const testGlobs = parseGlobList(options.testGlobs)
37 return {
38 testCommand: splitCommand(typeof options.testCommand === 'string' ? options.testCommand : ''),
39 testGlobs: testGlobs.length > 0 ? testGlobs : [...DEFAULT_TEST_GLOBS],
40 sourceGlobs: parseGlobList(options.sourceGlobs),
41 base: typeof options.base === 'string' ? options.base.trim() : '',
42 timeoutMs: timeoutSecondsOf(options.timeoutSeconds) * 1000,
43 tmpDir,
44 }
45}
46
47const hostOf = ($: Dollar, isGateOn: boolean, signal: AbortSignal | undefined): Host => ({
48 run: (argv, init) => $.process.run(argv, init),
49 write: (path, text) => $.fs.write(path, text),
50 exists: path => $.fs.exists(path),
51 onPhase: async phase => {
52 await update($, phaseAtom, () => phase)
53 await update($, hiddenAtom, () => false)
54 $.ui.status(statusLine(await read($, proofAtom), phase, isGateOn))
55 },
56 signal,
57})
58
59let runCount = 0
60
61const PROBLEM_VERDICTS = new Set(['restore-failed', 'error'])
62
63/**
64 * Proves the change as it stands. A proof already made for the same
65 * fingerprint is reused unless it was an error, so a push followed by a PR
66 * create runs the tests once.
67 */
68const proveNow = async (
69 $: Dollar,
70 options: Options,
71 isGateOn: boolean,
72 reuse: boolean,
73 signal?: AbortSignal,
74): Promise<ProveProof> => {
75 const at = await $.clock.now()
76 // Claim the run in one update, so two proofs never revert the same files at
77 // once; only this token releases it, so a claim is never let go early or by another run.
78 runCount += 1
79 const token = `${at}:${runCount}:${Math.random()}`
80 let isMine = false as boolean
81 await update($, ownerAtom, owner => {
82 isMine = owner === ''
83 return isMine ? token : owner
84 })
85 if (!isMine) return emptyProof('error', 'a proof is already running; wait for it to finish', at)
86
87 try {
88 await update($, phaseAtom, () => 'without' as ProvePhase)
89 const tmpDir = (await $.env.get('TMPDIR')) ?? ''
90 const settings = settingsOf(options, tmpDir === '' ? '/tmp' : tmpDir)
91 const host = hostOf($, isGateOn, signal)
92 const cwd = await $.session.cwd()
93
94 const surveyed = await survey(host, cwd, settings, at)
95 const last = await read($, proofAtom)
96 const isSame =
97 reuse &&
98 !('verdict' in surveyed) &&
99 last !== null &&
100 last.fingerprint === surveyed.fingerprint &&
101 !PROBLEM_VERDICTS.has(last.verdict)
102 const proof =
103 'verdict' in surveyed ? surveyed : isSame && last !== null ? last : await runProof(host, surveyed, settings, at)
104
105 await update($, proofAtom, () => proof)
106 await update($, hiddenAtom, () => false)
107 if (proof.verdict === 'restore-failed') $.ui.toast(`PROVE-IT ▸ RESTORE FAILED · copies in ${proof.copiesDir}`)
108 else if (proof.detail === INTERRUPTED) $.ui.toast('PROVE-IT ▸ INTERRUPTED · YOUR FILES ARE BACK')
109
110 return proof
111 } finally {
112 let isReleased = false as boolean
113 await update($, ownerAtom, owner => {
114 isReleased = owner === token
115 return isReleased ? '' : owner
116 })
117 if (isReleased) {
118 await update($, phaseAtom, () => 'idle' as ProvePhase)
119 $.ui.status(statusLine(await read($, proofAtom), 'idle', isGateOn))
120 }
121 }
122}
123
124const USAGE = 'Usage: /prove [run|status|skip]'
125
126export const register: Register = (on, options) => {
127 const isGateOn = options.gate === true
128
129 on('session.start', async ($, e, next) => {
130 try {
131 await $.command.register({
132 name: 'prove',
133 description: 'Prove the fix: the changed tests must fail without it and pass with it',
134 argumentHint: '[run|status|skip]',
135 })
136 } catch {
137 $.ui.toast('PROVE-IT: /prove could not be registered')
138 }
139 // A run cannot outlive a reload of this module: clear a claim and phase one left behind.
140 await update($, ownerAtom, () => '')
141 await update($, phaseAtom, () => 'idle' as ProvePhase)
142 $.ui.status(statusLine(await read($, proofAtom), 'idle', isGateOn))
143
144 return next(e)
145 })
146
147 on('command.run', { command: 'prove' }, async ($, e, next) => {
148 const word = e.args.trim().toLowerCase()
149 if (word === 'skip') {
150 await update($, skipAtom, () => true)
151 $.ui.toast('PROVE-IT ▸ SKIP ARMED')
152 return { text: 'PROVE-IT ▸ the next push or PR goes through without a proof.' }
153 }
154 if (word === 'status') {
155 const proof = await read($, proofAtom)
156 const skip = (await read($, skipAtom)) ? '\nThe next push or PR is skipped.' : ''
157 return { text: `${proof === null ? 'PROVE-IT ▸ no proof yet. Run /prove.' : replyText(proof)}${skip}` }
158 }
159 if (word !== '' && word !== 'run') return { text: USAGE }
160
161 const proof = await proveNow($, options, isGateOn, false, next.signal)
162 const text = replyText(proof)
163 return { text, context: [text], exitCode: gateAllows(proof.verdict) ? 0 : 1 }
164 })
165
166 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
167 const command = isGateOn ? gatedCommand(e.command) : null
168 if (command === null) return next(e)
169
170 if (await read($, skipAtom)) {
171 await update($, skipAtom, () => false)
172 $.ui.toast(`PROVE-IT ▸ SKIPPED FOR ${command.toUpperCase()}`)
173 return next(e)
174 }
175
176 const proof = await proveNow($, options, isGateOn, true, next.signal)
177 if (gateAllows(proof.verdict)) return next(e)
178
179 $.ui.toast(`PROVE-IT ▸ ${verdictLabel(proof.verdict)} · ${command.toUpperCase()} REFUSED`)
180 return { deny: denyText(proof, command) }
181 })
182
183 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
184 if (e.props.hasSurvey || e.props.view.agentId !== undefined) return next(e)
185 const phase = await read($, phaseAtom)
186 const proof = await read($, proofAtom)
187 const isRunning = phase !== 'idle'
188 if (!isRunning && (proof === null || (await read($, hiddenAtom)))) return next(e)
189
190 const { Box, Button, Text } = $.ui.resolve(e)
191 const verdict = proof?.verdict ?? 'error'
192 const isGood = !isRunning && gateAllows(verdict)
193 const sprite = isRunning
194 ? FLASK
195 : verdict === 'proven'
196 ? STAR
197 : verdict === 'error' || verdict === 'restore-failed'
198 ? CRACKED
199 : isGood
200 ? STAR
201 : CROSS
202 const pixels = pixelRows(sprite).map(runs => (
203 <Box flexDirection="row">
204 {runs.map((cell: Run) => (
205 <Text color={cell.color} backgroundColor={cell.backgroundColor}>
206 {cell.text}
207 </Text>
208 ))}
209 </Box>
210 ))
211
212 const title = isRunning ? 'PROVING...' : verdictLabel(verdict)
213 const titleColor = isRunning ? PALETTE.o : isGood ? PALETTE.i : PALETTE.r
214 const runText = (exitCode: number | undefined) =>
215 exitCode === undefined ? '-' : exitCode === 0 ? '★ PASS' : `× FAIL ${exitCode}`
216 const lines = isRunning
217 ? [
218 <Text color={phase === 'without' ? PALETTE.y : PALETTE.l}>1 WITHOUT THE FIX {phase === 'without' ? '◆' : '★'}</Text>,
219 <Text color={phase === 'with' ? PALETTE.y : PALETTE.d}>2 WITH THE FIX {phase === 'with' ? '◆' : '·'}</Text>,
220 <Text color={PALETTE.d}>YOUR FILES COME BACK EITHER WAY</Text>,
221 ]
222 : [
223 <Box key="without">
224 <Text color={(proof?.without?.exitCode ?? 0) === 0 ? PALETTE.l : PALETTE.r}>
225 WITHOUT THE FIX {runText(proof?.without?.exitCode)}
226 </Text>
227 </Box>,
228 <Box key="with">
229 <Text color={(proof?.with?.exitCode ?? 1) === 0 ? PALETTE.i : PALETTE.l}>
230 WITH THE FIX {' '}
231 {runText(proof?.with?.exitCode)}
232 </Text>
233 </Box>,
234 <Text color={PALETTE.l}>
235 {proof === null
236 ? ''
237 : `${proof.sources.length} SOURCE · ${proof.tests.length} TEST${proof.base === '' ? '' : ` · BASE ${proof.base}`}`}
238 </Text>,
239 ]
240
241 return (
242 <Box flexDirection="column">
243 <Box flexDirection="row">
244 <Box key="title">
245 <Text color={titleColor} bold>
246 ▶ {title}
247 </Text>
248 </Box>
249 <Text color={PALETTE.d}> PROVE-IT</Text>
250 </Box>
251 <Box flexDirection="row">
252 <Box flexDirection="column">{pixels}</Box>
253 <Box flexDirection="column" marginLeft={2}>
254 {lines}
255 {isRunning ? null : (
256 <Box flexDirection="row">
257 <Button key="ok" label="OK" hotkey="o" role="dismiss" onPress={() => update($, hiddenAtom, () => true)} />
258 <Text> </Text>
259 <Button key="again" label="AGAIN" hotkey="a" onPress={() => proveNow($, options, isGateOn, false)} />
260 </Box>
261 )}
262 </Box>
263 </Box>
264 </Box>
265 )
266 })
267}
268hooks/files.ts 58 lines1// Which changed files are tests and which are the fix, plus the small parsers
2// for git output and the test command.
3
4export const DEFAULT_TEST_GLOBS = ['**/*.test.*', '**/*_test.*', '**/test_*.py', 'tests/**'] as const
5
6/** A path glob: `*` and `?` stay inside one folder, `**` crosses folders (and may match none). */
7export const globToRegExp = (glob: string): RegExp => {
8 let pattern = ''
9 for (let i = 0; i < glob.length; i += 1) {
10 const char = glob[i] as string
11 if (char === '*' && glob[i + 1] === '*') {
12 const isFolder = glob[i + 2] === '/'
13 pattern += isFolder ? '(?:.*/)?' : '.*'
14 i += isFolder ? 2 : 1
15 } else if (char === '*') {
16 pattern += '[^/]*'
17 } else if (char === '?') {
18 pattern += '[^/]'
19 } else {
20 pattern += char.replace(/[.+^${}()|[\]\\]/g, '\\$&')
21 }
22 }
23
24 return new RegExp(`^${pattern}$`)
25}
26
27/** A userConfig glob list: comma or blank separated. */
28export const parseGlobList = (text: unknown): string[] =>
29 typeof text === 'string' ? text.split(/[\s,]+/).filter(word => word !== '') : []
30
31const matchesAny = (path: string, globs: readonly RegExp[]): boolean => globs.some(glob => glob.test(path))
32
33/** Splits changed paths into tests and sources; with no source globs every non-test is a source. */
34export const classifyFiles = (
35 paths: readonly string[],
36 testGlobs: readonly string[],
37 sourceGlobs: readonly string[],
38): { tests: string[]; sources: string[] } => {
39 const tests = testGlobs.map(globToRegExp)
40 const sources = sourceGlobs.map(globToRegExp)
41 const isTest = (path: string) => matchesAny(path, tests)
42 const isSource = (path: string) => !isTest(path) && (sources.length === 0 || matchesAny(path, sources))
43
44 return { tests: paths.filter(isTest), sources: paths.filter(isSource) }
45}
46
47/** `git ... -z` output: NUL-separated names, in order, each once. */
48export const parseNulList = (text: string): string[] => [...new Set(text.split('\0').filter(name => name !== ''))]
49
50/** The test command as an argument vector (no shell): blanks split, quotes group. */
51export const splitCommand = (command: string): string[] => {
52 const words: string[] = []
53 const pattern = /"([^"]*)"|'([^']*)'|(\S+)/g
54 for (const match of command.matchAll(pattern)) words.push(match[1] ?? match[2] ?? match[3] ?? '')
55
56 return words
57}
58hooks/pixels.ts 107 lines1/**
2 * Half-block pixel art: a sprite is a grid of palette keys ('.' is
3 * transparent); two pixel rows fold into one text row, the top pixel as the
4 * `▀`'s color and the bottom one as its background.
5 */
6
7/** PICO-8, keyed by one letter. */
8export const PALETTE = {
9 k: '#000000', // black
10 n: '#1D2B53', // navy
11 p: '#7E2553', // plum
12 g: '#008751', // green
13 b: '#AB5236', // brown
14 d: '#5F574F', // dark grey
15 l: '#C2C3C7', // light grey
16 w: '#FFF1E8', // white
17 r: '#FF004D', // red: prove-it's signature
18 o: '#FFA300', // orange
19 y: '#FFEC27', // yellow
20 i: '#00E436', // lime
21 u: '#29ADFF', // blue
22 v: '#83769C', // lavender
23 m: '#FF77A8', // pink
24 e: '#FFCCAA', // peach
25} as const
26
27export type Run = { text: string; color?: string; backgroundColor?: string }
28
29const colorOf = (key: string | undefined): string | undefined =>
30 key === undefined || key === '.' ? undefined : (PALETTE as Record<string, string>)[key]
31
32const cellOf = (top: string | undefined, bottom: string | undefined): Run => {
33 if (top && bottom) return { text: '▀', color: top, backgroundColor: bottom }
34 if (top) return { text: '▀', color: top }
35 if (bottom) return { text: '▄', color: bottom }
36
37 return { text: ' ' }
38}
39
40const sameStyle = (a: Run, b: Run): boolean => a.color === b.color && a.backgroundColor === b.backgroundColor
41
42/** Folds a grid into text rows of styled runs, merging neighbours of one style. */
43export const pixelRows = (grid: readonly string[]): Run[][] => {
44 const rows: Run[][] = []
45 const width = Math.max(0, ...grid.map(line => line.length))
46 for (let y = 0; y < grid.length; y += 2) {
47 const runs: Run[] = []
48 for (let x = 0; x < width; x += 1) {
49 const cell = cellOf(colorOf(grid[y]?.[x]), colorOf(grid[y + 1]?.[x]))
50 const last = runs[runs.length - 1]
51 if (last && last.text[0] === cell.text && sameStyle(last, cell)) last.text += cell.text
52 else runs.push({ ...cell })
53 }
54 rows.push(runs)
55 }
56
57 return rows
58}
59
60/** A test tube bubbling red: the proof is running. 8 x 8 pixels, 4 rows. */
61export const FLASK = [
62 '..wwww..',
63 '...ll...',
64 '...ll.r.',
65 '..l..l..',
66 '.l.r..l.',
67 'lrrrrrrl',
68 'lrrwrrrl',
69 '.llllll.',
70]
71
72/** A gold star: proven. */
73export const STAR = [
74 '...yy...',
75 '...yy...',
76 'yyyyyyyy',
77 '.yywwyy.',
78 '..yyyy..',
79 '.yyyyyy.',
80 '.yy..yy.',
81 'yo....oy',
82]
83
84/** A red cross: not proven, broken, or nothing to show. */
85export const CROSS = [
86 'rr....rr',
87 'rrr..rrr',
88 '.rrrrrr.',
89 '..rrrr..',
90 '..rrrr..',
91 '.rrrrrr.',
92 'rrr..rrr',
93 'rr....rr',
94]
95
96/** A cracked tube spilling orange: the proof could not run, or the restore failed. */
97export const CRACKED = [
98 '..wwww..',
99 '...ll...',
100 '...ll...',
101 '..l..l..',
102 '.l.l..l.',
103 'lo..l..l',
104 'loo.l.ol',
105 '.llo.ll.',
106]
107hooks/prove.ts 332 lines1// The proof: measure the change against the merge base, put the changed
2// sources back to their base versions, require the changed tests to fail,
3// restore the sources (verified by hash), require them to pass.
4//
5// It never uses git stash or git checkout: copies go to the temp dir with cp,
6// base versions are written from `git show`, and the restore runs in a
7// finally block whatever happened in between. A lock folder in the git dir
8// keeps a second proof (from this session or another) off the same tree, and
9// an interrupt stops waiting on the tests and restores at once.
10
11import type { ProvePhase, ProveProof, ProveRun, ProveVerdict } from '../types'
12import { classifyFiles, parseNulList } from './files'
13
14export type RunOutput = { exitCode: number; stdout: string; stderr: string }
15
16/** What the proof needs from the host; register.tsx builds it from `$`. Every call may reject. */
17export type Host = {
18 run: (argv: readonly string[], init?: { cwd?: string; timeoutMs?: number }) => Promise<RunOutput>
19 write: (path: string, text: string) => Promise<void>
20 exists: (path: string) => Promise<boolean>
21 /** Reports progress while the proof runs; never called with idle (the caller owns that). */
22 onPhase: (phase: Exclude<ProvePhase, 'idle'>) => Promise<void>
23 /** Aborts when the person interrupts or the dispatch goes on without the proof. */
24 signal?: AbortSignal
25}
26
27export type ProveSettings = {
28 /** The test command as argv; the changed test files are appended. */
29 testCommand: readonly string[]
30 testGlobs: readonly string[]
31 sourceGlobs: readonly string[]
32 /** The ref to measure against; empty means detect it. */
33 base: string
34 timeoutMs: number
35 tmpDir: string
36}
37
38/** The change, measured: where the repo is, what changed, and its fingerprint. */
39export type Survey = {
40 root: string
41 /** The absolute git dir (per worktree), where the lock lives. */
42 gitDir: string
43 baseSha: string
44 sources: string[]
45 tests: string[]
46 fingerprint: string
47}
48
49const GIT_TIMEOUT_MS = 30_000
50const FALLBACK_BASES = ['origin/main', 'origin/master', 'main', 'master'] as const
51
52const errorText = (error: unknown): string => (error instanceof Error ? error.message : String(error))
53
54const firstLine = (text: string): string => text.trim().split('\n')[0] ?? ''
55
56/** FNV-1a, 32 bit, as hex: enough to tell one state of a change from another. */
57export const fingerprintOf = (text: string): string => {
58 let hash = 0x811c9dc5
59 for (let i = 0; i < text.length; i += 1) {
60 hash ^= text.charCodeAt(i)
61 hash = Math.imul(hash, 0x01000193) >>> 0
62 }
63
64 return hash.toString(16).padStart(8, '0')
65}
66
67/** The last few lines a test run printed, for the reply and the deny text. */
68export const tailOf = (output: RunOutput, lines = 4): string =>
69 `${output.stdout}\n${output.stderr}`
70 .split('\n')
71 .map(line => line.trimEnd())
72 .filter(line => line.trim() !== '')
73 .slice(-lines)
74 .join('\n')
75 .slice(-400)
76
77export const emptyProof = (verdict: ProveVerdict, detail: string, at: number): ProveProof => ({
78 verdict,
79 base: '',
80 sources: [],
81 tests: [],
82 without: null,
83 with: null,
84 detail,
85 copiesDir: '',
86 fingerprint: '',
87 at,
88})
89
90class Stop extends Error {}
91
92/** The proof was interrupted; whatever it had changed is restored before it says so. */
93class Interrupted extends Error {}
94
95export const INTERRUPTED = 'interrupted: the proof stopped early and your files were restored'
96
97export const LOCK_NAME = 'prove-it.lock'
98
99/** `work`, or a rejection as soon as `signal` aborts (the work itself runs on: process.run cannot be cancelled). */
100const untilAborted = async <T>(work: Promise<T>, signal: AbortSignal | undefined): Promise<T> => {
101 if (signal === undefined) return work
102 if (signal.aborted) throw new Interrupted(INTERRUPTED)
103 return new Promise<T>((resolve, reject) => {
104 const onAbort = () => reject(new Interrupted(INTERRUPTED))
105 signal.addEventListener('abort', onAbort, { once: true })
106 work.then(
107 value => {
108 signal.removeEventListener('abort', onAbort)
109 resolve(value)
110 },
111 error => {
112 signal.removeEventListener('abort', onAbort)
113 reject(error)
114 },
115 )
116 })
117}
118
119const git = async (host: Host, cwd: string, args: readonly string[]): Promise<RunOutput> =>
120 host.run(['git', ...args], { cwd, timeoutMs: GIT_TIMEOUT_MS })
121
122/** git that must succeed: its stdout, or a Stop with git's own first line. */
123const gitOut = async (host: Host, cwd: string, args: readonly string[], what: string): Promise<string> => {
124 const out = await git(host, cwd, args).catch(error => {
125 throw new Stop(`${what}: ${errorText(error)}`)
126 })
127 if (out.exitCode !== 0) throw new Stop(`${what}: ${firstLine(out.stderr) || `git exited ${out.exitCode}`}`)
128 return out.stdout
129}
130
131const findBaseRef = async (host: Host, root: string, configured: string): Promise<string> => {
132 if (configured !== '') return configured
133 const remoteHead = await git(host, root, ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD']).catch(() => null)
134 const named = remoteHead?.exitCode === 0 ? remoteHead.stdout.trim() : ''
135 if (named !== '') return named
136 for (const ref of FALLBACK_BASES) {
137 const found = await git(host, root, ['rev-parse', '--verify', '--quiet', ref]).catch(() => null)
138 if (found?.exitCode === 0) return ref
139 }
140 throw new Stop('no base branch found (tried origin HEAD, origin/main, origin/master, main, master); set the base option')
141}
142
143const hashOf = async (host: Host, root: string, path: string): Promise<string> =>
144 (await gitOut(host, root, ['hash-object', '--no-filters', '--', path], `hash ${path}`)).trim()
145
146/** Measures the change: the repo root, the merge base, the changed tests and sources, a fingerprint. */
147export const survey = async (host: Host, cwd: string, settings: ProveSettings, at: number): Promise<Survey | ProveProof> => {
148 try {
149 const root = (await gitOut(host, cwd, ['rev-parse', '--show-toplevel'], 'not a git repository')).trim()
150 const gitDir = (await gitOut(host, root, ['rev-parse', '--absolute-git-dir'], 'no git dir')).trim()
151 const baseRef = await findBaseRef(host, root, settings.base)
152 const baseSha = (await gitOut(host, root, ['merge-base', 'HEAD', baseRef], `no merge base with ${baseRef}`)).trim()
153 const head = (await gitOut(host, root, ['rev-parse', 'HEAD'], 'no HEAD commit')).trim()
154 // --no-renames: a renamed source must show its old path too, so the run without the fix brings it back.
155 const tracked = parseNulList(
156 await gitOut(host, root, ['diff', '--no-renames', '--name-only', '-z', baseSha], 'git diff failed'),
157 )
158 const untracked = parseNulList(
159 await gitOut(host, root, ['ls-files', '--others', '--exclude-standard', '-z'], 'git ls-files failed'),
160 )
161
162 const { sources } = classifyFiles(tracked, settings.testGlobs, settings.sourceGlobs)
163 const changedTests = classifyFiles([...new Set([...tracked, ...untracked])], settings.testGlobs, []).tests
164 const tests: string[] = []
165 for (const path of changedTests) if (await host.exists(`${root}/${path}`)) tests.push(path)
166
167 const diff = await gitOut(host, root, ['diff', '--no-renames', '--no-ext-diff', '--binary', baseSha], 'git diff failed')
168 const untrackedTests = tests.filter(path => untracked.includes(path))
169 const untrackedHashes: string[] = []
170 for (const path of untrackedTests) untrackedHashes.push(`${path} ${await hashOf(host, root, `${root}/${path}`)}`)
171 const fingerprint = fingerprintOf([head, baseSha, diff, ...untrackedHashes, settings.testCommand.join(' ')].join('\n'))
172
173 return { root, gitDir, baseSha, sources, tests, fingerprint }
174 } catch (error) {
175 if (error instanceof Stop) return emptyProof('error', error.message, at)
176 return emptyProof('error', errorText(error), at)
177 }
178}
179
180type Aside = { path: string; absolute: string; copy: string; hash: string | null; baseText: string | null }
181
182const dirOf = (path: string): string => path.slice(0, Math.max(0, path.lastIndexOf('/'))) || '/'
183
184/** Copies every changed source aside, checking each copy, and reads its base version. Touches nothing in the tree. */
185const putAside = async (host: Host, survey: Survey, copiesDir: string): Promise<Aside[]> => {
186 const asides: Aside[] = []
187 for (const path of survey.sources) {
188 const absolute = `${survey.root}/${path}`
189 const copy = `${copiesDir}/${path}`
190 let hash: string | null = null
191 if (await host.exists(absolute)) {
192 hash = await hashOf(host, survey.root, absolute)
193 await gitlessRun(host, ['mkdir', '-p', dirOf(copy)], `could not make ${dirOf(copy)}`)
194 await gitlessRun(host, ['cp', '-p', absolute, copy], `could not copy ${path} aside`)
195 const copied = await hashOf(host, survey.root, copy).catch(() => '')
196 if (copied !== hash) throw new Stop(`the copy of ${path} in ${copiesDir} does not match the original; nothing was changed`)
197 }
198 const shown = await git(host, survey.root, ['show', `${survey.baseSha}:${path}`]).catch(error => {
199 throw new Stop(`git show ${path}: ${errorText(error)}`)
200 })
201 asides.push({ path, absolute, copy, hash, baseText: shown.exitCode === 0 ? shown.stdout : null })
202 }
203
204 return asides
205}
206
207const gitlessRun = async (host: Host, argv: readonly string[], what: string): Promise<void> => {
208 const out = await host.run(argv, { timeoutMs: GIT_TIMEOUT_MS }).catch(error => {
209 throw new Stop(`${what}: ${errorText(error)}`)
210 })
211 if (out.exitCode !== 0) throw new Stop(`${what}: ${firstLine(out.stderr) || `exit ${out.exitCode}`}`)
212}
213
214/** Puts each source back to its base version: written from git show, or removed when the base had none. */
215const revert = async (host: Host, asides: readonly Aside[]): Promise<void> => {
216 for (const aside of asides) {
217 if (aside.baseText !== null) await host.write(aside.absolute, aside.baseText)
218 else if (aside.hash !== null) await gitlessRun(host, ['rm', '-f', aside.absolute], `could not remove ${aside.path}`)
219 }
220}
221
222/** Copies every source back (or removes what the fix had deleted) and verifies each; the paths that failed. */
223const restore = async (host: Host, root: string, asides: readonly Aside[]): Promise<string[]> => {
224 const failures: string[] = []
225 for (const aside of asides) {
226 try {
227 if (aside.hash !== null) {
228 await gitlessRun(host, ['cp', '-p', aside.copy, aside.absolute], `could not restore ${aside.path}`)
229 if ((await hashOf(host, root, aside.absolute)) !== aside.hash) failures.push(aside.path)
230 } else {
231 if (await host.exists(aside.absolute)) {
232 await gitlessRun(host, ['rm', '-f', aside.absolute], `could not remove ${aside.path}`)
233 }
234 if (await host.exists(aside.absolute)) failures.push(aside.path)
235 }
236 } catch {
237 failures.push(aside.path)
238 }
239 }
240
241 return failures
242}
243
244const runTests = async (host: Host, survey: Survey, settings: ProveSettings): Promise<ProveRun> => {
245 const run = host.run([...settings.testCommand, ...survey.tests], { cwd: survey.root, timeoutMs: settings.timeoutMs })
246 const out = await untilAborted(run, host.signal)
247 return { exitCode: out.exitCode, tail: tailOf(out) }
248}
249
250/** Takes the repository's proof lock (mkdir is atomic), or stops: another proof is reverting this tree. */
251const takeLock = async (host: Host, lock: string): Promise<void> => {
252 const out = await host.run(['mkdir', lock], { timeoutMs: GIT_TIMEOUT_MS }).catch(error => {
253 throw new Stop(`could not take the lock ${lock}: ${errorText(error)}`)
254 })
255 if (out.exitCode !== 0) {
256 throw new Stop(
257 `a proof is already running in this repository (${lock} exists). If none is, a proof was cut off: ` +
258 'your versions are in the newest $TMPDIR/prove-it-<time>/ folder; put them back, then remove the lock folder',
259 )
260 }
261}
262
263/** Runs the proof on a survey: fail without the fix, restore and verify, pass with it. */
264export const runProof = async (host: Host, survey: Survey, settings: ProveSettings, at: number): Promise<ProveProof> => {
265 const proof: ProveProof = {
266 ...emptyProof('error', '', at),
267 base: survey.baseSha.slice(0, 7),
268 sources: survey.sources,
269 tests: survey.tests,
270 fingerprint: survey.fingerprint,
271 }
272 if (survey.sources.length === 0 && survey.tests.length === 0) return { ...proof, verdict: 'nothing' }
273 if (survey.tests.length === 0) return { ...proof, verdict: 'no-tests' }
274 if (settings.testCommand.length === 0) {
275 return { ...proof, detail: 'no test command: set the testCommand option (e.g. "npm test --" or "pytest")' }
276 }
277
278 if (host.signal?.aborted) return { ...proof, detail: INTERRUPTED }
279
280 let lock = ''
281 try {
282 if (survey.sources.length === 0) {
283 await host.onPhase('with')
284 const only = await runTests(host, survey, settings)
285 return { ...proof, with: only, verdict: only.exitCode === 0 ? 'no-source' : 'broken' }
286 }
287
288 await takeLock(host, `${survey.gitDir}/${LOCK_NAME}`)
289 lock = `${survey.gitDir}/${LOCK_NAME}`
290 const copiesDir = `${settings.tmpDir.replace(/\/+$/, '')}/prove-it-${at}`
291 proof.copiesDir = copiesDir
292 const asides = await putAside(host, survey, copiesDir)
293
294 // Every way out of the run without the fix lands here, so the restore below always runs.
295 let withoutError: unknown = null
296 try {
297 await host.onPhase('without')
298 if (host.signal?.aborted) throw new Interrupted(INTERRUPTED)
299 await revert(host, asides)
300 proof.without = await runTests(host, survey, settings)
301 } catch (error) {
302 withoutError = error
303 }
304 const failures = await restore(host, survey.root, asides)
305 if (failures.length > 0) {
306 return {
307 ...proof,
308 verdict: 'restore-failed',
309 detail: `restore did not match for ${failures.join(', ')}; your copies are in ${copiesDir}`,
310 }
311 }
312 if (withoutError instanceof Interrupted || host.signal?.aborted) return { ...proof, without: null, detail: INTERRUPTED }
313 if (withoutError !== null) return { ...proof, detail: `the run without the fix failed to run: ${errorText(withoutError)}` }
314
315 await host.onPhase('with')
316 proof.with = await runTests(host, survey, settings)
317 const verdict: ProveVerdict =
318 proof.with.exitCode !== 0 ? 'broken' : (proof.without?.exitCode ?? 0) !== 0 ? 'proven' : 'not-proven'
319 return { ...proof, verdict }
320 } catch (error) {
321 return { ...proof, detail: errorText(error) }
322 } finally {
323 if (lock !== '') await host.run(['rmdir', lock], { timeoutMs: GIT_TIMEOUT_MS }).catch(() => undefined)
324 }
325}
326
327/** Survey then proof, in one go. */
328export const prove = async (host: Host, cwd: string, settings: ProveSettings, at: number): Promise<ProveProof> => {
329 const surveyed = await survey(host, cwd, settings, at)
330 return 'verdict' in surveyed ? surveyed : runProof(host, surveyed, settings, at)
331}
332hooks/verdict.ts 152 lines1// Words for a proof: labels, the status line, the /prove reply, the gate's
2// deny text, and which Bash commands the gate stops.
3
4import type { ProvePhase, ProveProof, ProveRun, ProveVerdict } from '../types'
5
6const LABELS: Record<ProveVerdict, string> = {
7 proven: 'PROVEN ★',
8 'not-proven': 'NOT PROVEN',
9 broken: 'BROKEN',
10 'no-tests': 'NO TESTS CHANGED',
11 'no-source': 'TESTS PASS',
12 nothing: 'NOTHING TO PROVE',
13 error: 'ERROR',
14 'restore-failed': 'RESTORE FAILED',
15}
16
17const MEANINGS: Record<ProveVerdict, string> = {
18 proven: 'The changed tests fail without the fix and pass with it.',
19 'not-proven': 'The changed tests pass without the fix, so they do not test it. Write a test that fails on the old code.',
20 broken: 'The changed tests fail with the fix.',
21 'no-tests': 'Source changed but no test file did. Add a test that fails without the fix.',
22 'no-source': 'Only tests changed and they pass; there is no fix to revert.',
23 nothing: 'Nothing changed against the base.',
24 error: 'The proof could not run; your files were restored.',
25 'restore-failed': 'A restored file did not match its copy. Stop and put it back by hand.',
26}
27
28export const verdictLabel = (verdict: ProveVerdict): string => LABELS[verdict]
29
30/** The verdicts the gate lets through: proven, and the ones with nothing to revert. */
31export const gateAllows = (verdict: ProveVerdict): boolean =>
32 verdict === 'proven' || verdict === 'no-source' || verdict === 'nothing'
33
34/** Words before a command that still run it: `command git push`, `env A=1 git push`, `sudo git push`. */
35const WRAPPERS = new Set(['command', 'builtin', 'exec', 'nohup', 'time', 'env', 'sudo', 'nice', 'doas'])
36/** A wrapper's options that take the next word as their value. */
37const WRAPPER_ARGS = new Set(['-u', '-g', '-n', '-C', '--user', '--group', '--chdir', '--unset'])
38/** git's global options that take the next word as their value. */
39const GIT_ARGS = new Set(['-C', '-c', '--git-dir', '--work-tree', '--namespace', '--super-prefix', '--config-env', '--exec-path'])
40/** gh's options that take the next word as their value. */
41const GH_ARGS = new Set(['-R', '--repo'])
42/** Shells whose `-c` text is a command line of its own. */
43const SHELLS = new Set(['sh', 'bash', 'zsh', 'dash', 'ksh'])
44
45const baseName = (word: string): string => word.slice(word.lastIndexOf('/') + 1)
46
47/** Skips options (and the values of those in `withValue`) from `i`; the index of the first other word. */
48const skipOptions = (words: readonly string[], i: number, withValue: ReadonlySet<string>): number => {
49 let at = i
50 while (at < words.length && (words[at] ?? '').startsWith('-')) {
51 at += withValue.has(words[at] ?? '') ? 2 : 1
52 }
53 return at
54}
55
56/** What one simple command runs, if it is a push or a PR create. */
57const gatedWords = (words: readonly string[]): string | null => {
58 let i = 0
59 for (;;) {
60 while (i < words.length && /^[A-Za-z_]\w*=/.test(words[i] ?? '')) i += 1
61 if (!WRAPPERS.has(baseName(words[i] ?? ''))) break
62 i = skipOptions(words, i + 1, WRAPPER_ARGS)
63 }
64 const program = baseName(words[i] ?? '')
65 if (program === 'git') return words[skipOptions(words, i + 1, GIT_ARGS)] === 'push' ? 'git push' : null
66 if (program === 'gh') {
67 const pr = skipOptions(words, i + 1, GH_ARGS)
68 if (words[pr] !== 'pr') return null
69 const verb = words[skipOptions(words, pr + 1, GH_ARGS)]
70 return verb === 'create' || verb === 'new' ? 'gh pr create' : null
71 }
72 return null
73}
74
75/** The text of `sh -c '...'` / `bash -c "..."` and `eval '...'`, which run as command lines of their own. */
76const nestedScripts = (command: string): string[] => {
77 const nested: string[] = []
78 const pattern = /(?:^|[\s;&|(`])(?:(?:\S*\/)?(\w+)\s+(?:-\w+\s+)*-\w*c\w*|eval)\s+("((?:[^"\\]|\\.)*)"|'([^']*)')/g
79 for (const match of command.matchAll(pattern)) {
80 if (match[1] !== undefined && !SHELLS.has(match[1])) continue
81 nested.push((match[3] ?? match[4] ?? '').replace(/\\(.)/g, '$1'))
82 }
83 return nested
84}
85
86/**
87 * `git push` or `gh pr create` (or its alias `gh pr new`) when the command
88 * runs one, else null. Catches the usual spellings: wrappers such as
89 * `command`, `env`, `sudo`, `time`; a path on the program; git's global
90 * options with or without `=`; `gh -R o/r`; line continuations; `sh -c`,
91 * `eval`, `$(...)` and backticks. Other quoted text is ignored. It is a guard
92 * rail, not a security boundary.
93 */
94export const gatedCommand = (command: string, depth = 0): string | null => {
95 const joined = command.replace(/\\\r?\n/g, ' ')
96 if (depth < 3) {
97 for (const script of nestedScripts(joined)) {
98 const found = gatedCommand(script, depth + 1)
99 if (found !== null) return found
100 }
101 }
102 const unquoted = joined.replace(/"(?:[^"\\]|\\.)*"|'[^']*'/g, '""')
103 for (const part of unquoted.split(/[;&|()`\n]+|\$\(/)) {
104 const found = gatedWords(part.trim().split(/\s+/).filter(word => word !== '' && word !== '!' && word !== '{'))
105 if (found !== null) return found
106 }
107
108 return null
109}
110
111/** The status line: the verdict, or the run's progress, or the gate; under 40 columns. */
112export const statusLine = (proof: ProveProof | null, phase: ProvePhase, isGateOn: boolean): string | undefined => {
113 if (phase === 'without') return 'PROVE-IT ▸ PROVING 1/2'
114 if (phase === 'with') return 'PROVE-IT ▸ PROVING 2/2'
115 const gate = isGateOn ? ' · GATE' : ''
116 if (proof === null) return isGateOn ? 'PROVE-IT ▸ GATE ARMED' : undefined
117 if (proof.verdict === 'proven') return `PROVE-IT ★ PROVEN${gate}`
118
119 return `PROVE-IT ▸ ${LABELS[proof.verdict]}${gate}`
120}
121
122const runLine = (name: string, run: ProveRun | null): string[] => {
123 if (run === null) return []
124 const result = run.exitCode === 0 ? 'PASS' : `FAIL (exit ${run.exitCode})`
125 const tail = run.tail === '' ? [] : run.tail.split('\n').map(line => ` ${line}`)
126 return [` ${name}: ${result}`, ...tail]
127}
128
129const listLine = (name: string, paths: readonly string[]): string[] =>
130 paths.length === 0 ? [] : [` ${name}: ${paths.slice(0, 8).join(', ')}${paths.length > 8 ? ` +${paths.length - 8} more` : ''}`]
131
132/** The /prove reply: verdict, meaning, files, both runs, base. */
133export const replyText = (proof: ProveProof): string =>
134 [
135 `PROVE-IT ▸ ${LABELS[proof.verdict]}`,
136 MEANINGS[proof.verdict],
137 ...(proof.detail === '' ? [] : [proof.detail]),
138 ...listLine('fix', proof.sources),
139 ...listLine('tests', proof.tests),
140 ...runLine('without the fix', proof.without),
141 ...runLine('with the fix', proof.with),
142 ...(proof.base === '' ? [] : [` base: ${proof.base}`]),
143 ].join('\n')
144
145/** What the model reads when the gate refuses a push or PR. */
146export const denyText = (proof: ProveProof, command: string): string =>
147 [
148 `prove-it refused ${command}: ${LABELS[proof.verdict]}.`,
149 replyText(proof),
150 'Fix this and try again. If the user decides to go ahead anyway, they can run /prove skip to let the next push or PR through.',
151 ].join('\n')
152types/index.d.ts 61 lines1// prove-it's $.state contract: the last proof, the run in progress and the
2// one-shot skip. All of it lives for the session and survives a hot reload.
3
4/**
5 * proven: the changed tests fail without the fix and pass with it.
6 * not-proven: they pass without the fix, so they do not test it.
7 * broken: they fail with the fix.
8 * no-tests: source changed but no test file did.
9 * no-source: only tests changed and they pass; there is nothing to revert.
10 * nothing: no change against the base.
11 * error: git or the test command could not run; the tree was restored.
12 * restore-failed: a restored file's hash did not match; the copies are kept.
13 */
14export type ProveVerdict =
15 | 'proven'
16 | 'not-proven'
17 | 'broken'
18 | 'no-tests'
19 | 'no-source'
20 | 'nothing'
21 | 'error'
22 | 'restore-failed'
23
24/** One test run: its exit code and the last lines it printed. */
25export type ProveRun = { exitCode: number; tail: string }
26
27export type ProveProof = {
28 verdict: ProveVerdict
29 /** The merge base, short. Empty when it was never found. */
30 base: string
31 sources: string[]
32 tests: string[]
33 /** Without the fix (sources at the base). */
34 without: ProveRun | null
35 /** With the fix (sources restored). */
36 with: ProveRun | null
37 /** Why, for error, restore-failed and the like. */
38 detail: string
39 /** Where the copies of the changed sources were put aside. */
40 copiesDir: string
41 /** The state of the change this proof was made for; equal means nothing changed since. */
42 fingerprint: string
43 at: number
44}
45
46/** idle, or which half of the proof is running. */
47export type ProvePhase = 'idle' | 'without' | 'with'
48
49declare module 'claude-code' {
50 interface PluginState {
51 'prove-it': {
52 proof: ProveProof | null
53 phase: ProvePhase
54 /** The token of the proof that holds the run; empty when none does. */
55 owner: string
56 skipNext: boolean
57 isBandHidden: boolean
58 }
59 }
60}
61