Skip to content

fix(scripts): teach check-side-effects-array to see a call in a top-level initializer - #8851

Merged
baozhoutao merged 2 commits into
mainfrom
claude/issue-8578-side-effects-const-initializer
Sep 9, 2026
Merged

baozhoutao merged 2 commits into
mainfrom
claude/issue-8578-side-effects-const-initializer

Conversation

@baozhoutao

Copy link
Copy Markdown
Contributor

Fixes #8578

classifyEffect sorted a module's top-level effects by looking for a call
STATEMENT, so export const X = f(...) — one statement that declares a binding
AND performs an effect, and that a bundler drops whole — was filed as "declares
a constant, does nothing". @object-ui/types'
AnyComponentSchema = defineNodeComponentUnion(...) is the live shape: the
initializer writes the node recursion point's option slot into base.zod.ts,
and the gate that exists to enumerate exactly those effects could not see it.

The recognition rule, and why it is not "any call in an initializer"

The opposite failure is the expensive one, and it was measured rather than
argued. Reading ANY call in a top-level initializer as a registration takes
@object-ui/app-shell from 14 registering modules to 122 — new Set([...]),
React.createContext(...), new RegExp(...) and React.lazy(...) are calls
that produce the binding's VALUE and nothing else, and naming their modules
spends exactly the consumer bytes the array exists to save.

So an initializer registers only when all three hold:

  1. the call is evaluated NOW — the walk stops at every function boundary, so
    a closure that is RETURNED is not a load-time effect. withSettleSignal in
    app-shell increments a module counter inside the wrapper it returns, and it
    is the ONE site a coarser rule flagged workspace-wide. A function handed as
    an ARGUMENT to a call being made now IS walked, because that call may invoke
    it now.
  2. the callee resolves inside this package, through relative imports and
    re-exports only.
  3. that callee writes to a binding it did not itself declare — the
    local-binding-write argument applied one level in, to the callee's own
    scope: anything it did not declare outlives the call and is observable by
    someone other than the dropped module.

