run.sh 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417
  1. #!/usr/bin/env bash
  2. # Self-test for adr-ops scripts (adr-new.sh, adr-index.sh, adr-lint.py).
  3. #
  4. # Offline-deterministic (no network). Builds throwaway ADR fixtures, asserts the
  5. # documented exit codes and key output of each script, then cleans up. Resolves
  6. # paths relative to itself so it works both in the repo and once installed to
  7. # ~/.claude/skills/adr-ops/.
  8. #
  9. # Usage: bash tests/run.sh
  10. # Exit: 0 all pass, 1 one or more failures
  11. set -uo pipefail
  12. HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
  13. SKILL="$(dirname "$HERE")"
  14. SCRIPTS="$SKILL/scripts"
  15. NEW="$SCRIPTS/adr-new.sh"
  16. INDEX="$SCRIPTS/adr-index.sh"
  17. LINT="$SCRIPTS/adr-lint.py"
  18. TOUCHING="$SCRIPTS/adr-touching.py"
  19. INIT="$SCRIPTS/adr-init.sh"
  20. # Pick a python that actually executes — skips the Windows Store `python3` stub.
  21. PYTHON=""
  22. for c in python python3 py; do
  23. if command -v "$c" >/dev/null 2>&1 && "$c" -c "" >/dev/null 2>&1; then PYTHON="$c"; break; fi
  24. done
  25. [[ -z "$PYTHON" ]] && { echo "no working python found" >&2; exit 1; }
  26. SB="$(mktemp -d)"; trap 'rm -rf "$SB"' EXIT
  27. PASS=0; FAIL=0
  28. ok() { PASS=$((PASS+1)); printf ' PASS %s\n' "$1"; }
  29. no() { FAIL=$((FAIL+1)); printf ' FAIL %s\n' "$1"; }
  30. expect_exit() { [[ "$2" == "$3" ]] && ok "$1 (exit $3)" || no "$1 (want $2 got $3)"; }
  31. expect_has() { case "$3" in *"$2"*) ok "$1";; *) no "$1 (missing '$2')";; esac; }
  32. # Write a minimal conformant ADR.
  33. # make_adr <dir> <NNN> <slug> <status> <date> <supersedes-yaml> <superseded-by-yaml>
  34. make_adr() {
  35. local dir="$1" nnn="$2" slug="$3" status="$4" date="$5" sup="$6" supby="$7"
  36. cat > "$dir/ADR-$nnn-$slug.md" <<EOF
  37. ---
  38. status: $status
  39. date: $date
  40. supersedes: $sup
  41. superseded-by: $supby
  42. touches:
  43. - "src/x.py"
  44. ---
  45. # ADR-$nnn: $slug Title
  46. ## Decision (one sentence)
  47. The system does the $slug thing by default.
  48. ## Context
  49. Some forces were in play.
  50. ## Alternatives considered
  51. We considered nothing and it lost.
  52. ## Consequences
  53. ### Positive
  54. - Good.
  55. ### Negative
  56. - Cost.
  57. ### Non-goals
  58. - Not that.
  59. ## See also
  60. - src/x.py
  61. EOF
  62. }
  63. echo "=== adr-ops self-test (python: $PYTHON) ==="
  64. # ── --help contracts (exit 0) ──────────────────────────────────────────────
  65. echo "-- --help --"
  66. bash "$NEW" --help >/dev/null 2>&1; expect_exit "adr-new --help" 0 $?
  67. bash "$INDEX" --help >/dev/null 2>&1; expect_exit "adr-index --help" 0 $?
  68. "$PYTHON" "$LINT" --help >/dev/null 2>&1; expect_exit "adr-lint --help" 0 $?
  69. "$PYTHON" "$TOUCHING" --help >/dev/null 2>&1; expect_exit "adr-touching --help" 0 $?
  70. bash "$INIT" --help >/dev/null 2>&1; expect_exit "adr-init --help" 0 $?
  71. # ── adr-lint.py: clean conformant pair -> 0 ────────────────────────────────
  72. echo "-- adr-lint: clean --"
  73. CLEAN="$SB/clean"; mkdir -p "$CLEAN"
  74. make_adr "$CLEAN" 001 alpha accepted 2026-01-01 "[]" "[]"
  75. make_adr "$CLEAN" 002 beta accepted 2026-01-02 "[]" "[]"
  76. "$PYTHON" "$LINT" --dir "$CLEAN" >/dev/null 2>&1; expect_exit "clean pair -> 0" 0 $?
  77. # ── adr-lint.py: missing dir -> 3 ──────────────────────────────────────────
  78. "$PYTHON" "$LINT" --dir "$SB/no-such-dir" >/dev/null 2>&1; expect_exit "missing dir -> 3" 3 $?
  79. # ── adr-lint.py: missing required field -> 10 ──────────────────────────────
  80. echo "-- adr-lint: findings --"
  81. MISS="$SB/missing"; mkdir -p "$MISS"
  82. cat > "$MISS/ADR-001-x.md" <<'EOF'
  83. ---
  84. status: accepted
  85. date: 2026-01-01
  86. superseded-by: []
  87. touches:
  88. - "a"
  89. ---
  90. # ADR-001: X
  91. ## Decision (one sentence)
  92. Rule.
  93. ## Context
  94. ## Alternatives considered
  95. ## Consequences
  96. ## See also
  97. EOF
  98. out="$("$PYTHON" "$LINT" --dir "$MISS" 2>&1)"; rc=$?
  99. expect_exit "missing 'supersedes' field -> 10" 10 "$rc"
  100. expect_has "names missing field" "supersedes" "$out"
  101. # ── adr-lint.py: bad status -> 10 ──────────────────────────────────────────
  102. BADS="$SB/badstatus"; mkdir -p "$BADS"
  103. make_adr "$BADS" 001 x bogus 2026-01-01 "[]" "[]"
  104. out="$("$PYTHON" "$LINT" --dir "$BADS" 2>&1)"; rc=$?
  105. expect_exit "bad status -> 10" 10 "$rc"
  106. expect_has "flags bad status" "not in" "$out"
  107. # ── adr-lint.py: broken supersession (one-sided) -> 10 ─────────────────────
  108. BROKE="$SB/broken-sup"; mkdir -p "$BROKE"
  109. # 002 supersedes 001, but 001 was NOT flipped (still accepted, empty superseded-by).
  110. make_adr "$BROKE" 001 old accepted 2026-01-01 "[]" "[]"
  111. make_adr "$BROKE" 002 new accepted 2026-01-02 "[ADR-001]" "[]"
  112. out="$("$PYTHON" "$LINT" --dir "$BROKE" 2>&1)"; rc=$?
  113. expect_exit "broken supersession -> 10" 10 "$rc"
  114. expect_has "flags one-sided supersession" "superseded-by" "$out"
  115. # ── adr-lint.py: a properly-flipped supersession pair is clean -> 0 ─────────
  116. GOODSUP="$SB/good-sup"; mkdir -p "$GOODSUP"
  117. make_adr "$GOODSUP" 001 old superseded 2026-01-01 "[]" "[ADR-002]"
  118. make_adr "$GOODSUP" 002 new accepted 2026-01-02 "[ADR-001]" "[]"
  119. "$PYTHON" "$LINT" --dir "$GOODSUP" >/dev/null 2>&1; expect_exit "valid supersession pair -> 0" 0 $?
  120. # ── adr-lint.py: duplicate number -> 10 ────────────────────────────────────
  121. DUP="$SB/dup"; mkdir -p "$DUP"
  122. make_adr "$DUP" 001 a accepted 2026-01-01 "[]" "[]"
  123. make_adr "$DUP" 001 b accepted 2026-01-02 "[]" "[]"
  124. out="$("$PYTHON" "$LINT" --dir "$DUP" 2>&1)"; rc=$?
  125. expect_exit "duplicate number -> 10" 10 "$rc"
  126. expect_has "flags duplicate" "duplicate ADR number" "$out"
  127. # ── adr-lint.py: unparseable frontmatter -> 4 ──────────────────────────────
  128. BADFM="$SB/badfm"; mkdir -p "$BADFM"
  129. printf '# ADR-001: No Frontmatter\n\n## Decision (one sentence)\nRule.\n' > "$BADFM/ADR-001-x.md"
  130. "$PYTHON" "$LINT" --dir "$BADFM" >/dev/null 2>&1; expect_exit "no frontmatter fence -> 4" 4 $?
  131. # ── adr-lint.py: --json envelope ───────────────────────────────────────────
  132. out="$("$PYTHON" "$LINT" --dir "$DUP" --json 2>/dev/null)"
  133. expect_has "json envelope schema" "claude-mods.adr-ops.lint/v1" "$out"
  134. # ── adr-new.sh: computes next number, derives slug ─────────────────────────
  135. echo "-- adr-new --"
  136. NEWDIR="$SB/newdir"; mkdir -p "$NEWDIR"
  137. make_adr "$NEWDIR" 001 alpha accepted 2026-01-01 "[]" "[]"
  138. make_adr "$NEWDIR" 007 gamma accepted 2026-01-02 "[]" "[]"
  139. out="$(bash "$NEW" --dir "$NEWDIR" --title "Cache The Things" --date 2026-02-02 2>/dev/null)"; rc=$?
  140. expect_exit "adr-new -> 0" 0 "$rc"
  141. expect_has "next number is highest+1 (008)" "ADR-008-cache-the-things.md" "$out"
  142. [[ -f "$NEWDIR/ADR-008-cache-the-things.md" ]] && ok "file written" || no "file not written"
  143. # the written file passes the linter
  144. "$PYTHON" "$LINT" --dir "$NEWDIR" >/dev/null 2>&1; expect_exit "scaffolded file lints clean -> 0" 0 $?
  145. # ── adr-new.sh: refuses to overwrite -> 5 ──────────────────────────────────
  146. # A pre-existing target must never be clobbered. Create one (ADR-004 already on
  147. # disk), then ask adr-new to write that exact slot via --number — the precondition
  148. # guard must refuse. (--number also exercises the explicit-number override path.)
  149. OWDIR="$SB/overwrite"; mkdir -p "$OWDIR"
  150. make_adr "$OWDIR" 004 collide accepted 2026-01-01 "[]" "[]"
  151. bash "$NEW" --dir "$OWDIR" --title "Collide" --slug collide --number 4 --date 2026-02-02 >/dev/null 2>&1
  152. expect_exit "refuse overwrite -> 5" 5 $?
  153. # --number on a free slot writes there (and lints clean).
  154. bash "$NEW" --dir "$OWDIR" --title "Backfill Two" --slug backfill-two --number 2 --date 2026-02-02 >/dev/null 2>&1
  155. expect_exit "--number free slot writes -> 0" 0 $?
  156. [[ -f "$OWDIR/ADR-002-backfill-two.md" ]] && ok "--number wrote ADR-002" || no "--number did not write ADR-002"
  157. # ── adr-new.sh: --dry-run writes nothing ───────────────────────────────────
  158. DRY="$SB/dry"; mkdir -p "$DRY"
  159. before="$(ls "$DRY" | wc -l)"
  160. out="$(bash "$NEW" --dir "$DRY" --title "Dry Run Test" --dry-run 2>/dev/null)"; rc=$?
  161. expect_exit "dry-run -> 0" 0 "$rc"
  162. after="$(ls "$DRY" | wc -l)"
  163. [[ "$before" == "$after" ]] && ok "dry-run wrote nothing" || no "dry-run wrote a file"
  164. expect_has "dry-run prints target path" "ADR-001-dry-run-test.md" "$out"
  165. # ── adr-new.sh: bad status -> 2, missing title -> 2 ────────────────────────
  166. bash "$NEW" --dir "$NEWDIR" --title "X" --status nonsense >/dev/null 2>&1; expect_exit "bad status -> 2" 2 $?
  167. bash "$NEW" --dir "$NEWDIR" >/dev/null 2>&1; expect_exit "missing title -> 2" 2 $?
  168. bash "$NEW" --dir "$SB/no-such" --title "X" >/dev/null 2>&1; expect_exit "missing dir -> 3" 3 $?
  169. # ── adr-new.sh: --apply-supersede flips the old record ─────────────────────
  170. SUPDIR="$SB/supdir"; mkdir -p "$SUPDIR"
  171. make_adr "$SUPDIR" 001 router accepted 2026-01-01 "[]" "[]"
  172. bash "$NEW" --dir "$SUPDIR" --title "New Router" --supersedes ADR-001 --apply-supersede --date 2026-03-03 >/dev/null 2>&1
  173. expect_exit "apply-supersede -> 0" 0 $?
  174. "$PYTHON" "$LINT" --dir "$SUPDIR" >/dev/null 2>&1
  175. expect_exit "auto-flipped pair lints clean -> 0" 0 $?
  176. # ── adr-index.sh: one row per ADR, in order ────────────────────────────────
  177. echo "-- adr-index --"
  178. out="$(bash "$INDEX" --dir "$CLEAN" 2>/dev/null)"; rc=$?
  179. expect_exit "adr-index -> 0" 0 "$rc"
  180. lines="$(printf '%s\n' "$out" | grep -c '^ADR-')"
  181. [[ "$lines" == 2 ]] && ok "two ADRs -> two rows" || no "expected 2 rows, got $lines"
  182. expect_has "row carries status" "accepted" "$out"
  183. bash "$INDEX" --dir "$SB/no-such-dir" >/dev/null 2>&1; expect_exit "missing dir -> 3" 3 $?
  184. out="$(bash "$INDEX" --dir "$CLEAN" --json 2>/dev/null)"
  185. expect_has "json envelope schema" "claude-mods.adr-ops.index/v1" "$out"
  186. # ── adr-lint.py: lifecycle consistency checks ──────────────────────────────
  187. echo "-- adr-lint: lifecycle --"
  188. # superseded with empty superseded-by -> error -> 10
  189. LCS="$SB/lc-sup-empty"; mkdir -p "$LCS"
  190. make_adr "$LCS" 001 a superseded 2026-01-01 "[]" "[]"
  191. out="$("$PYTHON" "$LINT" --dir "$LCS" 2>&1)"; rc=$?
  192. expect_exit "superseded w/ empty superseded-by -> 10" 10 "$rc"
  193. expect_has "names the lifecycle error" "must name its successor" "$out"
  194. # deprecated with non-empty superseded-by -> error -> 10
  195. LCD="$SB/lc-dep"; mkdir -p "$LCD"
  196. make_adr "$LCD" 001 a deprecated 2026-01-01 "[]" "[ADR-002]"
  197. make_adr "$LCD" 002 b accepted 2026-01-02 "[]" "[]"
  198. out="$("$PYTHON" "$LINT" --dir "$LCD" 2>&1)"; rc=$?
  199. expect_exit "deprecated w/ superseded-by -> 10" 10 "$rc"
  200. expect_has "names the deprecated error" "nothing replaces it" "$out"
  201. # accepted (in force) with superseded-by -> error -> 10. Pair it with a valid
  202. # back-reference so ONLY the lifecycle error fires (no bidirectionality noise),
  203. # proving the two checks don't double-report.
  204. LCA="$SB/lc-accepted"; mkdir -p "$LCA"
  205. make_adr "$LCA" 001 a accepted 2026-01-01 "[]" "[ADR-002]"
  206. make_adr "$LCA" 002 b accepted 2026-01-02 "[ADR-001]" "[]"
  207. out="$("$PYTHON" "$LINT" --dir "$LCA" 2>&1)"; rc=$?
  208. expect_exit "accepted w/ superseded-by -> 10" 10 "$rc"
  209. expect_has "names the in-force error" "in force" "$out"
  210. # ── adr-lint.py: stale touches: warning ────────────────────────────────────
  211. echo "-- adr-lint: stale touches --"
  212. # An ADR whose touches: lists a literal path absent under --repo-root. Warning
  213. # tier: exit 0 normally, exit 10 under --strict. (make_adr writes touches src/x.py;
  214. # the sandbox repo-root has no such file, so it's stale by construction.)
  215. STALE="$SB/stale"; mkdir -p "$STALE"
  216. make_adr "$STALE" 001 a accepted 2026-01-01 "[]" "[]"
  217. out="$("$PYTHON" "$LINT" --dir "$STALE" --repo-root "$SB" 2>&1)"; rc=$?
  218. expect_exit "stale touches normally -> 0" 0 "$rc"
  219. expect_has "warns on stale touches path" "no longer exists" "$out"
  220. "$PYTHON" "$LINT" --dir "$STALE" --repo-root "$SB" --strict >/dev/null 2>&1
  221. expect_exit "stale touches --strict -> 10" 10 $?
  222. # When the path DOES exist under repo-root, no stale warning.
  223. mkdir -p "$STALE/repo/src"; : > "$STALE/repo/src/x.py"
  224. out="$("$PYTHON" "$LINT" --dir "$STALE" --repo-root "$STALE/repo" 2>&1)"
  225. case "$out" in *"no longer exists"*) no "existing touches path still flagged";; *) ok "existing touches path not flagged";; esac
  226. # ── adr-touching.py: exact / prefix / glob / config-key / no-match ──────────
  227. echo "-- adr-touching --"
  228. TCH="$SB/touching"; mkdir -p "$TCH"
  229. cat > "$TCH/ADR-001-auth.md" <<'EOF'
  230. ---
  231. status: accepted
  232. date: 2026-01-01
  233. supersedes: []
  234. superseded-by: []
  235. touches:
  236. - "src/auth.py"
  237. - "lib/**"
  238. - "config.yaml:db.host"
  239. ---
  240. # ADR-001: Auth Title
  241. ## Decision (one sentence)
  242. Rule.
  243. ## Context
  244. C.
  245. ## Alternatives considered
  246. A.
  247. ## Consequences
  248. ### Positive
  249. - G.
  250. ## See also
  251. - x
  252. EOF
  253. # exact match -> 10, names the ADR
  254. out="$("$PYTHON" "$TOUCHING" --dir "$TCH" src/auth.py 2>/dev/null)"; rc=$?
  255. expect_exit "touching exact match -> 10" 10 "$rc"
  256. expect_has "touching names the ADR" "ADR-001" "$out"
  257. # prefix query (dir governs file) -> 10
  258. "$PYTHON" "$TOUCHING" --dir "$TCH" src/ >/dev/null 2>&1; expect_exit "touching prefix query -> 10" 10 $?
  259. # glob query matches a literal touches entry -> 10
  260. "$PYTHON" "$TOUCHING" --dir "$TCH" 'src/*.py' >/dev/null 2>&1; expect_exit "touching glob query -> 10" 10 $?
  261. # touches glob matches a concrete query path -> 10
  262. "$PYTHON" "$TOUCHING" --dir "$TCH" lib/deep/thing.go >/dev/null 2>&1; expect_exit "touching matched by touches-glob -> 10" 10 $?
  263. # config-key exact -> 10
  264. "$PYTHON" "$TOUCHING" --dir "$TCH" config.yaml:db.host >/dev/null 2>&1; expect_exit "touching config-key -> 10" 10 $?
  265. # no governing ADR -> 0
  266. "$PYTHON" "$TOUCHING" --dir "$TCH" other/unrelated.txt >/dev/null 2>&1; expect_exit "touching no match -> 0" 0 $?
  267. # dir not found -> 3
  268. "$PYTHON" "$TOUCHING" --dir "$SB/no-such-dir" src/auth.py >/dev/null 2>&1; expect_exit "touching missing dir -> 3" 3 $?
  269. # missing query -> 2
  270. "$PYTHON" "$TOUCHING" --dir "$TCH" >/dev/null 2>&1; expect_exit "touching missing query -> 2" 2 $?
  271. # --json envelope schema
  272. out="$("$PYTHON" "$TOUCHING" --dir "$TCH" --json src/auth.py 2>/dev/null)"
  273. expect_has "touching json envelope schema" "claude-mods.adr-ops.touching/v1" "$out"
  274. # ── adr-init.sh: bootstrap, refuse populated, dry-run ──────────────────────
  275. echo "-- adr-init --"
  276. INITD="$SB/init"
  277. bash "$INIT" --dir "$INITD/docs/adr" --first-title "Adopt ADRs" >/dev/null 2>&1
  278. expect_exit "adr-init -> 0" 0 $?
  279. [[ -f "$INITD/docs/adr/ADR-001-adopt-adrs.md" ]] && ok "init scaffolded ADR-001" || no "init did not scaffold ADR-001"
  280. [[ -f "$INITD/docs/adr/README.md" ]] && ok "init wrote README.md" || no "init did not write README.md"
  281. case "$(cat "$INITD/docs/adr/README.md" 2>/dev/null)" in
  282. *"generated by adr-init.sh"*) ok "init README carries generated marker";;
  283. *) no "init README missing generated marker";;
  284. esac
  285. # the scaffolded ADR-001 lints clean (repo-root = init root; touches paths are template placeholders -> warnings only, exit 0)
  286. "$PYTHON" "$LINT" --dir "$INITD/docs/adr" --repo-root "$INITD" >/dev/null 2>&1
  287. expect_exit "init ADR-001 lints clean -> 0" 0 $?
  288. # refuses a populated dir -> 5
  289. bash "$INIT" --dir "$INITD/docs/adr" >/dev/null 2>&1; expect_exit "init refuses populated dir -> 5" 5 $?
  290. # --dry-run writes nothing into a fresh location
  291. DRYI="$SB/init-dry"
  292. bash "$INIT" --dir "$DRYI/docs/adr" --dry-run >/dev/null 2>&1; expect_exit "init dry-run -> 0" 0 $?
  293. [[ -e "$DRYI" ]] && no "init dry-run created files" || ok "init dry-run wrote nothing"
  294. # ── adr-index.sh: --output generated file ──────────────────────────────────
  295. echo "-- adr-index --output --"
  296. OUTF="$SB/index-out.md"
  297. bash "$INDEX" --dir "$CLEAN" --output "$OUTF" >/dev/null 2>&1; expect_exit "adr-index --output -> 0" 0 $?
  298. [[ -f "$OUTF" ]] && ok "--output wrote a file" || no "--output wrote no file"
  299. outc="$(cat "$OUTF" 2>/dev/null)"
  300. expect_has "--output has the table header" "| # | Status | Date | Title |" "$outc"
  301. expect_has "--output carries the generated marker" "do not hand-edit" "$outc"
  302. expect_has "--output lists a row" "ADR-001" "$outc"
  303. # --json + --output is a usage error -> 2
  304. bash "$INDEX" --dir "$CLEAN" --json --output "$SB/x.md" >/dev/null 2>&1; expect_exit "index --json+--output -> 2" 2 $?
  305. # ── terminal design system (term.sh adoption + ASCII fallback) ─────────────
  306. echo "-- terminal design system --"
  307. # Bash scripts adopt the shared toolkit (full panel grammar), not hand-rolled ANSI.
  308. for s in "$INDEX" "$NEW" "$INIT"; do
  309. b="$(basename "$s")"
  310. if grep -q '_lib/term.sh' "$s"; then ok "$b sources _lib/term.sh"; else no "$b does not source _lib/term.sh"; fi
  311. done
  312. # Python scripts carry the inline Term helper (term.sh is bash-only; spec §9).
  313. for s in "$LINT" "$TOUCHING"; do
  314. b="$(basename "$s")"
  315. if grep -q 'class Term' "$s"; then ok "$b carries inline Term helper"; else no "$b missing inline Term helper"; fi
  316. done
  317. # term.sh primitives are pure ASCII under TERM_ASCII=1 (design principle #3).
  318. LIBTERM="$SKILL/../_lib/term.sh"
  319. if [[ -f "$LIBTERM" ]]; then
  320. ok "term.sh present"
  321. prims="$(TERM_ASCII=1 LT="$LIBTERM" bash -c '. "$LT"; term_init; printf "%s%s%s%s%s" \
  322. "$(term_mark ok)" "$(term_mark bad)" "$(term_status_row ok lbl val)" \
  323. "$TERM_DOT" "$(term_panel_open adr adr x)"')"
  324. if printf '%s' "$prims" | LC_ALL=C grep -q '[^[:print:][:cntrl:]]'; then
  325. no "term.sh TERM_ASCII=1 primitives still emit non-ASCII bytes"
  326. else ok "term.sh TERM_ASCII=1 primitives are pure ASCII"; fi
  327. else
  328. no "term.sh missing at $LIBTERM"
  329. fi
  330. # adr-index panel: full bordered frame under FORCE_COLOR, pure ASCII under TERM_ASCII=1.
  331. PB="$SB/panel"; mkdir -p "$PB"
  332. make_adr "$PB" 001 alpha accepted 2026-01-01 "[]" "[]"
  333. pout="$(TERM_ASCII=1 FORCE_COLOR=1 bash "$INDEX" --dir "$PB" 2>/dev/null)"
  334. if printf '%s' "$pout" | LC_ALL=C grep -q '[^[:print:][:cntrl:]]'; then
  335. no "adr-index panel emits non-ASCII under TERM_ASCII=1"
  336. else ok "adr-index panel is pure ASCII under TERM_ASCII=1"; fi
  337. case "$pout" in *"+-- "*) ok "adr-index renders the full panel frame";; *) no "adr-index panel frame missing";; esac
  338. # Piped (non-TTY, no FORCE_COLOR) stays plain data rows — the stdout contract.
  339. pp="$(bash "$INDEX" --dir "$PB" 2>/dev/null)"
  340. case "$pp" in
  341. *$'\033'*) no "piped adr-index leaked ANSI into the data stream";;
  342. *"ADR-001 | accepted"*) ok "piped adr-index stays plain data rows";;
  343. *) no "piped adr-index lost its data row";;
  344. esac
  345. # Python inline Term: colorizes under FORCE_COLOR, pure ASCII under TERM_ASCII=1,
  346. # byte-plain when piped (the data contract).
  347. pylint="$(TERM_ASCII=1 FORCE_COLOR=1 "$PYTHON" "$LINT" --dir "$DUP" 2>/dev/null)"
  348. if printf '%s' "$pylint" | LC_ALL=C grep -q '[^[:print:][:cntrl:]]'; then
  349. no "adr-lint colored output emits non-ASCII under TERM_ASCII=1"
  350. else ok "adr-lint colored output is pure ASCII under TERM_ASCII=1"; fi
  351. case "$pylint" in *$'\033'*) ok "adr-lint colorizes under FORCE_COLOR";; *) no "adr-lint did not colorize under FORCE_COLOR";; esac
  352. plain_lint="$("$PYTHON" "$LINT" --dir "$DUP" 2>/dev/null)"
  353. case "$plain_lint" in *$'\033'*) no "piped adr-lint leaked ANSI";; *) ok "piped adr-lint stays plain data";; esac
  354. # ── summary ────────────────────────────────────────────────────────────────
  355. echo "=== $PASS passed, $FAIL failed ==="
  356. [[ "$FAIL" -eq 0 ]] || exit 1