forgedock 1.28.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.
Files changed (60) hide show
  1. package/bin/engine/outcome-codes.mjs +29 -0
  2. package/bin/engine-cli.mjs +77 -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 +356 -0
  7. package/bin/lean/context.test.mjs +307 -0
  8. package/bin/lean/effects.mjs +19 -1
  9. package/bin/lean/lean.mjs +178 -37
  10. package/bin/lean/lean.test.mjs +441 -7
  11. package/bin/lean/orchestrate.mjs +112 -5
  12. package/bin/lean/prefilter.mjs +81 -16
  13. package/bin/lean/prefilter.test.mjs +91 -2
  14. package/bin/lean/prompts.mjs +22 -7
  15. package/bin/lean/recovery.test.mjs +419 -0
  16. package/bin/lean/render.mjs +38 -5
  17. package/bin/lean/testmap.mjs +101 -0
  18. package/bin/lean/testmap.test.mjs +73 -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/deploy-pr.md +5 -4
  26. package/commands/orchestrate.md +54 -7
  27. package/commands/review-pr-staging.md +6 -1
  28. package/docs/CONFIG.md +15 -0
  29. package/docs/OUTCOME-CODES.md +14 -0
  30. package/docs/RELEASE-NOTES.md +9 -0
  31. package/package.json +1 -1
  32. package/scripts/check-spec-markers.sh +1 -0
  33. package/scripts/code-index.sh +33 -33
  34. package/scripts/code-index.test.sh +21 -2
  35. package/scripts/fixtures/test-map/go/.github/workflows/ci.yml +25 -0
  36. package/scripts/fixtures/test-map/go/Makefile +5 -0
  37. package/scripts/fixtures/test-map/go/README.md +1 -0
  38. package/scripts/fixtures/test-map/go/go.mod +3 -0
  39. package/scripts/fixtures/test-map/go/internal/util/util.go +3 -0
  40. package/scripts/fixtures/test-map/go/main.go +3 -0
  41. package/scripts/fixtures/test-map/node/package.json +10 -0
  42. package/scripts/fixtures/test-map/node/packages/ui/package.json +4 -0
  43. package/scripts/fixtures/test-map/node/packages/ui/src/button.tsx +1 -0
  44. package/scripts/fixtures/test-map/node/pnpm-lock.yaml +1 -0
  45. package/scripts/fixtures/test-map/node/src/a.ts +1 -0
  46. package/scripts/fixtures/test-map/node/vitest.config.ts +1 -0
  47. package/scripts/fixtures/test-map/python/pkg/__init__.py +0 -0
  48. package/scripts/fixtures/test-map/python/pkg/mod.py +2 -0
  49. package/scripts/fixtures/test-map/python/pyproject.toml +9 -0
  50. package/scripts/fixtures/test-map/python/tests/test_mod.py +5 -0
  51. package/scripts/fixtures/test-map/python/uv.lock +1 -0
  52. package/scripts/repo-facts.sh +146 -0
  53. package/scripts/repo-facts.test.sh +106 -0
  54. package/scripts/review-prepare.sh +28 -6
  55. package/scripts/review-prepare.test.sh +22 -2
  56. package/scripts/test-map.sh +317 -0
  57. package/scripts/test-map.test.sh +353 -0
  58. package/scripts/verify-phase-trail.test.sh +44 -44
  59. package/scripts/wait-ci-green.sh +10 -4
  60. package/scripts/wait-ci-green.test.sh +13 -0
