forgedock 1.41.0 → 1.43.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 (49) hide show
  1. package/bin/engine-cli.mjs +82 -17
  2. package/bin/forgedock.mjs +12 -1
  3. package/bin/lean/autochildren.mjs +103 -0
  4. package/bin/lean/autochildren.test.mjs +126 -0
  5. package/bin/lean/batch.mjs +8 -2
  6. package/bin/lean/batch.test.mjs +29 -2
  7. package/bin/lean/conformance.mjs +121 -8
  8. package/bin/lean/conformance.test.mjs +132 -2
  9. package/bin/lean/fixround.test.mjs +9 -1
  10. package/bin/lean/guardgate.mjs +66 -7
  11. package/bin/lean/guardgate.test.mjs +109 -3
  12. package/bin/lean/heavy.test.mjs +11 -1
  13. package/bin/lean/lean.mjs +40 -16
  14. package/bin/lean/lean.test.mjs +66 -5
  15. package/bin/lean/orchestrate-children.test.mjs +83 -0
  16. package/bin/lean/orchestrate.mjs +51 -12
  17. package/bin/lean/postmerge.mjs +5 -2
  18. package/bin/lean/postmerge.test.mjs +7 -0
  19. package/bin/lean/preflight.mjs +21 -0
  20. package/bin/lean/preflight.test.mjs +15 -1
  21. package/bin/lean/prompts.mjs +19 -4
  22. package/bin/lean/schedd.mjs +79 -15
  23. package/bin/lean/schedd.test.mjs +92 -2
  24. package/bin/lean/selftest-gate.mjs +46 -0
  25. package/bin/lean/selftest-gate.test.mjs +40 -0
  26. package/bin/lean/selftest-live.mjs +248 -0
  27. package/bin/lean/selftest-live.test.mjs +154 -0
  28. package/bin/lean/selftest-nightly.mjs +81 -0
  29. package/bin/lean/selftest-nightly.test.mjs +84 -0
  30. package/bin/lean/selftest-static.mjs +81 -0
  31. package/bin/lean/selftest-static.test.mjs +32 -0
  32. package/bin/lean/toolstate.mjs +19 -3
  33. package/bin/lean/toolstate.test.mjs +22 -0
  34. package/bin/lean/verify.mjs +9 -1
  35. package/bin/lean/verify.test.mjs +4 -0
  36. package/bin/review-staging.mjs +28 -6
  37. package/commands/deploy-pr.md +3 -3
  38. package/commands/orchestrate.md +2 -2
  39. package/commands/review-pr-staging.md +2 -2
  40. package/docs/CONFIG.md +30 -2
  41. package/docs/RELEASE-NOTES.md +13 -0
  42. package/docs/SELFTEST.md +34 -0
  43. package/package.json +1 -1
  44. package/scripts/file-deferred-followups.sh +1 -0
  45. package/scripts/file-deferred-followups.test.sh +7 -0
  46. package/scripts/review-pr-staging-scope.test.sh +3 -3
  47. package/scripts/review-prepare.sh +3 -0
  48. package/scripts/review-staging-pre.sh +88 -43
  49. package/scripts/review-staging-pre.test.sh +42 -1
