feat(lint): null-guard 闸门覆盖 requiredWhen,其余各面按绑定全量性定案 (#4811) - #4951
Merged
Conversation
#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
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 1 package(s): 3 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
xuyushun441-sys
marked this pull request as ready for review
August 3, 2026 17:06
xuyushun441-sys
enabled auto-merge
August 3, 2026 17:06
This was referenced Aug 3, 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 #4811
#4763 的闸门只接了两面,其余留作「待定」。本 PR 把「待定」收敛成一条可判定的判据,按它逐面定案,并把每条排除的理由留在代码里 —— 一个只覆盖部分面、又没有任何东西说出这件事的闸门,正是这一族缺陷本身的形状。
议题正文里的两处判断经实测不成立,已照实修正(详见下文 §2、§3)。
1. 判据:记录绑定是否对已声明字段全量
不是口味问题,也不是「这个谓词是不是 CEL」。实测
@marcbachmann/cel-js,两种绑定下语义恰好相反:{a: null}{}has(record.a)true← 陷阱false← 真守卫record.a < record.bno such overload: dyn< null > < dyn< null >No such key: arecord.a != nullfalse← 修法有效No such key: a全量绑定下
has()恒真而无用、!= null是解药;稀疏绑定下has()恰恰是正确的守卫,而!= null自身就会 fault。⇒ 把闸门指向稀疏绑定的面,等于判红正确的元数据、并给出一个会把它改坏的「修法」——比不覆盖更糟。只有绑定全量的面才可以接入。
逐面台账
rule-validator.tsmaterializeDeclaredFields(merged, …)conditionhook-wrappers.ts同上requiredWhenmerged;且 fail-openreadonlyWhenstripReadonlyWhenFields合并{...previous, ...data},不物化visible/disabledobjectui该路径无任何物化conditionrecord-change-trigger.ts播种{...inputDoc, ...after}conditionField.formula2. 纳入:字段
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'}信封,渲染器(toPredicateInput→useCondition)保留它并路由到真 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—— 判绿,符合预期。requiredWhen四面上真实的has(...)用法:0 处。examples/**里落在排序/算术运算符上的requiredWhen:1 处 ——showcase_invoice_line.description的record.quantity >= 100。quantity是required: true+defaultValue: 1,不可空,故判绿(已加断言钉住这条真实元数据)。examples/**现存谓词的判红数:0。5. 双向证明(真实输出)
requiredWhen上构造has(a) && has(b) && a < b(落在可空的已声明字段start_date/end_date):改前(
origin/main)改后
点名了规则、操作数、
!= null修法,并沿用 #4763 的现成文案收尾(与运行时同一句)。正例(改前改后均判绿)
has()用在未声明键上(它的正当用途)不判红 —— 断言按 null-guard 判决过滤,因为独立的 #1928 字段存在性检查对未声明名字另有(既有且正确的)意见。6. 顺带修正:
field '?'诊断的字段名此前走
Object.values(fields),把名字键丢掉了 —— 而名字键正是Field.text({…})这种(最常见的)写法产生的形状,于是这类对象上每条字段级诊断都定位在field '?'。名字只出现在where里时还能忍;现在报错正文要告诉作者改哪个字段,就不能忍了。改前/改后输出里可直接看到field '?'→field 'note'。7. 一处既有 fixture 被判红(真阳性,已按判据修 fixture 而非放宽闸门)
validate-expressions.test.ts的accepts record-qualified field rules …里qty声明为可空,其record.qty >= 100被新覆盖判红 —— 这是真阳性(可空字段上的>=,运行时 fault、requiredWhen静默失效)。该用例的意图是裸引用 vs 限定引用(#1928)与parent命名空间,与可空性无关,故把 fixture 对齐真实的showcase_invoice_line.quantity(required+defaultValue),没有放宽判据绕过。验证
改动范围限于
packages/lint(+ changeset)。未改根package.json、.github/workflows/、scripts/,未触碰content/docs/releases/。Generated by Claude Code