spec-validator.ts 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273
  1. import type { Spec, UIElement } from "./types";
  2. // =============================================================================
  3. // Spec Structural Validation
  4. // =============================================================================
  5. /**
  6. * Severity level for validation issues.
  7. */
  8. export type SpecIssueSeverity = "error" | "warning";
  9. /**
  10. * A single validation issue found in a spec.
  11. */
  12. export interface SpecIssue {
  13. /** Severity: errors should be fixed, warnings are informational */
  14. severity: SpecIssueSeverity;
  15. /** Human-readable description of the issue */
  16. message: string;
  17. /** The element key where the issue was found (if applicable) */
  18. elementKey?: string;
  19. /** Machine-readable issue code for programmatic handling */
  20. code:
  21. | "missing_root"
  22. | "root_not_found"
  23. | "missing_child"
  24. | "visible_in_props"
  25. | "orphaned_element"
  26. | "empty_spec"
  27. | "on_in_props"
  28. | "repeat_in_props";
  29. }
  30. /**
  31. * Result of spec structural validation.
  32. */
  33. export interface SpecValidationIssues {
  34. /** Whether the spec passed validation (no errors; warnings are OK) */
  35. valid: boolean;
  36. /** List of issues found */
  37. issues: SpecIssue[];
  38. }
  39. /**
  40. * Options for validateSpec.
  41. */
  42. export interface ValidateSpecOptions {
  43. /**
  44. * Whether to check for orphaned elements (elements not reachable from root).
  45. * Defaults to false since orphans are harmless (just unused).
  46. */
  47. checkOrphans?: boolean;
  48. }
  49. /**
  50. * Validate a spec for structural integrity.
  51. *
  52. * Checks for common AI-generation errors:
  53. * - Missing or empty root
  54. * - Root element not found in elements map
  55. * - Children referencing non-existent elements
  56. * - `visible` placed inside `props` instead of on the element
  57. * - Orphaned elements (optional)
  58. *
  59. * @example
  60. * ```ts
  61. * const result = validateSpec(spec);
  62. * if (!result.valid) {
  63. * console.log("Spec errors:", result.issues);
  64. * }
  65. * ```
  66. */
  67. export function validateSpec(
  68. spec: Spec,
  69. options: ValidateSpecOptions = {},
  70. ): SpecValidationIssues {
  71. const { checkOrphans = false } = options;
  72. const issues: SpecIssue[] = [];
  73. // 1. Check root
  74. if (!spec.root) {
  75. issues.push({
  76. severity: "error",
  77. message: "Spec has no root element defined.",
  78. code: "missing_root",
  79. });
  80. return { valid: false, issues };
  81. }
  82. if (!spec.elements[spec.root]) {
  83. issues.push({
  84. severity: "error",
  85. message: `Root element "${spec.root}" not found in elements map.`,
  86. code: "root_not_found",
  87. });
  88. }
  89. // 2. Check for empty spec
  90. if (Object.keys(spec.elements).length === 0) {
  91. issues.push({
  92. severity: "error",
  93. message: "Spec has no elements.",
  94. code: "empty_spec",
  95. });
  96. return { valid: false, issues };
  97. }
  98. // 3. Check each element
  99. for (const [key, element] of Object.entries(spec.elements)) {
  100. // 3a. Missing children
  101. if (element.children) {
  102. for (const childKey of element.children) {
  103. if (!spec.elements[childKey]) {
  104. issues.push({
  105. severity: "error",
  106. message: `Element "${key}" references child "${childKey}" which does not exist in the elements map.`,
  107. elementKey: key,
  108. code: "missing_child",
  109. });
  110. }
  111. }
  112. }
  113. // 3b. `visible` inside props
  114. const props = element.props as Record<string, unknown> | undefined;
  115. if (props && "visible" in props && props.visible !== undefined) {
  116. issues.push({
  117. severity: "error",
  118. message: `Element "${key}" has "visible" inside "props". It should be a top-level field on the element (sibling of type/props/children).`,
  119. elementKey: key,
  120. code: "visible_in_props",
  121. });
  122. }
  123. // 3c. `on` inside props (should be a top-level field)
  124. if (props && "on" in props && props.on !== undefined) {
  125. issues.push({
  126. severity: "error",
  127. message: `Element "${key}" has "on" inside "props". It should be a top-level field on the element (sibling of type/props/children).`,
  128. elementKey: key,
  129. code: "on_in_props",
  130. });
  131. }
  132. // 3d. `repeat` inside props (should be a top-level field)
  133. if (props && "repeat" in props && props.repeat !== undefined) {
  134. issues.push({
  135. severity: "error",
  136. message: `Element "${key}" has "repeat" inside "props". It should be a top-level field on the element (sibling of type/props/children).`,
  137. elementKey: key,
  138. code: "repeat_in_props",
  139. });
  140. }
  141. }
  142. // 4. Orphaned elements (optional)
  143. if (checkOrphans) {
  144. const reachable = new Set<string>();
  145. const walk = (key: string) => {
  146. if (reachable.has(key)) return;
  147. reachable.add(key);
  148. const el = spec.elements[key];
  149. if (el?.children) {
  150. for (const childKey of el.children) {
  151. if (spec.elements[childKey]) {
  152. walk(childKey);
  153. }
  154. }
  155. }
  156. };
  157. if (spec.elements[spec.root]) {
  158. walk(spec.root);
  159. }
  160. for (const key of Object.keys(spec.elements)) {
  161. if (!reachable.has(key)) {
  162. issues.push({
  163. severity: "warning",
  164. message: `Element "${key}" is not reachable from root "${spec.root}".`,
  165. elementKey: key,
  166. code: "orphaned_element",
  167. });
  168. }
  169. }
  170. }
  171. const hasErrors = issues.some((i) => i.severity === "error");
  172. return { valid: !hasErrors, issues };
  173. }
  174. /**
  175. * Auto-fix common spec issues in-place and return a corrected copy.
  176. *
  177. * Currently fixes:
  178. * - `visible` inside `props` → moved to element level
  179. * - `on` inside `props` → moved to element level
  180. * - `repeat` inside `props` → moved to element level
  181. *
  182. * Returns the fixed spec and a list of fixes applied.
  183. */
  184. export function autoFixSpec(spec: Spec): {
  185. spec: Spec;
  186. fixes: string[];
  187. } {
  188. const fixes: string[] = [];
  189. const fixedElements: Record<string, UIElement> = {};
  190. for (const [key, element] of Object.entries(spec.elements)) {
  191. const props = element.props as Record<string, unknown> | undefined;
  192. let fixed = element;
  193. if (props && "visible" in props && props.visible !== undefined) {
  194. // Move visible from props to element level
  195. const { visible, ...restProps } = fixed.props as Record<string, unknown>;
  196. fixed = {
  197. ...fixed,
  198. props: restProps,
  199. visible: visible as UIElement["visible"],
  200. };
  201. fixes.push(`Moved "visible" from props to element level on "${key}".`);
  202. }
  203. let currentProps = fixed.props as Record<string, unknown> | undefined;
  204. if (currentProps && "on" in currentProps && currentProps.on !== undefined) {
  205. // Move on from props to element level
  206. const { on, ...restProps } = currentProps;
  207. fixed = {
  208. ...fixed,
  209. props: restProps,
  210. on: on as UIElement["on"],
  211. };
  212. fixes.push(`Moved "on" from props to element level on "${key}".`);
  213. }
  214. currentProps = fixed.props as Record<string, unknown> | undefined;
  215. if (
  216. currentProps &&
  217. "repeat" in currentProps &&
  218. currentProps.repeat !== undefined
  219. ) {
  220. // Move repeat from props to element level
  221. const { repeat, ...restProps } = currentProps;
  222. fixed = {
  223. ...fixed,
  224. props: restProps,
  225. repeat: repeat as UIElement["repeat"],
  226. };
  227. fixes.push(`Moved "repeat" from props to element level on "${key}".`);
  228. }
  229. fixedElements[key] = fixed;
  230. }
  231. return {
  232. spec: { root: spec.root, elements: fixedElements, state: spec.state },
  233. fixes,
  234. };
  235. }
  236. /**
  237. * Format validation issues into a human-readable string suitable for
  238. * inclusion in a repair prompt sent back to the AI.
  239. */
  240. export function formatSpecIssues(issues: SpecIssue[]): string {
  241. const errors = issues.filter((i) => i.severity === "error");
  242. if (errors.length === 0) return "";
  243. const lines = ["The generated UI spec has the following errors:"];
  244. for (const issue of errors) {
  245. lines.push(`- ${issue.message}`);
  246. }
  247. return lines.join("\n");
  248. }