Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions .changeset/lint-visibility-predicate-syntax-gate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
---
"@objectstack/lint": minor
---

feat(lint): view/page 可见性谓词的 CEL 语法构建期闸门 —— `country === "USA"` 不再零诊断(#6253)

新增 **error 级** 规则 `visibility-predicate-syntax`:view/page 的可见性谓词
(`visibleWhen` 及其两个已弃用别名 `visibleOn` / `visibility`)如果规范 CEL 前端根本
解析不了,`os validate` / `os build` / `os lint` 一律拒收。`===` 这类写法从此发不出去。

按维护者 2026-08-07 对 #6253 的裁定落地:**判 blocking error**,与其它谓词面
(validation rule / flow / action,ADR-0032)同级;不设 warning 档,也不为本面写豁免——
warning 在 CI 里通常不拦,那只是「多绕几步的静默」。

**为什么这一面此前无人判**:`validate-expressions.ts`(ADR-0032)对它遍历到的每条谓词都跑
`validateExpression`,语法错报 blocking error——但它的遍历面是 objects / flows / actions /
sharingRules / hooks,**从不走 `views` 与 `pages`**。走这一面的三条规则(ADR-0089 D3b 两条
advisory,加 #6128 的裸标识符闸)都明确不判语法,理由是「不发明第二个语法判定」。那条政策
在它自己的调用点上成立(`validateExpression` 就在同一批调用点上跑),**在 view/page 面上不成立:
那里没有第二个判定,沉默就是没人报**。后果与 #5149 同型:谓词求值失败 → `evalFieldPredicate`
返回 fallback → 可见性 fallback 是 `true` → 元素无条件渲染,与「没写谓词」在屏幕上一模一样。
`packages/spec/src/ui/view.test.ts` 的 fixture 就写着 `'country === "USA"'`,正说明这是作者
(尤其 AI)会写出来的形状。

**判定仍然不是本包给的**——旧政策要保护的正是这一点,它完整保留:判定取 `parseCelToAst`
(规范前端,带 #3306 改写与 `DEFAULT_LIMITS`,#4812),本规则不自建 `Environment`、不手写
tokenizer。#6253 加的是**对既有判定的上报**,外加原始报错缺的自纠措辞:cel-js 只说
`Unexpected character: =` 并画一个 caret,既没点名作者写的运算符,也没给出 CEL 的写法。

**明确不走 `validateExpression` / `celEngine.compile`**,尽管那才是 ADR-0032 的入口:
`compile()` 是 parse **+ 类型检查**,差别不是理论上的——实测它会以
`no such overload: type == string` 拒掉 `type == 'grid'`,而那正是本文件**已钉测试的既有盲点**
(字段名与 CEL 类型名相同时不判,因为改读 overload 消息会误杀合法的 `type(record.x) == string`)。
从语法分支绕过去会把那条决定悄悄推翻,并把一条 error 级闸门从「解析不了」扩张成「类型检查不过」——
而这一面的谓词绝大多数是 `dyn`。裁定说的是语法,parse 判定恰好就是语法。

**消息自纠**:实测过的非 CEL 拼法各自点名并给出 CEL 写法——`===`→`==`、`!==`→`!=`、
`<>`→`!=`、`and`→`&&`、`or`→`||`、`not`→`!`、单个 `=`→`==`。扫描前先把字符串字面量抹平,
所以 `record.msg == 'a === b' and record.n > 1` 归咎于 `and` 而不是字面量里的 `===`;
`record.msg == 'a === b'` 本身能解析,压根不报。`??` 与 SQL 的 `IN (…)` 故意不进表:两者
都会解析失败、都照报(带前端原话),但都没有「换一个 token」就能修好的等价写法,给半个修法
只会让作者多跑一趟。

**边界**(均已钉测试):空/纯空白谓词不是语法错(`parseCelToAst` 对空源也返回 `null`,
没有这道 guard 会把「没写谓词」报成坏 CEL);`DEFAULT_LIMITS` 超限属于**边界**错而非语法错,
照报但引用前端原话、不假装找到了 typo,与 ADR-0032 把两者一并归入「invalid CEL predicate」
的既有做法一致,且超长谓词在消息里省略,单条 runaway 表达式刷不满控制台;一条坏谓词**只出一个
finding**——源码解析不出 AST 就没有标识符可判,裸标识符闸自动让位,该互斥性由「断言整个上报集合」
钉住而不是靠调用方内部实现。

注册表无需改动:`validateVisibilityPredicates` 的 tier 在 #6128 已是 `gating`、commands 已是
build/lint/validate,本规则的 `error` 直接沿用(已加测试复核该前提仍然成立)。

仓内清扫:除 `packages/spec/src/ui/view.test.ts` 那几条**纯 schema 测试样本**(它们只跑
`FormFieldSchema.parse`,不经 lint,属于本单援引的证据而非待修点)外,全仓 examples / apps /
packages 的 view/page 可见性谓词均能通过规范前端解析,无需修改任何示例内容。
1 change: 1 addition & 0 deletions packages/lint/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ export {
VISIBILITY_ALIAS_DEPRECATED,
VISIBILITY_ROOT_MISLAYERED,
VISIBILITY_BARE_IDENTIFIER,
VISIBILITY_PREDICATE_SYNTAX,
} from './validate-visibility-predicates.js';
export type {
VisibilityFinding,
Expand Down
250 changes: 242 additions & 8 deletions packages/lint/src/validate-visibility-predicates.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import {
VISIBILITY_ALIAS_DEPRECATED,
VISIBILITY_ROOT_MISLAYERED,
VISIBILITY_BARE_IDENTIFIER,
} from './validate-visibility-predicates';
VISIBILITY_PREDICATE_SYNTAX,
} from './validate-visibility-predicates.js';
import { AUTHORING_RULES } from './authoring-rules.js';

describe('validateVisibilityPredicates (ADR-0089 D3b)', () => {
Expand Down Expand Up @@ -459,13 +460,16 @@ describe('visibility-bare-identifier (#6128 / #5149 requirement 3)', () => {
expect(bareFindings(formStack("record.data == 1"))).toEqual([]);
});

it('a predicate the canonical front end will not parse is left to the syntax verdict', () => {
// `===` is not CEL. `parseCelToAst` returns null and this rule stays
// silent rather than inventing a second syntax verdict — the same policy
// `validate-null-guards.ts` states. (Documented gap: nothing validates
// view/page predicate SYNTAX today, so this one is currently un-reported.)
expect(validateVisibilityPredicates(formStack('country === "USA"'))).toEqual([]);
expect(validateVisibilityPredicates(formStack('status =='))).toEqual([]);
it('a predicate that does not parse yields no BARE-IDENTIFIER verdict (the syntax rule owns it)', () => {
// This case used to assert whole-rule SILENCE on an unparseable source,
// on the policy that a second syntax verdict must not be invented. #6253
// ruled that policy wrong for this surface specifically — nothing else
// judges view/page syntax — so the source is now reported, by
// `visibility-predicate-syntax`. What survives from the old assertion is
// the division of labour: the declaredness check needs an AST and gets
// none, so `country` is NOT also reported as a bare identifier.
expect(bareFindings(formStack('country === "USA"'))).toEqual([]);
expect(bareFindings(formStack('status =='))).toEqual([]);
});

it('an absent / empty predicate is not a finding', () => {
Expand Down Expand Up @@ -502,3 +506,233 @@ describe('visibility-bare-identifier (#6128 / #5149 requirement 3)', () => {
});
});
});

// ─────────────────────────────────────────────────────────────────────
// `visibility-predicate-syntax` — #6253 (maintainer ruling 2026-08-07:
// blocking error, same severity as every other predicate surface; no warning
// tier and no exception for this surface).
//
// The surface this closes: `validate-expressions.ts` (ADR-0032) runs
// `validateExpression` over every predicate it walks, but it walks objects /
// flows / actions / sharingRules / hooks and never `views` / `pages`. The rules
// that DO walk views/pages all declined the syntax verdict so as not to invent a
// second one — correct wherever `validateExpression` runs alongside, and wrong
// here, where nothing did. Net effect before this rule: `country === "USA"`
// built clean and then failed OPEN at runtime (#5149).
// ─────────────────────────────────────────────────────────────────────

/** Only the syntax findings, for assertions that ignore the other three rules. */
function syntaxFindings(stack: Record<string, unknown>, opts?: { layer: 'runtime' | 'metadata' }) {
return validateVisibilityPredicates(stack, opts).filter((f) => f.rule === VISIBILITY_PREDICATE_SYNTAX);
}

describe('visibility-predicate-syntax (#6253)', () => {
describe('the acceptance pair', () => {
it('`===` is an ERROR whose message names the token and shows the CEL spelling', () => {
// `country === "USA"` is the fixture-proven shape: `packages/spec/src/ui/
// view.test.ts` writes it at :1126 / :1240 / :1245 / :1291 / :1373, which
// is what the issue cites as evidence that authors reach for it.
const findings = validateVisibilityPredicates(formStack('country === "USA"'));

// The WHOLE reported set, not just "a syntax finding is present" — so the
// bare-identifier rule staying out of the way is pinned here too.
expect(findings.map((f) => f.rule)).toEqual([VISIBILITY_PREDICATE_SYNTAX]);
expect(findings[0].severity).toBe('error');
expect(findings[0].path).toBe('views[0].sections[0].fields[0]');
expect(findings[0].where).toBe('view "task_form"');

// Self-correcting, per the ruling: name the offending token, show the CEL
// spelling. The raw parser message does neither — cel-js says
// `Unexpected character: =` and points a caret, which tells an author
// nothing about `==`.
expect(findings[0].hint).toContain('`===`');
expect(findings[0].hint).toContain('`==`');
// The front end's own diagnostic is quoted rather than paraphrased, and
// the predicate is echoed so the finding is self-contained.
expect(findings[0].message).toContain('Unexpected character: =');
expect(findings[0].message).toContain('country === "USA"');
// The consequence is stated, because on screen it is invisible.
expect(findings[0].message).toContain('#5149');
});

it('the CEL spelling of the SAME predicate is clean — paired so it cannot pass vacuously', () => {
// A lone "no syntax finding is reported" assertion is green whenever the
// feature is absent, so it can never detect a regression. Pairing it with
// the positive case in one test fixes that: delete the rule and the FIRST
// expectation goes red.
expect(syntaxFindings(formStack('country === "USA"'))).toHaveLength(1);
expect(validateVisibilityPredicates(formStack("record.country == 'USA'"))).toEqual([]);
});
});

describe('every non-CEL spelling in the table names its own token', () => {
// Each row is measured against the canonical front end — `parseCelToAst`
// really does refuse all of these — so none of them is a guessed hint.
it.each([
['country === "USA"', '===', '=='],
['country !== "USA"', '!==', '!='],
["record.country <> 'USA'", '<>', '!='],
["record.a == 1 and record.b == 2", 'and', '&&'],
["record.a == 1 or record.b == 2", 'or', '||'],
['not record.archived', 'not', '!'],
["record.status = 'open'", '=', '=='],
])('%s → names `%s`, prescribes `%s`', (predicate, wrote, cel) => {
const findings = syntaxFindings(formStack(predicate));
expect(findings).toHaveLength(1);
expect(findings[0].severity).toBe('error');
expect(findings[0].hint).toContain(`\`${wrote}\``);
expect(findings[0].hint).toContain(`\`${cel}\``);
});

it('a fault with no single-token equivalent still reports, with the parser\'s own words', () => {
// `status ==` is a truncated expression: nothing to swap, so the hint
// falls back to the general shape instead of inventing a token.
const findings = syntaxFindings(formStack('status =='));
expect(findings).toHaveLength(1);
expect(findings[0].message).toContain('Unexpected token: EOF');
expect(findings[0].hint).toContain("record.status == 'open'");
});

it('blames the operator that actually broke it, not one quoted inside a string', () => {
// The predicate fails on `and`; the `===` sits inside a string literal and
// is none of the reason. Blaming it would send the author to edit a
// perfectly good literal. (String literals are blanked before the scan.)
const findings = syntaxFindings(formStack("record.msg == 'a === b' and record.n > 1"));
expect(findings).toHaveLength(1);
expect(findings[0].hint).toContain('`and`');
expect(findings[0].hint).not.toContain('`===`');
});

it('a non-CEL operator INSIDE a string literal is not a fault at all', () => {
// Same string, no `and` — this parses, so there is no finding to word.
expect(validateVisibilityPredicates(formStack("record.msg == 'a === b'"))).toEqual([]);
});
});

describe('the boundaries', () => {
it('an absent / blank predicate is NOT a syntax fault', () => {
// `parseCelToAst` returns null for an empty source too, so without an
// explicit guard this rule would report "no predicate" as broken CEL.
// Paired with a live case so the assertion can actually fail.
expect(syntaxFindings(formStack('country === "USA"'))).toHaveLength(1);
expect(validateVisibilityPredicates(formStack(undefined))).toEqual([]);
expect(validateVisibilityPredicates(formStack(' '))).toEqual([]);
expect(validateVisibilityPredicates(formStack(''))).toEqual([]);
});

it('exactly ONE finding per broken predicate — the syntax rule, not also the bare-ref rule', () => {
// `country` is rootless as well as mis-spelled, but a source with no AST
// yields no identifiers to judge. Asserting the whole set (not just "a
// syntax finding exists") is what pins the exclusivity.
expect(validateVisibilityPredicates(formStack('country === "USA"')).map((f) => f.rule))
.toEqual([VISIBILITY_PREDICATE_SYNTAX]);
// …and the converse: a source that PARSES is judged by the bare-ref rule
// and never by this one.
expect(validateVisibilityPredicates(formStack("status == 'active'")).map((f) => f.rule))
.toEqual([VISIBILITY_BARE_IDENTIFIER]);
});

it('does NOT widen to type-checking — the CEL-type blind spot stays a blind spot', () => {
// `type == 'grid'` PARSES; only `celEngine.compile`'s type checker rejects
// it (`no such overload: type == string`). Routing this rule through
// `compile` / `validateExpression` would silently overturn the deliberate,
// separately-pinned decision to stay conservative there — and would widen
// an error-level gate from "does not parse" to "does not type-check" on a
// surface whose predicates are overwhelmingly `dyn`. The ruling said
// syntax; the parse verdict is exactly syntax.
expect(validateVisibilityPredicates(formStack("type == 'grid'"))).toEqual([]);
// The legitimate CEL the overload message cannot be told apart from.
expect(validateVisibilityPredicates(formStack('type(record.x) == string'))).toEqual([]);
});

it('a `DEFAULT_LIMITS` overrun is reported too, in the front end\'s own words', () => {
// `parseCelToAst` also returns null for a source over the platform bounds.
// That is a bounds fault, not a syntax one, and the message says so rather
// than pretending to have found a typo — the same way ADR-0032 already
// reports it under the "invalid CEL predicate" heading.
const overrun = `record.a${' + record.b'.repeat(400)}`;
const findings = syntaxFindings(formStack(overrun));
expect(findings).toHaveLength(1);
expect(findings[0].message).toContain('Exceeded maxAstNodes');
// The echoed predicate is elided, so one runaway expression cannot flood
// the console with a 4KB finding.
expect(findings[0].message).not.toContain(overrun);
expect(findings[0].message).toContain('...');
});

it.each([
['record.a == 1 && record.b == 2', 'the `&&` CEL spells `and` as'],
['record.a == 1 || record.b == 2', 'the `||` CEL spells `or` as'],
['!record.archived', 'the `!` CEL spells `not` as'],
["record.status != 'open'", 'the `!=` CEL spells `<>` as'],
["record.tags.all(t, t != '')", 'a comprehension macro'],
["record.type == 'a' ? record.x > 1 : record.y == 'b'", 'a ternary'],
["record.type in ['lookup', 'master_detail']", 'lowercase `in` — a REAL CEL operator, unlike SQL `IN`'],
["has(record.a) && record.b != null", 'both guard idioms at once'],
])('%s parses and is not reported (%s)', (predicate) => {
expect(syntaxFindings(formStack(predicate))).toEqual([]);
});
});

describe('every carrier the schema declares, and both layers', () => {
it('a form SECTION predicate', () => {
const stack = { views: [{ name: 'f', sections: [{ visibleWhen: 'country === "USA"', fields: [] }] }] };
expect(syntaxFindings(stack).map((f) => f.path)).toEqual(['views[0].sections[0]']);
});

it('a PAGE COMPONENT predicate', () => {
const stack = {
pages: [{ name: 'p', regions: [{ components: [{ type: 'element:text', visibleWhen: 'kind === "a"' }] }] }],
};
const findings = syntaxFindings(stack);
expect(findings.map((f) => f.path)).toEqual(['pages[0].regions[0].components[0]']);
expect(findings[0].where).toBe('page "p"');
});

it('reads the value through the deprecated `visibleOn` alias (alias + syntax, both reported)', () => {
// Two independent defects on one element, so unlike the syntax/bare-ref
// pair these DO both report.
const stack = { views: [{ name: 'f', sections: [{ visibleOn: 'status === "x"', fields: [] }] }] };
expect(validateVisibilityPredicates(stack).map((f) => f.rule).sort())
.toEqual([VISIBILITY_ALIAS_DEPRECATED, VISIBILITY_PREDICATE_SYNTAX].sort());
});

it('reads the value through the deprecated page-side `visibility` alias', () => {
const stack = {
pages: [{ name: 'p', regions: [{ components: [{ type: 'element:text', visibility: 'shown === true' }] }] }],
};
expect(syntaxFindings(stack)).toHaveLength(1);
});

it('resolves a `{ dialect, source }` envelope the same as a bare string', () => {
expect(syntaxFindings(formStack({ dialect: 'cel', source: 'country === "USA"' }))).toHaveLength(1);
});

it('reaches a container\'s `formViews.<key>` — the shape a real stack emits', () => {
const stack = {
views: [{ object: 'showcase_task', formViews: { edit: { sections: [{ fields: [{ field: 'n', visibleWhen: 'country === "USA"' }] }] } } }],
};
expect(syntaxFindings(stack).map((f) => f.path)).toEqual([
'views[0].formViews.edit.sections[0].fields[0]',
]);
});

it('is layer-agnostic — a syntax fault is a syntax fault on a metadata form too', () => {
// Unlike the root rules, nothing about "does it parse" depends on which
// namespace the surface binds.
expect(syntaxFindings(formStack('country === "USA"'), { layer: 'metadata' })).toHaveLength(1);
expect(syntaxFindings(formStack('country === "USA"'))).toHaveLength(1);
});
});

it('the registry entry already gates, so this `error` reaches all three commands', () => {
// `severity: 'error'` only fails a build because `authoring-rules.ts` marks
// the family `gating` and runs it on validate/build/lint alike. That entry
// was already `gating` (#6128 promoted it), so #6253 needed no registry
// change — this pins that it is still true rather than assuming it.
const entry = AUTHORING_RULES.find((r) => r.name === 'validateVisibilityPredicates');
expect(entry, 'validateVisibilityPredicates must be registered').toBeDefined();
expect(entry!.tier).toBe('gating');
expect([...entry!.commands].sort()).toEqual(['build', 'lint', 'validate']);
});
});
Loading
Loading