forgedock 1.36.0 → 1.37.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 (112) hide show
  1. package/bin/bot-token-status.mjs +26 -0
  2. package/bin/engine/events.mjs +10 -1
  3. package/bin/engine/events.test.mjs +15 -1
  4. package/bin/engine/outcome-codes.mjs +39 -1
  5. package/bin/engine/phases.mjs +65 -5
  6. package/bin/engine/telemetry-seam.mjs +1 -1
  7. package/bin/engine/telemetry-seam.test.mjs +4 -0
  8. package/bin/engine-cli.mjs +268 -37
  9. package/bin/engine.mjs +3 -1
  10. package/bin/forgedock.mjs +108 -5
  11. package/bin/labels.json +50 -0
  12. package/bin/lean/batch.mjs +10 -4
  13. package/bin/lean/batch.test.mjs +75 -1
  14. package/bin/lean/conformance.e2e.test.mjs +1 -1
  15. package/bin/lean/conformance.mjs +125 -11
  16. package/bin/lean/conformance.test.mjs +122 -1
  17. package/bin/lean/decisions.mjs +181 -0
  18. package/bin/lean/decisions.test.mjs +71 -0
  19. package/bin/lean/effects.mjs +29 -14
  20. package/bin/lean/execution.mjs +60 -16
  21. package/bin/lean/execution.test.mjs +59 -2
  22. package/bin/lean/gatewatch.mjs +31 -0
  23. package/bin/lean/gatewatch.test.mjs +59 -0
  24. package/bin/lean/guardgate.mjs +170 -0
  25. package/bin/lean/guardgate.test.mjs +153 -0
  26. package/bin/lean/guards.mjs +243 -0
  27. package/bin/lean/guards.test.mjs +177 -0
  28. package/bin/lean/heavy.mjs +54 -8
  29. package/bin/lean/heavy.test.mjs +74 -1
  30. package/bin/lean/hostkill.mjs +1 -1
  31. package/bin/lean/integration.mjs +276 -0
  32. package/bin/lean/integration.test.mjs +189 -0
  33. package/bin/lean/lean.mjs +134 -28
  34. package/bin/lean/lean.test.mjs +239 -22
  35. package/bin/lean/lifecycle.mjs +129 -0
  36. package/bin/lean/lifecycle.test.mjs +118 -0
  37. package/bin/lean/light.mjs +1 -1
  38. package/bin/lean/milestonesync.mjs +130 -0
  39. package/bin/lean/milestonesync.test.mjs +133 -0
  40. package/bin/lean/orchestrate.mjs +83 -15
  41. package/bin/lean/parked-sweep.mjs +45 -0
  42. package/bin/lean/parked-sweep.test.mjs +71 -0
  43. package/bin/lean/postdeploy-check.mjs +38 -6
  44. package/bin/lean/postdeploy-check.test.mjs +62 -2
  45. package/bin/lean/postdeploy-monitor.mjs +12 -2
  46. package/bin/lean/postdeploy-monitor.test.mjs +27 -1
  47. package/bin/lean/prefilter.mjs +83 -23
  48. package/bin/lean/prefilter.test.mjs +73 -1
  49. package/bin/lean/probe.mjs +2 -2
  50. package/bin/lean/probe.test.mjs +1 -1
  51. package/bin/lean/prompts.mjs +15 -9
  52. package/bin/lean/resume.test.mjs +1 -1
  53. package/bin/lean/retry.mjs +28 -0
  54. package/bin/lean/sandbox-provider.contract.test.mjs +8 -3
  55. package/bin/lean/sandbox-remote.mjs +4 -4
  56. package/bin/lean/schedd.mjs +53 -9
  57. package/bin/lean/schedd.test.mjs +77 -5
  58. package/bin/lean/sequence.mjs +24 -0
  59. package/bin/lean/sequence.test.mjs +31 -1
  60. package/bin/lean/signoff.mjs +66 -0
  61. package/bin/lean/signoff.test.mjs +102 -0
  62. package/bin/lean/train.mjs +177 -0
  63. package/bin/lean/train.test.mjs +113 -0
  64. package/bin/pool/cli.mjs +2 -0
  65. package/bin/pool/health.mjs +30 -0
  66. package/bin/review-context.mjs +298 -0
  67. package/bin/runner.mjs +26 -3
  68. package/bin/stats/compounding.mjs +157 -0
  69. package/bin/stats/compounding.test.mjs +74 -0
  70. package/bin/stats/index.mjs +21 -2
  71. package/commands/bootstrap.md +10 -8
  72. package/commands/deploy-info.md +3 -0
  73. package/commands/issue.md +2 -0
  74. package/commands/milestone.md +43 -54
  75. package/commands/orchestrate/phase-4-execution.md +2 -0
  76. package/commands/orchestrate.md +8 -1
  77. package/commands/pipeline-status.md +20 -0
  78. package/commands/review-pr.md +1 -0
  79. package/commands/work-on/build/implement.md +1 -1
  80. package/commands/work-on/build.md +4 -2
  81. package/commands/work-on/investigate.md +3 -1
  82. package/commands/work-on/remediate.md +1 -1
  83. package/docs/CONFIG.md +71 -2
  84. package/docs/EVENTS.md +1 -4
  85. package/docs/OUTCOME-CODES.md +9 -1
  86. package/docs/STATS.md +20 -0
  87. package/docs/site/start-a-new-project.md +1 -1
  88. package/docs/spec/label-state-machine.md +6 -0
  89. package/package.json +1 -1
  90. package/pool/install/install-agent.sh +4 -2
  91. package/pool/install/install-router.sh +4 -2
  92. package/pool/install/install-schedd.sh +28 -0
  93. package/pool/install/unit-hardening.sh +34 -0
  94. package/scripts/acceptance-red-green.sh +19 -2
  95. package/scripts/acceptance-red-green.test.sh +2 -0
  96. package/scripts/bootstrap-create.mjs +3 -2
  97. package/scripts/bootstrap-validate.mjs +31 -4
  98. package/scripts/file-deferred-followups.sh +285 -77
  99. package/scripts/file-deferred-followups.test.sh +111 -4
  100. package/scripts/hotspots.sh +23 -2
  101. package/scripts/hotspots.test.sh +9 -0
  102. package/scripts/plan-validate-live.mjs +111 -0
  103. package/scripts/readiness.mjs +141 -0
  104. package/scripts/review-prepare.sh +17 -1
  105. package/scripts/review-prepare.test.sh +17 -0
  106. package/scripts/settle-scope.mjs +74 -0
  107. package/scripts/systemd/forgedock-bot-token-refresh.service +7 -0
  108. package/scripts/systemd/forgedock-bot-token-refresh.timer +11 -0
  109. package/scripts/wait-ci-green.sh +14 -3
  110. package/scripts/wait-ci-green.test.sh +3 -1
  111. package/templates/bootstrap/needs-human-issue.md +4 -1
  112. package/templates/github-actions/migration-journal-guard.test.mjs +26 -0
