@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
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What repeated test runs can and cannot establish.
|
|
3
|
+
*
|
|
4
|
+
* Three failures in five runs is not a 60% failure rate. It is a point
|
|
5
|
+
* estimate drawn from five observations, and the Wilson interval around it
|
|
6
|
+
* runs from 0.231 to 0.882 — a range that spans "mildly annoying" and "the
|
|
7
|
+
* test is broken" without distinguishing them. Quarantining on the point
|
|
8
|
+
* estimate is quarantining on noise, so every rate here travels with its
|
|
9
|
+
* interval (from `@crewhaus/tool-math`'s kernel) and the quarantine
|
|
10
|
+
* recommendation keys on the interval's LOWER bound, never on the estimate.
|
|
11
|
+
*
|
|
12
|
+
* The other thing five runs cannot do is separate nondeterminism from order
|
|
13
|
+
* dependence. A test that fails only when another test ran first is not
|
|
14
|
+
* flaky; it is deterministic in a coordinate the runner did not vary. If the
|
|
15
|
+
* input carries execution order, this module looks for the predecessor that
|
|
16
|
+
* distinguishes the failing runs from the passing ones. If every run used the
|
|
17
|
+
* SAME order, it says the question is unanswerable rather than answering
|
|
18
|
+
* "nondeterministic" — which is the wrong answer given confidently, and the
|
|
19
|
+
* whole reason a team spends a week chasing the wrong test.
|
|
20
|
+
*/
|
|
21
|
+
import { statsKernel } from "@crewhaus/tool-math";
|
|
22
|
+
export const TEST_STATUSES = ["pass", "fail", "error", "skip"];
|
|
23
|
+
export class FlakyError extends Error {
|
|
24
|
+
name = "FlakyError";
|
|
25
|
+
}
|
|
26
|
+
/** A failure is a failure; `error` is one that did not reach an assertion. */
|
|
27
|
+
const isFailure = (status) => status === "fail" || status === "error";
|
|
28
|
+
/**
|
|
29
|
+
* Apply the caller's substitutions and report how many times each fired.
|
|
30
|
+
*
|
|
31
|
+
* A mask that rewrites text invisibly can merge two genuinely different
|
|
32
|
+
* failures into one group, which reads as "it always fails the same way" when
|
|
33
|
+
* it does not — so the counts come back with the result, the same rule
|
|
34
|
+
* `GoldenCompare` follows for its normalizers.
|
|
35
|
+
*
|
|
36
|
+
* There are deliberately no built-in masks. `ErrorCluster` in
|
|
37
|
+
* `@crewhaus/tool-obs` already owns shape-based masking of error text (URLs,
|
|
38
|
+
* uuids, timestamps, paths, prefixed ids, hex blobs, numbers); a second set
|
|
39
|
+
* here would drift from it, and the two tools would then disagree about
|
|
40
|
+
* whether two failures are the same failure. Pass its fingerprint in as
|
|
41
|
+
* `signature`, or declare the substitutions this suite needs.
|
|
42
|
+
*/
|
|
43
|
+
export function applyMasks(text, masks, counts) {
|
|
44
|
+
let out = text;
|
|
45
|
+
for (const mask of masks) {
|
|
46
|
+
const flags = mask.flags ?? "";
|
|
47
|
+
let re;
|
|
48
|
+
try {
|
|
49
|
+
re = new RegExp(mask.pattern, flags.includes("g") ? flags : `${flags}g`);
|
|
50
|
+
}
|
|
51
|
+
catch (err) {
|
|
52
|
+
throw new FlakyError(`mask /${mask.pattern}/ is not a valid regular expression: ${err.message}`);
|
|
53
|
+
}
|
|
54
|
+
const fired = out.match(re)?.length ?? 0;
|
|
55
|
+
if (fired > 0) {
|
|
56
|
+
out = out.replace(re, mask.with ?? "<masked>");
|
|
57
|
+
counts[mask.pattern] = (counts[mask.pattern] ?? 0) + fired;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return out.replace(/\s+/g, " ").trim();
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* The smallest run count whose all-failing Wilson lower bound clears
|
|
64
|
+
* `target`. Answers "how many consecutive failures before I may call this
|
|
65
|
+
* deterministic?" — at 95% confidence against a 0.9 target the answer is in
|
|
66
|
+
* the dozens, which is why three-for-three is evidence and not proof.
|
|
67
|
+
*/
|
|
68
|
+
export function runsForLowerBound(target, z = statsKernel.Z_95) {
|
|
69
|
+
for (let n = 1; n <= 1_000; n++) {
|
|
70
|
+
const interval = statsKernel.wilsonScoreInterval(n, n, z);
|
|
71
|
+
if (interval !== null && interval.lower >= target)
|
|
72
|
+
return n;
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
function orderAnalysis(occurrences, orderVaried, suiteSize) {
|
|
77
|
+
const failing = occurrences.filter((o) => isFailure(o.status));
|
|
78
|
+
const passing = occurrences.filter((o) => o.status === "pass");
|
|
79
|
+
const positions = {
|
|
80
|
+
medianPositionWhenFailing: statsKernel.median(failing.map((o) => o.position)),
|
|
81
|
+
medianPositionWhenPassing: statsKernel.median(passing.map((o) => o.position)),
|
|
82
|
+
};
|
|
83
|
+
if (suiteSize < 2) {
|
|
84
|
+
return {
|
|
85
|
+
decidable: false,
|
|
86
|
+
suspects: [],
|
|
87
|
+
...positions,
|
|
88
|
+
note: "each run contained a single test, so nothing could have run before this one",
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
if (!orderVaried) {
|
|
92
|
+
return {
|
|
93
|
+
decidable: false,
|
|
94
|
+
suspects: [],
|
|
95
|
+
...positions,
|
|
96
|
+
note: "every run executed the tests in the same order, so an order-dependent failure and a nondeterministic one are indistinguishable here — re-run with the suite shuffled (and the seed recorded) before concluding either",
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
if (failing.length === 0 || passing.length === 0) {
|
|
100
|
+
return {
|
|
101
|
+
decidable: false,
|
|
102
|
+
suspects: [],
|
|
103
|
+
...positions,
|
|
104
|
+
note: "this test did not both pass and fail, so there is no contrast between orders to learn from",
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
// `orderVaried` is a claim about the SUITE. A suite that merely gained or
|
|
108
|
+
// lost a test, or shuffled only the tail, varies its sequence while this
|
|
109
|
+
// test keeps the identical prefix in every run — and an identical prefix is
|
|
110
|
+
// the suite-wide unvaried case all over again: "fails after this prefix" and
|
|
111
|
+
// "fails at random" are the same observation. Taking the suite's flag for
|
|
112
|
+
// this test's would report "the failures do not line up with a predecessor"
|
|
113
|
+
// about a comparison that was never available.
|
|
114
|
+
const prefixes = new Set(occurrences.map((o) => JSON.stringify([...o.predecessors].sort())));
|
|
115
|
+
if (prefixes.size < 2) {
|
|
116
|
+
return {
|
|
117
|
+
decidable: false,
|
|
118
|
+
suspects: [],
|
|
119
|
+
...positions,
|
|
120
|
+
note: "the suite's order varied, but this test ran after exactly the same set of tests in every run, so nothing here separates an order-dependent failure from a nondeterministic one",
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
// Intersect the predecessors of every failing run, then subtract everything
|
|
124
|
+
// that EVER preceded a passing run. What survives ran before the test each
|
|
125
|
+
// time it failed and never when it passed: a shortlist to bisect, not a
|
|
126
|
+
// cause.
|
|
127
|
+
let shared = null;
|
|
128
|
+
for (const occurrence of failing) {
|
|
129
|
+
if (shared === null) {
|
|
130
|
+
shared = new Set(occurrence.predecessors);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
for (const id of [...shared]) {
|
|
134
|
+
if (!occurrence.predecessors.has(id))
|
|
135
|
+
shared.delete(id);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
const suspects = [...(shared ?? new Set())]
|
|
139
|
+
.filter((id) => !passing.some((o) => o.predecessors.has(id)))
|
|
140
|
+
.sort();
|
|
141
|
+
return {
|
|
142
|
+
decidable: true,
|
|
143
|
+
suspects,
|
|
144
|
+
...positions,
|
|
145
|
+
note: suspects.length === 0
|
|
146
|
+
? "run order varied and no test ran before this one in every failure and in none of the passes; the failures do not line up with a predecessor"
|
|
147
|
+
: `${suspects.length} test(s) ran before this one in every failing run and in none of the passing runs; re-run this test after each of them alone to confirm`,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
function groupFailures(occurrences, masks, counts) {
|
|
151
|
+
const groups = new Map();
|
|
152
|
+
for (const occurrence of occurrences) {
|
|
153
|
+
if (!isFailure(occurrence.status))
|
|
154
|
+
continue;
|
|
155
|
+
const raw = occurrence.error ?? "";
|
|
156
|
+
// No text and no signature means the run recorded a failure without
|
|
157
|
+
// saying anything about it; that is not a group of one, it is an unknown.
|
|
158
|
+
if (occurrence.signature === undefined && raw === "")
|
|
159
|
+
continue;
|
|
160
|
+
const key = occurrence.signature ?? applyMasks(raw, masks, counts);
|
|
161
|
+
const row = groups.get(key);
|
|
162
|
+
if (row === undefined) {
|
|
163
|
+
groups.set(key, { count: 1, example: raw, runs: [occurrence.runId] });
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
row.count += 1;
|
|
167
|
+
row.runs.push(occurrence.runId);
|
|
168
|
+
}
|
|
169
|
+
return [...groups.entries()]
|
|
170
|
+
.map(([signature, row]) => ({
|
|
171
|
+
signature,
|
|
172
|
+
count: row.count,
|
|
173
|
+
example: row.example,
|
|
174
|
+
runs: row.runs,
|
|
175
|
+
}))
|
|
176
|
+
.sort((a, b) => b.count - a.count || (a.signature < b.signature ? -1 : 1));
|
|
177
|
+
}
|
|
178
|
+
export function detectFlaky(runs, options = {}) {
|
|
179
|
+
if (runs.length === 0)
|
|
180
|
+
throw new FlakyError("no runs were given; there is nothing to classify");
|
|
181
|
+
const z = options.z ?? statsKernel.Z_95;
|
|
182
|
+
const quarantineLowerBound = options.quarantineLowerBound ?? 0.05;
|
|
183
|
+
if (!(quarantineLowerBound >= 0 && quarantineLowerBound <= 1)) {
|
|
184
|
+
throw new FlakyError(`quarantineLowerBound must be a probability between 0 and 1, got ${quarantineLowerBound}`);
|
|
185
|
+
}
|
|
186
|
+
const masks = options.masks ?? [];
|
|
187
|
+
const maskCounts = {};
|
|
188
|
+
const occurrences = new Map();
|
|
189
|
+
const sequences = [];
|
|
190
|
+
for (const [runIndex, run] of runs.entries()) {
|
|
191
|
+
const runId = run.id ?? `run-${runIndex + 1}`;
|
|
192
|
+
const seenInRun = new Set();
|
|
193
|
+
const before = new Set();
|
|
194
|
+
for (const [position, test] of run.tests.entries()) {
|
|
195
|
+
if (seenInRun.has(test.id)) {
|
|
196
|
+
throw new FlakyError(`test "${test.id}" appears twice in ${runId}; a run reports one outcome per test, and two rows cannot both be it`);
|
|
197
|
+
}
|
|
198
|
+
seenInRun.add(test.id);
|
|
199
|
+
const list = occurrences.get(test.id) ?? [];
|
|
200
|
+
list.push({
|
|
201
|
+
runIndex,
|
|
202
|
+
runId,
|
|
203
|
+
position,
|
|
204
|
+
status: test.status,
|
|
205
|
+
predecessors: new Set(before),
|
|
206
|
+
...(test.error === undefined ? {} : { error: test.error }),
|
|
207
|
+
...(test.signature === undefined ? {} : { signature: test.signature }),
|
|
208
|
+
});
|
|
209
|
+
occurrences.set(test.id, list);
|
|
210
|
+
before.add(test.id);
|
|
211
|
+
}
|
|
212
|
+
// JSON, not a delimiter: a test id is caller-supplied and could contain
|
|
213
|
+
// whatever separator character we picked, which would make two different
|
|
214
|
+
// orders compare equal and silently disable the order analysis.
|
|
215
|
+
sequences.push(JSON.stringify(run.tests.map((t) => t.id)));
|
|
216
|
+
}
|
|
217
|
+
const orderVaried = new Set(sequences).size > 1;
|
|
218
|
+
const seedsDeclared = [
|
|
219
|
+
...new Set(runs.map((r) => r.seed).filter((s) => s !== undefined)),
|
|
220
|
+
].sort();
|
|
221
|
+
const suiteSize = Math.max(...runs.map((r) => r.tests.length));
|
|
222
|
+
const tests = [...occurrences.keys()].sort().map((id) => {
|
|
223
|
+
const list = occurrences.get(id);
|
|
224
|
+
const failures = list.filter((o) => isFailure(o.status)).length;
|
|
225
|
+
const passes = list.filter((o) => o.status === "pass").length;
|
|
226
|
+
const skipped = list.filter((o) => o.status === "skip").length;
|
|
227
|
+
// A skipped test is not a pass. Counting it as one is how a suite that
|
|
228
|
+
// stopped running a test reports it as stable for a year.
|
|
229
|
+
const runsObserved = failures + passes;
|
|
230
|
+
const absent = runs.length - list.length;
|
|
231
|
+
const interval = statsKernel.wilsonScoreInterval(failures, runsObserved, z);
|
|
232
|
+
const failureRate = {
|
|
233
|
+
pointEstimate: interval?.pointEstimate ?? null,
|
|
234
|
+
lower: interval?.lower ?? null,
|
|
235
|
+
upper: interval?.upper ?? null,
|
|
236
|
+
width: interval?.width ?? null,
|
|
237
|
+
z,
|
|
238
|
+
};
|
|
239
|
+
const order = orderAnalysis(list, orderVaried, suiteSize);
|
|
240
|
+
const failureGroups = groupFailures(list, masks, maskCounts);
|
|
241
|
+
// A failure that recorded no text and no signature is in no group, so one
|
|
242
|
+
// group does not mean one failure mode — it means one failure mode among
|
|
243
|
+
// the failures that said anything. Answering "yes, always the same way"
|
|
244
|
+
// from that subset is the cheap wrong answer: it is what sends someone
|
|
245
|
+
// after a single root cause for two different bugs.
|
|
246
|
+
const ungroupedFailures = list.filter((o) => isFailure(o.status) && o.signature === undefined && (o.error ?? "") === "").length;
|
|
247
|
+
const sameFailureEveryTime = failures === 0 || failureGroups.length === 0 || ungroupedFailures > 0
|
|
248
|
+
? null
|
|
249
|
+
: failureGroups.length === 1;
|
|
250
|
+
const classification = runsObserved === 0
|
|
251
|
+
? "not-observed"
|
|
252
|
+
: failures === 0
|
|
253
|
+
? "passing"
|
|
254
|
+
: failures === runsObserved
|
|
255
|
+
? "failing-every-run"
|
|
256
|
+
: "flaky";
|
|
257
|
+
let recommendation = "keep";
|
|
258
|
+
let why;
|
|
259
|
+
if (classification === "not-observed") {
|
|
260
|
+
why = `never ran: skipped in ${skipped} run(s) and absent from ${absent}`;
|
|
261
|
+
}
|
|
262
|
+
else if (classification === "passing") {
|
|
263
|
+
why = `passed all ${runsObserved} run(s) it was observed in`;
|
|
264
|
+
// Passing every time is not the same as being stable. With two or three
|
|
265
|
+
// observations the interval still reaches past a coin flip, so quote it
|
|
266
|
+
// rather than implying a clean bill of health.
|
|
267
|
+
if (interval !== null && interval.upper > 0.5) {
|
|
268
|
+
why += `; with only ${runsObserved} observation(s) its failure rate could still be as high as ${(interval.upper * 100).toFixed(0)}%`;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
else if (classification === "failing-every-run") {
|
|
272
|
+
recommendation = "fix";
|
|
273
|
+
why = `failed in all ${runsObserved} run(s)`;
|
|
274
|
+
if (!orderVaried && suiteSize > 1) {
|
|
275
|
+
why +=
|
|
276
|
+
"; every run used the same order, so this is equally consistent with a deterministic failure and with one that only happens after the tests that preceded it";
|
|
277
|
+
}
|
|
278
|
+
if (interval !== null) {
|
|
279
|
+
why += `; ${runsObserved}-for-${runsObserved} puts the failure rate at ${(interval.lower * 100).toFixed(0)}% or above, not at 100%`;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
else {
|
|
283
|
+
const lower = interval?.lower ?? 0;
|
|
284
|
+
const upper = interval?.upper ?? 1;
|
|
285
|
+
recommendation = lower >= quarantineLowerBound ? "quarantine" : "insufficient-evidence";
|
|
286
|
+
why = `failed ${failures} of ${runsObserved} run(s); the ${(lower * 100).toFixed(1)}%-${(upper * 100).toFixed(1)}% interval ${recommendation === "quarantine"
|
|
287
|
+
? `clears the ${(quarantineLowerBound * 100).toFixed(1)}% quarantine floor`
|
|
288
|
+
: `does not clear the ${(quarantineLowerBound * 100).toFixed(1)}% quarantine floor — run it more before acting on the point estimate`}`;
|
|
289
|
+
if (order.suspects.length > 0) {
|
|
290
|
+
why += `; it may be order-dependent rather than nondeterministic (${order.suspects.length} candidate predecessor(s))`;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return {
|
|
294
|
+
id,
|
|
295
|
+
classification,
|
|
296
|
+
runsObserved,
|
|
297
|
+
failures,
|
|
298
|
+
passes,
|
|
299
|
+
skipped,
|
|
300
|
+
absent,
|
|
301
|
+
failureRate,
|
|
302
|
+
order,
|
|
303
|
+
failureGroups,
|
|
304
|
+
sameFailureEveryTime,
|
|
305
|
+
recommendation,
|
|
306
|
+
why,
|
|
307
|
+
};
|
|
308
|
+
});
|
|
309
|
+
const summary = {
|
|
310
|
+
passing: 0,
|
|
311
|
+
flaky: 0,
|
|
312
|
+
"failing-every-run": 0,
|
|
313
|
+
"not-observed": 0,
|
|
314
|
+
};
|
|
315
|
+
for (const test of tests)
|
|
316
|
+
summary[test.classification] += 1;
|
|
317
|
+
// One run can show a failure. It cannot show a flake: every classification
|
|
318
|
+
// above needs at least two observations of one test to mean anything.
|
|
319
|
+
const verdict = runs.length < 2
|
|
320
|
+
? "inconclusive"
|
|
321
|
+
: summary.flaky > 0
|
|
322
|
+
? "flaky"
|
|
323
|
+
: summary["failing-every-run"] > 0
|
|
324
|
+
? "failing"
|
|
325
|
+
: "clean";
|
|
326
|
+
return {
|
|
327
|
+
runs: runs.length,
|
|
328
|
+
ordering: {
|
|
329
|
+
varied: orderVaried,
|
|
330
|
+
seedsDeclared,
|
|
331
|
+
note: orderVaried
|
|
332
|
+
? `${new Set(sequences).size} distinct execution order(s) across ${runs.length} run(s)`
|
|
333
|
+
: seedsDeclared.length > 1
|
|
334
|
+
? "the runs declare different seeds but executed the tests in the same order, so the seed never reached the suite"
|
|
335
|
+
: "every run executed the tests in the same order; order-dependent failures cannot be distinguished from nondeterministic ones in this input",
|
|
336
|
+
},
|
|
337
|
+
tests,
|
|
338
|
+
summary,
|
|
339
|
+
masksApplied: maskCounts,
|
|
340
|
+
runsForDeterminismClaim: runsForLowerBound(0.9, z),
|
|
341
|
+
verdict,
|
|
342
|
+
note: runs.length < 2
|
|
343
|
+
? "a single run cannot distinguish a flaky test from a deterministic one: re-run the suite and pass every run"
|
|
344
|
+
: `${tests.length} test(s) across ${runs.length} run(s); rates are Wilson intervals at z=${z}, not raw fail/run ratios`,
|
|
345
|
+
};
|
|
346
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
export declare const COMPRESSION_ALGORITHMS: readonly ["none", "gzip", "brotli"];
|
|
2
|
+
export type CompressionAlgorithm = (typeof COMPRESSION_ALGORITHMS)[number];
|
|
3
|
+
/**
|
|
4
|
+
* Everything that determines the compressed byte count, stated rather than
|
|
5
|
+
* assumed.
|
|
6
|
+
*
|
|
7
|
+
* `tuning` carries the settings below the level that also move the output —
|
|
8
|
+
* zlib's memLevel and strategy, brotli's window and mode. They are pinned
|
|
9
|
+
* explicitly when compressing instead of left to the library defaults,
|
|
10
|
+
* because a default that changes under a runtime upgrade would silently make
|
|
11
|
+
* old baselines incomparable while still LOOKING comparable.
|
|
12
|
+
*/
|
|
13
|
+
export type CompressionParameters = {
|
|
14
|
+
readonly algorithm: CompressionAlgorithm;
|
|
15
|
+
/** Compression level; `null` only for `none`, where there is no level. */
|
|
16
|
+
readonly level: number | null;
|
|
17
|
+
readonly tuning: Readonly<Record<string, number>>;
|
|
18
|
+
};
|
|
19
|
+
export declare class SizeError extends Error {
|
|
20
|
+
readonly name = "SizeError";
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Fill in and validate the parameters for a run.
|
|
24
|
+
*
|
|
25
|
+
* A level outside the algorithm's range is a refusal, not a clamp: gzip
|
|
26
|
+
* silently accepting 11 (a brotli quality) and compressing at 9 would produce
|
|
27
|
+
* a report claiming a level it did not use, which is the exact failure this
|
|
28
|
+
* module exists to prevent.
|
|
29
|
+
*/
|
|
30
|
+
export declare function compressionParameters(algorithm: CompressionAlgorithm, level?: number): CompressionParameters;
|
|
31
|
+
/**
|
|
32
|
+
* The string two parameter sets must agree on for a comparison to mean
|
|
33
|
+
* anything. Sorted, so key order in a stored baseline cannot change it.
|
|
34
|
+
*/
|
|
35
|
+
export declare function comparabilityKey(p: CompressionParameters): string;
|
|
36
|
+
/**
|
|
37
|
+
* Why these two sets of parameters cannot be compared, or `null` when they
|
|
38
|
+
* can. The message names the fields that differ, because "incomparable" on
|
|
39
|
+
* its own sends the reader to the source.
|
|
40
|
+
*/
|
|
41
|
+
export declare function parametersMismatch(baseline: CompressionParameters, head: CompressionParameters): string | null;
|
|
42
|
+
/**
|
|
43
|
+
* Why this baseline's ENTRIES cannot be differenced against a run measured
|
|
44
|
+
* under `algorithm`, or `null` when they can.
|
|
45
|
+
*
|
|
46
|
+
* `parametersMismatch` guards the compressor settings. This guards the
|
|
47
|
+
* column. Every entry carries two numbers — a raw size and a compressed one —
|
|
48
|
+
* and which of them a delta is taken on is decided by the algorithm, so a
|
|
49
|
+
* baseline that declares gzip but stores no `compressedBytes` makes the
|
|
50
|
+
* obvious `compressed ?? raw` fallback subtract a gzip size from a raw one.
|
|
51
|
+
* That lands near -70% on real bundles: not a number a reader distrusts, a
|
|
52
|
+
* number a reader celebrates, and it hides whatever the change actually did.
|
|
53
|
+
* The reverse — a compressed column under algorithm "none" — fabricates a
|
|
54
|
+
* regression the same way, so both directions are refused rather than
|
|
55
|
+
* silently filled in.
|
|
56
|
+
*/
|
|
57
|
+
export declare function baselineBasisMismatch(algorithm: CompressionAlgorithm, entries: ReadonlyArray<{
|
|
58
|
+
readonly path: string;
|
|
59
|
+
readonly compressedBytes?: number | null;
|
|
60
|
+
}>): string | null;
|
|
61
|
+
/** Compressed byte count under `parameters`; the raw length for `none`. */
|
|
62
|
+
export declare function compressedSize(bytes: Buffer, parameters: CompressionParameters): number;
|
|
63
|
+
export declare const JOIN_MODES: readonly ["exact", "auto", "custom"];
|
|
64
|
+
export type JoinMode = (typeof JOIN_MODES)[number];
|
|
65
|
+
/**
|
|
66
|
+
* Whether a filename segment looks like a content hash rather than a name.
|
|
67
|
+
*
|
|
68
|
+
* Deliberately conservative in one direction only: `chunk`, `min`, `esm` and
|
|
69
|
+
* `vendor` must never be eaten, because normalizing a real name merges two
|
|
70
|
+
* different artifacts into one row. Pure hex needs 6 characters (webpack's
|
|
71
|
+
* shortest common `[contenthash:6]`); a mixed alphabet needs 8 AND both a
|
|
72
|
+
* digit and a letter (Vite's base64url ids, e.g. `BXaGz2Qm`). A lowercase
|
|
73
|
+
* word of 6+ letters that happens to be all hex — `decade`, `defaced` — is
|
|
74
|
+
* the known false positive, which is why every entry reports the key it was
|
|
75
|
+
* joined on rather than just the delta.
|
|
76
|
+
*/
|
|
77
|
+
export declare function looksLikeHash(segment: string): boolean;
|
|
78
|
+
export type JoinKeyResult = {
|
|
79
|
+
readonly key: string;
|
|
80
|
+
readonly normalized: boolean;
|
|
81
|
+
};
|
|
82
|
+
/**
|
|
83
|
+
* The key an entry joins on.
|
|
84
|
+
*
|
|
85
|
+
* `exact` is the default and joins on the path as written. It is the right
|
|
86
|
+
* choice for a build with stable filenames and the wrong one for a hashed
|
|
87
|
+
* build, where it matches NOTHING on every run — which reads as "every file
|
|
88
|
+
* is new", not as "the join is broken". `auto` strips hash-shaped segments
|
|
89
|
+
* from the basename; `custom` applies the caller's own pattern, which is the
|
|
90
|
+
* escape hatch for a bundler whose scheme the heuristic does not recognise.
|
|
91
|
+
*/
|
|
92
|
+
export declare function joinKey(path: string, mode: JoinMode, custom?: RegExp): JoinKeyResult;
|
|
93
|
+
/**
|
|
94
|
+
* Long enough for any real budget, short enough that the matcher's recursion
|
|
95
|
+
* depth (one frame per token) cannot reach the stack limit.
|
|
96
|
+
*/
|
|
97
|
+
export declare const MAX_GLOB_LENGTH = 512;
|
|
98
|
+
export type GlobMatcher = (path: string) => boolean;
|
|
99
|
+
/**
|
|
100
|
+
* Compile a pattern once, match many paths with it.
|
|
101
|
+
*
|
|
102
|
+
* This deliberately does NOT compile to a regular expression. `*` and `**`
|
|
103
|
+
* become `[^/]*` and `.*`, and a backtracking engine handed several of those
|
|
104
|
+
* separated by literals explores every way of splitting the string between
|
|
105
|
+
* them: `*a*a*a…*b` — a 49-character pattern — against sixty `a`s does not
|
|
106
|
+
* finish, and a budget pattern is caller-supplied. The state here is only
|
|
107
|
+
* (token index, offset in the path), so memoising it bounds the search at
|
|
108
|
+
* tokens x length cells no matter how the wildcards are arranged.
|
|
109
|
+
*
|
|
110
|
+
* Callers with many paths should hold the matcher: recompiling per path
|
|
111
|
+
* multiplies the tokenizer by the size of the build.
|
|
112
|
+
*/
|
|
113
|
+
export declare function compileGlob(pattern: string): GlobMatcher;
|
|
114
|
+
export declare function globMatch(pattern: string, path: string): boolean;
|