@decohere/ci-tools 0.0.1-dev.12 → 0.0.1-dev.120
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/package.json +8 -3
- package/src/audit/enforce.mjs +7 -5
- package/src/audit/format.mjs +133 -0
- package/src/audit/summarize.mjs +10 -32
- package/src/audit/upsert-issue.mjs +25 -74
- package/src/bench/bench.mjs +134 -368
- package/src/bench/file-regressions.mjs +76 -0
- package/src/bench/history.mjs +248 -0
- package/src/bench/nightly.mjs +118 -0
- package/src/bench/regression-issue.mjs +44 -0
- package/src/bench/trace.mjs +18 -14
- package/src/github/credential-nudge.mjs +245 -0
- package/src/github/oidc-bootstrap-nudge.mjs +210 -0
- package/src/github/publish-verify.mjs +206 -0
- package/src/github/smoke-create-launcher.mjs +70 -0
- package/src/lib/github-issue.mjs +75 -0
- package/src/lib/workspace-packages.mjs +30 -0
- package/src/pages/artifact.mjs +38 -183
- package/src/pages/bench-page.mjs +323 -0
- package/src/pages/restore-badges.mjs +58 -0
- package/src/pages/restore-bench-history.mjs +15 -28
- package/src/release/npm-links.mjs +6 -13
- package/src/release/sync-dependencies.mjs +1 -2
- package/tests/audit/format.test.mjs +115 -0
- package/tests/bench/history.test.mjs +279 -0
- package/tests/bench/regression-issue.test.mjs +42 -0
- package/tests/github/credential-nudge.test.mjs +116 -0
- package/tests/github/oidc-bootstrap-nudge.test.mjs +89 -0
- package/tests/github/publish-verify.test.mjs +115 -0
- package/tests/github/smoke-create-launcher.test.mjs +71 -0
- package/tests/lib/github-issue.test.mjs +85 -0
- package/tests/lib/lcov.test.mjs +4 -4
- package/tests/pages/bench-page.test.mjs +123 -0
- package/src/coverage/enforce-thresholds.mjs +0 -144
- package/src/pages/clear-bench-history.mjs +0 -27
- package/src/pages/keep-prior-bench-badge.mjs +0 -38
package/src/bench/bench.mjs
CHANGED
|
@@ -1,22 +1,22 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* bench.mjs — run the
|
|
4
|
+
* bench.mjs — run the benchmark suites and compare against the last saved run (#660).
|
|
5
5
|
*
|
|
6
6
|
* Usage:
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
);
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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 (#667).
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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
|
-
|
|
320
|
-
|
|
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
|
|
324
|
-
|
|
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
|
-
|
|
327
|
-
const
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
-
|
|
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
|
+
}
|