forgedock 1.27.0 → 1.28.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 (127) hide show
  1. package/README.md +4 -1
  2. package/bin/bootstrap.mjs +55 -0
  3. package/bin/engine/outcome-codes.mjs +10 -0
  4. package/bin/engine/telemetry-seam.mjs +37 -8
  5. package/bin/engine/telemetry-seam.test.mjs +31 -0
  6. package/bin/engine-cli.mjs +542 -38
  7. package/bin/engine.mjs +8 -4
  8. package/bin/forgedock.mjs +75 -9
  9. package/bin/gc.mjs +580 -0
  10. package/bin/lean/admission.mjs +33 -3
  11. package/bin/lean/batch.mjs +22 -4
  12. package/bin/lean/batch.test.mjs +5 -3
  13. package/bin/lean/checks.mjs +199 -0
  14. package/bin/lean/checks.test.mjs +183 -0
  15. package/bin/lean/conformance.mjs +190 -58
  16. package/bin/lean/conformance.test.mjs +116 -7
  17. package/bin/lean/credentials.mjs +29 -0
  18. package/bin/lean/depcache.mjs +312 -0
  19. package/bin/lean/depcache.test.mjs +298 -0
  20. package/bin/lean/drift.mjs +198 -0
  21. package/bin/lean/drift.test.mjs +223 -0
  22. package/bin/lean/effects.mjs +85 -8
  23. package/bin/lean/execution.e2e.test.mjs +81 -0
  24. package/bin/lean/execution.mjs +431 -0
  25. package/bin/lean/execution.test.mjs +583 -0
  26. package/bin/lean/heavy.mjs +289 -0
  27. package/bin/lean/heavy.test.mjs +207 -0
  28. package/bin/lean/lean.mjs +572 -139
  29. package/bin/lean/lean.test.mjs +1193 -31
  30. package/bin/lean/ledger.mjs +27 -2
  31. package/bin/lean/ledger.test.mjs +36 -1
  32. package/bin/lean/limits.mjs +58 -0
  33. package/bin/lean/limits.test.mjs +150 -0
  34. package/bin/lean/orchestrate.mjs +312 -19
  35. package/bin/lean/prompts.mjs +89 -15
  36. package/bin/lean/refactor.mjs +276 -0
  37. package/bin/lean/refactor.test.mjs +359 -0
  38. package/bin/lean/render.mjs +24 -5
  39. package/bin/lean/review-trigger.mjs +134 -0
  40. package/bin/lean/review-trigger.test.mjs +104 -0
  41. package/bin/lean/sandbox-provider.contract.test.mjs +257 -0
  42. package/bin/lean/sandbox-remote.mjs +142 -0
  43. package/bin/lean/sched.mjs +221 -0
  44. package/bin/lean/sched.test.mjs +310 -0
  45. package/bin/lean/schedd.mjs +247 -0
  46. package/bin/lean/schedd.test.mjs +180 -0
  47. package/bin/lean/toolchain.mjs +228 -0
  48. package/bin/lean/toolchain.test.mjs +154 -0
  49. package/bin/lean/verify.mjs +20 -9
  50. package/bin/lean/verify.test.mjs +26 -3
  51. package/bin/lean/worktree.mjs +69 -0
  52. package/bin/lean/worktree.test.mjs +141 -0
  53. package/bin/runner.mjs +53 -10
  54. package/bin/stats/github-source.mjs +1 -1
  55. package/bin/stats/index.mjs +14 -2
  56. package/bin/stats/query.mjs +72 -0
  57. package/bin/stats/stats.test.mjs +54 -0
  58. package/commands/audit.md +2 -0
  59. package/commands/autopilot.md +2 -0
  60. package/commands/bootstrap.md +35 -0
  61. package/commands/cleanup.md +7 -22
  62. package/commands/forge-stats.md +2 -1
  63. package/commands/forgedock-init.md +7 -0
  64. package/commands/issue.md +89 -830
  65. package/commands/milestone.md +55 -6
  66. package/commands/orchestrate.md +10 -2
  67. package/commands/upgrade-deps.md +3 -3
  68. package/commands/work-on/build/validate.md +4 -0
  69. package/commands/work-on/close.md +2 -0
  70. package/commands/work-on/decompose.md +24 -6
  71. package/docs/CI.md +3 -3
  72. package/docs/CONFIG.md +177 -2
  73. package/docs/OUTCOME-CODES.md +8 -0
  74. package/docs/TELEMETRY.md +1 -1
  75. package/docs/site/command-reference.md +22 -0
  76. package/docs/site/getting-started.md +2 -0
  77. package/docs/site/start-a-new-project.md +327 -0
  78. package/package.json +1 -1
  79. package/scripts/affected-files-check.sh +91 -0
  80. package/scripts/affected-files-check.test.sh +80 -0
  81. package/scripts/bootstrap-create.mjs +393 -0
  82. package/scripts/bootstrap-validate.mjs +36 -2
  83. package/scripts/calibration.mjs +116 -0
  84. package/scripts/check-devdocs-freshness.sh +9 -4
  85. package/scripts/check-devdocs-freshness.test.sh +11 -1
  86. package/scripts/check-spec-markers.sh +3 -0
  87. package/scripts/check-spec-size.sh +29 -11
  88. package/scripts/check-spec-size.test.sh +27 -4
  89. package/scripts/ci-local/lib.mjs +8 -1
  90. package/scripts/ci-local/parallelism.test.mjs +27 -0
  91. package/scripts/ci-local/run.mjs +3 -3
  92. package/scripts/ci-local/scoping.mjs +82 -0
  93. package/scripts/ci-local.test.sh +1 -1
  94. package/scripts/fixtures/symbol-index/other/sample.rb +3 -0
  95. package/scripts/fixtures/symbol-index/py/pkg/__init__.py +1 -0
  96. package/scripts/fixtures/symbol-index/py/pkg/a.py +5 -0
  97. package/scripts/fixtures/symbol-index/py/pkg/b.py +14 -0
  98. package/scripts/fixtures/symbol-index/py/pkg/c.py +1 -0
  99. package/scripts/fixtures/symbol-index/py/sample.py +41 -0
  100. package/scripts/fixtures/symbol-index/ts/sample.ts +13 -0
  101. package/scripts/fixtures/symbol-move/gen_big.py +201 -0
  102. package/scripts/fixtures/symbol-move-ts/gen_big.mjs +56 -0
  103. package/scripts/forge-root.test.sh +1 -1
  104. package/scripts/hotspots.sh +100 -0
  105. package/scripts/hotspots.test.sh +101 -0
  106. package/scripts/issue-create.sh +197 -0
  107. package/scripts/issue-create.test.sh +166 -0
  108. package/scripts/maintenance-scan.sh +185 -0
  109. package/scripts/maintenance-scan.test.sh +89 -0
  110. package/scripts/milestone-drift.sh +206 -0
  111. package/scripts/milestone-drift.test.sh +120 -0
  112. package/scripts/refactor-verify.sh +368 -0
  113. package/scripts/refactor-verify.test.sh +202 -0
  114. package/scripts/structure-delta.sh +10 -5
  115. package/scripts/structure-delta.test.sh +3 -2
  116. package/scripts/symbol-index.mjs +253 -0
  117. package/scripts/symbol-index.py +354 -0
  118. package/scripts/symbol-index.test.sh +119 -0
  119. package/scripts/symbol-move-ts.test.sh +161 -0
  120. package/scripts/symbol-move.mjs +379 -0
  121. package/scripts/symbol-move.py +383 -0
  122. package/scripts/symbol-move.test.sh +168 -0
  123. package/templates/bootstrap/interview.md +34 -0
  124. package/templates/bootstrap/issue-body.md +42 -0
  125. package/templates/bootstrap/needs-human-issue.md +45 -0
  126. package/templates/bootstrap/stack-profiles.md +34 -0
  127. package/templates/bootstrap/technical-spike.md +75 -0
