Skip to content

feat(ci): fail the startup bundle check when the CLI entry is hoisted into a chunk - #8203

Merged
wenshao merged 1 commit into
QwenLM:mainfrom
wenshao:fix/bundled-entry-bootstrap-gate
Jul 31, 2026
Merged

feat(ci): fail the startup bundle check when the CLI entry is hoisted into a chunk#8203
wenshao merged 1 commit into
QwenLM:mainfrom
wenshao:fix/bundled-entry-bootstrap-gate

Conversation

@wenshao

@wenshao wenshao commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Problem

packages/cli/src/cli.ts is the esbuild entry point (esbuild.config.js) 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 bare re-export stub. Inside a chunk import.meta.url is the chunk's own URL, so the guard can never match: runCliEntryPoint() is never called and the bundled CLI exits 0 without doing anything.

Nothing catches this today. tsc, eslint and every src-based unit test stay green, because the breakage exists only in the bundle. The one CI step that executes dist/cli.js is the no-AK integration smoke test, and what it reports is:

daemon exited with 0 before listening:
stdout=
stderr=

from three unrelated qwen serve suites — a symptom that points nowhere near the import that caused it. #8088 has been stuck on exactly this: the base-update bot re-merged main assuming a stale base, AutoFix has burned six attempts, and the PR is still red.

Change

checkEntryBootstrapIntact() asserts that dist/cli.js still compiles packages/cli/src/cli.ts. When the entry is hoisted, dist/cli.js keeps no inputs of its own, so the esbuild metafile that the existing closure checks already read is a precise, deterministic signal — no string matching against generated code.

It runs alongside the serve fast-path, ACP and sdk-impl closure checks in the same npm run check:serve-fast-path-bundle step, and the diagnostic names both the cause and the fix:

dist/cli.js no longer contains packages/cli/src/cli.ts — esbuild code splitting hoisted the entry
into a shared chunk, so its `import.meta.url === pathToFileURL(process.argv[1]).href` guard can
never match and the bundled CLI would exit 0 without running. Cause: a module the entry loads
lazily now statically imports './cli.js'. Move the shared helper into a leaf module and import
that from both sides instead.
Current dist/cli.js inputs: (none)

Verification

Built #8088's head with the real esbuild.config.js options and ran this check against both bundle shapes:

Bundle dist/cli.js metafile inputs Check
#8088 head (cycle present) 629 B re-export stub [] exits 1 with the diagnostic above
Same tree, cycle removed 11,467 B, guard inline ['packages/cli/src/cli.ts'] Startup bundle closure checks passed.

scripts/tests/serve-fast-path-bundle-check.test.js: 35 passed (31 existing + 4 new — healthy entry, hoisted entry, entry output absent from the metafile, and the CLI exit code). The shared makeMetafile() helper now includes a healthy dist/cli.js output so the existing cases keep exercising the closure checks rather than tripping the new one.

Scope

This is the gate only. The ./cli.js import that #8088 introduces is that PR's to fix; the fix there is to move the shared helpers into a leaf module — details posted on #8088.

中文说明

问题

packages/cli/src/cli.ts 是 esbuild 的 entry(见 esbuild.config.js),它只在自己是主模块时才会 bootstrap:

if (
  process.argv[1] !== undefined &&
  import.meta.url === pathToFileURL(process.argv[1]).href
) {
  void runCliEntryPoint();
}

bundle 开了 splitting: true。只要 entry 懒加载的任何模块(比如通过 await import('./gemini.js') 进来的 gemini.tsx)加一条静态 import ... from './cli.js',esbuild 就会把 entry 的模块体搬进共享 chunk,dist/cli.js 只剩一个 re-export 空壳。在 chunk 里 import.meta.url 指向 chunk 自身,守卫永远不成立:runCliEntryPoint() 一次都不会被调用,打包后的 CLI 什么都不做就 exit 0。

目前没有任何检查能发现它。tsc、eslint、所有基于 src 的单测全是绿的,因为只有 bundle 坏了。CI 里唯一真正执行 dist/cli.js 的是 no-AK 集成冒烟测试,而它报出来的是:

