|
| 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