forgedock 1.14.0 → 1.16.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.
@@ -0,0 +1,102 @@
1
+ #!/usr/bin/env bash
2
+ # check-spec-size.sh — PR spec-size budget (forge#3762).
3
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
4
+ # SPDX-License-Identifier: AGPL-3.0-or-later
5
+ #
6
+ # Usage: check-spec-size.sh [base_ref] (default: $BASE_REF, else origin/staging)
7
+ # Env: PR_LABELS comma-separated PR label names (untrusted; grep-matched only)
8
+ # PR_BODY PR body text (untrusted; grep-matched only)
9
+ # GITHUB_STEP_SUMMARY summary file (stdout when unset)
10
+ # Exit: 0 pass/exempt, 1 budget violation, 2 usage/base-ref error (fail closed)
11
+ #
12
+ # Rules (against the PR base, compared in bytes):
13
+ # 1. Budgeted specs may not grow by more than 2% or more than 4096 bytes.
14
+ # 2. No commands/**/*.md file may gain a canonical block copy (per-file marker
15
+ # count may not rise). Count-based until the shared helper lands (#3493).
16
+ # Shrinking or equal size always passes.
17
+ # Exemption: `spec-size-exempt` label, or a `<!-- spec-size-exempt: <reason> -->`
18
+ # line with a non-empty reason in the PR body. Failures are reported, exit 0.
19
+ # Bash 3.2 portable: no assoc arrays, mapfile, grep -P.
20
+ set -uo pipefail
21
+
22
+ BASE="${1:-${BASE_REF:-origin/staging}}"
23
+ PCT_NUM=2 # 2%
24
+ ABS_MAX=4096 # bytes
25
+ BUDGETED="commands/review-pr.md commands/review-pr-staging.md commands/work-on/remediate.md commands/work-on/review.md commands/work-on.md"
26
+
27
+ SUMMARY="${GITHUB_STEP_SUMMARY:-/dev/stdout}"
28
+ out() { printf '%s\n' "$*" >> "$SUMMARY"; }
29
+
30
+ if ! git rev-parse --verify -q "$BASE^{commit}" >/dev/null; then
31
+ echo "ERROR: base ref '$BASE' cannot be resolved (shallow checkout? need fetch-depth: 0)" >&2
32
+ exit 2
33
+ fi
34
+
35
+ # --- exemption -------------------------------------------------------------
36
+ EXEMPT_REASON=""
37
+ if printf '%s' "${PR_LABELS:-}" | tr ',' '\n' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | grep -qxF 'spec-size-exempt'; then
38
+ EXEMPT_REASON="label spec-size-exempt"
39
+ else
40
+ R=$(printf '%s\n' "${PR_BODY:-}" | tr -d '\r' \
41
+ | sed -n 's/^[[:space:]]*<!--[[:space:]]*spec-size-exempt:[[:space:]]*\(.*\)-->[[:space:]]*$/\1/p' \
42
+ | head -n 1 | sed 's/[[:space:]]*$//')
43
+ [ -n "$R" ] && EXEMPT_REASON="PR body marker: $R"
44
+ fi
45
+
46
+ FAILURES=0
47
+ base_size() { git cat-file -s "$BASE:$1" 2>/dev/null || echo 0; }
48
+ MARKERS='FORGE_ROOT bootstrap \(canonical|TRUSTED_SCRIPT=""|HELPER_SCRIPT=""|mkdir -p "\$_SD".*# FENCE_BOOTSTRAP'
49
+
50
+ out "## Spec-size budget (base: \`$BASE\`)"
51
+ out ""
52
+ out "| File | Base | Head | Delta | Delta % | Status |"
53
+ out "|------|-----:|-----:|------:|--------:|--------|"
54
+ for f in $BUDGETED; do
55
+ [ -f "$f" ] || [ "$(base_size "$f")" != 0 ] || continue
56
+ b=$(base_size "$f")
57
+ h=0; [ -f "$f" ] && h=$(wc -c < "$f" | tr -d ' ')
58
+ g=$((h - b))
59
+ status="ok"
60
+ if [ "$g" -gt 0 ] && { [ $((g * 100)) -gt $((b * PCT_NUM)) ] || [ "$g" -gt "$ABS_MAX" ]; }; then
61
+ status="OVER BUDGET"; FAILURES=$((FAILURES + 1))
62
+ fi
63
+ if [ "$b" -gt 0 ]; then
64
+ p=$(awk -v g="$g" -v b="$b" 'BEGIN{printf "%+.2f%%", g*100/b}')
65
+ else p="n/a"; fi
66
+ out "| \`$f\` | $b | $h | $g | $p | $status |"
67
+ done
68
+
69
+ DUP_ROWS=""
70
+ # Every commands/**/*.md present at head (new files have base count 0).
71
+ for f in $(git ls-files 'commands/*.md' 'commands/**/*.md' | sort -u); do
72
+ [ -f "$f" ] || continue
73
+ hc=$(grep -cE "$MARKERS" "$f" 2>/dev/null || true); hc=${hc:-0}
74
+ bc=$(git show "$BASE:$f" 2>/dev/null | grep -cE "$MARKERS" 2>/dev/null || true); bc=${bc:-0}
75
+ if [ "$hc" -gt "$bc" ]; then
76
+ DUP_ROWS="$DUP_ROWS| \`$f\` | $bc | $hc | NEW CANONICAL BLOCK COPY |
77
+ "
78
+ FAILURES=$((FAILURES + 1))
79
+ fi
80
+ done
81
+ if [ -n "$DUP_ROWS" ]; then
82
+ out ""
83
+ out "### Canonical block copies added (source the shared helper instead)"
84
+ out ""
85
+ out "| File | Base markers | Head markers | Status |"
86
+ out "|------|-----:|-----:|--------|"
87
+ printf '%s' "$DUP_ROWS" >> "$SUMMARY"
88
+ fi
89
+
90
+ out ""
91
+ if [ "$FAILURES" -eq 0 ]; then
92
+ out "Result: PASS"
93
+ exit 0
94
+ fi
95
+ if [ -n "$EXEMPT_REASON" ]; then
96
+ out "Result: EXEMPT ($FAILURES violation(s) waived: $EXEMPT_REASON)"
97
+ echo "spec-size: $FAILURES violation(s) waived: $EXEMPT_REASON"
98
+ exit 0
99
+ fi
100
+ out "Result: FAIL ($FAILURES violation(s)). Shrink the spec, or add the spec-size-exempt label / a \`<!-- spec-size-exempt: <reason> -->\` line to the PR body."
101
+ echo "spec-size: FAIL ($FAILURES violation(s)); see job summary" >&2
102
+ exit 1
@@ -0,0 +1,85 @@
1
+ #!/usr/bin/env bash
2
+ # check-spec-size.test.sh — cases for scripts/check-spec-size.sh (forge#3762).
3
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
4
+ # SPDX-License-Identifier: AGPL-3.0-or-later
5
+ set -uo pipefail
6
+ S="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/check-spec-size.sh"
7
+ PASS=0; FAILN=0
8
+ TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
9
+ ok() { PASS=$((PASS+1)); }
10
+ bad() { FAILN=$((FAILN+1)); echo "FAIL: $1"; }
11
+
12
+ git init -q --bare "$TMP/origin.git"
13
+ git clone -q "$TMP/origin.git" "$TMP/w" 2>/dev/null
14
+ cd "$TMP/w" || exit 1
15
+ git config user.email t@example.com; git config user.name t
16
+ mkdir -p commands/work-on
17
+ gen() { awk -v n="$1" 'BEGIN{for(i=0;i<n;i++) print "line of spec text number " i}'; }
18
+ gen 4000 > commands/review-pr.md # ~110 KB: 2% ~ 2.2 KB < 4 KB cap
19
+ gen 20000 > commands/work-on.md # ~560 KB: 2% ~ 11 KB, so the 4 KB cap binds
20
+ gen 100 > commands/other.md
21
+ gen 100 > commands/work-on/remediate.md
22
+ git add -A; git commit -qm base; git branch -M staging; git push -q origin staging 2>/dev/null
23
+ git checkout -q -b feat
24
+
25
+ reset() { git reset -q --hard origin/staging; git clean -qfd; }
26
+ run() { # name want_rc [env...]
27
+ local name="$1" want="$2"; shift 2
28
+ local sum="$TMP/summary"; : > "$sum"
29
+ env GITHUB_STEP_SUMMARY="$sum" PR_LABELS="" PR_BODY="" "$@" bash "$S" origin/staging >"$TMP/out" 2>"$TMP/err"
30
+ local rc=$?
31
+ if [ "$rc" = "$want" ]; then ok; else bad "$name (rc=$rc want $want)"; cat "$TMP/err"; fi
32
+ }
33
+ has() { grep -qF "$2" "$TMP/summary" && ok || bad "$1 (summary lacks: $2)"; }
34
+
35
+ reset; run "no change passes" 0
36
+ has "summary shows delta table" "| File | Base | Head | Delta |"
37
+
38
+ reset; gen 100 >> commands/review-pr.md # +~2.8 KB on ~110 KB = ~2.5% (>2%, <4 KB)
39
+ run "over 2% fails" 1; has "over-budget flagged" "OVER BUDGET"
40
+
41
+ reset; gen 200 >> commands/work-on.md # +~5.6 KB (<2% of 560 KB, >4 KB)
42
+ run "over 4 KB fails" 1
43
+
44
+ reset; gen 5 >> commands/review-pr.md # small growth under both limits
45
+ run "small growth passes" 0
46
+
47
+ reset; gen 5000 >> commands/other.md # not a budgeted spec
48
+ run "non-budgeted growth passes" 0
49
+
50
+ reset; gen 100 > commands/review-pr.md # shrink
51
+ run "shrink passes" 0
52
+
53
+ reset; printf 'TRUSTED_SCRIPT=""\n' >> commands/other.md
54
+ run "new TRUSTED_SCRIPT copy fails" 1; has "dup flagged" "NEW CANONICAL BLOCK COPY"
55
+
56
+ reset; printf '_SD=x; mkdir -p "$_SD" # FENCE_BOOTSTRAP\n' >> commands/other.md
57
+ run "new FENCE_BOOTSTRAP anchor fails" 1
58
+
59
+ reset; printf 'echo hi # FENCE_BOOTSTRAP\n' >> commands/other.md
60
+ run "non-anchor FENCE_BOOTSTRAP tag passes" 0
61
+
62
+ reset; printf '# FORGE_ROOT bootstrap (canonical)\n' > commands/new-spec.md; git add -A
63
+ run "new spec file with block fails" 1
64
+
65
+ # Existing block, count unchanged or reduced, passes
66
+ reset; printf 'HELPER_SCRIPT=""\n' >> commands/other.md; git commit -qam x; git push -q origin HEAD:staging 2>/dev/null
67
+ git fetch -q origin staging
68
+ run "unchanged count passes" 0
69
+ : > commands/other.md; git add -A
70
+ run "reduced count passes" 0
71
+
72
+ # Exemption
73
+ reset; gen 100 >> commands/review-pr.md
74
+ run "exempt by label" 0 PR_LABELS="bug,spec-size-exempt"; has "label reported" "EXEMPT"
75
+ run "exempt by body marker" 0 PR_BODY=$'text\n<!-- spec-size-exempt: security fix -->\nmore'; has "reason reported" "security fix"
76
+ run "empty-reason marker not exempt" 1 PR_BODY='<!-- spec-size-exempt: -->'
77
+ run "label substring not exempt" 1 PR_LABELS="spec-size-exempt-not"
78
+
79
+ # Fail closed on unresolvable base
80
+ : > "$TMP/summary"
81
+ GITHUB_STEP_SUMMARY="$TMP/summary" bash "$S" origin/does-not-exist >/dev/null 2>&1; rc=$?
82
+ [ "$rc" = 2 ] && ok || bad "unresolved base fails closed (rc=$rc)"
83
+
84
+ echo "check-spec-size.test: pass=$PASS fail=$FAILN"
85
+ [ "$FAILN" -eq 0 ]
@@ -51,7 +51,9 @@
51
51
  # earlier head of this PR or by a bundle's member PRs, one per line, columns