daemon exited with 0 before listening:
stdout=
stderr=

来自三个互不相干的 qwen serve 套件——这个症状离真正的元凶(那一行 import)十万八千里。#8088 就卡在这上面:update-branch 机器人以为是 base 过期又合了一次 main,AutoFix 烧掉了 6 次尝试,PR 至今还是红的。

改动

checkEntryBootstrapIntact() 断言 dist/cli.js 里仍然编译进了 packages/cli/src/cli.ts。entry 一旦被提到 chunk 里,dist/cli.js 就不再拥有自己的 inputs,所以现有闭包检查已经在读的 esbuild metafile 就是一个精确、确定的判据——不需要对生成代码做字符串匹配。

它和 serve fast-path、ACP、sdk-impl 三个闭包检查跑在同一个 npm run check:serve-fast-path-bundle 步骤里,报错信息同时点出成因和修法(见上方英文部分的输出示例)。

验证

用真实的 esbuild.config.js 选项构建了 #8088 的 head,再用本检查跑两种 bundle 形状:

Bundle dist/cli.js metafile inputs 检查结果
#8088 head(存在环) 629 B re-export 空壳 [] 退出 1,输出上述诊断
同一棵树、去掉环 11,467 B,守卫在 entry 内 ['packages/cli/src/cli.ts'] Startup bundle closure checks passed.

scripts/tests/serve-fast-path-bundle-check.test.js:35 passed(31 条原有 + 4 条新增——正常 entry、被提到 chunk 的 entry、metafile 中缺少 entry 输出、以及 CLI 退出码)。共用的 makeMetafile() 里补了一个健康的 dist/cli.js 输出,这样原有用例继续考验闭包检查,而不是被新检查拦下。

范围

本 PR 只做门禁。#8088 引入的那条 ./cli.js import 由该 PR 自己修——修法是把共享 helper 挪到叶子模块,细节已发在 #8088 上。

… into a chunk

