forgedock 1.26.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 (137) hide show
  1. package/README.md +4 -1
  2. package/bin/bootstrap.mjs +55 -0
  3. package/bin/engine/outcome-codes.mjs +13 -0
  4. package/bin/engine/phases.mjs +38 -2
  5. package/bin/engine/telemetry-seam.mjs +41 -6
  6. package/bin/engine/telemetry-seam.test.mjs +35 -0
  7. package/bin/engine-cli.mjs +616 -54
  8. package/bin/engine.mjs +13 -5
  9. package/bin/forgedock.mjs +169 -39
  10. package/bin/gc.mjs +580 -0
  11. package/bin/init-detect.mjs +68 -3
  12. package/bin/init-enrich.mjs +3 -0
  13. package/bin/init-repo.mjs +213 -0
  14. package/bin/journey.mjs +278 -43
  15. package/bin/lean/admission.mjs +33 -3
  16. package/bin/lean/batch.mjs +24 -5
  17. package/bin/lean/batch.test.mjs +54 -4
  18. package/bin/lean/checks.mjs +199 -0
  19. package/bin/lean/checks.test.mjs +183 -0
  20. package/bin/lean/conformance.mjs +235 -37
  21. package/bin/lean/conformance.test.mjs +143 -5
  22. package/bin/lean/credentials.mjs +29 -0
  23. package/bin/lean/depcache.mjs +312 -0
  24. package/bin/lean/depcache.test.mjs +298 -0
  25. package/bin/lean/drift.mjs +198 -0
  26. package/bin/lean/drift.test.mjs +223 -0
  27. package/bin/lean/effects.mjs +178 -16
  28. package/bin/lean/execution.e2e.test.mjs +81 -0
  29. package/bin/lean/execution.mjs +431 -0
  30. package/bin/lean/execution.test.mjs +583 -0
  31. package/bin/lean/heavy.mjs +289 -0
  32. package/bin/lean/heavy.test.mjs +207 -0
  33. package/bin/lean/lean.mjs +877 -151
  34. package/bin/lean/lean.test.mjs +1798 -28
  35. package/bin/lean/ledger.mjs +92 -0
  36. package/bin/lean/ledger.test.mjs +100 -0
  37. package/bin/lean/limits.mjs +58 -0
  38. package/bin/lean/limits.test.mjs +150 -0
  39. package/bin/lean/orchestrate.mjs +321 -19
  40. package/bin/lean/prompts.mjs +125 -17
  41. package/bin/lean/refactor.mjs +276 -0
  42. package/bin/lean/refactor.test.mjs +359 -0
  43. package/bin/lean/render.mjs +25 -6
  44. package/bin/lean/review-trigger.mjs +134 -0
  45. package/bin/lean/review-trigger.test.mjs +104 -0
  46. package/bin/lean/sandbox-provider.contract.test.mjs +257 -0
  47. package/bin/lean/sandbox-remote.mjs +142 -0
  48. package/bin/lean/sched.mjs +221 -0
  49. package/bin/lean/sched.test.mjs +310 -0
  50. package/bin/lean/schedd.mjs +247 -0
  51. package/bin/lean/schedd.test.mjs +180 -0
  52. package/bin/lean/toolchain.mjs +228 -0
  53. package/bin/lean/toolchain.test.mjs +154 -0
  54. package/bin/lean/verify.mjs +124 -0
  55. package/bin/lean/verify.test.mjs +118 -0
  56. package/bin/lean/worktree.mjs +69 -0
  57. package/bin/lean/worktree.test.mjs +141 -0
  58. package/bin/runner.mjs +53 -10
  59. package/bin/stats/github-source.mjs +1 -1
  60. package/bin/stats/index.mjs +14 -2
  61. package/bin/stats/query.mjs +72 -0
  62. package/bin/stats/stats.test.mjs +54 -0
  63. package/commands/adopt.md +3 -0
  64. package/commands/audit.md +5 -8
  65. package/commands/autopilot.md +2 -0
  66. package/commands/bootstrap.md +35 -0
  67. package/commands/cleanup.md +7 -22
  68. package/commands/forge-stats.md +2 -1
  69. package/commands/forgedock-init.md +125 -6
  70. package/commands/issue.md +89 -830
  71. package/commands/milestone.md +55 -6
  72. package/commands/orchestrate.md +10 -2
  73. package/commands/review-pr.md +2 -2
  74. package/commands/upgrade-deps.md +3 -3
  75. package/commands/work-on/build/validate.md +4 -0
  76. package/commands/work-on/close.md +2 -0
  77. package/commands/work-on/decompose.md +24 -6
  78. package/docs/CI.md +3 -3
  79. package/docs/CONFIG.md +227 -3
  80. package/docs/OUTCOME-CODES.md +11 -0
  81. package/docs/TELEMETRY.md +1 -1
  82. package/docs/site/command-reference.md +22 -0
  83. package/docs/site/getting-started.md +2 -0
  84. package/docs/site/start-a-new-project.md +327 -0
  85. package/package.json +1 -1
  86. package/scripts/affected-files-check.sh +91 -0
  87. package/scripts/affected-files-check.test.sh +80 -0
  88. package/scripts/bootstrap-create.mjs +393 -0
  89. package/scripts/bootstrap-validate.mjs +321 -0
  90. package/scripts/calibration.mjs +116 -0
  91. package/scripts/check-devdocs-freshness.sh +125 -0
  92. package/scripts/check-devdocs-freshness.test.sh +130 -0
  93. package/scripts/check-spec-markers.sh +3 -0
  94. package/scripts/check-spec-size.sh +29 -11
  95. package/scripts/check-spec-size.test.sh +27 -4
  96. package/scripts/ci-local/lib.mjs +8 -1
  97. package/scripts/ci-local/parallelism.test.mjs +27 -0
  98. package/scripts/ci-local/run.mjs +16 -5
  99. package/scripts/ci-local/scoping.mjs +82 -0
  100. package/scripts/ci-local.test.sh +14 -2
  101. package/scripts/fixtures/symbol-index/other/sample.rb +3 -0
  102. package/scripts/fixtures/symbol-index/py/pkg/__init__.py +1 -0
  103. package/scripts/fixtures/symbol-index/py/pkg/a.py +5 -0
  104. package/scripts/fixtures/symbol-index/py/pkg/b.py +14 -0
  105. package/scripts/fixtures/symbol-index/py/pkg/c.py +1 -0
  106. package/scripts/fixtures/symbol-index/py/sample.py +41 -0
  107. package/scripts/fixtures/symbol-index/ts/sample.ts +13 -0
  108. package/scripts/fixtures/symbol-move/gen_big.py +201 -0
  109. package/scripts/fixtures/symbol-move-ts/gen_big.mjs +56 -0
  110. package/scripts/forge-root.test.sh +1 -1
  111. package/scripts/hotspots.sh +100 -0
  112. package/scripts/hotspots.test.sh +101 -0
  113. package/scripts/issue-create.sh +197 -0
  114. package/scripts/issue-create.test.sh +166 -0
  115. package/scripts/maintenance-scan.sh +185 -0
  116. package/scripts/maintenance-scan.test.sh +89 -0
  117. package/scripts/milestone-drift.sh +206 -0
  118. package/scripts/milestone-drift.test.sh +120 -0
  119. package/scripts/northstar-docs.test.sh +68 -0
  120. package/scripts/refactor-verify.sh +368 -0
  121. package/scripts/refactor-verify.test.sh +202 -0
  122. package/scripts/review-prepare.sh +11 -9
  123. package/scripts/review-prepare.test.sh +14 -0
  124. package/scripts/structure-delta.sh +155 -0
  125. package/scripts/structure-delta.test.sh +136 -0
  126. package/scripts/symbol-index.mjs +253 -0
  127. package/scripts/symbol-index.py +354 -0
  128. package/scripts/symbol-index.test.sh +119 -0
  129. package/scripts/symbol-move-ts.test.sh +161 -0
  130. package/scripts/symbol-move.mjs +379 -0
  131. package/scripts/symbol-move.py +383 -0
  132. package/scripts/symbol-move.test.sh +168 -0
  133. package/templates/bootstrap/interview.md +34 -0
  134. package/templates/bootstrap/issue-body.md +42 -0
  135. package/templates/bootstrap/needs-human-issue.md +45 -0
  136. package/templates/bootstrap/stack-profiles.md +34 -0
  137. package/templates/bootstrap/technical-spike.md +75 -0
@@ -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
+ });