@@ -0,0 +1,81 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ // Opt-in nightly `selftest --live` (#4894), started from schedd's due callback on the host (never GitHub Actions). Off unless
4
+ // forge.yaml `selftest.nightly_interval_hours` is a positive number. The daemon only decides it is due and launches a
5
+ // detached `forgedock selftest --live --nightly-report <repo>`: the harness itself talks to schedd (orchestrate --add), so
6
+ // running it inside the daemon's event loop would deadlock it, and a 45-minute run would hold the due pass. A failing run
7
+ // (or one that cannot start, e.g. no sandbox repo) files ONE issue naming the failed step; the next failure edits it.
8
+ import { spawn } from "node:child_process";
9
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { schedDir } from "./sched.mjs";
12
+
13
+ export const NIGHTLY_MARKER = "<!-- FORGE:SELFTEST_NIGHTLY -->";
14
+ const neutralize = (s) => String(s ?? "").replace(/</g, "&lt;").replace(/>/g, "&gt;");
15
+ /** One last-run file per repo: two repos on one host never share (and so never skip) each other's schedule. */
16
+ const stateFile = (repo) => `selftest-nightly-${String(repo).replace(/[^\w.-]+/g, "_")}.json`;
17
+
18
+ /** Interval in hours from the forge.yaml text, or null when the key is unset, zero or not a number (= nothing scheduled). */
19
+ export function nightlyConfig(raw) {
20
+ const sec = String(raw ?? "").split(/^selftest:/m)[1]?.split(/^\S/m)[0] ?? "";
21
+ const h = Number(/^\s+nightly_interval_hours:\s*["']?([\d.]+)/m.exec(sec)?.[1]);
22
+ return h > 0 ? h : null;
23
+ }
24
+
25
+ function readLast(dir, repo) {
26
+ try { return Number(JSON.parse(readFileSync(join(dir, stateFile(repo)), "utf8")).lastRun) || 0; } catch { return 0; }
27
+ }
28
+ function writeLast(dir, repo, ts) {
29
+ mkdirSync(dir, { recursive: true });
30
+ writeFileSync(join(dir, stateFile(repo)), JSON.stringify({ lastRun: ts }));
31
+ }
32
+
33
+ /**
34
+ * Start `node <cli> selftest --live --nightly-report <repo>` detached, from the daemon. A spawn failure (EAGAIN, EMFILE,
35
+ * a vanished cwd) arrives as an async 'error' event: without a listener it is an uncaught exception, which stops schedd.
36
+ * It is swallowed here; that night's run is lost, never the daemon.
37
+ */
38
+ export function launchNightly({ cli, repo, cwd = process.cwd(), spawnFn = spawn }) {
39
+ try {
40
+ const c = spawnFn(process.execPath, [cli, "selftest", "--live", "--nightly-report", repo], { cwd, detached: true, stdio: "ignore" });
41
+ c.on("error", () => {});
42
+ c.unref();
43
+ return c;
44
+ } catch { return null; }
45
+ }
46
+
47
+ /** File (or edit) the one nightly failure issue in `repo`. `result` is {step, message}. @returns {Promise<{issue:?number, action:"create"|"edit"}>} */
48
+ export async function reportNightly({ repo, io, now = () => Date.now(), result }) {
49
+ const step = result?.step ?? "unknown";
50
+ const title = `selftest --live failed at step ${step} (nightly)`;
51
+ const body = `The nightly \`forgedock selftest --live\` failed at step **${neutralize(step)}**.\n\n${neutralize(String(result?.message ?? "").slice(0, 2000))}\n\nLast failure: ${new Date(now()).toISOString()}\n\n${NIGHTLY_MARKER}\n`;
52
+ const open = JSON.parse(String(await io.gh(["issue", "list", "-R", repo, "--state", "open", "--search", `"FORGE:SELFTEST_NIGHTLY" in:body`, "--limit", "30", "--json", "number,body"])))
53
+ .find((i) => String(i.body ?? "").includes(NIGHTLY_MARKER));
54
+ if (open) {
55
+ await io.gh(["issue", "edit", String(open.number), "-R", repo, "--title", title, "--body", body]);
56
+ return { issue: open.number, action: "edit" };
57
+ }
58
+ const out = await io.gh(["issue", "create", "-R", repo, "--title", title, "--body", body]);
59
+ return { issue: Number(/(\d+)\s*$/.exec(String(out).trim())?.[1]) || null, action: "create" };
60
+ }
61
+
62
+ /**
63
+ * One schedd pass. With `launch` (the daemon) it only starts the detached run and returns {ran, launched}; with `runLive`
64
+ * (tests, or an in-process caller) it awaits {ok, step, message} and files the failure. A harness that throws is a
65
+ * failure at step "harness", never silent. Never throws.
66
+ * @returns {Promise<{ran:boolean, launched?:boolean, ok?:boolean, issue?:?number, action?:"create"|"edit"}>}
67
+ */
68
+ export async function nightlyDue({ repo, cwd = process.cwd(), io, now = () => Date.now(), runLive, launch, stateDir = schedDir() } = {}) {
69
+ try {
70
+ const file = join(cwd, "forge.yaml");
71
+ const hours = nightlyConfig(existsSync(file) ? readFileSync(file, "utf8") : "");
72
+ if (!hours || !repo) return { ran: false };
73
+ if (now() - readLast(stateDir, repo) < hours * 3600_000) return { ran: false };
74
+ writeLast(stateDir, repo, now()); // before the run: a crashing harness must not re-run every 15 min
75
+ if (launch) { launch(); return { ran: true, launched: true }; }
76
+ let r;
77
+ try { r = await runLive(); } catch (e) { r = { ok: false, step: "harness", message: String(e?.message ?? e) }; }
78
+ if (r.ok) return { ran: true, ok: true };
79
+ return { ran: true, ok: false, ...(await reportNightly({ repo, io, now, result: r })) };
80
+ } catch { return { ran: false }; }
81
+ }
@@ -0,0 +1,84 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { writeFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { tmpDir } from "../tests/_tmp.mjs";
6
+ import { EventEmitter } from "node:events";
7
+ import { nightlyConfig, nightlyDue, launchNightly, NIGHTLY_MARKER } from "./selftest-nightly.mjs";
8
+
9
+ const setup = (yaml) => { const cwd = tmpDir("nt-"), stateDir = tmpDir("ns-"); if (yaml) writeFileSync(join(cwd, "forge.yaml"), yaml); return { cwd, stateDir }; };
10
+ const YAML = "selftest:\n sandbox_repo: a/b\n nightly_interval_hours: 24\n";
11
+ const fakeIo = (open = []) => { const calls = []; return { calls, gh: async (a) => { calls.push(a); if (a[1] === "list") return JSON.stringify(open); if (a[1] === "create") return "https://github.com/o/r/issues/77\n"; return ""; } }; };
12
+
13
+ test("nightlyConfig: unset, zero and other sections are off", () => {
14
+ assert.equal(nightlyConfig(""), null);
15
+ assert.equal(nightlyConfig("selftest:\n sandbox_repo: a/b\n"), null);
16
+ assert.equal(nightlyConfig("selftest:\n nightly_interval_hours: 0\n"), null);
17
+ assert.equal(nightlyConfig("other:\n nightly_interval_hours: 5\n"), null);
18
+ assert.equal(nightlyConfig(YAML), 24);
19
+ });
20
+
21
+ test("key unset: nothing is scheduled", async () => {
22
+ const s = setup("selftest:\n sandbox_repo: a/b\n"); let n = 0;
23
+ const r = await nightlyDue({ ...s, repo: "o/r", io: fakeIo(), runLive: async () => { n++; return { ok: true }; } });
24
+ assert.equal(r.ran, false); assert.equal(n, 0);
25
+ });
26
+
27
+ test("key set: runs when due, not again within the interval", async () => {
28
+ const s = setup(YAML); let n = 0, t = 1e13;
29
+ const run = () => nightlyDue({ ...s, repo: "o/r", io: fakeIo(), now: () => t, runLive: async () => { n++; return { ok: true }; } });
30
+ assert.equal((await run()).ran, true);
31
+ t += 3600_000; assert.equal((await run()).ran, false);
32
+ t += 24 * 3600_000; assert.equal((await run()).ran, true);
33
+ assert.equal(n, 2);
34
+ });
35
+
36
+ test("failure files one issue naming the step; the second failure edits it", async () => {
37
+ const s = setup(YAML); let t = 1e13;
38
+ const io1 = fakeIo();
39
+ const r1 = await nightlyDue({ ...s, repo: "o/r", io: io1, now: () => t, runLive: async () => ({ ok: false, step: "body", message: "lost <!-- x --> body" }) });
40
+ assert.deepEqual([r1.action, r1.issue], ["create", 77]);
41
+ const create = io1.calls.find((a) => a[1] === "create");
42
+ assert.match(create.join(" "), /step body/); assert.ok(create.join(" ").includes(NIGHTLY_MARKER));
43
+ assert.ok(!create[create.indexOf("--body") + 1].includes("<!-- x"));
44
+ t += 25 * 3600_000;
45
+ const io2 = fakeIo([{ number: 77, body: `x\n${NIGHTLY_MARKER}` }]);
46
+ const r2 = await nightlyDue({ ...s, repo: "o/r", io: io2, now: () => t, runLive: async () => ({ ok: false, step: "add", message: "m" }) });
47
+ assert.deepEqual([r2.action, r2.issue], ["edit", 77]);
48
+ assert.ok(!io2.calls.some((a) => a[1] === "create"));
49
+ assert.match(io2.calls.find((a) => a[1] === "edit").join(" "), /step add/);
50
+ });
51
+
52
+ test("a passing run writes no issue; a throwing harness is reported, never silent", async () => {
53
+ const s = setup(YAML), io = fakeIo();
54
+ await nightlyDue({ ...s, repo: "o/r", io, runLive: async () => ({ ok: true }) });
55
+ assert.equal(io.calls.length, 0);
56
+ const s2 = setup(YAML), io2 = fakeIo();
57
+ const r = await nightlyDue({ ...s2, repo: "o/r", io: io2, runLive: async () => { throw new Error("boom"); } });
58
+ assert.deepEqual([r.ran, r.ok, r.action], [true, false, "create"]);
59
+ assert.match(io2.calls.find((a) => a[1] === "create").join(" "), /step harness/);
60
+ });
61
+
62
+ test("with launch (the daemon) the run is only started, detached: nothing is awaited or filed in-process", async () => {
63
+ const s = setup(YAML), io = fakeIo(); let launched = 0;
64
+ const r = await nightlyDue({ ...s, repo: "o/r", io, launch: () => { launched++; }, runLive: async () => { throw new Error("must not run"); } });
65
+ assert.deepEqual(r, { ran: true, launched: true }); assert.equal(launched, 1); assert.equal(io.calls.length, 0);
66
+ assert.equal((await nightlyDue({ ...s, repo: "o/r", io, launch: () => { launched++; } })).ran, false, "not again within the interval");
67
+ });
68
+
69
+ test("the schedule is per repo: one repo's run never skips another's", async () => {
70
+ const s = setup(YAML); let n = 0; const t = 1e13;
71
+ const run = (repo) => nightlyDue({ ...s, repo, io: fakeIo(), now: () => t, runLive: async () => { n++; return { ok: true }; } });
72
+ assert.equal((await run("o/a")).ran, true); assert.equal((await run("o/b")).ran, true); assert.equal(n, 2);
73
+ });
74
+
75
+ test("launchNightly: detached, unref'd, and an async spawn error never escapes (it would stop schedd)", async () => {
76
+ let args = null, unrefd = false;
77
+ const child = new EventEmitter(); child.unref = () => { unrefd = true; };
78
+ const c = launchNightly({ cli: "/x/forgedock.mjs", repo: "o/r", cwd: "/tmp", spawnFn: (...a) => { args = a; return child; } });
79
+ assert.equal(c, child); assert.ok(unrefd);
80
+ assert.deepEqual(args[1], ["/x/forgedock.mjs", "selftest", "--live", "--nightly-report", "o/r"]);
81
+ assert.equal(args[2].detached, true); assert.equal(args[2].stdio, "ignore");
82
+ assert.doesNotThrow(() => child.emit("error", Object.assign(new Error("spawn EAGAIN"), { code: "EAGAIN" })));
83
+ assert.equal(launchNightly({ cli: "c", repo: "o/r", spawnFn: () => { throw new Error("EMFILE"); } }), null);
84
+ });
@@ -0,0 +1,81 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ // Static half of `forgedock selftest` (#4865): every `gh issue|pr view|list --json a,b` field list in bin/ must be a
4
+ // subset of the fields `gh <cmd> --help` advertises on this host (#4859: `authorAssociation` is not a gh field).
5
+ import { readdirSync, readFileSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { execFileSync } from "node:child_process";
8
+
9
+ /** Non-test .mjs files under `dir`, recursively (node_modules skipped). */
10
+ export function listSources(dir) {
11
+ const out = [];
12
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
13
+ if (e.name === "node_modules") continue;
14
+ const p = join(dir, e.name);
15
+ if (e.isDirectory()) out.push(...listSources(p));
16
+ else if (e.name.endsWith(".mjs") && !/\.test\.mjs$/.test(e.name)) out.push(p);
17
+ }
18
+ return out.sort();
19
+ }
20
+
21
+ /** @returns {{cmd:string, fields:string[], line:number}[]} the literal gh `--json` field lists of `text` (cmd e.g. "issue view"). */
22
+ export function extractJsonFields(text) {
23
+ const out = [];
24
+ const re = /\[([^[\]]*?)["'`]--json["'`]\s*,\s*["'`]([A-Za-z0-9_,]+)["'`]/g;
25
+ for (let m; (m = re.exec(text));) {
26
+ const words = [...m[1].matchAll(/["'`]([a-z-]+)["'`]/g)].map((x) => x[1]);
27
+ const i = words.findIndex((w, k) => (w === "issue" || w === "pr") && (words[k + 1] === "view" || words[k + 1] === "list"));
28
+ if (i < 0) continue;
29
+ out.push({ cmd: `${words[i]} ${words[i + 1]}`, fields: m[2].split(",").filter(Boolean), line: text.slice(0, m.index).split("\n").length });
30
+ }
31
+ return out;
32
+ }
33
+
34
+ /** The field names of the `JSON FIELDS` section of `gh <cmd> --help`. */
35
+ export function parseHelpFields(help) {
36
+ const lines = String(help).split("\n");
37
+ const at = lines.findIndex((l) => /^\s*JSON FIELDS\s*$/i.test(l));
38
+ if (at < 0) return [];
39
+ const out = [];
40
+ for (const l of lines.slice(at + 1)) {
41
+ if (!l.trim()) { if (out.length) break; continue; }
42
+ if (!/^\s/.test(l)) break;
43
+ out.push(...l.split(",").map((s) => s.trim()).filter(Boolean));
44
+ }
45
+ return out;
46
+ }
47
+
48
+ /**
49
+ * @param {{root:string, io:{gh:(args:string[])=>string, read?:(p:string)=>string, list?:(d:string)=>string[]}}} a
50
+ * @returns {{ok:boolean, checked:number, missing:{file:string,line:number,cmd:string,field:string}[], errors:string[]}}
51
+ */
52
+ export function staticCheck({ root, io }) {
53
+ const read = io.read ?? ((p) => readFileSync(p, "utf8")), list = io.list ?? listSources;
54
+ const known = new Map(), errors = [], missing = [];
55
+ let checked = 0;
56
+ for (const file of list(root)) {
57
+ for (const u of extractJsonFields(read(file))) {
58
+ if (!known.has(u.cmd)) {
59
+ let fields = [];
60
+ try { fields = parseHelpFields(io.gh([...u.cmd.split(" "), "--help"])); } catch (e) { errors.push(`gh ${u.cmd} --help failed: ${e.message}`); }
61
+ if (!fields.length && !errors.length) errors.push(`gh ${u.cmd} --help lists no JSON fields`);
62
+ known.set(u.cmd, new Set(fields));
63
+ }
64
+ const set = known.get(u.cmd);
65
+ if (!set.size) continue;
66
+ checked++;
67
+ for (const field of u.fields) if (!set.has(field)) missing.push({ file, line: u.line, cmd: u.cmd, field });
68
+ }
69
+ }
70
+ return { ok: !missing.length && !errors.length, checked, missing, errors };
71
+ }
72
+
73
+ export const realGh = (args) => execFileSync("gh", args, { encoding: "utf8", maxBuffer: 16 * 1024 * 1024 });
74
+
75
+ export function formatStatic(r) {
76
+ return [
77
+ ...r.missing.map((m) => `${m.file}:${m.line}: gh ${m.cmd} --json field "${m.field}" is not offered by this gh`),
78
+ ...r.errors,
79
+ `static check: ${r.ok ? "ok" : "FAILED"} (${r.checked} field lists checked)`,
80
+ ].join("\n");
81
+ }
@@ -0,0 +1,32 @@
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 { extractJsonFields, parseHelpFields, staticCheck, formatStatic } from "./selftest-static.mjs";
6
+
7
+ const HELP = "Usage\n\nJSON FIELDS\n author, body, number,\n title\n\nARGUMENTS\n none\n";
8
+ const gh = (args) => { if (args[0] === "issue" || args[0] === "pr") return HELP; throw new Error("no"); };
9
+
10
+ describe("selftest static check (#4865)", () => {
11
+ it("extracts only gh issue|pr view|list field lists", () => {
12
+ const t = `x\nawait io.gh(["issue", "view", String(n), "--json", "body,title"]);\nrun(["repo", "view", "--json", "nameWithOwner"]);\ngh(["pr", "list", "--json", "number"])`;
13
+ assert.deepEqual(extractJsonFields(t), [{ cmd: "issue view", fields: ["body", "title"], line: 2 }, { cmd: "pr list", fields: ["number"], line: 4 }]);
14
+ });
15
+ it("parses the JSON FIELDS section of the help", () => {
16
+ assert.deepEqual(parseHelpFields(HELP), ["author", "body", "number", "title"]);
17
+ });
18
+ it("fails on a bogus field (#4859 authorAssociation) and names file:line", () => {
19
+ const r = staticCheck({ root: "/bin", io: { gh, list: () => ["/bin/a.mjs"], read: () => 'gh(["issue", "list", "--json", "number,authorAssociation"])' } });
20
+ assert.equal(r.ok, false);
21
+ assert.deepEqual(r.missing, [{ file: "/bin/a.mjs", line: 1, cmd: "issue list", field: "authorAssociation" }]);
22
+ assert.match(formatStatic(r), /a\.mjs:1: gh issue list --json field "authorAssociation"/);
23
+ });
24
+ it("passes when every field is offered", () => {
25
+ const r = staticCheck({ root: "/bin", io: { gh, list: () => ["/bin/a.mjs"], read: () => 'gh(["pr", "view", "1", "--json", "title,body"])' } });
26
+ assert.equal(r.ok, true); assert.equal(r.checked, 1);
27
+ });
28
+ it("fails closed when gh help cannot be read", () => {
29
+ const r = staticCheck({ root: "/bin", io: { gh: () => { throw new Error("no gh"); }, list: () => ["/a.mjs"], read: () => 'x(["pr", "view", "--json", "title"])' } });
30
+ assert.equal(r.ok, false);
31
+ });
32
+ });
@@ -51,14 +51,30 @@ export async function unstageToolState(git) {
51
51
  return stray.length ? (await git(["diff", "--cached", "--name-only"])).trim() !== "" : staged.length > 0;
52
52
  }
53
53
 
54
+ const REQUIREMENTS_EXPORT = /^requirements([-_.][\w.-]+)?\.txt$/;
55
+
56
+ /**
57
+ * #4959: `requirements*.txt` files the branch added to a Poetry/uv project (pyproject [tool.poetry], or poetry.lock / uv.lock at
58
+ * the base) that the base does not track and the contract does not list (`keep`): an export generated to run local tests.
59
+ */
60
+ export async function strayGenerated(git, ref, added, keep = []) {
61
+ const cands = added.filter((p) => REQUIREMENTS_EXPORT.test(base(p)) && !keep.includes(p));
62
+ if (!cands.length) return [];
63
+ const tracked = zlist(await git(["ls-tree", "-r", "--name-only", "-z", ref]));
64
+ const managed = tracked.some((f) => /(^|\/)(poetry|uv)\.lock$/.test(f))
65
+ || (await Promise.all(tracked.filter((f) => /(^|\/)pyproject\.toml$/.test(f)).map((f) => git(["show", `${ref}:${f}`]).catch(() => "")))).some((t) => /^\[tool\.poetry[\].]/m.test(String(t)));
66
+ return managed ? cands : [];
67
+ }
68
+
54
69
  /**
55
70
  * 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.
71
+ * when the branch is clean). `keep`: contract deliverable paths, never removed as generated files. Never throws: a failure
72
+ * leaves the branch as it was and the push proceeds.
57
73
  */
58
- export async function untrackToolState({ git, base: ref, ev = () => {} }) {
74
+ export async function untrackToolState({ git, base: ref, ev = () => {}, keep = [] }) {
59
75
  try {
60
76
  const added = zlist(await git(["diff", "--diff-filter=A", "--name-only", "-z", `${ref}...HEAD`]));
61
- const stray = await strayPaths(git, added);
77
+ const stray = [...await strayPaths(git, added), ...await strayGenerated(git, ref, added, keep)];
62
78
  if (!stray.length) return [];
63
79
  await git(["rm", "--cached", "-r", "-q", "--ignore-unmatch", "--", ...stray]);
64
80
  await git(["commit", "--no-verify", "-m", "chore: untrack lane tool state (#4590)", "-m", stray.slice(0, 20).join("\n")]);
@@ -112,3 +112,25 @@ describe("lane scratch is untracked and cleared (#4880)", () => {
112
112
  assert.equal(execFileSync("git", ["ls-files", ".forge-lean"], { cwd: root, encoding: "utf8" }).trim(), "");
113
113
  });
114
114
  });
115
+
116
+ describe("generated requirements.txt is untracked in a Poetry project (#4959)", () => {
117
+ const lane = (poetry, files) => {
118
+ const d = tmpDir("toolstate-poetry-");
119
+ sh(d, "init", "-q", "-b", "main"); sh(d, "config", "user.email", "t@t"); sh(d, "config", "user.name", "t");
120
+ put(d, "pyproject.toml", poetry ? "[tool.poetry]\nname = \"x\"\n" : "[project]\nname = \"x\"\n");
121
+ sh(d, "add", "-A"); sh(d, "commit", "-q", "-m", "base"); sh(d, "checkout", "-q", "-b", "lane");
122
+ for (const f of files) put(d, f);
123
+ sh(d, "add", "-A"); sh(d, "commit", "-q", "-m", "lane");
124
+ return d;
125
+ };
126
+ it("drops an added requirements.txt, keeps product files and contract-listed ones", async () => {
127
+ const d = lane(true, ["services/worker/requirements.txt", "services/worker/app.py", "services/api/requirements.txt"]);
128
+ const removed = await untrackToolState({ git: gitIn(d), base: "main", keep: ["services/api/requirements.txt"] });
129
+ assert.deepEqual(removed, ["services/worker/requirements.txt"]);
130
+ assert.deepEqual(sh(d, "diff", "--name-only", "main...HEAD").trim().split("\n"), ["services/api/requirements.txt", "services/worker/app.py"]);
131
+ });
132
+ it("a non-Poetry project is untouched", async () => {
133
+ const d = lane(false, ["requirements.txt"]);
134
+ assert.deepEqual(await untrackToolState({ git: gitIn(d), base: "main" }), []);
135
+ });
136
+ });
@@ -60,7 +60,15 @@ export function parseVerify(sec = {}) {
60
60
  const flaky = [...new Set(rawFlaky.map((x) => String(x ?? "").trim().replace(/^(["'])(.*)\1$/s, "$2").trim()).filter(Boolean))];
61
61
  const duplicateExports = str(s.duplicate_exports).toLowerCase() !== "false";
62
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) };
63
+ // #4792: guard_command maps an extension (`.py` or `py`) to a test-file command template; read by engine-cli readYamlMap; the key is omitted when empty so unconfigured repos parse as before
64
+ const guardCommand = {};
65
+ if (s.guard_command && typeof s.guard_command === "object" && !Array.isArray(s.guard_command)) {
66
+ for (const [k, v] of Object.entries(s.guard_command)) {
67
+ const ext = String(k).trim().toLowerCase().replace(/^\.?/, "."), cmd = str(v);
68
+ if (ext.length > 1 && cmd) guardCommand[ext] = cmd;
69
+ }
70
+ }
71
+ return { mode, workflow, pathsInput, skipPaths, maxRounds, precheckTimeoutSeconds, precheckTotalSeconds, checkRun, checks, postMerge, flaky, duplicateExports, invariants, ...(Object.keys(guardCommand).length ? { guardCommand } : {}), e2e: parseE2e(s) };
64
72
  }
65
73
 
66
74
  const globRe = (g) => new RegExp(`^${g.split("**").map((p) => p.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*").replace(/\?/g, "[^/]")).join(".*")}$`);
@@ -22,6 +22,10 @@ function fake(o = {}) {
22
22
  }
23
23
 
24
24
  describe("verify-by-CI config (#4167)", () => {
25
+ it("parseVerify normalises guard_command extensions and drops empty values (#4792)", () => {
26
+ assert.deepEqual(parseVerify({ guard_command: { PY: " scripts/pytest {path} ", ".js": "", "": "x" } }).guardCommand, { ".py": "scripts/pytest {path}" });
27
+ assert.equal("guardCommand" in parseVerify({ guard_command: "nope" }), false);
28
+ });
25
29
  it("parseVerify defaults to local and rejects invalid values", () => {
26
30
  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
31
  const v = parseVerify({ mode: "ci", workflow: ".github/workflows/verify.yml", paths_input: "changed_paths", skip_paths: "*.md, docs/** ,", max_rounds: "3" });
@@ -26,12 +26,15 @@ import * as nodeFs from "node:fs";
26
26
  import { join } from "node:path";
27
27
  import { fileURLToPath } from "node:url";
28
28
  import { parseForgeYaml } from "./forge-utils.mjs";
29
+ import { selftestGate, readMarkerComments } from "./lean/selftest-gate.mjs";
29
30
 
30
31
  const INSTALL_ROOT = fileURLToPath(new URL("..", import.meta.url));
31
32
  const USAGE = "usage: forgedock review-staging [<PR number|PR URL>] [--thorough | --delta <from-sha>] [--json]\n" +
32
33
  " forgedock review-staging [<PR>] --finalize --test-gate-output <file> [--notes-dropped <n>] [--json]\n" +
33
34
  " forgedock review-staging [<PR>] --summary [--filed <issue>]... [--json]";
34
35
  const SHA_RE = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
36
+ /** selftest.sandbox_repo turns the release gate on only when it names a repo (`null`, `~`, `false` or prose leave it off). */
37
+ const SANDBOX_RE = /^[\w.-]+\/[\w.-]+$/;
35
38
  /** Persona file / catalog keys per canonical marker domain (review-pr-agents.md routing table). */
36
39
  const INDEX_KEY = { auth: "auth", billing: "billing", database: "database", api: "api", frontend: "frontend", infra: "infrastructure" };
37
40
  const CONFIG_DOMAIN = { scraper: "scraping" };
@@ -137,7 +140,7 @@ function readConfig(io) {
137
140
  try { cfg = parseForgeYaml(io.readFile(path)) ?? {}; } catch { /* absent config: owner/repo check below fails */ }
138
141
  const owner = cfg.project?.owner, repo = cfg.project?.repo;
139
142
  if (!owner || !repo) throw new Stop(1, [`review-staging: project.owner/project.repo not set in ${path}`]);
140
- return { ghRepo: `${owner}/${repo}`, defaultBranch: cfg.branches?.default || "main", stagingBranch: cfg.branches?.staging || "staging", configPath: path };
143
+ return { ghRepo: `${owner}/${repo}`, defaultBranch: cfg.branches?.default || "main", stagingBranch: cfg.branches?.staging || "staging", configPath: path, sandboxRepo: SANDBOX_RE.test(String(cfg.selftest?.sandbox_repo ?? "").trim()) ? String(cfg.selftest.sandbox_repo).trim() : "", sandboxRaw: String(cfg.selftest?.sandbox_repo ?? "").trim() };
141
144
  }
142
145
 
143
146
  /** Same derivation as commands/review-pr-staging.md Phase -1 (and scripts/lib/fence-bootstrap.sh). */
@@ -223,6 +226,14 @@ async function main(argv, io) {
223
226
  const head = await headSha(io, c, pr);
224
227
  if (a.delta && a.delta === head) throw new Stop(1, [`REVIEW_RESULT: status: BLOCKED, blocker: --delta ${head} is the current head of PR #${pr}: nothing changed since that pass, nothing to review`]);
225
228
  const sha7 = head.slice(0, 7);
229
+ // #4865: with selftest.sandbox_repo set, the release needs a passing live selftest for exactly this head (fail closed).
230
+ // Without a sandbox repo there is nothing the harness can run against, so the gate is skipped and says so.
231
+ if (c.sandboxRepo) {
232
+ let marks;
233
+ try { marks = await readMarkerComments(io.gh, c.ghRepo, pr); } catch (e) { throw new Stop(1, [`REVIEW_RESULT: status: BLOCKED, blocker: could not read the FORGE:SELFTEST markers of PR #${pr} (${String(e?.message ?? e).split("\n")[0].slice(0, 200)}); nothing reviewed (fail closed)`]); }
234
+ const selftest = selftestGate(marks, head);
235
+ if (!selftest.ok) throw new Stop(1, [`REVIEW_RESULT: status: BLOCKED, blocker: ${selftest.reason}; nothing reviewed (fail closed)`]);
236
+ } else io.err(`SELFTEST: skipped (selftest.sandbox_repo ${c.sandboxRaw ? `"${c.sandboxRaw.slice(0, 80)}" is not an owner/repo` : "not set"} in forge.yaml; the release does not require a FORGE:SELFTEST marker)`);
226
237
  let priorRoutes = 0;
227
238
  try {
228
239
  const out = await io.gh(["api", "--paginate", `repos/${c.ghRepo}/issues/${pr}/comments`, "--jq", '[.[] | select(.body | test("FORGE:REVIEW_ROUTE"))] | length']);
@@ -454,9 +465,19 @@ const TEST_GATE_NOTE = {
454
465
  BLOCK: (r) => `> **Test Gate: BLOCK (override/advisory)**: Runtime failures were detected but deploy is proceeding. Reason: ${r}. Filed \`test-failure\` issues track the failures.`,
455
466
  };
456
467
 
468
+ /** Checks that never count as CI failures: each only reports this review's own gate marker or the lean gate summary. */
469
+ export const CI_EXCLUDED_CHECKS = ["forgedock/verify", "forge/deploy-gate", "Check for FORGE gate markers"];
470
+
471
+ const parseChecks = (out) => { try { const j = JSON.parse(out); return Array.isArray(j) ? j : null; } catch { return null; } };
472
+
473
+ /** Failing (fail/cancel) checks outside CI_EXCLUDED_CHECKS. */
474
+ export function countFailingChecks(checks) {
475
+ return checks.filter((k) => !CI_EXCLUDED_CHECKS.includes(k?.name) && (k?.bucket === "fail" || k?.bucket === "cancel")).length;
476
+ }
477
+
457
478
  /** Phase 8 verdict from the deterministic inputs (CI, test gate, CONFIRMED CRITICAL/HIGH). */
458
479
  export function stagingVerdict({ ciFailing, testGateInvalid, confirmedCritical, confirmedHigh }) {
459
- if (ciFailing > 0) return { verdict: "BLOCK DEPLOY", why: `CI failing (${ciFailing} required check(s) red; forgedock/verify and forge/deploy-gate excluded)` };
480
+ if (ciFailing > 0) return { verdict: "BLOCK DEPLOY", why: `CI failing (${ciFailing} required check(s) red; forgedock/verify, forge/deploy-gate and the gate-marker check excluded)` };
460
481
  if (testGateInvalid) return { verdict: "BLOCK DEPLOY", why: "test-gate result missing/invalid (fail closed)" };
461
482
  if (confirmedCritical > 0) return { verdict: "BLOCK DEPLOY", why: `${confirmedCritical} CONFIRMED CRITICAL finding(s)` };
462
483
  if (confirmedHigh > 0) return { verdict: "NEEDS FIXES FIRST", why: `${confirmedHigh} CONFIRMED HIGH finding(s)` };
@@ -472,10 +493,11 @@ async function summary(a, io) {
472
493
  const tg = testGateResult(io, sd, pr);
473
494
 
474
495
  // Phase 1E read: forgedock/verify is excluded; an unreadable result counts as failing (fail closed). forge/deploy-gate
475
- // is excluded too: it reports this review's own marker, so an earlier GATE_FAILURE must not block the re-run that clears it.
496
+ // and the gate-marker check are excluded too: they report this review's own marker, so an earlier GATE_FAILURE must not block the re-run that clears it.
476
497
  let ciFailing = 1;
477
- const ci = await io.run("gh", ["pr", "checks", String(pr), "-R", c.ghRepo, "--json", "name,bucket", "--jq", '[.[] | select(.name != "forgedock/verify" and .name != "forge/deploy-gate") | select(.bucket == "fail" or .bucket == "cancel")] | length'], { env, cwd: io.cwd });
478
- if (/^\d+$/.test(ci.stdout.trim())) ciFailing = Number(ci.stdout.trim());
498
+ const ci = await io.run("gh", ["pr", "checks", String(pr), "-R", c.ghRepo, "--json", "name,bucket"], { env, cwd: io.cwd });
499
+ const checks = ci.code === 0 ? parseChecks(ci.stdout) : null;
500
+ if (checks) ciFailing = countFailingChecks(checks);
479
501
 
480
502
  const v = stagingVerdict({ ciFailing, testGateInvalid: tg.invalid, confirmedCritical: st.confirmedCritical, confirmedHigh: st.confirmedHigh });
481
503
  const pass = v.verdict === "APPROVE FOR DEPLOY";
@@ -501,7 +523,7 @@ async function summary(a, io) {
501
523
  "### Risk Matrix",
502
524
  "| Domain | Result | Notes |",
503
525
  "|--------|--------|-------|",
504
- `| CI | ${ciFailing > 0 ? "FAIL" : "PASS"} | ${ciFailing} failing required check(s), forgedock/verify and forge/deploy-gate excluded |`,
526
+ `| CI | ${ciFailing > 0 ? "FAIL" : "PASS"} | ${ciFailing} failing required check(s), forgedock/verify, forge/deploy-gate and the gate-marker check excluded |`,
505
527
  `| Findings | ${st.confirmedCritical} CRITICAL / ${st.confirmedHigh} HIGH (CONFIRMED) | per-domain verdicts are in the FORGE:REVIEW-AGENT comments |`,
506
528
  `| **Test Gate** | \`${tg.verdict}\` | \`${tg.reason || "Phase 6.5 not run"}\` |`,
507
529
  "",
@@ -370,9 +370,9 @@ else
370
370
  # Fail closed: valid fields only (`status`/`conclusion` do not exist on gh pr checks --json; the
371
371
  # old query errored and defaulted to 0 = "no failures"). An unreadable result counts as failing.
372
372
  # forgedock/verify (the lean engine's report-only gate summary) is not a code failure: ignored, as wait-ci-green.sh does.
373
- # forge/deploy-gate (the staging review's marker status) is not fixable by /fix-ci: ignored here, still waited on in Phase 5.
373
+ # forge/deploy-gate and "Check for FORGE gate markers" (the staging review's marker status and its workflow job) are not fixable by /fix-ci: ignored here, still waited on in Phase 5.
374
374
  CURRENT_CI_STATUS=$(gh pr checks "$PR_NUMBER" $GH_FLAG --json name,bucket \
375
- --jq '[.[] | select(.name != "forgedock/verify" and .name != "forge/deploy-gate") | select(.bucket == "fail" or .bucket == "cancel")] | length' 2>/dev/null || echo "1")
375
+ --jq '[.[] | select(.name != "forgedock/verify" and .name != "forge/deploy-gate" and .name != "Check for FORGE gate markers") | select(.bucket == "fail" or .bucket == "cancel")] | length' 2>/dev/null || echo "1")
376
376
 
377
377
  if [ "$CURRENT_CI_STATUS" -eq 0 ]; then
378
378
  echo "CI gate: PASSED (no failing checks)"
@@ -394,7 +394,7 @@ else
394
394
 
395
395
  # Re-check CI status after fix attempt (same fail-closed query as above)
396
396
  FAILING_AFTER=$(gh pr checks "$PR_NUMBER" $GH_FLAG --json name,bucket \
397
- --jq '[.[] | select(.name != "forgedock/verify" and .name != "forge/deploy-gate") | select(.bucket == "fail" or .bucket == "cancel")] | length' 2>/dev/null || echo "1")
397
+ --jq '[.[] | select(.name != "forgedock/verify" and .name != "forge/deploy-gate" and .name != "Check for FORGE gate markers") | select(.bucket == "fail" or .bucket == "cancel")] | length' 2>/dev/null || echo "1")
398
398
 
399
399
  if [ "$FAILING_AFTER" -eq 0 ]; then
400
400
  echo "CI gate: PASSED after $CI_ITER iteration(s)"
@@ -103,10 +103,10 @@ remaining maintenance once no feature is pending; otherwise the rest ends `held
103
103
  `forgedock orchestrate --set-parallel <id> K`. Issues filed while a batch runs join it with
104
104
  `forgedock orchestrate --add <id> N...` (or `--resume <id> N...`), under its K and DAG; never start a second batch
105
105
  for them. After a decision (a `FORGE:DECISION` posted and `needs-human` removed), a blocked issue that already ended in the batch
106
- (`needs-human`, `invalid`, `skipped`, `dependency-*`, `ci-pending`) returns to it with
106
+ (`needs-human`, `invalid`, `skipped`, `dependency-*`, `ci-pending`, `deferred`) returns to it with
107
107
  `forgedock orchestrate --requeue <id> N... --reason "..."` (refused while it is labelled `needs-human` or closed; skipped dependents
108
108
  return to pending with it; a finished batch is reopened); `--add` on a `skipped` issue re-queues it (other ended issues still report `already in batch`). A dependency-skipped issue shows as `waiting on #X` while the batch runs (the blocker is re-checked each pass; merged or closed re-admits it, closed as not planned keeps it skipped) and ends `skipped` only if the blocker never resolved. Its pre-filter still closes already-fixed ones. The runner requeues `deferred` lanes by code (host busy and a full sandbox pool after their
109
- retry-in, uncapped, a usage limit holds admissions for that account (backend identity) until the reset, across every batch on
109
+ retry-in, uncapped, a usage limit holds admissions for that account (backend identity) until the reset, never capped, across every batch on
110
110
  the host; `forgedock limits show|clear <id>|--all` lists or drops holds; transient merge/push/idle failures back off; other
111
111
  deferrals stop after `orchestration.max_deferrals`), relaunches an `engine-error` once, and stops admitting after
112
112
  `orchestration.breaker` engine-errors (`errors` within `window_min`; held issues continue on `--resume`). Lanes are
@@ -212,10 +212,10 @@ gh pr checks "$PR_NUMBER" -R "$GH_REPO" 2>&1
212
212
  # forgedock/verify (the lean engine's report-only gate summary) is not a code failure: ignored, as wait-ci-green.sh does.
213
213
  # Fail closed: an unreadable result counts as failing.
214
214
  CI_FAILING=$(gh pr checks "$PR_NUMBER" -R "$GH_REPO" --json name,bucket \
215
- --jq '[.[] | select(.name != "forgedock/verify" and .name != "forge/deploy-gate") | select(.bucket == "fail" or .bucket == "cancel")] | length' 2>/dev/null || echo "1")
215
+ --jq '[.[] | select(.name != "forgedock/verify" and .name != "forge/deploy-gate" and .name != "Check for FORGE gate markers") | select(.bucket == "fail" or .bucket == "cancel")] | length' 2>/dev/null || echo "1")
216
216
  echo "CI_FAILING: $CI_FAILING"
217
217
  ```
218
- `CI_FAILING` > 0 → BLOCK DEPLOY (unless autofixed in Phase 1F). A red `forgedock/verify` alone does not block, and neither does `forge/deploy-gate`: it reports this review's own gate marker, so an earlier `GATE_FAILURE` never blocks the re-run that clears it.
218
+ `CI_FAILING` > 0 → BLOCK DEPLOY (unless autofixed in Phase 1F). A red `forgedock/verify` alone does not block, and neither do `forge/deploy-gate` or `Check for FORGE gate markers`: they report this review's own gate marker, so an earlier `GATE_FAILURE` never blocks the re-run that clears it.
219
219
 
220
220
  ### Phase 1F: CI Autofix
221
221