@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/README.md
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# @crewhaus/tool-buildperf
|
|
2
|
+
|
|
3
|
+
Did this change make the build worse — bigger, slower, or less trustworthy.
|
|
4
|
+
|
|
5
|
+
| Tool | Answers |
|
|
6
|
+
|---|---|
|
|
7
|
+
| `BundleSizeCheck` | what the artifacts weigh, against budgets and a baseline |
|
|
8
|
+
| `BenchmarkCompare` | which timing differences are real |
|
|
9
|
+
| `FlakyTestDetect` | which tests are flaky, which are broken, and which are ordered |
|
|
10
|
+
|
|
11
|
+
Each of these has a cheap wrong answer that a model will produce all day: a
|
|
12
|
+
percentage between two numbers that were never comparable, a mean of five
|
|
13
|
+
timings, a fail count divided by a run count. Each wrong answer is expensive,
|
|
14
|
+
because a gate that cries wolf gets switched off within a month. So the shared
|
|
15
|
+
property here is the refusal — every tool has inputs it will not answer, and
|
|
16
|
+
says why instead.
|
|
17
|
+
|
|
18
|
+
## The refusals, one per tool
|
|
19
|
+
|
|
20
|
+
**`BundleSizeCheck` will not compare across compression parameters.** A gzip
|
|
21
|
+
size is not a property of a file; it is a property of a file *and* the
|
|
22
|
+
compressor settings. Level 6 against level 9 differs by several percent on a
|
|
23
|
+
typical bundle — the same order as the regression anyone is hunting — so a
|
|
24
|
+
baseline recorded at one level and a head measured at another produces a
|
|
25
|
+
confident `+4.1%` about nothing. The parameters travel with every report, and
|
|
26
|
+
a mismatch (including a baseline that never declared any) comes back as
|
|
27
|
+
`comparison.status: "refused"` with no delta in the output at all. The verdict
|
|
28
|
+
is then `indeterminate`, never `pass`.
|
|
29
|
+
|
|
30
|
+
The same refusal covers the *column*. A report carries a raw and a compressed
|
|
31
|
+
number per entry, and which one a delta is taken on is decided by the
|
|
32
|
+
algorithm — so a baseline that declares gzip but stores no `compressedBytes`
|
|
33
|
+
is refused rather than read as raw. Filling that in would subtract a gzip size
|
|
34
|
+
from a raw one, land near `-98%`, and read as the best week the team ever had.
|
|
35
|
+
A compressed column under `algorithm: "none"` is refused for the mirror reason.
|
|
36
|
+
|
|
37
|
+
**`BenchmarkCompare` will not call significance on five samples a side.** Below
|
|
38
|
+
8 per side the normal approximation to Mann-Whitney's U is materially wrong:
|
|
39
|
+
for two fully separated groups of five it reads p = 0.0122 where the exact
|
|
40
|
+
test reads 0.0079. With the common default of `runs: 5`, the honest answer is
|
|
41
|
+
"cannot tell", and that is what comes back — with the p value carried along
|
|
42
|
+
for ranking candidates, explicitly not as a rate.
|
|
43
|
+
|
|
44
|
+
**`FlakyTestDetect` will not report 3-of-5 as 60% flaky.** The Wilson interval
|
|
45
|
+
on three failures in five runs is 23%–88%, which spans "mildly annoying" and
|
|
46
|
+
"the test is broken" without distinguishing them. Every rate comes with its
|
|
47
|
+
interval, and the quarantine recommendation keys on the interval's *lower*
|
|
48
|
+
bound; below the floor it says "run it more" rather than acting on the point
|
|
49
|
+
estimate.
|
|
50
|
+
|
|
51
|
+
## Why the median, and why a noise floor
|
|
52
|
+
|
|
53
|
+
Benchmark timings are right-skewed. They are bounded below by the work the
|
|
54
|
+
machine has to do and unbounded above by everything that can interrupt it, so
|
|
55
|
+
a GC pause or a scheduler preemption lands in the tail. The mean follows the
|
|
56
|
+
tail: for `[1,1,1,1,1,1,1,1,1,100]` the mean is 10.9 and the median is 1, and
|
|
57
|
+
comparing means against a clean run of ones reports a 91% improvement from a
|
|
58
|
+
change that did nothing. So the location estimate is the median, the spread is
|
|
59
|
+
the MAD, and the test is nonparametric.
|
|
60
|
+
|
|
61
|
+
The noise floor is a separate thing from significance and is **required**, not
|
|
62
|
+
defaulted. Significance is about variance you can average down with more
|
|
63
|
+
samples. The floor is about *bias* you cannot: thermal throttling, a different
|
|
64
|
+
worktree layout, a noisy neighbour on the runner. A difference inside the
|
|
65
|
+
declared floor is reported as `no-detectable-change` however small p is,
|
|
66
|
+
because no amount of sampling sees past a bias. Nobody but the caller knows
|
|
67
|
+
what their machine can resolve, so the tool does not guess it.
|
|
68
|
+
|
|
69
|
+
For the same reason `collection` is asked for. Base and head runs that were
|
|
70
|
+
not interleaved per benchmark have machine drift confounded with the change,
|
|
71
|
+
and no test on those samples can separate the two. Undeclared or sequential
|
|
72
|
+
collection produces a caveat on the report rather than a quieter verdict.
|
|
73
|
+
|
|
74
|
+
Size needs none of this. Compression at fixed parameters is deterministic, so
|
|
75
|
+
a one-byte difference is a real one-byte difference — which is exactly why the
|
|
76
|
+
parameters have to match, and why they are the only thing `BundleSizeCheck`
|
|
77
|
+
refuses over.
|
|
78
|
+
|
|
79
|
+
## Joining a hashed build
|
|
80
|
+
|
|
81
|
+
Bundlers write content hashes into filenames, so joining head against baseline
|
|
82
|
+
by exact path matches nothing on every build — and "nothing matched" reads as
|
|
83
|
+
"every file is new", not as "the join is broken". `join: "auto"` normalises
|
|
84
|
+
hash-shaped segments in the basename (`app-BXaGz2Qm.js` and `app.4f2a1c.js`
|
|
85
|
+
both become `app-[hash].js`), and `join: "custom"` takes the caller's own
|
|
86
|
+
pattern for a scheme the heuristic does not know.
|
|
87
|
+
|
|
88
|
+
The heuristic is conservative in one direction only: `chunk`, `min`, `vendor`
|
|
89
|
+
and `esm` are never eaten, because normalising a real name merges two
|
|
90
|
+
different artifacts into one row. It needs 6 characters for pure hex or 8 for
|
|
91
|
+
a mixed alphabet with both a digit and a letter. The known false positive is a
|
|
92
|
+
word that is also hex — `decade`, `defaced` — which is why every entry reports
|
|
93
|
+
the `key` it joined on rather than just a delta.
|
|
94
|
+
|
|
95
|
+
When two artifacts normalise to the same key, neither is paired: they are
|
|
96
|
+
listed under `join.ambiguous` and excluded from the comparison. Pairing one
|
|
97
|
+
arbitrarily would produce a delta for a file the reader never chose. The
|
|
98
|
+
baseline row under that key is set aside with them, in
|
|
99
|
+
`comparison.baselineSetAside` — it has no single successor either, and letting
|
|
100
|
+
it fall through as `removed` sends a reader hunting for a deleted chunk that
|
|
101
|
+
was in fact split in two.
|
|
102
|
+
|
|
103
|
+
## Order dependence is not flakiness
|
|
104
|
+
|
|
105
|
+
A test that fails only when another test ran first is not nondeterministic; it
|
|
106
|
+
is deterministic in a coordinate the runner did not vary. `FlakyTestDetect`
|
|
107
|
+
takes each run's tests **in execution order**, and for a test that both passed
|
|
108
|
+
and failed it names the tests that ran before it in *every* failure and in
|
|
109
|
+
*none* of the passes. That is a shortlist to bisect, not a cause.
|
|
110
|
+
|
|
111
|
+
If every run used the same order, the tool reports the question as undecidable
|
|
112
|
+
rather than answering "nondeterministic" — including for a test that failed
|
|
113
|
+
every single time, which under one fixed order is equally consistent with "it
|
|
114
|
+
is broken" and "it is broken after the test before it". Recording a shuffle
|
|
115
|
+
seed that never changed the order is reported too: the seed did not reach the
|
|
116
|
+
suite.
|
|
117
|
+
|
|
118
|
+
That check is per test, not per suite. A suite that merely gained or lost a
|
|
119
|
+
test varies its *sequence* while a given test keeps the identical prefix in
|
|
120
|
+
every run, and an identical prefix is the unvaried case again: "fails after
|
|
121
|
+
this prefix" and "fails at random" are the same observation. Reading the
|
|
122
|
+
suite's flag as the test's would report "the failures do not line up with a
|
|
123
|
+
predecessor" about a comparison that was never available.
|
|
124
|
+
|
|
125
|
+
A skipped test is never counted as a pass. Counting it as one is how a suite
|
|
126
|
+
that stopped running a test reports it as stable for a year.
|
|
127
|
+
|
|
128
|
+
## Grouping failures
|
|
129
|
+
|
|
130
|
+
Failures are grouped by `signature` when the caller supplies one, and
|
|
131
|
+
otherwise by the failure text after the caller's declared `masks`, with each
|
|
132
|
+
mask's hit count returned — a mask that rewrites text invisibly can merge two
|
|
133
|
+
genuinely different failures into one group, which reads as "it always fails
|
|
134
|
+
the same way" when it does not.
|
|
135
|
+
|
|
136
|
+
A failure that recorded neither a signature nor any text is in no group at all,
|
|
137
|
+
so `sameFailureEveryTime` comes back `null` rather than `true` whenever a
|
|
138
|
+
failure went ungrouped. One group among the failures that said something is not
|
|
139
|
+
an answer about the failures that did not, and answering `true` from that
|
|
140
|
+
subset is what sends someone after a single root cause for two different bugs.
|
|
141
|
+
|
|
142
|
+
There are deliberately no built-in masks. `ErrorCluster` in
|
|
143
|
+
`@crewhaus/tool-obs` already owns shape-based masking of error text (URLs,
|
|
144
|
+
uuids, timestamps, paths, prefixed ids, hex blobs, numbers). A second set here
|
|
145
|
+
would drift from it and the two tools would then disagree about whether two
|
|
146
|
+
failures are the same failure, so pass its fingerprint in as `signature`
|
|
147
|
+
instead.
|
|
148
|
+
|
|
149
|
+
## Shared implementations
|
|
150
|
+
|
|
151
|
+
Every statistic comes from `@crewhaus/tool-math`'s `statsKernel` — the median,
|
|
152
|
+
the MAD, the trimmed mean, Mann-Whitney U and the Wilson score interval. None
|
|
153
|
+
of it is re-derived here, and the z table for a confidence level is read from
|
|
154
|
+
the kernel rather than written as a literal. The kernel pins its own numbers
|
|
155
|
+
against R and against a brute-force enumeration; this package pins what it
|
|
156
|
+
does with them.
|
|
157
|
+
|
|
158
|
+
## What it does not do
|
|
159
|
+
|
|
160
|
+
- **It runs nothing.** No build, no benchmark suite, no test runner, no git
|
|
161
|
+
worktree. Measurements come in and the honest reading of them comes out.
|
|
162
|
+
Use `RunTests`, or the project's own commands, to produce them.
|
|
163
|
+
- **It does not reach a network** and does not spawn a process. Only
|
|
164
|
+
`BundleSizeCheck` touches the filesystem, read-only, and every path is
|
|
165
|
+
contained within the workspace root — a symlink pointing outside it is
|
|
166
|
+
refused rather than weighed. Walking a `directory` does not follow links at
|
|
167
|
+
all, because weighing a link and its target double-counts; the ones it
|
|
168
|
+
skipped are named in `warnings` rather than dropped silently.
|
|
169
|
+
- **It does not write a baseline.** A `BundleSizeCheck` report *is* a
|
|
170
|
+
baseline: store it with `Write` and pass it back as `baseline`, or point
|
|
171
|
+
`baselineFile` at it.
|
|
172
|
+
- **It does not read `.size-limit.json`, `bundlesize` or any other budget
|
|
173
|
+
config.** Those three formats disagree about what a bare `*` crosses, so one
|
|
174
|
+
glob dialect is implemented and documented instead: `*` within a segment,
|
|
175
|
+
`**` across segments, `?` one character, everything else literal.
|
|
176
|
+
- **It does not judge two aggregates.** A framework that reports only a mean
|
|
177
|
+
per side is compared against the noise floor and then explicitly left
|
|
178
|
+
untested — `not-tested`, not `clean`.
|
|
179
|
+
- **A budget that matches nothing is a violation**, not a pass. A renamed
|
|
180
|
+
entry point is the usual way a size gate quietly stops gating. For the same
|
|
181
|
+
reason `maxIncreasePercent` gates the **total** as well as each matched
|
|
182
|
+
entry, and says so when it could not gate anything: a new chunk is `added`
|
|
183
|
+
rather than matched, and a build that writes a fresh content hash into every
|
|
184
|
+
filename joins nothing at all, so a limit that only walked the matched rows
|
|
185
|
+
would go green on a bundle that tripled. A limit declared with no baseline is
|
|
186
|
+
a violation too.
|
|
187
|
+
|
|
188
|
+
- **The glob dialect is not compiled to a regular expression.** `*` and `**`
|
|
189
|
+
would become `[^/]*` and `.*`, and a backtracking engine handed several of
|
|
190
|
+
those separated by literals explores every way of splitting the path between
|
|
191
|
+
them — `*a*a*a…*b` is 49 characters and does not finish against sixty `a`s.
|
|
192
|
+
Patterns are matched by a memoised walk over (token, offset) instead, which
|
|
193
|
+
is bounded by the pattern's token count times the path's length, and a
|
|
194
|
+
pattern over 512 characters is refused.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { RegisteredTool } from "@crewhaus/tool-catalog";
|
|
2
|
+
export declare const bundleSizeCheck: RegisteredTool;
|
|
3
|
+
export declare const benchmarkCompare: RegisteredTool;
|
|
4
|
+
export declare const flakyTestDetect: RegisteredTool;
|
|
5
|
+
/** Every tool this package registers, in the order a catalog should list them. */
|
|
6
|
+
export declare const BUILDPERF_TOOLS: ReadonlyArray<RegisteredTool>;
|