forgedock 1.30.0 → 1.32.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 (162) hide show
  1. package/bin/engine/outcome-codes.mjs +3 -2
  2. package/bin/engine/runlog.mjs +82 -12
  3. package/bin/engine/telemetry-seam.mjs +11 -4
  4. package/bin/engine/telemetry-seam.test.mjs +18 -0
  5. package/bin/engine-cli.mjs +409 -38
  6. package/bin/engine.mjs +47 -37
  7. package/bin/forgedock.mjs +35 -3
  8. package/bin/gc.mjs +50 -17
  9. package/bin/hooks/reread-nudge.mjs +65 -0
  10. package/bin/labels.json +5 -0
  11. package/bin/lean/apishape.mjs +170 -0
  12. package/bin/lean/apishape.test.mjs +187 -0
  13. package/bin/lean/batch.mjs +32 -0
  14. package/bin/lean/batch.test.mjs +338 -2
  15. package/bin/lean/budget.mjs +89 -0
  16. package/bin/lean/budget.test.mjs +106 -0
  17. package/bin/lean/checks.mjs +32 -9
  18. package/bin/lean/checks.test.mjs +78 -1
  19. package/bin/lean/conformance.e2e.test.mjs +27 -5
  20. package/bin/lean/conformance.mjs +150 -15
  21. package/bin/lean/conformance.test.mjs +175 -3
  22. package/bin/lean/context.mjs +128 -44
  23. package/bin/lean/context.test.mjs +157 -5
  24. package/bin/lean/decompose.mjs +90 -0
  25. package/bin/lean/decompose.test.mjs +105 -0
  26. package/bin/lean/depcache.mjs +11 -7
  27. package/bin/lean/depcache.test.mjs +81 -0
  28. package/bin/lean/e2e.mjs +102 -0
  29. package/bin/lean/e2e.test.mjs +86 -0
  30. package/bin/lean/effects.mjs +48 -15
  31. package/bin/lean/explore.mjs +47 -0
  32. package/bin/lean/explore.test.mjs +61 -0
  33. package/bin/lean/facts.mjs +84 -0
  34. package/bin/lean/facts.test.mjs +66 -0
  35. package/bin/lean/fixround.mjs +90 -0
  36. package/bin/lean/fixround.test.mjs +117 -0
  37. package/bin/lean/heavy.mjs +35 -2
  38. package/bin/lean/heavy.test.mjs +32 -2
  39. package/bin/lean/lean.mjs +712 -134
  40. package/bin/lean/lean.test.mjs +1626 -37
  41. package/bin/lean/ledger.mjs +5 -3
  42. package/bin/lean/ledger.test.mjs +18 -0
  43. package/bin/lean/light.mjs +135 -0
  44. package/bin/lean/light.test.mjs +173 -0
  45. package/bin/lean/lightlane.mjs +219 -0
  46. package/bin/lean/lightlane.test.mjs +237 -0
  47. package/bin/lean/limits.test.mjs +36 -1
  48. package/bin/lean/orchestrate.mjs +303 -41
  49. package/bin/lean/pitfalls.mjs +136 -0
  50. package/bin/lean/pitfalls.test.mjs +117 -0
  51. package/bin/lean/postmerge.mjs +85 -0
  52. package/bin/lean/postmerge.test.mjs +79 -0
  53. package/bin/lean/precheck.mjs +138 -13
  54. package/bin/lean/precheck.test.mjs +118 -1
  55. package/bin/lean/prefilter.mjs +220 -5
  56. package/bin/lean/prefilter.test.mjs +260 -4
  57. package/bin/lean/preflight.mjs +108 -3
  58. package/bin/lean/preflight.test.mjs +48 -1
  59. package/bin/lean/probe.mjs +297 -0
  60. package/bin/lean/probe.test.mjs +210 -0
  61. package/bin/lean/prompts.mjs +104 -13
  62. package/bin/lean/render.mjs +7 -3
  63. package/bin/lean/resume.mjs +67 -0
  64. package/bin/lean/resume.test.mjs +84 -0
  65. package/bin/lean/review-trigger.mjs +12 -0
  66. package/bin/lean/review-trigger.test.mjs +17 -1
  67. package/bin/lean/routing.mjs +74 -0
  68. package/bin/lean/routing.test.mjs +67 -0
  69. package/bin/lean/sched.mjs +16 -1
  70. package/bin/lean/sched.test.mjs +175 -1
  71. package/bin/lean/schedd.mjs +55 -9
  72. package/bin/lean/schedd.test.mjs +170 -3
  73. package/bin/lean/selfreview.mjs +37 -0
  74. package/bin/lean/selfreview.test.mjs +51 -0
  75. package/bin/lean/seqlease.mjs +40 -0
  76. package/bin/lean/seqlease.test.mjs +101 -0
  77. package/bin/lean/sequence.mjs +296 -13
  78. package/bin/lean/sequence.test.mjs +263 -2
  79. package/bin/lean/toolchain.mjs +60 -15
  80. package/bin/lean/toolchain.test.mjs +120 -3
  81. package/bin/lean/toolstats.mjs +72 -0
  82. package/bin/lean/toolstats.test.mjs +47 -0
  83. package/bin/lean/verifier.mjs +232 -0
  84. package/bin/lean/verifier.test.mjs +163 -0
  85. package/bin/lean/verify.mjs +72 -10
  86. package/bin/lean/verify.test.mjs +68 -3
  87. package/bin/review-staging.mjs +533 -0
  88. package/bin/runner.mjs +82 -13
  89. package/bin/stats/aggregate.mjs +87 -2
  90. package/bin/stats/efficiency.mjs +335 -0
  91. package/bin/stats/efficiency.test.mjs +266 -0
  92. package/bin/stats/github-source.mjs +8 -3
  93. package/bin/stats/index.mjs +91 -6
  94. package/bin/stats/local-source.mjs +145 -8
  95. package/bin/stats/query.mjs +181 -3
  96. package/bin/stats/render.mjs +22 -0
  97. package/bin/stats/stats.test.mjs +403 -8
  98. package/commands/orchestrate.md +3 -1
  99. package/commands/review-pr-agents/protocols.md +1 -1
  100. package/commands/review-pr-staging.md +84 -831
  101. package/commands/review-pr.md +1 -1
  102. package/commands/work-on/build/implement.md +1 -0
  103. package/commands/work-on/build/validate.md +11 -1
  104. package/docs/CONFIG.md +80 -5
  105. package/docs/OUTCOME-CODES.md +2 -1
  106. package/docs/STATS.md +66 -1
  107. package/docs/TELEMETRY.md +1 -1
  108. package/docs/eval/corpus/lightlane-v1.json +55 -0
  109. package/docs/eval/lightlane-bench.md +91 -0
  110. package/docs/spec/review-protocol.md +1 -1
  111. package/package.json +1 -1
  112. package/scripts/bench-lightlane.mjs +286 -0
  113. package/scripts/ci-local/lib.mjs +20 -0
  114. package/scripts/ci-local/plan.mjs +2 -12
  115. package/scripts/ci-local/scoping.mjs +10 -1
  116. package/scripts/ci-local.test.sh +2 -0
  117. package/scripts/fixtures/apishape/other/go-change/base/x.go +3 -0
  118. package/scripts/fixtures/apishape/other/go-change/head/x.go +3 -0
  119. package/scripts/fixtures/apishape/python/generator-function/base/seq.py +3 -0
  120. package/scripts/fixtures/apishape/python/generator-function/head/seq.py +2 -0
  121. package/scripts/fixtures/apishape/python/ichunked/base/more.py +17 -0
  122. package/scripts/fixtures/apishape/python/ichunked/head/more.py +20 -0
  123. package/scripts/fixtures/apishape/python/private-edit/base/calc.py +6 -0
  124. package/scripts/fixtures/apishape/python/private-edit/head/calc.py +9 -0
  125. package/scripts/fixtures/apishape/python/removed/base/ops.py +6 -0
  126. package/scripts/fixtures/apishape/python/removed/head/ops.py +2 -0
  127. package/scripts/fixtures/apishape/python/renamed/base/text.py +2 -0
  128. package/scripts/fixtures/apishape/python/renamed/head/text.py +2 -0
  129. package/scripts/fixtures/apishape/python/signature/base/shapes.py +2 -0
  130. package/scripts/fixtures/apishape/python/signature/head/shapes.py +2 -0
  131. package/scripts/fixtures/apishape/python/sync-async/base/client.py +7 -0
  132. package/scripts/fixtures/apishape/python/sync-async/head/client.py +7 -0
  133. package/scripts/fixtures/apishape/ts/generator-function/base/seq.js +3 -0
  134. package/scripts/fixtures/apishape/ts/generator-function/head/seq.js +5 -0
  135. package/scripts/fixtures/apishape/ts/private-edit/base/calc.ts +7 -0
  136. package/scripts/fixtures/apishape/ts/private-edit/head/calc.ts +9 -0
  137. package/scripts/fixtures/apishape/ts/removed/base/ops.ts +7 -0
  138. package/scripts/fixtures/apishape/ts/removed/head/ops.ts +3 -0
  139. package/scripts/fixtures/apishape/ts/renamed/base/text.ts +3 -0
  140. package/scripts/fixtures/apishape/ts/renamed/head/text.ts +3 -0
  141. package/scripts/fixtures/apishape/ts/signature/base/shapes.ts +3 -0
  142. package/scripts/fixtures/apishape/ts/signature/head/shapes.ts +3 -0
  143. package/scripts/fixtures/apishape/ts/sync-async/base/client.mjs +5 -0
  144. package/scripts/fixtures/apishape/ts/sync-async/head/client.mjs +5 -0
  145. package/scripts/forge-root.test.sh +2 -2
  146. package/scripts/publish-staging-sync.test.sh +62 -4
  147. package/scripts/review-finalize.sh +17 -6
  148. package/scripts/review-finalize.test.sh +47 -0
  149. package/scripts/review-pr-arguments.test.sh +13 -1
  150. package/scripts/review-pr-delta-wiring.test.sh +2 -2
  151. package/scripts/review-pr-gate2.test.sh +6 -14
  152. package/scripts/review-pr-staging-finding-filter.test.sh +4 -4
  153. package/scripts/review-pr-staging-scope.test.sh +39 -33
  154. package/scripts/review-prepare.sh +73 -6
  155. package/scripts/review-prepare.test.sh +85 -0
  156. package/scripts/review-staging-post.sh +293 -0
  157. package/scripts/review-staging-post.test.sh +143 -0
  158. package/scripts/review-staging-pre.sh +401 -0
  159. package/scripts/review-staging-pre.test.sh +150 -0
  160. package/scripts/symbol-index.mjs +26 -7
  161. package/scripts/symbol-index.py +55 -9
  162. package/scripts/trusted-comments.test.sh +1 -1
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env bash
2
- # publish-staging-sync.test.sh — guards the publish.yml "Sync version to staging" step (forge#3695)
3
- # and the deploy-pr Phase 2A.5 version-sync pre-flight. Static assertions, no network.
2
+ # publish-staging-sync.test.sh — guards the publish.yml "Sync version to staging" step (forge#3695, forge#4357)
3
+ # and the deploy-pr Phase 2A.5 version-sync pre-flight. Static assertions plus a local-only run of the step (bare origin, stub gh); no network.
4
4
  # Usage: bash scripts/publish-staging-sync.test.sh
