@decohere/ci-tools 0.0.1-dev.13 → 0.0.1-dev.130

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/LICENSE +15 -0
  2. package/package.json +17 -3
  3. package/src/audit/enforce.mjs +7 -5
  4. package/src/audit/format.mjs +133 -0
  5. package/src/audit/summarize.mjs +10 -32
  6. package/src/audit/upsert-issue.mjs +25 -74
  7. package/src/bench/bench.mjs +134 -368
  8. package/src/bench/file-regressions.mjs +76 -0
  9. package/src/bench/history.mjs +248 -0
  10. package/src/bench/nightly.mjs +118 -0
  11. package/src/bench/regression-issue.mjs +44 -0
  12. package/src/bench/trace.mjs +18 -14
  13. package/src/github/npm-bootstrap.mjs +179 -0
  14. package/src/github/oidc-bootstrap-nudge.mjs +214 -0
  15. package/src/github/publish-verify.mjs +206 -0
  16. package/src/github/smoke-create-launcher.mjs +85 -0
  17. package/src/lib/github-issue.mjs +75 -0
  18. package/src/lib/lcov.mjs +1 -1
  19. package/src/lib/workspace-packages.mjs +30 -0
  20. package/src/pages/artifact.mjs +38 -183
  21. package/src/pages/bench-page.mjs +323 -0
  22. package/src/pages/restore-badges.mjs +35 -0
  23. package/src/pages/restore-bench-history.mjs +15 -28
  24. package/src/release/npm-links.mjs +6 -13
  25. package/src/release/sync-dependencies.mjs +1 -2
  26. package/tests/audit/format.test.mjs +115 -0
  27. package/tests/bench/history.test.mjs +279 -0
  28. package/tests/bench/regression-issue.test.mjs +42 -0
  29. package/tests/github/ci-workflow.test.mjs +19 -0
  30. package/tests/github/npm-bootstrap.test.mjs +125 -0
  31. package/tests/github/oidc-bootstrap-nudge.test.mjs +89 -0
  32. package/tests/github/package-metadata.test.mjs +69 -0
  33. package/tests/github/publish-verify.test.mjs +115 -0
  34. package/tests/github/smoke-create-launcher.test.mjs +132 -0
  35. package/tests/lib/github-issue.test.mjs +85 -0
  36. package/tests/lib/lcov.test.mjs +8 -8
  37. package/tests/pages/bench-page.test.mjs +123 -0
  38. package/src/coverage/enforce-thresholds.mjs +0 -144
  39. package/src/pages/clear-bench-history.mjs +0 -27
  40. package/src/pages/keep-prior-bench-badge.mjs +0 -38
@@ -1,22 +1,22 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  /**
4
- * bench.mjs — run the forge benchmark and compare against the last saved baseline.
4
+ * bench.mjs — run the benchmark suites and compare against the last saved run.
5
5
  *
6
6
  * Usage:
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
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
11
10
  *
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.
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.
16
17
  */
17
18
 
18
- import { spawnSync } from "node:child_process";
19
- import crypto from "node:crypto";
19
+ import { execFileSync, spawnSync } from "node:child_process";
20
20
  import {
21
21
  existsSync,
22
22
  mkdirSync,
@@ -26,386 +26,152 @@ import {
26
26
  writeFileSync,
27
27
  } from "node:fs";
28
28
  import os from "node:os";
29
- import { join } from "node:path";
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";
30
42
 
31
43
  const WORKSPACE_ROOT = process.cwd();
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),
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: "" },
117
57
  });
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
- );
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}`);
176
66
  }
67
+ return parseResults(result.stdout, file);
177
68
  };
69
+ }
178
70
 
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`,
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
+ },
196
96
  );
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 });
197
103
  }
198
104
  }
199
105
 
200
- function getBenchmarks(vitestOutput) {
201
- return vitestOutput.files
202
- .flatMap((file) => file.groups)
203
- .flatMap((group) => group.benchmarks);
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();
204
119
  }
