fix(predicate): 「空谓词」不再算已声明门 —— 唯一定义下沉 core,渲染面 / SchemaRenderer / 执行门同读 (#3850, #3862) - #3966
Merged
Merged
Conversation
…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
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
Contributor
✅ Console Performance Budget
📦 Bundle Size Report
Size Limits
|
This was referenced Aug 9, 2026
Collaborator
Author
|
PM 验收(session_01GTRjn8xBqp75dk7kFupVRt):通过,转 ready 并挂 auto-merge。#3850 + #3862 结对落地。 核验记录(head
衍生三单 #3955/#3960/#3957 已分诊晋级、方向已代裁,本 PR 合入后按三单结对一 PR 派出。 Generated by Claude Code |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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这个不能宽的键上:hasDeclaredVisibilityGate问!= null && !== '',于是任何对象都算已声明 —— 包括{ dialect: 'cel', source: '' }。这不是手写才有的拼法:@objectstack/spec的ExpressionInputSchema把任何已授权谓词归一成信封,所以「作者把谓词留空」编译出来正好是它。求值侧把同一个值归一回undefined,evaluateCondition(undefined)答true→ 置灰,永久,界面上与元数据本意无法区分(「空谓词」在三处有三种范围:disabled: { dialect: 'cel', source: '' } 仍被判成已声明的门 → 永久置灰(#3842 修完后的残留,需先裁) #3850,动作disabled的「已声明」判定用!= null,disabled: ''把按钮永久置灰(#3492 同族的另一半 predicate,探针实证) #3842 修完后的残留)。SchemaRenderer内联问disabled !== undefined,再宽一格,于是disabled: null也置灰 —— 而且是通用路径(该块在evaluatedSchema的useMemo里不带 type 判定,覆盖全部节点),且_disabled不是内部标记:它被disabled: __disabled || undefined交给组件,真的置灰(SchemaRenderer 的 disabled 门用第三种拼法 !== undefined:disabled: '' / disabled: null 在通用渲染路径上永久置灰 #3862)。ActionRunner的 module-private helper 问「归一后还有东西可求值吗」—— 事后证明这就是对的那一档(ActionRunner.execute 的 disabled 门把「空谓词」当已禁用,拦掉执行(实测 handler 不跑),且与渲染器判定不一致 #3848 / ActionRunner.execute 的 condition 门用真值判定:condition: false(最明确的「永不执行」)照样执行(实测 handler 跑了) #3872)。裁决采纳第三档:
''、纯空白串、{ dialect, source: '' }空信封、以及压根不是谓词的值(0、{})一律不算已声明;声明为false的门算已声明(verdict 不是缺门,#3812)。2. 放置:唯一定义下沉 packages/core,三处改读
packages/core/src/evaluator/declaredPredicate.ts的hasDeclaredPredicate(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/componentsbarrel 读它的DeclaredActionsBar、record-quick-actions零改动,自动继承新范围;barrel 导出面不变。packages/react/src/SchemaRenderer.tsx的disabled/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与通用路径,改动前 → 改动后:visibledisabledenableddisabledprop''null{ dialect: 'cel', source: '' }{ source: '' }(无 dialect)' '(纯空白)0/{}(非谓词)true/false/ 裸 CEL /${…}/ 非空信封#3850 表格里那两行「残留」(两种空信封)转绿;#3862 的
disabled: ''/disabled: null转绿,且_disabled转发链在空拼法上不再注入disabledprop(钉子直接读组件收到的 prop,区分「没收到」与disabled={false})。两处不是等价而是行为变更,如实钉住而不是包装成等价:
visible/enabled上会动(裁决第 3 条的钉子①原文说三种空拼法在visible侧「行为不变」,这一行不成立)。机理:' '被归一器包成'${ }'(它是归一器唯一不折叠的空形状),求值出 falsy,于是一个什么都没说的谓词把动作从所有人面前藏起来、并通过取反的enabled腿把按钮置灰。改后落在「没有门」= 显示 / 可点,方向与visibility-gate自己文档一直声称的不变量一致(「空谓词不该把动作藏起来」),但它确实是行为变更。0/{})不再置灰(fail-open),与ActionRunner早已承诺的catch { isDisabled = false }同向。SchemaRenderer的visible/visibleWhen/visibleOn/visibility四条腿故意不动:它们的true被取反,空谓词本来就落在「显示」,收窄只会改别名优先级而不修任何东西。反向验证(先预判方向,再跑)
把下沉定义临时改回历史窄范围(
value != null && value !== '',不提交),预判:只有本 PR 新增 / 更新的四个 suite 翻红,既有 suite 全绿(它们只拼''与布尔,对信封不敏感 —— 这正是残留能活过 #3842 的原因)。实测 32 个用例红,分布与预判逐条相符:其余 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 修)
量这条链时顺手量到三处同族缺陷,全部未认领、按「附着而非散落」立成独立卡:
useMemo里hidden/hiddenOn两条腿不取反,所以同一个「空谓词 →true」在那里意味着 HIDE:hidden: ''/null/' '/ 空信封让节点在通用路径上整个消失。SchemaRenderer 的 disabled 门用第三种拼法 !== undefined:disabled: '' / disabled: null 在通用渲染路径上永久置灰 #3862 的反极性孪生,后果比置灰更难诊断。本 PR 把它作为 documented divergence 钉在 SchemaRenderer 的 suite 里(修好时那条用例会红,即为搬行信号)。{ dialect: 'cel', source: ' ' }(source 只有空白)。归一器对信封只判if (!src)而不 trim,所以它仍算已声明 →disabled侧仍永久置灰、执行入口仍拒执行,而 core 自己的evaluateCelCondition第一行if (!source.trim()) return true已经把它叫作「没有谓词」。裁决显式枚举了三种拼法,把第四种并进来会同时改到 ActionRunner.execute 的 disabled 门把「空谓词」当已禁用,拦掉执行(实测 handler 不跑),且与渲染器判定不一致 #3848 已合入并钉住的执行门行为 —— 那是 PM 该裁的一步,故立卡不顺手改;共享定义的这处不对称(trim 字符串、不 trim 信封 source)在单元 suite 里钉成 documented residue。finding)—— 收敛之后ActionEngine.getActionsForLocation的visible过滤是最后一处自带范围的消费者:非对象非谓词值保留Boolean(raw)强转、纯空白串走求值,于是visible: 0/NaN/' '在引擎面隐藏、在渲染面显示。其中' '一行是本 PR 造成的新分叉(改前两面都隐藏),已在卡里如实写明。验证
pnpm --workspace-concurrency=2 --filter '@object-ui/components...' build(含 core / react 依赖闭包)—— 通过。--filter):pnpm exec vitest run packages/core packages/components packages/react --maxWorkers=2→ 213 files / 2921 tests 全绿。hasDeclaredVisibilityGate):pnpm exec vitest run packages/app-shell/src/views/__tests__/DeclaredActionsBar.test.tsx packages/plugin-detail/src/renderers/__tests__ --maxWorkers=2→ 9 files / 106 tests 全绿。这一步是必要的:DeclaredActionsBar.test.tsx用vi.mock深导入visibility-gate模块以避开 components barrel,而该模块现在改成从@object-ui/corere-export,解析面变了。两处 fixture 只拼''(在新旧范围下都不算门),无需重判。pnpm exec turbo run type-check --concurrency=2→ 78 / 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(「ExporthasDeclaredVisibilityGatefrom the package barrel」)同样按patch发。行为变更的两行(纯空白、非谓词值)在 changeset 正文里逐行写明。Generated by Claude Code