Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 10 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,13 +231,19 @@ taxonomy and reads as `test:`.
prevents is bounded because the glob, not the workflow, enumerates suites).
- Container-side root logic lives in `lib/*.sh` and is INJECTED per run via
`dc exec ... sh -c "$script" <argv0> <args...>` — a runtime input: no
rebuild, no fingerprint entry unless it is also COPYed
(`tests/test-volume-chown-guard.sh` pins the volume-perms driver line).
rebuild, no fingerprint entry unless it is also COPYed. The `<argv0>`
operand is load-bearing and was itself unguarded: without it the first mount
point becomes `$0` and is silently never chowned
(`test: tests/test-volume-chown-guard.sh` pins the driver line, reads the
executed injection line, and asserts the argv0 is present).
- `setup()` runs on EVERY cold start (gated only on `is_running`), so its
steps must be idempotent and cheap. The named-volume chown is guarded by one
owner+group stat, sound because `chown -R` is post-order: an interrupted
walk leaves the mount point root-owned and retries next start
(`tests/test-volume-chown-guard.sh`).
walk leaves the mount point root-owned and retries next start. That the
chown is *called* from `setup()` at all is part of the invariant and was
mutable-green until the audit — the suite exercised the lib without checking
production reached it (`test: tests/test-volume-chown-guard.sh`, which now
extracts `setup()`'s body and asserts the call).
- Lock discipline: per-worktree lock on fd 9, repo-scoped build lock on fd 8;
any helper backgrounded inside the locked region must be spawned with
`9>&-` or it holds the worktree lock forever (`test:
Expand Down
102 changes: 102 additions & 0 deletions tests/lib/shellsrc.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
# shellcheck shell=bash
# Shared reading of bash SOURCES for the guards that assert wiring — "production
# actually calls this" — rather than behaviour.
#
# Same rationale as tests/lib/dockerfile.sh and tests/lib/workflow-paths.sh: the
# hand-rolled form fails OPEN, so there is one implementation and a fix lands
# everywhere. Here the hand-rolled form is `grep -q '<literal>' dev/foo`, which
# matches the literal inside a COMMENT. Commenting a call out is the most common
# way code gets disabled, so the assertion stays green precisely when the wiring
# it guards has been turned off.
#
# Proven three times on this repo: `# chown_named_volume_targets (disabled)`
# passed the setup() check at 25/0; a commented-out `check-gitignore-agents`
# call passed the setup-worktree wiring check at 16/0; and prefixing the
# volume-perms driver line with `# disabled for now: ` passed at 21/0 — all with
# `tests/run-all` green.
#
# Comment stripping is deliberately naive: a `#` at line start or after
# whitespace ends the line. It can therefore truncate a line whose `#` sits
# inside a quoted string. That direction is safe — the pattern then fails to
# match and the assertion goes RED, never green — but if you hit a false FAIL on
# such a line, match a shorter prefix of it rather than reaching back for a raw
# `grep` on the unstripped file.

# shell_strip_comments <file>...
#
# Emit the file with `#` comments removed, so a match means the text is not in a
# comment. NOT that it is live code — see the scope note above.
#
# Strips only `#` at line start or after whitespace. Bash actually begins a
# comment at any word-initial `#`, so `:;#real_call args` is dead code this
# leaves intact; that spelling is unnatural but it is a genuine false-pass
# direction, not merely the fail-closed truncation documented above.
shell_strip_comments() {
sed -e 's/^[[:space:]]*#.*$//' -e 's/[[:space:]]#.*$//' "$@"
}

# assert_shell_wired <label> <file> <pattern> [grep-mode]
#
# The pattern must appear in the file's LIVE code. grep-mode defaults to -F
# (fixed string); pass -E for a regex.
assert_shell_wired() {
local label="$1" file="$2" pattern="$3" mode="${4:--F}"
if [ ! -f "$file" ]; then
_fail "$label" "no such file: $file"
return
fi
# An empty pattern matches every line, so `grep -qF -- ""` is an
# unconditional PASS — the fail-open this helper exists to remove. A
# multi-line pattern degrades to grep's OR semantics, so a garbage first
# line would pass on the second. Both are what an extracted-from-elsewhere
# pattern looks like when its extraction broke.
if [ -z "$pattern" ]; then
_fail "$label" "empty pattern — the extraction that produced it failed"
return
fi
case "$pattern" in
*$'\n'*) _fail "$label" "multi-line pattern would match as an OR: $pattern"; return ;;
esac
# Capture first rather than piping into `grep -q`: -q exits on the first
# match, `sed` then dies of SIGPIPE, and `pipefail` reports 141 for the whole
# pipeline — so a SUCCESSFUL match read as a failure. Fail-closed, but wrong.
local live
live="$(shell_strip_comments "$file")"
if grep -q "$mode" -- "$pattern" <<<"$live"; then
_pass "$label"
else
_fail "$label" \
"not present in the live (non-comment) code of $file:" \
" $pattern" \
"a commented-out call is not wiring."
fi
}

# shell_function_body <file> <name>
#
# The body of a shell function, in ONE spelling. Matches `name()` and `name ()`,
# with the brace on that line or the next. Returns 1 — not an empty string —
# when the function is absent, so a rename fails CLOSED instead of yielding an
# empty body that vacuously satisfies a "does not contain X" assertion.
#
# The repo had six hand-rolled versions of this range in three incompatible
# spellings (`awk '/^f\(\) \{/,/^\}/'`, `sed -n '/^f()/,/^}$/p'`, and the same
# without the `$`). They disagree about `f ()` and about a brace on the next
# line: reformatting `dc_up()` to `dc_up ()` — valid bash, shellcheck-clean —
# emptied three of them and false-FAILed the assertions built on them.
#
# Two known edges, neither reachable in this repo today. It stops at the FIRST
# column-0 `}`, so a body containing one inside a heredoc is truncated there;
# and a one-liner definition captures on through the next function's closer.
# Truncation is the unsafe direction — a NEGATIVE assertion over a truncated
# body can pass for the wrong reason — so guard negative assertions with a
# non-empty check on the body.
shell_function_body() {
local file="$1" name="$2" body
body="$(awk -v fn="$name" '
!inb && $0 ~ "^"fn"[[:space:]]*\\([[:space:]]*\\)" { inb = 1; print; next }
inb { print; if ($0 ~ /^\}/) exit }
' "$file")"
[ -n "$body" ] || return 1
printf '%s\n' "$body"
}
155 changes: 148 additions & 7 deletions tests/test-gitignore-agents-reinclusion.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,34 @@
# warning). The live file is untracked, so the probe is the guard for a fact
# no hermetic test over committed files can see; this suite guards the probe.
#
# Verified (ADR-0005 §2), third round: the parser tokenized YAML COMMENTS.
# stages: [manual] # run via pre-commit run --hook-stage manual
# passed at 16/0 with the gate fully disabled, because "pre-commit" from the
# comment landed in the value set — and that sentence is the natural comment to
# write when moving a hook to manual. Same for the block form. Also
# `stages: [pre-commit,manual]` (legitimate, fires on commit) was glued into one
# token by `tr -d ','` and spuriously FAILed. Ten spellings now verified:
# [manual], [commit-msg], block -manual, bare `stages:`, and either form with a
# pre-commit-naming comment all FAIL; [pre-commit], [commit], block -pre-commit
# and [pre-commit,manual] all pass (mutation runs 2026-08-04).
#
# Verified (ADR-0005 §2), review round: the first cut of the stage check parsed
# only the FLOW spelling, so the YAML block form
# stages:
# - manual
# yielded an empty match and took the "no stages: key" pass — the ADR-0003 gate
# still silently disabled, suite green. All seven spellings now behave:
# [manual]/[commit-msg]/block -manual/bare `stages:` FAIL; [pre-commit]/[commit]/
# block -pre-commit PASS. Also replaced a fixed `grep -A5` window for
# always_run, which produced a spurious FAIL whenever the hook block grew by two
# lines (mutation runs 2026-08-04).
#
# Verified (ADR-0005 §2), audit round: adding `stages: [manual]` to the hook in
# .pre-commit-config.yaml left this suite at 14 passed / 0 failed while a commit
# with a broken live .gitignore went through clean (rc 0; rc 1 with the hook
# restored) — the ADR-0003 hard gate off, silently. Now FAILs "the hook runs at
# the commit stage" (15 passed, 1 failed) (mutation run 2026-08-04).
#
# Verified (ADR-0005 §2), review round: reverting the probe to its dotfile name
# and its `dir="${1:-.}"` handling, and un-gating the setup-worktree warning,
# turns three assertions red — "a broken repo is still red when probed from a
Expand All @@ -17,9 +45,38 @@
# .gitignore, dev/check-gitignore-agents exits 1 and
# `pre-commit run gitignore-agents-reinclusion` fails; restoring the template
# stanza turns both green (mutation run 2026-08-02).
# Verified (ADR-0005 §2), fourth round: BOTH setup-worktree wiring greps matched
# commented-out code. Commenting the probe call passed at 16/0 (ADR-0003's
# worktree probe never runs); commenting the ls-files gate — never tested before
# — passed too. Rounds two and three anchored the hook-block assertions and left
# these. Now via tests/lib/shellsrc.sh (mutation runs 2026-08-04).
#
# Verified (ADR-0005 §2 pair), sixth round — three more, all previously 16/0:
# the whole `shellcheck (extensionless)` hook commented out, files: pattern
# included, left "the extensionless shellcheck hook covers the probe
# script" PASSING while the probe was linted by nothing;
# a QUOTED key, `"stages": [manual]` — pre-commit honours it, yamllint is
# clean, and the bare `stages:` match never saw it. The parser failed
# closed on unreadable VALUES and open on unreadable KEYS;
# top-level `default_stages: [manual]`, which moves the hook off the commit
# stage from OUTSIDE the block this suite parses, so "no stages: key"
# passed while the gate was off.
# (mutation runs 2026-08-04)
#
# Verified (ADR-0005 §2 pair), fifth round:
# SEMANTIC — a comment on the `stages:` KEY line, `stages: # run via
# pre-commit run --hook-stage manual` over a block `- manual`, was 16/0
# GREEN: the parser's own sed needed whitespace BEFORE the `#`, and the awk
# had already eaten it. One stripper now (shell_strip_comments carries the
# line-start rule). Also: commenting the setup-worktree gate out => red.
# FORM-ONLY — requoting to `ls-files -- '.claude/agents'` was a false FAIL
# against the fixed-string pin; the pattern is quoting-tolerant now and the
# suite holds at 16. (mutation runs 2026-08-04)
#
set -euo pipefail

