Repository navigation
Expand file tree
/
Copy pathbundle-deps.mjs
More file actions
231 lines (201 loc) · 7.65 KB
/
Copy pathbundle-deps.mjs
File metadata and controls
231 lines (201 loc) · 7.65 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
#!/usr/bin/env node
/**
* Gates the third-party packages that ship in the library bundle.
*
* `size-limit` bundles the whole library with webpack (`.size-limit.cjs`), and
* Statoscope writes that build's stats to `size-limit-report/stats.json`. This
* script sums the bundled bytes of every package under `node_modules` and
* compares them with `scripts/bundle-deps-baseline.json`, the packages last
* approved in review. It fails, and reports, only when the dependency graph
* changes: a package enters or leaves the bundle, or grows more than
* `TOLERANCE` bytes past its approved size. A version bump within the
* tolerance passes without a word. UI Kit's own code is not gated, since it
* grows with every feature, and neither are `@tenphi/*` packages, which are
* ours and released on their own.
*
* Sizes are module source bytes before minification, which are exact and the
* same on every machine, unlike the minified, gzipped totals `size-limit`
* reports.
*
* CI runs it on every PR (`.github/workflows/size-limit.yml`), and when it
* fails the PR comment lists the exact baseline entries to set and delete, so
* approving a change needs no local build. Locally:
*
* pnpm build && pnpm size # measure, then check the packages
* pnpm size --update # re-approve every package at its current size
*
* Growth within the tolerance is not approved, only tolerated: the next change
* is measured against the same baseline, so creep adds up until it fails. A
* package that left the bundle fails too, so the baseline holds only what
* ships, and nothing returns to the bundle unreviewed.
*/
import { readFileSync, writeFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import bytes from 'bytes';
import { markdownTable } from 'markdown-table';
const root = resolve(import.meta.dirname, '..');
const baselinePath = resolve(root, 'scripts/bundle-deps-baseline.json');
const defaultReportFolder =
process.env.REPORT_FOLDER ?? join(root, 'size-limit-report');
export const TOLERANCE = 10 * 1024;
/** The package a bundled module belongs to, or `null` for UI Kit's own code. */
export function packageOf(path) {
// The last segment wins: pnpm nests every package under
// `node_modules/.pnpm/<name>@<version>/node_modules/<name>/`.
const matches = [...path.matchAll(/node_modules\/((?:@[^/]+\/)?[^/]+)/g)];
return matches.at(-1)?.[1] ?? null;
}
/**
* Bundled bytes per third-party package, from webpack stats. Statoscope
* normalizes them: a chunk lists its modules by id, and the modules sit under
* `__statoscope.normalization` as `[id, module]` pairs. A concatenated module
* carries the modules webpack merged into it inline.
*/
export function packageSizes(stats) {
const [compilation] =
stats.__statoscope?.normalization?.data?.compilations ?? [];
const byId = new Map(compilation?.data?.modules ?? []);
const sizes = {};
const visit = (ref) => {
const module = typeof ref === 'number' ? byId.get(ref) : ref;
if (!module) {
throw new Error(`Module ${ref} is referenced but not in the stats.`);
}
if (module.modules?.length) {
for (const inner of module.modules) visit(inner);
return;
}
const name = packageOf(module.nameForCondition ?? module.name ?? '');
if (name && !name.startsWith('@tenphi/')) {
sizes[name] = (sizes[name] ?? 0) + (module.size ?? 0);
}
};
for (const chunk of stats.chunks ?? []) {
for (const ref of chunk.modules ?? []) visit(ref);
}
if (!Object.keys(sizes).length) {
throw new Error(
'Found no third-party packages in the bundle stats. Either the stats are not from size-limit, or their format changed.',
);
}
return sortedByName(sizes);
}
/**
* The changes that need approval: a package that entered or left the bundle,
* or grew more than `tolerance` bytes past its approved size.
*/
export function unapprovedChanges(current, approved, tolerance = TOLERANCE) {
const names = [
...new Set([...Object.keys(approved), ...Object.keys(current)]),
];
return names
.map((name) => {
const before = approved[name];
const after = current[name];
const status =
before === undefined
? 'new'
: after === undefined
? 'removed'
: after - before > tolerance
? 'grown'
: null;
return { name, before, after, status };
})
.filter(({ status }) => status)
.sort((a, b) => a.name.localeCompare(b.name));
}
/**
* The Markdown section the size report and the PR comment show. Every line
* starts at column 0: the comment goes through `dedent` (`set-message.cjs`),
* which would strip an indented line.
*/
export function formatPackageReport(changes) {
const limit = formatSize(TOLERANCE);
if (!changes.length) {
return `✅ No third-party package entered or left the bundle, and none grew more than ${limit} past its approved size.`;
}
const [lead, subject] =
changes.length === 1
? ['A third-party package needs approval', 'it']
: [`${changes.length} third-party packages need approval`, 'each'];
const entries = changes.filter(({ status }) => status !== 'removed');
const removed = changes.filter(({ status }) => status === 'removed');
return [
`❌ **${lead}**: ${subject} entered or left the bundle, or grew more than ${limit} past its approved size.`,
'',
markdownTable([
['Package', 'Approved', 'Now', 'Change'],
...changes.map(({ name, before, after, status }) => [
`\`${name}\``,
formatSize(before),
formatSize(after),
status === 'new'
? 'new in the bundle'
: status === 'removed'
? 'left the bundle'
: `+${formatSize(after - before)}`,
]),
]),
'',
`If that is intended, approve ${changes.length === 1 ? 'it' : 'them'} in \`scripts/bundle-deps-baseline.json\` and say why in the PR.`,
...(entries.length
? [
'',
'Set these entries:',
'',
'```json',
entries
.map(({ name, after }) => `${JSON.stringify(name)}: ${after}`)
.join(',\n'),
'```',
]
: []),
...(removed.length
? ['', `Delete ${removed.map(({ name }) => `\`${name}\``).join(', ')}.`]
: []),
].join('\n');
}
/**
* Checks the bundle that `size-limit` measured into `reportFolder` against the
* baseline: `failed` when the dependency graph changed, with the report to
* show either way.
*/
export function checkPackages(reportFolder = defaultReportFolder) {
const changes = unapprovedChanges(
packageSizes(readStats(reportFolder)),
readBaseline(),
);
return { failed: changes.length > 0, report: formatPackageReport(changes) };
}
export function formatSize(size) {
return size === undefined ? '—' : bytes.format(size, { unitSeparator: ' ' });
}
function readStats(reportFolder) {
return JSON.parse(readFileSync(join(reportFolder, 'stats.json'), 'utf8'));
}
function readBaseline() {
return JSON.parse(readFileSync(baselinePath, 'utf8'));
}
function writeBaseline(sizes) {
writeFileSync(baselinePath, `${JSON.stringify(sizes, null, 2)}\n`);
}
function sortedByName(object) {
return Object.fromEntries(
Object.entries(object).sort(([a], [b]) => a.localeCompare(b)),
);
}
function main() {
if (process.argv.includes('--update')) {
const current = packageSizes(readStats(defaultReportFolder));
writeBaseline(current);
console.log(
`Approved ${Object.keys(current).length} third-party packages in scripts/bundle-deps-baseline.json.`,
);
return;
}
const { failed, report } = checkPackages();
console.log(report);
if (failed) process.exitCode = 1;
}
if (import.meta.main) main();