fix(lint,spec,objectql): 编写期识别注册表注入的系统列 —— 一份权威派生,registry 与 linter 共用 (#5378) - #5904
Merged
Merged
Conversation
…author time (#5378) `buildFieldIndex` (validate-expressions.ts) and the `highlightFields` existence check (validate-semantic-roles.ts) resolved field references against the AUTHORED `fields` map only, so every registry-injected system column was invisible: `has(record.owner_id)` was hard-rejected as an unknown field and `highlightFields: ['owner_id']` warned that the column "is not a field on this object". The platform's own linter denied the platform's own contract, and apps escaped it by re-declaring system columns — hotcrm#548 declared `owner_id` on all 12 business objects for exactly this reason. Add `resolveInjectedSystemColumns()` (@objectstack/spec/data): a pure per-object derivation of WHICH system columns the platform provisions, and make the registry's `applySystemFields()` consume it. Same split #3786 established for the audit family — the spec declares which columns exist, the registry owns what each one looks like. `@objectstack/lint` (contract: depends on spec, never on a runtime) reads the same derivation, so the author-time verdict cannot disagree with the runtime that provisions the column. The union is CONDITIONAL per object, not a blanket allowance of every system name: `ownership: 'org' | 'none'` gets no `owner_id`, so `record.owner_id` there is still the error it should be; `tenancy.enabled: false` gets no `organization_id`; `systemFields: { audit: false }` gets no audit family; `systemFields: false` / `managedBy: 'better-auth'` get nothing but the driver-provisioned `id`. Injected names also join the "did you mean?" candidates. The type-soundness and null-guard indexes deliberately stay declared-only (reasons at each site): column types and nullability belong to the registry's definitions, and the null-guard index feeds a build-breaking verdict. Injection behaviour is unchanged — a new parity pin compares the derivation against the live injection pass across the full condition matrix. Fixes #5378
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 3 package(s): 113 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
os-zhuang
marked this pull request as ready for review
August 6, 2026 11:35
This was referenced Aug 6, 2026
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 #5378
缺陷
平台在每个业务对象上注入
owner_id/created_at/organization_id等系统列,文档把ownership: 'user'写作 "injects reassignable owner_id"。但编写期两处校验只读作者声明的fields,注入列一律当作不存在:packages/lint/src/validate-expressions.ts的buildFieldIndex→has(record.owner_id)硬拒为unknown field owner_id;packages/lint/src/validate-semantic-roles.ts的highlightFields存在性检查 →['owner_id']警告 "is not a field on this object — it is silently skipped by every consumer"。即平台自己的 linter 否认平台自己的契约。后果是应用被迫重声明系统列才能过编写期:hotcrm#548 在全部 12 个业务对象上显式声明
owner_id(6 个对象报highlightFields警告,contact_welcome的has(record.owner_id)被硬拒)。declared-but-not-honoured 的标准形状。在当前
origin/main上两处盲点均复现(含record.id—— 同类,且是最常用的写法):权威来源:先找,再消费,不手抄
注入的单一事实源是 registry 的
applySystemFields()(packages/objectql/src/registry.ts)。SystemFieldName的文档本身就写明它只是名字注册表、不是注入列集合,并点名applySystemFields()为按对象的权威——但那个权威此前只能靠跑运行时拿到,而@objectstack/lint的包契约是 "Depends on @objectstack/spec; never on a runtime",根本读不到。于是它自己回答,或干脆不回答。因此本 PR 把判定上提到 spec,并让 registry 消费它:
resolveInjectedSystemColumns()(packages/spec/src/data/injected-system-columns.ts)—— 纯派生,只读对象自己声明的键;applySystemFields()改为消费该派生(plan.tenant/.audit/.owner/.owningBusinessUnit),两处 early-return 折叠进派生;injectedColumnsFor()(system-fields.ts)转发同一派生,两个消费面共用同一判定。沿用 #3786 为审计字段族确立的分工:spec 声明有哪些列,registry 拥有每列长什么样。⛔ lint 里没有第二份列清单,也没有第二份条件。
派生是纯函数:实测
multiTenant只影响organization_id是否indexed,从不影响列是否存在——所以编写期无需任何运行时上下文即可给出与运行时一致的答案。有条件并入(实测矩阵)
不是无条件放行整张系统列名单:
ownership: 'user'organization_id+ 审计四列 +owner_id+owning_business_unit_idownership: 'org'/'none'record.owner_id仍是真错误)ownership: 'business_unit'owning_business_unit_id,无owner_id(ADR-0117 D1 / #5677 正向清单)sys_*名 / 任意managedBytenancy.enabled: false/systemFields.tenant: falseorganization_idsystemFields: { audit: false }systemFields: false/managedBy: 'better-auth'idsystemFields: false是被折叠进派生的一个真陷阱:它不是对象,朴素的sf?.audit !== false读法会让审计族仍然开着——旧代码靠 early-return 兜住,现在由派生统一兜住。类型 / 文案
注入列在诊断与补全语义上与授权字段等同:也进入 "did you mean?" 候选(
record.ownerid→ 提示owner_id)。类型健全性索引与 null-guard 索引刻意仍只读声明字段,理由写在各自注释:列的
type与可空性属于 registry 的列定义(在 lint 另立一份就是本次要消灭的第二份副本);且 null-guard 喂的是会中断构建的判定,擅自并入会让今天能构建的 stack 变红。少覆盖,不误报。反向验证 —— 方向按测试分别预判,两个方向都实测
同一套改动下两类测试的正确预期不同,如实记录:
owner改成无条件):预判 lint 反例测试红、spec 单测红、parity 测试仍绿。实测完全吻合——lint 4 条反例红(ownership: 'none'/'org'各两面),spec 单测 3 红,parity 28/28 全绿。parity 之所以抓不到错条件,正因为 registry 消费同一派生、两侧一起动;它的职责是抓结构性分叉,不是抓条件对错。这一点写进了 parity 文件的头注,免得下一个读者误以为它在保证条件正确。ownership: 'business_unit'两行(手抄 deny-list 给出owner_id= true,与 D1 的语义正好相反)+systemFields: false两行(手抄条件没折叠硬 opt-out)+ flags 那条。这才是 parity 真正防的漂移。条件对错由 lint 反例测试与 spec 单测钉住;结构分叉由 parity 钉住。两层分工。
测试
pnpm --filter @objectstack/lint test→ 61 files / 1446 passed(新增 30 条:验收三条 + 全部反例)pnpm --filter @objectstack/spec test→ 322 files / 8235 passed(新增派生单测 13 条)pnpm --filter @objectstack/objectql test→ 127 files / 2107 passed(新增 parity pin 28 条)pnpm --filter @objectstack/cli test→ 85 files / 844 passed;metadata-protocol抽测 47 files / 461 passedtypecheck三包全绿;check:api-surface重新生成后绿(0 breaking / 2 added,纯增量)node scripts/check-nul-bytes.mjs绿 + 改动文件控制字节自扫描零命中验收三条(issue 原文)逐条:注入-only 对象上
has(record.owner_id)过 ✅;highlightFields: ['owner_id']不警 ✅;双向 pin(ownership: 'none'仍拒、tenancyopt-out 仍拒、审计 opt-out 仍拒、真未知字段仍拒、拼错仍拒)✅。examples A/B
三个 app
os validate改动前后诊断逐字节一致(仅Load time/ 耗时行不同):app-crm13 warn / 0 err、app-showcase61 warn / 0 err、app-todo4 warn / 0 err。零新增诊断;已显式重声明系统列的对象不受影响(重声明仍合法,并有专门测试钉住)。注入行为零改动
applySystemFields的输出在全条件矩阵 ×multiTenant两值上逐列不变(重构前后实跑对照,并由新 parity pin 长期看守)。hotcrm#548 的 workaround 自此非必需——12 个对象上纯为过编写期校验而加的
owner_id重声明可以回收(不在本仓,本 PR 未动 hotcrm,供其 PM 决定)。Generated by Claude Code