forgedock 1.30.0 → 1.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/engine/outcome-codes.mjs +3 -2
- package/bin/engine/runlog.mjs +82 -12
- package/bin/engine/telemetry-seam.mjs +11 -4
- package/bin/engine/telemetry-seam.test.mjs +18 -0
- package/bin/engine-cli.mjs +362 -33
- package/bin/engine.mjs +47 -37
- package/bin/forgedock.mjs +35 -3
- package/bin/gc.mjs +50 -17
- package/bin/labels.json +5 -0
- package/bin/lean/apishape.mjs +170 -0
- package/bin/lean/apishape.test.mjs +187 -0
- package/bin/lean/batch.mjs +32 -0
- package/bin/lean/batch.test.mjs +294 -1
- package/bin/lean/budget.mjs +89 -0
- package/bin/lean/budget.test.mjs +106 -0
- package/bin/lean/checks.mjs +32 -9
- package/bin/lean/checks.test.mjs +78 -1
- package/bin/lean/conformance.e2e.test.mjs +27 -5
- package/bin/lean/conformance.mjs +150 -15
- package/bin/lean/conformance.test.mjs +175 -3
- package/bin/lean/context.mjs +116 -43
- package/bin/lean/context.test.mjs +139 -4
- package/bin/lean/decompose.mjs +90 -0
- package/bin/lean/decompose.test.mjs +105 -0
- package/bin/lean/depcache.mjs +11 -7
- package/bin/lean/depcache.test.mjs +81 -0
- package/bin/lean/e2e.mjs +102 -0
- package/bin/lean/e2e.test.mjs +86 -0
- package/bin/lean/effects.mjs +37 -15
- package/bin/lean/explore.mjs +47 -0
- package/bin/lean/explore.test.mjs +61 -0
- package/bin/lean/facts.mjs +84 -0
- package/bin/lean/facts.test.mjs +66 -0
- package/bin/lean/fixround.mjs +90 -0
- package/bin/lean/fixround.test.mjs +117 -0
- package/bin/lean/heavy.mjs +35 -2
- package/bin/lean/heavy.test.mjs +32 -2
- package/bin/lean/lean.mjs +650 -124
- package/bin/lean/lean.test.mjs +1450 -37
- package/bin/lean/ledger.mjs +5 -3
- package/bin/lean/ledger.test.mjs +18 -0
- package/bin/lean/light.mjs +135 -0
- package/bin/lean/light.test.mjs +173 -0
- package/bin/lean/limits.test.mjs +36 -1
- package/bin/lean/orchestrate.mjs +261 -38
- package/bin/lean/pitfalls.mjs +136 -0
- package/bin/lean/pitfalls.test.mjs +117 -0
- package/bin/lean/postmerge.mjs +85 -0
- package/bin/lean/postmerge.test.mjs +79 -0
- package/bin/lean/precheck.mjs +138 -13
- package/bin/lean/precheck.test.mjs +113 -1
- package/bin/lean/prefilter.mjs +159 -5
- package/bin/lean/prefilter.test.mjs +152 -3
- package/bin/lean/preflight.mjs +108 -3
- package/bin/lean/preflight.test.mjs +48 -1
- package/bin/lean/probe.mjs +297 -0
- package/bin/lean/probe.test.mjs +210 -0
- package/bin/lean/prompts.mjs +101 -13
- package/bin/lean/render.mjs +7 -3
- package/bin/lean/resume.mjs +67 -0
- package/bin/lean/resume.test.mjs +84 -0
- package/bin/lean/review-trigger.mjs +12 -0
- package/bin/lean/review-trigger.test.mjs +17 -1
- package/bin/lean/routing.mjs +74 -0
- package/bin/lean/routing.test.mjs +67 -0
- package/bin/lean/sched.test.mjs +127 -1
- package/bin/lean/schedd.mjs +55 -9
- package/bin/lean/schedd.test.mjs +149 -3
- package/bin/lean/selfreview.mjs +37 -0
- package/bin/lean/selfreview.test.mjs +51 -0
- package/bin/lean/seqlease.mjs +40 -0
- package/bin/lean/seqlease.test.mjs +101 -0
- package/bin/lean/sequence.mjs +296 -13
- package/bin/lean/sequence.test.mjs +263 -2
- package/bin/lean/toolchain.mjs +60 -15
- package/bin/lean/toolchain.test.mjs +120 -3
- package/bin/lean/toolstats.mjs +72 -0
- package/bin/lean/toolstats.test.mjs +47 -0
- package/bin/lean/verifier.mjs +232 -0
- package/bin/lean/verifier.test.mjs +163 -0
- package/bin/lean/verify.mjs +72 -10
- package/bin/lean/verify.test.mjs +68 -3
- package/bin/review-staging.mjs +533 -0
- package/bin/runner.mjs +82 -13
- package/bin/stats/aggregate.mjs +87 -2
- package/bin/stats/efficiency.mjs +319 -0
- package/bin/stats/efficiency.test.mjs +233 -0
- package/bin/stats/github-source.mjs +8 -3
- package/bin/stats/index.mjs +69 -6
- package/bin/stats/local-source.mjs +86 -3
- package/bin/stats/query.mjs +48 -0
- package/bin/stats/render.mjs +22 -0
- package/bin/stats/stats.test.mjs +224 -5
- package/commands/orchestrate.md +3 -1
- package/commands/review-pr-agents/protocols.md +1 -1
- package/commands/review-pr-staging.md +84 -831
- package/commands/review-pr.md +1 -1
- package/commands/work-on/build/implement.md +1 -0
- package/commands/work-on/build/validate.md +11 -1
- package/docs/CONFIG.md +80 -5
- package/docs/OUTCOME-CODES.md +2 -1
- package/docs/STATS.md +53 -1
- package/docs/TELEMETRY.md +1 -1
- package/docs/spec/review-protocol.md +1 -1
- package/package.json +1 -1
- package/scripts/ci-local/lib.mjs +20 -0
- package/scripts/ci-local/plan.mjs +2 -12
- package/scripts/ci-local/scoping.mjs +10 -1
- package/scripts/ci-local.test.sh +2 -0
- package/scripts/fixtures/apishape/other/go-change/base/x.go +3 -0
- package/scripts/fixtures/apishape/other/go-change/head/x.go +3 -0
- package/scripts/fixtures/apishape/python/generator-function/base/seq.py +3 -0
- package/scripts/fixtures/apishape/python/generator-function/head/seq.py +2 -0
- package/scripts/fixtures/apishape/python/ichunked/base/more.py +17 -0
- package/scripts/fixtures/apishape/python/ichunked/head/more.py +20 -0
- package/scripts/fixtures/apishape/python/private-edit/base/calc.py +6 -0
- package/scripts/fixtures/apishape/python/private-edit/head/calc.py +9 -0
- package/scripts/fixtures/apishape/python/removed/base/ops.py +6 -0
- package/scripts/fixtures/apishape/python/removed/head/ops.py +2 -0
- package/scripts/fixtures/apishape/python/renamed/base/text.py +2 -0
- package/scripts/fixtures/apishape/python/renamed/head/text.py +2 -0
- package/scripts/fixtures/apishape/python/signature/base/shapes.py +2 -0
- package/scripts/fixtures/apishape/python/signature/head/shapes.py +2 -0
- package/scripts/fixtures/apishape/python/sync-async/base/client.py +7 -0
- package/scripts/fixtures/apishape/python/sync-async/head/client.py +7 -0
- package/scripts/fixtures/apishape/ts/generator-function/base/seq.js +3 -0
- package/scripts/fixtures/apishape/ts/generator-function/head/seq.js +5 -0
- package/scripts/fixtures/apishape/ts/private-edit/base/calc.ts +7 -0
- package/scripts/fixtures/apishape/ts/private-edit/head/calc.ts +9 -0
- package/scripts/fixtures/apishape/ts/removed/base/ops.ts +7 -0
- package/scripts/fixtures/apishape/ts/removed/head/ops.ts +3 -0
- package/scripts/fixtures/apishape/ts/renamed/base/text.ts +3 -0
- package/scripts/fixtures/apishape/ts/renamed/head/text.ts +3 -0
- package/scripts/fixtures/apishape/ts/signature/base/shapes.ts +3 -0
- package/scripts/fixtures/apishape/ts/signature/head/shapes.ts +3 -0
- package/scripts/fixtures/apishape/ts/sync-async/base/client.mjs +5 -0
- package/scripts/fixtures/apishape/ts/sync-async/head/client.mjs +5 -0
- package/scripts/forge-root.test.sh +2 -2
- package/scripts/publish-staging-sync.test.sh +62 -4
- package/scripts/review-finalize.sh +17 -6
- package/scripts/review-finalize.test.sh +47 -0
- package/scripts/review-pr-arguments.test.sh +13 -1
- package/scripts/review-pr-delta-wiring.test.sh +2 -2
- package/scripts/review-pr-gate2.test.sh +6 -14
- package/scripts/review-pr-staging-finding-filter.test.sh +4 -4
- package/scripts/review-pr-staging-scope.test.sh +39 -33
- package/scripts/review-prepare.sh +73 -6
- package/scripts/review-prepare.test.sh +85 -0
- package/scripts/review-staging-post.sh +293 -0
- package/scripts/review-staging-post.test.sh +143 -0
- package/scripts/review-staging-pre.sh +401 -0
- package/scripts/review-staging-pre.test.sh +150 -0
- package/scripts/symbol-index.mjs +26 -7
- package/scripts/symbol-index.py +55 -9
- package/scripts/trusted-comments.test.sh +1 -1
package/bin/stats/render.mjs
CHANGED
|
@@ -41,7 +41,16 @@ function sections(g) {
|
|
|
41
41
|
out.push("### Flow", ...table(["metric", "value"], [
|
|
42
42
|
["merged", pct(f.mergedPct)], ["needs-human", pct(f.needsHumanPct)], ["remediation rate", pct(f.remediationPct)],
|
|
43
43
|
["top outcome codes (n=" + f.codesN + ")", f.codes.slice(0, 5).map((x) => `${x.code} ${x.count} (${pct(x.pct)})`).join(", ") || NA],
|
|
44
|
+
...(g.firstPass ? [["first-pass rate", `${pct(g.firstPass.rate)} (${g.firstPass.firstPass}/${g.firstPass.n} lanes that reached the build, ${g.firstPass.unknown} unknown)`]] : []),
|
|
45
|
+
// #4375: the same rate split by whether the context pack carried pitfalls matching the issue's files
|
|
46
|
+
...(g.firstPassByPitfalls && (g.firstPassByPitfalls.with.n || g.firstPassByPitfalls.without.n)
|
|
47
|
+
? [["with", "without"].map((k) => [`first-pass ${k} matching pitfalls`, `${pct(g.firstPassByPitfalls[k].rate)} (${g.firstPassByPitfalls[k].firstPass}/${g.firstPassByPitfalls[k].n})`])].flat() : []),
|
|
44
48
|
]), "");
|
|
49
|
+
if (g.rework) { // #4362: builder rework rounds by trigger; untagged (older) attempts are "unknown"
|
|
50
|
+
const w = g.rework, rows = Object.entries(w.byTrigger).sort((a, b) => b[1].usd - a[1].usd || a[0].localeCompare(b[0]));
|
|
51
|
+
out.push("### Rework by trigger", ...(rows.length ? table(["trigger", "count", "cost"], [...rows.map(([t, d]) => [t, String(d.n), usd(d.usd)]), ["total", String(w.n), usd(w.usd)]])
|
|
52
|
+
: ["No rework attempts."]), "");
|
|
53
|
+
}
|
|
45
54
|
const r = g.funnel;
|
|
46
55
|
const sev = Object.entries(r.bySeverity).map(([s, d]) => [s, String(d.total), f1(d.perReview), String(d.ISSUE), String(d.NOTE), String(d.INPR_FIX)]);
|
|
47
56
|
out.push("### Review funnel", `${r.reviews} reviews, ${r.findings} findings (${f1(r.perReview)} per review). Validated issues: ${r.validated}, false positives: ${r.falsePositive}.`, "",
|
|
@@ -68,6 +77,7 @@ export function renderMarkdown(report) {
|
|
|
68
77
|
const out = ["# ForgeDock stats", ""];
|
|
69
78
|
if (!report.groups.length) out.push("No data: no finished runs, telemetry records or issues were found.", "");
|
|
70
79
|
for (const g of report.groups) out.push(...sections(g));
|
|
80
|
+
if (report.alerts?.length) out.push("## ALERTS", "", ...renderAlerts(report.alerts).map((l) => `- ${l}`), "");
|
|
71
81
|
const e = report.excluded;
|
|
72
82
|
out.push(`Finished runs only. Excluded: ${e.unfinished} unfinished, ${e.noUsage} without usage data (hydrated or empty). "~" = version estimated from release times; "unknown" = no version available. Groups with n < ${report.minN} are flagged low-n.`);
|
|
73
83
|
return out.join("\n");
|
|
@@ -80,3 +90,15 @@ export function renderCompact(report) {
|
|
|
80
90
|
pct(g.flow.remediationPct), dur(g.time.wall.p50), f1(g.funnel.perReview), pct(g.gates.acceptance.rate), g.delta ? signed(g.delta.cacheReadMean, k) : NA, g.lowN ? `low-n (<${g.minN})` : "ok"]);
|
|
81
91
|
return table(["Version", "Repo", "n", "Cache-read/issue", "Merged", "Needs-human", "Remediated", "Wall p50", "Findings/review", "Acceptance block", "Delta tokens", "Confidence"], rows).join("\n");
|
|
82
92
|
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* forge#4354: one line per fail-soft alert: event, rate, count/sample lanes, threshold and one example reason. Outcome-code
|
|
96
|
+
* alerts carry the previous-window share and a factor threshold instead of a percentage.
|
|
97
|
+
*/
|
|
98
|
+
export function renderAlerts(alerts) {
|
|
99
|
+
return (alerts ?? []).map((a) => {
|
|
100
|
+
const limit = isNum(a.prevRate) ? `previous window ${pct(a.prevRate)}, threshold ${a.threshold}x` : `threshold ${pct(a.threshold)}`;
|
|
101
|
+
const ex = a.example ? `, e.g. ${JSON.stringify(String(a.example).slice(0, 200))}` : "";
|
|
102
|
+
return `${a.event}: ${pct(a.rate)} (${a.count}/${a.sample} lanes, ${limit})${ex}`;
|
|
103
|
+
});
|
|
104
|
+
}
|
package/bin/stats/stats.test.mjs
CHANGED
|
@@ -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, readLocalRuns, summariseLog } from "./local-source.mjs";
|
|
16
|
+
import { renderAlerts, renderCompact, renderJson, renderMarkdown } from "./render.mjs";
|
|
17
|
+
import { edgeQuery, parseEdge, verdictMix, contextPackAb, renderContextAb, builderTierReport, renderTiers } from "./query.mjs";
|
|
18
18
|
|
|
19
19
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
20
20
|
const fx = (f) => join(here, "fixtures", f);
|
|
@@ -545,6 +545,8 @@ describe("context-pack A/B over run logs (forge#4275)", () => {
|
|
|
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", () => {
|
|
@@ -564,3 +566,220 @@ 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("builder model tiers over run logs (forge#4394)", () => {
|
|
571
|
+
const use = (phase, trigger, usd = 1) => ({ event: "PHASE_ATTEMPT_USAGE", phase, trigger, cost_usd: usd, usage: { cache_read_input_tokens: 1, output_tokens: 1 } });
|
|
572
|
+
const slice = (issue, { tier = "mid", cls = "small", escalated = false, reason = "merged", triggers = ["plan", "build"], review = true, shadow } = {}) => [
|
|
573
|
+
{ event: "RUN_START", issue, repo: "o/r", run: `r${issue}` },
|
|
574
|
+
...triggers.map((t) => use("implement", t)), ...(review ? [use("review", "review", 0.5)] : []),
|
|
575
|
+
{ 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" },
|
|
576
|
+
];
|
|
577
|
+
|
|
578
|
+
it("summariseLog reads builderRoute from RUN_TERMINAL, else the BUILDER_ROUTE/BUILDER_ESCALATED events; null before routing", () => {
|
|
579
|
+
const s = summariseLog(slice(1, { escalated: true, triggers: ["plan", "build", "conformance"], shadow: "conformance.scope" }));
|
|
580
|
+
assert.deepEqual(s.builderRoute, { tier: "mid", class: "small", escalated: true, model: "claude-sonnet-5-5" });
|
|
581
|
+
assert.equal(s.conformanceRound, true); assert.equal(s.shadowWould, "conformance.scope"); assert.equal(s.reachedReview, true);
|
|
582
|
+
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" }];
|
|
583
|
+
assert.deepEqual(summariseLog(evs).builderRoute, { tier: "mid", class: "docs-config-test", escalated: true, model: "m" });
|
|
584
|
+
const old = summariseLog([{ event: "RUN_START", issue: 3 }, { event: "RUN_TERMINAL", reason: "merged" }]);
|
|
585
|
+
assert.equal(old.builderRoute, null); assert.equal(old.conformanceRound, false); assert.equal(old.shadowWould, null);
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
it("reports merge, conformance and post-merge-rework rates per tier and class; unrouted runs are left out", () => {
|
|
589
|
+
const reworked = [...slice(5, { tier: "top", cls: "large" }), ...slice(5, { tier: "top", cls: "large", reason: "needs-human" })]; // a later run of a merged issue
|
|
590
|
+
const runs = [slice(1), slice(2, { triggers: ["plan", "build", "review"] }), slice(3, { reason: "needs-human", review: false }),
|
|
591
|
+
slice(4, { escalated: true, triggers: ["plan", "build", "precheck"] }), reworked, [{ event: "RUN_START", issue: 9 }, { event: "RUN_TERMINAL", reason: "merged" }]].map((l) => summariseLog(l));
|
|
592
|
+
const rep = builderTierReport(runs);
|
|
593
|
+
assert.equal(rep.unrouted, 1);
|
|
594
|
+
assert.deepEqual(rep.rows.map((g) => [g.tier, g.class]), [["mid", "small"], ["mid->top", "small"], ["top", "large"]]);
|
|
595
|
+
const [mid, esc, top] = rep.rows;
|
|
596
|
+
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]);
|
|
597
|
+
assert.deepEqual([esc.n, esc.merge_rate, esc.conformance_rate], [1, 1, 1]);
|
|
598
|
+
assert.deepEqual([top.n, top.merge_rate, top.merges, top.post_merge_rework_rate], [1, 0, 1, 1]);
|
|
599
|
+
const md = renderTiers(rep);
|
|
600
|
+
assert.match(md, /\| mid \| small \| 3 \| 0\.6667 \| 0\.5 \(2\) \| 0 \(2\) \| 0\.3333 \|/);
|
|
601
|
+
assert.match(md, /\| top \| large \| 1 \| 0 \| .* \| 1 \(1\) \|/);
|
|
602
|
+
assert.match(md, /1 finished run\(s\) without a builder route/);
|
|
603
|
+
assert.match(renderTiers(builderTierReport([])), /\| none \|/);
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
it("--tiers reads run logs only (no GitHub crawl); other reports carry no tiers", async () => {
|
|
607
|
+
assert.equal(parseArgs(["--tiers"]).tiers, true); assert.equal(parseArgs([]).tiers, false);
|
|
608
|
+
const rep = await buildReport({ repo: "o/r", tiers: true }, { localRuns: [summariseLog(slice(1))], releaseTimes: [], gh: async () => { throw new Error("no network"); } });
|
|
609
|
+
assert.equal(rep.query.tiers.rows[0].n, 1);
|
|
610
|
+
});
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
describe("rework by trigger and first-pass rate (#4362)", () => {
|
|
614
|
+
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 } });
|
|
615
|
+
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,
|
|
616
|
+
{ event: "RUN_TERMINAL", reason: "merged", ts: "2026-10-20T01:00:00Z" }];
|
|
617
|
+
const runs = [
|
|
618
|
+
log(1, [use(1, "plan", 1), use(2, "build", 3)]),
|
|
619
|
+
log(2, [use(1, "plan", 1), use(2, "build", 3), use(3, "precheck", 2), use(4, "review", 4), use(1, "review", 0.2, "review")]),
|
|
620
|
+
log(3, [use(1, "plan", 1), use(2, "build", 3), use(3, "timeout_restart", 1), use(4, "recovery", 1)]),
|
|
621
|
+
log(4, [use(1, null, 1), use(2, null, 3), use(3, null, 5)]), // written before the field existed
|
|
622
|
+
log(5, [use(1, "plan", 1)]), // closed at the plan: never reached the build
|
|
623
|
+
].map((l) => summariseLog(l));
|
|
624
|
+
|
|
625
|
+
it("summariseLog keeps builder attempts in order with their trigger (null when untagged)", () => {
|
|
626
|
+
assert.deepEqual(runs[1].builderAttempts.map((a) => a.trigger), ["plan", "build", "precheck", "review"]);
|
|
627
|
+
assert.deepEqual(runs[3].builderAttempts.map((a) => [a.attempt, a.trigger, a.cost_usd]), [[1, null, 1], [2, null, 3], [3, null, 5]]);
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
it("aggregate counts rework by trigger (untagged as unknown, never dropped) and the first-pass rate with counts", () => {
|
|
631
|
+
const g = aggregate({ runs }).groups[0];
|
|
632
|
+
assert.deepEqual(g.rework.byTrigger, { precheck: { n: 1, usd: 2 }, review: { n: 1, usd: 4 }, unknown: { n: 3, usd: 9 } });
|
|
633
|
+
assert.deepEqual([g.rework.n, g.rework.usd], [5, 15]);
|
|
634
|
+
assert.deepEqual(g.rework.attempts.build, { n: 3, usd: 9 });
|
|
635
|
+
assert.deepEqual(g.firstPass, { rate: 2 / 3, firstPass: 2, n: 3, unknown: 1 });
|
|
636
|
+
const md = renderMarkdown(aggregate({ runs }));
|
|
637
|
+
assert.match(md, /first-pass rate \| 67% \(2\/3 lanes that reached the build, 1 unknown\)/);
|
|
638
|
+
assert.match(md, /### Rework by trigger/);
|
|
639
|
+
assert.match(md, /\| unknown \| 3 \| \$9\.00 \|/);
|
|
640
|
+
assert.match(md, /\| total \| 5 \| \$15\.00 \|/);
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
it("GitHub phase_attempt records carry the trigger into builderAttempts, ordered by attempt", () => {
|
|
644
|
+
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 });
|
|
645
|
+
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"),
|
|
646
|
+
{ event: "run_terminal", repo: "o/r", issue: 9, run: "r_9", reason: "merged", ts: "t5" }]);
|
|
647
|
+
assert.deepEqual(run.builderAttempts.map((a) => [a.attempt, a.trigger]), [[1, "plan"], [2, "build"], [2, "timeout_restart"], [3, "ci_red"], [4, null]]);
|
|
648
|
+
});
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
describe("per-attempt cost deltas (#4407)", () => {
|
|
652
|
+
const use = (attempt, usd, delta, phase = "implement") => ({ event: "PHASE_ATTEMPT_USAGE", phase, attempt, trigger: attempt === 1 ? "plan" : "build", cost_usd: usd,
|
|
653
|
+
...(delta === undefined ? {} : { cost_usd_delta: delta, cost_usd_session: usd }), usage: { cache_read_input_tokens: 10, output_tokens: 1 } });
|
|
654
|
+
const log = (attempts) => [{ event: "RUN_START", issue: 1, repo: "o/r", run: "r1", version: "1.30.0", ts: "2026-10-20T00:00:00Z" }, ...attempts,
|
|
655
|
+
{ event: "RUN_TERMINAL", reason: "merged", ts: "2026-10-20T01:00:00Z" }];
|
|
656
|
+
|
|
657
|
+
it("a local run sums cost_usd_delta when present, else cost_usd (old logs)", () => {
|
|
658
|
+
const r = summariseLog(log([use(1, 2, 2), use(2, 5, 3), use(1, 0.5, 0.5, "review")]));
|
|
659
|
+
assert.equal(r.costUsd, 5.5);
|
|
660
|
+
assert.equal(r.phases.implement.usd, 5);
|
|
661
|
+
assert.deepEqual(r.builderAttempts.map((a) => a.cost_usd), [2, 3]);
|
|
662
|
+
const old = summariseLog(log([use(1, 2), use(2, 5)]));
|
|
663
|
+
assert.equal(old.costUsd, 7); assert.deepEqual(old.builderAttempts.map((a) => a.cost_usd), [2, 5]);
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
it("GitHub phase_attempt records sum cost_usd_delta when present, else cost_usd", () => {
|
|
667
|
+
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}` });
|
|
668
|
+
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" }]);
|
|
669
|
+
assert.equal(run.phases.implement.usd, 9);
|
|
670
|
+
assert.deepEqual(run.builderAttempts.map((a) => a.cost_usd), [2, 3, 4]);
|
|
671
|
+
});
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
describe("first-pass rate with and without matching pitfalls (#4375)", () => {
|
|
675
|
+
const use = (attempt, trigger) => ({ event: "PHASE_ATTEMPT_USAGE", phase: "implement", attempt, trigger, cost_usd: 1, usage: { cache_read_input_tokens: 10, output_tokens: 1 } });
|
|
676
|
+
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" },
|
|
677
|
+
...(pack ? [{ event: "CONTEXT_PACK", mode: "shadow", pitfalls: pack }] : []), ...attempts, { event: "RUN_TERMINAL", reason: "merged", ts: "2026-10-20T01:00:00Z", ...term }];
|
|
678
|
+
const clean = [use(1, "plan"), use(2, "build")], rework = [...clean, use(3, "precheck")];
|
|
679
|
+
|
|
680
|
+
it("summariseLog reads pitfalls_matched from RUN_TERMINAL, else the CONTEXT_PACK pitfalls, else null", () => {
|
|
681
|
+
assert.equal(summariseLog(log(1, clean, { term: { pitfalls_matched: 2 } })).pitfallsMatched, 2);
|
|
682
|
+
assert.equal(summariseLog(log(1, clean, { pack: [{ sig: "a", match: "files" }, { sig: "b", match: "repo" }] })).pitfallsMatched, 1);
|
|
683
|
+
assert.equal(summariseLog(log(1, clean)).pitfallsMatched, null, "older logs: unknown");
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
it("aggregate splits the first-pass rate by matching pitfalls and leaves unknown runs out; render prints both rows", () => {
|
|
687
|
+
const runs = [
|
|
688
|
+
log(1, clean, { term: { pitfalls_matched: 1 } }), log(2, clean, { term: { pitfalls_matched: 3 } }), log(3, rework, { term: { pitfalls_matched: 1 } }),
|
|
689
|
+
log(4, rework, { term: { pitfalls_matched: 0 } }), log(5, clean, { term: { pitfalls_matched: 0 } }), log(6, rework, { term: { pitfalls_matched: 0 } }),
|
|
690
|
+
log(7, clean), // no pitfall field: unknown
|
|
691
|
+
].map((l) => summariseLog(l));
|
|
692
|
+
const g = aggregate({ runs }).groups[0];
|
|
693
|
+
assert.deepEqual(g.firstPassByPitfalls, { with: { rate: 2 / 3, firstPass: 2, n: 3 }, without: { rate: 1 / 3, firstPass: 1, n: 3 } });
|
|
694
|
+
assert.equal(g.firstPass.n, 7, "the overall rate still counts every lane");
|
|
695
|
+
const md = renderMarkdown(aggregate({ runs }));
|
|
696
|
+
assert.match(md, /first-pass with matching pitfalls \| 67% \(2\/3\)/);
|
|
697
|
+
assert.match(md, /first-pass without matching pitfalls \| 33% \(1\/3\)/);
|
|
698
|
+
const none = renderMarkdown(aggregate({ runs: [log(8, clean)].map((l) => summariseLog(l)) }));
|
|
699
|
+
assert.ok(!/matching pitfalls/.test(none), "no pitfall data: no rows");
|
|
700
|
+
});
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
describe("fail-soft alerts (#4354)", () => {
|
|
704
|
+
const ts = (i) => new Date(Date.UTC(2026, 9, 1) + i * 60000).toISOString();
|
|
705
|
+
// One lane per call; `extra` are the events after RUN_START.
|
|
706
|
+
const lane = (i, extra = [], { repo = "o/r", code = "completed", reason = "merged" } = {}) => [
|
|
707
|
+
{ event: "RUN_START", ts: ts(i), repo, issue: 100 + i }, ...extra, ...(code ? [{ event: "RUN_TERMINAL", reason, code }] : [])];
|
|
708
|
+
const deps = (eco, status, why = null) => ({ event: "DEPS_PREPARED", eco, status, reason: why });
|
|
709
|
+
const lanes = (n, f, o) => Array.from({ length: n }, (_, i) => lanesOfLog(lane(i, f(i), typeof o === "function" ? o(i) : o))[0]);
|
|
710
|
+
|
|
711
|
+
it("DEPS_PREPARED failed above 20% per ecosystem, with rate, sample and an example reason; healthy ecosystems stay quiet", () => {
|
|
712
|
+
const l = lanes(10, (i) => [deps("pnpm", i < 3 ? "failed" : "hit", "tool not found: spawn pnpm ENOENT"), deps("uv", "hit")]);
|
|
713
|
+
const a = computeAlerts(l, { repo: "o/r" });
|
|
714
|
+
assert.deepEqual(a.map((x) => [x.event, x.count, x.sample]), [["DEPS_PREPARED failed (pnpm)", 3, 10]]);
|
|
715
|
+
assert.equal(a[0].example, "tool not found: spawn pnpm ENOENT");
|
|
716
|
+
assert.deepEqual(computeAlerts(lanes(10, (i) => [deps("uv", i < 2 ? "failed" : "hit")]), { repo: "o/r" }), [], "exactly 20% is not above");
|
|
717
|
+
const line = renderAlerts(a)[0];
|
|
718
|
+
assert.match(line, /^DEPS_PREPARED failed \(pnpm\): 30% \(3\/10 lanes, threshold 20%\), e\.g\. "tool not found: spawn pnpm ENOENT"$/);
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
it("deferred-to-ci above 30% of lanes and a TOOLCHAIN_WARNING repeated on more than 50% of lanes", () => {
|
|
722
|
+
const pre = (deferred) => ({ event: "PRECHECK_COMMANDS", round: 0, results: [{ command: "npm test", rc: 0, ms: 5 }, ...(deferred ? [{ command: "npx tsc", status: "deferred-to-ci" }] : [])] });
|
|
723
|
+
const warn = { event: "TOOLCHAIN_WARNING", warning: "host node v26 does not satisfy the .nvmrc pin \"22\"" };
|
|
724
|
+
const a = computeAlerts(lanes(10, (i) => [pre(i < 4), ...(i < 6 ? [warn] : [])]), { repo: "o/r" });
|
|
725
|
+
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]]);
|
|
726
|
+
assert.deepEqual(computeAlerts(lanes(10, (i) => [pre(i < 3), ...(i < 5 ? [warn] : [])]), { repo: "o/r" }), [], "30% and 50% are not above");
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
it("an outcome code whose share doubled against the previous window; needs a full previous window; completed never alerts", () => {
|
|
730
|
+
// 20 lanes, window 10: previous window has 1 ci.red (10%), current has 3 (30%)
|
|
731
|
+
const code = (i) => ({ code: [0, 12, 15, 18].includes(i) ? "ci.red" : "completed", reason: [0, 12, 15, 18].includes(i) ? "needs-human" : "merged" });
|
|
732
|
+
const l = lanes(20, () => [], code);
|
|
733
|
+
const a = computeAlerts(l, { repo: "o/r", window: 10 });
|
|
734
|
+
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"]]);
|
|
735
|
+
assert.match(renderAlerts(a)[0], /^outcome ci\.red: 30% \(3\/10 lanes, previous window 10%, threshold 2x\), e\.g\. "#118 needs-human"$/);
|
|
736
|
+
assert.deepEqual(computeAlerts(l.slice(5), { repo: "o/r", window: 10 }), [], "a previous window under min_lanes never compares");
|
|
737
|
+
assert.deepEqual(computeAlerts(lanes(20, () => [], (i) => ({ code: i < 15 ? "invalid" : "completed" })), { repo: "o/r", window: 10 }), []);
|
|
738
|
+
});
|
|
739
|
+
|
|
740
|
+
it("rolling window per repo, ordered by start: old failures and other repos fall out; under 10 lanes or no logs gives nothing", () => {
|
|
741
|
+
const bad = lanes(10, () => [deps("pnpm", "failed", "x")]);
|
|
742
|
+
const good = lanes(50, () => [deps("pnpm", "hit")]).map((l, i) => ({ ...l, startTs: ts(100 + i) }));
|
|
743
|
+
assert.deepEqual(computeAlerts([...good, ...bad], { repo: "o/r" }), [], "the 50 newest lanes are healthy");
|
|
744
|
+
assert.equal(computeAlerts(bad, { repo: "o/r" }).length, 1);
|
|
745
|
+
assert.deepEqual(computeAlerts(bad, { repo: "x/y" }), []);
|
|
746
|
+
assert.deepEqual(computeAlerts(bad.slice(1), { repo: "o/r" }), [], "9 lanes is too few");
|
|
747
|
+
assert.deepEqual(computeAlerts([], { repo: "o/r" }), []);
|
|
748
|
+
assert.deepEqual(readLanes(join(td, "missing")), []);
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
it("readLanes splits every RUN_START slice of every log (local files only) and keeps the repo", () => {
|
|
752
|
+
const dir = join(td, "runs"); mkdirSync(dir);
|
|
753
|
+
const text = (evs) => evs.map((e) => JSON.stringify(e)).join("\n") + "\n";
|
|
754
|
+
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");
|
|
755
|
+
writeFileSync(join(dir, "8.jsonl"), text(lane(2, [], { repo: "a/b" })));
|
|
756
|
+
const got = readLanes(dir);
|
|
757
|
+
assert.deepEqual(got.map((l) => [l.repo, l.issue, l.code, l.deps.length, l.sparseFallback]),
|
|
758
|
+
[["o/r", 100, "completed", 1, null], ["o/r", 101, null, 0, "no cone"], ["a/b", 102, "completed", 0, null]]);
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
it("thresholds come from forge.yaml stats.alerts.* (nested block and flat form) and change the result", () => {
|
|
762
|
+
assert.deepEqual(readAlertConfig(join(td, "none")), DEFAULT_ALERTS);
|
|
763
|
+
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");
|
|
764
|
+
const c = readAlertConfig(td);
|
|
765
|
+
assert.deepEqual(c, { ...DEFAULT_ALERTS, minLanes: 5, depsFailedPct: 50, window: 8 });
|
|
766
|
+
const l = lanes(6, (i) => [deps("pnpm", i < 2 ? "failed" : "hit", "x")]);
|
|
767
|
+
assert.deepEqual(computeAlerts(l, { repo: "o/r" }), [], "default min_lanes 10");
|
|
768
|
+
assert.deepEqual(computeAlerts(l, { repo: "o/r", ...c }), [], "33% is under the configured 50%");
|
|
769
|
+
assert.equal(computeAlerts(l, { repo: "o/r", ...c, depsFailedPct: 20 }).length, 1);
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
it("stats report carries alerts and renders an ALERTS section only when one fired; --no-local skips them; no gh call for alerts", async () => {
|
|
773
|
+
const { gh } = fakeGh();
|
|
774
|
+
const localLanes = lanes(10, () => [deps("pnpm", "failed", "spawn pnpm ENOENT")]);
|
|
775
|
+
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: {} });
|
|
776
|
+
assert.equal(rep.alerts.length, 1);
|
|
777
|
+
assert.match(renderMarkdown(rep), /## ALERTS\n\n- DEPS_PREPARED failed \(pnpm\): 100% \(10\/10 lanes/);
|
|
778
|
+
assert.equal(JSON.parse(renderJson(rep)).alerts[0].event, "DEPS_PREPARED failed (pnpm)");
|
|
779
|
+
const off = await buildReport({ repo: "o/r", local: false, cache: false, minN: 10 }, { gh, env: ENV, releaseTimes: [], localLanes });
|
|
780
|
+
assert.deepEqual(off.alerts, []);
|
|
781
|
+
assert.doesNotMatch(renderMarkdown(off), /ALERTS/);
|
|
782
|
+
// computed from local run logs alone: no gh in scope at all
|
|
783
|
+
assert.equal(computeAlerts(localLanes, { repo: "o/r" }).length, 1);
|
|
784
|
+
});
|
|
785
|
+
});
|
package/commands/orchestrate.md
CHANGED
|
@@ -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`.
|
|
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
|