forgedock 1.12.0 → 1.13.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 (76) hide show
  1. package/bin/engine/phases-args.test.mjs +2 -1
  2. package/bin/engine/phases.mjs +349 -90
  3. package/bin/engine/runlog.mjs +25 -2
  4. package/bin/engine-cli.mjs +4 -1
  5. package/bin/engine.mjs +72 -19
  6. package/bin/hooks/interactive-engine.mjs +135 -209
  7. package/bin/hooks/pre-tool-use.mjs +16 -4
  8. package/bin/labels.json +10 -0
  9. package/bin/opencode-adapter.mjs +1 -1
  10. package/bin/orchestrate-preflight.mjs +1 -1
  11. package/bin/runner.mjs +258 -23
  12. package/bin/watch-utils.mjs +1 -0
  13. package/commands/autopilot.md +6 -6
  14. package/commands/cleanup.md +8 -5
  15. package/commands/orchestrate/phase-1-resolve.md +1 -1
  16. package/commands/orchestrate/phase-3-dependency.md +14 -19
  17. package/commands/orchestrate/phase-4-execution.md +80 -53
  18. package/commands/orchestrate/phase-6-report.md +6 -0
  19. package/commands/orchestrate/safety.md +1 -1
  20. package/commands/pipeline-health/phase-2-metrics.md +39 -0
  21. package/commands/pipeline-health/phase-5-report.md +1 -0
  22. package/commands/pipeline-resume.md +1 -1
  23. package/commands/pipeline-status.md +2 -2
  24. package/commands/recover-orphans.md +5 -5
  25. package/commands/review-pr-agents/api.md +1 -1
  26. package/commands/review-pr-agents/auth.md +1 -1
  27. package/commands/review-pr-agents/infra.md +3 -3
  28. package/commands/review-pr-agents/protocols.md +15 -6
  29. package/commands/review-pr-agents/scraper.md +1 -1
  30. package/commands/review-pr-agents/security.md +5 -5
  31. package/commands/review-pr-agents.md +12 -12
  32. package/commands/review-pr-staging.md +226 -8
  33. package/commands/review-pr.md +394 -53
  34. package/commands/work-on/build/architect.md +36 -0
  35. package/commands/work-on/build/implement.md +1 -1
  36. package/commands/work-on/build/validate.md +88 -4
  37. package/commands/work-on/build.md +106 -7
  38. package/commands/work-on/decompose.md +2 -2
  39. package/commands/work-on/investigate.md +14 -4
  40. package/commands/work-on/remediate.md +137 -25
  41. package/commands/work-on/review.md +25 -9
  42. package/commands/work-on.md +15 -15
  43. package/docs/CONFIG.md +29 -0
  44. package/docs/RELEASE-NOTES.md +45 -0
  45. package/docs/spec/forge-protocol-v1.md +22 -1
  46. package/docs/spec/label-state-machine.md +6 -1
  47. package/docs/spec/review-protocol.md +20 -5
  48. package/package.json +2 -1
  49. package/packages/protocol/src/phases.js +21 -0
  50. package/scripts/check-blast-radius.sh +164 -0
  51. package/scripts/check-blast-radius.test.sh +183 -0
  52. package/scripts/check-contract-scope.sh +137 -0
  53. package/scripts/check-contract-scope.test.sh +83 -0
  54. package/scripts/check-spec-bash.sh +60 -3
  55. package/scripts/check-spec-bash.test.sh +56 -0
  56. package/scripts/check-spec-markers.sh +29 -0
  57. package/scripts/check-test-wiring.sh +57 -0
  58. package/scripts/check-test-wiring.test.sh +70 -0
  59. package/scripts/classify-finding.sh +72 -8
  60. package/scripts/classify-finding.test.sh +77 -8
  61. package/scripts/close-dossier-no-main-commit.test.sh +12 -12
  62. package/scripts/diff-size.test.sh +3 -2
  63. package/scripts/doctor-pipeline-state.sh +35 -0
  64. package/scripts/forge-annotation.sh +3 -0
  65. package/scripts/forge-root.test.sh +103 -13
  66. package/scripts/forge-run.sh +2 -2
  67. package/scripts/recovery-claim-live.test.sh +10 -7
  68. package/scripts/remediate-basesync.test.sh +103 -0
  69. package/scripts/review-delta.sh +206 -0
  70. package/scripts/review-delta.test.sh +213 -0
  71. package/scripts/review-pr-domain-map.test.sh +87 -0
  72. package/scripts/review-pr-gate2.test.sh +84 -0
  73. package/scripts/review-pr-staging-scope.test.sh +99 -0
  74. package/scripts/run-shell-tests.sh +115 -0
  75. package/scripts/select-fix-targets.sh +17 -5
  76. package/scripts/transition-label.sh +3 -1
