@uicast/core 0.0.1-beta.1 → 0.0.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.
Files changed (89) hide show
  1. package/CHANGELOG.md +3 -6
  2. package/README.md +120 -1
  3. package/dist/build-elements-by-key.d.ts +13 -0
  4. package/dist/build-elements-by-key.js +39 -0
  5. package/dist/constants.d.ts +2 -0
  6. package/dist/constants.js +4 -0
  7. package/dist/create-component-definition.d.ts +26 -0
  8. package/dist/create-component-definition.js +74 -0
  9. package/dist/entry-error.d.ts +57 -0
  10. package/dist/entry-error.js +85 -0
  11. package/dist/entry-shape.d.ts +7 -0
  12. package/dist/entry-shape.js +88 -0
  13. package/dist/expr/evaluate.d.ts +5 -0
  14. package/dist/expr/evaluate.js +54 -0
  15. package/dist/expr/extract-deps.d.ts +4 -0
  16. package/dist/expr/extract-deps.js +30 -0
  17. package/dist/expr/plan-step-waves.d.ts +8 -0
  18. package/dist/expr/plan-step-waves.js +45 -0
  19. package/dist/index.d.ts +8 -0
  20. package/dist/index.js +6 -0
  21. package/dist/internal.d.ts +12 -0
  22. package/dist/internal.js +13 -0
  23. package/dist/json-schema.d.ts +58 -0
  24. package/dist/json-schema.js +73 -0
  25. package/dist/prompt/format.d.ts +5 -0
  26. package/dist/prompt/format.js +22 -0
  27. package/dist/prompt/get-common-instructions-partial-prompt.d.ts +23 -0
  28. package/dist/prompt/get-common-instructions-partial-prompt.js +16 -0
  29. package/dist/prompt/get-components-partial-prompt.d.ts +27 -0
  30. package/dist/prompt/get-components-partial-prompt.js +133 -0
  31. package/dist/prompt/get-edit-request-prompt.d.ts +23 -0
  32. package/dist/prompt/get-edit-request-prompt.js +15 -0
  33. package/dist/prompt/get-error-recovery-prompt.d.ts +35 -0
  34. package/dist/prompt/get-error-recovery-prompt.js +26 -0
  35. package/dist/prompt/get-expressions-partial-prompt.d.ts +23 -0
  36. package/dist/prompt/get-expressions-partial-prompt.js +17 -0
  37. package/dist/prompt/get-functions-partial-prompt.d.ts +22 -0
  38. package/dist/prompt/get-functions-partial-prompt.js +58 -0
  39. package/dist/prompt/get-scope-partial-prompt.d.ts +24 -0
  40. package/dist/prompt/get-scope-partial-prompt.js +30 -0
  41. package/dist/prompt/index.d.ts +7 -0
  42. package/dist/prompt/index.js +7 -0
  43. package/dist/prompt/json-schema-to-ts.d.ts +6 -0
  44. package/dist/prompt/json-schema-to-ts.js +146 -0
  45. package/dist/prompt/md/EXPRESSIONS.json +1 -0
  46. package/dist/prompt/md/INSTRUCTIONS.json +1 -0
  47. package/dist/prompt/shared-types.d.ts +5 -0
  48. package/dist/prompt/shared-types.js +82 -0
  49. package/dist/scope/create-proxy-scope.d.ts +55 -0
  50. package/dist/scope/create-proxy-scope.js +295 -0
  51. package/dist/scope/parse-scope.d.ts +2 -0
  52. package/dist/scope/parse-scope.js +11 -0
  53. package/dist/scope/parse-set-address.d.ts +16 -0
  54. package/dist/scope/parse-set-address.js +52 -0
  55. package/dist/stream-json-lines.d.ts +14 -0
  56. package/dist/stream-json-lines.js +59 -0
  57. package/dist/types.d.ts +124 -0
  58. package/dist/types.js +19 -0
  59. package/dist/url-policy.d.ts +45 -0
  60. package/dist/url-policy.js +252 -0
  61. package/package.json +27 -18
  62. package/src/def/create-component-definition.ts +0 -21
  63. package/src/entry-error.ts +0 -104
  64. package/src/expr/analyze.ts +0 -190
  65. package/src/expr/ast-utils.ts +0 -28
  66. package/src/expr/evaluate.ts +0 -74
  67. package/src/expr/extract-deps.ts +0 -32
  68. package/src/expr/globals.ts +0 -118
  69. package/src/expr/safer-eval.ts +0 -233
  70. package/src/expr/validate.ts +0 -149
  71. package/src/index.ts +0 -28
  72. package/src/prompt/get-common-instructions-partial-prompt.ts +0 -20
  73. package/src/prompt/get-components-partial-prompt.ts +0 -153
  74. package/src/prompt/get-edit-request-prompt.ts +0 -37
  75. package/src/prompt/get-error-recovery-prompt.ts +0 -37
  76. package/src/prompt/get-expressions-partial-prompt.ts +0 -33
  77. package/src/prompt/get-functions-partial-prompt.ts +0 -62
  78. package/src/prompt/get-scope-partial-prompt.ts +0 -57
  79. package/src/prompt/index.ts +0 -32
  80. package/src/prompt/md/EXPRESSIONS.json +0 -1
  81. package/src/prompt/md/EXPRESSIONS.md +0 -29
  82. package/src/prompt/md/INSTRUCTIONS.json +0 -1
  83. package/src/prompt/md/INSTRUCTIONS.md +0 -172
  84. package/src/prompt-utils/json-schema-to-ts.ts +0 -225
  85. package/src/scope/create-proxy-scope.ts +0 -131
  86. package/src/scope/parse-scope.ts +0 -36
  87. package/src/stream/stream-json-lines.ts +0 -56
  88. package/src/types.ts +0 -124
  89. package/src/utils/utils.ts +0 -64