The header now states this in both directions, including the two things a zero
from this gate still does not mean (a call into another package is read as
value-producing; only the source barrel's graph is walked).

False-positive tail — measured before shipping

Scanned every .ts/.tsx/.mts/.js/.jsx/.mjs source under packages/ and apps/
(tests, dist/ and node_modules/ excluded) with the widened classifier:

source files scanned: 1623
initializer-registrations: 1 site(s) in 1 module(s)
packages/types/src/zod/index.zod.ts:438  export const AnyComponentSchema = defineNodeComponentUnion(z.discriminatedUnion('type', [

One flagged declaration, and it is the true positive the card names — out of 88
const-initializer call sites in that one file. No package changes verdict.
node scripts/check-side-effects-array.mjs --list is byte-identical before and
after, and the gate stays green:

✅ @object-ui/app-shell: `sideEffects` names exactly the 14 module(s) that register at load time, plus its entry forms (31 entries, 449 modules walked).
✅ @object-ui/layout: `sideEffects` names exactly the 1 module(s) that register at load time, plus its entry forms (3 entries, 8 modules walked).

⚠️ Two corrections to the card's reading — neither is acted on here

  1. The card's headline number has a second, independent cause.
    @object-ui/types scores zero not only because of the classifier, but
    because evaluatePackage walks only the src/index.* graph, and
    src/zod/index.zod.ts is not reachable from packages/types/src/index.ts.
    Even with this fix the package-level count is unchanged; pointing
    scanModule straight at the file does report the registration. Filed as
    check-side-effects-array walks only the SOURCE BARREL's graph, so a registrar reachable only from a secondary entry form is outside the enumeration #8850, ⛔ not fixed here.
  2. @object-ui/types declares sideEffects: false today, so it is not in
    this gate's population at all (readArrayPackages takes ARRAY declarations
    only). Whether it should be is The node-recursion fill is tree-shaken away for bundled consumers — closing it needs a maintainer-floor census edit #8577 / spec(types): redirect the node recursion point from BaseSchemaCore to AnyComponentSchema — measured at 9 newly-refused documents, and it drops 118 phantom strict refusals #8344, ⛔ untouched here — no
    package's sideEffects array is edited by this PR.

⭐ On #8344. Its decision batch #93 refused the "narrow sideEffects to the
zod barrel" route citing this card verbatim — "its detector cannot see a
const-initializer call — so A is not implementable as spelled without
weakening a gate."
That premise has moved: the detector can now see that
shape, and seeing it costs no gate. ⛔ This PR does not re-open #8344 and takes
no position on it; correction 1 above is the thing whoever re-prices that route
needs to read first, because the detector is no longer the only obstacle.

Tests

Both directions, per this suite's own rule that every assertion needs a partner
that makes the same fixture fail:

  • callee writes a binding it did not declare → registration; callee only
    computes the value → nothing.
  • write inside a RETURNED closure (the withSettleSignal shape) → nothing;
    callback handed to a call being made now → registration.
  • z.object({}), new Set([1, 2]), React.createContext(null) → nothing.
  • an arrow-valued const whose body calls register() → nothing.
  • workspace-level: a barrel whose const initializer calls a writer one relative
    import away must be NAMED in the array; identical fixture with a non-writing
    callee must not.
 Test Files  3 passed (3)
      Tests  78 passed (78)

(scripts/__tests__/check-side-effects-array.test.ts,
check-sdui-registration-pins.test.ts,
side-effects-declaration-consistency.test.ts — the three test files in the tree
that name the changed module.)

Ablation, from the committed state, with the mutation proven on disk
(HEAD blob fa73bcb1 → mutated 725e3857) and a byte-identical restore
(git diff HEAD empty, git hash-object back to fa73bcb1): commenting out the
one new classifyEffect branch turns 3 tests red (the two positive-direction
unit tests and the workspace-level one); the negative-direction partners stay
green, which is what they should do.

Other gates at 54491c3b0: type-check:scripts exit 0 · check-control-bytes
OK (7,041 files) · check-changeset-presence exit 0, "No source or published
contract of a released package changed in this range, so no changeset is owed"

· eslint over the two changed files 0 errors / 0 warnings (population 4,656
files; type-aware linting is not configured, so this diff cannot move the
verdict on any untouched file).

维护者速读(草稿)

改了什么 — 只改了一个内部门禁脚本 scripts/check-side-effects-array.mjs
和它的测试。让它能看见"写在顶层 const 初始化器里的加载期副作用",这类副作用
以前被静默判成"什么也没做"。

为什么改 — sideEffects 数组是发布给所有下游打包器的承诺;数组漏掉一个
模块,下游会静默丢掉一次注册,没有报错、没有警告、退出码 0。这个门禁就是为了
挡住这种静默失败,而它自己有一个盲区。

风险与代价(含回滚) — 放宽判定的反向风险是把纯声明误判成注册,那会让数组
被迫写满整个包、把这个数组省下来的字节全花掉。所以先做了全量测量:全仓 1,623
个源文件只命中 1 条,就是要找的那一条,两个数组包的结论一字未变。不改任何包的
sideEffects,不动发布产物,无需 changeset。回滚 = revert 这两个 commit,门禁
回到旧行为。

席位意见 — (留空)

你要做的 — 确认两点是否同意:① 对"调用进另一个包"(z.object、
React.createContext)一律读作产值不读作注册,这条边界写进了文件头;
② #8344 那条被本卡挡住的路线,前提已经变了,但本 PR 不去动它,重新定价留给
持有那张卡的人 —— 另外还有 #8850 是更关键的一半。


🤖 Generated with Claude Code

https://claude.ai/code/session_01FhBNJcLRZLe8M87VcUgpKr


Generated by Claude Code

…evel initializer

`classifyEffect` sorted a module's top-level effects by looking for a call
STATEMENT, so `export const X = f(...)` — a binding declared AND an effect
performed in one statement a bundler drops whole — was filed as "declares a
constant, does nothing". `@object-ui/types`'
`AnyComponentSchema = defineNodeComponentUnion(...)` is the live shape: the
initializer writes the node recursion point's option slot into `base.zod.ts`,
and the gate that exists to enumerate exactly those effects could not see it.

The widening is bounded by measurement, not by taste. Reading ANY call in an
initializer as a registration takes `@object-ui/app-shell` from 14 registering
modules to 122 — `new Set([...])`, `React.createContext(...)`, `new RegExp(...)`
produce the binding's value and nothing else, and naming their modules spends
exactly the consumer bytes the array exists to save. So an initializer registers
only when the call is evaluated NOW (the walk stops at function boundaries, so a
returned closure is not load-time), its callee resolves inside this package
through relative imports, and that callee writes to a binding it did not itself
declare — the `local-binding-write` argument applied to the callee's scope.

Measured over 1,623 source files in `packages/` and `apps/`: one declaration is
flagged, the true positive above. No package's verdict changes.

The header now states the recognition rule in both directions, including the two
things a zero from this gate still does not mean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FhBNJcLRZLe8M87VcUgpKr
The header's list of what a zero from this gate still does not mean said the
secondary-entry blindness was "tracked separately" without saying where. It is
objectui#8850.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FhBNJcLRZLe8M87VcUgpKr
@github-actions github-actions Bot added the tests label Sep 9, 2026
@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 50 chunks) 3485.4 KB 3512.7 KB
Main entry chunk (gzip) 144.1 KB 350 KB
Entry file index-DabQzI0B.js —
Status PASS —

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 16.69KB 6.21KB
app-shell (runtime-config.js) 20.68KB 7.36KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (ActiveOrganizationStorage.js) 25.05KB 9.16KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.18KB 10.59KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.41KB 1.23KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 8.46KB 3.43KB
auth (index.js) 3.19KB 1.44KB
auth (invitation-status.js) 1.22KB 0.70KB
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.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 11.08KB 4.58KB
collaboration (CommentThread.js) 26.08KB 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.68KB 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) 500.00KB 114.54KB
core (index.js) 7.48KB 2.96KB
create-plugin (index.js) 26.68KB 8.94KB
data-objectstack (index.js) 198.39KB 55.29KB
fields (index.js) 246.35KB 62.12KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 1.22KB 0.64KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 6.57KB 2.76KB
i18n (index.js) 3.65KB 1.47KB
i18n (pickLocalized.js) 7.62KB 3.26KB
i18n (provider.js) 26.89KB 9.04KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 34.34KB 9.17KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 38.84KB 10.94KB
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.75KB
mobile (index.js) 1.99KB 0.87KB
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.72KB 0.42KB
mobile (useSpecGesture.js) 4.39KB 1.66KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 13.52KB 4.88KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.24KB 2.16KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 8.39KB 3.10KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 14.81KB 3.63KB
plugin-calendar (index.js) 49.03KB 13.93KB
plugin-charts (index.js) 71.39KB 19.92KB
plugin-chatbot (index.js) 194.54KB 46.34KB
plugin-dashboard (index.js) 131.43KB 34.44KB
plugin-designer (index.js) 215.51KB 44.29KB
plugin-detail (index.js) 252.45KB 65.33KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 134.25KB 33.52KB
plugin-gantt (index.js) 167.16KB 40.99KB
plugin-grid (index.js) 210.67KB 57.19KB
plugin-kanban (index.js) 56.50KB 16.08KB
plugin-list (index.js) 112.73KB 27.69KB
plugin-map (index.js) 20.49KB 6.83KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 43.42KB 11.92KB
plugin-timeline (index.js) 30.10KB 8.74KB
plugin-tree (index.js) 9.55KB 3.32KB
plugin-view (index.js) 84.54KB 20.84KB
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.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 81.07KB 26.86KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.63KB 2.18KB
react (schema-input.js) 2.32KB 1.24KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 6.58KB 2.74KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 5.55KB 2.45KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 20.57KB 5.88KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 13.64KB 4.59KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 1.00KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.93KB 1.49KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (expression.js) 0.20KB 0.18KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.74KB 2.25KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 4.73KB 2.28KB
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 (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (strict-authoring-face.js) 14.27KB 5.47KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.11KB 3.32KB
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

@baozhoutao
baozhoutao marked this pull request as ready for review September 9, 2026 14:28
@baozhoutao
baozhoutao added this pull request to the merge queue Sep 9, 2026
Merged via the queue into main with commit 316cee0 Sep 9, 2026
33 checks passed
@baozhoutao
baozhoutao deleted the claude/issue-8578-side-effects-const-initializer branch September 9, 2026 14:56
@claude

claude Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

⭐ LANDED — merged 316cee018 at 2026-09-09T14:56:15Z

Probe run verbatim from the Armed note (5603562650); full reading on the card: #8578 (comment)

⭐⭐ The load-bearing leg was run, not inherited: --list on the landed tree and --list with the pre-merge blob of the gate against that same tree both read @object-ui/app-shell — 14 module(s) with a top-level registration, 449 walked, exit 0, with identical module lists. ⇒ the widening changes the classifier and no verdict — measured with the old gate as the control rather than taken from the report.

⛔ The probe deliberately does not assert that @object-ui/types now scores non-zero. It does not and should not: objectui#8850 is the second, unfixed cause, and a leg asserting non-zero would fail on correct code. The gate's header now says so itself at :133.


Generated by Claude Code

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.

check-side-effects-array cannot see a load-time effect written inside a const initializer, and scores @object-ui/types zero

2 participants