forgedock 1.42.0 → 1.43.1

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 (51) hide show
  1. package/bin/engine/outcome-codes.mjs +1 -0
  2. package/bin/engine-cli.mjs +91 -19
  3. package/bin/journey.mjs +2 -2
  4. package/bin/lean/autochildren.mjs +103 -0
  5. package/bin/lean/autochildren.test.mjs +126 -0
  6. package/bin/lean/batch.mjs +11 -3
  7. package/bin/lean/batch.test.mjs +66 -2
  8. package/bin/lean/conformance.mjs +140 -9
  9. package/bin/lean/conformance.test.mjs +164 -2
  10. package/bin/lean/effects.mjs +1 -1
  11. package/bin/lean/execution.test.mjs +9 -6
  12. package/bin/lean/fixround.test.mjs +9 -1
  13. package/bin/lean/guardgate.mjs +66 -7
  14. package/bin/lean/guardgate.test.mjs +109 -3
  15. package/bin/lean/heavy.test.mjs +11 -1
  16. package/bin/lean/issuewatch.mjs +2 -2
  17. package/bin/lean/lean.mjs +80 -24
  18. package/bin/lean/lean.test.mjs +119 -7
  19. package/bin/lean/orchestrate-blocked.test.mjs +16 -0
  20. package/bin/lean/orchestrate-children.test.mjs +83 -0
  21. package/bin/lean/orchestrate.mjs +51 -12
  22. package/bin/lean/postmerge.mjs +5 -2
  23. package/bin/lean/postmerge.test.mjs +7 -0
  24. package/bin/lean/preflight.mjs +21 -0
  25. package/bin/lean/preflight.test.mjs +15 -1
  26. package/bin/lean/probe.mjs +1 -1
  27. package/bin/lean/prompts.mjs +19 -4
  28. package/bin/lean/schedd.mjs +116 -19
  29. package/bin/lean/schedd.test.mjs +201 -2
  30. package/bin/lean/selftest-live.mjs +1 -1
  31. package/bin/lean/selftest-nightly.mjs +1 -1
  32. package/bin/lean/signoff.mjs +11 -1
  33. package/bin/lean/signoff.test.mjs +24 -1
  34. package/bin/lean/toolstate.mjs +19 -3
  35. package/bin/lean/toolstate.test.mjs +22 -0
  36. package/bin/lean/verify.mjs +9 -1
  37. package/bin/lean/verify.test.mjs +4 -0
  38. package/bin/pool/agent.mjs +1 -1
  39. package/bin/runner.mjs +1 -1
  40. package/commands/orchestrate.md +2 -2
  41. package/docs/CONFIG.md +8 -2
  42. package/docs/OUTCOME-CODES.md +1 -0
  43. package/docs/RELEASE-NOTES.md +3 -0
  44. package/package.json +1 -1
  45. package/scripts/ci-local/run.mjs +1 -1
  46. package/scripts/file-deferred-followups.sh +1 -0
  47. package/scripts/file-deferred-followups.test.sh +7 -0
  48. package/scripts/review-pr-staging-scope.test.sh +3 -3
  49. package/scripts/review-prepare.sh +3 -0
  50. package/scripts/review-staging-pre.sh +88 -43
  51. package/scripts/review-staging-pre.test.sh +42 -1
