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.
- package/bin/engine-cli.mjs +72 -12
- package/bin/forgedock.mjs +19 -1
- package/bin/hooks/reread-nudge.mjs +33 -5
- package/bin/lean/batch.mjs +24 -1
- package/bin/lean/batch.test.mjs +75 -0
- package/bin/lean/budget.mjs +14 -3
- package/bin/lean/budget.test.mjs +11 -1
- package/bin/lean/conflictres.mjs +93 -0
- package/bin/lean/conflictres.test.mjs +93 -0
- package/bin/lean/conformance.mjs +39 -6
- package/bin/lean/conformance.test.mjs +46 -0
- package/bin/lean/effects.mjs +11 -3
- package/bin/lean/lane.mjs +13 -0
- package/bin/lean/lean.mjs +111 -33
- package/bin/lean/lean.test.mjs +197 -5
- package/bin/lean/orchestrate.mjs +81 -8
- package/bin/lean/precheck.mjs +56 -8
- package/bin/lean/precheck.test.mjs +62 -3
- package/bin/lean/prompts.mjs +28 -6
- package/bin/lean/schedd.mjs +10 -1
- package/bin/lean/schedd.test.mjs +30 -0
- package/bin/lean/sequence.mjs +26 -0
- package/bin/lean/sequence.test.mjs +41 -2
- package/bin/lean/toolstate.mjs +77 -0
- package/bin/lean/toolstate.test.mjs +83 -0
- package/bin/lean/verify.mjs +18 -2
- package/bin/lean/verify.test.mjs +29 -3
- package/bin/review-staging.mjs +14 -2
- package/bin/stats/efficiency.mjs +103 -17
- package/bin/stats/efficiency.test.mjs +103 -4
- package/bin/stats/index.mjs +27 -5
- package/bin/stats/pool.mjs +2 -2
- package/commands/review-pr-agents/protocols.md +1 -1
- package/commands/review-pr-staging.md +1 -1
- package/docs/CONFIG.md +12 -2
- package/docs/STATS.md +29 -1
- package/package.json +1 -1
- package/scripts/review-finalize.sh +17 -1
- package/scripts/review-finalize.test.sh +41 -0
- package/scripts/review-pr-domain-map.test.sh +2 -2
- package/scripts/review-pr-staging-scope.test.sh +4 -2
- package/scripts/review-prepare.sh +62 -27
- package/scripts/review-prepare.test.sh +53 -0
- package/scripts/review-staging-pre.sh +29 -9
- 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
|
+
});
|
package/bin/lean/prompts.mjs
CHANGED
|
@@ -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
|
|
175
|
-
- {"kind":"add-deliverable","path":"<file>","why":""}: a file
|
|
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
|
|
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
|
-
|
|
180
|
-
|
|
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,
|
|
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.
|
package/bin/lean/schedd.mjs
CHANGED
|
@@ -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}
|
|
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) {
|
package/bin/lean/schedd.test.mjs
CHANGED
|
@@ -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 {
|
package/bin/lean/sequence.mjs
CHANGED
|
@@ -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
|
+
});
|
package/bin/lean/verify.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
package/bin/lean/verify.test.mjs
CHANGED
|
@@ -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 = [];
|