52
52
  # file<TAB>line-start<TAB>line-end<TAB>title<TAB>prior-disposition<TAB>issue#.
53
53
  # prior-disposition is `filed` (its issue is still open), `filed-closed` (issue closed:
54
- # the row is IGNORED so a regressed defect is classified normally) or `note`.
54
+ # the row is IGNORED so a regressed defect is classified normally), `note` (a recorded NOTE) or
55
+ # `unknown` (a reviewer-body row whose issue linkage failed: it is IGNORED for a MEDIUM
56
+ # CONFIRMED/LIKELY finding so it can never suppress one, and behaves like `note` otherwise).
55
57
  # Line columns may be empty (NOTE-sourced rows carry no range). A finding re-flags a row
56
58
  # when it is the same file AND ((line IoU >= 0.6 and >= 1 shared title token), or >= 3 shared title tokens). Output is
57
59
  # `NOTE reflag-filed #N` (row has an issue number) or `NOTE reflag-note`; this gate never
@@ -180,7 +182,7 @@ if [ -n "$PRIOR_FINDINGS" ] && [ -n "$FINDING_FILE" ]; then
180
182
  case "$_rls" in ''|*[!0-9]*) _rls=""; _rle="" ;; esac
181
183
  case "$_rle" in ''|*[!0-9]*) _rle="$_rls" ;; esac
