forgedock 1.29.1 → 1.31.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 (166) hide show
  1. package/bin/engine/outcome-codes.mjs +5 -2
  2. package/bin/engine/phases.mjs +20 -3
  3. package/bin/engine/runlog.mjs +82 -12
  4. package/bin/engine/telemetry-seam.mjs +11 -4
  5. package/bin/engine/telemetry-seam.test.mjs +18 -0
  6. package/bin/engine-cli.mjs +453 -66
  7. package/bin/engine.mjs +50 -38
  8. package/bin/forgedock.mjs +35 -3
  9. package/bin/gc.mjs +50 -17
  10. package/bin/labels.json +5 -0
  11. package/bin/lean/apishape.mjs +170 -0
  12. package/bin/lean/apishape.test.mjs +187 -0
  13. package/bin/lean/batch.mjs +32 -0
  14. package/bin/lean/batch.test.mjs +356 -2
  15. package/bin/lean/budget.mjs +89 -0
  16. package/bin/lean/budget.test.mjs +106 -0
  17. package/bin/lean/checks.mjs +32 -9
  18. package/bin/lean/checks.test.mjs +78 -1
  19. package/bin/lean/conformance.e2e.test.mjs +27 -5
  20. package/bin/lean/conformance.mjs +278 -22
  21. package/bin/lean/conformance.test.mjs +251 -5
  22. package/bin/lean/context.mjs +133 -55
  23. package/bin/lean/context.test.mjs +175 -9
  24. package/bin/lean/decompose.mjs +90 -0
  25. package/bin/lean/decompose.test.mjs +105 -0
  26. package/bin/lean/depcache.mjs +11 -7
  27. package/bin/lean/depcache.test.mjs +81 -0
  28. package/bin/lean/e2e.mjs +102 -0
  29. package/bin/lean/e2e.test.mjs +86 -0
  30. package/bin/lean/effects.mjs +55 -19
  31. package/bin/lean/enumerating.mjs +189 -0
  32. package/bin/lean/enumerating.test.mjs +97 -0
  33. package/bin/lean/explore.mjs +47 -0
  34. package/bin/lean/explore.test.mjs +61 -0
  35. package/bin/lean/facts.mjs +84 -0
  36. package/bin/lean/facts.test.mjs +66 -0
  37. package/bin/lean/fixround.mjs +90 -0
  38. package/bin/lean/fixround.test.mjs +117 -0
  39. package/bin/lean/heavy.mjs +35 -2
  40. package/bin/lean/heavy.test.mjs +32 -2
  41. package/bin/lean/lean.mjs +799 -127
  42. package/bin/lean/lean.test.mjs +1749 -37
  43. package/bin/lean/ledger.mjs +5 -3
  44. package/bin/lean/ledger.test.mjs +18 -0
  45. package/bin/lean/light.mjs +135 -0
  46. package/bin/lean/light.test.mjs +173 -0
  47. package/bin/lean/limits.test.mjs +36 -1
  48. package/bin/lean/orchestrate.mjs +341 -99
  49. package/bin/lean/pitfalls.mjs +136 -0
  50. package/bin/lean/pitfalls.test.mjs +117 -0
  51. package/bin/lean/postmerge.mjs +85 -0
  52. package/bin/lean/postmerge.test.mjs +79 -0
  53. package/bin/lean/precheck.mjs +180 -0
  54. package/bin/lean/precheck.test.mjs +205 -0
  55. package/bin/lean/prefilter.mjs +180 -9
  56. package/bin/lean/prefilter.test.mjs +175 -3
  57. package/bin/lean/preflight.mjs +238 -0
  58. package/bin/lean/preflight.test.mjs +108 -0
  59. package/bin/lean/probe.mjs +297 -0
  60. package/bin/lean/probe.test.mjs +210 -0
  61. package/bin/lean/prompts.mjs +134 -12
  62. package/bin/lean/recovery.test.mjs +75 -0
  63. package/bin/lean/render.mjs +7 -3
  64. package/bin/lean/resume.mjs +67 -0
  65. package/bin/lean/resume.test.mjs +84 -0
  66. package/bin/lean/review-trigger.mjs +12 -0
  67. package/bin/lean/review-trigger.test.mjs +17 -1
  68. package/bin/lean/routing.mjs +74 -0
  69. package/bin/lean/routing.test.mjs +67 -0
  70. package/bin/lean/sched.test.mjs +127 -1
  71. package/bin/lean/schedd.mjs +55 -9
  72. package/bin/lean/schedd.test.mjs +149 -3
  73. package/bin/lean/selfreview.mjs +37 -0
  74. package/bin/lean/selfreview.test.mjs +51 -0
  75. package/bin/lean/seqlease.mjs +40 -0
  76. package/bin/lean/seqlease.test.mjs +101 -0
  77. package/bin/lean/sequence.mjs +385 -0
  78. package/bin/lean/sequence.test.mjs +373 -0
  79. package/bin/lean/testmap.mjs +43 -6
  80. package/bin/lean/testmap.test.mjs +38 -1
  81. package/bin/lean/toolchain.mjs +60 -15
  82. package/bin/lean/toolchain.test.mjs +120 -3
  83. package/bin/lean/toolstats.mjs +72 -0
  84. package/bin/lean/toolstats.test.mjs +47 -0
  85. package/bin/lean/verifier.mjs +232 -0
  86. package/bin/lean/verifier.test.mjs +163 -0
  87. package/bin/lean/verify.mjs +77 -10
  88. package/bin/lean/verify.test.mjs +74 -3
  89. package/bin/review-staging.mjs +533 -0
  90. package/bin/runner.mjs +82 -13
  91. package/bin/stats/aggregate.mjs +87 -2
  92. package/bin/stats/efficiency.mjs +319 -0
  93. package/bin/stats/efficiency.test.mjs +233 -0
  94. package/bin/stats/github-source.mjs +8 -3
  95. package/bin/stats/index.mjs +69 -6
  96. package/bin/stats/local-source.mjs +86 -3
  97. package/bin/stats/query.mjs +48 -0
  98. package/bin/stats/render.mjs +22 -0
  99. package/bin/stats/stats.test.mjs +224 -5
  100. package/commands/orchestrate.md +6 -2
  101. package/commands/review-pr-agents/protocols.md +1 -1
  102. package/commands/review-pr-staging.md +84 -831
  103. package/commands/review-pr.md +1 -1
  104. package/commands/work-on/build/implement.md +1 -0
  105. package/commands/work-on/build/validate.md +11 -1
  106. package/docs/CONFIG.md +88 -6
  107. package/docs/OUTCOME-CODES.md +7 -4
  108. package/docs/RELEASE-NOTES.md +10 -0
  109. package/docs/STATS.md +53 -1
  110. package/docs/TELEMETRY.md +1 -1
  111. package/docs/spec/review-protocol.md +1 -1
  112. package/package.json +1 -1
  113. package/scripts/acceptance-red-green.sh +13 -4
  114. package/scripts/acceptance-red-green.test.sh +14 -0
  115. package/scripts/ci-local/lib.mjs +20 -0
  116. package/scripts/ci-local/plan.mjs +2 -12
  117. package/scripts/ci-local/scoping.mjs +10 -1
  118. package/scripts/ci-local.test.sh +2 -0
  119. package/scripts/fixtures/apishape/other/go-change/base/x.go +3 -0
  120. package/scripts/fixtures/apishape/other/go-change/head/x.go +3 -0
  121. package/scripts/fixtures/apishape/python/generator-function/base/seq.py +3 -0
  122. package/scripts/fixtures/apishape/python/generator-function/head/seq.py +2 -0
  123. package/scripts/fixtures/apishape/python/ichunked/base/more.py +17 -0
  124. package/scripts/fixtures/apishape/python/ichunked/head/more.py +20 -0
  125. package/scripts/fixtures/apishape/python/private-edit/base/calc.py +6 -0
  126. package/scripts/fixtures/apishape/python/private-edit/head/calc.py +9 -0
  127. package/scripts/fixtures/apishape/python/removed/base/ops.py +6 -0
  128. package/scripts/fixtures/apishape/python/removed/head/ops.py +2 -0
  129. package/scripts/fixtures/apishape/python/renamed/base/text.py +2 -0
  130. package/scripts/fixtures/apishape/python/renamed/head/text.py +2 -0
  131. package/scripts/fixtures/apishape/python/signature/base/shapes.py +2 -0
  132. package/scripts/fixtures/apishape/python/signature/head/shapes.py +2 -0
  133. package/scripts/fixtures/apishape/python/sync-async/base/client.py +7 -0
  134. package/scripts/fixtures/apishape/python/sync-async/head/client.py +7 -0
  135. package/scripts/fixtures/apishape/ts/generator-function/base/seq.js +3 -0
  136. package/scripts/fixtures/apishape/ts/generator-function/head/seq.js +5 -0
  137. package/scripts/fixtures/apishape/ts/private-edit/base/calc.ts +7 -0
  138. package/scripts/fixtures/apishape/ts/private-edit/head/calc.ts +9 -0
  139. package/scripts/fixtures/apishape/ts/removed/base/ops.ts +7 -0
  140. package/scripts/fixtures/apishape/ts/removed/head/ops.ts +3 -0
  141. package/scripts/fixtures/apishape/ts/renamed/base/text.ts +3 -0
  142. package/scripts/fixtures/apishape/ts/renamed/head/text.ts +3 -0
  143. package/scripts/fixtures/apishape/ts/signature/base/shapes.ts +3 -0
  144. package/scripts/fixtures/apishape/ts/signature/head/shapes.ts +3 -0
  145. package/scripts/fixtures/apishape/ts/sync-async/base/client.mjs +5 -0
  146. package/scripts/fixtures/apishape/ts/sync-async/head/client.mjs +5 -0
  147. package/scripts/forge-root.test.sh +2 -2
  148. package/scripts/publish-staging-sync.test.sh +62 -4
  149. package/scripts/review-finalize.sh +17 -6
  150. package/scripts/review-finalize.test.sh +47 -0
  151. package/scripts/review-pr-arguments.test.sh +13 -1
  152. package/scripts/review-pr-delta-wiring.test.sh +2 -2
  153. package/scripts/review-pr-gate2.test.sh +6 -14
  154. package/scripts/review-pr-staging-finding-filter.test.sh +4 -4
  155. package/scripts/review-pr-staging-scope.test.sh +39 -33
  156. package/scripts/review-prepare.sh +73 -6
  157. package/scripts/review-prepare.test.sh +85 -0
  158. package/scripts/review-staging-post.sh +293 -0
  159. package/scripts/review-staging-post.test.sh +143 -0
  160. package/scripts/review-staging-pre.sh +401 -0
  161. package/scripts/review-staging-pre.test.sh +150 -0
  162. package/scripts/symbol-index.mjs +26 -7
  163. package/scripts/symbol-index.py +55 -9
  164. package/scripts/test-map.sh +63 -17
  165. package/scripts/test-map.test.sh +55 -0
  166. package/scripts/trusted-comments.test.sh +1 -1
