From cf417efe08a88d301a20fd87358eb7c91b82fcef Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:15:12 +0000 Subject: [PATCH 1/3] fix(lint)!: page/view visibleWhen refuses a has() argument that is not a field selection Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01Jc59n5NWaMp1khDwL7Fici --- packages/formula/src/cel-engine.ts | 10 +- packages/lint/src/index.ts | 1 + packages/lint/src/rule-explanations.ts | 18 ++ ...e.page-visibility-predicate-writes.test.ts | 9 + .../validate-visibility-predicates.test.ts | 235 ++++++++++++++- .../src/validate-visibility-predicates.ts | 275 ++++++++++++++++-- 6 files changed, 520 insertions(+), 28 deletions(-) diff --git a/packages/formula/src/cel-engine.ts b/packages/formula/src/cel-engine.ts index f4f10ad31d5..85314d3ac22 100644 --- a/packages/formula/src/cel-engine.ts +++ b/packages/formula/src/cel-engine.ts @@ -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 diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index 248fbb33f64..bb0411682d6 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -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, diff --git a/packages/lint/src/rule-explanations.ts b/packages/lint/src/rule-explanations.ts index cba057f56bf..c4ab22eb5fe 100644 --- a/packages/lint/src/rule-explanations.ts +++ b/packages/lint/src/rule-explanations.ts @@ -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', @@ -4772,6 +4789,7 @@ export const RULE_EXPLANATIONS: Readonly> = 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, diff --git a/packages/lint/src/runtime-gate.page-visibility-predicate-writes.test.ts b/packages/lint/src/runtime-gate.page-visibility-predicate-writes.test.ts index 155ae28fa2a..01d9443f9bc 100644 --- a/packages/lint/src/runtime-gate.page-visibility-predicate-writes.test.ts +++ b/packages/lint/src/runtime-gate.page-visibility-predicate-writes.test.ts @@ -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'; @@ -59,6 +60,7 @@ const FAMILY_IDS = new Set([ VISIBILITY_BARE_IDENTIFIER, VISIBILITY_PREDICATE_OVER_BUDGET, VISIBILITY_PREDICATE_SYNTAX, + VISIBILITY_PREDICATE_HAS_ARGUMENT, VISIBILITY_PREDICATE_UNKNOWN_FUNCTION, VISIBILITY_ROOT_MISLAYERED, PREDICATE_PATH_UNRESOLVED, @@ -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; /** @@ -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); diff --git a/packages/lint/src/validate-visibility-predicates.test.ts b/packages/lint/src/validate-visibility-predicates.test.ts index 71c111c3df3..971d7457e53 100644 --- a/packages/lint/src/validate-visibility-predicates.test.ts +++ b/packages/lint/src/validate-visibility-predicates.test.ts @@ -12,10 +12,11 @@ import { VISIBILITY_PREDICATE_SYNTAX, VISIBILITY_PREDICATE_OVER_BUDGET, VISIBILITY_PREDICATE_UNKNOWN_FUNCTION, + VISIBILITY_PREDICATE_HAS_ARGUMENT, } from './validate-visibility-predicates.js'; import { explainRule } from './rule-explanations.js'; import { AUTHORING_RULES } from './authoring-rules.js'; -import { CEL_STDLIB_FUNCTIONS } from '@objectstack/formula'; +import { CEL_STDLIB_FUNCTIONS, celEngine } from '@objectstack/formula'; // [#22161] Each finding of the rule ids this file's rule shortened is one // verdict sentence; the reasoning it used to carry is the id's `os explain` @@ -29,6 +30,7 @@ const SHORTENED_RULE_IDS: readonly string[] = [ VISIBILITY_PREDICATE_OVER_BUDGET, VISIBILITY_PREDICATE_SYNTAX, VISIBILITY_PREDICATE_UNKNOWN_FUNCTION, + VISIBILITY_PREDICATE_HAS_ARGUMENT, VISIBILITY_BARE_IDENTIFIER, ]; const firedShortened: Array<{ rule: string; message: string; hint: string }> = []; @@ -895,12 +897,19 @@ describe('visibility-bare-identifier (#6128 / #5149 requirement 3)', () => { expect(findings[0].hint).toContain('`record.status`'); }); - it('row 3 — `has(status)` ALONE stays silent (the negative control)', () => { + it('row 3 — `has(status)` ALONE draws no BARE-IDENTIFIER finding (the negative control)', () => { // The exclusion stays: a `has()` argument is a select target, so the one // occurrence that IS the argument earns no bare-identifier verdict. Pinned // so a later refactor cannot delete the exclusion wholesale and still read // green off row 2. - expect(validateVisibilityPredicates(formStack('has(status)'))).toEqual([]); + // + // Flipped by ruling 6351260886 on #25281, not silently: this row used to + // pin the WHOLE set as `[]`. The argument shape itself is now refused, by + // `visibility-predicate-has-argument` and only by it, so the set is that + // ONE finding — the mask is what keeps it from being two. + expect(bareFindings(formStack('has(status)'))).toEqual([]); + expect(validateVisibilityPredicates(formStack('has(status)')).map((f) => f.rule)) + .toEqual([VISIBILITY_PREDICATE_HAS_ARGUMENT]); }); it('a prefixed guard beside a bare use still reports the bare use', () => { @@ -922,15 +931,20 @@ describe('visibility-bare-identifier (#6128 / #5149 requirement 3)', () => { expect(findings[0].message).toContain('`overdue`'); }); - it('several bare `has()` arguments and nothing else stay silent', () => { - expect(validateVisibilityPredicates(formStack('has(status) && has(owner)'))).toEqual([]); + it('several bare `has()` arguments draw no bare-identifier finding — one `has()` finding instead', () => { + // Flipped with row 3 (ruling 6351260886): was `[]` for the whole set. + expect(validateVisibilityPredicates(formStack('has(status) && has(owner)')).map((f) => f.rule)) + .toEqual([VISIBILITY_PREDICATE_HAS_ARGUMENT]); }); it('a comprehension variable behind a `has()` guard is still not a bare identifier', () => { // The checker remains the oracle for macro-bound names: the body is handed // to it unchanged, so `t` keeps resolving the way it does without a guard. expect(validateVisibilityPredicates(formStack("has(record.tags) && record.tags.all(t, t != '')"))).toEqual([]); - expect(validateVisibilityPredicates(formStack("has(tags) && record.tags.all(t, t != '')"))).toEqual([]); + // `has(tags)` is itself refused since ruling 6351260886 — by the `has()` + // rule, never as a bare identifier, and `t` stays unreported. + expect(validateVisibilityPredicates(formStack("has(tags) && record.tags.all(t, t != '')")).map((f) => f.rule)) + .toEqual([VISIBILITY_PREDICATE_HAS_ARGUMENT]); }); }); }); @@ -1078,7 +1092,7 @@ describe('visibility-predicate-syntax (#6253)', () => { // would be a fresh false statement. expect(validateVisibilityPredicates(formStack('upper(1, 2)'))).toEqual([]); - // ── #13594 narrows this pin by exactly one axis ────────────────── + // ── #13594 and #25281 narrow this pin by exactly two axes ──────── // // The maintainer's 2026-08-31 ruling is a SCOPED supersession of the // parse-only decision quoted above: a call to a name the environment does @@ -1087,6 +1101,12 @@ describe('visibility-predicate-syntax (#6253)', () => { // the narrowing is visible where the old absolute claim used to be. expect(validateVisibilityPredicates(formStack('totallyBogusFn(1,2)')).map((f) => f.rule)) .toEqual([VISIBILITY_PREDICATE_UNKNOWN_FUNCTION]); + // The second axis, ruling 6351260886 on #25281, updated the same way: a + // `has()` whose argument is not a field selection. Data-independent like + // the first — the CEL definition refuses the shape before any type is + // known — and every line above it stays exactly as blind as it was. + expect(validateVisibilityPredicates(formStack('has(status)')).map((f) => f.rule)) + .toEqual([VISIBILITY_PREDICATE_HAS_ARGUMENT]); }); it('a `DEFAULT_LIMITS` overrun is NOT this rule any more — it is `over-budget` (#7217)', () => { @@ -1666,6 +1686,206 @@ describe('emitted prose names the surface, not a source file (#8042)', () => { }); }); +// ───────────────────────────────────────────────────────────────────── +// `visibility-predicate-has-argument` — #25281 (ruling 6351260886, letter A). +// +// `has(status)` and `has(record.items[0])` published clean through this gate +// and faulted `has() invalid argument` on every evaluation, so the console drew +// the guarded element for every user. The CEL definition admits one form, +// `has(e.f)`; the refusal is a fact about the source, decided before any type +// is known — the property #13594's arm was admitted on — and the arm reads +// nothing else about types. +// ───────────────────────────────────────────────────────────────────── + +/** Only the `has()` argument findings (#25281). */ +function hasArgFindings(stack: Record, opts?: { layer: 'runtime' | 'metadata' }) { + return validateVisibilityPredicates(stack, opts).filter((f) => f.rule === VISIBILITY_PREDICATE_HAS_ARGUMENT); +} + +/** The engine's own verdict: does `celEngine.compile` refuse `source` as `has() invalid argument`? */ +function engineRefusesHasArgument(source: string): boolean { + const compiled = celEngine.compile(source); + return !compiled.ok && compiled.error.message.split('\n')[0].trim() === 'has() invalid argument'; +} + +describe('visibility-predicate-has-argument (#25281)', () => { + describe('the acceptance pair', () => { + it.each([ + ['has(status)', 'a bare name', '`status`'], + ['has(record.items[0])', 'an index as the last step', '`record.items[0]`'], + ])('%s (%s) is an ERROR — the one finding, quoting the engine', (predicate, _shape, subject) => { + const findings = validateVisibilityPredicates(formStack(predicate)); + // The WHOLE set: the #16118 mask keeps the bare-identifier rule off the + // argument occurrence, so this rule's finding is the only one. + expect(findings.map((f) => f.rule)).toEqual([VISIBILITY_PREDICATE_HAS_ARGUMENT]); + expect(findings[0].severity).toBe('error'); + expect(findings[0].path).toBe('views[0].sections[0].fields[0]'); + expect(findings[0].message).toContain(subject); + expect(findings[0].message).toContain('has() invalid argument'); + // Non-vacuous: the engine really refuses what the finding reports. + expect(engineRefusesHasArgument(predicate)).toBe(true); + }); + + it('the control: `has(record.status)` is clean, and the engine compiles it', () => { + expect(validateVisibilityPredicates(formStack('has(record.status)'))).toEqual([]); + expect(celEngine.compile('has(record.status)').ok).toBe(true); + }); + }); + + // The arm walks the canonical AST (mechanism (b) of the ruling); these pins + // are what make that the ENGINE's verdict and not a second one. Every shape + // in the first table is refused by `compile`, and by the arm; every one in + // the second compiles, and the arm accepts it. + describe('the engine is the oracle — pinned in both directions', () => { + const REFUSED = [ + 'has(status)', + 'has(record)', + 'has(record.items[0])', + "has(record['k'])", + 'has(record.items[0][1])', + 'has(record.items[size(record.items) - 1])', + 'has(record.?a)', + 'has(record.a.?b)', + 'has(1)', + "has('x')", + 'has(true)', + 'has(null)', + 'has(record.x.size())', + 'has(upper(record.x))', + 'has(-record.a)', + 'has(!record.a)', + 'has(record.a == 1)', + 'has(record.a + 1)', + 'has(record.a ? record.b : record.c)', + 'has(record.x) || has(y)', + 'record.items.exists(i, has(i))', + 'has (status)', + ]; + const ACCEPTED = [ + 'has(record.a)', + 'has(record.a.b.c.d)', + 'has((record.a))', + 'has(record.items[0].x)', + "has(record['a'].b)", + 'has(record.a.b[0].c)', + 'has(dyn(record).a)', + 'has(record.a.?b.c)', + 'record.items.exists(i, has(i.x))', + "has(record.a) && record.a != ''", + ]; + + it.each(REFUSED)('%s — refused by `compile`, and by this arm', (predicate) => { + expect(engineRefusesHasArgument(predicate), 'the engine refuses it').toBe(true); + expect(hasArgFindings(formStack(predicate))).toHaveLength(1); + }); + + it.each(ACCEPTED)('%s — compiles, and this arm accepts it', (predicate) => { + expect(celEngine.compile(predicate).ok, 'the engine compiles it').toBe(true); + expect(hasArgFindings(formStack(predicate))).toEqual([]); + }); + }); + + describe('the prescription names the spelling the definition admits', () => { + it('a bare name: the field on the root this layer binds', () => { + expect(hasArgFindings(formStack('has(status)'))[0].hint) + .toBe("`has()` tests a field on a namespace: write `has(record.status)` or `'status' in record`"); + expect(hasArgFindings(formStack('has(status)'), { layer: 'metadata' })[0].hint) + .toContain("`has(data.status)` or `'status' in data`"); + }); + + it('a bare ROOT: a field of it, never `record.record`', () => { + const hint = hasArgFindings(formStack('has(record)'))[0].hint; + expect(hint).toContain("`has(record.)` or `'' in record`"); + expect(hint).not.toContain('record.record'); + expect(hasArgFindings(formStack('has(current_user)'))[0].hint).toContain('`has(current_user.)`'); + }); + + it('a CEL type name is not a root: `has(type)` is told `has(record.type)`', () => { + expect(hasArgFindings(formStack('has(type)'))[0].hint).toContain('`has(record.type)`'); + }); + + it('an index as the last step: `size(e) > i`, or `in` for a string key', () => { + expect(hasArgFindings(formStack('has(record.items[0])'))[0].hint).toContain('`size(record.items) > 0`'); + expect(hasArgFindings(formStack("has(record['k'])"))[0].hint).toContain("`'k' in record`"); + }); + + it('an optional selection: the plain selection', () => { + expect(hasArgFindings(formStack('has(record.?a)'))[0].hint).toContain('`has(record.a)`'); + }); + + it('anything else: the one form, with the layer root', () => { + expect(hasArgFindings(formStack('has(upper(record.x))'))[0].hint).toContain('`has(record.)`'); + expect(hasArgFindings(formStack('has(upper(data.x))'), { layer: 'metadata' })[0].hint) + .toContain('`has(data.)`'); + }); + + it('repeats no null-guard advice (`validate-null-guards` owns it), and every line is one line of ≤ 200', () => { + for (const predicate of ['has(status)', 'has(record)', 'has(record.items[0])', "has(record['k'])", 'has(record.?a)', 'has(1)']) { + const [finding] = hasArgFindings(formStack(predicate)); + expect(finding.hint, predicate).not.toContain('!= null'); + expect(finding.hint, predicate).not.toContain('\n'); + expect(finding.hint.length, finding.hint).toBeLessThanOrEqual(200); + } + }); + }); + + describe('the #16118 mask stays load-bearing', () => { + it('`has(status) && status == ...` keeps its bare-identifier finding on the unwrapped occurrence, beside this one', () => { + const findings = validateVisibilityPredicates(formStack("has(status) && status == 'qualified'")); + expect(findings.map((f) => f.rule).sort()).toEqual([VISIBILITY_BARE_IDENTIFIER, VISIBILITY_PREDICATE_HAS_ARGUMENT].sort()); + expect(findings.find((f) => f.rule === VISIBILITY_BARE_IDENTIFIER)!.message).toContain('`status`'); + }); + + it('one finding per predicate, for the first refused call', () => { + const findings = hasArgFindings(formStack('has(owner) && has(record.items[0])')); + expect(findings).toHaveLength(1); + expect(findings[0].message).toContain('`owner`'); + }); + }); + + describe('the boundaries — #13594\'s scope discipline stands verbatim', () => { + it.each([ + ["type == 'grid'", 'a CEL type name compared — refused by `compile`, not by this'], + ['type(record.x) == string', 'legitimate CEL over the same names'], + ['upper(1, 2)', 'a registered function called wrongly'], + ["1 + 'a'", 'an operator overload nobody judges'], + ["record.note == 'has(x)'", 'a `has(` inside a string literal'], + ['record.tags.has(x)', 'a member call named `has`, not the macro'], + ['has()', 'no argument — the engine refuses it under another message'], + ['has(record.a, record.b)', 'two arguments — likewise'], + ])('%s draws no `has()` finding (%s)', (predicate) => { + expect(hasArgFindings(formStack(predicate))).toEqual([]); + }); + }); + + describe('every carrier the schema declares', () => { + it('a form SECTION predicate', () => { + const stack = { views: [{ name: 'f', sections: [{ visibleWhen: 'has(status)', fields: [] }] }] }; + expect(hasArgFindings(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: 'has(record.items[0])' }] }] }], + }; + const findings = hasArgFindings(stack); + expect(findings.map((f) => f.path)).toEqual(['pages[0].regions[0].components[0]']); + expect(findings[0].where).toBe('page "p"'); + }); + + it('resolves a `{ dialect, source }` envelope the same as a bare string', () => { + expect(hasArgFindings(formStack({ dialect: 'cel', source: 'has(status)' }))).toHaveLength(1); + }); + + it('reaches a container\'s `formViews.`', () => { + const stack = { + views: [{ object: 'showcase_task', formViews: { edit: { sections: [{ fields: [{ field: 'n', visibleWhen: 'has(status)' }] }] } } }], + }; + expect(hasArgFindings(stack).map((f) => f.path)).toEqual(['views[0].formViews.edit.sections[0].fields[0]']); + }); + }); +}); + describe('[#22161] one-line verdicts — the rule ids this file shortened', () => { it('every verdict the cases above fired for those ids is one line of at most 200 characters', () => { // The coverage control first: each shortened id fired at least once, so @@ -1683,6 +1903,7 @@ describe('[#22161] one-line verdicts — the rule ids this file shortened', () = [VISIBILITY_PREDICATE_OVER_BUDGET]: ['falls OPEN', 'settled behaviour', 'SIZE fault', '`maxListElements` is 64'], [VISIBILITY_PREDICATE_SYNTAX]: ['falls OPEN', 'settled behaviour', 'ADR-0032', '`===`'], [VISIBILITY_PREDICATE_UNKNOWN_FUNCTION]: ['`throwOnError: true`', 'falls CLOSED', '`console.warn`', 'CEL_STDLIB_FUNCTIONS'], + [VISIBILITY_PREDICATE_HAS_ARGUMENT]: ['`has(e.f)`', 'falls OPEN', '`size(record.items) > 0`', '`has(record.items[0].name)`'], [VISIBILITY_BARE_IDENTIFIER]: ['never flattened', 'falls OPEN', 'predicate-rhs-path-shaped'], }; diff --git a/packages/lint/src/validate-visibility-predicates.ts b/packages/lint/src/validate-visibility-predicates.ts index 6e44aeaad59..ae118991896 100644 --- a/packages/lint/src/validate-visibility-predicates.ts +++ b/packages/lint/src/validate-visibility-predicates.ts @@ -50,7 +50,7 @@ * normalized tier, and none was affected by the retirement. * * One advisory rule (`warning` — nothing is broken, a mis-rooted predicate just - * never matches) plus FOUR **gating** rules (`error` — the predicate can never + * never matches) plus FIVE **gating** rules (`error` — the predicate can never * evaluate at all): * * - `visibility-predicate-syntax` (**error**, #6253) — a predicate the canonical @@ -66,8 +66,14 @@ * that PARSES perfectly and calls a function the CEL environment does not * register (`current_user.canApprove(object, verb)`, `totallyBogusFn(1,2)`). * See the §Function existence block below for the ruling that put it here, for - * why it is the one `check()` verdict this file adopts, and for the four + * why it was the first `check()` verdict this file adopted, and for the four * things it deliberately still does not report. + * - `visibility-predicate-has-argument` (**error**, #25281) — a predicate that + * PARSES and hands `has()` an argument that is not a `.` field selection + * (`has(status)`, `has(record.items[0])`), which the engine refuses as + * `has() invalid argument` on every evaluation. See §`has()` argument shape + * below for the ruling and for why it is the second data-independent verdict + * this file adopts, and the last one §Function existence leaves room for. * - `visibility-bare-identifier` (**error**, #6128 / #5149 requirement 3) — a * predicate referencing a top-level identifier that no binding root can * resolve (`status == 'active'` instead of `record.status == 'active'`). See @@ -218,7 +224,10 @@ * refused only when it NAMES something that does not exist. `type(record.x) * == string` still passes, `type == 'grid'` is still a blind spot, `upper(1, * 2)` is still not our business, and `1 + 'a'` is still nobody's. The blind - * spot narrowed by exactly one axis and is pinned that way. + * spot narrows by exactly TWO axes and is pinned that way: function + * existence (this ruling) and the `has()` argument shape (ruling 6351260886 + * on #25281, §`has()` argument shape below). Both are data-independent; + * neither reads a type that flows through the predicate. * 3. **It cannot be a false positive.** The validation environment and the * runtime environment are the same builder (53 probes, 0 divergence), so a * call this refuses is a call that WILL fault when evaluated. The old ruling's @@ -275,6 +284,66 @@ * ADR-0032's shared producer, which this gate deliberately does not go through * (that is the paragraph above) — hence the same defect had to be fixed here too. * + * ## `has()` argument shape — the second data-independent verdict (#25281) + * + * ### The gap + * + * `has(status)` and `has(record.items[0])` published clean through this gate + * and then faulted on every evaluation, so the console drew the guarded element + * for every user. Nothing here judged them: the #16118 mask below removes every + * `has(…)` call before the bare-identifier check sees the source, and the one + * `check()` verdict §Function existence adopted reads only the call template. + * The engine refuses both as `has() invalid argument` at `compile` and at + * `evaluate`, whatever the record holds. + * + * ### The ruling + * + * Ruling 6351260886 on #25281, letter A (a ruling under the maintainer's + * delegation, reversible on one word): this gate refuses, as an `error`, a + * `has()` call whose single argument is not a `.` field selection. It is a + * scoped extension BESIDE + * #13594, not a revision of it: that ruling's approved scope — function + * existence, no general type checking, `dyn` untouched — stands in full. + * + * ### Why it is the same kind of verdict as function existence + * + * The CEL definition gives the macro one form, `has(e.f)`, and says `f` must + * syntactically be an identifier; only the select notation is supported. A + * bare name and an index are not field selections, and no binding, scope or + * value makes them evaluate. So the refusal is a fact about the SOURCE, decided + * before any type is known — the property that admitted function existence + * ("It cannot be a false positive", point 3 above) — and it reads nothing about + * the types flowing through the predicate. + * + * ### How the verdict is decided + * + * On the canonical AST this file already walks for the #16118 mask: a `has` + * call with exactly one argument whose node is not `.`. That is the engine's + * verdict by construction — `rewriteIndexedHas` (#25267) lowers every `.` + * selection over an indexed or call receiver, so after it the engine refuses + * exactly the arguments that are not `.` — and the equivalence is pinned + * against `celEngine.compile` in both directions in this file's suite. A call + * with no argument or with several is not this arm's; the engine refuses it + * under another message. + * + * ### What it reports + * + * One finding per predicate, for the first such call, quoting the engine's + * wording and the argument as written. The hint names the spelling the + * definition admits and nothing else — `has(.x)` or `'x' in ` for a + * bare name, with the root this layer binds; `size(e) > i` for an index as the + * last step (`'k' in e` when the index is a string key) — and repeats no + * null-guard advice, which is `validate-null-guards`' to give. + * + * ### What it does NOT change + * + * `has(e.f)` for any `e`, including the indexed and call receivers #25267 + * lowers; every `dyn` predicate; `type == 'grid'`, `type(record.x) == string`, + * `upper(1, 2)`, `1 + 'a'`; the #16118 mask, which stays load-bearing so a + * refused `has(status)` yields ONE finding (this one) and `has(status) && + * status == 'x'` keeps its bare-identifier finding on the unwrapped occurrence; + * the render-side fail-open (ADR-0137 D3). + * * ## Bare identifiers — the gap between two gates that both wave it through * * `visibility-bare-identifier` exists because #5149 Repro 1 measured a predicate @@ -359,7 +428,8 @@ * way #4953 is eventually settled. The exclusion a `has()` argument earns is * keyed to that ARGUMENT's occurrence, never to its name: the same identifier * written bare elsewhere in the predicate is reported exactly as it would be - * with no guard beside it (#16118). + * with no guard beside it (#16118). Whether the argument is one `has()` + * accepts at all is §`has()` argument shape's verdict, not this rule's. * - **A predicate the canonical front end will not parse.** `parseCelToAst` * returns `null` there, so the declaredness check has no AST to reason about * and this rule gives no BARE-IDENTIFIER verdict on it. That is a division of @@ -385,14 +455,15 @@ * measured blind spot in the safe direction — a missed catch, never a false * build error — pinned by a test so it reads as a decision. * - * ⚠️ **Function existence excepted (#13594).** The pin used to read "the - * CEL-type blind spot stays a blind spot" without qualification; the ruling in - * §Function existence narrows it by exactly one axis. `type == 'grid'` and - * `type(record.x) == string` are unchanged — the first is refused as `no such - * overload: type == string`, which is not a call verdict, and the second - * type-checks clean. What is no longer blind is a call to a name the - * environment does not register at all. Everything the checker says about the - * TYPES flowing through a `dyn` predicate stays unread. + * ⚠️ **Function existence and the `has()` argument shape excepted (#13594, + * #25281).** The pin used to read "the CEL-type blind spot stays a blind + * spot" without qualification; the two rulings narrow it by exactly two axes. + * `type == 'grid'` and `type(record.x) == string` are unchanged — the first + * is refused as `no such overload: type == string`, which is neither verdict, + * and the second type-checks clean. What is no longer blind is a call to a + * name the environment does not register at all, and a `has()` whose argument + * is not a field selection. Everything the checker says about the TYPES + * flowing through a `dyn` predicate stays unread. * * ### The one position this rule does not judge (#7696) * @@ -463,6 +534,7 @@ import { firstUnknownFunctionCall, parseCelToAst, parseCelToAstWithReason, + SCOPE_ROOTS, } from '@objectstack/formula'; import type { CelAstNode, CelBoundsOverrun, UnknownFunctionCall } from '@objectstack/formula'; @@ -491,6 +563,14 @@ export const VISIBILITY_PREDICATE_OVER_BUDGET = 'visibility-predicate-over-budge * and `--json` consumers key on the id. */ export const VISIBILITY_PREDICATE_UNKNOWN_FUNCTION = 'visibility-predicate-unknown-function'; +/** + * A predicate handing `has()` an argument that is not a `.` field selection — + * `has(status)`, `has(record.items[0])` — which the engine refuses as + * `has() invalid argument` (#25281, ruling 6351260886; see the module note's + * §`has()` argument shape). A separate id for the reason `unknown-function` is + * one: the source parses, and the fix is a different edit. + */ +export const VISIBILITY_PREDICATE_HAS_ARGUMENT = 'visibility-predicate-has-argument'; export type VisibilitySeverity = 'error' | 'warning'; @@ -522,9 +602,10 @@ export interface VisibilityOptions { export interface VisibilityFinding { /** * `warning` for the ADR-0089 D3b advisory (`visibility-root-mislayered`); - * `error` for the four rules that gate — `visibility-predicate-syntax`, - * `visibility-predicate-over-budget`, `visibility-predicate-unknown-function` - * and `visibility-bare-identifier` (see module note). + * `error` for the five rules that gate — `visibility-predicate-syntax`, + * `visibility-predicate-over-budget`, `visibility-predicate-unknown-function`, + * `visibility-predicate-has-argument` and `visibility-bare-identifier` (see + * module note). */ severity: VisibilitySeverity; /** Diagnostic rule id, e.g. `visibility-root-mislayered`. */ @@ -892,6 +973,131 @@ function maskHasCalls(source: string, ast: unknown): string { return masked; } +// ── `visibility-predicate-has-argument` (#25281) ──────────────────── + +/** + * A `has()` call whose one argument is not a `.` field selection — what the + * engine refuses as `has() invalid argument` — and how the author wrote it: + * + * - `bare` — a name alone (`has(status)`); + * - `index` — an index as the LAST step (`has(record.items[0])`); + * - `other` — anything else that is not a selection (`has(record.?a)`, + * `has(f(x))`, `has(record.a == 1)`, a literal). + */ +interface RefusedHasArgument { + kind: 'bare' | 'index' | 'other'; + /** The argument as written. */ + argument: string; + /** `bare`: the name. */ + name?: string; + /** `index`: the indexed receiver and the index, as written. */ + receiver?: string; + index?: string; + /** `index`: the index is a string literal (`e['k']`), so it names a map key. */ + stringKey?: string; + /** `other`, written `e.?f`: the receiver and the field. */ + optional?: { receiver: string; field: string }; +} + +/** The source text `node` spans, or `undefined` when the AST carries no usable span. */ +function spanText(node: unknown, source: string): string | undefined { + if (!isNode(node)) return undefined; + const { start, end } = node as { start?: unknown; end?: unknown }; + if (!Number.isInteger(start) || !Number.isInteger(end)) return undefined; + const from = start as number; + const to = end as number; + if (from < 0 || to > source.length || to <= from) return undefined; + return source.slice(from, to); +} + +/** + * The first `has()` call in `ast`, in source order, whose single argument is + * not a `.` field selection — or `null` when every `has()` takes one. + * + * The test is the one the module note's §`has()` argument shape states: the + * canonical AST (`parseCelToAst`, the tree {@link hasCallSpans} already walks) + * keeps the authored `has(…)` call, and the engine refuses exactly the one- + * argument calls whose argument node is not `.` — after `rewriteIndexedHas` + * lowers every `.` selection over an indexed or call receiver, no `.` argument + * is refused and every other one is. `validate-visibility-predicates.test.ts` + * pins that equivalence against `celEngine.compile`. A call with no argument or + * with several is not this arm's: the engine refuses it under another message, + * and nothing here names a shape for it. + */ +function firstRefusedHasArgument(node: unknown, source: string): RefusedHasArgument | null { + if (Array.isArray(node)) { + for (const child of node) { + const found = firstRefusedHasArgument(child, source); + if (found) return found; + } + return null; + } + if (!isNode(node)) return null; + const args = node.args; + if (node.op === 'call' && Array.isArray(args) && args[0] === 'has' + && Array.isArray(args[1]) && args[1].length === 1) { + const arg: unknown = args[1][0]; + if (isNode(arg) && arg.op !== '.') { + const argument = spanText(arg, source) ?? 'its argument'; + const parts: unknown[] = Array.isArray(arg.args) ? arg.args : []; + if (arg.op === 'id' && typeof arg.args === 'string') { + return { kind: 'bare', argument, name: arg.args }; + } + if (arg.op === '[]') { + const index = parts[1]; + const stringKey = isNode(index) && index.op === 'value' && typeof index.args === 'string' + ? index.args + : undefined; + return { + kind: 'index', + argument, + receiver: spanText(parts[0], source), + index: spanText(index, source), + stringKey, + }; + } + if (arg.op === '.?' && typeof parts[1] === 'string') { + const receiver = spanText(parts[0], source); + if (receiver) return { kind: 'other', argument, optional: { receiver, field: parts[1] } }; + } + return { kind: 'other', argument }; + } + } + return firstRefusedHasArgument(args, source); +} + +/** + * The corrective line for a refused `has()` argument: the spelling the CEL + * definition admits (`has(e.f)`, `f` an identifier), or the test the author + * was reaching for. Null-guard advice is `validate-null-guards`' to give and is + * not repeated here. + */ +function hasArgumentHint(refused: RefusedHasArgument, root: string): string { + if (refused.kind === 'bare' && refused.name) { + const name = refused.name; + // A name the surface binds as a namespace (`record`, `current_user`, …) + // is the root itself: the fix names a field OF it, never `record.record`. + // Read off the root LIST, not the declaredness oracle, which also declares + // CEL's own type names (`type`, `string`, …) — see `SCOPE_ROOTS`. + const roots: readonly string[] = [...SCOPE_ROOTS, ...VIEW_PAGE_EXTRA_ROOTS]; + if (roots.includes(name)) { + return `\`has()\` tests a field of \`${name}\`, never \`${name}\` itself: write ` + + `\`has(${name}.)\` or \`'' in ${name}\``; + } + return `\`has()\` tests a field on a namespace: write \`has(${root}.${name})\` or \`'${name}' in ${root}\``; + } + if (refused.kind === 'index' && refused.receiver && refused.index) { + return refused.stringKey !== undefined + ? `\`has()\` cannot test a bracketed key: write \`'${refused.stringKey}' in ${refused.receiver}\`` + : `\`has()\` cannot test an element: write \`size(${refused.receiver}) > ${refused.index}\``; + } + if (refused.optional) { + const { receiver, field } = refused.optional; + return `\`has()\` takes a plain selection: write \`has(${receiver}.${field})\``; + } + return `\`has()\` takes one field selection, e.g. \`has(${root}.)\`, never a call, literal or operator`; +} + /** * The first identifier in `source` that no binding root can resolve — a bare * value or, since #22212, the root of a namespace nobody binds — or `null` when @@ -1011,8 +1217,8 @@ const MISLAYER_BY_LAYER: Record< /** * Inspect one element carrying a visibility predicate. Emits the mis-layered-root * finding (when the effective predicate's binding root does not match `layer`), - * the syntax / over-budget finding, the unknown-function finding (#13594), and - * the bare-identifier finding. + * the syntax / over-budget finding, the unknown-function finding (#13594), the + * `has()` argument finding (#25281), and the bare-identifier finding. * * There is no alias-KEY step here: `visibility-alias-deprecated` was retired * under #6318 (see the module note for the per-site measurement and for the D2 @@ -1152,6 +1358,38 @@ function checkElement( }); } + // (3b) #25281 — a `has()` whose argument is not a `.` field selection. GATES, + // by ruling 6351260886, quoted in the module note's §`has()` argument shape: + // the engine refuses the call (`has() invalid argument`) whatever the data, + // so the predicate never evaluates and the console falls open. Reached only + // when the front end ACCEPTED the source, like (3). + // + // Independent of (3) and (4), for the reason (3) gives: a different token, + // a different fix. Two facts keep it from doubling up with (4). The #16118 + // mask still removes the `has()` argument occurrence before the bare- + // identifier check, so `has(status)` alone is ONE finding — this one, which + // carries the prescription — and `has(status) && status == 'x'` keeps its + // bare-identifier finding on the unwrapped `status` beside it. And (3) + // cannot fire on the same call: the engine phrases this refusal outside the + // call template `firstUnknownFunctionCall` reads. + const ast = source && !refusal ? parseCelToAst(source) : null; + const refusedHas = ast && source ? firstRefusedHasArgument(ast, source) : null; + if (source && refusedHas) { + findings.push({ + severity: 'error', + rule: VISIBILITY_PREDICATE_HAS_ARGUMENT, + where, + path, + // The engine's own wording, quoted; no tracker id (`check:doc-authoring`). + message: + `visibility predicate passes \`${quoteSource(refusedHas.argument)}\` to \`has()\`, which takes only a ` + + `field selection (has() invalid argument), ${NEVER_EVALUATES}`, + // One remedy line, the spelling the CEL definition admits; why the + // engine refuses the shape is `os explain visibility-predicate-has-argument`. + hint: hasArgumentHint(refusedHas, CANONICAL_ROOT_BY_LAYER[layer]), + }); + } + // (4) #6128 — a reference no binding root can resolve. Unlike (1) this one // GATES: a mis-rooted predicate is at least a statement about a namespace // someone binds somewhere, while a bare identifier resolves nowhere, on no @@ -1234,7 +1472,8 @@ function isFieldObject(entry: unknown): entry is AnyRec { * Returns findings (empty = clean). `visibility-root-mislayered` is advisory * (`warning`); `visibility-predicate-syntax` (#6253), * `visibility-predicate-over-budget` (#7217), - * `visibility-predicate-unknown-function` (#13594) and + * `visibility-predicate-unknown-function` (#13594), + * `visibility-predicate-has-argument` (#25281) and * `visibility-bare-identifier` (#6128) are `error` and the caller is expected to * fail the build on them. * From c48c86b3034d7a545ef828013453503f63e3267e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:19:02 +0000 Subject: [PATCH 2/3] fix(spec): list stored page/view visibleWhen has() arguments the engine refuses (ADR-0087 D3) Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01Jc59n5NWaMp1khDwL7Fici --- ...sibility-predicates.stored-listing.test.ts | 98 +++++++++ .../src/conversions/cel-has-argument.test.ts | 81 ++++++++ .../spec/src/conversions/cel-has-argument.ts | 196 ++++++++++++++++++ .../src/conversions/refused-shapes.test.ts | 62 ++++++ .../spec/src/conversions/refused-shapes.ts | 68 +++++- ...sibility-predicate-has-argument-refused.ts | 43 ++++ 6 files changed, 547 insertions(+), 1 deletion(-) create mode 100644 packages/lint/src/validate-visibility-predicates.stored-listing.test.ts create mode 100644 packages/spec/src/conversions/cel-has-argument.test.ts create mode 100644 packages/spec/src/conversions/cel-has-argument.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.visibility-predicate-has-argument-refused.ts diff --git a/packages/lint/src/validate-visibility-predicates.stored-listing.test.ts b/packages/lint/src/validate-visibility-predicates.stored-listing.test.ts new file mode 100644 index 00000000000..7d68fd81845 --- /dev/null +++ b/packages/lint/src/validate-visibility-predicates.stored-listing.test.ts @@ -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); + }); +}); diff --git a/packages/spec/src/conversions/cel-has-argument.test.ts b/packages/spec/src/conversions/cel-has-argument.test.ts new file mode 100644 index 00000000000..3deb2b03875 --- /dev/null +++ b/packages/spec/src/conversions/cel-has-argument.test.ts @@ -0,0 +1,81 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, expect, it } from 'vitest'; + +import { refusedHasArguments } from './cel-has-argument.js'; + +/** + * [#25281] The listing's `has()` argument reading, held to the shapes the + * engine (`@objectstack/formula`'s `celEngine.compile`, cel-js 8.0.0) was + * measured on. Every source in the first table compiles to `has() invalid + * argument`; every one in the second compiles, or fails for a reason that is + * not the macro's. The lint side pins the same listing against the refusing + * door itself (`validate-visibility-predicates.stored-listing.test.ts`). + */ +describe('refusedHasArguments — the shapes the engine refuses', () => { + it.each([ + ['has(status)', 'status', 'bare'], + ['has(record)', 'record', 'bare'], + ['has (status)', 'status', 'bare'], + ['has(\n status )', 'status', 'bare'], + ['has(record.items[0])', 'record.items[0]', 'index'], + ["has(record['k'])", "record['k']", 'index'], + ['has(record.items[0][1])', 'record.items[0][1]', 'index'], + ['has(record.items[size(record.items) - 1])', 'record.items[size(record.items) - 1]', 'index'], + ['has(record.?a)', 'record.?a', 'other'], + ['has(record.a.?b)', 'record.a.?b', 'other'], + ['has(1)', '1', 'other'], + ['has(1.5)', '1.5', 'other'], + ['has(0x1)', '0x1', 'other'], + ['has(1u)', '1u', 'other'], + ["has('x')", "'x'", 'other'], + ['has(b"x")', 'b"x"', 'other'], + ['has(true)', 'true', 'other'], + ['has(null)', 'null', 'other'], + ['has(f(x))', 'f(x)', 'other'], + ['has(record.x.size())', 'record.x.size()', 'other'], + ['has(-record.a)', '-record.a', 'other'], + ['has(!record.a)', '!record.a', 'other'], + ['has(record.a == 1)', 'record.a == 1', 'other'], + ['has(record.a + 1)', 'record.a + 1', 'other'], + ['has(record.a ? record.b : record.c)', 'record.a ? record.b : record.c', 'other'], + ['has(record.a in record.b)', 'record.a in record.b', 'other'], + ['has(record.a.b.has(c))', 'record.a.b.has(c)', 'other'], + ])('%s → `%s` (%s)', (source, argument, kind) => { + expect(refusedHasArguments(source)).toEqual([{ argument, kind }]); + }); + + it('reads every call, in source order, inside a macro body too', () => { + expect(refusedHasArguments('has(record.x) || has(y) && has(record.items[0])')).toEqual([ + { argument: 'y', kind: 'bare' }, + { argument: 'record.items[0]', kind: 'index' }, + ]); + expect(refusedHasArguments('record.items.exists(i, has(i))')).toEqual([{ argument: 'i', kind: 'bare' }]); + }); +}); + +describe('refusedHasArguments — what it does not list', () => { + it.each([ + ['has(record.a)', 'a field selection'], + ['has(record.a.b.c.d)', 'a deeper selection'], + ['has((record.a))', 'a parenthesised selection — grouping adds no node'], + ['has(record.items[0].x)', 'a selection over an indexed receiver, lowered by the engine'], + ["has(record['a'].b)", 'a selection over a bracketed receiver'], + ['has(dyn(record).a)', 'a selection over a call'], + ['has(record.a.?b.c)', 'a plain last step after an optional one'], + ['has(1.x)', 'a selection on a literal — the engine refuses it for its type, not as the macro'], + ['record.items.exists(i, has(i.x))', 'a selection on a comprehension variable'], + ["has(record.a) && record.a != ''", 'the guard idiom'], + ['record.tags.has(x)', 'a member call named `has`, not the macro'], + ["record.note == 'has(x)'", 'a `has(` inside a string literal'], + ['record.note == r"has(x)" && has(record.a)', 'inside a raw string'], + ["record.note == '''has(x)\n'''", 'inside a triple-quoted string'], + ["record.note == 'it\\'s has(x)'", 'past an escaped quote'], + ['has()', 'no argument — the engine refuses it under another message'], + ['has(record.a, record.b)', 'two arguments — likewise'], + ["record.note == 'has(x)", 'a string left open — not CEL, nothing to read'], + ['', 'an empty source'], + ])('%s is not listed (%s)', (source) => { + expect(refusedHasArguments(source)).toEqual([]); + }); +}); diff --git a/packages/spec/src/conversions/cel-has-argument.ts b/packages/spec/src/conversions/cel-has-argument.ts new file mode 100644 index 00000000000..f28dcd2f037 --- /dev/null +++ b/packages/spec/src/conversions/cel-has-argument.ts @@ -0,0 +1,196 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The `has()` argument reading the stored-shape listing uses (#25281) — which + * `has(…)` calls in a CEL source hand the macro an argument that is not a `.` + * field selection. + * + * ## Why a reading of its own lives here + * + * The door that refuses the shape is `@objectstack/lint`'s + * `visibility-predicate-has-argument`, and its verdict is the engine's: it + * walks the AST `@objectstack/formula` parses, and its suite pins that walk + * against `celEngine.compile` in both directions. This package depends on + * neither, so the listing in `refused-shapes.ts` cannot ask that door. It + * reads the source here instead, with the same test on the token stream: the + * argument of a one-argument `has(` call is a field selection exactly when it + * carries no operator outside brackets and ends in `.` followed by a name. + * + * ⚠️ This is a LISTING reading, never a gate: nothing here refuses anything, + * and a site it lists is one the door refuses. `cel-has-argument.test.ts` holds + * it to the shapes the engine was measured on, and lint's + * `validate-visibility-predicates.stored-listing.test.ts` holds the listing equal + * to the door's refusal over the same predicates. It reads a source the front + * end refuses for another reason the same way, so a predicate that is also + * broken CEL can be listed here while the door names its syntax fault first — + * both send the owner to the same predicate. + * + * ⚠️ Internal to `packages/spec`: not re-exported. + */ + +/** A `has()` call whose argument the engine refuses, and how it was written. */ +export interface HasArgumentRefusal { + /** The argument as written. */ + readonly argument: string; + /** `bare` — a name alone; `index` — an index as the last step; `other` — anything else. */ + readonly kind: 'bare' | 'index' | 'other'; +} + +interface Token { + kind: 'id' | 'num' | 'str' | 'punct'; + text: string; + start: number; + end: number; +} + +/** Two-character operators, so `.?`, `==` and the rest stay one token. */ +const TWO_CHAR = new Set(['.?', '==', '!=', '<=', '>=', '&&', '||']); + +/** Operators that make an argument something other than a selection when they sit outside brackets. */ +const OPERATORS = new Set(['||', '&&', '==', '!=', '<', '<=', '>', '>=', '+', '-', '*', '/', '%', '?', ':', '!']); + +/** CEL literals spelled like names. */ +const LITERAL_NAMES = new Set(['true', 'false', 'null']); + +const NAME_START = /[A-Za-z_]/; +const NAME_PART = /[A-Za-z0-9_]/; +const NUMBER = /^(?:0[xX][0-9a-fA-F]+|\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)[uU]?/; +const STRING_PREFIX = /^[rRbB]{1,2}$/; + +/** + * The token stream of `source`, or `null` when a string literal is left open + * (the front end refuses that source as not CEL, and nothing here reads it). + */ +function tokenize(source: string): Token[] | null { + const out: Token[] = []; + let i = 0; + while (i < source.length) { + const c = source[i]!; + if (/\s/.test(c)) { + i++; + continue; + } + if (NAME_START.test(c)) { + let j = i + 1; + while (j < source.length && NAME_PART.test(source[j]!)) j++; + const word = source.slice(i, j); + // `r'…'`, `b"…"`, `rb'''…'''`: a prefix glued to a quote opens a string. + if (STRING_PREFIX.test(word) && (source[j] === '"' || source[j] === "'")) { + const end = stringEnd(source, j, /[rR]/.test(word)); + if (end === null) return null; + out.push({ kind: 'str', text: source.slice(i, end), start: i, end }); + i = end; + continue; + } + out.push({ kind: 'id', text: word, start: i, end: j }); + i = j; + continue; + } + if (c === '"' || c === "'") { + const end = stringEnd(source, i, false); + if (end === null) return null; + out.push({ kind: 'str', text: source.slice(i, end), start: i, end }); + i = end; + continue; + } + const num = /\d/.test(c) ? NUMBER.exec(source.slice(i)) : null; + if (num) { + out.push({ kind: 'num', text: num[0], start: i, end: i + num[0].length }); + i += num[0].length; + continue; + } + const two = source.slice(i, i + 2); + const text = TWO_CHAR.has(two) ? two : c; + out.push({ kind: 'punct', text, start: i, end: i + text.length }); + i += text.length; + } + return out; +} + +/** The index just past the string literal whose opening quote is at `at`, or `null` when it never closes. */ +function stringEnd(source: string, at: number, raw: boolean): number | null { + const quote = source[at]!; + const delimiter = source.startsWith(quote.repeat(3), at) ? quote.repeat(3) : quote; + let i = at + delimiter.length; + while (i < source.length) { + if (!raw && source[i] === '\\') { + i += 2; + continue; + } + if (source.startsWith(delimiter, i)) return i + delimiter.length; + i++; + } + return null; +} + +const OPEN = new Set(['(', '[', '{']); +const CLOSE = new Set([')', ']', '}']); + +/** The index of the token that closes the bracket opened at `open`, or -1. */ +function closing(tokens: readonly Token[], open: number): number { + let depth = 0; + for (let j = open; j < tokens.length; j++) { + const t = tokens[j]!; + if (t.kind !== 'punct') continue; + if (OPEN.has(t.text)) depth++; + else if (CLOSE.has(t.text) && --depth === 0) return j; + } + return -1; +} + +/** How the engine reads one `has()` argument: `null` for a field selection, the refused kind otherwise. */ +function argumentKind(arg: readonly Token[]): HasArgumentRefusal['kind'] | null { + let a = arg; + // `has((record.a))` is `has(record.a)`: grouping adds no node. + while (a.length >= 2 && a[0]!.text === '(' && closing(a, 0) === a.length - 1) a = a.slice(1, -1); + if (a.length === 1 && a[0]!.kind === 'id' && !LITERAL_NAMES.has(a[0]!.text)) return 'bare'; + let depth = 0; + for (const t of a) { + if (t.kind === 'punct' && OPEN.has(t.text)) depth++; + else if (t.kind === 'punct' && CLOSE.has(t.text)) depth--; + else if (depth === 0 && ((t.kind === 'punct' && OPERATORS.has(t.text)) || (t.kind === 'id' && t.text === 'in'))) { + return 'other'; + } + } + const last = a[a.length - 1]; + const beforeLast = a[a.length - 2]; + if (last?.kind === 'id' && beforeLast?.kind === 'punct' && beforeLast.text === '.') return null; + if (last?.kind === 'punct' && last.text === ']') return 'index'; + return 'other'; +} + +/** + * Every `has(…)` call in `source`, in source order, whose single argument the + * engine refuses as `has() invalid argument`. A member call (`x.has(y)`), a + * `has(` inside a string literal, and a call with no argument or with several + * are not listed: the engine either does not read them as the macro or refuses + * them under another message. + */ +export function refusedHasArguments(source: string): HasArgumentRefusal[] { + if (typeof source !== 'string' || !source.includes('has')) return []; + const tokens = tokenize(source); + if (!tokens) return []; + const out: HasArgumentRefusal[] = []; + for (let i = 0; i < tokens.length; i++) { + const t = tokens[i]!; + if (t.kind !== 'id' || t.text !== 'has' || tokens[i + 1]?.text !== '(') continue; + const before = tokens[i - 1]; + if (before?.kind === 'punct' && (before.text === '.' || before.text === '.?')) continue; + const close = closing(tokens, i + 1); + if (close < 0) continue; + const arg = tokens.slice(i + 2, close); + if (arg.length === 0) continue; + let depth = 0; + let several = false; + for (const a of arg) { + if (a.kind !== 'punct') continue; + if (OPEN.has(a.text)) depth++; + else if (CLOSE.has(a.text)) depth--; + else if (depth === 0 && a.text === ',') several = true; + } + if (several) continue; + const kind = argumentKind(arg); + if (kind) out.push({ kind, argument: source.slice(arg[0]!.start, arg[arg.length - 1]!.end) }); + } + return out; +} diff --git a/packages/spec/src/conversions/refused-shapes.test.ts b/packages/spec/src/conversions/refused-shapes.test.ts index aa40aa95983..8dceb6182dd 100644 --- a/packages/spec/src/conversions/refused-shapes.test.ts +++ b/packages/spec/src/conversions/refused-shapes.test.ts @@ -62,6 +62,7 @@ describe('the refused-shape list — registered against the migration chain', () 'view-data-api-provider-retired', 'flow-value-slot-template-dialect-refused', 'object-external-binding-managed-datasource-refused', + 'visibility-predicate-has-argument-refused', ]); }); @@ -534,3 +535,64 @@ describe('#25168 — a stored object whose `external` binding is on a managed da expect(todosOf(stack).todos.filter((t) => t.conversionId === 'object-external-binding-managed-datasource-refused')).toEqual([]); }); }); + +// ── #25281 ──────────────────────────────────────────────────────────────── + +const HAS_ARGUMENT_ID = 'visibility-predicate-has-argument-refused'; + +const hasPage = (visibleWhen: unknown) => ({ + name: 'pipeline_home', + label: 'Pipeline', + type: 'app', + regions: [{ name: 'main', components: [{ type: 'page:header', properties: { title: 'Pipeline' }, visibleWhen }] }], +}); + +const hasView = (visibleWhen: unknown) => ({ + name: 'task_form', + label: 'Task', + form: { sections: [{ label: 'Main', visibleWhen, fields: [{ field: 'notes', visibleWhen }] }] }, +}); + +describe('#25281 — a stored page or view whose visibleWhen hands `has()` an argument that is not a field selection is listed', () => { + it.each([ + ['has(status)', 'status'], + ['has(record.items[0])', 'record.items[0]'], + ])('a page component carrying `%s`: one TODO naming the argument and the fate; the row is not rewritten', (predicate, argument) => { + const row = hasPage(predicate); + const { item, todos } = storedTodos('page', row); + expect(item).toEqual(row); + expect(todos).toHaveLength(1); + const todo = todos[0]!; + expect(todo.conversionId).toBe(HAS_ARGUMENT_ID); + expect(todo.path).toBe('pages[0].regions[0].components[0].visibleWhen'); + expect(todo.reason).toContain(`passes \`${argument}\` to \`has()\``); + expect(todo.reason).toContain('has() invalid argument'); + expect(todo.reason).toContain('No lossless rewrite'); + expect(JSON.parse(todo.from)).toBe(predicate); + }); + + it('a form view: the section and its field are each listed, at their own path', () => { + const { todos } = storedTodos('view', hasView({ dialect: 'cel', source: 'has(status)' })); + expect(todos.filter((t) => t.conversionId === HAS_ARGUMENT_ID).map((t) => t.path)).toEqual([ + 'views[0].form.sections[0].visibleWhen', + 'views[0].form.sections[0].fields[0].visibleWhen', + ]); + }); + + it.each([ + 'has(record.status)', + "has(record.items[0].name) && record.items[0].name != ''", + "record.note == 'has(x)'", + undefined, + ])('`%s` is not listed', (predicate) => { + expect(storedTodos('page', hasPage(predicate)).todos).toEqual([]); + expect(storedTodos('view', hasView(predicate)).todos).toEqual([]); + }); + + it('the stored page parses: the listing names a predicate the parse accepts and the authoring rule refuses', () => { + // The parse does not judge CEL, so the row loads and is served; the refusal + // belongs to `visibility-predicate-has-argument` at the save door and in + // `os validate` (pinned beside that rule, `@objectstack/lint`). + expect(PageSchema.safeParse(hasPage('has(status)')).success).toBe(true); + }); +}); diff --git a/packages/spec/src/conversions/refused-shapes.ts b/packages/spec/src/conversions/refused-shapes.ts index 99feed3b5c8..21b9f663759 100644 --- a/packages/spec/src/conversions/refused-shapes.ts +++ b/packages/spec/src/conversions/refused-shapes.ts @@ -41,7 +41,11 @@ * ({@link flowNodeValueTemplateRefusals}), and the `external` binding judge * the object refinement and `defineStack` share * ({@link externalBindingDatasourceRefusal}). A site is listed here exactly - * when that door refuses it. + * when that door refuses it. One door lives outside this package: the + * page/view `visibleWhen` rule in `@objectstack/lint` judges a `has()` + * argument on the engine's AST, which this package cannot parse, so its + * detector reads the source through {@link refusedHasArguments}, held equal + * to that rule by a test on the lint side (#25281). * - It never prints a credential. A view-level API source could carry request * `headers`. The listing names the URL and the method, and withholds the rest. * @@ -58,6 +62,7 @@ import { import { externalBindingDatasourceRefusal } from '../data/external-binding-datasource.js'; import { ComponentPropsMap } from '../ui/component.zod.js'; import { ViewDataSchema } from '../ui/view.zod.js'; +import { refusedHasArguments } from './cel-has-argument.js'; import type { ConversionTodoDetail } from './types.js'; import { mapCollection, mapFlowNodeList, mapPageComponents, mapViewPayloads } from './walk.js'; @@ -393,6 +398,66 @@ const flowValueSlotTemplateToken: RefusedStoredShape = { }, }; +// ── #25281: a page or view `visibleWhen` handing `has()` an argument that is not a field selection ── + +/** The predicate's CEL source: a bare string, or the `{ dialect, source }` envelope. */ +function visibleWhenSource(value: unknown): string | undefined { + if (typeof value === 'string') return value; + if (isDict(value) && typeof value.source === 'string') return value.source; + return undefined; +} + +const HAS_ARGUMENT_FATE = + 'No lossless rewrite: which test the author meant (`has(record.x)`, `\'x\' in record`, `record.x != null`, ' + + '`size(record.items) > 0`) is not recorded anywhere, so the upgrade does not write one. Left as stored, the ' + + 'page or view still loads and is served, and the console still draws the element for every user because the ' + + 'predicate never evaluates; its next save through the Studio, REST or MCP is refused with 422 ' + + 'INVALID_METADATA by `visibility-predicate-has-argument`, as `os validate`, `os build` and `os lint` refuse it ' + + 'in source. Write the presence test `has(e.f)` admits, on the root this surface binds.'; + +const visibilityHasArgument: RefusedStoredShape = { + id: 'visibility-predicate-has-argument-refused', + toMajor: 18, + surface: 'visibleWhen on a page component and on a form view\'s sections and section fields', + detect(stack, report) { + const judge = (element: Dict, path: string, where: string): void => { + const source = visibleWhenSource(element.visibleWhen); + if (source === undefined) return; + const [refused] = refusedHasArguments(source); + if (!refused) return; + report({ + path: `${path}.visibleWhen`, + from: JSON.stringify(element.visibleWhen), + reason: `The visibleWhen of ${where} passes \`${refused.argument}\` to \`has()\`, which takes only a field ` + + 'selection (`has(e.f)`); the engine refuses it as `has() invalid argument` on every evaluation. ' + + HAS_ARGUMENT_FATE, + }); + }; + mapViewPayloads(stack, (payload, kind, path) => { + if (kind !== 'form') return payload; + for (const bucket of ['sections', 'groups'] as const) { + const sections = payload[bucket]; + if (!Array.isArray(sections)) continue; + sections.forEach((section, si) => { + if (!isDict(section)) return; + const sectionPath = `${path}.${bucket}[${si}]`; + judge(section, sectionPath, `the form section at ${sectionPath}`); + const fields = section.fields; + if (!Array.isArray(fields)) return; + fields.forEach((field, fi) => { + if (isDict(field)) judge(field, `${sectionPath}.fields[${fi}]`, `the form field at ${sectionPath}.fields[${fi}]`); + }); + }); + } + return payload; + }); + mapPageComponents(stack, (component, path) => { + judge(component, path, `the \`${String(component.type)}\` component at ${path}`); + return component; + }); + }, +}; + /** * Every refused stored shape, in report order. A new entry is a reviewed edit: * `refused-shapes.test.ts` pins each one. @@ -402,6 +467,7 @@ export const REFUSED_STORED_SHAPES: readonly RefusedStoredShape[] = [ viewDataApiProvider, flowValueSlotTemplateToken, externalBindingOnManagedDatasource, + visibilityHasArgument, ]; /** diff --git a/packages/spec/src/migrations/entries/semantic/18.visibility-predicate-has-argument-refused.ts b/packages/spec/src/migrations/entries/semantic/18.visibility-predicate-has-argument-refused.ts new file mode 100644 index 00000000000..db249647f86 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.visibility-predicate-has-argument-refused.ts @@ -0,0 +1,43 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// The page/view `visibleWhen` door refuses a `has()` argument that is not a +// field selection (ruling 6351260886 on #25281). A structured TODO, not a D2 +// conversion: `has(status)` could mean `has(record.status)`, +// `'status' in record` or `record.status != null`, and `has(record.items[0])` +// could mean `size(record.items) > 0` or a test on the first item's field. +// Which one the author meant is not recorded, and the platform does not guess. +export const entry: SemanticMigration = { + id: 'visibility-predicate-has-argument-refused', + // No backticks in `surface` — build-upgrade-guide renders it inside a code + // span already, and a nested backtick would close it. + surface: + 'visibleWhen on a page component and on a form view\'s sections and section fields — a has() call whose ' + + 'argument is not a field selection: a name alone, has(status), an index as the last step, ' + + 'has(record.items[0]), or any other argument that is not e.f', + replacement: + 'Write the presence test the CEL definition admits, `has(e.f)` with `f` a field name after a dot, on the root ' + + 'the surface binds (`record` on a runtime view or page, `data` on a metadata-editing form): ' + + '`has(record.status)` or `\'status\' in record` for a field, and `size(record.items) > 0` to ask whether a ' + + 'list has a first element (`\'k\' in e` for a bracketed string key). `has()` over a selection whose receiver ' + + 'is indexed or called, such as `has(record.items[0].name)`, is accepted. `os validate`, `os build`, `os lint` ' + + 'and the page and view save doors (`422 INVALID_METADATA`) refuse the shape as ' + + '`visibility-predicate-has-argument`, an error.', + reason: + 'The CEL definition gives `has()` one form, `has(e.f)`, where `f` is syntactically an identifier and only the ' + + 'select notation is supported. The engine refuses any other argument as `has() invalid argument` on every ' + + 'evaluation, whatever the record holds. The page and view `visibleWhen` door did not judge the argument, so ' + + 'such a predicate published clean, and the console, which renders a faulting predicate as visible, drew the ' + + 'guarded element for every user. The refusal is a fact about the source, decided before any type is known, ' + + 'which is why the door that refuses unknown functions can refuse it too without judging the types that flow ' + + 'through a predicate. No mechanical move is lossless: the test the author meant is not recorded. Measured at ' + + 'the change: no producer of either shape in this repository or in HotCRM; every other predicate surface ' + + 'already refused it through `validateExpression`. ADR-0137 / ADR-0087.', + acceptanceCriteria: + 'Load every page and view module (`os validate` or `os build`): no `visibility-predicate-has-argument` ' + + 'finding. Every `has()` in a page component\'s or a form view\'s `visibleWhen` takes a field selection, and ' + + 'the element is drawn only when its predicate is true. A stored page or view row in the refused shape still ' + + 'loads and is served, and its next save is refused until it is corrected; `os migrate meta --stored` lists ' + + 'each such predicate under this entry\'s id. Done when no stored or authored `visibleWhen` carries the shape.', +}; From ce74b8efcedf3ef52459f083a9e6c9d73253a2d1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:22:05 +0000 Subject: [PATCH 3/3] chore(changeset): 25281 visibility has() argument refused Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01Jc59n5NWaMp1khDwL7Fici --- .../25281-visibility-has-argument-refused.md | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 .changeset/25281-visibility-has-argument-refused.md diff --git a/.changeset/25281-visibility-has-argument-refused.md b/.changeset/25281-visibility-has-argument-refused.md new file mode 100644 index 00000000000..73e41a07473 --- /dev/null +++ b/.changeset/25281-visibility-has-argument-refused.md @@ -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 + + + +**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).