forgedock 1.29.1 → 1.31.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 (166) hide show
  1. package/bin/engine/outcome-codes.mjs +5 -2
  2. package/bin/engine/phases.mjs +20 -3
  3. package/bin/engine/runlog.mjs +82 -12
  4. package/bin/engine/telemetry-seam.mjs +11 -4
  5. package/bin/engine/telemetry-seam.test.mjs +18 -0
  6. package/bin/engine-cli.mjs +453 -66
  7. package/bin/engine.mjs +50 -38
  8. package/bin/forgedock.mjs +35 -3
  9. package/bin/gc.mjs +50 -17
  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 +356 -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 +278 -22
  21. package/bin/lean/conformance.test.mjs +251 -5
  22. package/bin/lean/context.mjs +133 -55
  23. package/bin/lean/context.test.mjs +175 -9
  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 +55 -19
  31. package/bin/lean/enumerating.mjs +189 -0
  32. package/bin/lean/enumerating.test.mjs +97 -0
  33. package/bin/lean/explore.mjs +47 -0
  34. package/bin/lean/explore.test.mjs +61 -0
  35. package/bin/lean/facts.mjs +84 -0
  36. package/bin/lean/facts.test.mjs +66 -0
  37. package/bin/lean/fixround.mjs +90 -0
  38. package/bin/lean/fixround.test.mjs +117 -0
  39. package/bin/lean/heavy.mjs +35 -2
  40. package/bin/lean/heavy.test.mjs +32 -2
  41. package/bin/lean/lean.mjs +799 -127
  42. package/bin/lean/lean.test.mjs +1749 -37
  43. package/bin/lean/ledger.mjs +5 -3
  44. package/bin/lean/ledger.test.mjs +18 -0
  45. package/bin/lean/light.mjs +135 -0
  46. package/bin/lean/light.test.mjs +173 -0
  47. package/bin/lean/limits.test.mjs +36 -1
  48. package/bin/lean/orchestrate.mjs +341 -99
  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 +180 -0
  54. package/bin/lean/precheck.test.mjs +205 -0
  55. package/bin/lean/prefilter.mjs +180 -9
  56. package/bin/lean/prefilter.test.mjs +175 -3
  57. package/bin/lean/preflight.mjs +238 -0
  58. package/bin/lean/preflight.test.mjs +108 -0
  59. package/bin/lean/probe.mjs +297 -0
  60. package/bin/lean/probe.test.mjs +210 -0
  61. package/bin/lean/prompts.mjs +134 -12
  62. package/bin/lean/recovery.test.mjs +75 -0
  63. package/bin/lean/render.mjs +7 -3
  64. package/bin/lean/resume.mjs +67 -0
  65. package/bin/lean/resume.test.mjs +84 -0
  66. package/bin/lean/review-trigger.mjs +12 -0
  67. package/bin/lean/review-trigger.test.mjs +17 -1
  68. package/bin/lean/routing.mjs +74 -0
  69. package/bin/lean/routing.test.mjs +67 -0
  70. package/bin/lean/sched.test.mjs +127 -1
  71. package/bin/lean/schedd.mjs +55 -9
  72. package/bin/lean/schedd.test.mjs +149 -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 +385 -0
  78. package/bin/lean/sequence.test.mjs +373 -0
  79. package/bin/lean/testmap.mjs +43 -6
  80. package/bin/lean/testmap.test.mjs +38 -1
  81. package/bin/lean/toolchain.mjs +60 -15
  82. package/bin/lean/toolchain.test.mjs +120 -3
  83. package/bin/lean/toolstats.mjs +72 -0
  84. package/bin/lean/toolstats.test.mjs +47 -0
  85. package/bin/lean/verifier.mjs +232 -0
  86. package/bin/lean/verifier.test.mjs +163 -0
  87. package/bin/lean/verify.mjs +77 -10
  88. package/bin/lean/verify.test.mjs +74 -3
  89. package/bin/review-staging.mjs +533 -0
  90. package/bin/runner.mjs +82 -13
  91. package/bin/stats/aggregate.mjs +87 -2
  92. package/bin/stats/efficiency.mjs +319 -0
  93. package/bin/stats/efficiency.test.mjs +233 -0
  94. package/bin/stats/github-source.mjs +8 -3
  95. package/bin/stats/index.mjs +69 -6
  96. package/bin/stats/local-source.mjs +86 -3
  97. package/bin/stats/query.mjs +48 -0
  98. package/bin/stats/render.mjs +22 -0
  99. package/bin/stats/stats.test.mjs +224 -5
  100. package/commands/orchestrate.md +6 -2
  101. package/commands/review-pr-agents/protocols.md +1 -1
  102. package/commands/review-pr-staging.md +84 -831
  103. package/commands/review-pr.md +1 -1
  104. package/commands/work-on/build/implement.md +1 -0
  105. package/commands/work-on/build/validate.md +11 -1
  106. package/docs/CONFIG.md +88 -6
  107. package/docs/OUTCOME-CODES.md +7 -4
  108. package/docs/RELEASE-NOTES.md +10 -0
  109. package/docs/STATS.md +53 -1
  110. package/docs/TELEMETRY.md +1 -1
  111. package/docs/spec/review-protocol.md +1 -1
  112. package/package.json +1 -1
  113. package/scripts/acceptance-red-green.sh +13 -4
  114. package/scripts/acceptance-red-green.test.sh +14 -0
  115. package/scripts/ci-local/lib.mjs +20 -0
  116. package/scripts/ci-local/plan.mjs +2 -12
  117. package/scripts/ci-local/scoping.mjs +10 -1
  118. package/scripts/ci-local.test.sh +2 -0
  119. package/scripts/fixtures/apishape/other/go-change/base/x.go +3 -0
  120. package/scripts/fixtures/apishape/other/go-change/head/x.go +3 -0
  121. package/scripts/fixtures/apishape/python/generator-function/base/seq.py +3 -0
  122. package/scripts/fixtures/apishape/python/generator-function/head/seq.py +2 -0
  123. package/scripts/fixtures/apishape/python/ichunked/base/more.py +17 -0
  124. package/scripts/fixtures/apishape/python/ichunked/head/more.py +20 -0
  125. package/scripts/fixtures/apishape/python/private-edit/base/calc.py +6 -0
  126. package/scripts/fixtures/apishape/python/private-edit/head/calc.py +9 -0
  127. package/scripts/fixtures/apishape/python/removed/base/ops.py +6 -0
  128. package/scripts/fixtures/apishape/python/removed/head/ops.py +2 -0
  129. package/scripts/fixtures/apishape/python/renamed/base/text.py +2 -0
  130. package/scripts/fixtures/apishape/python/renamed/head/text.py +2 -0
  131. package/scripts/fixtures/apishape/python/signature/base/shapes.py +2 -0
  132. package/scripts/fixtures/apishape/python/signature/head/shapes.py +2 -0
  133. package/scripts/fixtures/apishape/python/sync-async/base/client.py +7 -0
  134. package/scripts/fixtures/apishape/python/sync-async/head/client.py +7 -0
  135. package/scripts/fixtures/apishape/ts/generator-function/base/seq.js +3 -0
  136. package/scripts/fixtures/apishape/ts/generator-function/head/seq.js +5 -0
  137. package/scripts/fixtures/apishape/ts/private-edit/base/calc.ts +7 -0
  138. package/scripts/fixtures/apishape/ts/private-edit/head/calc.ts +9 -0
  139. package/scripts/fixtures/apishape/ts/removed/base/ops.ts +7 -0
  140. package/scripts/fixtures/apishape/ts/removed/head/ops.ts +3 -0
  141. package/scripts/fixtures/apishape/ts/renamed/base/text.ts +3 -0
  142. package/scripts/fixtures/apishape/ts/renamed/head/text.ts +3 -0
  143. package/scripts/fixtures/apishape/ts/signature/base/shapes.ts +3 -0
  144. package/scripts/fixtures/apishape/ts/signature/head/shapes.ts +3 -0
  145. package/scripts/fixtures/apishape/ts/sync-async/base/client.mjs +5 -0
  146. package/scripts/fixtures/apishape/ts/sync-async/head/client.mjs +5 -0
  147. package/scripts/forge-root.test.sh +2 -2
  148. package/scripts/publish-staging-sync.test.sh +62 -4
  149. package/scripts/review-finalize.sh +17 -6
  150. package/scripts/review-finalize.test.sh +47 -0
  151. package/scripts/review-pr-arguments.test.sh +13 -1
  152. package/scripts/review-pr-delta-wiring.test.sh +2 -2
  153. package/scripts/review-pr-gate2.test.sh +6 -14
  154. package/scripts/review-pr-staging-finding-filter.test.sh +4 -4
  155. package/scripts/review-pr-staging-scope.test.sh +39 -33
  156. package/scripts/review-prepare.sh +73 -6
  157. package/scripts/review-prepare.test.sh +85 -0
  158. package/scripts/review-staging-post.sh +293 -0
  159. package/scripts/review-staging-post.test.sh +143 -0
  160. package/scripts/review-staging-pre.sh +401 -0
  161. package/scripts/review-staging-pre.test.sh +150 -0
  162. package/scripts/symbol-index.mjs +26 -7
  163. package/scripts/symbol-index.py +55 -9
  164. package/scripts/test-map.sh +63 -17
  165. package/scripts/test-map.test.sh +55 -0
  166. package/scripts/trusted-comments.test.sh +1 -1
