forgedock 1.29.1 → 1.30.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 (38) hide show
  1. package/bin/engine/outcome-codes.mjs +4 -2
  2. package/bin/engine/phases.mjs +20 -3
  3. package/bin/engine-cli.mjs +94 -36
  4. package/bin/engine.mjs +3 -1
  5. package/bin/lean/batch.test.mjs +62 -1
  6. package/bin/lean/conformance.mjs +129 -8
  7. package/bin/lean/conformance.test.mjs +77 -3
  8. package/bin/lean/context.mjs +17 -12
  9. package/bin/lean/context.test.mjs +36 -5
  10. package/bin/lean/effects.mjs +20 -6
  11. package/bin/lean/enumerating.mjs +189 -0
  12. package/bin/lean/enumerating.test.mjs +97 -0
  13. package/bin/lean/lean.mjs +189 -43
  14. package/bin/lean/lean.test.mjs +305 -6
  15. package/bin/lean/orchestrate.mjs +104 -85
  16. package/bin/lean/precheck.mjs +55 -0
  17. package/bin/lean/precheck.test.mjs +93 -0
  18. package/bin/lean/prefilter.mjs +21 -4
  19. package/bin/lean/prefilter.test.mjs +24 -1
  20. package/bin/lean/preflight.mjs +133 -0
  21. package/bin/lean/preflight.test.mjs +61 -0
  22. package/bin/lean/prompts.mjs +37 -3
  23. package/bin/lean/recovery.test.mjs +75 -0
  24. package/bin/lean/sequence.mjs +102 -0
  25. package/bin/lean/sequence.test.mjs +112 -0
  26. package/bin/lean/testmap.mjs +43 -6
  27. package/bin/lean/testmap.test.mjs +38 -1
  28. package/bin/lean/verify.mjs +7 -2
  29. package/bin/lean/verify.test.mjs +8 -2
  30. package/commands/orchestrate.md +3 -1
  31. package/docs/CONFIG.md +8 -1
  32. package/docs/OUTCOME-CODES.md +6 -4
  33. package/docs/RELEASE-NOTES.md +10 -0
  34. package/package.json +1 -1
  35. package/scripts/acceptance-red-green.sh +13 -4
  36. package/scripts/acceptance-red-green.test.sh +14 -0
  37. package/scripts/test-map.sh +63 -17
  38. package/scripts/test-map.test.sh +55 -0
@@ -0,0 +1,61 @@
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 { mkdirSync, writeFileSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { shellSegments, pythonImports, nodeImports, preflightProbes, preflightProblems } from "./preflight.mjs";
8
+ import { runCheckCommand } from "./effects.mjs";
9
+ import { repoTracks } from "./conformance.mjs";
10
+ import { tmpDir } from "../tests/_tmp.mjs";
11
+
12
+ const C = (...commands) => ({ deliverables: [], validate: commands.map((command, i) => ({ id: `V${i + 1}`, command })) });
13
+ const needs = (c, o) => preflightProbes(c, o).probes.map((p) => [p.kind, p.tool, p.interp ?? null, p.ids.join(",")]);
14
+
15
+ describe("#4328 validate preflight: what a command needs", () => {
16
+ it("splits on && || | ; and newlines, keeps quotes and $(...) whole", () => {
17
+ assert.deepEqual(shellSegments(`cd a && python3 -c "import yaml; print('x | y')" | tee "$TMPDIR/o" ; git diff $(git merge-base HEAD origin/x) -- a || true`),
18
+ [["cd", "a"], ["python3", "-c", "import yaml; print('x | y')"], ["tee", "$TMPDIR/o"], ["git", "diff", "$(git merge-base HEAD origin/x)", "--", "a"], ["true"]]);
19
+ });
20
+ it("python -c imports and -m modules (top-level, absolute only); node -e bare packages", () => {
21
+ assert.deepEqual(pythonImports("import yaml, os.path as p\nfrom jsonschema.validators import x; from . import y; from __future__ import annotations; __import__('toml')"), ["yaml", "os", "jsonschema", "toml"]);
22
+ assert.deepEqual(nodeImports(`const z=require("zod"); import("@scope/pkg/sub"); import fs from "node:fs"; require("./x"); require("path")`), ["zod", "@scope/pkg"]);
23
+ });
24
+ it("probes each bare executable, a bare python's modules and node -e packages, deduped across validate ids", () => {
25
+ assert.deepEqual(needs(C(`FOO=1 python3 -c "import yaml,json"`, "python3 -m pytest -q tests/x.py && jq . a.json", `node -e "require('zod')"`, "python3 -c 'import yaml'")), [
26
+ ["command", "python3", null, "V1,V2,V4"], ["python-module", "yaml", "python3", "V1,V4"], ["python-module", "json", "python3", "V1"],
27
+ ["python-module", "pytest", "python3", "V2"], ["command", "jq", null, "V2"], ["command", "node", null, "V3"], ["node-module", "zod", "node", "V3"]]);
28
+ });
29
+ it("wrappers and paths: only the wrapper is probed; ./script and .venv/bin/python and $VAR never are", () => {
30
+ assert.deepEqual(needs(C(`uv run python -c "import yaml"`, "./scripts/check.sh", `.venv/bin/python -c "import yaml"`, "$PY -m x", "npx vitest run a.test.ts")),
31
+ [["command", "uv", null, "V1"], ["command", "npx", null, "V5"]]);
32
+ });
33
+ it("skips names the repo tracks and names a deliverable's change adds", () => {
34
+ const c = { ...C(`python3 -c "import app, yaml"`, `node -e "require('left-pad')"`), deliverables: [{ id: "D1", path: "package.json", change: "add devDependency left-pad for the check" }] };
35
+ assert.deepEqual(needs(c, { repoHas: repoTracks(["app/__init__.py"]) }).map((x) => x[1]), ["python3", "yaml", "node"]);
36
+ });
37
+ it("caps the probes and reports the dropped count", () => {
38
+ const r = preflightProbes(C(Array.from({ length: 25 }, (_, i) => `tool${i}`).join(" && ")), { cap: 20 });
39
+ assert.equal(r.probes.length, 20); assert.equal(r.dropped, 5);
40
+ });
41
+ it("a problem line names the ids, the kind and the tool and forbids a global install", () => {
42
+ const [p] = preflightProblems([{ ids: ["V5"], tool: "yaml", kind: "python-module", interp: "python3" }]);
43
+ assert.match(p, /^V5: needs Python module 'yaml' \(for python3\), which this host lacks/);
44
+ assert.match(p, /repo's own toolchain .*never a global install/);
45
+ });
46
+ it("the probes are real read-only shell checks: present tools pass, absent ones fail", async () => {
47
+ const dir = tmpDir("preflight-");
48
+ mkdirSync(join(dir, "node_modules", "here"), { recursive: true }); writeFileSync(join(dir, "node_modules", "here", "package.json"), "{}");
49
+ mkdirSync(join(dir, "sub"));
50
+ const probes = preflightProbes(C("bash -c true", "no-such-tool-4328 --x", `node -e "require('here'); require('fs'); require('absent-pkg-4328')"`)).probes;
51
+ const rc = {};
52
+ for (const p of probes) rc[`${p.kind}:${p.tool}`] = (await runCheckCommand({ command: p.probe, cwd: join(dir, "sub"), timeoutMs: 15000 })).rc;
53
+ assert.equal(rc["command:bash"], 0); assert.notEqual(rc["command:no-such-tool-4328"], 0);
54
+ assert.equal(rc["command:node"], 0); assert.equal(rc["node-module:here"], 0, "found in an ancestor node_modules");
55
+ assert.notEqual(rc["node-module:absent-pkg-4328"], 0);
56
+ const py = preflightProbes(C(`python3 -c "import json, no_such_mod_4328"`)).probes.filter((p) => p.kind === "python-module");
57
+ if ((await runCheckCommand({ command: "command -v python3", cwd: dir })).rc === 0) {
58
+ assert.deepEqual(await Promise.all(py.map(async (p) => (await runCheckCommand({ command: p.probe, cwd: dir })).rc !== 0)), [false, true]);
59
+ }
60
+ });
61
+ });
@@ -85,6 +85,7 @@ If the issue is already fixed or invalid, write ${RESULT_FILE} {"status":"alread
85
85
  "validate":[{"id":"V1","command":"<exact command: the new regression test plus the existing tests that reference the files you change; a git diff compares against the merge base (origin/<lane>...HEAD), never the moving origin/<lane> tip>"}],
86
86
  "decisions":[{"id":"K1","choice":"<a non-obvious choice: a new dependency, a copied pattern, a boundary relied on>","justification":"<why, with file:line>","source":"doc|lint|precedent|spec"}]}
87
87
  Every requirement and acceptance criterion in the issue and its maintainers' comments is one requirement, quoted verbatim (others' comments are context, not requirements). Never drop or narrow one silently: "defer" (separate work) and "post-deploy" (only checkable after a deploy) are judged by the reviewer and, if accepted, filed by the engine as a tracked follow-up issue. A requirement you can do in this change is "deliver". List every file you will change as a deliverable; tests are always allowed.
88
+ A validate or repro command must not read a $TMPDIR file it does not create itself: each run gets a fresh TMPDIR.
88
89
  A new dependency between workspace packages (a workspace package added to a package.json dependencies, devDependencies or peerDependencies) must be a deliverable on that package.json whose change names the package and gives the reason, citing the architecture doc where there is one; "the lint allows it" or "there is a precedent" is not a reason on its own. Record each non-obvious choice in "decisions" with where its justification comes from (doc, lint, precedent or spec); omit the field when there is none. Then stop.
89
90
 
90
91
  ${BLOCK_RULES}`;
@@ -93,7 +94,7 @@ ${BLOCK_RULES}`;
93
94
  /** Builder call 2, same session: implement the frozen contract. */
94
95
  export function builderImplementPrompt(mode = "local", { toolchain = null, sparse = null, facts = null } = {}) {
95
96
  const remote = mode === "ci" || mode === "hybrid";
96
- return `The engine froze ${CONTRACT_FILE}; it is now the authority. Implement it exactly: change only deliverable paths (tests are always allowed), keep every preserve item intact, add a regression test, ${remote ? VERIFY_RUN[mode] : "run every validate command"}, and commit. Do not push or touch GitHub.
97
+ return `The engine froze ${CONTRACT_FILE}; it is now the authority. Implement it exactly: change only deliverable paths (tests are always allowed), keep every preserve item intact, add a regression test, ${remote ? VERIFY_RUN[mode] : "run every validate command"}, run the repo's formatter on the files you changed, and commit. Do not push or touch GitHub.
97
98
 
98
99
  ${HOST_RULES}${toolchainRule(toolchain)}${sparseRule(sparse)}${factsRule(facts)}${remote ? `\n\n${VERIFY_RULES[mode]}` : ""}
99
100
 
@@ -147,6 +148,39 @@ export function hotspotBuildRule(facts) {
147
148
  return `\n\nDon't grow a file over ${facts.file_lines} lines by more than ${facts.growth_lines} lines. Put new code next to it.`;
148
149
  }
149
150
 
151
+ /** #4322: tests that enumerate the siblings of a module the issue names (bin/lean/enumerating.mjs), for the plan; "" when none. */
152
+ export function enumeratingPlanNote(list) {
153
+ if (!Array.isArray(list) || !list.length) return "";
154
+ 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")}`;
155
+ }
156
+
157
+ 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"`;
158
+
159
+ /** #4322: the builder rule citing the plan's enumerating tests; "" when none. */
160
+ export function enumeratingBuildRule(list) {
161
+ if (!Array.isArray(list) || !list.length) return "";
162
+ return `\n\nEnumerating tests: add every new exported symbol, table, job, action, namespace or file to each test listed below whose list enumerates its siblings, one test-list edit per new symbol, ${NOT_MEMBER}.\n${list.slice(0, 40).map((e) => `- ${e.module} -> ${e.test}:${e.line}`).join("\n")}`;
163
+ }
164
+
165
+ /** #4322: after implement, new symbols that tests enumerating their siblings do not name. */
166
+ export function missingEntriesPrompt(missing, mode = "local") {
167
+ return `These tests enumerate the siblings of symbols or files this change adds, but do not name them (CI will fail on the stale list):\n${missing.slice(0, 30).map((m) => `- ${m.symbol} (${m.module}) -> ${m.test}:${m.line}`).join("\n")}\n\nFor each, add the symbol to that test's list, ${NOT_MEMBER}. ${rerun(mode)}, commit, and update ${IMPLEMENTATION_FILE} and ${RESULT_FILE}. Do not push or touch GitHub.`;
168
+ }
169
+
170
+ const RENUMBER_RULE = "Renumber the lane's files, never the base's: rename each with git mv to its new number, keep the SQL (or other migration source) byte-identical, regenerate the snapshot and journal with the repo's own migration generator (never hand- or text-merge the journal), keep journal entries strictly ordered, and update every reference to the old name.";
171
+
172
+ /** #4322: lane-added sequence-numbered files whose number the base already uses, at hand-off or after a base-sync. */
173
+ export function sequencePrompt(collisions, lane, mode = "local") {
174
+ const rows = collisions.slice(0, 30).map((c) => `- ${c.lane} -> number ${c.next}${c.base.length ? ` (base has ${c.base.join(", ")})` : " (moves to keep the order)"}`).join("\n");
175
+ return `origin/${lane} already uses sequence numbers this change adds:\n${rows}\n\nFirst bring the branch up to date with \`git merge --no-edit origin/${lane}\` if it is not (already fetched; never rebase or force). ${RENUMBER_RULE} ${rerun(mode)}, commit, and update ${IMPLEMENTATION_FILE} and ${RESULT_FILE}. Do not push or touch GitHub.`;
176
+ }
177
+
178
+ /** #4322: the base-sync conflict addition when a conflicted file is a numbered migration or a journal; "" otherwise. */
179
+ export function sequenceSyncNote(files) {
180
+ const hit = (files ?? []).some((f) => /(?:^|\/)\d{3,}[_-][^/]*$|(?:^|\/)_journal\.json$/.test(String(f)));
181
+ return hit ? ` A numbered migration or its journal conflicts: do not text-merge it. ${RENUMBER_RULE}` : "";
182
+ }
183
+
150
184
  export function contractCorrectionPrompt(problems) {
151
185
  return `Your ${CONTRACT_FILE} is not valid:\n${problems.slice(0, 20).map((p) => `- ${p}`).join("\n")}\n\nFix the contract (planning only, no code, no commit) and stop. Do not push or touch GitHub.`;
152
186
  }
@@ -207,7 +241,7 @@ export const REVIEWER_SYSTEM = `You are an independent conformance reviewer of o
207
241
 
208
242
  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):
209
243
  - 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.
210
- - requirements: one row per contract requirement id: implemented|partial|missing|deferred|post-deploy|not-applicable, with repo-relative path:line evidence (e.g. src/app/route.ts: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.
244
+ - 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.
211
245
  - 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.
212
246
  - preserve: one row per preserve id: intact|violated|unverified, with path:line. The skeleton starts each at "unverified"; set intact only after checking it.
213
247
  - regressions: concrete breakages only, each with path:line.
@@ -257,7 +291,7 @@ export function reviewerPrompt({ issue, comments = [], contract, result, files =
257
291
  * for, with the exact problems and the reviewer's previous reply; no diff is re-sent (the worktree still holds the change).
258
292
  */
259
293
  export function reviewerRepairPrompt({ missing = [], problems = [], rows = null }) {
260
- return `Your conformance review is incomplete. The engine keeps your other rows; answer ONLY for these ids or sections: ${missing.join(", ") || "(see the problems)"}.\n\n<previous_reply_problems>\nYour previous reply was rejected as incomplete. Fix exactly these:\n${problems.slice(0, 60).map((p) => `- ${String(p).slice(0, 300)}`).join("\n")}\n</previous_reply_problems>\n\n<previous_reply>\n${cap(JSON.stringify(rows ?? {}), 20000)}\n</previous_reply>\n\nRead the changed files in your worktree as needed. Output ONLY a JSON object in a json code fence with just the rows for those ids (requirements rows by id, preserve rows by id, scope rows by file, issue_coverage in full if it is listed) plus "verdict":"CONFORMS|NONCONFORMING" consistent with all rows. Evidence is repo-relative path:line.`;
294
+ return `Your conformance review is incomplete. The engine keeps your other rows; answer ONLY for these ids or sections: ${missing.join(", ") || "(see the problems)"}.\n\n<previous_reply_problems>\nYour previous reply was rejected as incomplete. Fix exactly these:\n${problems.slice(0, 60).map((p) => `- ${String(p).slice(0, 300)}`).join("\n")}\n</previous_reply_problems>\n\n<previous_reply>\n${cap(JSON.stringify(rows ?? {}), 20000)}\n</previous_reply>\n\nRead the changed files in your worktree as needed. Output ONLY a JSON object in a json code fence with just the rows for those ids (requirements rows by id, preserve rows by id, scope rows by file, issue_coverage in full if it is listed) plus "verdict":"CONFORMS|NONCONFORMING" consistent with all rows. Evidence is a backtick-quoted repo-relative path:line copied verbatim (e.g. \`apps/web/app/(console)/layout.tsx:12\`); for an evidence path reported as not existing, give the full repo-relative path of that file from the changed files or the repository.`;
261
295
  }
262
296
 
263
297
  /** Pre-filter (#4123): one read-only, single-turn check of an issue a cheap signal flagged as possibly already fixed. */
@@ -417,3 +417,78 @@ describe("#4316 one shared recovery evidence text", () => {
417
417
  }
418
418
  });
419
419
  });
