Skip to content

Commit ecff951

Browse files
hotlongclaude
andauthored
feat(lint): view/page 可见性谓词的 CEL 语法构建期闸门 (#6253) (#6472)
* feat(lint): view/page 可见性谓词的 CEL 语法构建期闸门(#6253) 新增 error 级规则 `visibility-predicate-syntax`:view/page 的可见性谓词 (`visibleWhen` 及两个已弃用别名 `visibleOn` / `visibility`)如果规范 CEL 前端解析不了,构建期直接拒收,不再零诊断放行到运行时 fail-open。 按维护者 2026-08-07 对 #6253 的裁定:判 blocking error,与 ADR-0032 下 其它谓词面同级,不设 warning 档、不写本面豁免。 判定仍取 `parseCelToAst`(规范前端),本规则不自建 Environment、不手写 tokenizer;新增的是对既有判定的上报与自纠措辞。明确不走 `compile()` / `validateExpression`——那是 parse + 类型检查,实测会拒掉 `type == 'grid'`, 从语法分支推翻本文件已钉测试的既有盲点,并把闸门从「解析不了」扩张成 「类型检查不过」。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BDmDsu2575gDxeMCxXhDE3 * refactor(lint): 用类型收窄替代非空断言 `quoteSource(source!)` 改为在 `if (source && syntaxFault)` 内由编译器收窄, 去掉本文件注释自己反对的 `!` 断言。行为不变。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BDmDsu2575gDxeMCxXhDE3 * fix(lint): 测试文件的相对导入补 `.js` 后缀,消除 TS2835 及其 TS7006 级联 `check:type-check-debt`(TypeScript Type Check job 的最后一步)红: @objectstack/lint 的 TEST_DEBT 实测 42 → 47(+5)。 根因是一处**既有**缺陷,被本单新增的测试放大:测试文件第 10 行 `from './validate-visibility-predicates'` 缺 `.js` 后缀。在 NodeNext 下这是 TS2835,且该模块因此解析为 `any`,于是文件里每一个 `.map((f) => …)` / `.filter((f) => …)` 回调都级联出 TS7006(`f` 隐式 any)。本单新增的断言带来 更多这类回调,把既有级联乘大了。 补上 `.js` 后(与同包所有测试文件、以及本文件自己的第二条 import `./authoring-rules.js` 一致),该文件的测试层错误 28 → **0**, 整包 47 → **19**,低于台账记录的 42。 **台账未抬**(棘轮只减不增,#5278):entry 仍是 42,门现在把它报成 改进(`ℹ TEST_DEBT records 42, tsc now reports 19 (-23)`)。 **测试未削弱**:本次改动是 1 增 1 删的单行 import,断言一条未动 —— 118 处 expect、86 条用例(其中本单新增 32 条)全绿。 本地未复现是因为检查面不同:@objectstack/lint 的 tsconfig 把 `*.test.ts` 排除在外,所以 `pnpm --filter @objectstack/lint typecheck` 根本不读测试文件, 而该门会解除排除后重测。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BDmDsu2575gDxeMCxXhDE3 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f8fe47e commit ecff951

4 files changed

Lines changed: 502 additions & 25 deletions

File tree

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
---
2+
"@objectstack/lint": minor
3+
---
4+
5+
feat(lint): view/page 可见性谓词的 CEL 语法构建期闸门 —— `country === "USA"` 不再零诊断(#6253)
6+
7+
新增 **error 级** 规则 `visibility-predicate-syntax`:view/page 的可见性谓词
8+
(`visibleWhen` 及其两个已弃用别名 `visibleOn` / `visibility`)如果规范 CEL 前端根本
9+
解析不了,`os validate` / `os build` / `os lint` 一律拒收。`===` 这类写法从此发不出去。
10+
11+
按维护者 2026-08-07 对 #6253 的裁定落地:**判 blocking error**,与其它谓词面
12+
(validation rule / flow / action,ADR-0032)同级;不设 warning 档,也不为本面写豁免——
13+
warning 在 CI 里通常不拦,那只是「多绕几步的静默」。
14+
15+
**为什么这一面此前无人判**:`validate-expressions.ts`(ADR-0032)对它遍历到的每条谓词都跑
16+
`validateExpression`,语法错报 blocking error——但它的遍历面是 objects / flows / actions /
17+
sharingRules / hooks,**从不走 `views``pages`**。走这一面的三条规则(ADR-0089 D3b 两条
18+
advisory,加 #6128 的裸标识符闸)都明确不判语法,理由是「不发明第二个语法判定」。那条政策
19+
在它自己的调用点上成立(`validateExpression` 就在同一批调用点上跑),**在 view/page 面上不成立:
20+
那里没有第二个判定,沉默就是没人报**。后果与 #5149 同型:谓词求值失败 → `evalFieldPredicate`
21+
返回 fallback → 可见性 fallback 是 `true` → 元素无条件渲染,与「没写谓词」在屏幕上一模一样。
22+
`packages/spec/src/ui/view.test.ts` 的 fixture 就写着 `'country === "USA"'`,正说明这是作者
23+
(尤其 AI)会写出来的形状。
24+
25+
**判定仍然不是本包给的**——旧政策要保护的正是这一点,它完整保留:判定取 `parseCelToAst`
26+
(规范前端,带 #3306 改写与 `DEFAULT_LIMITS`,#4812),本规则不自建 `Environment`、不手写
27+
tokenizer。#6253 加的是**对既有判定的上报**,外加原始报错缺的自纠措辞:cel-js 只说
28+
`Unexpected character: =` 并画一个 caret,既没点名作者写的运算符,也没给出 CEL 的写法。
29+
30+
**明确不走 `validateExpression` / `celEngine.compile`**,尽管那才是 ADR-0032 的入口:
31+
`compile()` 是 parse **+ 类型检查**,差别不是理论上的——实测它会以
32+
`no such overload: type == string` 拒掉 `type == 'grid'`,而那正是本文件**已钉测试的既有盲点**
33+
(字段名与 CEL 类型名相同时不判,因为改读 overload 消息会误杀合法的 `type(record.x) == string`)。
34+
从语法分支绕过去会把那条决定悄悄推翻,并把一条 error 级闸门从「解析不了」扩张成「类型检查不过」——
35+
而这一面的谓词绝大多数是 `dyn`。裁定说的是语法,parse 判定恰好就是语法。
36+
37+
**消息自纠**:实测过的非 CEL 拼法各自点名并给出 CEL 写法——`===``==``!==``!=`
38+
`<>``!=``and``&&``or``||``not``!`、单个 `=``==`。扫描前先把字符串字面量抹平,
39+
所以 `record.msg == 'a === b' and record.n > 1` 归咎于 `and` 而不是字面量里的 `===`;
40+
`record.msg == 'a === b'` 本身能解析,压根不报。`??` 与 SQL 的 `IN (…)` 故意不进表:两者
41+
都会解析失败、都照报(带前端原话),但都没有「换一个 token」就能修好的等价写法,给半个修法
42+
只会让作者多跑一趟。
43+
44+
**边界**(均已钉测试):空/纯空白谓词不是语法错(`parseCelToAst` 对空源也返回 `null`,
45+
没有这道 guard 会把「没写谓词」报成坏 CEL);`DEFAULT_LIMITS` 超限属于**边界**错而非语法错,
46+
照报但引用前端原话、不假装找到了 typo,与 ADR-0032 把两者一并归入「invalid CEL predicate」
47+
的既有做法一致,且超长谓词在消息里省略,单条 runaway 表达式刷不满控制台;一条坏谓词**只出一个
48+
finding**——源码解析不出 AST 就没有标识符可判,裸标识符闸自动让位,该互斥性由「断言整个上报集合」
49+
钉住而不是靠调用方内部实现。
50+
51+
注册表无需改动:`validateVisibilityPredicates` 的 tier 在 #6128 已是 `gating`、commands 已是
52+
build/lint/validate,本规则的 `error` 直接沿用(已加测试复核该前提仍然成立)。
53+
54+
仓内清扫:除 `packages/spec/src/ui/view.test.ts` 那几条**纯 schema 测试样本**(它们只跑
55+
`FormFieldSchema.parse`,不经 lint,属于本单援引的证据而非待修点)外,全仓 examples / apps /
56+
packages 的 view/page 可见性谓词均能通过规范前端解析,无需修改任何示例内容。

packages/lint/src/index.ts

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

packages/lint/src/validate-visibility-predicates.test.ts

Lines changed: 242 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ import {
66
VISIBILITY_ALIAS_DEPRECATED,
77
VISIBILITY_ROOT_MISLAYERED,
88
VISIBILITY_BARE_IDENTIFIER,
9-
} from './validate-visibility-predicates';
9+
VISIBILITY_PREDICATE_SYNTAX,
10+
} from './validate-visibility-predicates.js';
1011
import { AUTHORING_RULES } from './authoring-rules.js';
1112

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

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

471475
it('an absent / empty predicate is not a finding', () => {
@@ -502,3 +506,233 @@ describe('visibility-bare-identifier (#6128 / #5149 requirement 3)', () => {
502506
});
503507
});
504508
});
509+
510+
// ─────────────────────────────────────────────────────────────────────
511+
// `visibility-predicate-syntax` — #6253 (maintainer ruling 2026-08-07:
512+
// blocking error, same severity as every other predicate surface; no warning
513+
// tier and no exception for this surface).
514+
//
515+
// The surface this closes: `validate-expressions.ts` (ADR-0032) runs
516+
// `validateExpression` over every predicate it walks, but it walks objects /
517+
// flows / actions / sharingRules / hooks and never `views` / `pages`. The rules
518+
// that DO walk views/pages all declined the syntax verdict so as not to invent a
519+
// second one — correct wherever `validateExpression` runs alongside, and wrong
520+
// here, where nothing did. Net effect before this rule: `country === "USA"`
521+
// built clean and then failed OPEN at runtime (#5149).
522+
// ─────────────────────────────────────────────────────────────────────
523+
524+
/** Only the syntax findings, for assertions that ignore the other three rules. */
525+
function syntaxFindings(stack: Record<string, unknown>, opts?: { layer: 'runtime' | 'metadata' }) {
526+
return validateVisibilityPredicates(stack, opts).filter((f) => f.rule === VISIBILITY_PREDICATE_SYNTAX);
527+
}
528+
529+
describe('visibility-predicate-syntax (#6253)', () => {
530+
describe('the acceptance pair', () => {
531+
it('`===` is an ERROR whose message names the token and shows the CEL spelling', () => {
532+
// `country === "USA"` is the fixture-proven shape: `packages/spec/src/ui/
533+
// view.test.ts` writes it at :1126 / :1240 / :1245 / :1291 / :1373, which
534+
// is what the issue cites as evidence that authors reach for it.
535+
const findings = validateVisibilityPredicates(formStack('country === "USA"'));
536+
537+
// The WHOLE reported set, not just "a syntax finding is present" — so the
538+
// bare-identifier rule staying out of the way is pinned here too.
539+
expect(findings.map((f) => f.rule)).toEqual([VISIBILITY_PREDICATE_SYNTAX]);
540+
expect(findings[0].severity).toBe('error');
541+
expect(findings[0].path).toBe('views[0].sections[0].fields[0]');
542+
expect(findings[0].where).toBe('view "task_form"');
543+
544+
// Self-correcting, per the ruling: name the offending token, show the CEL
545+
// spelling. The raw parser message does neither — cel-js says
546+
// `Unexpected character: =` and points a caret, which tells an author
547+
// nothing about `==`.
548+
expect(findings[0].hint).toContain('`===`');
549+
expect(findings[0].hint).toContain('`==`');
550+
// The front end's own diagnostic is quoted rather than paraphrased, and
551+
// the predicate is echoed so the finding is self-contained.
552+
expect(findings[0].message).toContain('Unexpected character: =');
553+
expect(findings[0].message).toContain('country === "USA"');
554+
// The consequence is stated, because on screen it is invisible.
555+
expect(findings[0].message).toContain('#5149');
556+
});
557+
558+
it('the CEL spelling of the SAME predicate is clean — paired so it cannot pass vacuously', () => {
559+
// A lone "no syntax finding is reported" assertion is green whenever the
560+
// feature is absent, so it can never detect a regression. Pairing it with
561+
// the positive case in one test fixes that: delete the rule and the FIRST
562+
// expectation goes red.
563+
expect(syntaxFindings(formStack('country === "USA"'))).toHaveLength(1);
564+
expect(validateVisibilityPredicates(formStack("record.country == 'USA'"))).toEqual([]);
565+
});
566+
});
567+
568+
describe('every non-CEL spelling in the table names its own token', () => {
569+
// Each row is measured against the canonical front end — `parseCelToAst`
570+
// really does refuse all of these — so none of them is a guessed hint.
571+
it.each([
572+
['country === "USA"', '===', '=='],
573+
['country !== "USA"', '!==', '!='],
574+
["record.country <> 'USA'", '<>', '!='],
575+
["record.a == 1 and record.b == 2", 'and', '&&'],
576+
["record.a == 1 or record.b == 2", 'or', '||'],
577+
['not record.archived', 'not', '!'],
578+
["record.status = 'open'", '=', '=='],
579+
])('%s → names `%s`, prescribes `%s`', (predicate, wrote, cel) => {
580+
const findings = syntaxFindings(formStack(predicate));
581+
expect(findings).toHaveLength(1);
582+
expect(findings[0].severity).toBe('error');
583+
expect(findings[0].hint).toContain(`\`${wrote}\``);
584+
expect(findings[0].hint).toContain(`\`${cel}\``);
585+
});
586+
587+
it('a fault with no single-token equivalent still reports, with the parser\'s own words', () => {
588+
// `status ==` is a truncated expression: nothing to swap, so the hint
589+
// falls back to the general shape instead of inventing a token.
590+
const findings = syntaxFindings(formStack('status =='));
591+
expect(findings).toHaveLength(1);
592+
expect(findings[0].message).toContain('Unexpected token: EOF');
593+
expect(findings[0].hint).toContain("record.status == 'open'");
594+
});
595+
596+
it('blames the operator that actually broke it, not one quoted inside a string', () => {
597+
// The predicate fails on `and`; the `===` sits inside a string literal and
598+
// is none of the reason. Blaming it would send the author to edit a
599+
// perfectly good literal. (String literals are blanked before the scan.)
600+
const findings = syntaxFindings(formStack("record.msg == 'a === b' and record.n > 1"));
601+
expect(findings).toHaveLength(1);
602+
expect(findings[0].hint).toContain('`and`');
603+
expect(findings[0].hint).not.toContain('`===`');
604+
});
605+
606+
it('a non-CEL operator INSIDE a string literal is not a fault at all', () => {
607+
// Same string, no `and` — this parses, so there is no finding to word.
608+
expect(validateVisibilityPredicates(formStack("record.msg == 'a === b'"))).toEqual([]);
609+
});
610+
});
611+
612+
describe('the boundaries', () => {
613+
it('an absent / blank predicate is NOT a syntax fault', () => {
614+
// `parseCelToAst` returns null for an empty source too, so without an
615+
// explicit guard this rule would report "no predicate" as broken CEL.
616+
// Paired with a live case so the assertion can actually fail.
617+
expect(syntaxFindings(formStack('country === "USA"'))).toHaveLength(1);
618+
expect(validateVisibilityPredicates(formStack(undefined))).toEqual([]);
619+
expect(validateVisibilityPredicates(formStack(' '))).toEqual([]);
620+
expect(validateVisibilityPredicates(formStack(''))).toEqual([]);
621+
});
622+
623+
it('exactly ONE finding per broken predicate — the syntax rule, not also the bare-ref rule', () => {
624+
// `country` is rootless as well as mis-spelled, but a source with no AST
625+
// yields no identifiers to judge. Asserting the whole set (not just "a
626+
// syntax finding exists") is what pins the exclusivity.
627+
expect(validateVisibilityPredicates(formStack('country === "USA"')).map((f) => f.rule))
628+
.toEqual([VISIBILITY_PREDICATE_SYNTAX]);
629+
// …and the converse: a source that PARSES is judged by the bare-ref rule
630+
// and never by this one.
631+
expect(validateVisibilityPredicates(formStack("status == 'active'")).map((f) => f.rule))
632+
.toEqual([VISIBILITY_BARE_IDENTIFIER]);
633+
});
634+
635+
it('does NOT widen to type-checking — the CEL-type blind spot stays a blind spot', () => {
636+
// `type == 'grid'` PARSES; only `celEngine.compile`'s type checker rejects
637+
// it (`no such overload: type == string`). Routing this rule through
638+
// `compile` / `validateExpression` would silently overturn the deliberate,
639+
// separately-pinned decision to stay conservative there — and would widen
640+
// an error-level gate from "does not parse" to "does not type-check" on a
641+
// surface whose predicates are overwhelmingly `dyn`. The ruling said
642+
// syntax; the parse verdict is exactly syntax.
643+
expect(validateVisibilityPredicates(formStack("type == 'grid'"))).toEqual([]);
644+
// The legitimate CEL the overload message cannot be told apart from.
645+
expect(validateVisibilityPredicates(formStack('type(record.x) == string'))).toEqual([]);
646+
});
647+
648+
it('a `DEFAULT_LIMITS` overrun is reported too, in the front end\'s own words', () => {
649+
// `parseCelToAst` also returns null for a source over the platform bounds.
650+
// That is a bounds fault, not a syntax one, and the message says so rather
651+
// than pretending to have found a typo — the same way ADR-0032 already
652+
// reports it under the "invalid CEL predicate" heading.
653+
const overrun = `record.a${' + record.b'.repeat(400)}`;
654+
const findings = syntaxFindings(formStack(overrun));
655+
expect(findings).toHaveLength(1);
656+
expect(findings[0].message).toContain('Exceeded maxAstNodes');
657+
// The echoed predicate is elided, so one runaway expression cannot flood
658+
// the console with a 4KB finding.
659+
expect(findings[0].message).not.toContain(overrun);
660+
expect(findings[0].message).toContain('...');
661+
});
662+
663+
it.each([
664+
['record.a == 1 && record.b == 2', 'the `&&` CEL spells `and` as'],
665+
['record.a == 1 || record.b == 2', 'the `||` CEL spells `or` as'],
666+
['!record.archived', 'the `!` CEL spells `not` as'],
667+
["record.status != 'open'", 'the `!=` CEL spells `<>` as'],
668+
["record.tags.all(t, t != '')", 'a comprehension macro'],
669+
["record.type == 'a' ? record.x > 1 : record.y == 'b'", 'a ternary'],
670+
["record.type in ['lookup', 'master_detail']", 'lowercase `in` — a REAL CEL operator, unlike SQL `IN`'],
671+
["has(record.a) && record.b != null", 'both guard idioms at once'],
672+
])('%s parses and is not reported (%s)', (predicate) => {
673+
expect(syntaxFindings(formStack(predicate))).toEqual([]);
674+
});
675+
});
676+
677+
describe('every carrier the schema declares, and both layers', () => {
678+
it('a form SECTION predicate', () => {
679+
const stack = { views: [{ name: 'f', sections: [{ visibleWhen: 'country === "USA"', fields: [] }] }] };
680+
expect(syntaxFindings(stack).map((f) => f.path)).toEqual(['views[0].sections[0]']);
681+
});
682+
683+
it('a PAGE COMPONENT predicate', () => {
684+
const stack = {
685+
pages: [{ name: 'p', regions: [{ components: [{ type: 'element:text', visibleWhen: 'kind === "a"' }] }] }],
686+
};
687+
const findings = syntaxFindings(stack);
688+
expect(findings.map((f) => f.path)).toEqual(['pages[0].regions[0].components[0]']);
689+
expect(findings[0].where).toBe('page "p"');
690+
});
691+
692+
it('reads the value through the deprecated `visibleOn` alias (alias + syntax, both reported)', () => {
693+
// Two independent defects on one element, so unlike the syntax/bare-ref
694+
// pair these DO both report.
695+
const stack = { views: [{ name: 'f', sections: [{ visibleOn: 'status === "x"', fields: [] }] }] };
696+
expect(validateVisibilityPredicates(stack).map((f) => f.rule).sort())
697+
.toEqual([VISIBILITY_ALIAS_DEPRECATED, VISIBILITY_PREDICATE_SYNTAX].sort());
698+
});
699+
700+
it('reads the value through the deprecated page-side `visibility` alias', () => {
701+
const stack = {
702+
pages: [{ name: 'p', regions: [{ components: [{ type: 'element:text', visibility: 'shown === true' }] }] }],
703+
};
704+
expect(syntaxFindings(stack)).toHaveLength(1);
705+
});
706+
707+
it('resolves a `{ dialect, source }` envelope the same as a bare string', () => {
708+
expect(syntaxFindings(formStack({ dialect: 'cel', source: 'country === "USA"' }))).toHaveLength(1);
709+
});
710+
711+
it('reaches a container\'s `formViews.<key>` — the shape a real stack emits', () => {
712+
const stack = {
713+
views: [{ object: 'showcase_task', formViews: { edit: { sections: [{ fields: [{ field: 'n', visibleWhen: 'country === "USA"' }] }] } } }],
714+
};
715+
expect(syntaxFindings(stack).map((f) => f.path)).toEqual([
716+
'views[0].formViews.edit.sections[0].fields[0]',
717+
]);
718+
});
719+
720+
it('is layer-agnostic — a syntax fault is a syntax fault on a metadata form too', () => {
721+
// Unlike the root rules, nothing about "does it parse" depends on which
722+
// namespace the surface binds.
723+
expect(syntaxFindings(formStack('country === "USA"'), { layer: 'metadata' })).toHaveLength(1);
724+
expect(syntaxFindings(formStack('country === "USA"'))).toHaveLength(1);
725+
});
726+
});
727+
728+
it('the registry entry already gates, so this `error` reaches all three commands', () => {
729+
// `severity: 'error'` only fails a build because `authoring-rules.ts` marks
730+
// the family `gating` and runs it on validate/build/lint alike. That entry
731+
// was already `gating` (#6128 promoted it), so #6253 needed no registry
732+
// change — this pins that it is still true rather than assuming it.
733+
const entry = AUTHORING_RULES.find((r) => r.name === 'validateVisibilityPredicates');
734+
expect(entry, 'validateVisibilityPredicates must be registered').toBeDefined();
735+
expect(entry!.tier).toBe('gating');
736+
expect([...entry!.commands].sort()).toEqual(['build', 'lint', 'validate']);
737+
});
738+
});

0 commit comments

Comments
 (0)