@uicast/core 0.0.1-beta.2 → 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 (78) hide show
  1. package/CHANGELOG.md +3 -13
  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 +41 -31
  10. package/dist/entry-error.js +49 -20
  11. package/dist/entry-shape.d.ts +7 -0
  12. package/dist/entry-shape.js +88 -0
  13. package/dist/expr/evaluate.d.ts +4 -6
  14. package/dist/expr/evaluate.js +36 -46
  15. package/dist/expr/extract-deps.d.ts +3 -1
  16. package/dist/expr/extract-deps.js +20 -20
  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 +7 -8
  20. package/dist/index.js +5 -9
  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 +20 -10
  28. package/dist/prompt/get-common-instructions-partial-prompt.js +11 -9
  29. package/dist/prompt/get-components-partial-prompt.d.ts +20 -28
  30. package/dist/prompt/get-components-partial-prompt.js +122 -123
  31. package/dist/prompt/get-edit-request-prompt.d.ts +16 -16
  32. package/dist/prompt/get-edit-request-prompt.js +11 -18
  33. package/dist/prompt/get-error-recovery-prompt.d.ts +25 -14
  34. package/dist/prompt/get-error-recovery-prompt.js +17 -14
  35. package/dist/prompt/get-expressions-partial-prompt.d.ts +19 -20
  36. package/dist/prompt/get-expressions-partial-prompt.js +11 -21
  37. package/dist/prompt/get-functions-partial-prompt.d.ts +16 -16
  38. package/dist/prompt/get-functions-partial-prompt.js +52 -40
  39. package/dist/prompt/get-scope-partial-prompt.d.ts +17 -17
  40. package/dist/prompt/get-scope-partial-prompt.js +21 -29
  41. package/dist/prompt/index.js +0 -2
  42. package/dist/prompt/json-schema-to-ts.d.ts +6 -0
  43. package/dist/prompt/json-schema-to-ts.js +146 -0
  44. package/dist/prompt/md/EXPRESSIONS.json +1 -1
  45. package/dist/prompt/md/INSTRUCTIONS.json +1 -1
  46. package/dist/prompt/shared-types.d.ts +5 -0
  47. package/dist/prompt/shared-types.js +82 -0
  48. package/dist/scope/create-proxy-scope.d.ts +52 -17
  49. package/dist/scope/create-proxy-scope.js +280 -86
  50. package/dist/scope/parse-scope.d.ts +2 -20
  51. package/dist/scope/parse-scope.js +10 -35
  52. package/dist/scope/parse-set-address.d.ts +16 -0
  53. package/dist/scope/parse-set-address.js +52 -0
  54. package/dist/stream-json-lines.d.ts +14 -0
  55. package/dist/stream-json-lines.js +59 -0
  56. package/dist/types.d.ts +81 -46
  57. package/dist/types.js +6 -9
  58. package/dist/url-policy.d.ts +45 -0
  59. package/dist/url-policy.js +252 -0
  60. package/package.json +18 -16
  61. package/dist/def/create-component-definition.d.ts +0 -9
  62. package/dist/def/create-component-definition.js +0 -10
  63. package/dist/expr/analyze.d.ts +0 -5
  64. package/dist/expr/analyze.js +0 -189
  65. package/dist/expr/ast-utils.d.ts +0 -4
  66. package/dist/expr/ast-utils.js +0 -23
  67. package/dist/expr/globals.d.ts +0 -3
  68. package/dist/expr/globals.js +0 -111
  69. package/dist/expr/safer-eval.d.ts +0 -21
  70. package/dist/expr/safer-eval.js +0 -170
  71. package/dist/expr/validate.d.ts +0 -7
  72. package/dist/expr/validate.js +0 -109
  73. package/dist/prompt-utils/json-schema-to-ts.d.ts +0 -73
  74. package/dist/prompt-utils/json-schema-to-ts.js +0 -199
  75. package/dist/stream/stream-json-lines.d.ts +0 -1
  76. package/dist/stream/stream-json-lines.js +0 -94
  77. package/dist/utils/utils.d.ts +0 -13
  78. package/dist/utils/utils.js +0 -58
package/CHANGELOG.md CHANGED
@@ -2,18 +2,8 @@
2
2
 
3
3
  All notable changes to this package will be documented in this file.
4
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).
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
6
 
8
- ## [Unreleased]
7
+ ## [0.0.1] - 2026-09-30
9
8
 
