@uicast/core 0.0.1-beta.1 → 0.0.1-beta.2

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.
Files changed (72) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/{src/def/create-component-definition.ts → dist/def/create-component-definition.d.ts} +2 -14
  3. package/dist/def/create-component-definition.js +10 -0
  4. package/dist/entry-error.d.ts +47 -0
  5. package/dist/entry-error.js +56 -0
  6. package/dist/expr/analyze.d.ts +5 -0
  7. package/dist/expr/analyze.js +189 -0
  8. package/dist/expr/ast-utils.d.ts +4 -0
  9. package/dist/expr/ast-utils.js +23 -0
  10. package/dist/expr/evaluate.d.ts +7 -0
  11. package/dist/expr/evaluate.js +64 -0
  12. package/dist/expr/extract-deps.d.ts +2 -0
  13. package/dist/expr/extract-deps.js +30 -0
  14. package/dist/expr/globals.d.ts +3 -0
  15. package/dist/expr/globals.js +111 -0
  16. package/dist/expr/safer-eval.d.ts +21 -0
  17. package/dist/expr/safer-eval.js +170 -0
  18. package/dist/expr/validate.d.ts +7 -0
  19. package/dist/expr/validate.js +109 -0
  20. package/dist/index.d.ts +9 -0
  21. package/dist/index.js +10 -0
  22. package/{src/prompt/get-common-instructions-partial-prompt.ts → dist/prompt/get-common-instructions-partial-prompt.d.ts} +1 -8
  23. package/dist/prompt/get-common-instructions-partial-prompt.js +14 -0
  24. package/dist/prompt/get-components-partial-prompt.d.ts +35 -0
  25. package/dist/prompt/get-components-partial-prompt.js +134 -0
  26. package/dist/prompt/get-edit-request-prompt.d.ts +23 -0
  27. package/dist/prompt/get-edit-request-prompt.js +22 -0
  28. package/{src/prompt/get-error-recovery-prompt.ts → dist/prompt/get-error-recovery-prompt.d.ts} +7 -20
  29. package/dist/prompt/get-error-recovery-prompt.js +23 -0
  30. package/{src/prompt/get-expressions-partial-prompt.ts → dist/prompt/get-expressions-partial-prompt.d.ts} +1 -10
  31. package/dist/prompt/get-expressions-partial-prompt.js +27 -0
  32. package/dist/prompt/get-functions-partial-prompt.d.ts +22 -0
  33. package/{src/prompt/get-functions-partial-prompt.ts → dist/prompt/get-functions-partial-prompt.js} +18 -34
  34. package/dist/prompt/get-scope-partial-prompt.d.ts +24 -0
  35. package/{src/prompt/get-scope-partial-prompt.ts → dist/prompt/get-scope-partial-prompt.js} +15 -34
  36. package/dist/prompt/index.d.ts +7 -0
  37. package/dist/prompt/index.js +9 -0
  38. package/{src → dist}/prompt/md/EXPRESSIONS.json +1 -1
  39. package/dist/prompt/md/INSTRUCTIONS.json +1 -0
  40. package/dist/prompt-utils/json-schema-to-ts.d.ts +73 -0
  41. package/dist/prompt-utils/json-schema-to-ts.js +199 -0
  42. package/dist/scope/create-proxy-scope.d.ts +20 -0
  43. package/dist/scope/create-proxy-scope.js +101 -0
  44. package/{src/scope/parse-scope.ts → dist/scope/parse-scope.d.ts} +1 -17
  45. package/dist/scope/parse-scope.js +36 -0
  46. package/dist/stream/stream-json-lines.d.ts +1 -0
  47. package/dist/stream/stream-json-lines.js +94 -0
  48. package/dist/types.d.ts +89 -0
  49. package/dist/types.js +22 -0
  50. package/dist/utils/utils.d.ts +13 -0
  51. package/dist/utils/utils.js +58 -0
  52. package/package.json +13 -6
  53. package/src/entry-error.ts +0 -104
  54. package/src/expr/analyze.ts +0 -190
  55. package/src/expr/ast-utils.ts +0 -28
  56. package/src/expr/evaluate.ts +0 -74
  57. package/src/expr/extract-deps.ts +0 -32
  58. package/src/expr/globals.ts +0 -118
  59. package/src/expr/safer-eval.ts +0 -233
  60. package/src/expr/validate.ts +0 -149
  61. package/src/index.ts +0 -28
  62. package/src/prompt/get-components-partial-prompt.ts +0 -153
  63. package/src/prompt/get-edit-request-prompt.ts +0 -37
  64. package/src/prompt/index.ts +0 -32
  65. package/src/prompt/md/EXPRESSIONS.md +0 -29
  66. package/src/prompt/md/INSTRUCTIONS.json +0 -1
  67. package/src/prompt/md/INSTRUCTIONS.md +0 -172
  68. package/src/prompt-utils/json-schema-to-ts.ts +0 -225
  69. package/src/scope/create-proxy-scope.ts +0 -131
  70. package/src/stream/stream-json-lines.ts +0 -56
  71. package/src/types.ts +0 -124
  72. package/src/utils/utils.ts +0 -64
