Files
claude_mac/.claude/tasks/plans/2026-10-06-macos-portability-1030.md
T

150 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 on `git log | grep -q`.
NOT fixed by `grep 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 -q` is 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 sibling `t=$(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)
1. 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 11 `printf '%s' "$v" | grep -q` sites 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).
2. lib/profile.sh:269-274 and lib/design-tool-gate.sh:163-165 — capture
`claude plugin list` into a variable, then awk+grep on it (kills both the
awk `exit` and 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 bare
`out="$(cmd)"`: profile.sh:374/444 (enable_skill/disable_skill),
install-plugins.sh:474 (install_plugin) and toggle-external.sh:142
(pack_hints) run bare under `set -euo pipefail`, a bare capture of a
failing CLI would abort `profile.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 on `plugin enable` so the regression is
caught.
3. 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:1143 `TFD_WHO=$(21st whoami … | head -1)` — same capture-then-first-line (A, errexit-safe).
4. 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.
5. lib/doc-shape.sh:70 — `grep -Eq … < <(git diff HEAD -- "$p")` (A).
6. update-all.sh:607 — `grep -oP '(?<=❯ )\S+'` → `sed -n 's/.*❯ \([^[:space:]][^[:space:]]*\).*/\1/p'` (non-empty token, so a line ending in "❯ " never yields `claude plugin update ""`; one token per line, as `claude plugin list` prints one plugin per line).
7. install-plugins.sh:1201,1204 — sibling-temp form (B). Delete :1207-1208
(`{ N; /^\n$/d; }`): dead on GNU (pattern space never `^\n$`), and BSD `N`
at EOF would DROP the marker comment; deletion = GNU behaviour preserved.
8. 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 `\n` in the replacement, no rewrite.
9. 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).
10. lib/seo-data/seo-data.test.sh:26,28,480 (D); :139 (E).
11. lib/tests/fast-libs.test.sh:39 (F).
12. 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 (plain
`realpath` is native on both). lib/tests/gstack-links.test.sh — add T4b:
dst=`$SRC/missing/x` (parent absent) must be refused, nothing created.
13. 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 `**name` substring anywhere on a line still resolves (bullet labels like `- **Secrets**`). Flip fixture gains a case: doctrine heading "Alphabet", citation "Alpha" → must stay DANGLING.
14. Machine state (no commit): `bash lib/effort-pins.sh` re-pins the two skills (J).
15. 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: plant `sed -i 's/a/b/' x` in a mktemp dir and pass
that file as the arg → must red. NO `| grep -q` rule (not deterministic
by text; the real fix is structural). Makefile untouched (glob already
picks `lib/tests/*.test.sh`).
16. 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 test` exits 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 Linux `make test`
run 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 -q` rule.
- MAJOR (correctness 2): `>/dev/null` is 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.bak` on 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.