forgedock 1.25.1 → 1.26.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 +12 -0
- package/bin/engine-cli.mjs +9 -2
- package/bin/lean/conformance.e2e.test.mjs +232 -0
- package/bin/lean/conformance.mjs +238 -0
- package/bin/lean/conformance.test.mjs +163 -0
- package/bin/lean/effects.mjs +23 -0
- package/bin/lean/lean.mjs +221 -51
- package/bin/lean/lean.test.mjs +452 -107
- package/bin/lean/prompts.mjs +60 -57
- package/bin/lean/render.mjs +51 -6
- package/docs/CONFIG.md +2 -1
- package/docs/OUTCOME-CODES.md +11 -0
- package/package.json +1 -1
- package/scripts/ci-local.test.sh +4 -1
|
@@ -0,0 +1,163 @@
|
|
|
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 } 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 a changed file, missing preserve row", () => {
|
|
99
|
+
assert.equal(decide({ ...good, requirements: good.requirements.slice(1) }, ctx).state, "review.incomplete");
|
|
100
|
+
assert.equal(decide({ ...good, scope: good.scope.slice(1) }, ctx).state, "review.incomplete");
|
|
101
|
+
assert.equal(decide({ ...good, preserve: [] }, ctx).state, "review.incomplete");
|
|
102
|
+
});
|
|
103
|
+
it("incomplete: implemented without path:line, with evidence in a nonexistent file, or without validation", () => {
|
|
104
|
+
assert.equal(decide(row("R1", { evidence: "looks fine" }), ctx).state, "review.incomplete");
|
|
105
|
+
assert.equal(decide(row("R1", { evidence: "src/nope.js:3" }), ctx).state, "review.incomplete");
|
|
106
|
+
assert.equal(decide(row("R1", { evidence: "src/billing.js:1" }), ctx).state, "conforms", "an existing unchanged file is valid evidence");
|
|
107
|
+
assert.equal(decide(row("R1", { validated_by: "none" }), ctx).state, "review.incomplete");
|
|
108
|
+
});
|
|
109
|
+
it("incomplete: a coverage quote paraphrased instead of verbatim", () => {
|
|
110
|
+
assert.equal(decide({ ...good, issue_coverage: [{ quote: "scrape should release money on rate limits", status: "covered" }] }, ctx).state, "review.incomplete");
|
|
111
|
+
});
|
|
112
|
+
it("omission: an issue requirement missing from or narrowed by the contract", () => {
|
|
113
|
+
const d = decide({ ...good, issue_coverage: [...good.issue_coverage.slice(0, 2), { quote: "Billing pricing is unchanged.", status: "missing" }] }, ctx);
|
|
114
|
+
assert.deepEqual(codes(d), ["conformance.omission"]);
|
|
115
|
+
});
|
|
116
|
+
it("contract mismatch: a deliver requirement partial or missing in the diff", () => {
|
|
117
|
+
assert.deepEqual(codes(decide(row("R2", { status: "missing", evidence: "src/search.js:5" }), ctx)), ["conformance.unimplemented"]);
|
|
118
|
+
assert.deepEqual(codes(decide(row("R2", { status: "deferred" }), ctx)), ["conformance.untracked_deferral"]);
|
|
119
|
+
});
|
|
120
|
+
it("scope creep, preserve violated or unverified, and regressions fail", () => {
|
|
121
|
+
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"]);
|
|
122
|
+
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");
|
|
123
|
+
assert.deepEqual(codes(decide({ ...good, preserve: [{ id: "P1", status: "violated", evidence: "src/billing.js:1" }] }, ctx)), ["conformance.preserve_violated"]);
|
|
124
|
+
assert.deepEqual(codes(decide({ ...good, preserve: [{ id: "P1", status: "unverified" }] }, ctx)), ["conformance.preserve_violated"]);
|
|
125
|
+
assert.deepEqual(codes(decide({ ...good, regressions: [{ at: "src/x.js:3", failure: "caller passes null" }] }, ctx)), ["conformance.regression"]);
|
|
126
|
+
});
|
|
127
|
+
it("CONFORMS with a failing row is nonconforming and flagged inconsistent; NONCONFORMING with no failing row is re-asked (review.inconsistent)", () => {
|
|
128
|
+
const d = decide(row("R1", { status: "partial" }), ctx);
|
|
129
|
+
assert.equal(d.state, "nonconforming"); assert.equal(d.inconsistent, true);
|
|
130
|
+
const i = decide({ ...good, verdict: "NONCONFORMING" }, ctx);
|
|
131
|
+
assert.equal(i.state, "review.inconsistent"); assert.deepEqual(codes(i), ["review.inconsistent"]);
|
|
132
|
+
});
|
|
133
|
+
it("a deferred requirement needs the reviewer's deferral_ok; false is an unjustified deferral", () => {
|
|
134
|
+
const k = { ...C, requirements: [C.requirements[0], { ...C.requirements[1], disposition: "defer", why: "w" }] };
|
|
135
|
+
const c2 = { ...ctx, contract: k };
|
|
136
|
+
const rv = (extra) => ({ ...good, requirements: [good.requirements[0], { id: "R2", status: "deferred", ...extra }] });
|
|
137
|
+
assert.equal(decide(rv({}), c2).state, "review.incomplete");
|
|
138
|
+
assert.equal(decide(rv({ deferral_ok: true }), c2).state, "conforms");
|
|
139
|
+
assert.deepEqual(codes(decide(rv({ deferral_ok: false, note: "two lines in the same file" }), c2)), ["conformance.unjustified_deferral"]);
|
|
140
|
+
});
|
|
141
|
+
it("short quotes are accepted only as a whole issue line or list item", () => {
|
|
142
|
+
const t = "Do this:\n- [ ] add --dry-run\n2. keep -v";
|
|
143
|
+
assert.ok(inIssue("add --dry-run", t)); assert.ok(inIssue("keep -v", t)); assert.ok(!inIssue("dry-run", t)); assert.ok(!inIssue("do", t));
|
|
144
|
+
});
|
|
145
|
+
it("add-requirement quotes are checked against the issue; an oversized contract is invalid", () => {
|
|
146
|
+
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);
|
|
147
|
+
assert.equal(applyAmendments(C, [{ kind: "add-requirement", requirement: { id: "R9", quote: "Billing pricing is unchanged." }, why: "w" }], ISSUE).problems.length, 0);
|
|
148
|
+
assert.match(validateContract({ ...C, deliverables: [...C.deliverables, { id: "DX", path: "x".repeat(41000) }] }, ISSUE).problems.join(), /over 40000 characters/);
|
|
149
|
+
});
|
|
150
|
+
it("checkCompleteness lists every problem", () => assert.ok(checkCompleteness({}, ctx).problems.length >= 5));
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe("close plan and follow-up items", () => {
|
|
154
|
+
it("links every tracking issue once; nothing tracked when everything is delivered", () => {
|
|
155
|
+
assert.deepEqual(closePlan(C), { reason: "completed", tracked: [] });
|
|
156
|
+
const k = { requirements: [{ id: "R1", disposition: "deliver" }, { id: "R2", disposition: "defer", tracked: "#9" }, { id: "R3", disposition: "post-deploy", tracked: "#9" }] };
|
|
157
|
+
assert.deepEqual(closePlan(k).tracked, ["#9"]);
|
|
158
|
+
});
|
|
159
|
+
it("follow-up keys are single-line, tab-free and stable", () => {
|
|
160
|
+
const [i] = followupItems([{ id: "R2", quote: "a\tb\n`c`", disposition: "defer", why: "x\ny" }]);
|
|
161
|
+
assert.equal(i.key, "R2: a b c"); assert.equal(i.why, "defer: x y"); assert.doesNotMatch(i.key + i.why, /[\t\n`]/);
|
|
162
|
+
});
|
|
163
|
+
});
|
package/bin/lean/effects.mjs
CHANGED
|
@@ -14,6 +14,17 @@ import { makeProjector } from "../engine/projector.mjs";
|
|
|
14
14
|
|
|
15
15
|
const pexec = promisify(execFile);
|
|
16
16
|
const script = (n) => fileURLToPath(new URL(`../../scripts/${n}`, import.meta.url));
|
|
17
|
+
/** Argv-only child with stdin input; resolves stdout, rejects on a non-zero exit with stderr attached. */
|
|
18
|
+
export function runWithInput(bin, args, input, timeout = 120000) {
|
|
19
|
+
return new Promise((res, rej) => {
|
|
20
|
+
const child = execFile(bin, args, { timeout, maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => {
|
|
21
|
+
if (err) { err.stdout = stdout; err.stderr = stderr; rej(err); } else res(String(stdout));
|
|
22
|
+
});
|
|
23
|
+
// A child that exits before reading stdin closes the pipe: EPIPE must reject via the exit callback, never crash the lane.
|
|
24
|
+
child.stdin.on("error", () => {});
|
|
25
|
+
child.stdin.end(input);
|
|
26
|
+
});
|
|
27
|
+
}
|
|
17
28
|
|
|
18
29
|
/** Claude CLI session runner: fresh session, or `--resume <id>` for the builder. Resolves usage + session_id. */
|
|
19
30
|
let systemdProbe = null;
|
|
@@ -133,12 +144,24 @@ export function makeLeanIo({ repo }) {
|
|
|
133
144
|
});
|
|
134
145
|
},
|
|
135
146
|
async transitionLabel(issue, state) { await pexec("bash", [script("transition-label.sh"), String(issue), "-R", repo, state], { timeout: 60000 }); },
|
|
147
|
+
/**
|
|
148
|
+
* File (or append to) the parent's ONE batched follow-up issue (scripts/file-deferred-followups.sh: trusted lookup,
|
|
149
|
+
* append, create, claim marker, duplicate re-check). items: [{key, why}]. Returns {key: issueNumber}; throws on exit 3/4.
|
|
150
|
+
*/
|
|
151
|
+
async fileFollowups({ parent, items }) {
|
|
152
|
+
const tsv = items.map((i) => `${i.key}\t${i.why}\t`).join("\n") + "\n";
|
|
153
|
+
const out = await runWithInput("bash", [script("file-deferred-followups.sh"), "--parent", String(parent), "--repo", repo], tsv, 180000);
|
|
154
|
+
const map = {};
|
|
155
|
+
for (const l of out.split("\n")) { const [k, n] = l.split("\t"); if (k && /^\d+$/.test(String(n ?? "").trim())) map[k] = Number(n); }
|
|
156
|
+
return map;
|
|
157
|
+
},
|
|
136
158
|
fs: {
|
|
137
159
|
read: async (p) => readFileSync(p, "utf8"),
|
|
138
160
|
exists: async (p) => existsSync(p),
|
|
139
161
|
rmdir: async (p) => rmSync(p, { recursive: true, force: true }),
|
|
140
162
|
stat: async (p) => { try { return { mtimeMs: statSync(p).mtimeMs }; } catch { return null; } },
|
|
141
163
|
append: async (p, t) => { mkdirSync(dirname(p), { recursive: true }); appendFileSync(p, t); },
|
|
164
|
+
write: async (p, t) => { mkdirSync(dirname(p), { recursive: true }); writeFileSync(p, t); },
|
|
142
165
|
},
|
|
143
166
|
};
|
|
144
167
|
}
|