forgedock 1.16.1 → 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.
@@ -8,16 +8,22 @@
8
8
  # forge_resolve <name> prints the absolute path of scripts/<name> from the first trusted root that has it,
9
9
  # or prints nothing (status 0). Callers fail closed on empty output.
10
10
  #
11
- # Trust boundary: roots come only from the engine (FORGEDOCK_HOME, authoritative when set), the plugin runtime
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
12
13
  # (CLAUDE_PLUGIN_ROOT), the legacy FORGE_ROOT/FORGE_HOME variables, the install.sh symlink, the Codex
13
14
  # $CODEX_HOME/forge-home file, the newest pinned plugin cache and the marketplace checkout under CLAUDE_CONFIG_DIR
14
15
  # then ~/.claude, and last the directory this file was sourced from. The consumer's working directory and the repo
15
16
  # under review are author-controlled and are never consulted, and every root must be an absolute path.
16
17
  #
17
18
  # Order (matches docs/CONFIG.md "Install root resolution"): FORGEDOCK_HOME (when set, nothing else is consulted) >
18
- # CLAUDE_PLUGIN_ROOT > FORGE_ROOT > FORGE_HOME > install symlink > Codex forge-home > newest cache > marketplace > self.
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.
19
24
  # The self root is last on purpose: the stub locator that sourced this file picks a cache directory by glob order
20
- # (lexical), so the helper re-resolves by the numeric-newest tier instead of trusting the copy that happened to load.
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.
21
27
 
22
28
  # Root this file was sourced from (scripts/lib/.. /..), captured now because the cwd changes later. Under zsh
23
29
  # BASH_SOURCE is empty, so the sourced path comes from ${(%):-%x} (eval'd: bash cannot parse that expansion). An empty
@@ -40,12 +46,15 @@ _forge_posix() {
40
46
  esac
41
47
  }
42
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
+
43
52
  # One absolute candidate root per line, highest priority first.
44
53
  _forge_candidates() {
45
54
  local _l _cfg _mk _x _cx
46
55
  if [ -n "${FORGEDOCK_HOME:-}" ]; then _forge_posix "$FORGEDOCK_HOME"; return 0; fi
47
56
  _mk="${FORGEDOCK_MARKETPLACE:-forgedock}"; case "$_mk" in ""|.|..|*[!A-Za-z0-9._-]*) _mk="forgedock" ;; esac
48
- printf '%s\n' "${CLAUDE_PLUGIN_ROOT:-}" "${FORGE_ROOT:-}" "${FORGE_HOME:-}"
57
+ printf '%s\n' "$_FORGE_RUNNING_ROOT" "${CLAUDE_PLUGIN_ROOT:-}" "${FORGE_ROOT:-}" "${FORGE_HOME:-}"
49
58
  for _cfg in "${CLAUDE_CONFIG_DIR:-}" "$HOME/.claude"; do
50
59
  [ -n "$_cfg" ] || continue
51
60
  _l="$(readlink -f "$_cfg/commands/work-on.md" 2>/dev/null || true)"
@@ -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 ]