@bookedsolid/rea 0.9.2 → 0.9.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -87,7 +87,12 @@ triage_score() {
87
87
  local diff_input
88
88
  diff_input=$(cat)
89
89
  local line_count
90
- line_count=$(printf '%s' "$diff_input" | grep -cE '^\+[^+]|^-[^-]' 2>/dev/null || echo "0")
90
+ # Defect K (rea#62) sibling: see `hooks/commit-review-gate.sh` for the
91
+ # full bug rationale. `|| echo "0"` captures "0\n0" on no-match, which
92
+ # breaks arithmetic comparisons downstream. `|| true` + bash default keeps
93
+ # the branch arithmetic-safe.
94
+ line_count=$(printf '%s' "$diff_input" | grep -cE '^\+[^+]|^-[^-]' 2>/dev/null || true)
95
+ line_count="${line_count:-0}"
91
96
 
92
97
  # Check for sensitive paths
93
98
  local sensitive=0
@@ -341,15 +341,7 @@ pr_core_run() {
341
341
  exit 0
342
342
  fi
343
343
 
344
- # ── 5. Check if quality gates are enabled ─────────────────────────────────
345
- local POLICY_FILE="${REA_ROOT}/.rea/policy.yaml"
346
- if [[ -f "$POLICY_FILE" ]]; then
347
- if grep -qE 'push_review:[[:space:]]*false' "$POLICY_FILE" 2>/dev/null; then
348
- exit 0
349
- fi
350
- fi
351
-
352
- # ── 5a. REA_SKIP_PUSH_REVIEW — whole-gate escape hatch ────────────────────
344
+ # ── 5. REA_SKIP_PUSH_REVIEW whole-gate escape hatch ─────────────────────
353
345
  # An opt-in bypass for the ENTIRE push-review gate (not just the Codex
354
346
  # branch). Exists to unblock consumers when rea itself is broken or a
355
347
  # corrupt policy/audit file would otherwise deadlock a push. Requires an
@@ -443,8 +435,8 @@ pr_core_run() {
443
435
  --arg os_uid "$SKIP_OS_UID" \
444
436
  --arg os_whoami "$SKIP_OS_WHOAMI" \
445
437
  --arg os_hostname "$SKIP_OS_HOST" \
446
- --arg os_pid "$SKIP_OS_PID" \
447
- --arg os_ppid "$SKIP_OS_PPID" \
438
+ --argjson os_pid "$SKIP_OS_PID" \
439
+ --argjson os_ppid "$SKIP_OS_PPID" \
448
440
  --arg os_ppid_cmd "$SKIP_OS_PPID_CMD" \
449
441
  --arg os_tty "$SKIP_OS_TTY" \
450
442
  --arg os_ci "$SKIP_OS_CI" \
@@ -856,7 +848,7 @@ pr_core_run() {
856
848
  } >&2
857
849
  exit 2
858
850
  fi
859
- if printf '%s\n' "$_refspec_hits" | awk -v re='^(src/gateway/middleware/|hooks/|[.]claude/hooks/|src/policy/|[.]github/workflows/)' '
851
+ if printf '%s\n' "$_refspec_hits" | awk -v re='^(src/gateway/middleware/|hooks/|[.]claude/hooks/|src/policy/|[.]github/workflows/|[.]rea/|[.]husky/)' '
860
852
  {
861
853
  status = $1
862
854
  if (status !~ /^[ACDMRTU]/) next
@@ -896,6 +888,8 @@ pr_core_run() {
896
888
  printf ' - .claude/hooks/\n'
897
889
  printf ' - src/policy/\n'
898
890
  printf ' - .github/workflows/\n'
891
+ printf ' - .rea/\n'
892
+ printf ' - .husky/\n'
899
893
  printf '\n'
900
894
  printf ' Run /codex-review against %s, then retry the push.\n' "$local_sha"
901
895
  printf ' The codex-adversarial agent emits the required audit entry.\n'
@@ -930,17 +924,26 @@ pr_core_run() {
930
924
  fi
931
925
  done
932
926
 
927
+ # Defect J (rea#61): branch-deletion guard MUST fail closed regardless of
928
+ # whether another refspec in the same push resolved a SOURCE_SHA. A mixed
929
+ # push like `git push origin safe:safe :main` iterates both refspecs; the
930
+ # safe refspec sets SOURCE_SHA from its local_sha, and the deletion refspec
931
+ # sets only HAS_DELETE=1 via its `continue` branch. If we check HAS_DELETE
932
+ # INSIDE the `-z SOURCE_SHA` fallback, the delete slips through unchecked.
933
+ # Hoist the check above the fallback so any deletion anywhere in the push
934
+ # blocks the entire push.
935
+ if [[ "$HAS_DELETE" -eq 1 ]]; then
936
+ {
937
+ printf 'PUSH BLOCKED: refspec is a branch deletion.\n'
938
+ printf '\n'
939
+ printf ' Branch deletions are sensitive operations and require explicit\n'
940
+ printf ' human action outside the agent. Perform the deletion manually.\n'
941
+ printf '\n'
942
+ } >&2
943
+ exit 2
944
+ fi
945
+
933
946
  if [[ -z "$SOURCE_SHA" || -z "$MERGE_BASE" ]]; then
934
- if [[ "$HAS_DELETE" -eq 1 ]]; then
935
- {
936
- printf 'PUSH BLOCKED: refspec is a branch deletion.\n'
937
- printf '\n'
938
- printf ' Branch deletions are sensitive operations and require explicit\n'
939
- printf ' human action outside the agent. Perform the deletion manually.\n'
940
- printf '\n'
941
- } >&2
942
- exit 2
943
- fi
944
947
  {
945
948
  printf 'PUSH BLOCKED: could not resolve a merge-base for any push refspec.\n'
946
949
  printf '\n'
@@ -975,8 +978,13 @@ pr_core_run() {
975
978
  exit 0
976
979
  fi
977
980
 
981
+ # Defect K (rea#62): `grep -c ... || echo "0"` captures `0\n0` when grep
982
+ # exits non-zero on no-match — grep still prints its own `0` to stdout before
983
+ # exiting, and the `|| echo "0"` branch appends another. `|| true` swallows
984
+ # the non-zero exit, and `${LINE_COUNT:-0}` defaults an empty result to 0.
978
985
  local LINE_COUNT
979
- LINE_COUNT=$(printf '%s' "$DIFF_FULL" | grep -cE '^\+[^+]|^-[^-]' 2>/dev/null || echo "0")
986
+ LINE_COUNT=$(printf '%s' "$DIFF_FULL" | grep -cE '^\+[^+]|^-[^-]' 2>/dev/null || true)
987
+ LINE_COUNT="${LINE_COUNT:-0}"
980
988
 
981
989
  # ── 7a. Protected-path Codex adversarial review gate ──────────────────────
982
990
  # The per-refspec check runs inside the main loop (section 7, above) so
@@ -987,8 +995,36 @@ pr_core_run() {
987
995
  # refspec was either clean or had an acceptable audit.
988
996
 
989
997
  # ── 8. Check review cache ─────────────────────────────────────────────────
990
- local PUSH_SHA
991
- PUSH_SHA=$(printf '%s' "$DIFF_FULL" | shasum -a 256 | cut -d' ' -f1 2>/dev/null || echo "")
998
+ # Defect L (rea#63): `shasum` is not installed on Alpine, distroless, or
999
+ # most minimal Linux CI images only `sha256sum` is. The prior `shasum -a
1000
+ # 256 ... || echo ""` chain silently produced an empty PUSH_SHA, which the
1001
+ # rest of the gate treats as "no cache entry" rather than "hasher missing".
1002
+ # Combined with the silent-cache-miss fallback (Defect F), every push from
1003
+ # such a runner burned a full fresh codex review invisibly.
1004
+ #
1005
+ # Portable chain: sha256sum → shasum → openssl. The openssl branch uses
1006
+ # `awk '{print $NF}'` WITHOUT `-r` — `-r` was added in OpenSSL 3.0 /
1007
+ # LibreSSL 3.3+; on OpenSSL 1.1.1 (Debian 11, Ubuntu 20.04, RHEL 8,
1008
+ # Amazon Linux 2, Alpine 3.13–3.14) `-r` is rejected and stdout is empty.
1009
+ # `$NF` handles BOTH default output shapes: `(stdin)= <hex>` (1.1.x) and
1010
+ # `<hex> *stdin` (3.x/LibreSSL coreutils-style).
1011
+ #
1012
+ # Hex-64 validation catches broken pipes, partial reads, or unexpected
1013
+ # hasher output that would otherwise be silently cached as garbage.
1014
+ local PUSH_SHA=""
1015
+ if command -v sha256sum >/dev/null 2>&1; then
1016
+ PUSH_SHA=$(printf '%s' "$DIFF_FULL" | sha256sum 2>/dev/null | awk '{print $1}')
1017
+ elif command -v shasum >/dev/null 2>&1; then
1018
+ PUSH_SHA=$(printf '%s' "$DIFF_FULL" | shasum -a 256 2>/dev/null | awk '{print $1}')
1019
+ elif command -v openssl >/dev/null 2>&1; then
1020
+ PUSH_SHA=$(printf '%s' "$DIFF_FULL" | openssl dgst -sha256 2>/dev/null | awk '{print $NF}')
1021
+ else
1022
+ printf 'rea push-review: WARN no sha256 hasher found (sha256sum/shasum/openssl); cache disabled\n' >&2
1023
+ fi
1024
+ if [[ -n "$PUSH_SHA" && ! "$PUSH_SHA" =~ ^[0-9a-f]{64}$ ]]; then
1025
+ printf 'rea push-review: WARN hasher returned invalid output; cache disabled\n' >&2
1026
+ PUSH_SHA=""
1027
+ fi
992
1028
 
993
1029
  local -a REA_CLI_ARGS
994
1030
  REA_CLI_ARGS=()
@@ -1043,8 +1079,10 @@ pr_core_run() {
1043
1079
  fi
1044
1080
 
1045
1081
  # ── 9. Block and request review ───────────────────────────────────────────
1082
+ # Defect K (rea#62): same `0\n0` bug as LINE_COUNT above.
1046
1083
  local FILE_COUNT
1047
- FILE_COUNT=$(printf '%s' "$DIFF_FULL" | grep -c '^\+\+\+ ' 2>/dev/null || echo "0")
1084
+ FILE_COUNT=$(printf '%s' "$DIFF_FULL" | grep -c '^\+\+\+ ' 2>/dev/null || true)
1085
+ FILE_COUNT="${FILE_COUNT:-0}"
1048
1086
 
1049
1087
  {
1050
1088
  printf 'PUSH REVIEW GATE: Review required before pushing\n'
@@ -1056,8 +1094,23 @@ pr_core_run() {
1056
1094
  printf ' Action required:\n'
1057
1095
  printf ' 1. Spawn a code-reviewer agent to review: git diff %s..%s\n' "$MERGE_BASE" "$SOURCE_SHA"
1058
1096
  printf ' 2. Spawn a security-engineer agent for security review\n'
1059
- printf ' 3. After both pass, cache the result:\n'
1060
- printf ' rea cache set %s pass --branch %s --base %s\n' "$PUSH_SHA" "$SOURCE_BRANCH" "$TARGET_BRANCH"
1097
+ # Defect L (rea#63) follow-up: when no sha256 hasher is available the
1098
+ # cache is disabled and PUSH_SHA is empty. Emitting `rea cache set <blank>
1099
+ # pass ...` would be a dead-end — the CLI rejects the empty positional.
1100
+ # Print an alternate completion path in that case. The Codex-adversarial
1101
+ # review concerns list flagged this UX cliff in the 0.9.4 pass.
1102
+ if [[ -n "$PUSH_SHA" ]]; then
1103
+ printf ' 3. After both pass, cache the result:\n'
1104
+ printf ' rea cache set %s pass --branch %s --base %s\n' "$PUSH_SHA" "$SOURCE_BRANCH" "$TARGET_BRANCH"
1105
+ else
1106
+ printf ' 3. Cache is DISABLED on this host (no sha256 hasher found).\n'
1107
+ printf ' After both reviews pass, bypass the push-review gate with:\n'
1108
+ printf ' REA_SKIP_PUSH_REVIEW="<reason>" git push ...\n'
1109
+ printf ' The bypass is audited as push.review.skipped — this is the\n'
1110
+ printf ' documented escape hatch when cache is unavailable.\n'
1111
+ printf ' To restore the cache path, install one of: sha256sum,\n'
1112
+ printf ' shasum (Perl Digest::SHA), or openssl.\n'
1113
+ fi
1061
1114
  printf '\n'
1062
1115
  } >&2
1063
1116
  exit 2
@@ -103,18 +103,7 @@ if printf '%s' "$CMD" | grep -qiE 'git[[:space:]]+commit.*--amend'; then
103
103
  exit 0
104
104
  fi
105
105
 
106
- # ── 5. Check if quality gates are enabled ─────────────────────────────────────
107
- # Fail-open if policy doesn't exist or doesn't have quality_gates
108
- POLICY_FILE="${REA_ROOT}/.rea/policy.yaml"
109
- if [[ -f "$POLICY_FILE" ]]; then
110
- if grep -qE '^quality_gates:' "$POLICY_FILE" 2>/dev/null; then
111
- if grep -qE 'commit_review:[[:space:]]*false' "$POLICY_FILE" 2>/dev/null; then
112
- exit 0
113
- fi
114
- fi
115
- fi
116
-
117
- # ── 6. Compute diff stats ────────────────────────────────────────────────────
106
+ # ── 5. Compute diff stats ────────────────────────────────────────────────────
118
107
  # Get staged diff (what would be committed)
119
108
  DIFF_OUTPUT=$(cd "$REA_ROOT" && git diff --cached --stat 2>/dev/null || echo "")
120
109
  DIFF_FULL=$(cd "$REA_ROOT" && git diff --cached 2>/dev/null || echo "")
@@ -125,7 +114,16 @@ if [[ -z "$DIFF_OUTPUT" ]]; then
125
114
  fi
126
115
 
127
116
  # Count changed lines (additions + deletions)
128
- LINE_COUNT=$(printf '%s' "$DIFF_FULL" | grep -cE '^\+[^+]|^-[^-]' 2>/dev/null || echo "0")
117
+ # Defect K (rea#62) sibling: `|| echo "0"` captures "0\n0" into LINE_COUNT
118
+ # when grep exits non-zero on a no-match — grep still prints its own `0` and
119
+ # `echo "0"` appends another. At this site the concatenated `"0\n0"` is then
120
+ # evaluated as arithmetic (`-gt $SIGNIFICANT_THRESHOLD`, `-ge $TRIVIAL_THRESHOLD`
121
+ # below) and bash emits a "syntax error in expression" at runtime on any
122
+ # rename-only / mode-only / empty-file-add diff. `|| true` + bash-default
123
+ # expansion fixes both the banner cosmetic and the arithmetic-unsafe control
124
+ # flow in one shot.
125
+ LINE_COUNT=$(printf '%s' "$DIFF_FULL" | grep -cE '^\+[^+]|^-[^-]' 2>/dev/null || true)
126
+ LINE_COUNT="${LINE_COUNT:-0}"
129
127
 
130
128
  # Check for sensitive paths
131
129
  SENSITIVE=0
@@ -173,17 +171,79 @@ fi
173
171
 
174
172
  # ── 10. Check review cache for all non-trivial commits ────────────────────────
175
173
  # Compute SHA and branch here so both standard and significant tiers share them.
176
- STAGED_SHA=$(cd "$REA_ROOT" && git diff --cached | shasum -a 256 | cut -d' ' -f1 2>/dev/null || echo "")
174
+ #
175
+ # Defect L (rea#63) sibling: `shasum` is not installed on Alpine, distroless,
176
+ # or most minimal Linux CI images — only `sha256sum` is. The prior chain
177
+ # silently produced an empty STAGED_SHA, which the cache block then skipped
178
+ # AND the banner at §11 rendered as `rea cache set pass` — a dead-end the
179
+ # agent cannot execute. Portable chain mirrors push-review-core.sh §8:
180
+ # sha256sum → shasum → openssl. The openssl branch uses `awk '{print $NF}'`
181
+ # WITHOUT `-r` to stay compatible with OpenSSL 1.1.x (Debian 11, Ubuntu
182
+ # 20.04, RHEL 8, Amazon Linux 2, Alpine 3.13–3.14).
183
+ STAGED_SHA=""
184
+ if command -v sha256sum >/dev/null 2>&1; then
185
+ STAGED_SHA=$(printf '%s' "$DIFF_FULL" | sha256sum 2>/dev/null | awk '{print $1}')
186
+ elif command -v shasum >/dev/null 2>&1; then
187
+ STAGED_SHA=$(printf '%s' "$DIFF_FULL" | shasum -a 256 2>/dev/null | awk '{print $1}')
188
+ elif command -v openssl >/dev/null 2>&1; then
189
+ STAGED_SHA=$(printf '%s' "$DIFF_FULL" | openssl dgst -sha256 2>/dev/null | awk '{print $NF}')
190
+ else
191
+ printf 'rea commit-review: WARN no sha256 hasher found (sha256sum/shasum/openssl); cache disabled\n' >&2
192
+ fi
193
+ if [[ -n "$STAGED_SHA" && ! "$STAGED_SHA" =~ ^[0-9a-f]{64}$ ]]; then
194
+ printf 'rea commit-review: WARN hasher returned invalid output; cache disabled\n' >&2
195
+ STAGED_SHA=""
196
+ fi
177
197
  BRANCH=$(cd "$REA_ROOT" && git branch --show-current 2>/dev/null || echo "")
178
198
  CACHE_FILE="${REA_ROOT}/.rea/review-cache.json"
179
199
 
200
+ # Codex pass-3 finding #1: `rea cache check` and `rea cache set` both declare
201
+ # `--base` as a `requiredOption` in src/cli/index.ts. Prior versions of this
202
+ # gate omitted `--base`, so (a) the CLI path exited non-zero and the
203
+ # `|| echo '{"hit":false}'` fallback quietly masked the contract error, and
204
+ # (b) the section-11 banner instructed the agent to run `rea cache set <sha>
205
+ # pass` — also missing `--base`, rejected by the CLI on every retry. A
206
+ # successful cache flow was unreachable.
207
+ #
208
+ # Resolve BASE_BRANCH by the same preference order the push-gate uses in
209
+ # push-review-core.sh §7 (lines 778-794): origin/HEAD → origin/main →
210
+ # origin/master → empty. If nothing resolves, disable the cache (the
211
+ # alternative is emitting a cache command the CLI rejects on every call).
212
+ BASE_BRANCH=""
213
+ _origin_head=$(cd "$REA_ROOT" && git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null || true)
214
+ if [[ -n "$_origin_head" ]]; then
215
+ BASE_BRANCH="${_origin_head#refs/remotes/origin/}"
216
+ fi
217
+ if [[ -z "$BASE_BRANCH" ]]; then
218
+ # Use `git -C` so the current-shell cwd is never mutated — matches the
219
+ # cross-repo guard at §1a and keeps the file's dominant idiom. Raw
220
+ # `cd "$REA_ROOT" && git …` would leave the hook process sitting in
221
+ # $REA_ROOT, which is safe today but breaks silently if a future edit
222
+ # adds a relative-path command downstream.
223
+ if git -C "$REA_ROOT" rev-parse --verify --quiet refs/remotes/origin/main >/dev/null 2>&1; then
224
+ BASE_BRANCH="main"
225
+ elif git -C "$REA_ROOT" rev-parse --verify --quiet refs/remotes/origin/master >/dev/null 2>&1; then
226
+ BASE_BRANCH="master"
227
+ fi
228
+ fi
229
+ if [[ -z "$BASE_BRANCH" && -n "$STAGED_SHA" ]]; then
230
+ printf 'rea commit-review: WARN could not resolve base branch (no origin/HEAD, no origin/main, no origin/master); cache disabled\n' >&2
231
+ STAGED_SHA=""
232
+ fi
233
+ unset _origin_head
234
+
180
235
  if [[ -n "$STAGED_SHA" ]]; then
181
236
  CACHE_HIT=false
182
237
 
183
- # Primary: use CLI when available — handles TTL, expiry, and branch-scoped keys
238
+ # Primary: use CLI when available — handles TTL, expiry, and branch-scoped keys.
239
+ # Cache predicate must require BOTH `.hit == true` AND `.result == "pass"` —
240
+ # a cached `fail` verdict would otherwise satisfy `.hit == true` and let the
241
+ # commit proceed despite a recorded negative review. Mirrors the push-gate
242
+ # predicate at push-review-core.sh §8; the §218-226 direct-cache fallback
243
+ # already enforces `result == "pass"`, so the two paths must agree.
184
244
  if [[ ${#REA_CLI_ARGS[@]} -gt 0 ]]; then
185
- CACHE_RESULT=$("${REA_CLI_ARGS[@]}" cache check "$STAGED_SHA" --branch "$BRANCH" 2>/dev/null || echo '{"hit":false}')
186
- if printf '%s' "$CACHE_RESULT" | jq -e '.hit == true' >/dev/null 2>&1; then
245
+ CACHE_RESULT=$("${REA_CLI_ARGS[@]}" cache check "$STAGED_SHA" --branch "$BRANCH" --base "$BASE_BRANCH" 2>/dev/null || echo '{"hit":false}')
246
+ if printf '%s' "$CACHE_RESULT" | jq -e '.hit == true and .result == "pass"' >/dev/null 2>&1; then
187
247
  CACHE_HIT=true
188
248
  fi
189
249
  fi
@@ -221,8 +281,25 @@ fi
221
281
  printf ' 1. Inspect: git diff --cached\n'
222
282
  printf ' 2. Decide: Is this safe to commit? (initial commits, refactors, and\n'
223
283
  printf ' feature work are normal — use judgement, not ceremony)\n'
224
- printf ' 3. Approve: rea cache set %s pass\n' "$STAGED_SHA"
225
- printf ' 4. Retry the git commit command\n'
284
+ # Defect L follow-up: when no sha256 hasher is available STAGED_SHA is empty
285
+ # and `rea cache set pass` is a dead-end the CLI rejects. Branch the banner
286
+ # to surface an actionable path instead. Unlike push-review-core.sh there is
287
+ # no `REA_SKIP_COMMIT_REVIEW` env escape hatch (the commit gate only fires
288
+ # under Claude Code's Bash `PreToolUse` matcher, so a human direct-shell
289
+ # commit bypasses it entirely). The only remediation is to install a sha256
290
+ # hasher or ask the user to commit directly.
291
+ if [[ -n "$STAGED_SHA" ]]; then
292
+ printf ' 3. Approve: rea cache set %s pass --branch %s --base %s\n' \
293
+ "$STAGED_SHA" "$BRANCH" "$BASE_BRANCH"
294
+ printf ' 4. Retry the git commit command\n'
295
+ else
296
+ printf ' 3. Cache is DISABLED on this host (no sha256 hasher or no base\n'
297
+ printf ' branch resolvable). Install one of: sha256sum (Linux coreutils),\n'
298
+ printf ' shasum (perl-core), or openssl; or ensure origin/HEAD is set so\n'
299
+ printf ' the gate can identify the merge target. Without these the cache\n'
300
+ printf ' path cannot complete — escalate to the user if neither can be\n'
301
+ printf ' provided.\n'
302
+ fi
226
303
  printf '\n'
227
304
  printf ' Only escalate to the user if you find a genuine problem in the diff.\n'
228
305
  } >&2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bookedsolid/rea",
3
- "version": "0.9.2",
3
+ "version": "0.9.4",
4
4
  "description": "Agentic governance layer for Claude Code — policy enforcement, hook-based safety gates, audit logging, and Codex-integrated adversarial review for AI-assisted projects",
5
5
  "license": "MIT",
6
6
  "author": "Booked Solid Technology <oss@bookedsolid.tech> (https://bookedsolid.tech)",
@@ -222,17 +222,134 @@ if [ -n "$SEC_CHANGESETS" ]; then
222
222
  echo "[smoke] security-claim gate: $(printf '%s\n' "$SEC_CHANGESETS" | wc -l | awk '{print $1}') changeset(s) tagged [security]"
223
223
 
224
224
  SEC_SRC_TESTS="$(cd "$REPO_ROOT" && find src -type f \( -name '*sanitize*.test.ts' -o -name '*security*.test.ts' \) 2>/dev/null | sort)"
225
- if [ -z "$SEC_SRC_TESTS" ]; then
226
- echo "[smoke] FAIL [security] changeset present but no *sanitize*.test.ts or *security*.test.ts under src/" >&2
225
+ # 0.9.3 extension some security hotfixes touch ONLY shell hooks (no TS/dist
226
+ # symbols), so the compiled-symbol gate above doesn't apply. For those, the
227
+ # regression proof lives under __tests__/hooks/*{security,bypass,injection,
228
+ # sanitize}*.test.ts and the tarball must ship the hook file(s) the test
229
+ # exercises. This block runs IN ADDITION to the src/ symbol gate — either
230
+ # layer alone can satisfy a [security] changeset, but at least one MUST.
231
+ SEC_HOOK_TESTS="$(cd "$REPO_ROOT" && find __tests__/hooks -type f \( -name '*security*.test.ts' -o -name '*bypass*.test.ts' -o -name '*sanitize*.test.ts' -o -name '*injection*.test.ts' \) 2>/dev/null | sort)"
232
+
233
+ if [ -z "$SEC_SRC_TESTS" ] && [ -z "$SEC_HOOK_TESTS" ]; then
234
+ echo "[smoke] FAIL — [security] changeset present but no matching regression test found:" >&2
235
+ echo "[smoke] - src/**/(*sanitize*|*security*).test.ts — for compiled-symbol fixes" >&2
236
+ echo "[smoke] - __tests__/hooks/(*security*|*bypass*|*sanitize*|*injection*).test.ts — for hook fixes" >&2
227
237
  echo "[smoke] a security-claim changeset with no matching regression test is a trust violation" >&2
228
238
  exit 2
229
239
  fi
230
240
 
231
- # For each security test, collect the named imports pulled from relative
241
+ # Hook-level gate: for each hook-security test FILE, extract the hook
242
+ # file path(s) it installs/exercises (relative to REPO_ROOT) and assert
243
+ # the tarball ships that hook under node_modules/@bookedsolid/rea/hooks/
244
+ # AND that `rea init` fanned it out to $SMOKE_DIR/.claude/hooks/.
245
+ #
246
+ # Known narrowness (called out honestly rather than papered over):
247
+ # - Granularity is per-test-file, not per-`it()` block. A file may
248
+ # contain multiple `it(...)` cases; the extractor scans the whole
249
+ # file body. In practice a [security]-claim test file should focus
250
+ # on one defect class; mixing unrelated `it()` cases with different
251
+ # hook refs dilutes the proof. PR review is the mitigation.
252
+ # - A [security] file with zero extractable refs fails LOUDLY (see
253
+ # EMPTY_REF_TESTS below). The narrowness only applies to files that
254
+ # do extract refs but don't scope them per `it()`.
255
+ if [ -n "$SEC_HOOK_TESTS" ]; then
256
+ HOOK_MISSING=""
257
+ HOOK_COUNT=0
258
+ EMPTY_REF_TESTS=""
259
+ while IFS= read -r hook_test; do
260
+ [ -z "$hook_test" ] && continue
261
+ # Pull hook paths referenced by the test. Matches forms like:
262
+ # 'hooks', 'push-review-gate.sh'
263
+ # 'hooks', '_lib', 'push-review-core.sh'
264
+ # 'hooks', 'commit-review-gate.sh'
265
+ HOOK_REFS="$(perl -0777 -ne '
266
+ while (/path\.join\(\s*REPO_ROOT\s*,\s*[\x27"]hooks[\x27"](?:\s*,\s*[\x27"]([^\x27"]+)[\x27"])*\s*\)/sg) {
267
+ my $all = $&;
268
+ my @parts;
269
+ while ($all =~ /[\x27"]([^\x27"]+)[\x27"]/g) {
270
+ push @parts, $1 unless $1 eq "REPO_ROOT";
271
+ }
272
+ print join("/", @parts), "\n" if @parts;
273
+ }
274
+ ' "$REPO_ROOT/$hook_test" 2>/dev/null | sort -u)"
275
+
276
+ # Per-test failure: a [security] hook-test that yields zero extractable
277
+ # refs (e.g. uses template literals, dynamic concatenation, or helper
278
+ # indirection) is invisible to this gate. Fail loud so the author is
279
+ # forced to use the extractable path.join(REPO_ROOT, 'hooks', ...) shape,
280
+ # rather than having a lone extractable neighbor test silently satisfy
281
+ # the whole gate.
282
+ if [ -z "$HOOK_REFS" ]; then
283
+ EMPTY_REF_TESTS="$EMPTY_REF_TESTS
284
+ $hook_test"
285
+ continue
286
+ fi
287
+
288
+ while IFS= read -r rel; do
289
+ [ -z "$rel" ] && continue
290
+ HOOK_COUNT=$((HOOK_COUNT + 1))
291
+ # `rel` already includes the leading `hooks/` segment from perl. It
292
+ # looks like `hooks/push-review-gate.sh` or
293
+ # `hooks/_lib/push-review-core.sh`. The tarball ships hooks under
294
+ # `node_modules/@bookedsolid/rea/hooks/` and `rea init` fans them out
295
+ # to `$SMOKE_DIR/.claude/hooks/`. Assert BOTH — the tarball
296
+ # source-of-truth AND the post-init install surface.
297
+ rel_no_prefix="${rel#hooks/}"
298
+ TARBALL_HOOK="$SMOKE_DIR/node_modules/@bookedsolid/rea/hooks/$rel_no_prefix"
299
+ INSTALLED_HOOK="$SMOKE_DIR/.claude/hooks/$rel_no_prefix"
300
+ if [ ! -f "$TARBALL_HOOK" ] || [ ! -f "$INSTALLED_HOOK" ]; then
301
+ HOOK_MISSING="$HOOK_MISSING
302
+ $rel (exercised by $hook_test)"
303
+ fi
304
+ done <<< "$HOOK_REFS"
305
+ done <<< "$SEC_HOOK_TESTS"
306
+
307
+ if [ -n "$HOOK_MISSING" ]; then
308
+ echo "[smoke] FAIL — [security] hook-test gate: hook file(s) under test are MISSING from tarball:" >&2
309
+ printf '%s\n' "$HOOK_MISSING" >&2
310
+ exit 2
311
+ fi
312
+
313
+ if [ -n "$EMPTY_REF_TESTS" ]; then
314
+ echo "[smoke] FAIL — [security] hook-test gate: one or more hook-security tests yielded zero extractable hook references:" >&2
315
+ printf '%s\n' "$EMPTY_REF_TESTS" >&2
316
+ echo "[smoke] hook-security tests MUST reference hook files via the literal shape" >&2
317
+ echo "[smoke] path.join(REPO_ROOT, 'hooks', '<name>.sh') (or with a nested '_lib' arg)" >&2
318
+ echo "[smoke] Template literals, dynamic concatenation, and helper indirection are" >&2
319
+ echo "[smoke] invisible to this gate and would let hook changes ship unverified." >&2
320
+ exit 2
321
+ fi
322
+
323
+ if [ "$HOOK_COUNT" -eq 0 ]; then
324
+ echo "[smoke] FAIL — [security] hook-test gate: no checkable hook references extracted" >&2
325
+ echo "[smoke] hook-security tests must reference hook files via path.join(REPO_ROOT, 'hooks', ...)" >&2
326
+ echo "[smoke] so the gate can verify the hook ships in the tarball" >&2
327
+ exit 2
328
+ fi
329
+
330
+ echo "[smoke] → $(printf '%s\n' "$SEC_HOOK_TESTS" | wc -l | awk '{print $1}') hook-security test(s), $HOOK_COUNT hook ref(s) all present in tarball"
331
+ fi
332
+
333
+ # The compiled-symbol gate below only runs when src/ security tests exist.
334
+ # A hook-only hotfix satisfies via the block above. Flag the src/ gate to
335
+ # skip gracefully — the remaining smoke checks (export resolution, tree
336
+ # equality) still run unconditionally below.
337
+ SKIP_SRC_SYMBOL_GATE=0
338
+ if [ -z "$SEC_SRC_TESTS" ]; then
339
+ SKIP_SRC_SYMBOL_GATE=1
340
+ fi
341
+
342
+ # For each src security test, collect the named imports pulled from relative
232
343
  # paths — those are the symbols under test and must be compiled into dist/.
233
344
  # Example line we want to match:
234
345
  # import { sanitizeHealthSnapshot, INJECTION_REDACTED_PLACEHOLDER } from './health';
235
346
  # We ignore imports from bare package names ('vitest', 'node:fs', etc.).
347
+ #
348
+ # Skipped when only __tests__/hooks/ security tests exist (hook-only hotfix);
349
+ # the hook-test gate above is authoritative for that case.
350
+ if [ "$SKIP_SRC_SYMBOL_GATE" = "1" ]; then
351
+ echo "[smoke] → src/ symbol gate skipped (no src/*{security,sanitize}*.test.ts — hook-test gate is authoritative)"
352
+ else
236
353
  MISSING_SYMBOLS=""
237
354
  SYMBOL_COUNT=0
238
355
  while IFS= read -r src_test; do
@@ -294,6 +411,7 @@ if [ -n "$SEC_CHANGESETS" ]; then
294
411
  fi
295
412
 
296
413
  echo "[smoke] → $(printf '%s\n' "$SEC_SRC_TESTS" | wc -l | awk '{print $1}') security regression test(s), $SYMBOL_COUNT imported symbol(s) all present in dist/"
414
+ fi # SKIP_SRC_SYMBOL_GATE
297
415
  fi
298
416
 
299
417
  # Verify every declared public export resolves. If the exports map points at a