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
@@ -8,13 +8,13 @@ import { tmpdir } from "node:os";
8
8
  import { dirname, join } from "node:path";
9
9
  import { fileURLToPath } from "node:url";
10
10
  import { spawnSync } from "node:child_process";
11
- import { aggregate, compareVersions, percentile } from "./aggregate.mjs";
11
+ import { aggregate, compareVersions, computeAlerts, DEFAULT_ALERTS, percentile } from "./aggregate.mjs";
12
12
  import { cacheDir } from "./cache.mjs";
13
13
  import { fetchGithubRecords, parseTelemetryComments, runsFromRecords, toModel } from "./github-source.mjs";
14
- import { buildReport, makeProgressPrinter, parseArgs, parseSince, runStats, sinceToTimestamp } from "./index.mjs";
15
- import { readLocalRuns, summariseLog } from "./local-source.mjs";
16
- import { renderCompact, renderJson, renderMarkdown } from "./render.mjs";
17
- import { edgeQuery, parseEdge, verdictMix, contextPackAb, renderContextAb } from "./query.mjs";
14
+ import { buildReport, makeProgressPrinter, parseArgs, parseSince, readAlertConfig, runStats, sinceToTimestamp } from "./index.mjs";
15
+ import { lanesOfLog, readLanes, readLocalLaneRuns, readLocalRuns, summariseLog } from "./local-source.mjs";
16
+ import { renderAlerts, renderCompact, renderJson, renderMarkdown } from "./render.mjs";
17
+ import { edgeQuery, parseEdge, verdictMix, contextPackAb, renderContextAb, builderTierReport, renderTiers, exploreAb, renderExploreAb, laneProfileReport, renderLaneProfiles } from "./query.mjs";
18
18
 
19
19
  const here = dirname(fileURLToPath(import.meta.url));
20
20
  const fx = (f) => join(here, "fixtures", f);
