forgedock 1.29.0 → 1.29.1

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.
@@ -17,11 +17,16 @@
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 `**`.
25
30
  # Exit: 0 ok | 2 usage or bad repo path. bash 3.2 portable.
26
31
  set -uo pipefail
27
32
 
@@ -53,6 +58,38 @@ fi
53
58
  US="$(printf '\037')"
54
59
  row() { printf '%s\t%s\t%s\t%s\n' "$1" "$2" "$3" "$4" >> "$T/rows"; }
55
60
  JS_EXT="js jsx ts tsx mjs cjs"
61
+ # check recognisers shared by package.json scripts / Makefile targets and the workflow allowlist (forge#4300)
62
+ PKG_SCRIPTS="test|lint|typecheck|type-check|check|tsc"
63
+ MAKE_TARGETS="test|lint|check|typecheck|vet"
64
+ # forge#4315: `test:unit`, `lint:css` ... are the same check under a sub-name
65
+ # (#4314: only a closed set of suffixes; fixers `lint:fix`, watchers, e2e/integration (need services), deploy-ish and
66
+ # snapshot-updating scripts never run pre-PR.) $2 (optional) is the script's command: `format` is a check only when
67
+ # that command is a check form.
68
+ PKG_DENY='fix|watch|write|update|dev|serve|start|e2e|integration|deploy|release|publish|snapshot|-u'
69
+ PKG_SUFFIXES="unit ci node types type ts js static check"
70
+ pkg_check_script() {
71
+ [ -n "${1:-}" ] || return 1
72
+ case "$1" in *[!A-Za-z0-9:_.-]*) return 1 ;; esac
73
+ b=${1%%:*}
74
+ case "|$PKG_SCRIPTS|" in *"|$b|"*) ;; *) return 1 ;; esac
75
+ [ "$b" = "$1" ] && return 0
76
+ printf '%s' "$1" | grep -qiE "$PKG_DENY" && return 1
77
+ rest=${1#*:}; [ -n "$rest" ] || return 1
78
+ oldifs=$IFS; IFS=:; set -f
79
+ for seg in $rest; do
80
+ ok=0
81
+ case " $PKG_SUFFIXES " in *" $seg "*) [ -n "$seg" ] && ok=1 ;; esac
82
+ if [ "$ok" = 0 ] && [ "$seg" = format ]; then
83
+ case " ${2:-} " in *" --check "*|*" -c "*|*" --list-different "*|*" -l "*) ok=1 ;; esac
84
+ fi
85
+ [ "$ok" = 1 ] || { IFS=$oldifs; set +f; return 1; }
86
+ done
87
+ IFS=$oldifs; set +f
88
+ return 0
89
+ }
90
+ make_check_target() { case "|$MAKE_TARGETS|" in *"|$1|"*) [ -n "$1" ] ;; *) return 1 ;; esac; }
91
+ has_word() { k=$1; shift; for w in "$@"; do [ "$w" = "$k" ] && return 0; done; return 1; }
92
+ safe_dir() { case "$1" in ""|/*|*[!A-Za-z0-9._/-]*|..|../*|*/..|*/../*) return 1 ;; esac; return 0; }
56
93
  # globs for the extensions in $2 under dir $1 ("." = repo root)
57
94
  ext_globs() {
58
95
  g=""; pre=""; [ "$1" = "." ] || pre="$1/"
@@ -70,10 +107,10 @@ grep -E '(^|/)package\.json$' "$T/files" | grep -v -E '(^|/)node_modules/' | sor
70
107
  globs="$(ext_globs "$d" "$JS_EXT")$US$pj"
71
108
  tag=""
72
109
  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
110
+ 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
111
  case "$s" in
75
112
  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" ;;
113
+ *) pkg_check_script "$s" "$sv" && row "$pj#scripts.$s" "$d" "$PM run $s" "$globs" ;;
77
114
  esac
78
115
  done
79
116
  done
@@ -105,20 +142,20 @@ done
105
142
  # ---- Makefile ----
106
143
  for mf in Makefile GNUmakefile; do
107
144
  [ -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
145
+ grep -oE "^($MAKE_TARGETS)[[:space:]]*:" "$mf" | sed -E 's/[[:space:]]*:$//' | sort -u | while IFS= read -r t; do
109
146
  row "$mf#$t" "." "make $t" "**"
110
147
  done
111
148
  done
112
149
 
113
150
  # ---- 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
151
+ # Emits: job US command (US = \037), one per command line; and P TAB path for each `paths:` filter item
115
152
  # before `jobs:`. A block ends at the first non-blank line not indented past the `run` key (sibling keys such as env:).
116
153
  wf_extract() {
117
154
  awk '
118
155
  function indent(s) { match(s, /^ */); return RLENGTH }