5
5
  # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
6
6
  # SPDX-License-Identifier: AGPL-3.0-or-later
@@ -29,14 +29,72 @@ else bad "sync step sits between Push version bump and Create GitHub Release"; f
29
29
 
30
30
  stepHas() { if printf '%s\n' "$STEP" | grep -qF -e "$2"; then ok "$1"; else bad "$1 (missing '$2')"; fi; }
31
31
  stepHas "step fetches origin/staging" "origin/staging"
32
- stepHas "step pushes HEAD to staging" "git push origin HEAD:staging"
33
32
  stepHas "step is non-fatal" "continue-on-error: true"
34
33
  stepHas "step is guarded by SKIP_PUBLISH" "env.SKIP_PUBLISH != 'true'"
35
- stepHas "commit carries [skip ci]" "[skip ci]"
34
+ # forge#4357: staging is protected (GH006), so the sync goes through a PR, never a direct push.
35
+ stepHas "step pushes to a chore/sync-version-X.Y.Z branch" 'SYNC_BRANCH="chore/sync-version-${VERSION}"'
36
+ stepHas "step force-pushes the sync branch" 'git push -f origin "HEAD:refs/heads/$SYNC_BRANCH"'
37
+ stepHas "step looks up an existing sync PR" 'gh pr list --head "$SYNC_BRANCH" --base staging'
38
+ stepHas "step opens a PR to staging" 'gh pr create --base staging --head "$SYNC_BRANCH"'
39
+ stepHas "step authenticates gh" 'GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}'
40
+ stepHas "step skips when staging is at or above the version" "nothing to sync"
41
+ if printf '%s\n' "$STEP" | grep -E 'git push' | grep -qE 'HEAD:staging|refs/heads/staging|origin staging'; then
42
+ bad "step must not push directly to staging"; else ok "step does not push directly to staging"; fi
43
+ if printf '%s\n' "$STEP" | grep -E 'git commit' | grep -qF '[skip ci]'; then
44
+ bad "sync commit (PR head) must not carry [skip ci]"; else ok "sync commit does not carry [skip ci]"; fi
45
+ PERMS=$(awk '/^ permissions:/{f=1;next} f&&/^ [a-z]/{exit} f{print}' "$PUB")
46
+ if printf '%s\n' "$PERMS" | grep -qE '^ +pull-requests: write'; then ok "publish job grants pull-requests: write"; else bad "publish job grants pull-requests: write"; fi
36
47
  stepHas "only version files are staged" "git add package.json .claude-plugin/plugin.json"
37
48
  stepHas "failure emits a warning" "::warning::"
38
49
  if printf '%s\n' "$STEP" | grep -qE 'git (merge|pull)'; then bad "step must not merge main (version-only commit)"; else ok "step does not merge main"; fi
39
50
 
51
+ # Behavioural: run the step's script against a local bare origin with a stub gh.
52
+ RUN=$(printf '%s\n' "$STEP" | awk '/^ run: \|/{f=1;next} f{sub(/^ /,"");print}')
53
+ SB=$(mktemp -d "${TMPDIR:-/tmp}/pubsync.XXXXXX")
54
+ trap 'rm -rf "$SB"' EXIT
55
+ (
56
+ set -e
57
+ export GIT_AUTHOR_NAME=t GIT_AUTHOR_EMAIL=t@t GIT_COMMITTER_NAME=t GIT_COMMITTER_EMAIL=t@t
58
+ git init -q --bare "$SB/origin.git"
59
+ git init -q -b staging "$SB/seed"; cd "$SB/seed"
60
+ mkdir .claude-plugin
61
+ echo '{"name":"x","version":"1.9.0"}' > package.json
62
+ echo '{"name":"x","version":"1.9.0"}' > .claude-plugin/plugin.json
63
+ echo keep > other.txt
64
+ git add -A; git commit -qm seed; git push -q "$SB/origin.git" staging
65
+ git clone -q -b staging "$SB/origin.git" "$SB/work"
66
+ mkdir "$SB/bin"
67
+ printf '#!/bin/sh\necho "$*" >> "%s/gh.log"\ncase "$2" in create) echo https://example.test/pr/1;; list) [ -z "$GH_STUB_LIST" ] || echo "$GH_STUB_LIST";; esac\n' "$SB" > "$SB/bin/gh"
68
+ chmod +x "$SB/bin/gh"
69
+ ) >/dev/null 2>&1 || bad "behavioural sandbox setup"
70
+ # The workflow's bump step sets git user.name/email; give the step that identity here
71
+ # (CI runners have no global git config).
72
+ runStep() { (cd "$SB/work" && PATH="$SB/bin:$PATH" RUNNER_TEMP="$SB/rt" VERSION="$1" \
73
+ GIT_AUTHOR_NAME=t GIT_AUTHOR_EMAIL=t@t GIT_COMMITTER_NAME=t GIT_COMMITTER_EMAIL=t@t bash -c "$RUN") >"$SB/out" 2>&1; }
74
+ mkdir -p "$SB/rt"
75
+ STAGING_BEFORE=$(git -C "$SB/origin.git" rev-parse staging 2>/dev/null)
76
+ if runStep 1.10.0; then ok "step exits 0"; else bad "step exits 0"; fi
77
+ [ "$(git -C "$SB/origin.git" rev-parse staging 2>/dev/null)" = "$STAGING_BEFORE" ] && ok "staging is not pushed to" || bad "staging is not pushed to"
78
+ SYNCV=$(git -C "$SB/origin.git" show chore/sync-version-1.10.0:package.json 2>/dev/null | node -e "process.stdout.write(JSON.parse(require('fs').readFileSync(0,'utf8')).version)" 2>/dev/null)
79
+ [ "$SYNCV" = "1.10.0" ] && ok "sync branch carries the new version" || bad "sync branch carries the new version (got '$SYNCV')"
80
+ CHANGED=$(git -C "$SB/origin.git" diff --name-only staging chore/sync-version-1.10.0 2>/dev/null | sort | tr '\n' ' ')
81
+ [ "$CHANGED" = ".claude-plugin/plugin.json package.json " ] && ok "sync commit is version-only" || bad "sync commit is version-only (got '$CHANGED')"
82
+ grep -q -- 'pr create --base staging --head chore/sync-version-1.10.0' "$SB/gh.log" 2>/dev/null && ok "gh pr create targets staging" || bad "gh pr create targets staging"
83
+ : > "$SB/gh.log"
84
+ if GH_STUB_LIST=https://example.test/pr/7 runStep 1.10.0 && grep -q 'Updated existing staging sync PR https://example.test/pr/7' "$SB/out" \
85
+ && ! grep -q 'pr create' "$SB/gh.log"; then
86
+ ok "an open sync PR is updated, not duplicated"; else bad "an open sync PR is updated, not duplicated"; fi
87
+ : > "$SB/gh.log"
88
+ if runStep 1.9.0 && grep -q 'nothing to sync' "$SB/out" && [ ! -s "$SB/gh.log" ]; then
89
+ ok "skipped when staging is already at the version"; else bad "skipped when staging is already at the version"; fi
90
+ # A failed commit (no identity) must not push an unchanged branch or open a PR.
91
+ : > "$SB/gh.log"
92
+ git -C "$SB/origin.git" branch -D chore/sync-version-1.11.0 >/dev/null 2>&1 || true
93
+ (cd "$SB/work" && PATH="$SB/bin:$PATH" RUNNER_TEMP="$SB/rt" VERSION=1.11.0 GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1 \
94
+ GIT_AUTHOR_NAME='' GIT_COMMITTER_NAME='' EMAIL='' bash -c "$RUN") >"$SB/out" 2>&1
95
+ if ! git -C "$SB/origin.git" rev-parse -q --verify chore/sync-version-1.11.0 >/dev/null && [ ! -s "$SB/gh.log" ]; then
96
+ ok "failed commit pushes nothing and opens no PR"; else bad "failed commit pushes nothing and opens no PR"; fi
97
+
40
98
  has "deploy-pr has the 2A.5 pre-flight" "### 2A.5: Version Sync Pre-flight" "$DEP"
