Skip to content

Commit cf417ef

Browse files
committed
fix(lint)!: page/view visibleWhen refuses a has() argument that is not a field selection
Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jc59n5NWaMp1khDwL7Fici
1 parent 5801a4f commit cf417ef

6 files changed

Lines changed: 520 additions & 28 deletions

‎packages/formula/src/cel-engine.ts‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -404,9 +404,13 @@ let recordScopeEnv: Environment | undefined;
404404
* faults `has() invalid argument`. `@objectstack/lint`'s
405405
* `validate-visibility-predicates` masks `has(…)` spans at its own call site
406406
* (#16118) and that mask stays load-bearing; no other consumer has one.
407-
* A select whose receiver is an index or a call (`has(a[0].b)`) is NOT in
408-
* this class: the spec admits it, and {@link rewriteIndexedHas} lowers it
409-
* before this check runs (#25267).
407+
* The page/view door now REPORTS the argument itself, under
408+
* `visibility-predicate-has-argument` (#25281); on every other surface
409+
* `validateExpression`'s compile pass refuses it. Neither changes what this
410+
* helper answers: a `has()` first error still masks the references behind
411+
* it here. A select whose receiver is an index or a call (`has(a[0].b)`) is
412+
* NOT in this class: the spec admits it, and {@link rewriteIndexedHas}
413+
* lowers it before this check runs (#25267).
410414
*
411415
* ⇒ A `null` from this helper is still "nothing was reported", never "the
412416
* source is clean". ⛔ Do not close the remaining classes by widening the regex

‎packages/lint/src/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,7 @@ export {
267267
VISIBILITY_PREDICATE_SYNTAX,
268268
VISIBILITY_PREDICATE_OVER_BUDGET,
269269
VISIBILITY_PREDICATE_UNKNOWN_FUNCTION,
270+
VISIBILITY_PREDICATE_HAS_ARGUMENT,
270271
} from './validate-visibility-predicates.js';
271272
export type {
272273
VisibilityFinding,

‎packages/lint/src/rule-explanations.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -853,6 +853,23 @@ const VISIBILITY_PREDICATE_UNKNOWN_FUNCTION_EXPLANATION: RuleExplanation = {
853853
],
854854
};
855855

856+
const VISIBILITY_PREDICATE_HAS_ARGUMENT_EXPLANATION: RuleExplanation = {
857+
rule: 'visibility-predicate-has-argument',
858+
covers: 'what `has()` accepts as its argument',
859+
paragraphs: [
860+
'CEL defines the macro in one form, `has(e.f)`: a presence test on field `f` of whatever `e` ' +
861+
'evaluates to, where `f` is a plain field name written after a dot. Any other argument — a name ' +
862+
'alone (`has(status)`), an index as the last step (`has(record.items[0])`), a call, a literal or ' +
863+
'an operator — is refused by the engine as `has() invalid argument` on every evaluation, whatever ' +
864+
'the record holds. On a view or page surface the console then falls OPEN and the element renders ' +
865+
'unconditionally, exactly like one carrying no predicate at all.',
866+
'Write the test the definition admits: `has(record.status)` or `\'status\' in record` for a field ' +
867+
'(`data` instead of `record` on a metadata-editing form), and `size(record.items) > 0` to ask whether ' +
868+
'a list has a first element. `has()` over a selection whose receiver is indexed or called, such as ' +
869+
'`has(record.items[0].name)`, is accepted.',
870+
],
871+
};
872+
856873
const VISIBILITY_BARE_IDENTIFIER_EXPLANATION: RuleExplanation = {
857874
rule: 'visibility-bare-identifier',
858875
covers: 'why every value is read through a namespace',
@@ -4772,6 +4789,7 @@ export const RULE_EXPLANATIONS: Readonly<Record<string, RuleExplanation>> = Obje
47724789
[VISIBILITY_PREDICATE_OVER_BUDGET_EXPLANATION.rule]: VISIBILITY_PREDICATE_OVER_BUDGET_EXPLANATION,
47734790
[VISIBILITY_PREDICATE_SYNTAX_EXPLANATION.rule]: VISIBILITY_PREDICATE_SYNTAX_EXPLANATION,
47744791
[VISIBILITY_PREDICATE_UNKNOWN_FUNCTION_EXPLANATION.rule]: VISIBILITY_PREDICATE_UNKNOWN_FUNCTION_EXPLANATION,
4792+
[VISIBILITY_PREDICATE_HAS_ARGUMENT_EXPLANATION.rule]: VISIBILITY_PREDICATE_HAS_ARGUMENT_EXPLANATION,
47754793
[VISIBILITY_BARE_IDENTIFIER_EXPLANATION.rule]: VISIBILITY_BARE_IDENTIFIER_EXPLANATION,
47764794
[HOOK_BODY_WRITE_UNKNOWN_FIELD_EXPLANATION.rule]: HOOK_BODY_WRITE_UNKNOWN_FIELD_EXPLANATION,
47774795
[HOOK_BODY_WRITE_UNPROVISIONED_ANCHOR_EXPLANATION.rule]: HOOK_BODY_WRITE_UNPROVISIONED_ANCHOR_EXPLANATION,

‎packages/lint/src/runtime-gate.page-visibility-predicate-writes.test.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import {
4343
VISIBILITY_BARE_IDENTIFIER,
4444
VISIBILITY_PREDICATE_OVER_BUDGET,
4545
VISIBILITY_PREDICATE_SYNTAX,
46+
VISIBILITY_PREDICATE_HAS_ARGUMENT,
4647
VISIBILITY_PREDICATE_UNKNOWN_FUNCTION,
4748
VISIBILITY_ROOT_MISLAYERED,
4849
} from './validate-visibility-predicates.js';
@@ -59,6 +60,7 @@ const FAMILY_IDS = new Set<string>([
5960
VISIBILITY_BARE_IDENTIFIER,
6061
VISIBILITY_PREDICATE_OVER_BUDGET,
6162
VISIBILITY_PREDICATE_SYNTAX,
63+
VISIBILITY_PREDICATE_HAS_ARGUMENT,
6264
VISIBILITY_PREDICATE_UNKNOWN_FUNCTION,
6365
VISIBILITY_ROOT_MISLAYERED,
6466
PREDICATE_PATH_UNRESOLVED,
@@ -90,6 +92,9 @@ const REFUSED = [
9092
// A wrong reference: no binding root resolves `status`.
9193
{ body: "status == 'open'", rule: VISIBILITY_BARE_IDENTIFIER, subject: '`status`' },
9294
{ body: 'record.status ==', rule: VISIBILITY_PREDICATE_SYNTAX, subject: 'not valid CEL' },
95+
// [#25281] A `has()` argument the engine refuses: a bare name, an index as the last step.
96+
{ body: 'has(status)', rule: VISIBILITY_PREDICATE_HAS_ARGUMENT, subject: '`status`' },
97+
{ body: 'has(record.items[0])', rule: VISIBILITY_PREDICATE_HAS_ARGUMENT, subject: '`record.items[0]`' },
9398
] as const;
9499

95100
/**
@@ -198,6 +203,10 @@ describe('#24491 — the page door gives the build\'s visibility-predicate verdi
198203
expect(fn.map((f) => [f.rule, f.path])).toEqual([
199204
[VISIBILITY_PREDICATE_UNKNOWN_FUNCTION, 'views[0].form.sections[0].fields[0]'],
200205
]);
206+
// [#25281] The `has()` argument arm reaches the view door the same way.
207+
expect(family(gateView(view('has(status)')).errors).map((f) => [f.rule, f.path])).toEqual([
208+
[VISIBILITY_PREDICATE_HAS_ARGUMENT, 'views[0].form.sections[0].fields[0]'],
209+
]);
201210
// The path half is live on the view surface it was built for.
202211
const path = family(gateView(view("data.tpye == 'text'", { provider: 'schema', schemaId: 'field' })).errors);
203212
expect(path.map((f) => f.rule)).toContain(PREDICATE_PATH_UNRESOLVED);

0 commit comments

Comments
 (0)