forgedock 1.8.4 → 1.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/bin/engine/admission.mjs +23 -4
  2. package/bin/engine/admission.test.mjs +37 -0
  3. package/commands/orchestrate/phase-1-resolve.md +40 -24
  4. package/commands/orchestrate/phase-3-dependency.md +56 -11
  5. package/commands/orchestrate/phase-4-execution.md +359 -111
  6. package/commands/orchestrate/phase-5-cleanup.md +17 -0
  7. package/commands/orchestrate/phase-6-report.md +1 -1
  8. package/commands/quality-gate.md +80 -0
  9. package/commands/recover-orphans.md +3 -3
  10. package/commands/review-pr.md +98 -16
  11. package/commands/work-on/build/context.md +25 -4
  12. package/commands/work-on/build/validate.md +16 -9
  13. package/commands/work-on/build.md +26 -9
  14. package/commands/work-on/close.md +104 -40
  15. package/commands/work-on/decompose.md +13 -7
  16. package/commands/work-on/investigate.md +13 -7
  17. package/commands/work-on/remediate.md +15 -4
  18. package/commands/work-on/review.md +48 -19
  19. package/commands/work-on.md +62 -9
  20. package/docs/CODEX.md +12 -0
  21. package/docs/CONFIG.md +49 -1
  22. package/docs/RELEASE-NOTES.md +61 -0
  23. package/package.json +1 -1
  24. package/scripts/amplification-breaker.sh +89 -0
  25. package/scripts/amplification-breaker.test.sh +39 -0
  26. package/scripts/check-admission-parity.mjs +74 -1
  27. package/scripts/check-spec-bash.sh +120 -0
  28. package/scripts/check-spec-bash.test.sh +90 -0
  29. package/scripts/check-spec-markers.sh +67 -3
  30. package/scripts/classify-finding.sh +123 -0
  31. package/scripts/classify-finding.test.sh +77 -0
  32. package/scripts/close-dossier-no-main-commit.test.sh +139 -0
  33. package/scripts/forge-root.test.sh +84 -27
  34. package/scripts/lint-build-validate-files.sh +29 -0
  35. package/scripts/lint-build-validate-files.test.sh +25 -0
  36. package/scripts/lint-dispatch-prompt.test.sh +2 -1
  37. package/scripts/recovery-claim-live.sh +73 -0
  38. package/scripts/recovery-claim-live.test.sh +66 -0
  39. package/scripts/verify-phase-trail.sh +183 -4
  40. package/scripts/verify-phase-trail.test.sh +211 -1
