forgedock 1.28.0 → 1.29.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 (53) hide show
  1. package/bin/engine/outcome-codes.mjs +29 -0
  2. package/bin/engine-cli.mjs +71 -16
  3. package/bin/lean/batch.test.mjs +2 -2
  4. package/bin/lean/conformance.mjs +47 -2
  5. package/bin/lean/conformance.test.mjs +53 -1
  6. package/bin/lean/context.mjs +333 -0
  7. package/bin/lean/context.test.mjs +279 -0
  8. package/bin/lean/effects.mjs +19 -1
  9. package/bin/lean/lean.mjs +171 -35
  10. package/bin/lean/lean.test.mjs +413 -7
  11. package/bin/lean/orchestrate.mjs +80 -3
  12. package/bin/lean/prefilter.mjs +2 -2
  13. package/bin/lean/prefilter.test.mjs +3 -0
  14. package/bin/lean/prompts.mjs +22 -7
  15. package/bin/lean/recovery.test.mjs +230 -0
  16. package/bin/lean/render.mjs +25 -5
  17. package/bin/lean/testmap.mjs +84 -0
  18. package/bin/lean/testmap.test.mjs +58 -0
  19. package/bin/lean/verify.mjs +48 -3
  20. package/bin/lean/verify.test.mjs +81 -3
  21. package/bin/stats/index.mjs +12 -2
  22. package/bin/stats/local-source.mjs +13 -0
  23. package/bin/stats/query.mjs +27 -0
  24. package/bin/stats/stats.test.mjs +41 -2
  25. package/commands/orchestrate.md +52 -6
  26. package/docs/CONFIG.md +15 -0
  27. package/docs/OUTCOME-CODES.md +14 -0
  28. package/docs/RELEASE-NOTES.md +9 -0
  29. package/package.json +1 -1
  30. package/scripts/check-spec-markers.sh +1 -0
  31. package/scripts/fixtures/test-map/go/.github/workflows/ci.yml +23 -0
  32. package/scripts/fixtures/test-map/go/Makefile +5 -0
  33. package/scripts/fixtures/test-map/go/README.md +1 -0
  34. package/scripts/fixtures/test-map/go/go.mod +3 -0
  35. package/scripts/fixtures/test-map/go/internal/util/util.go +3 -0
  36. package/scripts/fixtures/test-map/go/main.go +3 -0
  37. package/scripts/fixtures/test-map/node/package.json +10 -0
  38. package/scripts/fixtures/test-map/node/packages/ui/package.json +4 -0
  39. package/scripts/fixtures/test-map/node/packages/ui/src/button.tsx +1 -0
  40. package/scripts/fixtures/test-map/node/pnpm-lock.yaml +1 -0
  41. package/scripts/fixtures/test-map/node/src/a.ts +1 -0
  42. package/scripts/fixtures/test-map/node/vitest.config.ts +1 -0
  43. package/scripts/fixtures/test-map/python/pkg/__init__.py +0 -0
  44. package/scripts/fixtures/test-map/python/pkg/mod.py +2 -0
  45. package/scripts/fixtures/test-map/python/pyproject.toml +9 -0
  46. package/scripts/fixtures/test-map/python/tests/test_mod.py +5 -0
  47. package/scripts/fixtures/test-map/python/uv.lock +1 -0
  48. package/scripts/repo-facts.sh +146 -0
  49. package/scripts/repo-facts.test.sh +106 -0
  50. package/scripts/test-map.sh +211 -0
  51. package/scripts/test-map.test.sh +165 -0
  52. package/scripts/wait-ci-green.sh +10 -4
  53. package/scripts/wait-ci-green.test.sh +9 -0
