11 KiB
Plan — make test green on macOS: portable shell idioms (tests + prod) — r3
Date: 2026-10-06 · Branch: bugfix/macos-portability · Kind: build-plan (bugfix) r2 after the three-lens challenge (correctness FATAL(6), robustness CONCERNS(4), simplicity FATAL(2)); every BLOCKER/MAJOR closed by a named change below, see § Challenge log.
Bug
make test red on develop: 13 suites, ~45 FAIL lines. Suite and libs were
written and validated on Linux (GNU userland); this machine is macOS (BSD
userland, bash 5.3 from Homebrew, timeout/gdate only via
/opt/homebrew/bin, NO gnu-sed). Per-suite FAIL → cause map:
| Suite | FAIL | Cause |
|---|---|---|
| lib/gitflow-test.sh | 15 "merged into…" | early-exit consumer under pipefail (A) |
| lib/tests/run-release-candidate.sh | CHANGELOG not finalized | sed -i no suffix (B) |
| floor-guard, profile-census, profile-default .test.sh | 1+2+1 | sed -i no suffix (B) |
| effort-pins (3), source-scope (2), fast-libs H2-H4 (3) | string-compare of wc -l |
BSD wc pads (C) |
| lib/seo-data/seo-data.test.sh | 3 perms + 1 stdlib | stat -c (D), /bin/grep (E) |
| fast-libs T7-stale | 1 | touch -d (F) |
| gstack-links T4 | 3 | realpath -m absent → guard never fires (G, prod) |
| design-tool-gate | 5 | bare timeout off the sanitized PATH (H, prod) |
| doctrine-citers T3 | 1 | extractor false positive + regex-interpolated resolver (I) |
| effort-routing | 2 | pins dropped from 2 gitignored SKILL.md on 2026-10-05 (J, machine state) |
Not red but same class, found by the challenge: lib/doc-shape.sh:70 (git
producer | grep -Eq under pipefail, fails OPEN: structural doc change
classed MINOR), update-all.sh:607 (grep -oP, BSD grep rc 2, || true
silently empties _plugins → marketplace plugins never update on macOS).
Root cause classes and their portable form
- (A) Early-exit consumer under
set -o pipefail:grep -q,head -1, awk{print; exit}exit before the producer finished → producer gets SIGPIPE → rc 141 → pipeline false. Reproduced 5/5 ongit log | grep -q. NOT fixed bygrep PAT >/dev/null(GNU grep treats stdout=/dev/null like-q, challenger-verified in grep's main()). Portable form: take the producer OUT of the pipeline — tests:grep -q PAT < <(cmd); prod:out="$(cmd)"then grep/awk/head the variable (here-string). Only sites whose producer is an external command under pipefail.printf '%s' "$v" | grep -qis safe (builtin writes once; 50/50 probe) and is left alone. - (B)
sed -i 'x' f→ tests (temp dirs):sed -i.bak 'x' f && rm -f f.bak. Prod on a user dotfile: unique siblingt=$(mktemp "$f.XXXXXX"),sed 'x' "$f" >"$t" && cat "$t" >"$f",rm -f "$t", explicit failure branch (never-i.bak: it would overwrite and then delete a hand-made ~/.zshrc.bak, and a failing sed in a non-final&&escapes errexit). - (C)
$(… | wc -l)→ append| tr -d ' '(idiom of hooks/unpushed-guard.sh:41). - (D)
stat -c '%a'→python3 -I -c 'import os,stat,sys; print(oct(stat.S_IMODE(os.stat(sys.argv[1]).st_mode))[2:])' F. - (E)
/bin/grep→/usr/bin/grep(LRN-074 pin, 6 files already). - (F)
touch -d '10 days ago' F→touch -t 200001010000 F(POSIX; cache_status only tests-mtime -N). - (G)
realpath -m→ emulate: walk up to the nearest EXISTING ancestor,cd+pwd -P, append the unresolved remainder. - (H) bare
timeout 15→perl -e 'alarm shift; exec @ARGV' 15 …(perl ships in /usr/bin on both OS; keeps the 15 s bound everywhere, comment stays true). - (I) citation name must start with a non-blank; resolver matches FIXED strings (awk
index()on heading / bold lines), never interpolates the name into-E.
Decided (user, 2026-10-06)
- Scope: tests AND prod sites. Doctrine: POSIX/BSD-portable scripts on the native userland of both OS. Rejected: Homebrew GNU tools on PATH.
Fix plan (exact sites)
- lib/gitflow-test.sh — the 26 assertions whose producer is an external
command (git, bash …)
cmd | grep -q[xF|i] PAT→grep -q[xF|i] PAT < <(cmd)(A); the 11printf '%s' "$v" | grep -qsites stay. Same form at lib/tests/gstack-playwright.test.sh:126-127 (negative destructive-command guard, fails OPEN on SIGPIPE), lib/tests/run-doc-commit.sh:104 (git status), lib/tests/run-release-candidate.sh:55 (git show). - lib/profile.sh:269-274 and lib/design-tool-gate.sh:163-165 — capture
claude plugin listinto a variable, then awk+grep on it (kills both the awkexitand the grep -q race). lib/profile.sh:285,374,444, lib/design-tool-gate.sh:170, lib/toggle-external.sh:142, install-plugins.sh:474 — the call STAYS inside the condition:if grep -q[iE] PAT <<<"$(cmd 2>&1)"; then(A). Never a bareout="$(cmd)": profile.sh:374/444 (enable_skill/disable_skill), install-plugins.sh:474 (install_plugin) and toggle-external.sh:142 (pack_hints) run bare underset -euo pipefail, a bare capture of a failing CLI would abortprofile.sh set/make plugin(confirmation pass, bash 5.3 probe). lib/tests/profile-set-managed.test.sh — add a fake-claude case returning rc≠0 onplugin enableso the regression is caught. - lib/design-tool-gate.sh:125 —
line="$(perl -e 'alarm shift; exec @ARGV' 15 21st whoami 2>/dev/null </dev/null)", first line via${line%%$'\n'*}(H + A). install-plugins.sh:1143TFD_WHO=$(21st whoami … | head -1)— same capture-then-first-line (A, errexit-safe). - lib/design-tool-gate.sh
ensure_21st_on_path/ensure_claude_on_path— add/opt/homebrew/bin/<bin>to the candidate list (Apple-Silicon npm global prefix,npm prefix -g= /opt/homebrew here). SAME change: lib/tests/design-tool-gate.test.sh:20-21 hermeticity precondition gains|| [ -e /opt/homebrew/bin/21st ]so CLI_ABSENT_10 skips loudly instead of reaching a real CLI. - lib/doc-shape.sh:70 —
grep -Eq … < <(git diff HEAD -- "$p")(A). - update-all.sh:607 —
grep -oP '(?<=❯ )\S+'→sed -n 's/.*❯ \([^[:space:]][^[:space:]]*\).*/\1/p'(non-empty token, so a line ending in "❯ " never yieldsclaude plugin update ""; one token per line, asclaude plugin listprints one plugin per line). - install-plugins.sh:1201,1204 — sibling-temp form (B). Delete :1207-1208
(
{ N; /^\n$/d; }): dead on GNU (pattern space never^\n$), and BSDNat EOF would DROP the marker comment; deletion = GNU behaviour preserved. - lib/tests/run-release-candidate.sh:43, floor-guard.test.sh, profile-census.test.sh, profile-default.test.sh —
-i.bak+ rm (B); BSD sed expands\nin the replacement, no rewrite. - lib/tests/effort-pins.test.sh:41,99,109; source-scope.test.sh:48,49,66; fast-libs.test.sh:46,47,50 —
| tr -d ' '(C). - lib/seo-data/seo-data.test.sh:26,28,480 (D); :139 (E).
- lib/tests/fast-libs.test.sh:39 (F).
- lib/gstack-links.sh:64 —
_gstack_links_realpath_m <path>helper (G): refuse (rc 1, warn) any path holding a..component; walk up with[ -d ]to the nearest existing directory;CDPATH= cd -P -- "$dir" && pwd -P; append the unresolved remainder. Line 63 unchanged (plainrealpathis native on both). lib/tests/gstack-links.test.sh — add T4b: dst=$SRC/missing/x(parent absent) must be refused, nothing created. - lib/tests/doctrine-citers.test.sh:19 extractor
["“][^"”[:space:]][^"”]{1,59}["”]; :27-28 resolver → awk, fixed strings (I): a heading line resolves when the text right after#+STARTS with the name and the next char is one of:(—or end of line; a**namesubstring anywhere on a line still resolves (bullet labels like- **Secrets**). Flip fixture gains a case: doctrine heading "Alphabet", citation "Alpha" → must stay DANGLING. - Machine state (no commit):
bash lib/effort-pins.shre-pins the two skills (J). - Regression guard
lib/tests/portability-census.test.sh— DETERMINISTIC idioms only, over a file list given as args (default: tracked*.sh+hooks/*), comment lines skipped, exit 2 on hit:sed -i ['"](no suffix) ·stat -c·realpath -m·touch -d·grep -[A-Za-z]*P·(^|[^a-z])/bin/grep. Allowlist with reasons, file:line: lib/tests/guard-bash.test.sh:221,225 (deny fixtures must keep the GNU spelling an agent types), and the census file itself. Flip test: plantsed -i 's/a/b/' xin a mktemp dir and pass that file as the arg → must red. NO| grep -qrule (not deterministic by text; the real fix is structural). Makefile untouched (glob already pickslib/tests/*.test.sh). - TODO.md: known re-red trigger — update-all.sh applies pins only at :572 after every vendoring step; an abort upstream drops them again. Follow-up: pin right after each vendoring step or flag in doctor.
Untouched on purpose: hooks/rtk-rewrite.sh:115 (not under pipefail,
sha256-pinned hook); doctor.sh:405,564 (printf/one-line producers, cannot
SIGPIPE; doctor not in the suite); other printf | grep -q sites.
Acceptance
make testexits 0 on this macOS machine; no SKIP added, no suite removed.- New: gstack-links T4b green; portability census green on the tree and red on the planted fixture.
- Linux: NOT verifiable from this machine. Every replacement is chosen to be
semantically identical under GNU tools (producer capture,
-i.bak, tr, python3, perl alarm, sed -n).[deferred 2026-10-06]a Linuxmake testrun before the next release is the human's call.
Out of scope
- Why the 2026-10-05 fetch dropped the two pins (not reproduced; TODO entry).
- lib/tests/effort-routing.test.sh reading live gitignored files (it is the drift detector).
- doctor.sh
readlink -f(separate pass).
Challenge log (r1 → r2)
- BLOCKER (correctness 1, simplicity 1, robustness 4): census red by
construction → §15 narrowed to deterministic idioms, file-list arg,
allowlist, self-exclusion, no
grep -qrule. - MAJOR (correctness 2):
>/dev/nullis not a fix under GNU grep → class (A) form = producer out of the pipeline. - MAJOR (correctness 3, robustness 3): upstream awk
exit/head -1→ §2, §3 capture first. - MAJOR (correctness 4, robustness 3): missed same-class prod sites → §5 doc-shape, §6 update-all.
- MAJOR (correctness 5, robustness 1):
-i.bakon dotfiles → sibling temp, explicit failure. - MAJOR (correctness 6, robustness 2): gstack-links fallback false → §12 ancestor walk + T4b.
- MINOR accepted:
/usr/bin/grep(LRN-074),touch -t, dead :1208 deleted, Makefile untouched, perl alarm bound, /opt/homebrew/bin candidates, fixed-string resolver, TODO re-red trigger. - MINOR declined: none.
- Confirmation pass (correctness CONCERNS(2), r3): MAJOR errexit on bare
capture → §2 keeps the call inside the condition + rc≠0 fake-claude case;
MAJOR hermeticity → §4 precondition; MINOR 4 unlisted class-(A) test sites
→ §1; resolver semantics + Alphabet/Alpha flip → §13;
../CDPATH/-P → §12; non-empty token → §6.