Skip to content

Commit 424c510

Browse files
feat(lint): publish-time resolution of metadata-form predicate paths (#7010) (#7214)
* feat(lint): publish-time resolution of metadata-form predicate paths (#7010) New `error`-level rule family `validate-predicate-path-refs`: a conditional-visibility predicate on a schema-bound metadata form must name paths the target schema actually declares. - `predicate-path-unresolved` — a `data.`-rooted path whose first unresolvable segment is not a key of the schema at that point. - `predicate-path-unrooted` — a bare identifier that IS a key of the scope, i.e. #6254's shape (right name, dropped root). Immune to the CEL type-name blind spot that made #6248's gate structurally unable to catch it. Scoped to the `data.*` layer: the metadata-type schema registry is a closed key set, while an ObjectQL object's addressable path set is not. Corpus-counted before enforcing: 0 findings over the shipped METADATA_FORM_REGISTRY (17 forms, 46 predicates); 16 once #6254's pre-fix `object.form.ts` spellings are restored. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F8q5J1MQyocgtNspb15fSn * refactor(lint): fold the local `collectionEntries` copy into the #7186 helper #7186 landed `packages/lint/src/collection-entries.ts` on main after this branch was cut. The local 15-line duplicate documented as "folds into that helper when it lands" now does — same semantics, same array/name-keyed-map handling, same walk order, so no verdict or path changes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F8q5J1MQyocgtNspb15fSn --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent ef85bbc commit 424c510

7 files changed

Lines changed: 1151 additions & 1 deletion

File tree

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
---
2+
"@objectstack/lint": minor
3+
---
4+
5+
feat(lint): metadata 表单谓词的路径解析闸门 —— 引用不存在的路径在发布期就被拒(#7010)
6+
7+
新增 **error 级** 规则族 `validate-predicate-path-refs`,在 `os validate` / `os build` /
8+
`os lint` 三条命令上判定:一个 metadata 编辑表单(数据源为 `{ provider: 'schema', schemaId }`
9+
`defineForm` 形状)里的可见性谓词,其 `data.` 路径必须能在该 `schemaId` 对应的 schema 上逐段
10+
解析。两条规则,同一个问题:
11+
12+
- `predicate-path-unresolved` —— `data.` 有根,但某一段不是该层 schema 声明的键
13+
(`data.tpye == 'formula'`)。消息点名**不可解析的那一段**,hint 给出编辑距离最近的候选。
14+
- `predicate-path-unrooted` —— 裸标识符,而这个名字**恰好是目标 schema 的键**
15+
(`type == 'formula'`)。这是 #6254 的形状:名字写对了,根丢了。
16+
17+
## 为什么现有三道闸都放行
18+
19+
`validate-visibility-predicates.ts`(ADR-0089 D3b)判的是谓词的**形状**:能不能解析
20+
(`visibility-predicate-syntax`,#6253)、有没有根(`visibility-bare-identifier`,#6128)、
21+
根对不对层(`visibility-root-mislayered`)。三条都**不打开目标 schema**,所以
22+
`data.tpye == 'formula'` 三条全过,而后在控制台 fail-open —— 元素无条件渲染,和完全不写谓词
23+
像素级一致(#5149 一族)。
24+
25+
#6254 已经实测过这个洞的另一半:`object.form.ts` 的 16 处裸谓词写成 `type == 'formula'`,而
26+
#6248 的裸标识符闸**按构造抓不到** —— `type` 是 CEL 自己声明的类型名标识符,到严格检查器那里
27+
是类型 overload 错误而非未知变量。本规则不问 CEL「什么能解析」,只问**目标 schema**「这个键声明了
28+
没有」,所以 CEL 的类型名词汇表与它无关。
29+
30+
## 落点:`data.*` 一层,这是决定而非省略
31+
32+
`error` 级闸门要求 oracle 是**封闭**的 —— 一个能枚举、且「不在其中」确实等于「解析不到」的键集。
33+
ADR-0089 D3 的两层里只有一层满足:metadata 编辑表单的行是某个 metadata type 的实例,形状由
34+
`getMetadataTypeSchema` 这一份规范注册表逐键给出。运行期 record 面(`record.*`)**今天不封闭**
35+
—— lookup 穿透、authored `fields` 从不列出的系统列、formula/rollup 输出都是合法路径,在开集上架
36+
`error` 闸只会制造误红,而误红是闸门唯一不能犯的方向。已在规则注释与 #7010 上记为待裁的开放问题。
37+
38+
## repeater 行重绑 `data`,规则跟着重绑(#6254)
39+
40+
`type: 'record'` / `repeater` / `composite` 子字段列表里,`data` 绑定的是**这一行**
41+
(objectui 的 metadata SchemaForm 以 `{ data: row }` 求值),但根**仍然拼作 `data`**。所以
42+
`object.form.ts``data.type` 指的是 `FieldSchema.type` 而不是并不存在的 `ObjectSchema.type`
43+
规则按同样的重绑下降 —— 不这么做,已发货的语料会读出 16 条误红而不是 0 条。
44+
45+
## 语料计数(先量再收紧)
46+
47+
规则通过**生产入口**跑过本仓发货的全部 metadata 表单(`METADATA_FORM_REGISTRY`,17 张表 46 条
48+
谓词):**两条规则的命中数都是 0**,因此才落 `error`。反向验证同时钉住:把 #6254 修前的裸写法还原
49+
`object.form.ts` 的深拷贝上,规则报出 **恰好 16 条** `predicate-path-unrooted` —— 正是该单
50+
当年人工读出来的那 16 处。
51+
52+
## 明确不判(都是漏判方向,永远不会变成误红)
53+
54+
规范前端解析不了的谓词(交还 #6253);不声明键集的作用域(`z.record(z.string(), z.unknown())`
55+
/ `z.unknown()`);record map 的****段(`z.record(z.string(), X)` 按构造接受任何键);推导宏
56+
绑定的循环变量(`data.tags.all(t, …)`);下标访问(`data.x['y']`);解析不到 schema 的
57+
`schemaId`。裸标识符里**不是** schema 键的那些也不判 —— 那是 `visibility-bare-identifier`
58+
的判决,一条坏谓词只应产生一条 finding。
59+
60+
规则注册在 `AUTHORING_RULES`(`tier: 'gating'`,三条命令全覆盖),`surfaces` 保持 `cli`:它其实
61+
只需要被写入的那一条 item,但 `views[]` 可见性谓词这一族的另外三条规则今天都是 CLI-only,单独把
62+
三分之一的判决搬到 Studio 写入门上,比一条都不搬更难预测 —— 该族应当一次整体迁移,这是关于
63+
`views` 写入门的决定,不该搭在本单的车上。理由已写成 `RUNTIME_VISIBILITY_FAMILY_IS_CLI_ONLY`

packages/lint/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,8 @@
3636
},
3737
"devDependencies": {
3838
"@types/node": "^26.1.2",
39-
"vitest": "^4.1.10"
39+
"vitest": "^4.1.10",
40+
"zod": "^4.4.3"
4041
},
4142
"keywords": [
4243
"objectstack",

packages/lint/src/authoring-rules.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ import { validateFormLayout } from './validate-form-layout.js';
120120
import { validateSeedReplaySafety } from './validate-seed-replay-safety.js';
121121
import { validateSeedStateMachine } from './validate-seed-state-machine.js';
122122
import { validateVisibilityPredicates } from './validate-visibility-predicates.js';
123+
import { validatePredicatePathRefs } from './validate-predicate-path-refs.js';
123124
import { validateSecurityPosture } from './validate-security-posture.js';
124125
import { validateOrgAxisRedLines } from './validate-org-axis-red-lines.js';
125126
import { validateSharingRuleEnforceability } from './validate-sharing-rule-enforceability.js';
@@ -350,6 +351,32 @@ const RUNTIME_HEAVY_SOURCE_PARSE =
350351
* wrong 422 there is the whole product, so P1 does not gate them — the issue's
351352
* own worked example, and every acceptance criterion on it, is a flow.
352353
*/
354+
/**
355+
* The rule judges a `views[]` conditional-visibility predicate — and every OTHER
356+
* rule on that surface (the three ADR-0089 D3b rules in
357+
* `validate-visibility-predicates.ts`) is CLI-only.
358+
*
359+
* This reason is deliberately NOT one of the three above: none of them is true
360+
* here. `validatePredicatePathRefs` needs nothing but the written item — its
361+
* oracle is the static `getMetadataTypeSchema` registry, not the tenant's other
362+
* metadata — so the per-write snapshot IS enough, `stackKeyForType('view')`
363+
* already exists, and wiring it would work today.
364+
*
365+
* It is not wired because a HALF-wired wall is worse than an unwired one. A
366+
* Studio `view` write would then be refused for an unresolvable predicate PATH
367+
* while a predicate that does not parse at all (`visibility-predicate-syntax`)
368+
* and one with no root at all (`visibility-bare-identifier`) walked straight
369+
* through the same door — three sibling verdicts about one predicate, one of
370+
* them enforced, and no author able to predict which. The surface should move to
371+
* `runtime-publish` as a FAMILY, in one measured edit, which is a decision about
372+
* `views` writes rather than a rider on #7010's corpus-counted gate.
373+
*/
374+
const RUNTIME_VISIBILITY_FAMILY_IS_CLI_ONLY =
375+
'Deliberate, and not a snapshot limitation: this rule needs only the written item, but every other '
376+
+ 'rule on the `views[]` visibility-predicate surface (validate-visibility-predicates.ts) is CLI-only. '
377+
+ 'Gating one of three sibling verdicts about the same predicate at the Studio door is less '
378+
+ 'predictable than gating none; move the family together, as one measured edit.';
379+
353380
const RUNTIME_OBJECT_WRITES_P2 =
354381
'P2 (#4463): judges an object/field declaration. Object writes are the hottest metadata path in ' +
355382
'the product, so P1 gates `flow` first and widens once the gate has real traffic behind it.';
@@ -824,6 +851,30 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
824851
surfaceReason: RUNTIME_NEEDS_FULL_SNAPSHOT,
825852
run: (stack) => validateVisibilityPredicates(stack),
826853
},
854+
// #7010 — the same predicate surface, one question further in. The three
855+
// ADR-0089 D3b rules above judge a predicate's SHAPE (does it parse, is it
856+
// rooted, is the root right for the layer) and never open the target schema,
857+
// so `data.tpye == 'formula'` passes all three and still resolves to nothing.
858+
// This rule resolves the PATH against the schema the form edits — the closed
859+
// `getMetadataTypeSchema` key set — and is therefore immune to the CEL
860+
// type-name blind spot that made #6248's gate structurally unable to catch
861+
// #6254's 16 bare `type ==` predicates.
862+
//
863+
// Scoped to schema-bound forms (`data: { provider: 'schema', schemaId }`);
864+
// the `record.*` layer is deliberately out of scope because an ObjectQL
865+
// object's addressable path set is NOT closed (lookup traversal, system
866+
// columns, formula outputs), and an `error` gate over an open set generates
867+
// false build errors. See the rule's module note.
868+
{
869+
name: 'validatePredicatePathRefs',
870+
tier: 'gating',
871+
input: 'normalized',
872+
commands: ALL,
873+
source: 'packages/lint/src/validate-predicate-path-refs.ts',
874+
surfaces: CLI_ONLY,
875+
surfaceReason: RUNTIME_VISIBILITY_FAMILY_IS_CLI_ONLY,
876+
run: (stack) => validatePredicatePathRefs(stack),
877+
},
827878
// #1874 — flow authoring anti-patterns. Advisory by default; a finding marked
828879
// `error` gates. Three do today: `flow-runas-unscoped` (#3760 — metadata the
829880
// runtime REFUSES to execute), plus `flow-branch-label-unmatched` and

packages/lint/src/index.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,17 @@ export type {
174174
VisibilityOptions,
175175
} from './validate-visibility-predicates.js';
176176

177+
export {
178+
validatePredicatePathRefs,
179+
PREDICATE_PATH_UNRESOLVED,
180+
PREDICATE_PATH_UNROOTED,
181+
} from './validate-predicate-path-refs.js';
182+
export type {
183+
PredicatePathFinding,
184+
PredicatePathSeverity,
185+
PredicatePathOptions,
186+
} from './validate-predicate-path-refs.js';
187+
177188
export {
178189
validateCapabilityReferences,
179190
CAPABILITY_REFERENCE_UNKNOWN,

0 commit comments

Comments
 (0)