41
99
  has "deploy-pr pre-flight compares package.json" "package.json" "$DEP"
42
100
 
@@ -364,12 +364,18 @@ do_extract() {
364
364
  }
365
365
  }' "$BODY_FILE" > "$SECTION_FILE" 2>/dev/null || : > "$SECTION_FILE"
366
366
  # Failure scenario: present / absent when the section was found, unknown when the carrying comment or section is unreadable.
367
+ # Read from the prose (#4356): a `**Failure scenario**` anywhere on a line, or a line that starts (after list/quote/bold markup, any
368
+ # case) with a `failure scenario`, `scenario`, `repro`, `reproduction` or `steps to reproduce` label followed by `:` or a closing `**`.
369
+ # The content sits on that line or the next non-blank one; a mid-sentence use of the word is not a label.
367
370
  SCENARIO="unknown"
368
371
  if [ -s "$SECTION_FILE" ]; then
369
372
  SCENARIO=$(awk '
370
- tolower($0) ~ /\*\*failure scenario\*\*/ { s = $0; sub(/^.*\*\*[Ff]ailure [Ss]cenario\*\*[*:[:space:]]*/, "", s); gsub(/[[:space:]]/, "", s)
371
- if (s != "") { print "present"; found = 1; exit } else { want = 1; next } }
372
- want { t = $0; gsub(/[[:space:]]/, "", t); if (t != "") { print "present"; found = 1; exit } }
373
+ want { t = $0; gsub(/[[:space:]]/, "", t); if (t != "") { print "present"; found = 1; exit } next }
374
+ { l = tolower($0); s = "" }
375
+ l ~ /\*\*failure scenario\*\*/ { s = l; sub(/^.*\*\*failure scenario\*\*/, "", s) }
376
+ s == "" { m = l; sub(/^[[:space:]]*([-*+>_][[:space:]]*)*/, "", m)
377
+ if (match(m, /^(failure scenario|steps to reproduce|reproduction|repro|scenario)((\*\*|__)?[[:space:]]*:|\*\*|__)/)) s = substr(m, RLENGTH + 1); else next }
378
+ { gsub(/[*_:[:space:]]/, "", s); if (s != "") { print "present"; found = 1; exit } else want = 1 }
373
379
  END { if (!found) print "absent" }' "$SECTION_FILE")
374
380
  fi
375
381
  FIX_EFF=$(grep -iE '^[[:space:]]*(\*\*Fix efficacy\*\*:?|FIX_EFFICACY:)[[:space:]]*\**[[:space:]]*(fails|regresses)' "$SECTION_FILE" 2>/dev/null | head -n 1 \
@@ -531,10 +537,15 @@ SYMEOF
531
537
  case "$SEV" in MEDIUM|LOW|INFO) ROUTE="pre-existing" ;; esac
532
538
  fi
533
539
  fi
534
- # Gate 3 (concrete failure): MEDIUM or lower without a **Failure scenario** is speculative hardening, demoted to a NOTE.
535
- # CRITICAL/HIGH are exempt; an unreadable carrying comment keeps the finding.
540
+ # Gate 3 (concrete failure): MEDIUM or lower without a failure scenario in its prose is speculative hardening, demoted to a NOTE.
541
+ # CRITICAL/HIGH (any confidence) and CONFIRMED MEDIUM are exempt (#4356): a confirmed finding is filed through the classifier with its
542
+ # agent and prose as evidence. An unreadable carrying comment keeps the finding.
536
543
  if [ "$ROUTE" = "file" ] && [ "$SCEN" = "absent" ]; then
537
- case "$SEV" in MEDIUM|LOW|INFO) ROUTE="demoted-no-scenario" ;; esac
544
+ CONF=$(upper "$(printf '%s' "$F" | jq -r '.confidence // ""')")
545
+ case "$SEV" in
546
+ MEDIUM) [ "$CONF" = "CONFIRMED" ] || ROUTE="demoted-no-scenario" ;;
547
+ LOW|INFO) ROUTE="demoted-no-scenario" ;;
548
+ esac
538
549
  fi
539
550
  case "$ROUTE" in pre-existing) N_PREEXISTING=$((N_PREEXISTING + 1)) ;; demoted-no-scenario) N_NO_SCENARIO=$((N_NO_SCENARIO + 1)) ;; esac
540
551
  N_KEPT=$((N_KEPT + 1))
@@ -286,6 +286,45 @@ want "scope: HIGH near an added line" '.findings[] | select(.id == "N-2") | .sco
286
286
  want "scope: MEDIUM near an added line" '.findings[] | select(.id == "N-3") | .scope' findings.json "introduced"
287
287
  want "scope: every finding carries a valid scope" '[.findings[].scope | select(. == "introduced" or . == "pre-existing" or . == "unknown")] | length' findings.json "$(jq '.findings | length' "$WORK/out/findings.json")"
288
288
  want "scope: introduced count" '.counts.findings_introduced' findings.json "$(jq '[.findings[] | select(.scope == "introduced")] | length' "$WORK/out/findings.json")"
