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.
- package/README.md +4 -1
- package/bin/bootstrap.mjs +55 -0
- package/bin/engine/outcome-codes.mjs +10 -0
- package/bin/engine/telemetry-seam.mjs +37 -8
- package/bin/engine/telemetry-seam.test.mjs +31 -0
- package/bin/engine-cli.mjs +542 -38
- package/bin/engine.mjs +8 -4
- package/bin/forgedock.mjs +75 -9
- package/bin/gc.mjs +580 -0
- package/bin/lean/admission.mjs +33 -3
- package/bin/lean/batch.mjs +22 -4
- package/bin/lean/batch.test.mjs +5 -3
- package/bin/lean/checks.mjs +199 -0
- package/bin/lean/checks.test.mjs +183 -0
- package/bin/lean/conformance.mjs +190 -58
- package/bin/lean/conformance.test.mjs +116 -7
- package/bin/lean/credentials.mjs +29 -0
- package/bin/lean/depcache.mjs +312 -0
- package/bin/lean/depcache.test.mjs +298 -0
- package/bin/lean/drift.mjs +198 -0
- package/bin/lean/drift.test.mjs +223 -0
- package/bin/lean/effects.mjs +85 -8
- package/bin/lean/execution.e2e.test.mjs +81 -0
- package/bin/lean/execution.mjs +431 -0
- package/bin/lean/execution.test.mjs +583 -0
- package/bin/lean/heavy.mjs +289 -0
- package/bin/lean/heavy.test.mjs +207 -0
- package/bin/lean/lean.mjs +572 -139
- package/bin/lean/lean.test.mjs +1193 -31
- package/bin/lean/ledger.mjs +27 -2
- package/bin/lean/ledger.test.mjs +36 -1
- package/bin/lean/limits.mjs +58 -0
- package/bin/lean/limits.test.mjs +150 -0
- package/bin/lean/orchestrate.mjs +312 -19
- package/bin/lean/prompts.mjs +89 -15
- package/bin/lean/refactor.mjs +276 -0
- package/bin/lean/refactor.test.mjs +359 -0
- package/bin/lean/render.mjs +24 -5
- package/bin/lean/review-trigger.mjs +134 -0
- package/bin/lean/review-trigger.test.mjs +104 -0
- package/bin/lean/sandbox-provider.contract.test.mjs +257 -0
- package/bin/lean/sandbox-remote.mjs +142 -0
- package/bin/lean/sched.mjs +221 -0
- package/bin/lean/sched.test.mjs +310 -0
- package/bin/lean/schedd.mjs +247 -0
- package/bin/lean/schedd.test.mjs +180 -0
- package/bin/lean/toolchain.mjs +228 -0
- package/bin/lean/toolchain.test.mjs +154 -0
- package/bin/lean/verify.mjs +20 -9
- package/bin/lean/verify.test.mjs +26 -3
- package/bin/lean/worktree.mjs +69 -0
- package/bin/lean/worktree.test.mjs +141 -0
- package/bin/runner.mjs +53 -10
- package/bin/stats/github-source.mjs +1 -1
- package/bin/stats/index.mjs +14 -2
- package/bin/stats/query.mjs +72 -0
- package/bin/stats/stats.test.mjs +54 -0
- package/commands/audit.md +2 -0
- package/commands/autopilot.md +2 -0
- package/commands/bootstrap.md +35 -0
- package/commands/cleanup.md +7 -22
- package/commands/forge-stats.md +2 -1
- package/commands/forgedock-init.md +7 -0
- package/commands/issue.md +89 -830
- package/commands/milestone.md +55 -6
- package/commands/orchestrate.md +10 -2
- package/commands/upgrade-deps.md +3 -3
- package/commands/work-on/build/validate.md +4 -0
- package/commands/work-on/close.md +2 -0
- package/commands/work-on/decompose.md +24 -6
- package/docs/CI.md +3 -3
- package/docs/CONFIG.md +177 -2
- package/docs/OUTCOME-CODES.md +8 -0
- package/docs/TELEMETRY.md +1 -1
- package/docs/site/command-reference.md +22 -0
- package/docs/site/getting-started.md +2 -0
- package/docs/site/start-a-new-project.md +327 -0
- package/package.json +1 -1
- package/scripts/affected-files-check.sh +91 -0
- package/scripts/affected-files-check.test.sh +80 -0
- package/scripts/bootstrap-create.mjs +393 -0
- package/scripts/bootstrap-validate.mjs +36 -2
- package/scripts/calibration.mjs +116 -0
- package/scripts/check-devdocs-freshness.sh +9 -4
- package/scripts/check-devdocs-freshness.test.sh +11 -1
- package/scripts/check-spec-markers.sh +3 -0
- package/scripts/check-spec-size.sh +29 -11
- package/scripts/check-spec-size.test.sh +27 -4
- package/scripts/ci-local/lib.mjs +8 -1
- package/scripts/ci-local/parallelism.test.mjs +27 -0
- package/scripts/ci-local/run.mjs +3 -3
- package/scripts/ci-local/scoping.mjs +82 -0
- package/scripts/ci-local.test.sh +1 -1
- package/scripts/fixtures/symbol-index/other/sample.rb +3 -0
- package/scripts/fixtures/symbol-index/py/pkg/__init__.py +1 -0
- package/scripts/fixtures/symbol-index/py/pkg/a.py +5 -0
- package/scripts/fixtures/symbol-index/py/pkg/b.py +14 -0
- package/scripts/fixtures/symbol-index/py/pkg/c.py +1 -0
- package/scripts/fixtures/symbol-index/py/sample.py +41 -0
- package/scripts/fixtures/symbol-index/ts/sample.ts +13 -0
- package/scripts/fixtures/symbol-move/gen_big.py +201 -0
- package/scripts/fixtures/symbol-move-ts/gen_big.mjs +56 -0
- package/scripts/forge-root.test.sh +1 -1
- package/scripts/hotspots.sh +100 -0
- package/scripts/hotspots.test.sh +101 -0
- package/scripts/issue-create.sh +197 -0
- package/scripts/issue-create.test.sh +166 -0
- package/scripts/maintenance-scan.sh +185 -0
- package/scripts/maintenance-scan.test.sh +89 -0
- package/scripts/milestone-drift.sh +206 -0
- package/scripts/milestone-drift.test.sh +120 -0
- package/scripts/refactor-verify.sh +368 -0
- package/scripts/refactor-verify.test.sh +202 -0
- package/scripts/structure-delta.sh +10 -5
- package/scripts/structure-delta.test.sh +3 -2
- package/scripts/symbol-index.mjs +253 -0
- package/scripts/symbol-index.py +354 -0
- package/scripts/symbol-index.test.sh +119 -0
- package/scripts/symbol-move-ts.test.sh +161 -0
- package/scripts/symbol-move.mjs +379 -0
- package/scripts/symbol-move.py +383 -0
- package/scripts/symbol-move.test.sh +168 -0
- package/templates/bootstrap/interview.md +34 -0
- package/templates/bootstrap/issue-body.md +42 -0
- package/templates/bootstrap/needs-human-issue.md +45 -0
- package/templates/bootstrap/stack-profiles.md +34 -0
- 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
|
+
});
|
package/bin/lean/effects.mjs
CHANGED
|
@@ -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: {
|