forgedock 1.21.0 → 1.23.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/bin/engine/phases.mjs +15 -3
- package/bin/engine-cli.mjs +57 -16
- package/bin/forgedock.mjs +15 -3
- package/bin/lean/effects.mjs +18 -13
- package/bin/lean/lean.mjs +145 -213
- package/bin/lean/lean.test.mjs +360 -400
- package/bin/lean/orchestrate.mjs +69 -0
- package/bin/lean/prompts.mjs +37 -44
- package/bin/lean/render.mjs +66 -0
- package/commands/orchestrate/legacy.md +88 -0
- package/commands/orchestrate.md +74 -78
- package/docs/CONFIG.md +2 -1
- package/docs/RELEASE-NOTES.md +12 -3
- package/docs/site/command-reference.md +5 -8
- package/package.json +1 -1
- package/scripts/ci-local.test.sh +2 -2
- package/scripts/review-roster.sh +14 -1
- package/scripts/review-roster.test.sh +13 -0
- package/bin/lean/risk.mjs +0 -49
package/bin/lean/lean.test.mjs
CHANGED
|
@@ -2,46 +2,39 @@
|
|
|
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 { readFileSync, readdirSync } from "node:fs";
|
|
6
5
|
import { tmpDir } from "../tests/_tmp.mjs";
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
6
|
+
import { runCore } from "./lean.mjs";
|
|
7
|
+
import { renderValidate, renderPlan, renderReview, renderRegister, renderPrBody, cell, NR } from "./render.mjs";
|
|
8
|
+
import { orchestrate, parseDeps, formatTable } from "./orchestrate.mjs";
|
|
9
|
+
import { REVIEWER_SYSTEM, approxTokens, builderPrompt, reviewerPrompt, parseVerdict } from "./prompts.mjs";
|
|
11
10
|
import { mapRecord } from "../engine/telemetry-seam.mjs";
|
|
12
11
|
import { readLog } from "../engine/runlog.mjs";
|
|
13
|
-
import { runFromCli } from "../engine-cli.mjs";
|
|
14
|
-
import { runCliBackend, buildLeanSessionFlags, buildReadOnlyToolFlags } from "../runner.mjs";
|
|
12
|
+
import { runFromCli, orchestrateFromCli, ENGINE_PATHS } from "../engine-cli.mjs";
|
|
15
13
|
|
|
16
14
|
const WT = "/wt/lean-7";
|
|
17
|
-
const
|
|
18
|
-
const
|
|
15
|
+
const ok = (extra = {}) => "```json\n" + JSON.stringify({ verdict: "PASS", fixes_issue: true, regression: false, reason: "src/foo.js:3 fixed", ...extra }) + "\n```";
|
|
16
|
+
const bad = (reason = "src/foo.js:9 still wrong") => ok({ verdict: "FAIL", fixes_issue: false, reason });
|
|
19
17
|
|
|
20
|
-
/** In-memory world
|
|
18
|
+
/** In-memory world with fake gh, git, fs and runner. */
|
|
21
19
|
function world(o = {}) {
|
|
22
20
|
const w = {
|
|
23
|
-
|
|
24
|
-
changed: o.changed ?? ["src/foo.js", "test/foo.test.js"],
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
const writeBuilderFiles = () => {
|
|
28
|
-
w.files[`${WT}/.forge-lean/validate.json`] = JSON.stringify(w.validate);
|
|
29
|
-
w.files[`${WT}/.forge-lean/plan.md`] = "## Approach\nfix foo\n## Sibling sites\nbar";
|
|
30
|
-
w.files[`${WT}/.forge-lean/implement.json`] = JSON.stringify({ summary: "fixed", commit: w.head, files: w.changed });
|
|
21
|
+
comments: [], labels: [], closed: [], edits: [], prs: [], merges: [], calls: [], telemetry: [], files: {}, head: "aaaa1111aaaa1111",
|
|
22
|
+
changed: o.changed ?? ["src/foo.js", "test/foo.test.js"], reviews: [...(o.reviews ?? [])], waits: [...(o.waits ?? [])], clock: 0,
|
|
23
|
+
result: o.result ?? { status: "fixed", summary: "fixed foo", evidence: [], tests: ["test/foo.test.js"] },
|
|
24
|
+
prList: o.prList ?? [], remote: o.remote ?? "", resumeResult: o.resumeResult, seq: [],
|
|
31
25
|
};
|
|
32
26
|
w.io = {
|
|
33
27
|
now: () => w.clock, sleep: async (ms) => { w.clock += ms; },
|
|
34
28
|
gh: async (a) => {
|
|
35
29
|
const k = a.slice(0, 2).join(" ");
|
|
36
|
-
if (a[0] === "api")
|
|
30
|
+
if (a[0] === "api") return "";
|
|
37
31
|
if (k === "pr list") return JSON.stringify(w.prList);
|
|
38
|
-
if (k === "issue view"
|
|
39
|
-
if (k === "issue
|
|
40
|
-
if (k === "issue
|
|
41
|
-
if (k === "issue close") { w.closed.push(a); return ""; }
|
|
32
|
+
if (k === "issue view") return JSON.stringify({ number: 7, title: "foo breaks", body: "Bug in foo", state: "OPEN" });
|
|
33
|
+
if (k === "issue comment") { w.comments.push(a[a.indexOf("--body") + 1]); w.seq.push("comment"); return `https://github.com/o/r/issues/7#issuecomment-${w.comments.length}\n`; }
|
|
34
|
+
if (k === "issue close") { w.closed.push(a); w.seq.push("close"); return ""; }
|
|
42
35
|
if (k === "issue edit") { w.edits.push(a); return ""; }
|
|
43
|
-
if (k === "pr create") { w.prs.push(a); return "https://github.com/o/r/pull/77\n"; }
|
|
44
|
-
if (k === "pr merge") { w.merges.push(a); return ""; }
|
|
36
|
+
if (k === "pr create") { w.prs.push(a); w.seq.push("pr"); return "https://github.com/o/r/pull/77\n"; }
|
|
37
|
+
if (k === "pr merge") { w.merges.push(a); w.seq.push("merge"); return ""; }
|
|
45
38
|
throw new Error(`unexpected gh ${a.join(" ")}`);
|
|
46
39
|
},
|
|
47
40
|
git: async (a) => {
|
|
@@ -49,477 +42,444 @@ function world(o = {}) {
|
|
|
49
42
|
if (a[0] === "rev-parse" && a[1] === "--git-path") return "/repo/.git/info/exclude";
|
|
50
43
|
if (a[0] === "rev-parse") return w.head;
|
|
51
44
|
if (a[0] === "diff" && a[1] === "--name-only") return w.changed.join("\n");
|
|
52
|
-
if (a[0] === "diff" && a[1] === "--numstat") return `${w.lines}\t0\tx`;
|
|
53
|
-
if (a[0] === "ls-remote") return w.remote;
|
|
54
45
|
if (a[0] === "diff") return "diff --git a/src/foo.js b/src/foo.js\n+fix";
|
|
46
|
+
if (a[0] === "ls-remote") return w.remote;
|
|
47
|
+
if (a[0] === "push") w.seq.push("push");
|
|
55
48
|
return "";
|
|
56
49
|
},
|
|
57
50
|
runner: async (c) => {
|
|
58
|
-
w.calls.push(["runner", c.role, c.resume ?? null, c.model ?? null, c.prompt, c.readOnly ?? false]);
|
|
59
|
-
|
|
51
|
+
w.calls.push(["runner", c.role, c.resume ?? null, c.model ?? null, c.prompt, c.readOnly ?? false, c.system]);
|
|
52
|
+
w.seq.push(c.role + (c.resume ? "-resume" : ""));
|
|
60
53
|
if (c.role === "builder") {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
54
|
+
const r = c.resume ? (w.resumeResult ?? w.result) : w.result;
|
|
55
|
+
if (r) w.files[`${WT}/.forge-lean/result.json`] = JSON.stringify(r);
|
|
56
|
+
if (c.resume) w.head = `bbbb${w.calls.length}`.padEnd(16, "0");
|
|
57
|
+
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" };
|
|
64
58
|
}
|
|
65
|
-
|
|
66
|
-
return { text, sessionId: `rev-${w.calls.length}`, usage: { input_tokens: 4, output_tokens: 2 }, costUsd: 0.05, durationMs: 500, turns: 2, model: c.model };
|
|
59
|
+
return { text: w.reviews.length ? w.reviews.shift() : ok(), sessionId: "rev", usage: { input_tokens: 4, output_tokens: 2 }, costUsd: 0.05, durationMs: 500, turns: 2, model: c.model };
|
|
67
60
|
},
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
projector: { readState: async () => (w.lease ? { v: 1, lease: w.lease } : null), writeState: async (_i, st) => { w.leaseWrites.push(st); } },
|
|
71
|
-
transitionLabel: async (n, s) => { w.labels.push(s); },
|
|
61
|
+
waitCiGreen: async () => (w.waits.length ? w.waits.shift() : { rc: 0, text: `CI_GATE_HEAD: ${w.head}` }),
|
|
62
|
+
transitionLabel: async (n, s) => { w.labels.push(s); w.seq.push(`label:${s}`); },
|
|
72
63
|
fs: { 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; } },
|
|
73
64
|
};
|
|
74
65
|
return w;
|
|
75
66
|
}
|
|
76
|
-
|
|
77
|
-
const run = (w, extra = {}) => runLean({ issue: 7, repo: "o/r", lane: "staging", cwd: "/repo", worktreeRoot: "/wt", dir: tmpDir("lean-"),
|
|
67
|
+
const run = (w, extra = {}) => runCore({ issue: 7, repo: "o/r", lane: "staging", cwd: "/repo", worktreeRoot: "/wt", dir: tmpDir("core-"),
|
|
78
68
|
io: w.io, emitTelemetry: async (r) => w.telemetry.push(r), random: () => 0.5, ...extra });
|
|
79
|
-
const
|
|
80
|
-
const
|
|
69
|
+
const builders = (w) => w.calls.filter((c) => c[0] === "runner" && c[1] === "builder");
|
|
70
|
+
const reviewers = (w) => w.calls.filter((c) => c[0] === "runner" && c[1] === "reviewer");
|
|
71
|
+
const registers = (w) => w.comments.filter((c) => c.startsWith("<!-- FORGE:WORK-ON -->"));
|
|
81
72
|
|
|
82
|
-
describe("
|
|
83
|
-
it("
|
|
73
|
+
describe("core work-on", () => {
|
|
74
|
+
it("happy path: started comment, one register comment with everything, merge at the reviewed head", async () => {
|
|
84
75
|
const w = world();
|
|
85
|
-
const
|
|
76
|
+
const dir = tmpDir("core-");
|
|
77
|
+
const r = await run(w, { dir });
|
|
86
78
|
assert.equal(r.terminalReason, "merged");
|
|
87
|
-
assert.
|
|
88
|
-
assert.match(w.comments[0], /
|
|
89
|
-
|
|
90
|
-
assert.
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
79
|
+
assert.equal(r.pr, 77);
|
|
80
|
+
assert.match(w.comments[0], /FORGE:STARTED/);
|
|
81
|
+
const reg = registers(w);
|
|
82
|
+
assert.equal(reg.length, 1);
|
|
83
|
+
for (const needle of ["fixed foo", "src/foo.js", "test/foo.test.js", "PASS", "src/foo.js:3 fixed", "https://github.com/o/r/pull/77", "cost $0.15", "tokens 14 in / 7 out / 100 cache", "wall"]) {
|
|
84
|
+
assert.ok(reg[0].includes(needle), `register comment lacks ${needle}:\n${reg[0]}`);
|
|
85
|
+
}
|
|
86
|
+
assert.match(w.prs[0][w.prs[0].indexOf("--body") + 1], /Closes #7/);
|
|
94
87
|
assert.deepEqual(w.merges[0].slice(-2), ["--match-head-commit", w.head]);
|
|
88
|
+
assert.equal(w.closed.length, 1);
|
|
89
|
+
assert.equal(builders(w).length, 1);
|
|
90
|
+
assert.equal(reviewers(w).length, 1);
|
|
91
|
+
const ev = readLog(dir, 7).map((e) => e.event);
|
|
92
|
+
assert.equal(ev[0], "RUN_START"); assert.equal(ev.at(-1), "RUN_TERMINAL");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("validation comment is posted before the reviewer and the in-review label; content for a fixed issue; register links it", async () => {
|
|
96
|
+
const w = world({ result: { status: "fixed", summary: "fixed foo\nmore", evidence: ["src/foo.js:3 null deref"], tests: ["t"] } });
|
|
97
|
+
await run(w);
|
|
98
|
+
const vi = w.comments.findIndex((c) => c.startsWith("<!-- FORGE:VALIDATE -->"));
|
|
99
|
+
assert.ok(vi >= 0);
|
|
100
|
+
const v = w.comments[vi];
|
|
101
|
+
assert.match(v, /Verdict:\*\* valid/);
|
|
102
|
+
assert.match(v, /src\/foo.js:3 null deref/);
|
|
103
|
+
assert.match(v, /Summary:\*\* fixed foo$/);
|
|
104
|
+
assert.ok(!v.includes("more"));
|
|
105
|
+
const pos = (x) => w.seq.indexOf(x);
|
|
106
|
+
const commentSeq = w.seq.map((s, i) => (s === "comment" ? i : -1)).filter((i) => i >= 0);
|
|
107
|
+
assert.ok(commentSeq[vi] > pos("builder") && commentSeq[vi] < pos("reviewer"));
|
|
108
|
+
assert.ok(commentSeq[vi] < pos("label:in-review"));
|
|
109
|
+
assert.match(registers(w)[0], /Validation: https:\/\/github.com\/o\/r\/issues\/7#issuecomment-\d+/);
|
|
110
|
+
assert.equal(w.comments.filter((c) => c.includes("FORGE:VALIDATE")).length, 1);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("labels transition in order, and register is posted after the PR and before the merge", async () => {
|
|
114
|
+
const w = world();
|
|
115
|
+
await run(w);
|
|
95
116
|
assert.deepEqual(w.labels, ["investigating", "building", "in-review", "merged"]);
|
|
96
|
-
|
|
97
|
-
assert.
|
|
117
|
+
const order = w.seq.filter((s) => ["label:investigating", "label:building", "builder", "label:in-review", "reviewer", "push", "pr", "merge", "label:merged"].includes(s));
|
|
118
|
+
assert.deepEqual(order, ["label:investigating", "label:building", "builder", "label:in-review", "reviewer", "push", "pr", "merge", "label:merged"]);
|
|
119
|
+
assert.ok(w.seq.indexOf("pr") < w.seq.lastIndexOf("comment") && w.seq.lastIndexOf("comment") < w.seq.indexOf("merge"));
|
|
98
120
|
});
|
|
99
121
|
|
|
100
|
-
it("emits
|
|
122
|
+
it("emits phase_attempt for builder and reviewer plus run_terminal, stats-compatible", async () => {
|
|
101
123
|
const w = world();
|
|
102
|
-
|
|
124
|
+
await run(w);
|
|
103
125
|
const mapped = w.telemetry.map((t) => mapRecord(t, 5)).filter(Boolean);
|
|
104
126
|
assert.deepEqual(mapped.filter((m) => m.event === "phase_attempt").map((m) => m.phase), ["implement", "review"]);
|
|
105
|
-
assert.equal(mapped[0].payload.
|
|
106
|
-
assert.equal(mapped.filter((m) => m.event === "phase_attempt" && m.payload.input_tokens === undefined).length, 0, "no null-usage records");
|
|
127
|
+
assert.equal(mapped[0].payload.input_tokens, 10);
|
|
107
128
|
assert.equal(mapped.at(-1).event, "run_terminal");
|
|
108
129
|
assert.equal(mapped.at(-1).payload.reason, "merged");
|
|
109
|
-
const v = mapped[0].payload;
|
|
110
|
-
assert.equal(mapped[0].payload.attempt, 1);
|
|
111
|
-
assert.equal(v.input_tokens, 10); assert.equal(v.cost_usd, 0.1);
|
|
112
|
-
assert.ok(r.cost_usd > 0);
|
|
113
130
|
});
|
|
114
131
|
|
|
115
|
-
it("
|
|
116
|
-
const w = world(
|
|
117
|
-
await run(w
|
|
118
|
-
|
|
119
|
-
assert.
|
|
120
|
-
|
|
132
|
+
it("invalid with evidence closes the issue as invalid and opens no PR", async () => {
|
|
133
|
+
const w = world({ changed: [], result: { status: "invalid", summary: "not a bug", evidence: ["src/foo.js:3 already guards null"], tests: [] } });
|
|
134
|
+
const r = await run(w);
|
|
135
|
+
assert.equal(r.terminalReason, "invalid");
|
|
136
|
+
assert.deepEqual(w.labels, ["investigating", "building", "invalid"]);
|
|
137
|
+
const vs = w.comments.filter((c) => c.startsWith("<!-- FORGE:VALIDATE -->"));
|
|
138
|
+
assert.equal(vs.length, 1, "the validation comment is the evidence comment");
|
|
139
|
+
assert.match(vs[0], /Verdict:\*\* invalid/);
|
|
140
|
+
assert.match(vs[0], /src\/foo.js:3 already guards null/);
|
|
141
|
+
assert.match(vs[0], /Summary:\*\* not a bug/);
|
|
142
|
+
assert.equal(registers(w).length, 0, "no duplicate evidence comment");
|
|
143
|
+
assert.equal(w.closed.length, 1);
|
|
144
|
+
assert.equal(w.prs.length, 0);
|
|
145
|
+
assert.equal(reviewers(w).length, 0);
|
|
121
146
|
});
|
|
122
147
|
|
|
123
|
-
it("
|
|
124
|
-
const w = world(
|
|
125
|
-
assert.equal(
|
|
126
|
-
assert.equal(w.calls.find((c) => c[1] === "reviewer")[3], "claude-sonnet-5-5");
|
|
127
|
-
const w2 = world(); await run(w2, { reviewerModel: "my-model" });
|
|
128
|
-
assert.equal(w2.calls.find((c) => c[1] === "reviewer")[3], "my-model");
|
|
148
|
+
it("already-fixed with evidence also closes", async () => {
|
|
149
|
+
const w = world({ changed: [], result: { status: "already-fixed", summary: "done upstream", evidence: ["a.js:1"], tests: [] } });
|
|
150
|
+
assert.equal((await run(w)).terminalReason, "invalid");
|
|
129
151
|
});
|
|
130
|
-
});
|
|
131
152
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
const w = world({ waits: [{ rc: 0, text: "CI_GATE_HEAD: deadbeefdead" }] });
|
|
153
|
+
it("invalid without evidence goes to needs-human and does not close", async () => {
|
|
154
|
+
const w = world({ changed: [], result: { status: "invalid", summary: "trust me", evidence: [], tests: [] } });
|
|
135
155
|
const r = await run(w);
|
|
136
156
|
assert.equal(r.terminalReason, "needs-human");
|
|
137
|
-
assert.equal(w.
|
|
138
|
-
|
|
139
|
-
assert.
|
|
140
|
-
assert.equal(w2.merges[0].at(-1), w2.head);
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
it("a gate needs-human records exactly one RUN_TERMINAL and one telemetry terminal", async () => {
|
|
144
|
-
const f = { result: "FAIL", tail: "x" };
|
|
145
|
-
const w = world({ ci: [f, f, f] }); const dir = tmpDir("lean-");
|
|
146
|
-
const r = await run(w, { dir });
|
|
147
|
-
assert.equal(r.terminalReason, "needs-human");
|
|
148
|
-
assert.equal(readLog(dir, 7).filter((e) => e.event === "RUN_TERMINAL").length, 1);
|
|
149
|
-
assert.equal(w.telemetry.filter((t) => t.event === "RUN_TERMINAL").length, 1);
|
|
150
|
-
});
|
|
151
|
-
|
|
152
|
-
it("a held lease defers the run before any work; an owned lease is written and released", async () => {
|
|
153
|
-
const held = world({ lease: { by: "other_1", until: 10 ** 13 } });
|
|
154
|
-
const r = await run(held, { agentId: "me" });
|
|
155
|
-
assert.equal(r.terminalReason, "deferred");
|
|
156
|
-
assert.equal(held.calls.length, 0);
|
|
157
|
-
const w = world();
|
|
158
|
-
await run(w, { agentId: "me", leaseRenewMs: 10 ** 9 });
|
|
159
|
-
assert.equal(w.leaseWrites[0].lease.by, "me");
|
|
160
|
-
assert.equal(w.leaseWrites.at(-1).lease, null);
|
|
161
|
-
assert.equal(w.leaseWrites.at(-1).terminal, true);
|
|
162
|
-
});
|
|
163
|
-
|
|
164
|
-
it("reruns reset the lean worktree and branch instead of failing, and errors clear the in-progress label", async () => {
|
|
165
|
-
const w = world();
|
|
166
|
-
await run(w);
|
|
167
|
-
const add = w.calls.find((c) => c[1] === "worktree" && c[2] === "add");
|
|
168
|
-
assert.ok(add.includes("-B"));
|
|
169
|
-
assert.ok(w.calls.some((c) => c[1] === "worktree" && c[2] === "remove"));
|
|
170
|
-
const e = world({ builderThrows: true });
|
|
171
|
-
const r = await run(e);
|
|
172
|
-
assert.equal(r.terminalReason, "engine-error");
|
|
173
|
-
assert.ok(e.edits.some((a) => a.includes("--remove-label") && a.join(" ").includes("workflow:investigating")));
|
|
174
|
-
});
|
|
175
|
-
|
|
176
|
-
it("closes only on invalid/already-fixed WITH evidence; otherwise needs-human with the reasoning", async () => {
|
|
177
|
-
for (const validate of [{ status: "invalid", summary: "no repro", evidence: [] }, { status: "invalid", summary: "no repro", evidence: [" "] },
|
|
178
|
-
{ status: "maybe", summary: "unsure why", evidence: ["a.js:1"] }]) {
|
|
179
|
-
const w = world({ validate });
|
|
180
|
-
const r = await run(w);
|
|
181
|
-
assert.equal(r.terminalReason, "needs-human");
|
|
182
|
-
assert.equal(w.closed.length, 0);
|
|
183
|
-
assert.ok(w.comments.some((c) => c.includes("FORGE:NEEDS_HUMAN") && c.includes(validate.summary)));
|
|
184
|
-
}
|
|
185
|
-
const ok = world({ validate: { status: "already-fixed", summary: "fixed in abc", evidence: ["commit abc1234"] } });
|
|
186
|
-
assert.equal((await run(ok)).terminalReason, "invalid");
|
|
187
|
-
assert.equal(ok.closed.length, 1);
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
it("reviewers are read-only (Read/Grep/Glob) and the builder is not", async () => {
|
|
191
|
-
const w = world({ changed: ["src/auth/x.js", "test/x.test.js"] });
|
|
192
|
-
await run(w);
|
|
193
|
-
const rev = w.calls.filter((c) => c[0] === "runner" && c[1] !== "builder");
|
|
194
|
-
assert.ok(rev.length >= 2 && rev.every((c) => c[5] === true));
|
|
195
|
-
assert.ok(w.calls.filter((c) => c[1] === "builder").every((c) => c[5] === false));
|
|
196
|
-
assert.deepEqual(buildReadOnlyToolFlags({ strictMcp: true }), ["--tools", "Read,Grep,Glob"]);
|
|
197
|
-
assert.ok(buildReadOnlyToolFlags({ strictMcp: false }).includes("Bash"));
|
|
157
|
+
assert.equal(w.closed.length, 0);
|
|
158
|
+
assert.ok(w.edits.some((e) => e.includes("needs-human")));
|
|
159
|
+
assert.ok(w.comments.some((c) => c.includes("FORGE:NEEDS_HUMAN") && c.includes("without evidence")));
|
|
198
160
|
});
|
|
199
161
|
|
|
200
|
-
it("review
|
|
201
|
-
const w = world({ reviews: [
|
|
202
|
-
await run(w);
|
|
203
|
-
|
|
204
|
-
|
|
162
|
+
it("review FAIL -> builder resumed via --resume with the reason -> re-review PASS -> merge", async () => {
|
|
163
|
+
const w = world({ reviews: [bad("src/foo.js:9 still wrong"), ok()] });
|
|
164
|
+
const r = await run(w);
|
|
165
|
+
assert.equal(r.terminalReason, "merged");
|
|
166
|
+
const b = builders(w);
|
|
167
|
+
assert.equal(b.length, 2);
|
|
168
|
+
assert.equal(b[1][2], "sess-1");
|
|
169
|
+
assert.match(b[1][4], /src\/foo.js:9 still wrong/);
|
|
170
|
+
assert.equal(reviewers(w).length, 2);
|
|
171
|
+
assert.equal(w.prs.length, 1);
|
|
172
|
+
assert.equal(registers(w).length, 1);
|
|
173
|
+
assert.deepEqual(w.merges[0].slice(-2), ["--match-head-commit", w.head]);
|
|
205
174
|
});
|
|
206
|
-
});
|
|
207
|
-
|
|
208
|
-
describe("lean path: release review fixes (#4084)", () => {
|
|
209
|
-
const approvedAt = (w, head) => w.comments.push(`<!-- FORGE:REVIEW lean v1 -->\n## FORGE:REVIEW\n**Verdict:** APPROVED | round 1 | reviewed ${head.slice(0, 12)} | risk low\n\nx`);
|
|
210
175
|
|
|
211
|
-
it("
|
|
212
|
-
const w = world({
|
|
213
|
-
w.comments.push("<!-- FORGE:PLAN lean v1 -->\n## FORGE:PLAN\nthe plan");
|
|
214
|
-
approvedAt(w, w.head);
|
|
176
|
+
it("review FAIL twice -> needs-human, no PR", async () => {
|
|
177
|
+
const w = world({ reviews: [bad(), bad("still bad")] });
|
|
215
178
|
const r = await run(w);
|
|
216
|
-
assert.equal(r.terminalReason, "
|
|
217
|
-
assert.equal(w
|
|
179
|
+
assert.equal(r.terminalReason, "needs-human");
|
|
180
|
+
assert.equal(builders(w).length, 2);
|
|
218
181
|
assert.equal(w.prs.length, 0);
|
|
219
|
-
assert.equal(w.merges
|
|
220
|
-
|
|
221
|
-
assert.ok(add.includes("origin/feat/issue-7-lean"), "checks out the remote branch, not the base");
|
|
182
|
+
assert.equal(w.merges.length, 0);
|
|
183
|
+
assert.ok(w.edits.some((e) => e.includes("needs-human")));
|
|
222
184
|
});
|
|
223
185
|
|
|
224
|
-
it("
|
|
225
|
-
const w = world({
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
const r = await run(w);
|
|
229
|
-
assert.equal(r.terminalReason, "merged");
|
|
230
|
-
const rev = w.calls.find((c) => c[1] === "reviewer");
|
|
231
|
-
assert.ok(rev[4].includes("the plan text"));
|
|
232
|
-
assert.ok(!w.calls.some((c) => c[1] === "builder"));
|
|
186
|
+
it("a FAIL verdict with fixes_issue true and no regression is not a failure", async () => {
|
|
187
|
+
const w = world({ reviews: [ok({ verdict: "FAIL" })] });
|
|
188
|
+
assert.equal((await run(w)).terminalReason, "merged");
|
|
189
|
+
assert.equal(builders(w).length, 1);
|
|
233
190
|
});
|
|
234
191
|
|
|
235
|
-
it("
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
assert.equal(r.terminalReason, "ci-pending");
|
|
239
|
-
assert.ok(w.calls.filter((c) => c[1] === "worktree" && c[2] === "remove").length >= 2);
|
|
240
|
-
assert.ok(!w.calls.some((c) => c[1] === "branch"));
|
|
192
|
+
it("builder blocked, or no commit, is needs-human", async () => {
|
|
193
|
+
assert.equal((await run(world({ result: { status: "blocked", summary: "need creds" } }))).terminalReason, "needs-human");
|
|
194
|
+
assert.equal((await run(world({ changed: [] }))).terminalReason, "needs-human");
|
|
241
195
|
});
|
|
242
196
|
|
|
243
|
-
it("
|
|
244
|
-
const
|
|
245
|
-
|
|
246
|
-
const
|
|
247
|
-
|
|
248
|
-
assert.
|
|
249
|
-
assert.equal(
|
|
250
|
-
const
|
|
251
|
-
assert.equal((await run(
|
|
252
|
-
const closed = world({ apiIssues: { 7: own, 5: { state: "closed" } } });
|
|
253
|
-
assert.equal((await run(closed)).terminalReason, "merged");
|
|
197
|
+
it("red remote CI is needs-human; slow CI polls with backoff and ends ci-pending, never needs-human", async () => {
|
|
198
|
+
const w = world({ waits: [{ rc: 1, text: "lint failed" }] });
|
|
199
|
+
assert.equal((await run(w, { mergeWait: "all" })).terminalReason, "needs-human");
|
|
200
|
+
const w2 = world({ waits: Array(500).fill({ rc: 2, text: "" }) });
|
|
201
|
+
const r2 = await run(w2, { ciMaxMinutes: 10, mergeWait: "all" });
|
|
202
|
+
assert.equal(r2.terminalReason, "ci-pending");
|
|
203
|
+
assert.equal(w2.edits.length, 0);
|
|
204
|
+
const w3 = world({ waits: [{ rc: 2, text: "" }, { rc: 2, text: "" }] });
|
|
205
|
+
assert.equal((await run(w3, { mergeWait: "all" })).terminalReason, "merged");
|
|
254
206
|
});
|
|
255
207
|
|
|
256
|
-
it("
|
|
257
|
-
const w = world({
|
|
258
|
-
|
|
259
|
-
assert.equal(
|
|
260
|
-
assert.equal(resumes(w).length, 1);
|
|
261
|
-
const d = world({ ci: [{ result: "DEGRADED", tail: "" }] });
|
|
262
|
-
assert.equal((await run(d)).terminalReason, "merged");
|
|
208
|
+
it("refuses merge when the CI-gated head differs from the reviewed head", async () => {
|
|
209
|
+
const w = world({ waits: [{ rc: 0, text: "CI_GATE_HEAD: deadbeefdead" }] });
|
|
210
|
+
assert.equal((await run(w, { mergeWait: "all" })).terminalReason, "needs-human");
|
|
211
|
+
assert.equal(w.merges.length, 0);
|
|
263
212
|
});
|
|
264
213
|
|
|
265
|
-
it("
|
|
266
|
-
const
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
214
|
+
it("an open Depends-on dependency refuses the run before any label or worktree", async () => {
|
|
215
|
+
const w = world();
|
|
216
|
+
const gh = w.io.gh;
|
|
217
|
+
w.io.gh = async (a) => {
|
|
218
|
+
if (a[0] === "api" && /issues\/7$/.test(a[1])) return JSON.stringify({ body: "**Depends on**: #3", user: { login: "x" }, author_association: "OWNER" });
|
|
219
|
+
if (a[0] === "api" && /issues\/3$/.test(a[1])) return JSON.stringify({ state: "open" });
|
|
220
|
+
return gh(a);
|
|
221
|
+
};
|
|
222
|
+
const r = await run(w);
|
|
223
|
+
assert.ok(["dependency-open", "dependency-unreadable"].includes(r.terminalReason));
|
|
224
|
+
assert.equal(w.labels.length, 0);
|
|
225
|
+
assert.equal(w.calls.filter((c) => c[1] === "worktree").length, 0);
|
|
272
226
|
});
|
|
273
227
|
|
|
274
|
-
it("
|
|
275
|
-
const w = world(
|
|
276
|
-
await run(w
|
|
277
|
-
|
|
278
|
-
assert.equal(
|
|
228
|
+
it("an open PR / remote branch resumes: worktree from the branch, builder skipped, review then merge", async () => {
|
|
229
|
+
const w = world({ prList: [{ number: 77, headRefOid: "x" }], remote: "abc\trefs/heads/feat/issue-7-lean" });
|
|
230
|
+
const r = await run(w);
|
|
231
|
+
assert.equal(r.terminalReason, "merged");
|
|
232
|
+
assert.equal(builders(w).length, 0);
|
|
233
|
+
assert.equal(w.prs.length, 0);
|
|
234
|
+
assert.ok(w.calls.some((c) => c[0] === "git" && c[1] === "worktree" && c[2] === "add" && c.includes("origin/feat/issue-7-lean")));
|
|
279
235
|
});
|
|
280
236
|
|
|
281
|
-
it("
|
|
282
|
-
const w = world();
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
assert.
|
|
237
|
+
it("builder uses the CLI default model; reviewer is read-only Sonnet unless configured", async () => {
|
|
238
|
+
const w = world(); await run(w);
|
|
239
|
+
assert.equal(builders(w)[0][3], null);
|
|
240
|
+
assert.equal(builders(w)[0][5], false);
|
|
241
|
+
assert.equal(reviewers(w)[0][3], "claude-sonnet-5-5");
|
|
242
|
+
assert.equal(reviewers(w)[0][5], true);
|
|
243
|
+
const w2 = world(); await run(w2, { reviewerModel: "my-model" });
|
|
244
|
+
assert.equal(reviewers(w2)[0][3], "my-model");
|
|
287
245
|
});
|
|
246
|
+
});
|
|
288
247
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
248
|
+
describe("comment templates", () => {
|
|
249
|
+
const full = { status: "fixed", summary: "fixed foo", evidence: ["src/foo.js:3 null deref"], root_cause: "foo derefs null at src/foo.js:3",
|
|
250
|
+
approach: "guard null", sibling_sites_checked: ["src/bar.js:8: same guard added"], changes: [{ file: "src/foo.js", change: "add guard", why: "avoid crash" }],
|
|
251
|
+
tests: [{ command: "node --test test/foo.test.js", result: "pass", fails_without_fix: true }], risks: ["none"], follow_ups: ["audit baz"] };
|
|
252
|
+
const rv = { verdict: "PASS", fixes_issue: true, regression: false, reason: "src/foo.js:3 ok", checked: "callers of foo" };
|
|
253
|
+
|
|
254
|
+
it("renders full fields", () => {
|
|
255
|
+
const v = renderValidate(full, "valid"); const p = renderPlan(full, ["src/foo.js"]);
|
|
256
|
+
assert.match(v, /Root cause:\*\* foo derefs null at src\/foo.js:3/);
|
|
257
|
+
assert.match(p, /Approach:\*\* guard null/); assert.match(p, /src\/bar.js:8: same guard added/); assert.match(p, /node --test test\/foo.test.js/);
|
|
258
|
+
assert.match(renderReview(rv), /Checked:\*\* callers of foo/);
|
|
259
|
+
const reg = renderRegister({ result: full, gitFiles: [], review: rv, links: { validate: "V", plan: "P", review: "R" }, prUrl: "PRURL", usage: "cost $1", policy: "required" });
|
|
260
|
+
for (const n of ["| src/foo.js | add guard | avoid crash |", "fails without fix: yes", "Risks:** none", "Follow-ups:** audit baz", "Validation: V", "Plan: P", "Review: R", "PR: PRURL", "Merge policy:** required", "cost $1"]) assert.ok(reg.includes(n), n);
|
|
261
|
+
const pr = renderPrBody({ issue: 7, result: full });
|
|
262
|
+
assert.match(pr, /^Closes #7/); assert.match(pr, /\| file \| change \| why \|/); assert.match(pr, /node --test/);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("missing fields render as 'not reported', never broken markdown", () => {
|
|
266
|
+
const reg = renderRegister({ result: {}, gitFiles: ["a.js"], review: { ...rv, checked: undefined }, links: {}, prUrl: "U", usage: "u", policy: "none" });
|
|
267
|
+
assert.ok(reg.includes(NR));
|
|
268
|
+
assert.match(reg, /\| a.js \| not reported \| not reported \|/);
|
|
269
|
+
assert.match(reg, /Verification\*\*\nnot reported/);
|
|
270
|
+
assert.doesNotMatch(reg, /undefined|null|\[object/);
|
|
271
|
+
assert.doesNotMatch(renderValidate(null, "blocked"), /undefined|null/);
|
|
272
|
+
assert.doesNotMatch(renderPlan(undefined), /undefined/);
|
|
273
|
+
assert.doesNotMatch(renderReview({ verdict: "PASS", fixes_issue: true, regression: false }), /undefined/);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("escapes pipes and newlines in table cells", () => {
|
|
277
|
+
assert.equal(cell("a|b\nc"), "a\\|b c");
|
|
278
|
+
const t = renderPrBody({ issue: 1, result: { changes: [{ file: "a|b.js", change: "x\ny", why: "p|q" }] } });
|
|
279
|
+
const row = t.split("\n").find((l) => l.startsWith("| a"));
|
|
280
|
+
assert.equal(row, "| a\\|b.js | x y | p\\|q |");
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it("posts VALIDATE, PLAN, REVIEW, WORK-ON in order; VALIDATE and PLAN before the reviewer", async () => {
|
|
284
|
+
const w = world({ result: full });
|
|
285
|
+
await run(w);
|
|
286
|
+
const m = w.comments.map((c) => /FORGE:[A-Z_-]+/.exec(c)[0]);
|
|
287
|
+
assert.deepEqual(m, ["FORGE:STARTED", "FORGE:VALIDATE", "FORGE:PLAN", "FORGE:REVIEW", "FORGE:WORK-ON"]);
|
|
288
|
+
const idx = []; w.seq.forEach((s, i) => { if (s === "comment") idx.push(i); });
|
|
289
|
+
assert.ok(idx[1] < w.seq.indexOf("reviewer") && idx[2] < w.seq.indexOf("reviewer") && idx[3] > w.seq.indexOf("reviewer"));
|
|
290
|
+
const pr = w.prs[0][w.prs[0].indexOf("--body") + 1];
|
|
291
|
+
assert.match(pr, /Root cause/); assert.match(pr, /\| src\/foo.js \| add guard \| avoid crash \|/);
|
|
294
292
|
});
|
|
295
293
|
|
|
296
|
-
it("
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
await assert.rejects(runFromCli(["7", "--lane", "staging", "--path"], mk()), /--path requires a value/);
|
|
300
|
-
await assert.rejects(runFromCli(["7", "--lane", "staging", "--path", "--retry"], mk()), /--path requires a value/);
|
|
301
|
-
await assert.rejects(runFromCli(["7", "--lane", "staging", "--path", "lean", "--retry", "--repo", "o/r"], mk()), /not supported with the lean path/);
|
|
302
|
-
await assert.rejects(runFromCli(["7", "--lane", "staging", "--path", "auto", "--wait-lease", "--repo", "o/r"], mk()), /not supported with the lean path/);
|
|
294
|
+
it("builder prompt with the structured fields stays within 1.2k tokens", () => {
|
|
295
|
+
assert.ok(approxTokens(builderPrompt(7)) <= 1200);
|
|
296
|
+
assert.match(builderPrompt(7), /root_cause/); assert.match(builderPrompt(7), /sibling_sites_checked/);
|
|
303
297
|
});
|
|
304
298
|
});
|
|
305
299
|
|
|
306
|
-
describe("
|
|
307
|
-
|
|
308
|
-
|
|
300
|
+
describe("merge_wait policy", () => {
|
|
301
|
+
const states = (o) => Object.entries(o).map(([name, state]) => ({ name, state }));
|
|
302
|
+
it("required with no protection (empty) merges immediately without polling", async () => {
|
|
303
|
+
const w = world(); let polled = 0;
|
|
304
|
+
w.io.requiredChecks = async () => []; w.io.checkStates = async () => { polled++; return []; };
|
|
305
|
+
w.io.waitCiGreen = async () => { polled++; return { rc: 2, text: "" }; };
|
|
309
306
|
const r = await run(w);
|
|
310
|
-
assert.equal(r.terminalReason, "
|
|
311
|
-
assert.match(w
|
|
312
|
-
assert.equal(w.closed.length, 1); assert.ok(w.closed[0].includes("not planned"));
|
|
313
|
-
assert.equal(w.prs.length, 0);
|
|
314
|
-
assert.ok(w.labels.includes("invalid"));
|
|
307
|
+
assert.equal(r.terminalReason, "merged"); assert.equal(polled, 0);
|
|
308
|
+
assert.match(registers(w)[0], /Merge policy:\*\* required \(no required checks/);
|
|
315
309
|
});
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
const w = world({ ci: [{ result: "FAIL", tail: "FAILED test_foo: expected 1" }] });
|
|
310
|
+
it("required with a pending required check waits (backoff) then merges", async () => {
|
|
311
|
+
const w = world(); const seqs = [states({ build: "pending" }), states({ build: "pending" }), states({ build: "success", other: "failure" })]; let sleeps = 0;
|
|
312
|
+
w.io.requiredChecks = async () => ["build"]; w.io.checkStates = async () => seqs.shift();
|
|
313
|
+
const s = w.io.sleep; w.io.sleep = async (ms) => { sleeps++; return s(ms); };
|
|
321
314
|
const r = await run(w);
|
|
322
|
-
assert.equal(r.terminalReason, "merged");
|
|
323
|
-
|
|
324
|
-
assert.
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
315
|
+
assert.equal(r.terminalReason, "merged"); assert.equal(sleeps, 2);
|
|
316
|
+
assert.match(registers(w)[0], /required \(build\)/);
|
|
317
|
+
assert.deepEqual(w.merges[0].slice(-2), ["--match-head-commit", w.head]);
|
|
318
|
+
});
|
|
319
|
+
it("required with a failing required check is needs-human; slow required check is ci-pending", async () => {
|
|
320
|
+
const w = world(); w.io.requiredChecks = async () => ["build"]; w.io.checkStates = async () => states({ build: "failure" });
|
|
321
|
+
assert.equal((await run(w)).terminalReason, "needs-human"); assert.equal(w.merges.length, 0);
|
|
322
|
+
const w2 = world(); w2.io.requiredChecks = async () => ["build"]; w2.io.checkStates = async () => states({ build: "pending" });
|
|
323
|
+
assert.equal((await run(w2, { ciMaxMinutes: 5 })).terminalReason, "ci-pending");
|
|
324
|
+
});
|
|
325
|
+
it("all waits for every check (waitCiGreen); none merges immediately", async () => {
|
|
326
|
+
const w = world({ waits: [{ rc: 2, text: "" }] }); let req = 0; w.io.requiredChecks = async () => { req++; return []; };
|
|
327
|
+
assert.equal((await run(w, { mergeWait: "all" })).terminalReason, "merged");
|
|
328
|
+
assert.equal(w.waits.length, 0); assert.equal(req, 0);
|
|
329
|
+
assert.match(registers(w)[0], /Merge policy:\*\* all/);
|
|
330
|
+
const w2 = world({ waits: [{ rc: 1, text: "red" }] });
|
|
331
|
+
assert.equal((await run(w2, { mergeWait: "none" })).terminalReason, "merged");
|
|
332
|
+
assert.equal(w2.waits.length, 1); assert.deepEqual(w2.merges[0].slice(-2), ["--match-head-commit", w2.head]);
|
|
328
333
|
});
|
|
334
|
+
});
|
|
329
335
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
const
|
|
333
|
-
assert.
|
|
334
|
-
assert.match(
|
|
336
|
+
describe("prompts", () => {
|
|
337
|
+
it("builder prompt is at most 1k tokens and short and plain", () => {
|
|
338
|
+
const p = builderPrompt(7);
|
|
339
|
+
assert.ok(approxTokens(p) <= 1000);
|
|
340
|
+
assert.match(p, /Resolve issue #7 \(read it with gh issue view 7 --comments\)/);
|
|
341
|
+
assert.match(p, /Do not push or touch GitHub/);
|
|
342
|
+
assert.match(p, /result\.json/);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it("reviewer system prompt is at most 1.5k tokens and holds only the two questions", () => {
|
|
346
|
+
assert.ok(approxTokens(REVIEWER_SYSTEM) <= 1500);
|
|
347
|
+
assert.match(REVIEWER_SYSTEM, /Does this change fix the issue as described\?/);
|
|
348
|
+
assert.match(REVIEWER_SYSTEM, /introduce a regression/);
|
|
349
|
+
for (const banned of [/edge/i, /style/i, /harden/i, /nice.to.have/i, /severity/i, /nit/i, /optional/i, /refactor/i, /naming/i, /performance/i, /security/i]) {
|
|
350
|
+
assert.doesNotMatch(REVIEWER_SYSTEM, banned);
|
|
351
|
+
}
|
|
352
|
+
assert.equal((REVIEWER_SYSTEM.match(/^\d\. /gm) ?? []).length, 2);
|
|
335
353
|
});
|
|
336
354
|
|
|
337
|
-
it("
|
|
338
|
-
const
|
|
339
|
-
assert.
|
|
340
|
-
assert.
|
|
355
|
+
it("the full reviewer prompt for a typical diff stays under 1.5k tokens of instructions plus content", () => {
|
|
356
|
+
const p = reviewerPrompt({ issue: { number: 7, title: "t", body: "b" }, diff: "x".repeat(2000) });
|
|
357
|
+
assert.match(p, /<diff>/);
|
|
358
|
+
assert.ok(approxTokens(REVIEWER_SYSTEM) + approxTokens(reviewerPrompt({ issue: { number: 7, title: "t", body: "b" }, diff: "" })) <= 1500);
|
|
341
359
|
});
|
|
342
360
|
|
|
343
|
-
it(
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
assert.equal(
|
|
347
|
-
assert.equal(
|
|
348
|
-
assert.
|
|
349
|
-
assert.equal(w.prs.length, 0);
|
|
361
|
+
it("parseVerdict: flags decide, garbage is null", () => {
|
|
362
|
+
assert.equal(parseVerdict(ok()).fail, false);
|
|
363
|
+
assert.equal(parseVerdict(ok({ regression: true })).fail, true);
|
|
364
|
+
assert.equal(parseVerdict(bad()).fail, true);
|
|
365
|
+
assert.equal(parseVerdict("no json"), null);
|
|
366
|
+
assert.equal(parseVerdict('{"verdict":"FAIL","reason":"x"}').fail, true);
|
|
350
367
|
});
|
|
351
368
|
});
|
|
352
369
|
|
|
353
|
-
describe("
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
assert.equal(
|
|
362
|
-
assert.match(reviews[1][4], /DELTA re-review/);
|
|
363
|
-
assert.equal(reviews[1][2], null, "reviewer is always a fresh session");
|
|
364
|
-
assert.deepEqual(markers(w).filter((m) => m === "FORGE:REVIEW").length, 2);
|
|
365
|
-
});
|
|
366
|
-
|
|
367
|
-
it("2 failed fix rounds -> needs-human", async () => {
|
|
368
|
-
const bad = reply([finding({ severity: "CRITICAL" })]);
|
|
369
|
-
const w = world({ reviews: [bad, bad, bad] });
|
|
370
|
-
const r = await run(w);
|
|
371
|
-
assert.equal(r.terminalReason, "needs-human");
|
|
372
|
-
assert.equal(resumes(w).length, 1 + MAX_ROUNDS);
|
|
373
|
-
assert.equal(w.prs.length, 0);
|
|
370
|
+
describe("run-issue routing", () => {
|
|
371
|
+
const mk = () => { const seen = {}; return { seen, dir: tmpDir("core-cli-"), emitTelemetry: async () => {}, leanIo: {}, io: { gh: async () => "o/r\n" },
|
|
372
|
+
leanModule: { runCore: async (o) => { seen.core = o.path; return { terminalReason: "merged" }; } }, runIssue: async () => { seen.full = true; return { terminalReason: "merged" }; } }; };
|
|
373
|
+
const quiet = async (fn) => { const l = console.log; console.log = () => {}; try { return await fn(); } finally { console.log = l; } };
|
|
374
|
+
|
|
375
|
+
it("default (no --path) runs the core work-on", async () => {
|
|
376
|
+
const d = mk();
|
|
377
|
+
await quiet(() => runFromCli(["7", "--lane", "staging", "--repo", "o/r"], d));
|
|
378
|
+
assert.equal(d.seen.core, "core"); assert.ok(!d.seen.full);
|
|
374
379
|
});
|
|
375
380
|
|
|
376
|
-
it("
|
|
377
|
-
const
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
for (const t of ["naming nit", "maybe", "old bug"]) assert.ok(rev.includes(t));
|
|
383
|
-
assert.equal(resumes(w).length, 1);
|
|
381
|
+
it("--path lean and --path auto are aliases of the core", async () => {
|
|
382
|
+
for (const p of ["lean", "auto", "core"]) {
|
|
383
|
+
const d = mk();
|
|
384
|
+
await quiet(() => runFromCli(["7", "--lane", "staging", "--repo", "o/r", "--path", p], d));
|
|
385
|
+
assert.ok(d.seen.core && !d.seen.full, p);
|
|
386
|
+
}
|
|
384
387
|
});
|
|
385
388
|
|
|
386
|
-
it("
|
|
387
|
-
const
|
|
388
|
-
|
|
389
|
+
it("--path full still routes to the old engine", async () => {
|
|
390
|
+
const d = mk();
|
|
391
|
+
await quiet(() => runFromCli(["7", "--lane", "staging", "--repo", "o/r", "--path", "full"], d));
|
|
392
|
+
assert.ok(d.seen.full); assert.equal(d.seen.core, undefined);
|
|
389
393
|
});
|
|
390
394
|
|
|
391
|
-
it("
|
|
392
|
-
assert.
|
|
393
|
-
assert.
|
|
394
|
-
assert.
|
|
395
|
-
assert.ok(!isBlocking({ severity: "MEDIUM", confidence: "CONFIRMED", scope: "pre-existing" }));
|
|
396
|
-
assert.ok(!isBlocking({ severity: "MEDIUM", confidence: "LIKELY", scope: "introduced" }));
|
|
397
|
-
assert.ok(!isBlocking({ severity: "LOW", confidence: "CONFIRMED" }));
|
|
398
|
-
assert.deepEqual(parseFindings("x ```json\n[{\"a\":1}]\n``` y"), [{ a: 1 }]);
|
|
399
|
-
assert.equal(parseFindings("nothing"), null);
|
|
395
|
+
it("bad --path errors; --retry is rejected on the core path", async () => {
|
|
396
|
+
await assert.rejects(runFromCli(["7", "--lane", "staging", "--path", "bogus"], mk()), /--path must be/);
|
|
397
|
+
await assert.rejects(runFromCli(["7", "--lane", "staging", "--repo", "o/r", "--retry"], mk()), /not supported with the core path/);
|
|
398
|
+
assert.deepEqual(ENGINE_PATHS, ["core", "full", "lean", "auto"]);
|
|
400
399
|
});
|
|
401
400
|
});
|
|
402
401
|
|
|
403
|
-
describe("
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
402
|
+
describe("orchestrate", () => {
|
|
403
|
+
const tick = () => new Promise((r) => setImmediate(r));
|
|
404
|
+
const harness = (bodies, outcomes = {}) => {
|
|
405
|
+
const log = { starts: [], ends: [], active: 0, maxActive: 0 };
|
|
406
|
+
return { log,
|
|
407
|
+
readBody: async (n) => bodies[n] ?? "",
|
|
408
|
+
runOne: async (n) => {
|
|
409
|
+
log.starts.push(n); log.active++; log.maxActive = Math.max(log.maxActive, log.active);
|
|
410
|
+
await tick(); await tick();
|
|
411
|
+
log.active--; log.ends.push(n);
|
|
412
|
+
const reason = outcomes[n] ?? "merged";
|
|
413
|
+
return { terminalReason: reason, pr: reason === "merged" ? 100 + n : null, cost_usd: 0.5, detail: reason };
|
|
414
|
+
} };
|
|
415
|
+
};
|
|
416
|
+
|
|
417
|
+
it("parseDeps reads Depends-on lines and ignores self references", () => {
|
|
418
|
+
assert.deepEqual(parseDeps("x\n**Depends on**: #4\n**Depends on**: #7\n", 7), [4]);
|
|
411
419
|
});
|
|
412
420
|
|
|
413
|
-
it("
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
assert.equal(
|
|
418
|
-
assert.
|
|
419
|
-
});
|
|
420
|
-
|
|
421
|
-
it("auto: full only when risk high AND diff large", async () => {
|
|
422
|
-
assert.equal(choosePath("auto", { tier: "high" }, { files: 20, lines: 10 }), "full");
|
|
423
|
-
assert.equal(choosePath("auto", { tier: "high" }, { files: 2, lines: 50 }), "lean");
|
|
424
|
-
assert.equal(choosePath("auto", { tier: "low" }, { files: 40, lines: 5000 }), "lean");
|
|
425
|
-
assert.equal(choosePath("lean", { tier: "high" }, { files: 40, lines: 5000 }), "lean");
|
|
426
|
-
assert.ok(isLargeDiff({ lines: 400 }));
|
|
427
|
-
const big = world({ changed: ["src/auth/a.js", "test/a.test.js"], lines: 900 });
|
|
428
|
-
const r = await run(big, { path: "auto" });
|
|
429
|
-
assert.equal(r.terminalReason, "route-full");
|
|
430
|
-
assert.equal(big.prs.length, 0);
|
|
431
|
-
const small = world({ changed: ["src/auth/a.js", "test/a.test.js"], lines: 30 });
|
|
432
|
-
assert.equal((await run(small, { path: "auto" })).terminalReason, "merged");
|
|
433
|
-
const calm = world({ lines: 900 });
|
|
434
|
-
assert.equal((await run(calm, { path: "auto" })).terminalReason, "merged");
|
|
421
|
+
it("runs at most K at once", async () => {
|
|
422
|
+
const h = harness({});
|
|
423
|
+
const rows = await orchestrate({ issues: [1, 2, 3, 4, 5, 6], parallel: 2, ...h });
|
|
424
|
+
assert.equal(h.log.maxActive, 2);
|
|
425
|
+
assert.equal(rows.length, 6);
|
|
426
|
+
assert.ok(rows.every((r) => r.outcome === "merged"));
|
|
435
427
|
});
|
|
436
|
-
});
|
|
437
428
|
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
assert.equal(r.terminalReason, "ci-pending");
|
|
443
|
-
assert.equal(w.edits.length, 0);
|
|
444
|
-
assert.equal(w.merges.length, 0);
|
|
445
|
-
assert.ok(w.clock >= 30 * 60000);
|
|
429
|
+
it("default style K=3 runs three in parallel", async () => {
|
|
430
|
+
const h = harness({});
|
|
431
|
+
await orchestrate({ issues: [1, 2, 3, 4], parallel: 3, ...h });
|
|
432
|
+
assert.equal(h.log.maxActive, 3);
|
|
446
433
|
});
|
|
447
434
|
|
|
448
|
-
it("
|
|
449
|
-
const
|
|
450
|
-
|
|
435
|
+
it("a dependent waits until its dependency has merged", async () => {
|
|
436
|
+
const h = harness({ 2: "**Depends on**: #1" });
|
|
437
|
+
await orchestrate({ issues: [2, 1, 3], parallel: 3, ...h });
|
|
438
|
+
assert.ok(h.log.ends.indexOf(1) < h.log.starts.indexOf(2), JSON.stringify(h.log));
|
|
451
439
|
});
|
|
452
440
|
|
|
453
|
-
it("
|
|
454
|
-
const
|
|
455
|
-
|
|
456
|
-
assert.
|
|
441
|
+
it("a dependent is skipped, without running, when its dependency ends needs-human (transitively)", async () => {
|
|
442
|
+
const h = harness({ 2: "**Depends on**: #1", 3: "**Depends on**: #2" }, { 1: "needs-human" });
|
|
443
|
+
const rows = await orchestrate({ issues: [1, 2, 3, 4], parallel: 3, ...h });
|
|
444
|
+
assert.deepEqual(rows.map((r) => r.outcome), ["needs-human", "skipped", "skipped", "merged"]);
|
|
445
|
+
assert.ok(!h.log.starts.includes(2) && !h.log.starts.includes(3));
|
|
446
|
+
assert.match(rows[1].detail, /#1/);
|
|
457
447
|
});
|
|
458
|
-
});
|
|
459
448
|
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
assert.equal((await run(w)).terminalReason, "merged", changed.join());
|
|
465
|
-
}
|
|
466
|
-
assert.ok(isTestFile("pkg/x_test.go") && isTestFile("tests/test_x.py") && isTestFile("src/__tests__/a.ts") && !isTestFile("src/main.go"));
|
|
467
|
-
assert.ok(isDocsOnly(["docs/a.rst", "README.md"]) && !isDocsOnly(["README.md", "src/a.go"]));
|
|
449
|
+
it("a dependency that closed as invalid does not block", async () => {
|
|
450
|
+
const h = harness({ 2: "**Depends on**: #1" }, { 1: "invalid" });
|
|
451
|
+
const rows = await orchestrate({ issues: [1, 2], parallel: 2, ...h });
|
|
452
|
+
assert.equal(rows[1].outcome, "merged");
|
|
468
453
|
});
|
|
469
454
|
|
|
470
|
-
it("
|
|
471
|
-
|
|
472
|
-
const
|
|
473
|
-
assert.
|
|
455
|
+
it("a dependency cycle is skipped rather than hanging; a thrown run is an engine-error row", async () => {
|
|
456
|
+
const h = harness({ 1: "**Depends on**: #2", 2: "**Depends on**: #1" });
|
|
457
|
+
const rows = await orchestrate({ issues: [1, 2], parallel: 2, ...h });
|
|
458
|
+
assert.ok(rows.every((r) => r.outcome === "skipped"));
|
|
459
|
+
const rows2 = await orchestrate({ issues: [9], runOne: async () => { throw new Error("boom"); }, readBody: async () => "" });
|
|
460
|
+
assert.equal(rows2[0].outcome, "engine-error");
|
|
474
461
|
});
|
|
475
|
-
});
|
|
476
462
|
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
assert.match(
|
|
482
|
-
assert.match(BUILDER_SYSTEM, /[Ss]ibling/);
|
|
483
|
-
assert.match(REVIEWER_SYSTEM, /JSON array/);
|
|
463
|
+
it("formatTable prints issue, outcome, PR, cost, minutes", () => {
|
|
464
|
+
const t = formatTable([{ issue: 1, outcome: "merged", pr: 101, cost_usd: 0.5, minutes: 3.2 }, { issue: 2, outcome: "skipped", pr: null, cost_usd: 0, minutes: 0, detail: "dependency #1 ended needs-human" }]);
|
|
465
|
+
assert.match(t, /issue\s+outcome\s+PR\s+cost\s+min/);
|
|
466
|
+
assert.match(t, /#1\s+merged\s+#101\s+\$0\.50\s+3\.2/);
|
|
467
|
+
assert.match(t, /skipped \(dependency #1/);
|
|
484
468
|
});
|
|
485
|
-
});
|
|
486
469
|
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
const
|
|
491
|
-
extraArgs: ["--resume", "sess-9"], sessionFlags: ["--strict-mcp-config"],
|
|
492
|
-
spawnFn: (_b, a) => { argv = a; return { status: 0, stderr: "", stdout: JSON.stringify({ result: "ok", session_id: "sess-9", usage: { input_tokens: 1 } }) }; } });
|
|
493
|
-
assert.equal(argv[argv.indexOf("--resume") + 1], "sess-9");
|
|
494
|
-
assert.equal(r.sessionId, "sess-9");
|
|
495
|
-
const r2 = runCliBackend({ spec: { name: "x" }, userMessage: "go", cwd: process.cwd(), logger: { log() {} }, spawnFn: () => ({ status: 0, stdout: "{}", stderr: "" }) });
|
|
496
|
-
assert.equal(r2.sessionId, null);
|
|
497
|
-
});
|
|
498
|
-
|
|
499
|
-
it("buildLeanSessionFlags reuses the capability probe and falls back cleanly", () => {
|
|
500
|
-
const f = buildLeanSessionFlags({ strictMcp: true, disableSlash: true }, {});
|
|
501
|
-
for (const x of ["--strict-mcp-config", "--mcp-config", '{"mcpServers":{}}', "--disable-slash-commands"]) assert.ok(f.includes(x), x);
|
|
502
|
-
assert.ok(!f.includes("--setting-sources"), "user-scope safety hooks keep running");
|
|
503
|
-
assert.deepEqual(buildLeanSessionFlags({ strictMcp: false, disableSlash: false }, {}), []);
|
|
504
|
-
assert.deepEqual(buildLeanSessionFlags({ strictMcp: true, disableSlash: true }, { FORGEDOCK_FULL_SESSION: "1" }), []);
|
|
505
|
-
});
|
|
506
|
-
|
|
507
|
-
it("run-issue --path lean dispatches to the lean engine; default stays full; auto escalates", async () => {
|
|
508
|
-
const dir = tmpDir("lean-cli-");
|
|
509
|
-
const mk = (result) => { const seen = {}; return { seen, leanModule: { runLean: async (o) => { seen.path = o.path; return result; } }, leanIo: {}, dir, emitTelemetry: async () => {},
|
|
510
|
-
io: { gh: async () => "o/r\n" }, runIssue: async () => { seen.full = true; return { terminalReason: "merged" }; } }; };
|
|
511
|
-
const silent = console.log; console.log = () => {};
|
|
470
|
+
it("orchestrateFromCli wires parallel, lane and prints the table", async () => {
|
|
471
|
+
const seen = [];
|
|
472
|
+
const logs = [];
|
|
473
|
+
const l = console.log; console.log = (s) => logs.push(String(s));
|
|
512
474
|
try {
|
|
513
|
-
const
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
} finally { console.log = silent; }
|
|
523
|
-
assert.ok(readdirSync(dir).length >= 0);
|
|
475
|
+
const res = await orchestrateFromCli(["1", "2", "--parallel", "2", "--lane", "main", "--repo", "o/r"], {
|
|
476
|
+
dir: tmpDir("core-orch-"), emitTelemetry: async () => {}, io: { gh: async () => "o/r\n" },
|
|
477
|
+
leanIo: { gh: async () => JSON.stringify({ body: "" }) },
|
|
478
|
+
leanModule: { runCore: async (o) => { seen.push([o.issue, o.lane, o.repo]); return { terminalReason: "merged", pr: 50 + o.issue, cost_usd: 1, wall_ms: 1 }; } } });
|
|
479
|
+
assert.equal(res.ok, true);
|
|
480
|
+
} finally { console.log = l; }
|
|
481
|
+
assert.deepEqual(seen.sort(), [[1, "main", "o/r"], [2, "main", "o/r"]]);
|
|
482
|
+
assert.ok(logs.some((s) => /issue\s+outcome/.test(s)));
|
|
483
|
+
await assert.rejects(orchestrateFromCli([], {}), /usage/);
|
|
524
484
|
});
|
|
525
485
|
});
|