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.
Files changed (45) hide show
  1. package/bin/engine-cli.mjs +72 -12
  2. package/bin/forgedock.mjs +19 -1
  3. package/bin/hooks/reread-nudge.mjs +33 -5
  4. package/bin/lean/batch.mjs +24 -1
  5. package/bin/lean/batch.test.mjs +75 -0
  6. package/bin/lean/budget.mjs +14 -3
  7. package/bin/lean/budget.test.mjs +11 -1
  8. package/bin/lean/conflictres.mjs +93 -0
  9. package/bin/lean/conflictres.test.mjs +93 -0
  10. package/bin/lean/conformance.mjs +39 -6
  11. package/bin/lean/conformance.test.mjs +46 -0
  12. package/bin/lean/effects.mjs +11 -3
  13. package/bin/lean/lane.mjs +13 -0
  14. package/bin/lean/lean.mjs +111 -33
  15. package/bin/lean/lean.test.mjs +197 -5
  16. package/bin/lean/orchestrate.mjs +81 -8
  17. package/bin/lean/precheck.mjs +56 -8
  18. package/bin/lean/precheck.test.mjs +62 -3
  19. package/bin/lean/prompts.mjs +28 -6
  20. package/bin/lean/schedd.mjs +10 -1
  21. package/bin/lean/schedd.test.mjs +30 -0
  22. package/bin/lean/sequence.mjs +26 -0
  23. package/bin/lean/sequence.test.mjs +41 -2
  24. package/bin/lean/toolstate.mjs +77 -0
  25. package/bin/lean/toolstate.test.mjs +83 -0
  26. package/bin/lean/verify.mjs +18 -2
  27. package/bin/lean/verify.test.mjs +29 -3
  28. package/bin/review-staging.mjs +14 -2
  29. package/bin/stats/efficiency.mjs +103 -17
  30. package/bin/stats/efficiency.test.mjs +103 -4
  31. package/bin/stats/index.mjs +27 -5
  32. package/bin/stats/pool.mjs +2 -2
  33. package/commands/review-pr-agents/protocols.md +1 -1
  34. package/commands/review-pr-staging.md +1 -1
  35. package/docs/CONFIG.md +12 -2
  36. package/docs/STATS.md +29 -1
  37. package/package.json +1 -1
  38. package/scripts/review-finalize.sh +17 -1
  39. package/scripts/review-finalize.test.sh +41 -0
  40. package/scripts/review-pr-domain-map.test.sh +2 -2
  41. package/scripts/review-pr-staging-scope.test.sh +4 -2
  42. package/scripts/review-prepare.sh +62 -27
  43. package/scripts/review-prepare.test.sh +53 -0
  44. package/scripts/review-staging-pre.sh +29 -9
  45. 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
+ });
@@ -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
- return { ...f, detail: `${f.detail} ("${q.length > 160 ? `${q.slice(0, 157)}...` : q}")` };
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 a of Array.isArray(amendments) ? amendments : []) {
279
- const tag = JSON.stringify(a ?? null).slice(0, 100);
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(`unknown or incomplete amendment: ${tag}`); continue; }
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
- for (const c of rv.issue_coverage) if (c.status !== "covered") f.push({ code: C.CONFORMANCE_OMISSION, detail: `issue requirement ${c.status}: "${c.quote}"${c.note ? ` (${c.note})` : ""}` });
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
+ });
@@ -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 { sequenceCollisions, snapshotLane, verifyRenumber, renumberLane, resolveSequenceConflicts } from "./sequence.mjs";
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) => ["--settings", JSON.stringify({ hooks: { PostToolUse: [{ matcher: "Read|Bash",
67
- hooks: [{ type: "command", command: `${JSON.stringify(node)} ${JSON.stringify(hook)}` }] }] } })];
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
+ }