forgedock 1.17.0 → 1.19.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 (143) hide show
  1. package/bin/engine/outcome-codes.mjs +37 -2
  2. package/bin/engine/phases.mjs +207 -106
  3. package/bin/engine/runlog.mjs +17 -2
  4. package/bin/engine/telemetry-seam.mjs +3 -0
  5. package/bin/engine/telemetry.test.mjs +3 -2
  6. package/bin/engine-cli.mjs +37 -2
  7. package/bin/engine.mjs +221 -15
  8. package/bin/forgedock.mjs +1 -1
  9. package/bin/hooks/pre-tool-use.mjs +65 -32
  10. package/bin/runner.mjs +125 -6
  11. package/bin/stats/aggregate.mjs +7 -3
  12. package/bin/stats/cache.mjs +7 -3
  13. package/bin/stats/github-source.mjs +203 -24
  14. package/bin/stats/index.mjs +46 -9
  15. package/bin/stats/render.mjs +1 -1
  16. package/bin/stats/stats.test.mjs +264 -15
  17. package/commands/cleanup.md +10 -3
  18. package/commands/deploy-pr.md +64 -0
  19. package/commands/fix-ci.md +20 -6
  20. package/commands/milestone.md +34 -17
  21. package/commands/orchestrate/phase-4-execution.md +11 -4
  22. package/commands/review-pr-staging.md +145 -522
  23. package/commands/review-pr.md +496 -3124
  24. package/commands/test-gate.md +25 -10
  25. package/commands/work-on/build/architect.md +66 -1
  26. package/commands/work-on/build/implement.md +21 -1
  27. package/commands/work-on/build/validate.md +322 -28
  28. package/commands/work-on/build.md +173 -95
  29. package/commands/work-on/close.md +50 -15
  30. package/commands/work-on/decompose.md +72 -34
  31. package/commands/work-on/investigate.md +16 -12
  32. package/commands/work-on/remediate.md +58 -25
  33. package/commands/work-on/review.md +36 -21
  34. package/commands/work-on.md +1 -2
  35. package/docs/CONFIG.md +132 -26
  36. package/docs/OUTCOME-CODES.md +12 -3
  37. package/docs/RELEASE-NOTES.md +12 -0
  38. package/docs/STATS.md +13 -5
  39. package/docs/TELEMETRY.md +7 -4
  40. package/docs/WORK-ON-RUNTIME.md +24 -0
  41. package/docs/spec/forge-protocol-v1.md +2 -2
  42. package/package.json +2 -2
  43. package/packages/protocol/src/phases.js +5 -5
  44. package/packages/protocol/src/types.js +1 -1
  45. package/scripts/acceptance-red-green.sh +90 -0
  46. package/scripts/acceptance-red-green.test.sh +74 -0
  47. package/scripts/cap-findings.sh +1 -1
  48. package/scripts/cap-findings.test.sh +3 -1
  49. package/scripts/check-contract-scope.test.sh +2 -0
  50. package/scripts/check-edge-cases.sh +143 -0
  51. package/scripts/check-edge-cases.test.sh +175 -0
  52. package/scripts/check-spec-bash.sh +5 -1
  53. package/scripts/check-spec-bash.test.sh +20 -0
  54. package/scripts/check-spec-markers.sh +2 -0
  55. package/scripts/check-test-tmp-cleanup.sh +56 -0
  56. package/scripts/check-test-tmp-cleanup.test.sh +69 -0
  57. package/scripts/ci-local/adapters/azure-pipelines.mjs +113 -0
  58. package/scripts/ci-local/adapters/bitbucket.mjs +64 -0
  59. package/scripts/ci-local/adapters/buildkite.mjs +58 -0
  60. package/scripts/ci-local/adapters/circleci.mjs +101 -0
  61. package/scripts/ci-local/adapters/common.mjs +115 -0
  62. package/scripts/ci-local/adapters/explicit.mjs +21 -0
  63. package/scripts/ci-local/adapters/github-actions.mjs +349 -0
  64. package/scripts/ci-local/adapters/gitlab-ci.mjs +184 -0
  65. package/scripts/ci-local/adapters/portability.mjs +104 -0
  66. package/scripts/ci-local/adapters/task-runners.mjs +67 -0
  67. package/scripts/ci-local/cli.mjs +50 -0
  68. package/scripts/ci-local/lib.mjs +371 -0
  69. package/scripts/ci-local/plan.mjs +186 -0
  70. package/scripts/ci-local/run.mjs +345 -0
  71. package/scripts/ci-local/scoping.mjs +508 -0
  72. package/scripts/ci-local.sh +97 -0
  73. package/scripts/ci-local.test.sh +919 -0
  74. package/scripts/classify-finding.sh +32 -6
  75. package/scripts/classify-finding.test.sh +23 -0
  76. package/scripts/classify-test-run.sh +69 -0
  77. package/scripts/classify-test-run.test.sh +90 -0
  78. package/scripts/close-parent-c3.test.sh +71 -0
  79. package/scripts/consumer-sweep.sh +569 -0
  80. package/scripts/consumer-sweep.test.sh +357 -0
  81. package/scripts/detect-append-registries.sh +106 -0
  82. package/scripts/detect-append-registries.test.sh +64 -0
  83. package/scripts/diff-size.sh +93 -4
  84. package/scripts/diff-size.test.sh +59 -2
  85. package/scripts/fixtures/ci-local/azure/azure-pipelines.yml +11 -0
  86. package/scripts/fixtures/ci-local/bitbucket/bitbucket-pipelines.yml +12 -0
  87. package/scripts/fixtures/ci-local/buildkite/.buildkite/pipeline.yml +10 -0
  88. package/scripts/fixtures/ci-local/circleci/.circleci/config.yml +29 -0
  89. package/scripts/fixtures/ci-local/gha-basic/.github/workflows/ci.yml +59 -0
  90. package/scripts/fixtures/ci-local/gha-basic/.github/workflows/docs.yml +10 -0
  91. package/scripts/fixtures/ci-local/gha-basic/.github/workflows/nightly.yml +10 -0
  92. package/scripts/fixtures/ci-local/gha-basic/docs/index.md +1 -0
  93. package/scripts/fixtures/ci-local/gha-basic/src/greet.sh +2 -0
  94. package/scripts/fixtures/ci-local/gha-basic/tests/greet.test.sh +3 -0
  95. package/scripts/fixtures/ci-local/gha-dorny/.github/workflows/ci.yml +51 -0
  96. package/scripts/fixtures/ci-local/gha-dorny/backend/app.py +1 -0
  97. package/scripts/fixtures/ci-local/gha-dorny/docs/a.md +1 -0
  98. package/scripts/fixtures/ci-local/gha-dorny/frontend/app.js +1 -0
  99. package/scripts/fixtures/ci-local/gha-run/.github/workflows/ci.yml +39 -0
  100. package/scripts/fixtures/ci-local/gha-run/src/x.txt +1 -0
  101. package/scripts/fixtures/ci-local/gitlab/.gitlab-ci.yml +43 -0
  102. package/scripts/fixtures/ci-local/gitlab/docs/d.md +1 -0
  103. package/scripts/fixtures/ci-local/gitlab/src/a.txt +1 -0
  104. package/scripts/fixtures/ci-local/monorepo/.github/workflows/ci.yml +13 -0
  105. package/scripts/fixtures/ci-local/monorepo/package-lock.json +1 -0
  106. package/scripts/fixtures/ci-local/monorepo/packages/a/src/a.sh +2 -0
  107. package/scripts/fixtures/ci-local/monorepo/packages/a/tests/a.test.sh +3 -0
  108. package/scripts/fixtures/ci-local/monorepo/packages/b/src/b.sh +2 -0
  109. package/scripts/fixtures/ci-local/monorepo/packages/b/tests/b.test.sh +3 -0
  110. package/scripts/fixtures/ci-local/no-ci/Makefile +6 -0
  111. package/scripts/fixtures/ci-local/no-ci/package.json +1 -0
  112. package/scripts/fixtures/ci-local/no-ci/src/a.js +1 -0
  113. package/scripts/fixtures/ci-local/no-ci/src/b.js +1 -0
  114. package/scripts/fixtures/ci-local/no-ci/tests/a.test.js +2 -0
  115. package/scripts/fixtures/ci-local/no-ci/tests/b.test.js +2 -0
  116. package/scripts/forge-root.test.sh +14 -12
  117. package/scripts/lib/telemetry.sh +1 -0
  118. package/scripts/publish-staging-sync.test.sh +48 -0
  119. package/scripts/remediate-approved-head.test.sh +2 -0
  120. package/scripts/remediate-basesync.test.sh +1 -1
  121. package/scripts/resolve-deploy-gate.sh +32 -6
  122. package/scripts/resolve-deploy-gate.test.sh +42 -1
  123. package/scripts/review-acceptance-gate.sh +2 -2
  124. package/scripts/review-acceptance-gate.test.sh +20 -0
  125. package/scripts/review-delta.sh +8 -1
  126. package/scripts/review-delta.test.sh +19 -0
  127. package/scripts/review-finalize.sh +1579 -0
  128. package/scripts/review-finalize.test.sh +1040 -0
  129. package/scripts/review-pr-delta-wiring.test.sh +32 -72
  130. package/scripts/review-pr-domain-map.test.sh +18 -13
  131. package/scripts/review-pr-gate2.test.sh +9 -65
  132. package/scripts/review-pr-staging-scope.test.sh +14 -98
  133. package/scripts/review-prepare.sh +53 -15
  134. package/scripts/review-prepare.test.sh +37 -3
  135. package/scripts/review-roster.sh +16 -2
  136. package/scripts/review-roster.test.sh +22 -0
  137. package/scripts/run-shell-tests.sh +5 -0
  138. package/scripts/telemetry.test.sh +2 -0
  139. package/scripts/trusted-comments.test.sh +11 -3
  140. package/scripts/verify-phase-trail.test.sh +8 -0
  141. package/scripts/wait-ci-green.sh +61 -9
  142. package/scripts/wait-ci-green.test.sh +44 -6
  143. package/scripts/with-run-tmpdir.sh +20 -0
