@crewhaus/tool-buildperf 0.7.0
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/README.md +194 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +715 -0
- package/dist/lib/bench.d.ts +77 -0
- package/dist/lib/bench.js +267 -0
- package/dist/lib/flaky.d.ts +112 -0
- package/dist/lib/flaky.js +346 -0
- package/dist/lib/size.d.ts +114 -0
- package/dist/lib/size.js +339 -0
- package/dist/paths.d.ts +42 -0
- package/dist/paths.js +158 -0
- package/package.json +46 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,715 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @crewhaus/tool-buildperf — did this change make the build worse.
|
|
3
|
+
*
|
|
4
|
+
* Three questions a merge gate asks after every change: is the artifact
|
|
5
|
+
* bigger, is it slower, and did the suite stop being trustworthy. Each has a
|
|
6
|
+
* cheap wrong answer that a harness will produce all day — a percentage
|
|
7
|
+
* between two numbers that were never comparable, a mean of five timings, a
|
|
8
|
+
* fail count divided by a run count — and each of those wrong answers is
|
|
9
|
+
* expensive, because a gate that cries wolf gets switched off within a month.
|
|
10
|
+
*
|
|
11
|
+
* So the shared property of this package is the refusal. `BundleSizeCheck`
|
|
12
|
+
* will not subtract two sizes measured at different compression levels.
|
|
13
|
+
* `BenchmarkCompare` will not call significance on five samples per side, and
|
|
14
|
+
* will not call a difference below the caller's declared noise floor a change
|
|
15
|
+
* at all. `FlakyTestDetect` will not report 3-of-5 as "60% flaky", and will
|
|
16
|
+
* not say "nondeterministic" about a suite that never varied its order.
|
|
17
|
+
*
|
|
18
|
+
* All statistics come from `@crewhaus/tool-math`'s kernel — the median, the
|
|
19
|
+
* MAD, Mann-Whitney and Wilson. None of it is re-derived here, so a change to
|
|
20
|
+
* how this repository computes an interval happens in one place.
|
|
21
|
+
*
|
|
22
|
+
* Only `BundleSizeCheck` touches the filesystem, read-only, and every
|
|
23
|
+
* caller-supplied path goes through the same containment resolver the other
|
|
24
|
+
* filesystem packages use. Nothing here runs a build, a benchmark or a test:
|
|
25
|
+
* the measurements come in, the honest reading of them comes out.
|
|
26
|
+
*/
|
|
27
|
+
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
28
|
+
import { join } from "node:path";
|
|
29
|
+
import { buildTool } from "@crewhaus/tool-builder";
|
|
30
|
+
import { statsKernel } from "@crewhaus/tool-math";
|
|
31
|
+
import { z } from "zod";
|
|
32
|
+
import { MIN_SAMPLES_FOR_SIGNIFICANCE, compareBenchmarks } from "./lib/bench";
|
|
33
|
+
import { TEST_STATUSES, detectFlaky } from "./lib/flaky";
|
|
34
|
+
import { COMPRESSION_ALGORITHMS, JOIN_MODES, MAX_GLOB_LENGTH, baselineBasisMismatch, comparabilityKey, compileGlob, compressedSize, compressionParameters, joinKey, parametersMismatch, } from "./lib/size";
|
|
35
|
+
import { resolveSafe, toPosix } from "./paths";
|
|
36
|
+
const json = (value) => JSON.stringify(value);
|
|
37
|
+
/** Ceilings that keep one pathological input from becoming a hang. */
|
|
38
|
+
const LIMITS = {
|
|
39
|
+
/** A build with more artifacts than this is a tree, not a bundle. */
|
|
40
|
+
entries: 2_000,
|
|
41
|
+
/** Compressing a file this big to weigh it is not the intended use. */
|
|
42
|
+
fileBytes: 512 * 1024 * 1024,
|
|
43
|
+
budgets: 200,
|
|
44
|
+
benchmarks: 500,
|
|
45
|
+
samplesPerSide: 10_000,
|
|
46
|
+
runs: 200,
|
|
47
|
+
testsPerRun: 5_000,
|
|
48
|
+
masks: 64,
|
|
49
|
+
};
|
|
50
|
+
/** The zlib build that produced these numbers; see `runtime` in the report. */
|
|
51
|
+
const ZLIB_VERSION = process.versions.zlib ?? "unknown";
|
|
52
|
+
// --- BundleSizeCheck --------------------------------------------------------
|
|
53
|
+
// Not `.strict()`: a stored baseline is a previous REPORT, which carries the
|
|
54
|
+
// derived `comparabilityKey` alongside the parameters. Rejecting the report
|
|
55
|
+
// this tool just produced would make "store it and pass it back" a lie.
|
|
56
|
+
const parametersSchema = z.object({
|
|
57
|
+
algorithm: z.enum(COMPRESSION_ALGORITHMS),
|
|
58
|
+
level: z.number().int().min(0).max(11).nullable(),
|
|
59
|
+
tuning: z.record(z.number()).optional(),
|
|
60
|
+
});
|
|
61
|
+
const baselineSchema = z.object({
|
|
62
|
+
// Optional ON PURPOSE. A baseline that never declared its parameters is
|
|
63
|
+
// refused by the tool with an explanation, which is a more useful answer
|
|
64
|
+
// than a schema error about a missing field.
|
|
65
|
+
parameters: parametersSchema.optional(),
|
|
66
|
+
entries: z
|
|
67
|
+
.array(z.object({
|
|
68
|
+
path: z.string().min(1),
|
|
69
|
+
bytes: z.number().int().nonnegative(),
|
|
70
|
+
compressedBytes: z.number().int().nonnegative().nullable().optional(),
|
|
71
|
+
}))
|
|
72
|
+
.max(LIMITS.entries),
|
|
73
|
+
runtime: z.object({ zlib: z.string() }).optional(),
|
|
74
|
+
});
|
|
75
|
+
/** Every file under a directory, workspace-relative, sorted, capped. */
|
|
76
|
+
function filesUnder(root, rel, out) {
|
|
77
|
+
for (const entry of readdirSync(join(root, rel), { withFileTypes: true }).sort((a, b) => a.name < b.name ? -1 : 1)) {
|
|
78
|
+
const child = rel === "" ? entry.name : `${rel}/${entry.name}`;
|
|
79
|
+
if (entry.isDirectory()) {
|
|
80
|
+
filesUnder(root, child, out);
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (entry.isFile()) {
|
|
84
|
+
out.files.push(child);
|
|
85
|
+
}
|
|
86
|
+
else if (entry.isSymbolicLink()) {
|
|
87
|
+
// `readdir` reports links by their own type, so a link is neither a file
|
|
88
|
+
// nor a directory here and is not followed. Weighing both the link and
|
|
89
|
+
// its target would double-count, and following one out of the workspace
|
|
90
|
+
// is what the resolver exists to stop — but silently dropping it would
|
|
91
|
+
// under-report the total, so it is counted and named in the report.
|
|
92
|
+
out.links.push(child);
|
|
93
|
+
}
|
|
94
|
+
if (out.files.length > LIMITS.entries) {
|
|
95
|
+
throw new Error(`more than ${LIMITS.entries} files under "${rel}" — narrow it with extensions, or point at the build output directory rather than the project root`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
function measure(paths, parameters, mode, custom) {
|
|
101
|
+
return paths.map((path) => {
|
|
102
|
+
const at = resolveSafe("BundleSizeCheck", path);
|
|
103
|
+
const stats = statSync(at.real);
|
|
104
|
+
if (!stats.isFile())
|
|
105
|
+
throw new Error(`"${path}" is not a file`);
|
|
106
|
+
if (stats.size > LIMITS.fileBytes) {
|
|
107
|
+
throw new Error(`"${path}" is ${stats.size} bytes, over the ${LIMITS.fileBytes}-byte limit`);
|
|
108
|
+
}
|
|
109
|
+
const bytes = readFileSync(at.real);
|
|
110
|
+
return {
|
|
111
|
+
path: at.rel,
|
|
112
|
+
key: joinKey(at.rel, mode, custom).key,
|
|
113
|
+
bytes: bytes.byteLength,
|
|
114
|
+
compressedBytes: parameters.algorithm === "none" ? null : compressedSize(bytes, parameters),
|
|
115
|
+
};
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
/** The number a budget and a regression are measured against. */
|
|
119
|
+
const primarySize = (entry) => entry.compressedBytes ?? entry.bytes;
|
|
120
|
+
function percentDelta(before, after) {
|
|
121
|
+
// A baseline of zero bytes has no percentage: every increase is infinite.
|
|
122
|
+
// The absolute delta is still reported, which is the honest half.
|
|
123
|
+
if (before === 0)
|
|
124
|
+
return null;
|
|
125
|
+
return ((after - before) / before) * 100;
|
|
126
|
+
}
|
|
127
|
+
export const bundleSizeCheck = buildTool({
|
|
128
|
+
name: "BundleSizeCheck",
|
|
129
|
+
description: "Weigh build artifacts — raw and gzip or brotli — against declared budgets and a stored baseline, and report what actually grew. Use it as the size gate on a pull request, instead of a model comparing two numbers whose provenance it cannot see. The compression parameters are part of the measurement and travel with the report: a baseline produced at a different level, with a different algorithm, or with no declared parameters at all is REFUSED rather than differenced, because the gap between gzip level 6 and level 9 is the same few percent a regression is, and subtracting them produces a confident number about nothing. Hashed filenames (app-4f2a1c.js) can be joined on a normalised key so a content-hash change is not reported as every file being new, and two artifacts that normalise to the same key are reported as ambiguous rather than silently paired. The report is itself a baseline: store it and pass it back next time.",
|
|
130
|
+
inputSchema: z
|
|
131
|
+
.object({
|
|
132
|
+
files: z
|
|
133
|
+
.array(z.string().min(1))
|
|
134
|
+
.max(LIMITS.entries)
|
|
135
|
+
.optional()
|
|
136
|
+
.describe("workspace-relative artifact paths"),
|
|
137
|
+
directory: z.string().min(1).optional().describe("or a directory to weigh every file under"),
|
|
138
|
+
extensions: z
|
|
139
|
+
.array(z.string().min(1))
|
|
140
|
+
.max(32)
|
|
141
|
+
.optional()
|
|
142
|
+
.describe("with directory: keep only these extensions, e.g. ['.js','.css']"),
|
|
143
|
+
algorithm: z
|
|
144
|
+
.enum(COMPRESSION_ALGORITHMS)
|
|
145
|
+
.optional()
|
|
146
|
+
.describe("gzip (default), brotli, or none for raw bytes only"),
|
|
147
|
+
level: z
|
|
148
|
+
.number()
|
|
149
|
+
.int()
|
|
150
|
+
.min(0)
|
|
151
|
+
.max(11)
|
|
152
|
+
.optional()
|
|
153
|
+
.describe("compression level; gzip 0-9 (default 9), brotli 0-11 (default 11)"),
|
|
154
|
+
join: z
|
|
155
|
+
.enum(JOIN_MODES)
|
|
156
|
+
.optional()
|
|
157
|
+
.describe("exact (default), auto to strip content hashes, or custom"),
|
|
158
|
+
joinPattern: z
|
|
159
|
+
.string()
|
|
160
|
+
.optional()
|
|
161
|
+
.describe("with join=custom: a regex whose matches are replaced by [hash]"),
|
|
162
|
+
baseline: baselineSchema.optional().describe("a previous report, inline"),
|
|
163
|
+
baselineFile: z
|
|
164
|
+
.string()
|
|
165
|
+
.min(1)
|
|
166
|
+
.optional()
|
|
167
|
+
.describe("or a workspace-relative JSON file holding one"),
|
|
168
|
+
budgets: z
|
|
169
|
+
.array(z
|
|
170
|
+
.object({
|
|
171
|
+
pattern: z
|
|
172
|
+
.string()
|
|
173
|
+
.min(1)
|
|
174
|
+
.max(MAX_GLOB_LENGTH)
|
|
175
|
+
.describe("glob: * within a segment, ** across segments"),
|
|
176
|
+
maxBytes: z.number().int().positive(),
|
|
177
|
+
on: z.enum(["compressed", "raw"]).optional(),
|
|
178
|
+
})
|
|
179
|
+
.strict())
|
|
180
|
+
.max(LIMITS.budgets)
|
|
181
|
+
.optional(),
|
|
182
|
+
totalMaxBytes: z.number().int().positive().optional(),
|
|
183
|
+
maxIncreasePercent: z
|
|
184
|
+
.number()
|
|
185
|
+
.min(0)
|
|
186
|
+
.optional()
|
|
187
|
+
.describe("fail when a matched entry or the total grows by more than this"),
|
|
188
|
+
})
|
|
189
|
+
.strict()
|
|
190
|
+
.refine((v) => (v.files === undefined) !== (v.directory === undefined), {
|
|
191
|
+
message: "give exactly one of files or directory",
|
|
192
|
+
})
|
|
193
|
+
.refine((v) => !(v.baseline !== undefined && v.baselineFile !== undefined), {
|
|
194
|
+
message: "give at most one of baseline or baselineFile",
|
|
195
|
+
})
|
|
196
|
+
.refine((v) => v.join !== "custom" || v.joinPattern !== undefined, {
|
|
197
|
+
message: "join=custom needs joinPattern",
|
|
198
|
+
}),
|
|
199
|
+
readOnly: true,
|
|
200
|
+
concurrencySafe: true,
|
|
201
|
+
execute: async (input) => {
|
|
202
|
+
const warnings = [];
|
|
203
|
+
let parameters;
|
|
204
|
+
try {
|
|
205
|
+
parameters = compressionParameters(input.algorithm ?? "gzip", input.level);
|
|
206
|
+
}
|
|
207
|
+
catch (err) {
|
|
208
|
+
return err.message;
|
|
209
|
+
}
|
|
210
|
+
const mode = input.join ?? "exact";
|
|
211
|
+
let custom;
|
|
212
|
+
if (input.joinPattern !== undefined) {
|
|
213
|
+
try {
|
|
214
|
+
custom = new RegExp(input.joinPattern, "g");
|
|
215
|
+
}
|
|
216
|
+
catch (err) {
|
|
217
|
+
return `joinPattern /${input.joinPattern}/ is not a valid regular expression: ${err.message}`;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
let paths;
|
|
221
|
+
if (input.directory !== undefined) {
|
|
222
|
+
const at = resolveSafe("BundleSizeCheck", input.directory);
|
|
223
|
+
if (!statSync(at.real).isDirectory())
|
|
224
|
+
return `"${input.directory}" is not a directory`;
|
|
225
|
+
const walk = filesUnder(at.real, "", { files: [], links: [] });
|
|
226
|
+
const found = walk.files.map((rel) => toPosix(join(at.rel, rel)));
|
|
227
|
+
const extensions = input.extensions;
|
|
228
|
+
paths =
|
|
229
|
+
extensions === undefined
|
|
230
|
+
? found
|
|
231
|
+
: found.filter((p) => extensions.some((e) => p.endsWith(e)));
|
|
232
|
+
if (walk.links.length > 0) {
|
|
233
|
+
// Workspace-relative, like every other path in the report: a warning
|
|
234
|
+
// naming `alias.js` where the entries say `dist/alias.js` sends the
|
|
235
|
+
// reader looking in the wrong directory.
|
|
236
|
+
const named = walk.links.slice(0, 5).map((rel) => toPosix(join(at.rel, rel)));
|
|
237
|
+
warnings.push(`${walk.links.length} symlink(s) under "${at.rel}" were not weighed (${named.join(", ")}); pass them in files: [...] to measure what they point at`);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
else {
|
|
241
|
+
paths = [...(input.files ?? [])];
|
|
242
|
+
const duplicates = paths.filter((p, i) => paths.indexOf(p) !== i);
|
|
243
|
+
if (duplicates.length > 0) {
|
|
244
|
+
return `the same path was given twice (${[...new Set(duplicates)].join(", ")}); one file cannot contribute two rows to a size report`;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
if (paths.length === 0) {
|
|
248
|
+
return "no files matched — there is nothing to weigh, which is not the same as a build of zero bytes";
|
|
249
|
+
}
|
|
250
|
+
const entries = measure(paths, parameters, mode, custom);
|
|
251
|
+
const totals = entries.reduce((acc, e) => ({
|
|
252
|
+
bytes: acc.bytes + e.bytes,
|
|
253
|
+
compressedBytes: e.compressedBytes === null
|
|
254
|
+
? acc.compressedBytes
|
|
255
|
+
: (acc.compressedBytes ?? 0) + e.compressedBytes,
|
|
256
|
+
}), { bytes: 0, compressedBytes: null });
|
|
257
|
+
// --- budgets ---
|
|
258
|
+
const budgets = input.budgets ?? [];
|
|
259
|
+
const violations = [];
|
|
260
|
+
for (const budget of budgets) {
|
|
261
|
+
const on = budget.on ?? (parameters.algorithm === "none" ? "raw" : "compressed");
|
|
262
|
+
if (on === "compressed" && parameters.algorithm === "none") {
|
|
263
|
+
return `a budget on the compressed size cannot be checked with algorithm "none"; choose gzip or brotli, or set on: "raw"`;
|
|
264
|
+
}
|
|
265
|
+
// Compiled once per budget, not once per artifact: at the 200-budget and
|
|
266
|
+
// 2,000-entry ceilings the per-path form tokenizes 400,000 times.
|
|
267
|
+
let match;
|
|
268
|
+
try {
|
|
269
|
+
match = compileGlob(budget.pattern);
|
|
270
|
+
}
|
|
271
|
+
catch (err) {
|
|
272
|
+
return err.message;
|
|
273
|
+
}
|
|
274
|
+
const matched = entries.filter((e) => match(e.path));
|
|
275
|
+
for (const entry of matched) {
|
|
276
|
+
const size = on === "raw" ? entry.bytes : (entry.compressedBytes ?? entry.bytes);
|
|
277
|
+
if (size > budget.maxBytes) {
|
|
278
|
+
violations.push({
|
|
279
|
+
pattern: budget.pattern,
|
|
280
|
+
path: entry.path,
|
|
281
|
+
on,
|
|
282
|
+
size,
|
|
283
|
+
maxBytes: budget.maxBytes,
|
|
284
|
+
overBytes: size - budget.maxBytes,
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
if (matched.length === 0) {
|
|
289
|
+
// A budget that matches nothing passes vacuously, which is the most
|
|
290
|
+
// common way a size gate stops gating: the bundler renamed the entry.
|
|
291
|
+
violations.push({
|
|
292
|
+
pattern: budget.pattern,
|
|
293
|
+
matched: 0,
|
|
294
|
+
why: "this budget matched no artifact, so it checked nothing — a renamed entry point is the usual cause",
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
if (input.totalMaxBytes !== undefined) {
|
|
299
|
+
const total = totals.compressedBytes ?? totals.bytes;
|
|
300
|
+
if (total > input.totalMaxBytes) {
|
|
301
|
+
violations.push({
|
|
302
|
+
pattern: "<total>",
|
|
303
|
+
on: parameters.algorithm === "none" ? "raw" : "compressed",
|
|
304
|
+
size: total,
|
|
305
|
+
maxBytes: input.totalMaxBytes,
|
|
306
|
+
overBytes: total - input.totalMaxBytes,
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
// --- baseline ---
|
|
311
|
+
let baseline = input.baseline;
|
|
312
|
+
if (input.baselineFile !== undefined) {
|
|
313
|
+
const at = resolveSafe("BundleSizeCheck", input.baselineFile);
|
|
314
|
+
let parsed;
|
|
315
|
+
try {
|
|
316
|
+
parsed = JSON.parse(readFileSync(at.real, "utf-8"));
|
|
317
|
+
}
|
|
318
|
+
catch (err) {
|
|
319
|
+
return `baseline file "${at.rel}" is not valid JSON: ${err.message}`;
|
|
320
|
+
}
|
|
321
|
+
const result = baselineSchema.safeParse(parsed);
|
|
322
|
+
if (!result.success) {
|
|
323
|
+
return `baseline file "${at.rel}" is not a size report: ${result.error.issues
|
|
324
|
+
.map((i) => `${i.path.join(".") || "<root>"}: ${i.message}`)
|
|
325
|
+
.join("; ")}`;
|
|
326
|
+
}
|
|
327
|
+
baseline = result.data;
|
|
328
|
+
}
|
|
329
|
+
// Two artifacts that normalise to one key cannot both be "the" successor
|
|
330
|
+
// of the baseline row under it. Pairing either one arbitrarily produces a
|
|
331
|
+
// delta for a file the reader never chose, so both are set aside.
|
|
332
|
+
const byKey = new Map();
|
|
333
|
+
for (const entry of entries) {
|
|
334
|
+
byKey.set(entry.key, [...(byKey.get(entry.key) ?? []), entry.path]);
|
|
335
|
+
}
|
|
336
|
+
const ambiguous = [...byKey.entries()]
|
|
337
|
+
.filter(([, hits]) => hits.length > 1)
|
|
338
|
+
.map(([key, hits]) => ({ key, paths: hits }));
|
|
339
|
+
// The growth gate is applied AFTER the baseline block rather than inside
|
|
340
|
+
// it, because the case that matters is the one where the block produces no
|
|
341
|
+
// rows: a build that renames every file joins nothing, and a gate that
|
|
342
|
+
// only walks matched rows then inspects nothing and reports "pass".
|
|
343
|
+
let growth = null;
|
|
344
|
+
let comparison;
|
|
345
|
+
if (baseline === undefined) {
|
|
346
|
+
comparison = {
|
|
347
|
+
status: "no-baseline",
|
|
348
|
+
why: "no baseline was given, so this report is a measurement, not a comparison",
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
else if (baseline.parameters === undefined) {
|
|
352
|
+
comparison = {
|
|
353
|
+
status: "refused",
|
|
354
|
+
why: "the baseline does not declare the compression parameters it was measured with, so there is no way to know whether its sizes are comparable to these. Re-record it with a report from this tool",
|
|
355
|
+
parameters,
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
else {
|
|
359
|
+
const baselineParameters = {
|
|
360
|
+
algorithm: baseline.parameters.algorithm,
|
|
361
|
+
level: baseline.parameters.level,
|
|
362
|
+
tuning: baseline.parameters.tuning ?? {},
|
|
363
|
+
};
|
|
364
|
+
const mismatch = parametersMismatch(baselineParameters, parameters) ??
|
|
365
|
+
// The settings agreeing is not enough: the column has to agree too.
|
|
366
|
+
baselineBasisMismatch(parameters.algorithm, baseline.entries);
|
|
367
|
+
if (mismatch !== null) {
|
|
368
|
+
comparison = {
|
|
369
|
+
status: "refused",
|
|
370
|
+
why: mismatch,
|
|
371
|
+
baselineParameters: {
|
|
372
|
+
...baselineParameters,
|
|
373
|
+
comparabilityKey: comparabilityKey(baselineParameters),
|
|
374
|
+
},
|
|
375
|
+
parameters: { ...parameters, comparabilityKey: comparabilityKey(parameters) },
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
else {
|
|
379
|
+
if (baseline.runtime !== undefined && baseline.runtime.zlib !== ZLIB_VERSION) {
|
|
380
|
+
warnings.push(`the baseline was compressed by a different zlib build (${baseline.runtime.zlib} vs ${ZLIB_VERSION}); the parameters match, so the comparison stands, but a handful of bytes may come from the library rather than from the change`);
|
|
381
|
+
}
|
|
382
|
+
const ambiguousKeys = new Set(ambiguous.map((a) => a.key));
|
|
383
|
+
// The baseline is normalised with the SAME mode, and gets the same
|
|
384
|
+
// treatment when two of its rows collapse onto one key: neither is
|
|
385
|
+
// used. Keeping the first would pair the head against whichever row
|
|
386
|
+
// the baseline happened to list first, which is the arbitrary pairing
|
|
387
|
+
// the head-side check already refuses to make.
|
|
388
|
+
const baselineGroups = new Map();
|
|
389
|
+
for (const entry of baseline.entries) {
|
|
390
|
+
const key = joinKey(entry.path, mode, custom).key;
|
|
391
|
+
baselineGroups.set(key, [
|
|
392
|
+
...(baselineGroups.get(key) ?? []),
|
|
393
|
+
{ path: entry.path, bytes: entry.bytes, compressed: entry.compressedBytes ?? null },
|
|
394
|
+
]);
|
|
395
|
+
}
|
|
396
|
+
const baselineByKey = new Map();
|
|
397
|
+
const baselineDuplicates = [];
|
|
398
|
+
const baselineSetAside = [];
|
|
399
|
+
for (const [key, rows] of baselineGroups) {
|
|
400
|
+
const only = rows[0];
|
|
401
|
+
if (rows.length > 1 || only === undefined) {
|
|
402
|
+
baselineDuplicates.push(key);
|
|
403
|
+
ambiguousKeys.add(key);
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
if (ambiguousKeys.has(key)) {
|
|
407
|
+
// Two HEAD artifacts share this key, so this baseline row has no
|
|
408
|
+
// single successor either. Leaving it in the map drops it out the
|
|
409
|
+
// far end as `removed` — and a reader acts on "removed": they go
|
|
410
|
+
// looking for a deleted chunk that was in fact split in two.
|
|
411
|
+
baselineSetAside.push(only.path);
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
baselineByKey.set(key, only);
|
|
415
|
+
}
|
|
416
|
+
const matched = [];
|
|
417
|
+
const added = [];
|
|
418
|
+
for (const entry of entries) {
|
|
419
|
+
if (ambiguousKeys.has(entry.key))
|
|
420
|
+
continue;
|
|
421
|
+
const before = baselineByKey.get(entry.key);
|
|
422
|
+
if (before === undefined) {
|
|
423
|
+
added.push(entry.path);
|
|
424
|
+
continue;
|
|
425
|
+
}
|
|
426
|
+
baselineByKey.delete(entry.key);
|
|
427
|
+
const beforeSize = before.compressed ?? before.bytes;
|
|
428
|
+
const afterSize = primarySize(entry);
|
|
429
|
+
matched.push({
|
|
430
|
+
path: entry.path,
|
|
431
|
+
key: entry.key,
|
|
432
|
+
...(before.path === entry.path ? {} : { baselinePath: before.path }),
|
|
433
|
+
baselineBytes: before.bytes,
|
|
434
|
+
bytes: entry.bytes,
|
|
435
|
+
baselineCompressedBytes: before.compressed,
|
|
436
|
+
compressedBytes: entry.compressedBytes,
|
|
437
|
+
deltaBytes: afterSize - beforeSize,
|
|
438
|
+
deltaPercent: percentDelta(beforeSize, afterSize),
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
const removed = [...baselineByKey.values()].map((b) => b.path).sort();
|
|
442
|
+
const baselineTotal = baseline.entries.reduce((acc, e) => acc + (e.compressedBytes ?? e.bytes), 0);
|
|
443
|
+
const headTotal = totals.compressedBytes ?? totals.bytes;
|
|
444
|
+
const total = {
|
|
445
|
+
baseline: baselineTotal,
|
|
446
|
+
current: headTotal,
|
|
447
|
+
deltaBytes: headTotal - baselineTotal,
|
|
448
|
+
deltaPercent: percentDelta(baselineTotal, headTotal),
|
|
449
|
+
};
|
|
450
|
+
comparison = {
|
|
451
|
+
status: "compared",
|
|
452
|
+
on: parameters.algorithm === "none" ? "raw" : "compressed",
|
|
453
|
+
matched,
|
|
454
|
+
added,
|
|
455
|
+
removed,
|
|
456
|
+
...(baselineDuplicates.length > 0 ? { baselineAmbiguousKeys: baselineDuplicates } : {}),
|
|
457
|
+
...(baselineSetAside.length > 0 ? { baselineSetAside } : {}),
|
|
458
|
+
total,
|
|
459
|
+
};
|
|
460
|
+
growth = {
|
|
461
|
+
rows: matched,
|
|
462
|
+
total,
|
|
463
|
+
};
|
|
464
|
+
if (matched.length === 0 && baseline.entries.length > 0 && mode === "exact") {
|
|
465
|
+
warnings.push('nothing joined against the baseline by exact path. If this build writes content hashes into filenames, every run looks entirely new — re-run with join: "auto"');
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
// --- growth ---
|
|
470
|
+
if (input.maxIncreasePercent !== undefined) {
|
|
471
|
+
const limit = input.maxIncreasePercent;
|
|
472
|
+
if (growth === null) {
|
|
473
|
+
const status = comparison.status;
|
|
474
|
+
// A refusal already lands on `indeterminate`, which is not a green
|
|
475
|
+
// light. "no-baseline" lands on `pass`, so a growth limit declared
|
|
476
|
+
// without a baseline would gate nothing and still report success —
|
|
477
|
+
// the same vacuous pass a budget that matches nothing gets.
|
|
478
|
+
if (status === "no-baseline") {
|
|
479
|
+
violations.push({
|
|
480
|
+
pattern: "<regression>",
|
|
481
|
+
maxIncreasePercent: limit,
|
|
482
|
+
why: "a growth limit was declared but no baseline was given, so nothing was gated on growth",
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
else {
|
|
487
|
+
for (const row of growth.rows) {
|
|
488
|
+
if (row.deltaPercent !== null && row.deltaPercent > limit) {
|
|
489
|
+
violations.push({
|
|
490
|
+
pattern: "<regression>",
|
|
491
|
+
path: row.path,
|
|
492
|
+
deltaPercent: row.deltaPercent,
|
|
493
|
+
maxIncreasePercent: limit,
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
// The total, which is the number the gate is actually about. Every
|
|
498
|
+
// matched row can be flat while the bundle doubles: a new chunk is
|
|
499
|
+
// `added`, not matched, and a build that writes a fresh content hash
|
|
500
|
+
// into every filename matches nothing at all under join "exact".
|
|
501
|
+
const totalDelta = growth.total.deltaPercent;
|
|
502
|
+
if (totalDelta !== null && totalDelta > limit) {
|
|
503
|
+
violations.push({
|
|
504
|
+
pattern: "<total-regression>",
|
|
505
|
+
deltaPercent: totalDelta,
|
|
506
|
+
maxIncreasePercent: limit,
|
|
507
|
+
baselineBytes: growth.total.baseline,
|
|
508
|
+
bytes: growth.total.current,
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
else if (totalDelta === null && growth.total.current > growth.total.baseline) {
|
|
512
|
+
violations.push({
|
|
513
|
+
pattern: "<total-regression>",
|
|
514
|
+
maxIncreasePercent: limit,
|
|
515
|
+
baselineBytes: growth.total.baseline,
|
|
516
|
+
bytes: growth.total.current,
|
|
517
|
+
why: "the baseline total is 0 bytes, so growth has no percentage to compare against the limit and this run is larger — the gate could not be applied",
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
const refused = comparison.status === "refused";
|
|
523
|
+
if (ambiguous.length > 0) {
|
|
524
|
+
warnings.push(`${ambiguous.length} normalised key(s) cover more than one artifact; those entries are excluded from the comparison rather than paired arbitrarily`);
|
|
525
|
+
}
|
|
526
|
+
const verdict = violations.length > 0 ? "fail" : refused ? "indeterminate" : "pass";
|
|
527
|
+
return json({
|
|
528
|
+
parameters: { ...parameters, comparabilityKey: comparabilityKey(parameters) },
|
|
529
|
+
runtime: { zlib: ZLIB_VERSION },
|
|
530
|
+
join: { mode, ...(ambiguous.length > 0 ? { ambiguous } : {}) },
|
|
531
|
+
entries,
|
|
532
|
+
totals: { files: entries.length, ...totals },
|
|
533
|
+
budgets: { checked: budgets.length, violations },
|
|
534
|
+
comparison,
|
|
535
|
+
...(warnings.length > 0 ? { warnings } : {}),
|
|
536
|
+
verdict,
|
|
537
|
+
});
|
|
538
|
+
},
|
|
539
|
+
});
|
|
540
|
+
// --- BenchmarkCompare -------------------------------------------------------
|
|
541
|
+
export const benchmarkCompare = buildTool({
|
|
542
|
+
name: "BenchmarkCompare",
|
|
543
|
+
description: "Compare benchmark samples from two builds and say which differences are real. Use it on the output of a benchmark run at base and at head, instead of a model eyeballing two means. Benchmark timings are right-skewed — a GC pause is a tail event, not noise to average away — so the location estimate is the median and the test is Mann-Whitney, not a t-test on means. Two refusals are the point: a difference inside the noise floor you DECLARE (required, never guessed) is reported as no detectable change however small p is, and below 8 samples per side no significance is called at all, because the normal approximation to U is materially wrong there. At the usual default of 5 runs the answer is 'cannot tell', and this tool says so instead of picking a side. A framework that reports only an aggregate mean per side is compared against the noise floor and explicitly left untested.",
|
|
544
|
+
inputSchema: z
|
|
545
|
+
.object({
|
|
546
|
+
benchmarks: z
|
|
547
|
+
.array(z
|
|
548
|
+
.object({
|
|
549
|
+
name: z.string().min(1),
|
|
550
|
+
base: z
|
|
551
|
+
.array(z.number().finite())
|
|
552
|
+
.max(LIMITS.samplesPerSide)
|
|
553
|
+
.optional()
|
|
554
|
+
.describe("per-iteration samples from the base build"),
|
|
555
|
+
head: z.array(z.number().finite()).max(LIMITS.samplesPerSide).optional(),
|
|
556
|
+
baseAggregate: z
|
|
557
|
+
.number()
|
|
558
|
+
.finite()
|
|
559
|
+
.optional()
|
|
560
|
+
.describe("or one pre-aggregated number, when that is all the framework reports"),
|
|
561
|
+
headAggregate: z.number().finite().optional(),
|
|
562
|
+
})
|
|
563
|
+
.strict())
|
|
564
|
+
.min(1)
|
|
565
|
+
.max(LIMITS.benchmarks),
|
|
566
|
+
noiseFloorPercent: z
|
|
567
|
+
.number()
|
|
568
|
+
.min(0)
|
|
569
|
+
.max(100)
|
|
570
|
+
.describe("required: the smallest relative difference this machine can actually resolve; differences under it are reported as no detectable change"),
|
|
571
|
+
unit: z.string().min(1).optional().describe("what the samples measure, e.g. ms, ns/op"),
|
|
572
|
+
lowerIsBetter: z
|
|
573
|
+
.boolean()
|
|
574
|
+
.optional()
|
|
575
|
+
.describe("true (default) for durations, false for throughput"),
|
|
576
|
+
alpha: z.number().gt(0).lt(1).optional().describe("significance level; default 0.05"),
|
|
577
|
+
collection: z
|
|
578
|
+
.enum(["interleaved", "sequential", "unknown"])
|
|
579
|
+
.optional()
|
|
580
|
+
.describe("how the two sides were sampled; interleaving is what cancels machine drift"),
|
|
581
|
+
continuityCorrection: z
|
|
582
|
+
.boolean()
|
|
583
|
+
.optional()
|
|
584
|
+
.describe("default true, matching R's wilcox.test(correct = TRUE)"),
|
|
585
|
+
})
|
|
586
|
+
.strict(),
|
|
587
|
+
readOnly: true,
|
|
588
|
+
concurrencySafe: true,
|
|
589
|
+
execute: async (input) => {
|
|
590
|
+
const inputs = input.benchmarks.map((b) => ({
|
|
591
|
+
name: b.name,
|
|
592
|
+
...(b.base === undefined ? {} : { base: b.base }),
|
|
593
|
+
...(b.head === undefined ? {} : { head: b.head }),
|
|
594
|
+
...(b.baseAggregate === undefined ? {} : { baseAggregate: b.baseAggregate }),
|
|
595
|
+
...(b.headAggregate === undefined ? {} : { headAggregate: b.headAggregate }),
|
|
596
|
+
}));
|
|
597
|
+
let report;
|
|
598
|
+
try {
|
|
599
|
+
report = compareBenchmarks(inputs, {
|
|
600
|
+
noiseFloorPercent: input.noiseFloorPercent,
|
|
601
|
+
...(input.alpha === undefined ? {} : { alpha: input.alpha }),
|
|
602
|
+
...(input.lowerIsBetter === undefined ? {} : { lowerIsBetter: input.lowerIsBetter }),
|
|
603
|
+
...(input.continuityCorrection === undefined
|
|
604
|
+
? {}
|
|
605
|
+
: { continuityCorrection: input.continuityCorrection }),
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
catch (err) {
|
|
609
|
+
return err.message;
|
|
610
|
+
}
|
|
611
|
+
const collection = input.collection ?? "unknown";
|
|
612
|
+
const caveats = [];
|
|
613
|
+
if (collection !== "interleaved") {
|
|
614
|
+
// Machine drift is a bias, not a variance: a base suite run at 09:00 on
|
|
615
|
+
// a cold machine and a head suite at 09:20 on a hot one differ by the
|
|
616
|
+
// machine. No test on the samples can see that, so it is said out loud.
|
|
617
|
+
caveats.push(collection === "sequential"
|
|
618
|
+
? "the two sides were sampled one after the other, so thermal drift and background load are confounded with the change; interleave base and head per benchmark to remove it"
|
|
619
|
+
: "the sampling order was not declared; unless base and head runs were interleaved, machine drift is confounded with the change and no test on these samples can separate them");
|
|
620
|
+
}
|
|
621
|
+
const underpowered = report.benchmarks.filter((b) => b.test !== null && !b.test.normalApproximationValid).length;
|
|
622
|
+
if (underpowered > 0) {
|
|
623
|
+
caveats.push(`${underpowered} benchmark(s) had fewer than ${MIN_SAMPLES_FOR_SIGNIFICANCE} samples on a side; no significance was called for them`);
|
|
624
|
+
}
|
|
625
|
+
return json({
|
|
626
|
+
...report,
|
|
627
|
+
...(input.unit === undefined ? {} : { unit: input.unit }),
|
|
628
|
+
collection,
|
|
629
|
+
...(caveats.length > 0 ? { caveats } : {}),
|
|
630
|
+
});
|
|
631
|
+
},
|
|
632
|
+
});
|
|
633
|
+
// --- FlakyTestDetect --------------------------------------------------------
|
|
634
|
+
export const flakyTestDetect = buildTool({
|
|
635
|
+
name: "FlakyTestDetect",
|
|
636
|
+
description: "Classify each test across repeated runs as passing, flaky, failing every run or never observed, with a Wilson interval on its failure rate rather than a fail/run ratio. Use it on the results of running a suite several times, to decide what to quarantine. Three failures in five runs is not '60% flaky' — the interval is 23%-88%, which does not distinguish annoying from broken — so the quarantine recommendation keys on the interval's lower bound and says 'run it more' when that bound is not yet clear of the floor. It separates nondeterminism from order dependence when the input carries execution order: for a test that both passed and failed it names the tests that ran before it in every failure and in none of the passes. When every run used the SAME order it reports that the question is undecidable rather than answering 'nondeterministic', and a skipped test is never counted as a pass. One run is always inconclusive.",
|
|
637
|
+
inputSchema: z
|
|
638
|
+
.object({
|
|
639
|
+
runs: z
|
|
640
|
+
.array(z
|
|
641
|
+
.object({
|
|
642
|
+
id: z.string().min(1).optional(),
|
|
643
|
+
seed: z
|
|
644
|
+
.string()
|
|
645
|
+
.min(1)
|
|
646
|
+
.optional()
|
|
647
|
+
.describe("the shuffle seed, if the runner has one"),
|
|
648
|
+
tests: z
|
|
649
|
+
.array(z
|
|
650
|
+
.object({
|
|
651
|
+
id: z.string().min(1),
|
|
652
|
+
status: z.enum(TEST_STATUSES),
|
|
653
|
+
error: z.string().max(10_000).optional(),
|
|
654
|
+
signature: z
|
|
655
|
+
.string()
|
|
656
|
+
.min(1)
|
|
657
|
+
.optional()
|
|
658
|
+
.describe("a pre-computed failure signature, e.g. from ErrorCluster"),
|
|
659
|
+
})
|
|
660
|
+
.strict())
|
|
661
|
+
.max(LIMITS.testsPerRun)
|
|
662
|
+
.describe("in execution order"),
|
|
663
|
+
})
|
|
664
|
+
.strict())
|
|
665
|
+
.min(1)
|
|
666
|
+
.max(LIMITS.runs),
|
|
667
|
+
confidence: z
|
|
668
|
+
.enum(["0.80", "0.90", "0.95", "0.99"])
|
|
669
|
+
.optional()
|
|
670
|
+
.describe("two-sided confidence for the interval; default 0.95"),
|
|
671
|
+
quarantineLowerBound: z
|
|
672
|
+
.number()
|
|
673
|
+
.min(0)
|
|
674
|
+
.max(1)
|
|
675
|
+
.optional()
|
|
676
|
+
.describe("recommend quarantine only when the interval's LOWER bound clears this; default 0.05"),
|
|
677
|
+
masks: z
|
|
678
|
+
.array(z
|
|
679
|
+
.object({
|
|
680
|
+
pattern: z.string().min(1),
|
|
681
|
+
with: z.string().optional(),
|
|
682
|
+
flags: z.string().max(8).optional(),
|
|
683
|
+
})
|
|
684
|
+
.strict())
|
|
685
|
+
.max(LIMITS.masks)
|
|
686
|
+
.optional()
|
|
687
|
+
.describe("substitutions applied to failure text before grouping; each one's hit count is reported"),
|
|
688
|
+
})
|
|
689
|
+
.strict(),
|
|
690
|
+
readOnly: true,
|
|
691
|
+
concurrencySafe: true,
|
|
692
|
+
execute: async (input) => {
|
|
693
|
+
// The kernel's table is the only place a confidence level is defined in
|
|
694
|
+
// this repository; a literal 1.96 here would be a fourth copy.
|
|
695
|
+
const z = statsKernel.TWO_SIDED_Z[input.confidence ?? "0.95"];
|
|
696
|
+
try {
|
|
697
|
+
return json(detectFlaky(input.runs, {
|
|
698
|
+
z,
|
|
699
|
+
...(input.quarantineLowerBound === undefined
|
|
700
|
+
? {}
|
|
701
|
+
: { quarantineLowerBound: input.quarantineLowerBound }),
|
|
702
|
+
...(input.masks === undefined ? {} : { masks: input.masks }),
|
|
703
|
+
}));
|
|
704
|
+
}
|
|
705
|
+
catch (err) {
|
|
706
|
+
return err.message;
|
|
707
|
+
}
|
|
708
|
+
},
|
|
709
|
+
});
|
|
710
|
+
/** Every tool this package registers, in the order a catalog should list them. */
|
|
711
|
+
export const BUILDPERF_TOOLS = Object.freeze([
|
|
712
|
+
benchmarkCompare,
|
|
713
|
+
bundleSizeCheck,
|
|
714
|
+
flakyTestDetect,
|
|
715
|
+
]);
|