forgedock 1.16.0 → 1.16.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/phases.mjs +69 -15
- package/bin/engine/runlog.mjs +21 -3
- package/bin/engine.mjs +43 -7
- package/bin/runner.mjs +21 -2
- package/commands/forge-stats.md +2 -2
- package/commands/orchestrate/phase-1-resolve.md +6 -40
- package/commands/orchestrate/phase-4-execution.md +26 -162
- package/commands/quality-gate.md +12 -80
- package/commands/review-pr-staging.md +67 -301
- package/commands/review-pr.md +150 -752
- package/commands/work-on/build/validate.md +6 -40
- package/commands/work-on/build.md +82 -40
- package/commands/work-on/close.md +7 -22
- package/commands/work-on/decompose.md +8 -24
- package/commands/work-on/investigate.md +18 -29
- package/commands/work-on/remediate.md +38 -147
- package/commands/work-on/review.md +25 -42
- package/commands/work-on.md +13 -63
- package/docs/CONFIG.md +10 -7
- package/docs/RELEASE-NOTES.md +6 -0
- package/docs/WORK-ON-RUNTIME.md +1 -1
- package/docs/spec/forge-protocol-v1.md +1 -1
- package/package.json +1 -1
- package/scripts/check-spec-markers.sh +2 -0
- package/scripts/check-spec-size.sh +47 -27
- package/scripts/check-spec-size.test.sh +46 -3
- package/scripts/diff-size.sh +36 -3
- package/scripts/diff-size.test.sh +47 -0
- package/scripts/forge-root.test.sh +218 -91
- package/scripts/lib/fence-bootstrap.sh +16 -0
- package/scripts/lib/forge-resolve.sh +96 -0
- package/scripts/lib/review-delta-mode.sh +41 -0
- package/scripts/lint-dispatch-prompt.test.sh +3 -3
- package/scripts/remediate-liveness.sh +96 -0
- package/scripts/review-acceptance-gate.sh +132 -0
- package/scripts/review-acceptance-gate.test.sh +90 -0
- package/scripts/review-pr-delta-wiring.test.sh +24 -7
- package/scripts/review-pr-staging-scope.test.sh +14 -7
- package/scripts/spec-no-worktree-script.test.sh +1 -1
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# forge-resolve.sh — sourced helper: locate ForgeDock's OWN install root and resolve its scripts.
|
|
3
|
+
# SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
4
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
|
+
#
|
|
6
|
+
# Source it, never exec it: . "$_r/scripts/lib/forge-resolve.sh"
|
|
7
|
+
# forge_root prints the first trusted install root that carries scripts/lib/forge-resolve.sh
|
|
8
|
+
# forge_resolve <name> prints the absolute path of scripts/<name> from the first trusted root that has it,
|
|
9
|
+
# or prints nothing (status 0). Callers fail closed on empty output.
|
|
10
|
+
#
|
|
11
|
+
# Trust boundary: roots come only from the engine (FORGEDOCK_HOME, authoritative when set), the running plugin root the
|
|
12
|
+
# stub locator handed over (FORGE_SOURCED_ROOT, the loader-substituted plugin root), the plugin runtime
|
|
13
|
+
# (CLAUDE_PLUGIN_ROOT), the legacy FORGE_ROOT/FORGE_HOME variables, the install.sh symlink, the Codex
|
|
14
|
+
# $CODEX_HOME/forge-home file, the newest pinned plugin cache and the marketplace checkout under CLAUDE_CONFIG_DIR
|
|
15
|
+
# then ~/.claude, and last the directory this file was sourced from. The consumer's working directory and the repo
|
|
16
|
+
# under review are author-controlled and are never consulted, and every root must be an absolute path.
|
|
17
|
+
#
|
|
18
|
+
# Order (matches docs/CONFIG.md "Install root resolution"): FORGEDOCK_HOME (when set, nothing else is consulted) >
|
|
19
|
+
# running plugin root (FORGE_SOURCED_ROOT) > CLAUDE_PLUGIN_ROOT > FORGE_ROOT > FORGE_HOME > install symlink >
|
|
20
|
+
# Codex forge-home > newest cache > marketplace > self.
|
|
21
|
+
# The running plugin root wins so a session keeps the scripts that match its own spec text even when a newer version is
|
|
22
|
+
# cached or a stale FORGE_HOME is exported. The stub assigns it from the loader-substituted plugin root (a shell-local
|
|
23
|
+
# assignment, never exported); non-Claude runtimes leave the literal placeholder, which the absolute-path check rejects.
|
|
24
|
+
# The self root is last on purpose: the stub locator that sourced this file picks a cache directory by glob order
|
|
25
|
+
# (lexical), so absent a running root the helper re-resolves by the numeric-newest tier instead of trusting the copy
|
|
26
|
+
# that happened to load.
|
|
27
|
+
|
|
28
|
+
# Root this file was sourced from (scripts/lib/.. /..), captured now because the cwd changes later. Under zsh
|
|
29
|
+
# BASH_SOURCE is empty, so the sourced path comes from ${(%):-%x} (eval'd: bash cannot parse that expansion). An empty
|
|
30
|
+
# path would make dirname "." (a cwd-derived root), so there is no self root when neither shell reports one.
|
|
31
|
+
_FORGE_SELF_ROOT=""; _forge_src=""
|
|
32
|
+
if [ -n "${BASH_SOURCE[0]:-}" ]; then _forge_src="${BASH_SOURCE[0]}"
|
|
33
|
+
elif [ -n "${ZSH_VERSION:-}" ]; then eval '_forge_src="${(%):-%x}"' 2>/dev/null || _forge_src=""; fi
|
|
34
|
+
[ -n "$_forge_src" ] && _FORGE_SELF_ROOT="$(cd "$(dirname "$_forge_src")/../.." 2>/dev/null && pwd -P)"
|
|
35
|
+
unset _forge_src
|
|
36
|
+
|
|
37
|
+
# Windows drive-letter path (C:/x or C:\x) -> /c/x (cygpath when present); anything else is printed unchanged.
|
|
38
|
+
_forge_posix() {
|
|
39
|
+
local _w="${1:-}" _u
|
|
40
|
+
case "$_w" in
|
|
41
|
+
[A-Za-z]:[/\\]*)
|
|
42
|
+
_u="$(cygpath -u "$_w" 2>/dev/null || true)"
|
|
43
|
+
[ -n "$_u" ] || _u="/$(printf %s "$_w" | cut -c1 | tr 'A-Z' 'a-z')$(printf %s "${_w#??}" | tr '\\' '/')"
|
|
44
|
+
printf '%s\n' "$_u" ;;
|
|
45
|
+
*) printf '%s\n' "$_w" ;;
|
|
46
|
+
esac
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
# Running plugin root handed over by the stub locator (shell-local, consumed once, never exported).
|
|
50
|
+
_FORGE_RUNNING_ROOT="$(_forge_posix "${FORGE_SOURCED_ROOT:-}")"; unset FORGE_SOURCED_ROOT
|
|
51
|
+
|
|
52
|
+
# One absolute candidate root per line, highest priority first.
|
|
53
|
+
_forge_candidates() {
|
|
54
|
+
local _l _cfg _mk _x _cx
|
|
55
|
+
if [ -n "${FORGEDOCK_HOME:-}" ]; then _forge_posix "$FORGEDOCK_HOME"; return 0; fi
|
|
56
|
+
_mk="${FORGEDOCK_MARKETPLACE:-forgedock}"; case "$_mk" in ""|.|..|*[!A-Za-z0-9._-]*) _mk="forgedock" ;; esac
|
|
57
|
+
printf '%s\n' "$_FORGE_RUNNING_ROOT" "${CLAUDE_PLUGIN_ROOT:-}" "${FORGE_ROOT:-}" "${FORGE_HOME:-}"
|
|
58
|
+
for _cfg in "${CLAUDE_CONFIG_DIR:-}" "$HOME/.claude"; do
|
|
59
|
+
[ -n "$_cfg" ] || continue
|
|
60
|
+
_l="$(readlink -f "$_cfg/commands/work-on.md" 2>/dev/null || true)"
|
|
61
|
+
[ -n "$_l" ] && printf '%s\n' "${_l%/commands/work-on.md}"
|
|
62
|
+
done
|
|
63
|
+
# Codex: install-codex.sh records the clone path in $CODEX_HOME/forge-home (one absolute path).
|
|
64
|
+
_cx="${CODEX_HOME:-$HOME/.codex}"; case "$_cx" in /*) head -n 1 "$_cx/forge-home" 2>/dev/null || true; echo ;; esac
|
|
65
|
+
for _cfg in "${CLAUDE_CONFIG_DIR:-}" "$HOME/.claude"; do
|
|
66
|
+
[ -n "$_cfg" ] || continue
|
|
67
|
+
find -L "$_cfg/plugins/cache/$_mk/forgedock" -mindepth 1 -maxdepth 1 -type d 2>/dev/null \
|
|
68
|
+
| awk -F/ '$NF ~ /^[0-9]+\.[0-9]+\.[0-9]+$/{split($NF,a,".");printf "%d %d %d %s\n",a[1],a[2],a[3],$(0)}' \
|
|
69
|
+
| sort -k1,1nr -k2,2nr -k3,3nr | cut -d' ' -f4- || true
|
|
70
|
+
done
|
|
71
|
+
for _cfg in "${CLAUDE_CONFIG_DIR:-}" "$HOME/.claude"; do
|
|
72
|
+
[ -n "$_cfg" ] && printf '%s\n' "$_cfg/plugins/marketplaces/$_mk"
|
|
73
|
+
done
|
|
74
|
+
printf '%s\n' "$_FORGE_SELF_ROOT"
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
# forge_resolve <path under scripts/>: print the first match; print nothing when unresolved. Always returns 0, so a
|
|
78
|
+
# command substitution survives `set -e` and the caller's own empty-output check is the fail-closed branch.
|
|
79
|
+
forge_resolve() {
|
|
80
|
+
local _n="${1:-}" _c _all
|
|
81
|
+
case "$_n" in ''|/*|*..*) return 0 ;; esac
|
|
82
|
+
# Materialize the list first: returning early from a `< <(...)` loop would close the pipe under the generator (EPIPE noise).
|
|
83
|
+
_all="$(_forge_candidates)"
|
|
84
|
+
while IFS= read -r _c; do
|
|
85
|
+
case "$_c" in /*) [ -f "$_c/scripts/$_n" ] && { printf '%s\n' "$_c/scripts/$_n"; return 0; } ;; esac
|
|
86
|
+
done <<< "$_all"
|
|
87
|
+
return 0
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
# forge_root: print the first trusted install root (the one that carries this helper); prints nothing when unresolved.
|
|
91
|
+
forge_root() {
|
|
92
|
+
local _f
|
|
93
|
+
_f="$(forge_resolve lib/forge-resolve.sh)"
|
|
94
|
+
[ -n "$_f" ] && printf '%s\n' "${_f%/scripts/lib/forge-resolve.sh}"
|
|
95
|
+
return 0
|
|
96
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# review-delta-mode.sh: sourced helper for commands/review-pr.md (the review-delta mode functions).
|
|
3
|
+
# SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
4
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
|
+
#
|
|
6
|
+
# Source it, never exec it; the spec resolves it with `forge_resolve lib/review-delta-mode.sh` (install roots only, never the
|
|
7
|
+
# working directory or the PR checkout). When it is unresolved the spec defines FULL-only fallbacks, so a missing helper
|
|
8
|
+
# can only cause a full review, never a skipped one.
|
|
9
|
+
is_sha() { case "${1}" in *[!0-9a-f]*|"") return 1 ;; esac; [ "${#1}" -eq 40 ] || [ "${#1}" -eq 64 ]; }
|
|
10
|
+
# Prints "<MODE> <ARG> <FULL_ROUNDS>" from scripts/review-delta.sh: "FULL - n", "DELTA <reviewed-sha> n" or "BASE_SYNC_ONLY <reviewed-sha> n".
|
|
11
|
+
# Needs REPO PR_NUMBER REVIEW_SHA BASE_BRANCH TRUSTED_SCRIPT REVIEW_DELTA_SCRIPT in scope. Every failure (unresolved script, bad output, bad SHA, empty delta) prints FULL, never a skipped review.
|
|
12
|
+
review_delta_mode() {
|
|
13
|
+
local out l1 l2 kind arg from to rounds=0 top
|
|
14
|
+
is_sha "$REVIEW_SHA" || { echo "FULL - 0"; return 0; }
|
|
15
|
+
if [ -z "$TRUSTED_SCRIPT" ] || [ -z "$REVIEW_DELTA_SCRIPT" ] || [ -z "$BASE_BRANCH" ]; then echo "FULL - 0"; return 0; fi
|
|
16
|
+
top=$(git rev-parse --show-toplevel 2>/dev/null) || top=""
|
|
17
|
+
[ -n "$top" ] || { echo "FULL - 0"; return 0; }
|
|
18
|
+
out=$(bash "$REVIEW_DELTA_SCRIPT" --pr "$PR_NUMBER" --head "$REVIEW_SHA" --base "$BASE_BRANCH" --trusted-script "$TRUSTED_SCRIPT" --repo "$REPO" --repo-path "$top" 2>/dev/null) || out=""
|
|
19
|
+
l1=$(printf '%s\n' "$out" | sed -n 1p); l2=$(printf '%s\n' "$out" | sed -n 2p)
|
|
20
|
+
case "$l2" in FULL_ROUNDS=*) rounds="${l2#FULL_ROUNDS=}" ;; esac
|
|
21
|
+
case "$rounds" in ''|*[!0-9]*) echo "FULL - 0"; return 0 ;; esac
|
|
22
|
+
kind="${l1%% *}"; arg="${l1#* }"
|
|
23
|
+
if [ "$kind" = "BASE_SYNC_ONLY" ] && is_sha "$arg" && [ "$arg" != "$REVIEW_SHA" ]; then echo "BASE_SYNC_ONLY $arg $rounds"; return 0; fi
|
|
24
|
+
if [ "$kind" = "DELTA" ]; then
|
|
25
|
+
from="${arg%%..*}"; to="${arg##*..}"
|
|
26
|
+
if is_sha "$from" && [ "$to" = "$REVIEW_SHA" ] && [ "$from" != "$to" ]; then echo "DELTA $from $rounds"; return 0; fi
|
|
27
|
+
fi
|
|
28
|
+
echo "FULL - $rounds"
|
|
29
|
+
}
|
|
30
|
+
# Prints the Carried-From-SHA only when a TRUSTED FORGE:REVIEW-CARRIED record for this head names it AND the resolver
|
|
31
|
+
# still answers BASE_SYNC_ONLY from that same reviewed SHA right now. Needs COMMENTS_JSON as well. Marker text alone never counts.
|
|
32
|
+
carry_verified_from() {
|
|
33
|
+
local kind from rounds n
|
|
34
|
+
if [ -z "$TRUSTED_SCRIPT" ] || [ -z "$COMMENTS_JSON" ]; then return 0; fi
|
|
35
|
+
read -r kind from rounds <<< "$(review_delta_mode)"
|
|
36
|
+
[ "$kind" = "BASE_SYNC_ONLY" ] || return 0
|
|
37
|
+
n=$(printf '%s' "$COMMENTS_JSON" | bash "$TRUSTED_SCRIPT" count "\\A(?=[\\s\\S]*\\nReviewed-SHA: ${REVIEW_SHA}\\r?(\\n|$))(?=[\\s\\S]*\\nCarried-From-SHA: ${from}\\r?(\\n|$))<!-- FORGE:REVIEW-CARRIED -->" 2>/dev/null || echo "")
|
|
38
|
+
case "$n" in ''|*[!0-9]*) return 0 ;; esac
|
|
39
|
+
[ "$n" -ge 1 ] && printf '%s' "$from"
|
|
40
|
+
return 0
|
|
41
|
+
}
|
|
@@ -227,10 +227,10 @@ bash "$LINT" --expect nokey "$T/p27" >/dev/null 2>&1; [ $? -eq 2 ] && PASS=$((PA
|
|
|
227
227
|
check "mid-line CR not stripped" 1 "$T/p25"
|
|
228
228
|
|
|
229
229
|
# 10. Spec snippet enforcement (forge#3070): extract the lint gate from the spec and run it in a loop.
|
|
230
|
-
# Gate = from the
|
|
230
|
+
# Gate = from the forge_resolve stub locator line to the end of its fenced code block (not a hard-coded count of
|
|
231
231
|
# top-level fi lines, which breaks whenever the bootstrap gains or loses a conditional).
|
|
232
|
-
# Several spec blocks carry the
|
|
233
|
-
awk '
|
|
232
|
+
# Several spec blocks carry the stub locator (e.g. the recovery-claim gate, forge#3172); pick the one holding the lint gate.
|
|
233
|
+
awk '/^type forge_resolve >\/dev\/null/{f=1;buf=""} f&&/^```/{if(buf ~ /LINT_REFUSED_ISSUES/){printf "%s",buf;exit} f=0} f{buf=buf $0 "\n"}' "$SPEC" > "$T/gate"
|
|
234
234
|
[ -s "$T/gate" ] || { FAILN=$((FAILN+1)); echo "FAIL: could not extract spec lint gate"; }
|
|
235
235
|
gate_refused() { # prompt-file home -> prints refusal entries or LAUNCHED
|
|
236
236
|
( LINT_REFUSED_ISSUES=(); RENDERED_PROMPT="$(cat "$1")"; FORGEDOCK_HOME="$2"; unset FORGE_HOME; reset_vals
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# remediate-liveness.sh — is an earlier remediation of this PR still alive? (remediate.md Phase M0, forge#3792)
|
|
3
|
+
# SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
4
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
|
+
#
|
|
6
|
+
# Usage: remediate-liveness.sh <pr> <issue> <owner/repo> [lease-holder]
|
|
7
|
+
# Env: DRY_RUN non-empty: print the deferral record instead of posting it
|
|
8
|
+
# FORGE_LIVENESS_NOW_MS override the clock (tests)
|
|
9
|
+
#
|
|
10
|
+
# The decision is deterministic and comes from GitHub state only, never from how partial trail comments look:
|
|
11
|
+
# (a) lease: the FORGE:STATE block in the issue body carries lease.until in (now, now + MAX_LEASE_TTL] for a
|
|
12
|
+
# lease.by that is not the dispatching holder and not a provably dead same-host engine pid. An until past
|
|
13
|
+
# the TTL cap is not a lease an engine could have written (the issue body is author-editable), so it is dead.
|
|
14
|
+
# (b) review: the newest trusted FORGE:REVIEW_ROUTE comment is younger than REVIEW_PENDING_MAX_S and no trusted
|
|
15
|
+
# FORGE:REVIEW-AGENT:* comment carries a Reviewed-SHA that prefix-matches the routed sha (either direction).
|
|
16
|
+
# A deferral is bounded: after MAX_DEFERRALS trusted FORGE:REMEDIATION_DEFERRED records for this PR the answer is
|
|
17
|
+
# EXHAUSTED (a human is needed), so a flapping lease or a dead review can never park a PR forever.
|
|
18
|
+
#
|
|
19
|
+
# The last stdout line is the verdict; the exit code mirrors it:
|
|
20
|
+
# LIVENESS: DEAD exit 0 proceed (adopt the interrupted attempt)
|
|
21
|
+
# LIVENESS: DEFER <kind> <detail> exit 10 a live run owns the PR; a FORGE:REMEDIATION_DEFERRED record was posted
|
|
22
|
+
# LIVENESS: EXHAUSTED <n> exit 11 deferral budget used up; escalate
|
|
23
|
+
# LIVENESS: ERROR <why> exit 2 unreadable state; fail closed (never "no lease", never delete)
|
|
24
|
+
# Trust predicate for comments: Bot, or OWNER/MEMBER/COLLABORATOR (same default as scripts/trusted-comments.sh).
|
|
25
|
+
set -uo pipefail
|
|
26
|
+
|
|
27
|
+
PR="${1:-}"; ISSUE="${2:-}"; REPO="${3:-}"; HOLDER="${4:-}"
|
|
28
|
+
MAX_LEASE_TTL_MS=1800000 # 3x the engine's 10 min lease TTL
|
|
29
|
+
REVIEW_PENDING_MAX_S=1800 # a review with no verdict after 30 min is dead, not in flight
|
|
30
|
+
MAX_DEFERRALS=3
|
|
31
|
+
|
|
32
|
+
err() { printf 'LIVENESS: ERROR %s\n' "$1"; exit 2; }
|
|
33
|
+
case "$PR" in ''|*[!0-9]*) err "bad pr" ;; esac
|
|
34
|
+
case "$ISSUE" in ''|*[!0-9]*) err "bad issue" ;; esac
|
|
35
|
+
[ -n "$REPO" ] || err "no repo"
|
|
36
|
+
printf '%s' "$HOLDER" | grep -qE '^[A-Za-z0-9._-]{1,128}$' || HOLDER=""
|
|
37
|
+
NOW_MS="${FORGE_LIVENESS_NOW_MS:-$(( $(date +%s) * 1000 ))}"
|
|
38
|
+
NOW_S=$(( NOW_MS / 1000 ))
|
|
39
|
+
|
|
40
|
+
TRUST='((.user.type // "") == "Bot" or ((.author_association // "") | IN("OWNER","MEMBER","COLLABORATOR")))'
|
|
41
|
+
|
|
42
|
+
ISSUE_JSON=$(gh issue view "$ISSUE" -R "$REPO" --json body 2>/dev/null) || err "issue unreadable"
|
|
43
|
+
ISSUE_COMMENTS=$(gh api --paginate "repos/$REPO/issues/$ISSUE/comments" 2>/dev/null) || err "issue comments unreadable"
|
|
44
|
+
PR_COMMENTS=$(gh api --paginate "repos/$REPO/issues/$PR/comments" 2>/dev/null) || err "PR comments unreadable"
|
|
45
|
+
[ -n "$PR_COMMENTS" ] || err "PR comments unreadable"
|
|
46
|
+
|
|
47
|
+
# (a) lease. No FORGE:STATE block = no lease; a block that is present but unparseable fails closed.
|
|
48
|
+
STATE_JSON=$(printf '%s' "$ISSUE_JSON" | jq -r '.body // ""' | awk '/^<!-- FORGE:STATE[[:space:]]*$/{f=1;next} f&&/^-->/{exit} f')
|
|
49
|
+
LEASE_BY=""; LEASE_UNTIL=0; LEASE_LIVE=false
|
|
50
|
+
if [ -n "$STATE_JSON" ]; then
|
|
51
|
+
ROW=$(printf '%s' "$STATE_JSON" | jq -r '(.lease // {}) as $l | [($l.by // "" | tostring), (if ($l.until|type)=="number" then ($l.until|floor) else 0 end)] | @tsv' 2>/dev/null) \
|
|
52
|
+
|| err "FORGE:STATE block unparseable"
|
|
53
|
+
LEASE_BY=$(printf '%s' "$ROW" | cut -f1); LEASE_UNTIL=$(printf '%s' "$ROW" | cut -f2)
|
|
54
|
+
case "$LEASE_UNTIL" in ''|*[!0-9]*) LEASE_UNTIL=0 ;; esac
|
|
55
|
+
if [ -n "$LEASE_BY" ] && [ "$LEASE_BY" != "$HOLDER" ] && [ "$LEASE_UNTIL" -gt "$NOW_MS" ] && [ "$LEASE_UNTIL" -le $(( NOW_MS + MAX_LEASE_TTL_MS )) ]; then
|
|
56
|
+
LEASE_LIVE=true
|
|
57
|
+
# Provably dead same-host engine (id cli_<host>_<pid>, pid gone): not live, as the engine's own I3 guard decides.
|
|
58
|
+
if [[ "$LEASE_BY" =~ ^cli_(.+)_([0-9]+)$ ]]; then
|
|
59
|
+
_h="${BASH_REMATCH[1]}"; _p="${BASH_REMATCH[2]}"
|
|
60
|
+
_me=$(uname -n 2>/dev/null | sed 's/[^A-Za-z0-9.-]/-/g')
|
|
61
|
+
if [ "$_h" = "${_me:-unknown}" ] && ! kill -0 "$_p" 2>/dev/null && ! ps -p "$_p" >/dev/null 2>&1; then LEASE_LIVE=false; fi
|
|
62
|
+
fi
|
|
63
|
+
fi
|
|
64
|
+
fi
|
|
65
|
+
|
|
66
|
+
# (b) a routed review that is still inside its window and has produced no verdict for the routed sha.
|
|
67
|
+
ROUTE=$(printf '%s' "$PR_COMMENTS" | jq -rs "[.[][] | select($TRUST) | select((.body // \"\") | test(\"^<!-- FORGE:REVIEW_ROUTE mode=[^ ]+ spec=review-pr.md sha=[0-9a-f]{7,40} -->\")) | select((.body | test(\"mode=spec-evolution-blocked\")) | not)] | last | if . == null then \"\" else ((.body | capture(\"sha=(?<s>[0-9a-f]{7,40})\").s) + \" \" + ((.created_at // \"\") | fromdateiso8601? // 0 | tostring)) end" 2>/dev/null) \
|
|
68
|
+
|| err "review comments unparseable"
|
|
69
|
+
ROUTE_SHA="${ROUTE%% *}"; ROUTE_AT="${ROUTE##* }"; REVIEW_PENDING=false
|
|
70
|
+
if [ -n "$ROUTE_SHA" ]; then
|
|
71
|
+
case "$ROUTE_AT" in ''|*[!0-9]*) ROUTE_AT=0 ;; esac
|
|
72
|
+
VERDICTS=$(printf '%s' "$PR_COMMENTS" | jq -rs --arg r "$ROUTE_SHA" "[.[][] | select($TRUST) | select((.body // \"\") | test(\"^<!-- FORGE:REVIEW-AGENT:[a-z-]+ -->\")) | ((.body | capture(\"(^|\\\\n)Reviewed-SHA: (?<s>[0-9a-f]{7,40})\")? // {s: \"\"}).s) as \$s | select(\$s != \"\" and ((\$r | startswith(\$s)) or (\$s | startswith(\$r))))] | length" 2>/dev/null) || err "reviewer comments unreadable"
|
|
73
|
+
[ "${VERDICTS:-0}" -eq 0 ] && [ "$ROUTE_AT" -gt 0 ] && [ $(( NOW_S - ROUTE_AT )) -lt "$REVIEW_PENDING_MAX_S" ] && REVIEW_PENDING=true
|
|
74
|
+
fi
|
|
75
|
+
|
|
76
|
+
if [ "$LEASE_LIVE" != "true" ] && [ "$REVIEW_PENDING" != "true" ]; then
|
|
77
|
+
echo "LIVENESS: DEAD"; exit 0
|
|
78
|
+
fi
|
|
79
|
+
|
|
80
|
+
PRIOR=$(printf '%s' "$ISSUE_COMMENTS" | jq -rs --arg pr "pr=$PR" "[.[][] | select($TRUST) | select((.body // \"\") | startswith(\"<!-- FORGE:REMEDIATION_DEFERRED -->\")) | select((.body | split(\"\\n\") | index(\$pr)) != null)] | length" 2>/dev/null) || err "deferral records unreadable"
|
|
81
|
+
if [ "${PRIOR:-0}" -ge "$MAX_DEFERRALS" ]; then echo "LIVENESS: EXHAUSTED ${PRIOR}"; exit 11; fi
|
|
82
|
+
|
|
83
|
+
if [ "$LEASE_LIVE" = "true" ]; then KIND=lease; DETAIL="lease held by ${LEASE_BY} until ${LEASE_UNTIL}"
|
|
84
|
+
else KIND=review; DETAIL="re-review of ${ROUTE_SHA} has no reviewer verdicts yet"; fi
|
|
85
|
+
BODY="<!-- FORGE:REMEDIATION_DEFERRED -->
|
|
86
|
+
## Remediation Deferred
|
|
87
|
+
|
|
88
|
+
PR #${PR}: an earlier remediation run is still live. This run changed nothing (deferral $(( ${PRIOR:-0} + 1 )) of ${MAX_DEFERRALS}).
|
|
89
|
+
|
|
90
|
+
pr=${PR}
|
|
91
|
+
kind=${KIND}
|
|
92
|
+
lease_by=${LEASE_BY:-none}
|
|
93
|
+
lease_until=${LEASE_UNTIL}"
|
|
94
|
+
if [ -n "${DRY_RUN:-}" ]; then printf 'DRY_RUN: would post:\n%s\n' "$BODY"
|
|
95
|
+
else gh issue comment "$ISSUE" -R "$REPO" --body "$BODY" >/dev/null 2>&1 || err "could not post deferral record"; fi
|
|
96
|
+
echo "LIVENESS: DEFER ${KIND} ${DETAIL}"; exit 10
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# review-acceptance-gate.sh — review-phase acceptance gate (work-on/review.md Phase R2.5, forge#3793).
|
|
3
|
+
# SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
4
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
|
+
#
|
|
6
|
+
# Usage: review-acceptance-gate.sh <issue> <gh_repo> "<gh_flag>" <worktree_path> (DRY_RUN=1 skips gh writes)
|
|
7
|
+
# Evaluates the ACCEPTANCE_CHECK lines the build gate (B6.5) deferred: `phase=review` checks, plus untagged legacy
|
|
8
|
+
# command/behavior checks on a `gh pr` target. Uses its own FORGE:ACCEPTANCE_GATE_REVIEW marker, never the build
|
|
9
|
+
# gate's FORGE:ACCEPTANCE_GATE header or :PASSED/:FAILED/:BLOCKED sentinels (the engine reads those).
|
|
10
|
+
# Malformed checks (forge#3694 parity with B6.5: 4+ backslash run, `bash -n` parse failure, grep usage error) are skipped and listed,
|
|
11
|
+
# never a FAIL and never needs-human. With zero verified checks and some malformed, a non-blocking notice is posted (no PASSED marker).
|
|
12
|
+
# Exit: 0 passed / no-op / notice / already passed for this head, 1 a deferred check failed (issue gets needs-human).
|
|
13
|
+
set -uo pipefail
|
|
14
|
+
|
|
15
|
+
NUMBER="${1:?issue number}"; GH_REPO="${2:?repo}"; GH_FLAG="${3:-}"; WT="${4:-.}"
|
|
16
|
+
cd "$WT" || exit 2
|
|
17
|
+
run() { if [ -n "${DRY_RUN:-}" ]; then echo "DRY_RUN: $*"; else "$@"; fi; }
|
|
18
|
+
# shellcheck disable=SC2086 # GH_FLAG is a pre-split "-R owner/repo" flag string
|
|
19
|
+
gh_i() { gh "$@" $GH_FLAG; }
|
|
20
|
+
|
|
21
|
+
HEAD_SHA=$(git rev-parse HEAD 2>/dev/null)
|
|
22
|
+
# Trust (forge#3813): the spec lines are executed as check targets and the PASSED marker skips the gate, so both are read only
|
|
23
|
+
# from comments scripts/trusted-comments.sh accepts. The helper is resolved from ForgeDock's own install (forge_resolve), never
|
|
24
|
+
# the working directory; an unresolvable helper or unreadable comments fail closed (nothing executed, gate does not pass).
|
|
25
|
+
block() {
|
|
26
|
+
echo "Review acceptance gate: $1 (fail closed)" >&2
|
|
27
|
+
printf 'REVIEW_RESULT:\n status: BLOCKED\n pr_number:\n pr_url:\n merged_to:\n blocker: review acceptance gate cannot trust issue comments (%s)\n' "$1"
|
|
28
|
+
exit 1
|
|
29
|
+
}
|
|
30
|
+
_RESOLVE_LIB="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/forge-resolve.sh"
|
|
31
|
+
type forge_resolve >/dev/null 2>&1 || { [ -f "$_RESOLVE_LIB" ] && . "$_RESOLVE_LIB"; }
|
|
32
|
+
type forge_resolve >/dev/null 2>&1 || block "forge_resolve unavailable"
|
|
33
|
+
TRUSTED_SCRIPT=$(forge_resolve trusted-comments.sh)
|
|
34
|
+
[ -n "$TRUSTED_SCRIPT" ] || block "trusted-comments.sh unresolvable"
|
|
35
|
+
COMMENTS=$(gh api "repos/$GH_REPO/issues/$NUMBER/comments" --paginate 2>/dev/null) || block "comments unreadable"
|
|
36
|
+
[ -n "$COMMENTS" ] || block "comments unreadable"
|
|
37
|
+
TRUSTED_GATE=$(printf '%s' "$COMMENTS" | bash "$TRUSTED_SCRIPT" bodies '^<!-- FORGE:ACCEPTANCE_GATE_REVIEW -->') || block "trust filter failed"
|
|
38
|
+
TRUSTED_INV=$(printf '%s' "$COMMENTS" | bash "$TRUSTED_SCRIPT" bodies '^<!-- FORGE:INVESTIGATOR -->') || block "trust filter failed"
|
|
39
|
+
LAST=$(printf '%s\n' "$TRUSTED_GATE" | tail -n 1 | jq -r '. // ""' 2>/dev/null) || LAST=""
|
|
40
|
+
if printf '%s' "$LAST" | grep -qF "<!-- FORGE:ACCEPTANCE_GATE_REVIEW:PASSED:${HEAD_SHA} -->"; then
|
|
41
|
+
echo "Review acceptance gate already PASSED for ${HEAD_SHA}; skipping."; exit 0
|
|
42
|
+
fi
|
|
43
|
+
|
|
44
|
+
CHECKS=$(printf '%s\n' "$TRUSTED_INV" | jq -r '.' 2>/dev/null | grep "^ACCEPTANCE_CHECK:")
|
|
45
|
+
GATE_PASS=true; FAILED=""; VERIFIED_COUNT=0; MALFORMED_CHECKS=""; RESULTS=""
|
|
46
|
+
while IFS= read -r check_line; do
|
|
47
|
+
[ -z "$check_line" ] && continue
|
|
48
|
+
ID=$(echo "$check_line" | sed -n 's/^ACCEPTANCE_CHECK: id=\([^ ]*\) type=.*/\1/p')
|
|
49
|
+
TYPE=$(echo "$check_line" | sed -n 's/^ACCEPTANCE_CHECK: id=[^ ]* type=\([^ ]*\) target=.*/\1/p')
|
|
50
|
+
TARGET=$(printf '%s\n' "$check_line" | sed -nE 's/^ACCEPTANCE_CHECK: id=[^ ]* type=[^ ]* target="(([^"\\]|\\.)*)".*/\1/p' | sed 's/\\"/"/g')
|
|
51
|
+
[ -z "$TARGET" ] && TARGET=$(echo "$check_line" | sed -n 's/^ACCEPTANCE_CHECK: id=[^ ]* type=[^ ]* target=\([^ ]*\).*/\1/p')
|
|
52
|
+
MATCHER=$(printf '%s\n' "$check_line" | sed -nE 's/^ACCEPTANCE_CHECK: id=[^ ]* type=[^ ]* target=("([^"\\]|\\.)*"|[^ ]*) matcher="(([^"\\]|\\.)*)".*/\3/p' | sed 's/\\"/"/g')
|
|
53
|
+
[ -z "$MATCHER" ] && MATCHER=$(echo "$check_line" | sed -n 's/^ACCEPTANCE_CHECK: id=[^ ]* type=[^ ]* target=[^ ]* matcher=\([^ ]*\).*/\1/p')
|
|
54
|
+
DESC=$(echo "$check_line" | sed -n 's/.*description=\(.*\)/\1/p')
|
|
55
|
+
# phase= is anchored after matcher=, so text inside target/matcher/description cannot set it; anything else is build.
|
|
56
|
+
PHASE=$(printf '%s\n' "$check_line" | sed -nE 's/^ACCEPTANCE_CHECK: id=[^ ]* type=[^ ]* target=("(([^"\\]|\\.)*)"|[^ ]*) matcher=("(([^"\\]|\\.)*)"|[^ ]*) phase=(build|review)( .*)?$/\7/p')
|
|
57
|
+
[ "$PHASE" = "review" ] || PHASE=build
|
|
58
|
+
case "$TYPE" in command|behavior) ;; *) [ "$PHASE" = "review" ] || continue ;; esac
|
|
59
|
+
if [ "$PHASE" != "review" ]; then
|
|
60
|
+
printf '%s\n' "$check_line" | grep -qE ' phase=(build|review)( |$)' && continue
|
|
61
|
+
case "$TARGET" in *'gh pr'*) ;; *) continue ;; esac
|
|
62
|
+
fi
|
|
63
|
+
# malformed-check (forge#3694): static screen only parses (bash -n), never executes.
|
|
64
|
+
MALFORMED=""
|
|
65
|
+
{ [ "$TYPE" = "command" ] || [ "$TYPE" = "behavior" ]; } && { case "$TARGET" in *'\\\\'*) MALFORMED="run of 4+ backslashes (over-escaped target)";; esac; [ -n "$MALFORMED" ] || bash -n -c "$TARGET" 2>/dev/null || MALFORMED="shell syntax error (unbalanced quotes)"; }
|
|
66
|
+
if [ -n "$MALFORMED" ]; then MALFORMED_CHECKS="${MALFORMED_CHECKS}\n- **$ID** ($DESC): $MALFORMED"; continue; fi
|
|
67
|
+
RESULT="PASS"; DETAIL=""
|
|
68
|
+
case "$TYPE" in
|
|
69
|
+
exists) [ -e "$TARGET" ] || { RESULT="FAIL"; DETAIL="path not found: $TARGET"; } ;;
|
|
70
|
+
contains)
|
|
71
|
+
grep -qE "$MATCHER" "$TARGET" 2>/dev/null || grep -qF -- "$MATCHER" "$TARGET" 2>/dev/null || { RESULT="FAIL"; DETAIL="'$MATCHER' not found in $TARGET"; } ;;
|
|
72
|
+
command|behavior)
|
|
73
|
+
if [ "$MATCHER" = "exit_0" ]; then
|
|
74
|
+
ERR=$(eval "$TARGET" 2>&1 >/dev/null); RC=$?
|
|
75
|
+
# grep exit >= 2 is a usage error, not a verdict; a missing/unreadable operand also exits 2 but stays a real FAIL.
|
|
76
|
+
[ "$RC" -ge 2 ] && case "$TARGET" in grep\ *|egrep\ *|fgrep\ *) printf '%s' "$ERR" | grep -qE 'No such file or directory|Permission denied|Is a directory' || MALFORMED="grep usage error (exit $RC)";; esac
|
|
77
|
+
[ "$RC" -eq 0 ] || [ -n "$MALFORMED" ] || { RESULT="FAIL"; DETAIL="command exited non-zero: $TARGET"; }
|
|
78
|
+
else
|
|
79
|
+
OUTPUT=$(eval "$TARGET" 2>&1); RC=$?; ERR="$OUTPUT"
|
|
80
|
+
[ "$RC" -ge 2 ] && case "$TARGET" in grep\ *|egrep\ *|fgrep\ *) printf '%s' "$ERR" | grep -qE 'No such file or directory|Permission denied|Is a directory' || MALFORMED="grep usage error (exit $RC)";; esac
|
|
81
|
+
if [ -n "$MALFORMED" ]; then :
|
|
82
|
+
else
|
|
83
|
+
printf '%s\n' "$OUTPUT" | grep -qE "$MATCHER" || printf '%s\n' "$OUTPUT" | grep -qF -- "$MATCHER" \
|
|
84
|
+
|| { RESULT="FAIL"; DETAIL="output did not match '$MATCHER'. Got: $(printf '%s\n' "$OUTPUT" | head -3)"; }
|
|
85
|
+
fi
|
|
86
|
+
fi ;;
|
|
87
|
+
*) RESULT="FAIL"; DETAIL="unknown check type: $TYPE" ;;
|
|
88
|
+
esac
|
|
89
|
+
if [ -n "$MALFORMED" ]; then MALFORMED_CHECKS="${MALFORMED_CHECKS}\n- **$ID** ($DESC): $MALFORMED"; continue; fi
|
|
90
|
+
VERIFIED_COUNT=$((VERIFIED_COUNT+1))
|
|
91
|
+
RESULTS="${RESULTS}\n- **$ID** ($DESC): $RESULT"
|
|
92
|
+
if [ "$RESULT" = "FAIL" ]; then GATE_PASS=false; FAILED="${FAILED}\n- **$ID** ($DESC): $DETAIL"; fi
|
|
93
|
+
done <<< "$CHECKS"
|
|
94
|
+
|
|
95
|
+
MALFORMED_SECTION=""
|
|
96
|
+
[ -n "$MALFORMED_CHECKS" ] && MALFORMED_SECTION=$(printf '\n**malformed-check (not counted as failures; regenerate the acceptance spec by re-running investigate)**:%b\n' "$MALFORMED_CHECKS")
|
|
97
|
+
|
|
98
|
+
if [ "$VERIFIED_COUNT" -eq 0 ] && [ -z "$MALFORMED_CHECKS" ]; then
|
|
99
|
+
echo "No deferred acceptance checks; R2.5 is a no-op."
|
|
100
|
+
elif [ "$VERIFIED_COUNT" -eq 0 ]; then
|
|
101
|
+
# Review has no repair path, so unlike B6.5 this does not fail: notice only, no PASSED marker (a repaired spec is re-evaluated).
|
|
102
|
+
run gh_i issue comment "$NUMBER" --body "<!-- FORGE:ACCEPTANCE_GATE_REVIEW -->
|
|
103
|
+
## Review Acceptance Gate: SKIPPED (malformed checks)
|
|
104
|
+
|
|
105
|
+
Every deferred (phase=review) acceptance check was malformed, so none could be verified. This is not a failure and does not block merge.
|
|
106
|
+
$MALFORMED_SECTION"
|
|
107
|
+
echo "Review acceptance gate: all deferred checks malformed; non-blocking notice posted."
|
|
108
|
+
elif [ "$GATE_PASS" = "true" ]; then
|
|
109
|
+
run gh_i issue comment "$NUMBER" --body "<!-- FORGE:ACCEPTANCE_GATE_REVIEW -->
|
|
110
|
+
## Review Acceptance Gate: PASSED
|
|
111
|
+
|
|
112
|
+
Deferred (phase=review) acceptance checks verified against the PR:
|
|
113
|
+
$(echo -e "$RESULTS")
|
|
114
|
+
$MALFORMED_SECTION
|
|
115
|
+
|
|
116
|
+
<!-- FORGE:ACCEPTANCE_GATE_REVIEW:PASSED:${HEAD_SHA} -->"
|
|
117
|
+
else
|
|
118
|
+
run gh_i issue comment "$NUMBER" --body "<!-- FORGE:ACCEPTANCE_GATE_REVIEW -->
|
|
119
|
+
## Review Acceptance Gate: FAILED
|
|
120
|
+
|
|
121
|
+
The following deferred (phase=review) acceptance checks did not pass against the PR:
|
|
122
|
+
|
|
123
|
+
$(echo -e "$FAILED")
|
|
124
|
+
$MALFORMED_SECTION
|
|
125
|
+
|
|
126
|
+
Merge is blocked. Fix the PR (for example its body) and re-run the review phase.
|
|
127
|
+
|
|
128
|
+
<!-- FORGE:ACCEPTANCE_GATE_REVIEW:FAILED -->"
|
|
129
|
+
run gh_i issue edit "$NUMBER" --add-label "needs-human"
|
|
130
|
+
printf 'REVIEW_RESULT:\n status: BLOCKED\n pr_number:\n pr_url:\n merged_to:\n blocker: review acceptance gate failed (see FORGE:ACCEPTANCE_GATE_REVIEW comment)\n'
|
|
131
|
+
exit 1
|
|
132
|
+
fi
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# review-acceptance-gate.test.sh — trust filtering (forge#3813) and behavior (pass / fail / malformed-skip, forge#3814) of scripts/review-acceptance-gate.sh. No network (gh is stubbed, DRY_RUN=1).
|
|
3
|
+
# SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
4
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
5
|
+
set -uo pipefail
|
|
6
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
7
|
+
GATE="$SCRIPT_DIR/review-acceptance-gate.sh"
|
|
8
|
+
PASS=0; FAILN=0
|
|
9
|
+
ok() { PASS=$((PASS+1)); echo "ok - $1"; }
|
|
10
|
+
bad() { FAILN=$((FAILN+1)); echo "FAIL - $1"; }
|
|
11
|
+
eq() { if [ "$2" = "$3" ]; then ok "$1"; else bad "$1 (got '$2', want '$3')"; fi; }
|
|
12
|
+
|
|
13
|
+
TMP=$(mktemp -d); trap 'rm -rf "$TMP"' EXIT
|
|
14
|
+
mkdir -p "$TMP/bin" "$TMP/wt"
|
|
15
|
+
git -C "$TMP/wt" init -q . && git -C "$TMP/wt" -c user.email=t@t -c user.name=t commit -q --allow-empty -m init
|
|
16
|
+
HEAD_SHA=$(git -C "$TMP/wt" rev-parse HEAD)
|
|
17
|
+
cat > "$TMP/bin/gh" <<'STUB'
|
|
18
|
+
#!/usr/bin/env bash
|
|
19
|
+
[ "${GH_FAIL:-}" = 1 ] && exit 1
|
|
20
|
+
[ "$1" = api ] && { cat "$COMMENTS_FILE"; exit 0; }
|
|
21
|
+
echo "gh $*" >> "$GH_LOG"
|
|
22
|
+
STUB
|
|
23
|
+
chmod +x "$TMP/bin/gh"
|
|
24
|
+
export PATH="$TMP/bin:$PATH" GH_LOG="$TMP/gh.log" COMMENTS_FILE="$TMP/comments.json" FORGEDOCK_HOME="$(cd "$SCRIPT_DIR/.." && pwd)"
|
|
25
|
+
unset FORGE_TRAIL_TRUSTED_ASSOCIATIONS FORGE_TRAIL_TRUSTED_LOGINS
|
|
26
|
+
|
|
27
|
+
c() { jq -nc --arg a "$1" --arg t "$2" --arg b "$3" '{author_association:$a,user:{type:$t,login:"u"},body:$b}'; }
|
|
28
|
+
SENT="$TMP/sentinel"
|
|
29
|
+
INV_BAD="<!-- FORGE:INVESTIGATOR -->
|
|
30
|
+
ACCEPTANCE_CHECK: id=ac-1 type=command target=\"touch $SENT\" matcher=\"exit_0\" phase=review description=pwn"
|
|
31
|
+
INV_OK="<!-- FORGE:INVESTIGATOR -->
|
|
32
|
+
ACCEPTANCE_CHECK: id=ac-1 type=command target=\"touch $SENT\" matcher=\"exit_0\" phase=review description=ok"
|
|
33
|
+
PASSED="<!-- FORGE:ACCEPTANCE_GATE_REVIEW -->
|
|
34
|
+
<!-- FORGE:ACCEPTANCE_GATE_REVIEW:PASSED:${HEAD_SHA} -->"
|
|
35
|
+
|
|
36
|
+
run() { : > "$GH_LOG"; rm -f "$SENT"; DRY_RUN=1 bash "$GATE" 1 o/r "" "$TMP/wt" >"$TMP/out" 2>&1; echo $?; }
|
|
37
|
+
|
|
38
|
+
echo "[$(c NONE User "$INV_BAD")]" > "$COMMENTS_FILE"
|
|
39
|
+
rc=$(run); eq "untrusted spec ignored (rc 0, no-op)" "$rc" 0
|
|
40
|
+
[ ! -e "$SENT" ] && ok "untrusted spec line not executed" || bad "untrusted spec line executed"
|
|
41
|
+
|
|
42
|
+
echo "[$(c OWNER User "$INV_OK")]" > "$COMMENTS_FILE"
|
|
43
|
+
rc=$(run); eq "trusted spec runs" "$rc" 0
|
|
44
|
+
[ -e "$SENT" ] && ok "trusted check executed" || bad "trusted check not executed"
|
|
45
|
+
|
|
46
|
+
echo "[$(c NONE Bot "$INV_OK")]" > "$COMMENTS_FILE"
|
|
47
|
+
run >/dev/null; [ -e "$SENT" ] && ok "Bot author accepted" || bad "Bot author rejected"
|
|
48
|
+
|
|
49
|
+
echo "[$(c OWNER User "$INV_OK"),$(c NONE User "$PASSED")]" > "$COMMENTS_FILE"
|
|
50
|
+
run >/dev/null; [ -e "$SENT" ] && ok "untrusted PASSED marker does not skip" || bad "untrusted PASSED marker skipped the gate"
|
|
51
|
+
|
|
52
|
+
echo "[$(c OWNER User "$INV_OK"),$(c MEMBER User "$PASSED")]" > "$COMMENTS_FILE"
|
|
53
|
+
run >/dev/null; [ ! -e "$SENT" ] && grep -q "already PASSED" "$TMP/out" && ok "trusted PASSED marker skips" || bad "trusted PASSED marker ignored"
|
|
54
|
+
|
|
55
|
+
echo "[$(c OWNER User "$INV_OK")]" > "$COMMENTS_FILE"
|
|
56
|
+
GH_FAIL=1 run >/dev/null; rc=$(GH_FAIL=1 run)
|
|
57
|
+
eq "unreadable comments fail closed" "$rc" 1
|
|
58
|
+
[ ! -e "$SENT" ] && ok "nothing executed when unreadable" || bad "executed with unreadable comments"
|
|
59
|
+
|
|
60
|
+
rc=$(FORGEDOCK_HOME="$TMP/nowhere" run)
|
|
61
|
+
eq "unresolvable trust helper fails closed" "$rc" 1
|
|
62
|
+
[ ! -e "$SENT" ] && ok "nothing executed without helper" || bad "executed without helper"
|
|
63
|
+
|
|
64
|
+
# Behavioral cases (forge#3814). DRY_RUN=1 echoes gh writes as `DRY_RUN:` lines into $TMP/out.
|
|
65
|
+
printf 'x\n' > "$TMP/wt/f.txt"
|
|
66
|
+
inv() { local b="<!-- FORGE:INVESTIGATOR -->"; local l; for l in "$@"; do b="$b
|
|
67
|
+
$l"; done; echo "[$(c OWNER User "$b")]" > "$COMMENTS_FILE"; }
|
|
68
|
+
chk() { printf 'ACCEPTANCE_CHECK: id=%s type=command target="%s" matcher="exit_0" phase=review description=d' "$1" "$2"; }
|
|
69
|
+
nolabel() { ! grep -q 'needs-human' "$TMP/out"; }
|
|
70
|
+
|
|
71
|
+
inv "$(chk ac-1 true)"; rc=$(run); eq "pass: rc 0" "$rc" 0
|
|
72
|
+
grep -q ':PASSED:' "$TMP/out" && ok "pass: PASSED sentinel posted" || bad "pass: no PASSED sentinel"
|
|
73
|
+
|
|
74
|
+
inv "$(chk ac-1 false)"; rc=$(run); eq "real fail: rc 1" "$rc" 1
|
|
75
|
+
grep -q 'needs-human' "$TMP/out" && ok "real fail: needs-human added" || bad "real fail: no needs-human"
|
|
76
|
+
inv "$(chk ac-1 'grep -q nomatch f.txt')"; rc=$(run); eq "grep exit 1 is a real fail" "$rc" 1
|
|
77
|
+
inv "$(chk ac-1 'grep -q x nofile.txt')"; rc=$(run); eq "grep missing operand file stays a real fail" "$rc" 1
|
|
78
|
+
|
|
79
|
+
for t in "echo 'unbalanced" 'echo a\\\\\\\\b' "grep -E '[' f.txt"; do
|
|
80
|
+
inv "$(chk ac-7 "$t")"; rc=$(run); eq "malformed skipped, rc 0: $t" "$rc" 0
|
|
81
|
+
nolabel && ok "malformed: no needs-human" || bad "malformed: needs-human added: $t"
|
|
82
|
+
grep -qi 'malformed' "$TMP/out" && ok "malformed reported" || bad "malformed not reported: $t"
|
|
83
|
+
grep -q ':PASSED:' "$TMP/out" && bad "all-malformed wrote PASSED marker: $t" || ok "all-malformed: no PASSED marker"
|
|
84
|
+
done
|
|
85
|
+
|
|
86
|
+
inv "$(chk ac-1 true)" "$(chk ac-7 "echo 'unbalanced")"; rc=$(run); eq "mixed pass+malformed: rc 0" "$rc" 0
|
|
87
|
+
grep -q ':PASSED:' "$TMP/out" && grep -qi 'malformed' "$TMP/out" && ok "mixed: PASSED with malformed listed" || bad "mixed: wrong output"
|
|
88
|
+
inv "$(chk ac-1 false)" "$(chk ac-7 "echo 'unbalanced")"; rc=$(run); eq "mixed fail+malformed: fails closed" "$rc" 1
|
|
89
|
+
|
|
90
|
+
echo "$PASS passed, $FAILN failed"; [ "$FAILN" -eq 0 ]
|
|
@@ -92,15 +92,32 @@ fence_file() { # name pattern -> $TMP/<name>.sh (placeholders filled)
|
|
|
92
92
|
}
|
|
93
93
|
|
|
94
94
|
# ---- 0. structure ---------------------------------------------------------------------------------------------------
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
[ "$
|
|
95
|
+
# The mode functions live in ONE sourced helper (scripts/lib/review-delta-mode.sh); every consuming fence sources it through forge_resolve
|
|
96
|
+
# with a FULL-only fallback, so an unresolved helper can only mean a full review.
|
|
97
|
+
HELPER="$ROOT/scripts/lib/review-delta-mode.sh"
|
|
98
|
+
[ -f "$HELPER" ] && ok || bad "scripts/lib/review-delta-mode.sh must exist"
|
|
99
|
+
n=$(grep -c '^_dm=$(forge_resolve lib/review-delta-mode.sh)' "$SPEC")
|
|
100
|
+
[ "$n" -ge 6 ] && ok || bad "expected the review-delta helper source line in every consuming fence (3C mode, carry post, Phase 4, 5, 6A, 9), found $n"
|
|
101
|
+
distinct=$(grep '^_dm=$(forge_resolve lib/review-delta-mode.sh)' "$SPEC" | sort -u | grep -c . || true)
|
|
102
|
+
[ "$distinct" = 1 ] && ok || bad "review-delta helper source lines differ ($distinct distinct)"
|
|
103
|
+
[ "$(count_blocks review-pr-delta-mode)" = 0 ] && ok || bad "inline review-delta mode copies must not remain in the spec"
|
|
104
|
+
grep -q '^_dm=.*review_delta_mode() { echo "FULL - 0"; }' "$SPEC" && ok || bad "the helper source line must carry a FULL-only fallback"
|
|
105
|
+
# every source line is preceded by a locator in the same fence (a fresh shell has no forge_resolve)
|
|
106
|
+
miss=$(awk '/^```/{loc=0} /^type forge_resolve >\/dev\/null/{loc=1} /^_dm=\$\(forge_resolve lib\/review-delta-mode.sh\)/&&!loc{b++} END{print b+0}' "$SPEC")
|
|
107
|
+
[ "$miss" = 0 ] && ok || bad "$miss helper source line(s) lack a forge_resolve locator in the same fence"
|
|
108
|
+
# unresolved helper: the fallback answers FULL and carries nothing
|
|
109
|
+
fb=$(grep -m1 '^_dm=.*review_delta_mode() { echo "FULL - 0"; }' "$SPEC")
|
|
110
|
+
got=$(bash -c "forge_resolve() { :; }; $fb; review_delta_mode; printf 'carry=[%s]' \"\$(carry_verified_from)\"")
|
|
111
|
+
[ "$got" = "FULL - 0
|
|
112
|
+
carry=[]" ] && ok || bad "an unresolved helper must fall back to FULL (got '$got')"
|
|
113
|
+
# resolved helper: the real functions load
|
|
114
|
+
got=$(bash -c "forge_resolve() { echo '$HELPER'; }; $fb; type -t is_sha")
|
|
115
|
+
[ "$got" = "function" ] && ok || bad "a resolved helper must define the mode functions (got '$got')"
|
|
99
116
|
grep -qF 'scripts/review-delta.sh' "$SPEC" && ok || bad "spec does not name scripts/review-delta.sh"
|
|
100
117
|
|
|
101
118
|
# ---- 1. resolver block: trusted install only -------------------------------------------------------------------------
|
|
102
119
|
mkrepo; HEAD=$(sha HEAD)
|
|
103
|
-
|
|
120
|
+
fence_with 'REVIEW_DELTA_SCRIPT=$(forge_resolve review-delta.sh)' | grep -E '^type forge_resolve >/dev/null|^REVIEW_DELTA_SCRIPT=\$\(forge_resolve ' > "$TMP/rd_resolver.sh"
|
|
104
121
|
[ -s "$TMP/rd_resolver.sh" ] && ok || bad "no canonical REVIEW_DELTA_SCRIPT resolver block in the spec"
|
|
105
122
|
mkdir -p "$W/scripts"; printf '#!/usr/bin/env bash\necho "BASE_SYNC_ONLY %s"\necho FULL_ROUNDS=9\n' "$HEAD" > "$W/scripts/review-delta.sh"
|
|
106
123
|
got=$(cd "$W" && HOME="$TMP/home" FORGE_ROOT="$ROOT" CLAUDE_CONFIG_DIR="" bash -c "$(cat "$TMP/rd_resolver.sh"); printf %s \"\$REVIEW_DELTA_SCRIPT\"")
|
|
@@ -191,8 +208,8 @@ W=$BS_W; R=$BS_R; HEAD=$BS_HEAD
|
|
|
191
208
|
fence_file carry "CARRY_RECORD: POSTED" || exit 1
|
|
192
209
|
run_carry() { subst "$TMP/carry.raw" > "$TMP/carry.sh"; run_fence "$TMP/carry.sh" "$@"; }
|
|
193
210
|
verified() { # prints the verified Carried-From-SHA for the current $FAKE_COMMENTS using the real snippet
|
|
194
|
-
{
|
|
195
|
-
{ cat "$TMP/carry.raw" | awk '
|
|
211
|
+
{ cat "$HELPER"; echo 'COMMENTS_JSON=$(cat "$FAKE_COMMENTS"); carry_verified_from'; } > "$TMP/cv_body.sh"
|
|
212
|
+
{ cat "$TMP/carry.raw" | awk '/^_dm=\$\(forge_resolve lib\/review-delta-mode\.sh\)/ {exit} {print}'; cat "$TMP/cv_body.sh"; } | subst /dev/stdin > "$TMP/cv.sh"
|
|
196
213
|
run_fence "$TMP/cv.sh" FAKE_COMMENTS="$FAKE_COMMENTS"
|
|
197
214
|
}
|
|
198
215
|
panel_items "$R" | jq -s '.' > "$FAKE_COMMENTS"
|
|
@@ -96,9 +96,13 @@ for _s in "$SPEC" "$SPEC_MAIN"; do
|
|
|
96
96
|
if grep -qE 'BLOCKED.*classifier unavailable' "$_s" && ! grep -qF 'echo script || echo manual' "$_s" && ! grep -qF 'classifier=manual' "$_s" && ! grep -qF 'classifier-unavailable' "$_s"; then ok; else bad "$(basename "$_s") must fail closed with BLOCKED classifier unavailable and have no manual fallback"; fi
|
|
97
97
|
done
|
|
98
98
|
# (8) fresh-shell reload (forge#3654): the FENCE_BOOTSTRAP lines must reload PR_NUMBER / ALL_PR_NUMBERS from the scratchpad, stay empty (with a warning) when nothing was persisted, and reject a tampered number
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
[ "$(grep
|
|
99
|
+
# (forge#3778) the spec carries a byte-identical stub per fence; the body lives in scripts/lib/fence-bootstrap.sh, which is SOURCED
|
|
100
|
+
# in a fresh shell (the stub's locator + source line is executed for real, so the stub itself is covered too).
|
|
101
|
+
[ "$(grep '# FENCE_BOOTSTRAP$' "$SPEC" | sort -u | wc -l | tr -d ' ')" -eq 1 ] && ok || bad "FENCE_BOOTSTRAP stubs must be byte-identical"
|
|
102
|
+
[ "$(grep -c '# FENCE_BOOTSTRAP$' "$SPEC")" -ge 3 ] && ok || bad "bootstrap must be carried by 0B.1 and both 7B.5 fences"
|
|
103
|
+
grep '^type forge_resolve >/dev/null' "$SPEC" | sed 1q > "$TMP/boot.sh"
|
|
104
|
+
grep '# FENCE_BOOTSTRAP$' "$SPEC" | sed 1q >> "$TMP/boot.sh"
|
|
105
|
+
[ "$(wc -l < "$TMP/boot.sh")" -eq 2 ] && ok || bad "bootstrap stub (locator + source line) not found"
|
|
102
106
|
# forge#3681: EVERY bash fence after Phase 0B that reads cross-fence state must carry the bootstrap (a fresh Bash call loses it)
|
|
103
107
|
UNCOVERED=$(awk '
|
|
104
108
|
/^```bash/ { infence=1; body=""; start=NR; next }
|
|
@@ -123,7 +127,7 @@ mkdir -p "$TMP/sd9/empty" "$TMP/sd9/full" "$TMP/bin9"
|
|
|
123
127
|
printf '#!/bin/sh\necho stub-value\n' > "$TMP/bin9/yq"; printf '#!/bin/sh\nexit 0\n' > "$TMP/bin9/gh"; chmod +x "$TMP/bin9/yq" "$TMP/bin9/gh"
|
|
124
128
|
: > "$TMP/sd9/empty/42_launched_review_agents"
|
|
125
129
|
printf 'security\n' > "$TMP/sd9/full/42_launched_review_agents"
|
|
126
|
-
run675() { ( unset LAUNCHED_REVIEW_AGENTS REVIEW_SHA_FULL; export PATH="$TMP/bin9:$PATH" FORGE_SCRATCHPAD="$1" HOME="$TMP/nohome"; [ -n "$2" ] && export PR_NUMBER="$2"; bash "$TMP/f675.sh" 2>&1; echo "rc=$?" ); }
|
|
130
|
+
run675() { ( unset LAUNCHED_REVIEW_AGENTS REVIEW_SHA_FULL; export PATH="$TMP/bin9:$PATH" FORGE_SCRATCHPAD="$1" HOME="$TMP/nohome" FORGEDOCK_HOME="$ROOT"; [ -n "$2" ] && export PR_NUMBER="$2"; bash "$TMP/f675.sh" 2>&1; echo "rc=$?" ); }
|
|
127
131
|
for _case in "$TMP/sd9/empty|42" "$TMP/sd9/none|42" "$TMP/sd9/full|"; do
|
|
128
132
|
OUT9=$(run675 "${_case%%|*}" "${_case##*|}")
|
|
129
133
|
case "$OUT9" in *"REVIEW DELIVERY FAILURE: launched-agent list is empty or unreadable"*"rc=1"*) ok ;; *) bad "6.75 must exit 1 for case '$_case' (got '$OUT9')" ;; esac
|
|
@@ -147,7 +151,7 @@ printf '42\n' > "$TMP/sd8/full/staging_pr_number"; printf '7\n8\n' > "$TMP/sd8/f
|
|
|
147
151
|
printf '42; rm -rf /\n' > "$TMP/sd8/bad/staging_pr_number"
|
|
148
152
|
boot() { # scratch-dir -> prints "PR_NUMBER|ALL_PR_NUMBERS(joined)|GH_REPO" then any warning
|
|
149
153
|
( unset PR_NUMBER ALL_PR_NUMBERS GH_REPO DEFAULT_BRANCH STAGING_BRANCH; export PATH="$TMP/bin:$PATH" FORGE_SCRATCHPAD="$1"
|
|
150
|
-
. "$TMP/boot.sh" 2>&1; echo "PR=${PR_NUMBER:-}|ALL=$(echo ${ALL_PR_NUMBERS:-})|REPO=${GH_REPO:-}|BR=${STAGING_BRANCH:-}" ) 2>&1; }
|
|
154
|
+
export FORGEDOCK_HOME="$ROOT"; . "$TMP/boot.sh" 2>&1; echo "PR=${PR_NUMBER:-}|ALL=$(echo ${ALL_PR_NUMBERS:-})|REPO=${GH_REPO:-}|BR=${STAGING_BRANCH:-}" ) 2>&1; }
|
|
151
155
|
OUT8=$(boot "$TMP/sd8/full")
|
|
152
156
|
[ "$OUT8" = "PR=42|ALL=7 8|REPO=stub-value|BR=stub-value" ] && ok || bad "fresh shell must reload staging_pr_number and bundle-prs from the scratchpad (got '$OUT8')"
|
|
153
157
|
OUT8=$(boot "$TMP/sd8/empty")
|
|
@@ -155,7 +159,7 @@ case "$OUT8" in *"WARNING: PR_NUMBER is empty"*"PR=|ALL=|"*) ok ;; *) bad "empty
|
|
|
155
159
|
OUT8=$(boot "$TMP/sd8/bad")
|
|
156
160
|
case "$OUT8" in *"PR=|"*) ok ;; *) bad "non-numeric staging_pr_number must be rejected (got '$OUT8')" ;; esac
|
|
157
161
|
# an already-set PR_NUMBER wins over a stale scratch file
|
|
158
|
-
OUT8=$( ( unset ALL_PR_NUMBERS; PR_NUMBER=99; export PATH="$TMP/bin:$PATH" FORGE_SCRATCHPAD="$TMP/sd8/full"; . "$TMP/boot.sh" >/dev/null 2>&1; echo "PR=$PR_NUMBER|ALL=${ALL_PR_NUMBERS:-}" ) )
|
|
162
|
+
OUT8=$( ( unset ALL_PR_NUMBERS; PR_NUMBER=99; export PATH="$TMP/bin:$PATH" FORGE_SCRATCHPAD="$TMP/sd8/full" FORGEDOCK_HOME="$ROOT"; . "$TMP/boot.sh" >/dev/null 2>&1; echo "PR=$PR_NUMBER|ALL=${ALL_PR_NUMBERS:-}" ) )
|
|
159
163
|
[ "$OUT8" = "PR=99|ALL=" ] && ok || bad "in-shell PR_NUMBER must not be overridden by the scratch file (got '$OUT8')"
|
|
160
164
|
# producers persist, and 0B.1 / 7B.5 no longer use the unsubstituted-template repo token or a bare unquoted PR number in gh pr comment
|
|
161
165
|
grep -q 'staging_pr_number' "$SPEC" && grep -q '_bundle-prs.txt' "$SPEC" && ok || bad "Phase -1 / 0A must persist staging_pr_number and _bundle-prs.txt"
|
|
@@ -278,7 +282,10 @@ OUT9=$(credit9 "$H"); N9=$(printf '%s' "${OUT9#*|}" | wc -w)
|
|
|
278
282
|
grep -q 'review-delta.sh' "$SPEC" && grep -q 'REVIEW-CARRIED' "$SPEC" && ok || bad "spec must call review-delta.sh and accept the REVIEW-CARRIED record"
|
|
279
283
|
if grep -qE 'merge-base --is-ancestor.*REVIEWED_SHA_STALE|diff_shape\(\)' "$SPEC"; then bad "spec must not carry a second copy of the resolver logic"; else ok; fi
|
|
280
284
|
grep -qF 'printf '"'"'%s\n'"'"' "$_FILES" | sort -u | sed "s|^|$pr |" >> "$_PF"' "$SPEC" && grep -qF 'git diff-tree --no-commit-id --name-only -r --cc "$c"' "$SPEC" && ok || bad "CROSS_PR_FILES accumulation and --cc merge scoping must stay intact"
|
|
281
|
-
[ "$(grep -c '# FENCE_BOOTSTRAP' "$SPEC" | head -1)" -ge
|
|
285
|
+
[ "$(grep -c '# FENCE_BOOTSTRAP$' "$SPEC" | head -1)" -ge 3 ] && ok || bad "FENCE_BOOTSTRAP stubs must remain"
|
|
286
|
+
# an unresolvable helper fails the fence closed (exit 1), never an unsourced bootstrap
|
|
287
|
+
OUT8=$( ( unset FORGEDOCK_HOME; export HOME="$TMP/nohome"; cd "$TMP" && bash -c "$(cat "$TMP/boot.sh"); echo reached" ) 2>&1 )
|
|
288
|
+
case "$OUT8" in *BLOCKED*) case "$OUT8" in *reached*) bad "unresolved bootstrap helper must stop the fence" ;; *) ok ;; esac ;; *) bad "unresolved bootstrap helper must print BLOCKED (got '$OUT8')" ;; esac
|
|
282
289
|
# a stale-head PR must not credit its own merge commit (it contains the post-review commits): the DELTA branch only appends DELTA_COMMITS
|
|
283
290
|
if awk '/^bundle_delta_credit\(\)/,/^\}/' "$SPEC" | grep -q 'mergeCommit'; then bad "the DELTA path must not credit the PR merge commit"; else ok; fi
|
|
284
291
|
else echo "review-pr-staging-scope.test.sh: git missing, delta section skipped"; fi
|
|
@@ -32,7 +32,7 @@ chk commands/quality-gate.md 'VALIDATOR="${FORGE_ROOT:+$FORGE_ROOT/scripts/valid
|
|
|
32
32
|
chk commands/quality-gate.md 'CONFLICT_CHECKER="${FORGE_ROOT:+$FORGE_ROOT/scripts/check-native-conflicts.sh}"'
|
|
33
33
|
chk commands/quality-gate.md 'SPEC_BASH_CHECKER="${FORGE_ROOT:+$FORGE_ROOT/scripts/check-spec-bash.sh}"'
|
|
34
34
|
chk commands/quality-gate.md 'CLASSIFIER="${FORGE_ROOT:+$FORGE_ROOT/scripts/flaky-quarantine.sh}"'
|
|
35
|
-
chk commands/review-pr.md 'CLASSIFIER
|
|
35
|
+
chk commands/review-pr.md 'CLASSIFIER=$(forge_resolve flaky-quarantine.sh)'
|
|
36
36
|
# validate.md must not name the worktree scripts dir at all (ac-1)
|
|
37
37
|
grep -qF '{WORKTREE_PATH}/scripts' "$ROOT/commands/work-on/build/validate.md" && bad "validate.md references {WORKTREE_PATH}/scripts" || ok
|
|
38
38
|
|