forgedock 1.0.17 → 1.0.18

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 (131) hide show
  1. package/README.md +34 -1
  2. package/bin/cross-publish.mjs +344 -0
  3. package/bin/forgedock.mjs +229 -17
  4. package/commands/autopilot.md +77 -0
  5. package/commands/design-bench.md +176 -0
  6. package/commands/design-render-critique-loop.md +103 -0
  7. package/commands/design.md +259 -0
  8. package/commands/issue.md +7 -3
  9. package/commands/orchestrate.md +121 -0
  10. package/commands/qa-sweep.md +37 -2
  11. package/commands/quality-gate.md +30 -0
  12. package/commands/review-pr-staging.md +158 -2
  13. package/commands/test-gate.md +929 -0
  14. package/commands/work-on/build/implement.md +5 -3
  15. package/commands/work-on.md +53 -3
  16. package/docs/CI.md +177 -0
  17. package/docs/FORGE-PROTOCOL.md +324 -0
  18. package/docs/articles/b-vs-c-baseline.md +210 -0
  19. package/docs/articles/full-abc-benchmark.md +205 -0
  20. package/docs/articles/selective-spec-loading-token-savings.md +140 -0
  21. package/docs/design/abc-benchmark.md +195 -0
  22. package/docs/design/design-architect-rationale.md +123 -0
  23. package/docs/design/design-memory.md +61 -0
  24. package/docs/design/design-spec-schema.md +248 -0
  25. package/docs/design/design-system-lint.md +150 -0
  26. package/docs/design/divergent-generation.md +106 -0
  27. package/docs/design/effects-appropriateness.md +133 -0
  28. package/docs/design/fixtures/briefs/README.md +47 -0
  29. package/docs/design/fixtures/briefs/cadence.md +39 -0
  30. package/docs/design/fixtures/briefs/plume.md +39 -0
  31. package/docs/design/fixtures/briefs/slipstream.md +39 -0
  32. package/docs/design/fixtures/briefs/tender.md +39 -0
  33. package/docs/design/fixtures/briefs/voltage.md +39 -0
  34. package/docs/design/fixtures/runs/b-vs-c-baseline/cadence/arm-b/run-1.html +364 -0
  35. package/docs/design/fixtures/runs/b-vs-c-baseline/cadence/arm-b/run-2.html +420 -0
  36. package/docs/design/fixtures/runs/b-vs-c-baseline/cadence/arm-b/run-3.html +506 -0
  37. package/docs/design/fixtures/runs/b-vs-c-baseline/plume/arm-b/run-1.html +611 -0
  38. package/docs/design/fixtures/runs/b-vs-c-baseline/plume/arm-b/run-2.html +594 -0
  39. package/docs/design/fixtures/runs/b-vs-c-baseline/plume/arm-b/run-3.html +817 -0
  40. package/docs/design/fixtures/runs/b-vs-c-baseline/runs.json +175 -0
  41. package/docs/design/fixtures/runs/b-vs-c-baseline/scorecard.json +138 -0
  42. package/docs/design/fixtures/runs/b-vs-c-baseline/slipstream/arm-b/run-1.html +373 -0
  43. package/docs/design/fixtures/runs/b-vs-c-baseline/slipstream/arm-b/run-2.html +398 -0
  44. package/docs/design/fixtures/runs/b-vs-c-baseline/slipstream/arm-b/run-3.html +595 -0
  45. package/docs/design/fixtures/runs/b-vs-c-baseline/tender/arm-b/run-1.html +531 -0
  46. package/docs/design/fixtures/runs/b-vs-c-baseline/tender/arm-b/run-2.html +565 -0
  47. package/docs/design/fixtures/runs/b-vs-c-baseline/tender/arm-b/run-3.html +674 -0
  48. package/docs/design/fixtures/runs/b-vs-c-baseline/voltage/arm-b/run-1.html +388 -0
  49. package/docs/design/fixtures/runs/b-vs-c-baseline/voltage/arm-b/run-2.html +463 -0
  50. package/docs/design/fixtures/runs/b-vs-c-baseline/voltage/arm-b/run-3.html +568 -0
  51. package/docs/design/fixtures/runs/full-abc/cadence/arm-a/run-1.html +562 -0
  52. package/docs/design/fixtures/runs/full-abc/cadence/arm-a/run-2.html +618 -0
  53. package/docs/design/fixtures/runs/full-abc/cadence/arm-a/run-3.html +618 -0
  54. package/docs/design/fixtures/runs/full-abc/cadence/arm-b/run-1.html +364 -0
  55. package/docs/design/fixtures/runs/full-abc/cadence/arm-b/run-2.html +420 -0
  56. package/docs/design/fixtures/runs/full-abc/cadence/arm-b/run-3.html +506 -0
  57. package/docs/design/fixtures/runs/full-abc/plume/arm-a/run-1.html +527 -0
  58. package/docs/design/fixtures/runs/full-abc/plume/arm-a/run-2.html +445 -0
  59. package/docs/design/fixtures/runs/full-abc/plume/arm-a/run-3.html +483 -0
  60. package/docs/design/fixtures/runs/full-abc/plume/arm-b/run-1.html +611 -0
  61. package/docs/design/fixtures/runs/full-abc/plume/arm-b/run-2.html +594 -0
  62. package/docs/design/fixtures/runs/full-abc/plume/arm-b/run-3.html +817 -0
  63. package/docs/design/fixtures/runs/full-abc/runs.json +190 -0
  64. package/docs/design/fixtures/runs/full-abc/scorecard.json +144 -0
  65. package/docs/design/fixtures/runs/full-abc/slipstream/arm-a/run-1.html +496 -0
  66. package/docs/design/fixtures/runs/full-abc/slipstream/arm-a/run-2.html +447 -0
  67. package/docs/design/fixtures/runs/full-abc/slipstream/arm-a/run-3.html +564 -0
  68. package/docs/design/fixtures/runs/full-abc/slipstream/arm-b/run-1.html +373 -0
  69. package/docs/design/fixtures/runs/full-abc/slipstream/arm-b/run-2.html +398 -0
  70. package/docs/design/fixtures/runs/full-abc/slipstream/arm-b/run-3.html +595 -0
  71. package/docs/design/fixtures/runs/full-abc/tender/arm-a/run-1.html +622 -0
  72. package/docs/design/fixtures/runs/full-abc/tender/arm-a/run-2.html +475 -0
  73. package/docs/design/fixtures/runs/full-abc/tender/arm-a/run-3.html +660 -0
  74. package/docs/design/fixtures/runs/full-abc/tender/arm-b/run-1.html +531 -0
  75. package/docs/design/fixtures/runs/full-abc/tender/arm-b/run-2.html +565 -0
  76. package/docs/design/fixtures/runs/full-abc/tender/arm-b/run-3.html +674 -0
  77. package/docs/design/fixtures/runs/full-abc/voltage/arm-a/run-1.html +525 -0
  78. package/docs/design/fixtures/runs/full-abc/voltage/arm-a/run-2.html +446 -0
  79. package/docs/design/fixtures/runs/full-abc/voltage/arm-a/run-3.html +463 -0
  80. package/docs/design/fixtures/runs/full-abc/voltage/arm-b/run-1.html +388 -0
  81. package/docs/design/fixtures/runs/full-abc/voltage/arm-b/run-2.html +463 -0
  82. package/docs/design/fixtures/runs/full-abc/voltage/arm-b/run-3.html +568 -0
  83. package/docs/design/fixtures/runs/full-abc-opus/cadence/arm-a/run-1.html +1068 -0
  84. package/docs/design/fixtures/runs/full-abc-opus/cadence/arm-a/run-2.html +1423 -0
  85. package/docs/design/fixtures/runs/full-abc-opus/cadence/arm-a/run-3.html +1245 -0
  86. package/docs/design/fixtures/runs/full-abc-opus/cadence/arm-b/run-1.html +305 -0
  87. package/docs/design/fixtures/runs/full-abc-opus/cadence/arm-b/run-2.html +327 -0
  88. package/docs/design/fixtures/runs/full-abc-opus/cadence/arm-b/run-3.html +428 -0
  89. package/docs/design/fixtures/runs/full-abc-opus/plume/arm-a/run-1.html +1270 -0
  90. package/docs/design/fixtures/runs/full-abc-opus/plume/arm-a/run-2.html +1172 -0
  91. package/docs/design/fixtures/runs/full-abc-opus/plume/arm-a/run-3.html +1532 -0
  92. package/docs/design/fixtures/runs/full-abc-opus/plume/arm-b/run-1.html +315 -0
  93. package/docs/design/fixtures/runs/full-abc-opus/plume/arm-b/run-2.html +342 -0
  94. package/docs/design/fixtures/runs/full-abc-opus/plume/arm-b/run-3.html +353 -0
  95. package/docs/design/fixtures/runs/full-abc-opus/runs.json +190 -0
  96. package/docs/design/fixtures/runs/full-abc-opus/scorecard.json +144 -0
  97. package/docs/design/fixtures/runs/full-abc-opus/slipstream/arm-a/run-1.html +345 -0
  98. package/docs/design/fixtures/runs/full-abc-opus/slipstream/arm-a/run-2.html +396 -0
  99. package/docs/design/fixtures/runs/full-abc-opus/slipstream/arm-a/run-3.html +502 -0
  100. package/docs/design/fixtures/runs/full-abc-opus/slipstream/arm-b/run-1.html +408 -0
  101. package/docs/design/fixtures/runs/full-abc-opus/slipstream/arm-b/run-2.html +446 -0
  102. package/docs/design/fixtures/runs/full-abc-opus/slipstream/arm-b/run-3.html +496 -0
  103. package/docs/design/fixtures/runs/full-abc-opus/tender/arm-a/run-1.html +813 -0
  104. package/docs/design/fixtures/runs/full-abc-opus/tender/arm-a/run-2.html +711 -0
  105. package/docs/design/fixtures/runs/full-abc-opus/tender/arm-a/run-3.html +721 -0
  106. package/docs/design/fixtures/runs/full-abc-opus/tender/arm-b/run-1.html +280 -0
  107. package/docs/design/fixtures/runs/full-abc-opus/tender/arm-b/run-2.html +291 -0
  108. package/docs/design/fixtures/runs/full-abc-opus/tender/arm-b/run-3.html +366 -0
  109. package/docs/design/fixtures/runs/full-abc-opus/voltage/arm-a/run-1.html +1010 -0
  110. package/docs/design/fixtures/runs/full-abc-opus/voltage/arm-a/run-2.html +836 -0
  111. package/docs/design/fixtures/runs/full-abc-opus/voltage/arm-a/run-3.html +930 -0
  112. package/docs/design/fixtures/runs/full-abc-opus/voltage/arm-b/run-1.html +407 -0
  113. package/docs/design/fixtures/runs/full-abc-opus/voltage/arm-b/run-2.html +396 -0
  114. package/docs/design/fixtures/runs/full-abc-opus/voltage/arm-b/run-3.html +529 -0
  115. package/docs/design/foundations/brutalist.css +294 -0
  116. package/docs/design/foundations/editorial.css +300 -0
  117. package/docs/design/foundations/minimal-luxury.css +295 -0
  118. package/docs/design/foundations/technical.css +293 -0
  119. package/docs/design/foundations/warm-photo.css +299 -0
  120. package/docs/design/reference-corpus.md +1726 -0
  121. package/docs/design/render-critique-loop.md +57 -0
  122. package/docs/spec-graph-schema.md +397 -0
  123. package/package.json +1 -1
  124. package/scripts/bench-scorecard.mjs +239 -0
  125. package/scripts/build-spec-graph.mjs +509 -0
  126. package/scripts/classify-lane.sh +0 -7
  127. package/scripts/design-system-lint.mjs +993 -0
  128. package/scripts/graph-query.sh +486 -0
  129. package/scripts/transition-label.sh +10 -2
  130. package/scripts/validate-spec-graph.sh +423 -0
  131. package/templates/workflows/forgedock-review.yml +96 -0