. "$(dirname "$(readlink -f "$0")")/lib/harness.sh"
. "$(dirname "$(readlink -f "$0")")/lib/shellsrc.sh"

DEV_BASE="$(cd "$(dirname "$(readlink -f "$0")")/.." && pwd)"
check="$DEV_BASE/dev/check-gitignore-agents"
Expand Down Expand Up @@ -81,21 +138,105 @@ assert_false "a directory outside any repo is not reported green" \
bash -c "'$check' /tmp 2>/dev/null"

# Wiring: a probe only guards if its consumers run it.
# Read once, comment-stripped, and reused: a raw grep here matches a commented
# hook just as happily as a live one.
_precommit_live="$(shell_strip_comments "$DEV_BASE/.pre-commit-config.yaml")"

assert_true "the pre-commit local hook runs the probe" \
grep -q 'entry: dev/check-gitignore-agents' "$DEV_BASE/.pre-commit-config.yaml"
grep -q 'entry: dev/check-gitignore-agents' <<<"$_precommit_live"
# ...and runs at the COMMIT stage. Greps for `entry:` and `always_run:` say
# nothing about when the hook fires: adding `stages: [manual]` left this suite
# at 14/0 while a commit with a broken live .gitignore went through clean (rc 0
# vs rc 1 with the hook restored). The gate #91/#94 exists to provide was off,
# silently. Checked hermetically — pre-commit is not installed in the Bash-tests
# CI job, and a skip there is the same "verification never ran" shape.
# ONE comment-stripper for this file too. YAML and shell both use `#`, and the
# stage parser's own `sed 's/[[:space:]]#.*$//'` required whitespace BEFORE the
# `#` — so a comment on the `stages:` KEY line survived, because the awk had
# already eaten the whitespace. shell_strip_comments carries the line-start rule.
hook_block="$(shell_strip_comments "$DEV_BASE/.pre-commit-config.yaml" | awk '/id: gitignore-agents-reinclusion/{f=1} f&&/^ - id: /&&!/gitignore-agents-reinclusion/{exit} f' \
)"
assert_nonempty "the hook block was found in .pre-commit-config.yaml" "$hook_block"
# Against the extracted block, not a fixed `grep -A5` window: adding two lines
# to the hook pushed always_run out of that window and produced a spurious FAIL
# on an unrelated edit.
assert_true "the hook runs even when no files match" \
bash -c "grep -A5 'id: gitignore-agents-reinclusion' '$DEV_BASE/.pre-commit-config.yaml' | grep -q 'always_run: true'"
assert_true "setup-worktree warns through the same probe" \
grep -q 'check-gitignore-agents' "$DEV_BASE/dev/setup-worktree"
grep -qE '^[[:space:]]*always_run:[[:space:]]*true' <<<"$hook_block"
# A `stages:` key can be written flow-style (`stages: [manual]`) or block-style
# (`stages:` then indented `- manual` lines). The first cut parsed only the flow
# form, so the block form yielded an EMPTY match and took the "no stages: key"
# pass — the gate still silently disabled, suite green. Distinguish "no key"
# from "key present, values unreadable", and fail closed on the latter.
# The key may be quoted (`"stages":`), which pre-commit honours and a bare
# `stages:` match does not see — the parser failed closed on unreadable VALUES
# and open on unreadable KEYS. And with no hook-level key at all, a top-level
# `default_stages:` still moves the hook off the commit stage from outside the
# block this suite parses.
if ! grep -qE '^[[:space:]]*"?stages"?[[:space:]]*:' <<<"$hook_block"; then
_default_stages="$(grep -oP '^default_stages[[:space:]]*:[[:space:]]*\K.*' <<<"$_precommit_live" || true)"
if [ -z "$_default_stages" ]; then
_pass "the hook is not restricted off the commit stage (no stages: key)"
elif grep -qE '(^|[^a-z-])(pre-commit|commit)([^a-z-]|$)' <<<"$_default_stages"; then
_pass "no hook stages:; top-level default_stages includes commit: $_default_stages"
else
_fail "the hook runs at the commit stage" \
"no hook-level stages:, but top-level default_stages: $_default_stages" \
"excludes pre-commit, so the ADR-0003 gate never fires on a commit."
fi
else
# The pipeline matters: strip YAML comments FIRST (an inline comment on a
# `stages:` line naturally mentions pre-commit — "run via pre-commit run
# --hook-stage manual" — and tokenizing it re-enabled the pass), and map
# commas to newlines rather than deleting them (deleting glued
# `[pre-commit,manual]` into one token and failed a legitimate config).
stage_vals="$(awk '
/^[[:space:]]*stages:/ {
v = $0; sub(/^[[:space:]]*stages:[[:space:]]*/, "", v)
if (v != "") print v
blk = 1; next
}
blk {
if ($0 ~ /^[[:space:]]*-[[:space:]]*/) {
v = $0; sub(/^[[:space:]]*-[[:space:]]*/, "", v); print v; next
}
blk = 0
}
' <<<"$hook_block" \
| tr -d "[]\"'" \
| tr ', ' '\n\n' \
| grep -v '^$' || true)"
if [ -z "$stage_vals" ]; then
_fail "the hook runs at the commit stage" \
"a stages: key is present but no values could be parsed —" \
"failing closed rather than assuming it is harmless."
elif grep -qxE '(pre-commit|commit)' <<<"$stage_vals"; then
_pass "the hook's stages include the commit stage: $(tr '\n' ' ' <<<"$stage_vals")"
else
_fail "the hook runs at the commit stage" \
"stages: $(tr '\n' ' ' <<<"$stage_vals") excludes pre-commit, so the" \
"ADR-0003 gate never fires on a real commit — silently, suite green."
fi
fi
assert_shell_wired "setup-worktree warns through the same probe" \
"$DEV_BASE/dev/setup-worktree" "check-gitignore-agents"
# ...but only where ADR-0003's convention is in force. setup-worktree runs on
# every `devcontainer up` in a non-main worktree for ANY project, so an
# ungated warning is per-start noise in consumer repos that simply ignore
# .claude/ — and its "commits will be blocked" claim is false there, since the
# hard gate is this repo's .pre-commit-config.yaml.
assert_true "the setup-worktree warning is gated on the repo tracking .claude/agents" \
grep -q 'ls-files -- .claude/agents' "$DEV_BASE/dev/setup-worktree"
# Quoting-tolerant: pinning the unquoted spelling as a fixed string made an
# ordinary requote (`ls-files -- '.claude/agents'`) a false FAIL. Fail-closed,
# but a guard that reddens on a no-op reformat trains people to edit the test.
assert_shell_wired "the setup-worktree warning is gated on the repo tracking .claude/agents" \
"$DEV_BASE/dev/setup-worktree" "ls-files -- [\"']?\.claude/agents[\"']?" -E
# Read from the stripped config, and in THIS shell: a `bash -c` subshell does
# not inherit $_precommit_live. Commenting out the whole shellcheck hook — its
# files: pattern included — used to leave this PASSING while the probe script
# was no longer linted at all.
_shellcheck_files="$(grep -F '^dev/(' <<<"$_precommit_live" || true)"
assert_nonempty "the extensionless shellcheck hook has a files: pattern" "$_shellcheck_files"
assert_true "the extensionless shellcheck hook covers the probe script" \
bash -c "grep -F '^dev/(' '$DEV_BASE/.pre-commit-config.yaml' | grep -q 'check-gitignore-agents'"
grep -q 'check-gitignore-agents' <<<"$_shellcheck_files"

echo ""
finish
Loading
Loading