spec-validator.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401
  1. import type { Spec, UIElement } from "./types";
  2. import { getByPath } from "./types";
  3. import { VisibilityConditionStrictSchema } from "./visibility";
  4. // =============================================================================
  5. // Spec Structural Validation
  6. // =============================================================================
  7. /**
  8. * Severity level for validation issues.
  9. */
  10. export type SpecIssueSeverity = "error" | "warning";
  11. /**
  12. * A single validation issue found in a spec.
  13. */
  14. export interface SpecIssue {
  15. /** Severity: errors should be fixed, warnings are informational */
  16. severity: SpecIssueSeverity;
  17. /** Human-readable description of the issue */
  18. message: string;
  19. /** The element key where the issue was found (if applicable) */
  20. elementKey?: string;
  21. /** Machine-readable issue code for programmatic handling */
  22. code:
  23. | "missing_root"
  24. | "root_not_found"
  25. | "missing_child"
  26. | "invalid_visible"
  27. | "repeat_without_children"
  28. | "repeat_state_mismatch"
  29. | "visible_in_props"
  30. | "orphaned_element"
  31. | "empty_spec"
  32. | "on_in_props"
  33. | "repeat_in_props"
  34. | "watch_in_props";
  35. }
  36. /**
  37. * Result of spec structural validation.
  38. */
  39. export interface SpecValidationIssues {
  40. /** Whether the spec passed validation (no errors; warnings are OK) */
  41. valid: boolean;
  42. /** List of issues found */
  43. issues: SpecIssue[];
  44. }
  45. /**
  46. * Options for validateSpec.
  47. */
  48. export interface ValidateSpecOptions {
  49. /**
  50. * Whether to check for orphaned elements (elements not reachable from root).
  51. * Defaults to false since orphans are harmless (just unused).
  52. */
  53. checkOrphans?: boolean;
  54. }
  55. /**
  56. * Validate a spec for structural integrity.
  57. *
  58. * Checks for common AI-generation errors:
  59. * - Missing or empty root
  60. * - Root element not found in elements map
  61. * - Children referencing non-existent elements
  62. * - `visible` placed inside `props` instead of on the element
  63. * - Orphaned elements (optional)
  64. *
  65. * @example
  66. * ```ts
  67. * const result = validateSpec(spec);
  68. * if (!result.valid) {
  69. * console.log("Spec errors:", result.issues);
  70. * }
  71. * ```
  72. */
  73. export function validateSpec(
  74. spec: Spec,
  75. options: ValidateSpecOptions = {},
  76. ): SpecValidationIssues {
  77. const { checkOrphans = false } = options;
  78. const issues: SpecIssue[] = [];
  79. // 1. Check root
  80. if (!spec.root) {
  81. issues.push({
  82. severity: "error",
  83. message: "Spec has no root element defined.",
  84. code: "missing_root",
  85. });
  86. return { valid: false, issues };
  87. }
  88. if (!spec.elements[spec.root]) {
  89. issues.push({
  90. severity: "error",
  91. message: `Root element "${spec.root}" not found in elements map.`,
  92. code: "root_not_found",
  93. });
  94. }
  95. // 2. Check for empty spec
  96. if (Object.keys(spec.elements).length === 0) {
  97. issues.push({
  98. severity: "error",
  99. message: "Spec has no elements.",
  100. code: "empty_spec",
  101. });
  102. return { valid: false, issues };
  103. }
  104. // 3. Check each element
  105. for (const [key, element] of Object.entries(spec.elements)) {
  106. // 3a. Missing children
  107. if (element.children) {
  108. for (const childKey of element.children) {
  109. if (!spec.elements[childKey]) {
  110. issues.push({
  111. severity: "error",
  112. message: `Element "${key}" references child "${childKey}" which does not exist in the elements map.`,
  113. elementKey: key,
  114. code: "missing_child",
  115. });
  116. }
  117. }
  118. }
  119. // 3b. Repeat containers that can never render anything. Both shapes pass
  120. // schema validation but produce silently empty regions at runtime.
  121. if (element.repeat !== undefined) {
  122. if (!element.children || element.children.length === 0) {
  123. issues.push({
  124. severity: "error",
  125. 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"}).`,
  126. elementKey: key,
  127. code: "repeat_without_children",
  128. });
  129. }
  130. if (spec.state !== undefined) {
  131. const value = getByPath(spec.state, element.repeat.statePath);
  132. if (!Array.isArray(value)) {
  133. issues.push({
  134. severity: "error",
  135. 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.`,
  136. elementKey: key,
  137. code: "repeat_state_mismatch",
  138. });
  139. }
  140. }
  141. }
  142. // 3b. Malformed visible condition. Unrecognized shapes silently evaluate
  143. // to hidden at runtime, so catch them here with a repairable message.
  144. if (
  145. element.visible !== undefined &&
  146. !VisibilityConditionStrictSchema.safeParse(element.visible).success
  147. ) {
  148. issues.push({
  149. severity: "error",
  150. 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.`,
  151. elementKey: key,
  152. code: "invalid_visible",
  153. });
  154. }
  155. // 3b. `visible` inside props
  156. const props = element.props as Record<string, unknown> | undefined;
  157. if (props && "visible" in props && props.visible !== undefined) {
  158. issues.push({
  159. severity: "error",
  160. message: `Element "${key}" has "visible" inside "props". It should be a top-level field on the element (sibling of type/props/children).`,
  161. elementKey: key,
  162. code: "visible_in_props",
  163. });
  164. }
  165. // 3c. `on` inside props (should be a top-level field)
  166. if (props && "on" in props && props.on !== undefined) {
  167. issues.push({
  168. severity: "error",
  169. message: `Element "${key}" has "on" inside "props". It should be a top-level field on the element (sibling of type/props/children).`,
  170. elementKey: key,
  171. code: "on_in_props",
  172. });
  173. }
  174. // 3d. `repeat` inside props (should be a top-level field)
  175. if (props && "repeat" in props && props.repeat !== undefined) {
  176. issues.push({
  177. severity: "error",
  178. message: `Element "${key}" has "repeat" inside "props". It should be a top-level field on the element (sibling of type/props/children).`,
  179. elementKey: key,
  180. code: "repeat_in_props",
  181. });
  182. }
  183. // 3e. `watch` inside props (should be a top-level field)
  184. if (props && "watch" in props && props.watch !== undefined) {
  185. issues.push({
  186. severity: "error",
  187. message: `Element "${key}" has "watch" inside "props". It should be a top-level field on the element (sibling of type/props/children).`,
  188. elementKey: key,
  189. code: "watch_in_props",
  190. });
  191. }
  192. }
  193. // 4. Orphaned elements (optional)
  194. if (checkOrphans) {
  195. const reachable = new Set<string>();
  196. const walk = (key: string) => {
  197. if (reachable.has(key)) return;
  198. reachable.add(key);
  199. const el = spec.elements[key];
  200. if (el?.children) {
  201. for (const childKey of el.children) {
  202. if (spec.elements[childKey]) {
  203. walk(childKey);
  204. }
  205. }
  206. }
  207. };
  208. if (spec.elements[spec.root]) {
  209. walk(spec.root);
  210. }
  211. for (const key of Object.keys(spec.elements)) {
  212. if (!reachable.has(key)) {
  213. issues.push({
  214. severity: "warning",
  215. message: `Element "${key}" is not reachable from root "${spec.root}".`,
  216. elementKey: key,
  217. code: "orphaned_element",
  218. });
  219. }
  220. }
  221. }
  222. const hasErrors = issues.some((i) => i.severity === "error");
  223. return { valid: !hasErrors, issues };
  224. }
  225. /**
  226. * Auto-fix common spec issues in-place and return a corrected copy.
  227. *
  228. * Currently fixes:
  229. * - `visible` inside `props` → moved to element level
  230. * - `on` inside `props` → moved to element level
  231. * - `repeat` inside `props` → moved to element level
  232. *
  233. * Returns the fixed spec and a list of fixes applied.
  234. */
  235. export interface SpecFix {
  236. message: string;
  237. /**
  238. * Lossy fixes change what renders (e.g. pruning a dangling child
  239. * reference); lossless fixes only relocate misplaced fields. Callers with a
  240. * repair loop should prefer re-prompting over accepting lossy fixes, and
  241. * use the lossy-fixed spec as a last resort.
  242. */
  243. lossy: boolean;
  244. }
  245. export interface AutoFixOptions {
  246. /**
  247. * Apply lossy fixes (content pruning). Default true. Callers with a repair
  248. * loop should pass false while retries remain so the model regenerates the
  249. * missing content, then true as a last resort.
  250. */
  251. lossy?: boolean;
  252. }
  253. export function autoFixSpec(
  254. spec: Spec,
  255. options: AutoFixOptions = {},
  256. ): {
  257. spec: Spec;
  258. fixes: string[];
  259. /** Structured fix records; fixes is the plain-message projection. */
  260. fixDetails: SpecFix[];
  261. } {
  262. const applyLossy = options.lossy !== false;
  263. const fixDetails: SpecFix[] = [];
  264. const fixes = {
  265. push(message: string, lossy = false) {
  266. fixDetails.push({ message, lossy });
  267. },
  268. };
  269. const fixedElements: Record<string, UIElement> = {};
  270. for (const [key, element] of Object.entries(spec.elements)) {
  271. const props = element.props as Record<string, unknown> | undefined;
  272. let fixed = element;
  273. if (props && "visible" in props && props.visible !== undefined) {
  274. // Move visible from props to element level
  275. const { visible, ...restProps } = fixed.props as Record<string, unknown>;
  276. fixed = {
  277. ...fixed,
  278. props: restProps,
  279. visible: visible as UIElement["visible"],
  280. };
  281. fixes.push(`Moved "visible" from props to element level on "${key}".`);
  282. }
  283. let currentProps = fixed.props as Record<string, unknown> | undefined;
  284. if (currentProps && "on" in currentProps && currentProps.on !== undefined) {
  285. // Move on from props to element level
  286. const { on, ...restProps } = currentProps;
  287. fixed = {
  288. ...fixed,
  289. props: restProps,
  290. on: on as UIElement["on"],
  291. };
  292. fixes.push(`Moved "on" from props to element level on "${key}".`);
  293. }
  294. currentProps = fixed.props as Record<string, unknown> | undefined;
  295. if (
  296. currentProps &&
  297. "repeat" in currentProps &&
  298. currentProps.repeat !== undefined
  299. ) {
  300. // Move repeat from props to element level
  301. const { repeat, ...restProps } = currentProps;
  302. fixed = {
  303. ...fixed,
  304. props: restProps,
  305. repeat: repeat as UIElement["repeat"],
  306. };
  307. fixes.push(`Moved "repeat" from props to element level on "${key}".`);
  308. }
  309. currentProps = fixed.props as Record<string, unknown> | undefined;
  310. if (
  311. currentProps &&
  312. "watch" in currentProps &&
  313. currentProps.watch !== undefined
  314. ) {
  315. const { watch, ...restProps } = currentProps;
  316. fixed = {
  317. ...fixed,
  318. props: restProps,
  319. watch: watch as UIElement["watch"],
  320. };
  321. fixes.push(`Moved "watch" from props to element level on "${key}".`);
  322. }
  323. fixedElements[key] = fixed;
  324. }
  325. // Drop references to elements that were never defined. The renderer skips
  326. // missing children at runtime, so pruning produces the same rendered output
  327. // while letting the spec pass validation instead of hard-failing.
  328. if (applyLossy)
  329. for (const [key, element] of Object.entries(fixedElements)) {
  330. if (!element.children || element.children.length === 0) continue;
  331. const present = element.children.filter(
  332. (child) => child in fixedElements,
  333. );
  334. if (present.length === element.children.length) continue;
  335. if (element.repeat !== undefined && present.length === 0) {
  336. // Pruning every child of a repeat container would only trade the
  337. // missing_child error for repeat_without_children; keep the dangling
  338. // reference so repair targets the real problem (the missing template).
  339. continue;
  340. }
  341. for (const child of element.children) {
  342. if (!(child in fixedElements)) {
  343. fixes.push(
  344. `Removed reference to undefined element "${child}" from children of "${key}".`,
  345. true,
  346. );
  347. }
  348. }
  349. fixedElements[key] = { ...element, children: present };
  350. }
  351. return {
  352. spec: { root: spec.root, elements: fixedElements, state: spec.state },
  353. fixes: fixDetails.map((fix) => fix.message),
  354. fixDetails,
  355. };
  356. }
  357. /**
  358. * Format validation issues into a human-readable string suitable for
  359. * inclusion in a repair prompt sent back to the AI.
  360. */
  361. export function formatSpecIssues(issues: SpecIssue[]): string {
  362. const errors = issues.filter((i) => i.severity === "error");
  363. if (errors.length === 0) return "";
  364. const lines = ["The generated UI spec has the following errors:"];
  365. for (const issue of errors) {
  366. lines.push(`- ${issue.message}`);
  367. }
  368. return lines.join("\n");
  369. }