forgedock 1.30.0 → 1.31.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 (155) hide show
  1. package/bin/engine/outcome-codes.mjs +3 -2
  2. package/bin/engine/runlog.mjs +82 -12
  3. package/bin/engine/telemetry-seam.mjs +11 -4
  4. package/bin/engine/telemetry-seam.test.mjs +18 -0
  5. package/bin/engine-cli.mjs +362 -33
  6. package/bin/engine.mjs +47 -37
  7. package/bin/forgedock.mjs +35 -3
  8. package/bin/gc.mjs +50 -17
  9. package/bin/labels.json +5 -0
  10. package/bin/lean/apishape.mjs +170 -0
  11. package/bin/lean/apishape.test.mjs +187 -0
  12. package/bin/lean/batch.mjs +32 -0
  13. package/bin/lean/batch.test.mjs +294 -1
  14. package/bin/lean/budget.mjs +89 -0
  15. package/bin/lean/budget.test.mjs +106 -0
  16. package/bin/lean/checks.mjs +32 -9
  17. package/bin/lean/checks.test.mjs +78 -1
  18. package/bin/lean/conformance.e2e.test.mjs +27 -5
  19. package/bin/lean/conformance.mjs +150 -15
  20. package/bin/lean/conformance.test.mjs +175 -3
  21. package/bin/lean/context.mjs +116 -43
  22. package/bin/lean/context.test.mjs +139 -4
  23. package/bin/lean/decompose.mjs +90 -0
  24. package/bin/lean/decompose.test.mjs +105 -0
  25. package/bin/lean/depcache.mjs +11 -7
  26. package/bin/lean/depcache.test.mjs +81 -0
  27. package/bin/lean/e2e.mjs +102 -0
  28. package/bin/lean/e2e.test.mjs +86 -0
  29. package/bin/lean/effects.mjs +37 -15
  30. package/bin/lean/explore.mjs +47 -0
  31. package/bin/lean/explore.test.mjs +61 -0
  32. package/bin/lean/facts.mjs +84 -0
  33. package/bin/lean/facts.test.mjs +66 -0
  34. package/bin/lean/fixround.mjs +90 -0
  35. package/bin/lean/fixround.test.mjs +117 -0
  36. package/bin/lean/heavy.mjs +35 -2
  37. package/bin/lean/heavy.test.mjs +32 -2
  38. package/bin/lean/lean.mjs +650 -124
  39. package/bin/lean/lean.test.mjs +1450 -37
  40. package/bin/lean/ledger.mjs +5 -3
  41. package/bin/lean/ledger.test.mjs +18 -0
  42. package/bin/lean/light.mjs +135 -0
  43. package/bin/lean/light.test.mjs +173 -0
  44. package/bin/lean/limits.test.mjs +36 -1
  45. package/bin/lean/orchestrate.mjs +261 -38
  46. package/bin/lean/pitfalls.mjs +136 -0
  47. package/bin/lean/pitfalls.test.mjs +117 -0
  48. package/bin/lean/postmerge.mjs +85 -0
  49. package/bin/lean/postmerge.test.mjs +79 -0
  50. package/bin/lean/precheck.mjs +138 -13
  51. package/bin/lean/precheck.test.mjs +113 -1
  52. package/bin/lean/prefilter.mjs +159 -5
  53. package/bin/lean/prefilter.test.mjs +152 -3
  54. package/bin/lean/preflight.mjs +108 -3
  55. package/bin/lean/preflight.test.mjs +48 -1
  56. package/bin/lean/probe.mjs +297 -0
  57. package/bin/lean/probe.test.mjs +210 -0
  58. package/bin/lean/prompts.mjs +101 -13
  59. package/bin/lean/render.mjs +7 -3
  60. package/bin/lean/resume.mjs +67 -0
  61. package/bin/lean/resume.test.mjs +84 -0
  62. package/bin/lean/review-trigger.mjs +12 -0
  63. package/bin/lean/review-trigger.test.mjs +17 -1
  64. package/bin/lean/routing.mjs +74 -0
  65. package/bin/lean/routing.test.mjs +67 -0
  66. package/bin/lean/sched.test.mjs +127 -1
  67. package/bin/lean/schedd.mjs +55 -9
  68. package/bin/lean/schedd.test.mjs +149 -3
  69. package/bin/lean/selfreview.mjs +37 -0
  70. package/bin/lean/selfreview.test.mjs +51 -0
  71. package/bin/lean/seqlease.mjs +40 -0
  72. package/bin/lean/seqlease.test.mjs +101 -0
  73. package/bin/lean/sequence.mjs +296 -13
  74. package/bin/lean/sequence.test.mjs +263 -2
  75. package/bin/lean/toolchain.mjs +60 -15
  76. package/bin/lean/toolchain.test.mjs +120 -3
  77. package/bin/lean/toolstats.mjs +72 -0
  78. package/bin/lean/toolstats.test.mjs +47 -0
  79. package/bin/lean/verifier.mjs +232 -0
  80. package/bin/lean/verifier.test.mjs +163 -0
  81. package/bin/lean/verify.mjs +72 -10
  82. package/bin/lean/verify.test.mjs +68 -3
  83. package/bin/review-staging.mjs +533 -0
  84. package/bin/runner.mjs +82 -13
  85. package/bin/stats/aggregate.mjs +87 -2
  86. package/bin/stats/efficiency.mjs +319 -0
  87. package/bin/stats/efficiency.test.mjs +233 -0
  88. package/bin/stats/github-source.mjs +8 -3
  89. package/bin/stats/index.mjs +69 -6
  90. package/bin/stats/local-source.mjs +86 -3
  91. package/bin/stats/query.mjs +48 -0
  92. package/bin/stats/render.mjs +22 -0
  93. package/bin/stats/stats.test.mjs +224 -5
  94. package/commands/orchestrate.md +3 -1
  95. package/commands/review-pr-agents/protocols.md +1 -1
  96. package/commands/review-pr-staging.md +84 -831
  97. package/commands/review-pr.md +1 -1
  98. package/commands/work-on/build/implement.md +1 -0
  99. package/commands/work-on/build/validate.md +11 -1
  100. package/docs/CONFIG.md +80 -5
  101. package/docs/OUTCOME-CODES.md +2 -1
  102. package/docs/STATS.md +53 -1
  103. package/docs/TELEMETRY.md +1 -1
  104. package/docs/spec/review-protocol.md +1 -1
  105. package/package.json +1 -1
  106. package/scripts/ci-local/lib.mjs +20 -0
  107. package/scripts/ci-local/plan.mjs +2 -12
  108. package/scripts/ci-local/scoping.mjs +10 -1
  109. package/scripts/ci-local.test.sh +2 -0
  110. package/scripts/fixtures/apishape/other/go-change/base/x.go +3 -0
  111. package/scripts/fixtures/apishape/other/go-change/head/x.go +3 -0
  112. package/scripts/fixtures/apishape/python/generator-function/base/seq.py +3 -0
  113. package/scripts/fixtures/apishape/python/generator-function/head/seq.py +2 -0
  114. package/scripts/fixtures/apishape/python/ichunked/base/more.py +17 -0
  115. package/scripts/fixtures/apishape/python/ichunked/head/more.py +20 -0
  116. package/scripts/fixtures/apishape/python/private-edit/base/calc.py +6 -0
  117. package/scripts/fixtures/apishape/python/private-edit/head/calc.py +9 -0
  118. package/scripts/fixtures/apishape/python/removed/base/ops.py +6 -0
  119. package/scripts/fixtures/apishape/python/removed/head/ops.py +2 -0
  120. package/scripts/fixtures/apishape/python/renamed/base/text.py +2 -0
  121. package/scripts/fixtures/apishape/python/renamed/head/text.py +2 -0
  122. package/scripts/fixtures/apishape/python/signature/base/shapes.py +2 -0
  123. package/scripts/fixtures/apishape/python/signature/head/shapes.py +2 -0
  124. package/scripts/fixtures/apishape/python/sync-async/base/client.py +7 -0
  125. package/scripts/fixtures/apishape/python/sync-async/head/client.py +7 -0
  126. package/scripts/fixtures/apishape/ts/generator-function/base/seq.js +3 -0
  127. package/scripts/fixtures/apishape/ts/generator-function/head/seq.js +5 -0
  128. package/scripts/fixtures/apishape/ts/private-edit/base/calc.ts +7 -0
  129. package/scripts/fixtures/apishape/ts/private-edit/head/calc.ts +9 -0
  130. package/scripts/fixtures/apishape/ts/removed/base/ops.ts +7 -0
  131. package/scripts/fixtures/apishape/ts/removed/head/ops.ts +3 -0
  132. package/scripts/fixtures/apishape/ts/renamed/base/text.ts +3 -0
  133. package/scripts/fixtures/apishape/ts/renamed/head/text.ts +3 -0
  134. package/scripts/fixtures/apishape/ts/signature/base/shapes.ts +3 -0
  135. package/scripts/fixtures/apishape/ts/signature/head/shapes.ts +3 -0
  136. package/scripts/fixtures/apishape/ts/sync-async/base/client.mjs +5 -0
  137. package/scripts/fixtures/apishape/ts/sync-async/head/client.mjs +5 -0
  138. package/scripts/forge-root.test.sh +2 -2
  139. package/scripts/publish-staging-sync.test.sh +62 -4
  140. package/scripts/review-finalize.sh +17 -6
  141. package/scripts/review-finalize.test.sh +47 -0
  142. package/scripts/review-pr-arguments.test.sh +13 -1
  143. package/scripts/review-pr-delta-wiring.test.sh +2 -2
  144. package/scripts/review-pr-gate2.test.sh +6 -14
  145. package/scripts/review-pr-staging-finding-filter.test.sh +4 -4
  146. package/scripts/review-pr-staging-scope.test.sh +39 -33
  147. package/scripts/review-prepare.sh +73 -6
  148. package/scripts/review-prepare.test.sh +85 -0
  149. package/scripts/review-staging-post.sh +293 -0
  150. package/scripts/review-staging-post.test.sh +143 -0
  151. package/scripts/review-staging-pre.sh +401 -0
  152. package/scripts/review-staging-pre.test.sh +150 -0
  153. package/scripts/symbol-index.mjs +26 -7
  154. package/scripts/symbol-index.py +55 -9
  155. package/scripts/trusted-comments.test.sh +1 -1
