forgedock 1.26.0 → 1.27.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 (42) hide show
  1. package/bin/engine/outcome-codes.mjs +3 -0
  2. package/bin/engine/phases.mjs +38 -2
  3. package/bin/engine/telemetry-seam.mjs +6 -0
  4. package/bin/engine/telemetry-seam.test.mjs +4 -0
  5. package/bin/engine-cli.mjs +78 -20
  6. package/bin/engine.mjs +5 -1
  7. package/bin/forgedock.mjs +94 -30
  8. package/bin/init-detect.mjs +68 -3
  9. package/bin/init-enrich.mjs +3 -0
  10. package/bin/init-repo.mjs +213 -0
  11. package/bin/journey.mjs +278 -43
  12. package/bin/lean/batch.mjs +2 -1
  13. package/bin/lean/batch.test.mjs +49 -1
  14. package/bin/lean/conformance.mjs +76 -10
  15. package/bin/lean/conformance.test.mjs +32 -3
  16. package/bin/lean/effects.mjs +96 -11
  17. package/bin/lean/lean.mjs +326 -33
  18. package/bin/lean/lean.test.mjs +618 -10
  19. package/bin/lean/ledger.mjs +67 -0
  20. package/bin/lean/ledger.test.mjs +65 -0
  21. package/bin/lean/orchestrate.mjs +9 -0
  22. package/bin/lean/prompts.mjs +42 -8
  23. package/bin/lean/render.mjs +1 -1
  24. package/bin/lean/verify.mjs +113 -0
  25. package/bin/lean/verify.test.mjs +95 -0
  26. package/commands/adopt.md +3 -0
  27. package/commands/audit.md +3 -8
  28. package/commands/forgedock-init.md +118 -6
  29. package/commands/review-pr.md +2 -2
  30. package/docs/CONFIG.md +52 -3
  31. package/docs/OUTCOME-CODES.md +3 -0
  32. package/package.json +1 -1
  33. package/scripts/bootstrap-validate.mjs +287 -0
  34. package/scripts/check-devdocs-freshness.sh +120 -0
  35. package/scripts/check-devdocs-freshness.test.sh +120 -0
  36. package/scripts/ci-local/run.mjs +13 -2
  37. package/scripts/ci-local.test.sh +13 -1
  38. package/scripts/northstar-docs.test.sh +68 -0
  39. package/scripts/review-prepare.sh +11 -9
  40. package/scripts/review-prepare.test.sh +14 -0
  41. package/scripts/structure-delta.sh +150 -0
  42. package/scripts/structure-delta.test.sh +135 -0
@@ -59,6 +59,33 @@ export function authorizationOf(contract, f) {
59
59
  }
60
60
  const blank = (s) => !String(s ?? "").trim() || /^none$/i.test(String(s).trim());
61
61
 
