forgedock 1.13.1 → 1.14.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 (34) hide show
  1. package/bin/engine/phases.mjs +160 -18
  2. package/bin/engine-cli.mjs +18 -5
  3. package/bin/engine.mjs +109 -6
  4. package/bin/forgedock.mjs +4 -1
  5. package/commands/orchestrate/phase-3-dependency.md +5 -2
  6. package/commands/orchestrate/phase-4-execution.md +144 -24
  7. package/commands/pipeline-health/phase-2-metrics.md +5 -3
  8. package/commands/review-pr-staging.md +289 -39
  9. package/commands/review-pr.md +604 -42
  10. package/commands/validate.md +11 -3
  11. package/commands/work-on/build/architect.md +11 -8
  12. package/commands/work-on/build.md +27 -6
  13. package/commands/work-on/close.md +5 -4
  14. package/commands/work-on/decompose.md +7 -2
  15. package/commands/work-on/investigate.md +11 -0
  16. package/commands/work-on/remediate.md +135 -74
  17. package/commands/work-on/review.md +3 -3
  18. package/docs/CONFIG.md +14 -1
  19. package/docs/RELEASE-NOTES.md +8 -0
  20. package/package.json +1 -1
  21. package/scripts/architect-resume-match.test.sh +53 -0
  22. package/scripts/check-spec-bash.sh +73 -3
  23. package/scripts/check-spec-bash.test.sh +50 -0
  24. package/scripts/checkbox-sections.test.sh +19 -0
  25. package/scripts/classify-finding.sh +96 -1
  26. package/scripts/classify-finding.test.sh +47 -0
  27. package/scripts/forge-root.test.sh +48 -1
  28. package/scripts/orchestrate-soft-edge.test.sh +58 -0
  29. package/scripts/remediate-basesync.test.sh +62 -3
  30. package/scripts/review-delta.sh +3 -2
  31. package/scripts/review-delta.test.sh +1 -1
  32. package/scripts/review-pr-delta-wiring.test.sh +266 -0
  33. package/scripts/review-pr-staging-scope.test.sh +60 -0
  34. package/scripts/trusted-comments.test.sh +11 -0
@@ -14,6 +14,7 @@
14
14
  # check-spec-bash.sh [--base <git-ref>] [--shellcheck] <file.md>...
15
15
  # check-spec-bash.sh --positional [<file.md>...]
16
16
  # check-spec-bash.sh --fence-state <VAR>... -- <file.md>...
17
+ # check-spec-bash.sh --fence-funcs -- <file.md>...
17
18
  # check-spec-bash.sh (no args: same as --positional over all Skill-loaded commands/**/*.md,
18
19
  # excluding orchestrate/**, pipeline-health/**, review-pr-agents/**)
19
20
  #
@@ -33,6 +34,13 @@
33
34
  # does not match `$REPO_FLAG`. Suppress a deliberate read by putting
34
35
  # `allowlist:fence-state` on the same line. Pseudo-tool-call fences are scanned too.
35
36
  # Output: `FAIL <file>:<line> fence-state ...` then `SPEC-FENCE-STATE: ...`.
37
+ # --fence-funcs Different check, opt-in: shell functions are per-shell too, so a function defined
38
+ # (`name() {`, `function name`) in one ```bash/sh fence is undefined in every other
39
+ # fence. Fail any fence that calls (command position) a function that the same file
40
+ # defines only in a DIFFERENT fence. A fence that carries its own definition (also
41
+ # `declare -f name >/dev/null || name() {...}`) is fine. Comment lines are ignored.
42
+ # Suppress a deliberate call with `allowlist:fence-funcs` on the same line.
43
+ # Output: `FAIL <file>:<line> fence-funcs ...` then `SPEC-FENCE-FUNCS: ...`.
36
44
  # --base <ref> Only check blocks that contain a line changed relative to <ref>
37
45
  # (`git diff -U0 <ref> -- <file>`), so pre-existing blocks never fail
38
46
  # a new change. Without --base every block is checked.
@@ -53,8 +61,8 @@
53
61
 
54
62
  set -u
55
63
 
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; }
64
+ BASE=""; SHELLCHECK=0; POSITIONAL=0; FILES=""; FENCESTATE=0; FSVARS=""; FENCEFUNCS=0
65
+ usage() { echo "ERROR: Usage: check-spec-bash.sh [--base <git-ref>] [--shellcheck] <file.md>... | --positional [<file.md>...] | --fence-state <VAR>... -- <file.md>... | --fence-funcs -- <file.md>..." >&2; exit 2; }
58
66
 
59
67
  while [ "$#" -gt 0 ]; do
60
68
  case "$1" in
@@ -69,6 +77,10 @@ while [ "$#" -gt 0 ]; do
69
77
  done
70
78
  [ "$#" -gt 0 ] && [ -n "$FSVARS" ] || usage
71
79
  shift ;;
80
+ --fence-funcs)
81
+ FENCEFUNCS=1; shift
82
+ [ "$#" -gt 0 ] && [ "$1" = "--" ] || usage
83
+ shift ;;
72
84
  --*) usage ;;
73
85
  *) FILES="$FILES
74
86
  $1"; shift ;;
@@ -78,7 +90,7 @@ done
78
90
  # No file arguments: default to the --positional check over every Skill-loaded spec under commands/
79
91
  # (orchestrate/**, pipeline-health/** and the review-pr-agents catalog are Read-loaded, not substituted).
80
92
  # Resolved relative to this script, so it works from any cwd and from linked worktrees.
81
- if [ -z "$FILES" ] && [ "$FENCESTATE" = 0 ] && { [ "$#" -eq 0 ] && [ "$POSITIONAL" = 0 ] && [ -z "$BASE" ] && [ "$SHELLCHECK" = 0 ] || [ "$POSITIONAL" = 1 ]; }; then
93
+ if [ -z "$FILES" ] && [ "$FENCESTATE" = 0 ] && [ "$FENCEFUNCS" = 0 ] && { [ "$#" -eq 0 ] && [ "$POSITIONAL" = 0 ] && [ -z "$BASE" ] && [ "$SHELLCHECK" = 0 ] || [ "$POSITIONAL" = 1 ]; }; then
82
94
  POSITIONAL=1
83
95
  cd "$(dirname "$0")/.." || exit 2