10
- ### Added
11
-
12
- - Initial public beta of the framework-agnostic uicast engine: entry format, sandboxed JavaScript expressions, reactive scopes, and prompt partial builders.
13
- - Prompt signatures carry schema descriptions: any described field (Zod `.describe()` / `.meta({ description })`) renders inline as `type /* description */`, at every nesting level — objects, arrays, enums, tuples, unions, `$ref`s.
14
- - Function signatures in the prompt render multiline, one field per line, via `JSONSchemaToTs`'s new `multiline` option.
15
-
16
- ### Changed
17
-
18
- - Reactive writes wake descendant-path subscribers: replacing `products` re-renders a reader of `products.length`. The reverse stays exact.
19
- - The contract no longer mentions a `deps` array — the runtime extracts the read set from expression text.
9
+ The first release. Earlier changes are in the [pre-release changelog](https://github.com/finom/uicast/blob/a2eaa7703496f7679efd56f5309ee08a1dd4209b/packages/core/CHANGELOG.md).
package/README.md CHANGED
@@ -1 +1,120 @@
1
- Coming soon.
1
+ <h1 align="center">@uicast/core</h1>
2
+ <p align="center">Part of <a href="https://github.com/finom/uicast"><strong>uicast</strong></a>, the expression-driven generative UI framework.</p>
3
+ <p align="center"><a href="https://uicast.dev">uicast.dev</a></p>
4
+ <p align="center"><a href="https://www.npmjs.com/package/@uicast/core"><img src="https://img.shields.io/npm/v/@uicast/core.svg?color=brightgreen" alt="npm version"></a> <a href="https://scorecard.dev/viewer/?uri=github.com/finom/uicast"><img src="https://api.scorecard.dev/projects/github.com/finom/uicast/badge" alt="OpenSSF Scorecard"></a> <a href="https://www.bestpractices.dev/projects/15106"><img src="https://www.bestpractices.dev/projects/15106/badge" alt="OpenSSF Best Practices"></a> <a href="https://github.com/finom/uicast/actions/workflows/ci.yml"><img src="https://github.com/finom/uicast/actions/workflows/ci.yml/badge.svg" alt="CI"></a></p>
5
+
6
+ The engine of **uicast**, without React: component definitions, the entry format, reactive scopes, error classification and the prompt builders. [`@uicast/react`](https://www.npmjs.com/package/@uicast/react) renders on top of it.
7
+
8
+ ```sh
9
+ npm install @uicast/core @uicast/expr
10
+ ```
11
+
12
+ ## Define a component
13
+
14
+ A definition is what the model reads about a component. It imports no React, so the prompt can be built on the server.
15
+
16
+ ```ts
17
+ import { createComponentDefinition } from "@uicast/core";
18
+ import z from "zod";
19
+
20
+ export const StatDef = createComponentDefinition({
21
+ name: "Stat",
22
+ description: "One number with a label. Use it for a KPI, not for a table cell.",
23
+ props: z.strictObject({
24
+ label: z.string().meta({ description: "What the number means" }),
25
+ value: z.number().meta({ description: "The number itself" }),
26
+ }),
27
+ });
28
+ ```
29
+
30
+ `props` and each payload in `callbacks` are schemas that implement [Standard Schema](https://standardschema.dev/schema) and [Standard JSON Schema](https://standardschema.dev/json-schema): Zod 4.2+, Valibot or ArkType. Every `description` reaches the model word for word.
31
+
32
+ ## Build the prompt
33
+
34
+ ```ts
35
+ import {
36
+ getCommonInstructionsPartialPrompt,
37
+ getComponentsPartialPrompt,
38
+ getExpressionsPartialPrompt,
39
+ getFunctionsPartialPrompt,
40
+ getScopePartialPrompt,
41
+ } from "@uicast/core/prompt";
42
+
43
+ const system = [
44
+ getCommonInstructionsPartialPrompt(),
45
+ getScopePartialPrompt({ kind: "page" }),
46
+ getComponentsPartialPrompt({ definitions: [StatDef] }),
47
+ getFunctionsPartialPrompt({ functions: tools }),
48
+ getExpressionsPartialPrompt(),
49
+ ].join("\n\n");
50
+ ```
51
+
52
+ Keep this order: each block refers to earlier ones.
53
+
54
+ | Builder | Emits |
55
+ | --- | --- |
56
+ | `getCommonInstructionsPartialPrompt()` | The entry format and its rules. |
57
+ | `getScopePartialPrompt({ kind })` | What one response should be: `"page"`, `"widget"` or `"answer"`. |
58
+ | `getComponentsPartialPrompt({ definitions })` | The components, from your definitions. |
59
+ | `getFunctionsPartialPrompt({ functions })` | The signatures of your [host functions](https://uicast.dev/expr/functions). |
60
+ | `getExpressionsPartialPrompt()` | The expression language. |
61
+
62
+ Each takes `note`: your text, added at the end of its block. Two more builders make a user message for one turn:
63
+
64
+ - `getEditRequestPrompt({ request, missingKeys })` asks for a change to a stored page. The model sends only the entries that change; a line with an existing key replaces that entry. `buildElementsByKey(entries)` applies the same rule to stored lines.
65
+ - `getErrorRecoveryPrompt({ failures })` names the elements that failed, so the model sends them fixed.
66
+
67
+ ## Read the stream
68
+
69
+ `streamJsonLines` yields each line of a stream that parses as JSON, and skips the rest: code fences, prose, a cut-off last line. On the server, store each entry as it arrives:
70
+
71
+ ```ts
72
+ import { isComponentEntry, streamJsonLines } from "@uicast/core";
73
+
74
+ for await (const value of streamJsonLines(result.textStream)) {
75
+ if (isComponentEntry(value)) await saveEntry(value);
76
+ }
77
+ ```
78
+
79
+ In the browser, `streamJsonLines<ComponentEntry>(res.body)` feeds the renderer. See [Streaming](https://uicast.dev/streaming).
80
+
81
+ ## Errors
82
+
83
+ Every element failure is an `EntryError`. Its `fault` says whose code failed: `"document"` means the model wrote the bug, so sending it back can fix it; `"environment"` means your code failed.
84
+
85
+ ```ts
86
+ import type { EntryError } from "@uicast/core";
87
+ import { getErrorRecoveryPrompt } from "@uicast/core/prompt";
88
+
89
+ function onError(error: EntryError) {
90
+ if (error.fault !== "document" || !error.elementKey) return report(error);
91
+ sendUserMessage(
92
+ getErrorRecoveryPrompt({
93
+ failures: [{ key: error.elementKey, message: error.message, reason: error.reason }],
94
+ }),
95
+ );
96
+ }
97
+ ```
98
+
99
+ The renderer calls `onError` once per failure. See [Error recovery](https://uicast.dev/error-recovery).
100
+
101
+ ## Exports
102
+
103
+ | Export | What it does |
104
+ | --- | --- |
105
+ | `createComponentDefinition(def)` | Builds a component definition. |
106
+ | `streamJsonLines(source)` | Yields each JSON line of a byte or text stream. |
107
+ | `isComponentEntry(value)` | Checks that a parsed value is an entry. |
108
+ | `buildElementsByKey(entries)` | Folds entries into a `key → entry` map, applying replacements. |
109
+ | `createProxyScope(initial?)` | A reactive scope, for a [scope your app adds](https://uicast.dev/react/renderer#extra-named-scopes). |
110
+ | `EntryError` | The error every element failure arrives as. |
111
+
112
+ The prompt builders come from `@uicast/core/prompt`.
113
+
114
+ ## Documentation
115
+
116
+ [Concepts](https://uicast.dev/concepts) · [Component definition](https://uicast.dev/def) · [Assembling the prompt](https://uicast.dev/prompt) · [Entry fields](https://uicast.dev/entry) · [Streaming](https://uicast.dev/streaming) · [Error recovery](https://uicast.dev/error-recovery) · [API reference](https://uicast.dev/api-ref#uicastcore)
117
+
118
+ ## License
119
+
120
+ [MIT](https://github.com/finom/uicast/blob/main/LICENSE) © [Andrey Gubanov](https://github.com/finom)
@@ -0,0 +1,13 @@
1
+ import type { ComponentEntry } from "./types.js";
2
+ /**
3
+ * Folds entries into a `key → entry` map, as they render: a re-emitted key replaces its subtree but keeps the old
4
+ * children its new `children` still names.
5
+ *
6
+ * @example
7
+ * const byKey = buildElementsByKey(storedEntries);
8
+ * const current = Object.values(byKey); // without the entries a re-emit dropped
9
+ *
10
+ * @example
11
+ * const missingKeys = Object.values(byKey).flatMap((entry) => entry.children ?? []).filter((key) => !byKey[key]);
12
+ */
13
+ export declare function buildElementsByKey(lines: ComponentEntry[]): Record<string, ComponentEntry>;
@@ -0,0 +1,39 @@
1
+ // The keys reachable from `start` through `children`, optionally only those `within` a set.
2
+ const reachable = (map, start, within) => {
3
+ const out = new Set();
4
+ const stack = [...start];
5
+ for (let key = stack.pop(); key !== undefined; key = stack.pop()) {
6
+ if (out.has(key) || (within && !within.has(key)))
7
+ continue;
8
+ out.add(key);
9
+ stack.push(...(map[key]?.children ?? []));
10
+ }
11
+ return out;
12
+ };
13
+ /**
14
+ * Folds entries into a `key → entry` map, as they render: a re-emitted key replaces its subtree but keeps the old
15
+ * children its new `children` still names.
16
+ *
17
+ * @example
18
+ * const byKey = buildElementsByKey(storedEntries);
19
+ * const current = Object.values(byKey); // without the entries a re-emit dropped
20
+ *
21
+ * @example
22
+ * const missingKeys = Object.values(byKey).flatMap((entry) => entry.children ?? []).filter((key) => !byKey[key]);
23
+ */
24
+ export function buildElementsByKey(lines) {
25
+ // No prototype: keys are model-written, and `__proto__` or `toString` must be plain keys.
26
+ const map = Object.create(null);
27
+ for (const line of lines) {
28
+ const old = map[line.key];
29
+ if (old) {
30
+ const descendants = reachable(map, old.children ?? []);
31
+ const kept = reachable(map, line.children ?? [], descendants);
32
+ for (const key of descendants)
33
+ if (!kept.has(key))
34
+ delete map[key];
35
+ }
36
+ map[line.key] = line;
37
+ }
38
+ return map;
39
+ }
@@ -0,0 +1,2 @@
1
+ export declare const CALLBACK_DEBOUNCE_MS = 300;
2
+ export declare const CONTEXT_NAMES: ReadonlySet<string>;
@@ -0,0 +1,4 @@
1
+ // The prompt tells the model this number too.
2
+ export const CALLBACK_DEBOUNCE_MS = 300;
3
+ // A host function may not take one of these: it would shadow the context.
4
+ export const CONTEXT_NAMES = new Set(["scopes", "evt", "currentValue"]);
@@ -0,0 +1,26 @@
1
+ import type { CombinedSpec, ComponentDefinition } from "./types.js";
2
+ type EmptyProps = Record<string, never>;
3
+ declare const NO_PROPS: CombinedSpec<EmptyProps, EmptyProps>;
4
+ /**
5
+ * Builds a component definition, what the model reads about a component. Throws when `props` or a callback payload
6
+ * declares a `children` field: that name is reserved for child entries.
7
+ *
8
+ * @example
9
+ * export const BadgeDef = createComponentDefinition({
10
+ * name: "Badge",
11
+ * description: "A short status label.",
12
+ * props: z.strictObject({ text: z.string().meta({ description: "The label" }) }),
13
+ * });
14
+ *
15
+ * @example
16
+ * createComponentDefinition({
17
+ * name: "Button", description: "A clickable button.",
18
+ * props: z.strictObject({ text: z.string() }),
19
+ * callbacks: { onClick: z.null() }, // printed as onClick()
20
+ * });
21
+ */
22
+ export declare const createComponentDefinition: <TProps extends CombinedSpec = typeof NO_PROPS, TCallbacks extends Record<string, CombinedSpec> = Record<string, never>>({ name, description, props, callbacks, hidden, }: Omit<ComponentDefinition<TProps, TCallbacks>, "props"> & {
23
+ /** The props schema. Omit it for a component that takes none. */
24
+ props?: TProps;
25
+ }) => ComponentDefinition<TProps, TCallbacks>;
26
+ export {};
@@ -0,0 +1,74 @@
1
+ import { isSchemaObject, resolvePointer, specToJSONSchema } from "./json-schema.js";
2
+ const EMPTY_OBJECT_SCHEMA = {
3
+ type: "object",
4
+ properties: {},
5
+ additionalProperties: false,
6
+ };
7
+ // Hand-written, so core needs no schema library.
8
+ const NO_PROPS = {
9
+ "~standard": {
10
+ version: 1,
11
+ vendor: "uicast",
12
+ validate: (value) => ({ value: value }),
13
+ jsonSchema: {
14
+ input: () => ({ ...EMPTY_OBJECT_SCHEMA }),
15
+ output: () => ({ ...EMPTY_OBJECT_SCHEMA }),
16
+ },
17
+ },
18
+ };
19
+ const RESERVED_PROP = "children";
20
+ // The top-level field names: the root object's, each union or intersection branch's, and a root `$ref`'s.
21
+ const propertyNames = (spec) => {
22
+ let schema;
23
+ try {
24
+ schema = specToJSONSchema(spec);
25
+ }
26
+ catch {
27
+ // A spec that cannot convert fails loudly in the prompt builder instead.
28
+ return [];
29
+ }
30
+ const names = new Set();
31
+ const seen = new Set();
32
+ const stack = [schema];
33
+ while (stack.length > 0) {
34
+ const node = stack.pop();
35
+ if (!isSchemaObject(node) || seen.has(node))
36
+ continue;
37
+ seen.add(node);
38
+ for (const name of Object.keys(node.properties ?? {}))
39
+ names.add(name);
40
+ if (typeof node.$ref === "string")
41
+ stack.push(resolvePointer(node.$ref, schema));
42
+ stack.push(...(node.anyOf ?? []), ...(node.oneOf ?? []), ...(node.allOf ?? []));
43
+ }
44
+ return [...names];
45
+ };
46
+ /**
47
+ * Builds a component definition, what the model reads about a component. Throws when `props` or a callback payload
48
+ * declares a `children` field: that name is reserved for child entries.
49
+ *
50
+ * @example
51
+ * export const BadgeDef = createComponentDefinition({
52
+ * name: "Badge",
53
+ * description: "A short status label.",
54
+ * props: z.strictObject({ text: z.string().meta({ description: "The label" }) }),
55
+ * });
56
+ *
57
+ * @example
58
+ * createComponentDefinition({
59
+ * name: "Button", description: "A clickable button.",
60
+ * props: z.strictObject({ text: z.string() }),
61
+ * callbacks: { onClick: z.null() }, // printed as onClick()
62
+ * });
63
+ */
64
+ export const createComponentDefinition = ({ name, description, props = NO_PROPS, callbacks, hidden, }) => {
65
+ if (propertyNames(props).includes(RESERVED_PROP)) {
66
+ throw new Error(`Component "${name}": "children" is a reserved name and cannot be a prop. Name the prop for what it holds (e.g. "text"); nested elements come from the entry's \`children\` array.`);
67
+ }
68
+ for (const [event, payload] of Object.entries(callbacks ?? {})) {
69
+ if (propertyNames(payload).includes(RESERVED_PROP)) {
70
+ throw new Error(`Component "${name}": callback "${event}" declares a "children" field, which is a reserved name. Rename it.`);
71
+ }
72
+ }
73
+ return { name, description, props, callbacks, hidden };
74
+ };
@@ -1,47 +1,57 @@
1
- export type EntryErrorReason =
2
- /** The expression doesn't parse. */
3
- "expression-syntax"
4
- /** The expression uses syntax or reaches an API the sandbox blocks. */
5
- | "sandbox-violation"
6
- /** The expression references an identifier that doesn't exist — an unregistered function, a blocked-by-omission global, or an unknown scope path target. */
7
- | "unknown-reference"
8
- /** The element's `component` name has no implementation in the registry. */
9
- | "unknown-component"
10
- /** A list's `each` did not evaluate to an array, or a non-list element was rendered as a list. */
11
- | "invalid-list"
12
- /** The implementation's render threw, and the evaluated props FAIL the def's schema — the document sent a shape the contract forbids. */
13
- | "invalid-props"
14
- /** A host function rejected the arguments the document called it with (host functions throw this deliberately). */
15
- | "invalid-arguments"
16
- /** A host function threw or rejected while executing — server/host failure. */
17
- | "host-function"
18
- /** The host `init` callback threw or rejected. */
19
- | "host-init"
20
- /** The implementation's render threw on props its own def schema accepts — an implementation bug. */
21
- | "implementation"
22
- /** 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. */
23
- | "expression-runtime"
24
- /** Escaped from an uninstrumented path — the defensive default. */
25
- | "unknown";
1
+ import type { ExpressionErrorReason } from "@uicast/expr";
2
+ /**
3
+ * What failed: an expression's refusal or failure (an `ExpressionErrorReason`), or an element-level one.
4
+ *
5
+ * @example
6
+ * if (error.reason === "unknown-component") return <p>No implementation for {error.elementKey}</p>;
7
+ */
8
+ export type EntryErrorReason = ExpressionErrorReason | "invalid-entry" | "unknown-component" | "invalid-list" | "invalid-props" | "host-init" | "implementation" | "unknown";
9
+ /**
10
+ * Whose code must fix a failure: `"document"` the model's (re-emit the entry), `"environment"` the host's, `"unknown"`
11
+ * either.
12
+ *
13
+ * @example
14
+ * const fault: EntryFault = error.fault; // "document" for reason "invalid-props"
15
+ */
26
16
  export type EntryFault = "document" | "environment" | "unknown";
17
+ export declare const FAULT_BY_REASON: Record<EntryErrorReason, EntryFault>;
18
+ export declare const REASON_DESCRIPTIONS: Record<EntryErrorReason, string>;
19
+ /**
20
+ * The classified error every element failure arrives as, in the error slot and in `onError`. `reason` says what
21
+ * failed; `fault` says whose code must fix it.
22
+ *
23
+ * @example
24
+ * const onError = (error: EntryError) =>
25
+ * error.fault === "document" ? askModelToFix(error.elementKey, error.message) : report(error);
26
+ */
27
27
  export declare class EntryError extends Error {
28
+ /** What failed, e.g. `"invalid-props"`. */
28
29
  readonly reason: EntryErrorReason;
29
- /** The element the failure belongs to; the error boundary fills it in when the throw site couldn't. */
30
+ /** The key of the element that failed. The error boundary fills it in when the throw site could not. */
30
31
  elementKey?: string;
32
+ /** Brand for `EntryError.is`: `instanceof` fails when two copies of this package share a bundle. */
31
33
  readonly uicastEntryError = true;
32
34
  constructor(message: string, options: {
33
35
  reason: EntryErrorReason;
34
36
  elementKey?: string;
35
37
  cause?: unknown;
36
38
  });
37
- /** Coarse verdict for recovery flows: `document` → ask the model to re-emit; `environment` → the host's problem; `unknown` → undecidable. */
39
+ /** Whose code must fix it: `"document"` (the model, by re-emitting), `"environment"` (host code) or `"unknown"`. */
38
40
  get fault(): EntryFault;
39
- /** Cross-copy-safe `instanceof`. */
41
+ /**
42
+ * Whether `err` is an `EntryError`, from any copy of this package. Use it over `instanceof`.
43
+ *
44
+ * @example
45
+ * if (EntryError.is(err)) console.warn(err.reason, err.elementKey);
46
+ */
40
47
  static is(err: unknown): err is EntryError;
41
48
  /**
42
- * Classify `err` as `reason`, keeping the original in `cause`. An error that
43
- * is already an EntryError passes through untouched (the deepest — most
44
- * precise — classification wins); a missing `elementKey` is filled in.
49
+ * Turns any thrown value into an `EntryError` with this `reason`. An `EntryError` passes through as is, and gets
50
+ * `elementKey` if it had none.
51
+ *
52
+ * @example
53
+ * const error = EntryError.wrap(err, "implementation", "orders-table");
54
+ * error.fault; // "environment"
45
55
  */
46
56
  static wrap(err: unknown, reason: EntryErrorReason, elementKey?: string): EntryError;
47
57
  }
@@ -1,13 +1,9 @@
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
- // The single source of truth `fault` derives from — every reason maps to
6
- // exactly one fault; hosts pick a side by picking the reason.
7
- const FAULT_BY_REASON = {
1
+ export const FAULT_BY_REASON = {
8
2
  "expression-syntax": "document",
9
- "sandbox-violation": "document",
3
+ "guardrail-violation": "document",
4
+ "budget-exceeded": "document",
10
5
  "unknown-reference": "document",
6
+ "invalid-entry": "document",
11
7
  "unknown-component": "document",
12
8
  "invalid-list": "document",
13
9
  "invalid-props": "document",
@@ -18,31 +14,64 @@ const FAULT_BY_REASON = {
18
14
  "expression-runtime": "unknown",
19
15
  unknown: "unknown",
20
16
  };
17
+ // Appended to the recovery prompt's failure lines.
18
+ export const REASON_DESCRIPTIONS = {
19
+ "expression-syntax": "expression doesn't parse",
20
+ "guardrail-violation": "breaks guardrail rule: blocked syntax or API, or `set` not `scopes.<scope>.<field>`",
21
+ "budget-exceeded": "expression passed its step, time or allocation budget",
22
+ "unknown-reference": "name doesn't exist: unregistered function, blocked global, or unset scope path",
23
+ "invalid-entry": "line field has wrong type, like `seed` not array of steps",
24
+ "unknown-component": "no component with this name",
25
+ "invalid-list": "`each` not array, or `as` names existing scope",
26
+ "invalid-props": "evaluated props don't match component's props schema",
27
+ "invalid-arguments": "host function rejected call's arguments",
28
+ "host-function": "host function failed",
29
+ "host-init": "host init callback failed",
30
+ implementation: "component implementation threw",
31
+ "expression-runtime": "valid expression threw at runtime",
32
+ unknown: "unclassified failure",
33
+ };
34
+ /**
35
+ * The classified error every element failure arrives as, in the error slot and in `onError`. `reason` says what
36
+ * failed; `fault` says whose code must fix it.
37
+ *
38
+ * @example
39
+ * const onError = (error: EntryError) =>
40
+ * error.fault === "document" ? askModelToFix(error.elementKey, error.message) : report(error);
41
+ */
21
42
  export class EntryError extends Error {
43
+ /** What failed, e.g. `"invalid-props"`. */
44
+ reason;
45
+ /** The key of the element that failed. The error boundary fills it in when the throw site could not. */
46
+ elementKey;
47
+ /** Brand for `EntryError.is`: `instanceof` fails when two copies of this package share a bundle. */
48
+ uicastEntryError = true;
22
49
  constructor(message, options) {
23
50
  super(message, options.cause !== undefined ? { cause: options.cause } : undefined);
24
- // Brand marker: with git-dep consumption two copies of core can coexist in
25
- // one bundle, where `instanceof` silently fails — `EntryError.is()` checks
26
- // this instead.
27
- this.uicastEntryError = true;
28
51
  this.name = "EntryError";
29
52
  this.reason = options.reason;
30
53
  this.elementKey = options.elementKey;
31
54
  }
32
- /** Coarse verdict for recovery flows: `document` → ask the model to re-emit; `environment` → the host's problem; `unknown` → undecidable. */
55
+ /** Whose code must fix it: `"document"` (the model, by re-emitting), `"environment"` (host code) or `"unknown"`. */
33
56
  get fault() {
34
57
  return FAULT_BY_REASON[this.reason];
35
58
  }
36
- /** Cross-copy-safe `instanceof`. */
59
+ /**
60
+ * Whether `err` is an `EntryError`, from any copy of this package. Use it over `instanceof`.
61
+ *
62
+ * @example
63
+ * if (EntryError.is(err)) console.warn(err.reason, err.elementKey);
64
+ */
37
65
  static is(err) {
38
- return (typeof err === "object" &&
39
- err !== null &&
40
- err.uicastEntryError === true);
66
+ return typeof err === "object" && err !== null && err.uicastEntryError === true;
41
67
  }
42
68
  /**
43
- * Classify `err` as `reason`, keeping the original in `cause`. An error that
44
- * is already an EntryError passes through untouched (the deepest — most
45
- * precise — classification wins); a missing `elementKey` is filled in.
69
+ * Turns any thrown value into an `EntryError` with this `reason`. An `EntryError` passes through as is, and gets
70
+ * `elementKey` if it had none.
71
+ *
72
+ * @example
73
+ * const error = EntryError.wrap(err, "implementation", "orders-table");
74
+ * error.fault; // "environment"
46
75
  */
47
76
  static wrap(err, reason, elementKey) {
48
77
  if (EntryError.is(err)) {
@@ -0,0 +1,7 @@
1
+ import { EntryError } from "./entry-error.js";
2
+ import type { ComponentEntry } from "./types.js";
3
+ type RawEntry = {
4
+ [K in keyof ComponentEntry]?: unknown;
5
+ };
6
+ export declare const entryShapeError: (entry: RawEntry) => EntryError | null;
7
+ export {};
@@ -0,0 +1,88 @@
1
+ import { EntryError } from "./entry-error.js";
2
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
3
+ const jsonType = (value) => {
4
+ if (value === null)
5
+ return "null";
6
+ if (Array.isArray(value))
7
+ return "an array";
8
+ if (typeof value === "object")
9
+ return "an object";
10
+ return `a ${typeof value}`;
11
+ };
12
+ const expected = (at, what, value) => `"${at}" must be ${what}, got ${jsonType(value)}.`;
13
+ // No `expr` (or `null`) is no value, as `evaluate` reads it.
14
+ const exprFault = (source, at) => source.expr == null || typeof source.expr === "string"
15
+ ? null
16
+ : expected(`${at}.expr`, "an expression string", source.expr);
17
+ const stepsFault = (steps, at, setRequired) => {
18
+ if (!Array.isArray(steps))
19
+ return expected(at, "an array of steps", steps);
20
+ for (const [i, step] of steps.entries()) {
21
+ const here = `${at}[${i}]`;
22
+ if (!isRecord(step))
23
+ return expected(here, 'a step ({ "set": "…", "expr": "…" })', step);
24
+ if (setRequired && step.set === undefined)
25
+ return `"${here}" has no "set": a seed step writes one field.`;
26
+ if (step.set !== undefined && typeof step.set !== "string")
27
+ return expected(`${here}.set`, "an address string", step.set);
28
+ if (step.confirm !== undefined && typeof step.confirm !== "string") {
29
+ return expected(`${here}.confirm`, "a message string", step.confirm);
30
+ }
31
+ if (step.debounce !== undefined && typeof step.debounce !== "boolean") {
32
+ return expected(`${here}.debounce`, "true or false", step.debounce);
33
+ }
34
+ const fault = exprFault(step, here);
35
+ if (fault)
36
+ return fault;
37
+ }
38
+ return null;
39
+ };
40
+ const STRING_FIELDS = ["hidden", "loading", "each", "as", "keyBy"];
41
+ const shapeFault = (entry) => {
42
+ if (entry.props !== undefined) {
43
+ if (!isRecord(entry.props))
44
+ return expected("props", '{ "expr": "…" } or { "literal": … }', entry.props);
45
+ const fault = exprFault(entry.props, "props");
46
+ if (fault)
47
+ return fault;
48
+ }
49
+ for (const field of STRING_FIELDS) {
50
+ const value = entry[field];
51
+ if (value !== undefined && typeof value !== "string")
52
+ return expected(field, "a string", value);
53
+ }
54
+ if (entry.each !== undefined && entry.as === undefined)
55
+ return 'A list needs "as": the name its rows are read under.';
56
+ if (typeof entry.as === "string" && entry.as.startsWith("$"))
57
+ return `"as": "${entry.as}" starts with "$", which marks a row's own scope, "scopes.$<as>".`;
58
+ if (entry.children !== undefined) {
59
+ if (!Array.isArray(entry.children))
60
+ return expected("children", "an array of element keys", entry.children);
61
+ const i = entry.children.findIndex((child) => typeof child !== "string");
62
+ if (i !== -1)
63
+ return expected(`children[${i}]`, "an element key string", entry.children[i]);
64
+ }
65
+ if (entry.seed !== undefined) {
66
+ const fault = stepsFault(entry.seed, "seed", true);
67
+ if (fault)
68
+ return fault;
69
+ }
70
+ if (entry.callbacks !== undefined) {
71
+ if (!isRecord(entry.callbacks))
72
+ return expected("callbacks", "an object of step arrays", entry.callbacks);
73
+ for (const [name, steps] of Object.entries(entry.callbacks)) {
74
+ const fault = stepsFault(steps, `callbacks.${name}`, false);
75
+ if (fault)
76
+ return fault;
77
+ }
78
+ }
79
+ return null;
80
+ };
81
+ // Every field but `key` and `component`. The message names the field, so a re-emit can fix it.
82
+ export const entryShapeError = (entry) => {
83
+ const fault = shapeFault(entry);
84
+ if (!fault)
85
+ return null;
86
+ const elementKey = typeof entry.key === "string" ? entry.key : undefined;
87
+ return new EntryError(fault, { reason: "invalid-entry", elementKey });
88
+ };
@@ -1,7 +1,5 @@
1
- import type { StandardToolV0 } from "standard-tool";
2
1
  import type { ValueSource } from "../types.js";
3
- export declare const getScopeReads: (expr: string) => string[];
4
- export declare const evaluate: (expr: ValueSource, context: Record<string, unknown>, options?: {
5
- functions?: StandardToolV0[];
6
- allowedGlobals?: string[];
7
- }) => unknown;
2
+ import { EntryError } from "../entry-error.js";
3
+ import type { ExpressionEvaluator } from "@uicast/expr";
4
+ export declare const wrapEvalError: (err: unknown) => EntryError;
5
+ export declare const evaluate: (expr: ValueSource, context: Record<string, unknown>, evaluator: ExpressionEvaluator) => unknown;