289
+ # Gate 3 reads the scenario from the prose and never demotes a CONFIRMED MEDIUM+ for a missing field (#4356); the damper stays for the rest
290
+ reset
291
+ for f in a b c d e f g h; do printf '{"filename":"%s.py","status":"modified","patch":"@@ -9,0 +10,1 @@\\n+new line"}\n' "$f"; done | jq -s . > "$FIX/pr-files.json"
292
+ cm MEMBER User "$(agent_body security "$SHA" '### C-1 confirmed
293
+ Real bug described in prose only.
294
+ ### C-2 confirmed lower case
295
+ Real bug described in prose only.
296
+ ### C-3 confirmed low
297
+ Prose only.
298
+ ### C-4 plain label
299
+ Failure scenario: a missing key crashes the loader
300
+ ### C-5 repro label
301
+ - **Repro**: call load() twice
302
+ ### C-6 no scenario
303
+ Some prose without the field.
304
+ ### C-7 mid-sentence word
305
+ no scenario written here
306
+ ### C-8 label on next line
307
+ **Steps to reproduce**:
308
+
309
+ 1. run it
310
+ ' '<!-- FINDING:C-1|CONFIRMED|MEDIUM|a.py:10|Confirmed medium without a scenario field -->
311
+ <!-- FINDING:C-2|confirmed|medium|b.py:10|Lower case confirmed medium without a scenario field -->
312
+ <!-- FINDING:C-3|CONFIRMED|LOW|c.py:10|Confirmed low without a scenario field -->
313
+ <!-- FINDING:C-4|LIKELY|MEDIUM|d.py:10|Plain failure scenario label in prose -->
314
+ <!-- FINDING:C-5|POSSIBLE|MEDIUM|e.py:10|Bold repro label in a list item -->
315
+ <!-- FINDING:C-6|POSSIBLE|MEDIUM|f.py:10|Possible medium with no scenario anywhere -->
316
+ <!-- FINDING:C-7|LIKELY|MEDIUM|g.py:10|Scenario word mid sentence only -->
317
+ <!-- FINDING:C-8|LIKELY|MEDIUM|h.py:10|Scenario content on the next line -->')"
318
+ flush; run "$GOOD" extract; rc_is "gate3 prose" "$RC" 0
319
+ want "gate3: CONFIRMED MEDIUM without a scenario is filed" '.findings[] | select(.id == "C-1") | [.scenario, .route] | join(",")' findings.json "absent,file"
320
+ want "gate3: lower-case confirmed medium is filed" '.findings[] | select(.id == "C-2") | .route' findings.json "file"
321
+ want "gate3: CONFIRMED LOW without a scenario is still demoted" '.findings[] | select(.id == "C-3") | .route' findings.json "demoted-no-scenario"
322
+ want "gate3: plain Failure scenario label counts" '.findings[] | select(.id == "C-4") | [.scenario, .route] | join(",")' findings.json "present,file"
323
+ want "gate3: bold Repro label counts" '.findings[] | select(.id == "C-5") | [.scenario, .route] | join(",")' findings.json "present,file"
324
+ want "gate3: POSSIBLE MEDIUM with no scenario is demoted" '.findings[] | select(.id == "C-6") | .route' findings.json "demoted-no-scenario"
325
+ want "gate3: a mid-sentence scenario word is not a label" '.findings[] | select(.id == "C-7") | [.scenario, .route] | join(",")' findings.json "absent,demoted-no-scenario"
326
+ want "gate3: label content on the next line counts" '.findings[] | select(.id == "C-8") | .scenario' findings.json "present"
327
+ want "gate3: demoted count" '.counts.notes_demoted_no_scenario' findings.json "3"
289
328
  # an unreadable files API classifies nothing as pre-existing
290
329
  reset; echo '[]' > "$FIX/pr-files.json"
291
330
  cm MEMBER User "$(agent_body security "$SHA" '### P-1 pre
@@ -625,6 +664,14 @@ want "lineage damps LIKELY medium" '.findings[] | select(.id == "L-1") | .class'
625
664
  want "no-scenario note" '.findings[] | select(.id == "L-2") | .disposition' classified.json "NOTE no-scenario"
626
665
  posted_has "no-scenario counter" "notes_demoted_no_scenario=1"
627
666
  posted_has "no-scenario listed" "- L-2 a.py:9 [LIKELY] Demoted for lack of a scenario: no-scenario"
667
+ # a CONFIRMED MEDIUM that Gate 3 kept (scenario absent, route file) is classified, never a no-scenario NOTE (#4356); marker fields unchanged
668
+ reset
669
+ mkfind K-1 CONFIRMED MEDIUM a.py 5 "Confirmed medium kept without a scenario field"
670
+ FJ=$(printf '%s' "$FJ" | jq -c '.scenario = "absent"'); putfind 1
671
+ run "$GOOD" classify; rc_is "confirmed medium classify" "$RC" 0
672
+ want "confirmed medium without scenario is an issue" '.findings[] | select(.id == "K-1") | .class' classified.json "ISSUE"
673
+ has "confirmed medium without scenario is filed" "FINDINGS_FILED=1"
674
+ posted_has "confirmed medium: marker field set unchanged" "<!-- FORGE:NOTE_DISPOSITION: notes_fixed=0 notes_listed=0 notes_dropped=0 findings_filed=1 inpr_fix=0 contract_demoted=0 findings_dropped_stale=0 findings_preexisting=0 introduced=0 notes_demoted_no_scenario=0 lineage=none classifier=script notes_reflag=0 notes_capped=0 reviewed_sha=${SHA} -->"
628
675
  # the PR-body issue in the context alone never sets lineage: only the invoker's --issue does (#3952 SEC-1/QA-2)
629
676
  reset; echo review-finding > "$FIX/labels"; mkctx 1 FULL "" 12
630
677
  mkfind L-1 LIKELY MEDIUM a.py 2 "Lineage damped medium finding"
@@ -151,6 +151,17 @@ touch $SENTINEL"; do
151
151
  else bad "staging injection not rejected: '$got'"; fi
152
152
  done
153
153
 
154
+ # Test 6b (forge#4360): `--delta <full sha>` is forwarded to the CLI; anything but a full lowercase hex SHA is dropped
155
+ DELTA_LINE=$(grep -m1 '^STAGING_DELTA=' "$STAGING")
156
+ if [ -n "$DELTA_LINE" ] && grep -qF '${STAGING_DELTA:+--delta "$STAGING_DELTA"}' "$STAGING"; then ok; else bad "staging --delta parse/forward not found"; fi
157
+ run_delta() { STAGING_ARGS_RAW="$1" "${BASH:-bash}" -c "$DELTA_LINE"$'\n''printf "%s" "$STAGING_DELTA"'; }
158
+ D40=0123456789abcdef0123456789abcdef01234567
159
+ got=$(run_delta "3401 --delta $D40"); if [ "$got" = "$D40" ]; then ok; else bad "staging --delta not parsed: '$got'"; fi
160
+ got=$(run_delta "3401 --delta $D40 --thorough"); if [ "$got" = "$D40" ]; then ok; else bad "staging --delta before a flag: '$got'"; fi
161
+ for v in "abc123" "${D40}0" "XYZ" ""; do
162
+ got=$(run_delta "3401 --delta $v"); if [ -z "$got" ]; then ok; else bad "staging --delta '$v' not dropped: '$got'"; fi
163
+ done
164
+
154
165
  # Test 7: Phase -1 stops on an empty PR_NUMBER before any `gh pr view` (gh falls back to the current-branch PR)
155
166
  GUARD=$(awk '/^ if \[ -z "\$PR_NUMBER" \]; then$/{p=1} p{print} p && /^ fi$/{exit}' "$SPEC")
156
167
  if [ -n "$GUARD" ]; then ok; else bad "Phase -1 empty-PR_NUMBER guard not found"; fi
@@ -167,7 +178,8 @@ done
167
178
  got=$(run_route "3401 --auto-merge --issue 3398"); if [ "$got" = REACHED ]; then ok; else bad "route stopped a valid PR number: '$got'"; fi
168
179
 
169
180
  # Test 8: the whole staging fence stops (non-zero, no REVIEW_ROUTE post) on a rejected string
170
- STAGING_FENCE=$(awk '/^```bash/{buf=""; in_f=1; next} /^```$/{if (in_f && buf ~ /STAGING_ARGS_RAW/) {printf "%s", buf; exit}; in_f=0; next} in_f{buf=buf $0 "\n"}' "$STAGING")
181
+ # Cut before the CLI resolution (#4359): the fence ends by running `forgedock review-staging`, which this test never runs.
182
+ STAGING_FENCE=$(awk '/^```bash/{buf=""; in_f=1; next} /^```$/{if (in_f && buf ~ /STAGING_ARGS_RAW/) {printf "%s", buf; exit}; in_f=0; next} in_f && /^FD_ROOT=/{if (buf ~ /STAGING_ARGS_RAW/) {printf "%s", buf; exit}} in_f{buf=buf $0 "\n"}' "$STAGING")
171
183
  if [ -n "$STAGING_FENCE" ]; then ok; else bad "staging fence not found"; fi