84
96
  FILES=$(find commands -name '*.md' -not -path 'commands/orchestrate/*' \
@@ -127,6 +139,64 @@ EOS
127
139
  exit $?
128
140
  fi
129
141
 
142
+ if [ "$FENCEFUNCS" = 1 ]; then
143
+ ffail=0; ffiles=0
144
+ while IFS= read -r f; do
145
+ [ -n "$f" ] || continue
146
+ case "$f" in *.md) ;; *) continue ;; esac
147
+ [ -f "$f" ] || { echo "SKIP $f missing"; continue; }
148
+ ffiles=$((ffiles + 1))
149
+ hits=$(awk -v f="$f" '
150
+ function isdef(line, name) {
151
+ return line ~ ("(^|[;&|{ \t])(function[ \t]+)?" name "[ \t]*[(][)]") || line ~ ("(^|[;&|{ \t])function[ \t]+" name "([ \t;{]|$)")
152
+ }
153
+ # A non-bash fence (prompt templates) can nest ```lang fences: track depth so they stay ignored.
154
+ other && /^[ \t]*```[A-Za-z]/ { depth++; next }
155
+ other && /^[ \t]*```[ \t]*$/ { if (depth > 0) depth--; else other = 0; next }
156
+ other { next }
157
+ !inb && /^[ \t]*```(bash|sh)[ \t]*$/ { inb = 1; nf++; next }
158
+ !inb && /^[ \t]*```/ { other = 1; depth = 0; next }
159
+ inb && /^[ \t]*```[ \t]*$/ { inb = 0; next }
160
+ inb {
161
+ n++; fen[n] = nf; ln[n] = NR; txt[n] = $0
162
+ if ($0 ~ /^[ \t]*#/) next
163
+ # Function definitions: `name()` or `function name`, wherever they sit on the line.
164
+ s = $0
165
+ while (match(s, /(^|[;&|{ \t])(function[ \t]+)?[A-Za-z_][A-Za-z0-9_]*[ \t]*[(][)]/)) {
166
+ d = substr(s, RSTART, RLENGTH); s = substr(s, RSTART + RLENGTH)
167
+ sub(/^[;&|{ \t]+/, "", d); sub(/^function[ \t]+/, "", d); sub(/[ \t]*[(][)]$/, "", d)
168
+ defn[d, nf] = 1; if (!(d in seen)) { seen[d] = 1; names[++nn] = d }
169
+ }
170
+ s = $0
171
+ while (match(s, /(^|[;&|{ \t])function[ \t]+[A-Za-z_][A-Za-z0-9_]*/)) {
172
+ d = substr(s, RSTART, RLENGTH); s = substr(s, RSTART + RLENGTH)
173
+ sub(/^[;&|{ \t]+/, "", d); sub(/^function[ \t]+/, "", d)
174
+ defn[d, nf] = 1; if (!(d in seen)) { seen[d] = 1; names[++nn] = d }
175
+ }
176
+ }
177
+ END {
178
+ for (k = 1; k <= nn; k++) {
179
+ name = names[k]
180
+ for (i = 1; i <= n; i++) {
181
+ if (defn[name, fen[i]] || reported[name, fen[i]]) continue
182
+ if (txt[i] ~ /^[ \t]*#/ || txt[i] ~ /allowlist:fence-funcs/) continue
183
+ if (txt[i] ~ ("(^[ \t]*|[;&|({][ \t]*|[$][(][ \t]*|(then|do|else|elif|if|while|until|!)[ \t]+)" name "([ \t;&|)]|$)")) {
184
+ reported[name, fen[i]] = 1
185
+ print "FAIL " f ":" ln[i] " fence-funcs: " name "() is called in a fence that does not define it (fresh shell per Bash call; defined only in another fence): " txt[i]
186
+ }
187
+ }
188
+ }
189
+ }
190
+ ' "$f" | LC_ALL=C sort -t: -k2,2n)
191
+ if [ -n "$hits" ]; then printf '%s\n' "$hits"; ffail=$((ffail + $(printf '%s\n' "$hits" | wc -l))); fi
192
+ done <<EOS
193
+ $FILES
194
+ EOS
195
+ echo "SPEC-FENCE-FUNCS: files=$ffiles violations=$ffail"
196
+ [ "$ffail" -eq 0 ]
197
+ exit $?
198
+ fi
199
+
130
200
  if [ "$POSITIONAL" = 1 ]; then
131
201
  pfail=0; pfiles=0
132
202
  while IFS= read -r f; do
@@ -165,5 +165,55 @@ check 0 "violations=0" "--fence-state passes declared, loop, e
165
165
  check 2 "Usage" "--fence-state without -- is a usage error" -- --fence-state PR_NUMBER fs-good.md
166
166
  check 2 "Usage" "--fence-state without vars is a usage error" -- --fence-state -- fs-good.md
167
167
 
168
+ # --fence-funcs: a function defined only in another fence, then called, is flagged.
169
+ cat > "$T/ff-bad.md" <<EOF
170
+ ${FENCE}bash
171
+ helper() {
172
+ echo hi
173
+ }
174
+ helper
175
+ ${FENCE}
176
+ ${FENCE}bash
177
+ X=\$(helper arg)
178
+ ${FENCE}
179
+ ${FENCE}bash
180
+ if helper; then :; fi
181
+ ${FENCE}
182
+ EOF
183
+ cat > "$T/ff-good.md" <<EOF
184
+ Prose calls helper and mentions helper() freely.
185
+ ${FENCE}bash
186
+ helper() { echo hi; }
187
+ function other_fn { :; }
188
+ helper
189
+ ${FENCE}
190
+ ${FENCE}bash
191
+ declare -f helper >/dev/null 2>&1 || helper() { echo hi; }
192
+ helper
193
+ ${FENCE}
194
+ ${FENCE}bash
195
+ # helper is only named in a comment
196
+ declare -f helper >/dev/null 2>&1
197
+ echo "call helper later"
198
+ git log --grep helper
199
+ helper # allowlist:fence-funcs
200
+ ${FENCE}
201
+ ${FENCE}text
202
+ helper
203
+ ${FENCE}
204
+ ${FENCE}
205
+ Template with a nested fence:
206
+ ${FENCE}bash
207
+ helper
208
+ ${FENCE}
209
+ ${FENCE}
210
+ EOF
211
+ check 1 "FAIL ff-bad.md:8 fence-funcs: helper()" "--fence-funcs flags a call in a fence without the definition" -- --fence-funcs -- ff-bad.md
212
+ check 1 "FAIL ff-bad.md:11 fence-funcs: helper()" "--fence-funcs flags a call after if" -- --fence-funcs -- ff-bad.md
213
+ check 1 "violations=2" "--fence-funcs counts violations" -- --fence-funcs -- ff-bad.md
214
+ check 0 "violations=0" "--fence-funcs passes same-fence defs, guarded redefinition, comments, quoted/arg uses, allowlist, prose, non-bash fences" -- --fence-funcs -- ff-good.md
215
+ check 2 "Usage" "--fence-funcs without -- is a usage error" -- --fence-funcs ff-good.md
216
+ check 2 "Usage" "--fence-funcs without files is a usage error" -- --fence-funcs --
217
+
168
218
  echo "check-spec-bash.test.sh: passed=$pass failed=$fail"
169
219
  [ "$fail" -eq 0 ]
@@ -49,4 +49,23 @@ SINGLE_BODY=$'## Acceptance Criteria\n- [ ] complete this work'
49
49
  exit 1
50
50
  }
51
51
 
52
+ # forge#3545: the investigate placeholder box is scaffolding and must not form a section.
53
+ PLACEHOLDER_SECOND=$'## Success Criteria\n- [ ] real work\n\n## Acceptance Criteria\n- [ ] Fix confirmed during investigation.'
54
+ [[ "$(printf '%s\n' "$PLACEHOLDER_SECOND" | awk "$CLOSE_CLASSIFIER")" == "1" ]] || {
55
+ printf 'FAIL: placeholder box must not count as a second checkbox section\n' >&2
56
+ exit 1
57
+ }
58
+ PLACEHOLDER_ONLY=$'## Acceptance Criteria\n- [ ] Fix confirmed during investigation.'
59
+ [[ "$(printf '%s\n' "$PLACEHOLDER_ONLY" | awk "$CLOSE_CLASSIFIER")" == "0" ]] || {
60
+ printf 'FAIL: placeholder-only body must count zero checkbox sections\n' >&2
61
+ exit 1
62
+ }
63
+ grep -Fq 'Fix confirmed during investigation' "$ROOT/commands/work-on/close.md" || {
64
+ printf 'FAIL: close.md must exclude the investigate placeholder\n' >&2
65
+ exit 1
66
+ }
67
+ # REMAINING_BEFORE must also ignore the placeholder (placeholder-only body => 0).
68
+ REM=$(printf '%s\n' "$PLACEHOLDER_ONLY" | grep -vE '^[-*+] \[ \] Fix confirmed during investigation\.?[[:space:]]*$' | grep -cE '^[-*+] \[ \]' || true)
69
+ [[ "$REM" == "0" ]] || { printf 'FAIL: placeholder counted in REMAINING_BEFORE\n' >&2; exit 1; }
70
+
52
71
  printf 'PASS: close-path classifier supports setext section boundaries\n'
@@ -11,6 +11,7 @@
11
11
  # classify-finding.sh --severity <S> [--confidence <C>] [--agent <A>]
12
12
  # [--lineage none|review-finding] [--text <T> | --text-file <F>]
13
13
  # [--inpr-diff <F> --file <P>] [--contract-scope <F> --contract-open <F> [--pr-files <F>] [--merge-issue <N>]]
14
+ # [--prior-findings <F> [--title <T>] [--line-start <N> [--line-end <M>]] [--diff-map <F>]]
14
15
  #
15
16
  # --severity CRITICAL | HIGH | MEDIUM | LOW (case-insensitive). Missing or
16
17
  # unparseable → ISSUE (fail toward filing, never drop).
@@ -45,6 +46,23 @@
45
46
  # `--pr-files <F>` is the PR's changed paths, one per line, supplied for this guard
46
47
  # alone (it never triggers INPR_FIX). The gate runs after the severity and in-PR gates. Without the flags output is unchanged.
47
48
  #
49
+ # --prior-findings <F> --file <P> [--title <T>] [--line-start <N> [--line-end <M>]] [--diff-map <F>]
50
+ # Cross-head / cross-PR re-flag gate: F is a TSV of findings already raised for an
51
+ # earlier head of this PR or by a bundle's member PRs, one per line, columns
52
+ # file<TAB>line-start<TAB>line-end<TAB>title<TAB>prior-disposition<TAB>issue#.
53
+ # prior-disposition is `filed` (its issue is still open), `filed-closed` (issue closed:
54
+ # the row is IGNORED so a regressed defect is classified normally) or `note`.
55
+ # Line columns may be empty (NOTE-sourced rows carry no range). A finding re-flags a row
56
+ # when it is the same file AND ((line IoU >= 0.6 and >= 1 shared title token), or >= 3 shared title tokens). Output is
57
+ # `NOTE reflag-filed #N` (row has an issue number) or `NOTE reflag-note`; this gate never
58
+ # emits ISSUE. `--file` may carry `path:line`; explicit --line-start wins.
59
+ # `--diff-map <F>` (optional, best-effort) lists `git diff <old>..<new>` hunks as
60
+ # file<TAB>old-start<TAB>old-count<TAB>new-start<TAB>new-count; a prior range lying
61
+ # wholly after hunks is shifted by their net line delta before the IoU. A missing or
62
+ # unparseable map means the unshifted range is used. The gate runs after the severity
63
+ # parse (HIGH/CRITICAL and unparseable severity stay ISSUE) and before the in-PR gate.
64
+ # Without the flags output is unchanged.
65
+ #
48
66
  # Output (stdout, one line): `ISSUE <reason>`, `NOTE <reason>` or `INPR_FIX <reason>`.
49
67
  # Exit codes: 0 classified, 2 usage error.
50
68
  #
@@ -80,9 +98,14 @@ CONTRACT_SCOPE=""
80
98
  CONTRACT_OPEN=""
81
99
  PR_FILES=""
82
100
  MERGE_ISSUE=""
101
+ PRIOR_FINDINGS=""
102
+ TITLE=""
103
+ LINE_START=""
104
+ LINE_END=""
105
+ DIFF_MAP=""
83
106
 
84
107
  usage() {
85
- echo "ERROR: Usage: classify-finding.sh --severity <S> [--confidence <C>] [--agent <A>] [--lineage none|review-finding] [--text <T> | --text-file <F>] [--inpr-diff <F> --file <P>] [--contract-scope <F> --contract-open <F> [--pr-files <F>] [--merge-issue <N>]]" >&2
108
+ echo "ERROR: Usage: classify-finding.sh --severity <S> [--confidence <C>] [--agent <A>] [--lineage none|review-finding] [--text <T> | --text-file <F>] [--inpr-diff <F> --file <P>] [--contract-scope <F> --contract-open <F> [--pr-files <F>] [--merge-issue <N>]] [--prior-findings <F> [--title <T>] [--line-start <N> [--line-end <M>]] [--diff-map <F>]]" >&2
86
109
  exit 2
87
110
  }
88
111
 
@@ -99,6 +122,11 @@ while [ "$#" -gt 0 ]; do
99
122
  --contract-open) [ "$#" -ge 2 ] || usage; CONTRACT_OPEN="$2"; shift 2 ;;
