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
21 changes: 21 additions & 0 deletions .changeset/25281-visibility-has-argument-refused.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
'@objectstack/lint': minor
'@objectstack/spec': minor
---

fix(lint)!: a page or view `visibleWhen` that hands `has()` an argument other than a field selection is refused at publish

Clause-②: no (narrowing)

Metadata-protocol: narrowed

<!-- adr-0087: registered visibility-predicate-has-argument-refused -->

**BREAKING** (what an author can declare narrows), shipped as `minor` under the launch-window convention for breaking changes.

CEL defines `has()` in one form, `has(e.f)`, with `f` a field name after a dot. The engine refuses any other argument as `has() invalid argument` on every evaluation, whatever the record holds. Every other predicate surface already refused it through `validateExpression`. The page and view `visibleWhen` door did not, so `has(status)` and `has(record.items[0])` published clean, and the console, which renders a faulting predicate as visible, drew the guarded element for every user.

- **FROM:** a `visibleWhen` on a page component, a form view section or a section field could hand `has()` a name alone (`has(status)`), an index as the last step (`has(record.items[0])`), or any other argument that is not a field selection (`has(record.?a)`, `has(upper(record.x))`, a literal, an operator), and `os validate`, `os build`, `os lint` and the page and view save doors accepted it.
- **TO:** each is refused as `visibility-predicate-has-argument`, an error, at those doors (the save doors answer `422 INVALID_METADATA`). The finding quotes the argument and the engine's wording, and its hint names the spelling to write. `os explain visibility-predicate-has-argument` says why.
- **Unchanged:** `has(e.f)` for any `e`, including a selection over an indexed or called receiver (`has(record.items[0].name)`); every other predicate, and nothing about the types that flow through one is judged. A `has(status) && status == 'x'` keeps its `visibility-bare-identifier` finding on the unguarded `status`, beside the new one. A stored page or view row in the refused shape still loads and is served; its next save is refused, and `os migrate meta --stored` lists each such predicate under `visibility-predicate-has-argument-refused`.
- **The fix for an author:** write `has(record.status)` or `'status' in record` for a field (`data` instead of `record` on a metadata-editing form), and `size(record.items) > 0` to ask whether a list has a first element (`'k' in e` for a bracketed string key).
10 changes: 7 additions & 3 deletions packages/formula/src/cel-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -404,9 +404,13 @@ let recordScopeEnv: Environment | undefined;
* faults `has() invalid argument`. `@objectstack/lint`'s
* `validate-visibility-predicates` masks `has(…)` spans at its own call site
* (#16118) and that mask stays load-bearing; no other consumer has one.
* A select whose receiver is an index or a call (`has(a[0].b)`) is NOT in
* this class: the spec admits it, and {@link rewriteIndexedHas} lowers it
* before this check runs (#25267).
* The page/view door now REPORTS the argument itself, under
* `visibility-predicate-has-argument` (#25281); on every other surface
* `validateExpression`'s compile pass refuses it. Neither changes what this
* helper answers: a `has()` first error still masks the references behind
* it here. A select whose receiver is an index or a call (`has(a[0].b)`) is
* NOT in this class: the spec admits it, and {@link rewriteIndexedHas}
* lowers it before this check runs (#25267).
*
* ⇒ A `null` from this helper is still "nothing was reported", never "the
* source is clean". ⛔ Do not close the remaining classes by widening the regex
Expand Down
1 change: 1 addition & 0 deletions packages/lint/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,7 @@ export {
VISIBILITY_PREDICATE_SYNTAX,
VISIBILITY_PREDICATE_OVER_BUDGET,
VISIBILITY_PREDICATE_UNKNOWN_FUNCTION,
VISIBILITY_PREDICATE_HAS_ARGUMENT,
} from './validate-visibility-predicates.js';
export type {
VisibilityFinding,
Expand Down
18 changes: 18 additions & 0 deletions packages/lint/src/rule-explanations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -853,6 +853,23 @@ const VISIBILITY_PREDICATE_UNKNOWN_FUNCTION_EXPLANATION: RuleExplanation = {
],
};

const VISIBILITY_PREDICATE_HAS_ARGUMENT_EXPLANATION: RuleExplanation = {
rule: 'visibility-predicate-has-argument',
covers: 'what `has()` accepts as its argument',
paragraphs: [
'CEL defines the macro in one form, `has(e.f)`: a presence test on field `f` of whatever `e` ' +
'evaluates to, where `f` is a plain field name written after a dot. Any other argument — a name ' +
'alone (`has(status)`), an index as the last step (`has(record.items[0])`), a call, a literal or ' +
'an operator — is refused by the engine as `has() invalid argument` on every evaluation, whatever ' +
'the record holds. On a view or page surface the console then falls OPEN and the element renders ' +
'unconditionally, exactly like one carrying no predicate at all.',
'Write the test the definition admits: `has(record.status)` or `\'status\' in record` for a field ' +
'(`data` instead of `record` on a metadata-editing form), and `size(record.items) > 0` to ask whether ' +
'a list has a first element. `has()` over a selection whose receiver is indexed or called, such as ' +
'`has(record.items[0].name)`, is accepted.',
],
};

const VISIBILITY_BARE_IDENTIFIER_EXPLANATION: RuleExplanation = {
rule: 'visibility-bare-identifier',
covers: 'why every value is read through a namespace',
Expand Down Expand Up @@ -4772,6 +4789,7 @@ export const RULE_EXPLANATIONS: Readonly<Record<string, RuleExplanation>> = Obje
[VISIBILITY_PREDICATE_OVER_BUDGET_EXPLANATION.rule]: VISIBILITY_PREDICATE_OVER_BUDGET_EXPLANATION,
[VISIBILITY_PREDICATE_SYNTAX_EXPLANATION.rule]: VISIBILITY_PREDICATE_SYNTAX_EXPLANATION,
[VISIBILITY_PREDICATE_UNKNOWN_FUNCTION_EXPLANATION.rule]: VISIBILITY_PREDICATE_UNKNOWN_FUNCTION_EXPLANATION,
[VISIBILITY_PREDICATE_HAS_ARGUMENT_EXPLANATION.rule]: VISIBILITY_PREDICATE_HAS_ARGUMENT_EXPLANATION,
[VISIBILITY_BARE_IDENTIFIER_EXPLANATION.rule]: VISIBILITY_BARE_IDENTIFIER_EXPLANATION,
[HOOK_BODY_WRITE_UNKNOWN_FIELD_EXPLANATION.rule]: HOOK_BODY_WRITE_UNKNOWN_FIELD_EXPLANATION,
[HOOK_BODY_WRITE_UNPROVISIONED_ANCHOR_EXPLANATION.rule]: HOOK_BODY_WRITE_UNPROVISIONED_ANCHOR_EXPLANATION,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import {
VISIBILITY_BARE_IDENTIFIER,
VISIBILITY_PREDICATE_OVER_BUDGET,
VISIBILITY_PREDICATE_SYNTAX,
VISIBILITY_PREDICATE_HAS_ARGUMENT,
VISIBILITY_PREDICATE_UNKNOWN_FUNCTION,
VISIBILITY_ROOT_MISLAYERED,
} from './validate-visibility-predicates.js';
Expand All @@ -59,6 +60,7 @@ const FAMILY_IDS = new Set<string>([
VISIBILITY_BARE_IDENTIFIER,
VISIBILITY_PREDICATE_OVER_BUDGET,
VISIBILITY_PREDICATE_SYNTAX,
VISIBILITY_PREDICATE_HAS_ARGUMENT,
VISIBILITY_PREDICATE_UNKNOWN_FUNCTION,
VISIBILITY_ROOT_MISLAYERED,
PREDICATE_PATH_UNRESOLVED,
Expand Down Expand Up @@ -90,6 +92,9 @@ const REFUSED = [
// A wrong reference: no binding root resolves `status`.
{ body: "status == 'open'", rule: VISIBILITY_BARE_IDENTIFIER, subject: '`status`' },
{ body: 'record.status ==', rule: VISIBILITY_PREDICATE_SYNTAX, subject: 'not valid CEL' },
// [#25281] A `has()` argument the engine refuses: a bare name, an index as the last step.
{ body: 'has(status)', rule: VISIBILITY_PREDICATE_HAS_ARGUMENT, subject: '`status`' },
{ body: 'has(record.items[0])', rule: VISIBILITY_PREDICATE_HAS_ARGUMENT, subject: '`record.items[0]`' },
] as const;

/**
Expand Down Expand Up @@ -198,6 +203,10 @@ describe('#24491 — the page door gives the build\'s visibility-predicate verdi
expect(fn.map((f) => [f.rule, f.path])).toEqual([
[VISIBILITY_PREDICATE_UNKNOWN_FUNCTION, 'views[0].form.sections[0].fields[0]'],
]);
// [#25281] The `has()` argument arm reaches the view door the same way.
expect(family(gateView(view('has(status)')).errors).map((f) => [f.rule, f.path])).toEqual([
[VISIBILITY_PREDICATE_HAS_ARGUMENT, 'views[0].form.sections[0].fields[0]'],
]);
// The path half is live on the view surface it was built for.
const path = family(gateView(view("data.tpye == 'text'", { provider: 'schema', schemaId: 'field' })).errors);
expect(path.map((f) => f.rule)).toContain(PREDICATE_PATH_UNRESOLVED);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#25281] The stored-shape listing and the refusing door name ONE set.
*
* `os migrate meta --stored` lists a stored page or view whose `visibleWhen`
* hands `has()` an argument that is not a field selection, under the D3 entry
* `visibility-predicate-has-argument-refused`. The listing lives in
* `@objectstack/spec`, which cannot parse CEL, so it reads the source on its
* own (`conversions/cel-has-argument.ts`). The refusal is this package's
* `visibility-predicate-has-argument`, whose verdict is the engine's AST. This
* file holds the two equal over every shape the engine was measured on: a
* predicate is listed exactly when the door refuses it under that id, and the
* door refuses exactly what `celEngine.compile` refuses as `has() invalid
* argument`.
*/
import { describe, expect, it } from 'vitest';
import { applyConversionsToStoredItem, type ConversionTodoNotice } from '@objectstack/spec';
import { celEngine } from '@objectstack/formula';

import { runRuntimeAuthoringRules } from './runtime-gate.js';
import { VISIBILITY_PREDICATE_HAS_ARGUMENT } from './validate-visibility-predicates.js';

const ENTRY_ID = 'visibility-predicate-has-argument-refused';

const page = (visibleWhen: string) => ({
name: 'pipeline_home',
label: 'Pipeline',
type: 'app',
regions: [{ name: 'main', components: [{ type: 'page:header', properties: { title: 'Pipeline' }, visibleWhen }] }],
});

/** Does the stored pass list this page under the entry? */
function listed(visibleWhen: string): boolean {
const todos: ConversionTodoNotice[] = [];
applyConversionsToStoredItem('page', page(visibleWhen), { onTodo: (t) => todos.push(t) });
return todos.some((t) => t.conversionId === ENTRY_ID);
}

/** Does the page save door refuse it under the rule? */
function refusedAtDoor(visibleWhen: string): boolean {
const result = runRuntimeAuthoringRules({ type: 'page', item: page(visibleWhen), context: { objects: [] } });
return result.errors.some((f) => f.rule === VISIBILITY_PREDICATE_HAS_ARGUMENT);
}

function engineRefuses(visibleWhen: string): boolean {
const compiled = celEngine.compile(visibleWhen);
return !compiled.ok && compiled.error.message.split('\n')[0].trim() === 'has() invalid argument';
}

const REFUSED = [
'has(status)',
'has(record)',
'has(record.items[0])',
"has(record['k'])",
'has(record.items[size(record.items) - 1])',
'has(record.?a)',
'has(record.a.?b)',
'has(1)',
"has('x')",
'has(true)',
'has(record.x.size())',
'has(upper(record.x))',
'has(-record.a)',
'has(record.a == 1)',
'has(record.a ? record.b : record.c)',
'has(record.x) || has(record.items[0])',
'record.items.exists(i, has(i))',
'has (record)',
];

const ACCEPTED = [
'has(record.a)',
'has(record.a.b.c.d)',
'has((record.a))',
'has(record.items[0].x)',
"has(record['a'].b)",
'has(dyn(record).a)',
'has(record.a.?b.c)',
'record.items.exists(i, has(i.x))',
"has(record.a) && record.a != ''",
"record.note == 'has(x)'",
'record.tags.has(record.a)',
];

describe('#25281 — the stored listing, the save door and the engine agree on `has()` arguments', () => {
it.each(REFUSED)('%s — the engine refuses it, the door refuses it, the stored pass lists it', (predicate) => {
expect(engineRefuses(predicate), 'engine').toBe(true);
expect(refusedAtDoor(predicate), 'door').toBe(true);
expect(listed(predicate), 'listing').toBe(true);
});

it.each(ACCEPTED)('%s — none of the three refuses or lists it', (predicate) => {
expect(engineRefuses(predicate), 'engine').toBe(false);
expect(refusedAtDoor(predicate), 'door').toBe(false);
expect(listed(predicate), 'listing').toBe(false);
});
});
Loading
Loading