forgedock 1.25.1 → 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.
- package/bin/engine/outcome-codes.mjs +15 -0
- package/bin/engine/phases.mjs +38 -2
- package/bin/engine/telemetry-seam.mjs +6 -0
- package/bin/engine/telemetry-seam.test.mjs +4 -0
- package/bin/engine-cli.mjs +85 -20
- package/bin/engine.mjs +5 -1
- package/bin/forgedock.mjs +94 -30
- package/bin/init-detect.mjs +68 -3
- package/bin/init-enrich.mjs +3 -0
- package/bin/init-repo.mjs +213 -0
- package/bin/journey.mjs +278 -43
- package/bin/lean/batch.mjs +2 -1
- package/bin/lean/batch.test.mjs +49 -1
- package/bin/lean/conformance.e2e.test.mjs +232 -0
- package/bin/lean/conformance.mjs +304 -0
- package/bin/lean/conformance.test.mjs +192 -0
- package/bin/lean/effects.mjs +119 -11
- package/bin/lean/lean.mjs +533 -70
- package/bin/lean/lean.test.mjs +1065 -112
- package/bin/lean/ledger.mjs +67 -0
- package/bin/lean/ledger.test.mjs +65 -0
- package/bin/lean/orchestrate.mjs +9 -0
- package/bin/lean/prompts.mjs +94 -57
- package/bin/lean/render.mjs +52 -7
- package/bin/lean/verify.mjs +113 -0
- package/bin/lean/verify.test.mjs +95 -0
- package/commands/adopt.md +3 -0
- package/commands/audit.md +3 -8
- package/commands/forgedock-init.md +118 -6
- package/commands/review-pr.md +2 -2
- package/docs/CONFIG.md +53 -3
- package/docs/OUTCOME-CODES.md +14 -0
- package/package.json +1 -1
- package/scripts/bootstrap-validate.mjs +287 -0
- package/scripts/check-devdocs-freshness.sh +120 -0
- package/scripts/check-devdocs-freshness.test.sh +120 -0
- package/scripts/ci-local/run.mjs +13 -2
- package/scripts/ci-local.test.sh +17 -2
- package/scripts/northstar-docs.test.sh +68 -0
- package/scripts/review-prepare.sh +11 -9
- package/scripts/review-prepare.test.sh +14 -0
- package/scripts/structure-delta.sh +150 -0
- package/scripts/structure-delta.test.sh +135 -0
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
import { describe, it } from "node:test";
|
|
4
|
+
import assert from "node:assert/strict";
|
|
5
|
+
import { validateContract, applyAmendments, precheck, parseReview, checkCompleteness, decide, closePlan, followupItems, inIssue, isTestPath, reviewSkeleton, resolveEvidencePath } from "./conformance.mjs";
|
|
6
|
+
|
|
7
|
+
const ISSUE = "## Requirements\n1. The scrape route must release the spend reservation on 429.\n2. The search route must release the spend reservation on 429.\n\n- [ ] Billing pricing is unchanged.";
|
|
8
|
+
const Q1 = "The scrape route must release the spend reservation on 429.";
|
|
9
|
+
const Q2 = "The search route must release the spend reservation on 429.";
|
|
10
|
+
const C = {
|
|
11
|
+
requirements: [{ id: "R1", quote: Q1, disposition: "deliver" }, { id: "R2", quote: Q2, disposition: "deliver" }],
|
|
12
|
+
deliverables: [{ id: "D1", path: "src/scrape.js" }, { id: "D2", path: "src/search.js" }],
|
|
13
|
+
preserve: [{ id: "P1", what: "Billing pricing is unchanged. (src/billing.js)" }],
|
|
14
|
+
validate: [{ id: "V1", command: "node --test test/leak.test.js" }],
|
|
15
|
+
};
|
|
16
|
+
const files = ["src/scrape.js", "src/search.js", "test/leak.test.js"];
|
|
17
|
+
const ctx = { contract: C, changedFiles: files, issueText: ISSUE, pathExists: (p) => p === "src/billing.js" };
|
|
18
|
+
const good = {
|
|
19
|
+
issue_coverage: [{ quote: Q1, status: "covered" }, { quote: Q2, status: "covered" }, { quote: "Billing pricing is unchanged.", status: "covered" }],
|
|
20
|
+
requirements: [{ id: "R1", status: "implemented", evidence: "src/scrape.js:7", validated_by: "V1" }, { id: "R2", status: "implemented", evidence: "src/search.js:7", validated_by: "V1" }],
|
|
21
|
+
scope: [{ file: "src/scrape.js", authorized_by: "D1" }, { file: "src/search.js", authorized_by: "D2" }, { file: "test/leak.test.js", authorized_by: "test" }],
|
|
22
|
+
preserve: [{ id: "P1", status: "intact", evidence: "src/billing.js:1" }], regressions: [], verdict: "CONFORMS", summary: "ok",
|
|
23
|
+
};
|
|
24
|
+
const row = (id, patch) => ({ ...good, requirements: good.requirements.map((r) => (r.id === id ? { ...r, ...patch } : r)) });
|
|
25
|
+
const codes = (d) => d.failures.map((f) => f.code);
|
|
26
|
+
|
|
27
|
+
describe("contract", () => {
|
|
28
|
+
it("a well-formed contract with verbatim quotes is valid", () => assert.deepEqual(validateContract(C, ISSUE), { ok: true, problems: [] }));
|
|
29
|
+
it("quotes must be verbatim (whitespace and emphasis aside) and long enough", () => {
|
|
30
|
+
assert.ok(inIssue("the SCRAPE route must release the spend reservation on 429.", ISSUE));
|
|
31
|
+
assert.ok(!inIssue("scrape should free money", ISSUE));
|
|
32
|
+
assert.ok(!inIssue("429.", ISSUE), "too short to identify a requirement");
|
|
33
|
+
});
|
|
34
|
+
it("rejects paraphrases, a defer without why, not-applicable without evidence, missing validate, duplicate ids", () => {
|
|
35
|
+
const v = validateContract({ ...C, validate: [], requirements: [
|
|
36
|
+
{ id: "R1", quote: "make scrape better", disposition: "deliver" }, { id: "R2", quote: Q2, disposition: "defer" },
|
|
37
|
+
{ id: "R2", quote: Q2, disposition: "not-applicable" }, { id: "R4", quote: Q1, disposition: "maybe" }] }, ISSUE);
|
|
38
|
+
assert.equal(v.ok, false);
|
|
39
|
+
for (const p of [/R1: quote is not verbatim/, /R2: defer needs a why/, /duplicated: R2/, /not-applicable needs file:line evidence/, /R4: disposition must be one of/, /no validate\[\] commands/]) assert.ok(v.problems.some((x) => p.test(x)), String(p));
|
|
40
|
+
assert.equal(validateContract(null, ISSUE).ok, false);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe("amendments", () => {
|
|
45
|
+
it("add-deliverable, add-requirement and defer apply with a why; defer is queued for tracking once", () => {
|
|
46
|
+
const a = applyAmendments(C, [
|
|
47
|
+
{ kind: "add-deliverable", path: "src/shared.js", why: "both routes share the helper" },
|
|
48
|
+
{ kind: "add-requirement", requirement: { id: "R3", quote: "Billing pricing is unchanged.", disposition: "deliver" }, why: "missed" },
|
|
49
|
+
{ kind: "defer", requirement: "R2", why: "search is owned by another module" }]);
|
|
50
|
+
assert.deepEqual(a.problems, []);
|
|
51
|
+
assert.ok(a.contract.deliverables.some((d) => d.path === "src/shared.js"));
|
|
52
|
+
assert.equal(a.contract.requirements.find((r) => r.id === "R2").disposition, "defer");
|
|
53
|
+
assert.deepEqual(a.toTrack.map((r) => r.id), ["R2"]);
|
|
54
|
+
assert.equal(a.applied.length, 3);
|
|
55
|
+
assert.equal(C.requirements[1].disposition, "deliver", "the frozen contract is never mutated");
|
|
56
|
+
});
|
|
57
|
+
it("an amendment without a why, an unknown requirement or an unknown kind is a problem, not applied", () => {
|
|
58
|
+
const a = applyAmendments(C, [{ kind: "add-deliverable", path: "x.js" }, { kind: "defer", requirement: "R9", why: "w" }, { kind: "rewrite", why: "w" }]);
|
|
59
|
+
assert.equal(a.problems.length, 3); assert.equal(a.applied.length, 0);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("precheck (deterministic, no reviewer)", () => {
|
|
64
|
+
const res = { requirements: [{ id: "R1", status: "implemented" }, { id: "R2", status: "implemented" }], validation: [{ id: "V1", exit: 0 }] };
|
|
65
|
+
it("passes a complete, validated, in-scope change", () => assert.deepEqual(precheck({ contract: C, result: res, changedFiles: files }), { ok: true, failures: [] }));
|
|
66
|
+
it("unauthorized file -> scope_creep; tests and ignored paths are always allowed", () => {
|
|
67
|
+
assert.deepEqual(codes(precheck({ contract: C, result: res, changedFiles: [...files, "src/billing.js", ".forge-lean/x", "tests/test_x.py", "pkg/foo_test.go"], ignore: [".forge-lean/"] })), ["conformance.scope_creep"]);
|
|
68
|
+
for (const t of ["test/a.js", "src/a.test.ts", "tests/test_a.py", "a_test.go", "spec/a.spec.js"]) assert.ok(isTestPath(t), t);
|
|
69
|
+
assert.ok(!isTestPath("src/testing.js"));
|
|
70
|
+
});
|
|
71
|
+
it("failed or missing validation -> unimplemented", () => {
|
|
72
|
+
assert.deepEqual(codes(precheck({ contract: C, result: { ...res, validation: [{ id: "V1", exit: 1 }] }, changedFiles: files })), ["conformance.unimplemented"]);
|
|
73
|
+
assert.deepEqual(codes(precheck({ contract: C, result: { ...res, validation: [] }, changedFiles: files })), ["conformance.unimplemented"]);
|
|
74
|
+
});
|
|
75
|
+
it("a deliver requirement reported deferred without an amendment -> untracked_deferral; partial -> unimplemented", () => {
|
|
76
|
+
const r = { ...res, requirements: [{ id: "R1", status: "deferred" }, { id: "R2", status: "partial" }] };
|
|
77
|
+
assert.deepEqual(codes(precheck({ contract: C, result: r, changedFiles: files })), ["conformance.untracked_deferral", "conformance.unimplemented"]);
|
|
78
|
+
});
|
|
79
|
+
it("a typed deferral is not a precheck failure: the reviewer judges it, then it is filed", () => {
|
|
80
|
+
const k = { ...C, requirements: [C.requirements[0], { ...C.requirements[1], disposition: "defer", why: "w" }] };
|
|
81
|
+
assert.equal(precheck({ contract: k, result: res, changedFiles: files }).ok, true);
|
|
82
|
+
});
|
|
83
|
+
it("deliverables may be directories or globs; changelogs and lockfiles are always allowed", () => {
|
|
84
|
+
const k = { ...C, deliverables: [{ id: "D1", path: "src/" }, { id: "D2", path: "./lib/**/*.go" }] };
|
|
85
|
+
assert.equal(precheck({ contract: k, result: res, changedFiles: ["src/a/b.js", "lib/x/y/z.go", "CHANGELOG.md", "web/package-lock.json", "go.sum"] }).ok, true);
|
|
86
|
+
assert.deepEqual(codes(precheck({ contract: k, result: res, changedFiles: ["lib/x.py"] })), ["conformance.scope_creep"]);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("conformance review: the engine computes the verdict from complete rows", () => {
|
|
91
|
+
it("complete conforming rows conform", () => assert.equal(decide(good, ctx).state, "conforms"));
|
|
92
|
+
it("a generic PASS (the old two-question reply) is incomplete, never conforming", () => {
|
|
93
|
+
const d = decide({ verdict: "PASS", fixes_issue: true, regression: false, reason: "looks good" }, ctx);
|
|
94
|
+
assert.equal(d.state, "review.incomplete");
|
|
95
|
+
assert.ok(d.failures.every((f) => f.code === "review.incomplete"));
|
|
96
|
+
});
|
|
97
|
+
it("no JSON is unparseable", () => { assert.equal(parseReview("LGTM"), null); assert.equal(decide(null, ctx).state, "review.unparseable"); });
|
|
98
|
+
it("incomplete: missing requirement row, missing scope row for an unauthorized changed file, missing preserve row", () => {
|
|
99
|
+
assert.equal(decide({ ...good, requirements: good.requirements.slice(1) }, ctx).state, "review.incomplete");
|
|
100
|
+
const extra = { ...ctx, changedFiles: [...files, "src/billing.js"] };
|
|
101
|
+
assert.equal(checkCompleteness(good, extra).problems.includes("changed file src/billing.js has no scope row"), true);
|
|
102
|
+
assert.equal(decide({ ...good, preserve: [] }, ctx).state, "review.incomplete");
|
|
103
|
+
});
|
|
104
|
+
it("#4160: a changed file the contract already authorizes (deliverable, test, incidental) needs no scope row", () => {
|
|
105
|
+
assert.equal(decide({ ...good, scope: [] }, ctx).state, "conforms");
|
|
106
|
+
assert.equal(checkCompleteness({ ...good, scope: [] }, { ...ctx, changedFiles: [...files, "CHANGELOG.md"] }).complete, true);
|
|
107
|
+
});
|
|
108
|
+
it("#4160: a scope row the reviewer gives for an authorized file is still judged (none on a deliverable is creep)", () => {
|
|
109
|
+
const s = good.scope.map((x) => (x.file === "src/scrape.js" ? { ...x, authorized_by: "none", note: "unrelated rewrite" } : x));
|
|
110
|
+
assert.equal(decide({ ...good, scope: s }, ctx).state, "nonconforming");
|
|
111
|
+
});
|
|
112
|
+
it("#4160: evidence given as a bare or partial file name resolves to the one changed file it names", () => {
|
|
113
|
+
assert.equal(resolveEvidencePath("scrape.js", files), "src/scrape.js");
|
|
114
|
+
assert.equal(resolveEvidencePath("./src/scrape.js", files), "src/scrape.js");
|
|
115
|
+
assert.equal(resolveEvidencePath("leak.test.js", files), "test/leak.test.js");
|
|
116
|
+
assert.equal(resolveEvidencePath("x.js", ["a/x.js", "b/x.js"]), null, "ambiguous");
|
|
117
|
+
assert.equal(resolveEvidencePath("nope.js", files), null);
|
|
118
|
+
assert.equal(decide(row("R1", { evidence: "scrape.js:4" }), ctx).state, "conforms");
|
|
119
|
+
});
|
|
120
|
+
it("#4160: reviewSkeleton lists every required row, pre-filled, with scope rows only for unauthorized files", () => {
|
|
121
|
+
const k = { ...C, requirements: [...C.requirements, { id: "R9", quote: "Billing pricing is unchanged.", disposition: "defer", why: "w" }] };
|
|
122
|
+
const sk = reviewSkeleton({ contract: k, changedFiles: [...files, "src/billing.js", ".forge-lean/x"], ignore: [".forge-lean/"] });
|
|
123
|
+
assert.deepEqual(sk.requirements.map((r) => r.id), k.requirements.map((r) => r.id));
|
|
124
|
+
assert.equal(sk.requirements.find((r) => r.id === "R9").deferral_ok, null);
|
|
125
|
+
assert.ok(!("deferral_ok" in sk.requirements[0]));
|
|
126
|
+
assert.deepEqual(sk.preserve.map((p) => p.id), (k.preserve ?? []).map((p) => p.id));
|
|
127
|
+
assert.deepEqual(sk.scope.map((s) => s.file), ["src/billing.js"]);
|
|
128
|
+
assert.deepEqual(sk.issue_coverage.map((c) => c.quote), k.requirements.map((r) => r.quote));
|
|
129
|
+
assert.ok(sk.issue_coverage.every((c) => inIssue(c.quote, ISSUE)), "seeded quotes pass the verbatim check");
|
|
130
|
+
assert.ok([...sk.requirements, ...sk.preserve, ...sk.issue_coverage].every((r) => r.status === ""), "no status is pre-judged");
|
|
131
|
+
});
|
|
132
|
+
it("incomplete: implemented without path:line, with evidence in a nonexistent file, or without validation", () => {
|
|
133
|
+
assert.equal(decide(row("R1", { evidence: "looks fine" }), ctx).state, "review.incomplete");
|
|
134
|
+
assert.equal(decide(row("R1", { evidence: "src/nope.js:3" }), ctx).state, "review.incomplete");
|
|
135
|
+
assert.equal(decide(row("R1", { evidence: "src/billing.js:1" }), ctx).state, "conforms", "an existing unchanged file is valid evidence");
|
|
136
|
+
assert.equal(decide(row("R1", { validated_by: "none" }), ctx).state, "review.incomplete");
|
|
137
|
+
});
|
|
138
|
+
it("incomplete: a coverage quote paraphrased instead of verbatim", () => {
|
|
139
|
+
assert.equal(decide({ ...good, issue_coverage: [{ quote: "scrape should release money on rate limits", status: "covered" }] }, ctx).state, "review.incomplete");
|
|
140
|
+
});
|
|
141
|
+
it("omission: an issue requirement missing from or narrowed by the contract", () => {
|
|
142
|
+
const d = decide({ ...good, issue_coverage: [...good.issue_coverage.slice(0, 2), { quote: "Billing pricing is unchanged.", status: "missing" }] }, ctx);
|
|
143
|
+
assert.deepEqual(codes(d), ["conformance.omission"]);
|
|
144
|
+
});
|
|
145
|
+
it("contract mismatch: a deliver requirement partial or missing in the diff", () => {
|
|
146
|
+
assert.deepEqual(codes(decide(row("R2", { status: "missing", evidence: "src/search.js:5" }), ctx)), ["conformance.unimplemented"]);
|
|
147
|
+
assert.deepEqual(codes(decide(row("R2", { status: "deferred" }), ctx)), ["conformance.untracked_deferral"]);
|
|
148
|
+
});
|
|
149
|
+
it("scope creep, preserve violated or unverified, and regressions fail", () => {
|
|
150
|
+
assert.deepEqual(codes(decide({ ...good, scope: [{ ...good.scope[0], authorized_by: "none", note: "rewrites pricing inside scrape.js" }, ...good.scope.slice(1)] }, ctx)), ["conformance.scope_creep"]);
|
|
151
|
+
assert.equal(decide({ ...good, scope: [...good.scope.slice(0, 2), { file: "test/leak.test.js", authorized_by: "none" }] }, ctx).state, "conforms", "a reviewer 'none' on a test file is a misreading, not creep");
|
|
152
|
+
assert.deepEqual(codes(decide({ ...good, preserve: [{ id: "P1", status: "violated", evidence: "src/billing.js:1" }] }, ctx)), ["conformance.preserve_violated"]);
|
|
153
|
+
assert.deepEqual(codes(decide({ ...good, preserve: [{ id: "P1", status: "unverified" }] }, ctx)), ["conformance.preserve_violated"]);
|
|
154
|
+
assert.deepEqual(codes(decide({ ...good, regressions: [{ at: "src/x.js:3", failure: "caller passes null" }] }, ctx)), ["conformance.regression"]);
|
|
155
|
+
});
|
|
156
|
+
it("CONFORMS with a failing row is nonconforming and flagged inconsistent; NONCONFORMING with no failing row is re-asked (review.inconsistent)", () => {
|
|
157
|
+
const d = decide(row("R1", { status: "partial" }), ctx);
|
|
158
|
+
assert.equal(d.state, "nonconforming"); assert.equal(d.inconsistent, true);
|
|
159
|
+
const i = decide({ ...good, verdict: "NONCONFORMING" }, ctx);
|
|
160
|
+
assert.equal(i.state, "review.inconsistent"); assert.deepEqual(codes(i), ["review.inconsistent"]);
|
|
161
|
+
});
|
|
162
|
+
it("a deferred requirement needs the reviewer's deferral_ok; false is an unjustified deferral", () => {
|
|
163
|
+
const k = { ...C, requirements: [C.requirements[0], { ...C.requirements[1], disposition: "defer", why: "w" }] };
|
|
164
|
+
const c2 = { ...ctx, contract: k };
|
|
165
|
+
const rv = (extra) => ({ ...good, requirements: [good.requirements[0], { id: "R2", status: "deferred", ...extra }] });
|
|
166
|
+
assert.equal(decide(rv({}), c2).state, "review.incomplete");
|
|
167
|
+
assert.equal(decide(rv({ deferral_ok: true }), c2).state, "conforms");
|
|
168
|
+
assert.deepEqual(codes(decide(rv({ deferral_ok: false, note: "two lines in the same file" }), c2)), ["conformance.unjustified_deferral"]);
|
|
169
|
+
});
|
|
170
|
+
it("short quotes are accepted only as a whole issue line or list item", () => {
|
|
171
|
+
const t = "Do this:\n- [ ] add --dry-run\n2. keep -v";
|
|
172
|
+
assert.ok(inIssue("add --dry-run", t)); assert.ok(inIssue("keep -v", t)); assert.ok(!inIssue("dry-run", t)); assert.ok(!inIssue("do", t));
|
|
173
|
+
});
|
|
174
|
+
it("add-requirement quotes are checked against the issue; an oversized contract is invalid", () => {
|
|
175
|
+
assert.equal(applyAmendments(C, [{ kind: "add-requirement", requirement: { id: "R9", quote: "something nobody asked for at all", disposition: "defer" }, why: "w" }], ISSUE).problems.length, 1);
|
|
176
|
+
assert.equal(applyAmendments(C, [{ kind: "add-requirement", requirement: { id: "R9", quote: "Billing pricing is unchanged." }, why: "w" }], ISSUE).problems.length, 0);
|
|
177
|
+
assert.match(validateContract({ ...C, deliverables: [...C.deliverables, { id: "DX", path: "x".repeat(41000) }] }, ISSUE).problems.join(), /over 40000 characters/);
|
|
178
|
+
});
|
|
179
|
+
it("checkCompleteness lists every problem", () => assert.ok(checkCompleteness({}, ctx).problems.length >= 5));
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe("close plan and follow-up items", () => {
|
|
183
|
+
it("links every tracking issue once; nothing tracked when everything is delivered", () => {
|
|
184
|
+
assert.deepEqual(closePlan(C), { reason: "completed", tracked: [] });
|
|
185
|
+
const k = { requirements: [{ id: "R1", disposition: "deliver" }, { id: "R2", disposition: "defer", tracked: "#9" }, { id: "R3", disposition: "post-deploy", tracked: "#9" }] };
|
|
186
|
+
assert.deepEqual(closePlan(k).tracked, ["#9"]);
|
|
187
|
+
});
|
|
188
|
+
it("follow-up keys are single-line, tab-free and stable", () => {
|
|
189
|
+
const [i] = followupItems([{ id: "R2", quote: "a\tb\n`c`", disposition: "defer", why: "x\ny" }]);
|
|
190
|
+
assert.equal(i.key, "R2: a b c"); assert.equal(i.why, "defer: x y"); assert.doesNotMatch(i.key + i.why, /[\t\n`]/);
|
|
191
|
+
});
|
|
192
|
+
});
|
package/bin/lean/effects.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
/** Core work-on: the real effects behind runCore's injected `io`. Argv only, no shell. */
|
|
4
4
|
import { execFile, execFileSync } from "node:child_process";
|
|
5
5
|
import { promisify } from "node:util";
|
|
6
|
-
import { readFileSync, appendFileSync, mkdirSync, statSync, existsSync, rmSync, writeFileSync, readdirSync, lstatSync } from "node:fs";
|
|
6
|
+
import { readFileSync, appendFileSync, mkdirSync, statSync, existsSync, rmSync, writeFileSync, readdirSync, lstatSync, readlinkSync, realpathSync } from "node:fs";
|
|
7
7
|
import { homedir, hostname, freemem, loadavg, cpus, tmpdir } from "node:os";
|
|
8
8
|
import { promises as fsp } from "node:fs";
|
|
9
9
|
import { dirname, join, resolve, sep } from "node:path";
|
|
@@ -11,9 +11,21 @@ import { fileURLToPath } from "node:url";
|
|
|
11
11
|
import { runCliBackendAsync, resolveClaudeCliBinary, getClaudeCliCapabilities, buildLeanSessionFlags, buildReadOnlyToolFlags } from "../runner.mjs";
|
|
12
12
|
import { makeIo } from "../engine-cli.mjs";
|
|
13
13
|
import { makeProjector } from "../engine/projector.mjs";
|
|
14
|
+
import { makeLedger } from "./ledger.mjs";
|
|
14
15
|
|
|
15
16
|
const pexec = promisify(execFile);
|
|
16
17
|
const script = (n) => fileURLToPath(new URL(`../../scripts/${n}`, import.meta.url));
|
|
18
|
+
/** Argv-only child with stdin input; resolves stdout, rejects on a non-zero exit with stderr attached. */
|
|
19
|
+
export function runWithInput(bin, args, input, timeout = 120000) {
|
|
20
|
+
return new Promise((res, rej) => {
|
|
21
|
+
const child = execFile(bin, args, { timeout, maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => {
|
|
22
|
+
if (err) { err.stdout = stdout; err.stderr = stderr; rej(err); } else res(String(stdout));
|
|
23
|
+
});
|
|
24
|
+
// A child that exits before reading stdin closes the pipe: EPIPE must reject via the exit callback, never crash the lane.
|
|
25
|
+
child.stdin.on("error", () => {});
|
|
26
|
+
child.stdin.end(input);
|
|
27
|
+
});
|
|
28
|
+
}
|
|
17
29
|
|
|
18
30
|
/** Claude CLI session runner: fresh session, or `--resume <id>` for the builder. Resolves usage + session_id. */
|
|
19
31
|
let systemdProbe = null;
|
|
@@ -34,18 +46,40 @@ export function wrapLaneLimits(bin, args, limits) {
|
|
|
34
46
|
}
|
|
35
47
|
|
|
36
48
|
export function makeLeanRunner({ runCli = runCliBackendAsync, probe = systemdRunAvailable } = {}) {
|
|
37
|
-
return async ({ role, issue, cwd, model, resume, system, prompt, readOnly = false, env = null, idleMs = 0, maxMs = 0, laneLimits = null, control = null }) => {
|
|
49
|
+
return async ({ role, issue, cwd, model, resume, system, prompt, readOnly = false, env = null, idleMs = 0, maxMs = 0, laneLimits = null, control = null, onSpawn = null }) => {
|
|
38
50
|
const bin = resolveClaudeCliBinary(cwd) || "claude";
|
|
39
51
|
const caps = getClaudeCliCapabilities(cwd);
|
|
40
52
|
const flags = [...buildLeanSessionFlags(caps), ...(readOnly ? buildReadOnlyToolFlags(caps) : [])];
|
|
41
53
|
const r = await runCli({ spec: { name: `core-${role}` }, userMessage: prompt, systemPrompt: resume ? "" : system,
|
|
42
54
|
args: [String(issue)], cwd, bin, model, env, sessionFlags: flags, extraArgs: resume ? ["--resume", resume] : [],
|
|
43
55
|
logger: { log() {} },
|
|
44
|
-
idleMs, maxMs, stream: idleMs > 0, control, wrap: laneLimits && probe() ? (b, a) => wrapLaneLimits(b, a, laneLimits) : null });
|
|
56
|
+
idleMs, maxMs, stream: idleMs > 0, control, onSpawn, wrap: laneLimits && probe() ? (b, a) => wrapLaneLimits(b, a, laneLimits) : null });
|
|
45
57
|
return { text: r.text, sessionId: r.sessionId, usage: r.usage, costUsd: r.costUsd, durationMs: r.durationMs, turns: r.turns, model: r.cliModel ?? "default" };
|
|
46
58
|
};
|
|
47
59
|
}
|
|
48
60
|
|
|
61
|
+
/** Parent pid from /proc/<pid>/stat (the field after the parenthesised command name); 0 when unreadable. */
|
|
62
|
+
const ppidOf = (root, pid) => { try { const st = readFileSync(join(root, String(pid), "stat"), "utf8"); return Number(st.slice(st.lastIndexOf(")") + 2).split(" ")[1]) || 0; } catch { return 0; } };
|
|
63
|
+
/**
|
|
64
|
+
* #4162: processes whose cwd is `dir` or inside it, from /proc (Linux). Excludes this process and its ancestors.
|
|
65
|
+
* Returns [{pid, cmd}]; [] where /proc is unavailable (the caller then skips stopping them).
|
|
66
|
+
*/
|
|
67
|
+
export function procsInDir(dir, { procRoot = "/proc", self = process.pid } = {}) {
|
|
68
|
+
if (!dir || !existsSync(procRoot)) return [];
|
|
69
|
+
try { dir = realpathSync(dir); } catch { /* not on disk: compare as given */ } // /proc cwd links are resolved paths
|
|
70
|
+
const skip = new Set();
|
|
71
|
+
for (let p = self, i = 0; p > 1 && i < 64 && !skip.has(p); i++) { skip.add(p); p = ppidOf(procRoot, p); }
|
|
72
|
+
const out = [];
|
|
73
|
+
for (const e of readdirSync(procRoot)) {
|
|
74
|
+
if (!/^\d+$/.test(e) || skip.has(Number(e))) continue;
|
|
75
|
+
let at; try { at = readlinkSync(join(procRoot, e, "cwd")); } catch { continue; }
|
|
76
|
+
if (at !== dir && !at.startsWith(`${dir}/`)) continue;
|
|
77
|
+
let cmd = ""; try { cmd = readFileSync(join(procRoot, e, "cmdline"), "utf8").split("\0").filter(Boolean).join(" ").slice(0, 200); } catch { /* exited */ }
|
|
78
|
+
out.push({ pid: Number(e), cmd });
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
|
|
49
83
|
export const RUN_TMP_MAX_AGE_MS = 6 * 3600 * 1000;
|
|
50
84
|
export const runTmpRoot = () => join(homedir(), ".forge", "tmp", "runs");
|
|
51
85
|
const OWNER = ".forge-run-owner";
|
|
@@ -55,6 +89,8 @@ const pidAlive = (pid) => { try { process.kill(pid, 0); return true; } catch (e)
|
|
|
55
89
|
export function makeRunTmp({ root = runTmpRoot(), now = () => Date.now(), alive = pidAlive, host = hostname() } = {}) {
|
|
56
90
|
const inside = (d) => { const r = resolve(d); return r.startsWith(resolve(root) + sep) && !r.slice(resolve(root).length + 1).includes(sep); };
|
|
57
91
|
return {
|
|
92
|
+
/** The dir make(issue, runId) creates, so a ledger can register it first (#4179). */
|
|
93
|
+
path: (runId) => join(root, String(runId)),
|
|
58
94
|
async make(issue, runId) {
|
|
59
95
|
const dir = join(root, String(runId));
|
|
60
96
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
@@ -89,6 +125,22 @@ export function makeRunTmp({ root = runTmpRoot(), now = () => Date.now(), alive
|
|
|
89
125
|
};
|
|
90
126
|
}
|
|
91
127
|
|
|
128
|
+
/**
|
|
129
|
+
* SIGTERM to a ledger-registered pid's process group (#4179). Only when the pid is alive and, where /proc exists, its
|
|
130
|
+
* command line still names `marker`, so a reused pid is never signalled. Returns true when the pid is gone or signalled.
|
|
131
|
+
*/
|
|
132
|
+
export function killLedgerPid(pid, marker, { kill = process.kill.bind(process), procDir = "/proc" } = {}) {
|
|
133
|
+
const n = Number(pid);
|
|
134
|
+
if (!Number.isSafeInteger(n) || n <= 1) return false;
|
|
135
|
+
if (!pidAlive(n)) return true;
|
|
136
|
+
if (marker && existsSync(procDir)) {
|
|
137
|
+
let cmd = "";
|
|
138
|
+
try { cmd = readFileSync(join(procDir, String(n), "cmdline"), "utf8"); } catch { return true; } // exited meanwhile
|
|
139
|
+
if (!cmd.includes(String(marker))) return true; // pid reused by something else: not ours any more
|
|
140
|
+
}
|
|
141
|
+
try { kill(-n, "SIGTERM"); return true; } catch { try { kill(n, "SIGTERM"); return true; } catch (e) { return e?.code === "ESRCH"; } }
|
|
142
|
+
}
|
|
143
|
+
|
|
92
144
|
/** Real host probes for admission (#4105). MemAvailable from /proc/meminfo (fallback os.freemem), statfs for tmp. */
|
|
93
145
|
export function makeHostProbe() {
|
|
94
146
|
return {
|
|
@@ -111,18 +163,34 @@ export function makeHostProbe() {
|
|
|
111
163
|
};
|
|
112
164
|
}
|
|
113
165
|
|
|
166
|
+
/**
|
|
167
|
+
* Required status checks for `lane` (#4158): the union of classic branch protection (contexts + checks) and the
|
|
168
|
+
* repository rulesets that apply to the branch (`rules/branches/{lane}`, type required_status_checks). Each source
|
|
169
|
+
* fails independently to [] (404, no protection, no rulesets, bad JSON), so one never hides the other.
|
|
170
|
+
*/
|
|
171
|
+
export async function requiredChecksFor({ gh, repo, lane }) {
|
|
172
|
+
const classic = async () => {
|
|
173
|
+
const j = JSON.parse(String(await gh(["api", `repos/${repo}/branches/${lane}/protection/required_status_checks`])));
|
|
174
|
+
return [...(j.contexts ?? []), ...(j.checks ?? []).map((c) => c?.context)];
|
|
175
|
+
};
|
|
176
|
+
const rulesets = async () => {
|
|
177
|
+
// --jq emits one context per line across all pages, so pagination needs no JSON stitching.
|
|
178
|
+
const out = String(await gh(["api", "--paginate", `repos/${repo}/rules/branches/${lane}`,
|
|
179
|
+
"--jq", '.[] | select(.type == "required_status_checks") | .parameters.required_status_checks[]?.context']));
|
|
180
|
+
return out.split("\n").map((x) => x.trim());
|
|
181
|
+
};
|
|
182
|
+
const safe = async (f) => { try { const v = await f(); return Array.isArray(v) ? v : []; } catch { return []; } };
|
|
183
|
+
const all = [...(await safe(classic)), ...(await safe(rulesets))];
|
|
184
|
+
return [...new Set(all.filter((x) => typeof x === "string" && x))];
|
|
185
|
+
}
|
|
186
|
+
|
|
114
187
|
export function makeLeanIo({ repo }) {
|
|
115
188
|
const base = makeIo();
|
|
116
189
|
const run = (bin) => async (args, opts = {}) => (await pexec(bin, args, { cwd: opts.cwd, maxBuffer: 100 * 1024 * 1024, timeout: 120000 })).stdout;
|
|
117
190
|
return {
|
|
118
|
-
gh: run("gh"), git: run("git"), runTmp: makeRunTmp(), host: makeHostProbe(), projector: makeProjector({ gh: base.gh }), runner: makeLeanRunner(), waitCiGreen: base.waitCiGreen,
|
|
119
|
-
/** Names of the checks the base branch requires
|
|
120
|
-
async requiredChecks(lane) {
|
|
121
|
-
try {
|
|
122
|
-
const j = JSON.parse(await run("gh")(["api", `repos/${repo}/branches/${lane}/protection/required_status_checks`]));
|
|
123
|
-
return [...new Set([...(j.contexts ?? []), ...(j.checks ?? []).map((c) => c.context)].filter(Boolean))];
|
|
124
|
-
} catch { return []; }
|
|
125
|
-
},
|
|
191
|
+
gh: run("gh"), git: run("git"), docker: run("docker"), killGroup: async (pid, marker) => killLedgerPid(pid, marker), ledger: (opts) => makeLedger(opts), runTmp: makeRunTmp(), host: makeHostProbe(), projector: makeProjector({ gh: base.gh }), runner: makeLeanRunner(), waitCiGreen: base.waitCiGreen,
|
|
192
|
+
/** Names of the checks the base branch requires: classic protection plus rulesets (#4158). [] when neither has any. */
|
|
193
|
+
async requiredChecks(lane) { return requiredChecksFor({ gh: run("gh"), repo, lane }); },
|
|
126
194
|
/** [{name, state: success|failure|pending}] for the PR's checks. */
|
|
127
195
|
async checkStates(pr) {
|
|
128
196
|
const j = JSON.parse(await run("gh")(["pr", "view", String(pr), "-R", repo, "--json", "statusCheckRollup"]));
|
|
@@ -132,13 +200,53 @@ export function makeLeanIo({ repo }) {
|
|
|
132
200
|
return { name: c.name ?? c.context, state };
|
|
133
201
|
});
|
|
134
202
|
},
|
|
203
|
+
/** #4167 verify-by-CI: `gh workflow run` on the lane branch; inputs become -f key=value. */
|
|
204
|
+
async dispatchWorkflow({ workflow, ref, inputs = {} }) {
|
|
205
|
+
await run("gh")(["workflow", "run", String(workflow), "-R", repo, "--ref", String(ref), ...Object.entries(inputs).flatMap(([k, v]) => ["-f", `${k}=${v}`])]);
|
|
206
|
+
},
|
|
207
|
+
/** Recent workflow_dispatch runs of `workflow` on `branch`, newest first: [{databaseId, headSha, status, conclusion, url, name}]. */
|
|
208
|
+
async workflowRuns({ workflow, branch }) {
|
|
209
|
+
return JSON.parse(await run("gh")(["run", "list", "-R", repo, "--workflow", String(workflow), "--branch", String(branch), "--event", "workflow_dispatch",
|
|
210
|
+
"--limit", "10", "--json", "databaseId,headSha,status,conclusion,url,name"]) || "[]");
|
|
211
|
+
},
|
|
212
|
+
/** Check runs on a commit (push-triggered CI): [{name, state: success|failure|pending, url}], same mapping as checkStates. */
|
|
213
|
+
async commitChecks(sha) {
|
|
214
|
+
const out = String(await run("gh")(["api", "--paginate", `repos/${repo}/commits/${sha}/check-runs`, "--jq", ".check_runs[] | {name, status, conclusion, url: .html_url} | @json"]));
|
|
215
|
+
return out.split("\n").filter((l) => l.trim()).map((l) => JSON.parse(l)).map((c) => {
|
|
216
|
+
const v = String(c.status === "completed" ? c.conclusion ?? "" : "").toUpperCase();
|
|
217
|
+
const state = ["SUCCESS", "NEUTRAL", "SKIPPED"].includes(v) ? "success" : ["FAILURE", "ERROR", "TIMED_OUT", "CANCELLED", "ACTION_REQUIRED", "STARTUP_FAILURE", "STALE"].includes(v) ? "failure" : "pending";
|
|
218
|
+
return { name: c.name, state, url: c.url ?? null };
|
|
219
|
+
});
|
|
220
|
+
},
|
|
221
|
+
/** Failed-job logs of an Actions run (`gh run view --log-failed`), tail-capped; "" when unreadable. */
|
|
222
|
+
async failedLogs(runId) {
|
|
223
|
+
try { const out = String(await run("gh")(["run", "view", String(runId), "-R", repo, "--log-failed"])); return out.length > 64000 ? out.slice(-64000) : out; } catch { return ""; }
|
|
224
|
+
},
|
|
135
225
|
async transitionLabel(issue, state) { await pexec("bash", [script("transition-label.sh"), String(issue), "-R", repo, state], { timeout: 60000 }); },
|
|
226
|
+
/**
|
|
227
|
+
* File (or append to) the parent's ONE batched follow-up issue (scripts/file-deferred-followups.sh: trusted lookup,
|
|
228
|
+
* append, create, claim marker, duplicate re-check). items: [{key, why}]. Returns {key: issueNumber}; throws on exit 3/4.
|
|
229
|
+
*/
|
|
230
|
+
async fileFollowups({ parent, items }) {
|
|
231
|
+
const tsv = items.map((i) => `${i.key}\t${i.why}\t`).join("\n") + "\n";
|
|
232
|
+
const out = await runWithInput("bash", [script("file-deferred-followups.sh"), "--parent", String(parent), "--repo", repo], tsv, 180000);
|
|
233
|
+
const map = {};
|
|
234
|
+
for (const l of out.split("\n")) { const [k, n] = l.split("\t"); if (k && /^\d+$/.test(String(n ?? "").trim())) map[k] = Number(n); }
|
|
235
|
+
return map;
|
|
236
|
+
},
|
|
237
|
+
/** forge#4159: scripts/structure-delta.sh over base...HEAD in cwd; null on any failure (never fails a run). */
|
|
238
|
+
async structureDelta({ cwd, base }) {
|
|
239
|
+
try { return JSON.parse((await pexec("bash", [script("structure-delta.sh"), `${base}...HEAD`], { cwd, timeout: 30000, maxBuffer: 16 * 1024 * 1024 })).stdout); } catch { return null; }
|
|
240
|
+
},
|
|
241
|
+
/** #4162: processes left running inside a lane worktree (Linux /proc). */
|
|
242
|
+
proc: { inDir: async (dir) => procsInDir(dir), alive: pidAlive, kill: (pid, sig) => { try { process.kill(pid, sig); } catch { /* already gone */ } } },
|
|
136
243
|
fs: {
|
|
137
244
|
read: async (p) => readFileSync(p, "utf8"),
|
|
138
245
|
exists: async (p) => existsSync(p),
|
|
139
246
|
rmdir: async (p) => rmSync(p, { recursive: true, force: true }),
|
|
140
247
|
stat: async (p) => { try { return { mtimeMs: statSync(p).mtimeMs }; } catch { return null; } },
|
|
141
248
|
append: async (p, t) => { mkdirSync(dirname(p), { recursive: true }); appendFileSync(p, t); },
|
|
249
|
+
write: async (p, t) => { mkdirSync(dirname(p), { recursive: true }); writeFileSync(p, t); },
|
|
142
250
|
},
|
|
143
251
|
};
|
|
144
252
|
}
|