Skip to content

Commit c48c86b

Browse files
committed
fix(spec): list stored page/view visibleWhen has() arguments the engine refuses (ADR-0087 D3)
Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jc59n5NWaMp1khDwL7Fici
1 parent cf417ef commit c48c86b

6 files changed

Lines changed: 547 additions & 1 deletion

File tree

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#25281] The stored-shape listing and the refusing door name ONE set.
5+
*
6+
* `os migrate meta --stored` lists a stored page or view whose `visibleWhen`
7+
* hands `has()` an argument that is not a field selection, under the D3 entry
8+
* `visibility-predicate-has-argument-refused`. The listing lives in
9+
* `@objectstack/spec`, which cannot parse CEL, so it reads the source on its
10+
* own (`conversions/cel-has-argument.ts`). The refusal is this package's
11+
* `visibility-predicate-has-argument`, whose verdict is the engine's AST. This
12+
* file holds the two equal over every shape the engine was measured on: a
13+
* predicate is listed exactly when the door refuses it under that id, and the
14+
* door refuses exactly what `celEngine.compile` refuses as `has() invalid
15+
* argument`.
16+
*/
17+
import { describe, expect, it } from 'vitest';
18+
import { applyConversionsToStoredItem, type ConversionTodoNotice } from '@objectstack/spec';
19+
import { celEngine } from '@objectstack/formula';
20+
21+
import { runRuntimeAuthoringRules } from './runtime-gate.js';
22+
import { VISIBILITY_PREDICATE_HAS_ARGUMENT } from './validate-visibility-predicates.js';
23+
24+
const ENTRY_ID = 'visibility-predicate-has-argument-refused';
25+
26+
const page = (visibleWhen: string) => ({
27+
name: 'pipeline_home',
28+
label: 'Pipeline',
29+
type: 'app',
30+
regions: [{ name: 'main', components: [{ type: 'page:header', properties: { title: 'Pipeline' }, visibleWhen }] }],
31+
});
32+
33+
/** Does the stored pass list this page under the entry? */
34+
function listed(visibleWhen: string): boolean {
35+
const todos: ConversionTodoNotice[] = [];
36+
applyConversionsToStoredItem('page', page(visibleWhen), { onTodo: (t) => todos.push(t) });
37+
return todos.some((t) => t.conversionId === ENTRY_ID);
38+
}
39+
40+
/** Does the page save door refuse it under the rule? */
41+
function refusedAtDoor(visibleWhen: string): boolean {
42+
const result = runRuntimeAuthoringRules({ type: 'page', item: page(visibleWhen), context: { objects: [] } });
43+
return result.errors.some((f) => f.rule === VISIBILITY_PREDICATE_HAS_ARGUMENT);
44+
}
45+
46+
function engineRefuses(visibleWhen: string): boolean {
47+
const compiled = celEngine.compile(visibleWhen);
48+
return !compiled.ok && compiled.error.message.split('\n')[0].trim() === 'has() invalid argument';
49+
}
50+
51+
const REFUSED = [
52+
'has(status)',
53+
'has(record)',
54+
'has(record.items[0])',
55+
"has(record['k'])",
56+
'has(record.items[size(record.items) - 1])',
57+
'has(record.?a)',
58+
'has(record.a.?b)',
59+
'has(1)',
60+
"has('x')",
61+
'has(true)',
62+
'has(record.x.size())',
63+
'has(upper(record.x))',
64+
'has(-record.a)',
65+
'has(record.a == 1)',
66+
'has(record.a ? record.b : record.c)',
67+
'has(record.x) || has(record.items[0])',
68+
'record.items.exists(i, has(i))',
69+
'has (record)',
70+
];
71+
72+
const ACCEPTED = [
73+
'has(record.a)',
74+
'has(record.a.b.c.d)',
75+
'has((record.a))',
76+
'has(record.items[0].x)',
77+
"has(record['a'].b)",
78+
'has(dyn(record).a)',
79+
'has(record.a.?b.c)',
80+
'record.items.exists(i, has(i.x))',
81+
"has(record.a) && record.a != ''",
82+
"record.note == 'has(x)'",
83+
'record.tags.has(record.a)',
84+
];
85+
86+
describe('#25281 — the stored listing, the save door and the engine agree on `has()` arguments', () => {
87+
it.each(REFUSED)('%s — the engine refuses it, the door refuses it, the stored pass lists it', (predicate) => {
88+
expect(engineRefuses(predicate), 'engine').toBe(true);
89+
expect(refusedAtDoor(predicate), 'door').toBe(true);
90+
expect(listed(predicate), 'listing').toBe(true);
91+
});
92+
93+
it.each(ACCEPTED)('%s — none of the three refuses or lists it', (predicate) => {
94+
expect(engineRefuses(predicate), 'engine').toBe(false);
95+
expect(refusedAtDoor(predicate), 'door').toBe(false);
96+
expect(listed(predicate), 'listing').toBe(false);
97+
});
98+
});
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, expect, it } from 'vitest';
4+
5+
import { refusedHasArguments } from './cel-has-argument.js';
6+
7+
/**
8+
* [#25281] The listing's `has()` argument reading, held to the shapes the
9+
* engine (`@objectstack/formula`'s `celEngine.compile`, cel-js 8.0.0) was
10+
* measured on. Every source in the first table compiles to `has() invalid
11+
* argument`; every one in the second compiles, or fails for a reason that is
12+
* not the macro's. The lint side pins the same listing against the refusing
13+
* door itself (`validate-visibility-predicates.stored-listing.test.ts`).
14+
*/
15+
describe('refusedHasArguments — the shapes the engine refuses', () => {
16+
it.each([
17+
['has(status)', 'status', 'bare'],
18+
['has(record)', 'record', 'bare'],
19+
['has (status)', 'status', 'bare'],
20+
['has(\n status )', 'status', 'bare'],
21+
['has(record.items[0])', 'record.items[0]', 'index'],
22+
["has(record['k'])", "record['k']", 'index'],
23+
['has(record.items[0][1])', 'record.items[0][1]', 'index'],
24+
['has(record.items[size(record.items) - 1])', 'record.items[size(record.items) - 1]', 'index'],
25+
['has(record.?a)', 'record.?a', 'other'],
26+
['has(record.a.?b)', 'record.a.?b', 'other'],
27+
['has(1)', '1', 'other'],
28+
['has(1.5)', '1.5', 'other'],
29+
['has(0x1)', '0x1', 'other'],
30+
['has(1u)', '1u', 'other'],
31+
["has('x')", "'x'", 'other'],
32+
['has(b"x")', 'b"x"', 'other'],
33+
['has(true)', 'true', 'other'],
34+
['has(null)', 'null', 'other'],
35+
['has(f(x))', 'f(x)', 'other'],
36+
['has(record.x.size())', 'record.x.size()', 'other'],
37+
['has(-record.a)', '-record.a', 'other'],
38+
['has(!record.a)', '!record.a', 'other'],
39+
['has(record.a == 1)', 'record.a == 1', 'other'],
40+
['has(record.a + 1)', 'record.a + 1', 'other'],
41+
['has(record.a ? record.b : record.c)', 'record.a ? record.b : record.c', 'other'],
42+
['has(record.a in record.b)', 'record.a in record.b', 'other'],
43+
['has(record.a.b.has(c))', 'record.a.b.has(c)', 'other'],
44+
])('%s → `%s` (%s)', (source, argument, kind) => {
45+
expect(refusedHasArguments(source)).toEqual([{ argument, kind }]);
46+
});
47+
48+
it('reads every call, in source order, inside a macro body too', () => {
49+
expect(refusedHasArguments('has(record.x) || has(y) && has(record.items[0])')).toEqual([
50+
{ argument: 'y', kind: 'bare' },
51+
{ argument: 'record.items[0]', kind: 'index' },
52+
]);
53+
expect(refusedHasArguments('record.items.exists(i, has(i))')).toEqual([{ argument: 'i', kind: 'bare' }]);
54+
});
55+
});
56+
57+
describe('refusedHasArguments — what it does not list', () => {
58+
it.each([
59+
['has(record.a)', 'a field selection'],
60+
['has(record.a.b.c.d)', 'a deeper selection'],
61+
['has((record.a))', 'a parenthesised selection — grouping adds no node'],
62+
['has(record.items[0].x)', 'a selection over an indexed receiver, lowered by the engine'],
63+
["has(record['a'].b)", 'a selection over a bracketed receiver'],
64+
['has(dyn(record).a)', 'a selection over a call'],
65+
['has(record.a.?b.c)', 'a plain last step after an optional one'],
66+
['has(1.x)', 'a selection on a literal — the engine refuses it for its type, not as the macro'],
67+
['record.items.exists(i, has(i.x))', 'a selection on a comprehension variable'],
68+
["has(record.a) && record.a != ''", 'the guard idiom'],
69+
['record.tags.has(x)', 'a member call named `has`, not the macro'],
70+
["record.note == 'has(x)'", 'a `has(` inside a string literal'],
71+
['record.note == r"has(x)" && has(record.a)', 'inside a raw string'],
72+
["record.note == '''has(x)\n'''", 'inside a triple-quoted string'],
73+
["record.note == 'it\\'s has(x)'", 'past an escaped quote'],
74+
['has()', 'no argument — the engine refuses it under another message'],
75+
['has(record.a, record.b)', 'two arguments — likewise'],
76+
["record.note == 'has(x)", 'a string left open — not CEL, nothing to read'],
77+
['', 'an empty source'],
78+
])('%s is not listed (%s)', (source) => {
79+
expect(refusedHasArguments(source)).toEqual([]);
80+
});
81+
});
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The `has()` argument reading the stored-shape listing uses (#25281) — which
5+
* `has(…)` calls in a CEL source hand the macro an argument that is not a `.`
6+
* field selection.
7+
*
8+
* ## Why a reading of its own lives here
9+
*
10+
* The door that refuses the shape is `@objectstack/lint`'s
11+
* `visibility-predicate-has-argument`, and its verdict is the engine's: it
12+
* walks the AST `@objectstack/formula` parses, and its suite pins that walk
13+
* against `celEngine.compile` in both directions. This package depends on
14+
* neither, so the listing in `refused-shapes.ts` cannot ask that door. It
15+
* reads the source here instead, with the same test on the token stream: the
16+
* argument of a one-argument `has(` call is a field selection exactly when it
17+
* carries no operator outside brackets and ends in `.` followed by a name.
18+
*
19+
* ⚠️ This is a LISTING reading, never a gate: nothing here refuses anything,
20+
* and a site it lists is one the door refuses. `cel-has-argument.test.ts` holds
21+
* it to the shapes the engine was measured on, and lint's
22+
* `validate-visibility-predicates.stored-listing.test.ts` holds the listing equal
23+
* to the door's refusal over the same predicates. It reads a source the front
24+
* end refuses for another reason the same way, so a predicate that is also
25+
* broken CEL can be listed here while the door names its syntax fault first —
26+
* both send the owner to the same predicate.
27+
*
28+
* ⚠️ Internal to `packages/spec`: not re-exported.
29+
*/
30+
31+
/** A `has()` call whose argument the engine refuses, and how it was written. */
32+
export interface HasArgumentRefusal {
33+
/** The argument as written. */
34+
readonly argument: string;
35+
/** `bare` — a name alone; `index` — an index as the last step; `other` — anything else. */
36+
readonly kind: 'bare' | 'index' | 'other';
37+
}
38+
39+
interface Token {
40+
kind: 'id' | 'num' | 'str' | 'punct';
41+
text: string;
42+
start: number;
43+
end: number;
44+
}
45+
46+
/** Two-character operators, so `.?`, `==` and the rest stay one token. */
47+
const TWO_CHAR = new Set(['.?', '==', '!=', '<=', '>=', '&&', '||']);
48+
49+
/** Operators that make an argument something other than a selection when they sit outside brackets. */
50+
const OPERATORS = new Set(['||', '&&', '==', '!=', '<', '<=', '>', '>=', '+', '-', '*', '/', '%', '?', ':', '!']);
51+
52+
/** CEL literals spelled like names. */
53+
const LITERAL_NAMES = new Set(['true', 'false', 'null']);
54+
55+
const NAME_START = /[A-Za-z_]/;
56+
const NAME_PART = /[A-Za-z0-9_]/;
57+
const NUMBER = /^(?:0[xX][0-9a-fA-F]+|\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)[uU]?/;
58+
const STRING_PREFIX = /^[rRbB]{1,2}$/;
59+
60+
/**
61+
* The token stream of `source`, or `null` when a string literal is left open
62+
* (the front end refuses that source as not CEL, and nothing here reads it).
63+
*/
64+
function tokenize(source: string): Token[] | null {
65+
const out: Token[] = [];
66+
let i = 0;
67+
while (i < source.length) {
68+
const c = source[i]!;
69+
if (/\s/.test(c)) {
70+
i++;
71+
continue;
72+
}
73+
if (NAME_START.test(c)) {
74+
let j = i + 1;
75+
while (j < source.length && NAME_PART.test(source[j]!)) j++;
76+
const word = source.slice(i, j);
77+
// `r'…'`, `b"…"`, `rb'''…'''`: a prefix glued to a quote opens a string.
78+
if (STRING_PREFIX.test(word) && (source[j] === '"' || source[j] === "'")) {
79+
const end = stringEnd(source, j, /[rR]/.test(word));
80+
if (end === null) return null;
81+
out.push({ kind: 'str', text: source.slice(i, end), start: i, end });
82+
i = end;
83+
continue;
84+
}
85+
out.push({ kind: 'id', text: word, start: i, end: j });
86+
i = j;
87+
continue;
88+
}
89+
if (c === '"' || c === "'") {
90+
const end = stringEnd(source, i, false);
91+
if (end === null) return null;
92+
out.push({ kind: 'str', text: source.slice(i, end), start: i, end });
93+
i = end;
94+
continue;
95+
}
96+
const num = /\d/.test(c) ? NUMBER.exec(source.slice(i)) : null;
97+
if (num) {
98+
out.push({ kind: 'num', text: num[0], start: i, end: i + num[0].length });
99+
i += num[0].length;
100+
continue;
101+
}
102+
const two = source.slice(i, i + 2);
103+
const text = TWO_CHAR.has(two) ? two : c;
104+
out.push({ kind: 'punct', text, start: i, end: i + text.length });
105+
i += text.length;
106+
}
107+
return out;
108+
}
109+
110+
/** The index just past the string literal whose opening quote is at `at`, or `null` when it never closes. */
111+
function stringEnd(source: string, at: number, raw: boolean): number | null {
112+
const quote = source[at]!;
113+
const delimiter = source.startsWith(quote.repeat(3), at) ? quote.repeat(3) : quote;
114+
let i = at + delimiter.length;
115+
while (i < source.length) {
116+
if (!raw && source[i] === '\\') {
117+
i += 2;
118+
continue;
119+
}
120+
if (source.startsWith(delimiter, i)) return i + delimiter.length;
121+
i++;
122+
}
123+
return null;
124+
}
125+
126+
const OPEN = new Set(['(', '[', '{']);
127+
const CLOSE = new Set([')', ']', '}']);
128+
129+
/** The index of the token that closes the bracket opened at `open`, or -1. */
130+
function closing(tokens: readonly Token[], open: number): number {
131+
let depth = 0;
132+
for (let j = open; j < tokens.length; j++) {
133+
const t = tokens[j]!;
134+
if (t.kind !== 'punct') continue;
135+
if (OPEN.has(t.text)) depth++;
136+
else if (CLOSE.has(t.text) && --depth === 0) return j;
137+
}
138+
return -1;
139+
}
140+
141+
/** How the engine reads one `has()` argument: `null` for a field selection, the refused kind otherwise. */
142+
function argumentKind(arg: readonly Token[]): HasArgumentRefusal['kind'] | null {
143+
let a = arg;
144+
// `has((record.a))` is `has(record.a)`: grouping adds no node.
145+
while (a.length >= 2 && a[0]!.text === '(' && closing(a, 0) === a.length - 1) a = a.slice(1, -1);
146+
if (a.length === 1 && a[0]!.kind === 'id' && !LITERAL_NAMES.has(a[0]!.text)) return 'bare';
147+
let depth = 0;
148+
for (const t of a) {
149+
if (t.kind === 'punct' && OPEN.has(t.text)) depth++;
150+
else if (t.kind === 'punct' && CLOSE.has(t.text)) depth--;
151+
else if (depth === 0 && ((t.kind === 'punct' && OPERATORS.has(t.text)) || (t.kind === 'id' && t.text === 'in'))) {
152+
return 'other';
153+
}
154+
}
155+
const last = a[a.length - 1];
156+
const beforeLast = a[a.length - 2];
157+
if (last?.kind === 'id' && beforeLast?.kind === 'punct' && beforeLast.text === '.') return null;
158+
if (last?.kind === 'punct' && last.text === ']') return 'index';
159+
return 'other';
160+
}
161+
162+
/**
163+
* Every `has(…)` call in `source`, in source order, whose single argument the
164+
* engine refuses as `has() invalid argument`. A member call (`x.has(y)`), a
165+
* `has(` inside a string literal, and a call with no argument or with several
166+
* are not listed: the engine either does not read them as the macro or refuses
167+
* them under another message.
168+
*/
169+
export function refusedHasArguments(source: string): HasArgumentRefusal[] {
170+
if (typeof source !== 'string' || !source.includes('has')) return [];
171+
const tokens = tokenize(source);
172+
if (!tokens) return [];
173+
const out: HasArgumentRefusal[] = [];
174+
for (let i = 0; i < tokens.length; i++) {
175+
const t = tokens[i]!;
176+
if (t.kind !== 'id' || t.text !== 'has' || tokens[i + 1]?.text !== '(') continue;
177+
const before = tokens[i - 1];
178+
if (before?.kind === 'punct' && (before.text === '.' || before.text === '.?')) continue;
179+
const close = closing(tokens, i + 1);
180+
if (close < 0) continue;
181+
const arg = tokens.slice(i + 2, close);
182+
if (arg.length === 0) continue;
183+
let depth = 0;
184+
let several = false;
185+
for (const a of arg) {
186+
if (a.kind !== 'punct') continue;
187+
if (OPEN.has(a.text)) depth++;
188+
else if (CLOSE.has(a.text)) depth--;
189+
else if (depth === 0 && a.text === ',') several = true;
190+
}
191+
if (several) continue;
192+
const kind = argumentKind(arg);
193+
if (kind) out.push({ kind, argument: source.slice(arg[0]!.start, arg[arg.length - 1]!.end) });
194+
}
195+
return out;
196+
}

0 commit comments

Comments
 (0)