182
184
  _dmap="$DIFF_MAP"; { [ -n "$_dmap" ] && [ -r "$_dmap" ]; } || _dmap=""
183
- _reflag=$(awk -F '\t' -v file="$_rpath" -v ls="$_rls" -v le="$_rle" -v title="$TITLE" -v dmap="$_dmap" '
185
+ _reflag=$(awk -F '\t' -v file="$_rpath" -v ls="$_rls" -v le="$_rle" -v title="$TITLE" -v dmap="$_dmap" -v sev="$SEV" -v conf="$CONF" '
184
186
  function toks(str, arr, n, i, parts, t, c) {
185
187
  c = 0; str = tolower(str); gsub(/[^a-z0-9]+/, " ", str)
186
188
  n = split(str, parts, " ")
@@ -202,18 +204,23 @@ if [ -n "$PRIOR_FINDINGS" ] && [ -n "$FINDING_FILE" ]; then
202
204
  }
203
205
  close(dmap)
204
206
  }
207
+ # Generic words count toward the token set but never as topical similarity (#3719).
208
+ ns = split("the and with for from before after write read when that this into not are but can has have via use using does should may all any only also than then over under without within while where which what its it was were been being each both one two", sw, " ")
209
+ for (i = 1; i <= ns; i++) stop[sw[i]] = 1
205
210
  ntitle = toks(title, ft)
206
211
  }
