forgedock 1.8.1 → 1.8.2
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.
- package/bin/engine/admission.mjs +57 -1
- package/bin/engine/admission.test.mjs +60 -0
- package/commands/analytics.md +3 -1
- package/commands/audit-agents.md +3 -1
- package/commands/audit.md +10 -8
- package/commands/autopilot.md +16 -14
- package/commands/ci-audit.md +4 -2
- package/commands/cleanup.md +1 -1
- package/commands/deploy-pr.md +31 -11
- package/commands/geo-audit.md +3 -1
- package/commands/incident-response.md +3 -1
- package/commands/issue.md +4 -2
- package/commands/milestone.md +8 -6
- package/commands/orchestrate/config.md +2 -2
- package/commands/orchestrate/phase-1-resolve.md +56 -12
- package/commands/orchestrate/phase-3-dependency.md +15 -5
- package/commands/orchestrate/phase-4-execution.md +593 -78
- package/commands/orchestrate/phase-5-cleanup.md +4 -2
- package/commands/orchestrate/phase-6-report.md +7 -3
- package/commands/orchestrate.md +13 -9
- package/commands/pipeline-health/phase-4-proposals.md +6 -4
- package/commands/pipeline-health/phase-5-report.md +4 -2
- package/commands/pipeline-health.md +9 -7
- package/commands/pipeline-resume.md +4 -2
- package/commands/qa-sweep.md +3 -1
- package/commands/quality-gate.md +4 -3
- package/commands/recover-orphans.md +172 -13
- package/commands/review-pr-agents.md +6 -4
- package/commands/review-pr-staging.md +13 -5
- package/commands/review-pr.md +187 -19
- package/commands/rollback.md +3 -1
- package/commands/security-audit.md +3 -1
- package/commands/signal-planner.md +6 -4
- package/commands/sync-ecosystem.md +4 -2
- package/commands/test-gate.md +4 -2
- package/commands/upgrade-deps.md +3 -1
- package/commands/validate.md +3 -1
- package/commands/work-on/build/architect.md +81 -19
- package/commands/work-on/build/context.md +61 -18
- package/commands/work-on/build/implement.md +62 -18
- package/commands/work-on/build/validate.md +240 -60
- package/commands/work-on/build.md +344 -146
- package/commands/work-on/close.md +617 -246
- package/commands/work-on/decompose.md +239 -41
- package/commands/work-on/investigate.md +377 -61
- package/commands/work-on/remediate.md +42 -9
- package/commands/work-on/review.md +240 -29
- package/commands/work-on-monolithic.md +48 -7
- package/commands/work-on.md +180 -2014
- package/docs/CODEX.md +2 -0
- package/docs/CONFIG.md +11 -0
- package/package.json +1 -1
- package/scripts/build-spec-graph.mjs +11 -4
- package/scripts/check-spec-markers.sh +11 -1
- package/scripts/checkbox-sections.test.sh +8 -23
- package/scripts/extract-affected-files.sh +5 -2
- package/scripts/extract-affected-files.test.sh +27 -0
- package/scripts/forge-root.test.sh +176 -0
- package/scripts/is-docs-only.sh +31 -0
- package/scripts/is-docs-only.test.sh +64 -0
- package/scripts/lint-dispatch-prompt.sh +217 -0
- package/scripts/lint-dispatch-prompt.test.sh +307 -0
- package/scripts/validate-spec-graph.sh +3 -2
- package/scripts/verify-phase-trail.sh +282 -0
- package/scripts/verify-phase-trail.test.sh +298 -0
- package/scripts/wait-ci-green.sh +98 -0
- package/scripts/wait-ci-green.test.sh +99 -0
|
@@ -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 ]
|