Skip to content

Prove a bundled guard still blocks, and settle sideEffects with a measurement - #192

Merged
patchstackdave merged 6 commits into
mainfrom
feat/tree-shaking-and-size
Aug 28, 2026
Merged

Prove a bundled guard still blocks, and settle sideEffects with a measurement#192
patchstackdave merged 6 commits into
mainfrom
feat/tree-shaking-and-size

Conversation

@patchstackdave

@patchstackdave patchstackdave commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

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: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 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.

edge guard (workerd condition, runs the canary)
  ok    as published            97.3 kB   exploit=403 benign=allowed other-route=allowed
  ok    sideEffects: false      97.3 kB   exploit=403 benign=allowed other-route=allowed
  ok    no `module` field       97.3 kB   exploit=403 benign=allowed other-route=allowed

one symbol from the root entry
  ok    as published             0.8 kB

sideEffects: false is not declared

It 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-effects supports 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.js ends in main().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. --selftest is 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 over dist/, covering all 14 emitted .js/.cjs files including the scaffolder's guard templates under dist/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.ts drives all of that against temporary directories and checks the real report against an independent walk of dist/.

Local constants are resolved so new Set(PHASES) over a frozen array declared above it is not reported, while unresolved names stay foreign.

module is kept

Removing it also changes nothing, but only because esbuild resolves through exports — which is what exports is for. Webpack 4 and older rollup setups predate it and fall back to module. 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 sourcesContent still 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.ts for three artifacts and scripts/build-edge.mjs for the edge bundle — so tests/source-maps.test.ts asserts 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 runs resolveConfig with a malformed site UUID under --enable-source-maps and reads the resolved line back out of src/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 gate depends on the capability contract, the whole consumer matrix, the bundled consumer, the Windows smoke test, the whole Node matrix and the production audit. It uses if: 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 audit and the three Validate on Node … contexts with Required CI gate.

1721 tests, typecheck, build, the audit self-test, the bundled proof and all 7 consumer shapes pass.

@coderbuds

coderbuds Bot commented Aug 28, 2026

Copy link
Copy Markdown

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

See how your team is trending →

patchstackdave and others added 6 commits August 28, 2026 14:04
…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
patchstackdave force-pushed the feat/tree-shaking-and-size branch from 6657e9e to f2e1796 Compare August 28, 2026 12:04
@patchstackdave

Copy link
Copy Markdown
Contributor Author

/review

@patchstackdave
patchstackdave merged commit d5d4b08 into main Aug 28, 2026
14 checks passed
@patchstackdave
patchstackdave deleted the feat/tree-shaking-and-size branch August 28, 2026 12:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

2 participants