forgedock 1.29.0 → 1.30.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 (50) hide show
  1. package/bin/engine/outcome-codes.mjs +4 -2
  2. package/bin/engine/phases.mjs +20 -3
  3. package/bin/engine-cli.mjs +102 -38
  4. package/bin/engine.mjs +3 -1
  5. package/bin/lean/batch.test.mjs +62 -1
  6. package/bin/lean/conformance.mjs +129 -8
  7. package/bin/lean/conformance.test.mjs +77 -3
  8. package/bin/lean/context.mjs +32 -4
  9. package/bin/lean/context.test.mjs +71 -12
  10. package/bin/lean/effects.mjs +20 -6
  11. package/bin/lean/enumerating.mjs +189 -0
  12. package/bin/lean/enumerating.test.mjs +97 -0
  13. package/bin/lean/lean.mjs +207 -56
  14. package/bin/lean/lean.test.mjs +336 -9
  15. package/bin/lean/orchestrate.mjs +132 -83
  16. package/bin/lean/precheck.mjs +55 -0
  17. package/bin/lean/precheck.test.mjs +93 -0
  18. package/bin/lean/prefilter.mjs +96 -14
  19. package/bin/lean/prefilter.test.mjs +111 -2
  20. package/bin/lean/preflight.mjs +133 -0
  21. package/bin/lean/preflight.test.mjs +61 -0
  22. package/bin/lean/prompts.mjs +37 -3
  23. package/bin/lean/recovery.test.mjs +271 -7
  24. package/bin/lean/render.mjs +13 -0
  25. package/bin/lean/sequence.mjs +102 -0
  26. package/bin/lean/sequence.test.mjs +112 -0
  27. package/bin/lean/testmap.mjs +57 -3
  28. package/bin/lean/testmap.test.mjs +53 -1
  29. package/bin/lean/verify.mjs +7 -2
  30. package/bin/lean/verify.test.mjs +8 -2
  31. package/commands/deploy-pr.md +5 -4
  32. package/commands/orchestrate.md +7 -4
  33. package/commands/review-pr-staging.md +6 -1
  34. package/docs/CONFIG.md +8 -1
  35. package/docs/OUTCOME-CODES.md +6 -4
  36. package/docs/RELEASE-NOTES.md +10 -0
  37. package/package.json +1 -1
  38. package/scripts/acceptance-red-green.sh +13 -4
  39. package/scripts/acceptance-red-green.test.sh +14 -0
  40. package/scripts/code-index.sh +33 -33
  41. package/scripts/code-index.test.sh +21 -2
  42. package/scripts/fixtures/test-map/go/.github/workflows/ci.yml +2 -0
  43. package/scripts/repo-facts.test.sh +5 -5
  44. package/scripts/review-prepare.sh +28 -6
  45. package/scripts/review-prepare.test.sh +22 -2
  46. package/scripts/test-map.sh +186 -34
  47. package/scripts/test-map.test.sh +250 -7
  48. package/scripts/verify-phase-trail.test.sh +44 -44
  49. package/scripts/wait-ci-green.sh +1 -1
  50. package/scripts/wait-ci-green.test.sh +4 -0
@@ -17,11 +17,22 @@
17
17
  # `ruff check .` (through `uv run` / `poetry run` when that lockfile exists), `**/*.py`
18
18
  # go.mod (dir D) → `go test ./...`, `go vet ./...` in D, `D/**/*.go`, `D/go.mod`, `D/go.sum`
19
19
  # Makefile / GNUmakefile targets test/lint/check/typecheck/vet → `make <target>`, `**`
20
- # .github/workflows/*.yml|*.yaml `run:` steps (one command per line of a `|` block, a `>` block is one command;
21
- # install/setup lines skipped): globs
22
- # from the tool (python/go/shell/js), else the workflow's `paths:` filter, else `**`. A step whose job id, job
23
- # name, step name or command matches deploy/publish/release/push/secret is never mapped (forge#4297): the engine
24
- # runs these commands before a PR, so only check steps belong here.
20
+ # .github/workflows/*.yml|*.yaml `run:` steps (one command per line of a `|` block, a `>` block is one command).
21
+ # The engine runs these commands before a PR, so a step is mapped only when its whole
22
+ # command is an allowlisted check (forge#4300): `<pm> test`, `<pm> run <package.json check script>` (also `test:*`
23
+ # style names, `pnpm -r`, `npm --prefix DIR`), `make <check target>`, `go test|vet|build`, `cargo test|clippy|check`,
24
+ # gradle/maven with check tasks/goals only, `[bundle exec] rspec`, `python manage.py test`, `python -m unittest`,
25
+ # `tox`/`nox` (not a publish/release/deploy/upload env), `deno|bun test`, `bash|sh <path>.test.sh` or a known
26
+ # test/lint/typecheck tool (forge#4315); everything else is dropped, and job and step names are never consulted. One leading `cd DIR &&` / `pushd DIR &&` becomes the entry cwd; any other chaining,
27
+ # substitution, `${{ }}`, `$GITHUB_*`, redirect or `<(...)`/`>(...)`, a non-check make target, or a fixer form
28
+ # (`--fix`, `--write`, `-w`, `black`/`ruff format`/`gofmt`/`prettier` without their check flag) drops the step (forge#4316). Globs from the tool (python/go/shell/js, under the cwd), else the
29
+ # workflow's `paths:` filter, else `DIR/**` for a cwd; a workflow entry never gets a bare `**`.
30
+ # forge#4318: also dropped: any quote, fixer/snapshot flag families (`--fix-only`, `--add-noqa`, `--write=*`, `--w`,
31
+ # `--update*`, `--updateSnapshot`, `--snapshot-update`, `-u` on jest/vitest/package managers), write targets (`go -o`,
32
+ # `-exec`, `-toolexec`, `tsc --outDir/--outFile`, `pytest --junitxml/--basetemp/--html`, any absolute or `..` path) and
33
+ # tox/nox `dist`/`pypi`/`twine` envs in any spelling; `-w` is a workspace on npm/pnpm. Also mapped: `uv run python -m X`,
34
+ # `python3.N -m X`, `npx --yes X`, `pnpm --filter X`, `npm -w PKG`, `make -C DIR` (DIR becomes the cwd), `cargo +TC <sub>`,
35
+ # `cargo fmt --check`, gradle `--tests X` / maven `-pl X`, `./x.test.sh`, `bash -x x.test.sh`. `clean` goals stay unmapped.
25
36
  # Exit: 0 ok | 2 usage or bad repo path. bash 3.2 portable.