@@ -0,0 +1,170 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ /**
4
+ * Guard gate (#4693): execution-based, no pattern matching on diff text. A lane whose diff changes lines a merged fix
5
+ * protected (git blame on the lane's merge base names the guard's merge commit, or a later commit that kept the lines), or
6
+ * edits/deletes a guard's test file, reruns that guard's test files *as they exist on base* against the lane's tree. A
7
+ * failure is `conformance.guard_broken` (builder resumed once). The only override is a `supersedes` amendment (conformance.mjs).
8
+ * Deterministic, 0 LLM calls; a lane touching nothing guarded does one local index read and no git work.
9
+ */
10
+ import * as nodeFs from "node:fs";
11
+ import { dirname, extname, join } from "node:path";
12
+ import { OUTCOME_CODES } from "../engine/outcome-codes.mjs";
13
+ import { collectGuards, guardsIndexPath, readIndex, writeIndex } from "./guards.mjs";
14
+ import { isTestPath } from "./enumerating.mjs";
15
+
16
+ export const GUARD_CAP_MS = 120000;
17
+ export const INDEX_MAX_AGE_MS = 6 * 3600000;
18
+ const TIMEOUT_RC = 124;
19
+ const SLACK = 10; // lines a guarded hunk may have drifted since its merge when only a later commit kept it
20
+ const SHA_RE = /^[0-9a-f]{40}$/;
21
+ const ok = async (fn) => { try { await fn(); return true; } catch { return false; } };
22
+ const norm = (p) => String(p ?? "").replace(/^\.\//, "");
23
+
24
+ /** Guards from the local index; rebuilt from the GitHub register when absent or older than INDEX_MAX_AGE_MS (a stale index is still used when GitHub is unreachable). */
25
+ export async function loadGuards({ repo, gh, root, fs = nodeFs, now = Date.now() }) {
26
+ const cached = readIndex(repo, { root, fs });
27
+ let age = Infinity;
28
+ try { age = now - fs.statSync(guardsIndexPath(repo, root)).mtimeMs; } catch { /* no index yet */ }
29
+ if (cached && age < INDEX_MAX_AGE_MS) return cached;
30
+ try { const g = await collectGuards({ gh, repo }); writeIndex(repo, g, { root, fs }); return g; } catch { return cached ?? []; }
31
+ }
32
+
33
+ /** Old-side line ranges of a `git diff -U0` text for non-test files: {file: [[start, end]]}. Pure insertions touch no old line. */
34
+ export function oldRanges(diff) {
35
+ const out = {};
36
+ let file = null;
37
+ for (const line of String(diff ?? "").split("\n")) {
38
+ if (line.startsWith("--- ")) { file = line === "--- /dev/null" ? null : line.replace(/^--- a\//, ""); continue; }
39
+ if (line.startsWith("+++ ")) { if (line === "+++ /dev/null") continue; continue; }
40
+ const m = /^@@ -(\d+)(?:,(\d+))? \+/.exec(line);
41
+ if (!m || !file || isTestPath(file)) continue;
42
+ const start = Number(m[1]), len = m[2] === undefined ? 1 : Number(m[2]);
43
+ if (len > 0) (out[file] ??= []).push([start, start + len - 1]);
44
+ }
45
+ return out;
46
+ }
47
+
48
+ /** Commit shas that last touched lines a..b of `file` at `rev` (git blame --porcelain). */
49
+ export function blameShas(text) {
50
+ const shas = new Set();
51
+ for (const l of String(text ?? "").split("\n")) { const s = l.split(" ")[0]; if (SHA_RE.test(s)) shas.add(s); }
52
+ return shas;
53
+ }
54
+
55
+ /**
56
+ * The guards a lane's diff intersects, each with why it was selected: {guard, reason}. `files` are the lane's changed paths.
57
+ * Guards whose hunk files and test files the lane does not touch are never looked at (no git calls).
58
+ */
59
+ export async function selectGuards({ guards, files, git, base }) {
60
+ const changed = new Set((files ?? []).map(norm));
61
+ const cands = (guards ?? []).filter((g) => (g.hunks ?? []).some((h) => changed.has(norm(h.file))) || (g.tests ?? []).some((t) => changed.has(norm(t.path))));
62
+ if (!cands.length) return { selected: [], mergeBase: null };
63
+ const mergeBase = String(await git(["merge-base", base, "HEAD"])).trim();
64
+ const hunkFiles = [...new Set(cands.flatMap((g) => (g.hunks ?? []).map((h) => norm(h.file))).filter((f) => changed.has(f)))];
65
+ const ranges = hunkFiles.length ? oldRanges(String(await git(["diff", "-U0", "--no-renames", `${base}...HEAD`, "--", ...hunkFiles]))) : {};
66
+ const blamed = new Map(); // `${file}:${a}-${b}` -> Set(sha)
67
+ const shasOf = async (file, [a, b]) => {
68
+ const k = `${file}:${a}-${b}`;
69
+ if (!blamed.has(k)) blamed.set(k, blameShas(await git(["blame", "--porcelain", "-L", `${a},${b}`, mergeBase, "--", file]).catch(() => "")));
70
+ return blamed.get(k);
71
+ };
72
+ const selected = [];
73
+ for (const g of cands) {
74
+ const sha = String(g.merge_sha ?? "");
75
+ const known = SHA_RE.test(sha) || sha.length >= 7 ? await ok(() => git(["cat-file", "-e", `${sha}^{commit}`])) : false;
76
+ let reason = null;
77
+ for (const h of g.hunks ?? []) {
78
+ const f = norm(h.file);
79
+ for (const r of ranges[f] ?? []) {
80
+ const near = r[0] <= h.end + SLACK && r[1] >= h.start - SLACK;
81
+ if (!known) { if (near) reason = `lines ${r[0]}-${r[1]} of ${f} overlap the protected hunk (merge commit not local)`; }
82
+ else if (near) {
83
+ for (const s of await shasOf(f, r)) {
84
+ if (s === sha || (s.startsWith(sha) && sha.length >= 7) || sha.startsWith(s) || await ok(() => git(["merge-base", "--is-ancestor", sha, s]))) { reason = `lines ${r[0]}-${r[1]} of ${f} last changed by ${s.slice(0, 10)}, which carries the fix`; break; }
85
+ }
86
+ }
87
+ if (reason) break;
88
+ }
89
+ if (reason) break;
90
+ }
91
+ if (!reason) { const t = (g.tests ?? []).find((x) => changed.has(norm(x.path))); if (t) reason = `the lane edits or deletes its test ${t.path}`; }
92
+ if (reason) selected.push({ guard: g, reason });
93
+ }
94
+ return { selected, mergeBase };
95
+ }
96
+
97
+ /** The command that runs one test file: the contract's own validate command with that path substituted, else a per-extension default; null when unknown. */
98
+ export function commandFor(path, contract) {
99
+ const ext = extname(path);
100
+ for (const v of contract?.validate ?? []) {
101
+ const cmd = String(v?.command ?? "");
102
+ if (/(?:^|\s)(?:-k|-t|--grep|--test-name-pattern|--testNamePattern)(?:\s|=)|::/.test(cmd)) continue;
103
+ const toks = cmd.match(/[^\s"']+/g) ?? [];
104
+ const tests = toks.filter((t) => extname(t) === ext && isTestPath(t));
105
+ if (tests.length === 1 && /\b(?:node|pytest|python3?|vitest|jest|npx|pnpm|npm|yarn|bun)\b/.test(cmd)) return cmd.replace(tests[0], path);
106
+ }
107
+ if ([".mjs", ".cjs", ".js"].includes(ext)) return `node --test ${path}`;
108
+ if (ext === ".py") return `python -m pytest -q ${path}`;
109
+ return null;
110
+ }
111
+
112
+ /** Sibling path for the base copy of a test file, so its relative imports resolve against the lane's tree. */
113
+ export const basePathOf = (path) => { const e = extname(path); return `${path.slice(0, path.length - e.length)}__guardbase${e}`; };
114
+
115
+ const tailOf = (out) => String(out ?? "").split("\n").filter((l) => l.trim()).slice(-40).join("\n").slice(-1500);
116
+
117
+ /**
118
+ * Rerun the selected guards' base test files against the lane tree, in a small pool, under one wall-time cap.
119
+ * Returns {failures, runs, skipped[], ms}. A test that cannot be run (no base copy, unknown runner, over the cap) is skipped, never a failure.
120
+ */
121
+ export async function runGuards({ selected, mergeBase, contract, git, runCommand, cwd, env, capMs = GUARD_CAP_MS, parallelism = 2, fs = nodeFs, now = Date.now }) {
122
+ const t0 = now(), skipped = [], runs = [], jobs = [];
123
+ for (const { guard } of selected) {
124
+ for (const t of guard.tests ?? []) {
125
+ const path = norm(t.path), copy = basePathOf(path), command = commandFor(copy, contract);
126
+ if (!command) { skipped.push(`#${guard.issue} ${path}: no runner known for ${extname(path) || "this file"}`); continue; }
127
+ jobs.push({ guard, path, copy, command });
128
+ }
129
+ }
130
+ const made = [];
131
+ try {
132
+ const queue = jobs.slice();
133
+ const work = async () => {
134
+ for (let j = queue.shift(); j; j = queue.shift()) {
135
+ const left = capMs - (now() - t0);
136
+ if (left <= 0) { skipped.push(`#${j.guard.issue} ${j.path}: over the ${Math.round(capMs / 1000)}s guard cap`); continue; }
137
+ let body;
138
+ try { body = await git(["show", `${mergeBase}:${j.path}`]); } catch { skipped.push(`#${j.guard.issue} ${j.path}: not on base`); continue; }
139
+ const abs = join(cwd, j.copy);
140
+ try { fs.mkdirSync(dirname(abs), { recursive: true }); fs.writeFileSync(abs, body); made.push(abs); } catch (e) { skipped.push(`#${j.guard.issue} ${j.path}: ${String(e?.message ?? e).slice(0, 80)}`); continue; }
141
+ let r;
142
+ try { r = await runCommand({ command: j.command, cwd, timeoutMs: left, env }); } catch (e) { r = { rc: 127, output: String(e?.message ?? e) }; }
143
+ if (r?.rc === TIMEOUT_RC) { skipped.push(`#${j.guard.issue} ${j.path}: over the ${Math.round(capMs / 1000)}s guard cap`); continue; }
144
+ if (r?.rc === 127) { skipped.push(`#${j.guard.issue} ${j.path}: runner unavailable`); continue; }
145
+ runs.push({ issue: j.guard.issue, why: j.guard.why, test: j.path, command: j.command, rc: Number.isInteger(r?.rc) ? r.rc : 1, output: String(r?.output ?? "") });
146
+ }
147
+ };
148
+ await Promise.all(Array.from({ length: Math.max(1, Math.min(parallelism, jobs.length)) }, work));
149
+ } finally {
150
+ for (const p of made) { try { fs.rmSync(p, { force: true }); } catch { /* best effort */ } }
151
+ }
152
+ return { failures: guardFailures(runs), runs, skipped, ms: now() - t0 };
153
+ }
154
+
155
+ /** conformance.guard_broken rows (one per failing guard test): the guard's issue, its one-line why, the failing test and its output tail. */
156
+ export function guardFailures(runs) {
157
+ return (runs ?? []).filter((r) => r.rc !== 0).map((r) => ({
158
+ code: OUTCOME_CODES.CONFORMANCE_GUARD_BROKEN, kind: "guard_broken", guard: `#${r.issue}`, test: r.test,
159
+ detail: `guard #${r.issue} broken (${String(r.why ?? "").slice(0, 200)}): base test ${r.test} fails against your tree (\`${r.command}\`, exit ${r.rc}):\n${tailOf(r.output)}`,
160
+ }));
161
+ }
162
+
163
+ /** The comment the engine posts on a guarded issue once its guard is superseded. */
164
+ export const supersessionComment = ({ guard, pr, why }) => `The fix guard from ${guard} was superseded by PR #${pr}: ${String(why ?? "").trim()}\n\nThe lane's contract carried a \`supersedes\` amendment the reviewer accepted; this guard's test no longer describes current behaviour.`;
165
+
166
+ /** Guard ids the effective contract's accepted supersedes amendments retire, limited to guards the gate selected. */
167
+ export function supersededIds(contract, selected) {
168
+ const sel = new Set((selected ?? []).map((s) => `#${s.guard.issue}`));
169
+ return (contract?.supersedes ?? []).filter((s) => sel.has(s.guard));
170
+ }
@@ -0,0 +1,153 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ import test from "node:test";
4
+ import assert from "node:assert/strict";
5
+ import { execFileSync, spawnSync } from "node:child_process";
6
+ import { existsSync, mkdirSync, readdirSync, writeFileSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { oldRanges, selectGuards, runGuards, commandFor, basePathOf, guardFailures, supersessionComment, supersededIds, loadGuards } from "./guardgate.mjs";
9
+ import { applyAmendments, precheck } from "./conformance.mjs";
10
+ import { tmpDir } from "../tests/_tmp.mjs";
11
+
12
+ const sh = (cwd, ...a) => execFileSync("git", a, { cwd, encoding: "utf8", env: { ...process.env, GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@t", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@t" } });
13
+ const gitIn = (cwd) => async (args) => sh(cwd, ...args);
14
+ const { NODE_TEST_CONTEXT: _ctx, ...CLEAN_ENV } = process.env; // the fixture runs node --test inside a test
15
+ const runCommand = async ({ command, cwd }) => { const r = spawnSync("sh", ["-c", command], { cwd, encoding: "utf8", env: CLEAN_ENV }); return { rc: r.status ?? 1, output: `${r.stdout}${r.stderr}` }; };
16
+ const put = (cwd, p, body) => { mkdirSync(join(cwd, p, ".."), { recursive: true }); writeFileSync(join(cwd, p), body); };
17
+
18
+ const TEST_SRC = (v) => `import test from "node:test";\nimport assert from "node:assert/strict";\nimport { price } from "./a.mjs";\ntest("price is ${v}", () => assert.equal(price(), ${v}));\n`;
19
+
20
+ /** A repo whose fix commit (merge_sha) made price() return 2, guarded by src/a.test.mjs; the lane starts from it. */
21
+ function fixture() {
22
+ const d = tmpDir("guardgate-");
23
+ sh(d, "init", "-q", "-b", "main");
24
+ put(d, "src/a.mjs", "export function price() {\n return 1;\n}\n");
25
+ sh(d, "add", "-A"); sh(d, "commit", "-qm", "init");
26
+ put(d, "src/a.mjs", "export function price() {\n return 2;\n}\n");
27
+ put(d, "src/a.test.mjs", TEST_SRC(2));
28
+ sh(d, "add", "-A"); sh(d, "commit", "-qm", "fix: price");
29
+ const sha = sh(d, "rev-parse", "HEAD").trim();
30
+ put(d, "src/other.mjs", "export const x = 1;\n");
31
+ sh(d, "add", "-A"); sh(d, "commit", "-qm", "later");
32
+ sh(d, "update-ref", "refs/remotes/origin/main", "HEAD");
33
+ sh(d, "checkout", "-qb", "lane");
34
+ const guard = { v: 1, issue: 7, pr: 9, merge_sha: sha, tests: [{ path: "src/a.test.mjs", ids: [] }], hunks: [{ file: "src/a.mjs", start: 2, end: 2, symbol: "price" }], why: "price must be 2" };
35
+ return { d, guard };
36
+ }
37
+ const commit = (d, m) => { sh(d, "add", "-A"); sh(d, "commit", "-qm", m); };
38
+ const files = (d) => sh(d, "diff", "--name-only", "origin/main...HEAD").split("\n").filter(Boolean);
39
+ const gate = async (d, guard, contract = {}) => {
40
+ const git = gitIn(d);
41
+ const { selected, mergeBase } = await selectGuards({ guards: [guard], files: files(d), git, base: "origin/main" });
42
+ const r = selected.length ? await runGuards({ selected, mergeBase, contract, git, runCommand, cwd: d, env: process.env }) : null;
43
+ return { selected, r };
44
+ };
45
+
46
+ test("a lane that reverts a guarded fix fails guard_broken even when it also edits the guard's test to pass", async () => {
47
+ const { d, guard } = fixture();
48
+ put(d, "src/a.mjs", "export function price() {\n return 1;\n}\n"); // reverts the fix
49
+ put(d, "src/a.test.mjs", TEST_SRC(1)); // and weakens the test so the lane's own copy passes
50
+ commit(d, "revert");
51
+ assert.equal((await runCommand({ command: "node --test src/a.test.mjs", cwd: d })).rc, 0, "the lane's own edited test passes");
52
+ const { selected, r } = await gate(d, guard);
53
+ assert.equal(selected.length, 1);
54
+ assert.match(selected[0].reason, /src\/a\.mjs/);
55
+ assert.equal(r.failures.length, 1, JSON.stringify({ runs: r.runs, skipped: r.skipped }));
56
+ assert.equal(r.failures[0].code, "conformance.guard_broken");
57
+ assert.equal(r.failures[0].guard, "#7");
58
+ assert.match(r.failures[0].detail, /price must be 2/);
59
+ assert.match(r.failures[0].detail, /src\/a\.test\.mjs/);
60
+ assert.deepEqual(readdirSync(join(d, "src")).filter((f) => f.includes("__guardbase")), [], "base copies are removed");
61
+ const pc = precheck({ contract: { requirements: [], deliverables: [{ id: "D1", path: "src/a.mjs" }, { id: "D2", path: "src/a.test.mjs" }], validate: [] }, result: {}, changedFiles: files(d), guardFailures: r.failures });
62
+ assert.deepEqual(pc.failures.filter((f) => f.kind === "guard_broken").map((f) => f.guard), ["#7"]);
63
+ assert.equal(pc.ok, false);
64
+ });
65
+
66
+ test("an accepted supersedes amendment passes the precheck and names the superseded guard for the issue comment", async () => {
67
+ const { d, guard } = fixture();
68
+ put(d, "src/a.mjs", "export function price() {\n return 3;\n}\n");
69
+ commit(d, "change");
70
+ const { selected, r } = await gate(d, guard);
71
+ assert.equal(r.failures.length, 1);
72
+ const frozen = { requirements: [], deliverables: [{ id: "D1", path: "src/a.mjs" }], validate: [] };
73
+ const a = applyAmendments(frozen, [{ kind: "supersedes", guard: "#7", why: "price is now 3 by product decision" }]);
74
+ assert.deepEqual(a.problems, []);
75
+ assert.deepEqual(a.applied, [{ kind: "supersedes", target: "#7", why: "price is now 3 by product decision" }]);
76
+ const pc = precheck({ contract: a.contract, result: {}, changedFiles: ["src/a.mjs"], guardFailures: r.failures });
77
+ assert.equal(pc.ok, true, JSON.stringify(pc.failures));
78
+ const sup = supersededIds(a.contract, selected);
79
+ assert.deepEqual(sup.map((s) => s.guard), ["#7"]);
80
+ assert.match(supersessionComment({ guard: "#7", pr: 12, why: sup[0].why }), /#7 was superseded by PR #12: price is now 3/);
81
+ assert.deepEqual(supersededIds(a.contract, []), [], "only selected guards are retired");
82
+ });
83
+
84
+ test("a supersedes amendment without a guard id is rejected", () => {
85
+ const a = applyAmendments({ requirements: [], deliverables: [] }, [{ kind: "supersedes", why: "x" }, { kind: "supersedes", guard: "abc", why: "x" }]);
86
+ assert.equal(a.problems.length, 2);
87
+ assert.match(a.problems[0], /needs guard/);
88
+ });
89
+
90
+ test("a lane touching no guarded hunk selects nothing and runs no git or test", async () => {
91
+ const { d, guard } = fixture();
92
+ put(d, "src/other.mjs", "export const x = 2;\n");
93
+ commit(d, "unrelated");
94
+ const calls = [];
95
+ const git = async (a) => { calls.push(a[0]); return gitIn(d)(a); };
96
+ const r = await selectGuards({ guards: [guard], files: files(d), git, base: "origin/main" });
97
+ assert.deepEqual(r.selected, []);
98
+ assert.deepEqual(calls, [], "no git call at all");
99
+ // same file as the guard but lines far from the hunk, with the guard's commit not the blamed one
100
+ const big = { ...guard, hunks: [{ file: "src/a.mjs", start: 200, end: 210 }] };
101
+ assert.deepEqual((await selectGuards({ guards: [big], files: files(d).concat("src/a.mjs"), git: gitIn(d), base: "origin/main" })).selected, []);
102
+ });
103
+
104
+ test("deleting or editing a guard's test selects it even when no guarded line changed", async () => {
105
+ const { d, guard } = fixture();
106
+ sh(d, "rm", "-q", "src/a.test.mjs");
107
+ commit(d, "drop test");
108
+ const { selected, r } = await gate(d, guard);
109
+ assert.equal(selected.length, 1);
110
+ assert.match(selected[0].reason, /edits or deletes its test/);
111
+ assert.deepEqual(r.failures, [], "the base test still passes against the unchanged code");
112
+ assert.equal(r.runs.length, 1);
113
+ });
114
+
115
+ test("a base test that cannot run in the cap, or has no known runner, is skipped and never blocks", async () => {
116
+ const { d, guard } = fixture();
117
+ put(d, "src/a.mjs", "export function price() {\n return 1;\n}\n");
118
+ commit(d, "revert");
119
+ const git = gitIn(d);
120
+ const { selected, mergeBase } = await selectGuards({ guards: [guard], files: files(d), git, base: "origin/main" });
121
+ const slow = await runGuards({ selected, mergeBase, contract: {}, git, runCommand: async () => ({ rc: 124, output: "" }), cwd: d, env: {} });
122
+ assert.deepEqual(slow.failures, []);
123
+ assert.match(slow.skipped[0], /guard cap/);
124
+ const none = await runGuards({ selected: [{ guard: { ...guard, tests: [{ path: "src/a.test.rb" }] }, reason: "" }], mergeBase, contract: {}, git, runCommand, cwd: d, env: {} });
125
+ assert.match(none.skipped[0], /no runner/);
126
+ assert.equal(existsSync(join(d, basePathOf("src/a.test.mjs"))), false);
127
+ });
128
+
129
+ test("pytest-style guards run through the contract's own validate command with the path substituted", () => {
130
+ assert.equal(commandFor("tests/test_a__guardbase.py", { validate: [{ command: "pytest -q tests/test_a.py" }] }), "pytest -q tests/test_a__guardbase.py");
131
+ assert.equal(commandFor("tests/test_a__guardbase.py", {}), "python -m pytest -q tests/test_a__guardbase.py");
132
+ assert.equal(commandFor("src/a.test__guardbase.mjs", { validate: [{ command: "node --test src/a.test.mjs -t x" }] }), "node --test src/a.test__guardbase.mjs");
133
+ assert.equal(commandFor("a.rb", {}), null);
134
+ assert.equal(basePathOf("src/a.test.mjs"), "src/a.test__guardbase.mjs");
135
+ });
136
+
137
+ test("oldRanges reads old-side lines of non-test files and skips pure insertions", () => {
138
+ const diff = "--- a/src/a.mjs\n+++ b/src/a.mjs\n@@ -3,2 +3 @@\n-a\n-b\n+c\n@@ -9,0 +9,2 @@\n+x\n+y\n@@ -20 +19,0 @@\n-z\n--- a/src/a.test.mjs\n+++ b/src/a.test.mjs\n@@ -1 +1 @@\n";
139
+ assert.deepEqual(oldRanges(diff), { "src/a.mjs": [[3, 4], [20, 20]] });
140
+ });
141
+
142
+ test("guardFailures keeps only failing runs and loadGuards falls back to a stale index when GitHub is down", async () => {
143
+ assert.deepEqual(guardFailures([{ issue: 1, rc: 0 }]), []);
144
+ const root = tmpDir("guardgate-idx-");
145
+ const fs = await import("node:fs");
146
+ const { writeIndex } = await import("./guards.mjs");
147
+ writeIndex("o/r", [{ issue: 3 }], { root });
148
+ const old = new Date(Date.now() - 48 * 3600000);
149
+ fs.utimesSync(join(root, "o_r.json"), old, old);
150
+ const down = await loadGuards({ repo: "o/r", gh: async () => { throw new Error("offline"); }, root });
151
+ assert.deepEqual(down, [{ issue: 3 }]);
152
+ assert.deepEqual(await loadGuards({ repo: "o/none", gh: async () => "[]", root }), []);
153
+ });
@@ -0,0 +1,243 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ /**
4
+ * Fix guards (#4692): a merged lane whose frozen contract names a regression/repro test records a guard on its issue as
5
+ * `<!-- FORGE:GUARD {json} -->`: {v, issue, pr, merge_sha, tests:[{path, ids}], hunks:[{file, start, end, symbol}], why}.
6
+ * GitHub is the register; `~/.forge/guards/<repo>.json` is a cache rebuilt from those comments. Codebase-agnostic and
7
+ * deterministic (0 LLM calls): test identity comes from the contract's repro_test and validate commands, never from a
8
+ * language parser; without per-test ids a guard is file-granular (ids: []). Protected hunks come from the merged diff
9
+ * against the merge commit's first parent, which is the base for both merge-commit and squash merges.
10
+ */
11
+ import * as nodeFs from "node:fs";
12
+ import { homedir } from "node:os";
13
+ import { dirname, join } from "node:path";
14
+ import { isTestPath } from "./enumerating.mjs";
15
+ import { parseContractComment } from "./render.mjs";
16
+
17
+ export const GUARD_MARKER = "FORGE:GUARD";
18
+ const GUARD_RE = /<!-- FORGE:GUARD (\{[\s\S]*?\}) -->/;
19
+ const PATHISH = /^[\w@.+/-]+\.[A-Za-z0-9]+$/;
20
+ const hasMarker = (b) => GUARD_RE.test(String(b ?? ""));
21
+
22
+ export const guardsIndexPath = (repo, root = join(homedir(), ".forge", "guards")) => join(root, `${String(repo).replace(/[^A-Za-z0-9._-]/g, "_")}.json`);
23
+
24
+ /** Test ids a validate/repro command names: `-k expr`, `--test-name-pattern x`, `-t x`, `--grep x`, and `file::name` pytest ids. */
25
+ function idsOf(command, path) {
26
+ const toks = String(command ?? "").match(/(?:[^\s"']|"[^"]*"|'[^']*')+/g) ?? [];
27
+ const unq = (t) => t.replace(/^(["'])(.*)\1$/, "$2");
28
+ const ids = [];
29
+ toks.forEach((t, i) => {
30
+ const v = unq(t);
31
+ if (/^(?:-k|-t|--grep|--test-name-pattern|--testNamePattern)$/.test(v) && toks[i + 1]) ids.push(unq(toks[i + 1]));
32
+ else if (/^--(?:k|grep|test-name-pattern|testNamePattern)=/.test(v)) ids.push(unq(v.split("=").slice(1).join("=")));
33
+ else if (v.startsWith(`${path}::`)) ids.push(v.slice(path.length + 2));
34
+ });
35
+ return ids;
36
+ }
37
+
38
+ /** Test files (and ids) from the contract's repro_test and validate commands. [] when the contract names no test. */
39
+ export function guardTests(contract) {
40
+ const by = new Map();
41
+ const add = (path, ids) => { const e = by.get(path) ?? new Set(); ids.filter(Boolean).forEach((i) => e.add(i)); by.set(path, e); };
42
+ const t = contract?.repro_test;
43
+ const cmds = [];
44
+ if (t && typeof t === "object") {
45
+ if (t.path) add(String(t.path), t.id ? [String(t.id)] : []);
46
+ cmds.push(t.command);
47
+ }
48
+ for (const v of contract?.validate ?? []) cmds.push(v?.command);
49
+ for (const c of cmds) {
50
+ for (const tok of String(c ?? "").match(/[^\s"']+/g) ?? []) {
51
+ const p = tok.split("::")[0];
52
+ if (PATHISH.test(p) && isTestPath(p)) add(p, idsOf(c, p));
53
+ }
54
+ }
55
+ if (t && typeof t === "object" && t.path) add(String(t.path), []);
56
+ return [...by].map(([path, ids]) => ({ path, ids: [...ids] }));
57
+ }
58
+
59
+ /** Hunks of a `git diff -U0` text for non-test files: [{file, start, end, symbol}] in post-image lines (deletions anchor at the line after). */
60
+ export function parseHunks(diff) {
61
+ const out = [];
62
+ let file = null;
63
+ for (const line of String(diff ?? "").split("\n")) {
64
+ if (line.startsWith("+++ ")) { file = line === "+++ /dev/null" ? null : line.replace(/^\+\+\+ b\//, ""); continue; }
65
+ if (line.startsWith("--- ")) { if (line === "--- /dev/null") file = null; continue; }
66
+ const m = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@ ?(.*)$/.exec(line);
67
+ if (!m || !file || isTestPath(file)) continue;
68
+ const delOnly = m[4] === "0", start = Number(m[3]) + (delOnly ? 1 : 0), len = m[4] === undefined ? 1 : Number(m[4]);
69
+ out.push({ file, start, end: delOnly ? start : start + len - 1, symbol: m[5].trim().slice(0, 120) || null });
70
+ }
71
+ return out;
72
+ }
73
+
74
+ /** The guard for a merged lane, or {skipped, reason} when its contract names no regression/repro test. */
75
+ export function buildGuard({ issue, title = "", pr = null, mergeSha = null, contract, diff = "" }) {
76
+ const tests = guardTests(contract);
77
+ if (!tests.length) {
78
+ const why = contract?.repro_test === "none" ? `repro_test none (${String(contract.repro_test_reason ?? "").slice(0, 120)})` : "contract names no test file";
79
+ return { skipped: true, reason: `no guard for #${issue}: ${why}` };
80
+ }
81
+ return { guard: { v: 1, issue, pr, merge_sha: mergeSha, tests, hunks: parseHunks(diff), why: String(title).replace(/\s+/g, " ").trim().slice(0, 200) } };
82
+ }
83
+
84
+ export const renderGuard = (g) => `<!-- ${GUARD_MARKER} ${JSON.stringify(g).replace(/</g, "\\u003c").replace(/>/g, "\\u003e")} -->`;
85
+ export function parseGuard(body) {
86
+ const m = GUARD_RE.exec(String(body ?? ""));
87
+ if (!m) return null;
88
+ try { const g = JSON.parse(m[1]); return g && g.v === 1 && Number.isInteger(g.issue) ? g : null; } catch { return null; }
89
+ }
90
+
91
+ const json = (s, d) => { try { return JSON.parse(String(s) || "null") ?? d; } catch { return d; } };
92
+
93
+ /**
94
+ * Build the guard for one merged issue from GitHub alone (its FORGE:CONTRACT comment, merged PR and merge commit) and post it
95
+ * once. Idempotent: an issue that already has a FORGE:GUARD comment is left alone. Returns {posted|exists|skipped, ...}.
96
+ */
97
+ export async function guardIssue({ gh, git, repo, issue, pr = null, post = null, dryRun = false }) {
98
+ const iss = json(await gh(["issue", "view", String(issue), "-R", repo, "--json", "number,title,comments"]), null);
99
+ if (!iss) return { skipped: true, reason: `#${issue}: issue not readable` };
100
+ const bodies = (iss.comments ?? []).map((c) => c?.body ?? "");
101
+ const existing = bodies.map(parseGuard).find(Boolean);
102
+ if (existing) return { exists: true, guard: existing };
103
+ const cb = [...bodies].reverse().find((b) => b.startsWith("<!-- FORGE:CONTRACT -->") && parseContractComment(b));
104
+ if (!cb) return { skipped: true, reason: `no guard for #${issue}: no frozen FORGE:CONTRACT comment` };
105
+ const contract = parseContractComment(cb).contract;
106
+ let prn = pr, sha = null;
107
+ const view = prn
108
+ ? json(await gh(["pr", "view", String(prn), "-R", repo, "--json", "number,state,mergeCommit"]), null)
109
+ : json(await gh(["pr", "list", "-R", repo, "--state", "merged", "--search", `#${issue} in:body`, "--limit", "5", "--json", "number,mergeCommit,body"]), []).find((p) => new RegExp(`(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\\s+#${issue}\\b`, "i").test(p.body ?? "")) ?? null;
110
+ if (view) { prn = view.number ?? prn; sha = view.mergeCommit?.oid ?? null; }
111
+ if (!sha) return { skipped: true, reason: `no guard for #${issue}: no merged PR with a merge commit` };
112
+ let diff = "";
113
+ const getDiff = async () => String(await git(["diff", "-U0", "--no-color", "--no-renames", `${sha}^1`, sha]));
114
+ try {
115
+ try { diff = await getDiff(); } catch { await git(["fetch", "origin", sha]); diff = await getDiff(); } // merge commit not local yet: fetch it by sha
116
+ } catch (e) {
117
+ return { skipped: true, reason: `no guard for #${issue}: merge commit ${sha.slice(0, 10)} not available locally (${String(e?.message ?? e).split("\n")[0].slice(0, 100)})` };
118
+ }
119
+ const built = buildGuard({ issue, title: iss.title, pr: prn, mergeSha: sha, contract, diff });
120
+ if (built.skipped) return built;
121
+ if (!dryRun) await (post ?? ((b) => gh(["issue", "comment", String(issue), "-R", repo, "--body", b])))(renderGuard(built.guard));
122
+ return { posted: true, guard: built.guard };
123
+ }
124
+
125
+ /** Guards read from a repo's issue comments (the register of record). */
126
+ export async function collectGuards({ gh, repo, limit = 500 }) {
127
+ const rows = json(await gh(["issue", "list", "-R", repo, "--state", "all", "--search", `${GUARD_MARKER} in:comments`, "--limit", String(limit), "--json", "number,comments"]), []);
128
+ const guards = [];
129
+ for (const r of rows) for (const c of r.comments ?? []) { const g = hasMarker(c?.body) ? parseGuard(c.body) : null; if (g) { guards.push(g); break; } }
130
+ return guards;
131
+ }
132
+
133
+ /** Rewrite the local cache from the guards found on GitHub. */
134
+ export function writeIndex(repo, guards, { root, fs = nodeFs } = {}) {
135
+ const p = guardsIndexPath(repo, root);
136
+ fs.mkdirSync(dirname(p), { recursive: true });
137
+ fs.writeFileSync(p, JSON.stringify({ v: 1, repo, guards }, null, 1));
138
+ return p;
139
+ }
140
+ export function readIndex(repo, { root, fs = nodeFs } = {}) {
141
+ try { const j = JSON.parse(fs.readFileSync(guardsIndexPath(repo, root), "utf8")); return Array.isArray(j?.guards) ? j.guards : null; } catch { return null; }
142
+ }
143
+
144
+ /** Guards covering `path` (a protected hunk file or a guarding test file; a directory prefix matches too). No path: all. */
145
+ export function listGuards(guards, { path = null } = {}) {
146
+ if (!path) return guards;
147
+ const p = String(path).replace(/^\.\//, "").replace(/\/$/, "");
148
+ const hit = (f) => f === p || f.startsWith(`${p}/`);
149
+ return guards.filter((g) => (g.hunks ?? []).some((h) => hit(h.file)) || (g.tests ?? []).some((t) => hit(t.path)));
150
+ }
151
+
152
+ const H_ROW = /^H\d+$/;
153
+ const dirOf = (f) => String(f).split("/").slice(0, -1).join("/");
154
+
155
+ /**
156
+ * History preserve rows (#4694): at most `cap` `{id:"H<n>", what, source:"guard"}` rows for the guards covering the deliverable
157
+ * paths. Pure and deterministic: score 3 = a protected hunk file is a deliverable, 2 = a guarding test file is, 1 = either sits
158
+ * under a deliverable's directory; ties go to the newest issue. The lane's own issue is skipped.
159
+ */
160
+ export function historyPreserve({ guards, deliverables, issue = null, cap = 5 }) {
161
+ const paths = [...new Set((deliverables ?? []).map((d) => String(d?.path ?? d ?? "").replace(/^\.\//, "")).filter(Boolean))];
162
+ if (!paths.length || !Array.isArray(guards) || !guards.length) return [];
163
+ const dirs = paths.map(dirOf).filter(Boolean);
164
+ const under = (f) => dirs.some((d) => f.startsWith(`${d}/`));
165
+ const scored = [];
166
+ for (const g of guards) {
167
+ if (!g || g.issue == null || String(g.issue) === String(issue)) continue;
168
+ const hunks = (g.hunks ?? []).map((h) => String(h.file)), tests = (g.tests ?? []).map((t) => String(t.path));
169
+ const score = hunks.some((f) => paths.includes(f)) ? 3 : tests.some((f) => paths.includes(f)) ? 2 : [...hunks, ...tests].some(under) ? 1 : 0;
170
+ if (score) scored.push({ g, score });
171
+ }
172
+ scored.sort((a, b) => b.score - a.score || Number(b.g.issue) - Number(a.g.issue) || (Number(a.g.pr) || 0) - (Number(b.g.pr) || 0));
173
+ const seen = new Set();
174
+ return scored.filter(({ g }) => !seen.has(String(g.issue)) && seen.add(String(g.issue))).slice(0, cap).map(({ g }, i) => ({
175
+ id: `H${i + 1}`,
176
+ what: `${String(g.why ?? "").replace(/\s+/g, " ").trim().slice(0, 160) || "guarded fix"} (#${g.issue}${g.pr ? `, PR #${g.pr}` : ""})`,
177
+ source: "guard",
178
+ }));
179
+ }
180
+
181
+ /** The contract with H-rows from the local guards index appended to preserve; the same object when nothing applies. Never throws. */
182
+ export function withHistoryRows(contract, { repo, issue, root, fs = nodeFs } = {}) {
183
+ try {
184
+ const guards = readIndex(repo, { root, fs });
185
+ if (!guards?.length) return contract;
186
+ const rows = historyPreserve({ guards, deliverables: contract?.deliverables, issue });
187
+ if (!rows.length) return contract;
188
+ return { ...contract, preserve: [...(contract.preserve ?? []).filter((p) => !(H_ROW.test(p?.id) && p?.source === "guard")), ...rows] };
189
+ } catch { return contract; }
190
+ }
191
+
192
+ /**
193
+ * Backfill guards for issues closed since `since` (ISO date) that ran through the lean lane (a FORGE:CONTRACT comment and a
194
+ * merged PR). Lanes without a test yield no guard; the reason is reported in `logs`. Idempotent.
195
+ */
196
+ export async function backfill({ gh, git, repo, since, dryRun = false, log = () => {} }) {
197
+ const rows = json(await gh(["issue", "list", "-R", repo, "--state", "closed", "--search", `closed:>=${since}`, "--limit", "500", "--json", "number"]), []);
198
+ const res = { posted: [], exists: [], skipped: [] };
199
+ for (const { number } of rows) {
200
+ let r;
201
+ try { r = await guardIssue({ gh, git, repo, issue: number, dryRun }); } catch (e) { r = { skipped: true, reason: `#${number}: ${String(e?.message ?? e).split("\n")[0].slice(0, 160)}` }; }
202
+ if (r.posted) res.posted.push(number); else if (r.exists) res.exists.push(number);
203
+ else { res.skipped.push({ issue: number, reason: r.reason }); log(r.reason); }
204
+ }
205
+ return res;
206
+ }
207
+
208
+ /** Lane hook: after a merge, fetch the base so the merge commit is local, then guard the issue. Best effort: never throws. */
209
+ export async function guardAfterMerge({ io, repo, issue, pr, cwd, base = null, root }) {
210
+ try {
211
+ const branch = base ? String(base).replace(/^(?:refs\/remotes\/)?origin\//, "") : null; // lean.mjs passes `origin/<lane>`
212
+ if (branch) { try { await io.git(["fetch", "origin", branch], { cwd }); } catch { /* offline: use the local ref */ } }
213
+ const r = await guardIssue({ gh: (a) => io.gh(a), git: (a) => io.git(a, { cwd }), repo, issue, pr });
214
+ if (r.posted) { // #4694: keep the local cache current so the next lane's freeze sees this guard; only extend an existing index
215
+ try { const cur = readIndex(repo, { root }); if (cur) writeIndex(repo, [...cur.filter((g) => g.issue !== r.guard.issue), r.guard], { root }); } catch { /* cache only */ }
216
+ }
217
+ return r;
218
+ } catch (e) { return { skipped: true, reason: `guard failed: ${String(e?.message ?? e).split("\n")[0].slice(0, 160)}` }; }
219
+ }
220
+
221
+ /** `forgedock guards list [--path p] [--json] | backfill --since <date> [--dry-run]`. Returns the exit code. */
222
+ export async function guardsCli(argv, { gh, git, repo, print = console.log, root, fs = nodeFs } = {}) {
223
+ const sub = argv[0];
224
+ const flag = (n) => { const i = argv.indexOf(n); return i >= 0 ? argv[i + 1] : null; };
225
+ if (sub === "list") {
226
+ let guards = null;
227
+ try { guards = await collectGuards({ gh, repo }); writeIndex(repo, guards, { root, fs }); } catch { guards = readIndex(repo, { root, fs }); }
228
+ if (!guards) throw new Error("guards list: GitHub is unreachable and there is no local index");
229
+ const hits = listGuards(guards, { path: flag("--path") });
230
+ if (argv.includes("--json")) print(JSON.stringify(hits, null, 1));
231
+ else if (!hits.length) print("No guards" + (flag("--path") ? ` cover ${flag("--path")}` : "") + ".");
232
+ else for (const g of hits) print(`#${g.issue} (PR ${g.pr ?? "?"}, ${String(g.merge_sha ?? "").slice(0, 10)}): ${g.why}\n tests: ${g.tests.map((t) => t.path + (t.ids.length ? ` [${t.ids.join(", ")}]` : "")).join(", ")}\n hunks: ${(g.hunks ?? []).map((h) => `${h.file}:${h.start}-${h.end}${h.symbol ? ` (${h.symbol})` : ""}`).join(", ") || "none"}`);
233
+ return 0;
234
+ }
235
+ if (sub === "backfill") {
236
+ const since = flag("--since");
237
+ if (!since || Number.isNaN(Date.parse(since))) throw new Error("usage: forgedock guards backfill --since <YYYY-MM-DD> [--dry-run]");
238
+ const r = await backfill({ gh, git, repo, since, dryRun: argv.includes("--dry-run"), log: (m) => print(m) });
239
+ print(`guards backfill: ${r.posted.length} ${argv.includes("--dry-run") ? "would be posted" : "posted"}, ${r.exists.length} already guarded, ${r.skipped.length} skipped`);
240
+ return 0;
241
+ }
242
+ throw new Error("usage: forgedock guards list [--path p] [--json] | backfill --since <date> [--dry-run]");
243
+ }