forgedock 1.27.0 → 1.28.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 (127) hide show
  1. package/README.md +4 -1
  2. package/bin/bootstrap.mjs +55 -0
  3. package/bin/engine/outcome-codes.mjs +10 -0
  4. package/bin/engine/telemetry-seam.mjs +37 -8
  5. package/bin/engine/telemetry-seam.test.mjs +31 -0
  6. package/bin/engine-cli.mjs +542 -38
  7. package/bin/engine.mjs +8 -4
  8. package/bin/forgedock.mjs +75 -9
  9. package/bin/gc.mjs +580 -0
  10. package/bin/lean/admission.mjs +33 -3
  11. package/bin/lean/batch.mjs +22 -4
  12. package/bin/lean/batch.test.mjs +5 -3
  13. package/bin/lean/checks.mjs +199 -0
  14. package/bin/lean/checks.test.mjs +183 -0
  15. package/bin/lean/conformance.mjs +190 -58
  16. package/bin/lean/conformance.test.mjs +116 -7
  17. package/bin/lean/credentials.mjs +29 -0
  18. package/bin/lean/depcache.mjs +312 -0
  19. package/bin/lean/depcache.test.mjs +298 -0
  20. package/bin/lean/drift.mjs +198 -0
  21. package/bin/lean/drift.test.mjs +223 -0
  22. package/bin/lean/effects.mjs +85 -8
  23. package/bin/lean/execution.e2e.test.mjs +81 -0
  24. package/bin/lean/execution.mjs +431 -0
  25. package/bin/lean/execution.test.mjs +583 -0
  26. package/bin/lean/heavy.mjs +289 -0
  27. package/bin/lean/heavy.test.mjs +207 -0
  28. package/bin/lean/lean.mjs +572 -139
  29. package/bin/lean/lean.test.mjs +1193 -31
  30. package/bin/lean/ledger.mjs +27 -2
  31. package/bin/lean/ledger.test.mjs +36 -1
  32. package/bin/lean/limits.mjs +58 -0
  33. package/bin/lean/limits.test.mjs +150 -0
  34. package/bin/lean/orchestrate.mjs +312 -19
  35. package/bin/lean/prompts.mjs +89 -15
  36. package/bin/lean/refactor.mjs +276 -0
  37. package/bin/lean/refactor.test.mjs +359 -0
  38. package/bin/lean/render.mjs +24 -5
  39. package/bin/lean/review-trigger.mjs +134 -0
  40. package/bin/lean/review-trigger.test.mjs +104 -0
  41. package/bin/lean/sandbox-provider.contract.test.mjs +257 -0
  42. package/bin/lean/sandbox-remote.mjs +142 -0
  43. package/bin/lean/sched.mjs +221 -0
  44. package/bin/lean/sched.test.mjs +310 -0
  45. package/bin/lean/schedd.mjs +247 -0
  46. package/bin/lean/schedd.test.mjs +180 -0
  47. package/bin/lean/toolchain.mjs +228 -0
  48. package/bin/lean/toolchain.test.mjs +154 -0
  49. package/bin/lean/verify.mjs +20 -9
  50. package/bin/lean/verify.test.mjs +26 -3
  51. package/bin/lean/worktree.mjs +69 -0
  52. package/bin/lean/worktree.test.mjs +141 -0
  53. package/bin/runner.mjs +53 -10
  54. package/bin/stats/github-source.mjs +1 -1
  55. package/bin/stats/index.mjs +14 -2
  56. package/bin/stats/query.mjs +72 -0
  57. package/bin/stats/stats.test.mjs +54 -0
  58. package/commands/audit.md +2 -0
  59. package/commands/autopilot.md +2 -0
  60. package/commands/bootstrap.md +35 -0
  61. package/commands/cleanup.md +7 -22
  62. package/commands/forge-stats.md +2 -1
  63. package/commands/forgedock-init.md +7 -0
  64. package/commands/issue.md +89 -830
  65. package/commands/milestone.md +55 -6
  66. package/commands/orchestrate.md +10 -2
  67. package/commands/upgrade-deps.md +3 -3
  68. package/commands/work-on/build/validate.md +4 -0
  69. package/commands/work-on/close.md +2 -0
  70. package/commands/work-on/decompose.md +24 -6
  71. package/docs/CI.md +3 -3
  72. package/docs/CONFIG.md +177 -2
  73. package/docs/OUTCOME-CODES.md +8 -0
  74. package/docs/TELEMETRY.md +1 -1
  75. package/docs/site/command-reference.md +22 -0
  76. package/docs/site/getting-started.md +2 -0
  77. package/docs/site/start-a-new-project.md +327 -0
  78. package/package.json +1 -1
  79. package/scripts/affected-files-check.sh +91 -0
  80. package/scripts/affected-files-check.test.sh +80 -0
  81. package/scripts/bootstrap-create.mjs +393 -0
  82. package/scripts/bootstrap-validate.mjs +36 -2
  83. package/scripts/calibration.mjs +116 -0
  84. package/scripts/check-devdocs-freshness.sh +9 -4
  85. package/scripts/check-devdocs-freshness.test.sh +11 -1
  86. package/scripts/check-spec-markers.sh +3 -0
  87. package/scripts/check-spec-size.sh +29 -11
  88. package/scripts/check-spec-size.test.sh +27 -4
  89. package/scripts/ci-local/lib.mjs +8 -1
  90. package/scripts/ci-local/parallelism.test.mjs +27 -0
  91. package/scripts/ci-local/run.mjs +3 -3
  92. package/scripts/ci-local/scoping.mjs +82 -0
  93. package/scripts/ci-local.test.sh +1 -1
  94. package/scripts/fixtures/symbol-index/other/sample.rb +3 -0
  95. package/scripts/fixtures/symbol-index/py/pkg/__init__.py +1 -0
  96. package/scripts/fixtures/symbol-index/py/pkg/a.py +5 -0
  97. package/scripts/fixtures/symbol-index/py/pkg/b.py +14 -0
  98. package/scripts/fixtures/symbol-index/py/pkg/c.py +1 -0
  99. package/scripts/fixtures/symbol-index/py/sample.py +41 -0
  100. package/scripts/fixtures/symbol-index/ts/sample.ts +13 -0
  101. package/scripts/fixtures/symbol-move/gen_big.py +201 -0
  102. package/scripts/fixtures/symbol-move-ts/gen_big.mjs +56 -0
  103. package/scripts/forge-root.test.sh +1 -1
  104. package/scripts/hotspots.sh +100 -0
  105. package/scripts/hotspots.test.sh +101 -0
  106. package/scripts/issue-create.sh +197 -0
  107. package/scripts/issue-create.test.sh +166 -0
  108. package/scripts/maintenance-scan.sh +185 -0
  109. package/scripts/maintenance-scan.test.sh +89 -0
  110. package/scripts/milestone-drift.sh +206 -0
  111. package/scripts/milestone-drift.test.sh +120 -0
  112. package/scripts/refactor-verify.sh +368 -0
  113. package/scripts/refactor-verify.test.sh +202 -0
  114. package/scripts/structure-delta.sh +10 -5
  115. package/scripts/structure-delta.test.sh +3 -2
  116. package/scripts/symbol-index.mjs +253 -0
  117. package/scripts/symbol-index.py +354 -0
  118. package/scripts/symbol-index.test.sh +119 -0
  119. package/scripts/symbol-move-ts.test.sh +161 -0
  120. package/scripts/symbol-move.mjs +379 -0
  121. package/scripts/symbol-move.py +383 -0
  122. package/scripts/symbol-move.test.sh +168 -0
  123. package/templates/bootstrap/interview.md +34 -0
  124. package/templates/bootstrap/issue-body.md +42 -0
  125. package/templates/bootstrap/needs-human-issue.md +45 -0
  126. package/templates/bootstrap/stack-profiles.md +34 -0
  127. package/templates/bootstrap/technical-spike.md +75 -0