@@ -48,9 +48,11 @@ export function dockerLabels({ batch, lane, issue, expires } = {}) {
48
48
  return [`forgedock.owner=1`, `forgedock.batch=${v(batch)}`, `forgedock.lane=${v(lane)}`, `forgedock.issue=${v(issue)}`, `forgedock.expires=${v(expires)}`];
49
49
  }
50
50
 
51
- export function makeLedger({ root = ledgerRoot(), batch = "adhoc", lane, owner, issue, host = hostname(), pid = process.pid, now = () => Date.now(), ttlMs = LEDGER_TTL_MS, fs = nodeFs } = {}) {
51
+ /** #4432: with a `repo`, the lane file is `<batch>/<owner>__<repo>__<lane>.json` (issue numbers, hence lane names, are per repo). */
52
+ export function makeLedger({ root = ledgerRoot(), batch = "adhoc", lane, owner, issue, repo = null, host = hostname(), pid = process.pid, now = () => Date.now(), ttlMs = LEDGER_TTL_MS, fs = nodeFs } = {}) {
52
53
  const dir = join(root, safeSegment(batch));
53
- const path = join(dir, `${safeSegment(lane ?? `lane-${issue}`)}.json`);
54
+ const repoPrefix = repo ? `${String(repo).split("/").map(safeSegment).join("__")}__` : "";
55
+ const path = join(dir, `${repoPrefix}${safeSegment(lane ?? `lane-${issue}`)}.json`);
54
56
  const read = () => { try { const j = JSON.parse(fs.readFileSync(path, "utf8")); return j && typeof j === "object" && Array.isArray(j.resources) ? j : null; } catch { return null; } };
55
57
  const write = (j) => {
56
58
  fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
@@ -58,7 +60,7 @@ export function makeLedger({ root = ledgerRoot(), batch = "adhoc", lane, owner,
58
60
  fs.writeFileSync(tmp, JSON.stringify(j, null, 2) + "\n");
59
61
  fs.renameSync(tmp, path);
60
62
  };
61
- const fresh = () => { const c = now(); return { v: 1, owner, batch: String(batch), lane: String(lane ?? `lane-${issue}`), issue, host, pid, created: c, expires: c + ttlMs, resources: [] }; };
63
+ const fresh = () => { const c = now(); return { v: 1, owner, batch: String(batch), lane: String(lane ?? `lane-${issue}`), issue, ...(repo ? { repo: String(repo) } : {}), host, pid, created: c, expires: c + ttlMs, resources: [] }; };
62
64
  const same = (r, kind, value) => r.kind === kind && String(r.value) === String(value);
63
65
  return {
64
66
  path,
@@ -54,6 +54,24 @@ describe("lane resource ledger (#4179)", () => {
54
54
  });
55
55
  });
56
56
 
57
+ describe("repo-scoped lane files (#4432)", () => {
58
+ it("ledgers of two repos with the same lane are separate files", () => {
59
+ const root = tmpDir("ledger-");
60
+ const one = makeLedger({ root, batch: "adhoc", lane: "lean-2", owner: "core_1", issue: 2, repo: "acme/one" });
61
+ const two = makeLedger({ root, batch: "adhoc", lane: "lean-2", owner: "core_2", issue: 2, repo: "acme/two" });
62
+ assert.equal(one.path, join(root, "adhoc", "acme__one__lean-2.json"));
63
+ assert.notEqual(one.path, two.path);
64
+ one.register("worktree", "/one/.claude/worktrees/lean-2");
65
+ two.register("worktree", "/two/.claude/worktrees/lean-2");
66
+ one.release("worktree", "/one/.claude/worktrees/lean-2");
67
+ assert.deepEqual(two.entries().map((r) => r.value), ["/two/.claude/worktrees/lean-2"], "one repo's teardown leaves the other's resources");
68
+ assert.equal(JSON.parse(readFileSync(two.path, "utf8")).repo, "acme/two");
69
+ assert.equal(JSON.parse(readFileSync(two.path, "utf8")).lane, "lean-2", "the lane field is unchanged");
70
+ const legacy = makeLedger({ root, batch: "adhoc", lane: "lean-2", issue: 2 });
71
+ assert.equal(legacy.path, join(root, "adhoc", "lean-2.json"), "no repo: unchanged path");
72
+ });
73
+ });
74
+
57
75
  describe("live lane count (#4182)", () => {
58
76
  it("counts only live, same-host, unexpired lane files across batches, and deletes nothing", async () => {
59
77
  const { mkdirSync, writeFileSync } = await import("node:fs");
@@ -0,0 +1,135 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ /**
4
+ * Light-lane eligibility and upgrade triggers (#4448, parent #4442). Rules first: an issue is eligible for the light
5
+ * lane (one builder session, deterministic gates) when it has a concrete repro or expected behaviour, predicts 1..max_files
6
+ * non-test files, is size S, touches no risk path and is not an investigation, post-deploy or decision issue. A missing
7
+ * repro or file prediction is `ambiguous` (the caller's cheap classifier decides). During a light lane, any upgrade
8
+ * trigger (oversized diff, repro not red on base, two test failures, risk path, public-API-shape change from #4449)
9
+ * moves the lane to the full pipeline. forge.yaml `lean.light` {mode: off|shadow (default)|on, max_lines: 40,
10
+ * max_files: 2}: off is never eligible, shadow reports the verdict without changing the lane shape, on lets an eligible
11
+ * issue run light (lane wiring: #4450). Pure: no I/O, no model call.
12
+ */
13
+ import { riskClassOf, riskPathHits } from "./review-trigger.mjs";
14
+ import { classifyPath } from "./routing.mjs";
15
+ import { laneSize } from "./budget.mjs";
16
+ import { isInvestigation } from "./conformance.mjs";
17
+ import { postDeployOnly } from "./prefilter.mjs";
18
+
19
+ export const LIGHT_MODES = Object.freeze(["off", "shadow", "on"]);
20
+ export const LIGHT_DEFAULTS = Object.freeze({ mode: "shadow", maxLines: 40, maxFiles: 2 });
21
+
22
+ const posInt = (v) => { const s = String(v ?? "").trim(); return /^\d+$/.test(s) && Number(s) > 0 ? Number(s) : null; };
23
+
24
+ /**
25
+ * forge.yaml lean section -> {mode, maxLines, maxFiles}. The one-level parser flattens a nested `light:` block to
26
+ * `lean.mode|max_lines|max_files`; flat `lean.light_mode|light_max_lines|light_max_files` also work and win.
27
+ * An unknown mode is shadow; a limit that is not a positive integer keeps its default.
28
+ */
29
+ export function parseLightConfig(lean = {}) {
30
+ const l = lean && typeof lean === "object" ? lean : {};
31
+ const m = String(l.light_mode ?? l.mode ?? "").trim().toLowerCase();
32
+ return {
33
+ mode: LIGHT_MODES.includes(m) ? m : LIGHT_DEFAULTS.mode,
34
+ maxLines: posInt(l.light_max_lines) ?? posInt(l.max_lines) ?? LIGHT_DEFAULTS.maxLines,
35
+ maxFiles: posInt(l.light_max_files) ?? posInt(l.max_files) ?? LIGHT_DEFAULTS.maxFiles,
36
+ };
37
+ }
38
+
39
+ /** A parsed config, or the defaults merged with whatever valid fields `cfg` carries. */
40
+ const cfgOf = (cfg) => {
41
+ const c = cfg && typeof cfg === "object" ? cfg : {};
42
+ return {
43
+ mode: LIGHT_MODES.includes(c.mode) ? c.mode : LIGHT_DEFAULTS.mode,
44
+ maxLines: posInt(c.maxLines) ?? LIGHT_DEFAULTS.maxLines,
45
+ maxFiles: posInt(c.maxFiles) ?? LIGHT_DEFAULTS.maxFiles,
46
+ };
47
+ };
48
+
49
+ const uniquePaths = (xs) => [...new Set((Array.isArray(xs) ? xs : []).map((p) => String(p ?? "").trim().replace(/^\.\//, "")).filter(Boolean))].sort();
50
+ const labelNames = (labels) => (Array.isArray(labels) ? labels : []).map((l) => String(l?.name ?? l ?? "").trim()).filter(Boolean);
51
+ const sortReasons = (rs) => rs.sort((a, b) => a.code.localeCompare(b.code) || a.detail.localeCompare(b.detail));
52
+
53
+ const REPRO_RE = /steps to reproduce|\brepro(duction|duce)?\b|\bexpected[ _-]+(behaviou?r|result|output)\b|\bactual[ _-]+(behaviou?r|result|output)\b/i;
54
+ const FENCE_RE = /^\s*(```|~~~)/m;
55
+ const DECISION_LABEL_RE = /^(type[:/]\s*)?(decision|needs-decision|product-decision|rfc|adr)$/i;
56
+ const DECISION_TITLE_RE = /^\s*(decision|rfc|adr)[(:]/i;
57
+ const POST_DEPLOY_LABELS = new Set(["post-deploy", "awaiting-promotion"]);
58
+
59
+ /** Risk details for `files`: a built-in risk word or a declared review.risk_paths glob. */
60
+ function riskDetails(files, riskPaths) {
61
+ const declared = new Set(riskPathHits(files, riskPaths));
62
+ const out = [];
63
+ for (const f of files) {
64
+ const cls = riskClassOf(f);
65
+ if (cls) out.push(`${f} (${cls})`);
66
+ else if (declared.has(f)) out.push(`${f} (review.risk_paths)`);
67
+ }
68
+ return out;
69
+ }
70
+
71
+ /**
72
+ * Light-lane eligibility of one issue: {mode, verdict, eligible, light, reasons:[{code, detail}]}.
73
+ * verdict: off | eligible | ineligible (a hard reason: files, size, risk_path, investigate, post_deploy, decision) |
74
+ * ambiguous (only soft reasons: no_repro, files_unknown). `light` is the lane shape: true only in mode on when eligible.
75
+ */
76
+ export function lightEligibility({ title = "", body = "", labels = [], predictedFiles = [], riskPaths = [], cfg = {} } = {}) {
77
+ const c = cfgOf(cfg);
78
+ if (c.mode === "off") return { mode: "off", verdict: "off", eligible: false, light: false, reasons: [] };
79
+ const hard = [], soft = [];
80
+ const text = String(body ?? "").replace(/<!--[\s\S]*?-->/g, " ");
81
+ if (!FENCE_RE.test(text) && !REPRO_RE.test(text)) soft.push({ code: "no_repro", detail: "no repro, expected behaviour or code block in the body" });
82
+
83
+ const files = uniquePaths(predictedFiles);
84
+ const nonTest = files.filter((f) => classifyPath(f) !== "test");
85
+ if (nonTest.length > c.maxFiles) hard.push({ code: "files", detail: `${nonTest.length} predicted non-test files (max ${c.maxFiles})` });
86
+ else if (!nonTest.length) soft.push({ code: "files_unknown", detail: "no predicted non-test files" });
87
+
88
+ const names = labelNames(labels);
89
+ const size = laneSize({ labels: names });
90
+ if (size && size !== "S") hard.push({ code: "size", detail: `size:${size}` });
91
+
92
+ for (const d of riskDetails(files, riskPaths)) hard.push({ code: "risk_path", detail: d });
93
+
94
+ if (isInvestigation(title)) hard.push({ code: "investigate", detail: "investigate( issue" });
95
+ const pdLabel = names.find((n) => POST_DEPLOY_LABELS.has(n.toLowerCase()));
96
+ if (pdLabel) hard.push({ code: "post_deploy", detail: `label ${pdLabel}` });
97
+ else if (postDeployOnly(body)) hard.push({ code: "post_deploy", detail: "every checklist item is a post-deploy check" });
98
+ const decLabel = names.find((n) => DECISION_LABEL_RE.test(n));
99
+ if (decLabel) hard.push({ code: "decision", detail: `label ${decLabel}` });
100
+ else if (DECISION_TITLE_RE.test(String(title ?? ""))) hard.push({ code: "decision", detail: "decision title" });
101
+
102
+ const verdict = hard.length ? "ineligible" : soft.length ? "ambiguous" : "eligible";
103
+ const eligible = verdict === "eligible";
104
+ return { mode: c.mode, verdict, eligible, light: c.mode === "on" && eligible, reasons: sortReasons([...hard, ...soft]) };
105
+ }
106
+
107
+ const lineCount = (v) => { const n = Number(v); return Number.isFinite(n) && n > 0 ? n : 0; };
108
+
109
+ /**
110
+ * Upgrade triggers of a light lane: {upgrade, reasons:[{code, detail}]}. diff: {files:[{path, added, deleted}]}
111
+ * (git numstat; "-" binary counts 0). repro: {base, head} words of scripts/acceptance-red-green.sh. testFailures: count
112
+ * of failed targeted-test runs. apiShape: public-API-shape changes from #4449 (strings or {symbol, kind}).
113
+ * Codes: diff_lines, diff_files (non-test files only), repro_not_red, tests_failed_twice, risk_path (every path), api_shape.
114
+ */
115
+ export function upgradeTriggers({ diff = null, repro = null, testFailures = 0, riskPaths = [], apiShape = [], cfg = {} } = {}) {
116
+ const c = cfgOf(cfg);
117
+ const reasons = [];
118
+ const rows = (Array.isArray(diff?.files) ? diff.files : []).filter((f) => f && String(f.path ?? "").trim());
119
+ const nonTest = rows.filter((f) => classifyPath(String(f.path).trim()) !== "test");
120
+ const lines = nonTest.reduce((n, f) => n + lineCount(f.added) + lineCount(f.deleted), 0);
121
+ if (lines > c.maxLines) reasons.push({ code: "diff_lines", detail: `${lines} non-test lines (max ${c.maxLines})` });
122
+ const nFiles = uniquePaths(nonTest.map((f) => f.path)).length;
123
+ if (nFiles > c.maxFiles) reasons.push({ code: "diff_files", detail: `${nFiles} non-test files (max ${c.maxFiles})` });
124
+ const base = repro && typeof repro === "object" ? String(repro.base ?? "") : "";
125
+ if (base !== "FAILED") reasons.push({ code: "repro_not_red", detail: base ? `repro on base: ${base}` : "no repro result" });
126
+ const fails = Number(testFailures);
127
+ if (Number.isFinite(fails) && fails >= 2) reasons.push({ code: "tests_failed_twice", detail: `${fails} failed test runs` });
128
+ for (const d of riskDetails(uniquePaths(rows.map((f) => f.path)), riskPaths)) reasons.push({ code: "risk_path", detail: d });
129
+ for (const a of Array.isArray(apiShape) ? apiShape : []) {
130
+ const d = typeof a === "string" ? a.trim() : a && typeof a === "object" ? [a.symbol, a.kind].filter(Boolean).map(String).join(": ") : "";
131
+ if (d) reasons.push({ code: "api_shape", detail: d });
132
+ }
133
+ sortReasons(reasons);
134
+ return { upgrade: reasons.length > 0, reasons };
135
+ }
@@ -0,0 +1,173 @@
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 { readFileSync, writeFileSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { tmpDir } from "../tests/_tmp.mjs";
8
+ import { LIGHT_MODES, LIGHT_DEFAULTS, parseLightConfig, lightEligibility, upgradeTriggers } from "./light.mjs";
9
+ import { readLight } from "../engine-cli.mjs";
10
+
11
+ const ON = { mode: "on", maxLines: 40, maxFiles: 2 };
12
+ const BODY = "## Steps to reproduce\n```js\nchunk([1, 2], 0)\n```\nExpected behaviour: a ValueError.";
13
+ /** A fully eligible issue; override one field to break one rule. */
14
+ const issue = (o = {}) => ({ title: "fix(chunk): reject n=0", body: BODY, labels: ["bug", "size:S"], predictedFiles: ["src/chunk.py", "tests/test_chunk.py"], riskPaths: [], cfg: ON, ...o });
15
+ const codes = (r) => r.reasons.map((x) => x.code);
16
+ const numstat = (...rows) => ({ files: rows.map(([path, added, deleted = 0]) => ({ path, added, deleted })) });
17
+ const RED = { base: "FAILED", head: "RAN" };
18
+
19
+ describe("parseLightConfig (#4448)", () => {
20
+ it("defaults to shadow, 40 lines, 2 files", () => {
21
+ assert.deepEqual(LIGHT_MODES, ["off", "shadow", "on"]);
22
+ assert.deepEqual(parseLightConfig(), LIGHT_DEFAULTS);
23
+ assert.deepEqual(parseLightConfig(null), { mode: "shadow", maxLines: 40, maxFiles: 2 });
24
+ });
25
+ it("reads the flattened nested keys; flat light_* keys win; junk keeps the default", () => {
26
+ assert.deepEqual(parseLightConfig({ mode: "ON", max_lines: "60", max_files: "3" }), { mode: "on", maxLines: 60, maxFiles: 3 });
27
+ assert.deepEqual(parseLightConfig({ mode: "on", light_mode: "off", max_lines: "60", light_max_lines: "80", light_max_files: "1" }), { mode: "off", maxLines: 80, maxFiles: 1 });
28
+ assert.deepEqual(parseLightConfig({ mode: "enforce", max_lines: "0", max_files: "-1" }), LIGHT_DEFAULTS);
29
+ assert.deepEqual(parseLightConfig({ light_max_lines: "4x" }), LIGHT_DEFAULTS);
30
+ });
31
+ it("readLight reads forge.yaml lean.light; absent forge.yaml is the defaults", () => {
32
+ const d = tmpDir("light-yaml-");
33
+ assert.deepEqual(readLight(d), LIGHT_DEFAULTS);
34
+ writeFileSync(join(d, "forge.yaml"), "lean:\n light:\n mode: on\n max_lines: 50\n max_files: 3\n");
35
+ assert.deepEqual(readLight(d), { mode: "on", maxLines: 50, maxFiles: 3 });
36
+ writeFileSync(join(d, "forge.yaml"), "lean:\n light_mode: off\n");
37
+ assert.equal(readLight(d).mode, "off");
38
+ });
39
+ });
40
+
41
+ describe("lightEligibility rules (#4448)", () => {
42
+ it("accepts a well-specified size:S issue with 1-2 predicted files", () => {
43
+ const r = lightEligibility(issue());
44
+ assert.deepEqual(r, { mode: "on", verdict: "eligible", eligible: true, light: true, reasons: [] });
45
+ });
46
+ it("repro or expected behaviour: present accepts, absent is ambiguous (never a model call)", () => {
47
+ for (const body of ["Repro: `x()` throws", "Expected result: 3", "```\nboom\n```", "actual output differs"]) assert.equal(lightEligibility(issue({ body })).verdict, "eligible", body);
48
+ const r = lightEligibility(issue({ body: "It is broken. <!-- steps to reproduce -->" }));
49
+ assert.equal(r.verdict, "ambiguous");
50
+ assert.equal(r.eligible, false);
51
+ assert.equal(r.light, false);
52
+ assert.deepEqual(codes(r), ["no_repro"]);
53
+ });
54
+ it("predicted files: 1 or 2 non-test files accept, 3 reject, tests do not count, none is ambiguous", () => {
55
+ assert.equal(lightEligibility(issue({ predictedFiles: ["src/a.py"] })).verdict, "eligible");
56
+ assert.equal(lightEligibility(issue({ predictedFiles: ["src/a.py", "src/b.py", "tests/test_a.py", "src/c.test.ts"] })).verdict, "eligible");
57
+ const r = lightEligibility(issue({ predictedFiles: ["src/a.py", "src/b.py", "src/c.py"] }));
58
+ assert.equal(r.verdict, "ineligible");
59
+ assert.deepEqual(codes(r), ["files"]);
60
+ assert.deepEqual(codes(lightEligibility(issue({ predictedFiles: [] }))), ["files_unknown"]);
61
+ assert.equal(lightEligibility(issue({ predictedFiles: ["tests/test_a.py"] })).verdict, "ambiguous");
62
+ assert.equal(lightEligibility(issue({ predictedFiles: ["src/a.py", "src/b.py", "src/c.py"], cfg: { ...ON, maxFiles: 3 } })).verdict, "eligible");
63
+ });
64
+ it("size: size:S accepts, size:M and size:L reject", () => {
65
+ assert.equal(lightEligibility(issue({ labels: [{ name: "size:S" }] })).verdict, "eligible");
66
+ assert.equal(lightEligibility(issue({ labels: [] })).verdict, "eligible");
67
+ for (const s of ["size:M", "size:L"]) {
68
+ const r = lightEligibility(issue({ labels: ["bug", s] }));
69
+ assert.equal(r.verdict, "ineligible", s);
70
+ assert.deepEqual(codes(r), ["size"]);
71
+ }
72
+ });
73
+ it("risk path: a built-in risk word or a declared review.risk_paths glob rejects; a clean path accepts", () => {
74
+ const w = lightEligibility(issue({ predictedFiles: ["src/auth/login.ts"] }));
75
+ assert.deepEqual(codes(w), ["risk_path"]);
76
+ assert.match(w.reasons[0].detail, /src\/auth\/login\.ts \(auth\)/);
77
+ const g = lightEligibility(issue({ predictedFiles: ["src/detect/rules.ts"], riskPaths: "src/detect/**" }));
78
+ assert.deepEqual(codes(g), ["risk_path"]);
79
+ assert.match(g.reasons[0].detail, /review\.risk_paths/);
80
+ assert.equal(lightEligibility(issue({ predictedFiles: ["src/chunk.py"], riskPaths: ["src/detect/**"] })).verdict, "eligible");
81
+ });
82
+ it("kind: investigate(, post-deploy and decision issues reject; a plain bug accepts", () => {
83
+ assert.deepEqual(codes(lightEligibility(issue({ title: "investigate(lean): why lanes stall" }))), ["investigate"]);
84
+ assert.deepEqual(codes(lightEligibility(issue({ labels: ["awaiting-promotion"] }))), ["post_deploy"]);
85
+ assert.deepEqual(codes(lightEligibility(issue({ labels: ["post-deploy"] }))), ["post_deploy"]);
86
+ const pd = "Post-deploy checks.\n```\nx\n```\n- [ ] `R3: verify in prod` — post-deploy: needs a deploy\n";
87
+ assert.deepEqual(codes(lightEligibility(issue({ body: pd }))), ["post_deploy"]);
88
+ for (const labels of [["decision"], ["needs-decision"], ["type:decision"], ["product-decision"], ["RFC"], ["adr"]]) assert.deepEqual(codes(lightEligibility(issue({ labels }))), ["decision"], labels[0]);
89
+ for (const title of ["decision(lean): pick a queue", "RFC: light lanes", "adr(engine): leases"]) assert.deepEqual(codes(lightEligibility(issue({ title }))), ["decision"], title);
90
+ assert.equal(lightEligibility(issue({ title: "fix: decision table off by one", labels: ["bug"] })).verdict, "eligible");
91
+ });
92
+ it("hard reasons win over soft ones; reasons are sorted and deterministic", () => {
93
+ const o = issue({ body: "broken", labels: ["size:L", "decision"], predictedFiles: ["src/billing.ts", "a.py", "b.py"] });
94
+ const r = lightEligibility(o);
95
+ assert.equal(r.verdict, "ineligible");
96
+ assert.deepEqual(codes(r), ["decision", "files", "no_repro", "risk_path", "size"]);
97
+ assert.deepEqual(lightEligibility({ ...o, predictedFiles: [...o.predictedFiles].reverse() }), r);
98
+ });
99
+ it("tolerates missing and junk input", () => {
100
+ assert.equal(lightEligibility().verdict, "ambiguous");
101
+ assert.doesNotThrow(() => lightEligibility({ title: null, body: null, labels: "x", predictedFiles: "y", riskPaths: 3, cfg: null }));
102
+ });
103
+ });
104
+
105
+ describe("lightEligibility modes (#4448)", () => {
106
+ it("off never returns eligible, even for a fully eligible issue", () => {
107
+ assert.deepEqual(lightEligibility(issue({ cfg: { mode: "off" } })), { mode: "off", verdict: "off", eligible: false, light: false, reasons: [] });
108
+ });
109
+ it("shadow returns the verdict without changing the lane shape", () => {
110
+ const r = lightEligibility(issue({ cfg: { mode: "shadow" } }));
111
+ assert.equal(r.verdict, "eligible");
112
+ assert.equal(r.eligible, true);
113
+ assert.equal(r.light, false);
114
+ assert.equal(lightEligibility(issue({ cfg: {} })).mode, "shadow", "default mode");
115
+ assert.equal(lightEligibility(issue({ cfg: { mode: "shadow" }, labels: ["size:M"] })).verdict, "ineligible");
116
+ });
117
+ it("on runs an eligible issue light and never an ineligible one", () => {
118
+ assert.equal(lightEligibility(issue()).light, true);
119
+ assert.equal(lightEligibility(issue({ labels: ["size:M"] })).light, false);
120
+ assert.equal(lightEligibility(issue({ body: "broken" })).light, false);
121
+ });
122
+ });
123
+
124
+ describe("upgradeTriggers (#4448)", () => {
125
+ const quiet = { diff: numstat(["src/chunk.py", 30, 10], ["tests/test_chunk.py", 200]), repro: RED, testFailures: 1, cfg: ON };
126
+ it("no trigger on a small red-green diff", () => {
127
+ assert.deepEqual(upgradeTriggers(quiet), { upgrade: false, reasons: [] });
128
+ });
129
+ it("oversized diff by lines: 41 non-test lines fire, 40 do not, test lines are ignored, max_lines is honoured", () => {
130
+ const r = upgradeTriggers({ ...quiet, diff: numstat(["src/chunk.py", 31, 10], ["tests/test_chunk.py", 500]) });
131
+ assert.equal(r.upgrade, true);
132
+ assert.deepEqual(codes(r), ["diff_lines"]);
133
+ assert.match(r.reasons[0].detail, /41 non-test lines \(max 40\)/);
134
+ assert.equal(upgradeTriggers({ ...quiet, diff: numstat(["src/a.py", 41], ["src/b.py", "-", "-"]), cfg: { ...ON, maxLines: 41 } }).upgrade, false);
135
+ });
136
+ it("oversized diff by files: 3 non-test files fire, 2 do not", () => {
137
+ assert.deepEqual(codes(upgradeTriggers({ ...quiet, diff: numstat(["src/a.py", 1], ["src/b.py", 1], ["src/c.py", 1]) })), ["diff_files"]);
138
+ assert.equal(upgradeTriggers({ ...quiet, diff: numstat(["src/a.py", 1], ["src/b.py", 1], ["tests/test_a.py", 1]) }).upgrade, false);
139
+ });
140
+ it("non-red repro: anything but FAILED on base fires, a missing repro included", () => {
141
+ for (const base of ["RAN", "RAN_NOTHING", "UNRUNNABLE", "SKIPPED"]) assert.deepEqual(codes(upgradeTriggers({ ...quiet, repro: { base, head: "RAN" } })), ["repro_not_red"], base);
142
+ assert.deepEqual(codes(upgradeTriggers({ ...quiet, repro: null })), ["repro_not_red"]);
143
+ assert.equal(upgradeTriggers({ ...quiet, repro: RED }).upgrade, false);
144
+ });
145
+ it("tests fail twice: 2 failures fire, 1 does not", () => {
146
+ assert.deepEqual(codes(upgradeTriggers({ ...quiet, testFailures: 2 })), ["tests_failed_twice"]);
147
+ assert.equal(upgradeTriggers({ ...quiet, testFailures: 1 }).upgrade, false);
148
+ });
149
+ it("risk path: a risk word or declared glob on any diff path fires, tests included; a clean diff does not", () => {
150
+ assert.deepEqual(codes(upgradeTriggers({ ...quiet, diff: numstat(["src/session.ts", 2]) })), ["risk_path"]);
151
+ assert.deepEqual(codes(upgradeTriggers({ ...quiet, diff: numstat(["src/a.py", 2], ["tests/test_payments.py", 2]) })), ["risk_path"]);
152
+ assert.deepEqual(codes(upgradeTriggers({ ...quiet, diff: numstat(["lib/detect.py", 2]), riskPaths: "lib/detect.py" })), ["risk_path"]);
153
+ assert.equal(upgradeTriggers({ ...quiet, riskPaths: ["lib/**"] }).upgrade, false);
154
+ });
155
+ it("public-API-shape changes (#4449) plug in as one more input", () => {
156
+ const r = upgradeTriggers({ ...quiet, apiShape: ["ichunked: generator -> function", { symbol: "chunk", kind: "signature" }, "", null] });
157
+ assert.deepEqual(r.reasons, [{ code: "api_shape", detail: "chunk: signature" }, { code: "api_shape", detail: "ichunked: generator -> function" }]);
158
+ });
159
+ it("reasons are sorted and deterministic; junk input does not throw", () => {
160
+ const o = { diff: numstat(["src/z.py", 30], ["src/auth.py", 30], ["src/m.py", 1]), repro: null, testFailures: 3, cfg: ON };
161
+ const r = upgradeTriggers(o);
162
+ assert.deepEqual(codes(r), ["diff_files", "diff_lines", "repro_not_red", "risk_path", "tests_failed_twice"]);
163
+ assert.deepEqual(upgradeTriggers({ ...o, diff: { files: [...o.diff.files].reverse() } }), r);
164
+ assert.doesNotThrow(() => upgradeTriggers({ diff: { files: [null, { path: "" }, { path: 3, added: "x" }] }, testFailures: "nan", apiShape: "x", cfg: null }));
165
+ });
166
+ });
167
+
168
+ describe("light.mjs purity (#4448)", () => {
169
+ it("imports no filesystem, process or network module", () => {
170
+ const src = readFileSync(new URL("./light.mjs", import.meta.url), "utf8");
171
+ for (const m of ["node:fs", "node:child_process", "node:net", "node:http", "node:https"]) assert.equal(src.includes(`"${m}"`), false, m);
172
+ });
173
+ });
@@ -108,12 +108,47 @@ describe("account-scoped usage-limit holds (#4190)", () => {
108
108
  const limits = openLimits(storeFile(), { now });
109
109
  limits.set("A", t0 + 3_600_000, "batch b0 #1");
110
110
  const starts = [];
111
- const sleep = async (ms) => { limits.clear("A"); c.t += Math.min(ms, 5000); };
111
+ const sleep = async (ms) => { limits.clear("A"); c.t += ms; }; // faithful: a sleep advances the full time asked for
112
112
  await orchestrate({ issues: [1], now, sleep, readBody: async () => "", limits, identity: () => "A", getParallel: () => 1, pollMs: 5000,
113
113
  runOne: async () => { starts.push(now()); return { terminalReason: "merged" }; } });
114
114
  assert.equal(starts[0], t0 + 5000, "admitted on the poll after the clear, not at the hour-long reset");
115
115
  });
116
116
 
117
+ it("a held batch polls the host store: limits clear admits within one poll without getCancelled or getParallel (#4446)", async () => {
118
+ const { c, now } = fakeClock();
119
+ const t0 = c.t;
120
+ const file = storeFile();
121
+ const limits = openLimits(file, { now });
122
+ limits.set("A", t0 + 3_600_000, "batch b0 #1");
123
+ const sleeps = [];
124
+ // The operator clears the hold from another process (its own handle on the host file) while the batch sleeps.
125
+ const sleep = async (ms) => {
126
+ sleeps.push(ms);
127
+ if (sleeps.length === 1) await quiet(() => limitsFromCli(["clear", "A"], { limits: openLimits(file, { now }), identity: () => "A" }));
128
+ c.t += ms;
129
+ };
130
+ const starts = [];
131
+ const rows = await orchestrate({ issues: [1, 2], parallel: 2, now, sleep, readBody: async () => "", limits, identity: () => "A", pollMs: 5000,
132
+ runOne: async (n) => { starts.push([n, now()]); return { terminalReason: "merged" }; } });
133
+ assert.deepEqual(rows.map((r) => r.outcome), ["merged", "merged"]);
134
+ assert.ok(starts.every(([, at]) => at <= t0 + 30_000), `admitted within 30s of the clear, got ${JSON.stringify(starts)}`);
135
+ assert.deepEqual(starts, [[1, t0 + 5000], [2, t0 + 5000]], "admitted on the first poll after the clear");
136
+ });
137
+
138
+ it("a held batch keeps waiting while the hold is present (#4446)", async () => {
139
+ const { c, now } = fakeClock();
140
+ const t0 = c.t;
141
+ const limits = openLimits(storeFile(), { now });
142
+ limits.set("A", t0 + 3_600_000, "batch b0 #1");
143
+ const sleeps = [];
144
+ const sleep = async (ms) => { sleeps.push(ms); c.t += ms; };
145
+ const starts = [];
146
+ await orchestrate({ issues: [1], now, sleep, readBody: async () => "", limits, identity: () => "A", pollMs: 5000,
147
+ runOne: async () => { starts.push(now()); return { terminalReason: "merged" }; } });
148
+ assert.deepEqual(starts, [t0 + 3_600_000], "admitted exactly at the reset, never earlier");
149
+ assert.ok(sleeps.length > 1 && sleeps.every((ms) => ms > 0 && ms <= 5000), "re-read the store every poll while held");
150
+ });
151
+
117
152
  it("a legacy batch-local holdUntil is ignored on resume and no longer persisted", async () => {
118
153
  const { c, now, sleep } = fakeClock();
119
154
  let state = { v: 1, order: [1], pending: [1], holdUntil: c.t + 3_600_000 };