forgedock 1.33.0 → 1.35.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-cli.mjs +72 -12
- package/bin/forgedock.mjs +19 -1
- package/bin/hooks/reread-nudge.mjs +33 -5
- package/bin/lean/batch.mjs +24 -1
- package/bin/lean/batch.test.mjs +75 -0
- package/bin/lean/budget.mjs +14 -3
- package/bin/lean/budget.test.mjs +11 -1
- package/bin/lean/conflictres.mjs +93 -0
- package/bin/lean/conflictres.test.mjs +93 -0
- package/bin/lean/conformance.mjs +39 -6
- package/bin/lean/conformance.test.mjs +46 -0
- package/bin/lean/effects.mjs +11 -3
- package/bin/lean/lane.mjs +13 -0
- package/bin/lean/lean.mjs +111 -33
- package/bin/lean/lean.test.mjs +197 -5
- package/bin/lean/orchestrate.mjs +81 -8
- package/bin/lean/precheck.mjs +56 -8
- package/bin/lean/precheck.test.mjs +62 -3
- package/bin/lean/prompts.mjs +28 -6
- package/bin/lean/schedd.mjs +10 -1
- package/bin/lean/schedd.test.mjs +30 -0
- package/bin/lean/sequence.mjs +26 -0
- package/bin/lean/sequence.test.mjs +41 -2
- package/bin/lean/toolstate.mjs +77 -0
- package/bin/lean/toolstate.test.mjs +83 -0
- package/bin/lean/verify.mjs +18 -2
- package/bin/lean/verify.test.mjs +29 -3
- package/bin/review-staging.mjs +14 -2
- package/bin/stats/efficiency.mjs +103 -17
- package/bin/stats/efficiency.test.mjs +103 -4
- package/bin/stats/index.mjs +27 -5
- package/bin/stats/pool.mjs +2 -2
- package/commands/review-pr-agents/protocols.md +1 -1
- package/commands/review-pr-staging.md +1 -1
- package/docs/CONFIG.md +12 -2
- package/docs/STATS.md +29 -1
- package/package.json +1 -1
- package/scripts/review-finalize.sh +17 -1
- package/scripts/review-finalize.test.sh +41 -0
- package/scripts/review-pr-domain-map.test.sh +2 -2
- package/scripts/review-pr-staging-scope.test.sh +4 -2
- package/scripts/review-prepare.sh +62 -27
- package/scripts/review-prepare.test.sh +53 -0
- package/scripts/review-staging-pre.sh +29 -9
- package/scripts/review-staging-pre.test.sh +18 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
// #4588: deterministic base-merge conflict resolution, on real temp git repos.
|
|
4
|
+
import { describe, it } from "node:test";
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import { execFileSync } from "node:child_process";
|
|
7
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { dirname, join } from "node:path";
|
|
9
|
+
import { tmpDir } from "../tests/_tmp.mjs";
|
|
10
|
+
import { readBaseMerge } from "../engine-cli.mjs";
|
|
11
|
+
import { resolveBaseConflicts, jsonUnionText } from "./conflictres.mjs";
|
|
12
|
+
|
|
13
|
+
const sh = (cwd, ...a) => execFileSync("git", ["-c", "user.name=t", "-c", "user.email=t@t", ...a], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
|
|
14
|
+
const put = (root, files) => { for (const [p, t] of Object.entries(files)) { mkdirSync(dirname(join(root, p)), { recursive: true }); writeFileSync(join(root, p), t); } };
|
|
15
|
+
const commit = (d, m) => { sh(d, "add", "-A"); sh(d, "commit", "-qm", m); };
|
|
16
|
+
|
|
17
|
+
/** A repo whose lane and main both edited `files`; the merge of main into the lane is left in progress. */
|
|
18
|
+
function conflicted(root, start, lane, main) {
|
|
19
|
+
sh(root, "init", "-q", "-b", "main"); put(root, start); commit(root, "base");
|
|
20
|
+
sh(root, "checkout", "-qb", "lane"); put(root, lane); commit(root, "lane");
|
|
21
|
+
sh(root, "checkout", "-q", "main"); put(root, main); commit(root, "main");
|
|
22
|
+
sh(root, "checkout", "-q", "lane");
|
|
23
|
+
try { sh(root, "merge", "--no-edit", "main"); } catch { /* conflicts */ }
|
|
24
|
+
return Object.keys(start).filter((f) => sh(root, "diff", "--name-only", "--diff-filter=U").split("\n").includes(f));
|
|
25
|
+
}
|
|
26
|
+
const env = (root) => ({ git: async (a) => sh(root, ...a), cwd: root });
|
|
27
|
+
|
|
28
|
+
describe("resolveBaseConflicts (#4588)", () => {
|
|
29
|
+
it("union: two lanes appending to one barrel keep both lines", async () => {
|
|
30
|
+
const root = tmpDir("cr-union"); const files = conflicted(root, { "index.ts": "a\n" }, { "index.ts": "a\nlane\n" }, { "index.ts": "a\nmain\n" });
|
|
31
|
+
const r = await resolveBaseConflicts({ ...env(root), files, cfg: { unionGlobs: ["index.ts"] } });
|
|
32
|
+
assert.deepEqual(r, { resolved: ["index.ts"], remaining: [] });
|
|
33
|
+
assert.equal(readFileSync(join(root, "index.ts"), "utf8"), "a\nlane\nmain\n");
|
|
34
|
+
sh(root, "commit", "--no-edit"); // all staged: the merge commits
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("union: a hunk that edits a base line is refused and stays conflicted", async () => {
|
|
38
|
+
const root = tmpDir("cr-union-edit"); const files = conflicted(root, { "index.ts": "a\n" }, { "index.ts": "x\n" }, { "index.ts": "y\n" });
|
|
39
|
+
assert.deepEqual(await resolveBaseConflicts({ ...env(root), files, cfg: { unionGlobs: ["index.ts"] } }), { resolved: [], remaining: ["index.ts"] });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("json: key union of a status map; the lane's indent is kept", async () => {
|
|
43
|
+
const root = tmpDir("cr-json");
|
|
44
|
+
const files = conflicted(root, { "status.json": '{\n "a": 1\n}\n' }, { "status.json": '{\n "a": 1,\n "lane": 2\n}\n' }, { "status.json": '{\n "a": 1,\n "main": 3\n}\n' });
|
|
45
|
+
const r = await resolveBaseConflicts({ ...env(root), files, cfg: { jsonGlobs: ["status.json"] } });
|
|
46
|
+
assert.deepEqual(r.resolved, ["status.json"]);
|
|
47
|
+
assert.equal(readFileSync(join(root, "status.json"), "utf8"), '{\n "a": 1,\n "lane": 2,\n "main": 3\n}\n');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("json: the same key changed differently on both sides is refused", async () => {
|
|
51
|
+
const root = tmpDir("cr-json-clash");
|
|
52
|
+
const files = conflicted(root, { "status.json": '{"a":1}\n' }, { "status.json": '{"a":2}\n' }, { "status.json": '{"a":3}\n' });
|
|
53
|
+
assert.deepEqual(await resolveBaseConflicts({ ...env(root), files, cfg: { jsonGlobs: ["status.json"] } }), { resolved: [], remaining: ["status.json"] });
|
|
54
|
+
assert.throws(() => jsonUnionText('[1]', '[2]', '[3]'));
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("generated: the base side is taken and the command regenerates the file", async () => {
|
|
58
|
+
const root = tmpDir("cr-gen");
|
|
59
|
+
const files = conflicted(root, { "gen/out.txt": "v0\n", "src.txt": "s\n" }, { "gen/out.txt": "lane\n" }, { "gen/out.txt": "main\n" });
|
|
60
|
+
const r = await resolveBaseConflicts({ ...env(root), files, cfg: { generatedGlobs: ["gen/*"], generatedCommand: "echo regenerated > gen/out.txt" } });
|
|
61
|
+
assert.deepEqual(r, { resolved: ["gen/out.txt"], remaining: [] });
|
|
62
|
+
assert.equal(readFileSync(join(root, "gen/out.txt"), "utf8"), "regenerated\n");
|
|
63
|
+
assert.equal(sh(root, "diff", "--name-only", "--diff-filter=U"), "");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("generated: a failing command leaves the file conflicted", async () => {
|
|
67
|
+
const root = tmpDir("cr-gen-fail");
|
|
68
|
+
const files = conflicted(root, { "gen/out.txt": "v0\n" }, { "gen/out.txt": "lane\n" }, { "gen/out.txt": "main\n" });
|
|
69
|
+
const r = await resolveBaseConflicts({ ...env(root), files, cfg: { generatedGlobs: ["gen/*"], generatedCommand: "exit 3" } });
|
|
70
|
+
assert.deepEqual(r.resolved, []);
|
|
71
|
+
assert.deepEqual(r.remaining, ["gen/out.txt"]);
|
|
72
|
+
assert.match(r.note, /generated command failed/);
|
|
73
|
+
assert.match(readFileSync(join(root, "gen/out.txt"), "utf8"), /<<<<<<</);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("sequence files and unconfigured files are left alone", async () => {
|
|
77
|
+
const root = tmpDir("cr-skip");
|
|
78
|
+
const files = conflicted(root, { "db/meta/_journal.json": '{"a":1}\n', "x.ts": "a\n" }, { "db/meta/_journal.json": '{"a":1,"l":1}\n', "x.ts": "a\nl\n" }, { "db/meta/_journal.json": '{"a":1,"m":1}\n', "x.ts": "a\nm\n" });
|
|
79
|
+
const r = await resolveBaseConflicts({ ...env(root), files, cfg: { jsonGlobs: ["*.json"] } });
|
|
80
|
+
assert.deepEqual(r.resolved, []);
|
|
81
|
+
assert.deepEqual(r.remaining.sort(), ["db/meta/_journal.json", "x.ts"]);
|
|
82
|
+
assert.deepEqual(await resolveBaseConflicts({ ...env(root), files, cfg: {} }), { resolved: [], remaining: files });
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe("readBaseMerge (#4588)", () => {
|
|
87
|
+
it("reads lean.base_merge_* and is undefined when no glob is set", () => {
|
|
88
|
+
const root = tmpDir("cr-cfg");
|
|
89
|
+
assert.equal(readBaseMerge(root), undefined);
|
|
90
|
+
writeFileSync(join(root, "forge.yaml"), 'lean:\n base_merge_union_globs: "index.ts, **/messages.ts"\n base_merge_json_globs: "status.json"\n base_merge_generated_globs: "gen/**"\n base_merge_generated_command: "pnpm gen"\n');
|
|
91
|
+
assert.deepEqual(readBaseMerge(root), { unionGlobs: ["index.ts", "**/messages.ts"], jsonGlobs: ["status.json"], generatedGlobs: ["gen/**"], generatedCommand: "pnpm gen" });
|
|
92
|
+
});
|
|
93
|
+
});
|
package/bin/lean/conformance.mjs
CHANGED
|
@@ -57,11 +57,13 @@ export function scopeText(iss, trustedComments = []) {
|
|
|
57
57
|
/** #4265: failures whose detail starts with a contract requirement id also quote that requirement, so a needs-human names what is unmet. */
|
|
58
58
|
export function explainFailures(failures, contract) {
|
|
59
59
|
const reqs = new Map((contract?.requirements ?? []).map((r) => [String(r?.id ?? ""), String(r?.quote ?? "")]));
|
|
60
|
+
const amended = new Map((contract?.requirements ?? []).filter((r) => r?.amendment?.text).map((r) => [String(r.id), String(r.amendment.text)]));
|
|
60
61
|
return (failures ?? []).map((f) => {
|
|
61
62
|
const id = /^([A-Za-z][\w-]*)\b/.exec(String(f?.detail ?? ""))?.[1];
|
|
62
63
|
const q = id && reqs.get(id);
|
|
63
64
|
if (!q) return f;
|
|
64
|
-
|
|
65
|
+
const am = amended.get(id);
|
|
66
|
+
return { ...f, detail: `${f.detail} ("${q.length > 160 ? `${q.slice(0, 157)}...` : q}"${am ? `, amended to "${am.length > 160 ? `${am.slice(0, 157)}...` : am}"` : ""})` };
|
|
65
67
|
});
|
|
66
68
|
}
|
|
67
69
|
export const isTestPath = (p) => TEST_RE.test(String(p));
|
|
@@ -264,6 +266,29 @@ export function validateContract(c, issueText, { repro = false } = {}) {
|
|
|
264
266
|
/** forge#4519: a shell pattern as in scripts/hotspots.sh `case` (`*` crosses `/`, `?` one character), anchored. */
|
|
265
267
|
const shellGlobRe = (g) => new RegExp(`^${String(g).replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".")}$`);
|
|
266
268
|
|
|
269
|
+
const AMEND_KINDS = ["add-deliverable", "add-requirement", ...TRACKED, "amend-requirement"];
|
|
270
|
+
const AMEND_SLIPS = { "amend-requirement": "defer", limitation: "defer" };
|
|
271
|
+
|
|
272
|
+
/** forge#4574: common builder slips: `type` for `kind`, amend-requirement/limitation as defer, an `R#` in `id` as the requirement. */
|
|
273
|
+
function normalizeAmendment(a) {
|
|
274
|
+
if (!a || typeof a !== "object" || Array.isArray(a)) return a;
|
|
275
|
+
const n = { ...a };
|
|
276
|
+
if (blank(n.kind) && !blank(n.type)) n.kind = n.type;
|
|
277
|
+
// #4594: amend-requirement with a revised `text` is a real amendment; without one it stays the slip for defer
|
|
278
|
+
if (Object.hasOwn(AMEND_SLIPS, n.kind) && !(n.kind === "amend-requirement" && !blank(n.text))) n.kind = AMEND_SLIPS[n.kind];
|
|
279
|
+
if (TRACKED.includes(n.kind) && blank(n.requirement) && /^R\d+$/.test(String(n.id ?? ""))) n.requirement = n.id;
|
|
280
|
+
return n;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** The rejection reason, naming the valid kinds or the missing field so the builder's correction round can fix it. */
|
|
284
|
+
function amendmentProblem(a, tag) {
|
|
285
|
+
const kind = a?.kind;
|
|
286
|
+
const need = kind === "add-deliverable" ? "needs a path" : kind === "add-requirement" ? "needs requirement {id, quote}"
|
|
287
|
+
: TRACKED.includes(kind) ? "needs requirement: \"R#\"" : kind === "amend-requirement" ? "needs requirement: \"R#\" and text (the revised wording)" : null;
|
|
288
|
+
const why = need ? `amendment kind '${kind}' ${need}` : `unknown kind '${kind ?? ""}'; valid kinds are ${AMEND_KINDS.join(", ")}`;
|
|
289
|
+
return `unknown or incomplete amendment: ${why}: ${tag}`;
|
|
290
|
+
}
|
|
291
|
+
|
|
267
292
|
/**
|
|
268
293
|
* Apply result.json amendments to the frozen contract. Kinds: add-deliverable {path}, add-requirement {requirement},
|
|
269
294
|
* defer|post-deploy {requirement: id}. Every amendment needs a why. Returns {contract, applied[], toTrack[], problems[]}.
|
|
@@ -275,8 +300,9 @@ export function applyAmendments(frozen, amendments, issueText = null, { generate
|
|
|
275
300
|
c.deliverables = c.deliverables ?? [];
|
|
276
301
|
const applied = [], toTrack = [], problems = [];
|
|
277
302
|
const genRes = (Array.isArray(generatedPaths) ? generatedPaths : []).filter((g) => typeof g === "string" && g).map(shellGlobRe);
|
|
278
|
-
for (const
|
|
279
|
-
const
|
|
303
|
+
for (const raw of Array.isArray(amendments) ? amendments : []) {
|
|
304
|
+
const a = normalizeAmendment(raw);
|
|
305
|
+
const tag = JSON.stringify(raw ?? null).slice(0, 400);
|
|
280
306
|
if (blank(a?.why)) { problems.push(`amendment without why: ${tag}`); continue; }
|
|
281
307
|
if (a.kind === "add-deliverable" && !blank(a.path)) {
|
|
282
308
|
if (genRes.some((re) => re.test(cleanPath(a.path)))) {
|
|
@@ -297,11 +323,16 @@ export function applyAmendments(frozen, amendments, issueText = null, { generate
|
|
|
297
323
|
if (tagsOutOfBand(r)) r.verify = "out_of_band"; // #4404: same freeze-time tag as the planned requirements
|
|
298
324
|
c.requirements.push(r);
|
|
299
325
|
if (TRACKED.includes(r.disposition)) toTrack.push(r);
|
|
326
|
+
} else if (a.kind === "amend-requirement" && !blank(a.requirement)) {
|
|
327
|
+
// #4594: the requirement stays deliver and keeps its verbatim quote; the reviewer judges it against amendment.text
|
|
328
|
+
const r = c.requirements.find((x) => x.id === a.requirement);
|
|
329
|
+
if (!r) { problems.push(`amendment names unknown requirement ${a.requirement}`); continue; }
|
|
330
|
+
r.amendment = { text: String(a.text).trim(), why: String(a.why).trim() }; r.amended = true;
|
|
300
331
|
} else if (TRACKED.includes(a.kind) && a.requirement) {
|
|
301
332
|
const r = c.requirements.find((x) => x.id === a.requirement);
|
|
302
333
|
if (!r) { problems.push(`amendment names unknown requirement ${a.requirement}`); continue; }
|
|
303
334
|
if (!TRACKED.includes(r.disposition)) { r.disposition = a.kind; r.why = a.why; r.amended = true; toTrack.push(r); }
|
|
304
|
-
} else { problems.push(
|
|
335
|
+
} else { problems.push(amendmentProblem(a, tag)); continue; }
|
|
305
336
|
applied.push({ kind: a.kind, target: a.path ?? a.requirement?.id ?? a.requirement, why: String(a.why).trim() });
|
|
306
337
|
}
|
|
307
338
|
return { contract: c, applied, toTrack, problems };
|
|
@@ -602,14 +633,16 @@ export function decide(raw, ctx) {
|
|
|
602
633
|
const comp = checkCompleteness(rv, ctx);
|
|
603
634
|
if (!comp.complete) return { state: "review.incomplete", failures: comp.problems.map((d) => ({ code: C.REVIEW_INCOMPLETE, detail: d })), missing: comp.missing, review: rv };
|
|
604
635
|
const f = [];
|
|
605
|
-
|
|
636
|
+
const amendedOf = (ids) => (ctx.contract.requirements ?? []).find((r) => r.amendment?.text && (ids ?? []).includes(r.id));
|
|
637
|
+
const notAccepted = (r) => (r ? `amendment not accepted (${r.id} amended to "${String(r.amendment.text).slice(0, 200)}"): ` : "");
|
|
638
|
+
for (const c of rv.issue_coverage) if (c.status !== "covered") f.push({ code: C.CONFORMANCE_OMISSION, detail: `${notAccepted(amendedOf(c.contract_ids))}issue requirement ${c.status}: "${c.quote}"${c.note ? ` (${c.note})` : ""}` });
|
|
606
639
|
for (const r of ctx.contract.requirements) {
|
|
607
640
|
const row = rv.requirements.find((x) => x.id === r.id);
|
|
608
641
|
if (r.disposition === "deliver" && isOutOfBand(r)) {
|
|
609
642
|
// #4404: the measured result lives in the named job; only the instrumentation is judged here
|
|
610
643
|
if (row.instrumented !== true) f.push({ code: C.CONFORMANCE_UNIMPLEMENTED, detail: `${r.id} verified out of band${r.evidence ? ` by ${r.evidence}` : ""}: instrumentation missing${row.note ? `: ${row.note}` : ""}${row.evidence ? ` (${row.evidence})` : ""}` });
|
|
611
644
|
} else if (r.disposition === "deliver" && row.status !== "implemented") {
|
|
612
|
-
f.push({ code: ["deferred", "post-deploy"].includes(row.status) ? C.CONFORMANCE_UNTRACKED_DEFERRAL : C.CONFORMANCE_UNIMPLEMENTED, detail: `${r.id} ${row.status}${row.note ? `: ${row.note}` : ""}${row.evidence ? ` (${row.evidence})` : ""}` });
|
|
645
|
+
f.push({ code: ["deferred", "post-deploy"].includes(row.status) ? C.CONFORMANCE_UNTRACKED_DEFERRAL : C.CONFORMANCE_UNIMPLEMENTED, detail: `${r.amendment?.text ? `${r.id} amendment not accepted (amended to "${String(r.amendment.text).slice(0, 200)}"): ` : `${r.id} `}${row.status}${row.note ? `: ${row.note}` : ""}${row.evidence ? ` (${row.evidence})` : ""}` });
|
|
613
646
|
}
|
|
614
647
|
if (TRACKED.includes(r.disposition) && row.deferral_ok === false) f.push({ code: C.CONFORMANCE_UNJUSTIFIED_DEFERRAL, detail: `${r.id} ${r.disposition} is not justified${row.note ? `: ${row.note}` : ""}; implement it` });
|
|
615
648
|
}
|
|
@@ -60,6 +60,27 @@ describe("amendments", () => {
|
|
|
60
60
|
});
|
|
61
61
|
});
|
|
62
62
|
|
|
63
|
+
describe("#4574 amendment slips", () => {
|
|
64
|
+
it("type for kind, amend-requirement and limitation apply as defer; an R# id fills requirement", () => {
|
|
65
|
+
const a = applyAmendments(C, [{ kind: "amend-requirement", id: "R2", why: "stored hashed" }, { id: "A1", requirement: "R1", type: "limitation", why: "plugin limit" }]);
|
|
66
|
+
assert.deepEqual(a.problems, []);
|
|
67
|
+
assert.deepEqual(a.toTrack.map((r) => r.id).sort(), ["R1", "R2"]);
|
|
68
|
+
assert.ok(a.contract.requirements.every((r) => r.disposition === "defer"));
|
|
69
|
+
});
|
|
70
|
+
it("an unknown kind names the valid kinds and shows the full amendment", () => {
|
|
71
|
+
const why = "x".repeat(300);
|
|
72
|
+
const a = applyAmendments(C, [{ kind: "rewrite", why }]);
|
|
73
|
+
assert.match(a.problems[0], /unknown kind 'rewrite'; valid kinds are add-deliverable, add-requirement, defer, post-deploy/);
|
|
74
|
+
assert.ok(a.problems[0].includes(why));
|
|
75
|
+
});
|
|
76
|
+
it("an Object.prototype key as kind is an unknown kind, not a slip", () => {
|
|
77
|
+
assert.match(applyAmendments(C, [{ kind: "constructor", why: "w" }]).problems[0], /unknown kind 'constructor'; valid kinds are add-deliverable/);
|
|
78
|
+
});
|
|
79
|
+
it("an incomplete known kind names the missing field", () => {
|
|
80
|
+
assert.match(applyAmendments(C, [{ kind: "defer", why: "w" }]).problems[0], /'defer' needs requirement/);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
63
84
|
describe("precheck (deterministic, no reviewer)", () => {
|
|
64
85
|
const res = { requirements: [{ id: "R1", status: "implemented" }, { id: "R2", status: "implemented" }], validation: [{ id: "V1", exit: 0 }] };
|
|
65
86
|
it("passes a complete, validated, in-scope change", () => assert.deepEqual(precheck({ contract: C, result: res, changedFiles: files }), { ok: true, failures: [], superseded: [], ciCovered: [] }));
|
|
@@ -624,3 +645,28 @@ describe("#4404 verified out of band: a requirement measured outside the lane is
|
|
|
624
645
|
assert.deepEqual(outOfBandRequirements(tagged), [{ id: "R3", quote: LH, job: "lighthouse-nightly" }]);
|
|
625
646
|
});
|
|
626
647
|
});
|
|
648
|
+
|
|
649
|
+
describe("#4594 amend-requirement", () => {
|
|
650
|
+
const am = { kind: "amend-requirement", requirement: "R1", text: "regenerate and reveal the feed URL once", why: "token stored hash-only" };
|
|
651
|
+
it("with text it keeps deliver, tracks nothing and records the amendment", () => {
|
|
652
|
+
const a = applyAmendments(C, [am]);
|
|
653
|
+
assert.deepEqual(a.problems, []); assert.deepEqual(a.toTrack, []);
|
|
654
|
+
const r = a.contract.requirements.find((x) => x.id === "R1");
|
|
655
|
+
assert.equal(r.disposition, "deliver"); assert.equal(r.amendment.text, am.text); assert.equal(r.amendment.why, am.why); assert.equal(r.quote, C.requirements[0].quote);
|
|
656
|
+
});
|
|
657
|
+
it("an unknown requirement is a problem; the frozen contract is untouched", () => {
|
|
658
|
+
assert.match(applyAmendments(C, [{ ...am, requirement: "R9" }]).problems[0], /unknown requirement R9/);
|
|
659
|
+
assert.equal(C.requirements[0].amendment, undefined);
|
|
660
|
+
});
|
|
661
|
+
it("decide: an amended requirement the reviewer rejects fails as 'amendment not accepted'; accepted conforms", () => {
|
|
662
|
+
const k = { ...ctx, contract: applyAmendments(C, [am]).contract };
|
|
663
|
+
assert.equal(decide(good, k).state, "conforms");
|
|
664
|
+
const d = decide({ ...row("R1", { status: "partial", note: "amendment: still never shows the URL" }), issue_coverage: good.issue_coverage.map((c) => (c.quote === Q1 ? { ...c, contract_ids: ["R1"], status: "narrowed" } : c)) }, k);
|
|
665
|
+
assert.equal(d.state, "nonconforming");
|
|
666
|
+
assert.ok(d.failures.length >= 2 && d.failures.every((f) => /amendment not accepted \(.*amended to "regenerate/.test(f.detail)));
|
|
667
|
+
});
|
|
668
|
+
it("explainFailures shows the amended text", () => {
|
|
669
|
+
const c = applyAmendments(C, [am]).contract;
|
|
670
|
+
assert.match(explainFailures([{ code: "x", detail: "R1 partial" }], c)[0].detail, /amended to "regenerate and reveal/);
|
|
671
|
+
});
|
|
672
|
+
});
|
package/bin/lean/effects.mjs
CHANGED
|
@@ -17,7 +17,8 @@ import { makeHeavyIo } from "./heavy.mjs";
|
|
|
17
17
|
import { gatherContextFacts } from "./context.mjs";
|
|
18
18
|
import { makePitfallStore } from "./pitfalls.mjs";
|
|
19
19
|
import { enumeratingTests, missingEntries } from "./enumerating.mjs";
|
|
20
|
-
import {
|
|
20
|
+
import { resolveBaseConflicts } from "./conflictres.mjs";
|
|
21
|
+
import { sequenceCollisions, snapshotLane, verifyRenumber, renumberLane, resolveSequenceConflicts, generatorProblems } from "./sequence.mjs";
|
|
21
22
|
import { duplicateExports } from "./postmerge.mjs";
|
|
22
23
|
import { scopedChecks } from "./checks.mjs";
|
|
23
24
|
import { makeSequenceLeaseIo } from "./seqlease.mjs";
|
|
@@ -63,8 +64,11 @@ export const noToolFlags = (caps) => (caps?.strictMcp ? ["--tools", ""]
|
|
|
63
64
|
|
|
64
65
|
/** #4456: `--settings` registering the re-read nudge (bin/hooks/reread-nudge.mjs) as a PostToolUse hook on Read and Bash. */
|
|
65
66
|
export const REREAD_HOOK = fileURLToPath(new URL("../hooks/reread-nudge.mjs", import.meta.url));
|
|
66
|
-
export const rereadNudgeFlags = (node = process.execPath, hook = REREAD_HOOK) =>
|
|
67
|
-
hooks
|
|
67
|
+
export const rereadNudgeFlags = (node = process.execPath, hook = REREAD_HOOK) => {
|
|
68
|
+
const hooks = [{ type: "command", command: `${JSON.stringify(node)} ${JSON.stringify(hook)}` }];
|
|
69
|
+
// #4580: the same script denies a full-file Read of an unchanged path already read twice (PreToolUse)
|
|
70
|
+
return ["--settings", JSON.stringify({ hooks: { PostToolUse: [{ matcher: "Read|Bash", hooks }], PreToolUse: [{ matcher: "Read", hooks }] } })];
|
|
71
|
+
};
|
|
68
72
|
|
|
69
73
|
export function makeLeanRunner({ runCli = runCliBackendAsync, probe = systemdRunAvailable } = {}) {
|
|
70
74
|
return async ({ role, issue, cwd, model, resume, system, prompt, readOnly = false, noTools = false, rereadNudge = false, maxTurns = null, agents = null, env = null, idleMs = 0, maxMs = 0, laneLimits = null, control = null, onSpawn = null, contextMax = 0, turnMax = 0, busy = null, busyMs = 0, onIdleExtend = null }) => {
|
|
@@ -372,7 +376,11 @@ export function makeLeanIo({ repo }) {
|
|
|
372
376
|
/** #4386: the deterministic renumber, sequence-only base-sync resolution, and the host sequence lease at the merge gate. */
|
|
373
377
|
sequenceRenumber: ({ cwd, base, laneRef, regenerate, reserve }) => renumberLane({ git: (a) => run("git")(a, { cwd }), base, cwd, laneRef, regenerate, reserve }),
|
|
374
378
|
sequenceResolve: ({ cwd, files }) => resolveSequenceConflicts({ git: (a) => run("git")(a, { cwd }), files }),
|
|
379
|
+
/** #4588: deterministic base-merge resolution of union / JSON-map / generated files (bin/lean/conflictres.mjs). */
|
|
380
|
+
conflictResolve: ({ cwd, files, cfg }) => resolveBaseConflicts({ git: (a) => run("git")(a, { cwd }), cwd, files, cfg }),
|
|
375
381
|
sequenceLease: makeSequenceLeaseIo(),
|
|
382
|
+
/** #4591: lane-added migrations the repo's generator did not produce (no journal entry or snapshot); [] on failure. */
|
|
383
|
+
migrationGenerator: ({ cwd, base }) => generatorProblems({ git: (a) => run("git")(a, { cwd }), base }),
|
|
376
384
|
/** #4406: the lane's scoped checks after a base merge (bin/lean/checks.mjs; [] on failure), and same-app duplicate exports (bin/lean/postmerge.mjs). */
|
|
377
385
|
postMergeChecks: async ({ cwd, base }) => { try { return scopedChecks({ repo: cwd, base }); } catch { return []; } },
|
|
378
386
|
duplicateExports: ({ cwd, base }) => duplicateExports({ git: (a) => run("git")(a, { cwd }), cwd, base }),
|
package/bin/lean/lane.mjs
CHANGED
|
@@ -83,3 +83,16 @@ export async function resolveLane({ issue, repo, io, cwd, branches = {}, request
|
|
|
83
83
|
if (!out.trim()) return refuse(OUTCOME_CODES.LANE_BRANCH_MISSING, `lane branch ${lane} (${why}) does not exist on origin; create it first (e.g. /milestone start) or fix the milestone title`);
|
|
84
84
|
return { ok: true, lane, expected, milestone, overridden, defaults };
|
|
85
85
|
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* #4593: true when `lane` is a non-staging branch fully merged into `staging` (a shipped milestone branch).
|
|
89
|
+
* Fail closed: any git error (no such ref, offline) is "not shipped".
|
|
90
|
+
*/
|
|
91
|
+
export async function isShippedLane({ lane, staging = DEFAULT_STAGING_BRANCH, io, cwd }) {
|
|
92
|
+
if (!lane || lane === staging) return false;
|
|
93
|
+
try {
|
|
94
|
+
try { await io.git(["fetch", "origin", lane, staging], { cwd }); } catch { /* offline: use the local refs */ }
|
|
95
|
+
await io.git(["merge-base", "--is-ancestor", `origin/${lane}`, `origin/${staging}`], { cwd });
|
|
96
|
+
return true;
|
|
97
|
+
} catch { return false; }
|
|
98
|
+
}
|