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
@@ -4,12 +4,14 @@
4
4
  * Durable batch state and detached lanes for `forgedock orchestrate` (#4121).
5
5
  *
6
6
  * A batch lives in `~/.forge/batches/<id>/` (or `FORGEDOCK_BATCH_DIR`): `plan.json` (the command), `state.json` (the
7
- * scheduler state, rewritten after every transition), `log.jsonl` (one line per lane update), `parallel` (live K) and
8
- * `lanes/<issue>.{json,log}` (each lane's result and output). Every write is tmp + fsync + rename, and the root must be
7
+ * scheduler state, rewritten after every transition), `log.jsonl` (one line per lane update), `parallel` (live K),
8
+ * `lanes/<issue>.{json,log}` (each lane's result and output) and `report.json` (the final rows and printed report, written
9
+ * when the scheduler loop returns; `forgedock orchestrate --resume` on a finished batch replays it, #4192). Every write is tmp + fsync + rename, and the root must be
9
10
  * on disk, never tmpfs. A lane is its own session (`setsid` through `detached`), so killing the dispatcher leaves it
10
- * running; a resumed dispatcher re-attaches to it by pid, checking `/proc/<pid>/cmdline` against its result path.
11
+ * running; a resumed dispatcher re-attaches to it by pid, checking `/proc/<pid>/cmdline` against its result path. These
12
+ * detached lanes are the `local` execution (#4253); `execution.mjs` is the sandbox-provider seam for remote lanes.
11
13
  */
12
- import { appendFileSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeSync } from "node:fs";
14
+ import { appendFileSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, rmSync, writeSync } from "node:fs";
13
15
  import { spawn } from "node:child_process";
14
16
  import { homedir } from "node:os";
15
17
  import { dirname, join, resolve, sep } from "node:path";
@@ -63,6 +65,13 @@ export function newBatchId(now = Date.now(), pid = process.pid) {
63
65
  return `b${new Date(now).toISOString().replace(/[-:]/g, "").replace("T", "-").slice(0, 15)}-${pid}`;
64
66
  }
65
67
 
68
+ /** Ids of the batches under `root` (directories with a `plan.json`). */
69
+ export function listBatches(root) {
70
+ let names = [];
71
+ try { names = readdirSync(root); } catch { return []; }
72
+ return names.filter((n) => ID_RE.test(n) && existsSync(join(root, n, "plan.json"))).sort();
73
+ }
74
+
66
75
  /** Handle on one batch directory. */
67
76
  export function openBatch(root, id) {
68
77
  if (!ID_RE.test(String(id ?? ""))) throw new Error(`invalid batch id ${JSON.stringify(id)}`);
@@ -84,6 +93,15 @@ export function openBatch(root, id) {
84
93
  if (!Number.isInteger(k) || k < 1) throw new Error("parallel must be a positive integer");
85
94
  writeAtomic(f("parallel"), `${k}\n`);
86
95
  },
96
+ /** The lane updates in `log.jsonl`, in order (unparsable lines skipped). */
97
+ readLog: () => {
98
+ let text = "";
99
+ try { text = readFileSync(f("log.jsonl"), "utf8"); } catch { return []; }
100
+ return text.split("\n").flatMap((l) => { try { return l ? [JSON.parse(l)] : []; } catch { return []; } });
101
+ },
102
+ readReport: () => readJson(f("report.json")),
103
+ writeReport: (report) => writeAtomic(f("report.json"), JSON.stringify(report) + "\n"),
104
+ clearReport: () => rmSync(f("report.json"), { force: true }),
87
105
  resultFile: (n) => f(join("lanes", `${n}.json`)),
88
106
  logFile: (n) => f(join("lanes", `${n}.log`)),
89
107
  };
@@ -10,6 +10,8 @@ import { orchestrate, deferPolicy, DEFER_BACKOFF_S } from "./orchestrate.mjs";
10
10
  import { openBatch, fsTypeOf, assertDiskPath, isLaneAlive, writeAtomic, launchLane } from "./batch.mjs";
11
11
  import { orchestrateFromCli, runFromCli, readOrchestration } from "../engine-cli.mjs";
12
12
 
13
+ // orchestrateFromCli opens the host lane registry (#4191): keep it in a temp dir, also for spawned dispatchers.
14
+ process.env.FORGEDOCK_SCHED_DIR = tmpDir("batch-sched-");
13
15
  const quiet = async (fn) => { const l = console.log; console.log = () => {}; try { return await fn(); } finally { console.log = l; } };
14
16
  const until = async (cond, ms = 15000) => {
15
17
  const end = Date.now() + ms;
@@ -201,9 +203,9 @@ await orchestrateFromCli(["1", "2", "3", "--parallel", "2", "--repo", "o/r", "--
201
203
  assert.doesNotThrow(() => assertDiskPath("/home/u/.forge/batches", mounts));
202
204
  assert.equal(isLaneAlive(process.pid, "--definitely-not-in-my-cmdline"), false);
203
205
  assert.equal(isLaneAlive(2 ** 22 + 12345, null), false);
204
- assert.deepEqual(readOrchestration(root), { breaker: { errors: 3, window_min: 30 }, maxDeferrals: 3 });
205
- writeFileSync(join(root, "forge.yaml"), "orchestration:\n breaker:\n errors: 5\n window_min: 10\n max_deferrals: 2\n");
206
- assert.deepEqual(readOrchestration(root), { breaker: { errors: 5, window_min: 10 }, maxDeferrals: 2 });
206
+ assert.deepEqual(readOrchestration(root), { breaker: { errors: 3, window_min: 30 }, maxDeferrals: 3, globalParallel: null });
207
+ writeFileSync(join(root, "forge.yaml"), "orchestration:\n breaker:\n errors: 5\n window_min: 10\n max_deferrals: 2\n global_parallel: 4\n");
208
+ assert.deepEqual(readOrchestration(root), { breaker: { errors: 5, window_min: 10 }, maxDeferrals: 2, globalParallel: 4 });
207
209
  });
208
210
 
209
211
  it("run-issue --result-file writes the lane result, also when the run throws", async () => {
@@ -0,0 +1,199 @@
1
+ #!/usr/bin/env node
2
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
3
+ // SPDX-License-Identifier: AGPL-3.0-or-later
4
+ /**
5
+ * Change-scoped lane checks (#4218): discover the repo's typecheck/lint/test commands, narrow each to what the lane's
6
+ * change can affect with the ci-local scoper (widening to the full command when impact is unknown), and share the
7
+ * tsc/eslint incremental caches across worktrees keyed by base commit: a shared entry is read-only and is copied into
8
+ * the worktree (hydrate); a lane publishes its caches only when no entry exists yet (copy on write, first writer wins).
9
+ *
10
+ * node bin/lean/checks.mjs [--repo DIR] --base REF [--json] print the scoped checks (hydrates the cache first)
11
+ * node bin/lean/checks.mjs [--repo DIR] --base REF --publish publish this worktree's caches for the base commit
12
+ */
13
+ import fs from "node:fs";
14
+ import path from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+ import { git, changedFiles, sha256, homeForgeDir, shellSplit, shellQuote, globToRegex } from "../../scripts/ci-local/lib.mjs";
17
+ import { makeScopeCtx, scopeCommand } from "../../scripts/ci-local/scoping.mjs";
18
+
19
+ const px = path.posix;
20
+ /** Lane-local cache dir, at the same relative path in every worktree so tsbuildinfo's relative paths match. */
21
+ export const LOCAL_CACHE = "node_modules/.cache/forge-verify";
22
+ const SCRIPT_KINDS = { typecheck: "typecheck", "type-check": "typecheck", tsc: "typecheck", lint: "lint", test: "test" };
23
+ /** Runs a resolved script body's binary from node_modules/.bin, as `<pm> run` would. */
24
+ const EXEC = { pnpm: "pnpm exec", yarn: "yarn", npm: "npx" };
25
+ /** Commands a script body runs from PATH, never from node_modules/.bin. */
26
+ const SYSTEM_BINS = new Set(["pnpm", "yarn", "npx", "npm", "bun", "bunx", "node", "bash", "sh", "env", "make", "python", "python3", "uv", "poetry"]);
27
+ const exists = (repo, f) => fs.existsSync(path.join(repo, f));
28
+ const readJson = (file) => { try { return JSON.parse(fs.readFileSync(file, "utf8")); } catch { return null; } };
29
+ const readText = (file) => { try { return fs.readFileSync(file, "utf8"); } catch { return ""; } };
30
+
31
+ function packageManager(repo) {
32
+ if (exists(repo, "pnpm-lock.yaml")) return "pnpm";
33
+ if (exists(repo, "yarn.lock")) return "yarn";
34
+ return "npm";
35
+ }
36
+
37
+ function turboTasks(repo) {
38
+ const t = readJson(path.join(repo, "turbo.json"));
39
+ return new Set(Object.keys((t && (t.tasks || t.pipeline)) || {}));
40
+ }
41
+
42
+ /** The root package plus its declared workspace packages (package.json `workspaces`, pnpm-workspace.yaml `packages`). */
43
+ function workspaceDirs(repo, tracked) {
44
+ const root = readJson(path.join(repo, "package.json"));
45
+ if (!root) return [];
46
+ const ws = root.workspaces;
47
+ const globs = Array.isArray(ws) ? ws : Array.isArray(ws?.packages) ? ws.packages : [];
48
+ const pnpm = readText(path.join(repo, "pnpm-workspace.yaml"));
49
+ for (const m of pnpm.matchAll(/^\s*-\s*['"]?([^'"\s#]+)['"]?/gm)) globs.push(m[1]);
50
+ const res = globs.filter((g) => typeof g === "string" && !g.startsWith("!")).map((g) => globToRegex(g.replace(/^\.\//, "").replace(/\/$/, "")));
51
+ const dirs = tracked.filter((f) => px.basename(f) === "package.json" && f !== "package.json" && !f.split("/").includes("node_modules") && exists(repo, f))
52
+ .map((f) => px.dirname(f)).filter((d) => res.some((re) => re.test(d)));
53
+ return [".", ...dirs];
54
+ }
55
+
56
+ /**
57
+ * The repo's typecheck/lint/test commands: [{kind, command, cwd}] (cwd repo-relative). JS: a turbo task when turbo.json
58
+ * defines it, else the workspace packages' scripts when any defines it, else the root script. Python: pytest and ruff
59
+ * when configured, run through `uv run` / `poetry run` when that lockfile exists.
60
+ */
61
+ export function discoverChecks(repo) {
62
+ const out = [];
63
+ const tracked = git(repo, ["ls-files", "--cached", "--others", "--exclude-standard"]).out.split("\n").filter(Boolean);
64
+ const pkgDirs = workspaceDirs(repo, tracked);
65
+ if (pkgDirs.length) {
66
+ const pm = packageManager(repo);
67
+ const turbo = turboTasks(repo);
68
+ const scripts = new Map(pkgDirs.map((d) => [d, (readJson(path.join(repo, d, "package.json")) || {}).scripts || {}]));
69
+ for (const [script, kind] of Object.entries(SCRIPT_KINDS)) {
70
+ if (turbo.has(script)) { out.push({ kind, command: `${exists(repo, "node_modules/.bin/turbo") ? "" : "npx "}turbo run ${script}`, cwd: "." }); continue; }
71
+ const ws = pkgDirs.filter((d) => d !== "." && typeof scripts.get(d)[script] === "string").sort();
72
+ const dirs = ws.length ? ws : typeof (scripts.get(".") || {})[script] === "string" ? ["."] : [];
73
+ for (const d of dirs) out.push({ kind, command: `${pm} run ${script}`, cwd: d, exec: EXEC[pm] });
74
+ }
75
+ }
76
+ const pyproject = readText(path.join(repo, "pyproject.toml"));
77
+ const run = exists(repo, "uv.lock") ? "uv run " : exists(repo, "poetry.lock") ? "poetry run " : "";
78
+ if (/^\[tool\.ruff/m.test(pyproject) || exists(repo, "ruff.toml") || exists(repo, ".ruff.toml")) out.push({ kind: "lint", command: `${run}ruff check .`, cwd: "." });
79
+ if (/^\[tool\.pytest/m.test(pyproject) || exists(repo, "pytest.ini") || tracked.some((f) => px.basename(f) === "conftest.py")) out.push({ kind: "test", command: `${run}pytest`, cwd: "." });
80
+ return out;
81
+ }
82
+
83
+ /** The shared cache entry for this repo (all its worktrees share the git common dir) at the base commit. */
84
+ export function cacheEntry(repo, baseSha) {
85
+ const gc = git(repo, ["rev-parse", "--git-common-dir"]).out.trim();
86
+ const common = fs.realpathSync(path.resolve(repo, gc || ".git"));
87
+ return path.join(homeForgeDir(), "verify-cache", sha256(common).slice(0, 16), baseSha);
88
+ }
89
+
90
+ /** Copy the shared entry's files into the worktree's local cache (files the lane already has are kept). */
91
+ export function hydrate(repo, entry) {
92
+ if (!exists(repo, "node_modules") || !fs.existsSync(entry)) return { hydrated: [] };
93
+ const local = path.join(repo, LOCAL_CACHE);
94
+ fs.mkdirSync(local, { recursive: true });
95
+ const hydrated = [];
96
+ for (const f of fs.readdirSync(entry)) {
97
+ const to = path.join(local, f);
98
+ if (fs.existsSync(to)) continue;
99
+ fs.copyFileSync(path.join(entry, f), to);
100
+ hydrated.push(f);
101
+ }
102
+ return { hydrated };
103
+ }
104
+
105
+ /** Publish the worktree's caches as the shared entry, only when none exists yet: tmp dir + rename, never overwrite. */
106
+ export function publish(repo, entry) {
107
+ const local = path.join(repo, LOCAL_CACHE);
108
+ const files = fs.existsSync(local) ? fs.readdirSync(local).filter((f) => fs.statSync(path.join(local, f)).isFile()) : [];
109
+ if (!files.length) return { published: false, reason: "no local cache" };
110
+ if (fs.existsSync(entry)) return { published: false, reason: "entry exists" };
111
+ fs.mkdirSync(path.dirname(entry), { recursive: true });
112
+ const tmp = `${entry}.tmp-${process.pid}-${Date.now()}`;
113
+ fs.mkdirSync(tmp);
114
+ try {
115
+ for (const f of files) fs.copyFileSync(path.join(local, f), path.join(tmp, f));
116
+ fs.renameSync(tmp, entry);
117
+ return { published: true, files };
118
+ } catch (e) {
119
+ fs.rmSync(tmp, { recursive: true, force: true });
120
+ if (["EEXIST", "ENOTEMPTY", "EPERM"].includes(e.code)) return { published: false, reason: "entry exists" };
121
+ throw e;
122
+ }
123
+ }
124
+
125
+ /** Add the shared-cache flags to a tsc (-p or plain; not -b, which keeps per-project buildinfo) or eslint command. */
126
+ export function withCacheFlags(check, repo) {
127
+ if (!check.command || !exists(repo, "node_modules")) return check;
128
+ const toks = shellSplit(check.command);
129
+ if (!toks) return check;
130
+ const local = px.relative(check.cwd || ".", LOCAL_CACHE);
131
+ const i = toks.findIndex((t) => ["tsc", "eslint"].includes(px.basename(t)));
132
+ if (i < 0) return check;
133
+ const rest = toks.slice(i + 1);
134
+ let add = [];
135
+ if (px.basename(toks[i]) === "tsc") {
136
+ if (rest.some((t) => ["-b", "--build", "--tsBuildInfoFile", "--incremental", "-w", "--watch"].includes(t) || t.startsWith("--incremental="))) return check;
137
+ const p = rest.indexOf("-p") >= 0 ? rest[rest.indexOf("-p") + 1] : rest.indexOf("--project") >= 0 ? rest[rest.indexOf("--project") + 1] : ".";
138
+ const project = px.normalize(px.join(check.cwd || ".", p || "."));
139
+ add = ["--incremental", "--tsBuildInfoFile", `${local}/tsc-${sha256(project).slice(0, 12)}.tsbuildinfo`];
140
+ } else {
141
+ if (rest.some((t) => t === "--cache-location" || t.startsWith("--cache-location="))) return check;
142
+ add = [...(rest.includes("--cache") ? [] : ["--cache"]), ...(rest.includes("--cache-strategy") ? [] : ["--cache-strategy", "content"]), "--cache-location", `${local}/eslintcache`];
143
+ }
144
+ return { ...check, command: [...toks.slice(0, i + 1), ...add, ...rest].map(shellQuote).join(" "), cache: true };
145
+ }
146
+
147
+ /** Changed files vs the merge base with `base` (the engine's .forge-lean/ hand-off files excluded). */
148
+ export function laneChanges(repo, base) {
149
+ const { files, from } = changedFiles(repo, base);
150
+ return { files: files.filter((f) => !f.startsWith(".forge-lean/")), from };
151
+ }
152
+
153
+ /** The scoped checks for this lane: [{kind, command, cwd, mode, widen_reason, selector, reason}]. */
154
+ export function scopedChecks({ repo, base, checks = discoverChecks(repo) }) {
155
+ const { files, from } = laneChanges(repo, base);
156
+ const sctx = makeScopeCtx({ repo, baseRef: base, from, changed: files });
157
+ return checks.map((c) => {
158
+ const r = scopeCommand(c.command, c.cwd === "." ? "" : c.cwd, sctx);
159
+ // A resolved script body (`pnpm run typecheck` -> `tsc -p apps/web`) runs its binary through the package manager.
160
+ const resolved = r.action !== "skip" && c.exec && r.command !== c.command && !SYSTEM_BINS.has(px.basename((shellSplit(r.command) || [""])[0]));
161
+ const out = { kind: c.kind, cwd: c.cwd, command: r.action === "skip" ? "" : resolved ? `${c.exec} ${r.command}` : r.command, mode: r.mode, widen_reason: r.widen_reason || "", selector: r.selector || "" };
162
+ if (r.reason) out.reason = r.reason;
163
+ return r.action === "skip" ? out : withCacheFlags(out, repo);
164
+ });
165
+ }
166
+
167
+ function parseArgs(argv) {
168
+ const o = { repo: process.cwd() };
169
+ for (let i = 0; i < argv.length; i++) {
170
+ const t = argv[i];
171
+ if (t === "--repo") o.repo = argv[++i]; else if (t === "--base") o.base = argv[++i];
172
+ else if (t === "--json") o.json = true; else if (t === "--publish") o.publish = true;
173
+ else throw new Error(`unknown option ${t}`);
174
+ }
175
+ return o;
176
+ }
177
+
178
+ function main(argv) {
179
+ let o;
180
+ try { o = parseArgs(argv); } catch (e) { console.error(`checks: ${e.message}`); return 2; }
181
+ if (!o.base) { console.error("usage: checks.mjs [--repo DIR] --base REF [--json] [--publish]"); return 2; }
182
+ const repo = path.resolve(o.repo);
183
+ const mb = git(repo, ["merge-base", o.base, "HEAD"]);
184
+ if (mb.code !== 0) { console.error(`checks: cannot resolve base ${o.base}`); return 2; }
185
+ const entry = cacheEntry(repo, mb.out.trim());
186
+ if (o.publish) { console.log(JSON.stringify(publish(repo, entry))); return 0; }
187
+ hydrate(repo, entry);
188
+ const checks = scopedChecks({ repo, base: o.base });
189
+ if (o.json) { console.log(JSON.stringify(checks, null, 2)); return 0; }
190
+ if (!checks.length) console.log("# no typecheck/lint/test command found");
191
+ for (const c of checks) {
192
+ const where = c.cwd && c.cwd !== "." ? `(cd ${shellQuote(c.cwd)} && ${c.command})` : c.command;
193
+ if (c.mode === "skip") console.log(`${c.kind} skip # skipped: ${c.reason || "not affected"}`);
194
+ else console.log(`${c.kind} ${c.mode} ${where}${c.widen_reason ? ` # widened: ${c.widen_reason}` : ""}`);
195
+ }
196
+ return 0;
197
+ }
198
+
199
+ if (process.argv[1] && fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) process.exitCode = main(process.argv.slice(2));
@@ -0,0 +1,183 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ /** #4218: change-scoped typecheck/lint/test commands and the shared tsc/eslint cache (fixture repos, no tool runs). */
4
+ import { test } from "node:test";
5
+ import assert from "node:assert/strict";
6
+ import fs from "node:fs";
7
+ import path from "node:path";
8
+ import { execFileSync } from "node:child_process";
9
+ import { tmpDir } from "../tests/_tmp.mjs";
10
+ import { discoverChecks, scopedChecks, cacheEntry, hydrate, publish, withCacheFlags, LOCAL_CACHE } from "./checks.mjs";
11
+
12
+ const TMP = tmpDir("checks-4218-");
13
+ process.env.FORGE_CI_LOCAL_HOME = path.join(TMP, "forge-home");
14
+ const GIT_ENV = { ...process.env, GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@t", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@t", GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_NOSYSTEM: "1" };
15
+ const git = (repo, ...a) => execFileSync("git", ["-C", repo, ...a], { env: GIT_ENV, encoding: "utf8" });
16
+ let n = 0;
17
+
18
+ /** A throwaway repo: `files` committed on main, then branch feat checked out; node_modules/ is ignored. */
19
+ function repo(files) {
20
+ const r = path.join(TMP, `repo${n++}`);
21
+ write(r, { ".gitignore": "node_modules/\n", ...files });
22
+ git(r, "init", "-q", "-b", "main");
23
+ git(r, "add", "-A");
24
+ git(r, "commit", "-qm", "base");
25
+ git(r, "checkout", "-qb", "feat");
26
+ fs.mkdirSync(path.join(r, "node_modules"), { recursive: true });
27
+ return r;
28
+ }
29
+ function write(r, files) {
30
+ for (const [f, body] of Object.entries(files)) {
31
+ fs.mkdirSync(path.dirname(path.join(r, f)), { recursive: true });
32
+ fs.writeFileSync(path.join(r, f), body);
33
+ }
34
+ }
35
+ const touch = (r, ...fs_) => { for (const f of fs_) fs.appendFileSync(path.join(r, f), "\n// change\n"); };
36
+ const byKind = (checks) => Object.fromEntries(checks.map((c) => [c.kind, c]));
37
+
38
+ const pnpmMonorepo = () => repo({
39
+ "package.json": JSON.stringify({ name: "root", private: true, scripts: { typecheck: "tsc --noEmit", lint: "eslint .", test: "vitest run" } }),
40
+ "pnpm-lock.yaml": "lockfileVersion: '9.0'\n",
41
+ "tsconfig.json": JSON.stringify({ files: [], references: [{ path: "apps/web" }, { path: "packages/ui" }] }),
42
+ "apps/web/package.json": JSON.stringify({ name: "web" }),
43
+ "apps/web/tsconfig.json": "{\n // web app\n \"compilerOptions\": { \"jsx\": \"preserve\", },\n \"references\": [{ \"path\": \"../../packages/ui\" }]\n}\n",
44
+ "apps/web/src/page.tsx": "import { Button } from \"../../../packages/ui/src/button\";\nexport const Page = () => Button;\n",
45
+ "apps/web/src/page.test.tsx": "import { Page } from \"./page\";\ntest(\"page\", () => Page);\n",
46
+ "packages/ui/package.json": JSON.stringify({ name: "ui" }),
47
+ "packages/ui/tsconfig.json": JSON.stringify({ compilerOptions: { composite: true } }),
48
+ "packages/ui/src/button.tsx": "export const Button = 1;\n",
49
+ "packages/ui/src/button.test.tsx": "import { Button } from \"./button\";\ntest(\"button\", () => Button);\n",
50
+ ".github/workflows/ci.yml": "on: pull_request\n",
51
+ });
52
+
53
+ test("JS/TS pnpm monorepo: one web file scopes typecheck to its project, lint to the file, tests to related", () => {
54
+ const r = pnpmMonorepo();
55
+ assert.deepEqual(discoverChecks(r).map((c) => `${c.kind}:${c.command}:${c.cwd}`), ["typecheck:pnpm run typecheck:.", "lint:pnpm run lint:.", "test:pnpm run test:."]);
56
+ touch(r, "apps/web/src/page.tsx");
57
+ const c = byKind(scopedChecks({ repo: r, base: "main" }));
58
+ assert.equal(c.typecheck.mode, "scoped");
59
+ assert.equal(c.typecheck.selector, "tsc-project");
60
+ assert.match(c.typecheck.command, /^pnpm exec tsc --incremental --tsBuildInfoFile node_modules\/\.cache\/forge-verify\/tsc-[0-9a-f]{12}\.tsbuildinfo -p apps\/web --noEmit$/);
61
+ assert.equal(c.lint.mode, "scoped");
62
+ assert.equal(c.lint.command, "pnpm exec eslint --cache --cache-strategy content --cache-location node_modules/.cache/forge-verify/eslintcache apps/web/src/page.tsx");
63
+ assert.equal(c.test.mode, "scoped");
64
+ assert.match(c.test.command, /vitest related --run --passWithNoTests apps\/web\/src\/page\.tsx$/);
65
+ });
66
+
67
+ test("JS/TS pnpm monorepo: a shared package change builds every reached composite/referenced project with tsc -b", () => {
68
+ const r = pnpmMonorepo();
69
+ touch(r, "packages/ui/src/button.tsx");
70
+ const c = byKind(scopedChecks({ repo: r, base: "main" }));
71
+ assert.equal(c.typecheck.mode, "scoped");
72
+ assert.equal(c.typecheck.command, "pnpm exec tsc -b apps/web packages/ui");
73
+ });
74
+
75
+ test("npm single-package TS repo: the root project runs the full typecheck", () => {
76
+ const r = repo({
77
+ "package.json": JSON.stringify({ name: "one", scripts: { typecheck: "tsc --noEmit" } }),
78
+ "package-lock.json": "{}\n",
79
+ "tsconfig.json": "{}\n",
80
+ "src/a.ts": "export const a = 1;\n",
81
+ "src/a.test.ts": "import { a } from './a';\n",
82
+ });
83
+ touch(r, "src/a.ts");
84
+ const [c] = scopedChecks({ repo: r, base: "main" });
85
+ assert.equal(c.command.startsWith("npx tsc --incremental"), true);
86
+ assert.equal(c.mode, "full");
87
+ assert.equal(c.widen_reason, "root-project");
88
+ });
89
+
90
+ // Regression (#4218 AC2): unknown impact never narrows.
91
+ for (const [file, reason] of [["pnpm-lock.yaml", "lockfile"], ["apps/web/tsconfig.json", "build-config"], [".github/workflows/ci.yml", "ci-config"]]) {
92
+ test(`unknown impact (${file}) runs every check in full`, () => {
93
+ const r = pnpmMonorepo();
94
+ fs.appendFileSync(path.join(r, file), "\n");
95
+ touch(r, "apps/web/src/page.tsx");
96
+ const checks = scopedChecks({ repo: r, base: "main" });
97
+ assert.equal(checks.length, 3);
98
+ for (const c of checks) {
99
+ assert.equal(c.mode, "full", `${c.kind} ${c.command}`);
100
+ assert.match(c.widen_reason, new RegExp(`^${reason}: ${file.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`));
101
+ }
102
+ });
103
+ }
104
+
105
+ const pyRepo = (lock, lockBody) => repo({
106
+ "pyproject.toml": "[project]\nname = \"pkg\"\n\n[tool.pytest.ini_options]\ntestpaths = [\"tests\"]\n\n[tool.ruff]\nline-length = 100\n",
107
+ [lock]: lockBody,
108
+ "pkg/__init__.py": "",
109
+ "pkg/alpha.py": "def f():\n return 1\n",
110
+ "pkg/beta.py": "def g():\n return 2\n",
111
+ "tests/test_alpha.py": "from pkg.alpha import f\n\ndef test_f():\n assert f() == 1\n",
112
+ "tests/test_beta.py": "from pkg.beta import g\n\ndef test_g():\n assert g() == 2\n",
113
+ });
114
+
115
+ test("Python uv repo: one module scopes pytest to its tests and ruff to the file; uv.lock widens", () => {
116
+ const r = pyRepo("uv.lock", "version = 1\n");
117
+ assert.deepEqual(discoverChecks(r).map((c) => c.command), ["uv run ruff check .", "uv run pytest"]);
118
+ fs.appendFileSync(path.join(r, "pkg/alpha.py"), "# change\n");
119
+ const c = byKind(scopedChecks({ repo: r, base: "main" }));
120
+ assert.equal(c.test.mode, "scoped");
121
+ assert.equal(c.test.command, "uv run pytest tests/test_alpha.py");
122
+ assert.equal(c.lint.command, "uv run ruff check pkg/alpha.py");
123
+ fs.appendFileSync(path.join(r, "uv.lock"), "# change\n");
124
+ for (const x of scopedChecks({ repo: r, base: "main" })) { assert.equal(x.mode, "full"); assert.equal(x.widen_reason, "lockfile: uv.lock"); }
125
+ });
126
+
127
+ test("Python poetry repo: poetry run prefix; poetry.lock widens", () => {
128
+ const r = pyRepo("poetry.lock", "# lock\n");
129
+ fs.appendFileSync(path.join(r, "pkg/beta.py"), "# change\n");
130
+ const c = byKind(scopedChecks({ repo: r, base: "main" }));
131
+ assert.equal(c.test.command, "poetry run pytest tests/test_beta.py");
132
+ fs.appendFileSync(path.join(r, "poetry.lock"), "# change\n");
133
+ const t = byKind(scopedChecks({ repo: r, base: "main" })).test;
134
+ assert.equal(t.mode, "full");
135
+ assert.equal(t.command, "poetry run pytest");
136
+ assert.equal(t.widen_reason, "lockfile: poetry.lock");
137
+ });
138
+
139
+ test("shared cache: keyed by base commit, hydrated by copy, published once and never modified", () => {
140
+ const a = pnpmMonorepo();
141
+ const base = git(a, "rev-parse", "main").trim();
142
+ const entry = cacheEntry(a, base);
143
+ assert.ok(entry.startsWith(path.join(process.env.FORGE_CI_LOCAL_HOME, "verify-cache")));
144
+ assert.ok(entry.endsWith(base));
145
+ assert.deepEqual(publish(a, entry), { published: false, reason: "no local cache" });
146
+ write(a, { [`${LOCAL_CACHE}/eslintcache`]: "v1", [`${LOCAL_CACHE}/tsc-x.tsbuildinfo`]: "b1" });
147
+ assert.equal(publish(a, entry).published, true);
148
+ assert.equal(fs.readFileSync(path.join(entry, "eslintcache"), "utf8"), "v1");
149
+ // A second lane's different caches never overwrite the entry (first writer wins).
150
+ write(a, { [`${LOCAL_CACHE}/eslintcache`]: "v2" });
151
+ assert.deepEqual(publish(a, entry), { published: false, reason: "entry exists" });
152
+ assert.equal(fs.readFileSync(path.join(entry, "eslintcache"), "utf8"), "v1");
153
+ // Another worktree of the same repo shares the entry; hydrate copies, and a later local write leaves the entry alone.
154
+ const wt = path.join(TMP, `wt${n++}`);
155
+ git(a, "worktree", "add", "-q", wt, "main");
156
+ assert.equal(cacheEntry(wt, base), entry);
157
+ fs.mkdirSync(path.join(wt, "node_modules"));
158
+ assert.deepEqual(hydrate(wt, entry).hydrated.sort(), ["eslintcache", "tsc-x.tsbuildinfo"]);
159
+ fs.writeFileSync(path.join(wt, LOCAL_CACHE, "eslintcache"), "lane-write");
160
+ assert.equal(fs.readFileSync(path.join(entry, "eslintcache"), "utf8"), "v1");
161
+ assert.deepEqual(hydrate(wt, entry).hydrated, [], "files the lane already has are kept");
162
+ assert.equal(fs.readFileSync(path.join(wt, LOCAL_CACHE, "eslintcache"), "utf8"), "lane-write");
163
+ });
164
+
165
+ test("withCacheFlags: tsc -p and eslint get cache flags relative to cwd; tsc -b and no node_modules are untouched", () => {
166
+ const r = pnpmMonorepo();
167
+ assert.match(withCacheFlags({ command: "tsc --noEmit", cwd: "apps/web" }, r).command, /^tsc --incremental --tsBuildInfoFile \.\.\/\.\.\/node_modules\/\.cache\/forge-verify\/tsc-[0-9a-f]{12}\.tsbuildinfo --noEmit$/);
168
+ assert.equal(withCacheFlags({ command: "tsc -b apps/web", cwd: "." }, r).command, "tsc -b apps/web");
169
+ assert.equal(withCacheFlags({ command: "eslint --cache-location x a.ts", cwd: "." }, r).command, "eslint --cache-location x a.ts");
170
+ fs.rmSync(path.join(r, "node_modules"), { recursive: true });
171
+ assert.equal(withCacheFlags({ command: "eslint a.ts", cwd: "." }, r).command, "eslint a.ts");
172
+ });
173
+
174
+ test("discovery: declared workspace packages' scripts win over the root; undeclared package.json files are ignored", () => {
175
+ const r = repo({
176
+ "package.json": JSON.stringify({ name: "root", private: true, scripts: { test: "node --test" } }),
177
+ "pnpm-lock.yaml": "lockfileVersion: '9.0'\n",
178
+ "pnpm-workspace.yaml": "packages:\n - 'apps/*'\n",
179
+ "apps/web/package.json": JSON.stringify({ name: "web", scripts: { typecheck: "tsc --noEmit" } }),
180
+ "scripts/fixtures/demo/package.json": JSON.stringify({ name: "demo", scripts: { test: "jest", lint: "eslint ." } }),
181
+ });
182
+ assert.deepEqual(discoverChecks(r).map((c) => `${c.kind}:${c.command}:${c.cwd}`), ["typecheck:pnpm run typecheck:apps/web", "test:pnpm run test:."]);
183
+ });