From b81394b411fbd1174a5718a025dd0f16dbb390ab Mon Sep 17 00:00:00 2001 From: Abhin Rustagi Date: Fri, 3 Jul 2026 00:13:01 +0530 Subject: [PATCH 01/31] fix: add cases for web search tool --- .../src/stream/adapters/openai-responses.ts | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/packages/react-headless/src/stream/adapters/openai-responses.ts b/packages/react-headless/src/stream/adapters/openai-responses.ts index 3b912f11b..7c6d3721e 100644 --- a/packages/react-headless/src/stream/adapters/openai-responses.ts +++ b/packages/react-headless/src/stream/adapters/openai-responses.ts @@ -102,6 +102,33 @@ export const openAIResponsesAdapter = (): StreamProtocolAdapter => ({ break; } + case "response.web_search_call.in_progress": + const callId = itemIdToCallId[event.item_id] ?? event.item_id; + yield { + type: EventType.TOOL_CALL_START, + toolCallName: "web_search", + toolCallId: callId, + }; + break; + + case "response.web_search_call.searching": { + const callId = itemIdToCallId[event.item_id] ?? event.item_id; + yield { + type: EventType.TOOL_CALL_CHUNK, + toolCallId: callId, + }; + break; + } + + case "response.web_search_call.completed": { + const callId = itemIdToCallId[event.item_id] ?? event.item_id; + yield { + type: EventType.TOOL_CALL_END, + toolCallId: callId, + }; + break; + } + case "error": yield { type: EventType.RUN_ERROR, From f3d3e2e848ce9f8ce6a900faad1b995a1065b0c1 Mon Sep 17 00:00:00 2001 From: Abhin Rustagi Date: Fri, 3 Jul 2026 13:26:30 +0530 Subject: [PATCH 02/31] fix: add switch cases for web search --- .../src/stream/adapters/openai-responses.ts | 84 ++++++++++++++----- 1 file changed, 61 insertions(+), 23 deletions(-) diff --git a/packages/react-headless/src/stream/adapters/openai-responses.ts b/packages/react-headless/src/stream/adapters/openai-responses.ts index 7c6d3721e..1a0cd4092 100644 --- a/packages/react-headless/src/stream/adapters/openai-responses.ts +++ b/packages/react-headless/src/stream/adapters/openai-responses.ts @@ -4,6 +4,10 @@ import type { } from "openai/resources/responses/responses"; import { AGUIEvent, EventType, StreamProtocolAdapter } from "../../types"; +/** A tool result's `output` as a string (JSON-encoded if structured, "" if absent). */ +const stringifyOutput = (output: unknown): string => + typeof output === "string" ? output : output != null ? JSON.stringify(output) : ""; + export const openAIResponsesAdapter = (): StreamProtocolAdapter => ({ async *parse(response: Response): AsyncIterable { const reader = response.body?.getReader(); @@ -61,8 +65,24 @@ export const openAIResponsesAdapter = (): StreamProtocolAdapter => ({ type: EventType.TOOL_CALL_RESULT, messageId: item.id, toolCallId: item.call_id, - content: - typeof item.output === "string" ? item.output : JSON.stringify(item.output), + content: stringifyOutput(item.output), + }; + } else if (typeof item.type === "string" && item.type.endsWith("_call")) { + // Native server-executed tools (currently web_search) open with a + // `_call` item, not a plain function_call. Key on the item + // id (no separate call_id) and name it by the tool, falling back + // to the type minus `_call`. (MCP is a separate follow-up — see + // .claude/plans/mcp-tool-streaming.md.) + const toolItem = item as { + id?: string; + call_id?: string; + name?: string; + type: string; + }; + yield { + type: EventType.TOOL_CALL_START, + toolCallId: toolItem.id ?? toolItem.call_id ?? toolItem.type, + toolCallName: toolItem.name ?? toolItem.type.replace(/_call$/, ""), }; } break; @@ -102,29 +122,46 @@ export const openAIResponsesAdapter = (): StreamProtocolAdapter => ({ break; } - case "response.web_search_call.in_progress": - const callId = itemIdToCallId[event.item_id] ?? event.item_id; - yield { - type: EventType.TOOL_CALL_START, - toolCallName: "web_search", - toolCallId: callId, + case "response.output_item.done": { + // Native server-executed tools (currently web_search) deliver their + // result on the done item — there's no function_call_output for + // them. function_call closes via function_call_arguments.done and + // message via output_text.done, so both are excluded here. + const item = event.item as { + type?: string; + id?: string; + call_id?: string; + status?: string; + output?: unknown; + error?: unknown; + action?: unknown; }; - break; + const itemType = item.type ?? ""; + if (!itemType.endsWith("_call") || itemType === "function_call") break; - case "response.web_search_call.searching": { - const callId = itemIdToCallId[event.item_id] ?? event.item_id; - yield { - type: EventType.TOOL_CALL_CHUNK, - toolCallId: callId, - }; - break; - } + const toolCallId = item.id ?? item.call_id ?? itemType; - case "response.web_search_call.completed": { - const callId = itemIdToCallId[event.item_id] ?? event.item_id; + // web_search streams no argument deltas — its query lives in + // `action`. Surface it as the tool-call args so the card shows the + // query live, matching a reload's persisted function_call args. + if (item.action && typeof item.action === "object") { + yield { + type: EventType.TOOL_CALL_ARGS, + toolCallId, + delta: JSON.stringify(item.action), + }; + } + + const content = stringifyOutput(item.output); + const isError = item.status === "failed" || item.error != null; yield { - type: EventType.TOOL_CALL_END, - toolCallId: callId, + type: EventType.TOOL_CALL_RESULT, + messageId: toolCallId, + toolCallId, + content, + ...(isError + ? { isError: true, error: typeof item.error === "string" ? item.error : content } + : {}), }; break; } @@ -147,8 +184,9 @@ export const openAIResponsesAdapter = (): StreamProtocolAdapter => ({ // Intentionally unhandled — these are lifecycle/metadata events: // response.created, response.in_progress, response.completed, - // response.content_part.added, response.content_part.done, - // response.output_item.done, etc. + // response.content_part.added, response.content_part.done, the + // per-tool *.in_progress/searching/completed status events (the + // generic output_item.added/.done handling above covers them), etc. default: break; } From 63c66a752f3ee72f97a97d2339921bdf00512bd0 Mon Sep 17 00:00:00 2001 From: Abhin Rustagi Date: Fri, 3 Jul 2026 14:29:24 +0530 Subject: [PATCH 03/31] fix: add web_search_call --- .../src/stream/adapters/openai-responses.ts | 42 ++++++++----------- 1 file changed, 17 insertions(+), 25 deletions(-) diff --git a/packages/react-headless/src/stream/adapters/openai-responses.ts b/packages/react-headless/src/stream/adapters/openai-responses.ts index 1a0cd4092..4dc92150d 100644 --- a/packages/react-headless/src/stream/adapters/openai-responses.ts +++ b/packages/react-headless/src/stream/adapters/openai-responses.ts @@ -67,22 +67,15 @@ export const openAIResponsesAdapter = (): StreamProtocolAdapter => ({ toolCallId: item.call_id, content: stringifyOutput(item.output), }; - } else if (typeof item.type === "string" && item.type.endsWith("_call")) { - // Native server-executed tools (currently web_search) open with a - // `_call` item, not a plain function_call. Key on the item - // id (no separate call_id) and name it by the tool, falling back - // to the type minus `_call`. (MCP is a separate follow-up — see - // .claude/plans/mcp-tool-streaming.md.) - const toolItem = item as { - id?: string; - call_id?: string; - name?: string; - type: string; - }; + } else if (item.type === "web_search_call") { + // web_search is a native OpenAI tool: it opens with a + // web_search_call item rather than a plain function_call and + // carries no separate call_id, so key the tool call on the item + // id (its result + query arrive on output_item.done below). yield { type: EventType.TOOL_CALL_START, - toolCallId: toolItem.id ?? toolItem.call_id ?? toolItem.type, - toolCallName: toolItem.name ?? toolItem.type.replace(/_call$/, ""), + toolCallId: item.id, + toolCallName: "web_search", }; } break; @@ -123,23 +116,21 @@ export const openAIResponsesAdapter = (): StreamProtocolAdapter => ({ } case "response.output_item.done": { - // Native server-executed tools (currently web_search) deliver their - // result on the done item — there's no function_call_output for - // them. function_call closes via function_call_arguments.done and - // message via output_text.done, so both are excluded here. + // web_search delivers its result on the done item — there's no + // function_call_output for it. Every other item type closes via its + // own event (function_call → function_call_arguments.done, message → + // output_text.done), so this case handles web_search only. const item = event.item as { type?: string; id?: string; - call_id?: string; status?: string; output?: unknown; error?: unknown; action?: unknown; }; - const itemType = item.type ?? ""; - if (!itemType.endsWith("_call") || itemType === "function_call") break; + if (item.type !== "web_search_call") break; - const toolCallId = item.id ?? item.call_id ?? itemType; + const toolCallId = item.id ?? "web_search_call"; // web_search streams no argument deltas — its query lives in // `action`. Surface it as the tool-call args so the card shows the @@ -184,9 +175,10 @@ export const openAIResponsesAdapter = (): StreamProtocolAdapter => ({ // Intentionally unhandled — these are lifecycle/metadata events: // response.created, response.in_progress, response.completed, - // response.content_part.added, response.content_part.done, the - // per-tool *.in_progress/searching/completed status events (the - // generic output_item.added/.done handling above covers them), etc. + // response.content_part.added, response.content_part.done, and + // web_search's *.in_progress/searching/completed status events (the + // web_search_call output_item.added/.done handling above covers it), + // etc. default: break; } From 624b1dc80af9247d9d1c7f4ed9732c8a3b029a07 Mon Sep 17 00:00:00 2001 From: Abhin Rustagi Date: Fri, 3 Jul 2026 15:52:53 +0530 Subject: [PATCH 04/31] fix: rm error handling for now --- .../src/stream/adapters/openai-responses.ts | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/packages/react-headless/src/stream/adapters/openai-responses.ts b/packages/react-headless/src/stream/adapters/openai-responses.ts index 4dc92150d..6a289998d 100644 --- a/packages/react-headless/src/stream/adapters/openai-responses.ts +++ b/packages/react-headless/src/stream/adapters/openai-responses.ts @@ -58,9 +58,7 @@ export const openAIResponsesAdapter = (): StreamProtocolAdapter => ({ toolCallName: item.name, }; } else if (item.type === "function_call_output") { - // Fired when a function_call_output we submitted as input is - // integrated into a conversation-linked response — surfaces - // server-side tool execution to the SDK store. + const isError = item.status === "incomplete"; yield { type: EventType.TOOL_CALL_RESULT, messageId: item.id, @@ -68,10 +66,6 @@ export const openAIResponsesAdapter = (): StreamProtocolAdapter => ({ content: stringifyOutput(item.output), }; } else if (item.type === "web_search_call") { - // web_search is a native OpenAI tool: it opens with a - // web_search_call item rather than a plain function_call and - // carries no separate call_id, so key the tool call on the item - // id (its result + query arrive on output_item.done below). yield { type: EventType.TOOL_CALL_START, toolCallId: item.id, @@ -144,15 +138,11 @@ export const openAIResponsesAdapter = (): StreamProtocolAdapter => ({ } const content = stringifyOutput(item.output); - const isError = item.status === "failed" || item.error != null; yield { type: EventType.TOOL_CALL_RESULT, messageId: toolCallId, toolCallId, content, - ...(isError - ? { isError: true, error: typeof item.error === "string" ? item.error : content } - : {}), }; break; } From 2a4719676ffc0fa2842cbca23bc82b287ed4a811 Mon Sep 17 00:00:00 2001 From: Abhin Rustagi Date: Fri, 3 Jul 2026 16:07:04 +0530 Subject: [PATCH 05/31] fix: rename to thesys_web_search --- packages/react-headless/src/stream/adapters/openai-responses.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-headless/src/stream/adapters/openai-responses.ts b/packages/react-headless/src/stream/adapters/openai-responses.ts index 6a289998d..25182c698 100644 --- a/packages/react-headless/src/stream/adapters/openai-responses.ts +++ b/packages/react-headless/src/stream/adapters/openai-responses.ts @@ -69,7 +69,7 @@ export const openAIResponsesAdapter = (): StreamProtocolAdapter => ({ yield { type: EventType.TOOL_CALL_START, toolCallId: item.id, - toolCallName: "web_search", + toolCallName: "thesys_web_search", }; } break; From d4c4083cd7ed9c8f9279a8458e7de71a86c001f5 Mon Sep 17 00:00:00 2001 From: Abhin Rustagi Date: Fri, 3 Jul 2026 19:07:48 +0530 Subject: [PATCH 06/31] chore: bump version --- packages/react-headless/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-headless/package.json b/packages/react-headless/package.json index 6d7ab2f9c..78a79a278 100644 --- a/packages/react-headless/package.json +++ b/packages/react-headless/package.json @@ -1,6 +1,6 @@ { "name": "@openuidev/react-headless", - "version": "0.9.1", + "version": "0.9.2", "description": "Headless React primitives for AI chat — state management, streaming adapters for OpenAI and AG-UI, message format converters, and thread management for OpenUI generative UI apps", "license": "MIT", "type": "module", From e44591a494a2696cb43e0cf0ab3395dfbfbddd91 Mon Sep 17 00:00:00 2001 From: Abhin Rustagi Date: Sat, 4 Jul 2026 14:57:30 +0530 Subject: [PATCH 07/31] fix: rm unused var --- packages/react-headless/src/stream/adapters/openai-responses.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/react-headless/src/stream/adapters/openai-responses.ts b/packages/react-headless/src/stream/adapters/openai-responses.ts index 25182c698..384959745 100644 --- a/packages/react-headless/src/stream/adapters/openai-responses.ts +++ b/packages/react-headless/src/stream/adapters/openai-responses.ts @@ -58,7 +58,6 @@ export const openAIResponsesAdapter = (): StreamProtocolAdapter => ({ toolCallName: item.name, }; } else if (item.type === "function_call_output") { - const isError = item.status === "incomplete"; yield { type: EventType.TOOL_CALL_RESULT, messageId: item.id, From e4999bc1410488c139765e9b5b548ab50d36d86b Mon Sep 17 00:00:00 2001 From: Abhin Rustagi Date: Tue, 7 Jul 2026 16:20:40 +0530 Subject: [PATCH 08/31] fix: update schema validation --- packages/lang-core/src/parser/materialize.ts | 190 +++++++++++++++++-- packages/lang-core/src/parser/parser.ts | 1 + packages/lang-core/src/parser/types.ts | 8 +- 3 files changed, 186 insertions(+), 13 deletions(-) diff --git a/packages/lang-core/src/parser/materialize.ts b/packages/lang-core/src/parser/materialize.ts index f97bab3f2..45740de47 100644 --- a/packages/lang-core/src/parser/materialize.ts +++ b/packages/lang-core/src/parser/materialize.ts @@ -5,7 +5,7 @@ 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 ParamMap, type ScalarParamType, type ValidationError } from "./types"; /** * Recursively check if a prop value contains any AST nodes that need runtime @@ -35,6 +35,170 @@ export interface MaterializeCtx { unreached?: Set; } +/** 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[]; +} + +/** + * Check a materialized value against a scalar leaf's declared type/enum. + * Returns a human-readable {expected, actual} on mismatch, or null when it + * passes or isn't checkable. Only concrete scalar literals are checked. + */ +function describeTypeMismatch( + value: unknown, + info: ScalarTypeInfo, +): { expected: string; actual: string } | null { + const actual = typeof value; + if (!["string", "number", "boolean"].includes(actual)) { + return null; + } + if (info.enumValues) { + 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 }; + } + return null; +} + +/** Extract the scalar leaf type/enum from an already-narrowed JSON Schema object. */ +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 {}; + } +} + +function pushValidationError( + ctx: MaterializeCtx, + error: Omit, +): void { + ctx.errors.push({ ...error, statementId: ctx.currentStatementId }); +} + +/** + * Recursively validate a materialized value against a JSON Schema fragment. + * + * Descends into `type: object` (checking required keys and each declared + * property) and `type: array` (checking every element against `items`), + * reporting nested errors with JSON-pointer paths. Leaf scalars are checked + * for type/enum mismatches. + * + * Conservative — silently skips anything it can't reliably check: + * - composite shapes ($ref / anyOf / oneOf / allOf) + * - dynamic values (runtime AST nodes) and child components (ElementNodes, + * which are validated separately as their own elements) + * - required-key checks while streaming is in progress + */ +export function validateSchemaValue( + value: unknown, + schema: unknown, + component: string, + path: string, + ctx: MaterializeCtx, +): void { + if (!schema || typeof schema !== "object" || Array.isArray(schema)) return; + const s = schema as Record; + // Composite shapes can't be reliably matched to a single value — skip. + if ("$ref" in s || "anyOf" in s || "oneOf" in s || "allOf" in s) return; + // Absence is handled by required checks on the parent; skip null/undefined. + if (value == null) return; + // Dynamic runtime expressions ($var, builtins) resolve later — don't flag. + if (isASTNode(value)) return; + // Child components validate themselves when materialized as elements. + if (isElementNode(value)) return; + + const type = s["type"]; + + if (type === "object") { + if (typeof value !== "object" || Array.isArray(value)) { + pushValidationError(ctx, { + code: "type-mismatch", + component, + path, + message: `field "${path}" expects object but got ${Array.isArray(value) ? "array" : typeof value}`, + }); + return; + } + const obj = value as Record; + const props = + s["properties"] && typeof s["properties"] === "object" + ? (s["properties"] as Record) + : {}; + // Structural checks are streaming-sensitive — only run on complete input. + if (!ctx.partial) { + const required = Array.isArray(s["required"]) ? (s["required"] as string[]) : []; + for (const key of required) { + if (!(key in obj) || obj[key] == null) { + const isNull = key in obj; + pushValidationError(ctx, { + code: isNull ? "null-required" : "missing-required", + component, + path: `${path}/${key}`, + message: isNull + ? `required field "${path}/${key}" cannot be null` + : `missing required field "${path}/${key}"`, + }); + } + } + } + for (const [key, sub] of Object.entries(props)) { + if (key in obj) { + validateSchemaValue(obj[key], sub, component, `${path}/${key}`, ctx); + } + } + return; + } + + if (type === "array") { + if (!Array.isArray(value)) { + pushValidationError(ctx, { + code: "type-mismatch", + component, + path, + message: `field "${path}" expects array but got ${typeof value}`, + }); + return; + } + const items = s["items"]; + // Only single-schema `items` (not tuple form) is checked. + if (items && typeof items === "object" && !Array.isArray(items)) { + value.forEach((el, i) => validateSchemaValue(el, items, component, `${path}/${i}`, ctx)); + } + return; + } + + // Leaf scalar — reuse the scalar/enum mismatch logic. + const leaf = getScalarTypeInfo(s); + if (leaf.expectedType == null && leaf.enumValues == null) return; + const mismatch = describeTypeMismatch(value, leaf); + if (mismatch) { + pushValidationError(ctx, { + code: "type-mismatch", + component, + path, + message: `field "${path}" expects ${mismatch.expected} but got ${mismatch.actual}`, + }); + } +} + /** * Resolve a Ref node: inline from symbol table, detect cycles, emit RuntimeRef * for Query/Mutation declarations. Shared by materializeValue and materializeExpr. @@ -126,12 +290,11 @@ function materializeExprInternal( return { ...node, args: recursedArgs, mappedProps }; } // Unknown component in expression: push error (same as value path) - ctx.errors.push({ + pushValidationError(ctx, { code: "unknown-component", component: node.name, path: "", message: `Unknown component "${node.name}" — not found in catalog or builtins`, - statementId: ctx.currentStatementId, }); return { ...node, args: recursedArgs }; } @@ -248,12 +411,11 @@ 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({ + pushValidationError(ctx, { 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, }); return null; } @@ -264,18 +426,24 @@ export function materializeValue(node: ASTNode, ctx: MaterializeCtx): unknown { if (def) { // 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 for nested object/array shapes. + if (param.schema !== undefined) { + validateSchemaValue(value, param.schema, name, `/${param.name}`, ctx); + } } // 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({ + pushValidationError(ctx, { code: "excess-args", component: name, path: "", message: `${name} takes ${def.params.length} arg(s), got ${args.length} (${excessCount} excess dropped)`, - statementId: ctx.currentStatementId, }); } @@ -294,14 +462,13 @@ 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({ + pushValidationError(ctx, { 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, }); } return null; @@ -309,12 +476,11 @@ export function materializeValue(node: ASTNode, ctx: MaterializeCtx): unknown { } } else if (!isBuiltin(name) && !isReservedCall(name)) { // Unknown component: error and drop from tree - ctx.errors.push({ + pushValidationError(ctx, { code: "unknown-component", component: name, path: "", message: `Unknown component "${name}" — not found in catalog or builtins`, - statementId: ctx.currentStatementId, }); return null; } diff --git a/packages/lang-core/src/parser/parser.ts b/packages/lang-core/src/parser/parser.ts index 32b443aca..944b300c3 100644 --- a/packages/lang-core/src/parser/parser.ts +++ b/packages/lang-core/src/parser/parser.ts @@ -648,6 +648,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/types.ts b/packages/lang-core/src/parser/types.ts index dbe32229c..77d8e4fb1 100644 --- a/packages/lang-core/src/parser/types.ts +++ b/packages/lang-core/src/parser/types.ts @@ -14,6 +14,9 @@ export interface LibraryJSONSchema { >; } +/** 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; @@ -21,6 +24,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. */ @@ -73,7 +78,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 From b8a1444ebd523e3cf47e4d3c74ed2e9b325bc1e7 Mon Sep 17 00:00:00 2001 From: Abhin Rustagi Date: Tue, 7 Jul 2026 16:30:40 +0530 Subject: [PATCH 09/31] fix: version bump --- packages/lang-core/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/lang-core/package.json b/packages/lang-core/package.json index e88caa981..c7063a6d0 100644 --- a/packages/lang-core/package.json +++ b/packages/lang-core/package.json @@ -1,6 +1,6 @@ { "name": "@openuidev/lang-core", - "version": "0.2.7", + "version": "0.2.8", "description": "Framework-agnostic core for OpenUI Lang: parser, prompt generation, validation, and type definitions", "license": "MIT", "type": "module", From fe2b91d04e9356750d76adec87d4f1d7feade0a4 Mon Sep 17 00:00:00 2001 From: Abhin Rustagi Date: Tue, 7 Jul 2026 18:28:09 +0530 Subject: [PATCH 10/31] fix: stricter checks --- packages/lang-core/src/parser/materialize.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/lang-core/src/parser/materialize.ts b/packages/lang-core/src/parser/materialize.ts index 45740de47..f6ad29cbd 100644 --- a/packages/lang-core/src/parser/materialize.ts +++ b/packages/lang-core/src/parser/materialize.ts @@ -46,17 +46,19 @@ interface ScalarTypeInfo { /** * Check a materialized value against a scalar leaf's declared type/enum. * Returns a human-readable {expected, actual} on mismatch, or null when it - * passes or isn't checkable. Only concrete scalar literals are checked. + * passes or isn't checkable. Values reaching this point are concrete literals + * (dynamic/element/null values are filtered out by validateSchemaValue), so a + * declared scalar type also flags compound values (object/array). Enum + * membership is only checked for scalar literals — `includes` compares + * non-scalar enum members by reference, which could never match. */ function describeTypeMismatch( value: unknown, info: ScalarTypeInfo, ): { expected: string; actual: string } | null { const actual = typeof value; - if (!["string", "number", "boolean"].includes(actual)) { - return null; - } if (info.enumValues) { + if (!["string", "number", "boolean"].includes(actual)) return null; if (info.enumValues.includes(value)) return null; return { expected: `one of [${info.enumValues.map((v) => JSON.stringify(v)).join(", ")}]`, @@ -64,7 +66,7 @@ function describeTypeMismatch( }; } if (info.expectedType && info.expectedType !== actual) { - return { expected: info.expectedType, actual }; + return { expected: info.expectedType, actual: Array.isArray(value) ? "array" : actual }; } return null; } From 72af83ce1baebffbdef61ab4bc7d6f07ca67a617 Mon Sep 17 00:00:00 2001 From: Abhin Rustagi Date: Thu, 9 Jul 2026 10:58:56 +0530 Subject: [PATCH 11/31] fix: mv files into validation.ts --- .../__tests__/materialize.validation.test.ts | 552 ++++++++++++++++++ packages/lang-core/src/parser/materialize.ts | 182 +----- packages/lang-core/src/parser/parser.ts | 3 +- packages/lang-core/src/parser/types.ts | 13 + packages/lang-core/src/parser/validation.ts | 168 ++++++ 5 files changed, 737 insertions(+), 181 deletions(-) create mode 100644 packages/lang-core/src/parser/__tests__/materialize.validation.test.ts create mode 100644 packages/lang-core/src/parser/validation.ts 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..9b2a9b947 --- /dev/null +++ b/packages/lang-core/src/parser/__tests__/materialize.validation.test.ts @@ -0,0 +1,552 @@ +import { describe, expect, it } from "vitest"; +import type { MaterializeCtx } from "../types"; +import { validateSchemaValue } from "../validation"; +import { createParser, createStreamingParser } from "../parser"; +import type { LibraryJSONSchema, ValidationError } from "../types"; + +/** + * Exercises the recursive nested validator (validateSchemaValue): objects, + * arrays, and the items inside them. Prior validation only checked a + * positional arg's presence; these cover descent into `type: object` / + * `type: array` and their leaves. + * + * 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 → `groups`: array → object → array → number (deep recursion) + DeepBox: { + properties: { + groups: { + type: "array", + items: { + type: "object", + properties: { + name: { type: "string" }, + points: { type: "array", items: { type: "number" } }, + }, + required: ["name"], + }, + }, + }, + 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 → `qty`: `integer` maps to the number check + IntBox: { + properties: { qty: { type: "integer" } }, + required: [], + }, + // arg0 → `mode`: `const` behaves as a single-value enum + ConstBox: { + properties: { mode: { const: "fixed" } }, + required: [], + }, + // arg0 → `theme`: required with a schema default — applied, not validated + DefBox: { + properties: { theme: { type: "string", default: "dark" } }, + required: ["theme"], + }, + // 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 → `levels`: enum leaves nested inside array items + LevelBox: { + properties: { levels: { type: "array", items: { enum: [1, 2, 3] } } }, + required: [], + }, + }, +}; + +const parser = createParser(schema); + +function errorsFor(src: string): ValidationError[] { + return parser.parse(src).meta.errors; +} + +describe("validateSchemaValue — nested objects", () => { + it("accepts a well-typed object with its required key present", () => { + expect(errorsFor('root = ObjBox({ author: "ann", views: 3 })')).toEqual([]); + }); + + it("flags a wrong scalar type on a nested object leaf", () => { + const errors = errorsFor('root = ObjBox({ author: "ann", views: "lots" })'); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ + code: "type-mismatch", + component: "ObjBox", + path: "/info/views", + statementId: "root", + }); + expect(errors[0].message).toContain("expects number but got string"); + }); + + it("flags a missing required key inside the object", () => { + const errors = errorsFor("root = ObjBox({ views: 3 })"); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ + code: "missing-required", + component: "ObjBox", + path: "/info/author", + }); + }); + + it("flags a null required key inside the object", () => { + const errors = errorsFor("root = ObjBox({ author: null, views: 3 })"); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ + code: "null-required", + path: "/info/author", + }); + }); + + it("flags a scalar where an object is expected (and skips inner checks)", () => { + const errors = errorsFor('root = ObjBox("hello")'); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ code: "type-mismatch", path: "/info" }); + expect(errors[0].message).toContain("expects object but got string"); + }); +}); + +describe("validateSchemaValue — arrays and their items", () => { + it("accepts an array of well-typed objects (optional leaf omitted)", () => { + expect(errorsFor('root = ArrBox([{ label: "a", value: 1 }, { label: "b" }])')).toEqual([]); + }); + + it("flags a wrong scalar type inside an array item, with the element index", () => { + const errors = errorsFor('root = ArrBox([{ label: "a", value: "x" }])'); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ + code: "type-mismatch", + path: "/rows/0/value", + }); + expect(errors[0].message).toContain("expects number but got string"); + }); + + it("flags a missing required key on a specific array element", () => { + const errors = errorsFor('root = ArrBox([{ label: "a" }, { value: 2 }])'); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ + code: "missing-required", + path: "/rows/1/label", + }); + }); + + it("flags a non-array where an array is expected", () => { + const errors = errorsFor('root = ArrBox({ label: "a" })'); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ code: "type-mismatch", path: "/rows" }); + expect(errors[0].message).toContain("expects array but got object"); + }); + + it("accepts a homogeneous array of scalars", () => { + expect(errorsFor('root = TagBox(["a", "b", "c"])')).toEqual([]); + }); + + it("flags a bad scalar element in a scalar array at its index", () => { + const errors = errorsFor('root = TagBox(["a", 2, "c"])'); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ + code: "type-mismatch", + path: "/tags/1", + }); + expect(errors[0].message).toContain("expects string but got number"); + }); +}); + +describe("validateSchemaValue — enums and deep recursion", () => { + it("accepts a valid enum value", () => { + expect(errorsFor('root = EnumBox("active")')).toEqual([]); + }); + + it("flags an out-of-enum value", () => { + const errors = errorsFor('root = EnumBox("bogus")'); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ code: "type-mismatch", path: "/status" }); + expect(errors[0].message).toContain("one of"); + }); + + it("recurses array → object → array → scalar and reports the deep path", () => { + const errors = errorsFor('root = DeepBox([{ name: "g1", points: [1, "two", 3] }])'); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ + code: "type-mismatch", + path: "/groups/0/points/1", + }); + }); + + it("reports a required key missing deep inside a nested array item", () => { + const errors = errorsFor("root = DeepBox([{ points: [1, 2] }])"); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ + code: "missing-required", + path: "/groups/0/name", + }); + }); + + it("collects independent errors across multiple array elements", () => { + const errors = errorsFor('root = ArrBox([{ label: 1 }, { value: "x" }])'); + // el0: label is number-not-string. + // el1: label missing (required) AND value is string-not-number. + expect(errors).toHaveLength(3); + const byPath = errors.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", + ]); + }); +}); + +describe("validateSchemaValue — conservative skips", () => { + it("does not flag dynamic ($var) elements against the item schema", () => { + // $x resolves at runtime; it must not be type-checked as a string. + expect(errorsFor('root = TagBox(["a", $x])')).toEqual([]); + }); + + it("leaves composite (anyOf) schemas unchecked", () => { + expect(errorsFor("root = AnyBox({ anything: true })")).toEqual([]); + }); + + it("suppresses nested required-key checks while streaming is incomplete", () => { + const sp = createStreamingParser(schema); + // Trailing, unterminated object → parse is marked incomplete (partial). + const res = sp.push("root = ObjBox({ views: 3 "); + expect(res.meta.incomplete).toBe(true); + expect(res.meta.errors.some((e) => e.code === "missing-required")).toBe(false); + }); +}); + +describe("validateSchemaValue — top-level scalar params", () => { + it("accepts matching scalar types across all three leaves", () => { + expect(errorsFor('root = ScalarBox("a", 1, true)')).toEqual([]); + }); + + it("flags a number where a string is expected", () => { + const errors = errorsFor("root = ScalarBox(5)"); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ code: "type-mismatch", path: "/title" }); + expect(errors[0].message).toContain("expects string but got number"); + }); + + it("flags a string where a number is expected", () => { + const errors = errorsFor('root = ScalarBox("a", "b")'); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ code: "type-mismatch", path: "/count" }); + expect(errors[0].message).toContain("expects number but got string"); + }); + + it("flags a string where a boolean is expected", () => { + const errors = errorsFor('root = ScalarBox("a", 1, "yes")'); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ code: "type-mismatch", path: "/flag" }); + expect(errors[0].message).toContain("expects boolean but got string"); + }); + + it("accepts an integer against `type: integer`", () => { + expect(errorsFor("root = IntBox(3)")).toEqual([]); + }); + + it("flags a string against `type: integer`", () => { + const errors = errorsFor('root = IntBox("3")'); + expect(errors).toHaveLength(1); + expect(errors[0].message).toContain("expects number but got string"); + }); + + it("does not flag a float against `type: integer` (integer ≈ number, conservative)", () => { + expect(errorsFor("root = IntBox(1.5)")).toEqual([]); + }); + + it("accepts the exact `const` value", () => { + expect(errorsFor('root = ConstBox("fixed")')).toEqual([]); + }); + + it("flags a non-matching value against `const` as a single-value enum", () => { + const errors = errorsFor('root = ConstBox("loose")'); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ code: "type-mismatch", path: "/mode" }); + expect(errors[0].message).toContain('one of ["fixed"]'); + }); + + it("flags a boolean against a string enum", () => { + const errors = errorsFor("root = EnumBox(true)"); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ code: "type-mismatch", path: "/status" }); + expect(errors[0].message).toContain("one of"); + }); + + it("flags an object where a scalar is expected", () => { + const errors = errorsFor("root = ScalarBox({ a: 1 })"); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ code: "type-mismatch", path: "/title" }); + expect(errors[0].message).toContain("expects string but got object"); + }); + + it("flags an array where a scalar is expected", () => { + const errors = errorsFor("root = ScalarBox([1, 2])"); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ code: "type-mismatch", path: "/title" }); + expect(errors[0].message).toContain("expects string but got array"); + }); + + it("flags an object with dynamic parts — it stays an object at runtime", () => { + const errors = errorsFor("root = ScalarBox({ a: $x })"); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ code: "type-mismatch", path: "/title" }); + expect(errors[0].message).toContain("expects string but got object"); + }); +}); + +describe("validateSchemaValue — conservative skips on leaves and values", () => { + it("does not flag an object against an enum leaf (reference equality could never match)", () => { + expect(errorsFor("root = EnumBox({ a: 1 })")).toEqual([]); + }); + + it("skips a null arg (absence is the parent required-check's concern)", () => { + expect(errorsFor("root = ObjBox(null)")).toEqual([]); + }); + + it("skips validation entirely when the arg is omitted", () => { + expect(errorsFor("root = ObjBox()")).toEqual([]); + }); + + it("ignores extra keys not declared in the object schema", () => { + expect(errorsFor('root = ObjBox({ author: "a", bonus: 99 })')).toEqual([]); + }); + + it("skips builtin calls as leaf values (they resolve at runtime)", () => { + expect(errorsFor('root = ObjBox({ author: "a", views: Sum([1, 2]) })')).toEqual([]); + }); + + it("skips a nested component element against a scalar leaf", () => { + expect(errorsFor('root = ObjBox({ author: CardBox("hi"), views: 1 })')).toEqual([]); + }); + + it("still validates the nested component's own args", () => { + const errors = errorsFor("root = ObjBox({ author: CardBox(9) })"); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ + code: "type-mismatch", + component: "CardBox", + path: "/text", + }); + expect(errors[0].message).toContain("expects string but got number"); + }); +}); + +describe("validateSchemaValue — shape mismatch variants", () => { + it("flags an array where an object is expected", () => { + const errors = errorsFor("root = ObjBox([1])"); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ code: "type-mismatch", path: "/info" }); + expect(errors[0].message).toContain("expects object but got array"); + }); + + it("flags a number where an array is expected", () => { + const errors = errorsFor("root = TagBox(42)"); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ code: "type-mismatch", path: "/tags" }); + expect(errors[0].message).toContain("expects array but got number"); + }); + + it("leaves elements unchecked when the array schema has no `items`", () => { + expect(errorsFor('root = ListBox([1, "a", true])')).toEqual([]); + }); + + it("flags out-of-enum values nested inside array items", () => { + const errors = errorsFor("root = LevelBox([1, 5])"); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ code: "type-mismatch", path: "/levels/1" }); + expect(errors[0].message).toContain("one of"); + }); + + it("flags an object against a scalar leaf nested inside an object", () => { + const errors = errorsFor('root = ObjBox({ author: { first: "a" }, views: 3 })'); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ code: "type-mismatch", path: "/info/author" }); + expect(errors[0].message).toContain("expects string but got object"); + }); +}); + +describe("validateSchemaValue — malformed and composite schemas (direct)", () => { + 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; + } + + it("skips non-object schemas (undefined, null, string, boolean, array)", () => { + expect(directErrors(5, undefined)).toEqual([]); + expect(directErrors(5, null)).toEqual([]); + expect(directErrors(5, "string")).toEqual([]); + expect(directErrors(5, true)).toEqual([]); + expect(directErrors(5, [{ type: "number" }])).toEqual([]); + }); + + it("skips $ref / oneOf / allOf composites", () => { + expect(directErrors(5, { $ref: "#/$defs/Thing" })).toEqual([]); + expect(directErrors(5, { oneOf: [{ type: "string" }] })).toEqual([]); + expect(directErrors(5, { allOf: [{ type: "string" }] })).toEqual([]); + }); + + it("skips undefined values", () => { + expect(directErrors(undefined, { type: "string" })).toEqual([]); + }); + + it("skips tuple-form `items`", () => { + expect(directErrors([5], { type: "array", items: [{ type: "string" }] })).toEqual([]); + }); + + it("skips unknown scalar `type` keywords", () => { + expect(directErrors("x", { type: "date" })).toEqual([]); + expect(directErrors("x", {})).toEqual([]); + }); + + it("ignores a non-array `required`", () => { + expect(directErrors({}, { type: "object", required: "author" })).toEqual([]); + }); + + it("checks `required` even when `properties` is absent", () => { + const errors = directErrors({}, { type: "object", required: ["a"] }); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ code: "missing-required", path: "/p/a" }); + }); + + it("suppresses required checks in a partial ctx but keeps type checks", () => { + expect(directErrors({}, { type: "object", required: ["a"] }, true)).toEqual([]); + const errors = directErrors(5, { type: "string" }, true); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ code: "type-mismatch" }); + }); +}); + +describe("validateSchemaValue — attribution and interplay with other checks", () => { + it("attributes errors to the statement that defines the component", () => { + const errors = errorsFor( + 'root = ListBox([inner])\ninner = ObjBox({ author: "ann", views: "lots" })', + ); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ + code: "type-mismatch", + component: "ObjBox", + path: "/info/views", + statementId: "inner", + }); + }); + + it("applies a schema default instead of erroring, without validating it", () => { + const result = parser.parse("root = DefBox()"); + expect(result.meta.errors).toEqual([]); + expect(result.root?.props.theme).toBe("dark"); + }); + + it("reports excess args alongside validation of the mapped args", () => { + const errors = errorsFor('root = ScalarBox(1, 1, true, "extra")'); + expect(errors).toHaveLength(2); + const codes = errors.map((e) => e.code).sort(); + expect(codes).toEqual(["excess-args", "type-mismatch"]); + expect(errors.find((e) => e.code === "type-mismatch")).toMatchObject({ path: "/title" }); + }); + + it("accumulates independent errors across sibling leaves of one object", () => { + const errors = errorsFor('root = ObjBox({ author: 7, views: "x" })'); + expect(errors).toHaveLength(2); + const byPath = errors.map((e) => `${e.code}:${e.path}`).sort(); + expect(byPath).toEqual(["type-mismatch:/info/author", "type-mismatch:/info/views"]); + }); +}); + +describe("validateSchemaValue — streaming", () => { + it("still flags type mismatches while streaming is incomplete", () => { + const sp = createStreamingParser(schema); + const res = sp.push('root = ObjBox({ author: "a", views: "lots" '); + expect(res.meta.incomplete).toBe(true); + const mismatch = res.meta.errors.find((e) => e.code === "type-mismatch"); + expect(mismatch).toMatchObject({ path: "/info/views" }); + }); + + it("reports suppressed required checks once the stream completes", () => { + const sp = createStreamingParser(schema); + const mid = sp.push("root = ObjBox({ views: 3 "); + expect(mid.meta.errors.some((e) => e.code === "missing-required")).toBe(false); + const done = sp.push("})\n"); + expect(done.meta.incomplete).toBe(false); + const missing = done.meta.errors.find((e) => e.code === "missing-required"); + expect(missing).toMatchObject({ path: "/info/author", statementId: "root" }); + }); +}); diff --git a/packages/lang-core/src/parser/materialize.ts b/packages/lang-core/src/parser/materialize.ts index f6ad29cbd..76de53584 100644 --- a/packages/lang-core/src/parser/materialize.ts +++ b/packages/lang-core/src/parser/materialize.ts @@ -5,7 +5,8 @@ import type { ASTNode } from "./ast"; import { isASTNode, isRuntimeExpr } from "./ast"; import { isBuiltin, isReservedCall, LAZY_BUILTINS, RESERVED_CALLS } from "./builtins"; -import { isElementNode, type ParamMap, type ScalarParamType, type ValidationError } from "./types"; +import { isElementNode, type MaterializeCtx, type ParamMap } from "./types"; +import { pushValidationError, validateSchemaValue } from "./validation"; /** * Recursively check if a prop value contains any AST nodes that need runtime @@ -22,185 +23,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; -} - -/** 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[]; -} - -/** - * Check a materialized value against a scalar leaf's declared type/enum. - * Returns a human-readable {expected, actual} on mismatch, or null when it - * passes or isn't checkable. Values reaching this point are concrete literals - * (dynamic/element/null values are filtered out by validateSchemaValue), so a - * declared scalar type also flags compound values (object/array). Enum - * membership is only checked for scalar literals — `includes` compares - * non-scalar enum members by reference, which could never match. - */ -function describeTypeMismatch( - value: unknown, - info: ScalarTypeInfo, -): { expected: string; actual: string } | null { - const actual = typeof value; - if (info.enumValues) { - if (!["string", "number", "boolean"].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: Array.isArray(value) ? "array" : actual }; - } - return null; -} - -/** Extract the scalar leaf type/enum from an already-narrowed JSON Schema object. */ -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 {}; - } -} - -function pushValidationError( - ctx: MaterializeCtx, - error: Omit, -): void { - ctx.errors.push({ ...error, statementId: ctx.currentStatementId }); -} - -/** - * Recursively validate a materialized value against a JSON Schema fragment. - * - * Descends into `type: object` (checking required keys and each declared - * property) and `type: array` (checking every element against `items`), - * reporting nested errors with JSON-pointer paths. Leaf scalars are checked - * for type/enum mismatches. - * - * Conservative — silently skips anything it can't reliably check: - * - composite shapes ($ref / anyOf / oneOf / allOf) - * - dynamic values (runtime AST nodes) and child components (ElementNodes, - * which are validated separately as their own elements) - * - required-key checks while streaming is in progress - */ -export function validateSchemaValue( - value: unknown, - schema: unknown, - component: string, - path: string, - ctx: MaterializeCtx, -): void { - if (!schema || typeof schema !== "object" || Array.isArray(schema)) return; - const s = schema as Record; - // Composite shapes can't be reliably matched to a single value — skip. - if ("$ref" in s || "anyOf" in s || "oneOf" in s || "allOf" in s) return; - // Absence is handled by required checks on the parent; skip null/undefined. - if (value == null) return; - // Dynamic runtime expressions ($var, builtins) resolve later — don't flag. - if (isASTNode(value)) return; - // Child components validate themselves when materialized as elements. - if (isElementNode(value)) return; - - const type = s["type"]; - - if (type === "object") { - if (typeof value !== "object" || Array.isArray(value)) { - pushValidationError(ctx, { - code: "type-mismatch", - component, - path, - message: `field "${path}" expects object but got ${Array.isArray(value) ? "array" : typeof value}`, - }); - return; - } - const obj = value as Record; - const props = - s["properties"] && typeof s["properties"] === "object" - ? (s["properties"] as Record) - : {}; - // Structural checks are streaming-sensitive — only run on complete input. - if (!ctx.partial) { - const required = Array.isArray(s["required"]) ? (s["required"] as string[]) : []; - for (const key of required) { - if (!(key in obj) || obj[key] == null) { - const isNull = key in obj; - pushValidationError(ctx, { - code: isNull ? "null-required" : "missing-required", - component, - path: `${path}/${key}`, - message: isNull - ? `required field "${path}/${key}" cannot be null` - : `missing required field "${path}/${key}"`, - }); - } - } - } - for (const [key, sub] of Object.entries(props)) { - if (key in obj) { - validateSchemaValue(obj[key], sub, component, `${path}/${key}`, ctx); - } - } - return; - } - - if (type === "array") { - if (!Array.isArray(value)) { - pushValidationError(ctx, { - code: "type-mismatch", - component, - path, - message: `field "${path}" expects array but got ${typeof value}`, - }); - return; - } - const items = s["items"]; - // Only single-schema `items` (not tuple form) is checked. - if (items && typeof items === "object" && !Array.isArray(items)) { - value.forEach((el, i) => validateSchemaValue(el, items, component, `${path}/${i}`, ctx)); - } - return; - } - - // Leaf scalar — reuse the scalar/enum mismatch logic. - const leaf = getScalarTypeInfo(s); - if (leaf.expectedType == null && leaf.enumValues == null) return; - const mismatch = describeTypeMismatch(value, leaf); - if (mismatch) { - pushValidationError(ctx, { - code: "type-mismatch", - component, - path, - message: `field "${path}" expects ${mismatch.expected} but got ${mismatch.actual}`, - }); - } -} - /** * Resolve a Ref node: inline from symbol table, detect cycles, emit RuntimeRef * for Query/Mutation declarations. Shared by materializeValue and materializeExpr. diff --git a/packages/lang-core/src/parser/parser.ts b/packages/lang-core/src/parser/parser.ts index 944b300c3..e3be6fef5 100644 --- a/packages/lang-core/src/parser/parser.ts +++ b/packages/lang-core/src/parser/parser.ts @@ -3,12 +3,13 @@ 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, diff --git a/packages/lang-core/src/parser/types.ts b/packages/lang-core/src/parser/types.ts index 77d8e4fb1..09843547d 100644 --- a/packages/lang-core/src/parser/types.ts +++ b/packages/lang-core/src/parser/types.ts @@ -98,6 +98,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..69826f9d9 --- /dev/null +++ b/packages/lang-core/src/parser/validation.ts @@ -0,0 +1,168 @@ +import { isASTNode } from "./ast"; +import { isElementNode, type MaterializeCtx, type ScalarParamType, type ValidationError } from "./types"; + +/** 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[]; +} + +/** + * Check a materialized value against a scalar leaf's declared type/enum. + * Returns a human-readable {expected, actual} on mismatch, or null when it + * passes or isn't checkable. Values reaching this point are concrete literals + * (dynamic/element/null values are filtered out by validateSchemaValue), so a + * declared scalar type also flags compound values (object/array). Enum + * membership is only checked for scalar literals — `includes` compares + * non-scalar enum members by reference, which could never match. + */ +function describeTypeMismatch( + value: unknown, + info: ScalarTypeInfo, +): { expected: string; actual: string } | null { + const actual = typeof value; + if (info.enumValues) { + if (!["string", "number", "boolean"].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: Array.isArray(value) ? "array" : actual }; + } + return null; +} + +/** Extract the scalar leaf type/enum from an already-narrowed JSON Schema object. */ +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 {}; + } +} + +export function pushValidationError( + ctx: MaterializeCtx, + error: Omit, +): void { + ctx.errors.push({ ...error, statementId: ctx.currentStatementId }); +} + +/** + * Recursively validate a materialized value against a JSON Schema fragment. + * + * Descends into `type: object` (checking required keys and each declared + * property) and `type: array` (checking every element against `items`), + * reporting nested errors with JSON-pointer paths. Leaf scalars are checked + * for type/enum mismatches. + * + * Conservative — silently skips anything it can't reliably check: + * - composite shapes ($ref / anyOf / oneOf / allOf) + * - dynamic values (runtime AST nodes) and child components (ElementNodes, + * which are validated separately as their own elements) + * - required-key checks while streaming is in progress + */ +export function validateSchemaValue( + value: unknown, + schema: unknown, + component: string, + path: string, + ctx: MaterializeCtx, +): void { + if (!schema || typeof schema !== "object" || Array.isArray(schema)) return; + const s = schema as Record; + // Composite shapes can't be reliably matched to a single value — skip. + if ("$ref" in s || "anyOf" in s || "oneOf" in s || "allOf" in s) return; + // Absence is handled by required checks on the parent; skip null/undefined. + if (value == null) return; + // Dynamic runtime expressions ($var, builtins) resolve later — don't flag. + if (isASTNode(value)) return; + // Child components validate themselves when materialized as elements. + if (isElementNode(value)) return; + + const type = s["type"]; + + if (type === "object") { + if (typeof value !== "object" || Array.isArray(value)) { + pushValidationError(ctx, { + code: "type-mismatch", + component, + path, + message: `field "${path}" expects object but got ${Array.isArray(value) ? "array" : typeof value}`, + }); + return; + } + const obj = value as Record; + const props = + s["properties"] && typeof s["properties"] === "object" + ? (s["properties"] as Record) + : {}; + // Structural checks are streaming-sensitive — only run on complete input. + if (!ctx.partial) { + const required = Array.isArray(s["required"]) ? (s["required"] as string[]) : []; + for (const key of required) { + if (!(key in obj) || obj[key] == null) { + const isNull = key in obj; + pushValidationError(ctx, { + code: isNull ? "null-required" : "missing-required", + component, + path: `${path}/${key}`, + message: isNull + ? `required field "${path}/${key}" cannot be null` + : `missing required field "${path}/${key}"`, + }); + } + } + } + for (const [key, sub] of Object.entries(props)) { + if (key in obj) { + validateSchemaValue(obj[key], sub, component, `${path}/${key}`, ctx); + } + } + return; + } + + if (type === "array") { + if (!Array.isArray(value)) { + pushValidationError(ctx, { + code: "type-mismatch", + component, + path, + message: `field "${path}" expects array but got ${typeof value}`, + }); + return; + } + const items = s["items"]; + // Only single-schema `items` (not tuple form) is checked. + if (items && typeof items === "object" && !Array.isArray(items)) { + value.forEach((el, i) => validateSchemaValue(el, items, component, `${path}/${i}`, ctx)); + } + return; + } + + // Leaf scalar — reuse the scalar/enum mismatch logic. + const leaf = getScalarTypeInfo(s); + if (leaf.expectedType == null && leaf.enumValues == null) return; + const mismatch = describeTypeMismatch(value, leaf); + if (mismatch) { + pushValidationError(ctx, { + code: "type-mismatch", + component, + path, + message: `field "${path}" expects ${mismatch.expected} but got ${mismatch.actual}`, + }); + } +} From 32c8c0233a21c5f7607c7c12f759fd4c451809bb Mon Sep 17 00:00:00 2001 From: Abhin Rustagi Date: Thu, 9 Jul 2026 11:04:09 +0530 Subject: [PATCH 12/31] fix: add imports to index --- packages/lang-core/src/index.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/lang-core/src/index.ts b/packages/lang-core/src/index.ts index bb40ad57e..9fd8c8359 100644 --- a/packages/lang-core/src/index.ts +++ b/packages/lang-core/src/index.ts @@ -15,8 +15,14 @@ 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 { parseExpression } from "./parser/expressions"; +export { tokenize } from "./parser/lexer"; +export type { Token } from "./parser/tokens"; +export { autoClose, split } from "./parser/statements"; export { ACTION_NAMES, ACTION_STEPS, From 60a5ed95f896811e0d0e1a74ad6ea80d2cc8f196 Mon Sep 17 00:00:00 2001 From: Abhin Rustagi Date: Thu, 9 Jul 2026 11:08:53 +0530 Subject: [PATCH 13/31] fix: add missing checks --- .../lang-core/src/parser/enrich-errors.ts | 35 ++++++++++++++++--- packages/lang-core/src/parser/prompt.ts | 3 ++ 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/packages/lang-core/src/parser/enrich-errors.ts b/packages/lang-core/src/parser/enrich-errors.ts index 65fef725d..5046931ac 100644 --- a/packages/lang-core/src/parser/enrich-errors.ts +++ b/packages/lang-core/src/parser/enrich-errors.ts @@ -1,14 +1,37 @@ import type { LibraryJSONSchema, OpenUIError, ValidationError } from "./types"; -/** Build a signature hint like "Header(title*, subtitle, icon)" from JSON schema. */ +/** Render a property's type for a signature hint, e.g. "string", "'a'|'b'", "Header". */ +function describeSchemaType(property: unknown): string | undefined { + if (!property || typeof property !== "object" || Array.isArray(property)) { + return undefined; + } + const p = property as Record; + if (Array.isArray(p.enum)) { + return p.enum.map((v) => JSON.stringify(v)).join("|"); + } + if (typeof p.$ref === "string") { + return p.$ref.split("/").pop(); + } + return typeof p.type === "string" ? p.type : undefined; +} + +/** + * Build a typed signature hint like + * "Header(title*: string, variant: 'a'|'b')" from JSON schema. + * Positional order is preserved so the LLM can fix swapped args. + */ function buildSignatureHint( componentName: string, schema: { properties?: Record; required?: string[] } | undefined, ): string | undefined { if (!schema?.properties) return undefined; const required = new Set(schema.required ?? []); - const params = Object.keys(schema.properties) - .map((k) => (required.has(k) ? `${k}*` : k)) + const params = Object.entries(schema.properties) + .map(([k, prop]) => { + const type = describeSchemaType(prop); + const marked = required.has(k) ? `${k}*` : k; + return type ? `${marked}: ${type}` : marked; + }) .join(", "); return `Signature: ${componentName}(${params}) — * marks required`; } @@ -34,7 +57,11 @@ export function enrichErrors( }; if (ve.code === "unknown-component" && componentNames.length) { error.hint = `Available components: ${componentNames.join(", ")}`; - } else if (ve.code === "missing-required" || ve.code === "null-required") { + } else if ( + ve.code === "missing-required" || + ve.code === "null-required" || + ve.code === "type-mismatch" + ) { error.hint = buildSignatureHint(ve.component, schema.$defs?.[ve.component]); } else if (ve.code === "inline-reserved") { error.hint = `Declare as a top-level statement: myVar = ${ve.component}(...)`; diff --git a/packages/lang-core/src/parser/prompt.ts b/packages/lang-core/src/parser/prompt.ts index f28ef5b25..89709512f 100644 --- a/packages/lang-core/src/parser/prompt.ts +++ b/packages/lang-core/src/parser/prompt.ts @@ -429,6 +429,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 From 116953a8869a9c5687ccb1d1f4668a7f8f55176a Mon Sep 17 00:00:00 2001 From: Abhin Rustagi Date: Thu, 9 Jul 2026 11:17:20 +0530 Subject: [PATCH 14/31] fix: update validation & enrich-errors --- packages/lang-core/src/parser/enrich-errors.ts | 14 +++++++++++--- packages/lang-core/src/parser/validation.ts | 2 +- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/lang-core/src/parser/enrich-errors.ts b/packages/lang-core/src/parser/enrich-errors.ts index 5046931ac..794355b6f 100644 --- a/packages/lang-core/src/parser/enrich-errors.ts +++ b/packages/lang-core/src/parser/enrich-errors.ts @@ -1,4 +1,5 @@ import type { LibraryJSONSchema, OpenUIError, ValidationError } from "./types"; +import { getScalarTypeInfo } from "./validation"; /** Render a property's type for a signature hint, e.g. "string", "'a'|'b'", "Header". */ function describeSchemaType(property: unknown): string | undefined { @@ -6,12 +7,19 @@ function describeSchemaType(property: unknown): string | undefined { return undefined; } const p = property as Record; - if (Array.isArray(p.enum)) { - return p.enum.map((v) => JSON.stringify(v)).join("|"); - } + // $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 + // hints 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; } diff --git a/packages/lang-core/src/parser/validation.ts b/packages/lang-core/src/parser/validation.ts index 69826f9d9..23e921ec0 100644 --- a/packages/lang-core/src/parser/validation.ts +++ b/packages/lang-core/src/parser/validation.ts @@ -38,7 +38,7 @@ function describeTypeMismatch( } /** Extract the scalar leaf type/enum from an already-narrowed JSON Schema object. */ -function getScalarTypeInfo(s: Record): ScalarTypeInfo { +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"]) { From fde218b7708032775b7434bbb472abae2ec86974 Mon Sep 17 00:00:00 2001 From: Abhin Rustagi Date: Thu, 9 Jul 2026 11:51:30 +0530 Subject: [PATCH 15/31] fix: prettier errors --- packages/lang-core/src/index.ts | 8 ++++---- .../src/parser/__tests__/materialize.validation.test.ts | 5 ++--- packages/lang-core/src/parser/materialize.ts | 2 +- packages/lang-core/src/parser/validation.ts | 7 ++++++- 4 files changed, 13 insertions(+), 9 deletions(-) diff --git a/packages/lang-core/src/index.ts b/packages/lang-core/src/index.ts index 9fd8c8359..58b9bfed6 100644 --- a/packages/lang-core/src/index.ts +++ b/packages/lang-core/src/index.ts @@ -19,10 +19,6 @@ 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 { parseExpression } from "./parser/expressions"; -export { tokenize } from "./parser/lexer"; -export type { Token } from "./parser/tokens"; -export { autoClose, split } from "./parser/statements"; export { ACTION_NAMES, ACTION_STEPS, @@ -34,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, 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 index 9b2a9b947..eb79f2bd0 100644 --- a/packages/lang-core/src/parser/__tests__/materialize.validation.test.ts +++ b/packages/lang-core/src/parser/__tests__/materialize.validation.test.ts @@ -1,8 +1,7 @@ import { describe, expect, it } from "vitest"; -import type { MaterializeCtx } from "../types"; -import { validateSchemaValue } from "../validation"; import { createParser, createStreamingParser } from "../parser"; -import type { LibraryJSONSchema, ValidationError } from "../types"; +import type { LibraryJSONSchema, MaterializeCtx, ValidationError } from "../types"; +import { validateSchemaValue } from "../validation"; /** * Exercises the recursive nested validator (validateSchemaValue): objects, diff --git a/packages/lang-core/src/parser/materialize.ts b/packages/lang-core/src/parser/materialize.ts index 76de53584..a24856494 100644 --- a/packages/lang-core/src/parser/materialize.ts +++ b/packages/lang-core/src/parser/materialize.ts @@ -5,7 +5,7 @@ import type { ASTNode } from "./ast"; import { isASTNode, isRuntimeExpr } from "./ast"; import { isBuiltin, isReservedCall, LAZY_BUILTINS, RESERVED_CALLS } from "./builtins"; -import { isElementNode, type MaterializeCtx, type ParamMap } from "./types"; +import { isElementNode, type MaterializeCtx } from "./types"; import { pushValidationError, validateSchemaValue } from "./validation"; /** diff --git a/packages/lang-core/src/parser/validation.ts b/packages/lang-core/src/parser/validation.ts index 23e921ec0..21c200ac3 100644 --- a/packages/lang-core/src/parser/validation.ts +++ b/packages/lang-core/src/parser/validation.ts @@ -1,5 +1,10 @@ import { isASTNode } from "./ast"; -import { isElementNode, type MaterializeCtx, type ScalarParamType, type ValidationError } from "./types"; +import { + isElementNode, + type MaterializeCtx, + type ScalarParamType, + type ValidationError, +} from "./types"; /** Scalar type/enum info extracted from a JSON Schema leaf, for describeTypeMismatch. */ interface ScalarTypeInfo { From 93ce0bed8bedf6da89f5a707e9ebe11c5989829b Mon Sep 17 00:00:00 2001 From: Abhin Rustagi Date: Thu, 9 Jul 2026 23:12:04 +0530 Subject: [PATCH 16/31] fix: update for dropping elements when nested validation is false --- .../__tests__/materialize.validation.test.ts | 89 +++++++++++++++++++ packages/lang-core/src/parser/materialize.ts | 11 ++- packages/lang-core/src/parser/validation.ts | 43 ++++++--- 3 files changed, 129 insertions(+), 14 deletions(-) diff --git a/packages/lang-core/src/parser/__tests__/materialize.validation.test.ts b/packages/lang-core/src/parser/__tests__/materialize.validation.test.ts index eb79f2bd0..93093473f 100644 --- a/packages/lang-core/src/parser/__tests__/materialize.validation.test.ts +++ b/packages/lang-core/src/parser/__tests__/materialize.validation.test.ts @@ -492,6 +492,15 @@ describe("validateSchemaValue — malformed and composite schemas (direct)", () expect(errors).toHaveLength(1); expect(errors[0]).toMatchObject({ code: "type-mismatch" }); }); + + it("defers enum membership in a partial ctx but checks it when complete", () => { + const enumSchema = { enum: ["active", "inactive"] }; + // Mid-stream the literal may still be completing toward a valid member. + expect(directErrors("act", enumSchema, true)).toEqual([]); + const errors = directErrors("act", enumSchema, false); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ code: "type-mismatch" }); + }); }); describe("validateSchemaValue — attribution and interplay with other checks", () => { @@ -549,3 +558,83 @@ describe("validateSchemaValue — streaming", () => { expect(missing).toMatchObject({ path: "/info/author", statementId: "root" }); }); }); + +describe("validateSchemaValue — dropping the component on nested failure", () => { + it("drops the parent when a nested required key is missing (like a top-level required)", () => { + const result = parser.parse("root = ObjBox({ views: 3 })"); + expect(result.root).toBeNull(); + expect(result.meta.errors).toHaveLength(1); + expect(result.meta.errors[0]).toMatchObject({ + code: "missing-required", + component: "ObjBox", + path: "/info/author", + }); + }); + + it("drops the parent when a nested required key is null", () => { + const result = parser.parse("root = ObjBox({ author: null, views: 3 })"); + expect(result.root).toBeNull(); + expect(result.meta.errors[0]).toMatchObject({ code: "null-required", path: "/info/author" }); + }); + + it("drops the parent when a required key is missing deep inside an array item", () => { + const result = parser.parse("root = ArrBox([{ value: 2 }])"); + expect(result.root).toBeNull(); + expect( + result.meta.errors.some( + (e) => e.code === "missing-required" && e.path === "/rows/0/label", + ), + ).toBe(true); + }); + + it("keeps the parent rendered on a nested type mismatch (report-only, like a top-level scalar)", () => { + const result = parser.parse('root = ObjBox({ author: "ann", views: "lots" })'); + expect(result.root).not.toBeNull(); + expect(result.root?.props.info).toMatchObject({ author: "ann", views: "lots" }); + expect(result.meta.errors).toHaveLength(1); + expect(result.meta.errors[0]).toMatchObject({ code: "type-mismatch", path: "/info/views" }); + }); + + it("keeps a top-level scalar type mismatch rendered (unchanged behavior)", () => { + const result = parser.parse("root = ScalarBox(5)"); + expect(result.root).not.toBeNull(); + expect(result.root?.props.title).toBe(5); + expect(result.meta.errors[0]).toMatchObject({ code: "type-mismatch", path: "/title" }); + }); + + it("renders a valid nested object with no errors", () => { + const result = parser.parse('root = ObjBox({ author: "ann", views: 3 })'); + expect(result.root).not.toBeNull(); + expect(result.meta.errors).toEqual([]); + }); +}); + +describe("validateSchemaValue — enum leaves while streaming", () => { + it("does not flag an out-of-enum leaf while the stream is incomplete", () => { + const sp = createStreamingParser(schema); + // Trailing, unterminated call → parse is marked incomplete (partial). + const res = sp.push('root = EnumBox("bogus" '); + expect(res.meta.incomplete).toBe(true); + expect(res.meta.errors.some((e) => e.code === "type-mismatch")).toBe(false); + }); + + it("flags the out-of-enum value once the stream completes", () => { + const sp = createStreamingParser(schema); + const mid = sp.push('root = EnumBox("bogus" '); + expect(mid.meta.errors.some((e) => e.code === "type-mismatch")).toBe(false); + const done = sp.push(")\n"); + expect(done.meta.incomplete).toBe(false); + expect(done.meta.errors.find((e) => e.code === "type-mismatch")).toMatchObject({ + path: "/status", + }); + }); + + it("still flags a nested scalar type mismatch while streaming (only 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", + }); + }); +}); diff --git a/packages/lang-core/src/parser/materialize.ts b/packages/lang-core/src/parser/materialize.ts index a24856494..cea92345a 100644 --- a/packages/lang-core/src/parser/materialize.ts +++ b/packages/lang-core/src/parser/materialize.ts @@ -248,6 +248,9 @@ export function materializeValue(node: ASTNode, ctx: MaterializeCtx): unknown { const props: Record = {}; if (def) { + // A nested required-field violation (any depth) drops the whole + // component, matching how a missing top-level required prop does below. + let hasNestedRequiredViolation = false; // Catalog component: map positional args → named props for (let i = 0; i < def.params.length && i < args.length; i++) { const param = def.params[i]; @@ -256,7 +259,9 @@ export function materializeValue(node: ASTNode, ctx: MaterializeCtx): unknown { // Single validation entry point: scalar leaf type/enum for simple // props, recursive key/type checks for nested object/array shapes. if (param.schema !== undefined) { - validateSchemaValue(value, param.schema, name, `/${param.name}`, ctx); + if (validateSchemaValue(value, param.schema, name, `/${param.name}`, ctx)) { + hasNestedRequiredViolation = true; + } } } @@ -298,6 +303,10 @@ export function materializeValue(node: ASTNode, ctx: MaterializeCtx): unknown { return null; } } + + // Nested required-field violations drop the component too — same as a + // missing top-level required prop. Errors were already reported above. + if (hasNestedRequiredViolation) return null; } else if (!isBuiltin(name) && !isReservedCall(name)) { // Unknown component: error and drop from tree pushValidationError(ctx, { diff --git a/packages/lang-core/src/parser/validation.ts b/packages/lang-core/src/parser/validation.ts index 21c200ac3..98ed2e56c 100644 --- a/packages/lang-core/src/parser/validation.ts +++ b/packages/lang-core/src/parser/validation.ts @@ -79,6 +79,8 @@ export function pushValidationError( * - dynamic values (runtime AST nodes) and child components (ElementNodes, * which are validated separately as their own elements) * - required-key checks while streaming is in progress + * - enum membership while streaming (a partial literal may still be + * completing toward a valid member) */ export function validateSchemaValue( value: unknown, @@ -86,17 +88,17 @@ export function validateSchemaValue( component: string, path: string, ctx: MaterializeCtx, -): void { - if (!schema || typeof schema !== "object" || Array.isArray(schema)) return; +): boolean { + if (!schema || typeof schema !== "object" || Array.isArray(schema)) return false; const s = schema as Record; // Composite shapes can't be reliably matched to a single value — skip. - if ("$ref" in s || "anyOf" in s || "oneOf" in s || "allOf" in s) return; + if ("$ref" in s || "anyOf" in s || "oneOf" in s || "allOf" in s) return false; // Absence is handled by required checks on the parent; skip null/undefined. - if (value == null) return; + if (value == null) return false; // Dynamic runtime expressions ($var, builtins) resolve later — don't flag. - if (isASTNode(value)) return; + if (isASTNode(value)) return false; // Child components validate themselves when materialized as elements. - if (isElementNode(value)) return; + if (isElementNode(value)) return false; const type = s["type"]; @@ -108,13 +110,14 @@ export function validateSchemaValue( path, message: `field "${path}" expects object but got ${Array.isArray(value) ? "array" : typeof value}`, }); - return; + return false; } const obj = value as Record; const props = s["properties"] && typeof s["properties"] === "object" ? (s["properties"] as Record) : {}; + let hasRequiredViolation = false; // Structural checks are streaming-sensitive — only run on complete input. if (!ctx.partial) { const required = Array.isArray(s["required"]) ? (s["required"] as string[]) : []; @@ -129,15 +132,18 @@ export function validateSchemaValue( ? `required field "${path}/${key}" cannot be null` : `missing required field "${path}/${key}"`, }); + hasRequiredViolation = true; } } } for (const [key, sub] of Object.entries(props)) { if (key in obj) { - validateSchemaValue(obj[key], sub, component, `${path}/${key}`, ctx); + if (validateSchemaValue(obj[key], sub, component, `${path}/${key}`, ctx)) { + hasRequiredViolation = true; + } } } - return; + return hasRequiredViolation; } if (type === "array") { @@ -148,19 +154,28 @@ export function validateSchemaValue( path, message: `field "${path}" expects array but got ${typeof value}`, }); - return; + return false; } const items = s["items"]; + let hasRequiredViolation = false; // Only single-schema `items` (not tuple form) is checked. if (items && typeof items === "object" && !Array.isArray(items)) { - value.forEach((el, i) => validateSchemaValue(el, items, component, `${path}/${i}`, ctx)); + value.forEach((el, i) => { + if (validateSchemaValue(el, items, component, `${path}/${i}`, ctx)) { + hasRequiredViolation = true; + } + }); } - return; + return hasRequiredViolation; } // Leaf scalar — reuse the scalar/enum mismatch logic. const leaf = getScalarTypeInfo(s); - if (leaf.expectedType == null && leaf.enumValues == null) return; + if (leaf.expectedType == null && leaf.enumValues == null) return false; + // Enum membership can't be judged mid-stream — a partial literal may still be + // completing toward a valid member. Scalar type checks stay: a wrong scalar + // type won't become the right type by streaming more. + if (leaf.enumValues && ctx.partial) return false; const mismatch = describeTypeMismatch(value, leaf); if (mismatch) { pushValidationError(ctx, { @@ -170,4 +185,6 @@ export function validateSchemaValue( message: `field "${path}" expects ${mismatch.expected} but got ${mismatch.actual}`, }); } + // A type/enum mismatch is reported but never drops the component. + return false; } From c26572cc36c17f973365c0924f9987b972fb98cc Mon Sep 17 00:00:00 2001 From: Abhin Rustagi Date: Fri, 10 Jul 2026 11:57:29 +0530 Subject: [PATCH 17/31] chore: version bump --- packages/lang-core/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/lang-core/package.json b/packages/lang-core/package.json index c7063a6d0..4773a1223 100644 --- a/packages/lang-core/package.json +++ b/packages/lang-core/package.json @@ -1,6 +1,6 @@ { "name": "@openuidev/lang-core", - "version": "0.2.8", + "version": "0.2.9", "description": "Framework-agnostic core for OpenUI Lang: parser, prompt generation, validation, and type definitions", "license": "MIT", "type": "module", From 259df6344079aff20336b8376b9913c6ef37acc3 Mon Sep 17 00:00:00 2001 From: Abhin Rustagi Date: Mon, 13 Jul 2026 17:23:52 +0530 Subject: [PATCH 18/31] fix: version bump --- packages/lang-core/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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", From a9391ae793f9fe95ce77c7a60f8d6817b26d3a41 Mon Sep 17 00:00:00 2001 From: Abhin Rustagi Date: Tue, 14 Jul 2026 16:20:08 +0530 Subject: [PATCH 19/31] fix: format fix --- .../src/parser/__tests__/materialize.validation.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/lang-core/src/parser/__tests__/materialize.validation.test.ts b/packages/lang-core/src/parser/__tests__/materialize.validation.test.ts index 93093473f..15cc22157 100644 --- a/packages/lang-core/src/parser/__tests__/materialize.validation.test.ts +++ b/packages/lang-core/src/parser/__tests__/materialize.validation.test.ts @@ -581,9 +581,7 @@ describe("validateSchemaValue — dropping the component on nested failure", () const result = parser.parse("root = ArrBox([{ value: 2 }])"); expect(result.root).toBeNull(); expect( - result.meta.errors.some( - (e) => e.code === "missing-required" && e.path === "/rows/0/label", - ), + result.meta.errors.some((e) => e.code === "missing-required" && e.path === "/rows/0/label"), ).toBe(true); }); From 76b51fb7d5c3d0000f78b7a69ec2c786b27c9599 Mon Sep 17 00:00:00 2001 From: Abhin Rustagi Date: Tue, 14 Jul 2026 21:31:37 +0530 Subject: [PATCH 20/31] fix: implement recursive data type check --- .../__tests__/materialize.validation.test.ts | 215 ++++++++++++++++-- packages/lang-core/src/parser/materialize.ts | 30 ++- packages/lang-core/src/parser/parser.ts | 8 +- packages/lang-core/src/parser/validation.ts | 111 +++++++-- 4 files changed, 304 insertions(+), 60 deletions(-) diff --git a/packages/lang-core/src/parser/__tests__/materialize.validation.test.ts b/packages/lang-core/src/parser/__tests__/materialize.validation.test.ts index 15cc22157..277b404c5 100644 --- a/packages/lang-core/src/parser/__tests__/materialize.validation.test.ts +++ b/packages/lang-core/src/parser/__tests__/materialize.validation.test.ts @@ -122,6 +122,56 @@ const schema: LibraryJSONSchema = { properties: { levels: { type: "array", items: { enum: [1, 2, 3] } } }, required: [], }, + // arg0 → `info`: REQUIRED object param — invalid nested data drops the component + ReqBox: { + properties: { + info: { + type: "object", + properties: { author: { type: "string" } }, + required: ["author"], + }, + }, + required: ["info"], + }, + // arg0 → `title`: REQUIRED scalar param — a type mismatch drops the component + ReqScalar: { + properties: { title: { type: "string" } }, + required: ["title"], + }, + // arg0 → `theme`: OPTIONAL param with a default — invalid values fall back to it + OptDefBox: { + properties: { theme: { type: "string", default: "dark" } }, + required: [], + }, + // 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: [], + }, }, }; @@ -385,19 +435,23 @@ describe("validateSchemaValue — conservative skips on leaves and values", () = expect(errorsFor('root = ObjBox({ author: "a", views: Sum([1, 2]) })')).toEqual([]); }); - it("skips a nested component element against a scalar leaf", () => { - expect(errorsFor('root = ObjBox({ author: CardBox("hi"), views: 1 })')).toEqual([]); + it("flags a component element sitting in a plain-data slot", () => { + const errors = errorsFor('root = ObjBox({ author: CardBox("hi"), views: 1 })'); + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ code: "type-mismatch", path: "/info/author" }); + expect(errors[0].message).toContain('expects string but got component "CardBox"'); }); - it("still validates the nested component's own args", () => { + it("still validates the misplaced component's own args (both errors surface)", () => { const errors = errorsFor("root = ObjBox({ author: CardBox(9) })"); - expect(errors).toHaveLength(1); - expect(errors[0]).toMatchObject({ + expect(errors).toHaveLength(2); + expect(errors.find((e) => e.component === "CardBox")).toMatchObject({ code: "type-mismatch", - component: "CardBox", path: "/text", }); - expect(errors[0].message).toContain("expects string but got number"); + expect(errors.find((e) => e.component === "ObjBox")).toMatchObject({ + path: "/info/author", + }); }); }); @@ -559,10 +613,11 @@ describe("validateSchemaValue — streaming", () => { }); }); -describe("validateSchemaValue — dropping the component on nested failure", () => { - it("drops the parent when a nested required key is missing (like a top-level required)", () => { +describe("validateSchemaValue — required-edge propagation (prune vs drop)", () => { + it("prunes an OPTIONAL prop whose object is missing a required key — component survives", () => { const result = parser.parse("root = ObjBox({ views: 3 })"); - expect(result.root).toBeNull(); + expect(result.root).not.toBeNull(); + expect(result.root?.props).not.toHaveProperty("info"); expect(result.meta.errors).toHaveLength(1); expect(result.meta.errors[0]).toMatchObject({ code: "missing-required", @@ -571,35 +626,85 @@ describe("validateSchemaValue — dropping the component on nested failure", () }); }); - it("drops the parent when a nested required key is null", () => { + it("prunes an OPTIONAL prop whose object has a null required key — component survives", () => { const result = parser.parse("root = ObjBox({ author: null, views: 3 })"); - expect(result.root).toBeNull(); + expect(result.root).not.toBeNull(); + expect(result.root?.props).not.toHaveProperty("info"); expect(result.meta.errors[0]).toMatchObject({ code: "null-required", path: "/info/author" }); }); - it("drops the parent when a required key is missing deep inside an array item", () => { - const result = parser.parse("root = ArrBox([{ value: 2 }])"); - expect(result.root).toBeNull(); + it("prunes the offending ARRAY ITEM, not the component — the array keeps valid items", () => { + const result = parser.parse('root = ArrBox([{ label: "a", value: 1 }, { value: 2 }])'); + expect(result.root).not.toBeNull(); + expect(result.root?.props.rows).toEqual([{ label: "a", value: 1 }]); expect( - result.meta.errors.some((e) => e.code === "missing-required" && e.path === "/rows/0/label"), + result.meta.errors.some((e) => e.code === "missing-required" && e.path === "/rows/1/label"), ).toBe(true); }); - it("keeps the parent rendered on a nested type mismatch (report-only, like a top-level scalar)", () => { + it("prunes a wrong-typed scalar item from a scalar array", () => { + const result = parser.parse('root = TagBox(["a", 2, "c"])'); + expect(result.root?.props.tags).toEqual(["a", "c"]); + expect(result.meta.errors[0]).toMatchObject({ code: "type-mismatch", path: "/tags/1" }); + }); + + it("prunes an OPTIONAL nested key with a type mismatch — parent object survives", () => { const result = parser.parse('root = ObjBox({ author: "ann", views: "lots" })'); expect(result.root).not.toBeNull(); - expect(result.root?.props.info).toMatchObject({ author: "ann", views: "lots" }); + expect(result.root?.props.info).toEqual({ author: "ann" }); expect(result.meta.errors).toHaveLength(1); expect(result.meta.errors[0]).toMatchObject({ code: "type-mismatch", path: "/info/views" }); }); - it("keeps a top-level scalar type mismatch rendered (unchanged behavior)", () => { + it("prunes an OPTIONAL top-level scalar prop on type mismatch", () => { const result = parser.parse("root = ScalarBox(5)"); expect(result.root).not.toBeNull(); - expect(result.root?.props.title).toBe(5); + expect(result.root?.props).not.toHaveProperty("title"); + expect(result.meta.errors[0]).toMatchObject({ code: "type-mismatch", path: "/title" }); + }); + + it("propagates through a REQUIRED nested key: wrong-typed required key prunes its parent", () => { + // info.author is required-and-invalid → info invalid → info is an optional + // param → info pruned, component survives. + const result = parser.parse('root = ObjBox({ author: { first: "a" }, views: 3 })'); + expect(result.root).not.toBeNull(); + expect(result.root?.props).not.toHaveProperty("info"); + expect(result.meta.errors[0]).toMatchObject({ code: "type-mismatch", path: "/info/author" }); + }); + + it("drops the component when a REQUIRED prop's object is missing a required key", () => { + const result = parser.parse("root = ReqBox({ other: 1 })"); + expect(result.root).toBeNull(); + expect(result.meta.errors).toHaveLength(1); + expect(result.meta.errors[0]).toMatchObject({ + code: "missing-required", + path: "/info/author", + }); + }); + + it("drops the component on a type mismatch of a REQUIRED scalar prop", () => { + const result = parser.parse("root = ReqScalar(5)"); + expect(result.root).toBeNull(); + expect(result.meta.errors).toHaveLength(1); expect(result.meta.errors[0]).toMatchObject({ code: "type-mismatch", path: "/title" }); }); + it("substitutes the schema default when a REQUIRED prop's value is invalid", () => { + const result = parser.parse("root = DefBox(5)"); + expect(result.root).not.toBeNull(); + expect(result.root?.props.theme).toBe("dark"); + expect(result.meta.errors[0]).toMatchObject({ code: "type-mismatch", path: "/theme" }); + }); + + it("an ARRAY edge absorbs a required-required chain: bad item pruned, component survives", () => { + // ReqBox requires info; info requires author — but here the violation sits + // inside ArrBox's rows items, so the item edge absorbs it before any + // required edge is crossed. + const result = parser.parse('root = ArrBox([{ value: "x" }])'); + expect(result.root).not.toBeNull(); + expect(result.root?.props.rows).toEqual([]); + }); + it("renders a valid nested object with no errors", () => { const result = parser.parse('root = ObjBox({ author: "ann", views: 3 })'); expect(result.root).not.toBeNull(); @@ -607,6 +712,76 @@ describe("validateSchemaValue — dropping the component on nested failure", () }); }); +describe("validateSchemaValue — schema defaults repair invalid values at every edge", () => { + it("substitutes the default for an OPTIONAL param's invalid value (instead of pruning)", () => { + const result = parser.parse("root = OptDefBox(5)"); + expect(result.root?.props.theme).toBe("dark"); + expect(result.meta.errors[0]).toMatchObject({ code: "type-mismatch", path: "/theme" }); + }); + + it("substitutes the default for a REQUIRED nested enum key on mismatch — parent survives", () => { + const result = parser.parse('root = NestDefBox({ mode: "warp", retries: 1 })'); + expect(result.root?.props.cfg).toEqual({ mode: "fast", retries: 1 }); + expect(result.meta.errors).toHaveLength(1); + expect(result.meta.errors[0]).toMatchObject({ code: "type-mismatch", path: "/cfg/mode" }); + }); + + it("substitutes the default for an OPTIONAL nested key on mismatch (instead of pruning)", () => { + const result = parser.parse('root = NestDefBox({ mode: "slow", retries: "many" })'); + expect(result.root?.props.cfg).toEqual({ mode: "slow", retries: 3 }); + expect(result.meta.errors[0]).toMatchObject({ code: "type-mismatch", path: "/cfg/retries" }); + }); + + it("fills an absent REQUIRED nested key from its default silently (no error)", () => { + const result = parser.parse("root = NestDefBox({ retries: 2 })"); + expect(result.root?.props.cfg).toEqual({ mode: "fast", retries: 2 }); + expect(result.meta.errors).toEqual([]); + }); + + it("fills a null REQUIRED nested key from its default silently (no error)", () => { + const result = parser.parse("root = NestDefBox({ mode: null, retries: 2 })"); + expect(result.root?.props.cfg).toEqual({ mode: "fast", retries: 2 }); + expect(result.meta.errors).toEqual([]); + }); + + it("still prunes invalid ARRAY ITEMS — item defaults are never substituted", () => { + const result = parser.parse('root = TagBox(["a", 2])'); + expect(result.root?.props.tags).toEqual(["a"]); + expect(result.meta.errors).toHaveLength(1); + }); +}); + +describe("validateSchemaValue — components in data slots", () => { + it("leaves elements in component slots ($ref/anyOf) unchecked — membership comes later", () => { + const result = parser.parse('root = SlotBox([CardBox("hi"), EnumBox("active")])'); + expect(result.meta.errors).toEqual([]); + expect((result.root?.props.children as unknown[]).length).toBe(2); + }); + + it("drops the component when a REQUIRED data param receives an element", () => { + // hbc = HorizontalBarChart(Card([]), [], "grouped") — the motivating case. + const result = parser.parse('root = ChartBox(CardBox("x"), "grouped")'); + expect(result.root).toBeNull(); + expect(result.meta.errors).toHaveLength(1); + expect(result.meta.errors[0]).toMatchObject({ code: "type-mismatch", path: "/labels" }); + expect(result.meta.errors[0].message).toContain('expects array but got component "CardBox"'); + }); + + it("prunes an element sitting where an enum leaf is expected (optional edge)", () => { + const result = parser.parse('root = ChartBox(["a"], CardBox("x"))'); + expect(result.root).not.toBeNull(); + expect(result.root?.props).not.toHaveProperty("variant"); + expect(result.meta.errors[0]).toMatchObject({ code: "type-mismatch", path: "/variant" }); + expect(result.meta.errors[0].message).toContain("expects a literal value but got component"); + }); + + it("prunes an element from a data ARRAY's items — siblings survive", () => { + const result = parser.parse('root = TagBox(["a", CardBox("x"), "b"])'); + expect(result.root?.props.tags).toEqual(["a", "b"]); + expect(result.meta.errors[0]).toMatchObject({ code: "type-mismatch", path: "/tags/1" }); + }); +}); + describe("validateSchemaValue — enum leaves while streaming", () => { it("does not flag an out-of-enum leaf while the stream is incomplete", () => { const sp = createStreamingParser(schema); diff --git a/packages/lang-core/src/parser/materialize.ts b/packages/lang-core/src/parser/materialize.ts index cea92345a..c75b00980 100644 --- a/packages/lang-core/src/parser/materialize.ts +++ b/packages/lang-core/src/parser/materialize.ts @@ -248,19 +248,29 @@ export function materializeValue(node: ASTNode, ctx: MaterializeCtx): unknown { const props: Record = {}; if (def) { - // A nested required-field violation (any depth) drops the whole - // component, matching how a missing top-level required prop does below. - let hasNestedRequiredViolation = false; + // 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++) { 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 for nested object/array shapes. - if (param.schema !== undefined) { - if (validateSchemaValue(value, param.schema, name, `/${param.name}`, ctx)) { - hasNestedRequiredViolation = true; + // 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 repair rule as + // every nested edge: schema default first, then propagate along a + // required edge (drop the component) or prune an optional prop. + if (param.defaultValue !== undefined) { + props[param.name] = param.defaultValue; + } else if (!param.required) { + delete props[param.name]; + } else { + dropComponent = true; } } } @@ -304,9 +314,9 @@ export function materializeValue(node: ASTNode, ctx: MaterializeCtx): unknown { } } - // Nested required-field violations drop the component too — same as a - // missing top-level required prop. Errors were already reported above. - if (hasNestedRequiredViolation) 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 pushValidationError(ctx, { diff --git a/packages/lang-core/src/parser/parser.ts b/packages/lang-core/src/parser/parser.ts index e3be6fef5..ca2650cc6 100644 --- a/packages/lang-core/src/parser/parser.ts +++ b/packages/lang-core/src/parser/parser.ts @@ -5,6 +5,7 @@ import { parseExpression } from "./expressions"; import { tokenize } from "./lexer"; import { materializeValue } from "./materialize"; import { autoClose, split, type RawStmt } from "./statements"; +import { getSchemaDefaultValue } from "./validation"; import { T } from "./tokens"; import { isElementNode, @@ -631,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 ?? {}; diff --git a/packages/lang-core/src/parser/validation.ts b/packages/lang-core/src/parser/validation.ts index 98ed2e56c..d84e3f3d8 100644 --- a/packages/lang-core/src/parser/validation.ts +++ b/packages/lang-core/src/parser/validation.ts @@ -42,6 +42,14 @@ function describeTypeMismatch( return null; } +/** 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; +} + /** 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"] }; @@ -67,17 +75,42 @@ export function pushValidationError( } /** - * Recursively validate a materialized value against a JSON Schema fragment. + * Recursively validate a materialized value against a JSON Schema fragment, + * pruning unsalvageable data along the way. * * Descends into `type: object` (checking required keys and each declared * property) and `type: array` (checking every element against `items`), * reporting nested errors with JSON-pointer paths. Leaf scalars are checked * for type/enum mismatches. * + * Every violation is reported, then the same repair rule applies at each + * recursion edge, in order: + * 1. the child schema carries a `default` → substitute it (absent/null + * required keys are filled silently; reported violations keep their error) + * 2. the edge is REQUIRED → the parent object is invalid, and the caller + * applies the same rule one level up + * 3. the edge is OPTIONAL → prune (key deleted) and the parent stays valid + * ARRAY ITEMS are always pruned (spliced out), never default-substituted — + * substituting `items.default` would inject duplicate placeholder rows. + * + * Returns true when `value` itself is invalid: wrong container type, a + * scalar/enum mismatch, a missing/null required key, or invalid data under a + * required key. The caller decides its fate — materializeValue prunes optional + * props, substitutes the schema default for required ones, and drops the + * component only when a required prop is invalid with no default (see the Comp + * branch there). Pruning mutates in place; materialized objects/arrays are + * freshly built per parse, so cached AST is never touched. + * + * Child components (ElementNodes) validate their own args separately; here a + * component sitting in a slot that declares a plain data shape (type/enum/ + * const, no component refs) is reported as a type-mismatch — a component can + * never satisfy such a slot. + * * Conservative — silently skips anything it can't reliably check: - * - composite shapes ($ref / anyOf / oneOf / allOf) - * - dynamic values (runtime AST nodes) and child components (ElementNodes, - * which are validated separately as their own elements) + * - composite shapes ($ref / anyOf / oneOf / allOf), including elements in + * component slots (membership isn't checked yet) + * - dynamic values (runtime AST nodes) + * - elements in unconstrained slots (no refs, no declared data shape) * - required-key checks while streaming is in progress * - enum membership while streaming (a partial literal may still be * completing toward a valid member) @@ -91,14 +124,29 @@ export function validateSchemaValue( ): boolean { if (!schema || typeof schema !== "object" || Array.isArray(schema)) return false; const s = schema as Record; - // Composite shapes can't be reliably matched to a single value — skip. - if ("$ref" in s || "anyOf" in s || "oneOf" in s || "allOf" in s) return false; // 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 validate themselves when materialized as elements. - if (isElementNode(value)) return false; + // Child components validate their own args when materialized. Slots that + // admit components ($ref / anyOf / oneOf members) are left unchecked here; + // but a slot declaring a plain-data shape can never be satisfied by a + // component — flag it. Unconstrained schemas stay unchecked. + if (isElementNode(value)) { + if ("$ref" in s || "anyOf" in s || "oneOf" in s || "allOf" in s) return false; + if (typeof s["type"] === "string" || Array.isArray(s["enum"]) || "const" in s) { + pushValidationError(ctx, { + code: "type-mismatch", + component, + path, + message: `field "${path}" expects ${typeof s["type"] === "string" ? s["type"] : "a literal value"} but got component "${value.typeName}"`, + }); + return true; + } + return false; + } + // Composite shapes can't be reliably matched to a single plain value — skip. + if ("$ref" in s || "anyOf" in s || "oneOf" in s || "allOf" in s) return false; const type = s["type"]; @@ -110,19 +158,26 @@ export function validateSchemaValue( path, message: `field "${path}" expects object but got ${Array.isArray(value) ? "array" : typeof value}`, }); - return false; + return true; } const obj = value as Record; const props = s["properties"] && typeof s["properties"] === "object" ? (s["properties"] as Record) : {}; - let hasRequiredViolation = false; + const required = new Set(Array.isArray(s["required"]) ? (s["required"] as string[]) : []); + let invalid = false; // Structural checks are streaming-sensitive — only run on complete input. if (!ctx.partial) { - const required = Array.isArray(s["required"]) ? (s["required"] as string[]) : []; for (const key of required) { if (!(key in obj) || obj[key] == null) { + // Absent/null required key with a schema default: fill silently, + // mirroring the top-level defaultValue rescue in materializeValue. + const fallback = getSchemaDefaultValue(props[key]); + if (fallback !== undefined) { + obj[key] = fallback; + continue; + } const isNull = key in obj; pushValidationError(ctx, { code: isNull ? "null-required" : "missing-required", @@ -132,18 +187,27 @@ export function validateSchemaValue( ? `required field "${path}/${key}" cannot be null` : `missing required field "${path}/${key}"`, }); - hasRequiredViolation = true; + invalid = true; } } } for (const [key, sub] of Object.entries(props)) { - if (key in obj) { - if (validateSchemaValue(obj[key], sub, component, `${path}/${key}`, ctx)) { - hasRequiredViolation = true; + if (key in obj && validateSchemaValue(obj[key], sub, component, `${path}/${key}`, ctx)) { + // Invalid child (error already reported): schema default repairs it in + // place; otherwise propagate along a required edge or prune an optional one. + const fallback = getSchemaDefaultValue(sub); + if (fallback !== undefined) { + obj[key] = fallback; + } else if (required.has(key)) { + // Required key holds invalid data — the object can't be repaired locally. + invalid = true; + } else { + // Optional key — prune the invalid value; the object stays usable. + delete obj[key]; } } } - return hasRequiredViolation; + return invalid; } if (type === "array") { @@ -154,19 +218,20 @@ export function validateSchemaValue( path, message: `field "${path}" expects array but got ${typeof value}`, }); - return false; + return true; } const items = s["items"]; - let hasRequiredViolation = false; // Only single-schema `items` (not tuple form) is checked. if (items && typeof items === "object" && !Array.isArray(items)) { + // Validate every item first so error paths keep original indices, then prune. + const bad: number[] = []; value.forEach((el, i) => { - if (validateSchemaValue(el, items, component, `${path}/${i}`, ctx)) { - hasRequiredViolation = true; - } + if (validateSchemaValue(el, items, component, `${path}/${i}`, ctx)) bad.push(i); }); + for (let i = bad.length - 1; i >= 0; i--) value.splice(bad[i], 1); } - return hasRequiredViolation; + // Items are individually prunable — a bad item never invalidates the array. + return false; } // Leaf scalar — reuse the scalar/enum mismatch logic. @@ -184,7 +249,7 @@ export function validateSchemaValue( path, message: `field "${path}" expects ${mismatch.expected} but got ${mismatch.actual}`, }); + return true; } - // A type/enum mismatch is reported but never drops the component. return false; } From 47fb7791d32c766f3093d4444b4cd74ad9212fd5 Mon Sep 17 00:00:00 2001 From: Abhin Rustagi Date: Tue, 14 Jul 2026 21:43:47 +0530 Subject: [PATCH 21/31] fix: refactor --- packages/lang-core/src/parser/materialize.ts | 13 +- packages/lang-core/src/parser/validation.ts | 331 ++++++++++--------- 2 files changed, 176 insertions(+), 168 deletions(-) diff --git a/packages/lang-core/src/parser/materialize.ts b/packages/lang-core/src/parser/materialize.ts index c75b00980..9731f7162 100644 --- a/packages/lang-core/src/parser/materialize.ts +++ b/packages/lang-core/src/parser/materialize.ts @@ -6,7 +6,7 @@ import type { ASTNode } from "./ast"; import { isASTNode, isRuntimeExpr } from "./ast"; import { isBuiltin, isReservedCall, LAZY_BUILTINS, RESERVED_CALLS } from "./builtins"; import { isElementNode, type MaterializeCtx } from "./types"; -import { pushValidationError, validateSchemaValue } from "./validation"; +import { pushValidationError, resolveInvalidValue, validateSchemaValue } from "./validation"; /** * Recursively check if a prop value contains any AST nodes that need runtime @@ -262,14 +262,9 @@ export function materializeValue(node: ASTNode, ctx: MaterializeCtx): unknown { param.schema !== undefined && validateSchemaValue(value, param.schema, name, `/${param.name}`, ctx) ) { - // Invalid prop value (error already reported). Same repair rule as - // every nested edge: schema default first, then propagate along a - // required edge (drop the component) or prune an optional prop. - if (param.defaultValue !== undefined) { - props[param.name] = param.defaultValue; - } else if (!param.required) { - delete props[param.name]; - } else { + // 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; } } diff --git a/packages/lang-core/src/parser/validation.ts b/packages/lang-core/src/parser/validation.ts index d84e3f3d8..4d84ee843 100644 --- a/packages/lang-core/src/parser/validation.ts +++ b/packages/lang-core/src/parser/validation.ts @@ -1,6 +1,7 @@ import { isASTNode } from "./ast"; import { isElementNode, + type ElementNode, type MaterializeCtx, type ScalarParamType, type ValidationError, @@ -14,14 +15,14 @@ interface ScalarTypeInfo { enumValues?: readonly unknown[]; } +function jsType(value: unknown): string { + return Array.isArray(value) ? "array" : typeof value; +} + /** * Check a materialized value against a scalar leaf's declared type/enum. * Returns a human-readable {expected, actual} on mismatch, or null when it - * passes or isn't checkable. Values reaching this point are concrete literals - * (dynamic/element/null values are filtered out by validateSchemaValue), so a - * declared scalar type also flags compound values (object/array). Enum - * membership is only checked for scalar literals — `includes` compares - * non-scalar enum members by reference, which could never match. + * passes or isn't checkable. */ function describeTypeMismatch( value: unknown, @@ -37,7 +38,7 @@ function describeTypeMismatch( }; } if (info.expectedType && info.expectedType !== actual) { - return { expected: info.expectedType, actual: Array.isArray(value) ? "array" : actual }; + return { expected: info.expectedType, actual: jsType(value) }; } return null; } @@ -74,182 +75,194 @@ export function pushValidationError( ctx.errors.push({ ...error, statementId: ctx.currentStatementId }); } +/** Report a type-mismatch in the shared `expects X but got Y` shape. */ +function pushTypeMismatch( + ctx: MaterializeCtx, + component: string, + path: string, + expected: string, + actual: string, +): void { + pushValidationError(ctx, { + code: "type-mismatch", + component, + path, + message: `field "${path}" expects ${expected} but got ${actual}`, + }); +} + +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; +} + /** - * Recursively validate a materialized value against a JSON Schema fragment, - * pruning unsalvageable data along the way. - * - * Descends into `type: object` (checking required keys and each declared - * property) and `type: array` (checking every element against `items`), - * reporting nested errors with JSON-pointer paths. Leaf scalars are checked - * for type/enum mismatches. - * - * Every violation is reported, then the same repair rule applies at each - * recursion edge, in order: - * 1. the child schema carries a `default` → substitute it (absent/null - * required keys are filled silently; reported violations keep their error) - * 2. the edge is REQUIRED → the parent object is invalid, and the caller - * applies the same rule one level up - * 3. the edge is OPTIONAL → prune (key deleted) and the parent stays valid - * ARRAY ITEMS are always pruned (spliced out), never default-substituted — - * substituting `items.default` would inject duplicate placeholder rows. - * - * Returns true when `value` itself is invalid: wrong container type, a - * scalar/enum mismatch, a missing/null required key, or invalid data under a - * required key. The caller decides its fate — materializeValue prunes optional - * props, substitutes the schema default for required ones, and drops the - * component only when a required prop is invalid with no default (see the Comp - * branch there). Pruning mutates in place; materialized objects/arrays are - * freshly built per parse, so cached AST is never touched. - * - * Child components (ElementNodes) validate their own args separately; here a - * component sitting in a slot that declares a plain data shape (type/enum/ - * const, no component refs) is reported as a type-mismatch — a component can - * never satisfy such a slot. - * - * Conservative — silently skips anything it can't reliably check: - * - composite shapes ($ref / anyOf / oneOf / allOf), including elements in - * component slots (membership isn't checked yet) - * - dynamic values (runtime AST nodes) - * - elements in unconstrained slots (no refs, no declared data shape) - * - required-key checks while streaming is in progress - * - enum membership while streaming (a partial literal may still be - * completing toward a valid member) + * Position check for a component element in a data slot. */ -export function validateSchemaValue( - value: unknown, - schema: unknown, +function validateElementPosition( + element: ElementNode, + s: Record, 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 validate their own args when materialized. Slots that - // admit components ($ref / anyOf / oneOf members) are left unchecked here; - // but a slot declaring a plain-data shape can never be satisfied by a - // component — flag it. Unconstrained schemas stay unchecked. - if (isElementNode(value)) { - if ("$ref" in s || "anyOf" in s || "oneOf" in s || "allOf" in s) return false; - if (typeof s["type"] === "string" || Array.isArray(s["enum"]) || "const" in s) { - pushValidationError(ctx, { - code: "type-mismatch", - component, - path, - message: `field "${path}" expects ${typeof s["type"] === "string" ? s["type"] : "a literal value"} but got component "${value.typeName}"`, - }); - return true; - } - return false; - } - // Composite shapes can't be reliably matched to a single plain value — skip. if ("$ref" in s || "anyOf" in s || "oneOf" in s || "allOf" in s) return false; + if (typeof s["type"] === "string" || Array.isArray(s["enum"]) || "const" in s) { + pushTypeMismatch( + ctx, + component, + path, + typeof s["type"] === "string" ? s["type"] : "a literal value", + `component "${element.typeName}"`, + ); + return true; + } + return false; +} - const type = s["type"]; - - if (type === "object") { - if (typeof value !== "object" || Array.isArray(value)) { - pushValidationError(ctx, { - code: "type-mismatch", - component, - path, - message: `field "${path}" expects object but got ${Array.isArray(value) ? "array" : typeof value}`, - }); - return true; - } - const obj = value as Record; - const props = - s["properties"] && typeof s["properties"] === "object" - ? (s["properties"] as Record) - : {}; - const required = new Set(Array.isArray(s["required"]) ? (s["required"] as string[]) : []); - let invalid = false; - // Structural checks are streaming-sensitive — only run on complete input. - if (!ctx.partial) { - for (const key of required) { - if (!(key in obj) || obj[key] == null) { - // Absent/null required key with a schema default: fill silently, - // mirroring the top-level defaultValue rescue in materializeValue. - const fallback = getSchemaDefaultValue(props[key]); - if (fallback !== undefined) { - obj[key] = fallback; - continue; - } - const isNull = key in obj; - pushValidationError(ctx, { - code: isNull ? "null-required" : "missing-required", - component, - path: `${path}/${key}`, - message: isNull - ? `required field "${path}/${key}" cannot be null` - : `missing required field "${path}/${key}"`, - }); - invalid = true; - } - } - } - for (const [key, sub] of Object.entries(props)) { - if (key in obj && validateSchemaValue(obj[key], sub, component, `${path}/${key}`, ctx)) { - // Invalid child (error already reported): schema default repairs it in - // place; otherwise propagate along a required edge or prune an optional one. - const fallback = getSchemaDefaultValue(sub); +/** + * Validate an object-shaped slot: container type, required keys + */ +function validateObjectValue( + value: unknown, + s: Record, + component: string, + path: string, + ctx: MaterializeCtx, +): boolean { + if (typeof value !== "object" || Array.isArray(value)) { + pushTypeMismatch(ctx, component, path, "object", jsType(value)); + return true; + } + const obj = value as Record; + const props = + s["properties"] && typeof s["properties"] === "object" + ? (s["properties"] as Record) + : {}; + const required = new Set(Array.isArray(s["required"]) ? (s["required"] as string[]) : []); + let invalid = false; + // Structural checks are streaming-sensitive — only run on complete input. + if (!ctx.partial) { + for (const key of required) { + if (!(key in obj) || obj[key] == null) { + const fallback = getSchemaDefaultValue(props[key]); if (fallback !== undefined) { obj[key] = fallback; - } else if (required.has(key)) { - // Required key holds invalid data — the object can't be repaired locally. - invalid = true; - } else { - // Optional key — prune the invalid value; the object stays usable. - delete obj[key]; + continue; } + const isNull = key in obj; + pushValidationError(ctx, { + code: isNull ? "null-required" : "missing-required", + component, + path: `${path}/${key}`, + message: isNull + ? `required field "${path}/${key}" cannot be null` + : `missing required field "${path}/${key}"`, + }); + invalid = true; } } - return invalid; } - - if (type === "array") { - if (!Array.isArray(value)) { - pushValidationError(ctx, { - code: "type-mismatch", - component, - path, - message: `field "${path}" expects array but got ${typeof value}`, - }); - return true; - } - const items = s["items"]; - // Only single-schema `items` (not tuple form) is checked. - if (items && typeof items === "object" && !Array.isArray(items)) { - // Validate every item first so error paths keep original indices, then prune. - const bad: number[] = []; - value.forEach((el, i) => { - if (validateSchemaValue(el, items, component, `${path}/${i}`, ctx)) bad.push(i); - }); - for (let i = bad.length - 1; i >= 0; i--) value.splice(bad[i], 1); + for (const [key, sub] of Object.entries(props)) { + if (key in obj && validateSchemaValue(obj[key], sub, component, `${path}/${key}`, ctx)) { + if (resolveInvalidValue(obj, key, required.has(key), getSchemaDefaultValue(sub))) { + invalid = true; + } } - // Items are individually prunable — a bad item never invalidates the array. - return false; } + return invalid; +} - // Leaf scalar — reuse the scalar/enum mismatch logic. +/** + * Validate an array slot: container type, then every element against `items` + * (single-schema form only — tuples are skipped). Invalid items are pruned. + */ +function validateArrayValue( + value: unknown, + s: Record, + component: string, + path: string, + ctx: MaterializeCtx, +): boolean { + if (!Array.isArray(value)) { + pushTypeMismatch(ctx, component, path, "array", jsType(value)); + return true; + } + const items = s["items"]; + if (items && typeof items === "object" && !Array.isArray(items)) { + const bad: number[] = []; + value.forEach((el, i) => { + if (validateSchemaValue(el, items, component, `${path}/${i}`, ctx)) bad.push(i); + }); + for (let i = bad.length - 1; i >= 0; i--) value.splice(bad[i], 1); + } + return false; +} + +/** + * Validate a scalar/enum leaf. + */ +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; - // Enum membership can't be judged mid-stream — a partial literal may still be - // completing toward a valid member. Scalar type checks stay: a wrong scalar - // type won't become the right type by streaming more. if (leaf.enumValues && ctx.partial) return false; const mismatch = describeTypeMismatch(value, leaf); if (mismatch) { - pushValidationError(ctx, { - code: "type-mismatch", - component, - path, - message: `field "${path}" expects ${mismatch.expected} but got ${mismatch.actual}`, - }); + pushTypeMismatch(ctx, component, path, mismatch.expected, mismatch.actual); 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); + // Composite shapes can't be reliably matched to a single plain value — skip. + if ("$ref" in s || "anyOf" in s || "oneOf" in s || "allOf" in 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); +} From 9bff65e591f0d2191d8e26ced78036c15fd5d931 Mon Sep 17 00:00:00 2001 From: Abhin Rustagi Date: Tue, 14 Jul 2026 21:47:57 +0530 Subject: [PATCH 22/31] fix: reduce tests --- .../__tests__/materialize.validation.test.ts | 767 +++++------------- 1 file changed, 196 insertions(+), 571 deletions(-) diff --git a/packages/lang-core/src/parser/__tests__/materialize.validation.test.ts b/packages/lang-core/src/parser/__tests__/materialize.validation.test.ts index 277b404c5..83d150579 100644 --- a/packages/lang-core/src/parser/__tests__/materialize.validation.test.ts +++ b/packages/lang-core/src/parser/__tests__/materialize.validation.test.ts @@ -4,10 +4,15 @@ import type { LibraryJSONSchema, MaterializeCtx, ValidationError } from "../type import { validateSchemaValue } from "../validation"; /** - * Exercises the recursive nested validator (validateSchemaValue): objects, - * arrays, and the items inside them. Prior validation only checked a - * positional arg's presence; these cover descent into `type: object` / - * `type: array` and their leaves. + * Exercises the recursive nested validator (validateSchemaValue) and the + * resolveInvalidValue edge rule, one significant case per behavior: + * + * 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. @@ -92,21 +97,16 @@ const schema: LibraryJSONSchema = { }, required: [], }, - // arg0 → `qty`: `integer` maps to the number check - IntBox: { - properties: { qty: { type: "integer" } }, - required: [], - }, - // arg0 → `mode`: `const` behaves as a single-value enum - ConstBox: { - properties: { mode: { const: "fixed" } }, - required: [], - }, // arg0 → `theme`: required with a schema default — applied, not validated DefBox: { properties: { theme: { type: "string", default: "dark" } }, required: ["theme"], }, + // arg0 → `theme`: OPTIONAL param with a default — invalid values fall back to it + OptDefBox: { + properties: { theme: { type: "string", default: "dark" } }, + required: [], + }, // arg0 → `children`: array without `items` — elements are unchecked ListBox: { properties: { children: { type: "array" } }, @@ -117,11 +117,6 @@ const schema: LibraryJSONSchema = { properties: { text: { type: "string" } }, required: [], }, - // arg0 → `levels`: enum leaves nested inside array items - LevelBox: { - properties: { levels: { type: "array", items: { enum: [1, 2, 3] } } }, - required: [], - }, // arg0 → `info`: REQUIRED object param — invalid nested data drops the component ReqBox: { properties: { @@ -138,11 +133,6 @@ const schema: LibraryJSONSchema = { properties: { title: { type: "string" } }, required: ["title"], }, - // arg0 → `theme`: OPTIONAL param with a default — invalid values fall back to it - OptDefBox: { - properties: { theme: { type: "string", default: "dark" } }, - required: [], - }, // arg0 → `children`: a component slot (anyOf of $refs) — membership unchecked SlotBox: { properties: { @@ -181,633 +171,268 @@ function errorsFor(src: string): ValidationError[] { return parser.parse(src).meta.errors; } -describe("validateSchemaValue — nested objects", () => { - it("accepts a well-typed object with its required key present", () => { +/** 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 at every depth with no errors", () => { expect(errorsFor('root = ObjBox({ author: "ann", views: 3 })')).toEqual([]); + expect(errorsFor('root = ArrBox([{ label: "a", value: 1 }, { label: "b" }])')).toEqual([]); + expect(errorsFor('root = EnumBox("active")')).toEqual([]); }); - it("flags a wrong scalar type on a nested object leaf", () => { - const errors = errorsFor('root = ObjBox({ author: "ann", views: "lots" })'); - expect(errors).toHaveLength(1); - expect(errors[0]).toMatchObject({ + it("reports leaf violations with JSON-pointer paths at any depth", () => { + // object leaf + expect(errorsFor('root = ObjBox({ author: "ann", views: "lots" })')[0]).toMatchObject({ code: "type-mismatch", component: "ObjBox", path: "/info/views", - statementId: "root", }); - expect(errors[0].message).toContain("expects number but got string"); + // array item leaf, with element index + expect(errorsFor('root = ArrBox([{ label: "a", value: "x" }])')[0]).toMatchObject({ + path: "/rows/0/value", + }); + // deep recursion: array → object → array → scalar + expect(errorsFor('root = DeepBox([{ name: "g1", points: [1, "two", 3] }])')[0]).toMatchObject({ + path: "/groups/0/points/1", + }); }); - it("flags a missing required key inside the object", () => { - const errors = errorsFor("root = ObjBox({ views: 3 })"); - expect(errors).toHaveLength(1); - expect(errors[0]).toMatchObject({ + it("reports missing vs null required keys inside objects with distinct codes", () => { + expect(errorsFor("root = ObjBox({ views: 3 })")[0]).toMatchObject({ code: "missing-required", - component: "ObjBox", path: "/info/author", }); - }); - - it("flags a null required key inside the object", () => { - const errors = errorsFor("root = ObjBox({ author: null, views: 3 })"); - expect(errors).toHaveLength(1); - expect(errors[0]).toMatchObject({ + expect(errorsFor("root = ObjBox({ author: null, views: 3 })")[0]).toMatchObject({ code: "null-required", path: "/info/author", }); }); - it("flags a scalar where an object is expected (and skips inner checks)", () => { - const errors = errorsFor('root = ObjBox("hello")'); - expect(errors).toHaveLength(1); - expect(errors[0]).toMatchObject({ code: "type-mismatch", path: "/info" }); - expect(errors[0].message).toContain("expects object but got string"); - }); -}); - -describe("validateSchemaValue — arrays and their items", () => { - it("accepts an array of well-typed objects (optional leaf omitted)", () => { - expect(errorsFor('root = ArrBox([{ label: "a", value: 1 }, { label: "b" }])')).toEqual([]); - }); - - it("flags a wrong scalar type inside an array item, with the element index", () => { - const errors = errorsFor('root = ArrBox([{ label: "a", value: "x" }])'); - expect(errors).toHaveLength(1); - expect(errors[0]).toMatchObject({ - code: "type-mismatch", - path: "/rows/0/value", - }); - expect(errors[0].message).toContain("expects number but got string"); - }); - - it("flags a missing required key on a specific array element", () => { - const errors = errorsFor('root = ArrBox([{ label: "a" }, { value: 2 }])'); - expect(errors).toHaveLength(1); - expect(errors[0]).toMatchObject({ - code: "missing-required", - path: "/rows/1/label", - }); - }); - - it("flags a non-array where an array is expected", () => { - const errors = errorsFor('root = ArrBox({ label: "a" })'); - expect(errors).toHaveLength(1); - expect(errors[0]).toMatchObject({ code: "type-mismatch", path: "/rows" }); - expect(errors[0].message).toContain("expects array but got object"); - }); - - it("accepts a homogeneous array of scalars", () => { - expect(errorsFor('root = TagBox(["a", "b", "c"])')).toEqual([]); - }); - - it("flags a bad scalar element in a scalar array at its index", () => { - const errors = errorsFor('root = TagBox(["a", 2, "c"])'); - expect(errors).toHaveLength(1); - expect(errors[0]).toMatchObject({ - code: "type-mismatch", - path: "/tags/1", - }); - expect(errors[0].message).toContain("expects string but got number"); - }); -}); - -describe("validateSchemaValue — enums and deep recursion", () => { - it("accepts a valid enum value", () => { - expect(errorsFor('root = EnumBox("active")')).toEqual([]); - }); - - it("flags an out-of-enum value", () => { - const errors = errorsFor('root = EnumBox("bogus")'); - expect(errors).toHaveLength(1); - expect(errors[0]).toMatchObject({ code: "type-mismatch", path: "/status" }); - expect(errors[0].message).toContain("one of"); - }); - - it("recurses array → object → array → scalar and reports the deep path", () => { - const errors = errorsFor('root = DeepBox([{ name: "g1", points: [1, "two", 3] }])'); - expect(errors).toHaveLength(1); - expect(errors[0]).toMatchObject({ - code: "type-mismatch", - path: "/groups/0/points/1", - }); - }); - - it("reports a required key missing deep inside a nested array item", () => { - const errors = errorsFor("root = DeepBox([{ points: [1, 2] }])"); - expect(errors).toHaveLength(1); - expect(errors[0]).toMatchObject({ - code: "missing-required", - path: "/groups/0/name", - }); + it("reports container-shape mismatches (wrong value kind for object/array/scalar slots)", () => { + 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"); + expect(errorsFor("root = ScalarBox([1, 2])")[0].message).toContain( + "expects string but got array", + ); + // 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", + ); }); - it("collects independent errors across multiple array elements", () => { - const errors = errorsFor('root = ArrBox([{ label: 1 }, { value: "x" }])'); - // el0: label is number-not-string. - // el1: label missing (required) AND value is string-not-number. - expect(errors).toHaveLength(3); - const byPath = errors.map((e) => `${e.code}:${e.path}`).sort(); + it("accumulates independent errors across siblings, array elements, and excess args", () => { + // two sibling leaves of one object + expect(errorsFor('root = ObjBox({ author: 7, views: "x" })')).toHaveLength(2); + // 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", ]); - }); -}); - -describe("validateSchemaValue — conservative skips", () => { - it("does not flag dynamic ($var) elements against the item schema", () => { - // $x resolves at runtime; it must not be type-checked as a string. - expect(errorsFor('root = TagBox(["a", $x])')).toEqual([]); - }); - - it("leaves composite (anyOf) schemas unchecked", () => { - expect(errorsFor("root = AnyBox({ anything: true })")).toEqual([]); + // arg validation and excess-args reporting are independent + const codes = errorsFor('root = ScalarBox(1, 1, true, "extra")') + .map((e) => e.code) + .sort(); + expect(codes).toEqual(["excess-args", "type-mismatch"]); }); - it("suppresses nested required-key checks while streaming is incomplete", () => { - const sp = createStreamingParser(schema); - // Trailing, unterminated object → parse is marked incomplete (partial). - const res = sp.push("root = ObjBox({ views: 3 "); - expect(res.meta.incomplete).toBe(true); - expect(res.meta.errors.some((e) => e.code === "missing-required")).toBe(false); + it("attributes errors to the statement that defines the component", () => { + const errors = errorsFor( + 'root = ListBox([inner])\ninner = ObjBox({ author: "ann", views: "lots" })', + ); + expect(errors[0]).toMatchObject({ component: "ObjBox", statementId: "inner" }); }); }); -describe("validateSchemaValue — top-level scalar params", () => { - it("accepts matching scalar types across all three leaves", () => { - expect(errorsFor('root = ScalarBox("a", 1, true)')).toEqual([]); - }); - - it("flags a number where a string is expected", () => { - const errors = errorsFor("root = ScalarBox(5)"); - expect(errors).toHaveLength(1); - expect(errors[0]).toMatchObject({ code: "type-mismatch", path: "/title" }); - expect(errors[0].message).toContain("expects string but got number"); - }); - - it("flags a string where a number is expected", () => { - const errors = errorsFor('root = ScalarBox("a", "b")'); - expect(errors).toHaveLength(1); - expect(errors[0]).toMatchObject({ code: "type-mismatch", path: "/count" }); - expect(errors[0].message).toContain("expects number but got string"); - }); - - it("flags a string where a boolean is expected", () => { - const errors = errorsFor('root = ScalarBox("a", 1, "yes")'); - expect(errors).toHaveLength(1); - expect(errors[0]).toMatchObject({ code: "type-mismatch", path: "/flag" }); - expect(errors[0].message).toContain("expects boolean but got string"); - }); - - it("accepts an integer against `type: integer`", () => { - expect(errorsFor("root = IntBox(3)")).toEqual([]); - }); - - it("flags a string against `type: integer`", () => { - const errors = errorsFor('root = IntBox("3")'); - expect(errors).toHaveLength(1); - expect(errors[0].message).toContain("expects number but got string"); - }); - - it("does not flag a float against `type: integer` (integer ≈ number, conservative)", () => { - expect(errorsFor("root = IntBox(1.5)")).toEqual([]); - }); - - it("accepts the exact `const` value", () => { - expect(errorsFor('root = ConstBox("fixed")')).toEqual([]); - }); - - it("flags a non-matching value against `const` as a single-value enum", () => { - const errors = errorsFor('root = ConstBox("loose")'); - expect(errors).toHaveLength(1); - expect(errors[0]).toMatchObject({ code: "type-mismatch", path: "/mode" }); - expect(errors[0].message).toContain('one of ["fixed"]'); - }); - - it("flags a boolean against a string enum", () => { - const errors = errorsFor("root = EnumBox(true)"); - expect(errors).toHaveLength(1); - expect(errors[0]).toMatchObject({ code: "type-mismatch", path: "/status" }); - expect(errors[0].message).toContain("one of"); - }); - - it("flags an object where a scalar is expected", () => { - const errors = errorsFor("root = ScalarBox({ a: 1 })"); - expect(errors).toHaveLength(1); - expect(errors[0]).toMatchObject({ code: "type-mismatch", path: "/title" }); - expect(errors[0].message).toContain("expects string but got object"); - }); - - it("flags an array where a scalar is expected", () => { - const errors = errorsFor("root = ScalarBox([1, 2])"); - expect(errors).toHaveLength(1); - expect(errors[0]).toMatchObject({ code: "type-mismatch", path: "/title" }); - expect(errors[0].message).toContain("expects string but got array"); - }); - - it("flags an object with dynamic parts — it stays an object at runtime", () => { - const errors = errorsFor("root = ScalarBox({ a: $x })"); - expect(errors).toHaveLength(1); - expect(errors[0]).toMatchObject({ code: "type-mismatch", path: "/title" }); - expect(errors[0].message).toContain("expects string but got object"); +describe("resolveInvalidValue — the edge rule (default → required → prune)", () => { + it("prunes invalid values on OPTIONAL edges — component survives", () => { + // nested optional key + const nested = parser.parse('root = ObjBox({ author: "ann", views: "lots" })'); + expect(nested.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) + const obj = parser.parse("root = ObjBox({ views: 3 })"); + expect(obj.root).not.toBeNull(); + expect(obj.root?.props).not.toHaveProperty("info"); + }); + + it("always prunes invalid ARRAY ITEMS — siblings survive, arrays never invalidate", () => { + const scalars = parser.parse('root = TagBox(["a", 2, "c"])'); + expect(scalars.root?.props.tags).toEqual(["a", "c"]); + // a required-key violation inside an item is absorbed by the item edge + const rows = parser.parse('root = ArrBox([{ label: "a", value: 1 }, { value: 2 }])'); + expect(rows.root).not.toBeNull(); + expect(rows.root?.props.rows).toEqual([{ label: "a", value: 1 }]); + }); + + it("propagates through REQUIRED nested keys up to the nearest optional edge", () => { + // info.author (required) is invalid → info invalid → info is optional → pruned + const result = parser.parse('root = ObjBox({ author: { first: "a" }, views: 3 })'); + expect(result.root).not.toBeNull(); + expect(result.root?.props).not.toHaveProperty("info"); + expect(result.meta.errors[0]).toMatchObject({ path: "/info/author" }); + }); + + it("drops the component when a REQUIRED param is invalid with no default", () => { + // required object param with a missing required key + expect(parser.parse("root = ReqBox({ other: 1 })").root).toBeNull(); + // required scalar param with a type mismatch + expect(parser.parse("root = ReqScalar(5)").root).toBeNull(); + }); + + it("substitutes schema defaults for invalid values at every edge (error kept)", () => { + // top-level required param + const req = parser.parse("root = DefBox(5)"); + expect(req.root?.props.theme).toBe("dark"); + expect(req.meta.errors).toHaveLength(1); + // top-level optional param (default wins over pruning) + expect(parser.parse("root = OptDefBox(5)").root?.props.theme).toBe("dark"); + // nested required enum key — parent survives + const nestedReq = parser.parse('root = NestDefBox({ mode: "warp", retries: 1 })'); + expect(nestedReq.root?.props.cfg).toEqual({ mode: "fast", retries: 1 }); + expect(nestedReq.meta.errors[0]).toMatchObject({ path: "/cfg/mode" }); + // nested optional key + const nestedOpt = parser.parse('root = NestDefBox({ mode: "slow", retries: "many" })'); + expect(nestedOpt.root?.props.cfg).toEqual({ mode: "slow", retries: 3 }); + }); + + it("fills absent/null required keys from their defaults silently (no error)", () => { + expect(parser.parse("root = DefBox()").meta.errors).toEqual([]); + expect(parser.parse("root = DefBox()").root?.props.theme).toBe("dark"); + 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("validateSchemaValue — conservative skips on leaves and values", () => { - it("does not flag an object against an enum leaf (reference equality could never match)", () => { - expect(errorsFor("root = EnumBox({ a: 1 })")).toEqual([]); - }); - - it("skips a null arg (absence is the parent required-check's concern)", () => { - expect(errorsFor("root = ObjBox(null)")).toEqual([]); - }); - - it("skips validation entirely when the arg is omitted", () => { - expect(errorsFor("root = ObjBox()")).toEqual([]); - }); - - it("ignores extra keys not declared in the object schema", () => { - expect(errorsFor('root = ObjBox({ author: "a", bonus: 99 })')).toEqual([]); +describe("components in data slots", () => { + it("drops the component when a REQUIRED data param receives an element", () => { + // hbc = HorizontalBarChart(Card([]), [], "grouped") — the motivating case. + const result = parser.parse('root = ChartBox(CardBox("x"), "grouped")'); + expect(result.root).toBeNull(); + expect(result.meta.errors[0]).toMatchObject({ code: "type-mismatch", path: "/labels" }); + expect(result.meta.errors[0].message).toContain('expects array but got component "CardBox"'); }); - it("skips builtin calls as leaf values (they resolve at runtime)", () => { - expect(errorsFor('root = ObjBox({ author: "a", views: Sum([1, 2]) })')).toEqual([]); + it("prunes an element on optional edges (enum leaf, data-array item)", () => { + const enumSlot = parser.parse('root = ChartBox(["a"], CardBox("x"))'); + expect(enumSlot.root?.props).not.toHaveProperty("variant"); + expect(enumSlot.meta.errors[0].message).toContain( + "expects a literal value but got component", + ); + const arrayItem = parser.parse('root = TagBox(["a", CardBox("x"), "b"])'); + expect(arrayItem.root?.props.tags).toEqual(["a", "b"]); }); - it("flags a component element sitting in a plain-data slot", () => { - const errors = errorsFor('root = ObjBox({ author: CardBox("hi"), views: 1 })'); - expect(errors).toHaveLength(1); - expect(errors[0]).toMatchObject({ code: "type-mismatch", path: "/info/author" }); - expect(errors[0].message).toContain('expects string but got component "CardBox"'); + it("leaves elements in component slots ($ref/anyOf) unchecked — membership comes later", () => { + const result = parser.parse('root = SlotBox([CardBox("hi"), EnumBox("active")])'); + expect(result.meta.errors).toEqual([]); + expect((result.root?.props.children as unknown[]).length).toBe(2); }); it("still validates the misplaced component's own args (both errors surface)", () => { const errors = errorsFor("root = ObjBox({ author: CardBox(9) })"); expect(errors).toHaveLength(2); - expect(errors.find((e) => e.component === "CardBox")).toMatchObject({ - code: "type-mismatch", - path: "/text", - }); - expect(errors.find((e) => e.component === "ObjBox")).toMatchObject({ - path: "/info/author", - }); + expect(errors.find((e) => e.component === "CardBox")).toMatchObject({ path: "/text" }); + expect(errors.find((e) => e.component === "ObjBox")).toMatchObject({ path: "/info/author" }); }); }); -describe("validateSchemaValue — shape mismatch variants", () => { - it("flags an array where an object is expected", () => { - const errors = errorsFor("root = ObjBox([1])"); - expect(errors).toHaveLength(1); - expect(errors[0]).toMatchObject({ code: "type-mismatch", path: "/info" }); - expect(errors[0].message).toContain("expects object but got array"); - }); - - it("flags a number where an array is expected", () => { - const errors = errorsFor("root = TagBox(42)"); - expect(errors).toHaveLength(1); - expect(errors[0]).toMatchObject({ code: "type-mismatch", path: "/tags" }); - expect(errors[0].message).toContain("expects array but got number"); - }); - - it("leaves elements unchecked when the array schema has no `items`", () => { - expect(errorsFor('root = ListBox([1, "a", true])')).toEqual([]); - }); - - it("flags out-of-enum values nested inside array items", () => { - const errors = errorsFor("root = LevelBox([1, 5])"); - expect(errors).toHaveLength(1); - expect(errors[0]).toMatchObject({ code: "type-mismatch", path: "/levels/1" }); - expect(errors[0].message).toContain("one of"); - }); - - it("flags an object against a scalar leaf nested inside an object", () => { - const errors = errorsFor('root = ObjBox({ author: { first: "a" }, views: 3 })'); - expect(errors).toHaveLength(1); - expect(errors[0]).toMatchObject({ code: "type-mismatch", path: "/info/author" }); - expect(errors[0].message).toContain("expects string but got object"); +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` }); -}); - -describe("validateSchemaValue — malformed and composite schemas (direct)", () => { - 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; - } - it("skips non-object schemas (undefined, null, string, boolean, array)", () => { + it("skips malformed and uncheckable schema fragments (direct)", () => { expect(directErrors(5, undefined)).toEqual([]); - expect(directErrors(5, null)).toEqual([]); expect(directErrors(5, "string")).toEqual([]); - expect(directErrors(5, true)).toEqual([]); - expect(directErrors(5, [{ type: "number" }])).toEqual([]); - }); - - it("skips $ref / oneOf / allOf composites", () => { expect(directErrors(5, { $ref: "#/$defs/Thing" })).toEqual([]); expect(directErrors(5, { oneOf: [{ type: "string" }] })).toEqual([]); expect(directErrors(5, { allOf: [{ type: "string" }] })).toEqual([]); - }); - - it("skips undefined values", () => { + 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 expect(directErrors(undefined, { type: "string" })).toEqual([]); - }); - - it("skips tuple-form `items`", () => { - expect(directErrors([5], { type: "array", items: [{ type: "string" }] })).toEqual([]); - }); - - it("skips unknown scalar `type` keywords", () => { - expect(directErrors("x", { type: "date" })).toEqual([]); - expect(directErrors("x", {})).toEqual([]); - }); - - it("ignores a non-array `required`", () => { - expect(directErrors({}, { type: "object", required: "author" })).toEqual([]); - }); - - it("checks `required` even when `properties` is absent", () => { - const errors = directErrors({}, { type: "object", required: ["a"] }); - expect(errors).toHaveLength(1); - expect(errors[0]).toMatchObject({ code: "missing-required", path: "/p/a" }); - }); - - it("suppresses required checks in a partial ctx but keeps type checks", () => { - expect(directErrors({}, { type: "object", required: ["a"] }, true)).toEqual([]); - const errors = directErrors(5, { type: "string" }, true); - expect(errors).toHaveLength(1); - expect(errors[0]).toMatchObject({ code: "type-mismatch" }); - }); - - it("defers enum membership in a partial ctx but checks it when complete", () => { - const enumSchema = { enum: ["active", "inactive"] }; - // Mid-stream the literal may still be completing toward a valid member. - expect(directErrors("act", enumSchema, true)).toEqual([]); - const errors = directErrors("act", enumSchema, false); - expect(errors).toHaveLength(1); - expect(errors[0]).toMatchObject({ code: "type-mismatch" }); - }); -}); - -describe("validateSchemaValue — attribution and interplay with other checks", () => { - it("attributes errors to the statement that defines the component", () => { - const errors = errorsFor( - 'root = ListBox([inner])\ninner = ObjBox({ author: "ann", views: "lots" })', - ); - expect(errors).toHaveLength(1); - expect(errors[0]).toMatchObject({ - code: "type-mismatch", - component: "ObjBox", - path: "/info/views", - statementId: "inner", + // ...but `required` works even without `properties` + expect(directErrors({}, { type: "object", required: ["a"] })[0]).toMatchObject({ + code: "missing-required", }); }); - it("applies a schema default instead of erroring, without validating it", () => { - const result = parser.parse("root = DefBox()"); - expect(result.meta.errors).toEqual([]); - expect(result.root?.props.theme).toBe("dark"); - }); - - it("reports excess args alongside validation of the mapped args", () => { - const errors = errorsFor('root = ScalarBox(1, 1, true, "extra")'); - expect(errors).toHaveLength(2); - const codes = errors.map((e) => e.code).sort(); - expect(codes).toEqual(["excess-args", "type-mismatch"]); - expect(errors.find((e) => e.code === "type-mismatch")).toMatchObject({ path: "/title" }); - }); - - it("accumulates independent errors across sibling leaves of one object", () => { - const errors = errorsFor('root = ObjBox({ author: 7, views: "x" })'); - expect(errors).toHaveLength(2); - const byPath = errors.map((e) => `${e.code}:${e.path}`).sort(); - expect(byPath).toEqual(["type-mismatch:/info/author", "type-mismatch:/info/views"]); + it("applies documented leaf-check edges (direct): integer ≈ number, const ≈ enum, non-scalar skips", () => { + expect(directErrors(3, { type: "integer" })).toEqual([]); + expect(directErrors(1.5, { type: "integer" })).toEqual([]); // conservative + expect(directErrors("3", { type: "integer" })).toHaveLength(1); + expect(directErrors("loose", { const: "fixed" })[0].message).toContain('one of ["fixed"]'); + expect(directErrors(true, { enum: ["active", "inactive"] })).toHaveLength(1); + // objects never match enum members (reference equality) — skipped, not flagged + expect(directErrors({ a: 1 }, { enum: ["active"] })).toEqual([]); }); }); -describe("validateSchemaValue — streaming", () => { - it("still flags type mismatches while streaming is incomplete", () => { - const sp = createStreamingParser(schema); - const res = sp.push('root = ObjBox({ author: "a", views: "lots" '); - expect(res.meta.incomplete).toBe(true); - const mismatch = res.meta.errors.find((e) => e.code === "type-mismatch"); - expect(mismatch).toMatchObject({ path: "/info/views" }); - }); - - it("reports suppressed required checks once the stream completes", () => { +describe("streaming gates", () => { + it("defers required-key checks while partial, then reports them on completion", () => { const sp = createStreamingParser(schema); const mid = sp.push("root = ObjBox({ views: 3 "); + expect(mid.meta.incomplete).toBe(true); expect(mid.meta.errors.some((e) => e.code === "missing-required")).toBe(false); const done = sp.push("})\n"); expect(done.meta.incomplete).toBe(false); - const missing = done.meta.errors.find((e) => e.code === "missing-required"); - expect(missing).toMatchObject({ path: "/info/author", statementId: "root" }); - }); -}); - -describe("validateSchemaValue — required-edge propagation (prune vs drop)", () => { - it("prunes an OPTIONAL prop whose object is missing a required key — component survives", () => { - const result = parser.parse("root = ObjBox({ views: 3 })"); - expect(result.root).not.toBeNull(); - expect(result.root?.props).not.toHaveProperty("info"); - expect(result.meta.errors).toHaveLength(1); - expect(result.meta.errors[0]).toMatchObject({ - code: "missing-required", - component: "ObjBox", - path: "/info/author", - }); - }); - - it("prunes an OPTIONAL prop whose object has a null required key — component survives", () => { - const result = parser.parse("root = ObjBox({ author: null, views: 3 })"); - expect(result.root).not.toBeNull(); - expect(result.root?.props).not.toHaveProperty("info"); - expect(result.meta.errors[0]).toMatchObject({ code: "null-required", path: "/info/author" }); - }); - - it("prunes the offending ARRAY ITEM, not the component — the array keeps valid items", () => { - const result = parser.parse('root = ArrBox([{ label: "a", value: 1 }, { value: 2 }])'); - expect(result.root).not.toBeNull(); - expect(result.root?.props.rows).toEqual([{ label: "a", value: 1 }]); - expect( - result.meta.errors.some((e) => e.code === "missing-required" && e.path === "/rows/1/label"), - ).toBe(true); - }); - - it("prunes a wrong-typed scalar item from a scalar array", () => { - const result = parser.parse('root = TagBox(["a", 2, "c"])'); - expect(result.root?.props.tags).toEqual(["a", "c"]); - expect(result.meta.errors[0]).toMatchObject({ code: "type-mismatch", path: "/tags/1" }); - }); - - it("prunes an OPTIONAL nested key with a type mismatch — parent object survives", () => { - const result = parser.parse('root = ObjBox({ author: "ann", views: "lots" })'); - expect(result.root).not.toBeNull(); - expect(result.root?.props.info).toEqual({ author: "ann" }); - expect(result.meta.errors).toHaveLength(1); - expect(result.meta.errors[0]).toMatchObject({ code: "type-mismatch", path: "/info/views" }); - }); - - it("prunes an OPTIONAL top-level scalar prop on type mismatch", () => { - const result = parser.parse("root = ScalarBox(5)"); - expect(result.root).not.toBeNull(); - expect(result.root?.props).not.toHaveProperty("title"); - expect(result.meta.errors[0]).toMatchObject({ code: "type-mismatch", path: "/title" }); - }); - - it("propagates through a REQUIRED nested key: wrong-typed required key prunes its parent", () => { - // info.author is required-and-invalid → info invalid → info is an optional - // param → info pruned, component survives. - const result = parser.parse('root = ObjBox({ author: { first: "a" }, views: 3 })'); - expect(result.root).not.toBeNull(); - expect(result.root?.props).not.toHaveProperty("info"); - expect(result.meta.errors[0]).toMatchObject({ code: "type-mismatch", path: "/info/author" }); - }); - - it("drops the component when a REQUIRED prop's object is missing a required key", () => { - const result = parser.parse("root = ReqBox({ other: 1 })"); - expect(result.root).toBeNull(); - expect(result.meta.errors).toHaveLength(1); - expect(result.meta.errors[0]).toMatchObject({ - code: "missing-required", + expect(done.meta.errors.find((e) => e.code === "missing-required")).toMatchObject({ path: "/info/author", + statementId: "root", }); }); - it("drops the component on a type mismatch of a REQUIRED scalar prop", () => { - const result = parser.parse("root = ReqScalar(5)"); - expect(result.root).toBeNull(); - expect(result.meta.errors).toHaveLength(1); - expect(result.meta.errors[0]).toMatchObject({ code: "type-mismatch", path: "/title" }); - }); - - it("substitutes the schema default when a REQUIRED prop's value is invalid", () => { - const result = parser.parse("root = DefBox(5)"); - expect(result.root).not.toBeNull(); - expect(result.root?.props.theme).toBe("dark"); - expect(result.meta.errors[0]).toMatchObject({ code: "type-mismatch", path: "/theme" }); - }); - - it("an ARRAY edge absorbs a required-required chain: bad item pruned, component survives", () => { - // ReqBox requires info; info requires author — but here the violation sits - // inside ArrBox's rows items, so the item edge absorbs it before any - // required edge is crossed. - const result = parser.parse('root = ArrBox([{ value: "x" }])'); - expect(result.root).not.toBeNull(); - expect(result.root?.props.rows).toEqual([]); - }); - - it("renders a valid nested object with no errors", () => { - const result = parser.parse('root = ObjBox({ author: "ann", views: 3 })'); - expect(result.root).not.toBeNull(); - expect(result.meta.errors).toEqual([]); - }); -}); - -describe("validateSchemaValue — schema defaults repair invalid values at every edge", () => { - it("substitutes the default for an OPTIONAL param's invalid value (instead of pruning)", () => { - const result = parser.parse("root = OptDefBox(5)"); - expect(result.root?.props.theme).toBe("dark"); - expect(result.meta.errors[0]).toMatchObject({ code: "type-mismatch", path: "/theme" }); - }); - - it("substitutes the default for a REQUIRED nested enum key on mismatch — parent survives", () => { - const result = parser.parse('root = NestDefBox({ mode: "warp", retries: 1 })'); - expect(result.root?.props.cfg).toEqual({ mode: "fast", retries: 1 }); - expect(result.meta.errors).toHaveLength(1); - expect(result.meta.errors[0]).toMatchObject({ code: "type-mismatch", path: "/cfg/mode" }); - }); - - it("substitutes the default for an OPTIONAL nested key on mismatch (instead of pruning)", () => { - const result = parser.parse('root = NestDefBox({ mode: "slow", retries: "many" })'); - expect(result.root?.props.cfg).toEqual({ mode: "slow", retries: 3 }); - expect(result.meta.errors[0]).toMatchObject({ code: "type-mismatch", path: "/cfg/retries" }); - }); - - it("fills an absent REQUIRED nested key from its default silently (no error)", () => { - const result = parser.parse("root = NestDefBox({ retries: 2 })"); - expect(result.root?.props.cfg).toEqual({ mode: "fast", retries: 2 }); - expect(result.meta.errors).toEqual([]); - }); - - it("fills a null REQUIRED nested key from its default silently (no error)", () => { - const result = parser.parse("root = NestDefBox({ mode: null, retries: 2 })"); - expect(result.root?.props.cfg).toEqual({ mode: "fast", retries: 2 }); - expect(result.meta.errors).toEqual([]); - }); - - it("still prunes invalid ARRAY ITEMS — item defaults are never substituted", () => { - const result = parser.parse('root = TagBox(["a", 2])'); - expect(result.root?.props.tags).toEqual(["a"]); - expect(result.meta.errors).toHaveLength(1); - }); -}); - -describe("validateSchemaValue — components in data slots", () => { - it("leaves elements in component slots ($ref/anyOf) unchecked — membership comes later", () => { - const result = parser.parse('root = SlotBox([CardBox("hi"), EnumBox("active")])'); - expect(result.meta.errors).toEqual([]); - expect((result.root?.props.children as unknown[]).length).toBe(2); - }); - - it("drops the component when a REQUIRED data param receives an element", () => { - // hbc = HorizontalBarChart(Card([]), [], "grouped") — the motivating case. - const result = parser.parse('root = ChartBox(CardBox("x"), "grouped")'); - expect(result.root).toBeNull(); - expect(result.meta.errors).toHaveLength(1); - expect(result.meta.errors[0]).toMatchObject({ code: "type-mismatch", path: "/labels" }); - expect(result.meta.errors[0].message).toContain('expects array but got component "CardBox"'); - }); - - it("prunes an element sitting where an enum leaf is expected (optional edge)", () => { - const result = parser.parse('root = ChartBox(["a"], CardBox("x"))'); - expect(result.root).not.toBeNull(); - expect(result.root?.props).not.toHaveProperty("variant"); - expect(result.meta.errors[0]).toMatchObject({ code: "type-mismatch", path: "/variant" }); - expect(result.meta.errors[0].message).toContain("expects a literal value but got component"); - }); - - it("prunes an element from a data ARRAY's items — siblings survive", () => { - const result = parser.parse('root = TagBox(["a", CardBox("x"), "b"])'); - expect(result.root?.props.tags).toEqual(["a", "b"]); - expect(result.meta.errors[0]).toMatchObject({ code: "type-mismatch", path: "/tags/1" }); - }); -}); - -describe("validateSchemaValue — enum leaves while streaming", () => { - it("does not flag an out-of-enum leaf while the stream is incomplete", () => { - const sp = createStreamingParser(schema); - // Trailing, unterminated call → parse is marked incomplete (partial). - const res = sp.push('root = EnumBox("bogus" '); - expect(res.meta.incomplete).toBe(true); - expect(res.meta.errors.some((e) => e.code === "type-mismatch")).toBe(false); - }); - - it("flags the out-of-enum value once the stream completes", () => { + it("defers enum membership while partial, then flags it on completion", () => { const sp = createStreamingParser(schema); const mid = sp.push('root = EnumBox("bogus" '); - expect(mid.meta.errors.some((e) => e.code === "type-mismatch")).toBe(false); + expect(mid.meta.errors).toEqual([]); const done = sp.push(")\n"); - expect(done.meta.incomplete).toBe(false); expect(done.meta.errors.find((e) => e.code === "type-mismatch")).toMatchObject({ path: "/status", }); }); - it("still flags a nested scalar type mismatch while streaming (only enums defer)", () => { + 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); }); }); From a3e952fb7925a4666e3c93e303c9baa5b910637e Mon Sep 17 00:00:00 2001 From: Abhin Rustagi Date: Tue, 14 Jul 2026 22:38:39 +0530 Subject: [PATCH 23/31] fix: format fix --- .../src/parser/__tests__/materialize.validation.test.ts | 4 +--- packages/lang-core/src/parser/parser.ts | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/lang-core/src/parser/__tests__/materialize.validation.test.ts b/packages/lang-core/src/parser/__tests__/materialize.validation.test.ts index 83d150579..613a85dac 100644 --- a/packages/lang-core/src/parser/__tests__/materialize.validation.test.ts +++ b/packages/lang-core/src/parser/__tests__/materialize.validation.test.ts @@ -340,9 +340,7 @@ describe("components in data slots", () => { it("prunes an element on optional edges (enum leaf, data-array item)", () => { const enumSlot = parser.parse('root = ChartBox(["a"], CardBox("x"))'); expect(enumSlot.root?.props).not.toHaveProperty("variant"); - expect(enumSlot.meta.errors[0].message).toContain( - "expects a literal value but got component", - ); + expect(enumSlot.meta.errors[0].message).toContain("expects a literal value but got component"); const arrayItem = parser.parse('root = TagBox(["a", CardBox("x"), "b"])'); expect(arrayItem.root?.props.tags).toEqual(["a", "b"]); }); diff --git a/packages/lang-core/src/parser/parser.ts b/packages/lang-core/src/parser/parser.ts index ca2650cc6..41bd2e7bc 100644 --- a/packages/lang-core/src/parser/parser.ts +++ b/packages/lang-core/src/parser/parser.ts @@ -5,7 +5,6 @@ import { parseExpression } from "./expressions"; import { tokenize } from "./lexer"; import { materializeValue } from "./materialize"; import { autoClose, split, type RawStmt } from "./statements"; -import { getSchemaDefaultValue } from "./validation"; import { T } from "./tokens"; import { isElementNode, @@ -17,6 +16,7 @@ import { type QueryStatementInfo, type ValidationError, } from "./types"; +import { getSchemaDefaultValue } from "./validation"; // ───────────────────────────────────────────────────────────────────────────── // Result building From 2abb64bcc7f80d16f7e4e4cb6aadd0cf70f705c1 Mon Sep 17 00:00:00 2001 From: Abhin Rustagi Date: Tue, 14 Jul 2026 23:03:29 +0530 Subject: [PATCH 24/31] refactor: validation issue --- packages/lang-core/src/parser/materialize.ts | 41 +++-------- packages/lang-core/src/parser/validation.ts | 76 ++++++++++++-------- 2 files changed, 54 insertions(+), 63 deletions(-) diff --git a/packages/lang-core/src/parser/materialize.ts b/packages/lang-core/src/parser/materialize.ts index 9731f7162..562411cec 100644 --- a/packages/lang-core/src/parser/materialize.ts +++ b/packages/lang-core/src/parser/materialize.ts @@ -6,7 +6,7 @@ import type { ASTNode } from "./ast"; import { isASTNode, isRuntimeExpr } from "./ast"; import { isBuiltin, isReservedCall, LAZY_BUILTINS, RESERVED_CALLS } from "./builtins"; import { isElementNode, type MaterializeCtx } from "./types"; -import { pushValidationError, resolveInvalidValue, validateSchemaValue } from "./validation"; +import { pushValidationIssue, resolveInvalidValue, validateSchemaValue } from "./validation"; /** * Recursively check if a prop value contains any AST nodes that need runtime @@ -114,12 +114,7 @@ function materializeExprInternal( return { ...node, args: recursedArgs, mappedProps }; } // Unknown component in expression: push error (same as value path) - pushValidationError(ctx, { - code: "unknown-component", - component: node.name, - path: "", - message: `Unknown component "${node.name}" — not found in catalog or builtins`, - }); + pushValidationIssue(ctx, node.name, "", { code: "unknown-component" }); return { ...node, args: recursedArgs }; } @@ -235,12 +230,7 @@ export function materializeValue(node: ASTNode, ctx: MaterializeCtx): unknown { // Inline Query/Mutation (not from a statement-level declaration) → validation error if (isReservedCall(name)) { - pushValidationError(ctx, { - code: "inline-reserved", - component: name, - path: "", - message: `${name}() must be declared as a top-level statement, not used inline as a value`, - }); + pushValidationIssue(ctx, name, "", { code: "inline-reserved" }); return null; } @@ -272,12 +262,10 @@ export function materializeValue(node: ASTNode, ctx: MaterializeCtx): unknown { // Report excess positional args (extra args are silently dropped) if (args.length > def.params.length) { - const excessCount = args.length - def.params.length; - pushValidationError(ctx, { + pushValidationIssue(ctx, name, "", { code: "excess-args", - component: name, - path: "", - message: `${name} takes ${def.params.length} arg(s), got ${args.length} (${excessCount} excess dropped)`, + declared: def.params.length, + got: args.length, }); } @@ -295,14 +283,8 @@ export function materializeValue(node: ASTNode, ctx: MaterializeCtx): unknown { }); if (stillInvalid.length) { for (const p of stillInvalid) { - const isNull = p.name in props; - pushValidationError(ctx, { - 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}"`, + pushValidationIssue(ctx, name, `/${p.name}`, { + code: p.name in props ? "null-required" : "missing-required", }); } return null; @@ -314,12 +296,7 @@ export function materializeValue(node: ASTNode, ctx: MaterializeCtx): unknown { if (dropComponent) return null; } else if (!isBuiltin(name) && !isReservedCall(name)) { // Unknown component: error and drop from tree - pushValidationError(ctx, { - code: "unknown-component", - component: name, - path: "", - message: `Unknown component "${name}" — not found in catalog or builtins`, - }); + pushValidationIssue(ctx, name, "", { code: "unknown-component" }); return null; } diff --git a/packages/lang-core/src/parser/validation.ts b/packages/lang-core/src/parser/validation.ts index 4d84ee843..aff8c3ac6 100644 --- a/packages/lang-core/src/parser/validation.ts +++ b/packages/lang-core/src/parser/validation.ts @@ -4,7 +4,6 @@ import { type ElementNode, type MaterializeCtx, type ScalarParamType, - type ValidationError, } from "./types"; /** Scalar type/enum info extracted from a JSON Schema leaf, for describeTypeMismatch. */ @@ -68,26 +67,49 @@ export function getScalarTypeInfo(s: Record): ScalarTypeInfo { } } -export function pushValidationError( - ctx: MaterializeCtx, - error: Omit, -): void { - ctx.errors.push({ ...error, statementId: ctx.currentStatementId }); +/** + * 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" } + | { code: "null-required" } + | { code: "unknown-component" } + | { 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}"`; + case "null-required": + return `required field "${path}" cannot be null`; + case "unknown-component": + return `Unknown component "${component}" — not found in catalog or builtins`; + 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)`; + } } -/** Report a type-mismatch in the shared `expects X but got Y` shape. */ -function pushTypeMismatch( +/** Append a validation error, deriving its message from the structured issue. */ +export function pushValidationIssue( ctx: MaterializeCtx, component: string, path: string, - expected: string, - actual: string, + issue: ValidationIssue, ): void { - pushValidationError(ctx, { - code: "type-mismatch", + ctx.errors.push({ + code: issue.code, component, path, - message: `field "${path}" expects ${expected} but got ${actual}`, + message: validationMessage(component, path, issue), + statementId: ctx.currentStatementId, }); } @@ -118,13 +140,11 @@ function validateElementPosition( ): boolean { if ("$ref" in s || "anyOf" in s || "oneOf" in s || "allOf" in s) return false; if (typeof s["type"] === "string" || Array.isArray(s["enum"]) || "const" in s) { - pushTypeMismatch( - ctx, - component, - path, - typeof s["type"] === "string" ? s["type"] : "a literal value", - `component "${element.typeName}"`, - ); + 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; @@ -141,7 +161,7 @@ function validateObjectValue( ctx: MaterializeCtx, ): boolean { if (typeof value !== "object" || Array.isArray(value)) { - pushTypeMismatch(ctx, component, path, "object", jsType(value)); + pushValidationIssue(ctx, component, path, { code: "type-mismatch", expected: "object", actual: jsType(value) }); return true; } const obj = value as Record; @@ -160,14 +180,8 @@ function validateObjectValue( obj[key] = fallback; continue; } - const isNull = key in obj; - pushValidationError(ctx, { - code: isNull ? "null-required" : "missing-required", - component, - path: `${path}/${key}`, - message: isNull - ? `required field "${path}/${key}" cannot be null` - : `missing required field "${path}/${key}"`, + pushValidationIssue(ctx, component, `${path}/${key}`, { + code: key in obj ? "null-required" : "missing-required", }); invalid = true; } @@ -195,7 +209,7 @@ function validateArrayValue( ctx: MaterializeCtx, ): boolean { if (!Array.isArray(value)) { - pushTypeMismatch(ctx, component, path, "array", jsType(value)); + pushValidationIssue(ctx, component, path, { code: "type-mismatch", expected: "array", actual: jsType(value) }); return true; } const items = s["items"]; @@ -224,7 +238,7 @@ function validateLeafValue( if (leaf.enumValues && ctx.partial) return false; const mismatch = describeTypeMismatch(value, leaf); if (mismatch) { - pushTypeMismatch(ctx, component, path, mismatch.expected, mismatch.actual); + pushValidationIssue(ctx, component, path, { code: "type-mismatch", ...mismatch }); return true; } return false; From c45b8699bed102d49d2df7c7a34242801cdccba7 Mon Sep 17 00:00:00 2001 From: Abhin Rustagi Date: Tue, 14 Jul 2026 23:08:17 +0530 Subject: [PATCH 25/31] refactor: group fns in validation.ts --- packages/lang-core/src/parser/validation.ts | 85 ++++++++++++++------- 1 file changed, 57 insertions(+), 28 deletions(-) diff --git a/packages/lang-core/src/parser/validation.ts b/packages/lang-core/src/parser/validation.ts index aff8c3ac6..c09ca5ba8 100644 --- a/packages/lang-core/src/parser/validation.ts +++ b/packages/lang-core/src/parser/validation.ts @@ -6,6 +6,14 @@ import { type ScalarParamType, } from "./types"; +// ───────────────────────────────────────────────────────────────────────────── +// Schema & value introspection +// ───────────────────────────────────────────────────────────────────────────── + +function jsType(value: unknown): string { + return Array.isArray(value) ? "array" : typeof value; +} + /** 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. */ @@ -14,8 +22,29 @@ interface ScalarTypeInfo { enumValues?: readonly unknown[]; } -function jsType(value: unknown): string { - return Array.isArray(value) ? "array" : typeof value; +/** 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; } /** @@ -42,30 +71,9 @@ function describeTypeMismatch( return null; } -/** 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; -} - -/** 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 {}; - } -} +// ───────────────────────────────────────────────────────────────────────────── +// Errors +// ───────────────────────────────────────────────────────────────────────────── /** * A structured validation issue. Every error of a kind reads identically @@ -113,6 +121,15 @@ export function pushValidationIssue( }); } +// ───────────────────────────────────────────────────────────────────────────── +// Invalid-value resolution — the edge rule +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Decide the fate of an invalid child value held at `container[key]`: a schema + * default substitutes it; otherwise a REQUIRED edge propagates invalidity to the + * caller (returns true) and an OPTIONAL edge prunes the value. + */ export function resolveInvalidValue( container: Record, key: string, @@ -128,6 +145,10 @@ export function resolveInvalidValue( return false; } +// ───────────────────────────────────────────────────────────────────────────── +// Validators — one per schema shape, dispatched by validateSchemaValue +// ───────────────────────────────────────────────────────────────────────────── + /** * Position check for a component element in a data slot. */ @@ -161,7 +182,11 @@ function validateObjectValue( ctx: MaterializeCtx, ): boolean { if (typeof value !== "object" || Array.isArray(value)) { - pushValidationIssue(ctx, component, path, { code: "type-mismatch", expected: "object", actual: jsType(value) }); + pushValidationIssue(ctx, component, path, { + code: "type-mismatch", + expected: "object", + actual: jsType(value), + }); return true; } const obj = value as Record; @@ -209,7 +234,11 @@ function validateArrayValue( ctx: MaterializeCtx, ): boolean { if (!Array.isArray(value)) { - pushValidationIssue(ctx, component, path, { code: "type-mismatch", expected: "array", actual: jsType(value) }); + pushValidationIssue(ctx, component, path, { + code: "type-mismatch", + expected: "array", + actual: jsType(value), + }); return true; } const items = s["items"]; From 5cd6be433d16040eeb27f7e76d2c4a0a72f5d5c4 Mon Sep 17 00:00:00 2001 From: Abhin Rustagi Date: Fri, 17 Jul 2026 13:12:53 +0530 Subject: [PATCH 26/31] fix: deprecate enrich-errors --- packages/lang-core/src/parser/enrich-errors.ts | 6 ++++-- packages/lang-core/src/parser/types.ts | 16 ++++++++-------- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/packages/lang-core/src/parser/enrich-errors.ts b/packages/lang-core/src/parser/enrich-errors.ts index 794355b6f..e309cec96 100644 --- a/packages/lang-core/src/parser/enrich-errors.ts +++ b/packages/lang-core/src/parser/enrich-errors.ts @@ -45,9 +45,11 @@ function buildSignatureHint( } /** - * Convert parser ValidationErrors into enriched OpenUIErrors with hints. - * + * @deprecated ValidationError.message is already humanized — read + * `result.meta.errors` directly (match on `.code`/`.path`, display `.message`). + * This extra enrichment pass will be removed in a future major release. * Framework-agnostic — usable by React, Svelte, Vue, or standalone. + * Convert parser ValidationErrors into enriched OpenUIErrors with hints. */ export function enrichErrors( validationErrors: ValidationError[], diff --git a/packages/lang-core/src/parser/types.ts b/packages/lang-core/src/parser/types.ts index 0530c34e9..2a378972f 100644 --- a/packages/lang-core/src/parser/types.ts +++ b/packages/lang-core/src/parser/types.ts @@ -1,18 +1,18 @@ 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. */ From 859c4c963befb5cb116402899250b3035e66b234 Mon Sep 17 00:00:00 2001 From: Abhin Rustagi Date: Fri, 17 Jul 2026 13:17:24 +0530 Subject: [PATCH 27/31] revert changes on enrichErrors --- .../lang-core/src/parser/enrich-errors.ts | 53 ++++--------------- 1 file changed, 10 insertions(+), 43 deletions(-) diff --git a/packages/lang-core/src/parser/enrich-errors.ts b/packages/lang-core/src/parser/enrich-errors.ts index e309cec96..9677da56d 100644 --- a/packages/lang-core/src/parser/enrich-errors.ts +++ b/packages/lang-core/src/parser/enrich-errors.ts @@ -1,55 +1,26 @@ import type { LibraryJSONSchema, OpenUIError, ValidationError } from "./types"; -import { getScalarTypeInfo } from "./validation"; -/** Render a property's type for a signature hint, e.g. "string", "'a'|'b'", "Header". */ -function describeSchemaType(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 - // hints 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 hint like - * "Header(title*: string, variant: 'a'|'b')" from JSON schema. - * Positional order is preserved so the LLM can fix swapped args. - */ +/** Build a signature hint like "Header(title*, subtitle, icon)" from JSON schema. */ function buildSignatureHint( componentName: string, schema: { properties?: Record; required?: string[] } | undefined, ): string | undefined { if (!schema?.properties) return undefined; const required = new Set(schema.required ?? []); - const params = Object.entries(schema.properties) - .map(([k, prop]) => { - const type = describeSchemaType(prop); - const marked = required.has(k) ? `${k}*` : k; - return type ? `${marked}: ${type}` : marked; - }) + const params = Object.keys(schema.properties) + .map((k) => (required.has(k) ? `${k}*` : k)) .join(", "); return `Signature: ${componentName}(${params}) — * marks required`; } /** - * @deprecated ValidationError.message is already humanized — read - * `result.meta.errors` directly (match on `.code`/`.path`, display `.message`). - * This extra enrichment pass will be removed in a future major release. - * Framework-agnostic — usable by React, Svelte, Vue, or standalone. * 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[], @@ -67,11 +38,7 @@ export function enrichErrors( }; if (ve.code === "unknown-component" && componentNames.length) { error.hint = `Available components: ${componentNames.join(", ")}`; - } else if ( - ve.code === "missing-required" || - ve.code === "null-required" || - ve.code === "type-mismatch" - ) { + } else if (ve.code === "missing-required" || ve.code === "null-required") { error.hint = buildSignatureHint(ve.component, schema.$defs?.[ve.component]); } else if (ve.code === "inline-reserved") { error.hint = `Declare as a top-level statement: myVar = ${ve.component}(...)`; From 159109d14f25ed3c331b9fce21a6316902f44fcf Mon Sep 17 00:00:00 2001 From: Abhin Rustagi Date: Fri, 17 Jul 2026 13:24:06 +0530 Subject: [PATCH 28/31] fix: update validation --- .../__tests__/materialize.validation.test.ts | 14 +++++ packages/lang-core/src/parser/materialize.ts | 18 +++++-- packages/lang-core/src/parser/validation.ts | 51 ++++++++++++++++--- 3 files changed, 74 insertions(+), 9 deletions(-) diff --git a/packages/lang-core/src/parser/__tests__/materialize.validation.test.ts b/packages/lang-core/src/parser/__tests__/materialize.validation.test.ts index 613a85dac..2bcaf2f13 100644 --- a/packages/lang-core/src/parser/__tests__/materialize.validation.test.ts +++ b/packages/lang-core/src/parser/__tests__/materialize.validation.test.ts @@ -253,6 +253,20 @@ describe("recursive descent & error reporting", () => { expect(codes).toEqual(["excess-args", "type-mismatch"]); }); + it("messages are self-sufficient: signatures and available components are inlined", () => { + // 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 + const unknown = errorsFor("root = Nope()")[0]; + expect(unknown.code).toBe("unknown-component"); + expect(unknown.message).toContain("Available components: ObjBox, ArrBox"); + }); + it("attributes errors to the statement that defines the component", () => { const errors = errorsFor( 'root = ListBox([inner])\ninner = ObjBox({ author: "ann", views: "lots" })', diff --git a/packages/lang-core/src/parser/materialize.ts b/packages/lang-core/src/parser/materialize.ts index 562411cec..97d294dfa 100644 --- a/packages/lang-core/src/parser/materialize.ts +++ b/packages/lang-core/src/parser/materialize.ts @@ -6,7 +6,12 @@ import type { ASTNode } from "./ast"; import { isASTNode, isRuntimeExpr } from "./ast"; import { isBuiltin, isReservedCall, LAZY_BUILTINS, RESERVED_CALLS } from "./builtins"; import { isElementNode, type MaterializeCtx } from "./types"; -import { pushValidationIssue, resolveInvalidValue, validateSchemaValue } from "./validation"; +import { + describeSignature, + pushValidationIssue, + resolveInvalidValue, + validateSchemaValue, +} from "./validation"; /** * Recursively check if a prop value contains any AST nodes that need runtime @@ -114,7 +119,10 @@ function materializeExprInternal( return { ...node, args: recursedArgs, mappedProps }; } // Unknown component in expression: push error (same as value path) - pushValidationIssue(ctx, node.name, "", { code: "unknown-component" }); + pushValidationIssue(ctx, node.name, "", { + code: "unknown-component", + available: ctx.cat && [...ctx.cat.keys()], + }); return { ...node, args: recursedArgs }; } @@ -285,6 +293,7 @@ export function materializeValue(node: ASTNode, ctx: MaterializeCtx): unknown { for (const p of stillInvalid) { pushValidationIssue(ctx, name, `/${p.name}`, { code: p.name in props ? "null-required" : "missing-required", + signature: describeSignature(name, def.params), }); } return null; @@ -296,7 +305,10 @@ export function materializeValue(node: ASTNode, ctx: MaterializeCtx): unknown { if (dropComponent) return null; } else if (!isBuiltin(name) && !isReservedCall(name)) { // Unknown component: error and drop from tree - pushValidationIssue(ctx, name, "", { code: "unknown-component" }); + pushValidationIssue(ctx, name, "", { + code: "unknown-component", + available: ctx.cat && [...ctx.cat.keys()], + }); return null; } diff --git a/packages/lang-core/src/parser/validation.ts b/packages/lang-core/src/parser/validation.ts index c09ca5ba8..287299261 100644 --- a/packages/lang-core/src/parser/validation.ts +++ b/packages/lang-core/src/parser/validation.ts @@ -3,6 +3,7 @@ import { isElementNode, type ElementNode, type MaterializeCtx, + type ParamDef, type ScalarParamType, } from "./types"; @@ -47,6 +48,44 @@ export function getSchemaDefaultValue(property: unknown): unknown { return (property as { default?: unknown }).default; } +/** Render a schema fragment's type for a signature, e.g. `string`, `"a"|"b"`, `Header`. */ +export function describeSchemaType(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 describeSignature(component: string, params: ParamDef[]): string { + const rendered = params + .map((p) => { + const type = describeSchemaType(p.schema); + const marked = p.required ? `${p.name}*` : p.name; + return type ? `${marked}: ${type}` : marked; + }) + .join(", "); + return `${component}(${rendered})`; +} + /** * Check a materialized value against a scalar leaf's declared type/enum. * Returns a human-readable {expected, actual} on mismatch, or null when it @@ -82,9 +121,9 @@ function describeTypeMismatch( */ export type ValidationIssue = | { code: "type-mismatch"; expected: string; actual: string } - | { code: "missing-required" } - | { code: "null-required" } - | { code: "unknown-component" } + | { 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 }; @@ -93,11 +132,11 @@ function validationMessage(component: string, path: string, issue: ValidationIss case "type-mismatch": return `field "${path}" expects ${issue.expected} but got ${issue.actual}`; case "missing-required": - return `missing required field "${path}"`; + return `missing required field "${path}"${issue.signature ? ` — signature: ${issue.signature}` : ""}`; case "null-required": - return `required field "${path}" cannot be null`; + 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`; + 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": From c39181eb824c1e8aa55171adaa696d85420cbccc Mon Sep 17 00:00:00 2001 From: Abhin Rustagi Date: Fri, 17 Jul 2026 14:00:24 +0530 Subject: [PATCH 29/31] refactor: validation --- packages/lang-core/src/parser/materialize.ts | 4 +- packages/lang-core/src/parser/validation.ts | 94 ++++++++++++-------- 2 files changed, 59 insertions(+), 39 deletions(-) diff --git a/packages/lang-core/src/parser/materialize.ts b/packages/lang-core/src/parser/materialize.ts index 97d294dfa..310e2eab8 100644 --- a/packages/lang-core/src/parser/materialize.ts +++ b/packages/lang-core/src/parser/materialize.ts @@ -7,7 +7,7 @@ import { isASTNode, isRuntimeExpr } from "./ast"; import { isBuiltin, isReservedCall, LAZY_BUILTINS, RESERVED_CALLS } from "./builtins"; import { isElementNode, type MaterializeCtx } from "./types"; import { - describeSignature, + buildParamsSignature, pushValidationIssue, resolveInvalidValue, validateSchemaValue, @@ -293,7 +293,7 @@ export function materializeValue(node: ASTNode, ctx: MaterializeCtx): unknown { for (const p of stillInvalid) { pushValidationIssue(ctx, name, `/${p.name}`, { code: p.name in props ? "null-required" : "missing-required", - signature: describeSignature(name, def.params), + signature: buildParamsSignature(name, def.params), }); } return null; diff --git a/packages/lang-core/src/parser/validation.ts b/packages/lang-core/src/parser/validation.ts index 287299261..77458972b 100644 --- a/packages/lang-core/src/parser/validation.ts +++ b/packages/lang-core/src/parser/validation.ts @@ -11,10 +11,21 @@ import { // 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. */ @@ -48,8 +59,8 @@ export function getSchemaDefaultValue(property: unknown): unknown { return (property as { default?: unknown }).default; } -/** Render a schema fragment's type for a signature, e.g. `string`, `"a"|"b"`, `Header`. */ -export function describeSchemaType(property: unknown): string | undefined { +/** 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; } @@ -75,10 +86,10 @@ export function describeSchemaType(property: unknown): string | undefined { * compiled params — * marks required. Positional order is preserved so an LLM * can fix swapped args. */ -export function describeSignature(component: string, params: ParamDef[]): string { +export function buildParamsSignature(component: string, params: ParamDef[]): string { const rendered = params .map((p) => { - const type = describeSchemaType(p.schema); + const type = getTypeFromSchema(p.schema); const marked = p.required ? `${p.name}*` : p.name; return type ? `${marked}: ${type}` : marked; }) @@ -86,18 +97,14 @@ export function describeSignature(component: string, params: ParamDef[]): string return `${component}(${rendered})`; } -/** - * Check a materialized value against a scalar leaf's declared type/enum. - * Returns a human-readable {expected, actual} on mismatch, or null when it - * passes or isn't checkable. - */ -function describeTypeMismatch( +/** 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 (!["string", "number", "boolean"].includes(actual)) return null; + 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(", ")}]`, @@ -165,9 +172,9 @@ export function pushValidationIssue( // ───────────────────────────────────────────────────────────────────────────── /** - * Decide the fate of an invalid child value held at `container[key]`: a schema - * default substitutes it; otherwise a REQUIRED edge propagates invalidity to the - * caller (returns true) and an OPTIONAL edge prunes the value. + * 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, @@ -189,7 +196,10 @@ export function resolveInvalidValue( // ───────────────────────────────────────────────────────────────────────────── /** - * Position check for a component element in a data slot. + * 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, @@ -198,7 +208,7 @@ function validateElementPosition( path: string, ctx: MaterializeCtx, ): boolean { - if ("$ref" in s || "anyOf" in s || "oneOf" in s || "allOf" in s) return false; + if (isCompositeSchema(s)) return false; if (typeof s["type"] === "string" || Array.isArray(s["enum"]) || "const" in s) { pushValidationIssue(ctx, component, path, { code: "type-mismatch", @@ -211,7 +221,9 @@ function validateElementPosition( } /** - * Validate an object-shaped slot: container type, required keys + * 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, @@ -232,29 +244,33 @@ function validateObjectValue( const props = s["properties"] && typeof s["properties"] === "object" ? (s["properties"] as Record) - : {}; - const required = new Set(Array.isArray(s["required"]) ? (s["required"] as string[]) : []); + : 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) { - if (!(key in obj) || obj[key] == null) { + 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: key in obj ? "null-required" : "missing-required", + code: present ? "null-required" : "missing-required", }); invalid = true; } } } - for (const [key, sub] of Object.entries(props)) { - if (key in obj && validateSchemaValue(obj[key], sub, component, `${path}/${key}`, ctx)) { - if (resolveInvalidValue(obj, key, required.has(key), getSchemaDefaultValue(sub))) { - 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; + } } } } @@ -262,8 +278,8 @@ function validateObjectValue( } /** - * Validate an array slot: container type, then every element against `items` - * (single-schema form only — tuples are skipped). Invalid items are pruned. + * Validate an array slot: Invalid items are pruned in + * place: validated first so error paths keep original indices */ function validateArrayValue( value: unknown, @@ -281,18 +297,23 @@ function validateArrayValue( return true; } const items = s["items"]; - if (items && typeof items === "object" && !Array.isArray(items)) { - const bad: number[] = []; - value.forEach((el, i) => { - if (validateSchemaValue(el, items, component, `${path}/${i}`, ctx)) bad.push(i); - }); - for (let i = bad.length - 1; i >= 0; i--) value.splice(bad[i], 1); + 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. + * 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, @@ -304,7 +325,7 @@ function validateLeafValue( const leaf = getScalarTypeInfo(s); if (leaf.expectedType == null && leaf.enumValues == null) return false; if (leaf.enumValues && ctx.partial) return false; - const mismatch = describeTypeMismatch(value, leaf); + const mismatch = checkTypeMismatch(value, leaf); if (mismatch) { pushValidationIssue(ctx, component, path, { code: "type-mismatch", ...mismatch }); return true; @@ -340,8 +361,7 @@ export function validateSchemaValue( if (isASTNode(value)) return false; // Child components: only their position is checked here. if (isElementNode(value)) return validateElementPosition(value, s, component, path, ctx); - // Composite shapes can't be reliably matched to a single plain value — skip. - if ("$ref" in s || "anyOf" in s || "oneOf" in s || "allOf" in s) return false; + if (isCompositeSchema(s)) return false; const type = s["type"]; if (type === "object") return validateObjectValue(value, s, component, path, ctx); From 08c8c28cc0533556c5e6cafad037991a1b7d1442 Mon Sep 17 00:00:00 2001 From: Abhin Rustagi Date: Fri, 17 Jul 2026 14:37:41 +0530 Subject: [PATCH 30/31] fix: drop redundant tests --- .../__tests__/materialize.validation.test.ts | 253 ++++++------------ 1 file changed, 83 insertions(+), 170 deletions(-) diff --git a/packages/lang-core/src/parser/__tests__/materialize.validation.test.ts b/packages/lang-core/src/parser/__tests__/materialize.validation.test.ts index 2bcaf2f13..42a4bfe05 100644 --- a/packages/lang-core/src/parser/__tests__/materialize.validation.test.ts +++ b/packages/lang-core/src/parser/__tests__/materialize.validation.test.ts @@ -4,8 +4,8 @@ import type { LibraryJSONSchema, MaterializeCtx, ValidationError } from "../type import { validateSchemaValue } from "../validation"; /** - * Exercises the recursive nested validator (validateSchemaValue) and the - * resolveInvalidValue edge rule, one significant case per behavior: + * 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 → @@ -64,23 +64,6 @@ const schema: LibraryJSONSchema = { }, required: [], }, - // arg0 → `groups`: array → object → array → number (deep recursion) - DeepBox: { - properties: { - groups: { - type: "array", - items: { - type: "object", - properties: { - name: { type: "string" }, - points: { type: "array", items: { type: "number" } }, - }, - required: ["name"], - }, - }, - }, - required: [], - }, // arg0 → `combo`: a composite (anyOf) that must be left unchecked AnyBox: { properties: { @@ -97,16 +80,6 @@ const schema: LibraryJSONSchema = { }, required: [], }, - // arg0 → `theme`: required with a schema default — applied, not validated - DefBox: { - properties: { theme: { type: "string", default: "dark" } }, - required: ["theme"], - }, - // arg0 → `theme`: OPTIONAL param with a default — invalid values fall back to it - OptDefBox: { - properties: { theme: { type: "string", default: "dark" } }, - required: [], - }, // arg0 → `children`: array without `items` — elements are unchecked ListBox: { properties: { children: { type: "array" } }, @@ -117,22 +90,16 @@ const schema: LibraryJSONSchema = { properties: { text: { type: "string" } }, required: [], }, - // arg0 → `info`: REQUIRED object param — invalid nested data drops the component - ReqBox: { - properties: { - info: { - type: "object", - properties: { author: { type: "string" } }, - required: ["author"], - }, - }, - required: ["info"], - }, // 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: { @@ -186,14 +153,9 @@ function directErrors(value: unknown, s: unknown, partial = false): ValidationEr } describe("recursive descent & error reporting", () => { - it("accepts valid values at every depth with no errors", () => { + it("accepts valid values and reports violations with JSON-pointer paths + distinct codes", () => { expect(errorsFor('root = ObjBox({ author: "ann", views: 3 })')).toEqual([]); - expect(errorsFor('root = ArrBox([{ label: "a", value: 1 }, { label: "b" }])')).toEqual([]); - expect(errorsFor('root = EnumBox("active")')).toEqual([]); - }); - - it("reports leaf violations with JSON-pointer paths at any depth", () => { - // object leaf + // wrong leaf type inside an object expect(errorsFor('root = ObjBox({ author: "ann", views: "lots" })')[0]).toMatchObject({ code: "type-mismatch", component: "ObjBox", @@ -203,13 +165,7 @@ describe("recursive descent & error reporting", () => { expect(errorsFor('root = ArrBox([{ label: "a", value: "x" }])')[0]).toMatchObject({ path: "/rows/0/value", }); - // deep recursion: array → object → array → scalar - expect(errorsFor('root = DeepBox([{ name: "g1", points: [1, "two", 3] }])')[0]).toMatchObject({ - path: "/groups/0/points/1", - }); - }); - - it("reports missing vs null required keys inside objects with distinct codes", () => { + // missing vs null required keys carry distinct codes expect(errorsFor("root = ObjBox({ views: 3 })")[0]).toMatchObject({ code: "missing-required", path: "/info/author", @@ -220,23 +176,15 @@ describe("recursive descent & error reporting", () => { }); }); - it("reports container-shape mismatches (wrong value kind for object/array/scalar slots)", () => { + 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"); - expect(errorsFor("root = ScalarBox([1, 2])")[0].message).toContain( - "expects string but got array", - ); // 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", ); - }); - - it("accumulates independent errors across siblings, array elements, and excess args", () => { - // two sibling leaves of one object - expect(errorsFor('root = ObjBox({ author: 7, views: "x" })')).toHaveLength(2); // three violations spread over two array elements const byPath = errorsFor('root = ArrBox([{ label: 1 }, { value: "x" }])') .map((e) => `${e.code}:${e.path}`) @@ -247,13 +195,14 @@ describe("recursive descent & error reporting", () => { "type-mismatch:/rows/1/value", ]); // arg validation and excess-args reporting are independent - const codes = errorsFor('root = ScalarBox(1, 1, true, "extra")') - .map((e) => e.code) - .sort(); - expect(codes).toEqual(["excess-args", "type-mismatch"]); + expect( + errorsFor('root = ScalarBox(1, 1, true, "extra")') + .map((e) => e.code) + .sort(), + ).toEqual(["excess-args", "type-mismatch"]); }); - it("messages are self-sufficient: signatures and available components are inlined", () => { + 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)', @@ -262,77 +211,59 @@ describe("recursive descent & error reporting", () => { 'required field "/labels" cannot be null — signature: ChartBox(labels*: array, variant: "grouped"|"stacked")', ); // unknown components list the catalog - const unknown = errorsFor("root = Nope()")[0]; - expect(unknown.code).toBe("unknown-component"); - expect(unknown.message).toContain("Available components: ObjBox, ArrBox"); - }); - - it("attributes errors to the statement that defines the component", () => { - const errors = errorsFor( - 'root = ListBox([inner])\ninner = ObjBox({ author: "ann", views: "lots" })', + expect(errorsFor("root = Nope()")[0].message).toContain( + "Available components: ObjBox, ArrBox", ); - expect(errors[0]).toMatchObject({ component: "ObjBox", statementId: "inner" }); + // 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 — component survives", () => { + it("prunes invalid values on OPTIONAL edges and ARRAY ITEMS — component survives", () => { // nested optional key - const nested = parser.parse('root = ObjBox({ author: "ann", views: "lots" })'); - expect(nested.root?.props.info).toEqual({ author: "ann" }); + 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) - const obj = parser.parse("root = ObjBox({ views: 3 })"); - expect(obj.root).not.toBeNull(); - expect(obj.root?.props).not.toHaveProperty("info"); - }); - - it("always prunes invalid ARRAY ITEMS — siblings survive, arrays never invalidate", () => { - const scalars = parser.parse('root = TagBox(["a", 2, "c"])'); - expect(scalars.root?.props.tags).toEqual(["a", "c"]); - // a required-key violation inside an item is absorbed by the item edge - const rows = parser.parse('root = ArrBox([{ label: "a", value: 1 }, { value: 2 }])'); - expect(rows.root).not.toBeNull(); - expect(rows.root?.props.rows).toEqual([{ label: "a", value: 1 }]); + 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 nested keys up to the nearest optional edge", () => { - // info.author (required) is invalid → info invalid → info is optional → pruned - const result = parser.parse('root = ObjBox({ author: { first: "a" }, views: 3 })'); - expect(result.root).not.toBeNull(); - expect(result.root?.props).not.toHaveProperty("info"); - expect(result.meta.errors[0]).toMatchObject({ path: "/info/author" }); - }); - - it("drops the component when a REQUIRED param is invalid with no default", () => { - // required object param with a missing required key - expect(parser.parse("root = ReqBox({ other: 1 })").root).toBeNull(); - // required scalar param with a type mismatch + 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 for invalid values at every edge (error kept)", () => { - // top-level required param - const req = parser.parse("root = DefBox(5)"); - expect(req.root?.props.theme).toBe("dark"); - expect(req.meta.errors).toHaveLength(1); - // top-level optional param (default wins over pruning) - expect(parser.parse("root = OptDefBox(5)").root?.props.theme).toBe("dark"); - // nested required enum key — parent survives - const nestedReq = parser.parse('root = NestDefBox({ mode: "warp", retries: 1 })'); - expect(nestedReq.root?.props.cfg).toEqual({ mode: "fast", retries: 1 }); - expect(nestedReq.meta.errors[0]).toMatchObject({ path: "/cfg/mode" }); - // nested optional key - const nestedOpt = parser.parse('root = NestDefBox({ mode: "slow", retries: "many" })'); - expect(nestedOpt.root?.props.cfg).toEqual({ mode: "slow", retries: 3 }); - }); - - it("fills absent/null required keys from their defaults silently (no error)", () => { + 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([]); - expect(parser.parse("root = DefBox()").root?.props.theme).toBe("dark"); + // 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([]); @@ -343,29 +274,27 @@ describe("resolveInvalidValue — the edge rule (default → required → prune) }); describe("components in data slots", () => { - it("drops the component when a REQUIRED data param receives an element", () => { + it("elements in data slots follow the edge rule: drop on required, prune on optional/items", () => { // hbc = HorizontalBarChart(Card([]), [], "grouped") — the motivating case. - const result = parser.parse('root = ChartBox(CardBox("x"), "grouped")'); - expect(result.root).toBeNull(); - expect(result.meta.errors[0]).toMatchObject({ code: "type-mismatch", path: "/labels" }); - expect(result.meta.errors[0].message).toContain('expects array but got component "CardBox"'); - }); - - it("prunes an element on optional edges (enum leaf, data-array item)", () => { - const enumSlot = parser.parse('root = ChartBox(["a"], CardBox("x"))'); - expect(enumSlot.root?.props).not.toHaveProperty("variant"); - expect(enumSlot.meta.errors[0].message).toContain("expects a literal value but got component"); - const arrayItem = parser.parse('root = TagBox(["a", CardBox("x"), "b"])'); - expect(arrayItem.root?.props.tags).toEqual(["a", "b"]); - }); - - it("leaves elements in component slots ($ref/anyOf) unchecked — membership comes later", () => { - const result = parser.parse('root = SlotBox([CardBox("hi"), EnumBox("active")])'); - expect(result.meta.errors).toEqual([]); - expect((result.root?.props.children as unknown[]).length).toBe(2); + 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("still validates the misplaced component's own args (both errors surface)", () => { + 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" }); @@ -384,55 +313,39 @@ describe("conservative skips", () => { expect(errorsFor('root = ListBox([1, "a", true])')).toEqual([]); // array without `items` }); - it("skips malformed and uncheckable schema fragments (direct)", () => { + 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, { allOf: [{ 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 - expect(directErrors(undefined, { type: "string" })).toEqual([]); // ...but `required` works even without `properties` expect(directErrors({}, { type: "object", required: ["a"] })[0]).toMatchObject({ code: "missing-required", }); - }); - - it("applies documented leaf-check edges (direct): integer ≈ number, const ≈ enum, non-scalar skips", () => { - expect(directErrors(3, { type: "integer" })).toEqual([]); - expect(directErrors(1.5, { type: "integer" })).toEqual([]); // conservative + // 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(true, { enum: ["active", "inactive"] })).toHaveLength(1); - // objects never match enum members (reference equality) — skipped, not flagged expect(directErrors({ a: 1 }, { enum: ["active"] })).toEqual([]); }); }); describe("streaming gates", () => { - it("defers required-key checks while partial, then reports them on completion", () => { - const sp = createStreamingParser(schema); - const mid = sp.push("root = ObjBox({ views: 3 "); - expect(mid.meta.incomplete).toBe(true); - expect(mid.meta.errors.some((e) => e.code === "missing-required")).toBe(false); - const done = sp.push("})\n"); - expect(done.meta.incomplete).toBe(false); - expect(done.meta.errors.find((e) => e.code === "missing-required")).toMatchObject({ + 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", }); - }); - - it("defers enum membership while partial, then flags it on completion", () => { - const sp = createStreamingParser(schema); - const mid = sp.push('root = EnumBox("bogus" '); - expect(mid.meta.errors).toEqual([]); - const done = sp.push(")\n"); - expect(done.meta.errors.find((e) => e.code === "type-mismatch")).toMatchObject({ - path: "/status", - }); + 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)", () => { From 1133694bf7242028c16b8d5597fed9db71348a8d Mon Sep 17 00:00:00 2001 From: Abhin Rustagi Date: Fri, 17 Jul 2026 14:45:55 +0530 Subject: [PATCH 31/31] fix: format fix --- .../parser/__tests__/materialize.validation.test.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/lang-core/src/parser/__tests__/materialize.validation.test.ts b/packages/lang-core/src/parser/__tests__/materialize.validation.test.ts index 42a4bfe05..5309bb0dc 100644 --- a/packages/lang-core/src/parser/__tests__/materialize.validation.test.ts +++ b/packages/lang-core/src/parser/__tests__/materialize.validation.test.ts @@ -211,9 +211,7 @@ describe("recursive descent & error reporting", () => { '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", - ); + 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], @@ -224,9 +222,9 @@ describe("recursive descent & error reporting", () => { 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" }, - ); + 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();