@@ -0,0 +1,211 @@
1
+ #!/usr/bin/env bash
2
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
3
+ # SPDX-License-Identifier: AGPL-3.0-or-later
4
+ #
5
+ # test-map.sh — deterministic `file glob → check command` map for a repo (forge#4272). No LLM, no network, runs none of
6
+ # the repo's commands: git + jq + awk only (no yq). Codebase-agnostic: node, python, go, make and CI workflow steps.
7
+ #
8
+ # Usage: test-map.sh [--repo-path DIR] [--output FILE | --stdout]
9
+ # default output <repo>/.forge/test-map.json, rewritten on every run (a regenerable cache; deleting it restores the
10
+ # pre-PR step's `learned.test_commands` behaviour).
11
+ # Output: {"version":1,"entries":[{"globs":[...],"command","cwd","source"}]}, sorted by source then command; entries
12
+ # with the same (command, cwd) are merged (globs unioned, first source kept).
13
+ # Sources:
14
+ # package.json (root + every tracked one outside node_modules): scripts test/lint/typecheck/type-check/check →
15
+ # `<pm> run <script>` in the package dir (pm from the root lockfile), JS/TS globs under that dir
16
+ # pytest (pyproject [tool.pytest*], pytest.ini, setup.cfg [tool:pytest], conftest.py) and ruff config → `pytest`,
17
+ # `ruff check .` (through `uv run` / `poetry run` when that lockfile exists), `**/*.py`
18
+ # go.mod (dir D) → `go test ./...`, `go vet ./...` in D, `D/**/*.go`, `D/go.mod`, `D/go.sum`
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.
25
+ # Exit: 0 ok | 2 usage or bad repo path. bash 3.2 portable.
26
+ set -uo pipefail
27
+
28
+ die() { echo "test-map: $*" >&2; exit 2; }
29
+ command -v jq >/dev/null 2>&1 || die "jq is required"
30
+ REPO="."; OUT=""; STDOUT=0
31
+ while [ $# -gt 0 ]; do
32
+ case "$1" in
33
+ --repo-path) [ $# -ge 2 ] && [ -n "$2" ] || die "usage: test-map.sh [--repo-path DIR] [--output FILE | --stdout]"; REPO="$2"; shift 2 ;;
34
+ --output) [ $# -ge 2 ] && [ -n "$2" ] || die "usage: test-map.sh [--repo-path DIR] [--output FILE | --stdout]"; OUT="$2"; shift 2 ;;
35
+ --stdout) STDOUT=1; shift ;;
36
+ *) die "unknown option: $1" ;;
37
+ esac
38
+ done
39
+ [ -d "$REPO" ] || die "repo path not found: $REPO"
40
+ case "$OUT" in ""|/*) ;; *) OUT="$PWD/$OUT" ;; esac
41
+ cd "$REPO" || die "cannot enter $REPO"
42
+
43
+ T=$(mktemp -d "${TMPDIR:-/tmp}/test-map.XXXXXX") || die "mktemp failed"
44
+ trap 'rm -rf "$T"' EXIT
45
+ if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
46
+ # -z and quotepath=off (#4298): non-ASCII and spaced paths come out verbatim, never C-quoted
47
+ git -c core.quotepath=off ls-files -z --cached --others --exclude-standard | tr '\0' '\n' > "$T/files"
48
+ else
49
+ find . -type f -not -path './.git/*' | sed 's|^\./||' | sort > "$T/files"
50
+ fi
51
+ : > "$T/rows"
52
+ # row: source TAB cwd TAB command TAB globs separated by US (\037, #4298: a glob may contain spaces)
53
+ US="$(printf '\037')"
54
+ row() { printf '%s\t%s\t%s\t%s\n' "$1" "$2" "$3" "$4" >> "$T/rows"; }
55
+ JS_EXT="js jsx ts tsx mjs cjs"
56
+ # globs for the extensions in $2 under dir $1 ("." = repo root)
57
+ ext_globs() {
58
+ g=""; pre=""; [ "$1" = "." ] || pre="$1/"
59
+ for e in $2; do g="$g$US${pre}**/*.$e"; done
60
+ printf '%s' "${g#"$US"}"
61
+ }
62
+
63
+ # ---- package.json scripts ----
64
+ PM=npm
65
+ [ -f yarn.lock ] && PM=yarn
66
+ [ -f pnpm-lock.yaml ] && PM=pnpm
67
+ grep -E '(^|/)package\.json$' "$T/files" | grep -v -E '(^|/)node_modules/' | sort | while IFS= read -r pj; do
68
+ [ -f "$pj" ] || continue
69
+ d=$(dirname "$pj")
70
+ globs="$(ext_globs "$d" "$JS_EXT")$US$pj"
71
+ tag=""
72
+ 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
74
+ case "$s" in
75
+ 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" ;;
77
+ esac
78
+ done
79
+ done
80
+
81
+ # ---- python ----
82
+ RUN=""
83
+ [ -f poetry.lock ] && RUN="poetry run "
84
+ [ -f uv.lock ] && RUN="uv run "
85
+ PY_SRC=""
86
+ if [ -f pyproject.toml ] && grep -qE '^\[tool\.pytest' pyproject.toml; then PY_SRC="pyproject.toml#tool.pytest"
87
+ elif [ -f pytest.ini ]; then PY_SRC="pytest.ini"
88
+ elif [ -f setup.cfg ] && grep -qE '^\[tool:pytest\]' setup.cfg; then PY_SRC="setup.cfg#tool:pytest"
89
+ elif grep -qE '(^|/)conftest\.py$' "$T/files"; then PY_SRC="conftest.py"; fi
90
+ [ -n "$PY_SRC" ] && row "$PY_SRC" "." "${RUN}pytest" "**/*.py$US**/conftest.py"
91
+ RUFF_SRC=""
92
+ if [ -f pyproject.toml ] && grep -qE '^\[tool\.ruff' pyproject.toml; then RUFF_SRC="pyproject.toml#tool.ruff"
93
+ elif [ -f ruff.toml ]; then RUFF_SRC="ruff.toml"
94
+ elif [ -f .ruff.toml ]; then RUFF_SRC=".ruff.toml"; fi
95
+ [ -n "$RUFF_SRC" ] && row "$RUFF_SRC" "." "${RUN}ruff check ." "**/*.py"
96
+
97
+ # ---- go ----
98
+ grep -E '(^|/)go\.mod$' "$T/files" | grep -v -E '(^|/)vendor/' | sort | while IFS= read -r gm; do
99
+ d=$(dirname "$gm"); pre=""; [ "$d" = "." ] || pre="$d/"
100
+ globs="${pre}**/*.go$US${pre}go.mod$US${pre}go.sum"
101
+ row "$gm" "$d" "go test ./..." "$globs"
102
+ row "$gm" "$d" "go vet ./..." "$globs"
103
+ done
104
+
105
+ # ---- Makefile ----
106
+ for mf in Makefile GNUmakefile; do
107
+ [ -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
109
+ row "$mf#$t" "." "make $t" "**"
110
+ done
111
+ done
112
+
113
+ # ---- 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
115
+ # before `jobs:`. A block ends at the first non-blank line not indented past the `run` key (sibling keys such as env:).
116
+ wf_extract() {
117
+ awk '
118
+ function indent(s) { match(s, /^ */); return RLENGTH }
119
+ 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 = "" }
122
+ {
123
+ line = $0; sub(/\r$/, "", line)
124
+ if (block) {
125
+ if (line ~ /^[ \t]*$/) next
126
+ if (indent(line) > bind) {
127
+ l = line; sub(/^ +/, "", l)
128
+ if (fold) { pend = (pend == "" ? l : pend " " l); next }
129
+ if (pend != "") { l = pend " " l; pend = "" }
130
+ if (l ~ /\\$/) { pend = substr(l, 1, length(l) - 1); sub(/[ \t]+$/, "", pend); next }
131
+ emit(l); next
132
+ }
133
+ if (pend != "") { emit(pend); pend = "" }
134
+ block = 0
135
+ }
136
+ if (line ~ /^jobs:[ \t]*$/) { injobs = 1; inpaths = 0; next }
137
+ if (!injobs) {
138
+ if (line ~ /^[ \t]*paths:[ \t]*$/) { inpaths = 1; pind = indent(line); next }
139
+ if (inpaths) {
140
+ if (line ~ /^[ \t]*-[ \t]/ && indent(line) >= pind) { p = line; sub(/^[ \t]*-[ \t]*/, "", p); sub(/[ \t]+#.*$/, "", p); print "P\t" unq(p); next }
141
+ if (line !~ /^[ \t]*$/ && line !~ /^[ \t]*#/) inpaths = 0
142
+ }
143
+ next
144
+ }
145
+ 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
+ }
153
+ if (line ~ /^[ \t]*(-[ \t]+)?run:/) {
154
+ rest = line; sub(/^[ \t]*(-[ \t]+)?run:[ \t]*/, "", rest); sub(/[ \t]+#.*$/, "", rest)
155
+ # bind = the column of the `run` key: step keys after it (env:, shell:) sit there and end the block
156
+ if (rest ~ /^[|>][-+0-9]*$/) { block = 1; pend = ""; fold = (rest ~ /^>/); bind = match(line, /run:/) - 1; next }
157
+ emit(rest)
158
+ }
159
+ }
160
+ END { if (block && pend != "") emit(pend) }
161
+ ' "$1"
162
+ }
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 ;;
167
+ esac
168
+ return 1
169
+ }
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
+ tool_globs() {
175
+ c=" $1 "
176
+ case "$c" in
177
+ *[\ /]ruff\ *|*[\ /]flake8\ *|*[\ /]pylint\ *|*[\ /]mypy\ *|*[\ /]black\ *|*[\ /]pytest\ *) printf '**/*.py'; return ;;
178
+ *[\ /]golangci-lint\ *|*[\ /]gofmt\ *|*[\ /]staticcheck\ *|*\ go\ *) printf '**/*.go'; return ;;
179
+ *[\ /]shellcheck\ *|*\ bash\ *.sh\ *|*\ sh\ *.sh\ *) printf '**/*.sh'; return ;;
180
+ *[\ /]eslint\ *|*[\ /]tsc\ *|*[\ /]prettier\ *|*[\ /]vitest\ *|*[\ /]jest\ *|*\ node\ --test*|*\ npm\ *|*\ pnpm\ *|*\ yarn\ *|*\ npx\ *) ext_globs . "$JS_EXT"; return ;;
181
+ esac
182
+ printf ''
183
+ }
184
+ grep -E '^\.github/workflows/[^/]+\.(yml|yaml)$' "$T/files" | sort | while IFS= read -r wf; do
185
+ [ -f "$wf" ] || continue
186
+ wf_extract "$wf" > "$T/wf"
187
+ 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")
192
+ [ -n "$g" ] || g="$paths"
193
+ [ -n "$g" ] || g="**"
194
+ row "$wf#jobs.$job" "." "$cmd" "$g"
195
+ done
196
+ done
197
+
198
+ MAP=$(jq -R -s -c '
199
+ [split("\n")[] | select(length > 0) | split("\t") | {source: .[0], cwd: .[1], command: .[2], globs: (.[3] | split("\u001f") | map(select(length > 0)))}]
200
+ | sort_by(.source, .command)
201
+ | reduce .[] as $e ({order: [], by: {}};
202
+ ([$e.command, $e.cwd] | tojson) as $k
203
+ | if .by[$k] then .by[$k].globs += ($e.globs - .by[$k].globs)
204
+ else .order += [$k] | .by[$k] = {globs: $e.globs, command: $e.command, cwd: $e.cwd, source: $e.source} end)
205
+ | {version: 1, entries: [.by[.order[]]]}' < "$T/rows") || die "jq failed"
206
+
207
+ if [ "$STDOUT" = 1 ]; then printf '%s\n' "$MAP"; exit 0; fi
208
+ [ -n "$OUT" ] || OUT=".forge/test-map.json"
209
+ mkdir -p "$(dirname "$OUT")" || die "cannot create $(dirname "$OUT")"
210
+ printf '%s\n' "$MAP" > "$OUT.tmp.$$" && mv "$OUT.tmp.$$" "$OUT" || die "cannot write $OUT"
211
+ echo "$OUT"
@@ -0,0 +1,165 @@
1
+ #!/usr/bin/env bash
2
+ # test-map.test.sh — cases for scripts/test-map.sh over the node, python and go fixtures (forge#4272).
3
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
4
+ # SPDX-License-Identifier: AGPL-3.0-or-later
5
+ set -uo pipefail
6
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
7
+ S="$HERE/test-map.sh"
8
+ SEL="$HERE/../bin/lean/testmap.mjs"
9
+ FIX="$HERE/fixtures/test-map"
10
+ PASS=0; FAILN=0
11
+ TMP="$(mktemp -d "${TMPDIR:-/tmp}/test-map-test.XXXXXX")"; trap 'rm -rf "$TMP"' EXIT
12
+ ok() { PASS=$((PASS+1)); }
13
+ bad() { FAILN=$((FAILN+1)); echo "FAIL: $1"; }
14
+ eq() { if [ "$2" = "$3" ]; then ok; else bad "$1 (got: $2 want: $3)"; fi }
15
+
16
+ # copy a fixture into a fresh git repo and build its map
17
+ setup() {
18
+ rm -rf "$TMP/$1"; cp -R "$FIX/$1" "$TMP/$1"; cd "$TMP/$1" || exit 1
19
+ git init -q -b main; git config user.email t@example.com; git config user.name t; git config commit.gpgsign false
20
+ git add -A; git commit -qm init
21
+ OUTP="$(bash "$S")"; RC=$?
22
+ }
23
+ # selected commands for changed files, sorted, joined by |
24
+ sel() { node "$SEL" --map .forge/test-map.json --select "$@" | LC_ALL=C sort | paste -sd'|' -; }
25
+
26
+ # ---- 1. node: root scripts for root files, workspace scripts for package files ----
27
+ setup node
28
+ eq "node rc" "$RC" 0
29
+ eq "node output path" "$OUTP" ".forge/test-map.json"
30
+ eq "schema" "$(jq -c '[.version, (.entries | length > 0), (.entries | all(has("globs") and has("command") and has("cwd") and has("source")))]' .forge/test-map.json)" '[1,true,true]'
31
+ eq "non-check script ignored" "$(jq '[.entries[] | select(.command | test("build"))] | length' .forge/test-map.json)" 0
32
+ eq "root file" "$(sel src/a.ts)" "pnpm run lint|pnpm run test"
33
+ eq "package file" "$(sel packages/ui/src/button.tsx)" "cd packages/ui && pnpm run test|cd packages/ui && pnpm run typecheck|pnpm run lint|pnpm run test"
34
+ eq "vitest config noted" "$(jq -r '.entries[] | select(.cwd == "." and .command == "pnpm run test") | .source' .forge/test-map.json)" "package.json#scripts.test (vitest/jest config)"
35
+ eq "unrelated file" "$(sel README.md)" ""
36
+
37
+ # ---- 2. python: pytest + ruff through uv ----
38
+ setup python
39
+ eq "python rc" "$RC" 0
40
+ eq "python file" "$(sel pkg/mod.py)" "uv run pytest|uv run ruff check ."
41
+ eq "python test file" "$(sel tests/test_mod.py)" "uv run pytest|uv run ruff check ."
42
+ eq "python non-py" "$(sel uv.lock)" ""
43
+
44
+ # ---- 3. go: go.mod, Makefile and CI workflow steps; CI-only lint step; install lines skipped ----
45
+ setup go
46
+ 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 "ci-only lint source" "$(jq -r '.entries[] | select(.command == "golangci-lint run ./...") | .source' .forge/test-map.json)" ".github/workflows/ci.yml#jobs.lint"
50
+ 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"]'
52
+ eq "docs file" "$(sel README.md)" "make test"
53
+ eq "go.sum" "$(sel go.sum)" "go test ./...|go vet ./...|make test"
54
+
55
+ # ---- 4. regenerable: a rerun is byte-identical; --stdout writes nothing; --output ----
56
+ cp .forge/test-map.json "$TMP/first.json"
57
+ bash "$S" >/dev/null
58
+ if cmp -s .forge/test-map.json "$TMP/first.json"; then ok; else bad "rerun not byte-identical"; fi
59
+ rm -rf .forge; OUTS="$(bash "$S" --stdout)"
60
+ eq "stdout same map" "$OUTS" "$(cat "$TMP/first.json")"
61
+ eq "stdout writes nothing" "$([ -e .forge ] && echo yes || echo no)" no
62
+ bash "$S" --repo-path "$TMP/go" --output "$TMP/out/map.json" >/dev/null
63
+ if cmp -s "$TMP/out/map.json" "$TMP/first.json"; then ok; else bad "--output differs"; fi
64
+
65
+ # ---- 5. block runs, continuation lines, setup lines, workflow without paths ----
66
+ mkdir -p "$TMP/wf/.github/workflows"; cd "$TMP/wf" || exit 1
67
+ git init -q -b main
68
+ cat > .github/workflows/lint.yaml <<'EOF'
69
+ on: push
70
+ jobs:
71
+ checks:
72
+ runs-on: ubuntu-latest
73
+ steps:
74
+ - run: npm ci
75
+ - run: "shellcheck scripts/*.sh"
76
+ - run: >-
77
+ ruff check
78
+ --fix
79
+ - run: |
80
+ echo hi
81
+ bash scripts/verify.sh \
82
+ --strict
83
+ ./custom-check
84
+ EOF
85
+ git add -A
86
+ 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"]}'
89
+
90
+ # ---- 5b. forge#4297: `- run: |` then env:/shell: ends the block; deploy/publish/release/push/secret steps dropped ----
91
+ mkdir -p "$TMP/wf2/.github/workflows"; cd "$TMP/wf2" || exit 1
92
+ git init -q -b main
93
+ cat > .github/workflows/ci.yml <<'EOF'
94
+ on:
95
+ push:
96
+ branches: [main]
97
+ jobs:
98
+ test:
99
+ runs-on: ubuntu-latest
100
+ steps:
101
+ - run: |
102
+ npm test
103
+ env:
104
+ CI: true
105
+ shell: bash
106
+ - name: Lint
107
+ run: |
108
+ npm run lint
109
+ working-directory: .
110
+ - name: Publish to npm
111
+ run: npm run ship
112
+ - run: ./scripts/release.sh
113
+ - run: git push origin HEAD
114
+ - run: curl -H "token ${{ secrets.TOKEN }}" https://x
115
+ - run: >-
116
+ tsc
117
+ --noEmit
118
+ shell: bash
119
+ deploy:
120
+ name: Ship it
121
+ runs-on: ubuntu-latest
122
+ steps:
123
+ - run: ./ship.sh
124
+ site:
125
+ name: Deploy site
126
+ runs-on: ubuntu-latest
127
+ steps:
128
+ - run: ./build-site.sh
129
+ EOF
130
+ git add -A
131
+ OUTS="$(bash "$S" --stdout)"
132
+ eq "wf2 commands" "$(printf '%s' "$OUTS" | jq -r '[.entries[].command] | sort | join("|")')" "npm run lint|npm test|tsc --noEmit"
133
+
134
+ # ---- 5c. forge#4298: a package dir with a space and a non-ASCII name keeps whole globs; workflow paths with spaces ----
135
+ mkdir -p "$TMP/sp/web app" "$TMP/sp/pkg/ü/src" "$TMP/sp/.github/workflows"; cd "$TMP/sp" || exit 1
136
+ git init -q -b main
137
+ echo '{"name":"root","private":true}' > package.json
138
+ echo '{"name":"web","scripts":{"test":"vitest run"}}' > "web app/package.json"
139
+ echo '{"name":"u","scripts":{"lint":"eslint ."}}' > "pkg/ü/package.json"
140
+ echo 'x' > "pkg/ü/src/a.ts"
141
+ cat > .github/workflows/ci.yml <<'EOF'
142
+ on:
143
+ push:
144
+ paths:
145
+ - "docs site/**"
146
+ jobs:
147
+ docs:
148
+ runs-on: ubuntu-latest
149
+ steps:
150
+ - run: ./build-docs.sh
151
+ EOF
152
+ git add -A
153
+ OUTS="$(bash "$S" --stdout)"
154
+ 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
+ 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/**"]]'
157
+ printf '%s' "$OUTS" > "$TMP/sp-map.json"
158
+ 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
+
160
+ # ---- 6. usage ----
161
+ bash "$S" --repo-path "$TMP/missing" >/dev/null 2>&1; eq "bad repo path" "$?" 2
162
+ bash "$S" --nope >/dev/null 2>&1; eq "unknown option" "$?" 2
163
+
164
+ echo "test-map.test.sh: $PASS passed, $FAILN failed"
165
+ [ "$FAILN" -eq 0 ]
@@ -4,7 +4,11 @@
4
4
  #
5
5
  # wait-ci-green.sh — CI gate every autonomous merge must pass first.
6
6
  #
7
- # Usage: wait-ci-green.sh <pr-number> -R <owner/repo> [--timeout SECONDS] [--interval SECONDS]
7
+ # Usage: wait-ci-green.sh <pr-number> -R <owner/repo> [--timeout SECONDS] [--interval SECONDS] [--ignore-check NAME]...
8
+ #
9
+ # --ignore-check NAME (repeatable): drop checks with exactly that name before deciding. `forgedock/verify` (forge#4270,
10
+ # the lean engine's report-only gate summary) is always ignored, so every caller of this gate treats it the same
11
+ # (forge#4298); requiring it is the repository's branch-protection choice.
8
12
  #
9
13
  # Waits until every check on the PR has finished, then decides. Fails closed: a merge may
10
14
  # proceed ONLY on exit 0. Field test: pipeline agents merged PRs to staging while checks were
@@ -40,17 +44,18 @@ set -uo pipefail
40
44
  _TEL="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/telemetry.sh"; [ -f "$_TEL" ] && . "$_TEL" 2>/dev/null
41
45
  type forge_emit >/dev/null 2>&1 || forge_emit() { :; }
42
46
 
43
- PR=""; REPO=""
47
+ PR=""; REPO=""; IGNORE='["forgedock/verify"]'
44
48
  TIMEOUT="${FORGE_CI_TIMEOUT:-540}"; INTERVAL="${FORGE_CI_INTERVAL:-20}"; SETTLE="${FORGE_CI_SETTLE:-45}"
45
49
  GRACE="${FORGE_CI_NO_CHECKS_GRACE:-120}"; REQUIRE="${FORGE_CI_REQUIRE_CHECKS:-0}"
46
- usage() { echo "CI_GATE: ERROR"; echo "CI_GATE_HEAD: unknown"; echo "usage: wait-ci-green.sh <pr> -R <owner/repo> [--timeout S] [--interval S]" >&2; exit 2; }
50
+ usage() { echo "CI_GATE: ERROR"; echo "CI_GATE_HEAD: unknown"; echo "usage: wait-ci-green.sh <pr> -R <owner/repo> [--timeout S] [--interval S] [--ignore-check NAME]..." >&2; exit 2; }
47
51
  while [ $# -gt 0 ]; do
48
52
  case "$1" in
49
53
  -R) [ $# -ge 2 ] || usage; REPO="$2"; shift 2 ;;
50
54
  -R*) REPO="${1#-R}"; REPO="${REPO# }"; shift ;;
51
55
  --timeout) [ $# -ge 2 ] || usage; TIMEOUT="$2"; shift 2 ;;
52
56
  --interval) [ $# -ge 2 ] || usage; INTERVAL="$2"; shift 2 ;;
53
- -h|--help) sed -n '5,32p' "$0"; exit 0 ;;
57
+ --ignore-check) [ $# -ge 2 ] || usage; IGNORE="$(jq -c --arg n "$2" '. + [$n]' <<<"$IGNORE")"; shift 2 ;;
58
+ -h|--help) sed -n '5,37p' "$0"; exit 0 ;;
54
59
  *) [ -z "$PR" ] || usage; PR="$1"; shift ;;
55
60
  esac
56
61
  done
@@ -132,6 +137,7 @@ while :; do
132
137
  fi
133
138
  fi
134
139
  errors=0; limited=0; transient=0
140
+ [ "$IGNORE" = '[]' ] || JSON="$(printf '%s' "$JSON" | jq -c --argjson ig "$IGNORE" '[.[] | select(.name as $n | $ig | index($n) | not)]')"
135
141
  cur="$(head_sha)"
136
142
  if [ -n "$cur" ] && [ "$cur" != "$START_SHA" ]; then emit ERROR; echo "PR head moved during the wait ($START_SHA -> $cur); re-review the new head"; exit 2; fi
137
143
  total=$(printf '%s' "$JSON" | jq 'length')
@@ -55,6 +55,15 @@ expect "pending then pass" "0|CI_GATE: PASS" "$(run "$(mk pend '[{"name":"a","bu
55
55
  expect "pending then fail" "1|CI_GATE: FAIL" "$(run "$(mk pendfail '[{"name":"a","bucket":"pending"}]' '[{"name":"a","bucket":"fail"}]')")"
56
56
  expect "pending past timeout" "3|CI_GATE: TIMEOUT" "$(run "$(mk slow '[{"name":"a","bucket":"pending"}]')" --timeout 2)"
57
57
  expect "fail wins over pending" "1|CI_GATE: FAIL" "$(run "$(mk mixed '[{"name":"a","bucket":"pending"},{"name":"b","bucket":"fail"}]')")"
58
+ # forge#4270: --ignore-check drops the named check (the lane's own report-only forgedock/verify) before deciding.
59
+ OWN='[{"name":"a","bucket":"pass"},{"name":"forgedock/verify","bucket":"fail"}]'
60
+ # forge#4298: forgedock/verify is ignored by default, so every caller (legacy engine, review-finalize, spec gates) agrees.
61
+ expect "own verify check failing is ignored by default" "0|CI_GATE: PASS" "$(run "$(mk ownfail "$OWN")")"
62
+ expect "default ignore keeps other failures" "1|CI_GATE: FAIL" "$(run "$(mk owndef '[{"name":"b","bucket":"fail"},{"name":"forgedock/verify","bucket":"fail"}]')")"
63
+ expect "usage text lists --ignore-check" "1" "$(bash "$GATE" 2>&1 >/dev/null | grep -c -- '--ignore-check NAME')"
64
+ expect "--ignore-check drops the named failing check" "0|CI_GATE: PASS" "$(run "$(mk ownign "$OWN")" --ignore-check forgedock/verify)"
65
+ expect "--ignore-check keeps other failures" "1|CI_GATE: FAIL" "$(run "$(mk ownother '[{"name":"b","bucket":"fail"},{"name":"forgedock/verify","bucket":"pass"}]')" --ignore-check forgedock/verify)"
66
+ expect "--ignore-check without a name is a usage error" "2|CI_GATE: ERROR" "$(run "$(mk ownusage "$P")" --ignore-check)"
58
67
  expect "transient outage then pass" "0|CI_GATE: PASS" "$(run "$(mk blip ERR "$P")")"
59
68
  expect "persistent unclassified failure is an ERROR" "2|CI_GATE: ERROR" "$(run "$(mk down ERRX)" --timeout 60)"
60
69
  # Rate-limit / 5xx classification: retried, never an ERROR; a window that only saw them ends RATE_LIMITED (rc 4).