SLOPSHOPPER

Ship Check

A development verification board: shows which tests, builds and type checks really ran, how they ended, and whether the result still matches your code.

newpanebandguardcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ship-check
│ ┃ Ship Check ✕ › fix the failing auth test and add an audit log call │ ┃ Ship Check │ ┃ ⏺ Read(src/auth.ts) │ ┃ app ⎿ Read 6 lines │ ┃ Tests ? Unknown 22:30:03 · 0ms ⏺ Update(src/auth.ts) │ ┃ bun test · app ⎿ Added 2 lines, removed 1 line │ ┃ The tool reported an error but no exit ⏺ Bash(bun test) │ ┃ status. ⎿ 3 pass, 1 fail │ ┃ [ View output ] │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Types ○ Not run │ ┃ Build ○ Not run ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ [ Prepare checks ] › /ship-check │ ┃ Results come only from what the tools │ ┃ reported, not from Claude’s summaries. │ Tests ? Unknown | Types ○ Not run | Build ○ Not run Details ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
Tests ? Unknown | Types ○ Not run | Build ○ Not run Details ⟨Claude Code's own drawing⟩
Pane · Ship Check
Ship Check app Tests ? Unknown 22:30:03 · 0ms bun test · app The tool reported an error but no exit status. [ View output ] Types ○ Not run Build ○ Not run [ Prepare checks ] Results come only from what the tools reported, not from Claude’s summaries.
README

ship-check-mods

A Claude Code marketplace with one mod: Ship Check, a development verification board.

When Claude changes your code, Ship Check shows which checks really ran, how they ended, and whether each result still matches the code on disk. It never takes Claude's word for it. A check is Passed only when the tool that ran it said so.

Tests ✓ | Types ↻ Stale | Build ○ Not run

Features

  • A status line above the prompt. One short line per project: Tests ✓ | Types ↻ Stale | Build ○ Not run. When the same kind of check has results in several folders (for example the tests of two packages), the line shows the worst one, in the order Failed, Stale, Unknown, Running, Passed, so a failure is never hidden behind a newer pass elsewhere. Open the panel to see every folder. It updates quietly and never pops up a notification.
  • A detailed panel. Press Details on the status line, or run /ship-check, to open Ship Check. For each check it shows the result, the time, the command, the project location, and why a result is stale.
  • View output. Each row has a View output button that shows a length-limited tail of the real tool output.
  • Prepare checks. Puts a request to re-run stale, failed, unknown or not-yet-run checks into the prompt box. Nothing is sent until you press Enter.
  • Honest statuses. Every check is in exactly one state:
StatusMeaning
Not runNo result has been recorded for this project yet.
RunningThe check has started and has not reported back.
PassedThe tool reported a clean finish.
FailedThe tool reported a non-zero exit status.
StaleThe check finished, but files changed afterwards (or while it ran).
UnknownThe check ran, but the tool gave no reliable way to tell how it ended.

What is recognized

  • npm, pnpm, yarn and bun: test, build, and type-check scripts (typecheck, type-check, check-types, tsc), including npm run <script>, pnpm -C dir, yarn workspace <name> <script>, npm --prefix dir, --workspace / --filter, and a leading cd dir &&.
  • Direct tools: tsc (--noEmit counts as a type check), vitest run, jest, npx/bunx forms of those.
  • Your own commands (see Settings).

Ship Check reads the Bash tool and the PowerShell tool (Windows), and looks through cmd /c "..." and bash -c "..." wrappers.

The same check in two different project locations, or two workspaces, is recorded separately.

How a result is decided

Only the tool result counts. A check is Passed or Failed only when the exit status the tool reported can be tied to that check:

  • A clean finish → Passed.
  • An error with an Exit code N line → Failed, and N is shown.
  • Anything else → Unknown, with the reason shown in the panel.

What each tool reports, and so what Ship Check trusts:

ShellThe tool reportsTrusted formsUnknown
BashThe exit status of the last statementnpm test, cd app && npm test, cd app; npm test, a check that is the last command of a ; list, and npm test; echo "EXIT: $?" (the printed status is read)A pipe (`npm test \tail), \\, background jobs, ; followed by another command, a failure in an && chain that could belong to an earlier command. After a pipe, echo "${PIPESTATUS[0]}"` is read.
PowerShell$LASTEXITCODE: the exit status of the last native programSet-Location x; npm test, a check followed by cmdlets, strings, Write-Output, or if ($?) {...} / if ($LASTEXITCODE -ne 0) {...} blocks that run no program, a pipeline into cmdlets (`npm test \Select-Object -Last 1), PowerShell 7 &&` chainsA later native program (npm test; npm run build — the first check), a pipeline into a program (`npm test \findstr x), , an assignment like $x = npm test`
cmd.exeThe exit status of the last commandcmd /c "npm test", cmd /c "cd app && npm test", cmd /c "a & b" (the last command)The same forms as above

Also Unknown:

  • background runs and commands that timed out into the background
  • interrupted commands
  • watch mode (--watch, plain vitest), which never finishes by itself
  • a cd or Set-Location that failed, because the check may then have run in another directory
  • a tool result with no completion information
  • a command run by a tool Ship Check does not know. If a future Claude Code version adds another way to run commands, a check seen there is listed as Unknown with the tool's name, instead of being silently ignored.

Test counts are not shown. Test runners print summaries in too many formats to parse reliably, so Ship Check shows the real output instead of a guessed number.

A run that passes extra arguments (for example npm test -- -t login) is marked in the panel, because it may not cover everything.

When a result goes stale

A finished result becomes Stale when any tracked file under the project changes afterwards. A change that happens while the check is running also makes the result stale, because the result may not match the latest code.

Ship Check notices:

ChangeHow it is noticed
Claude's Edit, Write, MultiEdit and NotebookEdit toolsThe tool call, immediately.
Files changed by a Bash commandThe file list the Bash tool reports. If the tool cannot report one, every finished result in that project is marked stale to be safe.
Edits you make yourselfA fingerprint comparison when a turn ends, and when you submit a prompt.
Git commit or branch changesThe fingerprint includes HEAD.

The fingerprint is HEAD plus the modified and untracked files that Git reports, with each file's size and modified time. Outside a Git repository it walks the folder with caps (depth 5, 1,500 entries); if a cap is hit the fingerprint is marked partial and is never used to claim that something changed.

Never counted: node_modules, .git, dist, build, out, .next, .nuxt, .svelte-kit, .turbo, .cache, coverage, .nyc_output, target, __pycache__, .venv, .claude, *.log, *.tsbuildinfo, and similar generated output.

The stale reason names the kind of change: source files, configuration files (tsconfig*, *.config.*, .env*), or dependency files (package.json, lockfiles).

Requirements

  • A Claude Code that supports mods. Anthropic's documentation says mods need Claude Code 2.1.287 or later. In the terminal, check with claude --version. The desktop app bundles its own copy of Claude Code; Ship Check also loaded in a Windows desktop app whose built-in version was 2.1.286, so if it does not appear, update the app first.
  • Git is optional but recommended; it makes change detection faster and more precise.
  • The terminal and the Code tab of the desktop app can load mods. Other places (the VS Code chat panel, claude -p, cloud sessions) run a mod's hooks but do not draw its interface.

Settings

Both settings are optional, and Ship Check works without them. To change them, open /plugin in a terminal session, select ship-check, and choose Configure options, or edit pluginConfigs in your settings file.

  • Extra checks: your own commands, one per entry, written as kind=command. The kind becomes the label in the status line. `` lint=npm run lint e2e=npx playwright test ``
  • Ignored paths: folders or files whose changes should not make a result stale, such as generated.

Both are optional.

Troubleshooting

If the status line does not appear, start Claude Code with SHIP_CHECK_DEBUG set to a file path (for example $env:SHIP_CHECK_DEBUG = "C:\temp\ship.json" in PowerShell). Ship Check then writes the size the band was given and the number of recorded checks to that file. The status line appears once at least one check has run.

Try it locally

From a clone of this repository:

claude --plugin-dir ./plugins/ship-check

Then ask Claude to run your tests, and open the panel with /ship-check. Edits to the mod reload while the session runs.

Run the checks for the mod itself:

claude plugin validate .
claude plugin validate ./plugins/ship-check --strict
cd plugins/ship-check && claude plugin test

Install

In the Claude desktop app (no terminal)

Use a recent Claude desktop app. Ship Check was tested in the Code tab of the Windows app. If it does not show up after the steps below, update the app and try again.

  1. Open Settings and choose Plugins (under Customize).
  2. Click Add, then Add from a repository.
  3. Enter panyuti-ai/ship-check-mods and confirm.
  4. Open the Discover tab, find Ship Check, and install it. It then appears under Yours with its switch turned on.
  5. Wait a few minutes, then open a new session in the Code tab. A session that was already open when you installed does not have it, and a new session can still miss it for the first minutes after the install.
  6. Ask Claude to run your tests, a type check, or a build. The status line appears above the prompt, with a Details button at its end. Press Details to open the Ship Check panel on the right.
  7. To open the panel before any check has run, type /ship-check and press Enter. Below the prompt you may then see an orange line, /ship-check isn't a command here. It is only a notice from the app, which does not know commands that a mod adds: the panel still opens.

To turn it off or remove it later, use Settings → Plugins → Yours.

In the terminal

/plugin marketplace add panyuti-ai/ship-check-mods
/plugin install ship-check@ship-check-mods
/reload-plugins

The terminal and the desktop app's Code tab read the same plugin settings on one computer, so a plugin installed in one is available in the other.

Update

Desktop app: open Settings → Plugins → Yours and use the menu (⋮) next to Ship Check or its marketplace to update. The menu wording can differ between app versions. Start a new session afterwards, because a running session keeps the version it loaded.

Terminal: marketplace update takes the marketplace's name (ship-check-mods), not the GitHub path, so these commands are the same for everyone:

/plugin marketplace update ship-check-mods
/plugin update ship-check@ship-check-mods
/reload-plugins

Claude Code caches an installed plugin by version, so a new release must raise version in both plugins/ship-check/.claude-plugin/plugin.json and .claude-plugin/marketplace.json.

Known limits

  • How each shell reports its exit status was measured, not documented. The table above comes from real Claude Code 2.1.287 sessions with the Bash and PowerShell tools. PowerShell 7's && and || are covered by unit tests only, and the cmd /c handling rests on the PowerShell tool reporting the wrapped command's status. If a Claude Code release changes what the tools report, results may become Unknown or, in the worst case, wrong; please open an issue.
  • Only Claude's own tool calls are observed. Checks you run in your own terminal are not recorded. A file you change there is still noticed as a change, at the next turn end or prompt.
  • The scope of "stale" is the whole Git repository. In a monorepo, an edit in one package marks results from other packages stale too. This is deliberately cautious.
  • Edits you make are noticed late. There is no file watcher; changes you make by hand are noticed when a turn ends or you submit a prompt, not the instant you save.
  • Same-size, same-second edits can be missed by the fingerprint. Claude's own edits are tracked by tool call, so this only affects hand edits made within the clock resolution of a file system.
  • A check that writes tracked files makes itself stale. For example a test that rewrites a snapshot. Add the folder to Ignored paths.
  • Filtered runs overwrite the full run. The latest run of a check in a location is the one shown.
  • bashEditDiff is an internal field. Ship Check uses the file list the Bash tool reports when it is there and falls back to the fingerprint when it is not. If a future Claude Code version removes it, stale detection still works, just a little later.
  • Mods are early access. The API can change between Claude Code releases. This version was built and tested against Claude Code 2.1.287.
  • State lives for the session. Results survive a hot reload but reset on /clear, /resume and /branch.
  • Locations. cd to a path that uses ~, a variable, or - cannot be resolved, so that check is not recorded.

Layout

ship-check-mods/
├── .claude-plugin/marketplace.json
├── plugins/ship-check/
│   ├── .claude-plugin/plugin.json
│   ├── hooks/
│   │   ├── hooks.json
│   │   ├── register.js        # wires events to the board
│   │   └── lib/               # command parsing, shell analysis, statuses, views (no Claude Code API calls)
│   ├── types/index.d.ts       # the state the mod keeps
│   └── tests/
├── README.md
└── LICENSE

License

MIT. See LICENSE.

Source 7 files
hooks/register.js 492 lines
1// ship-check: a development verification board for Claude Code.
2// It watches the test, build and type-check commands that actually ran, records what the tools
3// reported, and marks a result Stale when the project changes after it. See the README.
4
5import { atom, read, update } from 'claude-code'
6import { parseCustomChecks } from './lib/commands.js'
7import { analyzeCommand } from './lib/analyze.js'
8import { normalizePath, isUnder, relativeTo, resolveDir } from './lib/paths.js'
9import {
10  emptyLedger,
11  beginRecord,
12  restoreRecord,
13  finishRecord,
14  applyEdit,
15  applyUnnamedChange,
16  applyScan,
17  settleRunning,
18  activeRoots,
19  classifyOutcome,
20  tailOutput,
21  isTrackedPath,
22  categorizePath,
23  staleReasonFor,
24  recordKey,
25  preparePrompt,
26  displayStatus,
27  hasAnyRecord,
28  STATUS,
29} from './lib/model.js'
30import { buildStrip, buildPane } from './lib/view.js'
31
32const PANE_ID = 'ship-check'
33const MAX_SCAN_FILES = 600
34const MAX_WALK_ENTRIES = 1500
35const WALK_DEPTH = 5
36const SCAN_THROTTLE_MS = 1500
37const GIT_TIMEOUT_MS = 8000
38
39// State that survives a hot reload lives in `$.state`; the ledger is plain JSON.
40const ledgerAtom = atom({ plugin: 'ship-check', key: 'ledger' }, emptyLedger())
41const viewAtom = atom({ plugin: 'ship-check', key: 'view' }, { expanded: null })
42
43// Caches that can be rebuilt: lost on reload, which only costs an exact "what changed" list.
44const gitTops = new Map()
45const lastScans = new Map() // root -> { at, sig, map, head }
46
47// --- Fingerprints ---------------------------------------------------------------------------------
48
49function hashLines(lines) {
50  let a = 0x811c9dc5
51  let b = 0x01000193
52  for (const line of lines) {
53    for (let i = 0; i < line.length; i++) {
54      const c = line.charCodeAt(i)
55      a = Math.imul(a ^ c, 0x01000193) >>> 0
56      b = Math.imul(b + c, 0x85ebca6b) >>> 0
57    }
58    a = Math.imul(a ^ 10, 0x01000193) >>> 0
59  }
60  return a.toString(16).padStart(8, '0') + b.toString(16).padStart(8, '0')
61}
62
63async function gitTop($, dir) {
64  if (gitTops.has(dir)) return gitTops.get(dir)
65  let top = null
66  try {
67    const r = await $.process.run(['git', '-C', dir, 'rev-parse', '--show-toplevel'], { timeoutMs: GIT_TIMEOUT_MS })
68    if (r.exitCode === 0 && r.stdout.trim()) top = normalizePath(r.stdout.trim())
69  } catch {
70    top = null
71  }
72  gitTops.set(dir, top)
73  return top
74}
75
76async function statSig($, path) {
77  try {
78    const s = await $.fs.stat(path)
79    return s.mtimeMs + ':' + s.size
80  } catch {
81    return 'gone'
82  }
83}
84
85async function statMany($, root, rels) {
86  const map = {}
87  for (let i = 0; i < rels.length; i += 40) {
88    const batch = rels.slice(i, i + 40)
89    const sigs = await Promise.all(batch.map((rel) => statSig($, root + '/' + rel)))
90    batch.forEach((rel, j) => {
91      map[rel] = sigs[j]
92    })
93  }
94  return map
95}
96
97// Git route: HEAD plus the files that differ from it. Cheap, and ignores ignored files.
98async function scanGit($, root, extra) {
99  let head = ''
100  try {
101    const h = await $.process.run(['git', '-C', root, 'rev-parse', 'HEAD'], { timeoutMs: GIT_TIMEOUT_MS })
102    if (h.exitCode === 0) head = h.stdout.trim()
103  } catch {
104    head = ''
105  }
106  const st = await $.process.run(['git', '-C', root, 'status', '--porcelain=v1', '-z', '-uall', '--no-renames'], { timeoutMs: GIT_TIMEOUT_MS })
107  if (st.exitCode !== 0) return null
108  const rels = []
109  for (const entry of st.stdout.split('\0')) {
110    if (entry.length < 4) continue
111    const rel = entry.slice(3).replace(/\\/g, '/')
112    if (isTrackedPath(rel, extra)) rels.push(rel)
113  }
114  const partial = rels.length > MAX_SCAN_FILES || st.isStdoutTruncated
115  const map = await statMany($, root, rels.slice(0, MAX_SCAN_FILES))
116  const lines = [head, ...Object.keys(map).sort().map((k) => k + '\t' + map[k])]
117  return { sig: { hash: hashLines(lines), count: Object.keys(map).length, partial }, map, head }
118}
119
120// No Git: a bounded walk. When it hits a cap the fingerprint is marked partial and is never used
121// to claim that something changed.
122async function scanWalk($, root, extra) {
123  const files = []
124  let seen = 0
125  let partial = false
126  const queue = [{ rel: '', depth: 0 }]
127  while (queue.length) {
128    const { rel, depth } = queue.shift()
129    let entries
130    try {
131      entries = await $.fs.list(rel ? root + '/' + rel : root)
132    } catch {
133      continue
134    }
135    for (const entry of entries) {
136      if (++seen > MAX_WALK_ENTRIES) {
137        partial = true
138        break
139      }
140      const childRel = rel ? rel + '/' + entry.name : entry.name
141      if (entry.kind === 'dir') {
142        if (depth + 1 > WALK_DEPTH) partial = true
143        else if (isTrackedPath(childRel + '/x', extra)) queue.push({ rel: childRel, depth: depth + 1 })
144      } else if (entry.kind === 'file' && isTrackedPath(childRel, extra)) {
145        files.push({ rel: childRel, sig: entry.mtimeMs + ':' + entry.size })
146      }
147    }
148    if (partial && seen > MAX_WALK_ENTRIES) break
149  }
150  const map = {}
151  for (const f of files) map[f.rel] = f.sig
152  const lines = Object.keys(map).sort().map((k) => k + '\t' + map[k])
153  return { sig: { hash: hashLines(lines), count: files.length, partial }, map, head: '' }
154}
155
156async function scanRoot($, root, extra, force) {
157  const now = Date.now()
158  const last = lastScans.get(root)
159  if (!force && last && now - last.at < SCAN_THROTTLE_MS) return { ...last, reused: true }
160  const top = await gitTop($, root)
161  let scanned = null
162  try {
163    scanned = top ? await scanGit($, root, extra) : null
164  } catch {
165    scanned = null
166  }
167  if (!scanned) scanned = await scanWalk($, root, extra)
168  const next = { at: now, ...scanned }
169  lastScans.set(root, next)
170  return { ...next, previous: last || null }
171}
172
173function describeChange(previous, current) {
174  if (!previous) return null
175  const files = []
176  for (const rel of Object.keys(current.map)) if (previous.map[rel] !== current.map[rel]) files.push(rel)
177  for (const rel of Object.keys(previous.map)) if (!(rel in current.map)) files.push(rel)
178  if (files.length) {
179    return { hash: previous.sig.hash, reason: staleReasonFor(files.map(categorizePath)), files }
180  }
181  if (previous.head !== current.head) {
182    return { hash: previous.sig.hash, reason: 'The Git commit changed after this check completed.', files: [] }
183  }
184  return null
185}
186
187// Rescans the roots that hold a result worth protecting and marks changed ones Stale.
188async function scanActive($, extra, force) {
189  const ledger = await read($, ledgerAtom)
190  for (const root of activeRoots(ledger).slice(0, 5)) {
191    const scan = await scanRoot($, root, extra, force)
192    if (scan.reused || !scan.sig) continue
193    const detail = describeChange(scan.previous, scan)
194    await update($, ledgerAtom, (value) => applyScan(value, root, scan.sig, Date.now(), detail || { hash: '' }))
195  }
196}
197
198// --- Tool calls ------------------------------------------------------------------------------------
199
200// Claude Code runs commands with the Bash tool or, on Windows, the PowerShell tool. Their results
201// are understood. Any other tool that takes a "command" (a future shell tool, an MCP terminal) is
202// "untrusted": a check seen there is shown as Unknown with the reason, never silently ignored.
203const FILE_TOOLS = ['Edit', 'Write', 'MultiEdit', 'NotebookEdit']
204function shellKind(e) {
205  if (e.tool === 'Bash' || e.tool === 'PowerShell') return 'trusted'
206  if (typeof e.command === 'string' && !FILE_TOOLS.includes(e.tool)) return 'untrusted'
207  return null
208}
209
210async function sessionCwd($) {
211  try {
212    return normalizePath(await $.session.cwd())
213  } catch {
214    return ''
215  }
216}
217
218async function beginTool($, e, custom, extra) {
219  const kind = shellKind(e)
220  if (!kind || typeof e.command !== 'string') return null
221  const cwd = await sessionCwd($)
222  const parsed = analyzeCommand(e.command, cwd, custom, { powershell: e.tool === 'PowerShell' })
223  if (kind === 'untrusted') parsed.untrustedTool = e.tool
224  const ctx = { cwd, parsed, checks: [], trusted: kind === 'trusted' }
225  if (!parsed.checks.length) return ctx
226
227  const before = await read($, ledgerAtom)
228  const roots = new Map()
229  for (const check of parsed.checks) {
230    const root = (await gitTop($, check.location)) || check.location
231    if (!roots.has(root)) roots.set(root, (await scanRoot($, root, extra, true)).sig)
232    ctx.checks.push({ check, root, key: recordKey(check.location, check.kind, check.scope), previous: before.records[recordKey(check.location, check.kind, check.scope)] || null, startedAt: Date.now() })
233  }
234  // Give simultaneous checks distinct start stamps so a newer run can be told from an older one.
235  ctx.checks.forEach((c, i) => {
236    c.startedAt += i
237  })
238  await update($, ledgerAtom, (value) => {
239    let next = value
240    for (const c of ctx.checks) next = beginRecord(next, { ...c.check, together: ctx.checks.length }, roots.get(c.root), c.root, c.startedAt)
241    return next
242  })
243  ctx.roots = roots
244  return ctx
245}
246
247async function abortTool($, ctx) {
248  if (!ctx || !ctx.checks.length) return
249  await update($, ledgerAtom, (value) => {
250    let next = value
251    for (const c of ctx.checks) next = finishRecord(next, c.key, c.startedAt, { status: 'unknown', reason: 'The tool call failed before it reported a result.', exitCode: null, summary: '', endSig: null, now: Date.now() })
252    return next
253  })
254}
255
256async function endTool($, e, ctx, result, extra) {
257  if (e.tool === 'Edit' || e.tool === 'Write' || e.tool === 'MultiEdit' || e.tool === 'NotebookEdit') {
258    if (!result || result.deny || result.isError) return
259    const raw = e.file_path || e.notebook_path
260    if (typeof raw !== 'string') return
261    const cwd = await sessionCwd($)
262    const path = resolveDir(cwd, raw) || normalizePath(raw)
263    await update($, ledgerAtom, (value) => applyEdit(value, path, Date.now(), extra))
264    return
265  }
266  if (!shellKind(e) || !ctx) return
267
268  const denied = !result || typeof result.deny === 'string'
269  if (ctx.checks.length) {
270    if (denied) {
271      await update($, ledgerAtom, (value) => {
272        let next = value
273        for (const c of ctx.checks) next = restoreRecord(next, c.key, c.previous)
274        return next
275      })
276      return
277    }
278    const r = result.result && typeof result.result === 'object' ? result.result : null
279    const summary = r ? tailOutput(r.stdout, r.stderr) : tailOutput(result.text, '')
280    const endSigs = new Map()
281    for (const c of ctx.checks) {
282      if (!endSigs.has(c.root)) endSigs.set(c.root, await scanRoot($, c.root, extra, true))
283    }
284    const now = Date.now()
285    await update($, ledgerAtom, (value) => {
286      let next = value
287      for (const c of ctx.checks) {
288        const verdict = classifyOutcome(ctx.parsed, c.check, result)
289        const scan = endSigs.get(c.root)
290        const start = value.records[c.key] && value.records[c.key].startSig
291        const duringFiles = []
292        if (start && scan && scan.sig && start.hash !== scan.sig.hash && scan.previous && scan.previous.map) {
293          for (const rel of Object.keys(scan.map)) if (scan.previous.map[rel] !== scan.map[rel]) duringFiles.push(rel)
294        }
295        next = finishRecord(next, c.key, c.startedAt, {
296          ...verdict,
297          summary,
298          endSig: scan ? scan.sig : null,
299          now,
300          duringFiles: duringFiles.slice(0, 5),
301          duringCategories: duringFiles.map(categorizePath),
302        })
303      }
304      return next
305    })
306    return
307  }
308
309  // A shell command that is not a check: it may have edited files. Only the tools we understand
310  // tell us anything about that.
311  if (denied || !ctx.trusted) return
312  const r = result.result && typeof result.result === 'object' ? result.result : null
313  const diff = r && r.bashEditDiff
314  const changed = diff && Array.isArray(diff.changedFiles) ? diff.changedFiles.slice(0, 200) : []
315  if (changed.length) {
316    await update($, ledgerAtom, (value) => {
317      let next = value
318      for (const p of changed) next = applyEdit(next, p, Date.now(), extra)
319      return next
320    })
321  } else if (diff && (diff.unavailable || diff.skipped) && result.isReadOnly !== true) {
322    const ledger = await read($, ledgerAtom)
323    const root = (await gitTop($, ctx.cwd)) || ctx.cwd
324    if (root && activeRoots(ledger).length) {
325      await update($, ledgerAtom, (value) => applyUnnamedChange(value, root, Date.now(), 'A shell command may have changed files after this check completed.'))
326    }
327  } else if (result.isReadOnly !== true) {
328    await scanActive($, extra, false)
329  }
330}
331
332// --- "Prepare checks" ---------------------------------------------------------------------------------
333
334async function detectScripts($, cwd) {
335  const out = []
336  try {
337    const pkg = JSON.parse(await $.fs.read(cwd + '/package.json'))
338    const scripts = pkg && typeof pkg.scripts === 'object' ? pkg.scripts : {}
339    let pm = 'npm'
340    if (await $.fs.exists(cwd + '/pnpm-lock.yaml')) pm = 'pnpm'
341    else if (await $.fs.exists(cwd + '/yarn.lock')) pm = 'yarn'
342    else if ((await $.fs.exists(cwd + '/bun.lockb')) || (await $.fs.exists(cwd + '/bun.lock'))) pm = 'bun'
343    const run = (name) => (pm === 'yarn' ? 'yarn ' + name : pm + ' run ' + name)
344    if (scripts.test) out.push({ kind: 'test', command: pm === 'npm' || pm === 'pnpm' || pm === 'bun' ? pm + ' test' : 'yarn test' })
345    const type = ['typecheck', 'type-check', 'check-types', 'tsc'].find((n) => scripts[n])
346    if (type) out.push({ kind: 'typecheck', command: run(type) })
347    if (scripts.build) out.push({ kind: 'build', command: run('build') })
348  } catch {
349    // no package.json, or not readable
350  }
351  return out
352}
353
354async function prepareChecks($) {
355  const cwd = await sessionCwd($)
356  const ledger = await read($, ledgerAtom)
357  const items = []
358  const seen = new Set()
359  const covered = new Set()
360  for (const rec of Object.values(ledger.records)) {
361    if (cwd && !(isUnder(rec.location, cwd) || isUnder(cwd, rec.location))) continue
362    const shown = displayStatus(rec)
363    if (shown.status === STATUS.PASSED) {
364      covered.add(rec.kind)
365      continue
366    }
367    const k = rec.location + '|' + rec.command
368    if (seen.has(k)) continue
369    seen.add(k)
370    items.push({ command: rec.command, location: relativeTo(rec.location, cwd) || '.' })
371  }
372  for (const s of await detectScripts($, cwd)) {
373    if (covered.has(s.kind) || items.some((i) => i.command === s.command)) continue
374    const has = Object.values(ledger.records).some((r) => r.kind === s.kind && (!cwd || isUnder(r.location, cwd) || isUnder(cwd, r.location)))
375    if (!has) items.push({ command: s.command, location: '.' })
376  }
377  const filled = await $.prompt.fill({ text: preparePrompt(items), mode: 'replace' })
378  if (filled && filled.isFilled) await $.ui.close({ id: PANE_ID })
379  else $.ui.toast('Could not fill the prompt box. Press Esc and try again.')
380}
381
382// Set SHIP_CHECK_DEBUG to a file path to write what the band was asked to draw (props, size, records).
383async function debugRender($, e, ledger) {
384  try {
385    const path = await $.env.get('SHIP_CHECK_DEBUG')
386    if (!path) return
387    const info = { at: new Date().toISOString(), surface: e.surface, props: e.props, viewport: e.viewport, records: Object.keys(ledger.records) }
388    await $.fs.write(path, JSON.stringify(info, null, 2))
389  } catch {
390    // debugging aid only
391  }
392}
393
394// --- Registration ----------------------------------------------------------------------------------
395
396export function register(on, options) {
397  const custom = parseCustomChecks(options && options.extra_checks)
398  const customKinds = custom.map((c) => c.kind)
399  const extra = options && Array.isArray(options.extra_ignored_paths) ? options.extra_ignored_paths : []
400
401  on('session.start', async ($, e, next) => {
402    try {
403      await $.command.register({ name: 'ship-check', description: 'Open the Ship Check panel', immediate: true })
404      await update($, ledgerAtom, (value) => settleRunning(value))
405    } catch {
406      // the board still works without the command
407    }
408    return next(e)
409  })
410
411  on('tool.call', async ($, e, next) => {
412    let ctx = null
413    try {
414      ctx = await beginTool($, e, custom, extra)
415    } catch {
416      ctx = null
417    }
418    let result
419    try {
420      result = await next(e)
421    } catch (err) {
422      try {
423        await abortTool($, ctx)
424      } catch {
425        // ignore
426      }
427      throw err
428    }
429    try {
430      await endTool($, e, ctx, result, extra)
431    } catch {
432      // a bookkeeping failure must never change the tool's result
433    }
434    return result
435  })
436
437  // A person may have edited files between turns.
438  on('prompt.submit', async ($, e, next) => {
439    try {
440      await scanActive($, extra, true)
441    } catch {
442      // ignore
443    }
444    return next(e)
445  })
446
447  on('session.measure', async ($, e, next) => {
448    const out = await next(e)
449    try {
450      await scanActive($, extra, false)
451    } catch {
452      // ignore
453    }
454    return out
455  })
456
457  on('command.run', { command: 'ship-check' }, async ($) => {
458    await $.ui.open({ id: PANE_ID, title: 'Ship Check', focus: true, closeOnEscape: true })
459    return {}
460  })
461
462  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
463    const ledger = await read($, ledgerAtom)
464    await debugRender($, e, ledger)
465    if (!hasAnyRecord(ledger)) return next(e)
466    const els = $.ui.resolve(e)
467    const cwd = await sessionCwd($)
468    const openPanel = () => $.ui.open({ id: PANE_ID, title: 'Ship Check', focus: true, closeOnEscape: true })
469    const strip = buildStrip(els, ledger, cwd, customKinds, openPanel)
470    // Other mods may draw here too. The band is one row tall, so sit side by side instead of stacking.
471    const theirs = await next(e)
472    if (!theirs) return strip
473    return els.Box({ flexDirection: 'row', columnGap: 3, children: [strip, theirs] })
474  })
475
476  on('ui.render', { component: 'Pane' }, async ($, e, next) => {
477    if (e.requestId !== PANE_ID) return next(e)
478    const ledger = await read($, ledgerAtom)
479    const view = await read($, viewAtom)
480    const els = $.ui.resolve(e)
481    const cwd = await sessionCwd($)
482    return buildPane(els, {
483      ledger,
484      cwd,
485      expanded: view.expanded,
486      customKinds,
487      onToggle: (key) => update($, viewAtom, (v) => ({ expanded: v.expanded === key ? null : key })),
488      onPrepare: () => prepareChecks($),
489    })
490  })
491}
492
hooks/lib/commands.js 309 lines
1// Recognizes test, build and type-check commands. analyze.js builds on this to read whole command lines.
2// Pure functions: no Claude Code API is used here.
3
4import { resolveDir } from './paths.js'
5
6// Display names for the built-in kinds; custom kinds are capitalized from their id.
7export const KIND_LABELS = { test: 'Tests', typecheck: 'Types', build: 'Build' }
8export const DEFAULT_KINDS = ['test', 'typecheck', 'build']
9
10export function kindLabel(kind) {
11  if (KIND_LABELS[kind]) return KIND_LABELS[kind]
12  return kind.charAt(0).toUpperCase() + kind.slice(1)
13}
14
15// Splits a command line on `&&`. `simple` is false when anything else could change which command
16// decides the exit status: pipes, `;`, `||`, backgrounding, subshells, substitutions, newlines.
17export function tokenizeChain(cmd, ps = false) {
18  const segments = []
19  let tokens = []
20  let cur = ''
21  let has = false
22  let simple = true
23  let quote = null
24
25  const pushToken = () => {
26    if (has) tokens.push(cur)
27    cur = ''
28    has = false
29  }
30  const pushSegment = () => {
31    pushToken()
32    if (tokens.length) segments.push(tokens)
33    tokens = []
34  }
35
36  for (let i = 0; i < cmd.length; i++) {
37    const c = cmd[i]
38    if (quote) {
39      if (c === quote) quote = null
40      else if (c === '\\' && quote === '"' && i + 1 < cmd.length) cur += cmd[++i]
41      else cur += c
42      has = true
43      continue
44    }
45    if (c === "'" || c === '"') {
46      quote = c
47      has = true
48      continue
49    }
50    if (c === '\\' && i + 1 < cmd.length && /[\s"'\\$&|;]/.test(cmd[i + 1])) {
51      cur += cmd[++i]
52      has = true
53      continue
54    }
55    if (c === '\n' || c === '\r') {
56      simple = false
57      pushSegment()
58      continue
59    }
60    if (/\s/.test(c)) {
61      pushToken()
62      continue
63    }
64    if (c === '&') {
65      if (ps && !has && tokens.length === 0 && cmd[i + 1] !== '&') continue // the call operator: & "path"
66      if (cmd[i + 1] === '&') {
67        pushSegment()
68        i++
69        continue
70      }
71      if (cmd[i + 1] === '>' || cmd[i - 1] === '>') {
72        cur += c // a redirect such as 2>&1 or &>file
73        has = true
74        continue
75      }
76      simple = false // a single & runs the command in the background
77      pushSegment()
78      continue
79    }
80    if (c === '|') {
81      simple = false
82      if (cmd[i + 1] === '|') i++
83      pushSegment()
84      continue
85    }
86    if (c === ';') {
87      simple = false
88      pushSegment()
89      continue
90    }
91    if ((!ps && c === '`') || c === '(' || c === ')' || (c === '$' && cmd[i + 1] === '(')) {
92      simple = false
93      cur += c
94      has = true
95      continue
96    }
97    cur += c
98    has = true
99  }
100  if (quote) simple = false
101  pushSegment()
102  return { segments, simple }
103}
104
105// Drops leading `time`, `env`, VAR=value words, and redirections.
106function cleanTokens(tokens) {
107  const out = []
108  for (let i = 0; i < tokens.length; i++) {
109    const t = tokens[i]
110    if (/^\d*>>?(&\d+)?$/.test(t) || /^&>/.test(t)) {
111      i++ // the operator and its target
112      continue
113    }
114    if (/^\d*>>?\S/.test(t) || /^<\S/.test(t)) continue
115    out.push(t)
116  }
117  let start = 0
118  while (start < out.length && (/^[A-Za-z_][A-Za-z0-9_]*=/.test(out[start]) || out[start] === 'time' || out[start] === 'env')) start++
119  return out.slice(start)
120}
121
122const WATCH_FLAG = /^--watch(All)?(=true)?$/
123const PM_NAMES = ['npm', 'pnpm', 'yarn', 'bun']
124
125export function kindForScript(name) {
126  if (!name) return null
127  if (/watch/i.test(name)) return null
128  if (/^(test|tests|unit|test:.+|tests:.+|unit:.+)$/i.test(name)) return 'test'
129  if (/^(typecheck|type-check|check-types|check:types|types|tsc|tsc:check|typecheck:.+|type-check:.+)$/i.test(name)) return 'typecheck'
130  if (/^(build|build:.+)$/i.test(name)) return 'build'
131  return null
132}
133
134function kindForTool(tool, args) {
135  if (tool === 'tsc') {
136    if (args.some((a) => /^-{1,2}noEmit$/i.test(a))) return { kind: 'typecheck' }
137    if (args.includes('--watch') || args.includes('-w')) return { kind: 'build', watch: true }
138    return { kind: 'build' }
139  }
140  if (tool === 'vitest') {
141    const run = args[0] === 'run' || args.includes('--run')
142    return { kind: 'test', watch: !run || args.some((a) => WATCH_FLAG.test(a)) }
143  }
144  if (tool === 'jest') return { kind: 'test', watch: args.some((a) => WATCH_FLAG.test(a)) }
145  return null
146}
147
148// Reads one package-manager invocation. Returns null when it is not a recognized check.
149function interpretPackageManager(pm, rest) {
150  const info = { pm, cwdArg: null, scope: '', watch: false, filtered: false, script: null, builtinTest: false }
151  let i = 0
152  let command = null
153  const take = () => rest[++i]
154
155  for (; i < rest.length; i++) {
156    const t = rest[i]
157    if (t === '--') break
158    if (t.startsWith('-')) {
159      let m
160      if ((m = /^--(prefix|cwd|dir)=(.*)$/.exec(t))) info.cwdArg = m[2]
161      else if (t === '--prefix' || t === '--cwd' || t === '--dir' || t === '-C') info.cwdArg = take()
162      else if ((m = /^--(workspace|filter)=(.*)$/.exec(t))) info.scope += (info.scope ? ',' : '') + m[2]
163      else if (t === '--workspace' || t === '-w' || t === '--filter' || t === '-F') {
164        const v = take()
165        if (v) info.scope += (info.scope ? ',' : '') + v
166      } else if (t === '-r' || t === '--recursive' || t === '--workspaces') info.scope += (info.scope ? ',' : '') + '*'
167      else if (WATCH_FLAG.test(t)) info.watch = true
168      continue
169    }
170    command = t
171    break
172  }
173  if (command === null) return null
174
175  let script = null
176  const after = rest.slice(i + 1)
177  const scriptArgs = []
178  let passthrough = false
179  const addScope = (v) => {
180    if (v) info.scope += (info.scope ? ',' : '') + v
181  }
182  const readScriptTokens = (list) => {
183    for (let k = 0; k < list.length; k++) {
184      const a = list[k]
185      if (a === '--') {
186        passthrough = true
187        continue
188      }
189      if (passthrough) {
190        info.filtered = true
191        if (WATCH_FLAG.test(a) || a === '-w') info.watch = true
192        continue
193      }
194      let m
195      if (WATCH_FLAG.test(a)) info.watch = true
196      else if ((m = /^--(workspace|filter)=(.*)$/.exec(a))) addScope(m[2])
197      else if (a === '--workspace' || a === '-w' || a === '--filter' || a === '-F') addScope(list[++k])
198      else if ((m = /^--(prefix|cwd|dir)=(.*)$/.exec(a))) info.cwdArg = m[2]
199      else if (a === '--prefix' || a === '--cwd' || a === '--dir' || a === '-C') info.cwdArg = list[++k]
200      else if (a === '-r' || a === '--recursive' || a === '--workspaces') addScope('*')
201      else if (!a.startsWith('-')) scriptArgs.push(a)
202    }
203  }
204
205  if (command === 'test' || command === 't' || command === 'tst') {
206    script = 'test'
207    if (pm === 'bun') info.builtinTest = true
208    readScriptTokens(after)
209  } else if (command === 'run' || command === 'run-script' || command === 'rum' || command === 'urn') {
210    // flags may sit between `run` and the script name
211    const idx = after.findIndex((a) => !a.startsWith('-'))
212    if (idx === -1) return null
213    for (const a of after.slice(0, idx)) {
214      if (WATCH_FLAG.test(a)) info.watch = true
215    }
216    script = after[idx]
217    readScriptTokens(after.slice(idx + 1))
218  } else if (command === 'workspace' && pm === 'yarn') {
219    info.scope += (info.scope ? ',' : '') + (after[0] || '')
220    const idx = after.slice(1).findIndex((a) => !a.startsWith('-'))
221    if (idx === -1) return null
222    script = after.slice(1)[idx]
223    readScriptTokens(after.slice(1 + idx + 1))
224  } else if (command === 'build' && pm === 'bun') {
225    script = 'build'
226    readScriptTokens(after)
227  } else if (pm !== 'npm' && kindForScript(command)) {
228    script = command // pnpm build, yarn typecheck, bun typecheck
229    readScriptTokens(after)
230  } else if (command === 'exec' || command === 'x' || command === 'dlx') {
231    const tool = after[0]
232    const toolInfo = tool ? kindForTool(tool, after.slice(1)) : null
233    if (!toolInfo) return null
234    return { ...info, kind: toolInfo.kind, watch: info.watch || !!toolInfo.watch, script: tool }
235  } else {
236    return null
237  }
238
239  const kind = kindForScript(script)
240  if (!kind) return null
241  if (scriptArgs.length) info.filtered = true // e.g. `pnpm test src/a.test.ts`
242  return { ...info, kind, script }
243}
244
245// Interprets one `&&` segment: a directory change, a check, or something else (null).
246export function interpretSegment(rawTokens, customChecks, ps = false) {
247  const tokens = cleanTokens(rawTokens)
248  if (tokens.length === 0) return null
249
250  if (/^(cd|chdir|sl|set-location)$/i.test(tokens[0]) || (ps && /^(push-location|pushd)$/i.test(tokens[0]))) {
251    // Drop flags: PowerShell's -Path style, and cmd's `cd /d dir`.
252    const args = tokens.slice(1).filter((t) => !/^\/[a-z]$/i.test(t) && !/^-(path|literalpath|passthru|stackname)$/i.test(t) && !(t.startsWith('-') && t.length > 1))
253    return { type: 'cd', dir: args[0] || null }
254  }
255  if (ps && /^(pop-location|popd)$/i.test(tokens[0])) return { type: 'cd', dir: null }
256  if (!ps && (tokens[0] === 'pushd' || tokens[0] === 'popd')) return { type: 'unsupported-cd' }
257
258  const text = tokens.join(' ')
259  for (const custom of customChecks || []) {
260    if (text === custom.command || text.startsWith(custom.command + ' ')) {
261      const rest = text.slice(custom.command.length).trim()
262      return {
263        type: 'check',
264        kind: custom.kind,
265        pm: 'custom',
266        cwdArg: null,
267        scope: '',
268        watch: /(^|\s)--watch(All)?(\s|$)/.test(rest),
269        filtered: false,
270        text,
271      }
272    }
273  }
274
275  let head = tokens[0]
276  let rest = tokens.slice(1)
277  if (head === 'npx' || head === 'bunx' || head === 'pnpx') {
278    const toolInfo = rest[0] ? kindForTool(rest[0], rest.slice(1)) : null
279    if (!toolInfo) return null
280    return { type: 'check', kind: toolInfo.kind, pm: head, cwdArg: null, scope: '', watch: !!toolInfo.watch, filtered: false, text }
281  }
282  if (head === 'tsc' || head === 'vitest' || head === 'jest') {
283    const toolInfo = kindForTool(head, rest)
284    return { type: 'check', kind: toolInfo.kind, pm: 'direct', cwdArg: null, scope: '', watch: !!toolInfo.watch, filtered: false, text }
285  }
286  if (!PM_NAMES.includes(head)) return null
287  const found = interpretPackageManager(head, rest)
288  if (!found) return null
289  return { type: 'check', ...found, text }
290}
291
292// Parses user config lines such as `lint=npm run lint` into custom checks.
293export function parseCustomChecks(lines) {
294  const out = []
295  for (const line of Array.isArray(lines) ? lines : []) {
296    if (typeof line !== 'string') continue
297    const eq = line.indexOf('=')
298    if (eq < 1) continue
299    const kind = line.slice(0, eq).trim().toLowerCase()
300    const command = line
301      .slice(eq + 1)
302      .trim()
303      .split(/\s+/)
304      .join(' ')
305    if (!/^[a-z0-9-]{1,16}$/.test(kind) || !command) continue
306    out.push({ kind, command })
307  }
308  return out
309}
hooks/lib/analyze.js 306 lines
1// Reads a whole shell command line and decides, for every check inside it, how far the tool's exit
2// status can be trusted. Pure functions: no Claude Code API is used here.
3//
4// What the tools report, measured against real sessions:
5//   Bash        the exit status of the last statement.
6//   PowerShell  `$LASTEXITCODE`: the exit status of the last native program that ran, however many
7//               cmdlets, pipelines into cmdlets, or `if` blocks follow it.
8//
9// Each check gets a `mode`:
10//   exact         the tool's exit status is this check's exit status.
11//   success-only  a clean finish proves this check passed, but a failure could belong to another
12//                 command in the same `&&` chain.
13//   echo          the shell printed the status (`echo "EXIT: $?"`), so it is read from the output.
14//   none          the exit status cannot be tied to this check.
15
16import { interpretSegment, tokenizeChain } from './commands.js'
17import { resolveDir } from './paths.js'
18
19const OR_FLAG = /\|\|/
20const BACKGROUND = /(?<![&>])&(?![&>])/
21const SUBSHELL_SH = /\$\(|\(|\)|`/
22const SUBSHELL_PS = /\$\(|\(|\)/
23const CMDLET = /^[A-Za-z]+-[A-Za-z]+/
24const UNSAFE_CMDLET = /^(invoke-|start-process|start-job|new-object|add-type)/i
25const PS_ALIASES = new Set(['select', 'where', 'foreach', 'sort', 'group', 'measure', 'tee', 'ft', 'fl', 'more', 'oh', 'ogv'])
26
27// Replaces the inside of quoted strings with `_`, keeping every index the same.
28function stripQuoted(s) {
29  let out = ''
30  let q = null
31  for (let i = 0; i < s.length; i++) {
32    const c = s[i]
33    if (q) {
34      if (c === q) {
35        q = null
36        out += c
37      } else out += '_'
38    } else {
39      out += c
40      if (c === '"' || c === "'") q = c
41    }
42  }
43  return { text: out, unterminated: q !== null }
44}
45
46// Splits `orig` where `match(stripped, i)` returns a separator length, outside () {} [].
47function splitBy(orig, stripped, match) {
48  const out = []
49  let start = 0
50  let depth = 0
51  for (let i = 0; i < stripped.length; i++) {
52    const c = stripped[i]
53    if (c === '(' || c === '{' || c === '[') depth++
54    else if (c === ')' || c === '}' || c === ']') depth = Math.max(0, depth - 1)
55    if (depth === 0) {
56      const len = match(stripped, i)
57      if (len > 0) {
58        out.push(orig.slice(start, i))
59        start = i + len
60        i += len - 1
61      }
62    }
63  }
64  out.push(orig.slice(start))
65  return out.map((s) => s.trim()).filter(Boolean)
66}
67
68const matchStatementEnd = (s, i) => (s[i] === ';' || s[i] === '\n' || s[i] === '\r' ? 1 : 0)
69const matchAndAnd = (s, i) => (s[i] === '&' && s[i + 1] === '&' ? 2 : 0)
70const matchPipe = (s, i) => (s[i] === '|' && s[i + 1] !== '|' && s[i - 1] !== '|' ? 1 : 0)
71
72function isCmdletStage(stage) {
73  const first = stage.trim().split(/\s+/)[0] || ''
74  if (UNSAFE_CMDLET.test(first)) return false
75  return CMDLET.test(first) || PS_ALIASES.has(first.toLowerCase())
76}
77
78// One statement: its `&&` chain, each segment's pipeline, and flags for forms we cannot follow.
79function parseStatement(text, ps, customChecks) {
80  const { text: stripped, unterminated } = stripQuoted(text)
81  const s = ps ? stripped.replace(/^&\s+/, ' ') : stripped
82  const flags = {
83    or: OR_FLAG.test(s),
84    bg: BACKGROUND.test(s),
85    sub: (ps ? SUBSHELL_PS : SUBSHELL_SH).test(s),
86    unterminated,
87  }
88  const segTexts = splitBy(text, stripped, matchAndAnd)
89  const chain = segTexts.map((segText) => {
90    const segStripped = stripQuoted(segText).text
91    const stages = splitBy(segText, segStripped, matchPipe)
92    const head = stages[0] || ''
93    const tokens = tokenizeChain(head, ps).segments[0] || []
94    const interp = interpretSegment(tokens, customChecks, ps)
95    let kind = 'other'
96    if (interp && interp.type === 'cd') kind = 'cd'
97    else if (interp && interp.type === 'check') kind = 'check'
98    return {
99      text: segText,
100      stages,
101      pipe: stages.length > 1,
102      nativePipe: ps && stages.slice(1).some((st) => !isCmdletStage(st)),
103      interp,
104      kind,
105    }
106  })
107  return { text, flags, chain }
108}
109
110// --- Statements that cannot change the exit status ---------------------------------------------
111
112function braceBodies(s) {
113  const bodies = []
114  let depth = 0
115  let start = -1
116  for (let i = 0; i < s.length; i++) {
117    if (s[i] === '{') {
118      if (depth === 0) start = i + 1
119      depth++
120    } else if (s[i] === '}') {
121      depth = Math.max(0, depth - 1)
122      if (depth === 0 && start >= 0) {
123        bodies.push(s.slice(start, i))
124        start = -1
125      }
126    }
127  }
128  return bodies
129}
130
131// True when a PowerShell statement runs no native program, so `$LASTEXITCODE` stays as it was.
132export function psStatementNonNative(stmt, customChecks) {
133  const s = stmt.trim()
134  if (!s) return true
135  const { text: st } = stripQuoted(s)
136  if (/^["']/.test(s) && !/[|&]/.test(st)) return true
137  if (/^\$(\?|LASTEXITCODE)\s*$/i.test(s)) return true
138  if (/^\$\w+\s*=/.test(s)) return false // an assignment may hold a native program
139  if (/^exit\s+\$LASTEXITCODE\s*$/i.test(s)) return true
140  const first = (s.split(/\s+/)[0] || '').toLowerCase()
141
142  if (/^(if|elseif)\b/.test(first) || first === 'if(') {
143    const open = st.indexOf('(')
144    if (open < 0) return false
145    let depth = 0
146    let close = -1
147    for (let i = open; i < st.length; i++) {
148      if (st[i] === '(') depth++
149      else if (st[i] === ')') {
150        depth--
151        if (depth === 0) {
152          close = i
153          break
154        }
155      }
156    }
157    if (close < 0) return false
158    const condition = st.slice(open + 1, close)
159    if (!/^[\w\s$?!.\-='"<>]*$/.test(condition)) return false
160    const bodies = braceBodies(s.slice(close + 1))
161    if (!bodies.length) return false
162    return bodies.every((b) => splitBy(b, stripQuoted(b).text, matchStatementEnd).every((x) => psStatementNonNative(x, customChecks)))
163  }
164
165  const tokens = tokenizeChain(s, true).segments[0] || []
166  const interp = interpretSegment(tokens, customChecks, true)
167  if (interp && interp.type === 'cd') return true
168  if (interp && interp.type === 'check') return false
169  if (/\$\(|\(/.test(st)) return false
170  if (/^(write-host|write-output|write-information|write-warning|write-verbose|echo)\b/i.test(s)) return true
171  if (UNSAFE_CMDLET.test(first)) return false
172  if (CMDLET.test(first)) {
173    // every pipeline stage must be a cmdlet too
174    return splitBy(s, st, matchPipe).every(isCmdletStage)
175  }
176  return false
177}
178
179// `echo "EXIT: $?"` (or `${PIPESTATUS[0]}` after a pipeline): the shell printed the status itself.
180export function exitEchoSh(stmt, afterPipe) {
181  const s = stmt.trim()
182  if (!/^(echo|printf)\b/.test(s)) return false
183  const { text: st } = stripQuoted(s)
184  if (/[|&;]|\$\(|`/.test(st)) return false
185  if (/\$\{PIPESTATUS\[0\]\}/.test(s)) return true
186  return !afterPipe && /\$\?/.test(s)
187}
188
189// --- Wrappers: cmd /c "..." and sh -c "..." --------------------------------------------------------
190
191function unwrap(text) {
192  const t = text.trim()
193  let m = /^cmd(?:\.exe)?\s+((?:\/[a-z]\s+)*)\/c\s+([\s\S]+)$/i.exec(t)
194  if (m) {
195    let inner = m[2].trim()
196    if (inner.startsWith('"')) {
197      const close = inner.indexOf('"', 1)
198      if (close !== inner.length - 1) return null // text after the quoted command: not a plain wrapper
199      inner = inner.slice(1, -1)
200    }
201    return { inner, shell: 'cmd' }
202  }
203  m = /^(?:bash|sh|zsh)\s+-[a-z]*c\s+(['"])([\s\S]*)\1$/i.exec(t)
204  if (m) return { inner: m[2], shell: 'sh' }
205  return null
206}
207
208// In cmd, a lone `&` separates commands like `;` does elsewhere.
209function cmdAmpersands(text) {
210  const { text: st } = stripQuoted(text)
211  let out = ''
212  for (let i = 0; i < text.length; i++) {
213    if (st[i] === '&' && st[i + 1] !== '&' && st[i - 1] !== '&' && st[i - 1] !== '>' && st[i + 1] !== '>') out += ';'
214    else out += text[i]
215  }
216  return out
217}
218
219// --- The analysis ---------------------------------------------------------------------------------------
220
221function decideMode(check, statements, ps) {
222  const none = (reason) => ({ mode: 'none', reason })
223  const st = statements[check.si]
224  const seg = st.chain[check.ci]
225  if (st.flags.unterminated) return none('The command has an unterminated quote, so it cannot be read reliably.')
226  if (st.flags.or || st.flags.bg || st.flags.sub) return none('This compound command does not show which part decided the exit status.')
227
228  const later = statements.slice(check.si + 1)
229  const lastSegment = check.ci === st.chain.length - 1
230  const earlierOnlyCd = st.chain.slice(0, check.ci).every((s) => s.kind === 'cd')
231  const successOnly = (reason) => ({ mode: 'success-only', reason })
232
233  if (ps) {
234    if (seg.nativePipe) return none('The check is piped into another program, so the exit status may belong to that program.')
235    if (!later.every((s) => psStatementNonNative(s.text))) return none('Another command ran after this check, so the exit status may belong to it.')
236    if (!lastSegment) return successOnly('A later command in this && chain decided the exit status.')
237    if (!earlierOnlyCd) return successOnly('An earlier command in this && chain may have failed instead of this check.')
238    return { mode: 'exact', reason: null }
239  }
240
241  if (later.length) {
242    if (later.every((s) => exitEchoSh(s.text, seg.pipe))) {
243      if (!lastSegment || !earlierOnlyCd) return none('The printed exit status may belong to another command in this chain.')
244      return { mode: 'echo', reason: null }
245    }
246    return none('A later command decided the exit status.')
247  }
248  if (seg.pipe) return none('The check is piped into another command, so the exit status may belong to that command.')
249  if (!lastSegment) return successOnly('A later command in this && chain decided the exit status.')
250  if (!earlierOnlyCd) return successOnly('An earlier command in this && chain may have failed instead of this check.')
251  return { mode: 'exact', reason: null }
252}
253
254export function analyzeCommand(cmd, baseDir, customChecks, options = {}) {
255  let ps = options.powershell === true
256  let text = String(cmd)
257  const wrapped = unwrap(text)
258  if (wrapped) {
259    text = wrapped.shell === 'cmd' ? cmdAmpersands(wrapped.inner) : wrapped.inner
260    ps = false
261  }
262
263  const result = { checks: [], hasWatch: false, unresolvedDir: false, shell: ps ? 'powershell' : wrapped ? wrapped.shell : 'sh' }
264  const { text: stripped } = stripQuoted(text)
265  const statements = splitBy(text, stripped, matchStatementEnd).map((s) => parseStatement(s, ps, customChecks))
266
267  let dir = baseDir || null
268  let cdSeen = false
269  statements.forEach((st, si) => {
270    st.chain.forEach((seg, ci) => {
271      const info = seg.interp
272      if (!info) return
273      if (info.type === 'unsupported-cd') {
274        st.flags.sub = true
275        return
276      }
277      if (info.type === 'cd') {
278        dir = info.dir ? resolveDir(dir, info.dir) : null
279        cdSeen = true
280        return
281      }
282      let location = dir
283      if (info.cwdArg) location = resolveDir(dir, info.cwdArg)
284      if (!location) {
285        result.unresolvedDir = true
286        return
287      }
288      if (info.watch) result.hasWatch = true
289      result.checks.push({
290        si,
291        ci,
292        kind: info.kind,
293        location,
294        scope: info.scope || '',
295        watch: !!info.watch,
296        filtered: !!info.filtered,
297        command: info.text,
298        cdUsed: cdSeen,
299      })
300    })
301  })
302
303  for (const check of result.checks) Object.assign(check, decideMode(check, statements, ps))
304  return result
305}
306
hooks/lib/paths.js 62 lines
1// Path helpers. Everything is normalized to forward slashes with a lower-case drive letter,
2// so a Windows path and its Git Bash spelling (/c/Users/me) compare equal.
3
4export function normalizePath(p) {
5  if (typeof p !== 'string' || p === '') return ''
6  let s = p.replace(/\\/g, '/')
7  // Git Bash spells C:\Users as /c/Users
8  const bash = /^\/([a-zA-Z])(\/|$)/.exec(s)
9  if (bash) s = bash[1] + ':/' + s.slice(3)
10  const drive = /^([a-zA-Z]):(\/|$)/.exec(s)
11  let prefix = ''
12  if (drive) {
13    prefix = drive[1].toLowerCase() + ':'
14    s = s.slice(2)
15  }
16  const absolute = s.startsWith('/')
17  const out = []
18  for (const part of s.split('/')) {
19    if (part === '' || part === '.') continue
20    if (part === '..') {
21      if (out.length && out[out.length - 1] !== '..') out.pop()
22      else if (!absolute) out.push('..')
23      continue
24    }
25    out.push(part)
26  }
27  return prefix + (absolute || prefix ? '/' : '') + out.join('/')
28}
29
30export function isAbsolute(p) {
31  return typeof p === 'string' && (/^[a-zA-Z]:[\\/]/.test(p) || p.startsWith('/') || p.startsWith('\\'))
32}
33
34// Resolves `rel` against `base`. Returns null for spellings we cannot resolve (~, $VAR, -).
35export function resolveDir(base, rel) {
36  if (typeof rel !== 'string' || rel === '') return null
37  if (rel.startsWith('~') || rel.includes('$') || rel === '-') return null
38  if (isAbsolute(rel)) return normalizePath(rel)
39  if (!base) return null
40  return normalizePath(base + '/' + rel)
41}
42
43export function isUnder(child, parent) {
44  if (!child || !parent) return false
45  if (child === parent) return true
46  const p = parent.endsWith('/') ? parent : parent + '/'
47  return child.startsWith(p)
48}
49
50export function relativeTo(child, parent) {
51  if (child === parent) return ''
52  return child.startsWith(parent + '/') ? child.slice(parent.length + 1) : child
53}
54
55// A short spelling of a project location for the UI.
56export function displayLocation(location, cwd) {
57  if (!location) return 'unknown location'
58  if (cwd && location === cwd) return location.split('/').pop() || location
59  if (cwd && isUnder(location, cwd)) return './' + relativeTo(location, cwd)
60  return location
61}
62
hooks/lib/model.js 438 lines
1// The ledger of checks and the rules that turn tool results into statuses.
2// Pure functions over plain JSON: nothing here calls the Claude Code API.
3
4import { isUnder, relativeTo, normalizePath } from './paths.js'
5import { DEFAULT_KINDS, kindLabel } from './commands.js'
6
7export const MAX_RUNS = 40
8export const MAX_SUMMARY_CHARS = 1500
9export const MAX_STALE_FILES = 5
10
11export const STATUS = {
12  NOT_RUN: 'Not run',
13  RUNNING: 'Running',
14  PASSED: 'Passed',
15  FAILED: 'Failed',
16  STALE: 'Stale',
17  UNKNOWN: 'Unknown',
18}
19
20export function emptyLedger() {
21  return { records: {}, runs: [] }
22}
23
24export function recordKey(location, kind, scope) {
25  return location + '|' + kind + '|' + (scope || '')
26}
27
28// --- Which files count -------------------------------------------------------------------------
29
30const EXCLUDED_DIRS = new Set([
31  'node_modules', '.git', '.hg', '.svn', 'dist', 'build', 'out', '.next', '.nuxt', '.svelte-kit',
32  '.turbo', '.cache', '.parcel-cache', '.vite', '.vitepress', '.yarn', '.pnpm-store', 'coverage',
33  '.nyc_output', 'target', '__pycache__', '.venv', 'venv', '.claude', '.idea', '.vscode',
34  '.gradle', 'storybook-static', 'playwright-report', 'test-results',
35])
36const EXCLUDED_FILE = /(\.log|\.tsbuildinfo|\.tmp|\.swp|\.swo|~)$|^\.DS_Store$|^Thumbs\.db$/
37const DEPENDENCY_FILE = /^(package\.json|package-lock\.json|npm-shrinkwrap\.json|pnpm-lock\.yaml|pnpm-workspace\.yaml|yarn\.lock|bun\.lockb?|\.npmrc|\.yarnrc(\.yml)?|\.nvmrc|\.node-version|\.tool-versions)$/
38const CONFIG_FILE = /^(tsconfig[\w.-]*\.json|jsconfig\.json|[\w.-]*\.config\.[cm]?[jt]s|\.eslintrc[\w.]*|\.prettierrc[\w.]*|babel\.config[\w.]*|\.babelrc[\w.]*|\.env[\w.]*|vite\.config[\w.]*|Makefile|Dockerfile)$/
39
40// `rel` uses forward slashes and is relative to the project root. `extra` is the user's ignore list.
41export function isTrackedPath(rel, extra) {
42  if (!rel || rel.startsWith('../')) return false
43  const parts = rel.split('/')
44  for (let i = 0; i < parts.length - 1; i++) if (EXCLUDED_DIRS.has(parts[i])) return false
45  if (EXCLUDED_DIRS.has(parts[parts.length - 1]) && parts.length === 1) return false
46  if (EXCLUDED_FILE.test(parts[parts.length - 1])) return false
47  for (const raw of extra || []) {
48    const pat = String(raw).replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, '')
49    if (!pat) continue
50    if (rel === pat || rel.startsWith(pat + '/') || parts.includes(pat)) return false
51  }
52  return true
53}
54
55export function categorizePath(rel) {
56  const name = rel.split('/').pop() || rel
57  if (DEPENDENCY_FILE.test(name)) return 'dependency'
58  if (CONFIG_FILE.test(name)) return 'config'
59  return 'source'
60}
61
62// "Source files changed after this check completed."
63export function staleReasonFor(categories, when = 'completed') {
64  const order = ['source', 'config', 'dependency']
65  const present = order.filter((c) => categories.includes(c))
66  if (present.length === 0) present.push('source')
67  const names = present.map((c) => (c === 'source' ? 'Source' : c === 'config' ? 'configuration' : 'dependency'))
68  let subject
69  if (names.length === 1) subject = names[0] + ' files'
70  else if (names.length === 2) subject = names[0] + ' and ' + names[1] + ' files'
71  else subject = names[0] + ', ' + names[1] + ', and ' + names[2] + ' files'
72  const phrase = when === 'running' ? 'while this check was running' : 'after this check completed'
73  return subject.charAt(0).toUpperCase() + subject.slice(1) + ' changed ' + phrase + '.'
74}
75
76// --- Turning a tool result into a status ------------------------------------------------------
77
78export function stripAnsi(s) {
79  // eslint-disable-next-line no-control-regex
80  return String(s).replace(/\u001b\[[0-9;?]*[ -/]*[@-~]/g, '').replace(/\r(?!\n)/g, '\n')
81}
82
83export function tailOutput(stdout, stderr, max = MAX_SUMMARY_CHARS) {
84  const parts = []
85  if (typeof stdout === 'string' && stdout) parts.push(stdout)
86  if (typeof stderr === 'string' && stderr) parts.push(stderr)
87  const text = stripAnsi(parts.join('\n')).trim()
88  if (text.length <= max) return text
89  return '…' + text.slice(text.length - max + 1)
90}
91
92export function parseExitCode(text) {
93  if (typeof text !== 'string') return null
94  const m = /(?:^|\n)Exit code:?\s+(-?\d+)/.exec(text)
95  return m ? Number(m[1]) : null
96}
97
98// Decides the status of one check from what the Bash tool actually reported.
99// `parsed` is analyzeCommand's output, `check` the check inside it, `outcome` the tool result.
100// The last number on the last output line, as in "EXIT: 2".
101export function parseTrailingCode(text) {
102  if (typeof text !== 'string') return null
103  const lines = stripAnsi(text).split('\n').map((l) => l.trim()).filter(Boolean)
104  const m = lines.length ? /(-?\d+)$/.exec(lines[lines.length - 1]) : null
105  return m ? Number(m[1]) : null
106}
107
108export function classifyOutcome(parsed, check, outcome) {
109  const unknown = (reason) => ({ status: 'unknown', reason, exitCode: null })
110
111  if (parsed.untrustedTool) {
112    return unknown('Ship Check cannot read results from the "' + parsed.untrustedTool + '" tool, so this run is not counted.')
113  }
114  if (check.watch || parsed.hasWatch) return unknown('Watch mode does not finish on its own, so no result was recorded.')
115  if (check.mode === 'none') return unknown(check.reason || 'The exit status cannot be tied to this check.')
116  if (!outcome || typeof outcome !== 'object') return unknown('The tool returned no result for this command.')
117
118  // A command that exits non-zero comes back as isError with the text 'Exit code N ...' and a
119  // string result; only a successful run carries the structured record.
120  const r = outcome.result && typeof outcome.result === 'object' ? outcome.result : null
121  if (r) {
122    if (r.backgroundTaskId || r.backgroundedByUser || r.backgroundedByTurnAbort || r.backgroundedToDeliverMessage || r.timedOutAfterMs) {
123      return unknown('The command moved to the background before it finished.')
124    }
125    if (r.interrupted === true) return unknown('The command was interrupted before it finished.')
126  }
127
128  if (check.mode === 'echo') return classifyEcho(outcome, r)
129  if (outcome.isError === true) return classifyFailure(check, outcome)
130
131  if (!r) return unknown('The tool returned no result for this command.')
132  if (typeof r.interrupted !== 'boolean') return unknown('The tool did not report whether the command finished.')
133  if (typeof r.returnCodeInterpretation === 'string' && r.returnCodeInterpretation) {
134    return unknown('The command exited with a status that the tool did not treat as a plain success.')
135  }
136  if (directoryChangeFailed(check, outcome)) return unknown(DIRECTORY_REASON)
137  // A clean finish: every command in an && chain succeeded, so the exit status was 0.
138  return { status: 'passed', reason: null, exitCode: 0 }
139}
140
141const DIRECTORY_REASON = 'The directory change may have failed, so the check may have run somewhere else.'
142
143// If `cd` or Set-Location failed, the check ran in the old directory: do not trust the location.
144function directoryChangeFailed(check, outcome) {
145  if (!check.cdUsed) return false
146  const text = String(outcome.text || (outcome.result && outcome.result.stderr) || '')
147  return /No such file or directory|Cannot find path|cannot find the path|cannot find the file|does not exist|The system cannot find/i.test(text)
148}
149
150// The shell printed the status itself: `npm test; echo "EXIT: $?"`.
151function classifyEcho(outcome, r) {
152  const unknown = (reason) => ({ status: 'unknown', reason, exitCode: null })
153  const code = parseTrailingCode(r ? r.stdout : outcome.text)
154  if (code === null) return unknown('The exit status line could not be read from the output.')
155  return code === 0 ? { status: 'passed', reason: null, exitCode: 0 } : { status: 'failed', reason: null, exitCode: code }
156}
157
158function classifyFailure(check, outcome) {
159  const unknown = (reason) => ({ status: 'unknown', reason, exitCode: null })
160  if (check.mode === 'success-only') return unknown(check.reason || 'Another command in this chain may have failed, so the result is not tied to this check.')
161  const exitCode = parseExitCode(outcome.text)
162  if (exitCode === null) return unknown('The tool reported an error but no exit status.')
163  if (directoryChangeFailed(check, outcome)) return unknown(DIRECTORY_REASON)
164  return { status: 'failed', reason: null, exitCode }
165}
166
167// --- Display -------------------------------------------------------------------------------------
168
169export function displayStatus(record) {
170  if (!record) return { status: STATUS.NOT_RUN, detail: null }
171  if (record.status === 'running') return { status: STATUS.RUNNING, detail: null }
172  if (record.status === 'unknown') return { status: STATUS.UNKNOWN, detail: record.reason || null }
173  if (record.staleAt) {
174    return {
175      status: STATUS.STALE,
176      detail: record.staleReason || 'Files changed after this check completed.',
177      last: record.status === 'passed' ? STATUS.PASSED : STATUS.FAILED,
178    }
179  }
180  return { status: record.status === 'passed' ? STATUS.PASSED : STATUS.FAILED, detail: null }
181}
182
183export const ICONS = {
184  [STATUS.NOT_RUN]: '○',
185  [STATUS.RUNNING]: '◌',
186  [STATUS.PASSED]: '✓',
187  [STATUS.FAILED]: '✗',
188  [STATUS.STALE]: '↻',
189  [STATUS.UNKNOWN]: '?',
190}
191
192export const STATUS_COLORS = {
193  [STATUS.NOT_RUN]: undefined,
194  [STATUS.RUNNING]: 'cyan',
195  [STATUS.PASSED]: 'green',
196  [STATUS.FAILED]: 'red',
197  [STATUS.STALE]: 'yellow',
198  [STATUS.UNKNOWN]: 'yellow',
199}
200
201export function relevantToCwd(record, cwd) {
202  if (!cwd) return true
203  return isUnder(record.location, cwd) || isUnder(cwd, record.location)
204}
205
206// One entry per kind for the status line: the most recent record near `cwd`.
207// Which status the status line shows when a kind has results in several locations, worst first.
208const SEVERITY = [STATUS.FAILED, STATUS.STALE, STATUS.UNKNOWN, STATUS.RUNNING, STATUS.PASSED]
209
210export function kindSummary(ledger, cwd, customKinds) {
211  const kinds = [...DEFAULT_KINDS]
212  for (const k of customKinds || []) if (!kinds.includes(k)) kinds.push(k)
213  const records = Object.values(ledger.records).filter((r) => relevantToCwd(r, cwd))
214  return kinds.map((kind) => {
215    // Several locations can hold the same kind of check. Show the worst of them, so a failure in
216    // one package is not hidden behind a newer pass in another. Ties go to the most recent run.
217    const mine = records
218      .filter((r) => r.kind === kind)
219      .map((record) => ({ record, shown: displayStatus(record) }))
220      .sort((a, b) => SEVERITY.indexOf(a.shown.status) - SEVERITY.indexOf(b.shown.status) || (b.record.startedAt || 0) - (a.record.startedAt || 0))
221    const top = mine[0]
222    return { kind, label: kindLabel(kind), record: top ? top.record : null, count: mine.length, ...(top ? top.shown : displayStatus(null)) }
223  })
224}
225
226export function hasAnyRecord(ledger) {
227  return Object.keys(ledger.records).length > 0
228}
229
230export function formatClock(ms) {
231  if (!ms) return ''
232  const d = new Date(ms)
233  const p = (n) => String(n).padStart(2, '0')
234  return p(d.getHours()) + ':' + p(d.getMinutes()) + ':' + p(d.getSeconds())
235}
236
237export function formatDuration(ms) {
238  if (typeof ms !== 'number' || ms < 0) return ''
239  if (ms < 1000) return ms + 'ms'
240  const s = ms / 1000
241  if (s < 60) return s.toFixed(1) + 's'
242  const m = Math.floor(s / 60)
243  return m + 'm ' + Math.round(s - m * 60) + 's'
244}
245
246export function summaryLine(record) {
247  if (!record) return ''
248  const bits = []
249  if (record.endedAt) bits.push(formatClock(record.endedAt))
250  else if (record.startedAt) bits.push('started ' + formatClock(record.startedAt))
251  if (record.endedAt && record.startedAt) {
252    const took = formatDuration(record.endedAt - record.startedAt)
253    bits.push(record.together > 1 ? took + ' for the whole command' : took)
254  }
255  if (typeof record.exitCode === 'number') bits.push('exit ' + record.exitCode)
256  return bits.join(' · ')
257}
258
259// --- Ledger updates -------------------------------------------------------------------------------
260
261function pushRun(runs, record) {
262  const run = {
263    key: record.key,
264    kind: record.kind,
265    location: record.location,
266    command: record.command,
267    status: record.status,
268    startedAt: record.startedAt,
269    endedAt: record.endedAt || null,
270  }
271  return [...runs, run].slice(-MAX_RUNS)
272}
273
274export function beginRecord(ledger, check, startSig, root, now) {
275  const key = recordKey(check.location, check.kind, check.scope)
276  const record = {
277    key,
278    kind: check.kind,
279    location: check.location,
280    scope: check.scope,
281    root,
282    command: check.command,
283    together: check.together || 1,
284    filtered: check.filtered,
285    status: 'running',
286    startedAt: now,
287    endedAt: null,
288    exitCode: null,
289    reason: null,
290    summary: '',
291    startSig,
292    endSig: null,
293    dirtyDuringRun: false,
294    staleAt: null,
295    staleReason: null,
296    staleFiles: [],
297  }
298  return { ...ledger, records: { ...ledger.records, [key]: record } }
299}
300
301// Puts a record back the way it was before this run began (the tool call never ran).
302export function restoreRecord(ledger, key, previous) {
303  const records = { ...ledger.records }
304  if (previous) records[key] = previous
305  else delete records[key]
306  return { ...ledger, records }
307}
308
309export function finishRecord(ledger, key, startedAt, finish) {
310  const current = ledger.records[key]
311  if (!current || current.startedAt !== startedAt) return ledger // a newer run owns this key
312  let record = {
313    ...current,
314    status: finish.status,
315    endedAt: finish.now,
316    exitCode: finish.exitCode,
317    reason: finish.reason,
318    summary: finish.summary,
319    endSig: finish.endSig,
320  }
321  if (record.status === 'passed' || record.status === 'failed') {
322    const changedDuring = current.dirtyDuringRun || (current.startSig && finish.endSig && current.startSig.hash !== finish.endSig.hash)
323    if (changedDuring) {
324      record = {
325        ...record,
326        staleAt: finish.now,
327        staleReason: staleReasonFor(finish.duringCategories && finish.duringCategories.length ? finish.duringCategories : ['source'], 'running'),
328        staleFiles: finish.duringFiles || [],
329      }
330    }
331  }
332  return { ...ledger, records: { ...ledger.records, [key]: record }, runs: pushRun(ledger.runs, record) }
333}
334
335// A file changed through Claude's own tools or a shell command we could read.
336export function applyEdit(ledger, absPath, now, extra) {
337  const path = normalizePath(absPath)
338  let changed = false
339  const records = {}
340  for (const [key, rec] of Object.entries(ledger.records)) {
341    records[key] = rec
342    if (!rec.root || !isUnder(path, rec.root)) continue
343    const rel = relativeTo(path, rec.root)
344    if (!isTrackedPath(rel, extra)) continue
345    if (rec.status === 'running') {
346      if (!rec.dirtyDuringRun) {
347        records[key] = { ...rec, dirtyDuringRun: true }
348        changed = true
349      }
350      continue
351    }
352    if ((rec.status === 'passed' || rec.status === 'failed') && !rec.staleAt) {
353      records[key] = {
354        ...rec,
355        staleAt: now,
356        staleReason: staleReasonFor([categorizePath(rel)]),
357        staleFiles: [rel],
358      }
359      changed = true
360    }
361  }
362  return changed ? { ...ledger, records } : ledger
363}
364
365// A change we cannot name: mark every finished result under `root` as stale for `reason`.
366export function applyUnnamedChange(ledger, root, now, reason) {
367  let changed = false
368  const records = {}
369  for (const [key, rec] of Object.entries(ledger.records)) {
370    records[key] = rec
371    if (!rec.root || !(isUnder(root, rec.root) || isUnder(rec.root, root))) continue
372    if (rec.status === 'running') {
373      if (!rec.dirtyDuringRun) records[key] = { ...rec, dirtyDuringRun: true }
374      changed = true
375    } else if ((rec.status === 'passed' || rec.status === 'failed') && !rec.staleAt) {
376      records[key] = { ...rec, staleAt: now, staleReason: reason, staleFiles: [] }
377      changed = true
378    }
379  }
380  return changed ? { ...ledger, records } : ledger
381}
382
383// A fingerprint scan of `root` found `sig`. Compares it with each finished record's end fingerprint.
384// `previous` is { hash, reason, files } describing what changed since the scan with that hash;
385// a record whose end fingerprint is some other scan gets the generic reason.
386export function applyScan(ledger, root, sig, now, previous) {
387  let changed = false
388  const records = {}
389  for (const [key, rec] of Object.entries(ledger.records)) {
390    records[key] = rec
391    if (rec.root !== root || !sig) continue
392    if ((rec.status !== 'passed' && rec.status !== 'failed') || rec.staleAt || !rec.endSig) continue
393    if (rec.endSig.partial || sig.partial) continue // a capped scan cannot prove a difference
394    if (rec.endSig.hash === sig.hash) continue
395    const known = previous && previous.hash === rec.endSig.hash
396    records[key] = {
397      ...rec,
398      staleAt: now,
399      staleReason: known && previous.reason ? previous.reason : 'Files changed after this check completed.',
400      staleFiles: known && previous.files ? previous.files.slice(0, MAX_STALE_FILES) : [],
401    }
402    changed = true
403  }
404  return changed ? { ...ledger, records } : ledger
405}
406
407// Records still marked Running after a reload or restart can no longer be completed.
408export function settleRunning(ledger) {
409  let changed = false
410  const records = {}
411  for (const [key, rec] of Object.entries(ledger.records)) {
412    if (rec.status === 'running') {
413      records[key] = { ...rec, status: 'unknown', reason: 'The session reloaded before this check reported a result.' }
414      changed = true
415    } else records[key] = rec
416  }
417  return changed ? { ...ledger, records } : ledger
418}
419
420export function activeRoots(ledger) {
421  const roots = new Set()
422  for (const rec of Object.values(ledger.records)) {
423    if (!rec.root) continue
424    if (rec.status === 'running' || ((rec.status === 'passed' || rec.status === 'failed') && !rec.staleAt)) roots.add(rec.root)
425  }
426  return [...roots]
427}
428
429// Text that goes into the prompt box when the user presses "Prepare checks".
430export function preparePrompt(items) {
431  const lines = items.map((i) => '- ' + i.command + (i.location ? ' (in ' + i.location + ')' : ''))
432  return (
433    'Please re-run these checks now and tell me the real results from the tool output, not from memory:\n' +
434    (lines.length ? lines.join('\n') : '- the project’s test, type-check, and build commands') +
435    '\nRun each one separately and wait for it to finish.'
436  )
437}
438
hooks/lib/view.js 139 lines
1// Builds the element trees for the status line and the Ship Check pane.
2// `els` is what `$.ui.resolve(e)` returned: { Box, Text, Button, ... }. No Claude Code API is called here.
3
4import { displayStatus, STATUS, ICONS, STATUS_COLORS, kindSummary, summaryLine } from './model.js'
5import { kindLabel, DEFAULT_KINDS } from './commands.js'
6import { displayLocation } from './paths.js'
7
8const OUTPUT_LINES = 14
9
10function statusText(Text, status, key) {
11  const props = { key, children: [ICONS[status] + ' ' + status] }
12  const color = STATUS_COLORS[status]
13  if (color) props.color = color
14  return Text(props)
15}
16
17// "Tests ✓ | Types ↻ Stale | Build ○ Not run"
18export function buildStrip(els, ledger, cwd, customKinds, onOpen) {
19  const { Box, Text, Button } = els
20  const items = kindSummary(ledger, cwd, customKinds)
21  const children = []
22  items.forEach((item, i) => {
23    if (i > 0) children.push(Text({ key: 'sep-' + i, dimColor: true, children: ['|'] }))
24    const color = STATUS_COLORS[item.status]
25    const mark = item.status === STATUS.PASSED ? ICONS[item.status] : ICONS[item.status] + ' ' + item.status
26    const parts = [Text({ key: 'l-' + item.kind, children: [item.label] })]
27    const markProps = { key: 'm-' + item.kind, children: [mark] }
28    if (color) markProps.color = color
29    parts.push(Text(markProps))
30    children.push(Box({ key: 'k-' + item.kind, flexDirection: 'row', columnGap: 1, children: parts }))
31  })
32  // A button, because some apps do not offer a command that a mod registers.
33  if (onOpen) children.push(Button({ key: 'open', label: 'Details', plain: true, onPress: onOpen }))
34  return Box({ flexDirection: 'row', columnGap: 1, children })
35}
36
37function outputBlock(els, record, key) {
38  const { Box, Text } = els
39  const lines = (record.summary || '').split('\n')
40  const shown = lines.slice(-OUTPUT_LINES)
41  const children = []
42  if (!record.summary) children.push(Text({ key: key + '-none', dimColor: true, children: ['No output was captured.'] }))
43  else {
44    if (lines.length > shown.length) children.push(Text({ key: key + '-cut', dimColor: true, children: ['… showing the last ' + shown.length + ' lines'] }))
45    shown.forEach((line, i) => children.push(Text({ key: key + '-o' + i, dimColor: true, wrap: 'truncate-end', children: [line === '' ? ' ' : line] })))
46  }
47  return Box({ key: key + '-out', flexDirection: 'column', paddingLeft: 2, children })
48}
49
50function recordRows(els, record, cwd, expanded, onToggle) {
51  const { Box, Text, Button } = els
52  const shown = displayStatus(record)
53  const key = record.key
54  const rows = []
55
56  const head = [Text({ key: key + '-label', bold: true, children: [kindLabel(record.kind)] }), statusText(Text, shown.status, key + '-st')]
57  const when = summaryLine(record)
58  if (when) head.push(Text({ key: key + '-when', dimColor: true, children: [when] }))
59  rows.push(Box({ key: key + '-head', flexDirection: 'row', columnGap: 2, children: head }))
60
61  const where = displayLocation(record.location, cwd) + (record.scope ? ' · ' + record.scope : '')
62  rows.push(Text({ key: key + '-cmd', dimColor: true, wrap: 'truncate-end', children: ['  ' + record.command + '  ·  ' + where] }))
63
64  if (shown.detail) rows.push(Text({ key: key + '-why', children: ['  ' + shown.detail] }))
65  if (shown.status === STATUS.STALE) {
66    const files = (record.staleFiles || []).slice(0, 3)
67    const tail = files.length ? ' Changed: ' + files.join(', ') + ((record.staleFiles || []).length > files.length ? ', …' : '') : ''
68    rows.push(Text({ key: key + '-last', dimColor: true, children: ['  Last result: ' + shown.last + '.' + tail] }))
69  }
70  if (record.filtered && (shown.status === STATUS.PASSED || shown.status === STATUS.FAILED || shown.status === STATUS.STALE)) {
71    rows.push(Text({ key: key + '-filter', dimColor: true, children: ['  This run passed extra arguments, so it may not cover everything.'] }))
72  }
73
74  if (record.summary || shown.status !== STATUS.RUNNING) {
75    const open = expanded === key
76    rows.push(
77      Button({
78        key: 'out-' + key,
79        label: open ? 'Hide output' : 'View output',
80        onPress: () => onToggle(key),
81      }),
82    )
83    if (open) rows.push(outputBlock(els, record, key))
84  }
85  return Box({ key: key + '-card', flexDirection: 'column', children: rows })
86}
87
88function notRunRow(els, kind) {
89  const { Box, Text } = els
90  return Box({
91    key: 'nr-' + kind,
92    flexDirection: 'row',
93    columnGap: 2,
94    children: [Text({ key: 'nr-l-' + kind, bold: true, children: [kindLabel(kind)] }), statusText(Text, STATUS.NOT_RUN, 'nr-s-' + kind)],
95  })
96}
97
98export function buildPane(els, { ledger, cwd, expanded, customKinds, onToggle, onPrepare }) {
99  const { Box, Text, Button } = els
100  const records = Object.values(ledger.records).sort(
101    (a, b) => a.location.localeCompare(b.location) || a.kind.localeCompare(b.kind) || a.scope.localeCompare(b.scope),
102  )
103  const children = [Text({ key: 'title', bold: true, children: ['Ship Check'] })]
104
105  if (records.length === 0) {
106    children.push(Text({ key: 'empty', children: ['No checks have run yet. Ask Claude to run your tests, type check, or build.'] }))
107  }
108
109  let lastLocation = null
110  for (const record of records) {
111    if (record.location !== lastLocation) {
112      children.push(Text({ key: 'loc-' + record.location, dimColor: true, children: [' '] }))
113      children.push(Text({ key: 'locn-' + record.location, dimColor: true, children: [displayLocation(record.location, cwd)] }))
114      lastLocation = record.location
115    }
116    children.push(recordRows(els, record, cwd, expanded, onToggle))
117  }
118
119  // Default kinds nobody has run near the current directory.
120  const kinds = [...DEFAULT_KINDS, ...(customKinds || []).filter((k) => !DEFAULT_KINDS.includes(k))]
121  const missing = kindSummary(ledger, cwd, customKinds).filter((s) => !s.record && kinds.includes(s.kind))
122  if (missing.length) {
123    children.push(Text({ key: 'nr-gap', dimColor: true, children: [' '] }))
124    for (const m of missing) children.push(notRunRow(els, m.kind))
125  }
126
127  children.push(Text({ key: 'gap-btn', children: [' '] }))
128  children.push(
129    Box({
130      key: 'actions',
131      flexDirection: 'row',
132      columnGap: 2,
133      children: [Button({ key: 'prepare', label: 'Prepare checks', hotkey: 'p', autoFocus: true, onPress: onPrepare })],
134    }),
135  )
136  children.push(Text({ key: 'note', dimColor: true, children: ['Results come only from what the tools reported, not from Claude’s summaries.'] }))
137  return Box({ flexDirection: 'column', children })
138}
139
types/index.d.ts 52 lines
1// The values Ship Check keeps in `$.state`, so they survive a hot reload.
2declare module 'claude-code' {
3  interface PluginState {
4    'ship-check': {
5      ledger: {
6        records: Record<string, ShipCheckRecord>
7        runs: ShipCheckRun[]
8      }
9      view: { expanded: string | null }
10    }
11  }
12
13  interface ShipCheckSig {
14    hash: string
15    count: number
16    partial: boolean
17  }
18
19  interface ShipCheckRecord {
20    key: string
21    kind: string
22    location: string
23    scope: string
24    root: string
25    command: string
26    together: number
27    filtered: boolean
28    status: 'running' | 'passed' | 'failed' | 'unknown'
29    startedAt: number
30    endedAt: number | null
31    exitCode: number | null
32    reason: string | null
33    summary: string
34    startSig: ShipCheckSig | null
35    endSig: ShipCheckSig | null
36    dirtyDuringRun: boolean
37    staleAt: number | null
38    staleReason: string | null
39    staleFiles: string[]
40  }
41
42  interface ShipCheckRun {
43    key: string
44    kind: string
45    location: string
46    command: string
47    status: string
48    startedAt: number
49    endedAt: number | null
50  }
51}
52