@@ -538,21 +538,23 @@ describe("context-pack A/B over run logs (forge#4275)", () => {
538
538
 
539
539
  it("summariseLog carries mode, lane type, builder turns, cache tokens, cost and first-pass", () => {
540
540
  const s = summariseLog(log("on", "child"));
541
- assert.deepEqual(s.contextPack, { mode: "on", laneType: "child" });
541
+ assert.deepEqual(s.contextPack, { mode: "on", laneType: "child", ab: false });
542
542
  assert.deepEqual([s.builderTurns, s.cacheTokens, s.costUsd, s.firstPass], [10, 2130, 1.1, true]);
543
543
  assert.equal(summariseLog(log("on", "child", { builds: 4 })).firstPass, false);
544
544
  assert.equal(summariseLog(log("on", "child", { reason: "needs-human" })).firstPass, false);
545
545
  const fixed = log("on", "child"); fixed.splice(-1, 0, { event: "CI_FIX_ROUND", trigger: "build", round: 1 });
546
546
  assert.equal(summariseLog(fixed).firstPass, false);
547
547
  assert.equal(summariseLog([{ event: "RUN_START", issue: 3 }, { event: "RUN_TERMINAL", reason: "merged" }]).contextPack.mode, "off");
548
+ assert.equal(summariseLog([{ event: "RUN_START", issue: 3 }, { event: "RUN_TERMINAL", reason: "merged" }]).size, null);
549
+ assert.equal(summariseLog([{ event: "RUN_START", issue: 3 }, { event: "RUN_TERMINAL", reason: "merged", turns: { size: "L", budget: 100, build: 9 } }]).size, "L"); // #4409
548
550
  });
549
551
 
550
552
  it("reports per lane type and mode, never one blended average", () => {
551
553
  const runs = [log("on", "first"), log("on", "first", { builds: 4, reviews: 2 }), log("off", "first"), log("shadow", "rerun"),
552
554
  [{ event: "RUN_START", issue: 2 }, { event: "RUN_TERMINAL", reason: "merged" }]].map((l) => summariseLog(l));
553
555
  const ab = contextPackAb(runs);
554
- assert.deepEqual(Object.keys(ab), ["rerun", "child", "first"]);
555
- assert.deepEqual(ab.first.on, { n: 2, first_pass_yield: 0.5, builder_turns: 15, cache_tokens: 3195, cost_usd: 1.65 });
556
+ assert.deepEqual(Object.keys(ab), ["rerun", "child", "first", "recovery"]);
557
+ assert.deepEqual(ab.first.on, { n: 2, first_pass_yield: 0.5, builder_turns: 15, cache_tokens: 3195, cost_usd: 1.65, merged: 2, turns_per_merge: 15, cache_read_per_merge: 3150, rework_rate: 0 });
556
558
  assert.equal(ab.first.off.n, 1); assert.equal(ab.first.off.first_pass_yield, 1);
557
559
  assert.equal(ab.rerun.shadow.n, 1); assert.equal(ab.child.on.n, 0); assert.equal(ab.child.on.cost_usd, null);
558
560
  assert.match(renderContextAb(ab), /\| first \| on \| 2 \| 0\.5 \| 15 \| 3195 \| 1\.65 \|/);
@@ -564,3 +566,396 @@ describe("context-pack A/B over run logs (forge#4275)", () => {
564
566
  assert.equal(rep.query.context_ab.first.on.n, 1);
565
567
  });
566
568
  });
569
+
570
+ describe("explorer A/B over run logs (forge#4418)", () => {
571
+ const use = (phase, turns, cr) => ({ event: "PHASE_ATTEMPT_USAGE", phase, turns, cost_usd: 0.5, usage: { cache_read_input_tokens: cr, cache_creation_input_tokens: 10, output_tokens: 1 } });
572
+ const slice = (issue, explore, { size = "M", turns = 5, cr = 1000, reason = "merged", triggers = ["plan", "build"], review = true } = {}) => [
573
+ { event: "RUN_START", issue, repo: "o/r", run: `r${issue}${explore}` },
574
+ ...triggers.map((t) => ({ ...use("implement", turns, cr), trigger: t })), ...(review ? [use("review", 2, 100)] : []),
575
+ { event: "RUN_TERMINAL", reason, code: "x", ...(explore ? { explore } : {}), ...(size ? { turns: { size, budget: 100, build: 9 } } : {}) },
576
+ ];
577
+
578
+ it("summariseLog carries the explore tag and the plan + build cache-read", () => {
579
+ const on = summariseLog(slice(1, "on"));
580
+ assert.deepEqual([on.explore, on.builderCacheRead, on.builderTurns, on.cacheTokens], ["on", 2000, 10, 2130]);
581
+ assert.equal(summariseLog(slice(1, "off")).explore, "off");
582
+ assert.equal(summariseLog(slice(1, null)).explore, null);
583
+ assert.equal(summariseLog(slice(1, "maybe")).explore, null);
584
+ });
585
+
586
+ it("readLocalLaneRuns keeps every RUN_START slice of a log", () => {
587
+ const dir = tmpDir("explore-ab-");
588
+ try {
589
+ writeFileSync(join(dir, "o__r__7.jsonl"), [...slice(7, "on"), ...slice(7, "off", { reason: "needs-human" })].map((e) => JSON.stringify(e)).join("\n"));
590
+ const runs = readLocalLaneRuns(dir);
591
+ assert.deepEqual(runs.map((r) => [r.issue, r.explore, r.reason]), [[7, "on", "merged"], [7, "off", "needs-human"]]);
592
+ assert.equal(readLocalRuns(dir).length, 1);
593
+ assert.deepEqual(readLocalLaneRuns(join(dir, "missing")), []);
594
+ } finally { rmSync(dir, { recursive: true, force: true }); }
595
+ });
596
+
597
+ it("reports per size and mode, pairs same issues and leaves untagged runs out", () => {
598
+ const runs = [slice(1, "on", { size: "L", turns: 3, cr: 500 }), slice(1, "off", { size: "L", turns: 10, cr: 4000 }),
599
+ slice(2, "on", { size: "L", turns: 4, cr: 600, triggers: ["plan", "build", "conformance"] }), slice(3, "off", { size: "S", reason: "needs-human", review: false }),
600
+ slice(4, null), slice(5, "on", { size: null })].map((l) => summariseLog(l));
601
+ const ab = exploreAb(runs);
602
+ assert.deepEqual(Object.keys(ab.by_size), ["S", "M", "L", "unsized"]);
603
+ assert.deepEqual(ab.by_size.L.on, { n: 2, builder_turns: 9, builder_cache_read: 1400, merge_rate: 1, conformance_rate: 0.5, reviewed: 2 });
604
+ assert.deepEqual(ab.by_size.L.off, { n: 1, builder_turns: 20, builder_cache_read: 8000, merge_rate: 1, conformance_rate: 1, reviewed: 1 });
605
+ assert.deepEqual([ab.by_size.S.off.merge_rate, ab.by_size.S.off.conformance_rate, ab.by_size.M.on.n, ab.by_size.M.on.builder_turns], [0, null, 0, null]);
606
+ assert.equal(ab.by_size.unsized.on.n, 1);
607
+ assert.equal(ab.untagged, 1);
608
+ assert.equal(ab.paired.issues, 1);
609
+ assert.deepEqual([ab.paired.on.n, ab.paired.on.builder_cache_read, ab.paired.off.n, ab.paired.off.builder_cache_read], [1, 1000, 1, 8000]);
610
+ const md = renderExploreAb(ab);
611
+ assert.match(md, /\| L \| on \| 2 \| 9 \| 1400 \| 1 \| 0\.5 \(2\) \|/);
612
+ assert.match(md, /\| paired \| off \| 1 \| 20 \| 8000 \| 1 \| 1 \(1\) \|/);
613
+ assert.match(md, /Same issues in both modes: 1/);
614
+ assert.match(md, /1 finished run\(s\) without an explore tag/);
615
+ });
616
+
617
+ it("--explore-ab reads run logs only (no GitHub crawl)", async () => {
618
+ assert.equal(parseArgs(["--explore-ab"]).exploreAb, true);
619
+ assert.equal(parseArgs([]).exploreAb, false);
620
+ const rep = await buildReport({ repo: "o/r", exploreAb: true }, { localRuns: [summariseLog(slice(1, "on")), { ...summariseLog(slice(2, "on")), repo: "x/y" }], releaseTimes: [], gh: async () => { throw new Error("no network"); } });
621
+ assert.equal(rep.query.explore_ab.by_size.M.on.n, 1);
622
+ assert.equal(rep.sources.local.runs, 1);
623
+ });
624
+ });
625
+
626
+ describe("context-pack A/B per merge, rework and recovery vs first (#4444)", () => {
627
+ const use = (trigger, turns, cr, out) => ({ event: "PHASE_ATTEMPT_USAGE", phase: "implement", trigger, turns, cost_usd: 0.5, usage: { cache_read_input_tokens: cr, cache_creation_input_tokens: 10, output_tokens: out } });
628
+ const slice = (issue, lt, { reason = "merged", mode = "on", ab = false, calls = [["plan", 2, 100, 10], ["build", 10, 1000, 400]], extra = [] } = {}) => [
629
+ { event: "RUN_START", issue, repo: "o/r", run: `r${issue}${lt}`, ts: lt === "rerun" ? "2026-10-02T00:00:00Z" : "2026-10-01T00:00:00Z" },
630
+ ...calls.map((c) => use(...c)), ...extra,
631
+ { event: "RUN_TERMINAL", reason, code: "x", context_pack: mode, lane_type: lt, ...(ab ? { context_ab: true } : {}) },
632
+ ];
633
+
634
+ it("summariseLog: cache-read alone, the implement attempt without the plan call, the ab tag and the log's first-run baseline", () => {
635
+ const s = summariseLog([...slice(5, "first"), ...slice(5, "rerun", { ab: true, calls: [["recover", 3, 50, 100]] })]);
636
+ assert.equal(s.cacheReadTokens, 50);
637
+ assert.deepEqual(s.implementAttempt, { turns: 3, output_tokens: 100 });
638
+ assert.deepEqual(s.firstRunAttempt, { turns: 10, output_tokens: 400 });
639
+ assert.equal(s.contextPack.ab, true);
640
+ assert.equal(summariseLog(slice(5, "first")).firstRunAttempt, null);
641
+ assert.deepEqual(summariseLog(slice(6, "first", { calls: [] })).implementAttempt, null);
642
+ });
643
+
644
+ it("per-merge turns and cache-read, rework rate and the same-issue recovery ratio", () => {
645
+ const runs = [summariseLog(slice(1, "first")), summariseLog(slice(2, "first", { reason: "needs-human" })),
646
+ summariseLog(slice(3, "first", { extra: [{ event: "PHASE_COMMIT", phase: "remediate" }] })),
647
+ summariseLog([...slice(4, "first"), ...slice(4, "rerun", { calls: [["recover", 5, 0, 100]] })]),
648
+ summariseLog(slice(1, "rerun", { calls: [["recover", 2, 0, 200]] }))];
649
+ const ab = contextPackAb(runs);
650
+ // three runs: 12 builder turns each, 1100 cache-read each, two merged
651
+ assert.deepEqual([ab.first.on.merged, ab.first.on.turns_per_merge, ab.first.on.cache_read_per_merge, ab.first.on.rework_rate], [2, 18, 1650, 0.3333]);
652
+ assert.equal(ab.rerun.on.rework_rate, 1);
653
+ assert.equal(ab.child.on.turns_per_merge, null);
654
+ // issue 4: 5/10 turns, 100/400 output (own log); issue 1: 2/10 turns, 200/400 output (earlier first run in `runs`)
655
+ assert.deepEqual(ab.recovery, { pairs: 2, mean_turn_ratio: 0.35, mean_output_ratio: 0.375 });
656
+ const md = renderContextAb(ab);
657
+ assert.match(md, /\| Merged \| Turns\/merge \| Cache-read\/merge \| Rework rate \|/);
658
+ assert.match(md, /\| first \| on \| 3 \| .* \| 2 \| 18 \| 1650 \| 0\.3333 \|/);
659
+ assert.match(md, /Recovery vs first run \(same issue\): 2 pair\(s\), implement turns ratio 0\.35, output tokens ratio 0\.375/);
660
+ assert.deepEqual(contextPackAb([]).recovery, { pairs: 0, mean_turn_ratio: null, mean_output_ratio: null });
661
+ });
662
+ });
663
+
664
+ describe("builder model tiers over run logs (forge#4394)", () => {
665
+ const use = (phase, trigger, usd = 1) => ({ event: "PHASE_ATTEMPT_USAGE", phase, trigger, cost_usd: usd, usage: { cache_read_input_tokens: 1, output_tokens: 1 } });
666
+ const slice = (issue, { tier = "mid", cls = "small", escalated = false, reason = "merged", triggers = ["plan", "build"], review = true, shadow } = {}) => [
667
+ { event: "RUN_START", issue, repo: "o/r", run: `r${issue}` },
668
+ ...triggers.map((t) => use("implement", t)), ...(review ? [use("review", "review", 0.5)] : []),
669
+ { event: "RUN_TERMINAL", reason, code: "x", ...(shadow ? { shadow_would: shadow } : {}), builder_tier: tier, builder_class: cls, builder_escalated: escalated, builder_model: tier === "mid" ? "claude-sonnet-5-5" : "default" },
670
+ ];
671
+
672
+ it("summariseLog reads builderRoute from RUN_TERMINAL, else the BUILDER_ROUTE/BUILDER_ESCALATED events; null before routing", () => {
673
+ const s = summariseLog(slice(1, { escalated: true, triggers: ["plan", "build", "conformance"], shadow: "conformance.scope" }));
674
+ assert.deepEqual(s.builderRoute, { tier: "mid", class: "small", escalated: true, model: "claude-sonnet-5-5" });
675
+ assert.equal(s.conformanceRound, true); assert.equal(s.shadowWould, "conformance.scope"); assert.equal(s.reachedReview, true);
676
+ const evs = [{ event: "RUN_START", issue: 2 }, { event: "BUILDER_ROUTE", tier: "mid", class: "docs-config-test", model: "m" }, { event: "BUILDER_ESCALATED", from: "m", to: "default", trigger: "ci_red" }, { event: "RUN_TERMINAL", reason: "merged" }];
677
+ assert.deepEqual(summariseLog(evs).builderRoute, { tier: "mid", class: "docs-config-test", escalated: true, model: "m" });
678
+ const old = summariseLog([{ event: "RUN_START", issue: 3 }, { event: "RUN_TERMINAL", reason: "merged" }]);
679
+ assert.equal(old.builderRoute, null); assert.equal(old.conformanceRound, false); assert.equal(old.shadowWould, null);
680
+ });
681
+
682
+ it("reports merge, conformance and post-merge-rework rates per tier and class; unrouted runs are left out", () => {
683
+ const reworked = [...slice(5, { tier: "top", cls: "large" }), ...slice(5, { tier: "top", cls: "large", reason: "needs-human" })]; // a later run of a merged issue
684
+ const runs = [slice(1), slice(2, { triggers: ["plan", "build", "review"] }), slice(3, { reason: "needs-human", review: false }),
685
+ slice(4, { escalated: true, triggers: ["plan", "build", "precheck"] }), reworked, [{ event: "RUN_START", issue: 9 }, { event: "RUN_TERMINAL", reason: "merged" }]].map((l) => summariseLog(l));
686
+ const rep = builderTierReport(runs);
687
+ assert.equal(rep.unrouted, 1);
688
+ assert.deepEqual(rep.rows.map((g) => [g.tier, g.class]), [["mid", "small"], ["mid->top", "small"], ["top", "large"]]);
689
+ const [mid, esc, top] = rep.rows;
690
+ assert.deepEqual([mid.n, mid.merge_rate, mid.reviewed, mid.conformance_rate, mid.merges, mid.post_merge_rework_rate, mid.first_pass_yield], [3, 0.6667, 2, 0.5, 2, 0, 0.3333]);
691
+ assert.deepEqual([esc.n, esc.merge_rate, esc.conformance_rate], [1, 1, 1]);
692
+ assert.deepEqual([top.n, top.merge_rate, top.merges, top.post_merge_rework_rate], [1, 0, 1, 1]);
693
+ const md = renderTiers(rep);
694
+ assert.match(md, /\| mid \| small \| 3 \| 0\.6667 \| 0\.5 \(2\) \| 0 \(2\) \| 0\.3333 \|/);
695
+ assert.match(md, /\| top \| large \| 1 \| 0 \| .* \| 1 \(1\) \|/);
696
+ assert.match(md, /1 finished run\(s\) without a builder route/);
697
+ assert.match(renderTiers(builderTierReport([])), /\| none \|/);
698
+ });
699
+
700
+ it("--tiers reads run logs only (no GitHub crawl); other reports carry no tiers", async () => {
701
+ assert.equal(parseArgs(["--tiers"]).tiers, true); assert.equal(parseArgs([]).tiers, false);
702
+ const rep = await buildReport({ repo: "o/r", tiers: true }, { localRuns: [summariseLog(slice(1))], releaseTimes: [], gh: async () => { throw new Error("no network"); } });
703
+ assert.equal(rep.query.tiers.rows[0].n, 1);
704
+ });
705
+ });
706
+
707
+ describe("lane profile report (#4436)", () => {
708
+ const use = (phase, profile, baseline, trigger = "build") => ({ event: "PHASE_ATTEMPT_USAGE", phase, trigger, cost_usd: 1, usage: { cache_read_input_tokens: 1, output_tokens: 1 },
709
+ ...(profile ? { lane_profile: profile } : {}), ...(baseline != null ? { baseline_tokens: baseline } : {}) });
710
+ const slice = (issue, profile, { impl = [40000], review = 20000, shadow, triggers } = {}) => [
711
+ { event: "RUN_START", issue, repo: "o/r", run: `r${issue}${profile}` },
712
+ ...impl.map((b, i) => use("implement", profile, b, triggers?.[i])), ...(review != null ? [use("review", profile, review, "review")] : []),
713
+ { event: "RUN_TERMINAL", reason: "merged", ...(shadow ? { shadow_would: shadow } : {}) },
714
+ ];
715
+
716
+ it("summariseLog keeps the slice's lane profile and per-phase baselines; resumed sessions add none", () => {
717
+ const s = summariseLog(slice(1, "lane", { impl: [30000, null] }));
718
+ assert.equal(s.laneProfile, "lane");
719
+ assert.deepEqual(s.baselines, { implement: [30000], review: [20000] });
720
+ const mixed = summariseLog([{ event: "RUN_START", issue: 2 }, use("implement", "memory-off", 1), use("review", "lane", 2), { event: "RUN_TERMINAL", reason: "merged" }]);
721
+ assert.equal(mixed.laneProfile, "mixed");
722
+ const old = summariseLog([{ event: "RUN_START", issue: 3 }, use("implement", null, null), { event: "RUN_TERMINAL", reason: "merged" }]);
723
+ assert.equal(old.laneProfile, null); assert.deepEqual(old.baselines, {});
724
+ });
725
+
726
+ it("readLocalRuns keeps one summary per log by default and every RUN_START slice with allSlices", () => {
727
+ const dir = tmpDir("lane-profile-");
728
+ try {
729
+ writeFileSync(join(dir, "7.jsonl"), [...slice(7, "memory-off", { impl: [60000] }), ...slice(7, "lane", { impl: [25000] })].map((e) => JSON.stringify(e)).join("\n"));
730
+ const last = readLocalRuns(dir);
731
+ assert.equal(last.length, 1); assert.equal(last[0].laneProfile, "lane");
732
+ const all = readLocalRuns(dir, { allSlices: true });
733
+ assert.deepEqual(all.map((r) => [r.issue, r.laneProfile, r.baselines.implement[0]]), [[7, "memory-off", 60000], [7, "lane", 25000]]);
734
+ } finally { rmSync(dir, { recursive: true, force: true }); }
735
+ });
736
+
737
+ it("compares baseline and conformance per profile on a pinned issue set", () => {
738
+ const runs = [
739
+ slice(1, "memory-off", { impl: [60000, 70000] }), slice(2, "memory-off", { impl: [50000], shadow: "conformance.scope" }),
740
+ slice(1, "lane", { impl: [20000, 30000], review: 10000 }), slice(2, "lane", { impl: [24000], review: 12000, triggers: ["conformance"] }),
741
+ slice(3, "lane", { impl: [99999] }), [{ event: "RUN_START", issue: 4 }, use("implement", null, null), { event: "RUN_TERMINAL", reason: "merged" }],
742
+ [{ event: "RUN_START", issue: 5 }, use("implement", "lane", 1)], // unfinished: left out
743
+ ].map((l) => summariseLog(l));
744
+ const all = laneProfileReport(runs);
745
+ assert.deepEqual(all.profiles.map((p) => [p.profile, p.n]), [["memory-off", 2], ["lane", 3], ["unknown", 1]]);
746
+ assert.deepEqual(all.issues_in_all, []);
747
+ const rep = laneProfileReport(runs, { issues: [1, 2] });
748
+ assert.deepEqual(rep.profiles.map((p) => p.profile), ["memory-off", "lane"]);
749
+ const [before, after] = rep.profiles;
750
+ assert.deepEqual(before.phases, [{ phase: "implement", sessions: 3, median: 60000, mean: 60000 }, { phase: "review", sessions: 2, median: 20000, mean: 20000 }]);
751
+ assert.deepEqual(after.phases[0], { phase: "implement", sessions: 3, median: 24000, mean: 24667 });
752
+ assert.deepEqual([before.reviewed, before.conformance_rate, before.shadow_blocks], [2, 0.5, 1]);
753
+ assert.deepEqual([after.reviewed, after.conformance_rate, after.shadow_blocks], [2, 0.5, 0]);
754
+ assert.deepEqual(rep.issues_in_all, [1, 2]);
755
+ const md = renderLaneProfiles(rep);
756
+ assert.match(md, /\| lane \| implement \| 3 \| 24000 \| 24667 \|/);
757
+ assert.match(md, /\| memory-off \| 2 \| 0\.5 \(2\) \| 1 \|/);
758
+ assert.match(md, /every profile: #1, #2/);
759
+ assert.match(renderLaneProfiles(laneProfileReport([])), /\| none \|/);
760
+ });
761
+
762
+ it("--lane-profile reads run logs only (no GitHub crawl); --issues takes a comma list", async () => {
763
+ assert.equal(parseArgs(["--lane-profile"]).laneProfile, true); assert.equal(parseArgs([]).laneProfile, false);
764
+ assert.deepEqual(parseArgs(["--issues", "4,12"]).issues, [4, 12]); assert.deepEqual(parseArgs(["--issues=7"]).issues, [7]);
765
+ assert.throws(() => parseArgs(["--issues", "4,x"]), /--issues must be a comma list/);
766
+ const rep = await buildReport({ repo: "o/r", laneProfile: true, issues: [1] }, { localRuns: [summariseLog(slice(1, "lane")), summariseLog(slice(2, "lane"))],
767
+ releaseTimes: [], gh: async () => { throw new Error("no network"); } });
768
+ assert.deepEqual(rep.query.lane_profile.profiles.map((p) => [p.profile, p.n]), [["lane", 1]]);
769
+ });
770
+ });
771
+
772
+ describe("rework by trigger and first-pass rate (#4362)", () => {
773
+ const use = (attempt, trigger, usd, phase = "implement") => ({ event: "PHASE_ATTEMPT_USAGE", phase, attempt, ...(trigger ? { trigger } : {}), cost_usd: usd, usage: { cache_read_input_tokens: 10, output_tokens: 1 } });
774
+ const log = (issue, attempts) => [{ event: "RUN_START", issue, repo: "o/r", run: `r${issue}`, version: "1.30.0", ts: "2026-10-20T00:00:00Z" }, ...attempts,
775
+ { event: "RUN_TERMINAL", reason: "merged", ts: "2026-10-20T01:00:00Z" }];
776
+ const runs = [
777
+ log(1, [use(1, "plan", 1), use(2, "build", 3)]),
778
+ log(2, [use(1, "plan", 1), use(2, "build", 3), use(3, "precheck", 2), use(4, "review", 4), use(1, "review", 0.2, "review")]),
779
+ log(3, [use(1, "plan", 1), use(2, "build", 3), use(3, "timeout_restart", 1), use(4, "recovery", 1)]),
780
+ log(4, [use(1, null, 1), use(2, null, 3), use(3, null, 5)]), // written before the field existed
781
+ log(5, [use(1, "plan", 1)]), // closed at the plan: never reached the build
782
+ ].map((l) => summariseLog(l));
783
+
784
+ it("summariseLog keeps builder attempts in order with their trigger (null when untagged)", () => {
785
+ assert.deepEqual(runs[1].builderAttempts.map((a) => a.trigger), ["plan", "build", "precheck", "review"]);
786
+ assert.deepEqual(runs[3].builderAttempts.map((a) => [a.attempt, a.trigger, a.cost_usd]), [[1, null, 1], [2, null, 3], [3, null, 5]]);
787
+ });
788
+
789
+ it("aggregate counts rework by trigger (untagged as unknown, never dropped) and the first-pass rate with counts", () => {
790
+ const g = aggregate({ runs }).groups[0];
791
+ assert.deepEqual(g.rework.byTrigger, { precheck: { n: 1, usd: 2 }, review: { n: 1, usd: 4 }, unknown: { n: 3, usd: 9 } });
792
+ assert.deepEqual([g.rework.n, g.rework.usd], [5, 15]);
793
+ assert.deepEqual(g.rework.attempts.build, { n: 3, usd: 9 });
794
+ assert.deepEqual(g.firstPass, { rate: 2 / 3, firstPass: 2, n: 3, unknown: 1 });
795
+ const md = renderMarkdown(aggregate({ runs }));
796
+ assert.match(md, /first-pass rate \| 67% \(2\/3 lanes that reached the build, 1 unknown\)/);
797
+ assert.match(md, /### Rework by trigger/);
798
+ assert.match(md, /\| unknown \| 3 \| \$9\.00 \|/);
799
+ assert.match(md, /\| total \| 5 \| \$15\.00 \|/);
800
+ });
801
+
802
+ it("GitHub phase_attempt records carry the trigger into builderAttempts, ordered by attempt", () => {
803
+ const rec = (attempt, trigger, ts) => ({ event: "phase_attempt", repo: "o/r", issue: 9, run: "r_9", phase: "implement", attempt, ...(trigger ? { trigger } : {}), cost_usd: 1, output_tokens: 1, ts });
804
+ const [run] = runsFromRecords([rec(3, "ci_red", "t3"), rec(1, "plan", "t1"), rec(2, "build", "t2"), rec(2, "timeout_restart", "t2b"), rec(4, null, "t4"),
805
+ { event: "run_terminal", repo: "o/r", issue: 9, run: "r_9", reason: "merged", ts: "t5" }]);
806
+ assert.deepEqual(run.builderAttempts.map((a) => [a.attempt, a.trigger]), [[1, "plan"], [2, "build"], [2, "timeout_restart"], [3, "ci_red"], [4, null]]);
807
+ });
808
+ });
809
+
810
+ describe("per-attempt cost deltas (#4407)", () => {
811
+ const use = (attempt, usd, delta, phase = "implement") => ({ event: "PHASE_ATTEMPT_USAGE", phase, attempt, trigger: attempt === 1 ? "plan" : "build", cost_usd: usd,
812
+ ...(delta === undefined ? {} : { cost_usd_delta: delta, cost_usd_session: usd }), usage: { cache_read_input_tokens: 10, output_tokens: 1 } });
813
+ const log = (attempts) => [{ event: "RUN_START", issue: 1, repo: "o/r", run: "r1", version: "1.30.0", ts: "2026-10-20T00:00:00Z" }, ...attempts,
814
+ { event: "RUN_TERMINAL", reason: "merged", ts: "2026-10-20T01:00:00Z" }];
815
+
816
+ it("a local run sums cost_usd_delta when present, else cost_usd (old logs)", () => {
817
+ const r = summariseLog(log([use(1, 2, 2), use(2, 5, 3), use(1, 0.5, 0.5, "review")]));
818
+ assert.equal(r.costUsd, 5.5);
819
+ assert.equal(r.phases.implement.usd, 5);
820
+ assert.deepEqual(r.builderAttempts.map((a) => a.cost_usd), [2, 3]);
821
+ const old = summariseLog(log([use(1, 2), use(2, 5)]));
822
+ assert.equal(old.costUsd, 7); assert.deepEqual(old.builderAttempts.map((a) => a.cost_usd), [2, 5]);
823
+ });
824
+
825
+ it("GitHub phase_attempt records sum cost_usd_delta when present, else cost_usd", () => {
826
+ const rec = (attempt, usd, delta) => ({ event: "phase_attempt", repo: "o/r", issue: 9, run: "r_9", phase: "implement", attempt, cost_usd: usd, ...(delta === undefined ? {} : { cost_usd_delta: delta }), output_tokens: 1, ts: `t${attempt}` });
827
+ const [run] = runsFromRecords([rec(1, 2, 2), rec(2, 5, 3), rec(3, 4), { event: "run_terminal", repo: "o/r", issue: 9, run: "r_9", reason: "merged", ts: "t9" }]);
828
+ assert.equal(run.phases.implement.usd, 9);
829
+ assert.deepEqual(run.builderAttempts.map((a) => a.cost_usd), [2, 3, 4]);
830
+ });
831
+ });
832
+
833
+ describe("first-pass rate with and without matching pitfalls (#4375)", () => {
834
+ const use = (attempt, trigger) => ({ event: "PHASE_ATTEMPT_USAGE", phase: "implement", attempt, trigger, cost_usd: 1, usage: { cache_read_input_tokens: 10, output_tokens: 1 } });
835
+ const log = (issue, attempts, { term = {}, pack = null } = {}) => [{ event: "RUN_START", issue, repo: "o/r", run: `r${issue}`, version: "1.30.0", ts: "2026-10-20T00:00:00Z" },
836
+ ...(pack ? [{ event: "CONTEXT_PACK", mode: "shadow", pitfalls: pack }] : []), ...attempts, { event: "RUN_TERMINAL", reason: "merged", ts: "2026-10-20T01:00:00Z", ...term }];
837
+ const clean = [use(1, "plan"), use(2, "build")], rework = [...clean, use(3, "precheck")];
838
+
839
+ it("summariseLog reads pitfalls_matched from RUN_TERMINAL, else the CONTEXT_PACK pitfalls, else null", () => {
840
+ assert.equal(summariseLog(log(1, clean, { term: { pitfalls_matched: 2 } })).pitfallsMatched, 2);
841
+ assert.equal(summariseLog(log(1, clean, { pack: [{ sig: "a", match: "files" }, { sig: "b", match: "repo" }] })).pitfallsMatched, 1);
842
+ assert.equal(summariseLog(log(1, clean)).pitfallsMatched, null, "older logs: unknown");
843
+ });
844
+
845
+ it("aggregate splits the first-pass rate by matching pitfalls and leaves unknown runs out; render prints both rows", () => {
846
+ const runs = [
847
+ log(1, clean, { term: { pitfalls_matched: 1 } }), log(2, clean, { term: { pitfalls_matched: 3 } }), log(3, rework, { term: { pitfalls_matched: 1 } }),
848
+ log(4, rework, { term: { pitfalls_matched: 0 } }), log(5, clean, { term: { pitfalls_matched: 0 } }), log(6, rework, { term: { pitfalls_matched: 0 } }),
849
+ log(7, clean), // no pitfall field: unknown
850
+ ].map((l) => summariseLog(l));
851
+ const g = aggregate({ runs }).groups[0];
852
+ assert.deepEqual(g.firstPassByPitfalls, { with: { rate: 2 / 3, firstPass: 2, n: 3 }, without: { rate: 1 / 3, firstPass: 1, n: 3 } });
853
+ assert.equal(g.firstPass.n, 7, "the overall rate still counts every lane");
854
+ const md = renderMarkdown(aggregate({ runs }));
855
+ assert.match(md, /first-pass with matching pitfalls \| 67% \(2\/3\)/);
856
+ assert.match(md, /first-pass without matching pitfalls \| 33% \(1\/3\)/);
857
+ const none = renderMarkdown(aggregate({ runs: [log(8, clean)].map((l) => summariseLog(l)) }));
858
+ assert.ok(!/matching pitfalls/.test(none), "no pitfall data: no rows");
859
+ });
860
+ });
861
+
862
+ describe("fail-soft alerts (#4354)", () => {
863
+ const ts = (i) => new Date(Date.UTC(2026, 9, 1) + i * 60000).toISOString();
864
+ // One lane per call; `extra` are the events after RUN_START.
865
+ const lane = (i, extra = [], { repo = "o/r", code = "completed", reason = "merged" } = {}) => [
866
+ { event: "RUN_START", ts: ts(i), repo, issue: 100 + i }, ...extra, ...(code ? [{ event: "RUN_TERMINAL", reason, code }] : [])];
867
+ const deps = (eco, status, why = null) => ({ event: "DEPS_PREPARED", eco, status, reason: why });
868
+ const lanes = (n, f, o) => Array.from({ length: n }, (_, i) => lanesOfLog(lane(i, f(i), typeof o === "function" ? o(i) : o))[0]);
869
+
870
+ it("DEPS_PREPARED failed above 20% per ecosystem, with rate, sample and an example reason; healthy ecosystems stay quiet", () => {
871
+ const l = lanes(10, (i) => [deps("pnpm", i < 3 ? "failed" : "hit", "tool not found: spawn pnpm ENOENT"), deps("uv", "hit")]);
872
+ const a = computeAlerts(l, { repo: "o/r" });
873
+ assert.deepEqual(a.map((x) => [x.event, x.count, x.sample]), [["DEPS_PREPARED failed (pnpm)", 3, 10]]);
874
+ assert.equal(a[0].example, "tool not found: spawn pnpm ENOENT");
875
+ assert.deepEqual(computeAlerts(lanes(10, (i) => [deps("uv", i < 2 ? "failed" : "hit")]), { repo: "o/r" }), [], "exactly 20% is not above");
876
+ const line = renderAlerts(a)[0];
877
+ assert.match(line, /^DEPS_PREPARED failed \(pnpm\): 30% \(3\/10 lanes, threshold 20%\), e\.g\. "tool not found: spawn pnpm ENOENT"$/);
878
+ });
879
+
880
+ it("deferred-to-ci above 30% of lanes and a TOOLCHAIN_WARNING repeated on more than 50% of lanes", () => {
881
+ const pre = (deferred) => ({ event: "PRECHECK_COMMANDS", round: 0, results: [{ command: "npm test", rc: 0, ms: 5 }, ...(deferred ? [{ command: "npx tsc", status: "deferred-to-ci" }] : [])] });
882
+ const warn = { event: "TOOLCHAIN_WARNING", warning: "host node v26 does not satisfy the .nvmrc pin \"22\"" };
883
+ const a = computeAlerts(lanes(10, (i) => [pre(i < 4), ...(i < 6 ? [warn] : [])]), { repo: "o/r" });
884
+ assert.deepEqual(a.map((x) => [x.event, x.count, x.sample, x.example]), [["deferred-to-ci", 4, 10, "npx tsc"], ["TOOLCHAIN_WARNING", 6, 10, warn.warning]]);
885
+ assert.deepEqual(computeAlerts(lanes(10, (i) => [pre(i < 3), ...(i < 5 ? [warn] : [])]), { repo: "o/r" }), [], "30% and 50% are not above");
886
+ });
887
+
888
+ it("an outcome code whose share doubled against the previous window; needs a full previous window; completed never alerts", () => {
889
+ // 20 lanes, window 10: previous window has 1 ci.red (10%), current has 3 (30%)
890
+ const code = (i) => ({ code: [0, 12, 15, 18].includes(i) ? "ci.red" : "completed", reason: [0, 12, 15, 18].includes(i) ? "needs-human" : "merged" });
891
+ const l = lanes(20, () => [], code);
892
+ const a = computeAlerts(l, { repo: "o/r", window: 10 });
893
+ assert.deepEqual(a.map((x) => [x.event, x.count, x.sample, x.prevRate, x.example]), [["outcome ci.red", 3, 10, 0.1, "#118 needs-human"]]);
894
+ assert.match(renderAlerts(a)[0], /^outcome ci\.red: 30% \(3\/10 lanes, previous window 10%, threshold 2x\), e\.g\. "#118 needs-human"$/);
895
+ assert.deepEqual(computeAlerts(l.slice(5), { repo: "o/r", window: 10 }), [], "a previous window under min_lanes never compares");
896
+ assert.deepEqual(computeAlerts(lanes(20, () => [], (i) => ({ code: i < 15 ? "invalid" : "completed" })), { repo: "o/r", window: 10 }), []);
897
+ });
898
+
899
+ it("rolling window per repo, ordered by start: old failures and other repos fall out; under 10 lanes or no logs gives nothing", () => {
900
+ const bad = lanes(10, () => [deps("pnpm", "failed", "x")]);
901
+ const good = lanes(50, () => [deps("pnpm", "hit")]).map((l, i) => ({ ...l, startTs: ts(100 + i) }));
902
+ assert.deepEqual(computeAlerts([...good, ...bad], { repo: "o/r" }), [], "the 50 newest lanes are healthy");
903
+ assert.equal(computeAlerts(bad, { repo: "o/r" }).length, 1);
904
+ assert.deepEqual(computeAlerts(bad, { repo: "x/y" }), []);
905
+ assert.deepEqual(computeAlerts(bad.slice(1), { repo: "o/r" }), [], "9 lanes is too few");
906
+ assert.deepEqual(computeAlerts([], { repo: "o/r" }), []);
907
+ assert.deepEqual(readLanes(join(td, "missing")), []);
908
+ });
909
+
910
+ it("readLanes splits every RUN_START slice of every log (local files only) and keeps the repo", () => {
911
+ const dir = join(td, "runs"); mkdirSync(dir);
912
+ const text = (evs) => evs.map((e) => JSON.stringify(e)).join("\n") + "\n";
913
+ writeFileSync(join(dir, "7.jsonl"), text([...lane(0, [deps("cargo", "failed", "lock drift")]), ...lane(1, [{ event: "WORKTREE_SPARSE_FALLBACK", reason: "no cone" }], { code: null })]) + "{torn");
914
+ writeFileSync(join(dir, "8.jsonl"), text(lane(2, [], { repo: "a/b" })));
915
+ const got = readLanes(dir);
916
+ assert.deepEqual(got.map((l) => [l.repo, l.issue, l.code, l.deps.length, l.sparseFallback]),
917
+ [["o/r", 100, "completed", 1, null], ["o/r", 101, null, 0, "no cone"], ["a/b", 102, "completed", 0, null]]);
918
+ });
919
+
920
+ it("thresholds come from forge.yaml stats.alerts.* (nested block and flat form) and change the result", () => {
921
+ assert.deepEqual(readAlertConfig(join(td, "none")), DEFAULT_ALERTS);
922
+ writeFileSync(join(td, "forge.yaml"), "stats:\n alerts:\n min_lanes: 5\n deps_failed_pct: 50\n alerts_window: 8\n window: 30\n deferred_to_ci_pct: nope\n");
923
+ const c = readAlertConfig(td);
924
+ assert.deepEqual(c, { ...DEFAULT_ALERTS, minLanes: 5, depsFailedPct: 50, window: 8 });
925
+ const l = lanes(6, (i) => [deps("pnpm", i < 2 ? "failed" : "hit", "x")]);
926
+ assert.deepEqual(computeAlerts(l, { repo: "o/r" }), [], "default min_lanes 10");
927
+ assert.deepEqual(computeAlerts(l, { repo: "o/r", ...c }), [], "33% is under the configured 50%");
928
+ assert.equal(computeAlerts(l, { repo: "o/r", ...c, depsFailedPct: 20 }).length, 1);
929
+ });
930
+
931
+ it("stats report carries alerts and renders an ALERTS section only when one fired; --no-local skips them; no gh call for alerts", async () => {
932
+ const { gh } = fakeGh();
933
+ const localLanes = lanes(10, () => [deps("pnpm", "failed", "spawn pnpm ENOENT")]);
934
+ const rep = await buildReport({ repo: "o/r", local: true, cache: false, minN: 10 }, { gh, env: { ...ENV, FORGE_RUNS_DIR: join(td, "none") }, releaseTimes: [], localRuns: [], localLanes, alertConfig: {} });
935
+ assert.equal(rep.alerts.length, 1);
936
+ assert.match(renderMarkdown(rep), /## ALERTS\n\n- DEPS_PREPARED failed \(pnpm\): 100% \(10\/10 lanes/);
937
+ assert.equal(JSON.parse(renderJson(rep)).alerts[0].event, "DEPS_PREPARED failed (pnpm)");
938
+ const off = await buildReport({ repo: "o/r", local: false, cache: false, minN: 10 }, { gh, env: ENV, releaseTimes: [], localLanes });
939
+ assert.deepEqual(off.alerts, []);
940
+ assert.doesNotMatch(renderMarkdown(off), /ALERTS/);
941
+ // computed from local run logs alone: no gh in scope at all
942
+ assert.equal(computeAlerts(localLanes, { repo: "o/r" }).length, 1);
943
+ });
944
+ });
945
+
946
+ describe("env.tool_missing per release (forge#4414)", () => {
947
+ // The #4393 replay criterion is read as outcome-code counts per engine version; pin that the report surfaces them.
948
+ const run = (issue, version, reason, code) => ({ issue, repo: "o/r", version, reason, code, finished: true, usageEvents: 1, endTs: "2026-10-23T10:00:00Z", phases: { implement: { cr: 1, out: 1, ms: 1 } } });
949
+ const runs = [run(1, "1.30.0", "needs-human", "env.tool_missing"), run(2, "1.30.0", "merged", "completed"),
950
+ run(3, "1.31.0", "merged", "completed"), run(4, "1.31.0", "needs-human", "dependency.open")];
951
+ const codesOf = (g) => Object.fromEntries(g.flow.codes.map((c) => [c.code, c.count]));
952
+
953
+ it("counts env.tool_missing per version and drops pre-release lanes with sinceVersion", () => {
954
+ const all = aggregate({ runs }, { releaseTimes: [] });
955
+ assert.equal(codesOf(all.groups.find((g) => g.version === "1.30.0"))["env.tool_missing"], 1);
956
+ const after = aggregate({ runs }, { releaseTimes: [], sinceVersion: "1.31.0" });
957
+ assert.deepEqual(after.groups.map((g) => g.version), ["1.31.0"]);
958
+ assert.equal(codesOf(after.groups[0])["env.tool_missing"], undefined);
959
+ assert.equal(after.groups[0].flow.codesN, 2);
960
+ });
961
+ });
@@ -100,7 +100,9 @@ remaining maintenance once no feature is pending; otherwise the rest ends `held
100
100
  (live lanes are re-adopted, nothing is launched twice). The runner is hosted by the detached `forgedock schedd` daemon,
101
101
  which resumes every daemon-hosted batch that has no report yet when it starts, so a closed session needs no manual
102
102
  `--resume`; a batch started without the daemon still needs `forgedock orchestrate --resume <id>`. Change K of a running batch with
103
- `forgedock orchestrate --set-parallel <id> K`. The runner requeues `deferred` lanes by code (host busy after its
103
+ `forgedock orchestrate --set-parallel <id> K`. Issues filed while a batch runs join it with
104
+ `forgedock orchestrate --add <id> N...` (or `--resume <id> N...`), under its K and DAG; never start a second batch
105
+ for them. Its pre-filter still closes already-fixed ones. The runner requeues `deferred` lanes by code (host busy after its
104
106
  retry-in, a usage limit holds admissions for that account (backend identity) until the reset, across every batch on
105
107
  the host; `forgedock limits show|clear <id>|--all` lists or drops holds; transient merge/push/idle failures back off; non-host
106
108
  deferrals stop after `orchestration.max_deferrals`), relaunches an `engine-error` once, and stops admitting after
@@ -210,7 +210,7 @@ Append this block at the very end of your comment (after the `---` footer line,
210
210
  8. **HTML comments**: The block is invisible in rendered markdown but parseable by the review system
211
211
  9. **Agent marker**: Include exactly one `<!-- FORGE:REVIEW-AGENT:{domain} -->` marker in the persisted body, where `{domain}` is the marker domain the orchestrator passes you (the canonical value from the roster-to-marker table in `review-pr.md`, e.g. `infra` and `scraper`; never a lowercased display name such as `infrastructure` or `scraping`).
212
212
  10. **Reviewed SHA**: Include exactly one line `Reviewed-SHA: [REVIEW_SHA]` (the full 40-hex head you were pinned to) in the persisted body, outside the marker. `/review-pr` scopes its completion wait and idempotent re-entry to this line, so a comment without the current head does not count as a completed review. Do NOT put the SHA inside the `<!-- FORGE:REVIEW-AGENT:{domain} -->` marker.
213
- 11. **Failure scenario and scope (provenance inputs)**: every finding's body section (the prose above the findings block) MUST carry a `**Failure scenario**` line — concrete inputs leading to a wrong output or behaviour — and a `**Scope**: changed` or `**Scope**: pre-existing` line (`changed` = the cited line is added or modified by this PR; `pre-existing` = it was already there). `/review-pr` §6B.4 reads these: a finding without a failure scenario is demoted to a note (unless CRITICAL/HIGH, any confidence), and a MEDIUM or lower finding whose cited line is not in the PR diff is routed as pre-existing (CRITICAL/HIGH never are; see §5). The `**Scope**` tag is informational and never gates routing. Speculative hardening with no failure scenario is a note, not a finding. The one-line `<!-- FINDING:... -->` grammar above is unchanged. <!-- Added: forge#3452 -->
213
+ 11. **Failure scenario and scope (provenance inputs)**: every finding's body section (the prose above the findings block) MUST carry a `**Failure scenario**` line — concrete inputs leading to a wrong output or behaviour — and a `**Scope**: changed` or `**Scope**: pre-existing` line (`changed` = the cited line is added or modified by this PR; `pre-existing` = it was already there). `/review-pr` §6B.4 reads these: a finding without a failure scenario is demoted to a note (unless CRITICAL/HIGH, any confidence, or CONFIRMED MEDIUM; a plain `Failure scenario:`, `Scenario:`, `Repro:` or `Steps to reproduce:` line also counts as the scenario), and a MEDIUM or lower finding whose cited line is not in the PR diff is routed as pre-existing (CRITICAL/HIGH never are; see §5). The `**Scope**` tag is informational and never gates routing. Speculative hardening with no failure scenario is a note, not a finding. The one-line `<!-- FINDING:... -->` grammar above is unchanged. <!-- Added: forge#3452 -->
214
214
  12. **Fix efficacy (linked-issue contradiction)**: when a finding says the PR's linked issue is NOT fixed, or that the PR re-introduces the defect it claims to fix, its body section MUST carry a `**Fix efficacy**: fails` (the fix does not work) or `**Fix efficacy**: regresses` (the PR re-introduces the defect) line, and the prose may include the token on its own line (`FIX_EFFICACY: fails|regresses`; line-anchored, a mid-sentence mention does not count). Opt-in on the tag, never on keywords. `/review-pr` §6B.5 passes it to `scripts/classify-finding.sh --fix-efficacy`, which exempts a MEDIUM finding from the fix-PR damper. Use it only for the linked issue's own fix; never for unrelated defects. The one-line `<!-- FINDING:... -->` grammar is unchanged. <!-- Added: forge#3693 -->
215
215
 
216
216
  ### Domain Prefixes