Tracks which test, typecheck, lint and build results are still true after later edits, and stops commit messages that claim checks which are stale


Tests and tsc pass, so the board reads ✓ tests fresh · ✓ types fresh. One edit later both go ⚠ stale (1 edit), and a commit claiming "All tests pass" is refused by Oathkeeper. MP4
| Fresh | Stale | Refused |
|---|---|---|
![]() | ![]() | ![]() |
A Claude Code mod that tracks which verification results are still true.
"Tests passed" stops meaning anything once the code changes. Proof Decay records every test, typecheck, lint and build run the agent makes, then marks the result stale as soon as a later edit touches what it covered.
✓ tests fresh · ⚠ types stale (3 edits) · ✗ lint failed. It shows the repo you're in.&& chain that succeeded, or the last command of a newline/; script is recorded, because those are the only cases where the one exit status the call has can be pinned to the check. npm test; true, npm test || …, pipes, subshells and $(…) never produce a pass, and anything inside quotes is never treated as a command. A check whose outcome can't be read (npm test || true, $(npm test), a failed chain) casts doubt on the earlier pass instead. Interrupted or backgrounded runs are skipped.npm test, pytest, go test ./..., tsc -p tsconfig.json) counts as a project result. Some runs are scoped or filtered instead. That includes runs from below the repo root (or from a nested package.json), runs that name files or a directory, script variants (test:unit), workspace or package flags, plain cargo test at a workspace root, and name filters (-t, -kfoo, --lib, -only-testing). They still show on the board, but they never back a claim. A failed scoped run also casts doubt on the project pass.-m, heredocs and -F files, and git -C is supported. If it claims checks passed ("all tests pass", "typecheck is clean", "CI is green"), but the matching whole-project run in that repo is stale, failed or missing, the commit is refused. Hedged lines ("should pass", "tests pass when…", "not all tests pass") are ignored. A claimed commit has to run as its own command, not chained after other commands. git add before it in the same call is fine. It's also refused if it would commit less than what was tested: unstaged changes, untracked files the tests saw (even with -a), or pathspec, --only and --patch commits. bash -c '…' wrappers are looked inside. After popd, cd ~ or cd -, the target repo can't be pinned, so claims are refused. If a message can't be read in advance (a $VAR, an editor, --amend --no-edit when history can't be read), the commit only goes through while every check in the repo is fresh. Every other commit goes through, with a note to the model listing what wasn't re-verified./proofs lists every recorded run with its age, scope, repo and any reason for doubt. /proofs clear forgets them all. Results last for the whole session, across prompts.Recognised commands include npm/yarn/pnpm/bun test/lint/typecheck/build scripts, jest, vitest, mocha, playwright test, pytest, tsc, vue-tsc, mypy, pyright, eslint, ruff, biome, go test/vet/build, cargo test/check/clippy/build, swift test/build, xcodebuild test/build, gradle test/build, and make test/make lint.
/plugin marketplace add ccdwyer/claude-mods
/plugin install proof-decay@ccdwyer-mods
/reload-plugins
claude plugin validate .
claude plugin test .
Events this mod hooks, as claude plugin validate reads the module:
session.startcommand.run{command=proofs}tool.call{tool=Bash}tool.callturn.completeui.render{component=AbovePrompt}Engine calls it makes: $.clock.now, $.command.register, $.fs.read (via demote, judgeCommit), $.fs.stat (via exists), $.process.run (via fingerprint, judgeCommit, rootOf), $.session.cwd, $.state.get, $.state.set, $.ui.resolve.
A tool.call hook sits in the middle of every tool call: it can see the call, refuse it, or add context to its result. This mod uses that only for the behaviour described above.
It runs entirely on your machine. It sends nothing over the network. It runs git locally to fingerprint the working tree.
The mod collects no analytics or telemetry, and its author receives no data from it.
Full policy: PRIVACY.md.
MIT
hooks/register.tsx 615 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Proof, ProofKind } from '../types'
5import { chdirOf, checkOf, claimSets, claimsIn, commitOf, isHarmless, isInfallible, literal, parse, resolvePath, substitutions, unwrap } from './shell'
6import type { Check, Commit, Parsed } from './shell'
7
8const proofs = atom({ plugin: 'proof-decay', key: 'proofs' } as const, {})
9const edits = atom({ plugin: 'proof-decay', key: 'edits' } as const, 0)
10
11const KINDS: ProofKind[] = ['tests', 'typecheck', 'lint', 'build']
12const NAMES: Record<ProofKind, string> = { tests: 'tests', typecheck: 'types', lint: 'lint', build: 'build' }
13const EDITORS = new Set(['Edit', 'Write', 'NotebookEdit'])
14const DRIFT = 'files changed on disk since the run'
15const UNSEEN = 'the repo could not be fingerprinted'
16const AMBIGUOUS = 'a later run of it had an outcome that could not be attributed'
17
18// The working tree as git sees it: every tracked or untracked, non-ignored file
19// that exists, by path and content id. HEAD and the index are left out, so
20// committing the tested tree leaves it unchanged. Filters never run (--no-filters).
21// A submodule counts by its HEAD plus its own dirty work tree (tracked changes
22// and untracked files by content), so an edit inside it makes proofs stale.
23const FINGERPRINT = `set -eo pipefail
24files=$(mktemp); out=$(mktemp)
25trap 'rm -f "$files" "$out"' EXIT
26sub_state() {
27 { git -C "$1" diff HEAD --no-ext-diff --no-textconv --binary -- 2>/dev/null || echo unreadable
28 git -C "$1" -c core.quotePath=false ls-files -z -o --exclude-standard 2>/dev/null | while IFS= read -r -d '' u; do
29 printf 'new %s %s\\0' "$u" "$(git -C "$1" hash-object --no-filters -- "$u" 2>/dev/null || echo unreadable)"
30 done
31 } | git hash-object --stdin
32}
33git -c core.quotePath=false ls-files -z -co --exclude-standard --deduplicate | while IFS= read -r -d '' f; do
34 if [ -L "$f" ]; then printf 'link %s -> %s\\n' "$f" "$(readlink -- "$f")" >> "$out"
35 elif [ -d "$f" ]; then printf 'sub %s %s %s\\n' "$f" "$(git -C "$f" rev-parse HEAD 2>/dev/null || echo none)" "$(sub_state "$f")" >> "$out"
36 elif [ -f "$f" ]; then
37 case "$f" in *$'\\n'*) printf 'odd %s %s\\n' "$f" "$(git hash-object --no-filters -- "$f")" >> "$out" ;; *) printf '%s\\n' "$f" >> "$files" ;; esac
38 if [ -x "$f" ]; then printf 'exec %s\\n' "$f" >> "$out"; fi
39 fi
40done
41git hash-object --no-filters --stdin-paths < "$files" | paste -d ' ' - "$files" >> "$out"
42LC_ALL=C sort "$out"`
43
44const isFresh = (p: Proof) => p.status === 'pass' && p.staleEdits === 0 && p.doubt === null
45
46const describe = (p: Proof) => {
47 const scope = p.scope === 'project' ? '' : ` (${p.scope})`
48 if (p.status === 'fail') return `✗ ${NAMES[p.kind]} failed${scope}`
49 if (p.staleEdits > 0) return `⚠ ${NAMES[p.kind]} stale (${p.staleEdits} edit${p.staleEdits === 1 ? '' : 's'})${scope}`
50 if (p.doubt !== null) return `⚠ ${NAMES[p.kind]} stale${scope}`
51 return `✓ ${NAMES[p.kind]} fresh${scope}`
52}
53
54const keyOf = (root: string, c: { kind: ProofKind; scope: Check['scope']; label: string }) =>
55 c.scope === 'project' ? `${root}|${c.kind}|project` : `${root}|${c.kind}|${c.scope}|${c.label}`
56
57const inRoot = (root: string, path: string) => path === root || path.startsWith(`${root}/`)
58const ignoredPath = (path: string) => /\/(\.git|node_modules)\//.test(path)
59
60async function hash(text: string): Promise<string> {
61 const bytes = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text))
62 return [...new Uint8Array(bytes).slice(0, 12)].map(b => b.toString(16).padStart(2, '0')).join('')
63}
64
65type Root = { root: string; git: boolean }
66const roots = new Map<string, Root>()
67async function rootOf($: EngineInterface, dir: string): Promise<Root> {
68 const known = roots.get(dir)
69 if (known !== undefined) return known
70 let found: Root = { root: dir, git: false }
71 try {
72 const r = await $.process.run(['git', '-C', dir, 'rev-parse', '--show-toplevel'], { timeoutMs: 5000 })
73 if (r.exitCode === 0 && r.stdout.trim() !== '') found = { root: r.stdout.trim(), git: true }
74 } catch {
75 // Not a repo, or git missing: the directory stands for itself.
76 }
77 roots.set(dir, found)
78 return found
79}
80
81async function fingerprint($: EngineInterface, root: Root): Promise<string | null> {
82 if (!root.git) return null
83 try {
84 const r = await $.process.run(['bash', '-c', FINGERPRINT], { cwd: root.root, timeoutMs: 30000 })
85 if (r.exitCode !== 0 || r.isStdoutTruncated) return null
86 return await hash(r.stdout)
87 } catch {
88 return null
89 }
90}
91
92async function exists($: EngineInterface, path: string): Promise<boolean> {
93 try {
94 await $.fs.stat(path)
95 return true
96 } catch {
97 return false
98 }
99}
100
101// A run below the repo root, or of one package, checked less than the whole repo.
102async function demote($: EngineInterface, check: Check, dir: string, root: string): Promise<Check['scope']> {
103 if (check.scope !== 'project') return check.scope
104 if (['npm', 'yarn', 'pnpm', 'bun'].includes(check.tool)) {
105 // The package manager runs the nearest package.json's script.
106 for (let d = dir; inRoot(root, d); d = resolvePath(d, '..')) {
107 if (await exists($, `${d}/package.json`)) return d === root ? 'project' : 'scoped'
108 if (d === root) break
109 }
110 return 'project'
111 }
112 // `cargo test --workspace` covers every member from any directory inside it.
113 if (check.tool === 'cargo' && check.wholeWorkspace) return 'project'
114 if (dir !== root) return 'scoped'
115 if (check.tool === 'cargo') {
116 // A root package with a workspace runs only the root package; a virtual
117 // workspace with no default-members runs every member.
118 try {
119 const manifest = await $.fs.read(`${root}/Cargo.toml`)
120 const isWorkspace = /^\s*\[workspace\]/m.test(manifest)
121 const isPackage = /^\s*\[package\]/m.test(manifest)
122 if (isWorkspace && (isPackage || /^\s*default-members\s*=/m.test(manifest))) return 'scoped'
123 } catch {
124 return 'scoped'
125 }
126 }
127 return 'project'
128}
129
130// Compares each repo's files with what its proofs saw. Changes no hook saw (an
131// editor, a formatter, a script) make them stale; a tree put back makes them fresh.
132async function refresh($: EngineInterface, only?: string) {
133 const all = Object.values(await read($, proofs))
134 const wanted = [...new Set(all.filter(p => p.fingerprint !== null).map(p => p.root))].filter(r => only === undefined || r === only)
135 const now = new Map<string, string | null>()
136 for (const root of wanted) now.set(root, await fingerprint($, { root, git: true }))
137 if (now.size === 0) return
138 await update($, proofs, cur =>
139 Object.fromEntries(
140 Object.entries(cur).map(([k, p]) => {
141 if (p.fingerprint === null || !now.has(p.root)) return [k, p]
142 const fp = now.get(p.root) ?? null
143 if (fp === null) return [k, p.doubt === null ? { ...p, doubt: UNSEEN } : p]
144 if (fp === p.fingerprint) {
145 const doubt = p.doubt === DRIFT || p.doubt === UNSEEN ? null : p.doubt
146 return [k, { ...p, staleEdits: 0, doubt }]
147 }
148 return [k, p.doubt === null ? { ...p, doubt: DRIFT } : p]
149 }),
150 ),
151 )
152}
153
154async function staleFiles($: EngineInterface, paths: string[]) {
155 const real = paths.filter(p => !ignoredPath(p))
156 if (real.length === 0) return
157 await update($, edits, n => n + 1)
158 await update($, proofs, all =>
159 Object.fromEntries(
160 Object.entries(all).map(([k, p]) => [k, real.some(path => inRoot(p.root, path)) ? { ...p, staleEdits: p.staleEdits + 1 } : p]),
161 ),
162 )
163}
164
165type BashResult = {
166 interrupted?: boolean
167 backgroundTaskId?: string
168 bashEditDiff?: { files?: { filePath: string }[]; changedFiles?: string[] }
169}
170
171// dir is null after a directory change this mod cannot follow (popd, cd ~, cd -, cd $X).
172type Step = { words: string[]; sep: string; ctx: number; docs: number[]; dir: string | null; check: Check | null; commit: Commit | null }
173
174// Whether the commands before a commit stage the whole tree. Only a full-repo
175// add counts (-A / --all / :/ with no pathspec, or `.` from the repo root); any
176// other add leaves the unstaged and untracked checks in force.
177function stagedBefore(steps: Step[], upTo: number, root: string): boolean {
178 let all = false
179 for (const s of steps.slice(0, upTo)) {
180 const argv = unwrap(s.words)
181 if (base(argv[0] ?? '') !== 'git') continue
182 let i = 1
183 let dir = s.dir
184 while (argv[i]?.startsWith('-')) {
185 if (argv[i] === '-C') {
186 dir = dir === null ? null : resolvePath(dir, argv[i + 1] ?? '.')
187 i += 2
188 } else i += argv[i] === '-c' ? 2 : 1
189 }
190 if (argv[i] !== 'add') continue
191 const args = argv.slice(i + 1)
192 if (args.some(a => a === '-n' || a === '--dry-run' || a === '-u' || a === '--update' || a === '-p' || a === '--patch')) continue
193 const paths = args.filter(a => !a.startsWith('-'))
194 const full = args.some(a => a === '-A' || a === '--all') && paths.length === 0
195 if (full || (paths.length === 1 && (paths[0] === ':/' || (paths[0] === '.' && dir === root)))) all = true
196 }
197 return all
198}
199
200// Each simple command with the directory it runs in. Every shell has its own
201// directory: a `bash -c` body starts where its parent shell is, its cd ends with
202// it, and the parent carries on from its own directory. `env -C dir cmd` runs cmd
203// elsewhere without moving the shell.
204function stepsOf(parsed: Parsed, cwd: string | null): Step[] {
205 const dirs = new Map<number, string | null>([[0, cwd]])
206 const dirOf = (ctx: number): string | null => {
207 if (!dirs.has(ctx)) {
208 const from = dirOf(parsed.parents[ctx] ?? 0)
209 const start = parsed.starts[ctx]
210 dirs.set(ctx, start === undefined ? from : /^[~$+-]/.test(start) || from === null ? null : resolvePath(from, literal(start)))
211 }
212 return dirs.get(ctx) ?? null
213 }
214 return parsed.segments.map(seg => {
215 const dir = dirOf(seg.ctx)
216 const argv = unwrap(seg.words)
217 const moved = chdirOf(seg.words)
218 const runsIn = moved === null ? dir : /^[~$+-]/.test(moved) || dir === null ? null : resolvePath(dir, literal(moved))
219 const docs = seg.docs.map(d => parsed.heredocs[d]!).filter(d => d !== undefined)
220 const live = docs.filter(d => parsed.liveHeredocs.includes(d))
221 const step: Step = { ...seg, dir: runsIn, check: checkOf(seg.words), commit: commitOf(seg.words, docs, live) }
222 if (argv[0] === 'cd' || argv[0] === 'pushd') {
223 const to = argv[1]
224 dirs.set(seg.ctx, to === undefined || /^[~$+-]/.test(to) || dir === null ? null : resolvePath(dir, literal(to)))
225 } else if (argv[0] === 'popd') dirs.set(seg.ctx, null)
226 return step
227 })
228}
229
230type HiddenRun = { steps: Step[]; before: Step[] }
231
232// The commands inside $(...) and backticks. The call's own substitutions start in
233// the directory of the command that holds them, after the steps before it; each
234// nested shell body's start where that body runs. Bodies are new shells: the outer
235// quoting that hid their substitutions from the call does not hide them from it.
236function hiddenRuns(parsed: Parsed, steps: Step[], command: string, cwd: string): HiddenRun[] {
237 const out: HiddenRun[] = []
238 if (!parsed.complex) return out
239 const sources: { text: string; ctx: number | null }[] = [{ text: command, ctx: null }, ...parsed.bodies]
240 const seen = new Set<string>()
241 for (const src of sources) {
242 for (const sub of substitutions(src.text)) {
243 const key = `${src.ctx}|${sub}`
244 if (seen.has(key)) continue
245 seen.add(key)
246 const at = steps.findIndex(st => (src.ctx === null || st.ctx === src.ctx) && st.words.some(w => w.includes(sub)))
247 const host = at === -1 ? undefined : steps[at]
248 const start = host !== undefined ? host.dir : src.ctx === null ? cwd : steps.find(st => st.ctx === src.ctx)?.dir ?? null
249 const before = at === -1 ? [...steps] : steps.slice(0, at)
250 out.push({ steps: stepsOf(parse(sub), start), before })
251 }
252 }
253 return out
254}
255
256// The repo each run belongs to; null when its directory could not be followed.
257async function rootsFor($: EngineInterface, runs: { check: Check; dir: string | null }[]): Promise<{ root: string | null; kind: ProofKind }[]> {
258 const out: { root: string | null; kind: ProofKind }[] = []
259 for (const r of runs) out.push({ root: r.dir === null ? null : (await rootOf($, r.dir)).root, kind: r.check.kind })
260 return out
261}
262
263const base = (word: string) => word.replace(/^.*\//, '')
264
265// Oathkeeper: the reason to refuse this commit, or null to let it run.
266async function judgeCommit($: EngineInterface, steps: Step[], at: number): Promise<string | null> {
267 const step = steps[at]!
268 const c = step.commit!
269 const target = step.dir === null ? null : c.dirs.reduce((d, next) => resolvePath(d, next), step.dir)
270 const messages = [...c.messages]
271 let unknown = c.unknown
272 if (c.retargeted || target === null) {
273 // The repo git will write to is not one this mod can pin; a claim cannot be checked.
274 const said = claimsIn(messages.join('\n')).length > 0 || unknown || c.reuse !== null || c.file !== null
275 if (!said) return null
276 return 'Proof Decay: the commit may claim checks pass, but the repo it targets cannot be pinned (--git-dir, --work-tree, GIT_DIR, or a directory change like popd / cd ~ / cd -). Commit from the repo directory without them, or keep claims out of the message.'
277 }
278 if (c.file !== null) {
279 try {
280 messages.push(await $.fs.read(resolvePath(target, c.file)))
281 } catch {
282 unknown = true
283 }
284 }
285 if (c.reuse !== null) {
286 try {
287 const r = await $.process.run(['git', '-C', target, 'log', '-1', '--format=%B', c.reuse, '--'], { timeoutMs: 5000 })
288 if (r.exitCode === 0) messages.push(r.stdout)
289 else unknown = true
290 } catch {
291 unknown = true
292 }
293 }
294 const text = messages.join('\n')
295 const claims = claimsIn(text)
296 if (claims.length === 0 && !unknown) return null
297
298 const { root, git } = await rootOf($, target)
299 // Only what runs before the commit can change what it records.
300 if (steps.slice(0, at).some(s => !isHarmless(s.words))) {
301 return (
302 'Proof Decay: this commit message claims checks pass (or cannot be read in advance), and other commands run before the commit in the same call, ' +
303 'so it cannot tell what was verified. Run the checks, then run `git commit` as its own command (git add before it is fine).'
304 )
305 }
306 await refresh($, root)
307 const mine = Object.values(await read($, proofs)).filter(p => p.root === root)
308
309 const kinds = new Set<ProofKind>()
310 for (const claim of claimSets(text)) {
311 if (claim.kind === 'all') {
312 // An exception trims only the all-claim of its own clause.
313 const all: ProofKind[] = ['tests', 'typecheck', 'lint']
314 if (mine.some(p => p.kind === 'build' && p.scope === 'project')) all.push('build')
315 const left = all.filter(k => !claim.except.includes(k))
316 if (left.length === 0) {
317 return 'Proof Decay: the commit message says all checks pass while excepting every one of them, so nothing it claims can be verified. Say which checks passed, or take the claim out.'
318 }
319 for (const k of left) kinds.add(k)
320 } else kinds.add(claim.kind)
321 }
322 if (unknown) {
323 // A message nobody can read may claim anything: every check must hold, tests at least.
324 kinds.add('tests')
325 for (const p of mine) if (p.scope === 'project') kinds.add(p.kind)
326 const shaky = mine.filter(p => !isFresh(p))
327 if (shaky.length > 0) {
328 return (
329 'Proof Decay: the commit message cannot be read before the commit runs (a shell variable, an editor, or a ' +
330 'reused message that could not be read), and some checks in this repo are not fresh: ' +
331 `${shaky.map(describe).join('; ')}. Commit with a literal -m message.`
332 )
333 }
334 }
335 for (const kind of kinds) {
336 const proof = mine.find(p => p.kind === kind && p.scope === 'project')
337 const problem =
338 proof === undefined ? `no whole-project ${NAMES[kind]} run was recorded in ${root} this session`
339 : proof.status === 'fail' ? `the last ${NAMES[kind]} run (\`${proof.label}\`) failed`
340 : proof.staleEdits > 0 ? `the last ${NAMES[kind]} run (\`${proof.label}\`) passed, but ${proof.staleEdits} edit(s) landed since`
341 : proof.doubt !== null ? `the last ${NAMES[kind]} run (\`${proof.label}\`) passed, but ${proof.doubt}`
342 : null
343 if (problem !== null) {
344 const subject = unknown && claims.length === 0 ? 'the commit message cannot be read, so it must hold for every check, and' : `the commit message claims ${NAMES[kind]} pass, but`
345 return (
346 `Proof Decay: ${subject} ${problem}. ` +
347 `Run the ${NAMES[kind]} again (whole project, from the repo root) and commit only if it passes, or take the claim out of the message.`
348 )
349 }
350 }
351
352 // What was tested is the working tree; what is committed is the index plus what this call stages.
353 if (!git) return null
354 if (c.partial) {
355 return 'Proof Decay: the commit message claims checks pass, but this commit takes only part of the changes (pathspecs, --only, --include or --patch), which is not the tree that was tested. Commit everything that was tested, or take the claim out.'
356 }
357 const stagedAll = stagedBefore(steps, at, root)
358 try {
359 if (!(c.all || stagedAll)) {
360 const unstaged = await $.process.run(['git', '-C', root, 'diff', '--quiet', '--no-ext-diff'], { timeoutMs: 10000 })
361 if (unstaged.exitCode === 1) {
362 return (
363 'Proof Decay: the commit message claims checks pass, but the working tree has unstaged changes, so the ' +
364 'commit is not the tree that was tested. Stage everything that was tested (or use -a), or take the claim out.'
365 )
366 }
367 if (unstaged.exitCode !== 0) throw new Error('git diff failed')
368 }
369 if (!stagedAll) {
370 // -a stages tracked files only: untracked files the tests saw stay out.
371 const untracked = await $.process.run(['git', '-c', 'core.quotePath=false', '-C', root, 'ls-files', '-o', '--exclude-standard'], { timeoutMs: 10000 })
372 if (untracked.exitCode !== 0) throw new Error('git ls-files failed')
373 const names = untracked.stdout.split('\n').filter(Boolean)
374 if (names.length > 0) {
375 return (
376 `Proof Decay: the commit message claims checks pass, but ${names.length} untracked file(s) were part of the tested tree ` +
377 `and are not in this commit (${names.slice(0, 5).join(', ')}${names.length > 5 ? ', …' : ''}). ` +
378 'Add them (or remove them and rerun the checks), or take the claim out.'
379 )
380 }
381 }
382 } catch {
383 return 'Proof Decay: the commit message claims checks pass, but git could not report whether the commit matches the tested tree. Try again, or take the claim out.'
384 }
385 return null
386}
387
388export const register: Register = on => {
389 on('session.start', async ($, e, next) => {
390 await $.command.register({
391 name: 'proofs',
392 description: 'Proof Decay: list recorded test/typecheck/lint/build results and whether they still hold ("/proofs clear" forgets them)',
393 immediate: true,
394 })
395 return next(e)
396 })
397
398 on('command.run', { command: 'proofs' }, async ($, e) => {
399 if (e.args.trim() === 'clear') {
400 await update($, proofs, () => ({}))
401 return { text: 'Proof Decay: all recorded results forgotten.' }
402 }
403 await refresh($)
404 const all = Object.values(await read($, proofs))
405 if (all.length === 0) return { text: 'Proof Decay: no verification runs recorded this session yet.' }
406 const now = await $.clock.now()
407 const lines = all
408 .sort((a, b) => a.root.localeCompare(b.root) || KINDS.indexOf(a.kind) - KINDS.indexOf(b.kind) || b.at - a.at)
409 .map(p => {
410 const ago = Math.max(0, Math.round((now - p.at) / 60000))
411 const files = p.files.length > 0 ? ` · covers ${p.files.join(', ')}` : ''
412 const why = p.doubt !== null ? ` — ${p.doubt}` : ''
413 return `${describe(p)} · \`${p.label}\` · ${ago} min ago · ${p.root}${files}${why}`
414 })
415 return { text: `Proof Decay:\n${lines.join('\n')}` }
416 })
417
418 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
419 const parsed = parse(e.command)
420 const cwd = await $.session.cwd()
421
422 // Each simple command with the directory it runs in. A `bash -c` body starts
423 // where the call's shell is, and its cd ends with it.
424 const steps = stepsOf(parsed, cwd)
425
426 for (let i = 0; i < steps.length; i += 1) {
427 if (steps[i]!.commit === null) continue
428 const reason = await judgeCommit($, steps, i)
429 if (reason !== null) return { deny: reason }
430 }
431
432 // What runs inside $(...) and backticks, in the call and in each nested shell body,
433 // with the directory it starts from and the steps of the call already run by then.
434 const subs = hiddenRuns(parsed, steps, e.command, cwd)
435
436 // A commit inside a substitution runs too: judge it after its own body's earlier
437 // steps and the call's steps before the command that holds it.
438 for (const sub of subs) {
439 for (let i = 0; i < sub.steps.length; i += 1) {
440 if (sub.steps[i]!.commit === null) continue
441 const around = [...sub.before, ...sub.steps.slice(0, i + 1)]
442 const reason = await judgeCommit($, around, around.length - 1)
443 if (reason !== null) return { deny: reason }
444 }
445 }
446 const checks = steps.filter((s): s is Step & { dir: string; check: Check } => s.check !== null && s.dir !== null)
447 // Checks hidden in $(...) or backticks run too, with outcomes nobody sees.
448 const hidden = subs.flatMap(sub => sub.steps.filter(st => st.check !== null).map(st => ({ check: st.check!, dir: st.dir })))
449 const before = new Map<string, string | null>()
450 const editsBefore = await read($, edits)
451 for (const s of checks) {
452 const r = await rootOf($, s.dir)
453 if (!before.has(r.root)) before.set(r.root, await fingerprint($, r))
454 }
455
456 const ran = await next(e)
457 if (ran.deny !== undefined) return ran
458 const result = (ran.result ?? {}) as BashResult
459 const failed = ran.isError === true
460
461 // Files the command itself wrote make earlier proofs stale before this run's are recorded.
462 const diff = result.bashEditDiff
463 const written = [...new Set([...(diff?.changedFiles ?? []), ...(diff?.files?.map(f => f.filePath) ?? [])])]
464 .map(p => resolvePath(cwd, p))
465 .filter(p => !ignoredPath(p))
466 await staleFiles($, written)
467
468 const unfinished = e.run_in_background === true || result.backgroundTaskId !== undefined || result.interrupted === true
469 // A check in a directory this mod lost track of, or one that never finished,
470 // says nothing new: it only casts doubt on the earlier pass of its kind.
471 const lost = steps.filter(s => s.check !== null && s.dir === null).map(s => ({ check: s.check!, dir: null }))
472 const doubtful: { check: Check; dir: string | null }[] = unfinished
473 ? [...checks.map(s => ({ check: s.check, dir: s.dir as string | null })), ...hidden, ...lost]
474 : lost
475 if (doubtful.length > 0) {
476 const marks = await rootsFor($, doubtful)
477 await update($, proofs, all => {
478 const out: Record<string, Proof> = { ...all }
479 for (const m of marks) {
480 for (const key of Object.keys(out)) {
481 const p = out[key]!
482 if (p.kind !== m.kind || p.scope !== 'project' || p.status !== 'pass') continue
483 if (m.root !== null && p.root !== m.root) continue
484 out[key] = { ...p, doubt: unfinished ? 'a later run of it did not finish' : AMBIGUOUS }
485 }
486 }
487 return out
488 })
489 }
490 if ((checks.length > 0 || hidden.length > 0) && !unfinished) {
491 const now = await $.clock.now()
492 const editedMeanwhile = (await read($, edits)) !== editsBefore + (written.length > 0 ? 1 : 0)
493 const recorded: Record<string, Proof> = {}
494 // root null: a directory nobody could follow, so every repo's pass of that kind is in doubt.
495 const undermined: { root: string | null; kind: ProofKind; why: string; over?: boolean }[] = []
496 const seps = steps.map(s => s.sep)
497 // Substitutions and heredocs inside words do not change whose exit status the call has; grouping does.
498 const andChain = !parsed.grouped && seps.every(sep => sep === '' || sep === '&&')
499
500 // A check in a substitution may run before or after the visible one: its doubt
501 // stands even over a pass recorded in this same call.
502 for (const m of await rootsFor($, hidden)) undermined.push({ root: m.root, kind: m.kind, why: AMBIGUOUS, over: true })
503
504 for (const s of checks) {
505 const index = steps.indexOf(s)
506 const isLast = index === steps.length - 1
507 const before_ = seps.slice(0, index)
508 // When this check's own outcome can be read from the one exit status the call has.
509 let outcome: 'pass' | 'fail' | null = null
510 if (!parsed.grouped && s.sep !== '&' && s.sep !== '|') {
511 if (andChain && !failed) outcome = 'pass'
512 else if (andChain && failed && checks.length === 1 && steps.every(o => o === s || isInfallible(o.words))) outcome = 'fail'
513 else if (isLast && before_.every(sep => sep === ';' || sep === '&&') && !failed) outcome = 'pass'
514 else if (isLast && before_.every(sep => sep === ';') && failed) outcome = 'fail'
515 }
516 const r = await rootOf($, s.dir)
517 if (outcome === null) {
518 undermined.push({ root: r.root, kind: s.check.kind, why: AMBIGUOUS })
519 continue
520 }
521 const scope = await demote($, s.check, s.dir, r.root)
522 const after = await fingerprint($, r)
523 let doubt: string | null = null
524 if (outcome === 'pass') {
525 const tail = steps.slice(index + 1)
526 const otherChecks = checks.length > 1
527 if (r.git && after === null) doubt = UNSEEN
528 else if (editedMeanwhile) doubt = 'files were edited while it ran'
529 else if (after !== before.get(r.root) && (otherChecks || !tail.every(t => isHarmless(t.words)))) doubt = 'files changed during the same command'
530 }
531 const entry = { kind: s.check.kind, scope, label: s.check.label }
532 recorded[keyOf(r.root, entry)] = {
533 root: r.root, ...entry, files: s.check.files,
534 status: outcome, at: now, staleEdits: 0, fingerprint: after, doubt,
535 }
536 if (outcome === 'fail' && scope !== 'project') undermined.push({ root: r.root, kind: s.check.kind, why: `a later ${scope} run failed` })
537 }
538 await update($, proofs, all => {
539 const out: Record<string, Proof> = { ...all, ...recorded }
540 for (const u of undermined) {
541 for (const key of Object.keys(out)) {
542 const p = out[key]!
543 if (p.kind !== u.kind || p.scope !== 'project' || p.status !== 'pass') continue
544 if (u.root !== null && p.root !== u.root) continue
545 if (recorded[key] !== undefined && u.over !== true) continue
546 out[key] = { ...p, doubt: u.why }
547 }
548 }
549 return out
550 })
551 }
552
553 // After a commit, say plainly what in that repo was never re-verified.
554 const commit = steps.find(s => s.commit !== null && s.dir !== null)
555 if (commit !== undefined && !failed) {
556 const target = commit.commit!.dirs.reduce((d, n) => resolvePath(d, n), commit.dir!)
557 const { root } = await rootOf($, target)
558 await refresh($, root)
559 const unverified = Object.values(await read($, proofs)).filter(p => p.root === root && !isFresh(p))
560 if (unverified.length > 0) {
561 const note =
562 'Proof Decay: this commit includes changes that were not re-verified: ' +
563 unverified.map(describe).join('; ') +
564 '. Say so plainly when you report the commit, or rerun those checks.'
565 return { ...ran, context: [...(ran.context ?? []), note] }
566 }
567 }
568 return ran
569 })
570
571 on('tool.call', async ($, e, next) => {
572 if (!EDITORS.has(e.tool)) return next(e)
573 const ran = await next(e)
574 if (ran.deny !== undefined || ran.isError === true) return ran
575 if ((ran.result as { staged?: boolean } | undefined)?.staged === true) return ran
576 const call = e as { file_path?: unknown; notebook_path?: unknown }
577 const path = String(call.file_path ?? call.notebook_path ?? '')
578 if (path !== '') await staleFiles($, [resolvePath(await $.session.cwd(), path)])
579 return ran
580 })
581
582 on('turn.complete', async ($, e, next) => {
583 const done = await next(e)
584 await refresh($)
585 return done
586 })
587
588 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
589 if (e.props.hasSurvey) return next(e)
590 const cwd = await $.session.cwd()
591 const all = Object.values(await read($, proofs)).filter(p => inRoot(p.root, cwd))
592 // Per kind: the whole-project run if there is one, else the latest.
593 const shown = KINDS.map(kind => {
594 const mine = all.filter(p => p.kind === kind)
595 return mine.find(p => p.scope === 'project') ?? mine.sort((a, b) => b.at - a.at)[0]
596 }).filter((p): p is Proof => p !== undefined)
597 if (shown.length === 0) return next(e)
598 const below = await next(e)
599 const { Box, Text } = $.ui.resolve(e)
600 return (
601 <Box flexDirection="column">
602 <Box>
603 {shown.map((p, i) => (
604 <Text color={p.status === 'fail' ? 'red' : isFresh(p) ? 'green' : 'yellow'}>
605 {i > 0 ? ' · ' : ''}
606 {describe(p)}
607 </Text>
608 ))}
609 </Box>
610 {below}
611 </Box>
612 )
613 })
614}
615hooks/shell.ts 746 lines1import type { ProofKind } from '../types'
2
3// A simple command and the operator that follows it ('' after the last). ctx is 0
4// for the call's own shell and n for the n-th `bash -c` body, whose cd ends with it.
5// docs: indexes into Parsed.heredocs of the heredocs this command opened.
6export type Segment = { words: string[]; sep: string; ctx: number; docs: number[] }
7// complex: substitutions, backticks, subshells or heredocs whose effect a parse cannot see.
8// grouped: subshells, brace groups or nested shells, whose exit status is not one segment's.
9// liveHeredocs: bodies whose delimiter was unquoted, so $ in them expands.
10// parents: each nested shell's (or subshell's) context and the context that started it.
11// starts: the directory a nested shell starts in when a wrapper moves it (`env -C dir bash -c`).
12// bodies: each nested shell's decoded text and context, for what runs inside it.
13export type Parsed = {
14 segments: Segment[]
15 complex: boolean
16 grouped: boolean
17 heredocs: string[]
18 liveHeredocs: string[]
19 parents: Record<number, number>
20 starts: Record<number, string>
21 bodies: { text: string; ctx: number }[]
22}
23
24// $ and ` that the shell will not expand (single quotes, a backslash) are carried
25// in words as these private-use characters, so a reader can tell them from live ones.
26const LITERAL_DOLLAR = '\uE000'
27const LITERAL_TICK = '\uE001'
28// The word as the program receives it.
29const DOC = /\uE002(\d+)\uE002/g
30export const literal = (word: string) => word.replace(/\uE000/g, '$').replace(/\uE001/g, '`').replace(DOC, '')
31const shield = (text: string) => text.replace(/\$/g, LITERAL_DOLLAR).replace(/`/g, LITERAL_TICK)
32
33const HEREDOC = /<<-?[ \t]*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\1[^\n]*\n([\s\S]*?)\n[ \t]*\2(?=\s|\)|$)/g
34
35// Splits a command into simple commands, honouring quotes and comments: operators
36// inside quotes, $(...), backticks or a # comment are text, never separators.
37export function parse(command: string): Parsed {
38 return parseIn(command, { next: 0 }, 0)
39}
40
41function parseIn(command: string, ids: { next: number }, own: number): Parsed {
42 const parents: Record<number, number> = {}
43 const starts: Record<number, string> = {}
44 const bodies: { text: string; ctx: number }[] = []
45 // Contexts open at this point: a subshell `( … )` is a shell of its own; braces are not.
46 const stack = [own]
47 const ctxNow = () => stack[stack.length - 1]!
48 const heredocs: string[] = []
49 const liveHeredocs: string[] = []
50 let complex = false
51 let grouped = false
52 // Heredoc bodies are data; keep them aside and drop them from what is split.
53 // Each body is marked in place, so the command that opened it can find it.
54 const text = command.replace(HEREDOC, (_m, quote: string, tag: string, body: string) => {
55 heredocs.push(body)
56 if (quote === '') liveHeredocs.push(body)
57 complex = true
58 return `<<${tag}\uE002${heredocs.length - 1}\uE002`
59 })
60
61 const segments: Segment[] = []
62 let words: string[] = []
63 let word = ''
64 let hasWord = false
65 const endWord = () => {
66 if (hasWord) words.push(word)
67 word = ''
68 hasWord = false
69 }
70 const endSegment = (sep: string) => {
71 endWord()
72 if (words.length > 0) segments.push({ words, sep, ctx: ctxNow(), docs: [...words.join(' ').matchAll(DOC)].map(m => Number(m[1])) })
73 else if (segments.length > 0 && sep !== '') segments[segments.length - 1]!.sep = sep
74 words = []
75 }
76
77 for (let i = 0; i < text.length; i += 1) {
78 const c = text[i]!
79 const two = text.slice(i, i + 2)
80 if (c === '\\' && i + 1 < text.length) {
81 if (text[i + 1] !== '\n') word += shield(text[i + 1]!)
82 hasWord = hasWord || text[i + 1] !== '\n'
83 i += 1
84 } else if (c === '#' && !hasWord) {
85 // A comment runs to the end of the line.
86 const end = text.indexOf('\n', i)
87 i = (end === -1 ? text.length : end) - 1
88 } else if (c === "'") {
89 const end = text.indexOf("'", i + 1)
90 const stop = end === -1 ? text.length : end
91 word += shield(text.slice(i + 1, stop))
92 hasWord = true
93 i = stop
94 } else if (c === '"') {
95 let j = i + 1
96 while (j < text.length && text[j] !== '"') {
97 if (text[j] === '\\' && j + 1 < text.length) {
98 word += shield(text[j + 1]!)
99 j += 2
100 continue
101 }
102 if (text.slice(j, j + 2) === '$(' || text[j] === '`') complex = true
103 word += text[j]
104 j += 1
105 }
106 hasWord = true
107 i = j
108 } else if (two === '$(') {
109 complex = true
110 let depth = 0
111 let j = i + 1
112 for (; j < text.length; j += 1) {
113 if (text[j] === '(') depth += 1
114 else if (text[j] === ')' && (depth -= 1) === 0) break
115 }
116 word += text.slice(i, j + 1)
117 hasWord = true
118 i = j
119 } else if (c === '`') {
120 complex = true
121 const end = text.indexOf('`', i + 1)
122 const stop = end === -1 ? text.length : end
123 word += text.slice(i, stop + 1)
124 hasWord = true
125 i = stop
126 } else if (two === '&&' || two === '||') {
127 endSegment(two)
128 i += 1
129 } else if (c === ';' || c === '\n' || c === '|' || c === '&') {
130 // `2>&1` and `&>` are redirections, not separators.
131 if (c === '&' && (text[i - 1] === '>' || text[i + 1] === '>')) {
132 word += c
133 hasWord = true
134 } else {
135 endSegment(c === '\n' ? ';' : c)
136 }
137 } else if (c === '(' || c === ')') {
138 // A subshell: its cd ends at the `)`, and its exit status is not one command's.
139 complex = true
140 grouped = true
141 if (c === '(') {
142 endWord()
143 if (words.length > 0) endSegment('')
144 const sub = (ids.next += 1)
145 parents[sub] = ctxNow()
146 stack.push(sub)
147 } else {
148 endSegment('')
149 if (stack.length > 1) stack.pop()
150 }
151 } else if ((c === '{' || c === '}') && !hasWord) {
152 complex = true
153 grouped = true
154 } else if (c === ' ' || c === '\t') {
155 endWord()
156 } else {
157 word += c
158 hasWord = true
159 }
160 }
161 endSegment('')
162 // A trailing `;` or newline ends the command; nothing follows it.
163 const last = segments[segments.length - 1]
164 if (last !== undefined && last.sep === ';') last.sep = ''
165 // `bash -c '...'` runs a command of its own: look inside it, in place.
166 const flat: Segment[] = []
167 for (const seg of segments) {
168 const inner = shellInner(seg.words)
169 if (inner === null) {
170 flat.push(seg)
171 continue
172 }
173 // The body runs in a shell of its own: its cd ends with it. Its && / ; chain
174 // has the same exit status flattened in place, so only grouping inside it is.
175 const ctx = (ids.next += 1)
176 parents[ctx] = seg.ctx
177 const body = literal(inner)
178 const p = parseIn(body, ids, ctx)
179 const offset = heredocs.length
180 heredocs.push(...p.heredocs)
181 liveHeredocs.push(...p.liveHeredocs)
182 Object.assign(parents, p.parents)
183 Object.assign(starts, p.starts)
184 bodies.push({ text: body, ctx }, ...p.bodies)
185 if (p.complex) complex = true
186 // A group inside the child does not hide the parent chain's exit status: the
187 // child's own && / ; chain is flattened in place and judged by its separators.
188 // `env -C dir bash -c '…'` starts the child shell in dir.
189 const moved = chdirOf(seg.words)
190 if (moved !== null) starts[ctx] = moved
191 p.segments.forEach((s, i) =>
192 flat.push({ ...s, docs: s.docs.map(d => d + offset), sep: i === p.segments.length - 1 ? seg.sep : s.sep }),
193 )
194 }
195 return { segments: flat, complex, grouped, heredocs, liveHeredocs, parents, starts, bodies }
196}
197
198// The commands inside $(...) and backticks: they run too.
199export function substitutions(command: string): string[] {
200 const found: string[] = []
201 let doubled = false
202 for (let i = 0; i < command.length; i += 1) {
203 if (command[i] === '\\') {
204 i += 1
205 } else if (command[i] === '"') {
206 doubled = !doubled
207 } else if (command[i] === "'" && !doubled) {
208 // Single quotes outside double quotes: nothing inside runs.
209 const end = command.indexOf("'", i + 1)
210 if (end === -1) break
211 i = end
212 } else if (command.slice(i, i + 2) === '$(') {
213 let depth = 0
214 let j = i + 1
215 for (; j < command.length; j += 1) {
216 if (command[j] === '(') depth += 1
217 else if (command[j] === ')' && (depth -= 1) === 0) break
218 }
219 found.push(command.slice(i + 2, j))
220 i = j
221 } else if (command[i] === '`') {
222 const end = command.indexOf('`', i + 1)
223 if (end === -1) break
224 found.push(command.slice(i + 1, end))
225 i = end
226 }
227 }
228 return found
229}
230
231function shellInner(words: string[]): string | null {
232 const argv = unwrap(words)
233 if (!['bash', 'sh', 'zsh'].includes(base(argv[0] ?? ''))) return null
234 const at = argv.findIndex((w, i) => i > 0 && /^-[a-z]*c$/.test(w))
235 return at === -1 ? null : argv[at + 1] ?? null
236}
237
238// Redirections and their targets say where output goes, not what runs.
239export function stripRedirects(words: string[]): string[] {
240 const out: string[] = []
241 for (let i = 0; i < words.length; i += 1) {
242 const w = words[i]!
243 if (/^(\d*|&)(>>?|<)$/.test(w) || /^\d*>&$/.test(w)) {
244 i += 1
245 continue
246 }
247 if (/^(\d*|&)(>>?|<)./.test(w) || /^\d*>&\d+$/.test(w)) continue
248 out.push(w)
249 }
250 return out
251}
252
253// Drops what runs a tool without being it: env assignments, npx and its flags, time, python -m.
254export function unwrap(argv: string[]): string[] {
255 let rest = [...argv]
256 for (;;) {
257 const first = rest[0]
258 if (first === undefined) return rest
259 if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(first)) rest = rest.slice(1)
260 else if (WRAPPERS[base(first)] !== undefined) {
261 const takesValue = WRAPPERS[base(first)]!
262 rest = rest.slice(1)
263 while (rest[0]?.startsWith('-')) rest = rest.slice(takesValue.includes(rest[0]) ? 2 : 1)
264 } else if (['pnpm', 'yarn'].includes(first) && ['exec', 'dlx'].includes(rest[1] ?? '')) rest = rest.slice(2)
265 else if (['uv', 'poetry', 'pipenv'].includes(first) && rest[1] === 'run') rest = rest.slice(2)
266 else if (/^python[\d.]*$/.test(base(first)) && rest[1] === '-m') rest = rest.slice(2)
267 else return rest
268 }
269}
270
271const base = (word: string) => word.replace(/^.*\//, '')
272
273// The directory a wrapper runs the command in (`env -C dir`, `env --chdir=dir`), if any.
274export function chdirOf(words: string[]): string | null {
275 const argv = stripRedirects(words)
276 let i = 0
277 while (i < argv.length) {
278 const w = argv[i]!
279 if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(w)) {
280 i += 1
281 continue
282 }
283 const tool = base(w)
284 if (tool === 'env') {
285 i += 1
286 while (i < argv.length) {
287 const f = argv[i]!
288 if (f === '-C' || f === '--chdir') return argv[i + 1] ?? null
289 if (f.startsWith('--chdir=')) return f.slice(8)
290 if (/^-C./.test(f)) return f.slice(2)
291 if (f === '-u' || f === '--unset' || f === '-S' || f === '--split-string') i += 2
292 else if (f.startsWith('-') || /^[A-Za-z_][A-Za-z0-9_]*=/.test(f)) i += 1
293 else break
294 }
295 continue
296 }
297 const takesValue = WRAPPERS[tool]
298 if (takesValue === undefined) return null
299 // Another wrapper (sudo, nice, time, …) in front: skip it and its own flags.
300 i += 1
301 while (argv[i]?.startsWith('-')) i += takesValue.includes(argv[i]!) ? 2 : 1
302 }
303 return null
304}
305
306// Commands that run another, with the flags of theirs that take a value.
307const WRAPPERS: Record<string, string[]> = {
308 npx: ['-p', '--package', '-c', '--call'], bunx: ['-p', '--package'], time: ['-f', '-o'], command: [],
309 exec: ['-a'], nice: ['-n'], env: ['-u', '-C', '-S'], sudo: ['-u', '-g', '-C', '-D', '-h', '-p', '-U'],
310}
311
312export type Check = {
313 kind: ProofKind
314 scope: 'project' | 'scoped' | 'filtered'
315 files: string[]
316 label: string
317 tool: string
318 // Cargo only: the run named --workspace / --all.
319 wholeWorkspace: boolean
320}
321
322// Flags whose next word is a value.
323const VALUE = new Set([
324 '-c', '--config', '--config-file', '--rootDir', '--reporter', '--outputFile', '--junitxml', '--cov',
325 '--format', '-f', '--output', '-configuration', '-destination', '-sdk', '-scheme', '-workspace',
326 '--target', '--features', '--profile', '-j', '--jobs', '--maxWorkers', '-n', '--numprocesses', '--timeout',
327 '--testTimeout', '--env', '--environment', '--color', '--max-warnings', '--ext', '--cache-location',
328 '--pretty', '--maxWorkers', '--reporters', '--log-level', '--seed',
329])
330// Flags that pick some tests by name or by change: the run proves nothing about the rest.
331const FILTER = new Set([
332 '-k', '-t', '-m', '--testNamePattern', '--testPathPattern', '--grep', '-g', '--filter', '--tests',
333 '--run', '-run', '-only-testing', '-skip-testing', '--shard', '--test', '--bin', '--example', '--bench',
334 '--exclude', '--ignore', '--ignore-glob', '--deselect', '--testPathIgnorePatterns', '--skip', '-skip',
335])
336const FILTER_BARE = new Set([
337 '--changed', '--onlyChanged', '-o', '--lf', '--last-failed', '--sf', '--stepwise', '--lib', '--doc',
338 '--bins', '--examples', '--findRelatedTests', '--only-failures', '--related',
339])
340// Flags that narrow to one package or directory of a monorepo.
341const PACKAGE = new Set(['--workspace', '--prefix', '-C', '-p', '--package', '--manifest-path', '--project', '--dir', '--cwd'])
342// Flags that make a runner list, describe or maybe skip instead of check.
343const NOT_A_RUN = new Set([
344 '--listTests', '--list-tests', '--collect-only', '--co', '--showConfig', '--version', '--help', '-h',
345 '--list', '--dry-run', '--no-run', '--watch', '--watchAll', '--init', '--print-config', '--fix-dry-run',
346 '--if-present', '--noCheck', '--listFilesOnly', '-list',
347])
348
349const PROJECT_SCRIPTS: Record<string, ProofKind> = {
350 test: 'tests', tests: 'tests', lint: 'lint', typecheck: 'typecheck', 'type-check': 'typecheck', tsc: 'typecheck',
351 'check-types': 'typecheck', types: 'typecheck', build: 'build',
352}
353
354function scriptKind(script: string): ProofKind | null {
355 if (/^tests?(:|$)|^(unit|e2e|spec|jest|vitest)(:|$)/.test(script)) return 'tests'
356 if (/type-?check|^tsc(:|$)|^types(:|$)|^check-types(:|$)/.test(script)) return 'typecheck'
357 if (/^lint(:|$)/.test(script)) return 'lint'
358 if (/^build(:|$)/.test(script)) return 'build'
359 return null
360}
361
362// What the arguments after the runner say about the run's reach.
363function reach(args: string[], tool: string): { scope: Check['scope']; files: string[]; whole: boolean } | null {
364 let scope: Check['scope'] = 'project'
365 let whole = false
366 const files: string[] = []
367 const narrow = (to: Check['scope']) => {
368 if (scope !== 'filtered') scope = to
369 }
370 for (let i = 0; i < args.length; i += 1) {
371 const word = args[i]!
372 const eq = word.indexOf('=')
373 const flag = eq === -1 ? word : word.slice(0, eq)
374 const inline = eq === -1 ? undefined : word.slice(eq + 1)
375 if (word === '--') continue
376 if (flag === '-w') {
377 // -w names a workspace for npm, sets workers for jest, and watches everywhere else.
378 if (['npm', 'yarn', 'pnpm', 'bun'].includes(tool)) narrow('scoped')
379 else if (tool !== 'jest') return null
380 if (inline === undefined) i += 1
381 continue
382 }
383 if (NOT_A_RUN.has(flag)) return null
384 if (word.startsWith('-')) {
385 if (tool === 'cargo' && (flag === '--workspace' || flag === '--all')) {
386 whole = true
387 } else if (FILTER.has(flag)) {
388 scope = 'filtered'
389 if (inline === undefined) i += 1
390 } else if (FILTER_BARE.has(flag) || /^-(only|skip)-testing:/.test(word)) {
391 scope = 'filtered'
392 } else if (/^-[ktm]./.test(word) && ['pytest', 'py.test', 'jest', 'vitest'].includes(tool)) {
393 // -kfoo, -tname: a filter with its value attached.
394 scope = 'filtered'
395 } else if (PACKAGE.has(flag)) {
396 const value = inline ?? args[i + 1] ?? ''
397 if (inline === undefined) i += 1
398 // `tsc -p tsconfig.json` / `-p .` at the root is still the whole project.
399 if (!(tool === 'tsc' && /^(\.\/?)?(tsconfig\.json)?$/.test(value))) narrow('scoped')
400 } else if (VALUE.has(flag) && inline === undefined) {
401 i += 1
402 }
403 continue
404 }
405 if (word === '.' || word === './' || word === './...' || word === '...') continue
406 // A flag's boolean value (`--pretty false`), not a path.
407 if (word === 'true' || word === 'false') continue
408 // A positional names files, a directory, a package or a name filter: never the whole project.
409 files.push(word.replace(/^\.\//, '').replace(/\/$/, ''))
410 narrow('scoped')
411 }
412 return { scope, files, whole }
413}
414
415type Hit = { kind: ProofKind; args: string[]; tool: string; forceScoped?: boolean }
416
417function classify(argv: string[]): Hit | null {
418 const [a = '', b = '', c = ''] = argv
419 const tool = base(a)
420 if (['npm', 'yarn', 'pnpm', 'bun'].includes(tool)) {
421 if (tool === 'bun' && b === 'test') return { kind: 'tests', args: argv.slice(2), tool: 'bun-test' }
422 // Workspace flags may come before the script.
423 let rest = argv.slice(1)
424 let scoped = false
425 while (rest[0]?.startsWith('-')) {
426 const word = rest[0]
427 const flag = word.includes('=') ? word.slice(0, word.indexOf('=')) : word
428 if (NOT_A_RUN.has(flag)) return null
429 if (PACKAGE.has(flag) || ['--filter', '-F', '-w'].includes(flag)) {
430 scoped = true
431 rest = rest.slice(word.includes('=') ? 1 : 2)
432 } else rest = rest.slice(1)
433 }
434 const isRun = rest[0] === 'run' || rest[0] === 'run-script'
435 const script = isRun ? rest[1] ?? '' : rest[0] ?? ''
436 const kind = scriptKind(script)
437 if (kind === null) return null
438 const args = rest.slice(isRun ? 2 : 1)
439 return { kind, args, tool, forceScoped: scoped || PROJECT_SCRIPTS[script] === undefined }
440 }
441 if (['jest', 'vitest', 'mocha', 'ava', 'pytest', 'py.test'].includes(tool)) {
442 if (tool === 'vitest' && b === 'watch') return null
443 const args = argv.slice(1)
444 if (tool === 'vitest' && b === 'run') return { kind: 'tests', args: args.slice(1), tool }
445 if (tool === 'vitest' && b === 'related') return { kind: 'tests', args: ['--related', ...args.slice(1)], tool }
446 return { kind: 'tests', args, tool }
447 }
448 if (tool === 'nextest' && b === 'run') return { kind: 'tests', args: argv.slice(2), tool: 'cargo' }
449 if (tool === 'playwright' && b === 'test') return { kind: 'tests', args: argv.slice(2), tool }
450 if (['tsc', 'vue-tsc', 'mypy', 'pyright'].includes(tool)) return { kind: 'typecheck', args: argv.slice(1), tool: tool === 'vue-tsc' ? 'tsc' : tool }
451 if (['eslint', 'flake8', 'pylint', 'swiftlint', 'golangci-lint', 'rubocop', 'ktlint'].includes(tool)) {
452 if (tool === 'golangci-lint' && b === 'run') return { kind: 'lint', args: argv.slice(2), tool }
453 return { kind: 'lint', args: argv.slice(1), tool }
454 }
455 if (tool === 'ruff' && (b === 'check' || b === '')) return { kind: 'lint', args: argv.slice(2), tool }
456 if (tool === 'biome' && ['check', 'lint', 'ci'].includes(b)) return { kind: 'lint', args: argv.slice(2), tool }
457 if (tool === 'go') {
458 const kind = ({ test: 'tests', vet: 'lint', build: 'build' } as const)[b as 'test' | 'vet' | 'build']
459 if (kind === undefined) return null
460 const args = argv.slice(2)
461 // -c compiles the test binary, -list lists tests: neither runs them.
462 if (args.some(w => w === '-c' || w === '-list' || w.startsWith('-list='))) return null
463 // Bare `go test` checks only the package in this directory; `./...` is everything under it.
464 return { kind, args, tool, forceScoped: !(args.includes('./...') || args.includes('...')) }
465 }
466 if (tool === 'cargo') {
467 const kind = ({ test: 'tests', nextest: 'tests', check: 'typecheck', clippy: 'lint', build: 'build' } as const)[b as 'test']
468 if (kind === undefined) return null
469 return { kind, args: argv.slice(b === 'nextest' && c === 'run' ? 3 : 2), tool }
470 }
471 if (tool === 'swift' && (b === 'test' || b === 'build')) return { kind: b === 'test' ? 'tests' : 'build', args: argv.slice(2), tool }
472 if (tool === 'xcodebuild') {
473 const kind = argv.includes('test') ? 'tests' : argv.includes('build') ? 'build' : null
474 if (kind === null || argv.includes('-dry-run') || argv.includes('-n')) return null
475 // Only the filters matter here; the rest are settings.
476 return { kind, args: argv.slice(1).filter(w => /^-(only|skip)-testing/.test(w)), tool }
477 }
478 if (/^(gradlew|gradle)$/.test(tool)) {
479 if (argv.some(w => ['--dry-run', '-m', '--help', '--status'].includes(w))) return null
480 const tasks = argv.slice(1).filter(w => !w.startsWith('-'))
481 const tests = tasks.filter(t => /(^|:)(test|check|connectedAndroidTest)\w*$/i.test(t))
482 const builds = tasks.filter(t => /(^|:)(build|assemble)\w*$/i.test(t))
483 const picked = tests.length > 0 ? tests : builds
484 if (picked.length === 0) return null
485 return {
486 kind: tests.length > 0 ? 'tests' : 'build',
487 args: argv.includes('--tests') ? ['--tests', 'x'] : [],
488 tool,
489 forceScoped: picked.some(t => t.includes(':')),
490 }
491 }
492 if (tool === 'make') {
493 if (argv.some(w => ['-n', '--dry-run', '--just-print', '-q', '--question'].includes(w))) return null
494 const rest = argv.slice(1)
495 let elsewhere = false
496 const targets: string[] = []
497 for (let i = 0; i < rest.length; i += 1) {
498 const w = rest[i]!
499 if (w === '-C' || w === '--directory') {
500 elsewhere = true
501 i += 1
502 } else if (/^-C.|^--directory=/.test(w)) elsewhere = true
503 else if (w === '-f' || w === '-j' || w === '--file' || w === '-I') i += 1
504 else if (!w.startsWith('-') && !w.includes('=')) targets.push(w)
505 }
506 const target = targets[0]
507 if (target === 'test' || target === 'check') return { kind: 'tests', args: [], tool, forceScoped: elsewhere }
508 if (target === 'lint') return { kind: 'lint', args: [], tool, forceScoped: elsewhere }
509 }
510 return null
511}
512
513// The verification a simple command runs, if any.
514export function checkOf(words: string[]): Check | null {
515 const argv = unwrap(stripRedirects(words))
516 const hit = classify(argv)
517 if (hit === null) return null
518 const r = reach(hit.args, hit.tool)
519 if (r === null) return null
520 const scope = r.scope === 'project' && hit.forceScoped === true ? 'scoped' : r.scope
521 return {
522 kind: hit.kind, scope, files: [...new Set(r.files.map(literal))].sort(), label: literal(argv.join(' ')).slice(0, 120),
523 tool: hit.tool, wholeWorkspace: r.whole,
524 }
525}
526
527// Commands that change nothing a check depends on.
528const HARMLESS = /^(cd|pushd|popd|echo|printf|true|ls|pwd|which|date|sleep)$/
529export function isHarmless(words: string[]): boolean {
530 if (words.some(w => /[<>]/.test(w) || w.includes('$(') || w.includes('`'))) return false
531 const argv = unwrap(words)
532 const tool = base(argv[0] ?? '')
533 if (HARMLESS.test(tool)) return true
534 return tool === 'git' && ['add', 'status', 'diff', 'log', 'show', 'rev-parse', 'branch'].includes(argv[1] ?? '')
535}
536
537// Commands that cannot fail, so a failed chain was not their doing.
538export function isInfallible(words: string[]): boolean {
539 if (words.some(w => /[<>]/.test(w) || w.includes('$(') || w.includes('`'))) return false
540 return /^(echo|printf|true|:)$/.test(base(unwrap(words)[0] ?? ''))
541}
542
543export type Commit = {
544 // Directories git -C moves through, in order.
545 dirs: string[]
546 messages: string[]
547 file: string | null
548 // A message to read from history: `--amend` keeping it, or -C REV.
549 reuse: string | null
550 // The message cannot be seen (a variable, an editor) before the commit runs.
551 unknown: boolean
552 // --git-dir / --work-tree / GIT_DIR: the target repo is not the directory.
553 retargeted: boolean
554 // -a / --all: tracked changes are staged by the commit itself.
555 all: boolean
556 // Pathspecs, --only or --include: only part of the index is committed.
557 partial: boolean
558}
559
560// Shell expansions git would see substituted: the text is not known in advance.
561const EXPANDS = /\$[({A-Za-z_0-9?#@*!$-]|`/
562
563// A `git commit` and where its message comes from; null if the command is not one.
564export function commitOf(words: string[], heredocs: string[], liveHeredocs: string[] = []): Commit | null {
565 const retargetedByEnv = words.some(w => /^GIT_(DIR|WORK_TREE|INDEX_FILE)=/.test(w))
566 const argv = unwrap(stripRedirects(words))
567 if (base(argv[0] ?? '') !== 'git') return null
568 let i = 1
569 const dirs: string[] = []
570 let retargeted = retargetedByEnv
571 while (i < argv.length && argv[i]!.startsWith('-')) {
572 const flag = argv[i]!
573 if (flag === '-C') {
574 dirs.push(literal(argv[i + 1] ?? '.'))
575 i += 2
576 } else if (flag === '-c' || flag === '--namespace') {
577 i += 2
578 } else if (flag === '--git-dir' || flag === '--work-tree') {
579 retargeted = true
580 i += 2
581 } else {
582 if (flag.startsWith('--git-dir=') || flag.startsWith('--work-tree=')) retargeted = true
583 i += 1
584 }
585 }
586 if (argv[i] !== 'commit') return null
587 const c: Commit = { dirs, messages: [], file: null, reuse: null, unknown: false, retargeted, all: false, partial: false }
588 let amend = false
589 let edits: boolean | null = null
590 const args = argv.slice(i + 1)
591 // A heredoc whose delimiter was unquoted expands $ in its body.
592 const bodies = () => {
593 if (heredocs.some(b => liveHeredocs.includes(b) && EXPANDS.test(b))) c.unknown = true
594 c.messages.push(...heredocs)
595 }
596 const take = (value: string | undefined) => {
597 if (value === undefined) return
598 if (/\$\(\s*cat\s*<</.test(value)) bodies()
599 else if (EXPANDS.test(value)) c.unknown = true
600 else c.messages.push(literal(value))
601 }
602 for (let j = 0; j < args.length; j += 1) {
603 const w = args[j]!
604 if (w === '--') {
605 if (j + 1 < args.length) c.partial = true
606 break
607 }
608 if (w === '-m' || w === '--message') take(args[(j += 1)])
609 else if (w.startsWith('--message=')) take(w.slice(10))
610 else if (w === '-F' || w === '--file') c.file = args[(j += 1)] ?? null
611 else if (w.startsWith('--file=')) c.file = w.slice(7)
612 else if (w === '-C' || w === '--reuse-message') c.reuse = args[(j += 1)] ?? 'HEAD'
613 else if (w.startsWith('--reuse-message=')) c.reuse = w.slice(16)
614 else if (w === '-c' || w === '--reedit-message' || w.startsWith('--reedit-message=')) {
615 // Opens an editor on the reused message: the final text is unknown.
616 c.unknown = true
617 if (!w.includes('=')) j += 1
618 } else if (w === '--amend') amend = true
619 else if (w === '--no-edit') edits = false
620 else if (w === '--edit' || w === '-e') edits = true
621 else if (w === '--all') c.all = true
622 else if (w === '--only' || w === '-o' || w === '--include' || w === '-i' || w === '--interactive' || w === '-p' || w === '--patch') c.partial = true
623 else if (w.startsWith('--')) continue
624 else if (w.startsWith('-')) {
625 // Short flags, bundled or with an attached value: -am "msg", -m"msg", -Fmsg.txt, -aC HEAD.
626 for (let k = 1; k < w.length; k += 1) {
627 const f = w[k]!
628 const rest = w.slice(k + 1)
629 if (f === 'a') c.all = true
630 else if (f === 'e') edits = true
631 else if (f === 'o' || f === 'i' || f === 'p') c.partial = true
632 else if (f === 'm' || f === 'F' || f === 'C' || f === 'c') {
633 const value = rest !== '' ? rest : args[(j += 1)]
634 if (f === 'm') take(value)
635 else if (f === 'F') c.file = value ?? null
636 else if (f === 'C') c.reuse = value ?? 'HEAD'
637 else c.unknown = true
638 break
639 }
640 }
641 } else c.partial = true
642 }
643 if (c.file === '-') {
644 c.file = null
645 if (heredocs.length > 0) bodies()
646 else c.unknown = true
647 }
648 if (c.file !== null) c.file = literal(c.file)
649 if (c.reuse !== null) c.reuse = literal(c.reuse)
650 if (amend && c.messages.length === 0 && c.file === null && c.reuse === null) {
651 if (edits === false) c.reuse = 'HEAD'
652 else c.unknown = true
653 }
654 if (edits === true) c.unknown = true
655 // No message at all opens an editor nobody can read here.
656 if (c.messages.length === 0 && c.file === null && c.reuse === null && !amend) c.unknown = true
657 return c
658}
659
660// Words before a claim that negate it or ask for a check instead of reporting one.
661const HEDGE_BEFORE =
662 /\b(not|n't|no|never|no longer|fail(s|ed|ing|ure)?|broke(n)?|unless|previously|used to|should|will|would|until|when|if|todo|wip|make|get|keep|ensure|help|let|so that|hopefully|maybe|might)\b/i
663// Words after a claim that put it in another time or condition.
664const HEDGE_AFTER = /\b(when|if|unless|until|except|before|previously|earlier|yesterday|on main|locally only)\b/i
665const PASS = '(?:pass(?:es|ed|ing)?|green|ok|clean)'
666// The pass verb must end the claim: "tests pass", "tests pass now", not "the test passes the token".
667const ENDS = '(?=\\s*(?:$|[.!,;)\\]]|\\s+(?:now|again|locally|on\\s+ci|in\\s+ci|for\\s+me|✅|and|but|with|without|after)\\b))'
668// As ENDS, and an "except …" may follow the all-claim.
669const ENDS_ALL = '(?=\\s*(?:$|[.!,;)\\]]|\\s+(?:now|again|locally|on\\s+ci|in\\s+ci|for\\s+me|✅|and|but|with|without|after|except)\\b))'
670const CLAIM: [ProofKind | 'all', RegExp][] = [
671 ['tests', new RegExp(`\\b(?:all\\s+)?(?:unit\\s+|integration\\s+|e2e\\s+)?(?:tests|specs|test suite|test)\\s*:?\\s+(?:(?:are|is|now|all|still)\\s+)*${PASS}${ENDS}`, 'i')],
672 ['typecheck', new RegExp(`\\b(?:type-?checks?|typecheck(?:s|ing)?|tsc|type checking|types)\\s*:?\\s+(?:(?:is|are|now|still)\\s+)*${PASS}${ENDS}`, 'i')],
673 ['lint', new RegExp(`\\b(?:lint(?:s|ing|er)?|eslint|ruff)\\s*:?\\s+(?:(?:is|are|now|still)\\s+)*${PASS}${ENDS}`, 'i')],
674 ['build', new RegExp(`\\bbuilds?\\s*:?\\s+(?:(?:is|are|now|still)\\s+)*(?:${PASS}|succeed(?:s|ed)?)${ENDS}`, 'i')],
675 ['all', new RegExp(`\\ball\\s+(?:checks?\\s+)?(?:are\\s+)?(?:green|passing|pass(?:ed)?)${ENDS_ALL}|\\b(?:ci|checks)\\s*:?\\s+(?:is\\s+|are\\s+)?(?:green|passing|passed)${ENDS_ALL}`, 'i')],
676]
677const KIND_WORDS: [ProofKind, RegExp][] = [
678 ['lint', /\blint/i], ['typecheck', /\btype|\btsc/i], ['build', /\bbuild/i], ['tests', /\btest/i],
679]
680
681// The checks a message reports as passing. Hedges count per clause, so
682// "Tests pass, build skipped" still claims tests.
683export function claimsIn(message: string): Array<ProofKind | 'all'> {
684 return [...new Set(clauses(message).flatMap(clause => claimsOf(clause).kinds))]
685}
686
687// Kinds an "all checks pass except lint" leaves out.
688export function exceptionsIn(message: string): ProofKind[] {
689 return [...new Set(clauses(message).flatMap(clause => claimsOf(clause).except))]
690}
691
692// Each claim with the exceptions of its own clause: "all checks pass except lint"
693// trims only that claim, never a separate "lint passes" elsewhere in the message.
694export function claimSets(message: string): Array<{ kind: ProofKind | 'all'; except: ProofKind[] }> {
695 return clauses(message).flatMap(clause => {
696 const { kinds, except } = claimsOf(clause)
697 return kinds.map(kind => ({ kind, except: kind === 'all' ? except : [] }))
698 })
699}
700
701// An "except …" clause stays with the all-claim before it, and only with an all-claim:
702// "All tests pass, except lint" still claims tests.
703const LIST_ITEM = /^\s*(?:and\s+|or\s+)?(?:the\s+)?(?:unit\s+)?(?:tests?|types?|type-?checks?|tsc|lint(?:ing)?|eslint|builds?)\s*$/i
704function clauses(message: string): string[] {
705 const all = CLAIM.find(([k]) => k === 'all')![1]
706 const out: string[] = []
707 for (const piece of message.split(/\n|[.!?;,]\s+|\s+(?:but|while|though)\s+/i)) {
708 const prev = out[out.length - 1]
709 const starts = prev !== undefined && /^\s*except\b/i.test(piece) && all.test(prev)
710 // "except tests, typecheck, and lint": the list after an except stays with it.
711 const continues = prev !== undefined && /\bexcept\b/i.test(prev) && all.test(prev) && LIST_ITEM.test(piece)
712 if (starts || continues) out[out.length - 1] = `${prev}, ${piece}`
713 else out.push(piece)
714 }
715 return out
716}
717
718function claimsOf(clause: string): { kinds: Array<ProofKind | 'all'>; except: ProofKind[] } {
719 const kinds: Array<ProofKind | 'all'> = []
720 const except: ProofKind[] = []
721 for (const [kind, re] of CLAIM) {
722 const m = re.exec(clause)
723 if (m === null) continue
724 if (HEDGE_BEFORE.test(clause.slice(0, m.index))) continue
725 const after = clause.slice(m.index + m[0].length)
726 if (kind === 'all') {
727 const ex = /\bexcept\b(.*)$/i.exec(after)
728 if (ex !== null) for (const [k, w] of KIND_WORDS) if (w.test(ex[1] ?? '')) except.push(k)
729 if (HEDGE_AFTER.test(after.replace(/\bexcept\b.*$/i, ''))) continue
730 } else if (HEDGE_AFTER.test(after)) continue
731 kinds.push(kind)
732 }
733 return { kinds, except }
734}
735
736export function resolvePath(from: string, to: string): string {
737 const parts = (to.startsWith('/') ? to : `${from}/${to}`).split('/')
738 const out: string[] = []
739 for (const p of parts) {
740 if (p === '' || p === '.') continue
741 if (p === '..') out.pop()
742 else out.push(p)
743 }
744 return `/${out.join('/')}`
745}
746types/index.d.ts 28 lines1export type ProofKind = 'tests' | 'typecheck' | 'lint' | 'build'
2
3// One verification run: what it checked, in which repo, and whether it still holds.
4export type Proof = {
5 // Repo (git top level) or directory the check ran in.
6 root: string
7 kind: ProofKind
8 label: string
9 // project: a bare whole-project run; scoped: named files, a package or a script variant;
10 // filtered: selected tests by name. Only project proofs back a commit message claim.
11 scope: 'project' | 'scoped' | 'filtered'
12 files: string[]
13 status: 'pass' | 'fail'
14 at: number
15 // Edits seen landing in the root since the run.
16 staleEdits: number
17 // The workspace fingerprint the run saw; null outside git.
18 fingerprint: string | null
19 // Why the result is no longer trusted though no edit was seen, else null.
20 doubt: string | null
21}
22
23declare module 'claude-code' {
24 interface PluginState {
25 'proof-decay': { proofs: Record<string, Proof>; edits: number }
26 }
27}
28