From ac0102f1ae7220a0ed2e1cbb17af8c958d6cd455 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 03:01:25 +0000 Subject: [PATCH 1/2] feat(lint): publish-time resolution of metadata-form predicate paths (#7010) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01F8q5J1MQyocgtNspb15fSn --- .changeset/lint-predicate-path-refs-gate.md | 63 ++ packages/lint/package.json | 3 +- packages/lint/src/authoring-rules.ts | 51 ++ packages/lint/src/index.ts | 11 + .../src/validate-predicate-path-refs.test.ts | 392 +++++++++++ .../lint/src/validate-predicate-path-refs.ts | 651 ++++++++++++++++++ pnpm-lock.yaml | 3 + 7 files changed, 1173 insertions(+), 1 deletion(-) create mode 100644 .changeset/lint-predicate-path-refs-gate.md create mode 100644 packages/lint/src/validate-predicate-path-refs.test.ts create mode 100644 packages/lint/src/validate-predicate-path-refs.ts diff --git a/.changeset/lint-predicate-path-refs-gate.md b/.changeset/lint-predicate-path-refs-gate.md new file mode 100644 index 0000000000..bf5501ff06 --- /dev/null +++ b/.changeset/lint-predicate-path-refs-gate.md @@ -0,0 +1,63 @@ +--- +"@objectstack/lint": minor +--- + +feat(lint): metadata 表单谓词的路径解析闸门 —— 引用不存在的路径在发布期就被拒(#7010) + +新增 **error 级** 规则族 `validate-predicate-path-refs`,在 `os validate` / `os build` / +`os lint` 三条命令上判定:一个 metadata 编辑表单(数据源为 `{ provider: 'schema', schemaId }` +的 `defineForm` 形状)里的可见性谓词,其 `data.` 路径必须能在该 `schemaId` 对应的 schema 上逐段 +解析。两条规则,同一个问题: + +- `predicate-path-unresolved` —— `data.` 有根,但某一段不是该层 schema 声明的键 + (`data.tpye == 'formula'`)。消息点名**不可解析的那一段**,hint 给出编辑距离最近的候选。 +- `predicate-path-unrooted` —— 裸标识符,而这个名字**恰好是目标 schema 的键** + (`type == 'formula'`)。这是 #6254 的形状:名字写对了,根丢了。 + +## 为什么现有三道闸都放行 + +`validate-visibility-predicates.ts`(ADR-0089 D3b)判的是谓词的**形状**:能不能解析 +(`visibility-predicate-syntax`,#6253)、有没有根(`visibility-bare-identifier`,#6128)、 +根对不对层(`visibility-root-mislayered`)。三条都**不打开目标 schema**,所以 +`data.tpye == 'formula'` 三条全过,而后在控制台 fail-open —— 元素无条件渲染,和完全不写谓词 +像素级一致(#5149 一族)。 + +#6254 已经实测过这个洞的另一半:`object.form.ts` 的 16 处裸谓词写成 `type == 'formula'`,而 +#6248 的裸标识符闸**按构造抓不到** —— `type` 是 CEL 自己声明的类型名标识符,到严格检查器那里 +是类型 overload 错误而非未知变量。本规则不问 CEL「什么能解析」,只问**目标 schema**「这个键声明了 +没有」,所以 CEL 的类型名词汇表与它无关。 + +## 落点:`data.*` 一层,这是决定而非省略 + +`error` 级闸门要求 oracle 是**封闭**的 —— 一个能枚举、且「不在其中」确实等于「解析不到」的键集。 +ADR-0089 D3 的两层里只有一层满足:metadata 编辑表单的行是某个 metadata type 的实例,形状由 +`getMetadataTypeSchema` 这一份规范注册表逐键给出。运行期 record 面(`record.*`)**今天不封闭** +—— lookup 穿透、authored `fields` 从不列出的系统列、formula/rollup 输出都是合法路径,在开集上架 +`error` 闸只会制造误红,而误红是闸门唯一不能犯的方向。已在规则注释与 #7010 上记为待裁的开放问题。 + +## repeater 行重绑 `data`,规则跟着重绑(#6254) + +`type: 'record'` / `repeater` / `composite` 子字段列表里,`data` 绑定的是**这一行** +(objectui 的 metadata SchemaForm 以 `{ data: row }` 求值),但根**仍然拼作 `data`**。所以 +`object.form.ts` 的 `data.type` 指的是 `FieldSchema.type` 而不是并不存在的 `ObjectSchema.type`。 +规则按同样的重绑下降 —— 不这么做,已发货的语料会读出 16 条误红而不是 0 条。 + +## 语料计数(先量再收紧) + +规则通过**生产入口**跑过本仓发货的全部 metadata 表单(`METADATA_FORM_REGISTRY`,17 张表 46 条 +谓词):**两条规则的命中数都是 0**,因此才落 `error`。反向验证同时钉住:把 #6254 修前的裸写法还原 +到 `object.form.ts` 的深拷贝上,规则报出 **恰好 16 条** `predicate-path-unrooted` —— 正是该单 +当年人工读出来的那 16 处。 + +## 明确不判(都是漏判方向,永远不会变成误红) + +规范前端解析不了的谓词(交还 #6253);不声明键集的作用域(`z.record(z.string(), z.unknown())` +/ `z.unknown()`);record map 的**键**段(`z.record(z.string(), X)` 按构造接受任何键);推导宏 +绑定的循环变量(`data.tags.all(t, …)`);下标访问(`data.x['y']`);解析不到 schema 的 +`schemaId`。裸标识符里**不是** schema 键的那些也不判 —— 那是 `visibility-bare-identifier` +的判决,一条坏谓词只应产生一条 finding。 + +规则注册在 `AUTHORING_RULES`(`tier: 'gating'`,三条命令全覆盖),`surfaces` 保持 `cli`:它其实 +只需要被写入的那一条 item,但 `views[]` 可见性谓词这一族的另外三条规则今天都是 CLI-only,单独把 +三分之一的判决搬到 Studio 写入门上,比一条都不搬更难预测 —— 该族应当一次整体迁移,这是关于 +`views` 写入门的决定,不该搭在本单的车上。理由已写成 `RUNTIME_VISIBILITY_FAMILY_IS_CLI_ONLY`。 diff --git a/packages/lint/package.json b/packages/lint/package.json index 108976921b..0cb1e88892 100644 --- a/packages/lint/package.json +++ b/packages/lint/package.json @@ -36,7 +36,8 @@ }, "devDependencies": { "@types/node": "^26.1.2", - "vitest": "^4.1.10" + "vitest": "^4.1.10", + "zod": "^4.4.3" }, "keywords": [ "objectstack", diff --git a/packages/lint/src/authoring-rules.ts b/packages/lint/src/authoring-rules.ts index 2665fe20e8..9a0e921e0c 100644 --- a/packages/lint/src/authoring-rules.ts +++ b/packages/lint/src/authoring-rules.ts @@ -120,6 +120,7 @@ import { validateFormLayout } from './validate-form-layout.js'; import { validateSeedReplaySafety } from './validate-seed-replay-safety.js'; import { validateSeedStateMachine } from './validate-seed-state-machine.js'; import { validateVisibilityPredicates } from './validate-visibility-predicates.js'; +import { validatePredicatePathRefs } from './validate-predicate-path-refs.js'; import { validateSecurityPosture } from './validate-security-posture.js'; import { validateOrgAxisRedLines } from './validate-org-axis-red-lines.js'; import { validateSharingRuleEnforceability } from './validate-sharing-rule-enforceability.js'; @@ -350,6 +351,32 @@ const RUNTIME_HEAVY_SOURCE_PARSE = * wrong 422 there is the whole product, so P1 does not gate them — the issue's * own worked example, and every acceptance criterion on it, is a flow. */ +/** + * The rule judges a `views[]` conditional-visibility predicate — and every OTHER + * rule on that surface (the three ADR-0089 D3b rules in + * `validate-visibility-predicates.ts`) is CLI-only. + * + * This reason is deliberately NOT one of the three above: none of them is true + * here. `validatePredicatePathRefs` needs nothing but the written item — its + * oracle is the static `getMetadataTypeSchema` registry, not the tenant's other + * metadata — so the per-write snapshot IS enough, `stackKeyForType('view')` + * already exists, and wiring it would work today. + * + * It is not wired because a HALF-wired wall is worse than an unwired one. A + * Studio `view` write would then be refused for an unresolvable predicate PATH + * while a predicate that does not parse at all (`visibility-predicate-syntax`) + * and one with no root at all (`visibility-bare-identifier`) walked straight + * through the same door — three sibling verdicts about one predicate, one of + * them enforced, and no author able to predict which. The surface should move to + * `runtime-publish` as a FAMILY, in one measured edit, which is a decision about + * `views` writes rather than a rider on #7010's corpus-counted gate. + */ +const RUNTIME_VISIBILITY_FAMILY_IS_CLI_ONLY = + 'Deliberate, and not a snapshot limitation: this rule needs only the written item, but every other ' + + 'rule on the `views[]` visibility-predicate surface (validate-visibility-predicates.ts) is CLI-only. ' + + 'Gating one of three sibling verdicts about the same predicate at the Studio door is less ' + + 'predictable than gating none; move the family together, as one measured edit.'; + const RUNTIME_OBJECT_WRITES_P2 = 'P2 (#4463): judges an object/field declaration. Object writes are the hottest metadata path in ' + '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[] = [ surfaceReason: RUNTIME_NEEDS_FULL_SNAPSHOT, run: (stack) => validateVisibilityPredicates(stack), }, + // #7010 — the same predicate surface, one question further in. The three + // ADR-0089 D3b rules above judge a predicate's SHAPE (does it parse, is it + // rooted, is the root right for the layer) and never open the target schema, + // so `data.tpye == 'formula'` passes all three and still resolves to nothing. + // This rule resolves the PATH against the schema the form edits — the closed + // `getMetadataTypeSchema` key set — and is therefore immune to the CEL + // type-name blind spot that made #6248's gate structurally unable to catch + // #6254's 16 bare `type ==` predicates. + // + // Scoped to schema-bound forms (`data: { provider: 'schema', schemaId }`); + // the `record.*` layer is deliberately out of scope because an ObjectQL + // object's addressable path set is NOT closed (lookup traversal, system + // columns, formula outputs), and an `error` gate over an open set generates + // false build errors. See the rule's module note. + { + name: 'validatePredicatePathRefs', + tier: 'gating', + input: 'normalized', + commands: ALL, + source: 'packages/lint/src/validate-predicate-path-refs.ts', + surfaces: CLI_ONLY, + surfaceReason: RUNTIME_VISIBILITY_FAMILY_IS_CLI_ONLY, + run: (stack) => validatePredicatePathRefs(stack), + }, // #1874 — flow authoring anti-patterns. Advisory by default; a finding marked // `error` gates. Three do today: `flow-runas-unscoped` (#3760 — metadata the // runtime REFUSES to execute), plus `flow-branch-label-unmatched` and diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index ba82e16df1..d3dd5e5556 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -174,6 +174,17 @@ export type { VisibilityOptions, } from './validate-visibility-predicates.js'; +export { + validatePredicatePathRefs, + PREDICATE_PATH_UNRESOLVED, + PREDICATE_PATH_UNROOTED, +} from './validate-predicate-path-refs.js'; +export type { + PredicatePathFinding, + PredicatePathSeverity, + PredicatePathOptions, +} from './validate-predicate-path-refs.js'; + export { validateCapabilityReferences, CAPABILITY_REFERENCE_UNKNOWN, diff --git a/packages/lint/src/validate-predicate-path-refs.test.ts b/packages/lint/src/validate-predicate-path-refs.test.ts new file mode 100644 index 0000000000..d4b8c00ac9 --- /dev/null +++ b/packages/lint/src/validate-predicate-path-refs.test.ts @@ -0,0 +1,392 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Tests for the #7010 predicate PATH-resolution gate. + * + * The load-bearing block is `#6254 corpus` at the bottom. Everything above it is + * unit coverage over a hand-built schema; that block runs the rule over the + * metadata forms this repo actually SHIPS (`METADATA_FORM_REGISTRY`), which is + * both the corpus count the widening discipline requires before an `error`-level + * gate may land, and the reverse verification — restoring #6254's pre-fix + * `object.form.ts` spellings must turn the count from 0 to 16. + */ + +import { describe, it, expect } from 'vitest'; +import { z } from 'zod'; +import { METADATA_FORM_REGISTRY } from '@objectstack/spec/system'; +import { getMetadataTypeSchema } from '@objectstack/spec/kernel'; + +import { + validatePredicatePathRefs, + PREDICATE_PATH_UNRESOLVED, + PREDICATE_PATH_UNROOTED, +} from './validate-predicate-path-refs.js'; +import { AUTHORING_RULES } from './authoring-rules.js'; + +// ── A miniature target schema, so the traversal is pinned against a shape the +// test fully controls rather than against whatever `FieldSchema` happens to +// declare this month. +const RowSchema = z.object({ + name: z.string(), + type: z.string(), + maxLength: z.number().optional(), +}); + +const DemoSchema = z.object({ + name: z.string(), + type: z.string().optional(), + enable: z.object({ search: z.boolean().optional() }).optional(), + rows: z.array(RowSchema).optional(), + entries: z.record(z.string(), RowSchema).optional(), + bag: z.record(z.string(), z.unknown()).optional(), + tags: z.array(z.string()).optional(), +}); + +const resolveSchema = (schemaId: string) => (schemaId === 'demo' ? DemoSchema : undefined); +const run = (stack: Record) => validatePredicatePathRefs(stack, { resolveSchema }); + +/** A `defineForm`-shaped view: the bare FormView with a schema data source. */ +const form = (sections: unknown[], schemaId = 'demo') => ({ + views: [{ name: 'demo_form', data: { provider: 'schema', schemaId }, sections }], +}); + +describe('validatePredicatePathRefs — resolvable paths', () => { + it('is clean when every `data.` path resolves', () => { + expect( + run(form([ + { + label: 'Basics', + visibleWhen: "data.type == 'formula'", + fields: [ + { field: 'name', visibleWhen: "data.type != 'code'" }, + { field: 'x', visibleWhen: 'data.enable.search' }, + ], + }, + ])), + ).toEqual([]); + }); + + it('rebinds `data` to the ROW inside a repeater sub-field list (#6254)', () => { + // `data.maxLength` is NOT a key of DemoSchema; it IS a key of RowSchema. + // Judging the sub-field against the parent scope would report it. + expect( + run(form([ + { + label: 'Rows', + fields: [ + { + field: 'rows', + type: 'repeater', + fields: [{ field: 'maxLength', visibleWhen: "data.type == 'text'" }], + }, + ], + }, + ])), + ).toEqual([]); + }); + + it('treats a record map KEY as accepted and resolves the rest against the value schema', () => { + expect(run(form([{ fields: [{ field: 'x', visibleWhen: "data.entries.anything.type == 't'" }] }]))) + .toEqual([]); + }); + + it('stops at a scope that declares no key set', () => { + expect(run(form([{ fields: [{ field: 'x', visibleWhen: 'data.bag.whatever.deep == 1' }] }]))) + .toEqual([]); + }); + + it('does not judge a comprehension-macro variable as a dropped root', () => { + // `type` is a DemoSchema key, so a naive bare-identifier scan would report + // the loop variable if it were spelled `type`. + expect(run(form([{ fields: [{ field: 'x', visibleWhen: "data.tags.all(type, type != '')" }] }]))) + .toEqual([]); + }); +}); + +describe('validatePredicatePathRefs — `predicate-path-unresolved`', () => { + it('names the unresolvable path and gates', () => { + const findings = run(form([{ fields: [{ field: 'x', visibleWhen: "data.tpye == 'formula'" }] }])); + expect(findings).toHaveLength(1); + expect(findings[0].rule).toBe(PREDICATE_PATH_UNRESOLVED); + expect(findings[0].severity).toBe('error'); + expect(findings[0].message).toContain('`data.tpye`'); + expect(findings[0].path).toBe('views[0].sections[0].fields[0].visibleWhen'); + // The suggestion is what makes this self-correcting for an AI author. + expect(findings[0].hint).toContain("'type'"); + }); + + it('names the unresolvable SEGMENT of a nested path, not just its head', () => { + const findings = run(form([{ visibleWhen: 'data.enable.serach', fields: [] }])); + expect(findings).toHaveLength(1); + expect(findings[0].rule).toBe(PREDICATE_PATH_UNRESOLVED); + expect(findings[0].message).toContain('`data.enable.serach`'); + expect(findings[0].message).toContain('not a key of `data.enable`'); + }); + + it('reports a bad path inside a repeater row against the ROW schema', () => { + const findings = run(form([ + { + fields: [ + { + field: 'rows', + type: 'repeater', + fields: [{ field: 'maxLength', visibleWhen: 'data.enable.search' }], + }, + ], + }, + ])); + expect(findings).toHaveLength(1); + expect(findings[0].rule).toBe(PREDICATE_PATH_UNRESOLVED); + expect(findings[0].message).toContain('`data.enable`'); + expect(findings[0].path).toBe('views[0].sections[0].fields[0].fields[0].visibleWhen'); + }); +}); + +describe('validatePredicatePathRefs — `predicate-path-unrooted`', () => { + it('reports a bare identifier that IS a schema key (#6254 shape)', () => { + const findings = run(form([{ fields: [{ field: 'x', visibleWhen: "type == 'formula'" }] }])); + expect(findings).toHaveLength(1); + expect(findings[0].rule).toBe(PREDICATE_PATH_UNROOTED); + expect(findings[0].severity).toBe('error'); + expect(findings[0].message).toContain('`type`'); + expect(findings[0].hint).toContain('`data.type`'); + }); + + it('catches the exact pre-#6254 spelling `type in [...]`', () => { + const findings = run(form([ + { fields: [{ field: 'x', visibleWhen: "type in ['text','textarea','email']" }] }, + ])); + expect(findings.map((f) => f.rule)).toEqual([PREDICATE_PATH_UNROOTED]); + }); + + it('says nothing about a bare identifier the schema does not declare', () => { + // That is `visibility-bare-identifier`'s (#6128) verdict to give, and one + // broken predicate must not produce two findings from two rules that + // disagree about the reason. + expect(run(form([{ fields: [{ field: 'x', visibleWhen: "nonsense == 'a'" }] }]))).toEqual([]); + }); +}); + +describe('validatePredicatePathRefs — deliberate boundaries', () => { + it('gives no verdict on a predicate the canonical front end refuses', () => { + // `visibility-predicate-syntax` (#6253) owns this source. + expect(run(form([{ fields: [{ field: 'x', visibleWhen: "type === 'formula'" }] }]))).toEqual([]); + }); + + it('skips a form bound to an ObjectQL object rather than a schema', () => { + const stack = { + views: [ + { + name: 'contact', + data: { provider: 'object', objectName: 'contact' }, + sections: [{ fields: [{ field: 'x', visibleWhen: "data.tpye == 'a'" }] }], + }, + ], + }; + expect(run(stack)).toEqual([]); + }); + + it('skips a `schemaId` no schema resolves', () => { + expect(run(form([{ fields: [{ field: 'x', visibleWhen: "data.tpye == 'a'" }] }], 'unknown_kind'))) + .toEqual([]); + }); + + it('does not descend a sub-field list whose row schema cannot be resolved', () => { + expect( + run(form([ + { + fields: [ + { field: 'not_a_key', fields: [{ field: 'y', visibleWhen: "data.whatever == 'a'" }] }, + ], + }, + ])), + ).toEqual([]); + }); + + it('survives a resolver that throws', () => { + const stack = form([{ fields: [{ field: 'x', visibleWhen: "data.tpye == 'a'" }] }]); + expect( + validatePredicatePathRefs(stack, { + resolveSchema: () => { + throw new Error('registry offline'); + }, + }), + ).toEqual([]); + }); +}); + +describe('validatePredicatePathRefs — traversal reach', () => { + it('reaches `formViews.` and the container `form` (#6381 ladder)', () => { + const bad = { fields: [{ field: 'x', visibleWhen: "data.tpye == 'a'" }] }; + const findings = validatePredicatePathRefs( + { + views: [ + { + name: 'demo', + form: { data: { provider: 'schema', schemaId: 'demo' }, sections: [bad] }, + formViews: { + edit: { data: { provider: 'schema', schemaId: 'demo' }, sections: [bad] }, + }, + }, + ], + }, + { resolveSchema }, + ); + expect(findings.map((f) => f.path)).toEqual([ + 'views[0].form.sections[0].fields[0].visibleWhen', + 'views[0].formViews.edit.sections[0].fields[0].visibleWhen', + ]); + }); + + it('reads the deprecated `visibleOn` alias VALUE on a raw authored object', () => { + const findings = run(form([{ fields: [{ field: 'x', visibleOn: "data.tpye == 'a'" }] }])); + expect(findings.map((f) => f.rule)).toEqual([PREDICATE_PATH_UNRESOLVED]); + }); + + it('walks a name-keyed `views` map and reports its map path', () => { + const findings = validatePredicatePathRefs( + { + views: { + demo_form: { + data: { provider: 'schema', schemaId: 'demo' }, + sections: [{ fields: [{ field: 'x', visibleWhen: "data.tpye == 'a'" }] }], + }, + }, + }, + { resolveSchema }, + ); + expect(findings.map((f) => f.path)).toEqual([ + 'views.demo_form.sections[0].fields[0].visibleWhen', + ]); + }); +}); + +describe('registry wiring', () => { + it('is registered in AUTHORING_RULES as a gating rule on all three commands', () => { + const entry = AUTHORING_RULES.find((r) => r.name === 'validatePredicatePathRefs'); + expect(entry, '#7010 rule missing from AUTHORING_RULES').toBeDefined(); + expect(entry!.tier).toBe('gating'); + expect([...entry!.commands].sort()).toEqual(['build', 'lint', 'validate']); + // CLI-only, with a reason that is a DECISION rather than a limitation: see + // `RUNTIME_VISIBILITY_FAMILY_IS_CLI_ONLY`. The whole `views[]` visibility + // family sits on this side of the wall, and it should move together. + expect([...entry!.surfaces]).toEqual(['cli']); + expect(entry!.surfaceReason).toBeTruthy(); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// #6254 corpus — the shipped metadata forms +// ──────────────────────────────────────────────────────────────────────────── +// +// `METADATA_FORM_REGISTRY` is the whole population of `data.`-rooted predicates +// this repo ships (17 forms; 46 predicates, all `visibleWhen`). Every entry is a +// `defineForm` output, i.e. exactly the bare FormView shape `formViewSites` +// walks as its `self` rung — so the corpus is fed through the rule's PRODUCTION +// entry point, not through a parallel walker written for the test. +// +// This is the count the widening discipline requires before an `error`-level +// gate may land. Measured on `origin/main@55da611e5`: **0**. A non-zero count +// here would have been a STOP. +describe('#7010 corpus — shipped METADATA_FORM_REGISTRY', () => { + const shippedStack = { views: Object.values(METADATA_FORM_REGISTRY) }; + + it('every shipped metadata form resolves a schema (the oracle is not vacuously absent)', () => { + // Without this the corpus assertion below could read 0 because no form + // resolved a schema at all — a green gate over nothing, the #4984 signature. + const unresolved = Object.keys(METADATA_FORM_REGISTRY) + .filter((type) => !getMetadataTypeSchema(type)); + expect(unresolved).toEqual([]); + }); + + it('reaches every shipped predicate (the walk is not vacuously empty)', () => { + // The anti-vacuity guard from the other end, and the reason it is written + // this way. The obvious version — "resolve every form against an empty + // schema, expect a finding per predicate" — is WRONG here and was measured + // wrong: an empty schema also fails to resolve the `fields` repeater, so the + // walk stops before the 16 sub-field predicates and the count comes out + // BELOW the corpus while looking like proof of reach. + // + // So keep the real schemas (descent works exactly as in production) and + // corrupt the PREDICATES instead: every source becomes a path no schema can + // declare. Each predicate the walk reaches must then report exactly once, + // which makes the assertion an equality on the corpus size rather than a + // floor that any subset satisfies. + const corrupted = structuredClone(shippedStack) as { views: unknown[] }; + let predicates = 0; + const corrupt = (node: unknown): void => { + if (Array.isArray(node)) { + for (const child of node) corrupt(child); + return; + } + if (!node || typeof node !== 'object') return; + const rec = node as Record; + for (const key of ['visibleWhen', 'visibleOn']) { + const value = rec[key]; + if (typeof value === 'string') { + rec[key] = 'data.__no_such_key__'; + predicates++; + } else if (value && typeof value === 'object' + && typeof (value as Record).source === 'string') { + (value as Record).source = 'data.__no_such_key__'; + predicates++; + } + } + for (const value of Object.values(rec)) corrupt(value); + }; + corrupt(corrupted.views); + expect(predicates, 'the shipped metadata forms carry no predicates at all').toBe(46); + + const findings = validatePredicatePathRefs(corrupted); + expect(findings).toHaveLength(predicates); + expect(new Set(findings.map((f) => f.rule))).toEqual(new Set([PREDICATE_PATH_UNRESOLVED])); + }); + + it('reports NOTHING over the shipped forms (corpus count = 0)', () => { + const findings = validatePredicatePathRefs(shippedStack); + expect( + findings.map((f) => `${f.rule} @ ${f.path}: ${f.message}`), + 'a shipped metadata form now carries a predicate path its schema does not declare', + ).toEqual([]); + }); + + it('catches the pre-#6254 bare spellings when they are restored (reverse verification)', () => { + // The reverse direction is RED-on-restore: #6254 rewrote 16 predicates in + // `object.form.ts` from `type ...` to `data.type ...`. Restoring the bare + // spelling on a deep copy of the shipped `object` form must produce exactly + // 16 `predicate-path-unrooted` findings — the count the issue measured, and + // the count this rule exists to have caught before it shipped. + const objectForm = structuredClone(METADATA_FORM_REGISTRY.object) as Record; + let restored = 0; + const debare = (node: unknown): void => { + if (Array.isArray(node)) { + for (const child of node) debare(child); + return; + } + if (!node || typeof node !== 'object') return; + const rec = node as Record; + // `defineForm` PARSES, so a shipped predicate is the post-parse + // `{ dialect, source }` envelope, not the raw string the source file spells. + const predicate = rec.visibleWhen; + const source = typeof predicate === 'string' ? predicate + : predicate && typeof predicate === 'object' + && typeof (predicate as Record).source === 'string' + ? ((predicate as Record).source as string) + : undefined; + if (source && /^data\.type\b/.test(source)) { + const bare = source.replace(/\bdata\.type\b/g, 'type'); + if (typeof predicate === 'string') rec.visibleWhen = bare; + else (predicate as Record).source = bare; + restored++; + } + for (const value of Object.values(rec)) debare(value); + }; + debare(objectForm); + expect(restored, 'the 16 sites #6254 rewrote are no longer where this test looks').toBe(16); + + const findings = validatePredicatePathRefs({ views: [objectForm] }); + expect(findings).toHaveLength(16); + expect(new Set(findings.map((f) => f.rule))).toEqual(new Set([PREDICATE_PATH_UNROOTED])); + expect(findings[0].message).toContain('`type`'); + }); +}); diff --git a/packages/lint/src/validate-predicate-path-refs.ts b/packages/lint/src/validate-predicate-path-refs.ts new file mode 100644 index 0000000000..3e807c9bda --- /dev/null +++ b/packages/lint/src/validate-predicate-path-refs.ts @@ -0,0 +1,651 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * **Publish-time resolution of predicate PATH references** (#7010, the + * producer-side companion to #6936). + * + * #6936 was ruled Option C: the objectui evaluator keeps failing OPEN, with a + * warning. That settles the RENDERER's posture and deliberately leaves the + * producer side open — the filing's own words for what should happen instead: + * 「谓词表达式应在发布期被校验(引用的路径必须存在于对应 schema),而不是让渲染 + * 器在运行期猜」. This file is that check. + * + * ## What was already covered, and the hole between the two + * + * `validate-visibility-predicates.ts` (ADR-0089 D3b) judges three things about a + * conditional-visibility predicate, and **none of them looks at the target + * schema**: + * + * - `visibility-predicate-syntax` (#6253) — does it parse at all; + * - `visibility-bare-identifier` (#6128) — is a reference rooted at *anything*; + * - `visibility-root-mislayered` (ADR-0089 D3) — is that root the right one for + * the layer (`record.` on a runtime surface, `data.` on a metadata form). + * + * A predicate can pass all three and still name a path that does not exist: + * `data.tpye == 'formula'` is valid CEL, is rooted, and is rooted on the right + * root — and resolves to nothing. The evaluator then faults, visibility falls + * back to `true`, and the element renders unconditionally, pixel-identical to + * one carrying no predicate at all. That is the #5149 fail-open family, arriving + * through the one door the family's own gates leave open. + * + * The gap is not hypothetical on this exact surface. #6254 measured 16 + * predicates in `packages/spec/src/data/object.form.ts` written as BARE + * identifiers (`type == 'formula'`) where the sibling `field.form.ts` wrote + * `data.type` — and stated, in as many words, why the #6128 gate structurally + * could not catch them: `type` is an identifier **CEL itself declares** (it + * denotes a type value), so the strict-environment checker reports a type + * overload rather than an unknown variable, and `visibility-bare-identifier` + * skips it by design (widening it to read overload messages would kill the + * legitimate `type(record.x) == string`). + * + * This rule does not have that blind spot, because it never asks CEL what + * resolves. It asks the **target schema** — a closed, enumerable key set — and + * CEL's own type-name vocabulary has no bearing on whether `type` is a key of + * `FieldSchema`. Measured: restoring #6254's pre-fix `object.form.ts` makes this + * rule report exactly those 16 sites (see the reverse-verification test). + * + * ## Scope: the `data.*` layer only, and why that is a decision + * + * The target-schema oracle has to be CLOSED for an `error`-level gate — a key + * set the rule can enumerate, where "absent" really means "cannot resolve". That + * is true for exactly one of the two binding layers ADR-0089 D3 names: + * + * - **metadata-editing forms** (`data`, a `defineForm` whose data source is + * `{ provider: 'schema', schemaId }` — `view.zod.ts:151-161`). The row under + * edit is an instance of a metadata type, and its shape is a Zod schema this + * package can read key by key through the canonical + * `getMetadataTypeSchema` registry. Closed. **This rule's scope.** + * - **runtime record surfaces** (`record`, a form bound to an ObjectQL object). + * Not closed today, and not closable by this rule: `record.` legitimately + * reaches related records through lookup traversal, system columns the + * authored `fields` map never lists, and formula/rollup outputs. An + * `error`-level gate over an open set is a false-build-error generator, which + * is the one direction a gate may not fail in. Left to the sibling rules, + * and recorded as an open question on #7010 rather than guessed at here. + * + * ## Two limbs, one question + * + * Both limbs ask "does this identifier name something the target schema + * declares?" — they differ only in whether the author supplied the root: + * + * - `predicate-path-unresolved` — a `data.`-rooted path whose first + * unresolvable segment is not declared by the schema at that point. + * `data.tpye`, `data.enable.trahs`. + * - `predicate-path-unrooted` — a BARE identifier that IS a declared key of the + * scope. This is #6254's shape verbatim: the author wrote the right name and + * dropped the root. Deliberately narrower than "any bare identifier" (that is + * `visibility-bare-identifier`'s question, and this rule must not answer it a + * second time): the schema-key membership is what makes the verdict + * unambiguous, and it is precisely the evidence CEL's declaration table + * cannot supply. + * + * ## Where the walk stops, deliberately + * + * Every one of these is a MISSED CATCH, never a false build error — the only + * safe direction for a gate: + * + * - **A predicate the canonical front end will not parse.** No AST, no paths, + * no verdict — that source is `visibility-predicate-syntax`'s (#6253), and + * one broken predicate must produce one finding, not two. + * - **A scope that is not key-bearing.** `z.record(z.string(), z.unknown())`, + * `z.unknown()`, `z.any()`, an array reached by `.` — the schema declares no + * key set, so "absent" carries no information. The walk stops at that segment + * and everything below it is unjudged. + * - **A `record` map's KEY segment.** `z.record(z.string(), FieldSchema)` + * accepts any key by construction, so `data.fields.acme` consumes `acme` + * unconditionally and resolves the REST against `FieldSchema`. Treating the + * map key as a declared name would report every real map entry. + * - **Comprehension-macro variables.** `data.tags.all(t, t != '')` binds `t` + * inside the macro body; a bare `t` there is not a dropped root, whatever the + * schema happens to declare. + * - **Index access** (`data.x['y']`, `data.list[0]`). The path chain is built + * from `.`-member access only; an `[]` node ends the chain and the rest is + * unjudged. + * - **A `schemaId` that resolves to no schema.** A stack may name a schema this + * package cannot see (a custom one, or a type served only at runtime). No + * oracle, no verdict. + * + * ## Repeater rows rebind `data`, and the rule follows (#6254) + * + * A sub-field of a `type: 'record'` / `repeater` / `composite` entry is rendered + * with its own activation: objectui's metadata SchemaForm evaluates it as + * `evaluatePredicate(spec.visibleOn, { data: row })`. So inside `object.form.ts`'s + * `fields` repeater, `data.type` means *this row's* `type` — `FieldSchema.type`, + * not `ObjectSchema.type` (which does not exist). `view.zod.ts:1647-1657` states + * both halves: the root is still spelled `data` at every depth, and the object it + * binds is the ROW. This rule descends with the same rebinding, which is what + * makes the shipped corpus read 0 instead of 16 false positives. + */ + +import { parseCelToAst } from '@objectstack/formula'; +import { getMetadataTypeSchema } from '@objectstack/spec/kernel'; +import { findClosestMatches, formatSuggestion } from '@objectstack/spec'; + +import { formViewSites } from './view-walk.js'; + +export const PREDICATE_PATH_UNRESOLVED = 'predicate-path-unresolved'; +export const PREDICATE_PATH_UNROOTED = 'predicate-path-unrooted'; + +/** Both rules GATE — see the module note for why each is safe at `error`. */ +export type PredicatePathSeverity = 'error' | 'warning'; + +export interface PredicatePathFinding { + severity: PredicatePathSeverity; + /** Diagnostic rule id — `predicate-path-unresolved` / `predicate-path-unrooted`. */ + rule: string; + /** Human-readable location, e.g. `view "object" · schema form "object"`. */ + where: string; + /** Config path, e.g. `views[0].sections[1].fields[0].fields[21].visibleWhen`. */ + path: string; + /** What is wrong — always names the unresolvable path. */ + message: string; + /** How to fix it. */ + hint: string; +} + +/** Options for {@link validatePredicatePathRefs}. */ +export interface PredicatePathOptions { + /** + * Schema oracle: `schemaId` → the Zod schema of the row under edit. Defaults + * to the canonical `getMetadataTypeSchema` registry + * (`packages/spec/src/kernel/metadata-type-schemas.ts`), which is the same + * entry `saveMetaItem` validates against — so this rule can never disagree + * with the parse that judges the saved row. Injectable so a test can pin the + * traversal against a small schema, and so a future caller holding a richer + * registry (runtime `/meta/types`, a package-declared type) can supply it + * without this file growing a second lookup path. + */ + resolveSchema?: (schemaId: string) => unknown; +} + +type AnyRec = Record; + +/** + * The predicate keys a form field / section can carry. Canonical first + * (ADR-0089): `visibleOn` is the deprecated view-side alias, folded into + * `visibleWhen` by the ADR-0087 D2 conversion one layer above the `normalized` + * tier — so on the three CLI commands the alias limb is already unreachable. It + * stays for the reason `validate-visibility-predicates.ts` states about its own + * limbs: this is a PUBLISHED export, and a caller handing it a raw authored + * object must have the alias-spelled predicate judged rather than skipped. + * Canonical-first ordering means the limb can only ever add coverage. + */ +const PREDICATE_KEYS = ['visibleWhen', 'visibleOn'] as const; + +/** The binding root this rule resolves. Metadata-editing forms only — see the module note. */ +const ROOT = 'data'; + +/** + * CEL comprehension macros: the receiver-call forms that BIND their first + * argument as a loop variable. A bare identifier that is one of those is not a + * dropped root, so it is declared before the unrooted limb runs. + */ +const COMPREHENSION_MACROS = new Set(['all', 'exists', 'exists_one', 'map', 'filter']); + +// ── Zod introspection ─────────────────────────────────────────────── +// +// Reads `.def` directly rather than importing zod's internals, and tolerates +// the `lazySchema` proxy. This is the same peeling +// `packages/spec/src/kernel/metadata-authoring-lint.ts` and the metadata-form ↔ +// Zod reconciliation gate already do — including the `pipe` fork, which is +// load-bearing rather than defensive: `a.transform(fn)` authors against the IN +// side while `z.preprocess(fn, schema)` puts the transform on IN and the +// authorable schema on OUT, and taking `def.in` unconditionally makes the walker +// return the transform and go silent on the type (#4488 measured it on +// `translation`, #5074 on `view`). A gate that stops covering a type is worse +// than one that fails. + +type ZodNode = { def?: { type?: string;[k: string]: unknown }; _def?: { type?: string;[k: string]: unknown }; shape?: AnyRec }; + +/** + * The node's `def`, or `undefined` when it is not a schema node. + * + * `typeof s === 'function'` is NOT defensive padding — several of this repo's + * canonical schemas arrive as the `lazySchema` proxy, which is CALLABLE, and a + * plain `typeof s === 'object'` guard silently answers "not a schema" for every + * one of them. Measured while building this rule: with the object-only guard the + * whole `METADATA_FORM_REGISTRY` corpus resolved to a key set of size 0, so the + * gate reported clean over 46 predicates and over a deliberately corrupted copy + * of the same corpus alike. A green gate that reads nothing is the failure mode + * the corpus tests exist to catch, and this is the line it turned on. + */ +function defOf(schema: unknown): AnyRec | undefined { + if (!schema || (typeof schema !== 'object' && typeof schema !== 'function')) return undefined; + const s = schema as ZodNode; + return (s.def ?? s._def) as AnyRec | undefined; +} + +/** + * Peel WRAPPER nodes only — optionality, defaults, laziness, pipes. Container + * nodes (`array` / `record`) are deliberately NOT peeled here: whether a + * container is transparent depends on the question being asked, and the two + * questions have opposite answers (see {@link rowScopeOf} vs {@link stepInto}). + */ +function peel(schema: unknown, depth = 0): unknown { + if (!schema || depth > 25) return schema; + const d = defOf(schema); + if (!d) return schema; + switch (d.type) { + case 'optional': + case 'nullable': + case 'default': + case 'prefault': + case 'readonly': + case 'catch': + case 'nonoptional': + return peel(d.innerType, depth + 1); + case 'lazy': + return peel((d.getter as () => unknown)(), depth + 1); + case 'pipe': { + const inner = peel(d.in, depth + 1); + return defOf(inner)?.type === 'transform' ? peel(d.out, depth + 1) : inner; + } + default: + return schema; + } +} + +function optionsOf(d: AnyRec | undefined): unknown[] { + return Array.isArray(d?.options) ? (d.options as unknown[]) : []; +} + +/** + * Every key the node declares, or `null` when it is not key-bearing. + * + * A union contributes the UNION of its members' keys: an author may legally + * write any member's shape, so a key declared by one member is declared. That is + * the same safe direction the metadata-form reconciliation gate takes, and it + * matters here — `view`'s schema is a three-way union (#3095). + */ +function keysOf(schema: unknown, depth = 0): string[] | null { + if (depth > 25) return null; + const u = peel(schema); + const d = defOf(u); + if (d?.type === 'object') return Object.keys((d.shape ?? (u as ZodNode).shape ?? {}) as AnyRec); + if (d?.type === 'union' || d?.type === 'discriminated_union') { + const all = new Set(); + let keyBearing = false; + for (const option of optionsOf(d)) { + const k = keysOf(option, depth + 1); + if (!k) continue; + keyBearing = true; + for (const key of k) all.add(key); + } + return keyBearing ? [...all] : null; + } + if (d?.type === 'intersection') { + const left = keysOf(d.left, depth + 1); + const right = keysOf(d.right, depth + 1); + if (!left && !right) return null; + return [...new Set([...(left ?? []), ...(right ?? [])])]; + } + return null; +} + +/** The sub-schema stored under `key`, looking through union / intersection members. */ +function propertyOf(schema: unknown, key: string, depth = 0): unknown { + if (depth > 25) return undefined; + const u = peel(schema); + const d = defOf(u); + if (d?.type === 'object') return ((d.shape ?? (u as ZodNode).shape ?? {}) as AnyRec)[key]; + if (d?.type === 'union' || d?.type === 'discriminated_union') { + for (const option of optionsOf(d)) { + const found = propertyOf(option, key, depth + 1); + if (found !== undefined) return found; + } + } + if (d?.type === 'intersection') { + return propertyOf(d.left, key, depth + 1) ?? propertyOf(d.right, key, depth + 1); + } + return undefined; +} + +/** + * The scope a REPEATER ROW binds — `data` inside a `type: 'record'` / + * `repeater` / `composite` sub-field list (#6254). Here the containers ARE + * transparent: the form's `fields` entry declares the collection, and each row + * is one element of it. + */ +function rowScopeOf(scope: unknown, key: string): unknown { + const prop = propertyOf(scope, key); + if (prop === undefined) return undefined; + let node = peel(prop); + for (let i = 0; i < 25; i++) { + const d = defOf(node); + if (d?.type === 'array') node = peel(d.element); + else if (d?.type === 'record') node = peel(d.valueType); + else return node; + } + return node; +} + +/** The outcome of resolving ONE `.`-segment against a scope. */ +type Step = + /** The segment is declared; `next` is the scope for the segment after it. */ + | { kind: 'declared'; next: unknown } + /** The scope declares a key set and this segment is not in it. */ + | { kind: 'undeclared'; declared: string[] } + /** The scope declares no key set — nothing below can be judged. */ + | { kind: 'opaque' }; + +function stepInto(scope: unknown, segment: string): Step { + const u = peel(scope); + const d = defOf(u); + // A record map accepts ANY key by construction (`z.record(z.string(), X)`), + // so the segment is a map KEY, not a declared name. Consume it and judge the + // rest against the value schema. + if (d?.type === 'record') return { kind: 'declared', next: d.valueType }; + const declared = keysOf(u); + if (declared === null) return { kind: 'opaque' }; + if (!declared.includes(segment)) return { kind: 'undeclared', declared }; + return { kind: 'declared', next: propertyOf(u, segment) }; +} + +// ── CEL AST reading ───────────────────────────────────────────────── + +type AstNode = { op?: string; args?: unknown }; + +function isNode(v: unknown): v is AstNode { + return !!v && typeof v === 'object' && typeof (v as AstNode).op === 'string'; +} + +/** + * The dotted segment chain a `.`-access node spells, or `null` when its head is + * not a plain identifier (an index access, a call result, a literal). Built from + * `.` member access only — `args[1]` of a `.` node is the member NAME string, + * `args[0]` the receiver. + */ +function memberChain(node: unknown): string[] | null { + if (!isNode(node)) return null; + if (node.op === 'id' && typeof node.args === 'string') return [node.args]; + if (node.op === '.' && Array.isArray(node.args) && typeof node.args[1] === 'string') { + const head = memberChain(node.args[0]); + return head ? [...head, node.args[1]] : null; + } + return null; +} + +/** Every `..…` chain in the AST, as its segments below the root. */ +function rootedPaths(node: unknown, out: string[][]): void { + if (Array.isArray(node)) { + for (const child of node) rootedPaths(child, out); + return; + } + if (!isNode(node)) return; + if (node.op === '.') { + const chain = memberChain(node); + if (chain && chain[0] === ROOT && chain.length > 1) { + out.push(chain.slice(1)); + return; + } + } + rootedPaths(node.args, out); +} + +/** + * Split the AST's identifiers into the ones used as a NAMESPACE (`a.b`, `a?.b`, + * `a['b']`, `a.exists(…)`) or BOUND by a comprehension macro, and the plain + * value references. Only the latter can be a dropped root. + */ +function classifyIdentifiers(node: unknown, values: Set, excluded: Set): void { + if (Array.isArray(node)) { + for (const child of node) classifyIdentifiers(child, values, excluded); + return; + } + if (!isNode(node)) return; + const args = node.args; + if (Array.isArray(args)) { + // `.` / `.?` / `[]` hold the receiver first; `rcall` holds the method NAME + // first and the receiver second. + const receiver = node.op === 'rcall' ? args[1] : args[0]; + if ( + (node.op === '.' || node.op === '.?' || node.op === '[]' || node.op === 'rcall') + && isNode(receiver) && receiver.op === 'id' && typeof receiver.args === 'string' + ) { + excluded.add(receiver.args); + } + // A comprehension binds its loop variable: `x.all(t, …)` parses as + // `rcall(['all', x, [id(t), body]])`. + if (node.op === 'rcall' && typeof args[0] === 'string' && COMPREHENSION_MACROS.has(args[0])) { + const macroArgs = args[2]; + if (Array.isArray(macroArgs) && macroArgs.length >= 2) { + const bound = macroArgs[0]; + if (isNode(bound) && bound.op === 'id' && typeof bound.args === 'string') { + excluded.add(bound.args); + } + } + } + } + if (node.op === 'id' && typeof node.args === 'string') { + values.add(node.args); + return; + } + classifyIdentifiers(args, values, excluded); +} + +// ── The walk ──────────────────────────────────────────────────────── + +/** Extract the CEL source from a predicate value (string, or `{ source }` envelope). */ +function predicateSource(v: unknown): string | undefined { + if (typeof v === 'string') return v; + if (v && typeof v === 'object' && typeof (v as AnyRec).source === 'string') { + return (v as AnyRec).source as string; + } + return undefined; +} + +function isRec(v: unknown): v is AnyRec { + return !!v && typeof v === 'object' && !Array.isArray(v); +} + +/** + * Records in a collection authored either as an array or as a name-keyed map, + * each with its config PATH. Local rather than shared because the two other + * copies in this package are being consolidated under #7186 — this one folds + * into that helper when it lands, and duplicating it now is cheaper than + * colliding with an in-flight refactor of `view-walk.ts`. + */ +function collectionEntries(v: unknown, base: string): Array<{ rec: AnyRec; path: string }> { + if (Array.isArray(v)) { + const out: Array<{ rec: AnyRec; path: string }> = []; + for (let i = 0; i < v.length; i++) { + if (isRec(v[i])) out.push({ rec: v[i] as AnyRec, path: `${base}[${i}]` }); + } + return out; + } + if (isRec(v)) { + return Object.entries(v) + .filter(([, def]) => isRec(def)) + .map(([name, def]) => ({ rec: { name, ...(def as AnyRec) }, path: `${base}.${name}` })); + } + return []; +} + +/** + * The `schemaId` a form view resolves its row shape from, or `undefined` when + * the view is not schema-bound. Read off `ViewDataSourceSchema`'s `schema` + * member (`view.zod.ts:151-161`) — the shape `defineForm` writes. + */ +function schemaIdOf(view: AnyRec): string | undefined { + const data = view.data; + if (!isRec(data)) return undefined; + if (data.provider !== 'schema') return undefined; + return typeof data.schemaId === 'string' ? data.schemaId : undefined; +} + +function checkPredicate( + source: string, + scope: unknown, + where: string, + path: string, + findings: PredicatePathFinding[], +): void { + const ast = parseCelToAst(source); + // Not parseable through the canonical front end — `visibility-predicate-syntax` + // (#6253) owns that verdict; one broken predicate, one finding. + if (!ast) return; + + // ── `predicate-path-unresolved` ── + const paths: string[][] = []; + rootedPaths(ast, paths); + for (const segments of paths) { + let cursor: unknown = scope; + const walked: string[] = []; + for (const segment of segments) { + const step = stepInto(cursor, segment); + if (step.kind === 'opaque') break; + if (step.kind === 'undeclared') { + const full = [ROOT, ...walked, segment].join('.'); + const container = walked.length ? `${ROOT}.${walked.join('.')}` : ROOT; + findings.push({ + severity: 'error', + rule: PREDICATE_PATH_UNRESOLVED, + where, + path, + message: + `predicate references \`${full}\`, which the target schema does not declare — ` + + `\`${segment}\` is not a key of \`${container}\`. The reference resolves to nothing, ` + + `so the predicate can never evaluate and the console falls OPEN: the element renders ` + + `unconditionally and looks exactly like one carrying no predicate at all (#5149).`, + hint: + `${formatSuggestion(findClosestMatches(segment, step.declared)) + || `\`${container}\` declares: ${step.declared.slice(0, 12).sort().join(', ')}`}` + + ` Every reference must resolve against the schema the form edits.`, + }); + break; + } + walked.push(segment); + cursor = step.next; + } + } + + // ── `predicate-path-unrooted` ── + const declaredHere = keysOf(scope); + if (!declaredHere) return; + const values = new Set(); + const excluded = new Set(); + classifyIdentifiers(ast, values, excluded); + for (const id of values) { + if (excluded.has(id) || !declaredHere.includes(id)) continue; + findings.push({ + severity: 'error', + rule: PREDICATE_PATH_UNROOTED, + where, + path, + message: + `predicate references \`${id}\` as a bare identifier, but \`${id}\` is a key of the schema ` + + `this form edits — the binding root was dropped. Values are bound under \`${ROOT}\` and are ` + + `never flattened to top level, so \`${id}\` resolves to nothing, the predicate can never ` + + `evaluate and the console falls OPEN: the element renders unconditionally and looks exactly ` + + `like one carrying no predicate at all (#5149, #6254).`, + hint: + `Write \`${ROOT}.${id}\` instead of \`${id}\`. A metadata-editing form binds the row under ` + + `edit as \`${ROOT}\` at every depth — inside a repeater \`${ROOT}\` is the ROW, but it is ` + + `still spelled \`${ROOT}\` (there is no implicit row scope).`, + }); + } +} + +function walkFields( + entries: unknown, + scope: unknown, + where: string, + base: string, + findings: PredicatePathFinding[], + depth: number, +): void { + if (!Array.isArray(entries) || depth > 12) return; + for (let i = 0; i < entries.length; i++) { + const entry = entries[i]; + if (!isRec(entry)) continue; + const path = `${base}[${i}]`; + for (const key of PREDICATE_KEYS) { + const source = predicateSource(entry[key]); + if (source !== undefined && source.trim()) { + checkPredicate(source, scope, where, `${path}.${key}`, findings); + break; // canonical-first: `visibleWhen` wins when both are present + } + } + // A composite / repeater / record sub-field list REBINDS `data` to the row. + // When the row schema cannot be resolved the sub-tree is unjudged rather + // than judged against the parent scope, which would report every sub-field. + if (Array.isArray(entry.fields) && entry.fields.length > 0 && typeof entry.field === 'string') { + const row = scope === undefined ? undefined : rowScopeOf(scope, entry.field); + walkFields(entry.fields, row, where, `${path}.fields`, findings, depth + 1); + } + } +} + +/** + * Refuse a metadata-form predicate that names a path the target schema does not + * declare (#7010). + * + * Walks `views[]` through the shared {@link formViewSites} ladder — the entry + * itself (the bare `defineForm` shape every `*.form.ts` uses), the container's + * default `form`, and each `formViews.` — and judges only the sites whose + * data source is `{ provider: 'schema', schemaId }`, resolving `schemaId` + * through `opts.resolveSchema` (the canonical metadata-type registry by + * default). A site bound to an ObjectQL object, or naming a `schemaId` no schema + * resolves, is skipped: see the module note for why the `record.*` layer is out + * of scope rather than merely unimplemented. + * + * Both rules emit `error` and the caller is expected to fail the build on them. + * The corpus measurement behind that severity is on the PR for #7010: over the + * shipped `METADATA_FORM_REGISTRY` (17 forms, 46 predicates) the count is **0** + * for both, and 16 for `predicate-path-unrooted` once #6254's pre-fix + * `object.form.ts` is restored. + * + * Returns findings (empty = clean). + */ +export function validatePredicatePathRefs( + stack: AnyRec, + opts: PredicatePathOptions = {}, +): PredicatePathFinding[] { + const resolveSchema = opts.resolveSchema + ?? ((schemaId: string): unknown => getMetadataTypeSchema(schemaId)); + const findings: PredicatePathFinding[] = []; + + for (const { rec: view, path: viewPath } of collectionEntries(stack.views, 'views')) { + const viewName = typeof view.name === 'string' ? view.name + : typeof view.object === 'string' ? view.object + : viewPath; + + for (const site of formViewSites(view, viewPath)) { + const schemaId = schemaIdOf(site.view); + if (!schemaId) continue; + let root: unknown; + try { + root = resolveSchema(schemaId); + } catch { + // A resolver that throws on an unknown id must not take the build down + // with it — no oracle, no verdict, same as an id that resolves to + // `undefined`. + continue; + } + if (!root) continue; + + const where = site.surface + ? `view "${viewName}" · ${site.surface} (schema "${schemaId}")` + : `view "${viewName}" (schema "${schemaId}")`; + + for (const bucket of ['sections', 'groups'] as const) { + const sections = Array.isArray(site.view[bucket]) ? (site.view[bucket] as unknown[]) : []; + for (let s = 0; s < sections.length; s++) { + const section = sections[s]; + if (!isRec(section)) continue; + const sectionPath = `${site.path}.${bucket}[${s}]`; + for (const key of PREDICATE_KEYS) { + const source = predicateSource(section[key]); + if (source !== undefined && source.trim()) { + checkPredicate(source, root, where, `${sectionPath}.${key}`, findings); + break; + } + } + walkFields(section.fields, root, where, `${sectionPath}.fields`, findings, 0); + } + } + } + } + + return findings; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e712868b9c..a57d2b127f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1042,6 +1042,9 @@ importers: vitest: specifier: ^4.1.10 version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.2)(jsdom@30.0.1(@noble/hashes@2.2.0))(msw@2.14.6(@types/node@26.1.2)(typescript@6.0.3))(vite@8.0.16(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) + zod: + specifier: ^4.4.3 + version: 4.4.3 packages/mcp: dependencies: From b14ab11c09d487fefc12b9ddd8685ed50ec277b8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 03:09:37 +0000 Subject: [PATCH 2/2] refactor(lint): fold the local `collectionEntries` copy into the #7186 helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #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 Claude-Session: https://claude.ai/code/session_01F8q5J1MQyocgtNspb15fSn --- .../lint/src/validate-predicate-path-refs.ts | 24 +------------------ 1 file changed, 1 insertion(+), 23 deletions(-) diff --git a/packages/lint/src/validate-predicate-path-refs.ts b/packages/lint/src/validate-predicate-path-refs.ts index 3e807c9bda..21400b5335 100644 --- a/packages/lint/src/validate-predicate-path-refs.ts +++ b/packages/lint/src/validate-predicate-path-refs.ts @@ -121,6 +121,7 @@ import { parseCelToAst } from '@objectstack/formula'; import { getMetadataTypeSchema } from '@objectstack/spec/kernel'; import { findClosestMatches, formatSuggestion } from '@objectstack/spec'; +import { collectionEntries } from './collection-entries.js'; import { formViewSites } from './view-walk.js'; export const PREDICATE_PATH_UNRESOLVED = 'predicate-path-unresolved'; @@ -438,29 +439,6 @@ function isRec(v: unknown): v is AnyRec { return !!v && typeof v === 'object' && !Array.isArray(v); } -/** - * Records in a collection authored either as an array or as a name-keyed map, - * each with its config PATH. Local rather than shared because the two other - * copies in this package are being consolidated under #7186 — this one folds - * into that helper when it lands, and duplicating it now is cheaper than - * colliding with an in-flight refactor of `view-walk.ts`. - */ -function collectionEntries(v: unknown, base: string): Array<{ rec: AnyRec; path: string }> { - if (Array.isArray(v)) { - const out: Array<{ rec: AnyRec; path: string }> = []; - for (let i = 0; i < v.length; i++) { - if (isRec(v[i])) out.push({ rec: v[i] as AnyRec, path: `${base}[${i}]` }); - } - return out; - } - if (isRec(v)) { - return Object.entries(v) - .filter(([, def]) => isRec(def)) - .map(([name, def]) => ({ rec: { name, ...(def as AnyRec) }, path: `${base}.${name}` })); - } - return []; -} - /** * The `schemaId` a form view resolves its row shape from, or `undefined` when * the view is not schema-bound. Read off `ViewDataSourceSchema`'s `schema`