@@ -0,0 +1,139 @@
1
+ #!/usr/bin/env bash
2
+ # close-dossier-no-main-commit.test.sh — close.md Phase C1.7 must never write or commit in the main checkout.
3
+ #
4
+ # Static asserts on the spec plus execution of the real extracted C1.7 block
5
+ # against a temp repo with a bare origin and a stubbed gh.
6
+ #
7
+ # Usage: bash scripts/close-dossier-no-main-commit.test.sh
8
+ # Exit code: 0 if all assertions pass, 1 if any fail.
9
+ #
10
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
11
+ # SPDX-License-Identifier: AGPL-3.0-or-later
12
+
13
+ set -uo pipefail
14
+
15
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
16
+ CLOSE_MD="$SCRIPT_DIR/../commands/work-on/close.md"
17
+
18
+ TMP=$(mktemp -d)
19
+ trap 'rm -rf "$TMP"' EXIT
20
+ PASS=0; FAIL=0
21
+ ok() { PASS=$((PASS+1)); echo "PASS: $1"; }
22
+ bad() { FAIL=$((FAIL+1)); echo "FAIL: $1"; }
23
+
24
+ # --- Static asserts on the spec ---
25
+ if grep -nE 'git -C "\$\{REPO_PATH\}" (commit|add)' "$CLOSE_MD" >/dev/null; then
26
+ bad "close.md still runs git add/commit against REPO_PATH"
27
+ else ok "close.md has no git add/commit against REPO_PATH"; fi
28
+
29
+ C17=$(sed -n '/^## Phase C1.7/,/^## Phase C1.5/p' "$CLOSE_MD")
30
+ if printf '%s\n' "$C17" | grep -nE '^\s*cd "\$\{REPO_PATH\}"' >/dev/null; then
31
+ bad "C1.7 changes directory into REPO_PATH"
32
+ else ok "C1.7 does not cd into REPO_PATH"; fi
33
+ printf '%s\n' "$C17" | grep -q 'Phase C1.7: skipped' && ok "C1.7 logs skip reasons" || bad "C1.7 missing 'Phase C1.7: skipped'"
34
+ printf '%s\n' "$C17" | grep -q 'worktree add --detach' && ok "C1.7 uses a temporary detached worktree" || bad "C1.7 missing temp worktree"
35
+ printf '%s\n' "$C17" | grep -q 'gh pr create' && ok "C1.7 opens a PR" || bad "C1.7 missing gh pr create"
36
+ [ "$(printf '%s\n' "$C17" | grep -c '^```bash')" -eq 1 ] && ok "C1.7 is a single bash block (no cross-block shell state)" || bad "C1.7 must be exactly one bash block"
37
+ printf '%s\n' "$C17" | grep -qE "^\s*trap .*EXIT" && ok "C1.7 registers an EXIT trap" || bad "C1.7 missing EXIT trap"
38
+ printf '%s\n' "$C17" | grep -q 'worktree remove --force' && ok "C1.7 cleanup removes the worktree" || bad "C1.7 cleanup missing worktree remove"
39
+ ADD_LN=$(printf '%s\n' "$C17" | grep -n 'worktree add --detach' | head -1 | cut -d: -f1)
40
+ TRAP_LN=$(printf '%s\n' "$C17" | grep -nE "^\s*trap .*EXIT" | head -1 | cut -d: -f1)
41
+ [ -n "$ADD_LN" ] && [ -n "$TRAP_LN" ] && [ "$TRAP_LN" -gt "$ADD_LN" ] && ok "EXIT trap registered after worktree add" || bad "EXIT trap must follow worktree add"
42
+ printf '%s\n' "$C17" | grep -q 'force-with-lease' && ok "C1.7 pushes with --force-with-lease" || bad "C1.7 missing force-with-lease"
43
+ printf '%s\n' "$C17" | grep -qE 'push .*--force( |$)' && bad "C1.7 uses bare --force" || ok "C1.7 has no bare --force"
44
+ printf '%s\n' "$C17" | grep -q 'gh pr list' && ok "C1.7 checks for an existing open PR" || bad "C1.7 missing gh pr list"
45
+ grep -q 'FORGE:DOSSIER_UPDATED' "$CLOSE_MD" && ok "FORGE:DOSSIER_UPDATED marker kept" || bad "FORGE:DOSSIER_UPDATED marker missing"
46
+
47
+ # --- Dynamic: execute the REAL C1.7 block extracted from close.md (gh stubbed) ---
48
+ BLOCK=$(printf '%s\n' "$C17" | awk '/^```bash/{f=1;next} /^```/{f=0} f')
49
+ if [ -z "$BLOCK" ]; then
50
+ bad "could not extract the C1.7 bash block"
51
+ elif ! command -v perl >/dev/null 2>&1 || ! yq --version 2>&1 | grep -q 'mikefarah'; then
52
+ bad "dynamic section needs perl and mikefarah yq v4 (not silently skipped)"
53
+ else
54
+ printf '%s\n' "$BLOCK" | grep -q '^_dossier_run()' && printf '%s\n' "$BLOCK" | grep -q '^_dossier_cleanup()' \
55
+ && ok "extracted block defines _dossier_run and _dossier_cleanup" || bad "extracted block missing _dossier_run/_dossier_cleanup"
56
+
57
+ # Substitute only the exact {NAME} placeholders; leave ${NAME} shell expansions intact.
58
+ printf '%s\n' "$BLOCK" | perl -pe 's/(?<!\$)\{GH_REPO\}/o\/r/g; s/(?<!\$)\{NUMBER\}/1/g; s/(?<!\$)\{PR_NUMBER\}/2/g; s/(?<!\$)\{GH_FLAG\}/-R o\/r/g' > "$TMP/block.sh"
59
+ bash -n "$TMP/block.sh" && ok "substituted C1.7 block parses (bash -n)" || bad "substituted C1.7 block has a syntax error"
60
+
61
+ export GIT_AUTHOR_NAME=t GIT_AUTHOR_EMAIL=t@example.com GIT_COMMITTER_NAME=t GIT_COMMITTER_EMAIL=t@example.com
62
+ git init -q --bare "$TMP/origin.git"
63
+ git init -q -b staging "$TMP/main"
64
+ mkdir -p "$TMP/main/devdocs/modules"
65
+ echo seed > "$TMP/main/devdocs/modules/m.md"
66
+ printf 'modules:\n - name: m\n glob: "*.sh"\n path: modules/m.md\n' > "$TMP/main/devdocs/index.yaml"
67
+ git -C "$TMP/main" add -A && git -C "$TMP/main" commit -q -m seed
68
+ git -C "$TMP/main" remote add origin "$TMP/origin.git"
69
+ git -C "$TMP/main" push -q origin staging 2>/dev/null
70
+
71
+ # gh stub: never touches the network; every call is logged.
72
+ mkdir -p "$TMP/bin" "$TMP/tmpd" "$TMP/cwd"
73
+ cat > "$TMP/bin/gh" <<'GHEOF'
74
+ #!/usr/bin/env bash
75
+ echo "gh $*" >> "$GH_LOG"
76
+ case "$1 $2" in
77
+ "api repos/o/r/issues/1/comments")
78
+ printf '<!-- FORGE:BUILDER -->\n### Changes\n- `scripts/foo.sh` changed\n### Next\n' ;;
79
+ "pr view") echo "Test PR title" ;;
80
+ "pr list")
81
+ echo "pr-list worktrees: $(git -C "$REPO_PATH" worktree list | wc -l)" >> "$GH_LOG"
82
+ [ "${GH_KILL_ON_PR_LIST:-}" = 1 ] && kill -TERM "$(cat "$PIDF")" && sleep 1
83
+ echo "${GH_PR_LIST_URL:-}" ;;
84
+ "pr create") [ "${GH_FAIL_PR_CREATE:-}" = 1 ] && exit 1; echo "https://example.invalid/o/r/pull/9" ;;
85
+ esac
86
+ exit 0
87
+ GHEOF
88
+ chmod +x "$TMP/bin/gh"
89
+ export GH_LOG="$TMP/gh.log" PIDF="$TMP/pid" REPO_PATH="$TMP/main" PR_BASE=staging NUMBER=1
90
+ [ "$(PATH="$TMP/bin:$PATH" command -v gh)" = "$TMP/bin/gh" ] && ok "gh resolves to the stub" || bad "gh stub not first on PATH"
91
+
92
+ HEAD_BEFORE=$(git -C "$TMP/main" rev-parse HEAD)
93
+ BRANCH_BEFORE=$(git -C "$TMP/main" branch --show-current)
94
+
95
+ # run_block: executes the real block in a fresh bash, cwd without forge.yaml.
96
+ run_block() { : > "$GH_LOG"; ( cd "$TMP/cwd" && env PATH="$TMP/bin:$PATH" TMPDIR="$TMP/tmpd" "$@" bash -c 'echo $$ > "$PIDF"; source "$1"' _ "$TMP/block.sh" ) > "$TMP/out.log" 2>&1; }
97
+ main_untouched() {
98
+ [ "$(git -C "$TMP/main" rev-parse HEAD)" = "$HEAD_BEFORE" ] && [ "$(git -C "$TMP/main" branch --show-current)" = "$BRANCH_BEFORE" ] \
99
+ && [ -z "$(git -C "$TMP/main" status --porcelain)" ]
100
+ }
101
+ no_leak() {
102
+ [ -z "$(ls -A "$TMP/tmpd" 2>/dev/null)" ] && [ "$(git -C "$TMP/main" worktree list | wc -l)" -eq 1 ]
103
+ }
104
+
105
+ # Scenario A: happy path
106
+ run_block; RC=$?
107
+ [ "$RC" -eq 0 ] && ok "A: real block exits 0" || bad "A: real block exit $RC"
108
+ main_untouched && ok "A: main checkout untouched (HEAD, branch, clean tree)" || bad "A: main checkout modified"
109
+ git -C "$TMP/origin.git" rev-parse -q --verify refs/heads/docs/dossier-1 >/dev/null && ok "A: dossier branch pushed to origin" || bad "A: dossier branch not pushed"
110
+ git -C "$TMP/origin.git" show refs/heads/docs/dossier-1:devdocs/modules/m.md 2>/dev/null | grep -q '^## Entry' && ok "A: dossier entry appended on pushed branch" || bad "A: no entry on pushed branch"
111
+ grep -q '^gh pr create' "$GH_LOG" && ok "A: gh pr create called" || bad "A: gh pr create not called"
112
+ grep -q 'FORGE:DOSSIER_UPDATED' "$GH_LOG" && ok "A: FORGE:DOSSIER_UPDATED comment posted" || bad "A: no FORGE:DOSSIER_UPDATED comment"
113
+ no_leak && ok "A: temporary worktree removed" || bad "A: temporary worktree leaked"
114
+
115
+ # Scenario B: retry over the existing remote branch (real fetch + --force-with-lease)
116
+ OLD=$(git -C "$TMP/origin.git" rev-parse refs/heads/docs/dossier-1)
117
+ sleep 1 # commit SHAs embed second-resolution timestamps; ensure the retry differs
118
+ run_block; RC=$?
119
+ NEW=$(git -C "$TMP/origin.git" rev-parse refs/heads/docs/dossier-1)
120
+ [ "$RC" -eq 0 ] && [ "$OLD" != "$NEW" ] && ok "B: retry push over existing remote branch succeeds" || bad "B: retry push failed (rc=$RC)"
121
+ main_untouched && no_leak && ok "B: main untouched and worktree removed" || bad "B: main modified or worktree leaked"
122
+
123
+ # Scenario C: fault injection - SIGTERM after worktree add, before the explicit cleanup.
124
+ # Only the block's real EXIT trap can remove the worktree.
125
+ run_block GH_KILL_ON_PR_LIST=1 2>/dev/null
126
+ grep -q '^pr-list worktrees: 2' "$GH_LOG" && ok "C: worktree existed when the fault fired" || bad "C: fault did not fire after worktree add (vacuous)"
127
+ grep -q '^gh pr create' "$GH_LOG" && bad "C: block continued past the injected fault" || ok "C: block aborted before pr create"
128
+ no_leak && ok "C: real EXIT trap cleaned the worktree" || bad "C: worktree leaked after abort (EXIT trap regression)"
129
+ main_untouched && ok "C: main checkout untouched" || bad "C: main checkout modified"
130
+
131
+ # Scenario D: gh pr create fails - block still exits 0, logs a skip, cleans up
132
+ run_block GH_FAIL_PR_CREATE=1; RC=$?
133
+ [ "$RC" -eq 0 ] && grep -q 'skipped - gh pr create failed' "$TMP/out.log" && ok "D: pr create failure logged as skip, exit 0" || bad "D: pr create failure not handled"
134
+ grep -q 'issue comment' "$GH_LOG" && bad "D: annotation posted despite no PR" || ok "D: no annotation without a PR"
135
+ no_leak && ok "D: worktree removed" || bad "D: worktree leaked"
136
+ fi
137
+
138
+ echo "Passed: $PASS Failed: $FAIL"
139
+ [ "$FAIL" -eq 0 ]
@@ -8,7 +8,7 @@
8
8
  set -uo pipefail
