forgedock 1.8.1 → 1.8.3

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 (90) hide show
  1. package/bin/engine/admission.mjs +57 -1
  2. package/bin/engine/admission.test.mjs +60 -0
  3. package/commands/analytics.md +3 -1
  4. package/commands/audit-agents.md +3 -1
  5. package/commands/audit.md +10 -8
  6. package/commands/autopilot.md +16 -14
  7. package/commands/ci-audit.md +4 -2
  8. package/commands/cleanup.md +1 -1
  9. package/commands/deploy-pr.md +31 -11
  10. package/commands/design-bench.md +1 -0
  11. package/commands/design-render-critique-loop.md +1 -0
  12. package/commands/design.md +1 -0
  13. package/commands/forge-stats.md +1 -0
  14. package/commands/geo-audit.md +3 -1
  15. package/commands/incident-response.md +3 -1
  16. package/commands/issue.md +4 -2
  17. package/commands/milestone.md +8 -6
  18. package/commands/orchestrate/config.md +3 -2
  19. package/commands/orchestrate/phase-1-resolve.md +57 -12
  20. package/commands/orchestrate/phase-2-triage.md +1 -0
  21. package/commands/orchestrate/phase-2.5-synthesis.md +1 -0
  22. package/commands/orchestrate/phase-3-dependency.md +17 -5
  23. package/commands/orchestrate/phase-4-execution.md +625 -78
  24. package/commands/orchestrate/phase-5-cleanup.md +5 -2
  25. package/commands/orchestrate/phase-6-report.md +8 -3
  26. package/commands/orchestrate/safety.md +1 -0
  27. package/commands/orchestrate.md +13 -9
  28. package/commands/pipeline-health/config.md +1 -0
  29. package/commands/pipeline-health/phase-1-context.md +1 -0
  30. package/commands/pipeline-health/phase-2-metrics.md +1 -0
  31. package/commands/pipeline-health/phase-3-analyze.md +1 -0
  32. package/commands/pipeline-health/phase-4-proposals.md +7 -4
  33. package/commands/pipeline-health/phase-5-report.md +5 -2
  34. package/commands/pipeline-health/phase-6-summary.md +1 -0
  35. package/commands/pipeline-health.md +9 -7
  36. package/commands/pipeline-resume.md +4 -2
  37. package/commands/qa-sweep.md +3 -1
  38. package/commands/quality-gate.md +4 -3
  39. package/commands/recover-orphans.md +219 -13
  40. package/commands/review-pr-agents/api.md +1 -0
  41. package/commands/review-pr-agents/auth.md +1 -0
  42. package/commands/review-pr-agents/billing.md +1 -0
  43. package/commands/review-pr-agents/concurrency.md +1 -0
  44. package/commands/review-pr-agents/database.md +1 -0
  45. package/commands/review-pr-agents/frontend.md +1 -0
  46. package/commands/review-pr-agents/infra.md +1 -0
  47. package/commands/review-pr-agents/protocols.md +1 -0
  48. package/commands/review-pr-agents/scraper.md +1 -0
  49. package/commands/review-pr-agents/security.md +1 -0
  50. package/commands/review-pr-agents/spec-cli.md +1 -0
  51. package/commands/review-pr-agents.md +10 -4
  52. package/commands/review-pr-staging.md +14 -5
  53. package/commands/review-pr.md +187 -19
  54. package/commands/rollback.md +3 -1
  55. package/commands/security-audit.md +3 -1
  56. package/commands/signal-planner.md +6 -4
  57. package/commands/sync-ecosystem.md +4 -2
  58. package/commands/test-gate.md +4 -2
  59. package/commands/upgrade-deps.md +3 -1
  60. package/commands/validate.md +3 -1
  61. package/commands/work-on/build/architect.md +82 -19
  62. package/commands/work-on/build/context.md +62 -18
  63. package/commands/work-on/build/implement.md +63 -18
  64. package/commands/work-on/build/validate.md +241 -60
  65. package/commands/work-on/build.md +345 -146
  66. package/commands/work-on/close.md +618 -246
  67. package/commands/work-on/decompose.md +240 -41
  68. package/commands/work-on/investigate.md +378 -61
  69. package/commands/work-on/remediate.md +43 -9
  70. package/commands/work-on/review.md +241 -29
  71. package/commands/work-on.md +180 -2014
  72. package/docs/CODEX.md +2 -0
  73. package/docs/CONFIG.md +11 -0
  74. package/package.json +1 -1
  75. package/scripts/build-spec-graph.mjs +11 -4
  76. package/scripts/check-spec-markers.sh +21 -1
  77. package/scripts/checkbox-sections.test.sh +8 -23
  78. package/scripts/extract-affected-files.sh +5 -2
  79. package/scripts/extract-affected-files.test.sh +27 -0
  80. package/scripts/forge-root.test.sh +176 -0
  81. package/scripts/is-docs-only.sh +31 -0
  82. package/scripts/is-docs-only.test.sh +64 -0
  83. package/scripts/lint-dispatch-prompt.sh +217 -0
  84. package/scripts/lint-dispatch-prompt.test.sh +307 -0
  85. package/scripts/validate-spec-graph.sh +3 -2
  86. package/scripts/verify-phase-trail.sh +282 -0
  87. package/scripts/verify-phase-trail.test.sh +298 -0
  88. package/scripts/wait-ci-green.sh +98 -0
  89. package/scripts/wait-ci-green.test.sh +99 -0
  90. package/commands/work-on-monolithic.md +0 -326