@@ -114,7 +114,17 @@ run "no relevant changes pass" 0
114
114
 
115
115
  reset
116
116
  env PR_BODY="" bash "$S" no/such-ref >"$TMP/out" 2>&1; [ $? = 2 ] && PASS=$((PASS+1)) || bad "unknown base ref exits 2"
117
- env bash "$S" >"$TMP/out" 2>&1; [ $? = 2 ] && PASS=$((PASS+1)) || bad "missing base ref exits 2"
117
+ env bash "$S" >"$TMP/out" 2>&1; [ $? = 2 ] && PASS=$((PASS+1)) || bad "missing base ref and no upstream exits 2"
118
+
119
+ # #4220: without a base ref the branch's upstream is the base (CI always passes it explicitly).
120
+ git branch -q --set-upstream-to=origin/staging
121
+ printf '#!/bin/sh\necho bar\n' > scripts/foo.sh; commit
122
+ env PR_BODY="" bash "$S" >"$TMP/out" 2>&1; [ $? = 1 ] && PASS=$((PASS+1)) || bad "no base ref: upstream diff, docs missing fails"
123
+ printf 'docs-not-needed: fixture\n' > "$TMP/body"
124
+ env PR_BODY="" bash "$S" --body-file "$TMP/body" >"$TMP/out" 2>&1; [ $? = 0 ] && PASS=$((PASS+1)) || bad "no base ref + --body-file uses the upstream"
125
+ printf '# ADR\n' > devdocs/decisions/4220-x.md; commit
126
+ env PR_BODY="" bash "$S" >"$TMP/out" 2>&1; [ $? = 0 ] && PASS=$((PASS+1)) || bad "no base ref: upstream diff with a new decision passes"
127
+ git branch -q --unset-upstream
118
128
 
119
129
  echo "check-devdocs-freshness.test.sh: $PASS passed, $FAILN failed"
120
130
  [ "$FAILN" = 0 ]
@@ -91,7 +91,9 @@ MARKER_REGISTRY="
91
91
  BENCH_SCORECARD
92
92
  BLAST_RADIUS
93
93
  BLOCKED_ON_HUMAN_MERGE
94
+ BOOTSTRAP_PLAN
94
95
  BODY
96
+ BOOTSTRAP_PLAN
95
97
  BUILDER
96
98
  BUILD_BLOCKED
97
99
  CALIBRATION_CHECK
@@ -106,6 +108,7 @@ MARKER_REGISTRY="
106
108
  COORD_ISSUE
107
109
  CRITIQUE
108
110
  DECISION_RECORD
111
+ BOOTSTRAP_PLAN
109
112
  DECOMPOSED
110
113
  DECOMPOSE_BLOCKED
111
114
  DEGRADED_REREVIEW
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env bash
2
- # check-spec-size.sh — PR spec-size budget (forge#3762).
2
+ # check-spec-size.sh — PR spec-size budget for every command spec (forge#3762, #4164).
3
3
  # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
4
4
  # SPDX-License-Identifier: AGPL-3.0-or-later
5
5
  #
@@ -13,7 +13,9 @@
13
13
  # Exit: 0 pass/exempt, 1 budget violation, 2 usage/base-ref error (fail closed)
14
14
  #
15
15
  # Rules (against the merge-base of HEAD and the PR base, compared in bytes):
16
- # 1. Budgeted specs may not grow by more than 2% or more than 4096 bytes.
16
+ # 1. Every commands/**/*.md spec (head files plus files deleted at head) may not grow by more
17
+ # than 2% or more than 4096 bytes. A new spec (absent at the base) may be at most 4096 bytes.
18
+ # CEILINGS adds a per-file hard size cap (path:max_bytes) checked whatever the growth.
17
19
  # 2. The NET count of canonical block markers across all of commands/**/*.md
18
20
  # may not rise (moving a block between files, or replacing one with a stub,
19
21
  # is allowed). Count-based until the shared helper lands (#3493).
@@ -25,8 +27,9 @@ set -uo pipefail
25
27
 
26
28
  BASE="${1:-${BASE_REF:-origin/staging}}"
27
29
  PCT_NUM=2 # 2%
28
- ABS_MAX=4096 # bytes
29
- BUDGETED="commands/review-pr.md commands/review-pr-staging.md commands/work-on/remediate.md commands/work-on/review.md commands/work-on.md"
30
+ ABS_MAX=4096 # bytes; also the size cap for a new spec
31
+ # Per-file hard ceilings, one path:max_bytes per line (a spec converted to a script stub stays small).
32
+ CEILINGS="commands/issue.md:15360"
30
33
 