@@ -0,0 +1,252 @@
1
+ import { isSchemaObject, resolvePointer } from "./json-schema.js";
2
+ // The defaults, in one place: the check applies them and the prompt describes them.
3
+ export const resolveUrlPolicy = (policy = {}) => {
4
+ const { allowRelative = true, allowSameOrigin = true, hosts = [], allowDataImages = true, origin } = policy;
5
+ return { allowRelative, allowSameOrigin, hosts, allowDataImages, origin };
6
+ };
7
+ const OK = { ok: true };
8
+ // Never fetch on their own.
9
+ const INERT_SCHEMES = new Set(["mailto", "tel", "sms", "blob"]);
10
+ // No `svg+xml`: SVG can carry script.
11
+ const DATA_IMAGE_RE = /^data:image\/(png|jpe?g|gif|webp|avif|bmp|x-icon|vnd\.microsoft\.icon)[;,]/i;
12
+ const SCHEME_RE = /^([a-z][a-z0-9+.-]*):/i;
13
+ // For http(s) the URL parser reads `\` as `/`, so a value with no scheme names a host exactly when it starts with two.
14
+ const NAMES_HOST_RE = /^[\\/]{2}/;
15
+ // Supplies only the scheme: a value that names a host keeps its own.
16
+ const NETWORK_PATH_BASE = "https://base.invalid/";
17
+ // Strip what the URL parser strips, or `"java\nscript:"` reads as relative here and `javascript:` in the DOM.
18
+ const normalize = (raw) => {
19
+ const url = raw.replace(/[\t\n\r]/g, "");
20
+ // A loop, not a regex: a trailing `[\0- ]+$` takes quadratic time on a long run of spaces.
21
+ let start = 0;
22
+ let end = url.length;
23
+ while (start < end && url.charCodeAt(start) <= 0x20)
24
+ start++;
25
+ while (end > start && url.charCodeAt(end - 1) <= 0x20)
26
+ end--;
27
+ return url.slice(start, end);
28
+ };
29
+ const parseUrl = (url, base) => {
30
+ try {
31
+ return new URL(url, base);
32
+ }
33
+ catch {
34
+ return null;
35
+ }
36
+ };
37
+ const hostAllowed = (host, hosts) => {
38
+ const lower = host.toLowerCase();
39
+ return hosts.some((entry) => {
40
+ const pattern = entry.toLowerCase();
41
+ if (pattern.startsWith("*.")) {
42
+ return lower.endsWith(pattern.slice(1)) && lower !== pattern.slice(2);
43
+ }
44
+ return lower === pattern;
45
+ });
46
+ };
47
+ // Parsed once per string: an explicit origin with a path or a trailing slash still names its origin.
48
+ const explicitOrigins = new Map();
49
+ const currentOrigin = (explicit) => {
50
+ if (!explicit)
51
+ return typeof location !== "undefined" ? location.origin : null;
52
+ let origin = explicitOrigins.get(explicit);
53
+ if (origin === undefined) {
54
+ origin = parseUrl(explicit)?.origin ?? null;
55
+ explicitOrigins.set(explicit, origin);
56
+ }
57
+ return origin;
58
+ };
59
+ const checkHost = (url, allowSameOrigin, explicitOrigin, hosts) => {
60
+ if (allowSameOrigin && url.origin === currentOrigin(explicitOrigin))
61
+ return OK;
62
+ if (hosts.length && hostAllowed(url.hostname, hosts))
63
+ return OK;
64
+ return {
65
+ ok: false,
66
+ reason: `"${url.hostname}" is not an allowed host. Add it to the renderer's ` +
67
+ `urlPolicy ({ hosts: ["${url.hostname}"] }) if this destination is trusted`,
68
+ };
69
+ };
70
+ // An empty string passes: nothing to fetch.
71
+ export const checkUrl = (value, policy) => {
72
+ const url = normalize(value);
73
+ if (!url)
74
+ return OK;
75
+ if (typeof policy === "function") {
76
+ return policy(url) ? OK : { ok: false, reason: "rejected by the host's urlPolicy predicate" };
77
+ }
78
+ const { allowRelative, allowSameOrigin, hosts, allowDataImages, origin: explicitOrigin } = resolveUrlPolicy(policy);
79
+ const scheme = SCHEME_RE.exec(url)?.[1]?.toLowerCase() ?? null;
80
+ if (scheme === null) {
81
+ if (NAMES_HOST_RE.test(url)) {
82
+ const resolved = parseUrl(url, NETWORK_PATH_BASE);
83
+ if (!resolved)
84
+ return { ok: false, reason: "not a parseable URL" };
85
+ return checkHost(resolved, allowSameOrigin, explicitOrigin, hosts);
86
+ }
87
+ return allowRelative ? OK : { ok: false, reason: "relative URLs are not allowed by this urlPolicy" };
88
+ }
89
+ if (scheme === "data") {
90
+ if (allowDataImages && DATA_IMAGE_RE.test(url))
91
+ return OK;
92
+ return {
93
+ ok: false,
94
+ reason: DATA_IMAGE_RE.test(url)
95
+ ? "data: URLs are not allowed by this urlPolicy"
96
+ : 'only "data:image/<raster>" URLs are allowed (svg+xml can carry script)',
97
+ };
98
+ }
99
+ if (INERT_SCHEMES.has(scheme))
100
+ return OK;
101
+ if (scheme !== "http" && scheme !== "https") {
102
+ return { ok: false, reason: `the "${scheme}:" scheme is not allowed` };
103
+ }
104
+ const parsed = parseUrl(url);
105
+ if (!parsed)
106
+ return { ok: false, reason: "not a parseable absolute URL" };
107
+ return checkHost(parsed, allowSameOrigin, explicitOrigin, hosts);
108
+ };
109
+ const URL_FORMATS = new Set(["uri", "url", "uri-reference", "iri", "iri-reference"]);
110
+ const hasUrlFormat = (node) => {
111
+ const format = Object.hasOwn(node, "format") ? Reflect.get(node, "format") : undefined;
112
+ return typeof format === "string" && URL_FORMATS.has(format);
113
+ };
114
+ const indexes = new WeakMap();
115
+ const indexOf = (root) => {
116
+ let index = indexes.get(root);
117
+ if (!index) {
118
+ index = { root, reach: new Map(), applicable: new Map() };
119
+ indexes.set(root, index);
120
+ }
121
+ return index;
122
+ };
123
+ // Whether a URL format sits at or below `start`, through any keyword and any `$ref` chain. A stack, so depth sets no limit.
124
+ const reachesUrlFormat = (index, start) => {
125
+ if (typeof start !== "object" || start === null)
126
+ return false;
127
+ const cached = index.reach.get(start);
128
+ if (cached !== undefined)
129
+ return cached;
130
+ const seen = new Set();
131
+ const stack = [start];
132
+ let found = false;
133
+ while (!found && stack.length > 0) {
134
+ const node = stack.pop();
135
+ if (typeof node !== "object" || node === null || seen.has(node))
136
+ continue;
137
+ seen.add(node);
138
+ found = hasUrlFormat(node);
139
+ for (const [key, child] of Object.entries(node)) {
140
+ stack.push(key === "$ref" && typeof child === "string" ? resolvePointer(child, index.root) : child);
141
+ }
142
+ }
143
+ index.reach.set(start, found);
144
+ return found;
145
+ };
146
+ // The nodes that hold for one value: `start` and every applicator under it that keeps the value.
147
+ // All but `not` (a URL format under it forbids a URL); one that holds for only some values counts for all.
148
+ const applicableNodes = (index, start) => {
149
+ const cached = index.applicable.get(start);
150
+ if (cached)
151
+ return cached;
152
+ const out = new Set();
153
+ const stack = [start];
154
+ while (stack.length > 0) {
155
+ const node = stack.pop();
156
+ if (!isSchemaObject(node) || out.has(node) || !reachesUrlFormat(index, node))
157
+ continue;
158
+ out.add(node);
159
+ if (typeof node.$ref === "string")
160
+ stack.push(resolvePointer(node.$ref, index.root));
161
+ stack.push(...(node.allOf ?? []), ...(node.anyOf ?? []), ...(node.oneOf ?? []));
162
+ stack.push(node.if, node.then, node.else, ...Object.values(node.dependentSchemas ?? {}));
163
+ }
164
+ const nodes = [...out];
165
+ index.applicable.set(start, nodes);
166
+ return nodes;
167
+ };
168
+ const pathOf = (visit) => {
169
+ let path = "";
170
+ for (let at = visit; at?.parent; at = at.parent) {
171
+ const step = typeof at.key === "number" ? `[${at.key}]` : `.${at.key}`;
172
+ path = step + path;
173
+ }
174
+ return path.startsWith(".") ? path.slice(1) : path;
175
+ };
176
+ // A subschema with no URL format below it is never walked, so a URL-free table costs nothing.
177
+ const pushVisit = (stack, index, node, value, parent, key) => {
178
+ if (isSchemaObject(node) && reachesUrlFormat(index, node))
179
+ stack.push({ node, value, parent, key });
180
+ };
181
+ // Only declared URL props are checked: a URL-looking `text` prop is content.
182
+ export const findUrlViolations = (schema, value, policy) => {
183
+ if (!schema)
184
+ return [];
185
+ const index = indexOf(schema);
186
+ if (!reachesUrlFormat(index, schema))
187
+ return [];
188
+ const out = [];
189
+ const flagged = new Set();
190
+ // Per object, the nodes already applied: a shared or cyclic value is walked once per node.
191
+ const walked = new WeakMap();
192
+ // Children are pushed last to first, so values are checked, and violations reported, in document order.
193
+ const stack = [{ node: schema, value, parent: null, key: "" }];
194
+ for (let visit = stack.pop(); visit; visit = stack.pop()) {
195
+ const val = visit.value;
196
+ const nodes = applicableNodes(index, visit.node);
197
+ if (typeof val === "string") {
198
+ if (!nodes.some(hasUrlFormat))
199
+ continue;
200
+ const result = checkUrl(val, policy);
201
+ if (result.ok)
202
+ continue;
203
+ const path = pathOf(visit);
204
+ if (!flagged.has(path)) {
205
+ flagged.add(path);
206
+ out.push({ path, url: val, reason: result.reason });
207
+ }
208
+ continue;
209
+ }
210
+ if (typeof val !== "object" || val === null)
211
+ continue;
212
+ let done = walked.get(val);
213
+ if (!done) {
214
+ done = new Set();
215
+ walked.set(val, done);
216
+ }
217
+ for (let n = nodes.length - 1; n >= 0; n--) {
218
+ const node = nodes[n];
219
+ if (done.has(node))
220
+ continue;
221
+ done.add(node);
222
+ if (Array.isArray(val)) {
223
+ const tuple = node.prefixItems ?? [];
224
+ for (let i = val.length - 1; i >= 0; i--) {
225
+ pushVisit(stack, index, node.unevaluatedItems, val[i], visit, i);
226
+ pushVisit(stack, index, node.contains, val[i], visit, i);
227
+ pushVisit(stack, index, i < tuple.length ? tuple[i] : node.items, val[i], visit, i);
228
+ }
229
+ continue;
230
+ }
231
+ const declared = node.properties ?? {};
232
+ // Patterns are not matched: each one applies to every key.
233
+ const patterns = Object.values(node.patternProperties ?? {});
234
+ const entries = Object.entries(val);
235
+ for (let e = entries.length - 1; e >= 0; e--) {
236
+ const [key, child] = entries[e];
237
+ if (Object.hasOwn(declared, key)) {
238
+ pushVisit(stack, index, declared[key], child, visit, key);
239
+ }
240
+ else {
241
+ pushVisit(stack, index, node.unevaluatedProperties, child, visit, key);
242
+ pushVisit(stack, index, node.additionalProperties, child, visit, key);
243
+ }
244
+ for (const sub of patterns)
245
+ pushVisit(stack, index, sub, child, visit, key);
246
+ pushVisit(stack, index, node.propertyNames, key, visit, key);
247
+ }
248
+ }
249
+ }
250
+ return out;
251
+ };
252
+ export const schemaHasUrlFormat = (schema) => schema !== undefined && reachesUrlFormat(indexOf(schema), schema);
package/package.json CHANGED
@@ -1,18 +1,18 @@
1
1
  {
2
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.",
3
+ "version": "0.0.1",
4
+ "description": "The framework-agnostic engine of uicast, the expression-driven generative UI framework: entry format, guarded expressions (allow-list, not a sandbox), reactive scopes, and prompt builders.",
5
5
  "license": "MIT",
6
+ "author": "Andrey Gubanov (https://github.com/finom)",
6
7
  "repository": {
7
8
  "type": "git",
8
9
  "url": "git+https://github.com/finom/uicast.git",
9
10
  "directory": "packages/core"
10
11
  },
11
- "homepage": "https://github.com/finom/uicast#readme",
12
+ "homepage": "https://uicast.dev",
12
13
  "keywords": [
13
14
  "generative-ui",
14
15
  "llm",
15
- "jsonlines",
16
16
  "streaming",
17
17
  "ui",
18
18
  "agent",
@@ -20,33 +20,42 @@
20
20
  ],
21
21
  "type": "module",
22
22
  "exports": {
23
- ".": "./src/index.ts",
24
- "./prompt": "./src/prompt/index.ts"
23
+ ".": {
24
+ "types": "./dist/index.d.ts",
25
+ "default": "./dist/index.js"
26
+ },
27
+ "./internal": {
28
+ "types": "./dist/internal.d.ts",
29
+ "default": "./dist/internal.js"
30
+ },
31
+ "./prompt": {
32
+ "types": "./dist/prompt/index.d.ts",
33
+ "default": "./dist/prompt/index.js"
34
+ }
25
35
  },
26
36
  "files": [
27
- "src",
28
- "!src/**/test/**",
37
+ "dist",
29
38
  "LICENSE",
30
39
  "CHANGELOG.md"
31
40
  ],
32
41
  "scripts": {
42
+ "build": "rm -rf dist && tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json",
43
+ "prepublishOnly": "node -e \"if (!process.env.CI) throw new Error('Publish from CI: PACKAGE=<dir> npm run patch or minor tags a release, and the tag publishes it')\" && npm run build",
33
44
  "test": "vitest run",
34
45
  "test:watch": "vitest",
35
46
  "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"
47
+ "typecheck": "tsc --noEmit"
43
48
  },
44
49
  "devDependencies": {
45
- "@vitest/coverage-v8": "^4.1.0",
46
- "vitest": "^4.1.0",
47
- "zod": "~4.3.6"
50
+ "@vitest/coverage-v8": "^5.0.2",
51
+ "standard-tool": "^0.1.0",
52
+ "vitest": "^5.0.1",
53
+ "zod": "~4.6.5"
48
54
  },
49
55
  "publishConfig": {
50
56
  "access": "public"
57
+ },
58
+ "peerDependencies": {
59
+ "@uicast/expr": "^0.0.1"
51
60
  }
52
61
  }
@@ -1,21 +0,0 @@
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
- };
@@ -1,104 +0,0 @@
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
- }
@@ -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
- }