119
156
  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 = "" }
157
+ function emit(c) { c = unq(c); if (c != "") print job "\037" c }
158
+ BEGIN { injobs = 0; jind = -1; block = 0; inpaths = 0; job = "" }
122
159
  {
123
160
  line = $0; sub(/\r$/, "", line)
124
161
  if (block) {
@@ -143,13 +180,7 @@ wf_extract() {
143
180
  next
144
181
  }
145
182
  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
- }
183
+ if (line ~ /^ +[A-Za-z0-9_-]+:[ \t]*$/ && (jind < 0 || indent(line) == jind)) { jind = indent(line); job = line; sub(/^ +/, "", job); sub(/:.*$/, "", job); next }
153
184
  if (line ~ /^[ \t]*(-[ \t]+)?run:/) {
154
185
  rest = line; sub(/^[ \t]*(-[ \t]+)?run:[ \t]*/, "", rest); sub(/[ \t]+#.*$/, "", rest)
155
186
  # bind = the column of the `run` key: step keys after it (env:, shell:) sit there and end the block
@@ -160,20 +191,93 @@ wf_extract() {
160
191
  END { if (block && pend != "") emit(pend) }
161
192
  ' "$1"
162
193
  }
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 ;;
194
+ # forge#4300: allowlist. 0 when the whole command $1 is a recognised check; sets CK_CWD (one leading `cd|pushd DIR &&`,
195
+ # else ".") and CK_CMD (the command without that prefix). Names never matter: a mutating command is dropped whatever its
196
+ # step is called, and a check is mapped whatever it is called.
197
+ check_cmd() {
198
+ c=$1; CK_CWD=.; CK_CMD=
199
+ # shellcheck disable=SC2016 # literal ${{ and $( patterns
200
+ # forge#4316: runner-file writes, redirects and process substitution are CI plumbing, never a local check
201
+ case "$c" in *'${{'*|*'`'*|*'$('*|*'$GITHUB_'*|*'<'*|*'>'*) return 1 ;; esac
202
+ case "$c" in
203
+ "cd "*" && "*|"pushd "*" && "*)
204
+ d=${c#* }; d=${d%%" && "*}; c=${c#*" && "}
205
+ case "$d" in ""|/*|*[!A-Za-z0-9._/-]*|..|../*|*/..|*/../*) return 1 ;; esac
206
+ d=${d%/}; [ -n "$d" ] && [ "$d" != . ] && CK_CWD=$d ;;
207
+ esac
208
+ c=${c%" && popd"}
209
+ case "$c" in *';'*|*'|'*|*'&'*) return 1 ;; esac
210
+ CK_CMD=$c
211
+ set -f
212
+ # shellcheck disable=SC2086 # split into words on purpose; set -f stops glob expansion
213
+ set -- $c
214
+ set +f
215
+ while [ $# -gt 0 ]; do case "$1" in [A-Za-z_]*=*) shift ;; *) break ;; esac; done
216
+ case "${1:-} ${2:-}" in
217
+ "uv run"|"poetry run"|"pnpm exec"|"yarn exec"|"python -m"|"python3 -m"|"bundle exec") shift 2 ;;
218
+ "npx "*|"bunx "*) shift ;;
219
+ esac
220
+ # forge#4316: a fixer rewrites the tree; only its read-only check form is a check
221
+ for w in "$@"; do case "$w" in --fix|--fix=*|--write|-w) return 1 ;; esac; done
222
+ case "${1:-} ${2:-}" in
223
+ "black "*|"ruff format") has_word --check "$@" || return 1 ;;
224
+ "gofmt "*) has_word -l "$@" || has_word -d "$@" || return 1 ;;
225
+ "prettier "*) has_word --check "$@" || has_word -c "$@" || has_word --list-different "$@" || has_word -l "$@" || return 1 ;;
226
+ esac
227
+ case "${1:-}" in
228
+ npm|pnpm|yarn|bun)
229
+ pm=$1; shift
230
+ case "$pm ${1:-}" in # forge#4315: pnpm -r test, npm --prefix DIR test
231
+ "pnpm -r"|"pnpm --recursive") shift ;;
232
+ "npm --prefix") [ $# -ge 2 ] && safe_dir "$2" || return 1; shift 2 ;;
233
+ esac
234
+ [ "${1:-}" = test ] && return 0
235
+ [ "${1:-}" = run ] && pkg_check_script "${2:-}" && return 0
236
+ case "$pm" in yarn|pnpm) pkg_check_script "${1:-}" && return 0 ;; esac
237
+ return 1 ;;
238
+ cargo) case "${2:-}" in test|clippy|check) return 0 ;; esac; return 1 ;;
239
+ ./gradlew|gradlew|gradle|./mvnw|mvnw|mvn)
240
+ # every non-flag word is a check task/goal (`:mod:test` qualified for gradle); publish/deploy/install never pass
241
+ case "$1" in *mvn*) goals="test|verify|validate" ;; *) goals="test|check|lint" ;; esac
242
+ shift; nt=0
243
+ for w in "$@"; do
244
+ case "$w" in -*) continue ;; esac
245
+ t=${w##*:}
246
+ case "|$goals|" in *"|$t|"*) [ -n "$t" ] && nt=$((nt+1)) ;; *) return 1 ;; esac
247
+ done
248
+ [ "$nt" -gt 0 ]; return ;;
249
+ rspec|unittest) return 0 ;;
250
+ python|python3) [ "${2:-}" = manage.py ] && [ "${3:-}" = test ]; return ;;
251
+ tox|nox)
252
+ shift; prev=""
253
+ for w in "$@"; do
254
+ case "$prev" in -e|-s|--env|--session|--sessions) case ",$w," in *publish*|*release*|*deploy*|*upload*) return 1 ;; esac ;; esac
255
+ case "$w" in -e*|-s*) case "${w#-?}" in *publish*|*release*|*deploy*|*upload*) return 1 ;; esac ;; esac
256
+ prev=$w
257
+ done
258
+ return 0 ;;
259
+ deno) [ "${2:-}" = test ]; return ;;
260
+ bash|sh) case "${2:-}" in *.test.sh) safe_dir "$2"; return ;; esac; return 1 ;;
261
+ make)
262
+ # forge#4316: every target must be a check (`make test deploy` is dropped); VAR=value and flags are not targets
263
+ shift; nt=0
264
+ for w in "$@"; do case "$w" in -*|*=*) continue ;; esac; make_check_target "$w" || return 1; nt=$((nt+1)); done
265
+ [ "$nt" -gt 0 ]; return ;;
266
+ go) case "${2:-}" in test|vet|build) return 0 ;; esac; return 1 ;;
267
+ node) [ "${2:-}" = --test ]; return ;;
268
+ eslint|tsc|prettier|vitest|jest|pytest|ruff|flake8|pylint|mypy|black|golangci-lint|gofmt|staticcheck|shellcheck) return 0 ;;
167
269
  esac
168
270
  return 1
169
271
  }
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
272
  tool_globs() {
175
273
  c=" $1 "
176
274
  case "$c" in
275
+ *\ cargo\ *) printf '%s' "**/*.rs$US**/Cargo.toml${US}Cargo.lock"; return ;;
276
+ *gradlew\ *|*\ gradle\ *|*mvnw\ *|*\ mvn\ *) printf '%s' "$(ext_globs . "java kt kts gradle groovy scala")$US**/pom.xml"; return ;;
277
+ *[\ /]rspec\ *) printf '**/*.rb'; return ;;
278
+ *\ manage.py\ test\ *|*\ unittest\ *|*\ tox\ *|*\ nox\ *) printf '**/*.py'; return ;;
279
+ *\ npm\ --prefix\ *) d=${1#*--prefix }; d=${d%% *}; ext_globs "$d" "$JS_EXT"; return ;;
280
+ *\ deno\ *|*\ bun\ *|*\ bunx\ *) ext_globs . "$JS_EXT"; return ;;
177
281
  *[\ /]ruff\ *|*[\ /]flake8\ *|*[\ /]pylint\ *|*[\ /]mypy\ *|*[\ /]black\ *|*[\ /]pytest\ *) printf '**/*.py'; return ;;