9
9
  HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"; ROOT="$HERE/.."
10
10
  T=$(mktemp -d); trap 'rm -rf "$T"' EXIT
11
- PASS=0; FAILN=0
11
+ PASS=0; FAILN=0; SKIPPED=0
12
12
  ok() { PASS=$((PASS+1)); }
13
13
  bad() { FAILN=$((FAILN+1)); echo "FAIL: $1"; }
14
14
  expect() { [ "$2" = "$3" ] && ok || bad "$1 (got '$3' want '$2')"; }
@@ -40,6 +40,8 @@ grep -v '^[[:space:]]*#' "$T/canon" > "$T/canon.code" # code only: the comment
40
40
  grep -qE 'mapfile|readarray|declare -A|sort[^|]* -[a-zA-Z]*V' "$T/canon.code" && bad "canonical bootstrap uses a bash-4+/non-portable construct" || ok
41
41
  grep -q 'sort -k1,1nr -k2,2nr -k3,3nr' "$T/canon.code" && ok || bad "canonical bootstrap lacks numeric component version sort"
42
42
  grep -qF 'marketplaces/*; do' "$T/canon" && bad "canonical bootstrap globs all marketplaces" || ok
43
+ grep -qF -- "-iname '*forgedock*'" "$T/canon.code" && bad "canonical bootstrap wildcard-matches marketplaces" || ok
44
+ grep -qF '[A-Za-z]:' "$T/canon.code" && ok || bad "canonical bootstrap lacks drive-letter path handling"
43
45
  # review.md must hard-exit on an unreadable trail
44
46
  grep -qE 'TRAIL_RC.* -ge 2 .*exit 1' "$ROOT/commands/work-on/review.md" && ok || bad "work-on/review.md lacks hard exit guard for TRAIL_RC>=2"
45
47
  # behavioral: execute the guard line itself. rc>=2 => BLOCKED on STDOUT + exit 1; rc 0/1 => falls through.
@@ -88,9 +90,9 @@ mkscripts() {
88
90
  : > "$1/bin/engine/resolve.mjs"; : > "$1/bin/engine/orchestrate-canary.mjs"; : > "$1/bin/engine/admission.mjs"
89
91
  }
90
92
  # plugin install: no env vars at all
91
- mkscripts "$T/h1/.claude/plugins/cache/mk/forgedock/1.0.0"
92
- expect "plugin cache resolves with no env" "$T/h1/.claude/plugins/cache/mk/forgedock/1.0.0" "$(run "$T/h1")"
93
- expect "CLAUDE_PLUGIN_ROOT resolves" "$T/h1/.claude/plugins/cache/mk/forgedock/1.0.0" "$(run "$T/empty" CLAUDE_PLUGIN_ROOT="$T/h1/.claude/plugins/cache/mk/forgedock/1.0.0")"
93
+ mkscripts "$T/h1/.claude/plugins/cache/forgedock/forgedock/1.0.0"
94
+ expect "plugin cache resolves with no env" "$T/h1/.claude/plugins/cache/forgedock/forgedock/1.0.0" "$(run "$T/h1")"
95
+ expect "CLAUDE_PLUGIN_ROOT resolves" "$T/h1/.claude/plugins/cache/forgedock/forgedock/1.0.0" "$(run "$T/empty" CLAUDE_PLUGIN_ROOT="$T/h1/.claude/plugins/cache/forgedock/forgedock/1.0.0")"
94
96
  # nothing installed: empty (never the consumer repo, never a relative path)
95
97
  expect "unresolvable stays empty" "" "$(run "$T/empty")"
96
98
  expect "relative FORGE_HOME ignored" "" "$(run "$T/empty" FORGE_HOME=.)"
@@ -98,31 +100,38 @@ expect "relative FORGE_HOME ignored" "" "$(run "$T/empty" FORGE_HOME=.)"
98
100
  expect "FORGEDOCK_HOME authoritative" "$T/nowhere" "$(run "$T/h1" FORGEDOCK_HOME="$T/nowhere")"
99
101
  # symlink install (install.sh): ~/.claude/commands/work-on.md -> <clone>/commands/work-on.md
100
102
  mkdir -p "$T/clone/commands" "$T/h2/.claude/commands"; mkscripts "$T/clone"; : > "$T/clone/commands/work-on.md"
101
- ln -sf "$T/clone/commands/work-on.md" "$T/h2/.claude/commands/work-on.md"
102
- expect "install.sh symlink resolves" "$(cd "$T/clone" && pwd -P)" "$(cd "$(run "$T/h2")" && pwd -P)"
103
- expect "FORGE_HOME without scripts falls through" "$(cd "$T/clone" && pwd -P)" "$(cd "$(run "$T/h2" FORGE_HOME="$T/h2/.claude")" && pwd -P)"
103
+ # Git Bash (MSYS) makes a COPY for `ln -s` unless native symlinks are requested, which leaves readlink
104
+ # nothing to resolve; request them, and run the two symlink cases only when a real symlink exists (a runner
105
+ # without symlink privilege cannot exercise install.sh's layout, which is itself a symlink install).
106
+ MSYS=winsymlinks:nativestrict ln -sf "$T/clone/commands/work-on.md" "$T/h2/.claude/commands/work-on.md" 2>/dev/null
107
+ if [ -L "$T/h2/.claude/commands/work-on.md" ]; then
108
+ expect "install.sh symlink resolves" "$(cd "$T/clone" && pwd -P)" "$(cd "$(run "$T/h2")" && pwd -P)"
109
+ expect "FORGE_HOME without scripts falls through" "$(cd "$T/clone" && pwd -P)" "$(cd "$(run "$T/h2" FORGE_HOME="$T/h2/.claude")" && pwd -P)"
110
+ else
111
+ echo "SKIP: symlink cases (this platform cannot create symlinks)"; SKIPPED=$((SKIPPED+1))
112
+ fi
104
113
 
