Skip to content

fix(predicate): 「空谓词」不再算已声明门 —— 唯一定义下沉 core,渲染面 / SchemaRenderer / 执行门同读 (#3850, #3862) - #3966

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-3850-3862-empty-predicate-gate
Aug 9, 2026
Merged

fix(predicate): 「空谓词」不再算已声明门 —— 唯一定义下沉 core,渲染面 / SchemaRenderer / 执行门同读 (#3850, #3862)#3966
yinlianghui merged 1 commit into
mainfrom
claude/issue-3850-3862-empty-predicate-gate

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes #3850
Fixes #3862

按 2026-08-09 01:50 的 PM 裁决(#3850 评论 5229248164)三条实施,#3850#3862 一 PR 结对。

1. 语义:「已声明门」= 归一后仍有可求值条件

evaluateCondition 对「没有条件」的唯一默认是 return true(意为 visible/enabled)。这个默认在 visible / enabled 上正确,在 disabled反向 —— 同一个 true 意味着置灰。所以每一处门都必须在求值之前自己回答「有没有条件」,而这个问题此前在仓里被回答了三次、三种范围,且最宽的两种恰好落在 disabled 这个不能宽的键上:

裁决采纳第三档:''、纯空白串、{ dialect, source: '' } 空信封、以及压根不是谓词的值(0{})一律不算已声明;声明为 false 的门已声明(verdict 不是缺门,#3812)。

2. 放置:唯一定义下沉 packages/core,三处改读

  • 新增 packages/core/src/evaluator/declaredPredicate.tshasDeclaredPredicate(value) —— 与 toPredicateInput 同层(它的答案就是从归一器推出来的),经 evaluator/index.ts@object-ui/core 导出。依赖方向成立:core 是 react / components 的共同下游。
  • packages/components/src/renderers/action/visibility-gate.ts 保名 re-export:export { hasDeclaredPredicate as hasDeclaredVisibilityGate } from '@object-ui/core'。五处 member-action 落点(action:button / action:icon / action:group 的内联按钮与下拉项 / action:menu 的项)以及经 @object-ui/components barrel 读它的 DeclaredActionsBarrecord-quick-actions 零改动,自动继承新范围;barrel 导出面不变。
  • packages/react/src/SchemaRenderer.tsxdisabled / disabledOn 两条腿改读它(SchemaRenderer 的 disabled 门用第三种拼法 !== undefined:disabled: '' / disabled: null 在通用渲染路径上永久置灰 #3862 的第三拼法)。
  • packages/core/src/actions/ActionRunner.ts 删掉 module-private 的 hasDeclaredPredicate,改 import 下沉后的同名函数(PR fix(core): ActionRunner.execute 的 disabled 门改问「有没有条件」,空谓词不再拦掉执行 (#3848) #3873 注释里预告的收敛);两处门(condition / disabled)的调用点与语义不变。

⛔ 没有任何调用点新增「顺便判一下空」的本地判定 —— 那第 N 种拼法正是 #3842 / #3849 两个 PR 合掉的东西。verdict 仍读原始值,只有它前面的门收窄了。

3. 钉子(实测,非推演)

action:button 与通用路径,改动前 → 改动后:

value visible disabled enabled SchemaRenderer 的 disabled prop
'' 显示 → 显示 可点 → 可点 可点 → 可点 注入 → 不注入
null 显示 → 显示 可点 → 可点 可点 → 可点 注入 → 不注入
{ dialect: 'cel', source: '' } 显示 → 显示 置灰 → 可点 可点 → 可点 注入 → 不注入
{ source: '' }(无 dialect) 显示 → 显示 置灰 → 可点 可点 → 可点 注入 → 不注入
' '(纯空白) 隐藏 → 显示 可点 → 可点 置灰 → 可点 注入 → 不注入
0 / {}(非谓词) 显示 → 显示 置灰 → 可点 可点 → 可点 注入 → 不注入
true / false / 裸 CEL / ${…} / 非空信封 不变 不变 不变 不变

#3850 表格里那两行「残留」(两种空信封)转绿;#3862disabled: '' / disabled: null 转绿,且 _disabled 转发链在空拼法上不再注入 disabled prop(钉子直接读组件收到的 prop,区分「没收到」与 disabled={false})。

两处不是等价而是行为变更,如实钉住而不是包装成等价:

  • 纯空白串visible / enabled 上会动(裁决第 3 条的钉子①原文说三种空拼法在 visible 侧「行为不变」,这一行不成立)。机理:' ' 被归一器包成 '${ }'(它是归一器唯一不折叠的空形状),求值出 falsy,于是一个什么都没说的谓词把动作从所有人面前藏起来、并通过取反的 enabled 腿把按钮置灰。改后落在「没有门」= 显示 / 可点,方向与 visibility-gate 自己文档一直声称的不变量一致(「空谓词不该把动作藏起来」),但它确实是行为变更。
  • 非谓词值(0 / {})不再置灰(fail-open),与 ActionRunner 早已承诺的 catch { isDisabled = false } 同向。

SchemaRenderervisible / visibleWhen / visibleOn / visibility 四条腿故意不动:它们的 true 被取反,空谓词本来就落在「显示」,收窄只会改别名优先级而不修任何东西。

反向验证(先预判方向,再跑)

把下沉定义临时改回历史窄范围(value != null && value !== '',不提交),预判:只有本 PR 新增 / 更新的四个 suite 翻红,既有 suite 全绿(它们只拼 '' 与布尔,对信封不敏感 —— 这正是残留能活过 #3842 的原因)。实测 32 个用例红,分布与预判逐条相符:

❯ packages/components/.../action-empty-predicate-scope.test.tsx   (35 tests | 10 failed)
❯ packages/react/.../SchemaRenderer.disabledDeclaredGate.test.tsx (27 tests |  8 failed)
❯ packages/core/.../declaredPredicate.test.ts                     (23 tests | 10 failed)
❯ packages/core/.../ActionRunner.disabledGate.test.ts             (20 tests |  4 failed)

其余 209 个 test file 全绿,ActionRunner.conditionGate.test.ts 亦全绿(它的空拼法行在两种范围下都落在「执行」,方向上不构成检测器 —— 这一点也如实记录)。

fixture 逐条重判(不是批量改拼法)

ActionRunner.disabledGate.test.ts 的 parity 表里有三行原本标着「与渲染面已知分叉,owner = #3850」(空信封、0{})。本 PR 让渲染面读同一个定义,这三行变成可主张 parity,所以逐行改判为 rendererDisabled: false 并删掉 divergence 说明。原先那条「记录哪些行仍分叉」的用例在改判后会断言一个空数组 —— 绿是因为什么都没产生,不是因为逻辑对 —— 所以整条替换成更强的主张:表里每一行都参与 parity 比较(claimed.length === SHAPES.length),且 divergence 逃生口仍被检查(将来重新引入分叉但不写 owner 的行会在这里红)。

范围外发现(只立卡,不在本 PR 修)

量这条链时顺手量到三处同族缺陷,全部未认领、按「附着而非散落」立成独立卡:

验证

  • 构建:pnpm --workspace-concurrency=2 --filter '@object-ui/components...' build(含 core / react 依赖闭包)—— 通过。
  • 测试(仓根,不用 --filter):pnpm exec vitest run packages/core packages/components packages/react --maxWorkers=2213 files / 2921 tests 全绿
  • 消费半径另跑(它们经 barrel 读 hasDeclaredVisibilityGate):pnpm exec vitest run packages/app-shell/src/views/__tests__/DeclaredActionsBar.test.tsx packages/plugin-detail/src/renderers/__tests__ --maxWorkers=29 files / 106 tests 全绿。这一步是必要的:DeclaredActionsBar.test.tsxvi.mock 深导入 visibility-gate 模块以避开 components barrel,而该模块现在改成从 @object-ui/core re-export,解析面变了。两处 fixture 只拼 ''(在新旧范围下都不算门),无需重判。
  • pnpm exec turbo run type-check --concurrency=278 / 78 successful
  • node scripts/check-control-bytes.mjs,以及对本 PR 全部文件的越界自查 grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f]'(gate 不扫的那几个字节)。

changeset

.changeset/empty-predicate-declared-gate-3850-3862.md,@object-ui/core / @object-ui/components / @object-ui/react 三个包 patch。依据:①按 AGENTS.md 版本号策略,fixed 组内不声明 major;②本 PR 的头条是缺陷修复(#3850 / #3862 都是 bug),不是新能力;③core 新增一个公开导出属增量 API,同族先例 .changeset/components-export-declared-visibility-gate-3835.md(「Export hasDeclaredVisibilityGate from the package barrel」)同样按 patch 发。行为变更的两行(纯空白、非谓词值)在 changeset 正文里逐行写明。


Generated by Claude Code

…ere (#3850, #3862)

「这是不是一个已声明的门」在仓里被回答了三次、三种范围,而最宽的两种恰好落在
`disabled` 这个不能宽的键上 —— `evaluateCondition` 对「没有条件」的唯一默认是
`true`(意为 visible/enabled),在 `visible` 上它意味着显示、与「没有门」同结果,
在 `disabled` 上它意味着置灰,于是过宽的「已声明」判定与宽容的空谓词不再互相
抵消而是叠加。

- 动作面 `hasDeclaredVisibilityGate` 问 `!= null && !== ''`,任何对象都算已声明,
  包括 `{ dialect: 'cel', source: '' }` —— 而这正是 `ExpressionInputSchema` 对留空
  谓词归一出的形状,也是 `objectstack build` 编译出的形状 → 永久置灰(#3850)。
- `SchemaRenderer` 内联问 `disabled !== undefined`,再宽一格,`disabled: null` 也
  置灰,且是通用路径(该块不带 type 判定),`_disabled` 还会被当成真的 `disabled`
  prop 转发给组件(#3862)。
- `ActionRunner` 的 module-private helper 问「归一后还有东西可求值吗」—— 事后证明
  这是对的那一档(#3848 / #3872)。

裁决(#3850 评论 5229248164)采纳第三档并把唯一定义下沉:

- 新增 `@object-ui/core` 的 `hasDeclaredPredicate`(`evaluator/declaredPredicate.ts`,
  与 `toPredicateInput` 同层,答案由它推出)。
- `components/renderers/action/visibility-gate.ts` 保名 re-export,五处 member-action
  落点与经 barrel 读它的 `DeclaredActionsBar` / `record-quick-actions` 零改动。
- `SchemaRenderer` 的 `disabled` / `disabledOn` 两条腿改读它;`ActionRunner` 删掉
  module-private 孪生改读同一个函数,两处门的调用点与语义不变。

verdict 仍读原始值,只有它前面的门收窄了;没有任何调用点新增本地「顺便判空」。
两处不是等价而是行为变更,已逐行钉住:纯空白串在 `visible` / `enabled` 上从
隐藏/置灰变为显示/可点(它被归一器包成 `'${   }'` 求值出 falsy,一个什么都没说的
谓词把动作藏了起来),非谓词值(`0` / `{}`)不再置灰(fail-open,与
`catch { isDisabled = false }` 同向)。

`SchemaRenderer` 的 `visible` 四条腿故意保持 `!== undefined`(取反后空谓词本就落在
显示,收窄只会改别名优先级);`hidden` / `hiddenOn` 不取反因而带同族缺陷(节点消失),
已立 #3955;「空」的第四种拼法 `{ dialect: 'cel', source: '   ' }` 仍算已声明,已立
#3960 并在单元 suite 里钉成 documented residue;`ActionEngine` 是收敛后最后一处自带
范围的消费者,已立 #3957。

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GTRjn8xBqp75dk7kFupVRt
@vercel

vercel Bot commented Aug 9, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectui Ignored Ignored Aug 9, 2026 6:09pm

Request Review

@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

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

📦 Bundle Size Report

Package Size Gzipped
app-shell (index.js) 8.66KB 3.13KB
app-shell (runtime-config.js) 7.42KB 2.32KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 7.57KB 2.97KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 1.17KB 0.53KB
auth (AuthProvider.js) 22.10KB 4.37KB
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) 35.76KB 9.11KB
auth (createAuthenticatedFetch.js) 4.37KB 1.69KB
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) 4.91KB 0.87KB
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) 482.58KB 106.41KB
core (index.js) 3.00KB 1.14KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 139.61KB 35.99KB
fields (index.js) 227.45KB 56.31KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (i18n.js) 4.32KB 1.77KB
i18n (index.js) 2.65KB 1.06KB
i18n (pickLocalized.js) 1.70KB 0.83KB
i18n (provider.js) 9.48KB 3.27KB
i18n (useObjectLabel.js) 27.59KB 6.63KB
i18n (useSafeTranslation.js) 4.52KB 1.96KB
layout (index.js) 38.53KB 10.71KB
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) 8.75KB 3.06KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 3.67KB 1.12KB
permissions (evaluator.js) 4.41KB 1.44KB
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.55KB 0.71KB
plugin-ai (index.js) 15.71KB 3.79KB
plugin-calendar (index.js) 44.98KB 12.37KB
plugin-charts (index.js) 61.17KB 17.35KB
plugin-chatbot (index.js) 180.33KB 42.79KB
plugin-dashboard (index.js) 118.02KB 30.47KB
plugin-designer (index.js) 210.51KB 42.51KB
plugin-detail (index.js) 236.63KB 59.02KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 112.10KB 27.10KB
plugin-gantt (index.js) 162.55KB 39.57KB
plugin-grid (index.js) 187.63KB 49.66KB
plugin-kanban (index.js) 48.30KB 13.28KB
plugin-list (index.js) 109.73KB 26.55KB
plugin-map (index.js) 16.81KB 5.24KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 40.58KB 10.58KB
plugin-timeline (index.js) 25.76KB 7.33KB
plugin-tree (index.js) 8.50KB 2.88KB
plugin-view (index.js) 84.03KB 20.55KB
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.71KB 3.53KB
providers (index.js) 0.44KB 0.22KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.67KB 2.37KB
react (LazyPluginLoader.js) 3.77KB 1.33KB
react (SchemaRenderer.js) 22.31KB 7.59KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 1.02KB 0.55KB
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 (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) 2.71KB 1.34KB
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 验收(session_01GTRjn8xBqp75dk7kFupVRt):通过,转 ready 并挂 auto-merge。#3850 + #3862 结对落地。

核验记录(head d3e612758,基 65bb513dc,实物核验 + CI 亲读):

  1. 裁决三条全部落地(实物 grep 确认):hasDeclaredPredicate 下沉 packages/core/src/evaluator/declaredPredicate.ts(与 toPredicateInput 同层,文档注释把范围出处写全);visibility-gate.ts 保名 re-export(五处落点零改动);SchemaRenderer 的 disabled/disabledOn 与 ActionRunner 的 module-private helper 均改读唯一定义;零新增本地拼法。
  2. 裁决钉①偏差的处置正确,PM 确认:我的裁决评论写「三种空拼法在 visible 侧行为不变」,dev 实测纯空白一行不成立(visible: ' ' 从隐藏变显示)—— 这是我裁决措辞的缺陷,不是 dev 的:裁决第 1 条既然把纯空白列为「未声明」,visible 侧变为显示正是该语义的直接后果。dev 拒绝硬套等价钉、如实钉成 CHANGED 用例并写进 changeset —— 完全正确。非谓词值 0/{} 的同向变更同样已披露并钉住。
  3. 钉子覆盖:「空谓词」在三处有三种范围:disabled: { dialect: 'cel', source: '' } 仍被判成已声明的门 → 永久置灰(#3842 修完后的残留,需先裁) #3850 正文表格逐行 + 超出表格的 11 类值 + _disabled 转发链(区分 absent 与 false);第四种拼法 {dialect:'cel',source:' '} 按裁决枚举边界未扩围,钉成 documented residue 并立 「空谓词」的第四种拼法:{ dialect: 'cel', source: ' ' }(source 只有空白)仍被判成已声明门 → disabled 侧仍永久置灰、执行入口仍拒执行(#3850 裁决枚举未覆盖) #3960 —— 边界纪律正确。
  4. 反向验证:预判「窄范围变异只翻红本 PR 四个 suite」,实测 32 红精确分布、209 文件绿逐条相符;conditionGate 面「不构成检测器」如实记录并为组合腿补了真实检测器。
  5. 测试与门:213 files / 2921 tests 绿 + 消费半径(DeclaredActionsBar 深导入 mock 解析面、plugin-detail)106 tests 绿;type-check 78/78;控制字节门 + 盲区自查零命中;三包 patch changeset 论证成立(同族先例 app-shell/DeclaredActionsBar: 服务端声明动作的真值可见性门实证可达 —— visible: false 照样渲染(#3823 消费半径清扫发现的同族第五处) #3835);提交文案控制词 0;⛔ releases/ 未触碰。
  6. CI 亲读终态:20/20 全 completed、0 失败(Test shard×4 至 18:15:01Z、Type Check 18:15:00Z;coverage/dependabot skipped 计绿)。基落后 main 数合无冲突面,交合并队列重算。

衍生三单 #3955/#3960/#3957 已分诊晋级、方向已代裁,本 PR 合入后按三单结对一 PR 派出。


Generated by Claude Code

@yinlianghui
yinlianghui marked this pull request as ready for review August 9, 2026 18:24
@yinlianghui
yinlianghui added this pull request to the merge queue Aug 9, 2026
Merged via the queue into main with commit ab3ad4f Aug 9, 2026
21 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-3850-3862-empty-predicate-gate branch August 9, 2026 18:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment