Skip to content

列身份双读家族:field ?? name 两种优先序并存于 15+ 处——ingestion 归一 + 单键消费 + 禁新增闸门(objectstack#4115) #3104

Description

@os-zhuang

背景:同一列,两条管线解析出两个身份

列/字段身份的双读 field ?? name 在仓内已扩散成家族病:15+ 处、4 个键(field / name / fieldName / accessorKey·key),且两种优先序几乎对半分:

优先序 点位
name 优先 plugin-list/ListView.tsx ×8(name || fieldName || field)、plugin-grid/ObjectGrid.tsx:510、core/utils/dashboard-filters.ts:146
field 优先 core/utils/expand-fields.ts:107(field ?? name ?? fieldName)、plugin-detail/RelatedList.tsx:646,654(accessorKey || field || name)、plugin-detail/renderers/record-related-list.tsx:25(field || name || key)、app-shell/utils/resolveActionParams.ts:161;显示层另有 ViewPreview:58、SchemaForm:1410

spec 里列的规范键是 field(ListColumnSchema),name 是 objectui 遗留 —— 与表单正好相反(#3090:表单 runtime 键是 name)。同一个 pattern 在相邻表面语义相反,是 AI 复制粘贴必然踩雷的结构性原因;两种优先序并存说明污染已经发生过。

内建缺陷场景(待 PR1 落成红测试):列对象同时携带两键时,渲染路径(ListView,name 优先)与 $expand 请求路径(expand-fields,field 优先)解析出不同身份 → 展开了 A 字段、渲染的是 B 字段 → 关系列显示裸 id —— 已知高频缺陷类。排序键与渲染列不符、导出缺列、related-list 错列同根源。

方向(预设,PR1 开工时验证)

归一方向 = 列的规范键 field;归一点 = 元数据 ingestion chokepoint(先例:#2598 normalizeSchemaReferenceKeys 双键 stamp;app-shell 已有 view-item-normalize.ts 可挂);消费者逐步收敛为单键读;新增双读被闸门拒绝。不做的:在消费端继续铺 field ?? name(reference↔reference_to 的教训)。

分期

PR1 盘点 + 复现 + 归一器(零行为变化)

  • 点位清单入库为 ratchet 测试(inventory 含每处优先序;双读点位数只减不增)
  • 复现红测试:构造同时携带 field+name 的列,证明 ListView 渲染身份 ≠ expand-fields 请求身份(裸 id 场景落地)
  • 归一器 normalizeColumnIdentity(或挂 view-item-normalize):ingestion 处 stamp 规范键 —— 过渡期 stamp 双键(field 权威、name 镜像),行为零变化;mutation-test
  • 未知项查清并写进 PR:fieldName 第三键的写入方与存量;accessorKey 是 TanStack Table 内部键 —— 归一时区分「元数据身份」与「表格库适配键」,后者不属于本战役

PR2 消费者收敛

  • expand-fields / ListView(×8) / ObjectGrid / dashboard-filters / RelatedList / record-related-list / resolveActionParams 改读归一后的单键(归一器保证存在)
  • 每处收敛配行为测试;PR1 的复现测试转绿;ratchet 计数随之下降

PR3 闸门 + 响亮化

验收

  • 复现测试绿(渲染身份 === 请求身份)
  • ratchet 归零或只剩带理由豁免
  • 「关系列裸 id」缺陷类有回归钉住

业务价值:列表/详情是本产品第一表面,列身份解析不一致是「关系列裸 id / 排序不生效 / 导出缺列」工单的持续工厂 —— 修的是缺陷类,不是单个缺陷。playbook 全部来自 #3090 已验证的三板斧(chokepoint → 单键消费 → 闸门+响亮化)。

关联:objectstack#4115、#3090(playbook)、#2598(chokepoint 先例)、#3103(热身件,先行)。

Activity

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

    @os-zhuang
    ContributorAuthor

    收官:三期全部合并,家族归零

    期 PR 合并
    PR1 盘点 + 复现 + 归一器 #3119 6ae818e
    PR2 消费者收敛 #3122 95b7214
    PR3 闸门 + 响亮化 #3124 已合

    验收三条全部成立:复现测试绿(渲染身份 === 请求身份)、ratchet 家族归零、「关系列裸 id」缺陷类有回归钉住。


    对本 issue 正文的三处更正

    正文的盘点有几处与实况不符。写在这里而不是让它留在正文里——按 objectstack#4115 的说法,一个错误的清单就是给下一个 session 埋的前提。

    一、缺陷机制比正文描述的更严重:不是「不同文件用不同优先序」,是各自和自己打架

    正文的框架是「name 优先的点位」对「field 优先的点位」分散在不同文件。逐点位读完源码后,真正的机制是同一个文件里,请求路径和渲染路径读同一个 schema.columns 却解析出两个字段:

    路径 读法
    请求($expand / $select) f?.field,只读 field(单键读,扫描器看不见,因为它不是 alternation)
    渲染(FLS 闸门 / hiddenFields / fieldOrder / 导出 ×2 / hide-fields) f.name || f.fieldName || f.field,name 优先

    ListView 和 ObjectGrid 各自内部就是这个形状。后果有两个,正文只写了第一个:

    1. { field: 'account', name: 'account_name' } → 按 account 取数,而字段级权限检查、导出、hide-fields 全按 account_name。
    2. { name: 'account' } → 渲染路径把这列画出来,请求路径解析成 undefined 直接滤掉。列在那里,后面什么都没有。

    第二种在独立渲染的 object-grid 节点上尤其活跃(页面直接 author 一个 grid,上面没有 list-view,不经过 ingestion fold)。PR2 的测试把它照了出来:

    AssertionError: expected [ 'id', undefined ] to include 'account'
    

    —— 一个字面的 undefined 空洞进了 $select,服务端不返回该字段,整列全空。

    二、家族真实规模是 22,不是 15+,也不是 PR1 记的 24

    正文举证的两处不是同一身份的双读,而是两层概念(正是 #3090 的判别法):

    • resolveActionParams.ts(3 处) —— 同一文件里两种优先序都对。field ?? name 选的是「从 row 上读哪个键」(行数据按对象字段做键);name ?? field 命名的是「动作负载里的 param」,缺省取所绑字段名。
    • dashboard-filters.ts —— DashboardFilterDef 两个键都显式声明:name 是过滤器句柄,field 是所指字段。f.name || f.field 是「没起名的过滤器,句柄缺省取所绑字段」,是派生不是双读。

    PR1 我自己也误分流了两处,PR2 读清楚它们喂给谁之后改判:

    所以「两种优先序对半分 ⇒ 污染已经发生」这个推论,有一部分其实是两层概念。真正的污染在别处,而且更糟。

    三、accessorKey 与 key 都留在原位,是有意的

    accessorKey 是 TanStack Table 的列键(TableColumn.accessorKey),命名的是表格库列模型里的槽位;key 是通用条目键。两者都不是 ObjectStack 元数据身份,跨这条边界归一只会把合并固化下来。RelatedList 保持 accessorKey || columnIdentity(col),ObjectTree / record-related-list 保持 columnIdentity() || key。ratchet 有一条 pin 守住 accessorKey 的优先级——它一旦失守,导入的 TanStack 列会整列变空。


    未知项的结论

    fieldName 全仓没有写入方。 grep 'fieldName:' 的每一处命中都是函数形参或无关接口成员(AIFieldSuggestion.fieldName),没有任何代码往列对象上写它。它只活在读者里(防御性写法)。归一器仍然接纳(宿主存量元数据可能带),但这个键没有生产者。


    两处「查完之后决定不做」

    lint 规则不加。 正文要求评估 no-restricted-syntax 的误报率后再决定——评估做了:家族归零后扫描器剩下的 12 处命中,12 处全是合法的,误报率 100%、真阳性 0。因为区别在于这两个键在那一层里意味着什么,不在于表达式怎么写,语法规则看不见。叠上去等于在 12 处正确代码上写 inline disable,而那恰好训练下一个作者伸手拿 disable。ratchet 每个点位带 verdict 和 why,新命中必须被分流而不是被消音。评估过程写进了 ratchet 的 header。

    台账条目本来就合规。 ListColumnSchema 已是按引用 re-export,且 spec-subschema-parity.test.ts:154 已有 toBe(SpecListColumnSchema) 的 identity pin——正是 objectstack#4115 强调的那条(忠实的副本能通过所有值比较,引用同一性是唯一能区分 re-export 和 fork 的检查)。无需改动。


    一处明确没做,及原因

    fold 的「镜像 → 删除」没做。 我在 PR1 描述里把它列成了 PR3 的活,但本 issue 的 PR3 清单里没有这一项,而且风险是实的:columns 条目会越过包边界进入宿主应用和第三方 renderer,把 name 从它们脚下抽掉是我们没有清单的破坏性变更。PR2 收敛的是仓内消费者,这个前提只覆盖仓内。

    真要删需要单开一件:major/minor + 迁移说明 + 宿主侧排查。当前状态是自洽的——缺陷已闭合(家族归零 + ratchet 守住),矛盾元数据会在 dev 下自己喊出来。


    遗留观察(未开件)

    正文提到「同一个 pattern 在相邻表面语义相反,是 AI 复制粘贴必然踩雷的结构性原因」。#3090 和本件各自加固了自己那一侧,但相邻性本身没被处理——没有任何东西告诉作者「你现在在列这一层,不是表单那一层」。

    另外,动作参数身份是同一形状在隔壁一层:4 处 param-name 读点(resolveActionParams ×2、ActionPreview、resolveBulkActions)目前全是 name 优先、一致,那 1 处 field ?? name 是另一层概念。今天没有缺陷,但没有共享 reader,一致性靠手工维持——结构上等同于本件漂移之前的状态。属于预防,不是止血。


    Generated by Claude Code

  3. added a commit that references this issue on Aug 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions