Skip to content
This repository was archived by the owner on Nov 4, 2026. It is now read-only.
This repository was archived by the owner on Nov 4, 2026. It is now read-only.

objectstack#4171 的三处 inverted pin 在 spec 17.0.0-rc.1 翻转了 —— NavigationItem / FormField / ConditionalValidation 该做 burn-down 了 #3177

Closed

Description

@os-zhuang

升到 @objectstack/spec@17.0.0-rc.1 的 PR 里,objectstack#4171 立的反向 pin(inverted pin)按设计触发了。那些 pin 的注释写得很清楚:

The day the spec types any of them properly, IsAny<…> flips to false, true satisfies false stops compiling, and the failure is the instruction: re-run the triage and burn that symbol down.

升级 PR 里只把断言翻转成记录当前事实(false satisfies IsAny<…>),没有做它要求的 burn-down——因为那会改动被广泛使用的公共类型,不该混进一次版本升级里。本条就是那笔欠账。

翻转了什么

符号 位置 rc.1 之前 现在 pin 要求的动作
NavigationItem packages/types/src/__tests__/spec-derived-unions.test.ts spec 侧 z.infer 出 any 已正确定型 objectui 的本地 interface 改为从 spec 派生
FormField 同上 同上 已正确定型 同上
ConditionalValidation.then / .otherwise packages/types/src/__tests__/validation-rule-spec-parity.test.ts spec 侧擦除为 unknown 已正确定型 「删掉分歧,整体派生 ConditionalValidation」