172
184
  run_stg_fence() { gen_script "$STAGING_FENCE" "$1" "$WORK/stgf.sh" 'echo REACHED'; : > "$ERR"; (cd "$WORK" && "${BASH:-bash}" "$WORK/stgf.sh" 2>"$ERR"); }
173
185
  got=$(run_stg_fence "\$(touch $SENTINEL)"); rc=$?
@@ -235,8 +235,8 @@ case "$(p4 FULL 'security auth')" in "PANEL_STATUS: SHORTFALL"*) ok ;; *) bad "u
235
235
  # a verdict without a findings block is undelivered (#3976): not complete, never counted
236
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
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"
238
+ # the 3C pending-agent count and the staging 6.75 delivery count (scripts/review-staging-post.sh) carry the same block lookahead as Phase 4
239
+ STAGING="$ROOT/scripts/review-staging-post.sh" # owner of the staging 6.75 delivery count (forge#4359)
240
240
  for spec in "$SPEC" "$STAGING"; do
241
241
  n=$(grep -F 'count "^(?=[\\s\\S]*<!-- REVIEW-FINDINGS-START -->[\\s\\S]*<!-- REVIEW-FINDINGS-END -->)<!-- FORGE:REVIEW-AGENT:${AGENT_DOMAIN} -->' "$spec" | wc -l)
242
242
  want=2; [ "$spec" = "$STAGING" ] && want=1
@@ -37,20 +37,12 @@ for p in infra security scraper; do
37
37
  if [ "$reads" -ge 1 ] && [ "$reads" = "$decls" ]; then ok; else bad "$p.md: $reads fences read SLICE_FILE, $decls re-declare it"; fi
38
38
  done
39
39
 
40
- # Staging slice path: every placeholder must be bound, and an unresolved SHA must yield a slash-free component.
41
- STAGING_SPEC="$ROOT/commands/review-pr-staging.md"
42
- if grep -qE '^REVIEW_SHA_SHORT=.*tr -cd .*unresolved' "$STAGING_SPEC"; then ok; else bad "staging spec does not define a hex-sanitized REVIEW_SHA_SHORT with an unresolved fallback"; fi
43
- if grep -qF 'Phase -1 `REVIEW_SHA_SHORT`' "$STAGING_SPEC"; then ok; else bad "staging dispatch text does not bind [REVIEW_SHA_SHORT] to Phase -1 REVIEW_SHA_SHORT"; fi
44
- SHORT_LINE=$(grep -E '^REVIEW_SHA_SHORT=' "$STAGING_SPEC" | head -n 1)
45
- for full in "" "n/x" "0123456789abcdef0123456789abcdef01234567"; do
46
- got=$(REVIEW_SHA_FULL="$full"; eval "$SHORT_LINE"; printf '%s' "$REVIEW_SHA_SHORT")
47
- case "$full" in
48
- 0123*) want="0123456" ;;
49
- *) want="unresolved" ;;
50
- esac
51
- if [ "$got" = "$want" ]; then ok; else bad "REVIEW_SHA_SHORT for '$full' = '$got' (want '$want')"; fi
52
- case "$got" in */*) bad "REVIEW_SHA_SHORT contains a slash for '$full'" ;; *) ok ;; esac
53
- done
40
+ # Staging slice path (#4359): `forgedock review-staging` renders [REVIEW_SHA_SHORT] into the briefs. It derives it only from a validated
41
+ # full head SHA and stops before any brief when none resolves, so the slice path can never carry a / or an unresolved sentinel.
42
+ STAGING_CLI="$ROOT/bin/review-staging.mjs"
43
+ if grep -qF 'const SHA_RE = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;' "$STAGING_CLI" && grep -qF 'if (!SHA_RE.test(head)) throw new Stop(1' "$STAGING_CLI"; then ok; else bad "staging CLI does not refuse an unvalidated head SHA"; fi
44
+ if grep -qF 'const sha7 = head.slice(0, 7);' "$STAGING_CLI" && grep -qF 'REVIEW_SHA_SHORT: sha7' "$STAGING_CLI"; then ok; else bad "staging CLI does not bind [REVIEW_SHA_SHORT] to the validated head's first 7 hex"; fi
45
+ if grep -q 'REVIEW_SHA_SHORT' "$ROOT/commands/review-pr-staging.md"; then bad "staging spec still binds [REVIEW_SHA_SHORT] by hand (the CLI renders the briefs)"; else ok; fi
54
46
 
55
47
  echo "review-pr-gate2.test.sh: passed=$PASS failed=$FAILN"
56
48
  [ "$FAILN" -eq 0 ]
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env bash
2
2
  # review-pr-staging-finding-filter.test.sh — fixtures for the anchored open-finding filter (forge#3800).
3
- # The spec's `# FINDING_PR_FILTER` line is extracted and run against issue shapes: the gate must keep a
3
+ # The staging gate's `# FINDING_PR_FILTER` line (scripts/review-staging-pre.sh since #4359) is extracted and run against issue shapes: the gate must keep a
4
4
  # hit only when its title suffix or `**Source**:` line names exactly PR #N; hidden markers never count.
5
5
  # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
6
6
  # SPDX-License-Identifier: AGPL-3.0-or-later
@@ -12,10 +12,10 @@ bad() { FAILN=$((FAILN+1)); echo "FAIL: $1"; }
12
12
  command -v jq >/dev/null 2>&1 || { echo "review-pr-staging-finding-filter.test.sh: jq missing, skipped"; exit 0; }
13
13
 
14
14
  extract() { grep -E '# FINDING_PR_FILTER$' "$1" | sed 's/[[:space:]]*# FINDING_PR_FILTER$//; s/^[[:space:]]*//'; }
15
- SPEC="$ROOT/commands/review-pr-staging.md"
15
+ SPEC="$ROOT/scripts/review-staging-pre.sh" # owner of the staging 0A gate (forge#4359)
16
16
  DEF="$(extract "$SPEC" | sort -u)"
17
- [ "$(printf '%s\n' "$DEF" | grep -c .)" -eq 1 ] && ok || bad "review-pr-staging.md must carry one identical FINDING_PR_FILTER definition"
18
- [ "$(extract "$ROOT/commands/deploy-info.md" | sort -u)" = "$DEF" ] && ok || bad "deploy-info.md filter differs from review-pr-staging.md"
17
+ [ "$(printf '%s\n' "$DEF" | grep -c .)" -eq 1 ] && ok || bad "review-staging-pre.sh must carry one identical FINDING_PR_FILTER definition"
18
+ [ "$(extract "$ROOT/commands/deploy-info.md" | sort -u)" = "$DEF" ] && ok || bad "deploy-info.md filter differs from review-staging-pre.sh"
19
19
  [ "$(grep -c -F 'FINDING_PR_FILTER' "$SPEC")" -ge 3 ] && ok || bad "filter must be referenced by all three queries"
20
20
  grep -q -F 'OVERRIDE: shipping with open findings' "$SPEC" && ok || bad "OVERRIDE text missing"
21
21
  eval "$DEF"
@@ -8,6 +8,12 @@
8
8
  set -uo pipefail
9
9
  ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
10
10
  SPEC="$ROOT/commands/review-pr-staging.md"
11
+ # Since #4359 the spec calls `forgedock review-staging`: the 0A/0B.1 blocks live in review-staging-pre.sh, the panel guard,
12
+ # 6.75 delivery and 6.5 verdict handling in review-staging-post.sh, and the 7A-7C wiring in bin/review-staging.mjs.
13
+ PRE="$ROOT/scripts/review-staging-pre.sh"
14
+ POST="$ROOT/scripts/review-staging-post.sh"
15
+ CLI="$ROOT/bin/review-staging.mjs"
16
+ blk() { awk -v n="$2" '$0 == "# BEGIN " n {f=1; next} $0 == "# END " n {f=0} f' "$1"; }
11
17
  TC="$ROOT/scripts/trusted-comments.sh"
12
18
  PASS=0; FAILN=0
13
19
  ok() { PASS=$((PASS+1)); }
@@ -24,10 +30,10 @@ chk HIGH 0; chk high 0; chk " High " 0; chk CRITICAL 0; chk critical 0
24
30
  chk MEDIUM 1; chk medium 1; chk LOW 1; chk INFO 1
25
31
  chk "" 0; chk "SEVERE" 0; chk "HIGH/MEDIUM" 0
26
32
 