105
114
  # newest cached version wins (1.10.0 must beat 1.9.0)
106
- for v in 1.9.0 1.10.0 1.2.0; do mkscripts "$T/h3/.claude/plugins/cache/mk/forgedock/$v"; done
107
- expect "newest cached version wins" "$T/h3/.claude/plugins/cache/mk/forgedock/1.10.0" "$(run "$T/h3")"
115
+ for v in 1.9.0 1.10.0 1.2.0; do mkscripts "$T/h3/.claude/plugins/cache/forgedock/forgedock/$v"; done
116
+ expect "newest cached version wins" "$T/h3/.claude/plugins/cache/forgedock/forgedock/1.10.0" "$(run "$T/h3")"
108
117
  # HOME containing a space still resolves
109
- mkscripts "$T/sp ace/.claude/plugins/cache/mk/forgedock/2.0.0"
110
- expect "space in HOME resolves" "$T/sp ace/.claude/plugins/cache/mk/forgedock/2.0.0" "$(run "$T/sp ace")"
118
+ mkscripts "$T/sp ace/.claude/plugins/cache/forgedock/forgedock/2.0.0"
119
+ expect "space in HOME resolves" "$T/sp ace/.claude/plugins/cache/forgedock/forgedock/2.0.0" "$(run "$T/sp ace")"
111
120
  # relative FORGEDOCK_HOME / CLAUDE_PLUGIN_ROOT rejected (fail closed => empty)
112
121
  expect "relative FORGEDOCK_HOME rejected" "" "$(run "$T/h1" FORGEDOCK_HOME=scripts/..)"
113
122
  expect "relative CLAUDE_PLUGIN_ROOT ignored" "" "$(run "$T/empty" CLAUDE_PLUGIN_ROOT=../consumer)"
114
123
  # a candidate with only one of the two needed scripts is rejected
115
- mkdir -p "$T/h4/.claude/plugins/cache/mk/forgedock/1.0.0/scripts"; : > "$T/h4/.claude/plugins/cache/mk/forgedock/1.0.0/scripts/verify-phase-trail.sh"
124
+ mkdir -p "$T/h4/.claude/plugins/cache/forgedock/forgedock/1.0.0/scripts"; : > "$T/h4/.claude/plugins/cache/forgedock/forgedock/1.0.0/scripts/verify-phase-trail.sh"
116
125
  expect "partial install (missing lint script) rejected" "" "$(run "$T/h4")"
117
126
  # stale install: has the two original gate scripts but lacks is-docs-only.sh / engine modules => rejected
118
127
  for missing in scripts/is-docs-only.sh bin/engine/resolve.mjs bin/engine/orchestrate-canary.mjs bin/engine/admission.mjs; do
119
- rm -rf "$T/h12"; mkscripts "$T/h12/.claude/plugins/cache/mk/forgedock/1.0.0"; rm -f "$T/h12/.claude/plugins/cache/mk/forgedock/1.0.0/$missing"
128
+ rm -rf "$T/h12"; mkscripts "$T/h12/.claude/plugins/cache/forgedock/forgedock/1.0.0"; rm -f "$T/h12/.claude/plugins/cache/forgedock/forgedock/1.0.0/$missing"
120
129
  expect "stale install missing $missing rejected" "" "$(run "$T/h12")"
121
130
  done
122
131
  # a stale newest version is skipped in favor of a complete older one
123
- rm -rf "$T/h13"; mkscripts "$T/h13/.claude/plugins/cache/mk/forgedock/1.0.0"; mkscripts "$T/h13/.claude/plugins/cache/mk/forgedock/2.0.0"
124
- rm -f "$T/h13/.claude/plugins/cache/mk/forgedock/2.0.0/scripts/is-docs-only.sh"
125
- expect "stale newest skipped for complete older" "$T/h13/.claude/plugins/cache/mk/forgedock/1.0.0" "$(run "$T/h13")"
132
+ rm -rf "$T/h13"; mkscripts "$T/h13/.claude/plugins/cache/forgedock/forgedock/1.0.0"; mkscripts "$T/h13/.claude/plugins/cache/forgedock/forgedock/2.0.0"
133
+ rm -f "$T/h13/.claude/plugins/cache/forgedock/forgedock/2.0.0/scripts/is-docs-only.sh"
134
+ expect "stale newest skipped for complete older" "$T/h13/.claude/plugins/cache/forgedock/forgedock/1.0.0" "$(run "$T/h13")"
126
135
  # non-ForgeDock marketplace dirs are ignored; ForgeDock-named ones resolve
127
136
  mkscripts "$T/h5/.claude/plugins/marketplaces/other-tool"
128
137
  expect "non-ForgeDock marketplace ignored" "" "$(run "$T/h5")"
@@ -131,21 +140,67 @@ expect "forgedock marketplace resolves" "$T/h6/.claude/plugins/marketplaces/forg
131
140
 
132
141
  # release outranks its own pre-release; a newer pre-release core still beats an older release
133
142
  rm -rf "$T/h7" # fixture is mutated below; reset per shell
