@decohere/ci-tools 0.0.1-dev.126 → 0.0.1-dev.13
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 +3 -18
- package/src/audit/enforce.mjs +5 -7
- package/src/audit/summarize.mjs +32 -10
- package/src/audit/upsert-issue.mjs +74 -25
- package/src/bench/bench.mjs +368 -134
- package/src/bench/trace.mjs +14 -18
- package/src/coverage/enforce-thresholds.mjs +144 -0
- package/src/pages/artifact.mjs +183 -38
- package/src/pages/clear-bench-history.mjs +27 -0
- package/src/pages/keep-prior-bench-badge.mjs +38 -0
- package/src/pages/restore-bench-history.mjs +28 -15
- package/src/release/npm-links.mjs +13 -6
- package/src/release/sync-dependencies.mjs +2 -1
- package/tests/lib/lcov.test.mjs +4 -4
- package/LICENSE +0 -15
- package/src/audit/format.mjs +0 -133
- package/src/bench/file-regressions.mjs +0 -76
- package/src/bench/history.mjs +0 -248
- package/src/bench/nightly.mjs +0 -118
- package/src/bench/regression-issue.mjs +0 -44
- package/src/github/credential-nudge.mjs +0 -226
- package/src/github/npm-bootstrap.mjs +0 -179
- package/src/github/oidc-bootstrap-nudge.mjs +0 -214
- package/src/github/publish-verify.mjs +0 -206
- package/src/github/smoke-create-launcher.mjs +0 -70
- package/src/lib/github-issue.mjs +0 -75
- package/src/lib/workspace-packages.mjs +0 -30
- package/src/pages/bench-page.mjs +0 -323
- package/src/pages/restore-badges.mjs +0 -58
- package/tests/audit/format.test.mjs +0 -115
- package/tests/bench/history.test.mjs +0 -279
- package/tests/bench/regression-issue.test.mjs +0 -42
- package/tests/github/credential-nudge.test.mjs +0 -112
- package/tests/github/npm-bootstrap.test.mjs +0 -125
- package/tests/github/oidc-bootstrap-nudge.test.mjs +0 -89
- package/tests/github/package-metadata.test.mjs +0 -69
- package/tests/github/publish-verify.test.mjs +0 -115
- package/tests/github/smoke-create-launcher.test.mjs +0 -71
- package/tests/lib/github-issue.test.mjs +0 -85
- package/tests/pages/bench-page.test.mjs +0 -123
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 benchmark
|
|
4
|
+
* bench.mjs — run the forge benchmark and compare against the last saved baseline.
|
|
5
5
|
*
|
|
6
6
|
* Usage:
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
122
|
-
if (
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
149
|
-
|
|
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
|
|
157
|
-
|
|
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
|
-
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
176
|
-
|
|
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
|
}
|
package/src/bench/trace.mjs
CHANGED
|
@@ -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/
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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 : "";
|