After a turn that edited source files, runs the project's tests and lint in the background and shows a one-line badge (tests 41/42 · lint 2 errors · 4s ago)…

Runs your project's tests and lint in the background after Claude edits source files, and shows the result as one line, with a details pane that says where each failure is.
/plugin marketplace add partalemre/MODZ
/plugin install test-badge@modz
Or in one step: /plugin install test-badge --marketplace partalemre/MODZ.
The MODZ panel (modz) shows its line here: the last run's result, and the watched PR's CI.
[x 3/42 failed] [lint 2 errors] 4s ago [ Details t ] failing: red pill, amber lint pill
[v 42/42 passed] [lint clean] 4s ago passing: green pill
[* tests running] running: blue pill
t) when something failed, and a close mark. A live notice from session-budget takes that line first./test-badge details): when it ran and how long it took; a headline such as 3 failed · 39 passed over a passed/failed bar; filter chips with counts (All, then only the suites that have failures: Unit, E2E, Lint, Types); then the failures grouped by file, each with the line, the test's name, its first error line, the suite and, for vitest, jest, go and bun, its duration. Open opens the file at that line; Run again (r) runs again.The pane's eyebrow is red only for failed tests, amber for lint errors alone or a run that did not finish. A run that hits the 120-second limit shows timed out. Set MODZ_THEME=light or MODZ_THEME=dark in the env block of ~/.claude/settings.json to pin the theme.
Write, Edit or MultiEdit (main agent or a subagent) changed a source file (.ts .tsx .js .py .go .rs .php .rb .java .kt .swift .dart .c .cpp .cs .vue .svelte and similar). Edits to Markdown, JSON or config files do not trigger it.nice -n 10 with a 120-second timeout, in the session's working directory, with CI=1 (so vitest and jest do not start watch mode) and FORCE_COLOR=0. Tests run first, then lint.It also follows your pull request's CI on GitHub, through your own gh CLI:
gh pr create (the PR is read from its output), after a git push, and when the session opens on a branch with an open PR (also on /test-badge on). Only in a git repo, with gh installed and signed in; otherwise it does nothing and says nothing.gh pr checks <n> --json name,state,bucket,link and gh pr view <n> --json state,title,reviews,comments (reviews and comments are counted, never read) once a minute, every three minutes after ten polls. It stops when every check is done, the PR is merged or closed, no check reported after five polls, or after 90 minutes. After a push it waits to see a check running before it counts a finish, so the last commit's result is not taken for the new one.CI 3/4 · 1 failing · 2 new reviews or comments, each check with its state (running, passed, failed, skipped) and a Logs link, and Open PR.CI 3/4 · 1 failing, beside the test result when both fit in 40 characters.CI failed: lint · #12, CI passed · #12 · 4/4). A red finish also leaves a card above the prompt: Ask Claude to fix (f) sends Claude the failing checks with their links, Open PR opens it, Dismiss closes the card. Nothing is sent until you press.A cancelled check counts as failed. /test-badge off stops the watch too.
| Found | Test | Lint |
|---|---|---|
package.json scripts.test / scripts.lint (npm's placeholder test is ignored) | npm run --silent test; pnpm, yarn or bun run when that lockfile exists | the same with lint |
pytest.ini, conftest.py, or pytest in pyproject.toml / setup.cfg / tox.ini | python3 -m pytest -q | |
go.mod | go test ./... | |
Cargo.toml | cargo test -q | |
phpunit.xml(.dist) | vendor/bin/phpunit (or phpunit) | |
pubspec.yaml and a test/ folder | flutter test or dart test | |
phpstan.neon(.dist) | vendor/bin/phpstan analyse --no-progress --no-ansi |
Counts are read from jest, vitest, pytest, cargo, mocha, node:test, bun, phpunit, flutter/dart and go output. Lint errors are read from eslint, tsc, flutter/dart analyze and phpstan. Any other tool falls back to its exit code (tests passed / tests failed, lint clean / lint failed). Failures are read from the output; no extra command is run. If no parser recognises a failing run's output, its last 6 lines are shown. At most 50 failures per check are kept.
| Command | Effect | |
|---|---|---|
| `/test-badge [on\ | off]` | Turns it on or off; saved across sessions |
/test-badge run | Runs tests and lint now | |
/test-badge details | Opens the details pane |
| Option | Default | Meaning |
|---|---|---|
testCommand | empty | Overrides the detected test command, run with /bin/sh -c in the project root. Empty means auto-detect. |
lintCommand | empty | Overrides the detected lint command, run the same way. |
openCommand | empty | How a failure row opens its file, for example cursor -g {file}:{line} (no shell). Empty means VS Code's code -g, then the OS opener. If none starts, file:line is copied to the clipboard. |
Uses no model tokens: it runs your own commands (and gh) and reads their output. The only prompt it sends is the one you press Ask Claude to fix for.
The interface is English. Set MODZ_LANG=tr in the env block of ~/.claude/settings.json for Turkish.
hooks/register.tsx 285 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On, Register, RenderSurface, Timer } from 'claude-code'
3
4import { PROBED_TEXTS, detect, goModuleOf, isSource } from './detect'
5import type { RootInfo } from './detect'
6import { hookBand } from './band-hook'
7import { hookCi } from './ci'
8import { HUB_PANE, hubCardOf, isOpenFor } from './hub'
9import { DEBOUNCE_MS, DISMISS_AFTER_MS, EMPTY, NICE, OPEN_TIMEOUT_MS, PRIORITY, REFRESH_MS, TIMEOUT_MS, TIMEOUT_SLACK_MS, VENDOR_TOOLS, WRITE_TOOLS } from './limits'
10import { openCandidates, pathOf, placeOf } from './open'
11import { overridesOf, type Overrides } from './options'
12import { badgeText, isFailing, parseLint, parseTests } from './parse'
13import type { ParseContext } from './text'
14import { withHome } from './home'
15import { isOffStage, shownOf } from './stage'
16import { pinned } from './pin'
17import { AUTO, themeFrom } from './tidemark'
18import { motion } from './pane-pic'
19import { paneTree } from './view'
20import { setLang } from './lang'
21import { words } from './words'
22import type { CheckOutcome, CiWatch, Failure, SuiteFilter, TestBadge } from '../types'
23
24// The $.state values this file reads or writes, declared here: the scan lists them per file.
25const badgeAtom = atom({ plugin: 'test-badge', key: 'badge' } as const, EMPTY)
26const ciAtom = atom({ plugin: 'test-badge', key: 'ci' } as const, null as CiWatch | null)
27const enabledAtom = atom({ plugin: 'test-badge', key: 'isEnabled' } as const, true)
28const filterAtom = atom({ plugin: 'test-badge', key: 'filter' } as const, 'all' as SuiteFilter)
29const noticeAtom = atom({ plugin: 'test-badge', key: 'notice' } as const, null)
30const themeAtom = atom({ plugin: 'test-badge', key: 'theme' } as const, AUTO)
31
32const COMMAND = 'test-badge'
33const PANE = 'test-badge'
34const STORE_KEY = 'test-badge.enabled'
35// `on`/`off` set the switch; any other argument flips it.
36const SWITCH_ARGS = new Map([['on', true], ['off', false]])
37
38const homeRef = { plugin: 'test-badge', key: 'home' } as const
39const hubRef = { plugin: 'test-badge', key: 'hub' } as const
40
41type Parse = (out: string, code: number, ctx: ParseContext) => CheckOutcome
42const flags = { isDirty: false, isRunning: false, isQueued: false, debounce: null as Timer | null, noticeId: 0 }
43
44async function rootInfo($: EngineInterface, cwd: string): Promise<RootInfo> {
45 const names = new Set((await $.fs.list(cwd)).map(entry => entry.name))
46 const texts: Record<string, string> = {}
47 for (const name of PROBED_TEXTS.filter(file => names.has(file))) texts[name] = await $.fs.read(`${cwd}/${name}`)
48 const vendor = new Set<string>()
49 for (const tool of VENDOR_TOOLS) if (await $.fs.exists(`${cwd}/vendor/bin/${tool}`)) vendor.add(tool)
50
51 return { names, texts, hasVendorBin: tool => vendor.has(tool) }
52}
53
54async function check($: EngineInterface, argv: string[], how: { parse: Parse; ctx: ParseContext }): Promise<CheckOutcome> {
55 const started = await $.clock.now()
56 try {
57 const out = await $.process.run([...NICE, ...argv], { cwd: how.ctx.root, timeoutMs: TIMEOUT_MS, env: { CI: '1', FORCE_COLOR: '0' } })
58
59 return how.parse(`${out.stdout}\n${out.stderr}`, out.exitCode, how.ctx)
60 } catch {
61 const isTimeout = (await $.clock.now()) - started >= TIMEOUT_MS - TIMEOUT_SLACK_MS
62
63 return { status: isTimeout ? 'timeout' : 'error', passed: null, total: null, errors: null, failures: [], tail: [] }
64 }
65}
66
67// The status line is drawn as a warning (⚠), so it holds a failure only; a green run lives in the band and the MODZ hub.
68async function showStatus($: EngineInterface): Promise<void> {
69 const badge = await read($, badgeAtom)
70 const isShown = (await read($, enabledAtom)) && isFailing(badge)
71 $.ui.status(isShown ? badgeText(badge, await $.clock.now()) : undefined)
72}
73
74async function notify($: EngineInterface, badge: TestBadge): Promise<void> {
75 flags.noticeId += 1
76 const id = flags.noticeId
77 const now = await $.clock.now()
78 const text = badgeText(badge, now) ?? ''
79 const expiresAt = now + DISMISS_AFTER_MS
80 await update($, noticeAtom, () => ({ id, text, isFailing: isFailing(badge), hasDetails: isFailing(badge), priority: PRIORITY, expiresAt }))
81 $.clock.after(DISMISS_AFTER_MS, () => {
82 void update($, noticeAtom, current => (current !== null && current.id === id ? null : current))
83 })
84}
85
86async function runChecks($: EngineInterface, overrides: Overrides): Promise<void> {
87 const root = await $.session.cwd()
88 const info = await rootInfo($, root)
89 const commands = detect(info, overrides)
90 if (commands.test === null && commands.lint === null) {
91 $.ui.log('test-badge: no test or lint command found here', { to: 'debug' })
92
93 return
94 }
95 const ctx: ParseContext = { root, goModule: goModuleOf(info) }
96 const started = await $.clock.now()
97 await update($, badgeAtom, badge => ({ ...badge, isRunning: true }))
98 await writeHub($)
99 await showStatus($)
100 const tests = commands.test === null ? null : await check($, commands.test, { parse: parseTests, ctx })
101 const lint = commands.lint === null ? null : await check($, commands.lint, { parse: parseLint, ctx })
102 const finishedAt = await $.clock.now()
103 const badge: TestBadge = { tests, lint, root, finishedAt, durationMs: finishedAt - started, isRunning: false }
104 await update($, badgeAtom, () => badge)
105 await writeHub($)
106 await update($, filterAtom, () => 'all')
107 await showStatus($)
108 await notify($, badge)
109}
110
111// One run at a time: a request while one runs is queued and runs once after it.
112async function runAll($: EngineInterface, overrides: Overrides): Promise<void> {
113 if (flags.isRunning) {
114 flags.isQueued = true
115
116 return
117 }
118 flags.isRunning = true
119 try {
120 await runChecks($, overrides)
121 } catch (error) {
122 $.ui.log(`test-badge: run failed (${error instanceof Error ? error.message : 'unknown'})`, { to: 'debug' })
123 await update($, badgeAtom, badge => ({ ...badge, isRunning: false }))
124 await writeHub($)
125 } finally {
126 flags.isRunning = false
127 }
128 if (flags.isQueued) {
129 flags.isQueued = false
130 await runAll($, overrides)
131 }
132}
133
134function schedule($: EngineInterface, overrides: Overrides): void {
135 flags.debounce?.cancel()
136 flags.debounce = $.clock.after(DEBOUNCE_MS, () => void runAll($, overrides))
137}
138
139// Opens the failure's file at its line; where no opener runs, copies "file:line" instead.
140async function openFailure($: EngineInterface, failure: Failure, where: { root: string; template: string; surface: RenderSurface }): Promise<void> {
141 const path = pathOf(failure, where.root)
142 if (path === null) return
143 for (const argv of openCandidates(where.template, { path, line: failure.line })) {
144 const out = await $.process.run(argv, { timeoutMs: OPEN_TIMEOUT_MS }).catch(() => null)
145 if (out !== null && out.exitCode === 0) return
146 }
147 const place = placeOf(failure)
148 const copied = await $.ui.copy({ text: place, surface: where.surface })
149 $.ui.toast(copied.isCopied ? words().openCopied(place) : words().openFailed(place))
150}
151
152// The MODZ main panel's card, from the local checks and the CI watch (ci.ts writes it the same way).
153async function writeHub($: EngineInterface): Promise<void> {
154 const standing = (await $.state.get(hubRef)).value
155 const card = hubCardOf({ badge: await read($, badgeAtom), ci: await read($, ciAtom), standing, at: await $.clock.now() })
156 if (card !== null) await $.state.set(hubRef, card)
157}
158
159// Tells the hub this pane now holds the panel, so the hub and every other pane step aside (kit/stage.ts).
160async function reportShown($: EngineInterface): Promise<void> {
161 try {
162 if ((await $.state.get({ plugin: 'modz', key: 'isOn' })).value !== true) return
163 await $.state.set({ plugin: 'test-badge', key: 'shown' }, shownOf(HUB_PANE, await $.clock.now()))
164 } catch (error) {
165 $.ui.log(`test-badge: shown failed (${String(error)})`, { to: 'debug' })
166 }
167}
168
169// The same pane whoever asks (/test-badge details, the MODZ main panel; band-hook.tsx opens it the same way); an
170// open one comes forward.
171async function openPane($: EngineInterface): Promise<void> {
172 motion.reset()
173 const opened = await $.ui.open({ id: PANE, title: words().paneTitle, focus: true })
174 if (opened.isPlaced) await reportShown($)
175}
176
177async function runCommand($: EngineInterface, arg: string, overrides: Overrides): Promise<string> {
178 if (arg === 'run') {
179 void runAll($, overrides)
180
181 return words().commandRunning
182 }
183 if (arg === 'details') {
184 await openPane($)
185
186 return words().commandDetails
187 }
188 const isOn = SWITCH_ARGS.get(arg) ?? !(await read($, enabledAtom))
189 await update($, enabledAtom, () => isOn)
190 await $.store.set(STORE_KEY, isOn)
191 if (!isOn) await update($, noticeAtom, () => null)
192 await showStatus($)
193
194 return words().commandSwitched(isOn)
195}
196
197function hookRuns(on: On, overrides: Overrides): void {
198 on('session.start', async ($, e, next) => {
199 setLang(await $.env.get('MODZ_LANG').catch(() => undefined))
200 try {
201 const saved = await $.store.get(STORE_KEY)
202 await update($, enabledAtom, () => saved !== false)
203 const pinned = await $.env.get('MODZ_THEME').catch(() => undefined)
204 await update($, themeAtom, () => themeFrom(pinned))
205 await writeHub($)
206 await $.command.register({ name: COMMAND, description: words().commandHelp, argumentHint: '[on|off|run|details]' })
207 $.clock.every(REFRESH_MS, () => {
208 void read($, badgeAtom).then(badge => (badge.finishedAt === null ? undefined : showStatus($)))
209 })
210 } catch {
211 $.ui.log('test-badge: could not load settings', { to: 'debug' })
212 }
213
214 return next(e)
215 })
216
217 on('command.run', { command: COMMAND }, async ($, e) => ({ text: await runCommand($, e.args.trim().toLowerCase(), overrides) }))
218
219 on('tool.call', async ($, e, next) => {
220 const result = await next(e)
221 const path = (e as { file_path?: unknown }).file_path
222 const isEdit = WRITE_TOOLS.has(String(e.tool)) && typeof path === 'string' && isSource(path)
223 if (isEdit && result.deny === undefined && result.isError !== true) flags.isDirty = true
224
225 return result
226 }).catch(($, e, next) => next(e))
227
228 on('turn.complete', async ($, e, next) => {
229 if (e.agentId === undefined && flags.isDirty && (await read($, enabledAtom))) {
230 flags.isDirty = false
231 schedule($, overrides)
232 }
233
234 return next(e)
235 })
236}
237
238// The main panel's open request opens the details pane, or brings it forward; it never closes it. Another
239// pane taking the main panel closes it, so it never waits as a tab.
240function hookHub(on: On): void {
241 on('state.set', { plugin: 'modz', key: 'open' }, async ($, e, next) => {
242 const done = await next(e)
243 if (isOpenFor(e.value, HUB_PANE)) await openPane($)
244
245 return done
246 }).catch(($, e, next) => next(e))
247 on('state.set', { plugin: 'modz', key: 'stage' }, async ($, e, next) => {
248 const done = await next(e)
249 if (isOffStage(e.value, HUB_PANE)) await $.ui.close({ id: PANE })
250
251 return done
252 }).catch(($, e, next) => next(e))
253}
254
255function hookPane(on: On, overrides: Overrides): void {
256 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
257 const badge = await read($, badgeAtom)
258 const where = { root: badge.root ?? (await $.session.cwd()), template: overrides.open, surface: e.surface }
259
260 const isHubOn = (await $.state.get({ plugin: 'modz', key: 'isOn' })).value === true
261 // Written when pressed, never while drawing; a failure only goes to the debug log.
262 const goHome = () => void $.clock.now().then(at => $.state.set(homeRef, { at })).catch(() => $.ui.log('test-badge: could not go back to MODZ', { to: 'debug' }))
263 const ui = $.ui.resolve(e)
264 const tree = paneTree(ui, {
265 badge, now: await $.clock.now(), columns: e.props.bodyColumns, filter: await read($, filterAtom),
266 theme: await read($, themeAtom), isRich: e.surface !== 'terminal',
267 onOpen: failure => void openFailure($, failure, where),
268 onRerun: () => void runAll($, overrides),
269 onFilter: filter => void update($, filterAtom, () => filter),
270 ci: await read($, ciAtom),
271 })
272
273 return pinned(e, withHome(ui, tree, isHubOn ? goHome : null))
274 })
275}
276
277export const register: Register = (on, options) => {
278 const overrides = overridesOf(options)
279 hookRuns(on, overrides)
280 hookHub(on)
281 hookBand(on)
282 hookPane(on, overrides)
283 hookCi(on)
284}
285hooks/detect.ts 83 lines1export type Commands = { test: string[] | null; lint: string[] | null }
2
3// What the project root holds: its file names, and the text of the few files detection reads.
4export type RootInfo = { names: ReadonlySet<string>; texts: Readonly<Record<string, string>>; hasVendorBin: (tool: string) => boolean }
5
6const NPM_DEFAULT_TEST = 'no test specified'
7const SOURCE_EXT = new Set([
8 'ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs', 'mts', 'cts', 'vue', 'svelte', 'py', 'go', 'rs', 'php', 'rb',
9 'java', 'kt', 'kts', 'swift', 'dart', 'c', 'h', 'cc', 'cpp', 'hpp', 'cs', 'scala', 'ex', 'exs', 'lua',
10])
11export const PROBED_TEXTS = ['package.json', 'pyproject.toml', 'setup.cfg', 'tox.ini', 'go.mod', 'pubspec.yaml']
12
13export function isSource(path: string): boolean {
14 const name = path.split('/').pop() ?? ''
15 const dot = name.lastIndexOf('.')
16
17 return dot > 0 && SOURCE_EXT.has(name.slice(dot + 1).toLowerCase())
18}
19
20function runnerOf(names: ReadonlySet<string>): string[] {
21 if (names.has('pnpm-lock.yaml')) return ['pnpm']
22 if (names.has('yarn.lock')) return ['yarn']
23 if (names.has('bun.lockb') || names.has('bun.lock')) return ['bun', 'run']
24
25 return ['npm', 'run', '--silent']
26}
27
28function scriptsOf(text: string | undefined): Record<string, string> {
29 if (text === undefined) return {}
30 try {
31 const parsed: unknown = JSON.parse(text)
32 const scripts = (parsed as { scripts?: unknown } | null)?.scripts
33
34 return typeof scripts === 'object' && scripts !== null ? (scripts as Record<string, string>) : {}
35 } catch {
36 return {}
37 }
38}
39
40function hasPytest(root: RootInfo): boolean {
41 if (root.names.has('pytest.ini') || root.names.has('conftest.py')) return true
42
43 return ['pyproject.toml', 'setup.cfg', 'tox.ini'].some(name => (root.texts[name] ?? '').includes('pytest'))
44}
45
46function phpTool(root: RootInfo, tool: string): string {
47 return root.hasVendorBin(tool) ? `vendor/bin/${tool}` : tool
48}
49
50function detectTest(root: RootInfo, scripts: Record<string, string>): string[] | null {
51 const test = scripts.test
52 if (typeof test === 'string' && !test.includes(NPM_DEFAULT_TEST)) return [...runnerOf(root.names), 'test']
53 if (hasPytest(root)) return ['python3', '-m', 'pytest', '-q']
54 if (root.names.has('go.mod')) return ['go', 'test', './...']
55 if (root.names.has('Cargo.toml')) return ['cargo', 'test', '-q']
56 if (root.names.has('pubspec.yaml') && root.names.has('test')) return /sdk:\s*flutter/.test(root.texts['pubspec.yaml'] ?? '') ? ['flutter', 'test'] : ['dart', 'test']
57 if (root.names.has('phpunit.xml') || root.names.has('phpunit.xml.dist')) return [phpTool(root, 'phpunit')]
58
59 return null
60}
61
62function detectLint(root: RootInfo, scripts: Record<string, string>): string[] | null {
63 if (typeof scripts.lint === 'string') return [...runnerOf(root.names), 'lint']
64 if (root.names.has('phpstan.neon') || root.names.has('phpstan.neon.dist')) return [phpTool(root, 'phpstan'), 'analyse', '--no-progress', '--no-ansi']
65
66 return null
67}
68
69// The module path in go.mod, which maps a failing package back to its folder.
70export function goModuleOf(root: RootInfo): string | null {
71 return /^module\s+(\S+)/m.exec(root.texts['go.mod'] ?? '')?.[1] ?? null
72}
73
74export function detect(root: RootInfo, overrides: { test: string; lint: string }): Commands {
75 const scripts = scriptsOf(root.texts['package.json'])
76 const shell = (command: string) => ['/bin/sh', '-c', command]
77
78 return {
79 test: overrides.test.trim() !== '' ? shell(overrides.test) : detectTest(root, scripts),
80 lint: overrides.lint.trim() !== '' ? shell(overrides.lint) : detectLint(root, scripts),
81 }
82}
83hooks/band-hook.tsx 105 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On } from 'claude-code'
3
4import { bandTree } from './band'
5import { fixPrompt } from './ci-text'
6import { ciCardTree } from './ci-view'
7import { HUB_PANE } from './hub'
8import { isLive } from './notice'
9import { isFailing } from './parse'
10import { EMPTY } from './limits'
11import { motion } from './pane-pic'
12import { pinned } from './pin'
13import { shownOf } from './stage'
14import { AUTO } from './tidemark'
15import { words } from './words'
16import type { CiCard, CiWatch, TestBadge } from '../types'
17
18// The band's notice slot (DESIGN.md §6, notices, one at a time): a red CI finish's card first, until pressed;
19// else a finished run's notice, or the running pill. All of it gives way to louder notices.
20
21// The $.state values this file reads or writes, declared here: the scan lists them per file.
22const badgeAtom = atom({ plugin: 'test-badge', key: 'badge' } as const, EMPTY)
23const ciAtom = atom({ plugin: 'test-badge', key: 'ci' } as const, null as CiWatch | null)
24const ciCardAtom = atom({ plugin: 'test-badge', key: 'ciCard' } as const, null as CiCard | null)
25const noticeAtom = atom({ plugin: 'test-badge', key: 'notice' } as const, null)
26const themeAtom = atom({ plugin: 'test-badge', key: 'theme' } as const, AUTO)
27
28const PANE = 'test-badge'
29
30// The shared notice line: every one of these outranks this mod's line (session-budget ties at 1 and wins).
31const cacheTaxNotice = { plugin: 'cache-tax', key: 'notice' } as const
32const knowNotice = { plugin: 'you-should-know', key: 'notice' } as const
33const budgetNotice = { plugin: 'session-budget', key: 'notice' } as const
34const reflectNotice = { plugin: 'reflect', key: 'notice' } as const
35const handoffNotice = { plugin: 'handoff', key: 'notice' } as const
36
37async function isOutranked($: EngineInterface): Promise<boolean> {
38 const nowMs = await $.clock.now()
39 const others: { value?: unknown }[] = [
40 await $.state.get(cacheTaxNotice as never), await $.state.get(knowNotice as never),
41 await $.state.get(budgetNotice as never), await $.state.get(reflectNotice as never),
42 await $.state.get(handoffNotice as never),
43 ]
44
45 return others.some(other => isLive(other.value, nowMs))
46}
47
48async function bandOf($: EngineInterface, now: number): Promise<{ badge: TestBadge; isNotice: boolean } | null> {
49 const badge = await read($, badgeAtom)
50 const notice = await read($, noticeAtom)
51 const isNotice = notice !== null && isLive(notice, now)
52 if (!badge.isRunning && !isNotice) return null
53
54 return (await isOutranked($)) ? null : { badge, isNotice }
55}
56
57async function clearCard($: EngineInterface): Promise<void> {
58 if ((await read($, ciCardAtom)) !== null) await update($, ciCardAtom, () => null)
59}
60
61// "Ask Claude to fix": the person's press, the only way this mod submits a prompt.
62async function askFix($: EngineInterface, card: CiCard): Promise<void> {
63 await clearCard($)
64 await $.prompt.submit({ text: fixPrompt(card) }).catch(error => $.ui.log(`test-badge: could not send the CI prompt (${String(error)})`, { to: 'debug' }))
65}
66
67// Ayrıntı opens the details pane as register.tsx's openPane does, and with the hub on tells it the pane took
68// its panel (kit/stage.ts); `$` is not handed across modules, so the open is spelled out here.
69async function openDetails($: EngineInterface): Promise<void> {
70 motion.reset()
71 const opened = await $.ui.open({ id: PANE, title: words().paneTitle, focus: true })
72 try {
73 if (!opened.isPlaced || (await $.state.get({ plugin: 'modz', key: 'isOn' })).value !== true) return
74 await $.state.set({ plugin: 'test-badge', key: 'shown' }, shownOf(HUB_PANE, await $.clock.now()))
75 } catch (error) {
76 $.ui.log(`test-badge: shown failed (${String(error)})`, { to: 'debug' })
77 }
78}
79
80export function hookBand(on: On): void {
81 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
82 const theirs = await next(e)
83 if (e.props.hasSurvey) return theirs
84 const ui = $.ui.resolve(e)
85 const view = { columns: e.props.bodyColumns, theme: await read($, themeAtom), isRich: e.surface !== 'terminal' }
86 const card = await read($, ciCardAtom)
87 if (card !== null && !(await isOutranked($))) {
88 const title = (await read($, ciAtom))?.title ?? ''
89
90 return pinned(e, ciCardTree(ui, { ...view, card, title, onAsk: () => void askFix($, card), onDismiss: () => void clearCard($) }, theirs))
91 }
92 const now = await $.clock.now()
93 const shown = await bandOf($, now)
94 if (shown === null) return theirs
95 const { badge, isNotice } = shown
96
97 return pinned(e, bandTree(ui, {
98 ...view, badge, now,
99 hasDetails: !badge.isRunning && isFailing(badge),
100 onDetails: () => void openDetails($),
101 onDismiss: badge.isRunning || !isNotice ? undefined : () => void update($, noticeAtom, () => null),
102 }, theirs))
103 })
104}
105hooks/ci.ts 160 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On, ProcessRunResult, Timer } from 'claude-code'
3
4import { checksOf, isGitPush, isPrCreate, prInfoOf, prPollOf, prRefOf } from './ci-parse'
5import { intervalOf, isBackoffPoll, newWatch, stepOf } from './ci-state'
6import type { Step } from './ci-state'
7import { notifyText } from './ci-text'
8import { hubCardOf } from './hub'
9import { CI_SLOW_POLL_MS, EMPTY, GH_TIMEOUT_MS } from './limits'
10import { words } from './words'
11import type { CiCard, CiWatch } from '../types'
12
13// The CI watch: starts when Claude opens a PR, pushes, or the session opens on a branch with an open PR;
14// polls `gh` on a timer until every check is done, the PR leaves the open state, or the longest watch ends.
15// Nothing runs where the folder is not a git repo or gh is missing or signed out: each call fails quietly.
16
17// The $.state values this file reads or writes, declared here: the scan lists them per file.
18const badgeAtom = atom({ plugin: 'test-badge', key: 'badge' } as const, EMPTY)
19const ciAtom = atom({ plugin: 'test-badge', key: 'ci' } as const, null as CiWatch | null)
20const ciCardAtom = atom({ plugin: 'test-badge', key: 'ciCard' } as const, null as CiCard | null)
21const enabledAtom = atom({ plugin: 'test-badge', key: 'isEnabled' } as const, true)
22const hubRef = { plugin: 'test-badge', key: 'hub' } as const
23
24const GH_ENV = { GH_PROMPT_DISABLED: '1', GH_NO_UPDATE_NOTIFIER: '1', NO_COLOR: '1' }
25const PR_FIELDS = 'number,url,headRefName,state,title'
26const CHECK_FIELDS = 'name,state,bucket,link'
27const POLL_FIELDS = 'state,title,reviews,comments'
28
29// A new watch bumps the generation, so a poll still out for the last one is dropped when it answers.
30const live = { timer: null as Timer | null, generation: 0, isPolling: false, cardId: 0 }
31type Start = { selector: string | null; isFreshRun: boolean }
32
33const debug = ($: EngineInterface, text: string) => $.ui.log(`test-badge: ${text}`, { to: 'debug' })
34
35// A command that cannot start (no gh, no git) or overruns is no answer: the watch stays quiet.
36async function run($: EngineInterface, argv: string[], cwd: string): Promise<ProcessRunResult | null> {
37 return $.process.run(argv, { cwd, env: GH_ENV, timeoutMs: GH_TIMEOUT_MS }).catch(() => null)
38}
39
40async function isGitRepo($: EngineInterface, cwd: string): Promise<boolean> {
41 const out = await run($, ['git', 'rev-parse', '--is-inside-work-tree'], cwd)
42
43 return out !== null && out.exitCode === 0 && out.stdout.trim() === 'true'
44}
45
46function stop(): void {
47 live.timer?.cancel()
48 live.timer = null
49}
50
51function every($: EngineInterface, spec: { ms: number; generation: number; cwd: string }): void {
52 stop()
53 live.timer = $.clock.every(spec.ms, () => void poll($, spec).catch(error => debug($, `CI poll failed (${String(error)})`)))
54}
55
56// The MODZ main panel's card follows each write of the watch, as register.tsx writes it for a run.
57async function writeHub($: EngineInterface): Promise<void> {
58 const standing = (await $.state.get(hubRef)).value
59 const card = hubCardOf({ badge: await read($, badgeAtom), ci: await read($, ciAtom), standing, at: await $.clock.now() })
60 if (card !== null) await $.state.set(hubRef, card)
61}
62
63async function clearCard($: EngineInterface): Promise<void> {
64 if ((await read($, ciCardAtom)) !== null) await update($, ciCardAtom, () => null)
65}
66
67async function startWatch($: EngineInterface, start: Start): Promise<void> {
68 if (!(await read($, enabledAtom))) return
69 const cwd = await $.session.cwd()
70 if (!(await isGitRepo($, cwd))) return
71 const argv = ['gh', 'pr', 'view', ...(start.selector === null ? [] : [start.selector]), '--json', PR_FIELDS]
72 const out = await run($, argv, cwd)
73 const pr = out === null || out.exitCode !== 0 ? null : prInfoOf(out.stdout)
74 if (pr === null || pr.state !== 'open') return
75 live.generation += 1
76 const generation = live.generation
77 const watch = newWatch(pr, { now: await $.clock.now(), isFreshRun: start.isFreshRun })
78 await update($, ciAtom, () => watch)
79 await writeHub($)
80 await clearCard($)
81 every($, { ms: intervalOf(0), generation, cwd })
82 if (!start.isFreshRun) await poll($, { generation, cwd })
83}
84
85async function finish($: EngineInterface, step: Step): Promise<void> {
86 if (step.heard === null) return
87 if (step.heard === 'fail') {
88 live.cardId += 1
89 const card: CiCard = { id: live.cardId, number: step.watch.number, url: step.watch.url, failing: step.watch.checks.filter(check => check.state === 'fail') }
90 await update($, ciCardAtom, () => card)
91 }
92 const sent = await $.ui.notify(notifyText(step.watch, step.heard), { title: words().ci.eyebrow }).catch(() => null)
93 if (sent === null || !sent.isSent) debug($, 'CI notification not sent')
94}
95
96async function answers($: EngineInterface, watch: CiWatch, cwd: string) {
97 const n = String(watch.number)
98 const checks = await run($, ['gh', 'pr', 'checks', n, '--json', CHECK_FIELDS], cwd)
99 const pr = await run($, ['gh', 'pr', 'view', n, '--json', POLL_FIELDS], cwd)
100
101 // gh pr checks exits 8 while checks are pending and 1 when one failed: its JSON is read whatever the code.
102 return { checks: checks === null ? null : checksOf(checks.stdout), pr: pr === null || pr.exitCode !== 0 ? null : prPollOf(pr.stdout) }
103}
104
105// One poll at a time: a tick that comes while one is out is skipped, not queued.
106async function poll($: EngineInterface, spec: { generation: number; cwd: string }): Promise<void> {
107 if (live.isPolling) return
108 live.isPolling = true
109 try {
110 const watch = await read($, ciAtom)
111 if (watch === null || watch.phase !== 'watching' || spec.generation !== live.generation) return
112 if (!(await read($, enabledAtom))) return stop()
113 const got = await answers($, watch, spec.cwd)
114 if (spec.generation !== live.generation) return
115 const step = stepOf(watch, { ...got, now: await $.clock.now() })
116 await update($, ciAtom, () => step.watch)
117 await writeHub($)
118 if (step.isDone) stop()
119 else if (isBackoffPoll(step.watch.polls)) every($, { generation: spec.generation, cwd: spec.cwd, ms: CI_SLOW_POLL_MS })
120 await finish($, step)
121 } finally {
122 live.isPolling = false
123 }
124}
125
126function outputOf(result: { text?: string; result?: unknown }): string {
127 const record = result.result as { stdout?: unknown } | undefined
128 const stdout = typeof record?.stdout === 'string' ? record.stdout : ''
129
130 return `${result.text ?? ''}\n${stdout}`
131}
132
133function startQuietly($: EngineInterface, start: Start): void {
134 void startWatch($, start).catch(error => debug($, `CI watch did not start (${String(error)})`))
135}
136
137export function hookCi(on: On): void {
138 // The switch is written at session.start and by /test-badge on|off: on looks for the branch's open PR
139 // (a finish already over by then stays quiet), off stops the polling.
140 on('state.set', { plugin: 'test-badge', key: 'isEnabled' }, async ($, e, next) => {
141 const done = await next(e)
142 if (e.value === true) startQuietly($, { selector: null, isFreshRun: false })
143 else stop()
144
145 return done
146 }).catch(($, e, next) => next(e))
147
148 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
149 const result = await next(e)
150 const command = String((e as { command?: unknown }).command ?? '')
151 if (result.deny !== undefined || result.isError === true) return result
152 const ref = isPrCreate(command) ? prRefOf(outputOf(result)) : null
153 if (isPrCreate(command) || isGitPush(command)) {
154 startQuietly($, { selector: ref === null ? null : String(ref.number), isFreshRun: true })
155 }
156
157 return result
158 }).catch(($, e, next) => next(e))
159}
160hooks/hub.ts 77 lines1import type { CheckOutcome, CiWatch, HubCard, HubTone, TestBadge } from '../types'
2import { ciLine, ciTone } from './ci-text'
3import { words } from './words'
4
5// The MODZ main panel's side of this mod (DESIGN.md §9): the one-line card it collects ("41/42 geçti · lint 2 hata"),
6// and its open requests, which name a pane by key.
7
8export const HUB_PANE = 'tests'
9
10export type HubShown = { detail: string; tone: HubTone }
11
12function testsPart(tests: CheckOutcome): string {
13 const says = words()
14 if (tests.status === 'timeout') return says.testTimeout
15 if (tests.status === 'error') return says.testNotStarted
16 // A failing run with every counted test passed (a coverage gate, a late crash) has no honest count.
17 const isCounted = tests.passed !== null && tests.total !== null && !(tests.status === 'fail' && tests.passed >= tests.total)
18 if (isCounted) return says.passedOf(tests.passed, tests.total)
19
20 return tests.status === 'pass' ? says.testsPassed : says.testsFailed
21}
22
23function lintPart(lint: CheckOutcome): string {
24 const says = words()
25 if (lint.status === 'pass') return ''
26 if (lint.status === 'timeout') return says.lintTimeout
27 if (lint.status === 'error') return says.lintNotStarted
28
29 return lint.errors !== null && lint.errors > 0 ? says.lintErrors(lint.errors) : says.lintFailed
30}
31
32// Red for a failing check; Amber for a check that could not say (timed out, never started).
33function toneOf(badge: TestBadge): HubTone {
34 const outcomes = [badge.tests, badge.lint].filter((one): one is CheckOutcome => one !== null)
35 if (outcomes.some(one => one.status === 'fail')) return 'bad'
36
37 return outcomes.some(one => one.status !== 'pass') ? 'warn' : 'ok'
38}
39
40export function hubOf(badge: TestBadge): HubShown {
41 if (badge.isRunning) return { detail: words().running, tone: 'live' }
42 if (badge.finishedAt === null) return { detail: '', tone: 'idle' }
43 const parts = [badge.tests === null ? '' : testsPart(badge.tests), badge.lint === null ? '' : lintPart(badge.lint)]
44 const detail = parts.filter(part => part !== '').join(' · ')
45
46 return { detail: detail === '' ? words().lintClean : detail, tone: toneOf(badge) }
47}
48
49// The card holds ≤40 characters: the local checks and CI side by side when both fit, else CI alone,
50// the newer news; the tone is the worse of the two.
51const DETAIL_MAX = 40
52const SEP = ' · '
53const TONE_RANK: Record<HubTone, number> = { idle: 0, ok: 1, live: 2, warn: 3, bad: 4 }
54
55export function hubWith(badge: TestBadge, ci: CiWatch | null): HubShown {
56 const local = hubOf(badge)
57 const line = ci === null ? '' : ciLine(ci)
58 if (ci === null || line === '') return local
59 const both = local.detail === '' ? line : `${local.detail}${SEP}${line}`
60 const tone = TONE_RANK[ciTone(ci)] > TONE_RANK[local.tone] ? ciTone(ci) : local.tone
61
62 return { detail: both.length <= DETAIL_MAX ? both : line, tone }
63}
64
65// The card to write, or null while the standing one already shows the same: it is written only on a change.
66export function hubCardOf(spec: { badge: TestBadge; ci: CiWatch | null; standing: HubCard | undefined; at: number }): HubCard | null {
67 const shown = hubWith(spec.badge, spec.ci)
68 const { standing } = spec
69 if (standing !== undefined && standing.detail === shown.detail && standing.tone === shown.tone) return null
70
71 return { pane: HUB_PANE, ...shown, at: spec.at }
72}
73
74export function isOpenFor(value: unknown, pane: string): boolean {
75 return typeof value === 'object' && value !== null && (value as { pane?: unknown }).pane === pane
76}
77hooks/limits.ts 28 lines1import type { TestBadge } from '../types'
2
3// When and how the checks run, and how long their results stay on the band.
4
5export const WRITE_TOOLS = new Set(['Write', 'Edit', 'MultiEdit'])
6export const DEBOUNCE_MS = 5_000
7export const TIMEOUT_MS = 120_000
8export const TIMEOUT_SLACK_MS = 1_000
9export const REFRESH_MS = 15_000
10export const DISMISS_AFTER_MS = 20_000
11export const PRIORITY = 1
12export const NICE = ['nice', '-n', '10']
13export const OPEN_TIMEOUT_MS = 10_000
14export const VENDOR_TOOLS = ['phpunit', 'phpstan']
15export const EMPTY: TestBadge = { tests: null, lint: null, root: null, finishedAt: null, durationMs: 0, isRunning: false }
16
17// The CI watch: a poll a minute, then one every three after ten polls; never longer than ninety minutes.
18export const CI_POLL_MS = 60_000
19export const CI_SLOW_POLL_MS = 180_000
20export const CI_BACKOFF_AFTER_POLLS = 10
21export const CI_MAX_WATCH_MS = 90 * 60_000
22// After a push the first polls may still show the last commit's finished run: such a finish counts only
23// once a check was seen pending, or after this many polls (then quietly, as nothing was heard finishing).
24export const CI_GRACE_POLLS = 3
25// A PR with no check reported after this many polls has no CI to watch.
26export const CI_NO_CHECKS_POLLS = 5
27export const GH_TIMEOUT_MS = 20_000
28hooks/open.ts 22 lines1import type { Failure } from '../types'
2
3export type OpenTarget = { path: string; line: number | null }
4
5export function pathOf(failure: Failure, root: string): string | null {
6 if (failure.file === null) return null
7
8 return failure.file.startsWith('/') ? failure.file : `${root}/${failure.file}`
9}
10
11export function placeOf(failure: Failure): string {
12 return failure.line === null ? (failure.file ?? '') : `${failure.file}:${failure.line}`
13}
14
15// The commands tried in order: the configured one alone, else VS Code at the line, then the OS opener.
16export function openCandidates(template: string, target: OpenTarget): string[][] {
17 const line = String(target.line ?? 1)
18 if (template.trim() !== '') return [template.trim().split(/\s+/).map(arg => arg.replace('{file}', target.path).replace('{line}', line))]
19
20 return [['code', '-g', `${target.path}:${line}`], ['open', target.path], ['xdg-open', target.path]]
21}
22hooks/options.ts 9 lines1import type { PluginOptions } from 'claude-code'
2
3// The commands the person set in the plugin's options; an empty one means "detect it here".
4export type Overrides = { test: string; lint: string; open: string }
5
6export function overridesOf(options: PluginOptions): Overrides {
7 return { test: String(options.testCommand ?? ''), lint: String(options.lintCommand ?? ''), open: String(options.openCommand ?? '') }
8}
9hooks/parse.ts 157 lines1import type { CheckOutcome, Failure, TestBadge } from '../types'
2import { testFailures } from './failures'
3import { lintFailures } from './lint'
4import { clean } from './text'
5import type { ParseContext } from './text'
6import { words } from './words'
7
8type Counts = { passed: number; failed: number }
9type Extract = (text: string) => Counts | null
10
11const TAIL_LINES = 6
12const SECOND = 1000
13const MINUTE = 60 * SECOND
14const HOUR = 60 * MINUTE
15
16const num = (text: string, re: RegExp): number => Number(re.exec(text)?.[1] ?? 0)
17
18const cargo: Extract = text => {
19 const lines = [...text.matchAll(/test result: \w+\. (\d+) passed; (\d+) failed/g)]
20 if (lines.length === 0) return null
21
22 return lines.reduce((sum, m) => ({ passed: sum.passed + Number(m[1]), failed: sum.failed + Number(m[2]) }), { passed: 0, failed: 0 })
23}
24
25// jest "Tests: 1 failed, 41 passed, 42 total", vitest "Tests 1 failed | 41 passed (42)"
26const jestLike: Extract = text => {
27 const line = text.split('\n').reverse().find(row => /^\s*Tests:?\s/.test(row) && /\d+ (passed|failed)/.test(row))
28
29 return line === undefined ? null : { passed: num(line, /(\d+) passed/), failed: num(line, /(\d+) failed/) }
30}
31
32// pytest "41 passed, 1 failed in 0.12s", with or without the === frame
33const pytest: Extract = text => {
34 const line = text.split('\n').reverse().find(row => /\d+ (passed|failed)/.test(row) && /\bin \d+(\.\d+)?s\b/.test(row))
35
36 return line === undefined ? null : { passed: num(line, /(\d+) passed/), failed: num(line, /(\d+) failed/) + num(line, /(\d+) errors?/) }
37}
38
39const mocha: Extract = text => (/\d+ passing/.test(text) ? { passed: num(text, /(\d+) passing/), failed: num(text, /(\d+) failing/) } : null)
40
41// node:test "# pass 42" / "ℹ pass 42", bun "42 pass" on its own line
42const tap: Extract = text => {
43 const pass = /^(?:#|ℹ)\s*pass (\d+)$|^\s*(\d+) pass$/m.exec(text)
44 if (pass === null) return null
45 const fail = /^(?:#|ℹ)\s*fail (\d+)$|^\s*(\d+) fail$/m.exec(text)
46
47 return { passed: Number(pass[1] ?? pass[2]), failed: Number(fail?.[1] ?? fail?.[2] ?? 0) }
48}
49
50const phpunit: Extract = text => {
51 const ok = /OK \((\d+) tests?/.exec(text)
52 if (ok !== null) return { passed: Number(ok[1]), failed: 0 }
53 const line = /Tests: (\d+),.*$/m.exec(text)
54 if (line === null) return null
55 const failed = num(line[0], /Failures: (\d+)/) + num(line[0], /Errors: (\d+)/)
56
57 return { passed: Number(line[1]) - failed, failed }
58}
59
60// go test counts packages: "ok pkg 0.1s" and "FAIL\tpkg\t0.1s"
61const goPackages: Extract = text => {
62 const passed = (text.match(/^ok\s+\S+/gm) ?? []).length
63 const failed = (text.match(/^FAIL\s+\S+/gm) ?? []).length
64
65 return passed + failed === 0 ? null : { passed, failed }
66}
67
68// flutter test / dart test: the last "00:03 +41 -1:" progress line holds the totals.
69const dart: Extract = text => {
70 const line = [...text.matchAll(/^\d+:\d+ \+(\d+)(?: ~\d+)?(?: -(\d+))?:/gm)].at(-1)
71
72 return line === undefined ? null : { passed: Number(line[1]), failed: Number(line[2] ?? 0) }
73}
74
75const EXTRACTORS = [cargo, jestLike, pytest, dart, mocha, tap, phpunit, goPackages]
76
77// The last lines worth reading, for a failure no parser could place.
78function tailOf(text: string, isFailing: boolean, failures: Failure[]): string[] {
79 if (!isFailing || failures.length > 0) return []
80
81 return text.split('\n').map(line => line.trimEnd()).filter(line => line.trim() !== '').slice(-TAIL_LINES)
82}
83
84export function parseTests(output: string, exitCode: number, ctx: ParseContext): CheckOutcome {
85 const text = clean(output)
86 const counts = EXTRACTORS.reduce<Counts | null>((found, extract) => found ?? extract(text), null)
87 const status = exitCode === 0 ? 'pass' : 'fail'
88 const failures = status === 'fail' ? testFailures(text, ctx) : []
89 const tail = tailOf(text, status === 'fail', failures)
90 if (counts === null) return { status, passed: null, total: null, errors: null, failures, tail }
91
92 return { status, passed: counts.passed, total: counts.passed + counts.failed, errors: null, failures, tail }
93}
94
95// No count printed: a clean exit means none, a failing one leaves the count unknown.
96function countOf(match: RegExpExecArray | null, exitCode: number): number | null {
97 if (match !== null) return Number(match[1])
98
99 return exitCode === 0 ? 0 : null
100}
101
102// eslint "✖ 3 problems (2 errors, 1 warning)", tsc "Found 2 errors", phpstan "[ERROR] Found 2 errors"
103export function parseLint(output: string, exitCode: number, ctx: ParseContext): CheckOutcome {
104 const text = clean(output)
105 const failures = lintFailures(text, ctx)
106 const match = /\((\d+) errors?/.exec(text) ?? /Found (\d+) errors?/.exec(text) ?? /^(\d+) errors?\b/m.exec(text)
107 const counted = countOf(match, exitCode)
108 const errors = failures.length > 0 ? Math.max(failures.length, counted ?? 0) : counted
109 const status = exitCode === 0 && (errors ?? 0) === 0 ? 'pass' : 'fail'
110
111 return { status, passed: null, total: null, errors, failures, tail: tailOf(text, status === 'fail', failures) }
112}
113
114export function ago(ms: number): string {
115 const { ago: says } = words()
116 if (ms < MINUTE) return says.s(Math.max(0, Math.round(ms / SECOND)))
117 if (ms < HOUR) return says.m(Math.round(ms / MINUTE))
118
119 return says.h(Math.round(ms / HOUR))
120}
121
122function testsText(outcome: CheckOutcome): string {
123 const says = words()
124 if (outcome.status === 'timeout') return says.testsTimeout
125 if (outcome.status === 'error') return says.testsNotStarted
126 if (outcome.total !== null) return says.testsCount(outcome.passed, outcome.total)
127
128 return outcome.status === 'pass' ? says.testsPassed : says.testsFailed
129}
130
131function lintText(outcome: CheckOutcome): string {
132 const says = words()
133 if (outcome.status === 'timeout') return says.lintTimeout
134 if (outcome.status === 'error') return says.lintNotStarted
135 if (outcome.status === 'pass') return says.lintClean
136
137 return outcome.errors !== null && outcome.errors > 0 ? says.lintErrors(outcome.errors) : says.lintFailed
138}
139
140export function badgeText(badge: TestBadge, now: number): string | undefined {
141 const parts: string[] = []
142 if (badge.tests !== null) parts.push(testsText(badge.tests))
143 if (badge.lint !== null) parts.push(lintText(badge.lint))
144 if (badge.isRunning) return parts.length === 0 ? words().testsRunning : `${parts.join(' · ')} · ${words().runningTail}`
145 if (parts.length === 0 || badge.finishedAt === null) return undefined
146
147 return `${parts.join(' · ')} · ${ago(now - badge.finishedAt)}`
148}
149
150export function isFailing(badge: TestBadge): boolean {
151 return [badge.tests, badge.lint].some(outcome => outcome !== null && outcome.status !== 'pass')
152}
153
154export function failuresOf(badge: TestBadge): Failure[] {
155 return [...(badge.tests?.failures ?? []), ...(badge.lint?.failures ?? [])]
156}
157hooks/text.ts 71 lines1import type { Failure, Suite } from '../types'
2
3// Where the run happened, so paths can be made relative and Go packages mapped to folders.
4export type ParseContext = { root: string; goModule: string | null }
5
6export const ANSI = /\u001b\[[0-9;?]*[A-Za-z]/g
7export const MAX_FAILURES = 50
8const MESSAGE_MAX = 160
9const E2E_PATH = /(^|\/)(e2e|integration_test|integration-tests|cypress|playwright)(\/|$)|\.e2e\.|\.cy\.|\.pw\./
10
11export const clean = (output: string): string => output.replace(ANSI, '').replace(/\r/g, '')
12
13export function firstLine(text: string): string {
14 const line = text.trim().split('\n')[0]?.trim() ?? ''
15
16 return line.length > MESSAGE_MAX ? `${line.slice(0, MESSAGE_MAX - 1)}…` : line
17}
18
19export function relative(path: string, ctx: ParseContext): string {
20 const bare = path.replace(/^file:\/\//, '').replace(/^\.\//, '')
21 const prefix = `${ctx.root.replace(/\/$/, '')}/`
22
23 return bare.startsWith(prefix) ? bare.slice(prefix.length) : bare
24}
25
26export function testSuite(file: string | null): Suite {
27 return file !== null && E2E_PATH.test(file) ? 'e2e' : 'unit'
28}
29
30type Spec = { file: string | null; line: number | null; name: string; message: string; suite?: Suite; durationMs?: number | null }
31
32export function failure(spec: Spec, ctx: ParseContext): Failure {
33 const file = spec.file === null ? null : relative(spec.file, ctx)
34
35 return { suite: spec.suite ?? testSuite(file), file, line: spec.line, name: firstLine(spec.name), message: firstLine(spec.message), durationMs: spec.durationMs ?? null }
36}
37
38// Splits lines into sections, each opened by a line `start` matches and running to the next one.
39export function sections(lines: string[], start: RegExp): { head: RegExpExecArray; body: string[] }[] {
40 const out: { head: RegExpExecArray; body: string[] }[] = []
41 for (const line of lines) {
42 const head = start.exec(line)
43 if (head !== null) out.push({ head, body: [] })
44 else out.at(-1)?.body.push(line)
45 }
46
47 return out
48}
49
50export const firstText = (body: string[]): string => body.find(line => line.trim() !== '')?.trim() ?? ''
51
52export function firstMatch(body: string[], re: RegExp): RegExpExecArray | null {
53 for (const line of body) {
54 const found = re.exec(line)
55 if (found !== null) return found
56 }
57
58 return null
59}
60
61export const lineNumber = (text: string | undefined): number | null => (text === undefined ? null : Number(text))
62
63const MS_PER_SECOND = 1000
64
65// "12" with "ms" or "0.40" with "s": a runner's per-test time in milliseconds.
66export function durationOf(value: string | undefined, unit: string | undefined): number | null {
67 if (value === undefined) return null
68
69 return unit === 's' ? Number(value) * MS_PER_SECOND : Number(value)
70}
71hooks/home.tsx 33 lines1// MODZ kit — the way back to the hub from a mod's pane (DESIGN.md §9). While the hub runs it keeps
2// `{ plugin: 'modz', key: 'isOn' }` true; a pane then draws `HomeButton` first, and its press writes the mod's
3// own `{ plugin: '<mod>', key: 'home' }` value (`{ at }`). The hub hears it, comes forward, and opens again if
4// it was closed, so the person always has a way back, even with one pane left and no tab to click.
5
6// The resolved element table of the surface that draws the pane.
7type Table = Record<string, any>
8
9export const HOME_LABEL = '← MODZ'
10
11export function HomeButton(ui: Table, onPress: () => void) {
12 const { Box, Button } = ui
13
14 return (
15 <Box key="modz-home-row" flexDirection="row">
16 <Button key="modz-home" label={HOME_LABEL} variant="secondary" onPress={onPress} />
17 </Box>
18 )
19}
20
21// The pane's tree with the way back above it; null while the hub is off draws the pane as it is.
22export function withHome<T>(ui: Table, tree: T, onHome: (() => void) | null) {
23 if (onHome === null) return tree
24 const { Box } = ui
25
26 return (
27 <Box flexDirection="column" rowGap={1}>
28 {HomeButton(ui, onHome)}
29 {tree}
30 </Box>
31 )
32}
33hooks/stage.ts 30 lines1// MODZ kit — one panel (DESIGN.md §9). The hub and every mod's pane take turns in one place instead of
2// piling up as tabs: a mod that opens its pane writes its own `{ plugin: '<mod>', key: 'shown' }` (`{ pane, at }`);
3// the hub then steps aside and writes `{ plugin: 'modz', key: 'stage' }` naming that pane, and every other mod
4// whose pane is open closes it. The hub coming back (its row, "← MODZ", /modz) writes the stage as `modz`.
5
6export const HUB_PANE = 'modz'
7
8export type Stage = { pane: string; at: number }
9
10// The pane a `modz.open`, `modz.stage` or `shown` value names; null for anything malformed.
11export const paneOf = (value: unknown): string | null => {
12 if (typeof value !== 'object' || value === null) return null
13 const pane = (value as { pane?: unknown }).pane
14
15 return typeof pane === 'string' && pane !== '' ? pane : null
16}
17
18// Whether a `modz.open` write asks for this pane.
19export const isAskedFor = (value: unknown, pane: string): boolean => paneOf(value) === pane
20
21// Whether a `modz.stage` write puts another pane in the panel, so this one closes.
22export function isOffStage(value: unknown, pane: string): boolean {
23 const staged = paneOf(value)
24
25 return staged !== null && staged !== pane
26}
27
28// What a mod writes under its own `shown` key once its pane is open.
29export const shownOf = (pane: string, at: number): Stage => ({ pane, at })
30