134
- mkscripts "$T/h7/.claude/plugins/cache/mk/forgedock/1.9.0-rc1"; mkscripts "$T/h7/.claude/plugins/cache/mk/forgedock/1.9.0"
135
- expect "release beats its pre-release" "$T/h7/.claude/plugins/cache/mk/forgedock/1.9.0" "$(run "$T/h7")"
136
- mkscripts "$T/h7/.claude/plugins/cache/mk/forgedock/2.0.0-rc1"
137
- expect "newer-core pre-release beats older release" "$T/h7/.claude/plugins/cache/mk/forgedock/2.0.0-rc1" "$(run "$T/h7")"
138
- # version component decides, not the marketplace name (zz sorts after aa lexically but holds the older version)
139
- mkscripts "$T/h8/.claude/plugins/cache/zz-market/forgedock/1.0.0"; mkscripts "$T/h8/.claude/plugins/cache/aa-market/forgedock/1.1.0"
140
- expect "version beats marketplace name" "$T/h8/.claude/plugins/cache/aa-market/forgedock/1.1.0" "$(run "$T/h8")"
143
+ mkscripts "$T/h7/.claude/plugins/cache/forgedock/forgedock/1.9.0-rc1"; mkscripts "$T/h7/.claude/plugins/cache/forgedock/forgedock/1.9.0"
144
+ expect "release beats its pre-release" "$T/h7/.claude/plugins/cache/forgedock/forgedock/1.9.0" "$(run "$T/h7")"
145
+ mkscripts "$T/h7/.claude/plugins/cache/forgedock/forgedock/2.0.0-rc1"
146
+ expect "newer-core pre-release beats older release" "$T/h7/.claude/plugins/cache/forgedock/forgedock/2.0.0-rc1" "$(run "$T/h7")"
147
+ # marketplace pinning: a higher-versioned forgedock plugin from a non-official marketplace is never used
148
+ mkscripts "$T/h8/.claude/plugins/cache/evil-market/forgedock/99.0.0"; mkscripts "$T/h8/.claude/plugins/cache/forgedock/forgedock/1.1.0"
149
+ expect "hostile marketplace at 99.0.0 ignored" "$T/h8/.claude/plugins/cache/forgedock/forgedock/1.1.0" "$(run "$T/h8")"
150
+ rm -rf "$T/h8b"; mkscripts "$T/h8b/.claude/plugins/cache/evil-market/forgedock/99.0.0"
151
+ expect "only a hostile marketplace => empty" "" "$(run "$T/h8b")"
152
+ expect "FORGEDOCK_MARKETPLACE pins another marketplace" "$T/h8b/.claude/plugins/cache/evil-market/forgedock/99.0.0" "$(run "$T/h8b" FORGEDOCK_MARKETPLACE=evil-market)"
153
+ expect "invalid FORGEDOCK_MARKETPLACE falls back to forgedock" "" "$(run "$T/h8b" FORGEDOCK_MARKETPLACE=..)"
154
+ rm -rf "$T/h8c"; mkscripts "$T/h8c/.claude/plugins/marketplaces/evil-forgedock-tools"
155
+ expect "wildcard-named marketplace dir ignored" "" "$(run "$T/h8c")"
156
+ # non-semver cache dir names (commit SHAs, 1e5x) are skipped, never ranked by numeric coercion
157
+ rm -rf "$T/h14"; P14="$T/h14/.claude/plugins/cache/forgedock/forgedock"
158
+ mkscripts "$P14/1.10.0"; mkscripts "$P14/1e5abcdef"; mkscripts "$P14/abc1234def"; mkscripts "$P14/6f3a9c0"
159
+ expect "SHA-named cache dirs ignored" "$P14/1.10.0" "$(run "$T/h14")"
160
+ rm -rf "$T/h14b"; mkscripts "$T/h14b/.claude/plugins/cache/forgedock/forgedock/abc1234def"
161
+ expect "only SHA-named dir => empty" "" "$(run "$T/h14b")"
162
+ mkscripts "$P14/1.10.0-rc1"; expect "pre-release dir name still accepted (release wins)" "$P14/1.10.0" "$(run "$T/h14")"
163
+ # Windows drive-letter FORGEDOCK_HOME is normalized (/c/...), relative stays rejected
164
+ rm -rf "$T/win"; mkscripts "$T/win/c/forge"
165
+ expect "C:/ FORGEDOCK_HOME authoritative (no cygpath, bogus path)" "/c/nowhere/forge" "$(run "$T/h1" FORGEDOCK_HOME='C:/nowhere/forge')"
166
+ expect "C:\\ FORGEDOCK_HOME normalized" "/d/x/y" "$(run "$T/h1" FORGEDOCK_HOME='D:\x\y')"
167
+ # Codex: forge-home pointer file written by install-codex.sh
168
+ mkscripts "$T/hx/clone"; mkdir -p "$T/hx/.codex"; printf '%s\n' "$T/hx/clone" > "$T/hx/.codex/forge-home"
169
+ expect "codex forge-home pointer resolves" "$T/hx/clone" "$(run "$T/hx")"
170
+ expect "CODEX_HOME override resolves" "$T/hx/clone" "$(run "$T/empty" CODEX_HOME="$T/hx/.codex")"
171
+ # A relative CODEX_HOME must not resolve the pointer against the consumer cwd
172
+ mkdir -p "$T/consumer/.codex"; printf '%s\n' "$T/hx/clone" > "$T/consumer/.codex/forge-home"
173
+ expect "relative CODEX_HOME ignored (cwd-local pointer not read)" "" "$(run "$T/empty" CODEX_HOME=.codex)"
174
+ rm -rf "$T/consumer/.codex"
175
+ printf 'relative/path\n' > "$T/hx/.codex/forge-home"
176
+ expect "relative forge-home pointer rejected" "" "$(run "$T/hx")"
177
+ # install-codex.sh: FORGE_HOME is shell-escaped in env files, and absent env files are not created (forge#3241)
178
+ IC="$T/ic/we ird\$x's"; mkdir -p "$IC/commands" "$T/ic-home"
179
+ cp "$ROOT/install-codex.sh" "$IC/install-codex.sh"
180
+ : > "$T/ic-home/.zshenv"
181
+ ( cd "$IC" && env -i PATH="$PATH" HOME="$T/ic-home" CODEX_HOME="$T/ic-home/.codex" bash ./install-codex.sh >/dev/null 2>&1 )
182
+ [ ! -e "$T/ic-home/.profile" ] && ok || bad "install-codex.sh created a missing ~/.profile"
183
+ # The installer records its own resolved dir (macOS /var -> /private/var, Git Bash /tmp -> /c/Users/...), so compare against that, not $IC.
184
+ IC_REAL=$( cd "$IC" && env -i PATH="$PATH" bash -c 'cd "$(dirname ./install-codex.sh)" && pwd' )
185
+ got=$(env -i PATH="$PATH" HOME="$T/ic-home" bash -c '. "$HOME/.zshenv"; printf %s "$FORGE_HOME"' 2>&1)
186
+ expect "env-file FORGE_HOME round-trips space, \$ and single quote" "$IC_REAL" "$got"
187
+ got=$(env -i PATH="$PATH" HOME="$T/ic-home" sh -c '. "$HOME/.zshenv"; printf %s "$FORGE_HOME"' 2>&1)
188
+ expect "env-file FORGE_HOME round-trips under POSIX sh" "$IC_REAL" "$got"
189
+ grep -qF "\$'" "$T/ic-home/.zshenv" && bad "env file contains bash-only \$'...' quoting" || ok
190
+ expect "pointer file still written" "$IC_REAL" "$(cat "$T/ic-home/.codex/forge-home" 2>/dev/null)"
191
+ # errexit/pipefail safety: absent readlink target, absent cache dir, no ~/.claude at all
192
+ for opts in "-e" "-eo pipefail" "-euo pipefail"; do
193
+ expect "survives set $opts with nothing installed" "ok:" "$( cd "$T/consumer" && env -i PATH="$PATH" HOME="$T/empty" "$SH" -c "set $opts; $(cat "$T/canon"); printf 'ok:%s' \"\$FORGE_ROOT\"" 2>&1 )"
194
+ expect "resolves under set $opts" "ok:$T/h3/.claude/plugins/cache/forgedock/forgedock/1.10.0" "$( cd "$T/consumer" && env -i PATH="$PATH" HOME="$T/h3" "$SH" -c "set $opts; $(cat "$T/canon"); printf 'ok:%s' \"\$FORGE_ROOT\"" 2>&1 )"
195
+ done
141
196
  # a non-forgedock plugin in the cache is ignored even with a higher version