@@ -1,190 +0,0 @@
1
- import type * as acorn from "acorn";
2
- import { childNodes } from "./ast-utils";
3
-
4
- // Static analysis of a parsed expression (not the security check — that's
5
- // validate.ts). Two things the renderer needs: whether the expr is async
6
- // (containsAwait), and which scopes.X.Y paths it reads (extractScopeReads), so it
7
- // can subscribe without the LLM writing a deps array.
8
-
9
- /** Does the expression `await` anything? */
10
- export function containsAwait(node: acorn.AnyNode | null | undefined): boolean {
11
- if (!node || typeof node !== "object") return false;
12
- if (node.type === "AwaitExpression") return true;
13
- for (const child of childNodes(node)) {
14
- if (containsAwait(child)) return true;
15
- }
16
- return false;
17
- }
18
-
19
- // Collect every static scopes.X.Y chain the expression reads, stopping at the
20
- // first dynamic segment (computed key, call, non-identifier property). A method
21
- // call drops its last segment (rows.filter(...) depends on rows, not .filter).
22
- // The test file pins the trickier cases.
23
-
24
- // Read a member chain into its identifier segments (`a.b.c` -> ["a","b","c"]), or
25
- // null if it hits a computed / non-identifier segment.
26
- function collectChain(node: acorn.AnyNode | null | undefined): string[] | null {
27
- const parts: string[] = [];
28
- let cur: acorn.AnyNode | null | undefined = node;
29
- while (cur) {
30
- if (cur.type === "MemberExpression") {
31
- if (cur.computed) return null;
32
- const prop = cur.property;
33
- if (prop.type !== "Identifier") return null;
34
- parts.unshift(prop.name);
35
- cur = cur.object;
36
- } else if (cur.type === "Identifier") {
37
- parts.unshift(cur.name);
38
- return parts;
39
- } else {
40
- return null;
41
- }
42
- }
43
- return null;
44
- }
45
-
46
- // A computed key can read scopes itself (`scopes.x[scopes.y]`), so walk the
47
- // computed parts of a chain too.
48
- function walkComputedKeysWithin(memberExpr: acorn.AnyNode, out: Set<string>): void {
49
- let cur: acorn.AnyNode = memberExpr;
50
- while (cur.type === "MemberExpression") {
51
- if (cur.computed) {
52
- walkScopeReads(cur.property, out);
53
- }
54
- cur = cur.object;
55
- }
56
- }
57
-
58
- // The recursive walker behind extractScopeReads().
59
- function walkScopeReads(
60
- node: acorn.AnyNode | null | undefined,
61
- out: Set<string>,
62
- ): void {
63
- if (!node || typeof node !== "object") return;
64
-
65
- // A method call depends on the chain, not the method name — record the chain
66
- // without the method, then recurse args.
67
- if (node.type === "CallExpression") {
68
- const callee = node.callee;
69
- if (callee.type === "MemberExpression" && !callee.computed) {
70
- const chain = collectChain(callee.object);
71
- if (chain && chain[0] === "scopes" && chain.length > 1) {
72
- out.add(chain.join("."));
73
- walkComputedKeysWithin(callee.object, out);
74
- } else {
75
- walkScopeReads(callee.object, out);
76
- }
77
- } else {
78
- walkScopeReads(callee, out);
79
- }
80
- for (const arg of node.arguments) {
81
- walkScopeReads(arg, out);
82
- }
83
- return;
84
- }
85
-
86
- // A plain member read: take the chain if it's rooted at `scopes`.
87
- if (node.type === "MemberExpression") {
88
- const chain = collectChain(node);
89
- if (chain && chain[0] === "scopes" && chain.length > 1) {
90
- out.add(chain.join("."));
91
- walkComputedKeysWithin(node, out);
92
- return;
93
- }
94
- // Not a scopes chain (or it hit a computed segment) — recurse for nested reads.
95
- walkScopeReads(node.object, out);
96
- if (node.computed) {
97
- walkScopeReads(node.property, out);
98
- }
99
- return;
100
- }
101
-
102
- for (const child of childNodes(node)) {
103
- walkScopeReads(child, out);
104
- }
105
- }
106
-
107
- export function extractScopeReads(ast: acorn.AnyNode): string[] {
108
- const out = new Set<string>();
109
- walkScopeReads(ast, out);
110
- return [...out];
111
- }
112
-
113
- // Free identifiers — names read from the outer scope (globals + injected
114
- // context). Anything bound by a param, destructuring, or a local declaration
115
- // stays internal and is not reported.
116
- function bindPattern(
117
- node: acorn.AnyNode | null | undefined,
118
- scope: Set<string>,
119
- ): void {
120
- if (!node) return;
121
- if (node.type === "Identifier") {
122
- scope.add(node.name);
123
- } else if (node.type === "ObjectPattern") {
124
- for (const p of node.properties)
125
- bindPattern(p.type === "RestElement" ? p.argument : p.value, scope);
126
- } else if (node.type === "ArrayPattern") {
127
- for (const el of node.elements) if (el) bindPattern(el, scope);
128
- } else if (node.type === "AssignmentPattern") {
129
- bindPattern(node.left, scope);
130
- } else if (node.type === "RestElement") {
131
- bindPattern(node.argument, scope);
132
- }
133
- }
134
-
135
- function isBound(name: string, stack: Set<string>[]): boolean {
136
- for (let i = stack.length - 1; i >= 0; i--)
137
- if (stack[i].has(name)) return true;
138
- return false;
139
- }
140
-
141
- function walkFree(
142
- node: acorn.AnyNode | null | undefined,
143
- stack: Set<string>[],
144
- out: Set<string>,
145
- ): void {
146
- if (!node || typeof node !== "object") return;
147
-
148
- switch (node.type) {
149
- case "Identifier":
150
- if (!isBound(node.name, stack)) out.add(node.name);
151
- return;
152
- case "MemberExpression":
153
- walkFree(node.object, stack, out);
154
- if (node.computed) walkFree(node.property, stack, out);
155
- return;
156
- case "Property":
157
- if (node.computed) walkFree(node.key, stack, out);
158
- walkFree(node.value, stack, out);
159
- return;
160
- case "VariableDeclarator":
161
- bindPattern(node.id, stack[stack.length - 1]);
162
- walkFree(node.init, stack, out);
163
- return;
164
- case "ArrowFunctionExpression":
165
- case "FunctionExpression": {
166
- const scope = new Set<string>();
167
- if (node.type === "FunctionExpression" && node.id)
168
- scope.add(node.id.name);
169
- for (const p of node.params) bindPattern(p, scope);
170
- const inner = [...stack, scope];
171
- for (const p of node.params) walkFree(p, inner, out);
172
- walkFree(node.body, inner, out);
173
- return;
174
- }
175
- case "CatchClause": {
176
- const scope = new Set<string>();
177
- if (node.param) bindPattern(node.param, scope);
178
- walkFree(node.body, [...stack, scope], out);
179
- return;
180
- }
181
- }
182
-
183
- for (const child of childNodes(node)) walkFree(child, stack, out);
184
- }
185
-
186
- export function extractFreeIdentifiers(ast: acorn.AnyNode): string[] {
187
- const out = new Set<string>();
188
- walkFree(ast, [new Set()], out);
189
- return [...out];
190
- }
@@ -1,28 +0,0 @@
1
- import type * as acorn from "acorn";
2
-
3
- // Low-level acorn-AST helpers shared by validate.ts and analyze.ts. NOT a parser
4
- // — acorn parses; these are a node type-guard and a child-traversal helper.
5
-
6
- /** Runtime guard: does `x` look like an AST node (object with a string `type`)? */
7
- export function isNode(x: unknown): x is acorn.AnyNode {
8
- return (
9
- typeof x === "object" &&
10
- x !== null &&
11
- typeof (x as { type?: unknown }).type === "string"
12
- );
13
- }
14
-
15
- // Direct child nodes, flattening array-valued fields — for key-agnostic walks.
16
- export function childNodes(node: acorn.AnyNode): acorn.AnyNode[] {
17
- const out: acorn.AnyNode[] = [];
18
- for (const value of Object.values(
19
- node as unknown as Record<string, unknown>,
20
- )) {
21
- if (Array.isArray(value)) {
22
- for (const child of value) if (isNode(child)) out.push(child);
23
- } else if (isNode(value)) {
24
- out.push(value);
25
- }
26
- }
27
- return out;
28
- }
@@ -1,74 +0,0 @@
1
- import type { StandardToolV0 } from "standard-tool";
2
- import type { ValueSource } from "../types";
3
- import { EntryError } from "../entry-error";
4
- import { ALLOWED_GLOBALS } from "./globals";
5
- import { SaferEval, SaferEvalError } from "./safer-eval";
6
-
7
- const saferEval = new SaferEval({
8
- allowGlobals: ALLOWED_GLOBALS,
9
- enforceAllowlist: true,
10
- });
11
-
12
- // Every scopes.X.Y path an expression reads, via the shared SaferEval singleton
13
- // (so extractDeps doesn't reach into the evaluator). Same parse cache as eval().
14
- export const getScopeReads = (expr: string): string[] =>
15
- saferEval.scopeReads(expr);
16
-
17
- // Classify an evaluation failure by provenance: SaferEval's own rejections
18
- // carry their reason (syntax / policy / unknown identifier); an EntryError
19
- // passes through (a host function already tagged it); anything else threw
20
- // while the compiled expression ran.
21
- const wrapEvalError = (err: unknown): EntryError => {
22
- if (EntryError.is(err)) return err;
23
- if (err instanceof SaferEvalError) return EntryError.wrap(err, err.reason);
24
- return EntryError.wrap(err, "expression-runtime");
25
- };
26
-
27
- // Evaluate a ValueSource: a `literal` is returned as-is, an `expr` runs through
28
- // SaferEval against context + host functions. Returns unknown — an async expr
29
- // resolves to a Promise (callers check `value instanceof Promise`). `functions`
30
- // (a StandardToolV0[]) are spread after context, so a host fn wins a name clash.
31
- // Every failure surfaces as a classified EntryError.
32
- export const evaluate = (
33
- expr: ValueSource,
34
- context: Record<string, unknown>,
35
- options?: { functions?: StandardToolV0[]; allowedGlobals?: string[] },
36
- ): unknown => {
37
- if ("literal" in expr) return expr.literal;
38
- if (!expr.expr) return null;
39
- const functions = Object.fromEntries(
40
- (options?.functions ?? []).map((tool) => [
41
- tool.name,
42
- // This closure is the engine/host boundary: anything escaping
43
- // `tool.execute` is host code failing — unless the host already threw a
44
- // classified EntryError itself (e.g. "invalid-arguments"), which wrap()
45
- // passes through.
46
- (input: unknown) => {
47
- try {
48
- const result = tool.execute(input);
49
- return result instanceof Promise
50
- ? result.catch((err) => {
51
- throw EntryError.wrap(err, "host-function");
52
- })
53
- : result;
54
- } catch (err) {
55
- throw EntryError.wrap(err, "host-function");
56
- }
57
- },
58
- ]),
59
- );
60
- try {
61
- const result = saferEval.eval(
62
- expr.expr,
63
- { ...context, ...functions },
64
- options?.allowedGlobals,
65
- );
66
- return result instanceof Promise
67
- ? result.catch((err) => {
68
- throw wrapEvalError(err);
69
- })
70
- : result;
71
- } catch (err) {
72
- throw wrapEvalError(err);
73
- }
74
- };
@@ -1,32 +0,0 @@
1
- import { type ComponentEntry, isComponentListEntry } from "../types";
2
- import { getScopeReads } from "./evaluate";
3
-
4
- // Entries are immutable, so cached reads never go stale; WeakMap so a dropped
5
- // entry can be collected.
6
- const cache = new WeakMap<ComponentEntry, string[]>();
7
-
8
- // The reactive scopes.X.Y paths an entry reads, across its props, hidden, and each
9
- // expressions — the renderer subscribes to these. (seed run once and
10
- // callbacks read at fire time, so neither is scanned.)
11
- export function extractDeps(entry: ComponentEntry): string[] {
12
- const cached = cache.get(entry);
13
- if (cached) return cached;
14
-
15
- const out = new Set<string>();
16
-
17
- if (entry.props && "expr" in entry.props && entry.props.expr) {
18
- for (const r of getScopeReads(entry.props.expr)) out.add(r);
19
- }
20
- if (entry.hidden) {
21
- for (const r of getScopeReads(entry.hidden)) out.add(r);
22
- }
23
-
24
- // `each` reads both the list and anything its filter touches (e.g. a search term).
25
- if (isComponentListEntry(entry)) {
26
- for (const r of getScopeReads(entry.each)) out.add(r);
27
- }
28
-
29
- const result = [...out];
30
- cache.set(entry, result);
31
- return result;
32
- }
@@ -1,118 +0,0 @@
1
- // Which JavaScript globals an expression can see. The engine enforces an
2
- // allowlist (safer-eval.ts): a free identifier that is neither an allowed global
3
- // nor an injected context name is rejected before the expression runs.
4
- // ALLOWED_GLOBALS is that list — also handed to SaferEval as allowGlobals and
5
- // shown to the LLM in the prompt. GLOBALS_TO_SHADOW are bound to undefined as a
6
- // second line of defence, so an allowed-by-mistake reference still stays inert.
7
-
8
- /** Globals an expression may reference. Every entry is also usable with `new`. */
9
- export const ALLOWED_GLOBALS: string[] = [
10
- // Namespaces / utilities
11
- "Math",
12
- "JSON",
13
- "Intl",
14
- "Object",
15
- "Array",
16
- // Coercion (use as calls — `new Number(x)` etc. boxes the value)
17
- "Number",
18
- "String",
19
- "Boolean",
20
- "parseInt",
21
- "parseFloat",
22
- // Predicates / constants
23
- "isNaN",
24
- "isFinite",
25
- "undefined",
26
- "NaN",
27
- "Infinity",
28
- // URI helpers
29
- "encodeURIComponent",
30
- "decodeURIComponent",
31
- "encodeURI",
32
- "decodeURI",
33
- // Constructors & async
34
- "Date",
35
- "Map",
36
- "Set",
37
- "RegExp",
38
- "URL",
39
- "URLSearchParams",
40
- "Promise",
41
- "BigInt",
42
- // Error constructors
43
- "Error",
44
- "TypeError",
45
- "RangeError",
46
- "SyntaxError",
47
- "ReferenceError",
48
- "EvalError",
49
- "URIError",
50
- "AggregateError",
51
- ];
52
-
53
- // Capability globals shadowed to undefined. (`eval` / `arguments` can't be param
54
- // names in strict mode — blocked in validate.ts instead.)
55
- export const GLOBALS_TO_SHADOW: string[] = [
56
- // Global objects
57
- "globalThis",
58
- "self",
59
- "window",
60
- "global",
61
- "document",
62
- "navigator",
63
- "location",
64
- "history",
65
- "localStorage",
66
- "sessionStorage",
67
- "indexedDB",
68
-
69
- // Network / IO
70
- "fetch",
71
- "XMLHttpRequest",
72
- "WebSocket",
73
- "EventSource",
74
- "Worker",
75
- "SharedWorker",
76
- "ServiceWorker",
77
- "importScripts",
78
- // Fire a network request via `.src` — an exfiltration channel even with fetch
79
- // shadowed.
80
- "Image",
81
- "Audio",
82
-
83
- // Code execution
84
- "Function",
85
- "WebAssembly", // runs arbitrary code
86
- "setTimeout",
87
- "setInterval",
88
- "setImmediate",
89
- "requestAnimationFrame",
90
- "requestIdleCallback",
91
- "queueMicrotask",
92
-
93
- // Process / non-browser runtimes
94
- "process",
95
- "require",
96
- "module",
97
- "exports",
98
- "__dirname",
99
- "__filename",
100
- "Buffer",
101
- "Deno", // runtime god-object (fs/net/env) outside a browser
102
- "Bun",
103
-
104
- // DOM
105
- "alert",
106
- "confirm",
107
- "prompt",
108
- "close",
109
- "open",
110
- "print",
111
- "postMessage",
112
-
113
- // Constructors that can escape
114
- "Proxy",
115
- "Reflect",
116
- "SharedArrayBuffer",
117
- "Atomics",
118
- ];
@@ -1,233 +0,0 @@
1
- import * as acorn from "acorn";
2
- import {
3
- containsAwait,
4
- extractFreeIdentifiers,
5
- extractScopeReads,
6
- } from "./analyze";
7
- import { GLOBALS_TO_SHADOW } from "./globals";
8
- import { SaferEvalError, validateNode } from "./validate";
9
-
10
- // Compiles and runs the expressions validate.ts approves: parse + analyse once
11
- // (cached), compile to a Function (AsyncFunction when the expr uses `await`),
12
- // shadow ambient globals as undefined params, run in-realm so Proxy state works.
13
- // Guardrail, not a sandbox — see the Expressions docs page, § Safety.
14
-
15
- // Re-exported from validate.ts for callers of this module.
16
- export { SaferEvalError };
17
-
18
- // The AsyncFunction constructor, for expressions that use `await`.
19
- const AsyncFunction = Object.getPrototypeOf(async () => {})
20
- .constructor as typeof Function;
21
-
22
- type CacheEntry = {
23
- isAsync: boolean;
24
- scopeReads: string[];
25
- freeIds: string[];
26
- // Built lazily on first compile(); validate()/scopeReads() leave it unset.
27
- fn?: (context: Record<string, unknown>) => unknown;
28
- };
29
-
30
- export class SaferEval {
31
- #cache = new Map<string, CacheEntry>();
32
- #maxCacheSize: number;
33
- #shadowParams: string[];
34
- #allowGlobals: Set<string>;
35
- #enforceAllowlist: boolean;
36
-
37
- constructor(
38
- options: {
39
- maxCacheSize?: number;
40
- extraGlobalsToShadow?: string[];
41
- allowGlobals?: string[];
42
- enforceAllowlist?: boolean;
43
- } = {},
44
- ) {
45
- const {
46
- maxCacheSize = 500,
47
- extraGlobalsToShadow = [],
48
- allowGlobals = [],
49
- enforceAllowlist = false,
50
- } = options;
51
-
52
- this.#maxCacheSize = maxCacheSize;
53
- this.#enforceAllowlist = enforceAllowlist;
54
-
55
- const allowSet = new Set(allowGlobals);
56
- this.#allowGlobals = allowSet;
57
- this.#shadowParams = [...GLOBALS_TO_SHADOW, ...extraGlobalsToShadow].filter(
58
- (g) => !allowSet.has(g),
59
- );
60
- }
61
-
62
- // Parse + validate without running. Throws SaferEvalError if invalid; returns
63
- // isAsync + scopeReads and caches the analysis (compile() builds the fn later).
64
- validate(expression: string): {
65
- isAsync: boolean;
66
- scopeReads: string[];
67
- freeIds: string[];
68
- } {
69
- if (typeof expression !== "string") {
70
- throw new SaferEvalError("Expression must be a string", "expression-syntax");
71
- }
72
-
73
- const trimmed = expression.trim();
74
- if (!trimmed) {
75
- throw new SaferEvalError("Expression cannot be empty", "expression-syntax");
76
- }
77
-
78
- const cached = this.#cache.get(expression);
79
- if (cached) {
80
- return {
81
- isAsync: cached.isAsync,
82
- scopeReads: cached.scopeReads,
83
- freeIds: cached.freeIds,
84
- };
85
- }
86
-
87
- // Parse as `"use strict"; void (expr)`: void(...) forces expression context
88
- // (so `{...}` is an object literal, not a block), and strict mode rejects
89
- // sloppy-only constructs (octal, `with`, dup params) here, not at compile.
90
- const wrapper = `"use strict"; void (${trimmed})`;
91
- let ast: acorn.Program;
92
- try {
93
- ast = acorn.parse(wrapper, {
94
- ecmaVersion: 2022,
95
- sourceType: "script",
96
- allowAwaitOutsideFunction: true,
97
- });
98
- } catch (e: unknown) {
99
- throw new SaferEvalError(
100
- `Syntax error: ${e instanceof Error ? e.message : String(e)}. Expression: ${expression}`,
101
- "expression-syntax",
102
- );
103
- }
104
-
105
- validateNode(ast);
106
-
107
- const analysis = {
108
- isAsync: containsAwait(ast),
109
- scopeReads: extractScopeReads(ast),
110
- freeIds: extractFreeIdentifiers(ast),
111
- };
112
-
113
- // Cache the analysis (no fn yet); evict oldest when full.
114
- if (this.#cache.size >= this.#maxCacheSize) {
115
- const firstKey = this.#cache.keys().next().value;
116
- if (firstKey !== undefined) this.#cache.delete(firstKey);
117
- }
118
- this.#cache.set(expression, { ...analysis });
119
-
120
- return analysis;
121
- }
122
-
123
- // Compile to a reusable fn (returns a Promise if the expr uses `await`).
124
- compile(expression: string): (context: Record<string, unknown>) => unknown {
125
- const { isAsync } = this.validate(expression);
126
- const entry = this.#cache.get(expression);
127
- if (!entry) {
128
- // Unreachable — validate() just inserted this entry.
129
- throw new SaferEvalError(
130
- `Cache entry missing for expression: ${expression}`,
131
- );
132
- }
133
- if (entry.fn) return entry.fn;
134
-
135
- const expr = expression.trim();
136
- const Ctor = isAsync ? AsyncFunction : Function;
137
-
138
- // Single-slot cache: new Ctor bakes in the param names (context keys ∪ shadow
139
- // params), so it recompiles only if the context shape changes — which it
140
- // doesn't in practice (a given expr always sees the same keys).
141
- let cachedSig: string | undefined;
142
- let cachedFn: ((...args: unknown[]) => unknown) | undefined;
143
-
144
- const evaluator = (context: Record<string, unknown>) => {
145
- const contextKeys = Object.keys(context);
146
- const sig = contextKeys.join("\u0000");
147
- if (sig !== cachedSig) {
148
- // Context keys become params; shadow params follow, bound to undefined.
149
- const allParams = [...contextKeys, ...this.#shadowParams];
150
- try {
151
- cachedFn = new Ctor(
152
- ...allParams,
153
- `"use strict"; return (${expr})`,
154
- ) as (...args: unknown[]) => unknown;
155
- } catch (e: unknown) {
156
- // Parsed but won't compile — surface as SaferEvalError, not a raw one.
157
- throw new SaferEvalError(
158
- `Failed to compile expression: ${
159
- e instanceof Error ? e.message : String(e)
160
- }. Expression: ${expression}`,
161
- "expression-syntax",
162
- );
163
- }
164
- cachedSig = sig;
165
- }
166
-
167
- // Args match allParams: context values (Object.keys order) then shadow fills.
168
- const allArgs = [
169
- ...Object.values(context),
170
- ...new Array(this.#shadowParams.length).fill(undefined),
171
- ];
172
- return (cachedFn as (...args: unknown[]) => unknown)(...allArgs);
173
- };
174
-
175
- entry.fn = evaluator;
176
- return evaluator;
177
- }
178
-
179
- isAsync(expression: string): boolean {
180
- const cached = this.#cache.get(expression);
181
- if (cached) return cached.isAsync;
182
- return this.validate(expression).isAsync;
183
- }
184
-
185
- // Every `scopes.X.Y` path the expression reads — the renderer auto-subscribes
186
- // to these instead of the LLM writing a deps array.
187
- scopeReads(expression: string): string[] {
188
- const cached = this.#cache.get(expression);
189
- if (cached) return cached.scopeReads;
190
- return this.validate(expression).scopeReads;
191
- }
192
-
193
- // Allowlist gate: every free identifier must be an injected context name, a
194
- // base allowed global, or one the host opted into via allowedGlobals.
195
- #checkAllowlist(
196
- freeIds: string[],
197
- contextKeys: string[],
198
- allowedGlobals: string[],
199
- ): void {
200
- const allowed = new Set([
201
- ...contextKeys,
202
- ...this.#allowGlobals,
203
- ...allowedGlobals,
204
- ]);
205
- for (const id of freeIds) {
206
- if (!allowed.has(id)) {
207
- // The expression named something that doesn't exist here — an
208
- // unregistered function or a global outside the allowlist.
209
- throw new SaferEvalError(
210
- `"${id}" is not available in expressions`,
211
- "unknown-reference",
212
- );
213
- }
214
- }
215
- }
216
-
217
- eval(
218
- expression: string,
219
- context: Record<string, unknown> = {},
220
- allowedGlobals: string[] = [],
221
- ): unknown {
222
- if (this.#enforceAllowlist) {
223
- const { freeIds } = this.validate(expression);
224
- this.#checkAllowlist(freeIds, Object.keys(context), allowedGlobals);
225
- }
226
- const evaluator = this.compile(expression);
227
- return evaluator(context);
228
- }
229
-
230
- clearCache(): void {
231
- this.#cache.clear();
232
- }
233
- }