@@ -0,0 +1,282 @@
1
+ #!/usr/bin/env bash
2
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
3
+ # SPDX-License-Identifier: AGPL-3.0-or-later
4
+ #
5
+ # verify-phase-trail.sh — Deterministic phase-trail gate for the /work-on pipeline.
6
+ #
7
+ # Verifies that an issue carries the FORGE artifacts that the mandatory phases
8
+ # are required to produce, so a PR is never opened or merged on a reviewer
9
+ # verdict alone after phases were silently skipped.
10
+ #
11
+ # Usage:
12
+ # verify-phase-trail.sh <issue> -R <owner/repo> [--docs-only] [--code-diff] [--head-tree <sha>]
13
+ #
14
+ # --docs-only The diff is documentation-only; the FORGE:QUALITY_GATE marker
15
+ # is not required.
16
+ # --code-diff The diff contains at least one non-documentation file. An agent-chosen
17
+ # INVESTIGATION band is then NOT honoured (investigation tasks produce issues,
18
+ # not code), so the STANDARD requirement set applies (#3149).
19
+ # --head-tree Git tree id (40 or 64 hex) of the commit being gated. The passing
20
+ # FORGE:QUALITY_GATE marker must record the same `**Tree**: <sha>`; a PASS from an
21
+ # earlier tree, or a marker without a Tree line, does not satisfy the gate (#3149).
22
+ # An empty or non-hex value is a usage error (exit 2, fails closed).
23
+ # Scope: this binds the PASS to the built tree; it is not tamper-proof, because the
24
+ # pipeline agent posts under the same trusted identity and could compute the tree itself.
25
+ #
26
+ # Requirements by COMPLEXITY_BAND (read from the FORGE:FAST_PATH comment).
27
+ # The authoritative table is the `case "$EFFECTIVE"` block below; this summary must be
28
+ # updated together with it (and commands/work-on/review.md):
29
+ # INVESTIGATION INVESTIGATOR, FAST_PATH
30
+ # TRIVIAL INVESTIGATOR, CONTRACT, FAST_PATH, QUALITY_GATE*
31
+ # STANDARD/COMPLEX (or unknown band)
32
+ # INVESTIGATOR, CONTRACT, FAST_PATH, CONTEXT, ARCHITECT, QUALITY_GATE*
33
+ # (* waived with --docs-only)
34
+ # A missing FAST_PATH is itself a failure; the remaining checks then use the
35
+ # conservative STANDARD requirement set.
36
+ #
37
+ # Output (stdout, machine-readable):
38
+ # PHASE_TRAIL: PASS|FAIL|ERROR
39
+ # BAND: <band>
40
+ # MISSING: <marker> -> <phase to re-run> (one line per missing artifact)
41
+ #
42
+ # Exit codes: 0 pass, 1 one or more artifacts missing, 2 could not
43
+ # read the issue (fails closed — an unreadable trail is never treated as a pass).
44
+ # Callers route on the exit code: 1 -> re-run the MISSING phases; 2 (or 127 when the
45
+ # verifier itself cannot be resolved) -> infrastructure BLOCKED, never "missing phases".
46
+ #
47
+ # Legacy grace (#3102): an issue whose trusted FORGE:BUILDER:COMPLETE comment was last updated before
48
+ # FORGE_TRAIL_QG_SINCE (default: when the FORGE:QUALITY_GATE marker was introduced, #3061) could
49
+ # not have posted the marker, so QUALITY_GATE is waived for it. The time is the comment's
50
+ # `updated_at`, not `created_at`: BUILDER:COMPLETE is appended to the existing BUILDER comment later
51
+ # (#3149). A missing/undated BUILDER:COMPLETE never earns the grace (fail closed). Set FORGE_TRAIL_QG_SINCE="" to disable the grace.
52
+ # TRUSTED ENVIRONMENT ONLY (#3139): any valid past FORGE_TRAIL_QG_SINCE is honoured, and an earlier
53
+ # value waives QUALITY_GATE for every build completed before it. Never set it from untrusted input
54
+ # (PR content, issue text, contributor-controlled CI variables); only the operator's runner environment.
55
+ #
56
+ # Comment trust: only markers posted by a trusted author count; markers from any
57
+ # other commenter are ignored (they cannot satisfy the gate or force a band).
58
+ # Trusted = author_association in FORGE_TRAIL_TRUSTED_ASSOCIATIONS
59
+ # (default "OWNER,MEMBER,COLLABORATOR"), OR user.type == "Bot" (the pipeline's
60
+ # GitHub App identity), OR user.login in FORGE_TRAIL_TRUSTED_LOGINS
61
+ # (comma-separated, default empty).
62
+ # Identities outside that set -- e.g. a human with author_association CONTRIBUTOR/NONE/FIRST_TIME_CONTRIBUTOR
63
+ # (an external contributor running the pipeline under their own login) -- are NOT trusted, so their
64
+ # markers are ignored and the gate reports them MISSING. To accept such an identity add its login to
65
+ # FORGE_TRAIL_TRUSTED_LOGINS, or widen FORGE_TRAIL_TRUSTED_ASSOCIATIONS (e.g. add CONTRIBUTOR). On FAIL the
66
+ # script prints a NOTE when untrusted-author FORGE markers were seen, so this is diagnosable (#3123).
67
+ # Limits: "Bot" trusts any GitHub App/bot that can comment on the repo (set
68
+ # FORGE_TRAIL_TRUSTED_ASSOCIATIONS and FORGE_TRAIL_TRUSTED_LOGINS to tighten);
69
+ # COLLABORATOR includes read-level collaborators; login matching is case-sensitive.
70
+ # END-HELP (`-h` prints the header up to this line; keep it last)
71
+
72
+ set -uo pipefail
73
+
74
+ ISSUE=""
75
+ REPO=""
76
+ DOCS_ONLY=0
77
+ CODE_DIFF=0
78
+ HEAD_TREE=""
79
+ HEAD_TREE_SET=0
80
+
81
+ while [ $# -gt 0 ]; do
82
+ case "$1" in
83
+ -R|--repo)
84
+ if [ $# -lt 2 ] || [ -z "${2:-}" ]; then echo "PHASE_TRAIL: ERROR"; echo "usage error: $1 needs a value" >&2; exit 2; fi
85
+ REPO="$2"; shift 2 ;;
86
+ --docs-only) DOCS_ONLY=1; shift ;;
87
+ --code-diff) CODE_DIFF=1; shift ;;
88
+ --head-tree)
89
+ if [ $# -lt 2 ]; then echo "PHASE_TRAIL: ERROR"; echo "usage error: $1 needs a value" >&2; exit 2; fi
90
+ HEAD_TREE="$2"; HEAD_TREE_SET=1; shift 2 ;;
91
+ -h|--help) awk 'NR >= 5 { if (/^# END-HELP/) exit; print }' "$0"; exit 0 ;;
92
+ *)
93
+ if [ -z "$ISSUE" ] && [[ "$1" =~ ^[0-9]+$ ]]; then ISSUE="$1"; shift
94
+ else echo "PHASE_TRAIL: ERROR"; echo "usage error: unexpected argument '$1'" >&2; exit 2; fi
95
+ ;;
96
+ esac
97
+ done
98
+
99
+ if [ -z "$ISSUE" ] || [ -z "$REPO" ]; then
100
+ echo "PHASE_TRAIL: ERROR"
101
+ echo "usage: verify-phase-trail.sh <issue> -R <owner/repo> [--docs-only] [--code-diff] [--head-tree <sha>]" >&2
102
+ exit 2
103
+ fi
104
+ if [ "$HEAD_TREE_SET" = "1" ]; then
105
+ # Fail closed: a caller that could not resolve the tree passes an empty value and must not silently skip the binding.
106
+ HEAD_TREE=$(printf '%s' "$HEAD_TREE" | tr 'A-F' 'a-f')
107
+ if ! [[ "$HEAD_TREE" =~ ^([0-9a-f]{40}|[0-9a-f]{64})$ ]]; then
108
+ echo "PHASE_TRAIL: ERROR"
109
+ echo "usage error: --head-tree needs a 40 or 64 hex git tree id" >&2
110
+ exit 2
111
+ fi
112
+ fi
113
+
114
+ RAW=$(gh api "repos/${REPO}/issues/${ISSUE}/comments" --paginate 2>/dev/null) || {
115
+ echo "PHASE_TRAIL: ERROR"
116
+ echo "could not read comments for ${REPO}#${ISSUE}" >&2
117
+ exit 2
118
+ }
119
+
120
+ # `gh api --paginate` emits one JSON array per page; merge them into a single array so every
121
+ # later computation (notably the latest BUILDER:COMPLETE time) sees ALL pages (#3121).
122
+ # Every page must itself be a JSON array of comment OBJECTS (an error object, a scalar page, or a
123
+ # page holding non-object elements such as [1,"x"] fails closed, #3130/#3139).
124
+ RAW=$(printf '%s' "$RAW" | jq -s 'if all(.[]; type == "array" and all(.[]; type == "object")) then (add // []) else error("non-array page") end' 2>/dev/null) || {
125
+ echo "PHASE_TRAIL: ERROR"
126
+ echo "could not parse comments for ${REPO}#${ISSUE}" >&2
127
+ exit 2
128
+ }
129
+
130
+ # One line per comment, newlines folded to \x1f so a marker and its sentinel can
131
+ # be matched within the SAME comment.
132
+ TRUSTED_ASSOC="${FORGE_TRAIL_TRUSTED_ASSOCIATIONS-OWNER,MEMBER,COLLABORATOR}"
133
+ TRUSTED_LOGINS="${FORGE_TRAIL_TRUSTED_LOGINS-}"
134
+ COMMENTS=$(printf '%s' "$RAW" | jq -r --arg assoc "$TRUSTED_ASSOC" --arg logins "$TRUSTED_LOGINS" '
135
+ ($assoc | split(",") | map(gsub("^\\s+|\\s+$"; "")) | map(select(length > 0))) as $A
136
+ | ($logins | split(",") | map(gsub("^\\s+|\\s+$"; "")) | map(select(length > 0))) as $L
137
+ | .[]
138
+ | select(
139
+ ((.author_association // "") as $x | $A | index($x) != null)
140
+ or ((.user.type // "") == "Bot")
141
+ or ((.user.login // "") as $x | $L | index($x) != null)
142
+ )
143
+ | .body // "" | gsub("\r?\n"; "\u001f")' 2>/dev/null) || {
144
+ echo "PHASE_TRAIL: ERROR"
145
+ echo "could not parse comments for ${REPO}#${ISSUE}" >&2
146
+ exit 2
147
+ }
148
+
149
+ # Last-update time of the LATEST trusted FORGE:BUILDER:COMPLETE comment (empty when absent/undated).
150
+ QG_SINCE="${FORGE_TRAIL_QG_SINCE-2026-10-07T03:40:12Z}"
151
+ # The cutoff must be ISO-8601 UTC (YYYY-MM-DDTHH:MM:SSZ) and not in the future: a malformed or
152
+ # far-future value would otherwise waive QUALITY_GATE for every issue (#3121). Fails closed.
153
+ if [ -n "$QG_SINCE" ]; then
154
+ NOW_UTC=$(date -u +%Y-%m-%dT%H:%M:%SZ)
155
+ QG_VALID=1
156
+ if ! [[ "$QG_SINCE" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$ ]] || [[ "$QG_SINCE" > "$NOW_UTC" ]]; then
157
+ QG_VALID=0
158
+ else
159
+ # Round-trip through `date` so impossible dates (2020-13-45T99:99:99Z) are rejected (#3130).
160
+ # GNU date first, then BSD/macOS date; no usable parser or a mismatch fails closed.
161
+ QG_RT=$(date -u -d "$QG_SINCE" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null \
162
+ || date -u -j -f %Y-%m-%dT%H:%M:%SZ "$QG_SINCE" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || true)
163
+ [ "$QG_RT" = "$QG_SINCE" ] || QG_VALID=0
164
+ fi
165
+ if [ "$QG_VALID" != "1" ]; then
166
+ echo "PHASE_TRAIL: ERROR"
167
+ # A runner clock behind the default cutoff also lands here (cutoff appears to be in the future): fails closed.
168
+ echo "invalid FORGE_TRAIL_QG_SINCE '${QG_SINCE}': must be a real ISO-8601 UTC time (YYYY-MM-DDTHH:MM:SSZ) and not in the future (check the runner clock)" >&2
169
+ exit 2
170
+ fi
171
+ fi
172
+ BUILD_AT=$(printf '%s' "$RAW" | jq -r --arg assoc "$TRUSTED_ASSOC" --arg logins "$TRUSTED_LOGINS" '
173
+ ($assoc | split(",") | map(gsub("^\\s+|\\s+$"; "")) | map(select(length > 0))) as $A
174
+ | ($logins | split(",") | map(gsub("^\\s+|\\s+$"; "")) | map(select(length > 0))) as $L
175
+ | [ .[]
176
+ | select(
177
+ ((.author_association // "") as $x | $A | index($x) != null)
178
+ or ((.user.type // "") == "Bot")
179
+ or ((.user.login // "") as $x | $L | index($x) != null)
180
+ )
181
+ | select((.body // "") | startswith("<!-- FORGE:BUILDER -->") and contains("<!-- FORGE:BUILDER:COMPLETE -->"))
182
+ | .updated_at // empty ] | sort | .[-1] // empty' 2>/dev/null) || BUILD_AT=""
183
+
184
+ # NOTE: never use early-exiting `grep -q`/`head -1` after printf under pipefail: on large threads the
185
+ # writer gets SIGPIPE and the pipeline reports failure for a marker that is present (#3099).
186
+ # A marker only counts when it is the FIRST thing in a comment (comments are folded to one
187
+ # line each above), so a comment that merely quotes a marker mid-body cannot satisfy it.
188
+ has() { printf '%s\n' "$COMMENTS" | grep -E "^$1" >/dev/null; }
189
+
190
+ # INVESTIGATION:INVALID is the other terminal sentinel (issue closed invalid, no PR follows).
191
+ has_investigator() {
192
+ printf '%s\n' "$COMMENTS" | grep -E '^<!-- FORGE:INVESTIGATOR -->' | grep -E 'INVESTIGATION:(COMPLETE|INVALID)' >/dev/null
193
+ }
194
+
195
+ # Quality gate: a marker comment whose result is PASS (any later PASS wins over an earlier FAIL).
196
+ has_quality_gate_pass() {
197
+ # With --head-tree the PASS must also record that exact tree (a PASS for an earlier commit does not count).
198
+ if [ "$HEAD_TREE_SET" = "1" ]; then
199
+ printf '%s\n' "$COMMENTS" | grep -E '^<!-- FORGE:QUALITY_GATE -->' | grep -E '\*\*Result\*\*: *PASS' \
200
+ | grep -E "\*\*Tree\*\*: *${HEAD_TREE}([^0-9a-fA-F]|$)" >/dev/null
201
+ else
202
+ printf '%s\n' "$COMMENTS" | grep -E '^<!-- FORGE:QUALITY_GATE -->' | grep -E '\*\*Result\*\*: *PASS' >/dev/null
203
+ fi
204
+ }
205
+
206
+ BAND=""
207
+ if has '<!-- FORGE:FAST_PATH -->'; then
208
+ # The FIRST classification wins: a later FAST_PATH comment cannot downgrade the requirement set.
209
+ BAND=$(printf '%s\n' "$COMMENTS" | grep -E '^<!-- FORGE:FAST_PATH -->' | sed -n '1p' \
210
+ | sed -n 's/.*\*\*COMPLEXITY_BAND\*\*: *\([A-Za-z_]*\).*/\1/p' | tr '[:lower:]' '[:upper:]')
211
+ fi
212
+
213
+ MISSING=()
214
+ add_missing() { MISSING+=("$1 -> $2"); }
215
+
216
+ has_investigator || add_missing "INVESTIGATOR" "re-run Skill work-on/investigate"
217
+
218
+ if [ -z "$BAND" ]; then
219
+ if has '<!-- FORGE:FAST_PATH -->'; then
220
+ add_missing "FAST_PATH (no COMPLEXITY_BAND value)" "re-run work-on Phase 3B classification"
221
+ else
222
+ add_missing "FAST_PATH" "re-run work-on Phase 3B classification"
223
+ fi
224
+ EFFECTIVE="STANDARD"
225
+ else
226
+ EFFECTIVE="$BAND"
227
+ fi
228
+
229
+ # The band is agent-chosen: do not let it waive requirements when the diff contains code (#3149).
230
+ if [ "$EFFECTIVE" = "INVESTIGATION" ] && [ "$CODE_DIFF" = "1" ]; then
231
+ EFFECTIVE="STANDARD"
232
+ BAND_NOTE="INVESTIGATION band ignored: the diff contains non-documentation files"
233
+ fi
234
+
235
+ case "$EFFECTIVE" in
236
+ INVESTIGATION) NEED_CONTRACT=0; NEED_CTX=0; NEED_QG=0 ;;
237
+ TRIVIAL) NEED_CONTRACT=1; NEED_CTX=0; NEED_QG=1 ;;
238
+ *) NEED_CONTRACT=1; NEED_CTX=1; NEED_QG=1 ;;
239
+ esac
240
+ [ "$DOCS_ONLY" = "1" ] && NEED_QG=0
241
+ # Legacy grace: built before the quality-gate marker existed -> not required (string compare on ISO-8601 UTC).
242
+ if [ "$NEED_QG" = "1" ] && [ -n "$QG_SINCE" ] && [ -n "$BUILD_AT" ] && [[ "$BUILD_AT" < "$QG_SINCE" ]]; then
243
+ NEED_QG=0
244
+ LEGACY_NOTE="QUALITY_GATE waived: build completed ${BUILD_AT} before ${QG_SINCE}"
245
+ fi
246
+
247
+ if [ "$NEED_CONTRACT" = "1" ] && ! has '<!-- FORGE:CONTRACT -->'; then
248
+ add_missing "CONTRACT" "re-run Skill work-on/build Phase B2 (builder contract)"
249
+ fi
250
+ if [ "$NEED_CTX" = "1" ]; then
251
+ has '<!-- FORGE:CONTEXT -->' || add_missing "CONTEXT" "re-run Skill work-on/build/context"
252
+ has '<!-- FORGE:ARCHITECT -->' || add_missing "ARCHITECT" "re-run Skill work-on/build/architect"
253
+ fi
254
+ if [ "$NEED_QG" = "1" ] && ! has_quality_gate_pass; then
255
+ add_missing "QUALITY_GATE" "re-run Skill work-on/build/validate"
256
+ fi
257
+
258
+ if [ "${#MISSING[@]}" -eq 0 ]; then
259
+ echo "PHASE_TRAIL: PASS"
260
+ echo "BAND: ${BAND:-UNKNOWN}"
261
+ [ -n "${LEGACY_NOTE:-}" ] && echo "NOTE: $LEGACY_NOTE"
262
+ [ -n "${BAND_NOTE:-}" ] && echo "NOTE: $BAND_NOTE"
263
+ exit 0
264
+ fi
265
+
266
+ echo "PHASE_TRAIL: FAIL"
267
+ echo "BAND: ${BAND:-UNKNOWN}"
268
+ for m in "${MISSING[@]}"; do echo "MISSING: $m"; done
269
+ [ -n "${BAND_NOTE:-}" ] && echo "NOTE: $BAND_NOTE"
270
+ # Diagnose the "marker present but ignored" case: count FORGE markers posted by untrusted authors.
271
+ UNTRUSTED_FORGE=$(printf '%s' "$RAW" | jq -r --arg assoc "$TRUSTED_ASSOC" --arg logins "$TRUSTED_LOGINS" '
272
+ ($assoc | split(",") | map(gsub("^\\s+|\\s+$"; "")) | map(select(length > 0))) as $A
273
+ | ($logins | split(",") | map(gsub("^\\s+|\\s+$"; "")) | map(select(length > 0))) as $L
274
+ | [ .[]
275
+ | select(((.author_association // "") as $x | $A | index($x) == null)
276
+ and ((.user.type // "") != "Bot")
277
+ and ((.user.login // "") as $x | $L | index($x) == null))
278
+ | select((.body // "") | startswith("<!-- FORGE:")) ] | length' 2>/dev/null || echo 0)
279
+ if [ "${UNTRUSTED_FORGE:-0}" -gt 0 ] 2>/dev/null; then
280
+ echo "NOTE: ${UNTRUSTED_FORGE} FORGE marker comment(s) from untrusted authors were ignored; set FORGE_TRAIL_TRUSTED_LOGINS or FORGE_TRAIL_TRUSTED_ASSOCIATIONS to trust them"
281
+ fi
282
+ exit 1
@@ -0,0 +1,298 @@
1
+ #!/usr/bin/env bash
2
+ # verify-phase-trail.test.sh — tests for scripts/verify-phase-trail.sh
3
+ # No network: `gh` is mocked on PATH; MOCK_GH_JSON names a fixture file holding the
4
+ # raw JSON array `gh api .../comments` would return. MOCK_GH_FAIL=1 simulates an outage.
5
+ #
6
+ # Usage: bash scripts/verify-phase-trail.test.sh
7
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
8
+ # SPDX-License-Identifier: AGPL-3.0-or-later
9
+
10
+ set -uo pipefail
11
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
12
+ VERIFY="$SCRIPT_DIR/verify-phase-trail.sh"
13
+ TMP_BIN=$(mktemp -d); TMP_FX=$(mktemp -d)
14
+ trap 'rm -rf "$TMP_BIN" "$TMP_FX"' EXIT
15
+
16
+ cat > "$TMP_BIN/gh" <<'MOCK'
17
+ #!/usr/bin/env bash
18
+ if [ "${MOCK_GH_FAIL:-}" = "1" ]; then echo "mock gh: outage" >&2; exit 1; fi
19
+ cat "$MOCK_GH_JSON"
20
+ MOCK
21
+ chmod +x "$TMP_BIN/gh"
22
+ export PATH="$TMP_BIN:$PATH"
23
+
24
+ PASS=0; FAILN=0
25
+ ok() { PASS=$((PASS+1)); echo "ok - $1"; }
26
+ bad() { FAILN=$((FAILN+1)); echo "FAIL - $1"; }
27
+
28
+ # fixture <name> <marker...>: builds a comments JSON array from marker keys
29
+ INV='<!-- FORGE:INVESTIGATOR -->\n## Investigation Report\n<!-- INVESTIGATION:COMPLETE -->'
30
+ mk() {
31
+ local out="$TMP_FX/$1.json"; shift
32
+ local items=()
33
+ for k in "$@"; do
34
+ case "$k" in
35
+ INV) items+=("{\"body\":\"$INV\"}") ;;
36
+ INV_PARTIAL) items+=("{\"body\":\"<!-- FORGE:INVESTIGATOR -->\\n## partial\"}") ;;
37
+ CONTRACT) items+=('{"body":"<!-- FORGE:CONTRACT -->\n## Builder Contract"}') ;;
38
+ CONTEXT) items+=('{"body":"<!-- FORGE:CONTEXT -->\n## Context"}') ;;
39
+ ARCH) items+=('{"body":"<!-- FORGE:ARCHITECT -->\n## Plan"}') ;;
40
+ QG_PASS) items+=('{"body":"<!-- FORGE:QUALITY_GATE -->\n**Result**: PASS\n"}') ;;
41
+ QG_FAIL) items+=('{"body":"<!-- FORGE:QUALITY_GATE -->\n**Result**: FAIL\n"}') ;;
42
+ FP_NOBAND) items+=('{"body":"<!-- FORGE:FAST_PATH -->\nnothing"}') ;;
43
+ FP_*) items+=("{\"body\":\"<!-- FORGE:FAST_PATH -->\\n**COMPLEXITY_BAND**: ${k#FP_}\"}") ;;
44
+ QUOTED) items+=('{"body":"see `<!-- FORGE:CONTRACT -->` `<!-- FORGE:CONTEXT -->` `<!-- FORGE:ARCHITECT -->` `<!-- FORGE:QUALITY_GATE -->` **Result**: PASS"}') ;;
45
+ INV_INVALID) items+=('{"body":"<!-- FORGE:INVESTIGATOR -->\nInvalid\n<!-- INVESTIGATION:INVALID -->"}') ;;
46
+ PROSE) items+=('{"body":"I ran the architect and context inline; FORGE:CONTEXT FORGE:ARCHITECT FORGE:QUALITY_GATE done"}') ;;
47
+ esac
48
+ done
49
+ # Fixtures model a trusted (OWNER) author; trust filtering is exercised separately below (#3100).
50
+ local IFS=,; echo "[${items[*]}]" | jq -c 'map(. + {author_association:"OWNER", user:{login:"owner",type:"User"}})' > "$out"; echo "$out"
51
+ }
52
+
53
+ run() { # run <fixture> [args...] -> sets OUT, RC
54
+ local fx="$1"; shift
55
+ OUT=$(MOCK_GH_JSON="$fx" bash "$VERIFY" 3061 -R o/r "$@" 2>/dev/null); RC=$?
56
+ }
57
+ expect_pass() { run "$2" "${@:3}"; [ $RC -eq 0 ] && echo "$OUT" | grep -q 'PHASE_TRAIL: PASS' && ok "$1" || bad "$1 (rc=$RC out=$OUT)"; }
58
+ expect_fail() { # name fixture needle [args]
59
+ run "$2" "${@:4}"
60
+ [ $RC -eq 1 ] && echo "$OUT" | grep -q 'PHASE_TRAIL: FAIL' && echo "$OUT" | grep -q "MISSING: $3" && ok "$1" || bad "$1 (rc=$RC out=$OUT)"
61
+ }
62
+
63
+ expect_pass "full COMPLEX trail passes" "$(mk full INV CONTRACT FP_COMPLEX CONTEXT ARCH QG_PASS)"
64
+ expect_pass "full STANDARD trail passes" "$(mk std INV CONTRACT FP_STANDARD CONTEXT ARCH QG_PASS)"
65
+ expect_pass "TRIVIAL passes without CONTEXT/ARCHITECT" "$(mk triv INV CONTRACT FP_TRIVIAL QG_PASS)"
66
+ expect_pass "lowercase band is accepted" "$(mk lower INV CONTRACT FP_trivial QG_PASS)"
67
+ expect_pass "Investigation band passes with only INVESTIGATOR+FAST_PATH" "$(mk invest INV FP_INVESTIGATION)"
68
+ expect_pass "docs-only waives QUALITY_GATE" "$(mk docs INV CONTRACT FP_TRIVIAL)" --docs-only
69
+
70
+ expect_fail "missing INVESTIGATOR" "$(mk a CONTRACT FP_COMPLEX CONTEXT ARCH QG_PASS)" INVESTIGATOR
71
+ expect_fail "partial investigation (no COMPLETE) fails" "$(mk b INV_PARTIAL CONTRACT FP_COMPLEX CONTEXT ARCH QG_PASS)" INVESTIGATOR
72
+ expect_fail "missing CONTRACT" "$(mk c INV FP_COMPLEX CONTEXT ARCH QG_PASS)" CONTRACT
73
+ expect_fail "missing CONTEXT on STANDARD" "$(mk d INV CONTRACT FP_STANDARD ARCH QG_PASS)" CONTEXT
74
+ expect_fail "missing ARCHITECT on STANDARD" "$(mk e INV CONTRACT FP_STANDARD CONTEXT QG_PASS)" ARCHITECT
75
+ expect_fail "missing FAST_PATH" "$(mk f INV CONTRACT CONTEXT ARCH QG_PASS)" FAST_PATH
76
+ expect_fail "FAST_PATH without band value fails" "$(mk g INV CONTRACT FP_NOBAND CONTEXT ARCH QG_PASS)" FAST_PATH
77
+ expect_fail "missing QUALITY_GATE" "$(mk h INV CONTRACT FP_COMPLEX CONTEXT ARCH)" QUALITY_GATE
78
+ expect_fail "QUALITY_GATE FAIL result does not count" "$(mk i INV CONTRACT FP_COMPLEX CONTEXT ARCH QG_FAIL)" QUALITY_GATE
79
+ expect_fail "TRIVIAL still needs CONTRACT" "$(mk j INV FP_TRIVIAL QG_PASS)" CONTRACT
80
+ expect_fail "prose mentions do not satisfy markers" "$(mk k INV CONTRACT FP_COMPLEX PROSE)" CONTEXT
81
+
82
+ expect_fail "quoted markers mid-comment do not count" "$(mk q INV FP_COMPLEX QUOTED)" CONTRACT
83
+ expect_fail "first FAST_PATH wins over a later downgrade" "$(mk r INV FP_COMPLEX FP_INVESTIGATION)" CONTRACT
84
+ expect_pass "INVESTIGATION:INVALID accepted as terminal investigator sentinel" "$(mk s INV_INVALID FP_INVESTIGATION)"
85
+
86
+ # Missing FAST_PATH uses conservative STANDARD set, so CONTEXT/ARCHITECT also named
87
+ run "$(mk l INV CONTRACT QG_PASS)"
88
+ echo "$OUT" | grep -q 'MISSING: FAST_PATH' && echo "$OUT" | grep -q 'MISSING: CONTEXT' && echo "$OUT" | grep -q 'MISSING: ARCHITECT' \
89
+ && ok "missing FAST_PATH names every missing artifact" || bad "missing FAST_PATH names every missing artifact ($OUT)"
90
+
91
+ # Every missing line names the phase to route back to
92
+ run "$(mk m INV CONTRACT FP_COMPLEX)"
93
+ echo "$OUT" | grep -q 'MISSING: CONTEXT -> re-run Skill work-on/build/context' && ok "refusal routes to the missing phase" || bad "route text ($OUT)"
94
+
95
+ # Large thread (pipefail + early-exit grep SIGPIPE regression, #3099): a valid trail buried in filler
96
+ python3 - "$TMP_FX/big.json" <<'PY'
97
+ import json,sys
98
+ inv="<!-- FORGE:INVESTIGATOR -->\n## Investigation Report\n<!-- INVESTIGATION:COMPLETE -->"
99
+ c=[{"body":inv},{"body":"<!-- FORGE:FAST_PATH -->\n**COMPLEXITY_BAND**: COMPLEX"},
100
+ {"body":"<!-- FORGE:CONTRACT -->\nx"},{"body":"<!-- FORGE:CONTEXT -->\nx"},
101
+ {"body":"<!-- FORGE:ARCHITECT -->\nx"},{"body":"<!-- FORGE:QUALITY_GATE -->\n**Result**: PASS\n"}]
102
+ c+= [{"body":"filler "+("x"*2000)} for _ in range(300)]
103
+ for x in c: x.update({"author_association":"OWNER","user":{"login":"owner","type":"User"}})
104
+ json.dump(c,open(sys.argv[1],"w"))
105
+ PY
106
+ expect_pass "large thread with present markers still passes" "$TMP_FX/big.json"
107
+
108
+ # --- Comment-author trust filtering (#3100) ---
109
+ # tc <assoc> <login> <type> <body>: one comment with explicit author metadata
110
+ tc() { jq -nc --arg a "$1" --arg l "$2" --arg t "$3" --arg b "$4" '{author_association:$a,user:{login:$l,type:$t},body:$b}'; }
111
+ tj() { local out="$TMP_FX/$1.json"; shift; local IFS=,; echo "[$*]" > "$out"; echo "$out"; }
112
+ T_INV=$'<!-- FORGE:INVESTIGATOR -->\nx\n<!-- INVESTIGATION:COMPLETE -->'
113
+ T_FPI=$'<!-- FORGE:FAST_PATH -->\n**COMPLEXITY_BAND**: INVESTIGATION'
114
+ T_FPS=$'<!-- FORGE:FAST_PATH -->\n**COMPLEXITY_BAND**: STANDARD'
115
+
116
+ # Forged early INVESTIGATION band from a stranger must not downgrade requirements.
117
+ expect_fail "forged early FAST_PATH from untrusted user is ignored" \
118
+ "$(tj t1 "$(tc NONE evil User "$T_FPI")" "$(tc OWNER own User "$T_INV")" "$(tc OWNER own User "$T_FPS")")" CONTRACT
119
+ expect_fail "untrusted-only markers satisfy nothing" \
120
+ "$(tj t2 "$(tc NONE evil User "$T_INV")" "$(tc NONE evil User "$T_FPI")")" INVESTIGATOR
121
+ expect_fail "untrusted user mixed into a complete trail cannot supply the missing CONTRACT" \
122
+ "$(tj t3 "$(tc OWNER own User "$T_INV")" "$(tc OWNER own User "$T_FPS")" "$(tc NONE evil User '<!-- FORGE:CONTRACT -->')" "$(tc OWNER own User '<!-- FORGE:CONTEXT -->')" "$(tc OWNER own User '<!-- FORGE:ARCHITECT -->')" "$(tc OWNER own User $'<!-- FORGE:QUALITY_GATE -->\n**Result**: PASS')")" CONTRACT
123
+ expect_fail "comment with no author metadata is untrusted" \
124
+ "$(tj t4 '{"body":"<!-- FORGE:INVESTIGATOR -->\n<!-- INVESTIGATION:COMPLETE -->"}')" INVESTIGATOR
125
+ expect_pass "Bot identity is trusted" "$(tj t5 "$(tc NONE app Bot "$T_INV")" "$(tc NONE app Bot "$T_FPI")")"
126
+ expect_pass "MEMBER and COLLABORATOR are trusted by default" "$(tj t6 "$(tc MEMBER m User "$T_INV")" "$(tc COLLABORATOR c User "$T_FPI")")"
127
+ FORGE_TRAIL_TRUSTED_LOGINS="svc-user, other" expect_pass "allowlisted login is trusted" \
128
+ "$(tj t7 "$(tc NONE svc-user User "$T_INV")" "$(tc NONE svc-user User "$T_FPI")")"
129
+ FORGE_TRAIL_TRUSTED_ASSOCIATIONS="OWNER" expect_fail "narrowed associations reject COLLABORATOR" \
130
+ "$(tj t8 "$(tc COLLABORATOR c User "$T_INV")" "$(tc COLLABORATOR c User "$T_FPI")")" INVESTIGATOR
131
+ FORGE_TRAIL_TRUSTED_ASSOCIATIONS="" expect_fail "empty associations trust no one but Bots/allowlist" \
132
+ "$(tj t9 "$(tc OWNER own User "$T_INV")" "$(tc OWNER own User "$T_FPI")")" INVESTIGATOR
133
+
134
+ # Legacy grace (#3102): BUILDER:COMPLETE predating the quality-gate marker waives QUALITY_GATE only
135
+ lg() { # lg <name> <builder updated_at> -> fixture without QUALITY_GATE
136
+ local out="$TMP_FX/$1.json"
137
+ jq -nc --arg at "$2" '[{"body":"<!-- FORGE:INVESTIGATOR -->\n<!-- INVESTIGATION:COMPLETE -->"},{"body":"<!-- FORGE:FAST_PATH -->\n**COMPLEXITY_BAND**: TRIVIAL"},{"body":"<!-- FORGE:CONTRACT -->\nc"},{"body":"<!-- FORGE:BUILDER -->\nx\n<!-- FORGE:BUILDER:COMPLETE -->","updated_at":$at}] | map(. + {author_association:"OWNER", user:{login:"owner",type:"User"}})' > "$out"; echo "$out"
138
+ }
139
+ expect_pass "legacy build (pre-cutoff) waives QUALITY_GATE" "$(lg lg1 2026-10-01T00:00:00Z)"
140
+ expect_fail "post-cutoff build still requires QUALITY_GATE" "$(lg lg2 2026-10-08T00:00:00Z)" QUALITY_GATE
141
+ OUT=$(FORGE_TRAIL_QG_SINCE="" MOCK_GH_JSON="$(lg lg4 2026-10-01T00:00:00Z)" bash "$VERIFY" 3061 -R o/r 2>/dev/null); RC=$?
142
+ [ $RC -eq 1 ] && ok "grace disabled via empty FORGE_TRAIL_QG_SINCE" || bad "grace disable (rc=$RC)"
143
+
144
+ # Multi-page threads (#3121): gh --paginate emits one array per page; the LATEST build must win
145
+ mp="$TMP_FX/mp.json"
146
+ { jq -nc '[{"body":"<!-- FORGE:INVESTIGATOR -->\n<!-- INVESTIGATION:COMPLETE -->"},{"body":"<!-- FORGE:FAST_PATH -->\n**COMPLEXITY_BAND**: TRIVIAL"},{"body":"<!-- FORGE:CONTRACT -->\nc"},{"body":"<!-- FORGE:BUILDER -->\nx\n<!-- FORGE:BUILDER:COMPLETE -->","updated_at":"2026-01-04T00:00:00Z"}] | map(. + {author_association:"OWNER", user:{login:"owner",type:"User"}})'
147
+ jq -nc '[{"body":"<!-- FORGE:BUILDER -->\nx\n<!-- FORGE:BUILDER:COMPLETE -->","updated_at":"2026-10-06T00:00:00Z"}] | map(. + {author_association:"OWNER", user:{login:"owner",type:"User"}})'; } > "$mp"
148
+ OUT=$(FORGE_TRAIL_QG_SINCE=2026-10-05T00:00:00Z MOCK_GH_JSON="$mp" bash "$VERIFY" 3061 -R o/r 2>/dev/null); RC=$?
149
+ [ $RC -eq 1 ] && echo "$OUT" | grep -q 'MISSING: QUALITY_GATE' && ok "multi-page: latest build on page 2 is not waived" || bad "multi-page (rc=$RC out=$OUT)"
150
+
151
+ # Cutoff validation (#3121): malformed or future cutoff fails closed
152
+ for bad_since in "garbage" "2999-01-01T00:00:00Z" "2020-13-45T99:99:99Z" "2026-02-30T00:00:00Z" "2026-10-01T24:00:00Z"; do
153
+ OUT=$(FORGE_TRAIL_QG_SINCE="$bad_since" MOCK_GH_JSON="$(lg lgv 2026-10-01T00:00:00Z)" bash "$VERIFY" 3061 -R o/r 2>/dev/null); RC=$?
154
+ [ $RC -eq 2 ] && echo "$OUT" | grep -q 'PHASE_TRAIL: ERROR' && ok "invalid cutoff '$bad_since' rejected" || bad "cutoff '$bad_since' (rc=$RC out=$OUT)"
155
+ done
156
+
157
+ # Runner clock behind the default cutoff: cutoff looks future-dated -> exit 2 (fails closed)
158
+ CLK="$TMP_FX/clock"; mkdir -p "$CLK"
159
+ REAL_DATE=$(command -v date)
160
+ cat > "$CLK/date" <<MOCKDATE
161
+ #!/usr/bin/env bash
162
+ # Intercept the "now" probe by scanning every arg (not one exact arg form): a bare +FORMAT call with no -d/-j parse flags.
163
+ mode=now; fmt=""
164
+ for a in "\$@"; do case "\$a" in -d|-j|-f) mode=parse ;; +%Y-%m-%dT%H:%M:%SZ) fmt=1 ;; esac; done
165
+ if [ "\$mode" = now ] && [ -n "\$fmt" ]; then echo 2020-01-01T00:00:00Z; else exec "$REAL_DATE" "\$@"; fi
166
+ MOCKDATE
167
+ chmod +x "$CLK/date"
168
+ OUT=$(PATH="$CLK:$PATH" MOCK_GH_JSON="$(lg lgc 2026-10-01T00:00:00Z)" bash "$VERIFY" 3061 -R o/r 2>/dev/null); RC=$?
169
+ [ $RC -eq 2 ] && echo "$OUT" | grep -q 'PHASE_TRAIL: ERROR' && ok "clock behind default cutoff fails closed (exit 2)" || bad "clock behind cutoff (rc=$RC out=$OUT)"
170
+
171
+ # BSD/macOS date fallback (#3139): GNU `date -d` unavailable, `date -j -f` available -> valid cutoff still honoured;
172
+ # neither parser available -> fails closed (exit 2).
173
+ BSD="$TMP_FX/bsd"; mkdir -p "$BSD"
174
+ cat > "$BSD/date" <<BSDDATE
175
+ #!/usr/bin/env bash
176
+ for a in "\$@"; do [ "\$a" = "-d" ] && exit 1; done
177
+ if [ "\$2" = "-j" ] && [ "\$3" = "-f" ]; then
178
+ [ -n "\${BSD_NO_PARSE:-}" ] && exit 1
179
+ # Emulate BSD parse-and-reformat without GNU/BSD-specific flags: the value is already in the output format.
180
+ echo "\${5}"; exit 0
181
+ fi
182
+ exec "$REAL_DATE" "\$@"
183
+ BSDDATE
184
+ chmod +x "$BSD/date"
185
+ OUT=$(PATH="$BSD:$PATH" FORGE_TRAIL_QG_SINCE=2026-10-05T00:00:00Z MOCK_GH_JSON="$(lg lgbsd 2026-10-01T00:00:00Z)" bash "$VERIFY" 3061 -R o/r 2>/dev/null); RC=$?
186
+ [ $RC -eq 0 ] && ok "BSD date -j -f fallback validates a real cutoff" || bad "BSD fallback (rc=$RC out=$OUT)"
187
+ OUT=$(PATH="$BSD:$PATH" BSD_NO_PARSE=1 FORGE_TRAIL_QG_SINCE=2026-10-05T00:00:00Z MOCK_GH_JSON="$(lg lgbsd2 2026-10-01T00:00:00Z)" bash "$VERIFY" 3061 -R o/r 2>/dev/null); RC=$?
188
+ [ $RC -eq 2 ] && echo "$OUT" | grep -q 'PHASE_TRAIL: ERROR' && ok "no usable date parser fails closed (exit 2)" || bad "no date parser (rc=$RC out=$OUT)"
189
+
190
+ # Boundary: cutoff exactly equal to BUILDER:COMPLETE time is NOT waived (strictly-before only)
191
+ OUT=$(FORGE_TRAIL_QG_SINCE=2026-10-01T00:00:00Z MOCK_GH_JSON="$(lg lgb 2026-10-01T00:00:00Z)" bash "$VERIFY" 3061 -R o/r 2>/dev/null); RC=$?
192
+ [ $RC -eq 1 ] && echo "$OUT" | grep -q 'MISSING: QUALITY_GATE' && ok "boundary: build at exactly the cutoff is not waived" || bad "boundary equal (rc=$RC out=$OUT)"
193
+ OUT=$(FORGE_TRAIL_QG_SINCE=2026-10-01T00:00:01Z MOCK_GH_JSON="$(lg lgb2 2026-10-01T00:00:00Z)" bash "$VERIFY" 3061 -R o/r 2>/dev/null); RC=$?
194
+ [ $RC -eq 0 ] && ok "boundary: build one second before the cutoff is waived" || bad "boundary minus 1s (rc=$RC out=$OUT)"
195
+
196
+ # Page-shape edge cases (#3130)
197
+ BLD='{"body":"<!-- FORGE:BUILDER -->\nx\n<!-- FORGE:BUILDER:COMPLETE -->","updated_at":"2026-10-06T00:00:00Z"}'
198
+ pg() { jq -nc --argjson c "$1" '$c | map(. + {author_association:"OWNER", user:{login:"owner",type:"User"}})'; }
199
+ BASE='[{"body":"<!-- FORGE:INVESTIGATOR -->\n<!-- INVESTIGATION:COMPLETE -->"},{"body":"<!-- FORGE:FAST_PATH -->\n**COMPLEXITY_BAND**: TRIVIAL"},{"body":"<!-- FORGE:CONTRACT -->\nc"}]'
200
+ # empty pages around a populated one
201
+ { echo '[]'; pg "$BASE"; echo '[]'; pg "[$BLD]"; echo '[]'; } > "$TMP_FX/empty.json"
202
+ OUT=$(FORGE_TRAIL_QG_SINCE=2026-10-05T00:00:00Z MOCK_GH_JSON="$TMP_FX/empty.json" bash "$VERIFY" 3061 -R o/r 2>/dev/null); RC=$?
203
+ [ $RC -eq 1 ] && echo "$OUT" | grep -q 'MISSING: QUALITY_GATE' && ok "empty pages interleaved are tolerated" || bad "empty pages (rc=$RC out=$OUT)"
204
+ OUT=$(MOCK_GH_JSON=<(echo '[]') bash "$VERIFY" 3061 -R o/r 2>/dev/null); RC=$?
205
+ [ $RC -eq 1 ] && echo "$OUT" | grep -q 'MISSING: INVESTIGATOR' && ok "all-empty thread fails (not error)" || bad "all-empty (rc=$RC out=$OUT)"
206
+ # 3+ pages: latest build on the LAST page wins
207
+ { pg "$BASE"; pg '[{"body":"<!-- FORGE:BUILDER -->\nx\n<!-- FORGE:BUILDER:COMPLETE -->","updated_at":"2026-01-01T00:00:00Z"}]'; pg "[$BLD]"; } > "$TMP_FX/p3.json"
208
+ OUT=$(FORGE_TRAIL_QG_SINCE=2026-10-05T00:00:00Z MOCK_GH_JSON="$TMP_FX/p3.json" bash "$VERIFY" 3061 -R o/r 2>/dev/null); RC=$?
209
+ [ $RC -eq 1 ] && echo "$OUT" | grep -q 'MISSING: QUALITY_GATE' && ok "3 pages: latest build on page 3 is not waived" || bad "3 pages (rc=$RC out=$OUT)"
210
+ # Malformed / mixed pages fail closed with exit 2
211
+ { pg "$BASE"; echo '{"message":"Server Error"}'; } > "$TMP_FX/mixed.json"
212
+ OUT=$(MOCK_GH_JSON="$TMP_FX/mixed.json" bash "$VERIFY" 3061 -R o/r 2>/dev/null); RC=$?
213
+ [ $RC -eq 2 ] && echo "$OUT" | grep -q 'PHASE_TRAIL: ERROR' && ok "mixed array/object pages fail closed (exit 2)" || bad "mixed pages (rc=$RC out=$OUT)"
214
+ { pg "$BASE"; echo '[1,"x"]'; } > "$TMP_FX/nonobj.json"
215
+ OUT=$(MOCK_GH_JSON="$TMP_FX/nonobj.json" bash "$VERIFY" 3061 -R o/r 2>/dev/null); RC=$?
216
+ [ $RC -eq 2 ] && echo "$OUT" | grep -q 'PHASE_TRAIL: ERROR' && ok "page with non-object elements fails closed (exit 2)" || bad "non-object elements (rc=$RC out=$OUT)"
217
+ { pg "$BASE"; echo '[{"body":'; } > "$TMP_FX/malformed.json"
218
+ OUT=$(MOCK_GH_JSON="$TMP_FX/malformed.json" bash "$VERIFY" 3061 -R o/r 2>/dev/null); RC=$?
219
+ [ $RC -eq 2 ] && echo "$OUT" | grep -q 'PHASE_TRAIL: ERROR' && ok "malformed page fails closed (exit 2)" || bad "malformed page (rc=$RC out=$OUT)"
220
+
221
+ # SEC-4 (#3149): the grace keys on updated_at -- BUILDER:COMPLETE is PATCHed in later than the comment was created
222
+ jq -nc '[{"body":"<!-- FORGE:INVESTIGATOR -->\n<!-- INVESTIGATION:COMPLETE -->"},{"body":"<!-- FORGE:FAST_PATH -->\n**COMPLEXITY_BAND**: TRIVIAL"},{"body":"<!-- FORGE:CONTRACT -->\nc"},{"body":"<!-- FORGE:BUILDER -->\nx\n<!-- FORGE:BUILDER:COMPLETE -->","created_at":"2026-10-01T00:00:00Z","updated_at":"2026-10-08T00:00:00Z"}] | map(. + {author_association:"OWNER", user:{login:"owner",type:"User"}})' > "$TMP_FX/patched.json"
223
+ expect_fail "old created_at but post-cutoff updated_at is not waived" "$TMP_FX/patched.json" QUALITY_GATE
224
+ jq -c 'map(del(.updated_at))' "$TMP_FX/patched.json" > "$TMP_FX/noupd.json"
225
+ expect_fail "BUILDER:COMPLETE without updated_at never earns the grace" "$TMP_FX/noupd.json" QUALITY_GATE
226
+
227
+ # SEC-3 (#3149): --code-diff makes an INVESTIGATION band unusable for waiving requirements
228
+ expect_fail "INVESTIGATION band + code diff requires CONTRACT" "$(mk cd1 INV FP_INVESTIGATION)" CONTRACT --code-diff
229
+ expect_fail "INVESTIGATION band + code diff requires QUALITY_GATE" "$(mk cd2 INV CONTRACT CONTEXT ARCH FP_INVESTIGATION)" QUALITY_GATE --code-diff
230
+ expect_pass "INVESTIGATION band + code diff passes with a full trail" "$(mk cd3 INV CONTRACT CONTEXT ARCH QG_PASS FP_INVESTIGATION)" --code-diff
231
+ run "$(mk cd4 INV FP_INVESTIGATION)" --code-diff
232
+ echo "$OUT" | grep -q 'NOTE: INVESTIGATION band ignored' && ok "ignored band is reported" || bad "band note ($OUT)"
233
+ expect_pass "INVESTIGATION band + docs-only (no code diff) still passes" "$(mk cd5 INV FP_INVESTIGATION)" --docs-only
234
+ expect_pass "TRIVIAL band is unaffected by --code-diff" "$(mk cd6 INV CONTRACT FP_TRIVIAL QG_PASS)" --code-diff
235
+
236
+ # SEC-5 (#3149): --head-tree binds the QUALITY_GATE PASS to the built tree
237
+ T1=1111111111111111111111111111111111111111; T2=2222222222222222222222222222222222222222
238
+ qgt() { jq -nc --arg b "$1" '{body:$b}' ; }
239
+ ht() { # ht <name> <qg body...> -> full STANDARD trail whose only QUALITY_GATE comments are the given bodies
240
+ local out="$TMP_FX/$1.json"; shift
241
+ { echo '{"body":"<!-- FORGE:INVESTIGATOR -->\n<!-- INVESTIGATION:COMPLETE -->"}'; echo '{"body":"<!-- FORGE:FAST_PATH -->\n**COMPLEXITY_BAND**: STANDARD"}'
242
+ echo '{"body":"<!-- FORGE:CONTRACT -->\nc"}'; echo '{"body":"<!-- FORGE:CONTEXT -->\nc"}'; echo '{"body":"<!-- FORGE:ARCHITECT -->\nc"}'
243
+ local b; for b in "$@"; do qgt "$b"; done; } | jq -sc 'map(. + {author_association:"OWNER", user:{login:"owner",type:"User"}})' > "$out"; echo "$out"
244
+ }
245
+ QG_T1=$'<!-- FORGE:QUALITY_GATE -->\n**Result**: PASS\n**Tree**: '"$T1"$'\n**Iterations**: 1'
246
+ QG_T2=$'<!-- FORGE:QUALITY_GATE -->\n**Result**: PASS\n**Tree**: '"$T2"
247
+ QG_NOTREE=$'<!-- FORGE:QUALITY_GATE -->\n**Result**: PASS'
248
+ expect_pass "head-tree matches the PASS marker's Tree" "$(ht h1 "$QG_T1")" --head-tree "$T1"
249
+ expect_fail "PASS recorded for an earlier tree does not satisfy the gate" "$(ht h2 "$QG_T2")" QUALITY_GATE --head-tree "$T1"
250
+ expect_fail "PASS without a Tree line does not satisfy a bound gate" "$(ht h3 "$QG_NOTREE")" QUALITY_GATE --head-tree "$T1"
251
+ expect_pass "a later PASS for the head tree wins over an earlier-tree PASS" "$(ht h4 "$QG_T2" "$QG_T1")" --head-tree "$T1"
252
+ expect_pass "no --head-tree keeps the unbound behaviour" "$(ht h5 "$QG_NOTREE")"
253
+ expect_pass "uppercase head-tree is normalised" "$(ht h6 "$QG_T1")" --head-tree "$(echo "$T1" | tr a-f A-F)"
254
+ expect_pass "docs-only still waives the bound gate" "$(ht h7)" --docs-only --head-tree "$T1"
255
+ for bt in "" "xyz" "${T1}0" "abc123"; do
256
+ OUT=$(MOCK_GH_JSON="$(ht h8 "$QG_T1")" bash "$VERIFY" 3061 -R o/r --head-tree "$bt" 2>/dev/null); RC=$?
257
+ [ $RC -eq 2 ] && echo "$OUT" | grep -q 'PHASE_TRAIL: ERROR' && ok "invalid --head-tree '$bt' fails closed (exit 2)" || bad "head-tree '$bt' (rc=$RC out=$OUT)"
258
+ done
259
+ # A 64-hex marker tree that merely starts with the 40-hex head tree must not match
260
+ QG_LONG=$'<!-- FORGE:QUALITY_GATE -->\n**Result**: PASS\n**Tree**: '"${T1}1111111111111111111111"
261
+ expect_fail "marker tree longer than (but prefixed by) the head tree is rejected" "$(ht h10 "$QG_LONG")" QUALITY_GATE --head-tree "$T1"
262
+ OUT=$(MOCK_GH_JSON="$(ht h9 "$QG_T1")" bash "$VERIFY" 3061 -R o/r --head-tree 2>/dev/null); RC=$?
263
+ [ $RC -eq 2 ] && ok "--head-tree without a value exits 2" || bad "head-tree no value (rc=$RC)"
264
+
265
+ # -h prints the full header
266
+ HOUT=$(bash "$VERIFY" -h)
267
+ echo "$HOUT" | grep -q 'TRUSTED ENVIRONMENT ONLY' && echo "$HOUT" | grep -q 'COLLABORATOR includes read-level' && echo "$HOUT" | grep -q '^# Usage:' && ok "-h prints the full multi-line header" || bad "-h truncated"
268
+
269
+ # Untrusted-author FORGE markers are ignored AND diagnosed with a NOTE (#3123)
270
+ UT="$TMP_FX/untrusted.json"
271
+ jq -c 'map(. + {author_association:"CONTRIBUTOR", user:{login:"ext",type:"User"}})' "$(mk ut_src INV CONTRACT FP_TRIVIAL QG_PASS)" > "$UT"
272
+ OUT=$(MOCK_GH_JSON="$UT" bash "$VERIFY" 3061 -R o/r 2>/dev/null); RC=$?
273
+ [ $RC -eq 1 ] && echo "$OUT" | grep -q 'MISSING: INVESTIGATOR' && echo "$OUT" | grep -q 'NOTE: .*untrusted authors were ignored' && ok "CONTRIBUTOR markers ignored with NOTE" || bad "contributor note (rc=$RC out=$OUT)"
274
+ OUT=$(FORGE_TRAIL_TRUSTED_LOGINS=ext MOCK_GH_JSON="$UT" bash "$VERIFY" 3061 -R o/r 2>/dev/null); RC=$?
275
+ [ $RC -eq 0 ] && ok "FORGE_TRAIL_TRUSTED_LOGINS accepts a CONTRIBUTOR login" || bad "trusted login override (rc=$RC out=$OUT)"
276
+ expect_pass "marker-only CONTEXT satisfies STANDARD" "$(mk minctx INV FP_STANDARD CONTRACT CONTEXT ARCH QG_PASS)"
277
+
278
+ # -h is bounded by the END-HELP sentinel, not a hardcoded line range (#3147)
279
+ echo "$HOUT" | grep -q 'Callers route on the exit code' && ! echo "$HOUT" | grep -q 'END-HELP' && ! echo "$HOUT" | grep -q 'set -uo pipefail' && ok "-h stops at the END-HELP sentinel" || bad "-h sentinel"
280
+
281
+ # Outage fails closed (exit 2, never PASS)
282
+ OUT=$(MOCK_GH_FAIL=1 MOCK_GH_JSON=/dev/null bash "$VERIFY" 3061 -R o/r 2>/dev/null); RC=$?
283
+ [ $RC -eq 2 ] && echo "$OUT" | grep -q 'PHASE_TRAIL: ERROR' && ok "gh outage fails closed" || bad "gh outage (rc=$RC out=$OUT)"
284
+
285
+ # -R with no value must error out, not hang
286
+ # macOS has no `timeout`; fall back to gtimeout, then perl alarm.
287
+ if command -v timeout >/dev/null 2>&1; then TMO=(timeout 5)
288
+ elif command -v gtimeout >/dev/null 2>&1; then TMO=(gtimeout 5)
289
+ else TMO=(perl -e 'alarm shift; exec @ARGV' 5); fi
290
+ OUT=$("${TMO[@]}" bash "$VERIFY" 5 -R 2>/dev/null); RC=$?
291
+ [ $RC -eq 2 ] && ok "-R without value exits 2 (no hang)" || bad "-R without value (rc=$RC)"
292
+
293
+ # Usage errors
294
+ OUT=$(bash "$VERIFY" 2>/dev/null); RC=$?
295
+ [ $RC -eq 2 ] && ok "no args is a usage error" || bad "usage error (rc=$RC)"
296
+
297
+ echo "passed=$PASS failed=$FAILN"
298
+ [ "$FAILN" -eq 0 ]