@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 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.
@@ -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>;