fix(guard): derive must-not-bundle candidates independently of external

Addresses the suppressed review comment on #2099, which was the sharpest of the
three rounds: the guard stopped looking at a package the moment its `external`
entry was removed — so deleting `"undici"` from the web config would have made
the pre-fix bundle pass. That is self-referential, and #2067 was precisely a
MISSING declaration, so the guard covered its regression but not the bug.

Candidates are now the union of two independently-derived lists: each client's
`external` array, and the root manifest's `dependencies` — root-declared by
rule, reached through `core/`, and required to be external in every client
bundle. Removing a package from an `external` list can no longer remove it from
scrutiny.

That immediately surfaced one latent instance of the same rule violation:
`@modelcontextprotocol/ext-apps` is root-declared and reached through
`core/mcp/apps.ts`, but was absent from the CLI's `external` list and was being
inlined into `index.js` — dragging the transitive v1 SDK and zod-to-json-schema
in with it. It is ESM, so it was not failing the way `undici` did; what it
violated was the rule. Adding it drops the CLI bundle from 590KB to 518KB.

It is also a real example of the shape the previous revision could not see: it
was inlined into the entry, emitting no `<pkg>-HASH.js` chunk at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JTHVxSu8AUgHRLvo1ntZ8H
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-23 22:02:54 -04:00
commit dbe95e58ed
2 changed files with 87 additions and 10 deletions

View file

@ -136,14 +136,32 @@ export function findInlinedExternals({ externals, files }) {
/**
* The per-client verdict, as pure data so every branch is unit-testable.
*
* Note it **fails closed on an empty `external` list**. `parseExternals` keys
* off the literal text `external: [`, so an ordinary tsup refactor — hoisting
* the array to a constant, or a line break after the colon — would make it
* return nothing, and a guard that checks zero packages reports success. Every
* enrolled bundle has externals today, so "none parsed" means the parser lost
* track of the config, not that the invariant holds.
* The candidate set is the **union of two independently-derived lists**, and the
* second is what makes this guard cover #2067 rather than merely its regression:
*
* - `externals` — what the client's tsup config declares. Catches a package that
* was meant to stay out and got pulled in anyway.
* - `rootDependencies` — the root manifest's runtime dependencies. These are
* root-declared *by rule* (see AGENTS.md), reached through `core/`, and must be
* external in every client bundle. Deriving them independently is essential,
* because #2067 was a **missing** declaration: `undici` was absent from the web
* and TUI `external` lists, so a check driven only by those lists would have
* looked at the inlined 1.05MB bundle and reported success. Removing a package
* from an `external` list must not be able to remove it from scrutiny.
*
* It also **fails closed on an empty `external` list**. `parseExternals` keys off
* the literal text `external: [`, so an ordinary tsup refactor — hoisting the
* array to a constant, or a line break after the colon — would make it return
* nothing. Every enrolled bundle has externals today, so "none parsed" means the
* parser lost track of the config, not that the invariant holds.
*/
export function evaluateClient({ name, build, externals, files }) {
export function evaluateClient({
name,
build,
externals,
rootDependencies = [],
files,
}) {
if (externals.length === 0) {
return [
`${name}: no \`external\` entries could be parsed from its tsup config. ` +
@ -153,7 +171,11 @@ export function evaluateClient({ name, build, externals, files }) {
];
}
const { noBanners, violations } = findInlinedExternals({ externals, files });
const candidates = [...new Set([...externals, ...rootDependencies])];
const { noBanners, violations } = findInlinedExternals({
externals: candidates,
files,
});
if (noBanners) {
return [
`${name}: ${build} has no esbuild module banners, so this guard cannot see ` +
@ -169,6 +191,10 @@ export function evaluateClient({ name, build, externals, files }) {
function main() {
const failures = [];
const rootDependencies = Object.keys(
JSON.parse(readFileSync(join(repoRoot, "package.json"), "utf8"))
.dependencies ?? {},
);
for (const client of BUNDLED_CLIENTS) {
const buildDir = join(repoRoot, client.build);
const entry = join(buildDir, "index.js");
@ -192,6 +218,7 @@ function main() {
name: client.name,
build: client.build,
externals,
rootDependencies,
files,
}),
);

View file

@ -205,12 +205,56 @@ describe("evaluateClient", () => {
);
});
test("flags a root dependency even when the external list omits it", () => {
// THE #2067 SHAPE, and the reason the candidate set is not just `externals`.
// `undici` was missing from the web and TUI external lists — that omission
// WAS the bug — so a guard driven only by those lists would have inspected
// the inlined 1.05MB bundle and reported success. Removing a package from an
// `external` list must not remove it from scrutiny.
const failures = evaluateClient({
name: "web",
build: "clients/web/build",
externals: ["pino"],
rootDependencies: ["undici", "pino"],
files: [
{
name: "undici-HXPKCIY3.js",
source: banner("../../node_modules/undici/lib/core/symbols.js"),
},
],
});
assert.equal(failures.length, 1);
assert.match(failures[0], /"undici" is declared external but was inlined/);
});
test("does not flag a deliberately inlined package that is not a root dependency", () => {
// The TUI inlines `ink-form` / `ink-scroll-view` on purpose (#1952). They are
// declared in the TUI's own manifest, never at the root, which is exactly
// what keeps them out of the candidate set.
assert.deepEqual(
evaluateClient({
name: "tui",
build: "clients/tui/build",
externals: ["react", "ink"],
rootDependencies: ["react"],
files: [
{
name: "index.js",
source: banner("../../node_modules/ink-form/lib/Form.js"),
},
],
}),
[],
);
});
test("passes a clean bundle", () => {
assert.deepEqual(
evaluateClient({
name: "web",
build: "clients/web/build",
externals: ["undici"],
rootDependencies: ["undici"],
files: clean,
}),
[],