27
- # (2) full-sha reviewed test: the _AGENT regex from the spec, run through the real trusted-comments predicate
28
- LINE=$(grep -E '^[[:space:]]*_AGENT=' "$SPEC" | head -1)
33
+ # (2) full-sha reviewed test: the _AGENT regex from the 0B.1 scope loop, run through the real trusted-comments predicate
34
+ LINE=$(grep -E '^[[:space:]]*_AGENT=' "$PRE" | head -1)
29
35
  RE=$(printf '%s' "$LINE" | sed -n 's/.*count "\(.*\)") ||.*/\1/p')
30
- if [ -n "$RE" ]; then ok; else bad "_AGENT regex not found in review-pr-staging.md"; fi
36
+ if [ -n "$RE" ]; then ok; else bad "_AGENT regex not found in review-staging-pre.sh"; fi
31
37
  FULL=0123456789abcdef0123456789abcdef01234567; SHORT=${FULL:0:7}
32
38
  RE=${RE//\\\\/\\}; RE=${RE//\$\{_HEAD\}/$FULL}
33
39
  FB=$'<!-- REVIEW-FINDINGS-START -->\nNo findings.\n<!-- REVIEW-FINDINGS-END -->'
@@ -41,7 +47,7 @@ cnt() { bash "$TC" count "$RE"; }
41
47
  [ "$(mk $'<!-- FORGE:REVIEW-AGENT:security -->\nReviewed-SHA: '"$FULL"$'\n\nno findings block here' Bot | cnt)" = 0 ] && ok || bad "comment without a findings block must not count (panel integrity)"
42
48
 
43
49
  # (3) _ROUTE regex: real mode on the final head counts, spec-evolution-blocked and a stale sha do not
44
- RLINE=$(grep -E '^[[:space:]]*_ROUTE=' "$SPEC" | head -1)
50
+ RLINE=$(grep -E '^[[:space:]]*_ROUTE=' "$PRE" | head -1)
45
51
  RRE=$(printf '%s' "$RLINE" | sed -n 's/.*count "\(.*\)") ||.*/\1/p'); RRE=${RRE//\\\\/\\}; RRE=${RRE//\$\{_HEAD7\}/$SHORT}
46
52
  rcnt() { bash "$TC" count "$RRE"; }
47
53
  [ "$(mk '<!-- FORGE:REVIEW_ROUTE mode=single-pr spec=review-pr.md sha='"$SHORT"' -->' Bot | rcnt)" = 1 ] && ok || bad "route marker on the final head should count"
@@ -50,14 +56,14 @@ rcnt() { bash "$TC" count "$RRE"; }
50
56
 
51
57
  # (4) spec text guards: agents may not be told to suppress findings; no $PWD tier in the classifier resolver; git reads are status-checked
52
58
  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
53
- if grep -qE "^DEPLOY_WIRING_FILES=.*git diff" "$SPEC"; then bad "DEPLOY_WIRING_FILES must not pipe git diff directly"; else ok; fi
54
- 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
59
+ if grep -qE "^DEPLOY_WIRING_FILES=.*git diff" "$SPEC" "$PRE"; then bad "DEPLOY_WIRING_FILES must not pipe git diff directly"; else ok; fi
60
+ if grep -q 'git diff-tree .*|| { SCOPE_MODE=full' "$PRE"; then ok; else bad "git diff-tree reads must fall back to full scope"; fi
55
61
  # 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
56
- 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
62
+ if grep -qF '"--scope-files", scopeFile' "$CLI" && grep -qF '"--prior-prs", priorPrs' "$CLI" && grep -qF '_scope.json' "$CLI" && grep -qF '"review-finalize.sh"' "$CLI"; then ok; else bad "the staging CLI must call review-finalize.sh classify with --scope-files and --prior-prs from the persisted 0B.1 scope"; fi
57
63
  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
58
64
  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
59
65
  grep -q "skip-dedup" "$ROOT/scripts/review-finalize.sh" && ok || bad "review-finalize.sh must skip a deduped finding"
60
- grep -q "review-prepare.sh" "$SPEC" && grep -q -- "--no-delta" "$SPEC" && ok || bad "staging must call review-prepare.sh --no-delta in 0C"
66
+ grep -q "review-prepare.sh" "$CLI" && grep -q -- "--no-delta" "$CLI" && ok || bad "staging must call review-prepare.sh --no-delta in 0C"
61
67
  # (7) fail-closed guard: BLOCKED when the classifier is unresolvable, no manual classifier mode, no classifier-unavailable fallback
62
68
  SPEC_FIN="$ROOT/scripts/review-finalize.sh" # review-pr.md delegates classification to review-finalize.sh, which owns the fail-closed BLOCKED line (#3841)
63
69
  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
@@ -68,7 +74,7 @@ done
68
74
  # (forge#3778) the spec carries a byte-identical stub per fence; the body lives in scripts/lib/fence-bootstrap.sh, which is SOURCED
69
75
  # in a fresh shell (the stub's locator + source line is executed for real, so the stub itself is covered too).
70
76
  [ "$(grep '# FENCE_BOOTSTRAP$' "$SPEC" | sort -u | wc -l | tr -d ' ')" -eq 1 ] && ok || bad "FENCE_BOOTSTRAP stubs must be byte-identical"
71
- [ "$(grep -c '# FENCE_BOOTSTRAP$' "$SPEC")" -ge 3 ] && ok || bad "bootstrap must be carried by 0B.1 and both 7B.5 fences"
77
+ [ "$(grep -c '# FENCE_BOOTSTRAP$' "$SPEC")" -ge 3 ] && ok || bad "bootstrap must be carried by the 7A, 7C and Phase 8 fences"
72
78
  grep '^type forge_resolve >/dev/null' "$SPEC" | sed 1q > "$TMP/boot.sh"
73
79
  grep '# FENCE_BOOTSTRAP$' "$SPEC" | sed 1q >> "$TMP/boot.sh"
74
80
  [ "$(wc -l < "$TMP/boot.sh")" -eq 2 ] && ok || bad "bootstrap stub (locator + source line) not found"
@@ -81,17 +87,17 @@ UNCOVERED=$(awk '
81
87
  infence { body = body "\n" $0 }' "$SPEC")
82
88
  [ -z "$UNCOVERED" ] && ok || bad "bash fences starting at line(s) ${UNCOVERED}consume cross-fence state without the FENCE_BOOTSTRAP block"
83
89
  # the persisted Phase 6.5 / 6.75 values: producers write them, consumers reload them validated
84
- grep -q 'test_gate_verdict' "$SPEC" && grep -q 'launched_review_agents' "$SPEC" && ok || bad "TEST_GATE_VERDICT and LAUNCHED_REVIEW_AGENTS must be persisted to the scratchpad"
90
+ grep -q 'test_gate_verdict' "$POST" && grep -q 'launched_review_agents' "$POST" && ok || bad "TEST_GATE_VERDICT and LAUNCHED_REVIEW_AGENTS must be persisted to the scratchpad"
85
91
  # PR-keyed (concurrent runs in one cwd must not share state) and reset by an executable command, not prose
86
- if grep -qE '\$_SD/(test_gate_verdict|test_gate_reason|launched_review_agents)|\.forge-scratch\}/launched_review_agents' "$SPEC"; then bad "gate/launched-agent scratch files must be keyed by \${PR_NUMBER}_"; else ok; fi
87
- grep -qF ': > "$_SD/${PR_NUMBER}_launched_review_agents"' "$SPEC" && grep -qF 'rm -f "$_SD/${PR_NUMBER}_test_gate_verdict" "$_SD/${PR_NUMBER}_test_gate_reason"' "$SPEC" && ok || bad "Phase 2 must truncate and Phase 6.5 must rm -f the PR-keyed scratch files in executable commands"
92
+ if grep -qE '\$_SD/(test_gate_verdict|test_gate_reason|launched_review_agents)|\.forge-scratch\}/launched_review_agents' "$SPEC" "$POST"; then bad "gate/launched-agent scratch files must be keyed by \${PR_NUMBER}_"; else ok; fi
93
+ grep -qF 'io.writeFile(launchedFile, "")' "$CLI" && grep -qF 'rm -f "${_SD:?}/${PR_NUMBER:?}_test_gate_verdict" "${_SD:?}/${PR_NUMBER:?}_test_gate_reason"' "$POST" && ok || bad "the pre-dispatch must truncate the launched-agent file and the test gate must rm -f the PR-keyed verdict files"
88
94
  if awk '/^### 7E:/,/^### 7F:/' "$SPEC" | grep -qF '{GH_REPO}'; then bad "7E must not use the {GH_REPO} template token"; else ok; fi
89
95
  # (8b) forge#3733: Phase 6.75 fails closed on an empty/absent launched-agent list or empty PR_NUMBER; Phase 6 fails closed on a bad SELECTED_AGENT_COUNT
90
- F675=$(awk '/^### Phase 6.75/,/^### 7A/' "$SPEC" | awk '/^```bash/{f=1;next} /^```/{if(f)exit} f')
96
+ F675=$(blk "$POST" delivery-check)
91
97
  GUARD_LN=$(printf '%s\n' "$F675" | grep -n 'launched-agent list is empty or unreadable' | head -1 | cut -d: -f1)
92
98
  LOOP_LN=$(printf '%s\n' "$F675" | grep -n '^for AGENT_DOMAIN in' | head -1 | cut -d: -f1)
93
99
  [ -n "$GUARD_LN" ] && [ -n "$LOOP_LN" ] && [ "$GUARD_LN" -lt "$LOOP_LN" ] && ok || bad "6.75 empty-list guard must precede the for AGENT_DOMAIN loop"
94
- printf '%s\n' "$F675" | sed -n "1,$((LOOP_LN-1))p" | grep -v '^[[:space:]]*gh ' > "$TMP/f675.sh"
100
+ { echo '_SD="$FORGE_SCRATCHPAD"'; printf '%s\n' "$F675" | sed -n "1,$((LOOP_LN-1))p" | grep -v '^[[:space:]]*gh '; } > "$TMP/f675.sh"
95
101
  mkdir -p "$TMP/sd9/empty" "$TMP/sd9/full" "$TMP/bin9"
96
102
  printf '#!/bin/sh\necho stub-value\n' > "$TMP/bin9/yq"; printf '#!/bin/sh\nexit 0\n' > "$TMP/bin9/gh"; chmod +x "$TMP/bin9/yq" "$TMP/bin9/gh"
97
103
  : > "$TMP/sd9/empty/42_launched_review_agents"
@@ -104,7 +110,7 @@ done
104
110
  OUT9=$(run675 "$TMP/sd9/full" 42)
105
111
  case "$OUT9" in *"launched-agent list is empty"*) bad "6.75 guard must not fire for a non-empty list (got '$OUT9')" ;; *) ok ;; esac
106
112
  # Phase 6 SELECTED_AGENT_COUNT validation: static order + behaviour of the reload/validate lines
107
- F6=$(awk '/Selected isolated reviewers/{exit} {print}' "$SPEC" | awk '/^```bash/{b=""; f=1; next} /^```/{f=0; next} f{b=b $0 "\n"; last=b} END{printf "%s", last}')
113
+ F6=$(blk "$POST" panel-guard)
108
114
  printf '%s' "$F6" | grep -qF 'SELECTED_COUNT_INVALID' && ok || bad "Phase 6 fence must validate SELECTED_AGENT_COUNT (SELECTED_COUNT_INVALID)"
109
115
  printf '%s' "$F6" | grep -F 'SELECTED_AGENT_COUNT=' | grep -F 'launched_review_agents' > "$TMP/sel.sh"
110
116
  printf '%s\n' 'SELECTED_COUNT_INVALID=false' "$(printf '%s' "$F6" | grep -F 'case "${SELECTED_AGENT_COUNT:-}"')" > "$TMP/selcase.sh"
@@ -140,20 +146,20 @@ case "$OUT8" in *"PR=|"*) ok ;; *) bad "non-numeric staging_pr_number must be re
140
146
  OUT8=$( ( unset ALL_PR_NUMBERS; PR_NUMBER=99; export PATH="$TMP/bin:$PATH" FORGE_SCRATCHPAD="$TMP/sd8/full" FORGEDOCK_HOME="$ROOT"; . "$TMP/boot.sh" >/dev/null 2>&1; echo "PR=$PR_NUMBER|ALL=${ALL_PR_NUMBERS:-}" ) )