@@ -0,0 +1,183 @@
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/check-blast-radius.sh. Run: bash scripts/check-blast-radius.test.sh (bash 3.2 compatible)
6
+
7
+ set -u
8
+ DIR=$(cd "$(dirname "$0")" && pwd)
9
+ S="$DIR/check-blast-radius.sh"
10
+ T=$(mktemp -d "${TMPDIR:-/tmp}/check-blast-radius-test.XXXXXX")
11
+ trap 'rm -rf "$T"' EXIT
12
+ pass=0; fail=0
13
+ OUT=""; RC=0
14
+
15
+ g() { git -C "$T/r" -c user.name=t -c user.email=t@example.com -c commit.gpgsign=false "$@" >/dev/null 2>&1; }
16
+ run() { OUT=$(bash "$S" --manifest "$T/m" --base staging --repo "$T/r" 2>&1); RC=$?; }
17
+ manifest() { { echo '<!-- FORGE:BLAST_RADIUS:BEGIN -->'; cat; echo '<!-- FORGE:BLAST_RADIUS:END -->'; } > "$T/m"; }
18
+ expect() { # expect <want_rc> <desc> [want_substring]
19
+ if [ "$RC" = "$1" ] && { [ -z "${3:-}" ] || case "$OUT" in *"$3"*) true ;; *) false ;; esac; }; then
20
+ pass=$((pass+1))
21
+ else
22
+ fail=$((fail+1)); echo "FAIL: $2 (rc=$RC, want $1)"; printf '%s\n' "$OUT" | sed 's/^/ /'
23
+ fi
24
+ }
25
+ expect_absent() { # expect_absent <desc> <substring>
26
+ case "$OUT" in *"$2"*) fail=$((fail+1)); echo "FAIL: $1 (unexpected '$2')"; printf '%s\n' "$OUT" | sed 's/^/ /' ;; *) pass=$((pass+1)) ;; esac
27
+ }
28
+ reset_branch() { g checkout -q -f staging; g branch -q -D feat; g checkout -q -b feat; }
29
+
30
+ mkdir "$T/r"; git -C "$T/r" init -q -b staging >/dev/null 2>&1 || { git -C "$T/r" init -q; g checkout -b staging; }
31
+ mkdir -p "$T/r/routers" "$T/r/docs"
32
+ for f in a b c; do printf 'def handler():\n use(billing_principal)\n' > "$T/r/routers/$f.py"; done
33
+ echo 'notes about nothing' > "$T/r/docs/readme.md"
34
+ g add -A; g commit -m base
35
+ g checkout -q -b feat
36
+
37
+ SYM='SYMBOL: id=s1 kind=field name=billing_principal query="billing_principal"'
38
+
39
+ # (a) 3 sibling callers, 1 changed, 2 unlisted -> FAIL naming both unlisted files
40
+ echo 'use(billing_principal, new)' >> "$T/r/routers/a.py"; g add -A; g commit -m "change a"
41
+ manifest <<M
42
+ $SYM
43
+ HIT: symbol=s1 file=routers/a.py role=consumer disposition=change
44
+ M
45
+ run; expect 1 "3 siblings, 1 changed, 2 unlisted fails" "UNLISTED: s1 routers/b.py"
46
+ case "$OUT" in *"UNLISTED: s1 routers/c.py"*) pass=$((pass+1)) ;; *) fail=$((fail+1)); echo "FAIL: second unlisted sibling not named"; echo "$OUT" ;; esac
47
+ expect_absent "changed file is not unlisted" "UNLISTED: s1 routers/a.py"
48
+
49
+ # (b) all 3 changed -> PASS
50
+ echo 'use(billing_principal, new)' >> "$T/r/routers/b.py"; echo 'use(billing_principal, new)' >> "$T/r/routers/c.py"
51
+ g add -A; g commit -m "change b c"
52
+ manifest <<M
53
+ $SYM
54
+ HIT: symbol=s1 file=routers/a.py role=consumer disposition=change
55
+ M
56
+ run; expect 0 "all siblings changed passes (changed files need no row)" "OK:"
57
+
58
+ # (c) 1 changed + 2 verified-unaffected -> PASS
59
+ reset_branch; echo 'use(billing_principal, new)' >> "$T/r/routers/a.py"; g add -A; g commit -m "change a"
60
+ manifest <<M
61
+ $SYM
62
+ HIT: symbol=s1 file=routers/a.py role=consumer disposition=change
63
+ HIT: symbol=s1 file=routers/b.py role=sibling disposition=verified-unaffected reason=reads-only-never-writes
64
+ HIT: symbol=s1 file=routers/c.py role=sibling disposition=verified-unaffected reason=dead-code-path
65
+ M
66
+ run; expect 0 "verified-unaffected siblings pass" "OK:"
67
+
68
+ # (c2) staged but uncommitted and untracked changes count (validate runs before the commit)
69
+ reset_branch; echo 'use(billing_principal, new)' >> "$T/r/routers/a.py"; echo 'use(billing_principal, new)' >> "$T/r/routers/b.py"
70
+ g add routers/a.py; echo 'billing_principal = 1' > "$T/r/routers/d.py"
71
+ manifest <<M
72
+ $SYM
73
+ M
74
+ run; expect 1 "uncommitted: c.py still unlisted" "UNLISTED: s1 routers/c.py"
75
+ expect_absent "staged change counts" "UNLISTED: s1 routers/a.py"
76
+ expect_absent "unstaged tracked change counts" "UNLISTED: s1 routers/b.py"
77
+ expect_absent "untracked new file counts" "UNLISTED: s1 routers/d.py"
78
+ g checkout -q -f staging; rm -f "$T/r/routers/d.py"; g checkout -q -f feat; rm -f "$T/r/routers/d.py"; g reset -q --hard
79
+
80
+ # (d) change row whose file is not in the diff -> FAIL (planned but not done)
81
+ reset_branch; echo 'use(billing_principal, new)' >> "$T/r/routers/a.py"; g add -A; g commit -m "change a"
82
+ manifest <<M
83
+ $SYM
84
+ HIT: symbol=s1 file=routers/a.py role=consumer disposition=change
85
+ HIT: symbol=s1 file=docs/readme.md role=consumer disposition=change
86
+ HIT: symbol=s1 file=routers/b.py role=sibling disposition=verified-unaffected reason=x-reason
87
+ HIT: symbol=s1 file=routers/c.py role=sibling disposition=verified-unaffected reason=x-reason
88
+ M
89
+ run; expect 1 "planned-but-not-done change row fails" "NOT_DONE: s1 docs/readme.md"
90
+
91
+ # (e) rc 2 cases
92
+ manifest <<M
93
+ SYMBOL: id=s1 kind=field name=x query="-rf"
94
+ M
95
+ run; expect 2 "leading dash query rejected"
96
+ manifest <<M
97
+ SYMBOL: id=s1 kind=field name=x query=""
98
+ M
99
+ run; expect 2 "empty query rejected"
100
+ manifest <<M
101
+ SYMBOL: id=s1 kind=field name=x query="ab"
102
+ M
103
+ run; expect 2 "too-short query rejected"
104
+ manifest <<M
105
+ SYMBOL: id=s1 kind=field name=x query="foo bar baz"
106
+ M
107
+ run; expect 2 "query with spaces rejected"
108
+ manifest <<M
109
+ SYMBOL: id=s1 kind=field name=x query="a;rm -rf"
110
+ M
111
+ run; expect 2 "query with shell metacharacters rejected"
112
+ manifest <<M
113
+ $SYM
114
+ this is not a record
115
+ M
116
+ run; expect 2 "malformed line rejected"
117
+ manifest <<M
118
+ $SYM
119
+ HIT: symbol=s1 file=routers/b.py role=sibling disposition=verified-unaffected
120
+ M
121
+ run; expect 2 "verified-unaffected without reason rejected"
122
+ manifest <<M
123
+ $SYM
124
+ HIT: symbol=s9 file=routers/b.py role=sibling disposition=change
125
+ M
126
+ run; expect 2 "HIT with unknown symbol rejected"
127
+ manifest <<M
128
+ $SYM
129
+ HIT: symbol=s1 file=routers/b.py role=sibling disposition=maybe
130
+ M
131
+ run; expect 2 "invalid disposition rejected"
132
+ manifest <<M
133
+ $SYM
134
+ $SYM
135
+ M
136
+ run; expect 2 "duplicate symbol id rejected"
137
+ printf '<!-- FORGE:BLAST_RADIUS:BEGIN -->\n%s\n' "$SYM" > "$T/m"
138
+ run; expect 2 "unterminated BEGIN rejected"
139
+ manifest <<M
140
+ $SYM
141
+ M
142
+ OUT=$(bash "$S" --manifest "$T/m" --base no-such-ref --repo "$T/r" 2>&1); RC=$?; expect 2 "bad base ref is rc 2, not a pass"
143
+ mkdir "$T/notgit"
144
+ OUT=$(bash "$S" --manifest "$T/m" --base staging --repo "$T/notgit" 2>&1); RC=$?; expect 2 "non-git repo is rc 2, not a pass"
145
+ OUT=$(bash "$S" --manifest "$T/missing" --base staging --repo "$T/r" 2>&1); RC=$?; expect 2 "missing manifest file is rc 2"
146
+ OUT=$(bash "$S" --base staging 2>&1); RC=$?; expect 2 "missing --manifest is rc 2"
147
+
148
+ # (f) absent / empty manifest -> SKIP rc 0
149
+ : > "$T/m"; run; expect 0 "empty manifest skips" "SKIP: no manifest"
150
+ printf '## Implementation Plan\nno block here\n' > "$T/m"; run; expect 0 "no BEGIN/END block skips" "SKIP: no manifest"
151
+ printf '## Plan\nprose\n<!-- FORGE:BLAST_RADIUS:BEGIN -->\n%s\nHIT: symbol=s1 file=routers/a.py role=consumer disposition=change\n<!-- FORGE:BLAST_RADIUS:END -->\ntrailing prose\n<!-- FORGE:ARCHITECT:COMPLETE -->\n' "$SYM" > "$T/m"
152
+ run; expect 1 "whole comment body is accepted, prose outside the block ignored" "UNLISTED: s1 routers/b.py"
153
+
154
+ # fixed-string, not regex: a query with metacharacter-like text must match literally
155
+ g checkout -q -f staging; echo 'a.b(c)' > "$T/r/docs/regex.txt"; echo 'aXb(c)' > "$T/r/docs/other.txt"; g add -A; g commit -m regex
156
+ g branch -q -D feat; g checkout -q -b feat
157
+ manifest <<M
158
+ SYMBOL: id=s1 kind=function name=dot query="a.b"
159
+ M
160
+ run; expect 1 "fixed-string match lists the literal hit" "UNLISTED"
161
+ expect_absent "regex-style wildcard does not match aXb" "other.txt"
162
+ HIT_OK='HIT: symbol=s1 file=docs/regex.txt role=producer disposition=verified-unaffected reason=literal-only'
163
+ manifest <<M
164
+ SYMBOL: id=s1 kind=function name=dot query="a.b"
165
+ $HIT_OK
166
+ M
167
+ run; expect 0 "literal hit covered by verified-unaffected" "OK:"
168
+
169
+ # zero-hit symbol passes
170
+ manifest <<M
171
+ SYMBOL: id=s1 kind=flag name=nothing query="zz_never_present_zz"
172
+ M
173
+ run; expect 0 "zero-hit symbol passes" "OK:"
174
+
175
+ # query too broad -> rc 2
176
+ reset_branch; mkdir -p "$T/r/many"; i=0; while [ "$i" -lt 205 ]; do echo broadtoken > "$T/r/many/f$i.txt"; i=$((i+1)); done; g add -A; g commit -m many
177
+ manifest <<M
178
+ SYMBOL: id=s1 kind=flag name=broad query="broadtoken"
179
+ M
180
+ run; expect 2 "over-broad query is rc 2" "too broad"
181
+
182
+ echo "check-blast-radius.test.sh: $pass passed, $fail failed"
183
+ [ "$fail" -eq 0 ]
@@ -0,0 +1,137 @@
1
+ #!/usr/bin/env bash
2
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
3
+ # SPDX-License-Identifier: AGPL-3.0-or-later
4
+ #
5
+ # check-contract-scope.sh — Parser/validator for the `### Out of Scope` section of a
6
+ # FORGE:CONTRACT comment. One grammar shared by work-on:build
7
+ # (validate before posting) and review-pr (list, to demote findings
8
+ # that match a contract-declared item).
9
+ #
10
+ # Usage:
11
+ # check-contract-scope.sh validate [<file>] # exit 0 valid, 1 invalid (one line per bad item on stderr)
12
+ # check-contract-scope.sh list [<file>] # TSV: disposition<TAB>path<TAB>issue, one row per path
13
+ # Input is the contract comment body, from <file> or stdin.
14
+ #
15
+ # Grammar. The section is either the single line `None.` or a list of bullets, each naming at
16
+ # least one backticked path or symbol followed by exactly one disposition:
17
+ # - `path` — deferred → #N: <why> (a follow-up issue exists; `->` is accepted for `→`)
18
+ # - `path` — not-affected: <evidence>
19
+ # - `path` — accepted-risk: <reason>
20
+ # `deferred` without an issue number, an item without a backticked path, an item with no
21
+ # disposition, and a missing or empty section are all invalid.
22
+ #
23
+ # `list` prints disposition as one of: deferred, not-affected, accepted-risk. The issue column is
24
+ # the number for deferred rows and empty otherwise. A trailing `/` is stripped from paths. `*`
25
+ # is not a glob: consumers match literally (equal, or under the path as a directory prefix).
26
+ # `list` on an invalid section prints nothing and exits 1 (fail toward filing in review).
27
+ #
28
+ # Exit: 0 ok; 1 invalid section; 2 usage error. Portable: bash 3.2, no network.
29
+
30
+ set -u
31
+
32
+ usage() { echo "ERROR: Usage: check-contract-scope.sh validate|list [<file>]" >&2; exit 2; }
33
+
34
+ MODE="${1:-}"
35
+ case "$MODE" in validate|list) ;; *) usage ;; esac
36
+ [ "$#" -le 2 ] || usage
37
+ if [ "$#" -eq 2 ]; then
38
+ [ -r "$2" ] || { echo "ERROR: not readable: $2" >&2; exit 2; }
39
+ BODY=$(cat "$2")
40
+ else
41
+ BODY=$(cat)
42
+ fi
43
+
44
+ SECTION=$(printf '%s\n' "$BODY" | tr -d '\r' | awk '
45
+ /^### Out of Scope[[:space:]]*$/ { p = 1; found = 1; next }
46
+ p && /^### / { p = 0 }
47
+ p && /^> Pipeline powered by/ { p = 0 }
48
+ p && /^<!--/ { p = 0 }
49
+ p { print }
50
+ END { if (!found) exit 3 }
51
+ ')
52
+ RC=$?
53
+ if [ "$RC" -ne 0 ]; then
54
+ echo "invalid: no '### Out of Scope' section" >&2
55
+ exit 1
56
+ fi
57
+
58
+ ROWS=""
59
+ ERRS=""
60
+ COUNT=0
61
+ NONE=0
62
+ ITEM=""
63
+
64
+ flush_item() {
65
+ [ -n "$ITEM" ] || return 0
66
+ COUNT=$((COUNT + 1))
67
+ _text=$(printf '%s' "$ITEM" | sed -E 's/deferred[[:space:]]*(→|->)[[:space:]]*/deferred → /g')
68
+ # Earliest disposition keyword wins; the prefix before it holds the paths.
69
+ _pre_n="${_text%%not-affected:*}"
70
+ _pre_a="${_text%%accepted-risk:*}"
71
+ _pre_d="${_text%%deferred → *}"
72
+ _disp=""; _pre="$_text"
73
+ if [ "$_pre_n" != "$_text" ]; then _disp="not-affected"; _pre="$_pre_n"; fi
74
+ if [ "$_pre_a" != "$_text" ] && { [ -z "$_disp" ] || [ "${#_pre_a}" -lt "${#_pre}" ]; }; then _disp="accepted-risk"; _pre="$_pre_a"; fi
75
+ if [ "$_pre_d" != "$_text" ] && { [ -z "$_disp" ] || [ "${#_pre_d}" -lt "${#_pre}" ]; }; then _disp="deferred"; _pre="$_pre_d"; fi
76
+ _issue=""
77
+ if [ -z "$_disp" ]; then
78
+ ERRS="${ERRS}untyped item (needs deferred → #N, not-affected:, or accepted-risk:): ${ITEM}
79
+ "
80
+ ITEM=""; return 0
81
+ fi
82
+ if [ "$_disp" = "deferred" ]; then
83
+ _rest="${_text#*deferred → }"
84
+ if [[ "$_rest" =~ ^#([0-9]+) ]]; then
85
+ _issue="${BASH_REMATCH[1]}"
86
+ else
87
+ ERRS="${ERRS}deferred item has no issue number (needs deferred → #N): ${ITEM}
88
+ "
89
+ ITEM=""; return 0
90
+ fi
91
+ fi
92
+ _paths=$(printf '%s' "$_pre" | grep -o '`[^`][^`]*`' | tr -d '`')
93
+ if [ -z "$_paths" ]; then
94
+ ERRS="${ERRS}item names no backticked path or symbol: ${ITEM}
95
+ "
96
+ ITEM=""; return 0
97
+ fi
98
+ while IFS= read -r _p; do
99
+ [ -n "$_p" ] || continue
100
+ _p="${_p%/}"
101
+ ROWS="${ROWS}${_disp} ${_p} ${_issue}
102
+ "
103
+ done <<EOP
104
+ $_paths
105
+ EOP
106
+ ITEM=""
107
+ }
108
+
109
+ while IFS= read -r line; do
110
+ trimmed=$(printf '%s' "$line" | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//')
111
+ [ -n "$trimmed" ] || continue
112
+ case "$trimmed" in
113
+ "None."|"- None."|"* None.") NONE=1; continue ;;
114
+ "- "*|"* "*)
115
+ flush_item
116
+ ITEM=$(printf '%s' "$trimmed" | sed -E 's/^[-*][[:space:]]+//') ;;
117
+ *)
118
+ if [ -n "$ITEM" ]; then ITEM="${ITEM} ${trimmed}"
119
+ else ERRS="${ERRS}unexpected text outside a bullet: ${trimmed}
120
+ "; fi ;;
121
+ esac
122
+ done <<EOS
123
+ $SECTION
124
+ EOS
125
+ flush_item
126
+
127
+ if [ "$COUNT" -eq 0 ] && [ "$NONE" -eq 0 ] && [ -z "$ERRS" ]; then
128
+ ERRS="empty '### Out of Scope' section (write None. or typed items)
129
+ "
130
+ fi
131
+
132
+ if [ -n "$ERRS" ]; then
133
+ printf 'invalid: %s' "$ERRS" >&2
134
+ exit 1
135
+ fi
136
+ [ "$MODE" = "list" ] && printf '%s' "$ROWS"
137
+ exit 0
@@ -0,0 +1,83 @@
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/check-contract-scope.sh (typed `### Out of Scope` grammar).
6
+ # Run: bash scripts/check-contract-scope.test.sh (bash 3.2 compatible)
7
+
8
+ set -u
9
+ DIR=$(cd "$(dirname "$0")" && pwd)
10
+ S="$DIR/check-contract-scope.sh"
11
+ pass=0; fail=0
12
+ TAB=$(printf '\t')
13
+
14
+ mk() { printf '## Builder Contract\n\n### Deliverables\n\n| a | b |\n\n### Out of Scope\n\n%s\n\n> Pipeline powered by [ForgeDock](https://example.invalid)\n' "$1"; }
15
+
16
+ # check <valid|invalid> <description> <section-text>
17
+ check() {
18
+ want="$1"; desc="$2"; body=$(mk "$3")
19
+ printf '%s\n' "$body" | bash "$S" validate >/dev/null 2>&1; got=$?
20
+ if { [ "$want" = valid ] && [ "$got" = 0 ]; } || { [ "$want" = invalid ] && [ "$got" = 1 ]; }; then pass=$((pass+1)); else fail=$((fail+1)); echo "FAIL: $desc (want $want, exit $got)"; fi
21
+ }
22
+ # list_is <description> <section-text> <expected TSV (printf-style, \t and \n)>
23
+ list_is() {
24
+ desc="$1"; body=$(mk "$2"); want=$(printf "$3")
25
+ got=$(printf '%s\n' "$body" | bash "$S" list 2>/dev/null)
26
+ if [ "$got" = "$want" ]; then pass=$((pass+1)); else fail=$((fail+1)); echo "FAIL: $desc"; echo " want: $want"; echo " got: $got"; fi
27
+ }
28
+
29
+ check valid "None. accepted" 'None.'
30
+ check valid "deferred with issue" '- `scripts/a.sh` — deferred → #12: tracked there'
31
+ check valid "not-affected with evidence" '- `bin/engine/` — not-affected: no reader found by grep'
32
+ check valid "accepted-risk with reason" '- `pkg/types.js` — accepted-risk: body-only format'
33
+ check valid "ASCII arrow accepted" '- `a.sh` — deferred -> #7: later'
34
+ check valid "no-space arrow accepted" '- `a.sh` — deferred→#7: later'
35
+ check valid "three mixed items" '- `a/` — not-affected: x
36
+ - `b.js` — accepted-risk: y
37
+ - `c.md` — deferred → #3446: z'
38
+ check valid "continuation line joins item" '- `a.sh` — deferred → #5:
39
+ because of reasons'
40
+ check invalid "untyped bullet rejected" '- `a.sh` — out of scope for now'
41
+ check invalid "deferred without number rejected" '- `a.sh` — deferred: later'
42
+ check invalid "deferred arrow without number" '- `a.sh` — deferred → TBD: later'
43
+ check invalid "no backticked path rejected" '- the sync script — deferred → #9: later'
44
+ check invalid "bare prose rejected" 'Nothing relevant.'
45
+ check invalid "one bad item among good ones" '- `a` — not-affected: ok
46
+ - `b` — nope'
47
+ check invalid "empty section rejected" ''
48
+
49
+ # Missing section
50
+ printf '## Builder Contract\n\n### Deliverables\n\nx\n' | bash "$S" validate >/dev/null 2>&1; got=$?
51
+ if [ "$got" = 1 ]; then pass=$((pass+1)); else fail=$((fail+1)); echo "FAIL: missing section (want exit 1, got $got)"; fi
52
+
53
+ list_is "list rows for three forms" '- `a/` — not-affected: x
54
+ - `b.js` — accepted-risk: y
55
+ - `c.md` — deferred → #3446: z' "not-affected${TAB}a${TAB}\naccepted-risk${TAB}b.js${TAB}\ndeferred${TAB}c.md${TAB}3446"
56
+ list_is "multi-path item emits one row per path" '- `x.sh`, `y.sh` — deferred → #8: both' "deferred${TAB}x.sh${TAB}8\ndeferred${TAB}y.sh${TAB}8"
57
+ list_is "why-text with other keywords does not change disposition" '- `a` — accepted-risk: not-affected elsewhere, deferred → #1 is separate' "accepted-risk${TAB}a${TAB}"
58
+ list_is "None. lists nothing" 'None.' ""
59
+ list_is "invalid section lists nothing" '- `a` — untyped' ""
60
+
61
+ # Section ends at the next ### heading
62
+ printf '### Out of Scope\n\n- `a` — not-affected: x\n\n### Quality\n\n- plain bullet\n' | bash "$S" validate >/dev/null 2>&1; got=$?
63
+ if [ "$got" = 0 ]; then pass=$((pass+1)); else fail=$((fail+1)); echo "FAIL: section must end at next ### (exit $got)"; fi
64
+
65
+ # Live-shaped contract (mixed items, trailing attribution line)
66
+ LIVE=$(mk '- `bin/engine/` — not-affected: no engine module reads the contract Out of Scope section (grep in investigation).
67
+ - `packages/protocol/src/types.js` — accepted-risk: CONTRACT requiredFields stays [Task type].
68
+ - `commands/work-on/build/architect.md` — deferred → #3446: blast-radius manifest is tracked there.')
69
+ got=$(printf '%s\n' "$LIVE" | bash "$S" list 2>/dev/null | wc -l | tr -d ' ')
70
+ if [ "$got" = 3 ]; then pass=$((pass+1)); else fail=$((fail+1)); echo "FAIL: live-shaped contract rows (want 3, got $got)"; fi
71
+
72
+ # File argument and usage errors
73
+ TMPF=$(mktemp "${TMPDIR:-/tmp}/ccs.XXXXXX"); printf '%s\n' "$LIVE" > "$TMPF"
74
+ bash "$S" validate "$TMPF" >/dev/null 2>&1; got=$?
75
+ if [ "$got" = 0 ]; then pass=$((pass+1)); else fail=$((fail+1)); echo "FAIL: file argument (exit $got)"; fi
76
+ rm -f "$TMPF"
77
+ bash "$S" validate /nonexistent/x >/dev/null 2>&1; got=$?
78
+ if [ "$got" = 2 ]; then pass=$((pass+1)); else fail=$((fail+1)); echo "FAIL: unreadable file (want 2, got $got)"; fi
79
+ bash "$S" bogus </dev/null >/dev/null 2>&1; got=$?
80
+ if [ "$got" = 2 ]; then pass=$((pass+1)); else fail=$((fail+1)); echo "FAIL: bad mode (want 2, got $got)"; fi
81
+
82
+ echo "check-contract-scope.test.sh: passed=$pass failed=$fail"
83
+ [ "$fail" -eq 0 ]
@@ -13,6 +13,7 @@
13
13
  # Usage:
14
14
  # check-spec-bash.sh [--base <git-ref>] [--shellcheck] <file.md>...
15
15
  # check-spec-bash.sh --positional [<file.md>...]
16
+ # check-spec-bash.sh --fence-state <VAR>... -- <file.md>...
16
17
  # check-spec-bash.sh (no args: same as --positional over all Skill-loaded commands/**/*.md,
17
18
  # excluding orchestrate/**, pipeline-health/**, review-pr-agents/**)
18
19
  #
@@ -25,6 +26,13 @@
25
26
  # Scope the file list to Skill-loaded specs (Read-loaded orchestrate/**,
26
27
  # pipeline-health/** and the review-pr-agents catalog are not substituted).
27
28
  # Output: `FAIL <file>:<line> positional arg ...` then `SPEC-POSITIONAL: ...`.
29
+ # --fence-state Different check, opt-in: every Bash tool call is a fresh shell, so a variable bound
30
+ # in one fence is empty in the next. Fail any ```bash/sh fence that reads `$VAR` or
31
+ # `${VAR...}` for a listed VAR without assigning it in the same fence (`VAR=`,
32
+ # `export VAR=`, `read VAR`, `for VAR in`) at or before its first read. Comment-only lines are ignored; `$REPO`
33
+ # does not match `$REPO_FLAG`. Suppress a deliberate read by putting
34
+ # `allowlist:fence-state` on the same line. Pseudo-tool-call fences are scanned too.
35
+ # Output: `FAIL <file>:<line> fence-state ...` then `SPEC-FENCE-STATE: ...`.
28
36
  # --base <ref> Only check blocks that contain a line changed relative to <ref>
29
37
  # (`git diff -U0 <ref> -- <file>`), so pre-existing blocks never fail
30
38
  # a new change. Without --base every block is checked.
@@ -45,14 +53,22 @@
45
53
 
46
54
  set -u
47
55
 
48
- BASE=""; SHELLCHECK=0; POSITIONAL=0; FILES=""
49
- usage() { echo "ERROR: Usage: check-spec-bash.sh [--base <git-ref>] [--shellcheck] <file.md>... | --positional [<file.md>...]" >&2; exit 2; }
56
+ BASE=""; SHELLCHECK=0; POSITIONAL=0; FILES=""; FENCESTATE=0; FSVARS=""
57
+ usage() { echo "ERROR: Usage: check-spec-bash.sh [--base <git-ref>] [--shellcheck] <file.md>... | --positional [<file.md>...] | --fence-state <VAR>... -- <file.md>..." >&2; exit 2; }
50
58
 
51
59
  while [ "$#" -gt 0 ]; do
52
60
  case "$1" in
53
61
  --base) [ "$#" -ge 2 ] || usage; BASE="$2"; shift 2 ;;
54
62
  --shellcheck) SHELLCHECK=1; shift ;;