205
120
 
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]),
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`,
215
130
  );
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");
131
+ process.exit(1);
286
132
  }
133
+ const suites = requested.length > 0 ? requested : Object.keys(SUITES);
287
134
 
288
- // ── Run ───────────────────────────────────────────────────────────────────────
289
-
290
- const ctx = getContext();
135
+ const ctx = getContext(os, process.env, process.version);
291
136
  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
-
305
137
  process.stderr.write(
306
138
  [
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",
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
+ "",
316
145
  ].join("\n"),
317
146
  );
318
147
 
319
- if (traceEnabled) {
320
- process.stderr.write("Trace mode enabled with structured pino logs for analysis.\n\n");
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");
321
153
  }
154
+ assertValid(results);
322
155
 
323
- const tmpDir = mkdtempSync(`${os.tmpdir()}/ark-bench-`);
324
- const outJson = join(tmpDir, "output.json");
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`);
325
166
 
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
- },
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`,
344
174
  );
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 });
175
+ } else {
176
+ process.stderr.write("Not saved. Run `pnpm bench:save` to record this run.\n");
411
177
  }
@@ -0,0 +1,76 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * file-regressions.mjs — after nightly.mjs, open an issue for each benchmark that got slower
5
+ * (regressions.json: head slower than base in every round on one runner), or update the open
6
+ * one. Needs `gh` and GH_TOKEN with issues: write.
7
+ *
8
+ * node projects/ci-tools/src/bench/file-regressions.mjs # file issues
9
+ * node projects/ci-tools/src/bench/file-regressions.mjs --dry-run # print them only
10
+ */
11
+
12
+ import { spawnSync } from "node:child_process";
13
+ import { readFileSync } from "node:fs";
14
+ import { REGRESSION } from "./history.mjs";
15
+ import { issueBody, issueTitle } from "./regression-issue.mjs";
16
+
17
+ const REGRESSIONS_FILE = "projects/ci-tools/dist/ci/bench/regressions.json";
18
+ const dryRun = process.argv.includes("--dry-run");
19
+
20
+ function gh(args, input) {
21
+ const result = spawnSync("gh", args, { encoding: "utf8", input, env: process.env });
22
+ if (result.status !== 0) {
23
+ throw new Error(result.stderr || result.stdout || `gh ${args.join(" ")} failed`);
24
+ }
25
+ return result.stdout;
26
+ }
27
+
28
+ const server = process.env.GITHUB_SERVER_URL ?? "https://github.com";
29
+ const repository = process.env.GITHUB_REPOSITORY ?? "typesmiths/decohere";
30
+ const repoUrl = `${server}/${repository}`;
31
+ const runUrl = process.env.GITHUB_RUN_ID
32
+ ? `${repoUrl}/actions/runs/${process.env.GITHUB_RUN_ID}`
33
+ : "(local run)";
34
+
35
+ const { base, head, context, regressions } = JSON.parse(
36
+ readFileSync(REGRESSIONS_FILE, "utf8"),
37
+ );
38
+ if (base == null) {
39
+ console.log("Nothing was compared tonight (no earlier nightly, or no new commit).");
40
+ process.exit(0);
41
+ }
42
+ if (regressions.length === 0) {
43
+ console.log(`No benchmark slower on ${head.slice(0, 7)} than on ${base.slice(0, 7)}.`);
44
+ process.exit(0);
45
+ }
46
+
47
+ const machine = `${context.benchEnv}, ${context.cpu.model} × ${context.cpu.count}`;
48
+ const open = dryRun
49
+ ? []
50
+ : JSON.parse(
51
+ gh(["issue", "list", "--state", "open", "--limit", "200", "--json", "number,title"]),
52
+ );
53
+
54
+ for (const regression of regressions) {
55
+ const title = issueTitle(regression);
56
+ const body = issueBody(regression, {
57
+ repoUrl,
58
+ runUrl,
59
+ machine,
60
+ threshold: REGRESSION.threshold,
61
+ base,
62
+ head,
63
+ });
64
+ if (dryRun) {
65
+ console.log(`# ${title}\n\n${body}\n`);
66
+ continue;
67
+ }
68
+ const existing = open.find((issue) => issue.title === title);
69
+ if (existing != null) {
70
+ gh(["issue", "edit", String(existing.number), "--body-file", "-"], body);
71
+ console.log(`Updated #${existing.number}: ${title}`);
72
+ } else {
73
+ const url = gh(["issue", "create", "--title", title, "--body-file", "-"], body).trim();
74
+ console.log(`Opened ${url}: ${title}`);
75
+ }
76
+ }