`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.
@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Qwen Triage finished — CI landed green on c8569df and the deferred approval was posted. finalize run

Qwen Triage 已完成 —— c8569df 的 CI 全绿,延迟审批已提交。查看 finalize 运行

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks for the PR!

Template: the section names differ from the template (Problem/Change/Verification/Scope vs What this PR does/Why it's needed/Reviewer Test Plan/Risk & Scope), but every piece of information the template asks for is present and then some — the before/after verification table is exactly what a reviewer wants. Passing this; just a heads-up for future PRs. ✓

Problem: observed, not theoretical. #8088 is stuck red on precisely this failure mode — the no-AK integration smoke test reports daemon exited with 0 before listening from three unrelated qwen serve suites, a symptom that points nowhere near the ./cli.js import causing it, and AutoFix has burned six attempts without converging. The mechanism (esbuild splitting: true hoisting the entry into a shared chunk so the import.meta.url === pathToFileURL(process.argv[1]).href guard can never match) is explained concretely. This is a real, costly CI gap.

Direction: aligned. This is build-integrity tooling that turns a silent, hard-to-diagnose bundle breakage into a deterministic, well-messaged failure at the existing closure-check step. It touches no auth/sandbox/model-selection/telemetry/release surface and no public contract. CHANGELOG: N/A — internal CI infrastructure, not a user-facing feature.

Size: not applicable. Changes are confined to scripts/ (45 production lines in the check + 81 test lines); no packages/core or other protected paths, well under any threshold.

Approach: the scope feels right and genuinely minimal. It reuses the esbuild metafile the existing closure checks already read (a deterministic signal — no string-matching against generated code), plugs into the same npm run check:serve-fast-path-bundle step and main() aggregation, and the diagnostic names both cause and fix. The makeMetafile() test-helper update (adding a healthy dist/cli.js output) is necessary so the existing cases keep exercising the closure checks rather than tripping the new one — well reasoned. Scope is explicitly "the gate only"; the ./cli.js import itself is left for #8088 to fix, which is the right separation.

Risk: no elevated risk signals — neither changed file matches the revert-correlated high-risk paths.

Moving on to code review. 🔍

中文说明

感谢贡献!

模板: 小节命名与模板不同(Problem/Change/Verification/Scope 对比 What this PR does/Why it's needed/Reviewer Test Plan/Risk & Scope),但模板要求的信息全都具备、甚至更充分——before/after 验证表格正是 reviewer 想看的。本次通过;只是给后续 PR 提个醒。✓

问题: 已观测到,非理论性问题。#8088 正好卡在这个失败模式上——no-AK 集成冒烟测试从三个互不相干的 qwen serve 套件报出 daemon exited with 0 before listening,这个症状离真正的元凶(那条 ./cli.js import)十万八千里,AutoFix 烧了 6 次也没收敛。机理(esbuild splitting: true 把 entry 提到共享 chunk,导致 import.meta.url === pathToFileURL(process.argv[1]).href 守卫永远不成立)讲得很具体。这是一个真实、代价高昂的 CI 盲区。

方向: 对齐。这是构建完整性工具,把一个静默、难以诊断的 bundle 损坏,变成在现有闭包检查步骤里一个确定的、报错清晰的失败。它不触及 auth/sandbox/模型选择/telemetry/release 面,也不涉及公共契约。CHANGELOG:不适用——内部 CI 基础设施,非用户可见功能。

规模: 不适用。改动只在 scripts/(检查脚本 45 行生产代码 + 81 行测试),未触及 packages/core 或其他受保护路径,远低于任何阈值。

方案: 范围合理、确实最小化。它复用了现有闭包检查已经在读的 esbuild metafile(确定性判据——不对生成代码做字符串匹配),接入同一个 npm run check:serve-fast-path-bundle 步骤和 main() 聚合逻辑,报错信息同时点出成因和修法。测试 helper makeMetafile() 的更新(补一个健康的 dist/cli.js 输出)是必要的,这样原有用例继续考验闭包检查、而不是被新检查拦下——考虑周到。范围明确"只做门禁";那条 ./cli.js import 本身留给 #8088 去修,拆分得当。

风险: 无升级风险信号——两个改动文件都未命中与 revert 相关的高风险路径。

进入代码审查 🔍

Qwen Code · qwen3.8-max-preview

Reviewed at c8569df8cc5f9229277f076a3d79ae2a88e52833 · re-run with @qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Code review

My independent take before reading the diff: the right fix is a deterministic metafile assertion in the existing closure-check script — read dist/esbuild.json, confirm the dist/cli.js output still lists packages/cli/src/cli.ts among its inputs, wire it into the same main() aggregation, and cover healthy/hoisted/missing shapes plus the process exit code. That's exactly what this PR does, so the approach matches what I'd have written; I didn't find a simpler path it missed.

The implementation is clean and idiomatic for this file:

  • checkEntryBootstrapIntact() reuses readMetafile(), DEFAULT_METAFILE_PATH, and METAFILE_BUILD_COMMAND, and plugs into main() alongside the serve/ACP/sdk-impl checks, extending the success condition to && entryResult.ok. The diagnostic names both the cause (a lazily-loaded module now statically imports ./cli.js) and the fix (move the shared helper into a leaf module) — that's the part that would have saved fix(cli): prevent silent VP-mode crash by adding uncaughtException handler and error visibility #8088 six failed AutoFix attempts.
  • The tests reuse the existing output() / writeMetafile() / staticImport() helpers and the established execFileSync(process.execPath, [checkScriptPath]) subprocess pattern, so the exit-code test pins not just the detection logic but also the wiring into main(). The makeMetafile() update (adding a healthy dist/cli.js) is necessary and correct: the pre-existing CLI-level tests invoke main(), so without it they'd trip the new check instead of exercising the closure checks.

One non-blocking observation: the new check does a direct metafile.outputs['dist/cli.js'] lookup and an exact inputs.includes('packages/cli/src/cli.ts'), whereas the sibling checks route through normalizeOutputs() / inputMatchesSuffix() to tolerate ./ prefixes and backslashes. Against the real metafile shape esbuild emits (forward slashes, no leading ./ — confirmed by the PR's own before/after table) this is fine, just marginally less defensive than its neighbors. Not worth blocking on.

No correctness bugs, security concerns, or regressions found; no AGENTS.md convention violations.

Test evidence (PR's own CI)

precheck-pr / precheck (the lint / typecheck / build gate) passed on this commit. The Linux unit suite — Test (ubuntu-latest, Node 22.x), the job that actually runs scripts/tests/serve-fast-path-bundle-check.test.js with the four new cases — is still in progress; macOS/Windows and the no-sandbox integration suite are gated/skipped pending it. No check has failed on this commit. Because the suite hasn't settled, I'm not attesting to green CI here — the table below is wrapped for the finalize workflow to update in place once CI lands.

Final CI results for c8569df (auto-updated by the triage finalize job after CI completed):

Check Conclusion
Classify PR ✅ success
Test (ubuntu-latest, Node 22.x) ✅ success
web-shell E2E Smoke (ubuntu-latest, Node 22.x) ✅ success

One row per check name (latest run); skipped checks omitted; failures sort first. / 每个检查名一行(取最新一次运行),省略 skipped,失败项排在最前。

No sandboxed lane (/verify / /tmux) is warranted here: the central claim — the check fails on a hoisted entry and passes on a healthy one — is a pure function of the metafile, fully pinned by the four new unit tests (including a subprocess test that asserts the non-zero exit and the diagnostic text). A green suite on this PR genuinely exercises the change, unlike a runtime fix whose tests could pass with the fix removed; there is no TUI surface or runtime behaviour for /tmux or /verify to settle.

中文说明

代码审查

读 diff 之前我的独立判断:正确的修法是在现有闭包检查脚本里加一个确定性的 metafile 断言——读 dist/esbuild.json,确认 dist/cli.js 输出的 inputs 里仍有 packages/cli/src/cli.ts,接入同一个 main() 聚合,并覆盖健康/被提到 chunk/缺失三种形状以及进程退出码。这个 PR 做的正是这件事,方案与我会写的一致;我没找到它遗漏的更简路径。

实现干净、符合该文件的惯例:

  • checkEntryBootstrapIntact() 复用了 readMetafile()DEFAULT_METAFILE_PATHMETAFILE_BUILD_COMMAND,并和 serve/ACP/sdk-impl 检查一起接入 main(),把成功条件扩展为 && entryResult.ok。报错信息同时点出成因(某个懒加载模块现在静态 import 了 ./cli.js)和修法(把共享 helper 挪到叶子模块)——这正是能帮 fix(cli): prevent silent VP-mode crash by adding uncaughtException handler and error visibility #8088 省掉 6 次 AutoFix 失败的部分。
  • 测试复用了现有的 output() / writeMetafile() / staticImport() helper,以及既有的 execFileSync(process.execPath, [checkScriptPath]) 子进程模式,因此退出码测试不仅钉住了检测逻辑,也钉住了接入 main() 的接线。makeMetafile() 的更新(补一个健康的 dist/cli.js)是必要且正确的:原有的 CLI 级用例会调用 main(),不补的话它们会被新检查拦下,而不是继续考验闭包检查。

一个非阻塞的观察:新检查直接用 metafile.outputs['dist/cli.js'] 查找、并用精确的 inputs.includes('packages/cli/src/cli.ts'),而兄弟检查都走 normalizeOutputs() / inputMatchesSuffix() 以容忍 ./ 前缀和反斜杠。对照 esbuild 实际产出的 metafile 形状(正斜杠、无前导 ./——PR 自己的 before/after 表格已证实)这没问题,只是比邻居略少一层防御。不值得为此阻塞。

未发现正确性 bug、安全隐患或回归;无 AGENTS.md 约定违规。

测试证据(PR 自身的 CI)

precheck-pr / precheck(lint / typecheck / build 门禁)在本 commit 上通过。Linux 单测套件——Test (ubuntu-latest, Node 22.x),也就是真正运行 scripts/tests/serve-fast-path-bundle-check.test.js(含 4 条新用例)的任务——仍在进行中;macOS/Windows 与 no-sandbox 集成套件被门控/跳过,等它完成。本 commit 上没有任何检查失败。由于套件尚未 settle,我在此不为绿色 CI 背书——下方表格已用标记包裹,待 CI 落地后由 finalize workflow 原地更新。

无需沙箱通道(/verify / /tmux):核心论断——检查在被提到 chunk 的 entry 上失败、在健康 entry 上通过——是 metafile 的纯函数,被 4 条新单测完全钉住(其中子进程测试断言了非零退出码和诊断文本)。本 PR 的绿色套件确实真正考验了改动,不同于那种"去掉 fix 测试照样过"的运行时修复;没有 TUI 面或运行时行为需要 /tmux/verify 来 settle。

Qwen Code · qwen3.8-max-preview

Reviewed at c8569df8cc5f9229277f076a3d79ae2a88e52833 · re-run with @qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Confidence: 4/5 — minimal, well-tested gate that solves a real, observed CI failure mode; only nit is the minor normalization-consistency point above, and CI's Linux unit suite is still in flight so I'm deferring the approval until it lands green.

Stepping back: this is the kind of PR that pays for itself. The failure it guards against is genuinely nasty — a bundle that builds clean, passes tsc/eslint/every src-based unit test, and then exits 0 without running anything, with the only red signal being a cryptic daemon exited with 0 before listening three suites away from the cause. #8088 burning six AutoFix attempts on exactly this is the proof that the diagnostic gap is real and costly. Turning it into a deterministic, well-messaged metafile assertion at the existing closure-check step is the right move, and the diagnostic literally tells the next person how to fix it.

The implementation matches what I'd have written independently — I went looking for a simpler path and didn't find one. It reuses the metafile the sibling checks already read, adds ~26 lines of production logic, and the four new tests pin both the detection logic and the main() wiring (the subprocess exit-code test would fail if either broke). Scope is disciplined: "the gate only," with the actual ./cli.js import fix correctly left to #8088. If I had to maintain this in six months I'd thank the author — it's self-describing and sits naturally beside the checks it mirrors.

The one reservation, already noted in Stage 2, is cosmetic: the direct metafile.outputs['dist/cli.js'] / exact inputs.includes(...) lookup is marginally less defensive than the sibling checks' normalizeOutputs() / inputMatchesSuffix() path. Fine against the real metafile shape; not worth a round-trip.

Approval is deferred until CI lands green on c8569df8cc5f9229277f076a3d79ae2a88e52833. The Linux unit suite that runs the new tests is still in progress, so I'm not attesting to a result that doesn't exist yet — the finalize workflow will post the commit-pinned approval once every check on that commit completes green, and withhold it if anything lands red or the head moves.

中文说明

置信度:4/5 —— 一个最小化、测试充分的门禁,解决了一个真实、已观测到的 CI 失败模式;唯一的瑕疵是上面提到的归一化一致性小点,且 CI 的 Linux 单测套件仍在跑,所以我推迟到它变绿再批准。

退一步看:这个 PR 物有所值。它防的失败确实棘手——bundle 构建干净、tsc/eslint/所有基于 src 的单测全过,然后什么都不做就 exit 0,唯一的红灯是离元凶三个套件之远的 daemon exited with 0 before listening#8088 在这上面烧掉 6 次 AutoFix,正好证明这个诊断盲区真实且代价高昂。把它变成现有闭包检查步骤里一个确定性的、报错清晰的 metafile 断言,是正确之举,而且报错信息直接告诉下一个人怎么修。

实现与我独立会写的一致——我找过更简路径,没找到。它复用了兄弟检查已经在读的 metafile,新增约 26 行生产逻辑,4 条新测试同时钉住了检测逻辑和 main() 接线(子进程退出码测试在任一者坏掉时都会失败)。范围克制:"只做门禁",把那条 ./cli.js import 的实际修复正确地留给 #8088。半年后维护它我会感谢作者——自解释、自然地待在它所镜像的检查旁边。

唯一的保留(Stage 2 已提)是表面性的:直接的 metafile.outputs['dist/cli.js'] / 精确 inputs.includes(...) 查找,比兄弟检查的 normalizeOutputs() / inputMatchesSuffix() 路径略少一层防御。对照真实 metafile 形状没问题;不值得为此再来一轮。

批准推迟到 CI 在 c8569df8cc5f9229277f076a3d79ae2a88e52833 上变绿。 运行新测试的 Linux 单测套件仍在进行中,所以我不会为一个尚不存在的结果背书——finalize workflow 会在该 commit 上所有检查变绿后发出按 commit 钉住的批准;若有检查变红或 head 移动,则不发。

Qwen Code · qwen3.8-max-preview

Reviewed at c8569df8cc5f9229277f076a3d79ae2a88e52833 · re-run with @qwen-code /triage

@qwen-code-ci-bot qwen-code-ci-bot left a comment

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.

⚠️ Downgraded from Approve to Comment: CI still running. Reviewed.

— qwen3.8-max-preview via Qwen Code /review

@qwen-code-ci-bot qwen-code-ci-bot left a comment

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.

LGTM, looks ready to ship — CI landed green after the review. ✅

@wenshao
wenshao enabled auto-merge July 31, 2026 07:32
@wenshao

wenshao commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator Author

Local verification: built the real bundle and reproduced the failure this gate exists for

Ran on Linux, Node v22.22.2, worktree at c8569df8 (base 01afcb0bb). Everything below goes through a real npm run check:serve-fast-path-bundle — the exact CI step, full esbuild bundle (389 outputs) — not a hand-written metafile.

Verdict: does what it says, safe to merge. One Low finding and two optional follow-ups at the bottom; none of them block.

1. Baseline — no false positive on a healthy tree

baseline

Unmodified PR head: check passes, dist/cli.js is 13,307 B with inputs = ["packages/cli/src/cli.ts"], and node dist/cli.js --version prints 0.21.2.

2. The regression, reproduced for real

Replayed #8088's edit verbatim onto the PR head — packages/cli/src/gemini.tsx, which the entry only ever reaches through await import('./gemini.js'), statically imports isExpectedPtyRaceError from ./cli.js — then rebuilt:

regression

  • dist/cli.js: 13,307 B → 665 B bare re-export stub; metafile inputs: []; cli.ts relocated to dist/chunks/chunk-RZCLGFSK.js.
  • --version, --help and serve --port 45231 all exit 0 with no output at all. serve never listens — precisely the daemon exited with 0 before listening: stdout= stderr= symptom described in the PR body.

3. The gate is the entire delta

Both scripts read the same dist/esbuild.json from that build:

check script healthy bundle bundle with #8088's import
base (main @ 01afcb0bb) passed, exit 0 passed, exit 0 — blind
this PR (c8569df8) passed, exit 0 exit 1 + the diagnostic

gate A/B

4. inputs is the correct signal

Worth recording, because the obvious alternative is a trap: in the hoisted metafile the stub output still carries entryPoint: "packages/cli/src/cli.ts" (visible in 2/3 above). An entryPoint-based check would have been a false negative. inputs is the only field that flips.

5. Tests, lint

  • scripts/tests/serve-fast-path-bundle-check.test.js: base 31 passed → PR 35 passed, matching the description.
  • Whole scripts vitest project: 817 passed / 5 failed. All 5 are in tests/generate-release-notes.test.js (vi.mocked(...).mockImplementationOnce is not a function) and fail identically on base — unrelated to this PR.
  • prettier --check and eslint clean on both changed files.

6. Mutation testing — 9 mutants against the new tests, 7 killed

# mutation result
M1 ok: inputs.includes(ENTRY_INPUT)ok: true killed (2 tests)
M2 ENTRY_INPUTpackages/cli/src/gemini.tsx killed (2)
M3 ENTRY_OUTPUTdist/chunks/cli.js killed (4)
M4 drop the missing-entry-output throw killed (1)
M5 drop && entryResult.ok from the success-line condition survived
M6 drop process.exitCode = 1 in the new failure branch killed (1)
M7 exact input match → substring match (i.includes('cli')) survived
M8 diagnostic no longer names the entry input killed (1)
M9 checkEntryBootstrapIntact() never called from main() killed (1)

M6 is the one that matters — a gate that prints a diagnostic and still exits 0 is worth nothing, and the new CLI test kills it. The two survivors are coverage gaps, not defects; the shipped code is correct in both cases.


Finding (Low) — checkEntryBootstrapIntact() skips this file's own path normalization

checkEntryBootstrapIntact() looks the entry up with a raw key (metafile.outputs['dist/cli.js']) and compares with a raw inputs.includes('packages/cli/src/cli.ts'). The other three checks in this file all go through normalizeOutputs() / normalizeMetafilePath(), and the suite carries a dedicated backslash case (serve-fast-path-bundle-check.test.js:258). Given a healthy metafile whose paths use \, the three existing checks still pass and the new one fails — with a message that points at the wrong problem:

path normalization

Blast radius is small: the CI step is deliberately Linux-only (per the comment above it in ci.yml). But CONTRIBUTING.md tells every contributor to run npm run preflight before submitting, and preflight includes this step. Reusing what is already in the file fixes it:

const outputs = normalizeOutputs(metafile);           // already defined in this file
const output = outputs.get(ENTRY_OUTPUT);
// ...
const inputs = Object.keys(output.inputs ?? {}).map(normalizeMetafilePath);

Optional follow-ups

  1. Derive the entry input from the metafile. output.entryPoint survives hoisting (see §4), so inputs.includes(output.entryPoint ?? ENTRY_INPUT) keeps the same detection power while self-healing if cli.ts is ever moved or renamed — today that rename produces a "code splitting hoisted the entry" message for something that is not code splitting.
  2. Two assertions to close the mutation gaps. In the CLI test, expect(...).not.toContain('Startup bundle closure checks passed.') (kills M5); and a case where dist/cli.js holds only a different cli-*.ts input, asserting ok === false (kills M7, pins exact matching).

Note

#8088's current head (f71d37d3, "move uncaught-exception helpers to a leaf module") already applies exactly the fix this diagnostic recommends, so landing this gate turns nothing red today — it keeps that class of regression from silently coming back.

中文版

本地验证:真实构建 bundle,并复现了这个门禁要拦的故障

环境:Linux、Node v22.22.2,worktree 在 c8569df8(base 01afcb0bb)。下面所有结论都来自真实的 npm run check:serve-fast-path-bundle(就是 CI 里那一步,完整 esbuild 打包,389 个 output),不是手写的 metafile。

结论:功能与描述一致,可以合入。 底部有 1 个 Low 问题和 2 条可选跟进,都不阻塞。

1. 基线 —— 健康代码树上不会误报

baseline

未改动的 PR head:检查通过,dist/cli.js 13,307 B,inputs = ["packages/cli/src/cli.ts"]node dist/cli.js --version 输出 0.21.2

2. 真实复现回归

#8088 的改动原样搬到 PR head 上——packages/cli/src/gemini.tsx(entry 只通过 await import('./gemini.js') 才会加载它)静态 import ./cli.js 里的 isExpectedPtyRaceError——然后重新构建:

regression

  • dist/cli.js:13,307 B → 665 B 的纯 re-export 空壳;metafile inputs: []cli.ts 被搬到 dist/chunks/chunk-RZCLGFSK.js
  • --version--helpserve --port 45231 全部 0 输出、exit 0。serve 根本不监听——正是 PR 描述里那个 daemon exited with 0 before listening: stdout= stderr=

3. 门禁就是全部增量

两个脚本读的是同一份 dist/esbuild.json

检查脚本 健康 bundle #8088 那条 import 的 bundle
base(main @ 01afcb0bb 通过,exit 0 通过,exit 0 —— 看不见
本 PR(c8569df8 通过,exit 0 exit 1 + 诊断信息

gate A/B

4. 选 inputs 是对的

值得记一笔,因为最顺手的替代方案是个坑:在被提到 chunk 之后的 metafile 里,那个空壳 output 仍然带着 entryPoint: "packages/cli/src/cli.ts"(见第 2 张图)。用 entryPoint 做判据会漏报,只有 inputs 会翻转。

5. 测试与 lint

  • scripts/tests/serve-fast-path-bundle-check.test.js:base 31 passed → PR 35 passed,与描述一致。
  • 整个 scripts vitest project:817 passed / 5 failed。5 条全在 tests/generate-release-notes.test.jsvi.mocked(...).mockImplementationOnce is not a function),base 上同样失败——与本 PR 无关。
  • 两个改动文件的 prettier --checkeslint 均干净。

6. 变异测试 —— 9 个变异体,杀掉 7 个

# 变异 结果
M1 ok: inputs.includes(ENTRY_INPUT)ok: true killed(2 条用例)
M2 ENTRY_INPUTpackages/cli/src/gemini.tsx killed(2)
M3 ENTRY_OUTPUTdist/chunks/cli.js killed(4)
M4 去掉 entry output 缺失时的 throw killed(1)
M5 从成功提示的条件里去掉 && entryResult.ok 存活
M6 去掉新增失败分支里的 process.exitCode = 1 killed(1)
M7 精确匹配改成子串匹配(i.includes('cli') 存活
M8 诊断信息不再点名 entry input killed(1)
M9 main() 里根本不调用 checkEntryBootstrapIntact() killed(1)

M6 是最关键的一个——一个只打印诊断却仍然 exit 0 的门禁毫无价值,而新增的 CLI 用例把它杀掉了。两个存活的是覆盖率缺口,不是缺陷:现有代码在这两点上都是正确的。


问题(Low)—— checkEntryBootstrapIntact() 绕过了本文件自己的路径归一化

checkEntryBootstrapIntact() 用原始 key 取 entry(metafile.outputs['dist/cli.js']),也用原始的 inputs.includes('packages/cli/src/cli.ts') 比较。而这个文件里另外三个检查都走 normalizeOutputs() / normalizeMetafilePath(),测试里还专门有一条反斜杠用例(serve-fast-path-bundle-check.test.js:258)。给一份路径用 \健康 metafile:原有三个检查照常通过,新检查失败,而且报错指向了完全无关的方向:

path normalization

影响面有限:这个 CI 步骤按设计只跑 Linux(ci.yml 里那一步上方的注释写明了)。但 CONTRIBUTING.md 要求所有贡献者提交前跑 npm run preflight,而 preflight 包含这一步。直接复用文件里已有的东西即可:

const outputs = normalizeOutputs(metafile);           // 本文件里已有
const output = outputs.get(ENTRY_OUTPUT);
// ...
const inputs = Object.keys(output.inputs ?? {}).map(normalizeMetafilePath);

可选跟进

  1. 从 metafile 推导 entry input。 output.entryPoint 在被提到 chunk 后依然存在(见第 4 节),所以 inputs.includes(output.entryPoint ?? ENTRY_INPUT) 检测能力不变,同时在 cli.ts 被移动/改名时能自愈——按目前的写法,改名会得到一句"code splitting 把 entry 提走了"的误导性报错。
  2. 两条断言补上变异缺口。 CLI 用例里加 expect(...).not.toContain('Startup bundle closure checks passed.')(杀 M5);再加一个 dist/cli.js 只含另一个 cli-*.ts input 的用例并断言 ok === false(杀 M7,钉死精确匹配语义)。

备注

#8088 当前 head(f71d37d3,"move uncaught-exception helpers to a leaf module")已经采用了本诊断推荐的修法,所以这个门禁合入后今天不会把任何 PR 变红——它防的是这一类回归再悄悄回来。

@yiliang114 yiliang114 left a comment

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.

Verified the premise against the repo before approving: esbuild.config.js does build packages/cli/src/cli.tsdist/cli.js with splitting: true and writes the metafile the existing closure checks already read, and check:serve-fast-path-bundle rebuilds the bundle first in both ci.yml and release.yml, so the gate sees a fresh metafile. Using metafile inputs instead of string-matching generated code is the right call, and the failure modes are loud (entry rename / metafile shape change throw with the rebuild command) rather than silently passing. The four new tests each pin a distinct contract, and seeding makeMetafile() with a healthy entry output keeps the existing CLI-exit cases failing for their own reasons. One optional hardening thought inline — not blocking.

}

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.

@wenshao
wenshao added this pull request to the merge queue Jul 31, 2026
Merged via the queue into QwenLM:main with commit 3ff8892 Jul 31, 2026
69 checks passed
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Released in v0.21.3.

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.

3 participants