100
123
  --pr-files) [ "$#" -ge 2 ] || usage; PR_FILES="$2"; shift 2 ;;
101
124
  --merge-issue) [ "$#" -ge 2 ] || usage; MERGE_ISSUE="${2#\#}"; shift 2 ;;
125
+ --prior-findings) [ "$#" -ge 2 ] || usage; PRIOR_FINDINGS="$2"; shift 2 ;;
126
+ --title) [ "$#" -ge 2 ] || usage; TITLE="$2"; shift 2 ;;
127
+ --line-start) [ "$#" -ge 2 ] || usage; LINE_START="$2"; shift 2 ;;
128
+ --line-end) [ "$#" -ge 2 ] || usage; LINE_END="$2"; shift 2 ;;
129
+ --diff-map) [ "$#" -ge 2 ] || usage; DIFF_MAP="$2"; shift 2 ;;
102
130
  --text-file)
103
131
  [ "$#" -ge 2 ] || usage
104
132
  [ -r "$2" ] || { echo "ERROR: --text-file not readable: $2" >&2; exit 2; }
@@ -127,6 +155,10 @@ if [ -n "$PR_FILES" ] && [ ! -r "$PR_FILES" ]; then
127
155
  echo "ERROR: --pr-files not readable: $PR_FILES" >&2; exit 2
128
156
  fi
129
157
 
158
+ if [ -n "$PRIOR_FINDINGS" ] && [ ! -r "$PRIOR_FINDINGS" ]; then
159
+ echo "ERROR: --prior-findings not readable: $PRIOR_FINDINGS" >&2; exit 2
160
+ fi
161
+
130
162
  upper() { printf '%s' "$1" | tr '[:lower:]' '[:upper:]' | tr -d '[:space:]'; }
131
163
  SEV=$(upper "$SEVERITY")
132
164
  CONF=$(upper "$CONFIDENCE")
@@ -137,6 +169,69 @@ case "$SEV" in
137
169
  *) echo "ISSUE unparseable-severity"; exit 0 ;;