26
37
  set -uo pipefail
27
38
 
@@ -53,6 +64,38 @@ fi
53
64
  US="$(printf '\037')"
54
65
  row() { printf '%s\t%s\t%s\t%s\n' "$1" "$2" "$3" "$4" >> "$T/rows"; }
55
66
  JS_EXT="js jsx ts tsx mjs cjs"
67
+ # check recognisers shared by package.json scripts / Makefile targets and the workflow allowlist (forge#4300)
68
+ PKG_SCRIPTS="test|lint|typecheck|type-check|check|tsc"
69
+ MAKE_TARGETS="test|lint|check|typecheck|vet"
70
+ # forge#4315: `test:unit`, `lint:css` ... are the same check under a sub-name
71
+ # (#4314: only a closed set of suffixes; fixers `lint:fix`, watchers, e2e/integration (need services), deploy-ish and
72
+ # snapshot-updating scripts never run pre-PR.) $2 (optional) is the script's command: `format` is a check only when
73
+ # that command is a check form.
74
+ PKG_DENY='fix|watch|write|update|dev|serve|start|e2e|integration|deploy|release|publish|snapshot|-u'
75
+ PKG_SUFFIXES="unit ci node types type ts js static check"
76
+ pkg_check_script() {
77
+ [ -n "${1:-}" ] || return 1
78
+ case "$1" in *[!A-Za-z0-9:_.-]*) return 1 ;; esac
79
+ b=${1%%:*}
80
+ case "|$PKG_SCRIPTS|" in *"|$b|"*) ;; *) return 1 ;; esac
81
+ [ "$b" = "$1" ] && return 0
82
+ printf '%s' "$1" | grep -qiE "$PKG_DENY" && return 1
83
+ rest=${1#*:}; [ -n "$rest" ] || return 1
84
+ oldifs=$IFS; IFS=:; set -f
85
+ for seg in $rest; do
86
+ ok=0
87
+ case " $PKG_SUFFIXES " in *" $seg "*) [ -n "$seg" ] && ok=1 ;; esac
88
+ if [ "$ok" = 0 ] && [ "$seg" = format ]; then
89
+ case " ${2:-} " in *" --check "*|*" -c "*|*" --list-different "*|*" -l "*) ok=1 ;; esac
90
+ fi
91
+ [ "$ok" = 1 ] || { IFS=$oldifs; set +f; return 1; }
92
+ done
93
+ IFS=$oldifs; set +f
94
+ return 0
95
+ }
96
+ make_check_target() { case "|$MAKE_TARGETS|" in *"|$1|"*) [ -n "$1" ] ;; *) return 1 ;; esac; }
97
+ has_word() { k=$1; shift; for w in "$@"; do [ "$w" = "$k" ] && return 0; done; return 1; }
98
+ safe_dir() { case "$1" in ""|/*|*[!A-Za-z0-9._/-]*|..|../*|*/..|*/../*) return 1 ;; esac; return 0; }
56
99
  # globs for the extensions in $2 under dir $1 ("." = repo root)
57
100
  ext_globs() {
58
101
  g=""; pre=""; [ "$1" = "." ] || pre="$1/"
@@ -70,10 +113,10 @@ grep -E '(^|/)package\.json$' "$T/files" | grep -v -E '(^|/)node_modules/' | sor
70
113
  globs="$(ext_globs "$d" "$JS_EXT")$US$pj"
71
114
  tag=""
72
115
  if [ -n "$(ls "$d"/vitest.config.* "$d"/jest.config.* 2>/dev/null)" ] || jq -e 'has("jest")' "$pj" >/dev/null 2>&1; then tag=" (vitest/jest config)"; fi
73
- jq -r '(.scripts // {}) | to_entries[] | select(.value | type == "string") | .key' "$pj" 2>/dev/null | while IFS= read -r s; do
116
+ jq -r '(.scripts // {}) | to_entries[] | select(.value | type == "string") | .key + "\t" + .value' "$pj" 2>/dev/null | while IFS="$(printf '\t')" read -r s sv; do
74
117
  case "$s" in
75
118
  test) row "$pj#scripts.$s$tag" "$d" "$PM run $s" "$globs" ;;
76
- lint|typecheck|type-check|check) row "$pj#scripts.$s" "$d" "$PM run $s" "$globs" ;;
119
+ *) pkg_check_script "$s" "$sv" && row "$pj#scripts.$s" "$d" "$PM run $s" "$globs" ;;
77
120
  esac
78
121
  done
79
122
  done
@@ -105,20 +148,20 @@ done
105
148
  # ---- Makefile ----
106
149
  for mf in Makefile GNUmakefile; do
107
150
  [ -f "$mf" ] || continue
108
- grep -oE '^(test|lint|check|typecheck|vet)[[:space:]]*:' "$mf" | sed -E 's/[[:space:]]*:$//' | sort -u | while IFS= read -r t; do
151
+ grep -oE "^($MAKE_TARGETS)[[:space:]]*:" "$mf" | sed -E 's/[[:space:]]*:$//' | sort -u | while IFS= read -r t; do
109
152
  row "$mf#$t" "." "make $t" "**"
110
153
  done
111
154
  done
112
155
 
113
156
  # ---- CI workflow run: steps ----
114
- # Emits: job US jobname US stepname US command (US = \037, so empty names survive `read`), one per command line; and P TAB path for each `paths:` filter item
157
+ # Emits: job US command (US = \037), one per command line; and P TAB path for each `paths:` filter item
115
158
  # before `jobs:`. A block ends at the first non-blank line not indented past the `run` key (sibling keys such as env:).
116
159
  wf_extract() {
117
160
  awk '
118
161
  function indent(s) { match(s, /^ */); return RLENGTH }
119
162
  function unq(s) { sub(/^[ \t]+/, "", s); sub(/[ \t]+$/, "", s); if (s ~ /^".*"$/ || s ~ /^\x27.*\x27$/) s = substr(s, 2, length(s) - 2); return s }
