forgedock 1.8.4 → 1.9.0

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 (38) 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 +349 -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 +51 -0
  9. package/commands/recover-orphans.md +3 -3
  10. package/commands/review-pr.md +87 -16
  11. package/commands/work-on/build/context.md +25 -4
  12. package/commands/work-on/build/validate.md +1 -0
  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 +52 -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-markers.sh +67 -3
  28. package/scripts/classify-finding.sh +120 -0
  29. package/scripts/classify-finding.test.sh +74 -0
  30. package/scripts/close-dossier-no-main-commit.test.sh +139 -0
  31. package/scripts/forge-root.test.sh +84 -27
  32. package/scripts/lint-build-validate-files.sh +29 -0
  33. package/scripts/lint-build-validate-files.test.sh +25 -0
  34. package/scripts/lint-dispatch-prompt.test.sh +2 -1
  35. package/scripts/recovery-claim-live.sh +73 -0
  36. package/scripts/recovery-claim-live.test.sh +66 -0
  37. package/scripts/verify-phase-trail.sh +183 -4
  38. package/scripts/verify-phase-trail.test.sh +211 -1
@@ -0,0 +1,120 @@
1
+ #!/usr/bin/env bash
2
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
3
+ # SPDX-License-Identifier: AGPL-3.0-or-later
4
+ #
5
+ # classify-finding.sh — Deterministic review-pr §6B.5 note disposition.
6
+ # Decides whether one deduped review finding is filed
7
+ # as a standalone `review-finding` issue (ISSUE) or
8
+ # handled as a non-blocking note (NOTE).
9
+ #
10
+ # Usage:
11
+ # classify-finding.sh --severity <S> [--confidence <C>] [--agent <A>]
12
+ # [--lineage none|review-finding] [--text <T> | --text-file <F>]
13
+ #
14
+ # --severity CRITICAL | HIGH | MEDIUM | LOW (case-insensitive). Missing or
15
+ # unparseable → ISSUE (fail toward filing, never drop).
16
+ # --confidence CONFIRMED | LIKELY | POSSIBLE (case-insensitive). Missing is
17
+ # treated as not-POSSIBLE.
18
+ # --agent The `**Agent**:` value of the finding (e.g. "Security", "Auth").
19
+ # --lineage `review-finding` when the PR's linked issue (MERGE_ISSUE) carries
20
+ # the `review-finding` label — i.e. this PR is itself a fix for a
21
+ # review finding. Default `none`.
22
+ # --text/--text-file Finding title + body + affected file paths, used for the
23
+ # content-based safety exemption.
24
+ #
25
+ # Output (stdout, one line): `ISSUE <reason>` or `NOTE <reason>`.
26
+ # Exit codes: 0 classified, 2 usage error.
27
+ #
28
+ # Rules (forge#3060, tightened after the 2026-10-08 cascade audit):
29
+ # 1. HIGH/CRITICAL, or unparseable severity → ISSUE.
30
+ # 2. Safety exemption is CONTENT-based: the text matches the security/billing
31
+ # keyword set, or the finding came from a dedicated, signal-selected domain
32
+ # agent (Auth, Billing, Concurrency, Database). Origin from the always-on
33
+ # "General Security & Quality" agent alone NO LONGER exempts a finding —
34
+ # that agent runs on every PR, so origin-based exemption filed nearly every
35
+ # LOW note it raised (50 of 60 would-be notes in the audited batch).
36
+ # 3. Review-finding lineage (a fix for a finding): only MEDIUM findings that are
37
+ # CONFIRMED, or LIKELY and safety-exempt, become issues. LOW is always a
38
+ # NOTE — a fix for a finding must not mint a new generation of findings.
39
+ # 4. Otherwise: LOW, or POSSIBLE below HIGH, is a NOTE unless safety-exempt.
40
+ #
41
+ # NOTEs are never discarded silently: review-pr §6B.5 fixes them in-PR, lists
42
+ # them in the PR body, or records a drop reason in the review summary.
43
+ #
44
+ # Portable: bash 3.2 (macOS /bin/bash), BSD and GNU grep/tr. No network.
45
+
46
+ set -u
47
+
48
+ SEVERITY=""
49
+ CONFIDENCE=""
50
+ AGENT=""
51
+ LINEAGE="none"
52
+ TEXT=""
53
+
54
+ usage() {
55
+ echo "ERROR: Usage: classify-finding.sh --severity <S> [--confidence <C>] [--agent <A>] [--lineage none|review-finding] [--text <T> | --text-file <F>]" >&2
56
+ exit 2
57
+ }
58
+
59
+ while [ "$#" -gt 0 ]; do
60
+ case "$1" in
61
+ --severity) [ "$#" -ge 2 ] || usage; SEVERITY="$2"; shift 2 ;;
62
+ --confidence) [ "$#" -ge 2 ] || usage; CONFIDENCE="$2"; shift 2 ;;
63
+ --agent) [ "$#" -ge 2 ] || usage; AGENT="$2"; shift 2 ;;
64
+ --lineage) [ "$#" -ge 2 ] || usage; LINEAGE="$2"; shift 2 ;;
65
+ --text) [ "$#" -ge 2 ] || usage; TEXT="$2"; shift 2 ;;
66
+ --text-file)
67
+ [ "$#" -ge 2 ] || usage
68
+ [ -r "$2" ] || { echo "ERROR: --text-file not readable: $2" >&2; exit 2; }
69
+ TEXT=$(cat "$2"); shift 2 ;;
70
+ *) usage ;;
71
+ esac
72
+ done
73
+
74
+ case "$LINEAGE" in
75
+ none|review-finding) ;;
76
+ *) echo "ERROR: --lineage must be 'none' or 'review-finding' (got '$LINEAGE')" >&2; exit 2 ;;
77
+ esac
78
+
79
+ upper() { printf '%s' "$1" | tr '[:lower:]' '[:upper:]' | tr -d '[:space:]'; }
80
+ SEV=$(upper "$SEVERITY")
81
+ CONF=$(upper "$CONFIDENCE")
82
+
83
+ case "$SEV" in
84
+ CRITICAL|HIGH) echo "ISSUE severity-$SEV"; exit 0 ;;
85
+ MEDIUM|LOW) ;;
86
+ *) echo "ISSUE unparseable-severity"; exit 0 ;;
87
+ esac
88
+
89
+ # Safety exemption. Keywords match whole words, where `_`, `-`, `/` and other
90
+ # punctuation separate words (so `auth_service` matches, `author` does not).
91
+ SAFETY=""
92
+ KW='security|auth|authz|authn|billing|payment|stripe|charge|invoice|injection|xss|csrf|ssrf|idor|secret|secrets|credential|credentials|permission|permissions|sql|token|password|redact'
93
+ if printf '%s' "$TEXT" | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9\n' ' ' | grep -Eqw "($KW)"; then
94
+ SAFETY="keyword"
95
+ fi
96
+ AGENT_LC=$(printf '%s' "$AGENT" | tr '[:upper:]' '[:lower:]')
97
+ case "$AGENT_LC" in
98
+ auth*|billing*|concurrency*|database*) SAFETY="${SAFETY:-domain-agent}" ;;
99
+ esac
100
+
101
+ if [ "$LINEAGE" = "review-finding" ]; then
102
+ if [ "$SEV" = "MEDIUM" ] && { [ "$CONF" = "CONFIRMED" ] || { [ -n "$SAFETY" ] && [ "$CONF" = "LIKELY" ]; }; }; then
103
+ echo "ISSUE lineage-medium-${CONF:-UNKNOWN}"
104
+ else
105
+ echo "NOTE lineage-${SEV}-${CONF:-UNKNOWN}"
106
+ fi
107
+ exit 0
108
+ fi
109
+
110
+ if [ "$SEV" = "LOW" ] || [ "$CONF" = "POSSIBLE" ]; then
111
+ if [ -n "$SAFETY" ]; then
112
+ echo "ISSUE safety-exemption-$SAFETY"
113
+ else
114
+ echo "NOTE ${SEV}-${CONF:-UNKNOWN}"
115
+ fi
116
+ exit 0
117
+ fi
118
+
119
+ echo "ISSUE ${SEV}-${CONF:-UNKNOWN}"
120
+ exit 0
@@ -0,0 +1,74 @@
1
+ #!/usr/bin/env bash
2
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
3
+ # SPDX-License-Identifier: AGPL-3.0-or-later
4
+ #
5
+ # Fixtures for scripts/classify-finding.sh (review-pr §6B.5 note disposition).
6
+ # Run: bash scripts/classify-finding.test.sh (bash 3.2 compatible)
7
+
8
+ set -u
9
+ DIR=$(cd "$(dirname "$0")" && pwd)
10
+ S="$DIR/classify-finding.sh"
11
+ pass=0; fail=0
12
+
13
+ expect() { # expect <ISSUE|NOTE> <description> -- args...
14
+ want="$1"; desc="$2"; shift 3
15
+ got=$(bash "$S" "$@" 2>/dev/null | cut -d' ' -f1)
16
+ if [ "$got" = "$want" ]; then pass=$((pass+1)); else fail=$((fail+1)); echo "FAIL: $desc (want $want, got '${got}')"; fi
17
+ }
18
+ expect_exit() { # expect_exit <code> <description> -- args...
19
+ want="$1"; desc="$2"; shift 3
20
+ bash "$S" "$@" >/dev/null 2>&1; got=$?
21
+ if [ "$got" = "$want" ]; then pass=$((pass+1)); else fail=$((fail+1)); echo "FAIL: $desc (want exit $want, got $got)"; fi
22
+ }
23
+
24
+ # --- Rule 1: high severity and unparseable severity always file ---
25
+ expect ISSUE "CRITICAL files" -- --severity CRITICAL --confidence POSSIBLE
26
+ expect ISSUE "HIGH POSSIBLE files" -- --severity HIGH --confidence POSSIBLE
27
+ expect ISSUE "HIGH on lineage files" -- --severity HIGH --confidence LIKELY --lineage review-finding
28
+ expect ISSUE "missing severity files" -- --severity "" --confidence LIKELY
29
+ expect ISSUE "unknown severity files" -- --severity INFO
30
+ expect ISSUE "lowercase high files" -- --severity high
31
+
32
+ # --- Rule 4: non-lineage ---
33
+ expect ISSUE "MEDIUM CONFIRMED files" -- --severity MEDIUM --confidence CONFIRMED --text "loop exits early"
34
+ expect ISSUE "MEDIUM LIKELY files" -- --severity MEDIUM --confidence LIKELY --text "loop exits early"
35
+ expect NOTE "MEDIUM POSSIBLE no-safety note" -- --severity MEDIUM --confidence POSSIBLE --text "loop exits early"
36
+ expect NOTE "LOW CONFIRMED no-safety note" -- --severity LOW --confidence CONFIRMED --text "stale comment wording"
37
+ expect NOTE "LOW from Security agent, no keyword, is a NOTE (audit fix)" -- --severity LOW --confidence CONFIRMED --agent "Security" --text "comment wording drifted"
38
+ expect NOTE "LOW from General Security & Quality, no keyword" -- --severity LOW --confidence LIKELY --agent "General Security & Quality" --text "duplicated predicate in NOTE count"
39
+
40
+ # --- Safety exemption (content-based) ---
41
+ expect ISSUE "LOW with injection keyword" -- --severity LOW --confidence POSSIBLE --text "shell injection via unquoted var"
42
+ expect ISSUE "LOW with auth_service path" -- --severity LOW --confidence LIKELY --text "src/auth_service.py:12 missing check"
43
+ expect ISSUE "LOW with Secrets (case)" -- --severity LOW --confidence LIKELY --text "Secrets leak to log"
44
+ expect ISSUE "LOW with permission-check" -- --severity LOW --confidence LIKELY --text "permission-check skipped"
45
+ expect NOTE "'author' is not 'auth'" -- --severity LOW --confidence LIKELY --text "author field unused"
46
+ expect NOTE "'tokenizer' is not 'token'" -- --severity LOW --confidence LIKELY --text "tokenizer splits oddly"
47
+ expect ISSUE "LOW from Auth domain agent" -- --severity LOW --confidence LIKELY --agent "Auth" --text "nit"
48
+ expect ISSUE "LOW from Billing domain agent" -- --severity LOW --confidence POSSIBLE --agent "billing" --text "nit"
49
+ expect ISSUE "LOW from Database agent" -- --severity LOW --confidence POSSIBLE --agent "Database" --text "nit"
50
+ expect ISSUE "LOW from Concurrency agent" -- --severity LOW --confidence POSSIBLE --agent "Concurrency" --text "nit"
51
+
52
+ # --- Rule 3: review-finding lineage (fix for a finding) ---
53
+ expect NOTE "lineage LOW CONFIRMED note" -- --severity LOW --confidence CONFIRMED --lineage review-finding --text "nit"
54
+ expect NOTE "lineage LOW security keyword still note" -- --severity LOW --confidence CONFIRMED --lineage review-finding --text "injection nit"
55
+ expect ISSUE "lineage MEDIUM CONFIRMED files" -- --severity MEDIUM --confidence CONFIRMED --lineage review-finding --text "logic bug"
56
+ expect NOTE "lineage MEDIUM LIKELY no-safety note" -- --severity MEDIUM --confidence LIKELY --lineage review-finding --text "logic bug"
57
+ expect ISSUE "lineage MEDIUM LIKELY with keyword files" -- --severity MEDIUM --confidence LIKELY --lineage review-finding --text "token leaks"
58
+ expect NOTE "lineage MEDIUM POSSIBLE with keyword note" -- --severity MEDIUM --confidence POSSIBLE --lineage review-finding --text "token leaks"
59
+
60
+ # --- text-file input ---
61
+ TMPF=$(mktemp "${TMPDIR:-/tmp}/classify-finding-test.XXXXXX")
62
+ printf 'Title: unchecked xss sink\n' > "$TMPF"
63
+ expect ISSUE "text-file keyword" -- --severity LOW --confidence LIKELY --text-file "$TMPF"
64
+ rm -f "$TMPF"
65
+
66
+ # --- usage errors ---
67
+ expect_exit 2 "unknown flag" -- --bogus x
68
+ expect_exit 2 "bad lineage" -- --severity LOW --lineage P3
69
+ expect_exit 2 "missing flag value" -- --severity
70
+ expect_exit 2 "unreadable text-file" -- --severity LOW --text-file /nonexistent/x
71
+ expect_exit 0 "valid call exits 0" -- --severity LOW
72
+
73
+ echo "classify-finding.test.sh: passed=$pass failed=$fail"
74
+ [ "$fail" -eq 0 ]
@@ -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