@@ -0,0 +1,83 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ // #4965: the scheduler pass that adds epic and late children to a running batch.
4
+ import { describe, it } from "node:test";
5
+ import assert from "node:assert/strict";
6
+ import { orchestrate } from "./orchestrate.mjs";
7
+
8
+ const merged = { terminalReason: "merged" };
9
+ const until = async (f, ms = 3000) => { const t = Date.now(); while (!f()) { if (Date.now() - t > ms) throw new Error("timeout"); await new Promise((r) => setTimeout(r, 5)); } };
10
+
11
+ /** A batch with epic #1 running behind a gate; `list` is what the one list call returns. */
12
+ async function run({ mode, list, pollS = 0.001, elsewhere = [], epics = [], bodies = {}, extra = {} }) {
13
+ const started = [], log = [], decisions = [];
14
+ let calls = 0, open;
15
+ const g = new Promise((r) => { open = r; });
16
+ const done = orchestrate({ issues: [1], parallel: 3, pollMs: 5, trusted: () => true, readBody: async (n) => bodies[n] ?? "", onUpdate: (r) => log.push(r), decide: (d) => decisions.push(d),
17
+ autoChildren: { mode, pollS, epics }, getChildren: async () => { calls++; return { list: typeof list === "function" ? list() : list, elsewhere }; },
18
+ runOne: async (n) => { started.push(n); if (n === 1) await g; return merged; }, ...extra });
19
+ return { started, log, decisions, calls: () => calls, open, done };
20
+ }
21
+ const L = (...kids) => [{ number: 1, body: "", labels: [] }, ...kids.map((n) => ({ number: n, body: "**Parent**: #1", labels: [] }))];
22
+
23
+ describe("#4965 auto_add_children", () => {
24
+ it("epic: adds open children of a batch issue, one decision and one added row listing them", async () => {
25
+ const r = await run({ mode: "epic", list: L(2, 3), bodies: { 3: "Blocked by #2" } });
26
+ await until(() => r.started.includes(2) && r.started.includes(3));
27
+ r.open(); await r.done;
28
+ const added = r.log.filter((x) => x.outcome === "added");
29
+ assert.equal(added.length, 1);
30
+ assert.match(added[0].detail, /#2, #3; edges #3 blocked by #2/);
31
+ assert.equal(r.decisions.filter((d) => /auto_add_children/.test(d.what)).length, 1);
32
+ assert.ok(r.started.indexOf(2) < r.started.indexOf(3));
33
+ });
34
+ it("decomposed (default) and off never call getChildren", async () => {
35
+ for (const mode of ["decomposed", "off"]) {
36
+ const r = await run({ mode, list: L(2) });
37
+ await new Promise((x) => setTimeout(x, 40));
38
+ r.open(); await r.done;
39
+ assert.equal(r.calls(), 0, mode);
40
+ assert.deepEqual(r.started, [1]);
41
+ }
42
+ });
43
+ it("needs-human children and children live in another batch are not added", async () => {
44
+ const list = [...L(2, 3), { number: 4, body: "**Parent**: #1", labels: [{ name: "needs-human" }] }];
45
+ const r = await run({ mode: "all", list, elsewhere: [3] });
46
+ await until(() => r.started.includes(2));
47
+ await new Promise((x) => setTimeout(x, 40));
48
+ r.open(); await r.done;
49
+ assert.deepEqual([...r.started].sort(), [1, 2]);
50
+ });
51
+ it("an --epic root outside the batch adds its children; late children join on a later pass", async () => {
52
+ let list = [{ number: 50, body: "", labels: [] }, { number: 51, body: "**Parent**: #50", labels: [] }];
53
+ const r = await run({ mode: "epic", epics: [50], list: () => list });
54
+ await until(() => r.started.includes(51));
55
+ list = [...list, { number: 52, body: "**Parent**: #50", labels: [] }];
56
+ await until(() => r.started.includes(52));
57
+ r.open(); await r.done;
58
+ assert.equal(r.log.filter((x) => x.outcome === "added").length, 2);
59
+ });
60
+ it("a failing child scan is reported once as a decision, retried, and the batch still runs", async () => {
61
+ let calls = 0;
62
+ const r = await run({ mode: "epic", list: L(), extra: { getChildren: async () => { calls++; throw new Error("Unknown JSON field"); } } });
63
+ await new Promise((x) => setTimeout(x, 60));
64
+ r.open(); await r.done;
65
+ assert.ok(calls >= 2, "retried");
66
+ assert.equal(r.decisions.filter((d) => /child scan failed/.test(d.what)).length, 1);
67
+ assert.deepEqual(r.started, [1]);
68
+ });
69
+ it("scans at most once per poll window: one list call per pass", async () => {
70
+ const r = await run({ mode: "epic", list: L(), pollS: 3600 });
71
+ await new Promise((x) => setTimeout(x, 80));
72
+ r.open(); await r.done;
73
+ assert.equal(r.calls(), 1);
74
+ });
75
+ it("a decomposed lane's sub-issues join in decomposed|epic|all, not in off", async () => {
76
+ for (const [mode, joins] of [["decomposed", true], ["epic", true], ["all", true], ["off", false]]) {
77
+ const started = [];
78
+ await orchestrate({ issues: [1], parallel: 3, pollMs: 5, readBody: async () => "", autoChildren: { mode, pollS: 3600 }, getChildren: async () => ({ list: [], elsewhere: [] }),
79
+ runOne: async (n) => { started.push(n); return n === 1 ? { terminalReason: "decomposed", subissues: [2] } : merged; } });
80
+ assert.equal(started.includes(2), joins, mode);
81
+ }
82
+ });
83
+ });
@@ -19,13 +19,16 @@
19
19
  * A seam calls `onLive()` before it waits on a live lane (#4504); an adopt that resolves first is settled (or requeued on null)
20
20
  * before scheduling, so a live lane does not hold up the others.
21
21
  * `deferred` is a scheduling fact, not an end: each typed code is requeued with its retry-at (`deferPolicy`), a usage
22
- * limit holds admissions for that account until its reset, and deferrals other than a busy resource (`host.busy`,
23
- * `sandbox.capacity`, #4528; `sandbox.unreachable`, #4788) stop after `maxDeferrals`.
22
+ * limit holds admissions for that account until its reset, and deferrals other than a wait (`host.busy`,
23
+ * `sandbox.capacity`, #4528; `sandbox.unreachable`, #4788; `rate_limited`, #4784) stop after `maxDeferrals`.
24
24
  * Usage-limit holds (#4190) are keyed by backend identity in `limits` (the host store, shared by every batch on the
25
25
  * host; in memory when absent): admission checks the hold of `identity()`, the account the next lane would use. While held, the
26
26
  * batch re-reads the store every `pollMs` (#4446), so `forgedock limits clear` admits on the next poll, not at the reset. An `engine-error` is
27
27
  * relaunched once; `breaker` ({errors, window_min}) stops admissions after a burst and lets running lanes finish, the
28
28
  * rest stay pending (outcome `held`) for a resume. `getParallel()` is read on every pass, so K can change live.
29
+ * `getChildren()` + `autoChildren {mode, pollS, epics}` (#4965, `orchestration.auto_add_children`): with mode `epic`|`all`, at most once per `pollS`
30
+ * it reads one `{list, elsewhere}` and adds the open children (`**Parent**: #P`, sub-issues) of every batch issue and `epics` through the same
31
+ * path as `getAdded` (one decision and one `added` row listing the edges); mode `off` also stops the decomposed-lane join.
29
32
  * `getAdded()` (#4408, `forgedock orchestrate --add`) is read on every pass too: issues it returns that are not in the batch
30
33
  * join it through `addIssues` (the still-pending issues' `Depends on` are re-read first, so edges hold both ways), with one
31
34
  * batch decision and one `added` row; while it is set, lanes running with an empty queue still wake every `pollMs`.
@@ -112,13 +115,14 @@ import { formatParkedLine, postDeployOnly } from "./prefilter.mjs";
112
115
  import { laneSize, SIZES } from "./budget.mjs";
113
116
  import { requeueKey, REQUEUABLE } from "./batch.mjs";
114
117
  import { makeCapWait } from "./capwait.mjs";
118
+ import { joinsDecomposed, addsEpicChildren, selectChildren, edgeText, DEFAULT_POLL_S } from "./autochildren.mjs";
115
119
 
116
120
  // A dependency that finished like this no longer blocks its dependents.
117
121
  const SATISFIED = new Set(["merged", "invalid"]);
118
122
  const WATCH_EVERY_MS = 60_000; // #4595
119
123
  // Times one issue may be requeued behind a dependency before its dependency-open row is final.
120
124
  export const MAX_REQUEUES = 2;
121
- // Deferrals other than a busy resource (UNCAPPED_DEFERRALS) one issue may take before its deferred row is final.
125
+ // Deferrals other than a wait (UNCAPPED_DEFERRALS, usage limit) one issue may take before its deferred row is final.
122
126
  export const DEFAULT_MAX_DEFERRALS = 3;
123
127
  // Relaunches of an issue whose lane ended engine-error.
124
128
  export const ENGINE_ERROR_RELAUNCHES = 1;
@@ -176,18 +180,19 @@ export function parseDeps(body, issue) {
176
180
  const posNum = (v) => { const n = Number(v); return Number.isFinite(n) && n > 0 ? n : null; };
177
181
 
178
182
  /**
179
- * When to run a `deferred` issue again. `deferrals` counts this issue's earlier capped deferrals (not UNCAPPED_DEFERRALS).
183
+ * When to run a `deferred` issue again. `deferrals` counts this issue's earlier capped deferrals (not UNCAPPED_DEFERRALS or a usage limit).
180
184
  * @returns {{delayMs:number, host?:true, hold?:true} | {capped:true}}
181
185
  */
182
186
  export function deferPolicy(r, { now, deferrals = 0, maxDeferrals = DEFAULT_MAX_DEFERRALS }) {
183
187
  const code = r?.code ?? null;
184
188
  if (UNCAPPED_DEFERRALS.has(code)) return { delayMs: (posNum(r?.retryInSeconds) ?? HOST_BUSY_RETRY_S) * 1000, host: true };
185
- const k = deferrals + 1;
186
- if (k > maxDeferrals) return { capped: true };
189
+ // #4784: a usage limit is a known-time wait gated by the account hold, never capped.
187
190
  if (code === "rate_limited") {
188
191
  const at = posNum(r?.resetAtEpochMs) && r.resetAtEpochMs > now ? r.resetAtEpochMs : now + (posNum(r?.retryInSeconds) ?? RATE_LIMIT_FALLBACK_S) * 1000;
189
192
  return { delayMs: at - now, hold: true };
190
193
  }
194
+ const k = deferrals + 1;
195
+ if (k > maxDeferrals) return { capped: true };
191
196
  if (posNum(r?.retryInSeconds)) return { delayMs: r.retryInSeconds * 1000 };
192
197
  // lease-held: "leased by X until <ISO>"
193
198
  const until = /\buntil (\d{4}-\d\d-\d\dT[\d:.]+Z)/.exec(String(r?.detail ?? ""))?.[1];
@@ -248,7 +253,8 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
248
253
  store = null, adopt = null, getParallel = null, getAdded = null, getRequeued = null, breaker = null, maxDeferrals = DEFAULT_MAX_DEFERRALS, sleep = cancellableSleep, pollMs = 5000,
249
254
  classify = null, maintenance = null, limits = null, identity = () => "default", batchId = null, drift = null, integration = null, sched = null, priorityOf = null, remote = null, beforeAdmit = null, preflight = null,
250
255
  recovery = null, decide = () => {}, conflicting = null, relabelNeedsHuman = null, onExit = null, issueLabels = null, issueState = null, getCancelled = null, getHeld = null, getBlocked = null, trusted = null, getGates = null, stopLane = null,
251
- estimate = null, starveMs = DEFAULT_STARVE_MS, signal = null, dispatchCheck = null, syncParked = null, syncEveryMs = DEFAULT_SYNC_EVERY_MS, lifecycle = null }) {
256
+ estimate = null, starveMs = DEFAULT_STARVE_MS, signal = null, dispatchCheck = null, syncParked = null, syncEveryMs = DEFAULT_SYNC_EVERY_MS, lifecycle = null,
257
+ getChildren = null, autoChildren = null }) {
252
258
  const saved = store?.load?.() ?? null;
253
259
  const order = saved ? [...saved.order] : [];
254
260
  const set = new Set(order);
@@ -632,7 +638,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
632
638
  try { capGated = Number.isInteger(remote?.capacity?.()?.free); } catch { capGated = false; }
633
639
  }
634
640
  if (!pol.capped) {
635
- if (!pol.host) deferrals.set(n, (deferrals.get(n) ?? 0) + 1);
641
+ if (!pol.host && !pol.hold) deferrals.set(n, (deferrals.get(n) ?? 0) + 1);
636
642
  // A usage limit is held per account; the hold, not a per-issue delay, gates the retry, so a lane on another
637
643
  // account runs it at once. If the hold cannot be recorded, the reset delay applies to this issue instead.
638
644
  let delayMs = pol.delayMs;
@@ -655,7 +661,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
655
661
  return;
656
662
  }
657
663
  }
658
- if (row.outcome === "decomposed" && subs.length) {
664
+ if (row.outcome === "decomposed" && subs.length && joinsDecomposed(autoChildren?.mode ?? "decomposed")) {
659
665
  await addIssues(subs, { head: true, parent: n }); // #4409: on the batch's critical path by construction
660
666
  decided({ issue: n, what: `added sub-issues ${subs.map((d) => `#${d}`).join(", ")} to the batch; dependents wait on them`, why: "the builder decomposed the issue",
661
667
  alternative: "end the batch row decomposed and let dependents wait for a later batch" });
@@ -847,11 +853,12 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
847
853
 
848
854
  // #4408: issues added to the running batch; returns how many joined. Re-reading the cumulative list is a no-op.
849
855
  const drainAdds = async () => {
850
- if (!getAdded) return 0;
856
+ const kids = await drainChildren();
857
+ if (!getAdded) return kids;
851
858
  let got = [];
852
859
  try { got = getAdded() ?? []; } catch { got = []; }
853
860
  const fresh = [...new Set((Array.isArray(got) ? got : []).filter((n) => Number.isSafeInteger(n) && n > 0 && !set.has(n)))];
854
- if (!fresh.length) return 0;
861
+ if (!fresh.length) return kids;
855
862
  // An edge an operator wrote into a pending issue after the batch started (it waits on an added issue) holds too.
856
863
  for (const n of pending) {
857
864
  let body;
@@ -864,6 +871,38 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
864
871
  const list = fresh.map((n) => `#${n}`).join(", ");
865
872
  decided({ issue: null, what: `added ${list} to the batch (forgedock orchestrate --add)`, why: "operator request", alternative: "start a second batch" });
866
873
  update({ issue: null, outcome: "added", detail: list });
874
+ return fresh.length + kids;
875
+ };
876
+ // #4965: epic and late children (`orchestration.auto_add_children` epic|all): at most one `getChildren()` list call per poll window.
877
+ let nextChildScan = 0, childScanFailed = false;
878
+ const drainChildren = async () => {
879
+ if (!getChildren || !addsEpicChildren(autoChildren?.mode)) return 0;
880
+ const t = now();
881
+ if (t < nextChildScan) return 0;
882
+ nextChildScan = t + Math.max(0, autoChildren.pollS ?? DEFAULT_POLL_S) * 1000;
883
+ let list, elsewhere;
884
+ try { ({ list, elsewhere } = await getChildren()); } catch (e) {
885
+ if (!childScanFailed) { childScanFailed = true; decided({ issue: null, what: `auto_add_children: child scan failed (${firstLine(e?.message ?? e)}); retried every ${autoChildren.pollS ?? DEFAULT_POLL_S}s`, why: "the open-issue list could not be read", alternative: "add the children by hand with forgedock orchestrate --add" }); }
886
+ return 0;
887
+ }
888
+ childScanFailed = false;
889
+ const fresh = selectChildren({ list, roots: [...set, ...(autoChildren.epics ?? [])], skip: [...set, ...(elsewhere ?? [])] })
890
+ .filter((n) => { try { return !(reg?.holder && reg.holder(entryOf(n)) != null); } catch { return true; } });
891
+ if (!fresh.length) return 0;
892
+ for (const n of pending) {
893
+ let body;
894
+ try { body = await readBody(n); } catch { continue; }
895
+ if (drift?.holds) bodies.set(n, body);
896
+ deps.set(n, [...new Set([...(deps.get(n) ?? []), ...(await declared(n, body))])]);
897
+ }
898
+ await addIssues(fresh);
899
+ save();
900
+ const list2 = fresh.map((n) => `#${n}`).join(", ");
901
+ const created = fresh.flatMap((n) => edges(n).map((d) => ({ from: n, to: d })));
902
+ for (const n of pending) if (!fresh.includes(n)) for (const d of edges(n)) if (fresh.includes(d)) created.push({ from: n, to: d });
903
+ const detail = created.length ? `${list2}; edges ${edgeText(created)}` : list2;
904
+ decided({ issue: null, what: `added ${list2} to the batch (orchestration.auto_add_children: ${autoChildren.mode})${created.length ? `; edges ${edgeText(created)}` : ""}`, why: "open children of a batch issue or epic", alternative: "add them by hand with forgedock orchestrate --add" });
905
+ update({ issue: null, outcome: "added", detail });
867
906
  return fresh.length;
868
907
  };
869
908
  // #4550: `--requeue` entries (batch `requeue` file) not applied yet: a terminal issue and the dependents skipped behind it
@@ -1163,7 +1202,7 @@ export async function orchestrate({ issues, parallel = 3, runOne, readBody, pref
1163
1202
  if (holdUntil && pending.length) wake = Math.min(wake ?? pollMs, pollMs); // #4446: a cleared hold (forgedock limits clear) admits on the next poll
1164
1203
  if ((getParallel || getHeld) && pending.length) wake = Math.min(wake ?? pollMs, pollMs);
1165
1204
  if (offered.size && pending.length) wake = Math.min(wake ?? pollMs, pollMs); // a slot another batch frees
1166
- if ((getAdded || getRequeued) && running.size) wake = Math.min(wake ?? pollMs, pollMs); // #4408/#4550: an add or requeue while only lanes run
1205
+ if ((getAdded || getRequeued || (getChildren && addsEpicChildren(autoChildren?.mode))) && running.size) wake = Math.min(wake ?? pollMs, pollMs); // #4408/#4550: an add or requeue while only lanes run
1167
1206
  if ((pfAll || pfRemote) && pending.length) wake = Math.min(wake ?? pollMs, pollMs); // #4866
1168
1207
  if (getBlocked && pending.length && blockedHit.size) wake = Math.min(wake ?? pollMs, pollMs); // #4773: a removed label releases on the next tick
1169
1208
  if (getHeld && running.size) wake = Math.min(wake ?? pollMs, pollMs); // #4592: a release while only lanes run
@@ -15,6 +15,8 @@ import { exportNames, isTestPath } from "./enumerating.mjs";
15
15
  const SOURCE_RE = /\.(?:[cm]?[jt]sx?|py)$/;
16
16
  const SKIP_RE = /(?:^|\/)(?:node_modules|vendor|dist|build|coverage)\//;
17
17
  const MANIFESTS = ["package.json", "pyproject.toml", "setup.py", "go.mod", "Cargo.toml"];
18
+ // entry points a standalone script defines by convention: never a shared export
19
+ const ENTRY_NAMES = new Set(["main", "cli"]);
18
20
  const MIN_NAME = 3, MAX_NAMES = 50, CONVENTION = 3;
19
21
  const lines = (s) => String(s ?? "").split("\n").map((x) => x.trim()).filter(Boolean);
20
22
  const out = (git) => async (args) => { try { return String(await git(args)); } catch { return ""; } };
@@ -68,13 +70,14 @@ export async function duplicateExports({ git, cwd, base }) {
68
70
  let names = 0;
69
71
  for (const f of changed) {
70
72
  const was = new Set(added.has(f) ? [] : declaredExports(await g(["show", `${base}:${f}`]), f));
71
- const fresh = declaredExports(read(f), f).filter((n) => !was.has(n) && n.length >= MIN_NAME);
73
+ const fresh = declaredExports(read(f), f).filter((n) => !was.has(n) && n.length >= MIN_NAME && !ENTRY_NAMES.has(n));
72
74
  const root = packageRoot(f, manifests);
75
+ if (root === "." && /^scripts\//.test(f)) continue; // standalone scripts, not a package
73
76
  for (const name of fresh) {
74
77
  if (++names > MAX_NAMES) return res;
75
78
  const hits = lines(await g(["-c", "core.quotepath=off", "grep", "-l", "-w", "-e", name, "HEAD", "--", ...(root === "." ? [] : [`${root}/`])]))
76
79
  .map((l) => l.replace(/^HEAD:/, ""))
77
- .filter((p) => p !== f && keep(p) && packageRoot(p, manifests) === root);
80
+ .filter((p) => p !== f && keep(p) && packageRoot(p, manifests) === root && !(root === "." && /^scripts\//.test(p)));
78
81
  const others = [];
79
82
  for (const p of hits) if (declaredExports(read(p), p).includes(name)) others.push(p);
80
83
  if (others.length && others.length < CONVENTION) res.push({ symbol: name, path: f, others: others.sort() });
@@ -51,6 +51,13 @@ describe("duplicate exports (#4406)", () => {
51
51
  assert.deepEqual(await dups(d), []);
52
52
  });
53
53
 
54
+ it("standalone scripts each defining main are not duplicate exports; a real duplicate still is", async () => {
55
+ const d = repo({ "scripts/openapi_diff.py": "def main():\n pass\n\ndef shared_helper():\n pass\n" }, { "scripts/seed_minimal.py": "def main():\n pass\n\ndef shared_helper():\n pass\n" });
56
+ assert.deepEqual(await dups(d), []);
57
+ const e = repo({ "svc/pyproject.toml": "", "svc/a.py": "def main():\n pass\n" }, { "svc/b.py": "def main():\n pass\n\ndef resolve_x():\n pass\n" });
58
+ assert.deepEqual(await dups(e), []);
59
+ });
60
+
54
61
  it("Python modules of one package collide too; unreadable git yields []", async () => {
55
62
  const d = repo({ "svc/pyproject.toml": "", "svc/a.py": "def resolve_tenant():\n pass\n" }, { "svc/b.py": "def resolve_tenant():\n pass\n" });
56
63
  assert.deepEqual(await dups(d), [{ symbol: "resolve_tenant", path: "svc/b.py", others: ["svc/a.py"] }]);
@@ -131,6 +131,27 @@ export function preflightProbes(contract, { repoHas = () => false, cap = PREFLIG
131
131
  return { probes: all.slice(0, cap), dropped: Math.max(0, all.length - cap) };
132
132
  }
133
133
 
134
+ /**
135
+ * #4857: validate commands that touch a path under a configured forge.yaml `verification.test_runners` prefix
136
+ * ({"services/worker": "poetry -C services/worker run"}) without using that runner, one problem per validate id. Pure.
137
+ */
138
+ export function testRunnerProblems(contract, runners) {
139
+ const entries = Object.entries(runners && typeof runners === "object" ? runners : {}).filter(([k, v]) => k && typeof v === "string" && v.trim());
140
+ const out = [];
141
+ for (const v of Array.isArray(contract?.validate) ? contract.validate : []) {
142
+ if (!v?.id || typeof v.command !== "string") continue;
143
+ const cmd = v.command.replace(/\s+/g, " ");
144
+ for (const [prefix, runner] of entries) {
145
+ const p = prefix.replace(/^\.?\/+|\/+$/g, "");
146
+ if (new RegExp(`(^|[\\s=:'"])(\\./)?${p.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/`).test(cmd) && !cmd.includes(runner.trim().replace(/\s+/g, " "))) {
147
+ out.push(`${v.id}: touches ${p}/ but does not run through that path's configured test runner (forge.yaml verification.test_runners: \`${runner.trim()}\`); use it, with paths relative to its cwd`);
148
+ break;
149
+ }
150
+ }
151
+ }
152
+ return out;
153
+ }
154
+
134
155
  const FIX_HOW = "run the check through the repo's own toolchain (its virtualenv, lockfile-pinned runner or package scripts) or use a check that does not need it, never a global install";
135
156
  /** One contract problem per miss: names the validate ids and the tool (or path, or env var), and how to fix the plan (never a global install). */
136
157
  export function preflightProblems(misses) {
@@ -4,7 +4,7 @@ import { describe, it } from "node:test";
4
4
  import assert from "node:assert/strict";
5
5
  import { mkdirSync, writeFileSync } from "node:fs";
6
6
  import { join } from "node:path";
7
- import { shellSegments, pythonImports, nodeImports, preflightProbes, preflightProblems, runProbeMiss, unsetEnvVars, runPreflight, knownToContract } from "./preflight.mjs";
7
+ import { testRunnerProblems, shellSegments, pythonImports, nodeImports, preflightProbes, preflightProblems, runProbeMiss, unsetEnvVars, runPreflight, knownToContract } from "./preflight.mjs";
8
8
  import { runCheckCommand } from "./effects.mjs";
9
9
  import { repoTracks } from "./conformance.mjs";
10
10
  import { tmpDir } from "../tests/_tmp.mjs";
@@ -106,3 +106,17 @@ describe("#4393 validate preflight: running the command on base", () => {
106
106
  assert.deepEqual(r.misses.map((m) => [m.ids[0], m.tool]), [["V1", "absent-pkg-4393"]]);
107
107
  });
108
108
  });
109
+
110
+ describe("testRunnerProblems (#4857)", () => {
111
+ const runners = { "services/worker": "poetry -C services/worker run" };
112
+ it("flags a validate on a mapped path that skips its runner", () => {
113
+ const p = testRunnerProblems(C("python3 -m pytest services/worker/tests/test_a.py"), runners);
114
+ assert.equal(p.length, 1);
115
+ assert.match(p[0], /V1: touches services\/worker\//);
116
+ });
117
+ it("accepts the runner, other paths, and no config", () => {
118
+ assert.deepEqual(testRunnerProblems(C("poetry -C services/worker run pytest tests/test_a.py"), runners), []);
119
+ assert.deepEqual(testRunnerProblems(C("node --test bin/lean/x.test.mjs"), runners), []);
120
+ assert.deepEqual(testRunnerProblems(C("pytest services/worker/t.py"), null), []);
121
+ });
122
+ });
@@ -179,7 +179,7 @@ export function probeEnv(allow = [], env = process.env) {
179
179
  export function spawnProbe({ run, cwd, env, timeoutMs }) {
180
180
  return new Promise((resolve) => {
181
181
  let stdout = "", stderr = "", timedOut = false;
182
- const child = nodeSpawn("sh", ["-c", run], { cwd, env, detached: true, stdio: ["ignore", "pipe", "pipe"] });
182
+ const child = nodeSpawn("sh", ["-c", run], { cwd, env, detached: true, windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
183
183
  const cap = (s, d) => (s + d).slice(-OUTPUT_TAIL * 4);
184
184
  child.stdout.on("data", (d) => { stdout = cap(stdout, d); });
185
185
  child.stderr.on("data", (d) => { stderr = cap(stderr, d); });
@@ -43,7 +43,7 @@ export const syncChecks = (mode = "local") => (mode === "ci" ? `${LIGHT} (the en
43
43
  /** CI failed on the pushed branch (#4167): one fix round in the builder's own session. */
44
44
  export function ciFailurePrompt(failures, mode = "local") {
45
45
  const list = failures.slice(0, 10).map((f) => `#### ${f.job ?? "check"}${f.url ? ` (${f.url})` : ""}\n${f.log ? `\`\`\`\n${f.log}\n\`\`\`` : "(no log available: open the url)"}`).join("\n\n");
46
- return `CI failed on the pushed branch:\n\n${list}\n\nFix the cause in the code (or record a typed amendment with a why in ${RESULT_FILE}). ${rerun(mode)}, commit, and update ${IMPLEMENTATION_FILE} and ${RESULT_FILE}. Do not push or touch GitHub.`;
46
+ return `CI failed on the pushed branch:\n\n${list}\n\nFix the cause in the code (or record a typed amendment with a why in ${RESULT_FILE}). A failing check whose files this diff does not touch (a flaky or timing-dependent test) is not an amendment: name the check and why it is unrelated in ${IMPLEMENTATION_FILE} and change no code for it; unknown amendment kinds are rejected. ${rerun(mode)}, commit, and update ${IMPLEMENTATION_FILE} and ${RESULT_FILE}. Do not push or touch GitHub.`;
47
47
  }
48
48
 
49
49
  /** The repo's declared check commands (forge.yaml learned.test_commands) failed before the PR (#4240): fixed in-session. */
@@ -182,6 +182,15 @@ export const REVIEW_RUBRIC = `1. Fixed: does the diff resolve the issue as writt
182
182
  3. Contract: is every deliverable changed as described, every preserve item intact and every validate command passing?
183
183
  4. Scope: does the diff change only deliverable paths and tests?`;
184
184
 
185
+ /** #4959: reviewer-only (kept out of REVIEW_RUBRIC, which the builder prompt shares and its token budget caps). */
186
+ const VERDICT_RULE = "A criterion asking for a validation or finding verdict (VALIDATED, FALSE_POSITIVE, INCONCLUSIVE) is met by the recorded verdict here (read by the engine from the lane's investigation) together with a regression or repro test in the diff; it needs no prose in the diff. A gap you yourself call minor is not a failing row.";
187
+
188
+ /** #4959: the verdict word of an investigation note's \`**Verdict**:\` line (uppercased), or null. */
189
+ export function investigationVerdict(md) {
190
+ const m = /^\*\*Verdict\*\*:\s*([A-Za-z_-]+)/m.exec(String(md ?? ""));
191
+ return m ? m[1].toUpperCase() : null;
192
+ }
193
+
185
194
  /** Builder call 2, same session: implement the frozen contract. */
186
195
  export function builderImplementPrompt(mode = "local", { toolchain = null, sparse = null, facts = null } = {}) {
187
196
  const remote = mode === "ci" || mode === "hybrid";
@@ -234,6 +243,12 @@ export function contextPackNote(pack) {
234
243
  }
235
244
 
236
245
  /** #4271: the bug-lane plan addition (lean.repro_test prefer on a bug/type:bug issue); "" when off, so the prompt is unchanged. */
246
+ /** #4857: validate-command rules for the plan prompt (the implement session resumes it), kept out of the shared templates (their token caps are guarded). */
247
+ export function unrunnableValidateNote(testRunners = null) {
248
+ const map = testRunners && typeof testRunners === "object" ? Object.entries(testRunners).map(([k, v]) => `${k}/ -> ${v}`).join("; ") : "";
249
+ return `\n\nValidate and repro commands must pass bash -n (no prose notes), select tests that exist, and run from the right cwd${map ? ` (forge.yaml verification.test_runners: ${map})` : ""}. An unrunnable validate (missing tool, wrong cwd, no tests selected) is never a block or "human" product-decision: run an equivalent through the repo's toolchain and record {"kind":"substitute-validate","validate":"V#","command":"<runnable equivalent>","why":""} in ${RESULT_FILE} amendments.`;
250
+ }
251
+
237
252
  export function reproPlanNote(on) {
238
253
  if (!on) return "";
239
254
  return `\n\nThis issue is a bug. When it is testable, ${CONTRACT_FILE} must also carry "repro_test":{"path":"<test file>","id":"<test name>","command":"<exact command that runs only that test>"}: a test that fails on the base and passes after the fix. The engine proves it with scripts/acceptance-red-green.sh (head's changed test files are overlaid on the base) and fails the run if it is not red on base and green on head. When it is not testable (specs, docs, workflows, environment), write "repro_test":"none","repro_test_reason":"<one line why>" instead.`;
@@ -384,7 +399,7 @@ export const REVIEWER_SYSTEM = `You are an independent conformance reviewer of o
384
399
 
385
400
  You receive the GitHub issue with its comments, the frozen contract (requirements quoted from the issue, deliverables, preserve constraints, validate commands) with typed amendments, the builder's result with validation output, the changed files, the diff, and a row skeleton with one row per contract id and changed file. The engine computes the verdict from your rows; a reply without rows is rejected, so fill the skeleton rather than composing rows: fill EVERY row (set each empty status, evidence and validated_by, and each null deferral_ok; keep the ids and files as given):
386
401
  - issue_coverage: one row per requirement or acceptance criterion in the issue, quoted verbatim, saying whether the contract covers it, narrows it, or misses it. The skeleton lists the contract's quotes; add a row for any issue requirement the contract does not quote.
387
- - amendment: a requirement with an \`amendment\` is judged against amendment.text and its why, not the quote; its issue_coverage row is covered when the amendment follows from its why. If it does not hold, mark the row partial with note "amendment: <what to change>".
402
+ - amendment: a requirement with an \`amendment\` is judged against amendment.text and its why, not the quote; its issue_coverage row is covered when the amendment follows from its why. A requirement with \`amended_from\` was contracted defer or post-deploy and flipped to deliver: compare amended_from.quote and amended_from.why with amendment.text side by side, and mark the row partial when the rewording drops the original work or only trivially restates it. If it does not hold, mark the row partial with note "amendment: <what to change>".
388
403
  - requirements: one row per contract requirement id: implemented|partial|missing|deferred|post-deploy|not-applicable, with evidence as a backtick-quoted repo-relative path:line copied verbatim from the changed files, including (group), [slug] and [...slug] folders (e.g. \`apps/web/app/(console)/layout.tsx:12\`, not route.ts:12) and the validate id or test that proves it (validated_by). For a defer or post-deploy requirement add deferral_ok: true only if it is genuinely separate work or only checkable after a deploy, false if it belongs in this change, including an outcome criterion (a metric, rate or log expectation) this change could ship as a test, metric or alert, or a read-only production check possible now (bucket verify-now). For a verify out_of_band row (measured by the named job, not in this lane) set instrumented: true only when its budget test or bench is committed and that job is configured, with that path:line as evidence; false otherwise. Do not judge the measured result.
389
404
  - post-deploy requirements: deferral_ok is false when the requirement has no \`check\` id, or its check is neither in .forge/post-deploy-checks.json on the default branch nor added by this change (the commands run from that file only).
390
405
  - scope: one row per changed file, pre-filled with the engine's authorization: the deliverable id that authorizes it (deliverables may be directories or globs), "test", "incidental" (changelog, lockfile), or "none" with what it changes. Change it only if the diff shows the file does something its deliverable does not cover.
@@ -441,12 +456,12 @@ export function decisionNote(comments = []) {
441
456
  return kept.length ? `\n\nBinding decisions posted after the contract froze (trusted; they override the issue text where they conflict, and the frozen contract still applies):\n${kept.join("\n---\n")}` : "";
442
457
  }
443
458
 
444
- export function reviewerPrompt({ issue, comments = [], contract, result, files = [], diff, stat = "", base = "", structure = "", ignore = [`${LEAN_DIR}/`], skeleton = null }) {
459
+ export function reviewerPrompt({ issue, comments = [], contract, result, files = [], diff, stat = "", base = "", structure = "", ignore = [`${LEAN_DIR}/`], skeleton = null, investigationVerdict = null }) {
445
460
  const notes = comments.map((c) => String(c?.body ?? "")).filter((b) => !/^<!-- FORGE:/.test(b.trim()));
446
461
  const decisions = comments.filter((c) => isDecision(c?.body) && isTrustedIssueComment(c)).map((c) => String(c.body)); // #4901: operator decisions bind the review; other FORGE: records stay out
447
462
  notes.push(...decisions);
448
463
  const sk = JSON.stringify(skeleton ?? reviewSkeleton(contract ?? {}, files, ignore), null, 1);
449
- return `Issue #${issue.number}: ${issue.title}\n\n<issue_body>\n${cap(issue.body ?? "", 20000)}\n</issue_body>\n\n<issue_comments>\n${cap(notes.join("\n---\n"), 8000)}\n</issue_comments>\n\n<contract>\n${cap(JSON.stringify(contract ?? {}, null, 1), 12000)}\n</contract>\n\n<builder_result>\n${cap(JSON.stringify(result ?? {}, null, 1), 6000)}\n</builder_result>\n\n<changed_files>\n${files.join("\n")}\n</changed_files>\n\n<rubric>\n${REVIEW_RUBRIC}\n</rubric>\n\n<row_skeleton>\n${cap(sk, 30000)}\n</row_skeleton>\n\n<diff>\n${boundDiff(diff, { stat, base })}\n</diff>${structure ? `\n<structure>${String(structure).replace(/[\r\n]+/g, " ")}</structure>` : ""}`;
464
+ return `Issue #${issue.number}: ${issue.title}\n\n<issue_body>\n${cap(issue.body ?? "", 20000)}\n</issue_body>\n\n<issue_comments>\n${cap(notes.join("\n---\n"), 8000)}\n</issue_comments>\n\n<contract>\n${cap(JSON.stringify(contract ?? {}, null, 1), 12000)}\n</contract>\n\n<builder_result>\n${cap(JSON.stringify(result ?? {}, null, 1), 6000)}\n</builder_result>\n\n${investigationVerdict ? `<investigation_verdict>${investigationVerdict}</investigation_verdict>\n${VERDICT_RULE}\n\n` : ""}<changed_files>\n${files.join("\n")}\n</changed_files>\n\n<rubric>\n${REVIEW_RUBRIC}\n</rubric>\n\n<row_skeleton>\n${cap(sk, 30000)}\n</row_skeleton>\n\n<diff>\n${boundDiff(diff, { stat, base })}\n</diff>${structure ? `\n<structure>${String(structure).replace(/[\r\n]+/g, " ")}</structure>` : ""}`;
450
465
  }
451
466
 
452
467
  /**