SLOPSHOPPER

proof-decay

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

newbandguardcommandprocess
v0.2.0MITupdated 2026-10-02ccdwyer/proof-decay
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · proof-decay
› 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 › /proofs ⎿ proof-decay: Proof Decay: ⎿ proof-decay: ✗ tests failed · `bun test` · 0 min ago · /work/app ✗ tests failed ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
✗ tests failed ⟨Claude Code's own drawing⟩
README

Proof Decay

Proof Decay demo

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

FreshStaleRefused
freshstalerefused

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.

  • Proof board above the prompt, for example ✓ tests fresh · ⚠ types stale (3 edits) · ✗ lint failed. It shows the repo you're in.
  • What counts. Only a lone check, an && 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.
  • Scope. Only a bare whole-project run (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.
  • Staleness. Results go stale on any edit in the repo, including edits made by the agent's own shell commands. At the end of each turn, and before every commit, each repo's working tree is fingerprinted: the path and content id of every tracked and untracked file that isn't ignored. HEAD and the index are left out, so committing the tested tree doesn't make it stale, while putting an edit back makes the result fresh again. That catches changes no hook saw, such as your editor, a formatter or a script. A run whose files changed while it was running is not trusted.
  • Oathkeeper. The commit message is read from -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.

Install

/plugin marketplace add ccdwyer/claude-mods
/plugin install proof-decay@ccdwyer-mods
/reload-plugins

Develop

claude plugin validate .
claude plugin test .

What it hooks

Events this mod hooks, as claude plugin validate reads the module:

  • session.start
  • command.run{command=proofs}
  • tool.call{tool=Bash}
  • tool.call
  • turn.complete
  • ui.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.

Privacy

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.

License

MIT

Source 3 files
hooks/register.tsx 615 lines
1import { 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}
615
hooks/shell.ts 746 lines
1import 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}
746
types/index.d.ts 28 lines
1export 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