120
- function emit(c) { c = unq(c); if (c != "") print job "\037" jname "\037" sname "\037" c }
121
- BEGIN { injobs = 0; jind = -1; block = 0; inpaths = 0; job = ""; jname = ""; sname = "" }
163
+ function emit(c) { c = unq(c); if (c != "") print job "\037" c }
164
+ BEGIN { injobs = 0; jind = -1; block = 0; inpaths = 0; job = "" }
122
165
  {
123
166
  line = $0; sub(/\r$/, "", line)
124
167
  if (block) {
@@ -143,13 +186,7 @@ wf_extract() {
143
186
  next
144
187
  }
145
188
  if (line ~ /^[^ \t#]/) { injobs = 0; next }
146
- if (line ~ /^ +[A-Za-z0-9_-]+:[ \t]*$/ && (jind < 0 || indent(line) == jind)) { jind = indent(line); job = line; sub(/^ +/, "", job); sub(/:.*$/, "", job); jname = ""; sname = ""; next }
147
- if (line ~ /^[ \t]*-[ \t]/) sname = ""
148
- if (line ~ /^[ \t]*(-[ \t]+)?name:/) {
149
- nm = line; sub(/^[ \t]*(-[ \t]+)?name:[ \t]*/, "", nm); sub(/[ \t]+#.*$/, "", nm)
150
- if (line !~ /^[ \t]*-[ \t]/ && indent(line) == jind + 2) jname = unq(nm); else sname = unq(nm)
151
- next
152
- }
189
+ if (line ~ /^ +[A-Za-z0-9_-]+:[ \t]*$/ && (jind < 0 || indent(line) == jind)) { jind = indent(line); job = line; sub(/^ +/, "", job); sub(/:.*$/, "", job); next }
153
190
  if (line ~ /^[ \t]*(-[ \t]+)?run:/) {
154
191
  rest = line; sub(/^[ \t]*(-[ \t]+)?run:[ \t]*/, "", rest); sub(/[ \t]+#.*$/, "", rest)
155
192
  # bind = the column of the `run` key: step keys after it (env:, shell:) sit there and end the block
@@ -160,23 +197,136 @@ wf_extract() {
160
197
  END { if (block && pend != "") emit(pend) }
161
198
  ' "$1"
162
199
  }
163
- skip_cmd() {
164
- case "$1" in
165
- \#*|echo\ *|echo|cd\ *|export\ *|set\ *|if\ *|then|fi|else|for\ *|do|done|\}*|\{*|*'$GITHUB_'*|*'>> $GITHUB'*) return 0 ;;
166
- npm\ ci*|npm\ install*|npm\ i|npm\ i\ *|pnpm\ install*|pnpm\ i|yarn|yarn\ install*|pip\ install*|pip3\ install*|python*\ -m\ pip\ install*|uv\ sync*|uv\ pip\ install*|poetry\ install*|go\ mod\ download*|apt-get*|sudo\ *|corepack\ *|brew\ *) return 0 ;;
200
+ # forge#4300: allowlist. 0 when the whole command $1 is a recognised check; sets CK_CWD (one leading `cd|pushd DIR &&`,
201
+ # else ".") and CK_CMD (the command without that prefix). Names never matter: a mutating command is dropped whatever its
202
+ # step is called, and a check is mapped whatever it is called.
203
+ check_cmd() {
204
+ c=$1; CK_CWD=.; CK_CMD=
205
+ # shellcheck disable=SC2016 # literal ${{ and $( patterns
206
+ # forge#4316: runner-file writes, redirects and process substitution are CI plumbing, never a local check
207
+ case "$c" in *'${{'*|*'`'*|*'$('*|*'$GITHUB_'*|*'<'*|*'>'*) return 1 ;; esac
208
+ case "$c" in
209
+ "cd "*" && "*|"pushd "*" && "*)
210
+ d=${c#* }; d=${d%%" && "*}; c=${c#*" && "}
211
+ case "$d" in ""|/*|*[!A-Za-z0-9._/-]*|..|../*|*/..|*/../*) return 1 ;; esac
212
+ d=${d%/}; [ -n "$d" ] && [ "$d" != . ] && CK_CWD=$d ;;
213
+ esac
214
+ c=${c%" && popd"}
215
+ case "$c" in *';'*|*'|'*|*'&'*) return 1 ;; esac
216
+ # forge#4318: a quoted word hides a flag from the word checks below ("--fix"); a real check never needs quotes
217
+ case "$c" in *\"*|*\'*) return 1 ;; esac
218
+ CK_CMD=$c
219
+ set -f
220
+ # shellcheck disable=SC2086 # split into words on purpose; set -f stops glob expansion
221
+ set -- $c
222
+ set +f
223
+ while [ $# -gt 0 ]; do case "$1" in [A-Za-z_]*=*) shift ;; *) break ;; esac; done
224
+ case "${1:-} ${2:-}" in
225
+ "uv run"|"poetry run") shift 2; case "${1:-} ${2:-}" in "python -m"|"python3 -m") shift 2 ;; esac ;; # forge#4318: uv run python -m X
226
+ "pnpm exec"|"yarn exec"|"python -m"|"python3 -m"|"bundle exec") shift 2 ;;
227
+ "python3."[0-9]" -m"|"python3."[0-9][0-9]" -m") shift 2 ;; # forge#4318: a versioned interpreter
228
+ "npx "*|"bunx "*) shift; case "${1:-}" in --yes|-y) shift ;; esac ;;
229
+ esac
230
+ tool=${1:-}
231
+ # forge#4316/#4318: a fixer, snapshot updater or write-target flag rewrites the tree or writes outside it; matched as a
232
+ # flag family (`--fix-only`, `--write=true`, `--w`), and `-w` only off a package manager (there it names a workspace)
233
+ for w in "$@"; do
234
+ case "$w" in
235
+ --fix|--fix[=-]*|--add-noqa*|--unsafe-fixes*|--write|--write[=-]*|--update|--update[=-]*|--updateSnapshot*|--snapshot-update*) return 1 ;;
236
+ --outDir*|--outdir*|--out-dir*|--outFile*|--declarationDir*|--tsBuildInfoFile*|--junitxml*|--junit-xml*|--basetemp*|--html*|-exec|-exec=*|-toolexec*) return 1 ;;
237
+ /*|*=/*|..|../*|*/..|*/../*|*=..|*=../*) return 1 ;; # an absolute or parent path is outside the checkout
238
+ -w|-w=*|--w|--w=*) case "$tool" in npm|pnpm|yarn|bun) ;; *) return 1 ;; esac ;;
239
+ -u) case "$tool" in jest|vitest|npm|pnpm|yarn|bun) return 1 ;; esac ;;
240
+ -o|-o=*) [ "$tool" = go ] && return 1 ;;
241
+ esac
242
+ done
243
+ case "${1:-} ${2:-}" in
244
+ "black "*|"ruff format") has_word --check "$@" || return 1 ;;
245
+ "gofmt "*) has_word -l "$@" || has_word -d "$@" || return 1 ;;
246
+ "prettier "*) has_word --check "$@" || has_word -c "$@" || has_word --list-different "$@" || has_word -l "$@" || return 1 ;;
247
+ esac
248
+ case "${1:-}" in
249
+ npm|pnpm|yarn|bun)
250
+ pm=$1; shift
251
+ case "$pm ${1:-}" in # forge#4315: pnpm -r test, npm --prefix DIR test; forge#4318: npm -w PKG, pnpm -w, pnpm --filter X
252
+ "pnpm -r"|"pnpm --recursive"|"pnpm -w"|"pnpm --workspace-root") shift ;;
253
+ "npm --prefix"|"npm -w"|"npm --workspace") [ $# -ge 2 ] && safe_dir "$2" || return 1; shift 2 ;;
254
+ "pnpm --filter"|"pnpm -F") [ $# -ge 2 ] || return 1; case "$2" in *[!A-Za-z0-9@._/-]*) return 1 ;; esac; shift 2 ;;
255
+ esac
256
+ [ "${1:-}" = test ] && return 0
257
+ [ "${1:-}" = run ] && pkg_check_script "${2:-}" && return 0
258
+ case "$pm" in yarn|pnpm) pkg_check_script "${1:-}" && return 0 ;; esac
259
+ return 1 ;;
260
+ cargo)
261
+ a=${2:-}; [ "${a#+}" != "$a" ] && a=${3:-} # forge#4318: cargo +TOOLCHAIN <sub>
262
+ case "$a" in test|clippy|check) return 0 ;; fmt) has_word --check "$@"; return ;; esac; return 1 ;;
263
+ ./gradlew|gradlew|gradle|./mvnw|mvnw|mvn)
264
+ # every non-flag word is a check task/goal (`:mod:test` qualified for gradle); publish/deploy/install/clean never pass;
265
+ # forge#4318: the value of --tests / -pl / --projects / -P / -D is not a goal
266
+ case "$1" in *mvn*) goals="test|verify|validate" ;; *) goals="test|check|lint" ;; esac
267
+ shift; nt=0; skipv=""
268
+ for w in "$@"; do
269
+ if [ -n "$skipv" ]; then skipv=""; continue; fi
270
+ case "$w" in --tests|-pl|--projects|-P|-D) skipv=1; continue ;; -*) continue ;; esac
271
+ t=${w##*:}
272
+ case "|$goals|" in *"|$t|"*) [ -n "$t" ] && nt=$((nt+1)) ;; *) return 1 ;; esac
273
+ done
274
+ [ "$nt" -gt 0 ]; return ;;
275
+ rspec|unittest) return 0 ;;
276
+ python|python3) [ "${2:-}" = manage.py ] && [ "${3:-}" = test ]; return ;;
277
+ tox|nox)
278
+ # forge#4318: `--env=X`, `-eX` and comma lists are split, case-folded, and packaging envs (dist, pypi) are publish too
279
+ shift; prev=""
280
+ for w in "$@"; do
281
+ v=""
282
+ case "$prev" in -e|-s|--env|--session|--sessions) v=$w ;; esac
283
+ case "$w" in --env=*|--session=*|--sessions=*) v=${w#*=} ;; --*) ;; -e?*|-s?*) v=${w#-?} ;; esac
284
+ if [ -n "$v" ]; then
285
+ v=$(printf '%s' "$v" | tr '[:upper:]' '[:lower:]')
286
+ case ",$v," in *publish*|*release*|*deploy*|*upload*|*pypi*|*twine*|*,dist,*) return 1 ;; esac
287
+ fi
288
+ prev=$w
289
+ done
290
+ return 0 ;;
291
+ deno) [ "${2:-}" = test ]; return ;;
292
+ bash|sh)
293
+ # forge#4318: shell option words (-x, -eu) before the script are allowed
294
+ shift; while [ $# -gt 0 ]; do case "$1" in -[a-z]*) shift ;; *) break ;; esac; done
295
+ case "${1:-}" in *.test.sh) safe_dir "$1"; return ;; esac; return 1 ;;
296
+ ./*.test.sh) safe_dir "${1#./}"; return ;;
297
+ make)
298
+ # forge#4316: every target must be a check (`make test deploy` is dropped); VAR=value and flags are not targets
299
+ # forge#4318: `make -C DIR` runs in DIR: it becomes the entry cwd, not a target
300
+ shift; nt=0; prev=""; mdir=""
301
+ for w in "$@"; do
302
+ if [ "$prev" = -C ]; then mdir=$w; prev=$w; continue; fi
303
+ prev=$w
304
+ case "$w" in -*|*=*) continue ;; esac; make_check_target "$w" || return 1; nt=$((nt+1))
305
+ done
306
+ if [ -n "$mdir" ]; then
307
+ safe_dir "$mdir" || return 1
308
+ CK_CMD=${CK_CMD/ -C $mdir/}; mdir=${mdir%/}
309
+ if [ "$mdir" != . ]; then if [ "$CK_CWD" = . ]; then CK_CWD=$mdir; else CK_CWD="$CK_CWD/$mdir"; fi; fi
310
+ fi
311
+ [ "$nt" -gt 0 ]; return ;;
312
+ go) case "${2:-}" in test|vet|build) return 0 ;; esac; return 1 ;;
313
+ node) [ "${2:-}" = --test ]; return ;;
314
+ eslint|tsc|prettier|vitest|jest|pytest|ruff|flake8|pylint|mypy|black|golangci-lint|gofmt|staticcheck|shellcheck) return 0 ;;
167
315
  esac
168
316
  return 1
169
317
  }
170
- # forge#4297: deploy, publish, release, push and secret-using steps are never check commands
171
- deploy_step() {
172
- grep -qiE 'deploy|publish|release|push|secret' <<<"$*"
173
- }
174
318
  tool_globs() {
175
319
  c=" $1 "
176
320
  case "$c" in
321
+ *\ cargo\ *) printf '%s' "**/*.rs$US**/Cargo.toml${US}Cargo.lock"; return ;;
322
+ *gradlew\ *|*\ gradle\ *|*mvnw\ *|*\ mvn\ *) printf '%s' "$(ext_globs . "java kt kts gradle groovy scala")$US**/pom.xml"; return ;;
323
+ *[\ /]rspec\ *) printf '**/*.rb'; return ;;
324
+ *\ manage.py\ test\ *|*\ unittest\ *|*\ tox\ *|*\ nox\ *) printf '**/*.py'; return ;;
325
+ *\ npm\ --prefix\ *) d=${1#*--prefix }; d=${d%% *}; ext_globs "$d" "$JS_EXT"; return ;;
326
+ *\ deno\ *|*\ bun\ *|*\ bunx\ *) ext_globs . "$JS_EXT"; return ;;
177
327
  *[\ /]ruff\ *|*[\ /]flake8\ *|*[\ /]pylint\ *|*[\ /]mypy\ *|*[\ /]black\ *|*[\ /]pytest\ *) printf '**/*.py'; return ;;
178
328
  *[\ /]golangci-lint\ *|*[\ /]gofmt\ *|*[\ /]staticcheck\ *|*\ go\ *) printf '**/*.go'; return ;;
179
- *[\ /]shellcheck\ *|*\ bash\ *.sh\ *|*\ sh\ *.sh\ *) printf '**/*.sh'; return ;;
329
+ *[\ /]shellcheck\ *|*\ bash\ *.sh\ *|*\ sh\ *.sh\ *|*.test.sh\ *) printf '**/*.sh'; return ;;
180
330
  *[\ /]eslint\ *|*[\ /]tsc\ *|*[\ /]prettier\ *|*[\ /]vitest\ *|*[\ /]jest\ *|*\ node\ --test*|*\ npm\ *|*\ pnpm\ *|*\ yarn\ *|*\ npx\ *) ext_globs . "$JS_EXT"; return ;;
