forgedock 1.33.0 → 1.35.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 (45) hide show
  1. package/bin/engine-cli.mjs +72 -12
  2. package/bin/forgedock.mjs +19 -1
  3. package/bin/hooks/reread-nudge.mjs +33 -5
  4. package/bin/lean/batch.mjs +24 -1
  5. package/bin/lean/batch.test.mjs +75 -0
  6. package/bin/lean/budget.mjs +14 -3
  7. package/bin/lean/budget.test.mjs +11 -1
  8. package/bin/lean/conflictres.mjs +93 -0
  9. package/bin/lean/conflictres.test.mjs +93 -0
  10. package/bin/lean/conformance.mjs +39 -6
  11. package/bin/lean/conformance.test.mjs +46 -0
  12. package/bin/lean/effects.mjs +11 -3
  13. package/bin/lean/lane.mjs +13 -0
  14. package/bin/lean/lean.mjs +111 -33
  15. package/bin/lean/lean.test.mjs +197 -5
  16. package/bin/lean/orchestrate.mjs +81 -8
  17. package/bin/lean/precheck.mjs +56 -8
  18. package/bin/lean/precheck.test.mjs +62 -3
  19. package/bin/lean/prompts.mjs +28 -6
  20. package/bin/lean/schedd.mjs +10 -1
  21. package/bin/lean/schedd.test.mjs +30 -0
  22. package/bin/lean/sequence.mjs +26 -0
  23. package/bin/lean/sequence.test.mjs +41 -2
  24. package/bin/lean/toolstate.mjs +77 -0
  25. package/bin/lean/toolstate.test.mjs +83 -0
  26. package/bin/lean/verify.mjs +18 -2
  27. package/bin/lean/verify.test.mjs +29 -3
  28. package/bin/review-staging.mjs +14 -2
  29. package/bin/stats/efficiency.mjs +103 -17
  30. package/bin/stats/efficiency.test.mjs +103 -4
  31. package/bin/stats/index.mjs +27 -5
  32. package/bin/stats/pool.mjs +2 -2
  33. package/commands/review-pr-agents/protocols.md +1 -1
  34. package/commands/review-pr-staging.md +1 -1
  35. package/docs/CONFIG.md +12 -2
  36. package/docs/STATS.md +29 -1
  37. package/package.json +1 -1
  38. package/scripts/review-finalize.sh +17 -1
  39. package/scripts/review-finalize.test.sh +41 -0
  40. package/scripts/review-pr-domain-map.test.sh +2 -2
  41. package/scripts/review-pr-staging-scope.test.sh +4 -2
  42. package/scripts/review-prepare.sh +62 -27
  43. package/scripts/review-prepare.test.sh +53 -0
  44. package/scripts/review-staging-pre.sh +29 -9
  45. package/scripts/review-staging-pre.test.sh +18 -0
@@ -6,7 +6,7 @@ import { writeFileSync } from "node:fs";
6
6
  import { join } from "node:path";
7
7
  import { tmpDir } from "../tests/_tmp.mjs";
8
8
  import { formatFirstPassSummary } from "./fixround.mjs";
9
- import { runChecks, formatPrecheckSummary, planPrecheck, selfCheck, selfCheckState, formatCheckTable, worktreeFingerprint, quietFailure } from "./precheck.mjs";
9
+ import { runChecks, retryTimeoutMs, runWithTimeoutRetry, formatPrecheckSummary, planPrecheck, selfCheck, selfCheckState, formatCheckTable, worktreeFingerprint, quietFailure } from "./precheck.mjs";
10
10
  import { execFileSync } from "node:child_process";
11
11
  import { readPrecheckBudget } from "../engine-cli.mjs";
12
12
  import { orchestrate } from "./orchestrate.mjs";
@@ -52,9 +52,9 @@ describe("runChecks (#4331)", () => {
52
52
  assert.deepEqual(r.deferred, ["b", "c"]);
53
53
  });
54
54
 
