@bookedsolid/rea 0.25.0 → 0.26.1

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.
@@ -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