420
+
421
+ describe("#4318: the batch-exit relabel survives a throw and a signal; relabel results; full non-final reason", () => {
422
+ const LONG = `ci.red: line one ${"x".repeat(400)}\nsecond line END-OF-REASON`;
423
+ const NH = (code, detail) => ({ terminalReason: "needs-human", code, detail });
424
+ // issue 1 ends ci.red; attempt 1/2 is queued, and `onRecovering` runs when the runner reports it
425
+ const queued = (opts) => {
426
+ const relabels = [], ran = [];
427
+ const p = orchestrate({ issues: [1], parallel: 1, recovery: { maxAttempts: 2 }, readBody: async () => "",
428
+ relabelNeedsHuman: opts.relabel ?? (async (x) => { relabels.push(x); }),
429
+ runOne: async (n, _l, ctx = {}) => { ran.push(ctx.recover ?? null); await new Promise((r) => setImmediate(r)); return NH("ci.red", LONG); },
430
+ ...opts.extra(relabels) });
431
+ return { p, relabels, ran };
432
+ };
433
+
434
+ it("an exception out of the loop relabels the queued recovery once, then propagates", async () => {
435
+ let boom = false;
436
+ const { p, relabels, ran } = queued({ extra: () => ({
437
+ now: () => { if (boom) throw new Error("clock broke"); return 1000; },
438
+ onUpdate: (r) => { if (r.outcome === "recovering") boom = true; } }) });
439
+ await assert.rejects(p, /clock broke/);
440
+ assert.deepEqual(ran, [null], "attempt 1 never launched");
441
+ assert.equal(relabels.length, 1); assert.equal(relabels[0].issue, 1);
442
+ assert.ok(relabels[0].evidence.includes(LONG), "full persisted reason");
443
+ assert.match(relabels[0].evidence, /the batch stopped \(batch failed: clock broke\)/);
444
+ });
445
+
446
+ it("onExit hands the caller the relabel: a signal relabels with its cause, once; the normal exit does not repeat it", async () => {
447
+ let exitFn = null;
448
+ const { p, relabels } = queued({ extra: () => ({
449
+ onExit: (fn) => { exitFn = fn; },
450
+ onUpdate: async (r) => { if (r.outcome === "recovering" && exitFn) { const f = exitFn; exitFn = null; await f("interrupted by SIGINT"); await f("again"); } } }) });
451
+ await p;
452
+ assert.equal(relabels.length, 1, "idempotent");
453
+ assert.match(relabels[0].evidence, /the batch stopped \(interrupted by SIGINT\)/);
454
+ });
455
+
456
+ it("a non-final attempt's relabel carries the full multi-line reason, not 300 chars", async () => {
457
+ const relabels = [];
458
+ await orchestrate({ issues: [1], parallel: 1, recovery: { maxAttempts: 2 }, readBody: async () => "", relabelNeedsHuman: async (x) => { relabels.push(x); },
459
+ runOne: async (_n, _l, ctx = {}) => (ctx.recover ? NH("builder.blocked", LONG) : NH("ci.red", "red")) });
460
+ assert.equal(relabels.length, 1);
461
+ assert.ok(relabels[0].evidence.includes(LONG));
462
+ assert.ok(relabels[0].evidence.includes("END-OF-REASON"));
463
+ });
464
+
465
+ describe("relabel results on the batch-exit path", () => {
466
+ const lost = (relabel) => {
467
+ let state = { v: 1, order: [1], deps: { 1: [] }, pending: [], results: {}, running: { 1: { startedAt: 1, pid: 999 } }, recoveries: { 1: 2 },
468
+ recovering: { 1: { code: "ci.red", attempt: 2, detail: LONG, outcome: "needs-human", result_code: "ci.red", prev_attempt: 1 } },
469
+ breaker: { at: 1, detail: "circuit breaker: tripped" } };
470
+ const store = { load: () => state, save: (s) => { state = s; }, log: () => {} };
471
+ const calls = [];
472
+ const go = () => orchestrate({ issues: [1], store, recovery: { maxAttempts: 2 }, readBody: async () => "", adopt: async () => null,
473
+ relabelNeedsHuman: async (x) => { calls.push(x); return relabel(); }, runOne: async () => ({ terminalReason: "merged" }) });
474
+ return { go, calls, state: () => state };
475
+ };
476
+ it("a failed label add (false / \"failed\" / a throw) is not recorded: the next resume retries it", async () => {
477
+ for (const relabel of [() => false, () => "failed", () => { throw new Error("gh down"); }]) {
478
+ const b = lost(relabel);
479
+ await b.go();
480
+ assert.equal(b.state().relabelled, undefined);
481
+ await b.go();
482
+ assert.equal(b.calls.length, 2, "retried on resume");
483
+ }
484
+ });
485
+ it("\"skipped\" (closed or leased issue) and true are recorded: no retry", async () => {
486
+ for (const relabel of [() => "skipped", () => true]) {
487
+ const b = lost(relabel);
488
+ await b.go(); await b.go();
489
+ assert.deepEqual(b.state().relabelled, [1]);
490
+ assert.equal(b.calls.length, 1);
491
+ }
492
+ });
493
+ });
494
+ });
@@ -0,0 +1,102 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ /**
4
+ * Sequence-numbered artifacts at hand-off (#4322): migrations named `<number>_<name>` (0007_usage.sql, 0007_x.py,
5
+ * meta/0007_snapshot.json) that the lane added with a number the base already uses. Union merge (#3969) cannot help: a
6
+ * duplicate number is a semantic collision and a drizzle-style journal is structured JSON. The engine only detects and
7
+ * verifies; the builder renumbers with the repo's own migration generator. Deterministic (git only), never throws.
8
+ */
9
+ import { basename, dirname, posix } from "node:path";
10
+
11
+ const NUM_RE = /^(\d{3,})[_-]/;
12
+ const SKIP_RE = /(?:^|\/)(?:node_modules|vendor|dist|build)\//;
13
+ const lines = (s) => String(s ?? "").split("\n").map((x) => x.trim()).filter(Boolean);
14
+ const numOf = (p) => NUM_RE.exec(basename(p))?.[1] ?? null;
15
+ const inDir = (dir) => (dir === "." ? [] : [`${dir}/`]);
16
+ const out = (git) => async (args) => { try { return String(await git(args)); } catch { return ""; } };
17
+
18
+ /** Lane-added numbered files (base...HEAD), with their blob ids. */
19
+ async function laneNumbered(git, base) {
20
+ const g = out(git);
21
+ const added = lines(await g(["-c", "core.quotepath=off", "diff", "--name-only", "--diff-filter=A", `${base}...HEAD`])).filter((f) => numOf(f) && !SKIP_RE.test(f));
22
+ const res = [];
23
+ for (const path of added) res.push({ path, blob: (await g(["rev-parse", `HEAD:${path}`])).trim() });
24
+ return res;
25
+ }
26
+
27
+ /**
28
+ * Collisions: in each directory where a lane-added numbered file shares its number with a different base file (another
29
+ * name, or the same path with other content), every lane-added numbered file there must move, in order, above the base's
30
+ * highest number. @returns {Promise<Array<{dir, number, lane, base:string[], next}>>} (base [] = moves only to keep order)
31
+ */
32
+ export async function sequenceCollisions({ git, base }) {
33
+ try {
34
+ const g = out(git);
35
+ const lane = await laneNumbered(git, base);
36
+ const res = [];
37
+ for (const dir of [...new Set(lane.map((f) => dirname(f.path)))]) {
38
+ const mine = lane.filter((f) => dirname(f.path) === dir).sort((a, b) => Number(numOf(a.path)) - Number(numOf(b.path)) || a.path.localeCompare(b.path));
39
+ const theirs = [];
40
+ for (const l of lines(await g(["-c", "core.quotepath=off", "ls-tree", base, ...inDir(dir)]))) {
41
+ const m = /^\d+\s+blob\s+(\S+)\t(.+)$/.exec(l);
42
+ if (m && dirname(m[2]) === dir && numOf(m[2])) theirs.push({ path: m[2], blob: m[1] });
43
+ }
44
+ const clash = (f) => theirs.filter((t) => numOf(t.path) === numOf(f.path) && (t.path !== f.path || t.blob !== f.blob)).map((t) => t.path);
45
+ if (!mine.some((f) => clash(f).length)) continue;
46
+ const width = Math.max(...mine.map((f) => numOf(f.path).length));
47
+ let next = Math.max(0, ...theirs.map((t) => Number(numOf(t.path))));
48
+ for (const f of mine) res.push({ dir, number: numOf(f.path), lane: f.path, base: clash(f), next: String(++next).padStart(width, "0") });
49
+ }
50
+ return res;
51
+ } catch { return []; }
52
+ }
53
+
54
+ /**
55
+ * Drizzle-style journal checks for `dirs` at `ref` (`<dir>/meta/_journal.json`, {entries:[{idx, tag}]}): duplicate idx
56
+ * or tag, idx or tag number not strictly increasing, a numbered .sql file without a tag. No journal: [].
57
+ */
58
+ export async function journalProblems({ git, ref = "HEAD", dirs = [] }) {
59
+ const g = out(git), problems = [];
60
+ for (const dir of [...new Set(dirs)]) {
61
+ const jp = posix.join(dir, "meta", "_journal.json");
62
+ const text = await g(["show", `${ref}:${jp}`]);
63
+ if (!text.trim()) continue;
64
+ let entries;
65
+ try { entries = JSON.parse(text).entries; } catch { problems.push(`${jp}: not valid JSON`); continue; }
66
+ if (!Array.isArray(entries)) { problems.push(`${jp}: no entries array`); continue; }
67
+ const tags = new Set();
68
+ for (let i = 0; i < entries.length; i++) {
69
+ const e = entries[i] ?? {}, prev = entries[i - 1];
70
+ if (tags.has(e.tag)) problems.push(`${jp}: duplicate tag ${e.tag}`);
71
+ tags.add(e.tag);
72
+ if (prev && !(Number(e.idx) > Number(prev.idx))) problems.push(`${jp}: idx ${e.idx} (${e.tag}) not after ${prev.idx} (${prev.tag})`);
73
+ const n = numOf(String(e.tag ?? "")), pn = prev ? numOf(String(prev.tag ?? "")) : null;
74
+ if (n && pn && !(Number(n) > Number(pn))) problems.push(`${jp}: ${e.tag} not numbered after ${prev.tag}`);
75
+ }
76
+ for (const l of lines(await g(["-c", "core.quotepath=off", "ls-tree", "--name-only", ref, ...inDir(dir)]))) {
77
+ if (dirname(l) === dir && l.endsWith(".sql") && numOf(l) && !tags.has(basename(l, ".sql"))) problems.push(`${jp}: no entry for ${basename(l)}`);
78
+ }
79
+ }
80
+ return problems;
81
+ }
82
+
83
+ /** Lane-added numbered files with their blobs, recorded before a renumber so verifyRenumber can prove the SQL kept. */
84
+ export const snapshotLane = ({ git, base }) => laneNumbered(git, base).catch(() => []);
85
+
86
+ /**
87
+ * After the builder renumbered: no collision remains, every recorded non-JSON file (the SQL; snapshots are regenerated)
88
+ * still exists byte-identical in its directory, and the journals are strictly ordered. @returns {Promise<{ok, problems}>}
89
+ */
90
+ export async function verifyRenumber({ git, base, before = [] }) {
91
+ try {
92
+ const problems = [];
93
+ const left = await sequenceCollisions({ git, base });
94
+ for (const c of left.filter((x) => x.base.length)) problems.push(`${c.lane} still shares number ${c.number} with ${c.base.join(", ")}`);
95
+ const now = await laneNumbered(git, base);
96
+ for (const b of before.filter((f) => !f.path.endsWith(".json"))) {
97
+ if (!now.some((f) => dirname(f.path) === dirname(b.path) && f.blob === b.blob)) problems.push(`${b.path}: content changed or file gone after renumbering (keep it byte-identical, rename only)`);
98
+ }
99
+ problems.push(...await journalProblems({ git, dirs: [...new Set([...before, ...now].map((f) => dirname(f.path)))] }));
100
+ return { ok: !problems.length, problems };
101
+ } catch (e) { return { ok: false, problems: [String(e?.message ?? e)] }; }
102
+ }
@@ -0,0 +1,112 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ // Fixture: a drizzle-style migrations dir (NNNN_name.sql + meta/_journal.json + meta/NNNN_snapshot.json) where the base
4
+ // gains 0007 while the lane adds its own 0007 (#4322).
5
+ import { describe, it } from "node:test";
6
+ import assert from "node:assert/strict";
7
+ import { execFileSync } from "node:child_process";
8
+ import { mkdirSync, writeFileSync } from "node:fs";
9
+ import { dirname, join } from "node:path";
10
+ import { tmpDir } from "../tests/_tmp.mjs";
11
+ import { sequenceCollisions, journalProblems, snapshotLane, verifyRenumber } from "./sequence.mjs";
12
+ import { sequencePrompt, sequenceSyncNote } from "./prompts.mjs";
13
+
14
+ 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" } });
15
+ const gitIn = (cwd) => async (a) => sh(cwd, ...a);
16
+ const put = (root, files) => { for (const [p, t] of Object.entries(files)) { mkdirSync(dirname(join(root, p)), { recursive: true }); writeFileSync(join(root, p), t); } };
17
+ const commit = (d, m) => { sh(d, "add", "-A"); sh(d, "commit", "-qm", m); };
18
+ const journal = (...tags) => `${JSON.stringify({ version: "7", entries: tags.map((tag, idx) => ({ idx, tag, when: 1000 + idx })) }, null, 2)}\n`;
19
+
20
+ const USAGE_SQL = "CREATE TABLE usage (id int);\n";
21
+ /** main: 0006 init; lane (from main): 0007_usage; then main gains 0007_billing_reconcile. */
22
+ function fixture() {
23
+ const d = tmpDir("seq-");
24
+ sh(d, "init", "-q", "-b", "main");
25
+ put(d, { "db/drizzle/0006_init.sql": "CREATE TABLE t (id int);\n", "db/drizzle/meta/_journal.json": journal("0006_init"), "db/drizzle/meta/0006_snapshot.json": '{"id":"s6"}\n' });
26
+ commit(d, "init");
27
+ sh(d, "checkout", "-qb", "lane");
28
+ put(d, { "db/drizzle/0007_usage.sql": USAGE_SQL, "db/drizzle/meta/_journal.json": journal("0006_init", "0007_usage"), "db/drizzle/meta/0007_snapshot.json": '{"id":"lane7","prevId":"s6"}\n' });
29
+ commit(d, "lane migration");
30
+ sh(d, "checkout", "-q", "main");
31
+ put(d, { "db/drizzle/0007_billing_reconcile.sql": "CREATE TABLE billing (id int);\n", "db/drizzle/meta/_journal.json": journal("0006_init", "0007_billing_reconcile"), "db/drizzle/meta/0007_snapshot.json": '{"id":"s7","prevId":"s6"}\n' });
32
+ commit(d, "base migration");
33
+ sh(d, "checkout", "-q", "lane");
34
+ return d;
35
+ }
36
+
37
+ /** What the builder does when asked: merge the base, keep the base's journal/snapshot, rename the lane's SQL, regenerate. */
38
+ function renumber(d, { sql = null, tags = ["0006_init", "0007_billing_reconcile", "0008_usage"] } = {}) {
39
+ try { sh(d, "merge", "--no-edit", "main"); } catch { /* journal and snapshot conflict: resolved below */ }
40
+ sh(d, "checkout", "--theirs", "--", "db/drizzle/meta/_journal.json", "db/drizzle/meta/0007_snapshot.json");
41
+ sh(d, "mv", "db/drizzle/0007_usage.sql", "db/drizzle/0008_usage.sql");
42
+ if (sql) put(d, { "db/drizzle/0008_usage.sql": sql });
43
+ put(d, { "db/drizzle/meta/_journal.json": journal(...tags), "db/drizzle/meta/0008_snapshot.json": '{"id":"lane8","prevId":"s7"}\n' });
44
+ commit(d, "renumber");
45
+ }
46
+
47
+ describe("sequence-numbered migrations at hand-off (#4322)", () => {
48
+ it("detects the lane's 0007 against the base's 0007 (other name, and same snapshot path with other content) and proposes 0008", async () => {
49
+ const d = fixture();
50
+ const c = await sequenceCollisions({ git: gitIn(d), base: "main" });
51
+ assert.deepEqual(c.map((x) => [x.lane, x.base, x.next]), [
52
+ ["db/drizzle/0007_usage.sql", ["db/drizzle/0007_billing_reconcile.sql"], "0008"],
53
+ ["db/drizzle/meta/0007_snapshot.json", ["db/drizzle/meta/0007_snapshot.json"], "0008"],
54
+ ]);
55
+ const p = sequencePrompt(c, "main");
56
+ assert.match(p, /git merge --no-edit origin\/main/);
57
+ assert.match(p, /db\/drizzle\/0007_usage\.sql -> number 0008 \(base has db\/drizzle\/0007_billing_reconcile\.sql\)/);
58
+ assert.match(p, /byte-identical[\s\S]*regenerate the snapshot and journal with the repo's own migration generator \(never hand- or text-merge the journal\)[\s\S]*strictly ordered/);
59
+ });
60
+
61
+ it("a correct renumber verifies: SQL unchanged, snapshot regenerated, journal strictly ordered, no collision left", async () => {
62
+ const d = fixture();
63
+ const before = await snapshotLane({ git: gitIn(d), base: "main" });
64
+ assert.deepEqual(before.map((f) => f.path), ["db/drizzle/0007_usage.sql", "db/drizzle/meta/0007_snapshot.json"]);
65
+ renumber(d);
66
+ assert.deepEqual(await sequenceCollisions({ git: gitIn(d), base: "main" }), []);
67
+ assert.deepEqual(await verifyRenumber({ git: gitIn(d), base: "main", before }), { ok: true, problems: [] });
68
+ });
69
+
70
+ it("an edited SQL body, an unordered journal, or no renumber at all fails verification", async () => {
71
+ let d = fixture(); let before = await snapshotLane({ git: gitIn(d), base: "main" });
72
+ renumber(d, { sql: "CREATE TABLE usage (id bigint);\n" });
73
+ assert.match((await verifyRenumber({ git: gitIn(d), base: "main", before })).problems.join("\n"), /0007_usage\.sql: content changed/);
74
+
75
+ d = fixture(); before = await snapshotLane({ git: gitIn(d), base: "main" });
76
+ renumber(d, { tags: ["0006_init", "0008_usage", "0007_billing_reconcile"] });
77
+ const v = await verifyRenumber({ git: gitIn(d), base: "main", before });
78
+ assert.equal(v.ok, false);
79
+ assert.match(v.problems.join("\n"), /_journal\.json: 0007_billing_reconcile not numbered after 0008_usage/);
80
+
81
+ d = fixture(); before = await snapshotLane({ git: gitIn(d), base: "main" });
82
+ const left = await verifyRenumber({ git: gitIn(d), base: "main", before });
83
+ assert.match(left.problems.join("\n"), /0007_usage\.sql still shares number 0007 with db\/drizzle\/0007_billing_reconcile\.sql/);
84
+ });
85
+
86
+ it("a text-clean base merge still collides (no journal: e.g. app/migrations/0007_x.py), and later lane files move to keep the order", async () => {
87
+ const d = tmpDir("seq-");
88
+ sh(d, "init", "-q", "-b", "main");
89
+ put(d, { "app/migrations/0006_init.py": "x\n" }); commit(d, "init");
90
+ sh(d, "checkout", "-qb", "lane");
91
+ put(d, { "app/migrations/0007_a.py": "a\n", "app/migrations/0008_b.py": "b\n" }); commit(d, "lane");
92
+ sh(d, "checkout", "-q", "main");
93
+ put(d, { "app/migrations/0007_c.py": "c\n" }); commit(d, "base");
94
+ sh(d, "checkout", "-q", "lane");
95
+ sh(d, "merge", "--no-edit", "main");
96
+ const c = await sequenceCollisions({ git: gitIn(d), base: "main" });
97
+ assert.deepEqual(c.map((x) => [x.lane, x.base.length, x.next]), [["app/migrations/0007_a.py", 1, "0008"], ["app/migrations/0008_b.py", 0, "0009"]]);
98
+ assert.deepEqual(await journalProblems({ git: gitIn(d), dirs: ["app/migrations"] }), []);
99
+ });
100
+
101
+ it("regression guard: no numbered file added, or numbers above the base's, is no collision; a broken git never throws", async () => {
102
+ const d = fixture();
103
+ sh(d, "reset", "-q", "--hard", "main");
104
+ put(d, { "src/a.ts": "x\n", "db/drizzle/0008_new.sql": "y\n" }); commit(d, "lane2");
105
+ assert.deepEqual(await sequenceCollisions({ git: gitIn(d), base: "main" }), []);
106
+ const bad = async () => { throw new Error("boom"); };
107
+ assert.deepEqual(await sequenceCollisions({ git: bad, base: "main" }), []);
108
+ assert.equal(sequenceSyncNote(["src/a.ts", "package.json"]), "");
109
+ assert.match(sequenceSyncNote(["db/drizzle/meta/_journal.json"]), /do not text-merge/);
110
+ assert.match(sequenceSyncNote(["db/drizzle/0007_usage.sql"]), /Renumber the lane's files/);
111
+ });
112
+ });
@@ -6,7 +6,8 @@
6
6
  * command) picks the checks for the lane's changed files; `.forge/repo-facts.json` (scripts/repo-facts.sh) is cited to
7
7
  * the builder by path. The test map's commands are run, so it is read from the base ref, then the main checkout, never
8
8
  * the builder-writable lane worktree (#4297); repo facts are looked up in the worktree, then the main checkout. A missing
9
- * or invalid file is absent: the lane keeps forge.yaml `learned.test_commands` and today's prompts.
9
+ * or invalid file is absent: the lane keeps forge.yaml `learned.test_commands` and today's prompts. With a map, a learned
10
+ * command the map covers runs only when its globs match a changed file (`scopeChecks`, #4331).
10
11
  *
11
12
  * node bin/lean/testmap.mjs --map FILE --select FILE... print the selected commands, one per line
12
13
  */
@@ -52,21 +53,32 @@ export function selectCommands(map, files) {
52
53
  for (const e of map?.entries ?? []) {
53
54
  const res = e.globs.map((g) => globToRegex(g.replace(/^\.\//, "")));
54
55
  if (!changed.some((f) => res.some((re) => re.test(f)))) continue;
55
- const cwd = typeof e.cwd === "string" && e.cwd && e.cwd !== "." ? e.cwd : "";
56
- const cmd = cwd ? `cd ${shellQuote(cwd)} && ${e.command.trim()}` : e.command.trim();
56
+ const cmd = entryCommand(e);
57
57
  if (!out.includes(cmd)) out.push(cmd);
58
58
  }
59
59
  return out;
60
60
  }
61
61
 
62
+ /**
63
+ * #4318: the dedupe key of a check command: whitespace collapsed, a leading `cd . && ` dropped, `<pm> run test` read as
64
+ * `<pm> test` (npm/pnpm/yarn/bun) and an `npx`/`bunx [--yes|-y]` prefix dropped, so one suite under two spellings runs once.
65
+ */
66
+ export function checkKey(c) {
67
+ let k = String(c ?? "").trim().replace(/\s+/g, " ");
68
+ k = k.replace(/^cd \.\/? && /, "");
69
+ const cd = /^((?:cd|pushd) \S+ && )?(.*)$/.exec(k), pre = cd[1] ?? "";
70
+ const cmd = cd[2].replace(/^(?:npx|bunx)(?: (?:--yes|-y))? /, "").replace(/^(npm|pnpm|yarn|bun) run test(?= |$)/, "$1 test");
71
+ return pre + cmd;
72
+ }
73
+
62
74
  /**
63
75
  * #4315: the pre-PR checks are the learned commands (in order) plus each map-selected command they lack; compared
64
- * whitespace-collapsed, so the map can only add checks, never remove one. `source`: "learned" when the map added
65
- * nothing, "test-map" when there were no learned commands, else "test-map+learned".
76
+ * by `checkKey` (#4318), so the map can only add checks, never remove one; the first spelling seen is kept. `source`:
77
+ * "learned" when the map added nothing, "test-map" when there were no learned commands, else "test-map+learned".
66
78
  * @returns {{commands:string[], source:string, added:number}}
67
79
  */
68
80
  export function mergeChecks(selected, learned) {
69
- const norm = (c) => String(c ?? "").trim().replace(/\s+/g, " ");
81
+ const norm = checkKey;
70
82
  const commands = [], seen = new Set();
71
83
  const add = (c) => { const t = String(c ?? "").trim(), k = norm(t); if (!k || seen.has(k)) return false; seen.add(k); commands.push(t); return true; };
72
84
  for (const c of Array.isArray(learned) ? learned : []) add(c);
@@ -76,6 +88,31 @@ export function mergeChecks(selected, learned) {
76
88
  return { commands, added, source: !added ? "learned" : base ? "test-map+learned" : "test-map" };
77
89
  }
78
90
 
91
+ /**
92
+ * #4331: change-scoped pre-PR checks. A learned command the map covers (an entry's command, with its `cd <cwd> && `
93
+ * prefix, has the same `checkKey`) runs only when one of those entries has a glob matching a changed file; a learned
94
+ * command with no map entry always runs (the safe default); map-selected commands the learned list lacks are added
95
+ * as in `mergeChecks`. No map: the learned list unchanged. `source` is "learned" when the map added and dropped
96
+ * nothing, "test-map" when there were no learned commands, else "test-map+learned".
97
+ * @returns {{commands:string[], source:string, added:number, dropped:string[]}}
98
+ */
99
+ export function scopeChecks(map, files, learned) {
100
+ const list = Array.isArray(learned) ? learned : [];
101
+ if (!map) { const m = mergeChecks([], list); return { ...m, dropped: [] }; }
102
+ const selected = selectCommands(map, files), hit = new Set(selected.map(checkKey));
103
+ const mapped = new Set((map.entries ?? []).map((e) => checkKey(entryCommand(e))));
104
+ const dropped = [], kept = [];
105
+ for (const c of list) { const k = checkKey(c); if (!k) continue; (mapped.has(k) && !hit.has(k) ? dropped : kept).push(String(c).trim()); }
106
+ const m = mergeChecks(selected, kept);
107
+ return { commands: m.commands, added: m.added, dropped, source: !m.added && !dropped.length ? "learned" : kept.length || dropped.length ? "test-map+learned" : "test-map" };
108
+ }
109
+
110
+ /** An entry's command as `selectCommands` emits it (`cd <cwd> && cmd` for a non-root cwd). */
111
+ function entryCommand(e) {
112
+ const cwd = typeof e.cwd === "string" && e.cwd && e.cwd !== "." ? e.cwd : "";
113
+ return cwd ? `cd ${shellQuote(cwd)} && ${e.command.trim()}` : e.command.trim();
114
+ }
115
+
79
116
  /** The path of the first repo-facts file under `dirs` that exists, or null; never throws. */
80
117
  export async function factsPath(dirs, exists) {
81
118
  if (typeof exists !== "function") return null;
@@ -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 { parseTestMap, readTestMap, selectCommands, mergeChecks, factsPath, TEST_MAP_FILE, REPO_FACTS_FILE } from "./testmap.mjs";
5
+ import { parseTestMap, readTestMap, selectCommands, mergeChecks, scopeChecks, checkKey, factsPath, TEST_MAP_FILE, REPO_FACTS_FILE } from "./testmap.mjs";
6
6
 
7
7
  const MAP = {
8
8
  version: 1,
@@ -71,3 +71,40 @@ describe("mergeChecks (#4315): the map can only add checks", () => {
71
71
  assert.deepEqual(mergeChecks(undefined, undefined), { commands: [], added: 0, source: "learned" });
72
72
  });
73
73
  });
74
+
75
+ describe("mergeChecks (#4318): one suite under two spellings runs once", () => {
76
+ it("collapses <pm> run test / <pm> test, npx [--yes] X / X and cd . && X / X, keeping the first spelling", () => {
77
+ assert.deepEqual(mergeChecks(["pnpm test", "vitest", "make lint"], ["pnpm run test", "npx --yes vitest", "cd . && make lint"]),
78
+ { commands: ["pnpm run test", "npx --yes vitest", "cd . && make lint"], added: 0, source: "learned" });
79
+ assert.deepEqual(mergeChecks(["cd pkg && npx vitest run", "cd other && vitest run"], ["cd pkg && vitest run"]).commands, ["cd pkg && vitest run", "cd other && vitest run"]);
80
+ });
81
+ it("checkKey keeps different suites apart", () => {
82
+ assert.notEqual(checkKey("npm run test:unit"), checkKey("npm test"));
83
+ assert.notEqual(checkKey("cd a && npm test"), checkKey("npm test"));
84
+ assert.equal(checkKey("bunx -y jest"), "jest");
85
+ });
86
+ });
87
+
88
+ describe("scopeChecks (#4331): a mapped learned command runs only when its globs match", () => {
89
+ const map = { version: 1, entries: [
90
+ { globs: ["commands/**"], command: "bash scripts/check-a.sh", cwd: "." },
91
+ { globs: ["bin/**"], command: "bash scripts/check-b.sh", cwd: "." },
92
+ { globs: ["src/**"], command: "npm test", cwd: "pkg" },
93
+ { globs: ["**/*.py"], command: "uv run pytest", cwd: "." }] };
94
+ const learned = ["bash scripts/check-a.sh", "bash scripts/check-b.sh", "make lint"];
95
+ it("a lane changing only commands/foo.md runs the matching mapped commands plus the unmapped ones", () => {
96
+ assert.deepEqual(scopeChecks(map, ["commands/foo.md"], learned),
97
+ { commands: ["bash scripts/check-a.sh", "make lint"], added: 0, dropped: ["bash scripts/check-b.sh"], source: "test-map+learned" });
98
+ });
99
+ it("no map keeps the learned list; nothing added or dropped is source learned", () => {
100
+ assert.deepEqual(scopeChecks(null, ["commands/foo.md"], learned), { commands: learned, added: 0, dropped: [], source: "learned" });
101
+ assert.deepEqual(scopeChecks(map, ["commands/foo.md"], ["bash scripts/check-a.sh", "make lint"]).source, "learned");
102
+ });
103
+ it("matches by checkKey and by the entry's cwd prefix; map-selected commands are added", () => {
104
+ const r = scopeChecks(map, ["src/x.js", "a.py"], ["cd . && bash scripts/check-a.sh", "cd pkg && npm run test", "npm test"]);
105
+ assert.deepEqual(r.dropped, ["cd . && bash scripts/check-a.sh"]);
106
+ assert.deepEqual(r.commands, ["cd pkg && npm run test", "npm test", "uv run pytest"]);
107
+ assert.equal(r.source, "test-map+learned");
108
+ assert.deepEqual(scopeChecks(map, ["a.py"], []), { commands: ["uv run pytest"], added: 1, dropped: [], source: "test-map" });
109
+ });
110
+ });
@@ -13,6 +13,9 @@ export const DEFAULT_SKIP_PATHS = Object.freeze(["*.md", "*.mdx", "*.rst", "*.tx
13
13
  export const DEFAULT_MAX_ROUNDS = 2;
14
14
  /** Per-command timeout of the pre-PR declared check commands (#4240, `verify.precheck_timeout_seconds`). */
15
15
  export const DEFAULT_PRECHECK_TIMEOUT_SECONDS = 300;
16
+ /** Total budget of one round of pre-PR check commands (#4318, `verify.precheck_total_seconds`; 10 min since #4331, and
17
+ * forge.yaml `lean.precheck.max_minutes` overrides it). The rest of the round is deferred to CI. */
18
+ export const DEFAULT_PRECHECK_TOTAL_SECONDS = 600;
16
19
  /** Total characters of failed-job log handed to the builder. */
17
20
  export const LOG_CAP = 12000;
18
21
  /** No run or check for the head after this long means CI is unavailable (local fallback). */
@@ -26,7 +29,7 @@ const OK = ["success", "neutral", "skipped"];
26
29
 
27
30
  /**
28
31
  * forge.yaml `verify:` section (block style; parseForgeYaml gives strings) → {mode, workflow, pathsInput, skipPaths,
29
- * maxRounds, precheckTimeoutSeconds, checkRun}. Invalid values fall back to the defaults; mode defaults to local (today's
32
+ * maxRounds, precheckTimeoutSeconds, precheckTotalSeconds, checkRun}. Invalid values fall back to the defaults; mode defaults to local (today's
30
33
  * behaviour); checkRun (`check_run`, #4270) is true only for the literal `true`.
31
34
  */
32
35
  export function parseVerify(sec = {}) {
@@ -41,8 +44,10 @@ export function parseVerify(sec = {}) {
41
44
  const maxRounds = str(s.max_rounds) !== "" && Number.isInteger(n) && n >= 0 && n <= 10 ? n : DEFAULT_MAX_ROUNDS;
42
45
  const t = Number(str(s.precheck_timeout_seconds));
43
46
  const precheckTimeoutSeconds = str(s.precheck_timeout_seconds) !== "" && Number.isInteger(t) && t >= 1 && t <= 3600 ? t : DEFAULT_PRECHECK_TIMEOUT_SECONDS;
47
+ const tt = Number(str(s.precheck_total_seconds));
48
+ const precheckTotalSeconds = str(s.precheck_total_seconds) !== "" && Number.isInteger(tt) && tt >= 1 && tt <= 7200 ? tt : DEFAULT_PRECHECK_TOTAL_SECONDS;
44
49
  const checkRun = str(s.check_run).toLowerCase() === "true";
45
- return { mode, workflow, pathsInput, skipPaths, maxRounds, precheckTimeoutSeconds, checkRun };
50
+ return { mode, workflow, pathsInput, skipPaths, maxRounds, precheckTimeoutSeconds, precheckTotalSeconds, checkRun };
46
51
  }
47
52
 
48
53
  const globRe = (g) => new RegExp(`^${g.split("**").map((p) => p.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*").replace(/\?/g, "[^/]")).join(".*")}$`);