142
- mkscripts "$T/h9/.claude/plugins/cache/mk/otherplugin/9.9.9"; mkscripts "$T/h9/.claude/plugins/cache/mk/forgedock/1.0.0"
143
- expect "non-forgedock cache plugin ignored" "$T/h9/.claude/plugins/cache/mk/forgedock/1.0.0" "$(run "$T/h9")"
197
+ mkscripts "$T/h9/.claude/plugins/cache/forgedock/otherplugin/9.9.9"; mkscripts "$T/h9/.claude/plugins/cache/forgedock/forgedock/1.0.0"
198
+ expect "non-forgedock cache plugin ignored" "$T/h9/.claude/plugins/cache/forgedock/forgedock/1.0.0" "$(run "$T/h9")"
144
199
  # unmatched globs: empty cache dir, absent marketplaces dir, absent ~/.claude entirely (must not abort under zsh)
145
200
  mkdir -p "$T/h10/.claude/plugins/cache"
146
201
  expect "empty cache, no match, no abort" "" "$(run "$T/h10")"
147
202
  expect "no ~/.claude at all, no abort" "" "$(run "$T/empty")"
148
- expect "unmatched glob does not break later candidates" "$T/h1/.claude/plugins/cache/mk/forgedock/1.0.0" "$(run "$T/h10" CLAUDE_PLUGIN_ROOT="$T/h1/.claude/plugins/cache/mk/forgedock/1.0.0")"
203
+ expect "unmatched glob does not break later candidates" "$T/h1/.claude/plugins/cache/forgedock/forgedock/1.0.0" "$(run "$T/h10" CLAUDE_PLUGIN_ROOT="$T/h1/.claude/plugins/cache/forgedock/forgedock/1.0.0")"
149
204
  # plugin root (textually substituted) outranks an exported FORGE_HOME that also has the scripts:
150
205
  # the running plugin's own files win over a stale clone named by a global env var (forge#3147 field test)
151
206
  mkscripts "$T/h11/stale-clone"; mkscripts "$T/h11/running-plugin"
@@ -166,11 +221,13 @@ for f in $SITES commands/work-on/close.md; do
166
221
  done
167
222
  if command -v node >/dev/null 2>&1; then
168
223
  for d in "we#ird" "sp ace" "q?x"; do
224
+ # '?' is not a legal Windows filename character: MSYS remaps it, so node sees a different path.
225
+ case "$d:$(uname -s)" in *'?'*:MINGW*|*'?'*:MSYS*|*'?'*:CYGWIN*) echo "SKIP: path '$d' (illegal on Windows)"; SKIPPED=$((SKIPPED+1)); continue ;; esac
169
226
  mkdir -p "$T/url/$d/bin/engine"; echo 'export const v = 42;' > "$T/url/$d/bin/engine/resolve.mjs"
170
227
  out=$(node -e 'import(require("node:url").pathToFileURL(process.argv[1]).href).then(m => process.stdout.write(String(m.v)))' "$T/url/$d/bin/engine/resolve.mjs" 2>&1)
171
228
  expect "pathToFileURL import survives path '$d'" 42 "$out"
172
229
  done
173
230
  fi
174
231
 
175
- echo "forge-root tests: pass=$PASS fail=$FAILN"
232
+ echo "forge-root tests: pass=$PASS fail=$FAILN skipped=$SKIPPED"
176
233
  [ "$FAILN" -eq 0 ]
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env bash
2
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
3
+ # SPDX-License-Identifier: AGPL-3.0-or-later
4
+ # Lint: every work-on:build:validate Skill( invocation in build.md must carry --files,
5
+ # and the B6.5 repair section must contain an explicit one (forge#3254).
6
+ # Usage: bash scripts/lint-build-validate-files.sh [path/to/build.md]
7
+ set -uo pipefail
8
+ HERE="$(cd "$(dirname "$0")" && pwd)"
9
+ SPEC="${1:-$HERE/../commands/work-on/build.md}"
10
+ [ -f "$SPEC" ] || { echo "lint-build-validate-files: spec not found: $SPEC" >&2; exit 2; }
11
+ rc=0
12
+ calls=$(grep -n 'Skill(skill=.*work-on:build:validate' "$SPEC" || true)
13
+ if [ -z "$calls" ]; then
14
+ echo "FAIL: no work-on:build:validate Skill( invocation found in $SPEC" >&2
15
+ exit 1
16
+ fi
17
+ bad=$(printf '%s\n' "$calls" | grep -vF -- '--files' || true)
18
+ if [ -n "$bad" ]; then
19
+ printf '%s\n' "$bad" | while IFS= read -r l; do
20
+ echo "FAIL: $SPEC:${l%%:*}: validate invocation lacks --files" >&2
21
+ done
22
+ rc=1
23
+ fi
24
+ if ! awk '/Phase B6\.5/{f=1} f' "$SPEC" | grep 'Skill(skill=.*work-on:build:validate' | grep -qF -- '--files'; then
25
+ echo "FAIL: $SPEC: B6.5 repair section has no explicit validate invocation carrying --files" >&2
26
+ rc=1
27
+ fi
28
+ [ "$rc" -eq 0 ] && echo "OK: all validate invocations carry --files"
29
+ exit "$rc"
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env bash
2
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
3
+ # SPDX-License-Identifier: AGPL-3.0-or-later
4
+ # Fixtures for lint-build-validate-files.sh (forge#3254). Run: bash scripts/lint-build-validate-files.test.sh
5
+ set -uo pipefail
6
+ HERE="$(cd "$(dirname "$0")" && pwd)"
7
+ LINT="$HERE/lint-build-validate-files.sh"
8
+ T="$(mktemp -d)"; trap 'rm -rf "$T"' EXIT
9
+ PASS=0; FAILN=0
10
+ check() { # name want_rc file
11
+ bash "$LINT" "$3" >"$T/out" 2>&1; rc=$?
12
+ if [ "$rc" -eq "$2" ]; then PASS=$((PASS+1)); else FAILN=$((FAILN+1)); echo "FAIL: $1 (rc=$rc want $2)"; cat "$T/out"; fi
13
+ }
14
+ V='Skill(skill="{FORGE_SKILL_PREFIX}work-on:build:validate", args="{NUMBER} --worktree {W}'
15
+ printf '## Phase B6\n%s --files \\"{CHANGED_FILES}\\"")\n## Phase B6.5\n%s --files \\"{CHANGED_FILES}\\"")\n' "$V" "$V" > "$T/ok.md"
16
+ check "all carry --files" 0 "$T/ok.md"
17
+ printf '## Phase B6\n%s --files \\"x\\"")\n## Phase B6.5\n%s")\n' "$V" "$V" > "$T/bad1.md"
18
+ check "one invocation lacks --files" 1 "$T/bad1.md"
19
+ printf '## Phase B6\n%s --files \\"x\\"")\n## Phase B6.5\nfollowed by work-on:build:validate with the same args\n' "$V" > "$T/bad2.md"
20
+ check "B6.5 has no explicit invocation" 1 "$T/bad2.md"
21
+ printf 'nothing here\n' > "$T/bad3.md"
22
+ check "no invocations at all" 1 "$T/bad3.md"
23
+ check "real build.md" 0 "$HERE/../commands/work-on/build.md"
24
+ echo "passed=$PASS failed=$FAILN"
25
+ [ "$FAILN" -eq 0 ]
@@ -229,7 +229,8 @@ check "mid-line CR not stripped" 1 "$T/p25"
229
229
  # 10. Spec snippet enforcement (forge#3070): extract the lint gate from the spec and run it in a loop.