@@ -1,9 +1,11 @@
1
1
  #!/usr/bin/env bash
2
2
  # review-pr-delta-wiring.test.sh — wiring tests for the review-delta modes in commands/review-pr.md (forge#3637).
3
- # Extracts the real spec fences (Phase 3C review mode, carry record, Phase 4 panel guard, Phase 6A finding read) and runs
4
- # them against temp git repos, synthetic PR comments, a stubbed `gh` and the REAL scripts/review-delta.sh and
5
- # scripts/trusted-comments.sh. No network. Covers: DELTA roster and the 2-round cap, BASE_SYNC_ONLY zero dispatch, a forged
6
- # or untrusted carry record failing closed, resolver resolution from FORGE_ROOT (never the worktree), FULL fallbacks.
3
+ # Extracts the real spec fences (the 3C pre-dispatch carry record, the Phase 4 panel guard) and the delta_roster function of
4
+ # scripts/review-prepare.sh (which owns the 3C review-mode decision and the 6A finding read moved to review-finalize.sh extract;
5
+ # scripts/review-prepare.test.sh and scripts/review-finalize.test.sh cover those) and runs them against temp git repos,
6
+ # synthetic PR comments, a stubbed `gh` and the REAL scripts/review-delta.sh and scripts/trusted-comments.sh. No network.
7
+ # Covers: the DELTA roster and the 2-round cap, BASE_SYNC_ONLY zero dispatch, a forged or untrusted carry record failing
8
+ # closed, resolver resolution from FORGE_ROOT (never the worktree), FULL fallbacks.
7
9
  # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
8
10
  # SPDX-License-Identifier: AGPL-3.0-or-later
9
11
  set -uo pipefail
