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.
- package/bin/engine/phases.mjs +160 -18
- package/bin/engine-cli.mjs +18 -5
- package/bin/engine.mjs +109 -6
- package/bin/forgedock.mjs +4 -1
- package/commands/orchestrate/phase-3-dependency.md +5 -2
- package/commands/orchestrate/phase-4-execution.md +144 -24
- package/commands/pipeline-health/phase-2-metrics.md +5 -3
- package/commands/review-pr-staging.md +289 -39
- package/commands/review-pr.md +604 -42
- package/commands/validate.md +11 -3
- package/commands/work-on/build/architect.md +11 -8
- package/commands/work-on/build.md +27 -6
- package/commands/work-on/close.md +5 -4
- package/commands/work-on/decompose.md +7 -2
- package/commands/work-on/investigate.md +11 -0
- package/commands/work-on/remediate.md +135 -74
- package/commands/work-on/review.md +3 -3
- package/docs/CONFIG.md +14 -1
- package/docs/RELEASE-NOTES.md +8 -0
- package/package.json +1 -1
- package/scripts/architect-resume-match.test.sh +53 -0
- package/scripts/check-spec-bash.sh +73 -3
- package/scripts/check-spec-bash.test.sh +50 -0
- package/scripts/checkbox-sections.test.sh +19 -0
- package/scripts/classify-finding.sh +96 -1
- package/scripts/classify-finding.test.sh +47 -0
- package/scripts/forge-root.test.sh +48 -1
- package/scripts/orchestrate-soft-edge.test.sh +58 -0
- package/scripts/remediate-basesync.test.sh +62 -3
- package/scripts/review-delta.sh +3 -2
- package/scripts/review-delta.test.sh +1 -1
- package/scripts/review-pr-delta-wiring.test.sh +266 -0
- package/scripts/review-pr-staging-scope.test.sh +60 -0
- 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
|
|
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" '
|
|
40
|
-
has "
|
|
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="$(
|
|
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 ]
|