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
@@ -3,15 +3,17 @@
3
3
  import { describe, it } from "node:test";
4
4
  import assert from "node:assert/strict";
5
5
  import { tmpDir } from "../tests/_tmp.mjs";
6
- import { writeFileSync } from "node:fs";
7
- import { runCore, contractHash } from "./lean.mjs";
6
+ import { writeFileSync, existsSync, readFileSync, mkdirSync, symlinkSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { runCore, contractHash, isProtectedLaneBranch } from "./lean.mjs";
9
+ import { makeLedger } from "./ledger.mjs";
8
10
  import { renderInvestigation, renderPlan, renderImplementation, renderReview, renderPrBody, renderTrajectory, renderContract, contractJson, parseContractComment, sanitizeMd, capComment, section, cell, NR, MAX_COMMENT_CHARS } from "./render.mjs";
9
11
  import { orchestrate, parseDeps, formatTable } from "./orchestrate.mjs";
10
12
  import { REVIEWER_SYSTEM, approxTokens, builderPlanPrompt, builderImplementPrompt, reviewerPrompt } from "./prompts.mjs";
11
- import { parseReview } from "./conformance.mjs";
13
+ import { parseReview, unnamedEdges, structureNote, applyAmendments } from "./conformance.mjs";
12
14
  import { mapRecord } from "../engine/telemetry-seam.mjs";
13
15
  import { readLog } from "../engine/runlog.mjs";
14
- import { makeRunTmp, wrapLaneLimits, systemdRunAvailable, makeLeanRunner } from "./effects.mjs";
16
+ import { makeRunTmp, wrapLaneLimits, systemdRunAvailable, makeLeanRunner, requiredChecksFor, procsInDir } from "./effects.mjs";
15
17
  import { retrying, PUSH_TRANSIENT_RE } from "./retry.mjs";
16
18
  import { checkAdmission } from "./admission.mjs";
17
19
  import { OUTCOME_CODES } from "../engine/outcome-codes.mjs";
@@ -52,8 +54,9 @@ function world(o = {}) {
52
54
  contract: o.contract ?? CONTRACT, issueComments: o.issueComments, issueStates: o.issueStates,
53
55
  changed: o.changed ?? ["src/foo.js", "test/foo.test.js"], reviews: [...(o.reviews ?? [])], waits: [...(o.waits ?? [])], clock: 0,
54
56
  result: o.result ?? { status: "fixed", summary: "fixed foo", evidence: [], tests: ["test/foo.test.js"] },
55
- prList: o.prList ?? [], remote: o.remote ?? "", resumeResult: o.resumeResult, seq: [], mtimes: {}, envs: [], pushes: 0, pushErrors: [...(o.pushErrors ?? [])], commentErrors: [...(o.commentErrors ?? [])], dirty: o.dirty ?? false, lastSubject: o.lastSubject ?? "", prView: [...(o.prView ?? [])], mergeResults: [...(o.mergeResults ?? [])], mergeErrors: [...(o.mergeErrors ?? [])], conflictFiles: [], mergeInProgress: false, markers: false, mergeAttempts: 0,
57
+ prList: o.prList ?? [], remote: o.remote ?? "", resumeResult: o.resumeResult, seq: [], mtimes: {}, envs: [], pushes: 0, pushErrors: [...(o.pushErrors ?? [])], commentErrors: [...(o.commentErrors ?? [])], dirty: o.dirty ?? false, lastSubject: o.lastSubject ?? "", prView: [...(o.prView ?? [])], mergeResults: [...(o.mergeResults ?? [])], mergeErrors: [...(o.mergeErrors ?? [])], conflictFiles: [], mergeInProgress: false, markers: false, mergeAttempts: 0, branchDeletes: [],
56
58
  stages: { ...STAGE_MD, ...(o.stages ?? {}) }, builderN: 0,
59
+ salvageTrees: [...(o.salvageTrees ?? [])], alive: new Set((o.procs ?? []).map((p) => p.pid)), kills: [],
57
60
  };
58
61
  const put = (name, text) => { const p = `${WT}/.forge-lean/${name}`; if (w.files[p] !== text) w.mtimes[p] = Date.now(); w.files[p] = text; };
59
62
  const writeStages = (keys) => { for (const k of keys) if (w.stages[k] != null) put(`${k}.md`, w.stages[k]); };
@@ -67,6 +70,7 @@ function world(o = {}) {
67
70
  if (k === "pr list") return JSON.stringify(w.prList);
68
71
  if (k === "repo view") return `${o.defaultBranch ?? "main"}\n`;
69
72
  if (k === "issue view" && a.includes("milestone")) { w.seq.push("lane"); return JSON.stringify({ milestone: o.milestone ? { title: o.milestone } : null }); }
73
+ if (k === "issue view" && o.issueBodies && a[2] === "7" && a.includes("number,title,body,state,comments")) { const b = o.issueBodies.length > 1 ? o.issueBodies.shift() : o.issueBodies[0]; return JSON.stringify({ number: 7, title: "foo breaks", body: b, state: "OPEN", comments: [] }); }
70
74
  if (k === "issue view") return JSON.stringify({ number: Number(a[2]), title: "foo breaks", body: ISSUE_BODY, state: w.issueStates?.[a[2]] ?? "OPEN", comments: [] });
71
75
  if (k === "pr edit") { w.prEdits.push(a); return ""; }
72
76
  if (k === "issue comment" && w.commentErrors.length) throw new Error(w.commentErrors.shift());
@@ -85,9 +89,12 @@ function world(o = {}) {
85
89
  if (a[0] === "worktree" && a[1] === "add" && (w.lockFails ?? 0) > 0) {
86
90
  w.lockFails--; const e = new Error("git failed"); e.stderr = "error: could not lock config file .git/config: File exists"; throw e;
87
91
  }
92
+ if (a[0] === "worktree" && a[1] === "remove") w.seq.push("worktree-remove");
93
+ if (a[0] === "update-ref") w.seq.push(`update-ref ${a[1]}`);
88
94
  if (a[0] === "rev-parse" && a[1] === "--git-path") return "/repo/.git/info/exclude";
89
95
  if (a[0] === "status" && a[1] === "--porcelain") return w.dirty ? " M src/foo.js\n" : "";
90
96
  if (a[0] === "log" && a.includes("--format=%s")) return w.lastSubject;
97
+ if (a[0] === "push" && a.includes("--delete")) { w.branchDeletes.push(a.at(-1)); w.seq.push("delete-remote-branch"); return ""; }
91
98
  if (a[0] === "push") { w.pushes++; if (w.pushErrors.length) { const m = w.pushErrors.shift(); throw Object.assign(new Error(m), { stderr: m }); } }
92
99
  if (a[0] === "merge" && a[1] === "--no-edit") {
93
100
  const r = w.mergeResults.shift() ?? "clean";
@@ -95,6 +102,9 @@ function world(o = {}) {
95
102
  w.conflictFiles = o.conflictFiles ?? ["src/foo.js"]; w.mergeInProgress = true; throw new Error("CONFLICT (content): Merge conflict in src/foo.js");
96
103
  }
97
104
  if (a[0] === "rev-parse" && a.includes("MERGE_HEAD")) return w.mergeInProgress ? "deadbeef" : "";
105
+ if (a[0] === "write-tree") return w.salvageTrees.length ? w.salvageTrees.shift() : "";
106
+ if (a[0] === "rev-parse" && a[1] === "HEAD^{tree}") return "0".repeat(40);
107
+ if (a.includes("commit-tree")) return "c".repeat(40);
98
108
  if (a[0] === "diff" && a.includes("--diff-filter=U")) return w.conflictFiles.join("\n");
99
109
  if (a[0] === "diff" && a[1] === "--check") return "";
100
110
  if (a[0] === "grep") return w.markers ? "src/foo.js:3:<<<<<<< HEAD" : "";
@@ -131,7 +141,7 @@ function world(o = {}) {
131
141
  if (/Merging origin\//.test(c.prompt) && o.syncChanged) w.changed = o.syncChanged;
132
142
  if (r?.status === "blocked" || ["invalid", "already-fixed"].includes(r?.status)) put("result.json", JSON.stringify(r));
133
143
  else if (r) put("result.json", JSON.stringify({ ...r, requirements: o.claims ?? w.contract.requirements.filter((x) => x.disposition === "deliver").map((x) => ({ id: x.id, status: "implemented", evidence: "src/foo.js:3" })),
134
- validation: o.validation ?? (w.contract.validate ?? []).map((v) => ({ id: v.id, command: v.command, exit: 0 })), amendments: (c.resume && n > 2 ? o.resumeAmendments : null) ?? o.amendments ?? [] }));
144
+ validation: (c.resume && n > 2 ? o.resumeValidation : null) ?? o.validation ?? (w.contract.validate ?? []).map((v) => ({ id: v.id, command: v.command, exit: 0 })), amendments: (c.resume && n > 2 ? o.resumeAmendments : null) ?? o.amendments ?? [] }));
135
145
  }
136
146
  if (c.resume || /Merging origin\//.test(c.prompt)) { w.head = `bbbb${w.calls.length}`.padEnd(16, "0"); if (!o.leaveConflicts) { w.conflictFiles = []; w.mergeInProgress = false; } }
137
147
  return { text: "done", sessionId: "sess-1", usage: { input_tokens: 10, output_tokens: 5, cache_read_input_tokens: 100 }, costUsd: 0.1, durationMs: 1000, turns: 3, model: "default" };
@@ -140,7 +150,8 @@ function world(o = {}) {
140
150
  },
141
151
  waitCiGreen: async () => (w.waits.length ? w.waits.shift() : { rc: 0, text: `CI_GATE_HEAD: ${w.head}` }),
142
152
  transitionLabel: async (n, s) => { w.labels.push(s); w.seq.push(`label:${s}`); },
143
- fs: { stat: async (p) => (p in w.files ? { mtimeMs: w.mtimes[p] ?? 1 } : null), read: async (p) => { if (!(p in w.files)) throw new Error("ENOENT"); return w.files[p]; }, append: async (p, t) => { w.files[p] = (w.files[p] ?? "") + t; }, write: async (p, t) => { w.files[p] = t; } },
153
+ ...(o.procs ? { proc: { inDir: async () => o.procs, alive: (pid) => w.alive.has(pid), kill: (pid, sig) => { w.kills.push([pid, sig]); w.seq.push(`kill-${sig}`); if (sig === "SIGKILL" || !o.stubborn) w.alive.delete(pid); } } } : {}),
154
+ fs: { ...(o.wtExists ? { exists: async (p) => p.startsWith("/wt/lean-7") } : {}), stat: async (p) => (p in w.files ? { mtimeMs: w.mtimes[p] ?? 1 } : null), read: async (p) => { if (!(p in w.files)) throw new Error("ENOENT"); return w.files[p]; }, append: async (p, t) => { w.files[p] = (w.files[p] ?? "") + t; }, write: async (p, t) => { w.files[p] = t; } },
144
155
  fileFollowups: async ({ parent, items }) => { w.followups.push({ parent, items }); if (o.followupError) throw new Error(o.followupError); return Object.fromEntries(items.map((i) => [i.key, o.followupIssue ?? 900])); },
145
156
  };
146
157
  return w;
@@ -407,6 +418,29 @@ describe("core work-on", () => {
407
418
  noNeedsHuman(w);
408
419
  });
409
420
 
421
+ it("#4153 a split of a milestone issue files every sub-issue with the parent's milestone; no milestone passes none", async () => {
422
+ const split = { status: "blocked", summary: "epic", block: { kind: "split-required", subissues: [{ title: "part A", body: "do A" }, { title: "part B", body: "do B", depends_on: [0] }] } };
423
+ const w = depWorld({ changed: [], result: split, milestone: "Remote MCP Server", branchesOnOrigin: ["staging", "milestone/remote-mcp-server"] }, {});
424
+ const r = await run(w, { lane: null });
425
+ assert.equal(r.terminalReason, "decomposed");
426
+ assert.equal(w.created.length, 2);
427
+ for (const c of w.created) assert.equal(c[c.indexOf("--milestone") + 1], "Remote MCP Server", c.join(" "));
428
+ const w2 = depWorld({ changed: [], result: split }, {});
429
+ assert.equal((await run(w2)).terminalReason, "decomposed");
430
+ assert.ok(w2.created.every((c) => !c.includes("--milestone")));
431
+ });
432
+
433
+ it("#4153 a dependency block on an open decomposed parent resolves to its open sub-issues", async () => {
434
+ const decomposed = { body: "<!-- FORGE:DECOMPOSED -->\n## Split into sub-issues\n- #31 part A\n- #32 part B\n", author_association: "NONE", user: { type: "Bot", login: "forge[bot]" } };
435
+ const parent = { state: "OPEN", labels: [{ name: "workflow:decomposed" }] };
436
+ const w = depWorld({ changed: [], issueComments: [decomposed], result: { status: "blocked", summary: "needs #3", block: { kind: "dependency", issues: [3] } } },
437
+ { 3: parent, 31: { state: "CLOSED", labels: [] }, 32: { state: "OPEN", labels: [] } });
438
+ const r = await run(w);
439
+ assert.equal(r.terminalReason, "dependency-open");
440
+ assert.deepEqual(r.dependencies, [32]);
441
+ noNeedsHuman(w);
442
+ });
443
+
410
444
  it("#4124 a prod-data (non-human) or untyped block gets one corrective resume; a builder that then proceeds merges", async () => {
411
445
  const w = world({ result: { status: "blocked", summary: "needs live wire capture and prod baselines", block: { kind: "human", why: "prod-data" } },
412
446
  resumeResult: { status: "fixed", summary: "captured via read-only monitoring", evidence: [], tests: [] } });
@@ -634,9 +668,126 @@ describe("conformance gate (frozen contract, precheck, structured review)", () =
634
668
  });
635
669
  });
636
670
 
671
+ describe("issue edited mid-run (#4188)", () => {
672
+ const BODY_B = "foo now also needs a bar mode.\n\n- foo must accept a bar flag and return bar output.\n- Existing callers of foo keep working.";
673
+ const contractB = { ...CONTRACT, requirements: [{ id: "R1", quote: "foo must accept a bar flag and return bar output.", disposition: "deliver" }] };
674
+ const reviewB = () => conf({ issue_coverage: [{ quote: "foo must accept a bar flag and return bar output.", status: "covered" }] }, contractB);
675
+
676
+ it("body A at RUN_START, body B at validation, contract quoting B: validates against B, logs ISSUE_EDITED, merges", async () => {
677
+ const dir = tmpDir("issue-edited-");
678
+ const w = world({ contract: contractB, issueBodies: [ISSUE_BODY, BODY_B], reviews: [reviewB()] });
679
+ const r = await run(w, { dir });
680
+ assert.notEqual(r.code, "contract.invalid");
681
+ assert.equal(r.terminalReason, "merged");
682
+ assert.equal(builders(w).length, 2, "plan + implement, no contract correction");
683
+ const edits = readLog(dir, 7).filter((e) => e.event === "ISSUE_EDITED");
684
+ assert.equal(edits.length, 1);
685
+ assert.match(edits[0].old, /^[0-9a-f]{12}$/); assert.match(edits[0].new, /^[0-9a-f]{12}$/);
686
+ assert.notEqual(edits[0].old, edits[0].new);
687
+ assert.match(traj(w).at(-1), /Issue edited during the run \([0-9a-f]{12} -> [0-9a-f]{12}\)/);
688
+ });
689
+
690
+ it("an unchanged issue emits no ISSUE_EDITED", async () => {
691
+ const dir = tmpDir("issue-unedited-");
692
+ const w = world({ issueBodies: [ISSUE_BODY] });
693
+ assert.equal((await run(w, { dir })).terminalReason, "merged");
694
+ assert.equal(readLog(dir, 7).filter((e) => e.event === "ISSUE_EDITED").length, 0);
695
+ });
696
+ });
697
+
698
+ describe("review row skeleton and targeted retry (#4160)", () => {
699
+ it("the reviewer prompt carries a pre-filled skeleton with every requirement and preserve id", async () => {
700
+ const w = world();
701
+ assert.equal((await run(w)).terminalReason, "merged");
702
+ const p = reviewers(w)[0][4];
703
+ const sk = JSON.parse(/<row_skeleton>\n([\s\S]*?)\n<\/row_skeleton>/.exec(p)[1]);
704
+ assert.deepEqual(sk.requirements.map((r) => r.id), CONTRACT.requirements.map((r) => r.id));
705
+ assert.deepEqual(sk.preserve.map((x) => x.id), (CONTRACT.preserve ?? []).map((x) => x.id));
706
+ assert.doesNotMatch(p, /previous_reply_problems/);
707
+ });
708
+ it("an incomplete first reply gets one retry that lists exactly what was missing, and the complete second reply decides", async () => {
709
+ const w = world({ reviews: [genericPass(), ok()] });
710
+ assert.equal((await run(w)).terminalReason, "merged");
711
+ assert.equal(reviewers(w).length, 2);
712
+ const p2 = reviewers(w)[1][4];
713
+ assert.match(p2, /<previous_reply_problems>[\s\S]*requirement R1 needs exactly one row/);
714
+ assert.match(p2, /<previous_reply>[\s\S]*"verdict":"PASS"/);
715
+ });
716
+ it("a complete first reply needs no second reviewer run", async () => {
717
+ const w = world();
718
+ assert.equal((await run(w)).terminalReason, "merged");
719
+ assert.equal(reviewers(w).length, 1);
720
+ });
721
+ });
722
+
723
+ describe("worktree salvage and orphaned processes (#4162)", () => {
724
+ const T1 = "1".repeat(40), T2 = "2".repeat(40);
725
+ const salvageRefs = (w) => w.calls.filter((c) => c[0] === "git" && c[1] === "update-ref").map((c) => c[2]);
726
+
727
+ it("uncommitted work in an earlier run's worktree is saved to a salvage ref before the worktree is removed, and the implementer is told", async () => {
728
+ const dir = tmpDir("salvage-");
729
+ const w = world({ wtExists: true, salvageTrees: [T1] });
730
+ assert.equal((await run(w, { dir })).terminalReason, "merged");
731
+ const refs = salvageRefs(w);
732
+ assert.equal(refs.length, 1); assert.match(refs[0], /^refs\/forge\/salvage\/7\/\d+$/);
733
+ assert.ok(w.seq.indexOf(`update-ref ${refs[0]}`) < w.seq.indexOf("worktree-remove"), "saved before removal");
734
+ assert.ok(w.calls.some((c) => c[0] === "git" && c[1] === "add" && c[2] === "-f" && c.includes(".forge-lean")), ".forge-lean is included");
735
+ assert.ok(builders(w).some((b) => b[4].includes(`saved at \`${refs[0]}\``)), "implement prompt names the ref");
736
+ assert.match(traj(w).at(-1), /Uncommitted work from an earlier run saved to `refs\/forge\/salvage\/7\//);
737
+ assert.equal(readLog(dir, 7).filter((e) => e.event === "WORKTREE_SALVAGED").length, 1);
738
+ });
739
+
740
+ it("a clean earlier worktree (tree equals HEAD) saves nothing and the prompt is unchanged", async () => {
741
+ const w = world({ wtExists: true, salvageTrees: ["0".repeat(40)] });
742
+ assert.equal((await run(w)).terminalReason, "merged");
743
+ assert.equal(salvageRefs(w).length, 0);
744
+ assert.ok(builders(w).every((b) => !b[4].includes("saved at")));
745
+ });
746
+
747
+ it("no worktree on disk: nothing is staged or saved", async () => {
748
+ const w = world({ salvageTrees: [T1] });
749
+ assert.equal((await run(w)).terminalReason, "merged");
750
+ assert.equal(salvageRefs(w).length, 0);
751
+ assert.ok(!w.calls.some((c) => c[0] === "git" && c[1] === "write-tree"));
752
+ });
753
+
754
+ it("processes still running in the worktree are stopped (SIGTERM) before the worktree is removed", async () => {
755
+ const w = world({ procs: [{ pid: 4242, cmd: "claude --print" }] });
756
+ assert.equal((await run(w)).terminalReason, "merged");
757
+ assert.deepEqual(w.kills, [[4242, "SIGTERM"]]);
758
+ assert.ok(w.seq.indexOf("kill-SIGTERM") < w.seq.indexOf("worktree-remove"));
759
+ assert.match(traj(w).at(-1), /Stopped 1 process\(es\) still running in the worktree from an earlier run: 4242 claude --print/);
760
+ });
761
+
762
+ it("a process that ignores SIGTERM is killed with SIGKILL", async () => {
763
+ const w = world({ procs: [{ pid: 4243, cmd: "node x" }], stubborn: true });
764
+ assert.equal((await run(w)).terminalReason, "merged");
765
+ assert.deepEqual(w.kills, [[4243, "SIGTERM"], [4243, "SIGKILL"]]);
766
+ });
767
+
768
+ it("salvage runs only at the start: teardown at the end is the #4179 checkpoint, not a salvage ref", async () => {
769
+ const w = world({ wtExists: true, salvageTrees: ["0".repeat(40), T2] });
770
+ assert.equal((await run(w)).terminalReason, "merged");
771
+ assert.equal(salvageRefs(w).length, 0);
772
+ });
773
+
774
+ it("procsInDir reads cwd links from a /proc tree and skips this process and its ancestors", () => {
775
+ const root = tmpDir("proc-"), wtDir = tmpDir("wt-");
776
+ const mk = (pid, cwdTarget, ppid, cmd) => { mkdirSync(join(root, String(pid)), { recursive: true }); symlinkSync(cwdTarget, join(root, String(pid), "cwd")); writeFileSync(join(root, String(pid), "stat"), `${pid} (x y) S ${ppid} 0`); writeFileSync(join(root, String(pid), "cmdline"), cmd.split(" ").join("\0")); };
777
+ mk(100, wtDir, 1, "claude --print");
778
+ mk(101, join(wtDir, "src"), 1, "node test");
779
+ mk(102, "/elsewhere", 1, "bash");
780
+ mk(200, wtDir, 1, "self");
781
+ mkdirSync(join(root, "self")); // non-numeric entries are ignored
782
+ assert.deepEqual(procsInDir(wtDir, { procRoot: root, self: 200 }).map((p) => [p.pid, p.cmd]).sort(), [[100, "claude --print"], [101, "node test"]]);
783
+ assert.deepEqual(procsInDir(wtDir, { procRoot: join(root, "missing") }), []);
784
+ });
785
+ });
786
+
637
787
  describe("conformance shadow mode (the default)", () => {
638
788
  const runShadow = (w, extra = {}) => run(w, { conformance: "shadow", ...extra });
639
789
  const shadowNote = (w) => w.comments.find((c) => c.includes("<!-- FORGE:REVIEW -->")) ?? "";
790
+ const nhBody = (w) => w.comments.find((c) => c.includes("FORGE:NEEDS_HUMAN")) ?? "";
640
791
  const omission = () => conf({ issue_coverage: [{ quote: "foo must handle a null argument without throwing.", status: "covered" }, { quote: "Existing callers of foo keep working.", status: "missing" }] }, { ...CONTRACT, preserve: [] }, ["src/foo.js", "test/foo.test.js"]);
641
792
 
642
793
  it("runCore defaults to shadow", async () => {
@@ -675,6 +826,40 @@ describe("conformance shadow mode (the default)", () => {
675
826
  assert.equal((await runShadow(w2)).terminalReason, "merged"); assert.equal(builders(w2).length, 3, "a regression is resumed in shadow too");
676
827
  });
677
828
 
829
+ it("shadow blocks a failed validate command (#4160): one resume, then needs-human, and no reviewer tokens are spent", async () => {
830
+ const failed = [{ id: "V1", command: "node --test test/foo.test.js", exit: 1 }];
831
+ const w = world({ validation: failed });
832
+ const r = await runShadow(w);
833
+ assert.equal(r.code, "conformance.unimplemented"); assert.equal(w.merges.length, 0);
834
+ assert.equal(builders(w).length, 3, "plan + implement + one resume"); assert.equal(reviewers(w).length, 0);
835
+ assert.match(builders(w)[2][4], /validate V1 failed \(exit 1\)/);
836
+ assert.match(nhBody(w), /validate V1 failed \(exit 1\)/);
837
+ });
838
+
839
+ it("shadow: a failed validate fixed by the resume merges", async () => {
840
+ const w = world({ validation: [{ id: "V1", command: "node --test test/foo.test.js", exit: 1 }], resumeValidation: [{ id: "V1", command: "node --test test/foo.test.js", exit: 0 }] });
841
+ assert.equal((await runShadow(w)).terminalReason, "merged"); assert.equal(builders(w).length, 3);
842
+ });
843
+
844
+ it("shadow blocks a deliver requirement the builder reported as partial (#4160)", async () => {
845
+ const w = world({ claims: [{ id: "R1", status: "partial" }] });
846
+ const r = await runShadow(w);
847
+ assert.equal(r.code, "conformance.unimplemented"); assert.equal(w.merges.length, 0); assert.equal(builders(w).length, 3);
848
+ assert.match(nhBody(w), /R1 reported "partial"/);
849
+ });
850
+
851
+ it("shadow still only records a validate step with no recorded result (CI-only steps) and does not block on it", async () => {
852
+ const w = world({ validation: [] });
853
+ assert.equal((await runShadow(w)).terminalReason, "merged");
854
+ assert.match(shadowNote(w), /validate V1 .*has no recorded result/);
855
+ });
856
+
857
+ it("enforce blocks a failed validate exactly as before", async () => {
858
+ const w = world({ validation: [{ id: "V1", command: "node --test test/foo.test.js", exit: 1 }] });
859
+ const r = await run(w, { conformance: "enforce" });
860
+ assert.equal(r.code, "conformance.unimplemented"); assert.equal(w.merges.length, 0);
861
+ });
862
+
678
863
  it("shadow files nothing: accepted deferrals are listed as not filed, and the PR links nothing", async () => {
679
864
  const k = { ...CONTRACT, preserve: [], requirements: [CONTRACT.requirements[0], { id: "R2", quote: "Existing callers of foo keep working.", disposition: "defer", why: "w" }] };
680
865
  const w = world({ contract: k, claims: [{ id: "R1", status: "implemented" }], reviews: [conf({}, k)] });
@@ -711,6 +896,107 @@ describe("conformance gate: base-sync and contract size", () => {
711
896
  });
712
897
  });
713
898
 
899
+ describe("structure delta (forge#4159): undeclared package edges block, import growth only reports", () => {
900
+ const EDGE = { from: "@s/db", to: "@s/core", kind: "dependencies", manifest: "packages/db/package.json" };
901
+ const delta = (o = {}) => ({ package_edges_added: [], package_edges_removed: [], import_pairs: [], boundary_config_changed: { changed: false, files: [], loosens: false, tightens: false }, ...o });
902
+ /** world whose io.structureDelta returns the given deltas in order (the last one repeats). */
903
+ const sworld = (deltas, o = {}) => {
904
+ const w = world(o); const q = [...deltas]; w.structureCalls = [];
905
+ w.io.structureDelta = async (x) => { w.structureCalls.push(x); return q.length > 1 ? q.shift() : q[0]; };
906
+ return w;
907
+ };
908
+ const nh = (w) => w.comments.find((c) => c.includes("FORGE:NEEDS_HUMAN")) ?? "";
909
+ const withEdgeDeliverable = (change) => ({ ...CONTRACT, deliverables: [...CONTRACT.deliverables, { id: "D2", path: "packages/db/package.json", change }] });
910
+
911
+ it("a new package edge the contract does not name gets one builder resume; removed on the resume, it merges", async () => {
912
+ const dir = tmpDir("struct-");
913
+ const w = sworld([delta({ package_edges_added: [EDGE] }), delta()]);
914
+ const r = await run(w, { dir });
915
+ assert.equal(r.terminalReason, "merged");
916
+ assert.equal(builders(w).length, 3); assert.equal(reviewers(w).length, 1);
917
+ assert.match(builders(w)[2][4], /@s\/db -> @s\/core \(dependencies, packages\/db\/package\.json\)[\s\S]*add it to the contract with a reason, or remove it/);
918
+ assert.equal(builders(w)[2][2], "sess-1", "resumed in the builder's own session");
919
+ assert.deepEqual(w.structureCalls[0], { cwd: "/wt/lean-7", base: "origin/staging" });
920
+ const e = readLog(dir, 7).find((x) => x.event === "STRUCTURE_DELTA");
921
+ assert.equal(e.edges_added, 1);
922
+ assert.ok(readLog(dir, 7).findIndex((x) => x.event === "STRUCTURE_DELTA") < readLog(dir, 7).findIndex((x) => x.event === "CONFORMANCE"), "before the conformance review");
923
+ });
924
+
925
+ it("an undeclared edge that survives the resume is needs-human structure.undeclared_edge, in enforce and in shadow, with no reviewer run", async () => {
926
+ for (const conformance of ["enforce", "shadow"]) {
927
+ const w = sworld([delta({ package_edges_added: [EDGE] })]);
928
+ const r = await run(w, { conformance });
929
+ assert.equal(r.terminalReason, "needs-human"); assert.equal(r.code, OUTCOME_CODES.STRUCTURE_UNDECLARED_EDGE);
930
+ assert.equal(builders(w).length, 3, "exactly one builder resume"); assert.equal(reviewers(w).length, 0);
931
+ assert.equal(w.prs.length, 0); assert.equal(w.merges.length, 0);
932
+ assert.match(nh(w), /@s\/db → @s\/core \(dependencies, `packages\/db\/package\.json`\)/);
933
+ }
934
+ });
935
+
936
+ it("an edge the contract names (a deliverable on the manifest whose change names the package) passes with no resume", async () => {
937
+ const w = sworld([delta({ package_edges_added: [EDGE] })], { contract: withEdgeDeliverable("add @s/core: db needs the tenant constants (docs/architecture.md: db depends on core)") });
938
+ const r = await run(w);
939
+ assert.equal(r.terminalReason, "merged"); assert.equal(builders(w).length, 2);
940
+ // the manifest alone, without the package named, is not naming the edge
941
+ assert.equal(unnamedEdges(withEdgeDeliverable("bump versions"), delta({ package_edges_added: [EDGE] })).length, 1);
942
+ assert.equal(unnamedEdges(withEdgeDeliverable("add @s/core"), delta({ package_edges_added: [EDGE] })).length, 0);
943
+ });
944
+
945
+ it("an add-deliverable amendment on a package.json the contract already lists (for another change) declares the edge", () => {
946
+ const frozen = { requirements: [], deliverables: [{ id: "D1", path: "packages/db/package.json", change: "bump versions" }] };
947
+ const r = applyAmendments(frozen, [{ kind: "add-deliverable", path: "packages/db/package.json", why: "add @s/core: the architecture doc lets db depend on core" }]);
948
+ assert.equal(r.contract.deliverables.length, 1); assert.equal(r.contract.deliverables[0].id, "D1");
949
+ assert.match(r.contract.deliverables[0].change, /^bump versions; add @s\/core/);
950
+ assert.equal(unnamedEdges(r.contract, delta({ package_edges_added: [EDGE] })).length, 0);
951
+ assert.equal(frozen.deliverables[0].change, "bump versions", "the frozen contract is not mutated");
952
+ });
953
+
954
+ it("an add-deliverable amendment naming the package on the resume declares the edge", async () => {
955
+ const w = sworld([delta({ package_edges_added: [EDGE] })], { resumeAmendments: [{ kind: "add-deliverable", path: "packages/db/package.json", why: "add @s/core: the architecture doc lets db depend on core" }],
956
+ changed: ["src/foo.js", "test/foo.test.js"] });
957
+ const r = await run(w);
958
+ assert.equal(r.terminalReason, "merged"); assert.equal(builders(w).length, 3);
959
+ });
960
+
961
+ it("import-pair growth and boundary loosening never block: one <structure> line in the one reviewer prompt, telemetry recorded", async () => {
962
+ const d = delta({ import_pairs: [{ from: "@s/web", to: "@s/db", files_added: 8, files_removed: 0 }], boundary_config_changed: { changed: true, files: ["eslint.config.mjs"], loosens: true, tightens: false } });
963
+ for (const conformance of ["enforce", "shadow"]) {
964
+ const w = sworld([d]);
965
+ const r = await run(w, { conformance });
966
+ assert.equal(r.terminalReason, "merged"); assert.equal(builders(w).length, 2); assert.equal(reviewers(w).length, 1, "no extra reviewer run");
967
+ const lines = reviewers(w)[0][4].split("\n").filter((l) => l.includes("<structure>"));
968
+ assert.equal(lines.length, 1);
969
+ assert.match(lines[0], /^<structure>Structure \(report only\): this PR adds 8 value imports @s\/web→@s\/db \(files\); the repo's boundary config was loosened in this PR \(eslint\.config\.mjs\); flag only if the contract or the architecture doc says otherwise\.<\/structure>$/);
970
+ const t = w.telemetry.find((x) => x.event === "STRUCTURE_DELTA");
971
+ assert.deepEqual([t.import_files_added, t.boundary_loosened, t.phase], [8, true, "implement"]);
972
+ assert.deepEqual(mapRecord(t, 5).payload, { edges_added: 0, edges_removed: 0, import_files_added: 8, import_files_removed: 0, boundary_changed: 1, boundary_loosened: 1 });
973
+ }
974
+ });
975
+
976
+ it("no workspace packages ({}), a failed script or no io.structureDelta: no event, no prompt line, the same reviewer prompt", async () => {
977
+ const base = world(); await run(base);
978
+ for (const d of [{}, null]) {
979
+ const dir = tmpDir("struct-none-");
980
+ const w = sworld([d]);
981
+ assert.equal((await run(w, { dir })).terminalReason, "merged");
982
+ assert.equal(reviewers(w)[0][4], reviewers(base)[0][4]);
983
+ assert.ok(!readLog(dir, 7).some((x) => x.event === "STRUCTURE_DELTA")); assert.ok(!w.telemetry.some((x) => x.event === "STRUCTURE_DELTA"));
984
+ assert.equal(builders(w).length, 2);
985
+ }
986
+ assert.equal(structureNote({}), ""); assert.equal(structureNote(null), ""); assert.equal(structureNote(delta()), "");
987
+ assert.equal(structureNote(delta({ import_pairs: [{ from: "a", to: "b", files_added: 0, files_removed: 3 }] })), "", "removals are not growth");
988
+ });
989
+
990
+ it("the reviewer prompt is byte-identical without a structure line, and the planner and builder are told what declares an edge", () => {
991
+ const args = { issue: { number: 7, title: "t", body: "b" }, contract: CONTRACT, result: {}, files: ["a"], diff: "d" };
992
+ assert.equal(reviewerPrompt(args), reviewerPrompt({ ...args, structure: "" }));
993
+ assert.ok(reviewerPrompt({ ...args, structure: "x\ny" }).endsWith("</diff>\n<structure>x y</structure>"));
994
+ assert.match(builderPlanPrompt(7), /"the lint allows it" or "there is a precedent" is not a reason on its own/);
995
+ assert.match(builderPlanPrompt(7), /must be a deliverable on that package\.json whose change names the package and gives the reason/);
996
+ assert.match(builderImplementPrompt(), /blocked by the engine: add it through add-deliverable with a why naming the package/);
997
+ });
998
+ });
999
+
714
1000
  describe("comment templates", () => {
715
1001
  const rv = { verdict: "PASS", fixes_issue: true, regression: false, reason: "src/foo.js:3 ok", evidence: "src/foo.js:3 guard", checked: "callers of foo, test/foo.test.js" };
716
1002
  const result = { status: "fixed", summary: "fixed foo", evidence: ["src/foo.js:3 null deref"], root_cause: "derefs null at src/foo.js:3", changes: [{ file: "src/foo.js", change: "add guard", why: "avoid crash" }],
@@ -1141,6 +1427,46 @@ describe("runCliBackendAsync", () => {
1141
1427
  });
1142
1428
  });
1143
1429
 
1430
+ describe("requiredChecksFor: classic protection + rulesets (#4158)", () => {
1431
+ const RULES_JQ = '.[] | select(.type == "required_status_checks") | .parameters.required_status_checks[]?.context';
1432
+ const jqRules = (rules) => rules.filter((r) => r.type === "required_status_checks").flatMap((r) => r.parameters.required_status_checks.map((c) => c.context)).join("\n") + "\n";
1433
+ const stub = ({ classic, rules }) => {
1434
+ const calls = [];
1435
+ const gh = async (a) => {
1436
+ calls.push(a);
1437
+ if (a[1] === "repos/o/r/branches/staging/protection/required_status_checks") { if (classic instanceof Error) throw classic; return JSON.stringify(classic); }
1438
+ if (a.includes("repos/o/r/rules/branches/staging")) {
1439
+ assert.ok(a.includes("--paginate")); assert.equal(a[a.indexOf("--jq") + 1], RULES_JQ);
1440
+ if (rules instanceof Error) throw rules; return jqRules(rules);
1441
+ }
1442
+ throw new Error(`unexpected gh ${a.join(" ")}`);
1443
+ };
1444
+ return { gh, calls };
1445
+ };
1446
+ it("ruleset-only required checks are found when classic protection 404s", async () => {
1447
+ const { gh } = stub({ classic: new Error("HTTP 404: Branch not protected"), rules: [{ type: "required_status_checks", parameters: { required_status_checks: [{ context: "build" }] } }, { type: "pull_request", parameters: {} }] });
1448
+ assert.deepEqual(await requiredChecksFor({ gh, repo: "o/r", lane: "staging" }), ["build"]);
1449
+ });
1450
+ it("classic and ruleset checks are unioned without duplicates", async () => {
1451
+ const { gh } = stub({ classic: { contexts: ["a"], checks: [{ context: "b" }] }, rules: [{ type: "required_status_checks", parameters: { required_status_checks: [{ context: "b" }, { context: "c" }] } }] });
1452
+ assert.deepEqual(await requiredChecksFor({ gh, repo: "o/r", lane: "staging" }), ["a", "b", "c"]);
1453
+ });
1454
+ it("both sources failing yields [] and never throws", async () => {
1455
+ const { gh } = stub({ classic: new Error("HTTP 404"), rules: new Error("HTTP 404") });
1456
+ assert.deepEqual(await requiredChecksFor({ gh, repo: "o/r", lane: "staging" }), []);
1457
+ });
1458
+ it("a ruleset-derived required check is polled before the merge", async () => {
1459
+ const w = world();
1460
+ const { gh } = stub({ classic: new Error("HTTP 404"), rules: [{ type: "required_status_checks", parameters: { required_status_checks: [{ context: "build" }] } }] });
1461
+ w.io.requiredChecks = (lane) => requiredChecksFor({ gh, repo: "o/r", lane });
1462
+ const seqs = [[{ name: "build", state: "pending" }], [{ name: "build", state: "success" }]]; let polls = 0;
1463
+ w.io.checkStates = async () => { polls++; return seqs.shift(); };
1464
+ const r = await run(w);
1465
+ assert.equal(r.terminalReason, "merged"); assert.equal(polls, 2);
1466
+ assert.match(traj(w)[0], /\| Merge \| required \(build\) \|/);
1467
+ });
1468
+ });
1469
+
1144
1470
  describe("base-sync (#4100)", () => {
1145
1471
  const CONFLICTING = { mergeable: "CONFLICTING", mergeStateStatus: "DIRTY" };
1146
1472
  const OKSTATE = { mergeable: "MERGEABLE", mergeStateStatus: "CLEAN" };
@@ -1162,7 +1488,7 @@ describe("base-sync (#4100)", () => {
1162
1488
  const g = gitCalls(w);
1163
1489
  assert.ok(g.some((x) => x[0] === "fetch" && x[1] === "origin" && x[2] === "staging"));
1164
1490
  assert.ok(g.some((x) => x[0] === "merge" && x[1] === "--no-edit" && x[2] === "origin/staging"));
1165
- assert.equal(g.filter((x) => x[0] === "push").length, 2, "initial push plus the sync push");
1491
+ assert.equal(g.filter((x) => x[0] === "push" && !x.includes("--delete")).length, 2, "initial push plus the sync push");
1166
1492
  assert.match(syncComment(w), /## Base Sync[\s\S]*clean merge[\s\S]*not required \(conflict-free base merge\)/);
1167
1493
  assert.deepEqual(w.merges[0].slice(-2), ["--match-head-commit", w.head]);
1168
1494
  assert.match(traj(w)[0], /\| Base Sync \| 1 sync\(s\) \|/);
@@ -1233,6 +1559,27 @@ describe("base-sync (#4100)", () => {
1233
1559
  assert.ok(!syncComment(w));
1234
1560
  });
1235
1561
 
1562
+ const BLOCKED = { mergeable: "MERGEABLE", mergeStateStatus: "BLOCKED" };
1563
+ const POLICY = "GraphQL: Pull request is not mergeable: the base branch policy prohibits the merge (mergePullRequest)";
1564
+ it("#4158 a policy block (MERGEABLE + BLOCKED) waits and retries the merge; no base-sync", async () => {
1565
+ const w = world({ prView: [BLOCKED], mergeErrors: [POLICY] });
1566
+ const r = await run(w);
1567
+ assert.equal(r.terminalReason, "merged");
1568
+ assert.equal(w.mergeAttempts, 2);
1569
+ assert.equal(gitCalls(w).filter((x) => x[0] === "merge" && x[1] === "--no-edit").length, 0, "no base-sync");
1570
+ assert.ok(!syncComment(w));
1571
+ assert.deepEqual(w.merges[0].slice(-2), ["--match-head-commit", w.head]);
1572
+ });
1573
+ it("#4158 a persistent policy block ends ci-pending, never base.conflict", async () => {
1574
+ const w = world({ prView: [BLOCKED], mergeErrors: Array(50).fill(POLICY) });
1575
+ const r = await run(w, { ciMaxMinutes: 5 });
1576
+ assert.equal(r.terminalReason, "ci-pending");
1577
+ assert.notEqual(r.code, "base.conflict");
1578
+ assert.equal(gitCalls(w).filter((x) => x[0] === "merge" && x[1] === "--no-edit").length, 0, "no base-sync");
1579
+ assert.equal(w.merges.length, 0); assert.ok(!syncComment(w));
1580
+ assert.ok(!w.edits.some((e) => e.includes("needs-human")));
1581
+ });
1582
+
1236
1583
  it("a resumed run with a conflicting PR re-plans, verifies, then syncs in the same session", async () => {
1237
1584
  const w = world({ prList: [{ number: 77, headRefOid: "x" }], remote: "abc\trefs/heads/feat/issue-7-lean", prView: [CONFLICTING, OKSTATE], mergeResults: ["conflict"], reviews: [ok(), ok()] });
1238
1585
  const r = await run(w);
@@ -1364,6 +1711,26 @@ describe("usage limit, lease takeover, worktree setup, run tmp (#4101-#4104)", (
1364
1711
  }
1365
1712
  });
1366
1713
 
1714
+ it("#4181 the builder and reviewer get FORGE_DOCKER_LABELS with the ledger's batch and lane, with or without a run tmp", async () => {
1715
+ for (const withTmp of [false, true]) {
1716
+ const w = world();
1717
+ if (withTmp) w.io.runTmp = { prune: async () => {}, make: async (i, id) => `/home/x/.forge/tmp/runs/${id}`, remove: async () => {} };
1718
+ assert.equal((await run(w, { batchId: "b1" })).terminalReason, "merged");
1719
+ assert.ok(w.envs.length >= 1);
1720
+ for (const e of w.envs) {
1721
+ const labels = String(e?.FORGE_DOCKER_LABELS ?? "").split(" ");
1722
+ assert.deepEqual(labels.slice(0, 4), ["forgedock.owner=1", "forgedock.batch=b1", "forgedock.lane=lean-7", "forgedock.issue=7"]);
1723
+ assert.match(labels[4], /^forgedock\.expires=\d+$/);
1724
+ if (withTmp) assert.match(e.TMPDIR, /\/runs\/7-/); else assert.equal(e.TMPDIR, undefined);
1725
+ }
1726
+ }
1727
+ });
1728
+
1729
+ it("#4181 the implement prompt tells the builder to use --rm, the labels and a trap teardown", () => {
1730
+ const p = builderImplementPrompt();
1731
+ for (const needle of ["FORGE_DOCKER_LABELS", "--rm", "--label", "trap", "volume create", "network create", "build"]) assert.ok(p.includes(needle), needle);
1732
+ });
1733
+
1367
1734
  it("#4104 makeRunTmp: removes only its own dirs; prune takes only old dirs with a dead owner and our marker", async () => {
1368
1735
  const { mkdirSync, writeFileSync, existsSync, utimesSync } = await import("node:fs");
1369
1736
  const root = tmpDir("core-runs-");
@@ -1587,7 +1954,7 @@ describe("batch robustness (#4112-#4115)", () => {
1587
1954
  const w = world({ pushErrors: Array(10).fill("fatal: the remote end hung up unexpectedly\nerror: RPC failed; HTTP 503") });
1588
1955
  const r = await run(w);
1589
1956
  assert.equal(r.terminalReason, "deferred"); assert.equal(r.code, "push.transient");
1590
- assert.equal(w.pushes, 4);
1957
+ assert.equal(w.pushes, 8, "4 for the PR push, then 4 by the exit checkpoint that never discards unpushed work (#4179)");
1591
1958
  assert.equal(w.prs.length, 0);
1592
1959
  });
1593
1960
 
@@ -1595,8 +1962,9 @@ describe("batch robustness (#4112-#4115)", () => {
1595
1962
  for (const msg of ["remote: error: GH006: Protected branch update failed for refs/heads/x", "fatal: unable to access: The requested URL returned error: 403", "! [remote rejected] x -> x (pre-receive hook declined)"]) {
1596
1963
  const w = world({ pushErrors: [msg, msg, msg] });
1597
1964
  const r = await run(w);
1598
- assert.equal(w.pushes, 1, msg);
1965
+ assert.equal(w.pushes, 2, `${msg}: one rejected PR push, one rejected exit checkpoint (#4179), no retries`);
1599
1966
  assert.equal(r.terminalReason, "needs-human");
1967
+ assert.equal(w.calls.filter((c) => c[1] === "worktree" && c[2] === "remove").length, 1, "the unpushed worktree is kept: only the setup remove ran");
1600
1968
  }
1601
1969
  });
1602
1970
 
@@ -1919,3 +2287,243 @@ describe("large diffs never reach argv (#4129)", () => {
1919
2287
  assert.match(readFileSync(new URL("../../docs/OUTCOME-CODES.md", import.meta.url), "utf8"), /`cli\.spawn_failed`/);
1920
2288
  });
1921
2289
  });
2290
+
2291
+ describe("verify-by-CI (#4167): ci mode runs the heavy checks on CI and feeds failures to the builder", () => {
2292
+ const CI = { mode: "ci", workflow: "verify.yml", pathsInput: "changed_paths", skipPaths: ["*.md", "docs/**"], maxRounds: 2 };
2293
+ /** Adds the CI seams to a world: each dispatch creates a run for the current head; `conclusions` is consumed per run. */
2294
+ const withCi = (w, { conclusions = ["success"], dispatchError = null, log = "ERROR: poetry.lock is not up to date" } = {}) => {
2295
+ w.ci = { dispatched: [], runs: [], logsFor: [], conclusions: [...conclusions] };
2296
+ Object.assign(w.io, {
2297
+ dispatchWorkflow: async (a) => { w.seq.push("dispatch"); if (dispatchError) throw new Error(dispatchError); w.ci.dispatched.push(a); const id = 100 + w.ci.runs.length;
2298
+ const c = w.ci.conclusions.length > 1 ? w.ci.conclusions.shift() : w.ci.conclusions[0]; w.ci.runs.unshift({ databaseId: id, headSha: w.head, status: c === "pending" ? "in_progress" : "completed", conclusion: c === "pending" ? null : c, url: `https://github.com/o/r/actions/runs/${id}`, name: "verify" }); },
2299
+ workflowRuns: async () => w.ci.runs,
2300
+ failedLogs: async (id) => { w.ci.logsFor.push(id); return log; },
2301
+ });
2302
+ return w;
2303
+ };
2304
+ const ciValidation = [{ id: "V1", command: "node --test test/foo.test.js", ci: true }];
2305
+ const events = (dir) => readLog(dir, 7).filter((e) => e.event === "CI_VERIFY");
2306
+
2307
+ it("green CI: the builder is told not to run test suites, the branch is pushed and verified before review, validate left to CI passes enforce", async () => {
2308
+ const w = withCi(world({ validation: ciValidation }));
2309
+ const dir = tmpDir("core-ci-");
2310
+ const r = await run(w, { verify: CI, dir });
2311
+ assert.equal(r.terminalReason, "merged");
2312
+ const impl = builders(w)[1][4];
2313
+ assert.match(impl, /Do NOT run type-checks .*test suites .*docker\/test containers/);
2314
+ assert.doesNotMatch(impl, /run every validate command/);
2315
+ assert.deepEqual(w.ci.dispatched, [{ workflow: "verify.yml", ref: "feat/issue-7-lean", inputs: { changed_paths: "src/foo.js\ntest/foo.test.js" } }]);
2316
+ assert.ok(w.seq.indexOf("dispatch") < w.seq.indexOf("reviewer"), "CI runs before the conformance review");
2317
+ assert.ok(w.pushes >= 1);
2318
+ assert.deepEqual(events(dir).map((e) => [e.result, e.mode]), [["pass", "ci"]]);
2319
+ assert.match(traj(w)[0], /\| Verify \| CI green at /);
2320
+ });
2321
+
2322
+ it("red CI: the failed job's log goes back to the builder, the fix is re-verified, then merged", async () => {
2323
+ const w = withCi(world({ validation: ciValidation }), { conclusions: ["failure", "success"] });
2324
+ const r = await run(w, { verify: CI });
2325
+ assert.equal(r.terminalReason, "merged");
2326
+ const fix = builders(w).find((b) => /CI failed on the pushed branch/.test(b[4]));
2327
+ assert.ok(fix, "a CI-fix resume ran"); assert.equal(fix[2], "sess-1");
2328
+ assert.match(fix[4], /poetry\.lock is not up to date/); assert.match(fix[4], /actions\/runs\/100/);
2329
+ assert.deepEqual(w.ci.logsFor, [100]);
2330
+ assert.equal(w.ci.dispatched.length, 2, "the fixed head is verified again");
2331
+ assert.notEqual(w.ci.runs[0].headSha, w.ci.runs[1].headSha);
2332
+ });
2333
+
2334
+ it("CI still red after max_rounds: needs-human ci.red and nothing merges to the lane", async () => {
2335
+ const w = withCi(world({ validation: ciValidation }), { conclusions: ["failure"] });
2336
+ const r = await run(w, { verify: { ...CI, maxRounds: 1 } });
2337
+ assert.equal(r.terminalReason, "needs-human"); assert.equal(r.code, "ci.red");
2338
+ assert.equal(w.prs.length, 0); assert.equal(w.merges.length, 0); assert.equal(reviewers(w).length, 0);
2339
+ });
2340
+
2341
+ it("without the fix, a validate id left to CI is missing under enforce (the regression this guards)", async () => {
2342
+ const w = world({ validation: ciValidation });
2343
+ const r = await run(w);
2344
+ assert.equal(r.terminalReason, "needs-human"); assert.equal(w.merges.length, 0);
2345
+ });
2346
+
2347
+ it("docs-only lane: no push for CI, no dispatch; the decision is recorded", async () => {
2348
+ const w = withCi(world({ changed: ["README.md", "docs/guide.md"], validation: ciValidation }));
2349
+ const r = await run(w, { verify: CI, conformance: "shadow" });
2350
+ assert.equal(r.terminalReason, "merged"); assert.equal(w.ci.dispatched.length, 0);
2351
+ assert.match(traj(w)[0], /CI verification skipped/);
2352
+ });
2353
+
2354
+ it("CI unavailable: falls back once to local validation in the builder session", async () => {
2355
+ const w = withCi(world({ validation: ciValidation, resumeValidation: [{ id: "V1", command: "node --test test/foo.test.js", exit: 0 }] }), { dispatchError: "HTTP 422: Workflow does not have 'workflow_dispatch' trigger" });
2356
+ w.resumeResult = { ...w.result };
2357
+ const r = await run(w, { verify: CI, conformance: "shadow" });
2358
+ assert.equal(r.terminalReason, "merged");
2359
+ assert.ok(builders(w).some((b) => /CI verification is unavailable/.test(b[4])));
2360
+ assert.match(traj(w)[0], /local fallback/);
2361
+ });
2362
+
2363
+ it("slow CI ends ci-pending (never needs-human) before any PR", async () => {
2364
+ const w = withCi(world({ validation: ciValidation }), { conclusions: ["pending"] });
2365
+ const r = await run(w, { verify: CI, ciMaxMinutes: 10 });
2366
+ assert.equal(r.terminalReason, "ci-pending"); assert.equal(w.prs.length, 0);
2367
+ });
2368
+
2369
+ it("local (default): no CI seam is touched and the implement prompt is unchanged", async () => {
2370
+ const w = withCi(world());
2371
+ const r = await run(w);
2372
+ assert.equal(r.terminalReason, "merged"); assert.equal(w.ci.dispatched.length, 0);
2373
+ assert.equal(builders(w)[1][4], builderImplementPrompt());
2374
+ assert.doesNotMatch(traj(w)[0], /\| Verify \|/);
2375
+ });
2376
+
2377
+ it("forge.yaml verify: block section is read; absent section is undefined", async () => {
2378
+ const { readVerify } = await import("../engine-cli.mjs");
2379
+ const d = tmpDir("verify-yaml-");
2380
+ writeFileSync(`${d}/forge.yaml`, "engine:\n conformance: shadow\n");
2381
+ assert.equal(readVerify(d), undefined);
2382
+ writeFileSync(`${d}/forge.yaml`, "verify:\n mode: ci\n workflow: verify.yml\n skip_paths: \"*.md,docs/**\"\n max_rounds: 3\n");
2383
+ assert.deepEqual(readVerify(d), { mode: "ci", workflow: "verify.yml", pathsInput: null, skipPaths: ["*.md", "docs/**"], maxRounds: 3 });
2384
+ });
2385
+ });
2386
+
2387
+ describe("lane resource ledger + teardown on every exit path (#4179)", () => {
2388
+ /** A world with a real on-disk ledger, fake docker/kill, a run tmp with a known path and a builder that reports its pid + a container. */
2389
+ const ledgered = (o = {}) => {
2390
+ const w = world(o);
2391
+ const root = tmpDir("ledger-");
2392
+ w.ledgerRoot = root; w.docker = []; w.killed = []; w.snapshots = []; w.tmpRemoved = [];
2393
+ w.io.ledger = (opts) => makeLedger({ root, ...opts });
2394
+ w.io.docker = async (a) => { w.docker.push(a); return ""; };
2395
+ w.io.killGroup = async (pid, marker) => { w.killed.push([pid, marker]); return true; };
2396
+ w.io.runTmp = { prune: async () => {}, path: (id) => `/home/x/.forge/tmp/runs/${id}`, make: async (_i, id) => `/home/x/.forge/tmp/runs/${id}`, remove: async (d) => { w.tmpRemoved.push(d); return true; } };
2397
+ w.ledgerFile = join(root, o.batch ?? "adhoc", "lean-7.json");
2398
+ const r0 = w.io.runner;
2399
+ w.io.runner = async (c) => {
2400
+ if (c.role === "builder") {
2401
+ c.onSpawn?.({ pid: 4242, spawnfile: "/usr/bin/claude" });
2402
+ w.snapshots.push(JSON.parse(readFileSync(w.ledgerFile, "utf8"))); // what a crash right now would leave behind
2403
+ if (o.during) await o.during(w, c);
2404
+ }
2405
+ const r = await r0(c);
2406
+ if (c.role === "builder" && !/PLANNING ONLY/.test(c.prompt) && w.files[`${WT}/.forge-lean/result.json`]) {
2407
+ const j = JSON.parse(w.files[`${WT}/.forge-lean/result.json`]);
2408
+ w.files[`${WT}/.forge-lean/result.json`] = JSON.stringify({ ...j, resources: { containers: ["lean7-testpg"], volumes: ["lean7-pgdata", "bad name; rm -rf /"] } });
2409
+ }
2410
+ return r;
2411
+ };
2412
+ return w;
2413
+ };
2414
+ const gitIdx = (w, pred) => { let i = -1; w.calls.forEach((c, k) => { if (c[0] === "git" && pred(c.slice(1))) i = k; }); return i; };
2415
+ /** The exit teardown: the last two worktree calls are `remove --force <wt>` then `prune`; the ledger file is gone. */
2416
+ const tornDown = (w, label) => {
2417
+ const wtCalls = w.calls.filter((c) => c[0] === "git" && c[1] === "worktree");
2418
+ assert.deepEqual(wtCalls.at(-2), ["git", "worktree", "remove", "--force", WT], `${label}: worktree removed`);
2419
+ assert.deepEqual(wtCalls.at(-1), ["git", "worktree", "prune"], `${label}: then pruned`);
2420
+ assert.equal(existsSync(w.ledgerFile), false, `${label}: ledger cleared`);
2421
+ };
2422
+
2423
+ it("while the lane runs, the ledger on disk lists worktree, branch, run tmp and builder pid with owner/batch/lane/issue/created/expires (survives a crash)", async () => {
2424
+ const w = ledgered({ batch: "b1" });
2425
+ await run(w, { batchId: "b1" });
2426
+ const snap = w.snapshots.at(-1);
2427
+ for (const k of ["owner", "batch", "lane", "issue", "created", "expires"]) assert.ok(snap[k] != null, `ledger has ${k}`);
2428
+ assert.equal(snap.batch, "b1"); assert.equal(snap.lane, "lean-7"); assert.equal(snap.issue, 7); assert.ok(snap.expires > snap.created);
2429
+ const kinds = snap.resources.map((r) => `${r.kind}:${r.value}`);
2430
+ for (const k of [`worktree:${WT}`, "branch:feat/issue-7-lean", "pid:4242"]) assert.ok(kinds.includes(k), `${k} in ${kinds}`);
2431
+ assert.ok(kinds.some((k) => k.startsWith("tmp:/home/x/.forge/tmp/runs/7-")));
2432
+ assert.equal(snap.resources.find((r) => r.kind === "pid").marker, "claude");
2433
+ // a crash at this point leaves exactly this file: a fresh reader still sees every resource
2434
+ const crashed = tmpDir("ledger-crash-");
2435
+ const l = makeLedger({ root: crashed, batch: "b1", lane: "lean-7", owner: "x", issue: 7 });
2436
+ for (const r of snap.resources) l.register(r.kind, r.value);
2437
+ assert.deepEqual(makeLedger({ root: crashed, batch: "b1", lane: "lean-7", issue: 7 }).entries().map((r) => r.kind).sort(), ["branch", "pid", "tmp", "worktree"]);
2438
+ });
2439
+
2440
+ it("merged: remote branch deleted after the merge, worktree removed + pruned, local branch deleted, reported docker resources removed, ledger cleared", async () => {
2441
+ const w = ledgered();
2442
+ const r = await run(w);
2443
+ assert.equal(r.terminalReason, "merged");
2444
+ assert.deepEqual(w.branchDeletes, ["feat/issue-7-lean"]);
2445
+ assert.ok(w.seq.indexOf("delete-remote-branch") > w.seq.indexOf("merge"), "deleted only after the merge");
2446
+ tornDown(w, "merged");
2447
+ const rm = gitIdx(w, (a) => a[0] === "worktree" && a[1] === "remove"), bd = gitIdx(w, (a) => a[0] === "branch" && a[1] === "-D");
2448
+ assert.ok(bd > rm, "local branch deleted after its worktree is gone");
2449
+ assert.deepEqual(w.docker, [["rm", "-f", "lean7-testpg"], ["volume", "rm", "lean7-pgdata"]], "only well-formed names the builder reported");
2450
+ assert.equal(w.tmpRemoved.length, 1);
2451
+ assert.equal(gitIdx(w, (a) => a[0] === "commit"), -1, "a merged lane is never checkpointed");
2452
+ });
2453
+
2454
+ it("an open PR using the lane branch as head or base keeps it (remote and local)", async () => {
2455
+ for (const side of ["--head", "--base"]) {
2456
+ const w = ledgered();
2457
+ const g = w.io.gh; let merged = false;
2458
+ w.io.gh = async (a) => {
2459
+ if (a[0] === "pr" && a[1] === "merge") merged = true;
2460
+ if (merged && a[0] === "pr" && a[1] === "list" && a[a.indexOf(side) + 1] === "feat/issue-7-lean") return JSON.stringify([{ number: 99 }]);
2461
+ return g(a);
2462
+ };
2463
+ assert.equal((await run(w)).terminalReason, "merged");
2464
+ assert.deepEqual(w.branchDeletes, [], side);
2465
+ assert.equal(gitIdx(w, (a) => a[0] === "branch" && a[1] === "-D"), -1, side);
2466
+ }
2467
+ });
2468
+
2469
+ it("main, staging, milestone/* and anything but the issue's own lane branch are never deletion targets", () => {
2470
+ const ctx = { issue: 7, lane: "staging", branches: { default: "main", staging: "staging" }, defaults: ["main"] };
2471
+ for (const b of ["main", "master", "staging", "milestone/x", "milestone/adaptive", "feat/issue-8-lean", "", null]) assert.equal(isProtectedLaneBranch(b, ctx), true, String(b));
2472
+ assert.equal(isProtectedLaneBranch("feat/issue-7-lean", ctx), false);
2473
+ assert.equal(isProtectedLaneBranch("feat/issue-7-lean", { ...ctx, lane: "feat/issue-7-lean" }), true, "never the lane itself");
2474
+ assert.equal(isProtectedLaneBranch("feat/issue-7-lean", { ...ctx, defaults: ["feat/issue-7-lean"] }), true, "never a default branch");
2475
+ });
2476
+
2477
+ const exits = {
2478
+ invalid: { o: { changed: [], result: { status: "invalid", summary: "not a bug", evidence: ["src/foo.js:3 already guards null"], tests: [] } }, reason: "invalid" },
2479
+ "needs-human": { o: { waits: [{ rc: 1, text: "lint failed" }] }, extra: { mergeWait: "all" }, reason: "needs-human" },
2480
+ "engine-error": { o: { during: (w, c) => { if (!/PLANNING ONLY/.test(c.prompt)) throw new Error("boom"); } }, reason: "engine-error" },
2481
+ deferred: { o: { during: (w, c) => { if (!/PLANNING ONLY/.test(c.prompt)) throw Object.assign(new Error("session limit"), { usageLimit: true, resetAtEpochMs: 1e13 }); } }, reason: "deferred" },
2482
+ };
2483
+ for (const [name, x] of Object.entries(exits)) {
2484
+ it(`${name}: teardown runs (worktree remove --force + prune, pid killed, docker removed, ledger cleared); no branch is deleted on origin`, async () => {
2485
+ const w = ledgered(x.o);
2486
+ const r = await run(w, x.extra ?? {});
2487
+ assert.equal(r.terminalReason, x.reason);
2488
+ tornDown(w, name);
2489
+ assert.deepEqual(w.branchDeletes, []);
2490
+ assert.equal(w.tmpRemoved.length, 1);
2491
+ });
2492
+ }
2493
+
2494
+ it("an unmerged lane with uncommitted work is committed and pushed to the lane branch BEFORE its worktree is removed", async () => {
2495
+ const w = ledgered({ dirty: true, waits: [{ rc: 1, text: "lint failed" }] });
2496
+ const r = await run(w, { mergeWait: "all" });
2497
+ assert.equal(r.terminalReason, "needs-human");
2498
+ const commit = gitIdx(w, (a) => a[0] === "commit" && a.some((x) => /checkpoint after exit \(needs-human\)/.test(x)));
2499
+ const push = gitIdx(w, (a) => a[0] === "push" && a.includes("feat/issue-7-lean") && !a.includes("--delete"));
2500
+ const rm = gitIdx(w, (a) => a[0] === "worktree" && a[1] === "remove");
2501
+ assert.ok(commit > 0 && push > commit && rm > push, `commit ${commit} < push ${push} < remove ${rm}`);
2502
+ tornDown(w, "dirty needs-human");
2503
+ });
2504
+
2505
+ it("a checkpoint that cannot push keeps the worktree and leaves worktree + branch listed in the ledger", async () => {
2506
+ const msg = "remote: error: GH006: Protected branch update failed for refs/heads/x";
2507
+ const w = ledgered({ dirty: true, pushErrors: [msg, msg, msg] });
2508
+ const r = await run(w);
2509
+ assert.equal(r.terminalReason, "needs-human");
2510
+ assert.equal(w.calls.filter((c) => c[1] === "worktree" && c[2] === "remove").length, 1, "only the setup remove ran");
2511
+ const left = JSON.parse(readFileSync(w.ledgerFile, "utf8")).resources.map((x) => x.kind).sort();
2512
+ assert.deepEqual(left, ["branch", "worktree"]);
2513
+ });
2514
+
2515
+ it("SIGTERM/SIGINT: the registered teardown checkpoints, removes the worktree, clears the ledger, releases the lease, ends deferred run.interrupted", async () => {
2516
+ for (const sig of ["SIGTERM", "SIGINT"]) {
2517
+ let td = null, out = null;
2518
+ const w = ledgered({ dirty: true, during: async (w2, c) => { if (!/PLANNING ONLY/.test(c.prompt) && !out) out = await td(sig); } });
2519
+ w.leaseWrites = []; w.io.projector = { readState: async () => null, writeState: async (_i, st) => { w.leaseWrites.push(st); } };
2520
+ const r = await run(w, { onTeardown: (fn) => { td = fn; } });
2521
+ assert.equal(out.terminalReason, "deferred", sig); assert.equal(out.code, "run.interrupted");
2522
+ assert.equal(r, out, "the run's own later finish returns the same result (idempotent)");
2523
+ assert.equal(w.leaseWrites.at(-1).lease, null, "lease released");
2524
+ assert.ok(gitIdx(w, (a) => a[0] === "commit") > 0, "uncommitted work checkpointed");
2525
+ assert.deepEqual(w.killed, [[4242, "claude"]], "the builder's process group is signalled");
2526
+ tornDown(w, sig);
2527
+ }
2528
+ });
2529
+ });