62
+ /**
63
+ * #4160: an evidence path the reviewer gave as a bare or partial name (`route.ts`, `pdf/route.ts`) resolves to the one
64
+ * changed file it names. Returns the repo path, or null when it is not a unique match.
65
+ */
66
+ export function resolveEvidencePath(p, changedFiles = []) {
67
+ const s = String(p ?? "").replace(/^\.\//, "");
68
+ if (!s) return null;
69
+ const hits = changedFiles.filter((f) => f === s || f.endsWith(`/${s}`));
70
+ return hits.length === 1 ? hits[0] : null;
71
+ }
72
+
73
+ /**
74
+ * #4160: the rows the engine requires, pre-filled with every id so the reviewer fills statuses instead of composing rows.
75
+ * Scope rows are listed only for changed files the contract does not already authorize (deliverable, test or
76
+ * incidental); issue_coverage starts from the contract's verbatim quotes, and the reviewer adds any the contract missed.
77
+ */
78
+ export function reviewSkeleton({ contract, changedFiles = [], ignore = [] }) {
79
+ const reqs = contract?.requirements ?? [];
80
+ return {
81
+ issue_coverage: reqs.map((r) => ({ quote: r.quote, contract_ids: [r.id], status: "" })),
82
+ requirements: reqs.map((r) => ({ id: r.id, status: "", evidence: "", validated_by: "", ...(TRACKED.includes(r.disposition) ? { deferral_ok: null } : {}), note: "" })),
83
+ scope: changedFiles.filter((f) => !ignore.some((pre) => f.startsWith(pre)) && !authorizationOf(contract, f)).map((f) => ({ file: f, authorized_by: "", note: "" })),
84
+ preserve: (contract?.preserve ?? []).map((x) => ({ id: x.id, status: "", evidence: "" })),
85
+ regressions: [], verdict: "", summary: "",
86
+ };
87
+ }
88
+
62
89
  /** Shape check of the builder's contract.json against the issue text. Returns {ok, problems[]}. */
63
90
  export function validateContract(c, issueText) {
64
91
  const problems = [];
@@ -97,7 +124,11 @@ export function applyAmendments(frozen, amendments, issueText = null) {
97
124
  const tag = JSON.stringify(a ?? null).slice(0, 100);
98
125
  if (blank(a?.why)) { problems.push(`amendment without why: ${tag}`); continue; }
99
126
  if (a.kind === "add-deliverable" && !blank(a.path)) {
100
- if (!c.deliverables.some((d) => d.path === a.path)) c.deliverables.push({ id: a.id ?? `A${c.deliverables.length + 1}`, path: a.path, change: a.why, amended: true });
127
+ const have = c.deliverables.find((d) => d.path === a.path);
128
+ // An existing deliverable keeps its id and gains the why, so an amendment naming a package on an already-listed
129
+ // package.json declares the edge (forge#4159); before, it was reported as applied but changed nothing.
130
+ if (have) { have.change = have.change ? `${have.change}; ${a.why}` : a.why; have.amended = true; }
131
+ else c.deliverables.push({ id: a.id ?? `A${c.deliverables.length + 1}`, path: a.path, change: a.why, amended: true });
101
132
  } else if (a.kind === "add-requirement" && a.requirement?.id && !blank(a.requirement?.quote)) {
102
133
  if (c.requirements.some((r) => r.id === a.requirement.id)) { problems.push(`add-requirement reuses id ${a.requirement.id}`); continue; }
103
134
  if (issueText != null && !inIssue(a.requirement.quote, issueText)) { problems.push(`add-requirement ${a.requirement.id}: quote is not verbatim from the issue`); continue; }
@@ -118,25 +149,31 @@ export function applyAmendments(frozen, amendments, issueText = null) {
118
149
 
119
150
  /**
120
151
  * Deterministic checks before any reviewer tokens are spent: unauthorized files, failed or missing validation,
121
- * requirements not implemented, and deferrals without a tracking issue. Returns {ok, failures[]}.
152
+ * requirements not implemented, and deferrals without a tracking issue. Returns {ok, failures[]}; each failure carries a
153
+ * `kind` (scope_creep, validate_missing, validate_failed, requirement_unimplemented, untracked_deferral) so callers can
154
+ * tell the builder's own failed checks apart from bookkeeping gaps. `ciVerified` (#4167): the current head passed the
155
+ * engine's CI verification, so a validate id left to CI is not missing; a recorded non-zero exit still fails.
122
156
  */
123
- export function precheck({ contract, result, changedFiles, ignore = [] }) {
157
+ export function precheck({ contract, result, changedFiles, ignore = [], ciVerified = false }) {
124
158
  const failures = [];
125
159
  const creep = changedFiles.filter((f) => !ignore.some((p) => f.startsWith(p)) && !authorizationOf(contract, f));
126
- if (creep.length) failures.push({ code: C.CONFORMANCE_SCOPE_CREEP, detail: `changed outside the contract's deliverables: ${creep.join(", ")}` });
160
+ if (creep.length) failures.push({ code: C.CONFORMANCE_SCOPE_CREEP, kind: "scope_creep", detail: `changed outside the contract's deliverables: ${creep.join(", ")}` });
127
161
  const runs = Array.isArray(result?.validation) ? result.validation : [];
128
162
  for (const v of contract.validate ?? []) {
129
163
  const r = runs.find((x) => x?.id === v.id);
130
- if (!r) failures.push({ code: C.CONFORMANCE_UNIMPLEMENTED, detail: `validate ${v.id} (\`${v.command}\`) has no recorded result` });
131
- else if (r.exit !== 0) failures.push({ code: C.CONFORMANCE_UNIMPLEMENTED, detail: `validate ${v.id} failed (exit ${r.exit})` });
164
+ // #4167: a head the engine verified on CI covers validate ids the builder left to CI (no result, or ci:true without an exit)
165
+ const leftToCi = !r || (r.ci === true && r.exit === undefined);
166
+ if (ciVerified && leftToCi) continue;
167
+ if (leftToCi) failures.push({ code: C.CONFORMANCE_UNIMPLEMENTED, kind: "validate_missing", detail: `validate ${v.id} (\`${v.command}\`) has no recorded result` });
168
+ else if (r.exit !== 0) failures.push({ code: C.CONFORMANCE_UNIMPLEMENTED, kind: "validate_failed", detail: `validate ${v.id} failed (exit ${r.exit})` });
132
169
  }
133
170
  const st = Array.isArray(result?.requirements) ? result.requirements : [];
134
171
  for (const r of contract.requirements ?? []) {
135
172
  const s = st.find((x) => x?.id === r.id)?.status;
136
173
  if (TRACKED.includes(r.disposition)) continue; // typed deferrals are judged by the reviewer, then filed before the PR
137
174
  if (r.disposition === "deliver") {
138
- if (s === "deferred" || s === "post-deploy") failures.push({ code: C.CONFORMANCE_UNTRACKED_DEFERRAL, detail: `${r.id} was contracted as deliver but reported "${s}" without a typed amendment` });
139
- else if (s !== "implemented") failures.push({ code: C.CONFORMANCE_UNIMPLEMENTED, detail: `${r.id} reported "${s ?? "no status"}"` });
175
+ if (s === "deferred" || s === "post-deploy") failures.push({ code: C.CONFORMANCE_UNTRACKED_DEFERRAL, kind: "untracked_deferral", detail: `${r.id} was contracted as deliver but reported "${s}" without a typed amendment` });
176
+ else if (s !== "implemented") failures.push({ code: C.CONFORMANCE_UNIMPLEMENTED, kind: "requirement_unimplemented", detail: `${r.id} reported "${s ?? "no status"}"` });
140
177
  }
141
178
  }
142
179
  return { ok: failures.length === 0, failures };
@@ -175,13 +212,15 @@ export function checkCompleteness(rv, { contract, changedFiles, issueText, pathE
175
212
  if (row.status === "implemented") {
176
213
  const m = EVIDENCE_RE.exec(String(row.evidence ?? ""));
177
214
  if (!m) p.push(`${r.id}: implemented needs path:line evidence`);
178
- else if (!changedFiles.includes(m[1]) && !pathExists(m[1])) p.push(`${r.id}: evidence path ${m[1]} does not exist`);
215
+ else if (!changedFiles.includes(m[1]) && !pathExists(m[1]) && !resolveEvidencePath(m[1], changedFiles)) p.push(`${r.id}: evidence path ${m[1]} does not exist`);
179
216
  if (blank(row.validated_by)) p.push(`${r.id}: implemented needs validated_by (a validate id or test)`);
180
217
  }
181
218
  }
182
219
  const scope = arr("scope");
183
220
  if (!scope) p.push("scope rows are missing");
184
- for (const f of changedFiles.filter((x) => !ignore.some((pre) => x.startsWith(pre)))) if (!(scope ?? []).some((s) => s?.file === f)) p.push(`changed file ${f} has no scope row`);
221
+ // #4160: a file the contract authorizes (deliverable, test, incidental) is already settled by the deterministic precheck;
222
+ // a row is required only for the files it could not authorize. A row the reviewer does give is still judged in decide().
223
+ for (const f of changedFiles.filter((x) => !ignore.some((pre) => x.startsWith(pre)) && !authorizationOf(contract, x))) if (!(scope ?? []).some((s) => s?.file === f)) p.push(`changed file ${f} has no scope row`);
185
224
  const pres = arr("preserve") ?? [];
186
225
  for (const c of contract.preserve ?? []) {
187
226
  const row = pres.find((x) => x?.id === c.id);
@@ -236,3 +275,30 @@ export function followupItems(reqs) {
236
275
  const flat = (s) => String(s ?? "").replace(/[\t\r\n`]+/g, " ").replace(/\s+/g, " ").trim();
237
276
  return reqs.map((r) => ({ id: r.id, key: flat(`${r.id}: ${r.quote}`).slice(0, 240), why: flat(`${r.disposition}: ${r.why ?? ""}`).slice(0, 400) }));
238
277
  }
278
+
279
+ /**
280
+ * forge#4159: package.json edges between workspace packages (scripts/structure-delta.sh `package_edges_added`) that
281
+ * the contract does not name. Named = a deliverable covering the edge's manifest whose `change` names the target
282
+ * package (an add-deliverable amendment counts: its change is the why). A null or `{}` delta names nothing to check.
283
+ */
284
+ export function unnamedEdges(contract, delta) {
285
+ const edges = Array.isArray(delta?.package_edges_added) ? delta.package_edges_added : [];
286
+ const dels = contract?.deliverables ?? [];
287
+ return edges.filter((e) => e?.manifest && e?.to && !dels.some((d) => pathCovers(d.path, e.manifest) && String(d.change ?? "").includes(e.to)));
288
+ }
289
+
290
+ /**
291
+ * forge#4159: the one report-only line for the existing reviewer prompt, or "" when there is no import-pair growth
292
+ * and no boundary-config loosening. Never blocks; adds no reviewer run.
293
+ */
294
+ export function structureNote(delta) {
295
+ const grow = (Array.isArray(delta?.import_pairs) ? delta.import_pairs : []).filter((p) => p?.files_added > 0);
296
+ const b = delta?.boundary_config_changed;
297
+ const loosened = Boolean(b?.loosens);
298
+ if (!grow.length && !loosened) return "";
299
+ const pairs = grow.map((p) => `${p.files_added} value import${p.files_added === 1 ? "" : "s"} ${p.from}→${p.to}`).join(", ");
300
+ const files = (Array.isArray(b?.files) ? b.files : []).join(", ");
301
+ const config = loosened ? `the repo's boundary config was loosened in this PR (${files})` : "the repo's boundary config allows it";
302
+ const s = grow.length ? `this PR adds ${pairs} (files); ${config}` : `this PR loosens the repo's boundary config (${files})`;
303
+ return `Structure (report only): ${s}; flag only if the contract or the architecture doc says otherwise.`.replace(/[\r\n]+/g, " ");
304
+ }
@@ -2,7 +2,7 @@
2
2
  // SPDX-License-Identifier: AGPL-3.0-or-later
3
3
  import { describe, it } from "node:test";
4
4
  import assert from "node:assert/strict";
5
- import { validateContract, applyAmendments, precheck, parseReview, checkCompleteness, decide, closePlan, followupItems, inIssue, isTestPath } from "./conformance.mjs";
5
+ import { validateContract, applyAmendments, precheck, parseReview, checkCompleteness, decide, closePlan, followupItems, inIssue, isTestPath, reviewSkeleton, resolveEvidencePath } from "./conformance.mjs";
6
6
 
7
7
  const ISSUE = "## Requirements\n1. The scrape route must release the spend reservation on 429.\n2. The search route must release the spend reservation on 429.\n\n- [ ] Billing pricing is unchanged.";
8
8
  const Q1 = "The scrape route must release the spend reservation on 429.";
@@ -95,11 +95,40 @@ describe("conformance review: the engine computes the verdict from complete rows
95
95
  assert.ok(d.failures.every((f) => f.code === "review.incomplete"));
96
96
  });
97
97
  it("no JSON is unparseable", () => { assert.equal(parseReview("LGTM"), null); assert.equal(decide(null, ctx).state, "review.unparseable"); });
98
- it("incomplete: missing requirement row, missing scope row for a changed file, missing preserve row", () => {
98
+ it("incomplete: missing requirement row, missing scope row for an unauthorized changed file, missing preserve row", () => {
99
99
  assert.equal(decide({ ...good, requirements: good.requirements.slice(1) }, ctx).state, "review.incomplete");
100
- assert.equal(decide({ ...good, scope: good.scope.slice(1) }, ctx).state, "review.incomplete");
100
+ const extra = { ...ctx, changedFiles: [...files, "src/billing.js"] };
101
+ assert.equal(checkCompleteness(good, extra).problems.includes("changed file src/billing.js has no scope row"), true);
101
102
  assert.equal(decide({ ...good, preserve: [] }, ctx).state, "review.incomplete");
102
103
  });
104
+ it("#4160: a changed file the contract already authorizes (deliverable, test, incidental) needs no scope row", () => {
105
+ assert.equal(decide({ ...good, scope: [] }, ctx).state, "conforms");
106
+ assert.equal(checkCompleteness({ ...good, scope: [] }, { ...ctx, changedFiles: [...files, "CHANGELOG.md"] }).complete, true);
107
+ });
108
+ it("#4160: a scope row the reviewer gives for an authorized file is still judged (none on a deliverable is creep)", () => {
109
+ const s = good.scope.map((x) => (x.file === "src/scrape.js" ? { ...x, authorized_by: "none", note: "unrelated rewrite" } : x));
110
+ assert.equal(decide({ ...good, scope: s }, ctx).state, "nonconforming");
111
+ });
112
+ it("#4160: evidence given as a bare or partial file name resolves to the one changed file it names", () => {
113
+ assert.equal(resolveEvidencePath("scrape.js", files), "src/scrape.js");
114
+ assert.equal(resolveEvidencePath("./src/scrape.js", files), "src/scrape.js");
115
+ assert.equal(resolveEvidencePath("leak.test.js", files), "test/leak.test.js");
116
+ assert.equal(resolveEvidencePath("x.js", ["a/x.js", "b/x.js"]), null, "ambiguous");
117
+ assert.equal(resolveEvidencePath("nope.js", files), null);
118
+ assert.equal(decide(row("R1", { evidence: "scrape.js:4" }), ctx).state, "conforms");
119
+ });
120
+ it("#4160: reviewSkeleton lists every required row, pre-filled, with scope rows only for unauthorized files", () => {
121
+ const k = { ...C, requirements: [...C.requirements, { id: "R9", quote: "Billing pricing is unchanged.", disposition: "defer", why: "w" }] };
122
+ const sk = reviewSkeleton({ contract: k, changedFiles: [...files, "src/billing.js", ".forge-lean/x"], ignore: [".forge-lean/"] });
123
+ assert.deepEqual(sk.requirements.map((r) => r.id), k.requirements.map((r) => r.id));
124
+ assert.equal(sk.requirements.find((r) => r.id === "R9").deferral_ok, null);
125
+ assert.ok(!("deferral_ok" in sk.requirements[0]));
126
+ assert.deepEqual(sk.preserve.map((p) => p.id), (k.preserve ?? []).map((p) => p.id));
127
+ assert.deepEqual(sk.scope.map((s) => s.file), ["src/billing.js"]);
128
+ assert.deepEqual(sk.issue_coverage.map((c) => c.quote), k.requirements.map((r) => r.quote));
129
+ assert.ok(sk.issue_coverage.every((c) => inIssue(c.quote, ISSUE)), "seeded quotes pass the verbatim check");
130
+ assert.ok([...sk.requirements, ...sk.preserve, ...sk.issue_coverage].every((r) => r.status === ""), "no status is pre-judged");
131
+ });
103
132
  it("incomplete: implemented without path:line, with evidence in a nonexistent file, or without validation", () => {
104
133
  assert.equal(decide(row("R1", { evidence: "looks fine" }), ctx).state, "review.incomplete");
105
134
  assert.equal(decide(row("R1", { evidence: "src/nope.js:3" }), ctx).state, "review.incomplete");
@@ -3,7 +3,7 @@
3
3
  /** Core work-on: the real effects behind runCore's injected `io`. Argv only, no shell. */
4
4
  import { execFile, execFileSync } from "node:child_process";
5
5
  import { promisify } from "node:util";
6
- import { readFileSync, appendFileSync, mkdirSync, statSync, existsSync, rmSync, writeFileSync, readdirSync, lstatSync } from "node:fs";
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";
8
8
  import { promises as fsp } from "node:fs";
9
9
  import { dirname, join, resolve, sep } from "node:path";
@@ -11,6 +11,7 @@ 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
15
 
15
16
  const pexec = promisify(execFile);
16
17
  const script = (n) => fileURLToPath(new URL(`../../scripts/${n}`, import.meta.url));
@@ -45,18 +46,40 @@ export function wrapLaneLimits(bin, args, limits) {
45
46
  }
46
47
 
47
48
  export function makeLeanRunner({ runCli = runCliBackendAsync, probe = systemdRunAvailable } = {}) {
48
- return async ({ role, issue, cwd, model, resume, system, prompt, readOnly = false, env = null, idleMs = 0, maxMs = 0, laneLimits = null, control = null }) => {
49
+ return async ({ role, issue, cwd, model, resume, system, prompt, readOnly = false, env = null, idleMs = 0, maxMs = 0, laneLimits = null, control = null, onSpawn = null }) => {
49
50
  const bin = resolveClaudeCliBinary(cwd) || "claude";
50
51
  const caps = getClaudeCliCapabilities(cwd);
51
52
  const flags = [...buildLeanSessionFlags(caps), ...(readOnly ? buildReadOnlyToolFlags(caps) : [])];
52
53
  const r = await runCli({ spec: { name: `core-${role}` }, userMessage: prompt, systemPrompt: resume ? "" : system,
53
54
  args: [String(issue)], cwd, bin, model, env, sessionFlags: flags, extraArgs: resume ? ["--resume", resume] : [],
54
55
  logger: { log() {} },
55
- idleMs, maxMs, stream: idleMs > 0, control, wrap: laneLimits && probe() ? (b, a) => wrapLaneLimits(b, a, laneLimits) : null });
56
+ idleMs, maxMs, stream: idleMs > 0, control, onSpawn, wrap: laneLimits && probe() ? (b, a) => wrapLaneLimits(b, a, laneLimits) : null });
56
57
  return { text: r.text, sessionId: r.sessionId, usage: r.usage, costUsd: r.costUsd, durationMs: r.durationMs, turns: r.turns, model: r.cliModel ?? "default" };
57
58
  };
58
59
  }
59
60
 
61
+ /** Parent pid from /proc/<pid>/stat (the field after the parenthesised command name); 0 when unreadable. */
62
+ const ppidOf = (root, pid) => { try { const st = readFileSync(join(root, String(pid), "stat"), "utf8"); return Number(st.slice(st.lastIndexOf(")") + 2).split(" ")[1]) || 0; } catch { return 0; } };
63
+ /**
64
+ * #4162: processes whose cwd is `dir` or inside it, from /proc (Linux). Excludes this process and its ancestors.
65
+ * Returns [{pid, cmd}]; [] where /proc is unavailable (the caller then skips stopping them).
66
+ */
67
+ export function procsInDir(dir, { procRoot = "/proc", self = process.pid } = {}) {
68
+ if (!dir || !existsSync(procRoot)) return [];
69
+ try { dir = realpathSync(dir); } catch { /* not on disk: compare as given */ } // /proc cwd links are resolved paths
70
+ const skip = new Set();
71
+ for (let p = self, i = 0; p > 1 && i < 64 && !skip.has(p); i++) { skip.add(p); p = ppidOf(procRoot, p); }
72
+ const out = [];
73
+ for (const e of readdirSync(procRoot)) {
74
+ if (!/^\d+$/.test(e) || skip.has(Number(e))) continue;
75
+ let at; try { at = readlinkSync(join(procRoot, e, "cwd")); } catch { continue; }
76
+ if (at !== dir && !at.startsWith(`${dir}/`)) continue;
77
+ let cmd = ""; try { cmd = readFileSync(join(procRoot, e, "cmdline"), "utf8").split("\0").filter(Boolean).join(" ").slice(0, 200); } catch { /* exited */ }
78
+ out.push({ pid: Number(e), cmd });
79
+ }
80
+ return out;
81
+ }
82
+
60
83
  export const RUN_TMP_MAX_AGE_MS = 6 * 3600 * 1000;
61
84
  export const runTmpRoot = () => join(homedir(), ".forge", "tmp", "runs");
62
85
  const OWNER = ".forge-run-owner";
@@ -66,6 +89,8 @@ const pidAlive = (pid) => { try { process.kill(pid, 0); return true; } catch (e)
66
89
  export function makeRunTmp({ root = runTmpRoot(), now = () => Date.now(), alive = pidAlive, host = hostname() } = {}) {
67
90
  const inside = (d) => { const r = resolve(d); return r.startsWith(resolve(root) + sep) && !r.slice(resolve(root).length + 1).includes(sep); };
68
91
  return {
92
+ /** The dir make(issue, runId) creates, so a ledger can register it first (#4179). */
93
+ path: (runId) => join(root, String(runId)),
69
94
  async make(issue, runId) {
70
95
  const dir = join(root, String(runId));
71
96
  mkdirSync(dir, { recursive: true, mode: 0o700 });
@@ -100,6 +125,22 @@ export function makeRunTmp({ root = runTmpRoot(), now = () => Date.now(), alive
100
125
  };
101
126
  }
102
127
 
128
+ /**
129
+ * SIGTERM to a ledger-registered pid's process group (#4179). Only when the pid is alive and, where /proc exists, its
130
+ * command line still names `marker`, so a reused pid is never signalled. Returns true when the pid is gone or signalled.
131
+ */
132
+ export function killLedgerPid(pid, marker, { kill = process.kill.bind(process), procDir = "/proc" } = {}) {
133
+ const n = Number(pid);
134
+ if (!Number.isSafeInteger(n) || n <= 1) return false;
135
+ if (!pidAlive(n)) return true;
136
+ if (marker && existsSync(procDir)) {
137
+ let cmd = "";
138
+ try { cmd = readFileSync(join(procDir, String(n), "cmdline"), "utf8"); } catch { return true; } // exited meanwhile
139
+ if (!cmd.includes(String(marker))) return true; // pid reused by something else: not ours any more
140
+ }
141
+ try { kill(-n, "SIGTERM"); return true; } catch { try { kill(n, "SIGTERM"); return true; } catch (e) { return e?.code === "ESRCH"; } }
142
+ }
143
+
103
144
  /** Real host probes for admission (#4105). MemAvailable from /proc/meminfo (fallback os.freemem), statfs for tmp. */
104
145
  export function makeHostProbe() {
105
146
  return {
@@ -122,18 +163,34 @@ export function makeHostProbe() {
122
163
  };
123
164
  }
124
165
 
166
+ /**
167
+ * Required status checks for `lane` (#4158): the union of classic branch protection (contexts + checks) and the
168
+ * repository rulesets that apply to the branch (`rules/branches/{lane}`, type required_status_checks). Each source
169
+ * fails independently to [] (404, no protection, no rulesets, bad JSON), so one never hides the other.
170
+ */
171
+ export async function requiredChecksFor({ gh, repo, lane }) {
172
+ const classic = async () => {
173
+ const j = JSON.parse(String(await gh(["api", `repos/${repo}/branches/${lane}/protection/required_status_checks`])));
174
+ return [...(j.contexts ?? []), ...(j.checks ?? []).map((c) => c?.context)];
175
+ };
176
+ const rulesets = async () => {
177
+ // --jq emits one context per line across all pages, so pagination needs no JSON stitching.
178
+ const out = String(await gh(["api", "--paginate", `repos/${repo}/rules/branches/${lane}`,
179
+ "--jq", '.[] | select(.type == "required_status_checks") | .parameters.required_status_checks[]?.context']));
180
+ return out.split("\n").map((x) => x.trim());
181
+ };
182
+ const safe = async (f) => { try { const v = await f(); return Array.isArray(v) ? v : []; } catch { return []; } };
183
+ const all = [...(await safe(classic)), ...(await safe(rulesets))];
184
+ return [...new Set(all.filter((x) => typeof x === "string" && x))];
185
+ }
186
+
125
187
  export function makeLeanIo({ repo }) {
126
188
  const base = makeIo();
127
189
  const run = (bin) => async (args, opts = {}) => (await pexec(bin, args, { cwd: opts.cwd, maxBuffer: 100 * 1024 * 1024, timeout: 120000 })).stdout;
128
190
  return {
129
- gh: run("gh"), git: run("git"), runTmp: makeRunTmp(), host: makeHostProbe(), projector: makeProjector({ gh: base.gh }), runner: makeLeanRunner(), waitCiGreen: base.waitCiGreen,
130
- /** Names of the checks the base branch requires (contexts + checks). [] on 404 / no protection. */
131
- async requiredChecks(lane) {
132
- try {
133
- const j = JSON.parse(await run("gh")(["api", `repos/${repo}/branches/${lane}/protection/required_status_checks`]));
134
- return [...new Set([...(j.contexts ?? []), ...(j.checks ?? []).map((c) => c.context)].filter(Boolean))];
135
- } catch { return []; }
136
- },
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,
192
+ /** Names of the checks the base branch requires: classic protection plus rulesets (#4158). [] when neither has any. */
193
+ async requiredChecks(lane) { return requiredChecksFor({ gh: run("gh"), repo, lane }); },
137
194
  /** [{name, state: success|failure|pending}] for the PR's checks. */
138
195
  async checkStates(pr) {
139
196
  const j = JSON.parse(await run("gh")(["pr", "view", String(pr), "-R", repo, "--json", "statusCheckRollup"]));
@@ -143,6 +200,28 @@ export function makeLeanIo({ repo }) {
143
200
  return { name: c.name ?? c.context, state };
144
201
  });
145
202
  },
203
+ /** #4167 verify-by-CI: `gh workflow run` on the lane branch; inputs become -f key=value. */
204
+ async dispatchWorkflow({ workflow, ref, inputs = {} }) {
205
+ await run("gh")(["workflow", "run", String(workflow), "-R", repo, "--ref", String(ref), ...Object.entries(inputs).flatMap(([k, v]) => ["-f", `${k}=${v}`])]);
206
+ },
207
+ /** Recent workflow_dispatch runs of `workflow` on `branch`, newest first: [{databaseId, headSha, status, conclusion, url, name}]. */
208
+ async workflowRuns({ workflow, branch }) {
209
+ return JSON.parse(await run("gh")(["run", "list", "-R", repo, "--workflow", String(workflow), "--branch", String(branch), "--event", "workflow_dispatch",
210
+ "--limit", "10", "--json", "databaseId,headSha,status,conclusion,url,name"]) || "[]");
211
+ },
212
+ /** Check runs on a commit (push-triggered CI): [{name, state: success|failure|pending, url}], same mapping as checkStates. */
213
+ async commitChecks(sha) {
214
+ const out = String(await run("gh")(["api", "--paginate", `repos/${repo}/commits/${sha}/check-runs`, "--jq", ".check_runs[] | {name, status, conclusion, url: .html_url} | @json"]));
215
+ return out.split("\n").filter((l) => l.trim()).map((l) => JSON.parse(l)).map((c) => {
216
+ const v = String(c.status === "completed" ? c.conclusion ?? "" : "").toUpperCase();
217
+ const state = ["SUCCESS", "NEUTRAL", "SKIPPED"].includes(v) ? "success" : ["FAILURE", "ERROR", "TIMED_OUT", "CANCELLED", "ACTION_REQUIRED", "STARTUP_FAILURE", "STALE"].includes(v) ? "failure" : "pending";
218
+ return { name: c.name, state, url: c.url ?? null };
219
+ });
220
+ },
221
+ /** Failed-job logs of an Actions run (`gh run view --log-failed`), tail-capped; "" when unreadable. */
222
+ async failedLogs(runId) {
223
+ try { const out = String(await run("gh")(["run", "view", String(runId), "-R", repo, "--log-failed"])); return out.length > 64000 ? out.slice(-64000) : out; } catch { return ""; }
224
+ },
146
225
  async transitionLabel(issue, state) { await pexec("bash", [script("transition-label.sh"), String(issue), "-R", repo, state], { timeout: 60000 }); },
147
226
  /**
148
227
  * File (or append to) the parent's ONE batched follow-up issue (scripts/file-deferred-followups.sh: trusted lookup,
@@ -155,6 +234,12 @@ export function makeLeanIo({ repo }) {
155
234
  for (const l of out.split("\n")) { const [k, n] = l.split("\t"); if (k && /^\d+$/.test(String(n ?? "").trim())) map[k] = Number(n); }
156
235
  return map;
157
236
  },
237
+ /** forge#4159: scripts/structure-delta.sh over base...HEAD in cwd; null on any failure (never fails a run). */
238
+ async structureDelta({ cwd, base }) {
239
+ 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
+ },
241
+ /** #4162: processes left running inside a lane worktree (Linux /proc). */
242
+ proc: { inDir: async (dir) => procsInDir(dir), alive: pidAlive, kill: (pid, sig) => { try { process.kill(pid, sig); } catch { /* already gone */ } } },
158
243
  fs: {
159
244
  read: async (p) => readFileSync(p, "utf8"),
160
245
  exists: async (p) => existsSync(p),