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.
- 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 +409 -38
- package/bin/engine.mjs +47 -37
- package/bin/forgedock.mjs +35 -3
- package/bin/gc.mjs +50 -17
- package/bin/hooks/reread-nudge.mjs +65 -0
- 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 +338 -2
- 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 +128 -44
- package/bin/lean/context.test.mjs +157 -5
- 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 +48 -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 +712 -134
- package/bin/lean/lean.test.mjs +1626 -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/lightlane.mjs +219 -0
- package/bin/lean/lightlane.test.mjs +237 -0
- package/bin/lean/limits.test.mjs +36 -1
- package/bin/lean/orchestrate.mjs +303 -41
- 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 +118 -1
- package/bin/lean/prefilter.mjs +220 -5
- package/bin/lean/prefilter.test.mjs +260 -4
- 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 +104 -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.mjs +16 -1
- package/bin/lean/sched.test.mjs +175 -1
- package/bin/lean/schedd.mjs +55 -9
- package/bin/lean/schedd.test.mjs +170 -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 +335 -0
- package/bin/stats/efficiency.test.mjs +266 -0
- package/bin/stats/github-source.mjs +8 -3
- package/bin/stats/index.mjs +91 -6
- package/bin/stats/local-source.mjs +145 -8
- package/bin/stats/query.mjs +181 -3
- package/bin/stats/render.mjs +22 -0
- package/bin/stats/stats.test.mjs +403 -8
- 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 +66 -1
- package/docs/TELEMETRY.md +1 -1
- package/docs/eval/corpus/lightlane-v1.json +55 -0
- package/docs/eval/lightlane-bench.md +91 -0
- package/docs/spec/review-protocol.md +1 -1
- package/package.json +1 -1
- package/scripts/bench-lightlane.mjs +286 -0
- 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
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
import { describe, it } from "node:test";
|
|
4
|
+
import assert from "node:assert/strict";
|
|
5
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { dirname, join } from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { tmpDir } from "../tests/_tmp.mjs";
|
|
9
|
+
import { TRIGGERS } from "../lean/fixround.mjs";
|
|
10
|
+
import {
|
|
11
|
+
TRIGGER_CAUSE, RETRY_CAUSES, WEEK_MS, codeCause, issueSlices, sliceRetries, issueQuality, acceptanceOf, efficiencyMetrics,
|
|
12
|
+
batchEfficiency, windowEfficiency, formatEfficiencyLines, parseWindows,
|
|
13
|
+
} from "./efficiency.mjs";
|
|
14
|
+
import { parseArgs, runStats } from "./index.mjs";
|
|
15
|
+
|
|
16
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
const FX = join(here, "fixtures", "efficiency-20261022");
|
|
18
|
+
const AL = "RapierCraftStudios/AlterLab";
|
|
19
|
+
const B1 = "b20261022-181111-1103229", B2 = "b20261022-183421-1103229";
|
|
20
|
+
const NOW = Date.parse("2026-10-23T00:00:00Z");
|
|
21
|
+
const DAY = 86400000;
|
|
22
|
+
|
|
23
|
+
const start = (ts, repo = "o/r") => ({ event: "RUN_START", ts, repo });
|
|
24
|
+
const att = (phase, o = {}) => ({ event: "PHASE_ATTEMPT_USAGE", phase, turns: 10, cost_usd: 1, usage: { cache_read_input_tokens: 1000 }, model: "m1", ...o });
|
|
25
|
+
const term = (reason, code, o = {}) => ({ event: "RUN_TERMINAL", reason, code, ...o });
|
|
26
|
+
const probe = (result, at, trusted = true) => ({ body: `<!-- FORGE:PROBE -->\n## Acceptance probe\n\`{"fix":"abc","result":"${result}"}\``, created_at: new Date(at).toISOString(),
|
|
27
|
+
author_association: trusted ? "OWNER" : "NONE", user: { type: trusted ? "Bot" : "User", login: "x" } });
|
|
28
|
+
|
|
29
|
+
describe("#4402 retry causes", () => {
|
|
30
|
+
it("classifies every builder trigger (property: TRIGGERS ⊆ TRIGGER_CAUSE)", () => {
|
|
31
|
+
for (const t of TRIGGERS) assert.ok(Object.hasOwn(TRIGGER_CAUSE, t), `trigger ${t} has no cause`);
|
|
32
|
+
for (const c of Object.values(TRIGGER_CAUSE)) assert.ok(c === null || RETRY_CAUSES.includes(c));
|
|
33
|
+
assert.equal(TRIGGER_CAUSE.post_merge, "code"); assert.equal(TRIGGER_CAUSE.duplicate, "code"); // #4406: builder rework on the merged tree
|
|
34
|
+
});
|
|
35
|
+
it("maps lane-rerun codes by family", () => {
|
|
36
|
+
assert.equal(codeCause("env.tool_missing"), "env");
|
|
37
|
+
assert.equal(codeCause("followup.unavailable"), "engine");
|
|
38
|
+
assert.equal(codeCause("engine.error"), "engine");
|
|
39
|
+
assert.equal(codeCause("conformance.unimplemented"), "code");
|
|
40
|
+
assert.equal(codeCause("review.failed"), "review");
|
|
41
|
+
assert.equal(codeCause("weird"), "unknown");
|
|
42
|
+
assert.equal(codeCause(null), "unknown");
|
|
43
|
+
});
|
|
44
|
+
it("counts triggered attempts by cause and legacy attempts beyond plan + build as unknown", () => {
|
|
45
|
+
const s = issueSlices([start("t"), att("implement", { trigger: "plan" }), att("implement", { trigger: "build" }), att("implement", { trigger: "ci_red" }),
|
|
46
|
+
att("implement", { trigger: "context_restart" }), att("implement", { trigger: "ci_unavailable" }), att("review", { trigger: "review" }), att("implement", { trigger: "review" })])[0];
|
|
47
|
+
assert.deepEqual(sliceRetries(s), { env: 1, engine: 1, code: 1, review: 1, unknown: 0 });
|
|
48
|
+
const legacy = issueSlices([start("t"), att("implement"), att("implement"), att("implement"), att("implement"), att("review")])[0];
|
|
49
|
+
assert.deepEqual(sliceRetries(legacy), { env: 0, engine: 0, code: 0, review: 0, unknown: 2 });
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("#4402 issue quality and acceptance", () => {
|
|
54
|
+
it("tags root cause + file:line + fix design", () => {
|
|
55
|
+
assert.equal(issueQuality("## Root Cause\nx at `bin/a.mjs:12`\n## Fix design\ny").tag, "full");
|
|
56
|
+
assert.equal(issueQuality("**Root cause**: z\n### Proposal\nfoo").tag, "partial");
|
|
57
|
+
assert.equal(issueQuality("just words, see a.js:3").tag, "partial");
|
|
58
|
+
assert.equal(issueQuality("plain text").tag, "none");
|
|
59
|
+
assert.equal(issueQuality("<!-- ## Root Cause a.js:1 ## Fix -->\nnothing").tag, "none");
|
|
60
|
+
assert.equal(issueQuality("<!-- ## Root Cause a.js:1\n## Fix design\nunclosed").tag, "none");
|
|
61
|
+
assert.equal(issueQuality(null).tag, "none");
|
|
62
|
+
});
|
|
63
|
+
it("is pending under 7 days, then pass only when every record within 7 days passes", () => {
|
|
64
|
+
const t0 = NOW - WEEK_MS;
|
|
65
|
+
assert.equal(acceptanceOf([], NOW), "unprobed");
|
|
66
|
+
assert.equal(acceptanceOf([probe("pass", t0 + 1)], NOW), "pending");
|
|
67
|
+
assert.equal(acceptanceOf([probe("pass", t0)], NOW), "pass", "exactly 7 days settles");
|
|
68
|
+
assert.equal(acceptanceOf([probe("pass", t0), probe("fail", t0 + WEEK_MS)], NOW + WEEK_MS), "fail");
|
|
69
|
+
assert.equal(acceptanceOf([probe("pass", t0), probe("fail", t0 + WEEK_MS + 1)], NOW + WEEK_MS), "pass", "a fail after the window does not count");
|
|
70
|
+
assert.equal(acceptanceOf([probe("fail", t0, false)], NOW), "unprobed", "untrusted records are ignored");
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe("#4402 efficiencyMetrics", () => {
|
|
75
|
+
const logs = new Map([
|
|
76
|
+
[1, [start("2026-10-01T00:00:00Z"), att("implement", { trigger: "plan" }), att("implement", { trigger: "build" }), term("needs-human", "env.tool_missing"),
|
|
77
|
+
start("2026-10-02T00:00:00Z"), att("implement", { trigger: "build", model: "m2", turns: 30, usage: { cache_read_input_tokens: 9000 } }), att("review", { cost_usd: 0.5 }),
|
|
78
|
+
term("merged", "completed", { turns: { size: "S" } }), start("2026-10-09T00:00:00Z"), att("implement", { cost_usd: 99 })]],
|
|
79
|
+
[2, [start("2026-10-02T00:00:00Z", "x/y"), att("implement", { cost_usd: 50 }), term("merged", "completed"),
|
|
80
|
+
start("2026-10-03T00:00:00Z"), att("implement", { trigger: "build" }), term("merged", "completed")]],
|
|
81
|
+
]);
|
|
82
|
+
const entries = [
|
|
83
|
+
{ row: { issue: 1, outcome: "merged" }, settleAt: "2026-10-05T00:00:00Z" },
|
|
84
|
+
{ row: { issue: 2, outcome: "merged" }, settleAt: null },
|
|
85
|
+
{ row: { issue: 3, outcome: "needs-human", code: "builder.blocked", detail: "Builder is blocked (promotion): on staging", cost_usd: 0.3 } },
|
|
86
|
+
{ row: { issue: 4, outcome: "needs-human", code: "builder.blocked", detail: "Builder is blocked (credentials): x", cost_usd: 0.3 } },
|
|
87
|
+
{ row: { issue: 5, outcome: "held", prefilter: { parked: true, kind: "awaiting-promotion" }, cost_usd: 0 } },
|
|
88
|
+
];
|
|
89
|
+
const issues = new Map([[1, { body: "## Root cause\na.js:1\n## Fix", labels: [], comments: [probe("pass", NOW - 8 * DAY)] }],
|
|
90
|
+
[2, { body: "x", labels: ["size:L"], comments: [] }]]);
|
|
91
|
+
const m = efficiencyMetrics({ entries, logs, issues, repo: "o/r", now: NOW });
|
|
92
|
+
|
|
93
|
+
it("sums the cost to merge over earlier slices, bounded by the settle time and the repo", () => {
|
|
94
|
+
assert.equal(m.merged, 2);
|
|
95
|
+
assert.deepEqual(m.per_issue.map((p) => [p.issue, p.slices, p.cost_usd, p.implement_turns, p.size]), [[1, 2, 3.5, 50, "S"], [2, 1, 1, 10, "L"]]);
|
|
96
|
+
assert.equal(m.usd_per_merge, 2.25);
|
|
97
|
+
assert.equal(m.turns_per_merge, 30);
|
|
98
|
+
assert.equal(m.context_per_turn, 200);
|
|
99
|
+
assert.deepEqual(Object.keys(m.by_size), ["S", "L"]);
|
|
100
|
+
assert.deepEqual(m.by_model.m2, { merges: 1, attempts: 1, turns: 30, cost_usd: 1, usd_per_merge: 1, turns_per_merge: 30, context_per_turn: 300 });
|
|
101
|
+
});
|
|
102
|
+
it("counts retries by cause including lane reruns", () => {
|
|
103
|
+
assert.deepEqual(m.retries, { total: 1, per_merge: 0.5, by_cause: { env: 1, engine: 0, code: 0, review: 0, unknown: 0 }, lane_reruns: 1 });
|
|
104
|
+
});
|
|
105
|
+
it("lists no-action lanes: parked and promotion-blocked only", () => {
|
|
106
|
+
assert.equal(m.no_action.lanes, 2);
|
|
107
|
+
assert.deepEqual(Object.keys(m.no_action.by_kind).sort(), ["awaiting-promotion", "blocked-promotion"]);
|
|
108
|
+
assert.equal(m.no_action.cost_usd, 0.3);
|
|
109
|
+
});
|
|
110
|
+
it("compares quality tags and reports the acceptance rate", () => {
|
|
111
|
+
assert.equal(m.quality.full.n, 1);
|
|
112
|
+
assert.equal(m.quality.none.n, 1);
|
|
113
|
+
assert.equal(m.quality.partial.n, 0);
|
|
114
|
+
assert.deepEqual(m.acceptance, { pass: 1, fail: 0, pending: 0, unprobed: 1, probed: 1, pass_rate: 1 });
|
|
115
|
+
});
|
|
116
|
+
it("renders n/a, never NaN, for an empty cohort", () => {
|
|
117
|
+
const lines = formatEfficiencyLines(efficiencyMetrics({}));
|
|
118
|
+
assert.equal(lines.length, 9);
|
|
119
|
+
assert.ok(!lines.some((l) => /NaN|undefined/.test(l)), lines.join("\n"));
|
|
120
|
+
assert.match(lines[0], /^efficiency: merged 0, n\/a\/merge/);
|
|
121
|
+
assert.equal(lines[6], "acceptance (7d after promotion): n/a");
|
|
122
|
+
assert.equal(lines[7], "tools: calls/turn p50 n/a, re-reads (>=3x same path)/merge n/a");
|
|
123
|
+
assert.equal(lines[8], "lanes: light 0 ($0.00), upgraded 0 ($0.00), full 0 ($0.00); upgrade rate n/a");
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe("#4452 light/full split and upgrade rate", () => {
|
|
128
|
+
// issue 1: light, merged; 2: light then upgraded in the same run, merged; 3: full, merged; 4: light, failed; 5: upgraded, failed
|
|
129
|
+
const logs = new Map([
|
|
130
|
+
[1, [start("2026-10-22T10:00:00Z"), { event: "LIGHT_ELIGIBILITY", light: true }, att("implement", { trigger: "build", cost_usd: 0.9, cost_usd_delta: 0.4 }), { event: "LIGHT_DONE" }, att("review", { cost_usd: 0.05 }), term("merged", "merged")]],
|
|
131
|
+
[2, [start("2026-10-22T10:00:00Z"), att("implement", { trigger: "build", cost_usd_delta: 0.3 }), { event: "LIGHT_UPGRADE", reasons: [{ code: "diff_lines" }] }, att("implement", { trigger: "plan", cost_usd_delta: 0.5 }), term("merged", "merged")]],
|
|
132
|
+
[3, [start("2026-10-22T10:00:00Z"), att("implement", { trigger: "plan", cost_usd_delta: 0.6 }), att("implement", { trigger: "build", cost_usd: 1.2, cost_usd_delta: 0.4 }), term("merged", "merged")]],
|
|
133
|
+
[4, [start("2026-10-22T10:00:00Z"), att("implement", { trigger: "build", cost_usd_delta: 0.2 }), { event: "LIGHT_DONE" }, term("failed", "ci.red")]],
|
|
134
|
+
[5, [start("2026-10-22T10:00:00Z"), att("implement", { trigger: "build", cost_usd_delta: 0.1 }), { event: "LIGHT_UPGRADE" }, term("failed", "conformance.unimplemented")]],
|
|
135
|
+
]);
|
|
136
|
+
const entries = [1, 2, 3, 4, 5].map((issue) => ({ row: { issue, outcome: issue <= 3 ? "merged" : "failed" }, settleAt: null }));
|
|
137
|
+
const m = efficiencyMetrics({ entries, logs, repo: "o/r" });
|
|
138
|
+
|
|
139
|
+
it("tags each lane slice by shape from LIGHT_DONE / LIGHT_UPGRADE", () => {
|
|
140
|
+
assert.deepEqual(issueSlices(logs.get(1)).map((s) => s.shape), ["light"]);
|
|
141
|
+
assert.deepEqual(issueSlices(logs.get(2)).map((s) => s.shape), ["upgraded"]);
|
|
142
|
+
assert.deepEqual(issueSlices(logs.get(3)).map((s) => s.shape), ["full"]);
|
|
143
|
+
});
|
|
144
|
+
it("splits every lane (all outcomes) by shape with true #4407 delta costs and computes the upgrade rate", () => {
|
|
145
|
+
assert.deepEqual(m.lanes, {
|
|
146
|
+
light: { n: 2, cost_usd: 0.65 }, // 0.4 delta (not the 0.9 running total) + 0.05 review + 0.2
|
|
147
|
+
upgraded: { n: 2, cost_usd: 0.9 },
|
|
148
|
+
full: { n: 1, cost_usd: 1 }, // 0.6 + 0.4 delta, not 1.2
|
|
149
|
+
upgrade_rate: 0.5,
|
|
150
|
+
});
|
|
151
|
+
assert.deepEqual(m.per_issue.map((x) => [x.issue, x.shape]), [[1, "light"], [2, "upgraded"], [3, "full"]]);
|
|
152
|
+
});
|
|
153
|
+
it("the batch report prints the split and the upgrade rate", () => {
|
|
154
|
+
const lines = formatEfficiencyLines(m);
|
|
155
|
+
assert.equal(lines.at(-1), "lanes: light 2 ($0.65), upgraded 2 ($0.90), full 1 ($1.00); upgrade rate 50% (2/4)");
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
describe("#4410 tool calls per turn and re-reads", () => {
|
|
160
|
+
const tools = (calls, turns, rereads) => ({ tools: { calls, turns, per_turn: calls / turns, rereads, top: [] } });
|
|
161
|
+
const logs = new Map([
|
|
162
|
+
[1, [start("2026-10-01T00:00:00Z"), att("implement", { trigger: "plan", ...tools(4, 4, 0) }), att("implement", { trigger: "build", ...tools(8, 4, 2) }),
|
|
163
|
+
att("review", tools(50, 1, 9)), term("merged", "completed")]],
|
|
164
|
+
[2, [start("2026-10-01T00:00:00Z"), att("implement", { trigger: "build", ...tools(30, 10, 1) }), term("merged", "completed")]],
|
|
165
|
+
[3, [start("2026-10-01T00:00:00Z"), att("implement", { trigger: "build", ...tools(5, 5, 0) }), term("merged", "completed")]],
|
|
166
|
+
[4, [start("2026-10-01T00:00:00Z"), att("implement", { trigger: "build" }), term("merged", "completed")]],
|
|
167
|
+
]);
|
|
168
|
+
const entries = [1, 2, 3, 4].map((issue) => ({ row: { issue, outcome: "merged" }, settleAt: null }));
|
|
169
|
+
const m = efficiencyMetrics({ entries, logs, repo: "o/r", now: NOW });
|
|
170
|
+
|
|
171
|
+
it("p50 over lanes with tool data of implement calls / implement tool turns; re-reads per such lane", () => {
|
|
172
|
+
// lane 1: 12/8 = 1.5 (review excluded), lane 2: 3, lane 3: 1; lane 4 has no tool data
|
|
173
|
+
assert.equal(m.tool_calls_per_turn_p50, 1.5);
|
|
174
|
+
assert.equal(m.rereads_per_merge, 1);
|
|
175
|
+
const p = Object.fromEntries(m.per_issue.map((x) => [x.issue, x]));
|
|
176
|
+
assert.equal(p[1].implement_tool_calls_per_turn, 1.5); assert.equal(p[1].implement_rereads, 2);
|
|
177
|
+
assert.equal("implement_tool_calls_per_turn" in p[4], false);
|
|
178
|
+
assert.equal(formatEfficiencyLines(m).find((l) => l.startsWith("tools:")), "tools: calls/turn p50 1.50, re-reads (>=3x same path)/merge 1.00");
|
|
179
|
+
});
|
|
180
|
+
it("null without tool data (older logs)", () => {
|
|
181
|
+
const e = efficiencyMetrics({ entries: [entries[3]], logs, repo: "o/r", now: NOW });
|
|
182
|
+
assert.equal(e.tool_calls_per_turn_p50, null); assert.equal(e.rereads_per_merge, null);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
describe("#4402 reproduces the 2026-10-22 AlterLab batches (#4389-#4394)", () => {
|
|
187
|
+
const window = async (gh = null) => (await windowEfficiency({ batchRoot: join(FX, "batches"), runsDir: join(FX, "runs"), repo: AL, gh, now: NOW, windows: [7 * DAY] }))["7d"];
|
|
188
|
+
it("implement $85.98 against review $3.13 over the 10 merged lanes, per-issue turns, cache-read and cost", async () => {
|
|
189
|
+
const m = await window();
|
|
190
|
+
assert.equal(m.batches, 2);
|
|
191
|
+
assert.equal(m.merged, 10);
|
|
192
|
+
assert.equal(m.implement_usd.toFixed(2), "85.98");
|
|
193
|
+
assert.equal(m.review_usd.toFixed(2), "3.13");
|
|
194
|
+
assert.equal(Math.round((m.implement_usd / (m.implement_usd + m.review_usd)) * 100), 96);
|
|
195
|
+
assert.equal(m.implement_turns, 802);
|
|
196
|
+
assert.equal(m.implement_cache_read, 92215514);
|
|
197
|
+
assert.equal(m.context_per_turn, 114982);
|
|
198
|
+
assert.equal(m.turns_per_merge, 80.2);
|
|
199
|
+
const p = Object.fromEntries(m.per_issue.map((x) => [x.issue, x]));
|
|
200
|
+
const mm = (x) => `${(x / 1e6).toFixed(1)}M`;
|
|
201
|
+
assert.deepEqual([p[36629].implement_turns, mm(p[36629].implement_cache_read), p[36629].implement_usd.toFixed(2)], [124, "22.6M", "19.80"]);
|
|
202
|
+
assert.deepEqual([p[36143].implement_turns, mm(p[36143].implement_cache_read), p[36143].implement_usd.toFixed(2), p[36143].slices], [165, "14.2M", "15.03", 2]);
|
|
203
|
+
assert.equal(p[36143].retries.code, 1);
|
|
204
|
+
assert.deepEqual([p[36627].implement_turns, mm(p[36627].implement_cache_read), p[36627].implement_usd.toFixed(2)], [102, "15.8M", "9.20"]);
|
|
205
|
+
assert.deepEqual([p[36620].implement_turns, mm(p[36620].implement_cache_read)], [91, "12.2M"]);
|
|
206
|
+
assert.deepEqual([p[36612].implement_turns, p[36612].implement_usd.toFixed(2)], [19, "1.23"]);
|
|
207
|
+
assert.deepEqual([p[36619].slices, p[36619].retries.env, p[36619].retries.engine], [4, 1, 2]);
|
|
208
|
+
});
|
|
209
|
+
it("batch 183421: 32 promotion-blocked no-action lanes for $10.13 and the #4389 outcome table", async () => {
|
|
210
|
+
const b = await batchEfficiency({ batchDir: join(FX, "batches", B2), runsDir: join(FX, "runs"), now: NOW });
|
|
211
|
+
assert.equal(b.batch, B2);
|
|
212
|
+
assert.deepEqual([b.no_action.lanes, b.no_action.cost_usd.toFixed(2), Object.keys(b.no_action.by_kind)], [32, "10.13", ["blocked-promotion"]]);
|
|
213
|
+
const { readFileSync } = await import("node:fs");
|
|
214
|
+
const rows = JSON.parse(readFileSync(join(FX, "batches", B2, "report.json"), "utf8")).rows;
|
|
215
|
+
const by = (o) => { const xs = rows.filter((r) => r.outcome === o); return [xs.length, xs.reduce((a, r) => a + r.cost_usd, 0).toFixed(2)]; };
|
|
216
|
+
assert.deepEqual([by("merged"), by("dependency-blocked"), by("invalid")], [[7, "52.97"], [3, "1.27"], [2, "0.82"]]);
|
|
217
|
+
const b1 = await batchEfficiency({ batchDir: join(FX, "batches", B1), runsDir: join(FX, "runs"), now: NOW });
|
|
218
|
+
assert.equal(b1.merged + b.merged, 10);
|
|
219
|
+
});
|
|
220
|
+
it("a gh failure leaves quality and acceptance n/a with a note, never throws", async () => {
|
|
221
|
+
const m = await window(async () => { throw new Error("HTTP 502"); });
|
|
222
|
+
assert.equal(m.quality, null);
|
|
223
|
+
assert.equal(m.acceptance, null);
|
|
224
|
+
assert.match(formatEfficiencyLines(m).join("\n"), /issue quality: n\/a \(gh failed \(HTTP 502\)\)/);
|
|
225
|
+
});
|
|
226
|
+
it("windows exclude batches outside the window and other repos", async () => {
|
|
227
|
+
const out = await windowEfficiency({ batchRoot: join(FX, "batches"), runsDir: join(FX, "runs"), repo: AL, now: NOW + 30 * DAY, windows: [7 * DAY, 60 * DAY] });
|
|
228
|
+
assert.deepEqual([out["7d"].batches, out["7d"].merged, out["60d"].batches, out["60d"].merged], [0, 0, 2, 10]);
|
|
229
|
+
const other = await windowEfficiency({ batchRoot: join(FX, "batches"), runsDir: join(FX, "runs"), repo: "o/r", now: NOW, windows: [7 * DAY] });
|
|
230
|
+
assert.equal(other["7d"].batches, 0);
|
|
231
|
+
});
|
|
232
|
+
it("an issue in two batches counts once, with its latest row", async () => {
|
|
233
|
+
const root = tmpDir("eff-root-");
|
|
234
|
+
for (const [id, created, outcome] of [["b1", "2026-10-20T00:00:00Z", "deferred"], ["b2", "2026-10-21T00:00:00Z", "merged"]]) {
|
|
235
|
+
mkdirSync(join(root, id), { recursive: true });
|
|
236
|
+
writeFileSync(join(root, id, "plan.json"), JSON.stringify({ v: 1, id, repo: "o/r", created }));
|
|
237
|
+
writeFileSync(join(root, id, "report.json"), JSON.stringify({ rows: [{ issue: 7, outcome, cost_usd: 1 }] }));
|
|
238
|
+
}
|
|
239
|
+
const m = (await windowEfficiency({ batchRoot: root, runsDir: tmpDir("eff-runs-"), repo: "o/r", now: NOW, windows: [7 * DAY] }))["7d"];
|
|
240
|
+
assert.deepEqual([m.batches, m.merged], [2, 1]);
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
describe("#4402 forgedock stats --efficiency", () => {
|
|
245
|
+
it("parses the flags and rejects --batch/--window without --efficiency", () => {
|
|
246
|
+
const o = parseArgs(["--efficiency", "--batch", B2, "--window", "7d,30d"]);
|
|
247
|
+
assert.deepEqual([o.efficiency, o.batch, o.windows], [true, B2, [7 * DAY, 30 * DAY]]);
|
|
248
|
+
assert.throws(() => parseArgs(["--batch", B2]), /need --efficiency/);
|
|
249
|
+
assert.throws(() => parseArgs(["--efficiency", "--batch", "../x"]), /invalid --batch/);
|
|
250
|
+
assert.throws(() => parseWindows("7w"), /--window/);
|
|
251
|
+
});
|
|
252
|
+
it("prints the batch and each window", async () => {
|
|
253
|
+
const out = [], w = process.stdout.write;
|
|
254
|
+
process.stdout.write = (t) => { out.push(String(t)); return true; };
|
|
255
|
+
let code;
|
|
256
|
+
try {
|
|
257
|
+
code = await runStats(["--efficiency", "--batch", B2, "--window", "7d", "--repo", AL],
|
|
258
|
+
{ batchRoot: join(FX, "batches"), runsDir: join(FX, "runs"), now: () => NOW, gh: async () => { throw new Error("offline"); } });
|
|
259
|
+
} finally { process.stdout.write = w; }
|
|
260
|
+
assert.equal(code, 0);
|
|
261
|
+
const text = out.join("");
|
|
262
|
+
assert.match(text, new RegExp(`^efficiency \\(batch ${B2}\\): merged 7,`, "m"));
|
|
263
|
+
assert.match(text, /^efficiency \(7d, 2 batches\): merged 10, \$8\.91\/merge \(implement \$85\.98, review \$3\.13\), turns\/merge 80\.2, context\/turn 114982$/m);
|
|
264
|
+
assert.match(text, /^no action: 32 lanes, \$10\.13 \(blocked-promotion 32\)$/m);
|
|
265
|
+
});
|
|
266
|
+
});
|
|
@@ -77,7 +77,7 @@ export function runsFromRecords(records) {
|
|
|
77
77
|
const key = `${r.repo}#${r.issue}#${r.run ?? ""}`;
|
|
78
78
|
const run = byRun.get(key) ?? { source: "github", repo: r.repo ?? null, version: r.forge_version ?? null, issue: r.issue, run: r.run ?? null,
|
|
79
79
|
finished: false, hydrated: false, usageEvents: 0, reason: null, code: null, remediated: false, startTs: null, endTs: null, wallMs: null,
|
|
80
|
-
phases: {}, _first: null };
|
|
80
|
+
phases: {}, builderAttempts: [], _first: null };
|
|
81
81
|
byRun.set(key, run);
|
|
82
82
|
if (!run._first || r.ts < run._first) run._first = r.ts;
|
|
83
83
|
if (r.event === "run_terminal") {
|
|
@@ -92,8 +92,10 @@ export function runsFromRecords(records) {
|
|
|
92
92
|
p.out += num(r.output_tokens) ?? 0;
|
|
93
93
|
p.attempts++;
|
|
94
94
|
if (num(r.duration_ms) != null) p.ms = (p.ms ?? 0) + num(r.duration_ms);
|
|
95
|
-
|
|
95
|
+
const usd = num(r.cost_usd_delta) ?? num(r.cost_usd); // #4407: cost_usd is a running total for a resumed session
|
|
96
|
+
if (usd != null) p.usd = (p.usd ?? 0) + usd;
|
|
96
97
|
if (id === "remediate") run.remediated = true;
|
|
98
|
+
if (id === "implement") run.builderAttempts.push({ attempt: num(r.attempt), trigger: r.trigger ?? null, restart_of: r.restart_of ?? null, cost_usd: usd, _ts: r.ts ?? "" }); // #4362
|
|
97
99
|
}
|
|
98
100
|
const latest = new Map();
|
|
99
101
|
for (const run of byRun.values()) {
|
|
@@ -102,7 +104,10 @@ export function runsFromRecords(records) {
|
|
|
102
104
|
const a = run._first ?? "", b = cur?._first ?? "";
|
|
103
105
|
if (!cur || a > b || (a === b && (run.run ?? "") > (cur.run ?? ""))) latest.set(k, run);
|
|
104
106
|
}
|
|
105
|
-
|
|
107
|
+
// #4362: attempt order (a timeout restart reuses its number: then record time); stable for equal keys
|
|
108
|
+
const order = (a, b) => (a.attempt ?? 0) - (b.attempt ?? 0) || (a._ts < b._ts ? -1 : a._ts > b._ts ? 1 : 0);
|
|
109
|
+
return [...latest.values()].map(({ _first, ...run }) => ({ ...run, startTs: _first,
|
|
110
|
+
builderAttempts: [...run.builderAttempts].sort(order).map(({ _ts, ...a }) => a) }));
|
|
106
111
|
}
|
|
107
112
|
|
|
108
113
|
/** Gate/review records (everything that is not a run record) become aggregate events. */
|
package/bin/stats/index.mjs
CHANGED
|
@@ -9,16 +9,20 @@ import { execFileSync } from "node:child_process";
|
|
|
9
9
|
import { existsSync, readFileSync } from "node:fs";
|
|
10
10
|
import { dirname, join } from "node:path";
|
|
11
11
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
12
|
-
import { aggregate, DEFAULT_MIN_N } from "./aggregate.mjs";
|
|
13
|
-
import { fetchGithubRecords, isValidRepo, toModel } from "./github-source.mjs";
|
|
14
|
-
import { readLocalRuns, runsDir } from "./local-source.mjs";
|
|
12
|
+
import { aggregate, computeAlerts, DEFAULT_ALERTS, DEFAULT_MIN_N } from "./aggregate.mjs";
|
|
13
|
+
import { defaultGh, fetchGithubRecords, isValidRepo, toModel } from "./github-source.mjs";
|
|
14
|
+
import { readLanes, readLocalLaneRuns, readLocalRuns, runsDir } from "./local-source.mjs";
|
|
15
|
+
import { parseForgeYaml } from "../forge-utils.mjs";
|
|
15
16
|
import { renderCompact, renderJson, renderMarkdown } from "./render.mjs";
|
|
16
|
-
import { edgeQuery, parseEdge, verdictMix, contextPackAb, renderContextAb } from "./query.mjs";
|
|
17
|
+
import { edgeQuery, parseEdge, verdictMix, contextPackAb, renderContextAb, builderTierReport, renderTiers, exploreAb, renderExploreAb, laneProfileReport, renderLaneProfiles } from "./query.mjs";
|
|
18
|
+
import { batchEfficiency, windowEfficiency, formatEfficiencyLines, parseWindows } from "./efficiency.mjs";
|
|
19
|
+
import { batchRoot } from "../lean/batch.mjs";
|
|
17
20
|
|
|
18
21
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
19
22
|
|
|
20
23
|
export const HELP = `Usage: forgedock stats [--since <version|date>] [--repo <owner/repo>] [--json] [--refresh]
|
|
21
24
|
[--compact] [--no-local] [--no-cache] [--min-n N] [--release-times FILE]
|
|
25
|
+
forgedock stats --efficiency [--batch <id>] [--window 7d,30d] [--repo <owner/repo>] [--json]
|
|
22
26
|
|
|
23
27
|
Version-over-version report: Cost, Time, Flow, Review funnel, Gates and Issue flow per (version, repo), with a
|
|
24
28
|
delta against the previous version and a low-n flag for samples under 10. Missing data prints n/a.
|
|
@@ -36,6 +40,10 @@ delta against the previous version and a low-n flag for samples under 10. Missin
|
|
|
36
40
|
--release-times FILE "<version> <epoch-seconds>" lines used to estimate versions of unversioned records
|
|
37
41
|
(default: git tags v* of the ForgeDock checkout)
|
|
38
42
|
|
|
43
|
+
ALERTS (forge#4354; local run logs only, skipped with --no-local): fail-soft rates over the last 50 lanes of the repo
|
|
44
|
+
(DEPS_PREPARED failed per ecosystem, deferred-to-ci, repeated TOOLCHAIN_WARNING, outcome codes whose share doubled);
|
|
45
|
+
thresholds under forge.yaml stats.alerts.*. Nothing is printed when none fired or under 10 lanes.
|
|
46
|
+
|
|
39
47
|
Audit queries (forge#4165; JSON, from structure_delta / run_terminal telemetry, no comment bodies read):
|
|
40
48
|
--edge FROM:TO merged PRs that added the dependency or import edge FROM -> TO (e.g. @s/web:@s/db)
|
|
41
49
|
--milestone M conformance verdict mix and per-day structure series for milestone M
|
|
@@ -43,10 +51,29 @@ Audit queries (forge#4165; JSON, from structure_delta / run_terminal telemetry,
|
|
|
43
51
|
Context-pack A/B (forge#4275; local run logs only, no GitHub crawl):
|
|
44
52
|
--context-ab first-pass yield, builder turns, cache tokens and cost per lane type (rerun, child,
|
|
45
53
|
first) and context_pack mode (on, off, shadow); --json for JSON
|
|
54
|
+
|
|
55
|
+
Explorer A/B (forge#4418; local run logs only, every run of each log, no GitHub crawl):
|
|
56
|
+
--explore-ab builder turns, builder cache-read, merge and conformance rates per size (S, M, L) and
|
|
57
|
+
lean.explore mode (on, off), plus the same issues run in both modes; --json for JSON
|
|
58
|
+
|
|
59
|
+
Efficiency (forge#4402; local batch dirs and run logs, gh only for merged issues' bodies, labels and probes):
|
|
60
|
+
--efficiency $/merge, turns/merge, context/turn per size and model, retries/merge by cause, no-action
|
|
61
|
+
lanes, issue quality vs cost and the acceptance pass rate 7 days after promotion
|
|
62
|
+
--batch <id> also report one batch (~/.forge/batches/<id>, FORGEDOCK_BATCH_DIR)
|
|
63
|
+
--window <list> trailing windows over the repo's batches (default 7d,30d)
|
|
64
|
+
|
|
65
|
+
Builder model tiers (forge#4394; local run logs only, no GitHub crawl):
|
|
66
|
+
--tiers merge, conformance and post-merge-rework rates, first-pass yield and cost per builder tier
|
|
67
|
+
(mid, mid->top escalated, top) and routing class; --json for JSON
|
|
68
|
+
|
|
69
|
+
Lane profile baseline (forge#4436; local run logs only, every run of each issue, no GitHub crawl):
|
|
70
|
+
--lane-profile first-turn baseline_tokens per lane profile (full, memory-off, lane) and phase, and the
|
|
71
|
+
conformance rate per profile; --json for JSON
|
|
72
|
+
--issues <list> pin the report to these issues (comma list), for a before/after on the same issue set
|
|
46
73
|
`;
|
|
47
74
|
|
|
48
75
|
export function parseArgs(argv) {
|
|
49
|
-
const o = { since: null, repo: null, edge: null, milestone: null, contextAb: false, json: false, refresh: false, compact: false, local: true, cache: true, minN: DEFAULT_MIN_N, releaseTimes: null, help: false };
|
|
76
|
+
const o = { since: null, repo: null, edge: null, milestone: null, contextAb: false, exploreAb: false, tiers: false, laneProfile: false, issues: null, efficiency: false, batch: null, windows: null, json: false, refresh: false, compact: false, local: true, cache: true, minN: DEFAULT_MIN_N, releaseTimes: null, help: false };
|
|
50
77
|
const val = (i, name) => {
|
|
51
78
|
const v = argv[i + 1];
|
|
52
79
|
if (v == null || v.startsWith("--")) throw new Error(`${name} needs a value`);
|
|
@@ -63,6 +90,13 @@ export function parseArgs(argv) {
|
|
|
63
90
|
else if (name === "--edge") { const v = take(); if (!parseEdge(v)) throw new Error("--edge must be FROM:TO"); o.edge = v; }
|
|
64
91
|
else if (name === "--milestone") o.milestone = take();
|
|
65
92
|
else if (name === "--context-ab") o.contextAb = true;
|
|
93
|
+
else if (name === "--explore-ab") o.exploreAb = true;
|
|
94
|
+
else if (name === "--tiers") o.tiers = true;
|
|
95
|
+
else if (name === "--lane-profile") o.laneProfile = true;
|
|
96
|
+
else if (name === "--issues") { const v = take(); if (!/^[1-9]\d*(,[1-9]\d*)*$/.test(v)) throw new Error("--issues must be a comma list of issue numbers"); o.issues = v.split(",").map(Number); }
|
|
97
|
+
else if (name === "--efficiency") o.efficiency = true;
|
|
98
|
+
else if (name === "--batch") { const v = take(); if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(v)) throw new Error(`invalid --batch id: ${v}`); o.batch = v; }
|
|
99
|
+
else if (name === "--window") o.windows = parseWindows(take());
|
|
66
100
|
else if (name === "--min-n") { const v = take(); if (!/^\d+$/.test(v)) throw new Error("--min-n must be a non-negative integer"); o.minN = Number(v); }
|
|
67
101
|
else if (name === "--json") o.json = true;
|
|
68
102
|
else if (name === "--refresh") o.refresh = true;
|
|
@@ -72,6 +106,7 @@ export function parseArgs(argv) {
|
|
|
72
106
|
else if (name === "--help" || name === "-h") o.help = true;
|
|
73
107
|
else throw new Error(`unknown argument: ${a}`);
|
|
74
108
|
}
|
|
109
|
+
if ((o.batch || o.windows) && !o.efficiency) throw new Error("--batch and --window need --efficiency");
|
|
75
110
|
return o;
|
|
76
111
|
}
|
|
77
112
|
|
|
@@ -153,6 +188,25 @@ export function resolveRepo(explicit, cwd = process.cwd()) {
|
|
|
153
188
|
return null;
|
|
154
189
|
}
|
|
155
190
|
|
|
191
|
+
const ALERT_KEYS = { window: "window", min_lanes: "minLanes", deps_failed_pct: "depsFailedPct", deferred_to_ci_pct: "deferredPct",
|
|
192
|
+
toolchain_warning_pct: "toolchainWarningPct", outcome_share_factor: "outcomeShareFactor" };
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* forge.yaml `stats.alerts.*` (forge#4354). The one-level parser flattens a nested `alerts:` block under `stats:` to
|
|
196
|
+
* `stats.<key>`; the flat `stats.alerts_<key>` form also works and wins. Invalid or unreadable values keep DEFAULT_ALERTS.
|
|
197
|
+
*/
|
|
198
|
+
export function readAlertConfig(cwd = process.cwd()) {
|
|
199
|
+
const out = { ...DEFAULT_ALERTS };
|
|
200
|
+
try {
|
|
201
|
+
const s = parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.stats ?? {};
|
|
202
|
+
for (const [key, name] of Object.entries(ALERT_KEYS)) {
|
|
203
|
+
const raw = String(s[`alerts_${key}`] ?? s[key] ?? "").trim(), n = Number(raw);
|
|
204
|
+
if (raw !== "" && Number.isFinite(n) && n > 0) out[name] = n;
|
|
205
|
+
}
|
|
206
|
+
} catch { /* no forge.yaml: defaults */ }
|
|
207
|
+
return out;
|
|
208
|
+
}
|
|
209
|
+
|
|
156
210
|
/** Build the report object. deps: { gh, env, localRuns, now, onProgress, warn, sleep } for tests. Never touches the network when `gh` is stubbed. */
|
|
157
211
|
export async function buildReport(opts, deps = {}) {
|
|
158
212
|
const env = deps.env ?? process.env;
|
|
@@ -161,10 +215,29 @@ export async function buildReport(opts, deps = {}) {
|
|
|
161
215
|
if (opts.releaseTimes) releaseTimes = parseReleaseTimes(readFileSync(opts.releaseTimes, "utf8"));
|
|
162
216
|
else if (deps.releaseTimes) releaseTimes = deps.releaseTimes;
|
|
163
217
|
else releaseTimes = gitTagTimes();
|
|
218
|
+
if (opts.efficiency) { // forge#4402: local batch dirs + run logs; gh only for merged issues
|
|
219
|
+
const root = deps.batchRoot ?? batchRoot(env), rd = deps.runsDir ?? runsDir(env), now = deps.now?.() ?? Date.now(), gh = deps.gh ?? defaultGh;
|
|
220
|
+
const efficiency = {};
|
|
221
|
+
if (opts.batch) efficiency.batch = await batchEfficiency({ batchDir: join(root, opts.batch), runsDir: rd, repo, gh, now });
|
|
222
|
+
efficiency.windows = await windowEfficiency({ batchRoot: root, runsDir: rd, repo, gh, now, windows: opts.windows ?? parseWindows("7d,30d") });
|
|
223
|
+
return { repo, query: { efficiency } };
|
|
224
|
+
}
|
|
164
225
|
if (opts.contextAb) { // forge#4275: run logs only
|
|
165
226
|
const runs = (deps.localRuns ?? readLocalRuns(runsDir(env))).filter((r) => r.repo == null || r.repo === repo);
|
|
166
227
|
return { repo, sources: { local: { runs: runs.length } }, query: { context_ab: contextPackAb(runs) } };
|
|
167
228
|
}
|
|
229
|
+
if (opts.exploreAb) { // forge#4418: run logs only, every RUN_START slice
|
|
230
|
+
const runs = (deps.localRuns ?? readLocalLaneRuns(runsDir(env))).filter((r) => r.repo == null || r.repo === repo);
|
|
231
|
+
return { repo, sources: { local: { runs: runs.length } }, query: { explore_ab: exploreAb(runs) } };
|
|
232
|
+
}
|
|
233
|
+
if (opts.tiers) { // forge#4394: run logs only
|
|
234
|
+
const runs = (deps.localRuns ?? readLocalRuns(runsDir(env))).filter((r) => r.repo == null || r.repo === repo);
|
|
235
|
+
return { repo, sources: { local: { runs: runs.length } }, query: { tiers: builderTierReport(runs) } };
|
|
236
|
+
}
|
|
237
|
+
if (opts.laneProfile) { // forge#4436: run logs only, every RUN_START slice
|
|
238
|
+
const runs = (deps.localRuns ?? readLocalRuns(runsDir(env), { allSlices: true })).filter((r) => r.repo == null || r.repo === repo);
|
|
239
|
+
return { repo, sources: { local: { runs: runs.length } }, query: { lane_profile: laneProfileReport(runs, { issues: opts.issues }) } };
|
|
240
|
+
}
|
|
168
241
|
const bound = sinceToTimestamp(opts.since, releaseTimes);
|
|
169
242
|
if (bound.note) (deps.warn ?? ((m) => process.stderr.write(`forgedock stats: ${m}\n`)))(bound.note);
|
|
170
243
|
const gh = await fetchGithubRecords({ repo, since: bound.since, refresh: opts.refresh, useCache: opts.cache, gh: deps.gh, env,
|
|
@@ -182,7 +255,9 @@ export async function buildReport(opts, deps = {}) {
|
|
|
182
255
|
}
|
|
183
256
|
const report = aggregate({ runs: [...model.runs, ...local], events: model.events, issues: model.issues },
|
|
184
257
|
{ minN: opts.minN, releaseTimes, ...parseSince(opts.since) });
|
|
185
|
-
|
|
258
|
+
// forge#4354: fail-soft alerts from the local run logs only (no gh call)
|
|
259
|
+
const alerts = opts.local ? computeAlerts(deps.localLanes ?? readLanes(runsDir(env)), { repo, ...(deps.alertConfig ?? readAlertConfig()) }) : [];
|
|
260
|
+
return { repo, sources: { github: { items: gh.items.length, records: gh.records.length, cached: gh.fromCache }, local: { runs: local.length } }, ...report, alerts };
|
|
186
261
|
}
|
|
187
262
|
|
|
188
263
|
export async function runStats(argv, deps = {}) {
|
|
@@ -199,7 +274,17 @@ export async function runStats(argv, deps = {}) {
|
|
|
199
274
|
}
|
|
200
275
|
try {
|
|
201
276
|
const report = await buildReport(opts, { onProgress: makeProgressPrinter(), ...deps });
|
|
277
|
+
if (report.query?.lane_profile && !opts.json) { process.stdout.write(`${renderLaneProfiles(report.query.lane_profile)}\n`); return 0; }
|
|
278
|
+
if (report.query?.tiers && !opts.json) { process.stdout.write(`${renderTiers(report.query.tiers)}\n`); return 0; }
|
|
279
|
+
if (report.query?.efficiency && !opts.json) {
|
|
280
|
+
const e = report.query.efficiency;
|
|
281
|
+
const lines = [...(e.batch ? formatEfficiencyLines(e.batch, `batch ${e.batch.batch ?? opts.batch}`) : []),
|
|
282
|
+
...Object.entries(e.windows).flatMap(([w, m]) => formatEfficiencyLines(m, `${w}, ${m.batches} batch${m.batches === 1 ? "" : "es"}`))];
|
|
283
|
+
process.stdout.write(`${lines.join("\n")}\n`);
|
|
284
|
+
return 0;
|
|
285
|
+
}
|
|
202
286
|
if (report.query?.context_ab && !opts.json) { process.stdout.write(`${renderContextAb(report.query.context_ab)}\n`); return 0; }
|
|
287
|
+
if (report.query?.explore_ab && !opts.json) { process.stdout.write(`${renderExploreAb(report.query.explore_ab)}\n`); return 0; }
|
|
203
288
|
process.stdout.write(`${opts.json || report.query ? renderJson(report) : opts.compact ? renderCompact(report) : renderMarkdown(report)}\n`);
|
|
204
289
|
return 0;
|
|
205
290
|
} catch (err) {
|