@@ -13,7 +13,7 @@
13
13
  import fs from "node:fs";
14
14
  import path from "node:path";
15
15
  import { fileURLToPath } from "node:url";
16
- import { git, changedFiles, sha256, homeForgeDir, shellSplit, shellQuote, globToRegex } from "../../scripts/ci-local/lib.mjs";
16
+ import { git, changedFiles, sha256, homeForgeDir, shellSplit, shellQuote, globToRegex, parseYaml, trustedForgeYaml } from "../../scripts/ci-local/lib.mjs";
17
17
  import { makeScopeCtx, scopeCommand } from "../../scripts/ci-local/scoping.mjs";
18
18
 
19
19
  const px = path.posix;
@@ -67,16 +67,16 @@ export function discoverChecks(repo) {
67
67
  const turbo = turboTasks(repo);
68
68
  const scripts = new Map(pkgDirs.map((d) => [d, (readJson(path.join(repo, d, "package.json")) || {}).scripts || {}]));
69
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; }
70
+ if (turbo.has(script)) { out.push({ kind, command: `${exists(repo, "node_modules/.bin/turbo") ? "" : "npx "}turbo run ${script}`, cwd: ".", eco: "js" }); continue; }
71
71
  const ws = pkgDirs.filter((d) => d !== "." && typeof scripts.get(d)[script] === "string").sort();
72
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] });
73
+ for (const d of dirs) out.push({ kind, command: `${pm} run ${script}`, cwd: d, exec: EXEC[pm], eco: "js" });
74
74
  }
75
75
  }
76
76
  const pyproject = readText(path.join(repo, "pyproject.toml"));
77
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: "." });
78
+ if (/^\[tool\.ruff/m.test(pyproject) || exists(repo, "ruff.toml") || exists(repo, ".ruff.toml")) out.push({ kind: "lint", command: `${run}ruff check .`, cwd: ".", eco: "py" });
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: ".", eco: "py" });
80
80
  return out;
81
81
  }
82
82
 
@@ -150,16 +150,39 @@ export function laneChanges(repo, base) {
150
150
  return { files: files.filter((f) => !f.startsWith(".forge-lean/")), from };
151
151
  }
152
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) }) {
153
+ /**
154
+ * The repo's configured `verification.commands.typescript.lint` (#4381), read only from the trusted forge.yaml copy
155
+ * (forge#4040: never the PR's own tree). null when absent, empty or unreadable (no yq, parse error).
156
+ */
157
+ export function configuredLint(repo, base) {
158
+ try {
159
+ const mb = git(repo, ["merge-base", base, "HEAD"]).out.trim();
160
+ const { text } = trustedForgeYaml(repo, base, mb);
161
+ if (!text) return null;
162
+ const v = parseYaml(text)?.verification?.commands?.typescript?.lint;
163
+ return typeof v === "string" && v.trim() ? v.trim() : null;
164
+ } catch { return null; }
165
+ }
166
+
167
+ /**
168
+ * The scoped checks for this lane: [{kind, command, cwd, mode, widen_reason, selector, reason, configured}]. A configured
169
+ * typescript.lint replaces the discovered JS lint checks (#4381); Python checks are never replaced.
170
+ */
171
+ export function scopedChecks({ repo, base, checks = discoverChecks(repo), lint = configuredLint(repo, base) }) {
155
172
  const { files, from } = laneChanges(repo, base);
156
173
  const sctx = makeScopeCtx({ repo, baseRef: base, from, changed: files });
174
+ if (lint) {
175
+ const i = checks.findIndex((c) => c.kind === "lint" && c.eco === "js");
176
+ const own = { kind: "lint", command: lint, cwd: ".", configured: true, ...(exists(repo, "package.json") ? { exec: EXEC[packageManager(repo)] } : {}) };
177
+ checks = i < 0 ? [...checks, own] : [...checks.slice(0, i), own, ...checks.slice(i).filter((c) => !(c.kind === "lint" && c.eco === "js"))];
178
+ }
157
179
  return checks.map((c) => {
158
180
  const r = scopeCommand(c.command, c.cwd === "." ? "" : c.cwd, sctx);
159
181
  // A resolved script body (`pnpm run typecheck` -> `tsc -p apps/web`) runs its binary through the package manager.
160
182
  const resolved = r.action !== "skip" && c.exec && r.command !== c.command && !SYSTEM_BINS.has(px.basename((shellSplit(r.command) || [""])[0]));
161
183
  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
184
  if (r.reason) out.reason = r.reason;
185
+ if (c.configured) out.configured = true;
163
186
  return r.action === "skip" ? out : withCacheFlags(out, repo);
164
187
  });
165
188
  }
@@ -190,8 +213,8 @@ function main(argv) {
190
213
  if (!checks.length) console.log("# no typecheck/lint/test command found");
191
214
  for (const c of checks) {
192
215
  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}` : ""}`);
216
+ if (c.mode === "skip") console.log(`${c.kind} skip # skipped: ${c.reason || "not affected"}${c.configured ? " (configured: verification.commands.typescript.lint)" : ""}`);
217
+ else console.log(`${c.kind} ${c.mode} ${where}${c.widen_reason ? ` # widened: ${c.widen_reason}` : ""}${c.configured ? " # configured: verification.commands.typescript.lint" : ""}`);
195
218
  }
196
219
  return 0;
197
220
  }