31
34
  SUMMARY="${GITHUB_STEP_SUMMARY:-/dev/stdout}"
32
35
  out() { printf '%s\n' "$*" >> "$SUMMARY"; }
@@ -67,27 +70,42 @@ out "## Spec-size budget (base: \`$BASE\`)"
67
70
  out ""
68
71
  out "| File | Base | Head | Delta | Delta % | Status |"
69
72
  out "|------|-----:|-----:|------:|--------:|--------|"
70
- for f in $BUDGETED; do
71
- [ -f "$f" ] || [ "$(base_size "$f")" != 0 ] || continue
73
+ ceiling_of() { printf '%s\n' "$CEILINGS" | awk -F: -v f="$1" '$1 == f {print $2; exit}'; }
74
+ HEAD_FILES=$(git ls-files 'commands/*.md' | sort -u)
75
+ BASE_FILES=$(git ls-tree -r --name-only "$BASE" -- commands | grep -E '\.md$' | sort -u)
76
+ ALL_FILES=$(printf '%s\n%s\n' "$HEAD_FILES" "$BASE_FILES" | sort -u)
77
+ SPECS=0
78
+ for f in $ALL_FILES; do
79
+ [ -n "$f" ] || continue
80
+ SPECS=$((SPECS + 1))
72
81
  b=$(base_size "$f")
73
82
  h=0; [ -f "$f" ] && h=$(wc -c < "$f" | tr -d ' ')
74
83
  g=$((h - b))
84
+ cap=$(ceiling_of "$f")
75
85
  status="ok"
76
- if [ "$g" -gt 0 ] && { [ $((g * 100)) -gt $((b * PCT_NUM)) ] || [ "$g" -gt "$ABS_MAX" ]; }; then
77
- status="OVER BUDGET"; FAILURES=$((FAILURES + 1))
86
+ if [ "$b" -eq 0 ]; then
87
+ [ "$h" -gt "$ABS_MAX" ] && status="OVER BUDGET (new spec > $ABS_MAX bytes)"
88
+ elif [ "$g" -gt 0 ] && { [ $((g * 100)) -gt $((b * PCT_NUM)) ] || [ "$g" -gt "$ABS_MAX" ]; }; then
89
+ status="OVER BUDGET"
90
+ fi
91
+ if [ -n "$cap" ] && [ "$h" -gt "$cap" ]; then
92
+ status="OVER CEILING ($cap bytes)"
78
93
  fi
94
+ # Only changed or failing specs get a row; the table would otherwise list every spec.
95
+ [ "$g" -ne 0 ] || [ "$status" != "ok" ] || continue
96
+ [ "$status" = "ok" ] || FAILURES=$((FAILURES + 1))
79
97
  if [ "$b" -gt 0 ]; then
80
98
  p=$(awk -v g="$g" -v b="$b" 'BEGIN{printf "%+.2f%%", g*100/b}')
81
99
  else p="n/a"; fi
82
100
  out "| \`$f\` | $b | $h | $g | $p | $status |"
83
101
  done
102
+ out ""
103
+ out "$SPECS spec(s) budgeted; unchanged specs within budget are not listed."
84
104
 
85
105
  # Net marker total across every commands/**/*.md: head (working tree, incl. new
86
106
  # files) vs base (incl. files deleted at head). Fail only when the total rises.
87
107
  HEAD_TOTAL=0; BASE_TOTAL=0; MARKER_ROWS=""
88
- HEAD_FILES=$(git ls-files 'commands/*.md' | sort -u)
89
- BASE_FILES=$(git ls-tree -r --name-only "$BASE" -- commands | grep -E '\.md$' | sort -u)
90
- for f in $(printf '%s\n%s\n' "$HEAD_FILES" "$BASE_FILES" | sort -u); do
108
+ for f in $ALL_FILES; do
91
109
  [ -n "$f" ] || continue
92
110
  hc=0; [ -f "$f" ] && hc=$(grep -cE "$MARKERS" "$f" 2>/dev/null || true); hc=${hc:-0}
93
111
  bc=$(git show "$BASE:$f" 2>/dev/null | grep -cE "$MARKERS" 2>/dev/null || true); bc=${bc:-0}
@@ -45,8 +45,30 @@ run "over 4 KB fails" 1
45
45
  reset; gen 5 >> commands/review-pr.md # small growth under both limits
46
46
  run "small growth passes" 0
47
47
 
48
- reset; gen 5000 >> commands/other.md # not a budgeted spec
49
- run "non-budgeted growth passes" 0
48
+ # Every spec is budgeted (#4164): top-level and nested specs outside the old review list
49
+ reset; gen 5000 >> commands/other.md
50
+ run "formerly unbudgeted spec growth fails" 1; has "other spec flagged" "| \`commands/other.md\`"
51
+ reset; gen 1000 > commands/work-on/nested.md; git add -A; git commit -qm nested; git push -q origin HEAD:staging 2>/dev/null; git fetch -q origin staging
52
+ gen 100 >> commands/work-on/nested.md
53
+ run "nested spec over 2% fails" 1
54
+ git reset -q --hard HEAD~1; git push -q -f origin HEAD:staging 2>/dev/null; git fetch -q origin staging; reset
55
+ reset; gen 2 >> commands/other.md
56
+ run "unbudgeted-before small growth passes" 0
57
+
58
+ # New specs: up to 4096 bytes
59
+ reset; gen 100 > commands/brand-new.md; git add -A # ~2.8 KB
60
+ run "new spec under 4 KB passes" 0
61
+ reset; gen 200 > commands/brand-new.md; git add -A # ~5.6 KB
62
+ run "new spec over 4 KB fails" 1; has "new spec flagged" "new spec >"
63
+
64
+ # Per-file ceiling: commands/issue.md may not exceed 15360 bytes whatever the growth
65
+ reset; head -c 15300 /dev/zero | tr '\0' 'x' > commands/issue.md; git add -A; git commit -qm issue; git push -q origin HEAD:staging 2>/dev/null; git fetch -q origin staging
66
+ printf '%050d' 0 >> commands/issue.md # 15350 bytes: +0.3%, under the cap
67
+ run "ceiling: under the cap passes" 0
68
+ printf '%050d' 0 >> commands/issue.md # 15400 bytes: +0.65% (within 2%/4 KB) but over the cap
69
+ run "ceiling: over the cap fails" 1; has "ceiling flagged" "OVER CEILING (15360 bytes)"
70
+ run "ceiling: exempt label waives" 0 PR_LABELS="spec-size-exempt"
71
+ git reset -q --hard HEAD~1; git push -q -f origin HEAD:staging 2>/dev/null; git fetch -q origin staging; reset
50
72
 
