Repository navigation
排查「手抄 spec 清单 + "keep in sync" 注释」模式:objectui 侧六处,目前全部一致但零闸门(framework#3786) #3017
Description
承 framework#3786 —— 该 issue 点名 objectui 是头号嫌疑(「它消费 spec 的类型/枚举清单做渲染分发,而本轮没有覆盖它」)。framework 侧的排查已完成并落在 objectstack#4120:packages/spec 的 17 份元数据表单里查出四份已漂移、全部静默(表单绑到不存在的键 → 值被 .strict() 缺席的 schema 静默丢弃,作者看不到任何报错)。
本 issue 记录 objectui 侧的扫描结果。
结论先行:六处候选,目前全部内容一致 —— 没有查到活的漂移
这不是「没问题」,而是「还没轮到」。framework 那四例证明了这套写法的失效方式是静默的:漂移发生时没有任何信号,只有用户发现某个能力凭空消失。下面六处每一处都是同样的结构 —— 两份清单、一句注释、零机制。
| # | 位置 | 对照对象 | 现状 |
|---|---|---|---|
| 1 | app-shell/…/inspectors/FlowReferenceField.tsx KIND_TO_RECORD_LOOKUP |
spec APPROVER_VALUE_SOURCES |
✅ 一致(4 项 object/valueField 全同) |
| 2 | app-shell/…/inspectors/ActionDefaultInspector.tsx LOCATIONS |
spec ACTION_LOCATIONS |
✅ 一致(7 值) |
| 3 | app-shell/…/previews/object-fields-bridge.ts DESIGNER_TYPES |
FieldDesigner 的 FIELD_TYPE_META |
✅ 一致(26 型) |
| 4 | plugin-detail/src/RecordMetaFooter.tsx AUDIT_FIELDS |
RecordDetailView 的 AUDIT_FIELD_NAMES |
✅ 一致(4 项) |
| 5 | plugin-grid/src/ImportWizard.tsx BOOLEAN_IMPORT_TOKENS |
服务端 import-coerce.ts 的 BOOL_TRUE/BOOL_FALSE |
✅ 一致(20 token) |
| 6 | plugin-grid/src/ImportWizard.tsx REFERENCE_IMPORT_TYPES |
服务端 REFERENCE_TYPES |
✅ 一致(5 型) |
最扎眼的一点:#1 和 #2 抄的是「spec 已经为此导出的常量」
这两处不需要闸门,直接删掉第二份清单即可 —— 单一来源已经在那儿了:
-
ACTION_LOCATIONS是 spec 的导出常量。objectui 重述了它的 7 个值(只是顺序不同)再配上友好 label。改法:import { ACTION_LOCATIONS } from '@objectstack/spec/ui',label 用一张Record<ActionLocation, string>映射 —— 这样漏掉新 location 会变成类型错误,而不是一个悄悄缺席的下拉项。 -
APPROVER_VALUE_SOURCES更值得一提:spec 里这个投影就是为了让 objectui 别再自己推导而专门发布的,它的文档注释白纸黑字写着这段历史 ——「
xRef.map只说了渲染哪种 picker,从没说候选来自哪里。所以设计器不得不自带一份数据契约,而第一份抄错了:每个目录类 kind 都被接到了/api/v1/meta/:type(元数据注册表),而它根本不存放sys_user/sys_team/… 的行。候选返回空,控件退化成自由文本框(framework docs(ci): live-e2e allowlist 改为指向 package.json,不再手抄 spec 名单 #3508)。…… 发布这个 binding 就是在源头堵住这个缺口:渲染器直接读该查哪个 object、该提交哪个列,而不是重新推导一遍。」spec 侧还用
satisfies保证了新增ApproverType不声明 binding 就是编译错误。但 objectui 至今仍在手工维护KIND_TO_RECORD_LOOKUP—— 注释写的是「mirrors the spec'sAPPROVER_VALUE_SOURCES」,而它 mirror 的那个东西,本来就是造出来给人 import 的。spec 那侧的编译期保证,一步都没传导过来。⚠️ 注意 docs(ci): live-e2e allowlist 改为指向 package.json,不再手抄 spec 名单 #3508 那次翻车正是这一处:sales_manager被打进了只接受三个值的字段。这处的历史故障率不是零。
其余四处的改法
- Add public roadmap, VitePress documentation site, and GitHub Pages deployment #3 / Add default props to all components to prevent collapse in designer #4 是 objectui 内部的两份清单,spec 无对应导出 → 加集合覆盖断言当闸门(或让一方从另一方派生)。Add default props to all components to prevent collapse in designer #4 尤其值得一提:
created_at/created_by/updated_at/updated_by这 4 元清单,现在跨两个仓库至少存在四份拷贝 —— frameworkregistry.ts的注入源、frameworkrule-validator.ts的AUDIT_TIMELINE_FIELDS(framework 侧也无闸门,已在 fix(cli): give theobjectui initscaffold the vite-env.d.ts its own build script needs (#4111) #4120 里记录为待办)、以及 objectui 这两份。 - Implement component reordering via drag-and-drop in designer canvas #5 / Fix documentation deployment for www.objectui.org #6 跨仓对照服务端
import-coerce.ts。服务端的REFERENCE_TYPES本身已经是派生的(new Set([...REFERENCE_VALUE_TYPES, 'reference']),来自@objectstack/spec/data),所以 objectui 也可以直接消费 spec 的REFERENCE_VALUE_TYPES;布尔 token 表 spec 未导出,需要先在 framework 侧导出才能派生,否则退而求其次加闸门。
修法模板
沿用 #3747 / cloud#898 / objectstack#4120:能派生就从唯一来源派生;确实无法派生的,加一条集合覆盖/不相交断言当闸门。注释不是机制。``
#4120 里的 metadata-form-zod-reconciliation.test.ts 可作参照,其中两个设计点值得抄:
- 两个方向刻意不对称 —— 「消费方提供了源不接受的键」永远是缺陷、不可豁免(没有任何设计理由让用户去填一个值会被丢弃的控件);「源有而消费方不提供」可登记豁免,但必须带理由。
- 豁免登记表本身要被校验 —— 非空、且每条仍能在两侧解析,否则登记表会腐烂成对已删除键的引用。fix(cli): give the
objectui initscaffold the vite-env.d.ts its own build script needs (#4111) #4120 里就抓到该模式的终末形态:一句「两侧同时更新」的注释,它指向的ToolCategorySchema早在 AiChatPage 发给 agent 的四条确认文本仍按 UI 语言取值:zh 控制台 + 英文会话把「确认,开始搭建。」发进英文线程,planAnswerMessage 更是完全没有会话语言门控 #3896 就被删了 —— 注释腐烂得比清单还安静。
闸门写完请做变异测试确认它真会红(#4120 那份三个方向分别触发 1、4、4 个失败)—— 一个不会失败的对账测试,和一句注释是等价的。
Activity
refactor(app-shell): derive the action-location and directory-lookup …
refactor(types,app-shell,detail,grid,designer): hand-copied checklist…
chore(core): deprecate `ObjectValidationEngine` — enforcement stays s…