Skip to content

test(scripts): 仓级 sideEffects 声明与加载期副作用一致性门 (#3943) - #4907

Merged
yinlianghui merged 2 commits into
mainfrom
claude/issue-3943-sideeffects-gate
Aug 17, 2026
Merged

test(scripts): 仓级 sideEffects 声明与加载期副作用一致性门 (#3943)#4907
yinlianghui merged 2 commits into
mainfrom
claude/issue-3943-sideeffects-gate

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes #3943

按 2026-08-17 晋级评论的 scope 实施:在 scripts/__tests__/ 落一条派生式仓级门,形状照同目录 package-files-exist.test.ts 先例。

前提验证(先行,origin/main @ 97da1b0d6)

卡面的两条前提都复核为成立:

逐包判定表

声明形态 派生出的入口形态 门判定
@object-ui/core false dist/index.js, src/index.ts 绿 — 可达 87 模块,0 副作用
@object-ui/i18n false dist/index.js, src/index.ts 绿 — 可达 25 模块,0
@object-ui/react-runtime false dist/index.js, src/index.tsx 绿 — 可达 1 模块,0
@object-ui/sdui-parser false dist/index.js, src/index.ts 绿 — 可达 5 模块,0
@object-ui/types false 13 个(11 条 exports 子路径 + dist/index.cjs + src/index.ts) 绿 — 可达 39 模块,0
@object-ui/layout 数组 ×3 dist/index.js, dist/index.umd.cjs, src/index.ts 绿 — 数组覆盖全部 3 形态;所声明的 src/index.ts 确有顶层副作用
@object-ui/components true 不在判定面(true 是最保守 claim,不会丢注册)
@object-ui/fields true 同上

react-runtimeindex.tsx 是卡面点名「别被 index.ts 的 glob 漏掉」的那一处 —— 门不靠文件名约定猜,而是从 alias 表解析目录到 barrel,.tsx.ts 走同一条代码路径,并有一条断言与一组探针分别钉住。

门做什么

两个方向都判,因为声明可以朝两边说谎:

  1. sideEffects: false + 加载期副作用 = packages/layout: sideEffects: false contradicts the load-time registerLayout() — a side-effect-only import can be tree-shaken away #3899 那个谎(注册被整块丢掉,0 字节、退出码 0、无警告)。
  2. 数组里列了一个没有加载期副作用的模块 = 空头声明(白占体积,且对下一个作者读起来像「这里有注册」,真条目因此会被当噪音删掉)。

扫描面按可达性派生,不按文件 glob。 sideEffects: false 授权打包器丢掉该包任何导出未被用到的模块,不只是 barrel,所以诚实的扫描面是 barrel + 从它经相对 import 可达的全部模块 —— 恰好就是打包器能丢的那一集。这样还免掉了一份测试文件排除名单:src/** glob 会把同目录的 *.test.ts 一并扫进来(光 packages/core/src 就有 28 个顶层 describe(...),个个是顶层调用、个个不是打包风险),而按文件名排除等于在门上开一个文件名形状的洞。

入口形态含 workspace alias 入口。 仓内有三张打包器 alias 表把 @object-ui/* 指到包的 src(apps/consoleexamples/console-starter,以及此前未被点名的 packages/fields/vite.config.ts),打包器对这些源文件读的是同一份 package.json —— PR #3940 实测过只声明 dist/* 时 console 的 alias 形态照样 0 字节。三张表都是从配置源文本解析的(照 vitest-config-alias-targets-3944.test.ts 的理由:只要一串字符串,不该把整个 Vite 插件面 import 起来),新增一张表当天即被覆盖。根 vitest.config.mts 的第四张表故意不读:Vitest 转译并执行模块,不做 tree-shaking,不是打包面。

真打包器只用在静态分析答不了的那一问上。 写门之前先实测过「能不能拿打包器当副作用探测器」——把各包真实源码在镜像的 sideEffects: true 下打包(强制保留模块,存活下来的就是打包器认定的不纯部分):

@object-ui/i18n          -> 保留 1.28 MB   (locale 表上的 Object.freeze(Object.keys({...})),模块内自用)
@object-ui/types         -> 保留 261 B     (new Set(Object.values({...})))
@object-ui/react-runtime -> 保留 33 B      (外部依赖的裸 import)
@object-ui/sdui-parser   -> 保留 0 B

rollup 的纯度分析是刻意保守的,「打包器留了东西」≠「这个包有对外可见的副作用」——照这个读法,门第一天就会在三个诚实的包上变红。所以静态判据只问那个可判定的窄问题(裸调用 / 经成员表达式的写 / 纯副作用 import),打包器则负责回答「声明到底改不改变产物」,且每条探针都配 sideEffects: false 对照

反向验证(各面先书面预判再跑,变异前已 commit,还原一律 git checkout)

# 变异 预判 实测
false 包 barrel(packages/core/src/index.ts)注入顶层裸调用 registerEverything(); 1 红,指名包与语句;探针全绿(它们打的是 marker 镜像,不是真源码) 1 failed / 27 passed,报 @object-ui/core declares "sideEffects": false, but packages/core/src/index.ts:86 is a top-level call: registerEverything();
从 layout 数组里抠掉 alias 入口 "./src/index.ts"(#3940 实测过的坑) 3 红:数组覆盖(missing)、src/index.ts 的 marker 正向探针、真源码 specimen 探针 ⚠️ 4 红 —— 见下
把两组对照的镜像从 false 翻成数组形态(对照的对照) 8 红(3 条数组入口对照 + 5 条 false barrel 对照),其余不动 8 failed / 20 passed,不多不少正是那 8 条
删掉 layout barrel 的 try { registerLayout(); } catch {}(反向/空头声明方向) 2 红:「所声明的源入口确有副作用」+ specimen 探针 2 failed / 26 passed

② 的预判少了一条,如实记下而不是回头改预判: 第 4 条红是 every declared source entry really does have a load-time side effect 里的反空绿地板。抠掉 ./src/index.ts 后,数组里就没有任何源入口了,那条空头声明检查会在空集合上走完并报绿 —— 地板正是为此而设,它按设计先炸。这是地板在干活,不是误报。

另外 ② 的 specimen 探针给出的现场是 expected '' to contain 'page-header' —— 字面上的 0 字节 bundle,#3899 那个数字在门里被原样复现。

layout 包内 pin 的收敛方式与理由

删除并入(packages/layout/src/__tests__/side-effects-manifest.test.ts → 仓级门),理由:该文件 6 条断言里 5 条都是可派生的,并入后自动适用于所有包,而不再只护 layout 一个包——

原断言 去向
入口形态覆盖(missing + phantom + 反空绿) 仓级门的数组覆盖检查,适用于每个数组包;layout 的三形态另有一条按名钉住
declaresLoadTimeRegistration 泛化为「数组所声明的入口必须确有顶层副作用」
两条 published 入口 marker 探针 泛化为「每个数组包 × 每个入口形态」的正向探针
两条 sideEffects: false 对照 同上的对照探针
源码树对照 specimen 段保留
真源码 + 注册键普查探针 原样搬进仓级门作为具名 specimen

最后一条是唯一不可泛化的:marker 探针只能证明「每个声明路径上有某个模块存活」,而 layout 的实际载荷是一组注册键,只有打包真源码才能看到那些具体的键存活 —— 这正是 #3787 需要的、OBJUI-001 报告其缺失的那条断言,因此逐字搬运而非折成 marker。仓级门的反空绿断言按名点住 @object-ui/layout 且要求它仍是数组形态,所以 layout 掉出扫描面本身就会变红。

一处顺带修正:built-vs-unbuilt 分歧(写门途中实测到)

「哪些入口形态是源码」最初按 fs.existsSync 判定。跑 turbo type-check(dependsOn: ^build)时 packages/layout/dist 被生成出来,该判据当场在同一棵树上翻面 —— dist/index.js 会被算进静态扫描面,而 CI 的全新 clone 里不会。改为按 git index 判定:git ls-files 不会因为你跑了 pnpm build 而变。这与 package-files-exist.test.ts#4059 学到的是同一课(它的 producibility 检查最初也写在 onDisk 上,并在它本该拒绝的状态上报了绿),注释里已互相指认。改完在已 built 的树上重跑 ④,仍只报 src/index.ts,不报 dist/index.js

相邻缺陷(不扩围,已按纪律处理)

验证

pnpm exec vitest run scripts/__tests__/ packages/layout/src/__tests__/ --maxWorkers=2
  Test Files  65 passed (65)
       Tests  1321 passed (1321)

pnpm exec turbo run type-check --concurrency=2
  Tasks:    81 successful, 81 total          [exited with code 0]

pnpm run type-check:scripts        # tsc -p tsconfig.scripts.json — 新文件所属的程序
  (无输出,退出 0)

node scripts/check-control-bytes.mjs
  ✅  OK (scanned 4385 tracked text file(s); skipped 85 binary)

新门本身 28 tests / 2.3s(9 条静态 + 17 条探针 + 2 条 specimen;探针数与派生出的入口形态数逐条对得上,所以没有探针被静默吞掉)。

changeset 照 .changeset/app-shell-docs-nav-examples.md 的 test-only 先例,空 frontmatter、不 bump 任何包(未触碰任何包的运行时源码)。


Generated by Claude Code

claude added 2 commits August 17, 2026 03:07
…cy gate (#3943)

Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added the tests label Aug 17, 2026
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Main entry (gzip) 24.7 KB 350 KB
Entry file index-CXxZQTDa.js
Status PASS

📦 Bundle Size Report

Package Size Gzipped
app-shell (index.js) 9.56KB 3.59KB
app-shell (runtime-config.js) 7.42KB 2.32KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 8.92KB 3.41KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 1.17KB 0.53KB
auth (AuthProvider.js) 25.13KB 5.40KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.13KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.64KB 2.21KB
auth (SocialSignInButtons.js) 9.60KB 3.89KB
auth (UserMenu.js) 3.40KB 1.22KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 38.46KB 10.17KB
auth (createAuthenticatedFetch.js) 6.34KB 2.43KB
auth (index.js) 2.35KB 1.07KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.02KB 0.88KB
auth (useIsWorkspaceAdmin.js) 1.61KB 0.85KB
collaboration (CommentThread.js) 26.07KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.65KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 498.37KB 111.05KB
core (index.js) 3.79KB 1.52KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 157.05KB 43.28KB
fields (index.js) 231.73KB 57.60KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (i18n.js) 4.28KB 1.75KB
i18n (index.js) 3.35KB 1.38KB
i18n (pickLocalized.js) 3.69KB 1.73KB
i18n (provider.js) 23.12KB 7.62KB
i18n (useDisplayLocale.js) 2.84KB 1.45KB
i18n (useObjectLabel.js) 27.59KB 6.63KB
i18n (useSafeTranslation.js) 7.77KB 3.13KB
layout (index.js) 38.86KB 10.83KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.74KB
mobile (index.js) 1.50KB 0.62KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.71KB 0.42KB
mobile (useResponsiveConfig.js) 1.36KB 0.63KB
mobile (useSpecGesture.js) 4.32KB 1.64KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 9.35KB 3.31KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 4.42KB 1.42KB
permissions (evaluator.js) 5.12KB 1.74KB
permissions (index.js) 0.91KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.52KB
permissions (usePermissions.js) 1.81KB 0.83KB
plugin-ai (index.js) 15.75KB 3.80KB
plugin-calendar (index.js) 46.62KB 12.83KB
plugin-charts (index.js) 64.75KB 18.37KB
plugin-chatbot (index.js) 181.21KB 43.14KB
plugin-dashboard (index.js) 130.85KB 33.44KB
plugin-designer (index.js) 212.39KB 42.83KB
plugin-detail (index.js) 239.81KB 59.97KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 120.43KB 29.04KB
plugin-gantt (index.js) 164.10KB 39.87KB
plugin-grid (index.js) 197.59KB 53.01KB
plugin-kanban (index.js) 52.72KB 14.54KB
plugin-list (index.js) 111.23KB 26.97KB
plugin-map (index.js) 17.91KB 5.72KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 41.70KB 11.19KB
plugin-timeline (index.js) 26.68KB 7.66KB
plugin-tree (index.js) 8.50KB 2.88KB
plugin-view (index.js) 83.81KB 20.49KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.66KB 3.50KB
providers (index.js) 0.44KB 0.22KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 3.77KB 1.33KB
react (SchemaRenderer.js) 27.53KB 9.41KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 1.28KB 0.68KB
react (schema-input.js) 1.45KB 0.83KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 4.09KB 1.74KB
sdui-parser (index.js) 4.47KB 2.03KB
sdui-parser (parse.js) 10.04KB 2.82KB
sdui-parser (types.js) 0.29KB 0.24KB
sdui-parser (validate.js) 4.69KB 1.48KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 0.99KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 0.20KB 0.18KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 0.20KB 0.18KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.87KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-retry.js) 4.32KB 2.02KB
types (index.js) 3.05KB 1.52KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 2.59KB 1.31KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (spec-report.js) 5.05KB 1.93KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 0.20KB 0.18KB
types (ui-action.js) 3.40KB 1.71KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

Copy link
Copy Markdown
Collaborator Author

PM 验收:ACCEPT(#3943,批次 16,PM 会话 session_01GTRjn8xBqp75dk7kFupVRt)

实物核验(已过):diff 与报告逐字对账(+1234/−382);标识 0;releases 0。两处声明外文件的编辑抽查为实:readme-registration-keys.test.ts ±7 是纯散文指针重指(被删 pin 的继任者),check-package-self-import.test.ts ±26 是 specimen 换成更强的生产源 JSDoc 样本(且规避了它自己刚立卡的 #4902 陷阱)—— 均为删 layout pin 的强制后果,公开披露,接受。

门的设计

  • 逐包判定表 8 包齐(5 false 包 157 可达模块零副作用、layout 数组三入口形态覆盖、2 true 包保守声明出判定面);28 测试数与派生入口形态数精确相等 —— 探针不可能被静默吞。
  • 上提 PR fix(layout): declare the load-time registration in sideEffects (#3899) #3940 的推导与真打包器探针,8 个 sideEffects:false 对照镜像常驻防空绿;「打包器当探测器」路线先测后弃(会在三个诚实包上第一天误红,测量数字写进门头)—— 判据选择有据。
  • workspace alias 入口不止卡面点名的两处:发现第三张表(packages/fields/vite.config.ts)后改为从全部 vite config 派生 alias 表,vitest 根配置的第四张表有据排除(执行不摇树)。
  • layout pin 处置:5/6 断言泛化并入,第 6 条(真源打包 + 逐键存活)作具名 specimen 原样保留,且门的防空断言点名 layout 必须仍是数组案例 —— layout 掉出判定面本身就是红。

有据扩界(接受):扫描面从字面「barrel」放宽到「barrel + 相对导入可达闭包」—— sideEffects:false 许可丢弃的是任意导出未用模块,可达集才是诚实总体,且免去按文件名挖洞;严格超集,实测绿。

反向验证:四变异,M1/M3/M4 预判逐字命中;M2 预判 3 红实测 4 红 —— 多出的红是防空集 floor 按设计触发,dev 按「欠预判」如实报告而非追认,纪律正确。中途自纠 fs.existsSyncgit ls-files(#4059 同课),并在构建后树上复验 M4。

附带产出:新 finding #4902(scripts-type-check 把注释当导入);#4540 追加现状而非立孪生卡。

CI(亲读终态):20 项全 completed,18 success + 2 skipped,零失败。

→ undraft + auto-merge (SQUASH)。


Generated by Claude Code

@yinlianghui
yinlianghui marked this pull request as ready for review August 17, 2026 04:04
@yinlianghui
yinlianghui added this pull request to the merge queue Aug 17, 2026
Merged via the queue into main with commit 49e7ea7 Aug 17, 2026
21 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-3943-sideeffects-gate branch August 17, 2026 04:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

没有任何 gate 检查 sideEffects 声明与「加载期副作用」是否一致 —— #3899 的 pin 只覆盖 layout 一个包

2 participants