@uicast/core 0.0.1-beta.1
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 +12 -0
- package/LICENSE +21 -0
- package/README.md +1 -0
- package/package.json +52 -0
- package/src/def/create-component-definition.ts +21 -0
- package/src/entry-error.ts +104 -0
- package/src/expr/analyze.ts +190 -0
- package/src/expr/ast-utils.ts +28 -0
- package/src/expr/evaluate.ts +74 -0
- package/src/expr/extract-deps.ts +32 -0
- package/src/expr/globals.ts +118 -0
- package/src/expr/safer-eval.ts +233 -0
- package/src/expr/validate.ts +149 -0
- package/src/index.ts +28 -0
- package/src/prompt/get-common-instructions-partial-prompt.ts +20 -0
- package/src/prompt/get-components-partial-prompt.ts +153 -0
- package/src/prompt/get-edit-request-prompt.ts +37 -0
- package/src/prompt/get-error-recovery-prompt.ts +37 -0
- package/src/prompt/get-expressions-partial-prompt.ts +33 -0
- package/src/prompt/get-functions-partial-prompt.ts +62 -0
- package/src/prompt/get-scope-partial-prompt.ts +57 -0
- package/src/prompt/index.ts +32 -0
- package/src/prompt/md/EXPRESSIONS.json +1 -0
- package/src/prompt/md/EXPRESSIONS.md +29 -0
- package/src/prompt/md/INSTRUCTIONS.json +1 -0
- package/src/prompt/md/INSTRUCTIONS.md +172 -0
- package/src/prompt-utils/json-schema-to-ts.ts +225 -0
- package/src/scope/create-proxy-scope.ts +131 -0
- package/src/scope/parse-scope.ts +36 -0
- package/src/stream/stream-json-lines.ts +56 -0
- package/src/types.ts +124 -0
- package/src/utils/utils.ts +64 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this package will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- Initial public beta of the framework-agnostic uicast engine: entry format, sandboxed JavaScript expressions, reactive scopes, and prompt partial builders.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Andrey Gubanov
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Coming soon.
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@uicast/core",
|
|
3
|
+
"version": "0.0.1-beta.1",
|
|
4
|
+
"description": "LLM-generated UIs from a stream of JSONLines — the framework-agnostic uicast engine: entry format, sandboxed expressions, reactive scopes, and prompt builders.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/finom/uicast.git",
|
|
9
|
+
"directory": "packages/core"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/finom/uicast#readme",
|
|
12
|
+
"keywords": [
|
|
13
|
+
"generative-ui",
|
|
14
|
+
"llm",
|
|
15
|
+
"jsonlines",
|
|
16
|
+
"streaming",
|
|
17
|
+
"ui",
|
|
18
|
+
"agent",
|
|
19
|
+
"component-catalog"
|
|
20
|
+
],
|
|
21
|
+
"type": "module",
|
|
22
|
+
"exports": {
|
|
23
|
+
".": "./src/index.ts",
|
|
24
|
+
"./prompt": "./src/prompt/index.ts"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"src",
|
|
28
|
+
"!src/**/test/**",
|
|
29
|
+
"LICENSE",
|
|
30
|
+
"CHANGELOG.md"
|
|
31
|
+
],
|
|
32
|
+
"scripts": {
|
|
33
|
+
"test": "vitest run",
|
|
34
|
+
"test:watch": "vitest",
|
|
35
|
+
"test:coverage": "vitest run --coverage",
|
|
36
|
+
"typecheck": "tsc --noEmit",
|
|
37
|
+
"npm-publish": "if [ -z \"$NPM_TAG\" ]; then echo 'Error: NPM_TAG is not set'; exit 1; fi; npm publish --tag=$NPM_TAG"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"@standard-schema/spec": "^1.1.0",
|
|
41
|
+
"acorn": "^8.16.0",
|
|
42
|
+
"standard-tool": "^0.0.6"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@vitest/coverage-v8": "^4.1.0",
|
|
46
|
+
"vitest": "^4.1.0",
|
|
47
|
+
"zod": "~4.3.6"
|
|
48
|
+
},
|
|
49
|
+
"publishConfig": {
|
|
50
|
+
"access": "public"
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { CombinedSpec, ComponentDefinition } from "../types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Value-side constructor for a {@link ComponentDefinition} (the type lives in
|
|
5
|
+
* `types.ts`). Infers the concrete `props` / `callbacks` specs at the call site
|
|
6
|
+
* and returns the def unchanged — its purpose is the inference + a single typed
|
|
7
|
+
* authoring shape; `createComponentImplementation` later reads those inferred
|
|
8
|
+
* types off the returned def.
|
|
9
|
+
*/
|
|
10
|
+
export const createComponentDefinition = <
|
|
11
|
+
TProps extends CombinedSpec,
|
|
12
|
+
TCallbacks extends Record<string, CombinedSpec> = Record<string, never>,
|
|
13
|
+
>({
|
|
14
|
+
name,
|
|
15
|
+
description,
|
|
16
|
+
props,
|
|
17
|
+
callbacks,
|
|
18
|
+
hidden,
|
|
19
|
+
}: ComponentDefinition<TProps, TCallbacks>): ComponentDefinition<TProps, TCallbacks> => {
|
|
20
|
+
return { name, description, props, callbacks, hidden };
|
|
21
|
+
};
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// The engine's classified error: every failure surfaced to the host — through
|
|
2
|
+
// the error slot or the Renderer's `onError` — is an EntryError. Classification
|
|
3
|
+
// happens at the throw site (by provenance: whose code raised it), never by
|
|
4
|
+
// inspecting messages. Reason names state the verdict, not the location.
|
|
5
|
+
|
|
6
|
+
export type EntryErrorReason =
|
|
7
|
+
/** The expression doesn't parse. */
|
|
8
|
+
| "expression-syntax"
|
|
9
|
+
/** The expression uses syntax or reaches an API the sandbox blocks. */
|
|
10
|
+
| "sandbox-violation"
|
|
11
|
+
/** The expression references an identifier that doesn't exist — an unregistered function, a blocked-by-omission global, or an unknown scope path target. */
|
|
12
|
+
| "unknown-reference"
|
|
13
|
+
/** The element's `component` name has no implementation in the registry. */
|
|
14
|
+
| "unknown-component"
|
|
15
|
+
/** A list's `each` did not evaluate to an array, or a non-list element was rendered as a list. */
|
|
16
|
+
| "invalid-list"
|
|
17
|
+
/** The implementation's render threw, and the evaluated props FAIL the def's schema — the document sent a shape the contract forbids. */
|
|
18
|
+
| "invalid-props"
|
|
19
|
+
/** A host function rejected the arguments the document called it with (host functions throw this deliberately). */
|
|
20
|
+
| "invalid-arguments"
|
|
21
|
+
/** A host function threw or rejected while executing — server/host failure. */
|
|
22
|
+
| "host-function"
|
|
23
|
+
/** The host `init` callback threw or rejected. */
|
|
24
|
+
| "host-init"
|
|
25
|
+
/** The implementation's render threw on props its own def schema accepts — an implementation bug. */
|
|
26
|
+
| "implementation"
|
|
27
|
+
/** A valid expression threw at runtime (e.g. a property read through null) — the document wrote the path, but the host may have written the value. */
|
|
28
|
+
| "expression-runtime"
|
|
29
|
+
/** Escaped from an uninstrumented path — the defensive default. */
|
|
30
|
+
| "unknown";
|
|
31
|
+
|
|
32
|
+
export type EntryFault = "document" | "environment" | "unknown";
|
|
33
|
+
|
|
34
|
+
// The single source of truth `fault` derives from — every reason maps to
|
|
35
|
+
// exactly one fault; hosts pick a side by picking the reason.
|
|
36
|
+
const FAULT_BY_REASON: Record<EntryErrorReason, EntryFault> = {
|
|
37
|
+
"expression-syntax": "document",
|
|
38
|
+
"sandbox-violation": "document",
|
|
39
|
+
"unknown-reference": "document",
|
|
40
|
+
"unknown-component": "document",
|
|
41
|
+
"invalid-list": "document",
|
|
42
|
+
"invalid-props": "document",
|
|
43
|
+
"invalid-arguments": "document",
|
|
44
|
+
"host-function": "environment",
|
|
45
|
+
"host-init": "environment",
|
|
46
|
+
implementation: "environment",
|
|
47
|
+
"expression-runtime": "unknown",
|
|
48
|
+
unknown: "unknown",
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export class EntryError extends Error {
|
|
52
|
+
readonly reason: EntryErrorReason;
|
|
53
|
+
/** The element the failure belongs to; the error boundary fills it in when the throw site couldn't. */
|
|
54
|
+
elementKey?: string;
|
|
55
|
+
// Brand marker: with git-dep consumption two copies of core can coexist in
|
|
56
|
+
// one bundle, where `instanceof` silently fails — `EntryError.is()` checks
|
|
57
|
+
// this instead.
|
|
58
|
+
readonly uicastEntryError = true;
|
|
59
|
+
|
|
60
|
+
constructor(
|
|
61
|
+
message: string,
|
|
62
|
+
options: { reason: EntryErrorReason; elementKey?: string; cause?: unknown },
|
|
63
|
+
) {
|
|
64
|
+
super(
|
|
65
|
+
message,
|
|
66
|
+
options.cause !== undefined ? { cause: options.cause } : undefined,
|
|
67
|
+
);
|
|
68
|
+
this.name = "EntryError";
|
|
69
|
+
this.reason = options.reason;
|
|
70
|
+
this.elementKey = options.elementKey;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Coarse verdict for recovery flows: `document` → ask the model to re-emit; `environment` → the host's problem; `unknown` → undecidable. */
|
|
74
|
+
get fault(): EntryFault {
|
|
75
|
+
return FAULT_BY_REASON[this.reason];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Cross-copy-safe `instanceof`. */
|
|
79
|
+
static is(err: unknown): err is EntryError {
|
|
80
|
+
return (
|
|
81
|
+
typeof err === "object" &&
|
|
82
|
+
err !== null &&
|
|
83
|
+
(err as { uicastEntryError?: unknown }).uicastEntryError === true
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Classify `err` as `reason`, keeping the original in `cause`. An error that
|
|
89
|
+
* is already an EntryError passes through untouched (the deepest — most
|
|
90
|
+
* precise — classification wins); a missing `elementKey` is filled in.
|
|
91
|
+
*/
|
|
92
|
+
static wrap(
|
|
93
|
+
err: unknown,
|
|
94
|
+
reason: EntryErrorReason,
|
|
95
|
+
elementKey?: string,
|
|
96
|
+
): EntryError {
|
|
97
|
+
if (EntryError.is(err)) {
|
|
98
|
+
if (elementKey && !err.elementKey) err.elementKey = elementKey;
|
|
99
|
+
return err;
|
|
100
|
+
}
|
|
101
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
102
|
+
return new EntryError(message, { reason, elementKey, cause: err });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
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
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
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
|
+
];
|