feat(lint,docs): has(x) 不是 null 守卫 —— 发布期拒绝未守卫的可空比较 (#4763) - #4810
Merged
Conversation
…ates — `has(x)` is not a null guard (#4763) CEL's `has(x)` asks whether the KEY is present. Since #4649 every predicate reads a record that is total over the object's declared fields, so a declared column holding NULL is still present and `has(record.end_date)` is uniformly true. The idiom that reads like a guard — has(record.start_date) && has(record.end_date) && record.end_date < record.start_date — therefore reaches `null < null`, CEL has no overload, and the whole predicate aborts. Before #4761 the abort was swallowed, so a rule of this shape enforced NOTHING on exactly the rows it was written to catch. The fault is fully decidable from the metadata alone, so per PD #12 it belongs at authoring, not at a 400 on production data. New gate (error, no warn-mode escape hatch), in `packages/lint`: - `validate-null-guards.ts` — the decision procedure. Parses the predicate with cel-js and rejects an ordering (`< <= > >=`) or arithmetic (`+ - * / %`, unary `-`) operator applied to an operand that resolves to a declared NULLABLE field (no `required: true`, no `defaultValue`, no default option, not autonumber) and is not dominated by an explicit `!= null` / `== null` / `!isBlank()` test in the same boolean branch. `has()` deliberately does not count. Guard propagation follows `&&` left-to-right, `||` short-circuit, `!` polarity and ternary branches. - Wired into `validateStackExpressions`, already a `gating` authoring rule on `os build` / `os validate` / `os lint` AND the runtime publish gate. - Scope: object validation rules (including predicates nested in a `conditional` rule's `then`/`otherwise`) and lifecycle hook `condition`s — the surfaces CEL actually evaluates over a total record. Sharing rules (compiled to a SQL filter, three-valued, never faults), flattened flow conditions (a bare id may be a flow variable) and `Field.formula` (its own #3306 handling) are deliberately out, not half-covered. - Message names the rule, the operand and the fix, and closes with the sentence lifted verbatim from `unevaluableRuleError` in `rule-validator.ts`, so the publish-time and runtime rejections read identically. `has()` over an UNDECLARED key is untouched — that is its legitimate use. All three example apps pass the new gate unchanged; the pre-#4786 showcase hook shape is pinned as a regression test. 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): 2 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
This was referenced Aug 3, 2026
…escape, not a raw byte (#4763) `check:nul-bytes` was red: `validate-null-guards.ts` carried a literal 0x00 at byte offset 10987, inside the composite dedup key. Byte-identical at runtime -- this is purely how the character is spelled in source. It is not cosmetic. A raw NUL makes grep/ripgrep classify the whole file as binary and silently return ZERO matches, so the file drops out of code search and out of every grep-based lint. git does not warn, because it only inspects the first 8000 bytes to decide binary-ness and this one sits past that. A new gate whose own source is invisible to code search is a bad way to start. The unicode escape rather than the octal one, matching `packages/rest/src/rest-server.ts:1065`: the octal form becomes a legacy-escape error the moment a digit follows it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018iARDqtrhQgz6fVHDeDkbQ
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 #4763
问题
CEL 的
has(x)问的是键是否存在。自 #4649 起谓词读到的记录对对象声明的每个字段都是全量的,所以一个声明了却存NULL的列同样"存在",has(record.end_date)对声明字段恒为true,什么也没告诉作者。于是这个读起来像守卫的写法根本不是守卫:它会走到
null < null,CEL 没有对应重载,整个谓词中断。#4761 之前中断被吞掉(规则跳过 + 一条 WARN),也就是说这一形状的规则在任何含 null 值的行上从未生效过——写在元数据里、读起来完全正确、却什么都没强制执行。运行时 fail-closed 是兜底,不是该学到这件事的地方:作者会在真实数据上收到一个 400,离写下规则可能已经过去几个月。而这个错误仅凭元数据就可判定——谓词的 AST 加上对象声明的字段,足以判断某个操作数是否可能为 null。按 PD #12(在创作期拒绝,不要在消费端容忍),它属于发布闸门。
按 PM 裁定取 reject,不加 warn 模式、不加降级开关:这个陷阱的特征就是写错了看起来完全正确,而 warn 在 CI 噪音里等于没有。
实现
packages/lint/src/validate-null-guards.ts(新增) —— 判定过程本身。用 cel-js 解析谓词,拒绝这样的形状:对声明为可空的字段(没有required: true、没有defaultValue、没有默认选项、不是 autonumber)应用排序(< <= > >=)或算术(+ - * / %,含一元-)运算符,而该操作数没有被同一布尔分支内支配它的显式判空守卫。!= null/== null/!isBlank(x);has(x)刻意不计入——这正是本规则存在的理由;&&的从左到右、||的短路(x == null || x < y通过)、!的极性翻转与三元的两个分支;守卫不会反向越过&&(x > 1 && x != null仍被拒),也不会只由||的一个分支证成。接入
validateStackExpressions,它本来就是AUTHORING_RULES里的gating规则,os build/os validate/os lint与运行时发布闸门共用同一条——所以是"接到发布闸门",不是多出一条只在本地跑的 lint。覆盖面(有意划定,而不是含糊地覆盖一半):
conditional的then/otherwise嵌套谓词)conditionconditionNULL > x是三值逻辑,不会 faultField.formulaguard ? value : null处理未覆盖的三个面单独立 issue 跟进,不半接。
错误信息点名规则、操作数与修法,收尾句逐字取自
packages/objectql/src/validation/rule-validator.ts的unevaluableRuleError,两道闸门措辞完全一致(有一条测试把这段文本钉死,那边动了这边就红)。对已有元数据的影响
三个示例应用(showcase / crm / todo)无需改动即通过新闸门。已实跑验证:
其中
account.object.ts:195/203两处has()是正当用法(与显式== null/!= null配对、且只用相等运算符),按规则本来就该通过 —— 已作为负例 pin 写进测试。packages/objectql/src/validation/rule-*.test.ts里那些故意的坏形状 fixture 不会被咬:本闸门只走 stack 里的已声明元数据,从不读源文件,夹具结构上就在它触及范围之外(测试文件里有注释钉住这条边界)。一处既有测试断言随之更新:
validate-expressions.test.ts的record.close_date >= today()(close_date可空、无守卫)——它正是本规则的目标形状,加上!= null守卫后仍验证原本的类型健全性断言。回归 pin
验收清单里「修
examples/app-showcase/src/data/hooks/index.ts:70」已由 #4770 的 PR #4786 做掉,本 PR 不再动它,改为回归 pin:has(record.spent) && has(record.budget) && record.spent > record.budget→ 红;main上现在的形状record.spent != null && ...→ 绿。对着真实的
showcase_project字段声明实跑确认过(非空洞断言)。文档
skills/objectstack-formula/SKILL.md——has()is NOT a null check 一节补上:记录全量 ⇒has()对声明字段恒真;WRONG/RIGHT 对照;这是发布期拒绝而不是建议;对未声明键的has()仍然合法。content/docs/data-modeling/validation.mdx—— 同一条,写成校验规则页上的 warn Callout。测试
新增
validate-null-guards.test.ts(判定过程)+validate-expressions.test.ts的null-guard gate (#4763)区块(接线、负例 pin、hook 回归 pin、未覆盖面的边界 pin)。🤖 Generated with Claude Code
https://claude.ai/code/session_018iARDqtrhQgz6fVHDeDkbQ
Generated by Claude Code