diff --git a/packages/lang-core/package.json b/packages/lang-core/package.json index 4773a1223..83d0edb27 100644 --- a/packages/lang-core/package.json +++ b/packages/lang-core/package.json @@ -1,6 +1,6 @@ { "name": "@openuidev/lang-core", - "version": "0.2.9", + "version": "0.2.10", "description": "Framework-agnostic core for OpenUI Lang: parser, prompt generation, validation, and type definitions", "license": "MIT", "type": "module", diff --git a/packages/lang-core/src/index.ts b/packages/lang-core/src/index.ts index dccec529e..1126c5f60 100644 --- a/packages/lang-core/src/index.ts +++ b/packages/lang-core/src/index.ts @@ -15,8 +15,10 @@ export type { // ── Parser ── export { createParser, createStreamingParser, parse } from "./parser"; export type { Parser, StreamParser } from "./parser"; -export { isASTNode, isRuntimeExpr } from "./parser/ast"; +export { isASTNode, isRuntimeExpr, walkAST } from "./parser/ast"; export type { ASTNode, CallNode, RuntimeExprNode, Statement } from "./parser/ast"; +// Low-level parsing pipeline (tokenize → split → parseExpression) for consumers +// that walk partial/streaming openui-lang source without a full parser. export { ACTION_NAMES, ACTION_STEPS, @@ -28,11 +30,15 @@ export { } from "./parser/builtins"; export type { BuiltinDef } from "./parser/builtins"; export { enrichErrors } from "./parser/enrich-errors"; +export { parseExpression } from "./parser/expressions"; +export { tokenize } from "./parser/lexer"; export { mergeStatements } from "./parser/merge"; export { generatePrompt } from "./parser/prompt"; export type { ComponentPromptSpec, LibrarySpec, PromptSpec, ToolSpec } from "./parser/prompt"; export { jsonToOpenUI } from "./parser/serialize"; export type { SerializeOptions } from "./parser/serialize"; +export { autoClose, split } from "./parser/statements"; +export type { Token } from "./parser/tokens"; export { BuiltinActionType } from "./parser/types"; export type { ActionEvent, diff --git a/packages/lang-core/src/parser/__tests__/materialize.validation.test.ts b/packages/lang-core/src/parser/__tests__/materialize.validation.test.ts new file mode 100644 index 000000000..5309bb0dc --- /dev/null +++ b/packages/lang-core/src/parser/__tests__/materialize.validation.test.ts @@ -0,0 +1,361 @@ +import { describe, expect, it } from "vitest"; +import { createParser, createStreamingParser } from "../parser"; +import type { LibraryJSONSchema, MaterializeCtx, ValidationError } from "../types"; +import { validateSchemaValue } from "../validation"; + +/** + * Significant cases for the recursive validator (validateSchemaValue) and the + * resolveInvalidValue edge rule — one test per behavior family: + * + * 1. recursive descent + error reporting (JSON-pointer paths, attribution) + * 2. the edge rule — default → substitute; required → propagate; optional → + * prune; array items always pruned + * 3. component elements in data slots + * 4. conservative skips (composites, dynamic values, malformed schemas) + * 5. streaming gates (required + enum deferred; scalar checks stay on) + * + * Each component below exposes the schema-under-test as its FIRST positional + * arg, so `Comp()` maps `` straight onto the interesting prop. + */ +const schema: LibraryJSONSchema = { + $defs: { + // arg0 → `info`: an object with a required key + typed leaves + ObjBox: { + properties: { + info: { + type: "object", + properties: { + author: { type: "string" }, + views: { type: "number" }, + }, + required: ["author"], + }, + }, + required: [], + }, + // arg0 → `rows`: an array whose items are objects with a required key + ArrBox: { + properties: { + rows: { + type: "array", + items: { + type: "object", + properties: { + label: { type: "string" }, + value: { type: "number" }, + }, + required: ["label"], + }, + }, + }, + required: [], + }, + // arg0 → `tags`: an array of scalar strings + TagBox: { + properties: { + tags: { type: "array", items: { type: "string" } }, + }, + required: [], + }, + // arg0 → `status`: an enum leaf + EnumBox: { + properties: { + status: { enum: ["active", "inactive"] }, + }, + required: [], + }, + // arg0 → `combo`: a composite (anyOf) that must be left unchecked + AnyBox: { + properties: { + combo: { anyOf: [{ type: "string" }, { type: "number" }] }, + }, + required: [], + }, + // arg0..2 → scalar params for top-level leaf checks + ScalarBox: { + properties: { + title: { type: "string" }, + count: { type: "number" }, + flag: { type: "boolean" }, + }, + required: [], + }, + // arg0 → `children`: array without `items` — elements are unchecked + ListBox: { + properties: { children: { type: "array" } }, + required: [], + }, + // arg0 → `text`: a plain catalog component used as a nested prop value + CardBox: { + properties: { text: { type: "string" } }, + required: [], + }, + // arg0 → `title`: REQUIRED scalar param — a type mismatch drops the component + ReqScalar: { + properties: { title: { type: "string" } }, + required: ["title"], + }, + // arg0 → `theme`: REQUIRED param with a default — invalid/missing values fall back to it + DefBox: { + properties: { theme: { type: "string", default: "dark" } }, + required: ["theme"], + }, + // arg0 → `children`: a component slot (anyOf of $refs) — membership unchecked + SlotBox: { + properties: { + children: { type: "array", items: { anyOf: [{ $ref: "#/$defs/CardBox" }] } }, + }, + required: [], + }, + // args → `labels` (REQUIRED data array), `variant` (enum) — mirrors chart signatures + ChartBox: { + properties: { + labels: { type: "array", items: { type: "string" } }, + variant: { enum: ["grouped", "stacked"] }, + }, + required: ["labels"], + }, + // arg0 → `cfg`: nested keys with defaults at both required and optional edges + NestDefBox: { + properties: { + cfg: { + type: "object", + properties: { + mode: { enum: ["fast", "slow"], default: "fast" }, + retries: { type: "number", default: 3 }, + }, + required: ["mode"], + }, + }, + required: [], + }, + }, +}; + +const parser = createParser(schema); + +function errorsFor(src: string): ValidationError[] { + return parser.parse(src).meta.errors; +} + +/** Run validateSchemaValue directly against a raw schema fragment. */ +function directErrors(value: unknown, s: unknown, partial = false): ValidationError[] { + const ctx: MaterializeCtx = { + syms: new Map(), + cat: undefined, + errors: [], + unres: [], + visited: new Set(), + partial, + }; + validateSchemaValue(value, s, "X", "/p", ctx); + return ctx.errors; +} + +describe("recursive descent & error reporting", () => { + it("accepts valid values and reports violations with JSON-pointer paths + distinct codes", () => { + expect(errorsFor('root = ObjBox({ author: "ann", views: 3 })')).toEqual([]); + // wrong leaf type inside an object + expect(errorsFor('root = ObjBox({ author: "ann", views: "lots" })')[0]).toMatchObject({ + code: "type-mismatch", + component: "ObjBox", + path: "/info/views", + }); + // array item leaf, with element index + expect(errorsFor('root = ArrBox([{ label: "a", value: "x" }])')[0]).toMatchObject({ + path: "/rows/0/value", + }); + // missing vs null required keys carry distinct codes + expect(errorsFor("root = ObjBox({ views: 3 })")[0]).toMatchObject({ + code: "missing-required", + path: "/info/author", + }); + expect(errorsFor("root = ObjBox({ author: null, views: 3 })")[0]).toMatchObject({ + code: "null-required", + path: "/info/author", + }); + }); + + it("reports container-shape mismatches and accumulates independent errors", () => { + expect(errorsFor('root = ObjBox("hello")')[0].message).toContain( + "expects object but got string", + ); + expect(errorsFor("root = TagBox(42)")[0].message).toContain("expects array but got number"); + // an object with dynamic parts is still an object at runtime — flagged + expect(errorsFor("root = ScalarBox({ a: $x })")[0].message).toContain( + "expects string but got object", + ); + // three violations spread over two array elements + const byPath = errorsFor('root = ArrBox([{ label: 1 }, { value: "x" }])') + .map((e) => `${e.code}:${e.path}`) + .sort(); + expect(byPath).toEqual([ + "missing-required:/rows/1/label", + "type-mismatch:/rows/0/label", + "type-mismatch:/rows/1/value", + ]); + // arg validation and excess-args reporting are independent + expect( + errorsFor('root = ScalarBox(1, 1, true, "extra")') + .map((e) => e.code) + .sort(), + ).toEqual(["excess-args", "type-mismatch"]); + }); + + it("messages are self-sufficient and attributed to the defining statement", () => { + // top-level required violations carry the typed component signature + expect(errorsFor("root = ReqScalar()")[0].message).toBe( + 'missing required field "/title" — signature: ReqScalar(title*: string)', + ); + expect(errorsFor('root = ChartBox(null, "grouped")')[0].message).toBe( + 'required field "/labels" cannot be null — signature: ChartBox(labels*: array, variant: "grouped"|"stacked")', + ); + // unknown components list the catalog + expect(errorsFor("root = Nope()")[0].message).toContain("Available components: ObjBox, ArrBox"); + // errors point at the statement that defines the component + expect( + errorsFor('root = ListBox([inner])\ninner = ObjBox({ author: "ann", views: "lots" })')[0], + ).toMatchObject({ component: "ObjBox", statementId: "inner" }); + }); +}); + +describe("resolveInvalidValue — the edge rule (default → required → prune)", () => { + it("prunes invalid values on OPTIONAL edges and ARRAY ITEMS — component survives", () => { + // nested optional key + expect( + parser.parse('root = ObjBox({ author: "ann", views: "lots" })').root?.props.info, + ).toEqual({ author: "ann" }); + // optional top-level param + const top = parser.parse("root = ScalarBox(5)"); + expect(top.root).not.toBeNull(); + expect(top.root?.props).not.toHaveProperty("title"); + // optional param holding an unrepairable object (missing required key) + expect(parser.parse("root = ObjBox({ views: 3 })").root?.props).not.toHaveProperty("info"); + // array items: siblings survive, a required-key violation is absorbed by the item edge + expect(parser.parse('root = TagBox(["a", 2, "c"])').root?.props.tags).toEqual(["a", "c"]); + expect( + parser.parse('root = ArrBox([{ label: "a", value: 1 }, { value: 2 }])').root?.props.rows, + ).toEqual([{ label: "a", value: 1 }]); + }); + + it("propagates through REQUIRED edges: parent pruned at an optional edge, or component dropped", () => { + // info.author (required) invalid → info invalid → info optional → pruned + const nested = parser.parse('root = ObjBox({ author: { first: "a" }, views: 3 })'); + expect(nested.root).not.toBeNull(); + expect(nested.root?.props).not.toHaveProperty("info"); + // required param invalid with no default → component drops + expect(parser.parse("root = ReqScalar(5)").root).toBeNull(); + }); + + it("substitutes schema defaults at every edge; absent/null required keys fill silently", () => { + // top-level required param: default rescues an invalid value (error kept) and a missing one (silent) + const def = parser.parse("root = DefBox(5)"); + expect(def.root?.props.theme).toBe("dark"); + expect(def.meta.errors).toHaveLength(1); + expect(parser.parse("root = DefBox()").meta.errors).toEqual([]); + // top-level optional param: default wins over pruning (error kept) + const opt = parser.parse('root = NestDefBox({ mode: "warp", retries: 1 })'); + expect(opt.root?.props.cfg).toEqual({ mode: "fast", retries: 1 }); + expect(opt.meta.errors[0]).toMatchObject({ code: "type-mismatch", path: "/cfg/mode" }); + // nested optional key with default + expect( + parser.parse('root = NestDefBox({ mode: "slow", retries: "many" })').root?.props.cfg, + ).toEqual({ mode: "slow", retries: 3 }); + // absent and null required keys fill from defaults without an error + const absent = parser.parse("root = NestDefBox({ retries: 2 })"); + expect(absent.root?.props.cfg).toEqual({ mode: "fast", retries: 2 }); + expect(absent.meta.errors).toEqual([]); + const nulled = parser.parse("root = NestDefBox({ mode: null, retries: 2 })"); + expect(nulled.root?.props.cfg).toEqual({ mode: "fast", retries: 2 }); + expect(nulled.meta.errors).toEqual([]); + }); +}); + +describe("components in data slots", () => { + it("elements in data slots follow the edge rule: drop on required, prune on optional/items", () => { + // hbc = HorizontalBarChart(Card([]), [], "grouped") — the motivating case. + const dropped = parser.parse('root = ChartBox(CardBox("x"), "grouped")'); + expect(dropped.root).toBeNull(); + expect(dropped.meta.errors[0].message).toContain('expects array but got component "CardBox"'); + // optional enum leaf → prop pruned + const pruned = parser.parse('root = ChartBox(["a"], CardBox("x"))'); + expect(pruned.root?.props).not.toHaveProperty("variant"); + expect(pruned.meta.errors[0].message).toContain("expects a literal value but got component"); + // data-array item → item pruned, siblings survive + expect(parser.parse('root = TagBox(["a", CardBox("x"), "b"])').root?.props.tags).toEqual([ + "a", + "b", + ]); + }); + + it("component slots ($ref/anyOf) stay unchecked; a misplaced component's own args still validate", () => { + const slot = parser.parse('root = SlotBox([CardBox("hi"), EnumBox("active")])'); + expect(slot.meta.errors).toEqual([]); + expect((slot.root?.props.children as unknown[]).length).toBe(2); + // both the position error and the component's own arg error surface + const errors = errorsFor("root = ObjBox({ author: CardBox(9) })"); + expect(errors).toHaveLength(2); + expect(errors.find((e) => e.component === "CardBox")).toMatchObject({ path: "/text" }); + expect(errors.find((e) => e.component === "ObjBox")).toMatchObject({ path: "/info/author" }); + }); +}); + +describe("conservative skips", () => { + it("skips dynamic values, composites, null/omitted args, and extra keys", () => { + expect(errorsFor('root = TagBox(["a", $x])')).toEqual([]); // $var resolves at runtime + expect(errorsFor('root = ObjBox({ author: "a", views: Sum([1, 2]) })')).toEqual([]); // builtin + expect(errorsFor("root = AnyBox({ anything: true })")).toEqual([]); // anyOf composite + expect(errorsFor("root = ObjBox(null)")).toEqual([]); // absence is the parent's concern + expect(errorsFor("root = ObjBox()")).toEqual([]); + expect(errorsFor('root = ObjBox({ author: "a", bonus: 99 })')).toEqual([]); // undeclared keys + expect(errorsFor('root = ListBox([1, "a", true])')).toEqual([]); // array without `items` + }); + + it("skips malformed/uncheckable schemas and applies documented leaf edges (direct)", () => { + expect(directErrors(5, undefined)).toEqual([]); + expect(directErrors(5, "string")).toEqual([]); + expect(directErrors(5, { $ref: "#/$defs/Thing" })).toEqual([]); + expect(directErrors(5, { oneOf: [{ type: "string" }] })).toEqual([]); + expect(directErrors([5], { type: "array", items: [{ type: "string" }] })).toEqual([]); // tuple + expect(directErrors("x", { type: "date" })).toEqual([]); // unknown type keyword + expect(directErrors({}, { type: "object", required: "author" })).toEqual([]); // non-array required + // ...but `required` works even without `properties` + expect(directErrors({}, { type: "object", required: ["a"] })[0]).toMatchObject({ + code: "missing-required", + }); + // leaf edges: integer ≈ number (floats pass), const ≈ single-value enum, + // non-scalar values never judged against enums + expect(directErrors(1.5, { type: "integer" })).toEqual([]); + expect(directErrors("3", { type: "integer" })).toHaveLength(1); + expect(directErrors("loose", { const: "fixed" })[0].message).toContain('one of ["fixed"]'); + expect(directErrors({ a: 1 }, { enum: ["active"] })).toEqual([]); + }); +}); + +describe("streaming gates", () => { + it("defers required-key and enum checks while partial, then reports them on completion", () => { + const req = createStreamingParser(schema); + expect(req.push("root = ObjBox({ views: 3 ").meta.errors).toEqual([]); + expect(req.push("})\n").meta.errors[0]).toMatchObject({ + code: "missing-required", + path: "/info/author", + statementId: "root", + }); + const en = createStreamingParser(schema); + expect(en.push('root = EnumBox("bogus" ').meta.errors).toEqual([]); + expect(en.push(")\n").meta.errors[0]).toMatchObject({ path: "/status" }); + }); + + it("keeps scalar type checks on mid-stream (only structure and enums defer)", () => { + const sp = createStreamingParser(schema); + const res = sp.push('root = ObjBox({ author: "a", views: "lots" '); + expect(res.meta.incomplete).toBe(true); + expect(res.meta.errors.find((e) => e.code === "type-mismatch")).toMatchObject({ + path: "/info/views", + }); + // same gating verified directly on a partial ctx + expect(directErrors({}, { type: "object", required: ["a"] }, true)).toEqual([]); + expect(directErrors("act", { enum: ["active"] }, true)).toEqual([]); + expect(directErrors(5, { type: "string" }, true)).toHaveLength(1); + }); +}); diff --git a/packages/lang-core/src/parser/enrich-errors.ts b/packages/lang-core/src/parser/enrich-errors.ts index 65fef725d..9677da56d 100644 --- a/packages/lang-core/src/parser/enrich-errors.ts +++ b/packages/lang-core/src/parser/enrich-errors.ts @@ -17,6 +17,10 @@ function buildSignatureHint( * Convert parser ValidationErrors into enriched OpenUIErrors with hints. * * Framework-agnostic — usable by React, Svelte, Vue, or standalone. + * + * @deprecated ValidationError.message is already humanized and self-sufficient + * (signatures, available components, expected types are inlined) — read + * `result.meta.errors` directly. Will be removed in a future major release. */ export function enrichErrors( validationErrors: ValidationError[], diff --git a/packages/lang-core/src/parser/materialize.ts b/packages/lang-core/src/parser/materialize.ts index f97bab3f2..310e2eab8 100644 --- a/packages/lang-core/src/parser/materialize.ts +++ b/packages/lang-core/src/parser/materialize.ts @@ -5,7 +5,13 @@ import type { ASTNode } from "./ast"; import { isASTNode, isRuntimeExpr } from "./ast"; import { isBuiltin, isReservedCall, LAZY_BUILTINS, RESERVED_CALLS } from "./builtins"; -import { isElementNode, type ParamMap, type ValidationError } from "./types"; +import { isElementNode, type MaterializeCtx } from "./types"; +import { + buildParamsSignature, + pushValidationIssue, + resolveInvalidValue, + validateSchemaValue, +} from "./validation"; /** * Recursively check if a prop value contains any AST nodes that need runtime @@ -22,19 +28,6 @@ export function containsDynamicValue(v: unknown): boolean { return Object.values(obj).some(containsDynamicValue); } -export interface MaterializeCtx { - syms: Map; - cat: ParamMap | undefined; - errors: ValidationError[]; - unres: string[]; - visited: Set; - partial: boolean; - /** Tracks which statement is currently being materialized (for error attribution). */ - currentStatementId?: string; - /** Statement IDs not yet reached — delete as they're touched. Remaining = orphaned. */ - unreached?: Set; -} - /** * Resolve a Ref node: inline from symbol table, detect cycles, emit RuntimeRef * for Query/Mutation declarations. Shared by materializeValue and materializeExpr. @@ -126,12 +119,9 @@ function materializeExprInternal( return { ...node, args: recursedArgs, mappedProps }; } // Unknown component in expression: push error (same as value path) - ctx.errors.push({ + pushValidationIssue(ctx, node.name, "", { code: "unknown-component", - component: node.name, - path: "", - message: `Unknown component "${node.name}" — not found in catalog or builtins`, - statementId: ctx.currentStatementId, + available: ctx.cat && [...ctx.cat.keys()], }); return { ...node, args: recursedArgs }; } @@ -248,13 +238,7 @@ export function materializeValue(node: ASTNode, ctx: MaterializeCtx): unknown { // Inline Query/Mutation (not from a statement-level declaration) → validation error if (isReservedCall(name)) { - ctx.errors.push({ - code: "inline-reserved", - component: name, - path: "", - message: `${name}() must be declared as a top-level statement, not used inline as a value`, - statementId: ctx.currentStatementId, - }); + pushValidationIssue(ctx, name, "", { code: "inline-reserved" }); return null; } @@ -262,20 +246,34 @@ export function materializeValue(node: ASTNode, ctx: MaterializeCtx): unknown { const props: Record = {}; if (def) { + // Set when a REQUIRED prop holds invalid data with no default to fall + // back on — the only case where invalidity reaches the component itself. + let dropComponent = false; // Catalog component: map positional args → named props for (let i = 0; i < def.params.length && i < args.length; i++) { - props[def.params[i].name] = materializeValue(args[i], ctx); + const param = def.params[i]; + const value = materializeValue(args[i], ctx); + props[param.name] = value; + // Single validation entry point: scalar leaf type/enum for simple + // props, recursive key/type checks (with pruning) for nested shapes. + if ( + param.schema !== undefined && + validateSchemaValue(value, param.schema, name, `/${param.name}`, ctx) + ) { + // Invalid prop value (error already reported). Same resolution rule as + // every nested edge; propagation here means dropping the component. + if (resolveInvalidValue(props, param.name, param.required, param.defaultValue)) { + dropComponent = true; + } + } } // Report excess positional args (extra args are silently dropped) if (args.length > def.params.length) { - const excessCount = args.length - def.params.length; - ctx.errors.push({ + pushValidationIssue(ctx, name, "", { code: "excess-args", - component: name, - path: "", - message: `${name} takes ${def.params.length} arg(s), got ${args.length} (${excessCount} excess dropped)`, - statementId: ctx.currentStatementId, + declared: def.params.length, + got: args.length, }); } @@ -293,28 +291,23 @@ export function materializeValue(node: ASTNode, ctx: MaterializeCtx): unknown { }); if (stillInvalid.length) { for (const p of stillInvalid) { - const isNull = p.name in props; - ctx.errors.push({ - code: isNull ? "null-required" : "missing-required", - component: name, - path: `/${p.name}`, - message: isNull - ? `required field "${p.name}" cannot be null` - : `missing required field "${p.name}"`, - statementId: ctx.currentStatementId, + pushValidationIssue(ctx, name, `/${p.name}`, { + code: p.name in props ? "null-required" : "missing-required", + signature: buildParamsSignature(name, def.params), }); } return null; } } + + // A required prop with unsalvageable data (no default) drops the + // component — its error was already reported during validation. + if (dropComponent) return null; } else if (!isBuiltin(name) && !isReservedCall(name)) { // Unknown component: error and drop from tree - ctx.errors.push({ + pushValidationIssue(ctx, name, "", { code: "unknown-component", - component: name, - path: "", - message: `Unknown component "${name}" — not found in catalog or builtins`, - statementId: ctx.currentStatementId, + available: ctx.cat && [...ctx.cat.keys()], }); return null; } diff --git a/packages/lang-core/src/parser/parser.ts b/packages/lang-core/src/parser/parser.ts index 32b443aca..41bd2e7bc 100644 --- a/packages/lang-core/src/parser/parser.ts +++ b/packages/lang-core/src/parser/parser.ts @@ -3,18 +3,20 @@ import { isASTNode, walkAST } from "./ast"; import { isBuiltin, RESERVED_CALLS } from "./builtins"; import { parseExpression } from "./expressions"; import { tokenize } from "./lexer"; -import { materializeValue, type MaterializeCtx } from "./materialize"; +import { materializeValue } from "./materialize"; import { autoClose, split, type RawStmt } from "./statements"; import { T } from "./tokens"; import { isElementNode, type LibraryJSONSchema, + type MaterializeCtx, type MutationStatementInfo, type ParamMap, type ParseResult, type QueryStatementInfo, type ValidationError, } from "./types"; +import { getSchemaDefaultValue } from "./validation"; // ───────────────────────────────────────────────────────────────────────────── // Result building @@ -630,13 +632,6 @@ export interface Parser { parse(input: string): ParseResult; } -function getSchemaDefaultValue(property: unknown): unknown { - if (!property || typeof property !== "object" || Array.isArray(property)) { - return undefined; - } - return (property as { default?: unknown }).default; -} - export function compileSchema(schema: LibraryJSONSchema): ParamMap { const map: ParamMap = new Map(); const defs = schema.$defs ?? {}; @@ -648,6 +643,7 @@ export function compileSchema(schema: LibraryJSONSchema): ParamMap { name: key, required: required.includes(key), defaultValue: getSchemaDefaultValue(properties[key]), + schema: properties[key], })); map.set(name, { params }); } diff --git a/packages/lang-core/src/parser/prompt.ts b/packages/lang-core/src/parser/prompt.ts index 302ca80ce..13cec0611 100644 --- a/packages/lang-core/src/parser/prompt.ts +++ b/packages/lang-core/src/parser/prompt.ts @@ -438,6 +438,9 @@ function importantRules( `${flags.toolCalls ? "4" : "3"}. Every $binding appears in at least one component or expression.`, ); } + if (flags.toolCalls && flags.bindings) { + verifyLines.push("5. Every visible filter $binding appears in at least one Query args object."); + } return `## Important Rules - When asked about data, generate realistic/plausible data diff --git a/packages/lang-core/src/parser/types.ts b/packages/lang-core/src/parser/types.ts index ec1d82677..2a378972f 100644 --- a/packages/lang-core/src/parser/types.ts +++ b/packages/lang-core/src/parser/types.ts @@ -1,20 +1,23 @@ import type { ASTNode } from "./ast"; +export type JSONSchemaProperty = Record; +export type JSONSchemaDef = { + properties?: JSONSchemaProperty; + required?: string[]; + description?: string; +}; + /** * The JSON Schema document produced by `library.toJSONSchema()`. * All component schemas live in `$defs`, keyed by component name. */ export interface LibraryJSONSchema { - $defs?: Record< - string, - { - properties?: Record; - required?: string[]; - description?: string; - } - >; + $defs?: Record; } +/** Scalar JSON Schema types we can reliably check a positional literal against. */ +export type ScalarParamType = "string" | "number" | "boolean"; + export interface ParamDef { /** Parameter name, e.g. "title", "columns". */ name: string; @@ -22,6 +25,8 @@ export interface ParamDef { required: boolean; /** Default value from JSON Schema — used when the required field is missing/null. */ defaultValue?: unknown; + /** The raw JSON Schema fragment for this param. */ + schema?: unknown; } /** Internal parameter map for positional-arg to named-prop mapping. */ @@ -74,7 +79,8 @@ export type ValidationErrorCode = | "null-required" | "unknown-component" | "inline-reserved" - | "excess-args"; + | "excess-args" + | "type-mismatch"; /** * A prop validation error. Components with missing required props are @@ -93,6 +99,19 @@ export interface ValidationError { statementId?: string; } +export interface MaterializeCtx { + syms: Map; + cat: ParamMap | undefined; + errors: ValidationError[]; + unres: string[]; + visited: Set; + partial: boolean; + /** Tracks which statement is currently being materialized (for error attribution). */ + currentStatementId?: string; + /** Statement IDs not yet reached — delete as they're touched. Remaining = orphaned. */ + unreached?: Set; +} + /** * Error sources across the openui-lang pipeline. */ diff --git a/packages/lang-core/src/parser/validation.ts b/packages/lang-core/src/parser/validation.ts new file mode 100644 index 000000000..77458972b --- /dev/null +++ b/packages/lang-core/src/parser/validation.ts @@ -0,0 +1,370 @@ +import { isASTNode } from "./ast"; +import { + isElementNode, + type ElementNode, + type MaterializeCtx, + type ParamDef, + type ScalarParamType, +} from "./types"; + +// ───────────────────────────────────────────────────────────────────────────── +// Schema & value introspection +// ───────────────────────────────────────────────────────────────────────────── + +/** `typeof` results a scalar/enum leaf can be checked against. */ +const SCALAR_TYPEOFS: readonly string[] = ["string", "number", "boolean"]; + +const NO_PROPS: Record = {}; +const NO_REQUIRED: readonly string[] = []; + +function jsType(value: unknown): string { + return Array.isArray(value) ? "array" : typeof value; +} + +/** Composite shapes can't be reliably matched to a single plain value. */ +function isCompositeSchema(s: Record): boolean { + return "$ref" in s || "anyOf" in s || "oneOf" in s || "allOf" in s; +} + +/** Scalar type/enum info extracted from a JSON Schema leaf, for describeTypeMismatch. */ +interface ScalarTypeInfo { + /** Scalar leaf type, when the property is a plain string/number/boolean. */ + expectedType?: ScalarParamType; + /** Allowed literal values from `enum`/`const`. */ + enumValues?: readonly unknown[]; +} + +/** Extract the scalar leaf type/enum from an already-narrowed JSON Schema object. */ +export function getScalarTypeInfo(s: Record): ScalarTypeInfo { + if (Array.isArray(s["enum"])) return { enumValues: s["enum"] }; + if ("const" in s) return { enumValues: [s["const"]] }; + switch (s["type"]) { + case "string": + return { expectedType: "string" }; + case "number": + case "integer": + return { expectedType: "number" }; + case "boolean": + return { expectedType: "boolean" }; + default: + return {}; + } +} + +/** Read a JSON Schema fragment's `default`, if it carries one. */ +export function getSchemaDefaultValue(property: unknown): unknown { + if (!property || typeof property !== "object" || Array.isArray(property)) { + return undefined; + } + return (property as { default?: unknown }).default; +} + +/** Returns the expected type for a param from its schema properties */ +export function getTypeFromSchema(property: unknown): string | undefined { + if (!property || typeof property !== "object" || Array.isArray(property)) { + return undefined; + } + const p = property as Record; + // $refs and compound types aren't validator-checkable but still make useful hints. + if (typeof p.$ref === "string") { + return p.$ref.split("/").pop(); + } + // Scalar/enum leaves: share the validator's schema-leaf interpretation so + // signatures and type-mismatch messages never disagree. + const leaf = getScalarTypeInfo(p); + if (leaf.enumValues) { + return leaf.enumValues.map((v) => JSON.stringify(v)).join("|"); + } + if (leaf.expectedType) { + return leaf.expectedType; + } + return typeof p.type === "string" ? p.type : undefined; +} + +/** + * Build a typed signature like `Header(title*: string, variant: "a"|"b")` from + * compiled params — * marks required. Positional order is preserved so an LLM + * can fix swapped args. + */ +export function buildParamsSignature(component: string, params: ParamDef[]): string { + const rendered = params + .map((p) => { + const type = getTypeFromSchema(p.schema); + const marked = p.required ? `${p.name}*` : p.name; + return type ? `${marked}: ${type}` : marked; + }) + .join(", "); + return `${component}(${rendered})`; +} + +/** Checks a param's returned value against its expected value */ +function checkTypeMismatch( + value: unknown, + info: ScalarTypeInfo, +): { expected: string; actual: string } | null { + const actual = typeof value; + if (info.enumValues) { + if (!SCALAR_TYPEOFS.includes(actual)) return null; + if (info.enumValues.includes(value)) return null; + return { + expected: `one of [${info.enumValues.map((v) => JSON.stringify(v)).join(", ")}]`, + actual: JSON.stringify(value), + }; + } + if (info.expectedType && info.expectedType !== actual) { + return { expected: info.expectedType, actual: jsType(value) }; + } + return null; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Errors +// ───────────────────────────────────────────────────────────────────────────── + +/** + * A structured validation issue. Every error of a kind reads identically + * because its message template lives in validationMessage — call sites only + * supply the varying parts. + */ +export type ValidationIssue = + | { code: "type-mismatch"; expected: string; actual: string } + | { code: "missing-required"; signature?: string } + | { code: "null-required"; signature?: string } + | { code: "unknown-component"; available?: string[] } + | { code: "inline-reserved" } + | { code: "excess-args"; declared: number; got: number }; + +function validationMessage(component: string, path: string, issue: ValidationIssue): string { + switch (issue.code) { + case "type-mismatch": + return `field "${path}" expects ${issue.expected} but got ${issue.actual}`; + case "missing-required": + return `missing required field "${path}"${issue.signature ? ` — signature: ${issue.signature}` : ""}`; + case "null-required": + return `required field "${path}" cannot be null${issue.signature ? ` — signature: ${issue.signature}` : ""}`; + case "unknown-component": + return `Unknown component "${component}" — not found in catalog or builtins${issue.available?.length ? `. Available components: ${issue.available.join(", ")}` : ""}`; + case "inline-reserved": + return `${component}() must be declared as a top-level statement, not used inline as a value`; + case "excess-args": + return `${component} takes ${issue.declared} arg(s), got ${issue.got} (${issue.got - issue.declared} excess dropped)`; + } +} + +/** Append a validation error, deriving its message from the structured issue. */ +export function pushValidationIssue( + ctx: MaterializeCtx, + component: string, + path: string, + issue: ValidationIssue, +): void { + ctx.errors.push({ + code: issue.code, + component, + path, + message: validationMessage(component, path, issue), + statementId: ctx.currentStatementId, + }); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Invalid-value resolution — the edge rule +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Override invalid value with a default one if present. + * Propagate upwards if it's a required key. + * Else, delete the key. + */ +export function resolveInvalidValue( + container: Record, + key: string, + required: boolean, + defaultValue: unknown, +): boolean { + if (defaultValue !== undefined) { + container[key] = defaultValue; + return false; + } + if (required) return true; + delete container[key]; + return false; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Validators — one per schema shape, dispatched by validateSchemaValue +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Position check for a component element in a data slot: slots that admit + * components ($ref / anyOf / oneOf members) are left unchecked, a slot + * declaring a plain-data shape can never be satisfied by a component, and + * unconstrained slots stay conservative. + */ +function validateElementPosition( + element: ElementNode, + s: Record, + component: string, + path: string, + ctx: MaterializeCtx, +): boolean { + if (isCompositeSchema(s)) return false; + if (typeof s["type"] === "string" || Array.isArray(s["enum"]) || "const" in s) { + pushValidationIssue(ctx, component, path, { + code: "type-mismatch", + expected: typeof s["type"] === "string" ? s["type"] : "a literal value", + actual: `component "${element.typeName}"`, + }); + return true; + } + return false; +} + +/** + * Validate an object-shaped slot: container type, required keys (absent/null + * ones fill silently from their schema default), then each declared property + * recursively, resolving invalid children via the edge rule. + */ +function validateObjectValue( + value: unknown, + s: Record, + component: string, + path: string, + ctx: MaterializeCtx, +): boolean { + if (typeof value !== "object" || Array.isArray(value)) { + pushValidationIssue(ctx, component, path, { + code: "type-mismatch", + expected: "object", + actual: jsType(value), + }); + return true; + } + const obj = value as Record; + const props = + s["properties"] && typeof s["properties"] === "object" + ? (s["properties"] as Record) + : NO_PROPS; + const required = Array.isArray(s["required"]) ? (s["required"] as string[]) : NO_REQUIRED; + let invalid = false; + // Structural checks are streaming-sensitive — only run on complete input. + if (!ctx.partial) { + for (const key of required) { + const present = key in obj; + if (!present || obj[key] == null) { + const fallback = getSchemaDefaultValue(props[key]); + if (fallback !== undefined) { + obj[key] = fallback; + continue; + } + pushValidationIssue(ctx, component, `${path}/${key}`, { + code: present ? "null-required" : "missing-required", + }); + invalid = true; + } + } + } + for (const key of Object.keys(props)) { + if (key in obj) { + const sub = props[key]; + if (validateSchemaValue(obj[key], sub, component, `${path}/${key}`, ctx)) { + if (resolveInvalidValue(obj, key, required.includes(key), getSchemaDefaultValue(sub))) { + invalid = true; + } + } + } + } + return invalid; +} + +/** + * Validate an array slot: Invalid items are pruned in + * place: validated first so error paths keep original indices + */ +function validateArrayValue( + value: unknown, + s: Record, + component: string, + path: string, + ctx: MaterializeCtx, +): boolean { + if (!Array.isArray(value)) { + pushValidationIssue(ctx, component, path, { + code: "type-mismatch", + expected: "array", + actual: jsType(value), + }); + return true; + } + const items = s["items"]; + if (!items || typeof items !== "object" || Array.isArray(items)) return false; + let invalid: number[] | undefined; + for (let i = 0; i < value.length; i++) { + if (validateSchemaValue(value[i], items, component, `${path}/${i}`, ctx)) { + (invalid ??= []).push(i); + } + } + if (invalid) { + for (let i = invalid.length - 1; i >= 0; i--) value.splice(invalid[i], 1); // prune the array + } + return false; +} + +/** + * Validate a scalar/enum leaf. Enum membership defers while streaming — a + * partial literal may still complete toward a valid member; scalar type + * checks stay on (a wrong type won't become right with more input). + */ +function validateLeafValue( + value: unknown, + s: Record, + component: string, + path: string, + ctx: MaterializeCtx, +): boolean { + const leaf = getScalarTypeInfo(s); + if (leaf.expectedType == null && leaf.enumValues == null) return false; + if (leaf.enumValues && ctx.partial) return false; + const mismatch = checkTypeMismatch(value, leaf); + if (mismatch) { + pushValidationIssue(ctx, component, path, { code: "type-mismatch", ...mismatch }); + return true; + } + return false; +} + +/** + * Recursively validate a materialized value against a JSON Schema fragment, + * pruning unsalvageable data along the way. Dispatches on the schema shape: + * objects and arrays descend recursively (reporting nested errors with + * JSON-pointer paths), leaf scalars check type/enum, and component elements + * get a position check only (their own args validate separately). + * + * Every violation is reported, then the same rule (resolveInvalidValue) + * applies at each recursion edge: schema default → substitute; REQUIRED edge → + * parent invalid, caller repeats one level up; OPTIONAL edge → prune. ARRAY + * ITEMS are always pruned. Absent/null required keys fill silently from their + * schema default; reported violations keep their error even when a default steps in. + */ +export function validateSchemaValue( + value: unknown, + schema: unknown, + component: string, + path: string, + ctx: MaterializeCtx, +): boolean { + if (!schema || typeof schema !== "object" || Array.isArray(schema)) return false; + const s = schema as Record; + // Absence is handled by required checks on the parent; skip null/undefined. + if (value == null) return false; + // Dynamic runtime expressions ($var, builtins) resolve later — don't flag. + if (isASTNode(value)) return false; + // Child components: only their position is checked here. + if (isElementNode(value)) return validateElementPosition(value, s, component, path, ctx); + if (isCompositeSchema(s)) return false; + + const type = s["type"]; + if (type === "object") return validateObjectValue(value, s, component, path, ctx); + if (type === "array") return validateArrayValue(value, s, component, path, ctx); + return validateLeafValue(value, s, component, path, ctx); +}