@@ -7,7 +7,8 @@ import fs from "node:fs";
7
7
  import path from "node:path";
8
8
  import { execFileSync } from "node:child_process";
9
9
  import { tmpDir } from "../tests/_tmp.mjs";
10
- import { discoverChecks, scopedChecks, cacheEntry, hydrate, publish, withCacheFlags, LOCAL_CACHE } from "./checks.mjs";
10
+ import { discoverChecks, scopedChecks, configuredLint, cacheEntry, hydrate, publish, withCacheFlags, LOCAL_CACHE } from "./checks.mjs";
11
+ import { yqAvailable } from "../../scripts/ci-local/lib.mjs";
11
12
 
12
13
  const TMP = tmpDir("checks-4218-");
13
14
  process.env.FORGE_CI_LOCAL_HOME = path.join(TMP, "forge-home");
@@ -181,3 +182,79 @@ test("discovery: declared workspace packages' scripts win over the root; undecla
181
182
  });
182
183
  assert.deepEqual(discoverChecks(r).map((c) => `${c.kind}:${c.command}:${c.cwd}`), ["typecheck:pnpm run typecheck:apps/web", "test:pnpm run test:."]);
183
184
  });
185
+
186
+ // #4381: lint is scoped to the changed packages, never their dependency closure; a configured typescript.lint wins.
187
+ const HAS_YQ = yqAvailable();
188
+ const turboMonorepo = (extra = {}) => repo({
189
+ ".gitignore": "node_modules/\nforge.yaml\n",
190
+ "package.json": JSON.stringify({ name: "root", private: true, workspaces: ["apps/*", "packages/*"] }),
191
+ "pnpm-lock.yaml": "lockfileVersion: '9.0'\n",
192
+ "turbo.json": JSON.stringify({ tasks: { lint: {}, typecheck: {}, test: {} } }),
193
+ "packages/db/package.json": JSON.stringify({ name: "db", scripts: { lint: "eslint ." } }),
194
+ "packages/db/src/index.ts": "export const db = 1;\n",
195
+ "apps/api/package.json": JSON.stringify({ name: "api", dependencies: { db: "workspace:*" } }),
196
+ "apps/api/src/index.ts": "export const api = 1;\n",
197
+ ...extra,
198
+ });
199
+ const TS_LINT_YAML = "verification:\n commands:\n typescript:\n lint: \"pnpm exec eslint --cache packages/db/src\"\n";
200
+
201
+ test("turbo monorepo: an upstream package change lints only the changed packages; typecheck/test keep dependents", () => {
202
+ const r = turboMonorepo();
203
+ touch(r, "packages/db/src/index.ts");
204
+ const c = byKind(scopedChecks({ repo: r, base: "main", lint: null }));
205
+ assert.equal(c.lint.selector, "turbo-changed");
206
+ assert.match(c.lint.command, /^npx turbo run lint '--filter=\[[0-9a-f]{40}\]'$/);
207
+ assert.doesNotMatch(c.lint.command, /\.\.\.\[/);
208
+ assert.match(c.typecheck.command, /^npx turbo run typecheck '--filter=\.\.\.\[[0-9a-f]{40}\]'$/);
209
+ assert.match(c.test.command, /^npx turbo run test '--filter=\.\.\.\[[0-9a-f]{40}\]'$/);
210
+ });
211
+
212
+ test("turbo: lint-only task lists (lint:*, *:lint) scope to changed packages; mixed lists keep the closure", () => {
213
+ const r = turboMonorepo();
214
+ touch(r, "packages/db/src/index.ts");
215
+ const run = (command) => scopedChecks({ repo: r, base: "main", checks: [{ kind: "lint", command, cwd: "." }], lint: null })[0];
216
+ assert.equal(run("turbo run lint:types web:lint --concurrency 2").selector, "turbo-changed");
217
+ assert.equal(run("turbo run lint build").selector, "turbo-affected");
218
+ assert.equal(run("turbo run lint --filter=web").mode, "full");
219
+ });
220
+
221
+ test("configured typescript.lint in the main checkout's forge.yaml replaces the discovered lint", { skip: !HAS_YQ && "yq not installed" }, () => {
222
+ const r = turboMonorepo();
223
+ fs.writeFileSync(path.join(r, "forge.yaml"), TS_LINT_YAML);
224
+ const wt = path.join(TMP, `wt${n++}`);
225
+ git(r, "worktree", "add", "-q", "-b", "lane", wt, "main");
226
+ touch(wt, "packages/db/src/index.ts");
227
+ const checks = scopedChecks({ repo: wt, base: "main" });
228
+ const lints = checks.filter((c) => c.kind === "lint");
229
+ assert.equal(lints.length, 1);
230
+ assert.equal(lints[0].configured, true);
231
+ assert.doesNotMatch(lints[0].command, /turbo/);
232
+ assert.match(lints[0].command, /eslint .*packages\/db\/src/);
233
+ assert.deepEqual(checks.map((c) => c.kind), ["typecheck", "lint", "test"]);
234
+ });
235
+
236
+ test("a PR-tracked forge.yaml is not trusted; without the slot the output equals discovery-only", { skip: !HAS_YQ && "yq not installed" }, () => {
237
+ const r = turboMonorepo();
238
+ touch(r, "packages/db/src/index.ts");
239
+ const before = scopedChecks({ repo: r, base: "main", lint: null });
240
+ assert.deepEqual(scopedChecks({ repo: r, base: "main" }), before);
241
+ fs.writeFileSync(path.join(r, "forge.yaml"), "verification:\n commands:\n python:\n lint: \"ruff check .\"\n");
242
+ assert.deepEqual(scopedChecks({ repo: r, base: "main" }), before);
243
+ fs.writeFileSync(path.join(r, "forge.yaml"), TS_LINT_YAML);
244
+ git(r, "add", "-f", "forge.yaml");
245
+ git(r, "commit", "-qm", "pr config");
246
+ assert.equal(configuredLint(r, "main"), null);
247
+ const lint = scopedChecks({ repo: r, base: "main" }).find((c) => c.kind === "lint");
248
+ assert.equal(lint.configured, undefined);
249
+ assert.match(lint.command, /^npx turbo run lint/);
250
+ });
251
+
252
+ test("Python: a configured typescript.lint or python.lint never replaces ruff", { skip: !HAS_YQ && "yq not installed" }, () => {
253
+ const r = pyRepo("uv.lock", "version = 1\n");
254
+ fs.appendFileSync(path.join(r, ".gitignore"), "forge.yaml\n");
255
+ git(r, "commit", "-qam", "ignore forge.yaml");
256
+ fs.writeFileSync(path.join(r, "forge.yaml"), "verification:\n commands:\n python:\n lint: \"ruff check src\"\n");
257
+ fs.appendFileSync(path.join(r, "pkg/alpha.py"), "# change\n");
258
+ assert.equal(configuredLint(r, "main"), null);
259
+ assert.equal(byKind(scopedChecks({ repo: r, base: "main" })).lint.command, "uv run ruff check pkg/alpha.py");
260
+ });
@@ -14,6 +14,9 @@ import path from "node:path";
14
14
  import { execFileSync, spawnSync } from "node:child_process";
15
15
  import { tmpDir } from "../tests/_tmp.mjs";
16
16
  import { runCore } from "./lean.mjs";
17
+ import { readLog } from "../engine/runlog.mjs";
18
+ import { runCheckCommand } from "./effects.mjs";
19
+ import { checkFromCli } from "../engine-cli.mjs";
17
20
 
18
21
  const GIT_ENV = { ...process.env, GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@example.invalid", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@example.invalid", GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_NOSYSTEM: "1" };
19
22
  const git = (args, cwd) => { try { return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], env: GIT_ENV }); } catch (e) { throw Object.assign(new Error(`git ${args.join(" ")}: ${e.stderr || e.message}`), { stderr: String(e.stderr ?? "") }); } };