@@ -0,0 +1,198 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ /**
4
+ * Milestone drift checkpoint (forge#4161). No LLM anywhere: facts that merged lanes already computed are summed, and
5
+ * an issue is filed only when a threshold is crossed.
6
+ *
7
+ * - Summary: each merged milestone lane's merge-time STRUCTURE_DELTA (`result.structure`, lean.mjs recordStructure) is
8
+ * folded into one `<!-- FORGE:DRIFT {json} -->` block in the GitHub milestone description (GET + PATCH per merge).
9
+ * The fold is keyed by PR, so a re-fold is a no-op. Text outside the block is kept byte for byte.
10
+ * - Report: `scripts/milestone-drift.sh` recomputes pair growth, new edges and size growth from git (fork point to
11
+ * the milestone head). The summary only attributes PRs.
12
+ * - Crossings (only with `orchestrate.drift` thresholds): one templated issue per crossing in the milestone,
13
+ * deduplicated by its `<!-- FORGE:DRIFT:<slug>:<kind>:<key> -->` marker against the milestone's open issues.
14
+ * - block_dispatch (off by default): a queued issue on the same milestone lane whose Affected Files sit under a
15
+ * flagged package dir is held.
16
+ */
17
+ import { execFile } from "node:child_process";
18
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
19
+ import { tmpdir } from "node:os";
20
+ import { join } from "node:path";
21
+ import { fileURLToPath } from "node:url";
22
+
23
+ export const SUMMARY_MARKER = "FORGE:DRIFT";
24
+ const SUMMARY_RE = /<!-- FORGE:DRIFT (\{[\s\S]*?\}) -->/;
25
+ // The summary keeps the largest growth entries only, so the description stays small.
26
+ export const MAX_GROWTH_FILES = 50;
27
+ const SCRIPT = fileURLToPath(new URL("../../scripts/milestone-drift.sh", import.meta.url));
28
+
29
+ export const emptySummary = (milestone) => ({ v: 1, milestone, merged: [], pairs: {}, deps_added: [], deps_removed: [], growth: {} });
30
+
31
+ const arr = (x) => (Array.isArray(x) ? x : []);
32
+ const edgeKey = (e) => `${e?.from}:${e?.to}`;
33
+
34
+ /**
35
+ * Fold one merged run's structure record into the summary (pure; returns a new object). Keyed by PR: a PR already in
36
+ * `merged` leaves the summary unchanged.
37
+ */
38
+ export function foldSummary(summary, { issue, pr, milestone, structure }) {
39
+ const s = summary && typeof summary === "object" ? summary : emptySummary(milestone ?? null);
40
+ const merged = arr(s.merged);
41
+ if (pr != null && merged.some((m) => m?.pr === pr)) return s;
42
+ const st = structure ?? {};
43
+ const pairs = { ...(s.pairs ?? {}) };
44
+ const touch = (e, field, n) => {
45
+ if (!e?.from || !e?.to || !(n > 0)) return;
46
+ const k = edgeKey(e), p = pairs[k] ?? { files_added: 0, files_removed: 0, prs: [] };
47
+ pairs[k] = { ...p, [field]: (p[field] ?? 0) + n, prs: field === "files_added" && pr != null && !p.prs.includes(pr) ? [...p.prs, pr] : p.prs };
48
+ };
49
+ for (const e of arr(st.new_edges)) touch(e, "files_added", Number(e?.files) || 0);
50
+ for (const e of arr(st.removed_edges)) touch(e, "files_removed", Number(e?.files) || 0);
51
+ const deps = (old, add) => [...arr(old), ...arr(add).filter((e) => e?.from && e?.to).map((e) => ({ from: e.from, to: e.to, ...(e.kind ? { kind: e.kind } : {}), pr: pr ?? null }))];
52
+ const growth = { ...(s.growth ?? {}) };
53
+ for (const g of arr(st.top_growth)) if (g?.file && Number(g.lines) > 0) growth[g.file] = (growth[g.file] ?? 0) + Number(g.lines);
54
+ const topGrowth = Object.fromEntries(Object.entries(growth).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, MAX_GROWTH_FILES));
55
+ return { v: 1, milestone: s.milestone ?? milestone ?? null, merged: [...merged, { issue: issue ?? null, pr: pr ?? null }], pairs,
56
+ deps_added: deps(s.deps_added, st.deps_added), deps_removed: deps(s.deps_removed, st.deps_removed), growth: topGrowth };
57
+ }
58
+
59
+ /** The FORGE:DRIFT summary in a milestone description, or null. */
60
+ export function parseSummary(description) {
61
+ const m = SUMMARY_RE.exec(String(description ?? ""));
62
+ if (!m) return null;
63
+ try { const s = JSON.parse(m[1]); return s && typeof s === "object" && !Array.isArray(s) ? s : null; } catch { return null; }
64
+ }
65
+
66
+ /** `description` with its FORGE:DRIFT block replaced (or appended); the text around it is unchanged. */
67
+ export function renderSummary(description, summary) {
68
+ const d = String(description ?? "");
69
+ // `>` never appears in the JSON's structure, so escaping it keeps `-->` out of the comment and the JSON valid.
70
+ const block = `<!-- ${SUMMARY_MARKER} ${JSON.stringify(summary).replace(/>/g, "\\u003e")} -->`;
71
+ if (SUMMARY_RE.test(d)) return d.replace(SUMMARY_RE, () => block);
72
+ return d ? `${d}\n\n${block}` : block;
73
+ }
74
+
75
+ export const crossingMarker = (slug, c) => `FORGE:DRIFT:${slug}:${c.kind}:${c.key}`;
76
+
77
+ /** One issue per crossing: {title, body, marker}. The body is a template (numbers and files from the report). */
78
+ export function crossingIssues(report, { slug, milestone }) {
79
+ const fork = String(report?.fork_point ?? "").slice(0, 12);
80
+ const files = arr(report?.files);
81
+ return arr(report?.crossings).map((c) => {
82
+ const marker = crossingMarker(slug, c);
83
+ let evidence = [];
84
+ if (c.kind === "pair_growth") {
85
+ const [from, to] = String(c.key).split("->");
86
+ const p = arr(report?.pairs).find((x) => x.from === from && x.to === to) ?? {};
87
+ evidence = [...arr(p.files).map((f) => `- \`${f}\``), ...(arr(p.prs).length ? [`- PRs: ${p.prs.map((n) => `#${n}`).join(", ")}`] : [])];
88
+ } else if (c.kind === "package_lines") {
89
+ evidence = files.filter((f) => f.package === c.key).map((f) => `- \`${f.path}\` +${f.added} -${f.removed}`);
90
+ } else {
91
+ const f = files.find((x) => x.path === c.key);
92
+ if (f) evidence = [`- \`${f.path}\` +${f.added} -${f.removed}`];
93
+ }
94
+ const what = { pair_growth: "value-import files", package_lines: "lines of growth", file_lines: "lines of growth" }[c.kind] ?? "growth";
95
+ const body = [
96
+ "## Problem", "",
97
+ `\`milestone/${slug}\` (${milestone}) crossed \`orchestrate.drift.${c.kind}\` since its fork point \`${fork}\`: \`${c.key}\` has ${c.value} ${what} (threshold ${c.threshold}). No single PR shows this; found by \`scripts/milestone-drift.sh\` after a lane merged.`, "",
98
+ "## Evidence", "", ...(evidence.length ? evidence : ["- (see `scripts/milestone-drift.sh " + slug + "`)"]), "",
99
+ "## Acceptance Criteria", "",
100
+ `- [ ] \`scripts/milestone-drift.sh ${slug}\` no longer reports \`${c.kind}\` for \`${c.key}\`, or the growth is recorded as intended in the repo's architecture doc`, "",
101
+ `<!-- ${marker} -->`,
102
+ ].join("\n");
103
+ return { title: `drift(milestone/${slug}): ${c.kind} ${c.key} ${c.value} > ${c.threshold}`, body, marker, kind: c.kind, key: c.key };
104
+ });
105
+ }
106
+
107
+ /**
108
+ * File the crossings that no open issue of the milestone carries yet: one `gh issue list` read, one create per new
109
+ * crossing. `seen` (markers filed earlier in this process) guards against a list that lags a fresh create.
110
+ * @returns {Promise<{filed: Array<{marker:string, number:?number}>, skipped: string[]}>}
111
+ */
112
+ export async function fileCrossings({ gh, repo, milestone, issues, seen = new Set() }) {
113
+ if (!issues.length) return { filed: [], skipped: [] };
114
+ const open = JSON.parse(String(await gh(["issue", "list", "-R", repo, "--milestone", milestone, "--state", "open", "--limit", "500", "--json", "number,body"])) || "[]");
115
+ const bodies = arr(open).map((x) => String(x?.body ?? ""));
116
+ const filed = [], skipped = [];
117
+ for (const it of issues) {
118
+ if (seen.has(it.marker) || bodies.some((b) => b.includes(`<!-- ${it.marker} -->`))) { skipped.push(it.marker); continue; }
119
+ const out = String(await gh(["issue", "create", "-R", repo, "--title", it.title, "--body", it.body, "--milestone", milestone]));
120
+ seen.add(it.marker);
121
+ filed.push({ marker: it.marker, number: Number(/\/issues\/(\d+)/.exec(out)?.[1]) || null });
122
+ }
123
+ return { filed, skipped };
124
+ }
125
+
126
+ /** Package dirs a report flags: package_lines crossings and the importing side of pair_growth crossings. */
127
+ export function flaggedPackages(report) {
128
+ return [...new Set(arr(report?.crossings).filter((c) => (c.kind === "package_lines" || c.kind === "pair_growth") && c.package_path).map((c) => c.package_path))].sort();
129
+ }
130
+
131
+ /** The first flagged package dir that one of `files` sits under (or names), else null. */
132
+ export function heldBy(files, flagged) {
133
+ for (const d of arr(flagged)) if (arr(files).some((f) => f === d || String(f).startsWith(`${d}/`))) return d;
134
+ return null;
135
+ }
136
+
137
+ /** {number, description} of the milestone titled `title`, or null. One `gh api` read. */
138
+ export async function readMilestone({ gh, repo, title }) {
139
+ const list = JSON.parse(String(await gh(["api", `repos/${repo}/milestones?state=all&per_page=100`])) || "[]");
140
+ const m = arr(list).find((x) => x?.title === title);
141
+ return m ? { number: m.number, description: m.description ?? "" } : null;
142
+ }
143
+
144
+ const defaultRunScript = (args, { cwd }) => new Promise((res, rej) => {
145
+ execFile("bash", [SCRIPT, ...args], { cwd, timeout: 60000, maxBuffer: 16 * 1024 * 1024 }, (e, stdout) => (e ? rej(e) : res(stdout)));
146
+ });
147
+
148
+ /**
149
+ * The orchestrate seam: `{ onMerged(issue, row, result), holds? }`. `config` is engine-cli `readDrift()` (null: the
150
+ * summary only, no script run, no issue, no hold). `holds` exists only with `config.blockDispatch`. Calls to onMerged
151
+ * are serialised, so parallel merges never race on the milestone description.
152
+ */
153
+ export function makeDrift({ repo, cwd, io, config = null, stagingBranch = "staging", affectedFiles = () => [], runScript = defaultRunScript, onEvent = () => {} }) {
154
+ const flagged = new Map(); // lane -> flagged package dirs (from the last report of that milestone)
155
+ const seen = new Set();
156
+ let chain = Promise.resolve();
157
+ const thresholds = config && ["pairGrowth", "packageLines", "fileLines"].some((k) => config[k] != null);
158
+
159
+ async function check(issue, row, result) {
160
+ const lane = String(result?.lane ?? row?.lane ?? "");
161
+ const title = result?.milestone;
162
+ if (!lane.startsWith("milestone/") || !title || !result?.structure) return null;
163
+ const slug = lane.slice("milestone/".length);
164
+ const ms = await readMilestone({ gh: io.gh, repo, title });
165
+ if (!ms) { onEvent({ issue, detail: `drift: milestone "${title}" not found` }); return null; }
166
+ const before = parseSummary(ms.description);
167
+ const summary = foldSummary(before ?? emptySummary(title), { issue, pr: result.pr ?? row?.pr ?? null, milestone: title, structure: result.structure });
168
+ if (summary !== before) await io.gh(["api", "-X", "PATCH", `repos/${repo}/milestones/${ms.number}`, "-f", `description=${renderSummary(ms.description, summary)}`]);
169
+ if (!thresholds) return { summary };
170
+ for (const b of [lane, stagingBranch]) { try { await io.git(["fetch", "origin", b], { cwd }); } catch { /* offline: the local refs */ } }
171
+ const dir = mkdtempSync(join(tmpdir(), "milestone-drift-")), file = join(dir, "summary.json");
172
+ let report;
173
+ try {
174
+ writeFileSync(file, JSON.stringify(summary));
175
+ const t = (flag, v) => (v != null ? [flag, String(v)] : []);
176
+ report = JSON.parse(String(await runScript([slug, "--summary", file, ...t("--pair-growth", config.pairGrowth), ...t("--package-lines", config.packageLines), ...t("--file-lines", config.fileLines)], { cwd })));
177
+ } finally { rmSync(dir, { recursive: true, force: true }); }
178
+ flagged.set(lane, flaggedPackages(report));
179
+ const filed = await fileCrossings({ gh: io.gh, repo, milestone: title, issues: crossingIssues(report, { slug, milestone: title }), seen });
180
+ if (filed.filed.length) onEvent({ issue, detail: `drift: filed ${filed.filed.map((f) => (f.number ? `#${f.number}` : f.marker)).join(", ")}` });
181
+ return { summary, report, ...filed };
182
+ }
183
+
184
+ const seam = {
185
+ onMerged(issue, row, result) {
186
+ const p = chain.then(() => check(issue, row, result));
187
+ chain = p.catch(() => {});
188
+ return p;
189
+ },
190
+ };
191
+ if (config?.blockDispatch) {
192
+ seam.holds = (issue, { body, lane }) => {
193
+ const f = flagged.get(lane);
194
+ return f?.length ? heldBy(affectedFiles(body), f) : null;
195
+ };
196
+ }
197
+ return seam;
198
+ }
@@ -0,0 +1,223 @@
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 { writeFileSync } from "node:fs";
6
+ import { tmpDir } from "../tests/_tmp.mjs";
7
+ import { join } from "node:path";
8
+ import { foldSummary, emptySummary, parseSummary, renderSummary, crossingIssues, fileCrossings, flaggedPackages, heldBy, makeDrift } from "./drift.mjs";
9
+ import { orchestrate } from "./orchestrate.mjs";
10
+ import { readDrift } from "../engine-cli.mjs";
11
+ import { affectedFiles } from "../orchestrate-preflight.mjs";
12
+
13
+ const S1 = { deps_added: [{ from: "@s/web", to: "@s/core", kind: "dependencies", manifest: "apps/web/package.json" }], deps_removed: [],
14
+ new_edges: [{ from: "@s/web", to: "@s/db", file: "apps/web/a.ts", files: 2 }], removed_edges: [], top_growth: [{ file: "packages/db/big.ts", lines: 40 }], decisions: [] };
15
+ const S2 = { deps_added: [], deps_removed: [], new_edges: [{ from: "@s/web", to: "@s/db", file: "apps/web/c.ts", files: 1 }],
16
+ removed_edges: [{ from: "@s/web", to: "@s/old", files: 1 }], top_growth: [{ file: "packages/db/big.ts", lines: 10 }], decisions: [] };
17
+ const REPORT = {
18
+ milestone: "m1", fork_point: "0123456789abcdef",
19
+ pairs: [{ from: "@s/web", to: "@s/db", growth: 6, files_added: 6, files_removed: 0, files: ["apps/web/a.ts", "apps/web/c.ts"], prs: [21, 22] }],
20
+ files: [{ path: "packages/db/big.ts", added: 12000, removed: 0, growth: 12000, package: "@s/db" }],
21
+ packages: [{ name: "@s/db", path: "packages/db", added: 12000, removed: 0, growth: 12000 }],
22
+ crossings: [
23
+ { kind: "pair_growth", key: "@s/web->@s/db", value: 6, threshold: 5, package_path: "apps/web" },
24
+ { kind: "package_lines", key: "@s/db", value: 12000, threshold: 10000, package_path: "packages/db" },
25
+ ],
26
+ };
27
+
28
+ /** gh fake: one milestone, open issues, and a log of every call. */
29
+ function fakeGh({ description = "Ship the MCP server.", open = [] } = {}) {
30
+ const st = { description, open: [...open], calls: [], created: [] };
31
+ st.gh = async (a) => {
32
+ st.calls.push(a);
33
+ if (a[0] === "api" && /milestones\?state=all/.test(a[1])) return JSON.stringify([{ number: 7, title: "Other", description: "" }, { number: 3, title: "M One", description: st.description }]);
34
+ if (a[0] === "api" && a[1] === "-X" && a[2] === "PATCH") { st.description = a[a.indexOf("-f") + 1].slice("description=".length); return "{}"; }
35
+ if (a[0] === "issue" && a[1] === "list") return JSON.stringify(st.open);
36
+ if (a[0] === "issue" && a[1] === "create") {
37
+ const n = 900 + st.created.length;
38
+ st.created.push({ n, title: a[a.indexOf("--title") + 1], body: a[a.indexOf("--body") + 1], milestone: a[a.indexOf("--milestone") + 1] });
39
+ st.open.push({ number: n, body: a[a.indexOf("--body") + 1] });
40
+ return `https://github.com/o/r/issues/${n}\n`;
41
+ }
42
+ throw new Error(`unexpected gh ${a.join(" ")}`);
43
+ };
44
+ return st;
45
+ }
46
+ const fakeGit = () => { const calls = []; return { calls, git: async (a) => { calls.push(a); return ""; } }; };
47
+ const merged = (pr, structure = S1, lane = "milestone/m1") => ({ terminalReason: "merged", pr, structure, milestone: "M One", lane });
48
+
49
+ describe("drift summary fold (forge#4161)", () => {
50
+ it("folds structure records keyed by PR; a re-fold is a no-op", () => {
51
+ const a = foldSummary(emptySummary("M One"), { issue: 1, pr: 21, milestone: "M One", structure: S1 });
52
+ const b = foldSummary(a, { issue: 2, pr: 22, milestone: "M One", structure: S2 });
53
+ assert.deepEqual(b.merged, [{ issue: 1, pr: 21 }, { issue: 2, pr: 22 }]);
54
+ assert.deepEqual(b.pairs, { "@s/web:@s/db": { files_added: 3, files_removed: 0, prs: [21, 22] }, "@s/web:@s/old": { files_added: 0, files_removed: 1, prs: [] } });
55
+ assert.deepEqual(b.deps_added, [{ from: "@s/web", to: "@s/core", kind: "dependencies", pr: 21 }]);
56
+ assert.deepEqual(b.growth, { "packages/db/big.ts": 50 });
57
+ assert.equal(foldSummary(b, { issue: 2, pr: 22, structure: S2 }), b, "same PR: unchanged");
58
+ });
59
+
60
+ it("the description block round-trips and keeps the text around it byte for byte", () => {
61
+ const desc = "Line one.\r\n<!-- FORGE:MILESTONE_INDEX: https://gist.github.com/x -->\ntrailing ";
62
+ const s1 = foldSummary(emptySummary("M One"), { issue: 1, pr: 21, structure: { ...S1, top_growth: [{ file: "a-->b.ts", lines: 3 }] } });
63
+ const d1 = renderSummary(desc, s1);
64
+ assert.ok(d1.startsWith(`${desc}\n\n<!-- FORGE:DRIFT {`));
65
+ assert.equal((d1.split("-->").length - 1), 2, "a `-->` in a path never ends the comment");
66
+ assert.deepEqual(parseSummary(d1), s1);
67
+ const s2 = foldSummary(s1, { issue: 2, pr: 22, structure: S2 });
68
+ const d2 = renderSummary(d1, s2);
69
+ assert.equal(d2.slice(0, desc.length + 2), `${desc}\n\n`);
70
+ assert.deepEqual(parseSummary(d2), s2);
71
+ assert.equal((d2.match(/FORGE:DRIFT \{/g) ?? []).length, 1, "replaced, not appended");
72
+ assert.equal(parseSummary("no block"), null);
73
+ assert.equal(parseSummary("<!-- FORGE:DRIFT {bad} -->"), null);
74
+ assert.equal(renderSummary("", s1).startsWith("<!-- FORGE:DRIFT "), true);
75
+ });
76
+ });
77
+
78
+ describe("drift crossings (forge#4161)", () => {
79
+ it("one templated issue per crossing with numbers, files and a marker; flagged packages and holds", () => {
80
+ const issues = crossingIssues(REPORT, { slug: "m1", milestone: "M One" });
81
+ assert.deepEqual(issues.map((i) => i.marker), ["FORGE:DRIFT:m1:pair_growth:@s/web->@s/db", "FORGE:DRIFT:m1:package_lines:@s/db"]);
82
+ assert.equal(issues[0].title, "drift(milestone/m1): pair_growth @s/web->@s/db 6 > 5");
83
+ assert.match(issues[0].body, /`@s\/web->@s\/db` has 6 value-import files \(threshold 5\)/);
84
+ assert.match(issues[0].body, /- `apps\/web\/a\.ts`\n- `apps\/web\/c\.ts`\n- PRs: #21, #22/);
85
+ assert.match(issues[1].body, /- `packages\/db\/big\.ts` \+12000 -0/);
86
+ assert.ok(issues[1].body.endsWith("<!-- FORGE:DRIFT:m1:package_lines:@s/db -->"));
87
+ assert.deepEqual(flaggedPackages(REPORT), ["apps/web", "packages/db"]);
88
+ assert.equal(heldBy(["packages/db/src/x.ts"], ["packages/db"]), "packages/db");
89
+ assert.equal(heldBy(["packages/dbx/a.ts", "apps/webapp/a.ts"], ["packages/db", "apps/web"]), null, "a dir prefix only");
90
+ assert.deepEqual(crossingIssues({ ...REPORT, crossings: [] }, { slug: "m1", milestone: "M One" }), []);
91
+ });
92
+
93
+ it("files exactly one issue per new crossing; open drift issues and earlier filings are deduplicated", async () => {
94
+ const st = fakeGh({ open: [{ number: 5, body: "old\n<!-- FORGE:DRIFT:m1:package_lines:@s/db -->" }] });
95
+ const issues = crossingIssues(REPORT, { slug: "m1", milestone: "M One" });
96
+ const seen = new Set();
97
+ const r = await fileCrossings({ gh: st.gh, repo: "o/r", milestone: "M One", issues, seen });
98
+ assert.deepEqual(r, { filed: [{ marker: "FORGE:DRIFT:m1:pair_growth:@s/web->@s/db", number: 900 }], skipped: ["FORGE:DRIFT:m1:package_lines:@s/db"] });
99
+ assert.equal(st.created[0].milestone, "M One");
100
+ const again = await fileCrossings({ gh: st.gh, repo: "o/r", milestone: "M One", issues, seen });
101
+ assert.deepEqual(again.filed, []);
102
+ assert.equal(st.created.length, 1);
103
+ const none = await fileCrossings({ gh: st.gh, repo: "o/r", milestone: "M One", issues: [] });
104
+ assert.deepEqual(none, { filed: [], skipped: [] });
105
+ assert.equal(st.calls.filter((a) => a[0] === "issue" && a[1] === "list").length, 2, "no read without a crossing");
106
+ });
107
+ });
108
+
109
+ describe("makeDrift (forge#4161)", () => {
110
+ it("no config: the summary only (milestone GET + PATCH), no script, no fetch, no issue, no holds", async () => {
111
+ const st = fakeGh(), g = fakeGit(); let ran = 0;
112
+ const d = makeDrift({ repo: "o/r", cwd: "/x", io: { gh: st.gh, git: g.git }, config: null, runScript: async () => { ran++; return "{}"; } });
113
+ assert.equal(d.holds, undefined);
114
+ const r = await d.onMerged(1, { issue: 1, outcome: "merged", pr: 21 }, merged(21));
115
+ assert.deepEqual(parseSummary(st.description).merged, [{ issue: 1, pr: 21 }]);
116
+ assert.ok(st.description.startsWith("Ship the MCP server.\n\n"));
117
+ assert.equal(r.summary.pairs["@s/web:@s/db"].files_added, 2);
118
+ assert.deepEqual(st.calls.map((a) => `${a[0]} ${a[1]}`), ["api repos/o/r/milestones?state=all&per_page=100", "api -X"]);
119
+ assert.deepEqual([ran, g.calls.length], [0, 0]);
120
+ // same PR again: read only, no PATCH
121
+ await d.onMerged(1, { issue: 1, outcome: "merged", pr: 21 }, merged(21));
122
+ assert.equal(st.calls.filter((a) => a[2] === "PATCH").length, 1);
123
+ // staging lanes, results without structure or milestone: nothing
124
+ for (const res of [merged(23, S1, "staging"), { ...merged(24), structure: undefined }, { ...merged(25), milestone: undefined }]) assert.equal(await d.onMerged(3, {}, res), null);
125
+ assert.equal(st.calls.length, 3);
126
+ });
127
+
128
+ it("thresholds: runs the script with the summary and thresholds, files one issue per crossing once, flags packages", async () => {
129
+ const st = fakeGh(), g = fakeGit(); const runs = [];
130
+ const runScript = async (args) => {
131
+ const sum = JSON.parse((await import("node:fs")).readFileSync(args[args.indexOf("--summary") + 1], "utf8"));
132
+ runs.push({ args, merged: sum.merged.length });
133
+ return JSON.stringify(runs.length === 1 ? { ...REPORT, crossings: [] } : REPORT);
134
+ };
135
+ const d = makeDrift({ repo: "o/r", cwd: "/x", io: { gh: st.gh, git: g.git }, config: { pairGrowth: 5, packageLines: 10000, fileLines: null, blockDispatch: true }, stagingBranch: "staging", affectedFiles, runScript });
136
+ const below = await d.onMerged(1, {}, merged(21));
137
+ assert.deepEqual([below.filed, st.created.length], [[], 0], "below the thresholds nothing is filed");
138
+ assert.equal(st.calls.filter((a) => a[0] === "issue").length, 0, "and no issue read");
139
+ assert.deepEqual(runs[0].args.filter((x) => !x.includes("/")), ["m1", "--summary", "--pair-growth", "5", "--package-lines", "10000"]);
140
+ assert.deepEqual(g.calls, [["fetch", "origin", "milestone/m1"], ["fetch", "origin", "staging"]]);
141
+ const body = (files) => `## Affected Files\n${files.map((f) => `- \`${f}\``).join("\n")}\n`;
142
+ assert.equal(d.holds(9, { body: body(["packages/db/x.ts"]), lane: "milestone/m1" }), null, "no flag yet");
143
+ const r = await d.onMerged(2, {}, merged(22, S2));
144
+ assert.equal(runs[1].merged, 2, "the script sees the folded summary");
145
+ assert.deepEqual(r.filed.map((f) => f.number), [900, 901]);
146
+ assert.deepEqual(st.created.map((c) => c.title), ["drift(milestone/m1): pair_growth @s/web->@s/db 6 > 5", "drift(milestone/m1): package_lines @s/db 12000 > 10000"]);
147
+ await d.onMerged(3, {}, merged(23, S2));
148
+ assert.equal(st.created.length, 2, "a later merge with the same crossings files nothing");
149
+ assert.equal(d.holds(9, { body: body(["packages/db/x.ts"]), lane: "milestone/m1" }), "packages/db");
150
+ assert.equal(d.holds(9, { body: body(["packages/core/x.ts"]), lane: "milestone/m1" }), null, "another package");
151
+ assert.equal(d.holds(9, { body: body(["packages/db/x.ts"]), lane: "milestone/m2" }), null, "another milestone");
152
+ });
153
+
154
+ it("calls are serialised: parallel merges never lose a fold", async () => {
155
+ const st = fakeGh(); const slow = st.gh;
156
+ st.gh = async (a) => { await new Promise((r) => setTimeout(r, 5)); return slow(a); };
157
+ const d = makeDrift({ repo: "o/r", cwd: "/x", io: { gh: st.gh, git: async () => "" } });
158
+ await Promise.all([d.onMerged(1, {}, merged(21)), d.onMerged(2, {}, merged(22, S2)), d.onMerged(3, {}, merged(23))]);
159
+ assert.deepEqual(parseSummary(st.description).merged.map((m) => m.pr), [21, 22, 23]);
160
+ });
161
+ });
162
+
163
+ describe("orchestrate drift seam (forge#4161)", () => {
164
+ const lanesOf = (m) => async (n) => ({ ok: true, lane: m[n] ?? "staging" });
165
+
166
+ it("a merged milestone lane awaits onMerged before the batch ends; staging lanes and other outcomes do not", async () => {
167
+ const calls = [];
168
+ const drift = { onMerged: async (n, row, r) => { await new Promise((res) => setTimeout(res, 10)); calls.push([n, row.lane, r.pr]); } };
169
+ const rows = await orchestrate({ issues: [1, 2, 3], parallel: 3, readBody: async () => "", resolveLane: lanesOf({ 1: "milestone/m1", 3: "milestone/m1" }), drift,
170
+ runOne: async (n) => (n === 3 ? { terminalReason: "needs-human" } : merged(20 + n, S1, n === 1 ? "milestone/m1" : "staging")) });
171
+ assert.deepEqual(calls, [[1, "milestone/m1", 21]]);
172
+ assert.deepEqual(rows.map((r) => r.outcome), ["merged", "merged", "needs-human"]);
173
+ });
174
+
175
+ it("a drift failure never changes the outcome or stops the batch", async () => {
176
+ const seen = [];
177
+ const rows = await orchestrate({ issues: [1, 2], parallel: 1, readBody: async () => "", resolveLane: lanesOf({ 1: "milestone/m1", 2: "milestone/m1" }), onUpdate: (r) => seen.push(r),
178
+ drift: { onMerged: async () => { throw new Error("gh down"); } }, runOne: async (n) => merged(20 + n) });
179
+ assert.deepEqual(rows.map((r) => [r.issue, r.outcome]), [[1, "merged"], [2, "merged"]]);
180
+ assert.deepEqual(seen.filter((r) => r.outcome === "drift").map((r) => r.detail), ["drift check failed: gh down", "drift check failed: gh down"]);
181
+ });
182
+
183
+ it("without drift: identical rows and store shape", async () => {
184
+ const saves = [];
185
+ const run = (extra) => orchestrate({ issues: [1, 2], parallel: 2, readBody: async (n) => (n === 2 ? "Depends on #1" : ""), resolveLane: lanesOf({ 1: "milestone/m1", 2: "milestone/m1" }),
186
+ store: { load: () => null, save: (s) => saves.push(Object.keys(s).sort().join(",")) }, now: () => 0, runOne: async (n) => merged(20 + n), ...extra });
187
+ const a = await run({}); const keysA = [...saves]; saves.length = 0;
188
+ const b = await run({ drift: { onMerged: async () => {} } });
189
+ assert.deepEqual(a, b);
190
+ assert.deepEqual([...new Set(keysA)], [...new Set(saves)]);
191
+ });
192
+
193
+ it("block_dispatch: holds only queued issues whose Affected Files touch the flagged package; they end held", async () => {
194
+ const bodies = { 1: "", 2: "Depends on #1\n\n## Affected Files\n- `packages/db/src/x.ts`\n", 3: "Depends on #1\n\n## Affected Files\n- `packages/core/src/y.ts`\n" };
195
+ const st = fakeGh();
196
+ const d = makeDrift({ repo: "o/r", cwd: "/x", io: { gh: st.gh, git: async () => "" }, config: { packageLines: 10000, blockDispatch: true }, affectedFiles,
197
+ runScript: async () => JSON.stringify({ ...REPORT, crossings: [REPORT.crossings[1]] }) });
198
+ const ran = [];
199
+ const rows = await orchestrate({ issues: [1, 2, 3], parallel: 1, readBody: async (n) => bodies[n], resolveLane: lanesOf({ 1: "milestone/m1", 2: "milestone/m1", 3: "milestone/m1" }), drift: d,
200
+ runOne: async (n) => { ran.push(n); return merged(20 + n); } });
201
+ assert.deepEqual(ran, [1, 3]);
202
+ assert.deepEqual(rows.map((r) => [r.issue, r.outcome, r.detail ?? null]), [[1, "merged", null], [2, "held", "drift: packages/db flagged"], [3, "merged", null]]);
203
+ assert.equal(st.created.length, 1);
204
+ });
205
+ });
206
+
207
+ describe("readDrift (forge#4161)", () => {
208
+ const withYaml = (yaml, f) => {
209
+ const dir = tmpDir("drift-yaml-");
210
+ if (yaml != null) writeFileSync(join(dir, "forge.yaml"), yaml);
211
+ return f(dir);
212
+ };
213
+ it("null without forge.yaml, without the block, or with nothing set", () => {
214
+ assert.equal(withYaml(null, readDrift), null);
215
+ assert.equal(withYaml("orchestration:\n max_deferrals: 3\n", readDrift), null);
216
+ assert.equal(withYaml("orchestrate:\n drift:\n pair_growth: 0\n block_dispatch: false\n", readDrift), null);
217
+ });
218
+ it("reads the orchestrate.drift thresholds, block_dispatch and generated globs", () => {
219
+ const y = "orchestrate:\n drift:\n pair_growth: 5\n package_lines: 10000\n file_lines: 600\n block_dispatch: true\n generated_globs: data/*, *.lock\n";
220
+ assert.deepEqual(withYaml(y, readDrift), { pairGrowth: 5, packageLines: 10000, fileLines: 600, blockDispatch: true, generatedGlobs: ["data/*", "*.lock"] });
221
+ assert.deepEqual(withYaml("orchestrate:\n drift:\n file_lines: 600\n", readDrift), { pairGrowth: null, packageLines: null, fileLines: 600, blockDispatch: false, generatedGlobs: [] });
222
+ });
223
+ });
@@ -1,7 +1,7 @@
1
1
  // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
2
  // SPDX-License-Identifier: AGPL-3.0-or-later
3
3
  /** Core work-on: the real effects behind runCore's injected `io`. Argv only, no shell. */
4
- import { execFile, execFileSync } from "node:child_process";
4
+ import { execFile, execFileSync, spawn } from "node:child_process";
5
5
  import { promisify } from "node:util";
6
6
  import { readFileSync, appendFileSync, mkdirSync, statSync, existsSync, rmSync, writeFileSync, readdirSync, lstatSync, readlinkSync, realpathSync } from "node:fs";
7
7
  import { homedir, hostname, freemem, loadavg, cpus, tmpdir } from "node:os";
@@ -11,7 +11,9 @@ import { fileURLToPath } from "node:url";
11
11
  import { runCliBackendAsync, resolveClaudeCliBinary, getClaudeCliCapabilities, buildLeanSessionFlags, buildReadOnlyToolFlags } from "../runner.mjs";
12
12
  import { makeIo } from "../engine-cli.mjs";
13
13
  import { makeProjector } from "../engine/projector.mjs";
14
- import { makeLedger } from "./ledger.mjs";
14
+ import { makeLedger, countLiveLanes, ledgerRoot } from "./ledger.mjs";
15
+ import { makeDepCache } from "./depcache.mjs";
16
+ import { makeHeavyIo } from "./heavy.mjs";
15
17
 
16
18
  const pexec = promisify(execFile);
17
19
  const script = (n) => fileURLToPath(new URL(`../../scripts/${n}`, import.meta.url));
@@ -45,11 +47,15 @@ export function wrapLaneLimits(bin, args, limits) {
45
47
  return { bin: "systemd-run", args: ["--user", "--scope", "-q", ...(mem ? ["-p", `MemoryMax=${mem}`] : []), ...(cpu ? ["-p", `CPUQuota=${cpu}`] : []), "--", bin, ...args] };
46
48
  }
47
49
 
50
+ /** #4211: a judgment call over data in the prompt only (the refactor:split grouping call): no tool can read a file. */
51
+ export const noToolFlags = (caps) => (caps?.strictMcp ? ["--tools", ""]
52
+ : ["--disallowedTools", "Read", "Grep", "Glob", "Bash", "Edit", "Write", "MultiEdit", "NotebookEdit", "WebFetch", "WebSearch", "Task"]);
53
+
48
54
  export function makeLeanRunner({ runCli = runCliBackendAsync, probe = systemdRunAvailable } = {}) {
49
- return async ({ role, issue, cwd, model, resume, system, prompt, readOnly = false, env = null, idleMs = 0, maxMs = 0, laneLimits = null, control = null, onSpawn = null }) => {
55
+ return async ({ role, issue, cwd, model, resume, system, prompt, readOnly = false, noTools = false, env = null, idleMs = 0, maxMs = 0, laneLimits = null, control = null, onSpawn = null }) => {
50
56
  const bin = resolveClaudeCliBinary(cwd) || "claude";
51
57
  const caps = getClaudeCliCapabilities(cwd);
52
- const flags = [...buildLeanSessionFlags(caps), ...(readOnly ? buildReadOnlyToolFlags(caps) : [])];
58
+ const flags = [...buildLeanSessionFlags(caps), ...(noTools ? noToolFlags(caps) : readOnly ? buildReadOnlyToolFlags(caps) : [])];
53
59
  const r = await runCli({ spec: { name: `core-${role}` }, userMessage: prompt, systemPrompt: resume ? "" : system,
54
60
  args: [String(issue)], cwd, bin, model, env, sessionFlags: flags, extraArgs: resume ? ["--resume", resume] : [],
55
61
  logger: { log() {} },
@@ -159,6 +165,8 @@ export function makeHostProbe() {
159
165
  },
160
166
  loadAvg1: () => loadavg()[0],
161
167
  cores: () => cpus().length || 1,
168
+ /** Live lanes on this host from the ledger (#4182), this lane included once it registered. */
169
+ activeLanes: () => countLiveLanes({ root: ledgerRoot(), host: hostname() }),
162
170
  tmpPaths: () => [...new Set([join(homedir(), ".forge", "tmp"), tmpdir(), "/tmp"].filter((x) => existsSync(x) || x === join(homedir(), ".forge", "tmp")))],
163
171
  };
164
172
  }
@@ -184,22 +192,47 @@ export async function requiredChecksFor({ gh, repo, lane }) {
184
192
  return [...new Set(all.filter((x) => typeof x === "string" && x))];
185
193
  }
186
194
 
195
+ /** Combined stdout+stderr kept per declared check command (tail). */
196
+ const CHECK_OUTPUT_CAP = 64000;
197
+ /** #4240: run a forge.yaml declared check command locally (no gh, no Actions); {rc, output}, rc 124 on timeout. */
198
+ export function runCheckCommand({ command, cwd, timeoutMs = 300000 }) {
199
+ return new Promise((done) => {
200
+ let out = "", timedOut = false, child;
201
+ const add = (b) => { out += String(b); if (out.length > 2 * CHECK_OUTPUT_CAP) out = out.slice(-CHECK_OUTPUT_CAP); };
202
+ try { child = spawn("bash", ["-c", String(command)], { cwd, detached: true, stdio: ["ignore", "pipe", "pipe"] }); }
203
+ catch (e) { done({ rc: 127, output: String(e?.message ?? e) }); return; }
204
+ const kill = () => { try { process.kill(-child.pid, "SIGKILL"); } catch { try { child.kill("SIGKILL"); } catch { /* gone */ } } };
205
+ const timer = setTimeout(() => { timedOut = true; kill(); }, timeoutMs);
206
+ child.stdout.on("data", add); child.stderr.on("data", add);
207
+ child.on("error", (e) => { clearTimeout(timer); done({ rc: 127, output: `${out}${String(e?.message ?? e)}`.slice(-CHECK_OUTPUT_CAP) }); });
208
+ child.on("close", (code) => {
209
+ clearTimeout(timer);
210
+ const tail = out.slice(-CHECK_OUTPUT_CAP);
211
+ done(timedOut ? { rc: 124, output: `${tail}\n(timed out after ${Math.round(timeoutMs / 1000)}s)` } : { rc: Number.isInteger(code) ? code : 1, output: tail });
212
+ });
213
+ });
214
+ }
215
+
187
216
  export function makeLeanIo({ repo }) {
188
217
  const base = makeIo();
189
218
  const run = (bin) => async (args, opts = {}) => (await pexec(bin, args, { cwd: opts.cwd, maxBuffer: 100 * 1024 * 1024, timeout: 120000 })).stdout;
190
219
  return {
191
- gh: run("gh"), git: run("git"), docker: run("docker"), killGroup: async (pid, marker) => killLedgerPid(pid, marker), ledger: (opts) => makeLedger(opts), runTmp: makeRunTmp(), host: makeHostProbe(), projector: makeProjector({ gh: base.gh }), runner: makeLeanRunner(), waitCiGreen: base.waitCiGreen,
220
+ gh: run("gh"), git: run("git"), docker: run("docker"), killGroup: async (pid, marker) => killLedgerPid(pid, marker), ledger: (opts) => makeLedger(opts), runTmp: makeRunTmp(), host: makeHostProbe(), heavy: makeHeavyIo({ host: makeHostProbe() }), projector: makeProjector({ gh: base.gh }), runner: makeLeanRunner(), waitCiGreen: base.waitCiGreen,
221
+ /** Shared dependency cache (#4220): the repo's own package manager, argv only, bounded by the cache's timeout. */
222
+ deps: makeDepCache({ exec: async (cmd, args, { cwd, env, timeoutMs } = {}) => String((await pexec(cmd, args, { cwd, env: { ...process.env, ...(env ?? {}) }, timeout: timeoutMs ?? 0, maxBuffer: 100 * 1024 * 1024 })).stdout) }),
192
223
  /** Names of the checks the base branch requires: classic protection plus rulesets (#4158). [] when neither has any. */
193
224
  async requiredChecks(lane) { return requiredChecksFor({ gh: run("gh"), repo, lane }); },
194
- /** [{name, state: success|failure|pending}] for the PR's checks. */
225
+ /** [{name, state: success|failure|pending, url, head}] for the PR's checks; `head` is the PR head the rollup belongs to (#4240). */
195
226
  async checkStates(pr) {
196
- const j = JSON.parse(await run("gh")(["pr", "view", String(pr), "-R", repo, "--json", "statusCheckRollup"]));
227
+ const j = JSON.parse(await run("gh")(["pr", "view", String(pr), "-R", repo, "--json", "statusCheckRollup,headRefOid"]));
197
228
  return (j.statusCheckRollup ?? []).map((c) => {
198
229
  const v = String(c.conclusion || c.state || "").toUpperCase();
199
230
  const state = ["SUCCESS", "NEUTRAL", "SKIPPED"].includes(v) ? "success" : ["FAILURE", "ERROR", "TIMED_OUT", "CANCELLED", "ACTION_REQUIRED", "STARTUP_FAILURE"].includes(v) ? "failure" : "pending";
200
- return { name: c.name ?? c.context, state };
231
+ return { name: c.name ?? c.context, state, url: c.detailsUrl || c.targetUrl || null, head: j.headRefOid ?? null };
201
232
  });
202
233
  },
234
+ /** #4240: one declared check command (`bash -c`) in `cwd`; never throws. A timeout kills the process group and gives rc 124. */
235
+ runCommand: runCheckCommand,
203
236
  /** #4167 verify-by-CI: `gh workflow run` on the lane branch; inputs become -f key=value. */
204
237
  async dispatchWorkflow({ workflow, ref, inputs = {} }) {
205
238
  await run("gh")(["workflow", "run", String(workflow), "-R", repo, "--ref", String(ref), ...Object.entries(inputs).flatMap(([k, v]) => ["-f", `${k}=${v}`])]);
@@ -234,10 +267,54 @@ export function makeLeanIo({ repo }) {
234
267
  for (const l of out.split("\n")) { const [k, n] = l.split("\t"); if (k && /^\d+$/.test(String(n ?? "").trim())) map[k] = Number(n); }
235
268
  return map;
236
269
  },
270
+ /**
271
+ * forge#4163: forge.yaml env.setup, once per fresh lane worktree: `bash -c <command>` in cwd with the lane env.
272
+ * Resolves {exit, stdout, stderr} (output tails); a non-zero exit, timeout or spawn error never throws.
273
+ */
274
+ runSetup({ cwd, command, env = null, timeoutMs = 15 * 60000 }) {
275
+ return new Promise((res) => {
276
+ execFile("bash", ["-c", command], { cwd, env: { ...process.env, ...(env ?? {}) }, timeout: timeoutMs, maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => {
277
+ const tail = (x) => String(x ?? "").slice(-2000);
278
+ if (!err) return res({ exit: 0, stdout: tail(stdout), stderr: tail(stderr) });
279
+ const exit = Number.isInteger(err.code) ? err.code : 1;
280
+ const why = err.killed ? `timed out after ${Math.round(timeoutMs / 1000)}s` : typeof err.code === "string" ? err.code : "";
281
+ res({ exit: exit === 0 ? 1 : exit, stdout: tail(stdout), stderr: tail([stderr, why].filter(Boolean).join("\n")) });
282
+ });
283
+ });
284
+ },
237
285
  /** forge#4159: scripts/structure-delta.sh over base...HEAD in cwd; null on any failure (never fails a run). */
238
286
  async structureDelta({ cwd, base }) {
239
287
  try { return JSON.parse((await pexec("bash", [script("structure-delta.sh"), `${base}...HEAD`], { cwd, timeout: 30000, maxBuffer: 16 * 1024 * 1024 })).stdout); } catch { return null; }
240
288
  },
289
+ /** forge#4195: scripts/hotspots.sh facts for `paths` in cwd (growth since base when given); null on any failure. */
290
+ async hotspots({ cwd, base = null, paths = [] }) {
291
+ const args = [script("hotspots.sh"), ...(base ? ["--base", base] : []), "--", ...paths];
292
+ try { return JSON.parse((await pexec("bash", args, { cwd, timeout: 30000, maxBuffer: 16 * 1024 * 1024 })).stdout); } catch { return null; }
293
+ },
294
+ /** #4271: scripts/acceptance-red-green.sh for the frozen repro_test in cwd; {base, head} words, or null on any failure. */
295
+ async redGreen({ cwd, base, command, timeoutSec = 300 }) {
296
+ const args = [script("acceptance-red-green.sh"), "--repo-path", cwd, "--base-ref", base, "--test-cmd", command, "--timeout", String(timeoutSec)];
297
+ try {
298
+ const m = /BASE=(\w+) HEAD=(\w+)/.exec(String((await pexec("bash", args, { cwd, timeout: (timeoutSec * 2 + 60) * 1000, maxBuffer: 4 * 1024 * 1024 })).stdout ?? ""));
299
+ return m ? { base: m[1], head: m[2] } : null;
300
+ } catch { return null; }
301
+ },
302
+ /**
303
+ * The target repo's own docs-move-with-code rule, when it has one (scripts/check-devdocs-freshness.sh, forge#4185),
304
+ * over base...HEAD in cwd. null when the repo has no such script or it cannot run (exit 2+, timeout): never blocks.
305
+ * Otherwise {ok, output} with the script's stdout+stderr (exit 0 = ok, exit 1 = stale docs).
306
+ */
307
+ async docsFreshness({ cwd, base }) {
308
+ const s = join(cwd, "scripts", "check-devdocs-freshness.sh");
309
+ if (!existsSync(s)) return null;
310
+ try {
311
+ const r = await pexec("bash", [s, base], { cwd, timeout: 60000, maxBuffer: 4 * 1024 * 1024, env: { ...process.env, PR_BODY: "" } });
312
+ return { ok: true, output: String(r.stdout ?? "") };
313
+ } catch (e) {
314
+ if (e?.code !== 1) return null;
315
+ return { ok: false, output: `${e.stdout ?? ""}${e.stderr ?? ""}`.slice(0, 6000) };
316
+ }
317
+ },
241
318
  /** #4162: processes left running inside a lane worktree (Linux /proc). */
242
319
  proc: { inDir: async (dir) => procsInDir(dir), alive: pidAlive, kill: (pid, sig) => { try { process.kill(pid, sig); } catch { /* already gone */ } } },
243
320
  fs: {