forgedock 1.30.0 → 1.32.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 (162) 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 +409 -38
  6. package/bin/engine.mjs +47 -37
  7. package/bin/forgedock.mjs +35 -3
  8. package/bin/gc.mjs +50 -17
  9. package/bin/hooks/reread-nudge.mjs +65 -0
  10. package/bin/labels.json +5 -0
  11. package/bin/lean/apishape.mjs +170 -0
  12. package/bin/lean/apishape.test.mjs +187 -0
  13. package/bin/lean/batch.mjs +32 -0
  14. package/bin/lean/batch.test.mjs +338 -2
  15. package/bin/lean/budget.mjs +89 -0
  16. package/bin/lean/budget.test.mjs +106 -0
  17. package/bin/lean/checks.mjs +32 -9
  18. package/bin/lean/checks.test.mjs +78 -1
  19. package/bin/lean/conformance.e2e.test.mjs +27 -5
  20. package/bin/lean/conformance.mjs +150 -15
  21. package/bin/lean/conformance.test.mjs +175 -3
  22. package/bin/lean/context.mjs +128 -44
  23. package/bin/lean/context.test.mjs +157 -5
  24. package/bin/lean/decompose.mjs +90 -0
  25. package/bin/lean/decompose.test.mjs +105 -0
  26. package/bin/lean/depcache.mjs +11 -7
  27. package/bin/lean/depcache.test.mjs +81 -0
  28. package/bin/lean/e2e.mjs +102 -0
  29. package/bin/lean/e2e.test.mjs +86 -0
  30. package/bin/lean/effects.mjs +48 -15
  31. package/bin/lean/explore.mjs +47 -0
  32. package/bin/lean/explore.test.mjs +61 -0
  33. package/bin/lean/facts.mjs +84 -0
  34. package/bin/lean/facts.test.mjs +66 -0
  35. package/bin/lean/fixround.mjs +90 -0
  36. package/bin/lean/fixround.test.mjs +117 -0
  37. package/bin/lean/heavy.mjs +35 -2
  38. package/bin/lean/heavy.test.mjs +32 -2
  39. package/bin/lean/lean.mjs +712 -134
  40. package/bin/lean/lean.test.mjs +1626 -37
  41. package/bin/lean/ledger.mjs +5 -3
  42. package/bin/lean/ledger.test.mjs +18 -0
  43. package/bin/lean/light.mjs +135 -0
  44. package/bin/lean/light.test.mjs +173 -0
  45. package/bin/lean/lightlane.mjs +219 -0
  46. package/bin/lean/lightlane.test.mjs +237 -0
  47. package/bin/lean/limits.test.mjs +36 -1
  48. package/bin/lean/orchestrate.mjs +303 -41
  49. package/bin/lean/pitfalls.mjs +136 -0
  50. package/bin/lean/pitfalls.test.mjs +117 -0
  51. package/bin/lean/postmerge.mjs +85 -0
  52. package/bin/lean/postmerge.test.mjs +79 -0
  53. package/bin/lean/precheck.mjs +138 -13
  54. package/bin/lean/precheck.test.mjs +118 -1
  55. package/bin/lean/prefilter.mjs +220 -5
  56. package/bin/lean/prefilter.test.mjs +260 -4
  57. package/bin/lean/preflight.mjs +108 -3
  58. package/bin/lean/preflight.test.mjs +48 -1
  59. package/bin/lean/probe.mjs +297 -0
  60. package/bin/lean/probe.test.mjs +210 -0
  61. package/bin/lean/prompts.mjs +104 -13
  62. package/bin/lean/render.mjs +7 -3
  63. package/bin/lean/resume.mjs +67 -0
  64. package/bin/lean/resume.test.mjs +84 -0
  65. package/bin/lean/review-trigger.mjs +12 -0
  66. package/bin/lean/review-trigger.test.mjs +17 -1
  67. package/bin/lean/routing.mjs +74 -0
  68. package/bin/lean/routing.test.mjs +67 -0
  69. package/bin/lean/sched.mjs +16 -1
  70. package/bin/lean/sched.test.mjs +175 -1
  71. package/bin/lean/schedd.mjs +55 -9
  72. package/bin/lean/schedd.test.mjs +170 -3
  73. package/bin/lean/selfreview.mjs +37 -0
  74. package/bin/lean/selfreview.test.mjs +51 -0
  75. package/bin/lean/seqlease.mjs +40 -0
  76. package/bin/lean/seqlease.test.mjs +101 -0
  77. package/bin/lean/sequence.mjs +296 -13
  78. package/bin/lean/sequence.test.mjs +263 -2
  79. package/bin/lean/toolchain.mjs +60 -15
  80. package/bin/lean/toolchain.test.mjs +120 -3
  81. package/bin/lean/toolstats.mjs +72 -0
  82. package/bin/lean/toolstats.test.mjs +47 -0
  83. package/bin/lean/verifier.mjs +232 -0
  84. package/bin/lean/verifier.test.mjs +163 -0
  85. package/bin/lean/verify.mjs +72 -10
  86. package/bin/lean/verify.test.mjs +68 -3
  87. package/bin/review-staging.mjs +533 -0
  88. package/bin/runner.mjs +82 -13
  89. package/bin/stats/aggregate.mjs +87 -2
  90. package/bin/stats/efficiency.mjs +335 -0
  91. package/bin/stats/efficiency.test.mjs +266 -0
  92. package/bin/stats/github-source.mjs +8 -3
  93. package/bin/stats/index.mjs +91 -6
  94. package/bin/stats/local-source.mjs +145 -8
  95. package/bin/stats/query.mjs +181 -3
  96. package/bin/stats/render.mjs +22 -0
  97. package/bin/stats/stats.test.mjs +403 -8
  98. package/commands/orchestrate.md +3 -1
  99. package/commands/review-pr-agents/protocols.md +1 -1
  100. package/commands/review-pr-staging.md +84 -831
  101. package/commands/review-pr.md +1 -1
  102. package/commands/work-on/build/implement.md +1 -0
  103. package/commands/work-on/build/validate.md +11 -1
  104. package/docs/CONFIG.md +80 -5
  105. package/docs/OUTCOME-CODES.md +2 -1
  106. package/docs/STATS.md +66 -1
  107. package/docs/TELEMETRY.md +1 -1
  108. package/docs/eval/corpus/lightlane-v1.json +55 -0
  109. package/docs/eval/lightlane-bench.md +91 -0
  110. package/docs/spec/review-protocol.md +1 -1
  111. package/package.json +1 -1
  112. package/scripts/bench-lightlane.mjs +286 -0
  113. package/scripts/ci-local/lib.mjs +20 -0
  114. package/scripts/ci-local/plan.mjs +2 -12
  115. package/scripts/ci-local/scoping.mjs +10 -1
  116. package/scripts/ci-local.test.sh +2 -0
  117. package/scripts/fixtures/apishape/other/go-change/base/x.go +3 -0
  118. package/scripts/fixtures/apishape/other/go-change/head/x.go +3 -0
  119. package/scripts/fixtures/apishape/python/generator-function/base/seq.py +3 -0
  120. package/scripts/fixtures/apishape/python/generator-function/head/seq.py +2 -0
  121. package/scripts/fixtures/apishape/python/ichunked/base/more.py +17 -0
  122. package/scripts/fixtures/apishape/python/ichunked/head/more.py +20 -0
  123. package/scripts/fixtures/apishape/python/private-edit/base/calc.py +6 -0
  124. package/scripts/fixtures/apishape/python/private-edit/head/calc.py +9 -0
  125. package/scripts/fixtures/apishape/python/removed/base/ops.py +6 -0
  126. package/scripts/fixtures/apishape/python/removed/head/ops.py +2 -0
  127. package/scripts/fixtures/apishape/python/renamed/base/text.py +2 -0
  128. package/scripts/fixtures/apishape/python/renamed/head/text.py +2 -0
  129. package/scripts/fixtures/apishape/python/signature/base/shapes.py +2 -0
  130. package/scripts/fixtures/apishape/python/signature/head/shapes.py +2 -0
  131. package/scripts/fixtures/apishape/python/sync-async/base/client.py +7 -0
  132. package/scripts/fixtures/apishape/python/sync-async/head/client.py +7 -0
  133. package/scripts/fixtures/apishape/ts/generator-function/base/seq.js +3 -0
  134. package/scripts/fixtures/apishape/ts/generator-function/head/seq.js +5 -0
  135. package/scripts/fixtures/apishape/ts/private-edit/base/calc.ts +7 -0
  136. package/scripts/fixtures/apishape/ts/private-edit/head/calc.ts +9 -0
  137. package/scripts/fixtures/apishape/ts/removed/base/ops.ts +7 -0
  138. package/scripts/fixtures/apishape/ts/removed/head/ops.ts +3 -0
  139. package/scripts/fixtures/apishape/ts/renamed/base/text.ts +3 -0
  140. package/scripts/fixtures/apishape/ts/renamed/head/text.ts +3 -0
  141. package/scripts/fixtures/apishape/ts/signature/base/shapes.ts +3 -0
  142. package/scripts/fixtures/apishape/ts/signature/head/shapes.ts +3 -0
  143. package/scripts/fixtures/apishape/ts/sync-async/base/client.mjs +5 -0
  144. package/scripts/fixtures/apishape/ts/sync-async/head/client.mjs +5 -0
  145. package/scripts/forge-root.test.sh +2 -2
  146. package/scripts/publish-staging-sync.test.sh +62 -4
  147. package/scripts/review-finalize.sh +17 -6
  148. package/scripts/review-finalize.test.sh +47 -0
  149. package/scripts/review-pr-arguments.test.sh +13 -1
  150. package/scripts/review-pr-delta-wiring.test.sh +2 -2
  151. package/scripts/review-pr-gate2.test.sh +6 -14
  152. package/scripts/review-pr-staging-finding-filter.test.sh +4 -4
  153. package/scripts/review-pr-staging-scope.test.sh +39 -33
  154. package/scripts/review-prepare.sh +73 -6
  155. package/scripts/review-prepare.test.sh +85 -0
  156. package/scripts/review-staging-post.sh +293 -0
  157. package/scripts/review-staging-post.test.sh +143 -0
  158. package/scripts/review-staging-pre.sh +401 -0
  159. package/scripts/review-staging-pre.test.sh +150 -0
  160. package/scripts/symbol-index.mjs +26 -7
  161. package/scripts/symbol-index.py +55 -9
  162. package/scripts/trusted-comments.test.sh +1 -1
