Skip to content

feat(lint): view/page 谓词裸标识符构建期闸门 —— 坏谓词发不出去 (#6128) - #6248

Merged
hotlong merged 1 commit into
mainfrom
claude/issue-6128-predicate-bare-identifier-lint
Aug 7, 2026
Merged

feat(lint): view/page 谓词裸标识符构建期闸门 —— 坏谓词发不出去 (#6128)#6248
hotlong merged 1 commit into
mainfrom
claude/issue-6128-predicate-bare-identifier-lint

Conversation

@hotlong

@hotlong hotlong commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Fixes #6128

#5149 维护者 2026-08-06 裁决落地构建期半边(运行时 warn-once 半边已由 objectui#3541 合入)。新增 error 级 规则 visibility-bare-identifier:view/page 可见性谓词里引用了任何绑定根都解析不到的顶层标识符时,os validate / os build / os lint 一律拒收 —— 坏谓词根本发不出去。

一、为什么现有两道闸都放行(机制,已写进规则注释防后人误并)

#5149 Repro 1 实测:status == 'active' 这类写法通过了平台所有闸门,然后在控制台 fail-open —— 标识符解析不到,evalFieldPredicate 返回 fallback,而可见性的 fallback 是 true,于是「永远不生效的谓词」与「根本没写谓词」在屏幕上一模一样。两道闸各自漏掉它的原因是结构性的,两条都必须写下来:

  • ADR-0032 的标识符闸(validate-expressions.ts)确实解析 record 作用域的裸引用,但它的遍历面是 objects / flows / actions / sharingRules / hooks —— 从不走 viewspages,view 表单字段的 visibleWhen 完全在射程外。
  • ADR-0089 D3b(同文件的两条既有规则)走的正是这个面,但它判的是有根谓词的根错层(runtime 面写了 data.、metadata 面写了 record.)。无根谓词两个方向都不匹配,干净通过。

维护者裁决维持 fail-open(已发货 app 行为不变),但本仓传统的准确表述是:fail-open 或 fail-closed 都可以裁,静默不可以。运行时那半让坏谓词显形,这半让它进不了产物。

二、绑定约定的出处(读 schema / 运行时,不猜)

规则族按 schema 枚举,没有第四种拼写:visibleWhen 是三个载体的规范键(FormFieldBaseSchema view.zod.ts:1416、FormSectionSchema view.zod.ts:1510、PageComponentSchema page.zod.ts:143),visibleOn 是 view 侧已弃用别名(view.zod.ts:1418 / :1512),visibility 是 page 侧的(page.zod.ts:145)。

绑定根同样是读出来的:view.zod.ts:1416 / :1510 写明 runtime 表单绑 record + current_user、metadata 编辑表单绑 data;page.zod.ts:143 再加页面状态(page 前缀)。运行时侧 objectui packages/core/src/evaluator/fieldRules.tsevalFieldPredicaterecord + previous + 调用方给的 extra scope(如 parent)。取并集而不是任一份 —— 这是 error 级闸门唯一安全的方向(少一个根是误红,多一个根只是漏判)。current_user 照样声明为合法根,尽管 #6146 实测它「文档有、两端都没实现」:那个根到底解不解析是 #6146 的裁决,本规则不能替它先斩。

三、判定怎么做的:两个既有 oracle,本包不自建 CEL 环境

@objectstack/lint 不允许自建 Environment —— 那正是 #4812 从本包 null-guard pass 手里拿掉的私有前端。所以:

  1. AST 取规范入口 parseCelToAst(packages/lint 绕过 @objectstack/formula 直接 parse CEL —— 两个解析入口对「什么能解析」会给出不同答案 #4812 / refactor(formula,lint): parseCelToAst 成为唯一的 CEL 解析入口 (#4812) #6130);
  2. 先从 AST 收集所有处于接收者位置的标识符(a.b / a?.b / a['b'] / a.exists(…))—— 作者把它们当命名空间用,未知的那些是错误,归 ADR-0089 D3b 管;
  3. 把这些名字连同本面额外的根一起声明后,声明性判定交给 @objectstack/formulafirstUndeclaredReference —— 即 validateExpression 给 record 作用域裸引用定罪的同一个严格环境。

剩下的就只有「当作裸值引用」的标识符。这样拆的必要性是实测出来的:comprehension macro 变量(record.tags.all(t, t != '') 里的 t)在 AST 里就是一个顶层 id,只有 cel-js 的 checker 知道它是宏内绑定;反过来 checker 会把 my_record.x 的未知根也报出来,而那不是本规则的射程。任一个 oracle 单用都会误红。

@objectstack/formula 侧因此新增一个公开导出 firstUndeclaredReference,理由与既有的 collectCelRootIdentifiers 完全一致:绑定根集合不同的消费方需要的是同一个答案,替代方案是在消费方重建严格环境。

四、与 #4953 的边界(要求的稀疏绑定不误杀)

#4953 实测同一求值器在全量 vs 稀疏绑定下语义恰好相反:has(record.a) 全量 true / 稀疏 false;record.a != null 全量 false / 稀疏 FAULT。

本规则按构造与这条分叉无关:它从不追问某个 KEY 在已绑定的根上是否存在,只追问标识符有没有根 —— 而无根标识符在两种绑定下都解析不到。所以无论 #4953 最终怎么裁,has(record.x) / record.x != null / has(a) && has(b) && a < b / 其全量绑定对应写法,在本闸门下一律绿。这条边界不是叙述,是钉住的:

describe('shapes that are legal under a SPARSE binding stay green (#4953)', …)

五个形状逐一 pin,连同一句注释说明「若日后有人把规则扩成 key 级推理,这几条会红,而不是边界被悄悄丢掉」。

五、遍历按实测修正 —— 否则规则生来即死

写完规则后端到端注入验证,os validate 没有红。查因:os buildexamples/app-showcase,全仓唯一一条 view 表单谓词落在

views[0].formViews.edit.sections[0].fields[6].visibleWhen

运行时 app 形状下 views[] 条目是视图容器 —— ViewSchema 的自有键就是 list / form / listViews / formViews(view.zod.ts:1890-1903,严格错误映射里把这句话原文写了出来),sections 在下一层。原遍历只读 views[].sections,在这份 stack 上报「干净」。也就是说两条既有 ADR-0089 D3b advisory 在真实形状上一直基本不可达 —— 这是 #4984 / #5009 那一族。

所以遍历改成:

  • views:容器的 form、每个 formViews 具名项,以及仍然直接携带 sectionsdefineForm 形状(metadata 编辑表单走的就是它);list / listViewsObjectListViewSchema,不带 sections,不走。
  • pages:改走共享的 walkPageComponents(lint: no reference-integrity or option-key validation for app metadata #3583)—— regions、slotted 页的 slots 映射、以及藏在无类型 properties 袋里的 page:tabs / page:accordion / page:card 子树都随之覆盖,source-authored 页按其既有语义跳过(那里的 regions 是派生缓存,对它报 error 等于对作者没写过的元数据拒收)。手写一份 regions[].components[] 循环正是 page-walk.ts 自己头注里说的「已经造出过一条死规则」的那份拷贝。
  • objects[].views 明确不读:object.zod.ts:1833 已把该键立碑拒绝(「views is not an ObjectSchema field」),读它只会造出一条对任何能过 schema 的 stack 都不触发的幽灵检查。已加反向测试钉住「不读」。
  • 明确下探 fields[].fields[](composite / repeater 子字段):那里的绑定我引不出一句 spec 原文,而 error 级闸门不能去判一个自己叫不出名字的约定。已写进注释。

修正后同一次注入端到端复现:

✗ Author-time rules failed (1 issue)
  • view "views[0]" · formViews.edit: visibility predicate references `priority` as a bare identifier. …
      Write `record.priority` instead of `priority` …
      rule: visibility-bare-identifier  at views[0].formViews.edit.sections[0].fields[6]
exit=1

顺带把 where 补成能区分同一容器下两个表单(容器在产物里可能既无 name 也无 object),并让 name-keyed 集合报而不是合成下标。

六、逆向验证(方向先声明,再跑)

声明的方向:红。 新检查是纯增量的 error 诊断,新测试里的正向断言只有它能满足;所有「必须保持绿」的 pin(稀疏绑定 5 条、macro 4 条、合法根 5 条、未知根、语法不通过、CEL 类型名盲点、注册表 tier)断言的是不产生 finding,应当不受影响。

只把 firstBareIdentifier 短接为 return null(测试一行不动)后实测:

Tests  11 failed | 1482 passed (1497)

恰好 11 条,且恰好是事先枚举的那 11 条正向断言;所有绿 pin 与既有 ADR-0089 D3b 全套保持绿。恢复后全绿。

七、示例应用扫描(新增 gating 规则的爆炸半径)

三个示例逐个 os validate(遍历修正之后跑的,即用最宽的那一版):

应用 退出码 visibility finding
examples/app-todo 0 ✓ 0
examples/app-crm 0 ✓ 0
examples/app-showcase 0 ✓ 0

三例全部通过, visibility 类 finding —— 遍历放宽也没有新增 advisory。示例内容零改动,不需要 rollout 决策。

八、changeset 定级理由

@objectstack/lint: minor,@objectstack/formula: minor

注册表 tier 的改动不是自述:authoring-rule-wiring.test.ts 会读规则源码核对 advisory 声明的真伪,留 advisory 必红。

九、已知盲点(方向安全,已钉测试)

  • 字段名与 CEL 类型名相同时(type / int / string / list / map / timestamp …)不判:CEL 自身声明这些标识符,type == 'grid' 到 checker 那里是类型 overload 错误而非未知变量;改读 overload 消息会误杀合法的 type(record.x) == string
  • 语法不通过(或超 DEFAULT_LIMITS)的谓词不判,交还给拥有该判定的闸门 —— 与 validate-null-guards.ts 同一条政策。附带记录一个真实缺口:view/page 谓词今天没有任何语法校验,=== 这类写法目前无人报告(见 out-of-scope findings)。

两者都是漏判,永远不会变成误红,各有一条测试把它钉成「决定」而不是「洞」。

验证

pnpm --filter @objectstack/lint test      61 files, 1504 passed (baseline 1466)
pnpm --filter @objectstack/formula test   17 files, 399 passed
pnpm --filter @objectstack/cli test       89 files, 902 passed
pnpm --filter @objectstack/metadata-protocol test  49 files, 502 passed
pnpm --filter @objectstack/lint --filter @objectstack/formula typecheck   Done
npx eslint (5 touched files)              exit 0
node scripts/check-nul-bytes.mjs          OK (5926 tracked files)

Generated by Claude Code

新增 error 级规则 `visibility-bare-identifier`:view/page 可见性谓词
(`visibleWhen` + 两个已弃用别名 `visibleOn` / `visibility`)引用了任何绑定根都
解析不到的顶层标识符时,三条 authoring 命令一律拒收。#5149 维护者 2026-08-06
裁决的构建期半边(运行时 warn-once 半边已由 objectui#3541 合入)。

两道现有闸都放行的机制已写进规则注释防误并:ADR-0032 标识符闸的遍历从不走
views/pages;ADR-0089 D3b 只判有根谓词的根错层,无根谓词两边都不匹配。

判定由两个既有 oracle 合成,本包不自建 CEL 环境(#4812):声明性取 formula 的
`firstUndeclaredReference`,AST 取规范入口 `parseCelToAst`;AST 先声明所有接收者
位置的标识符,于是只剩当作裸值引用的会被判,未知根交还 D3b。

与 #4953 的边界按构造成立:本规则从不追问 KEY 在已绑定根上是否存在,只问标识符
有没有根 —— 无根标识符在全量与稀疏绑定下都解析不到,故 `has(record.x)` /
`record.x != null` 两种守卫写法一律绿,已加测试钉住。

遍历按实测修正,否则规则生来即死:`os build` 跑 app-showcase,唯一一条 view 表单
谓词落在 `views[0].formViews.edit.sections[0].fields[6]` —— 运行时形状下 `views[]`
是视图容器。现覆盖容器的 `form` 与每个 `formViews.<key>`,pages 改走共享的
`walkPageComponents`;`objects[].views` 明确不读(schema 已立碑拒绝)。

注册表 tier advisory → gating(#5762 先例)。app-todo / app-crm / app-showcase
三例 `os validate` 全绿、零 visibility finding,示例零改动。

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

vercel Bot commented Aug 7, 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 7, 2026 12:07pm

Request Review

@github-actions github-actions Bot added the size/l label Aug 7, 2026
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/formula, @objectstack/lint.

9 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/data-modeling/formulas.mdx (via @objectstack/formula)
  • content/docs/data-modeling/validation.mdx (via @objectstack/formula)
  • content/docs/permissions/authorization.mdx (via @objectstack/lint)
  • content/docs/plugins/packages.mdx (via @objectstack/formula)
  • content/docs/protocol/objectui/record-alert.mdx (via @objectstack/formula)
  • content/docs/releases/v15.mdx (via @objectstack/formula)
  • content/docs/releases/v16.mdx (via @objectstack/formula)
  • 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.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Aug 7, 2026
@hotlong
hotlong marked this pull request as ready for review August 7, 2026 12:50
@hotlong
hotlong enabled auto-merge August 7, 2026 12:50
@hotlong
hotlong added this pull request to the merge queue Aug 7, 2026
Merged via the queue into main with commit 6965160 Aug 7, 2026
36 of 38 checks passed
@hotlong
hotlong deleted the claude/issue-6128-predicate-bare-identifier-lint branch August 7, 2026 13:13
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/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

lint(devx): view/page 谓词(visibleWhen/visibleOn 等)的裸标识符构建期静态校验 —— #5149 裁决拆单

2 participants