@@ -97,7 +99,10 @@ fence_file() { # name pattern -> $TMP/<name>.sh (placeholders filled)
97
99
  HELPER="$ROOT/scripts/lib/review-delta-mode.sh"
98
100
  [ -f "$HELPER" ] && ok || bad "scripts/lib/review-delta-mode.sh must exist"
99
101
  n=$(grep -c '^_dm=$(forge_resolve lib/review-delta-mode.sh)' "$SPEC")
100
- [ "$n" -ge 6 ] && ok || bad "expected the review-delta helper source line in every consuming fence (3C mode, carry post, Phase 4, 5, 6A, 9), found $n"
102
+ [ "$n" -ge 2 ] && ok || bad "expected the review-delta helper source line in every consuming fence (3C pre-dispatch, Phase 4; the mode decision lives in scripts/review-prepare.sh, the 6A read and Phase 9 in scripts/review-finalize.sh), found $n"
103
+ for sc in review-prepare review-finalize; do
104
+ grep -q 'forge_resolve lib/review-delta-mode.sh' "$ROOT/scripts/$sc.sh" && grep -q 'review_delta_mode() { echo "FULL - 0"; }' "$ROOT/scripts/$sc.sh" && ok || bad "scripts/$sc.sh must source the helper through forge_resolve with a FULL-only fallback"
105
+ done
101
106
  distinct=$(grep '^_dm=$(forge_resolve lib/review-delta-mode.sh)' "$SPEC" | sort -u | grep -c . || true)
102
107
  [ "$distinct" = 1 ] && ok || bad "review-delta helper source lines differ ($distinct distinct)"
103
108
  [ "$(count_blocks review-pr-delta-mode)" = 0 ] && ok || bad "inline review-delta mode copies must not remain in the spec"
@@ -127,8 +132,9 @@ got=$(cd "$W" && HOME="$TMP/nohome" FORGE_ROOT="" FORGEDOCK_HOME="" FORGE_HOME="
127
132
  rm -f "$W/scripts/review-delta.sh"
128
133
 
129
134
  # ---- 2. delta_roster ------------------------------------------------------------------------------------------------
130
- marked_block review-pr-delta-roster > "$TMP/roster.sh"
131
- [ -s "$TMP/roster.sh" ] && ok || bad "delta roster block missing"
135
+ # delta_roster lives in scripts/review-prepare.sh (the 3C review-mode decision moved there): extract the real function.
136
+ awk '/^delta_roster\(\) \{/ {on=1} on {print} on && /^\}$/ {exit}' "$ROOT/scripts/review-prepare.sh" > "$TMP/roster.sh"
137
+ [ -s "$TMP/roster.sh" ] && ok || bad "delta_roster missing from scripts/review-prepare.sh"
132
138
  roster() { bash -c "source '$TMP/roster.sh'; delta_roster \"\$@\"" _ "$@"; }
133
139
  expect() { [ "$2" = "$3" ] && ok || bad "$1: want '$2', got '$3'"; }
134
140
  expect "round 0/1 keeps the 3B roster" "security auth billing" "$(roster 1 'security auth billing' 'README.md')"
@@ -149,63 +155,17 @@ expect "consumer with another trigger keeps it, no spec-cli" "security auth" "$(
149
155
  unset REPO_PATH
150
156
  expect "non-numeric rounds fail to the 3B roster" "security auth" "$(roster 'x' 'security auth' 'README.md')"
151
157
 
152
- # ---- 3. mode resolution through the real fence ----------------------------------------------------------------------
153
- # 3a. no checkpoint -> FULL
154
- mkrepo; R=$(sha HEAD); commit pr.txt pr2 pr2; HEAD=$(sha HEAD)
155
- comments "$(cm 'hello' User MEMBER)"
156
- SEL="security auth billing"; MODE=FULL
157
- fence_file mode "REVIEW_MODE: \${REVIEW_MODE}" || exit 1
158
- run_mode() { subst "$TMP/mode.raw" > "$TMP/mode.sh"; run_fence "$TMP/mode.sh"; }
159
- out=$(run_mode)
160
- printf '%s\n' "$out" | grep -qx 'REVIEW_MODE: FULL' && ok || bad "no checkpoint must be FULL: $out"
161
- printf '%s\n' "$out" | grep -qx 'SELECTED_AGENTS: security auth billing' && ok || bad "FULL keeps the 3B roster: $out"
162
-
163
- # 3b. one proven panel + a regular commit -> DELTA, roster unchanged below the cap
164
- { panel_items "$R"; } | jq -s '.' > "$FAKE_COMMENTS"
165
- out=$(run_mode)
166
- printf '%s\n' "$out" | grep -qx 'REVIEW_MODE: DELTA' && ok || bad "one panel then a commit must be DELTA: $out"
167
- printf '%s\n' "$out" | grep -qx "DELTA_FROM: $R" && ok || bad "DELTA_FROM must be the reviewed SHA: $out"
168
- printf '%s\n' "$out" | grep -qx 'SELECTED_AGENTS: security auth billing' && ok || bad "FULL_ROUNDS<2 keeps the 3B roster under DELTA: $out"
169
-
170
- # 3c. two proven panels -> cap: delta + security only
171
- mkrepo; R1=$(sha HEAD); commit pr.txt pr2 pr2; R2=$(sha HEAD); commit src/auth/login.py x "auth change"; HEAD=$(sha HEAD)
172
- { panel_items "$R1"; panel_items "$R2"; } | jq -s '.' > "$FAKE_COMMENTS"
173
- out=$(run_mode)
174
- printf '%s\n' "$out" | grep -qx 'REVIEW_MODE: DELTA' && ok || bad "two panels then a commit must be DELTA: $out"
175
- printf '%s\n' "$out" | grep -qx 'FULL_ROUNDS: 2' && ok || bad "FULL_ROUNDS must be 2: $out"
176
- printf '%s\n' "$out" | grep -qx 'SELECTED_AGENTS: security auth' && ok || bad "round 3 roster must be security + path-triggered auth: $out"
177
- mkrepo; R1=$(sha HEAD); commit pr.txt pr2 pr2; R2=$(sha HEAD); commit notes.txt x "notes"; HEAD=$(sha HEAD)
178
- { panel_items "$R1"; panel_items "$R2"; } | jq -s '.' > "$FAKE_COMMENTS"
179
- out=$(run_mode)
180
- printf '%s\n' "$out" | grep -qx 'SELECTED_AGENTS: security' && ok || bad "round 3 with no triggered path must be security only: $out"
181
-
182
- # 3d. THOROUGH forces FULL
183
- out=$(THOROUGH=true run_mode)
184
- printf '%s\n' "$out" | grep -qx 'REVIEW_MODE: FULL' && ok || bad "--thorough must stay FULL: $out"
185
-
186
- # 3e. a planted worktree resolver must not decide the mode
187
- mkdir -p "$W/scripts"; printf '#!/usr/bin/env bash\necho "BASE_SYNC_ONLY %s"\necho FULL_ROUNDS=1\n' "$R2" > "$W/scripts/review-delta.sh"
188
- out=$(run_mode)
189
- printf '%s\n' "$out" | grep -qx 'REVIEW_MODE: DELTA' && ok || bad "a planted scripts/review-delta.sh in the worktree must be ignored: $out"
190
- rm -f "$W/scripts/review-delta.sh"
191
-
192
- # 3f. unresolvable resolver (no trusted install) -> FULL
193
- subst "$TMP/mode.raw" > "$TMP/mode.sh"
194
- out=$(cd "$W" && env PATH="$STUB:$PATH" HOME="$TMP/nohome" FORGE_ROOT="" FORGEDOCK_HOME="" FORGE_HOME="" CLAUDE_CONFIG_DIR="" FAKE_HEAD="$HEAD" bash "$TMP/mode.sh" 2>&1)
195
- printf '%s\n' "$out" | grep -qx 'REVIEW_MODE: FULL' && ok || bad "no trusted install must fall back to FULL: $out"
196
-
197
- # 3g. base merge only -> BASE_SYNC_ONLY with an empty roster
158
+ # ---- 3. mode resolution -------------------------------------------------------------------------------------------
159
+ # The FULL / DELTA / BASE_SYNC_ONLY decision is made by scripts/review-prepare.sh (covered by scripts/review-prepare.test.sh, including the
160
+ # round cap, THOROUGH forcing FULL, an unvalidated SHA and a junk resolver answer). This test keeps the repos the carry and panel tests need.
161
+ # base merge only: a BASE_SYNC_ONLY head (the repo the carry-record and panel tests below run in)
198
162
  mkrepo; R=$(sha HEAD); base_merge b.txt b1; HEAD=$(sha HEAD)
199
163
  panel_items "$R" | jq -s '.' > "$FAKE_COMMENTS"
200
- out=$(run_mode)
201
- printf '%s\n' "$out" | grep -qx 'REVIEW_MODE: BASE_SYNC_ONLY' && ok || bad "a pure base merge must be BASE_SYNC_ONLY: $out"
202
- printf '%s\n' "$out" | grep -qx "CARRIED_FROM: $R" && ok || bad "CARRIED_FROM must name the reviewed SHA: $out"
203
- printf '%s\n' "$out" | grep -qx 'SELECTED_AGENTS: ' && ok || bad "BASE_SYNC_ONLY dispatches zero reviewers (empty roster): $out"
204
164
  BS_R=$R; BS_HEAD=$HEAD; BS_W=$W
205
165
 
206
166
  # ---- 4. carry record: post, trust, forge resistance ------------------------------------------------------------------
207
167
  W=$BS_W; R=$BS_R; HEAD=$BS_HEAD
208
- fence_file carry "CARRY_RECORD: POSTED" || exit 1
168
+ MODE=BASE_SYNC_ONLY; fence_file carry "CARRY_RECORD: POSTED" || exit 1
209
169
  run_carry() { subst "$TMP/carry.raw" > "$TMP/carry.sh"; run_fence "$TMP/carry.sh" "$@"; }
210
170
  verified() { # prints the verified Carried-From-SHA for the current $FAKE_COMMENTS using the real snippet
211
171
  { cat "$HELPER"; echo 'COMMENTS_JSON=$(cat "$FAKE_COMMENTS"); carry_verified_from'; } > "$TMP/cv_body.sh"
@@ -272,20 +232,20 @@ case "$(p4 DELTA 'security auth billing')" in "PANEL_STATUS: SHORTFALL"*) ok ;;
272
232
  { cm "$(body_agent security "$HEAD")" User NONE; cm "$(body_agent auth "$HEAD")" User NONE; } | jq -s '.' > "$FAKE_COMMENTS"
273
233
  case "$(p4 FULL 'security auth')" in "PANEL_STATUS: SHORTFALL"*) ok ;; *) bad "untrusted reviewer markers must not satisfy the panel: $(p4 FULL 'security auth')" ;; esac
