SLOPSHOPPER

prove-it

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…

newbandguardcommandtoaststatus
v0.1.0MITupdated 2026-10-03pourya7/claude-code-mods/prove-it
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · prove-it
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /prove ⎿ prove-it: PROVE-IT ▸ NO TESTS CHANGED ⎿ prove-it: Source changed but no test file did. Add a test that fails without the fix. ⎿ prove-it: fix: src/auth.ts ⎿ prove-it: src/auth.test.ts ⎿ prove-it: ▶ NO TESTS CHANGED PROVE-IT ▀▀▄ ▄▀▀ WITHOUT THE FIX - ▀▀▀▀▀▀ WITH THE FIX - ▄▀▀▀▀▄ 1 SOURCE · 0 TEST ▀▀▀ ▀▀▀ [ OK ] [ AGAIN ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ prove-it: PROVE-IT ▸ NO TESTS CHANGED

Draws

Band
▶ NO TESTS CHANGED PROVE-IT ▀▀▄ ▄▀▀ WITHOUT THE FIX - ▀▀▀▀▀▀ WITH THE FIX - ▄▀▀▀▀▄ 1 SOURCE · 0 TEST ▀▀▀ ▀▀▀ [ OK ] [ AGAIN ]
README

prove-it

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

prove-it reverting a fix, seeing the new test fail, restoring it and showing PROVEN

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.

Install

/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.

How a proof runs

  1. Measure the change. The base is the merge base of 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.
  2. Put the fix aside. prove-it first takes a lock, the folder 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.
  3. Run without the fix. Each source file gets its base version back, written from 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).
  4. Restore, and check. Whatever happened in step 3, including the test command not starting or timing out, every file is copied back from the temp dir and its hash is compared with the one taken in step 2. A file the fix deleted is removed again and checked to be gone. If anything does not match, prove-it stops, says RESTORE FAILED and tells you where the copies are.
  5. Run with the fix. The same test command must pass.

prove-it never runs git stash, git checkout or git restore, and it never deletes the copies in the temp dir.

VerdictMeaningGate
PROVEN ★The tests fail without the fix and pass with it.lets through
NOT PROVENThe tests pass without the fix, so they do not test it.refuses
BROKENThe tests fail with the fix.refuses
NO TESTS CHANGEDSource changed but no test file did.refuses
TESTS PASSOnly tests changed and they pass. There is no fix to revert.lets through
NOTHING TO PROVENothing changed against the base.lets through
ERRORgit or the test command could not run. Your files were restored.refuses
RESTORE FAILEDA restored file did not match its copy. The copies are kept in the temp dir.refuses

Commands

CommandWhat it does
/prove or /prove runRuns 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 statusShows the last proof without running anything.
/prove skipLets the next git push or gh pr create through without a proof, once.

The gate

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.

Options

Set them in /config or under pluginConfigs.prove-it.options in settings.

OptionDefaultMeaning
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).
gatefalseRun the proof before git push and gh pr create and refuse them unless the change is proven.
timeoutSeconds300How long each test run may take before it counts as an ERROR. At most 600.

What it looks like

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.

Permissions

NetworkRuns processesFilesCalls a modelAuto-submits promptsData 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.NoNoNone of its own. Your test command does whatever it does.

Limits

  • Your files are at their base versions while the first run goes. An editor or a subagent that writes one of those files during the run has its write replaced by the restore. The proof runs inside the tool call or the command, so the main session waits for it.
  • An interrupt stops the proof early. If you press Esc (or another plugin's hook settles first) while the proof runs, prove-it stops waiting for the tests, restores your files at once, says 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.
  • If Claude Code stops or the module reloads in the middle of a run, the files may be left at their base versions. Your versions are in the newest $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).
  • Any non-zero exit counts as a failure. A test that fails without the fix because it cannot import a module the fix added counts as proven; read the without the fix lines if that matters.
  • Only the changed test files are passed to the test command. A runner that takes packages rather than files (for example go test) needs a small wrapper script as testCommand.
  • Base versions are written as text from 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.
  • The gate proves the session's working directory. A command like cd ../other && git push is checked against the session's repository, not ../other.
  • The gate sees the Bash tool only. A push made by another tool, an MCP server or you in a terminal is not gated.
Source 6 files
hooks/register.tsx 268 lines
1// 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}
268
hooks/files.ts 58 lines
1// 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}
58
hooks/pixels.ts 107 lines
1/**
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]
107
hooks/prove.ts 332 lines
1// 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}
332
hooks/verdict.ts 152 lines
1// 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')
152
types/index.d.ts 61 lines
1// 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