138
170
  esac
139
171
 
172
+ # Cross-head / cross-PR re-flag gate (#3641). Runs after the severity parse so HIGH/CRITICAL and
173
+ # unparseable severity have already exited as ISSUE. Emits only NOTE; fails toward the normal
174
+ # classification below on a missing file, no match, or an unusable diff map.
175
+ if [ -n "$PRIOR_FINDINGS" ] && [ -n "$FINDING_FILE" ]; then
176
+ _rpath="${FINDING_FILE#./}"; _rline=""
177
+ case "$_rpath" in *:[0-9]*) _rline="${_rpath#*:}"; _rline="${_rline%%[!0-9]*}" ;; esac
178
+ _rpath="${_rpath%%:*}"
179
+ _rls="${LINE_START:-$_rline}"; _rle="${LINE_END:-$_rls}"
180
+ case "$_rls" in ''|*[!0-9]*) _rls=""; _rle="" ;; esac
181
+ case "$_rle" in ''|*[!0-9]*) _rle="$_rls" ;; esac
182
+ _dmap="$DIFF_MAP"; { [ -n "$_dmap" ] && [ -r "$_dmap" ]; } || _dmap=""
183
+ _reflag=$(awk -F '\t' -v file="$_rpath" -v ls="$_rls" -v le="$_rle" -v title="$TITLE" -v dmap="$_dmap" '
184
+ function toks(str, arr, n, i, parts, t, c) {
185
+ c = 0; str = tolower(str); gsub(/[^a-z0-9]+/, " ", str)
186
+ n = split(str, parts, " ")
187
+ for (i = 1; i <= n; i++) { t = parts[i]; if (length(t) >= 3 && !(t in arr)) { arr[t] = 1; c++ } }
188
+ return c
189
+ }
190
+ function shift(f, s, e, k, delta) {
191
+ delta = 0
192
+ for (k = 1; k <= nh; k++)
193
+ if (hf[k] == f && (hc[k] == 0 ? ho[k] < s : ho[k] + hc[k] <= s)) delta += hc2[k] - hc[k]
194
+ return delta
195
+ }
196
+ BEGIN {
197
+ if (dmap != "") {
198
+ while ((getline line < dmap) > 0) {
199
+ if (split(line, p, "\t") >= 5 && p[2] ~ /^[0-9]+$/ && p[3] ~ /^[0-9]+$/ && p[4] ~ /^[0-9]+$/ && p[5] ~ /^[0-9]+$/) {
200
+ nh++; hf[nh] = p[1]; ho[nh] = p[2] + 0; hc[nh] = p[3] + 0; hn[nh] = p[4] + 0; hc2[nh] = p[5] + 0
201
+ }
202
+ }
203
+ close(dmap)
204
+ }
205
+ ntitle = toks(title, ft)
206
+ }
207
+ {
208
+ pf = $1; sub(/^\.\//, "", pf)
209
+ if (pf != file) next
210
+ if ($5 == "filed-closed") next
211
+ hit = 0
212
+ delete pt; toks($4, pt); shared = 0
213
+ for (t in pt) if (t in ft) shared++
214
+ # Line overlap alone is not a match (an unrelated finding can land on the same line): it also
215
+ # needs at least one shared title token. Title-only matching still needs >= 3.
216
+ if (shared >= 1 && ls != "" && $2 ~ /^[0-9]+$/) {
217
+ ps = $2 + 0; pe = ($3 ~ /^[0-9]+$/) ? $3 + 0 : ps
218
+ if (pe < ps) pe = ps
219
+ d = shift(pf, ps, pe); ps += d; pe += d
220
+ lo = (ps > ls) ? ps : ls; hi = (pe < le) ? pe : le
221
+ inter = hi - lo + 1; if (inter < 0) inter = 0
222
+ uni = (pe - ps + 1) + (le - ls + 1) - inter
223
+ if (uni > 0 && inter / uni >= 0.6) hit = 1
224
+ }
225
+ if (!hit && ntitle >= 3 && shared >= 3) hit = 1
226
+ if (!hit) next
227
+ if ($5 == "filed" && $6 ~ /^#?[0-9]+$/) { n = $6; sub(/^#/, "", n); print "NOTE reflag-filed #" n; found = 1; exit }
228
+ if (!note) note = "NOTE reflag-note"
229
+ }
230
+ END { if (!found && note) print note }
231
+ ' "$PRIOR_FINDINGS" 2>/dev/null) || _reflag=""
232
+ if [ -n "$_reflag" ]; then echo "$_reflag"; exit 0; fi
233
+ fi
234
+
140
235
  # In-PR fix gate (#3387, narrowed). MEDIUM CONFIRMED is an ISSUE under every rule below
141
236
  # (lineage included), so checking here changes only where it is handled, never whether.
142
237
  if [ -n "$INPR_DIFF" ] && [ -n "$FINDING_FILE" ] && [ "$SEV" = "MEDIUM" ] && [ "$CONF" = "CONFIRMED" ]; then
@@ -152,6 +152,53 @@ rm -f "$CSCOPE" "$COPEN"
152
152
  expect_exit 2 "unreadable --contract-scope" -- --severity MEDIUM --contract-scope /nonexistent/s --file x
153
153
  expect_exit 2 "unreadable --contract-open" -- --severity MEDIUM --contract-open /nonexistent/o --file x
154
154
 
155
+ # --- Cross-head / cross-PR re-flag gate (#3641) ---
156
+ expect_out() { # expect_out <full output> <description> -- args...
157
+ want="$1"; desc="$2"; shift 3
158
+ got=$(bash "$S" "$@" 2>/dev/null)
159
+ if [ "$got" = "$want" ]; then pass=$((pass+1)); else fail=$((fail+1)); echo "FAIL: $desc (want '$want', got '${got}')"; fi
160
+ }
161
+ TAB=$(printf '\t')
162
+ PRIOR=$(mktemp "${TMPDIR:-/tmp}/classify-finding-prior.XXXXXX")
163
+ DMAP=$(mktemp "${TMPDIR:-/tmp}/classify-finding-dmap.XXXXXX")
164
+ {
165
+ printf 'src/sync.sh\t40\t50\tlock released before write completes\tnote\t\n'
166
+ printf 'src/api.py\t10\t20\tmissing retry on timeout handler\tfiled\t123\n'
167
+ printf 'src/closed.py\t5\t15\tstale cache entry never evicted\tfiled-closed\t77\n'
168
+ printf 'src/notes.py\t\t\tduplicated predicate counted twice\tnote\t\n'
169
+ } > "$PRIOR"
170
+ PF=(--prior-findings "$PRIOR")
171
+ M=(--severity MEDIUM --confidence CONFIRMED)
172
+
173
+ expect_out "NOTE reflag-note" "re-flag by line IoU (same range)" -- "${M[@]}" "${PF[@]}" --file src/sync.sh --line-start 42 --line-end 50 --title "lock handling wording here"
174
+ expect_out "NOTE reflag-note" "re-flag via path:line tolerated" -- "${M[@]}" "${PF[@]}" --file src/sync.sh:41 --line-end 50 --title "lock x"
175
+ printf 'src/sync.sh\t1\t2\t1\t12\n' > "$DMAP" # 2 lines became 12: prior 40-50 is now 50-60
176
+ expect_out "NOTE reflag-note" "re-flag after the line moved, shifted by diff map" -- "${M[@]}" "${PF[@]}" --diff-map "$DMAP" --file src/sync.sh --line-start 50 --line-end 60 --title "lock something else"
177
+ expect ISSUE "line moved with no diff map does not match" -- "${M[@]}" "${PF[@]}" --file src/sync.sh --line-start 50 --line-end 60 --title "lock something else"
178
+ printf 'garbage\n' > "$DMAP"
179
+ expect_out "NOTE reflag-note" "unparseable diff map falls back to unshifted range" -- "${M[@]}" "${PF[@]}" --diff-map "$DMAP" --file src/sync.sh --line-start 41 --line-end 50 --title "lock x"
180
+ expect_out "NOTE reflag-note" "re-flag by title tokens (far line)" -- "${M[@]}" "${PF[@]}" --file src/sync.sh --line-start 300 --title "Lock released before the write"
181
+ expect_out "NOTE reflag-note" "NOTE row with empty line columns matches by title" -- "${M[@]}" "${PF[@]}" --file src/notes.py --line-start 9 --title "Duplicated predicate is counted twice"
182
+ expect ISSUE "same line, different topic MEDIUM CONFIRMED is not swallowed (SEC-1)" -- "${M[@]}" --agent security "${PF[@]}" --file src/sync.sh --line-start 42 --line-end 50 --title "SQL injection via unsanitized token"
183
+ expect ISSUE "same line with no title at all is not swallowed" -- "${M[@]}" "${PF[@]}" --file src/sync.sh --line-start 42 --line-end 50
184
+ expect_out "NOTE reflag-note" "same line, same topic (one shared token) still NOTE" -- "${M[@]}" "${PF[@]}" --file src/sync.sh --line-start 42 --line-end 50 --title "Lock handling is racy"
185
+ expect ISSUE "non-overlapping same-file finding is classified normally" -- "${M[@]}" "${PF[@]}" --file src/sync.sh --line-start 200 --line-end 210 --title "missing bounds check"
186
+ expect ISSUE "two shared tokens are not enough" -- "${M[@]}" "${PF[@]}" --file src/sync.sh --line-start 200 --title "lock released late"
187
+ expect ISSUE "same title on a different file is classified normally" -- "${M[@]}" "${PF[@]}" --file src/other.sh --line-start 45 --title "lock released before write completes"
188
+ expect_out "NOTE reflag-filed #123" "re-flag of an already-filed issue links it" -- "${M[@]}" "${PF[@]}" --file src/api.py --line-start 12 --line-end 18 --title "retry x"
189
+ expect_out "NOTE reflag-filed #123" "filed re-flag by title tokens" -- "${M[@]}" "${PF[@]}" --file src/api.py --line-start 99 --title "Missing retry on timeout"
190
+ expect ISSUE "filed-closed row is ignored (regression not swallowed)" -- "${M[@]}" "${PF[@]}" --file src/closed.py --line-start 5 --line-end 15 --title "stale cache entry never evicted"
191
+ expect ISSUE "HIGH is never demoted by a re-flag" -- --severity HIGH --confidence CONFIRMED "${PF[@]}" --file src/api.py --line-start 12 --line-end 18 --title "missing retry on timeout handler"
192
+ expect ISSUE "CRITICAL is never demoted by a re-flag" -- --severity CRITICAL "${PF[@]}" --file src/api.py --line-start 12 --line-end 18
193
+ expect ISSUE "unparseable severity is never demoted" -- --severity INFO "${PF[@]}" --file src/api.py --line-start 12 --line-end 18
194
+ expect ISSUE "no --file: gate skipped" -- "${M[@]}" "${PF[@]}" --title "lock released before write completes"
195
+ expect ISSUE "no --prior-findings: unchanged" -- "${M[@]}" --file src/api.py --line-start 12 --line-end 18
196
+ expect NOTE "re-flag wins over in-PR fix gate" -- "${M[@]}" "${PF[@]}" --file src/api.py --line-start 12 --line-end 18 --title "retry gap" --inpr-diff "$PRIOR"
197
+ : > "$PRIOR"
198
+ expect ISSUE "empty prior-findings: unchanged" -- "${M[@]}" "${PF[@]}" --file src/api.py --line-start 12 --line-end 18 --title "missing retry"
199
+ rm -f "$PRIOR" "$DMAP"
200
+ expect_exit 2 "unreadable --prior-findings" -- --severity MEDIUM --prior-findings /nonexistent/p --file x
201
+
155
202
  # --- usage errors ---
156
203
  expect_exit 2 "unknown flag" -- --bogus x
157
204
  expect_exit 2 "bad lineage" -- --severity LOW --lineage P3
@@ -246,7 +246,7 @@ while IFS= read -r f; do
246
246
  arms=$(grep -c 'trusted-comments.sh" \]' "$f" || true)
247
247
  [ "$arms" = "$(cat "$T/tc_n")" ] || { TC_STRAY=$((TC_STRAY + 1)); echo " stray resolver in $f: $arms arms vs $(cat "$T/tc_n") blocks"; }
248
248
  done < <(grep -rl 'trusted-comments.sh" \]' "$ROOT/commands")
249
- expect "trusted-comments.sh resolver blocks discovered (review-pr 8 + review + remediate + phase-4 + review-pr-staging)" 12 "$TC_BLOCKS"
249
+ expect "trusted-comments.sh resolver blocks discovered (review-pr 11 + review + remediate x2 + phase-4 + review-pr-staging 7)" 22 "$TC_BLOCKS"
250
250
  expect "trusted-comments.sh resolvers without a canonical block" 0 "$TC_STRAY"
251
251
  expect "trusted-comments.sh resolvers byte-identical" 1 "$(sort -u "$T/tc_all" | wc -l | tr -d ' ')"
252
252
  grep -qF 'plugins/cache/forgedock/forgedock' "$T/tc_all" && ok || bad 'trusted-comments.sh resolver lacks the plugin-cache scan'
@@ -311,6 +311,53 @@ expect "AMP_SCRIPT: no install resolves empty (cwd copy ignored)" "" "$(hr_run "
311
311
  expect "extract-affected-files: cache-only install resolves (cwd and REPO_PATH copies ignored)" "$CACHE_SCRIPTS/extract-affected-files.sh" "$(hr_run "$T/hrhome" "$T/hr_ex4.sh" 'resolve_extract_affected_files')"
312
312
  expect "extract-affected-files: no install resolves nothing (cwd and REPO_PATH copies ignored)" "" "$(hr_run "$T/nohome" "$T/hr_ex4.sh" 'resolve_extract_affected_files')"
313
313
 
314
+ # Guard (#3596): review-time helpers (derive-finding-milestone.sh, severity-to-priority.sh, code-index.sh) run while the
315
+ # cwd / REPO_PATH is the PR checkout, so each call site must carry a canonical trusted-install resolver block.
316
+ HR_STG="$ROOT/commands/review-pr-staging.md"
317
+ hr_check_block() { # label file var script
318
+ extract_helper_block "$2" "$3" > "$T/hr_blk.sh"
319
+ expect "$1: $3 resolver block found" 1 "$(grep -c "^$3=\"\"" "$T/hr_blk.sh")"
320
+ grep -qF 'plugins/cache/forgedock/forgedock' "$T/hr_blk.sh" && ok || bad "$1: $3 resolver lacks the plugin-cache scan"
321
+ grep -qF 'CLAUDE_CONFIG_DIR' "$T/hr_blk.sh" && ok || bad "$1: $3 resolver lacks CLAUDE_CONFIG_DIR"
322
+ grep -qE '\$PWD|\$REPO_PATH|\$\{PWD|\$\{REPO_PATH' "$T/hr_blk.sh" && bad "$1: $3 resolver lists the working directory or repo path" || ok
323
+ grep -qF "scripts/$4" "$T/hr_blk.sh" && ok || bad "$1: $3 resolver does not resolve scripts/$4"
324
+ }
325
+ hr_check_block review-pr "$HR_CLASSIFY" DERIVE_MILESTONE_SCRIPT derive-finding-milestone.sh
326
+ hr_check_block review-pr "$HR_CLASSIFY" PRIORITY_SCRIPT severity-to-priority.sh
327
+ hr_check_block review-pr "$HR_CLASSIFY" CODE_INDEX_SCRIPT code-index.sh
328
+ hr_check_block review-pr-staging "$HR_STG" DERIVE_MILESTONE_SCRIPT derive-finding-milestone.sh
329
+ hr_check_block review-pr-staging "$HR_STG" PRIORITY_SCRIPT severity-to-priority.sh
330
+ extract_helper_block "$HR_CLASSIFY" DERIVE_MILESTONE_SCRIPT > "$T/hr_dm.sh"
331
+ extract_helper_block "$HR_CLASSIFY" PRIORITY_SCRIPT > "$T/hr_pr.sh"
332
+ extract_helper_block "$HR_STG" DERIVE_MILESTONE_SCRIPT > "$T/hr_dm_stg.sh"
333
+ extract_helper_block "$HR_STG" PRIORITY_SCRIPT > "$T/hr_pr_stg.sh"
334
+ # only the variable-name-specific text may differ, and review-pr/review-pr-staging must agree
335
+ cmp -s "$T/hr_dm.sh" "$T/hr_dm_stg.sh" && ok || bad 'DERIVE_MILESTONE_SCRIPT resolver differs between review-pr and review-pr-staging'
336
+ cmp -s "$T/hr_pr.sh" "$T/hr_pr_stg.sh" && ok || bad 'PRIORITY_SCRIPT resolver differs between review-pr and review-pr-staging'
337
+ # no bare cwd-relative helper call in either review spec
338
+ if grep -nE 'bash scripts/(derive-finding-milestone|severity-to-priority)\.sh' "$HR_CLASSIFY" "$HR_STG" >/dev/null 2>&1; then bad 'review spec still calls a helper by a bare scripts/ path'; else ok; fi
339
+
340
+ # Repo-wide guard (#3596): no executed bare `bash scripts/<name>` / `./scripts/` and no REPO_PATH/scripts tier under commands/.
341
+ # Allowlist entries are "file-suffix" prefixes; each is a reviewed row with a reason.
342
+ # design-bench.md, design-render-critique-loop.md, pipeline-health/phase-2-metrics.md, phase-3-dependency.md:
343
+ # node scripts/*.mjs run inside the ForgeDock repo itself or printed as user hints (not run under a PR checkout cwd).
344
+ # work-on/investigate.md, work-on/build/architect.md: {REPO_PATH}/scripts/code-index.sh, deferred to #3670.
345
+ # deploy-info.md: read-only grep of decrypt-secrets.sh, never executed.
346
+ SCRIPTS_ALLOW='commands/(design-bench|design-render-critique-loop|deploy-info|pipeline-health/phase-2-metrics|pipeline-health/phase-3-dependency|work-on/investigate|work-on/build/architect)\.md'
347
+ guard_hits=$(grep -rnE '(^|[^A-Za-z0-9_-])(bash|sh|source) +(\./)?scripts/[A-Za-z0-9_.-]+|REPO_PATH\}?/scripts/' "$ROOT/commands" 2>/dev/null \
348
+ | sed "s#^$ROOT/##" | grep -vE "^($SCRIPTS_ALLOW):" | grep -vE '^[^:]+:[0-9]+:[[:space:]]*#' || true) # comment lines are prose
349
+ if [ -n "$guard_hits" ]; then bad "commands/ has a cwd-relative or REPO_PATH helper call outside the allowlist: $(printf '%s' "$guard_hits" | head -5 | tr '\n' '|')"; else ok; fi
350
+
351
+ # Behavior (#3596): planted cwd copies of the two review helpers must never be selected.
352
+ for h in derive-finding-milestone severity-to-priority code-index; do
353
+ : > "$T/hrwork/scripts/$h.sh"; : > "$CACHE_SCRIPTS/$h.sh"
354
+ done
355
+ for pair in "dm:DERIVE_MILESTONE_SCRIPT:derive-finding-milestone" "pr:PRIORITY_SCRIPT:severity-to-priority"; do
356
+ IFS=: read -r k var h <<< "$pair"
357
+ expect "$var: cache-only install resolves (cwd copy ignored)" "$CACHE_SCRIPTS/$h.sh" "$(hr_run "$T/hrhome" "$T/hr_$k.sh" "printf %s \"\$$var\"")"
358
+ expect "$var: no install resolves empty (cwd copy ignored)" "" "$(hr_run "$T/nohome" "$T/hr_$k.sh" "printf %s \"\$$var\"")"
359
+ done
360
+
314
361
  # Guard (#3636): review-delta.sh takes its trust script ONLY from an explicit absolute --trusted-script. Any