274
234
 
275
- # ---- 6. Phase 6A reads the carried / earlier head's findings -------------------------------------------------------
276
- fence_file p6 'bodies_at()' || exit 1
277
- p6() { # mode dfrom -> FINDINGS or the BLOCKED line
278
- MODE="$1"; DFROM="${2:-}"; subst "$TMP/p6.raw" > "$TMP/p6.sh"; printf '\necho "FINDINGS=$(printf %%s "$FINDINGS" | tr "\\n" " ")"\n' >> "$TMP/p6.sh"
279
- run_fence "$TMP/p6.sh" | grep -E '^(FINDINGS=|REVIEW_RESULT:)' | head -1
280
- }
281
- { panel_items "$R" F-old; cm "$(body_carry "$HEAD" "$R")" Bot NONE; } | jq -s '.' > "$FAKE_COMMENTS"
282
- case "$(p6 BASE_SYNC_ONLY)" in *F-old*) ok ;; *) bad "BASE_SYNC_ONLY must read the carried head's findings: $(p6 BASE_SYNC_ONLY)" ;; esac
283
- { panel_items "$R" F-old; cm "$(body_carry "$HEAD" "$R")" User NONE; } | jq -s '.' > "$FAKE_COMMENTS"
284
- case "$(p6 BASE_SYNC_ONLY)" in REVIEW_RESULT:*BLOCKED*) ok ;; *) bad "an unverified carry must block reading findings (fail closed): $(p6 BASE_SYNC_ONLY)" ;; esac
285
- case "$(p6 FULL)" in REVIEW_RESULT:*BLOCKED*) ok ;; *) bad "FULL reads only current-head bodies; none posted must block: $(p6 FULL)" ;; esac
286
- { panel_items "$R" F-old; panel_items "$HEAD" F-new; } | jq -s '.' > "$FAKE_COMMENTS"
287
- case "$(p6 FULL)" in *F-new*) case "$(p6 FULL)" in *F-old*) bad "FULL must not read earlier-head findings" ;; *) ok ;; esac ;; *) bad "FULL must read current-head findings" ;; esac
288
- case "$(p6 DELTA "$R")" in *F-new*F-old*|*F-old*F-new*) ok ;; *) bad "DELTA must read the current and the earlier reviewed head's findings: $(p6 DELTA "$R")" ;; esac
235
+ # a verdict without a findings block is undelivered (#3976): not complete, never counted
236
+ { cm "$(printf '<!-- FORGE:REVIEW-AGENT:security -->\nReviewed-SHA: %s\n\nVerdict: APPROVE, no findings.' "$HEAD")" User MEMBER; cm "$(body_agent auth "$HEAD")" User MEMBER; } | jq -s '.' > "$FAKE_COMMENTS"
237
+ case "$(p4 FULL 'security auth')" in "PANEL_STATUS: SHORTFALL (ACTUAL_AGENT_COUNT=1)"*) ok ;; *) bad "a block-less reviewer comment must not satisfy Phase 4: $(p4 FULL 'security auth')" ;; esac
238
+ # the 3C pending-agent count and the staging 6.75 delivery count carry the same block lookahead as Phase 4
239
+ STAGING="$ROOT/commands/review-pr-staging.md"
240
+ for spec in "$SPEC" "$STAGING"; do
241
+ n=$(grep -F 'count "^(?=[\\s\\S]*<!-- REVIEW-FINDINGS-START -->[\\s\\S]*<!-- REVIEW-FINDINGS-END -->)<!-- FORGE:REVIEW-AGENT:${AGENT_DOMAIN} -->' "$spec" | wc -l)
242
+ want=2; [ "$spec" = "$STAGING" ] && want=1
243
+ [ "$n" -eq "$want" ] && ok || bad "$(basename "$spec"): $n delivery counts carry the findings-block lookahead, want $want"
244
+ if grep -E 'count "\^<!-- FORGE:REVIEW-AGENT:\$\{AGENT_DOMAIN\}' "$spec" >/dev/null; then bad "$(basename "$spec"): header-only delivery regex remains"; else ok; fi
245
+ if grep -qE 'scan the trusted (agent comments|reviewer bodies) for unstructured|no unstructured finding' "$spec"; then bad "$(basename "$spec"): free-text finding scan remains"; else ok; fi
246
+ done
247
+
248
+ # ---- 6. The 6A read of the carried / earlier head's findings is scripts/review-finalize.sh extract (covered by scripts/review-finalize.test.sh).
289
249
 
