SLOPSHOPPER

test-badge

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)…

newpanebandguardcommandtoast
★ 2v?MITupdated 2026-10-16partalemre/MODZ/mods/test-badge
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · test-badge
│ ┃ test-badge ✕ › fix the failing auth test and add an audit log call │ ┃ ● TESTS │ ┃ No test run yet · runs by itself when a ⏺ Read(src/auth.ts) │ ┃ source file changes ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ [ Run now ] ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /test-badge │ ⎿ test-badge: test-badge off │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · test-badge
● TESTS No test run yet · runs by itself when a source file changes [ Run now ]
README

test-badge

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.

Install

/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.

What you see

[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
  • Status line: the badge stays there and its "ago" refreshes every 15 seconds.
  • Notice above the prompt: the result for 20 seconds after each run, with Details (t) when something failed, and a close mark. A live notice from session-budget takes that line first.
  • Details pane (Details, or /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.

When it runs

  • Only after a turn in which 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.
  • Five seconds after the turn ends; quick turns in a row give one run.
  • One run at a time; a run requested during one waits and runs once afterwards.
  • Each command runs under 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.

CI watch

It also follows your pull request's CI on GitHub, through your own gh CLI:

  • When it starts: after a Bash call ran 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.
  • How it polls: 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.
  • Tests pane: a CI section with the PR number and title, a summary such as 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.
  • MODZ panel: the line reads like CI 3/4 · 1 failing, beside the test result when both fit in 40 characters.
  • When CI finishes (only a finish it saw happen, not one already over when the watch began): one notification through your terminal's notification channel (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.

Detection (first match in the project root)

FoundTestLint
package.json scripts.test / scripts.lint (npm's placeholder test is ignored)npm run --silent test; pnpm, yarn or bun run when that lockfile existsthe same with lint
pytest.ini, conftest.py, or pytest in pyproject.toml / setup.cfg / tox.inipython3 -m pytest -q
go.modgo test ./...
Cargo.tomlcargo test -q
phpunit.xml(.dist)vendor/bin/phpunit (or phpunit)
pubspec.yaml and a test/ folderflutter 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.

Commands

CommandEffect
`/test-badge [on\off]`Turns it on or off; saved across sessions
/test-badge runRuns tests and lint now
/test-badge detailsOpens the details pane

Settings

OptionDefaultMeaning
testCommandemptyOverrides the detected test command, run with /bin/sh -c in the project root. Empty means auto-detect.
lintCommandemptyOverrides the detected lint command, run the same way.
openCommandemptyHow 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.

Language

The interface is English. Set MODZ_LANG=tr in the env block of ~/.claude/settings.json for Turkish.

Source 35 files
hooks/register.tsx 285 lines
1import { 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}
285
hooks/detect.ts 83 lines
1export 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}
83
hooks/band-hook.tsx 105 lines
1import { 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}
105
hooks/ci.ts 160 lines
1import { 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}
160
hooks/hub.ts 77 lines
1import 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}
77
hooks/limits.ts 28 lines
1import 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
28
hooks/open.ts 22 lines
1import 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}
22
hooks/options.ts 9 lines
1import 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}
9
hooks/parse.ts 157 lines
1import 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}
157
hooks/text.ts 71 lines
1import 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}
71
hooks/home.tsx 33 lines
1// 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}
33
hooks/stage.ts 30 lines
1// 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