230
230
  # Gate = from the bootstrap marker 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
- awk '/^# FORGE_ROOT bootstrap/{f=1} f&&/^```/{exit} f{print}' "$SPEC" > "$T/gate"
232
+ # Several spec blocks carry the bootstrap marker (e.g. the recovery-claim gate, forge#3172); pick the one holding the lint gate.
233
+ awk '/^# FORGE_ROOT bootstrap/{f=1;buf=""} f&&/^```/{if(buf ~ /LINT_REFUSED_ISSUES/){printf "%s",buf;exit} f=0} f{buf=buf $0 "\n"}' "$SPEC" > "$T/gate"
233
234
  [ -s "$T/gate" ] || { FAILN=$((FAILN+1)); echo "FAIL: could not extract spec lint gate"; }
234
235
  gate_refused() { # prompt-file home -> prints refusal entries or LAUNCHED
235
236
  ( LINT_REFUSED_ISSUES=(); RENDERED_PROMPT="$(cat "$1")"; FORGEDOCK_HOME="$2"; unset FORGE_HOME; reset_vals
@@ -0,0 +1,73 @@
1
+ #!/usr/bin/env bash
2
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
3
+ # SPDX-License-Identifier: AGPL-3.0-or-later
4
+ #
5
+ # recovery-claim-live.sh — is an issue currently claimed by a /recover-orphans sweep?
6
+ #
7
+ # Single source of truth for the dispatcher-side check of FORGE:RECOVERY_CLAIM, used by
8
+ # /work-on Phase 0B and /orchestrate Phase 4 so a dispatcher never re-enters an issue a sweep is
9
+ # resuming. Same semantics as `claim_orphan` in commands/recover-orphans.md: a claim is LIVE when it
10
+ # is an unreleased `<!-- FORGE:RECOVERY_CLAIM -->` comment, its `updated_at` is within
11
+ # RECOVERY_CLAIM_TTL_MIN (default 30) minutes, and no `FORGE:RECOVERY_CLAIM_RELEASED` comment names
12
+ # its sweep id. FORGE:HEARTBEAT is deliberately NOT counted: a dispatcher must not defer to another
13
+ # dispatcher's heartbeat (that is the lease/claims-board job).
14
+ #
15
+ # Usage:
16
+ # recovery-claim-live.sh <issue> -R <owner/repo> [--exempt-sweep <sweep-id>]
17
+ #
18
+ # --exempt-sweep Ignore the claim held by this sweep id (the calling sweep's own claim), so the
19
+ # holder's inline /work-on is not blocked by its own claim. A claim with no
20
+ # parsable sweep id is never exempt (and `unknown` is rejected as an exemption id).
21
+ #
22
+ # Output (stdout): CLAIM: LIVE <sweep-id> | CLAIM: FREE | CLAIM: ERROR
23
+ # Exit codes: 0 free, 1 live, 2 error (unreadable comments or usage; fails closed — callers must
24
+ # treat 2 like "live" and defer, never like "free").
25
+ #
26
+ # Trust note: any commenter's claim marker counts (same as claim_orphan). A spoofed claim can only
27
+ # defer an issue for RECOVERY_CLAIM_TTL_MIN unless it is refreshed.
28
+ # END-HELP (`-h` prints the header up to this line; keep it last)
29
+
30
+ set -uo pipefail
31
+
32
+ ISSUE=""; REPO=""; EXEMPT=""
33
+ err() { echo "CLAIM: ERROR"; echo "$1" >&2; exit 2; }
34
+
35
+ while [ $# -gt 0 ]; do
36
+ case "$1" in
37
+ -R|--repo)
38
+ [ $# -ge 2 ] && [ -n "${2:-}" ] || err "usage error: $1 needs a value"
39
+ REPO="$2"; shift 2 ;;
40
+ --exempt-sweep)
41
+ [ $# -ge 2 ] && [ -n "${2:-}" ] || err "usage error: $1 needs a value"
42
+ [ "$2" != "unknown" ] || err "usage error: --exempt-sweep 'unknown' is the no-sweep-id sentinel and cannot exempt a claim"
43
+ EXEMPT="$2"; shift 2 ;;
44
+ -h|--help) awk 'NR >= 5 { if (/^# END-HELP/) exit; print }' "$0"; exit 0 ;;
45
+ *)
46
+ if [ -z "$ISSUE" ] && [[ "$1" =~ ^[0-9]+$ ]]; then ISSUE="$1"; shift
47
+ else err "usage error: unexpected argument '$1'"; fi ;;
48
+ esac
49
+ done
50
+ [ -n "$ISSUE" ] && [ -n "$REPO" ] || err "usage: recovery-claim-live.sh <issue> -R <owner/repo> [--exempt-sweep <id>]"
51
+
52
+ TTL="${RECOVERY_CLAIM_TTL_MIN:-30}"
53
+ [[ "$TTL" =~ ^[0-9]+$ ]] || TTL=30
54
+ CUTOFF=$(( $(date -u +%s) - TTL * 60 ))
55
+
56
+ # pipefail: a gh failure (rate limit, 403) must not look like "no comments".
57
+ COMMENTS=$(gh api --paginate "repos/${REPO}/issues/${ISSUE}/comments" 2>/dev/null | jq -s 'add // []') \
58
+ || err "could not read comments for #${ISSUE}"
59
+
60
+ LIVE=$(echo "$COMMENTS" | jq -r --argjson cutoff "$CUTOFF" --arg exempt "$EXEMPT" '
61
+ def sweepid: ((.body | capture("Sweep: (?<id>[^ \n*]+)")?) // {id: "unknown"}).id;
62
+ # "unknown" is the no-id sentinel, never a matchable id: an unparsable release frees nothing (fail closed).
63
+ ([.[] | select(.body | contains("FORGE:RECOVERY_CLAIM_RELEASED")) | sweepid] | map(select(. != "unknown"))) as $rel
64
+ | [ .[] | select(.body | contains("<!-- FORGE:RECOVERY_CLAIM -->"))
65
+ | select((.body | contains("FORGE:RECOVERY_CLAIM_RELEASED")) | not)
66
+ | select(((.updated_at // "") | fromdateiso8601? // 9999999999) >= $cutoff)
67
+ | select(sweepid as $sid | ($rel | index($sid)) == null)
68
+ | select($exempt == "" or sweepid == "unknown" or sweepid != $exempt)
69
+ | sweepid ] | first // empty') \
70
+ || err "could not evaluate recovery claims for #${ISSUE}"
71
+
72
+ if [ -n "$LIVE" ]; then echo "CLAIM: LIVE ${LIVE}"; exit 1; fi
73
+ echo "CLAIM: FREE"; exit 0
@@ -0,0 +1,66 @@
1
+ #!/usr/bin/env bash
2
+ # recovery-claim-live.test.sh — tests for scripts/recovery-claim-live.sh
3
+ # No network: `gh` is mocked on PATH; MOCK_GH_JSON names a fixture holding the JSON array
4
+ # `gh api .../comments` would return. MOCK_GH_FAIL=1 simulates an outage.
5
+ #
6
+ # Usage: bash scripts/recovery-claim-live.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
+ CHECK="$SCRIPT_DIR/recovery-claim-live.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
+ now_iso() { date -u +%Y-%m-%dT%H:%M:%SZ; }
29
+ old_iso() { date -u -d '2 hours ago' +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -v-2H +%Y-%m-%dT%H:%M:%SZ; }
30
+
31
+ # fx <name> <json-array>
32
+ fx() { echo "$2" > "$TMP_FX/$1.json"; echo "$TMP_FX/$1.json"; }
33
+ claim() { jq -cn --arg id "$1" --arg t "$2" '{body:("<!-- FORGE:RECOVERY_CLAIM -->\n**Sweep: "+$id+"**\nHolder: /recover-orphans"), updated_at:$t}'; }
34
+ release() { jq -cn --arg id "$1" --arg t "$2" '{body:("<!-- FORGE:RECOVERY_CLAIM_RELEASED -->\n**Sweep: "+$id+"**\nReleased"), updated_at:$t}'; }
35
+
36
+ run() { local f="$1"; shift; OUT=$(MOCK_GH_JSON="$f" bash "$CHECK" 7 -R o/r "$@" 2>/dev/null); RC=$?; }
37
+ expect() { # name rc needle
38
+ [ "$RC" -eq "$2" ] && echo "$OUT" | grep -q "$3" && ok "$1" || bad "$1 (rc=$RC out=$OUT)"
39
+ }
40
+
41
+ N=$(now_iso); O=$(old_iso)
42
+
43
+ run "$(fx live "[$(claim sweep-A "$N")]")"; expect "fresh claim is LIVE" 1 "CLAIM: LIVE sweep-A"
44
+ run "$(fx exp "[$(claim sweep-A "$O")]")"; expect "expired claim is FREE" 0 "CLAIM: FREE"
45
+ run "$(fx rel "[$(claim sweep-A "$N"), $(release sweep-A "$N")]")"; expect "released claim is FREE" 0 "CLAIM: FREE"
46
+ run "$(fx relother "[$(claim sweep-A "$N"), $(release sweep-B "$N")]")"; expect "release of another sweep does not free the claim" 1 "CLAIM: LIVE sweep-A"
47
+ run "$(fx unkrel "[{\"body\":\"<!-- FORGE:RECOVERY_CLAIM -->\\nno sweep line\",\"updated_at\":\"$N\"}, {\"body\":\"<!-- FORGE:RECOVERY_CLAIM_RELEASED -->\\nno sweep line\",\"updated_at\":\"$N\"}]")"; expect "release with no sweep id does not free an id-less claim" 1 "CLAIM: LIVE unknown"
48
+ run "$(fx own "[$(claim sweep-A "$N")]")" --exempt-sweep sweep-A; expect "own sweep id is exempt" 0 "CLAIM: FREE"
49
+ run "$(fx other "[$(claim sweep-A "$N")]")" --exempt-sweep sweep-B; expect "other sweep id is not exempt" 1 "CLAIM: LIVE sweep-A"
50
+ run "$(fx two "[$(claim sweep-A "$N"), $(claim sweep-B "$N")]")" --exempt-sweep sweep-A; expect "exempting A still sees live B" 1 "CLAIM: LIVE sweep-B"
51
+ run "$(fx unk "[{\"body\":\"<!-- FORGE:RECOVERY_CLAIM -->\\nno sweep line\",\"updated_at\":\"$N\"}]")" --exempt-sweep sweep-A; expect "claim with no sweep id is never exempt" 1 "CLAIM: LIVE"
52
+ run "$(fx unk2 "[{\"body\":\"<!-- FORGE:RECOVERY_CLAIM -->\\nno sweep line\",\"updated_at\":\"$N\"}]")" --exempt-sweep unknown; expect "--exempt-sweep unknown is rejected" 2 "CLAIM: ERROR"
53
+ run "$(fx none '[]')"; expect "no comments is FREE" 0 "CLAIM: FREE"
54
+ run "$(fx hb "[{\"body\":\"<!-- FORGE:HEARTBEAT -->\\nPhase 3\",\"updated_at\":\"$N\"}]")"; expect "heartbeat alone is not a recovery claim" 0 "CLAIM: FREE"
55
+ run "$(fx bad "[{\"body\":\"<!-- FORGE:RECOVERY_CLAIM -->\\nno sweep line\",\"updated_at\":\"$N\"}]")"; expect "malformed claim fails closed (LIVE)" 1 "CLAIM: LIVE"
56
+ run "$(fx nodate "[{\"body\":\"<!-- FORGE:RECOVERY_CLAIM -->\\n**Sweep: sweep-A**\",\"updated_at\":\"garbage\"}]")"; expect "unparsable updated_at fails closed (LIVE)" 1 "CLAIM: LIVE"
57
+
58
+ OUT=$(MOCK_GH_FAIL=1 MOCK_GH_JSON=/dev/null bash "$CHECK" 7 -R o/r 2>/dev/null); RC=$?
59
+ expect "gh outage is ERROR (exit 2)" 2 "CLAIM: ERROR"
60
+ OUT=$(MOCK_GH_JSON="$(fx junk 'not json')" bash "$CHECK" 7 -R o/r 2>/dev/null); RC=$?
61
+ expect "unparsable response is ERROR (exit 2)" 2 "CLAIM: ERROR"
62
+ OUT=$(bash "$CHECK" 2>/dev/null); RC=$?; expect "no args is a usage error" 2 "CLAIM: ERROR"
63
+ OUT=$(bash "$CHECK" 7 -R 2>/dev/null); RC=$?; expect "-R without value is a usage error" 2 "CLAIM: ERROR"
64
+
65
+ echo "passed=$PASS failed=$FAILN"
66
+ [ "$FAILN" -eq 0 ]