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
|
@@ -0,0 +1,233 @@
|
|
|
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, 8);
|
|
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
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe("#4410 tool calls per turn and re-reads", () => {
|
|
127
|
+
const tools = (calls, turns, rereads) => ({ tools: { calls, turns, per_turn: calls / turns, rereads, top: [] } });
|
|
128
|
+
const logs = new Map([
|
|
129
|
+
[1, [start("2026-10-01T00:00:00Z"), att("implement", { trigger: "plan", ...tools(4, 4, 0) }), att("implement", { trigger: "build", ...tools(8, 4, 2) }),
|
|
130
|
+
att("review", tools(50, 1, 9)), term("merged", "completed")]],
|
|
131
|
+
[2, [start("2026-10-01T00:00:00Z"), att("implement", { trigger: "build", ...tools(30, 10, 1) }), term("merged", "completed")]],
|
|
132
|
+
[3, [start("2026-10-01T00:00:00Z"), att("implement", { trigger: "build", ...tools(5, 5, 0) }), term("merged", "completed")]],
|
|
133
|
+
[4, [start("2026-10-01T00:00:00Z"), att("implement", { trigger: "build" }), term("merged", "completed")]],
|
|
134
|
+
]);
|
|
135
|
+
const entries = [1, 2, 3, 4].map((issue) => ({ row: { issue, outcome: "merged" }, settleAt: null }));
|
|
136
|
+
const m = efficiencyMetrics({ entries, logs, repo: "o/r", now: NOW });
|
|
137
|
+
|
|
138
|
+
it("p50 over lanes with tool data of implement calls / implement tool turns; re-reads per such lane", () => {
|
|
139
|
+
// lane 1: 12/8 = 1.5 (review excluded), lane 2: 3, lane 3: 1; lane 4 has no tool data
|
|
140
|
+
assert.equal(m.tool_calls_per_turn_p50, 1.5);
|
|
141
|
+
assert.equal(m.rereads_per_merge, 1);
|
|
142
|
+
const p = Object.fromEntries(m.per_issue.map((x) => [x.issue, x]));
|
|
143
|
+
assert.equal(p[1].implement_tool_calls_per_turn, 1.5); assert.equal(p[1].implement_rereads, 2);
|
|
144
|
+
assert.equal("implement_tool_calls_per_turn" in p[4], false);
|
|
145
|
+
assert.equal(formatEfficiencyLines(m).at(-1), "tools: calls/turn p50 1.50, re-reads (>=3x same path)/merge 1.00");
|
|
146
|
+
});
|
|
147
|
+
it("null without tool data (older logs)", () => {
|
|
148
|
+
const e = efficiencyMetrics({ entries: [entries[3]], logs, repo: "o/r", now: NOW });
|
|
149
|
+
assert.equal(e.tool_calls_per_turn_p50, null); assert.equal(e.rereads_per_merge, null);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe("#4402 reproduces the 2026-10-22 AlterLab batches (#4389-#4394)", () => {
|
|
154
|
+
const window = async (gh = null) => (await windowEfficiency({ batchRoot: join(FX, "batches"), runsDir: join(FX, "runs"), repo: AL, gh, now: NOW, windows: [7 * DAY] }))["7d"];
|
|
155
|
+
it("implement $85.98 against review $3.13 over the 10 merged lanes, per-issue turns, cache-read and cost", async () => {
|
|
156
|
+
const m = await window();
|
|
157
|
+
assert.equal(m.batches, 2);
|
|
158
|
+
assert.equal(m.merged, 10);
|
|
159
|
+
assert.equal(m.implement_usd.toFixed(2), "85.98");
|
|
160
|
+
assert.equal(m.review_usd.toFixed(2), "3.13");
|
|
161
|
+
assert.equal(Math.round((m.implement_usd / (m.implement_usd + m.review_usd)) * 100), 96);
|
|
162
|
+
assert.equal(m.implement_turns, 802);
|
|
163
|
+
assert.equal(m.implement_cache_read, 92215514);
|
|
164
|
+
assert.equal(m.context_per_turn, 114982);
|
|
165
|
+
assert.equal(m.turns_per_merge, 80.2);
|
|
166
|
+
const p = Object.fromEntries(m.per_issue.map((x) => [x.issue, x]));
|
|
167
|
+
const mm = (x) => `${(x / 1e6).toFixed(1)}M`;
|
|
168
|
+
assert.deepEqual([p[36629].implement_turns, mm(p[36629].implement_cache_read), p[36629].implement_usd.toFixed(2)], [124, "22.6M", "19.80"]);
|
|
169
|
+
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]);
|
|
170
|
+
assert.equal(p[36143].retries.code, 1);
|
|
171
|
+
assert.deepEqual([p[36627].implement_turns, mm(p[36627].implement_cache_read), p[36627].implement_usd.toFixed(2)], [102, "15.8M", "9.20"]);
|
|
172
|
+
assert.deepEqual([p[36620].implement_turns, mm(p[36620].implement_cache_read)], [91, "12.2M"]);
|
|
173
|
+
assert.deepEqual([p[36612].implement_turns, p[36612].implement_usd.toFixed(2)], [19, "1.23"]);
|
|
174
|
+
assert.deepEqual([p[36619].slices, p[36619].retries.env, p[36619].retries.engine], [4, 1, 2]);
|
|
175
|
+
});
|
|
176
|
+
it("batch 183421: 32 promotion-blocked no-action lanes for $10.13 and the #4389 outcome table", async () => {
|
|
177
|
+
const b = await batchEfficiency({ batchDir: join(FX, "batches", B2), runsDir: join(FX, "runs"), now: NOW });
|
|
178
|
+
assert.equal(b.batch, B2);
|
|
179
|
+
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"]]);
|
|
180
|
+
const { readFileSync } = await import("node:fs");
|
|
181
|
+
const rows = JSON.parse(readFileSync(join(FX, "batches", B2, "report.json"), "utf8")).rows;
|
|
182
|
+
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)]; };
|
|
183
|
+
assert.deepEqual([by("merged"), by("dependency-blocked"), by("invalid")], [[7, "52.97"], [3, "1.27"], [2, "0.82"]]);
|
|
184
|
+
const b1 = await batchEfficiency({ batchDir: join(FX, "batches", B1), runsDir: join(FX, "runs"), now: NOW });
|
|
185
|
+
assert.equal(b1.merged + b.merged, 10);
|
|
186
|
+
});
|
|
187
|
+
it("a gh failure leaves quality and acceptance n/a with a note, never throws", async () => {
|
|
188
|
+
const m = await window(async () => { throw new Error("HTTP 502"); });
|
|
189
|
+
assert.equal(m.quality, null);
|
|
190
|
+
assert.equal(m.acceptance, null);
|
|
191
|
+
assert.match(formatEfficiencyLines(m).join("\n"), /issue quality: n\/a \(gh failed \(HTTP 502\)\)/);
|
|
192
|
+
});
|
|
193
|
+
it("windows exclude batches outside the window and other repos", async () => {
|
|
194
|
+
const out = await windowEfficiency({ batchRoot: join(FX, "batches"), runsDir: join(FX, "runs"), repo: AL, now: NOW + 30 * DAY, windows: [7 * DAY, 60 * DAY] });
|
|
195
|
+
assert.deepEqual([out["7d"].batches, out["7d"].merged, out["60d"].batches, out["60d"].merged], [0, 0, 2, 10]);
|
|
196
|
+
const other = await windowEfficiency({ batchRoot: join(FX, "batches"), runsDir: join(FX, "runs"), repo: "o/r", now: NOW, windows: [7 * DAY] });
|
|
197
|
+
assert.equal(other["7d"].batches, 0);
|
|
198
|
+
});
|
|
199
|
+
it("an issue in two batches counts once, with its latest row", async () => {
|
|
200
|
+
const root = tmpDir("eff-root-");
|
|
201
|
+
for (const [id, created, outcome] of [["b1", "2026-10-20T00:00:00Z", "deferred"], ["b2", "2026-10-21T00:00:00Z", "merged"]]) {
|
|
202
|
+
mkdirSync(join(root, id), { recursive: true });
|
|
203
|
+
writeFileSync(join(root, id, "plan.json"), JSON.stringify({ v: 1, id, repo: "o/r", created }));
|
|
204
|
+
writeFileSync(join(root, id, "report.json"), JSON.stringify({ rows: [{ issue: 7, outcome, cost_usd: 1 }] }));
|
|
205
|
+
}
|
|
206
|
+
const m = (await windowEfficiency({ batchRoot: root, runsDir: tmpDir("eff-runs-"), repo: "o/r", now: NOW, windows: [7 * DAY] }))["7d"];
|
|
207
|
+
assert.deepEqual([m.batches, m.merged], [2, 1]);
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
describe("#4402 forgedock stats --efficiency", () => {
|
|
212
|
+
it("parses the flags and rejects --batch/--window without --efficiency", () => {
|
|
213
|
+
const o = parseArgs(["--efficiency", "--batch", B2, "--window", "7d,30d"]);
|
|
214
|
+
assert.deepEqual([o.efficiency, o.batch, o.windows], [true, B2, [7 * DAY, 30 * DAY]]);
|
|
215
|
+
assert.throws(() => parseArgs(["--batch", B2]), /need --efficiency/);
|
|
216
|
+
assert.throws(() => parseArgs(["--efficiency", "--batch", "../x"]), /invalid --batch/);
|
|
217
|
+
assert.throws(() => parseWindows("7w"), /--window/);
|
|
218
|
+
});
|
|
219
|
+
it("prints the batch and each window", async () => {
|
|
220
|
+
const out = [], w = process.stdout.write;
|
|
221
|
+
process.stdout.write = (t) => { out.push(String(t)); return true; };
|
|
222
|
+
let code;
|
|
223
|
+
try {
|
|
224
|
+
code = await runStats(["--efficiency", "--batch", B2, "--window", "7d", "--repo", AL],
|
|
225
|
+
{ batchRoot: join(FX, "batches"), runsDir: join(FX, "runs"), now: () => NOW, gh: async () => { throw new Error("offline"); } });
|
|
226
|
+
} finally { process.stdout.write = w; }
|
|
227
|
+
assert.equal(code, 0);
|
|
228
|
+
const text = out.join("");
|
|
229
|
+
assert.match(text, new RegExp(`^efficiency \\(batch ${B2}\\): merged 7,`, "m"));
|
|
230
|
+
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);
|
|
231
|
+
assert.match(text, /^no action: 32 lanes, \$10\.13 \(blocked-promotion 32\)$/m);
|
|
232
|
+
});
|
|
233
|
+
});
|
|
@@ -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, 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 } 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,20 @@ 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
|
+
Efficiency (forge#4402; local batch dirs and run logs, gh only for merged issues' bodies, labels and probes):
|
|
56
|
+
--efficiency $/merge, turns/merge, context/turn per size and model, retries/merge by cause, no-action
|
|
57
|
+
lanes, issue quality vs cost and the acceptance pass rate 7 days after promotion
|
|
58
|
+
--batch <id> also report one batch (~/.forge/batches/<id>, FORGEDOCK_BATCH_DIR)
|
|
59
|
+
--window <list> trailing windows over the repo's batches (default 7d,30d)
|
|
60
|
+
|
|
61
|
+
Builder model tiers (forge#4394; local run logs only, no GitHub crawl):
|
|
62
|
+
--tiers merge, conformance and post-merge-rework rates, first-pass yield and cost per builder tier
|
|
63
|
+
(mid, mid->top escalated, top) and routing class; --json for JSON
|
|
46
64
|
`;
|
|
47
65
|
|
|
48
66
|
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 };
|
|
67
|
+
const o = { since: null, repo: null, edge: null, milestone: null, contextAb: false, tiers: false, 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
68
|
const val = (i, name) => {
|
|
51
69
|
const v = argv[i + 1];
|
|
52
70
|
if (v == null || v.startsWith("--")) throw new Error(`${name} needs a value`);
|
|
@@ -63,6 +81,10 @@ export function parseArgs(argv) {
|
|
|
63
81
|
else if (name === "--edge") { const v = take(); if (!parseEdge(v)) throw new Error("--edge must be FROM:TO"); o.edge = v; }
|
|
64
82
|
else if (name === "--milestone") o.milestone = take();
|
|
65
83
|
else if (name === "--context-ab") o.contextAb = true;
|
|
84
|
+
else if (name === "--tiers") o.tiers = true;
|
|
85
|
+
else if (name === "--efficiency") o.efficiency = true;
|
|
86
|
+
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; }
|
|
87
|
+
else if (name === "--window") o.windows = parseWindows(take());
|
|
66
88
|
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
89
|
else if (name === "--json") o.json = true;
|
|
68
90
|
else if (name === "--refresh") o.refresh = true;
|
|
@@ -72,6 +94,7 @@ export function parseArgs(argv) {
|
|
|
72
94
|
else if (name === "--help" || name === "-h") o.help = true;
|
|
73
95
|
else throw new Error(`unknown argument: ${a}`);
|
|
74
96
|
}
|
|
97
|
+
if ((o.batch || o.windows) && !o.efficiency) throw new Error("--batch and --window need --efficiency");
|
|
75
98
|
return o;
|
|
76
99
|
}
|
|
77
100
|
|
|
@@ -153,6 +176,25 @@ export function resolveRepo(explicit, cwd = process.cwd()) {
|
|
|
153
176
|
return null;
|
|
154
177
|
}
|
|
155
178
|
|
|
179
|
+
const ALERT_KEYS = { window: "window", min_lanes: "minLanes", deps_failed_pct: "depsFailedPct", deferred_to_ci_pct: "deferredPct",
|
|
180
|
+
toolchain_warning_pct: "toolchainWarningPct", outcome_share_factor: "outcomeShareFactor" };
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* forge.yaml `stats.alerts.*` (forge#4354). The one-level parser flattens a nested `alerts:` block under `stats:` to
|
|
184
|
+
* `stats.<key>`; the flat `stats.alerts_<key>` form also works and wins. Invalid or unreadable values keep DEFAULT_ALERTS.
|
|
185
|
+
*/
|
|
186
|
+
export function readAlertConfig(cwd = process.cwd()) {
|
|
187
|
+
const out = { ...DEFAULT_ALERTS };
|
|
188
|
+
try {
|
|
189
|
+
const s = parseForgeYaml(readFileSync(join(cwd, "forge.yaml"), "utf8"))?.stats ?? {};
|
|
190
|
+
for (const [key, name] of Object.entries(ALERT_KEYS)) {
|
|
191
|
+
const raw = String(s[`alerts_${key}`] ?? s[key] ?? "").trim(), n = Number(raw);
|
|
192
|
+
if (raw !== "" && Number.isFinite(n) && n > 0) out[name] = n;
|
|
193
|
+
}
|
|
194
|
+
} catch { /* no forge.yaml: defaults */ }
|
|
195
|
+
return out;
|
|
196
|
+
}
|
|
197
|
+
|
|
156
198
|
/** Build the report object. deps: { gh, env, localRuns, now, onProgress, warn, sleep } for tests. Never touches the network when `gh` is stubbed. */
|
|
157
199
|
export async function buildReport(opts, deps = {}) {
|
|
158
200
|
const env = deps.env ?? process.env;
|
|
@@ -161,10 +203,21 @@ export async function buildReport(opts, deps = {}) {
|
|
|
161
203
|
if (opts.releaseTimes) releaseTimes = parseReleaseTimes(readFileSync(opts.releaseTimes, "utf8"));
|
|
162
204
|
else if (deps.releaseTimes) releaseTimes = deps.releaseTimes;
|
|
163
205
|
else releaseTimes = gitTagTimes();
|
|
206
|
+
if (opts.efficiency) { // forge#4402: local batch dirs + run logs; gh only for merged issues
|
|
207
|
+
const root = deps.batchRoot ?? batchRoot(env), rd = deps.runsDir ?? runsDir(env), now = deps.now?.() ?? Date.now(), gh = deps.gh ?? defaultGh;
|
|
208
|
+
const efficiency = {};
|
|
209
|
+
if (opts.batch) efficiency.batch = await batchEfficiency({ batchDir: join(root, opts.batch), runsDir: rd, repo, gh, now });
|
|
210
|
+
efficiency.windows = await windowEfficiency({ batchRoot: root, runsDir: rd, repo, gh, now, windows: opts.windows ?? parseWindows("7d,30d") });
|
|
211
|
+
return { repo, query: { efficiency } };
|
|
212
|
+
}
|
|
164
213
|
if (opts.contextAb) { // forge#4275: run logs only
|
|
165
214
|
const runs = (deps.localRuns ?? readLocalRuns(runsDir(env))).filter((r) => r.repo == null || r.repo === repo);
|
|
166
215
|
return { repo, sources: { local: { runs: runs.length } }, query: { context_ab: contextPackAb(runs) } };
|
|
167
216
|
}
|
|
217
|
+
if (opts.tiers) { // forge#4394: run logs only
|
|
218
|
+
const runs = (deps.localRuns ?? readLocalRuns(runsDir(env))).filter((r) => r.repo == null || r.repo === repo);
|
|
219
|
+
return { repo, sources: { local: { runs: runs.length } }, query: { tiers: builderTierReport(runs) } };
|
|
220
|
+
}
|
|
168
221
|
const bound = sinceToTimestamp(opts.since, releaseTimes);
|
|
169
222
|
if (bound.note) (deps.warn ?? ((m) => process.stderr.write(`forgedock stats: ${m}\n`)))(bound.note);
|
|
170
223
|
const gh = await fetchGithubRecords({ repo, since: bound.since, refresh: opts.refresh, useCache: opts.cache, gh: deps.gh, env,
|
|
@@ -182,7 +235,9 @@ export async function buildReport(opts, deps = {}) {
|
|
|
182
235
|
}
|
|
183
236
|
const report = aggregate({ runs: [...model.runs, ...local], events: model.events, issues: model.issues },
|
|
184
237
|
{ minN: opts.minN, releaseTimes, ...parseSince(opts.since) });
|
|
185
|
-
|
|
238
|
+
// forge#4354: fail-soft alerts from the local run logs only (no gh call)
|
|
239
|
+
const alerts = opts.local ? computeAlerts(deps.localLanes ?? readLanes(runsDir(env)), { repo, ...(deps.alertConfig ?? readAlertConfig()) }) : [];
|
|
240
|
+
return { repo, sources: { github: { items: gh.items.length, records: gh.records.length, cached: gh.fromCache }, local: { runs: local.length } }, ...report, alerts };
|
|
186
241
|
}
|
|
187
242
|
|
|
188
243
|
export async function runStats(argv, deps = {}) {
|
|
@@ -199,6 +254,14 @@ export async function runStats(argv, deps = {}) {
|
|
|
199
254
|
}
|
|
200
255
|
try {
|
|
201
256
|
const report = await buildReport(opts, { onProgress: makeProgressPrinter(), ...deps });
|
|
257
|
+
if (report.query?.tiers && !opts.json) { process.stdout.write(`${renderTiers(report.query.tiers)}\n`); return 0; }
|
|
258
|
+
if (report.query?.efficiency && !opts.json) {
|
|
259
|
+
const e = report.query.efficiency;
|
|
260
|
+
const lines = [...(e.batch ? formatEfficiencyLines(e.batch, `batch ${e.batch.batch ?? opts.batch}`) : []),
|
|
261
|
+
...Object.entries(e.windows).flatMap(([w, m]) => formatEfficiencyLines(m, `${w}, ${m.batches} batch${m.batches === 1 ? "" : "es"}`))];
|
|
262
|
+
process.stdout.write(`${lines.join("\n")}\n`);
|
|
263
|
+
return 0;
|
|
264
|
+
}
|
|
202
265
|
if (report.query?.context_ab && !opts.json) { process.stdout.write(`${renderContextAb(report.query.context_ab)}\n`); return 0; }
|
|
203
266
|
process.stdout.write(`${opts.json || report.query ? renderJson(report) : opts.compact ? renderCompact(report) : renderMarkdown(report)}\n`);
|
|
204
267
|
return 0;
|
|
@@ -21,6 +21,8 @@ function parseLog(text) {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
const n = (x) => (Number.isFinite(x) ? x : 0);
|
|
24
|
+
/** #4407: an attempt's own cost; `cost_usd` is a running total for a resumed session, so the delta wins when present. */
|
|
25
|
+
const costOf = (e) => (Number.isFinite(e.cost_usd_delta) ? e.cost_usd_delta : e.cost_usd);
|
|
24
26
|
|
|
25
27
|
/** Summarise one log: the last RUN_START..end slice. Returns null when there is no RUN_START. */
|
|
26
28
|
export function summariseLog(events, fallbackIssue = null) {
|
|
@@ -47,7 +49,7 @@ export function summariseLog(events, fallbackIssue = null) {
|
|
|
47
49
|
p.out += n(e.usage.output_tokens);
|
|
48
50
|
p.attempts++;
|
|
49
51
|
if (attempt && Number.isFinite(e.duration_ms)) p.ms = (p.ms ?? 0) + e.duration_ms;
|
|
50
|
-
if (attempt && Number.isFinite(e
|
|
52
|
+
if (attempt && Number.isFinite(costOf(e))) p.usd = (p.usd ?? 0) + costOf(e);
|
|
51
53
|
}
|
|
52
54
|
const finished = t >= 0 && (r < 0 || t > r);
|
|
53
55
|
// forge#4275: the context-pack A/B inputs. Builder calls log as phase "implement" (the plan call is the first).
|
|
@@ -68,14 +70,55 @@ export function summariseLog(events, fallbackIssue = null) {
|
|
|
68
70
|
startTs, endTs, wallMs: finished && !Number.isNaN(a) && !Number.isNaN(b) ? Math.max(0, b - a) : null,
|
|
69
71
|
phases,
|
|
70
72
|
contextPack,
|
|
73
|
+
// #4362: builder attempts in log order with their trigger (null before the field existed)
|
|
74
|
+
builderAttempts: builderCalls.map((e) => ({ attempt: e.attempt ?? null, trigger: e.trigger ?? null, restart_of: e.restart_of ?? null, cost_usd: Number.isFinite(costOf(e)) ? costOf(e) : null })),
|
|
75
|
+
// #4375: pitfalls in the pack matching the issue's files (RUN_TERMINAL, else the last CONTEXT_PACK); null before the field
|
|
76
|
+
pitfallsMatched: Number.isInteger(term?.pitfalls_matched) ? term.pitfalls_matched
|
|
77
|
+
: Array.isArray(cp?.pitfalls) ? cp.pitfalls.filter((p) => p?.match === "files").length : null,
|
|
71
78
|
builderTurns: builderCalls.reduce((x, e) => x + n(e.turns), 0),
|
|
79
|
+
size: term?.turns?.size ?? null, // #4409: S|M|L from the lane's turn record, buckets the admission estimate history
|
|
72
80
|
cacheTokens: usage.reduce((x, e) => x + n(e.usage?.cache_read_input_tokens) + n(e.usage?.cache_creation_input_tokens), 0),
|
|
73
|
-
costUsd: usage.reduce((x, e) => x + n(e
|
|
81
|
+
costUsd: usage.reduce((x, e) => x + n(costOf(e)), 0),
|
|
82
|
+
// #4394: the builder tier (RUN_TERMINAL builder_*, else the BUILDER_ROUTE / BUILDER_ESCALATED events); null before routing
|
|
83
|
+
builderRoute: routeOf(term, ev),
|
|
84
|
+
conformanceRound: builderCalls.some((e) => e.trigger === "conformance" || e.trigger === "review"),
|
|
85
|
+
shadowWould: typeof term?.shadow_would === "string" ? term.shadow_would : null,
|
|
86
|
+
reachedReview: reviewCalls > 0 || typeof term?.shadow_would === "string",
|
|
87
|
+
// #4394: routes of earlier runs in this log that ended merged: a later run of the same issue is post-merge rework
|
|
88
|
+
reworkedMerges: earlierMergedRoutes(events.slice(0, s)),
|
|
74
89
|
// first pass: merged off one plan + one implement call, at most one review, no CI fix round
|
|
75
90
|
firstPass: finished && term?.reason === "merged" && builderCalls.length <= 2 && reviewCalls <= 1 && !ev.some((e) => e.event === "CI_FIX_ROUND"),
|
|
76
91
|
};
|
|
77
92
|
}
|
|
78
93
|
|
|
94
|
+
/** #4394: {tier, class, escalated, model} of a lane, or null when it computed no builder route. */
|
|
95
|
+
function routeOf(term, ev) {
|
|
96
|
+
if (typeof term?.builder_tier === "string") {
|
|
97
|
+
return { tier: term.builder_tier, class: term.builder_class ?? null, escalated: term.builder_escalated === true, model: term.builder_model ?? null };
|
|
98
|
+
}
|
|
99
|
+
const r = [...ev].reverse().find((e) => e.event === "BUILDER_ROUTE");
|
|
100
|
+
if (!r) return null;
|
|
101
|
+
return { tier: r.tier ?? null, class: r.class ?? null, escalated: ev.some((e) => e.event === "BUILDER_ESCALATED"), model: r.model ?? null };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** #4394: the routes ({tier, class, escalated, model}) of the RUN_START slices in `events` that ended merged with a route. */
|
|
105
|
+
function earlierMergedRoutes(events) {
|
|
106
|
+
const out = [];
|
|
107
|
+
let slice = null;
|
|
108
|
+
const close = () => {
|
|
109
|
+
if (!slice) return;
|
|
110
|
+
const term = [...slice].reverse().find((e) => e.event === "RUN_TERMINAL");
|
|
111
|
+
const rt = term?.reason === "merged" ? routeOf(term, slice) : null;
|
|
112
|
+
if (rt) out.push(rt);
|
|
113
|
+
};
|
|
114
|
+
for (const e of events) {
|
|
115
|
+
if (e.event === "RUN_START") { close(); slice = []; }
|
|
116
|
+
if (slice) slice.push(e);
|
|
117
|
+
}
|
|
118
|
+
close();
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
|
|
79
122
|
/** All run summaries in `dir` (a missing dir or an unreadable log yields nothing, never an error). */
|
|
80
123
|
export function readLocalRuns(dir = runsDir()) {
|
|
81
124
|
if (!existsSync(dir)) return [];
|
|
@@ -83,10 +126,50 @@ export function readLocalRuns(dir = runsDir()) {
|
|
|
83
126
|
for (const f of readdirSync(dir).sort()) {
|
|
84
127
|
if (!f.endsWith(".jsonl")) continue;
|
|
85
128
|
try {
|
|
86
|
-
const m =
|
|
129
|
+
const m = /(?:^|__)(\d+)\.jsonl$/.exec(f); // #4432: `<issue>.jsonl` or `<owner>__<repo>__<issue>.jsonl`
|
|
87
130
|
const sum = summariseLog(parseLog(readFileSync(join(dir, f), "utf8")), m ? Number(m[1]) : null);
|
|
88
131
|
if (sum) runs.push({ ...sum, mtimeTs: new Date(statSync(join(dir, f)).mtimeMs).toISOString() });
|
|
89
132
|
} catch { /* unreadable log */ }
|
|
90
133
|
}
|
|
91
134
|
return runs;
|
|
92
135
|
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* forge#4354: one lane per RUN_START..next-RUN_START slice (every slice, not only the last), with the fail-soft events the
|
|
139
|
+
* alert pass rates: DEPS_PREPARED rows, TOOLCHAIN_WARNING texts, deferred-to-ci precheck commands and the terminal code.
|
|
140
|
+
*/
|
|
141
|
+
export function lanesOfLog(events, fallbackIssue = null, fallbackTs = null) {
|
|
142
|
+
const lanes = [];
|
|
143
|
+
let cur = null;
|
|
144
|
+
for (const e of events) {
|
|
145
|
+
if (e.event === "RUN_START") {
|
|
146
|
+
cur = { repo: e.repo ?? null, issue: e.issue ?? fallbackIssue, startTs: e.ts ?? fallbackTs, code: null, reason: null,
|
|
147
|
+
deps: [], toolchainWarnings: [], deferred: [], sparseFallback: null };
|
|
148
|
+
lanes.push(cur);
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
if (!cur) continue;
|
|
152
|
+
if (e.event === "RUN_TERMINAL") { cur.code = e.code ?? null; cur.reason = e.reason ?? null; }
|
|
153
|
+
else if (e.event === "DEPS_PREPARED") cur.deps.push({ eco: e.eco ?? null, status: e.status ?? null, reason: e.reason ?? null });
|
|
154
|
+
else if (e.event === "TOOLCHAIN_WARNING" && e.warning) cur.toolchainWarnings.push(String(e.warning));
|
|
155
|
+
else if (e.event === "PRECHECK_COMMANDS" && Array.isArray(e.results)) {
|
|
156
|
+
for (const r of e.results) if (r?.status === "deferred-to-ci") cur.deferred.push(String(r.command ?? ""));
|
|
157
|
+
} else if (e.event === "WORKTREE_SPARSE_FALLBACK") cur.sparseFallback = e.reason ?? "";
|
|
158
|
+
}
|
|
159
|
+
return lanes;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** All lanes in `dir` (a missing dir or an unreadable log yields nothing, never an error). Local files only, no network. */
|
|
163
|
+
export function readLanes(dir = runsDir()) {
|
|
164
|
+
if (!existsSync(dir)) return [];
|
|
165
|
+
const lanes = [];
|
|
166
|
+
for (const f of readdirSync(dir).sort()) {
|
|
167
|
+
if (!f.endsWith(".jsonl")) continue;
|
|
168
|
+
try {
|
|
169
|
+
const m = /(?:^|__)(\d+)\.jsonl$/.exec(f); // #4432: `<issue>.jsonl` or `<owner>__<repo>__<issue>.jsonl`
|
|
170
|
+
const mtime = new Date(statSync(join(dir, f)).mtimeMs).toISOString();
|
|
171
|
+
lanes.push(...lanesOfLog(parseLog(readFileSync(join(dir, f), "utf8")), m ? Number(m[1]) : null, mtime));
|
|
172
|
+
} catch { /* unreadable log */ }
|
|
173
|
+
}
|
|
174
|
+
return lanes;
|
|
175
|
+
}
|
package/bin/stats/query.mjs
CHANGED
|
@@ -97,3 +97,51 @@ export function renderContextAb(ab) {
|
|
|
97
97
|
const rows = CONTEXT_LANE_TYPES.flatMap((lt) => ["on", "off", "shadow"].map((m) => { const g = ab[lt][m]; return `| ${lt} | ${m} | ${g.n} | ${f(g.first_pass_yield)} | ${f(g.builder_turns)} | ${f(g.cache_tokens)} | ${f(g.cost_usd)} |`; }));
|
|
98
98
|
return `| Lane type | Mode | n | First-pass yield | Builder turns | Cache tokens | Cost USD |\n| --- | --- | --- | --- | --- | --- | --- |\n${rows.join("\n")}`;
|
|
99
99
|
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* forge#4394: builder model tiers over local run summaries (local-source.mjs summariseLog): tier mid, mid->top
|
|
103
|
+
* (escalated) or top, each with its routing class. merge_rate: merged / finished. conformance_rate: of the lanes that
|
|
104
|
+
* reached review, those with no conformance/review fix round and no shadow would-block. post_merge_rework_rate: merges
|
|
105
|
+
* reworked later (a later run of the same issue, or a remediate phase) / merges, where a log's earlier merged runs count
|
|
106
|
+
* as merges too. Runs without a route (older logs, refactor:split) are counted under `unrouted` only.
|
|
107
|
+
*/
|
|
108
|
+
export const BUILDER_TIERS = Object.freeze(["mid", "mid->top", "top"]);
|
|
109
|
+
export function builderTierReport(runs) {
|
|
110
|
+
const mean = (xs) => (xs.length ? Math.round((xs.reduce((a, b) => a + b, 0) / xs.length) * 1e4) / 1e4 : null);
|
|
111
|
+
const rate = (k, n) => (n ? Math.round((k / n) * 1e4) / 1e4 : null);
|
|
112
|
+
const tierOf = (rt) => (rt?.tier === "mid" ? (rt.escalated ? "mid->top" : "mid") : rt?.tier === "top" ? "top" : null);
|
|
113
|
+
const groups = new Map();
|
|
114
|
+
const group = (tier, cls) => {
|
|
115
|
+
const k = `${tier}\u0000${cls}`;
|
|
116
|
+
if (!groups.has(k)) groups.set(k, { tier, class: cls, n: 0, merged: 0, reviewed: 0, conforming: 0, merges: 0, reworked: 0, firstPass: 0, cost: [] });
|
|
117
|
+
return groups.get(k);
|
|
118
|
+
};
|
|
119
|
+
let unrouted = 0;
|
|
120
|
+
for (const r of runs ?? []) {
|
|
121
|
+
for (const m of Array.isArray(r?.reworkedMerges) ? r.reworkedMerges : []) {
|
|
122
|
+
const t = tierOf(m);
|
|
123
|
+
if (t) { const g = group(t, m.class ?? "unknown"); g.merges++; g.reworked++; }
|
|
124
|
+
}
|
|
125
|
+
if (!r?.finished) continue;
|
|
126
|
+
const t = tierOf(r.builderRoute);
|
|
127
|
+
if (!t) { unrouted++; continue; }
|
|
128
|
+
const g = group(t, r.builderRoute.class ?? "unknown");
|
|
129
|
+
g.n++;
|
|
130
|
+
g.cost.push(Number.isFinite(r.costUsd) ? r.costUsd : 0);
|
|
131
|
+
if (r.firstPass) g.firstPass++;
|
|
132
|
+
if (r.reason === "merged") { g.merged++; g.merges++; if (r.remediated) g.reworked++; }
|
|
133
|
+
if (r.reachedReview) { g.reviewed++; if (!r.conformanceRound && (r.shadowWould == null || r.shadowWould === "conforms")) g.conforming++; }
|
|
134
|
+
}
|
|
135
|
+
const rows = [...groups.values()]
|
|
136
|
+
.sort((a, b) => BUILDER_TIERS.indexOf(a.tier) - BUILDER_TIERS.indexOf(b.tier) || a.class.localeCompare(b.class))
|
|
137
|
+
.map((g) => ({ tier: g.tier, class: g.class, n: g.n, merge_rate: rate(g.merged, g.n), conformance_rate: rate(g.conforming, g.reviewed), reviewed: g.reviewed,
|
|
138
|
+
post_merge_rework_rate: rate(g.reworked, g.merges), merges: g.merges, first_pass_yield: rate(g.firstPass, g.n), cost_usd: mean(g.cost) }));
|
|
139
|
+
return { rows, unrouted };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Markdown table for builderTierReport. */
|
|
143
|
+
export function renderTiers(rep) {
|
|
144
|
+
const f = (v) => (v == null ? "n/a" : String(v));
|
|
145
|
+
const rows = (rep?.rows ?? []).map((g) => `| ${g.tier} | ${g.class} | ${g.n} | ${f(g.merge_rate)} | ${f(g.conformance_rate)} (${g.reviewed}) | ${f(g.post_merge_rework_rate)} (${g.merges}) | ${f(g.first_pass_yield)} | ${f(g.cost_usd)} |`);
|
|
146
|
+
return `| Tier | Class | n | Merge rate | Conformance rate (reviewed) | Post-merge rework (merges) | First-pass yield | Cost USD |\n| --- | --- | --- | --- | --- | --- | --- | --- |\n${rows.length ? rows.join("\n") : "| none | | 0 | | | | | |"}\n\n${rep?.unrouted ?? 0} finished run(s) without a builder route (before #4394) left out.`;
|
|
147
|
+
}
|