第四处 JoinNode 不在此列:spec 17.0.0 把这个符号连同 query.joins 整体退役了(framework#4286——查询路径上从没有引擎或驱动读过 join),所以它的 pin 已在升级 PR 里直接删除,没有「碰撞」可言了。

为什么当初要留分歧(别重新推导一遍)

any 会对任何 extends 提问点头,所以朴素的双向可赋值探测会把这些符号报成「与 spec 完全一致」,并建议恰好相反的修改——直接绑过去等于用 any 换掉一份精确、有文档的形状,是披着 burn-down 外衣的类型安全倒退。这正是那三处要写成显式 IsAny / IsUnknown 探针的原因。现在前提没了,绑定才第一次成为正确答案。

该做什么

  1. 逐个跑 triage:确认 rc.1 里这几个 spec 类型的形状确实覆盖 objectui 本地声明的用法(不只是「不再是 any」,而是足够精确);
  2. 把 NavigationItem / FormField 改为从 spec 派生,处理下游涟漪(这两个是公共类型,消费者不少);
  3. ConditionalValidation 按 pin 的原话「删掉分歧,整体派生」,同时移除 validation-rule-spec-parity.test.ts 里那半段 PINNED DIVERGENCE 的说明;
  4. 三处翻转后的记录性断言随之删除——它们的使命到此结束。

建议一个符号一个 PR,别一次推平:NavigationItem 和 FormField 的下游面不一样大。

Related

Activity

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

    @os-zhuang
    ContributorAuthor

    认领:PM 循环第 5 轮
    分支:claude/issue-3177-inverted-pin-burndown(objectui 仓库;按 issue 自身建议,三个符号可拆多个 PR,分支后缀 -nav/-form/-cv)
    Worktree:objectui-issue-3177
    分诊决定(PM 自决):按 issue 的四步执行——先逐符号 triage(确认 rc.1 形状足够精确而非仅「不再 any」),再按下游面大小排序落地,记录性断言随各自 PR 删除。


    Generated by Claude Code

  3. os-zhuang commented on Aug 2, 2026

    @os-zhuang
    ContributorAuthor

    Triage 结论(计划第 1 步):三个符号都没有通过「足够精确」的检验

    按 issue 自己的要求 —— 「不只是『不再是 any』,而是足够精确」—— 我逐个跑了 triage。结果是三个符号在 spec 17.0.0-rc.1 下都不该绑定,所以这次没有写任何代码、没有开 PR,把决定交回来。

    探针方法:在 packages/types/src/__tests__/ 下临时放一个 probe 文件,用 tsc -p packages/types/tsconfig.test.json --noEmit 编译(#3181 的 typetests gate 已经是真编译)。probe 里的断言全部写成 false,编译干净 = 每一条 false 都成立。实测 EXIT=0,--listFiles 确认该文件被读入。probe 已删除,工作树干净。


    1. NavigationItem —— 双向不可赋值,且有 spec 两个 tier 都没有的语义

    探针(全部成立):

    NavigationItem            extends SpecNavigationItem       = false
    NavigationItem            extends SpecNavigationItemInput  = false
    SpecNavigationItem        extends NavigationItem           = false
    SpecNavigationItemInput   extends NavigationItem           = false
    

    按 objectui#3190 的教训选了更宽的 z.input tier(SpecNavigationItemInput,它把 expanded / target 的 .default() 还原成可选)实测替换 app.ts 的 interface,光是 packages/types 自己的 build tsc 就 4 个 error:

    src/app.ts(273,7): error TS2353: Object literal may only specify known properties, and 'label'
      does not exist in type '{ type: "separator"; id?: string | undefined; order?: number | undefined; }'.
    src/app.ts(286,7): error TS2322: Type 'boolean | undefined' is not assignable to type
      'string | { dialect: "cel" | "cron" | "template"; source?: ...; ast?: unknown; meta?: ... } | undefined'.
      Type 'false' is not assignable to type 'string | { dialect: ... } | undefined'.
    src/app.ts(301,7): error TS2322: (同上)
    src/app.ts(313,5): error TS2322: (同上)
    

    后三条全部落在 menuItemToNavigationItem —— 那是导出的 legacy MenuItem 迁移转换,它的映射规则里明写 hidden → visible(取反),而 spec 的 visible 在 output 侧只有 Expression 对象、在 input 侧只有 string | Expression,两个 tier 都没有 boolean。

    spec 侧完全没有对应物的本地语义,一并会丢:

    本地键 现状 丢了会怎样
    visible?: boolean | string NavigationRenderer:903 evalVis(item.visible) visible: false 直接编译不过(上面 3 个 TS2322)
    pinned?: boolean app.ts 注释明写 "objectui-only; the spec has no counterpart";useNavPins + FavoritesProvider 整条 pin 链路建在它上面 侧栏 pin 功能失去类型
    defaultOpen?: boolean legacy 拼写,navigation-spec-parity.test.ts:69 有专门用例钉住 已发布 app metadata 的兼容承诺没了
    separator 带 label 本地 label: string 必填 见 TS2353

    还有 flat → union 的形状变化:packages/layout/src + packages/app-shell/src + packages/plugin-designer/src 的非测试代码里,item.< objectName / viewName / recordId / dashboardName / pageName / componentRef / actionDef / … > 这类不收窄的直读有 102 处。

    最关键的是:这件事仓库里已经有结论了。packages/types/src/__tests__/navigation-spec-parity.test.ts:30-36:

    Deliberately NOT modelled: the spec expresses navigation as a discriminated union of nine variants … objectui keeps one flat, all-optional shape … Converging on the union is a breaking change for every consumer that reads fields off NavigationItem without narrowing — tracked separately, not smuggled in here.

    any 的前提确实消失了,但**「flat vs union」和「三个 spec 没有的键」这两个障碍从来不是由 any 造成的**,pin 翻转并没有解决它们。


    2. FormField —— 不是同一个概念,仓库自己已经这么写了

    探针(全部成立):

    IsAny< SpecFormField >                = false   ← pin 正当地翻转了
    FormField     extends SpecFormField   = false
    SpecFormField extends FormField       = false
    'name' extends keyof SpecFormField    = false
    

    第 4 条是要害:两边的必填键互不存在于对方。本地必填 name(表单提交 key),spec 必填 field(被引用的对象字段名)。

    同名不同层,form.ts 自己已经用 ⚠️ 标出来了:

    ⚠️ Same key, different layer: in the SPEC form-view vocabulary field is a string … on a runtime FormField this slot is never a string, and its tripwire test pins that.

    本地 field?: Record< string, any >(解析后的字段元数据对象) vs spec field: string。其余分歧:

    • type:本地自由 string(组件注册表 key) vs spec 47 成员闭合枚举;
    • dependsOn:本地 DependsOnInput(string | 名字数组 | { field, param } 条目) vs spec 只有 string —— framework#4074 刚刚特意把本地这条放宽到与运行时 reader(resolveCascadingOptions)一致,绑回 spec 等于把那次修复原地退回去;
    • visibleOn:spec 按 ADR-0089 D2 在 schema 边界折进 visibleWhen,output 侧按构造不存在,而 objectui 的 Form-view FormField.visibleOn (CEL) is never evaluated — conditional fields always render #2212 wire 契约要保留它;
    • 本地独有还有 id / description / inputType / disabled / validation / condition / readonlyWhen / requiredWhen + 索引签名。

    而且 packages/types/src/index.ts:1122 已经把 SpecFormField 作为另一个公共类型导出,这正是 #3090 tripwire 要强制的消歧。绑定会让 FormField === SpecFormField,消歧一起塌掉。select-option-spec-parity.test.ts 开头那句是现成的判词:

    Unlike the FormField pair — two genuinely different concepts on two layers — a select option is ONE concept in two dialects …


    3. ConditionalValidation —— pin 翻转了,但翻转它的谓词太弱

    rc.1 里 then / otherwise 确实不再是 unknown。它们变成了 BaseValidationRuleShape:

    interface BaseValidationRuleShape {
      type: string; name: string; message: string; label?: string; description?: string;
      active?: boolean; events?: ('insert' | 'update')[]; priority?: number;
      tags?: string[]; severity?: 'error' | 'warning' | 'info';
      [key: string]: unknown;
    }

    spec 自己的注释就说这不够(object.zod-*.d.ts,ValidationRuleSchema 上方):

    it carries a [key: string]: unknown index signature, so it types the KNOWN keys and accepts any others. That is a real improvement over unknown (which types nothing) but it is not strictness — the discriminated union below is what actually rejects a malformed rule, at parse time. Removing the index signature is the #4075 family of work, not this change.

    探针实测:

    IsUnknown< SpecThen >                        = false   ← pin 因此翻转,合理
    IsAny< SpecThen >                            = false
    SpecThen['type'] extends 'script' | 'format' = false   ← 但无法按判别式收窄
    

    并且 const x: SpecThen = { type: 'script', name: 'n', message: 'm', totallyMadeUp: 123 } 编译通过。

    所以「整体派生」的实际交换是:

    现在 整体派生后
    then ObjectValidationRule(9 成员判别联合) BaseValidationRuleShape
    then.type 'script' | 'format' | 'conditional' | … string
    then.condition 按分支精确定型 unknown
    then: { type: 'formatt', … }(拼错) 编译报错 编译通过

    IsUnknown 探针问的是「有没有类型」,而 pin 的原话要求的是 "the spec types any of them properly"。BaseValidationRuleShape 满足前者、不满足后者 —— 这跟本文件顶部警告过的 any 陷阱是同一类错误:探针太弱,于是给出了恰好相反的建议。整体派生只是把同一个倒退从 unknown 挪到「弱一档」。

    补一句为什么作者端这道关是唯一的关:渲染器读裸对象、从不 parse(navigation-spec-parity.test.ts:15-16 已经指出这一点),所以 parse 期的判别联合根本不在渲染路径上。


    请维护者定夺

    选项 A —— 按 issue 原文执行,三个都整体派生。

    • 长期健康度:❌。三处都不是「删掉分歧」而是「用更弱的类型换掉更强的」;NavigationItem 还会丢 visible: boolean / pinned / defaultOpen 三个有实现在跑的语义,FormField 会把两层概念合并成一个,dependsOn 把 framework#4074 原地退回。
    • 对 AI 写的 metadata:❌。then.type: string + 索引签名、FormField 索引签名叠 spec 枚举 —— 拼错的规则类型、写错层的 field 全部编译通过,而渲染路径不 parse,错误会一路飘到运行时。

    选项 B —— 一个都不绑,改成修正探针的判据(推荐)。
    把三处 IsAny / IsUnknown 换成真正的准入条件,并把上面这些 gap 写进注释:

    • ConditionalValidation:探针改问「then['type'] 是否是字面量联合」/「是否还带索引签名」。今天它是红的(= 还不能绑),#4075 家族把索引签名去掉那天它变绿,burn-down 才真正到期。
    • NavigationItem / FormField:探针改问本地声明的真实用法是否可赋值(visible: boolean、pinned、必填 name vs 必填 field),而不是问「是不是 any」。
    • 成本:issue 的第 2/3/4 步都不执行,objectstack#4171 的三处 inverted pin 在 spec 17.0.0-rc.1 翻转了 —— NavigationItem / FormField / ConditionalValidation 该做 burn-down 了 #3177 的形态从「burn-down」变成「把 pin 的判据修对 + 记录三份 gap」。
    • 长期健康度:✅ 契约优先。不在消费者侧加任何容忍(不加 ??、不加 as),分歧留在它本来的位置并且由一个问对了问题的探针看着。
    • 对 AI 写的 metadata:✅。作者端保住 9 成员判别联合和必填 name,写错当场编译失败。

    选项 C —— 只做逐字段派生,不动符号本身。
    就像 app.ts 里 badgeVariant?: NonNullable< SpecObjectNavItem['badgeVariant'] > 已经在做的那样,把 NavigationItem 里凡是 spec 有精确对应的键逐个改成从 spec 分支类型取,visible / pinned / defaultOpen 保留本地。可以叠在 B 上面。

    • 长期健康度:🟡。真的减少了 restatement,但只覆盖 NavigationItem,FormField / ConditionalValidation 帮不上。
    • 对 AI 写的 metadata:🟡 小幅正向。

    我的推荐:B,NavigationItem 可选做 C。 两条轴指向同一边,没有取舍冲突:三处「绑定」都会把作者端的检查换弱,而作者端是这个体系里唯一在写 metadata 时能挡住错误的地方(渲染器不 parse)。issue 里那句「any 会对任何 extends 提问点头,所以朴素探测会建议恰好相反的修改」在 ConditionalValidation 上原样重演了一遍,只是这次点头的是索引签名而不是 any —— 所以要修的是探针,不是类型。

    另外:第 4 步「删掉三处记录性断言」在 B 下不能执行。它们是唯一还盯着这三处的机械装置,删掉等于让 gap 无人看管;正确做法是把它们改问对的问题,而不是删。

    ConditionalValidation 那条是上游 gap,但 spec 自己的注释已经点名 #4075 家族在跟踪,所以我没有重复开 issue。

    工作树已还原(git status --porcelain 空),基线绿:pnpm --filter @object-ui/types type-check 通过;vitest run 四个相关文件 Test Files 4 passed (4) / Tests 69 passed (69)。

    🤖 Generated with Claude Code


    Generated by Claude Code

  4. os-zhuang commented on Aug 2, 2026

    @os-zhuang
    ContributorAuthor

    PM 裁决(按升级判据属技术自决,不上传维护者):四问全部采纳方案 B,NavigationItem 可另叠加安全的 C 子集。

    开发 agent 将按此开 PR(修订探针 + 更新 PINNED DIVERGENCE 记述 + C 子集),以 Fixes #3177 关闭本 issue——重定向后的探针就是对「何时真正燃尽」的常设监听,届时由探针变绿触发新 issue。


    Generated by Claude Code

  5. os-zhuang commented on Aug 2, 2026

    @os-zhuang
    ContributorAuthor

    PM 验收:ACCEPT → PR #3194(已转正 + merge-when-ready,合并即关闭本 issue)。

    裁决(四问全 B + Nav C 子集)如实落地:三符号探针按各自真实阻塞逐点重定向(inverted-pin 风格,今日断言现状为绿、上游条件满足之日红转提醒);NavigationItemType 及六个同概念键从 spec 派生;零删除;附带修正了 check-spec-symbol-derivation.mjs 的过时表述并补 2c 类别(已定型但不精确——正是本次三符号的状态,原 any/unknown 探测对其均为盲区)。全仓 78 个 type-check 任务绿,逐探针 revert-proof 实测。燃尽的真实到期日自此由探针自动监听。


    Generated by Claude Code

  6. added a commit that references this issue on Sep 1, 2026
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