@@ -0,0 +1,106 @@
1
+ #!/usr/bin/env bash
2
+ # repo-facts.test.sh — cases for scripts/repo-facts.sh (forge#4272).
3
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
4
+ # SPDX-License-Identifier: AGPL-3.0-or-later
5
+ set -uo pipefail
6
+ S="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/repo-facts.sh"
7
+ PASS=0; FAILN=0
8
+ TMP="$(mktemp -d "${TMPDIR:-/tmp}/repo-facts-test.XXXXXX")"; trap 'rm -rf "$TMP"' EXIT
9
+ ok() { PASS=$((PASS+1)); }
10
+ bad() { FAILN=$((FAILN+1)); echo "FAIL: $1"; }
11
+ eq() { if [ "$2" = "$3" ]; then ok; else bad "$1 (got: $2 want: $3)"; fi }
12
+ q() { printf '%s' "$OUT" | jq -c "$1"; }
13
+
14
+ new_repo() {
15
+ rm -rf "${TMP:?}/$1"; mkdir -p "$TMP/$1"; cd "$TMP/$1" || exit 1
16
+ git init -q -b main; git config user.email t@example.com; git config user.name t; git config commit.gpgsign false
17
+ }
18
+ commit() { git add -A; git commit -qm "$1"; }
19
+ lines() { i=0; : > "$1"; while [ $i -lt "$2" ]; do echo "l$i" >> "$1"; i=$((i+1)); done; }
20
+
21
+ # ---- 1. pins, slow suites, package edge, boundary config, hotspot ----
22
+ new_repo full
23
+ mkdir -p packages/a packages/b .github/workflows src
24
+ cat > package.json <<'EOF'
25
+ {"name":"root","private":true,"packageManager":"pnpm@9.1.0","workspaces":["packages/*"],
26
+ "scripts":{"test":"vitest run","lint":"eslint .","test:e2e":"playwright test"}}
27
+ EOF
28
+ echo "20.11.1" > .nvmrc
29
+ echo '{"name":"@x/a","dependencies":{"@x/b":"workspace:*","left-pad":"1.0.0"}}' > packages/a/package.json
30
+ echo '{"name":"@x/b","scripts":{"test":"vitest run"}}' > packages/b/package.json
31
+ echo "module.exports = { rules: { 'boundaries/element-types': ['error'] } };" > .eslintrc.js
32
+ echo "module.exports = { rules: { semi: 'error' } };" > packages/b/.eslintrc.js
33
+ cat > .github/workflows/ci.yml <<'EOF'
34
+ on: push
35
+ jobs:
36
+ db:
37
+ runs-on: ubuntu-latest
38
+ services:
39
+ postgres:
40
+ image: postgres
41
+ steps:
42
+ - run: pnpm run test
43
+ long:
44
+ runs-on: ubuntu-latest
45
+ timeout-minutes: 45
46
+ steps:
47
+ - run: pytest tests/slow
48
+ quick:
49
+ runs-on: ubuntu-latest
50
+ timeout-minutes: 5
51
+ steps:
52
+ - run: ruff check .
53
+ EOF
54
+ lines src/big.ts 2100; commit c1
55
+ echo x >> src/big.ts; lines src/small.ts 10; commit c2
56
+ OUT="$(bash "$S" --stdout)"; rc=$?
57
+ eq "rc" "$rc" 0
58
+ eq "version" "$(q '.version')" 1
59
+ eq "pm pin" "$(q '.toolchain.pm')" '{"name":"pnpm","version":"9.1.0","source":"package.json packageManager"}'
60
+ eq "node pin" "$(q '.toolchain.node')" '{"spec":"20.11.1","source":".nvmrc"}'
61
+ eq "packages" "$(q '.layer_rules.packages')" '[{"name":"@x/a","dir":"packages/a"},{"name":"@x/b","dir":"packages/b"}]'
62
+ eq "edge" "$(q '.layer_rules.edges')" '[{"from":"@x/a","to":"@x/b","kind":"dependencies","manifest":"packages/a/package.json"}]'
63
+ eq "boundary configs" "$(q '.layer_rules.boundary_configs')" '[".eslintrc.js"]'
64
+ eq "services job" "$(q '[.slow_suites[] | select(.command == "pnpm run test") | .why]')" '["CI job .github/workflows/ci.yml#jobs.db uses services"]'
65
+ eq "timeout job" "$(q '[.slow_suites[] | select(.command == "pytest tests/slow") | .why]')" '["CI job .github/workflows/ci.yml#jobs.long sets timeout-minutes 45"]'
66
+ eq "quick job not slow" "$(q '[.slow_suites[] | select(.command == "ruff check .")] | length')" 0
67
+ eq "hotspot" "$(q '.hotspots')" '[{"path":"src/big.ts","lines":2101,"commits_90d":2,"hotspot":4202}]'
68
+
69
+ # ---- 2. a cached test map is read: its e2e command is a slow suite ----
70
+ mkdir -p .forge
71
+ echo '{"version":1,"entries":[{"globs":["e2e/**"],"command":"pnpm exec playwright test","cwd":".","source":"package.json#scripts.test:e2e"}]}' > .forge/test-map.json
72
+ OUT="$(bash "$S" --stdout)"
73
+ eq "map e2e" "$(q '.slow_suites')" '[{"command":"pnpm exec playwright test","source":"package.json#scripts.test:e2e","why":"command names playwright"}]'
74
+
75
+ # ---- 3. default output file, rerun stable ----
76
+ P="$(bash "$S")"; eq "output path" "$P" ".forge/repo-facts.json"
77
+ cp .forge/repo-facts.json "$TMP/first.json"; bash "$S" >/dev/null
78
+ if cmp -s .forge/repo-facts.json "$TMP/first.json"; then ok; else bad "rerun not identical"; fi
79
+
80
+ # ---- 4. a repo with none of these: empty sections, exit 0 ----
81
+ new_repo bare
82
+ echo hi > README.md; commit init
83
+ OUT="$(bash "$S" --stdout)"; rc=$?
84
+ eq "bare rc" "$rc" 0
85
+ eq "bare" "$(q '[.toolchain.pm, .toolchain.node, .slow_suites, .layer_rules, .hotspots]')" '[null,null,[],{"packages":[],"edges":[],"boundary_configs":[]},[]]'
86
+ new_repo empty
87
+ OUT="$(bash "$S" --stdout)"; eq "no commits rc" "$?" 0
88
+ eq "no commits" "$(q '.hotspots')" '[]'
89
+
90
+ # ---- 4b. #4298: a big boundary config matching early (pipefail + grep -q SIGPIPE) and non-ASCII / spaced paths ----
91
+ new_repo paths
92
+ mkdir -p "packages/é b" "apps/ü"
93
+ echo '{"name":"root","private":true,"workspaces":["packages/*"]}' > package.json
94
+ echo '{"name":"@x/c"}' > "packages/é b/package.json"
95
+ { echo "export default [{ rules: { 'boundaries/element-types': ['error'] } }];"; i=0; while [ $i -lt 20000 ]; do echo "// padding line $i to overflow the pipe buffer ......................................"; i=$((i+1)); done; } > "apps/ü/eslint.config.js"
96
+ commit init
97
+ OUT="$(bash "$S" --stdout)"; eq "paths rc" "$?" 0
98
+ eq "big boundary config in a non-ASCII dir" "$(q '.layer_rules.boundary_configs')" '["apps/ü/eslint.config.js"]'
99
+ eq "spaced non-ASCII package dir" "$(q '.layer_rules.packages')" '[{"name":"@x/c","dir":"packages/é b"}]'
100
+
101
+ # ---- 5. usage ----
102
+ bash "$S" --repo-path "$TMP/missing" >/dev/null 2>&1; eq "bad repo path" "$?" 2
103
+ bash "$S" --bogus >/dev/null 2>&1; eq "unknown option" "$?" 2
104
+
105
+ echo "repo-facts.test.sh: $PASS passed, $FAILN failed"
106
+ [ "$FAILN" -eq 0 ]
@@ -87,15 +87,23 @@ case "$MERGE_ISSUE" in *[!0-9]*) MERGE_ISSUE="" ;; esac
87
87
  [ "$THOROUGH" = "true" ] || THOROUGH="false"
