From c8569df8cc5f9229277f076a3d79ae2a88e52833 Mon Sep 17 00:00:00 2001 From: verify Date: Fri, 31 Jul 2026 14:03:56 +0800 Subject: [PATCH] feat(ci): fail the startup bundle check when the CLI entry is hoisted into a chunk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `packages/cli/src/cli.ts` is the esbuild entry point and bootstraps only under a main-module guard: if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href) { void runCliEntryPoint(); } The bundle is built with `splitting: true`. If any module the entry loads lazily (e.g. `gemini.tsx`, reached through `await import('./gemini.js')`) adds a static `import ... from './cli.js'`, esbuild moves the entry module's body into a shared chunk and leaves `dist/cli.js` as a re-export stub. Inside a chunk `import.meta.url` is the chunk's own URL, so the guard never matches and the bundled CLI exits 0 without running anything. Nothing catches that today: tsc, eslint and every src-based unit test stay green, because the breakage only exists in the bundle. The single CI step that executes `dist/cli.js` is the no-AK integration smoke test, which reports it as `daemon exited with 0 before listening` from three unrelated serve suites — a symptom that points nowhere near the import that caused it. Assert instead that the entry output still compiles the entry module. When the entry is hoisted, `dist/cli.js` keeps no inputs of its own, so the metafile the existing closure checks already read is a precise signal, and the diagnostic can name both the cause and the fix. --- scripts/check-serve-fast-path-bundle.js | 45 ++++++++++- .../serve-fast-path-bundle-check.test.js | 81 +++++++++++++++++++ 2 files changed, 125 insertions(+), 1 deletion(-) diff --git a/scripts/check-serve-fast-path-bundle.js b/scripts/check-serve-fast-path-bundle.js index 798dc76dd9b..caebf514217 100644 --- a/scripts/check-serve-fast-path-bundle.js +++ b/scripts/check-serve-fast-path-bundle.js @@ -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', @@ -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 }; +} + function main() { try { const serveResult = checkServeFastPathBundle(); @@ -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) { diff --git a/scripts/tests/serve-fast-path-bundle-check.test.js b/scripts/tests/serve-fast-path-bundle-check.test.js index dced8030d90..2553ab32cf6 100644 --- a/scripts/tests/serve-fast-path-bundle-check.test.js +++ b/scripts/tests/serve-fast-path-bundle-check.test.js @@ -12,6 +12,7 @@ import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { checkAcpImportBoundary, + checkEntryBootstrapIntact, checkSdkImplProtocolBoundary, checkServeFastPathBundle, findAcpImportBoundaryOffenders, @@ -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'], }), @@ -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 }); + } + }); +});