forgedock 1.12.0 → 1.13.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.
Files changed (76) hide show
  1. package/bin/engine/phases-args.test.mjs +2 -1
  2. package/bin/engine/phases.mjs +349 -90
  3. package/bin/engine/runlog.mjs +25 -2
  4. package/bin/engine-cli.mjs +4 -1
  5. package/bin/engine.mjs +72 -19
  6. package/bin/hooks/interactive-engine.mjs +135 -209
  7. package/bin/hooks/pre-tool-use.mjs +16 -4
  8. package/bin/labels.json +10 -0
  9. package/bin/opencode-adapter.mjs +1 -1
  10. package/bin/orchestrate-preflight.mjs +1 -1
  11. package/bin/runner.mjs +258 -23
  12. package/bin/watch-utils.mjs +1 -0
  13. package/commands/autopilot.md +6 -6
  14. package/commands/cleanup.md +8 -5
  15. package/commands/orchestrate/phase-1-resolve.md +1 -1
  16. package/commands/orchestrate/phase-3-dependency.md +14 -19
  17. package/commands/orchestrate/phase-4-execution.md +80 -53
  18. package/commands/orchestrate/phase-6-report.md +6 -0
  19. package/commands/orchestrate/safety.md +1 -1
  20. package/commands/pipeline-health/phase-2-metrics.md +39 -0
  21. package/commands/pipeline-health/phase-5-report.md +1 -0
  22. package/commands/pipeline-resume.md +1 -1
  23. package/commands/pipeline-status.md +2 -2
  24. package/commands/recover-orphans.md +5 -5
  25. package/commands/review-pr-agents/api.md +1 -1
  26. package/commands/review-pr-agents/auth.md +1 -1
  27. package/commands/review-pr-agents/infra.md +3 -3
  28. package/commands/review-pr-agents/protocols.md +15 -6
  29. package/commands/review-pr-agents/scraper.md +1 -1
  30. package/commands/review-pr-agents/security.md +5 -5
  31. package/commands/review-pr-agents.md +12 -12
  32. package/commands/review-pr-staging.md +226 -8
  33. package/commands/review-pr.md +394 -53
  34. package/commands/work-on/build/architect.md +36 -0
  35. package/commands/work-on/build/implement.md +1 -1
  36. package/commands/work-on/build/validate.md +88 -4
  37. package/commands/work-on/build.md +106 -7
  38. package/commands/work-on/decompose.md +2 -2
  39. package/commands/work-on/investigate.md +14 -4
  40. package/commands/work-on/remediate.md +137 -25
  41. package/commands/work-on/review.md +25 -9
  42. package/commands/work-on.md +15 -15
  43. package/docs/CONFIG.md +29 -0
  44. package/docs/RELEASE-NOTES.md +45 -0
  45. package/docs/spec/forge-protocol-v1.md +22 -1
  46. package/docs/spec/label-state-machine.md +6 -1
  47. package/docs/spec/review-protocol.md +20 -5
  48. package/package.json +2 -1
  49. package/packages/protocol/src/phases.js +21 -0
  50. package/scripts/check-blast-radius.sh +164 -0
  51. package/scripts/check-blast-radius.test.sh +183 -0
  52. package/scripts/check-contract-scope.sh +137 -0
  53. package/scripts/check-contract-scope.test.sh +83 -0
  54. package/scripts/check-spec-bash.sh +60 -3
  55. package/scripts/check-spec-bash.test.sh +56 -0
  56. package/scripts/check-spec-markers.sh +29 -0
  57. package/scripts/check-test-wiring.sh +57 -0
  58. package/scripts/check-test-wiring.test.sh +70 -0
  59. package/scripts/classify-finding.sh +72 -8
  60. package/scripts/classify-finding.test.sh +77 -8
  61. package/scripts/close-dossier-no-main-commit.test.sh +12 -12
  62. package/scripts/diff-size.test.sh +3 -2
  63. package/scripts/doctor-pipeline-state.sh +35 -0
  64. package/scripts/forge-annotation.sh +3 -0
  65. package/scripts/forge-root.test.sh +103 -13
  66. package/scripts/forge-run.sh +2 -2
  67. package/scripts/recovery-claim-live.test.sh +10 -7
  68. package/scripts/remediate-basesync.test.sh +103 -0
  69. package/scripts/review-delta.sh +206 -0
  70. package/scripts/review-delta.test.sh +213 -0
  71. package/scripts/review-pr-domain-map.test.sh +87 -0
  72. package/scripts/review-pr-gate2.test.sh +84 -0
  73. package/scripts/review-pr-staging-scope.test.sh +99 -0
  74. package/scripts/run-shell-tests.sh +115 -0
  75. package/scripts/select-fix-targets.sh +17 -5
  76. package/scripts/transition-label.sh +3 -1