@@ -0,0 +1,293 @@
1
+ #!/usr/bin/env bash
2
+ # review-staging-post.sh — deterministic post-dispatch checks of the staging->main review (forge#4359): the incomplete-panel guard,
3
+ # Phase 6.75 agent delivery verification and the Phase 6.5 test-gate verdict handling, moved out of commands/review-pr-staging.md.
4
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
5
+ # SPDX-License-Identifier: AGPL-3.0-or-later
6
+ #
7
+ # Usage: review-staging-post.sh delivery --repo R --pr N --sd DIR [--head SHA]
8
+ # review-staging-post.sh test-gate --repo R --pr N --sd DIR --output FILE --prs-file F
9
+ # [--default BRANCH] [--config forge.yaml] [--posture blocking|advisory] [--override-phrase TEXT]
10
+ # delivery counts trusted, current-head FORGE:REVIEW-AGENT comments (findings block required) for every domain listed in
11
+ # DIR/<N>_launched_review_agents. A shortfall labels the PR review-degraded + needs-human and posts
12
+ # FORGE:GATE_FAILURE:TYPE=review-panel-integrity; an empty/unreadable list or a missing domain is a delivery failure.
13
+ # Exit 0 = every launched reviewer delivered, 1 = not.
14
+ # test-gate reads the saved /test-gate output FILE (the last FORGE:TEST_GATE:RESULT= marker; an absent marker stays empty and
15
+ # fails closed as BLOCK, never SKIP), applies the trusted override, the posture and the FORGE:GATE_PASS /
16
+ # FORGE:GATE_FAILURE test-gate comments, and persists DIR/<N>_test_gate_verdict and _reason.
17
+ # Exit 0 = continue, 1 = RESULT: BLOCK DEPLOY.
18
+ # Exit: 2 on a usage error.
19
+ #
20
+ # The `# BEGIN <name>` ... `# END <name>` blocks below are the spec's former fences, copied without their forge_resolve locator
21
+ # stubs (this script resolves helpers once, below). Helpers (trusted-comments.sh) resolve through forge_resolve from trusted
22
+ # install roots only, never the working directory or the repo under review.
23
+ set -u
24
+
25
+ CMD="${1:-}"; [ $# -gt 0 ] && shift
26
+ GH_REPO=""; PR_NUMBER=""; SD=""; HEAD_ARG=""; OUTPUT_FILE=""; PRS_FILE=""; CONFIG_ARG=""; POSTURE_ARG=""; PHRASE_ARG=""; DEFAULT_BRANCH="main"
27
+ while [ $# -gt 0 ]; do
28
+ [ $# -ge 2 ] || { echo "review-staging-post: $1 requires a value" >&2; exit 2; }
29
+ case "$1" in
30
+ --repo) GH_REPO="$2" ;;
31
+ --pr) PR_NUMBER="$2" ;;
32
+ --sd) SD="$2" ;;
33
+ --head) HEAD_ARG="$2" ;;
34
+ --output) OUTPUT_FILE="$2" ;;
35
+ --prs-file) PRS_FILE="$2" ;;
36
+ --config) CONFIG_ARG="$2" ;;
37
+ --posture) POSTURE_ARG="$2" ;;
38
+ --override-phrase) PHRASE_ARG="$2" ;;
39
+ --default) DEFAULT_BRANCH="$2" ;;
40
+ *) echo "review-staging-post: unknown argument: $1" >&2; exit 2 ;;
41
+ esac
42
+ shift 2
43
+ done
44
+ case "$CMD" in delivery|test-gate) ;; *) echo "review-staging-post: unknown subcommand '${CMD}' (delivery|test-gate)" >&2; exit 2 ;; esac
45
+ case "$PR_NUMBER" in ''|*[!0-9]*) echo "review-staging-post: --pr must be numeric" >&2; exit 2 ;; esac
46
+ case "$GH_REPO" in *[!A-Za-z0-9._/-]*|*/*/*|/*|*/|'') echo "review-staging-post: invalid --repo" >&2; exit 2 ;; */*) ;; *) echo "review-staging-post: --repo must be owner/name" >&2; exit 2 ;; esac
47
+ [ -n "$SD" ] || { echo "review-staging-post: --sd is required" >&2; exit 2; }
48
+ mkdir -p "$SD" || exit 2
49
+ export FORGE_SCRATCHPAD="$SD"
50
+ _SD="$SD"
51
+ # A malformed --head is dropped: the blocks re-resolve it and refuse on empty (fail closed).
52
+ REVIEW_SHA_FULL="$HEAD_ARG"
53
+ case "$REVIEW_SHA_FULL" in *[!0-9a-f]*|"") REVIEW_SHA_FULL="" ;; *) [ "${#REVIEW_SHA_FULL}" -eq 40 ] || [ "${#REVIEW_SHA_FULL}" -eq 64 ] || REVIEW_SHA_FULL="" ;; esac
54
+ unset LAUNCHED_REVIEW_AGENTS SELECTED_AGENT_COUNT TEST_GATE_VERDICT TEST_GATE_REASON
55
+
56
+ # --- trusted helper resolution (install roots only; never cwd) ---
57
+ # shellcheck source=/dev/null # the resolver lib is found at runtime in the trusted install root
58
+ type forge_resolve >/dev/null 2>&1 || { setopt nonomatch 2>/dev/null || true; _SELF_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." 2>/dev/null && pwd -P)"; for _r in "$(cygpath -u "${FORGEDOCK_HOME:-}" 2>/dev/null || printf %s "${FORGEDOCK_HOME:-}")" "${CLAUDE_PLUGIN_ROOT:-}" "${FORGE_ROOT:-}" "${FORGE_HOME:-}" "$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null | sed 's|/commands/work-on\.md$||')" "$(head -n 1 "${CODEX_HOME:-$HOME/.codex}/forge-home" 2>/dev/null)" "$HOME/.claude/plugins/marketplaces/forgedock" "${CLAUDE_CONFIG_DIR:-$HOME/.claude}"/plugins/cache/forgedock/forgedock/* "$HOME/.claude"/plugins/cache/forgedock/forgedock/* "$_SELF_ROOT"; do case "$_r" in /*) [ -f "$_r/scripts/lib/forge-resolve.sh" ] && { . "$_r/scripts/lib/forge-resolve.sh"; break; };; esac; done; type forge_resolve >/dev/null 2>&1 || forge_resolve() { :; }; }
59
+
60
+ if [ "$CMD" = delivery ]; then
61
+ echo "=== Incomplete-panel guard and Phase 6.75: verify agent delivery ==="
62
+ # BEGIN panel-guard
63
+ # Each Bash call is a fresh shell: re-resolve the full head SHA if it is not set, and refuse when it is not a commit id
64
+ # (an empty SHA would make the Reviewed-SHA match accept any head).
65
+ [ -n "${REVIEW_SHA_FULL:-}" ] || REVIEW_SHA_FULL=$(gh pr view "$PR_NUMBER" -R "$GH_REPO" --json headRefOid --jq '.headRefOid' 2>/dev/null || echo "")
66
+ case "$REVIEW_SHA_FULL" in *[!0-9a-f]*|"") REVIEW_SHA_FULL="" ;; *) [ "${#REVIEW_SHA_FULL}" -eq 40 ] || [ "${#REVIEW_SHA_FULL}" -eq 64 ] || REVIEW_SHA_FULL="" ;; esac
67
+ TRUSTED_SCRIPT=$(forge_resolve trusted-comments.sh)
68
+ ACTUAL_AGENT_COUNT=0
69
+ if [ -n "$REVIEW_SHA_FULL" ] && [ -n "$TRUSTED_SCRIPT" ]; then
70
+ # Only TRUSTED, current-head reviewer comments count, over every page of comments. Unique domains, so a duplicate does not inflate the count.
71
+ ACTUAL_AGENT_COUNT=$(gh api --paginate "repos/${GH_REPO}/issues/${PR_NUMBER}/comments" 2>/dev/null \
72
+ | bash "$TRUSTED_SCRIPT" bodies "^<!-- FORGE:REVIEW-AGENT:[a-z0-9-]+ -->[\\s\\S]*(^|\\n)Reviewed-SHA:[ ]${REVIEW_SHA_FULL}(\\r?\\n|$)" 2>/dev/null \
73
+ | jq -r 'scan("^<!-- FORGE:REVIEW-AGENT:([a-z0-9-]+) -->") | .[0]' 2>/dev/null | sort -u | grep -c . || echo 0)
74
+ fi
75
+ case "$ACTUAL_AGENT_COUNT" in ''|*[!0-9]*) ACTUAL_AGENT_COUNT=0 ;; esac # fail closed: unreadable or untrusted counts as not posted
76
+ # Fresh shell: SELECTED_AGENT_COUNT is not carried over. Reload it from the PR-keyed launched-agent file, then fail closed when it is empty, non-integer or zero (a staging review always selects at least one reviewer).
77
+ [ -n "${SELECTED_AGENT_COUNT:-}" ] || { [ -n "${PR_NUMBER:-}" ] && [ -r "$_SD/${PR_NUMBER}_launched_review_agents" ] && SELECTED_AGENT_COUNT=$(grep -E '^[a-z0-9-]+$' "$_SD/${PR_NUMBER}_launched_review_agents" | sort -u | grep -c .); }
78
+ SELECTED_COUNT_INVALID=false
79
+ case "${SELECTED_AGENT_COUNT:-}" in ''|*[!0-9]*|0) SELECTED_COUNT_INVALID=true; SELECTED_AGENT_COUNT=0 ;; esac
80
+ if [ "${DISPATCH_FAILED:-}" = "true" ] || [ "$SELECTED_COUNT_INVALID" = "true" ] || [ "$ACTUAL_AGENT_COUNT" -lt "$SELECTED_AGENT_COUNT" ]; then
81
+ gh label create "review-degraded" --color "E4E669" --description "PR review panel was incomplete; re-review required before deployment. Managed by ForgeDock." --force -R "$GH_REPO" 2>/dev/null || true
82
+ gh pr edit "$PR_NUMBER" -R "$GH_REPO" --add-label "review-degraded" --add-label "needs-human" 2>/dev/null || true # allowlist:check-command-side-effects
83
+ gh pr comment "$PR_NUMBER" -R "$GH_REPO" --body "<!-- FORGE:GATE_FAILURE:TYPE=review-panel-integrity -->
84
+ ## Deploy Review Blocked: Incomplete Isolated Review Panel
85
+
86
+ **Selected isolated reviewers**: ${SELECTED_AGENT_COUNT}
87
+ **Completed isolated reviewers**: ${ACTUAL_AGENT_COUNT}
88
+
89
+ Re-run the full staging review in a fresh session before deployment."
90
+ exit 1
91
+ fi
92
+ # END panel-guard
93
+ # BEGIN delivery-check
94
+ # Reload the launched-agent list persisted at dispatch (validated: lowercase marker domains only).
95
+ [ -n "${LAUNCHED_REVIEW_AGENTS:-}" ] || { [ -r "$_SD/${PR_NUMBER}_launched_review_agents" ] && LAUNCHED_REVIEW_AGENTS=$(grep -E '^[a-z0-9-]+$' "$_SD/${PR_NUMBER}_launched_review_agents" | sort -u | tr '\n' ' '); }
96
+ # Fail closed: a staging review always launches at least the Phase 2 material-change agent, so an empty list means lost state or skipped bookkeeping, never "nothing to verify".
97
+ if [ -z "${PR_NUMBER:-}" ] || [ -z "${LAUNCHED_REVIEW_AGENTS:-}" ]; then
98
+ echo "REVIEW DELIVERY FAILURE: launched-agent list is empty or unreadable (PR_NUMBER='${PR_NUMBER:-}')"
99
+ echo "Recover the launched reviewer domains from the PR's FORGE:REVIEW-AGENT comments, re-append them to the launched-agent file, and rerun."
100
+ exit 1
101
+ fi
102
+ MISSING_AGENT_COMMENTS=""
103
+ # Each Bash call is a fresh shell: re-resolve the full head SHA if it is not set, and refuse when it is not a commit id
104
+ # (an empty SHA would make the Reviewed-SHA match accept any head).
105
+ [ -n "${REVIEW_SHA_FULL:-}" ] || REVIEW_SHA_FULL=$(gh pr view "$PR_NUMBER" -R "$GH_REPO" --json headRefOid --jq '.headRefOid' 2>/dev/null || echo "")
106
+ case "$REVIEW_SHA_FULL" in *[!0-9a-f]*|"") REVIEW_SHA_FULL="" ;; *) [ "${#REVIEW_SHA_FULL}" -eq 40 ] || [ "${#REVIEW_SHA_FULL}" -eq 64 ] || REVIEW_SHA_FULL="" ;; esac
107
+ TRUSTED_SCRIPT=$(forge_resolve trusted-comments.sh)
108
+ for AGENT_DOMAIN in $LAUNCHED_REVIEW_AGENTS; do
109
+ AGENT_COMMENT_COUNT=0
110
+ if [ -n "$REVIEW_SHA_FULL" ] && [ -n "$TRUSTED_SCRIPT" ]; then
111
+ AGENT_COMMENT_COUNT=$(gh api --paginate "repos/${GH_REPO}/issues/${PR_NUMBER}/comments" 2>/dev/null \
112
+ | bash "$TRUSTED_SCRIPT" count "^(?=[\\s\\S]*<!-- REVIEW-FINDINGS-START -->[\\s\\S]*<!-- REVIEW-FINDINGS-END -->)<!-- FORGE:REVIEW-AGENT:${AGENT_DOMAIN} -->[\\s\\S]*(^|\\n)Reviewed-SHA:[ ]${REVIEW_SHA_FULL}(\\r?\\n|$)" 2>/dev/null || echo 0)
113
+ fi
114
+ case "$AGENT_COMMENT_COUNT" in ''|*[!0-9]*) AGENT_COMMENT_COUNT=0 ;; esac # fail closed: counts as missing
115
+ if [ "${AGENT_COMMENT_COUNT:-0}" -lt 1 ]; then
116
+ MISSING_AGENT_COMMENTS="${MISSING_AGENT_COMMENTS} ${AGENT_DOMAIN}"
117
+ fi
118
+ done
119
+
120
+ if [ -n "$MISSING_AGENT_COMMENTS" ]; then
121
+ echo "REVIEW DELIVERY FAILURE: missing findings comment(s) for:${MISSING_AGENT_COMMENTS}"
122
+ echo "A comment without a REVIEW-FINDINGS-START block counts as missing. Re-dispatch the missing agent once with the note: structured findings block required. A second miss is a degraded panel. Recover each agent's returned verdict, findings, and durable body path before rerunning review."
123
+ exit 1
124
+ fi
125
+ # END delivery-check
126
+ echo "DELIVERY: OK (${ACTUAL_AGENT_COUNT}/${SELECTED_AGENT_COUNT} launched reviewers posted a findings block for head ${REVIEW_SHA_FULL})"
127
+ exit 0
128
+ fi
129
+
130
+ # test-gate
131
+ case "$POSTURE_ARG" in ''|blocking|advisory) ;; *) echo "review-staging-post: --posture must be blocking or advisory" >&2; exit 2 ;; esac
132
+ [ -n "$PRS_FILE" ] || { echo "review-staging-post: --prs-file is required" >&2; exit 2; }
133
+ [ -r "$PRS_FILE" ] || { echo "review-staging-post: --prs-file $PRS_FILE is not readable (run the pre-dispatch first)" >&2; exit 2; }
134
+ [ -n "$OUTPUT_FILE" ] || { echo "review-staging-post: --output is required (the saved /test-gate output)" >&2; exit 2; }
135
+ ALL_PR_NUMBERS=$(grep -E '^[0-9]+$' "$PRS_FILE")
136
+ CONFIG_FILE="${CONFIG_ARG:-${FORGE_CONFIG:-forge.yaml}}"
137
+ GH_FLAG="-R $GH_REPO"
138
+ GATE_POSTURE="$POSTURE_ARG"
139
+ [ -n "$GATE_POSTURE" ] || GATE_POSTURE=$(yq '.verification.test_gate.posture // "blocking"' "$CONFIG_FILE" 2>/dev/null || echo "blocking")
140
+ case "$GATE_POSTURE" in advisory) ;; *) GATE_POSTURE=blocking ;; esac # unreadable or unknown is the blocking default
141
+ OVERRIDE_PHRASE="$PHRASE_ARG"
142
+ [ -n "$OVERRIDE_PHRASE" ] || OVERRIDE_PHRASE=$(yq '.verification.test_gate.override_phrase // "OVERRIDE: shipping with test failures —"' "$CONFIG_FILE" 2>/dev/null || echo "OVERRIDE: shipping with test failures —")
143
+ [ -n "$OVERRIDE_PHRASE" ] || OVERRIDE_PHRASE="OVERRIDE: shipping with test failures —"
144
+ echo "=== Phase 6.5: Runtime Test Gate ==="
145
+ echo "Bundle PRs: $(echo $ALL_PR_NUMBERS | tr '\n' ' ')"
146
+ echo "Posture: ${GATE_POSTURE}"
147
+ # Reset any stale gate result from a prior run (files are PR-keyed)
148
+ rm -f "${_SD:?}/${PR_NUMBER:?}_test_gate_verdict" "${_SD:?}/${PR_NUMBER:?}_test_gate_reason"
149
+ TEST_GATE_VERDICT="SKIP"
150
+ TEST_GATE_REASON="Phase 6.5 not yet run"
151
+ GATE_OUTPUT=""
152
+ if [ -r "$OUTPUT_FILE" ]; then GATE_OUTPUT=$(cat "$OUTPUT_FILE"); else echo "WARNING: /test-gate output $OUTPUT_FILE unreadable: treated as a missing result"; fi
153
+ # BEGIN test-gate-extract
154
+ # Extract machine-readable verdict from Skill output. An absent/invalid marker stays EMPTY (never coerced to SKIP):
155
+ # the verdict-handling fence treats an empty verdict as missing/invalid and fails closed under blocking posture.
156
+ TEST_GATE_VERDICT=$(echo "$GATE_OUTPUT" | grep -oE 'FORGE:TEST_GATE:RESULT=(BLOCK|PASS|SKIP)' | sed 's/^FORGE:TEST_GATE:RESULT=//' | tail -1)
157
+ [ -n "$TEST_GATE_VERDICT" ] || TEST_GATE_REASON="gate result missing/invalid (no FORGE:TEST_GATE:RESULT marker in /test-gate output)"
158
+ # END test-gate-extract
159
+ echo "Test-gate verdict: ${TEST_GATE_VERDICT}"
160
+ printf '%s\n' "$TEST_GATE_VERDICT" > "$_SD/${PR_NUMBER}_test_gate_verdict"; printf '%s\n' "$TEST_GATE_REASON" > "$_SD/${PR_NUMBER}_test_gate_reason"
161
+ # BEGIN test-gate-verdict
162
+ # Reload the Phase 6.5 gate result persisted by its producer (validated: only a known verdict reaches a gate decision).
163
+ [ -n "${TEST_GATE_VERDICT:-}" ] || { [ -r "$_SD/${PR_NUMBER}_test_gate_verdict" ] && TEST_GATE_VERDICT=$(sed -n -E '1{/^(BLOCK|PASS|SKIP)$/p;q}' "$_SD/${PR_NUMBER}_test_gate_verdict"); }
164
+ [ -n "${TEST_GATE_REASON:-}" ] || { [ -r "$_SD/${PR_NUMBER}_test_gate_reason" ] && TEST_GATE_REASON=$(sed -n 1p "$_SD/${PR_NUMBER}_test_gate_reason" | tr -d '\r\000-\037' | cut -c1-400); }
165
+ # Fail closed: a missing, empty, or unrecognised verdict is never a SKIP. Normalize it to BLOCK so the BLOCK arm applies
166
+ # the override check, advisory/blocking posture, and exit 1; GATE_VERDICT_INVALID makes the reasons say missing/invalid.
167
+ GATE_VERDICT_INVALID=0
168
+ case "${TEST_GATE_VERDICT:-}" in
169
+ BLOCK|PASS|SKIP) ;;
170
+ *)
171
+ GATE_VERDICT_INVALID=1
172
+ if [ -r "$_SD/${PR_NUMBER}_test_gate_verdict" ]; then _GV_WHY="persisted verdict file present but empty/invalid"; else _GV_WHY="persisted verdict file missing"; fi
173
+ echo "⚠️ Test gate: gate result missing/invalid (${_GV_WHY}) — treating as BLOCK, not SKIP."
174
+ TEST_GATE_VERDICT="BLOCK"
175
+ ;;
176
+ esac
177
+ case "$TEST_GATE_VERDICT" in
178
+
179
+ SKIP)
180
+ echo "ℹ️ Test gate: SKIP — no executable changes or tests not configured."
181
+ echo " This gap will appear in the Phase 8 summary. No deploy impact."
182
+ TEST_GATE_REASON="SKIP — no runtime tests ran (docs-only bundle, no integration tests configured, or manual-only criteria)"
183
+ ;;
184
+
185
+ PASS)
186
+ echo "✅ Test gate: PASS — all test clusters passed. Deploy may proceed."
187
+ TEST_GATE_REASON="PASS — all automated test clusters passed"
188
+ # Post FORGE:GATE_PASS for symmetric observability
189
+ if [ -n "$PR_NUMBER" ]; then
190
+ gh pr comment "$PR_NUMBER" ${GH_FLAG} --body "<!-- FORGE:GATE_PASS -->
191
+ ## Deploy Gate: PASSED
192
+
193
+ **Gate**: test-gate (runtime acceptance criteria)
194
+ **Result**: PASS — all test clusters passed. Deploy may proceed.
195
+ **Bundle PRs**: ${ALL_PR_NUMBERS}
196
+ **Timestamp**: $(date -u +%Y-%m-%dT%H:%M:%SZ)
197
+
198
+ <!-- FORGE:GATE_PASS:TYPE=test-gate|BUNDLE=$(echo $ALL_PR_NUMBERS | tr '\n' ' ' | xargs) -->" 2>/dev/null || true
199
+ fi
200
+ ;;
201
+
202
+ BLOCK)
203
+ # Check for override comment on the staging→main PR (mirrors Phase 0A pattern)
204
+ if [ -n "$PR_NUMBER" ]; then
205
+ # Trusted authors only, all comment pages, anchored; fail closed (no override) if the script or comments are
206
+ # unreadable (#3492). OVERRIDE_PHRASE comes from forge.yaml, so regex-escape it before using it as a pattern.
207
+ TRUSTED_SCRIPT=$(forge_resolve trusted-comments.sh)
208
+ TG_SCRIPT="$TRUSTED_SCRIPT"
209
+ TG_COMMENTS=$(gh api --paginate "repos/${GH_REPO}/issues/${PR_NUMBER}/comments" 2>/dev/null) || TG_COMMENTS=""
210
+ TG_OVERRIDE=0; OVERRIDE_REASON="(reason not captured)"
211
+ if [ -n "$TG_SCRIPT" ] && [ -n "$TG_COMMENTS" ]; then
212
+ TG_RE="^$(printf '%s' "$OVERRIDE_PHRASE" | sed 's/[][\\.^$*+?(){}|\/]/\\&/g')"
213
+ TG_OVERRIDE=$(printf '%s' "$TG_COMMENTS" | bash "$TG_SCRIPT" count "$TG_RE" 2>/dev/null || echo 0)
214
+ [ "${TG_OVERRIDE:-0}" -gt 0 ] && OVERRIDE_REASON=$(printf '%s' "$TG_COMMENTS" | bash "$TG_SCRIPT" bodies "$TG_RE" 2>/dev/null | tail -1 | jq -r '.' 2>/dev/null || echo "(reason not captured)")
215
+ fi
216
+ else
217
+ TG_OVERRIDE=0
218
+ fi
219
+
220
+ if [ "${TG_OVERRIDE:-0}" -gt 0 ]; then
221
+ echo "⚠️ Test gate: BLOCK — but override comment detected on PR #${PR_NUMBER}."
222
+ echo " Override: ${OVERRIDE_REASON}"
223
+ echo " Proceeding with deploy. Override is logged in Phase 8 summary."
224
+ TEST_GATE_VERDICT="PASS"
225
+ TEST_GATE_REASON="BLOCK downgraded to PASS by override: ${OVERRIDE_REASON}"
226
+ [ "$GATE_VERDICT_INVALID" = 1 ] && TEST_GATE_REASON="BLOCK (gate result missing/invalid) downgraded to PASS by override: ${OVERRIDE_REASON}"
227
+
228
+ elif [ "$GATE_POSTURE" = "advisory" ]; then
229
+ echo "⚠️ Test gate: BLOCK (advisory posture) — runtime failures detected but deploy is NOT prevented."
230
+ echo " Switch verification.test_gate.posture to 'blocking' in forge.yaml to enforce this gate."
231
+ TEST_GATE_REASON="BLOCK (advisory) — runtime failures detected; deploy allowed by advisory posture"
232
+ [ "$GATE_VERDICT_INVALID" = 1 ] && TEST_GATE_REASON="BLOCK (advisory) — gate result missing/invalid; deploy allowed by advisory posture"
233
+
234
+ else
235
+ # blocking posture (default) — STOP
236
+ echo "⛔ DEPLOY BLOCKED — /test-gate returned BLOCK verdict."
237
+ [ "$GATE_VERDICT_INVALID" = 1 ] && echo " (gate result missing/invalid — failing closed; re-run /test-gate or post the override comment)"
238
+ echo ""
239
+ echo "Runtime failures were detected in the staging→${DEFAULT_BRANCH} bundle."
240
+ echo "The failures were batch-introduced (not pre-existing on ${DEFAULT_BRANCH} baseline)."
241
+ echo ""
242
+ echo "Options:"
243
+ echo " 1. Fix the failing tests and merge fixes to staging before retrying the deploy."
244
+ echo " 2. Post a comment on this PR starting with \"${OVERRIDE_PHRASE} <reason>\" to bypass this gate."
245
+ echo " 3. Set verification.test_gate.posture: advisory in forge.yaml to downgrade to a warning."
246
+ echo ""
247
+ echo "RESULT: BLOCK DEPLOY"
248
+
249
+ # Post structured FORGE:GATE_FAILURE comment for pipeline-health tracking
250
+ GATE_TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
251
+ if [ -n "$PR_NUMBER" ]; then
252
+ gh pr comment "$PR_NUMBER" ${GH_FLAG} --body "<!-- FORGE:GATE_FAILURE -->
253
+ ## Deploy Gate: BLOCKED
254
+
255
+ **Gate**: test-gate
256
+ **Timestamp**: ${GATE_TIMESTAMP}
257
+ **Bundle PRs**: $(echo $ALL_PR_NUMBERS | tr '\n' ' ')
258
+ **Posture**: ${GATE_POSTURE}
259
+
260
+ ### What Happened
261
+
262
+ \`/test-gate\` detected runtime failures in the staging→\`${DEFAULT_BRANCH}\` bundle that were NOT present on the \`${DEFAULT_BRANCH}\` baseline. These are batch-introduced regressions. (If the gate result was missing/invalid, no verdict could be read and the gate failed closed; re-run \`/test-gate\`.)
263
+
264
+ \`/test-gate\` has filed \`test-failure\` issues for each failing cluster — check the issue tracker for details.
265
+
266
+ ### Resolution
267
+
268
+ 1. Fix the failing tests and merge fixes to staging.
269
+ 2. Retry the staging→\`${DEFAULT_BRANCH}\` deploy.
270
+
271
+ To override (ship known failures with documented reason), post a comment containing:
272
+ \`${OVERRIDE_PHRASE} <reason>\`
273
+
274
+ <!-- FORGE:GATE_FAILURE:TYPE=test-gate|BUNDLE=$(echo $ALL_PR_NUMBERS | tr '\n' ' ' | xargs) -->" 2>/dev/null || true
275
+ fi
276
+ _GV_REASON="BLOCK (blocking posture, no override)"; [ "$GATE_VERDICT_INVALID" = 1 ] && _GV_REASON="BLOCK (gate result missing/invalid; blocking posture, no override)"
277
+ printf '%s\n' "$TEST_GATE_VERDICT" > "$_SD/${PR_NUMBER}_test_gate_verdict"; printf '%s\n' "$_GV_REASON" > "$_SD/${PR_NUMBER}_test_gate_reason"
278
+ exit 1
279
+ fi
280
+ ;;
281
+
282
+ *)
283
+ # Unreachable (normalized above); fail closed regardless — never SKIP.
284
+ echo "⛔ Test gate: gate result missing/invalid ('${TEST_GATE_VERDICT}') — failing closed."
285
+ printf '%s\n' "BLOCK" > "$_SD/${PR_NUMBER}_test_gate_verdict"; printf '%s\n' "BLOCK (gate result missing/invalid)" > "$_SD/${PR_NUMBER}_test_gate_reason"
286
+ exit 1
287
+ ;;
288
+
289
+ esac
290
+ # Persist the gate result: every Bash call is a fresh shell, and the verdict-handling fence and Phase 8 read it back (validated on reload).
291
+ printf '%s\n' "$TEST_GATE_VERDICT" > "$_SD/${PR_NUMBER}_test_gate_verdict"; printf '%s\n' "$TEST_GATE_REASON" > "$_SD/${PR_NUMBER}_test_gate_reason"
292
+ # END test-gate-verdict
293
+ exit 0
@@ -0,0 +1,143 @@
1
+ #!/usr/bin/env bash
2
+ # review-staging-post.test.sh — cases for scripts/review-staging-post.sh (forge#4359): the incomplete-panel guard, Phase 6.75
3
+ # agent delivery and the Phase 6.5 test-gate verdict handling (formerly extracted from commands/review-pr-staging.md fences).
4
+ # gh is a PATH stub reading per-case fixtures; trust decisions go through the real scripts/trusted-comments.sh.
5
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
6
+ # SPDX-License-Identifier: AGPL-3.0-or-later
7
+ set -uo pipefail
8
+ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
9
+ POST="$ROOT/scripts/review-staging-post.sh"
10
+ SPEC="$ROOT/commands/review-pr-staging.md"
11
+ PASS=0; FAILN=0
12
+ ok() { PASS=$((PASS+1)); }
13
+ bad() { FAILN=$((FAILN+1)); echo "FAIL: $1"; }
14
+ command -v jq >/dev/null 2>&1 || { echo "review-staging-post.test.sh: jq missing, skipped"; exit 0; }
15
+ TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
16
+ export FORGEDOCK_HOME="$ROOT" # resolve helpers from this checkout, not an ambient install
17
+ HEAD=0123456789abcdef0123456789abcdef01234567
18
+ OTHER=fedcba9876543210fedcba9876543210fedcba98
19
+ blk() { awk -v n="$1" '$0 == "# BEGIN " n {f=1; next} $0 == "# END " n {f=0} f' "$POST"; }
20
+
21
+ # (1) the fences now live only in the script: the blocks exist, the spec no longer carries them
22
+ for b in panel-guard delivery-check test-gate-extract test-gate-verdict; do
23
+ [ -n "$(blk "$b")" ] && ok || bad "block $b missing from review-staging-post.sh"
24
+ done
25
+ if grep -qE 'ACTUAL_AGENT_COUNT=\$\(gh api|MISSING_AGENT_COMMENTS=""|GATE_VERDICT_INVALID=0' "$SPEC"; then bad "the spec must not carry a copy of the panel-guard, delivery or verdict-handling fence"; else ok; fi
26
+ # 6.75 carries the findings-block lookahead; the 6.75 empty-list guard precedes the per-domain loop
27
+ blk delivery-check | grep -qF 'count "^(?=[\\s\\S]*<!-- REVIEW-FINDINGS-START -->[\\s\\S]*<!-- REVIEW-FINDINGS-END -->)<!-- FORGE:REVIEW-AGENT:${AGENT_DOMAIN} -->' && ok || bad "6.75 count must require the findings block"
28
+ G=$(blk delivery-check | grep -n 'launched-agent list is empty or unreadable' | head -1 | cut -d: -f1); L=$(blk delivery-check | grep -n '^for AGENT_DOMAIN in' | head -1 | cut -d: -f1)
29
+ [ -n "$G" ] && [ -n "$L" ] && [ "$G" -lt "$L" ] && ok || bad "6.75 empty-list guard must precede the for AGENT_DOMAIN loop"
30
+ # the producer never coerces an absent marker to SKIP; the catch-all arm never sets SKIP
31
+ if blk test-gate-extract | grep -q 'tail -1 || echo "SKIP"'; then bad "producer must not coerce an absent marker to SKIP"; else ok; fi
32
+ if blk test-gate-verdict | awk '/^ \*\)/,/;;/' | grep -q 'TEST_GATE_VERDICT="SKIP"'; then bad "catch-all arm must not set TEST_GATE_VERDICT=SKIP"; else ok; fi
33
+
34
+ # (2) usage errors
35
+ bash "$POST" bogus --repo o/r --pr 1 --sd "$TMP/u" >/dev/null 2>&1; [ $? -eq 2 ] && ok || bad "unknown subcommand must exit 2"
36
+ bash "$POST" delivery --repo o/r --pr x --sd "$TMP/u" >/dev/null 2>&1; [ $? -eq 2 ] && ok || bad "non-numeric --pr must exit 2"
37
+ bash "$POST" delivery --repo o/r --pr 1 >/dev/null 2>&1; [ $? -eq 2 ] && ok || bad "missing --sd must exit 2"
38
+ bash "$POST" test-gate --repo o/r --pr 1 --sd "$TMP/u" --output x --prs-file "$TMP/none" >/dev/null 2>&1; [ $? -eq 2 ] && ok || bad "unreadable --prs-file must exit 2"
39
+ bash "$POST" test-gate --repo o/r --pr 1 --sd "$TMP/u" --output x --prs-file /dev/null --posture lax >/dev/null 2>&1; [ $? -eq 2 ] && ok || bad "unknown --posture must exit 2"
40
+
41
+ # gh stub: $FIX/comments.json is the PR's comment list; posted bodies and edits go to $FIX/posted / $FIX/edits
42
+ mkdir -p "$TMP/bin"
43
+ cat > "$TMP/bin/gh" <<'STUB'
44
+ #!/usr/bin/env bash
45
+ F="$FIX"; a="$*"
46
+ case "$1 $2" in
47
+ "pr comment") while [ $# -gt 0 ]; do [ "$1" = --body ] && printf '%s\n@@\n' "$2" >> "$F/posted"; shift; done; exit 0 ;;
48
+ "pr edit") printf '%s\n' "$a" >> "$F/edits"; exit 0 ;;
49
+ "label create") exit 0 ;;
50
+ "pr view") [ -f "$F/head" ] && { cat "$F/head"; exit 0; }; exit 1 ;;
51
+ esac
52
+ if [ "$1" = api ]; then case "$a" in *"/comments"*) cat "$F/comments.json" 2>/dev/null || exit 1; exit 0 ;; esac; fi
53
+ echo "gh stub: unhandled: $a" >&2; exit 1
54
+ STUB
55
+ printf '#!/bin/sh\nexit 1\n' > "$TMP/bin/yq" # no yq: defaults apply
56
+ chmod +x "$TMP/bin/gh" "$TMP/bin/yq"
57
+ export PATH="$TMP/bin:$PATH"
58
+ newfix() { FIX="$TMP/fix.$1"; rm -rf "$FIX"; mkdir -p "$FIX/sd"; export FIX; }
59
+ FB=$'<!-- REVIEW-FINDINGS-START -->\n<!-- REVIEW-FINDINGS-END -->'
60
+ cm() { jq -n --arg b "$1" --arg t "${2:-Bot}" '{body:$b, author_association:"NONE", user:{type:$t, login:"x"}}'; }
61
+ agent() { printf '<!-- FORGE:REVIEW-AGENT:%s -->\nReviewed-SHA: %s\n\n%s' "$1" "${2:-$HEAD}" "${3-$FB}"; }
62
+ comments() { jq -s '.' > "$FIX/comments.json"; }
63
+ deliver() { bash "$POST" delivery --repo o/r --pr 42 --sd "$FIX/sd" --head "${1:-$HEAD}" 2>&1; echo "rc=$?"; }
64
+
65
+ # (3) delivery: a complete panel passes and posts nothing
66
+ newfix full; printf 'security\nmaterial-change\n' > "$FIX/sd/42_launched_review_agents"
67
+ { cm "$(agent security)"; cm "$(agent material-change)"; } | comments
68
+ OUT=$(deliver); case "$OUT" in *"DELIVERY: OK (2/2"*"rc=0") ok ;; *) bad "complete panel must pass (got: $OUT)" ;; esac
69
+ [ ! -s "$FIX/posted" ] && ok || bad "a complete panel must post nothing"
70
+ # a duplicate domain line does not inflate the selected count
71
+ printf 'security\nsecurity\nmaterial-change\n' > "$FIX/sd/42_launched_review_agents"
72
+ OUT=$(deliver); case "$OUT" in *"rc=0") ok ;; *) bad "duplicate launched lines must count once (got: $OUT)" ;; esac
73
+
74
+ # (4) shortfalls: missing domain, block-less comment, untrusted author, other head -> panel-integrity failure
75
+ for c in missing noblock untrusted otherhead; do
76
+ newfix "$c"; printf 'security\nmaterial-change\n' > "$FIX/sd/42_launched_review_agents"
77
+ case "$c" in
78
+ missing) cm "$(agent security)" | comments ;;
79
+ noblock) { cm "$(agent security)"; cm "$(agent material-change "$HEAD" 'Verdict: APPROVE, no findings.')"; } | comments ;;
80
+ untrusted) { cm "$(agent security)"; cm "$(agent material-change)" User; } | comments ;;
81
+ otherhead) { cm "$(agent security)"; cm "$(agent material-change "$OTHER")"; } | comments ;;
82
+ esac
83
+ OUT=$(deliver)
84
+ case "$OUT" in *"rc=1") ok ;; *) bad "$c: delivery must fail (got: $OUT)" ;; esac
85
+ if [ "$c" = noblock ]; then
86
+ case "$OUT" in *"missing findings comment(s) for: material-change"*) ok ;; *) bad "noblock: 6.75 must name the block-less domain (got: $OUT)" ;; esac
87
+ else
88
+ grep -qF '<!-- FORGE:GATE_FAILURE:TYPE=review-panel-integrity -->' "$FIX/posted" 2>/dev/null && ok || bad "$c: shortfall must post GATE_FAILURE:TYPE=review-panel-integrity"
89
+ grep -qF 'review-degraded' "$FIX/edits" 2>/dev/null && ok || bad "$c: shortfall must label the PR review-degraded"
90
+ fi
91
+ done
92
+ # empty / absent launched list fails closed
93
+ newfix empty; : > "$FIX/sd/42_launched_review_agents"; cm "$(agent security)" | comments
94
+ OUT=$(deliver); case "$OUT" in *"rc=1") ok ;; *) bad "empty launched list must fail (got: $OUT)" ;; esac
95
+ newfix none; cm "$(agent security)" | comments
96
+ OUT=$(deliver); case "$OUT" in *"rc=1") ok ;; *) bad "absent launched list must fail (got: $OUT)" ;; esac
97
+ # unreadable comments count as 0 delivered
98
+ newfix unreadable; printf 'security\n' > "$FIX/sd/42_launched_review_agents"
99
+ OUT=$(deliver); case "$OUT" in *"rc=1") ok ;; *) bad "unreadable comments must fail closed (got: $OUT)" ;; esac
100
+ # a malformed --head with no resolvable head refuses
101
+ newfix badhead; printf 'security\n' > "$FIX/sd/42_launched_review_agents"; cm "$(agent security)" | comments
102
+ OUT=$(deliver "n/a"); case "$OUT" in *"rc=1") ok ;; *) bad "an unresolvable head must fail closed (got: $OUT)" ;; esac
103
+ # ...but a malformed --head is re-resolved from gh when it can be
104
+ printf '%s\n' "$HEAD" > "$FIX/head"
105
+ OUT=$(deliver "n/a"); case "$OUT" in *"rc=0") ok ;; *) bad "a malformed --head must be re-resolved from gh (got: $OUT)" ;; esac
106
+
107
+ # (5) test gate
108
+ tg() { bash "$POST" test-gate --repo o/r --pr 42 --sd "$FIX/sd" --output "$FIX/out" --prs-file "$FIX/prs" "$@" 2>&1; echo "rc=$?"; }
109
+ tfix() { newfix "tg$1"; printf '7\n8\n' > "$FIX/prs"; echo '[]' > "$FIX/comments.json"; }
110
+ v() { sed -n 1p "$FIX/sd/42_test_gate_verdict"; }
111
+ tfix skip; printf 'noise\nFORGE:TEST_GATE:RESULT=SKIP\n' > "$FIX/out"
112
+ OUT=$(tg); case "$OUT" in *"rc=0") ok ;; *) bad "SKIP must continue (got: $OUT)" ;; esac
113
+ [ "$(v)" = SKIP ] && ok || bad "SKIP must be persisted (got $(v))"
114
+ [ ! -s "$FIX/posted" ] && ok || bad "SKIP posts no gate marker from the test gate"
115
+ tfix pass; printf 'FORGE:TEST_GATE:RESULT=BLOCK\nFORGE:TEST_GATE:RESULT=PASS\n' > "$FIX/out"
116
+ OUT=$(tg); case "$OUT" in *"rc=0") ok ;; *) bad "PASS must continue (got: $OUT)" ;; esac
117
+ [ "$(v)" = PASS ] && ok || bad "the last marker wins (got $(v))"
118
+ grep -qF '<!-- FORGE:GATE_PASS:TYPE=test-gate|BUNDLE=7 8 -->' "$FIX/posted" && ok || bad "PASS must post GATE_PASS:TYPE=test-gate with the bundle"
119
+ for m in missing nofile; do
120
+ tfix "$m"; [ "$m" = missing ] && printf 'all good, no marker\n' > "$FIX/out"
121
+ OUT=$(tg); case "$OUT" in *"RESULT: BLOCK DEPLOY"*"rc=1") ok ;; *) bad "$m marker must fail closed as BLOCK (got: $OUT)" ;; esac
122
+ [ "$(v)" = BLOCK ] && ok || bad "$m: persisted verdict must be BLOCK, never SKIP (got $(v))"
123
+ grep -qF '<!-- FORGE:GATE_FAILURE:TYPE=test-gate|BUNDLE=7 8 -->' "$FIX/posted" && ok || bad "$m: blocking BLOCK must post GATE_FAILURE"
124
+ done
125
+ tfix advisory; printf 'FORGE:TEST_GATE:RESULT=BLOCK\n' > "$FIX/out"
126
+ OUT=$(tg --posture advisory); case "$OUT" in *"rc=0") ok ;; *) bad "advisory BLOCK must continue (got: $OUT)" ;; esac
127
+ grep -q 'advisory' "$FIX/sd/42_test_gate_reason" && ok || bad "advisory reason must be persisted"
128
+ tfix override; printf 'FORGE:TEST_GATE:RESULT=BLOCK\n' > "$FIX/out"
129
+ cm 'OVERRIDE: shipping with test failures — flaky infra, tracked in #9' | comments
130
+ OUT=$(tg); case "$OUT" in *"rc=0") ok ;; *) bad "a trusted override must downgrade BLOCK (got: $OUT)" ;; esac
131
+ [ "$(v)" = PASS ] && grep -q 'downgraded to PASS by override' "$FIX/sd/42_test_gate_reason" && ok || bad "override must persist PASS with the reason"
132
+ tfix untrustedov; printf 'FORGE:TEST_GATE:RESULT=BLOCK\n' > "$FIX/out"
133
+ cm 'OVERRIDE: shipping with test failures — me' User | comments
134
+ OUT=$(tg); case "$OUT" in *"rc=1") ok ;; *) bad "an untrusted override must not downgrade BLOCK (got: $OUT)" ;; esac
135
+ tfix phrase; printf 'FORGE:TEST_GATE:RESULT=BLOCK\n' > "$FIX/out"
136
+ cm 'SHIP IT (a.b) because' | comments
137
+ OUT=$(tg --override-phrase 'SHIP IT (a.b)'); case "$OUT" in *"rc=0") ok ;; *) bad "a configured override phrase must be honoured literally (got: $OUT)" ;; esac
138
+ # stale verdict from a prior run is reset before reading
139
+ tfix stale; printf 'PASS\n' > "$FIX/sd/42_test_gate_verdict"; printf 'nothing\n' > "$FIX/out"
140
+ OUT=$(tg); [ "$(v)" = BLOCK ] && ok || bad "a stale persisted PASS must not survive a missing marker (got $(v))"
141
+
142
+ echo "review-staging-post.test.sh: passed=$PASS failed=$FAILN"
143
+ [ "$FAILN" -eq 0 ]