SLOPSHOPPER

mutation-tap

/mutate runs a mutation test limited to the lines changed since HEAD: each small change to the code (a flipped comparison, && for ||, a constant) is run…

newpanecommandtoaststatusprocess
★ 1v0.1.2MITupdated 2026-10-16simota/claude-mods/mods/mutation-tap
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mutation-tap
│ ┃ ミューテーション ✕ › fix the failing auth test and add an audit log call │ ┃ 準備中… │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /mutate │ ⎿ mutation-tap: 🧬 ミューテーションテストを開始しました。リポジトリの複製で実行するので、作業ツリーは書き換えません │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · ミューテーション
準備中…
README

claude-mods

English | 日本語

Personal Claude Code mods (function-hook plugins), published as one marketplace.

ModWhat it does
agent-paneLive pane of the session's subagents: running ones with elapsed time, finished ones with result and duration
done-gateFlags unverified edits, stops repeated failing commands, and blocks git --no-verify
sandbox-lensTallies sandbox denials seen in Bash output and reports them with /sandbox-report
skill-hintSuggests up to three matching skills above the prompt from what was typed; never invokes them.
todo-agentCollects #TODO(agent) follow-ups from Claude's replies into a per-project list with a /todos pane.
context-meterBand above the prompt with a Gruvbox sparkline of context use per turn, plus tokens and cost
tool-rowRedraws finished tool-call rows in the terminal as one compact line with a Nerd Font icon, Gruvbox colors and a short stat
image-previewThumbnails of the images a session writes, reads or generates: under the tool's row in the terminal, and in an /images pane
self-auditAfter each turn, a cached fork of the conversation audits Claude's own reply against the CLAUDE.md reporting rules and flags violations above the prompt
context-dietTrims the skill listing to what the project kind needs (off by default); /context-diet shows what was dropped and the tokens saved
browser-camLive pane of the browser Claude drives: the latest screenshot from Claude in Chrome, the page URL and the recent actions
prompt-coachHolds back a typed prompt that looks ambiguous (no done-condition, unclear target) and offers guesses at what it meant, sending it as is, or adding one; on by default
loopbackFeeds what self-audit, done-gate and sandbox-lens found back to Claude beside the next prompt, instead of only showing it to the person, skipping rules hit-rate found mostly wrong; off by default
zukanError book: catches the errors Bash commands hit across sessions and repos, records what got past each one, and when one comes back tells Claude and the person how it was fixed last time
pr-lampThe current branch's pull request as a lamp: checks, review, unresolved threads and what is left before a merge, in the status line and a /pr pane; red checks and new review comments go to Claude beside the next prompt
fog-mapExploration map: the repository as a tree under fog, cleared where Claude read and marked where it edited, made a file or hit an error, with the importers an edit may have broken unseen; /map
assertion-guardCatches edits that weaken tests to pass: assertions removed, expectations loosened or rewritten, tests skipped, errors swallowed; asks Claude for the reason beside the edit
negative-spaceWhen an edit adds authorization, validation or error handling, lists the refusal and boundary-value tests no test touched this session covers; /negative-space
mutation-tap/mutate runs a mutation test limited to the lines changed since HEAD and lists the mutants the tests did not catch
perf-delta/perf compares a benchmark before and after (a saved baseline, or a git ref in a temporary worktree): median time, peak memory, spread, and whether the change beats the noise
handoff-capsuleBefore a compaction and at session end, keeps the goal, decisions, open items, commands to reproduce and files touched; the next session picks which parts to bring back
port-lanternThe dev servers the session started: URL, port, whether each still listens, and their latest log lines, in the status line and /ports
artifact-shelfA shelf of what sessions made (CSV, PDF, HTML, video, patches…), tied to the call and request that made each; /shelf searches by kind, word and time
focus-hudOne status line: goal, target, what is running and for how long, and what the session waits on; /focus
side-effect-ledgerA ledger of what a git diff never shows (push, publish, deploy, PRs, API writes, messages) with outcome, how to undo, and whether the undo was checked; /ledger
trust-labelLabels external text (web, issues, logs, browser pages, services) with its source beside the result and flags candidate injected instructions; a heuristic, never a safety verdict; /trust
visual-diffCompares browser screenshots of the same URL and size before and after an edit: side by side, a map of changed cells, regions, and the edits in between; /vdiff
journey-traceRecords Claude's browser actions and the expected states as a replayable recipe; /journey md for a bug report, /journey e2e for a Playwright draft
hit-rateGives each finding of done-gate, assertion-guard, self-audit and zukan a later verdict from what the session did next, keeps each rule's precision across sessions, and names the rules loopback stops feeding; /hit-rate; off by default
token-lensChecks an element's actual colours, spacing and type against the project's design tokens and lists off-token values with the nearest token; /token-lens <selector>

Install

At the prompt of a terminal session:

/plugin install <mod> --marketplace simota/claude-mods

From a shell:

claude plugin install <mod> --marketplace simota/claude-mods

This repository is private: installing needs read access on GitHub and a git credential for github.com on the machine (an SSH key, or gh auth login then gh auth setup-git).

Usage

Options are set with /plugin configure <mod>@simota-mods in a session, or from a shell:

echo '{"enabled":"true"}' | claude plugin configure <mod>@simota-mods --values-stdin

A change made with /plugin configure reloads the mod; one made from a shell applies after a restart. Bands above the prompt stack: each mod draws its row above what the others draw.

agent-pane

  • /agent-pane opens the pane: running subagents first with elapsed time, then finished ones with ✓/✗ and duration; a background run that was stopped shows ■ 停止. A background run that left the roster without a result shows ? 結果不明; a teammate or remote launch shows ↗ 起動済み(追跡外).
  • In a fullscreen terminal the pane opens by itself once per session, on the first Agent call (a reload does not reopen it).
  • The status line shows agents: N running while any run.

done-gate

Works on its own, no command:

  • A turn that edited files without a passing test, lint, typecheck or build afterwards leaves a band: 未検証の変更があります. It stays until a check succeeds or 閉じる is pressed. Only the program a command runs counts (ls build, npm ci are not checks), and only when its exit status is the command's: the check is last, or only && follows it (npm test | tail does not count).
  • The second identical failure of the same Bash command tells Claude to stop retrying and diagnose the root cause (again after the command has succeeded in between).
  • Any git command with --no-verify (or a prefix of it from --no-ver), and git commit -n / -nm, is denied, also inside subshells, $(…), backticks, bash -c "…", eval and wrappers such as sudo/timeout. The value of an option (-m "…"), a comment or a heredoc never triggers it. So is a git command that moves its hooks for the run (-c core.hooksPath=…, --config-env core.hooksPath=…) or carries a hook manager's off switch in its environment (HUSKY=0, LEFTHOOK=0, pre-commit's SKIP=…), or moves the hooks through config in its environment (GIT_CONFIG_KEY_n=core.hooksPath, GIT_CONFIG_PARAMETERS). ANSI-C quoting ($'--no-\x76erify'), env -S "…", bash -c -- '…' and a shell reading its script from stdin (bash <<< '…', a heredoc, echo '…' | sh) are read through. It is a best-effort guard: git aliases (git ci -n), a hook path written into config beforehand and an environment exported by an earlier command are not detected.

sandbox-lens

  • /sandbox-report lists the sandbox denials seen in Bash output, for this session and all time: denied hosts and paths, counts, last seen, an example command, and which setting would allow each.
  • /sandbox-report reset clears the record.
  • Hints only: it never changes sandbox settings. A loopback or private address keeps its port in the hint (127.0.0.1:3000), since an entry without a port opens every port.
  • A <sandbox_violations> block, and Operation not permitted lines a program printed about itself, are read. For a command that succeeded or was interrupted, the block is taken from where the engine puts it, apart from the command's own output, so one inside a shown file or page is ignored even when it ends the output. A failed command keeps only its error text: the block that ends it (before an agent-proxy note) is read, and there a block the command printed itself cannot be told from the harness's. Credentials in the example command (tokens, passwords, Authorization/Cookie and other credential headers such as X-API-Key, URL userinfo, keys in env assignments including run-together names such as PGPASSWORD, known key shapes) are masked. Each section shows the top 10; when the record is full (200), the least recently seen entry gives way.

skill-hint

After a prompt is sent, up to three skills matching it show above the prompt (💡 skill候補: /coding-debug …), only ones that exist in the session. Nothing is invoked; 閉じる or the next prompt (a slash command included) clears it. A prompt that already names a skill (/name anywhere, or a hyphenated skill name such as coding-debug) gets no hint; built-in commands such as /clear and path segments such as src/review/ do not count as naming one. Plugin skills (plugin:name) are suggested by their full name.

todo-agent

  • Lines starting with #TODO(agent): <action> (after an optional quote or list bullet) in Claude's main-loop replies are collected per project (repo root or cwd). A mention inside a sentence, a table or inline code is not a TODO; subagent replies are ignored.
  • /todos opens the pane of this project's open items, each with a 完了 button.
  • /todos all lists every project's open items as text, marked as stored data.
  • The status line shows TODO(agent): N while the project has open items.

context-meter

A band with a sparkline of context use per turn and ctx 63% ▲4 · 126k · turn in 120k / out 8k · $1.23: the context size of the last request, then the whole turn's input/output (shown when the turn reported usage). The percentage turns yellow from 50% and red from 80%.

OptionDefault
bandtrueDraw the band (samples are recorded either way)

tool-row

Finished tool calls are drawn as one compact line with a Nerd Font icon: Edit/Write path +A −D, Read with line count, Bash (red on error), Agent, web and MCP calls. The result block under a compacted row is hidden, so a call is one line; an errored result stays. A row another mod decorated (an image-preview thumbnail) is left as it is, result included. Runs Claude Code folds into one count line (Read 3 files, ran 2 shell commands) stay folded. Terminal only.

OptionDefault
compacttrueOff leaves every row and result as Claude Code draws them

image-preview

  • Images a session writes, reads, or that a Bash command produces (png, jpg, jpeg, webp, gif) get a thumbnail under that tool's row in the terminal (kitty graphics: Ghostty, kitty).
  • /images opens a pane, newest first; h/l step through them.
  • Non-PNG files are converted with macOS sips after the tool result has gone back, so tool calls never wait on it. Converted files live in a private cache ($TMPDIR or ~/.cache), newest 50 kept. A killed session's folder is removed by a later session's start once a day untouched; a running session touches its own every hour (again after a plugin reload), so it is kept while idle, though not across a day the machine sleeps.
  • A Bash command and its output are searched for image paths in time linear in their length, before the result goes back. A path split at spaces (/tmp/a b/x.jpg) joins at most 8 preceding pieces and 4096 characters; the first 64 paths named are read, and of those the first 4 that exist are shown.
OptionDefault
inlinetrueThumbnail under the tool row
maxRows12Rows a thumbnail may take, 1 to 60

self-audit

After each reply of at least minChars characters, one tool-less fork of the conversation (served mostly from the prompt cache), given the reply itself as quoted data, checks it against the CLAUDE.md rules: claims done without evidence, first line not the result, unverified assertions, bypassed checks, scope inflation. Only violations of the five rules whose quote is found in the reply are kept (a made-up quote or rule is dropped); the quote is compared past markdown marks, bullets, curly quotes, case, spacing and a closing full stop, and one shortened with … is found piece by piece. Violations show in red above the prompt; 詳細 or /self-audit opens the pane with quotes and the fork's token use. Results belong to the latest reply only: when it was not audited, the pane says why.

OptionDefault
enabledtrue
minChars200Shorter replies are not audited

Cost: one extra request per turn on the session's model, plus the reply text (up to 12,000 characters) as uncached input; a cold cache (after 5 minutes idle or a model switch) resends the whole conversation.

context-diet

Off by default. When on, the skill listing is trimmed to what the project kind needs (Swift, Android, Rust, web, Python, Go, detected from the repo root and one level below). Coding, quality, planning and research skills are always kept; red-team, dynamic-*, persona and operation skills are dropped; image, design, ux-scan and brand-scan are dropped for Rust, Python and Go. Only the main loop's listing is reported. A dropped skill named in a prompt is still described for that prompt.

  • /context-diet shows the project kind, what was kept and dropped in the latest listing, and the tokens saved (estimate). A listing it cannot read reliably goes out unchanged.
  • /context-diet on|off|reset overrides the option for this session. Switching rebuilds the listing, so the prompt cache misses once.
OptionDefault
enabledfalse
keepComma list of names or prefix* never dropped; wins over drop
dropComma list of names or prefix* always dropped

browser-cam

  • While Claude drives a browser (Claude in Chrome, Claude Browser, or a remote device's browser), a pane shows the latest screenshot, the page title and URL, and the last 8 actions with ✓/✗. The URL comes only from the navigate call or the tab tools' own header, never from page text. Text typed or filled in is shown as its length only (type ‹7文字›), since the field may be a password; a URL's secrets (token parameters, userinfo, key-shaped parts) are masked.
  • /cam toggles the pane; in a fullscreen terminal it opens by itself once per session, on the first browser call. Screenshots are decoded only while the pane is open, and written to a private per-session folder that is removed at session end. A killed session's folder is removed by a later session's start once a day untouched; a running session touches its own every hour (again after a plugin reload), so it is kept while idle, though not across a day the machine sleeps.
  • The desktop app shows the title and URL, a caption and the actions, without the picture.
OptionDefault
autoOpentrueOpen on the first browser call (fullscreen terminal)

prompt-coach

On by default (enabled: false turns it off). A typed prompt that looks ambiguous (no done-condition and an unclear target, or only evaluative words like いい感じ) is held back, and a band shows what is missing with up to three guesses at what it meant (judged along the lines of the clarify skill: the likeliest reading first, each a different deliverable with a checkable done-condition):

  • a / b / d: a guess goes into the prompt to edit and send (not held again)
  • s: このまま送る sends it unchanged
  • e: 補足して送る puts it back with 完了条件: to fill in (not held again)
  • q: 閉じる puts it back in the prompt

Typing another prompt instead sends that one and puts the held text back into the prompt. The held text is never lost: when a send fails or is dropped beneath, or putting words back fails, it stays held with its band (unless another prompt was held since) and its words are shown in a toast; a prompt still held at /clear goes straight back into the box, and one held when the session quits comes back at this project's next interactive session start. Words go into an empty box as they are, and are appended after a draft typed meanwhile on a line of their own, never over it.

Slash commands, prompts under 12 or over 1500 characters, prompts with any attachment or an @ mention, prompts sent by a plugin, and prompts typed while a turn runs are not judged. A timeout or error sends the prompt unchanged; the same text is not held twice (within the last 50 judged). Sending can wait up to timeoutMs.

OptionDefault
enabledtrue
modelclaude-haiku-5-5Judging model (alias or id)
timeoutMs3000500 to 3000

loopback

Off by default. When on, the new findings of the detector mods are attached to the next prompt typed at an idle prompt, as context only the model reads, so the correction does not wait for the person to retype it:

  • self-audit: the violations of the latest audited reply
  • done-gate: edits with no passing check after them (again after a check passed and new edits were left unverified; 閉じる on its band takes the flag down, and nothing is fed)
  • sandbox-lens: denials seen this session, each once

Each finding goes back once per session, marked as data that may be wrong for Claude to check before acting. A toast says how many went back and from where. Slash commands, hidden prompts, prompts sent by a plugin and prompts typed while a turn runs carry nothing. A detector that is not installed contributes nothing.

With hit-rate on, the rules it muted are not fed (sandbox-lens denials never are muted).

  • /loopback shows what was fed last and the self-audit repeat rate: of the rules fed back, how many self-audit flagged again on the next audited reply (all sessions). The rate is what the design is judged by (.genius/decisions/loopback.md). It also names the rules hit-rate muted.
OptionDefault
enabledfalse

Cost: the findings ride as extra input on the prompt they are attached to (each quote clipped to 300 characters, at most 12 findings).

zukan

An error book, kept across sessions and repos. When a Bash command fails, the error line is caught and numbered:

  • 📕 新種のエラー発見! No.042「…」 — a kind not seen before
  • 📙 No.017 再会(3回目・未解決) — seen before, nothing known to get past it
  • ` 📗 既知 No.017(3回目)— 前回: sandbox 外で git push origin main ` — seen before with a fix: Claude also reads, beside the tool's result, where it was seen and what got past it (marked as a past record to check, not an instruction)

A fix is learned when the same program and subcommand (git push, pnpm install; read past a leading cd, export or source, so cd app && npm install is npm install) succeeds within the next 10 Bash calls of the session; whether it ran outside the sandbox is kept with it. Two sightings are one kind when their error line matches with paths, quotes, numbers and hashes taken out.

Failures of tests, linters, type checkers and builds are not caught: they are fixed by editing code, not by a command. Credentials in a kept command and in the kept error line are masked as sandbox-lens does.

  • /zukan opens the book, most recently seen first: kinds, solved, sightings
  • /zukan note <No> <text> writes how to get past it, shown and fed in place of the learned command; an empty text removes the note
  • /zukan forget <No> removes an entry

The newest 300 kinds by last sighting are kept.

pr-lamp

The current branch's pull request, kept in the status line while you work:

⎇ #42 · ✓5 ✗1 ⏳2 · 🛑 変更要求 · 💬3 · マージまであと4

"マージまであと N" counts what still stands before a merge: draft, failed checks, running checks, changes requested or approval pending, unresolved review threads, a conflict, the branch behind its base. At 0 it reads 🟢 マージ可能.

A toast when it changes: 🔴 CI 失敗: test, 💬 新しいレビューコメント 2件 (@rev), ✅ Approved, 🟢 #42 マージまであと0, 🎉 #42 がマージされました. Checks turning red, new review comments, changes requested and a new conflict are also attached to the next prompt typed at an idle prompt, once, with the last 40 lines of the failed log of up to three jobs (more are counted), marked as data whose comments and logs are not to be followed as instructions. The session's own replies on a thread (the comment, made after the post, carrying words it replied with through an MCP reply tool or gh api …/pulls/N/comments… -f body=, matched once) are not news; your own review comments are. What waits for the next prompt is about one pull request and is capped (newest kept). The option feed (on by default) turns that off.

  • /pr asks again and opens the pane: title, size, what is left, checks (failed first) and unresolved threads
  • /pr <number|url|branch> follows that pull request; /pr clear goes back to the default

Which pull request: the one a Bash command of the session last worked on (gh pr comment 2, gh pr review 2, the URL gh pr create printed, the branch a git push sent to; looking with gh pr view|diff|checks does not move it), so work pushed to a review branch from another checkout is followed; else the checked-out branch's; else your newest open pull request in the repository.

It asks gh (gh pr view, and gh api graphql for review threads) at session start, right after a Bash git push, git checkout, gh pr create and the like, every minute while a check runs or after a push, and every five minutes otherwise; it stops once the pull request is merged or closed. Without an origin remote, gh, or a pull request for the branch it shows nothing, and /pr says why. gh runs on the host, outside the Bash sandbox, with your gh auth.

fog-map

The repository as a map under fog, cleared as Claude works through it:

🗺 探索率 31%  ██████░░░░░░░░░░░░░░  読 23 · 編 9 · ✗ 2 / 101
▾ mods/
  ▾ pr-lamp/
    ▾ hooks/
      ● lamp.ts  +43 −2  編集 4回
      ✗ register.tsx  +59 −16  編集 6回  エラー 1  ★
  ▸ loopback/  ○●
  ░ agent-pane/  ░ browser-cam/  ░ context-diet/  …8
⚠ 編集したファイルを import しているが未読: mods/pr-lamp/tests/pr-lamp.test.ts

░ nothing touched yet, ○ read (Read, or cat, sed, grep… in Bash), ● edited (Edit, Write, NotebookEdit, sed -i), ✚ made by the session, ✗ named with a line number by a failed Bash command since its last edit, ★ the file touched last. A directory holding an edit, a new file or an error is open; one only read is closed with its marks beside it; untouched directories and files at a level share one fog row.

Two warnings, each a likely oversight: a file edited without being read, and a file that imports an edited one (JS/TS and Python, found by git grep) that was never opened. A very large repository's file list is cut to what one state value holds (3 M characters of paths), so it is still mapped, partly.

  • /map opens the pane
  • The status line keeps 🗺 31% · ●9 · ✗2 · ⚠1
  • A toast at 25, 50, 75 and 100% explored, and when an edit first reaches a new area (🗺 新しい区域を開拓: mods/zukan/)

The map is the session's, subagents' calls included; files outside the repository ar

Source 3 files
hooks/register.tsx 314 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Mutant, MutationRun } from '../types'
5import { apply, changedLines, guessCommand, isMutable, mutantsOf, scoreLine, spread, tallyOf } from './mutate'
6
7const PANE = 'mutation-tap'
8const TITLE = 'ミューテーション'
9// The store is one file for every session of the user: each copy is kept with when its run last
10// moved, so a session starting removes only copies whose run went quiet, never one running.
11const COPIES_KEY = 'copies'
12// The key an earlier version kept its single copy under.
13const LEGACY_KEY = 'copy'
14const DIR_NAME = 'claude-mutation-tap'
15// Copying a large tree (dependencies included) may take a while.
16const COPY_TIMEOUT_MS = 600_000
17// A live run moves at least this often: the copy and each test run are held to 10 minutes.
18const QUIET_MS = 30 * 60_000
19// An earlier version's copy carries only when it was made, never when its run last moved.
20const LEGACY_MS = 3 * 60 * 60_000
21// What is read of files not yet added: a scratch script or a bundle never ignored is no reason to wait.
22const MAX_NEW_FILES = 20
23const MAX_NEW_BYTES = 200_000
24// A line as kept for the pane: a minified one would otherwise ride whole, twice per mutant, in state.
25const SHOWN = 300
26const shown = (one: Mutant): Mutant => ({ ...one, original: one.original.slice(0, SHOWN), mutated: one.mutated.slice(0, SHOWN) })
27// Written at the copy's root once it is whole; a copy being removed loses it first (the folder is
28// moved aside in one step before it is deleted), so its absence says the copy is no longer whole.
29const SENTINEL = '.claude-mutation-tap'
30
31type Copies = Record<string, { at: number }>
32
33const copiesOf = async ($: EngineInterface): Promise<Copies> => {
34  const value = await $.store.get(COPIES_KEY)
35  return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Copies) : {}
36}
37
38/** Marks a copy as in use now (or forgets it): read and written afresh, so other sessions' copies stay. */
39async function mark($: EngineInterface, dir: string, isGone = false): Promise<void> {
40  const { [dir]: _, ...others } = await copiesOf($)
41  await $.store.set(COPIES_KEY, isGone ? others : { ...others, [dir]: { at: await $.clock.now() } })
42}
43
44const run = atom({ plugin: 'mutation-tap', key: 'run' } as const, null as MutationRun | null)
45const isStopping = atom({ plugin: 'mutation-tap', key: 'isStopping' } as const, false)
46
47type Options = { max: number; timeoutMs: number; command: string }
48
49/** Changes the run in hand, if there is one. */
50const patch = ($: EngineInterface, fn: (old: MutationRun) => MutationRun) => update($, run, old => (old === null ? null : fn(old)))
51
52async function sh($: EngineInterface, root: string, argv: string[], timeoutMs = 30_000) {
53  return $.process.run(argv, { cwd: root, timeoutMs }).catch(() => undefined)
54}
55
56/**
57 * A copy of the repository to mutate, so the working tree is never touched: a copy-on-write clone
58 * where the file system has one (APFS `cp -c`, `cp --reflink`), a plain copy otherwise.
59 * Dependencies and build output come along, so the tests run as they do in the tree.
60 */
61async function makeCopy($: EngineInterface, root: string): Promise<string | undefined> {
62  const base = ((await $.env.get('TMPDIR')) || `${(await $.env.get('HOME')) ?? ''}/.cache`).replace(/\/+$/, '')
63  if (base === '' || base === '/.cache') return undefined
64  const dir = `${base}/${DIR_NAME}/${await $.clock.now()}`
65  // Kept first, so a reload or a crash mid-copy still removes it at a later start.
66  await mark($, dir)
67  const made = await $.process
68    .run(
69      [
70        '/bin/sh',
71        '-c',
72        // Compiled Python is dropped: a mutant of the same length written within one second would
73        // otherwise run the bytecode of the line before it.
74        'umask 077 && mkdir -p -- "$1" && [ -d "$1" ] && [ ! -L "$1" ] && [ -O "$1" ] && { cp -cR -- "$2"/. "$1"/ 2>/dev/null || cp -a --reflink=auto -- "$2"/. "$1"/; } && { find "$1" -name __pycache__ -type d -prune -exec rm -rf {} + 2>/dev/null; true; }',
75        'sh',
76        dir,
77        root,
78      ],
79      { timeoutMs: COPY_TIMEOUT_MS },
80    )
81    .catch(() => undefined)
82  if (made?.exitCode === 0 && (await $.fs.write(`${dir}/${SENTINEL}`, String(await $.clock.now())).then(() => true).catch(() => false))) {
83    await mark($, dir)
84    return dir
85  }
86  await removeCopy($, dir)
87  return undefined
88}
89
90/** Removes a copy this module made: only a folder under its own name, found again from the store. */
91async function removeCopy($: EngineInterface, dir: string): Promise<void> {
92  if (dir.includes(`/${DIR_NAME}/`) && !dir.includes('..')) await removeTree($, dir)
93  await mark($, dir, true)
94}
95
96/** Moves the folder aside in one step, then deletes it: a run in it sees it whole or not at all. */
97async function removeTree($: EngineInterface, dir: string): Promise<void> {
98  await $.process
99    .run(['/bin/sh', '-c', 'd="$1.removing.$$"; mv -- "$1" "$d" 2>/dev/null && rm -rf -- "$d"', 'sh', dir], { timeoutMs: 120_000 })
100    .catch(() => undefined)
101}
102
103/** Whether the copy is still whole: its root marker is there. */
104const isWhole = ($: EngineInterface, copy: string): Promise<boolean> => $.fs.exists(`${copy}/${SENTINEL}`).catch(() => false)
105
106async function testCommand($: EngineInterface, root: string, given: string): Promise<string | undefined> {
107  if (given.trim() !== '') return given.trim()
108  const names = new Set((await $.fs.list(root).catch(() => [])).map(entry => entry.name))
109  const text = (name: string) => (names.has(name) ? $.fs.read(`${root}/${name}`).then(String).catch(() => undefined) : Promise.resolve(undefined))
110  return guessCommand(names, await text('package.json'), await text('Makefile'))
111}
112
113/** One run of the tests: killed when they fail, timeout when they run past the limit. */
114async function runTests($: EngineInterface, root: string, command: string, timeoutMs: number): Promise<'pass' | 'fail' | 'timeout'> {
115  const ran = await $.process.run(['/bin/sh', '-c', `export PYTHONDONTWRITEBYTECODE=1; ${command}`], { cwd: root, timeoutMs }).catch(() => undefined)
116  if (ran === undefined) return 'timeout'
117  return ran.exitCode === 0 ? 'pass' : 'fail'
118}
119
120async function mutateAll($: EngineInterface, options: Options, given: string): Promise<void> {
121  const at = await $.clock.now()
122  const root = (await $.session.repo())?.root ?? (await $.session.cwd())
123  const fail = (note: string, command = '') =>
124    update($, run, () => ({ status: 'failed', command, root, startedAt: at, finishedAt: at, mutants: [], ran: 0, note }) as MutationRun)
125
126  // Against HEAD; in a repository with no commit yet, the working tree against the index.
127  // Paths as written, whatever the person's diff settings (mnemonicPrefix, noprefix, quotePath).
128  const gitDiff = (...rest: string[]) =>
129    sh($, root, ['git', '-c', 'core.quotePath=false', 'diff', '-U0', '--no-color', '--no-ext-diff', '--src-prefix=a/', '--dst-prefix=b/', ...rest])
130  const head = await gitDiff('HEAD')
131  const diff = head?.exitCode === 0 ? head : await gitDiff()
132  if (diff === undefined || diff.exitCode !== 0) return void (await fail('git diff を取れませんでした(git リポジトリの外?)'))
133  // A new file not yet added is changed whole: an empty list stands for every line.
134  const untracked = await sh($, root, ['git', 'ls-files', '--others', '--exclude-standard', '-z'])
135  const added: [string, number[]][] = (untracked?.exitCode === 0 ? untracked.stdout.split('\0') : [])
136    .filter(path => path !== '' && isMutable(path))
137    .slice(0, MAX_NEW_FILES)
138    .map(path => [path, []])
139  const changed = [...changedLines(diff.stdout), ...added].filter(([path]) => isMutable(path))
140  if (changed.length === 0) return void (await fail('HEAD からの変更行に、改変できるソースの行がありません(テストファイル・コメント・import は対象外)'))
141
142  const command = await testCommand($, root, given || options.command)
143  if (command === undefined) return void (await fail('テストコマンドが分かりません。/mutate <コマンド> で指定するか、testCommand を設定してください'))
144
145  await update($, isStopping, () => false)
146  await update($, run, (): MutationRun => ({ status: 'running', command, root, startedAt: at, mutants: [], ran: 0, note: 'リポジトリの複製を作成中' }))
147  $.ui.status('🧬 複製を作成中')
148  const copy = await makeCopy($, root)
149  if (copy === undefined) {
150    $.ui.status(undefined)
151    return void (await fail('リポジトリの複製を作れませんでした(TMPDIR の空き容量を確認してください)', command))
152  }
153
154  try {
155    // The mutants come from the copy, so they match the files they are written into.
156    const byLine: Mutant[][] = []
157    for (const [path, lines] of changed) {
158      const text = await $.fs.read(`${copy}/${path}`).then(String).catch(() => undefined)
159      if (text === undefined || (lines.length === 0 && text.length > MAX_NEW_BYTES)) continue
160      const all = text.split('\n')
161      for (const n of lines.length > 0 ? lines : all.map((_, i) => i + 1)) byLine.push(mutantsOf(path, n, all[n - 1] ?? ''))
162    }
163    const mutants = spread(byLine.filter(list => list.length > 0), options.max)
164    if (mutants.length === 0) return void (await fail('HEAD からの変更行に、改変できるソースの行がありません(テストファイル・コメント・import は対象外)', command))
165
166    await patch($, old => ({ ...old, mutants: mutants.map(shown), note: '変更前のテストを実行中' }))
167    $.ui.status('🧬 ベースライン実行中')
168    const baseline = await runTests($, copy, command, options.timeoutMs)
169    if (baseline !== 'pass') {
170      return void (await fail(baseline === 'timeout' ? 'テストが時間内に終わりませんでした(timeoutSec を延ばしてください)' : '改変前のテストが既に失敗しています。通る状態にしてから実行してください', command))
171    }
172
173    for (const [index, mutant] of mutants.entries()) {
174      if (await read($, isStopping)) {
175        const now = await $.clock.now()
176        await patch($, old => ({ ...old, status: 'stopped', finishedAt: now, note: '停止しました' }))
177        return
178      }
179      const file = `${copy}/${mutant.path}`
180      await mark($, copy)
181      // Tests fail in a copy taken away from under them: that is no mutant caught.
182      const gone = () => fail('実行中にリポジトリの複製が消えました(別の処理が削除した可能性)。結果は数えていません', command)
183      if (!(await isWhole($, copy))) return void (await gone())
184      const original = await $.fs.read(file).then(String).catch(() => undefined)
185      if (original === undefined) return void (await gone())
186      const mutated = apply(original, mutant)
187      let outcome: Mutant['outcome'] = 'skipped'
188      if (mutated !== undefined) {
189        await $.fs.write(file, mutated)
190        let isThere = true
191        try {
192          const result = await runTests($, copy, command, options.timeoutMs)
193          isThere = await isWhole($, copy)
194          outcome = result === 'pass' ? 'survived' : result === 'fail' ? 'killed' : 'timeout'
195        } finally {
196          if (isThere) await $.fs.write(file, original)
197        }
198        if (!isThere) return void (await gone())
199      }
200      const done = await patch($, old => ({ ...old, ran: index + 1, mutants: old.mutants.map((one, i) => (i === index ? { ...one, outcome } : one)), note: undefined }))
201      const t = tallyOf(done?.mutants ?? [])
202      $.ui.status(`🧬 ${index + 1}/${mutants.length} · 生存 ${t.survived}`)
203    }
204
205    const end = await $.clock.now()
206    const all = tallyOf((await read($, run))?.mutants ?? [])
207    // Every mutant passing is more often the tests reading another tree than tests that check nothing.
208    const note = all.survived >= 2 && all.killed === 0 && all.timeout === 0
209      ? 'すべての改変が生存しました。テストが複製ではなく元のツリーを読んでいる可能性があります(editable install・絶対パスの参照・ビルド済みの dist)'
210      : undefined
211    const finished = await patch($, old => ({ ...old, status: 'done', finishedAt: end, note }))
212    const t = tallyOf(finished?.mutants ?? [])
213    $.ui.toast(t.survived > 0 ? `🧬 テストが見逃した改変 ${t.survived}件 — /mutate show` : `🧬 すべての改変をテストが検出(${scoreLine(t)})`)
214  } finally {
215    $.ui.status(undefined)
216    await removeCopy($, copy)
217  }
218}
219
220export const register: Register = (on, options) => {
221  const settings: Options = {
222    max: Math.max(1, Math.min(200, Number(options.maxMutants ?? 20) || 20)),
223    timeoutMs: Math.max(5, Math.min(600, Number(options.timeoutSec ?? 300) || 300)) * 1000,
224    command: typeof options.testCommand === 'string' ? options.testCommand : '',
225  }
226
227  on('session.start', async ($, e, next) => {
228    await $.command.register({
229      name: 'mutate',
230      description: 'HEAD からの変更行に限ったミューテーションテスト(/mutate [テストコマンド] · show · stop)',
231      argumentHint: '[テストコマンド | show | stop]',
232    })
233    const started = await next(e)
234    // A copy left behind by a run cut short (a reload, a crash) is removed once its run went
235    // quiet; one another session is running in is left alone.
236    const now = await $.clock.now()
237    for (const [dir, { at }] of Object.entries(await copiesOf($))) if (now - at > QUIET_MS) await removeCopy($, dir)
238    // An earlier version's copy, named by when it was made: removed once that is long past, else
239    // kept track of like this version's, so a run of that version still going is not cut short.
240    const legacy = await $.store.get(LEGACY_KEY)
241    if (typeof legacy === 'string') {
242      const made = Number(legacy.match(/\/(\d+)$/)?.[1] ?? 0)
243      if (legacy.includes(`/${DIR_NAME}/`) && !legacy.includes('..')) {
244        if (now - made > LEGACY_MS) await removeTree($, legacy)
245        else await $.store.set(COPIES_KEY, { ...(await copiesOf($)), [legacy]: { at: made + LEGACY_MS - QUIET_MS } })
246      }
247      await $.store.delete(LEGACY_KEY)
248    }
249    const last = await read($, run)
250    if (last?.status === 'running') await patch($, old => ({ ...old, status: 'stopped', note: '再読み込みで中断されました' }))
251    return started
252  })
253
254  on('command.run', { command: 'mutate' }, async ($, e) => {
255    const args = e.args.trim()
256    const current = await read($, run)
257    if (args === 'stop') {
258      if (current?.status !== 'running') return { text: '🧬 実行中のミューテーションはありません。' }
259      await update($, isStopping, () => true)
260      return { text: '🧬 今の改変が終わったら停止します。' }
261    }
262    if (args === 'show') {
263      await $.ui.open({ id: PANE, title: TITLE })
264      return { text: current === null ? '🧬 まだ実行していません。' : `🧬 ${scoreLine(tallyOf(current.mutants))}` }
265    }
266    if (current?.status === 'running') return { text: '🧬 実行中です。/mutate stop で止められます。' }
267
268    await update($, run, () => null)
269    $.clock.after(0, () => {
270      void mutateAll($, settings, args).catch(async err => {
271        $.ui.status(undefined)
272        await patch($, old => ({ ...old, status: 'failed', note: String(err) }))
273      })
274    })
275    await $.ui.open({ id: PANE, title: TITLE })
276    return { text: '🧬 ミューテーションテストを開始しました。リポジトリの複製で実行するので、作業ツリーは書き換えません。' }
277  })
278
279  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
280    const { Box, Text } = $.ui.resolve(e)
281    const shown = await read($, run)
282    if (shown === null) return <Text dimColor>準備中…</Text>
283
284    const t = tallyOf(shown.mutants)
285    const survivors = shown.mutants.filter(one => one.outcome === 'survived')
286    const room = Math.max(1, Math.floor(((e.props.scroll.bodyRows ?? 24) - 5) / 3))
287    const head =
288      shown.status === 'running'
289        ? `🧬 実行中 ${shown.ran}/${shown.mutants.length} · ${scoreLine(t)}`
290        : shown.status === 'done'
291          ? `🧬 完了 ${shown.mutants.length}件 · ${scoreLine(t)}`
292          : shown.status === 'stopped'
293            ? `🧬 停止 ${shown.ran}/${shown.mutants.length} · ${scoreLine(t)}`
294            : '🧬 実行できませんでした'
295    return (
296      <Box flexDirection="column">
297        <Text bold>{head}</Text>
298        {shown.command !== '' && <Text dimColor wrap="truncate-end">{`$ ${shown.command}`}</Text>}
299        {shown.note !== undefined && <Text color={shown.status === 'failed' ? 'red' : undefined}>{shown.note}</Text>}
300        {survivors.length > 0 && <Text color="yellow">テストが見逃した改変:</Text>}
301        {survivors.slice(0, room).map((one, index) => (
302          <Box key={`s-${index}`} flexDirection="column">
303            <Text wrap="truncate-end">{`✗ ${one.path}:${one.line}  ${one.operator}`}</Text>
304            <Text dimColor wrap="truncate-end">{`  - ${one.original}`}</Text>
305            <Text color="yellow" wrap="truncate-end">{`  + ${one.mutated}`}</Text>
306          </Box>
307        ))}
308        {survivors.length > room && <Text dimColor>{`…ほか ${survivors.length - room} 件`}</Text>}
309        {shown.status === 'done' && survivors.length === 0 && <Text color="green">見逃された改変はありません。</Text>}
310      </Box>
311    )
312  })
313}
314
hooks/mutate.ts 168 lines
1// Mutation, pure: which lines changed, the small changes worth making to them, and the
2// command that runs the project's tests.
3
4import type { Mutant } from '../types'
5
6const TEST_PATH = /(?:^|\/)(?:tests?|__tests__|spec|specs|testdata|fixtures?)\/|\.(?:test|spec)\.[cm]?[jt]sx?$|_test\.(?:go|py|rb|exs?)$|(?:^|\/)test_[^/]+\.py$|_spec\.rb$|Tests?\.(?:swift|kt|java|cs)$/
7const SOURCE = /\.(?:[cm]?[jt]sx?|py|rb|go|rs|swift|kt|java|cs|php|scala|c|cc|cpp|h|hpp)$/
8
9export const isMutable = (path: string): boolean => SOURCE.test(path) && !TEST_PATH.test(path) && !/\.d\.ts$/.test(path)
10
11/** The new-side line numbers each file's hunks touch, from `git diff -U0`. */
12export function changedLines(diff: string): Map<string, number[]> {
13  const out = new Map<string, number[]>()
14  let file: string | undefined
15  for (const line of diff.split('\n')) {
16    if (line.startsWith('+++ ')) {
17      const name = line.slice(4).trim()
18      file = name === '/dev/null' ? undefined : name.replace(/^b\//, '')
19      continue
20    }
21    const hunk = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/)
22    if (hunk === null || file === undefined) continue
23    const start = Number(hunk[1])
24    const count = hunk[2] === undefined ? 1 : Number(hunk[2])
25    const list = out.get(file) ?? []
26    for (let n = start; n < start + count; n += 1) list.push(n)
27    out.set(file, list)
28  }
29  return out
30}
31
32/** Which characters of a line are code: not inside a string literal or a trailing comment. */
33export function codeMask(line: string, hashComments: boolean): boolean[] {
34  const mask: boolean[] = new Array(line.length).fill(true)
35  let quote: string | undefined
36  for (let i = 0; i < line.length; i += 1) {
37    const ch = line[i] as string
38    if (quote !== undefined) {
39      mask[i] = false
40      if (ch === '\\') {
41        if (i + 1 < line.length) mask[i + 1] = false
42        i += 1
43      } else if (ch === quote) quote = undefined
44      continue
45    }
46    if (ch === '"' || ch === "'" || ch === '`') {
47      quote = ch
48      mask[i] = false
49      continue
50    }
51    if ((ch === '/' && line[i + 1] === '/') || (hashComments && ch === '#')) {
52      for (let j = i; j < line.length; j += 1) mask[j] = false
53      break
54    }
55  }
56  return mask
57}
58
59type Operator = { name: string; find: RegExp; to: string | ((found: string) => string) }
60
61// Comparisons and arithmetic only with spaces around them, so generics (`Array<T>`), arrows
62// (`=>`, `->`), JSX and unary signs are left alone.
63const OPERATORS: Operator[] = [
64  { name: '===→!==', find: / === /g, to: ' !== ' },
65  { name: '!==→===', find: / !== /g, to: ' === ' },
66  { name: '==→!=', find: / == /g, to: ' != ' },
67  { name: '!=→==', find: / != /g, to: ' == ' },
68  { name: '<=→<', find: / <= /g, to: ' < ' },
69  { name: '>=→>', find: / >= /g, to: ' > ' },
70  { name: '<→<=', find: / < /g, to: ' <= ' },
71  { name: '>→>=', find: / > /g, to: ' >= ' },
72  { name: '&&→||', find: / && /g, to: ' || ' },
73  { name: '||→&&', find: / \|\| /g, to: ' && ' },
74  { name: 'and→or', find: / and /g, to: ' or ' },
75  { name: 'or→and', find: / or /g, to: ' and ' },
76  { name: '+→-', find: / \+ /g, to: ' - ' },
77  { name: '-→+', find: / - /g, to: ' + ' },
78  { name: '*→/', find: / \* /g, to: ' / ' },
79  { name: 'true→false', find: /\btrue\b/g, to: 'false' },
80  { name: 'false→true', find: /\bfalse\b/g, to: 'true' },
81  { name: 'True→False', find: /\bTrue\b/g, to: 'False' },
82  { name: 'False→True', find: /\bFalse\b/g, to: 'True' },
83  { name: '!x→x', find: /!(?=[A-Za-z_($])/g, to: '' },
84  { name: 'n→n+1', find: /(?<![\w.$])\d+(?![\w.])/g, to: found => String(Number(found) + 1) },
85]
86
87const SKIP_LINE = /^(?:import\b|export\s+(?:\*|\{[^}]*\})\s+from\b|from\s+\S+\s+import\b|use\s|#include|package\s|require\(|\/\/|\/\*|\*|#(?!\[)|--|@)/
88
89/**
90 * Every mutant of one line: each operator at each place it applies in the code part of the
91 * line. Imports, comments and decorators make none.
92 */
93export function mutantsOf(path: string, line: number, text: string): Mutant[] {
94  const trimmed = text.trim()
95  if (trimmed === '' || SKIP_LINE.test(trimmed)) return []
96  const mask = codeMask(text, /\.(?:py|rb)$/.test(path))
97  const out: Mutant[] = []
98  for (const op of OPERATORS) {
99    for (const m of text.matchAll(op.find)) {
100      const at = m.index ?? 0
101      if (!mask.slice(at, at + m[0].length).every(Boolean)) continue
102      const to = typeof op.to === 'string' ? op.to : op.to(m[0])
103      const mutated = `${text.slice(0, at)}${to}${text.slice(at + m[0].length)}`
104      if (mutated !== text) out.push({ path, line, operator: op.name, original: trimmed, mutated: mutated.trim() })
105    }
106  }
107  return out
108}
109
110/** At most `max` mutants, taken round-robin across lines so every changed line gets one first. */
111export function spread(byLine: readonly Mutant[][], max: number): Mutant[] {
112  const picked: Mutant[] = []
113  const queues = byLine.map(list => [...list])
114  while (picked.length < max && queues.some(q => q.length > 0)) {
115    for (const q of queues) {
116      const one = q.shift()
117      if (one !== undefined && picked.length < max) picked.push(one)
118    }
119  }
120  return picked
121}
122
123/** Puts a mutant into a file's text, or undefined when the line no longer reads as it did. */
124export function apply(fileText: string, mutant: Mutant): string | undefined {
125  const lines = fileText.split('\n')
126  const at = mutant.line - 1
127  const current = lines[at]
128  if (current === undefined || current.trim() !== mutant.original) return undefined
129  const indent = current.slice(0, current.length - current.trimStart().length)
130  lines[at] = `${indent}${mutant.mutated}`
131  return lines.join('\n')
132}
133
134/** The project's test command, guessed from the files at its root. */
135export function guessCommand(files: ReadonlySet<string>, packageJson: string | undefined, makefile: string | undefined): string | undefined {
136  if (makefile !== undefined && /^test\s*:/m.test(makefile)) return 'make test'
137  if (packageJson !== undefined) {
138    try {
139      const scripts = (JSON.parse(packageJson) as { scripts?: Record<string, string> }).scripts ?? {}
140      if (typeof scripts.test === 'string' && !/no test specified/.test(scripts.test)) {
141        const runner = files.has('pnpm-lock.yaml') ? 'pnpm' : files.has('yarn.lock') ? 'yarn' : files.has('bun.lockb') || files.has('bun.lock') ? 'bun run' : 'npm'
142        return `${runner} test`
143      }
144    } catch {
145      // A package.json that does not parse says nothing of the tests.
146    }
147  }
148  if (files.has('Cargo.toml')) return 'cargo test --quiet'
149  if (files.has('go.mod')) return 'go test ./...'
150  if (files.has('pyproject.toml') || files.has('pytest.ini') || files.has('setup.cfg') || files.has('tox.ini')) return 'python3 -m pytest -q -x'
151  if (files.has('Package.swift')) return 'swift test'
152  return undefined
153}
154
155export type Tally = { killed: number; survived: number; timeout: number; skipped: number; score: number | undefined }
156
157export function tallyOf(mutants: readonly Mutant[]): Tally {
158  const count = (outcome: Mutant['outcome']) => mutants.filter(one => one.outcome === outcome).length
159  const killed = count('killed')
160  const survived = count('survived')
161  const timeout = count('timeout')
162  const judged = killed + survived + timeout
163  return { killed, survived, timeout, skipped: count('skipped'), score: judged === 0 ? undefined : Math.round(((killed + timeout) / judged) * 100) }
164}
165
166export const scoreLine = (t: Tally): string =>
167  `検出 ${t.killed + t.timeout} · 生存 ${t.survived}${t.skipped > 0 ? ` · 対象外 ${t.skipped}` : ''}${t.score !== undefined ? ` · スコア ${t.score}%` : ''}`
168
types/index.d.ts 38 lines
1/** One small change made to a changed line, and what the tests said of it. */
2export type Mutant = {
3  /** Repository-relative path and 1-based line. */
4  path: string
5  line: number
6  /** The operator applied, as `===→!==`. */
7  operator: string
8  /** The line before and after the change, trimmed. */
9  original: string
10  mutated: string
11  /** killed: the tests failed; survived: they passed; timeout: they ran past the limit; skipped: the line had changed. */
12  outcome?: 'killed' | 'survived' | 'timeout' | 'skipped'
13}
14
15/** A /mutate run. */
16export type MutationRun = {
17  status: 'running' | 'done' | 'failed' | 'stopped'
18  command: string
19  root: string
20  startedAt: number
21  finishedAt?: number
22  mutants: Mutant[]
23  /** How many have run. */
24  ran: number
25  /** Why it failed or stopped, or a note on how it went. */
26  note?: string
27}
28
29declare module 'claude-code' {
30  interface PluginState {
31    'mutation-tap': {
32      run: MutationRun | null
33      /** Set while a run is asked to stop after the mutant in hand. */
34      isStopping: boolean
35    }
36  }
37}
38