Skip to content

feat(lint): null-guard 闸门覆盖 requiredWhen,其余各面按绑定全量性定案 (#4811) - #4951

Merged
xuyushun441-sys merged 1 commit into
mainfrom
claude/issue-4811-null-guard-coverage
Aug 3, 2026
Merged

feat(lint): null-guard 闸门覆盖 requiredWhen,其余各面按绑定全量性定案 (#4811)#4951
xuyushun441-sys merged 1 commit into
mainfrom
claude/issue-4811-null-guard-coverage

Conversation

@xuyushun441-sys

Copy link
Copy Markdown
Contributor

Fixes #4811

#4763 的闸门只接了两面,其余留作「待定」。本 PR 把「待定」收敛成一条可判定的判据,按它逐面定案,并把每条排除的理由留在代码里 —— 一个只覆盖部分面、又没有任何东西说出这件事的闸门,正是这一族缺陷本身的形状。

议题正文里的两处判断经实测不成立,已照实修正(详见下文 §2、§3)。


1. 判据:记录绑定是否对已声明字段全量

不是口味问题,也不是「这个谓词是不是 CEL」。实测 @marcbachmann/cel-js,两种绑定下语义恰好相反:

谓词 全量绑定 {a: null} 稀疏绑定 {}
has(record.a) true ← 陷阱 false真守卫
record.a < record.b FAULT no such overload: dyn< null > < dyn< null > FAULT No such key: a
record.a != null false修法有效 FAULT No such key: a

全量绑定下 has() 恒真而无用、!= null 是解药;稀疏绑定下 has() 恰恰是正确的守卫,而 != null 自身就会 fault

把闸门指向稀疏绑定的面,等于判红正确的元数据、并给出一个会把它改坏的「修法」——比不覆盖更糟。只有绑定全量的面才可以接入。

逐面台账

绑定 依据 结论
对象校验规则 全量 rule-validator.ts materializeDeclaredFields(merged, …) 已覆盖 (#4763)
hook condition 全量 hook-wrappers.ts 同上 已覆盖 (#4763)
字段 requiredWhen 全量 同一个 merged;且 fail-open 本 PR 纳入
字段 readonlyWhen 稀疏 stripReadonlyWhenFields 合并 {...previous, ...data},不物化 排除
action visible/disabled 稀疏 客户端记录;objectui 该路径无任何物化 排除
flow / edge condition 稀疏 record-change-trigger.ts 播种 {...inputDoc, ...after} 排除
共享规则 condition n/a 下推 SQL,三值逻辑不 fault 排除(#4811 §4 已记)
Field.formula n/a 产品判断,非接线缺口 排除

2. 纳入:字段 requiredWhen(议题未列出的一面)

议题列了 action / flow / formula 三面,而唯一满足判据的是它没提的这一面:evaluateValidationRules 用与对象校验规则同一个 materializeDeclaredFields 合并记录求值 requiredWhen

它也是几个已覆盖面里失败得最安静的:谓词 fault 时是 fail-open —— rule-validator.ts 记一行 failed to evaluate — skipped 就跳过,字段于是从未真正必填,写入照常通过。校验规则自 #4761 起至少是 fail-closed 的拒绝。

所以报错文案按面区分后果(新增 NullGuardOutcome):「被跳过、字段从未必填」与「写入被 fail-closed 拒绝」是两个相反的故障,作者需要知道自己碰到的是哪一个。两条文案各有断言钉住,防止互相串用。


3. 排除,各自留下可引用的记录

每条都写进 validate-null-guards.ts 的台账,并在对应调用点留了注释,各配一条断言。

  • action visible / disabled —— 议题问的是「ActionEngine 求值前是否物化已声明字段」。只读确认:没有。 objectui 这条路径上不存在任何物化步骤,绑定是客户端已取到的那条记录(详情读取,或只带列表视图投影列的一行)。
    需要说清的是:陷阱在这一面是真的 —— 裸串经 ExpressionInputSchema 规范成 {dialect:'cel'} 信封,渲染器(toPredicateInputuseCondition)保留它并路由到真 CEL,fault 也确实 fail-closed(action 静默消失)。挡住闸门的不是语义,而是绑定的稀疏性:那里 != null 是错的修法。要覆盖它得先决定是否把该绑定做成全量 —— 平台契约改动,不是 lint 改动

  • flow / edge condition —— 议题记的理由是「扁平作用域下裸标识符可能是 flow 变量」。该理由对本模块不成立:findUnguardedNullableOperands 只解析 record.< f > / previous.< f >,从不解析裸标识符,而引擎无条件绑定这两个根(variables.set('record', …) / set('previous', …)),因此天然免疫那个歧义。
    真正的阻碍还是全量性:record-change-trigger.ts 把记录播种为 { ...inputDoc, ...after },没有 materializeDeclaredFields,所以写入未提及的已声明列是缺键而非 null,!= null 会和它本要守卫的比较一样 fault。
    (附带记录:扁平歧义本身是真的,只是属于另一个未建的 pass —— flow 输入会遮蔽记录字段(if (!variables.has(k))),节点 outputVariable 又能覆盖两者,所以健全的裸标识符 pass 必须减去 flow 输入、所有 outputVariable、screen 收集变量名与节点 id。)

  • 字段 readonlyWhen —— 与 requiredWhen 同一个字段、相反的结论,分歧点正是判据本身:它由 stripReadonlyWhenFields 求值,那里合并 { ...previous, ...data },从不物化。

  • Field.formula —— 按产品判断排除,而非按本判据(按 PM 分派约束,不在本单)。formula 是 value 角色、天然可空,guard ? value : null 是被祝福的写法(Shipped template formula fields silently evaluate to null on @objectstack 15.1.1 — daysBetween / Timestamp−Timestamp / floor in stored formulas (hr tenure_years, time_off days) #3306)。是否强制守卫会改变「作者被允许写什么」,该由维护者决定。


4. 实测数字

  • examples/** 里真实的 has(record.*) 用法:2 处,均在 app-showcase/src/data/objects/account.object.ts校验规则(已覆盖面)上,且都正确配了 != null —— 判绿,符合预期。
  • action / flow / formula / requiredWhen 四面上真实的 has(...) 用法:0 处
  • examples/** 里落在排序/算术运算符上的 requiredWhen:1 处 —— showcase_invoice_line.descriptionrecord.quantity >= 100quantityrequired: true + defaultValue: 1,不可空,故判绿(已加断言钉住这条真实元数据)。
  • 新覆盖对 examples/** 现存谓词的判红数:0

5. 双向证明(真实输出)

requiredWhen 上构造 has(a) && has(b) && a < b(落在可空的已声明字段 start_date / end_date):

改前(origin/main)

### A. field requiredWhen — the has()/has()/< trap on nullable declared fields
  GREEN — 0 issues

改后

### A. field requiredWhen — the has()/has()/< trap on nullable declared fields
  [error] object 'showcase_project' · field 'note' requiredWhen
    field 'note' requiredWhen applies `<` to `record.end_date`, which 'showcase_project'
    declares as nullable (no `required: true`, no `defaultValue`). `has(record.end_date)`
    does not guard it. At runtime the operand is null, CEL has no `<` overload for null,
    and the whole predicate aborts — so the predicate is SKIPPED fail-open — the field is
    never actually required, the write proceeds unchecked, and the only trace is a
    `requiredWhen … failed to evaluate — skipped` log line (#4649/#4811). The predicate
    compares a value that is null. Guard it with '!= null' — 'has(x)' does NOT do that:
    a declared field holding null is still PRESENT, so has(x) is true.
  [error] object 'showcase_project' · field 'note' requiredWhen
    … 同上,operand 为 `record.start_date`

点名了规则、操作数、!= null 修法,并沿用 #4763 的现成文案收尾(与运行时同一句)。

正例(改前改后均判绿)

### B. field requiredWhen — same predicate rewritten with != null      → GREEN — 0 issues
### D. real showcase_invoice_line requiredWhen (required + defaultValue) → GREEN — 0 issues
### E. EXCLUDED — action visible carrying the same trap                → GREEN — 0 issues
### F. EXCLUDED — field readonlyWhen carrying the same trap            → GREEN — 0 issues

has() 用在未声明键上(它的正当用途)不判红 —— 断言按 null-guard 判决过滤,因为独立的 #1928 字段存在性检查对未声明名字另有(既有且正确的)意见。


6. 顺带修正:field '?'

诊断的字段名此前走 Object.values(fields),把名字键丢掉了 —— 而名字键正是 Field.text({…}) 这种(最常见的)写法产生的形状,于是这类对象上每条字段级诊断都定位在 field '?'。名字只出现在 where 里时还能忍;现在报错正文要告诉作者改哪个字段,就不能忍了。改前/改后输出里可直接看到 field '?'field 'note'

7. 一处既有 fixture 被判红(真阳性,已按判据修 fixture 而非放宽闸门)

validate-expressions.test.tsaccepts record-qualified field rules …qty 声明为可空,其 record.qty >= 100 被新覆盖判红 —— 这是真阳性(可空字段上的 >=,运行时 fault、requiredWhen 静默失效)。该用例的意图是裸引用 vs 限定引用(#1928)与 parent 命名空间,与可空性无关,故把 fixture 对齐真实的 showcase_invoice_line.quantity(required + defaultValue),没有放宽判据绕过


验证

pnpm --filter @objectstack/lint test       → Test Files 54 passed (54) / Tests 980 passed (980)
pnpm --filter @objectstack/lint typecheck  → tsc --noEmit,无输出
pnpm --filter @objectstack/lint build      → ESM/CJS/DTS build success
npx eslint <三个改动文件>                   → 干净

改动范围限于 packages/lint(+ changeset)。未改根 package.json.github/workflows/scripts/,未触碰 content/docs/releases/


Generated by Claude Code

#4763 的 null-guard 闸门只接了校验规则与 hook condition,其余各面留作"待定"。
本次把"待定"收敛成一条可判定的判据 —— **记录绑定是否对已声明字段全量** —— 并按
它逐面定案,每条排除都在代码里留下可引用的理由。

实测 cel-js:全量绑定下 `has()` 恒真而无用、`!= null` 是解药;稀疏绑定下
`has()` 恰是正确守卫、而 `!= null` 自身 fault(`No such key`)。两种绑定的语义
恰好相反,所以把闸门指向稀疏绑定的面会判红正确的元数据,并给出会把它改坏的修法。

纳入:字段 `requiredWhen` —— 议题未列出,却是唯一满足判据的面
(`evaluateValidationRules` 用与校验规则同一个 materialize 合并记录求值),
且失败得最安静:fault 时 fail-open,字段从未真正必填。报错文案按面区分后果。

排除并记录:action visible/disabled(客户端记录非全量)、flow/edge condition
(trigger 播种 `{...inputDoc, ...after}`,非全量 —— 议题记的"裸标识符歧义"对本
模块不成立)、字段 readonlyWhen(strip 路径不物化)、Field.formula(产品判断)。

顺带修正字段名解析:此前走 Object.values 丢掉名字键,名字键形状的对象上每条
字段级诊断都定位在 `field '?'`。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018iARDqtrhQgz6fVHDeDkbQ
@vercel

vercel Bot commented Aug 3, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 3, 2026 5:03pm

Request Review

@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Aug 3, 2026
@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/lint.

3 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/automation/hook-bodies.mdx (via @objectstack/lint)
  • content/docs/permissions/authorization.mdx (via @objectstack/lint)
  • content/docs/releases/v17.mdx (via @objectstack/lint)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

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

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

null-guard 闸门(#4763)只覆盖了校验规则与 hook 条件 —— action / flow 条件两面待定,formula 面待判

2 participants