@decohere/ci-tools 0.0.1-dev.126 → 0.0.1-dev.14

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.
Files changed (40) hide show
  1. package/package.json +3 -18
  2. package/src/audit/enforce.mjs +5 -7
  3. package/src/audit/summarize.mjs +32 -10
  4. package/src/audit/upsert-issue.mjs +74 -25
  5. package/src/bench/bench.mjs +368 -134
  6. package/src/bench/trace.mjs +14 -18
  7. package/src/coverage/enforce-thresholds.mjs +144 -0
  8. package/src/pages/artifact.mjs +183 -38
  9. package/src/pages/clear-bench-history.mjs +27 -0
  10. package/src/pages/keep-prior-bench-badge.mjs +38 -0
  11. package/src/pages/restore-bench-history.mjs +28 -15
  12. package/src/release/npm-links.mjs +13 -6
  13. package/src/release/sync-dependencies.mjs +2 -1
  14. package/tests/lib/lcov.test.mjs +4 -4
  15. package/LICENSE +0 -15
  16. package/src/audit/format.mjs +0 -133
  17. package/src/bench/file-regressions.mjs +0 -76
  18. package/src/bench/history.mjs +0 -248
  19. package/src/bench/nightly.mjs +0 -118
  20. package/src/bench/regression-issue.mjs +0 -44
  21. package/src/github/credential-nudge.mjs +0 -226
  22. package/src/github/npm-bootstrap.mjs +0 -179
  23. package/src/github/oidc-bootstrap-nudge.mjs +0 -214
  24. package/src/github/publish-verify.mjs +0 -206
  25. package/src/github/smoke-create-launcher.mjs +0 -70
  26. package/src/lib/github-issue.mjs +0 -75
  27. package/src/lib/workspace-packages.mjs +0 -30
  28. package/src/pages/bench-page.mjs +0 -323
  29. package/src/pages/restore-badges.mjs +0 -58
  30. package/tests/audit/format.test.mjs +0 -115
  31. package/tests/bench/history.test.mjs +0 -279
  32. package/tests/bench/regression-issue.test.mjs +0 -42
  33. package/tests/github/credential-nudge.test.mjs +0 -112
  34. package/tests/github/npm-bootstrap.test.mjs +0 -125
  35. package/tests/github/oidc-bootstrap-nudge.test.mjs +0 -89
  36. package/tests/github/package-metadata.test.mjs +0 -69
  37. package/tests/github/publish-verify.test.mjs +0 -115
  38. package/tests/github/smoke-create-launcher.test.mjs +0 -71
  39. package/tests/lib/github-issue.test.mjs +0 -85
  40. package/tests/pages/bench-page.test.mjs +0 -123
@@ -1,22 +1,22 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  /**
4
- * bench.mjs — run the benchmark suites and compare against the last saved run.
4
+ * bench.mjs — run the forge benchmark and compare against the last saved baseline.
5
5
  *
6
6
  * Usage:
7
- * pnpm bench — run every suite and compare, do not save
8
- * pnpm bench flux-load flux-serve — run only these suites
9
- * pnpm bench:save — run, compare, and append to the history
7
+ * node bench.mjs — run and compare, do not save
8
+ * node bench.mjs save — run, compare, and save as the new baseline
9
+ * node bench.mjs report — run and compare, do not save
10
+ * BENCH_ITERATIONS=3 node bench.mjs
10
11
  *
11
- * Suites: forge-build, flux-load, flux-transpile, flux-serve. Each prints a BENCH_RESULT line
12
- * (see history.mjs). The history lives in projects/ci-tools/dist/ci/bench/bench-history.json;
13
- * the nightly bench workflow restores it from Pages, saves, and publishes the charts.
14
- *
15
- * Environment: BENCH_HISTORY_FILE overrides where `save` writes; BENCH_ENV names the machine class (CI sets gh-actions); BENCH_SAMPLES (default
16
- * 10) is the sample count for every benchmark; BENCH_LIBS and BENCH_FILES size the flux workspace.
12
+ * Context ID
13
+ * ----------
14
+ * 16-char SHA-256 prefix of: platform|arch|cpu|cores|memGB|node
15
+ * Ensures history is always compared like-for-like across machines.
17
16
  */
18
17
 