@@ -0,0 +1,136 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ /**
4
+ * #4375: learned per-repo pitfalls. When a rework round (fixround.mjs REWORK_TRIGGERS) fixes a failure, i.e. the same gate
5
+ * passes afterwards, the engine appends one record: trigger, a normalised failure signature, the fix's file globs and a
6
+ * one-line lesson from the failure output. The context pack carries the top ones for the next lane in the same repo.
7
+ * Deterministic: no model call. Local only: an append-only JSONL per repo under ~/.forge/pitfalls (FORGE_PITFALLS_DIR),
8
+ * never posted to GitHub or telemetry. A cache in the NORTHSTAR sense: deleting it only forgets the lessons.
9
+ * Records: {t:"lane", run, ts} once per lane (the aging clock) and {t:"pitfall", run, ts, sig, trigger, globs, lesson}.
10
+ */
11
+ import { createHash } from "node:crypto";
12
+ import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
13
+ import { homedir } from "node:os";
14
+ import { dirname, extname, join } from "node:path";
15
+ import { REWORK_TRIGGERS } from "./fixround.mjs";
16
+ import { matchesGlob } from "./verify.mjs";
17
+
18
+ export const DEFAULT_PITFALL_LANES = 50;
19
+ export const DEFAULT_PITFALL_LIMIT = 5;
20
+ export const LESSON_MAX = 160;
21
+ const MAX_GLOBS = 5;
22
+ const ERROR_LINE_RE = /\b(?:error|errors|fail(?:ed|ure|s)?|not ok|missing|stale|undeclared|collid\w*|cannot|unable|invalid|expected|assert\w*|exception|traceback|panic|denied)\b|✗|✖|×|\b[A-Z]{1,4}\d{3,5}\b/i;
23
+
24
+ const hash12 = (s) => createHash("sha256").update(String(s)).digest("hex").slice(0, 12);
25
+ // eslint-disable-next-line no-control-regex
26
+ const stripAnsi = (s) => String(s ?? "").replace(/\x1b\[[0-9;]*[A-Za-z]/g, "");
27
+ const squash = (s) => s.replace(/\s+/g, " ").trim();
28
+
29
+ /** The failure output's first error-like line (ANSI and blank lines skipped), else its first non-empty line, else "". */
30
+ export function failureLine(text) {
31
+ const lines = stripAnsi(text).split(/\r?\n/).map(squash).filter((l) => l && !/^[-=#*`~>]+$/.test(l));
32
+ return lines.find((l) => ERROR_LINE_RE.test(l)) ?? lines[0] ?? "";
33
+ }
34
+
35
+ /**
36
+ * The repeat-stable form of a failure line: quoted literals, file paths (with :line:col), hex ids and standalone numbers
37
+ * are replaced, so the same rule on another file or line gives the same signature. Rule codes (F401, TS2345) are kept.
38
+ */
39
+ export function normaliseFailure(line) {
40
+ return squash(String(line ?? "")
41
+ .replace(/"[^"\n]*"|'[^'\n]*'|`[^`\n]*`/g, "<str>")
42
+ .replace(/(?:[\w.@~-]+[/\\])*[\w.@~-]+\.[A-Za-z][A-Za-z0-9]{0,7}(?::\d+)*/g, "<path>")
43
+ .replace(/\b[0-9a-f]{7,40}\b/gi, "<hex>")
44
+ .replace(/\b\d+(?:\.\d+)*\b/g, "<n>")
45
+ .toLowerCase());
46
+ }
47
+
48
+ /** `trigger:` + a 12-hex hash of the normalised failure line. */
49
+ export const signatureOf = (trigger, text) => `${trigger}:${hash12(normaliseFailure(failureLine(text)))}`;
50
+
51
+ /** One line (at most LESSON_MAX chars) from the failure output, prefixed with the trigger. */
52
+ export function lessonOf(trigger, text) {
53
+ const t = `${trigger}: ${failureLine(text)}`;
54
+ return t.length > LESSON_MAX ? `${t.slice(0, LESSON_MAX - 1)}…` : t;
55
+ }
56
+
57
+ /** The fix's changed files as globs: `dir/*.ext` (`dir/*` without an extension), a root file by name; `.forge-lean/` left out. */
58
+ export function fixGlobs(files) {
59
+ const out = [];
60
+ for (const f of files ?? []) {
61
+ const p = String(f ?? "").trim().replace(/^\.?\//, "");
62
+ if (!p || p.startsWith(".forge-lean/")) continue;
63
+ const d = dirname(p), base = p.split("/").at(-1);
64
+ const g = d === "." ? base : `${d}/*${extname(base)}`;
65
+ if (!out.includes(g)) out.push(g);
66
+ if (out.length >= MAX_GLOBS) break;
67
+ }
68
+ return out;
69
+ }
70
+
71
+ /** Failure text from a gate's failure list: strings, CI jobs ({job, log}) or conformance rows ({code, detail}). */
72
+ export const failureLog = (failures) => (Array.isArray(failures) ? failures : []).map((f) => (typeof f === "string" ? f
73
+ : [f?.code, f?.detail, f?.log ? `${f.job ?? ""}\n${f.log}` : f?.job].filter(Boolean).join(" "))).join("\n");
74
+
75
+ /** A pending pitfall for a rework round, or null (not a rework trigger, or no failure text to learn from). */
76
+ export function pendingPitfall(trigger, text) {
77
+ if (!REWORK_TRIGGERS.includes(trigger) || !failureLine(text)) return null;
78
+ return { trigger, sig: signatureOf(trigger, text), lesson: lessonOf(trigger, text) };
79
+ }
80
+
81
+ /** The ledger record for a pending pitfall whose gate passed; `files` are the files the fix changed. */
82
+ export const pitfallRecord = ({ pending, files, run, ts }) => ({ t: "pitfall", run: String(run), ts, sig: pending.sig, trigger: pending.trigger, globs: fixGlobs(files), lesson: pending.lesson });
83
+
84
+ const byRank = (a, b) => b.count - a.count || String(b.lastTs ?? "").localeCompare(String(a.lastTs ?? "")) || a.sig.localeCompare(b.sig);
85
+
86
+ /**
87
+ * One entry per signature {sig, trigger, globs, lesson, count, lastSeq, lastTs}, ranked by count, then recency. The lane
88
+ * sequence is the order of distinct `lane` rows; an entry not seen in the last `maxAge` lanes ages out. One lane counts
89
+ * a signature once.
90
+ */
91
+ export function foldPitfalls(records, { maxAge = DEFAULT_PITFALL_LANES } = {}) {
92
+ const lanes = new Map(), bySig = new Map();
93
+ for (const r of records ?? []) {
94
+ if (!r || typeof r !== "object") continue;
95
+ if (r.t === "lane") { if (!lanes.has(String(r.run))) lanes.set(String(r.run), lanes.size); continue; }
96
+ if (r.t !== "pitfall" || typeof r.sig !== "string" || !r.sig) continue;
97
+ const seq = lanes.get(String(r.run)) ?? Math.max(0, lanes.size - 1);
98
+ const e = bySig.get(r.sig) ?? { sig: r.sig, trigger: String(r.trigger ?? ""), globs: [], lesson: "", count: 0, lastSeq: seq, lastTs: null, lastRun: null };
99
+ if (e.lastRun !== String(r.run)) e.count++;
100
+ e.lastRun = String(r.run); e.lastSeq = Math.max(e.lastSeq, seq); e.lastTs = r.ts ?? e.lastTs;
101
+ e.lesson = typeof r.lesson === "string" && r.lesson ? r.lesson : e.lesson;
102
+ for (const g of Array.isArray(r.globs) ? r.globs : []) if (typeof g === "string" && g && !e.globs.includes(g) && e.globs.length < MAX_GLOBS) e.globs.push(g);
103
+ bySig.set(r.sig, e);
104
+ }
105
+ const total = lanes.size;
106
+ return [...bySig.values()].filter((e) => total - 1 - e.lastSeq < maxAge).map(({ lastRun, ...e }) => e).sort(byRank);
107
+ }
108
+
109
+ /** Up to `limit` pitfalls: those whose globs match one of `paths` (match "files") first, then the rest (match "repo"). */
110
+ export function selectPitfalls(folded, { paths = [], limit = DEFAULT_PITFALL_LIMIT } = {}) {
111
+ const hits = (p) => p.globs.some((g) => paths.some((f) => matchesGlob(f, g)));
112
+ const files = folded.filter(hits).sort(byRank).map((p) => ({ ...p, match: "files" }));
113
+ const repo = folded.filter((p) => !hits(p)).sort(byRank).map((p) => ({ ...p, match: "repo" }));
114
+ return [...files, ...repo].slice(0, limit);
115
+ }
116
+
117
+ // ---- file store (I/O) ----
118
+
119
+ export const pitfallsDir = (env = process.env) => env.FORGE_PITFALLS_DIR || join(homedir(), ".forge", "pitfalls");
120
+ const segment = (s) => { const v = String(s ?? "").replace(/[^A-Za-z0-9._-]/g, "_"); return !v || /^\.+$/.test(v) ? "_" : v; };
121
+ export const pitfallsFile = (repo, dir = pitfallsDir()) => join(dir, `${String(repo ?? "").split("/").map(segment).join("__")}.jsonl`);
122
+
123
+ /** {read(repo) → records (corrupt lines skipped, missing file → []), append(repo, rec)}: one line per append. */
124
+ export function makePitfallStore({ dir = pitfallsDir() } = {}) {
125
+ return {
126
+ read(repo) {
127
+ const f = pitfallsFile(repo, dir);
128
+ if (!existsSync(f)) return [];
129
+ return readFileSync(f, "utf8").split("\n").map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter((x) => x && typeof x === "object");
130
+ },
131
+ append(repo, rec) {
132
+ mkdirSync(dir, { recursive: true });
133
+ appendFileSync(pitfallsFile(repo, dir), `${JSON.stringify(rec)}\n`);
134
+ },
135
+ };
136
+ }
@@ -0,0 +1,117 @@
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 { existsSync, readFileSync, appendFileSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { tmpDir } from "../tests/_tmp.mjs";
8
+ import { failureLine, normaliseFailure, signatureOf, lessonOf, fixGlobs, pendingPitfall, pitfallRecord, foldPitfalls, selectPitfalls, failureLog,
9
+ makePitfallStore, pitfallsFile, pitfallsDir, DEFAULT_PITFALL_LANES, LESSON_MAX } from "./pitfalls.mjs";
10
+
11
+ // Two ecosystems' failure output (#4375: the mechanism is generic, the data is each repo's own).
12
+ const NODE_ESLINT = (file, line) => `> app@1.0.0 lint\n> eslint .\n\n/home/u/wt/${file}\n ${line}:5 error 'unused' is defined but never used no-unused-vars\n\n✖ 1 problem (1 error, 0 warnings)`;
13
+ const NODE_TSC = "src/api/client.ts(41,7): error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'.";
14
+ const NODE_DOCS = "devdocs freshness: bin/lean/lean.mjs changed but devdocs/modules/lean.md did not (stale dossier)";
15
+ const PY_RUFF = (file, line) => `${file}:${line}:1: F401 [*] \`os\` imported but unused\nFound 1 error.\n[*] 1 fixable with the \`--fix\` option.`;
16
+ const PY_PYTEST = "============ FAILURES ============\n____ test_create_user ____\nE AssertionError: assert 404 == 201\ntests/test_users.py:22: AssertionError\n==== 1 failed, 12 passed in 0.91s ====";
17
+ const PY_MIGRATION = "sequence number collision: migrations/0042_add_index.py already taken by migrations/0042_user_email.py";
18
+
19
+ describe("#4375 pitfall signatures and lessons", () => {
20
+ it("picks the first error-like line, skipping banners and ANSI codes", () => {
21
+ assert.match(failureLine(NODE_ESLINT("src/a.ts", 12)), /error 'unused' is defined but never used no-unused-vars/);
22
+ assert.match(failureLine(`\x1b[31m${PY_RUFF("app/models.py", 3)}\x1b[0m`), /^app\/models\.py:3:1: F401/);
23
+ assert.match(failureLine(PY_PYTEST), /AssertionError: assert 404 == 201/);
24
+ assert.equal(failureLine("only info\nmore info"), "only info", "no error-like line: the first line");
25
+ assert.equal(failureLine("\n \n"), "");
26
+ });
27
+
28
+ it("the same rule on another file or line gives the same signature; another rule or trigger does not", () => {
29
+ assert.equal(signatureOf("precheck", NODE_ESLINT("src/a.ts", 12)), signatureOf("precheck", NODE_ESLINT("lib/b/c.ts", 380)));
30
+ assert.equal(signatureOf("precheck", PY_RUFF("app/models.py", 3)), signatureOf("precheck", PY_RUFF("svc/api/views.py", 17)));
31
+ assert.notEqual(signatureOf("precheck", PY_RUFF("app/models.py", 3)), signatureOf("precheck", PY_RUFF("app/models.py", 3).replace("F401", "E501")), "rule codes are kept");
32
+ assert.notEqual(signatureOf("precheck", NODE_TSC), signatureOf("ci_red", NODE_TSC));
33
+ assert.match(signatureOf("ci_red", NODE_TSC), /^ci_red:[0-9a-f]{12}$/);
34
+ assert.equal(normaliseFailure("Error at src/x.ts:1:2 in deadbeefcafe after 30 s: \"foo\""), "error at <path> in <hex> after <n> s: <str>");
35
+ assert.equal(signatureOf("sequence", PY_MIGRATION), signatureOf("sequence", PY_MIGRATION.replaceAll("0042", "0107")));
36
+ });
37
+
38
+ it("the lesson is one line from the failure output, prefixed with the trigger and capped", () => {
39
+ assert.equal(lessonOf("docs", NODE_DOCS), `docs: ${NODE_DOCS}`);
40
+ const long = lessonOf("precheck", `error ${"x".repeat(400)}\nsecond line`);
41
+ assert.ok(long.length <= LESSON_MAX && !long.includes("\n") && long.endsWith("…"), long);
42
+ });
43
+
44
+ it("fix globs: dir/*.ext, root files by name, .forge-lean left out, at most five", () => {
45
+ assert.deepEqual(fixGlobs(["src/a.ts", "src/b.ts", "package.json", ".forge-lean/result.json", "alembic/versions/0003_x.py", "Makefile", "docs/README"]),
46
+ ["src/*.ts", "package.json", "alembic/versions/*.py", "Makefile", "docs/*"]);
47
+ assert.equal(fixGlobs(Array.from({ length: 9 }, (_, i) => `d${i}/f.go`)).length, 5);
48
+ });
49
+
50
+ it("only rework triggers with failure text become pending pitfalls", () => {
51
+ assert.deepEqual(Object.keys(pendingPitfall("precheck", NODE_TSC)), ["trigger", "sig", "lesson"]);
52
+ for (const t of ["plan", "build", "block_correction", "ci_unavailable", "recovery", "timeout_restart"]) assert.equal(pendingPitfall(t, NODE_TSC), null, t);
53
+ assert.equal(pendingPitfall("docs", ""), null);
54
+ assert.match(failureLog([{ job: "lint", log: PY_RUFF("a.py", 1) }, { code: "review.finding", detail: "src/x.ts:3 misses the null check" }, "plain"]), /F401[\s\S]*review\.finding src\/x\.ts:3[\s\S]*plain/);
55
+ });
56
+ });
57
+
58
+ describe("#4375 pitfall ledger: dedup, aging, selection", () => {
59
+ const lane = (run) => ({ t: "lane", run, ts: `2026-10-${String(10 + (Number(String(run).replace(/\D/g, "")) % 20)).padStart(2, "0")}T00:00:00Z` });
60
+ const pit = (run, trigger, text, files, ts = "2026-10-20T00:00:00Z") => pitfallRecord({ pending: pendingPitfall(trigger, text), files, run, ts });
61
+
62
+ it("deduplicates by signature with a count (one per lane), union of globs, latest lesson", () => {
63
+ const recs = [lane("r1"), pit("r1", "precheck", NODE_ESLINT("src/a.ts", 1), ["src/a.ts"]), pit("r1", "precheck", NODE_ESLINT("src/a.ts", 9), ["src/a.ts"]),
64
+ lane("r2"), pit("r2", "precheck", NODE_ESLINT("web/b.tsx", 4), ["web/b.tsx"], "2026-10-21T00:00:00Z"), lane("r3"), pit("r3", "docs", NODE_DOCS, ["devdocs/modules/lean.md"])];
65
+ const f = foldPitfalls(recs);
66
+ assert.equal(f.length, 2);
67
+ assert.deepEqual([f[0].trigger, f[0].count, f[0].globs, f[0].lastTs], ["precheck", 2, ["src/*.ts", "web/*.tsx"], "2026-10-21T00:00:00Z"]);
68
+ assert.deepEqual([f[1].trigger, f[1].count, f[1].globs], ["docs", 1, ["devdocs/modules/*.md"]]);
69
+ assert.equal(foldPitfalls([{ t: "pitfall" }, null, "x", { t: "pitfall", sig: "" }]).length, 0, "malformed rows are skipped");
70
+ });
71
+
72
+ it("a pitfall not seen in N lanes (default 50) ages out; a recurring one stays", () => {
73
+ assert.equal(DEFAULT_PITFALL_LANES, 50);
74
+ const recs = [lane("r0"), pit("r0", "precheck", PY_RUFF("app/m.py", 3), ["app/m.py"]), pit("r0", "ci_red", PY_PYTEST, ["tests/test_users.py"])];
75
+ for (let i = 1; i <= 49; i++) recs.push(lane(`r${i}`));
76
+ assert.equal(foldPitfalls(recs).length, 2, "49 lanes since: kept");
77
+ recs.push(pit("r49", "ci_red", PY_PYTEST, ["tests/test_users.py"]), lane("r50"));
78
+ const f = foldPitfalls(recs);
79
+ assert.deepEqual(f.map((p) => p.trigger), ["ci_red"], "50 lanes without a recurrence: aged out; the recurring one stays");
80
+ assert.equal(foldPitfalls(recs, { maxAge: 100 }).length, 2, "custom N");
81
+ assert.equal(foldPitfalls(recs, { maxAge: 1 }).length, 0);
82
+ });
83
+
84
+ it("selects file matches first, then repo-wide, each by count then recency, up to the limit", () => {
85
+ const recs = [lane("a"), pit("a", "docs", NODE_DOCS, ["devdocs/modules/lean.md"]), lane("b"), pit("b", "docs", NODE_DOCS, ["devdocs/modules/lean.md"]),
86
+ lane("c"), pit("c", "precheck", PY_RUFF("app/models.py", 3), ["app/models.py"], "2026-10-01T00:00:00Z"),
87
+ lane("d"), pit("d", "ci_red", PY_PYTEST, ["app/views.py"], "2026-10-05T00:00:00Z"), pit("d", "sequence", PY_MIGRATION, ["migrations/0042_add_index.py"])];
88
+ const f = foldPitfalls(recs);
89
+ const s = selectPitfalls(f, { paths: ["app/models.py", "app/serializers.py"] });
90
+ assert.deepEqual(s.map((p) => [p.trigger, p.match]), [["ci_red", "files"], ["precheck", "files"], ["docs", "repo"], ["sequence", "repo"]]);
91
+ assert.deepEqual(selectPitfalls(f, { paths: [], limit: 2 }).map((p) => p.trigger), ["docs", "sequence"], "no likely files: repo-wide by count, then recency (sequence 10-20 is newer than ci_red 10-05)");
92
+ assert.equal(selectPitfalls([], { paths: ["x"] }).length, 0);
93
+ });
94
+ });
95
+
96
+ describe("#4375 local file store", () => {
97
+ it("is per repo under the pitfalls dir, appends one line per record and skips corrupt lines", () => {
98
+ const dir = tmpDir("pitfalls-");
99
+ const st = makePitfallStore({ dir });
100
+ assert.deepEqual(st.read("o/r"), [], "missing file: empty");
101
+ st.append("o/r", { t: "lane", run: "1" });
102
+ st.append("o/r", { t: "pitfall", run: "1", sig: "docs:abc", trigger: "docs", globs: [], lesson: "docs: x" });
103
+ st.append("acme/py-app", { t: "lane", run: "9" });
104
+ appendFileSync(pitfallsFile("o/r", dir), "{not json\n");
105
+ assert.equal(pitfallsFile("o/r", dir), join(dir, "o__r.jsonl"));
106
+ assert.deepEqual(st.read("o/r").map((r) => r.t), ["lane", "pitfall"]);
107
+ assert.deepEqual(st.read("acme/py-app"), [{ t: "lane", run: "9" }]);
108
+ assert.ok(existsSync(join(dir, "acme__py-app.jsonl")));
109
+ assert.equal(readFileSync(pitfallsFile("o/r", dir), "utf8").split("\n").filter(Boolean).length, 3);
110
+ assert.equal(pitfallsFile("../../etc/x", dir).startsWith(dir), true, "the repo name cannot escape the dir");
111
+ });
112
+
113
+ it("defaults to ~/.forge/pitfalls with a FORGE_PITFALLS_DIR override", () => {
114
+ assert.match(pitfallsDir({}), /[/\\]\.forge[/\\]pitfalls$/);
115
+ assert.equal(pitfallsDir({ FORGE_PITFALLS_DIR: "/x/p" }), "/x/p");
116
+ });
117
+ });
@@ -0,0 +1,85 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ /**
4
+ * Merge-time semantic checks (#4406). Parallel sibling lanes can each add the same name (an export, a registry key) and
5
+ * git merges both without a conflict: duplicate object keys, or a name-keyed probe map that tests only one of two
6
+ * same-named actions. Two deterministic pieces, codebase-agnostic (git + fs, no LLM):
7
+ * - postMergeCommands: what base-sync runs on the merged tree before the push (forge.yaml verify.post_merge, else the
8
+ * lane's scoped typecheck from checks.mjs; ci mode runs no host typecheck unless one is configured);
9
+ * - duplicateExports: names the lane newly exports that another module of the same app or package already declares.
10
+ */
11
+ import fs from "node:fs";
12
+ import { dirname, join, posix } from "node:path";
13
+ import { exportNames, isTestPath } from "./enumerating.mjs";
14
+
15
+ const SOURCE_RE = /\.(?:[cm]?[jt]sx?|py)$/;
16
+ const SKIP_RE = /(?:^|\/)(?:node_modules|vendor|dist|build|coverage)\//;
17
+ const MANIFESTS = ["package.json", "pyproject.toml", "setup.py", "go.mod", "Cargo.toml"];
18
+ const MIN_NAME = 3, MAX_NAMES = 50, CONVENTION = 3;
19
+ const lines = (s) => String(s ?? "").split("\n").map((x) => x.trim()).filter(Boolean);
20
+ const out = (git) => async (args) => { try { return String(await git(args)); } catch { return ""; } };
21
+ const shq = (s) => (/^[\w./-]+$/.test(s) ? s : `'${String(s).replace(/'/g, "'\\''")}'`);
22
+
23
+ /**
24
+ * The commands base-sync runs on the merged tree: [configured] when verify.post_merge is set, else (local/hybrid) the
25
+ * non-skip typecheck rows of checks.mjs scopedChecks, prefixed with their package dir; [] in ci mode.
26
+ */
27
+ export function postMergeCommands({ configured = null, scoped = [], mode = "local" } = {}) {
28
+ const c = String(configured ?? "").trim();
29
+ if (c) return [c];
30
+ if (mode === "ci") return [];
31
+ return (Array.isArray(scoped) ? scoped : [])
32
+ .filter((r) => r && r.kind === "typecheck" && r.mode !== "skip" && String(r.command ?? "").trim())
33
+ .map((r) => (r.cwd && r.cwd !== "." ? `cd ${shq(r.cwd)} && ${r.command}` : String(r.command)));
34
+ }
35
+
36
+ /** Declared exports only: re-exports (`export {..} from`, `export * from`) and default exports (named or not) are not collisions. */
37
+ export function declaredExports(text, path = "") {
38
+ const t = /\.py$/.test(path) ? String(text ?? "") : String(text ?? "")
39
+ .replace(/^\s*export\s+(?:type\s+)?\{[^}]*\}\s*from\s*["'][^"']*["'];?/gm, "")
40
+ .replace(/^\s*export\s+\*(?:\s+as\s+[\w$]+)?\s+from\s*["'][^"']*["'];?/gm, "")
41
+ .replace(/^(\s*)export\s+default\s+/gm, "$1");
42
+ return exportNames(t, path).filter((n) => n !== "default");
43
+ }
44
+
45
+ /** The nearest ancestor directory of `file` holding a package manifest, else "." (the repo root). */
46
+ function packageRoot(file, manifests) {
47
+ for (let d = dirname(file); ; d = dirname(d)) {
48
+ if (manifests.has(d)) return d;
49
+ if (d === "." || d === "/" || d === "") return ".";
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Exported names the lane added (vs `base`) in its changed non-test source files that other non-test source files of
55
+ * the same package root already declare at HEAD. A name 3+ other modules declare is a convention (GET, metadata), not a
56
+ * collision. @returns {Promise<Array<{symbol, path, others:string[]}>>}; never throws ([] on any failure).
57
+ */
58
+ export async function duplicateExports({ git, cwd, base }) {
59
+ try {
60
+ const g = out(git);
61
+ const keep = (f) => SOURCE_RE.test(f) && !isTestPath(f) && !SKIP_RE.test(f);
62
+ const changed = lines(await g(["-c", "core.quotepath=off", "diff", "--name-only", "--diff-filter=AM", `${base}...HEAD`])).filter(keep);
63
+ if (!changed.length) return [];
64
+ const added = new Set(lines(await g(["-c", "core.quotepath=off", "diff", "--name-only", "--diff-filter=A", `${base}...HEAD`])));
65
+ const manifests = new Set(lines(await g(["-c", "core.quotepath=off", "ls-files"])).filter((f) => MANIFESTS.includes(posix.basename(f))).map((f) => posix.dirname(f)));
66
+ const read = (f) => { try { return fs.readFileSync(join(cwd, f), "utf8"); } catch { return ""; } };
67
+ const res = [];
68
+ let names = 0;
69
+ for (const f of changed) {
70
+ 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);
72
+ const root = packageRoot(f, manifests);
73
+ for (const name of fresh) {
74
+ if (++names > MAX_NAMES) return res;
75
+ const hits = lines(await g(["-c", "core.quotepath=off", "grep", "-l", "-w", "-e", name, "HEAD", "--", ...(root === "." ? [] : [`${root}/`])]))
76
+ .map((l) => l.replace(/^HEAD:/, ""))
77
+ .filter((p) => p !== f && keep(p) && packageRoot(p, manifests) === root);
78
+ const others = [];
79
+ for (const p of hits) if (declaredExports(read(p), p).includes(name)) others.push(p);
80
+ if (others.length && others.length < CONVENTION) res.push({ symbol: name, path: f, others: others.sort() });
81
+ }
82
+ }
83
+ return res;
84
+ } catch { return []; }
85
+ }
@@ -0,0 +1,79 @@
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 { execFileSync } from "node:child_process";
6
+ import { mkdirSync, writeFileSync } from "node:fs";
7
+ import { dirname, join } from "node:path";
8
+ import { tmpDir } from "../tests/_tmp.mjs";
9
+ import { postMergeCommands, declaredExports, duplicateExports } from "./postmerge.mjs";
10
+
11
+ const sh = (cwd, ...a) => execFileSync("git", a, { cwd, encoding: "utf8", env: { ...process.env, GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@t", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@t" } });
12
+ const gitIn = (cwd) => async (a) => sh(cwd, ...a);
13
+ const put = (root, files) => { for (const [p, t] of Object.entries(files)) { mkdirSync(dirname(join(root, p)), { recursive: true }); writeFileSync(join(root, p), t); } };
14
+ /** A repo with `base` committed on main and `lane` committed on a lane branch. */
15
+ function repo(base, lane) {
16
+ const d = tmpDir("postmerge-");
17
+ sh(d, "init", "-q", "-b", "main");
18
+ put(d, base); sh(d, "add", "-A"); sh(d, "commit", "-qm", "base");
19
+ sh(d, "checkout", "-q", "-b", "lane");
20
+ put(d, lane); sh(d, "add", "-A"); sh(d, "commit", "-qm", "lane");
21
+ return d;
22
+ }
23
+ const dups = (d) => duplicateExports({ git: gitIn(d), cwd: d, base: "main" });
24
+
25
+ const BASE = {
26
+ "apps/web/package.json": "{}",
27
+ "apps/web/inbox/actions.ts": "export async function undoActivityAction() {}\nexport async function reassignCaptureAction() {}\n",
28
+ "packages/x/package.json": "{}",
29
+ "packages/x/src/feed.ts": "export function feedOnly() {}\n",
30
+ };
31
+
32
+ describe("duplicate exports (#4406)", () => {
33
+ it("a lane export another module of the same app declares is a finding with both paths", async () => {
34
+ const d = repo(BASE, { "apps/web/feed/actions.ts": "export async function undoActivityAction() {}\nexport async function undoFeedActivityAction() {}\n" });
35
+ assert.deepEqual(await dups(d), [{ symbol: "undoActivityAction", path: "apps/web/feed/actions.ts", others: ["apps/web/inbox/actions.ts"] }]);
36
+ });
37
+
38
+ it("another package, a barrel re-export, a test file and an export the file already had are not findings", async () => {
39
+ const d = repo({ ...BASE, "apps/web/feed/old.ts": "export function keepMe() {}\n", "apps/web/feed/twin.ts": "export function keepMe() {}\n" }, {
40
+ "packages/x/src/actions.ts": "export async function undoActivityAction() {}\n",
41
+ "apps/web/index.ts": "export { undoActivityAction } from './inbox/actions';\nexport * from './inbox/actions';\n",
42
+ "apps/web/__tests__/actions.test.ts": "export const undoActivityAction = 1;\n",
43
+ "apps/web/feed/old.ts": "export function keepMe() {}\nexport const touched = 1;\n",
44
+ });
45
+ assert.deepEqual(await dups(d), []);
46
+ });
47
+
48
+ it("a name 3+ other modules declare is a convention, not a collision; default is never one", async () => {
49
+ const route = "export async function GET() {}\nexport default function Page() {}\n";
50
+ const d = repo({ ...BASE, "apps/web/a/route.ts": route, "apps/web/b/route.ts": route, "apps/web/c/route.ts": route }, { "apps/web/d/route.ts": route });
51
+ assert.deepEqual(await dups(d), []);
52
+ });
53
+
54
+ it("Python modules of one package collide too; unreadable git yields []", async () => {
55
+ 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
+ assert.deepEqual(await dups(d), [{ symbol: "resolve_tenant", path: "svc/b.py", others: ["svc/a.py"] }]);
57
+ assert.deepEqual(await duplicateExports({ git: async () => { throw new Error("boom"); }, cwd: d, base: "main" }), []);
58
+ });
59
+
60
+ it("declaredExports drops re-exports and default", () => {
61
+ assert.deepEqual(declaredExports("export { a } from './a';\nexport * as ns from './n';\nexport default function x() {}\nexport const b = 1;\nconst c = 2;\nexport { c };\n", "m.ts").sort(), ["b", "c"]);
62
+ });
63
+ });
64
+
65
+ describe("post-merge commands (#4406)", () => {
66
+ const SCOPED = [
67
+ { kind: "typecheck", command: "pnpm exec tsc -p .", cwd: "apps/web", mode: "scoped" },
68
+ { kind: "typecheck", command: "", cwd: "packages/x", mode: "skip" },
69
+ { kind: "typecheck", command: "pnpm run typecheck", cwd: ".", mode: "full" },
70
+ { kind: "lint", command: "eslint .", cwd: ".", mode: "scoped" },
71
+ ];
72
+ it("verify.post_merge wins; else the non-skip scoped typecheck rows in their package dir; ci mode runs none", () => {
73
+ assert.deepEqual(postMergeCommands({ configured: " make check ", scoped: SCOPED, mode: "ci" }), ["make check"]);
74
+ assert.deepEqual(postMergeCommands({ scoped: SCOPED, mode: "local" }), ["cd apps/web && pnpm exec tsc -p .", "pnpm run typecheck"]);
75
+ assert.deepEqual(postMergeCommands({ scoped: SCOPED, mode: "hybrid" }).length, 2);
76
+ assert.deepEqual(postMergeCommands({ scoped: SCOPED, mode: "ci" }), []);
77
+ assert.deepEqual(postMergeCommands({ scoped: null }), []);
78
+ });
79
+ });
@@ -1,39 +1,62 @@
1
1
  // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
2
2
  // SPDX-License-Identifier: AGPL-3.0-or-later
3
3
  /**
4
- * Pre-PR declared check commands (#4240), one round (#4331): the commands run concurrently up to the host worker cap
5
- * (the caller holds one heavy-step slot for the whole round), each timed, inside one total budget. A command that never
4
+ * Pre-PR declared check commands (#4240), one round (#4331): the commands run concurrently up to the host worker cap,
5
+ * each timed, inside one total budget. #4344: the worker cap is shared, not multiplied (k running commands get
6
+ * max(1, floor(N / k)) workers each), and `acquire` takes one heavy-step slot per running command. A command that never
6
7
  * started before the budget ran out, or that the budget cut short, is left to CI (`deferred-to-ci`), never a failure.
7
8
  * Repo-agnostic: commands are opaque `bash -c` strings run through the injected `run`.
9
+ * #4364: `planPrecheck` is the one selection and budget rule shared by the engine's pre-PR round and `forgedock check`,
10
+ * which the builder runs in-session (`selfCheck`); `worktreeFingerprint` tells whether that run saw the final tree.
8
11
  */
12
+ import { createHash } from "node:crypto";
13
+ import { scopeChecks, selectCommands } from "./testmap.mjs";
14
+ import { LOG_CAP } from "./verify.mjs";
15
+
16
+ /** #4364: the engine's check plan for `forgedock check` (written beside contract.json) and the CLI's last run record. */
17
+ export const CHECK_PLAN_FILE = ".forge-lean/check-plan.json";
18
+ export const SELF_CHECK_FILE = ".forge-lean/self-check.json";
19
+ /** #4390: full output of each failing check command (`forgedock check`, the engine's precheck round). */
20
+ export const CHECK_LOG_DIR = ".forge-lean/logs";
9
21
 
10
22
  /** rc of a command killed by its timeout (effects.mjs runCheckCommand). */
11
23
  const TIMEOUT_RC = 124;
12
24
 
13
25
  /**
14
26
  * @param {{commands:string[], parallelism?:number, budgetMs:number, perCommandMs:number, now?:() => number,
15
- * run:(a:{command:string, timeoutMs:number}) => Promise<{rc:number, output?:string}>}} p
27
+ * acquire?:() => Promise<{release?:Function}|null>,
28
+ * run:(a:{command:string, timeoutMs:number, workers:number}) => Promise<{rc:number, output?:string}>}} p
29
+ * acquire: awaited per command before its timeout is computed (the wait counts against the budget); a throw runs ungated
16
30
  * @returns {Promise<{results:{command:string, rc:number, output:string, ms:number}[], deferred:string[], ms:number}>}
17
31
  * results in input order (commands that ran and were not cut by the budget); deferred in input order
18
32
  */
19
- export async function runChecks({ commands, parallelism = 1, budgetMs, perCommandMs, now = () => Date.now(), run }) {
33
+ export async function runChecks({ commands, parallelism = 1, budgetMs, perCommandMs, now = () => Date.now(), acquire, run }) {
20
34
  const list = Array.isArray(commands) ? commands : [];
21
35
  const workers = Math.max(1, Math.floor(Number(parallelism)) || 1);
36
+ const k = Math.max(1, Math.min(workers, list.length)), share = Math.max(1, Math.floor(workers / k));
22
37
  const start = now(), deadline = start + (Number(budgetMs) > 0 ? Number(budgetMs) : 0);
23
38
  const per = Number(perCommandMs) > 0 ? Number(perCommandMs) : Infinity;
24
39
  const slots = new Array(list.length).fill(null);
25
40
  let next = 0;
41
+ /** One command, once its slot (if any) is held: the slot wait counts against the budget. */
42
+ async function runOne(command) {
43
+ const left = deadline - now();
44
+ if (left <= 0) return { deferred: true };
45
+ const timeoutMs = Math.min(per, left), t = now();
46
+ let r;
47
+ try { r = await run({ command, timeoutMs, workers: share }); } catch (e) { r = { rc: 127, output: String(e?.message ?? e) }; }
48
+ const rc = Number.isInteger(r?.rc) ? r.rc : 1;
49
+ // the budget, not the command's own timeout, cut it short: CI runs it
50
+ if (rc === TIMEOUT_RC && timeoutMs < per) return { deferred: true };
51
+ return { command, rc, output: String(r?.output ?? ""), ms: Math.max(0, now() - t) };
52
+ }
26
53
  async function worker() {
27
54
  while (next < list.length) {
28
- const i = next++, command = list[i], left = deadline - now();
29
- if (left <= 0) { slots[i] = { deferred: true }; continue; }
30
- const timeoutMs = Math.min(per, left), t = now();
31
- let r;
32
- try { r = await run({ command, timeoutMs }); } catch (e) { r = { rc: 127, output: String(e?.message ?? e) }; }
33
- const rc = Number.isInteger(r?.rc) ? r.rc : 1;
34
- // the budget, not the command's own timeout, cut it short: CI runs it
35
- if (rc === TIMEOUT_RC && timeoutMs < per) { slots[i] = { deferred: true }; continue; }
36
- slots[i] = { command, rc, output: String(r?.output ?? ""), ms: Math.max(0, now() - t) };
55
+ const i = next++, command = list[i];
56
+ if (deadline - now() <= 0) { slots[i] = { deferred: true }; continue; }
57
+ let g = null;
58
+ try { g = acquire ? await acquire() : null; } catch { g = null; }
59
+ try { slots[i] = await runOne(command); } finally { try { g?.release?.(); } catch { /* fail-open */ } }
37
60
  }
38
61
  }
39
62
  await Promise.all(Array.from({ length: Math.min(workers, list.length) }, worker));
@@ -53,3 +76,105 @@ export function formatPrecheckSummary(rows) {
53
76
  const deferred = ran.reduce((a, r) => a + (Number.isInteger(r.precheck_deferred) ? r.precheck_deferred : 0), 0);
54
77
  return `precheck: ${(ms / 60000).toFixed(1)}m total over ${ran.length} lane${ran.length === 1 ? "" : "s"}${deferred ? `, ${deferred} deferred to CI` : ""}`;
55
78
  }
79
+
80
+ /**
81
+ * #4364: the pre-PR commands and budget for a round: `scopeChecks` over the trimmed learned commands (a mapped learned
82
+ * command runs only when its globs match a changed file); budget lean.precheck.max_minutes, else
83
+ * verify.precheck_total_seconds (default 600 s); per command verify.precheck_timeout_seconds.
84
+ * @returns {{commands:string[], source:string, added:number, dropped:string[], selected:number, budgetMs:number, perCommandMs:number}}
85
+ */
86
+ export function planPrecheck({ testMap = null, files = [], learned = [], maxMinutes, verify = {} }) {
87
+ const list = (Array.isArray(learned) ? learned : []).map((c) => String(c ?? "").trim()).filter(Boolean);
88
+ const m = scopeChecks(testMap, files, list);
89
+ const max = Number(maxMinutes);
90
+ const budgetMs = (max > 0 ? max * 60 : Number(verify.precheckTotalSeconds) || 600) * 1000;
91
+ return { ...m, selected: testMap ? selectCommands(testMap, files).length : 0, budgetMs, perCommandMs: (Number(verify.precheckTimeoutSeconds) || 300) * 1000 };
92
+ }
93
+
94
+ const lines = (out) => String(out ?? "").split("\n").map((x) => x.trim()).filter(Boolean);
95
+
96
+ /**
97
+ * #4364: content fingerprint of the worktree against merge-base(base, HEAD): sha256 over the sorted (path, blob sha or
98
+ * "deleted") pairs of every tracked path that differs (committed, staged or not) plus untracked non-ignored files,
99
+ * `.forge-lean/` excluded. Committing after a check leaves it unchanged; any edit changes it.
100
+ * @param {{git:(args:string[]) => Promise<string>, base:string}} p
101
+ */
102
+ export async function worktreeFingerprint({ git, base }) {
103
+ const mb = String(await git(["merge-base", base, "HEAD"])).trim();
104
+ const keep = (f) => !f.startsWith(".forge-lean/");
105
+ const deleted = new Set(lines(await git(["diff", "--name-only", "--diff-filter=D", mb])).filter(keep));
106
+ const paths = [...new Set([...lines(await git(["diff", "--name-only", mb])), ...lines(await git(["ls-files", "-o", "--exclude-standard"]))])].filter(keep).sort();
107
+ const live = paths.filter((f) => !deleted.has(f)), blobs = new Map();
108
+ for (let i = 0; i < live.length; i += 200) {
109
+ const part = live.slice(i, i + 200), shas = lines(await git(["hash-object", "--", ...part]));
110
+ part.forEach((f, j) => blobs.set(f, shas[j] ?? ""));
111
+ }
112
+ return createHash("sha256").update(paths.map((f) => `${f}\0${blobs.get(f) ?? "deleted"}`).join("\n")).digest("hex");
113
+ }
114
+
115
+ /** #4364: 'skipped' (no record), 'stale' (another tree), 'red' (this tree, failing) or 'green' (this tree, passing). */
116
+ export function selfCheckState(record, fingerprint) {
117
+ if (!record || typeof record !== "object" || !record.fingerprint) return "skipped";
118
+ if (record.fingerprint !== fingerprint) return "stale";
119
+ return record.ok === true ? "green" : "red";
120
+ }
121
+
122
+ /**
123
+ * #4364 `forgedock check`: the contract's validate commands (reported `ci`, not run, in verify.mode ci, as the engine
124
+ * accepts them on a CI-verified head), then the planned pre-PR commands under the plan's budget, both through
125
+ * `runChecks` with the same worker cap. No heavy slot is taken here: the heavy shims on the builder's PATH gate the
126
+ * heavy binaries. ok = every command that ran exited 0 (deferred and `ci` are not failures, as in the engine).
127
+ * @param {{contract:object, plan:{mode?:string, commands:string[], budgetMs:number, perCommandMs:number}, parallelism?:number, now?:() => number, run:Function}} p
128
+ */
129
+ export async function selfCheck({ contract, plan, parallelism = 1, now = () => Date.now(), run }) {
130
+ const vals = (Array.isArray(contract?.validate) ? contract.validate : []).filter((v) => v && String(v.command ?? "").trim());
131
+ const ci = plan?.mode === "ci", per = Number(plan?.perCommandMs) > 0 ? Number(plan.perCommandMs) : 300000;
132
+ const validate = [];
133
+ if (ci) for (const v of vals) validate.push({ id: v.id, command: String(v.command), status: "ci" });
134
+ else if (vals.length) {
135
+ const cmds = vals.map((v) => String(v.command));
136
+ // as the engine's validate rerun: each command its own timeout, no round budget
137
+ const r = await runChecks({ commands: cmds, parallelism, budgetMs: Infinity, perCommandMs: per, now, run });
138
+ const byCmd = new Map(r.results.map((x) => [x.command, x]));
139
+ for (const v of vals) { const x = byCmd.get(String(v.command)); validate.push(x ? { id: v.id, ...x } : { id: v.id, command: String(v.command), status: "deferred-to-ci" }); }
140
+ }
141
+ const pre = await runChecks({ commands: plan?.commands ?? [], parallelism, budgetMs: plan?.budgetMs ?? 0, perCommandMs: per, now, run });
142
+ const commands = [...pre.results];
143
+ const ok = [...validate, ...commands].every((x) => x.status || x.rc === 0);
144
+ return { ok, validate, commands, deferred: pre.deferred };
145
+ }
146
+
147
+ /** #4390: summary lines naming a failing test, as common runners print them (pytest, TAP/node --test, jest/vitest, go, cargo). */
148
+ const FAIL_ID_RE = /^\s*(?:FAILED |ERROR |not ok \d|FAIL |[✕×✗] |--- FAIL: |test \S.* \.\.\. FAILED)/;
149
+ export const QUIET_TAIL_LINES = 40;
150
+
151
+ /**
152
+ * #4390 quiet-output contract: one failure as its last `lines` lines (at most `maxChars`, cut marked with `…`), the
153
+ * failing-test-id lines found anywhere in the output (deduped, at most `maxIds`) and, when given, the full log's path.
154
+ */
155
+ export function quietFailure(output, { lines = QUIET_TAIL_LINES, maxChars = Infinity, logPath = null, maxIds = 30 } = {}) {
156
+ const all = String(output ?? "").split("\n");
157
+ while (all.length && !all[all.length - 1].trim()) all.pop();
158
+ const from = Math.max(0, all.length - lines);
159
+ let tail = all.slice(from).join("\n");
160
+ const cut = from > 0 || tail.length > maxChars;
161
+ if (tail.length > maxChars) tail = tail.slice(-maxChars);
162
+ const ids = [...new Set(all.filter((l) => FAIL_ID_RE.test(l)).map((l) => l.trim().slice(0, 300)))];
163
+ const out = [tail ? `${cut ? "…" : ""}${tail}` : "(no output)"];
164
+ if (ids.length) out.push(`failing tests:\n${ids.slice(0, maxIds).map((l) => ` ${l}`).join("\n")}${ids.length > maxIds ? `\n … and ${ids.length - maxIds} more` : ""}`);
165
+ if (logPath) out.push(`full log: ${logPath}`);
166
+ return out.join("\n");
167
+ }
168
+
169
+ /** #4364: the compact pass/fail table `forgedock check` prints; #4390: each failure quiet (`quietFailure`), within `cap` chars. */
170
+ export function formatCheckTable(r, cap = LOG_CAP) {
171
+ const word = (x) => (x.status === "ci" ? "CI" : x.status ? "DEFERRED" : x.rc === 0 ? "PASS" : `FAIL exit ${x.rc}`);
172
+ const rows = [...(r.validate ?? []).map((x) => ({ ...x, label: `${x.id} ${x.command}` })), ...(r.commands ?? []).map((x) => ({ ...x, label: x.command })),
173
+ ...(r.deferred ?? []).map((c) => ({ command: c, label: c, status: "deferred-to-ci" }))];
174
+ const out = rows.map((x) => `${word(x).padEnd(12)} ${Number.isFinite(x.ms) ? `${(x.ms / 1000).toFixed(1)}s`.padStart(7) : "".padStart(7)} ${x.label}`);
175
+ const failed = rows.filter((x) => !x.status && x.rc !== 0);
176
+ const per = Math.max(500, Math.floor(cap / Math.max(1, failed.length))); // verify.mjs capLogs share
177
+ for (const f of failed) out.push("", `--- ${f.label} (exit ${f.rc})`, quietFailure(f.output, { maxChars: per, logPath: f.logPath ?? null }));
178
+ out.push("", r.ok ? "check: PASS" : `check: FAIL (${failed.length} failing)`);
179
+ return out.join("\n");
180
+ }