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
|
@@ -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).
|
|
@@ -56,7 +58,7 @@ export function summariseLog(events, fallbackIssue = null) {
|
|
|
56
58
|
const usage = ev.filter((e) => e.event === "PHASE_ATTEMPT_USAGE");
|
|
57
59
|
const builderCalls = usage.filter((e) => e.phase === "implement");
|
|
58
60
|
const reviewCalls = usage.filter((e) => e.phase === "review").length;
|
|
59
|
-
const contextPack = { mode: term?.context_pack ?? cp?.mode ?? "off", laneType: term?.lane_type ?? cp?.lane_type ?? null };
|
|
61
|
+
const contextPack = { mode: term?.context_pack ?? cp?.mode ?? "off", laneType: term?.lane_type ?? cp?.lane_type ?? null, ab: (term?.context_ab ?? cp?.context_ab) === true };
|
|
60
62
|
const startTs = start.ts ?? null;
|
|
61
63
|
const endTs = finished ? ev[t].ts ?? null : null;
|
|
62
64
|
const a = Date.parse(startTs ?? ""); const b = Date.parse(endTs ?? "");
|
|
@@ -68,25 +70,160 @@ 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
|
+
// #4418: the explorer A/B tag (null before #4391 wrote it) and the main-session (plan + build) cache-read
|
|
80
|
+
explore: term?.explore === "on" || term?.explore === "off" ? term.explore : null,
|
|
81
|
+
builderCacheRead: builderCalls.reduce((x, e) => x + n(e.usage?.cache_read_input_tokens), 0),
|
|
82
|
+
size: term?.turns?.size ?? null, // #4409: S|M|L from the lane's turn record, buckets the admission estimate history
|
|
83
|
+
// #4444: cache-read alone (per-merge A/B metric) and the implement attempt (builder calls other than the plan call)
|
|
84
|
+
cacheReadTokens: usage.reduce((x, e) => x + n(e.usage?.cache_read_input_tokens), 0),
|
|
85
|
+
implementAttempt: implementAttemptOf(builderCalls),
|
|
86
|
+
// #4444: the implement attempt of the earliest finished first/child run in this log, the same-issue baseline of a rerun
|
|
87
|
+
firstRunAttempt: firstRunAttemptOf(events.slice(0, s)),
|
|
72
88
|
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
|
|
89
|
+
costUsd: usage.reduce((x, e) => x + n(costOf(e)), 0),
|
|
90
|
+
// #4394: the builder tier (RUN_TERMINAL builder_*, else the BUILDER_ROUTE / BUILDER_ESCALATED events); null before routing
|
|
91
|
+
builderRoute: routeOf(term, ev),
|
|
92
|
+
conformanceRound: builderCalls.some((e) => e.trigger === "conformance" || e.trigger === "review"),
|
|
93
|
+
shadowWould: typeof term?.shadow_would === "string" ? term.shadow_would : null,
|
|
94
|
+
reachedReview: reviewCalls > 0 || typeof term?.shadow_would === "string",
|
|
95
|
+
// #4394: routes of earlier runs in this log that ended merged: a later run of the same issue is post-merge rework
|
|
96
|
+
reworkedMerges: earlierMergedRoutes(events.slice(0, s)),
|
|
74
97
|
// first pass: merged off one plan + one implement call, at most one review, no CI fix round
|
|
75
98
|
firstPass: finished && term?.reason === "merged" && builderCalls.length <= 2 && reviewCalls <= 1 && !ev.some((e) => e.event === "CI_FIX_ROUND"),
|
|
99
|
+
// #4436: the lane profile (#4395) of this slice's sessions ("mixed" when they differ, null before the field) and the
|
|
100
|
+
// fresh sessions' first-turn baseline_tokens per phase (resumed sessions carry none)
|
|
101
|
+
laneProfile: profileOf(usage),
|
|
102
|
+
baselines: usage.reduce((acc, e) => { if (Number.isFinite(e.baseline_tokens)) (acc[e.phase ?? "other"] ??= []).push(e.baseline_tokens); return acc; }, {}),
|
|
76
103
|
};
|
|
77
104
|
}
|
|
78
105
|
|
|
79
|
-
/**
|
|
80
|
-
|
|
106
|
+
/** #4436: the one lane_profile of the usage records, "mixed" when they differ, null when none carries it. */
|
|
107
|
+
function profileOf(usage) {
|
|
108
|
+
const ps = new Set(usage.map((e) => e.lane_profile).filter((p) => typeof p === "string" && p));
|
|
109
|
+
return ps.size === 0 ? null : ps.size === 1 ? [...ps][0] : "mixed";
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** #4394: {tier, class, escalated, model} of a lane, or null when it computed no builder route. */
|
|
113
|
+
function routeOf(term, ev) {
|
|
114
|
+
if (typeof term?.builder_tier === "string") {
|
|
115
|
+
return { tier: term.builder_tier, class: term.builder_class ?? null, escalated: term.builder_escalated === true, model: term.builder_model ?? null };
|
|
116
|
+
}
|
|
117
|
+
const r = [...ev].reverse().find((e) => e.event === "BUILDER_ROUTE");
|
|
118
|
+
if (!r) return null;
|
|
119
|
+
return { tier: r.tier ?? null, class: r.class ?? null, escalated: ev.some((e) => e.event === "BUILDER_ESCALATED"), model: r.model ?? null };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** #4444: {turns, output_tokens} of the builder calls other than the plan call; null when there is none. */
|
|
123
|
+
function implementAttemptOf(builderCalls) {
|
|
124
|
+
const traced = builderCalls.some((e) => typeof e.trigger === "string");
|
|
125
|
+
const calls = traced ? builderCalls.filter((e) => e.trigger !== "plan") : builderCalls.slice(1);
|
|
126
|
+
if (!calls.length) return null;
|
|
127
|
+
return { turns: calls.reduce((x, e) => x + n(e.turns), 0), output_tokens: calls.reduce((x, e) => x + n(e.usage?.output_tokens), 0) };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** #4444: implementAttemptOf for the earliest finished RUN_START slice of `events` whose lane type was first or child. */
|
|
131
|
+
function firstRunAttemptOf(events) {
|
|
132
|
+
let slice = null;
|
|
133
|
+
const slices = [];
|
|
134
|
+
for (const e of events) {
|
|
135
|
+
if (e.event === "RUN_START") { slice = []; slices.push(slice); }
|
|
136
|
+
if (slice) slice.push(e);
|
|
137
|
+
}
|
|
138
|
+
for (const sl of slices) {
|
|
139
|
+
const term = [...sl].reverse().find((e) => e.event === "RUN_TERMINAL");
|
|
140
|
+
if (!["first", "child"].includes(term?.lane_type)) continue;
|
|
141
|
+
const att = implementAttemptOf(sl.filter((e) => e.event === "PHASE_ATTEMPT_USAGE" && e.phase === "implement"));
|
|
142
|
+
if (att) return att;
|
|
143
|
+
}
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** #4394: the routes ({tier, class, escalated, model}) of the RUN_START slices in `events` that ended merged with a route. */
|
|
148
|
+
function earlierMergedRoutes(events) {
|
|
149
|
+
const out = [];
|
|
150
|
+
let slice = null;
|
|
151
|
+
const close = () => {
|
|
152
|
+
if (!slice) return;
|
|
153
|
+
const term = [...slice].reverse().find((e) => e.event === "RUN_TERMINAL");
|
|
154
|
+
const rt = term?.reason === "merged" ? routeOf(term, slice) : null;
|
|
155
|
+
if (rt) out.push(rt);
|
|
156
|
+
};
|
|
157
|
+
for (const e of events) {
|
|
158
|
+
if (e.event === "RUN_START") { close(); slice = []; }
|
|
159
|
+
if (slice) slice.push(e);
|
|
160
|
+
}
|
|
161
|
+
close();
|
|
162
|
+
return out;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* All run summaries in `dir` (a missing dir or an unreadable log yields nothing, never an error). One per log (its last
|
|
167
|
+
* RUN_START slice); with `allSlices` (#4436) one per RUN_START slice, so a before and an after run of one issue both count.
|
|
168
|
+
*/
|
|
169
|
+
export function readLocalRuns(dir = runsDir(), { allSlices = false } = {}) {
|
|
81
170
|
if (!existsSync(dir)) return [];
|
|
82
171
|
const runs = [];
|
|
83
172
|
for (const f of readdirSync(dir).sort()) {
|
|
84
173
|
if (!f.endsWith(".jsonl")) continue;
|
|
85
174
|
try {
|
|
86
|
-
const m =
|
|
87
|
-
const
|
|
88
|
-
|
|
175
|
+
const m = /(?:^|__)(\d+)\.jsonl$/.exec(f); // #4432: `<issue>.jsonl` or `<owner>__<repo>__<issue>.jsonl`
|
|
176
|
+
const events = parseLog(readFileSync(join(dir, f), "utf8"));
|
|
177
|
+
const starts = allSlices ? events.flatMap((e, i) => (e.event === "RUN_START" ? [i] : [])) : [events.length];
|
|
178
|
+
const mtimeTs = new Date(statSync(join(dir, f)).mtimeMs).toISOString();
|
|
179
|
+
starts.forEach((_, k) => {
|
|
180
|
+
const sum = summariseLog(allSlices ? events.slice(0, starts[k + 1] ?? events.length) : events, m ? Number(m[1]) : null);
|
|
181
|
+
if (sum) runs.push({ ...sum, mtimeTs });
|
|
182
|
+
});
|
|
89
183
|
} catch { /* unreadable log */ }
|
|
90
184
|
}
|
|
91
185
|
return runs;
|
|
92
186
|
}
|
|
187
|
+
|
|
188
|
+
/** #4418: every RUN_START slice (readLocalRuns allSlices), so an on run and an off run of the same issue both count. */
|
|
189
|
+
export const readLocalLaneRuns = (dir = runsDir()) => readLocalRuns(dir, { allSlices: true });
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* forge#4354: one lane per RUN_START..next-RUN_START slice (every slice, not only the last), with the fail-soft events the
|
|
193
|
+
* alert pass rates: DEPS_PREPARED rows, TOOLCHAIN_WARNING texts, deferred-to-ci precheck commands and the terminal code.
|
|
194
|
+
*/
|
|
195
|
+
export function lanesOfLog(events, fallbackIssue = null, fallbackTs = null) {
|
|
196
|
+
const lanes = [];
|
|
197
|
+
let cur = null;
|
|
198
|
+
for (const e of events) {
|
|
199
|
+
if (e.event === "RUN_START") {
|
|
200
|
+
cur = { repo: e.repo ?? null, issue: e.issue ?? fallbackIssue, startTs: e.ts ?? fallbackTs, code: null, reason: null,
|
|
201
|
+
deps: [], toolchainWarnings: [], deferred: [], sparseFallback: null };
|
|
202
|
+
lanes.push(cur);
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
if (!cur) continue;
|
|
206
|
+
if (e.event === "RUN_TERMINAL") { cur.code = e.code ?? null; cur.reason = e.reason ?? null; }
|
|
207
|
+
else if (e.event === "DEPS_PREPARED") cur.deps.push({ eco: e.eco ?? null, status: e.status ?? null, reason: e.reason ?? null });
|
|
208
|
+
else if (e.event === "TOOLCHAIN_WARNING" && e.warning) cur.toolchainWarnings.push(String(e.warning));
|
|
209
|
+
else if (e.event === "PRECHECK_COMMANDS" && Array.isArray(e.results)) {
|
|
210
|
+
for (const r of e.results) if (r?.status === "deferred-to-ci") cur.deferred.push(String(r.command ?? ""));
|
|
211
|
+
} else if (e.event === "WORKTREE_SPARSE_FALLBACK") cur.sparseFallback = e.reason ?? "";
|
|
212
|
+
}
|
|
213
|
+
return lanes;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** All lanes in `dir` (a missing dir or an unreadable log yields nothing, never an error). Local files only, no network. */
|
|
217
|
+
export function readLanes(dir = runsDir()) {
|
|
218
|
+
if (!existsSync(dir)) return [];
|
|
219
|
+
const lanes = [];
|
|
220
|
+
for (const f of readdirSync(dir).sort()) {
|
|
221
|
+
if (!f.endsWith(".jsonl")) continue;
|
|
222
|
+
try {
|
|
223
|
+
const m = /(?:^|__)(\d+)\.jsonl$/.exec(f); // #4432: `<issue>.jsonl` or `<owner>__<repo>__<issue>.jsonl`
|
|
224
|
+
const mtime = new Date(statSync(join(dir, f)).mtimeMs).toISOString();
|
|
225
|
+
lanes.push(...lanesOfLog(parseLog(readFileSync(join(dir, f), "utf8")), m ? Number(m[1]) : null, mtime));
|
|
226
|
+
} catch { /* unreadable log */ }
|
|
227
|
+
}
|
|
228
|
+
return lanes;
|
|
229
|
+
}
|
package/bin/stats/query.mjs
CHANGED
|
@@ -85,15 +85,193 @@ export function contextPackAb(runs) {
|
|
|
85
85
|
for (const mode of ["on", "off", "shadow"]) {
|
|
86
86
|
const rs = runs.filter((r) => r?.finished && r.contextPack?.laneType === lt && r.contextPack?.mode === mode);
|
|
87
87
|
out[lt][mode] = { n: rs.length, first_pass_yield: mean(rs.map((r) => (r.firstPass ? 1 : 0))), builder_turns: mean(rs.map((r) => r.builderTurns ?? 0)),
|
|
88
|
-
cache_tokens: mean(rs.map((r) => r.cacheTokens ?? 0)), cost_usd: mean(rs.map((r) => r.costUsd ?? 0)) };
|
|
88
|
+
cache_tokens: mean(rs.map((r) => r.cacheTokens ?? 0)), cost_usd: mean(rs.map((r) => r.costUsd ?? 0)), ...perMerge(rs, lt) };
|
|
89
89
|
}
|
|
90
90
|
}
|
|
91
|
+
out.recovery = recoveryVsFirst(runs);
|
|
91
92
|
return out;
|
|
92
93
|
}
|
|
93
94
|
|
|
95
|
+
const round4 = (v) => Math.round(v * 1e4) / 1e4;
|
|
96
|
+
/**
|
|
97
|
+
* #4444: per-merge A/B metrics of one cell: builder turns and cache-read over the cell's runs divided by its merges (null
|
|
98
|
+
* without a merge), and rework rate: runs that were remediated, followed an earlier merge, or are a rerun lane, over n.
|
|
99
|
+
*/
|
|
100
|
+
function perMerge(rs, lt) {
|
|
101
|
+
const merged = rs.filter((r) => r.reason === "merged").length;
|
|
102
|
+
const sum = (f) => rs.reduce((x, r) => x + (Number.isFinite(f(r)) ? f(r) : 0), 0);
|
|
103
|
+
const reworked = rs.filter((r) => r.remediated || (r.reworkedMerges?.length ?? 0) > 0 || lt === "rerun").length;
|
|
104
|
+
return { merged, turns_per_merge: merged ? round4(sum((r) => r.builderTurns) / merged) : null,
|
|
105
|
+
cache_read_per_merge: merged ? round4(sum((r) => r.cacheReadTokens) / merged) : null, rework_rate: rs.length ? round4(reworked / rs.length) : null };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* #4444 (R8): a rerun lane's implement attempt against the first run on the same issue. The baseline is the earlier
|
|
110
|
+
* first/child slice of the rerun's own log (firstRunAttempt), else the earliest finished first/child run of the same
|
|
111
|
+
* repo+issue in `runs`. Ratios are rerun / first per pair (a zero baseline is skipped); null without a pair.
|
|
112
|
+
*/
|
|
113
|
+
export function recoveryVsFirst(runs) {
|
|
114
|
+
const key = (r) => `${String(r?.repo ?? "").toLowerCase()}#${r?.issue}`;
|
|
115
|
+
const firsts = new Map();
|
|
116
|
+
for (const r of [...runs].filter((x) => x?.finished && ["first", "child"].includes(x.contextPack?.laneType) && x.implementAttempt)
|
|
117
|
+
.sort((a, b) => String(a.startTs ?? "").localeCompare(String(b.startTs ?? "")))) if (!firsts.has(key(r))) firsts.set(key(r), r.implementAttempt);
|
|
118
|
+
const turnR = [], outR = [];
|
|
119
|
+
for (const r of runs) {
|
|
120
|
+
if (!r?.finished || r.contextPack?.laneType !== "rerun" || !r.implementAttempt) continue;
|
|
121
|
+
const base = r.firstRunAttempt ?? firsts.get(key(r));
|
|
122
|
+
if (!base) continue;
|
|
123
|
+
if (base.turns > 0) turnR.push(r.implementAttempt.turns / base.turns);
|
|
124
|
+
if (base.output_tokens > 0) outR.push(r.implementAttempt.output_tokens / base.output_tokens);
|
|
125
|
+
}
|
|
126
|
+
const mean = (xs) => (xs.length ? round4(xs.reduce((a, b) => a + b, 0) / xs.length) : null);
|
|
127
|
+
return { pairs: Math.max(turnR.length, outR.length), mean_turn_ratio: mean(turnR), mean_output_ratio: mean(outR) };
|
|
128
|
+
}
|
|
129
|
+
|
|
94
130
|
/** Markdown table for contextPackAb. */
|
|
95
131
|
export function renderContextAb(ab) {
|
|
96
132
|
const f = (v) => (v == null ? "n/a" : String(v));
|
|
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
|
-
|
|
133
|
+
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)} | ${f(g.merged)} | ${f(g.turns_per_merge)} | ${f(g.cache_read_per_merge)} | ${f(g.rework_rate)} |`; }));
|
|
134
|
+
const rc = ab.recovery; // #4444: R8, rerun implement attempt vs the first run on the same issue
|
|
135
|
+
const rec = rc ? `\n\nRecovery vs first run (same issue): ${rc.pairs} pair(s), implement turns ratio ${f(rc.mean_turn_ratio)}, output tokens ratio ${f(rc.mean_output_ratio)}` : "";
|
|
136
|
+
return `| Lane type | Mode | n | First-pass yield | Builder turns | Cache tokens | Cost USD | Merged | Turns/merge | Cache-read/merge | Rework rate |\n| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |\n${rows.join("\n")}${rec}`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* forge#4394: builder model tiers over local run summaries (local-source.mjs summariseLog): tier mid, mid->top
|
|
141
|
+
* (escalated) or top, each with its routing class. merge_rate: merged / finished. conformance_rate: of the lanes that
|
|
142
|
+
* reached review, those with no conformance/review fix round and no shadow would-block. post_merge_rework_rate: merges
|
|
143
|
+
* reworked later (a later run of the same issue, or a remediate phase) / merges, where a log's earlier merged runs count
|
|
144
|
+
* as merges too. Runs without a route (older logs, refactor:split) are counted under `unrouted` only.
|
|
145
|
+
*/
|
|
146
|
+
export const BUILDER_TIERS = Object.freeze(["mid", "mid->top", "top"]);
|
|
147
|
+
export function builderTierReport(runs) {
|
|
148
|
+
const mean = (xs) => (xs.length ? Math.round((xs.reduce((a, b) => a + b, 0) / xs.length) * 1e4) / 1e4 : null);
|
|
149
|
+
const rate = (k, n) => (n ? Math.round((k / n) * 1e4) / 1e4 : null);
|
|
150
|
+
const tierOf = (rt) => (rt?.tier === "mid" ? (rt.escalated ? "mid->top" : "mid") : rt?.tier === "top" ? "top" : null);
|
|
151
|
+
const groups = new Map();
|
|
152
|
+
const group = (tier, cls) => {
|
|
153
|
+
const k = `${tier}\u0000${cls}`;
|
|
154
|
+
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: [] });
|
|
155
|
+
return groups.get(k);
|
|
156
|
+
};
|
|
157
|
+
let unrouted = 0;
|
|
158
|
+
for (const r of runs ?? []) {
|
|
159
|
+
for (const m of Array.isArray(r?.reworkedMerges) ? r.reworkedMerges : []) {
|
|
160
|
+
const t = tierOf(m);
|
|
161
|
+
if (t) { const g = group(t, m.class ?? "unknown"); g.merges++; g.reworked++; }
|
|
162
|
+
}
|
|
163
|
+
if (!r?.finished) continue;
|
|
164
|
+
const t = tierOf(r.builderRoute);
|
|
165
|
+
if (!t) { unrouted++; continue; }
|
|
166
|
+
const g = group(t, r.builderRoute.class ?? "unknown");
|
|
167
|
+
g.n++;
|
|
168
|
+
g.cost.push(Number.isFinite(r.costUsd) ? r.costUsd : 0);
|
|
169
|
+
if (r.firstPass) g.firstPass++;
|
|
170
|
+
if (r.reason === "merged") { g.merged++; g.merges++; if (r.remediated) g.reworked++; }
|
|
171
|
+
if (r.reachedReview) { g.reviewed++; if (!r.conformanceRound && (r.shadowWould == null || r.shadowWould === "conforms")) g.conforming++; }
|
|
172
|
+
}
|
|
173
|
+
const rows = [...groups.values()]
|
|
174
|
+
.sort((a, b) => BUILDER_TIERS.indexOf(a.tier) - BUILDER_TIERS.indexOf(b.tier) || a.class.localeCompare(b.class))
|
|
175
|
+
.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,
|
|
176
|
+
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) }));
|
|
177
|
+
return { rows, unrouted };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Markdown table for builderTierReport. */
|
|
181
|
+
export function renderTiers(rep) {
|
|
182
|
+
const f = (v) => (v == null ? "n/a" : String(v));
|
|
183
|
+
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)} |`);
|
|
184
|
+
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.`;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* forge#4418: the explorer A/B (#4391) over per-slice run summaries (local-source.mjs readLocalLaneRuns), per size bucket
|
|
189
|
+
* (RUN_TERMINAL turns.size, set before the build) and RUN_TERMINAL `explore` mode. builder_turns / builder_cache_read:
|
|
190
|
+
* means over the plan + build calls. merge_rate: merged / n. conformance_rate: as builderTierReport. `paired` keeps only
|
|
191
|
+
* issues with a finished run in both modes. Untagged runs (before #4391) are counted, never folded into `off`.
|
|
192
|
+
*/
|
|
193
|
+
export const EXPLORE_SIZES = Object.freeze(["S", "M", "L", "unsized"]);
|
|
194
|
+
const EXPLORE_MODES = ["on", "off"];
|
|
195
|
+
export function exploreAb(runs) {
|
|
196
|
+
const mean = (xs) => (xs.length ? Math.round((xs.reduce((a, b) => a + b, 0) / xs.length) * 1e4) / 1e4 : null);
|
|
197
|
+
const rate = (k, n) => (n ? Math.round((k / n) * 1e4) / 1e4 : null);
|
|
198
|
+
const finished = (runs ?? []).filter((r) => r?.finished);
|
|
199
|
+
const tagged = finished.filter((r) => EXPLORE_MODES.includes(r.explore));
|
|
200
|
+
const stats = (rs) => {
|
|
201
|
+
const reviewed = rs.filter((r) => r.reachedReview);
|
|
202
|
+
const conforming = reviewed.filter((r) => !r.conformanceRound && (r.shadowWould == null || r.shadowWould === "conforms"));
|
|
203
|
+
return { n: rs.length, builder_turns: mean(rs.map((r) => r.builderTurns ?? 0)), builder_cache_read: mean(rs.map((r) => r.builderCacheRead ?? 0)),
|
|
204
|
+
merge_rate: rate(rs.filter((r) => r.reason === "merged").length, rs.length), conformance_rate: rate(conforming.length, reviewed.length), reviewed: reviewed.length };
|
|
205
|
+
};
|
|
206
|
+
const bySize = {};
|
|
207
|
+
for (const size of EXPLORE_SIZES) {
|
|
208
|
+
bySize[size] = {};
|
|
209
|
+
for (const mode of EXPLORE_MODES) bySize[size][mode] = stats(tagged.filter((r) => (EXPLORE_SIZES.includes(r.size) ? r.size : "unsized") === size && r.explore === mode));
|
|
210
|
+
}
|
|
211
|
+
const key = (r) => `${r.repo ?? ""}#${r.issue}`;
|
|
212
|
+
const both = new Set(tagged.filter((r) => r.explore === "on").map(key).filter((k) => tagged.some((r) => r.explore === "off" && key(r) === k)));
|
|
213
|
+
const pairedRuns = tagged.filter((r) => both.has(key(r)));
|
|
214
|
+
const paired = { issues: both.size };
|
|
215
|
+
for (const mode of EXPLORE_MODES) paired[mode] = stats(pairedRuns.filter((r) => r.explore === mode));
|
|
216
|
+
return { by_size: bySize, paired, untagged: finished.length - tagged.length };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Markdown tables for exploreAb. */
|
|
220
|
+
export function renderExploreAb(ab) {
|
|
221
|
+
const f = (v) => (v == null ? "n/a" : String(v));
|
|
222
|
+
const row = (label, mode, g) => `| ${label} | ${mode} | ${g.n} | ${f(g.builder_turns)} | ${f(g.builder_cache_read)} | ${f(g.merge_rate)} | ${f(g.conformance_rate)} (${g.reviewed}) |`;
|
|
223
|
+
const head = "| Size | Explore | n | Builder turns | Builder cache-read | Merge rate | Conformance rate (reviewed) |\n| --- | --- | --- | --- | --- | --- | --- |";
|
|
224
|
+
const sizes = EXPLORE_SIZES.flatMap((s) => EXPLORE_MODES.map((m) => row(s, m, ab.by_size[s][m])));
|
|
225
|
+
const paired = EXPLORE_MODES.map((m) => row("paired", m, ab.paired[m]));
|
|
226
|
+
return `${head}\n${sizes.join("\n")}\n\nSame issues in both modes: ${ab.paired.issues}\n\n${head}\n${paired.join("\n")}\n\n${ab.untagged} finished run(s) without an explore tag (before #4391) left out.`;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* forge#4436: the #4395 lane baseline before/after, over local run summaries (every RUN_START slice). Finished runs,
|
|
231
|
+
* optionally pinned to an issue set, grouped by lane profile (null → unknown). Per profile and phase: fresh-session
|
|
232
|
+
* first-turn baseline_tokens (sessions, median, mean). Per profile: conformance_rate (the builderTierReport rule) and
|
|
233
|
+
* shadow_blocks (reviewed runs with a would-block verdict). issues_in_all: issues with a finished run under every profile.
|
|
234
|
+
*/
|
|
235
|
+
export const LANE_PROFILES = Object.freeze(["full", "memory-off", "lane", "mixed", "unknown"]);
|
|
236
|
+
export function laneProfileReport(runs, { issues = null } = {}) {
|
|
237
|
+
const pin = Array.isArray(issues) && issues.length ? new Set(issues) : null;
|
|
238
|
+
const rate = (k, n) => (n ? Math.round((k / n) * 1e4) / 1e4 : null);
|
|
239
|
+
const groups = new Map();
|
|
240
|
+
for (const r of runs ?? []) {
|
|
241
|
+
if (!r?.finished || (pin && !pin.has(r.issue))) continue;
|
|
242
|
+
const name = typeof r.laneProfile === "string" && r.laneProfile ? r.laneProfile : "unknown";
|
|
243
|
+
const g = groups.get(name) ?? { profile: name, n: 0, issues: new Set(), reviewed: 0, conforming: 0, shadow_blocks: 0, phases: {} };
|
|
244
|
+
groups.set(name, g);
|
|
245
|
+
g.n++;
|
|
246
|
+
if (r.issue != null) g.issues.add(r.issue);
|
|
247
|
+
if (r.reachedReview) {
|
|
248
|
+
g.reviewed++;
|
|
249
|
+
if (!r.conformanceRound && (r.shadowWould == null || r.shadowWould === "conforms")) g.conforming++;
|
|
250
|
+
if (typeof r.shadowWould === "string" && r.shadowWould !== "conforms") g.shadow_blocks++;
|
|
251
|
+
}
|
|
252
|
+
for (const [phase, xs] of Object.entries(r.baselines ?? {})) (g.phases[phase] ??= []).push(...xs.filter(Number.isFinite));
|
|
253
|
+
}
|
|
254
|
+
const stat = (xs) => {
|
|
255
|
+
if (!xs.length) return { median: null, mean: null };
|
|
256
|
+
const s = [...xs].sort((a, b) => a - b), m = s.length >> 1;
|
|
257
|
+
return { median: Math.round(s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2), mean: Math.round(s.reduce((a, b) => a + b, 0) / s.length) };
|
|
258
|
+
};
|
|
259
|
+
const rank = (p) => { const i = LANE_PROFILES.indexOf(p); return i < 0 ? LANE_PROFILES.length : i; };
|
|
260
|
+
const profiles = [...groups.values()].sort((a, b) => rank(a.profile) - rank(b.profile) || a.profile.localeCompare(b.profile))
|
|
261
|
+
.map((g) => ({ profile: g.profile, n: g.n, issues: [...g.issues].sort((a, b) => a - b), reviewed: g.reviewed, conformance_rate: rate(g.conforming, g.reviewed),
|
|
262
|
+
shadow_blocks: g.shadow_blocks, phases: Object.keys(g.phases).sort().map((phase) => ({ phase, sessions: g.phases[phase].length, ...stat(g.phases[phase]) })) }));
|
|
263
|
+
const issuesInAll = profiles.length ? profiles[0].issues.filter((i) => profiles.every((p) => p.issues.includes(i))) : [];
|
|
264
|
+
return { profiles, issues_in_all: issuesInAll };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Markdown tables for laneProfileReport. */
|
|
268
|
+
export function renderLaneProfiles(rep) {
|
|
269
|
+
const f = (v) => (v == null ? "n/a" : String(v));
|
|
270
|
+
const ps = rep?.profiles ?? [];
|
|
271
|
+
const base = ps.flatMap((p) => (p.phases.length ? p.phases : [{ phase: "n/a", sessions: 0, median: null, mean: null }])
|
|
272
|
+
.map((x) => `| ${p.profile} | ${x.phase} | ${x.sessions} | ${f(x.median)} | ${f(x.mean)} |`));
|
|
273
|
+
const conf = ps.map((p) => `| ${p.profile} | ${p.n} | ${f(p.conformance_rate)} (${p.reviewed}) | ${p.shadow_blocks} |`);
|
|
274
|
+
return `| Profile | Phase | Sessions | Median baseline | Mean baseline |\n| --- | --- | --- | --- | --- |\n${base.length ? base.join("\n") : "| none | | 0 | | |"}\n\n`
|
|
275
|
+
+ `| Profile | Runs | Conformance rate (reviewed) | Shadow would-block |\n| --- | --- | --- | --- |\n${conf.length ? conf.join("\n") : "| none | 0 | | |"}\n\n`
|
|
276
|
+
+ `Issues with a finished run under every profile: ${rep?.issues_in_all?.length ? rep.issues_in_all.map((i) => `#${i}`).join(", ") : "none"}`;
|
|
99
277
|
}
|
package/bin/stats/render.mjs
CHANGED
|
@@ -41,7 +41,16 @@ function sections(g) {
|
|
|
41
41
|
out.push("### Flow", ...table(["metric", "value"], [
|
|
42
42
|
["merged", pct(f.mergedPct)], ["needs-human", pct(f.needsHumanPct)], ["remediation rate", pct(f.remediationPct)],
|
|
43
43
|
["top outcome codes (n=" + f.codesN + ")", f.codes.slice(0, 5).map((x) => `${x.code} ${x.count} (${pct(x.pct)})`).join(", ") || NA],
|
|
44
|
+
...(g.firstPass ? [["first-pass rate", `${pct(g.firstPass.rate)} (${g.firstPass.firstPass}/${g.firstPass.n} lanes that reached the build, ${g.firstPass.unknown} unknown)`]] : []),
|
|
45
|
+
// #4375: the same rate split by whether the context pack carried pitfalls matching the issue's files
|
|
46
|
+
...(g.firstPassByPitfalls && (g.firstPassByPitfalls.with.n || g.firstPassByPitfalls.without.n)
|
|
47
|
+
? [["with", "without"].map((k) => [`first-pass ${k} matching pitfalls`, `${pct(g.firstPassByPitfalls[k].rate)} (${g.firstPassByPitfalls[k].firstPass}/${g.firstPassByPitfalls[k].n})`])].flat() : []),
|
|
44
48
|
]), "");
|
|
49
|
+
if (g.rework) { // #4362: builder rework rounds by trigger; untagged (older) attempts are "unknown"
|
|
50
|
+
const w = g.rework, rows = Object.entries(w.byTrigger).sort((a, b) => b[1].usd - a[1].usd || a[0].localeCompare(b[0]));
|
|
51
|
+
out.push("### Rework by trigger", ...(rows.length ? table(["trigger", "count", "cost"], [...rows.map(([t, d]) => [t, String(d.n), usd(d.usd)]), ["total", String(w.n), usd(w.usd)]])
|
|
52
|
+
: ["No rework attempts."]), "");
|
|
53
|
+
}
|
|
45
54
|
const r = g.funnel;
|
|
46
55
|
const sev = Object.entries(r.bySeverity).map(([s, d]) => [s, String(d.total), f1(d.perReview), String(d.ISSUE), String(d.NOTE), String(d.INPR_FIX)]);
|
|
47
56
|
out.push("### Review funnel", `${r.reviews} reviews, ${r.findings} findings (${f1(r.perReview)} per review). Validated issues: ${r.validated}, false positives: ${r.falsePositive}.`, "",
|
|
@@ -68,6 +77,7 @@ export function renderMarkdown(report) {
|
|
|
68
77
|
const out = ["# ForgeDock stats", ""];
|
|
69
78
|
if (!report.groups.length) out.push("No data: no finished runs, telemetry records or issues were found.", "");
|
|
70
79
|
for (const g of report.groups) out.push(...sections(g));
|
|
80
|
+
if (report.alerts?.length) out.push("## ALERTS", "", ...renderAlerts(report.alerts).map((l) => `- ${l}`), "");
|
|
71
81
|
const e = report.excluded;
|
|
72
82
|
out.push(`Finished runs only. Excluded: ${e.unfinished} unfinished, ${e.noUsage} without usage data (hydrated or empty). "~" = version estimated from release times; "unknown" = no version available. Groups with n < ${report.minN} are flagged low-n.`);
|
|
73
83
|
return out.join("\n");
|
|
@@ -80,3 +90,15 @@ export function renderCompact(report) {
|
|
|
80
90
|
pct(g.flow.remediationPct), dur(g.time.wall.p50), f1(g.funnel.perReview), pct(g.gates.acceptance.rate), g.delta ? signed(g.delta.cacheReadMean, k) : NA, g.lowN ? `low-n (<${g.minN})` : "ok"]);
|
|
81
91
|
return table(["Version", "Repo", "n", "Cache-read/issue", "Merged", "Needs-human", "Remediated", "Wall p50", "Findings/review", "Acceptance block", "Delta tokens", "Confidence"], rows).join("\n");
|
|
82
92
|
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* forge#4354: one line per fail-soft alert: event, rate, count/sample lanes, threshold and one example reason. Outcome-code
|
|
96
|
+
* alerts carry the previous-window share and a factor threshold instead of a percentage.
|
|
97
|
+
*/
|
|
98
|
+
export function renderAlerts(alerts) {
|
|
99
|
+
return (alerts ?? []).map((a) => {
|
|
100
|
+
const limit = isNum(a.prevRate) ? `previous window ${pct(a.prevRate)}, threshold ${a.threshold}x` : `threshold ${pct(a.threshold)}`;
|
|
101
|
+
const ex = a.example ? `, e.g. ${JSON.stringify(String(a.example).slice(0, 200))}` : "";
|
|
102
|
+
return `${a.event}: ${pct(a.rate)} (${a.count}/${a.sample} lanes, ${limit})${ex}`;
|
|
103
|
+
});
|
|
104
|
+
}
|