Skip to content

[P2] objectui: a spec-named symbol must be an import, not a declaration — make "derived vs hand-written" a CI rule, not a review convention #4115

Closed as not planned
8 of 8 issues completed

Description

@os-zhuang

Systematizes what #4074 / objectui#3003 / objectui#3009 did by hand for the third time. Filed so the rule outlives the sessions that keep rediscovering it.

The failure class

objectui declares local types/consts under the same names as @objectstack/spec exports, with doc comments claiming spec canonicity. Every audited instance had either drifted or was one spec release away from it:

symbol comment claimed code did found
ActionType "canonical definition from @objectstack/spec" hand union, missing form #2231/#2901
ChartType (sibling schema re-exported under spec's name) 7 of 19 members objectui#2944
ActionLocation + 2 "single source of truth… re-export here" re-declared union + tuple + z.enum #4074
ActionParamFieldType "aligned with the field types available in spec" 16 of 49 members #4074

The mechanism that makes this class expensive is specific to agent-driven development: an agent reads the comment, takes it as ground truth, and builds on it. #2901 was literally filed with a backwards premise because the fork was re-exported under the spec's own symbol name. A wrong canonical-claim is not stale documentation — it is a planted premise for the next session.

And the absence of derivation is what made #3856 silent: spec 17 removed action.execute and objectui produced zero compile errors, because nothing was bound to the spec's z.infer.

The rule

Any exported type or const in objectui whose name matches a @objectstack/spec export must be an import/re-export of it — or carry an allowlist entry with a reason.

Enforced by a CI script (~100 lines), not prose:

  1. Enumerate spec's export names per subpath (@objectstack/spec/ui, /data, …) at runtime.
  2. Grep objectui packages/*/src for export (type|const|interface) <SameName> declarations not backed by an import from spec.
  3. Fail with the symbol, the file, and the fix ("import it, or add an allowlist entry with a reason").

Value exports additionally pin by reference identity (expect(X).toBe(SpecX)), because a faithful copy passes every value comparison — identity is the only check that distinguishes a re-export from a fork. This insight is already written down in objectui's spec-subschema-parity.test.ts and already applied to ACTION_LOCATIONS / ActionLocationSchema / ACTION_PARAM_FIELD_TYPES (objectui#3003); this issue is about applying it uniformly instead of one symbol per incident.

The allowlist (what keeps this from being a nuisance)

Same governance as check-type-check-coverage.mjs's DEBT map — declared, reasoned, shrink-only. Known-legitimate first entries:

  • @object-ui/types ActionSchema — a deliberate objectui-side view over the spec's action (carries renderer-only fields like openIn); documented as such.
  • ObjectUiLocalActionType / ObjectUiLocalParamFieldType — named local dialects, each with a tripwire test asserting the spec does not own the name (so the day spec adopts it, CI names the alias to retire). This is the sanctioned escape hatch for "implementation accepts more than spec": declare the dialect next to the vocabulary it extends, never hide it in a Record<string, string> in another package.
  • The legacy ActionSchema in crud.ts and similar explicitly-legacy shapes, if any collide.

A same-name symbol that is narrower or wider on purpose belongs in the allowlist with that purpose written down — at which point the comment is load-bearing instead of decorative.

One meta-rule

This rule must land as the check, not as an AGENTS.md paragraph. A prose-only rule is precisely the "declared ≠ enforced" landmine this whole thread is about (#4074, objectui#3009 — where a guard file's own header falsely claimed its checks were "the real enforcement" for the entire interval nothing compiled it).

Refs #4074, #3856, objectui#3003, objectui#3009, objectui#2944, #2901, #2231.

Activity

  1. self-assigned this
    on Jul 30, 2026
  2. os-zhuang commented on Jul 30, 2026

    @os-zhuang
    ContributorAuthor

    The rule landed as CI in objectui#3032 (62311b6) — scripts/check-spec-symbol-derivation.mjs, in the type-check job, 5.5s.

    One implementation note that mattered. The issue's step 1 says "enumerate spec's export names per subpath at runtime." That doesn't work: a runtime import() sees only values, and the drifted symbols in the table above are mostly types — ActionType, ChartType, ActionParamFieldType would all have been invisible. The script goes through the compiler's view of each subpath's .d.ts instead, which yields 4231 export names, types and values alike.

    The three allowlist forms are supported as prescribed: re-export, z.infer derivation, or a ledger entry. Derivation must sit in a structural position — a type alias's own type node, an interface's extends, a const's initializer — because a spec name merely mentioned inside a members block is what a fork looks like. Consts are stricter still: array and object literals aren't descended into, so export const ACTION_LOCATIONS = [...SpecLocations] is flagged. That is the reference-identity argument from objectui#3003 applied statically — a faithful copy passes every value comparison.

    Leaving this open, because the allowlist is not the size this issue imagined. The ask sketched "known-legitimate first entries" — roughly the three named above. The measured reality is 156 collisions across 19 packages. So what shipped is a debt ledger, not a small permanent exception list, and the governance detail matters more than expected: it names symbols, not per-package counts. A count would be a budget — it would let a new fork land as long as an unrelated one was fixed in the same PR, and it would make the failure message point at whichever symbol sorts first rather than the one just written. --ledger regenerates the block, so burning entries down is mechanical.

    Each of the 156 resolves one of three ways: it's a fork (→ re-export, delete the declaration), it needs local shaping (→ z.infer), or it's a genuine dialect (→ stays, but with a written reason and the tripwire test this issue prescribes, so the day spec adopts the name CI says which alias to retire). Starting the burn-down at @object-ui/types, since that's where the repo takes its vocabulary from and a fork there travels furthest.


    Generated by Claude Code

  3. os-zhuang commented on Jul 30, 2026

    @os-zhuang
    ContributorAuthor

    @object-ui/types 全量分诊完成:73 条碰撞,55 条已经漂了

    承 objectui#3017(「手抄 spec 清单」模式排查)的收尾结论 —— 本 issue 的债务台账是同一失效类的超集,且 @object-ui/types 占最大一块、又是全仓传播根(其他包都 import 它),所以先从它开刀。对 73 条碰撞记录(71 个唯一名)逐条两边对读:

    判决 数量
    FORK_DRIFTED — 与 spec 已经不一致 55
    COINCIDENCE — 真撞名,概念不同 17
    FORK_IDENTICAL 1

    本 issue 正文说这批是「已漂移或离漂移只差一个 spec 版本」。实测明显偏前者:四分之三已经漂了,而且不止是「少几个枚举成员」——有几处是活的缺陷。

    已落地(objectui#3057,台账 149 → 144)

    只取可证明安全的一档(本地是 spec 严格子集,或逐成员相同),其中两条是活缺陷:

    • PageVariableSchema 漏 source,即整个 ADR-0049 写绑定 —— spec 写法 {name:'selectedProjectId', source:'project_picker'} 解析后变成谁也写不了的变量;type 枚举还漏 record_id。同一文件下三行的 PageTypeSchema 已是 spec 引用,注释写着「镜像漏了 list 导致 spec 合法页面校验失败」——修了一个、漏了隔壁那个。这是本 issue 描述的失效模式最干净的一个现场。
    • FeedItemType 7/13。缺的 6 个(file/sharing/note/record_create/record_delete/approval)在 RecordActivityTimeline 里既无图标也无颜色 —— 服务端发出即渲染不出。因为两张表是 Record<FeedItemType,…> 全键映射,widen 后编译器直接把缺口报出来。
    • PermissionAction 8/11(漏 execute/manage/configure)、ObjectIndex(漏 fulltext/unique:'global'/partial)、PageVariable(与 spec 逐成员相同)。

    剩余 50 条 drifted:按修法难度分三组

    A 组 · 需要重命名(概念真不同,名字撞了) —— 正解是改本地名,但多数从 barrel 公开导出,是 breaking change,需先定约定:
    FilterCondition(本地是 FilterBuilder 的一行 {field,operator,value},spec 是递归 ObjectQL AST)、FilterOperator、EventHandler(SDUI 事件绑定 vs 内核事件总线订阅)、CacheStrategy(service-worker 取数策略 vs 缓存淘汰策略)、MutationEvent(对象 payload vs 字符串枚举)、SpanSchema(HTML <span> vs OpenTelemetry span)、ValidationRule(×2)、BatchOperationResult(本地是整批汇总,spec 是单条结果 —— 本地形状其实精确对应 spec 的 BatchUpdateResponse)、ReportSchedule、WidgetManifest/WidgetSource、GestureType/GestureConfig 等。

    B 组 · 渲染节点 vs 元数据文档(type 语义冲突) —— PageSchema/AppSchema/DashboardSchema/ReportSchema,TS + Zod 各一份共 8 处。核心冲突:spec 的 type 是页面/报表的种类(record|app|utility|list|home),objectui 的 type 是渲染器判别式(z.literal('page')),种类被挪到 pageType。所以 spec 页面 {type:'list'} 在本地直接 fail,本地页面在 spec 侧 pageType 被静默丢弃。得先定命名约定(*NodeSchema?)才能动。

    C 组 · .passthrough() 静默放行(建议优先) —— 这是 objectstack#4120 那个失效形态在 objectui 的等价物,且不需要重命名决策:AppSchema/DashboardSchema/PageSchema 的 Zod 侧继承 BaseSchema.passthrough(),而 spec 侧是 .strict()。后果是 spec 独有的 22/13/15 个键未经任何校验穿过去 —— branding/sharing/protection/_lock* 全部不验,brading: 这种拼写错误完全隐形。

    已有范式可抄,不必新发明

    zod/objectql.zod.ts 的 ListViewSchema 是全场最佳实践:spec 字段经 SpecListViewFields.shape 按引用流入,配 list-view-spec-parity.test.ts 漂移守卫(spec 长出新字段而 objectui 未分诊即报红),注释所述与代码所做完全一致。B/C 两组应向它收敛,而不是各自发明。

    顺带:守卫本身工作良好,值得说一句

    烧掉 5 个名字后跑守卫,它主动报红说台账里有 5 个「不再碰撞」的条目、要求删除 —— 也就是登记表自身的腐烂被防住了(这正是 #4120 抓到的那个终末形态:注释指向一个早已删除的符号)。两个方向都做了变异测试:重新 fork 一个已烧名字 → 按名报红;烧掉的名字留在台账 → 按腐烂报红。--ledger 重生成 + 逐条 diff 也确认没误抹其他包的债务。

  4. os-zhuang commented on Jul 30, 2026

    @os-zhuang
    ContributorAuthor

    C 组已落地(objectui#3063)

    分诊评论里划出的三组中,C 组(.passthrough() 静默放行)已合入 main。选它先做的理由成立:它是本 issue 失效类里唯一不需要重命名决策的一组,A/B 两组都得先定命名约定。

    实际规模比分诊估计的大

    AppSchema/DashboardSchema/PageSchema 都 extend 自 .passthrough() 的 BaseSchema,spec 侧是 strict —— 合计 39 个 spec 键完全不经校验穿过去:App 23(branding/sharing/embed/objects/apis/requiredPermissions/homePageId/protection + 整个 _lock*/_package*/_provenance 包锁封套)、Dashboard 10、Page 6。

    Page 那 6 个里有一个活缺陷:source 从未被声明,而 kind: 'html' | 'react' 的页面正是把正文放在 source 里 —— 这类页面在 objectui 里根本无法表达。

    修法要点(两条值得记下)

    1. 没有加 .strict(),那是错解。这三个是组件节点,开放封套是有意为之(渲染器属性该穿透)。正解是让 spec 字段按引用流入 —— 即 zod/objectql.zod.ts 的 ListViewSchema 已在用的范式。收紧的只是 spec 拥有的那部分,并专门加了断言守住「未知渲染器属性仍然放行」。

    2. zod 4 拒绝对带 refinement 的对象调 .omit(),而 spec 的 PageSchema 恰好有一个 —— 所以本 issue 里到处推荐的惯用写法 SpecSchema.omit({…}).partial() 会在 import 期直接抛异常,不是运行期。新的 specFieldsExcept()(objectui base.zod.ts)改读 .shape 绕开,坑记在它的文档注释里。后续给其他 schema 加派生的人会再撞上这个,建议在本 issue 正文的修法模板里补一句。

    闸门

    新增 page-app-dashboard-spec-parity.test.ts(19 断言),覆盖三个方向 + 一组「这些键现在真的被校验了」的钉扎(branding: 'blue' 从通过变拒绝、kind: 'nonsense' 从通过变拒绝、kind:'html' + source 现在能表达)。变异测试两个方向都红:偷偷 omit 掉 branding → 3 个测试按名报红;加未登记的本地键 → rogue-key 断言报红。

    验证:全仓 type-check 78/78,types 271 断言,下游 app-shell/plugin-dashboard/plugin-list/core 共 3626 断言全绿 —— 收紧校验没挡住任何现有渲染路径。

    剩下的 A/B 两组需要先拍板

    两组都卡在命名决策上,不适合由实现方自行决定:

    • A 组(约 17 条真撞名):FilterCondition/FilterOperator/EventHandler/CacheStrategy/MutationEvent/ValidationRule×2/BatchOperationResult 等,正解是改本地名,但多数从 barrel 公开导出 —— 是 breaking change,得先定是直接改、还是先加别名过渡。
    • B 组(8 处,TS+Zod 各一份):type 语义冲突 —— spec 的 type 是页面/报表种类,objectui 的是渲染器判别式。需要先定约定(*NodeSchema 之类)才能动。本 PR 已把 Page 的这处冲突在代码注释里写明并 omit 掉,没有擅自调和。
  5. os-zhuang commented on Jul 30, 2026

    @os-zhuang
    ContributorAuthor

    A 组已落地(objectui#3068)—— types 侧从 71 降到 44

    按维护者指示直接改名、不做别名过渡。这 15 个是分诊里的 COINCIDENCE 判决:本地概念与 spec 某个导出同名但建模的是别的东西 —— 也就是本 issue 正文点名的那类「名字本身就是缺陷」。

    几个对照值得记录(左本地、右 spec 同名符号的真实含义):
    SpanSchema = HTML <span> ↔ OpenTelemetry 追踪 span;CacheStrategy = service-worker 取数顺序 ↔ 缓存淘汰策略;MutationEvent = 对象 payload ↔ 字符串枚举;BatchOperationResult = 整批汇总 ↔ 单条记录结果;EventHandler = SDUI 事件绑定 ↔ 内核事件总线订阅。全部改为 FilterBuilderCondition/FetchCacheStrategy/TextSpanSchema/BatchOperationSummary/UIEventHandler 等。

    改名过程暴露的两个既有问题

    1. objectui 自己有两个互相冲突的 ValidationRule:field-types.ts 是扁平约束包(minLength?: number),form.ts 是 RHF 风格包装形(minLength?: {value, message})—— 键名全同、值形状不兼容。barrel 只导出后者,前者是被刻意从导出块省掉以躲开碰撞的,所以深引 field-types 的消费者会静默拿到另一个类型。这个碰撞在本仓内部藏了很久,是被 spec 改名工作顺带挖出来的。

    2. zod/reports.zod.ts 的 ReportComponentSchema 已被判别联合占用,而 zod/theme.zod.ts 早已在用「节点 = *ComponentSchema、联合 = *UnionSchema」的约定 —— 两个文件的命名约定原本就不一致。借这次统一了。

    方法上的一条建议,值得写进本 issue 的修法模板

    不要对这类改名做全局字符串替换。 FilterCondition/ValidationRule/ThemeSchema/ReportSchema/SpanSchema 这些名字两边都有,盲替会把消费 spec 的引用一并改错 —— 那正好制造出本 issue 要消灭的那种「名字说的和实际指的不是一回事」。

    可靠做法是先改声明与 barrel,让 tsc 把所有断裂点列出来逐个判定。这次人工排除的真危险点有三处(clientValidation.ts 从 @objectstack/spec/ui 导入 ReportSchema/ThemeSchema、report-schema.ts、spec-report.ts 的 ReportSchema as SpecReportSchema_ 导入别名),同时 tsc 也确实抓到了 3 处我漏改的引用。

    验证

    台账 144 → 129(types 侧 71 → 44),--ledger 重生成后逐条 diff:removed 恰为这 15 个、added 为空。变异测试:把 FilterBuilderCondition 改回 FilterCondition → 守卫按名+按文件报红。全仓 type-check 78/78,全量测试 8696 断言绿(752 文件)。

    本仓进度小结

    组 状态 台账变化
    先导(安全派生) objectui#3057 64 → 59(types)
    C(.passthrough() 静默放行) objectui#3063 结构性修复,无台账变化
    A(纯撞名改名) objectui#3068 59 → 44(types);全仓 144 → 129
    B(type 语义冲突) 待拍板 8 处,TS+Zod 各一份

    B 组仍需先定命名约定:spec 的 type 是页面/报表种类,objectui 的是渲染器判别式。注意 A 组已经把 *ComponentSchema(节点)/ *UnionSchema(联合)这套约定在 report/theme 两处统一了,B 组若沿用同一套会比较自洽 —— 但那是维护者的决定,本轮没有擅自扩大。

  6. os-zhuang commented on Jul 30, 2026

    @os-zhuang
    ContributorAuthor

    B 组已落地(objectui#3074)—— objectui 侧四组全部处理完

    B 组是 type 的语义冲突:spec 的 type 是页面/仪表盘的种类(record|app|utility|list|home),objectui 的 type 是组件判别式('page'),种类被挪到 pageType。所以 spec 写法 {type:'list'} 的页面在 objectui 直接校验失败,objectui 的 pageType 在 spec 那边被静默丢弃 —— 而两者顶着同一个名字。

    AppSchema → AppComponentSchema,DashboardSchema → DashboardComponentSchema,PageSchema → PageNodeSchema。

    一个值得写进 issue 正文的教训:改名也会撞名

    Page 本来要叫 PageComponentSchema(维护者钦定的约定)。但 PageComponentSchema 本身已经是 @objectstack/spec/ui 的导出,而且它指的是页面区域内部的一个组件({type: PageComponentType, properties, …}),不是页面本身。用它等于把本 issue 要消灭的缺陷原样搬到隔壁概念上。

    这不是推演 —— 那个 PR 的第一版真的用了 PageComponentSchema,被守卫按名拦下。 所以:给撞名符号挑新名字时,新名字必须先过一遍守卫,否则很容易从一个碰撞滑进另一个。建议在修法模板里补一句。

    方法上的两个坑(都被工具抓到,不是靠眼睛)

    1. 多行 import 块:import {\n PageSchema as SpecPageSchema,\n} from '@objectstack/spec/ui' —— 成员行本身不含 spec 路径,所以任何「跳过含 @objectstack/spec 的行」的启发式都会漏。4 处 spec 导入别名被误改,特征是 …ComponentSchema as Spec…。
    2. apps/ 目录:objectui 的消费者不只在 packages/ 下,apps/console 也有引用。tsc 抓到了。

    再次印证前一条评论的建议:不要做全局字符串替换,先改声明和 barrel、让 tsc 列出断裂点逐个判定。这一轮 tsc + 守卫合计抓到我 3 类疏漏。

    objectui 侧总账

    组 PR 台账
    先导(安全派生) objectui#3057 types 64 → 59
    C(.passthrough() 静默放行) objectui#3063 结构性修复(39 个 spec 键从不校验变为校验)
    A(纯撞名改名,15 个) objectui#3068 types 59 → 44
    B(type 语义冲突,3 个) objectui#3074 types 44 → 41

    全仓台账 156 → 126,@object-ui/types 71 → 41。

    余下的性质变了

    types 剩的 41 条不再有「纯撞名」这一类 —— 全是 FORK_DRIFTED,即同一个概念但两边形状已经不一致(FormField/SelectOption/ActionParam/NavigationItem/ObjectPermission/SharingRule/五个 *Validation/QueryAST/DriverInterface 等)。每一条都要单独决定「派生 + 收敛差异」还是「保留差异 + 记录理由」,没有机械做法,也不适合再按组批量推进。

    其中有几条我在分诊时标注过、值得优先看:

    • ObjectPermission —— objectui 内部有两个互不兼容的版本,而 app-shell/…/PermissionPreview.tsx 里那个恰恰是 spec 形状的手抄件;
    • NavigationItem/NavigationItemSchema —— spec 侧是 9 个变体的判别联合且每个目标字段必填,objectui 是一个全可选的扁平接口,badgeVariant 少了 secondary、group 展开键名不同(expanded vs defaultOpen);
    • 五个 *Validation(Script/StateMachine/CrossField/Conditional/Format)—— 注释都写着 "ObjectStack Spec v2.0.1",而装的是 17.0.0-rc.0,condition 在 spec 已是 Expression 对象而本地仍是裸字符串。
  7. os-zhuang commented on Jul 31, 2026

    @os-zhuang
    ContributorAuthor

    Navigation 那一簇的漂移已修(objectui#3088)—— 但台账不减,理由如下

    一个改变影响评估的事实,建议写进本 issue 正文

    objectui 的 zod schema 在生产代码里只有一条运行时路径:已发布的 objectui validate 命令(@object-ui/cli,非 private,bin 名 objectui)。渲染器直接读普通对象,从不 parse。

    这把剩余台账分成了两类,值得分开排期:

    • *Schema(zod)条目 → 影响的是 CLI 校验的正确性;而且 schema 是 strip 模式(丢弃未知键而非报错),所以失效必然是静默的;
    • TS 类型条目 → 影响的是在 TS 里写元数据时的可表达性,与渲染无关。

    我之前把这两件事混在一起谈了,这是修正。

    实测出来的六处(spec 17.0.0-rc.0)

    最值得记录的两条:

    1. 能力门控被自己的校验剥掉。 requiresObject/requiresService 是 NavigationItem 一直声明着、NavigationRenderer 一直在执行的运行时门控,只有 zod schema 落后 —— 于是校验一个 app,会把它自己 nav 元数据里的门控丢掉。这是「同一概念的三份表述(TS 类型 / zod / 渲染器)各自漂移」的典型。
    2. 渲染器早就是 spec-aware 的,契约不是。 expanded 那处渲染器写的是 (item as any).expanded,注释还写着「honor either so app authors don't get silently-ignored config」—— 作者知道 spec 用这个拼写并做了处理,却因为类型没声明而被迫 cast。一个 as any 就是漂移已经发生的现场证据,值得作为排查线索:grep "as any).<field>" 能找出更多「运行时已适配、契约未跟上」的地方。

    其余四条:actionDef 被剥掉(动作项校验通过却什么都不做)、badgeVariant:'secondary' 硬拒、{type:'separator'} 被拒、NavigationArea 的 order/description 被丢。

    台账为什么不减少

    spec 的 NavigationItemSchema 声明为 z.ZodType<any>(递归的 group 变体让联合类型被擦除),所以 type NavigationItem = z.infer<...> = any —— re-export 会删掉全部导航类型信息。objectui 必须保留本地声明,名字碰撞就还在。

    但它的各个变体类型是完整的(ObjectNavItem/ActionNavItem/GroupNavItem/…)。所以本 PR 的 badgeVariant 是从 ObjectNavItem['badgeVariant'] 派生的,而不是再抄一遍枚举。建议补进修法模板:联合擦除成 any 时,先看变体是否可派生,不要因为伞形类型不可用就整份手抄。

    一条方法教训

    为放行 spec 合法的 {type:'separator'},我第一版把 id/label 对所有类型改成可选 —— 这会让 {type:'object'} 这种无标识项也通过。是既有测试拦下的(navigation-model.test.ts 两条断言),测试对、我的 schema 错。改成用 superRefine 只豁免 separator,并补断言钉住边界。放宽一个 schema 去接纳 spec 的某个合法形状时,很容易顺手放宽掉别的不该放宽的。

    验证

    9 条新断言;变异测试:四处 schema 修复逐个撤掉各自按名报红,豁免放宽成无条件则三条一起报红。全仓 type-check 78/78,全量 8932 断言绿。

    objectui 侧剩余

    台账 124(types 39)。下一个候选是 FormField 那一簇(FormField/FormFieldSchema/SelectOption/SelectOptionSchema),失败模式最恶劣:spec 用 field 作字段身份键、objectui 用 name 且必填,实测 objectui validate 对任何 spec 写法的表单都硬拒(fields.0.name: expected string, received undefined)。但它需要先定 name vs field 的取舍,已交维护者拍板。

  8. os-zhuang commented on Jul 31, 2026

    @os-zhuang
    ContributorAuthor

    objectui 侧表单簇进展(objectui#3090 / objectui#3091,已合入):

    台账 124 → 122:SelectOption + SelectOptionSchema 完成 triage 后移入 ALLOW —— zod 侧改为 spec shape 按引用派生,两个钉住的有意分歧(value 放宽给独立 UI 表单;visibleWhen 保持 objectui wire 契约而非 ExpressionInput 的 envelope 规范化管道)+ 两个 UI 扩展(disabled/icon),parity 测试四向守卫(spec 加键/退键/认领扩展名/自行放宽 value 任一即红)。

    FormField/FormFieldSchema 两条确认不减,分类为双层概念:spec 的 FormField 是 authored 层(field = 对象字段引用,presentation delta),objectui 的是 runtime 层(name = 数据路径,自足控件配置)。转换枢纽 normalizeSectionField 一直存在但零闸门 —— 对照 spec 17.0.0-rc.0 盘点发现它丢四个键,包括 ADR-0089 的规范拼写 visibleWhen(弃用拼写 visibleOn 反而能用)。已补齐 + 28 键逐键行为闸门(mutation-tested)。

    修法模板可补一条:当同名符号是「双层词汇」而非「同概念方言」时,burn-down 的正确形态不是派生而是「枢纽 + 键集覆盖闸门」 —— 枢纽是翻译(每键有各自落点),闸门枚举 SpecSchema.in.shape 双向比对。判别法:问「这两个类型描述的是同一个对象,还是管道上下游的两个阶段?」SelectOption 是前者(可派生),FormField 是后者(枢纽 + 闸门)。

  9. os-zhuang commented on Jul 31, 2026

    @os-zhuang
    ContributorAuthor

    objectui 表单簇战役收官(objectui#3090 已关,7 个 PR:objectui#3091/#3092/#3097/#3100 + 前期 #3043/#3057 相关件)。台账 124 → 120,表单簇 6 条全部处置:2 烧(SelectOption 对,spec 派生 + 四向 parity 守卫)、2 ALLOW(FormField 对,双层论证 + no-restricted-imports tripwire)、加上前期 SelectOption TS/zod 双侧的 documented 处理。

    给修法模板再补两条经验:

    1. 「误导入会被 tsc 抓」在 [P2] spec: four exported types resolve to any, so "bind the spec-named symbol to the spec" actively degrades the consumer that obeys it #4171 修好前是错的——spec 类型擦除成 any 时,误导入是 tsc 完全沉默的类型安全删除。机制解法是 no-restricted-imports 按 importNames 禁导 + 报错文案写明两层与正确路径(文案会被 agent 当修复指令执行,值得当 API 设计)。规则上线即抓到按名 grep 不可见的三种形态:命名空间导入、export-all、多行 re-export——5 个既有点位全是合法消费者(守卫测试、Spec 前缀公共别名),逐一行内豁免带理由。已知残余侧门:export type * as UI 形态 base 规则不查。

    2. 跨包类型影响的本地验证必须先重建依赖 dist——per-package tsc 解析到改前的 types dist 会假绿(turbo 缓存再叠一层),CI 才抓到 OptionLike 波及。turbo build --filter=<dep> 后再 type-check --force 才是真话。

  10. 11 remaining items

  11. os-zhuang commented on Aug 2, 2026

    @os-zhuang
    ContributorAuthor

    跨包定名:OfflineConfig 归 @object-ui/react,types 侧改名 —— 批次 5 请直接沿用(附 Theme 结论)

    批次 2(objectui#3156,@object-ui/types 页面/导航/杂项簇 16 条)已处置完毕,PR 待开。先把批次 5 正在等的那条单独发出来。

    OfflineConfig:两侧不是方言,是两个概念 —— 而且拿错名字的是 types 侧

    对读三处后的实测:

    处 形状 是什么
    @objectstack/spec/ui { enabled, strategy: cache_first|network_first|…, cache: { maxSize, ttl, persistStorage, evictionPolicy }, sync: { strategy, conflictResolution, retryInterval, maxRetries, batchSize }, offlineIndicator, offlineMessage, queueMaxSize } 应用级离线数据模型:变更队列 + 其存储后端与冲突解决
    @object-ui/types(mobile.ts) { enabled, defaultStrategy: 'cache-first'|…, routes: OfflineRoute[], maxCacheSize, cacheExpiration, offlineObjects, syncStrategy, conflictResolution } PWA service-worker 路由缓存:URL glob 配 fetch 策略,与 PWAConfig/PWAIcon 同段
    @object-ui/react(hooks/useOffline.ts) { enabled?, strategy?, cache?, sync?, offlineIndicator?, offlineMessage?, queueMaxSize? } 就是 spec 那个,逐键对齐,只是全可选(授权侧)

    所以结论与直觉相反:

    • @object-ui/types 的改名为 PWAOfflineConfig(本批已落地)。它建模的是 service-worker 取数顺序 —— 注意它的策略类型早在 A 组就已被改名为 FetchCacheStrategy(「service-worker 取数顺序 ↔ 缓存淘汰策略」),这次只是把伞形类型补齐到同一判断上。枚举拼写也不同(kebab vs snake)。
    • @object-ui/react 的保留 OfflineConfig 这个名字,走派生,不要改名。 它是 spec 概念的手抄件,正解与 FilterCondition 在 app-shell 的处置同构(objectui#3169:看着像方言,其实是该 re-export 的那个)。

    批次 5(objectui#3159)施工建议:OfflineConfig / OfflineCacheConfig / OfflineSyncConfig 三条一起派生;并且要用 z.input 而不是 z.infer —— enabled/strategy/persistStorage/evictionPolicy/conflictResolution/offlineIndicator 全带 .default(),而 useOffline(config = {}) 是授权侧(调用方构造配置),用 output 类型会让「传空对象」不可表达。这正是 objectui#3169 那条判据:在写这份配置 → z.input,在读已 parse 的结果 → z.infer。同批的 OfflineStrategy/ConflictResolutionStrategy 大概率可直接 re-export(OfflineStrategy/ConflictResolution 是 spec 导出),但落地前每个新名字/每次 re-export 仍要自己跑一遍守卫与对读——本轮就有一条「早期分诊标注为疑似真撞名、实测是可派生」的反例(见下)。

    Theme:types 侧改为 re-export spec 的 ThemeInput(批次 7 请据此再分诊 providers)

    @object-ui/types 的 Theme 与 spec 的 ThemeInput 逐成员相同(双向可赋值探针为 identical),所以本批直接 export type { ThemeInput as Theme }。

    要点是取 ThemeInput 而不是 Theme:spec 的 z.infer<typeof ThemeSchema> 是 parse 后的形状,mode 因 .default('auto') 变成必填;objectui 里流转的 theme JSON 是授权侧(存储、编辑、交给 ThemeProvider),mode 必须可省。原声明的注释其实一直写着这件事(「ObjectUI uses the input shape」)—— 它知道自己是 input 形状,却仍然顶着 output 类型的名字自己抄了一份。

    批次 7(objectui#3161)的 @object-ui/providers:Theme 因此不能照搬结论:先问它是「运行时已解析的主题」还是「授权 JSON」。若是后者,它多半也能直接 re-export(types 侧现在就是),那样两个包会自然收敛到同一个 spec 类型;若它带了解析后的 CSS 变量/必填 mode,那才是 z.infer 那一侧,应派生自 spec 的 Theme 而非改名。两种情况都不需要新方言名。

    顺带一条给修法模板:「早期分诊的疑似撞名,仍要自己对读」

    本批正文预判 GestureConfig/GestureType、WidgetManifest/WidgetSource 「大概率走改名」——实测四条全部成立,但同一份分诊里被顺手带过的 ActionParam 却是可派生的(且派生后补回三个 spec 键)。反过来,NavigationArea 看着像「#3088 修完只剩名字问题」,实测 spec 侧 navigation 是 any[](NavigationItemSchema 的 z.ZodType<any> 传染到了伞形类型),整份 re-export 会把导航树类型删掉,只能「继承其余键 + 钉住这一键」。

    也就是说:分诊结论的颗粒度是「符号」,不是「簇」。同一簇里三条走三条不同的路,这一轮就发生了。


    Generated by Claude Code

  12. os-zhuang commented on Aug 2, 2026

    @os-zhuang
    ContributorAuthor

    批次 4(objectui#3158)已开草稿 PR:objectui#3188 —— 台账 77 → 64,@object-ui/core 整包出账

    @object-ui/core 13 条,6 派生 + 7 改名 + 0 ALLOW。判决与逐符号理由见 PR 正文,这里只记三条对后续批次有用的东西。

    一、三个副本在自己的注释里就写明了自己是副本

    这批最值得记的不是数量,而是失效证据都是现成的,写在被烧掉的代码旁边:

    • CONTEXT_TOKENS 的注释写着「mirrored here … because the installed @objectstack/spec predates that export … 临时,直到下次协调发布」。而装的 spec(17.0.0-rc.0)早就导出了它,副本与 spec 逐字节相同 —— 也就是说,在它的理由为假的整段时间里,它通过了所有值比较和所有行为测试。
    • RowHeight 的注释自称「the spec's RowHeightSchema vocabulary」,实际是手抄 union。碰巧还是对的。
    • managedBy.ts 自称「UI-side mirror of the framework's resolveCrudAffordances()」,并自带一份 DEFAULTS —— 与 spec 的 CRUD_AFFORDANCE_DEFAULTS 逐行相同,连 override 解析器也抄了一份。

    另外 defineView 已经把错误前提种到隔壁包了:app-shell/…/view-item-normalize.ts 的注释在讲「per-object defineView({list, form, listViews, formViews}) 文档」—— 那是 spec 的 defineView,而这个名字在本仓解析到的是 core 的「冻结 + 打标」helper。正是 #2901 的机制,只是这次被抓在了发生现场。

    二、改名不够,要把抄来的那一半交还给 spec

    resolveCrudAffordances 如果只改名,那张抄来的 bucket 表还在。所以 bucket/userActions 那一半改为委托 spec 的 resolveCrudAffordances(),全平台只剩一份定义;留在 objectui 的只有 spec 没有的那部分 —— 与服务端 effective API operation 求交(#3391),名字(resolveEffectiveCrudAffordances)现在说的就是这件事。配了一条 parity 测试,跨全部 7 个 managedBy 值 + #2614 对象形逐一比对 spec 输出。

    建议补进修法模板:同名符号如果是「spec 的那件事 + objectui 的额外一层」,正解是 委托 + 改名,而不是改名了事 —— 否则台账条目没了,fork 还在。

    三、变异测试把 #3003 那句话演了一遍

    把 CONTEXT_TOKENS 改回那份刚删掉的逐字节副本,守卫按名 + 按文件报红(filter-tokens.ts:61),同时新加的 parity 测试报:

    AssertionError: expected [ 'current_user_id', 'current_org_id' ]
                         to be [ 'current_user_id', 'current_org_id' ]  // Object.is equality
    

    两个长得一模一样的数组,同文件另外 20 条断言全部照常通过。忠实副本满足一切值比较,只有引用同一性能区分 re-export 与 fork —— 这条以后可以直接引用这个失败信息,比讲道理省事。

    四、两条施工规约建议(都是本轮踩到的)

    1. turbo type-check 全绿不等于改名改完了 —— 测试文件不在 type-check 范围内。 78/78 绿的时候,PluginSystem.test.ts 和 managedBy.test.ts 还引用着旧名字,是跑测试才暴露的。建议写在「禁止全局替换」旁边。
    2. 全局替换会毁掉 burn-down 自己新加的那些注释。 我对两个文件做了限定范围的 re.sub,结果把对照说明也改了 —— 「NOT the spec's ValidationError」变成「NOT the spec's SchemaNodeValidationError」,等于用修误描述的动作现制造了一条新误描述。已在自查时发现并还原。现有规约说的是「不要跨仓全局替换」,应当补一句:在记录该碰撞的文件内部同样不要。

    验证

    台账 --ledger 重生成逐条 diff:removed 恰为本批 13 条、added 为空、其余 17 包逐字未动(有并行批次)。守卫绿;turbo build --filter=@object-ui/core 重建 dist 后 type-check --force → 78/78;全量 9689 断言通过、0 失败(821 文件),受影响的包单独跑也全绿(core 61 文件/1335;app-shell + plugin-form + plugin-grid + plugin-list 347 文件/3127)。

    全量跑里有 21 个文件报的是文件级错误而非断言失败。拿 plugin-charts 在 detached origin/main 上对照,形态完全一致(1 failed | 25 passed / 400 passed),确认是既有问题、与本批无关;同一份代码连跑数次该计数还从 23 漂到 21,应是并行 worktree 的内存压力所致。

    PR:objectui#3188(草稿)。未自行合并,合入前不关闭 objectui#3158。


    Generated by Claude Code

  13. os-zhuang commented on Aug 2, 2026

    @os-zhuang
    ContributorAuthor

    批次 2 已开草稿 PR:objectui#3191(台账 77 → 61,@object-ui/types 20 → 4)。

    16 条两种处置,本轮无新增 ALLOW:

    • 派生 7 条:ActionParam(z.input + type 一处放宽)、CreateExportJobRequest(Omit<…,'object'>)、CreateExportJobResult、ImportRowResult、NavigationArea、NavigationAreaSchema、Theme(= spec 的 ThemeInput,逐成员相同)。顺带 ExportJobFormat 也改为 re-export spec 的 ExportFormat。
    • 改名 9 条:FileMetadata→UploadedFileMetadata、GestureType→TouchGestureType、GestureConfig→TouchGestureConfig、OfflineConfig→PWAOfflineConfig、PageRegion→PageNodeRegion、PageRegionSchema→PageNodeRegionSchema、ResponsiveConfig→MobileResponsiveConfig、WidgetManifest→RuntimeWidgetManifest、WidgetSource→RuntimeWidgetSource。

    跨包定名见上一条评论(OfflineConfig 归 react 侧派生保名、types 侧改名;Theme 两侧都不需要新方言名)。挖出的活缺陷、验证与变异测试细节都写在 PR 正文里,这里只补一条值得进修法模板的方法结论:

    parity 测试对「忠实手抄」是瞎的 —— 必须钉引用同一性

    变异测试第二组:把已派生的 NavigationAreaSchema 改回手写 z.object({…}),键集完全一致。结果是 39 条 parity 断言全绿 —— 键集覆盖、双向 parse、_input/_output 分歧钉扎,一条都没红,因为那份手抄件确实是忠实的。只有守卫按引用抓到了它。

    这正是 objectui#3003 的论证(也是 objectui#3169 在 isAggregatedViewContainer 上再次实证的那条),但之前的表述是「re-export 一个 value 时闸门要用 toBe」。实际范围更大:凡是 specFieldsExcept 这类「spec 字段按引用流入」的 schema 派生,parity 测试也必须断言引用同一性,否则它只能证明「今天两边一样」,而那恰恰是手抄件也能通过的那句话。

    具体写法有个坑:specFieldsExcept 结尾是 .partial(),会把每个字段重新包一层 ZodOptional,所以 local.shape.icon === spec.shape.icon 为 false,local.shape.icon.unwrap() === spec.shape.icon 才是 true。即往里剥一层再比,那层新 wrapper 是唯一允许的新分配。补上之后,同一个变异两个闸门都报红。

    建议把这条加进修法模板的「派生」一节:派生的闸门 = 键集 + 分歧钉扎 + 引用同一性;缺第三项时,把派生改回忠实手抄是测不出来的。


    Generated by Claude Code

  14. os-zhuang commented on Aug 2, 2026

    @os-zhuang
    ContributorAuthor

    批次 5 已开 PR:objectui#3199(草稿)—— 台账 62 → 45,包数 17 → 14

    @object-ui/auth + @object-ui/components + @object-ui/react 三包整包退出台账。18 个符号:12 派生、5 改名、1 进 ALLOW,另有 1 个根本不需要动(见下)。

    处置 符号
    re-export TenancyPosture、DelegableScope(+DelegableAdminScope)、ShareLinkPermission、ShareLinkAudience、OfflineStrategy
    结构性派生 AuthUser(extends)、ShareLink、SortItem(extends)、OfflineCacheConfig、OfflineSyncConfig、OfflineConfig、NavigationConfig
    改名 AuthSession→AuthClientSession、AuthProviderConfig→AuthProviderOptions、FilterCondition→FilterBuilderCondition、Field→FieldContainer、ConflictResolutionStrategy→**ConflictResolution(spec 自己的名字)**
    ALLOW AuthProvider
    无需处置(spec 已退掉该名字) PerformanceConfig

    一、FilterCondition 的跨包结论:三处三判,本批实测判定

    这个名字的跨包指引现在两个方向各错过一次了,所以把三处一次列清:

    处 实际是什么 判决
    @objectstack/spec/data 递归 ObjectQL 谓词 AST($and/$or/$not) —
    @object-ui/app-shell 同一个 AST,但被声明成 Record<string, any> re-export(#3169)
    @object-ui/types FilterBuilder 的扁平一行 改名 FilterBuilderCondition(#3068)
    @object-ui/components(本批) FilterBuilder 的扁平一行 —— {id, field, operator, value},operator 取自 builder 自己的 FILTER_BUILDER_OPERATORS 下拉词汇,不是 spec 的 $ 记号 改名 FilterBuilderCondition,与 types 一致

    照搬 app-shell 会把一行扁平条件换成递归查询类型;照搬 types 这次恰好是对的,但只有对读两边才知道。

    二、OfflineConfig 按更正派生并保留原名

    批次 2 已确认 types 侧那个是 service-worker 路由缓存、改名 PWAOfflineConfig,所以名字归 react。本批直接取 spec 绑定,无方言。

    取 input 侧而不是 z.infer,而这一次判据就写在函数签名里:useOffline(config: OfflineConfig = {}) 默认空对象,而 output 侧 enabled/strategy/offlineIndicator 在 .default() 跑完后全是必填 —— z.infer 会让这个 hook 自带的默认值不可表达。建议把这条写进模板:判断 input/output,先看这个类型有没有一个默认值形态的调用点,比这更好记。

    @object-ui/react 没有 zod 依赖(它渲染普通对象、从不 parse)。与其为了 z.input 加一个依赖,新增的 packages/react/src/spec-input.ts 从 schema 自身的 _zod 内部读 input 类型 —— 仍然是 spec 的绑定,零新依赖、零形状拷贝。

    三、ConflictResolutionStrategy:本批最锋利的坑

    这个名字本身就是 spec 导出 —— 在 @objectstack/spec/api,含义是元数据合并策略(error | priority | first-wins | last-wins)。而 hook 真正用的那个联合(manual | client_wins | server_wins | last_write_wins)在 spec 里叫 ConflictResolution,在 /ui。

    在旧名字下做派生会造出"名字指向一个联合、值是另一个联合"的符号 —— 正是本 issue 要消灭的形态,而且守卫会放行(派生就是派生)。正解是一步同时拿回 spec 的名字和它的绑定,与 #3169 的 FieldGroup → ObjectFieldGroup 同型。建议补进模板:改名前先查旧名字在 spec 里到底指什么,别只查新名字。

    四、PerformanceConfig:spec 在 17.0.0-rc.1 把这个名字退掉了

    PerformanceConfigSchema / PerformanceConfig 已不在 @objectstack/spec/ui 导出,碰撞自行消失,名字归 objectui。仍然配了 tripwire —— "spec 退掉了"是一句有保质期的断言,spec 哪天加回来,测试报红、符号重新分诊,而不是悄悄变成 fork。

    但键还在 spec 里(virtualScroll、debounceMs 等嵌套在 @objectstack/spec/api 下),所以这可能是搬家而非退休。若是搬家,usePerformance 应从新位置派生 —— 需要了解 spec 计划的人拍板。

    五、AuthUser:索引签名把丢键藏了

    本地声明从来没有 spec 的 positions / tenantId(授权判定的输入),而 [key: string]: unknown 让这个缺口在每个调用点和任何结构比较中都不可见 —— 就是 #4075 那个机制:索引签名吸收一切,[Local] extends [Spec] 无论漂多远都答"相同"。改成 extends spec 主体后,展示用字段(image/role/roles/emailVerified)是全部增量,spec 新增字段会自己到位。

    反面案例是 AuthSession:spec 要求 client 永远看不到的 id/userId,expiresAt 是 ISO 字符串,而本地持有 JS Date、token 必填、还有 spec 完全没有的 refreshToken。两个方向都不可赋值 —— re-export 会是一次静默的契约调包。所以改名。

    六、AuthProvider 与 AuthProviderConfig 判决故意不同

    组件进 ALLOW,它的配置类型改名。理由不是"组件一律豁免",而是失效模式本身:没人会把 <AuthProvider authUrl=…> 当成一个 provider id 的 z.ZodEnum,而改名会为了零缺陷破坏 React <XProvider> 惯例和所有消费者的 JSX;它的配置类型恰好相反 —— 同一个领域、同样的字眼,spec 的 AuthProviderConfig 是 OAuth 注册形状 {id, clientId, clientSecret, scope?},正是下一个 session 会当成权威去读的那个符号。


    七、方法学:逐行忠实的 re-fork 能通过全部 parity 断言(变异测试实证)

    这一条回应上一轮提出的"派生闸门光有键集比对不够"。把 TenancyPosture 改回一份与 spec 完全一致的手写联合、并删掉 import 让它干净编译:

    • pnpm type-check —— 绿
    • parity 测试 —— 14/14 全绿
    • 守卫 —— 红,按名+按文件(types.ts:33)

    对类型符号而言没有引用同一性可查(不像 #3169 的 isAggregatedViewContainer,expect(local).toBe(spec.x) 能抓),所以 Assert<Equal<Local, Spec>> 对忠实副本和真绑定的判定完全一样。结论应写进模板:parity 测试钉的是"有意分歧的形状",它在原理上无法发现拷贝;发现拷贝的只有守卫。(本批没有 zod schema 派生,所以".unwrap() 后比引用"这条这次用不上 —— 全部是类型别名与 extends。)

    变异测试共四向,全部报红:重新 fork(按名+按文件)、撤销改名、把已烧名字留在 DEBT(腐烂棘轮)、删掉 ALLOW 条目。

    八、又两处"为了不依赖 X 所以本地拷贝"的注释,都是假的

    useNavigationOverlay 写着「inline … to avoid importing from @object-ui/types(which may not be a direct dependency of @object-ui/react)」—— 但这套词汇属于 @objectstack/spec,而它本来就是该包的直接依赖。与 #3169 在 app-shell 抓到的完全同型。另外 useOffline / usePerformance 都宣称对齐「@objectstack/spec v2.0.7」,装的是 17.0.0-rc.1。

    九、编译期钉扎现在真的被编译了

    三个 parity 测试都带 Assert<Equal<…>>,而本分支最初把它们当作已生效发了出去 —— 每个包的 tsconfig 都排除 **/*.test.ts,CI 唯一的类型闸门跑的就是那些配置。objectui#3187 在本分支在飞期间落地了这个模式的修法,所以本 PR 改用它的约定(每包 tsconfig.typetests.json + 显式 include + 从 type-check 串接),而不是本分支先写的根级方案。check-type-check-coverage.mjs 现在报 4 个包有窄类型断言工程(原为 1)。


    验证

    --ledger 清块重生成后逐条 set-diff:removed 恰为本批 17 条(PerformanceConfig 已在 main 侧因 spec 退名而消失)、added 为空、其余包逐字节不变;守卫绿;三包先 turbo build --filter 重建 dist 再 turbo type-check --force → 78/78(含 apps/console);全量测试 9511 通过 / 25 skipped / 0 失败(813 文件);ESLint 三包 0 error。

    待拍板

    1. PerformanceConfigSchema 是退休还是搬家? 键仍嵌套存在于 @objectstack/spec/api。若是搬家,usePerformance 应从新位置派生。
    2. 守卫只扫 packages/。 apps/console/src/pages/shared-record-shape.ts 自带第三份 ShareLink,且 audience 是裸 string —— 任何拼错的 audience 在那里完全不可见。台账是 packages-only 所以本批未动,但这是同一个缺陷挪了一个目录,而 feat(metadata-protocol/objectql): turn-atomic package publish — a commit cannot half-land (ADR-0067 D2, #3066) #3074 已经提醒过 apps/ 消费者容易漏。
    3. @objectstack/spec 在 @object-ui/components 从 devDependencies 移入 dependencies —— 它的公开类型面现在引用 spec。若该包有意保持不依赖 spec,请指出(import 是 type-only、JS 侧会擦除,但产出的 .d.ts 引用它)。

    剩余

    台账 45 条 / 14 个包。批次 2(objectui#3156)PR objectui#3191 未合;批次 6、7 未认领;批次 8 待 objectstack#4171 的残余项解除。


    Generated by Claude Code

  15. os-zhuang commented on Aug 2, 2026

    @os-zhuang
    ContributorAuthor

    本轮收官:批次 1–5 全部合入,台账 115 → 29

    五个批次合并完毕,子 issue 均已关闭。台账数字是从 main 上守卫文件的 DEBT 块实测得出,不是各分支基线相减:

    批次 PR 范围 符号
    1 objectui#3167 types 报表/图表/查询簇 10
    3 objectui#3169 app-shell 整包 28
    4 objectui#3188 core 整包 13
    2 objectui#3191 types 页面/导航/杂项簇 16
    5 objectui#3199 auth+components+react 三包 17

    当前:29 条 / 14 个包 / 9 个 declared dialects。 已整包清空:app-shell、core、auth、components、react;types 从 71 一路降到 3。

    剩余分布:data-objectstack 5、plugin-chatbot 4、plugin-list 3、types 3、fields/layout/plugin-detail/plugin-grid 各 2,collaboration/plugin-charts/plugin-form/providers/runner/sdui-parser 各 1。

    ⚠️ 批次 8 的清单已过时,需重新核对

    objectui#3162 登记 4 条(JoinNode/NavigationItem/NavigationItemSchema/JoinedReportBlock),但 main 上 types 只剩 3 条碰撞——rc.1 之后有一条已不再碰撞。开工前须以 --ledger 实际输出为准。

    修出的活缺陷

    用户可见的四个:一条已过期的权限集贡献被渲染成生效中(ExplainLayer.contributors[].state 本地未声明);widget 的 requiresObject/requiresService 能力门控在校验时被静默删除,而渲染器运行时真在执行它(DashboardWidgetSchema 只声明了 spec 22 键中的 10 个);服务端已在发的索引差异在校验面板成为无名条目(SchemaDiffEntryKind 缺两个成员);AuthUser 缺 positions/tenantId,而 [key: string]: unknown 索引签名把这个缺失同时对所有调用点和任何结构比较隐藏了。

    三份副本自己的注释就是假的:CONTEXT_TOKENS 写着"临时重复,等 spec 导出后删除"(早已导出)、RowHeight 自称"spec 的词汇表"、managedBy.ts 自称"UI 侧镜像"而装着逐行副本。resolveCrudAffordances 光改名不够——那张复制的分桶表会换个名字继续说谎,已改为委托 spec。

    还挡下一个正要新造的缺陷:ConflictResolutionStrategy 若照原名派生,那个名字在 spec 里是另一个东西(/api 的元数据合并策略),会造成名字指向一个联合、值指向另一个。

    方法论修正(比符号数更值钱)

    1. 第四种分诊谎言:擦除成 unknown。 文件头的 0 extends (1 & T) 探针对 unknown 返回 false,只筛 any 的分诊会把"删掉整份类型"判成安全派生。已补 case 2b + 反向 pin。
    2. parity 测试挡不住退化回副本。 逐字节忠实的 re-fork 让 NavigationAreaSchema 39 条、TenancyPosture 14 条断言全绿,只有守卫抓到。zod 侧须补引用同一性断言(.partial() 会重新包一层,要 .unwrap() 一层再比);纯类型符号没有同一性可断言,守卫是唯一防线。
    3. turbo type-check 全绿不代表改名做完——测试文件不参与类型检查,须另行 grep。
    4. "不再是 any" 不等于"可以安全绑定"(case 2c)。rc.1 给 NavigationItem 真实类型后,NavigationArea 的钉扎理由当场过期,反向 pin 按设计报红,tsc 拒绝断言并指向注释;重新分诊后覆盖依然成立,但依据换成"形状不同 + visible 被 spec 每层拒绝"。这是机制第一次证明它能防住"理由过期而结论还挂着"。
    5. 新名字必须先过守卫:一轮撞掉三个候选名,SchemaValidationResult 与 SchemaValidationReport 双双是 spec 导出。
    6. 跨包同名 ≠ 同一概念,两次推翻了预设的跨包指引:app-shell 的 FilterCondition 是 ObjectQL AST(应 re-export),components 的才是 FilterBuilder 行(应改名);OfflineConfig 该保留原名的是 react 而非 types。照搬跨包结论就是本 issue 要消灭的失效模式本身。

    并行的代价(流程结论)

    三个批次并行导致容器 OOM 全灭、损失约七小时,且 DEBT 块反复冲突。现行标准已改为上一批 PR 真正合入 main 后才派下一批——不是"执行者空闲就派"。冲突的机械解法已验证多次:合并 main → 冲突块清空 → --ledger 整块重生成 → 守卫双向棘轮背书。

    待拍板

    剩余

    批次 6(objectui#3160,12 条)、批次 7(objectui#3161,14 条)未开始;批次 8(objectui#3162)等 objectstack#4171。本轮到此暂停。


    Generated by Claude Code

  16. os-zhuang commented on Aug 2, 2026

    @os-zhuang
    ContributorAuthor

    批次 6/8 已合入 —— objectui#3160 → objectui PR #3220(2026-08-02)。

    台账 29 → 17,包数 14 → 11:@object-ui/data-objectstack、@object-ui/plugin-chatbot、@object-ui/plugin-list 三包整包出账。

    处置 符号
    re-export DroppedFieldsEvent、PendingActionRow、PendingActionStatus
    结构性派生 ViewTab(取 schema 的 input 侧 —— 三个 .default() 键在 output 侧变必填,会让授权侧形状不可表达;同 ObjectFieldGroup / OfflineConfig 踩过的 _input/_output 陷阱)
    改名 CacheStats→MetadataCacheStats、MetadataSaveOptions→MetadataClientSaveOptions、SecurityPolicy→SecurityManagerPolicy、ValidationError→DataApiValidationError
    ALLOW ListView、UserFilters
    路径跳过 Tool、MessageContent(plugin-chatbot/src/elements/ 是 vendored 的 Vercel AI Elements + shadcn 原件,与已跳过的 components/src/ui/ 同类)

    方法学:本批新增两条

    1. 「已知情报」要逐符号对读,不能整簇采信。 立单时预判「SecurityPolicy / DroppedFieldsEvent 大概率是手抄件,优先派生」,实测只中一半:DroppedFieldsEvent 确是手抄件,SecurityPolicy 与 spec 的那个一个键都不重合(spec 是软件包供应链策略,本地是浏览器 CSP + 审计日志 + 字段脱敏),MetadataSaveOptions 同理(spec 两份都在描述「把元数据写成文件」,本地是 MetadataClient.save() 的 HTTP 信封)。两对均 Extract = never,已按该式钉住。与批次 5 的结论同源、方向相反(那次是「疑似撞名实测可派生」)。

    2. 手抄件的真正危害是它注销了一个检查,不是它「少了个键」。 本批三份各用一种方式关掉编译期检查:

    三种写法都能过 code review、都能过测试。这是本燃尽工作最核心的价值。

    3. 路径跳过要配 provenance 测试。 跳过 src/elements/ 会开一个洞:objectui 自己写的文件藏进该目录就不被扫描。本批一并补上——该目录每个文件必须带 vendor banner,唯一的 objectui barrel 只能是纯 re-export;变异测试第 5 向验证了洞与补丁都成立。同时按批次 5 PerformanceConfig 的做法配了反向 pin:spec 若退掉 Tool/MessageContent,跳过的理由就过期,测试报红要求重读。

    4. 改名不动线上契约。 DataApiValidationError 的运行时 name 仍是 'ValidationError' —— normaliseClientError 与 @object-ui/react 的 error-message 都在嗅探该字符串,那是线上契约,改它是行为变更。renames 全部按标识符边界替换,字面量一个没动。

    待复核(非阻塞)

    ListView / UserFilters 走 ALLOW 而非改名,依据是批次 5 的 AuthProvider 判据(非「组件一律豁免」——同批 Field → FieldContainer 被改名):两者都是渲染器且在声明处就绑住 spec 派生类型(ListViewProps.schema / UserFiltersProps.config),自身不声明形状,故无可漂移的形状;@object-ui/types 早已从另一侧 re-export 为 SpecListView 做消歧。ALLOW 配了会过期即报红的钉扎。若倾向改名,ObjectListView / UserFilterBar 已跑过守卫确认可用。

    剩余

    批次 7/8(objectui#3161,长尾 9 包 14 符号)与 批次 8/8(objectui#3162,被 #4171 阻塞的 any 擦除簇 3 符号)。批次 7 即将派发。


    Generated by Claude Code

  17. os-zhuang commented on Aug 2, 2026

    @os-zhuang
    ContributorAuthor

    批次 7 已合并(objectui#3224)—— 台账 17 → 3,11 包 → 1 包

    十个包整包出账。剩余全部是批次 8(objectui#3162)的 @object-ui/types 三条,逐字节未动:

    @object-ui/types: JoinedReportBlock, NavigationItem, NavigationItemSchema
    

    批次 8 仍被 objectstack#4171 阻塞,不派。 它一解除,燃尽即可收口。

    验收门:--ledger set-diff removed 恰 14 / added 空 / 其余包逐字节不变;零新增 ALLOW——14 个符号全部真解决,没有一个被寄存成新债(对比批次 6 的 ListView/UserFilters 走 ALLOW)。CI 15 项 13 绿 2 skipped。


    本批换来的方法学(五条,前两条是新形态)

    一、「同名但前置条件不同」比普通 fork 更危险

    plugin-grid 的 isMultiValueField 与 spec 同名同语义,预判是「语义一致就 re-export」——不能 re-export:spec 那个要求 type 必填,本地那个的调用点是 objectFields?.[p.name],例行传 undefined。

    危险在于:两个模块之间调错 import 仍然能编译,只在「字段不存在」这一条路径上改变行为。普通 fork 至少两边都能独立跑对;前置条件不同的同名函数,是一颗只在稀有路径上炸的雷。

    → 判据修正:re-export 的条件是语义一致 且前置条件一致,不是语义一致就够。落法是改名 hasMultiValueShape + 把分类委托给 spec 的函数(原来它拿 MULTI_OPTION_TYPES 自己重推规则——一个自称 "consumed instead of restated" 的 helper 最后还是把规则 restate 了)。

    二、新形态:顶着 spec 的名字,却是一个更宽的类型

    spec 的 ColumnSummaryConfig 只是 ListColumn.summary 的对象形态;简写形态 'sum' 在 spec 里叫 ColumnSummary。本地那个顶着 ColumnSummaryConfig 的名字装了整个并集。

    于是:同一个名字,一边接受 'sum',另一边拒绝。任何 parity/行为测试都发现不了——两边各自都是对的,只是在说不同的事。

    → 之前记录的碰撞形态都是「窄了」或「漂了」;「宽了」是第三种,而且它是唯一一种在测试里表现为「两边都对」的。落法:取 NonNullable<ListColumn['summary']>,由 spec 自己定义它接受几种形态。

    三、预判错在第三个方向:部分顶着整体的名字

    跟踪评论建议「先问 providers 的 Theme 是运行时已解析的主题,还是授权 JSON」。实测两个都不是:它是 Theme['mode'] 顶着 Theme 的名字——一个字符串联合,站在 spec 一整份文档(colors/typography)的位置上。

    这比一份漂了的副本更误导:下一个 session 读到 @object-ui/providers 的 Theme,会得出「ObjectStack 的主题是个字符串」的结论。

    顺带第三次印证批次 3 的规则:显而易见的新名字恰恰最容易已被占用——这个概念在 spec 里就叫 ThemeMode,也是 spec 导出。新名字必须先跑守卫。

    四、「派生」不等于「派生自正确的东西」——第二个活样本

    变异测试的第六向:把 ThemePreference 改回叫 Theme,但保留派生——守卫放行(派生就是派生),只有 tripwire 测试报红。

    这是批次 5 ConflictResolutionStrategy 那条结论的第二个实例,现在可以升为规则:守卫能看见「有没有派生」,看不见「派生自哪个概念」。 所以每一次改名都必须配一条钉住新名字的测试,不能只靠守卫。本批 10 个 tripwire 文件 / 30 条断言就是这条规则的执行。

    五、逐字副本的内容如果是线上契约,共享的收益是另一个量级

    fields 的 isFileIdToken 与 spec 逐字符相同(/^[A-Za-z0-9_-]{1,64}$/),注释写着 "Mirrors the platform's"。与批次 3 的 isAggregatedViewContainer 同型——所有行为断言全绿,只有引用同一性能区分(闸门:expect(isFileIdToken).toBe(specIsFileIdToken))。

    但这一份特别:那个正则是线上契约。服务端哪天放宽它,本地副本仍按老边界回答「这不是一个引用」,widget 于是朝一个期待引用的后端提交 legacy inline blob——症状是缩略图坏掉,离一个正则十万八千里。


    裁决:spec 依赖边按 A(接受),批次 5 留下的 待拍板 一并结掉

    批次 5 在 @object-ui/components 上提出、至今挂在本单的同一问题,连同批次 7 的 fields/providers(devDep → dep)、layout/runner/sdui-parser/collaboration(新增 devDep),统一按接受:

    • 一个包的公开 .d.ts 引用了 spec 却把 spec 放 devDependencies,消费方拿到解析不了的类型——这不是洁癖问题,是坏掉的包;
    • 「不声明依赖」的替代品是复制,而复制正是本燃尽任务要消灭的缺陷。选项 B 的字面含义就是把刚拆掉的 fork 抄回去;
    • 经 @object-ui/types 转一道(选项 C)保住绑定但让来源多一跳、更难读——与「让 AI 写元数据不容易出错」相反。

    若某个包是按策略刻意 spec-free,在本单指出是哪个,我改派回滚那一个。 package.json 级改动,回退成本接近零。


    燃尽进度

    批次 范围 状态
    1–6 — ✅ 已合并
    7 10 包 14 符号(长尾) ✅ 本次合并
    8 @object-ui/types × 3 🚧 阻塞于 objectstack#4171

    本批捎带立单:objectui#3221([key: string]: any,已入队,是 #3222 的硬前置)、objectui#3222(协议面,已标 needs-user-decision)、objectui#3223(死渲染器,已入队按 ADR-0049 派删除)。


    Generated by Claude Code

  18. os-zhuang commented on Aug 5, 2026

    @os-zhuang
    ContributorAuthor

    分诊:补 repo:objectui 路由标签(仅分类半边,不动任何认领状态)。

    • 落点锚定:全部批次落在 objectui(守卫脚本、各 @object-ui/* 包台账、tripwire 测试),按 rule 1 打 repo:* 不打 domain:*。
    • 在飞状态确认(读完全部评论):批次 1–7 已 MERGED(台账 115 → 3,仅剩 @object-ui/types 三条),批次 8(objectui#3162)明确阻塞于 objectstack#4171,认领在案且有持续产出 —— 不属 stale-claim,不回收、不改派。补标签只为把本单纳入路由索引,消除「有 assignee 但无 pm/domain 标签」的扫描悬置(上轮收尾简报的遗留核验项)。
    • 给执行/收尾者的备忘:待 objectstack#4171 解除后批次 8 收口即可全单闭环;评论区尚有三处「待拍板」(守卫扫描面扩到 apps/、PerformanceConfigSchema 退休 vs 搬家、spec 依赖边已按 A 接受)——收口前值得让维护者过目一次。

    本评论来自分诊座位 Routine(#5474 试点),不构成认领。


    Generated by Claude Code

  19. os-zhuang commented on Aug 6, 2026

    @os-zhuang
    ContributorAuthor

    存量裁决轮(维护者 2026-08-06 委托,session_01LeEfA7CFwbJb7JJmXm2KM3):补落 tracking 标签——CI 规则本体已落地(objectui#3032),批次 1–7 已合并、台账 115→3,仅剩批次 8(objectui#3162)等 objectui 侧开工信号(上游 #4171 已关)。在飞多批次程序卡,认领活跃非 stale,不是单次派发单。打标使其退出分诊扫描范围。


    Generated by Claude Code

  20. os-zhuang commented on Aug 10, 2026

    @os-zhuang
    ContributorAuthor

    Migrated to objectstack-ai/objectui#4043 under #7167 (file-at-destination ruling, maintainer 2026-08-10). Native GitHub issue transfer is not available to this session's credential, so the card was recreated at the destination; this thread stays as the authoritative history and is linked from the new card. Closing as not planned here — moved, not rejected.


    Generated by Claude Code

  21. claude commented on Aug 30, 2026

    @claude
    Contributor

    Ledger closed — batch 8/8 landed with zero re-exports

    Final batch (objectui#3162) is done, via objectui PR objectstack-ai/objectui#6883. Leaving
    the summary here as the record; this issue stays closed.

    The last four symbols are all NOT burnable, and three of them for reasons that are not
    the one the ledger recorded. The batch was filed as "blocked on objectstack#4171 — burn down
    when the upstream any erasure lifts". #4171 closed completed 2026-07-30, which was the
    designed start signal. Re-measuring each symbol in the built dist at @objectstack/spec
    17.2.0 — not in source, and not from the fact that #4171 closed, because dist erasure is
    invisible from either:

    Symbol Upstream in dist Verdict
    JoinNode does not exist TS2305 from both /ui and /data, against a control import that resolves. Spec 17.0.0 retired the query.joins cluster. Nothing to bind to.
    NavigationItem precise #4171 really did land. The three semantic blockers objectui#3177 pinned are untouched by it.
    NavigationItemSchema precise Live blocker is runtime shape, invisible to type-level probes.
    JoinedReportBlock still unknown Spec still declares the schema as a bare z.ZodTypeAny. #4171 typed the recursive schemas and never touched this one.

    The finding worth keeping

    NavigationItemSchema is the shape this whole ledger was created to catch, and it is the
    most dangerous form of it: its upstream became precise, which reads as "safe to bind
    now".
    It is not. That schema has a published consumer in objectui validate, and
    referencing the spec's would make it reject navigation metadata the renderer accepts today —
    pinned, defaultOpen and a separator carrying label fail unrecognized_keys against the
    spec's strict branches, visible: boolean fails invalid_union, and a one-character id
    fails too_small. All measured, behind positive controls.

    Generalising: "no longer any" was never the right admission question — objectui#3177
    established that, and batch 8 confirms it at the runtime layer too. A type-level erasure
    probe answers whether the upstream type is EMPTY, never whether binding it is SAFE.

    Two comments were found asserting an upstream type "erases to any" when it no longer does
    — one for NavigationItemSchema, one for FormField. That is this ledger's own failure mode
    turned inside out: a spent justification is a planted premise, and the next triage that
    checks it finds it false and burns down something it should not. Both corrected.

    Ledger state

    The enforceable half lived in objectui's check-spec-symbol-derivation.mjs as a DEBT map
    keyed to this issue. It is now empty — the three remaining symbols moved to that guard's
    ALLOW registry as declared dialects, each carrying its measured reason and naming its own
    release condition as a pin:

    before: 13 declared dialects, 3 untriaged collisions in 1 packages
    after:  16 declared dialects, 0 untriaged collisions in 0 packages
    

    So the ledger's remaining debt is no longer a card pointing at an upstream issue that has
    since closed without settling it. Every symbol that is still a local dialect now says why, in
    a place that fails when the reason stops being true.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions