Skip to content

block-local-mutation.sh

.claude/hooks/block-local-mutation.sh

PreToolUse

HARD BLOCK on running parallel mutation testing locally. Fanned-out mutation spawns dozens of PHP worker processes and routinely OOM-kills an agent's session/container mid-run — losing in-flight work. The fan-out triggers: • composer mutate* (pest-mutate aliases that hardcode --parallel) • pest --mutate --parallelartisan mutate:diff (runs pest in parallel unless --no-parallel) Mutation is gated on CI instead (the diff workflow + the nightly full run), so an agent never needs to fan out locally. This hook makes that a wall, not a guideline: it DENIES the offending command before it can launch. Single-process mutation is memory-safe (one worker, no fan-out) and is allowed as an escape hatch: artisan mutate:diff --no-parallel, or a direct pest --mutate without --parallel. Registered in settings.json under PreToolUse (Bash). jq parses the stdin JSON. Tested by block-local-mutation.test.sh.

Source

bash
#!/usr/bin/env bash
#
# PreToolUse (Bash) hook — HARD BLOCK on running parallel mutation testing locally.
#
# Fanned-out mutation spawns dozens of PHP worker processes and routinely OOM-kills
# an agent's session/container mid-run — losing in-flight work. The fan-out triggers:
#   • `composer mutate*` (pest-mutate aliases that hardcode `--parallel`)
#   • `pest --mutate --parallel`
#   • `artisan mutate:diff` (runs pest in parallel unless `--no-parallel`)
# Mutation is gated on CI instead (the diff workflow + the nightly full run), so an
# agent never needs to fan out locally. This hook makes that a wall, not a guideline:
# it DENIES the offending command before it can launch.
#
# Single-process mutation is memory-safe (one worker, no fan-out) and is allowed as an
# escape hatch: `artisan mutate:diff --no-parallel`, or a direct `pest --mutate` without
# `--parallel`.
#
# Registered in settings.json under PreToolUse (Bash). jq parses the stdin JSON.
# Tested by block-local-mutation.test.sh.

set -euo pipefail

input="$(cat)"
tool="$(jq -r '.tool_name // empty' <<<"$input")"
cmd="$(jq -r '.tool_input.command // empty' <<<"$input")"

[ "$tool" = "Bash" ] || exit 0

# A phrase counts only at a command boundary (start, or after ; && || |), so the
# hook fires on a real invocation — not on the phrase buried in an echo/grep arg.
at_boundary() { grep -qE "(^|[;&|])[[:space:]]*$1" <<<"$cmd"; }
has_flag() { grep -qE -- "$1" <<<"$cmd"; }

reason=""
if at_boundary 'composer[[:space:]]+(run[[:space:]]+)?mutate'; then
  # composer mutate / mutate:warm / mutate:diff — all run --parallel via the alias.
  reason="\`composer mutate*\` runs parallel mutation"
elif at_boundary '(vendor/bin/)?pest([[:space:]]|$)' && has_flag '(^|[[:space:]])--mutate([[:space:]]|=|$)' && has_flag '(^|[[:space:]])--parallel([[:space:]]|$)'; then
  # Pest mutation is sequential by default; only `--parallel` fans out and OOMs.
  # Plain `vendor/bin/pest --mutate` (one worker) is the memory-safe escape hatch.
  reason="\`pest --mutate --parallel\` fans out and OOMs the session"
elif at_boundary '(php[[:space:]]+)?artisan[[:space:]]+mutate:diff' && ! has_flag '(^|[[:space:]])--no-parallel([[:space:]]|$)'; then
  # mutate:diff runs `pest --mutate --parallel` unless told otherwise.
  reason="\`artisan mutate:diff\` runs pest in parallel (pass --no-parallel to run single-process)"
fi

[ -n "$reason" ] || exit 0

read -r -d '' message <<EOF || true
Blocked: $reason, which OOM-kills the agent session/container mid-run. Mutation is gated on CI, not locally.

• PR gate: the \`mutation (diff-scoped)\` workflow runs automatically on every PR, and can be re-run on demand from the Actions tab (workflow_dispatch) — no re-push.
• Full run: the \`mutation (nightly full)\` workflow (also dispatchable).
Read results via the Actions tab or the GitHub MCP tools; \`composer ci:check\` no longer runs mutation, so it's safe to run locally.

If you genuinely must run mutation in this session, keep it single-process: \`php artisan mutate:diff --no-parallel\`, or \`pest --mutate\` without \`--parallel\`.
EOF

jq -n --arg r "$message" \
  '{hookSpecificOutput: {hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: $r}}'
exit 0