51
73
  reset; gen 100 > commands/review-pr.md # shrink
52
74
  run "shrink passes" 0
@@ -89,9 +111,10 @@ run "copy without removal still fails" 1
89
111
  # Shared-helper stubs (forge#3778): the sourced-helper stub shape carries none of the markers, so converting an inlined block to a stub
90
112
  # is a net reduction, while re-pasting the full block still fails.
91
113
  STUB_LOC='type forge_resolve >/dev/null 2>&1 || for _r in "${FORGEDOCK_HOME:-}"; do . "$_r/scripts/lib/forge-resolve.sh"; done'
92
- reset; printf '%s\n' "$STUB_LOC" '. "$(forge_resolve lib/fence-bootstrap.sh)" || exit 1 # FENCE_BOOTSTRAP' 'HELPER_SCRIPT=$(forge_resolve x.sh)' >> commands/other.md
114
+ # (appended to the large work-on.md so the byte budget stays out of the marker cases)
115
+ reset; printf '%s\n' "$STUB_LOC" '. "$(forge_resolve lib/fence-bootstrap.sh)" || exit 1 # FENCE_BOOTSTRAP' 'HELPER_SCRIPT=$(forge_resolve x.sh)' >> commands/work-on.md
93
116
  run "one-tag-per-stub design passes" 0
94
- reset; : > commands/donor.md; printf '%s\n' "$STUB_LOC" 'HELPER_SCRIPT=$(forge_resolve x.sh)' >> commands/other.md
117
+ reset; : > commands/donor.md; printf '%s\n' "$STUB_LOC" 'HELPER_SCRIPT=$(forge_resolve x.sh)' >> commands/work-on.md
95
118
  run "block converted to a stub passes" 0
96
119
  reset; printf '%s\n' "$STUB_LOC" 'HELPER_SCRIPT=""' >> commands/other.md
97
120
  run "stub plus a re-pasted resolver block fails" 1