@@ -0,0 +1,213 @@
1
+ #!/usr/bin/env bash
2
+ # review-delta.test.sh — fixture cases for scripts/review-delta.sh (forge#3636).
3
+ # Real git repos (local bare origin), synthetic comment JSON, and the REAL scripts/trusted-comments.sh.
4
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
5
+ # SPDX-License-Identifier: AGPL-3.0-or-later
6
+ set -uo pipefail
7
+ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
8
+ RD="$ROOT/scripts/review-delta.sh"
9
+ TC="$ROOT/scripts/trusted-comments.sh"
10
+ PASS=0; FAILN=0
11
+ ok() { PASS=$((PASS+1)); }
12
+ bad() { FAILN=$((FAILN+1)); echo "FAIL: $1"; }
13
+ command -v jq >/dev/null 2>&1 || { echo "review-delta.test.sh: jq missing, skipped"; exit 0; }
14
+ command -v git >/dev/null 2>&1 || { echo "review-delta.test.sh: git missing, skipped"; exit 0; }
15
+ TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
16
+
17
+ # hermetic git: no user config, hooks or signing
18
+ export GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null GIT_CONFIG_NOSYSTEM=1
19
+ export GIT_AUTHOR_NAME=t GIT_AUTHOR_EMAIL=t@example.invalid GIT_COMMITTER_NAME=t GIT_COMMITTER_EMAIL=t@example.invalid
20
+ g() { git -C "$W" -c commit.gpgsign=false -c core.hooksPath=/dev/null "$@"; }
21
+ commit() { # file content msg
22
+ mkdir -p "$W/$(dirname "$1")"; printf '%s\n' "$2" > "$W/$1"; g add -A >/dev/null; g commit -q -m "$3" >/dev/null
23
+ }
24
+
25
+ CASE=0
26
+ mkrepo() { # sets W; main has a.txt, origin/main fetched, branch feat checked out with one PR commit
27
+ CASE=$((CASE+1)); local o="$TMP/o$CASE"; W="$TMP/w$CASE"
28
+ git init -q --bare -b main "$o"
29
+ git clone -q "$o" "$W" 2>/dev/null
30
+ g checkout -q -b main 2>/dev/null
31
+ commit a.txt "base" "base"
32
+ g push -q origin main
33
+ g checkout -q -b feat
34
+ commit pr.txt "pr1" "pr1"
35
+ }
36
+ sha() { g rev-parse "$1"; }
37
+ base_advance() { # file content -> new commit on origin/main, fetched
38
+ g checkout -q main; commit "$1" "$2" "base: $1"; g push -q origin main; g checkout -q feat; g fetch -q origin
39
+ }
40
+
41
+ # --- comment fixtures
42
+ body_agent() { printf '<!-- FORGE:REVIEW-AGENT:%s -->\nReviewed-SHA: %s\n\n<!-- REVIEW-FINDINGS-START -->\nNo findings.\n<!-- REVIEW-FINDINGS-END -->' "$1" "$2"; }
43
+ body_synth() { printf '<!-- REVIEW-FINDINGS-SYNTHESIZED-START -->\nReviewed-SHA: %s\n\nsynth' "$1"; }
44
+ body_summary() { printf '# PR Review Summary\n\n**Reviewed commit**: `%s` | **Current HEAD**: `%s` | **Status**: CURRENT\n**Domains**: x | **Agents**: %s (x)\n' "$1" "$1" "$2"; }
45
+ cm() { jq -n --arg b "$1" --arg t "${2:-Bot}" --arg a "${3:-NONE}" '{"body":$b,"author_association":$a,"user":{"type":$t,"login":"x"}}'; }
46
+ # comments <comment-json>... -> JSON array file path in $CF
47
+ comments() { CF="$TMP/c$CASE.json"; printf '%s\n' "$@" | jq -s '.' > "$CF"; }
48
+ panel() { # full proven panel for sha: 2 agents + synthesized body
49
+ comments "$(cm "$(body_agent security "$1")")" "$(cm "$(body_agent api "$1")")" "$(cm "$(body_synth "$1")")"
50
+ }
51
+
52
+ run_rd() { # prints stdout; args after the fixed set are appended
53
+ (cd "$W" && bash "$RD" --pr 1 --head "$HEAD" --base main --trusted-script "$TC" --comments-file "$CF" --labels "" "$@" 2>&1)
54
+ }
55
+ expect() { # name expected-line1 [expected-rounds]
56
+ local out l1 l2; out=$(run_rd "${@:4}"); l1=$(printf '%s\n' "$out" | sed -n 1p); l2=$(printf '%s\n' "$out" | sed -n 2p)
57
+ [ "$l1" = "$2" ] && ok || bad "$1: expected '$2', got '$l1'"
58
+ if [ -n "${3-}" ]; then [ "$l2" = "FULL_ROUNDS=$3" ] && ok || bad "$1: expected FULL_ROUNDS=$3, got '$l2'"; fi
59
+ }
60
+
61
+ # 1. ancestor delta -> DELTA
62
+ mkrepo; R=$(sha HEAD); commit pr.txt "pr2" "pr2"; HEAD=$(sha HEAD); panel "$R"
63
+ expect "ancestor delta" "DELTA $R..$HEAD" 1
64
+
65
+ # 2. head == reviewed -> empty delta, documented as DELTA
66
+ mkrepo; R=$(sha HEAD); HEAD=$R; panel "$R"
67
+ expect "head == reviewed" "DELTA $R..$R" 1
68
+
69
+ # 3. force-push / rebase -> FULL
70
+ mkrepo; R=$(sha HEAD); panel "$R"; g reset -q --hard HEAD~1; commit pr.txt "rewritten" "rewritten"; HEAD=$(sha HEAD)
71
+ expect "force-push (reviewed not an ancestor)" "FULL" 1
72
+ mkrepo; R=$(sha HEAD); panel "$R"; base_advance b.txt "b1"; g rebase -q origin/main >/dev/null 2>&1; HEAD=$(sha HEAD)
73
+ expect "rebase onto new base" "FULL" 1
74
+
75
+ # 4. clean base merge -> BASE_SYNC_ONLY
76
+ mkrepo; R=$(sha HEAD); panel "$R"; base_advance b.txt "b1"; g merge -q --no-ff -m "merge main" origin/main >/dev/null 2>&1; HEAD=$(sha HEAD)
77
+ expect "clean base merge" "BASE_SYNC_ONLY $R" 1
78
+
79
+ # 5. base merge with a conflict-resolution edit -> not BASE_SYNC_ONLY
80
+ mkrepo; commit shared.txt "line" "shared"; g push -q origin feat; R=$(sha HEAD); panel "$R"
81
+ g checkout -q main; commit shared.txt "base side" "base edits shared"; g push -q origin main; g checkout -q feat; g fetch -q origin
82
+ commit shared.txt "feat side" "feat edits shared"; R=$(sha HEAD); panel "$R"
83
+ g merge -q --no-ff origin/main >/dev/null 2>&1; printf 'resolved\n' > "$W/shared.txt"; g add -A >/dev/null; g commit -q -m "merge main (resolved)" >/dev/null; HEAD=$(sha HEAD)
84
+ out=$(run_rd | sed -n 1p); case "$out" in BASE_SYNC_ONLY*) bad "conflict-resolution merge must not be BASE_SYNC_ONLY (got $out)" ;; *) ok ;; esac
85
+
86
+ # 6. mixed: base merge plus a regular commit -> DELTA
87
+ mkrepo; R=$(sha HEAD); panel "$R"; base_advance b.txt "b1"; g merge -q --no-ff -m "merge main" origin/main >/dev/null 2>&1; commit pr.txt "pr2" "pr2"; HEAD=$(sha HEAD)
88
+ expect "base merge plus regular commit" "DELTA $R..$HEAD" 1
89
+
90
+ # 6b. merge of a non-base branch -> DELTA
91
+ mkrepo; R=$(sha HEAD); panel "$R"; g checkout -q -b other; commit o.txt "o" "other"; g checkout -q feat; g merge -q --no-ff -m "merge other" other >/dev/null 2>&1; HEAD=$(sha HEAD)
92
+ expect "merge of a non-base branch" "DELTA $R..$HEAD" 1
93
+
94
+ # 6c. crafted base "merge" whose second parent is an OLD base commit, reverting a binary file or a mode to that
95
+ # old version. diff-tree --cc is empty (result equals the second parent) and the 3-dot diffs have identical +/-
96
+ # lines, so only the per-path shape (binary blob ids, modes) can tell the PR change moved.
97
+ crafted_merge() { # path-from-old-base -> HEAD = merge(R, old base) taking that one path from the old base
98
+ local tree; g checkout -q "$OLD_B" -- "$1"; tree=$(g write-tree); HEAD=$(g commit-tree "$tree" -p "$R" -p "$OLD_B" -m "merge main")
99
+ g reset -q --hard "$HEAD"
100
+ }
101
+ mkrepo; g checkout -q main; printf '\000\001v1' > "$W/img.bin"; g add -A >/dev/null; g commit -q -m "img v1" >/dev/null; OLD_B=$(sha HEAD)
102
+ printf '\000\001v2' > "$W/img.bin"; g add -A >/dev/null; g commit -q -m "img v2" >/dev/null; g push -q origin main; g fetch -q origin
103
+ g checkout -q feat; g merge -q --no-ff -m "sync" origin/main >/dev/null 2>&1; R=$(sha HEAD); panel "$R"
104
+ crafted_merge img.bin
105
+ expect "crafted merge reverting a binary file" "DELTA $R..$HEAD" 1
106
+ # binary the PR already changes, swapped for the old base's bytes: same path set and modes, only the blob id differs
107
+ mkrepo; g checkout -q main; printf '\000\001v1' > "$W/img.bin"; g add -A >/dev/null; g commit -q -m "img v1" >/dev/null; OLD_B=$(sha HEAD)
108
+ printf '\000\001v2' > "$W/img.bin"; g add -A >/dev/null; g commit -q -m "img v2" >/dev/null; g push -q origin main; g fetch -q origin
109
+ g checkout -q feat; g merge -q --no-ff -m "sync" origin/main >/dev/null 2>&1
110
+ printf '\000\001pr' > "$W/img.bin"; g add -A >/dev/null; g commit -q -m "pr img" >/dev/null; R=$(sha HEAD); panel "$R"
111
+ crafted_merge img.bin
112
+ expect "crafted merge swapping a PR-changed binary" "DELTA $R..$HEAD" 1
113
+ mkrepo; g checkout -q main; chmod +x "$W/a.txt"; g add -A >/dev/null; g commit -q -m "a 755" >/dev/null; OLD_B=$(sha HEAD)
114
+ chmod -x "$W/a.txt"; g add -A >/dev/null; g commit -q -m "a 644" >/dev/null; g push -q origin main; g fetch -q origin
115
+ g checkout -q feat; g merge -q --no-ff -m "sync" origin/main >/dev/null 2>&1; R=$(sha HEAD); panel "$R"
116
+ crafted_merge a.txt
117
+ expect "crafted merge reverting a file mode" "DELTA $R..$HEAD" 1
118
+
119
+ # 7. spec / config changes in the delta -> FULL
120
+ for f in forge.yaml commands/review-pr.md commands/review-pr-agents.md commands/review-pr-agents/x.md scripts/review-delta.sh scripts/trusted-comments.sh; do
121
+ mkrepo; R=$(sha HEAD); panel "$R"; commit "$f" "changed" "touch $f"; HEAD=$(sha HEAD)
122
+ expect "$f changed in delta" "FULL" 1
123
+ done
124
+
125
+ # 8. checkpoint trust and shape
126
+ mkrepo; R=$(sha HEAD); commit pr.txt "pr2" "pr2"; HEAD=$(sha HEAD)
127
+ comments "$(cm "$(body_agent security "$R")" User NONE)" "$(cm "$(body_agent api "$R")" User NONE)" "$(cm "$(body_synth "$R")" User NONE)"
128
+ expect "untrusted reviewer comments" "FULL" 0
129
+ comments "$(cm "$(body_agent security "$R")" User MEMBER)" "$(cm "$(body_synth "$R")" User MEMBER)"
130
+ expect "trusted by author_association" "DELTA $R..$HEAD" 1
131
+ comments "$(cm "<!-- FORGE:REVIEW_ROUTE mode=single-pr spec=review-pr.md sha=${R:0:7} -->")"
132
+ expect "7-char ROUTE sha is never a checkpoint" "FULL" 0
133
+ comments "$(cm "$(body_agent security "${R:0:7}")")" "$(cm "$(body_synth "${R:0:7}")")"
134
+ expect "7-char Reviewed-SHA is never a checkpoint" "FULL" 0
135
+ comments "$(cm "$(body_agent security "$R")")" "$(cm "$(body_agent api "$R")")"
136
+ expect "agent bodies without the post-guard proof (partial panel)" "FULL" 0
137
+ comments "$(cm "$(body_agent security "$R")")" "$(cm "$(body_summary "$R" 2)")"
138
+ expect "summary Agents count above distinct domains" "FULL" 0
139
+ comments "$(cm "$(body_agent security "$R")")" "$(cm "$(body_agent api "$R")")" "$(cm "$(body_summary "$R" 2)")"
140
+ expect "summary Agents count equal to distinct domains" "DELTA $R..$HEAD" 1
141
+ comments "$(cm "<!-- FORGE:REVIEW-AGENT:security -->
142
+ Reviewed-SHA: $R
143
+
144
+ no findings block")" "$(cm "$(body_synth "$R")")"
145
+ expect "agent body without REVIEW-FINDINGS-START" "FULL" 0
146
+ comments "$(cm "$(body_agent security "$R")")" "$(cm "$(body_synth "$R")")" "$(cm "<!-- FORGE:GATE_FAILURE:TYPE=review-panel-integrity -->
147
+ panel degraded")"
148
+ expect "review-panel-integrity gate failure after the agents" "FULL" 0
149
+ comments "$(cm "<!-- FORGE:REVIEW_DEGRADED -->")" "$(cm "$(body_agent security "$R")")" "$(cm "$(body_synth "$R")")"
150
+ expect "degraded marker before the agents does not disqualify" "DELTA $R..$HEAD" 1
151
+ comments "$(cm "$(body_agent security "$R")")" "$(cm "$(body_synth "$R")")" "$(cm "<!-- FORGE:REVIEW_BLOCKED -->")"
152
+ expect "REVIEW_BLOCKED after the agents" "FULL" 0
153
+ panel "$R"
154
+ expect "review-degraded label" "FULL" 0 --labels "needs-human,review-degraded"
155
+ # --labels given twice: last wins in the parser, so use a fresh invocation without the fixed --labels
156
+ out=$(cd "$W" && bash "$RD" --pr 1 --head "$HEAD" --base main --trusted-script "$TC" --comments-file "$CF" --labels "bug,docs" 2>&1 | sed -n 1p)
157
+ [ "$out" = "DELTA $R..$HEAD" ] && ok || bad "unrelated labels must not force FULL (got $out)"
158
+
159
+ # 9. FULL_ROUNDS
160
+ mkrepo; R1=$(sha HEAD); commit pr.txt "pr2" "pr2"; R2=$(sha HEAD); commit pr.txt "pr3" "pr3"; HEAD=$(sha HEAD)
161
+ comments "$(cm "$(body_agent security "$R1")")" "$(cm "$(body_synth "$R1")")" \
162
+ "$(cm "$(body_agent security "$R2")")" "$(cm "$(body_synth "$R2")")"
163
+ expect "two complete SHAs: newest is the checkpoint, FULL_ROUNDS=2" "DELTA $R2..$HEAD" 2
164
+ comments "$(cm "$(body_agent security "$R1")")" "$(cm "$(body_synth "$R1")")" "$(cm "$(body_agent api "$R1")")" \
165
+ "$(cm "$(body_agent security "$R2")")" "$(cm "$(body_synth "$R2")")" "$(cm "$(body_synth "$R2")")"
166
+ expect "duplicate SHA counted once" "DELTA $R2..$HEAD" 2
167
+ comments "$(cm "$(body_agent security "$R1")")" "$(cm "$(body_synth "$R1")")" "$(cm "$(body_agent security "$R2")")"
168
+ expect "partial SHA ignored in FULL_ROUNDS" "DELTA $R1..$HEAD" 1
169
+
170
+ # 10. error paths all print FULL (+ FULL_ROUNDS line)
171
+ mkrepo; R=$(sha HEAD); commit pr.txt "pr2" "pr2"; HEAD=$(sha HEAD); panel "$R"
172
+ expect "relative --trusted-script" "FULL" "" --trusted-script "scripts/trusted-comments.sh"
173
+ out=$(run_rd --trusted-script "scripts/trusted-comments.sh" | sed -n 2p); case "$out" in FULL_ROUNDS=*) ok ;; *) bad "error path must print a FULL_ROUNDS line (got '$out')" ;; esac
174
+ expect "nonexistent --trusted-script" "FULL" "" --trusted-script "$TMP/nope.sh"
175
+ printf '#!/usr/bin/env bash\nexit 2\n' > "$TMP/tc-fail.sh"
176
+ expect "trust script exits 2" "FULL" 0 --trusted-script "$TMP/tc-fail.sh"
177
+ expect "unreadable comments file" "FULL" 0 --comments-file "$TMP/missing.json"
178
+ : > "$TMP/empty.json"
179
+ expect "empty comments file" "FULL" 0 --comments-file "$TMP/empty.json"
180
+ printf 'not json' > "$TMP/bad.json"
181
+ expect "malformed comments JSON" "FULL" 0 --comments-file "$TMP/bad.json"
182
+ BADHEAD=$HEAD; HEAD="${HEAD:0:7}"; expect "7-char --head" "FULL" ""; HEAD="ZZZ"; expect "malformed --head" "FULL" ""; HEAD=$BADHEAD
183
+ out=$(cd "$W" && bash "$RD" --pr x --head "$HEAD" --base main --trusted-script "$TC" --comments-file "$CF" --labels "" 2>&1 | sed -n 1p); [ "$out" = FULL ] && ok || bad "non-numeric --pr (got $out)"
184
+ out=$(cd "$W" && bash "$RD" --pr 1 --head "$HEAD" --base "--evil" --trusted-script "$TC" --comments-file "$CF" --labels "" 2>&1 | sed -n 1p); [ "$out" = FULL ] && ok || bad "option-like --base (got $out)"
185
+ out=$(cd "$W" && bash "$RD" --bogus 2>&1 | sed -n 1p); [ "$out" = FULL ] && ok || bad "unknown flag (got $out)"
186
+ out=$(cd "$W" && bash "$RD" 2>&1 | sed -n 1p); [ "$out" = FULL ] && ok || bad "no args (got $out)"
187
+ out=$(cd "$W" && bash "$RD" --pr 1 --head "$HEAD" --base main --trusted-script "$TC" --comments-file "$CF" 2>&1 | sed -n 1p); [ "$out" = FULL ] && ok || bad "labels neither given nor fetchable (got $out)"
188
+ out=$(cd "$W" && bash "$RD" --pr 1 --head "$HEAD" --base nosuchbranch --trusted-script "$TC" --comments-file "$CF" --labels "" 2>&1 | sed -n 1p); [ "$out" = FULL ] && ok || bad "missing origin/<base> (got $out)"
189
+ # unknown reviewed object: the checkpoint names a well-formed SHA that is not in the repo
190
+ GHOST=0123456789abcdef0123456789abcdef01234567; panel "$GHOST"
191
+ expect "unknown reviewed object" "FULL" 1
192
+ # not a git repo
193
+ panel "$R"; NOGIT="$TMP/nogit"; mkdir -p "$NOGIT"
194
+ out=$(bash "$RD" --pr 1 --head "$HEAD" --base main --trusted-script "$TC" --comments-file "$CF" --labels "" --repo-path "$NOGIT" 2>&1 | sed -n 1p); [ "$out" = FULL ] && ok || bad "not a git repo (got $out)"
195
+
196
+ # 11. pagination shape: two concatenated arrays
197
+ CF="$TMP/paged.json"; { printf '[%s]\n' "$(cm "$(body_agent security "$R")")"; printf '[%s]\n' "$(cm "$(body_synth "$R")")"; } > "$CF"
198
+ expect "concatenated pagination arrays" "DELTA $R..$HEAD" 1
199
+
200
+ # 12. 64-hex (sha256) checkpoints are accepted shape-wise
201
+ mkrepo; R=$(sha HEAD); commit pr.txt "pr2" "pr2"; HEAD=$(sha HEAD)
202
+ R64="${R}0123456789abcdef01234567"; R64=${R64:0:64}; panel "$R64"
203
+ expect "64-hex checkpoint not in repo -> FULL (validated, then unknown object)" "FULL" 1
204
+
205
+ # 13. trust script is never taken from the cwd: a planted copy must not change the verdict
206
+ mkrepo; R=$(sha HEAD); commit pr.txt "pr2" "pr2"; HEAD=$(sha HEAD)
207
+ comments "$(cm "$(body_agent security "$R")" User NONE)" "$(cm "$(body_synth "$R")" User NONE)"
208
+ mkdir -p "$W/scripts"; printf '#!/usr/bin/env bash\ncat >/dev/null; jq -c . <<< "\\"$1\\""\n' > "$W/scripts/trusted-comments.sh"
209
+ out=$(cd "$W" && bash "$RD" --pr 1 --head "$HEAD" --base main --trusted-script "$TC" --comments-file "$CF" --labels "" 2>&1 | sed -n 1p)
210
+ [ "$out" = FULL ] && ok || bad "planted cwd trust script must be ignored (got $out)"
211
+
212
+ echo "review-delta tests: pass=$PASS fail=$FAILN"
213
+ [ "$FAILN" -eq 0 ]
@@ -0,0 +1,87 @@
1
+ #!/usr/bin/env bash
2
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
3
+ # SPDX-License-Identifier: AGPL-3.0-or-later
4
+ #
5
+ # Guards the review-pr roster-label to marker-domain mapping: every roster spelling collapses to one marker
6
+ # domain, the helper is identical wherever it is inlined, the roster dedups across spellings, and every
7
+ # persona file is covered by the table and the catalog. Run: bash scripts/review-pr-domain-map.test.sh
8
+ # (bash 3.2 compatible)
9
+
10
+ set -u
11
+ DIR=$(cd "$(dirname "$0")" && pwd)
12
+ ROOT=$(cd "$DIR/.." && pwd)
13
+ SPEC="$ROOT/commands/review-pr.md"
14
+ CATALOG="$ROOT/commands/review-pr-agents.md"
15
+ PERSONAS="$ROOT/commands/review-pr-agents"
16
+ pass=0; fail=0
17
+ ok() { pass=$((pass+1)); }
18
+ bad() { fail=$((fail+1)); echo "FAIL: $1"; }
19
+
20
+ WORK=$(mktemp -d "${TMPDIR:-/tmp}/review-pr-domain-map.XXXXXX")
21
+ trap 'rm -rf "$WORK"' EXIT
22
+
23
+ # Test 1: helper copies. Every inline definition must be byte-identical (each Bash call is a fresh shell).
24
+ awk -v dir="$WORK" '
25
+ /^# BEGIN review-pr-agent-domain/ {n++; out=dir "/helper." n; on=1}
26
+ on {print > out}
27
+ /^# END review-pr-agent-domain/ {on=0; close(out)}
28
+ ' "$SPEC"
29
+ copies=$(ls "$WORK"/helper.* 2>/dev/null | wc -l | tr -d ' ')
30
+ if [ "$copies" -ge 4 ]; then ok; else bad "expected >=4 inline agent_domain copies (roster, canonicalize, dispatch, phase 4), found $copies"; fi
31
+ for f in "$WORK"/helper.*; do
32
+ [ -f "$f" ] || continue
33
+ if cmp -s "$f" "$WORK/helper.1"; then ok; else bad "$(basename "$f") differs from helper.1"; fi
34
+ done
35
+
36
+ # Test 2: no site derives the marker by lowercasing the roster label directly
37
+ if grep -nE "AGENT_DOMAIN=.*tr '\[:upper:\]' '\[:lower:\]'" "$SPEC" >/dev/null; then bad "direct lowercase marker derivation remains"; else ok; fi
38
+ sites=$(grep -c 'AGENT_DOMAIN=$(agent_domain "$AGENT")' "$SPEC")
39
+ if [ "$sites" -ge 2 ]; then ok; else bad "expected >=2 agent_domain derivation sites, found $sites"; fi
40
+
41
+ # Test 3: spelling collapse
42
+ # shellcheck disable=SC1090
43
+ . "$WORK/helper.1"
44
+ check() { got=$(agent_domain "$1"); if [ "$got" = "$2" ]; then ok; else bad "agent_domain '$1' = '$got', want '$2'"; fi; }
45
+ for l in INFRA Infrastructure infra infrastructure; do check "$l" infra; done
46
+ for l in SCRAPING Scraping scraper Scraper scraping; do check "$l" scraper; done
47
+ for l in FRONTEND Frontend frontend Web; do check "$l" frontend; done
48
+ for l in Security SECURITY; do check "$l" security; done
49
+ for l in AUTH Auth; do check "$l" auth; done
50
+ for l in BILLING Billing; do check "$l" billing; done
51
+ for l in CONCURRENCY Concurrency; do check "$l" concurrency; done
52
+ for l in DATABASE Database; do check "$l" database; done
53
+ for l in API Api; do check "$l" api; done
54
+ check "CustomDomain" customdomain
55
+
56
+ # Test 4: add_agent dedups across spellings; canonicalization block yields one entry per persona
57
+ ADD=$(awk '/^add_agent\(\) \{/{on=1} on{print} on && /^\}/{exit}' "$SPEC")
58
+ if [ -n "$ADD" ]; then ok; else bad "add_agent not found in spec"; fi
59
+ eval "$ADD"
60
+ SELECTED_AGENTS="Security INFRA SCRAPING"
61
+ add_agent "Infrastructure"; add_agent "Scraping"; add_agent "Auth"; add_agent "AUTH"; add_agent "security"
62
+ want=$(printf '%s' "Security INFRA SCRAPING auth")
63
+ if [ "$SELECTED_AGENTS" = "$want" ]; then ok; else bad "add_agent roster '$SELECTED_AGENTS', want '$want'"; fi
64
+
65
+ CANON=$(awk '/^# END review-pr-agent-domain/{seen++} seen && /^CANON_ROSTER=/{on=1} on{print} on && /^SELECTED_AGENTS=/{exit}' "$SPEC" | head -20)
66
+ SELECTED_AGENTS="Security AUTH INFRA Infrastructure SCRAPING Scraping Frontend API api"
67
+ eval "$CANON"
68
+ want="security auth infra scraper frontend api"
69
+ if [ "$SELECTED_AGENTS" = "$want" ]; then ok; else bad "canonical roster '$SELECTED_AGENTS', want '$want'"; fi
70
+ cnt=$(printf '%s' "$SELECTED_AGENTS" | tr ' ' '\n' | grep -c '.')
71
+ if [ "$cnt" -eq 6 ]; then ok; else bad "roster count $cnt, want 6"; fi
72
+
73
+ # Test 5: every persona file (minus protocols and spec-cli) has a table row in the spec and a catalog row
74
+ for pf in "$PERSONAS"/*.md; do
75
+ name=$(basename "$pf" .md)
76
+ case "$name" in protocols|spec-cli) continue ;; esac
77
+ if grep -qE "^\| .*\| \`$name\` \| \`$name\.md\` \|" "$SPEC"; then ok; else bad "spec table has no row for persona $name"; fi
78
+ if grep -qE "^\| .*\| \`$name\` \| \`review-pr-agents/$name\.md\` \|" "$CATALOG"; then ok; else bad "catalog has no marker-domain row for persona $name"; fi
79
+ got=$(agent_domain "$name")
80
+ if [ "$got" = "$name" ]; then ok; else bad "persona $name maps to $got"; fi
81
+ done
82
+
83
+ # Test 6: the protocol defines the marker domain through the mapping
84
+ if grep -qF 'marker domain' "$PERSONAS/protocols.md"; then ok; else bad "protocols.md does not define the marker domain"; fi
85
+
86
+ echo "review-pr-domain-map: $pass passed, $fail failed"
87
+ [ "$fail" -eq 0 ]
@@ -0,0 +1,84 @@
1
+ #!/usr/bin/env bash
2
+ # review-pr-gate2.test.sh — cases for the review-pr §6B.4 gate-2 sketch (forge#3597, #3598).
3
+ # Extracts the fenced bash block from commands/review-pr.md, runs its set-up against a stubbed
4
+ # `gh`, then applies the per-finding rules the block documents (gone, no patch, introduced, severity).
5
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
6
+ # SPDX-License-Identifier: AGPL-3.0-or-later
7
+ set -uo pipefail
8
+ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
9
+ SPEC="$ROOT/commands/review-pr.md"
10
+ PASS=0; FAILN=0
11
+ TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
12
+ ok() { PASS=$((PASS+1)); }
13
+ bad() { FAILN=$((FAILN+1)); echo "FAIL: $1"; }
14
+ command -v jq >/dev/null 2>&1 || { echo "review-pr-gate2.test.sh: jq missing, skipped"; exit 0; }
15
+
16
+ # Extract the sketch: the fenced block that declares NOPATCH_FILES_FILE, minus comment-only lines.
17
+ awk '/^```bash$/{b="";in_b=1;next} /^```$/{if(in_b&&b~/NOPATCH_FILES_FILE=/)printf "%s",b; in_b=0;next} in_b{b=b $0 "\n"}' "$SPEC" \
18
+ | grep -v '^#' > "$TMP/sketch.sh"
19
+ if [ -s "$TMP/sketch.sh" ]; then ok; else bad "sketch block not found in review-pr.md"; echo "review-pr-gate2.test.sh: failed=$FAILN"; exit 1; fi
20
+
21
+ # Fixture files API: a.sh patch adds line 10; big.txt has no patch; gone.txt removed.
22
+ cat > "$TMP/files.json" <<'JSON'
23
+ [{"filename":"a.sh","status":"modified","patch":"@@ -9,2 +9,3 @@\n ctx\n+added\n ctx"},
24
+ {"filename":"big.txt","status":"modified"},
25
+ {"filename":"gone.txt","status":"removed"},
26
+ {"filename":"new.sh","status":"renamed","previous_filename":"old.sh","patch":"@@ -1,1 +1,1 @@\n+x"}]
27
+ JSON
28
+ mkdir "$TMP/bin"
29
+ printf '#!/bin/sh\ncat "%s/files.json"\n' "$TMP" > "$TMP/bin/gh"; chmod +x "$TMP/bin/gh"
30
+
31
+ # Spec's executable severity rule: the SEV= line and the case line from the sketch comments, comment marker stripped.
32
+ grep -E '^#[[:space:]]+(SEV=|case "\$SEV" in)' "$SPEC" | sed -e 's/^#[[:space:]]*//' -e 's/[[:space:]]*#.*$//' > "$TMP/rule.sh"
33
+ if [ "$(wc -l < "$TMP/rule.sh")" -eq 2 ]; then ok; else bad "severity rule lines not found in review-pr.md"; fi
34
+
35
+ {
36
+ printf 'export PATH="%s/bin:$PATH" FORGE_SCRATCHPAD="%s"\n' "$TMP" "$TMP"
37
+ printf 'SEVERITY_RULE=%q\n' "$(cat "$TMP/rule.sh")"
38
+ sed -e 's/{GH_REPO}/o\/r/; s/{PR_NUMBER}/1/; s/{REVIEW_SHA}/0123456789abcdef0123456789abcdef01234567/' "$TMP/sketch.sh"
39
+ cat <<'CLASSIFY'
40
+ # classify FILE LINE SEVERITY -> KEEP | pre-existing (mirrors the per-finding rules in the sketch comments)
41
+ classify() {
42
+ FILE="$1"; LINE="$2"; SEVERITY="$3"; INTRODUCED=0
43
+ grep -qxF -- "$FILE" "$GONE_FILES_FILE" && { echo KEEP; return; }
44
+ grep -qxF -- "$FILE" "$NOPATCH_FILES_FILE" && { echo KEEP; return; }
45
+ LO=$((LINE>5?LINE-5:1))
46
+ for L in $(seq "$LO" $((LINE+5))); do grep -qxF -- "${FILE}:${L}" "$ADDED_LINES_FILE" && INTRODUCED=1; done
47
+ SCOPE=changed
48
+ # The severity rule is the spec's own line (extracted below), not a re-implementation.
49
+ eval "$SEVERITY_RULE"
50
+ if [ "$SCOPE" = pre-existing ]; then echo pre-existing; else echo KEEP; fi
51
+ }
52
+ classify "$@"
53
+ CLASSIFY
54
+ } > "$TMP/run.sh"
55
+
56
+ check() { # name want file line severity
57
+ local got; got="$(bash "$TMP/run.sh" "$3" "$4" "$5" 2>&1 | tail -n1)"
58
+ if [ "$got" = "$2" ]; then ok; else bad "$1 (got '$got' want '$2')"; fi
59
+ }
60
+ check "added line intersects" KEEP a.sh 10 MEDIUM
61
+ check "MEDIUM far from added -> pre" pre-existing a.sh 100 MEDIUM
62
+ check "HIGH far from added kept" KEEP a.sh 100 HIGH
63
+ check "CRITICAL far from added kept" KEEP a.sh 100 CRITICAL
64
+ check "null-patch file MEDIUM kept" KEEP big.txt 3 MEDIUM
65
+ check "lowercase high kept" KEEP a.sh 100 high
66
+ check "padded HIGH kept" KEEP a.sh 100 "HIGH "
67
+ check "empty severity kept" KEEP a.sh 100 ""
68
+ check "unparseable severity kept" KEEP a.sh 100 "SEVERE"
69
+ check "lowercase medium -> pre" pre-existing a.sh 100 medium
70
+ check "INFO far from added -> pre" pre-existing a.sh 100 INFO
71
+ check "renamed file kept" KEEP old.sh 3 MEDIUM
72
+ check "patched file still pre w/ nopatch sibling" pre-existing a.sh 100 LOW
73
+ check "removed file kept" KEEP gone.txt 3 LOW
74
+ check "file absent from diff MEDIUM pre" pre-existing other.sh 5 MEDIUM
75
+ if grep -qF 'select(.patch == null' "$SPEC"; then ok; else bad "spec lacks null-patch selector"; fi
76
+
77
+ # Mirrored wording must stay in sync across the spec family.
78
+ for f in commands/review-pr-staging.md commands/review-pr-agents/protocols.md docs/spec/review-protocol.md; do
79
+ if grep -qiE 'CRITICAL/HIGH.*never routed as pre-existing' "$ROOT/$f"; then ok; else bad "$f lacks the CRITICAL/HIGH never-pre-existing rule"; fi
80
+ done
81
+ if grep -qF 'only known low severities' "$SPEC"; then ok; else bad "spec lacks inverted severity rule comment"; fi
82
+
83
+ echo "review-pr-gate2.test.sh: passed=$PASS failed=$FAILN"
84
+ [ "$FAILN" -eq 0 ]
@@ -0,0 +1,99 @@
1
+ #!/usr/bin/env bash
2
+ # review-pr-staging-scope.test.sh — cases for the review-pr-staging scoped-review gates (forge#3451):
3
+ # (1) the 7B out-of-scope rule may only demote MEDIUM/LOW/INFO, never HIGH/CRITICAL or unknown severity;
4
+ # (2) the 0B.1 "reviewed" test needs the full-length Reviewed-SHA line, not a 7-char substring.
5
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
6
+ # SPDX-License-Identifier: AGPL-3.0-or-later
7
+ set -uo pipefail
8
+ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
9
+ SPEC="$ROOT/commands/review-pr-staging.md"
10
+ TC="$ROOT/scripts/trusted-comments.sh"
11
+ PASS=0; FAILN=0
12
+ ok() { PASS=$((PASS+1)); }
13
+ bad() { FAILN=$((FAILN+1)); echo "FAIL: $1"; }
14
+ command -v jq >/dev/null 2>&1 || { echo "review-pr-staging-scope.test.sh: jq missing, skipped"; exit 0; }
15
+ TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
16
+
17
+ # (1) severity rule: the two SCOPE_RULE lines, marker stripped
18
+ grep -E '# SCOPE_RULE$' "$SPEC" | sed 's/[[:space:]]*# SCOPE_RULE$//; s/^[[:space:]]*//' > "$TMP/rule.sh"
19
+ if [ "$(wc -l < "$TMP/rule.sh")" -eq 2 ]; then ok; else bad "SCOPE_RULE lines not found (expected 2)"; fi
20
+ demote() { FINDING_SEVERITY="$1"; OOS_DEMOTE=; eval "$(cat "$TMP/rule.sh")"; echo "$OOS_DEMOTE"; }
21
+ chk() { [ "$(demote "$1")" = "$2" ] && ok || bad "severity '$1' expected demote=$2"; }
22
+ chk HIGH 0; chk high 0; chk " High " 0; chk CRITICAL 0; chk critical 0
23
+ chk MEDIUM 1; chk medium 1; chk LOW 1; chk INFO 1
24
+ chk "" 0; chk "SEVERE" 0; chk "HIGH/MEDIUM" 0
25
+
26
+ # (2) full-sha reviewed test: the _AGENT regex from the spec, run through the real trusted-comments predicate
27
+ LINE=$(grep -E '^[[:space:]]*_AGENT=' "$SPEC" | head -1)
28
+ RE=$(printf '%s' "$LINE" | sed -n 's/.*count "\(.*\)") ||.*/\1/p')
29
+ if [ -n "$RE" ]; then ok; else bad "_AGENT regex not found in review-pr-staging.md"; fi
30
+ FULL=0123456789abcdef0123456789abcdef01234567; SHORT=${FULL:0:7}
31
+ RE=${RE//\\\\/\\}; RE=${RE//\$\{_HEAD\}/$FULL}
32
+ FB=$'<!-- REVIEW-FINDINGS-START -->\nNo findings.\n<!-- REVIEW-FINDINGS-END -->'
33
+ mk() { jq -n --arg b "$1" --arg t "$2" '[{"body":$b,"author_association":"NONE","user":{"type":$t,"login":"x"}}]'; }
34
+ cnt() { bash "$TC" count "$RE"; }
35
+ [ "$(mk $'<!-- FORGE:REVIEW-AGENT:security -->\nReviewed-SHA: '"$FULL"$'\n\n'"$FB" Bot | cnt)" = 1 ] && ok || bad "full Reviewed-SHA line should count"
36
+ [ "$(mk $'<!-- FORGE:REVIEW-AGENT:security -->\nreviewed '"$SHORT"' only' Bot | cnt)" = 0 ] && ok || bad "7-char substring must not count"
37
+ [ "$(mk $'<!-- FORGE:REVIEW-AGENT:security -->\nReviewed-SHA: '"$SHORT"$'\n' Bot | cnt)" = 0 ] && ok || bad "short Reviewed-SHA must not count"
38
+ [ "$(mk $'<!-- FORGE:REVIEW-AGENT:security -->\nReviewed-SHA: '"${FULL}"$'ff\n'"$FB" Bot | cnt)" = 0 ] && ok || bad "longer sha must not count"
39
+ [ "$(mk $'<!-- FORGE:REVIEW-AGENT:security -->\nReviewed-SHA: '"$FULL"$'\n'"$FB" User | cnt)" = 0 ] && ok || bad "untrusted author must not count"
40
+ [ "$(mk $'<!-- FORGE:REVIEW-AGENT:security -->\nReviewed-SHA: '"$FULL"$'\n\nno findings block here' Bot | cnt)" = 0 ] && ok || bad "comment without a findings block must not count (panel integrity)"
41
+
42
+ # (3) _ROUTE regex: real mode on the final head counts, spec-evolution-blocked and a stale sha do not
43
+ RLINE=$(grep -E '^[[:space:]]*_ROUTE=' "$SPEC" | head -1)
44
+ RRE=$(printf '%s' "$RLINE" | sed -n 's/.*count "\(.*\)") ||.*/\1/p'); RRE=${RRE//\\\\/\\}; RRE=${RRE//\$\{_HEAD7\}/$SHORT}
45
+ rcnt() { bash "$TC" count "$RRE"; }
46
+ [ "$(mk '<!-- FORGE:REVIEW_ROUTE mode=single-pr spec=review-pr.md sha='"$SHORT"' -->' Bot | rcnt)" = 1 ] && ok || bad "route marker on the final head should count"
47
+ [ "$(mk '<!-- FORGE:REVIEW_ROUTE mode=spec-evolution-blocked spec=review-pr.md sha='"$SHORT"' -->' Bot | rcnt)" = 0 ] && ok || bad "spec-evolution-blocked route must not count"
48
+ [ "$(mk '<!-- FORGE:REVIEW_ROUTE mode=single-pr spec=review-pr.md sha=abcdef0 -->' Bot | rcnt)" = 0 ] && ok || bad "stale-sha route must not count"
49
+
50
+ # (4) spec text guards: agents may not be told to suppress findings; no $PWD tier in the classifier resolver; git reads are status-checked
51
+ if grep -q 'MUST NOT report a defect' "$SPEC"; then bad "Phase 2 must not tell agents to suppress findings (7B owns demotion)"; else ok; fi
52
+ if grep -n 'FINDING_TEXT_FILE=$(mktemp' "$SPEC" >/dev/null && grep -q '> "$FINDING_TEXT_FILE"' "$SPEC"; then ok; else bad "FINDING_TEXT_FILE must be written before the classifier call"; fi
53
+ if awk '/^CLASSIFY_SCRIPT=""/,/^done <<</' "$SPEC" | grep -q 'PWD'; then bad "classifier resolver must have no PWD tier"; else ok; fi
54
+ if grep -qE "^DEPLOY_WIRING_FILES=.*git diff" "$SPEC"; then bad "DEPLOY_WIRING_FILES must not pipe git diff directly"; else ok; fi
55
+ if grep -q 'git diff-tree .*|| { SCOPE_MODE=full' "$SPEC"; then ok; else bad "git diff-tree reads must fall back to full scope"; fi
56
+ if grep -q 'classify_one()' "$SPEC" && grep -q 'REVIEWED_MERGES' "$SPEC" && grep -q '_scope.env' "$SPEC"; then ok; else bad "7B.5 must be a per-finding function with persisted scope state"; fi
57
+ if grep -q 'DUPLICATE: #' "$SPEC" && grep -q 'grep -qF -- "$_SAFE_FILE"' "$SPEC"; then ok; else bad "7E exit 1 must be confirmed by file before skipping"; fi
58
+ # (5) run the spec's classify_one with a stub classifier: HIGH out of scope stays ISSUE; LOW out of scope is demoted and listed; multi-file in-scope is not demoted
59
+ awk '/^classify_one\(\) \{/,/^\}/' "$SPEC" > "$TMP/fn.sh"
60
+ printf '#!/bin/sh\necho "$STUB_DISP"\n' > "$TMP/classify.sh"
61
+ run1() { # severity stub-disposition paths -> prints DISPOSITION
62
+ ( _SD="$TMP"; PR_NUMBER=9; CLASSIFY_SCRIPT="$TMP/classify.sh"; SCOPE_MODE=scoped; CROSS_PR_FILES="a/in.sh"; UNREVIEWED_FILES=""; DEPLOY_WIRING_FILES=""
63
+ NOTE_LIST_FILE="$TMP/n.md"; : > "$NOTE_LIST_FILE"; NOTES_LISTED=0; FINDINGS_FILED=0; OUT_OF_SCOPE=0
64
+ FINDING_ID=X1; FINDING_SEVERITY="$1"; FINDING_CONFIDENCE=CONFIRMED; FINDING_AGENT=Security; FINDING_TITLE=t; FINDING_BODY=b; FINDING_FILE=z/out.sh; FINDING_PATHS="$3"
65
+ export STUB_DISP="$2"; . "$TMP/fn.sh"; classify_one; echo "$DISPOSITION" ) 2>&1 | tail -1; }
66
+ [ "$(run1 HIGH 'ISSUE severity-HIGH' 'z/out.sh')" = "ISSUE severity-HIGH" ] && ok || bad "HIGH out of scope must stay ISSUE"
67
+ [ "$(run1 high 'ISSUE severity-HIGH' 'z/out.sh')" = "ISSUE severity-HIGH" ] && ok || bad "lowercase high out of scope must stay ISSUE"
68
+ [ "$(run1 LOW 'NOTE LOW-LIKELY' 'z/out.sh')" = "NOTE out_of_scope" ] && ok || bad "LOW out of scope should be demoted and listed"
69
+ [ "$(run1 MEDIUM 'ISSUE MEDIUM-CONFIRMED' 'z/out.sh')" = "NOTE out_of_scope" ] && ok || bad "MEDIUM out of scope should be demoted"
70
+ [ "$(run1 MEDIUM 'ISSUE MEDIUM-CONFIRMED' $'z/out.sh\na/in.sh')" = "ISSUE MEDIUM-CONFIRMED" ] && ok || bad "multi-file finding with an in-scope file must not be demoted"
71
+ [ "$(run1 LOW 'ISSUE safety-exemption-auth' 'z/out.sh')" = "ISSUE safety-exemption-auth" ] && ok || bad "safety-exempt finding must not be demoted"
72
+ # (6) behavioural --text-file: each classify_one call must hand the classifier its OWN finding text (a shared/stale text file would leak the first finding into the second)
73
+ cat > "$TMP/classify-textfile.sh" <<'STUB'
74
+ #!/bin/bash
75
+ tf=""
76
+ while [ $# -gt 0 ]; do case "$1" in --text-file) tf="$2"; shift ;; esac; shift; done
77
+ [ -n "$tf" ] && [ -r "$tf" ] || { echo "NOTE no-text-file"; exit 0; }
78
+ if grep -q 'MARKER_TOKEN' "$tf"; then echo "ISSUE saw-marker"; else echo "NOTE no-marker"; fi
79
+ STUB
80
+ mkdir -p "$TMP/sd6"
81
+ run2() { # prints the dispositions of two consecutive classify_one calls in ONE shell
82
+ ( _SD="$TMP/sd6"; PR_NUMBER=9; CLASSIFY_SCRIPT="$TMP/classify-textfile.sh"; SCOPE_MODE=full; CROSS_PR_FILES=""; UNREVIEWED_FILES=""; DEPLOY_WIRING_FILES=""
83
+ NOTE_LIST_FILE="$TMP/n6.md"; : > "$NOTE_LIST_FILE"; NOTES_LISTED=0; FINDINGS_FILED=0; OUT_OF_SCOPE=0
84
+ FINDING_ID=X1; FINDING_SEVERITY=LOW; FINDING_CONFIDENCE=CONFIRMED; FINDING_AGENT=Security; FINDING_BODY=b; FINDING_FILE=z/a.sh; FINDING_PATHS=z/a.sh
85
+ . "$TMP/fn.sh"
86
+ FINDING_TITLE="has MARKER_TOKEN"; classify_one; echo "$DISPOSITION"
87
+ FINDING_TITLE="plain title"; classify_one; echo "$DISPOSITION"
88
+ FINDING_TITLE="has MARKER_TOKEN"; classify_one; echo "$DISPOSITION" ) 2>&1
89
+ }
90
+ OUT6=$(run2 | tr '\n' '|')
91
+ [ "$OUT6" = "ISSUE saw-marker|NOTE no-marker|ISSUE saw-marker|" ] && ok || bad "each classify_one call must classify its own text-file content (got '$OUT6')"
92
+ if ls "$TMP/sd6"/staging-finding.* >/dev/null 2>&1; then bad "classify_one must remove its per-finding temp file"; else ok; fi
93
+ # (7) fail-closed guard: BLOCKED when the classifier is unresolvable, no manual classifier mode, no classifier-unavailable fallback
94
+ SPEC_MAIN="$(dirname "$SPEC")/review-pr.md"
95
+ for _s in "$SPEC" "$SPEC_MAIN"; do
96
+ if grep -qE 'BLOCKED.*classifier unavailable' "$_s" && ! grep -qF 'echo script || echo manual' "$_s" && ! grep -qF 'classifier=manual' "$_s" && ! grep -qF 'classifier-unavailable' "$_s"; then ok; else bad "$(basename "$_s") must fail closed with BLOCKED classifier unavailable and have no manual fallback"; fi
97
+ done
98
+ echo "review-pr-staging-scope.test.sh: passed=$PASS failed=$FAILN"
99
+ [ "$FAILN" -eq 0 ]
@@ -0,0 +1,115 @@
1
+ #!/usr/bin/env bash
2
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
3
+ # SPDX-License-Identifier: AGPL-3.0-or-later
4
+ #
5
+ # run-shell-tests.sh — glob runner for every scripts/*.test.sh suite.
6
+ #
7
+ # Usage: bash scripts/run-shell-tests.sh [suite-file ...]
8
+ # No args: runs every scripts/*.test.sh (sorted). New suites need no workflow edit.
9
+ # Args: runs only the given suite files (used to point at fixtures).
10
+ #
11
+ # Env:
12
+ # SHELL_TEST_TIMEOUT per-suite timeout in seconds (default 300)
13
+ #
14
+ # Exit: 0 when every suite passes; 1 on any FAIL/TIMEOUT or when no suite is found.
15
+ # Bash 3.2 portable: no mapfile, no associative arrays.
16
+
17
+ set -u
18
+
19
+ SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
20
+ REPO_ROOT=$(cd "$SCRIPT_DIR/.." && pwd)
21
+ cd "$REPO_ROOT" || exit 1
22
+
23
+ TIMEOUT_SECS="${SHELL_TEST_TIMEOUT:-300}"
24
+ # WIRE:PROVEN — manual: SHELL_TEST_TIMEOUT=abc and SHELL_TEST_TIMEOUT=0 both print the error and exit 2
25
+ case "$TIMEOUT_SECS" in
26
+ ''|*[!0-9]*) echo "run-shell-tests: SHELL_TEST_TIMEOUT must be a positive integer, got '$TIMEOUT_SECS'" >&2; exit 2 ;;
27
+ esac
28
+ if [ "$TIMEOUT_SECS" -eq 0 ]; then
29
+ echo "run-shell-tests: SHELL_TEST_TIMEOUT must be a positive integer, got '$TIMEOUT_SECS'" >&2
30
+ exit 2
31
+ fi
32
+
33
+ TIMEOUT_BIN=""
34
+ # WIRE:PROVEN — manual: timeout branch runs on Linux; gtimeout branch is the macOS fallback (same invocation shape); neither-found branch only warns and runs suites unbounded
35
+ if command -v timeout >/dev/null 2>&1; then
36
+ TIMEOUT_BIN="timeout"
37
+ elif command -v gtimeout >/dev/null 2>&1; then
38
+ TIMEOUT_BIN="gtimeout"
39
+ else
40
+ echo "run-shell-tests: no timeout/gtimeout found; suites run without a per-suite timeout" >&2
41
+ fi
42
+
43
+ SUITES=""
44
+ if [ "$#" -gt 0 ]; then
45
+ for f in "$@"; do
46
+ SUITES="${SUITES}${f}
47
+ "
48
+ done
49
+ else
50
+ for f in scripts/*.test.sh; do
51
+ [ -e "$f" ] || continue
52
+ SUITES="${SUITES}${f}
53
+ "
54
+ done
55
+ fi
56
+
57
+ if [ -z "$SUITES" ]; then
58
+ echo "run-shell-tests: no suites found (scripts/*.test.sh)" >&2
59
+ exit 1
60
+ fi
61
+
62
+ # WIRE:PROVEN — manual: GITHUB_ACTIONS=true emits ::group::/::endgroup:: lines, unset prints "=== suite"
63
+ GROUPING=false
64
+ [ "${GITHUB_ACTIONS:-}" = "true" ] && GROUPING=true
65
+
66
+ TOTAL=0
67
+ FAILED=0
68
+ SUMMARY=""
69
+
70
+ while IFS= read -r suite; do
71
+ [ -n "$suite" ] || continue
72
+ TOTAL=$((TOTAL + 1))
73
+ # WIRE:PROVEN — manual: passing a nonexistent suite path prints FAIL (missing file) and exits 1
74
+ if [ ! -f "$suite" ]; then
75
+ SUMMARY="${SUMMARY}FAIL ${suite} (missing file)
76
+ "
77
+ FAILED=$((FAILED + 1))
78
+ continue
79
+ fi
80
+
81
+ if $GROUPING; then echo "::group::${suite}"; else echo "=== ${suite}"; fi
82
+ start=$(date +%s)
83
+ if [ -n "$TIMEOUT_BIN" ]; then
84
+ "$TIMEOUT_BIN" "$TIMEOUT_SECS" bash "$suite"
85
+ else
86
+ bash "$suite"
87
+ fi
88
+ rc=$?
89
+ elapsed=$(( $(date +%s) - start ))
90
+ if $GROUPING; then echo "::endgroup::"; fi
91
+
92
+ if [ "$rc" -eq 0 ]; then
93
+ status="PASS"
94
+ # WIRE:PROVEN — manual: SHELL_TEST_TIMEOUT=1 against a `sleep 5` fixture suite yields rc 124 -> TIMEOUT
95
+ elif [ "$rc" -eq 124 ] && [ -n "$TIMEOUT_BIN" ]; then
96
+ status="TIMEOUT"
97
+ FAILED=$((FAILED + 1))
98
+ else
99
+ status="FAIL"
100
+ FAILED=$((FAILED + 1))
101
+ fi
102
+ [ "$status" = "PASS" ] || echo "${status}: ${suite} (exit ${rc})"
103
+ SUMMARY="${SUMMARY}$(printf '%-8s %s (%ss)' "$status" "$suite" "$elapsed")
104
+ "
105
+ done <<EOF_SUITES
106
+ ${SUITES}
107
+ EOF_SUITES
108
+
109
+ echo
110
+ echo "=== Shell test summary ==="
111
+ printf '%s' "$SUMMARY"
112
+ echo "Total: ${TOTAL} Failed: ${FAILED}"
113
+
114
+ [ "$FAILED" -eq 0 ] || exit 1
115
+ exit 0