Skip to content

Commit 6965160

Browse files
hotlongclaude
andauthored
feat(lint): view/page 谓词裸标识符构建期闸门 —— 坏谓词发不出去 (#6128) (#6248)
新增 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,示例零改动。 Claude-Session: https://claude.ai/code/session_01BDmDsu2575gDxeMCxXhDE3 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 95c4227 commit 6965160

6 files changed

Lines changed: 753 additions & 57 deletions

File tree

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
---
2+
"@objectstack/lint": minor
3+
"@objectstack/formula": minor
4+
---
5+
6+
feat(lint): view/page 可见性谓词的裸标识符构建期闸门 —— 坏谓词发不出去(#6128)
7+
8+
新增 **error 级** 规则 `visibility-bare-identifier`:view/page 的可见性谓词
9+
(`visibleWhen` 及其两个已弃用别名 `visibleOn` / `visibility`)里引用了任何绑定根都解析不到的
10+
顶层标识符时,`os validate` / `os build` / `os lint` 一律拒收。写成 `status == 'active'`
11+
而不是 `record.status == 'active'` 的谓词,从此发不出去。
12+
13+
#5149 维护者 2026-08-06 裁决的构建期半边落地(运行时 warn-once 半边已由 objectui#3541 合入)。
14+
本仓传统的准确表述是:fail-open 或 fail-closed 都可以裁,**静默不可以**。谓词失败仍然 fail-open
15+
(已发货 app 行为不变),但坏谓词不再能进入产物。
16+
17+
**为什么现有两道闸都放行**(#5149 Repro 1 实测,已写进规则注释,防后人误并):
18+
ADR-0032 的标识符闸(`validate-expressions.ts`)解析 record 作用域的裸引用,但它的遍历只覆盖
19+
objects / flows / actions / sharingRules / hooks,**从不走 views 与 pages**;ADR-0089 D3b
20+
只判**有根**的谓词根错层(runtime 面的 `data.`、metadata 面的 `record.`),**无根**的谓词两边都不匹配。
21+
两闸之间正好漏掉「作者按文档示例写了裸字段名 → 谓词永远解析失败 → 控制台 fail-open 静默显示」。
22+
23+
**判定由两个既有 oracle 合成,本包不自建 CEL 环境**(#4812 的教训):声明性判定取
24+
`@objectstack/formula``firstUndeclaredReference`(即 `validateExpression` 给 record 作用域
25+
裸引用定罪的同一个严格环境),AST 取规范入口 `parseCelToAst`。AST 先收集所有处于**接收者位置**
26+
的标识符(`a.b` / `a?.b` / `a['b']` / `a.exists(…)`)并在检查前声明它们,于是只剩「当作裸值引用」
27+
的标识符会被判 —— 未知****(`my_record.x`)交还给 ADR-0089 D3b,不在本规则射程内。
28+
29+
**#4953(全量 vs 稀疏绑定)的边界**:#4953 实测同一求值器在两种绑定下语义相反
30+
(`has(record.a)` 全量 true / 稀疏 false;`record.a != null` 全量 false / 稀疏 FAULT)。本规则
31+
**按构造与该分叉无关** —— 它从不追问某个 KEY 在已绑定的根上是否存在,只追问标识符有没有根,
32+
而无根标识符在两种绑定下都解析不到。`has(record.x)` / `record.x != null` 等守卫写法在本闸门下
33+
一律绿,无论 #4953 最终怎么裁;已加测试钉住这条边界。
34+
35+
**遍历按实测修正,否则规则生来即死**:`os build``examples/app-showcase` 得到的唯一一条
36+
view 表单谓词落在 `views[0].formViews.edit.sections[0].fields[6].visibleWhen` —— 运行时 app 形状下
37+
`views[]` 条目是**视图容器**(`ViewSchema` 声明的自有键就是 `list` / `form` / `listViews` /
38+
`formViews`),`sections` 在下一层。原遍历只读 `views[].sections`,在这份 stack 上报告「干净」。
39+
现在覆盖容器的 `form` 与每个 `formViews.<key>`,以及仍然直接携带 `sections``defineForm` 形状;
40+
pages 改走共享的 `walkPageComponents`(regions、slotted 页的 `slots`、以及 `properties` 里的
41+
`page:tabs` / `page:accordion` / `page:card` 子树都随之覆盖,source-authored 页按其既有语义跳过)。
42+
`objects[].views` 明确不读 —— 该键已被 schema 立碑拒绝,读它只会造出一条永不触发的幽灵检查。
43+
两条既有 ADR-0089 D3b advisory 随遍历一并变得真正可达。
44+
45+
注册表 tier `advisory``gating`(#5762 的先例):tier 声明并非自述,
46+
`authoring-rule-wiring.test.ts` 会读规则源码核对。
47+
48+
已知盲点(已钉测试、方向安全):字段名与 CEL **类型名**相同时(`type` / `int` / `string` / `list`
49+
/ `map` / `timestamp` …)不判 —— CEL 自身声明这些标识符,`type == 'grid'` 到检查器那里是类型
50+
overload 错误而非未知变量;改读 overload 消息会误杀合法的 `type(record.x) == string`。语法不通过
51+
的谓词同样不判,交还给拥有该判定的闸门。两者都是漏判,永远不会变成误红。
52+
53+
仓内 `app-todo` / `app-crm` / `app-showcase` 三个示例 `os validate` 全部通过、零 visibility finding,
54+
无需修改任何示例内容。
55+
56+
`@objectstack/formula` 侧:公开导出 `firstUndeclaredReference`(理由与既有的
57+
`collectCelRootIdentifiers` 一致 —— 绑定根集合不同的消费方需要的是同一个答案,替代方案是在消费方
58+
自建严格 `Environment`,而那正是 #4812 从本包消费方手里拿掉的私有前端)。

packages/formula/src/index.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,16 @@ export { celEngine, DEFAULT_LIMITS } from './cel-engine';
1515
// (approval `expression` approvers): lint and the runtime pre-check share this
1616
// one helper so what they accept can never drift.
1717
export { collectCelRootIdentifiers } from './cel-engine';
18+
// #6128 — the strict-environment "does this identifier resolve?" oracle, the
19+
// same one `validateExpression` gives its `record`-scoped bare-ref verdict from.
20+
// Published for the same reason as `collectCelRootIdentifiers` above: a lint
21+
// rule whose surface declares a DIFFERENT root set (`@objectstack/lint`'s
22+
// view/page visibility gate binds `current_user` / `page` on top of
23+
// SCOPE_ROOTS) needs this exact answer, and the alternative — rebuilding a
24+
// strict `Environment` in the consumer — is the private-front-end mistake
25+
// #4812 removed from that very package. One oracle, one answer to "what
26+
// resolves", whichever surface is asking.
27+
export { firstUndeclaredReference } from './cel-engine';
1828
// #4812 — the canonical parse-to-AST entry. Any consumer that needs the AST of
1929
// an authored CEL source takes it from here, so "what parses" has exactly ONE
2030
// answer across build, lint and runtime. Building a private `new Environment()`

packages/lint/src/authoring-rules.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -693,12 +693,20 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
693693
surfaceReason: RUNTIME_NEEDS_FULL_SNAPSHOT,
694694
run: (stack) => validateSeedStateMachine(stack),
695695
},
696-
// ADR-0089 D3b — deprecated visibility aliases and a mis-layered binding root.
697-
// Pre-parse: the schema folds `visibleOn`/`visibility` into `visibleWhen`
698-
// during parse, so the alias the author wrote is gone from `result.data`.
696+
// ADR-0089 D3b — deprecated visibility aliases and a mis-layered binding root,
697+
// plus (#6128) the bare-identifier gate. Pre-parse: the schema folds
698+
// `visibleOn`/`visibility` into `visibleWhen` during parse, so the alias the
699+
// author wrote is gone from `result.data`.
700+
//
701+
// `gating` since #6128: `visibility-bare-identifier` emits `error`. The two
702+
// ADR-0089 rules stay advisory findings within it — the tier is a property of
703+
// the RULE FUNCTION (can it emit `error`?), and the per-finding severity is
704+
// what decides whether any given diagnostic gates, exactly as `lintFlowPatterns`
705+
// has worked since #3760. The promotion follows the #5762 precedent: a family
706+
// that gains an `error` finding moves its registry tier in the same edit.
699707
{
700708
name: 'validateVisibilityPredicates',
701-
tier: 'advisory',
709+
tier: 'gating',
702710
input: 'normalized',
703711
commands: ALL,
704712
source: 'packages/lint/src/validate-visibility-predicates.ts',

packages/lint/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,7 @@ export {
139139
validateVisibilityPredicates,
140140
VISIBILITY_ALIAS_DEPRECATED,
141141
VISIBILITY_ROOT_MISLAYERED,
142+
VISIBILITY_BARE_IDENTIFIER,
142143
} from './validate-visibility-predicates.js';
143144
export type {
144145
VisibilityFinding,

0 commit comments

Comments
 (0)