@@ -134,7 +137,7 @@ function builderScript(cs) {
134
137
  const finish = (t) => { res.validation = [{ id: "V1", command: V1, exit: t.run(V1) }]; t.lean("implementation.md", `### Approach\nrelease in a catch\n\n### Changes\n${t.changed().map((f) => `- ${f}`).join("\n")}\n\n### Acceptance Criteria Status\n- see result\n\n### Verification\n- ${V1}\n\n### Notes\nnone`); t.lean("result.json", res); };
135
138
  return async (n, c, t) => {
136
139
  if (n === 1) { t.lean("investigation.md", "**Verdict**: CONFIRMED\n**Confidence**: high\n**Severity**: high\n**Task Type**: bug\n\n### Root Cause\ncheck() throws after reserve() with no release"); t.lean("contract.json", cs.contract()); return; }
137
- if (n === 2) { cs.impl(t); t.commit("fix: release on 429"); res = { status: "fixed", summary: "release on 429", requirements: (cs.claims ?? claimAll)(cs.contract()), amendments: [] }; finish(t); return; }
140
+ if (n === 2) { cs.impl(t); t.commit("fix: release on 429"); if (cs.selfCheck) await cs.selfCheck(c.cwd); res = { status: "fixed", summary: "release on 429", requirements: (cs.claims ?? claimAll)(cs.contract()), amendments: [] }; finish(t); return; }
138
141
  if (cs.resume) { cs.resume(t, res); t.commit("fix: conformance"); }
139
142
  finish(t);
140
143
  };
@@ -162,11 +165,12 @@ function oracle(c) {
162
165
  }
163
166
  const generic = () => "```json\n" + JSON.stringify({ verdict: "PASS", fixes_issue: true, regression: false, reason: "looks good" }) + "\n```";
164
167
 
165
- async function runCase(cs, conformance = "enforce") {
168
+ async function runCase(cs, conformance = "enforce", extra = {}) {
166
169
  const dir = tmpDir("conf-e2e-");
167
170
  const w = makeWorld(dir, { builder: builderScript(cs), reviewer: (n, c) => (cs.generic ? generic() : oracle(c)) });
168
- const ret = await runCore({ issue: 101, repo: "o/r", lane: "staging", cwd: w.repo, worktreeRoot: path.join(dir, "wt"), dir: path.join(dir, "runs"), io: w.io, random: () => 0.5, stableMs: 0, livePollMs: 20, conformance });
169
- return w.observe(ret);
171
+ if (extra.runCommand) w.io.runCommand = extra.runCommand;
172
+ const ret = await runCore({ issue: 101, repo: "o/r", lane: "staging", cwd: w.repo, worktreeRoot: path.join(dir, "wt"), dir: path.join(dir, "runs"), io: w.io, random: () => 0.5, stableMs: 0, livePollMs: 20, conformance, ...(extra.opts ?? {}) });
173
+ return { ...w.observe(ret), log: readLog(path.join(dir, "runs"), 101, { repo: "o/r" }) }; // #4432: keyed by repo
170
174
  }
171
175
  const blocked = (o, code) => {
172
176
  assert.equal(o.ret.terminalReason, "needs-human"); assert.equal(o.ret.code, code);
@@ -185,7 +189,7 @@ describe("conformance gate end-to-end (real git, disposable repo)", { skip: !HAV
185
189
  const o = await runCase({ contract: FULL, impl: (t) => fix(t, ["scrape", "search", "export"]) });
186
190
  merged(o); assert.ok(o.staging.search && o.staging.export && !o.staging.billingChanged);
187
191
  assert.deepEqual([o.counts.builder, o.counts.reviewer], [2, 1]);
188
- assert.deepEqual(o.markers, ["FORGE:STARTED", "FORGE:INVESTIGATION", "FORGE:CONTRACT", "FORGE:IMPLEMENTATION", "FORGE:REVIEW", "FORGE:TRAJECTORY"]);
192
+ assert.deepEqual(o.markers, ["FORGE:STARTED", "FORGE:INVESTIGATION", "FORGE:CONTRACT", "FORGE:IMPLEMENTATION", "FORGE:REVIEW", "FORGE:FACTS", "FORGE:TRAJECTORY"]);
189
193
  });
190
194
  it("2 omission: the contract silently drops requirement 3; review catches it; issue stays open, nothing merged", async () => {
191
195
  const o = await runCase({ contract: OMIT, impl: (t) => fix(t, ["scrape", "search"]) });
@@ -225,6 +229,24 @@ describe("conformance gate end-to-end (real git, disposable repo)", { skip: !HAV
225
229
  assert.match(o.issue.comments.find((c) => c.includes("FORGE:REVIEW")), /would BLOCK \(nonconforming\)[\s\S]*conformance\.omission/);
226
230
  assert.deepEqual([o.counts.builder, o.counts.reviewer], [2, 1]);
227
231
  });
232
+ it("#4364 forgedock check in the lane worktree runs the engine's commands and reaches its verdict", async () => {
233
+ let cli = null;
234
+ const learned = [V1, "exit 3"];
235
+ const o = await runCase({ contract: FULL, impl: (t) => fix(t, ["scrape", "search", "export"]),
236
+ selfCheck: async (wt) => { cli = await checkFromCli([], { cwd: wt, git: async (a, cwd) => git(a, cwd), out: () => {} }); } },
237
+ "enforce", { runCommand: runCheckCommand, opts: { precheck: { commands: learned }, verify: { maxRounds: 0 } } });
238
+ merged(o);
239
+ const [engine] = o.log.filter((e) => e.event === "PRECHECK_COMMANDS");
240
+ assert.deepEqual(cli.commands.map(({ command, rc }) => ({ command, rc })), engine.results.map(({ command, rc }) => ({ command, rc })));
241
+ assert.deepEqual(engine.results.map((r) => r.rc), [0, 3], "one passing and one failing pre-PR command");
242
+ // validate: the CLI's V1 run agrees with the engine's conformance precheck (no validate failure)
243
+ assert.deepEqual(cli.validate.map(({ id, rc }) => ({ id, rc })), [{ id: "V1", rc: 0 }]);
244
+ assert.ok(!o.log.some((e) => e.event === "PRECHECK" && e.failures.length), "the engine's validate verdict passes too");
245
+ assert.equal(cli.ok, false); assert.equal(cli.exitCode, 1);
246
+ // the run was on the final tree, so the engine records it as red (not skipped/stale) on both events
247
+ assert.equal(engine.self_check, "red");
248
+ assert.deepEqual(o.log.filter((e) => e.event === "SELF_CHECK_MISSING").map((e) => e.state), ["red"]);
249
+ });
228
250
  it("6 a generic PASS over an incomplete implementation never merges (review.incomplete)", async () => {
229
251
  const o = await runCase({ contract: FULL, impl: (t) => fix(t, ["scrape", "export"]), generic: true });
230
252
  blocked(o, "review.incomplete"); assert.deepEqual([o.counts.builder, o.counts.reviewer], [2, 2]);