@agjs/tsforge 0.1.13 → 0.1.15

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.
@@ -0,0 +1,129 @@
1
+ // Generate RULES.md: a catalog of all rule packs and meta-rules.
2
+ // This produces a deterministic, human-readable reference of what gets enforced.
3
+ // bun run packages/core/scripts/build-rules-md.ts
4
+ import { join } from "node:path";
5
+ import { RULE_PACKS } from "../src/rule-packs";
6
+ import { META_RULES } from "../src/meta-rules";
7
+
8
+ function getRuleDescription(obj: unknown): string | undefined {
9
+ const isObject = (val: unknown): val is Record<string, unknown> =>
10
+ val !== null && typeof val === "object";
11
+
12
+ if (!isObject(obj)) {
13
+ return undefined;
14
+ }
15
+
16
+ const meta = obj.meta;
17
+
18
+ if (!isObject(meta)) {
19
+ return undefined;
20
+ }
21
+
22
+ const docs = meta.docs;
23
+
24
+ if (!isObject(docs)) {
25
+ return undefined;
26
+ }
27
+
28
+ const description = docs.description;
29
+
30
+ return typeof description === "string" ? description : undefined;
31
+ }
32
+
33
+ const out: string[] = [
34
+ "# Rules and Meta-Rules Catalog",
35
+ "",
36
+ "This document lists all rules enforced by tsforge across rule packs and meta-rules.",
37
+ "",
38
+ ];
39
+
40
+ // Section: Rule Packs
41
+ out.push("## Rule Packs");
42
+ out.push("");
43
+
44
+ type PackId = keyof typeof RULE_PACKS;
45
+
46
+ function isPackId(id: string): id is PackId {
47
+ return id in RULE_PACKS;
48
+ }
49
+
50
+ const packIds = Object.keys(RULE_PACKS).sort();
51
+
52
+ for (const packId of packIds) {
53
+ if (!isPackId(packId)) {
54
+ continue;
55
+ }
56
+
57
+ const pack = RULE_PACKS[packId];
58
+
59
+ out.push(`### ${packId}`);
60
+ out.push("");
61
+ out.push(pack.description);
62
+ out.push("");
63
+
64
+ const ruleNames = Object.keys(pack.rules).sort();
65
+
66
+ for (const ruleName of ruleNames) {
67
+ const rule = pack.rules[ruleName];
68
+ const severity = pack.rulesConfig[ruleName] ?? "warn";
69
+ const description = getRuleDescription(rule) ?? ruleName;
70
+ const severityUpper = severity.toUpperCase();
71
+ const line = `- **${ruleName}** [${severityUpper}]: ${description}`;
72
+
73
+ out.push(line);
74
+ }
75
+
76
+ out.push("");
77
+ }
78
+
79
+ // Section: Meta-Rules
80
+ out.push("## Meta-Rules");
81
+ out.push("");
82
+ out.push(
83
+ "Meta-rules enforce project structure and configuration invariants that ESLint cannot express."
84
+ );
85
+ out.push("");
86
+
87
+ const categoryOrder = [
88
+ "supply-chain",
89
+ "config",
90
+ "source-text",
91
+ "testing",
92
+ "stack-layout",
93
+ "ci",
94
+ ] as const;
95
+
96
+ const rulesByCategory = new Map<string, (typeof META_RULES)[number][]>();
97
+
98
+ for (const rule of META_RULES) {
99
+ const cat = rule.category;
100
+ const rules = rulesByCategory.get(cat) ?? [];
101
+
102
+ rules.push(rule);
103
+ rulesByCategory.set(cat, rules);
104
+ }
105
+
106
+ // Render meta-rules by category.
107
+ for (const category of categoryOrder) {
108
+ const rules = rulesByCategory.get(category) ?? [];
109
+
110
+ if (rules.length === 0) {
111
+ continue;
112
+ }
113
+
114
+ out.push(`### ${category}`);
115
+ out.push("");
116
+
117
+ for (const rule of rules.sort((a, b) => a.id.localeCompare(b.id))) {
118
+ out.push(
119
+ `- **${rule.id}** [${rule.severity.toUpperCase()}]: ${rule.description}`
120
+ );
121
+ }
122
+
123
+ out.push("");
124
+ }
125
+
126
+ const path = join(import.meta.dir, "..", "RULES.md");
127
+
128
+ await Bun.write(path, out.join("\n"));
129
+ process.stdout.write(`\nwrote rules catalog → ${path}\n`);
@@ -0,0 +1,203 @@
1
+ // Turn a tsforge CLI `--log` (JSONL) into the metrics the local-Qwen literature
2
+ // says actually predict behavior — tokens-to-solution, repair iterations,
3
+ // hallucinated imports, peak context — measured against the model/window the log
4
+ // records. "Measure, don't assume": many apparent model failures are really
5
+ // quant/config/harness failures, so judge a run by these, not by vibes.
6
+ //
7
+ // Run: bun run packages/core/scripts/cli-metrics.ts [logfile]
8
+ // (no arg → the newest log under ~/.tsforge/logs)
9
+ import { readdir } from "node:fs/promises";
10
+ import { homedir } from "node:os";
11
+ import { join } from "node:path";
12
+ import { isRecord } from "../src/lib/guards";
13
+
14
+ function num(value: unknown): number {
15
+ return typeof value === "number" ? value : 0;
16
+ }
17
+
18
+ function str(value: unknown): string {
19
+ return typeof value === "string" ? value : "";
20
+ }
21
+
22
+ async function newestLog(): Promise<string> {
23
+ const dir = join(process.env.TSFORGE_HOME ?? homedir(), ".tsforge", "logs");
24
+ // Filenames are ISO-timestamp-prefixed, so lexicographic sort = chronological.
25
+ const names = (await readdir(dir)).filter((n) => n.endsWith(".jsonl")).sort();
26
+ const latest = names.at(-1);
27
+
28
+ if (latest === undefined) {
29
+ throw new Error(`no .jsonl logs in ${dir}`);
30
+ }
31
+
32
+ return join(dir, latest);
33
+ }
34
+
35
+ interface IMetrics {
36
+ model: string;
37
+ contextWindow: number;
38
+ finalStatus: string;
39
+ turns: number;
40
+ modelCalls: number;
41
+ tokensOut: number;
42
+ peakContext: number;
43
+ edits: number;
44
+ filesCreated: number;
45
+ gateRuns: number;
46
+ compactions: number;
47
+ buildNudges: number;
48
+ salvaged: number;
49
+ hallucinatedImports: string[];
50
+ wallClockSeconds: number;
51
+ }
52
+
53
+ type EventRecord = Record<string, unknown>;
54
+
55
+ /** One counter per event kind — a flat registry keeps `analyze` branch-free. */
56
+ const HANDLERS: Record<
57
+ string,
58
+ (m: IMetrics, event: EventRecord, created: Set<string>) => void
59
+ > = {
60
+ start: (m, e) => {
61
+ if (str(e.model).length > 0) {
62
+ m.model = str(e.model);
63
+ }
64
+
65
+ if (num(e.contextWindow) > 0) {
66
+ m.contextWindow = num(e.contextWindow);
67
+ }
68
+ },
69
+ cycle: (m) => {
70
+ m.turns += 1;
71
+ },
72
+ usage: (m, e) => {
73
+ m.modelCalls += 1;
74
+ m.tokensOut += num(e.completionTokens);
75
+ m.peakContext = Math.max(m.peakContext, num(e.promptTokens));
76
+ },
77
+ create: (m, e, created) => {
78
+ m.edits += 1;
79
+
80
+ if (str(e.file).length > 0) {
81
+ created.add(str(e.file));
82
+ }
83
+ },
84
+ edit: (m) => {
85
+ m.edits += 1;
86
+ },
87
+ timing: (m, e) => {
88
+ m.wallClockSeconds += Math.round(num(e.ms) / 1000);
89
+ },
90
+ validated: (m) => {
91
+ m.gateRuns += 1;
92
+ },
93
+ done: (m) => {
94
+ m.finalStatus = "done";
95
+ },
96
+ stuck: (m) => {
97
+ m.finalStatus = "stuck";
98
+ },
99
+ tool: (m, e) => {
100
+ const msg = str(e.message);
101
+
102
+ m.compactions += msg.includes("compacted") ? 1 : 0;
103
+ m.buildNudges += msg.includes("nudging") ? 1 : 0;
104
+ m.salvaged += msg.includes("recovered") ? 1 : 0;
105
+ },
106
+ };
107
+
108
+ function emptyMetrics(): IMetrics {
109
+ return {
110
+ model: "?",
111
+ contextWindow: 0,
112
+ finalStatus: "(none)",
113
+ turns: 0,
114
+ modelCalls: 0,
115
+ tokensOut: 0,
116
+ peakContext: 0,
117
+ edits: 0,
118
+ filesCreated: 0,
119
+ gateRuns: 0,
120
+ compactions: 0,
121
+ buildNudges: 0,
122
+ salvaged: 0,
123
+ hallucinatedImports: [],
124
+ wallClockSeconds: 0,
125
+ };
126
+ }
127
+
128
+ function analyze(lines: string[]): IMetrics {
129
+ const created = new Set<string>();
130
+ const m = emptyMetrics();
131
+ let allText = "";
132
+
133
+ for (const line of lines) {
134
+ let event: unknown;
135
+
136
+ try {
137
+ event = JSON.parse(line);
138
+ } catch {
139
+ continue;
140
+ }
141
+
142
+ if (!isRecord(event)) {
143
+ continue;
144
+ }
145
+
146
+ // Concatenate ALL message/output text, then scan once — a "Cannot find
147
+ // module" can be split across streamed token chunks.
148
+ allText += `${str(event.message)}\n${str(event.output)}\n`;
149
+ HANDLERS[str(event.kind)]?.(m, event, created);
150
+ }
151
+
152
+ const hallucinated = new Set<string>();
153
+
154
+ for (const match of allText.matchAll(
155
+ /Cannot find module ['"]([^'"]+)['"]/g
156
+ )) {
157
+ hallucinated.add(match[1] ?? "");
158
+ }
159
+
160
+ m.filesCreated = created.size;
161
+ m.hallucinatedImports = [...hallucinated];
162
+
163
+ return m;
164
+ }
165
+
166
+ async function main(): Promise<void> {
167
+ const path = process.argv[2] ?? (await newestLog());
168
+ const text = await Bun.file(path).text();
169
+ const lines = text.split("\n").filter((l) => l.trim().length > 0);
170
+ const m = analyze(lines);
171
+ const pct =
172
+ m.contextWindow > 0
173
+ ? Math.round((m.peakContext / m.contextWindow) * 100)
174
+ : 0;
175
+ const imports =
176
+ m.hallucinatedImports.length > 0
177
+ ? `${m.hallucinatedImports.length} → ${m.hallucinatedImports.join(", ")}`
178
+ : "0";
179
+
180
+ const rows: [string, string][] = [
181
+ ["log", path],
182
+ ["model", m.model],
183
+ ["context window", String(m.contextWindow)],
184
+ ["final status", m.finalStatus],
185
+ ["turns (repair iterations)", String(m.turns)],
186
+ ["model calls", String(m.modelCalls)],
187
+ ["tokens out (→ solution)", String(m.tokensOut)],
188
+ ["peak context", `${m.peakContext} (${pct}% of window)`],
189
+ ["edits/creates", `${m.edits} (${m.filesCreated} files created)`],
190
+ ["gate runs", String(m.gateRuns)],
191
+ ["auto-compactions", String(m.compactions)],
192
+ ["build nudges", String(m.buildNudges)],
193
+ ["salvaged tool calls", String(m.salvaged)],
194
+ ["hallucinated imports", imports],
195
+ ["wall clock", `${m.wallClockSeconds}s`],
196
+ ];
197
+
198
+ for (const [label, value] of rows) {
199
+ console.log(`${label.padEnd(26)} ${value}`);
200
+ }
201
+ }
202
+
203
+ await main();
@@ -0,0 +1,33 @@
1
+ #!/usr/bin/env bun
2
+ // Gate step (catalog builds): fail unless every declared entity has real UI — a
3
+ // feature folder with a component, not just a `.types.ts`. Wired into the web gate
4
+ // by headless-build so an app can't green with half its entities unbuilt (a run
5
+ // greened with 4 of 8 entities as types-only). Usage:
6
+ // bun coverage-check.ts <buildDir> "<Entity1>" "<Entity2>" ...
7
+ import { uncoveredEntities } from "../src/web-coverage";
8
+
9
+ const [, , dir, ...entities] = process.argv;
10
+
11
+ if (dir === undefined || entities.length === 0) {
12
+ // No entity list → nothing to enforce (ad-hoc build). Pass.
13
+ process.exit(0);
14
+ }
15
+
16
+ const missing = await uncoveredEntities(dir, entities);
17
+
18
+ if (missing.length > 0) {
19
+ const noun = missing.length === 1 ? "entity has" : "entities have";
20
+
21
+ process.stdout.write(
22
+ `coverage: ${String(missing.length)} declared ${noun} NO UI (types only) — ` +
23
+ `build each one's list + create + detail routes and wire a reachable "New" ` +
24
+ `button; the app is NOT done until every entity is reachable. Missing: ` +
25
+ `${missing.join(", ")}\n`
26
+ );
27
+ process.exit(1);
28
+ }
29
+
30
+ process.stdout.write(
31
+ `coverage: all ${String(entities.length)} declared entities have UI\n`
32
+ );
33
+ process.exit(0);
@@ -0,0 +1,314 @@
1
+ // Compare edit mechanisms (edit vs edit_lines) across completed run artifacts.
2
+ // Parses run.log + result.json from sweep runs to measure: tool calls, success rates,
3
+ // stale-anchor recovery, token cost (args size), gate failures, turns to green.
4
+ //
5
+ // Run: bun run packages/core/scripts/edit-benchmark.ts <dir> <dir> ...
6
+ // (analyze run dirs produced with hashline on vs off)
7
+ // Or: bun run packages/core/scripts/edit-benchmark.ts --json <output.json> <dir> <dir> ...
8
+ import { readFile } from "node:fs/promises";
9
+ import { basename, join } from "node:path";
10
+ import { isRecord } from "../src/lib/guards";
11
+
12
+ interface IEditMetrics {
13
+ runId: string;
14
+ hashlineEnabled: boolean;
15
+ editToolCalls: number;
16
+ editLinesToolCalls: number;
17
+ editRejections: number;
18
+ editLinesRejections: number;
19
+ staleAnchorRecoveries: number;
20
+ /** Mean tool-args bytes per successful edit (token-cost proxy). */
21
+ meanToolArgBytes: number;
22
+ gateFails: number;
23
+ /** Turns from start to first GREEN gate result. */
24
+ turnsToGreen: number;
25
+ totalTurns: number;
26
+ passed: boolean;
27
+ quality?: number;
28
+ }
29
+
30
+ interface IEditSummary {
31
+ label: string;
32
+ runs: number;
33
+ avgEditToolCalls: number;
34
+ avgEditLinesToolCalls: number;
35
+ editSuccessRate: number;
36
+ editLinesSuccessRate: number;
37
+ avgStaleAnchorRecoveries: number;
38
+ avgMeanToolArgBytes: number;
39
+ avgGateFails: number;
40
+ avgTurnsToGreen: number;
41
+ passRate: number;
42
+ avgQuality: number;
43
+ }
44
+
45
+ function countPattern(lines: string[], pattern: RegExp): number {
46
+ return lines.filter((line) => pattern.test(line)).length;
47
+ }
48
+
49
+ async function parseRunLog(
50
+ logPath: string
51
+ ): Promise<Omit<IEditMetrics, "runId" | "hashlineEnabled">> {
52
+ const logText = await readFile(logPath, "utf-8");
53
+ const lines = logText.split("\n");
54
+
55
+ const editToolCalls = countPattern(lines, /✎ edit .*/);
56
+ const editLinesToolCalls = countPattern(lines, /edit_lines /);
57
+ const editRejections = countPattern(lines, /edit .*REJECTED/);
58
+ const editLinesRejections = countPattern(lines, /edit_lines .*REJECTED/);
59
+ const staleAnchorRecoveries = countPattern(
60
+ lines,
61
+ /snapshot.*merge|anchor.*stale|recovery.*suggest/
62
+ );
63
+ const successfulEdits = countPattern(
64
+ lines,
65
+ /edited .*\(new hash #|✎ edit .* \)$/
66
+ );
67
+ const gateFails = countPattern(lines, /turn \d+: red \(\d+ error/);
68
+ const isGreen = lines.some((line) => /spec ".*": done/.test(line));
69
+
70
+ // Calculate total args bytes from successful edits
71
+ const successLines = lines.filter((line) =>
72
+ /edited .*\(new hash #|✎ edit .* \)$/.test(line)
73
+ );
74
+ const totalArgBytes = successLines.reduce(
75
+ (sum, line) => sum + line.length,
76
+ 0
77
+ );
78
+
79
+ // Find turn counts
80
+ let totalTurns = 0;
81
+ let turnsToGreen = -1;
82
+
83
+ for (const line of lines) {
84
+ const askMatch = /turn (\d+): asking model/.exec(line);
85
+
86
+ if (askMatch !== null) {
87
+ totalTurns = Math.max(totalTurns, Number(askMatch[1]));
88
+ }
89
+
90
+ if (/· turn \d+: GREEN/.test(line) && turnsToGreen < 0) {
91
+ turnsToGreen = totalTurns;
92
+ }
93
+ }
94
+
95
+ return {
96
+ editToolCalls,
97
+ editLinesToolCalls,
98
+ editRejections,
99
+ editLinesRejections,
100
+ staleAnchorRecoveries,
101
+ meanToolArgBytes:
102
+ successfulEdits > 0 ? Math.round(totalArgBytes / successfulEdits) : 0,
103
+ gateFails,
104
+ turnsToGreen: turnsToGreen >= 0 ? turnsToGreen : totalTurns,
105
+ totalTurns,
106
+ passed: isGreen,
107
+ };
108
+ }
109
+
110
+ async function parseResultJson(
111
+ jsonPath: string
112
+ ): Promise<{ quality?: number; hashlineEnabled: boolean }> {
113
+ try {
114
+ const text = await readFile(jsonPath, "utf-8");
115
+ const parsed: unknown = JSON.parse(text);
116
+
117
+ if (!isRecord(parsed)) {
118
+ return { hashlineEnabled: false };
119
+ }
120
+
121
+ const quality =
122
+ typeof parsed.quality === "number" ? parsed.quality : undefined;
123
+ const features = isRecord(parsed.features) ? parsed.features : {};
124
+
125
+ return {
126
+ quality,
127
+ hashlineEnabled:
128
+ features.TSFORGE_HASHLINE === "1" ||
129
+ process.env.TSFORGE_HASHLINE === "1",
130
+ };
131
+ } catch {
132
+ return { hashlineEnabled: false };
133
+ }
134
+ }
135
+
136
+ async function analyzeRunDir(dir: string): Promise<IEditMetrics | null> {
137
+ const logPath = join(dir, "run.log");
138
+ const jsonPath = join(dir, "result.json");
139
+ const runId = basename(dir);
140
+
141
+ try {
142
+ const logMetrics = await parseRunLog(logPath);
143
+ const jsonData = await parseResultJson(jsonPath);
144
+
145
+ return {
146
+ runId,
147
+ hashlineEnabled: jsonData.hashlineEnabled,
148
+ ...logMetrics,
149
+ quality: jsonData.quality,
150
+ };
151
+ } catch {
152
+ return null;
153
+ }
154
+ }
155
+
156
+ function summarizeMetrics(metrics: IEditMetrics[]): IEditSummary {
157
+ const m0 = metrics[0];
158
+ const label =
159
+ m0 !== undefined
160
+ ? m0.hashlineEnabled
161
+ ? "hashline=on"
162
+ : "hashline=off"
163
+ : "unknown";
164
+
165
+ const passed = metrics.filter((m) => m.passed).length;
166
+ const scored = metrics.filter((m) => m.quality !== undefined);
167
+
168
+ const sum = (sel: (m: IEditMetrics) => number): number =>
169
+ metrics.reduce((a, m) => a + sel(m), 0);
170
+ const avg = (sel: (m: IEditMetrics) => number): number =>
171
+ metrics.length > 0 ? sum(sel) / metrics.length : 0;
172
+
173
+ const totalEditCalls = sum((m) => m.editToolCalls);
174
+ const totalEditLines = sum((m) => m.editLinesToolCalls);
175
+
176
+ return {
177
+ label,
178
+ runs: metrics.length,
179
+ avgEditToolCalls: avg((m) => m.editToolCalls),
180
+ avgEditLinesToolCalls: avg((m) => m.editLinesToolCalls),
181
+ editSuccessRate:
182
+ totalEditCalls > 0
183
+ ? 1 - sum((m) => m.editRejections) / totalEditCalls
184
+ : 1,
185
+ editLinesSuccessRate:
186
+ totalEditLines > 0
187
+ ? 1 - sum((m) => m.editLinesRejections) / totalEditLines
188
+ : 1,
189
+ avgStaleAnchorRecoveries: avg((m) => m.staleAnchorRecoveries),
190
+ avgMeanToolArgBytes: avg((m) => m.meanToolArgBytes),
191
+ avgGateFails: avg((m) => m.gateFails),
192
+ avgTurnsToGreen: avg((m) => m.turnsToGreen),
193
+ passRate: metrics.length > 0 ? passed / metrics.length : 0,
194
+ avgQuality:
195
+ scored.length > 0
196
+ ? scored.reduce((a, m) => a + (m.quality ?? 0), 0) / scored.length
197
+ : 0,
198
+ };
199
+ }
200
+
201
+ function renderSummaryTable(summaries: IEditSummary[]): string {
202
+ const headers = [
203
+ "condition",
204
+ "runs",
205
+ "edit calls",
206
+ "edit_lines calls",
207
+ "edit % success",
208
+ "edit_lines % success",
209
+ "stale recovery",
210
+ "mean args (bytes)",
211
+ "gate fails",
212
+ "turns to green",
213
+ "pass rate",
214
+ "avg quality",
215
+ ];
216
+
217
+ const rows = summaries.map((s) => [
218
+ s.label,
219
+ String(s.runs),
220
+ s.avgEditToolCalls.toFixed(1),
221
+ s.avgEditLinesToolCalls.toFixed(1),
222
+ (s.editSuccessRate * 100).toFixed(0),
223
+ (s.editLinesSuccessRate * 100).toFixed(0),
224
+ s.avgStaleAnchorRecoveries.toFixed(2),
225
+ String(Math.round(s.avgMeanToolArgBytes)),
226
+ s.avgGateFails.toFixed(1),
227
+ s.avgTurnsToGreen.toFixed(1),
228
+ (s.passRate * 100).toFixed(0),
229
+ s.avgQuality.toFixed(1),
230
+ ]);
231
+
232
+ // Simple ASCII table
233
+ const colWidths = headers.map((h, i) => {
234
+ return Math.max(
235
+ h.length,
236
+ ...rows.map((r) => {
237
+ const cell = r[i];
238
+
239
+ return cell !== undefined ? cell.length : 0;
240
+ })
241
+ );
242
+ });
243
+
244
+ const headerLine = headers
245
+ .map((h, i) => h.padEnd(colWidths[i] ?? 0))
246
+ .join(" | ");
247
+ const separator = colWidths.map((w) => "-".repeat(w)).join("-+-");
248
+ const dataLines = rows
249
+ .map((r) => r.map((c, i) => c.padEnd(colWidths[i] ?? 0)).join(" | "))
250
+ .join("\n");
251
+
252
+ return `${headerLine}\n${separator}\n${dataLines}`;
253
+ }
254
+
255
+ const args = process.argv.slice(2);
256
+ const jsonOutput = args[0] === "--json" ? args[1] : undefined;
257
+ const dirs = jsonOutput !== undefined ? args.slice(2) : args;
258
+
259
+ if (dirs.length === 0) {
260
+ process.stdout.write(
261
+ "Usage: edit-benchmark [--json <output.json>] <dir> [<dir> ...]\n"
262
+ );
263
+ process.exit(1);
264
+ }
265
+
266
+ const allMetrics: IEditMetrics[] = [];
267
+
268
+ for (const dir of dirs) {
269
+ const metrics = await analyzeRunDir(dir);
270
+
271
+ if (metrics !== null) {
272
+ allMetrics.push(metrics);
273
+ }
274
+ }
275
+
276
+ // Group by hashline on/off
277
+ const byHashline = new Map<boolean, IEditMetrics[]>();
278
+
279
+ for (const m of allMetrics) {
280
+ const list = byHashline.get(m.hashlineEnabled) ?? [];
281
+
282
+ list.push(m);
283
+ byHashline.set(m.hashlineEnabled, list);
284
+ }
285
+
286
+ const summaries: IEditSummary[] = [];
287
+
288
+ for (const [, metrics] of byHashline) {
289
+ summaries.push(summarizeMetrics(metrics));
290
+ }
291
+
292
+ summaries.sort((a, b) => a.label.localeCompare(b.label));
293
+
294
+ const table = renderSummaryTable(summaries);
295
+
296
+ process.stdout.write("\n=== edit-benchmark comparison ===\n\n");
297
+ process.stdout.write(table);
298
+ process.stdout.write("\n");
299
+
300
+ if (jsonOutput !== undefined) {
301
+ await Bun.write(
302
+ jsonOutput,
303
+ JSON.stringify(
304
+ {
305
+ analyzed: allMetrics.length,
306
+ summaries,
307
+ table,
308
+ },
309
+ null,
310
+ 2
311
+ )
312
+ );
313
+ process.stdout.write(`\nsaved JSON report to ${jsonOutput}\n`);
314
+ }