88
88
 
89
89
  # --- trusted helper resolution (install roots only; never cwd) ---
90
+ # shellcheck source=/dev/null # the resolver lib is found at runtime in the trusted install root
90
91
  type forge_resolve >/dev/null 2>&1 || { setopt nonomatch 2>/dev/null || true; _SELF_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." 2>/dev/null && pwd -P)"; for _r in "$(cygpath -u "${FORGEDOCK_HOME:-}" 2>/dev/null || printf %s "${FORGEDOCK_HOME:-}")" "${CLAUDE_PLUGIN_ROOT:-}" "${FORGE_ROOT:-}" "${FORGE_HOME:-}" "$(readlink -f "$HOME/.claude/commands/work-on.md" 2>/dev/null | sed 's|/commands/work-on\.md$||')" "$(head -n 1 "${CODEX_HOME:-$HOME/.codex}/forge-home" 2>/dev/null)" "$HOME/.claude/plugins/marketplaces/forgedock" "${CLAUDE_CONFIG_DIR:-$HOME/.claude}"/plugins/cache/forgedock/forgedock/* "$HOME/.claude"/plugins/cache/forgedock/forgedock/* "$_SELF_ROOT"; do case "$_r" in /*) [ -f "$_r/scripts/lib/forge-resolve.sh" ] && { . "$_r/scripts/lib/forge-resolve.sh"; break; };; esac; done; type forge_resolve >/dev/null 2>&1 || forge_resolve() { :; }; }
91
92
 
92
93
  ROSTER_SCRIPT=$(forge_resolve review-roster.sh)
93
94
  TRUSTED_SCRIPT=$(forge_resolve trusted-comments.sh)
95
+ # shellcheck disable=SC2034 # read by lib/review-delta-mode.sh (sourced below)
94
96
  REVIEW_DELTA_SCRIPT=$(forge_resolve review-delta.sh)
95
97
  CODE_INDEX_SCRIPT=$(forge_resolve code-index.sh)
96
- _l=$(forge_resolve lib/pr-issue-number.sh); if [ -n "$_l" ]; then . "$_l"; else pr_issue_number() { return 0; }; fi
97
- _l=$(forge_resolve lib/review-template-resolve.sh); if [ -n "$_l" ]; then . "$_l"; else review_template_resolve() { TEMPLATE_BASE=""; TEMPLATE_SOURCE="none"; MONOLITHIC_CATALOG=""; }; fi
98
- _l=$(forge_resolve lib/review-delta-mode.sh); if [ -n "$_l" ]; then . "$_l"; else review_delta_mode() { echo "FULL - 0"; }; carry_verified_from() { :; }; fi
98
+ _l=$(forge_resolve lib/pr-issue-number.sh)
99
+ # shellcheck source=/dev/null # resolved at runtime from the trusted install root
100
+ if [ -n "$_l" ]; then . "$_l"; else pr_issue_number() { return 0; }; fi
101
+ _l=$(forge_resolve lib/review-template-resolve.sh)
102
+ # shellcheck source=/dev/null # resolved at runtime from the trusted install root
103
+ if [ -n "$_l" ]; then . "$_l"; else review_template_resolve() { TEMPLATE_BASE=""; TEMPLATE_SOURCE="none"; MONOLITHIC_CATALOG=""; }; fi
104
+ _l=$(forge_resolve lib/review-delta-mode.sh)
105
+ # shellcheck source=/dev/null disable=SC2329 # resolved at runtime; the fallback stubs mirror the lib's API
106
+ if [ -n "$_l" ]; then . "$_l"; else review_delta_mode() { echo "FULL - 0"; }; carry_verified_from() { :; }; fi
99
107
 
100
108
  # --- state, scratch and the ref-restore guard ---
101
109
  BLOCKERS=""
@@ -105,12 +113,14 @@ mkdir -p "$OUT_DIR" 2>/dev/null || { echo "review-prepare: cannot create $OUT_DI
105
113
  TMP_DIR=$(mktemp -d "${TMPDIR:-/tmp}/review-prepare.XXXXXX") || exit 2
106
114
  ORIG_REF=""; CHECKED_OUT=""
107
115
  # restore_ref: put the checkout back exactly where it was. -f so a dirty review checkout can never wedge the restore.
116
+ # shellcheck disable=SC2329 # called from cleanup (EXIT trap) and the checkout paths
108
117
  restore_ref() {
109
118
  if [ -n "$CHECKED_OUT" ] && [ -n "$ORIG_REF" ]; then
110
119
  git checkout -f "$ORIG_REF" >/dev/null 2>&1 || echo "review-prepare: WARNING could not restore $ORIG_REF" >&2
111
120
  fi
112
121
  CHECKED_OUT=""
113
122
  }
123
+ # shellcheck disable=SC2329 # invoked by the EXIT trap
114
124
  cleanup() { restore_ref; rm -rf "$TMP_DIR"; }
115
125
  trap cleanup EXIT
116
126
 
@@ -144,6 +154,7 @@ if [ -z "$BLOCKERS" ]; then
144
154
  # Pinned (forge#3935): same-repo PRs only (a fork's branch name resolves to origin's own branch), and the fetched tip must equal REVIEW_SHA; the diff is taken against REVIEW_SHA, never the branch name.
145
155
  if { [ "$NO_DELTA" = "true" ] || [ "$IS_MILESTONE_TO_STAGING" = "true" ]; } && [ -n "$BASE" ] && [ -n "$HEAD" ] && [ "$(printf '%s' "$PR_INFO" | jq -r .isCrossRepository 2>/dev/null)" = "false" ] && printf '%s' "$REVIEW_SHA" | grep -qE '^[0-9a-f]{40}$' \
146
156
  && git fetch -q origin "$BASE" "$HEAD" 2>/dev/null && [ "$(git rev-parse -q --verify "refs/remotes/origin/${HEAD}^{commit}" 2>/dev/null)" = "$REVIEW_SHA" ]; then
157
+ # shellcheck disable=SC2015 # either assignment failing resets both, by design
147
158
  FILES=$(git diff --name-only "origin/${BASE}...${REVIEW_SHA}" 2>/dev/null) && DIFF=$(git diff "origin/${BASE}...${REVIEW_SHA}" 2>/dev/null) || { FILES=""; DIFF=""; }
148
159
  fi
149
160
  [ -n "$FILES" ] || block "gh pr diff failed"
@@ -420,7 +431,9 @@ canonical_roster() { local out="" a d; for a in $1; do d=$(agent_domain "$a"); c
420
431
  SELECTED_AGENTS=$(canonical_roster "$SELECTED_AGENTS")
421
432
 
422
433
  # --- Phase 3C: review mode (FULL | DELTA | BASE_SYNC_ONLY) ---
434
+ # shellcheck disable=SC2034 # both read by lib/review-delta-mode.sh
423
435
  BASE_BRANCH="$BASE"
436
+ # shellcheck disable=SC2034
424
437
  COMMENTS_JSON=$(gh api --paginate "repos/${REPO}/issues/${PR_NUMBER}/comments" 2>/dev/null || echo "")
425
438
  delta_roster() {
426
439
  local rounds="${1}" full="${2}" files="${3}" out="security" root
@@ -473,11 +486,20 @@ if [ "$REVIEW_MODE" != "BASE_SYNC_ONLY" ]; then
473
486
  _ito="${REVIEW_PREPARE_INDEX_TIMEOUT:-120}"; case "$_ito" in ''|*[!0-9]*|0) _ito=120 ;; esac
474
487
  _tbin=$(command -v timeout || command -v gtimeout || true)
475
488
  echo "review-prepare: building code index (limit ${_ito}s)" >&2
476
- if [ -n "$_tbin" ]; then "$_tbin" "$_ito" bash "$CODE_INDEX_SCRIPT" --repo-path "$REPO_PATH" >/dev/null 2>&1; _irc=$?
477
- else bash "$CODE_INDEX_SCRIPT" --repo-path "$REPO_PATH" >/dev/null 2>&1; _irc=$?; fi
489
+ # forge#4235: keep the build's stderr so a failure is diagnosable (its tail is echoed below); stdout stays discarded.
490
+ _ierr=$(mktemp "$OUT_DIR/.code-index-err-XXXXXX" 2>/dev/null) || _ierr=/dev/null
491
+ if [ -n "$_tbin" ]; then "$_tbin" "$_ito" bash "$CODE_INDEX_SCRIPT" --repo-path "$REPO_PATH" >/dev/null 2>"$_ierr"; _irc=$?
492
+ else bash "$CODE_INDEX_SCRIPT" --repo-path "$REPO_PATH" >/dev/null 2>"$_ierr"; _irc=$?; fi
478
493
  if [ "$_irc" -eq 0 ]; then INDEX_OK=1
479
494
  elif [ "$_irc" -eq 124 ]; then echo "review-prepare: code index timed out after ${_ito}s — skipping index slices (agents grep instead)" >&2
480
- else echo "review-prepare: code index failed (exit $_irc) — skipping index slices (agents grep instead)" >&2; fi
495
+ else
496
+ echo "review-prepare: code index failed (exit $_irc) — skipping index slices (agents grep instead)" >&2
497
+ # forge#4316: the tail can name repo files; show the repo root as <repo>, strip control chars, cap each line
498
+ [ -s "$_ierr" ] && tail -n 20 "$_ierr" | tr -d '\000-\010\013-\037\177' \
499
+ | awk -v r="$REPO_PATH" '{ l = $0; if (r != "") { o = ""; while ((i = index(l, r)) > 0) { o = o substr(l, 1, i - 1) "<repo>"; l = substr(l, i + length(r)) } l = o l } print substr(l, 1, 200) }' \
500
+ | sed 's/^/review-prepare: code-index: /' >&2
501
+ fi
502
+ [ "$_ierr" = /dev/null ] || rm -f "$_ierr"
481
503
  fi
482
504
  build_index_slice() {
483
505
  local domain="${1}" files
@@ -109,7 +109,9 @@ setup feat/x staging "README.md" "$DIFF_PLAIN"; printf 'CONFLICTING|DIRTY\n' > "
109
109
  prep "$ROOT"; eq "conflicting" .merge_health.mergeable CONFLICTING; eq "conflicting state" .merge_health.state DIRTY; eq "no retry on CONFLICTING" .merge_health.retries 0
110
110
  setup feat/x staging "README.md" "$DIFF_PLAIN"; printf 'UNKNOWN|UNKNOWN\n' > "$FIX/merge.seq"
111
111
  prep "$ROOT"; eq "unknown stays unknown after 3 retries" .merge_health.retries 3; eq "still UNKNOWN" .merge_health.mergeable UNKNOWN
112
- setup feat/x staging "README.md" "$DIFF_PLAIN"; printf 'garbage|$(rm -rf)\n' > "$FIX/merge.seq"
112
+ setup feat/x staging "README.md" "$DIFF_PLAIN"
113
+ # shellcheck disable=SC2016 # the literal $(rm -rf) is the hostile payload under test
114
+ printf 'garbage|$(rm -rf)\n' > "$FIX/merge.seq"
113
115
  prep "$ROOT"; eq "garbage state normalized" .merge_health.state UNKNOWN
114
116
  setup feat/x staging "README.md" "$DIFF_PLAIN"; echo 0 > "$FIX/prior"
115
117
  prep "$ROOT"; eq "first-time contributor" .pr.first_time_contributor true
@@ -234,6 +236,7 @@ CFG_OVERRIDE="$WORK/emptyroot.yaml" prep "$WORK/root-notpl"
234
236
  rc_is "no template source" 3; eq "templates none" .templates.source none
235
237
 
236
238
  # --- 8. issue number extraction and trusted contract ---
239
+ # shellcheck source=/dev/null
237
240
  . "$ROOT/scripts/lib/pr-issue-number.sh"
238
241
  chk() { local got; got=$(pr_issue_number_of "$2"); if [ "$got" = "$3" ]; then ok; else bad "$1: got '$got', want '$3'"; fi; }
239
242
  chk "Closes" "Closes #12" 12
@@ -271,6 +274,7 @@ setup feat/x staging "README.md" "$DIFF_PLAIN" "00000000000000000000000000000000
271
274
  prep "$ROOT" --checkout-pr
272
275
  rc_is "unknown sha cannot be checked out" 3
273
276
  if [ "$(git -C "$REPO_DIR" symbolic-ref --short HEAD 2>/dev/null)" = "main" ]; then ok; else bad "failed checkout moved HEAD"; fi
277
+ # shellcheck disable=SC2016 # matches the literal $ORIG_REF in the script source
274
278
  if grep -q 'checkout -f "\$ORIG_REF"' "$ROOT/scripts/review-prepare.sh"; then ok; else bad "restore_ref must use git checkout -f"; fi
275
279
 
276
280
  # --- 10. fail closed on gh and bad arguments ---
@@ -317,8 +321,24 @@ eq "a hostile cwd roster script is never used" '.roster.agents | index("evil") !
317
321
  # --- code index build is bounded and visible (forge#4126) ---
318
322
  setup feat/x staging "services/api/auth.py" "$DIFF_AUTH_BILL"
319
323
  prep "$ROOT"
320
- eq "real index fills the auth slice" '.index_slices.auth | startswith("Domain files (auth):")' true
324
+ _f0=$fail; eq "real index fills the auth slice" '.index_slices.auth | startswith("Domain files (auth):")' true
325
+ # forge#4235: on failure show review-prepare's stderr (it carries the index build's exit code and stderr tail)
326
+ [ "$fail" -eq "$_f0" ] || sed 's/^/ stderr: /' "$WORK/stderr"
321
327
  if grep -q "review-prepare: building code index" "$WORK/stderr"; then ok; else bad "index build must print a progress line on stderr"; fi
328
+ # a failing build reports its exit code and stderr tail, and the slices fall back to grep
329
+ mkroot "$WORK/root-failindex" "" "" templates
330
+ # forge#4316: a line naming a repo file, carrying an ESC and over 200 chars is echoed sanitised and capped
331
+ # shellcheck disable=SC2016 # $2 is the fake index script's own argument
332
+ printf '#!/usr/bin/env bash\necho "index-boom-marker" >&2\nprintf "parse error in %%s/secret/file.py \\033[31m%%0300d\\n" "$2" 0 >&2\nexit 3\n' > "$WORK/root-failindex/scripts/code-index.sh"
333
+ prep "$WORK/root-failindex"
334
+ rc_is "failing index build" 0
335
+ if grep -q "code index failed (exit 3)" "$WORK/stderr"; then ok; else bad "index failure must report the build's exit code on stderr"; fi
336
+ if grep -q "review-prepare: code-index: index-boom-marker" "$WORK/stderr"; then ok; else bad "index failure must echo the build's stderr tail: $(head -c 300 "$WORK/stderr")"; fi
337
+ _ix=$(grep "review-prepare: code-index: parse error" "$WORK/stderr")
338
+ case "$_ix" in "review-prepare: code-index: parse error in <repo>/secret/file.py [31m0"*) ok ;; *) bad "index stderr must show the repo root as <repo> without control chars: $(printf '%s' "$_ix" | head -c 120 | od -c | head -3)" ;; esac
339
+ if [ "${#_ix}" -le $((28 + 200)) ]; then ok; else bad "index stderr line must be capped at 200 chars, got ${#_ix}"; fi
340
+ eq "failed index falls back to grep slices" .index_slices.auth "Code index not available — agent will use grep exploration"
341
+ if ls "$WORK/out"/.code-index-err-* >/dev/null 2>&1; then bad "index stderr temp file must be removed"; else ok; fi
322
342
  if command -v timeout >/dev/null 2>&1 || command -v gtimeout >/dev/null 2>&1; then
323
343
  mkroot "$WORK/root-slowindex" "" "" templates
324
344
  printf '#!/usr/bin/env bash\nsleep 30\n' > "$WORK/root-slowindex/scripts/code-index.sh"
@@ -0,0 +1,317 @@
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
+ # 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
+ # Exit: 0 ok | 2 usage or bad repo path. bash 3.2 portable.
31
+ set -uo pipefail
32
+
33
+ die() { echo "test-map: $*" >&2; exit 2; }
34
+ command -v jq >/dev/null 2>&1 || die "jq is required"
35
+ REPO="."; OUT=""; STDOUT=0
36
+ while [ $# -gt 0 ]; do
37
+ case "$1" in
38
+ --repo-path) [ $# -ge 2 ] && [ -n "$2" ] || die "usage: test-map.sh [--repo-path DIR] [--output FILE | --stdout]"; REPO="$2"; shift 2 ;;
39
+ --output) [ $# -ge 2 ] && [ -n "$2" ] || die "usage: test-map.sh [--repo-path DIR] [--output FILE | --stdout]"; OUT="$2"; shift 2 ;;
40
+ --stdout) STDOUT=1; shift ;;
41
+ *) die "unknown option: $1" ;;
42
+ esac
43
+ done
44
+ [ -d "$REPO" ] || die "repo path not found: $REPO"
45
+ case "$OUT" in ""|/*) ;; *) OUT="$PWD/$OUT" ;; esac
46
+ cd "$REPO" || die "cannot enter $REPO"
47
+
48
+ T=$(mktemp -d "${TMPDIR:-/tmp}/test-map.XXXXXX") || die "mktemp failed"
49
+ trap 'rm -rf "$T"' EXIT
50
+ if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
51
+ # -z and quotepath=off (#4298): non-ASCII and spaced paths come out verbatim, never C-quoted
52
+ git -c core.quotepath=off ls-files -z --cached --others --exclude-standard | tr '\0' '\n' > "$T/files"
53
+ else
54
+ find . -type f -not -path './.git/*' | sed 's|^\./||' | sort > "$T/files"
55
+ fi
56
+ : > "$T/rows"
57
+ # row: source TAB cwd TAB command TAB globs separated by US (\037, #4298: a glob may contain spaces)
58
+ US="$(printf '\037')"
59
+ row() { printf '%s\t%s\t%s\t%s\n' "$1" "$2" "$3" "$4" >> "$T/rows"; }
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; }
93
+ # globs for the extensions in $2 under dir $1 ("." = repo root)
94
+ ext_globs() {
95
+ g=""; pre=""; [ "$1" = "." ] || pre="$1/"
96
+ for e in $2; do g="$g$US${pre}**/*.$e"; done
97
+ printf '%s' "${g#"$US"}"
98
+ }
99
+
100
+ # ---- package.json scripts ----
101
+ PM=npm
102
+ [ -f yarn.lock ] && PM=yarn
103
+ [ -f pnpm-lock.yaml ] && PM=pnpm
104
+ grep -E '(^|/)package\.json$' "$T/files" | grep -v -E '(^|/)node_modules/' | sort | while IFS= read -r pj; do
105
+ [ -f "$pj" ] || continue
106
+ d=$(dirname "$pj")
107
+ globs="$(ext_globs "$d" "$JS_EXT")$US$pj"
108
+ tag=""
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
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
111
+ case "$s" in
112
+ test) row "$pj#scripts.$s$tag" "$d" "$PM run $s" "$globs" ;;
113
+ *) pkg_check_script "$s" "$sv" && row "$pj#scripts.$s" "$d" "$PM run $s" "$globs" ;;
114
+ esac
115
+ done
116
+ done
117
+
118
+ # ---- python ----
119
+ RUN=""
120
+ [ -f poetry.lock ] && RUN="poetry run "
121
+ [ -f uv.lock ] && RUN="uv run "
122
+ PY_SRC=""
123
+ if [ -f pyproject.toml ] && grep -qE '^\[tool\.pytest' pyproject.toml; then PY_SRC="pyproject.toml#tool.pytest"
124
+ elif [ -f pytest.ini ]; then PY_SRC="pytest.ini"
125
+ elif [ -f setup.cfg ] && grep -qE '^\[tool:pytest\]' setup.cfg; then PY_SRC="setup.cfg#tool:pytest"
126
+ elif grep -qE '(^|/)conftest\.py$' "$T/files"; then PY_SRC="conftest.py"; fi
127
+ [ -n "$PY_SRC" ] && row "$PY_SRC" "." "${RUN}pytest" "**/*.py$US**/conftest.py"
128
+ RUFF_SRC=""
129
+ if [ -f pyproject.toml ] && grep -qE '^\[tool\.ruff' pyproject.toml; then RUFF_SRC="pyproject.toml#tool.ruff"
130
+ elif [ -f ruff.toml ]; then RUFF_SRC="ruff.toml"
131
+ elif [ -f .ruff.toml ]; then RUFF_SRC=".ruff.toml"; fi
132
+ [ -n "$RUFF_SRC" ] && row "$RUFF_SRC" "." "${RUN}ruff check ." "**/*.py"
133
+
134
+ # ---- go ----
135
+ grep -E '(^|/)go\.mod$' "$T/files" | grep -v -E '(^|/)vendor/' | sort | while IFS= read -r gm; do
136
+ d=$(dirname "$gm"); pre=""; [ "$d" = "." ] || pre="$d/"
137
+ globs="${pre}**/*.go$US${pre}go.mod$US${pre}go.sum"
138
+ row "$gm" "$d" "go test ./..." "$globs"
139
+ row "$gm" "$d" "go vet ./..." "$globs"
140
+ done
141
+
142
+ # ---- Makefile ----
143
+ for mf in Makefile GNUmakefile; do
144
+ [ -f "$mf" ] || continue
145
+ grep -oE "^($MAKE_TARGETS)[[:space:]]*:" "$mf" | sed -E 's/[[:space:]]*:$//' | sort -u | while IFS= read -r t; do
146
+ row "$mf#$t" "." "make $t" "**"
147
+ done
148
+ done
149
+
150
+ # ---- CI workflow run: steps ----
151
+ # Emits: job US command (US = \037), one per command line; and P TAB path for each `paths:` filter item
152
+ # before `jobs:`. A block ends at the first non-blank line not indented past the `run` key (sibling keys such as env:).
153
+ wf_extract() {
154
+ awk '
155
+ function indent(s) { match(s, /^ */); return RLENGTH }
156
+ function unq(s) { sub(/^[ \t]+/, "", s); sub(/[ \t]+$/, "", s); if (s ~ /^".*"$/ || s ~ /^\x27.*\x27$/) s = substr(s, 2, length(s) - 2); return s }
157
+ function emit(c) { c = unq(c); if (c != "") print job "\037" c }
158
+ BEGIN { injobs = 0; jind = -1; block = 0; inpaths = 0; job = "" }
159
+ {
160
+ line = $0; sub(/\r$/, "", line)
161
+ if (block) {
162
+ if (line ~ /^[ \t]*$/) next
163
+ if (indent(line) > bind) {
164
+ l = line; sub(/^ +/, "", l)
165
+ if (fold) { pend = (pend == "" ? l : pend " " l); next }
166
+ if (pend != "") { l = pend " " l; pend = "" }
167
+ if (l ~ /\\$/) { pend = substr(l, 1, length(l) - 1); sub(/[ \t]+$/, "", pend); next }
168
+ emit(l); next
169
+ }
170
+ if (pend != "") { emit(pend); pend = "" }
171
+ block = 0
172
+ }
173
+ if (line ~ /^jobs:[ \t]*$/) { injobs = 1; inpaths = 0; next }
174
+ if (!injobs) {
175
+ if (line ~ /^[ \t]*paths:[ \t]*$/) { inpaths = 1; pind = indent(line); next }
176
+ if (inpaths) {
177
+ if (line ~ /^[ \t]*-[ \t]/ && indent(line) >= pind) { p = line; sub(/^[ \t]*-[ \t]*/, "", p); sub(/[ \t]+#.*$/, "", p); print "P\t" unq(p); next }
178
+ if (line !~ /^[ \t]*$/ && line !~ /^[ \t]*#/) inpaths = 0
179
+ }
180
+ next
181
+ }
182
+ if (line ~ /^[^ \t#]/) { injobs = 0; next }
183
+ if (line ~ /^ +[A-Za-z0-9_-]+:[ \t]*$/ && (jind < 0 || indent(line) == jind)) { jind = indent(line); job = line; sub(/^ +/, "", job); sub(/:.*$/, "", job); next }
184
+ if (line ~ /^[ \t]*(-[ \t]+)?run:/) {
185
+ rest = line; sub(/^[ \t]*(-[ \t]+)?run:[ \t]*/, "", rest); sub(/[ \t]+#.*$/, "", rest)
186
+ # bind = the column of the `run` key: step keys after it (env:, shell:) sit there and end the block
187
+ if (rest ~ /^[|>][-+0-9]*$/) { block = 1; pend = ""; fold = (rest ~ /^>/); bind = match(line, /run:/) - 1; next }
188
+ emit(rest)
189
+ }
190
+ }
191
+ END { if (block && pend != "") emit(pend) }
192
+ ' "$1"
193
+ }
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 ;;
269
+ esac
270
+ return 1
271
+ }
272
+ tool_globs() {
273
+ c=" $1 "
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 ;;
281
+ *[\ /]ruff\ *|*[\ /]flake8\ *|*[\ /]pylint\ *|*[\ /]mypy\ *|*[\ /]black\ *|*[\ /]pytest\ *) printf '**/*.py'; return ;;
282
+ *[\ /]golangci-lint\ *|*[\ /]gofmt\ *|*[\ /]staticcheck\ *|*\ go\ *) printf '**/*.go'; return ;;
283
+ *[\ /]shellcheck\ *|*\ bash\ *.sh\ *|*\ sh\ *.sh\ *) printf '**/*.sh'; return ;;
284
+ *[\ /]eslint\ *|*[\ /]tsc\ *|*[\ /]prettier\ *|*[\ /]vitest\ *|*[\ /]jest\ *|*\ node\ --test*|*\ npm\ *|*\ pnpm\ *|*\ yarn\ *|*\ npx\ *) ext_globs . "$JS_EXT"; return ;;
285
+ esac
286
+ printf ''
287
+ }
288
+ grep -E '^\.github/workflows/[^/]+\.(yml|yaml)$' "$T/files" | sort | while IFS= read -r wf; do
289
+ [ -f "$wf" ] || continue
290
+ wf_extract "$wf" > "$T/wf"
291
+ paths=$(awk -F'\t' '$1 == "P" { printf "%s\037", $2 }' "$T/wf"); paths=${paths%"$US"}
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/}"
296
+ [ -n "$g" ] || g="$paths"
297
+ [ -n "$g" ] || { [ "$CK_CWD" != . ] && g="$CK_CWD/**"; }
298
+ [ -n "$g" ] || continue
299
+ row "$wf#jobs.$job" "$CK_CWD" "$CK_CMD" "$g"
300
+ done
301
+ done
302
+
303
+ MAP=$(jq -R -s -c '
304
+ [split("\n")[] | select(length > 0) | split("\t") | {source: .[0], cwd: .[1], command: .[2], globs: (.[3] | split("\u001f") | map(select(length > 0)))}]
305
+ | sort_by(.source, .command)
306
+ | reduce .[] as $e ({order: [], by: {}};
307
+ ([$e.command, $e.cwd] | tojson) as $k
308
+ | if .by[$k] then .by[$k].globs += ($e.globs - .by[$k].globs)
309
+ else .order += [$k] | .by[$k] = {globs: $e.globs, command: $e.command, cwd: $e.cwd, source: $e.source} end)
310
+ | {version: 1, entries: [.by[.order[]]]}' < "$T/rows") || die "jq failed"
311
+
312
+ if [ "$STDOUT" = 1 ]; then printf '%s\n' "$MAP"; exit 0; fi
313
+ [ -n "$OUT" ] || OUT=".forge/test-map.json"
314
+ mkdir -p "$(dirname "$OUT")" || die "cannot create $(dirname "$OUT")"
315
+ printf '%s\n' "$MAP" > "$OUT.tmp.$$" || die "cannot write $OUT"
316
+ mv "$OUT.tmp.$$" "$OUT" || die "cannot write $OUT"
317
+ echo "$OUT"