#!/usr/bin/env bash # Pre-commit safety checks for common issues caught by AI code reviewers. # # Can be run standalone: bash scripts/pre-commit-safety.sh # Or installed as a git pre-commit hook via dev-setup.sh. # # Checks staged .rs files for: # 1. Unsafe UTF-8 byte slicing (panics on multi-byte chars) # 2. Case-sensitive file extension comparisons # 3. Hardcoded /tmp paths in tests (flaky in parallel runs) # 4. Tool parameters logged without redaction (secret leaks) # 5. Multi-step DB operations without transaction wrapping # 6. .unwrap(), .expect(), assert!() in production code (panics) # 10. Newly-added pub fn/type/struct/enum/trait with zero callers (dead/speculative API) # 11. Architecture sprawl smoke alarms without arch-exempt tracking plan # 13. Composition mass ratchet — blocks growth of ironclaw_composition's # share of production crate code past the committed budget (runs only when # composition or the gate is staged). See scripts/ci/check-composition-budget.sh. # # (Checks 7–9 and 12 — the v1 gateway DISPATCH / CREDNAME / SSE-projection / # multi-tenant-broadcast checks, plus the gateway i18n-parity and JS-syntax # runs — were removed under Tier B when the `src/channels/web/` tree and # `crates/ironclaw_gateway` were deleted. Reborn WebUI JS/i18n is validated by # the frontend's own tooling; the dispatch/projection invariants are enforced # in the product/composition crates and by `cargo test -p ironclaw_architecture_tests`.) # # Suppress individual lines with an inline "// safety: " comment. # For check #10, use "// pub-api-exempt: " instead. # For check #11, use "// arch-exempt: , , plan #NNNN" instead. set -euo pipefail TEST_BOUNDARIES_FILE="" GATEWAY_APP_JS_TMP="" # Determine a suitable base ref for standalone diffs. resolve_base_ref() { local candidates=( "@{upstream}" "origin/HEAD" "origin/main" "origin/master" "main" "master" ) for ref in "${candidates[@]}"; do if git rev-parse --verify --quiet "$ref" >/dev/null 2>&1; then echo "$ref" return 0 fi done echo "pre-commit-safety: could not determine a base Git ref for diff (tried: ${candidates[*]})." >&2 echo "pre-commit-safety: ensure your repository has an upstream or a local main/master branch." >&2 exit 1 } # Record whether there are staged changes (used throughout below). The v1 # gateway i18n-parity and gateway-JS-syntax blocks that used to run here were # removed under Tier B along with `crates/ironclaw_gateway` — Reborn WebUI # i18n and JS are validated by the frontend's own `pnpm lint`/`pnpm test` # (the `webui-v2-js-lint` CI job). if git diff --cached --quiet 2>/dev/null; then HAS_STAGED_CHANGES=0 else HAS_STAGED_CHANGES=1 fi # Composition mass ratchet: block commits that push ironclaw_composition's # share of production crate code past the committed ceiling # (scripts/ci/composition-budget.toml). Triggers on ANY staged crates/**.rs change # (composition growth OR another crate shrinking both lift the share — the metric # is a ratio) or a change to the gate itself. # # LIMITATION: the gate measures the WORKING TREE, not the staged index, so an # unstaged edit can mask a staged breach or cause a false block. This hook is a # fast local advisory; CI (.github/workflows/code_style.yml) runs the gate on the # actual merge commit and is the authoritative check. if [ "$HAS_STAGED_CHANGES" -eq 1 ]; then COMPOSITION_STAGED=$(git diff --cached --name-only -- \ 'crates/**/*.rs' \ 'scripts/ci/composition-budget.toml' \ 'scripts/ci/check-composition-budget.sh' 2>/dev/null || true) else COMPOSITION_STAGED=$(git diff --name-only -- \ 'crates/**/*.rs' \ 'scripts/ci/composition-budget.toml' \ 'scripts/ci/check-composition-budget.sh' 2>/dev/null || true) fi if [ -n "$COMPOSITION_STAGED" ]; then SOURCE="${BASH_SOURCE[0]:-$0}" while [ -L "$SOURCE" ]; do LINK_TARGET="$(readlink "$SOURCE")" case "$LINK_TARGET" in /*) SOURCE="$LINK_TARGET" ;; *) SOURCE="$(cd "$(dirname "$SOURCE")" && pwd)/$LINK_TARGET" ;; esac done PCS_SCRIPT_DIR="$(cd "$(dirname "$SOURCE")" && pwd)" if ! "$PCS_SCRIPT_DIR/ci/check-composition-budget.sh"; then echo "" echo "Commit blocked: composition mass budget exceeded (see above)." echo "Carve behavior out of ironclaw_composition, or raise the ceiling in" echo "scripts/ci/composition-budget.toml with a rationale. Bypass: git commit --no-verify" exit 1 fi fi # Support both pre-commit hook (staged files) and standalone (all changed vs base) if git diff --cached --quiet 2>/dev/null; then # No staged changes -- compare working tree against a resolved base ref BASE_REF="$(resolve_base_ref)" DIFF_OUTPUT=$(git diff "$BASE_REF" -- '*.rs' 2>/dev/null || true) CHANGED_FILES=$(git diff --name-only "$BASE_REF" -- '*.rs' 2>/dev/null || true) else DIFF_OUTPUT=$(git diff --cached -U0 -- '*.rs' 2>/dev/null || true) CHANGED_FILES=$(git diff --cached --name-only --diff-filter=AM -- '*.rs' 2>/dev/null || true) fi # Early exit if there are no relevant .rs changes if [ -z "$DIFF_OUTPUT" ]; then exit 0 fi # Build a "test mod start line" lookup per changed file by scanning the actual # file content. The previous heuristic relied on `mod tests` appearing in the # git diff hunk header (`@@ ... mod tests @@`), but that context only shows up # for tiny edits inside a known test fn — and never for brand-new files added # in a merge. Reading the file directly catches both cases. TEST_BOUNDARIES_FILE=$(mktemp) trap 'rm -f "${TEST_BOUNDARIES_FILE:-}" "${GATEWAY_APP_JS_TMP:-}"' EXIT for f in $CHANGED_FILES; do [ -f "$f" ] || continue test_line=$(awk ' /^#\[cfg\(test\)\]/ { cfg_at = NR; next } cfg_at && /^mod tests([[:space:]]*\{)?[[:space:]]*$/ { print NR; exit } cfg_at && NF > 0 && !/^[[:space:]]*$/ { cfg_at = 0 } ' "$f") if [ -n "$test_line" ]; then printf '%s\t%s\n' "$f" "$test_line" >> "$TEST_BOUNDARIES_FILE" fi done # Filter a unified diff (DIFF_OUTPUT-shaped) to drop `+` lines that come from # test code: either inside a `#[cfg(test)] mod tests` block (per the # precomputed boundaries) or in any file under the top-level `tests/` dir. # Marker, header, and context lines pass through untouched so downstream # grep/awk pipelines still see file/hunk anchors. strip_test_mod_lines() { awk -v boundaries="$TEST_BOUNDARIES_FILE" ' BEGIN { while ((getline line < boundaries) > 0) { idx = index(line, "\t") if (idx) { f = substr(line, 1, idx - 1) test_start[f] = substr(line, idx + 1) + 0 } } close(boundaries) cur_file = "" cur_start = 0 cur_skip_all = 0 new_line = 0 } /^\+\+\+ b\// { cur_file = substr($0, 7) cur_start = (cur_file in test_start) ? test_start[cur_file] : 0 cur_skip_all = (cur_file ~ /(^|\/)tests\// || cur_file ~ /(^|\/)tests\.rs$/ || cur_file ~ /(^|\/)test_[^\/]+\.rs$/ || cur_file ~ /(^|\/)[^\/]+_tests?\.rs$/) new_line = 0 print next } /^\+\+\+ / { cur_file = ""; cur_start = 0; cur_skip_all = 0; new_line = 0 print next } /^--- / { print; next } /^@@ / { # Parse new-file starting line from `@@ -X[,Y] +U[,V] @@` n = split($0, parts, " ") for (i = 1; i <= n; i++) { if (substr(parts[i], 1, 1) == "+") { range = substr(parts[i], 2) c = index(range, ",") if (c) range = substr(range, 1, c - 1) new_line = range + 0 - 1 break } } print next } /^\+/ { new_line++ if (cur_skip_all) next if (cur_start > 0 && new_line >= cur_start) next print next } /^-/ { print; next } { new_line++; print } ' } DIFF_OUTPUT_NO_TESTS=$(printf '%s\n' "$DIFF_OUTPUT" | strip_test_mod_lines) WARNINGS=0 warn() { if [ "$WARNINGS" -eq 0 ]; then echo "" echo "=== Pre-commit Safety Checks ===" echo "" fi WARNINGS=$((WARNINGS + 1)) echo " [$1] $2" } arch_exempt_re() { local category="$1" printf '//[[:space:]]*arch-exempt:[[:space:]]*%s,[[:space:]]*.+,[[:space:]]*plan #[0-9]+' "$category" } diff_for_file() { local f="$1" if [ "$HAS_STAGED_CHANGES" -eq 1 ]; then git diff --cached -U0 -- "$f" 2>/dev/null || true else git diff "$BASE_REF" -U0 -- "$f" 2>/dev/null || true fi } file_content_for_file() { local f="$1" if [ "$HAS_STAGED_CHANGES" -eq 1 ]; then git show ":$f" 2>/dev/null || true else cat "$f" 2>/dev/null || true fi } # 1. Unsafe UTF-8 byte slicing: &s[..N] or &s[..some_var] on strings # Safe patterns: is_char_boundary, char_indices, // safety: if echo "$DIFF_OUTPUT_NO_TESTS" | grep -nE '^\+' | grep -E '\[\.\..*\]' | grep -vE 'is_char_boundary|char_indices|// safety:|as_bytes|Vec<|&\[u8\]|\[u8\]|bytes\(\)|&bytes' | head -3 | grep -q .; then warn "UTF8" "Possible unsafe byte-index string slicing. Use is_char_boundary() or char_indices()." echo "$DIFF_OUTPUT_NO_TESTS" | grep -nE '^\+' | grep -E '\[\.\..*\]' | grep -vE 'is_char_boundary|char_indices|// safety:|as_bytes|Vec<|&\[u8\]|\[u8\]|bytes\(\)|&bytes' | head -3 | sed 's/^/ /' fi # 2. Case-sensitive file extension checks # Match: .ends_with(".png") without prior to_lowercase if echo "$DIFF_OUTPUT" | grep -nE '^\+.*ends_with\("\.([pP][nN][gG]|[jJ][pP][eE]?[gG]|[gG][iI][fF]|[wW][eE][bB][pP]|[mM][dD])"\)' | grep -vE 'to_lowercase|to_ascii_lowercase|// safety:' | head -3 | grep -q .; then warn "CASE" "Case-sensitive file extension comparison. Normalize to lowercase first." echo "$DIFF_OUTPUT" | grep -nE '^\+.*ends_with\("\.([pP][nN][gG]|[jJ][pP][eE]?[gG]|[gG][iI][fF]|[wW][eE][bB][pP]|[mM][dD])"\)' | grep -vE 'to_lowercase|to_ascii_lowercase|// safety:' | head -3 | sed 's/^/ /' fi # 3. Hardcoded /tmp paths in test files if echo "$DIFF_OUTPUT_NO_TESTS" | grep -nE '^\+.*"/tmp/' | grep -vE 'tempfile|tempdir|// safety:' | head -3 | grep -q .; then warn "TMPDIR" "Hardcoded /tmp path. Use tempfile::tempdir() for parallel-safe tests." echo "$DIFF_OUTPUT_NO_TESTS" | grep -nE '^\+.*"/tmp/' | grep -vE 'tempfile|tempdir|// safety:' | head -3 | sed 's/^/ /' fi # 4. Logging tool parameters without redaction if echo "$DIFF_OUTPUT" | grep -nE '^\+.*tracing::(info|debug|warn|error).*param' | grep -vE 'redact|// safety:' | head -3 | grep -q .; then warn "REDACT" "Logging tool parameters without redaction. Use redact_params() first." echo "$DIFF_OUTPUT" | grep -nE '^\+.*tracing::(info|debug|warn|error).*param' | grep -vE 'redact|// safety:' | head -3 | sed 's/^/ /' fi # 5. Multi-step DB operations without transaction # Uses -W (function context) to reduce false positives from existing transactions. # Suppressible with "// safety:" in the hunk. DIFF_W_OUTPUT=$(git diff --cached -W -- '*.rs' 2>/dev/null || git diff "$(resolve_base_ref)" -W -- '*.rs' 2>/dev/null || true) if [ -n "$DIFF_W_OUTPUT" ]; then HUNK_COUNT=$(echo "$DIFF_W_OUTPUT" | awk ' /^@@/ { if (count >= 2 && !has_tx && !has_safety) found++ count=0; has_tx=0; has_safety=0 } /^\+.*\.(execute|query)\(/ { count++ } /^\+.*(transaction|\.tx\.|\.begin\()/ { has_tx=1 } / .*(transaction|\.tx\.|\.begin\()/ { has_tx=1 } /\/\/ safety:/ { has_safety=1 } END { if (count >= 2 && !has_tx && !has_safety) found++ print found+0 } ') if [ "$HUNK_COUNT" -gt 0 ]; then warn "TX" "Multiple DB operations in same function without transaction. Wrap in a transaction for atomicity." echo "$DIFF_W_OUTPUT" | awk ' /^@@/ { if (count >= 2 && !has_tx && !has_safety) { print buf } buf=""; count=0; has_tx=0; has_safety=0 } /^\+.*\.(execute|query)\(/ { count++ } /^\+.*(transaction|\.tx\.|\.begin\()/ { has_tx=1 } / .*(transaction|\.tx\.|\.begin\()/ { has_tx=1 } /\/\/ safety:/ { has_safety=1 } { buf = buf "\n" $0 } END { if (count >= 2 && !has_tx && !has_safety) { print buf } } ' | grep -E '^\+.*\.(execute|query)\(' | head -4 | sed 's/^/ /' fi fi # 6. .unwrap(), .expect(), assert!() in production code # Matches added lines containing panic-inducing calls. # Excludes test files, test modules, and debug_assert (compiled out in release). # Suppress with "// safety: ". PROD_DIFF="$DIFF_OUTPUT_NO_TESTS" # Strip hunks from test-only files (tests/ directory, *_test.rs, test_*.rs) PROD_DIFF=$(echo "$PROD_DIFF" | grep -v '^+++ b/tests/' || true) # Strip hunks whose @@ context line indicates a test module. # git diff includes the enclosing function/module name after @@. # Only match `mod tests` (the conventional #[cfg(test)] module) — do NOT # match `fn test_*` because production code can have functions named test_*. PROD_DIFF=$(echo "$PROD_DIFF" | awk ' /^@@ / { in_test = ($0 ~ /mod tests/) } !in_test { print } ' || true) if echo "$PROD_DIFF" | grep -nE '^\+' \ | grep -E '\.(unwrap|expect)\(|[^_]assert(_eq|_ne)?!' \ | grep -vE 'debug_assert|// safety:|#\[cfg\(test\)\]|#\[test\]|mod tests' \ | head -5 | grep -q .; then warn "PANIC" "Production code must not use .unwrap(), .expect(), or assert!(). Use proper error handling." echo "$PROD_DIFF" | grep -nE '^\+' \ | grep -E '\.(unwrap|expect)\(|[^_]assert(_eq|_ne)?!' \ | grep -vE 'debug_assert|// safety:|#\[cfg\(test\)\]|#\[test\]|mod tests' \ | head -5 | sed 's/^/ /' fi # (Checks 7–9 — the v1 gateway DISPATCH / CREDNAME / SSE-PROJECTION checks, # all keyed on the deleted `src/channels/web/` tree — were removed under # Tier B. The Reborn equivalents are enforced in the product/composition # crates and by `cargo test -p ironclaw_architecture_tests`.) # 10. Dead/speculative public API: newly-added `pub fn`/`pub type`/`pub struct`/ # `pub enum`/`pub trait` whose identifier appears exactly once in the whole # repo — i.e. only the definition, no callers/uses. This is the "Theme 1" # maintainability anti-pattern: accessors, `_dyn` variants, and getters that # expand the stable surface with zero consumers. # # Heuristic, not a proof: a brand-new public symbol with a single repo-wide # occurrence is almost always dead-on-arrival. It's a WARNING (the script # only hard-blocks on the documented categories above via the shared # summary, but this check is advisory by design — see note below). # # Only added lines are scanned (`DIFF_OUTPUT_NO_TESTS` already drops test # modules and `tests/` files), so it never trips on pre-existing code or # on test-only helpers. Suppress an intentional new-but-uncalled symbol # (e.g. a trait method implemented elsewhere, an FFI/`#[no_mangle]` export, # or API staged ahead of its first caller in the same series) with # "// pub-api-exempt: " on the declaration line. PUB_API_HITS="" # Pull added pub-item declaration lines, excluding those already exempted. PUB_DECL_LINES=$(echo "$DIFF_OUTPUT_NO_TESTS" | grep -E '^\+' \ | grep -E '^\+[[:space:]]*pub(\([^)]*\))?[[:space:]]+(async[[:space:]]+)?(unsafe[[:space:]]+)?(fn|type|struct|enum|trait)[[:space:]]' \ | grep -vE '// pub-api-exempt:|// safety:' || true) if [ -n "$PUB_DECL_LINES" ]; then while IFS= read -r line; do [ -z "$line" ] && continue # Extract the declared identifier: strip leading `+`, the `pub[(...)]` # qualifier and modifier keywords, the item keyword, then take the # first identifier token (cutting at `<`, `(`, `{`, `:`, whitespace). ident=$(printf '%s\n' "$line" | sed -E \ -e 's/^\+[[:space:]]*//' \ -e 's/^pub(\([^)]*\))?[[:space:]]+//' \ -e 's/^(async[[:space:]]+)?(unsafe[[:space:]]+)?//' \ -e 's/^(fn|type|struct|enum|trait)[[:space:]]+//' \ | grep -oE '^[A-Za-z_][A-Za-z0-9_]*' || true) [ -z "$ident" ] && continue # Count repo-wide occurrences of the identifier as a whole word. # `git grep -w` is fast and respects .gitignore; one hit == definition only. occ=$(git grep -wIE "$ident" -- '*.rs' 2>/dev/null | wc -l | tr -d ' ') if [ "${occ:-0}" -le 1 ]; then PUB_API_HITS="${PUB_API_HITS} ${ident} (${occ:-0} occurrence) — ${line#+} " fi done <<<"$PUB_DECL_LINES" fi if [ -n "$PUB_API_HITS" ]; then warn "PUBAPI" "New \`pub\` item(s) with zero callers/uses (dead or speculative public API — maintainability audit Theme 1). Make them \`pub(crate)\`, delete, or annotate with '// pub-api-exempt: '." printf '%s' "$PUB_API_HITS" fi # 11. Architecture sprawl smoke alarms. # See `.claude/rules/architecture.md`. These checks intentionally cover # only grep-able review flags; semantic identity-copy review stays human. # Suppress with: `// arch-exempt: , , plan #NNNN`. TOO_MANY_ARGS_EXEMPT_RE=$(arch_exempt_re "too_many_args") TOO_MANY_ARGS_HITS=$(printf '%s\n' "$DIFF_OUTPUT_NO_TESTS" | awk -v exempt="$TOO_MANY_ARGS_EXEMPT_RE" ' /^(\+\+\+|---|@@)/ { prev = ""; next } /^\+/ { if ($0 ~ /#\[allow\(clippy::too_many_arguments\)\]/ && prev !~ exempt && $0 !~ exempt) { print $0 } prev = $0 next } /^ / { prev = $0; next } ') if [ -n "$TOO_MANY_ARGS_HITS" ]; then warn "ARCH-SPRAWL" "New #[allow(clippy::too_many_arguments)] requires '// arch-exempt: too_many_args, , plan #NNNN' on the line above." printf '%s\n' "$TOO_MANY_ARGS_HITS" | head -5 | sed 's/^/ /' fi OPTIONAL_ARC_EXEMPT_RE=$(arch_exempt_re "optional_arc") OPTIONAL_ARC_HITS=$(printf '%s\n' "$DIFF_OUTPUT_NO_TESTS" | awk -v exempt="$OPTIONAL_ARC_EXEMPT_RE" ' function flush() { if (file == "") return for (name in fields) { if (builders[name] && (field_added[name] || builder_added[name]) && !field_exempt[name] && !builder_exempt[name]) { print file break } } } function reset_file() { delete fields delete field_added delete field_exempt delete builders delete builder_added delete builder_exempt pending_exempt = 0 } function mark_field(name, added) { fields[name] = 1 if (added) field_added[name] = 1 if (pending_exempt) field_exempt[name] = 1 pending_exempt = 0 } function mark_builder(name, added) { builders[name] = 1 if (added) builder_added[name] = 1 if (pending_exempt) builder_exempt[name] = 1 pending_exempt = 0 } function option_field(line) { sub(/^\+[[:space:]]*/, "", line) sub(/^[[:space:]]*/, "", line) sub(/^pub(\([^)]*\))?[[:space:]]+/, "", line) sub(/[[:space:]]*:.*$/, "", line) return line } function builder_name(line) { sub(/^.*fn[[:space:]]+with_/, "", line) sub(/[[:space:]]*\(.*/, "", line) return line } /^\+\+\+ b\// { flush() file = substr($0, 7) reset_file() next } /^\+\+\+ / { flush(); file = ""; next } /^@@ / { next } /^[+ ].*arch-exempt:/ && $0 ~ exempt { pending_exempt = 1 } /^[+ ].*[A-Za-z_][A-Za-z0-9_]*[[:space:]]*:[[:space:]]*Option> paired with a with_* builder requires '// arch-exempt: optional_arc, , plan #NNNN'." printf '%s\n' "$OPTIONAL_ARC_HITS" | head -5 | sed 's/^/ /' fi PARALLEL_DISPATCH_EXEMPT_RE=$(arch_exempt_re "parallel_dispatch") PARALLEL_DISPATCH_HITS=$(printf '%s\n' "$DIFF_OUTPUT_NO_TESTS" | awk -v exempt="$PARALLEL_DISPATCH_EXEMPT_RE" ' function call_key(line) { if (line ~ /dispatcher\.dispatch[[:space:]]*\(/) return "dispatcher.dispatch" if (line ~ /effects\.execute_action[[:space:]]*\(/) return "effects.execute_action" if (match(line, /safety_layer\.scan_[A-Za-z0-9_]*[[:space:]]*\(/)) return substr(line, RSTART, RLENGTH) return "" } /^\+\+\+ b\// { prev = ""; file = substr($0, 7); next } /^(---|@@)/ { prev = ""; next } /^\+/ { if ($0 ~ exempt) file_exempt[file] = 1 key = call_key($0) if (key != "") { seen[file SUBSEP key]++ } if (key != "" && seen[file SUBSEP key] > 1 && !file_exempt[file] && prev !~ exempt && $0 !~ exempt) { print $0 } prev = $0 next } /^ / { prev = $0; next } ') if [ -n "$PARALLEL_DISPATCH_HITS" ]; then warn "ARCH-SPRAWL" "Repeated known dispatcher/executor/safety call sites in the same file require '// arch-exempt: parallel_dispatch, , plan #NNNN' or a single-gateway refactor." printf '%s\n' "$PARALLEL_DISPATCH_HITS" | head -5 | sed 's/^/ /' fi LARGE_FILE_EXEMPT_RE=$(arch_exempt_re "large_file") LARGE_FILE_HITS="" for f in $CHANGED_FILES; do case "$f" in crates/*.rs) ;; *) continue ;; esac [ -f "$f" ] || continue line_count=$(file_content_for_file "$f" | wc -l | tr -d ' ') [ "${line_count:-0}" -gt 1500 ] || continue file_diff=$(diff_for_file "$f") added_count=$(printf '%s\n' "$file_diff" | awk '/^\+/ && !/^\+\+\+ / { count++ } END { print count + 0 }') [ "${added_count:-0}" -gt 0 ] || continue if ! file_content_for_file "$f" | grep -Eq "$LARGE_FILE_EXEMPT_RE"; then LARGE_FILE_HITS="${LARGE_FILE_HITS} ${f} (${line_count} lines, +${added_count}) " fi done if [ -n "$LARGE_FILE_HITS" ]; then warn "ARCH-SPRAWL" "Adding to a .rs file over 1,500 lines requires '// arch-exempt: large_file, , plan #NNNN' or a decomposition follow-up." printf '%s' "$LARGE_FILE_HITS" | head -5 fi # (Check 12 — the unscoped-`sse.broadcast(...)` multi-tenant leak check keyed # on the deleted v1 gateway SSE manager — was removed under Tier B.) if [ "$WARNINGS" -gt 0 ]; then echo "" echo "Found $WARNINGS potential issue(s). Fix them or add '// safety: ' to suppress." echo "(For PUBAPI warnings, use '// pub-api-exempt: ' instead.)" echo "(For ARCH-SPRAWL warnings, use '// arch-exempt: , , plan #NNNN' instead.)" echo "" exit 1 fi