290
250
  echo "review-pr-delta-wiring tests: pass=$PASS fail=$FAILN"
291
251
  [ "$FAILN" -eq 0 ]
@@ -27,12 +27,14 @@ awk -v dir="$WORK" '
27
27
  /^# END review-pr-agent-domain/ {on=0; close(out)}
28
28
  ' "$SPEC"
29
29
  copies=$(ls "$WORK"/helper.* 2>/dev/null | wc -l | tr -d ' ')
30
- if [ "$copies" -ge 3 ]; then ok; else bad "expected >=3 inline agent_domain copies (canonicalize, dispatch, phase 4), found $copies"; fi
31
- # The roster script carries its own copy of the helper; it must match the inline copies (forge#3599).
32
- ROSTER="$ROOT/scripts/review-roster.sh"
30
+ if [ "$copies" -ge 2 ]; then ok; else bad "expected >=2 inline agent_domain copies (the 3C pre-dispatch fence and Phase 4), found $copies"; fi
31
+ # The roster and prepare scripts carry their own copy of the helper; both must match the inline copies (forge#3599, forge#3841).
32
+ ROSTER="$ROOT/scripts/review-roster.sh"; PREPARE="$ROOT/scripts/review-prepare.sh"
33
33
  want_fn=$(grep '^agent_domain()' "$WORK/helper.1")
34
34
  got_fn=$(grep '^agent_domain()' "$ROSTER")
35
35
  if [ -n "$want_fn" ] && [ "$want_fn" = "$got_fn" ]; then ok; else bad "scripts/review-roster.sh agent_domain differs from the inline copies"; fi
36
+ got_fn=$(grep '^agent_domain()' "$PREPARE")
37
+ if [ -n "$want_fn" ] && [ "$want_fn" = "$got_fn" ]; then ok; else bad "scripts/review-prepare.sh agent_domain differs from the inline copies"; fi
36
38
  for f in "$WORK"/helper.*; do
37
39
  [ -f "$f" ] || continue
38
40
  if cmp -s "$f" "$WORK/helper.1"; then ok; else bad "$(basename "$f") differs from helper.1"; fi
@@ -67,9 +69,11 @@ add_agent "Infrastructure"; add_agent "Scraping"; add_agent "Auth"; add_agent "A
67
69
  want=$(printf '%s' "Security INFRA SCRAPING auth")
68
70
  if [ "$SELECTED_AGENTS" = "$want" ]; then ok; else bad "add_agent roster '$SELECTED_AGENTS', want '$want'"; fi
69
71
 
70
- CANON=$(awk '/^# END review-pr-agent-domain/{seen++} seen && /^CANON_ROSTER=/{on=1} on{print} on && /^SELECTED_AGENTS=/{exit}' "$SPEC" | head -20)
71
- SELECTED_AGENTS="Security AUTH INFRA Infrastructure SCRAPING Scraping Frontend API api"
72
+ # The canonicalization (one entry per persona) is scripts/review-prepare.sh canonical_roster, which review-roster.sh output flows through.
73
+ CANON=$(grep '^canonical_roster()' "$PREPARE")
74
+ if [ -n "$CANON" ]; then ok; else bad "canonical_roster not found in scripts/review-prepare.sh"; fi
72
75
  eval "$CANON"
76
+ SELECTED_AGENTS=$(canonical_roster "Security AUTH INFRA Infrastructure SCRAPING Scraping Frontend API api")
73
77
  want="security auth infra scraper frontend api"
74
78
  if [ "$SELECTED_AGENTS" = "$want" ]; then ok; else bad "canonical roster '$SELECTED_AGENTS', want '$want'"; fi
75
79
  cnt=$(printf '%s' "$SELECTED_AGENTS" | tr ' ' '\n' | grep -c '.')
@@ -79,7 +83,7 @@ if [ "$cnt" -eq 6 ]; then ok; else bad "roster count $cnt, want 6"; fi
79
83
  for pf in "$PERSONAS"/*.md; do
80
84
  name=$(basename "$pf" .md)
81
85
  case "$name" in protocols|spec-cli) continue ;; esac
82
- if grep -qE "^\| .*\| \`$name\` \| \`$name\.md\` \|" "$SPEC"; then ok; else bad "spec table has no row for persona $name"; fi
86
+ if grep -qE "(→|->)\`$name\`" "$SPEC"; then ok; else bad "spec roster label mapping has no entry for persona $name"; fi
83
87
  if grep -qE "^\| .*\| \`$name\` \| \`review-pr-agents/$name\.md\` \|" "$CATALOG"; then ok; else bad "catalog has no marker-domain row for persona $name"; fi
84
88
  got=$(agent_domain "$name")
85
89
  if [ "$got" = "$name" ]; then ok; else bad "persona $name maps to $got"; fi
@@ -88,15 +92,16 @@ done
88
92
  # Test 6: the protocol defines the marker domain through the mapping
89
93
  if grep -qF 'marker domain' "$PERSONAS/protocols.md"; then ok; else bad "protocols.md does not define the marker domain"; fi
90
94
 
91
- # Test 7: the roster regexes in delta_roster must equal the DOMAIN_FILES_* regexes, and every domain needs an empty-slice fallback
92
- for pair in AUTH:auth BILLING:billing CONCURRENCY:concurrency DATABASE:database INFRA:infra SCRAPING:scraper FRONTEND:frontend API:api; do
95
+ # Test 7: the roster regexes in delta_roster must equal the per-domain file regexes of the diff slices (both in scripts/review-prepare.sh since #3841),
96
+ # and the slice function must fall back to the full file list and full diff for a domain with no hit
97
+ for pair in auth:auth billing:billing concurrency:concurrency database:database infra:infra scraper:scraper frontend:frontend api:api; do
93
98
  var=${pair%%:*}; dom=${pair#*:}
94
- a=$(grep -E "^DOMAIN_FILES_$var=" "$SPEC" | head -n 1 | sed -n 's/.*grep -iE "\([^"]*\)".*/\1/p')
95
- b=$(grep -F "&& out=\"\$out $dom\"" "$SPEC" | grep 'grep -qiE' | head -n 1 | sed -n 's/.*grep -qiE "\([^"]*\)".*/\1/p')
96
- if [ -z "$a" ] || [ -z "$b" ]; then bad "could not extract $var pattern (DOMAIN_FILES='$a' delta_roster='$b')"
97
- elif [ "$a" = "$b" ]; then ok; else bad "$var regex drifted: DOMAIN_FILES='$a' delta_roster='$b'"; fi
98
- 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
99
+ a=$(grep -E "^ slice $var " "$PREPARE" | head -n 1 | sed -n 's/.* "\([^"]*\)"$/\1/p')
100
+ b=$(grep -F "&& out=\"\$out $dom\"" "$PREPARE" | grep 'grep -qiE' | head -n 1 | sed -n 's/.*grep -qiE "\([^"]*\)".*/\1/p')
101
+ if [ -z "$a" ] || [ -z "$b" ]; then bad "could not extract $var pattern (slice='$a' delta_roster='$b')"
102
+ elif [ "$a" = "$b" ]; then ok; else bad "$var regex drifted: slice='$a' delta_roster='$b'"; fi
99
103
  done
