bundle-pulse 0.0.0-stage → 1.0.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/CHANGELOG.md +12 -0
- package/LICENSE +21 -0
- package/README.md +188 -2
- package/action.yml +44 -0
- package/bin/bundle-pulse.js +25 -0
- package/package.json +52 -4
- package/src/core.d.ts +16 -0
- package/src/core.js +176 -0
- package/src/index.d.ts +14 -0
- package/src/index.js +122 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 1.0.0 (2026-10-14)
|
|
4
|
+
|
|
5
|
+
First release, by Louis Wright.
|
|
6
|
+
|
|
7
|
+
- Measures raw, gzip (level 9) and brotli (quality 11) sizes of JS and CSS in a build directory; source maps skipped.
|
|
8
|
+
- Per-glob budgets on any of raw/gzip/brotli, total JS and CSS budgets, near-budget warnings.
|
|
9
|
+
- Base snapshots (`--snapshot`, `--base`) with content-hash-insensitive names; growth and new-large-file checks.
|
|
10
|
+
- Unminified and duplicate file detection; stale budgets that match nothing.
|
|
11
|
+
- Text/JSON/SARIF/Markdown output, `--fail-on`, exit codes 0/1/2, allowlist with expiry, severity overrides.
|
|
12
|
+
- Composite GitHub Action with job summary.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Louis Wright
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,189 @@
|
|
|
1
|
-
#
|
|
1
|
+
# bundle-pulse
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
> A CI gate for JavaScript and CSS bundle size: budgets on raw, gzip and brotli sizes, and a diff against your base branch.
|
|
4
|
+
|
|
5
|
+
Bundles grow one innocent import at a time. `bundle-pulse` measures every JS and CSS file in your build output the way browsers receive it (gzip and brotli as well as raw), checks each file against the budget for its glob, checks total JS and CSS, and compares against a snapshot from the base branch so a pull request that adds 40 kB to the main chunk fails with a clear message. Content hashes in file names (`index-BdT9x2Qa.js`, `main.3f2a9c1b.chunk.js`) are stripped when comparing, so the diff follows the entry, not the file name.
|
|
6
|
+
|
|
7
|
+
CLI, library and GitHub Action. Node.js 18+, **zero runtime dependencies**, runs offline. By Louis Wright.
|
|
8
|
+
|
|
9
|
+
## Features
|
|
10
|
+
|
|
11
|
+
- **Three sizes:** raw, gzip (level 9) and brotli (quality 11) for every `.js`, `.mjs`, `.cjs` and `.css` file; source maps are skipped.
|
|
12
|
+
- **Budgets:** per-glob budgets on any of the three sizes, total JS and total CSS budgets, and a warning when a file passes 90% of its budget.
|
|
13
|
+
- **Base diff:** `--snapshot` writes sizes to JSON; `--base` compares against it and reports files that grew more than `--max-growth` percent (default 10%, ignoring changes under 1 kB) and new large chunks.
|
|
14
|
+
- **Hygiene:** unminified files, identical files shipped under two names, budgets that match nothing (renamed entries), and large files with no budget.
|
|
15
|
+
- **Works with any bundler:** Vite, webpack, Rollup, esbuild, Parcel, Next.js static output, or plain files.
|
|
16
|
+
|
|
17
|
+
### Scope
|
|
18
|
+
|
|
19
|
+
bundle-pulse measures what is on disk after the build. It does not analyse which modules are inside a chunk (use a bundle analyser for that) or measure runtime performance (see vitals-pulse).
|
|
20
|
+
|
|
21
|
+
## Rules
|
|
22
|
+
|
|
23
|
+
| Rule | Default severity | What it means |
|
|
24
|
+
|---|---|---|
|
|
25
|
+
| `bundle-over-budget` | high | File is larger than its budget |
|
|
26
|
+
| `bundle-total-over-budget` | high | Total JS or CSS is larger than its budget |
|
|
27
|
+
| `bundle-growth` | medium | File grew more than the allowed percentage since the base snapshot |
|
|
28
|
+
| `bundle-budget-unmatched` | medium | Budget glob matches no file (renamed entry or stale budget) |
|
|
29
|
+
| `bundle-unminified` | medium | Large file looks unminified |
|
|
30
|
+
| `bundle-near-budget` | low | File is within the warning margin of its budget |
|
|
31
|
+
| `bundle-new-large` | low | New file since the base snapshot is large |
|
|
32
|
+
| `bundle-duplicate` | low | Identical file shipped under more than one name |
|
|
33
|
+
| `bundle-no-budget` | info | Large file has no budget |
|
|
34
|
+
| `allowlist-expired` | medium | An allowlist entry is past its `expires` date |
|
|
35
|
+
| `allowlist-unused` | info | An allowlist entry matched nothing |
|
|
36
|
+
|
|
37
|
+
## Install and run
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
npx bundle-pulse dist
|
|
41
|
+
npx bundle-pulse dist -b 'assets/index-*.js=60KB' -b 'assets/*.css=brotli:15KB' --total-js 200KB
|
|
42
|
+
npx bundle-pulse dist --snapshot bundle-base.json # on main
|
|
43
|
+
npx bundle-pulse dist --base bundle-base.json --max-growth 5 --format markdown
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### Sample output
|
|
47
|
+
|
|
48
|
+
```text
|
|
49
|
+
bundle-pulse — test/fixtures/bad
|
|
50
|
+
5 file(s): JS 32.6 kB gzip, CSS 10.9 kB gzip
|
|
51
|
+
|
|
52
|
+
HIGH Total JS is 32.6 kB gzip, over the 30.0 kB budget [bundle-total-over-budget]
|
|
53
|
+
HIGH test/fixtures/bad/assets/app-K2m9xQ7r.css assets/app-K2m9xQ7r.css is 10.9 kB gzip, over its 6.0 kB budget by 4.9 kB (82%) [bundle-over-budget]
|
|
54
|
+
HIGH test/fixtures/bad/assets/index-Xa91bQ2k.js assets/index-Xa91bQ2k.js is 9.1 kB gzip, over its 8.0 kB budget by 1.1 kB (14%) [bundle-over-budget]
|
|
55
|
+
MEDIUM Budget "assets/admin-*.js" matches no file [bundle-budget-unmatched]
|
|
56
|
+
MEDIUM test/fixtures/bad/assets/legacy.js assets/legacy.js (40.0 kB) looks unminified [bundle-unminified]
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Options
|
|
60
|
+
|
|
61
|
+
```text
|
|
62
|
+
bundle-pulse 1.0.0 — JS/CSS bundle size budgets with raw, gzip and brotli sizes
|
|
63
|
+
|
|
64
|
+
Usage: bundle-pulse [build-dir] [options]
|
|
65
|
+
|
|
66
|
+
Budgets
|
|
67
|
+
-b, --budget <glob=size> Budget for matching files, repeatable; gzip by default ("assets/index-*.js=60KB",
|
|
68
|
+
"**/*.css=brotli:20KB", "vendor*.js=raw:400KB")
|
|
69
|
+
--total-js <size> Budget for all JS together (gzip)
|
|
70
|
+
--total-css <size> Budget for all CSS together (gzip)
|
|
71
|
+
--include <globs> Files to measure (default **/*.{js,mjs,cjs,css}; source maps always skipped)
|
|
72
|
+
--exclude <globs> Files to skip
|
|
73
|
+
Base comparison
|
|
74
|
+
--base <file> Snapshot from the base branch (written with --snapshot)
|
|
75
|
+
--base-optional Do not fail if the base snapshot does not exist yet
|
|
76
|
+
--max-growth <pct> Allowed growth per file vs base, in percent (default 10)
|
|
77
|
+
--snapshot <file> Write the current sizes as a snapshot
|
|
78
|
+
|
|
79
|
+
Common
|
|
80
|
+
-c, --config <file> JSON config (default: .bundle-pulse.json or "bundlePulse" in package.json)
|
|
81
|
+
-f, --format <fmt> text (default) | json | sarif | markdown
|
|
82
|
+
--json <file> Also write a JSON report
|
|
83
|
+
--sarif <file> Also write SARIF 2.1.0
|
|
84
|
+
--markdown <file> Also write Markdown (e.g. for a job summary)
|
|
85
|
+
--fail-on <sev> critical | high | medium | low | info | none (default: high)
|
|
86
|
+
--list-rules Print rules and default severities
|
|
87
|
+
--no-fixes Hide fix suggestions in text output
|
|
88
|
+
--no-color Disable colours
|
|
89
|
+
-v, --version / -h, --help
|
|
90
|
+
|
|
91
|
+
Exit codes: 0 = passed, 1 = findings at or above --fail-on, 2 = usage, config or read error.
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Configuration and allowlist
|
|
95
|
+
|
|
96
|
+
Options can live in `.bundle-pulse.json` (or a `"bundlePulse"` key in `package.json`, or `--config <file>`):
|
|
97
|
+
|
|
98
|
+
```json
|
|
99
|
+
{
|
|
100
|
+
"failOn": "medium",
|
|
101
|
+
"severity": {
|
|
102
|
+
"bundle-over-budget": "low"
|
|
103
|
+
},
|
|
104
|
+
"allowlist": [
|
|
105
|
+
{
|
|
106
|
+
"rule": "bundle-unminified",
|
|
107
|
+
"subject": "assets/legacy.js",
|
|
108
|
+
"reason": "Vendored legacy widget, replaced in Q1",
|
|
109
|
+
"expires": "2027-03-31"
|
|
110
|
+
}
|
|
111
|
+
],
|
|
112
|
+
"budgets": [
|
|
113
|
+
{
|
|
114
|
+
"path": "assets/index-*.js",
|
|
115
|
+
"gzip": "60 KB",
|
|
116
|
+
"brotli": "50 KB"
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
"path": "assets/*.css",
|
|
120
|
+
"gzip": "15 KB"
|
|
121
|
+
}
|
|
122
|
+
],
|
|
123
|
+
"totalJs": "200 KB",
|
|
124
|
+
"maxGrowth": 5,
|
|
125
|
+
"base": "bundle-base.json"
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
- **`severity`** changes a rule's severity, or turns it `"off"`.
|
|
130
|
+
- **`allowlist`** entries suppress matching findings. Each needs a `reason` and matches on any combination of `rule`, `file`, `subject` (globs with `*`) and `match` (a substring of the message). Add `expires` (`YYYY-MM-DD`) so the exception is time-boxed: once it lapses the finding comes back along with an `allowlist-expired` finding (medium). Entries that no longer match anything are reported as `allowlist-unused` (info).
|
|
131
|
+
|
|
132
|
+
The config file is looked up in the target directory first, then in the current directory, so `.bundle-pulse.json` can live at your repository root. Other keys: `budgets` (array of `{ path, raw, gzip, brotli }` or a map of glob to gzip size), `totalJs`, `totalCss`, `maxGrowth`, `warnAt` (percent, default 90), `minDelta`, `largeFile`, `newLarge`, `base`, `include` and `exclude`. The config file is read from the build directory or from `--config`.
|
|
133
|
+
|
|
134
|
+
## Output and exit codes
|
|
135
|
+
|
|
136
|
+
- `--format text` (default), `json`, `sarif` (SARIF 2.1.0 for GitHub code scanning) or `markdown` (for job summaries and PR comments). `--json`, `--sarif` and `--markdown` write extra report files in the same run.
|
|
137
|
+
- `--fail-on <severity>` sets the threshold (`critical`, `high`, `medium`, `low`, `info`, `none`; default `high`).
|
|
138
|
+
- Exit codes: **0** passed, **1** findings at or above `--fail-on`, **2** usage, config or read error.
|
|
139
|
+
|
|
140
|
+
## GitHub Action
|
|
141
|
+
|
|
142
|
+
```yaml
|
|
143
|
+
permissions:
|
|
144
|
+
contents: read
|
|
145
|
+
security-events: write # only needed for the SARIF upload
|
|
146
|
+
jobs:
|
|
147
|
+
bundle-pulse:
|
|
148
|
+
runs-on: ubuntu-latest
|
|
149
|
+
steps:
|
|
150
|
+
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
|
151
|
+
with: { persist-credentials: false }
|
|
152
|
+
- uses: MrLouisWright/bundle-pulse@v1
|
|
153
|
+
with:
|
|
154
|
+
path: dist
|
|
155
|
+
fail-on: high
|
|
156
|
+
sarif: bundle-pulse.sarif
|
|
157
|
+
- uses: github/codeql-action/upload-sarif@v3
|
|
158
|
+
if: always()
|
|
159
|
+
with: { sarif_file: bundle-pulse.sarif }
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
| Input | Description | Default |
|
|
163
|
+
|---|---|---|
|
|
164
|
+
| `path` | Build output directory | `.` |
|
|
165
|
+
| `fail-on` | Failure threshold | `high` |
|
|
166
|
+
| `config` | Config file path | |
|
|
167
|
+
| `sarif` / `json` | Write SARIF / JSON report to this path | |
|
|
168
|
+
| `job-summary` | Add the Markdown report to the job summary | `true` |
|
|
169
|
+
| `args` | Extra CLI arguments | |
|
|
170
|
+
|
|
171
|
+
The action runs the bundled CLI with Node 20; it has no dependencies to install. Pin it to a commit SHA if your policy requires it.
|
|
172
|
+
|
|
173
|
+
## Library
|
|
174
|
+
|
|
175
|
+
```js
|
|
176
|
+
import { scan, analyze, normaliseBudgets } from "bundle-pulse";
|
|
177
|
+
const files = scan("dist");
|
|
178
|
+
const findings = analyze(files, { budgets: normaliseBudgets({ "assets/index-*.js": "60 KB" }) });
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Types are included.
|
|
182
|
+
|
|
183
|
+
## Requirements
|
|
184
|
+
|
|
185
|
+
Node.js 18 or newer. Zero runtime dependencies.
|
|
186
|
+
|
|
187
|
+
## Licence
|
|
188
|
+
|
|
189
|
+
MIT © Louis Wright. See [LICENSE](LICENSE).
|
package/action.yml
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
name: 'bundle-pulse'
|
|
2
|
+
description: 'JS/CSS bundle size budgets (raw, gzip, brotli)'
|
|
3
|
+
author: 'Louis Wright'
|
|
4
|
+
branding: { icon: 'package', color: 'blue' }
|
|
5
|
+
inputs:
|
|
6
|
+
path: { description: 'Build output directory', default: '.' }
|
|
7
|
+
fail-on: { description: 'critical | high | medium | low | info | none', default: 'high' }
|
|
8
|
+
config: { description: 'Path to JSON config file', default: '' }
|
|
9
|
+
sarif: { description: 'Write SARIF to this path', default: '' }
|
|
10
|
+
json: { description: 'Write JSON report to this path', default: '' }
|
|
11
|
+
job-summary: { description: 'Write the Markdown report to the job summary (true/false)', default: 'true' }
|
|
12
|
+
args: { description: 'Extra CLI arguments', default: '' }
|
|
13
|
+
outputs:
|
|
14
|
+
markdown-file: { description: 'Path of the Markdown report', value: '${{ steps.run.outputs.markdown-file }}' }
|
|
15
|
+
runs:
|
|
16
|
+
using: 'composite'
|
|
17
|
+
steps:
|
|
18
|
+
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
|
19
|
+
with: { node-version: '20' }
|
|
20
|
+
- id: run
|
|
21
|
+
shell: bash
|
|
22
|
+
env:
|
|
23
|
+
XB_PATH: ${{ inputs.path }}
|
|
24
|
+
XB_FAIL_ON: ${{ inputs.fail-on }}
|
|
25
|
+
XB_CONFIG: ${{ inputs.config }}
|
|
26
|
+
XB_SARIF: ${{ inputs.sarif }}
|
|
27
|
+
XB_JSON: ${{ inputs.json }}
|
|
28
|
+
XB_SUMMARY: ${{ inputs.job-summary }}
|
|
29
|
+
XB_ARGS: ${{ inputs.args }}
|
|
30
|
+
XB_BIN: ${{ github.action_path }}/bin/bundle-pulse.js
|
|
31
|
+
run: |
|
|
32
|
+
md="$RUNNER_TEMP/bundle-pulse.md"
|
|
33
|
+
args=("$XB_PATH" --fail-on "$XB_FAIL_ON" --markdown "$md")
|
|
34
|
+
[ -n "$XB_CONFIG" ] && args+=(--config "$XB_CONFIG")
|
|
35
|
+
[ -n "$XB_SARIF" ] && args+=(--sarif "$XB_SARIF")
|
|
36
|
+
[ -n "$XB_JSON" ] && args+=(--json "$XB_JSON")
|
|
37
|
+
set +e
|
|
38
|
+
# shellcheck disable=SC2086
|
|
39
|
+
node "$XB_BIN" "${args[@]}" $XB_ARGS
|
|
40
|
+
code=$?
|
|
41
|
+
set -e
|
|
42
|
+
echo "markdown-file=$md" >> "$GITHUB_OUTPUT"
|
|
43
|
+
if [ "$XB_SUMMARY" = "true" ] && [ -f "$md" ]; then cat "$md" >> "$GITHUB_STEP_SUMMARY"; fi
|
|
44
|
+
exit $code
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { mainCli, check, RULES, splitList } from '../src/index.js';
|
|
4
|
+
|
|
5
|
+
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
6
|
+
mainCli({
|
|
7
|
+
name: 'bundle-pulse', version: pkg.version, configKey: 'bundlePulse', rules: RULES,
|
|
8
|
+
tagline: 'JS/CSS bundle size budgets with raw, gzip and brotli sizes',
|
|
9
|
+
usage: 'Usage: bundle-pulse [build-dir] [options]',
|
|
10
|
+
help: `Budgets
|
|
11
|
+
-b, --budget <glob=size> Budget for matching files, repeatable; gzip by default ("assets/index-*.js=60KB",
|
|
12
|
+
"**/*.css=brotli:20KB", "vendor*.js=raw:400KB")
|
|
13
|
+
--total-js <size> Budget for all JS together (gzip)
|
|
14
|
+
--total-css <size> Budget for all CSS together (gzip)
|
|
15
|
+
--include <globs> Files to measure (default **/*.{js,mjs,cjs,css}; source maps always skipped)
|
|
16
|
+
--exclude <globs> Files to skip
|
|
17
|
+
Base comparison
|
|
18
|
+
--base <file> Snapshot from the base branch (written with --snapshot)
|
|
19
|
+
--base-optional Do not fail if the base snapshot does not exist yet
|
|
20
|
+
--max-growth <pct> Allowed growth per file vs base, in percent (default 10)
|
|
21
|
+
--snapshot <file> Write the current sizes as a snapshot
|
|
22
|
+
`,
|
|
23
|
+
options: { budget: { type: 'string', short: 'b', multiple: true }, 'total-js': { type: 'string' }, 'total-css': { type: 'string' }, include: { type: 'string', multiple: true }, exclude: { type: 'string', multiple: true }, base: { type: 'string' }, 'base-optional': { type: 'boolean' }, 'max-growth': { type: 'string' }, snapshot: { type: 'string' } },
|
|
24
|
+
run: async ({ dir, values: o, config, configFile }) => check(dir, { budget: o.budget || [], totalJs: o['total-js'], totalCss: o['total-css'], include: splitList(o.include), exclude: splitList(o.exclude), base: o.base, baseOptional: o['base-optional'], maxGrowth: o['max-growth'] != null ? Number(o['max-growth']) : undefined, snapshot: o.snapshot, toolVersion: pkg.version }, config, configFile),
|
|
25
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,54 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bundle-pulse",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "CI gate for JS/CSS bundle size: per-entry budgets on raw, gzip and brotli sizes, total JS/CSS budgets, growth vs a base-branch snapshot with content-hash-insensitive names, unminified and duplicate files. SARIF, Markdown, GitHub Action. Zero deps.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.js",
|
|
7
|
+
"types": "./src/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./src/index.d.ts",
|
|
11
|
+
"default": "./src/index.js"
|
|
12
|
+
},
|
|
13
|
+
"./package.json": "./package.json"
|
|
14
|
+
},
|
|
15
|
+
"bin": {
|
|
16
|
+
"bundle-pulse": "bin/bundle-pulse.js"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"src",
|
|
20
|
+
"bin",
|
|
21
|
+
"action.yml",
|
|
22
|
+
"README.md",
|
|
23
|
+
"CHANGELOG.md",
|
|
24
|
+
"LICENSE"
|
|
25
|
+
],
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=18"
|
|
28
|
+
},
|
|
29
|
+
"scripts": {
|
|
30
|
+
"test": "node --test test/*.test.js"
|
|
31
|
+
},
|
|
32
|
+
"keywords": [
|
|
33
|
+
"bundle-size",
|
|
34
|
+
"performance",
|
|
35
|
+
"budget",
|
|
36
|
+
"gzip",
|
|
37
|
+
"brotli",
|
|
38
|
+
"webpack",
|
|
39
|
+
"vite",
|
|
40
|
+
"ci",
|
|
41
|
+
"sarif",
|
|
42
|
+
"github-action"
|
|
43
|
+
],
|
|
44
|
+
"author": "Louis Wright <hello@louiswright.co.uk> (https://github.com/MrLouisWright)",
|
|
45
|
+
"license": "MIT",
|
|
46
|
+
"repository": {
|
|
47
|
+
"type": "git",
|
|
48
|
+
"url": "git+https://github.com/MrLouisWright/bundle-pulse.git"
|
|
49
|
+
},
|
|
50
|
+
"bugs": {
|
|
51
|
+
"url": "https://github.com/MrLouisWright/bundle-pulse/issues"
|
|
52
|
+
},
|
|
53
|
+
"homepage": "https://github.com/MrLouisWright/bundle-pulse#readme"
|
|
54
|
+
}
|
package/src/core.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export type Severity = 'critical' | 'high' | 'medium' | 'low' | 'info';
|
|
2
|
+
export interface Finding { rule: string; severity: Severity; message: string; fix?: string; file?: string; line?: number; subject?: string; [k: string]: unknown }
|
|
3
|
+
export interface AllowlistEntry { rule?: string; file?: string; subject?: string; match?: string; reason: string; expires?: string }
|
|
4
|
+
export interface Config { failOn?: Severity | 'none'; allowlist?: AllowlistEntry[]; severity?: Record<string, Severity | 'off'>; [k: string]: unknown }
|
|
5
|
+
export interface Rule { severity: Severity; title: string }
|
|
6
|
+
export interface Report { tool: { name: string; version: string; rules: Record<string, Rule> }; target: string; summaryLine?: string; counts: Record<Severity, number>; findings: Finding[]; suppressed: (Finding & { reason: string; expires: string | null })[]; [k: string]: unknown }
|
|
7
|
+
export const SEVERITIES: Severity[];
|
|
8
|
+
export const POLICY_RULES: Record<string, Rule>;
|
|
9
|
+
export class UsageError extends Error {}
|
|
10
|
+
export function applyPolicy(raw: Finding[], config?: Config, opts?: { now?: Date; configFile?: string }): { findings: Finding[]; suppressed: Report['suppressed'] };
|
|
11
|
+
export function validateConfig(config: unknown): Config;
|
|
12
|
+
export function exceeds(report: Report, failOn: Severity | 'none'): boolean;
|
|
13
|
+
export function formatText(report: Report, opts?: { color?: boolean; failOn?: string; fixes?: boolean }): string;
|
|
14
|
+
export function formatJson(report: Report): string;
|
|
15
|
+
export function formatSarif(report: Report): string;
|
|
16
|
+
export function formatMarkdown(report: Report, opts?: { failOn?: string }): string;
|
package/src/core.js
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// Shared reporting / policy / CLI core. Zero dependencies. Louis Wright, MIT.
|
|
2
|
+
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
3
|
+
import { join, resolve } from 'node:path';
|
|
4
|
+
import { parseArgs } from 'node:util';
|
|
5
|
+
|
|
6
|
+
export const SEVERITIES = ['critical', 'high', 'medium', 'low', 'info'];
|
|
7
|
+
export const sevRank = (s) => { const i = SEVERITIES.indexOf(s); return i === -1 ? 99 : i; };
|
|
8
|
+
export class UsageError extends Error {}
|
|
9
|
+
|
|
10
|
+
export const POLICY_RULES = {
|
|
11
|
+
'allowlist-expired': { severity: 'medium', title: 'Allowlist entry has expired' },
|
|
12
|
+
'allowlist-unused': { severity: 'info', title: 'Allowlist entry matched nothing' },
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
function globRe(p) { return new RegExp('^' + String(p).replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.') + '$', 'i'); }
|
|
16
|
+
|
|
17
|
+
/** Does an allowlist entry ({rule, file, match, subject}) cover a finding? */
|
|
18
|
+
export function entryMatches(e, f) {
|
|
19
|
+
if (e.rule && !globRe(e.rule).test(f.rule)) return false;
|
|
20
|
+
if (e.file && !globRe(e.file).test(f.file || '')) return false;
|
|
21
|
+
if (e.subject && !globRe(e.subject).test(f.subject || '')) return false;
|
|
22
|
+
if (e.match && !String(f.message).toLowerCase().includes(String(e.match).toLowerCase())) return false;
|
|
23
|
+
return !!(e.rule || e.file || e.subject || e.match);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function validateConfig(config) {
|
|
27
|
+
if (config == null || typeof config !== 'object' || Array.isArray(config)) throw new UsageError('config must be a JSON object');
|
|
28
|
+
if (config.allowlist != null && !Array.isArray(config.allowlist)) throw new UsageError('"allowlist" must be an array');
|
|
29
|
+
for (const [i, e] of (config.allowlist || []).entries()) {
|
|
30
|
+
if (!e || typeof e !== 'object') throw new UsageError(`allowlist[${i}] must be an object`);
|
|
31
|
+
if (!e.rule && !e.file && !e.subject && !e.match) throw new UsageError(`allowlist[${i}] needs at least one of rule, file, subject, match`);
|
|
32
|
+
if (!e.reason) throw new UsageError(`allowlist[${i}] needs a "reason"`);
|
|
33
|
+
if (e.expires != null && (!/^\d{4}-\d{2}-\d{2}$/.test(e.expires) || Number.isNaN(Date.parse(e.expires)))) throw new UsageError(`allowlist[${i}].expires must be YYYY-MM-DD`);
|
|
34
|
+
}
|
|
35
|
+
for (const [k, v] of Object.entries(config.severity || {})) if (v !== 'off' && !SEVERITIES.includes(v)) throw new UsageError(`severity.${k} must be one of ${SEVERITIES.join(', ')}, off`);
|
|
36
|
+
return config;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Apply severity overrides and the allowlist. Returns { findings, suppressed }. */
|
|
40
|
+
export function applyPolicy(raw, config = {}, { now = new Date(), configFile = 'config' } = {}) {
|
|
41
|
+
const today = now.toISOString().slice(0, 10);
|
|
42
|
+
const sev = config.severity || {};
|
|
43
|
+
const list = (config.allowlist || []).map((e, i) => ({ ...e, index: i, used: 0, expired: !!(e.expires && e.expires < today) }));
|
|
44
|
+
const findings = []; const suppressed = [];
|
|
45
|
+
for (const f0 of raw) {
|
|
46
|
+
const o = sev[f0.rule];
|
|
47
|
+
if (o === 'off') continue;
|
|
48
|
+
const f = o ? { ...f0, severity: o } : f0;
|
|
49
|
+
const hit = list.find((e) => entryMatches(e, f));
|
|
50
|
+
if (hit) { hit.used++; if (!hit.expired) { suppressed.push({ ...f, reason: hit.reason, expires: hit.expires || null }); continue; } }
|
|
51
|
+
findings.push(f);
|
|
52
|
+
}
|
|
53
|
+
for (const e of list) {
|
|
54
|
+
const label = [e.rule, e.file, e.subject, e.match].filter(Boolean).join(' / ');
|
|
55
|
+
if (e.expired && sev['allowlist-expired'] !== 'off') findings.push({ rule: 'allowlist-expired', severity: sev['allowlist-expired'] || 'medium', file: configFile, message: `Allowlist entry #${e.index + 1} (${label}) expired on ${e.expires}`, fix: 'Fix the underlying finding, or renew the entry with a new "expires" date and reason.' });
|
|
56
|
+
else if (!e.used && sev['allowlist-unused'] !== 'off') findings.push({ rule: 'allowlist-unused', severity: sev['allowlist-unused'] || 'info', file: configFile, message: `Allowlist entry #${e.index + 1} (${label}) matched no finding`, fix: 'Remove the stale entry.' });
|
|
57
|
+
}
|
|
58
|
+
findings.sort((a, b) => sevRank(a.severity) - sevRank(b.severity) || String(a.file || '').localeCompare(String(b.file || '')) || (a.line || 0) - (b.line || 0));
|
|
59
|
+
return { findings, suppressed };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function countBy(findings) { const c = {}; for (const s of SEVERITIES) c[s] = 0; for (const f of findings) c[f.severity] = (c[f.severity] || 0) + 1; return c; }
|
|
63
|
+
export const exceeds = (r, failOn) => !!failOn && failOn !== 'none' && r.findings.some((f) => sevRank(f.severity) <= sevRank(failOn));
|
|
64
|
+
|
|
65
|
+
const C = { critical: '\x1b[35;1m', high: '\x1b[31;1m', medium: '\x1b[33m', low: '\x1b[36m', info: '\x1b[2m', dim: '\x1b[2m', bold: '\x1b[1m', reset: '\x1b[0m', red: '\x1b[31m', green: '\x1b[32m' };
|
|
66
|
+
const sumText = (counts) => SEVERITIES.filter((k) => counts[k]).map((k) => `${counts[k]} ${k}`).join(', ') || 'no findings';
|
|
67
|
+
const loc = (f) => (f.file ? `${f.file}${f.line ? ':' + f.line : ''}` : '');
|
|
68
|
+
|
|
69
|
+
export function formatText(r, { color = false, failOn, fixes = true } = {}) {
|
|
70
|
+
const c = (k, s) => (color ? `${C[k]}${s}${C.reset}` : s);
|
|
71
|
+
const l = [c('bold', `${r.tool.name} — ${r.target}`)];
|
|
72
|
+
if (r.summaryLine) l.push(c('dim', r.summaryLine));
|
|
73
|
+
l.push('');
|
|
74
|
+
for (const f of r.findings) {
|
|
75
|
+
l.push(`${c(f.severity, f.severity.toUpperCase().padEnd(8))} ${loc(f) ? c('dim', loc(f) + ' ') : ''}${f.message} [${f.rule}]`);
|
|
76
|
+
if (fixes && f.fix) l.push(c('dim', ` ↳ fix: ${f.fix}`));
|
|
77
|
+
}
|
|
78
|
+
if (r.findings.length) l.push('');
|
|
79
|
+
if (r.suppressed.length) l.push(c('dim', `${r.suppressed.length} finding(s) covered by the allowlist`));
|
|
80
|
+
l.push(sumText(r.counts));
|
|
81
|
+
if (failOn) l.push(exceeds(r, failOn) ? c('red', `✖ Failing (fail-on: ${failOn}).`) : c('green', `✔ Passing (fail-on: ${failOn}).`));
|
|
82
|
+
return l.join('\n') + '\n';
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export const formatJson = (r) => JSON.stringify(r, null, 2) + '\n';
|
|
86
|
+
|
|
87
|
+
const LEVEL = { critical: 'error', high: 'error', medium: 'warning', low: 'note', info: 'note' };
|
|
88
|
+
const SCORE = { critical: '9.0', high: '7.5', medium: '5.0', low: '3.0', info: '1.0' };
|
|
89
|
+
export function formatSarif(r) {
|
|
90
|
+
const rules = { ...r.tool.rules, ...POLICY_RULES };
|
|
91
|
+
const ids = Object.keys(rules);
|
|
92
|
+
const repo = `https://github.com/MrLouisWright/${r.tool.name}`;
|
|
93
|
+
return JSON.stringify({
|
|
94
|
+
$schema: 'https://json.schemastore.org/sarif-2.1.0.json', version: '2.1.0',
|
|
95
|
+
runs: [{
|
|
96
|
+
tool: { driver: { name: r.tool.name, version: r.tool.version, informationUri: repo, rules: ids.map((id) => ({ id, name: id.replace(/(^|-)(\w)/g, (_, __, ch) => ch.toUpperCase()), shortDescription: { text: rules[id].title }, helpUri: `${repo}#rules`, defaultConfiguration: { level: LEVEL[rules[id].severity] }, properties: { 'security-severity': SCORE[rules[id].severity] } })) } },
|
|
97
|
+
results: r.findings.map((f) => ({ ruleId: f.rule, ruleIndex: ids.indexOf(f.rule), level: LEVEL[f.severity], message: { text: f.fix ? `${f.message}. Fix: ${f.fix}` : f.message }, locations: [{ physicalLocation: { artifactLocation: { uri: f.file || r.tool.defaultFile || '.' }, region: { startLine: f.line || 1 } } }], partialFingerprints: { ruleSubject: `${f.rule}:${f.file || ''}:${f.subject || f.message}` }, properties: { severity: f.severity } })),
|
|
98
|
+
}],
|
|
99
|
+
}, null, 2) + '\n';
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const esc = (s) => String(s ?? '').replace(/\|/g, '\\|').replace(/\n/g, ' ');
|
|
103
|
+
export function formatMarkdown(r, { failOn } = {}) {
|
|
104
|
+
const l = [`## ${r.tool.emoji || '📏'} ${r.tool.name}`, '', `**${esc(r.target)}**: ${r.summaryLine ? esc(r.summaryLine) + '; ' : ''}${sumText(r.counts)}.`];
|
|
105
|
+
if (failOn) l.push('', exceeds(r, failOn) ? `❌ Failing at \`--fail-on ${failOn}\`.` : `✅ Passing at \`--fail-on ${failOn}\`.`);
|
|
106
|
+
if (r.findings.length) {
|
|
107
|
+
l.push('', '| Severity | Location | Rule | Finding | Fix |', '|---|---|---|---|---|');
|
|
108
|
+
for (const f of r.findings) l.push(`| ${f.severity} | ${loc(f) ? '`' + esc(loc(f)) + '`' : ''} | \`${f.rule}\` | ${esc(f.message)} | ${esc(f.fix)} |`);
|
|
109
|
+
}
|
|
110
|
+
if (r.suppressed.length) l.push('', `_${r.suppressed.length} finding(s) covered by the allowlist._`);
|
|
111
|
+
return l.join('\n') + '\n';
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Build a report object from raw findings. */
|
|
115
|
+
export function buildReport(tool, target, raw, config, extra = {}) {
|
|
116
|
+
const { findings, suppressed } = applyPolicy(raw, config, { now: extra.now, configFile: extra.configFile });
|
|
117
|
+
const { now, configFile, ...rest } = extra;
|
|
118
|
+
return { tool: { name: tool.name, version: tool.version, rules: tool.rules, emoji: tool.emoji, defaultFile: tool.defaultFile }, target, ...rest, counts: countBy(findings), findings, suppressed };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function loadConfig(dir, name, key, explicit) {
|
|
122
|
+
const file = explicit ? resolve(explicit) : join(dir, `.${name}.json`);
|
|
123
|
+
if (explicit || existsSync(file)) {
|
|
124
|
+
let c; try { c = JSON.parse(readFileSync(file, 'utf8')); } catch (e) { throw new UsageError(`Cannot read config ${file}: ${e.message}`); }
|
|
125
|
+
return { config: validateConfig(c), configFile: explicit || `.${name}.json` };
|
|
126
|
+
}
|
|
127
|
+
for (const d of [dir, process.cwd()]) {
|
|
128
|
+
if (d !== dir && existsSync(join(d, `.${name}.json`))) return loadConfig(d, name, key, join(d, `.${name}.json`));
|
|
129
|
+
try { const pj = JSON.parse(readFileSync(join(d, 'package.json'), 'utf8')); if (pj[key]) return { config: validateConfig(pj[key]), configFile: 'package.json' }; } catch (e) { if (e instanceof UsageError) throw e; }
|
|
130
|
+
}
|
|
131
|
+
return { config: {}, configFile: null };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const list = (v) => (v || []).flatMap((x) => String(x).split(',')).map((x) => x.trim()).filter(Boolean);
|
|
135
|
+
export { list as splitList };
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Generic CLI. spec = { name, version, configKey, rules, help (tool-specific options text), options (parseArgs), defaultFailOn,
|
|
139
|
+
* run: async ({ dir, positionals, values, config, configFile, stdin }) => report }
|
|
140
|
+
*/
|
|
141
|
+
export async function runCli(spec, argv = process.argv.slice(2), io = { stdout: process.stdout, stderr: process.stderr }) {
|
|
142
|
+
const usage = (m) => { io.stderr.write(`${spec.name}: ${m}\nRun with --help for usage.\n`); return 2; };
|
|
143
|
+
let a;
|
|
144
|
+
try {
|
|
145
|
+
a = parseArgs({ args: argv, allowPositionals: true, options: { ...spec.options, config: { type: 'string', short: 'c' }, format: { type: 'string', short: 'f' }, json: { type: 'string' }, sarif: { type: 'string' }, markdown: { type: 'string' }, 'fail-on': { type: 'string' }, 'no-color': { type: 'boolean' }, 'no-fixes': { type: 'boolean' }, 'list-rules': { type: 'boolean' }, version: { type: 'boolean', short: 'v' }, help: { type: 'boolean', short: 'h' } } });
|
|
146
|
+
} catch (e) { return usage(e.message); }
|
|
147
|
+
const { values: o, positionals } = a;
|
|
148
|
+
const all = { ...spec.rules, ...POLICY_RULES };
|
|
149
|
+
if (o.help) { io.stdout.write(`${spec.name} ${spec.version} — ${spec.tagline}\n\n${spec.usage}\n\n${spec.help}\nCommon\n -c, --config <file> JSON config (default: .${spec.name}.json or "${spec.configKey}" in package.json)\n -f, --format <fmt> text (default) | json | sarif | markdown\n --json <file> Also write a JSON report\n --sarif <file> Also write SARIF 2.1.0\n --markdown <file> Also write Markdown (e.g. for a job summary)\n --fail-on <sev> critical | high | medium | low | info | none (default: ${spec.defaultFailOn || 'high'})\n --list-rules Print rules and default severities\n --no-fixes Hide fix suggestions in text output\n --no-color Disable colours\n -v, --version / -h, --help\n\nExit codes: 0 = passed, 1 = findings at or above --fail-on, 2 = usage, config or read error.\n`); return 0; }
|
|
150
|
+
if (o.version) { io.stdout.write(spec.version + '\n'); return 0; }
|
|
151
|
+
if (o['list-rules']) { for (const [id, r] of Object.entries(all)) io.stdout.write(`${id.padEnd(28)} ${r.severity.padEnd(9)} ${r.title}\n`); return 0; }
|
|
152
|
+
const format = o.format || 'text';
|
|
153
|
+
if (!['text', 'json', 'sarif', 'markdown'].includes(format)) return usage(`Invalid --format: ${format}`);
|
|
154
|
+
let report; let failOn;
|
|
155
|
+
try {
|
|
156
|
+
const dir = resolve(spec.dirFrom ? spec.dirFrom(o, positionals) : (positionals[0] || '.'));
|
|
157
|
+
if (!existsSync(dir)) return usage(`No such directory: ${dir}`);
|
|
158
|
+
const { config, configFile } = loadConfig(dir, spec.name, spec.configKey, o.config);
|
|
159
|
+
failOn = o['fail-on'] || config.failOn || spec.defaultFailOn || 'high';
|
|
160
|
+
if (failOn !== 'none' && !SEVERITIES.includes(failOn)) return usage(`Invalid --fail-on: ${failOn}`);
|
|
161
|
+
report = await spec.run({ dir, positionals, values: o, config, configFile: configFile || `.${spec.name}.json` });
|
|
162
|
+
} catch (e) { if (e instanceof UsageError || e.code === 'ENOENT' || e.code === 'EACCES') return usage(e.message); throw e; }
|
|
163
|
+
const color = !o['no-color'] && io.stdout.isTTY && !process.env.NO_COLOR;
|
|
164
|
+
const out = { text: () => formatText(report, { color, failOn, fixes: !o['no-fixes'] }), json: () => formatJson(report), sarif: () => formatSarif(report), markdown: () => formatMarkdown(report, { failOn }) };
|
|
165
|
+
io.stdout.write(out[format]());
|
|
166
|
+
try {
|
|
167
|
+
if (o.json) writeFileSync(o.json, formatJson(report));
|
|
168
|
+
if (o.sarif) writeFileSync(o.sarif, formatSarif(report));
|
|
169
|
+
if (o.markdown) writeFileSync(o.markdown, formatMarkdown(report, { failOn }));
|
|
170
|
+
} catch (e) { return usage(`Cannot write output: ${e.message}`); }
|
|
171
|
+
return exceeds(report, failOn) ? 1 : 0;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function mainCli(spec) {
|
|
175
|
+
runCli(spec).then((c) => { process.exitCode = c; }, (e) => { process.stderr.write(`${spec.name}: ${e.stack || e}\n`); process.exitCode = 2; });
|
|
176
|
+
}
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export * from './core.js';
|
|
2
|
+
import type { Report, Finding } from './core.js';
|
|
3
|
+
export declare const RULES: Record<string, { severity: string; title: string }>;
|
|
4
|
+
export interface FileSize { file: string; name: string; kind: 'js' | 'css'; raw: number; gzip: number; brotli?: number; sha: string; unminified?: boolean }
|
|
5
|
+
export interface Budget { path: string; raw?: number; gzip?: number; brotli?: number; src?: string }
|
|
6
|
+
export declare function parseSize(v: string | number): number | null;
|
|
7
|
+
export declare function fmtSize(n: number): string;
|
|
8
|
+
export declare function stableName(file: string): string;
|
|
9
|
+
export declare function measure(buf: Buffer, opts?: { brotli?: boolean }): { raw: number; gzip: number; brotli?: number };
|
|
10
|
+
export declare function scan(dir: string, opts?: { include?: string[]; exclude?: string[] }): FileSize[];
|
|
11
|
+
export declare function normaliseBudgets(config?: unknown, cli?: string[]): Budget[];
|
|
12
|
+
export declare function analyze(files: FileSize[], opts?: Record<string, unknown>): Finding[];
|
|
13
|
+
export declare function snapshot(files: FileSize[], meta?: Record<string, unknown>): { schema: string; generatedAt: string; files: FileSize[] };
|
|
14
|
+
export declare function check(dir: string, opts?: { budget?: string[]; totalJs?: string; totalCss?: string; include?: string[]; exclude?: string[]; base?: string; baseOptional?: boolean; maxGrowth?: number; snapshot?: string; toolVersion?: string }, config?: Record<string, unknown>, configFile?: string): Report;
|
package/src/index.js
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// bundle-pulse: JS/CSS bundle size budgets (raw/gzip/brotli) per entry, with diff against a base snapshot.
|
|
2
|
+
// Zero dependencies. Louis Wright, MIT.
|
|
3
|
+
import { readdirSync, readFileSync, statSync, existsSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { join, relative, sep, resolve } from 'node:path';
|
|
5
|
+
import { gzipSync, brotliCompressSync, constants as Z } from 'node:zlib';
|
|
6
|
+
import { createHash } from 'node:crypto';
|
|
7
|
+
import { buildReport, UsageError } from './core.js';
|
|
8
|
+
|
|
9
|
+
export * from './core.js';
|
|
10
|
+
|
|
11
|
+
export const RULES = {
|
|
12
|
+
'bundle-over-budget': { severity: 'high', title: 'File is larger than its budget' },
|
|
13
|
+
'bundle-total-over-budget': { severity: 'high', title: 'Total JS or CSS is larger than its budget' },
|
|
14
|
+
'bundle-growth': { severity: 'medium', title: 'File grew more than the allowed percentage since the base snapshot' },
|
|
15
|
+
'bundle-budget-unmatched': { severity: 'medium', title: 'Budget glob matches no file (renamed entry or stale budget)' },
|
|
16
|
+
'bundle-unminified': { severity: 'medium', title: 'Large file looks unminified' },
|
|
17
|
+
'bundle-near-budget': { severity: 'low', title: 'File is within the warning margin of its budget' },
|
|
18
|
+
'bundle-new-large': { severity: 'low', title: 'New file since the base snapshot is large' },
|
|
19
|
+
'bundle-duplicate': { severity: 'low', title: 'Identical file shipped under more than one name' },
|
|
20
|
+
'bundle-no-budget': { severity: 'info', title: 'Large file has no budget' },
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const UNITS = { b: 1, kb: 1000, k: 1000, kib: 1024, mb: 1e6, m: 1e6, mib: 1048576 };
|
|
24
|
+
/** "50 KB" | "50kb" | "1.2MB" | 51200 -> bytes. */
|
|
25
|
+
export function parseSize(v) {
|
|
26
|
+
if (typeof v === 'number') return v;
|
|
27
|
+
const m = /^\s*(\d+(?:\.\d+)?)\s*([a-z]*)\s*$/i.exec(String(v ?? ''));
|
|
28
|
+
if (!m) return null; const u = (m[2] || 'b').toLowerCase(); if (!(u in UNITS)) return null;
|
|
29
|
+
return Math.round(+m[1] * UNITS[u]);
|
|
30
|
+
}
|
|
31
|
+
export const fmtSize = (n) => (Math.abs(n) >= 1e6 ? `${(n / 1e6).toFixed(2)} MB` : Math.abs(n) >= 1000 ? `${(n / 1000).toFixed(1)} kB` : `${n} B`);
|
|
32
|
+
|
|
33
|
+
function globToRe(g) {
|
|
34
|
+
let s = ''; g = String(g).replace(/^\.?\//, '');
|
|
35
|
+
for (let k = 0; k < g.length; k++) { const c = g[k]; if (c === '*') { if (g[k + 1] === '*') { s += '.*'; k++; if (g[k + 1] === '/') k++; } else s += '[^/]*'; } else if (c === '?') s += '[^/]'; else if (c === '{') { const e = g.indexOf('}', k); s += `(?:${g.slice(k + 1, e).split(',').map((x) => x.replace(/[.+^$()|[\]\\]/g, '\\$&')).join('|')})`; k = e; } else s += c.replace(/[.+^${}()|[\]\\]/g, '\\$&'); }
|
|
36
|
+
return new RegExp(`^${s}$`);
|
|
37
|
+
}
|
|
38
|
+
const matchAny = (globs, f) => globs.some((g) => globToRe(g).test(f) || (!String(g).includes('/') && globToRe(g).test(f.split('/').pop())));
|
|
39
|
+
function walk(dir, base = dir, out = []) {
|
|
40
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) { if (e.name === 'node_modules' || e.name === '.git') continue; const p = join(dir, e.name); if (e.isDirectory()) walk(p, base, out); else if (e.isFile()) out.push(relative(base, p).split(sep).join('/')); }
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
/** Strip content hashes so main.3f2a9c1b.js / main-BdT9x2Qa.js compare to main.js across builds. */
|
|
44
|
+
export function stableName(f) {
|
|
45
|
+
return f.replace(/([.-])(?=[A-Za-z0-9_]*\d)(?=[A-Za-z0-9_]*[a-zA-Z])[A-Za-z0-9_]{8,}(?=\.(?:m?js|cjs|css)$)/, '').replace(/([.-])[a-f0-9]{6,}(?=\.)/i, '').replace(/[.-](?=\.)/, '');
|
|
46
|
+
}
|
|
47
|
+
export function measure(buf, { brotli = true } = {}) {
|
|
48
|
+
return { raw: buf.length, gzip: gzipSync(buf, { level: 9 }).length, brotli: brotli ? brotliCompressSync(buf, { params: { [Z.BROTLI_PARAM_QUALITY]: 11, [Z.BROTLI_PARAM_SIZE_HINT]: buf.length } }).length : undefined };
|
|
49
|
+
}
|
|
50
|
+
function looksUnminified(text) {
|
|
51
|
+
if (text.length < 20000) return false;
|
|
52
|
+
const lines = text.split('\n'); const avg = text.length / lines.length; const indented = lines.filter((l) => /^\s{2,}\S/.test(l)).length / lines.length;
|
|
53
|
+
const comments = (text.match(/\/\*[\s\S]*?\*\/|^\s*\/\/.*$/gm) || []).reduce((a, c) => a + c.length, 0) / text.length;
|
|
54
|
+
return (avg < 60 && indented > 0.3) || comments > 0.15;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function scan(dir, { include = ['**/*.{js,mjs,cjs,css}'], exclude = [] } = {}) {
|
|
58
|
+
if (!existsSync(dir) || !statSync(dir).isDirectory()) throw new UsageError(`No such directory: ${dir}`);
|
|
59
|
+
return walk(dir).filter((f) => matchAny(include, f) && !/\.map$/.test(f) && !matchAny(exclude, f)).sort().map((file) => {
|
|
60
|
+
const buf = readFileSync(join(dir, file)); const kind = /\.css$/.test(file) ? 'css' : 'js';
|
|
61
|
+
return { file, name: stableName(file), kind, ...measure(buf), sha: createHash('sha256').update(buf).digest('hex').slice(0, 16), unminified: looksUnminified(buf.toString('utf8')) };
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
/** Budgets: [{ path, raw?, gzip?, brotli? }] | { "glob": "50 KB" (gzip) | { gzip, brotli, raw } }; CLI "glob=50KB" or "glob=brotli:40KB". */
|
|
65
|
+
export function normaliseBudgets(b = [], cli = []) {
|
|
66
|
+
const out = [];
|
|
67
|
+
const push = (path, spec, src) => { const s = typeof spec === 'object' && spec ? spec : { gzip: spec }; const e = { path, src }; for (const k of ['raw', 'gzip', 'brotli']) if (s[k] != null) { const v = parseSize(s[k]); if (v == null) throw new UsageError(`Invalid ${k} size "${s[k]}" for budget ${path}`); e[k] = v; } if (!e.raw && !e.gzip && !e.brotli) throw new UsageError(`Budget ${path} has no size`); out.push(e); };
|
|
68
|
+
if (Array.isArray(b)) b.forEach((x, i) => { if (!x?.path) throw new UsageError(`budgets[${i}] needs "path"`); push(x.path, x, 'config'); }); else for (const [k, v] of Object.entries(b || {})) push(k, v, 'config');
|
|
69
|
+
for (const c of cli) { const m = /^(.+?)=(?:(raw|gzip|brotli):)?(.+)$/.exec(c); if (!m) throw new UsageError(`--budget must be glob=size, got "${c}"`); push(m[1], { [m[2] || 'gzip']: m[3] }, 'cli'); }
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function analyze(files, opts = {}) {
|
|
74
|
+
const raw = []; const add = (rule, subject, message, fix, file) => raw.push({ rule, severity: RULES[rule].severity, subject, message, fix, file });
|
|
75
|
+
const warn = (opts.warnAt ?? 90) / 100; const maxGrowth = opts.maxGrowth ?? 10; const minDelta = parseSize(opts.minDelta ?? '1 KB'); const large = parseSize(opts.largeFile ?? '100 KB');
|
|
76
|
+
const budgets = opts.budgets || [];
|
|
77
|
+
const budgeted = new Set();
|
|
78
|
+
for (const b of budgets) {
|
|
79
|
+
const hits = files.filter((f) => matchAny([b.path], f.file));
|
|
80
|
+
if (!hits.length) { add('bundle-budget-unmatched', b.path, `Budget "${b.path}" matches no file`, 'Update the glob to the current output name, or remove the budget.'); continue; }
|
|
81
|
+
for (const f of hits) {
|
|
82
|
+
budgeted.add(f.file);
|
|
83
|
+
for (const k of ['brotli', 'gzip', 'raw']) { if (b[k] == null || f[k] == null) continue;
|
|
84
|
+
if (f[k] > b[k]) add('bundle-over-budget', f.file, `${f.file} is ${fmtSize(f[k])} ${k}, over its ${fmtSize(b[k])} budget by ${fmtSize(f[k] - b[k])} (${Math.round((f[k] / b[k] - 1) * 100)}%)`, 'Split the entry (dynamic import), drop heavy dependencies, or raise the budget deliberately.', f.file);
|
|
85
|
+
else if (f[k] > b[k] * warn) add('bundle-near-budget', f.file, `${f.file} is ${fmtSize(f[k])} ${k}, ${Math.round((f[k] / b[k]) * 100)}% of its ${fmtSize(b[k])} budget`, 'Keep an eye on this entry.', f.file); }
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
for (const [kind, key] of [['js', 'totalJs'], ['css', 'totalCss']]) { const lim = opts[key]; if (!lim) continue; const t = files.filter((f) => f.kind === kind).reduce((a, f) => a + f.gzip, 0); if (t > lim) add('bundle-total-over-budget', `total-${kind}`, `Total ${kind.toUpperCase()} is ${fmtSize(t)} gzip, over the ${fmtSize(lim)} budget`, 'Remove unused code or split by route.'); }
|
|
89
|
+
if (opts.base) {
|
|
90
|
+
const base = new Map((opts.base.files || []).map((f) => [f.name || stableName(f.file), f]));
|
|
91
|
+
for (const f of files) { const b = base.get(f.name);
|
|
92
|
+
if (!b) { if (f.gzip >= parseSize(opts.newLarge ?? '50 KB')) add('bundle-new-large', f.file, `New file ${f.file} is ${fmtSize(f.gzip)} gzip`, 'Check this new chunk is intended.', f.file); continue; }
|
|
93
|
+
const d = f.gzip - b.gzip; const pct = b.gzip ? (d / b.gzip) * 100 : 0;
|
|
94
|
+
if (d >= minDelta && pct > maxGrowth) add('bundle-growth', f.file, `${f.file} grew ${fmtSize(d)} gzip (+${pct.toFixed(1)}%, ${fmtSize(b.gzip)} → ${fmtSize(f.gzip)})`, 'Find the new dependency or code (e.g. source-map-explorer) or accept the growth by updating the base snapshot.', f.file); }
|
|
95
|
+
}
|
|
96
|
+
for (const f of files) {
|
|
97
|
+
if (f.unminified) add('bundle-unminified', f.file, `${f.file} (${fmtSize(f.raw)}) looks unminified`, 'Enable minification for production builds.', f.file);
|
|
98
|
+
if (!budgeted.has(f.file) && f.gzip >= large && budgets.length) add('bundle-no-budget', f.file, `${f.file} is ${fmtSize(f.gzip)} gzip with no budget`, 'Add a budget for it.', f.file);
|
|
99
|
+
}
|
|
100
|
+
const bySha = new Map(); for (const f of files) (bySha.get(f.sha) || bySha.set(f.sha, []).get(f.sha)).push(f);
|
|
101
|
+
for (const g of bySha.values()) if (g.length > 1 && g[0].raw > 1000) add('bundle-duplicate', g[0].sha, `Same content shipped as ${g.map((f) => f.file).join(', ')} (${fmtSize(g[0].gzip)} gzip each)`, 'Deduplicate the asset or the build entry.', g[1].file);
|
|
102
|
+
return raw;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function snapshot(files, meta = {}) { return { schema: 'bundle-pulse/1', generatedAt: new Date().toISOString(), ...meta, files: files.map(({ unminified, ...f }) => f) }; }
|
|
106
|
+
|
|
107
|
+
export function check(dir, opts = {}, config = {}, configFile) {
|
|
108
|
+
const tool = { name: 'bundle-pulse', version: opts.toolVersion || '1.0.0', rules: RULES, emoji: '📦', defaultFile: '.' };
|
|
109
|
+
const files = scan(dir, { include: opts.include?.length ? opts.include : config.include || undefined, exclude: [...(config.exclude || []), ...(opts.exclude || [])] });
|
|
110
|
+
let base = null;
|
|
111
|
+
const bf = opts.base ?? config.base;
|
|
112
|
+
if (bf) { const p = resolve(bf); if (!existsSync(p)) { if (!opts.baseOptional) throw new UsageError(`Base snapshot not found: ${bf}`); } else { try { base = JSON.parse(readFileSync(p, 'utf8')); } catch (e) { throw new UsageError(`Invalid base snapshot ${bf}: ${e.message}`); } } }
|
|
113
|
+
const o = { budgets: normaliseBudgets(config.budgets, opts.budget), totalJs: parseSize(opts.totalJs ?? config.totalJs ?? 0) || 0, totalCss: parseSize(opts.totalCss ?? config.totalCss ?? 0) || 0, maxGrowth: opts.maxGrowth ?? config.maxGrowth, warnAt: config.warnAt, minDelta: config.minDelta, largeFile: config.largeFile, newLarge: config.newLarge, base };
|
|
114
|
+
const raw = analyze(files, o);
|
|
115
|
+
if (opts.snapshot) writeFileSync(opts.snapshot, JSON.stringify(snapshot(files), null, 2) + '\n');
|
|
116
|
+
const sum = (k, kind) => files.filter((f) => !kind || f.kind === kind).reduce((a, f) => a + (f[k] || 0), 0);
|
|
117
|
+
const rel = relative(process.cwd(), dir) || '.';
|
|
118
|
+
const delta = base ? sum('gzip') - (base.files || []).reduce((a, f) => a + f.gzip, 0) : null;
|
|
119
|
+
return buildReport(tool, rel, raw.map((f) => ({ ...f, file: f.file ? join(rel, f.file).split(sep).join('/') : undefined })), config, { now: opts.now, configFile,
|
|
120
|
+
summaryLine: `${files.length} file(s): JS ${fmtSize(sum('gzip', 'js'))} gzip, CSS ${fmtSize(sum('gzip', 'css'))} gzip${delta != null ? `; ${delta >= 0 ? '+' : ''}${fmtSize(delta)} vs base` : ''}`,
|
|
121
|
+
info: { files: files.map(({ file, kind, raw: r, gzip, brotli }) => ({ file, kind, raw: r, gzip, brotli })), totals: { raw: sum('raw'), gzip: sum('gzip'), brotli: sum('brotli') }, deltaGzip: delta } });
|
|
122
|
+
}
|