micra.js 2.3.1 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +96 -0
- package/README.md +22 -2
- package/dist/index.d.ts +1 -1
- package/dist/micra.cjs.js +306 -67
- package/dist/micra.cjs.js.map +3 -3
- package/dist/micra.esm.js +306 -67
- package/dist/micra.esm.js.map +3 -3
- package/dist/micra.js +306 -67
- package/dist/micra.js.map +3 -3
- package/dist/micra.min.js +3 -2
- package/dist/types.d.ts +1 -0
- package/dist/utils/expr.d.ts +29 -22
- package/llms-full.txt +19 -19
- package/llms.txt +3 -3
- package/package.json +3 -3
- package/src/core/mount.ts +4 -0
- package/src/dom/each.ts +19 -2
- package/src/dom/events.ts +37 -10
- package/src/index.ts +1 -1
- package/src/types.ts +1 -0
- package/src/utils/expr.ts +278 -122
package/src/utils/expr.ts
CHANGED
|
@@ -1,130 +1,213 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* src/utils/expr.ts — JS
|
|
2
|
+
* src/utils/expr.ts — CSP-safe JS-expression evaluator.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* - Shadow non-state identifiers so directive expressions cannot reach
|
|
9
|
-
* globals like `window`, `fetch`, `constructor`, etc. A small whitelist
|
|
10
|
-
* of utility globals (Math, JSON, Date, ...) remains accessible.
|
|
4
|
+
* Directive expressions (`data-text="count > 0"`, `data-class="x:a === b"`, …)
|
|
5
|
+
* are parsed into a small AST and walked by an interpreter. There is NO
|
|
6
|
+
* `new Function` / `eval` anywhere — so Micra runs under a strict
|
|
7
|
+
* Content-Security-Policy (`default-src 'self'`, no `unsafe-eval`).
|
|
11
8
|
*
|
|
12
9
|
* LLM NOTE: This module is PURE. It does not touch the DOM or mutate state.
|
|
13
10
|
*
|
|
14
11
|
* Security model:
|
|
15
|
-
*
|
|
16
|
-
* and
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
12
|
+
* The interpreter can only reach: top-level state keys, component methods,
|
|
13
|
+
* and a whitelist of utility globals (Math, JSON, Date, …). A bare
|
|
14
|
+
* identifier that is none of those resolves to `undefined` — `window`,
|
|
15
|
+
* `document`, `fetch`, `eval`, `constructor` are unreachable *by
|
|
16
|
+
* construction* (there is no scope that contains them), not by shadowing.
|
|
17
|
+
* Member access additionally refuses the prototype-escape property names
|
|
18
|
+
* (`__proto__`, `constructor`, `prototype`), closing the
|
|
19
|
+
* `item.constructor.constructor("…")()` chain that the old `with()`-based
|
|
20
|
+
* evaluator left open. Method calls still run real JS — if a component
|
|
21
|
+
* method touches `window`, that works; treat directive templates as
|
|
22
|
+
* trusted code regardless.
|
|
23
|
+
*
|
|
24
|
+
* Grammar (precedence low→high):
|
|
25
|
+
* ternary ?: | || | && | == != === !== | < <= > >= | + - |
|
|
26
|
+
* * / % | unary ! - | call() / member. | primary
|
|
27
|
+
* primary = number | string | true | false | null | undefined |
|
|
28
|
+
* identifier | ( expr )
|
|
22
29
|
*/
|
|
23
30
|
|
|
24
31
|
import type { StateRecord } from '../types'
|
|
25
32
|
|
|
26
|
-
// ──
|
|
27
|
-
|
|
28
|
-
|
|
33
|
+
// ── Whitelisted globals ─────────────────────────────────────────────────────
|
|
34
|
+
const ALLOWED_GLOBALS = new Set<string>(
|
|
35
|
+
'Math,JSON,Date,String,Number,Boolean,Array,Object,parseInt,parseFloat,isNaN,isFinite,NaN,Infinity,undefined'.split(','),
|
|
36
|
+
)
|
|
29
37
|
|
|
30
|
-
//
|
|
31
|
-
//
|
|
38
|
+
// Property names that would let an expression climb back to the Function
|
|
39
|
+
// constructor / prototype. Blocked on every member access.
|
|
40
|
+
const BLOCKED_PROPS = new Set<string>(['__proto__', 'constructor', 'prototype'])
|
|
32
41
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
type CompiledFn = (state: object, safe: object) => unknown
|
|
36
|
-
type CachedEntry =
|
|
37
|
-
| { kind: 'fn'; fn: CompiledFn }
|
|
38
|
-
| { kind: 'path'; parts: string[] }
|
|
42
|
+
/** @internal Names that live on Object.prototype (constructor, toString, …). */
|
|
43
|
+
const OBJ_PROTO_KEYS = new Set<string>(Object.getOwnPropertyNames(Object.prototype))
|
|
39
44
|
|
|
40
|
-
|
|
41
|
-
//
|
|
42
|
-
|
|
43
|
-
|
|
45
|
+
// ── AST ─────────────────────────────────────────────────────────────────────
|
|
46
|
+
// Compact node shapes; `k` is the kind tag.
|
|
47
|
+
type Node =
|
|
48
|
+
| { k: 'lit'; v: unknown }
|
|
49
|
+
| { k: 'id'; n: string }
|
|
50
|
+
| { k: 'mem'; o: Node; p: string }
|
|
51
|
+
| { k: 'call'; c: Node; a: Node[] }
|
|
52
|
+
| { k: 'un'; op: string; x: Node }
|
|
53
|
+
| { k: 'bin'; op: string; l: Node; r: Node }
|
|
54
|
+
| { k: 'tern'; c: Node; a: Node; b: Node }
|
|
44
55
|
|
|
45
|
-
//
|
|
46
|
-
|
|
47
|
-
const SIMPLE_PATH = /^[a-zA-Z_$][a-zA-Z0-9_$]*(\.[a-zA-Z_$][a-zA-Z0-9_$]*)*$/
|
|
56
|
+
// ── Tokenizer ────────────────────────────────────────────────────────────────
|
|
57
|
+
type Tok = { t: 'num' | 'str' | 'id' | 'p'; v: string }
|
|
48
58
|
|
|
49
|
-
|
|
59
|
+
const PUNCT = [
|
|
60
|
+
'===', '!==', '==', '!=', '<=', '>=', '&&', '||',
|
|
61
|
+
'(', ')', '.', ',', '?', ':', '!', '<', '>', '+', '-', '*', '/', '%',
|
|
62
|
+
]
|
|
50
63
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
64
|
+
function tokenize(src: string): Tok[] {
|
|
65
|
+
const toks: Tok[] = []
|
|
66
|
+
let i = 0
|
|
67
|
+
const n = src.length
|
|
68
|
+
while (i < n) {
|
|
69
|
+
const c = src[i]!
|
|
70
|
+
if (c === ' ' || c === '\t' || c === '\n' || c === '\r' || c === '\f') { i++; continue }
|
|
71
|
+
// string
|
|
72
|
+
if (c === '"' || c === "'") {
|
|
73
|
+
let s = ''
|
|
74
|
+
i++
|
|
75
|
+
while (i < n && src[i] !== c) {
|
|
76
|
+
if (src[i] === '\\') { s += src[i + 1] ?? ''; i += 2 }
|
|
77
|
+
else { s += src[i]; i++ }
|
|
78
|
+
}
|
|
79
|
+
if (src[i] !== c) throw 0 // unterminated
|
|
80
|
+
i++
|
|
81
|
+
toks.push({ t: 'str', v: s })
|
|
82
|
+
continue
|
|
83
|
+
}
|
|
84
|
+
// number
|
|
85
|
+
if (c >= '0' && c <= '9') {
|
|
86
|
+
let s = ''
|
|
87
|
+
while (i < n && ((src[i]! >= '0' && src[i]! <= '9') || src[i] === '.')) { s += src[i]; i++ }
|
|
88
|
+
toks.push({ t: 'num', v: s })
|
|
89
|
+
continue
|
|
90
|
+
}
|
|
91
|
+
// identifier
|
|
92
|
+
if (/[A-Za-z_$]/.test(c)) {
|
|
93
|
+
let s = ''
|
|
94
|
+
while (i < n && /[A-Za-z0-9_$]/.test(src[i]!)) { s += src[i]; i++ }
|
|
95
|
+
toks.push({ t: 'id', v: s })
|
|
96
|
+
continue
|
|
97
|
+
}
|
|
98
|
+
// punctuator (longest match first)
|
|
99
|
+
const m = PUNCT.find(p => src.startsWith(p, i))
|
|
100
|
+
if (!m) throw 0 // unknown char
|
|
101
|
+
toks.push({ t: 'p', v: m })
|
|
102
|
+
i += m.length
|
|
103
|
+
}
|
|
104
|
+
return toks
|
|
105
|
+
}
|
|
59
106
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
const PARAM_S = '$s'
|
|
69
|
-
const PARAM_SAFE = '$safe'
|
|
70
|
-
|
|
71
|
-
const SAFE_OUTER: object = new Proxy(Object.create(null) as object, {
|
|
72
|
-
has(_target, key): boolean {
|
|
73
|
-
if (typeof key !== 'string') return false
|
|
74
|
-
if (key === PARAM_S || key === PARAM_SAFE) return false
|
|
75
|
-
return !ALLOWED_GLOBALS.has(key)
|
|
76
|
-
},
|
|
77
|
-
get(): undefined {
|
|
78
|
-
return undefined
|
|
79
|
-
},
|
|
80
|
-
})
|
|
107
|
+
// ── Parser (Pratt) ────────────────────────────────────────────────────────────
|
|
108
|
+
const BIN_PREC: Record<string, number> = {
|
|
109
|
+
'||': 1, '&&': 2,
|
|
110
|
+
'==': 3, '!=': 3, '===': 3, '!==': 3,
|
|
111
|
+
'<': 4, '<=': 4, '>': 4, '>=': 4,
|
|
112
|
+
'+': 5, '-': 5,
|
|
113
|
+
'*': 6, '/': 6, '%': 6,
|
|
114
|
+
}
|
|
81
115
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
const
|
|
116
|
+
function parse(toks: Tok[]): Node {
|
|
117
|
+
let pos = 0
|
|
118
|
+
const peek = () => toks[pos]
|
|
119
|
+
const next = () => toks[pos++]
|
|
120
|
+
const eat = (v: string) => { if (peek()?.v !== v) throw 0; pos++ }
|
|
87
121
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
122
|
+
function parseExpr(): Node {
|
|
123
|
+
const c = parseBin(1)
|
|
124
|
+
if (peek()?.v === '?') {
|
|
125
|
+
next()
|
|
126
|
+
const a = parseExpr()
|
|
127
|
+
eat(':')
|
|
128
|
+
const b = parseExpr()
|
|
129
|
+
return { k: 'tern', c, a, b }
|
|
130
|
+
}
|
|
131
|
+
return c
|
|
132
|
+
}
|
|
94
133
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
134
|
+
function parseBin(minPrec: number): Node {
|
|
135
|
+
let left = parseUnary()
|
|
136
|
+
for (;;) {
|
|
137
|
+
const t = peek()
|
|
138
|
+
const prec = t && t.t === 'p' ? BIN_PREC[t.v] : undefined
|
|
139
|
+
if (prec === undefined || prec < minPrec) break
|
|
140
|
+
next()
|
|
141
|
+
const right = parseBin(prec + 1)
|
|
142
|
+
left = { k: 'bin', op: t!.v, l: left, r: right }
|
|
143
|
+
}
|
|
144
|
+
return left
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function parseUnary(): Node {
|
|
148
|
+
const t = peek()
|
|
149
|
+
if (t && t.t === 'p' && (t.v === '!' || t.v === '-')) {
|
|
150
|
+
next()
|
|
151
|
+
return { k: 'un', op: t.v, x: parseUnary() }
|
|
152
|
+
}
|
|
153
|
+
return parsePostfix()
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function parsePostfix(): Node {
|
|
157
|
+
let node = parsePrimary()
|
|
158
|
+
for (;;) {
|
|
159
|
+
const t = peek()
|
|
160
|
+
if (t?.v === '.') {
|
|
161
|
+
next()
|
|
162
|
+
const id = next()
|
|
163
|
+
if (!id || id.t !== 'id') throw 0
|
|
164
|
+
node = { k: 'mem', o: node, p: id.v }
|
|
165
|
+
} else if (t?.v === '(') {
|
|
166
|
+
next()
|
|
167
|
+
const args: Node[] = []
|
|
168
|
+
if (peek()?.v !== ')') {
|
|
169
|
+
args.push(parseExpr())
|
|
170
|
+
while (peek()?.v === ',') { next(); args.push(parseExpr()) }
|
|
171
|
+
}
|
|
172
|
+
eat(')')
|
|
173
|
+
node = { k: 'call', c: node, a: args }
|
|
174
|
+
} else break
|
|
175
|
+
}
|
|
176
|
+
return node
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function parsePrimary(): Node {
|
|
180
|
+
const t = next()
|
|
181
|
+
if (!t) throw 0
|
|
182
|
+
if (t.t === 'num') return { k: 'lit', v: Number(t.v) }
|
|
183
|
+
if (t.t === 'str') return { k: 'lit', v: t.v }
|
|
184
|
+
if (t.v === '(') { const e = parseExpr(); eat(')'); return e }
|
|
185
|
+
if (t.t === 'id') {
|
|
186
|
+
if (t.v === 'true') return { k: 'lit', v: true }
|
|
187
|
+
if (t.v === 'false') return { k: 'lit', v: false }
|
|
188
|
+
if (t.v === 'null') return { k: 'lit', v: null }
|
|
189
|
+
if (t.v === 'undefined') return { k: 'lit', v: undefined }
|
|
190
|
+
return { k: 'id', n: t.v }
|
|
191
|
+
}
|
|
192
|
+
throw 0
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const ast = parseExpr()
|
|
196
|
+
if (pos !== toks.length) throw 0 // trailing garbage
|
|
197
|
+
return ast
|
|
113
198
|
}
|
|
114
199
|
|
|
200
|
+
// ── Identifier resolution ─────────────────────────────────────────────────────
|
|
201
|
+
|
|
115
202
|
/**
|
|
116
203
|
* Return true iff `key` is reachable on `state` without walking into
|
|
117
|
-
* `Object.prototype`.
|
|
118
|
-
*
|
|
204
|
+
* `Object.prototype`. Blocks `'constructor' in state` from leaking the
|
|
205
|
+
* prototype while still allowing user-defined keys that happen to share a
|
|
206
|
+
* built-in name.
|
|
119
207
|
*/
|
|
120
|
-
function safeStateHas(state: object, key:
|
|
121
|
-
if (typeof key !== 'string') return false
|
|
208
|
+
function safeStateHas(state: object, key: string): boolean {
|
|
122
209
|
if (!Reflect.has(state, key)) return false
|
|
123
|
-
// Identifiers that are NOT on Object.prototype are always safe — accept them
|
|
124
|
-
// immediately without walking the chain.
|
|
125
210
|
if (!OBJ_PROTO_KEYS.has(key)) return true
|
|
126
|
-
// Built-in Object.prototype names (constructor, toString, hasOwnProperty, ...)
|
|
127
|
-
// are only accepted when they have been explicitly placed on the state chain.
|
|
128
211
|
let obj: object | null = state
|
|
129
212
|
while (obj && obj !== Object.prototype) {
|
|
130
213
|
if (Object.prototype.hasOwnProperty.call(obj, key)) return true
|
|
@@ -133,53 +216,126 @@ function safeStateHas(state: object, key: PropertyKey): boolean {
|
|
|
133
216
|
return false
|
|
134
217
|
}
|
|
135
218
|
|
|
136
|
-
|
|
219
|
+
function resolveIdent(name: string, scope: StateRecord): unknown {
|
|
220
|
+
if (safeStateHas(scope, name)) return scope[name]
|
|
221
|
+
if (ALLOWED_GLOBALS.has(name)) return (globalThis as Record<string, unknown>)[name]
|
|
222
|
+
return undefined
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ── Interpreter ────────────────────────────────────────────────────────────────
|
|
226
|
+
|
|
227
|
+
function evalNode(node: Node, scope: StateRecord): unknown {
|
|
228
|
+
switch (node.k) {
|
|
229
|
+
case 'lit': return node.v
|
|
230
|
+
case 'id': return resolveIdent(node.n, scope)
|
|
231
|
+
case 'mem': {
|
|
232
|
+
const o = evalNode(node.o, scope)
|
|
233
|
+
if (o == null || BLOCKED_PROPS.has(node.p)) return undefined
|
|
234
|
+
return (o as Record<string, unknown>)[node.p]
|
|
235
|
+
}
|
|
236
|
+
case 'un': {
|
|
237
|
+
const x = evalNode(node.x, scope)
|
|
238
|
+
return node.op === '!' ? !x : -(x as number)
|
|
239
|
+
}
|
|
240
|
+
case 'tern':
|
|
241
|
+
return evalNode(node.c, scope) ? evalNode(node.a, scope) : evalNode(node.b, scope)
|
|
242
|
+
case 'bin': {
|
|
243
|
+
const op = node.op
|
|
244
|
+
// short-circuit logicals (return operand value, JS semantics)
|
|
245
|
+
if (op === '&&') { const l = evalNode(node.l, scope); return l ? evalNode(node.r, scope) : l }
|
|
246
|
+
if (op === '||') { const l = evalNode(node.l, scope); return l ? l : evalNode(node.r, scope) }
|
|
247
|
+
const l = evalNode(node.l, scope) as never
|
|
248
|
+
const r = evalNode(node.r, scope) as never
|
|
249
|
+
switch (op) {
|
|
250
|
+
case '+': return (l as number) + (r as number)
|
|
251
|
+
case '-': return (l as number) - (r as number)
|
|
252
|
+
case '*': return (l as number) * (r as number)
|
|
253
|
+
case '/': return (l as number) / (r as number)
|
|
254
|
+
case '%': return (l as number) % (r as number)
|
|
255
|
+
case '<': return l < r
|
|
256
|
+
case '<=': return l <= r
|
|
257
|
+
case '>': return l > r
|
|
258
|
+
case '>=': return l >= r
|
|
259
|
+
case '==': return l == r
|
|
260
|
+
case '!=': return l != r
|
|
261
|
+
case '===': return l === r
|
|
262
|
+
case '!==': return l !== r
|
|
263
|
+
}
|
|
264
|
+
return undefined
|
|
265
|
+
}
|
|
266
|
+
case 'call': {
|
|
267
|
+
// member call binds `this` to the object (item.fmt(), Math.round(x));
|
|
268
|
+
// bare call leaves `this` undefined (instance methods are pre-bound).
|
|
269
|
+
let fn: unknown
|
|
270
|
+
let self: unknown
|
|
271
|
+
if (node.c.k === 'mem') {
|
|
272
|
+
self = evalNode(node.c.o, scope)
|
|
273
|
+
fn = self == null || BLOCKED_PROPS.has(node.c.p)
|
|
274
|
+
? undefined
|
|
275
|
+
: (self as Record<string, unknown>)[node.c.p]
|
|
276
|
+
} else {
|
|
277
|
+
fn = evalNode(node.c, scope)
|
|
278
|
+
}
|
|
279
|
+
if (typeof fn !== 'function') throw new TypeError('not a function')
|
|
280
|
+
return (fn as (...a: unknown[]) => unknown).apply(self, node.a.map(x => evalNode(x, scope)))
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ── Cache + public API ──────────────────────────────────────────────────────
|
|
286
|
+
|
|
287
|
+
type CachedEntry =
|
|
288
|
+
| { kind: 'path'; parts: string[] }
|
|
289
|
+
| { kind: 'ast'; ast: Node }
|
|
290
|
+
| { kind: 'err' }
|
|
291
|
+
|
|
292
|
+
const exprCache = new Map<string, CachedEntry>()
|
|
293
|
+
const warnedRuntime = new Set<string>()
|
|
294
|
+
|
|
295
|
+
// Simple identifier or dot-path: "count", "user.name", "item.email".
|
|
296
|
+
const SIMPLE_PATH = /^[a-zA-Z_$][a-zA-Z0-9_$]*(\.[a-zA-Z_$][a-zA-Z0-9_$]*)*$/
|
|
137
297
|
|
|
138
298
|
/**
|
|
139
|
-
* Evaluate
|
|
299
|
+
* Evaluate an expression string against a state object.
|
|
140
300
|
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
301
|
+
* Cached by string. Simple dot-paths take a fast path that skips tokenizing.
|
|
302
|
+
* Parse errors warn once and thereafter resolve to `undefined`; runtime
|
|
303
|
+
* errors (e.g. calling a non-function) warn once per expression.
|
|
144
304
|
*
|
|
145
305
|
* @example
|
|
146
|
-
* evalExpr('count > 0', { count: 5 })
|
|
147
|
-
* evalExpr('user.name', { user: { name: 'Alice' } })
|
|
148
|
-
* evalExpr('price * qty', { price: 9.99, qty: 3 })
|
|
306
|
+
* evalExpr('count > 0', { count: 5 }) // → true
|
|
307
|
+
* evalExpr('user.name', { user: { name: 'Alice' } }) // → 'Alice'
|
|
308
|
+
* evalExpr('price * qty', { price: 9.99, qty: 3 }) // → 29.97
|
|
149
309
|
*/
|
|
150
310
|
export function evalExpr(expr: string, state: StateRecord): unknown {
|
|
151
311
|
let cached = exprCache.get(expr)
|
|
152
312
|
|
|
153
313
|
if (!cached) {
|
|
154
|
-
// Determine once whether this is a simple dot-path and cache the result.
|
|
155
314
|
if (SIMPLE_PATH.test(expr)) {
|
|
156
315
|
cached = { kind: 'path', parts: expr.split('.') }
|
|
157
316
|
} else {
|
|
158
317
|
try {
|
|
159
|
-
cached = {
|
|
160
|
-
kind: 'fn',
|
|
161
|
-
fn: new Function('$s', '$safe', `with($safe){with($s){return (${expr})}}`) as CompiledFn,
|
|
162
|
-
}
|
|
318
|
+
cached = { kind: 'ast', ast: parse(tokenize(expr)) }
|
|
163
319
|
} catch {
|
|
164
320
|
warn(`invalid expression "${expr}"`)
|
|
165
|
-
cached = { kind: '
|
|
321
|
+
cached = { kind: 'err' }
|
|
166
322
|
}
|
|
167
323
|
}
|
|
168
324
|
exprCache.set(expr, cached)
|
|
169
325
|
}
|
|
170
326
|
|
|
171
|
-
// Fast-path: simple property access — no Function() needed.
|
|
172
|
-
// Still guarded so bare access to Object.prototype names returns undefined.
|
|
173
327
|
if (cached.kind === 'path') {
|
|
174
|
-
|
|
175
|
-
return
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
328
|
+
const parts = cached.parts
|
|
329
|
+
if (!safeStateHas(state, parts[0]!)) return undefined
|
|
330
|
+
let obj: unknown = state
|
|
331
|
+
for (const key of parts) obj = obj != null ? (obj as StateRecord)[key] : undefined
|
|
332
|
+
return obj
|
|
179
333
|
}
|
|
180
334
|
|
|
335
|
+
if (cached.kind === 'err') return undefined
|
|
336
|
+
|
|
181
337
|
try {
|
|
182
|
-
return cached.
|
|
338
|
+
return evalNode(cached.ast, state)
|
|
183
339
|
} catch (e) {
|
|
184
340
|
if (!warnedRuntime.has(expr)) {
|
|
185
341
|
warnedRuntime.add(expr)
|