Skip to content

fix(repo): the size gate's externals match what the build actually emits (#709) - #712

Open
rejifald wants to merge 1 commit into
mainfrom
fix/size-gate-externals
Open

fix(repo): the size gate's externals match what the build actually emits (#709)#712
rejifald wants to merge 1 commit into
mainfrom
fix/size-gate-externals

Conversation

@rejifald

@rejifald rejifald commented Aug 7, 2026

Copy link
Copy Markdown
Owner

The pattern was inert

packages/core/scripts/bundle-size.mjs re-bundled every scenario with
external: ['node:*']. That pattern matches nothing in the package's own build
artifacts.

The source is correct — src/registry.ts:7-9 imports node:fs / node:path /
node:url — but tsup strips the prefix. Across the 49 shipped JS files in
lib/, zero static import or require specifiers carry a node: prefix. The
builtins appear bare:

from"fs"   from"path"   from"url"   from"http"
require("fs")   require("fs/promises")   require("path")   require("url")   require("http")

The only surviving node: strings in the whole of lib/ are the 9 occurrences
inside globalThis.process?.getBuiltinModule?.("node:fs") — a runtime lookup no
bundler resolves.

The gate passes today only because its three measured entries need no builtin, so
the inert pattern has never been exercised.

The resolve failure, reproduced

Bundling stitchapi/registry (which reaches lib/chunk-63GGCWT7.mjs, the file
carrying from"fs" / from"path" / from"url") with the gate's own settings:

[gate's OLD pattern] external=["node:*"]
  FAILED
    ERROR: Could not resolve "fs"
    ERROR: Could not resolve "path"
    ERROR: Could not resolve "url"

[shipped fix] external=["node:*","fs","fs/promises","path","url","http"]
  SUCCESS — 5593 B

Reproduced on this branch against its own freshly built lib/, twice, the second
time in an isolated directory to rule out cross-contamination.

The shadowing consequence — and it is the worse one

Verified first-hand, not taken from the issue. A copy of the real lib/, with
trivial packages named fs, path and url planted in a node_modules beside
it, each exporting a marked payload:

[gate's OLD pattern] external=["node:*"]
  BUILD: SUCCESS — 6503 B
  SHADOW INLINED INTO ARTIFACT: YES — measurement is corrupted

[shipped fix] external=["node:*","fs","fs/promises","path","url","http"]
  BUILD: SUCCESS — 5593 B
  SHADOW INLINED INTO ARTIFACT: no

Note the failure mode. Without a shadow present the old pattern at least errors.
With one present it does not fail at all — it silently succeeds and reports a
number that includes 910 B of somebody else's package. A budget gate that measures
the wrong bytes and passes is worse than one that breaks. A node:-prefixed
specifier cannot be shadowed this way.

(The marker has to be a string literal, not an identifier — an identifier is
renamed by minify: true and the probe reports a false negative. It did, on the
first run, before the payload was moved into a literal.)

What changed

One list, plus a comment that now says what it means:

external: ['node:*', 'fs', 'fs/promises', 'path', 'url', 'http'],

The prefixed forms stay — they cost nothing and keep the gate working if the build
is ever changed to preserve node:. A builtin the list misses fails loudly
(Could not resolve) rather than silently measuring the wrong bytes.

No number moves. --json output is byte-identical before and after:

scenario gzip budget status
stitchapi — whole entry 24.09 KB 24.10 KB ✓ 0.01 KB left
import { stitch } 21.51 KB 21.55 KB ✓ 0.04 KB left
stitchapi/auth — whole surface 5.18 KB 5.35 KB ✓ 0.17 KB left

Verified end-to-end with a temporary registry scenario added to the real edited
script: it measures 5.46 KB where it previously could not build at all. (Temporary
— not committed.)

Scope

§1 of the issue (the missing splitting: true) is deliberately NOT in this PR.
It is not a one-liner: measure() reads result.outputFiles[0] and assumes a
single output, so it needs an outdir plus chunk summing — and it moves an
advertised figure
that the bundle-advertised-size tether cross-checks against
both READMEs, the installation and principles pages, the home-page metrics
component, and the docs' source blurb. That is a publishing decision for the
maintainer, so Refs, not Fixes.

Recommended follow-up: change the build so tsup preserves the node: prefix
through to lib/. That is the better fix for the shadowing risk — it makes the
specifiers unshadowable at the source rather than externalising the bare names —
but it has its own blast radius (it changes shipped artifacts, not just the gate),
so it belongs in its own PR.

Overlap with #674 — measured, and my hunk is not in it

Open PR #674 (claude/frosty-goodall-5de390) also edits this file. I ran
git merge-tree between the two branches rather than guess. bundle-size.mjs is
the only conflicted file, with exactly two hunks:

  1. the long budget comment block above SCENARIOS, and
  2. budget: 21.55 * KB (here, from fix(core): a lone failing cached call no longer kills the process (#670) #676) vs budget: 21.7 * KB (fix(core): an abort surfaces the caller's reason; a cancel is never a retry #674).

measure() merges cleanly — the external list this PR changes comes through
intact, with no markers. So neither conflict hunk is this PR's change: both are
#674 against current main, and they exist whether or not this lands. #674 was
cut before #676 moved that budget 21.50 → 21.55, which is why it still reads 21.50
as its base.

Nothing here for me to resolve, and nothing for a merger to trade off against this
PR. #674 needs a rebase and a re-measure against current main on its own merits;
this PR is orthogonal to it.

Gates

All green locally, plus the full pre-push suite and CI.

pnpm exec prettier --write packages/core/scripts/bundle-size.mjs CHANGELOG.md  # unchanged
pnpm --filter stitchapi build                                                  # ✓
pnpm --filter stitchapi check:size                                             # ✓ numbers unmoved
node scripts/check-changelog.mjs                                               # ✓

CI: verify, size, drift, coverage, e2e, mcp-e2e, sandbox,
search-relevance all green. drift passing is the meaningful one here — it is the
tether that cross-checks the advertised sizes, and it confirms nothing moved.

CHANGELOG entry went under the existing ### Notes (not ### Fixed) — this is repo
tooling that ships no bytes to consumers, and Notes already carries the
gate/lint-level entries. No new ### heading.

Refs #709

🤖 Generated with Claude Code

…its (#709)

`check:size` re-bundles each scenario with `external: ['node:*']`. That
pattern matches nothing in the package's own artifacts. The source is
correct — `src/registry.ts` imports `node:fs`/`node:path`/`node:url` —
but tsup strips the prefix, so `lib/` emits `from"fs"`, `from"path"`,
`require("fs/promises")`. Across the 49 shipped JS files, zero static
import or require specifiers carry a `node:` prefix; the only surviving
`node:` strings sit inside `process?.getBuiltinModule?.("node:fs")`, a
runtime lookup no bundler resolves.

The gate passes today only because its three measured entries reach for
no builtin at all, so the inert pattern has never been exercised. Point
it at an entry that does and it fails outright: bundling
`stitchapi/registry` under the gate's own externals reports
`Could not resolve "fs"`, `"path"` and `"url"`. Under the bare
specifiers it succeeds at 5593 B.

The shadowing risk is real rather than theoretical. With trivial packages
named `fs`, `path` and `url` planted in a `node_modules` beside a copy of
`lib/`, a `platform: 'neutral'` build with `external: ['node:*']`
succeeded and inlined the shadow package's body into the artifact — a
measurement of something other than the library. A `node:`-prefixed
specifier cannot be shadowed that way.

So the list gains the bare specifiers the build actually emits. The
prefixed forms stay: they cost nothing and keep this working if the build
is ever changed to preserve `node:`, which is the better fix for the
shadowing hazard and is left as a separate change with its own blast
radius. A builtin the list misses still fails loudly rather than
measuring the wrong bytes.

No measured number moves — `--json` output is byte-identical before and
after, so the `bundle-advertised-size` tether and the READMEs are
untouched. Verified end-to-end by adding a temporary `registry` scenario:
it measures 5.46 KB where it previously could not build at all.

`Refs`, not `Closes`: this is §2 of the issue only. §1 — the gate's
missing `splitting: true` — stays open. It is not a one-liner, because
`measure()` reads `result.outputFiles[0]` and assumes a single output, so
it needs an `outdir` plus chunk summing, and it moves an advertised
figure the tether cross-checks against the READMEs and docs. That is a
publishing decision, not a bug fix.

Refs #709

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant