Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 44 additions & 1 deletion scripts/check-serve-fast-path-bundle.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import { fileURLToPath } from 'node:url';
const DEFAULT_METAFILE_PATH = resolve('dist/esbuild.json');
const METAFILE_BUILD_COMMAND =
'node scripts/clean-package-build-artifacts.js && npm run build -- --cli-only && cross-env DEV=true npm run bundle';
const ENTRY_OUTPUT = 'dist/cli.js';
const ENTRY_INPUT = 'packages/cli/src/cli.ts';
const SERVE_PRE_LISTEN_ROOTS = [
{
label: 'serve fast path entry',
Expand Down Expand Up @@ -506,6 +508,32 @@ export function checkSdkImplProtocolBoundary({
return { ok: offenders.length === 0, offenders };
}

/**
* `cli.ts` bootstraps only when it is the main module, comparing
* `import.meta.url` against `process.argv[1]`. The bundle is built with
* `splitting: true`, so a *static* `import ... from './cli.js'` in any module
* the entry loads lazily (e.g. `gemini.tsx`) makes esbuild move the entry's
* body into a shared chunk and leave `dist/cli.js` as a re-export stub. Inside
* a chunk that comparison can never hold, so the bundled CLI exits 0 without
* running anything — with `tsc`, eslint and every src-based unit test still
* green. Assert the entry module still compiles into the entry output.
*/
export function checkEntryBootstrapIntact({
metafilePath = DEFAULT_METAFILE_PATH,
} = {}) {
const metafile = readMetafile(metafilePath);
const output = metafile?.outputs?.[ENTRY_OUTPUT];
if (!output) {
throw new Error(
`Missing ${ENTRY_OUTPUT} in the esbuild metafile at ${metafilePath}. ` +
`Run \`${METAFILE_BUILD_COMMAND}\` to regenerate it.`,
);
}

const inputs = Object.keys(output.inputs ?? {});
return { ok: inputs.includes(ENTRY_INPUT), inputs };

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One thing I noticed here: this accepts the entry input on key presence alone. Your empirical check showed the hoisted stub reports inputs: [] on the current esbuild, so this is correct today — but if a future esbuild version ever lists the entry in the stub output with bytesInOutput: 0, the check would pass while the bundle is still broken. Requiring output.inputs[ENTRY_INPUT].bytesInOutput > 0 would pin the invariant to "the entry's code is actually in the entry file" rather than to the current metafile shape. Fine as a follow-up or not at all.

}

function main() {
try {
const serveResult = checkServeFastPathBundle();
Expand Down Expand Up @@ -535,7 +563,22 @@ function main() {
process.exitCode = 1;
}

if (serveResult.ok && acpResult.ok && sdkImplResult.ok) {
const entryResult = checkEntryBootstrapIntact();
if (!entryResult.ok) {
console.error(
`${ENTRY_OUTPUT} no longer contains ${ENTRY_INPUT} — esbuild code ` +
'splitting hoisted the entry into a shared chunk, so its\n' +
'`import.meta.url === pathToFileURL(process.argv[1]).href` guard ' +
'can never match and the bundled CLI\nwould exit 0 without running. ' +
'Cause: a module the entry loads lazily now statically imports ' +
"'./cli.js'.\nMove the shared helper into a leaf module and import " +
`that from both sides instead.\nCurrent ${ENTRY_OUTPUT} inputs: ` +
`${entryResult.inputs.length === 0 ? '(none)' : entryResult.inputs.join(', ')}`,
);
process.exitCode = 1;
}

if (serveResult.ok && acpResult.ok && sdkImplResult.ok && entryResult.ok) {
console.log('Startup bundle closure checks passed.');
}
} catch (error) {
Expand Down
81 changes: 81 additions & 0 deletions scripts/tests/serve-fast-path-bundle-check.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
checkAcpImportBoundary,
checkEntryBootstrapIntact,
checkSdkImplProtocolBoundary,
checkServeFastPathBundle,
findAcpImportBoundaryOffenders,
Expand All @@ -28,6 +29,8 @@ const checkScriptPath = fileURLToPath(
function makeMetafile(outputs) {
return {
outputs: {
// A healthy bundle compiles the entry module into the entry output.
'dist/cli.js': output({ inputs: ['packages/cli/src/cli.ts'] }),
'dist/chunks/fast-path.js': output({
inputs: ['packages/cli/src/serve/fast-path.ts'],
}),
Expand Down Expand Up @@ -754,3 +757,81 @@ describe('telemetry sdk-impl protocol boundary check', () => {
}
});
});

describe('bundled entry bootstrap check', () => {
it('accepts an entry output that still contains the entry module', () => {
const tempDir = mkdtempSync(join(tmpdir(), 'serve-fast-path-bundle-'));
try {
const metafilePath = writeMetafile(tempDir, makeMetafile({}));
expect(checkEntryBootstrapIntact({ metafilePath }).ok).toBe(true);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});

it('rejects an entry hoisted into a shared chunk by code splitting', () => {
// What a static `import ... from './cli.js'` inside a lazily-loaded module
// does to the bundle: dist/cli.js keeps no inputs of its own and becomes a
// re-export stub, so cli.ts's main-module bootstrap guard never fires.
const tempDir = mkdtempSync(join(tmpdir(), 'serve-fast-path-bundle-'));
try {
const metafilePath = writeMetafile(
tempDir,
makeMetafile({
'dist/cli.js': output({
imports: [staticImport('dist/chunks/cli-entry.js')],
}),
'dist/chunks/cli-entry.js': output({
inputs: ['packages/cli/src/cli.ts'],
}),
}),
);

expect(checkEntryBootstrapIntact({ metafilePath }).ok).toBe(false);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});

it('throws when the entry output is absent from the metafile', () => {
const tempDir = mkdtempSync(join(tmpdir(), 'serve-fast-path-bundle-'));
try {
const metafile = makeMetafile({});
delete metafile.outputs['dist/cli.js'];
const metafilePath = writeMetafile(tempDir, metafile);

expect(() => checkEntryBootstrapIntact({ metafilePath })).toThrow(
/Missing dist\/cli\.js in the esbuild metafile/,
);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});

it('exits non-zero with CLI diagnostics for a hoisted entry', () => {
const tempDir = mkdtempSync(join(tmpdir(), 'serve-fast-path-bundle-'));
try {
writeMetafile(
tempDir,
makeMetafile({
'dist/cli.js': output({
imports: [staticImport('dist/chunks/cli-entry.js')],
}),
'dist/chunks/cli-entry.js': output({
inputs: ['packages/cli/src/cli.ts'],
}),
}),
);

expect(() =>
execFileSync(process.execPath, [checkScriptPath], {
cwd: tempDir,
encoding: 'utf8',
stdio: 'pipe',
}),
).toThrow(/no longer contains packages\/cli\/src\/cli\.ts/);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
});
Loading