104
+ if grep -qF '[ -n "$paths" ] || paths="$SLICE_FILES_SRC"' "$PREPARE" && grep -qF '[ -n "$body" ] || body="$FULL_DIFF"' "$PREPARE"; then ok; else bad "prepare slice() lacks the empty-slice fallback"; fi
100
105
 
101
106
  echo "review-pr-domain-map: $pass passed, $fail failed"
102
107
  [ "$fail" -eq 0 ]
@@ -1,7 +1,7 @@
1
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).
2
+ # review-pr-gate2.test.sh — static guards for the review-pr §6B.4 gate-2 rule family (forge#3597, #3598): the wording mirrored across the spec
3
+ # family, the deterministic slice path in the persona fences, and the staging slice-path binding. The executable route table lives in
4
+ # scripts/review-finalize.test.sh since the gate moved into scripts/review-finalize.sh (forge#3841).
5
5
  # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
6
6
  # SPDX-License-Identifier: AGPL-3.0-or-later
7
7
  set -uo pipefail
@@ -13,72 +13,16 @@ ok() { PASS=$((PASS+1)); }
13
13
  bad() { FAILN=$((FAILN+1)); echo "FAIL: $1"; }
14
14
  command -v jq >/dev/null 2>&1 || { echo "review-pr-gate2.test.sh: jq missing, skipped"; exit 0; }
15
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
16
+ # The gate-2 route table (severity normalisation, deleted/renamed/patch-less files, files absent from the diff) is exercised against the real
17
+ # implementation in scripts/review-finalize.test.sh ("gate2 table"); the spec no longer carries a bash sketch of it (forge#3841).
18
+ FIN="$ROOT/scripts/review-finalize.sh"
19
+ if grep -qF 'select(.patch == null' "$FIN"; then ok; else bad "review-finalize.sh lacks the null-patch selector"; fi
20
+ if grep -qF 'CRITICAL/HIGH and unknown severities never are' "$FIN"; then ok; else bad "review-finalize.sh lacks the inverted severity rule comment"; fi
76
21
 
77
22
  # 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
23
+ for f in commands/review-pr.md commands/review-pr-staging.md commands/review-pr-agents/protocols.md docs/spec/review-protocol.md; do
79
24
  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
25
  done
81
- if grep -qF 'only known low severities' "$SPEC"; then ok; else bad "spec lacks inverted severity rule comment"; fi
82
26
 
83
27
  # SLICE_FILE must be a deterministic path re-declared and guarded in every persona fence (random mktemp is lost between Bash calls).
84
28
  for f in commands/review-pr-agents/protocols.md docs/spec/review-protocol.md; do
@@ -14,10 +14,10 @@ bad() { FAILN=$((FAILN+1)); echo "FAIL: $1"; }
14
14
  command -v jq >/dev/null 2>&1 || { echo "review-pr-staging-scope.test.sh: jq missing, skipped"; exit 0; }
15
15
  TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
16
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"
17
+ # (1) severity rule: the two SCOPE_RULE lines live in scripts/review-finalize.sh (the staging triage runs `review-finalize.sh classify --scope-files`, #3841), marker stripped
18
+ grep -E '# SCOPE_RULE$' "$ROOT/scripts/review-finalize.sh" | sed 's/[[:space:]]*# SCOPE_RULE$//; s/^[[:space:]]*//' > "$TMP/rule.sh"
19
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"; }
20
+ demote() { F_SEV="$1"; OOS_DEMOTE=; eval "$(cat "$TMP/rule.sh")"; echo "$OOS_DEMOTE"; }
21
21
  chk() { [ "$(demote "$1")" = "$2" ] && ok || bad "severity '$1' expected demote=$2"; }
22
22
  chk HIGH 0; chk high 0; chk " High " 0; chk CRITICAL 0; chk critical 0
23
23
  chk MEDIUM 1; chk medium 1; chk LOW 1; chk INFO 1
@@ -49,50 +49,18 @@ rcnt() { bash "$TC" count "$RRE"; }
49
49
 
50
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
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
52
  if grep -qE "^DEPLOY_WIRING_FILES=.*git diff" "$SPEC"; then bad "DEPLOY_WIRING_FILES must not pipe git diff directly"; else ok; fi
55
53
  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
54
+ # the staging triage is the shared script: scoped and prior-PR arguments reach `classify`, the scope file and member PR list are built from the persisted 0B.1 state, no classifier logic is copied into the spec
55
+ if grep -qF -- '--scope-files "$SCOPE_FILE"' "$SPEC" && grep -qF -- '--prior-prs "$PRIOR_PRS"' "$SPEC" && grep -q '_scope.env' "$SPEC" && grep -q 'review-finalize.sh' "$SPEC"; then ok; else bad "7A/7C must call review-finalize.sh classify with --scope-files and --prior-prs from the persisted 0B.1 scope"; fi
56
+ if grep -qE "^(classify_one|cap_pass|prior_findings_tsv)\(\) \{" "$SPEC"; then bad "review-pr-staging.md must not carry a copy of the classifier, cap or re-flag logic"; else ok; fi
57
+ if grep -qF 'FINDING_TEXT_FILE="$WORKDIR/finding-text.$I"; jq -r' "$ROOT/scripts/review-finalize.sh" && grep -qF -- '--text-file "$FINDING_TEXT_FILE"' "$ROOT/scripts/review-finalize.sh"; then ok; else bad "FINDING_TEXT_FILE must be written before the classifier call in review-finalize.sh"; fi
58
+ grep -q "skip-dedup" "$ROOT/scripts/review-finalize.sh" && ok || bad "review-finalize.sh must skip a deduped finding"
59
+ grep -q "review-prepare.sh" "$SPEC" && grep -q -- "--no-delta" "$SPEC" && ok || bad "staging must call review-prepare.sh --no-delta in 0C"
93
60
  # (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
61
+ SPEC_FIN="$ROOT/scripts/review-finalize.sh" # review-pr.md delegates classification to review-finalize.sh, which owns the fail-closed BLOCKED line (#3841)
62
+ if grep -qE 'classifier=manual|echo script \|\| echo manual|classifier-unavailable' "$SPEC"; then bad "review-pr-staging.md must carry no manual classifier mode"; else ok; fi
63
+ for _s in $SPEC_FIN; do # one path today; unquoted so SC2066 does not flag a single-iteration loop
96
64
  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
65
  done
98
66
  # (8) fresh-shell reload (forge#3654): the FENCE_BOOTSTRAP lines must reload PR_NUMBER / ALL_PR_NUMBERS from the scratchpad, stay empty (with a warning) when nothing was persisted, and reject a tampered number
@@ -173,65 +141,13 @@ OUT8=$( ( unset ALL_PR_NUMBERS; PR_NUMBER=99; export PATH="$TMP/bin:$PATH" FORGE
173
141
  # producers persist, and 0B.1 / 7B.5 no longer use the unsubstituted-template repo token or a bare unquoted PR number in gh pr comment
174
142
  grep -q 'staging_pr_number' "$SPEC" && grep -q '_bundle-prs.txt' "$SPEC" && ok || bad "Phase -1 / 0A must persist staging_pr_number and _bundle-prs.txt"
175
143
  if awk '/^### 0B.1:/,/^## Phase 1:/' "$SPEC" | grep -q '{GH_REPO}'; then bad "0B.1 must not use the {GH_REPO} template token"; else ok; fi
176
- if awk '/^### 7B.5:/,/^### 7C:/' "$SPEC" | grep -qE '\{GH_REPO\}|gh pr comment \$\{PR_NUMBER\}'; then bad "7B.5 must use quoted \$GH_REPO / \$PR_NUMBER"; else ok; fi
144
+ if awk '/^### 7A:/,/^### 7D:/' "$SPEC" | grep -qE '\{GH_REPO\}|gh pr comment \$\{PR_NUMBER\}'; then bad "7A-7C must use quoted \$GH_REPO / \$PR_NUMBER"; else ok; fi
177
145
  # (7) reviewer templates bind the full head SHA via the dispatch placeholder, not an orchestrator-only shell var
178
146
  if grep -qE '^Reviewed-SHA: \$\{REVIEW_SHA_FULL\}$' "$SPEC"; then bad "reviewer template still uses \${REVIEW_SHA_FULL} (unbound in a fresh shell)"; else ok; fi
179
147
  [ "$(grep -cE '^Reviewed-SHA: \[REVIEW_SHA\]$' "$SPEC")" -ge 5 ] && ok || bad "all five reviewer templates must use Reviewed-SHA: [REVIEW_SHA]"
180
148
  if grep -E '^Reviewed-SHA:' "$SPEC" | grep -qvE '^Reviewed-SHA: \[REVIEW_SHA\]$'; then bad "every Reviewed-SHA template line must be [REVIEW_SHA]"; else ok; fi
181
149
  grep -F 'substitute `[REVIEW_SHA]`' "$SPEC" | grep -qF '[REVIEW_SHA_SHORT]' || grep -qF 'Also substitute `[REVIEW_SHA]`' "$SPEC" && ok || bad "dispatch text must bind [REVIEW_SHA]"
182
- # (8) cross-PR re-flag (forge#3641): a staging finding that duplicates a bundle member PR's finding becomes a NOTE and files nothing.
183
- grep -q 'prior-findings' "$SPEC" && grep -q 'notes_reflag=' "$SPEC" && grep -q 'trusted-comments.sh' "$SPEC" && ok || bad "7B.5 must pass --prior-findings, read members via trusted-comments.sh and record notes_reflag="
184
- S6=$(grep -n '^### 7B.6:' "$SPEC" | head -1 | cut -d: -f1); S5=$(grep -n '^### 7B.5:' "$SPEC" | head -1 | cut -d: -f1)
185
- [ -n "$S6" ] && [ -n "$S5" ] && [ "$S6" -lt "$S5" ] && ok || bad "7B.6 provenance gates must precede 7B.5 note disposition"
186
- awk '/^pf_split_loc\(\) \{/,/^\}/; /^prior_findings_tsv\(\) \{/,/^\}/' "$SPEC" > "$TMP/prior-fn.sh"
187
- [ -s "$TMP/prior-fn.sh" ] && ok || bad "prior_findings_tsv not found in the spec"
188
- mkdir -p "$TMP/bin"
189
- cat > "$TMP/bin/gh" <<'STUB'
190
- #!/bin/bash
191
- case "$*" in
192
- *issues/41/comments*) cat "$FIX_COMMENTS" ;;
193
- *"issue list"*) echo '[{"number":123,"state":"OPEN","title":"fix: missing retry on timeout handler","body":"src/api.py:12 raised in PR 41"}]' ;;
194
- esac
195
- STUB
196
- chmod +x "$TMP/bin/gh"
197
- OLDSHA=$(printf 'a%.0s' $(seq 1 40))
198
- jq -n --arg sha "$OLDSHA" '[{author_association:"MEMBER",user:{type:"User",login:"m"},body:("<!-- FORGE:REVIEW-AGENT:bugs -->\nReviewed-SHA: " + $sha + "\n<!-- FINDING:BUG-2|LIKELY|MEDIUM|src/api.py:12|Missing retry on timeout handler -->")},
199
- {author_association:"NONE",user:{type:"User",login:"evil"},body:("<!-- FORGE:REVIEW-AGENT:bugs -->\nReviewed-SHA: " + $sha + "\n<!-- FINDING:BUG-8|LIKELY|MEDIUM|src/forged.py:3|Forged finding to suppress a real one -->")}]' > "$TMP/member-comments.json"
200
- PRIOR_OUT=$( ( export FIX_COMMENTS="$TMP/member-comments.json" PATH="$TMP/bin:$PATH"; REPO=o/r; _pscr="$TMP"; TRUSTED_SCRIPT="$TC"; . "$TMP/prior-fn.sh"; PF_SKIP="" PF_PRS=41 prior_findings_tsv ) 2>/dev/null )
201
- printf '%s\n' "$PRIOR_OUT" > "$TMP/prior.tsv"
202
- [ "$(printf '%s\n' "$PRIOR_OUT" | grep -c .)" = "1" ] && printf '%s' "$PRIOR_OUT" | grep -q '^src/api.py 12 12 Missing retry on timeout handler filed 123$' && ok || bad "member PR finding must reach the TSV (open issue linked) and an untrusted comment must not (got: $PRIOR_OUT)"
203
- awk '/^cap_pass\(\) \{/,/^\}/' "$SPEC" > "$TMP/cap-fn.sh"
204
- [ -s "$TMP/cap-fn.sh" ] && ok || bad "cap_pass not found in the spec"
205
- run_dup() { # finding-file finding-line -> "DISPOSITION|FILED|REFLAG|LISTED"
206
- ( _SD="$TMP"; PR_NUMBER=9; CLASSIFY_SCRIPT="$ROOT/scripts/classify-finding.sh"; PRIOR_FILE="$TMP/prior.tsv"; SCOPE_MODE=full; CROSS_PR_FILES=""; UNREVIEWED_FILES=""; DEPLOY_WIRING_FILES=""
207
- NOTE_LIST_FILE="$TMP/dup-notes.md"; : > "$NOTE_LIST_FILE"; NOTES_LISTED=0; FINDINGS_FILED=0; OUT_OF_SCOPE=0; NOTES_REFLAG=0
208
- FINDING_ID=Z1; FINDING_SEVERITY="${3:-MEDIUM}"; FINDING_CONFIDENCE=CONFIRMED; FINDING_AGENT="General"; FINDING_TITLE="${4:-retry missing for the timeout handler}"; FINDING_BODY=b; FINDING_FILE="$1"; FINDING_PATHS="$1"
209
- CAP_ROWS_FILE="$TMP/dup-rows.tsv"; : > "$CAP_ROWS_FILE"; CAPPED_IDS_FILE="$TMP/dup-capped.txt"; CAP_SCRIPT="$ROOT/scripts/cap-findings.sh"; NOTES_CAPPED=0
210
- . "$TMP/fn.sh"; . "$TMP/cap-fn.sh"; classify_one; cap_pass >/dev/null; echo "$DISPOSITION|$FINDINGS_FILED|$NOTES_REFLAG|$NOTES_LISTED" ) 2>&1 | tail -1; }
211
- [ "$(run_dup src/api.py:12)" = "NOTE reflag-filed #123|0|1|0" ] && ok || bad "a staging finding duplicating a member PR finding must be NOTE reflag-filed and file nothing (got '$(run_dup src/api.py:12)')"
212
- [ "$(run_dup src/api.py:12 x HIGH)" = "ISSUE severity-HIGH|1|0|0" ] && ok || bad "a HIGH duplicate of a member PR finding must still be an ISSUE (got '$(run_dup src/api.py:12 x HIGH)')"
213
- [ "$(run_dup src/other.py:12)" = "ISSUE MEDIUM-CONFIRMED|1|0|0" ] && ok || bad "a non-duplicate on another file must classify normally (got '$(run_dup src/other.py:12)')"
214
- run_dup src/api.py:12 >/dev/null
215
- grep -q 'reflag-filed #123' "$TMP/dup-notes.md" && ok || bad "a re-flag NOTE must be listed, not dropped"
216
- # (8b) per-review cap (forge#3642): classify_one only records ISSUE rows; cap_pass keeps review.max_filed_findings (default 2), lists the rest as NOTE capped, never caps HIGH.
217
- run_cap() { # "<sev>:<conf>:<id>"... -> "FILED|CAPPED|NOTES_LISTED|capped-ids(comma)|note-lines-with-capped"
218
- ( _SD="$TMP"; PR_NUMBER=9; CLASSIFY_SCRIPT="$ROOT/scripts/classify-finding.sh"; PRIOR_FILE=""; SCOPE_MODE=full; CROSS_PR_FILES=""; UNREVIEWED_FILES=""; DEPLOY_WIRING_FILES=""
219
- NOTE_LIST_FILE="$TMP/cap-notes.md"; : > "$NOTE_LIST_FILE"; NOTES_LISTED=0; FINDINGS_FILED=0; OUT_OF_SCOPE=0; NOTES_REFLAG=0; NOTES_CAPPED=0
220
- CAP_ROWS_FILE="$TMP/cap-rows.tsv"; : > "$CAP_ROWS_FILE"; CAPPED_IDS_FILE="$TMP/cap-capped.txt"; CAP_SCRIPT="$ROOT/scripts/cap-findings.sh"; MAX_FILED_FINDINGS="${CAPMAX:-}"
221
- . "$TMP/fn.sh"; . "$TMP/cap-fn.sh"
222
- for spec in "$@"; do
223
- IFS=: read -r FINDING_SEVERITY FINDING_CONFIDENCE FINDING_ID <<EOF3
224
- $spec
225
- EOF3
226
- FINDING_AGENT=General; FINDING_TITLE="finding $FINDING_ID"; FINDING_BODY=b; FINDING_FILE="src/$FINDING_ID.py"; FINDING_PATHS="$FINDING_FILE"
227
- classify_one
228
- done
229
- cap_pass >/dev/null
230
- echo "$FINDINGS_FILED|$NOTES_CAPPED|$NOTES_LISTED|$(paste -sd, "$CAPPED_IDS_FILE")|$(grep -c 'NOTE capped (' "$NOTE_LIST_FILE")" ) 2>&1 | tail -1; }
231
- [ "$(run_cap MEDIUM:LIKELY:a MEDIUM:CONFIRMED:b MEDIUM:LIKELY:c)" = "2|1|0|c|1" ] && ok || bad "3 MEDIUM findings: 2 filed, the lowest-ranked capped and listed (got '$(run_cap MEDIUM:LIKELY:a MEDIUM:CONFIRMED:b MEDIUM:LIKELY:c)')"
232
- [ "$(run_cap HIGH:LIKELY:a HIGH:POSSIBLE:b CRITICAL:CONFIRMED:c MEDIUM:LIKELY:d)" = "4|0|0||0" ] && ok || bad "HIGH/CRITICAL must never be capped (got '$(run_cap HIGH:LIKELY:a HIGH:POSSIBLE:b CRITICAL:CONFIRMED:c MEDIUM:LIKELY:d)')"
233
- [ "$(CAPMAX=1 run_cap MEDIUM:LIKELY:a MEDIUM:CONFIRMED:b)" = "1|1|0|a|1" ] && ok || bad "review.max_filed_findings=1 must be honoured (got '$(CAPMAX=1 run_cap MEDIUM:LIKELY:a MEDIUM:CONFIRMED:b)')"
234
- [ "$(CAPMAX=zzz run_cap MEDIUM:LIKELY:a MEDIUM:LIKELY:b MEDIUM:LIKELY:c)" = "2|1|0|c|1" ] && ok || bad "an invalid review.max_filed_findings must fall back to the default 2"
150
+ # (8) cross-PR re-flag (forge#3641) and the per-review cap (forge#3642) now run inside review-finalize.sh classify: scripts/review-finalize.test.sh covers them (bundle re-flag, scope demotion, cap)
235
151
  # (9) stale-head delta credit (forge#3638): the spec's bundle_delta_credit runs against real git fixtures and the REAL review-delta.sh / trusted-comments.sh.
236
152
  # Fail-closed cases first: every FULL / error / untrusted path must leave the PR's commits UNREVIEWED (empty credit).
237
153
  if command -v git >/dev/null 2>&1; then