@@ -0,0 +1,929 @@
1
+ ---
2
+ description: Deterministic deploy-gate — verify a staging→main bundle's acceptance criteria against running code before deploy
3
+ argument-hint: [--prs "<N1 N2 ...>"] [--base <branch>]
4
+ allowed-tools: Task, Bash, Read, Grep, Glob
5
+ ---
6
+ <!-- SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios -->
7
+ <!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
8
+
9
+ # /test-gate — Deterministic Deploy-Gate Testing
10
+
11
+ **Input**: `$ARGUMENTS` — optional `--prs "<space-separated PR numbers>"` and `--base <branch>`.
12
+
13
+ Verifies a staging→main bundle's acceptance criteria against running code before deploy. Called by `/review-pr-staging` (Phase 6.5) with the bundle PRs, or run standalone when `--prs` is absent (computes the bundle itself). Returns a machine-readable BLOCK / PASS / SKIP verdict for the caller to consume.
14
+
15
+ **Agent model policy**: Default `model: "sonnet"`. If Sonnet is rate-limited, fall back to `model: "opus"`.
16
+
17
+ **Posture**: Blocking-with-override at the staging→main boundary (mirrors the Phase 0A open-finding gate in `/review-pr-staging`). Posture is config-driven via `verification.test_gate`.
18
+
19
+ ---
20
+
21
+ ## Config Resolution
22
+
23
+ Read `forge.yaml` **before** running any commands. All variable references below are populated from this block.
24
+
25
+ ```bash
26
+ CONFIG_FILE="${FORGE_CONFIG:-forge.yaml}"
27
+ GH_REPO=$(yq '.project.owner + "/" + .project.repo' "$CONFIG_FILE")
28
+ GH_FLAG="-R $GH_REPO"
29
+ REPO_PATH=$(yq '.paths.root' "$CONFIG_FILE")
30
+ DEFAULT_BRANCH=$(yq '.branches.default' "$CONFIG_FILE")
31
+ STAGING_BRANCH=$(yq '.branches.staging' "$CONFIG_FILE")
32
+
33
+ # Test-gate config (all fields optional — defaults shown)
34
+ INTEGRATION_TESTS=$(yq '.verification.integration_tests // []' "$CONFIG_FILE")
35
+ TEST_SERVICES=$(yq '.verification.test_services // {}' "$CONFIG_FILE")
36
+ GATE_POSTURE=$(yq '.verification.test_gate.posture // "blocking"' "$CONFIG_FILE")
37
+ OVERRIDE_PHRASE=$(yq '.verification.test_gate.override_phrase // "OVERRIDE: shipping with test failures —"' "$CONFIG_FILE")
38
+ ```
39
+
40
+ ---
41
+
42
+ ## Input Parsing
43
+
44
+ ```bash
45
+ BUNDLE_PRS=""
46
+ BASE_BRANCH="${DEFAULT_BRANCH}"
47
+
48
+ # Parse --prs and --base from $ARGUMENTS
49
+ while [[ "$@" ]]; do
50
+ case "$1" in
51
+ --prs) shift; BUNDLE_PRS="$1";;
52
+ --base) shift; BASE_BRANCH="$1";;
53
+ esac
54
+ shift
55
+ done
56
+ ```
57
+
58
+ If `--prs` is absent, Phase 0 computes the bundle.
59
+
60
+ ---
61
+
62
+ ## Missing-Config Guard (MANDATORY — runs before all phases)
63
+
64
+ ```bash
65
+ # If integration_tests is empty/absent, exit ADVISORY — never crash
66
+ TEST_COUNT=$(yq '.verification.integration_tests | length' "$CONFIG_FILE" 2>/dev/null || echo 0)
67
+ if [ "${TEST_COUNT:-0}" -eq 0 ]; then
68
+ echo "ADVISORY: verification.integration_tests is not configured in $CONFIG_FILE."
69
+ echo "No tests to run. Emitting SKIP verdict."
70
+ echo ""
71
+ echo "To enable /test-gate, add to forge.yaml:"
72
+ echo " verification:"
73
+ echo " integration_tests:"
74
+ echo " - cluster: \"api\""
75
+ echo " command: \"pytest tests/integration/ -q --tb=short\""
76
+ echo " working_dir: \".\""
77
+ echo " test_services:"
78
+ echo " api: \"your-api-container-name\""
79
+ echo " test_gate:"
80
+ echo " posture: \"blocking\""
81
+ echo " override_phrase: \"OVERRIDE: shipping with test failures —\""
82
+ # Emit structured SKIP verdict and exit
83
+ echo "<!-- FORGE:TEST_GATE:SKIP|reason=no-tests-configured -->"
84
+ echo "<!-- FORGE:TEST_GATE:RESULT=SKIP -->"
85
+ exit 0
86
+ fi
87
+ ```
88
+
89
+ ---
90
+
91
+ ## Phase 0: Triage — Test-or-Skip
92
+
93
+ **Purpose**: Decide test-or-skip before any provisioning. Three ordered checks:
94
+ 1. **0A** — no PRs in bundle → SKIP
95
+ 2. **0B** — no executable file changes → SKIP (docs/config/markdown-only)
96
+ 3. **0C** — no runtime-testable acceptance criteria → SKIP (all-manual or no criteria)
97
+
98
+ Every SKIP emits both a machine-readable verdict (`FORGE:TEST_GATE:RESULT=SKIP`) and a logged reason annotation (`FORGE:TEST_GATE:SKIP|reason=...`) so the caller and pipeline-health see a deliberate skip, not a silent gap.
99
+
100
+ ### 0A: Resolve bundle PRs
101
+
102
+ ```bash
103
+ git fetch origin ${DEFAULT_BRANCH} ${STAGING_BRANCH} 2>/dev/null
104
+
105
+ if [ -n "$BUNDLE_PRS" ]; then
106
+ # Caller passed PR numbers explicitly (standard case when invoked by review-pr-staging)
107
+ echo "Bundle PRs (from --prs): $BUNDLE_PRS"
108
+ else
109
+ # Standalone invocation: compute bundle from staging→main diff
110
+ BUNDLE_PRS=$(git log origin/${DEFAULT_BRANCH}..origin/${STAGING_BRANCH} --merges --oneline \
111
+ | grep -oP '(?<=pull request #)\d+' \
112
+ | sort -u \
113
+ | tr '\n' ' ')
114
+ echo "Bundle PRs (computed from ${STAGING_BRANCH}→${DEFAULT_BRANCH}): $BUNDLE_PRS"
115
+ fi
116
+
117
+ if [ -z "$BUNDLE_PRS" ]; then
118
+ echo "No PRs found in bundle. Emitting SKIP verdict."
119
+ echo "<!-- FORGE:TEST_GATE:SKIP|reason=no-bundle-prs -->"
120
+ echo "<!-- FORGE:TEST_GATE:RESULT=SKIP -->"
121
+ exit 0
122
+ fi
123
+ ```
124
+
125
+ ### 0B: Detect executable changes
126
+
127
+ ```bash
128
+ BUNDLE_DIFF=$(git diff origin/${BASE_BRANCH}...origin/${STAGING_BRANCH} --name-only 2>/dev/null)
129
+
130
+ EXECUTABLE_FILES=$(echo "$BUNDLE_DIFF" | grep -vE '\.(md|txt|yaml|yml|json|toml|lock|ini|cfg|env\.example|gitignore)$' | grep -v '^docs/' | grep -v '^\.github/' || true)
131
+
132
+ if [ -z "$EXECUTABLE_FILES" ]; then
133
+ echo "Bundle contains only documentation/config changes. No runtime testing required."
134
+ echo "Files changed:"
135
+ echo "$BUNDLE_DIFF"
136
+ echo "RESULT: SKIP (no executable changes in bundle)"
137
+ echo "<!-- FORGE:TEST_GATE:SKIP|reason=no-executable-changes -->"
138
+ echo "<!-- FORGE:TEST_GATE:RESULT=SKIP -->"
139
+ exit 0
140
+ fi
141
+
142
+ echo "Executable files changed in bundle:"
143
+ echo "$EXECUTABLE_FILES"
144
+ ```
145
+
146
+ ### 0C: Criteria pre-check — test-or-skip before provisioning
147
+
148
+ Check whether any solved issue in the bundle carries at least one runtime-testable acceptance criterion (`[type:api]`, `[type:unit]`, or `[type:e2e]`, or an unannotated criterion that does not match `[type:manual]`). Skip provisioning entirely if all criteria are manual-only or no criteria exist.
149
+
150
+ ```bash
151
+ echo "=== Phase 0C: Criteria pre-check ==="
152
+
153
+ TRIAGE_HAS_TESTABLE_CRITERIA=false
154
+ TRIAGE_ALL_ISSUES=""
155
+ TRIAGE_CRITERIA_COUNT=0
156
+ TRIAGE_MANUAL_COUNT=0
157
+
158
+ for pr_num in $BUNDLE_PRS; do
159
+ # Extract closing issue references from PR body and GitHub API
160
+ PR_BODY=$(gh pr view "$pr_num" ${GH_FLAG} --json body --jq '.body' 2>/dev/null || echo "")
161
+ CLOSED_ISSUES=$(echo "$PR_BODY" | grep -iP '(closes|fixes|resolves)\s+#\d+' | grep -oP '#\d+' | tr -d '#' || true)
162
+ LINKED_ISSUES=$(gh pr view "$pr_num" ${GH_FLAG} --json closingIssuesReferences \
163
+ --jq '.closingIssuesReferences[].number' 2>/dev/null || true)
164
+ ALL_ISSUES_FOR_PR=$(echo "${CLOSED_ISSUES} ${LINKED_ISSUES}" | tr ' ' '\n' | sort -u | grep -E '^[0-9]+$' || true)
165
+ TRIAGE_ALL_ISSUES="${TRIAGE_ALL_ISSUES} ${ALL_ISSUES_FOR_PR}"
166
+
167
+ for issue_num in $ALL_ISSUES_FOR_PR; do
168
+ CRITERIA=$(gh issue view "$issue_num" ${GH_FLAG} --json body \
169
+ --jq '.body' 2>/dev/null \
170
+ | awk '/^## Acceptance Criteria/{found=1; next} /^## /{if(found) exit} found{print}' \
171
+ | grep -E '^- \[' || true)
172
+
173
+ if [ -z "$CRITERIA" ]; then
174
+ continue
175
+ fi
176
+
177
+ # Check for any non-manual criterion — exit early as soon as one is found
178
+ while IFS= read -r criterion; do
179
+ [ -z "$criterion" ] && continue
180
+ TRIAGE_CRITERIA_COUNT=$((TRIAGE_CRITERIA_COUNT + 1))
181
+ if echo "$criterion" | grep -qP '\[type:(api|unit|e2e)\]'; then
182
+ # Explicit automated annotation found
183
+ TRIAGE_HAS_TESTABLE_CRITERIA=true
184
+ echo " Testable criterion found in issue #${issue_num}: ${criterion}"
185
+ break 3 # Break out of criterion loop, issue loop, and PR loop
186
+ elif echo "$criterion" | grep -qP '\[type:manual\]'; then
187
+ TRIAGE_MANUAL_COUNT=$((TRIAGE_MANUAL_COUNT + 1))
188
+ else
189
+ # Unannotated criterion — apply same regex heuristics as Phase 2
190
+ if echo "$criterion" | grep -qP '(endpoint|request|response|status\s+\d{3}|curl|API|HTTP|unit|function|return|assert|throws|browser|click|navigate|render|page|user flow)'; then
191
+ TRIAGE_HAS_TESTABLE_CRITERIA=true
192
+ echo " Testable criterion found (inferred) in issue #${issue_num}: ${criterion}"
193
+ break 3 # Break out of all loops
194
+ else
195
+ # No type signal — treat as manual for triage purposes
196
+ TRIAGE_MANUAL_COUNT=$((TRIAGE_MANUAL_COUNT + 1))
197
+ fi
198
+ fi
199
+ done <<< "$CRITERIA"
200
+ done
201
+ done
202
+
203
+ TRIAGE_ISSUE_COUNT=$(echo "$TRIAGE_ALL_ISSUES" | tr ' ' '\n' | sort -u | grep -cE '^[0-9]+$' || echo 0)
204
+ echo "Triage summary: ${TRIAGE_ISSUE_COUNT} solved issue(s), ${TRIAGE_CRITERIA_COUNT} criterion/criteria found, ${TRIAGE_MANUAL_COUNT} manual"
205
+
206
+ if [ "$TRIAGE_HAS_TESTABLE_CRITERIA" = "false" ]; then
207
+ if [ "${TRIAGE_CRITERIA_COUNT}" -eq 0 ]; then
208
+ SKIP_REASON="no-acceptance-criteria"
209
+ echo "No acceptance criteria found in any solved issue. No automated tests to run."
210
+ else
211
+ SKIP_REASON="no-testable-criteria"
212
+ echo "All ${TRIAGE_CRITERIA_COUNT} acceptance criteria are manual-only. No automated tests to run."
213
+ fi
214
+ echo "Emitting SKIP verdict (triage: ${SKIP_REASON})."
215
+ echo "<!-- FORGE:TEST_GATE:SKIP|reason=${SKIP_REASON} -->"
216
+ echo "<!-- FORGE:TEST_GATE:RESULT=SKIP -->"
217
+ exit 0
218
+ fi
219
+
220
+ echo "Runtime-testable criteria confirmed. Proceeding with provisioning."
221
+ ```
222
+
223
+ ---
224
+
225
+ ## Phase 1: Collate — Bundle PRs → Solved Issues → Acceptance Criteria
226
+
227
+ ```bash
228
+ echo "=== Phase 1: Collating acceptance criteria from bundle ==="
229
+
230
+ COLLATED_CRITERIA=""
231
+ SOLVED_ISSUES=""
232
+
233
+ for pr_num in $BUNDLE_PRS; do
234
+ # Get PR body to extract closing references
235
+ PR_BODY=$(gh pr view "$pr_num" ${GH_FLAG} --json body --jq '.body' 2>/dev/null || echo "")
236
+
237
+ # Extract issue numbers from Closes/Fixes references (case-insensitive)
238
+ CLOSED_ISSUES=$(echo "$PR_BODY" | grep -iP '(closes|fixes|resolves)\s+#\d+' | grep -oP '#\d+' | tr -d '#' || true)
239
+
240
+ # Also extract from linked issues via GitHub API
241
+ LINKED_ISSUES=$(gh pr view "$pr_num" ${GH_FLAG} --json closingIssuesReferences \
242
+ --jq '.closingIssuesReferences[].number' 2>/dev/null || true)
243
+
244
+ ALL_ISSUES=$(echo "${CLOSED_ISSUES} ${LINKED_ISSUES}" | tr ' ' '\n' | sort -u | grep -E '^[0-9]+$' || true)
245
+
246
+ for issue_num in $ALL_ISSUES; do
247
+ SOLVED_ISSUES="${SOLVED_ISSUES} ${issue_num}"
248
+
249
+ # Extract Acceptance Criteria section from issue body
250
+ CRITERIA=$(gh issue view "$issue_num" ${GH_FLAG} --json body \
251
+ --jq '.body' 2>/dev/null \
252
+ | awk '/^## Acceptance Criteria/{found=1; next} /^## /{if(found) exit} found{print}' \
253
+ | grep -E '^- \[' || true)
254
+
255
+ if [ -n "$CRITERIA" ]; then
256
+ COLLATED_CRITERIA="${COLLATED_CRITERIA}
257
+ ### Issue #${issue_num} (PR #${pr_num})
258
+ ${CRITERIA}"
259
+ fi
260
+ done
261
+ done
262
+
263
+ echo "Solved issues: $(echo $SOLVED_ISSUES | tr ' ' '\n' | sort -u | tr '\n' ' ')"
264
+ echo ""
265
+ echo "Collated Acceptance Criteria:"
266
+ echo "$COLLATED_CRITERIA"
267
+ ```
268
+
269
+ ---
270
+
271
+ ## Phase 2: Classify — Bucket Criteria by Test Type
272
+
273
+ Bucket each acceptance criterion by `[type:api|unit|e2e|manual]` annotation. Regex fallback when unannotated.
274
+
275
+ ```bash
276
+ echo "=== Phase 2: Classifying criteria by test type ==="
277
+
278
+ API_CRITERIA=""
279
+ UNIT_CRITERIA=""
280
+ E2E_CRITERIA=""
281
+ MANUAL_CRITERIA=""
282
+ UNCLASSIFIED=""
283
+
284
+ while IFS= read -r line; do
285
+ if echo "$line" | grep -qP '\[type:api\]'; then
286
+ API_CRITERIA="${API_CRITERIA}\n${line}"
287
+ elif echo "$line" | grep -qP '\[type:unit\]'; then
288
+ UNIT_CRITERIA="${UNIT_CRITERIA}\n${line}"
289
+ elif echo "$line" | grep -qP '\[type:e2e\]'; then
290
+ E2E_CRITERIA="${E2E_CRITERIA}\n${line}"
291
+ elif echo "$line" | grep -qP '\[type:manual\]'; then
292
+ MANUAL_CRITERIA="${MANUAL_CRITERIA}\n${line}"
293
+ elif echo "$line" | grep -qP '(endpoint|request|response|status\s+\d{3}|curl|API|HTTP)'; then
294
+ API_CRITERIA="${API_CRITERIA}\n${line} [inferred:api]"
295
+ elif echo "$line" | grep -qP '(unit|function|return|assert|throws)'; then
296
+ UNIT_CRITERIA="${UNIT_CRITERIA}\n${line} [inferred:unit]"
297
+ elif echo "$line" | grep -qP '(browser|click|navigate|render|page|user flow)'; then
298
+ E2E_CRITERIA="${E2E_CRITERIA}\n${line} [inferred:e2e]"
299
+ elif echo "$line" | grep -qP '^-\s+\['; then
300
+ # Has content but no type signal — treat as manual
301
+ MANUAL_CRITERIA="${MANUAL_CRITERIA}\n${line} [inferred:manual]"
302
+ fi
303
+ done <<< "$(echo -e "$COLLATED_CRITERIA")"
304
+
305
+ echo "API criteria: $(echo -e "$API_CRITERIA" | grep -c '^\-' || echo 0)"
306
+ echo "Unit criteria: $(echo -e "$UNIT_CRITERIA" | grep -c '^\-' || echo 0)"
307
+ echo "E2E criteria: $(echo -e "$E2E_CRITERIA" | grep -c '^\-' || echo 0)"
308
+ echo "Manual criteria: $(echo -e "$MANUAL_CRITERIA" | grep -c '^\-' || echo 0)"
309
+
310
+ AUTOMATED_CRITERIA="${API_CRITERIA}${UNIT_CRITERIA}${E2E_CRITERIA}"
311
+ if [ -z "$(echo -e "$AUTOMATED_CRITERIA" | grep -E '^-')" ]; then
312
+ echo "All criteria are manual — no automated test clusters to run."
313
+ echo "Emitting SKIP verdict (manual-only bundle — defense-in-depth check after Phase 0C)."
314
+ echo "<!-- FORGE:TEST_GATE:SKIP|reason=manual-only-criteria -->"
315
+ echo "<!-- FORGE:TEST_GATE:RESULT=SKIP -->"
316
+ exit 0
317
+ fi
318
+ ```
319
+
320
+ ---
321
+
322
+ ## Phase 3: Provision — Bring Up Test Services
323
+
324
+ For each cluster in `verification.integration_tests`, verify the mapped container is running. CLI-only projects (no `test_services` mapping) skip this phase.
325
+
326
+ ```bash
327
+ echo "=== Phase 3: Provisioning test services ==="
328
+
329
+ PROVISION_FAILURES=""
330
+ CLUSTERS=$(yq '.verification.integration_tests[].cluster' "$CONFIG_FILE" 2>/dev/null | sort -u)
331
+
332
+ for cluster in $CLUSTERS; do
333
+ # Look up container name for this cluster
334
+ CONTAINER=$(yq ".verification.test_services.${cluster} // \"\"" "$CONFIG_FILE" 2>/dev/null || echo "")
335
+
336
+ if [ -z "$CONTAINER" ] || [ "$CONTAINER" = "null" ]; then
337
+ echo "ADVISORY: No container mapped for cluster '${cluster}' in verification.test_services."
338
+ echo " Running in CLI-only mode — skipping container health check for '${cluster}'."
339
+ continue
340
+ fi
341
+
342
+ # Check if container is running
343
+ CONTAINER_STATE=$(docker inspect --format '{{.State.Running}}' "$CONTAINER" 2>/dev/null || echo "false")
344
+ if [ "$CONTAINER_STATE" != "true" ]; then
345
+ echo "WARNING: Container '${CONTAINER}' (cluster: ${cluster}) is not running."
346
+ PROVISION_FAILURES="${PROVISION_FAILURES}\n- Cluster '${cluster}': container '${CONTAINER}' not running"
347
+ else
348
+ echo "OK: Container '${CONTAINER}' (cluster: ${cluster}) is healthy."
349
+ fi
350
+ done
351
+
352
+ if [ -n "$PROVISION_FAILURES" ]; then
353
+ echo ""
354
+ echo "Provision warnings (advisory — continuing with available clusters):"
355
+ echo -e "$PROVISION_FAILURES"
356
+ fi
357
+ ```
358
+
359
+ ---
360
+
361
+ ## Phase 4: Fan Out — Execute Test Clusters
362
+
363
+ Spawn a narrow-band test subagent per cluster. Each executes its configured command and captures pass/fail output. Flaky-retry: up to 2 retries per cluster on non-deterministic failures.
364
+
365
+ ```bash
366
+ echo "=== Phase 4: Fanning out test clusters ==="
367
+
368
+ CLUSTER_RESULTS=""
369
+ CLUSTER_COUNT=$(yq '.verification.integration_tests | length' "$CONFIG_FILE" 2>/dev/null || echo 0)
370
+
371
+ for i in $(seq 0 $((CLUSTER_COUNT - 1))); do
372
+ CLUSTER=$(yq ".verification.integration_tests[${i}].cluster" "$CONFIG_FILE")
373
+ CMD=$(yq ".verification.integration_tests[${i}].command" "$CONFIG_FILE")
374
+ WORKING_DIR=$(yq ".verification.integration_tests[${i}].working_dir // \".\"" "$CONFIG_FILE")
375
+
376
+ echo "Running cluster '${CLUSTER}': ${CMD}"
377
+
378
+ RETRY=0
379
+ MAX_RETRIES=2
380
+ CLUSTER_EXIT=1
381
+
382
+ while [ $RETRY -le $MAX_RETRIES ]; do
383
+ if (cd "${REPO_PATH}/${WORKING_DIR}" && eval "$CMD") 2>&1; then
384
+ CLUSTER_EXIT=0
385
+ break
386
+ else
387
+ CLUSTER_EXIT=$?
388
+ RETRY=$((RETRY + 1))
389
+ if [ $RETRY -le $MAX_RETRIES ]; then
390
+ echo " Cluster '${CLUSTER}' failed (exit ${CLUSTER_EXIT}) — retry ${RETRY}/${MAX_RETRIES}..."
391
+ fi
392
+ fi
393
+ done
394
+
395
+ if [ "$CLUSTER_EXIT" -eq 0 ]; then
396
+ CLUSTER_RESULTS="${CLUSTER_RESULTS}\n- ${CLUSTER}: PASS"
397
+ echo " PASS: cluster '${CLUSTER}'"
398
+ else
399
+ CLUSTER_RESULTS="${CLUSTER_RESULTS}\n- ${CLUSTER}: FAIL (after ${MAX_RETRIES} retries)"
400
+ echo " FAIL: cluster '${CLUSTER}'"
401
+ fi
402
+ done
403
+
404
+ echo ""
405
+ echo "Cluster results:"
406
+ echo -e "$CLUSTER_RESULTS"
407
+ ```
408
+
409
+ ---
410
+
411
+ ## Phase 5: Baseline Comparison + Test-Failure Issue Creation
412
+
413
+ Capture a pre-batch baseline from the base branch (or previous run) to distinguish batch-introduced failures from pre-existing ones. File `test-failure` issues for batch-introduced failures only.
414
+
415
+ ### 5A: Capture baseline
416
+
417
+ ```bash
418
+ echo "=== Phase 5A: Capturing baseline from base branch (${BASE_BRANCH}) ==="
419
+
420
+ BASELINE_RESULTS=""
421
+
422
+ # Run the same tests against the base branch state to establish baseline
423
+ # If base-branch tests also fail, those are pre-existing failures — not batch-introduced
424
+ git stash 2>/dev/null || true
425
+ git checkout "origin/${BASE_BRANCH}" -- . 2>/dev/null || true
426
+
427
+ for i in $(seq 0 $((CLUSTER_COUNT - 1))); do
428
+ CLUSTER=$(yq ".verification.integration_tests[${i}].cluster" "$CONFIG_FILE")
429
+ CMD=$(yq ".verification.integration_tests[${i}].command" "$CONFIG_FILE")
430
+ WORKING_DIR=$(yq ".verification.integration_tests[${i}].working_dir // \".\"" "$CONFIG_FILE")
431
+
432
+ if (cd "${REPO_PATH}/${WORKING_DIR}" && eval "$CMD") 2>&1; then
433
+ BASELINE_RESULTS="${BASELINE_RESULTS}\n- ${CLUSTER}: PASS"
434
+ else
435
+ BASELINE_RESULTS="${BASELINE_RESULTS}\n- ${CLUSTER}: FAIL (pre-existing)"
436
+ fi
437
+ done
438
+
439
+ # Restore working state
440
+ git checkout HEAD -- . 2>/dev/null || true
441
+
442
+ echo "Baseline results:"
443
+ echo -e "$BASELINE_RESULTS"
444
+ ```
445
+
446
+ ### 5B: Identify batch-introduced failures
447
+
448
+ ```bash
449
+ echo "=== Phase 5B: Identifying batch-introduced failures ==="
450
+
451
+ BATCH_FAILURES=""
452
+
453
+ while IFS= read -r result_line; do
454
+ CLUSTER=$(echo "$result_line" | grep -oP '^- \K[^:]+')
455
+ STATUS=$(echo "$result_line" | grep -oP ':\s+\K.+')
456
+
457
+ if echo "$STATUS" | grep -q "^FAIL"; then
458
+ # Check if this cluster also failed in baseline
459
+ BASELINE_STATUS=$(echo -e "$BASELINE_RESULTS" | grep "^- ${CLUSTER}:" | grep -oP ':\s+\K.+' || echo "PASS")
460
+ if echo "$BASELINE_STATUS" | grep -q "^PASS"; then
461
+ # This cluster passed in baseline but fails in the bundle — batch-introduced failure
462
+ BATCH_FAILURES="${BATCH_FAILURES}\n${CLUSTER}"
463
+ echo "BATCH-INTRODUCED FAILURE: cluster '${CLUSTER}' (baseline: PASS → bundle: FAIL)"
464
+ else
465
+ echo "PRE-EXISTING FAILURE: cluster '${CLUSTER}' (baseline: FAIL → bundle: FAIL — not this batch)"
466
+ fi
467
+ fi
468
+ done <<< "$(echo -e "$CLUSTER_RESULTS")"
469
+ ```
470
+
471
+ ### 5C: File test-failure issues for batch-introduced failures
472
+
473
+ ```bash
474
+ echo "=== Phase 5C: Filing test-failure issues ==="
475
+
476
+ if [ -z "$(echo -e "$BATCH_FAILURES" | grep -E '\S')" ]; then
477
+ echo "No batch-introduced failures — skipping issue creation."
478
+ else
479
+ # Ensure test-failure label exists
480
+ gh label create "test-failure" --color "B60205" \
481
+ --description "Runtime test failure introduced by a bundle of PRs. Managed by ForgeDock." \
482
+ --force ${GH_FLAG} 2>/dev/null || true
483
+
484
+ while IFS= read -r cluster; do
485
+ [ -z "$cluster" ] && continue
486
+
487
+ # Duplicate prevention: check for open test-failure issues for this cluster
488
+ EXISTING=$(gh issue list ${GH_FLAG} \
489
+ --label "test-failure" \
490
+ --state open \
491
+ --search "test-gate cluster ${cluster}" \
492
+ --limit 5 \
493
+ --json number,title \
494
+ --jq '.[0].number // empty' 2>/dev/null || true)
495
+
496
+ if [ -n "$EXISTING" ]; then
497
+ echo "Skipping issue creation — open test-failure issue already exists: #${EXISTING} (cluster: ${cluster})"
498
+ continue
499
+ fi
500
+
501
+ # Identify source issues for this cluster's PRs
502
+ SOURCE_ISSUES_LIST=""
503
+ for issue_num in $(echo "$SOLVED_ISSUES" | tr ' ' '\n' | sort -u | grep -E '^[0-9]+$'); do
504
+ SOURCE_ISSUES_LIST="${SOURCE_ISSUES_LIST}\n- #${issue_num}"
505
+ done
506
+
507
+ ISSUE_NUM=$(gh issue create ${GH_FLAG} \
508
+ --title "fix: test-gate FAIL — cluster '${cluster}' broken by staging→${DEFAULT_BRANCH} bundle" \
509
+ --label "test-failure,priority:P1" \
510
+ --body "$(cat <<ISSUE_EOF
511
+ ## Problem
512
+
513
+ The \`/test-gate\` command detected a runtime failure in cluster \`${cluster}\` that was introduced by the staging→${DEFAULT_BRANCH} bundle. The failure did not exist on \`${BASE_BRANCH}\` (baseline PASS), confirming it is batch-introduced, not pre-existing.
514
+
515
+ ## Root Cause (if known)
516
+
517
+ Root cause unknown — investigation needed. The test command for cluster \`${cluster}\` failed after 2 retries.
518
+
519
+ ## Affected Files
520
+
521
+ Files to be identified during investigation. Start with changes in the bundle PRs: $(echo $BUNDLE_PRS | tr ' ' ', ' | sed 's/,\s*$//').
522
+
523
+ ## Acceptance Criteria
524
+
525
+ - [ ] Root cause of cluster \`${cluster}\` failure identified
526
+ - [ ] Fix implemented and cluster passes in isolation
527
+ - [ ] Baseline comparison confirms no regression in \`${BASE_BRANCH}\`
528
+ - [ ] /test-gate emits PASS verdict for this bundle after fix
529
+
530
+ ## Context
531
+
532
+ **Detected by**: \`/test-gate\` — Phase 5 baseline comparison
533
+ **Bundle PRs**: ${BUNDLE_PRS}
534
+ **Base branch**: \`${BASE_BRANCH}\`
535
+ **Cluster**: \`${cluster}\`
536
+ **Baseline result**: PASS (cluster was healthy on \`${BASE_BRANCH}\`)
537
+ **Bundle result**: FAIL (cluster broken after applying bundle)
538
+
539
+ ## Source Issues
540
+
541
+ The following issues were solved by this bundle:
542
+ $(echo -e "$SOURCE_ISSUES_LIST")
543
+
544
+ ## Reproduction Steps
545
+
546
+ 1. Check out \`origin/${STAGING_BRANCH}\`
547
+ 2. Run the cluster test command from \`forge.yaml\`:
548
+ \`\`\`bash
549
+ $(yq ".verification.integration_tests[] | select(.cluster == \"${cluster}\") | .command" "$CONFIG_FILE")
550
+ \`\`\`
551
+ 3. Observe failure. Compare against \`${BASE_BRANCH}\` baseline:
552
+ \`\`\`bash
553
+ git checkout origin/${BASE_BRANCH} -- .
554
+ $(yq ".verification.integration_tests[] | select(.cluster == \"${cluster}\") | .command" "$CONFIG_FILE")
555
+ \`\`\`
556
+ ISSUE_EOF
557
+ )" --json number --jq '.number' 2>/dev/null || echo "FAILED")
558
+
559
+ if [ "$ISSUE_NUM" != "FAILED" ] && [ -n "$ISSUE_NUM" ]; then
560
+ echo "Filed test-failure issue #${ISSUE_NUM} for cluster '${cluster}'"
561
+ else
562
+ echo "WARNING: Failed to create test-failure issue for cluster '${cluster}'"
563
+ fi
564
+ done <<< "$(echo -e "$BATCH_FAILURES")"
565
+ fi
566
+ ```
567
+
568
+ ---
569
+
570
+ ## Phase 6: Criteria-Adequacy Check
571
+
572
+ Reconcile the executed test plan against each solved issue's acceptance criteria. For each solved issue, map individual acceptance criteria to the clusters that ran. Flag uncovered criteria (runtime-testable, no cluster maps to them) and untestable-as-written criteria (vague oracle, no deterministic verification) as distinct finding classes. Coverage gaps contribute to the BLOCK verdict.
573
+
574
+ ```bash
575
+ echo "=== Phase 6: Criteria-adequacy check ==="
576
+
577
+ # Initialize adequacy state — all variables must be set before Phase 7 reads them
578
+ COVERAGE_BLOCK=false
579
+ UNCOVERED_BLOCK_COUNT=0
580
+ ADEQUACY_COVERED_LIST=""
581
+ ADEQUACY_UNCOVERED_LIST=""
582
+ ADEQUACY_UNTESTABLE_LIST=""
583
+ ADEQUACY_MANUAL_LIST=""
584
+
585
+ # Build a flat list of cluster names that passed, for mapping lookup
586
+ PASSING_CLUSTERS=$(echo -e "$CLUSTER_RESULTS" | grep ": PASS" | grep -oP '^- \K[^:]+' | tr '\n' ' ' || true)
587
+ ALL_CLUSTERS=$(yq '.verification.integration_tests[].cluster' "$CONFIG_FILE" 2>/dev/null | tr '\n' ' ' || true)
588
+
589
+ echo "Passing clusters: ${PASSING_CLUSTERS:-none}"
590
+ echo "All configured clusters: ${ALL_CLUSTERS:-none}"
591
+ echo ""
592
+
593
+ # Helper: classify a single criterion line into one of four buckets:
594
+ # covered, uncovered, untestable, manual
595
+ # Arguments: $1 = criterion text, $2 = source_issue, $3 = source_pr
596
+ classify_criterion() {
597
+ local criterion="$1"
598
+ local source_issue="$2"
599
+ local source_pr="$3"
600
+
601
+ # --- Step 1: Detect explicitly manual criteria ---
602
+ if echo "$criterion" | grep -qP '\[type:manual\]'; then
603
+ ADEQUACY_MANUAL_LIST="${ADEQUACY_MANUAL_LIST}\n [manual] Issue #${source_issue} (PR #${source_pr}): ${criterion}"
604
+ return
605
+ fi
606
+
607
+ # --- Step 2: Detect untestable-as-written criteria ---
608
+ # Vague oracles: subjective language, pure process requirements, non-deterministic assertions
609
+ if echo "$criterion" | grep -qiP '(should feel|must be appropriate|is good|as needed|where applicable|adequacy findings are filed|filed as tracked|flow back through|human validation|manual review|out-of-band|up to the reviewer|at the discretion|be reasonable|be sufficient|when possible|if applicable)'; then
610
+ ADEQUACY_UNTESTABLE_LIST="${ADEQUACY_UNTESTABLE_LIST}\n [untestable-as-written] Issue #${source_issue} (PR #${source_pr}): ${criterion}"
611
+ return
612
+ fi
613
+
614
+ # --- Step 3: Determine if criterion is runtime-testable ---
615
+ local is_automated=false
616
+ local inferred_type=""
617
+
618
+ if echo "$criterion" | grep -qP '\[type:(api|unit|e2e)\]'; then
619
+ is_automated=true
620
+ inferred_type=$(echo "$criterion" | grep -oP '\[type:\K(api|unit|e2e)' || echo "automated")
621
+ elif echo "$criterion" | grep -qP '(endpoint|request|response|status\s+\d{3}|curl|API|HTTP)'; then
622
+ is_automated=true
623
+ inferred_type="api"
624
+ elif echo "$criterion" | grep -qP '(unit|function|return|assert|throws)'; then
625
+ is_automated=true
626
+ inferred_type="unit"
627
+ elif echo "$criterion" | grep -qP '(browser|click|navigate|render|page|user flow)'; then
628
+ is_automated=true
629
+ inferred_type="e2e"
630
+ fi
631
+
632
+ if [ "$is_automated" = "false" ]; then
633
+ # No automation signal — treat as manual (inferred)
634
+ ADEQUACY_MANUAL_LIST="${ADEQUACY_MANUAL_LIST}\n [manual/inferred] Issue #${source_issue} (PR #${source_pr}): ${criterion}"
635
+ return
636
+ fi
637
+
638
+ # --- Step 4: Map automated criterion to a cluster ---
639
+ # A criterion is "covered" if at least one passing cluster name or cluster type
640
+ # matches the criterion's inferred type or the criterion text.
641
+ local is_covered=false
642
+
643
+ for cluster in $PASSING_CLUSTERS; do
644
+ # Match: cluster name substring appears in criterion text, OR
645
+ # cluster name matches the inferred test type (api/unit/e2e)
646
+ if echo "$criterion" | grep -qiF "$cluster"; then
647
+ is_covered=true
648
+ break
649
+ fi
650
+ if [ "$inferred_type" = "$cluster" ]; then
651
+ is_covered=true
652
+ break
653
+ fi
654
+ # Broad type-level match: if a passing cluster name contains the type keyword
655
+ if echo "$cluster" | grep -qiF "$inferred_type"; then
656
+ is_covered=true
657
+ break
658
+ fi
659
+ done
660
+
661
+ if [ "$is_covered" = "true" ]; then
662
+ ADEQUACY_COVERED_LIST="${ADEQUACY_COVERED_LIST}\n [covered:${inferred_type}] Issue #${source_issue} (PR #${source_pr}): ${criterion}"
663
+ else
664
+ ADEQUACY_UNCOVERED_LIST="${ADEQUACY_UNCOVERED_LIST}\n [UNCOVERED:${inferred_type}] Issue #${source_issue} (PR #${source_pr}): ${criterion}"
665
+ UNCOVERED_BLOCK_COUNT=$((UNCOVERED_BLOCK_COUNT + 1))
666
+ fi
667
+ }
668
+
669
+ # --- Iterate COLLATED_CRITERIA by issue section ---
670
+ # COLLATED_CRITERIA format:
671
+ # ### Issue #N (PR #M)
672
+ # - [ ] criterion text
673
+ # - [x] completed criterion
674
+ CURRENT_ISSUE=""
675
+ CURRENT_PR=""
676
+
677
+ while IFS= read -r line; do
678
+ # Detect section header: ### Issue #N (PR #M)
679
+ if echo "$line" | grep -qP '^### Issue #\d+'; then
680
+ CURRENT_ISSUE=$(echo "$line" | grep -oP '(?<=Issue #)\d+' | head -1)
681
+ CURRENT_PR=$(echo "$line" | grep -oP '(?<=PR #)\d+' | head -1)
682
+ continue
683
+ fi
684
+
685
+ # Process criterion lines (both checked and unchecked)
686
+ if echo "$line" | grep -qP '^- \['; then
687
+ [ -z "$CURRENT_ISSUE" ] && continue
688
+ classify_criterion "$line" "$CURRENT_ISSUE" "$CURRENT_PR"
689
+ fi
690
+ done <<< "$(echo -e "$COLLATED_CRITERIA")"
691
+
692
+ # --- Report adequacy findings ---
693
+ COVERED_COUNT=$(echo -e "$ADEQUACY_COVERED_LIST" | grep -c '^\s*\[covered' || echo 0)
694
+ UNCOVERED_COUNT=$(echo -e "$ADEQUACY_UNCOVERED_LIST" | grep -c '^\s*\[UNCOVERED' || echo 0)
695
+ UNTESTABLE_COUNT=$(echo -e "$ADEQUACY_UNTESTABLE_LIST" | grep -c '^\s*\[untestable' || echo 0)
696
+ MANUAL_COUNT=$(echo -e "$ADEQUACY_MANUAL_LIST" | grep -c '^\s*\[manual' || echo 0)
697
+
698
+ echo "Criteria-adequacy summary:"
699
+ echo " Covered (mapped to a passing cluster): ${COVERED_COUNT}"
700
+ echo " Uncovered (runtime-testable, no cluster maps): ${UNCOVERED_COUNT}"
701
+ echo " Untestable-as-written (vague oracle): ${UNTESTABLE_COUNT}"
702
+ echo " Manual (require human validation): ${MANUAL_COUNT}"
703
+ echo ""
704
+
705
+ if [ "${COVERED_COUNT}" -gt 0 ]; then
706
+ echo "Covered criteria:"
707
+ echo -e "$ADEQUACY_COVERED_LIST"
708
+ echo ""
709
+ fi
710
+
711
+ if [ "${UNCOVERED_COUNT}" -gt 0 ]; then
712
+ echo "UNCOVERED criteria (coverage gaps — no passing cluster maps to these):"
713
+ echo -e "$ADEQUACY_UNCOVERED_LIST"
714
+ echo ""
715
+ fi
716
+
717
+ if [ "${UNTESTABLE_COUNT}" -gt 0 ]; then
718
+ echo "Untestable-as-written criteria (flagged — not counted as failures):"
719
+ echo -e "$ADEQUACY_UNTESTABLE_LIST"
720
+ echo ""
721
+ fi
722
+
723
+ if [ "${MANUAL_COUNT}" -gt 0 ]; then
724
+ echo "Manual criteria (require human validation — not covered by /test-gate):"
725
+ echo -e "$ADEQUACY_MANUAL_LIST"
726
+ echo ""
727
+ fi
728
+
729
+ # --- Set COVERAGE_BLOCK if uncovered runtime-testable criteria exist ---
730
+ if [ "${UNCOVERED_BLOCK_COUNT}" -gt 0 ]; then
731
+ if [ "$GATE_POSTURE" = "blocking" ]; then
732
+ COVERAGE_BLOCK=true
733
+ echo "COVERAGE BLOCK: ${UNCOVERED_BLOCK_COUNT} runtime-testable criterion/criteria have no mapped test cluster."
734
+ echo "Coverage gaps cannot produce a clean PASS verdict (posture: blocking)."
735
+ else
736
+ echo "COVERAGE ADVISORY: ${UNCOVERED_BLOCK_COUNT} runtime-testable criterion/criteria have no mapped test cluster."
737
+ echo "(posture: advisory — surfaced but deploy is not blocked by coverage gaps)"
738
+ fi
739
+ fi
740
+
741
+ # --- File test-gap issues for uncovered runtime-testable criteria ---
742
+ if [ "${UNCOVERED_COUNT}" -gt 0 ]; then
743
+ # Ensure test-gap label exists
744
+ gh label create "test-gap" --color "E4E669" \
745
+ --description "Acceptance criterion not covered by any /test-gate cluster. Filed by ForgeDock." \
746
+ --force ${GH_FLAG} 2>/dev/null || true
747
+
748
+ while IFS= read -r gap_line; do
749
+ [ -z "$(echo "$gap_line" | grep -E '\S')" ] && continue
750
+
751
+ # Extract issue and criterion from gap line format: [UNCOVERED:type] Issue #N (PR #M): criterion
752
+ GAP_ISSUE=$(echo "$gap_line" | grep -oP '(?<=Issue #)\d+' | head -1)
753
+ GAP_PR=$(echo "$gap_line" | grep -oP '(?<=PR #)\d+' | head -1)
754
+ GAP_TYPE=$(echo "$gap_line" | grep -oP '(?<=UNCOVERED:)[^\]]+' | head -1)
755
+ GAP_CRITERION=$(echo "$gap_line" | grep -oP '(?<=: )- \[.+' | head -1 || echo "$gap_line")
756
+
757
+ [ -z "$GAP_ISSUE" ] && continue
758
+
759
+ # Duplicate prevention: check for open test-gap issues referencing this issue+criterion
760
+ EXISTING_GAP=$(gh issue list ${GH_FLAG} \
761
+ --label "test-gap" \
762
+ --state open \
763
+ --search "test-gap issue #${GAP_ISSUE}" \
764
+ --limit 5 \
765
+ --json number,title \
766
+ --jq '.[0].number // empty' 2>/dev/null || true)
767
+
768
+ if [ -n "$EXISTING_GAP" ]; then
769
+ echo "Skipping test-gap issue creation — open issue already exists: #${EXISTING_GAP} (source: #${GAP_ISSUE})"
770
+ continue
771
+ fi
772
+
773
+ GAP_ISSUE_NUM=$(gh issue create ${GH_FLAG} \
774
+ --title "fix: test-gap — uncovered criterion in issue #${GAP_ISSUE} (${GAP_TYPE})" \
775
+ --label "test-gap" \
776
+ --body "$(cat <<TGAP_EOF
777
+ ## Problem
778
+
779
+ The \`/test-gate\` criteria-adequacy check (Phase 6) found a runtime-testable acceptance criterion in issue #${GAP_ISSUE} that is not covered by any configured test cluster. Coverage gaps cannot produce a clean PASS verdict — this criterion must be covered before the staging→${DEFAULT_BRANCH} bundle can pass the deploy gate.
780
+
781
+ ## Root Cause (if known)
782
+
783
+ No test cluster in \`forge.yaml verification.integration_tests\` maps to this criterion. Either:
784
+ 1. The test cluster that would cover this criterion does not exist yet (gap in test suite), or
785
+ 2. The cluster name does not match the criterion's keywords (mapping heuristic miss).
786
+
787
+ ## Affected Files
788
+
789
+ Files to be identified during investigation. Start with the test suite for the \`${GAP_TYPE}\` cluster.
790
+
791
+ ## Acceptance Criteria
792
+
793
+ - [ ] A test cluster that covers this criterion is identified or created
794
+ - [ ] The criterion maps to the cluster in a /test-gate run
795
+ - [ ] /test-gate Phase 6 reports this criterion as covered (not uncovered) for this bundle
796
+
797
+ ## Context
798
+
799
+ **Detected by**: \`/test-gate\` — Phase 6 criteria-adequacy check
800
+ **Source issue**: #${GAP_ISSUE}
801
+ **Source PR**: #${GAP_PR}
802
+ **Bundle PRs**: ${BUNDLE_PRS}
803
+ **Criterion type**: \`${GAP_TYPE}\`
804
+ **Uncovered criterion**:
805
+ \`\`\`
806
+ ${GAP_CRITERION}
807
+ \`\`\`
808
+ TGAP_EOF
809
+ )" --json number --jq '.number' 2>/dev/null || echo "FAILED")
810
+
811
+ if [ "$GAP_ISSUE_NUM" != "FAILED" ] && [ -n "$GAP_ISSUE_NUM" ]; then
812
+ echo "Filed test-gap issue #${GAP_ISSUE_NUM} for uncovered criterion in #${GAP_ISSUE} (${GAP_TYPE})"
813
+ else
814
+ echo "WARNING: Failed to create test-gap issue for uncovered criterion in #${GAP_ISSUE}"
815
+ fi
816
+ done <<< "$(echo -e "$ADEQUACY_UNCOVERED_LIST")"
817
+ fi
818
+
819
+ ---
820
+
821
+ ## Phase 7: Verdict — Emit Machine-Readable Result
822
+
823
+ Determine BLOCK / PASS / SKIP based on batch-introduced failures, cluster results, coverage gaps, and posture config.
824
+
825
+ ```bash
826
+ echo "=== Phase 7: Emitting verdict ==="
827
+
828
+ BATCH_FAILURE_COUNT=$(echo -e "$BATCH_FAILURES" | grep -c '\S' || echo 0)
829
+ TOTAL_PASS=$(echo -e "$CLUSTER_RESULTS" | grep -c "PASS" || echo 0)
830
+ TOTAL_FAIL=$(echo -e "$CLUSTER_RESULTS" | grep -c "FAIL" || echo 0)
831
+
832
+ # COVERAGE_BLOCK is set by Phase 6 when uncovered runtime-testable criteria exist
833
+ # and posture is blocking. It is initialized to false if Phase 6 found no gaps.
834
+ COVERAGE_BLOCK="${COVERAGE_BLOCK:-false}"
835
+ UNCOVERED_BLOCK_COUNT="${UNCOVERED_BLOCK_COUNT:-0}"
836
+
837
+ if [ "${BATCH_FAILURE_COUNT}" -gt 0 ]; then
838
+ VERDICT="BLOCK"
839
+ VERDICT_REASON="${BATCH_FAILURE_COUNT} cluster(s) failed with batch-introduced regressions: $(echo -e "$BATCH_FAILURES" | tr '\n' ', ' | sed 's/,\s*$//')"
840
+ elif [ "$COVERAGE_BLOCK" = "true" ]; then
841
+ # Coverage gaps: runtime-testable criteria exist but no cluster maps to them.
842
+ # A gate that runs some tests but skips verifying specific criteria is not a clean pass.
843
+ VERDICT="BLOCK"
844
+ VERDICT_REASON="${UNCOVERED_BLOCK_COUNT} runtime-testable acceptance criterion/criteria not covered by any test cluster. Coverage gaps cannot produce a clean PASS verdict."
845
+ elif [ "${TOTAL_FAIL}" -gt 0 ]; then
846
+ # All failures are pre-existing (baseline also failed) — not blocking
847
+ VERDICT="PASS"
848
+ VERDICT_REASON="${TOTAL_FAIL} pre-existing failure(s) detected (also failed on ${BASE_BRANCH} baseline — not introduced by this batch). ${TOTAL_PASS} cluster(s) passed."
849
+ else
850
+ VERDICT="PASS"
851
+ VERDICT_REASON="All ${TOTAL_PASS} cluster(s) passed. No batch-introduced failures. All runtime-testable criteria covered."
852
+ fi
853
+
854
+ echo ""
855
+ echo "============================================="
856
+ echo " TEST GATE VERDICT: ${VERDICT}"
857
+ echo "============================================="
858
+ echo " Reason: ${VERDICT_REASON}"
859
+ echo " Posture: ${GATE_POSTURE}"
860
+ echo " Bundle PRs: ${BUNDLE_PRS}"
861
+ echo " Solved issues: $(echo $SOLVED_ISSUES | tr ' ' '\n' | sort -u | tr '\n' ' ')"
862
+ echo ""
863
+ echo "Cluster summary:"
864
+ echo -e "$CLUSTER_RESULTS"
865
+ echo ""
866
+ echo "Adequacy summary:"
867
+ echo " Covered: ${COVERED_COUNT:-0} | Uncovered: ${UNCOVERED_COUNT:-0} | Untestable-as-written: ${UNTESTABLE_COUNT:-0} | Manual: ${MANUAL_COUNT:-0}"
868
+ echo "============================================="
869
+
870
+ # Emit machine-readable verdict marker (consumed by review-pr-staging Phase 6.5)
871
+ echo ""
872
+ echo "<!-- FORGE:TEST_GATE:RESULT=${VERDICT} -->"
873
+
874
+ # Handle BLOCK verdict according to posture
875
+ if [ "$VERDICT" = "BLOCK" ]; then
876
+ if [ "$GATE_POSTURE" = "advisory" ]; then
877
+ echo ""
878
+ echo "ADVISORY posture: BLOCK verdict surfaced but deploy is NOT prevented."
879
+ echo "Switch to posture: blocking in forge.yaml to make this gate enforce."
880
+ echo ""
881
+ echo "TEST GATE: ADVISORY BLOCK"
882
+ else
883
+ # blocking posture (default)
884
+ echo ""
885
+ echo "BLOCKING posture: Deploy is prevented. To override, post a PR comment containing:"
886
+ echo " ${OVERRIDE_PHRASE} <reason>"
887
+ echo ""
888
+ echo "TEST GATE: BLOCK DEPLOY"
889
+ exit 1
890
+ fi
891
+ else
892
+ echo ""
893
+ echo "TEST GATE: ${VERDICT}"
894
+ fi
895
+ ```
896
+
897
+ ---
898
+
899
+ ## Override Escape Hatch
900
+
901
+ When a PR comment containing `verification.test_gate.override_phrase` (default: `OVERRIDE: shipping with test failures —`) is detected on the staging→main PR, the BLOCK verdict is downgraded and the deploy proceeds. Override detection is performed by the caller (`/review-pr-staging` Phase 6.5) using:
902
+
903
+ ```bash
904
+ OVERRIDE=$(gh pr view "$PR_NUMBER" ${GH_FLAG} \
905
+ --json comments \
906
+ --jq "[.comments[].body | select(startswith(\"${OVERRIDE_PHRASE}\"))] | length" 2>/dev/null || echo 0)
907
+
908
+ if [ "${OVERRIDE:-0}" -gt 0 ]; then
909
+ echo "Override detected — downgrading BLOCK to PASS for this deploy."
910
+ echo "<!-- FORGE:TEST_GATE:RESULT=PASS -->"
911
+ fi
912
+ ```
913
+
914
+ ---
915
+
916
+ ## Structured Verdict Reference
917
+
918
+ The caller (`/review-pr-staging`) reads the verdict via:
919
+
920
+ ```bash
921
+ VERDICT=$(gh pr view "$PR_NUMBER" ${GH_FLAG} --json comments \
922
+ --jq '[.comments[].body | capture("FORGE:TEST_GATE:RESULT=(?<v>BLOCK|PASS|SKIP)").v // empty] | last // "SKIP"')
923
+ ```
924
+
925
+ | Verdict | Meaning |
926
+ |---------|---------|
927
+ | `PASS` | All clusters passed (or all failures are pre-existing). Deploy may proceed. |
928
+ | `BLOCK` | Batch-introduced failures detected. Deploy blocked (blocking posture) or warned (advisory posture). |
929
+ | `SKIP` | No tests configured, no executable changes, or manual-only criteria. Deploy proceeds without test verification. |