207
212
  {
208
213
  pf = $1; sub(/^\.\//, "", pf)
209
214
  if (pf != file) next
210
215
  if ($5 == "filed-closed") next
216
+ if ($5 == "unknown" && sev == "MEDIUM" && (conf == "CONFIRMED" || conf == "LIKELY")) next
211
217
  hit = 0
212
- delete pt; toks($4, pt); shared = 0
213
- for (t in pt) if (t in ft) shared++
218
+ delete pt; toks($4, pt); shared = 0; cshared = 0
219
+ for (t in pt) if (t in ft) { shared++; if (!(t in stop)) cshared++ }
214
220
  # Line overlap alone is not a match (an unrelated finding can land on the same line): it also
215
- # needs at least one shared title token. Title-only matching still needs >= 3.
216
- if (shared >= 1 && ls != "" && $2 ~ /^[0-9]+$/) {
221
+ # needs at least one shared non-stopword title token. Title-only matching needs >= 3 shared
222
+ # tokens, of which >= 2 are non-stopword (generic words never count as topical similarity).
223
+ if (cshared >= 1 && ls != "" && $2 ~ /^[0-9]+$/) {
217
224
  ps = $2 + 0; pe = ($3 ~ /^[0-9]+$/) ? $3 + 0 : ps
218
225
  if (pe < ps) pe = ps
219
226
  d = shift(pf, ps, pe); ps += d; pe += d
@@ -222,7 +229,7 @@ if [ -n "$PRIOR_FINDINGS" ] && [ -n "$FINDING_FILE" ]; then
222
229
  uni = (pe - ps + 1) + (le - ls + 1) - inter
223
230
  if (uni > 0 && inter / uni >= 0.6) hit = 1
224
231
  }
225
- if (!hit && ntitle >= 3 && shared >= 3) hit = 1
232
+ if (!hit && ntitle >= 3 && shared >= 3 && cshared >= 2) hit = 1
226
233
  if (!hit) next
227
234
  if ($5 == "filed" && $6 ~ /^#?[0-9]+$/) { n = $6; sub(/^#/, "", n); print "NOTE reflag-filed #" n; found = 1; exit }
228
235
  if (!note) note = "NOTE reflag-note"
@@ -166,6 +166,7 @@ DMAP=$(mktemp "${TMPDIR:-/tmp}/classify-finding-dmap.XXXXXX")
166
166
  printf 'src/api.py\t10\t20\tmissing retry on timeout handler\tfiled\t123\n'
167
167
  printf 'src/closed.py\t5\t15\tstale cache entry never evicted\tfiled-closed\t77\n'
168
168
  printf 'src/notes.py\t\t\tduplicated predicate counted twice\tnote\t\n'
169
+ printf 'src/unlinked.py\t30\t40\tunvalidated redirect target in login handler\tunknown\t\n'
169
170
  } > "$PRIOR"
170
171
  PF=(--prior-findings "$PRIOR")
171
172
  M=(--severity MEDIUM --confidence CONFIRMED)
@@ -182,9 +183,17 @@ expect_out "NOTE reflag-note" "NOTE row with empty line columns matches by title
182
183
  expect ISSUE "same line, different topic MEDIUM CONFIRMED is not swallowed (SEC-1)" -- "${M[@]}" --agent security "${PF[@]}" --file src/sync.sh --line-start 42 --line-end 50 --title "SQL injection via unsanitized token"
183
184
  expect ISSUE "same line with no title at all is not swallowed" -- "${M[@]}" "${PF[@]}" --file src/sync.sh --line-start 42 --line-end 50
184
185
  expect_out "NOTE reflag-note" "same line, same topic (one shared token) still NOTE" -- "${M[@]}" "${PF[@]}" --file src/sync.sh --line-start 42 --line-end 50 --title "Lock handling is racy"
186
+ expect ISSUE "same line, only a stopword-class word shared is not swallowed (#3719)" -- "${M[@]}" "${PF[@]}" --file src/sync.sh --line-start 42 --line-end 50 --title "Path traversal in the write target"
187
+ expect ISSUE "far line, only generic words shared is not swallowed (#3719)" -- "${M[@]}" "${PF[@]}" --file src/sync.sh --line-start 300 --title "Command injection before write with the lock"
185
188
  expect ISSUE "non-overlapping same-file finding is classified normally" -- "${M[@]}" "${PF[@]}" --file src/sync.sh --line-start 200 --line-end 210 --title "missing bounds check"
186
189
  expect ISSUE "two shared tokens are not enough" -- "${M[@]}" "${PF[@]}" --file src/sync.sh --line-start 200 --title "lock released late"
187
190
  expect ISSUE "same title on a different file is classified normally" -- "${M[@]}" "${PF[@]}" --file src/other.sh --line-start 45 --title "lock released before write completes"
191
+ expect ISSUE "unknown row does not demote MEDIUM CONFIRMED (unlinked prior)" -- "${M[@]}" "${PF[@]}" --file src/unlinked.py --line-start 300 --title "Unvalidated redirect target in the login handler"
192
+ expect ISSUE "unknown row does not demote MEDIUM CONFIRMED by line IoU" -- "${M[@]}" "${PF[@]}" --file src/unlinked.py --line-start 32 --line-end 40 --title "redirect target unchecked"
193
+ expect ISSUE "unknown row does not demote MEDIUM LIKELY" -- --severity MEDIUM --confidence LIKELY "${PF[@]}" --file src/unlinked.py --line-start 300 --title "Unvalidated redirect target in the login handler"
194
+ expect_out "NOTE reflag-note" "unknown row still demotes LOW" -- --severity LOW --confidence CONFIRMED "${PF[@]}" --file src/unlinked.py --line-start 300 --title "Unvalidated redirect target in the login handler"
195
+ expect_out "NOTE reflag-note" "unknown row still demotes MEDIUM POSSIBLE" -- --severity MEDIUM --confidence POSSIBLE "${PF[@]}" --file src/unlinked.py --line-start 300 --title "Unvalidated redirect target in the login handler"
196
+ expect ISSUE "HIGH unaffected by unknown row" -- --severity HIGH --confidence CONFIRMED "${PF[@]}" --file src/unlinked.py --line-start 300 --title "Unvalidated redirect target in the login handler"
188
197
  expect_out "NOTE reflag-filed #123" "re-flag of an already-filed issue links it" -- "${M[@]}" "${PF[@]}" --file src/api.py --line-start 12 --line-end 18 --title "retry x"
189
198
  expect_out "NOTE reflag-filed #123" "filed re-flag by title tokens" -- "${M[@]}" "${PF[@]}" --file src/api.py --line-start 99 --title "Missing retry on timeout"
190
199
  expect ISSUE "filed-closed row is ignored (regression not swallowed)" -- "${M[@]}" "${PF[@]}" --file src/closed.py --line-start 5 --line-end 15 --title "stale cache entry never evicted"
@@ -13,7 +13,7 @@ ok() { PASS=$((PASS+1)); }
13
13
  bad() { FAILN=$((FAILN+1)); echo "FAIL: $1"; }
14
14
  expect() { [ "$2" = "$3" ] && ok || bad "$1 (got '$3' want '$2')"; }
15
15
 
16
- SITES="commands/work-on.md commands/work-on/build/validate.md commands/quality-gate.md commands/work-on/review.md commands/work-on/investigate.md commands/work-on/decompose.md commands/work-on/build.md commands/work-on/close.md commands/review-pr.md commands/orchestrate/phase-1-resolve.md commands/orchestrate/phase-4-execution.md"
16
+ SITES="commands/work-on.md commands/work-on/build/validate.md commands/quality-gate.md commands/work-on/review.md commands/work-on/investigate.md commands/work-on/decompose.md commands/work-on/build.md commands/work-on/close.md commands/review-pr.md commands/work-on/remediate.md commands/orchestrate/phase-1-resolve.md commands/orchestrate/phase-4-execution.md"
17
17
 
18
18
  # extract the bootstrap block (comment line .. closing top-level fi), indentation stripped
19
19
  # Block ends at the first line (after the marker) that starts with `fi` at the marker's own indentation.
@@ -111,6 +111,23 @@ else
111
111
  echo "SKIP: symlink cases (this platform cannot create symlinks)"; SKIPPED=$((SKIPPED+1))
112
112
  fi
113
113
 
114
+ # forge#3758 (hgate is a fixture of its own: the loop runs once per shell and h14 is reused below with other versions): deploy-gate script resolves on a plugin-only install (cache only, no symlink, no FORGE_* env).
115
+ # The gate-script line is taken verbatim from the spec so the test exercises exactly what ships.
116
+ mkscripts "$T/hgate/.claude/plugins/cache/forgedock/forgedock/1.0.0"; : > "$T/hgate/.claude/plugins/cache/forgedock/forgedock/1.0.0/scripts/resolve-deploy-gate.sh"
117
+ grep -hE '^[[:space:]]*GATE_SCRIPT=""; \[ -n "\$FORGE_ROOT" \]' "$ROOT/commands/work-on/remediate.md" "$ROOT/commands/review-pr.md" | sed 's/^[[:space:]]*//' | sort -u > "$T/gs"
118
+ expect "gate-script resolver line is identical across sites" 1 "$(wc -l < "$T/gs" | tr -d ' ')"
119
+ gate_run() { ( cd "$T/consumer" && env -i PATH="$PATH" HOME="$1" "$SH" -c "$(cat "$T/canon"); $(cat "$T/gs"); printf %s \"\$GATE_SCRIPT\"" ); }
120
+ expect "plugin-only install resolves resolve-deploy-gate.sh" "$T/hgate/.claude/plugins/cache/forgedock/forgedock/1.0.0/scripts/resolve-deploy-gate.sh" "$(gate_run "$T/hgate")"
121
+ expect "unresolved gate script stays empty (strict)" "" "$(gate_run "$T/empty")"
122
+ # cwd/consumer repo is never a candidate even when it carries the gate script
123
+ : > "$T/consumer/scripts/resolve-deploy-gate.sh"
124
+ expect "consumer repo gate script never used" "" "$(gate_run "$T/empty")"
125
+ rm -f "$T/consumer/scripts/resolve-deploy-gate.sh"
126
+ # no spec iterates CLAUDE_PLUGIN_ROOT as an env var for the gate script
127
+ for f in commands/work-on/remediate.md commands/review-pr.md; do
128
+ grep -qF '"${CLAUDE_PLUGIN_ROOT:-}" "${FORGE_ROOT:-}"' "$ROOT/$f" && bad "env-style CLAUDE_PLUGIN_ROOT gate loop in $f" || ok
129
+ done
130
+
114
131
  # newest cached version wins (1.10.0 must beat 1.9.0)
115
132
  for v in 1.9.0 1.10.0 1.2.0; do mkscripts "$T/h3/.claude/plugins/cache/forgedock/forgedock/$v"; done
116
133
  expect "newest cached version wins" "$T/h3/.claude/plugins/cache/forgedock/forgedock/1.10.0" "$(run "$T/h3")"
@@ -325,6 +342,8 @@ hr_check_block() { # label file var script
325
342
  hr_check_block review-pr "$HR_CLASSIFY" DERIVE_MILESTONE_SCRIPT derive-finding-milestone.sh
326
343
  hr_check_block review-pr "$HR_CLASSIFY" PRIORITY_SCRIPT severity-to-priority.sh
327
344
  hr_check_block review-pr "$HR_CLASSIFY" CODE_INDEX_SCRIPT code-index.sh
345
+ hr_check_block review-pr "$HR_CLASSIFY" DEDUP_SCRIPT issue-dedup.sh
346
+ hr_check_block issue "$ROOT/commands/issue.md" DEDUP_SCRIPT issue-dedup.sh
328
347
  hr_check_block review-pr-staging "$HR_STG" DERIVE_MILESTONE_SCRIPT derive-finding-milestone.sh
329
348
  hr_check_block review-pr-staging "$HR_STG" PRIORITY_SCRIPT severity-to-priority.sh
330
349
  extract_helper_block "$HR_CLASSIFY" DERIVE_MILESTONE_SCRIPT > "$T/hr_dm.sh"
@@ -334,6 +353,9 @@ extract_helper_block "$HR_STG" PRIORITY_SCRIPT > "$T/hr_pr_stg.sh"
334
353
  # only the variable-name-specific text may differ, and review-pr/review-pr-staging must agree
335
354
  cmp -s "$T/hr_dm.sh" "$T/hr_dm_stg.sh" && ok || bad 'DERIVE_MILESTONE_SCRIPT resolver differs between review-pr and review-pr-staging'
336
355
  cmp -s "$T/hr_pr.sh" "$T/hr_pr_stg.sh" && ok || bad 'PRIORITY_SCRIPT resolver differs between review-pr and review-pr-staging'
356
+ extract_helper_block "$HR_CLASSIFY" DEDUP_SCRIPT > "$T/hr_dd.sh"
357
+ extract_helper_block "$ROOT/commands/issue.md" DEDUP_SCRIPT > "$T/hr_dd_issue.sh"
358
+ cmp -s "$T/hr_dd.sh" "$T/hr_dd_issue.sh" && ok || bad 'DEDUP_SCRIPT resolver differs between review-pr and issue'
337
359
  # no bare cwd-relative helper call in either review spec
338
360
  if grep -nE 'bash scripts/(derive-finding-milestone|severity-to-priority)\.sh' "$HR_CLASSIFY" "$HR_STG" >/dev/null 2>&1; then bad 'review spec still calls a helper by a bare scripts/ path'; else ok; fi
339
361
 
@@ -342,10 +364,25 @@ if grep -nE 'bash scripts/(derive-finding-milestone|severity-to-priority)\.sh' "
342
364
  # design-bench.md, design-render-critique-loop.md, pipeline-health/phase-2-metrics.md, phase-3-dependency.md:
343
365
  # node scripts/*.mjs run inside the ForgeDock repo itself or printed as user hints (not run under a PR checkout cwd).
344
366
  # work-on/investigate.md, work-on/build/architect.md: {REPO_PATH}/scripts/code-index.sh, deferred to #3670.
367
+ # orchestrate/phase-3-dependency.md: scripts/worktree-lifecycle.sh lines sit in a roadmap/proposal fence, not executed.
345
368
  # deploy-info.md: read-only grep of decrypt-secrets.sh, never executed.
346
- SCRIPTS_ALLOW='commands/(design-bench|design-render-critique-loop|deploy-info|pipeline-health/phase-2-metrics|pipeline-health/phase-3-dependency|work-on/investigate|work-on/build/architect)\.md'
347
- guard_hits=$(grep -rnE '(^|[^A-Za-z0-9_-])(bash|sh|source) +(\./)?scripts/[A-Za-z0-9_.-]+|REPO_PATH\}?/scripts/' "$ROOT/commands" 2>/dev/null \
348
- | sed "s#^$ROOT/##" | grep -vE "^($SCRIPTS_ALLOW):" | grep -vE '^[^:]+:[0-9]+:[[:space:]]*#' || true) # comment lines are prose
369
+ SCRIPTS_ALLOW='commands/(design-bench|design-render-critique-loop|deploy-info|pipeline-health/phase-2-metrics|pipeline-health/phase-3-dependency|orchestrate/phase-3-dependency|work-on/investigate|work-on/build/architect)\.md'
370
+ # Executed forms: a bash/sh/source word, a command substitution `$(scripts/x.sh`, or a line-start/after-;&&| direct `scripts/x.sh` / `./scripts/x.sh`.
371
+ GUARD_RE='(^|[^A-Za-z0-9_-])(bash|sh|source) +(\./)?scripts/[A-Za-z0-9_.-]+|REPO_PATH\}?/scripts/|\$\( *(\./)?scripts/[A-Za-z0-9_.-]+\.sh|(^|:[0-9]+:|[;&(] *)[[:space:]]*(\./)?scripts/[A-Za-z0-9_.-]+\.sh'
372
+ guard_scan() { # dir -> offending file:line:text rows (comment lines are prose)
373
+ grep -rnE "$GUARD_RE" "$1" 2>/dev/null | grep -vE '^[^:]+:[0-9]+:[[:space:]]*#' || true
374
+ }
375
+ guard_hits=$(guard_scan "$ROOT/commands" | sed "s#^$ROOT/##" | grep -vE "^($SCRIPTS_ALLOW):" || true)
376
+ # Negative fixtures (#3679): a bare direct call must be flagged; backticked prose and comments must not.
377
+ mkdir -p "$T/guardfx/bad1" "$T/guardfx/bad2" "$T/guardfx/bad3" "$T/guardfx/good"
378
+ printf 'X=$(scripts/foo.sh a b)\n' > "$T/guardfx/bad1/x.md"
379
+ printf 'scripts/foo.sh arg\n' > "$T/guardfx/bad2/x.md"
380
+ printf 'ok && ./scripts/foo.sh\n' > "$T/guardfx/bad3/x.md"
381
+ printf 'See `scripts/foo.sh` for details, forwarded to scripts/foo.sh --exclude.\n# $(scripts/foo.sh) in a comment\n' > "$T/guardfx/good/x.md"
382
+ for f in bad1 bad2 bad3; do
383
+ [ -n "$(guard_scan "$T/guardfx/$f")" ] && ok || bad "widened guard missed direct execution fixture $f"
384
+ done
385
+ [ -z "$(guard_scan "$T/guardfx/good")" ] && ok || bad 'widened guard flags prose/comment fixture'
349
386
  if [ -n "$guard_hits" ]; then bad "commands/ has a cwd-relative or REPO_PATH helper call outside the allowlist: $(printf '%s' "$guard_hits" | head -5 | tr '\n' '|')"; else ok; fi
350
387
 
351
388
  # Behavior (#3596): planted cwd copies of the two review helpers must never be selected.
@@ -0,0 +1,43 @@
1
+ #!/usr/bin/env bash
2
+ # remediate-approved-head.test.sh — the M7 CI-gated merge is bound to APPROVED_HEAD on every path (forge#3686)
3
+ # Extracts the guard from commands/work-on/remediate.md and exercises it. No network. Usage: bash scripts/remediate-approved-head.test.sh
4
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
5
+ # SPDX-License-Identifier: AGPL-3.0-or-later
6
+
7
+ set -uo pipefail
8
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
9
+ ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
10
+ SPEC="$ROOT/commands/work-on/remediate.md"
11
+
12
+ PASS=0; FAILN=0
13
+ ok() { PASS=$((PASS+1)); echo "ok - $1"; }
14
+ bad() { FAILN=$((FAILN+1)); echo "FAIL - $1"; }
15
+ eq() { if [ "$2" = "$3" ]; then ok "$1"; else bad "$1 (got '$2', want '$3')"; fi; }
16
+
17
+ grep -qE 'GATED_HEAD" != "\$\{?APPROVED_HEAD' "$SPEC" && ok "spec compares GATED_HEAD to APPROVED_HEAD" || bad "spec compares GATED_HEAD to APPROVED_HEAD"
18
+ grep -qF 'if [ "${RESUME_AT_M7:-false}" = "true" ] && [ "$CI_GATE_RC" -eq 0 ]' "$SPEC" && bad "guard is no longer resume-only" || ok "guard is no longer resume-only"
19
+
20
+ # Extract the guard fence body (from the scratch reload through the closing fi) and run it per case.
21
+ GUARD=$(awk '/^_SD="\$\{FORGE_SCRATCHPAD/ && !g {g=1} g {print} g && /^fi$/ {exit}' "$SPEC")
22
+ [ -n "$GUARD" ] && ok "guard block extracted" || bad "guard block extracted"
23
+
24
+ # run_guard <persisted head or NONE> <GATED_HEAD> <CI_GATE_RC> <RESUME_AT_M7>
25
+ run_guard() {
26
+ local d; d=$(mktemp -d)
27
+ [ "$1" = NONE ] || printf '%s\n' "$1" > "$d/remediate_approved_head_7"
28
+ FORGE_SCRATCHPAD="$d" GATED_HEAD="$2" CI_GATE_RC="$3" RESUME_AT_M7="$4" CI_GATE_OUT="" \
29
+ bash -c "${GUARD//\{PR_NUMBER\}/7}"$'\nprintf %s "$CI_GATE_RC"'
30
+ rm -rf "$d"
31
+ }
32
+ A=$(printf 'a%.0s' {1..40}); B=$(printf 'b%.0s' {1..40})
33
+ for r in true false; do
34
+ eq "match merges (resume=$r)" "$(run_guard "$A" "$A" 0 $r)" 0
35
+ eq "mismatch refuses (resume=$r)" "$(run_guard "$A" "$B" 0 $r)" 4
36
+ eq "missing approved head refuses (resume=$r)" "$(run_guard NONE "$A" 0 $r)" 4
37
+ eq "malformed approved head refuses (resume=$r)" "$(run_guard "zz" "$A" 0 $r)" 4
38
+ eq "empty gated head refuses (resume=$r)" "$(run_guard "$A" "" 0 $r)" 4
39
+ eq "nonzero rc is unchanged (resume=$r)" "$(run_guard "$A" "$B" 3 $r)" 3
40
+ done
41
+
42
+ echo "passed=$PASS failed=$FAILN"
43
+ [ "$FAILN" -eq 0 ]
@@ -28,6 +28,8 @@ INTERIM=$'<!-- FORGE:REMEDIATION -->\n## Remediation (in progress)'
28
28
  QUOTED_REVIEW=$'## Review\nThe prior run posted FORGE:REMEDIATION:COMPLETE on this PR.\n<!-- FORGE:REMEDIATION:COMPLETE -->'
29
29
  QUOTED_WORK_ORDER=$'<!-- FORGE:INPR_FIX: head=abc1234 -->\nquoting <!-- FORGE:REMEDIATION -->\n<!-- FORGE:REMEDIATION:COMPLETE -->'
30
30
 
31
+ HELD=$'<!-- FORGE:REMEDIATION_HELD -->\n**Re-gate outcome**: HELD-AWAITING-MERGE'
32
+ eq "held-resume record matches neither COMPLETE nor TRAIL (forge#3685)" "$(complete_n "[$(c NONE Bot b "$HELD")]")/$(trail_n "[$(c NONE Bot b "$HELD")]")" "0/0"
31
33
  eq "genuine M8 trail by bot matches COMPLETE" "$(complete_n "[$(c NONE Bot 'app[bot]' "$REAL")]")" 1
32
34
  eq "review comment quoting COMPLETE mid-body does not match" "$(complete_n "[$(c OWNER User u "$QUOTED_REVIEW")]")" 0
33
35
  eq "INPR_FIX work order quoting the markers does not match COMPLETE" "$(complete_n "[$(c NONE Bot b "$QUOTED_WORK_ORDER")]")" 0
@@ -47,5 +49,9 @@ for f in commands/work-on/remediate.md commands/work-on.md commands/orchestrate/
47
49
  done
48
50
  grep -qF "count '$COMPLETE_RE'" "$ROOT/commands/work-on/remediate.md" && ok "remediate.md uses the tested COMPLETE regex" || bad "remediate.md COMPLETE regex drifted from this test"
49
51
 
52
+ grep -qF 'FORGE:REMEDIATION_HELD' "$ROOT/commands/work-on/remediate.md" && ok "remediate.md documents FORGE:REMEDIATION_HELD" || bad "remediate.md lacks FORGE:REMEDIATION_HELD"
53
+ ! grep -qE 'PR_OPEN_STATE = .?MERGED.? → EXIT .REMEDIATE_RESULT: status: ALREADY_DONE. \(nothing to remediate' "$ROOT/commands/work-on/remediate.md" && ok "M0 has no unconditional merged ALREADY_DONE exit" || bad "M0 still exits ALREADY_DONE unconditionally on MERGED"
54
+ grep -qiE 'merged.*(skip|exclusive|do not fall through)' "$ROOT/commands/work-on/remediate.md" && ok "M7 merged-first branch is exclusive" || bad "M7 merged-first exclusivity missing"
55
+
50
56
  echo "passed=$PASS failed=$FAILN"
51
57
  [ "$FAILN" -eq 0 ]
@@ -0,0 +1,51 @@
1
+ #!/usr/bin/env bash
2
+ # resolve-deploy-gate.sh — trusted deploy-gate resolver (forge#3753)
3
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
4
+ # SPDX-License-Identifier: AGPL-3.0-or-later
5
+ #
6
+ # Usage: resolve-deploy-gate.sh <base> <owner/repo>
7
+ #
8
+ # Prints exactly one line: `true` (deploy gate, strict human bar) or `false`
9
+ # (fast lane). Always exits 0; every failure path prints `true` (fail closed).
10
+ #
11
+ # A base is the deploy gate when it is empty/`null`, the literal `main` (hard
12
+ # floor), the repository's GitHub default branch (`default_branch`), or the
13
+ # `branches.default` of the default branch's COMMITTED forge.yaml
14
+ # (`origin/<default>:forge.yaml`, falling back to the contents API) or of an
15
+ # explicit trusted config (FORGE_TRUSTED_CONFIG, orchestrator-supplied).
16
+ #
17
+ # It NEVER reads forge.yaml from $PWD: the PR worktree is author-controlled, so
18
+ # a PR could otherwise redefine which branch counts as the deploy gate.
19
+ # Config sources can only ADD gate branches; main and the GitHub default are
20
+ # unconditional, so no source relaxes the bar below the literal-main floor.
21
+ set -uo pipefail
22
+
23
+ BASE="${1:-}"
24
+ REPO="${2:-}"
25
+
26
+ emit() { printf '%s\n' "$1"; exit 0; }
27
+
28
+ { [ -z "$BASE" ] || [ "$BASE" = "null" ] || [ "$BASE" = "main" ]; } && emit true
29
+ [ -n "$REPO" ] || emit true
30
+
31
+ GH_DEFAULT=$(gh api "repos/${REPO}" --jq .default_branch 2>/dev/null || echo "")
32
+ { [ -z "$GH_DEFAULT" ] || [ "$GH_DEFAULT" = "null" ]; } && emit true
33
+ [ "$BASE" = "$GH_DEFAULT" ] && emit true
34
+
35
+ _cfg_default() { yq '.branches.default // ""' 2>/dev/null || true; }
36
+
37
+ CFG_DEFAULT=""
38
+ # Committed default-branch config only (a ref, never the working tree).
39
+ CFG_DEFAULT=$(git show "origin/${GH_DEFAULT}:forge.yaml" 2>/dev/null | _cfg_default)
40
+ if [ -z "$CFG_DEFAULT" ]; then
41
+ CFG_DEFAULT=$(gh api "repos/${REPO}/contents/forge.yaml?ref=${GH_DEFAULT}" --jq .content 2>/dev/null \
42
+ | base64 -d 2>/dev/null | _cfg_default)
43
+ fi
44
+ [ -n "$CFG_DEFAULT" ] && [ "$CFG_DEFAULT" != "null" ] && [ "$BASE" = "$CFG_DEFAULT" ] && emit true
45
+
46
+ if [ -n "${FORGE_TRUSTED_CONFIG:-}" ] && [ -f "$FORGE_TRUSTED_CONFIG" ]; then
47
+ TRUSTED=$(_cfg_default < "$FORGE_TRUSTED_CONFIG")
48
+ [ -n "$TRUSTED" ] && [ "$TRUSTED" != "null" ] && [ "$BASE" = "$TRUSTED" ] && emit true
49
+ fi
50
+
51
+ emit false
@@ -0,0 +1,59 @@
1
+ #!/usr/bin/env bash
2
+ # resolve-deploy-gate.test.sh — cases for scripts/resolve-deploy-gate.sh (forge#3753).
3
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
4
+ # SPDX-License-Identifier: AGPL-3.0-or-later
5
+ set -uo pipefail
6
+ S="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/resolve-deploy-gate.sh"
7
+ PASS=0; FAILN=0
8
+ TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
9
+ ok() { PASS=$((PASS+1)); }
10
+ bad() { FAILN=$((FAILN+1)); echo "FAIL: $1"; }
11
+
12
+ command -v yq >/dev/null 2>&1 || { echo "SKIP: yq not installed"; exit 0; }
13
+
14
+ # Stub gh: GH_DEFAULT_STUB drives `repos/<r> --jq .default_branch`; contents API unused (fails).
15
+ mkdir -p "$TMP/bin"
16
+ cat > "$TMP/bin/gh" <<'EOF'
17
+ #!/usr/bin/env bash
18
+ case "$*" in
19
+ *"--jq .default_branch"*) [ -n "${GH_FAIL:-}" ] && exit 1; printf '%s\n' "${GH_DEFAULT_STUB:-}" ;;
20
+ *) exit 1 ;;
21
+ esac
22
+ EOF
23
+ chmod +x "$TMP/bin/gh"
24
+
25
+ # Repo fixture: origin/<default> committed forge.yaml, plus a PR worktree dir with its own forge.yaml.
26
+ ORIGIN="$TMP/origin.git"; REPO="$TMP/repo"
27
+ git init -q --bare "$ORIGIN"
28
+ git init -q "$REPO"; git -C "$REPO" config user.email t@t; git -C "$REPO" config user.name t
29
+ printf 'branches:\n default: develop\n' > "$REPO/forge.yaml"
30
+ git -C "$REPO" add forge.yaml; git -C "$REPO" commit -qm init
31
+ git -C "$REPO" branch -M trunk
32
+ git -C "$REPO" remote add origin "$ORIGIN"; git -C "$REPO" push -q origin trunk 2>/dev/null
33
+ git -C "$REPO" fetch -q origin
34
+ # PR worktree content: attacker redefines the deploy gate.
35
+ printf 'branches:\n default: release\n' > "$REPO/forge.yaml"
36
+
37
+ run() { # base default_stub [env...]
38
+ local base="$1" def="$2"; shift 2
39
+ (cd "$REPO" && env PATH="$TMP/bin:$PATH" GH_DEFAULT_STUB="$def" "$@" bash "$S" "$base" owner/repo)
40
+ }
41
+ expect() { [ "$1" = "$2" ] && ok || bad "$3: got '$1' want '$2'"; }
42
+
43
+ expect "$(run main trunk)" true "main with worktree release"
44
+ expect "$(run release trunk)" false "worktree-declared branch is not a gate"
45
+ expect "$(run staging trunk)" false "staging is fast lane"
46
+ expect "$(run trunk trunk)" true "github default"
47
+ expect "$(run master master)" true "github default master"
48
+ expect "$(run develop trunk)" true "committed origin config default"
49
+ expect "$(run '' trunk)" true "empty base"
50
+ expect "$(run null trunk)" true "null base"
51
+ expect "$(run staging '')" true "empty gh default fails closed"
52
+ expect "$(run staging trunk GH_FAIL=1)" true "gh failure fails closed"
53
+ expect "$(run main '' GH_FAIL=1)" true "main on gh failure"
54
+ printf 'branches:\n default: prod\n' > "$TMP/trusted.yaml"
55
+ expect "$(run prod trunk FORGE_TRUSTED_CONFIG="$TMP/trusted.yaml")" true "trusted config default"
56
+ expect "$(run prod trunk)" false "prod without trusted config"
57
+
58
+ echo "resolve-deploy-gate.test.sh: $PASS passed, $FAILN failed"
59
+ [ "$FAILN" -eq 0 ]
@@ -120,8 +120,16 @@ expect "round 2 narrows to security by default" "security" "$(roster 2 'security
120
120
  expect "round 2 adds a path-triggered domain" "security auth" "$(roster 2 'security auth billing' $'README.md\nsrc/auth/login.py')"
121
121
  expect "round 3 cap holds" "security" "$(roster 3 'security auth billing database' 'docs/notes.txt')"
122
122
  expect "claim/lease paths trigger concurrency" "security concurrency" "$(roster 2 'security' 'engine/claim-lease.txt')"
123
+ export REPO_PATH="$ROOT" # ForgeDock itself: the persona file exists
123
124
  expect "commands/** triggers spec-cli" "security spec-cli" "$(roster 2 'security' 'commands/foo.md')"
124
125
  expect "bin/** triggers spec-cli" "security spec-cli" "$(roster 2 'security' 'bin/foo.mjs')"
126
+ # consumer repo layout: no persona file, so no spec-cli
127
+ mkdir -p "$TMP/consumer/bin" "$TMP/consumer/commands"
128
+ export REPO_PATH="$TMP/consumer"
129
+ expect "consumer bin/ gets no spec-cli" "security" "$(roster 2 'security' 'bin/foo.mjs')"
130
+ expect "consumer commands/ gets no spec-cli" "security" "$(roster 2 'security' 'commands/foo.md')"
131
+ expect "consumer with another trigger keeps it, no spec-cli" "security auth" "$(roster 2 'security' $'bin/foo.mjs\nsrc/auth/login.py')"
132
+ unset REPO_PATH
125
133
  expect "non-numeric rounds fail to the 3B roster" "security auth" "$(roster 'x' 'security auth' 'README.md')"
126
134
 
127
135
  # ---- 3. mode resolution through the real fence ----------------------------------------------------------------------
@@ -83,5 +83,15 @@ done
83
83
  # Test 6: the protocol defines the marker domain through the mapping
84
84
  if grep -qF 'marker domain' "$PERSONAS/protocols.md"; then ok; else bad "protocols.md does not define the marker domain"; fi
85
85
 
86
+ # Test 7: the roster regexes in delta_roster must equal the DOMAIN_FILES_* regexes, and every domain needs an empty-slice fallback
87
+ for pair in AUTH:auth BILLING:billing CONCURRENCY:concurrency DATABASE:database INFRA:infra SCRAPING:scraper FRONTEND:frontend API:api; do
88
+ var=${pair%%:*}; dom=${pair#*:}
89
+ a=$(grep -E "^DOMAIN_FILES_$var=" "$SPEC" | head -n 1 | sed -n 's/.*grep -iE "\([^"]*\)".*/\1/p')
90
+ b=$(grep -F "&& out=\"\$out $dom\"" "$SPEC" | grep 'grep -qiE' | head -n 1 | sed -n 's/.*grep -qiE "\([^"]*\)".*/\1/p')
91
+ if [ -z "$a" ] || [ -z "$b" ]; then bad "could not extract $var pattern (DOMAIN_FILES='$a' delta_roster='$b')"
92
+ elif [ "$a" = "$b" ]; then ok; else bad "$var regex drifted: DOMAIN_FILES='$a' delta_roster='$b'"; fi
93
+ if grep -qF "[ -z \"\$DOMAIN_FILES_$var\" ] && DOMAIN_FILES_$var=\"\$FILES\"" "$SPEC"; then ok; else bad "no empty-slice fallback for DOMAIN_FILES_$var"; fi
94
+ done
95
+
86
96
  echo "review-pr-domain-map: $pass passed, $fail failed"
87
97
  [ "$fail" -eq 0 ]