141
147
  [ "$OUT8" = "PR=99|ALL=" ] && ok || bad "in-shell PR_NUMBER must not be overridden by the scratch file (got '$OUT8')"
142
148
  # 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
143
- 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"
144
- 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
149
+ grep -q 'staging_pr_number' "$CLI" && grep -q '_bundle-prs.txt' "$PRE" && ok || bad "Phase -1 / 0A must persist staging_pr_number and _bundle-prs.txt"
150
+ if grep -v '^[[:space:]]*#' "$PRE" "$POST" | grep -qE '(^|[^$]){GH_REPO}'; then bad "the 0A/0B.1 and post-dispatch scripts must not use the {GH_REPO} template token"; else ok; fi
145
151
  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
146
152
  # (7) reviewer templates bind the full head SHA via the dispatch placeholder, not an orchestrator-only shell var
147
153
  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
148
154
  [ "$(grep -cE '^Reviewed-SHA: \[REVIEW_SHA\]$' "$SPEC")" -ge 5 ] && ok || bad "all five reviewer templates must use Reviewed-SHA: [REVIEW_SHA]"
149
155
  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
150
- 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]"
156
+ grep -qiF 'substitute `[REVIEW_SHA]`' "$SPEC" && ok || bad "dispatch text must bind [REVIEW_SHA]"
151
157
  # (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)
152
158
  # (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.
153
159
  # Fail-closed cases first: every FULL / error / untrusted path must leave the PR's commits UNREVIEWED (empty credit).
154
160
  if command -v git >/dev/null 2>&1; then
155
- awk '/^# BEGIN bundle-delta-credit$/{f=1;next} /^# END bundle-delta-credit$/{f=0} f' "$SPEC" > "$TMP/delta-fn.sh"
156
- [ -s "$TMP/delta-fn.sh" ] && ok || bad "bundle-delta-credit block not found in the spec"
161
+ awk '/^# BEGIN bundle-delta-credit$/{f=1;next} /^# END bundle-delta-credit$/{f=0} f' "$PRE" > "$TMP/delta-fn.sh"
162
+ [ -s "$TMP/delta-fn.sh" ] && ok || bad "bundle-delta-credit block not found in review-staging-pre.sh"
157
163
  RDS="$ROOT/scripts/review-delta.sh"
158
164
  export GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null GIT_CONFIG_NOSYSTEM=1
159
165
  export GIT_AUTHOR_NAME=t GIT_AUTHOR_EMAIL=t@example.invalid GIT_COMMITTER_NAME=t GIT_COMMITTER_EMAIL=t@example.invalid
@@ -227,25 +233,25 @@ mkrepo9; commit9 pr.txt pr1b pr1b; R=$(git -C "$W9" rev-parse HEAD); panel9 "$R"
227
233
  OUT9=$(credit9 "$H"); N9=$(printf '%s' "${OUT9#*|}" | wc -w)
228
234
  [ "${OUT9%%|*}" = 0 ] && [ "$N9" -eq 2 ] && case "$OUT9" in *"$(short9 HEAD)"*|*"$(short9 HEAD~1)"*) false ;; *) true ;; esac && ok || bad "partial review must credit exactly the 2 reviewed commits (got '$OUT9')"
229
235
  # spec text guards: the resolver is reused, never copied; carried record accepted; the unchanged cross-PR and merge-resolution paths are still present
230
- grep -q 'review-delta.sh' "$SPEC" && grep -q 'REVIEW-CARRIED' "$SPEC" && ok || bad "spec must call review-delta.sh and accept the REVIEW-CARRIED record"
231
- if grep -qE 'merge-base --is-ancestor.*REVIEWED_SHA_STALE|diff_shape\(\)' "$SPEC"; then bad "spec must not carry a second copy of the resolver logic"; else ok; fi
232
- grep -qF 'printf '"'"'%s\n'"'"' "$_FILES" | sort -u | sed "s|^|$pr |" >> "$_PF"' "$SPEC" && grep -qF 'git diff-tree --no-commit-id --name-only -r --cc "$c"' "$SPEC" && ok || bad "CROSS_PR_FILES accumulation and --cc merge scoping must stay intact"
236
+ grep -q 'review-delta.sh' "$PRE" && grep -q 'REVIEW-CARRIED' "$PRE" && ok || bad "review-staging-pre.sh must call review-delta.sh and accept the REVIEW-CARRIED record"
237
+ if grep -qE 'merge-base --is-ancestor.*REVIEWED_SHA_STALE|diff_shape\(\)' "$SPEC" "$PRE"; then bad "spec must not carry a second copy of the resolver logic"; else ok; fi
238
+ grep -qF 'printf '"'"'%s\n'"'"' "$_FILES" | sort -u | sed "s|^|$pr |" >> "$_PF"' "$PRE" && grep -qF 'git diff-tree --no-commit-id --name-only -r --cc "$c"' "$PRE" && ok || bad "CROSS_PR_FILES accumulation and --cc merge scoping must stay intact"
233
239
  [ "$(grep -c '# FENCE_BOOTSTRAP$' "$SPEC" | head -1)" -ge 3 ] && ok || bad "FENCE_BOOTSTRAP stubs must remain"