315
362
  # commands/ block that resolves review-delta.sh (added by the specs that consume it) must use the trusted-install
316
363
  # tiers: plugin-cache scan and CLAUDE_CONFIG_DIR present, no "$PWD"/"$REPO_PATH" tier. Passes with 0 blocks today.
@@ -0,0 +1,58 @@
1
+ #!/usr/bin/env bash
2
+ # orchestrate-soft-edge.test.sh — cases for verify_soft_edge_early (forge#3697).
3
+ # Extracts the helper's fenced bash block from commands/orchestrate/phase-4-execution.md and runs it
4
+ # against a stubbed `gh`, so the spec and the test cannot drift.
5
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
6
+ # SPDX-License-Identifier: AGPL-3.0-or-later
7
+ set -uo pipefail
8
+ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
9
+ P4="$ROOT/commands/orchestrate/phase-4-execution.md"
10
+ P3="$ROOT/commands/orchestrate/phase-3-dependency.md"
11
+ PASS=0; FAILN=0
12
+ TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
13
+ ok() { PASS=$((PASS+1)); }
14
+ bad() { FAILN=$((FAILN+1)); echo "FAIL: $1"; }
15
+
16
+ if [ "${BASH_VERSINFO[0]}" -lt 4 ]; then echo "orchestrate-soft-edge.test.sh: bash 4+ required (associative arrays), skipped"; exit 0; fi
17
+
18
+ awk '/^```bash$/{b="";in_b=1;next} /^```$/{if(in_b&&b~/^verify_soft_edge_early\(\)/)printf "%s",b; in_b=0;next} in_b{b=b $0 "\n"}' "$P4" \
19
+ | sed 's/{GH_REPO}/o\/r/g' > "$TMP/helper.sh"
20
+ if [ -s "$TMP/helper.sh" ]; then ok; else bad "verify_soft_edge_early block not found"; echo "failed=$FAILN"; exit 1; fi
21
+
22
+ mkdir "$TMP/bin"
23
+ cat > "$TMP/bin/gh" <<'STUB'
24
+ #!/bin/sh
25
+ case "$1 $2" in
26
+ "pr list") [ "${STUB_LIST_FAIL:-0}" = 1 ] && exit 1; [ "${STUB_NO_PR:-0}" = 1 ] || echo 42; exit 0 ;;
27
+ "pr diff") [ "${STUB_DIFF_FAIL:-0}" = 1 ] && exit 1; printf '%s\n' $STUB_DIFF; exit 0 ;;
28
+ esac
29
+ exit 1
30
+ STUB
31
+ chmod +x "$TMP/bin/gh"
32
+
33
+ # run NAME WANT SOFT(1|0) EDGE_FILES DIFF [ENV=VAL...]
34
+ run() {
35
+ local name="$1" want="$2" soft="$3" files="$4" diff="$5"; shift 5
36
+ local got
37
+ got="$(env PATH="$TMP/bin:$PATH" STUB_DIFF="$diff" "$@" bash -c '
38
+ declare -A EDGE_SOFT EDGE_KIND EDGE_FILES
39
+ EDGE_KIND["1:2"]=same-file; EDGE_FILES["1:2"]="'"$files"'"
40
+ [ "'"$soft"'" = 1 ] && EDGE_SOFT["1:2"]=true
41
+ . "'"$TMP"'/helper.sh"
42
+ verify_soft_edge_early 1 2' 2>&1 | tail -n1)"
43
+ if [ "$got" = "$want" ]; then ok; else bad "$name (got '$got' want '$want')"; fi
44
+ }
45
+ run "soft edge, diff excludes shared file -> RELEASE" RELEASE 1 "commands/review-pr.md" "commands/review-pr-staging.md"
46
+ run "soft edge, diff has shared file -> HOLD" HOLD 1 "commands/review-pr.md" "commands/review-pr.md other.md"
47
+ run "soft edge, no PR -> HOLD" HOLD 1 "commands/review-pr.md" "" STUB_NO_PR=1
48
+ run "hard edge, no overlap -> HOLD" HOLD 0 "commands/review-pr.md" "commands/review-pr-staging.md"
49
+ run "soft edge, gh pr diff fails -> HOLD" HOLD 1 "commands/review-pr.md" "" STUB_DIFF_FAIL=1
50
+ run "soft edge, PR lookup fails -> HOLD" HOLD 1 "commands/review-pr.md" "x" STUB_LIST_FAIL=1
51
+ run "path boundary: utils vs some_utils_helper.py" RELEASE 1 "utils" "src/some_utils_helper.py"
52
+ run "directory edge overlap -> HOLD" HOLD 1 "src/utils/" "src/utils/a.py"
53
+
54
+ grep -q 'EDGE_SOFT' "$P3" && grep -qi 'soft' "$P3" && ok || bad "phase-3 does not declare/tag EDGE_SOFT"
55
+ grep -q 'verify_soft_edge_early "\$PRED"' "$P4" && ok || bad "phase-4 IN_PROGRESS arm does not call verify_soft_edge_early"
56
+
57
+ echo "orchestrate-soft-edge.test.sh: passed=$PASS failed=$FAILN"
58
+ [ "$FAILN" -eq 0 ]
@@ -36,14 +36,35 @@ has "M3 records merged base head" 'BASESYNC_BASE_SHA=\$\(git rev-parse origin/'
36
36
  has "re-sync rounds hard-capped" 'BASESYNC_MAX_ROUNDS'