@@ -327,7 +327,7 @@ export function shellQuote(s) {
327
327
  // An allowlist, not a blacklist: remote CI starts from a clean environment, so ambient operator state (agent
328
328
  // credentials, install-root overrides, git hook variables such as GIT_DIR/GIT_INDEX_FILE, FORGE_*/CLAUDE_* settings)
329
329
  // must not reach the jobs. Toolchain variables are passed through; credential-looking names are always dropped.
330
- const ENV_EXACT = new Set(['PATH', 'HOME', 'USER', 'LOGNAME', 'SHELL', 'LANG', 'LANGUAGE', 'TERM', 'TMPDIR', 'TMP', 'TEMP', 'TZ', 'COLORTERM', 'NO_COLOR', 'SSL_CERT_FILE', 'SSL_CERT_DIR', 'NODE_EXTRA_CA_CERTS', 'REQUESTS_CA_BUNDLE', 'CURL_CA_BUNDLE', 'HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY', 'http_proxy', 'https_proxy', 'no_proxy', 'DOCKER_HOST', 'JAVA_HOME', 'GOPATH', 'GOROOT', 'GOFLAGS', 'GOCACHE', 'GOMODCACHE', 'GOPROXY', 'GOTOOLCHAIN', 'VIRTUAL_ENV', 'PYTHONPATH', 'PYTHONDONTWRITEBYTECODE', 'RUSTFLAGS', 'RUSTUP_HOME', 'SDKMAN_DIR', 'PNPM_HOME', 'BUN_INSTALL', 'NVM_DIR', 'NVM_BIN', 'NVM_INC', 'RBENV_ROOT', 'PYENV_ROOT', 'ASDF_DIR', 'ASDF_DATA_DIR', 'MISE_DATA_DIR', 'HOMEBREW_PREFIX', 'ComSpec', 'SystemRoot', 'USERPROFILE', 'APPDATA', 'LOCALAPPDATA', 'PROGRAMFILES', 'MSYSTEM']);
330
+ const ENV_EXACT = new Set(['PATH', 'HOME', 'USER', 'LOGNAME', 'SHELL', 'LANG', 'LANGUAGE', 'TERM', 'TMPDIR', 'TMP', 'TEMP', 'TZ', 'COLORTERM', 'NO_COLOR', 'SSL_CERT_FILE', 'SSL_CERT_DIR', 'NODE_EXTRA_CA_CERTS', 'REQUESTS_CA_BUNDLE', 'CURL_CA_BUNDLE', 'HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY', 'http_proxy', 'https_proxy', 'no_proxy', 'DOCKER_HOST', 'JAVA_HOME', 'GOPATH', 'GOROOT', 'GOFLAGS', 'GOCACHE', 'GOMODCACHE', 'GOPROXY', 'GOTOOLCHAIN', 'VIRTUAL_ENV', 'PYTHONPATH', 'PYTHONDONTWRITEBYTECODE', 'RUSTFLAGS', 'RUSTUP_HOME', 'SDKMAN_DIR', 'PNPM_HOME', 'BUN_INSTALL', 'NVM_DIR', 'NVM_BIN', 'NVM_INC', 'RBENV_ROOT', 'PYENV_ROOT', 'ASDF_DIR', 'ASDF_DATA_DIR', 'MISE_DATA_DIR', 'HOMEBREW_PREFIX', 'ComSpec', 'SystemRoot', 'USERPROFILE', 'APPDATA', 'LOCALAPPDATA', 'PROGRAMFILES', 'MSYSTEM', 'FORGE_HOST_PARALLELISM']);
331
331
  const ENV_PREFIX = ['LC_', 'XDG_', 'CARGO_', 'MAVEN_', 'M2_', 'GRADLE_', 'DOTNET_', 'npm_config_', 'NPM_CONFIG_', 'YARN_', 'UV_', 'POETRY_', 'PIP_CACHE', 'FORGE_CI_LOCAL_'];
332
332
  // Substring tokens are unambiguous; short ones (PASS, PAT, KEY, IDENT, CRED, SESSION, COOKIE) match only as whole
333
333
  // name segments (or a trailing _KEY) so benign names such as FORGE_CI_LOCAL_PASSTHROUGH or PATH are kept.
@@ -347,6 +347,13 @@ export function scrubbedEnv(extra = {}) {
347
347
  return Object.assign(env, extra);
348
348
  }
349
349
 
350
+ // Host-derived worker cap a ForgeDock lane exports (#4182): never run more jobs/slots than FORGE_HOST_PARALLELISM.
351
+ export function capParallel(n, env = process.env) {
352
+ const s = String(env?.FORGE_HOST_PARALLELISM ?? '').trim();
353
+ const p = /^\d+$/.test(s) ? Number(s) : 0;
354
+ return p > 0 ? Math.min(n, p) : n;
355
+ }
356
+
350
357
  export function homeForgeDir() {
351
358
  return process.env.FORGE_CI_LOCAL_HOME || path.join(process.env.HOME || os.homedir(), '.forge');
352
359
  }
@@ -0,0 +1,27 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ import { describe, it } from "node:test";
4
+ import assert from "node:assert/strict";
5
+ import { scrubbedEnv, capParallel } from "./lib.mjs";
6
+
7
+ describe("ci-local host parallelism (#4182)", () => {
8
+ it("scrubbedEnv passes FORGE_HOST_PARALLELISM through and still drops secrets", () => {
9
+ const keep = { FORGE_HOST_PARALLELISM: process.env.FORGE_HOST_PARALLELISM, FD_TEST_API_TOKEN: process.env.FD_TEST_API_TOKEN, FORGE_OTHER_SETTING: process.env.FORGE_OTHER_SETTING };
10
+ process.env.FORGE_HOST_PARALLELISM = "2"; process.env.FD_TEST_API_TOKEN = "s3cret"; process.env.FORGE_OTHER_SETTING = "x";
11
+ try {
12
+ const env = scrubbedEnv({});
13
+ assert.equal(env.FORGE_HOST_PARALLELISM, "2");
14
+ assert.equal(env.FD_TEST_API_TOKEN, undefined);
15
+ assert.equal(env.FORGE_OTHER_SETTING, undefined, "other FORGE_* settings stay out");
16
+ } finally {
17
+ for (const [k, v] of Object.entries(keep)) { if (v === undefined) delete process.env[k]; else process.env[k] = v; }
18
+ }
19
+ });
20
+
21
+ it("capParallel caps slots/job parallelism at a positive FORGE_HOST_PARALLELISM, else leaves them alone", () => {
22
+ assert.equal(capParallel(4, { FORGE_HOST_PARALLELISM: "2" }), 2);
23
+ assert.equal(capParallel(1, { FORGE_HOST_PARALLELISM: "2" }), 1);
24
+ assert.equal(capParallel(4, {}), 4);
25
+ for (const bad of ["0", "abc", "-1", "", " "]) assert.equal(capParallel(4, { FORGE_HOST_PARALLELISM: bad }), 4, JSON.stringify(bad));
26
+ });
27
+ });
@@ -11,7 +11,7 @@ import fs from 'node:fs';
11
11
  import net from 'node:net';
12
12
  import os from 'node:os';
13
13
  import path from 'node:path';
14
- import { run as runSync, commandExists, scrubbedEnv, homeForgeDir, sha256, fileHash, toolVersion, shellSplit } from './lib.mjs';
14
+ import { run as runSync, commandExists, scrubbedEnv, capParallel, homeForgeDir, sha256, fileHash, toolVersion, shellSplit } from './lib.mjs';
15
15
  import { shellArgv } from './adapters/common.mjs';
16
16
  import { checkPortability } from './adapters/portability.mjs';
17
17
 
@@ -25,7 +25,7 @@ for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.on(sig, () => { clean
25
25
  // ---- machine-wide slot semaphore ----
26
26
  function slotCount() {
27
27
  const n = Number(process.env.FORGE_CI_LOCAL_SLOTS);
28
- return n > 0 ? n : Math.max(2, Math.min(8, Math.floor(os.cpus().length / 2)));
28
+ return capParallel(n > 0 ? n : Math.max(2, Math.min(8, Math.floor(os.cpus().length / 2))));
29
29
  }
30
30
  function alive(pid) { try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; } }
31
31
 
@@ -372,7 +372,7 @@ export async function runPlan(plan, o = {}) {
372
372
  // (DEGRADED) and a re-run resumes from the result cache.
373
373
  const maxInvS = Number(process.env.FORGE_CI_LOCAL_MAX_INVOCATION_SECONDS) || 540;
374
374
  const budgetS = Math.min(requestedS, maxInvS); const capped = requestedS > maxInvS;
375
- const maxParallel = o.maxParallel || plan.config.max_parallel || Number(process.env.FORGE_CI_LOCAL_MAX_PARALLEL) || 4;
375
+ const maxParallel = capParallel(o.maxParallel || plan.config.max_parallel || Number(process.env.FORGE_CI_LOCAL_MAX_PARALLEL) || 4);
376
376
  const tmpRoot = fs.mkdtempSync(path.join(process.env.TMPDIR || os.tmpdir(), 'forge-ci-local.'));
377
377
  cleanups.add(() => { try { fs.rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* ignore */ } });
378
378
  const ctx = {
@@ -342,6 +342,7 @@ export function scopeCommand(cmd, cwdRel, sctx, depth = 0) {
342
342
 
343
343
  let r = null;
344
344
  if (LINTERS[tool] && !(LINTERS[tool].sub && !LINTERS[tool].sub.has(rest[1]))) r = scopeLint(tool, rest, cmd, cwdRel, sctx, join);
345
+ else if (tool === 'tsc') r = scopeTsc(rest, cmd, cwdRel, sctx, join);
345
346
  else if (tool === 'jest') r = scopeJest(rest, cmd, cwdRel, sctx, join);
346
347
  else if (tool === 'vitest') r = scopeVitest(rest, cmd, cwdRel, sctx, join);
347
348
  else if (tool === 'pytest' || tool === 'py.test' || (/^python[0-9.]*$/.test(tool) && rest[1] === '-m' && rest[2] === 'pytest')) r = scopeRunner('pytest', tool === 'pytest' || tool === 'py.test' ? rest.slice(1) : rest.slice(3), tool === 'pytest' || tool === 'py.test' ? [rest[0]] : rest.slice(0, 3), cmd, cwdRel, sctx, join);
@@ -507,6 +508,87 @@ function scopeMaven(rest, cmd, cwdRel, sctx, join) {
507
508
  return scoped(join([...rest, '-pl', [...mods].sort().join(','), '-amd']), mods.size, null, 'maven-affected');
508
509
  }
509
510
 
511
+ // ---- TypeScript compiler: the owning tsconfig project(s) of the changed files and of the files that reference them ----
512
+ const TS_EXT = set('ts tsx mts cts js jsx mjs cjs');
513
+ const TSC_VALUE = set('-p --project -t --target -m --module --outDir --outFile --rootDir --lib --jsx --tsBuildInfoFile --declarationDir --moduleResolution --baseUrl --types --typeRoots --locale --generateTrace --maxNodeModuleJsDepth --moduleDetection --verbatimModuleSyntax');
514
+
515
+ // tsconfig.json is JSON with comments and trailing commas; null when unreadable.
516
+ function readTsconfig(sctx, dir) {
517
+ sctx.cache.tsconfig = sctx.cache.tsconfig || new Map();
518
+ if (sctx.cache.tsconfig.has(dir)) return sctx.cache.tsconfig.get(dir);
519
+ let cfg = null;
520
+ try {
521
+ const raw = fs.readFileSync(path.join(sctx.repo, dir, 'tsconfig.json'), 'utf8');
522
+ const noComments = raw.replace(/("(?:[^"\\]|\\.)*")|\/\/[^\n]*|\/\*[\s\S]*?\*\//g, (m, q) => q || '');
523
+ cfg = JSON.parse(noComments.replace(/,(\s*[}\]])/g, '$1'));
524
+ } catch { cfg = null; }
525
+ sctx.cache.tsconfig.set(dir, cfg);
526
+ return cfg;
527
+ }
528
+
529
+ // The directory of the nearest tsconfig.json above `file` ('.' for the repo root), or '' when none owns it.
530
+ export function tsProjectOf(sctx, file) {
531
+ for (let d = px.dirname(file); ; d = px.dirname(d)) {
532
+ if (sctx.present.has(d === '.' ? 'tsconfig.json' : `${d}/tsconfig.json`)) return d;
533
+ if (d === '.' || d === '/') return '';
534
+ }
535
+ }
536
+
537
+ // A project `tsc -b` can build: composite itself, or listed in another tsconfig's `references`.
538
+ function tsBuildable(sctx, dir) {
539
+ const cfg = readTsconfig(sctx, dir);
540
+ if (cfg && cfg.compilerOptions && cfg.compilerOptions.composite === true) return true;
541
+ if (!sctx.cache.tsRefs) {
542
+ sctx.cache.tsRefs = new Set();
543
+ for (const f of sctx.tracked) {
544
+ if (px.basename(f) !== 'tsconfig.json' || !sctx.present.has(f)) continue;
545
+ const from = px.dirname(f); const c = readTsconfig(sctx, from);
546
+ for (const r of (c && Array.isArray(c.references) ? c.references : [])) {
547
+ if (!r || typeof r.path !== 'string') continue;
548
+ const to = px.normalize(px.join(from, r.path));
549
+ sctx.cache.tsRefs.add(to.endsWith('.json') ? px.dirname(to) : to);
550
+ }
551
+ }
552
+ }
553
+ return sctx.cache.tsRefs.has(dir);
554
+ }
555
+
556
+ function scopeTsc(rest, cmd, cwdRel, sctx, join) {
557
+ const flags = []; let project = ''; let build = false; const buildTargets = [];
558
+ for (let i = 1; i < rest.length; i++) {
559
+ const t = rest[i];
560
+ if (t === '-p' || t === '--project') { if (i + 1 >= rest.length) return full(cmd, '', { selector: 'unscoped:tsc-args' }); project = rest[++i]; continue; }
561
+ if (t.startsWith('--project=')) { project = t.slice(10); continue; }
562
+ if (t === '-b' || t === '--build') { build = true; continue; }
563
+ if (t === '-w' || t === '--watch') return full(cmd, '', { selector: 'unscoped:tsc-watch' });
564
+ if (t.startsWith('-')) { flags.push(t); if (TSC_VALUE.has(t) && i + 1 < rest.length) flags.push(rest[++i]); continue; }
565
+ if (!build) return full(cmd, '', { selector: 'unscoped:tsc-explicit-files' });
566
+ buildTargets.push(t);
567
+ }
568
+ if (/[$`*?]/.test(project) || buildTargets.some((t) => /[$`*?]/.test(t))) return full(cmd, '', { selector: 'unscoped:complex-target' });
569
+ // The project this command checks: -p X, the single -b target, else the tsconfig in its cwd.
570
+ const targetOf = (t) => { const r = rel(cwdRel, t); return r.endsWith('.json') ? px.dirname(r) : r; };
571
+ if (buildTargets.length > 1) return full(cmd, '', { selector: 'unscoped:explicit-targets' });
572
+ const target = project ? targetOf(project) : buildTargets.length ? targetOf(buildTargets[0]) : rel(cwdRel, '.');
573
+ if (sctx.widen) return full(cmd, sctx.widen, { selector: 'tsc-project' });
574
+ const un = unmappedFile(sctx.changed, TS_EXT);
575
+ if (un) return full(cmd, `unknown-language: ${un}`, { selector: 'tsc-project' });
576
+ const r = reach(sctx);
577
+ if (r.widen) return full(cmd, r.widen, { selector: 'tsc-project' });
578
+ const tsFiles = [...r.files].filter((f) => TS_EXT.has(extOf(f)));
579
+ if (!tsFiles.length) return skipNo(0, null, 'tsc-project', 'no changed TS/JS file');
580
+ const owners = dedupe(tsFiles.map((f) => tsProjectOf(sctx, f)).filter(Boolean))
581
+ .filter((d) => target === '.' || d === target || d.startsWith(target + '/')).sort();
582
+ if (!owners.length) return skipNo(0, null, 'tsc-project', `no changed TS/JS file in ${target}`);
583
+ // A command already aimed at one non-root project is that project's check: keep it as is.
584
+ if (target !== '.') return scoped(cmd, owners.length, null, 'tsc-project', { projects: owners });
585
+ if (owners.includes('.')) return full(cmd, 'root-project', { selector: 'tsc-project', projects: owners });
586
+ if (owners.length === 1) return scoped(join([rest[0], build ? '-b' : '-p', fromCwd(cwdRel, owners[0]), ...flags]), 1, null, 'tsc-project', { projects: owners });
587
+ if (!owners.every((d) => tsBuildable(sctx, d))) return full(cmd, 'multi-project', { selector: 'tsc-project', projects: owners });
588
+ // Build mode rejects --noEmit; composite projects emit their declarations as the repo's own build does.
589
+ return scoped(join([rest[0], '-b', ...owners.map((d) => fromCwd(cwdRel, d)), ...flags.filter((f) => f !== '--noEmit')]), owners.length, null, 'tsc-project', { projects: owners });
590
+ }
591
+
510
592
  function scopeMono(tool, rest, cmd, sctx, join) {
511
593
  if (sctx.widen) return full(cmd, sctx.widen, { selector: `${tool}-affected` });
512
594
  const base = sctx.from || sctx.baseRef;
@@ -432,7 +432,7 @@ SHIM
432
432
  chmod +x "$T/bin/docker"
433
433
  export FAKE_DOCKER_LOG="$T/docker.log"
434
434
  : > "$FAKE_DOCKER_LOG"
435
- OUT=$(PATH="$T/bin:$PATH" bash "$S" run --repo "$r" --base main --base-branch main --head-branch feat 2>&1); RC=$?
435
+ OUT=$(env -u FORGE_DOCKER_LABELS PATH="$T/bin:$PATH" bash "$S" run --repo "$r" --base main --base-branch main --head-branch feat 2>&1); RC=$?
436
436
  eq "a service job runs when docker is available" 0 "$RC"
437
437
  LOG=$(cat "$FAKE_DOCKER_LOG")
438
438
  has "container is started with the same image" "$LOG" "postgres:16"
@@ -0,0 +1,3 @@
1
+ def greet(name)
2
+ "hi #{name}"
3
+ end
@@ -0,0 +1 @@
1
+ from .c import value
@@ -0,0 +1,5 @@
1
+ from pkg import b
2
+
3
+
4
+ def run():
5
+ return b.step()
@@ -0,0 +1,14 @@
1
+ from typing import TYPE_CHECKING
2
+
3
+ from . import c
4
+
5
+ if TYPE_CHECKING:
6
+ from . import a
7
+
8
+
9
+ def step():
10
+ from .a import run # function-local: not a module-level edge
11
+ return c.value + (0 if run else 1)
12
+
13
+
14
+ from .a import run as _run_alias # noqa: E402 module-level a <-> b cycle
@@ -0,0 +1 @@
1
+ value = 1
@@ -0,0 +1,41 @@
1
+ """Fixture module for symbol-index.test.sh. SECRET_DOCSTRING_MARKER"""
2
+ import os
3
+ from collections import OrderedDict as OD
4
+
5
+ __all__ = ["Greeter", "greet", "fetch", "LIMIT"]
6
+
7
+ LIMIT = 10
8
+ _cache = OD()
9
+
10
+
11
+ def _normalise(name):
12
+ return name.strip().lower()
13
+
14
+
15
+ def greet(name):
16
+ return "hi " + _normalise(name)
17
+
18
+
19
+ async def fetch(key):
20
+ return _cache.get(key, LIMIT)
21
+
22
+
23
+ def helper():
24
+ return os.getcwd()
25
+
26
+
27
+ class Greeter:
28
+ """SECRET_CLASS_DOC_MARKER"""
29
+
30
+ def __init__(self, name):
31
+ self.name = name
32
+
33
+ @property
34
+ def text(self):
35
+ return greet(self.name)
36
+
37
+ def _shout(self):
38
+ return self.text.upper()
39
+
40
+ def shout(self):
41
+ return self._shout()
@@ -0,0 +1,13 @@
1
+ import { readFileSync } from "node:fs";
2
+
3
+ export const LIMIT = 10;
4
+
5
+ export function load(path: string): string {
6
+ return readFileSync(path, "utf8").slice(0, LIMIT);
7
+ }
8
+
9
+ export class Loader {
10
+ read(path: string): string {
11
+ return load(path);
12
+ }
13
+ }
@@ -0,0 +1,201 @@
1
+ #!/usr/bin/env python3
2
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
3
+ # SPDX-License-Identifier: AGPL-3.0-or-later
4
+ #
5
+ # gen_big.py: deterministic fixture for scripts/symbol-move.test.sh (forge#4210). stdlib only, no randomness.
6
+ # Usage: python3 gen_big.py <out_root>
7
+ # Writes <out_root>/pkg/__init__.py, pkg/big.py (~3k lines) and tests/__init__.py, tests/test_big.py.
8
+ # big.py groups (the test's plan): consts (LIMIT_*, _SEPARATOR), helpers (_scale_*, compute_*, join_parts),
9
+ # aio (fetch_*), models (Item_*); VERSION, describe() and DEFAULT_TOTAL stay behind and use moved names.
10
+
11
+ import os
12
+ import sys
13
+
14
+ N_FUNCS = 90
15
+ N_ASYNC = 30
16
+ N_CLASSES = 30
17
+
18
+
19
+ def big():
20
+ out = ['"""Fixture module for the symbol-move codemod test."""', "",
21
+ "from __future__ import annotations", "",
22
+ "import functools", "import os", "", "", "# ---- constants ----", ""]
23
+ for i in range(N_FUNCS):
24
+ out.append("LIMIT_%02d = %d # upper bound for compute_%02d" % (i, 10 + i, i))
25
+ out += ["", '_SEPARATOR = os.sep + "|"', "", ""]
26
+ out += ["# ---- helpers ----", ""]
27
+ for i in range(N_FUNCS):
28
+ out += [
29
+ "",
30
+ "def _scale_%02d(value: int) -> int:" % i,
31
+ " # private helper: scales by the group factor",
32
+ " factor = %d" % (i % 7 + 1),
33
+ " if value < 0:",
34
+ " return -value * factor",
35
+ " return value * factor",
36
+ "",
37
+ "",
38
+ "def compute_%02d(value: int) -> int:" % i,
39
+ ' """Scale value and clamp it to LIMIT_%02d."""' % i,
40
+ " scaled = _scale_%02d(value)" % i,
41
+ " if scaled > LIMIT_%02d:" % i,
42
+ " return LIMIT_%02d" % i,
43
+ " total = 0",
44
+ " for step in range(scaled):",
45
+ " total += step % 3",
46
+ " return scaled + total - total",
47
+ "",
48
+ ]
49
+ out += [
50
+ "",
51
+ "def join_parts(*parts: str) -> str:",
52
+ " # joins with the module separator",
53
+ " return _SEPARATOR.join(str(p) for p in parts)",
54
+ "",
55
+ "",
56
+ "# ---- async ----",
57
+ "",
58
+ ]
59
+ for i in range(N_ASYNC):
60
+ out += [
61
+ "",
62
+ "async def fetch_%02d(value: int) -> dict:" % i,
63
+ ' """Pretend to fetch: returns the computed value in a record."""',
64
+ " result = compute_%02d(value)" % i,
65
+ ' record = {"id": %d, "value": result}' % i,
66
+ " if result % 2:",
67
+ ' record["odd"] = True',
68
+ " return record",
69
+ "",
70
+ ]
71
+ out += ["", "# ---- models ----", ""]
72
+ for i in range(N_CLASSES):
73
+ out += [
74
+ "",
75
+ "@functools.total_ordering",
76
+ "class Item_%02d:" % i,
77
+ ' """A comparable item backed by compute_%02d."""' % i,
78
+ "",
79
+ ' kind = "item-%02d"' % i,
80
+ "",
81
+ " def __init__(self, raw: int) -> None:",
82
+ " self.raw = raw",
83
+ "",
84
+ " @property",
85
+ " def value(self) -> int:",
86
+ " return compute_%02d(self.raw)" % i,
87
+ "",
88
+ " def label(self) -> str:",
89
+ ' return join_parts(self.kind, self.value, LIMIT_%02d)' % i,
90
+ "",
91
+ " def __eq__(self, other: object) -> bool:",
92
+ " if not isinstance(other, Item_%02d):" % i,
93
+ " return NotImplemented",
94
+ " return self.value == other.value",
95
+ "",
96
+ " def __lt__(self, other: Item_%02d) -> bool:" % i,
97
+ " return self.value < other.value",
98
+ "",
99
+ " def __hash__(self) -> int:",
100
+ " return hash((self.kind, self.value))",
101
+ "",
102
+ ]
103
+ out += [
104
+ "",
105
+ "# ---- kept in place ----",
106
+ "",
107
+ 'VERSION = "1.0"',
108
+ "",
109
+ "",
110
+ "def describe() -> str:",
111
+ " return join_parts(VERSION, compute_00(1), Item_01(2).label())",
112
+ "",
113
+ "",
114
+ "DEFAULT_TOTAL = LIMIT_03 * 2",
115
+ ]
116
+ return "\n".join(out) + "\n"
117
+
118
+
119
+ TESTS = '''import asyncio
120
+ import os
121
+ import unittest
122
+
123
+ from pkg.big import (
124
+ DEFAULT_TOTAL,
125
+ LIMIT_05,
126
+ VERSION,
127
+ Item_03,
128
+ _scale_02,
129
+ _SEPARATOR,
130
+ compute_04,
131
+ compute_89,
132
+ describe,
133
+ fetch_08,
134
+ join_parts,
135
+ )
136
+ import pkg.big as big
137
+
138
+
139
+ class BigTest(unittest.TestCase):
140
+ def test_constant(self):
141
+ self.assertEqual(LIMIT_05, 15)
142
+
143
+ def test_private_helper(self):
144
+ self.assertEqual(_scale_02(-2), 6)
145
+
146
+ def test_compute_clamps(self):
147
+ self.assertEqual(compute_04(100), 14)
148
+
149
+ def test_compute_last(self):
150
+ self.assertEqual(compute_89(1), 6)
151
+
152
+ def test_join(self):
153
+ self.assertEqual(join_parts("a", 1), "a" + os.sep + "|1")
154
+ self.assertEqual(_SEPARATOR, os.sep + "|")
155
+
156
+ def test_async(self):
157
+ self.assertEqual(asyncio.run(fetch_08(1)), {"id": 8, "value": 2})
158
+
159
+ def test_item_order(self):
160
+ self.assertLess(Item_03(1), Item_03(2))
161
+ self.assertEqual(Item_03(1), Item_03(1))
162
+ self.assertGreaterEqual(Item_03(3), Item_03(2))
163
+
164
+ def test_item_label(self):
165
+ self.assertEqual(Item_03(1).label(), join_parts("item-03", 4, 13))
166
+
167
+ def test_kept_names(self):
168
+ self.assertEqual(VERSION, "1.0")
169
+ self.assertEqual(DEFAULT_TOTAL, 26)
170
+ self.assertTrue(describe().startswith("1.0"))
171
+
172
+ def test_module_attributes(self):
173
+ self.assertIs(big.os, os)
174
+ self.assertEqual(big.Item_29(1).value, 2)
175
+
176
+ @unittest.skip("documents a skipped case for the count check")
177
+ def test_skipped(self):
178
+ pass
179
+
180
+
181
+ if __name__ == "__main__":
182
+ unittest.main()
183
+ '''
184
+
185
+
186
+ def main(argv):
187
+ if len(argv) != 1:
188
+ sys.stderr.write("usage: gen_big.py <out_root>\n")
189
+ return 2
190
+ root = argv[0]
191
+ for d in ("pkg", "tests"):
192
+ os.makedirs(os.path.join(root, d), exist_ok=True)
193
+ files = {"pkg/__init__.py": "", "pkg/big.py": big(), "tests/__init__.py": "", "tests/test_big.py": TESTS}
194
+ for rel, text in files.items():
195
+ with open(os.path.join(root, rel), "w", encoding="utf-8", newline="\n") as fh:
196
+ fh.write(text)
197
+ return 0
198
+
199
+
200
+ if __name__ == "__main__":
201
+ sys.exit(main(sys.argv[1:]))