19
- import { execFileSync, spawnSync } from "node:child_process";
18
+ import { spawnSync } from "node:child_process";
19
+ import crypto from "node:crypto";
20
20
  import {
21
21
  existsSync,
22
22
  mkdirSync,
@@ -26,152 +26,386 @@ import {
26
26
  writeFileSync,
27
27
  } from "node:fs";
28
28
  import os from "node:os";
29
- import { dirname, join } from "node:path";
30
- import {
31
- appendRun,
32
- assertValid,
33
- compare,
34
- contextId,
35
- formatComparison,
36
- fromVitest,
37
- getContext,
38
- normalize,
39
- parseResults,
40
- previousRun,
41
- } from "./history.mjs";
29
+ import { join } from "node:path";
42
30
 
43
31
  const WORKSPACE_ROOT = process.cwd();
44
- // BENCH_HISTORY_FILE lets the nightly job save each comparison round to its own file.
45
- const HISTORY_FILE =
46
- process.env.BENCH_HISTORY_FILE ??
47
- join(WORKSPACE_ROOT, "projects/ci-tools/dist/ci/bench/bench-history.json");
48
-
49
- /** Runs a TypeScript benchmark from source, the way the repo runs its CLIs. */
50
- function fluxSuite(file) {
51
- return () => {
52
- const result = spawnSync(process.execPath, ["--import", "./tools/register.mjs", file], {
53
- cwd: WORKSPACE_ROOT,
54
- encoding: "utf8",
55
- maxBuffer: 1024 * 1024 * 50,
56
- env: { ...process.env, NODE_OPTIONS: "" },
32
+ const HISTORY_FILE = join(
33
+ WORKSPACE_ROOT,
34
+ "projects/ci-tools/dist/ci/bench/bench-history.json",
35
+ );
36
+ const VITEST_BIN = join(WORKSPACE_ROOT, "node_modules/.bin/vitest");
37
+ const BENCH_FILE = "projects/terminal/e2e/build.benchmark.bench.ts";
38
+ const CONFIG = join(WORKSPACE_ROOT, "vitest.e2e.config.ts");
39
+ const TRACE_LOG_DIR = join(WORKSPACE_ROOT, ".decohere", "logs");
40
+ const MAX_RUNS = 10;
41
+ const command = process.argv[2] ?? "run";
42
+
43
+ // ── Context ───────────────────────────────────────────────────────────────────
44
+
45
+ function getContext() {
46
+ const cpus = os.cpus();
47
+ const nodeMajor = process.version.match(/^v(\d+)/)?.[1] ?? "?";
48
+ const rawEnv = process.env.BENCH_ENV ?? null;
49
+ return {
50
+ benchEnv: rawEnv ? `${rawEnv}-node${nodeMajor}` : "(local)",
51
+ date: new Date().toISOString(),
52
+ os: { platform: os.platform(), arch: os.arch() },
53
+ cpu: { model: cpus[0]?.model?.trim() ?? "unknown", count: cpus.length },
54
+ memoryGb: parseFloat((os.totalmem() / 1024 ** 3).toFixed(1)),
55
+ node: process.version,
56
+ };
57
+ }
58
+
59
+ function contextId(ctx) {
60
+ const key = [
61
+ ctx.os.platform,
62
+ ctx.os.arch,
63
+ ctx.cpu.model,
64
+ ctx.cpu.count,
65
+ ctx.memoryGb,
66
+ ctx.node,
67
+ ].join("|");
68
+ return crypto.createHash("sha256").update(key).digest("hex").slice(0, 16);
69
+ }
70
+
71
+ function truthy(value, defaultValue = false) {
72
+ if (value == null) return defaultValue;
73
+ return !["0", "false", "no", "off"].includes(String(value).toLowerCase());
74
+ }
75
+
76
+ function toFileTimestamp(date = new Date()) {
77
+ const pad = (n) => String(n).padStart(2, "0");
78
+ return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`;
79
+ }
80
+
81
+ function toTraceSample(line, parsed, durationMs) {
82
+ return {
83
+ time: parsed.time,
84
+ name: parsed.name,
85
+ message: parsed.msg,
86
+ durationMs,
87
+ line,
88
+ };
89
+ }
90
+
91
+ function parseTraceSamples(rawOutput) {
92
+ const samples = [];
93
+ for (const rawLine of rawOutput.split(/\r?\n/)) {
94
+ const line = rawLine.trim();
95
+ if (!line.startsWith("{")) continue;
96
+
97
+ let parsed;
98
+ try {
99
+ parsed = JSON.parse(line);
100
+ } catch {
101
+ continue;
102
+ }
103
+
104
+ if (typeof parsed?.name !== "string" || typeof parsed?.msg !== "string") continue;
105
+ const match = parsed.msg.match(/^(.*):\s+([0-9]+(?:\.[0-9]+)?)ms$/);
106
+ if (match == null) continue;
107
+
108
+ const durationMs = Number(match[2]);
109
+ if (!Number.isFinite(durationMs)) continue;
110
+
111
+ samples.push({
112
+ className: parsed.name,
113
+ methodLabel: match[1],
114
+ methodName: match[1].split("(")[0],
115
+ durationMs,
116
+ sample: toTraceSample(rawLine, parsed, durationMs),
57
117
  });
58
- process.stderr.write(
59
- result.stdout
60
- .split("\n")
61
- .filter((l) => !l.startsWith("BENCH_RESULT"))
62
- .join("\n"),
63
- );
64
- if (result.status !== 0) {
65
- throw new Error(`${file} exited ${result.status ?? result.signal}:\n${result.stderr}`);
118
+ }
119
+ return samples;
120
+ }
121
+
122
+ function aggregateSamples(samples, keyBuilder) {
123
+ const aggregates = new Map();
124
+ for (const sample of samples) {
125
+ const key = keyBuilder(sample);
126
+ const current = aggregates.get(key) ?? {
127
+ key,
128
+ hitCount: 0,
129
+ totalMs: 0,
130
+ maxMs: 0,
131
+ };
132
+ current.hitCount += 1;
133
+ current.totalMs += sample.durationMs;
134
+ if (sample.durationMs > current.maxMs) current.maxMs = sample.durationMs;
135
+ aggregates.set(key, current);
136
+ }
137
+ return [...aggregates.values()];
138
+ }
139
+
140
+ function analyzeTraceOutput(rawOutput) {
141
+ const samples = parseTraceSamples(rawOutput);
142
+ if (samples.length === 0) {
143
+ process.stderr.write("\n⚠ No structured trace samples found to analyze.\n");
144
+ return;
145
+ }
146
+
147
+ const byMethod = aggregateSamples(
148
+ samples,
149
+ (sample) => `${sample.className}.${sample.methodName}`,
150
+ );
151
+ const byClass = aggregateSamples(samples, (sample) => sample.className);
152
+ const byHits = [...byMethod].sort((a, b) => b.hitCount - a.hitCount).slice(0, 10);
153
+ const byTotal = [...byMethod].sort((a, b) => b.totalMs - a.totalMs).slice(0, 10);
154
+ const byMax = [...byMethod].sort((a, b) => b.maxMs - a.maxMs).slice(0, 10);
155
+ const byClassTotal = [...byClass].sort((a, b) => b.totalMs - a.totalMs).slice(0, 5);
156
+ const totalTraceMs = samples.reduce((sum, sample) => sum + sample.durationMs, 0);
157
+ const hottest = [...samples].sort((a, b) => b.durationMs - a.durationMs).slice(0, 5);
158
+
159
+ process.stderr.write(
160
+ "\n┌─ Trace Analysis ─────────────────────────────────────────────────────────\n",
161
+ );
162
+ process.stderr.write(`│ Samples : ${samples.length}\n`);
163
+ process.stderr.write(`│ Unique funcs : ${byMethod.length}\n`);
164
+ process.stderr.write(`│ Total traced : ${totalTraceMs.toFixed(2)}ms\n`);
165
+ process.stderr.write(`│ Avg sample : ${(totalTraceMs / samples.length).toFixed(3)}ms\n`);
166
+ process.stderr.write(
167
+ "└──────────────────────────────────────────────────────────────────────────\n",
168
+ );
169
+
170
+ const printTable = (title, rows, valueFormatter) => {
171
+ process.stderr.write(`\n${title}\n`);
172
+ for (const row of rows) {
173
+ process.stderr.write(
174
+ ` ${valueFormatter(row).padStart(10)} ${row.key} (hits=${row.hitCount}, max=${row.maxMs.toFixed(2)}ms)\n`,
175
+ );
66
176
  }
67
- return parseResults(result.stdout, file);
68
177
  };
69
- }
70
178
 
71
- function forgeBuildSuite() {
72
- const tmpDir = mkdtempSync(join(os.tmpdir(), "decohere-bench-"));
73
- const outJson = join(tmpDir, "output.json");
74
- try {
75
- const result = spawnSync(
76
- join(WORKSPACE_ROOT, "node_modules/.bin/vitest"),
77
- [
78
- "bench",
79
- "--config",
80
- join(WORKSPACE_ROOT, "vitest.config.ts"),
81
- "--project",
82
- "e2e",
83
- "--run",
84
- "--outputJson",
85
- outJson,
86
- "projects/cli/e2e/build.benchmark.bench.ts",
87
- ],
88
- {
89
- cwd: WORKSPACE_ROOT,
90
- stdio: ["ignore", "inherit", "inherit"],
91
- env: {
92
- ...process.env,
93
- NODE_OPTIONS: "",
94
- },
95
- },
179
+ printTable("Top Functions By Hit Count", byHits, (row) => String(row.hitCount));
180
+ printTable("Top Functions By Total Time", byTotal, (row) => `${row.totalMs.toFixed(2)}ms`);
181
+ printTable(
182
+ "Top Functions By Worst Single Call",
183
+ byMax,
184
+ (row) => `${row.maxMs.toFixed(2)}ms`,
185
+ );
186
+ printTable(
187
+ "Top Classes By Total Trace Time",
188
+ byClassTotal,
189
+ (row) => `${row.totalMs.toFixed(2)}ms`,
190
+ );
191
+
192
+ process.stderr.write("\nSlowest Individual Samples\n");
193
+ for (const hot of hottest) {
194
+ process.stderr.write(
195
+ ` ${hot.durationMs.toFixed(2).padStart(10)}ms ${hot.className}.${hot.methodLabel}\n`,
96
196
  );
97
- if (result.status !== 0) throw new Error(`vitest bench exited ${result.status}`);
98
- // vitest swallows errors in bench callbacks, so a missing file means it failed.
99
- if (!existsSync(outJson)) throw new Error("vitest bench wrote no output JSON");
100
- return fromVitest(JSON.parse(readFileSync(outJson, "utf8")));
101
- } finally {
102
- rmSync(tmpDir, { recursive: true, force: true });
103
197
  }
104
198
  }
105
199
 
106
- const SUITES = {
107
- "forge-build": forgeBuildSuite,
108
- "flux-load": fluxSuite("projects/flux/bench/load.bench.ts"),
109
- "flux-transpile": fluxSuite("projects/flux/bench/transpile.bench.ts"),
110
- "flux-serve": fluxSuite("projects/flux/bench/serve.bench.ts"),
111
- };
112
-
113
- function currentSha() {
114
- if (process.env.GITHUB_SHA) return process.env.GITHUB_SHA;
115
- return execFileSync("git", ["rev-parse", "HEAD"], {
116
- cwd: WORKSPACE_ROOT,
117
- encoding: "utf8",
118
- }).trim();
200
+ function getBenchmarks(vitestOutput) {
201
+ return vitestOutput.files
202
+ .flatMap((file) => file.groups)
203
+ .flatMap((group) => group.benchmarks);
119
204
  }
120
205
 
121
- const [command = "run", ...requested] = process.argv.slice(2);
122
- if (command !== "run" && command !== "save") {
123
- process.stderr.write(`Unknown command "${command}". Use "run" or "save".\n`);
124
- process.exit(1);
125
- }
126
- const unknown = requested.filter((suite) => !(suite in SUITES));
127
- if (unknown.length > 0) {
128
- process.stderr.write(
129
- `Unknown suite(s): ${unknown.join(", ")}. Available: ${Object.keys(SUITES).join(", ")}\n`,
206
+ function printBenchmarkComparison(currentRun, previousRun) {
207
+ if (previousRun == null) {
208
+ process.stderr.write("No previous saved run for this context; skipping comparison.\n");
209
+ return;
210
+ }
211
+
212
+ const currentBenchmarks = getBenchmarks(currentRun.vitestOutput);
213
+ const previousByName = new Map(
214
+ getBenchmarks(previousRun.vitestOutput).map((benchmark) => [benchmark.name, benchmark]),
130
215
  );
131
- process.exit(1);
216
+
217
+ process.stderr.write("Current Run vs Previous\n");
218
+ process.stderr.write(` current : ${currentRun.date}\n`);
219
+ process.stderr.write(` previous: ${previousRun.date}\n`);
220
+ process.stderr.write("\n");
221
+
222
+ const rows = currentBenchmarks.map((benchmark) => {
223
+ const previous = previousByName.get(benchmark.name);
224
+ if (previous == null) {
225
+ return {
226
+ name: benchmark.name,
227
+ current: `${benchmark.mean.toFixed(2)}ms`,
228
+ previous: "n/a",
229
+ delta: "n/a",
230
+ change: "n/a",
231
+ };
232
+ }
233
+
234
+ const delta = benchmark.mean - previous.mean;
235
+ const pct = previous.mean === 0 ? 0 : (delta / previous.mean) * 100;
236
+ const deltaSign = delta >= 0 ? "+" : "";
237
+ return {
238
+ name: benchmark.name,
239
+ current: `${benchmark.mean.toFixed(2)}ms`,
240
+ previous: `${previous.mean.toFixed(2)}ms`,
241
+ delta: `${deltaSign}${delta.toFixed(2)}ms`,
242
+ change: `${deltaSign}${pct.toFixed(2)}%`,
243
+ };
244
+ });
245
+
246
+ const headers = {
247
+ name: "Benchmark",
248
+ current: "Current",
249
+ previous: "Previous",
250
+ delta: "Delta",
251
+ change: "%",
252
+ };
253
+
254
+ const widths = {
255
+ name: Math.max(headers.name.length, ...rows.map((row) => row.name.length)),
256
+ current: Math.max(headers.current.length, ...rows.map((row) => row.current.length)),
257
+ previous: Math.max(headers.previous.length, ...rows.map((row) => row.previous.length)),
258
+ delta: Math.max(headers.delta.length, ...rows.map((row) => row.delta.length)),
259
+ change: Math.max(headers.change.length, ...rows.map((row) => row.change.length)),
260
+ };
261
+
262
+ const divider = [
263
+ "-".repeat(widths.name),
264
+ "-".repeat(widths.current),
265
+ "-".repeat(widths.previous),
266
+ "-".repeat(widths.delta),
267
+ "-".repeat(widths.change),
268
+ ].join("-+-");
269
+
270
+ const formatRow = (row) =>
271
+ [
272
+ row.name.padEnd(widths.name),
273
+ row.current.padStart(widths.current),
274
+ row.previous.padStart(widths.previous),
275
+ row.delta.padStart(widths.delta),
276
+ row.change.padStart(widths.change),
277
+ ].join(" | ");
278
+
279
+ process.stderr.write(`${formatRow(headers)}\n`);
280
+ process.stderr.write(`${divider}\n`);
281
+ for (const row of rows) {
282
+ process.stderr.write(`${formatRow(row)}\n`);
283
+ }
284
+
285
+ process.stderr.write("\n");
132
286
  }
133
- const suites = requested.length > 0 ? requested : Object.keys(SUITES);
134
287
 
135
- const ctx = getContext(os, process.env, process.version);
288
+ // ── Run ───────────────────────────────────────────────────────────────────────
289
+
290
+ const ctx = getContext();
136
291
  const id = contextId(ctx);
292
+ const iterations = process.env.BENCH_ITERATIONS ?? "5";
293
+ const traceEnabled = process.env.FORGE_TRACE != null;
294
+ const shouldSave = truthy(process.env.BENCH_SAVE, command === "save");
295
+ const shouldAnalyzeTrace = traceEnabled && truthy(process.env.BENCH_ANALYZE_TRACE, true);
296
+ const shouldCollectTraceLog = traceEnabled && truthy(process.env.BENCH_TRACE_LOG, true);
297
+ const traceLogPath = shouldCollectTraceLog
298
+ ? join(TRACE_LOG_DIR, `perf-trace-${toFileTimestamp()}.log`)
299
+ : null;
300
+
301
+ if (traceLogPath != null) {
302
+ mkdirSync(TRACE_LOG_DIR, { recursive: true });
303
+ }
304
+
137
305
  process.stderr.write(
138
306
  [
139
- "",
140
- `Context ${id} · ${ctx.benchEnv} · ${ctx.os.platform} ${ctx.os.arch}`,
141
- `${ctx.cpu.model} × ${ctx.cpu.count} · ${ctx.memoryGb} GB · Node ${ctx.node}`,
142
- `Suites: ${suites.join(", ")}`,
143
- "",
144
- "",
307
+ "\n┌─ Benchmark Environment ─────────────────────────────────────────────────",
308
+ `│ Context ID : ${id}`,
309
+ `│ BENCH_ENV : ${ctx.benchEnv}`,
310
+ `│ Date : ${ctx.date}`,
311
+ `│ OS : ${ctx.os.platform} ${ctx.os.arch}`,
312
+ `│ CPU : ${ctx.cpu.model} × ${ctx.cpu.count}`,
313
+ `│ Memory : ${ctx.memoryGb} GB`,
314
+ `│ Node : ${ctx.node}`,
315
+ "└──────────────────────────────────────────────────────────────────────────\n\n",
145
316
  ].join("\n"),
146
317
  );
147
318
 
148
- const results = [];
149
- for (const suite of suites) {
150
- process.stderr.write(`── ${suite}\n`);
151
- results.push(...SUITES[suite]());
152
- process.stderr.write("\n");
319
+ if (traceEnabled) {
320
+ process.stderr.write("Trace mode enabled with structured pino logs for analysis.\n\n");
153
321
  }
154
- assertValid(results);
155
322
 
156
- const history = normalize(
157
- existsSync(HISTORY_FILE) ? JSON.parse(readFileSync(HISTORY_FILE, "utf8")) : null,
158
- );
159
- const previous = previousRun(history, id);
160
- process.stderr.write(
161
- previous == null
162
- ? "No saved run for this machine yet; nothing to compare against.\n"
163
- : `Compared with the saved run from ${previous.date}:\n`,
164
- );
165
- process.stderr.write(`${formatComparison(compare(results, previous))}\n\n`);
323
+ const tmpDir = mkdtempSync(`${os.tmpdir()}/ark-bench-`);
324
+ const outJson = join(tmpDir, "output.json");
166
325
 
167
- if (command === "save") {
168
- const run = { date: ctx.date, sha: currentSha(), results };
169
- const next = appendRun(history, ctx, run);
170
- mkdirSync(dirname(HISTORY_FILE), { recursive: true });
171
- writeFileSync(HISTORY_FILE, `${JSON.stringify(next, null, 2)}\n`);
172
- process.stderr.write(
173
- `Saved (context ${id}, ${next.contexts[id].runs.length} runs stored).\n`,
326
+ try {
327
+ const result = spawnSync(
328
+ VITEST_BIN,
329
+ ["bench", "--config", CONFIG, "--run", "--outputJson", outJson, BENCH_FILE],
330
+ {
331
+ cwd: WORKSPACE_ROOT,
332
+ stdio: traceEnabled ? "pipe" : "inherit",
333
+ encoding: traceEnabled ? "utf8" : undefined,
334
+ maxBuffer: 1024 * 1024 * 200,
335
+ env: {
336
+ ...process.env,
337
+ NODE_OPTIONS: "",
338
+ BENCH_ITERATIONS: iterations,
339
+ ...(traceEnabled
340
+ ? { FORGE_TRACE_STRUCTURED: "1", LOG_LEVEL: process.env.LOG_LEVEL ?? "trace" }
341
+ : {}),
342
+ },
343
+ },
174
344
  );
175
- } else {
176
- process.stderr.write("Not saved. Run `pnpm bench:save` to record this run.\n");
345
+
346
+ if (traceEnabled) {
347
+ const stdout = typeof result.stdout === "string" ? result.stdout : "";
348
+ const stderr = typeof result.stderr === "string" ? result.stderr : "";
349
+ if (stdout.length > 0) process.stdout.write(stdout);
350
+ if (stderr.length > 0) process.stderr.write(stderr);
351
+
352
+ const combined = `${stdout}${stderr.length > 0 ? `\n${stderr}` : ""}`;
353
+ if (traceLogPath != null) {
354
+ writeFileSync(traceLogPath, combined);
355
+ process.stderr.write(`\n✓ Trace log written: ${traceLogPath}\n`);
356
+ }
357
+
358
+ if (shouldAnalyzeTrace) {
359
+ analyzeTraceOutput(combined);
360
+ }
361
+ }
362
+
363
+ if (result.status !== 0) process.exit(result.status ?? 1);
364
+
365
+ if (!existsSync(outJson)) {
366
+ process.stderr.write("✗ No output JSON — bench may have errored internally.\n");
367
+ process.exit(1);
368
+ }
369
+
370
+ const output = JSON.parse(readFileSync(outJson, "utf8"));
371
+ const benches = output.files.flatMap((f) => f.groups).flatMap((g) => g.benchmarks);
372
+ const currentRun = { date: ctx.date, context: ctx, vitestOutput: output };
373
+
374
+ // Fail loud on NaN — vitest swallows errors in bench callbacks silently
375
+ const invalid = benches.filter((b) => !Number.isFinite(b.mean) || !b.sampleCount);
376
+ if (invalid.length > 0) {
377
+ process.stderr.write(
378
+ `✗ ${invalid.length} benchmark(s) have NaN results — check for errors in the bench callback:\n` +
379
+ invalid.map((b) => ` • ${b.name}`).join("\n") +
380
+ "\n",
381
+ );
382
+ process.exit(1);
383
+ }
384
+
385
+ // ── Save ──────────────────────────────────────────────────────────────────
386
+
387
+ const history = existsSync(HISTORY_FILE)
388
+ ? JSON.parse(readFileSync(HISTORY_FILE, "utf8"))
389
+ : { runs: {} };
390
+
391
+ if (!history.runs[id]) history.runs[id] = { context: ctx, runs: [] };
392
+ const previousRun = history.runs[id].runs[0];
393
+
394
+ printBenchmarkComparison(currentRun, previousRun);
395
+
396
+ if (!shouldSave) {
397
+ process.stderr.write(`✓ Not saved (mode: ${command}, baseline unchanged)\n\n`);
398
+ process.exit(0);
399
+ }
400
+
401
+ history.runs[id].context = ctx;
402
+ history.runs[id].runs.unshift(currentRun);
403
+ if (history.runs[id].runs.length > MAX_RUNS) history.runs[id].runs.length = MAX_RUNS;
404
+
405
+ writeFileSync(HISTORY_FILE, JSON.stringify(history, null, 2));
406
+
407
+ const stored = history.runs[id].runs.length;
408
+ process.stderr.write(`\n✓ Saved (context: ${id}, runs stored: ${stored}/${MAX_RUNS})\n\n`);
409
+ } finally {
410
+ rmSync(tmpDir, { recursive: true, force: true });
177
411
  }
@@ -6,8 +6,8 @@ import { join } from "node:path";
6
6
 
7
7
  const WORKSPACE_ROOT = process.cwd();
8
8
  const VITEST_BIN = join(WORKSPACE_ROOT, "node_modules/.bin/vitest");
9
- const CONFIG = join(WORKSPACE_ROOT, "vitest.config.ts");
10
- const TRACE_SPEC = "projects/cli/e2e/build.trace.e2e.spec.ts";
9
+ const CONFIG = join(WORKSPACE_ROOT, "vitest.e2e.config.ts");
10
+ const TRACE_SPEC = "projects/terminal/e2e/build.trace.e2e.spec.ts";
11
11
  const TRACE_LOG_DIR = join(WORKSPACE_ROOT, ".decohere", "logs");
12
12
 
13
13
  function truthy(value, defaultValue = false) {
@@ -149,23 +149,19 @@ if (traceLogPath != null) {
149
149
  mkdirSync(TRACE_LOG_DIR, { recursive: true });
150
150
  }
151
151
 
152
- const result = spawnSync(
153
- VITEST_BIN,
154
- ["run", "--config", CONFIG, "--project", "e2e", TRACE_SPEC],
155
- {
156
- cwd: WORKSPACE_ROOT,
157
- stdio: "pipe",
158
- encoding: "utf8",
159
- maxBuffer: 1024 * 1024 * 200,
160
- env: {
161
- ...process.env,
162
- NODE_OPTIONS: "",
163
- FORGE_TRACE: "1",
164
- FORGE_TRACE_STRUCTURED: "1",
165
- LOG_LEVEL: process.env.LOG_LEVEL ?? "trace",
166
- },
152
+ const result = spawnSync(VITEST_BIN, ["run", "--config", CONFIG, TRACE_SPEC], {
153
+ cwd: WORKSPACE_ROOT,
154
+ stdio: "pipe",
155
+ encoding: "utf8",
156
+ maxBuffer: 1024 * 1024 * 200,
157
+ env: {
158
+ ...process.env,
159
+ NODE_OPTIONS: "",
160
+ FORGE_TRACE: "1",
161
+ FORGE_TRACE_STRUCTURED: "1",
162
+ LOG_LEVEL: process.env.LOG_LEVEL ?? "trace",
167
163
  },
168
- );
164
+ });
169
165
 
170
166
  const stdout = typeof result.stdout === "string" ? result.stdout : "";
171
167
  const stderr = typeof result.stderr === "string" ? result.stderr : "";