forgedock 1.16.2 → 1.17.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 (95) hide show
  1. package/bin/engine/outcome-codes.mjs +92 -0
  2. package/bin/engine/phases.mjs +86 -26
  3. package/bin/engine/projector.mjs +48 -1
  4. package/bin/engine/runlog.mjs +13 -5
  5. package/bin/engine/telemetry-seam.mjs +62 -0
  6. package/bin/engine/telemetry-seam.test.mjs +60 -0
  7. package/bin/engine/telemetry.mjs +148 -0
  8. package/bin/engine/telemetry.test.mjs +120 -0
  9. package/bin/engine-cli.mjs +12 -1
  10. package/bin/engine.mjs +145 -40
  11. package/bin/forgedock.mjs +7 -1
  12. package/bin/hooks/interactive-engine.mjs +10 -0
  13. package/bin/hooks/pre-tool-use.mjs +187 -0
  14. package/bin/runner.mjs +7 -0
  15. package/bin/stats/aggregate.mjs +231 -0
  16. package/bin/stats/cache.mjs +40 -0
  17. package/bin/stats/github-source.mjs +140 -0
  18. package/bin/stats/index.mjs +154 -0
  19. package/bin/stats/local-source.mjs +79 -0
  20. package/bin/stats/package.json +6 -0
  21. package/bin/stats/render.mjs +82 -0
  22. package/bin/stats/stats.test.mjs +224 -0
  23. package/commands/cleanup.md +83 -2
  24. package/commands/deploy-info.md +8 -4
  25. package/commands/orchestrate/phase-1-resolve.md +4 -1
  26. package/commands/orchestrate/phase-4-execution.md +17 -2
  27. package/commands/orchestrate/phase-5-cleanup.md +18 -2
  28. package/commands/pipeline-health/phase-1-context.md +2 -0
  29. package/commands/pipeline-health/phase-2-metrics.md +12 -5
  30. package/commands/pipeline-status.md +21 -1
  31. package/commands/review-pr-agents/protocols.md +1 -0
  32. package/commands/review-pr-agents.md +10 -2
  33. package/commands/review-pr-staging.md +102 -25
  34. package/commands/review-pr.md +119 -103
  35. package/commands/work-on/build/implement.md +26 -5
  36. package/commands/work-on/build/validate.md +3 -2
  37. package/commands/work-on/build.md +95 -64
  38. package/commands/work-on/close.md +17 -6
  39. package/commands/work-on/decompose.md +45 -4
  40. package/commands/work-on/investigate.md +6 -2
  41. package/commands/work-on/remediate.md +22 -6
  42. package/commands/work-on/review.md +2 -2
  43. package/docs/CONFIG.md +33 -0
  44. package/docs/OUTCOME-CODES.md +48 -0
  45. package/docs/RELEASE-NOTES.md +12 -0
  46. package/docs/STATS.md +62 -0
  47. package/docs/TELEMETRY.md +65 -0
  48. package/docs/WORK-ON-RUNTIME.md +13 -0
  49. package/docs/site/command-reference.md +1 -1
  50. package/docs/spec/label-state-machine.md +1 -1
  51. package/package.json +3 -2
  52. package/scripts/builder-resume-match.test.sh +58 -0
  53. package/scripts/cap-findings.sh +110 -0
  54. package/scripts/cap-findings.test.sh +130 -0
  55. package/scripts/check-spec-markers.sh +2 -0
  56. package/scripts/classify-finding.sh +65 -16
  57. package/scripts/classify-finding.test.sh +43 -1
  58. package/scripts/classify-grep-exit.sh +20 -0
  59. package/scripts/classify-grep-exit.test.sh +46 -0
  60. package/scripts/close-parent-c3.test.sh +54 -0
  61. package/scripts/cost-report.sh +146 -0
  62. package/scripts/cost-report.test.sh +80 -0
  63. package/scripts/diff-size.sh +5 -0
  64. package/scripts/doctor-pipeline-state.sh +43 -0
  65. package/scripts/file-deferred-followups.sh +159 -0
  66. package/scripts/file-deferred-followups.test.sh +106 -0
  67. package/scripts/fixtures/cost-report/a-merged1.jsonl +6 -0
  68. package/scripts/fixtures/cost-report/a-merged2-remediated.jsonl +7 -0
  69. package/scripts/fixtures/cost-report/a-needs-human.jsonl +4 -0
  70. package/scripts/fixtures/cost-report/b-merged.jsonl +3 -0
  71. package/scripts/fixtures/cost-report/c-merged-attempt-usage.jsonl +8 -0
  72. package/scripts/fixtures/cost-report/hydrated-no-usage.jsonl +3 -0
  73. package/scripts/fixtures/cost-report/old-log-no-version.jsonl +3 -0
  74. package/scripts/fixtures/cost-report/unfinished-no-terminal.jsonl +2 -0
  75. package/scripts/fixtures/cost-report/unfinished-reopened.jsonl +5 -0
  76. package/scripts/forge-root.test.sh +8 -3
  77. package/scripts/lib/fence-bootstrap.sh +4 -3
  78. package/scripts/lib/pr-issue-number.sh +18 -0
  79. package/scripts/lib/review-template-resolve.sh +41 -0
  80. package/scripts/lib/telemetry.sh +168 -0
  81. package/scripts/remediate-liveness.sh +42 -6
  82. package/scripts/review-acceptance-gate.sh +16 -7
  83. package/scripts/review-acceptance-gate.test.sh +13 -0
  84. package/scripts/review-pr-domain-map.test.sh +8 -3
  85. package/scripts/review-pr-gate2.test.sh +15 -0
  86. package/scripts/review-pr-staging-finding-filter.test.sh +44 -0
  87. package/scripts/review-pr-staging-scope.test.sh +65 -5
  88. package/scripts/review-prepare.sh +517 -0
  89. package/scripts/review-prepare.test.sh +270 -0
  90. package/scripts/review-roster.sh +172 -0
  91. package/scripts/review-roster.test.sh +163 -0
  92. package/scripts/run-shell-tests.sh +3 -0
  93. package/scripts/telemetry.test.sh +106 -0
  94. package/scripts/wait-ci-green.sh +8 -1
  95. package/templates/forge.yaml.minimal +9 -0
