Prove a bundled guard still blocks, and settle sideEffects with a measurement - #192
Merged
Conversation
|
Comprehensive CI enhancements with targeted tests for bundling and side-effect auditing. 🎯 Quality: 90% Elite · 📦 Size: Large — consider splitting if possible 📈 This month: Your 150th PR — above team average · Averaging Excellent |
…surement Most consumers of the runtime guard are bundled — a Worker through wrangler, a Next edge middleware, a SvelteKit adapter build — and all of them tree-shake. Nothing covered that path. A guard that has lost the part which screens requests still starts, still logs and still looks installed, and the failure would arrive in the consumer's build rather than ours. `npm run test:bundled` bundles a real edge guard with esbuild through the `workerd` export condition, minifies it, and puts the published CVE-2017-5941 exploit through the bundled output in a separate process: exploit blocked, benign control allowed, route scope honoured. Per-shape size ceilings catch the other regression that matters, a top-level side effect or heavy static import added to the root entry. That test answers the tree-shaking question with numbers instead of a guess. `sideEffects: false` changes both consumer shapes by zero bytes, so it is NOT declared: it would be a permanent promise to every bundler that no module needs evaluating, bought for nothing. `npm run audit:side-effects` parses the published bundles for top-level statements that are not pure declarations, so the decision stays reviewable rather than becoming folklore. `module` is kept. Removing it also changes nothing, but only because esbuild resolves through `exports` — that is not evidence about the older bundlers the field exists for. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…xt in maps The audit recognised shapes it believed were safe, which is the wrong direction to be wrong in: `import './register.js'`, `export default init()` and `class C extends init()` all execute at module evaluation and all three were reported clean, as were object spreads, template interpolations and computed member names. It now walks the region of the AST that runs at import time — pruning function and method bodies and instance field initialisers — and reports anything that can execute, rather than matching a list of permitted forms. A class is the case worth naming: method bodies do not run, but the heritage clause, computed member names, static field initialisers and static blocks do. One exemption is provable rather than assumed: `Object.freeze(<literal>)` over an argument constructed in place cannot be observed anywhere else. `Object.freeze(somethingImported)` is still reported. `--selftest` pins 23 cases, and CI runs it before the audit, because a tripwire that cannot fire is the same defect as no tripwire. The prose no longer calls it proof; the bundled-consumer test is the evidence. Source maps drop `sourcesContent`: 975 kB packed becomes 434 kB, and 3.55 MB unpacked becomes 1.77 MB. A frame still resolves to file and line, and the source text it duplicated is public in this repository at the tag matching the version the CLI now reports. Two build steps produce maps independently, so `tests/source-maps.test.ts` asserts over the artifacts rather than the config: every artifact has a map, every artifact points at it, every map has real mappings and sources, no map embeds source text, and a real thrown frame resolves to the statement that threw. Adds a single `Required CI gate` job for branch protection to require, so the gate does not name matrix labels that change with every version bump. It uses `if: always()` — a skipped required check reads as success — and refuses to pass while standing on fewer jobs than it is meant to require. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The audit missed four kinds of import-time execution: a top-level `throw`, a built-in consuming a value from elsewhere (`new Set(imported)` walks its iterator), a spread of such a value (which runs its getters), and imports with bindings — which evaluate the target module whatever this script can see. It also audited three named entries out of eight emitted files, leaving the chunks, the bin and both CommonJS bundles unexamined. All four are now recognised and every emitted artifact is covered. Findings carry a severity so a bound import is neither treated as a finding nor declared safe: it evaluates another module, and what that does is invisible here. Those are always printed, never hidden behind louder findings, because they are the part that is unknown. Local constants are resolved, one pass in source order, so `new Set(PHASES)` over a frozen array declared above it is not reported. Unresolved names stay foreign. Without this the report carried 52 findings, almost all false, and a report nobody reads is not a tripwire either. The exit code no longer speaks for what is found. Every stricter version was wrong: a CommonJS bundle executes at module scope by construction, `dist/cli.js` ends in `main().then(…)` because a bin is supposed to run, and a benign lazy-init call in a library entry is indistinguishable here from a harmful one. It fails only on a missing file or a failing self-test. The self-test is the gating half and now pins 31 cases across three outcomes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The npm script piped a directory listing through `xargs`, which an ordinary Windows shell does not have — so a documented contributor command worked on Linux CI and failed on a Windows machine. The script now finds every emitted `.js` and `.cjs` under `dist/` itself when given no file arguments, and still accepts explicit paths to narrow it. A missing `dist/` says to build rather than reporting on nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Discovery listed `dist/` without descending, so it found eight of fourteen files and reported on them as though that were the set. The six it missed are the scaffolder's guard templates in `dist/protect/templates/`, which are the files copied into a consumer's application and executed there. An artifact absent from the report is indistinguishable from one that reported clean, so a partial listing produces a report that looks complete. That is the defect this script exists to find, one level up. Discovery is now recursive, and zero artifacts is an error rather than a clean report: an empty or half-written build directory would otherwise print a summary saying nothing executes anywhere, which reads exactly like a pass. `tests/side-effect-audit-discovery.test.ts` drives the script against temporary directories it constructs — a nested artifact, no artifacts, a directory that does not exist — and checks the real report against an independent walk of `dist/`, so a new entry point cannot leave the audit behind. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comments in the audit, its discovery tests and the contributor notes explained several decisions by describing what other arrangements did. The reasoning a reader needs is the invariant and why it holds; the rest belongs in private review notes under the public-repository policy. No behaviour change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
patchstackdave
force-pushed
the
feat/tree-shaking-and-size
branch
from
August 28, 2026 12:04
6657e9e to
f2e1796
Compare
Contributor
Author
|
/review |
daniloradovic
approved these changes
Aug 28, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two questions about how this package behaves once a consumer builds it, and the answer to a third that had not been asked.
Bundled consumers
Most consumers of the runtime guard are bundled — a Worker through wrangler, a Next edge middleware, a SvelteKit adapter build — and all of them tree-shake. A guard that has lost the part which screens requests still starts, still logs and still looks installed, and the failure arrives in the consumer's build rather than here.
npm run test:bundledbundles a real edge guard with esbuild through theworkerdexport condition, minifies it, and puts the published CVE-2017-5941 exploit through the bundled output in a separate process: exploit blocked with 403, benign control allowed, route scope honoured. Per-shape size ceilings catch the other regression that matters — a top-level side effect or a heavy static import added to the root entry, which would put the scanner into every consumer's bundle.sideEffects: falseis not declaredIt changes both consumer shapes by zero bytes; the root entry already shakes to 0.8 kB for a single-symbol import. The field would buy nothing while standing as a permanent promise to every bundler that no module here ever needs evaluating — and the day someone adds a top-level registration, consumers lose it silently in their own build.
npm run audit:side-effectssupports that decision without settling it. It is a report, not a check: it names what every emitted artifact executes at import time and exits zero on what it finds. There is no honest threshold to fail on — a CommonJS bundle executes at module scope by construction,dist/cli.jsends inmain().then(…)because a bin is supposed to run, and a benign lazy-init call in a library entry is indistinguishable from a harmful one given only syntax. What it cannot see is what a called function does.Two things keep it from being decorative.
--selftestis the gating half and CI runs it first: 31 cases across three outcomes, because an import with bindings is neither a finding nor safe — it evaluates another module, and that evaluation is exactly what is invisible. And discovery is recursive overdist/, covering all 14 emitted.js/.cjsfiles including the scaffolder's guard templates underdist/protect/templates/, which are the files copied into a consumer's application and executed there. Zero artifacts is an error rather than a clean report, since a summary saying nothing executes anywhere reads exactly like a pass.tests/side-effect-audit-discovery.test.tsdrives all of that against temporary directories and checks the real report against an independent walk ofdist/.Local constants are resolved so
new Set(PHASES)over a frozen array declared above it is not reported, while unresolved names stay foreign.moduleis keptRemoving it also changes nothing, but only because esbuild resolves through
exports— which is whatexportsis for. Webpack 4 and older rollup setups predate it and fall back tomodule. The measurement cannot see those, so a zero delta is not evidence that dropping it is safe, and the script prints that caveat rather than letting the number speak for itself.Source maps: published, without the source text
975 kB → 434 kB packed; 3.55 MB → 1.77 MB unpacked. Maps were 68% of the package.
A map without
sourcesContentstill resolves a stack frame to the original file and line, which is what a support conversation needs. The source text it embedded is public in this repository at the tag matching the version the CLI reports.Two build steps produce maps independently —
tsup.config.tsfor three artifacts andscripts/build-edge.mjsfor the edge bundle — sotests/source-maps.test.tsasserts over the artifacts rather than the configuration: every emitted JavaScript file has a map, every file points at its map, every map has real mappings and sources, no map embeds source text, and a real thrown frame resolves to the statement that threw. That last one runsresolveConfigwith a malformed site UUID under--enable-source-mapsand reads the resolved line back out ofsrc/config.ts, so a map whose mappings are present but wrong fails it.One required status
Requiring the jobs directly means branch protection names matrix labels —
Consumers on npm 11.6.0,Validate on Node 24.x— which change with every version bump, and a required context whose name no longer exists is never satisfied rather than reported missing.Required CI gatedepends on the capability contract, the whole consumer matrix, the bundled consumer, the Windows smoke test, the whole Node matrix and the production audit. It usesif: always(), because a skipped required check reads as success to branch protection and the gate would then pass precisely when something was broken. It refuses to pass while standing on fewer jobs than it is meant to require.After merging, branch protection needs updating: replace
Production dependency auditand the threeValidate on Node …contexts withRequired CI gate.1721 tests, typecheck, build, the audit self-test, the bundled proof and all 7 consumer shapes pass.