234
240
  # an unresolvable helper fails the fence closed (exit 1), never an unsourced bootstrap
235
241
  OUT8=$( ( unset FORGEDOCK_HOME FORGE_HOME FORGE_ROOT CLAUDE_PLUGIN_ROOT CLAUDE_CONFIG_DIR CODEX_HOME FORGE_SOURCED_ROOT FORGEDOCK_MARKETPLACE; export HOME="$TMP/nohome"; cd "$TMP" && bash -c "$(cat "$TMP/boot.sh"); echo reached" ) 2>&1 )
236
242
  case "$OUT8" in *BLOCKED*) case "$OUT8" in *reached*) bad "unresolved bootstrap helper must stop the fence" ;; *) ok ;; esac ;; *) bad "unresolved bootstrap helper must print BLOCKED (got '$OUT8')" ;; esac
237
243
  # a stale-head PR must not credit its own merge commit (it contains the post-review commits): the DELTA branch only appends DELTA_COMMITS
238
- if awk '/^bundle_delta_credit\(\)/,/^\}/' "$SPEC" | grep -q 'mergeCommit'; then bad "the DELTA path must not credit the PR merge commit"; else ok; fi
244
+ if awk '/^bundle_delta_credit\(\)/,/^\}/' "$PRE" | grep -q 'mergeCommit'; then bad "the DELTA path must not credit the PR merge commit"; else ok; fi
239
245
  # (10) fail-closed gate verdict (forge#3734): an empty/missing/invalid persisted verdict must never become SKIP
240
- awk '/^\*\*Verdict handling\*\*:/{f=1;next} f&&/^```bash/{g=1;next} g&&/^```/{exit} g' "$SPEC" | grep -v '# FENCE_BOOTSTRAP$' > "$TMP/vh.sh"
246
+ blk "$POST" test-gate-verdict > "$TMP/vh.sh"
241
247
  [ -s "$TMP/vh.sh" ] && ok || bad "could not extract the Phase 6.5 verdict-handling fence"
242
- if awk '/^ \*\)/,/;;/' "$SPEC" | grep -q 'TEST_GATE_VERDICT="SKIP"'; then bad "catch-all arm must not set TEST_GATE_VERDICT=SKIP"; else ok; fi
243
- grep -qF 'missing/invalid' "$SPEC" && ok || bad "spec must document missing/invalid gate-verdict handling"
244
- if grep -F 'FORGE:TEST_GATE:RESULT=' "$SPEC" | grep -q 'tail -1 || echo "SKIP"'; then bad "producer must not coerce an absent marker to SKIP"; else ok; fi
248
+ if awk '/^ \*\)/,/;;/' "$POST" | grep -q 'TEST_GATE_VERDICT="SKIP"'; then bad "catch-all arm must not set TEST_GATE_VERDICT=SKIP"; else ok; fi
249
+ grep -qF 'missing/invalid' "$SPEC" && grep -qF 'missing/invalid' "$POST" && ok || bad "spec must document missing/invalid gate-verdict handling"
250
+ if grep -F 'FORGE:TEST_GATE:RESULT=' "$POST" | grep -q 'tail -1 || echo "SKIP"'; then bad "producer must not coerce an absent marker to SKIP"; else ok; fi
245
251
  vh10() { # $1 posture, $2 persisted verdict content ('-' = no file), $3 env verdict (optional)
246
252
  local sd="$TMP/vh10.$RANDOM"; mkdir -p "$sd"
247
253
  [ "$2" = "-" ] || printf '%s\n' "$2" > "$sd/5_test_gate_verdict"
248
- ( export _SD="$sd" PR_NUMBER=5 GH_REPO=o/r DEFAULT_BRANCH=main ALL_PR_NUMBERS="5" CONFIG_FILE=/dev/null P10="$1"
254
+ ( export _SD="$sd" PR_NUMBER=5 GH_REPO=o/r DEFAULT_BRANCH=main ALL_PR_NUMBERS="5" CONFIG_FILE=/dev/null P10="$1" GATE_POSTURE="$1" OVERRIDE_PHRASE="OVERRIDE: x" GH_FLAG="-R o/r"
249
255
  [ -z "${3:-}" ] || export TEST_GATE_VERDICT="$3"
250
256
  forge_resolve() { :; }; yq() { case "$*" in *posture*) echo "$P10" ;; *) echo "OVERRIDE: x" ;; esac; }; gh() { return 0; }
251
257
  . "$TMP/vh.sh" >"$sd/out" 2>&1; echo "rc=0" >> "$sd/out" ) ; local rc=$?
@@ -280,12 +286,12 @@ gh11() {
280
286
  *) return 1 ;;
281
287
  esac
282
288
  }
283
- [ "$(grep -c '^# BEGIN bundle-pr-kind$' "$SPEC")" -eq 2 ] && [ "$(awk '/^# BEGIN bundle-pr-kind$/{f=1;n++} f{print > ("'"$TMP"'/kind" n ".sh")} /^# END bundle-pr-kind$/{f=0}' "$SPEC"; cmp -s "$TMP/kind1.sh" "$TMP/kind2.sh" && echo same)" = same ] && ok || bad "0A and 0B.1 must carry identical bundle_pr_kind blocks"
284
- awk '/^# BEGIN bundle-pr-set$/{f=1;next} /^# END bundle-pr-set$/{f=0} f' "$SPEC" | sed 's/{GH_REPO}/o\/r/g' > "$TMP/prset.sh"
289
+ [ "$(grep -c '^# BEGIN bundle-pr-kind$' "$PRE")" -eq 2 ] && [ "$(awk '/^# BEGIN bundle-pr-kind$/{f=1;n++} f{print > ("'"$TMP"'/kind" n ".sh")} /^# END bundle-pr-kind$/{f=0}' "$PRE"; cmp -s "$TMP/kind1.sh" "$TMP/kind2.sh" && echo same)" = same ] && ok || bad "0A and 0B.1 must carry identical bundle_pr_kind blocks"
290
+ awk '/^# BEGIN bundle-pr-set$/{f=1;next} /^# END bundle-pr-set$/{f=0} f' "$PRE" | sed 's/{GH_REPO}/o\/r/g' > "$TMP/prset.sh"
285
291
  [ -s "$TMP/prset.sh" ] && ok || bad "bundle-pr-set block not found in Phase 0A"
286
- OUT11=$( gh() { gh11 "$@"; }; BUNDLE_PRS="5 6 28668 77 90"; MERGE_PRS="90"; . "$TMP/prset.sh"; echo $ALL_PR_NUMBERS "|$SKIPPED_ISSUES" )
292
+ OUT11=$( gh() { gh11 "$@"; }; GH_REPO=o/r; BUNDLE_PRS="5 6 28668 77 90"; MERGE_PRS="90"; . "$TMP/prset.sh"; echo $ALL_PR_NUMBERS "|$SKIPPED_ISSUES" )
287
293
  [ "$OUT11" = "5 6 77 90 | 28668" ] && ok || bad "0A must drop the confirmed issue and keep PRs, merge PRs and unclassifiable numbers (got '$OUT11')"
288
- awk '/^# BEGIN bundle-scope-loop$/{f=1;next} /^# END bundle-scope-loop$/{f=0} f' "$SPEC" > "$TMP/loop.sh"
294
+ awk '/^# BEGIN bundle-scope-loop$/{f=1;next} /^# END bundle-scope-loop$/{f=0} f' "$PRE" > "$TMP/loop.sh"
289
295
  [ -s "$TMP/loop.sh" ] && ok || bad "bundle-scope-loop block not found in 0B.1"
290
296
  scope11() { # $1 = ALL_PR_NUMBERS; prints "<SCOPE_MODE> <REVIEWED_PR_COUNT>" then the loop output
291
297
  ( gh() { gh11 "$@"; }; bundle_delta_credit() { DELTA_COMMITS=""; DELTA_FULL=0; }; . "$TMP/kind1.sh"