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
@@ -5,7 +5,9 @@ import assert from "node:assert/strict";
5
5
  import { writeFileSync } from "node:fs";
6
6
  import { join } from "node:path";
7
7
  import { tmpDir } from "../tests/_tmp.mjs";
8
- import { runChecks, formatPrecheckSummary } from "./precheck.mjs";
8
+ import { formatFirstPassSummary } from "./fixround.mjs";
9
+ import { runChecks, formatPrecheckSummary, planPrecheck, selfCheck, selfCheckState, formatCheckTable, worktreeFingerprint, quietFailure } from "./precheck.mjs";
10
+ import { execFileSync } from "node:child_process";
9
11
  import { readPrecheckBudget } from "../engine-cli.mjs";
10
12
  import { orchestrate } from "./orchestrate.mjs";
11
13
 
@@ -66,6 +68,43 @@ describe("runChecks (#4331)", () => {
66
68
  });
67
69
  });
68
70
 
71
+ describe("runChecks (#4344)", () => {
72
+ /** A fake heavy semaphore: counts live slots, their peak and releases; `wait` ms of the shared clock per acquire. */
73
+ const sem = (h, wait = 0) => { const s = { live: 0, peak: 0, acquired: 0, released: 0 };
74
+ s.acquire = async () => { s.acquired++; s.peak = Math.max(s.peak, ++s.live); h.t += wait; return { release: () => { s.live--; s.released++; } }; }; return s; };
75
+
76
+ it("#4344 divides the worker budget across concurrent commands and takes one heavy slot per running command", async () => {
77
+ for (const [n, cmds, share, peak] of [[8, ["a", "b", "c", "d", "e", "f", "g", "h"], 1, 8], [8, ["a", "b"], 4, 2], [6, ["a", "b", "c", "d"], 1, 4], [5, ["a", "b"], 2, 2], [4, ["a"], 4, 1]]) {
78
+ const h = harness(), s = sem(h), seen = [];
79
+ await runChecks({ commands: cmds, parallelism: n, budgetMs: 600000, perCommandMs: 300000, now: h.now, acquire: s.acquire, run: (a) => { seen.push(a.workers); return h.run(a); } });
80
+ assert.ok(seen.every((w) => w === share), `N=${n} k=${cmds.length}: ${seen}`);
81
+ assert.ok(share * h.peak <= n, `round total ${share * h.peak} workers > N=${n}`);
82
+ assert.deepEqual([s.peak, s.acquired, s.released, s.live], [peak, cmds.length, cmds.length, 0], `N=${n} slots`);
83
+ }
84
+ });
85
+
86
+ it("#4344 FORGE_HOST_PARALLELISM=1: one command at a time, one worker, one slot", async () => {
87
+ const h = harness(), s = sem(h), seen = [];
88
+ await runChecks({ commands: ["a", "b", "c"], parallelism: 1, budgetMs: 600000, perCommandMs: 300000, now: h.now, acquire: s.acquire, run: (a) => { seen.push(a.workers); return h.run(a); } });
89
+ assert.deepEqual([h.peak, s.peak, seen], [1, 1, [1, 1, 1]]);
90
+ });
91
+
92
+ it("#4344 the slot wait counts against the budget: a command whose wait exhausts it is deferred, its slot released", async () => {
93
+ const h = harness(), s = sem(h, 1000);
94
+ const r = await runChecks({ commands: ["a", "b"], parallelism: 1, budgetMs: 2500, perCommandMs: 300000, now: h.now, acquire: s.acquire, run: h.run });
95
+ assert.deepEqual(h.calls, [["a", 1500]]);
96
+ assert.deepEqual([r.results.map((x) => x.command), r.deferred], [["a"], ["b"]]);
97
+ assert.deepEqual([s.acquired, s.released], [2, 2]);
98
+ });
99
+
100
+ it("#4344 a throwing acquire runs the command ungated; a throwing release is ignored", async () => {
101
+ const h = harness();
102
+ const r1 = await runChecks({ commands: ["a"], parallelism: 2, budgetMs: 600000, perCommandMs: 300000, now: h.now, acquire: async () => { throw new Error("registry"); }, run: h.run });
103
+ const r2 = await runChecks({ commands: ["b"], parallelism: 2, budgetMs: 600000, perCommandMs: 300000, now: h.now, acquire: async () => ({ release: () => { throw new Error("x"); } }), run: h.run });
104
+ assert.deepEqual([...r1.results, ...r2.results].map((x) => [x.command, x.rc]), [["a", 0], ["b", 0]]);
105
+ });
106
+ });
107
+
69
108
  describe("formatPrecheckSummary (#4331)", () => {
70
109
  it("sums precheck_ms over the lanes that ran checks and counts deferrals; null when none did", () => {
71
110
  assert.equal(formatPrecheckSummary([{ precheck_ms: 90000 }, { precheck_ms: 30000, precheck_deferred: 2 }, { issue: 3 }]), "precheck: 2.0m total over 2 lanes, 2 deferred to CI");
@@ -79,6 +118,17 @@ describe("formatPrecheckSummary (#4331)", () => {
79
118
  assert.deepEqual(rows.map((r) => [r.issue, r.precheck_ms, r.precheck_deferred]), [[1, 90000, 1], [2, 30000, undefined], [3, undefined, undefined]]);
80
119
  assert.equal(formatPrecheckSummary(rows), "precheck: 2.0m total over 2 lanes, 1 deferred to CI");
81
120
  });
121
+ it("#4451: orchestrate rows carry the lane's wall_ms and model_ms, so wall vs model time can be measured", async () => {
122
+ const rows = await orchestrate({ issues: [1, 2], parallel: 1, readBody: async () => "",
123
+ runOne: async (n) => ({ terminalReason: "merged", ...({ 1: { wall_ms: 90000, model_ms: 60000 } })[n] }) });
124
+ assert.deepEqual(rows.map((r) => [r.issue, r.wall_ms, r.model_ms]), [[1, 90000, 60000], [2, undefined, undefined]]);
125
+ });
126
+ it("#4362: orchestrate rows carry the lane's firstPass as first_pass for the batch first-pass line", async () => {
127
+ const rows = await orchestrate({ issues: [1, 2, 3], parallel: 1, readBody: async () => "",
128
+ runOne: async (n) => ({ terminalReason: "merged", ...({ 1: { firstPass: true }, 2: { firstPass: false } })[n] }) });
129
+ assert.deepEqual(rows.map((r) => [r.issue, r.first_pass]), [[1, true], [2, false], [3, undefined]]);
130
+ assert.equal(formatFirstPassSummary(rows), "first-pass: 50% (1/2 lanes that reached the build)");
131
+ });
82
132
  });
83
133
 
84
134
  describe("readPrecheckBudget: forge.yaml lean.precheck.max_minutes (#4331)", () => {
@@ -91,3 +141,70 @@ describe("readPrecheckBudget: forge.yaml lean.precheck.max_minutes (#4331)", ()
91
141
  assert.equal(readPrecheckBudget(tmpDir("precheck-")), undefined);
92
142
  });
93
143
  });
144
+
145
+ describe("forgedock check core (#4364)", () => {
146
+ const MAP = { version: 1, entries: [{ globs: ["src/**"], command: "npm test", cwd: "." }, { globs: ["docs/**"], command: "make docs", cwd: "." }] };
147
+
148
+ it("planPrecheck: the engine's selection and budget rule (max_minutes wins, else precheck_total_seconds, else 600 s)", () => {
149
+ const p = planPrecheck({ testMap: MAP, files: ["src/a.js"], learned: [" make lint ", "", "make docs"], verify: { precheckTotalSeconds: 120, precheckTimeoutSeconds: 30 } });
150
+ assert.deepEqual(p.commands, ["make lint", "npm test"]); assert.deepEqual(p.dropped, ["make docs"]);
151
+ assert.equal(p.selected, 1); assert.equal(p.budgetMs, 120000); assert.equal(p.perCommandMs, 30000);
152
+ assert.equal(planPrecheck({ learned: ["x"], maxMinutes: 2, verify: { precheckTotalSeconds: 999 } }).budgetMs, 120000);
153
+ assert.equal(planPrecheck({ learned: ["x"], maxMinutes: 3, verify: {} }).budgetMs, 180000);
154
+ assert.deepEqual(planPrecheck({ learned: ["x"], verify: {} }), { commands: ["x"], added: 0, dropped: [], source: "learned", selected: 0, budgetMs: 600000, perCommandMs: 300000 });
155
+ });
156
+
157
+ it("selfCheck runs validates then pre-PR commands; ok only when every run exits 0; ci mode leaves validates to CI", async () => {
158
+ const h = harness({ rc: { "make lint": 2 } });
159
+ const contract = { validate: [{ id: "V1", command: "node --test a" }, { id: "V2", command: " " }] };
160
+ const plan = { mode: "local", commands: ["npm test", "make lint"], budgetMs: 600000, perCommandMs: 300000 };
161
+ const r = await selfCheck({ contract, plan, now: h.now, run: h.run });
162
+ assert.deepEqual(h.calls.map((c) => c[0]), ["node --test a", "npm test", "make lint"]);
163
+ assert.deepEqual(r.validate.map((x) => [x.id, x.rc]), [["V1", 0]]); assert.deepEqual(r.commands.map((x) => x.rc), [0, 2]); assert.equal(r.ok, false);
164
+ const h2 = harness();
165
+ const r2 = await selfCheck({ contract, plan: { ...plan, mode: "ci" }, now: h2.now, run: h2.run });
166
+ assert.deepEqual(h2.calls.map((c) => c[0]), ["npm test", "make lint"]); assert.deepEqual(r2.validate, [{ id: "V1", command: "node --test a", status: "ci" }]); assert.equal(r2.ok, true);
167
+ const h3 = harness({ cost: { "npm test": 5000 } });
168
+ const r3 = await selfCheck({ contract: {}, plan: { commands: ["npm test", "make lint"], budgetMs: 4000, perCommandMs: 300000 }, now: h3.now, run: h3.run });
169
+ assert.deepEqual(r3.deferred, ["npm test", "make lint"]); assert.equal(r3.ok, true, "deferred to CI is not a failure, as in the engine");
170
+ });
171
+
172
+ it("selfCheckState and formatCheckTable", () => {
173
+ assert.equal(selfCheckState(null, "f"), "skipped"); assert.equal(selfCheckState({ fingerprint: "g", ok: true }, "f"), "stale");
174
+ assert.equal(selfCheckState({ fingerprint: "f", ok: false }, "f"), "red"); assert.equal(selfCheckState({ fingerprint: "f", ok: true }, "f"), "green");
175
+ const t = formatCheckTable({ ok: false, validate: [{ id: "V1", command: "v", rc: 0, ms: 1200 }], commands: [{ command: "make lint", rc: 2, ms: 10, output: `${"x".repeat(5000)}TAIL` }], deferred: ["slow"] }, 1000);
176
+ assert.match(t, /^PASS +1\.2s {2}V1 v$/m); assert.match(t, /^FAIL exit 2 .* make lint$/m); assert.match(t, /^DEFERRED .* slow$/m);
177
+ assert.match(t, /--- make lint \(exit 2\)\n…x+TAIL/); assert.ok(t.length < 1400, "failure output capped"); assert.match(t, /check: FAIL \(1 failing\)$/);
178
+ });
179
+
180
+ it("#4390 quietFailure: last 40 lines, failing ids across ecosystems, log pointer; short output unchanged", () => {
181
+ const out = ["FAILED tests/test_x.py::test_a - assert 1 == 2", "not ok 3 - parses dates", " FAIL src/a.test.ts > adds", "--- FAIL: TestSum (0.00s)", "test parser::empty ... FAILED", " ✕ renders header (5 ms)",
182
+ ...Array.from({ length: 100 }, (_, i) => `line ${i}`)].join("\n");
183
+ const q = quietFailure(out, { logPath: ".forge-lean/logs/check-V1.log" });
184
+ assert.match(q, /^…line 60\n/); assert.match(q, /line 99\nfailing tests:\n/);
185
+ for (const id of ["FAILED tests/test_x.py::test_a", "not ok 3 - parses dates", "FAIL src/a.test.ts > adds", "--- FAIL: TestSum", "test parser::empty ... FAILED", "✕ renders header"]) assert.ok(q.includes(` ${id}`), id);
186
+ assert.match(q, /\nfull log: \.forge-lean\/logs\/check-V1\.log$/);
187
+ assert.equal(quietFailure("ok\nall good\n"), "ok\nall good");
188
+ assert.equal(quietFailure(""), "(no output)");
189
+ assert.equal(quietFailure("x".repeat(50), { maxChars: 10 }), `…${"x".repeat(10)}`);
190
+ });
191
+
192
+ it("worktreeFingerprint: the same content committed or not is the same tree; an edit or new file changes it; .forge-lean is ignored", async () => {
193
+ const dir = tmpDir("selfcheck-fp-");
194
+ const env = { ...process.env, GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@example.invalid", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@example.invalid", GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_NOSYSTEM: "1" };
195
+ const g = (a) => execFileSync("git", a, { cwd: dir, encoding: "utf8", env });
196
+ g(["init", "-q", "-b", "main"]); writeFileSync(join(dir, "a.js"), "1\n"); writeFileSync(join(dir, "gone.js"), "x\n"); g(["add", "-A"]); g(["commit", "-q", "-m", "base"]);
197
+ g(["branch", "base"]);
198
+ const fp = () => worktreeFingerprint({ git: async (a) => g(a), base: "base" });
199
+ const clean = await fp();
200
+ writeFileSync(join(dir, "a.js"), "2\n"); writeFileSync(join(dir, "new.js"), "n\n"); g(["rm", "-q", "gone.js"]);
201
+ const dirty = await fp(); assert.notEqual(dirty, clean);
202
+ execFileSync("mkdir", ["-p", join(dir, ".forge-lean")]); writeFileSync(join(dir, ".forge-lean", "result.json"), "{}");
203
+ g(["add", "-A"]); g(["reset", "-q", "--", ".forge-lean"]);
204
+ writeFileSync(join(dir, ".git", "info", "exclude"), ".forge-lean/\n");
205
+ g(["commit", "-q", "-m", "work"]);
206
+ assert.equal(await fp(), dirty, "committing the checked tree does not make the check stale");
207
+ writeFileSync(join(dir, "a.js"), "3\n");
208
+ assert.notEqual(await fp(), dirty);
209
+ });
210
+ });
@@ -10,12 +10,19 @@
10
10
  * 3. every repo path the issue names is gone from origin/<lane> -> candidate
11
11
  * A candidate gets ONE read-only, single-turn validation on a small model in the main checkout (no worktree). Only an
12
12
  * ALREADY-FIXED/INVALID verdict with evidence closes the issue; everything else, and every error, goes to the normal lane.
13
- * All effects are injected (gh, git, runner, transitionLabel, projector?) so it is unit-testable.
13
+ * Post-deploy gate (#4389), with `defaultBranch`: a `FORGE:DEFERRED_FROM: #P` follow-up whose checklist items are all
14
+ * post-deploy is parked (`parked: true`, label `awaiting-promotion`, no lane) while a merge SHA of #P is not an ancestor
15
+ * of origin/<default>, or while forge.yaml `post_deploy.precondition_cmd` exits 1. With `post_deploy.min_age` (#4425) it also
16
+ * stays parked (`awaiting-min-age`) until that long (or the longest window an item's why states) after the fix landed on
17
+ * the default branch. Anything unresolvable fails open.
18
+ * All effects are injected (gh, git, runner, transitionLabel, projector?, execCode?) so it is unit-testable.
14
19
  */
20
+ import { execFile } from "node:child_process";
15
21
  import { capComment, sanitizeMd, noComments } from "./render.mjs";
16
22
  import { PREFILTER_SYSTEM, prefilterPrompt, parsePrefilterVerdict } from "./prompts.mjs";
17
23
  import { isTrustedComment } from "../engine/phases.mjs";
18
24
  import { APP_SLUG } from "../init-repo.mjs";
25
+ import { probeResultOf } from "./probe.mjs";
19
26
 
20
27
  export const DEFAULT_PREFILTER_MODEL = "claude-haiku-5-5";
21
28
  export const PREFILTER_MARKER = "FORGE:PREFILTER";
@@ -78,11 +85,15 @@ export function flakyReason(iss, own = [bareLogin(APP_SLUG)]) {
78
85
  if (FLAKY_RE.test(`${iss.title ?? ""}\n${noComments(String(iss.body ?? ""), " ")}`)) return { reason: "flaky", why: "issue says the failure is intermittent" };
79
86
  if ((iss.labels ?? []).some((l) => String(l?.name ?? l).toLowerCase() === "flaky")) return { reason: "flaky", why: "flaky label" };
80
87
  const mine = new Set((own ?? []).map(bareLogin));
88
+ // #4399: the latest pipeline FORGE:PROBE result is a fail: the fix did not hold in production, so no already-fixed close
89
+ const probe = (iss.comments ?? []).filter((c) => probeResultOf(c?.body) && (trusted(c) || mine.has(bareLogin(loginOf(c))))).at(-1);
90
+ if (probe && probeResultOf(probe.body).result === "fail") return { reason: "probe-failed", why: "acceptance probe failed after promotion" };
81
91
  if ((iss.comments ?? []).some((c) => isMarked(c) && (trusted(c) || mine.has(bareLogin(loginOf(c)))))) return { reason: "reopened", why: "reopened after a pre-filter close" };
82
92
  return null;
83
93
  }
84
94
  /** The own logins, looked up only when an otherwise-untrusted marker comment needs them (no call for most issues). */
85
- const ownFor = async (iss, resolve) => ((iss.comments ?? []).some((c) => isMarked(c) && !trusted(c)) ? resolve() : undefined);
95
+ const ownFor = async (iss, resolve) => ((iss.comments ?? []).some((c) => (isMarked(c) || probeResultOf(c?.body)) && !trusted(c)) ? resolve() : undefined);
96
+ const selfResolver = (selfLogins, gh) => (typeof selfLogins === "function" ? selfLogins : selfLogins ? async () => selfLogins : () => ownLogins(gh));
86
97
 
87
98
  const PATH_RE = /(?:^|[\s`'"(\[])((?:\.{0,2}\/)?(?:[\w@.-]+\/)+[\w@.-]+\.[A-Za-z0-9]{1,8})(?::\d+(?:-\d+)?)?(?=$|[\s`'"),.:;\]])/gm;
88
99
  /** Repo-relative file paths the issue names (`dir/file.ext`, optionally `:line`); URLs and HTML comments are ignored. */
@@ -115,7 +126,7 @@ export async function collectSignals({ iss, repo, lane, io, cwd, selfLogins = nu
115
126
  const sha = latest.head_sha ?? "";
116
127
  // #4305: a workflow that failed and passed at the same SHA is flaky; the lookup is any branch (PR run vs push)
117
128
  // and only made when the issue itself gave no reason. An unreadable lookup throws: no CI signal.
118
- const own = await ownFor(iss, typeof selfLogins === "function" ? selfLogins : selfLogins ? async () => selfLogins : () => ownLogins(io.gh));
129
+ const own = await ownFor(iss, selfResolver(selfLogins, io.gh));
119
130
  let skip = flakyReason(iss, own);
120
131
  const same = { reason: "same-sha", why: `failed and passed at ${sha.slice(0, 12)}` };
121
132
  if (!skip && sha && failed.head_sha === sha) skip = same;
@@ -139,21 +150,225 @@ export async function collectSignals({ iss, repo, lane, io, cwd, selfLogins = nu
139
150
  return sig;
140
151
  }
141
152
 
153
+ // #4389: post-deploy precondition gate. A follow-up filed by scripts/file-deferred-followups.sh whose items are all
154
+ // post-deploy checks cannot be done before its parent's fix is on the default branch: a git fact, so no lane learns it.
155
+ export const PRECONDITION_MARKER = "FORGE:PRECONDITION";
156
+ export const AWAITING_PROMOTION_LABEL = "awaiting-promotion";
157
+ const DEFERRED_FROM_RE = /<!--\s*FORGE:DEFERRED_FROM:\s*#(\d+)\s*-->/;
158
+ /** The parent #P of a deferred follow-up (`<!-- FORGE:DEFERRED_FROM: #P -->`), or null. */
159
+ export function deferredFrom(body) {
160
+ const m = DEFERRED_FROM_RE.exec(String(body ?? ""));
161
+ return m ? Number(m[1]) : null;
162
+ }
163
+ const CHECK_RE = /^\s*[-*]\s+\[[ xX]\]\s+(.*)$/;
164
+ /** True when the body has checklist items and every one is a ``- [ ] `R#: …` — post-deploy: why`` line (conformance followupItems). */
165
+ export function postDeployOnly(body) {
166
+ const items = noComments(String(body ?? ""), " ").split("\n").map((l) => CHECK_RE.exec(l)?.[1]).filter((x) => x != null);
167
+ return items.length > 0 && items.every((t) => /^`[^`]*`\s+(?:\*\*known-flaw\*\*\s+)?[—-]+\s*post-deploy:/.test(t));
168
+ }
169
+ const SHA_RE = /^[0-9a-f]{7,40}$/i;
170
+ /**
171
+ * #4467: an issue comment the engine may trust, in REST shape (`user.type`) or `gh issue view` shape (`author.login`,
172
+ * `authorAssociation`): what isTrustedComment trusts, or the ForgeDock App, which `gh issue view` reports as a bare
173
+ * `rapiercraft-forgedock` CONTRIBUTOR with no type and no `[bot]` suffix (#4315).
174
+ */
175
+ export const isTrustedIssueComment = (c) => Boolean(c) && (trusted(c) || bareLogin(loginOf(c)) === bareLogin(APP_SLUG));
176
+ /** Merge commits of the merged PRs that claim to fix #parent; else the `merged at <sha>` of its trusted FORGE:TRAJECTORY. */
177
+ export async function parentMergeShas({ parent, repo, io }) {
178
+ const shas = new Set();
179
+ try {
180
+ const prs = JSON.parse(String(await io.gh(["pr", "list", "-R", repo, "--state", "merged", "--search", `#${parent}`, "--json", "number,title,body,mergeCommit", "--limit", "20"])) || "[]");
181
+ for (const p of prs) if ((claimsFix(p.title, parent) || claimsFix(p.body, parent)) && SHA_RE.test(p.mergeCommit?.oid ?? "")) shas.add(p.mergeCommit.oid);
182
+ } catch { /* unreadable: try the trajectory */ }
183
+ if (!shas.size) {
184
+ try {
185
+ const v = JSON.parse(String(await io.gh(["issue", "view", String(parent), "-R", repo, "--json", "comments"])));
186
+ for (const c of v.comments ?? []) {
187
+ const b = String(c?.body ?? "");
188
+ const m = b.includes("FORGE:TRAJECTORY") && isTrustedIssueComment(c) ? /merged at `?([0-9a-f]{7,40})\b/i.exec(b) : null;
189
+ if (m) shas.add(m[1]);
190
+ }
191
+ } catch { /* unreadable: no SHA */ }
192
+ }
193
+ return [...shas];
194
+ }
195
+ /** Default `io.execCode`: the exit code of an argv-only child (-1 when it could not run or timed out). */
196
+ function execCode(cmd, args, opts) {
197
+ return new Promise((res) => {
198
+ try { execFile(cmd, args, opts, (err) => res(err ? (typeof err.code === "number" ? err.code : -1) : 0)); } catch { res(-1); }
199
+ });
200
+ }
201
+ // #4425: post_deploy.min_age soak window. Deterministic: a strict duration parse, and git for the landing time.
202
+ const UNIT_MS = { m: 60e3, h: 3600e3, d: 86400e3, w: 604800e3 };
203
+ /** forge.yaml `post_deploy.min_age` ("30m", "24h", "7d", "2w"; a bare number is minutes, so "0" is allowed) -> ms, or null. */
204
+ export function parseMinAge(s) {
205
+ if (s == null) return null;
206
+ const m = /^(\d+(?:\.\d+)?)\s*([mhdw]?)$/i.exec(String(s).trim());
207
+ return m ? Math.round(Number(m[1]) * UNIT_MS[(m[2] || "m").toLowerCase()]) : null;
208
+ }
209
+ const WINDOW_RE = /\b(\d+(?:\.\d+)?)\s*(h|hrs?|hours?|d|days?|w|wks?|weeks?)\b/gi;
210
+ const POST_DEPLOY_ITEM_RE = /^`[^`]*`\s+(?:\*\*known-flaw\*\*\s+)?[—-]+\s*post-deploy:(.*)$/;
211
+ /** Per post-deploy checklist item: the largest duration its why states (ms), or null when it states none. */
212
+ export function itemWindowsMs(body) {
213
+ return noComments(String(body ?? ""), " ").split("\n").map((l) => CHECK_RE.exec(l)?.[1]).filter((x) => x != null)
214
+ .map((t) => POST_DEPLOY_ITEM_RE.exec(t)).filter(Boolean).map((m) => {
215
+ const ms = [...m[1].matchAll(WINDOW_RE)].map((w) => Number(w[1]) * UNIT_MS[w[2][0].toLowerCase()]);
216
+ return ms.length ? Math.round(Math.max(...ms)) : null;
217
+ });
218
+ }
219
+ /**
220
+ * Unix seconds when `sha` reached origin/<def>: the oldest first-parent commit of <def> that descends from it (the merge
221
+ * that brought it in), or the SHA's own commit time when it is on the mainline itself. Null on any git error.
222
+ */
223
+ export async function landedAt({ sha, def, io, cwd }) {
224
+ try {
225
+ // --first-parent with --ancestry-path drops merges that brought the SHA in through a second parent (git 2.55): two walks
226
+ const range = `${sha}..origin/${def}`;
227
+ const below = new Set(String(await io.git(["rev-list", "--ancestry-path", range], { cwd })).trim().split(/\s+/).filter(Boolean));
228
+ const chain = String(await io.git(["log", "--first-parent", "--format=%H %P %ct", range], { cwd })).trim().split("\n").map((l) => l.trim().split(/\s+/));
229
+ const oldest = chain.filter((c) => below.has(c[0])).at(-1);
230
+ let own = !oldest;
231
+ if (!own) {
232
+ const full = /^[0-9a-f]{40}$/i.test(sha) ? sha : String(await io.git(["rev-parse", `${sha}^{commit}`], { cwd })).trim();
233
+ own = oldest[1]?.toLowerCase() === full.toLowerCase();
234
+ }
235
+ const ct = own ? String(await io.git(["log", "-1", "--format=%ct", sha], { cwd })).trim() : oldest.at(-1);
236
+ return /^\d+$/.test(ct) ? Number(ct) : null;
237
+ } catch { return null; }
238
+ }
239
+ const fmtAge = (ms) => (ms % UNIT_MS.d === 0 && ms ? `${ms / UNIT_MS.d}d` : ms % UNIT_MS.h === 0 && ms ? `${ms / UNIT_MS.h}h` : `${Math.round(ms / UNIT_MS.m)}m`);
240
+ /**
241
+ * #4389: never throws, no model call. `defaultBranch` is a name or an (async) function resolving it (and fetching it).
242
+ * #4425: `minAge` (ms, opt-in) parks `awaiting-min-age` until max(item window ?? minAge) after the fix landed on <default>.
243
+ * @returns {Promise<{state:"not-applicable"|"unresolved"|"awaiting-promotion"|"awaiting-min-age"|"awaiting-precondition"|"ready", parent:?number, shas:string[], default?:string, eligibleAt?:string, detail:string}>}
244
+ * Fail-open: no merge SHA, a git answer other than yes/no, an unknown default branch or a hook error give "unresolved".
245
+ */
246
+ export async function postDeployGate({ iss, repo, lane, defaultBranch, cwd, io, preconditionCmd = null, minAge = null, now = null }) {
247
+ const parent = deferredFrom(iss?.body);
248
+ const res = (state, detail, extra = {}) => ({ state, parent, shas: [], detail, ...extra });
249
+ if (!parent || !postDeployOnly(iss.body)) return res("not-applicable", "");
250
+ try {
251
+ const def = typeof defaultBranch === "function" ? await defaultBranch() : defaultBranch;
252
+ if (!def) return res("unresolved", "default branch unknown");
253
+ const shas = await parentMergeShas({ parent, repo, io });
254
+ if (!shas.length) return res("unresolved", `no merge SHA found for #${parent}`);
255
+ const off = [];
256
+ for (const sha of shas) {
257
+ let on;
258
+ try { await io.git(["merge-base", "--is-ancestor", sha, `origin/${def}`], { cwd }); on = true; } catch (e) { on = e?.code === 1 ? false : null; }
259
+ if (on === null) return res("unresolved", `${sha.slice(0, 12)} unknown to git`, { shas, default: def });
260
+ if (!on) off.push(sha);
261
+ }
262
+ if (off.length) return res("awaiting-promotion", `fix ${off.map((s) => s.slice(0, 12)).join(", ")} of #${parent} is not on ${def}${lane ? ` (lane ${lane})` : ""}`, { shas, default: def });
263
+ if (minAge != null) {
264
+ const required = Math.max(0, ...itemWindowsMs(iss.body).map((w) => w ?? minAge));
265
+ let landed = 0;
266
+ for (const sha of shas) {
267
+ const t = await landedAt({ sha, def, io, cwd });
268
+ if (t == null) return res("unresolved", `landing time of ${sha.slice(0, 12)} on ${def} unknown`, { shas, default: def });
269
+ landed = Math.max(landed, t);
270
+ }
271
+ const at = now ?? io.now?.() ?? Date.now(), until = landed * 1000 + required;
272
+ if (at < until) {
273
+ const eligibleAt = new Date(until).toISOString();
274
+ return res("awaiting-min-age", `fix of #${parent} on ${def} since ${new Date(landed * 1000).toISOString()}; min age ${fmtAge(required)} until ${eligibleAt}`, { shas, default: def, eligibleAt });
275
+ }
276
+ }
277
+ if (preconditionCmd) {
278
+ const code = await (io.execCode ?? execCode)("sh", ["-c", preconditionCmd, "precondition", String(iss.number)],
279
+ { cwd, timeout: 60000, env: { ...process.env, FORGE_ISSUE: String(iss.number) } });
280
+ if (code === 1) return res("awaiting-precondition", `fix of #${parent} is on ${def}; post_deploy.precondition_cmd says not ready`, { shas, default: def });
281
+ if (code !== 0) return res("unresolved", `post_deploy.precondition_cmd exited ${code}`, { shas, default: def });
282
+ }
283
+ return res("ready", `fix of #${parent} is on ${def}`, { shas, default: def });
284
+ } catch (e) {
285
+ return res("unresolved", `gate error (${String(e?.message ?? e).slice(0, 120)})`);
286
+ }
287
+ }
288
+ /**
289
+ * #4389: the default branch for the gate, resolved and fetched once, only when a gated issue asks: forge.yaml
290
+ * `branches.default`, else GitHub's default branch. Null when neither answers (the gate then fails open).
291
+ */
292
+ export function lazyDefaultBranch({ io, repo, cwd, configured = null }) {
293
+ let p = null;
294
+ return () => (p ??= (async () => {
295
+ let name = configured;
296
+ if (!name) { try { name = String(await io.gh(["repo", "view", repo, "--json", "defaultBranchRef", "-q", ".defaultBranchRef.name"])).trim() || null; } catch { name = null; } }
297
+ if (name) { try { await io.git(["fetch", "origin", name], { cwd }); } catch { /* offline: use the local ref */ } }
298
+ return name;
299
+ })());
300
+ }
301
+ /** #4389: label and explain a parked issue once (the comment only when the label was not there yet); best effort. */
302
+ async function markParked({ issue, repo, io, had, gate }) {
303
+ const edit = () => io.gh(["issue", "edit", String(issue), "-R", repo, "--add-label", AWAITING_PROMOTION_LABEL]);
304
+ try { await edit(); } catch {
305
+ try {
306
+ await io.gh(["label", "create", AWAITING_PROMOTION_LABEL, "-R", repo, "--force", "--color", "C5DEF5", "--description", "Post-deploy follow-up parked until its parent fix reaches the default branch. Managed by ForgeDock."]);
307
+ await edit();
308
+ } catch { /* the comment and the report still show it */ }
309
+ }
310
+ if (had) return;
311
+ const what = gate.state === "awaiting-promotion" ? "awaiting promotion" : gate.state === "awaiting-min-age" ? "awaiting its post-deploy soak window" : "awaiting its post-deploy precondition";
312
+ const body = `<!-- ${PRECONDITION_MARKER} -->\n## Parked: ${what}\n${sanitizeMd(gate.detail)}.\n\nEvery item here is a post-deploy check, so no lane was started. The next orchestrate or triage run checks again and launches this issue once the precondition holds.`;
313
+ try { await io.gh(["issue", "comment", String(issue), "-R", repo, "--body", capComment(body)]); } catch { /* the label still shows it */ }
314
+ }
315
+
316
+ /** #4389 batch-report lines: one `awaiting promotion:` line (and one `awaiting min age:` / `awaiting precondition:` line) for the parked rows, or "". */
317
+ export function formatParkedLine(rows) {
318
+ const parked = (rows ?? []).filter((r) => r?.prefilter?.parked);
319
+ const uniq = (xs) => [...new Set(xs.filter(Boolean))];
320
+ const out = [];
321
+ const promo = parked.filter((r) => r.prefilter.kind === "awaiting-promotion");
322
+ if (promo.length) {
323
+ const lanes = uniq(promo.map((r) => r.prefilter.lane)), defs = uniq(promo.map((r) => r.prefilter.default));
324
+ out.push(`awaiting promotion: ${promo.map((r) => `#${r.issue}`).join(" ")} (fixes on ${lanes.join(", ") || "the lane"}, not on ${defs.join(", ") || "the default branch"})`);
325
+ }
326
+ const soak = parked.filter((r) => r.prefilter.kind === "awaiting-min-age"); // #4425
327
+ if (soak.length) {
328
+ const defs = uniq(soak.map((r) => r.prefilter.default));
329
+ out.push(`awaiting min age: ${soak.map((r) => `#${r.issue}`).join(" ")} (fix on ${defs.join(", ") || "the default branch"}, soak window not elapsed)`);
330
+ }
331
+ const pre = parked.filter((r) => r.prefilter.kind === "awaiting-precondition");
332
+ if (pre.length) out.push(`awaiting precondition: ${pre.map((r) => `#${r.issue}`).join(" ")} (post_deploy.precondition_cmd not ready)`);
333
+ return out.join("\n");
334
+ }
335
+
142
336
  /**
143
337
  * Pre-filter one issue. Never throws.
144
338
  * @returns {Promise<{issue:number, closed:boolean, kind:?string, candidate:boolean, verdict:?string, cost_usd:number, detail:string}>}
145
339
  */
146
- export async function prefilterIssue({ issue, repo, lane, cwd, io, model = DEFAULT_PREFILTER_MODEL, dryRun = false, agentId = null, maxMinutes = 5, selfLogins = null }) {
340
+ export async function prefilterIssue({ issue, repo, lane, cwd, io, model = DEFAULT_PREFILTER_MODEL, dryRun = false, agentId = null, maxMinutes = 5, selfLogins = null, defaultBranch = null, preconditionCmd = null, minAge = null }) {
147
341
  const out = { issue, closed: false, kind: null, candidate: false, verdict: null, cost_usd: 0, detail: "" };
148
342
  try {
149
343
  const iss = JSON.parse(String(await io.gh(["issue", "view", String(issue), "-R", repo, "--json", "number,title,body,state,labels,comments"])));
150
344
  if (String(iss.state).toUpperCase() !== "OPEN") return { ...out, detail: "not open" };
151
- if ((iss.labels ?? []).some((l) => ["needs-human", "workflow:merged"].includes(l?.name ?? l))) return { ...out, detail: "labelled, left to the lane" };
345
+ const names = (iss.labels ?? []).map((l) => l?.name ?? l);
346
+ const labelled = names.some((l) => ["needs-human", "workflow:merged"].includes(l));
347
+ // #4389: a post-deploy follow-up is gated even when an earlier lane escalated it (needs-human); nothing else changes.
348
+ const gated = !!defaultBranch && !names.includes("workflow:merged") && deferredFrom(iss.body) != null && postDeployOnly(iss.body);
349
+ if (labelled && !gated) return { ...out, detail: "labelled, left to the lane" };
152
350
  if (io.projector) {
153
351
  let st = null;
154
352
  try { st = await io.projector.readState(issue); } catch { /* unreadable: proceed */ }
155
353
  if (st?.lease && st.lease.until > Date.now() && st.lease.by !== agentId) return { ...out, detail: `leased by ${st.lease.by}` };
156
354
  }
355
+ if (gated) {
356
+ const gate = await postDeployGate({ iss, repo, lane, defaultBranch, cwd, io, preconditionCmd, minAge });
357
+ const had = names.includes(AWAITING_PROMOTION_LABEL);
358
+ if (["awaiting-promotion", "awaiting-min-age", "awaiting-precondition"].includes(gate.state)) {
359
+ if (!dryRun) await markParked({ issue, repo, io, had, gate });
360
+ return { ...out, parked: true, kind: gate.state, parent: gate.parent, lane, default: gate.default, detail: gate.detail };
361
+ }
362
+ if (gate.state === "ready" && had && !dryRun) {
363
+ try { await io.gh(["issue", "edit", String(issue), "-R", repo, "--remove-label", AWAITING_PROMOTION_LABEL]); } catch { /* best effort */ }
364
+ }
365
+ if (labelled) return { ...out, detail: `labelled, left to the lane (post-deploy gate: ${gate.state}${gate.detail ? `, ${gate.detail}` : ""})` };
366
+ }
367
+ // #4399: an issue reopened by a failed acceptance probe goes to a lane, never back to an already-fixed close
368
+ if ((iss.comments ?? []).some((c) => probeResultOf(c?.body)?.result === "fail")
369
+ && flakyReason({ comments: iss.comments }, await ownFor(iss, selfResolver(selfLogins, io.gh)))?.reason === "probe-failed") {
370
+ return { ...out, detail: "acceptance probe failed after promotion (FORGE:PROBE): no already-fixed close; lane" };
371
+ }
157
372
  const sig = await collectSignals({ iss, repo, lane, io, cwd, selfLogins });
158
373
  const close = async (kind, body, detail) => {
159
374
  let note = "";