37
37
  has "M6 distinguishes advanced base" 'Base advanced'
38
38
  has "M8 trail persists base sha" 'Base sync\*\*: ran \(base='
39
- has "rounds derived from pushed sync merges" 'BASESYNC_ROUNDS=\$\(git rev-list --first-parent --merges --count origin/\{PR_BASE\}\.\.origin/\{HEAD_BRANCH\}'
40
- has "unreadable round count treated as the cap" '\|\| BASESYNC_ROUNDS=\$BASESYNC_MAX_ROUNDS'
39
+ has "rounds derived from pushed sync merges" 'git rev-list --first-parent --merges origin/\{PR_BASE\}\.\.origin/\{HEAD_BRANCH\}'
40
+ has "only merges whose 2nd parent is a base ancestor count" 'git merge-base --is-ancestor "\$_q" origin/\{PR_BASE\}'
41
+ has "unreadable round count treated as the cap" 'else BASESYNC_ROUNDS=\$BASESYNC_MAX_ROUNDS; fi'
42
+ if grep -qF -- '--first-parent --merges --count' "$SPEC"; then bad "no raw all-merges round count"; else ok "no raw all-merges round count"; fi
43
+ if grep -qF 'BASESYNC_RAN' "$SPEC"; then bad "no BASESYNC_RAN shell-state carrier"; else ok "no BASESYNC_RAN shell-state carrier"; fi
44
+ if grep -qF "HEAD^2" "$SPEC"; then bad "M8 does not read HEAD^2"; else ok "M8 does not read HEAD^2"; fi
45
+ has "M0 DONE count accepts ran|attempted" 'Base sync\\\*\\\*: \(ran\|attempted\)'
46
+ has "M0 scopes override by newest bound kind" 'CURRENT_KIND" = "BASESYNC_REMEDIATION"'
47
+ has "M8 records a no-merge round as attempted" 'Base sync\*\*: attempted \(no merge\)'
48
+ # The cap derivation block must be byte-identical across every copy (M3, M6 freshness, M6 base-conflict, M8).
49
+ CAPDIR="$(mktemp -d)"
50
+ awk -v d="$CAPDIR" '/^BASESYNC_ROUNDS=0; _p2=""$/{c++; f=1} f{print > (d "/cap" c)} f&&/^BASESYNC_BASE_SHA=/{f=0}' "$SPEC"
51
+ NCAP=$(ls "$CAPDIR" | wc -l); [ "$NCAP" -ge 4 ] && ok "cap derivation present in M3, M6 x2 and M8 ($NCAP copies)" || bad "cap derivation copies ($NCAP)"
52
+ [ "$(cat "$CAPDIR"/cap* | sort | uniq -c | awk '{print $1}' | sort -u | wc -l)" -le 1 ] && [ "$(cksum < "$CAPDIR"/cap1)" = "$(for _f in "$CAPDIR"/cap*; do cksum < "$_f"; done | sort -u)" ] && ok "cap derivation copies are byte-identical" || bad "cap derivation copies are byte-identical"
41
53
  if grep -qF 'BASESYNC_ROUNDS=$(( ${BASESYNC_ROUNDS:-0}' "$SPEC"; then bad "no shell-state round counter"; else ok "no shell-state round counter"; fi
