Turns build, test and lint output from tsc, cargo, go, pytest, jest, gradle and more into a live pane of errors by file, and hands Claude the diagnostics…

Build, test and lint results in a pane, and far fewer tokens spent reading logs.
buildpane is a Claude Code mod. When Claude runs a build, a test suite or a linter, buildpane reads the output, shows the errors grouped by file in a live pane, and hands Claude the diagnostics instead of the raw log. A 400-line cargo build becomes a dozen lines Claude can act on, and the pane keeps a running count of the tokens that saved.

It works by itself: there is nothing to run and no command to learn.
Needs Claude Code 2.1.295 or later.
/plugin marketplace add griches/buildpane
/plugin install buildpane@buildpane
Or from a shell:
claude plugin marketplace add griches/buildpane
claude plugin install buildpane@buildpane
Run /reload-plugins in a session that is already open.
| Toolchain | Commands | What is read |
|---|---|---|
| TypeScript | tsc, vue-tsc | Errors with file, line, column and TS code, plain or --pretty |
| ESLint | eslint | Errors and warnings with their rule |
| Rust | cargo build, check, clippy, test | Errors and warnings with their E code; failed tests with the panic message and place |
| Go | go build, vet, test | Compile errors; failed tests with their message |
| Python | pytest, mypy, pyright, ruff check | Failed tests; type and lint errors with their code |
| JavaScript tests | jest, vitest, bun test | Counts and failed tests with message and place |
| JVM | gradle, ./gradlew, mvn, javac, kotlinc | Kotlin and Java errors and warnings; failed tests |
| .NET | dotnet build, dotnet test | Errors with their CS code; failed tests |
| C and C++ | make, ninja, cmake --build, gcc, clang | Errors and warnings |
| Package scripts | npm test, npm run build, pnpm lint, yarn typecheck and the like | Whatever the script runs, from the list above |
Runners and wrappers are followed: npx tsc, pnpm exec vitest, uv run pytest, python -m pytest, cd app && cargo test 2>&1 | tail -40.
xcodebuild and swift build are left to xcpane, which reads Xcode's result bundle and says more than the log does. The two run side by side.
w shows the warnings, c clears. It opens when a run fails.
/buildpane opens the pane. /buildpane clear forgets the runs.
cargo build: BUILD FAILED in 2.7s (exit code 101)
1 error · 1 warning
src/lib.rs:7:5: error E0308: mismatched types
1 warning not listed: lib.rs (1). Call mcp__buildpane__details to list them.
Other lines that mention a failure:
error: could not compile `demo` (lib) due to 1 previous error; 1 warning emitted
[buildpane: summarised from 44 lines of output. Call mcp__buildpane__details with show "raw" for the output itself.]
Nothing is hidden for good. Claude can call the details tool for every warning, the failed tests, or the end of the raw output, and the transcript on your side always keeps the output as it was.
buildpane only replaces output it understood:
cat src/a.ts && tsc, keeps its whole output. The pane still shows the build.All under buildpane in /config.
| Setting | Default | What it does |
|---|---|---|
| Condense what Claude reads | on | Off: Claude reads the raw output and the pane still shows the run |
| Warnings Claude reads | count | list: every warning goes into the summary |
| Open the pane | failure | always: when a run starts. never: only on /buildpane |
| Compact transcript row | on | Off: the transcript shows the raw output |
Characters of output replaced, less the characters of the summary, divided by four. It is an estimate of tokens, not a bill.
buildpane is a mod: code that runs inside Claude Code. This is everything it does.
It watches Bash commands. It hooks the Bash tool, and when a command is a build, a test run or a lint run it lets the command run unchanged and then reads its output. It never changes a command and runs no command of its own. Other commands are passed on untouched.
It replaces what Claude reads of that output. It hooks the row Claude Code stores for the tool's result and, for a run it understood, swaps the raw output for the summary shown above. Your transcript keeps the raw output, and the details tool hands any of it back.
It reads one kind of file: when Claude Code has saved a long output to a file of its own, buildpane reads that file to see the whole output.
It makes no network requests and calls no model.
It keeps one number between sessions: the running total of characters saved, in Claude Code's own plugin storage.
It adds the /buildpane command, a pane, a status line, a toast, a compact transcript row, and one tool for the model, details, which lists stored results and runs nothing.
claude plugin validate .
claude plugin test .
claude --plugin-dir .
The fixtures in tests/fixtures.ts are real output from each tool where it was installed, and say so where they are not.
MIT. See LICENSE.
hooks/register.tsx 451 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Issue, Run } from '../types'
5import { findInvocations, mixedWith } from './detect'
6import { basename, byFile, condense, details, location, plural, seconds, tally, saving, verdict } from './format'
7import type { Detail } from './format'
8import { parse } from './parse'
9
10const PANE = 'buildpane'
11const TITLE = 'Build'
12const COMMAND = 'buildpane'
13const DETAILS_TOOL = 'mcp__buildpane__details'
14const SAVED_KEY = 'savedChars'
15const KEPT_RUNS = 20
16const KEPT_ISSUES = 300
17const KEPT_RAW = 5
18const RAW_CHARS = 40_000
19const PANE_ISSUES = 60
20const ROW_ERRORS = 3
21/** A summary has to be this much shorter than the output before it replaces it. */
22const WORTH_IT = 0.7
23
24type AutoOpen = 'always' | 'failure' | 'never'
25
26const runs = atom({ plugin: 'buildpane', key: 'runs' } as const, [])
27const isShowingWarnings = atom({ plugin: 'buildpane', key: 'isShowingWarnings' } as const, false)
28const now = atom({ plugin: 'buildpane', key: 'now' } as const, 0)
29const savedChars = atom({ plugin: 'buildpane', key: 'savedChars' } as const, 0)
30const savedBefore = atom({ plugin: 'buildpane', key: 'savedBefore' } as const, 0)
31
32const GLYPH = { running: '●', succeeded: '✓', failed: '✗', cancelled: '◌' } as const
33const TONE = { running: 'warning', succeeded: 'success', failed: 'error', cancelled: undefined } as const
34
35const isError = (issue: Issue) => issue.severity === 'error'
36
37const openPane = ($: EngineInterface) => {
38 void $.ui.open({ id: PANE, title: TITLE }).catch(() => undefined)
39}
40
41const store = ($: EngineInterface, run: Run) =>
42 update($, runs, list => [...list.filter(one => one.id !== run.id), run].slice(-KEPT_RUNS))
43
44const drop = ($: EngineInterface, id: string) => update($, runs, list => list.filter(one => one.id !== id))
45
46/** Moves the pane's clock on, so a running timer redraws. */
47const tick = async ($: EngineInterface) => {
48 const at = await $.clock.now()
49 await update($, now, () => at)
50}
51
52/** Shows `running` in the pane with a ticking timer for as long as `work` takes. */
53async function track<T>($: EngineInterface, running: Run, autoOpen: AutoOpen, work: () => Promise<T>): Promise<T> {
54 await update($, now, () => running.startedAt)
55 await store($, running)
56
57 if (autoOpen === 'always') {
58 openPane($)
59 }
60
61 const ticker = $.clock.every(1000, () => {
62 void tick($).catch(() => undefined)
63 })
64
65 try {
66 return await work()
67 } catch (error) {
68 await drop($, running.id)
69 throw error
70 } finally {
71 ticker.cancel()
72 }
73}
74
75/** Stores a finished run and says how it went: the status line on a failure, a toast on a success. */
76const announce = async ($: EngineInterface, finished: Run, autoOpen: AutoOpen) => {
77 await store($, finished)
78
79 if (finished.status === 'failed') {
80 $.ui.status(finished.hasFindings ? `${GLYPH.failed} ${finished.label}: ${tally(finished)}` : `${GLYPH.failed} ${finished.label} failed`)
81
82 if (autoOpen === 'failure') {
83 openPane($)
84 }
85 } else {
86 $.ui.status(undefined)
87 }
88
89 if (finished.status === 'succeeded' && finished.hasFindings) {
90 $.ui.toast(`${GLYPH.succeeded} ${finished.label} · ${tally(finished)} · ${seconds(finished.durationMs ?? 0)}`)
91 }
92}
93
94const sorted = (issues: readonly Issue[]) => [...issues.filter(isError), ...issues.filter(one => !isError(one))].slice(0, KEPT_ISSUES)
95
96const textLength = (content: unknown): number => {
97 if (typeof content === 'string') {
98 return content.length
99 }
100
101 return Array.isArray(content)
102 ? content.reduce((total: number, block: { text?: unknown }) => total + (typeof block.text === 'string' ? block.text.length : 0), 0)
103 : 0
104}
105
106export const register: Register = (on, options) => {
107 const wantsCondense = options.condense !== false
108 const wantsCompactRow = options.compactRow !== false
109 const warnings = options.warnings === 'list' ? 'list' : 'count'
110 const autoOpen: AutoOpen = options.autoOpen === 'always' || options.autoOpen === 'never' ? options.autoOpen : 'failure'
111 const condensed = new Map<string, string>()
112 const raw = new Map<string, string>()
113
114 on('session.start', async ($, e, next) => {
115 await $.command.register({
116 name: COMMAND,
117 description: 'Show the build pane: errors by file, warnings, failed tests, tokens saved (clear: forget the runs)',
118 })
119 await $.tool.register({
120 name: 'details',
121 description:
122 'Lists what the most recent build, test run or lint run reported, in full: every error and warning with its file, line and message, and the failed tests. Call it when a summary counted warnings without listing them, or with show "raw" for the end of the output itself. It reads stored results and runs nothing.',
123 inputSchema: {
124 type: 'object',
125 properties: {
126 show: {
127 type: 'string',
128 enum: ['all', 'errors', 'warnings', 'tests', 'raw'],
129 description: 'Which part to list; all by default.',
130 },
131 },
132 },
133 })
134 const before = Number((await $.store.get(SAVED_KEY).catch(() => 0)) ?? 0)
135 await update($, savedBefore, () => (Number.isFinite(before) ? before : 0))
136
137 return next(e)
138 })
139
140 on('command.run', { command: COMMAND }, async ($, e) => {
141 if (e.args.trim() === 'clear') {
142 await update($, runs, () => [])
143 $.ui.status(undefined)
144
145 return { text: 'Build history cleared.' }
146 }
147
148 await $.ui.open({ id: PANE, title: TITLE })
149 const latest = (await read($, runs)).at(-1)
150 const saved = await read($, savedChars)
151 const told = saved === 0 ? '' : ` About ${saving(saved)} tokens saved this session.`
152
153 return {
154 text:
155 latest === undefined
156 ? `Build pane opened. No runs yet.${told}`
157 : `Build pane opened. Last: ${latest.label}: ${verdict(latest)} · ${tally(latest)}.${told}`,
158 }
159 })
160
161 on('tool.call', { tool: /^mcp__buildpane__details$/ }, async ($, e) => {
162 const latest = (await read($, runs)).findLast(one => one.status !== 'running')
163 const asked = (e as { show?: unknown }).show
164 const show: Detail = asked === 'errors' || asked === 'warnings' || asked === 'tests' || asked === 'raw' ? asked : 'all'
165
166 if (latest === undefined) {
167 return { result: 'No build, test run or lint run has finished in this session yet.' }
168 }
169
170 if (show === 'raw') {
171 return { result: raw.get(latest.id) ?? (latest.logPath === null ? 'The output is no longer kept.' : `Full output: ${latest.logPath}`) }
172 }
173
174 return { result: details(latest, show) }
175 })
176
177 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
178 const found = findInvocations(e.command)
179 const [invocation] = found
180
181 if (invocation === undefined || e.run_in_background === true) {
182 return next(e)
183 }
184
185 const id = e.tool_use_id
186 const startedAt = await $.clock.now()
187 const running: Run = {
188 id,
189 label: found.length === 1 ? invocation.label : found.map(one => one.label).join(' + '),
190 kind: found.some(one => one.kind === 'test') ? 'test' : invocation.kind,
191 status: 'running',
192 startedAt,
193 durationMs: null,
194 errorCount: 0,
195 warningCount: 0,
196 issues: [],
197 tests: null,
198 logPath: null,
199 logLines: 0,
200 hasFindings: false,
201 isCondensed: false,
202 savedChars: 0,
203 }
204 const ran = await track($, running, autoOpen, () => next(e))
205
206 if (ran.deny !== undefined) {
207 await drop($, id)
208
209 return ran
210 }
211
212 const shown = ran.text ?? ''
213 let output = shown
214 let logPath: string | null = null
215 let isStopped = false
216
217 if (ran.isError === true) {
218 output = typeof ran.result === 'string' ? ran.result : shown
219 } else {
220 const persisted = ran.result.persistedOutputPath
221 output = [ran.result.stdout, ran.result.stderr].filter(Boolean).join('\n')
222 isStopped = ran.result.interrupted || ran.result.backgroundTaskId !== undefined
223
224 if (persisted !== undefined) {
225 logPath = persisted
226 output = await $.fs.read(persisted).catch(() => output)
227 }
228 }
229
230 const report = parse(output, found.length === 1 ? invocation.tool : 'script')
231 const errorCount = report.issues.filter(isError).length
232 const failedTests = report.tests?.failed ?? 0
233 const hasFailed = ran.isError === true || report.verdict === 'failed' || errorCount > 0 || failedTests > 0
234 const hasFindings = report.issues.length > 0 || report.tests !== null || report.verdict !== null
235 const finished: Run = {
236 ...running,
237 status: isStopped ? 'cancelled' : hasFailed ? 'failed' : 'succeeded',
238 durationMs: (await $.clock.now()) - startedAt,
239 errorCount,
240 warningCount: report.issues.length - errorCount,
241 issues: sorted(report.issues),
242 tests: report.tests,
243 logPath,
244 logLines: report.lines,
245 hasFindings,
246 }
247 // A command line that failed without one line that speaks of a failure most likely failed
248 // before the build ran (a `cd` or a `sed` ahead of it): that is not a build to show.
249 if (hasFailed && !hasFindings && report.tail.length === 0 && !isStopped) {
250 await drop($, id)
251
252 return ran
253 }
254
255 // A failure nothing was read out of is left as it is: Claude needs the raw output to see why.
256 const isReadable = hasFindings && (finished.status === 'succeeded' || errorCount > 0 || failedTests > 0)
257
258 raw.set(id, output.slice(-RAW_CHARS))
259
260 for (const old of [...raw.keys()].slice(0, -KEPT_RAW)) {
261 raw.delete(old)
262 }
263
264 // A line that also prints something else (a file, a listing) keeps its output: only the build's part could be summed up.
265 const isMixed = mixedWith(e.command).length > 0
266
267 if (wantsCondense && isReadable && !isStopped && !isMixed && ran.text !== undefined) {
268 const exit = /^Exit code (\d+)/.exec(shown)
269 const summary = condense(finished, {
270 warnings,
271 exitCode: exit === null ? null : Number(exit[1]),
272 tail: report.tail,
273 detailsTool: DETAILS_TOOL,
274 })
275
276 if (summary.length < ran.text.length * WORTH_IT) {
277 condensed.set(id, summary)
278 finished.isCondensed = true
279 finished.savedChars = ran.text.length - summary.length
280 }
281 }
282
283 await announce($, finished, autoOpen)
284
285 return ran
286 })
287
288 on('session.append', { door: 'tool-result' }, async ($, e, next) => {
289 let saved = 0
290 const content = e.message.content.map(block => {
291 const id = typeof block.tool_use_id === 'string' ? block.tool_use_id : ''
292 const summary = block.type === 'tool_result' ? condensed.get(id) : undefined
293
294 if (summary === undefined) {
295 return block
296 }
297
298 condensed.delete(id)
299 saved += Math.max(0, textLength(block.content) - summary.length)
300
301 return { ...block, content: typeof block.content === 'string' ? summary : [{ type: 'text', text: summary }] }
302 })
303
304 if (saved === 0) {
305 return next(e)
306 }
307
308 const stored = await next({ ...e, message: { ...e.message, content } })
309 await update($, savedChars, total => total + saved)
310 const before = Number((await $.store.get(SAVED_KEY).catch(() => 0)) ?? 0)
311 await $.store.set(SAVED_KEY, (Number.isFinite(before) ? before : 0) + saved).catch(() => undefined)
312
313 return stored
314 })
315
316 on('ui.render', { component: 'ToolResult', props: { tool: 'Bash' } }, async ($, e, next) => {
317 const run = wantsCompactRow ? (await read($, runs)).find(one => one.id === e.requestId) : undefined
318
319 if (run === undefined || run.status === 'running' || !run.isCondensed) {
320 return next(e)
321 }
322
323 const { Box, Text } = $.ui.resolve(e)
324 const errors = run.issues.filter(isError)
325 const failures = run.tests?.failures ?? []
326 const facts = [tally(run), seconds(run.durationMs ?? 0), `${plural(run.logLines, 'line')} folded`]
327
328 return (
329 <Box flexDirection="column">
330 <Box flexDirection="row">
331 <Text dimColor>{' ⎿ '}</Text>
332 <Text bold color={TONE[run.status]}>{`${GLYPH[run.status]} ${verdict(run)}`}</Text>
333 <Text dimColor>{` · ${facts.join(' · ')}`}</Text>
334 </Box>
335 {errors.slice(0, ROW_ERRORS).map(issue => (
336 <Text wrap="truncate-end">
337 {` ${[issue.file === null ? null : basename(issue.file), location(issue)].filter(Boolean).join(':')} ${issue.message}`}
338 </Text>
339 ))}
340 {errors.length === 0 &&
341 failures.slice(0, ROW_ERRORS).map(failure => <Text wrap="truncate-end">{` ${failure.name} ${failure.message}`}</Text>)}
342 {errors.length > ROW_ERRORS && (
343 <Text dimColor>{` +${plural(run.errorCount - ROW_ERRORS, 'more error')} · /${COMMAND} shows them all`}</Text>
344 )}
345 </Box>
346 )
347 })
348
349 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
350 const { Box, Button, Text } = $.ui.resolve(e)
351 const list = await read($, runs)
352 const latest = list.at(-1)
353 const saved = await read($, savedChars)
354 const lifetime = (await read($, savedBefore)) + saved
355 const savings =
356 lifetime === 0 ? null : `Saved about ${saving(saved)} tokens this session · ${saving(lifetime)} in all`
357
358 if (latest === undefined) {
359 return (
360 <Box flexDirection="column">
361 <Text dimColor>No runs yet.</Text>
362 <Text dimColor>Ask Claude to build, test or lint: tsc, cargo, go, pytest, jest, gradle and more.</Text>
363 {savings !== null && <Text dimColor>{savings}</Text>}
364 </Box>
365 )
366 }
367
368 const showsWarnings = await read($, isShowingWarnings)
369 const elapsed = latest.status === 'running' ? Math.max(0, (await read($, now)) - latest.startedAt) : (latest.durationMs ?? 0)
370 const visible = latest.issues.filter(one => showsWarnings || isError(one))
371 const groups = byFile(visible.slice(0, PANE_ISSUES))
372 const failures = latest.tests?.failures ?? []
373 const earlier = list.slice(0, -1).slice(-5).reverse()
374 const hasNothingToShow = latest.status === 'failed' && latest.errorCount === 0 && failures.length === 0
375
376 return (
377 <Box flexDirection="column">
378 <Box flexDirection="row">
379 <Text bold color={TONE[latest.status]}>{`${GLYPH[latest.status]} ${verdict(latest)}`}</Text>
380 <Text dimColor>{` ${seconds(elapsed)}`}</Text>
381 </Box>
382 <Text dimColor>{latest.label}</Text>
383 {latest.status !== 'running' && <Text>{tally(latest)}</Text>}
384 {groups.map(group => (
385 <Box flexDirection="column" marginTop={1}>
386 <Box flexDirection="row">
387 <Text bold>{group.file === null ? 'Project' : basename(group.file)}</Text>
388 <Text dimColor>{` ${plural(group.issues.length, 'issue')}`}</Text>
389 </Box>
390 {group.issues.map(issue => (
391 <Box flexDirection="row">
392 <Box width={9} flexShrink={0}>
393 <Text color={isError(issue) ? 'error' : 'warning'}>{` ${location(issue) || (isError(issue) ? 'error' : 'warn')}`}</Text>
394 </Box>
395 <Box flexGrow={1} flexShrink={1}>
396 <Text>{issue.code === null ? issue.message : `${issue.message} (${issue.code})`}</Text>
397 </Box>
398 </Box>
399 ))}
400 </Box>
401 ))}
402 {visible.length > PANE_ISSUES && <Text dimColor>{`+${plural(visible.length - PANE_ISSUES, 'more issue')}`}</Text>}
403 {failures.length > 0 && (
404 <Box flexDirection="column" marginTop={1}>
405 <Text bold>Failed tests</Text>
406 {failures.slice(0, PANE_ISSUES).map(failure => (
407 <Box flexDirection="column">
408 <Text color="error">{` ${GLYPH.failed} ${failure.name}`}</Text>
409 {failure.message !== '' && <Text>{` ${failure.message}`}</Text>}
410 </Box>
411 ))}
412 </Box>
413 )}
414 {hasNothingToShow && (
415 <Box flexDirection="column" marginTop={1}>
416 <Text dimColor>No diagnostics found in the output; Claude read it as it was.</Text>
417 </Box>
418 )}
419 <Box flexDirection="row" gap={2} marginTop={1}>
420 {latest.warningCount > 0 && (
421 <Button
422 key="warnings"
423 hotkey="w"
424 plain
425 label={showsWarnings ? 'Hide warnings' : `Show ${plural(latest.warningCount, 'warning')}`}
426 onPress={() => update($, isShowingWarnings, shows => !shows)}
427 />
428 )}
429 <Button key="clear" hotkey="c" plain label="Clear" onPress={() => update($, runs, () => [])} />
430 </Box>
431 {earlier.length > 0 && (
432 <Box flexDirection="column" marginTop={1}>
433 <Text dimColor>Earlier</Text>
434 {earlier.map(run => (
435 <Box flexDirection="row">
436 <Text color={TONE[run.status]}>{` ${GLYPH[run.status]} `}</Text>
437 <Text dimColor>{`${run.label} · ${tally(run)} · ${seconds(run.durationMs ?? 0)}`}</Text>
438 </Box>
439 ))}
440 </Box>
441 )}
442 {savings !== null && (
443 <Box marginTop={1}>
444 <Text dimColor>{savings}</Text>
445 </Box>
446 )}
447 </Box>
448 )
449 })
450}
451hooks/detect.ts 220 lines1import type { Run } from '../types'
2import { commands } from './shell'
3
4export type Invocation = {
5 /** Which toolchain's output to expect: decides the rules only that toolchain needs. */
6 tool: string
7 label: string
8 kind: Run['kind']
9}
10
11const SCRIPT = /(^|[:_.-])(build|tests?|lint|check|typecheck|type-check|types|tsc|compile|verify|ci)($|[:_.-])/
12const TEST_SCRIPT = /(^|[:_.-])tests?($|[:_.-])/
13const LINT_SCRIPT = /(^|[:_.-])(lint|check|typecheck|type-check|types|tsc)($|[:_.-])/
14const PASSES_ON = new Set(['npx', 'bunx', 'pnpx', 'uvx'])
15const RUNS_REST = new Map([
16 ['pnpm', new Set(['exec', 'dlx'])],
17 ['yarn', new Set(['exec', 'dlx'])],
18 ['bun', new Set(['x'])],
19 ['uv', new Set(['run'])],
20 ['poetry', new Set(['run'])],
21 ['pipenv', new Set(['run'])],
22 ['hatch', new Set(['run'])],
23 ['bundle', new Set(['exec'])],
24 ['dotenv', new Set(['--'])],
25])
26const PACKAGE_MANAGERS = new Set(['npm', 'pnpm', 'yarn', 'bun'])
27const OFF_FLAGS = new Set(['--help', '-h', '--version', '-V', '--watch', '-w', '--init', '--showConfig', '--listFiles'])
28const CARGO = new Map<string, Run['kind']>([
29 ['build', 'build'],
30 ['b', 'build'],
31 ['check', 'build'],
32 ['c', 'build'],
33 ['clippy', 'lint'],
34 ['test', 'test'],
35 ['t', 'test'],
36 ['nextest', 'test'],
37 ['bench', 'test'],
38 ['doc', 'build'],
39])
40const GO = new Map<string, Run['kind']>([
41 ['build', 'build'],
42 ['vet', 'lint'],
43 ['test', 'test'],
44 ['install', 'build'],
45])
46const DOTNET = new Map<string, Run['kind']>([
47 ['build', 'build'],
48 ['test', 'test'],
49 ['publish', 'build'],
50])
51const SINGLE = new Map<string, { tool: string; kind: Run['kind'] }>([
52 ['tsc', { tool: 'tsc', kind: 'build' }],
53 ['vue-tsc', { tool: 'tsc', kind: 'build' }],
54 ['tsgo', { tool: 'tsc', kind: 'build' }],
55 ['eslint', { tool: 'eslint', kind: 'lint' }],
56 ['pytest', { tool: 'pytest', kind: 'test' }],
57 ['py.test', { tool: 'pytest', kind: 'test' }],
58 ['tox', { tool: 'pytest', kind: 'test' }],
59 ['nox', { tool: 'pytest', kind: 'test' }],
60 ['jest', { tool: 'jest', kind: 'test' }],
61 ['vitest', { tool: 'vitest', kind: 'test' }],
62 ['mypy', { tool: 'mypy', kind: 'lint' }],
63 ['pyright', { tool: 'pyright', kind: 'lint' }],
64 ['basedpyright', { tool: 'pyright', kind: 'lint' }],
65 ['gradle', { tool: 'gradle', kind: 'build' }],
66 ['gradlew', { tool: 'gradle', kind: 'build' }],
67 ['mvn', { tool: 'maven', kind: 'build' }],
68 ['mvnw', { tool: 'maven', kind: 'build' }],
69 ['make', { tool: 'make', kind: 'build' }],
70 ['gmake', { tool: 'make', kind: 'build' }],
71 ['ninja', { tool: 'make', kind: 'build' }],
72 ['gcc', { tool: 'make', kind: 'build' }],
73 ['g++', { tool: 'make', kind: 'build' }],
74 ['cc', { tool: 'make', kind: 'build' }],
75 ['clang', { tool: 'make', kind: 'build' }],
76 ['clang++', { tool: 'make', kind: 'build' }],
77 ['rustc', { tool: 'cargo', kind: 'build' }],
78 ['javac', { tool: 'gradle', kind: 'build' }],
79 ['kotlinc', { tool: 'gradle', kind: 'build' }],
80])
81const GRADLE_OFF = new Set(['tasks', 'help', 'projects', 'properties', 'dependencies', 'wrapper', 'init', '--stop', '--status'])
82const TEST_TASK = /(^|:)(test|check|connected\w*Test|\w+Test)$/i
83
84const words = (args: readonly string[]) => args.filter(one => !one.startsWith('-'))
85
86const script = (manager: string, name: string): Invocation | null =>
87 SCRIPT.test(name)
88 ? {
89 tool: 'script',
90 label: `${manager} ${name === 'test' ? 'test' : `run ${name}`}`,
91 kind: TEST_SCRIPT.test(name) ? 'test' : LINT_SCRIPT.test(name) ? 'lint' : 'build',
92 }
93 : null
94
95const packageManager = (name: string, args: readonly string[]): Invocation | null => {
96 const [verb, target] = words(args)
97
98 if (verb === undefined) {
99 return null
100 }
101
102 if (verb === 'test' || verb === 't') {
103 return name === 'bun' ? { tool: 'bun', label: 'bun test', kind: 'test' } : script(name, 'test')
104 }
105
106 if (verb === 'run' || verb === 'run-script') {
107 return target === undefined ? null : script(name, target)
108 }
109
110 // pnpm, yarn and bun run a script named as the verb; npm does not.
111 return name === 'npm' ? null : script(name, verb)
112}
113
114const resolve = (name: string, args: readonly string[], depth = 0): Invocation | null => {
115 if (depth > 3 || args.some(one => OFF_FLAGS.has(one))) {
116 return null
117 }
118
119 const [verb] = words(args)
120 const rest = () => {
121 const at = verb === undefined ? -1 : args.indexOf(verb)
122 const next = args.slice(at + 1)
123 const [head] = words(next)
124
125 return head === undefined ? null : resolve(basename(head), next.slice(next.indexOf(head) + 1), depth + 1)
126 }
127
128 if (PASSES_ON.has(name)) {
129 return verb === undefined ? null : resolve(basename(verb), args.slice(args.indexOf(verb) + 1), depth + 1)
130 }
131
132 if (verb !== undefined && RUNS_REST.get(name)?.has(verb) === true) {
133 return rest()
134 }
135
136 if (/^python[\d.]*$/.test(name) && args[0] === '-m' && args[1] !== undefined) {
137 return resolve(args[1], args.slice(2), depth + 1)
138 }
139
140 if (PACKAGE_MANAGERS.has(name)) {
141 return packageManager(name, args)
142 }
143
144 if (name === 'cargo') {
145 // `cargo +nightly test`: the toolchain comes before the verb.
146 const kind = CARGO.get((verb?.startsWith('+') === true ? words(args)[1] : verb) ?? '')
147
148 return kind === undefined ? null : { tool: 'cargo', label: `cargo ${words(args).filter(one => !one.startsWith('+'))[0]}`, kind }
149 }
150
151 if (name === 'go') {
152 const kind = GO.get(verb ?? '')
153
154 return kind === undefined ? null : { tool: 'go', label: `go ${verb}`, kind }
155 }
156
157 if (name === 'dotnet') {
158 const kind = DOTNET.get(verb ?? '')
159
160 return kind === undefined ? null : { tool: 'dotnet', label: `dotnet ${verb}`, kind }
161 }
162
163 if (name === 'ruff') {
164 return verb === 'check' ? { tool: 'ruff', label: 'ruff check', kind: 'lint' } : null
165 }
166
167 if (name === 'cmake') {
168 return args.includes('--build') ? { tool: 'make', label: 'cmake --build', kind: 'build' } : null
169 }
170
171 const single = SINGLE.get(name)
172
173 if (single === undefined) {
174 return null
175 }
176
177 if (single.tool === 'gradle' || single.tool === 'maven') {
178 const tasks = words(args)
179
180 if (tasks.length === 0 || tasks.some(one => GRADLE_OFF.has(one)) || args.some(one => GRADLE_OFF.has(one))) {
181 return null
182 }
183
184 const hasTests = tasks.some(one => TEST_TASK.test(one) || one === 'verify')
185
186 return { tool: single.tool, label: `${name} ${tasks.slice(0, 2).join(' ')}`, kind: hasTests ? 'test' : 'build' }
187 }
188
189 return { ...single, label: name }
190}
191
192const basename = (path: string) => path.slice(path.lastIndexOf('/') + 1)
193
194/**
195 * The builds, test runs and lint runs a Bash command starts, in order.
196 *
197 * `xcodebuild` and `swift build` are left out on purpose: xcpane reads those
198 * from Xcode's result bundle, which says more than the log does.
199 */
200export const findInvocations = (command: string): Invocation[] =>
201 commands(command)
202 .map(one => resolve(one.name, one.args))
203 .filter(one => one !== null)
204
205/** Commands that print nothing of their own, or only pass on what the build printed. */
206const PASSIVE = new Set([
207 'cd', 'pushd', 'popd', 'export', 'unset', 'set', 'source', '.', 'true', ':', 'mkdir', 'touch', 'rm', 'sleep', 'wait',
208 'tail', 'head', 'grep', 'egrep', 'rg', 'tee', 'sort', 'uniq', 'cut', 'awk', 'wc', 'tr', 'less', 'more', 'column',
209])
210
211/**
212 * The other commands of a line whose own output Claude may be after: `cat src/a.ts && tsc`
213 * prints a file as well as a build, so that line's output is not replaced by a summary.
214 */
215export const mixedWith = (command: string): string[] =>
216 commands(command)
217 .filter(one => resolve(one.name, one.args) === null)
218 .filter(one => !PASSIVE.has(one.name) && !((one.name === 'cat' || one.name === 'sed') && one.args.every(arg => arg.startsWith('-') || one.name === 'sed')))
219 .map(one => one.name)
220hooks/format.ts 164 lines1import type { Issue, Run } from '../types'
2
3export type FileGroup = { file: string | null; issues: Issue[] }
4
5export type CondenseSettings = {
6 warnings: 'count' | 'list'
7 exitCode: number | null
8 /** Lines that mention a failure the rules did not read, added to a failed run's summary so none is lost. */
9 tail: readonly string[]
10 /** The tool the model can call for what the summary leaves out. */
11 detailsTool: string
12}
13
14export type Detail = 'all' | 'errors' | 'warnings' | 'tests' | 'raw'
15
16const LISTED = 50
17const FEW = 5
18const CHARS_EACH = 4
19
20export const plural = (count: number, word: string) => `${count} ${word}${count === 1 ? '' : 's'}`
21
22export const basename = (path: string) => path.slice(Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + 1)
23
24export const seconds = (ms: number) => {
25 if (ms < 10_000) {
26 return `${(ms / 1000).toFixed(1)}s`
27 }
28
29 const whole = Math.round(ms / 1000)
30
31 return whole < 60 ? `${whole}s` : `${Math.floor(whole / 60)}m ${whole % 60}s`
32}
33
34/** `about 12.4k tokens`: a count of characters as the tokens they roughly come to. */
35export const saving = (chars: number) => {
36 const count = Math.round(chars / CHARS_EACH)
37
38 if (count < 1000) {
39 return `${count}`
40 }
41
42 return count < 1_000_000 ? `${(count / 1000).toFixed(1)}k` : `${(count / 1_000_000).toFixed(2)}M`
43}
44
45const NOUN = { build: 'BUILD', test: 'TEST', lint: 'CHECK' } as const
46
47/** `BUILD FAILED`, `TEST SUCCEEDED`, `CHECK FAILED`. */
48export const verdict = (run: Run) => `${NOUN[run.kind]} ${run.status.toUpperCase()}`
49
50/** `1 error · 3 warnings · 12 tests, 1 failed`. */
51export const tally = (run: Run) => {
52 const parts = [plural(run.errorCount, 'error'), plural(run.warningCount, 'warning')]
53
54 if (run.tests !== null) {
55 parts.push(`${plural(run.tests.total, 'test')}, ${run.tests.failed} failed`)
56 }
57
58 return parts.join(' · ')
59}
60
61export const location = (issue: Pick<Issue, 'line' | 'column'>) =>
62 issue.line === null ? '' : issue.column === null ? `${issue.line}` : `${issue.line}:${issue.column}`
63
64/** Issues grouped by file in the order the files first appear, errors before warnings within one. */
65export const byFile = (issues: readonly Issue[]): FileGroup[] => {
66 const groups = new Map<string | null, Issue[]>()
67
68 for (const issue of issues) {
69 groups.set(issue.file, [...(groups.get(issue.file) ?? []), issue])
70 }
71
72 return [...groups].map(([file, list]) => ({
73 file,
74 issues: [...list.filter(one => one.severity === 'error'), ...list.filter(one => one.severity === 'warning')],
75 }))
76}
77
78const diagnostic = (issue: Issue) => {
79 const where = [issue.file, location(issue)].filter(Boolean).join(':')
80 const kind = issue.code === null ? issue.severity : `${issue.severity} ${issue.code}`
81
82 return where === '' ? `${kind}: ${issue.message}` : `${where}: ${kind}: ${issue.message}`
83}
84
85const listed = (lines: readonly string[], total: number, word: string) =>
86 total > lines.length ? [...lines, `(+${plural(total - lines.length, `more ${word}`)})`] : [...lines]
87
88const failedTests = (run: Run, limit: number) =>
89 (run.tests?.failures ?? []).slice(0, limit).map(failure => {
90 const where = failure.file === null ? '' : ` (${[failure.file, failure.line].filter(one => one !== null).join(':')})`
91
92 return failure.message === '' ? ` ${failure.name}${where}` : ` ${failure.name}: ${failure.message}${where}`
93 })
94
95/**
96 * What Claude reads in place of the raw output: the verdict, every error with
97 * its place, the failed tests, and the warnings counted or listed.
98 */
99export const condense = (run: Run, settings: CondenseSettings): string => {
100 const errors = run.issues.filter(one => one.severity === 'error')
101 const warnings = run.issues.filter(one => one.severity === 'warning')
102 const took = run.durationMs === null ? '' : ` in ${seconds(run.durationMs)}`
103 const exit = settings.exitCode === null ? '' : ` (exit code ${settings.exitCode})`
104 const blocks: string[][] = [[`${run.label}: ${verdict(run)}${took}${exit}`, tally(run)]]
105
106 if (errors.length > 0) {
107 blocks.push(listed(errors.slice(0, LISTED).map(diagnostic), run.errorCount, 'error'))
108 }
109
110 if (run.tests !== null && run.tests.failures.length > 0) {
111 blocks.push(['Failed tests:', ...listed(failedTests(run, LISTED), run.tests.failed, 'failed test')])
112 }
113
114 // A few warnings are cheaper to list than to make Claude ask for.
115 if (run.warningCount > 0 && (settings.warnings === 'list' || run.warningCount <= FEW)) {
116 blocks.push(listed(warnings.slice(0, LISTED).map(diagnostic), run.warningCount, 'warning'))
117 } else if (run.warningCount > 0) {
118 const files = byFile(warnings)
119 .slice(0, 8)
120 .map(group => `${group.file === null ? 'no file' : basename(group.file)} (${group.issues.length})`)
121 blocks.push([`${plural(run.warningCount, 'warning')} not listed: ${files.join(', ')}. Call ${settings.detailsTool} to list them.`])
122 }
123
124 if (run.status === 'failed' && settings.tail.length > 0) {
125 blocks.push(['Other lines that mention a failure:', ...settings.tail.map(line => ` ${line}`)])
126 }
127
128 const whole =
129 run.logPath === null
130 ? `Call ${settings.detailsTool} with show "raw" for the output itself.`
131 : `Full output: ${run.logPath}`
132 blocks.push([`[buildpane: summarised from ${plural(run.logLines, 'line')} of output. ${whole}]`])
133
134 return blocks.map(block => block.join('\n')).join('\n\n')
135}
136
137/** A run in full, for the details tool: every stored error and warning with its place, and the failed tests. */
138export const details = (run: Run, show: Exclude<Detail, 'raw'>): string => {
139 const wants = (one: Detail) => show === 'all' || show === one
140 const of = (severity: Issue['severity']) => run.issues.filter(one => one.severity === severity).map(diagnostic)
141 const took = run.durationMs === null ? '' : ` in ${seconds(run.durationMs)}`
142 const blocks: string[][] = [[`${run.label}: ${verdict(run)}${took}`, tally(run)]]
143
144 if (wants('errors')) {
145 blocks.push(run.errorCount === 0 ? ['No errors.'] : listed(of('error'), run.errorCount, 'error'))
146 }
147
148 if (wants('warnings')) {
149 blocks.push(run.warningCount === 0 ? ['No warnings.'] : listed(of('warning'), run.warningCount, 'warning'))
150 }
151
152 if (wants('tests') && run.tests !== null) {
153 const { tests } = run
154 blocks.push([
155 `${plural(tests.total, 'test')}: ${tests.passed} passed, ${tests.failed} failed, ${tests.skipped} skipped`,
156 ...listed(failedTests(run, tests.failures.length), tests.failed, 'failed test'),
157 ])
158 } else if (show === 'tests') {
159 blocks.push(['This run reported no tests.'])
160 }
161
162 return blocks.map(block => block.join('\n')).join('\n\n')
163}
164hooks/parse.ts 464 lines1import type { Issue, TestFailure, Tests } from '../types'
2
3export type Report = {
4 issues: Issue[]
5 tests: Tests | null
6 /** What the tool itself said of the run, when it said so in words. */
7 verdict: 'passed' | 'failed' | null
8 lines: number
9 /** The last lines that speak of a failure without being one the rules above read. */
10 tail: string[]
11}
12
13const ANSI = /\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-_]/g
14const PATH = `((?:[A-Za-z]:)?[^\\s:()'"]+\\.[A-Za-z0-9]+)`
15const LINE_WIDTH = 2000
16const TAIL_LINES = 6
17const TAIL_WIDTH = 240
18const FAILURE_WORDS = /\b(error|fatal|fail(ed|ure)?|exception|panicked|undefined (reference|symbols?)|\w+Error)\b/i
19
20// `src/a.ts(12,5): error TS2322: ...` (tsc, MSBuild, C#)
21const PARENS = new RegExp(`^\\s*${PATH}\\((\\d+),(\\d+)\\): (error|warning) ([A-Za-z]+\\d+): (.+)$`)
22// `src/a.ts:12:5 - error TS2322: ...` (tsc --pretty, pyright)
23const DASHED = new RegExp(`^\\s*${PATH}:(\\d+):(\\d+) - (error|warning)(?: ([A-Za-z]+\\d+))?: (.+)$`)
24// `src/a.c:12:5: error: ...` (gcc, clang, swiftc, javac, mypy)
25const COLONS = new RegExp(`^\\s*${PATH}:(\\d+)(?::(\\d+))?: (?:fatal )?(error|warning): (.+)$`)
26// `e: file:///src/A.kt:12:5 ...` and the older `e: /src/A.kt: (12, 5): ...` (kotlinc)
27const KOTLIN = new RegExp(`^(e|w): (?:file://)?${PATH}:(\\d+):(\\d+):? (.+)$`)
28const KOTLIN_OLD = new RegExp(`^(e|w): (?:file://)?${PATH}: \\((\\d+), (\\d+)\\): (.+)$`)
29// `error[E0308]: mismatched types` then ` --> src/main.rs:4:18` (rustc)
30const RUST = /^(error|warning)(?:\[([A-Z]\d+)\])?: (.+)$/
31const RUST_AT = new RegExp(`^\\s*--> ${PATH}:(\\d+):(\\d+)`)
32const RUST_NOISE =
33 /^(could not compile|aborting due to|test failed|build failed|process didn't exit|unused manifest key|.*\bgenerated \d+ warnings?\b|.*\bwarnings? emitted\b|failed to run custom build)/i
34// `./main.go:10:2: undefined: foo` (go build, go vet): no severity word.
35const GO = /^(?:vet: )?((?:\.{1,2}\/)?[^\s:]+\.go):(\d+):(\d+): (.+)$/
36// `src/a.py:12:5: E501 Line too long` (ruff, flake8)
37const RUFF = new RegExp(`^${PATH}:(\\d+):(\\d+): ([A-Z]+\\d+) (.+)$`)
38// eslint's default report: a path on a line of its own, then ` 12:5 error message rule`.
39const ESLINT_FILE = /^(?:[A-Za-z]:)?[^\s:]*[\\/][^\s:]*\.[A-Za-z0-9]+$/
40const ESLINT_ROW = /^\s+(\d+):(\d+)\s+(error|warning)\s+(.+?)(?:\s{2,}(\S+))?$/
41const PROJECT_SUFFIX = /\s\[[^\]]+proj\]$/
42
43const FAILED_MARKS = [
44 /^Found \d+ errors?\b/,
45 /^✖ \d+ problems? \((?!0 errors)/,
46 /^error: could not compile\b/,
47 /^BUILD FAIL(ED|URE)\b/,
48 /^Build FAILED\./,
49 /^FAIL\b(?!ED TESTS)/,
50 /^make(\[\d+\])?: \*\*\* /,
51 /^ninja: build stopped/,
52 /^error: test failed/,
53]
54const PASSED_MARKS = [
55 /^Success: no issues found/,
56 /^All checks passed!/,
57 /^\s*Finished [`'a-z]/,
58 /^BUILD SUCCESS(FUL)?\b/,
59 /^Build succeeded\./,
60 /^0 errors, 0 warnings/,
61]
62
63const number = (text: string | undefined) => (text === undefined || text === '' ? null : Number(text))
64
65const count = (text: string, word: RegExp) => {
66 const found = new RegExp(`(\\d+) ${word.source}`).exec(text)
67
68 return found === null ? 0 : Number(found[1])
69}
70
71const issuesOf = (lines: readonly string[], tool: string): Issue[] => {
72 const found: Issue[] = []
73 const seen = new Set<string>()
74 let eslintFile: string | null = null
75 const add = (issue: Issue) => {
76 const key = `${issue.severity}|${issue.file}|${issue.line}|${issue.column}|${issue.message}`
77
78 if (!seen.has(key)) {
79 seen.add(key)
80 found.push(issue)
81 }
82 }
83
84 lines.forEach((line, at) => {
85 let m: RegExpExecArray | null
86
87 if ((m = PARENS.exec(line)) !== null) {
88 add({
89 severity: m[4] === 'warning' ? 'warning' : 'error',
90 file: m[1] ?? null,
91 line: number(m[2]),
92 column: number(m[3]),
93 code: m[5] ?? null,
94 message: (m[6] ?? '').replace(PROJECT_SUFFIX, ''),
95 })
96 } else if ((m = DASHED.exec(line)) !== null) {
97 add({
98 severity: m[4] === 'warning' ? 'warning' : 'error',
99 file: m[1] ?? null,
100 line: number(m[2]),
101 column: number(m[3]),
102 code: m[5] ?? null,
103 message: m[6] ?? '',
104 })
105 } else if ((m = COLONS.exec(line)) !== null) {
106 const coded = /^(.*\S)\s+\[([a-z][a-z0-9-]*)\]$/.exec(m[5] ?? '')
107 add({
108 severity: m[4] === 'warning' ? 'warning' : 'error',
109 file: m[1] ?? null,
110 line: number(m[2]),
111 column: number(m[3]),
112 code: coded?.[2] ?? null,
113 message: coded?.[1] ?? m[5] ?? '',
114 })
115 } else if ((m = KOTLIN.exec(line) ?? KOTLIN_OLD.exec(line)) !== null) {
116 add({
117 severity: m[1] === 'w' ? 'warning' : 'error',
118 file: m[2] ?? null,
119 line: number(m[3]),
120 column: number(m[4]),
121 code: null,
122 message: m[5] ?? '',
123 })
124 } else if ((m = RUST.exec(line)) !== null) {
125 const where = lines.slice(at + 1, at + 7).map(one => RUST_AT.exec(one)).find(one => one !== null)
126 const isNoise = RUST_NOISE.test(m[3] ?? '')
127
128 if (where !== undefined && where !== null) {
129 add({
130 severity: m[1] === 'warning' ? 'warning' : 'error',
131 file: where[1] ?? null,
132 line: number(where[2]),
133 column: number(where[3]),
134 code: m[2] ?? null,
135 message: m[3] ?? '',
136 })
137 } else if (tool === 'cargo' && m[1] === 'error' && !isNoise) {
138 add({ severity: 'error', file: null, line: null, column: null, code: m[2] ?? null, message: m[3] ?? '' })
139 }
140 } else if ((m = RUFF.exec(line)) !== null) {
141 add({ severity: 'error', file: m[1] ?? null, line: number(m[2]), column: number(m[3]), code: m[4] ?? null, message: m[5] ?? '' })
142 } else if (tool === 'go' && (m = GO.exec(line)) !== null) {
143 add({ severity: 'error', file: m[1] ?? null, line: number(m[2]), column: number(m[3]), code: null, message: m[4] ?? '' })
144 } else if (eslintFile !== null && (m = ESLINT_ROW.exec(line)) !== null) {
145 add({
146 severity: m[3] === 'warning' ? 'warning' : 'error',
147 file: eslintFile,
148 line: number(m[1]),
149 column: number(m[2]),
150 code: m[5] ?? null,
151 message: m[4] ?? '',
152 })
153 } else if (ESLINT_FILE.test(line)) {
154 eslintFile = line
155 } else if (line.trim() === '') {
156 eslintFile = null
157 }
158 })
159
160 return found
161}
162
163/** The first line after `from` that says something, for a failure whose message follows its name. */
164const following = (lines: readonly string[], from: number, isEnd: (line: string) => boolean) => {
165 for (let i = from; i < Math.min(lines.length, from + 12); i += 1) {
166 const line = lines[i] ?? ''
167
168 if (isEnd(line)) {
169 return ''
170 }
171
172 if (line.trim() !== '') {
173 return line.trim()
174 }
175 }
176
177 return ''
178}
179
180const locate = (lines: readonly string[], from: number, pattern: RegExp, isEnd: (line: string) => boolean) => {
181 for (let i = from; i < Math.min(lines.length, from + 40); i += 1) {
182 const line = lines[i] ?? ''
183
184 if (i > from && isEnd(line)) {
185 break
186 }
187
188 const found = pattern.exec(line)
189
190 if (found !== null && !line.includes('node_modules')) {
191 return { file: found[1] ?? null, line: number(found[2]) }
192 }
193 }
194
195 return { file: null, line: null }
196}
197
198const pytest = (lines: readonly string[]): Tests | null => {
199 const summary = lines.findLast(line => /^=+ .*\b(passed|failed|errors?|skipped|no tests ran)\b.* in [\d.]+s/.test(line))
200
201 if (summary === undefined) {
202 return null
203 }
204
205 const failed = count(summary, /failed/) + count(summary, /errors?/)
206 const passed = count(summary, /passed/)
207 const skipped = count(summary, /skipped/) + count(summary, /deselected/) + count(summary, /xfailed/)
208 const places = lines.map(line => /^(\S+\.py):(\d+): (\w+)/.exec(line)).filter(one => one !== null)
209 const failures: TestFailure[] = lines
210 .map(line => /^(FAILED|ERROR) (\S+?)(?: - (.*))?$/.exec(line))
211 .filter(one => one !== null)
212 .map(found => {
213 const name = found[2] ?? ''
214 const file = name.split('::')[0] ?? null
215 const place = places.findIndex(one => one[1] === file)
216 const [where] = place < 0 ? [] : places.splice(place, 1)
217
218 return { name, message: found[3] ?? '', file, line: number(where?.[2]) }
219 })
220
221 return { total: passed + failed + skipped, passed, failed, skipped, failures }
222}
223
224const jest = (lines: readonly string[]): Tests | null => {
225 const summary = lines.findLast(line => /^Tests:\s+.*\d+ total/.test(line))
226
227 if (summary === undefined) {
228 return null
229 }
230
231 const isHeading = (line: string) => /^\s+● /.test(line)
232 const failures: TestFailure[] = []
233
234 lines.forEach((line, at) => {
235 const found = /^\s+● (.+ › .+)$/.exec(line)
236
237 if (found !== null && !failures.some(one => one.name === found[1])) {
238 failures.push({
239 name: found[1] ?? '',
240 message: following(lines, at + 1, isHeading),
241 ...locate(lines, at + 1, /\(?((?:[A-Za-z]:)?[^\s:()]+\.[cm]?[jt]sx?):(\d+):\d+\)?/, isHeading),
242 })
243 }
244 })
245
246 const failed = count(summary, /failed/)
247 const passed = count(summary, /passed/)
248 const total = count(summary, /total/)
249
250 return { total, passed, failed, skipped: Math.max(0, total - passed - failed), failures }
251}
252
253const vitest = (lines: readonly string[]): Tests | null => {
254 const summary = lines.findLast(line => /^\s*Tests\s+.*\(\d+\)\s*$/.test(line))
255
256 if (summary === undefined) {
257 return null
258 }
259
260 const isHeading = (line: string) => /^\s*(FAIL|×)\s/.test(line) || /^[⎯─-]{6,}/.test(line.trim())
261 const failures: TestFailure[] = []
262
263 lines.forEach((line, at) => {
264 const found = /^\s*FAIL\s+(\S+) > (.+)$/.exec(line)
265 const name = found === null ? '' : `${found[1]} > ${found[2]}`
266
267 if (found !== null && !failures.some(one => one.name === name)) {
268 const where = locate(lines, at + 1, /❯ ((?:[A-Za-z]:)?[^\s:()]+\.[cm]?[jt]sx?):(\d+):\d+/, isHeading)
269 failures.push({ name, message: following(lines, at + 1, isHeading), file: where.file ?? found[1] ?? null, line: where.line })
270 }
271 })
272
273 const failed = count(summary, /failed/)
274 const passed = count(summary, /passed/)
275 const total = Number(/\((\d+)\)\s*$/.exec(summary)?.[1] ?? passed + failed)
276
277 return { total, passed, failed, skipped: Math.max(0, total - passed - failed), failures }
278}
279
280const cargo = (lines: readonly string[]): Tests | null => {
281 const results = lines.map(line => /^test result: \w+\. (\d+) passed; (\d+) failed; (\d+) ignored/.exec(line)).filter(one => one !== null)
282
283 if (results.length === 0) {
284 return null
285 }
286
287 const sum = (group: number) => results.reduce((total, one) => total + Number(one[group]), 0)
288 const isHeading = (line: string) => /^---- .+ ----$/.test(line) || /^failures:/.test(line)
289 const failures: TestFailure[] = []
290
291 lines.forEach((line, at) => {
292 const found = /^---- (\S+) stdout ----$/.exec(line)
293
294 if (found !== null) {
295 const panic = lines.slice(at + 1, at + 12).findIndex(one => /panicked at /.test(one))
296 const where = panic < 0 ? null : /panicked at ([^\s:]+):(\d+):\d+/.exec(lines[at + 1 + panic] ?? '')
297 const told = panic < 0 ? following(lines, at + 1, isHeading) : following(lines, at + 2 + panic, isHeading)
298 failures.push({ name: found[1] ?? '', message: told, file: where?.[1] ?? null, line: number(where?.[2]) })
299 }
300 })
301
302 const passed = sum(1)
303 const failed = sum(2)
304 const skipped = sum(3)
305
306 return { total: passed + failed + skipped, passed, failed, skipped, failures }
307}
308
309const go = (lines: readonly string[]): Tests | null => {
310 const failures: TestFailure[] = []
311 let passed = 0
312 let skipped = 0
313
314 lines.forEach((line, at) => {
315 const found = /^\s*--- (FAIL|PASS|SKIP): (\S+) \(/.exec(line)
316
317 if (found === null) {
318 return
319 }
320
321 passed += found[1] === 'PASS' ? 1 : 0
322 skipped += found[1] === 'SKIP' ? 1 : 0
323
324 if (found[1] === 'FAIL') {
325 // The test's own lines follow its verdict, or come before it under -v.
326 const said = /^\s+(\S+\.go):(\d+): (.+)$/
327 const told = said.exec(lines[at + 1] ?? '') ?? said.exec(lines[at - 1] ?? '')
328 failures.push({ name: found[2] ?? '', message: told?.[3] ?? '', file: told?.[1] ?? null, line: number(told?.[2]) })
329 }
330 })
331
332 // A parent test fails with its subtests; only the leaves say why.
333 const leaves = failures.filter(one => !failures.some(other => other.name.startsWith(`${one.name}/`)))
334 const total = passed + skipped + leaves.length
335
336 return total === 0 ? null : { total, passed, failed: leaves.length, skipped, failures: leaves }
337}
338
339const gradle = (lines: readonly string[]): Tests | null => {
340 const summary = lines.map(line => /^(\d+) tests? completed(?:, (\d+) failed)?(?:, (\d+) skipped)?/.exec(line)).findLast(one => one !== null)
341 const failures: TestFailure[] = []
342
343 lines.forEach((line, at) => {
344 const found = /^(\S+) > (.+) FAILED$/.exec(line)
345
346 if (found !== null) {
347 const where = /\((\w+\.(?:kt|java|groovy|scala)):(\d+)\)/.exec(lines.slice(at + 1, at + 8).join('\n'))
348 failures.push({
349 name: `${found[1]} > ${found[2]}`,
350 message: following(lines, at + 1, one => / FAILED$/.test(one)),
351 file: where?.[1] ?? null,
352 line: number(where?.[2]),
353 })
354 }
355 })
356
357 if (summary === undefined || summary === null) {
358 return failures.length === 0 ? null : { total: failures.length, passed: 0, failed: failures.length, skipped: 0, failures }
359 }
360
361 const total = Number(summary[1])
362 const failed = Number(summary[2] ?? 0)
363 const skipped = Number(summary[3] ?? 0)
364
365 return { total, passed: total - failed - skipped, failed, skipped, failures }
366}
367
368const dotnet = (lines: readonly string[]): Tests | null => {
369 const summary = lines
370 .map(line => /Failed:\s+(\d+), Passed:\s+(\d+), Skipped:\s+(\d+), Total:\s+(\d+)/.exec(line))
371 .filter(one => one !== null)
372
373 if (summary.length === 0) {
374 return null
375 }
376
377 const sum = (group: number) => summary.reduce((total, one) => total + Number(one[group]), 0)
378 const failures: TestFailure[] = []
379
380 lines.forEach((line, at) => {
381 const found = /^\s+Failed (\S+) \[/.exec(line)
382
383 if (found !== null) {
384 const told = lines.slice(at + 1, at + 6).findIndex(one => /Error Message:/.test(one))
385 const where = /in (\S+\.\w+):line (\d+)/.exec(lines.slice(at + 1, at + 14).join('\n'))
386 failures.push({
387 name: found[1] ?? '',
388 message: told < 0 ? '' : (lines[at + 2 + told] ?? '').trim(),
389 file: where?.[1] ?? null,
390 line: number(where?.[2]),
391 })
392 }
393 })
394
395 return { total: sum(4), passed: sum(2), failed: sum(1), skipped: sum(3), failures }
396}
397
398const bun = (lines: readonly string[]): Tests | null => {
399 const passed = lines.map(line => /^\s*(\d+) pass$/.exec(line)).findLast(one => one !== null)
400 const failed = lines.map(line => /^\s*(\d+) fail$/.exec(line)).findLast(one => one !== null)
401
402 if (passed === undefined || passed === null || failed === undefined || failed === null) {
403 return null
404 }
405
406 const skipped = Number(lines.map(line => /^\s*(\d+) skip$/.exec(line)).findLast(one => one !== null)?.[1] ?? 0)
407 const failures = lines
408 .map(line => /^\(fail\) (.+?)(?: \[[\d.]+m?s\])?$/.exec(line))
409 .filter(one => one !== null)
410 .map(found => ({ name: found[1] ?? '', message: '', file: null, line: null }))
411
412 return { total: Number(passed[1]) + Number(failed[1]) + skipped, passed: Number(passed[1]), failed: Number(failed[1]), skipped, failures }
413}
414
415const TESTS: Record<string, (lines: readonly string[]) => Tests | null> = { pytest, jest, vitest, cargo, go, gradle, maven: gradle, dotnet, bun }
416
417const testsOf = (lines: readonly string[], tool: string): Tests | null => {
418 const own = TESTS[tool]?.(lines) ?? null
419
420 if (own !== null) {
421 return own
422 }
423
424 // A package script or a Makefile target: the runner behind it is not named, so each is asked.
425 for (const read of [pytest, jest, vitest, cargo, dotnet, bun, gradle]) {
426 const found = read(lines)
427
428 if (found !== null) {
429 return found
430 }
431 }
432
433 return tool === 'script' || tool === 'make' ? go(lines) : null
434}
435
436/** What a build's, test run's or lint run's output says, whichever toolchain wrote it. */
437export const parse = (raw: string, tool: string): Report => {
438 // A line longer than any diagnostic is cut before it is matched: minified output must not stall the hook.
439 const lines = raw
440 .replace(ANSI, '')
441 .replaceAll('\r\n', '\n')
442 .split('\n')
443 .map(line => (line.length > LINE_WIDTH ? line.slice(0, LINE_WIDTH) : line))
444 const hasFailed = lines.some(line => FAILED_MARKS.some(mark => mark.test(line)))
445 const hasPassed = lines.some(line => PASSED_MARKS.some(mark => mark.test(line)))
446
447 const issues = issuesOf(lines, tool)
448 const tests = testsOf(lines, tool)
449 const told = [...issues.map(one => one.message), ...(tests?.failures ?? []).flatMap(one => [one.name, one.message])].filter(one => one !== '')
450
451 return {
452 issues,
453 tests,
454 verdict: hasFailed ? 'failed' : hasPassed ? 'passed' : null,
455 lines: lines.length,
456 // Lines that speak of a failure and are not one already read: a linker error, a crash, a missing tool.
457 tail: lines
458 .filter(line => FAILURE_WORDS.test(line) && !told.some(one => line.includes(one)))
459 .slice(-TAIL_LINES)
460 .map(line => line.trim())
461 .map(line => (line.length > TAIL_WIDTH ? `${line.slice(0, TAIL_WIDTH)}…` : line)),
462 }
463}
464hooks/shell.ts 186 lines1export type Word = {
2 text: string
3 start: number
4 end: number
5 isRedirect: boolean
6 isDynamic: boolean
7}
8
9export type Command = {
10 /** The executable's name, any folder before it dropped. */
11 name: string
12 args: string[]
13 /** True when an argument is built at run time (`$VAR`, `$(...)`), so its text is not what runs. */
14 hasDynamicArgs: boolean
15}
16
17const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/
18const WRAPPERS = new Set(['time', 'command', 'exec', 'env', 'nohup', 'sudo', 'caffeinate', '{', '!', 'if', 'then', 'else', 'do', 'while'])
19
20const split = (command: string): Word[][] => {
21 const segments: Word[][] = []
22 let words: Word[] = []
23 let word: Word | null = null
24 const open = (at: number): Word => {
25 word ??= { text: '', start: at, end: at, isRedirect: false, isDynamic: false }
26
27 return word
28 }
29 const push = (at: number) => {
30 if (word !== null) {
31 word.end = at
32 words.push(word)
33 word = null
34 }
35 }
36 const cut = (at: number) => {
37 push(at)
38
39 if (words.length > 0) {
40 segments.push(words)
41 }
42
43 words = []
44 }
45 const size = command.length
46 let i = 0
47
48 while (i < size) {
49 const c = command.charAt(i)
50 const following = command.charAt(i + 1)
51
52 if (c === '\\') {
53 if (following !== '\n') {
54 open(i).text += following
55 }
56
57 i += 2
58 } else if (c === "'") {
59 const close = command.indexOf("'", i + 1)
60 const stop = close < 0 ? size : close
61 open(i).text += command.slice(i + 1, stop)
62 i = stop + 1
63 } else if (c === '"') {
64 const quoted = open(i)
65 i += 1
66
67 while (i < size && command.charAt(i) !== '"') {
68 const inner = command.charAt(i)
69 const escaped = command.charAt(i + 1)
70
71 if (inner === '\\' && '\\"$`\n'.includes(escaped) && escaped !== '') {
72 quoted.text += escaped === '\n' ? '' : escaped
73 i += 2
74 } else {
75 quoted.isDynamic ||= inner === '$' || inner === '`'
76 quoted.text += inner
77 i += 1
78 }
79 }
80
81 i += 1
82 } else if (c === '$' && following === '(') {
83 const substituted = open(i)
84 let depth = 0
85 let stop = i + 1
86
87 for (; stop < size; stop += 1) {
88 const inner = command.charAt(stop)
89 depth += inner === '(' ? 1 : inner === ')' ? -1 : 0
90
91 if (depth === 0) {
92 break
93 }
94 }
95
96 substituted.isDynamic = true
97 substituted.text += command.slice(i, stop + 1)
98 i = stop + 1
99 } else if (c === '`') {
100 const close = command.indexOf('`', i + 1)
101 const stop = close < 0 ? size : close
102 const substituted = open(i)
103 substituted.isDynamic = true
104 substituted.text += command.slice(i, stop + 1)
105 i = stop + 1
106 } else if (c === '#' && word === null) {
107 const newline = command.indexOf('\n', i)
108 i = newline < 0 ? size : newline
109 } else if (c === ' ' || c === '\t') {
110 push(i)
111 i += 1
112 } else if (c === '>' || c === '<') {
113 const redirect = open(i)
114 redirect.isRedirect = true
115 redirect.text += c
116 i += 1
117 } else if (c === '&' && ('<>'.includes(command.charAt(i - 1) || ' ') || following === '>')) {
118 const redirect = open(i)
119 redirect.isRedirect = true
120 redirect.text += c
121 i += 1
122 } else if (';\n|&()'.includes(c)) {
123 cut(i)
124 i += 1
125 } else {
126 const plain = open(i)
127 plain.isDynamic ||= c === '$'
128 plain.text += c
129 i += 1
130 }
131 }
132
133 cut(size)
134
135 return segments
136}
137
138const analyse = (words: Word[]): Command | null => {
139 let i = 0
140
141 while (i < words.length) {
142 const text = words[i]?.text ?? ''
143
144 if (ASSIGNMENT.test(text)) {
145 i += 1
146 } else if (WRAPPERS.has(text)) {
147 i += 1
148
149 while (words[i]?.text.startsWith('-') === true) {
150 i += 1
151 }
152 } else {
153 break
154 }
155 }
156
157 const head = words[i]
158
159 if (head === undefined || head.isRedirect || head.isDynamic) {
160 return null
161 }
162
163 const rest = words.slice(i + 1)
164 const redirect = rest.findIndex(one => one.isRedirect)
165 const args = redirect < 0 ? rest : rest.slice(0, redirect)
166
167 return {
168 name: head.text.slice(head.text.lastIndexOf('/') + 1),
169 args: args.map(one => one.text),
170 hasDynamicArgs: args.some(one => one.isDynamic),
171 }
172}
173
174/**
175 * The commands a Bash command line runs, in order.
176 *
177 * Only a command standing at a command position counts: one inside a quoted
178 * string, a `$(...)` or a here-document is text, not something that runs here.
179 */
180export const commands = (command: string): Command[] =>
181 command.includes('<<')
182 ? []
183 : split(command)
184 .map(analyse)
185 .filter(one => one !== null)
186types/index.d.ts 61 lines1export type Issue = {
2 severity: 'error' | 'warning'
3 file: string | null
4 line: number | null
5 column: number | null
6 /** The compiler's or linter's own code for it: `TS2322`, `E0308`, `no-unused-vars`. */
7 code: string | null
8 message: string
9}
10
11export type TestFailure = {
12 name: string
13 message: string
14 file: string | null
15 line: number | null
16}
17
18export type Tests = {
19 total: number
20 passed: number
21 failed: number
22 skipped: number
23 failures: TestFailure[]
24}
25
26export type Run = {
27 id: string
28 /** What ran, as the pane names it: `tsc`, `cargo test`, `npm run build`. */
29 label: string
30 kind: 'build' | 'test' | 'lint'
31 status: 'running' | 'succeeded' | 'failed' | 'cancelled'
32 startedAt: number
33 durationMs: number | null
34 errorCount: number
35 warningCount: number
36 issues: Issue[]
37 tests: Tests | null
38 /** Where Claude Code kept the whole output, when it was too long for the result. */
39 logPath: string | null
40 logLines: number
41 /** True when anything was read out of the output: a diagnostic, a test count or a verdict. */
42 hasFindings: boolean
43 isCondensed: boolean
44 /** Characters of output Claude did not have to read. */
45 savedChars: number
46}
47
48declare module 'claude-code' {
49 interface PluginState {
50 'buildpane': {
51 runs: Run[]
52 isShowingWarnings: boolean
53 now: number
54 /** Characters of raw output replaced by summaries in this session. */
55 savedChars: number
56 /** The same, over every session before this one. */
57 savedBefore: number
58 }
59 }
60}
61