Skip to content

feat(lint,docs): has(x) 不是 null 守卫 —— 发布期拒绝未守卫的可空比较 (#4763) - #4810

Merged
xuyushun441-sys merged 2 commits into
mainfrom
claude/issue-4763-has-not-null-guard-lint
Aug 3, 2026
Merged

feat(lint,docs): has(x) 不是 null 守卫 —— 发布期拒绝未守卫的可空比较 (#4763)#4810
xuyushun441-sys merged 2 commits into
mainfrom
claude/issue-4763-has-not-null-guard-lint

Conversation

@xuyushun441-sys

Copy link
Copy Markdown
Contributor

Fixes #4763

问题

CEL 的 has(x) 问的是键是否存在。自 #4649 起谓词读到的记录对对象声明的每个字段都是全量的,所以一个声明了却存 NULL 的列同样"存在",has(record.end_date) 对声明字段恒为 true,什么也没告诉作者。于是这个读起来像守卫的写法根本不是守卫:

has(record.start_date) && has(record.end_date) && record.end_date < record.start_date

它会走到 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。

覆盖面(有意划定,而不是含糊地覆盖一半):

覆盖 理由
对象校验规则(含 conditionalthen/otherwise 嵌套谓词) CEL 在全量记录上求值,#4649/#4761 fail-closed
生命周期 hook condition issue 点名的第三处实例所在的求值路径
共享规则 condition 下推成 SQL 过滤,NULL > x 是三值逻辑,不会 fault
flow 扁平作用域条件 裸标识符可能是 flow 变量,判不了
Field.formula 有自己的 #3306 guard ? value : null 处理

未覆盖的三个面单独立 issue 跟进,不半接。

错误信息点名规则、操作数与修法,收尾句逐字取自 packages/objectql/src/validation/rule-validator.tsunevaluableRuleError,两道闸门措辞完全一致(有一条测试把这段文本钉死,那边动了这边就红)。

对已有元数据的影响

三个示例应用(showcase / crm / todo)无需改动即通过新闸门。已实跑验证:

=== app-showcase: 21 objects, 4 hooks with conditions ===  CLEAN — 0 issues
=== app-crm: 6 objects, 0 hooks with conditions ===        CLEAN — 0 issues
=== app-todo: 1 objects, 0 hooks with conditions ===       CLEAN — 0 issues

其中 account.object.ts:195/203 两处 has()正当用法(与显式 == null / != null 配对、且只用相等运算符),按规则本来就该通过 —— 已作为负例 pin 写进测试。

packages/objectql/src/validation/rule-*.test.ts 里那些故意的坏形状 fixture 不会被咬:本闸门只走 stack 里的已声明元数据,从不读源文件,夹具结构上就在它触及范围之外(测试文件里有注释钉住这条边界)。

一处既有测试断言随之更新:validate-expressions.test.tsrecord.close_date >= today()(close_date 可空、无守卫)——它正是本规则的目标形状,加上 != null 守卫后仍验证原本的类型健全性断言。

回归 pin

验收清单里「修 examples/app-showcase/src/data/hooks/index.ts:70」已由 #4770 的 PR #4786 做掉,本 PR 不再动它,改为回归 pin:

对着真实的 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。

测试

pnpm --filter @objectstack/lint test       →  54 files / 961 tests passed
pnpm --filter @objectstack/lint typecheck  →  clean
pnpm --filter @objectstack/spec check:generated      →  ✓ All 8 generated artifacts are up to date.
pnpm --filter @objectstack/spec check:skill-examples →  ✅ 202 prose examples type-check

新增 validate-null-guards.test.ts(判定过程)+ validate-expressions.test.tsnull-guard gate (#4763) 区块(接线、负例 pin、hook 回归 pin、未覆盖面的边界 pin)。


🤖 Generated with Claude Code

https://claude.ai/code/session_018iARDqtrhQgz6fVHDeDkbQ


Generated by Claude Code

…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
@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 8:21am

Request Review

@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation dependencies Pull requests that update a dependency file 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.

2 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)

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.

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

Labels

dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

has(x) reads as a null guard and is not one — a publish-time lint should reject un-guarded nullable comparisons in CEL predicates

2 participants