@bookedsolid/rea 0.25.0 → 0.26.0

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.
@@ -493,8 +493,21 @@ _rea_strip_prefix() {
493
493
  *)
494
494
  # Env-var assignment prefix (`KEY=value `) — only strip if the
495
495
  # token before the first space looks like NAME=value.
496
- if [[ "$seg" =~ ^[A-Za-z_][A-Za-z0-9_]*=[^[:space:]]+[[:space:]]+ ]]; then
497
- seg="${seg#* }"
496
+ #
497
+ # 0.26.0 round-25 P2-A fix: ANSI-C quoting `$'...'` was previously
498
+ # uncovered. `FOO=$'a b' git push` evaded the env-prefix stripper
499
+ # (whose value pattern `[^[:space:]]+` bailed at the space inside
500
+ # the ANSI-C body) AND the local-review-gate's raw-fallback regex.
501
+ # Add an explicit ANSI-C alternative here so the prefix is stripped
502
+ # cleanly even when the value carries embedded whitespace inside
503
+ # `$'...'`. Bash 3.2+ regex doesn't support non-capturing groups,
504
+ # so we keep the alternation flat.
505
+ if [[ "$seg" =~ ^[A-Za-z_][A-Za-z0-9_]*=([^[:space:]\"\'$]+|\"[^\"]*\"|\'[^\']*\'|\$\'[^\']*\')[[:space:]]+ ]]; then
506
+ # Compute prefix length and slice — `seg=${seg#* }` would split
507
+ # on the first space, which is INSIDE the value for ANSI-C and
508
+ # quoted forms. Slice by the matched length instead.
509
+ local _prefix_len=${#BASH_REMATCH[0]}
510
+ seg="${seg:_prefix_len}"
498
511
  seg="${seg#"${seg%%[![:space:]]*}"}"
499
512
  else
500
513
  break
@@ -621,3 +634,128 @@ any_segment_starts_with() {
621
634
  done < <(_rea_split_segments "$cmd")
622
635
  return 1
623
636
  }
637
+
638
+ # Return on stdout the FIRST segment of $1 (RAW form, env-var prefixes
639
+ # preserved) whose prefix-stripped form starts with the extended regex
640
+ # $2. Returns empty stdout and exit 1 if no segment matches.
641
+ #
642
+ # Use this when a downstream check needs to scope further parsing to the
643
+ # specific segment that triggered detection — e.g. local-review-gate's
644
+ # inline-bypass regex must only match `VAR=val git push` shapes inside
645
+ # the SAME segment that contained the `git push`, not anywhere in $CMD.
646
+ # Segment-scoped capture closes the round-24 P1 bypass class where
647
+ # `true VAR=fake git status; git push origin main` had the bypass shape
648
+ # in segment 1 and the real push in segment 2 — the un-scoped regex
649
+ # previously honored the bypass for the unrelated push.
650
+ #
651
+ # 0.26.0 helix-024 round-24 fix.
652
+ find_first_segment_starting_with() {
653
+ local cmd="$1"
654
+ local pattern="$2"
655
+ local segment stripped
656
+ while IFS= read -r segment; do
657
+ stripped=$(_rea_strip_prefix "$segment")
658
+ if printf '%s' "$stripped" | grep -qiE "^${pattern}"; then
659
+ printf '%s' "$segment"
660
+ return 0
661
+ fi
662
+ done < <(_rea_split_segments "$cmd")
663
+ return 1
664
+ }
665
+
666
+ # Return on stdout the FIRST segment of $1 (RAW — no prefix-stripping,
667
+ # but with leading whitespace trimmed for clean anchor matching) that
668
+ # matches the extended regex $2. Returns empty stdout and exit 1 if no
669
+ # segment matches.
670
+ #
671
+ # Companion to `any_segment_raw_matches`. Used by local-review-gate to
672
+ # capture the segment whose RAW shape (env-var prefixes intact) triggered
673
+ # the fallback `^([NAME=...])+git push` detector. The prefix-stripper's
674
+ # regex `NAME=[^[:space:]]+[[:space:]]+` bails on quoted-value-with-spaces,
675
+ # so `any_segment_starts_with` misses `REA_SKIP="urgent fix" git push`;
676
+ # the raw-anchor fallback catches it. Round-24's segment-scoped bypass
677
+ # regex must run against the SAME segment (raw form) that the fallback
678
+ # matched, not against the whole $CMD.
679
+ #
680
+ # 0.26.0 helix-024 round-24 fix.
681
+ find_first_segment_raw_matches() {
682
+ local cmd="$1"
683
+ local pattern="$2"
684
+ local segment
685
+ while IFS= read -r segment; do
686
+ segment="${segment#"${segment%%[![:space:]]*}"}"
687
+ if printf '%s' "$segment" | grep -qiE "$pattern"; then
688
+ printf '%s' "$segment"
689
+ return 0
690
+ fi
691
+ done < <(_rea_split_segments "$cmd")
692
+ return 1
693
+ }
694
+
695
+ # Return on stdout EVERY segment of $1 (RAW form) whose prefix-stripped
696
+ # form starts with the extended regex $2. Each match is a separate line.
697
+ # Returns empty stdout and exit 1 if no segments match.
698
+ #
699
+ # Use this when a downstream check needs to validate EVERY trigger segment
700
+ # — e.g. local-review-gate's per-segment bypass requirement. Pre-round-25
701
+ # fix the gate captured only the FIRST trigger segment via
702
+ # `find_first_segment_starting_with` and scoped the inline-bypass regex
703
+ # there. Multi-push laundering PoCs:
704
+ #
705
+ # REA_SKIP="x" git push fake --dry-run; git push origin main
706
+ # → first push has bypass, second does not. Pre-fix: bypass honored
707
+ # for FIRST segment only, but the gate exited 0 globally; second
708
+ # (real) push went through ungated.
709
+ #
710
+ # Round-25 P1-B closes that class by sweeping ALL trigger segments and
711
+ # requiring that EVERY one carries its own bypass. Any trigger segment
712
+ # without a bypass forces preflight invocation.
713
+ #
714
+ # 0.26.0 helix-026 round-25 P1-B fix.
715
+ find_all_segments_starting_with() {
716
+ local cmd="$1"
717
+ local pattern="$2"
718
+ local segment stripped
719
+ local _matched=0
720
+ while IFS= read -r segment; do
721
+ stripped=$(_rea_strip_prefix "$segment")
722
+ if printf '%s' "$stripped" | grep -qiE "^${pattern}"; then
723
+ printf '%s\n' "$segment"
724
+ _matched=1
725
+ fi
726
+ done < <(_rea_split_segments "$cmd")
727
+ if [ "$_matched" -eq 0 ]; then
728
+ return 1
729
+ fi
730
+ return 0
731
+ }
732
+
733
+ # Return on stdout EVERY segment of $1 (RAW — no prefix-stripping, but
734
+ # with leading whitespace trimmed for clean anchor matching) that matches
735
+ # the extended regex $2. Each match is a separate line. Returns empty
736
+ # stdout and exit 1 if no segments match.
737
+ #
738
+ # Companion to `find_all_segments_starting_with`. Used by
739
+ # local-review-gate's round-25 P1-B fix to sweep every trigger segment
740
+ # whose RAW shape (env-var prefixes intact) triggered the
741
+ # `^([NAME=...])+git push` fallback detector — so each can be validated
742
+ # for bypass independently.
743
+ #
744
+ # 0.26.0 helix-026 round-25 P1-B fix.
745
+ find_all_segments_raw_matches() {
746
+ local cmd="$1"
747
+ local pattern="$2"
748
+ local segment
749
+ local _matched=0
750
+ while IFS= read -r segment; do
751
+ segment="${segment#"${segment%%[![:space:]]*}"}"
752
+ if printf '%s' "$segment" | grep -qiE "$pattern"; then
753
+ printf '%s\n' "$segment"
754
+ _matched=1
755
+ fi
756
+ done < <(_rea_split_segments "$cmd")
757
+ if [ "$_matched" -eq 0 ]; then
758
+ return 1
759
+ fi
760
+ return 0
761
+ }
@@ -141,6 +141,261 @@ policy_list() {
141
141
  done < "$policy"
142
142
  }
143
143
 
144
+ # Resolve the rea binary the same 4-branch ladder used by
145
+ # `local-review-gate.sh` and `templates/pre-push.local-first.sh`. Echoes
146
+ # the resolved command (one shell-token per line) on stdout when found,
147
+ # nothing when the ladder exhausts. The caller passes the result to
148
+ # `read -ra` to materialize a bash array.
149
+ #
150
+ # Round-30 F2: shared helper used by `policy_nested_scalar` to invoke
151
+ # `rea hook policy-get` for canonical inline+block YAML reads. Falling
152
+ # open to empty when no rea CLI is reachable keeps the bash gates
153
+ # advisory rather than fail-closed on missing tooling — same posture
154
+ # `local-review-gate.sh` itself takes.
155
+ _rea_resolve_bin() {
156
+ local root="${REA_ROOT:-}"
157
+ if [[ -z "$root" ]]; then
158
+ if command -v rea_root >/dev/null 2>&1; then
159
+ root=$(rea_root)
160
+ else
161
+ root="${CLAUDE_PROJECT_DIR:-$(pwd)}"
162
+ fi
163
+ fi
164
+ if [ -x "${root}/node_modules/.bin/rea" ]; then
165
+ printf '%s\n' "${root}/node_modules/.bin/rea"
166
+ return 0
167
+ fi
168
+ if [ -f "${root}/dist/cli/index.js" ] \
169
+ && [ -f "${root}/package.json" ] \
170
+ && grep -q '"name": *"@bookedsolid/rea"' "${root}/package.json" 2>/dev/null; then
171
+ printf 'node\n'
172
+ printf '%s\n' "${root}/dist/cli/index.js"
173
+ return 0
174
+ fi
175
+ if command -v rea >/dev/null 2>&1; then
176
+ printf 'rea\n'
177
+ return 0
178
+ fi
179
+ if command -v npx >/dev/null 2>&1; then
180
+ printf 'npx\n'
181
+ printf -- '--no-install\n'
182
+ printf '@bookedsolid/rea\n'
183
+ return 0
184
+ fi
185
+ return 1
186
+ }
187
+
188
+ # Read the value of a nested scalar under a parent key.
189
+ # Usage: policy_nested_scalar "review" "local_review" "mode"
190
+ # Prints empty when any link in the chain is missing.
191
+ #
192
+ # Round-30 F2 (structural): delegates to `rea hook policy-get` so
193
+ # the bash reader and the TS loader use the same parser. Pre-fix the
194
+ # bash function only matched block-form mappings (parent+child+grandchild
195
+ # at increasing indents) and silently missed inline forms like
196
+ # `local_review: { mode: off }`. The TS loader (yaml.parse) accepts both
197
+ # forms — silent split-brain. Routing through the canonical parser
198
+ # closes the divergence by construction.
199
+ #
200
+ # Fallback: when the rea CLI cannot be located AT ALL (no
201
+ # node_modules/.bin/rea, no dogfood dist, no PATH, no npx), the
202
+ # function falls back to the legacy awk parser. This preserves the
203
+ # pre-0.27 behavior on machines that have not installed rea yet —
204
+ # the gate stays advisory, not fail-closed on missing tooling. The
205
+ # awk fallback keeps the block-only limitation; that's acceptable
206
+ # for the no-CLI scenario because consumers without rea installed
207
+ # can't have edited a rea-format policy anyway.
208
+ policy_nested_scalar() {
209
+ local parent="$1"
210
+ local child="$2"
211
+ local grandchild="$3"
212
+ local policy
213
+ policy=$(policy_path)
214
+ [[ -z "$policy" ]] && return 0
215
+
216
+ # Try the canonical TS reader first. `read -ra` materializes the
217
+ # newline-delimited resolver output into a bash array; an empty
218
+ # result means the ladder exhausted (no CLI reachable).
219
+ local resolved
220
+ resolved=$(_rea_resolve_bin 2>/dev/null) || resolved=""
221
+ if [[ -n "$resolved" ]]; then
222
+ local rea_cmd=()
223
+ while IFS= read -r tok; do
224
+ [[ -n "$tok" ]] && rea_cmd+=("$tok")
225
+ done <<< "$resolved"
226
+ if [[ ${#rea_cmd[@]} -gt 0 ]]; then
227
+ local out
228
+ out=$("${rea_cmd[@]}" hook policy-get "${parent}.${child}.${grandchild}" 2>/dev/null) || out=""
229
+ printf '%s' "$out"
230
+ return 0
231
+ fi
232
+ fi
233
+
234
+ # No rea CLI reachable — fall back to the legacy block-form awk
235
+ # parser. Inline forms still miss in this branch, but the consumer
236
+ # has bigger problems (no rea binary at all).
237
+ _rea_awk_nested_scalar "$parent" "$child" "$grandchild"
238
+ }
239
+
240
+ # Round-30 F2 perf optimization: cache the entire `review.local_review`
241
+ # subtree as JSON on first read. Three rea-CLI spawns per Bash hook fire
242
+ # (200ms each = ~0.6s overhead) is unacceptable; ONE spawn for all three
243
+ # fields is acceptable (~200ms once per hook). The JSON is parsed via jq
244
+ # (already a hook dependency) for each individual field.
245
+ #
246
+ # `_REA_LOCAL_REVIEW_JSON_CACHE` is process-scoped (set when the hook
247
+ # sources this file). Empty string before the first read; either `null`
248
+ # or a JSON object after. The `_REA_LOCAL_REVIEW_JSON_LOADED` flag
249
+ # distinguishes "not yet read" from "read and value was null". Both
250
+ # cleared at re-source time so each hook fire starts fresh.
251
+ _REA_LOCAL_REVIEW_JSON_CACHE=""
252
+ _REA_LOCAL_REVIEW_JSON_LOADED=0
253
+
254
+ _rea_load_local_review_json() {
255
+ if [[ $_REA_LOCAL_REVIEW_JSON_LOADED -eq 1 ]]; then
256
+ return 0
257
+ fi
258
+ _REA_LOCAL_REVIEW_JSON_LOADED=1
259
+ local policy
260
+ policy=$(policy_path)
261
+ if [[ -z "$policy" ]]; then
262
+ _REA_LOCAL_REVIEW_JSON_CACHE='null'
263
+ return 0
264
+ fi
265
+
266
+ # Try the canonical TS reader for the entire subtree as JSON. Falls
267
+ # back to awk-fallback emulation when the rea CLI is unreachable.
268
+ local resolved
269
+ resolved=$(_rea_resolve_bin 2>/dev/null) || resolved=""
270
+ if [[ -n "$resolved" ]]; then
271
+ local rea_cmd=()
272
+ while IFS= read -r tok; do
273
+ [[ -n "$tok" ]] && rea_cmd+=("$tok")
274
+ done <<< "$resolved"
275
+ if [[ ${#rea_cmd[@]} -gt 0 ]]; then
276
+ local out
277
+ out=$("${rea_cmd[@]}" hook policy-get review.local_review --json 2>/dev/null) || out=""
278
+ if [[ -n "$out" ]]; then
279
+ _REA_LOCAL_REVIEW_JSON_CACHE="$out"
280
+ return 0
281
+ fi
282
+ fi
283
+ fi
284
+
285
+ # Fallback: synthesize a JSON object from individual awk reads. This
286
+ # is the only path on machines without rea reachable. Inline-form
287
+ # values still miss in the awk fallback (the documented divergence
288
+ # from when no CLI is reachable), but block-form values work.
289
+ local mode_val refuse_val bypass_val
290
+ mode_val=$(_rea_awk_nested_scalar "review" "local_review" "mode")
291
+ refuse_val=$(_rea_awk_nested_scalar "review" "local_review" "refuse_at")
292
+ bypass_val=$(_rea_awk_nested_scalar "review" "local_review" "bypass_env_var")
293
+ if command -v jq >/dev/null 2>&1; then
294
+ _REA_LOCAL_REVIEW_JSON_CACHE=$(jq -n \
295
+ --arg mode "$mode_val" \
296
+ --arg refuse_at "$refuse_val" \
297
+ --arg bypass_env_var "$bypass_val" \
298
+ 'def s($x): if $x == "" then null else $x end;
299
+ {mode: s($mode), refuse_at: s($refuse_at), bypass_env_var: s($bypass_env_var)}')
300
+ else
301
+ # No jq either — synthesize minimal JSON via printf. Values are
302
+ # YAML scalars; we re-quote them safely. Empty values become null.
303
+ _REA_LOCAL_REVIEW_JSON_CACHE='null'
304
+ fi
305
+ }
306
+
307
+ # Helper: read from the cached local_review JSON via jq. Echoes the
308
+ # scalar value or empty string when missing/null.
309
+ _rea_local_review_get() {
310
+ local field="$1"
311
+ _rea_load_local_review_json
312
+ if [[ "$_REA_LOCAL_REVIEW_JSON_CACHE" == "null" || -z "$_REA_LOCAL_REVIEW_JSON_CACHE" ]]; then
313
+ return 0
314
+ fi
315
+ if ! command -v jq >/dev/null 2>&1; then
316
+ return 0
317
+ fi
318
+ local v
319
+ v=$(printf '%s' "$_REA_LOCAL_REVIEW_JSON_CACHE" | jq -r --arg f "$field" '.[$f] // empty' 2>/dev/null) || v=""
320
+ printf '%s' "$v"
321
+ }
322
+
323
+ # Read `policy.review.local_review.mode`. Prints "enforced" or "off"
324
+ # (defaults to empty when unset — the caller treats empty as "enforced",
325
+ # the protective default). Added 0.26.0; round-30 F2 routes through the
326
+ # canonical TS YAML parser so inline AND block forms both work.
327
+ policy_get_local_review_mode() {
328
+ _rea_local_review_get "mode"
329
+ }
330
+
331
+ # Read `policy.review.local_review.refuse_at`. Prints "push" / "commit"
332
+ # / "both" or empty when unset (default "push"). Added 0.26.0.
333
+ policy_get_local_review_refuse_at() {
334
+ _rea_local_review_get "refuse_at"
335
+ }
336
+
337
+ # Read `policy.review.local_review.bypass_env_var`. Prints the configured
338
+ # env-var name or empty when unset (default REA_SKIP_LOCAL_REVIEW).
339
+ # Added 0.26.0.
340
+ policy_get_local_review_bypass_env_var() {
341
+ _rea_local_review_get "bypass_env_var"
342
+ }
343
+
344
+ # Internal: pure-awk nested-scalar reader. Block-form only — used as the
345
+ # fallback when no rea CLI is reachable. Same body as the historical
346
+ # `policy_nested_scalar` awk parser, lifted into a private helper so
347
+ # the public function can route through `rea hook policy-get` first.
348
+ _rea_awk_nested_scalar() {
349
+ local parent="$1"
350
+ local child="$2"
351
+ local grandchild="$3"
352
+ local policy
353
+ policy=$(policy_path)
354
+ [[ -z "$policy" ]] && return 0
355
+ awk -v parent="$parent" -v child="$child" -v grandchild="$grandchild" '
356
+ function indent_of(line, n, c) {
357
+ n = 0
358
+ while (n < length(line)) {
359
+ c = substr(line, n + 1, 1)
360
+ if (c == " " || c == "\t") n++
361
+ else break
362
+ }
363
+ return n
364
+ }
365
+ BEGIN { in_parent = 0; parent_indent = -1; in_child = 0; child_indent = -1 }
366
+ {
367
+ ind = indent_of($0)
368
+ stripped = $0
369
+ sub(/^[[:space:]]+/, "", stripped)
370
+ if (!in_parent && stripped ~ ("^" parent ":[[:space:]]*$") && ind == 0) {
371
+ in_parent = 1
372
+ parent_indent = 0
373
+ next
374
+ }
375
+ if (in_parent && ind <= parent_indent && !match(stripped, "^$")) {
376
+ in_parent = 0
377
+ in_child = 0
378
+ }
379
+ if (in_parent && !in_child && stripped ~ ("^" child ":[[:space:]]*$") && ind > parent_indent) {
380
+ in_child = 1
381
+ child_indent = ind
382
+ next
383
+ }
384
+ if (in_child && ind <= child_indent && !match(stripped, "^$")) {
385
+ in_child = 0
386
+ }
387
+ if (in_child && match(stripped, ("^" grandchild ":[[:space:]]+"))) {
388
+ val = stripped
389
+ sub(("^" grandchild ":[[:space:]]+"), "", val)
390
+ sub(/[[:space:]]+#.*$/, "", val)
391
+ gsub(/^["'\'']|["'\'']$/, "", val)
392
+ printf "%s", val
393
+ exit
394
+ }
395
+ }
396
+ ' "$policy"
397
+ }
398
+
144
399
  # Emit each entry of an inline-array body (everything between `[` and
145
400
  # `]`, possibly across newlines if the caller concatenated lines with
146
401
  # spaces). Strips outer brackets, splits on `,`, trims whitespace and