55
- it("a command's own timeout (not the budget) stays a failure; a throw is rc 127", async () => {
55
+ it("a command's own timeout (not the budget) stays a failure once the retry also times out; a throw is rc 127", async () => {
56
56
  const h = harness({ cost: { slow: 5000 } });
57
- const r = await runChecks({ commands: ["slow", "boom", "bad"], parallelism: 2, budgetMs: 600000, perCommandMs: 2000, now: h.now,
57
+ const r = await runChecks({ commands: ["slow", "boom", "bad"], parallelism: 2, budgetMs: 600000, perCommandMs: 2000, now: h.now, load: () => ({ load1: 0, cores: 4 }),
58
58
  run: (a) => (a.command === "boom" ? Promise.reject(new Error("spawn failed")) : a.command === "bad" ? Promise.resolve({ rc: 2, output: "lint" }) : h.run(a)) });
59
59
  assert.deepEqual(r.results.map((x) => [x.command, x.rc]), [["slow", 124], ["boom", 127], ["bad", 2]]);
60
60
  assert.match(r.results[1].output, /spawn failed/);
@@ -145,6 +145,16 @@ describe("readPrecheckBudget: forge.yaml lean.precheck.max_minutes (#4331)", ()
145
145
  describe("forgedock check core (#4364)", () => {
146
146
  const MAP = { version: 1, entries: [{ globs: ["src/**"], command: "npm test", cwd: "." }, { globs: ["docs/**"], command: "make docs", cwd: "." }] };
147
147
 
148
+ it("planPrecheck #4585: invariants are appended unscoped, deduped, and kept when the map matches nothing", () => {
149
+ const p = planPrecheck({ testMap: MAP, files: ["README.md"], learned: ["make lint"], verify: { invariants: ["bash scripts/inv.sh", " make lint ", ""] } });
150
+ assert.deepEqual(p.commands.at(-1), "bash scripts/inv.sh");
151
+ assert.equal(p.commands.filter((c) => c === "make lint").length, 1);
152
+ assert.equal(p.invariants, 2);
153
+ const only = planPrecheck({ files: [], learned: [], verify: { invariants: ["bash scripts/inv.sh"] } });
154
+ assert.deepEqual(only.commands, ["bash scripts/inv.sh"]);
155
+ assert.deepEqual(planPrecheck({ testMap: MAP, files: ["README.md"], learned: ["make lint"] }).commands, ["make lint"], "no invariants: unchanged");
156
+ });
157
+
148
158
  it("planPrecheck: the engine's selection and budget rule (max_minutes wins, else precheck_total_seconds, else 600 s)", () => {
149
159
  const p = planPrecheck({ testMap: MAP, files: ["src/a.js"], learned: [" make lint ", "", "make docs"], verify: { precheckTotalSeconds: 120, precheckTimeoutSeconds: 30 } });
150
160
  assert.deepEqual(p.commands, ["make lint", "npm test"]); assert.deepEqual(p.dropped, ["make docs"]);
@@ -208,3 +218,52 @@ describe("forgedock check core (#4364)", () => {
208
218
  assert.notEqual(await fp(), dirty);
209
219
  });
210
220
  });
221
+
222
+ describe("check timeout is infrastructure (#4589)", () => {
223
+ const idle = () => ({ load1: 0, cores: 4 });
224
+ it("retryTimeoutMs doubles at least, scales with load per core, caps at 3600 s and never shrinks", () => {
225
+ assert.equal(retryTimeoutMs({ perMs: 300000, load1: 0, cores: 4 }), 600000);
226
+ assert.equal(retryTimeoutMs({ perMs: 300000, load1: 13, cores: 4 }), 1200000);
227
+ assert.equal(retryTimeoutMs({ perMs: 2000000, load1: 40, cores: 4 }), 3600000);
228
+ assert.equal(retryTimeoutMs({ perMs: 4000000, load1: 0, cores: 4 }), 4000000);
229
+ });
230
+ it("runChecks retries a command's own timeout once with a longer one and passes", async () => {
231
+ const h = harness({ cost: { slow: 400000 } });
232
+ const r = await runChecks({ commands: ["slow"], budgetMs: Infinity, perCommandMs: 300000, now: h.now, run: h.run, load: idle });
233
+ assert.deepEqual(h.calls, [["slow", 300000], ["slow", 600000]]);
234
+ assert.equal(r.results[0].rc, 0);
235
+ assert.deepEqual(r.deferred, []);
236
+ });
237
+ it("a repeated timeout stays rc 124 and is labelled infrastructure", async () => {
238
+ const h = harness({ cost: { slow: 9e9 } });
239
+ const r = await runChecks({ commands: ["slow"], budgetMs: Infinity, perCommandMs: 1000, now: h.now, run: h.run, load: idle });
240
+ assert.equal(h.calls.length, 2);
241
+ assert.equal(r.results[0].rc, 124);
242
+ assert.match(r.results[0].output, /infra timeout/);
243
+ });
244
+ it("selfCheck is ok when the retry passes", async () => {
245
+ const h = harness({ cost: { slow: 5000 } });
246
+ const r = await selfCheck({ contract: { validate: [{ id: "V1", command: "slow" }] }, plan: { commands: [], budgetMs: 0, perCommandMs: 3000 }, now: h.now, run: h.run });
247
+ assert.equal(r.ok, true);
248
+ });
249
+ it("a budget-cut timeout is still deferred, never retried; a non-124 failure is never retried", async () => {
250
+ const h = harness({ cost: { slow: 9e9 }, rc: { bad: 1 } });
251
+ const r = await runChecks({ commands: ["slow"], budgetMs: 2000, perCommandMs: 300000, now: h.now, run: h.run, load: idle });
252
+ assert.equal(h.calls.length, 1);
253
+ assert.deepEqual(r.deferred, ["slow"]);
254
+ const h2 = harness({ rc: { bad: 1 } });
255
+ const r2 = await runChecks({ commands: ["bad"], budgetMs: Infinity, perCommandMs: 1000, now: h2.now, run: h2.run, load: idle });
256
+ assert.equal(h2.calls.length, 1);
257
+ assert.equal(r2.results[0].rc, 1);
258
+ });
259
+ it("a retry the budget cuts short is deferred", async () => {
260
+ const h = harness({ cost: { slow: 9e9 } });
261
+ const r = await runChecks({ commands: ["slow"], budgetMs: 1500, perCommandMs: 1000, now: h.now, run: h.run, load: idle });
262
+ assert.deepEqual(r.deferred, ["slow"]);
263
+ });
264
+ it("runWithTimeoutRetry reports retried", async () => {
265
+ let n = 0;
266
+ const x = await runWithTimeoutRetry(async (ms) => (++n === 1 ? { rc: 124, output: "t" } : { rc: 0, output: String(ms) }), 1000, { load: idle });
267
+ assert.deepEqual([x.retried, x.r.rc, x.r.output], [true, 0, "2000"]);
268
+ });
269
+ });
@@ -63,6 +63,12 @@ export function duplicateExportsPrompt(findings, mode = "local") {
63
63
  return `This change exports names that another module of the same app or package already exports:\n${rows}\n\nRename the new export or reuse the existing one, and update every reference and any registry, map or test keyed by the name (same-named entries collide or leave one unprobed). ${rerun(mode)}, commit, and update ${IMPLEMENTATION_FILE} and ${RESULT_FILE}. Do not push or touch GitHub.`;
64
64
  }
65
65
 
66
+ /** #4591: lane-added migrations the repo's generator did not produce. */
67
+ export function migrationGeneratorPrompt(problems, mode = "local") {
68
+ const rows = problems.slice(0, 30).map((p) => `- ${p}`).join("\n");
69
+ return `These migration files were named by hand, not produced by the repo's migration generator:\n${rows}\n\nA made-up name or timestamp collides with another lane's migration. Delete each such file, then recreate it with the generator (hand-written SQL: the generator's \`--custom\` mode, e.g. \`drizzle-kit generate --custom --name=<name>\`, then put your SQL in the file it wrote), so the journal entry and snapshot come from it. Never name or number migration files by hand. ${rerun(mode)}, commit, and update ${IMPLEMENTATION_FILE} and ${RESULT_FILE}. Do not push or touch GitHub.`;
70
+ }
71
+
66
72
  /** #4390: byte cap of the diff hunks a context-ceiling restart carries (the stat is always kept). */
67
73
  export const RESTART_DIFF_MAX_BYTES = 40 * 1024;
68
74
 
@@ -171,13 +177,15 @@ export function builderImplementPrompt(mode = "local", { toolchain = null, spars
171
177
 
172
178
  ${HOST_RULES}${toolchainRule(toolchain)}${sparseRule(sparse)}${factsRule(facts)}${remote ? `\n\n${VERIFY_RULES[mode]}` : ""}
173
179
 
174
- The contract changes only through typed amendments in ${RESULT_FILE}, each with a why, which the engine posts on the issue:
175
- - {"kind":"add-deliverable","path":"<file>","why":""}: a file you must change that the contract did not list.
180
+ The contract changes only through typed amendments in ${RESULT_FILE}, each with a why:
181
+ - {"kind":"add-deliverable","path":"<file>","why":""}: a file the contract did not list.
176
182
  - {"kind":"add-requirement","requirement":{"id":"R9","quote":"<verbatim from the issue>","disposition":"deliver|defer|post-deploy"},"why":""}: an issue requirement the contract missed.
177
- - {"kind":"defer|post-deploy","requirement":"R#","why":""}: a contracted requirement that becomes separate work or a post-deploy check. The engine files it as a tracked follow-up.
183
+ - {"kind":"defer|post-deploy","requirement":"R#","why":""}: a requirement that becomes separate work or a post-deploy check. Filed as a tracked follow-up. Not "type"; other kinds are rejected.
184
+ - {"kind":"amend-requirement","requirement":"R#","text":"<revised wording>","why":""}: reworded; judged against text.
178
185
  Never report a deliver requirement as "deferred" or "follow-up" without an amendment.
179
- A new dependency between workspace packages in a package.json that no deliverable names is blocked by the engine: add it through add-deliverable with a why naming the package, or do not add it.
180
- If the repository has scripts/check-devdocs-freshness.sh, a behaviour change under the paths it checks must update the matching dossier (devdocs/modules/*.md, per devdocs/index.yaml) in the same change. Run it against the base before writing the result; the engine runs it too.
186
+ Never name or number migration files by hand; use the generator.
187
+ A new workspace-package dependency in a package.json no deliverable names is blocked by the engine: add it through add-deliverable with a why naming the package, or do not add it.
188
+ If the repository has scripts/check-devdocs-freshness.sh, a behaviour change under the paths it checks must update the matching dossier (devdocs/modules/*.md, per devdocs/index.yaml) in the same change. Run it against the base first; the engine runs it too.
181
189
 
182
190
  Before writing ${RESULT_FILE}, after your last edit, run \`node ${FORGEDOCK_CLI} check\` in the worktree and fix until it passes; if it cannot pass, report the problem as blocked.
183
191
 
@@ -244,6 +252,18 @@ export function enumeratingPlanNote(list) {
244
252
  return `\n\nEnumerating tests (found by the engine): these tests list every member of a module or directory this issue names, so a new export, table, job, action, namespace or file there must join their list or CI goes red. List each one you will touch under Affected Files in ${INVESTIGATION_FILE}, and add a validate command that runs only those tests.\n${list.slice(0, 40).map((e) => `- ${e.module} -> ${e.test}:${e.line} (${e.why})`).join("\n")}`;
245
253
  }
246
254
 
255
+ /** #4585: the repo's verify.invariants as a checklist for the plan; "" when none. */
256
+ export function invariantsPlanNote(list) {
257
+ if (!Array.isArray(list) || !list.length) return "";
258
+ return `\n\nRepo-wide invariants (verify.invariants): these commands run before every push whichever tests you choose, and each lists every table, export, key, namespace or file of its kind. A new one needs an entry in every such registry (e.g. registry, export, purge order, seeder, translation status, boundary or placeholder lists). Name each registry you will touch under Affected Files in ${INVESTIGATION_FILE}.\n${list.slice(0, 20).map((c) => `- \`${c}\``).join("\n")}`;
259
+ }
260
+
261
+ /** #4585: the builder rule for verify.invariants; "" when none. */
262
+ export function invariantsBuildRule(list) {
263
+ if (!Array.isArray(list) || !list.length) return "";
264
+ return `\n\nInvariants checklist: for each new table, export, key, namespace, page or file, add its entry to every registry or coverage list that enumerates its kind (e.g. a new table needs registry, export, purge and seeder entries; a new i18n key needs every locale). Then run each command below and fix any failure before you finish.\n${list.slice(0, 20).map((c) => `- \`${c}\``).join("\n")}`;
265
+ }
266
+
247
267
  const NOT_MEMBER = `or, when the symbol is not a member of that list, record {"symbol":"<name>","test":"<test path>","why":"<why it is not a member>"} in ${RESULT_FILE} "not_members"`;
248
268
 
249
269
  /** #4322: the builder rule citing the plan's enumerating tests; "" when none. */
@@ -280,7 +300,7 @@ export function conformanceResumePrompt(failures, mode = "local", lane = "") {
280
300
  const baseMoved = lane && failures.some((f) => f?.kind === "validate_failed")
281
301
  ? `\n\nIf a validate command fails only because origin/${lane} moved on after this branch started (the change itself is fine), bring the branch up to date with \`git merge --no-edit origin/${lane}\` (already fetched; never rebase or force), resolve any conflicts, then re-run every validate command and record the results.`
282
302
  : "";
283
- return `The change does not conform to the frozen contract and the issue:\n${failures.slice(0, 30).map((f) => `- ${f.code}: ${String(f.detail).slice(0, 500)}`).join("\n")}${baseMoved}\n\nFix each item in the code, or record a typed amendment with a why in ${RESULT_FILE}. ${rerun(mode)}, commit, and update ${IMPLEMENTATION_FILE} and ${RESULT_FILE}. Do not push or touch GitHub.`;
303
+ return `The change does not conform to the frozen contract and the issue:\n${failures.slice(0, 30).map((f) => `- ${f.code}: ${String(f.detail).slice(0, 900)}`).join("\n")}${baseMoved}\n\nFix each item in the code, or record a typed amendment with a why in ${RESULT_FILE}. ${rerun(mode)}, commit, and update ${IMPLEMENTATION_FILE} and ${RESULT_FILE}. Do not push or touch GitHub.`;
284
304
  }
285
305
 
286
306
  /** forge#4159: the one resume for package.json edges between workspace packages the contract does not name. */
@@ -311,6 +331,7 @@ const BLOCK_RULES = `"blocked" needs a typed "block" in ${RESULT_FILE}, one of:
311
331
  - {"kind":"split-required","subissues":[{"title":"","body":"","depends_on":[0],"files":["<path>"]}],"size":{"files":N,"lines":N}}: too big for one change. The engine files the sub-issues (depends_on = indexes of earlier sub-issues; files = the files or dirs each will edit, also accepted as "paths") only when every sub-issue's files are disjoint from the others' or the whole change's size estimate exceeds ${SPLIT_MIN_FILES} files or ${SPLIT_MIN_LINES} lines; otherwise keep one lane: an ordered checklist in the contract with a validate per item. In an admitted split, when siblings would edit the same hub (a barrel or index export list, a union type, a shared test fixture, an index doc), pre-stage its slots in the earliest sub-issue and make the others depend on it, or give each sibling its own per-feature file; the engine serialises sub-issues that still share a file.
312
332
  - {"kind":"human","why":"${HUMAN_BLOCK_REASONS.join("|")}"}: only an external action agents cannot take, or a product decision too critical for agents.
313
333
  "An upstream issue must land first", "needs a split" and "needs prod data that read-only monitoring can read" are never "human".
334
+ A check that times out (rc 124) is host load, never a block or "human" product-decision: run the narrowest targeted command for your changed files and say the full one could not finish in time.
314
335
  Remote sandbox lacking a host-only capability (sops, prod_monitor, ops_credentials): name it in the summary; the host reruns the lane.`;
315
336
 
316
337
  export function blockCorrectionPrompt(problem) {
@@ -342,6 +363,7 @@ export const REVIEWER_SYSTEM = `You are an independent conformance reviewer of o
342
363
 
343
364
  You receive the GitHub issue with its comments, the frozen contract (requirements quoted from the issue, deliverables, preserve constraints, validate commands) with typed amendments, the builder's result with validation output, the changed files, the diff, and a row skeleton with one row per contract id and changed file. The engine computes the verdict from your rows; a reply without rows is rejected, so fill the skeleton rather than composing rows: fill EVERY row (set each empty status, evidence and validated_by, and each null deferral_ok; keep the ids and files as given):
344
365
  - issue_coverage: one row per requirement or acceptance criterion in the issue, quoted verbatim, saying whether the contract covers it, narrows it, or misses it. The skeleton lists the contract's quotes; add a row for any issue requirement the contract does not quote.
366
+ - amendment: a requirement with an \`amendment\` is judged against amendment.text and its why, not the quote; its issue_coverage row is covered when the amendment follows from its why. If it does not hold, mark the row partial with note "amendment: <what to change>".
345
367
  - requirements: one row per contract requirement id: implemented|partial|missing|deferred|post-deploy|not-applicable, with evidence as a backtick-quoted repo-relative path:line copied verbatim from the changed files, including (group), [slug] and [...slug] folders (e.g. \`apps/web/app/(console)/layout.tsx:12\`, not route.ts:12) and the validate id or test that proves it (validated_by). For a defer or post-deploy requirement add deferral_ok: true only if it is genuinely separate work or only checkable after a deploy, false if it belongs in this change. For a verify out_of_band row (measured by the named job, not in this lane) set instrumented: true only when its budget test or bench is committed and that job is configured, with that path:line as evidence; false otherwise. Do not judge the measured result.
346
368
  - scope: one row per changed file, pre-filled with the engine's authorization: the deliverable id that authorizes it (deliverables may be directories or globs), "test", "incidental" (changelog, lockfile), or "none" with what it changes. Change it only if the diff shows the file does something its deliverable does not cover.
347
369
  - preserve: one row per preserve id: intact|violated|unverified, with path:line. The skeleton starts each at "unverified"; set intact only after checking it.
@@ -8,7 +8,7 @@
8
8
  *
9
9
  * Protocol: newline-delimited JSON, one request per connection.
10
10
  * {op:"submit", argv, cwd, env} → {type:"batch", id, dir}, {type:"row", row}…, {type:"line", text}…, {type:"done", result, report}
11
- * {op:"attach", id} → log.jsonl rows replayed, then live rows/lines and done; a finished batch replays report.json
11
+ * {op:"attach", id, resume?} → (`resume` also clears a pending cancel, #4592) log.jsonl rows replayed, then live rows/lines and done; a finished batch replays report.json
12
12
  * ({type:"done", replay:true}) unless it has unrun added issues; a batch nobody hosts is resumed here
13
13
  * {op:"set-parallel", id, k} → {type:"ok", parallel}
14
14
  * {op:"add", id, issues} → {type:"ok", added, skipped, hosted} (#4408): merged into the batch's `add` file, which a
@@ -17,6 +17,8 @@
17
17
  * admits nothing more, its running local lanes get SIGTERM (their teardown saves the work)
18
18
  * and the rest end held; no other batch and not the daemon. A finished, unhosted batch:
19
19
  * {type:"ok", cancelled:false, finished:true}, nothing written
20
+ * {op:"hold"|"release", id, issues} → {type:"ok", changed, hosted} (#4592): merges into / removes from the batch's `hold` file; a hosted
21
+ * runner admits no held issue from its next pass (running lanes are not stopped)
20
22
  * {op:"status"} → {type:"status", pid, batches:[{id, running}]}
21
23
  * Failures answer {type:"error", message}. A client closing its connection never cancels a batch (only `cancel` stops one), and stopping the daemon
22
24
  * never stops a lane (lanes are detached sessions). On start the daemon resumes every daemon-hosted batch without a
@@ -140,6 +142,12 @@ export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(
140
142
  const { added, skipped } = b.addIssues(req.issues ?? []);
141
143
  return send({ type: "ok", added, skipped, hosted: hosted.has(req.id) }, true);
142
144
  }
145
+ if (req.op === "hold" || req.op === "release") {
146
+ const b = openBatch(root, req.id);
147
+ if (!b.exists()) throw new Error(`no batch ${req.id} in ${root}`);
148
+ const changed = req.op === "hold" ? b.holdIssues(req.issues ?? []) : b.releaseIssues(req.issues ?? []);
149
+ return send({ type: "ok", changed, hosted: hosted.has(req.id) }, true);
150
+ }
143
151
  if (req.op === "cancel") {
144
152
  const b = openBatch(root, req.id);
145
153
  if (!b.exists()) throw new Error(`no batch ${req.id} in ${root}`);
@@ -153,6 +161,7 @@ export async function startDaemon({ dir = schedDir(), runBatch, idleMs = Number(
153
161
  return send({ type: "batch", id: ent.id, dir: ent.dir });
154
162
  }
155
163
  if (req.op === "attach") {
164
+ if (req.resume) { const rb = openBatch(root, req.id); if (rb.exists()) rb.clearCancel(); } // #4592: the operator's resume continues a cancelled batch the daemon still hosts
156
165
  if (!hosted.has(req.id) && starting.has(req.id)) await starting.get(req.id).catch(() => {});
157
166
  const live = hosted.get(req.id);
158
167
  if (live) {
@@ -238,6 +238,36 @@ await orchestrateFromCli(["1", "2", "--parallel", "2", "--repo", "o/r", "--no-pr
238
238
  } finally { await h.cleanup(); }
239
239
  });
240
240
 
241
+ it("#4592: --hold keeps #2 from running on a live batch; --resume <id> 2 after a cancel re-admits it", async () => {
242
+ const h = host();
243
+ try {
244
+ await h.daemon();
245
+ await withRoot(h, () => capture(async () => {
246
+ const first = orchestrateFromCli(["1", "2", "--parallel", "1", "--repo", "o/r", "--no-prefilter"], h.client);
247
+ await until(() => h.launched().length === 1 && h.lanePids().length === 1);
248
+ const [id] = listBatches(h.root);
249
+ const b = openBatch(h.root, id);
250
+ const held = await orchestrateFromCli(["--hold", id, "2", "x"], h.client);
251
+ assert.deepEqual(held.changed, [2]);
252
+ assert.deepEqual(b.readHeld(), [2]);
253
+ await assert.rejects(orchestrateFromCli(["--hold", id], h.client), /usage/);
254
+ await assert.rejects(orchestrateFromCli(["--release", "nope", "2"], h.client), /no batch nope/);
255
+ const res = await orchestrateFromCli(["--cancel", id], h.client);
256
+ assert.equal(res.cancelled, true);
257
+ const cancelled = await first;
258
+ assert.deepEqual(cancelled.rows.map((r) => [r.issue, r.outcome]), [[1, "held"], [2, "held"]]);
259
+ assert.deepEqual(b.readHeld(), [2], "the hold survives the cancel");
260
+ // The issue's case: a resume naming the issue re-admits it (it used to print `skipped #2 (already in batch)`).
261
+ const io = { gh: async (x) => (x[0] === "issue" ? JSON.stringify({ state: "OPEN" }) : "o/r\n") };
262
+ const resumed = orchestrateFromCli(["--resume", id, "2"], { ...h.client, io });
263
+ await until(() => b.readCancel() === null && b.readHeld().length === 0);
264
+ setTimeout(() => writeFileSync(h.release, ""), 300);
265
+ const done = await resumed;
266
+ assert.deepEqual(done.rows.map((r) => [r.issue, r.outcome]), [[1, "merged"], [2, "merged"]]);
267
+ }));
268
+ } finally { await h.cleanup(); }
269
+ });
270
+
241
271
  it("#4481: a batch cancelled through the daemon is not resumed by a schedd restart", async () => {
242
272
  const h = host();
243
273
  try {
@@ -431,3 +431,29 @@ export async function renumberLane({ git, base, cwd, laneRef = "HEAD", regenerat
431
431
  return { ok: true, moves: moves.map(({ from, to }) => ({ from, to })), commit: (await g(["rev-parse", "HEAD"])).trim() || null };
432
432
  } catch (e) { return await fail(`deterministic renumber failed: ${String(e?.message ?? e).split("\n")[0]}`); }
433
433
  }
434
+
435
+ /**
436
+ * #4591: hand-named migrations. In a drizzle-style dir (`<dir>/meta/_journal.json` at `ref`) a lane-added `<tag>.sql` the
437
+ * generator wrote always has a journal entry the lane added and a `<dir>/meta/<prefix>_snapshot.json` the lane added
438
+ * (`generate --custom` too); a file named by hand has neither, and its made-up timestamp collides with a sibling lane's.
439
+ * No journal dir: []. @returns {Promise<string[]>} one problem per file
440
+ */
441
+ export async function generatorProblems({ git, base, ref = "HEAD" }) {
442
+ const g = out(git), problems = [];
443
+ const added = lines(await g(["-c", "core.quotepath=off", "diff", "--name-only", "--diff-filter=A", `${base}...${ref}`])).filter((f) => !SKIP_RE.test(f));
444
+ const tagsAt = async (r, jp) => { try { return new Set((JSON.parse(await g(["show", `${r}:${jp}`])).entries ?? []).map((e) => e?.tag)); } catch { return new Set(); } };
445
+ for (const dir of [...new Set(added.filter((f) => f.endsWith(".sql")).map((f) => dirname(f)))]) {
446
+ const jp = posix.join(dir, "meta", "_journal.json");
447
+ if (!(await g(["show", `${ref}:${jp}`])).trim()) continue;
448
+ const [was, now] = [await tagsAt(base, jp), await tagsAt(ref, jp)];
449
+ for (const f of added.filter((x) => dirname(x) === dir && x.endsWith(".sql"))) {
450
+ const tag = basename(f, ".sql"), prefix = /^(\d+)[_-]/.exec(tag)?.[1];
451
+ if (!prefix) continue;
452
+ const why = [];
453
+ if (!now.has(tag) || was.has(tag)) why.push("no journal entry added for it");
454
+ if (!added.includes(posix.join(dir, "meta", `${prefix}_snapshot.json`))) why.push(`no meta/${prefix}_snapshot.json added`);
455
+ if (why.length) problems.push(`${f}: not produced by the migration generator (${why.join("; ")})`);
456
+ }
457
+ }
458
+ return problems;
459
+ }
@@ -8,9 +8,9 @@ import { execFileSync } from "node:child_process";
8
8
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
9
9
  import { dirname, join } from "node:path";
10
10
  import { tmpDir } from "../tests/_tmp.mjs";
11
- import { sequenceCollisions, journalProblems, snapshotLane, verifyRenumber, numberOnlyChange, headerOnlyChange, renumberLane, resolveSequenceConflicts } from "./sequence.mjs";
11
+ import { sequenceCollisions, journalProblems, snapshotLane, verifyRenumber, numberOnlyChange, headerOnlyChange, renumberLane, resolveSequenceConflicts, generatorProblems } from "./sequence.mjs";
12
12
  import { makeSequenceLeaseIo } from "./seqlease.mjs";
13
- import { sequencePrompt, sequenceSyncNote } from "./prompts.mjs";
13
+ import { sequencePrompt, sequenceSyncNote, migrationGeneratorPrompt, builderImplementPrompt } from "./prompts.mjs";
14
14
 
15
15
  const sh = (cwd, ...a) => execFileSync("git", a, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@t", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@t" } });
16
16
  const gitIn = (cwd) => async (a) => sh(cwd, ...a);
@@ -453,3 +453,42 @@ describe("sequence-numbered documents: two siblings each add an ADR (#4385)", ()
453
453
  assert.match(p, /series index/);
454
454
  });
455
455
  });
456
+
457
+ describe("hand-named migrations (#4591)", () => {
458
+ const T1 = "20261025020000_sso_domain_verification", T2 = "20261025020100_next";
459
+ function base() {
460
+ const d = tmpDir("gen-");
461
+ sh(d, "init", "-q", "-b", "main");
462
+ put(d, { "db/meta/_journal.json": journal("20261024000000_init"), "db/meta/20261024000000_snapshot.json": "{}\n", "db/20261024000000_init.sql": "x;\n" });
463
+ commit(d, "init");
464
+ sh(d, "checkout", "-qb", "lane");
465
+ return d;
466
+ }
467
+ it("flags a hand-named file with no journal entry and no snapshot, naming both gaps", async () => {
468
+ const d = base();
469
+ put(d, { [`db/${T1}.sql`]: "y;\n" });
470
+ commit(d, "hand");
471
+ const p = await generatorProblems({ git: gitIn(d), base: "main" });
472
+ assert.equal(p.length, 1);
473
+ assert.match(p[0], /20261025020000_sso_domain_verification\.sql: not produced by the migration generator \(no journal entry added for it; no meta\/20261025020000_snapshot\.json added\)/);
474
+ });
475
+ it("accepts generator output (journal entry + snapshot, also --custom) and dirs without a journal", async () => {
476
+ const d = base();
477
+ put(d, { [`db/${T1}.sql`]: "y;\n", [`db/${T2}.sql`]: "z;\n", "db/meta/_journal.json": journal("20261024000000_init", T1, T2), "db/meta/20261025020000_snapshot.json": "{}\n", "db/meta/20261025020100_snapshot.json": "{}\n", "plain/0001_a.sql": "a;\n" });
478
+ commit(d, "generated");
479
+ assert.deepEqual(await generatorProblems({ git: gitIn(d), base: "main" }), []);
480
+ });
481
+ it("flags an entry without a snapshot, and accepts an engine renumber (P3)", async () => {
482
+ const d = base();
483
+ put(d, { [`db/${T1}.sql`]: "y;\n", "db/meta/_journal.json": journal("20261024000000_init", T1) });
484
+ commit(d, "journal only");
485
+ assert.match((await generatorProblems({ git: gitIn(d), base: "main" }))[0], /\(no meta\/20261025020000_snapshot\.json added\)/);
486
+ const f = fixture();
487
+ renumber(f);
488
+ assert.deepEqual(await generatorProblems({ git: gitIn(f), base: "main" }), []);
489
+ });
490
+ it("the fix prompt and the builder rule forbid naming migrations by hand", () => {
491
+ assert.match(migrationGeneratorPrompt(["db/x.sql: not produced by the migration generator (no journal entry added for it)"]), /drizzle-kit generate --custom[\s\S]*Never name or number migration files by hand/);
492
+ assert.match(builderImplementPrompt("local"), /Never name or number migration files by hand/);
493
+ });
494
+ });
@@ -0,0 +1,77 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ /**
4
+ * #4590: tool state that must never ride a lane branch: the engine's own scratch (.forge-lean/) and tool caches
5
+ * (.eslintcache, *.tsbuildinfo, and a generated next-env.d.ts when the repo ignores it). `info/exclude` only hides untracked
6
+ * files, so a force-added or checkpoint-staged one gets committed and then conflicts in every base merge. This is the
7
+ * deterministic gate: before a push, files the branch ADDED against the merge base are removed from the index (a file the
8
+ * base itself tracks is the repo's own choice and is never touched). Git is injected: `git(args) -> stdout`, throws on failure.
9
+ */
10
+ export const LEAN_SCRATCH = ".forge-lean/";
11
+ const CACHE_BASENAMES = [/^\.eslintcache$/, /\.tsbuildinfo$/];
12
+ const NEXT_ENV = "next-env.d.ts";
13
+
14
+ /** Lines `forgedock init` adds to a repo's .gitignore (next-env.d.ts only for Next.js repos). */
15
+ export const GITIGNORE_LINES = [".forge-lean/", ".eslintcache", "*.tsbuildinfo"];
16
+ export const GITIGNORE_NEXT_LINE = NEXT_ENV;
17
+
18
+ const base = (p) => p.slice(p.lastIndexOf("/") + 1);
19
+ /** Deny-listed path? `nextEnvIgnored`: the repo's own .gitignore ignores next-env.d.ts, so a tracked copy is stray. */
20
+ export function isToolState(path, { nextEnvIgnored = false } = {}) {
21
+ const p = String(path ?? "").replace(/^\.\//, "");
22
+ if (!p) return false;
23
+ if (p.startsWith(LEAN_SCRATCH) || p === LEAN_SCRATCH.slice(0, -1)) return true;
24
+ const b = base(p);
25
+ return CACHE_BASENAMES.some((re) => re.test(b)) || (nextEnvIgnored && b === NEXT_ENV);
26
+ }
27
+ export const denied = (paths, opts) => paths.filter((p) => isToolState(p, opts));
28
+
29
+ const zlist = (out) => String(out ?? "").split("\0").filter(Boolean);
30
+ const quiet = async (git, args) => { try { await git(args); return true; } catch { return false; } };
31
+
32
+ /** The subset of `paths` the repo's .gitignore ignores (`--no-index`: tracked copies count). */
33
+ async function ignoredNextEnv(git, paths) {
34
+ const out = [];
35
+ for (const p of paths) if (base(p) === NEXT_ENV && await quiet(git, ["check-ignore", "-q", "--no-index", "--", p])) out.push(p);
36
+ return out;
37
+ }
38
+
39
+ /** Deny-listed paths among `paths`, including next-env.d.ts copies the repo ignores. */
40
+ export async function strayPaths(git, paths) {
41
+ const plain = denied(paths);
42
+ const nexts = await ignoredNextEnv(git, paths.filter((p) => !plain.includes(p)));
43
+ return [...plain, ...nexts];
44
+ }
45
+
46
+ /** After `git add -A`: unstage deny-listed paths so a checkpoint never commits them. Returns true when something else is staged. */
47
+ export async function unstageToolState(git) {
48
+ const staged = zlist(await git(["diff", "--cached", "--name-only", "-z"]));
49
+ const stray = await strayPaths(git, staged);
50
+ if (stray.length) await git(["reset", "-q", "--", ...stray]);
51
+ return stray.length ? (await git(["diff", "--cached", "--name-only"])).trim() !== "" : staged.length > 0;
52
+ }
53
+
54
+ /**
55
+ * Remove from the index (and commit) the deny-listed files this branch added against `base`. Returns the removed paths ([]
56
+ * when the branch is clean). Never throws: a failure leaves the branch as it was and the push proceeds.
57
+ */
58
+ export async function untrackToolState({ git, base: ref, ev = () => {} }) {
59
+ try {
60
+ const added = zlist(await git(["diff", "--diff-filter=A", "--name-only", "-z", `${ref}...HEAD`]));
61
+ const stray = await strayPaths(git, added);
62
+ if (!stray.length) return [];
63
+ await git(["rm", "--cached", "-r", "-q", "--ignore-unmatch", "--", ...stray]);
64
+ await git(["commit", "--no-verify", "-m", "chore: untrack lane tool state (#4590)", "-m", stray.slice(0, 20).join("\n")]);
65
+ ev({ event: "TOOLSTATE_UNTRACKED", files: stray.slice(0, 20), count: stray.length });
66
+ return stray;
67
+ } catch (e) {
68
+ ev({ event: "TOOLSTATE_UNTRACK_FAILED", error: String(e?.message ?? e).slice(0, 200) });
69
+ return [];
70
+ }
71
+ }
72
+
73
+ /** The deny-list lines a repo's .gitignore text lacks (append-only; `next`: also next-env.d.ts, for Next.js repos). */
74
+ export function gitignoreAdditions(text, { next = false } = {}) {
75
+ const have = new Set(String(text ?? "").split(/\r?\n/).map((l) => l.trim().replace(/^\//, "")));
76
+ return [...GITIGNORE_LINES, ...(next ? [GITIGNORE_NEXT_LINE] : [])].filter((l) => !have.has(l));
77
+ }
@@ -0,0 +1,83 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ import { describe, it } from "node:test";
4
+ import assert from "node:assert/strict";
5
+ import { execFileSync } from "node:child_process";
6
+ import { mkdirSync, writeFileSync } from "node:fs";
7
+ import { join, dirname } from "node:path";
8
+ import { tmpDir } from "../tests/_tmp.mjs";
9
+ import { isToolState, denied, untrackToolState, unstageToolState, gitignoreAdditions } from "./toolstate.mjs";
10
+
11
+ const sh = (cwd, ...a) => execFileSync("git", a, { cwd, encoding: "utf8" });
12
+ const put = (cwd, f, t = "x\n") => { mkdirSync(dirname(join(cwd, f)), { recursive: true }); writeFileSync(join(cwd, f), t); };
13
+ const repo = () => {
14
+ const d = tmpDir("toolstate-repo-");
15
+ sh(d, "init", "-q", "-b", "main"); sh(d, "config", "user.email", "t@t"); sh(d, "config", "user.name", "t");
16
+ put(d, "src/a.js"); put(d, ".gitignore", "next-env.d.ts\n"); sh(d, "add", "-A"); sh(d, "commit", "-q", "-m", "base");
17
+ sh(d, "checkout", "-q", "-b", "lane");
18
+ return d;
19
+ };
20
+ const gitIn = (d) => async (args) => execFileSync("git", args, { cwd: d, encoding: "utf8" });
21
+
22
+ describe("isToolState (#4590)", () => {
23
+ it("matches scratch and caches at any depth, nothing else", () => {
24
+ for (const p of [".forge-lean/result.json", ".forge-lean/logs/x", "apps/worker/.eslintcache", ".eslintcache", "a/b/tsconfig.tsbuildinfo"]) assert.ok(isToolState(p), p);
25
+ for (const p of ["src/eslintcache.ts", "src/.eslintcache.md", "forge-lean/x", "docs/.forge-lean.md", "apps/web/next-env.d.ts", "x.tsbuildinfo.json"]) assert.ok(!isToolState(p), p);
26
+ });
27
+ it("next-env.d.ts only when the repo ignores it", () => {
28
+ assert.ok(isToolState("apps/web/next-env.d.ts", { nextEnvIgnored: true }));
29
+ assert.deepEqual(denied(["a", ".eslintcache"]), [".eslintcache"]);
30
+ });
31
+ });
32
+
33
+ describe("untrackToolState on a real repo (#4590)", () => {
34
+ it("removes what the lane added, keeps product files and files the base tracks, then a second run is a no-op", async () => {
35
+ const d = repo();
36
+ put(d, "src/b.js"); put(d, ".forge-lean/result.json"); put(d, "apps/worker/.eslintcache"); put(d, "apps/web/next-env.d.ts");
37
+ sh(d, "add", "-f", "-A");
38
+ sh(d, "commit", "-q", "-m", "lane");
39
+ const events = [];
40
+ const removed = await untrackToolState({ git: gitIn(d), base: "main", ev: (e) => events.push(e) });
41
+ assert.deepEqual(removed.sort(), [".forge-lean/result.json", "apps/web/next-env.d.ts", "apps/worker/.eslintcache"]);
42
+ assert.equal(events[0].event, "TOOLSTATE_UNTRACKED");
43
+ assert.deepEqual(sh(d, "diff", "--name-only", "main...HEAD").trim().split("\n"), ["src/b.js"]);
44
+ assert.ok(sh(d, "status", "--porcelain").includes("?? .forge-lean/") || sh(d, "ls-files", "--others").includes(".forge-lean"), "files stay on disk");
45
+ assert.deepEqual(await untrackToolState({ git: gitIn(d), base: "main" }), []);
46
+ });
47
+
48
+ it("a cache the base already tracks is the repo's own choice and stays", async () => {
49
+ const d = repo(); sh(d, "checkout", "-q", "main");
50
+ put(d, "pkg/.eslintcache"); sh(d, "add", "-A"); sh(d, "commit", "-q", "-m", "base cache"); sh(d, "checkout", "-q", "lane"); sh(d, "merge", "-q", "main");
51
+ put(d, "src/c.js"); sh(d, "add", "-A"); sh(d, "commit", "-q", "-m", "lane");
52
+ assert.deepEqual(await untrackToolState({ git: gitIn(d), base: "main" }), []);
53
+ assert.ok(sh(d, "ls-files").includes("pkg/.eslintcache"));
54
+ });
55
+
56
+ it("a git failure never throws", async () => {
57
+ const events = [];
58
+ assert.deepEqual(await untrackToolState({ git: async () => { throw new Error("boom"); }, base: "main", ev: (e) => events.push(e) }), []);
59
+ assert.equal(events[0].event, "TOOLSTATE_UNTRACK_FAILED");
60
+ });
61
+ });
62
+
63
+ describe("unstageToolState (#4590)", () => {
64
+ it("a checkpoint's add -A stages the work but not the scratch or caches", async () => {
65
+ const d = repo(); put(d, "src/b.js"); put(d, ".eslintcache"); put(d, ".forge-lean/x");
66
+ sh(d, "add", "-A", "-f");
67
+ assert.equal(await unstageToolState(gitIn(d)), true);
68
+ assert.equal(sh(d, "diff", "--cached", "--name-only").trim(), "src/b.js");
69
+ });
70
+ it("only tool state staged: nothing to commit", async () => {
71
+ const d = repo(); put(d, ".eslintcache"); sh(d, "add", "-f", ".eslintcache");
72
+ assert.equal(await unstageToolState(gitIn(d)), false);
73
+ });
74
+ });
75
+
76
+ describe("gitignoreAdditions (#4590)", () => {
77
+ it("adds only the missing lines, and next-env.d.ts only for Next.js", () => {
78
+ assert.deepEqual(gitignoreAdditions(""), [".forge-lean/", ".eslintcache", "*.tsbuildinfo"]);
79
+ assert.deepEqual(gitignoreAdditions("node_modules\n/.eslintcache\n.forge-lean/\n"), ["*.tsbuildinfo"]);
80
+ assert.deepEqual(gitignoreAdditions(".forge-lean/\n.eslintcache\n*.tsbuildinfo\n"), []);
81
+ assert.deepEqual(gitignoreAdditions(".forge-lean/\n.eslintcache\n*.tsbuildinfo\n", { next: true }), ["next-env.d.ts"]);
82
+ });
83
+ });
@@ -33,7 +33,8 @@ const OK = ["success", "neutral", "skipped"];
33
33
  * maxRounds, precheckTimeoutSeconds, precheckTotalSeconds, checkRun, checks, postMerge, duplicateExports, e2e}. Invalid values fall back to the defaults; mode defaults to local (today's
34
34
  * behaviour); checkRun (`check_run`, #4270) is true only for the literal `true`; checks (#4387) is the optional allow-list
35
35
  * of check names that gate CI verification, or null; e2e is the e2e merge gate (#4403, e2e.mjs parseE2e). #4406: postMerge (`post_merge`) is the command run on
36
- * the merged tree at base-sync before the push (null: the lane's scoped typecheck); duplicateExports is false only for the literal `false`.
36
+ * the merged tree at base-sync before the push (null: the lane's scoped typecheck); duplicateExports is false only for the literal `false`. #4585: invariants (`invariants`, a block list read by engine-cli
37
+ * readVerify) are repo-wide check commands that run in every pre-PR round whichever tests were selected; [] when none.
37
38
  */
38
39
  export function parseVerify(sec = {}) {
39
40
  const s = sec && typeof sec === "object" ? sec : {};
@@ -54,8 +55,12 @@ export function parseVerify(sec = {}) {
54
55
  const names = rawChecks.map((x) => String(x ?? "").trim()).filter(Boolean);
55
56
  const checks = names.length ? [...new Set(names)] : null;
56
57
  const postMerge = str(s.post_merge) || null;
58
+ // a flow list (`[a, "b c"]`) arrives as one scalar from the one-level reader: strip the brackets and quotes
59
+ const rawFlaky = Array.isArray(s.flaky) ? s.flaky : str(s.flaky).trim().replace(/^\[(.*)\]$/s, "$1").split(",");
60
+ const flaky = [...new Set(rawFlaky.map((x) => String(x ?? "").trim().replace(/^(["'])(.*)\1$/s, "$2").trim()).filter(Boolean))];
57
61
  const duplicateExports = str(s.duplicate_exports).toLowerCase() !== "false";
58
- return { mode, workflow, pathsInput, skipPaths, maxRounds, precheckTimeoutSeconds, precheckTotalSeconds, checkRun, checks, postMerge, duplicateExports, e2e: parseE2e(s) };
62
+ const invariants = [...new Set((Array.isArray(s.invariants) ? s.invariants : []).map((x) => String(x ?? "").trim()).filter(Boolean))];
63
+ return { mode, workflow, pathsInput, skipPaths, maxRounds, precheckTimeoutSeconds, precheckTotalSeconds, checkRun, checks, postMerge, flaky, duplicateExports, invariants, e2e: parseE2e(s) };
59
64
  }
60
65
 
61
66
  const globRe = (g) => new RegExp(`^${g.split("**").map((p) => p.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*").replace(/\?/g, "[^/]")).join(".*")}$`);
@@ -91,6 +96,17 @@ export async function collectFailedLogs(io, failedChecks) {
91
96
  }
92
97
  return capLogs(out);
93
98
  }
99
+ /**
100
+ * #4586: split failures [{job, log}] by `verify.flaky` ids: a failure is flaky when its job name equals an id. Flaky
101
+ * failures are reported only; `own` is what may block or reach the builder. Never by log text: a job whose log names a
102
+ * flaky test can also hold a real failure, and an Actions run attaches its failed logs to its first failed check (#4614).
103
+ */
104
+ export function splitFlaky(failures, flaky = []) {
105
+ const ids = (flaky ?? []).filter(Boolean);
106
+ const own = [], known = [];
107
+ for (const f of failures ?? []) (ids.includes(f?.job) ? known : own).push(f);
108
+ return { own, flaky: known };
109
+ }
94
110
  const RANK = { failure: 2, pending: 1, success: 0 };
95
111
  /**
96
112
  * #4387: one check per name at its latest attempt: the highest check-run id (ids grow monotonically, so a rerun or a later
@@ -2,7 +2,7 @@
2
2
  // SPDX-License-Identifier: AGPL-3.0-or-later
3
3
  import { describe, it } from "node:test";
4
4
  import assert from "node:assert/strict";
5
- import { parseVerify, matchesGlob, skipsCi, capLogs, runCiVerify, collectFailedLogs, verifyReport, DEFAULT_SKIP_PATHS, LOG_CAP, VERIFY_CHECK_NAME, VERIFY_TEXT_CAP, latestPerName, ciGate, rerunFailedOnce, rerunTarget } from "./verify.mjs";
5
+ import { splitFlaky, parseVerify, matchesGlob, skipsCi, capLogs, runCiVerify, collectFailedLogs, verifyReport, DEFAULT_SKIP_PATHS, LOG_CAP, VERIFY_CHECK_NAME, VERIFY_TEXT_CAP, latestPerName, ciGate, rerunFailedOnce, rerunTarget } from "./verify.mjs";
6
6
  import { precheck } from "./conformance.mjs";
7
7
  import { parseE2e } from "./e2e.mjs";
8
8
 
@@ -23,9 +23,9 @@ function fake(o = {}) {
23
23
 
24
24
  describe("verify-by-CI config (#4167)", () => {
25
25
  it("parseVerify defaults to local and rejects invalid values", () => {
26
- assert.deepEqual(parseVerify({}), { mode: "local", workflow: null, pathsInput: null, skipPaths: [...DEFAULT_SKIP_PATHS], maxRounds: 2, precheckTimeoutSeconds: 300, precheckTotalSeconds: 600, checkRun: false, checks: null, postMerge: null, duplicateExports: true, e2e: parseE2e({}) });
26
+ assert.deepEqual(parseVerify({}), { mode: "local", workflow: null, pathsInput: null, skipPaths: [...DEFAULT_SKIP_PATHS], maxRounds: 2, precheckTimeoutSeconds: 300, precheckTotalSeconds: 600, checkRun: false, checks: null, postMerge: null, flaky: [], duplicateExports: true, invariants: [], e2e: parseE2e({}) });
27
27
  const v = parseVerify({ mode: "ci", workflow: ".github/workflows/verify.yml", paths_input: "changed_paths", skip_paths: "*.md, docs/** ,", max_rounds: "3" });
28
- assert.deepEqual(v, { mode: "ci", workflow: ".github/workflows/verify.yml", pathsInput: "changed_paths", skipPaths: ["*.md", "docs/**"], maxRounds: 3, precheckTimeoutSeconds: 300, precheckTotalSeconds: 600, checkRun: false, checks: null, postMerge: null, duplicateExports: true, e2e: parseE2e({}) });
28
+ assert.deepEqual(v, { mode: "ci", workflow: ".github/workflows/verify.yml", pathsInput: "changed_paths", skipPaths: ["*.md", "docs/**"], maxRounds: 3, precheckTimeoutSeconds: 300, precheckTotalSeconds: 600, checkRun: false, checks: null, postMerge: null, flaky: [], duplicateExports: true, invariants: [], e2e: parseE2e({}) });
29
29
  assert.equal(parseVerify({ mode: "remote" }).mode, "local");
30
30
  assert.equal(parseVerify({ workflow: "x; rm -rf /" }).workflow, null);
31
31
  assert.equal(parseVerify({ max_rounds: "-1" }).maxRounds, 2);
@@ -64,6 +64,14 @@ describe("verify-by-CI config (#4167)", () => {
64
64
  });
65
65
  });
66
66
 
67
+ describe("verify.invariants (#4585)", () => {
68
+ it("parseVerify keeps a trimmed, unique, non-empty invariants list; default []", () => {
69
+ assert.deepEqual(parseVerify({}).invariants, []);
70
+ assert.deepEqual(parseVerify({ invariants: [" a ", "a", "", "b c"] }).invariants, ["a", "b c"]);
71
+ assert.deepEqual(parseVerify({ invariants: "a,b" }).invariants, [], "only a block list counts");
72
+ });
73
+ });
74
+
67
75
  describe("runCiVerify (#4167)", () => {
68
76
  it("docs-only: skipped without a push or any CI call", async () => {
69
77
  const f = fake();
@@ -159,6 +167,24 @@ describe("CI verification gate, latest attempt and flake rerun (#4387)", () => {
159
167
  });
160
168
  });
161
169
 
170
+ describe("verify.flaky (#4586)", () => {
171
+ it("parses an array or comma list, trimmed and deduped; default empty", () => {
172
+ assert.deepEqual(parseVerify({}).flaky, []);
173
+ assert.deepEqual(parseVerify({ flaky: " a, b ,a," }).flaky, ["a", "b"]);
174
+ assert.deepEqual(parseVerify({ flaky: ["x", " y"] }).flaky, ["x", "y"]);
175
+ });
176
+ it("parses a flow list scalar, quotes stripped", () => {
177
+ assert.deepEqual(parseVerify({ flaky: "[a, \"b c\", 'd']" }).flaky, ["a", "b c", "d"]);
178
+ });
179
+ it("splitFlaky matches by exact job name only, never log text (#4614)", () => {
180
+ const fs = [{ job: "memory-erasure", log: "" }, { job: "test", log: "FAIL pdf timing\nFAIL real regression" }, { job: "lint", log: "boom" }];
181
+ const r = splitFlaky(fs, ["memory-erasure", "pdf timing"]);
182
+ assert.deepEqual(r.flaky.map((f) => f.job), ["memory-erasure"]);
183
+ assert.deepEqual(r.own.map((f) => f.job), ["test", "lint"]);
184
+ assert.equal(splitFlaky(fs, []).own.length, 3);
185
+ });
186
+ });
187
+
162
188
  describe("merge-time fix rounds and declared check commands (#4240)", () => {
163
189
  it("collectFailedLogs fetches one log per Actions run and caps the total", async () => {
164
190
  const fetched = [];