42
54
  if grep -qF '[ -n "${BASESYNC_BASE_SHA:-}" ]' "$SPEC"; then bad "freshness pass not gated on a shell var"; else ok "freshness pass not gated on a shell var"; fi
43
55
  has "freshness push rc checked" 'if git push origin HEAD:\{HEAD_BRANCH\}; then'
44
56
  has "freshness merge re-gated before push" 'Run the Phase M4 pre-push ancestry guard'
45
57
  if grep -qF 'do NOT sync a second time' "$SPEC"; then bad "unconditional second-sync refusal removed"; else ok "unconditional second-sync refusal removed"; fi
46
58
 
59
+ # Cross-fence shell state (forge#3653): every fence is a fresh shell, so no flag/var may cross fences.
60
+ if grep -qF 'BASESYNC_RAN:-false' "$SPEC"; then bad "M8 not gated on BASESYNC_RAN shell var"; else ok "M8 not gated on BASESYNC_RAN shell var"; fi
61
+ if grep -qE 'BASESYNC_(RAN|CONFLICTED|MERGE_REFUSED)=' "$SPEC"; then bad "no cross-fence BASESYNC_* flags"; else ok "no cross-fence BASESYNC_* flags"; fi
62
+ # MERGE_RC / MERGE_OUT may be read only in the fence that sets them (the M3 sync fence).
63
+ FENCE_SET="$(awk '/^```/{f=!f; n++; next} f&&/MERGE_RC=\$\?/{print n}' "$SPEC" | head -n1)"
64
+ FENCE_USE="$(awk '/^```/{f=!f; n++; next} f&&/\$\{?MERGE_(RC|OUT)/{print n}' "$SPEC" | sort -u | tr '\n' ' ')"
65
+ eq "MERGE_RC/MERGE_OUT read only in the fence that sets them" "$FENCE_USE" "$FENCE_SET "
66
+ has "refused park posts needs-human from the sync fence" 'echo "BLOCKED: base-sync: merge refused'
67
+
47
68
  # Behavioural: dirty tree + base change -> refused (rc!=0, no MERGE_HEAD, empty U-list); real conflict -> MERGE_HEAD present.