55
63
  --positional) POSITIONAL=1; shift ;;
64
+ --fence-state)
65
+ FENCESTATE=1; shift
66
+ while [ "$#" -gt 0 ] && [ "$1" != "--" ]; do
67
+ case "$1" in -*|*[!A-Za-z0-9_]*) usage ;; esac
68
+ FSVARS="$FSVARS $1"; shift
69
+ done
70
+ [ "$#" -gt 0 ] && [ -n "$FSVARS" ] || usage
71
+ shift ;;
56
72
  --*) usage ;;
57
73
  *) FILES="$FILES
58
74
  $1"; shift ;;
@@ -62,7 +78,7 @@ done
62
78
  # No file arguments: default to the --positional check over every Skill-loaded spec under commands/
63
79
  # (orchestrate/**, pipeline-health/** and the review-pr-agents catalog are Read-loaded, not substituted).
64
80
  # Resolved relative to this script, so it works from any cwd and from linked worktrees.
65
- if [ -z "$FILES" ] && { [ "$#" -eq 0 ] && [ "$POSITIONAL" = 0 ] && [ -z "$BASE" ] && [ "$SHELLCHECK" = 0 ] || [ "$POSITIONAL" = 1 ]; }; then
81
+ if [ -z "$FILES" ] && [ "$FENCESTATE" = 0 ] && { [ "$#" -eq 0 ] && [ "$POSITIONAL" = 0 ] && [ -z "$BASE" ] && [ "$SHELLCHECK" = 0 ] || [ "$POSITIONAL" = 1 ]; }; then
66
82
  POSITIONAL=1
