| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401 |
- import type { Spec, UIElement } from "./types";
- import { getByPath } from "./types";
- import { VisibilityConditionStrictSchema } from "./visibility";
- // =============================================================================
- // Spec Structural Validation
- // =============================================================================
- /**
- * Severity level for validation issues.
- */
- export type SpecIssueSeverity = "error" | "warning";
- /**
- * A single validation issue found in a spec.
- */
- export interface SpecIssue {
- /** Severity: errors should be fixed, warnings are informational */
- severity: SpecIssueSeverity;
- /** Human-readable description of the issue */
- message: string;
- /** The element key where the issue was found (if applicable) */
- elementKey?: string;
- /** Machine-readable issue code for programmatic handling */
- code:
- | "missing_root"
- | "root_not_found"
- | "missing_child"
- | "invalid_visible"
- | "repeat_without_children"
- | "repeat_state_mismatch"
- | "visible_in_props"
- | "orphaned_element"
- | "empty_spec"
- | "on_in_props"
- | "repeat_in_props"
- | "watch_in_props";
- }
- /**
- * Result of spec structural validation.
- */
- export interface SpecValidationIssues {
- /** Whether the spec passed validation (no errors; warnings are OK) */
- valid: boolean;
- /** List of issues found */
- issues: SpecIssue[];
- }
- /**
- * Options for validateSpec.
- */
- export interface ValidateSpecOptions {
- /**
- * Whether to check for orphaned elements (elements not reachable from root).
- * Defaults to false since orphans are harmless (just unused).
- */
- checkOrphans?: boolean;
- }
- /**
- * Validate a spec for structural integrity.
- *
- * Checks for common AI-generation errors:
- * - Missing or empty root
- * - Root element not found in elements map
- * - Children referencing non-existent elements
- * - `visible` placed inside `props` instead of on the element
- * - Orphaned elements (optional)
- *
- * @example
- * ```ts
- * const result = validateSpec(spec);
- * if (!result.valid) {
- * console.log("Spec errors:", result.issues);
- * }
- * ```
- */
- export function validateSpec(
- spec: Spec,
- options: ValidateSpecOptions = {},
- ): SpecValidationIssues {
- const { checkOrphans = false } = options;
- const issues: SpecIssue[] = [];
- // 1. Check root
- if (!spec.root) {
- issues.push({
- severity: "error",
- message: "Spec has no root element defined.",
- code: "missing_root",
- });
- return { valid: false, issues };
- }
- if (!spec.elements[spec.root]) {
- issues.push({
- severity: "error",
- message: `Root element "${spec.root}" not found in elements map.`,
- code: "root_not_found",
- });
- }
- // 2. Check for empty spec
- if (Object.keys(spec.elements).length === 0) {
- issues.push({
- severity: "error",
- message: "Spec has no elements.",
- code: "empty_spec",
- });
- return { valid: false, issues };
- }
- // 3. Check each element
- for (const [key, element] of Object.entries(spec.elements)) {
- // 3a. Missing children
- if (element.children) {
- for (const childKey of element.children) {
- if (!spec.elements[childKey]) {
- issues.push({
- severity: "error",
- message: `Element "${key}" references child "${childKey}" which does not exist in the elements map.`,
- elementKey: key,
- code: "missing_child",
- });
- }
- }
- }
- // 3b. Repeat containers that can never render anything. Both shapes pass
- // schema validation but produce silently empty regions at runtime.
- if (element.repeat !== undefined) {
- if (!element.children || element.children.length === 0) {
- issues.push({
- severity: "error",
- message: `Element "${key}" has "repeat" but no children. The repeated template must be a child element: add a child that renders one item (it may read fields with {"$item": "field"}).`,
- elementKey: key,
- code: "repeat_without_children",
- });
- }
- if (spec.state !== undefined) {
- const value = getByPath(spec.state, element.repeat.statePath);
- if (!Array.isArray(value)) {
- issues.push({
- severity: "error",
- message: `Element "${key}" repeats over "${element.repeat.statePath}" but state${value === undefined ? " has no value there" : ` has a ${typeof value} there`}. Repeat statePath must reference an array in state; add sample items to state at that path.`,
- elementKey: key,
- code: "repeat_state_mismatch",
- });
- }
- }
- }
- // 3b. Malformed visible condition. Unrecognized shapes silently evaluate
- // to hidden at runtime, so catch them here with a repairable message.
- if (
- element.visible !== undefined &&
- !VisibilityConditionStrictSchema.safeParse(element.visible).success
- ) {
- issues.push({
- severity: "error",
- message: `Element "${key}" has an invalid "visible" condition: ${JSON.stringify(element.visible)}. Valid forms: true, false, {"$state":"/path","eq":value}, {"$item":"field","eq":value}, {"$index":true,"eq":n}, an array of those (AND), or {"$and":[...]} / {"$or":[...]}. Use exactly one of $state, $item, or $index per condition object.`,
- elementKey: key,
- code: "invalid_visible",
- });
- }
- // 3b. `visible` inside props
- const props = element.props as Record<string, unknown> | undefined;
- if (props && "visible" in props && props.visible !== undefined) {
- issues.push({
- severity: "error",
- message: `Element "${key}" has "visible" inside "props". It should be a top-level field on the element (sibling of type/props/children).`,
- elementKey: key,
- code: "visible_in_props",
- });
- }
- // 3c. `on` inside props (should be a top-level field)
- if (props && "on" in props && props.on !== undefined) {
- issues.push({
- severity: "error",
- message: `Element "${key}" has "on" inside "props". It should be a top-level field on the element (sibling of type/props/children).`,
- elementKey: key,
- code: "on_in_props",
- });
- }
- // 3d. `repeat` inside props (should be a top-level field)
- if (props && "repeat" in props && props.repeat !== undefined) {
- issues.push({
- severity: "error",
- message: `Element "${key}" has "repeat" inside "props". It should be a top-level field on the element (sibling of type/props/children).`,
- elementKey: key,
- code: "repeat_in_props",
- });
- }
- // 3e. `watch` inside props (should be a top-level field)
- if (props && "watch" in props && props.watch !== undefined) {
- issues.push({
- severity: "error",
- message: `Element "${key}" has "watch" inside "props". It should be a top-level field on the element (sibling of type/props/children).`,
- elementKey: key,
- code: "watch_in_props",
- });
- }
- }
- // 4. Orphaned elements (optional)
- if (checkOrphans) {
- const reachable = new Set<string>();
- const walk = (key: string) => {
- if (reachable.has(key)) return;
- reachable.add(key);
- const el = spec.elements[key];
- if (el?.children) {
- for (const childKey of el.children) {
- if (spec.elements[childKey]) {
- walk(childKey);
- }
- }
- }
- };
- if (spec.elements[spec.root]) {
- walk(spec.root);
- }
- for (const key of Object.keys(spec.elements)) {
- if (!reachable.has(key)) {
- issues.push({
- severity: "warning",
- message: `Element "${key}" is not reachable from root "${spec.root}".`,
- elementKey: key,
- code: "orphaned_element",
- });
- }
- }
- }
- const hasErrors = issues.some((i) => i.severity === "error");
- return { valid: !hasErrors, issues };
- }
- /**
- * Auto-fix common spec issues in-place and return a corrected copy.
- *
- * Currently fixes:
- * - `visible` inside `props` → moved to element level
- * - `on` inside `props` → moved to element level
- * - `repeat` inside `props` → moved to element level
- *
- * Returns the fixed spec and a list of fixes applied.
- */
- export interface SpecFix {
- message: string;
- /**
- * Lossy fixes change what renders (e.g. pruning a dangling child
- * reference); lossless fixes only relocate misplaced fields. Callers with a
- * repair loop should prefer re-prompting over accepting lossy fixes, and
- * use the lossy-fixed spec as a last resort.
- */
- lossy: boolean;
- }
- export interface AutoFixOptions {
- /**
- * Apply lossy fixes (content pruning). Default true. Callers with a repair
- * loop should pass false while retries remain so the model regenerates the
- * missing content, then true as a last resort.
- */
- lossy?: boolean;
- }
- export function autoFixSpec(
- spec: Spec,
- options: AutoFixOptions = {},
- ): {
- spec: Spec;
- fixes: string[];
- /** Structured fix records; fixes is the plain-message projection. */
- fixDetails: SpecFix[];
- } {
- const applyLossy = options.lossy !== false;
- const fixDetails: SpecFix[] = [];
- const fixes = {
- push(message: string, lossy = false) {
- fixDetails.push({ message, lossy });
- },
- };
- const fixedElements: Record<string, UIElement> = {};
- for (const [key, element] of Object.entries(spec.elements)) {
- const props = element.props as Record<string, unknown> | undefined;
- let fixed = element;
- if (props && "visible" in props && props.visible !== undefined) {
- // Move visible from props to element level
- const { visible, ...restProps } = fixed.props as Record<string, unknown>;
- fixed = {
- ...fixed,
- props: restProps,
- visible: visible as UIElement["visible"],
- };
- fixes.push(`Moved "visible" from props to element level on "${key}".`);
- }
- let currentProps = fixed.props as Record<string, unknown> | undefined;
- if (currentProps && "on" in currentProps && currentProps.on !== undefined) {
- // Move on from props to element level
- const { on, ...restProps } = currentProps;
- fixed = {
- ...fixed,
- props: restProps,
- on: on as UIElement["on"],
- };
- fixes.push(`Moved "on" from props to element level on "${key}".`);
- }
- currentProps = fixed.props as Record<string, unknown> | undefined;
- if (
- currentProps &&
- "repeat" in currentProps &&
- currentProps.repeat !== undefined
- ) {
- // Move repeat from props to element level
- const { repeat, ...restProps } = currentProps;
- fixed = {
- ...fixed,
- props: restProps,
- repeat: repeat as UIElement["repeat"],
- };
- fixes.push(`Moved "repeat" from props to element level on "${key}".`);
- }
- currentProps = fixed.props as Record<string, unknown> | undefined;
- if (
- currentProps &&
- "watch" in currentProps &&
- currentProps.watch !== undefined
- ) {
- const { watch, ...restProps } = currentProps;
- fixed = {
- ...fixed,
- props: restProps,
- watch: watch as UIElement["watch"],
- };
- fixes.push(`Moved "watch" from props to element level on "${key}".`);
- }
- fixedElements[key] = fixed;
- }
- // Drop references to elements that were never defined. The renderer skips
- // missing children at runtime, so pruning produces the same rendered output
- // while letting the spec pass validation instead of hard-failing.
- if (applyLossy)
- for (const [key, element] of Object.entries(fixedElements)) {
- if (!element.children || element.children.length === 0) continue;
- const present = element.children.filter(
- (child) => child in fixedElements,
- );
- if (present.length === element.children.length) continue;
- if (element.repeat !== undefined && present.length === 0) {
- // Pruning every child of a repeat container would only trade the
- // missing_child error for repeat_without_children; keep the dangling
- // reference so repair targets the real problem (the missing template).
- continue;
- }
- for (const child of element.children) {
- if (!(child in fixedElements)) {
- fixes.push(
- `Removed reference to undefined element "${child}" from children of "${key}".`,
- true,
- );
- }
- }
- fixedElements[key] = { ...element, children: present };
- }
- return {
- spec: { root: spec.root, elements: fixedElements, state: spec.state },
- fixes: fixDetails.map((fix) => fix.message),
- fixDetails,
- };
- }
- /**
- * Format validation issues into a human-readable string suitable for
- * inclusion in a repair prompt sent back to the AI.
- */
- export function formatSpecIssues(issues: SpecIssue[]): string {
- const errors = issues.filter((i) => i.severity === "error");
- if (errors.length === 0) return "";
- const lines = ["The generated UI spec has the following errors:"];
- for (const issue of errors) {
- lines.push(`- ${issue.message}`);
- }
- return lines.join("\n");
- }
|