181
331
  esac
182
332
  printf ''
@@ -185,13 +335,14 @@ grep -E '^\.github/workflows/[^/]+\.(yml|yaml)$' "$T/files" | sort | while IFS=
185
335
  [ -f "$wf" ] || continue
186
336
  wf_extract "$wf" > "$T/wf"
187
337
  paths=$(awk -F'\t' '$1 == "P" { printf "%s\037", $2 }' "$T/wf"); paths=${paths%"$US"}
188
- awk -F'\t' '$1 != "P"' "$T/wf" | while IFS="$(printf '\037')" read -r job jname sname cmd; do
189
- skip_cmd "$cmd" && continue
190
- deploy_step "$job" "$jname" "$sname" "$cmd" && continue
191
- g=$(tool_globs "$cmd")
338
+ awk -F'\t' '$1 != "P"' "$T/wf" | while IFS="$US" read -r job cmd; do
339
+ check_cmd "$cmd" || continue
340
+ g=$(tool_globs "$CK_CMD")
341
+ [ -n "$g" ] && [ "$CK_CWD" != . ] && g="$CK_CWD/${g//$US/$US$CK_CWD/}"
192
342
  [ -n "$g" ] || g="$paths"
193
- [ -n "$g" ] || g="**"
194
- row "$wf#jobs.$job" "." "$cmd" "$g"
343
+ [ -n "$g" ] || { [ "$CK_CWD" != . ] && g="$CK_CWD/**"; }
344
+ [ -n "$g" ] || continue
345
+ row "$wf#jobs.$job" "$CK_CWD" "$CK_CMD" "$g"
195
346
  done
196
347
  done
197
348
 
@@ -207,5 +358,6 @@ MAP=$(jq -R -s -c '
207
358
  if [ "$STDOUT" = 1 ]; then printf '%s\n' "$MAP"; exit 0; fi
208
359
  [ -n "$OUT" ] || OUT=".forge/test-map.json"
209
360
  mkdir -p "$(dirname "$OUT")" || die "cannot create $(dirname "$OUT")"
210
- printf '%s\n' "$MAP" > "$OUT.tmp.$$" && mv "$OUT.tmp.$$" "$OUT" || die "cannot write $OUT"
361
+ printf '%s\n' "$MAP" > "$OUT.tmp.$$" || die "cannot write $OUT"
362
+ mv "$OUT.tmp.$$" "$OUT" || die "cannot write $OUT"
211
363
  echo "$OUT"
@@ -15,11 +15,13 @@ eq() { if [ "$2" = "$3" ]; then ok; else bad "$1 (got: $2 want: $3)"; fi }
15
15
 
16
16
  # copy a fixture into a fresh git repo and build its map
17
17
  setup() {
18
- rm -rf "$TMP/$1"; cp -R "$FIX/$1" "$TMP/$1"; cd "$TMP/$1" || exit 1
18
+ rm -rf "${TMP:?}/$1"; cp -R "$FIX/$1" "$TMP/$1"; cd "$TMP/$1" || exit 1
19
19
  git init -q -b main; git config user.email t@example.com; git config user.name t; git config commit.gpgsign false
20
20
  git add -A; git commit -qm init
21
21
  OUTP="$(bash "$S")"; RC=$?
22
22
  }
23
+ # forge#4300: workflow-derived entries of the map JSON in $2 carry no bare `**` glob
24
+ nostar() { eq "$1 no bare ** on workflow entries" "$(printf '%s' "$2" | jq '[.entries[] | select(.source | startswith(".github/workflows/")) | .globs[] | select(. == "**")] | length')" 0; }
23
25
  # selected commands for changed files, sorted, joined by |
24
26
  sel() { node "$SEL" --map .forge/test-map.json --select "$@" | LC_ALL=C sort | paste -sd'|' -; }
25
27
 
@@ -44,13 +46,15 @@ eq "python non-py" "$(sel uv.lock)" ""
44
46
  # ---- 3. go: go.mod, Makefile and CI workflow steps; CI-only lint step; install lines skipped ----
45
47
  setup go
46
48
  eq "go rc" "$RC" 0
47
- eq "go file" "$(sel main.go)" "./scripts/check-docs|go test ./...|go vet ./...|golangci-lint run ./...|make test"
48
- eq "nested go file" "$(sel internal/util/util.go)" "./scripts/check-docs|go test ./...|go vet ./...|golangci-lint run ./...|make test"
49
+ eq "go file" "$(sel main.go)" "go test ./...|go vet ./...|golangci-lint run ./...|make test|make vet"
50
+ eq "nested go file" "$(sel internal/util/util.go)" "go test ./...|go vet ./...|golangci-lint run ./...|make test|make vet"
51
+ eq "unrecognised ci script dropped" "$(jq '[.entries[] | select(.command | test("check-docs"))] | length' .forge/test-map.json)" 0
49
52
  eq "ci-only lint source" "$(jq -r '.entries[] | select(.command == "golangci-lint run ./...") | .source' .forge/test-map.json)" ".github/workflows/ci.yml#jobs.lint"
50
53
  eq "install line skipped" "$(jq '[.entries[] | select(.command | test("mod download"))] | length' .forge/test-map.json)" 0
51
- eq "paths filter fallback" "$(jq -c '.entries[] | select(.command == "./scripts/check-docs") | .globs' .forge/test-map.json)" '["**.go","go.mod"]'
54
+ eq "paths filter fallback" "$(jq -c '.entries[] | select(.command == "make vet") | .globs' .forge/test-map.json)" '["**.go","go.mod"]'
52
55
  eq "docs file" "$(sel README.md)" "make test"
53
56
  eq "go.sum" "$(sel go.sum)" "go test ./...|go vet ./...|make test"
57
+ nostar go "$(cat .forge/test-map.json)"
54
58
 
55
59
  # ---- 4. regenerable: a rerun is byte-identical; --stdout writes nothing; --output ----
56
60
  cp .forge/test-map.json "$TMP/first.json"
@@ -84,8 +88,11 @@ jobs:
84
88
  EOF
85
89
  git add -A
86
90
  OUTS="$(bash "$S" --stdout)"
87
- eq "wf commands" "$(printf '%s' "$OUTS" | jq -r '[.entries[].command] | sort | join("|")')" "./custom-check|bash scripts/verify.sh --strict|ruff check --fix|shellcheck scripts/*.sh"
88
- eq "wf globs" "$(printf '%s' "$OUTS" | jq -c '[.entries[] | {(.command): .globs}] | add')" '{"./custom-check":["**"],"bash scripts/verify.sh --strict":["**/*.sh"],"ruff check --fix":["**/*.py"],"shellcheck scripts/*.sh":["**/*.sh"]}'
91
+ # forge#4300: ./custom-check and `bash scripts/verify.sh` are not allowlisted checks, so they are dropped
92
+ # forge#4316: `ruff check --fix` rewrites the tree, so it is dropped too
93
+ eq "wf commands" "$(printf '%s' "$OUTS" | jq -r '[.entries[].command] | sort | join("|")')" "shellcheck scripts/*.sh"
94
+ eq "wf globs" "$(printf '%s' "$OUTS" | jq -c '[.entries[] | {(.command): .globs}] | add')" '{"shellcheck scripts/*.sh":["**/*.sh"]}'
95
+ nostar wf "$OUTS"
89
96
 
90
97
  # ---- 5b. forge#4297: `- run: |` then env:/shell: ends the block; deploy/publish/release/push/secret steps dropped ----
91
98
  mkdir -p "$TMP/wf2/.github/workflows"; cd "$TMP/wf2" || exit 1
@@ -130,6 +137,56 @@ EOF
130
137
  git add -A
131
138
  OUTS="$(bash "$S" --stdout)"
132
139
  eq "wf2 commands" "$(printf '%s' "$OUTS" | jq -r '[.entries[].command] | sort | join("|")')" "npm run lint|npm test|tsc --noEmit"
140
+ nostar wf2 "$OUTS"
141
+
142
+ # ---- 5d. forge#4300: allowlist, not a name/substring denylist; names never decide; cd/pushd prefix becomes the cwd ----
143
+ mkdir -p "$TMP/wf3/.github/workflows"; cd "$TMP/wf3" || exit 1
144
+ git init -q -b main
145
+ cat > .github/workflows/ci.yml <<'EOF'
146
+ on: pull_request
147
+ jobs:
148
+ release-tests:
149
+ runs-on: ubuntu-latest
150
+ steps:
151
+ - run: npm test
152
+ - run: pytest tests/test_release.py
153
+ - run: pushd sub && make test
154
+ - run: npm run lint
155
+ name: Deploy
156
+ - name: Run tests
157
+ uses: some/action@v1
158
+ with:
159
+ name: release
160
+ - name: Typecheck
161
+ with:
162
+ name: release
163
+ run: npm run typecheck
164
+ ship:
165
+ runs-on: ubuntu-latest
166
+ steps:
167
+ - run: twine upload dist/*
168
+ - run: terraform apply -auto-approve
169
+ - run: kubectl apply -f k8s/
170
+ - run: ./deploy.sh
171
+ name: test
172
+ - name: Tests
173
+ with:
174
+ name: test
175
+ run: ./publish.sh
176
+ - run: pytest && twine upload dist/*
177
+ - run: cd ../other && make test
178
+ - run: npm run build
179
+ - run: npm test -- --token=${{ secrets.NPM_TOKEN }}
180
+ EOF
181
+ git add -A
182
+ OUTS="$(bash "$S" --stdout)"
183
+ eq "wf3 commands" "$(printf '%s' "$OUTS" | jq -r '[.entries[].command] | sort | join("|")')" "make test|npm run lint|npm run typecheck|npm test|pytest tests/test_release.py"
184
+ eq "wf3 release-tests job kept" "$(printf '%s' "$OUTS" | jq -r '[.entries[].source] | unique | join("|")')" ".github/workflows/ci.yml#jobs.release-tests"
185
+ eq "wf3 pushd cwd + globs" "$(printf '%s' "$OUTS" | jq -c '[.entries[] | select(.command == "make test") | {cwd, globs}]')" '[{"cwd":"sub","globs":["sub/**"]}]'
186
+ eq "wf3 no mutating command" "$(printf '%s' "$OUTS" | jq '[.entries[] | select(.command | test("twine|terraform|kubectl|deploy|publish|build|secrets"))] | length')" 0
187
+ nostar wf3 "$OUTS"
188
+ printf '%s' "$OUTS" > "$TMP/wf3-map.json"
189
+ eq "wf3 pushd entry runs in its dir" "$(node "$SEL" --map "$TMP/wf3-map.json" --select sub/Makefile)" "cd sub && make test"
133
190
 
134
191
  # ---- 5c. forge#4298: a package dir with a space and a non-ASCII name keeps whole globs; workflow paths with spaces ----
135
192
  mkdir -p "$TMP/sp/web app" "$TMP/sp/pkg/ü/src" "$TMP/sp/.github/workflows"; cd "$TMP/sp" || exit 1
@@ -148,15 +205,201 @@ jobs:
148
205
  runs-on: ubuntu-latest
149
206
  steps:
150
207
  - run: ./build-docs.sh
208
+ - run: make check
151
209
  EOF
152
210
  git add -A
153
211
  OUTS="$(bash "$S" --stdout)"
154
212
  eq "spaced package globs" "$(printf '%s' "$OUTS" | jq -c '[.entries[] | select(.cwd == "web app") | .globs[]] | map(select(endswith("package.json") or endswith(".js")))')" '["web app/**/*.js","web app/package.json"]'
155
213
  eq "non-ASCII package dir" "$(printf '%s' "$OUTS" | jq -r '[.entries[] | select(.command == "npm run lint") | .cwd] | join("|")')" "pkg/ü"
156
- eq "spaced workflow path filter" "$(printf '%s' "$OUTS" | jq -c '[.entries[] | select(.command == "./build-docs.sh") | .globs]')" '[["docs site/**"]]'
214
+ eq "spaced workflow path filter" "$(printf '%s' "$OUTS" | jq -c '[.entries[] | select(.command == "make check") | .globs]')" '[["docs site/**"]]'
215
+ nostar sp "$OUTS"
216
+ eq "unrecognised docs script dropped" "$(printf '%s' "$OUTS" | jq '[.entries[] | select(.command == "./build-docs.sh")] | length')" 0
157
217
  printf '%s' "$OUTS" > "$TMP/sp-map.json"
158
218
  eq "spaced changed file selects its package test" "$(node "$SEL" --map "$TMP/sp-map.json" --select "web app/src/x.js")" "cd 'web app' && npm run test"
159
219
 
220
+ # ---- 5e. forge#4315: real runners per ecosystem are mapped with their globs; mutating siblings stay dropped ----
221
+ # wf_case NAME: the step commands on stdin become .github/workflows/ci.yml of a fresh repo; OUTS = its map
222
+ wf_case() {
223
+ mkdir -p "$TMP/$1/.github/workflows"; cd "$TMP/$1" || exit 1
224
+ git init -q -b main
225
+ { printf 'on: pull_request\njobs:\n ci:\n runs-on: ubuntu-latest\n steps:\n'; sed 's/^/ - run: /'; } > .github/workflows/ci.yml
226
+ git add -A
227
+ OUTS="$(bash "$S" --stdout)"
228
+ nostar "$1" "$OUTS"
229
+ }
230
+ cmds() { printf '%s' "$OUTS" | jq -r '[.entries[].command] | sort | join("|")'; }
231
+ globs_of() { printf '%s' "$OUTS" | jq -r --arg c "$1" '[.entries[] | select(.command == $c) | .globs[]] | join(",")'; }
232
+
233
+ wf_case rust <<'WF'
234
+ cargo test --workspace
235
+ cargo clippy -- -D warnings
236
+ cargo publish
237
+ cargo build --release
238
+ WF
239
+ eq "rust commands" "$(cmds)" "cargo clippy -- -D warnings|cargo test --workspace"
240
+ eq "rust globs" "$(globs_of "cargo test --workspace")" "**/*.rs,**/Cargo.toml,Cargo.lock"
241
+
242
+ wf_case jvm <<'WF'
243
+ ./gradlew test
244
+ ./gradlew :app:check --no-daemon
245
+ mvn -B test
246
+ ./gradlew publish
247
+ ./gradlew test publish
248
+ mvn deploy
249
+ mvn install
250
+ WF
251
+ eq "jvm commands" "$(cmds)" "./gradlew :app:check --no-daemon|./gradlew test|mvn -B test"
252
+ eq "jvm globs" "$(globs_of "mvn -B test")" "**/*.java,**/*.kt,**/*.kts,**/*.gradle,**/*.groovy,**/*.scala,**/pom.xml"
253
+
254
+ wf_case ruby <<'WF'
255
+ bundle exec rspec
256
+ rspec spec/models
257
+ bundle exec rake release
258
+ WF
259
+ eq "ruby commands" "$(cmds)" "bundle exec rspec|rspec spec/models"
260
+ eq "ruby globs" "$(globs_of "bundle exec rspec")" "**/*.rb"
261
+
262
+ wf_case pyrun <<'WF'
263
+ python manage.py test
264
+ python -m unittest discover
265
+ tox
266
+ nox -s lint
267
+ tox -e publish
268
+ nox -s release
269
+ python manage.py migrate
270
+ WF
271
+ eq "python runner commands" "$(cmds)" "nox -s lint|python -m unittest discover|python manage.py test|tox"
272
+ eq "python runner globs" "$(globs_of "tox")" "**/*.py"
273
+
274
+ wf_case js <<'WF'
275
+ deno test
276
+ bun test
277
+ pnpm -r test
278
+ npm --prefix web test
279
+ npm run test:unit
280
+ deno deploy
281
+ npm run deploy:prod
282
+ npm --prefix ../x test
283
+ WF
284
+ eq "js runner commands" "$(cmds)" "bun test|deno test|npm --prefix web test|npm run test:unit|pnpm -r test"
285
+ eq "bun globs" "$(globs_of "bun test")" "**/*.js,**/*.jsx,**/*.ts,**/*.tsx,**/*.mjs,**/*.cjs"
286
+ eq "npm --prefix globs" "$(globs_of "npm --prefix web test")" "web/**/*.js,web/**/*.jsx,web/**/*.ts,web/**/*.tsx,web/**/*.mjs,web/**/*.cjs"
287
+
288
+ # ---- 5f. forge#4314: package.json script suffix allowlist (fixers/watchers/e2e/deploy never run pre-PR) ----
289
+ mkdir -p "$TMP/sfx"; cd "$TMP/sfx" || exit 1
290
+ git init -q -b main
291
+ cat > package.json <<'JSON'
292
+ {"name":"s","scripts":{"test":"x","test:unit":"x","test:ci":"x","test:node":"x","test:types":"x","lint:ts":"x","lint:js":"x","check:static":"x","check:types":"x","tsc":"tsc --noEmit","typecheck":"tsc","lint:check":"x","test:unit:ci":"x","lint:format":"prettier --check .","check:format":"prettier --write .",
293
+ "lint:fix":"eslint --fix .","check:fix":"x","test:watch":"x","test:e2e":"x","test:integration":"x","test:deploy":"x","test:update":"x","test:snapshot":"x","lint:css":"x","test:unit:watch":"x","test:-u":"x","lint:write":"x","test:dev":"x","check:release":"x"}}
294
+ JSON
295
+ git add -A
296
+ SFX="$(bash "$S" --stdout | jq -r '[.entries[].source | sub("^package.json#scripts\\.";"")] | sort | join("|")')"
297
+ eq "script suffix allowlist" "$SFX" "check:static|check:types|lint:check|lint:format|lint:js|lint:ts|test|test:ci|test:node|test:types|test:unit|test:unit:ci|tsc|typecheck"
298
+
299
+ wf_case sfxwf <<'WF'
300
+ npm run lint:fix
301
+ npm run check:fix
302
+ pnpm test:watch
303
+ yarn test:e2e
304
+ bun run test:integration
305
+ npm run test:deploy
306
+ pnpm run lint:fix
307
+ npm run test:unit
308
+ pnpm lint:ts
309
+ yarn check:types
310
+ bun run test:ci
311
+ WF
312
+ eq "workflow suffix commands" "$(cmds)" "bun run test:ci|npm run test:unit|pnpm lint:ts|yarn check:types"
313
+
314
+ wf_case shell <<'WF'
315
+ bash scripts/a.test.sh
316
+ sh tests/b.test.sh
317
+ bash scripts/deploy.sh
318
+ bash scripts/verify.sh
319
+ WF
320
+ eq "shell commands" "$(cmds)" "bash scripts/a.test.sh|sh tests/b.test.sh"
321
+ eq "shell globs" "$(globs_of "bash scripts/a.test.sh")" "**/*.sh"
322
+
323
+ # ---- 5f. forge#4316: redirects, process substitution, $GITHUB_*, extra make targets and fixer forms are dropped ----
324
+ # (make needs a cwd for its globs: a workflow entry never gets a bare **)
325
+ wf_case unsafe <<'WF'
326
+ pytest > out.txt
327
+ pytest 2>&1
328
+ diff <(a) <(b)
329
+ tee >(x)
330
+ cd sub && make test deploy
331
+ eslint --fix .
332
+ prettier --write .
333
+ gofmt -w .
334
+ black .
335
+ ruff format .
336
+ cargo clippy --fix
337
+ pytest --junitxml=$GITHUB_WORKSPACE/r.xml
338
+ cd sub && make test lint
339
+ cd sub && make test V=1
340
+ black --check .
341
+ ruff format --check .
342
+ prettier --check .
343
+ gofmt -l .
344
+ eslint .
345
+ WF
346
+ eq "unsafe forms dropped, check forms kept" "$(cmds)" "black --check .|eslint .|gofmt -l .|make test V=1|make test lint|prettier --check .|ruff format --check ."
347
+
348
+ # ---- 5g. forge#4318: flag-family fixers, snapshot updaters, write targets, quotes and tox publish envs are dropped ----
349
+ wf_case bypass <<'WF'
350
+ cargo clippy --fix
351
+ tox --env=publish
352
+ tox --session=release
353
+ tox -ePUBLISH
354
+ tox -e dist,pypi
355
+ nox -s Release
356
+ eslint "--fix" .
357
+ gofmt -l --w .
358
+ gofmt -w=true .
359
+ prettier --write=true .
360
+ prettier -cw .
361
+ ruff --fix-only .
362
+ ruff check --add-noqa .
363
+ jest -u
364
+ jest --updateSnapshot
365
+ vitest -u
366
+ vitest --update
367
+ pytest --snapshot-update
368
+ npm test -- -u
369
+ go build -o /tmp/x ./...
370
+ go test -o out.test ./...
371
+ go test -exec foo ./...
372
+ tsc --outDir /tmp/out
373
+ tsc --outFile out.js
374
+ pytest --junitxml=/abs/x.xml
375
+ pytest --basetemp=../t
376
+ mvn clean test
377
+ ./gradlew clean test
378
+ tox -e py311,lint
379
+ pytest -q
380
+ WF
381
+ eq "bypass forms dropped" "$(cmds)" "pytest -q|tox -e py311,lint"
382
+
383
+ # ---- 5h. forge#4318: workspace flags, versioned/wrapped runners, -C, toolchains and test scripts are mapped ----
384
+ wf_case forms <<'WF'
385
+ npm -w pkg test
386
+ pnpm -w test
387
+ uv run python -m pytest
388
+ python3.12 -m pytest
389
+ npx --yes vitest
390
+ pnpm --filter web test
391
+ make -C sub test
392
+ mvn -pl mod test
393
+ ./gradlew test --tests Foo
394
+ cargo +nightly test
395
+ cargo fmt --check
396
+ ./x.test.sh
397
+ bash -x x.test.sh
398
+ WF
399
+ eq "real forms mapped" "$(cmds)" "./gradlew test --tests Foo|./x.test.sh|bash -x x.test.sh|cargo +nightly test|cargo fmt --check|make test|mvn -pl mod test|npm -w pkg test|npx --yes vitest|pnpm --filter web test|pnpm -w test|python3.12 -m pytest|uv run python -m pytest"
400
+ eq "make -C dir is the cwd" "$(printf '%s' "$OUTS" | jq -r '.entries[] | select(.command == "make test") | "\(.cwd) \(.globs | join(","))"')" "sub sub/**"
401
+ eq "test script globs" "$(globs_of "./x.test.sh")" "**/*.sh"
402
+
160
403
  # ---- 6. usage ----
161
404
  bash "$S" --repo-path "$TMP/missing" >/dev/null 2>&1; eq "bad repo path" "$?" 2
162
405
  bash "$S" --nope >/dev/null 2>&1; eq "unknown option" "$?" 2