67
83
  cd "$(dirname "$0")/.." || exit 2
68
84
  FILES=$(find commands -name '*.md' -not -path 'commands/orchestrate/*' \
@@ -70,6 +86,47 @@ if [ -z "$FILES" ] && { [ "$#" -eq 0 ] && [ "$POSITIONAL" = 0 ] && [ -z "$BASE"
70
86
  fi
71
87
  [ -n "$FILES" ] || usage
72
88
 
89
+ if [ "$FENCESTATE" = 1 ]; then
90
+ sfail=0; sfiles=0
91
+ while IFS= read -r f; do
92
+ [ -n "$f" ] || continue
93
+ case "$f" in *.md) ;; *) continue ;; esac
94
+ [ -f "$f" ] || { echo "SKIP $f missing"; continue; }
95
+ sfiles=$((sfiles + 1))
96
+ hits=$(awk -v f="$f" -v vars="$FSVARS" '
97
+ function flush( i, v, ln) {
98
+ for (i = 1; i <= nv; i++) {
99
+ v = vn[i]
100
+ if (first[v] != "" && !asg[v]) print "FAIL " f ":" first[v] " fence-state: $" v " read in a fence before it is assigned (fresh shell per Bash call): " firsttxt[v]
101
+ }
102
+ }
103
+ function reset( i) { for (i = 1; i <= nv; i++) { asg[vn[i]] = 0; first[vn[i]] = ""; firsttxt[vn[i]] = "" } }
104
+ BEGIN { nv = split(vars, vn, " ") }
105
+ !inb && /^[ \t]*```(bash|sh)[ \t]*$/ { inb = 1; reset(); next }
106
+ !inb && /^[ \t]*```/ { other = !other; next }
107
+ other { next }
108
+ inb && /^[ \t]*```[ \t]*$/ { flush(); inb = 0; next }
109
+ inb {
110
+ if ($0 ~ /^[ \t]*#/) next
111
+ for (i = 1; i <= nv; i++) {
112
+ v = vn[i]
113
+ if (first[v] == "" && ($0 ~ ("(^|[;&|( \t])(export[ \t]+|local[ \t]+|declare[ \t]+(-[A-Za-z]+[ \t]+)?|readonly[ \t]+)?" v "=") ||
114
+ $0 ~ ("(^|[;&|( \t])read[ \t]+(-[A-Za-z]+[ \t]+)*([A-Za-z0-9_]+[ \t]+)*" v "([ \t;]|$)") ||
115
+ $0 ~ ("(^|[;&|( \t])for[ \t]+" v "[ \t]+in[ \t]"))) asg[v] = 1
116
+ if (first[v] == "" && $0 !~ /allowlist:fence-state/ &&
117
+ ($0 ~ ("\\$" v "([^A-Za-z0-9_]|$)") || $0 ~ ("\\$\\{" v "([^A-Za-z0-9_]|$)"))) { first[v] = NR; firsttxt[v] = $0 }
118
+ }
119
+ }
120
+ ' "$f")
121
+ if [ -n "$hits" ]; then printf '%s\n' "$hits"; sfail=$((sfail + $(printf '%s\n' "$hits" | wc -l))); fi
122
+ done <<EOS
123
+ $FILES
124
+ EOS
125
+ echo "SPEC-FENCE-STATE: files=$sfiles violations=$sfail"
126
+ [ "$sfail" -eq 0 ]
127
+ exit $?
128
+ fi
129
+
73
130
  if [ "$POSITIONAL" = 1 ]; then
74
131
  pfail=0; pfiles=0
75
132
  while IFS= read -r f; do
@@ -109,5 +109,61 @@ check 1 "FAIL pos-bad.md:3" "--positional flags shell \$1"
109
109
  check 1 "violations=2" "--positional counts violations" -- --positional pos-bad.md
110
110
  check 0 "violations=0" "--positional passes safe forms, prose, allowlist" -- --positional pos-good.md
111
111
 
112
+ # --fence-state: a listed var read in a fence without a prior assignment in that same fence is flagged.
113
+ cat > "$T/fs-bad.md" <<EOF
114
+ ${FENCE}bash
115
+ gh pr view "\$PR_NUMBER" -R "\$REPO" --json state
116
+ ${FENCE}
117
+ ${FENCE}bash
118
+ X=\$(gh pr diff \${PR_NUMBER} --name-only)
119
+ ${FENCE}
120
+ ${FENCE}bash
121
+ echo "\${REPO:-none}"
122
+ ${FENCE}
123
+ ${FENCE}bash
124
+ gh pr view "\$PR_NUMBER"
125
+ PR_NUMBER="{PR_NUMBER}"
126
+ ${FENCE}
127
+ EOF
128
+ cat > "$T/fs-good.md" <<EOF
129
+ Prose mentions \$PR_NUMBER and \$REPO freely.
130
+ ${FENCE}bash
131
+ # comment reads \$PR_NUMBER and \$REPO
132
+ PR_NUMBER="{PR_NUMBER}"; REPO="{GH_REPO}"
133
+ gh pr view "\$PR_NUMBER" -R "\$REPO"
134
+ ${FENCE}
135
+ ${FENCE}bash
136
+ export REPO="{GH_REPO}"
137
+ for PR_NUMBER in 1 2; do echo "\${PR_NUMBER}"; done
138
+ echo "\$REPO"
139
+ ${FENCE}
140
+ ${FENCE}bash
141
+ REPO="\${REPO:-\$(gh repo view)}"
142
+ ${FENCE}
143
+ ${FENCE}bash
144
+ gh pr view \${REPO_FLAG} "\$OTHER"
145
+ ${FENCE}
146
+ ${FENCE}bash
147
+ echo "\$PR_NUMBER" # allowlist:fence-state
148
+ ${FENCE}
149
+ ${FENCE}text
150
+ echo \$PR_NUMBER \$REPO
151
+ ${FENCE}
152
+ ${FENCE}bash
153
+ PR_NUMBER="{PR_NUMBER}"
154
+ OUT=\$(Skill(skill="x", args="\$PR_NUMBER"))
155
+ ${FENCE}
156
+ EOF
157
+ check 1 "FAIL fs-bad.md:2 fence-state: \$PR_NUMBER" "--fence-state flags \$VAR read" -- --fence-state PR_NUMBER REPO -- fs-bad.md
158
+ check 1 "FAIL fs-bad.md:2 fence-state: \$REPO" "--fence-state flags each listed var" -- --fence-state PR_NUMBER REPO -- fs-bad.md
159
+ check 1 "FAIL fs-bad.md:5 fence-state: \$PR_NUMBER" "--fence-state flags \${VAR} form" -- --fence-state PR_NUMBER REPO -- fs-bad.md
160
+ check 1 "FAIL fs-bad.md:8 fence-state: \$REPO" "--fence-state flags \${VAR:-default}" -- --fence-state PR_NUMBER REPO -- fs-bad.md
161
+ check 1 "FAIL fs-bad.md:11 fence-state: \$PR_NUMBER" "--fence-state flags read before assignment" -- --fence-state PR_NUMBER REPO -- fs-bad.md
162
+ check 1 "violations=5" "--fence-state counts violations" -- --fence-state PR_NUMBER REPO -- fs-bad.md
163
+ check 0 "violations=0" "--fence-state ignores unlisted vars" -- --fence-state UNLISTED -- fs-bad.md
164
+ check 0 "violations=0" "--fence-state passes declared, loop, export, REPO_FLAG, allowlist, comments, prose, non-bash fences" -- --fence-state PR_NUMBER REPO -- fs-good.md
165
+ check 2 "Usage" "--fence-state without -- is a usage error" -- --fence-state PR_NUMBER fs-good.md
166
+ check 2 "Usage" "--fence-state without vars is a usage error" -- --fence-state -- fs-good.md
167
+
112
168
  echo "check-spec-bash.test.sh: passed=$pass failed=$fail"
113
169
  [ "$fail" -eq 0 ]
@@ -88,6 +88,7 @@ MARKER_REGISTRY="
88
88
  BASESYNC_REMEDIATION
89
89
  BATCH_MEMBERS
90
90
  BENCH_SCORECARD
91
+ BLAST_RADIUS
91
92
  BLOCKED_ON_HUMAN_MERGE
92
93
  BODY
93
94
  BUILDER
@@ -106,6 +107,7 @@ MARKER_REGISTRY="
106
107
  DECISION_RECORD
107
108
  DECOMPOSED
108
109
  DECOMPOSE_BLOCKED
110
+ DEFERRED_FROM
109
111
  DESIGN
110
112
  DIFF_SIZE
111
113
  DISPATCH
@@ -416,6 +418,33 @@ if [ -f "$WORKON_SPEC" ]; then
416
418
  fi
417
419
  fi
418
420
 
421
+ # ---------------------------------------------------------------------------
422
+ # Re-review claim id must be printed by both producers (forge#3472)
423
+ # ---------------------------------------------------------------------------
424
+ # A claim id generated in one bash block and compared in a later one is lost unless the producer prints it.
425
+ # Orchestrator: the Step 2 claim block's final echo must carry CLAIM_ID=. Router: Claim pre-check step 3 must
426
+ # require printing CLAIM_ID=. Scoped to the Step 2 / Claim pre-check text only; full-line HTML comments skipped.
427
+ # WIRE:PROVEN: manual mutation in a temp copy: removing CLAIM_ID= from the Step 2 echo fired the orchestrator check, removing it from router step 3 fired the router check, clean tree passes
428
+ P4_SPEC="$COMMANDS_DIR/orchestrate/phase-4-execution.md"
429
+ if [ -f "$P4_SPEC" ]; then
430
+ STEP2_ECHO=$(awk '
431
+ /Step 2 — dispatch/ { on = 1 }
432
+ /Step 3 — outcome/ { on = 0 }
433
+ on && !/^[[:space:]]*<!--.*-->[[:space:]]*$/ && /^[[:space:]]*echo "REREVIEW_MARKER=/ { print }
434
+ ' "$P4_SPEC" || true)
435
+ if ! printf '%s\n' "$STEP2_ECHO" | grep -qF 'CLAIM_ID='; then
436
+ echo "HIGH | $P4_SPEC | Step 2 claim block's final echo does not print CLAIM_ID= (own claim id is lost across bash blocks)" >&2
437
+ VIOLATIONS=$((VIOLATIONS + 1))
438
+ fi
439
+ fi
440
+ if [ -f "$WORKON_SPEC" ]; then
441
+ ROUTER_STEP3=$(printf '%s\n' "${PRECHECK_STEPS:-}" | grep '^STEP3:' || true)
442
+ if [ -z "$ROUTER_STEP3" ] || [[ "$ROUTER_STEP3" != *CLAIM_ID=* ]]; then
443
+ echo "HIGH | $WORKON_SPEC | claim pre-check step 3 does not require printing CLAIM_ID= (router own claim id is lost across bash blocks)" >&2
444
+ VIOLATIONS=$((VIOLATIONS + 1))
445
+ fi
446
+ fi
447
+
419
448
  # ---------------------------------------------------------------------------
420
449
  # Summary
421
450
  # ---------------------------------------------------------------------------