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.bFAULT no such overload: dyn< null > < dyn< null >FAULT No such key: a
record.a != nullfalse修法有效FAULT No such key: a

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

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

逐面台账

绑定依据结论
对象校验规则全量rule-validator.tsmaterializeDeclaredFields(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}排除
共享规则 conditionn/a下推 SQL,三值逻辑不 fault排除(#4811 §4 已记)
Field.formulan/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

vercelBot commented Aug 3, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredAug 3, 2026 5:03pm

Request Review

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

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 freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants

@xuyushun441-sys@claude