178
282
  *[\ /]golangci-lint\ *|*[\ /]gofmt\ *|*[\ /]staticcheck\ *|*\ go\ *) printf '**/*.go'; return ;;
179
283
  *[\ /]shellcheck\ *|*\ bash\ *.sh\ *|*\ sh\ *.sh\ *) printf '**/*.sh'; return ;;
@@ -185,13 +289,14 @@ grep -E '^\.github/workflows/[^/]+\.(yml|yaml)$' "$T/files" | sort | while IFS=
185
289
  [ -f "$wf" ] || continue
186
290
  wf_extract "$wf" > "$T/wf"
187
291
  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")
292
+ awk -F'\t' '$1 != "P"' "$T/wf" | while IFS="$US" read -r job cmd; do
293
+ check_cmd "$cmd" || continue
294
+ g=$(tool_globs "$CK_CMD")
295
+ [ -n "$g" ] && [ "$CK_CWD" != . ] && g="$CK_CWD/${g//$US/$US$CK_CWD/}"
192
296
  [ -n "$g" ] || g="$paths"
193
- [ -n "$g" ] || g="**"
194
- row "$wf#jobs.$job" "." "$cmd" "$g"
297
+ [ -n "$g" ] || { [ "$CK_CWD" != . ] && g="$CK_CWD/**"; }
298
+ [ -n "$g" ] || continue
299
+ row "$wf#jobs.$job" "$CK_CWD" "$CK_CMD" "$g"
195
300
  done
196
301
  done
197
302
 
@@ -207,5 +312,6 @@ MAP=$(jq -R -s -c '
207
312
  if [ "$STDOUT" = 1 ]; then printf '%s\n' "$MAP"; exit 0; fi
208
313
  [ -n "$OUT" ] || OUT=".forge/test-map.json"
209
314
  mkdir -p "$(dirname "$OUT")" || die "cannot create $(dirname "$OUT")"
210
- printf '%s\n' "$MAP" > "$OUT.tmp.$$" && mv "$OUT.tmp.$$" "$OUT" || die "cannot write $OUT"
315
+ printf '%s\n' "$MAP" > "$OUT.tmp.$$" || die "cannot write $OUT"
316
+ mv "$OUT.tmp.$$" "$OUT" || die "cannot write $OUT"
211
317
  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,146 @@ 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
+
160
348
  # ---- 6. usage ----
161
349
  bash "$S" --repo-path "$TMP/missing" >/dev/null 2>&1; eq "bad repo path" "$?" 2
162
350
  bash "$S" --nope >/dev/null 2>&1; eq "unknown option" "$?" 2