48
69
  T="$(mktemp -d)"; trap 'rm -rf "$T"' EXIT
49
70
  (
@@ -77,7 +98,7 @@ eq "advanced base: SHA differs, unchanged base same, second merge clean" "$(cat
77
98
 
78
99
  # Durable derivation (forge#3517): the M3/M6 lines, run in a fresh shell against a scratch repo, recover rounds and the
79
100
  # recorded base head from the pushed branch; an unpushed merge does not count.
80
- DERIVE="$(grep -m1 -E '^BASESYNC_ROUNDS=\$\(git rev-list' "$SPEC")"$'\n'"$(grep -m1 -E '^_lm=\$\(git rev-list' "$SPEC")"$'\n'"$(grep -m1 -E '^\[ -n "\$_lm" \]' "$SPEC")"
101
+ DERIVE="$(cat "$CAPDIR/cap1")"
81
102
  DERIVE="${DERIVE//\{PR_BASE\}/main}"; DERIVE="${DERIVE//\{HEAD_BRANCH\}/feat}"
82
103
  (
83
104
  R="$T/derive"; mkdir "$R" && cd "$R" && git init -q -b main . && git config user.email t@t && git config user.name t
@@ -99,5 +120,43 @@ eq "derive: no sync merge -> 0 rounds, no recorded head" "$D0" "0 none"
99
120
  eq "derive: unpushed merge does not count" "$DU" "0 none"
100
121
  eq "derive: pushed sync merge -> 1 round, recorded head = merged base" "$D1" "1 $B1"
101
122
 
123
+ # Cap counts only SYNC merges: a non-sync (manual / feature-branch) merge must not burn the budget, and must not be the recorded head.
124
+ (
125
+ R="$T/nonsync"; mkdir "$R" && cd "$R" && git init -q -b main . && git config user.email t@t && git config user.name t
126
+ derive() { BASESYNC_MAX_ROUNDS=2; eval "$DERIVE"; echo "$BASESYNC_ROUNDS ${_p2:-none}"; }
127
+ echo a > f && git add f && git commit -qm base
128
+ git checkout -qb feat && echo x > g && git add g && git commit -qm feat
129
+ git checkout -q main && echo b > f && git commit -qam m1 && git checkout -q feat
130
+ git update-ref refs/remotes/origin/main main; b1=$(git rev-parse main)
131
+ git merge origin/main --no-edit >/dev/null 2>&1 # sync merge
132
+ git checkout -qb side && echo s > s && git add s && git commit -qm side && git checkout -q feat
133
+ git merge side --no-ff --no-edit >/dev/null 2>&1 # manual non-sync merge on top (newest)
134
+ git checkout -q main && echo c > h && git add h && git commit -qm m2 && git checkout -q feat
135
+ git update-ref refs/remotes/origin/main main; git update-ref refs/remotes/origin/feat feat
136
+ echo "$(derive)|$b1|$(git rev-list --first-parent --merges --count origin/main..origin/feat)" > "$T/nonsync.out"
137
+ )
138
+ IFS='|' read -r N1 NB NRAW < "$T/nonsync.out"
139
+ eq "non-sync merge not counted: 1 sync round, recorded head = sync base (raw merge count was 2)" "$N1" "1 $NB"
140
+ eq "manual merge case really had 2 raw merges" "$NRAW" "2"
141
+
142
+ # M8 derivation in a fresh shell: the spec lines recover the base-sync line from git + trusted markers with no shell state.
143
+ M8="$(awk '/^BASESYNC_KIND=""$/{f=1} /^REMEDIATION_BODY=/{exit} f{print}' "$SPEC")"
144
+ M8="${M8//\{PR_NUMBER\}/7}"
145
+ mkdir -p "$T/bin"; cat > "$T/bin/trusted.sh" <<'TS'
146
+ #!/usr/bin/env bash
147
+ # stand-in for trusted-comments.sh bodies: grep the stdin comment bodies (one per line) with an ERE
148
+ [ "$1" = bodies ] && grep -E -- "$2" || true
149
+ TS
150
+ m8() { # $1 = newest-kind marker body line, $2 = prior trail body line
151
+ ( BASESYNC_BASE_SHA="$3"; TRUSTED_SCRIPT="$T/bin/trusted.sh"; ISSUE_COMMENTS="$(printf '%s\n%s\n' "$2" "$1")"; eval "$M8"; echo "${BASESYNC_LINE:-empty}" )
152
+ }
153
+ SHA=0123456789abcdef0123456789abcdef01234567
154
+ bs='<!-- FORGE:BASESYNC_REMEDIATION: pr=7 -->'; ci='<!-- FORGE:CI_REMEDIATION: pr=7 -->'
155
+ eq "M8: newest base-sync kind + new sync merge -> ran line" "$(m8 "$bs" "" "$SHA")" "**Base sync**: ran (base=$SHA)"
156
+ eq "M8: newest base-sync kind, no sync merge -> attempted" "$(m8 "$bs" "" "")" "**Base sync**: attempted (no merge)"
157
+ eq "M8: sync merge already recorded by an earlier trail -> attempted" "$(m8 "$bs" "**Base sync**: ran (base=$SHA)" "$SHA")" "**Base sync**: attempted (no merge)"
158
+ eq "M8: other kind -> no base-sync line" "$(m8 "$ci" "" "$SHA")" "empty"
159
+
160
+ rm -rf "$CAPDIR"
102
161
  echo "passed=$PASS failed=$FAILN"
103
162
  [ "$FAILN" -eq 0 ]