@@ -0,0 +1,270 @@
1
+ #!/usr/bin/env bash
2
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
3
+ # SPDX-License-Identifier: AGPL-3.0-or-later
4
+ #
5
+ # Tests for scripts/review-prepare.sh (review-pr Phases 1-3B + delta/slices) and its shared libs. A stubbed `gh` and throwaway git repos
6
+ # stand in for GitHub and the PR checkout. Run: FORGEDOCK_HOME=$PWD bash scripts/review-prepare.test.sh (bash 3.2 compatible)
7
+
8
+ set -u
9
+ DIR=$(cd "$(dirname "$0")" && pwd)
10
+ ROOT=$(cd "$DIR/.." && pwd)
11
+ pass=0; fail=0
12
+ ok() { pass=$((pass+1)); }
13
+ bad() { fail=$((fail+1)); echo "FAIL: $1"; }
14
+ WORK=$(mktemp -d "${TMPDIR:-/tmp}/review-prepare-test.XXXXXX")
15
+ trap 'rm -rf "$WORK"' EXIT
16
+ command -v jq >/dev/null 2>&1 || { echo "SKIP: jq not installed"; exit 0; }
17
+ command -v yq >/dev/null 2>&1 || { echo "SKIP: yq not installed"; exit 0; }
18
+
19
+ export HOME="$WORK/home"; mkdir -p "$HOME"
20
+ unset FORGE_ROOT FORGE_HOME CLAUDE_PLUGIN_ROOT FORGE_CONFIG FORGE_SCRATCHPAD
21
+ export REVIEW_PREPARE_SLEEP=0 GIT_AUTHOR_NAME=t GIT_AUTHOR_EMAIL=t@t GIT_COMMITTER_NAME=t GIT_COMMITTER_EMAIL=t@t
22
+
23
+ # ---- stubbed gh: every answer comes from files under $FIX ----
24
+ FIX="$WORK/fix"; mkdir -p "$FIX" "$WORK/bin"
25
+ cat > "$WORK/bin/gh" <<'STUB'
26
+ #!/usr/bin/env bash
27
+ a="$*"
28
+ case "$a" in
29
+ "pr view"*"mergeable,mergeStateStatus"*)
30
+ f="$FIX/merge.seq"; l=$(head -n 1 "$f"); [ "$(wc -l < "$f")" -gt 1 ] && sed -i.bak 1d "$f"; echo "$l" ;;
31
+ "pr view"*"--json body"*) cat "$FIX/body.txt" ;;
32
+ "pr view"*) cat "$FIX/prinfo.json" ;;
33
+ "pr diff"*"--name-only"*) [ -f "$FIX/difffail" ] && exit 1; cat "$FIX/files" ;;
34
+ "pr diff"*) cat "$FIX/diff" ;;
35
+ "pr list"*) cat "$FIX/prior" 2>/dev/null || echo 1 ;;
36
+ "api --paginate"*) [ -f "$FIX/comments.fail" ] && exit 1; cat "$FIX/comments.json" 2>/dev/null || echo '[]' ;;
37
+ "issue view"*"labels"*) cat "$FIX/labels" 2>/dev/null || echo false ;;
38
+ "issue view"*"body"*) cat "$FIX/issuebody" 2>/dev/null ;;
39
+ *) exit 1 ;;
40
+ esac
41
+ STUB
42
+ chmod +x "$WORK/bin/gh"; export FIX PATH="$WORK/bin:$PATH"
43
+
44
+ # ---- fake trusted install roots (the real one, or a trimmed copy with a stub delta resolver) ----
45
+ mkroot() { # mkroot <dir> [no-roster] [delta-stub-file] [templates]
46
+ local r="$1"; mkdir -p "$r/scripts/lib"
47
+ cp "$ROOT/scripts/lib/forge-resolve.sh" "$ROOT/scripts/lib/review-delta-mode.sh" "$ROOT/scripts/lib/pr-issue-number.sh" "$ROOT/scripts/lib/review-template-resolve.sh" "$r/scripts/lib/"
48
+ cp "$ROOT/scripts/trusted-comments.sh" "$r/scripts/"
49
+ [ "${2:-}" = "no-roster" ] || cp "$ROOT/scripts/review-roster.sh" "$r/scripts/"
50
+ [ -n "${3:-}" ] && cp "$3" "$r/scripts/review-delta.sh"
51
+ if [ -n "${4:-}" ]; then mkdir -p "$r/commands/review-pr-agents"; echo "# protocols" > "$r/commands/review-pr-agents/protocols.md"; fi
52
+ }
53
+
54
+ # ---- throwaway PR checkout: main (A) + one more commit (B = PR head) ----
55
+ REPO_DIR="$WORK/repo"; mkdir -p "$REPO_DIR/services/api"
56
+ git -C "$REPO_DIR" init -q -b main
57
+ echo a > "$REPO_DIR/a.txt"; git -C "$REPO_DIR" add -A; git -C "$REPO_DIR" commit -qm A
58
+ SHA_A=$(git -C "$REPO_DIR" rev-parse HEAD)
59
+ git -C "$REPO_DIR" checkout -q -b pr-branch; echo b > "$REPO_DIR/services/api/auth.py"
60
+ git -C "$REPO_DIR" add -A; git -C "$REPO_DIR" commit -qm B
61
+ SHA_B=$(git -C "$REPO_DIR" rev-parse HEAD)
62
+ git -C "$REPO_DIR" checkout -q main
63
+ git -C "$REPO_DIR" remote add origin "$REPO_DIR"
64
+
65
+ CFG="$WORK/forge.yaml"
66
+ printf 'project:\n name: Demo\nverification:\n commands:\n typescript:\n build: "npm run build"\n python:\n format: "ruff format --check"\n' > "$CFG"
67
+
68
+ # setup <head> <base> <files> <diff> [sha]
69
+ setup() {
70
+ rm -f "${FIX:?}"/*; printf 'MERGEABLE|CLEAN\n' > "$FIX/merge.seq"; : > "$FIX/body.txt"
71
+ printf '{"number":7,"title":"t","author":{"login":"dev"},"baseRefName":"%s","headRefName":"%s","headRefOid":"%s","additions":5,"deletions":2,"changedFiles":1}\n' "$2" "$1" "${5:-$SHA_B}" > "$FIX/prinfo.json"
72
+ printf '%s\n' "$3" > "$FIX/files"; printf '%s\n' "$4" > "$FIX/diff"
73
+ }
74
+ CTX=""; RC=0
75
+ # prep <install root> [extra args...]
76
+ prep() {
77
+ local r="$1"; shift
78
+ CTX=$(cd "$REPO_DIR" && FORGEDOCK_HOME="$r" FORGE_CONFIG="${CFG_OVERRIDE:-$CFG}" bash "$ROOT/scripts/review-prepare.sh" --pr 7 --repo o/r --out-dir "$WORK/out" "$@" 2>"$WORK/stderr"); RC=$?
79
+ }
80
+ jqv() { printf '%s' "$CTX" | jq -r "$1" 2>/dev/null; }
81
+ eq() { local got; got=$(jqv "$2"); if [ "$got" = "$3" ]; then ok; else bad "$1: $2 = '$got', want '$3'"; fi; }
82
+ rc_is() { if [ "$RC" = "$2" ]; then ok; else bad "$1: exit $RC, want $2 ($(head -c 300 "$WORK/stderr"))"; fi; }
83
+
84
+ DIFF_PLAIN='diff --git a/README.md b/README.md
85
+ +hello'
86
+ DIFF_AUTH_BILL='diff --git a/services/api/auth.py b/services/api/auth.py
87
+ +def f(): jwt = get_current_user(); stripe.charge()
88
+ diff --git a/services/api/billing.py b/services/api/billing.py
89
+ +credit = 1'
90
+
91
+ # --- 1. happy path, schema, small tier ---
92
+ setup feat/x staging "README.md" "$DIFF_PLAIN"
93
+ prep "$ROOT"
94
+ rc_is "happy path" 0
95
+ eq "schema" .schema_version 1; eq "status" .status OK; eq "pr number" .pr.number 7; eq "review sha" .pr.review_sha "$SHA_B"
96
+ eq "sha7" .pr.review_sha_short "$(printf '%s' "$SHA_B" | cut -c1-7)"
97
+ eq "small tier" .roster.tier small; eq "small roster" '.roster.agents | join(" ")' security
98
+ eq "lines" '.pr.additions + .pr.deletions' 7
99
+ eq "first-time false" .pr.first_time_contributor false
100
+ eq "template source resolved" '.templates.source != "none"' true
101
+ eq "delta FULL" .delta.mode FULL
102
+
103
+ # --- 2. merge health ---
104
+ setup feat/x staging "README.md" "$DIFF_PLAIN"; printf 'UNKNOWN|UNKNOWN\nUNKNOWN|UNKNOWN\nMERGEABLE|CLEAN\n' > "$FIX/merge.seq"
105
+ prep "$ROOT"; eq "retry count" .merge_health.retries 2; eq "retry result" .merge_health.mergeable MERGEABLE; eq "retry state" .merge_health.state CLEAN
106
+ setup feat/x staging "README.md" "$DIFF_PLAIN"; printf 'CONFLICTING|DIRTY\n' > "$FIX/merge.seq"
107
+ prep "$ROOT"; eq "conflicting" .merge_health.mergeable CONFLICTING; eq "conflicting state" .merge_health.state DIRTY; eq "no retry on CONFLICTING" .merge_health.retries 0
108
+ setup feat/x staging "README.md" "$DIFF_PLAIN"; printf 'UNKNOWN|UNKNOWN\n' > "$FIX/merge.seq"
109
+ prep "$ROOT"; eq "unknown stays unknown after 3 retries" .merge_health.retries 3; eq "still UNKNOWN" .merge_health.mergeable UNKNOWN
110
+ setup feat/x staging "README.md" "$DIFF_PLAIN"; printf 'garbage|$(rm -rf)\n' > "$FIX/merge.seq"
111
+ prep "$ROOT"; eq "garbage state normalized" .merge_health.state UNKNOWN
112
+ setup feat/x staging "README.md" "$DIFF_PLAIN"; echo 0 > "$FIX/prior"
113
+ prep "$ROOT"; eq "first-time contributor" .pr.first_time_contributor true
114
+
115
+ # --- 3. classification + scores ---
116
+ setup feat/x staging "services/api/auth.py
117
+ services/api/billing.py" "$DIFF_AUTH_BILL"
118
+ prep "$ROOT"
119
+ eq "services has API" '.classification.services | index("API") != null' true
120
+ eq "domains has AUTH" '.classification.domains | index("AUTH") != null' true
121
+ eq "domains has BILLING" '.classification.domains | index("BILLING") != null' true
122
+ eq "auth score" '.scores.AUTH > 0' true; eq "billing score" '.scores.BILLING > 0' true
123
+ eq "security baseline" .scores.SECURITY 1
124
+ eq "risk FINANCIAL" '.risk_signals | index("FINANCIAL") != null' true
125
+ eq "slice auth file exists" '.slices.auth.diff_file | length > 0' true
126
+ if grep -q "auth.py" "$WORK/out/slice-auth.diff" 2>/dev/null; then ok; else bad "auth slice should hold the auth.py diff"; fi
127
+ if grep -q "billing.py" "$WORK/out/slice-auth.diff" 2>/dev/null; then bad "auth slice leaked the billing diff"; else ok; fi
128
+ eq "auth slice paths" '.slices.auth.paths | join(" ")' "services/api/auth.py"
129
+ eq "security slice is the full diff" '.slices.security.paths | length' 2
130
+
131
+ # --- 4. roster: thorough, scraping config ordering, unresolved roster script ---
132
+ setup feat/x staging "services/api/auth.py
133
+ services/api/billing.py" "$DIFF_AUTH_BILL"
134
+ prep "$ROOT" --thorough true
135
+ eq "thorough tier" .roster.tier full
136
+ eq "thorough roster has auth+billing+concurrency" '.roster.agents | (index("auth") != null) and (index("billing") != null) and (index("concurrency") != null)' true
137
+ SCRAPE_DIFF='diff --git a/x/scraper.py b/x/scraper.py
138
+ +stealth scrape'
139
+ setup feat/x staging "x/scraper.py" "$SCRAPE_DIFF"
140
+ prep "$ROOT" --thorough true; eq "scraping off without config" '.roster.agents | index("scraper") != null' false
141
+ printf 'review:\n domains:\n scraping: "tiers"\n' > "$WORK/scrape.yaml"
142
+ CFG_OVERRIDE="$WORK/scrape.yaml" prep "$ROOT" --thorough true
143
+ eq "scraping scored on first pass once configured" '.roster.agents | index("scraper") != null' true
144
+ eq "scraping configured flag" .config.scraping_configured true
145
+ eq "absent yq key is empty, not null" '.config.domains.auth' ""
146
+ mkroot "$WORK/root-noroster" no-roster
147
+ setup feat/x staging "README.md" "$DIFF_PLAIN"
148
+ prep "$WORK/root-noroster"
149
+ eq "unresolved roster script falls back to full" .roster.tier full
150
+ eq "unresolved roster still has security" '.roster.agents | index("security") != null' true
151
+
152
+ # --- 5. delta mode ---
153
+ cat > "$WORK/delta-stub.sh" <<STUB
154
+ #!/usr/bin/env bash
155
+ case "\${DELTA_KIND:-DELTA}" in
156
+ DELTA) printf 'DELTA %s..%s\nFULL_ROUNDS=%s\n' "$SHA_A" "$SHA_B" "\${DELTA_ROUNDS:-1}" ;;
157
+ BASE_SYNC_ONLY) printf 'BASE_SYNC_ONLY %s\nFULL_ROUNDS=1\n' "$SHA_A" ;;
158
+ JUNK) echo nonsense ;;
159
+ esac
160
+ STUB
161
+ mkroot "$WORK/root-delta" "" "$WORK/delta-stub.sh" templates
162
+ FIXFILES="services/api/auth.py
163
+ services/api/billing.py"
164
+ setup feat/x staging "$FIXFILES" "$DIFF_AUTH_BILL"
165
+ DELTA_KIND=DELTA DELTA_ROUNDS=1 prep "$WORK/root-delta"
166
+ eq "DELTA mode" .delta.mode DELTA; eq "DELTA from" .delta.from "$SHA_A"; eq "DELTA files" '.delta.files | join(" ")' "services/api/auth.py"
167
+ eq "early rounds keep the 3B roster" '.roster.agents | index("security") != null' true
168
+ eq "DELTA slice paths narrow to the delta" '.slices.security.paths | join(" ")' "services/api/auth.py"
169
+ eq "full_files stays whole" '.full_files | length' 2
170
+ DELTA_KIND=DELTA DELTA_ROUNDS=2 prep "$WORK/root-delta"
171
+ eq "round 3 roster is security + touched domains" '.roster.agents | join(" ")' "security auth api"
172
+ DELTA_KIND=BASE_SYNC_ONLY prep "$WORK/root-delta"
173
+ eq "BASE_SYNC_ONLY mode" .delta.mode BASE_SYNC_ONLY; eq "carried from" .delta.carried_from "$SHA_A"
174
+ eq "BASE_SYNC_ONLY roster empty" '.roster.agents | length' 0; eq "BASE_SYNC_ONLY no slices" '.slices | length' 0
175
+ DELTA_KIND=JUNK prep "$WORK/root-delta"; eq "junk resolver output is FULL" .delta.mode FULL
176
+ mkroot "$WORK/root-nodelta" "" "" templates
177
+ prep "$WORK/root-nodelta"; eq "unresolved delta script is FULL" .delta.mode FULL
178
+ DELTA_KIND=DELTA prep "$WORK/root-delta" --thorough true; eq "thorough never goes DELTA" .delta.mode FULL
179
+ setup feat/x staging "$FIXFILES" "$DIFF_AUTH_BILL" "notasha"
180
+ DELTA_KIND=DELTA prep "$WORK/root-delta"; eq "unvalidated sha is FULL" .delta.mode FULL
181
+
182
+ # --- 6. full-build lane: empty trusted config is BLOCKED, never a silent skip ---
183
+ TS_DIFF='diff --git a/web/src/app/page.tsx b/web/src/app/page.tsx
184
+ +x'
185
+ printf 'project:\n name: Demo\nverification:\n commands:\n typescript:\n typecheck: "tsc"\n' > "$WORK/nobuild.yaml"
186
+ setup milestone/m1 staging "web/src/app/page.tsx" "$TS_DIFF"
187
+ CFG_OVERRIDE="$WORK/nobuild.yaml" prep "$ROOT"
188
+ rc_is "empty ts build on a milestone lane" 3; eq "BLOCKED status" .status BLOCKED
189
+ eq "blocker names typescript.build" '.blockers | join(" ") | test("typescript.build")' true
190
+ eq "config status blocked" .phase2.build_config.status blocked; eq "lane flag" .lane.requires_full_build true
191
+ prep "$ROOT"; rc_is "configured ts build passes" 0; eq "status OK" .status OK
192
+ PY_DIFF='diff --git a/app/x.py b/app/x.py
193
+ +x'
194
+ printf 'project:\n name: Demo\n' > "$WORK/empty.yaml"
195
+ setup milestone/m1 staging "app/x.py" "$PY_DIFF"
196
+ CFG_OVERRIDE="$WORK/empty.yaml" prep "$ROOT"; rc_is "empty python.format on full-build lane" 3
197
+ eq "blocker names python.format" '.blockers | join(" ") | test("python.format")' true
198
+ setup milestone/m1 staging "README.md" "$DIFF_PLAIN"
199
+ CFG_OVERRIDE="$WORK/empty.yaml" prep "$ROOT"; rc_is "docs-only milestone needs no build config" 0
200
+ CFG_OVERRIDE="$WORK/missing.yaml" prep "$ROOT"; rc_is "no forge.yaml at all on a full-build lane" 3
201
+ setup feat/x staging "web/src/app/page.tsx" "$TS_DIFF"
202
+ CFG_OVERRIDE="$WORK/nobuild.yaml" prep "$ROOT"; rc_is "empty ts build on a normal lane is fine" 0
203
+ setup staging main "web/src/app/page.tsx" "$TS_DIFF"
204
+ CFG_OVERRIDE="$WORK/nobuild.yaml" prep "$ROOT"; rc_is "staging to main requires the build" 3
205
+
206
+ # --- 7. templates: none is a fatal setup error ---
207
+ mkroot "$WORK/root-notpl"
208
+ mkdir -p "$WORK/emptyrepo"; printf 'paths:\n root: %s\n' "$WORK/emptyrepo" > "$WORK/emptyroot.yaml"
209
+ setup feat/x staging "README.md" "$DIFF_PLAIN"
210
+ CFG_OVERRIDE="$WORK/emptyroot.yaml" prep "$WORK/root-notpl"
211
+ rc_is "no template source" 3; eq "templates none" .templates.source none
212
+
213
+ # --- 8. issue number extraction and trusted contract ---
214
+ . "$ROOT/scripts/lib/pr-issue-number.sh"
215
+ chk() { local got; got=$(pr_issue_number_of "$2"); if [ "$got" = "$3" ]; then ok; else bad "$1: got '$got', want '$3'"; fi; }
216
+ chk "Closes" "Closes #12" 12
217
+ chk "closes lowercase" "intro
218
+ closes #7 and more" 7
219
+ chk "Fixes" "Fixes #345" 345
220
+ chk "Resolved" "Resolved #9" 9
221
+ chk "no reference yields empty, not the body" "Just a description with no issue reference" ""
222
+ chk "bare #N is not a closing keyword" "see #44" ""
223
+ chk "non numeric" "Closes #abc" ""
224
+ chk "first wins" "Closes #1, Fixes #2" 1
225
+ setup feat/x staging "README.md" "$DIFF_PLAIN"; printf 'Just text, no issue\n' > "$FIX/body.txt"
226
+ prep "$ROOT"; eq "no linked issue" .contract.issue null; eq "no flags" .contract.flags ""
227
+ setup feat/x staging "README.md" "$DIFF_PLAIN"; printf 'Fixes #55\n' > "$FIX/body.txt"
228
+ printf '[{"body":"<!-- FORGE:CONTRACT -->\\n**Risk**: HIGH\\nbilling change","author_association":"OWNER","user":{"login":"o","type":"User"}}]' > "$FIX/comments.json"
229
+ prep "$ROOT"; eq "trusted contract issue" .contract.issue 55
230
+ eq "trusted contract HIGH_RISK" '.contract.flags | test("HIGH_RISK")' true; eq "trusted contract billing flag" '.contract.flags | test("CONTRACT_BILLING")' true
231
+ eq "HIGH_RISK lifts the roster above small" '.roster.tier != "small"' true
232
+ printf '[{"body":"<!-- FORGE:CONTRACT -->\\n**Risk**: HIGH","author_association":"NONE","user":{"login":"evil","type":"User"}}]' > "$FIX/comments.json"
233
+ prep "$ROOT"; eq "planted contract from an untrusted author is ignored" .contract.flags ""; eq "roster stays small" .roster.tier small
234
+ touch "$FIX/comments.fail"
235
+ prep "$ROOT"; eq "a failed contract read widens, never narrows" '.contract.flags | test("HIGH_RISK")' true
236
+ rm -f "$FIX/comments.fail"
237
+ mkroot "$WORK/root-notrust"; rm "$WORK/root-notrust/scripts/trusted-comments.sh"
238
+ prep "$WORK/root-notrust"; eq "unresolved trust script widens, never narrows" '.contract.flags | test("HIGH_RISK")' true
239
+
240
+ # --- 9. restore_ref: git checkout -f of the recorded ref on every exit path ---
241
+ setup feat/x staging "services/api/auth.py" "$DIFF_AUTH_BILL"
242
+ echo dirty > "$REPO_DIR/a.txt"
243
+ prep "$ROOT" --checkout-pr
244
+ rc_is "checkout-pr run" 0
245
+ if [ "$(git -C "$REPO_DIR" symbolic-ref --short HEAD 2>/dev/null)" = "main" ]; then ok; else bad "restore_ref left HEAD detached, want main"; fi
246
+ git -C "$REPO_DIR" checkout -f -q main
247
+ setup feat/x staging "README.md" "$DIFF_PLAIN" "0000000000000000000000000000000000000000"
248
+ prep "$ROOT" --checkout-pr
249
+ rc_is "unknown sha cannot be checked out" 3
250
+ if [ "$(git -C "$REPO_DIR" symbolic-ref --short HEAD 2>/dev/null)" = "main" ]; then ok; else bad "failed checkout moved HEAD"; fi
251
+ if grep -q 'checkout -f "\$ORIG_REF"' "$ROOT/scripts/review-prepare.sh"; then ok; else bad "restore_ref must use git checkout -f"; fi
252
+
253
+ # --- 10. fail closed on gh and bad arguments ---
254
+ setup feat/x staging "README.md" "$DIFF_PLAIN"; : > "$FIX/difffail"
255
+ prep "$ROOT"; rc_is "gh pr diff failure" 3; eq "blocked on gh failure" .status BLOCKED
256
+ setup feat/x staging "README.md" "$DIFF_PLAIN"
257
+ for bad_args in "--pr abc --repo o/r" "--pr 7 --repo noslash" "--pr 7 --repo o/r;x/y" "--pr 7 --repo o/r --bogus" "--repo o/r --pr"; do
258
+ # shellcheck disable=SC2086
259
+ (cd "$REPO_DIR" && FORGEDOCK_HOME="$ROOT" bash "$ROOT/scripts/review-prepare.sh" $bad_args >/dev/null 2>&1); rcx=$?
260
+ if [ "$rcx" = 2 ]; then ok; else bad "args '$bad_args' exit $rcx, want 2"; fi
261
+ done
262
+ # every helper resolves through the trusted resolver, never the cwd
263
+ if grep -q 'forge_resolve' "$ROOT/scripts/review-prepare.sh" && grep -q 'schema_version' "$ROOT/scripts/review-prepare.sh"; then ok; else bad "script must use forge_resolve and document schema_version"; fi
264
+ mkdir -p "$WORK/hostile/scripts"; printf '#!/usr/bin/env bash\necho SELECTED_AGENTS=evil\n' > "$WORK/hostile/scripts/review-roster.sh"
265
+ setup feat/x staging "README.md" "$DIFF_PLAIN"
266
+ CTX=$(cd "$WORK/hostile" && FORGEDOCK_HOME="$ROOT" FORGE_CONFIG="$CFG" bash "$ROOT/scripts/review-prepare.sh" --pr 7 --repo o/r --out-dir "$WORK/out" 2>/dev/null)
267
+ eq "a hostile cwd roster script is never used" '.roster.agents | index("evil") != null' false
268
+
269
+ echo "review-prepare tests: $pass passed, $fail failed"
270
+ [ "$fail" -eq 0 ]
@@ -0,0 +1,172 @@
1
+ #!/usr/bin/env bash
2
+ # review-roster.sh — risk-tiered reviewer roster for /review-pr Phase 3B and /review-pr-staging (forge#3599).
3
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
4
+ # SPDX-License-Identifier: AGPL-3.0-or-later
5
+ #
6
+ # Pure function: flags in, key=value lines out. No network, no git, no eval; every flag value is data.
7
+ # Resolve it through forge_resolve (trusted FORGE_ROOT / plugin cache), never from the repo under review.
8
+ #
9
+ # Usage: review-roster.sh [--scores "AUTH=n BILLING=n CONCURRENCY=n DATABASE=n INFRA=n SECURITY=n SCRAPING=n FRONTEND=n API=n"]
10
+ # [--changed-lines N] [--changed-files N] [--files-file PATH|-] [--fix-pr true|false]
11
+ # [--source-severity SEV] [--source-domain DOMAIN] [--contract-flags "HIGH_RISK CONTRACT_AUTH ..."]
12
+ # [--churn true|false] [--danger-zone true|false] [--thorough true|false] [--milestone true|false]
13
+ # [--scraping-configured true|false] [--database-container true|false] [--spec-cli true|false]
14
+ # Output (stdout): SELECTED_AGENTS=<canonical marker domains, space separated> TIER=<tier> REASON=<text>
15
+ # Exit: 0 always for a parseable call; 2 on an unknown flag (callers fall back to the full union, never a narrower roster).
16
+ #
17
+ # Tiers, evaluated in this order:
18
+ # full THOROUGH or milestone->staging: Security plus every domain with a score > 0.
19
+ # fix-pr the PR closes a `review-finding` issue: Security, plus the source finding's domain when it was HIGH+.
20
+ # small <= 60 changed lines, or one file of <= 150 lines: Security only.
21
+ # medium 61-150 lines: Security plus the score-escalation table (no top-2 baseline).
22
+ # standard > 150 lines, or an unknown size (fail toward more agents): top-2 domains by score plus the table.
23
+ # Path triggers escalate in EVERY tier except full (which already runs the union): CONTRACT HIGH_RISK, cross-critical
24
+ # pairs, churn hot-spot, danger zone, bin/engine* (Concurrency + spec-cli), bin/ (spec-cli), auth/session/trust-filter
25
+ # files (Auth), CI/docker/deploy/infra files (Infra), migration/.sql files (Database), SDK/OpenAPI files (API).
26
+ # Auth and Infra are path-gated: a diff-text score alone never selects them. Security is a hard floor in every tier.
27
+ # spec-cli is added only with --spec-cli true (the persona file exists in the repo under review); in the standard tier it
28
+ # also covers commands/**. Bash 3.2 portable (no mapfile, associative arrays, grep -P).
29
+ set -u
30
+
31
+ S_AUTH=0; S_BILLING=0; S_CONCURRENCY=0; S_DATABASE=0; S_INFRA=0; S_SCRAPING=0; S_FRONTEND=0; S_API=0
32
+ LINES=""; NFILES=""; FILES_FILE=""; FIX_PR=false; SRC_SEV=""; SRC_DOM=""; CFLAGS=""
33
+ CHURN=false; DANGER=false; THOROUGH=false; MILESTONE=false; SCRAPING_ON=false; DB_CONTAINER=false; SPEC_CLI=false
34
+
35
+ num() { case "${1:-}" in ''|*[!0-9]*) printf '0' ;; *) printf '%s' "$1" ;; esac; }
36
+ agent_domain() { local l; l=$(printf '%s' "${1}" | tr '[:upper:]' '[:lower:]'); case "$l" in infrastructure) l=infra ;; scraping) l=scraper ;; web) l=frontend ;; esac; printf '%s' "$l"; }
37
+ add_agent() {
38
+ local AGENT CUR HAVE=""
39
+ AGENT=$(agent_domain "${1}")
40
+ for CUR in $SELECTED_AGENTS; do
41
+ [ "$(agent_domain "$CUR")" = "$AGENT" ] && HAVE=1
42
+ done
43
+ [ -n "$HAVE" ] || SELECTED_AGENTS="$SELECTED_AGENTS $AGENT"
44
+ }
45
+
46
+ while [ $# -gt 0 ]; do
47
+ case "$1" in
48
+ --scores)
49
+ for kv in ${2:-}; do
50
+ v=$(num "${kv#*=}")
51
+ case "${kv%%=*}" in
52
+ AUTH) S_AUTH=$v ;; BILLING) S_BILLING=$v ;; CONCURRENCY) S_CONCURRENCY=$v ;; DATABASE) S_DATABASE=$v ;;
53
+ INFRA) S_INFRA=$v ;; SCRAPING) S_SCRAPING=$v ;; FRONTEND) S_FRONTEND=$v ;; API) S_API=$v ;;
54
+ esac
55
+ done; shift 2 ;;
56
+ --changed-lines) LINES="${2:-}"; shift 2 ;;
57
+ --changed-files) NFILES="${2:-}"; shift 2 ;;
58
+ --files-file) FILES_FILE="${2:-}"; shift 2 ;;
59
+ --fix-pr) FIX_PR="${2:-false}"; shift 2 ;;
60
+ --source-severity) SRC_SEV="${2:-}"; shift 2 ;;
61
+ --source-domain) SRC_DOM="${2:-}"; shift 2 ;;
62
+ --contract-flags) CFLAGS="${2:-}"; shift 2 ;;
63
+ --churn) CHURN="${2:-false}"; shift 2 ;;
64
+ --danger-zone) DANGER="${2:-false}"; shift 2 ;;
65
+ --thorough) THOROUGH="${2:-false}"; shift 2 ;;
66
+ --milestone) MILESTONE="${2:-false}"; shift 2 ;;
67
+ --scraping-configured) SCRAPING_ON="${2:-false}"; shift 2 ;;
68
+ --database-container) DB_CONTAINER="${2:-false}"; shift 2 ;;
69
+ --spec-cli) SPEC_CLI="${2:-false}"; shift 2 ;;
70
+ *) echo "review-roster: unknown argument: $1" >&2; exit 2 ;;
71
+ esac
72
+ done
73
+
74
+ FILES=""
75
+ if [ "$FILES_FILE" = "-" ]; then FILES=$(cat)
76
+ elif [ -n "$FILES_FILE" ] && [ -f "$FILES_FILE" ]; then FILES=$(cat "$FILES_FILE"); fi
77
+ [ -n "$NFILES" ] || NFILES=$(printf '%s\n' "$FILES" | grep -c . || true)
78
+ case "$LINES" in ''|*[!0-9]*) LINES_KNOWN=false; LINES=0 ;; *) LINES_KNOWN=true ;; esac
79
+ case "$NFILES" in ''|*[!0-9]*) NFILES=0 ;; esac
80
+
81
+ SELECTED_AGENTS="security"
82
+ TIER=""; REASON=""
83
+
84
+ # Domains that may be picked by score alone. Auth and Infra are path-gated, Scraping is opt-in.
85
+ top_domains() { # $1 = how many
86
+ local d out="" list=""
87
+ list="$S_BILLING:BILLING $S_CONCURRENCY:CONCURRENCY $S_DATABASE:DATABASE $S_FRONTEND:FRONTEND $S_API:API"
88
+ [ "$SCRAPING_ON" = "true" ] && list="$list $S_SCRAPING:SCRAPING"
89
+ for d in $(printf '%s\n' "$list" | tr ' ' '\n' | sort -t: -k1,1 -rn | head -n "$1"); do
90
+ [ "${d%%:*}" -gt 0 ] && out="$out ${d#*:}"
91
+ done
92
+ printf '%s' "$out"
93
+ }
94
+
95
+ if [ "$THOROUGH" = "true" ] || [ "$MILESTONE" = "true" ]; then
96
+ TIER=full; REASON="thorough or milestone-to-staging: full union"
97
+ [ "$S_AUTH" -gt 0 ] && add_agent Auth
98
+ [ "$S_BILLING" -gt 0 ] && { add_agent Billing; add_agent Concurrency; }
99
+ [ "$S_DATABASE" -gt 0 ] && add_agent Database
100
+ [ "$S_INFRA" -gt 0 ] && add_agent Infra
101
+ [ "$S_SCRAPING" -gt 0 ] && [ "$SCRAPING_ON" = "true" ] && add_agent Scraping
102
+ [ "$S_FRONTEND" -gt 0 ] && add_agent Frontend
103
+ [ "$S_API" -gt 0 ] && add_agent API
104
+ printf 'SELECTED_AGENTS=%s\nTIER=%s\nREASON=%s\n' "${SELECTED_AGENTS# }" "$TIER" "$REASON"
105
+ exit 0
106
+ fi
107
+
108
+ HIGH_RISK=false
109
+ case " $CFLAGS " in *" HIGH_RISK "*) HIGH_RISK=true ;; esac
110
+
111
+ if [ "$FIX_PR" = "true" ]; then
112
+ TIER=fix-pr; REASON="fix PR for a review-finding issue"
113
+ elif [ "$LINES_KNOWN" = "true" ] && { [ "$LINES" -le 60 ] || { [ "$NFILES" -eq 1 ] && [ "$LINES" -le 150 ]; }; }; then
114
+ TIER=small; REASON="lines=$LINES files=$NFILES"
115
+ elif [ "$LINES_KNOWN" = "true" ] && [ "$LINES" -le 150 ]; then
116
+ TIER=medium; REASON="lines=$LINES"
117
+ else
118
+ TIER=standard
119
+ if [ "$LINES_KNOWN" = "true" ]; then REASON="lines=$LINES"; else REASON="size unknown"; fi
120
+ fi
121
+ # CONTRACT HIGH_RISK is never narrowed: a small, medium or fix-PR roster is lifted to the standard roster.
122
+ BASE_TIER="$TIER"
123
+ if [ "$HIGH_RISK" = "true" ] && [ "$TIER" != "standard" ]; then TIER=standard; REASON="$REASON; CONTRACT HIGH_RISK lifts $BASE_TIER to standard"; fi
124
+
125
+ TRIG=""
126
+ trig() { case " $TRIG " in *" $1 "*) ;; *) TRIG="$TRIG $1" ;; esac; }
127
+
128
+ # Tier baseline
129
+ case "$TIER" in
130
+ fix-pr)
131
+ case "$(printf '%s' "$SRC_SEV" | tr '[:lower:]' '[:upper:]')" in
132
+ HIGH|CRITICAL|BLOCKER)
133
+ if [ -n "$SRC_DOM" ]; then add_agent "$SRC_DOM"; else for d in $(top_domains 1); do add_agent "$d"; done; fi
134
+ REASON="$REASON; source finding ${SRC_SEV} adds its domain" ;;
135
+ esac ;;
136
+ standard) for d in $(top_domains 2); do add_agent "$d"; done ;;
137
+ esac
138
+
139
+ # Score escalation table (medium and standard only; diff-text signals, so never in the small or fix-pr tiers)
140
+ if [ "$TIER" = "medium" ] || [ "$TIER" = "standard" ]; then
141
+ [ "$S_BILLING" -ge 3 ] && { add_agent Billing; add_agent Concurrency; trig billing-score; }
142
+ [ "$S_DATABASE" -ge 3 ] && { add_agent Database; trig database-score; }
143
+ [ "$S_SCRAPING" -ge 3 ] && [ "$SCRAPING_ON" = "true" ] && { add_agent Scraping; trig scraping; }
144
+ [ "$S_INFRA" -ge 3 ] && [ "$DB_CONTAINER" = "true" ] && { add_agent Infra; trig db-container; }
145
+ fi
146
+
147
+ # Path and contract triggers (every tier)
148
+ if [ "$HIGH_RISK" = "true" ]; then
149
+ trig contract-high-risk
150
+ case " $CFLAGS " in *" CONTRACT_AUTH "*) add_agent Auth ;; esac
151
+ case " $CFLAGS " in *" CONTRACT_BILLING "*) add_agent Billing; add_agent Concurrency ;; esac
152
+ case " $CFLAGS " in *" CONTRACT_DATABASE "*) add_agent Database ;; esac
153
+ fi
154
+ { [ "$S_AUTH" -ge 2 ] && [ "$S_BILLING" -ge 2 ]; } && { add_agent Auth; add_agent Billing; add_agent Concurrency; trig cross-critical; }
155
+ { [ "$S_AUTH" -ge 2 ] && [ "$S_DATABASE" -ge 3 ]; } && { add_agent Auth; add_agent Database; trig cross-critical; }
156
+ { [ "$S_BILLING" -ge 2 ] && [ "$S_CONCURRENCY" -ge 2 ]; } && { add_agent Billing; add_agent Concurrency; add_agent Database; trig cross-critical; }
157
+ if [ "$CHURN" = "true" ] || [ "$DANGER" = "true" ]; then
158
+ for d in $(top_domains 1); do add_agent "$d"; trig churn-or-danger-zone; done
159
+ fi
160
+ hit() { printf '%s\n' "$FILES" | grep -qiE "$1"; }
161
+ hit '(^|[/_.-])(auth|authn|authz|session|sessions|jwt|oauth|login|logout|password|trust[-_]?filter|trusted[-_a-z]*)([/_.-]|$)' && { add_agent Auth; trig auth-path; }
162
+ hit '^\.github/|(^|/)Dockerfile|docker-compose|(^|/)(deploy|infra|traefik|nginx|k8s|helm|terraform)/|\.tf$' && { add_agent Infra; trig infra-path; }
163
+ hit 'migration|\.sql$' && { add_agent Database; trig migration-path; }
164
+ hit '^sdk/|openapi.*\.json$|openapi-versions/' && { add_agent API; trig sdk-path; }
165
+ hit '^bin/engine' && { add_agent Concurrency; trig bin-engine; }
166
+ if [ "$SPEC_CLI" = "true" ]; then
167
+ hit '^bin/' && { add_agent spec-cli; trig bin; }
168
+ [ "$TIER" = "standard" ] && hit '^commands/' && { add_agent spec-cli; trig commands; }
169
+ fi
170
+
171
+ [ -n "$TRIG" ] && REASON="$REASON; triggers:$TRIG"
172
+ printf 'SELECTED_AGENTS=%s\nTIER=%s\nREASON=%s\n' "${SELECTED_AGENTS# }" "$TIER" "$REASON"
@@ -0,0 +1,163 @@
1
+ #!/usr/bin/env bash
2
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
3
+ # SPDX-License-Identifier: AGPL-3.0-or-later
4
+ #
5
+ # Tests for scripts/review-roster.sh (risk-tiered reviewer roster, forge#3599): the tier cases, the path triggers that
6
+ # escalate in every tier, and the regression table of the 13 review-driven holds. Run: bash scripts/review-roster.test.sh
7
+ # (bash 3.2 compatible)
8
+ #
9
+ # Hold table fixture: the changed-line count, file count and file list of each PR, the PR's linked-issue lineage, and
10
+ # the reviewers whose posted findings were CRITICAL or BLOCKER on it (from the FORGE:REVIEW-AGENT comments). The
11
+ # regression gate: at least one catching reviewer stays selected on every hold, and the sole catcher always does.
12
+
13
+ set -u
14
+ DIR=$(cd "$(dirname "$0")" && pwd)
15
+ SCRIPT="$DIR/review-roster.sh"
16
+ pass=0; fail=0
17
+ ok() { pass=$((pass+1)); }
18
+ bad() { fail=$((fail+1)); echo "FAIL: $1"; }
19
+ WORK=$(mktemp -d "${TMPDIR:-/tmp}/review-roster.XXXXXX")
20
+ trap 'rm -rf "$WORK"' EXIT
21
+
22
+ SEL=""; TIER=""; REASON=""
23
+ # run <files, newline separated> <review-roster flags...>
24
+ run() {
25
+ printf '%s\n' "$1" > "$WORK/files"; shift
26
+ out=$(bash "$SCRIPT" --files-file "$WORK/files" "$@") || { bad "script exited non-zero for: $*"; return 1; }
27
+ SEL=$(printf '%s\n' "$out" | sed -n 's/^SELECTED_AGENTS=//p')
28
+ TIER=$(printf '%s\n' "$out" | sed -n 's/^TIER=//p')
29
+ REASON=$(printf '%s\n' "$out" | sed -n 's/^REASON=//p')
30
+ }
31
+ has() { case " $SEL " in *" $1 "*) return 0 ;; *) return 1 ;; esac; }
32
+ expect_sel() { if [ "$SEL" = "$2" ]; then ok; else bad "$1: roster '$SEL', want '$2' (tier $TIER; $REASON)"; fi; }
33
+ expect_has() { if has "$2"; then ok; else bad "$1: '$2' missing from '$SEL' (tier $TIER; $REASON)"; fi; }
34
+ expect_not() { if has "$2"; then bad "$1: '$2' should not be in '$SEL' (tier $TIER)"; else ok; fi; }
35
+ expect_tier() { if [ "$TIER" = "$2" ]; then ok; else bad "$1: tier '$TIER', want '$2'"; fi; }
36
+
37
+ SC_QUIET="AUTH=0 BILLING=0 CONCURRENCY=0 DATABASE=0 INFRA=0 SECURITY=1 SCRAPING=0 FRONTEND=0 API=0"
38
+ SC_NOISY="AUTH=3 BILLING=1 CONCURRENCY=0 DATABASE=1 INFRA=3 SECURITY=4 SCRAPING=0 FRONTEND=2 API=2"
39
+ SC_BUSY="AUTH=3 BILLING=3 CONCURRENCY=3 DATABASE=3 INFRA=3 SECURITY=1 SCRAPING=0 FRONTEND=2 API=2"
40
+
41
+ # Case 1: a small diff gives Security only, even when diff-text scores are loud
42
+ run "commands/foo.md" --scores "$SC_NOISY" --changed-lines 40 --changed-files 1
43
+ expect_sel "small diff" "security"; expect_tier "small diff" small
44
+ run "scripts/a.sh
45
+ scripts/b.sh" --scores "$SC_NOISY" --changed-lines 60 --changed-files 2
46
+ expect_sel "60-line boundary" "security"
47
+ # a single file is small up to 150 lines, not beyond
48
+ run "scripts/a.sh" --scores "$SC_QUIET" --changed-lines 120 --changed-files 1
49
+ expect_tier "single file" small
50
+ run "scripts/a.sh" --scores "$SC_QUIET" --changed-lines 400 --changed-files 1
51
+ expect_tier "single large file" standard
52
+
53
+ # Case 2: a small diff touching bin/engine/** adds concurrency and spec-cli
54
+ run "bin/engine/phases.mjs" --scores "$SC_QUIET" --changed-lines 30 --changed-files 1 --spec-cli true
55
+ expect_has "small bin/engine" concurrency; expect_has "small bin/engine" spec-cli; expect_has "small bin/engine" security; expect_tier "small bin/engine" small
56
+ run "bin/engine/phases.mjs" --scores "$SC_QUIET" --changed-lines 30 --changed-files 1 --spec-cli false
57
+ expect_has "consumer repo bin/engine" concurrency; expect_not "consumer repo bin/engine" spec-cli
58
+
59
+ # Case 3: a fix PR gives Security only
60
+ run "commands/orchestrate/phase-4-execution.md
61
+ commands/work-on.md" --scores "$SC_NOISY" --changed-lines 200 --changed-files 2 --fix-pr true --source-severity MEDIUM
62
+ expect_sel "fix PR" "security"; expect_tier "fix PR" fix-pr
63
+
64
+ # Case 4: a fix PR whose source finding was HIGH adds the domain agent
65
+ run "commands/work-on.md" --scores "AUTH=0 BILLING=0 CONCURRENCY=2 DATABASE=0 INFRA=0 FRONTEND=0 API=0" --changed-lines 40 --fix-pr true --source-severity HIGH --source-domain Concurrency
66
+ expect_sel "fix PR, HIGH source finding" "security concurrency"
67
+ run "commands/work-on.md" --scores "AUTH=0 BILLING=0 CONCURRENCY=2 DATABASE=0 INFRA=0 FRONTEND=0 API=0" --changed-lines 40 --fix-pr true --source-severity critical
68
+ expect_sel "fix PR, CRITICAL source, top domain" "security concurrency"
69
+
70
+ # Case 5: CONTRACT HIGH_RISK escalates in every tier
71
+ for tier_args in "--changed-lines 30 --changed-files 1" "--changed-lines 100" "--changed-lines 400" "--changed-lines 30 --fix-pr true"; do
72
+ # shellcheck disable=SC2086
73
+ run "svc/x.py" --scores "$SC_QUIET" $tier_args --contract-flags "HIGH_RISK CONTRACT_AUTH CONTRACT_BILLING"
74
+ expect_has "HIGH_RISK ($tier_args)" auth; expect_has "HIGH_RISK ($tier_args)" billing; expect_has "HIGH_RISK ($tier_args)" concurrency
75
+ done
76
+ run "svc/x.py" --scores "$SC_QUIET" --changed-lines 30 --contract-flags "CONTRACT_AUTH"
77
+ expect_not "CONTRACT_AUTH without HIGH_RISK" auth
78
+
79
+ # Case 6: --thorough and milestone give the full union
80
+ run "svc/x.py" --scores "$SC_BUSY" --changed-lines 10 --thorough true
81
+ expect_tier "thorough" full
82
+ for d in security auth billing concurrency database infra frontend api; do expect_has "thorough" "$d"; done
83
+ expect_not "thorough, scraping not configured" scraper
84
+ run "svc/x.py" --scores "$SC_BUSY SCRAPING=3" --changed-lines 10 --milestone true --scraping-configured true
85
+ expect_tier "milestone" full; expect_has "milestone" scraper
86
+
87
+ # Path gates: auth and infra only by path; the other path triggers fire in the small tier
88
+ run "svc/app/routers/session.py" --scores "$SC_QUIET" --changed-lines 20 --changed-files 1
89
+ expect_has "auth path" auth
90
+ run "scripts/trusted-comments.sh" --scores "$SC_QUIET" --changed-lines 20 --changed-files 1
91
+ expect_has "trust-filter path" auth
92
+ run "commands/review-pr.md" --scores "AUTH=3 INFRA=3 DATABASE=0 BILLING=0 CONCURRENCY=0" --changed-lines 120
93
+ expect_not "auth from prose score alone" auth; expect_not "infra from prose score alone" infra
94
+ run ".github/workflows/ci.yml" --scores "$SC_QUIET" --changed-lines 20 --changed-files 1
95
+ expect_has "infra path" infra
96
+ run "docker-compose.prod.yml" --scores "$SC_QUIET" --changed-lines 20 --changed-files 1
97
+ expect_has "docker path" infra
98
+ run "db/migrations/0042_add.sql" --scores "$SC_QUIET" --changed-lines 20 --changed-files 1
99
+ expect_has "migration path" database
100
+ run "sdk/node/src/index.ts" --scores "$SC_QUIET" --changed-lines 20 --changed-files 1
101
+ expect_has "sdk path" api
102
+ run "svc/x.py" --scores "AUTH=2 BILLING=2" --changed-lines 20 --changed-files 1
103
+ expect_has "cross-critical" auth; expect_has "cross-critical" billing
104
+ run "svc/x.py" --scores "BILLING=3 CONCURRENCY=3" --changed-lines 20 --changed-files 1 --churn true
105
+ expect_has "churn hot-spot top domain" billing
106
+ run "svc/x.py" --scores "BILLING=3" --changed-lines 20 --changed-files 2
107
+ expect_not "billing score in small tier" billing
108
+ run "svc/x.py
109
+ svc/y.py" --scores "BILLING=3" --changed-lines 100
110
+ expect_has "billing score in medium tier" billing; expect_has "billing score in medium tier" concurrency
111
+
112
+ # Tier shape: standard keeps the top-2 baseline, medium does not
113
+ run "web/src/app/a.tsx
114
+ web/src/b.tsx" --scores "FRONTEND=3 API=2 BILLING=1 DATABASE=1" --changed-lines 300
115
+ expect_tier "standard" standard; expect_has "standard" frontend; expect_has "standard" api; expect_not "standard top-2 only" billing
116
+ run "web/src/app/a.tsx
117
+ web/src/b.tsx" --scores "FRONTEND=3 API=2" --changed-lines 100
118
+ expect_sel "medium has no baseline" "security"
119
+ run "svc/x.py" --scores "FRONTEND=3" --changed-lines ""
120
+ expect_tier "unknown size" standard; expect_has "unknown size" frontend
121
+ run "commands/x.md
122
+ commands/y.md" --scores "$SC_QUIET" --changed-lines 300 --spec-cli true
123
+ expect_has "standard commands/** adds spec-cli" spec-cli
124
+ run "commands/x.md
125
+ commands/y.md" --scores "$SC_QUIET" --changed-lines 100 --spec-cli true
126
+ expect_not "medium commands/** has no spec-cli" spec-cli
127
+
128
+ # Roster output is canonical and duplicate free
129
+ run "bin/engine/a.mjs
130
+ .github/workflows/a.yml" --scores "$SC_BUSY" --changed-lines 300 --spec-cli true
131
+ n=$(printf '%s' "$SEL" | tr ' ' '\n' | grep -c .); u=$(printf '%s' "$SEL" | tr ' ' '\n' | sort -u | grep -c .)
132
+ if [ "$n" -eq "$u" ]; then ok; else bad "duplicate roster entries in '$SEL'"; fi
133
+ case "$SEL" in *[A-Z]*) bad "non-canonical roster '$SEL'" ;; *) ok ;; esac
134
+
135
+ # Unknown flag fails (callers fall back to the full union)
136
+ if bash "$SCRIPT" --bogus 1 >/dev/null 2>&1; then bad "unknown flag accepted"; else ok; fi
137
+
138
+ # Regression table: the 13 review-driven holds. Columns: PR | lines | files | fix-PR | catchers (CRITICAL/BLOCKER) | sole catcher | file list
139
+ hold() {
140
+ pr="$1"; lines="$2"; nf="$3"; fixpr="$4"; catchers="$5"; sole="$6"; files="$7"
141
+ run "$(printf '%s' "$files" | tr ' ' '\n')" --scores "$SC_QUIET" --changed-lines "$lines" --changed-files "$nf" --fix-pr "$fixpr" --spec-cli true
142
+ found=""
143
+ for c in $catchers; do has "$c" && found=1; done
144
+ if [ -n "$found" ]; then ok; else bad "hold #$pr: none of [$catchers] selected: '$SEL' (tier $TIER)"; fi
145
+ if [ -n "$sole" ]; then if has "$sole"; then ok; else bad "hold #$pr: sole catcher '$sole' not selected: '$SEL'"; fi; fi
146
+ if has security; then ok; else bad "hold #$pr: security missing"; fi
147
+ }
148
+ hold 3426 126 3 true "security concurrency" "" "commands/orchestrate/phase-4-execution.md commands/work-on.md commands/work-on/remediate.md"
149
+ hold 3438 227 8 false "spec-cli" "spec-cli" "commands/review-pr-agents/protocols.md commands/review-pr.md commands/work-on.md commands/work-on/remediate.md commands/work-on/review.md docs/WORK-ON-RUNTIME.md docs/spec/review-protocol.md scripts/forge-root.test.sh"
150
+ hold 3461 454 12 false "security spec-cli" "" "commands/work-on.md commands/work-on/build.md commands/work-on/build/implement.md commands/work-on/close.md commands/work-on/decompose.md docs/CONFIG.md docs/spec/forge-protocol-v1.md forge.yaml.example packages/protocol/src/types.js scripts/check-spec-markers.sh scripts/diff-size.sh scripts/diff-size.test.sh"
151
+ hold 3464 53 2 true "security concurrency" "" "commands/orchestrate/phase-4-execution.md commands/work-on.md"
152
+ hold 3480 56 3 true "security" "" "commands/orchestrate/phase-4-execution.md commands/work-on.md scripts/check-spec-markers.sh"
153
+ hold 3490 220 3 false "security" "" "commands/review-pr-staging.md commands/review-pr.md scripts/review-pr-arguments.test.sh"
154
+ hold 3500 263 5 false "security" "" ".github/workflows/check-commands.yml commands/work-on/build/validate.md commands/work-on/review.md scripts/check-branch-ancestry.sh scripts/check-branch-ancestry.test.sh"
155
+ hold 3502 813 11 false "concurrency" "concurrency" "bin/engine-cli.mjs bin/engine.mjs bin/engine/phases-args.test.mjs bin/engine/phases.mjs bin/engine/runlog.mjs bin/tests/engine-crash.test.mjs bin/tests/engine-phases.test.mjs bin/tests/engine-runlog.test.mjs bin/tests/engine.test.mjs docs/WORK-ON-RUNTIME.md packages/protocol/src/phases.js"
156
+ hold 3509 274 6 false "security spec-cli" "" "bin/engine.mjs bin/engine/phases.mjs bin/tests/engine-crash.test.mjs bin/tests/engine-phases.test.mjs bin/tests/engine-runlog.test.mjs bin/tests/engine.test.mjs"
157
+ hold 3513 205 7 false "security concurrency infra spec-cli" "" "commands/orchestrate/phase-4-execution.md commands/review-pr.md commands/work-on.md commands/work-on/remediate.md commands/work-on/review.md docs/WORK-ON-RUNTIME.md scripts/check-spec-markers.sh"
158
+ hold 3520 4977 9 false "concurrency" "concurrency" "bin/engine-cli.mjs bin/engine.mjs bin/engine/runlog.mjs bin/forgedock.mjs bin/runner.mjs bin/tests/engine.test.mjs bin/tests/runner.test.mjs commands/orchestrate/phase-4-execution.md devdocs/modules/runner.md"
159
+ hold 3524 156 5 false "spec-cli" "spec-cli" "bin/engine.mjs bin/engine/phases.mjs bin/runner.mjs bin/tests/engine-phases.test.mjs bin/tests/engine.test.mjs"
160
+ hold 3555 137 8 false "security" "" "bin/engine/phases.mjs bin/tests/engine-phases.test.mjs commands/work-on/build.md commands/work-on/build/implement.md commands/work-on/decompose.md commands/work-on/investigate.md packages/protocol/CHANGELOG.md packages/protocol/src/phases.js"
161
+
162
+ echo "review-roster: $pass passed, $fail failed"
163
+ [ "$fail" -eq 0 ]
@@ -19,6 +19,9 @@ set -u
19
19
  SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
20
20
  REPO_ROOT=$(cd "$SCRIPT_DIR/.." && pwd)
21
21
  cd "$REPO_ROOT" || exit 1
22
+ # Gate scripts emit best-effort telemetry (forge#3834); suites run with it off so stubbed gh calls never buffer into ~/.forge.
23
+ # scripts/telemetry.test.sh sets its own environment.
24
+ export FORGE_TELEMETRY=off
22
25
 
23
26
  TIMEOUT_SECS="${SHELL_TEST_TIMEOUT:-300}"
24
27
  # WIRE:PROVEN — manual: SHELL_TEST_TIMEOUT=abc and SHELL_TEST_TIMEOUT=0 both print the error and exit 2