#!/usr/bin/env python3 """ Batch component migration orchestrator using openai-agents. Usage: python scripts/component_migration_orchestrator.py """ from __future__ import annotations import json import os import sys import textwrap import difflib import time import random import re from concurrent.futures import ThreadPoolExecutor, as_completed, TimeoutError as FuturesTimeoutError from dataclasses import dataclass from pathlib import Path from typing import Any, Dict, Iterable, List, Optional, Tuple try: from agents import Agent, Runner # type: ignore except Exception as exc: # pragma: no cover - runtime dependency check raise SystemExit( "openai-agents is required. Install with: pip install openai-agents" ) from exc ROOT = Path(__file__).resolve().parents[1] DEFAULT_MODEL = os.getenv("CODEX_MODEL") or os.getenv("OPENAI_MODEL") or "gpt-4o-mini" API_CALL_DELAY_SECONDS = 2.0 API_CALL_TIMEOUT_SECONDS = 120.0 # Process all TSX components under src/components, excluding App, index, and test/story files. COMPONENT_DIRS = [ROOT / "src" / "components"] EXCLUDED_FILENAMES = {"app.tsx", "app.jsx", "index.tsx", "index.ts"} @dataclass(frozen=True) class ComponentTarget: name: str path: Path category: str PROMPT_TEMPLATE = """\ You are converting a React TSX component to the JSON-driven system. Follow this workflow strictly: 1) Identify if stateful (uses useState/useEffect/other hooks). 2) If stateful, extract logic into a custom hook file in src/hooks/use-.ts. 3) Create JSON definition under src/components/json-definitions/.json. 4) Create interface in src/lib/json-ui/interfaces/.ts. 5) Create config page schema under src/config/pages//.json. 6) Update json-components-registry.json with a new entry for the component if missing. 7) Provide export updates for: - src/hooks/index.ts (if hook exists) - src/lib/json-ui/hooks-registry.ts (if hook exists) - src/lib/json-ui/interfaces/index.ts - src/lib/json-ui/json-components.ts - src/components//index.ts 8) Indicate that TSX file should be deleted. IMPORTANT: - Return ONLY valid JSON. No Markdown or code fences. - For json-components.ts, output ONLY the snippet for the component export, matching this style: export const ComponentName = createJsonComponent(componentNameDef) OR export const ComponentName = createJsonComponentWithHooks(componentNameDef, {{ ... }}) - For createJsonComponentWithHooks, ALWAYS include a "hooks" object with hookName + args, e.g.: export const ComponentName = createJsonComponentWithHooks(componentNameDef, {{ hooks: {{ hookData: {{ hookName: "useComponentName", args: (props) => [props.example] }} }} }}) - Do NOT output any `jsonComponents` object or registry literal. - Provide `diffs` with unified diff lines (one string per line) for each target file, using the provided file contents as the "before" version. - Include diffs for the config page schema file and json-components-registry.json. Config page schema format (src/config/pages//.json): - Simple JSON-compatible component: {{ "type": "ComponentName", "props": {{ ... }} }} - Wrapper-required component (use when hooks/complex logic or not JSON-compatible): {{ "type": "ComponentName", "jsonCompatible": false, "wrapperRequired": true, "load": {{ "path": "@/components//ComponentName", "export": "ComponentName" }}, "props": {{ ... }}, "metadata": {{ "notes": "Contains hooks - needs wrapper" }} }} JSON definition schema (src/components/json-definitions/.json): - Single UIComponent tree using this shape: {{ "id": "component-id", "type": "ComponentType", "props": {{ ... }}, "className": "optional classes", "style": {{ ... }}, "bindings": {{ "propName": {{ "source": "data.path", "path": "optional.path", "transform": "optional js expression" }} }}, "dataBinding": "data.path" | {{ "source": "data.path", "path": "optional.path", "transform": "optional js expression" }}, "events": [{{ "event": "click", "actions": [{{ "id": "...", "type": "custom" }}] }}] | {{ "click": {{ "action": "customActionId" }} }}, "conditional": {{ "if": "expression", "then": {{ ... }} | [{{ ... }}, "..."] | "...", "else": {{ ... }} | [{{ ... }}, "..."] | "..." }}, "loop": {{ "source": "data.path", "itemVar": "item", "indexVar": "index" }}, "children": "text" | [{{ ... }}, "..."] }} - Use nested UIComponents in children. - For load.path use the component's folder under src/components: "{component_rel_dir}" (omit the subpath if empty). Registry entry format (json-components-registry.json) for the component if missing: {{ "type": "ComponentName", "name": "ComponentName", "category": "layout|input|display|navigation|feedback|data|custom", "canHaveChildren": true|false, "description": "Short description", "status": "supported", "source": "atoms|molecules|organisms|ui|custom|misc", "jsonCompatible": true|false, "wrapperRequired": true|false, "load": {{ "path": "@/components//ComponentName", "export": "ComponentName" }}, "metadata": {{ "conversionDate": "YYYY-MM-DD", "autoGenerated": true, "notes": "Optional notes" }} }} Omit optional fields when not applicable. Use the provided category "{category}" for both category and source when in doubt. Return ONLY valid JSON with this shape: {{ "componentName": "...", "category": "{category}", "isStateful": true|false, "hook": {{ "name": "useComponentName", "filePath": "src/hooks/use-component-name.ts", "source": "...typescript..." }} | null, "jsonDefinition": {{ "filePath": "src/components/json-definitions/component-name.json", "source": {{ ...json... }} }}, "interface": {{ "filePath": "src/lib/json-ui/interfaces/component-name.ts", "source": "...typescript..." }}, "configPageSchema": {{ "filePath": "src/config/pages//component-name.json", "source": {{ ...json... }} }}, "jsonComponentExport": {{ "filePath": "src/lib/json-ui/json-components.ts", "source": "...typescript snippet..." }}, "exports": {{ "hooksIndex": "...typescript snippet or null...", "hooksRegistry": "...typescript snippet or null...", "interfacesIndex": "...typescript snippet...", "componentsIndex": "...typescript snippet..." }}, "diffs": [ {{ "path": "src/lib/json-ui/json-components.ts", "diffLines": ["@@ ...", "+...", "-..."] }} ], "deleteTsx": true }} Component category: {category} Component path: {path} Component subpath under src/components: {component_rel_dir} Existing file contents for diffing: {existing_files} TSX source: {tsx} """ CONFLICT_PROMPT_TEMPLATE = """\ You are resolving a unified diff that failed to apply cleanly. Return ONLY valid JSON with this shape: {{ "path": "{path}", "resolvedContent": "...full file content..." }} Rules: - Use the diff to update the original content. - Preserve unrelated content. - Do NOT return Markdown or code fences. Original content: {original} Diff lines: {diff_lines} """ CONFLICT_PROMPT_TEMPLATE_STRICT = """\ You must return ONLY valid JSON with BOTH keys: "path" and "resolvedContent". "resolvedContent" must be the full file content string (no Markdown, no code fences). Do not omit it. Do not return arrays. JSON shape: {{ "path": "{path}", "resolvedContent": "...full file content..." }} Original content: {original} Diff lines: {diff_lines} """ CONFLICT_PROMPT_TEMPLATE_CONTENT_ONLY = """\ Return ONLY the full merged file content. No JSON, no code fences, no commentary. Use the diff to update the original content and preserve unrelated content. If the diff cannot be applied, return the original content unchanged. Original content: {original} Diff lines: {diff_lines} """ JSON_REPAIR_TEMPLATE = """\ Fix the following output so it is valid JSON only. Return ONLY the corrected JSON. Do not add commentary or code fences. Preserve all keys and values. Error: {error} Output to repair: {output} """ CONFIG_PAGE_SCHEMA_REPAIR_TEMPLATE = """\ Generate a config page schema JSON for this component. Return ONLY valid JSON. No Markdown or code fences. Component name: {component_name} Category: {category} Is stateful: {is_stateful} JSON definition source: {json_definition} Existing config content (may be empty): {existing_content} Rules: - Simple JSON-compatible component: {{ "type": "ComponentName", "props": {{ ... }} }} - If stateful or not JSON-compatible, use wrapper-required format: {{ "type": "ComponentName", "jsonCompatible": false, "wrapperRequired": true, "load": {{ "path": "@/components/{category}/ComponentName", "export": "ComponentName" }}, "props": {{ ... }}, "metadata": {{ "notes": "Contains hooks - needs wrapper" }} }} - Prefer props from the JSON definition "props" field if present, else use {{}}. """ REGISTRY_REPAIR_TEMPLATE = """\ Fix the registry JSON to match the required schema. Return ONLY valid JSON. No Markdown or code fences. Registry schema: {schema} Reference header (use for top-level fields and shape): {reference_header} Current registry content: {current} Rules: - Output must be an object with "version", "description", and "components" (array). - Preserve existing component entries; if entries are under "elements", "component", or "data", move them into the "components" array. - Keep any existing metadata or load info on entries. - Update or keep "lastUpdated" as a string if present in the reference header. """ HOOK_REPAIR_TEMPLATE = """\ Generate the custom hook file needed for this JSON component conversion. Return ONLY valid JSON with this shape: {{ "hook": {{ "name": "{hook_name}", "filePath": "{hook_file_path}", "source": "...typescript..." }} | null }} Rules: - Use the provided hook name and file path if a hook is needed. - Extract stateful/side-effect logic from the TSX into the hook. - If the component is stateless and doesn't need a hook, return {{"hook": null}}. - Do NOT include Markdown or code fences. Component name: {component_name} Category: {category} Expected hook name: {hook_name} Expected hook path: {hook_file_path} TSX source: {tsx} """ def list_components(roots: Iterable[Path]) -> List[ComponentTarget]: targets: List[ComponentTarget] = [] for root in roots: if not root.exists(): continue for path in sorted(root.rglob("*.tsx")): if path.name.lower() in EXCLUDED_FILENAMES: continue if re.search(r"\.(test|spec|stories)\.tsx$", path.name): continue name = path.stem try: rel = path.relative_to(root) category = rel.parts[0] if len(rel.parts) > 1 else path.parent.name except ValueError: category = path.parent.name targets.append( ComponentTarget(name=name, path=path, category=category) ) return targets def _strip_code_fences(output: str) -> str: trimmed = output.strip() if trimmed.startswith("```"): lines = trimmed.splitlines() if len(lines) >= 3 and lines[0].startswith("```") and lines[-1].startswith("```"): return "\n".join(lines[1:-1]).strip() return trimmed def _to_kebab_case(name: str) -> str: if not name: return "" return re.sub(r"([A-Z])", r"-\1", name).lower().lstrip("-") def _to_lower_camel(name: str) -> str: if not name: return "" return name[0].lower() + name[1:] def _to_pascal_from_kebab(name: str) -> str: parts = [part for part in name.split("-") if part] return "".join(part[:1].upper() + part[1:] for part in parts) def _extract_json_payload(output: str) -> str: trimmed = _strip_code_fences(output) start = trimmed.find("{") end = trimmed.rfind("}") if start == -1 or end == -1 or end < start: return trimmed return trimmed[start : end + 1] def _read_file_for_prompt(path: Path) -> str: if not path.exists(): return "" return path.read_text(encoding="utf-8") def _read_existing_file(out_dir: Path, rel_path: str) -> str: out_path = out_dir / rel_path if out_path.exists(): return out_path.read_text(encoding="utf-8") return _read_file_for_prompt(ROOT / rel_path) def _build_agent() -> Agent: return Agent( name="ComponentAnalyzer", instructions=( "You are a migration assistant. Given a TSX component, analyze it and " "produce a JSON-only response that includes: stateful analysis, " "hook details if needed, JSON UI definition, TS interface, " "and export updates. Follow the provided workflow." ), model=DEFAULT_MODEL, ) def _build_conflict_agent() -> Agent: return Agent( name="DiffConflictResolver", instructions=( "Resolve unified diff conflicts by producing the fully merged file content. " "Return JSON only with 'path' and 'resolvedContent'." ), model=DEFAULT_MODEL, ) def _build_json_repair_agent() -> Agent: return Agent( name="JSONRepair", instructions="Fix invalid JSON output. Return ONLY valid JSON, no Markdown.", model=DEFAULT_MODEL, ) def _build_content_only_agent() -> Agent: return Agent( name="DiffContentOnly", instructions="Return ONLY the full merged file content. No JSON.", model=DEFAULT_MODEL, ) def _build_schema_repair_agent() -> Agent: return Agent( name="SchemaRepair", instructions="Generate schema JSON only. Return ONLY valid JSON, no Markdown.", model=DEFAULT_MODEL, ) def _build_hook_repair_agent() -> Agent: return Agent( name="HookRepair", instructions="Generate hook JSON only. Return ONLY valid JSON, no Markdown.", model=DEFAULT_MODEL, ) def _is_rate_limited(exc: Exception) -> bool: status = getattr(exc, "status_code", None) if status == 429: return True response = getattr(exc, "response", None) if response is not None and getattr(response, "status_code", None) == 429: return True message = str(exc).lower() return "rate limit" in message or "too many requests" in message or "429" in message def _is_connection_error(exc: Exception) -> bool: message = str(exc).lower() return any( token in message for token in ( "connection error", "connection reset", "connection aborted", "connection refused", "timeout", "timed out", "temporarily unavailable", "network", "tls", "ssl", "proxy", "remote disconnect", "broken pipe", ) ) def _run_sync_with_timeout(agent: Agent, prompt: str, timeout: float) -> Any: executor = ThreadPoolExecutor(max_workers=1) future = executor.submit(Runner.run_sync, agent, prompt) try: return future.result(timeout=timeout) finally: # Do not block on shutdown; a hung request should not stall the orchestrator. executor.shutdown(wait=False, cancel_futures=True) def _run_with_retries(agent: Agent, prompt: str, label: str) -> Any: max_retries = 5 max_attempts = max_retries + 1 base_delay = 1.5 max_delay = 20.0 attempt = 0 while True: try: attempt += 1 print( ( f"[info] {label} attempt {attempt}/{max_attempts}: " f"sleeping {API_CALL_DELAY_SECONDS:.1f}s before API call" ), file=sys.stderr, ) time.sleep(API_CALL_DELAY_SECONDS) result = _run_sync_with_timeout( agent, prompt, API_CALL_TIMEOUT_SECONDS ) print( f"[info] {label} attempt {attempt}/{max_attempts} completed", file=sys.stderr, ) return result except Exception as exc: is_timeout = isinstance(exc, (FuturesTimeoutError, TimeoutError)) is_connection_error = _is_connection_error(exc) if not _is_rate_limited(exc) and not is_timeout and not is_connection_error: print( f"[error] {label} attempt {attempt} failed: {exc}", file=sys.stderr, ) raise if attempt >= max_attempts: raise delay = min(max_delay, base_delay * (2 ** (attempt - 1))) delay += random.uniform(0, delay * 0.2) if is_timeout: print( ( f"[warn] timeout {label} after {API_CALL_TIMEOUT_SECONDS:.0f}s; " f"retry {attempt}/{max_retries} in {delay:.1f}s" ), file=sys.stderr, ) elif is_connection_error: print( ( f"[warn] connection error {label}; " f"retry {attempt}/{max_retries} in {delay:.1f}s" ), file=sys.stderr, ) else: print( f"[warn] rate limited {label}; retry {attempt}/{max_retries} in {delay:.1f}s", file=sys.stderr, ) time.sleep(delay) def run_agent_for_component( target: ComponentTarget, out_dir: Path, debug: bool = False ) -> Dict[str, Any]: tsx = target.path.read_text(encoding="utf-8") config_file_name = f"{_to_kebab_case(target.name)}.json" components_root = ROOT / "src" / "components" try: rel_dir = str(target.path.parent.relative_to(components_root)) except ValueError: rel_dir = str(target.path.parent.relative_to(ROOT)) if rel_dir == ".": rel_dir = "" existing_files = { "src/lib/json-ui/json-components.ts": _read_existing_file( out_dir, "src/lib/json-ui/json-components.ts" ), "src/lib/json-ui/interfaces/index.ts": _read_existing_file( out_dir, "src/lib/json-ui/interfaces/index.ts" ), "src/hooks/index.ts": _read_existing_file(out_dir, "src/hooks/index.ts"), "src/lib/json-ui/hooks-registry.ts": _read_existing_file( out_dir, "src/lib/json-ui/hooks-registry.ts" ), "json-components-registry.json": _read_existing_file( out_dir, "json-components-registry.json" ), f"src/config/pages/{target.category}/{config_file_name}": _read_existing_file( out_dir, f"src/config/pages/{target.category}/{config_file_name}" ), f"src/components/{target.category}/index.ts": _read_existing_file( out_dir, f"src/components/{target.category}/index.ts" ), } existing_files_blob = "\n\n".join( f"--- {path} ---\n{content}" for path, content in existing_files.items() ) prompt = PROMPT_TEMPLATE.format( category=target.category, component_rel_dir=rel_dir, path=target.path, tsx=tsx, existing_files=existing_files_blob, ) result = _run_with_retries(_build_agent(), prompt, f"analysis:{target.name}") output = getattr(result, "final_output", None) if output is None: output = str(result) if not isinstance(output, str) or not output.strip(): raise ValueError( "Agent returned empty output. Check OPENAI_API_KEY and model access." ) output = _extract_json_payload(output) if debug: preview = textwrap.shorten(output.replace("\n", " "), width=300, placeholder="...") print(f"[debug] {target.name} raw output preview: {preview}") data = _parse_json_output(output, f"analysis:{target.name}", debug) return data def _resolve_diff_with_agent( path: str, original: str, diff_lines: List[str], debug: bool ) -> str: diff_blob = "\n".join(diff_lines) prompt = CONFLICT_PROMPT_TEMPLATE.format( path=path, original=original, diff_lines=diff_blob, ) result = _run_with_retries(_build_conflict_agent(), prompt, f"conflict:{path}") output = getattr(result, "final_output", None) if output is None: output = str(result) if not isinstance(output, str) or not output.strip(): raise ValueError("Conflict resolver returned empty output.") output = _extract_json_payload(output) if debug: preview = textwrap.shorten(output.replace("\n", " "), width=300, placeholder="...") print(f"[debug] conflict resolver output preview: {preview}") data = _parse_json_output(output, f"conflict:{path}", debug) resolved = _extract_resolved_content(data, path) if not resolved: strict_prompt = CONFLICT_PROMPT_TEMPLATE_STRICT.format( path=path, original=original, diff_lines=diff_blob, ) retry = _run_with_retries( _build_conflict_agent(), strict_prompt, f"conflict-strict:{path}" ) retry_output = getattr(retry, "final_output", None) if retry_output is None: retry_output = str(retry) if not isinstance(retry_output, str) or not retry_output.strip(): raise ValueError("Conflict resolver strict returned empty output.") retry_output = _extract_json_payload(retry_output) data = _parse_json_output(retry_output, f"conflict-strict:{path}", debug) resolved = _extract_resolved_content(data, path) if not resolved: content_prompt = CONFLICT_PROMPT_TEMPLATE_CONTENT_ONLY.format( original=original, diff_lines=diff_blob, ) content_result = _run_with_retries( _build_content_only_agent(), content_prompt, f"conflict-content:{path}" ) content_output = getattr(content_result, "final_output", None) if content_output is None: content_output = str(content_result) if not isinstance(content_output, str) or not content_output.strip(): raise ValueError("Conflict resolver content-only returned empty output.") resolved = _coerce_content_output( content_output, path, f"conflict-content:{path}", debug ) if not resolved: print( f"[warn] conflict resolver failed for {path}; keeping original content", file=sys.stderr, ) return original return resolved def _repair_json_output(output: str, error: str, label: str, debug: bool) -> Dict[str, Any]: last_error = error current_output = output for attempt in range(2): prompt = JSON_REPAIR_TEMPLATE.format(error=last_error, output=current_output) result = _run_with_retries(_build_json_repair_agent(), prompt, f"repair:{label}") fixed_output = getattr(result, "final_output", None) if fixed_output is None: fixed_output = str(result) if not isinstance(fixed_output, str) or not fixed_output.strip(): raise ValueError("JSON repair returned empty output.") fixed_output = _extract_json_payload(fixed_output) if debug: preview = textwrap.shorten( fixed_output.replace("\n", " "), width=300, placeholder="..." ) print(f"[debug] {label} repaired output preview: {preview}") try: return json.loads(fixed_output) except json.JSONDecodeError as exc: last_error = str(exc) current_output = fixed_output if attempt == 0: print( f"[warn] {label} repair attempt failed: {exc}; retrying", file=sys.stderr, ) continue raise def _parse_json_output(output: str, label: str, debug: bool) -> Dict[str, Any]: try: return json.loads(output) except json.JSONDecodeError as exc: return _repair_json_output(output, str(exc), label, debug) def _extract_resolved_content(data: Dict[str, Any], path: str) -> str | None: resolved = data.get("resolvedContent") if isinstance(resolved, str) and resolved.strip(): return resolved if isinstance(resolved, (dict, list)) and Path(path).suffix == ".json": return json.dumps(resolved, indent=2) + "\n" return None def _coerce_content_output(output: str, path: str, label: str, debug: bool) -> str: stripped = _strip_code_fences(str(output)).strip() if not stripped: return "" try: parsed = json.loads(_extract_json_payload(stripped)) except json.JSONDecodeError: return stripped if isinstance(parsed, dict): resolved = _extract_resolved_content(parsed, path) if resolved: return resolved if isinstance(parsed, str): return parsed if isinstance(parsed, (dict, list)) and Path(path).suffix == ".json": return json.dumps(parsed, indent=2) + "\n" if debug: print(f"[warn] {label} content-only output unexpected JSON shape", file=sys.stderr) return stripped def _validate_and_repair_json_file(path: Path, label: str, debug: bool) -> None: if not path.exists(): return if path.name == "json-components-registry.json" and label.startswith("diff-json:"): return try: json.loads(path.read_text(encoding="utf-8")) except json.JSONDecodeError as exc: print( f"[warn] invalid JSON in {path}: {exc}; attempting repair", file=sys.stderr, ) repaired = _repair_json_output( path.read_text(encoding="utf-8"), str(exc), label, debug ) path.write_text(json.dumps(repaired, indent=2) + "\n", encoding="utf-8") def _repair_config_page_schema( path: Path, component_name: str, category: str, data: Dict[str, Any], debug: bool, ) -> None: json_definition = json.dumps( (data.get("jsonDefinition") or {}).get("source", {}), indent=2 ) existing_content = path.read_text(encoding="utf-8") if path.exists() else "" prompt = CONFIG_PAGE_SCHEMA_REPAIR_TEMPLATE.format( component_name=component_name, category=category, is_stateful=bool(data.get("isStateful") or data.get("hook")), json_definition=json_definition, existing_content=existing_content, ) result = _run_with_retries( _build_schema_repair_agent(), prompt, f"config-repair:{component_name}" ) output = getattr(result, "final_output", None) if output is None: output = str(result) output = _extract_json_payload(str(output)) repaired = _parse_json_output(output, f"config-repair:{component_name}", debug) path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(repaired, indent=2) + "\n", encoding="utf-8") def _ensure_config_page_schema( path: Path, component_name: str, category: str, data: Dict[str, Any], debug: bool, ) -> None: if not path.exists(): _repair_config_page_schema(path, component_name, category, data, debug) return _validate_and_repair_json_file(path, f"config:{component_name}", debug) try: content = json.loads(path.read_text(encoding="utf-8")) except json.JSONDecodeError: _repair_config_page_schema(path, component_name, category, data, debug) return if not isinstance(content, dict) or not content.get("type"): _repair_config_page_schema(path, component_name, category, data, debug) def _repair_registry_file(path: Path, debug: bool) -> None: if not path.exists(): return try: current = json.loads(path.read_text(encoding="utf-8")) except json.JSONDecodeError as exc: current = _repair_json_output( path.read_text(encoding="utf-8"), str(exc), "registry", debug ) schema_path = ROOT / "schemas" / "json-components-registry-schema.json" schema = schema_path.read_text(encoding="utf-8") if schema_path.exists() else "{}" reference = {} reference_path = ROOT / "json-components-registry.json" if reference_path.exists(): try: ref_json = json.loads(reference_path.read_text(encoding="utf-8")) for key in ( "$schema", "version", "description", "lastUpdated", "categories", "sourceRoots", "statistics", ): if key in ref_json: reference[key] = ref_json[key] except json.JSONDecodeError: reference = {} prompt = REGISTRY_REPAIR_TEMPLATE.format( schema=schema, reference_header=json.dumps(reference, indent=2), current=json.dumps(current, indent=2), ) result = _run_with_retries(_build_schema_repair_agent(), prompt, "registry-repair") output = getattr(result, "final_output", None) if output is None: output = str(result) output = _extract_json_payload(str(output)) repaired = _parse_json_output(output, "registry-repair", debug) path.write_text(json.dumps(repaired, indent=2) + "\n", encoding="utf-8") def _ensure_registry_schema(path: Path, debug: bool) -> None: if not path.exists(): return _validate_and_repair_json_file(path, "registry", debug) try: content = json.loads(path.read_text(encoding="utf-8")) except json.JSONDecodeError: _repair_registry_file(path, debug) return if not isinstance(content, dict): _repair_registry_file(path, debug) return components = content.get("components") if not isinstance(components, list): _repair_registry_file(path, debug) return if not content.get("version") or not content.get("description"): _repair_registry_file(path, debug) return def _extract_definition_var(snippet: str) -> Optional[str]: match = re.search( r"createJsonComponent(?:WithHooks)?<[^>]+>\((\w+)", snippet, ) if not match: return None return match.group(1) def _extract_hook_names(snippet: str) -> List[str]: return re.findall(r"hookName:\s*['\"]([^'\"]+)['\"]", snippet) def _hook_name_to_file_name(hook_name: str) -> str: if hook_name.startswith("use") and len(hook_name) > 3: return f"use-{_to_kebab_case(hook_name[3:])}.ts" return f"{_to_kebab_case(hook_name)}.ts" def _build_json_components_file( out_dir: Path, components: List[Dict[str, Any]] ) -> None: if not components: return components_sorted = sorted(components, key=lambda item: item["name"]) use_hooks = any( "createJsonComponentWithHooks" in (item.get("snippet") or "") for item in components_sorted ) type_names = sorted({f"{item['name']}Props" for item in components_sorted}) lines: List[str] = [ "/**", " * Pure JSON components - no TypeScript wrappers needed", " * Interfaces are defined in src/lib/json-ui/interfaces/", " * JSON definitions are in src/components/json-definitions/", " */", "import { createJsonComponent } from './create-json-component'", ] if use_hooks: lines.append( "import { createJsonComponentWithHooks } from './create-json-component-with-hooks'" ) if type_names: lines.append("import type {") lines.extend([f" {name}," for name in type_names]) lines.append("} from './interfaces'") lines.append("") for item in components_sorted: snippet = item.get("snippet") or "" def_var = _extract_definition_var(snippet) or f"{_to_lower_camel(item['name'])}Def" def_path = f"@/components/json-definitions/{item['name']}.json" lines.append(f"import {def_var} from '{def_path}'") item["def_var"] = def_var lines.append("") for item in components_sorted: snippet = (item.get("snippet") or "").strip() if not snippet: snippet = ( f"export const {item['name']} = " f"createJsonComponent<{item['name']}Props>({item['def_var']})" ) lines.append(snippet) lines.append("") target = out_dir / "src" / "lib" / "json-ui" / "json-components.ts" target.parent.mkdir(parents=True, exist_ok=True) target.write_text("\n".join(lines), encoding="utf-8") def _ensure_interfaces_index(out_dir: Path) -> None: interfaces_dir = out_dir / "src" / "lib" / "json-ui" / "interfaces" if not interfaces_dir.exists(): return entries = sorted( path.stem for path in interfaces_dir.glob("*.ts") if path.name != "index.ts" ) if not entries: return lines = [f"export * from './{entry}'" for entry in entries] index_path = interfaces_dir / "index.ts" index_path.write_text("\n".join(lines) + "\n", encoding="utf-8") def _ensure_hooks_index(out_dir: Path) -> None: hooks_dir = out_dir / "src" / "hooks" if not hooks_dir.exists(): return entries = sorted( path.stem for path in hooks_dir.glob("*.ts") if path.name != "index.ts" ) if not entries: return lines = [f"export * from './{entry}'" for entry in entries] index_path = hooks_dir / "index.ts" index_path.parent.mkdir(parents=True, exist_ok=True) index_path.write_text("\n".join(lines) + "\n", encoding="utf-8") def _extract_hook_name(content: str, file_name: str) -> str: for pattern in (r"export function (\w+)", r"export const (\w+)", r"export default function (\w+)"): match = re.search(pattern, content) if match: return match.group(1) base = Path(file_name).stem if base.startswith("use-"): return f"use{_to_pascal_from_kebab(base[4:])}" return base def _ensure_hooks_registry(out_dir: Path) -> None: hooks_dir = out_dir / "src" / "hooks" if not hooks_dir.exists(): return hook_files = sorted( path for path in hooks_dir.glob("*.ts") if path.name != "index.ts" ) if not hook_files: return imports: List[str] = [] hook_names: List[str] = [] for path in hook_files: content = path.read_text(encoding="utf-8") hook_name = _extract_hook_name(content, path.name) hook_names.append(hook_name) imports.append(f"import {{ {hook_name} }} from '@/hooks/{path.stem}'") registry_lines = [ "/**", " * Hook Registry for JSON Components", " * Allows JSON components to use custom React hooks", " */", *imports, "", "export interface HookRegistry {", " [key: string]: (...args: any[]) => any", "}", "", "/**", " * Registry of all custom hooks available to JSON components", " */", "export const hooksRegistry: HookRegistry = {", *[f" {name}," for name in hook_names], "}", "", "/**", " * Get a hook from the registry by name", " */", "export function getHook(hookName: string) {", " return hooksRegistry[hookName]", "}", "", "/**", " * Register a new hook", " */", "export function registerHook(name: string, hook: (...args: any[]) => any) {", " hooksRegistry[name] = hook", "}", "", ] registry_path = out_dir / "src" / "lib" / "json-ui" / "hooks-registry.ts" registry_path.parent.mkdir(parents=True, exist_ok=True) registry_path.write_text("\n".join(registry_lines), encoding="utf-8") def _ensure_components_indices( out_dir: Path, components: List[Dict[str, Any]] ) -> None: categories: Dict[str, List[str]] = {} for item in components: category = item.get("category") or "components" categories.setdefault(category, []).append(item["name"]) for category, names in categories.items(): index_path = out_dir / "src" / "components" / category / "index.ts" index_path.parent.mkdir(parents=True, exist_ok=True) exports = [ f"export {{ {name} }} from '@/lib/json-ui/json-components'" for name in sorted(set(names)) ] index_path.write_text("\n".join(exports) + "\n", encoding="utf-8") def _post_process_outputs( out_dir: Path, processed: List[Tuple[ComponentTarget, Dict[str, Any]]] ) -> None: components: List[Dict[str, Any]] = [] missing_hooks: List[Tuple[str, str, ComponentTarget]] = [] hooks_dir = out_dir / "src" / "hooks" for target, data in processed: component_name = data.get("componentName") or target.name if not component_name: continue snippet = (data.get("jsonComponentExport") or {}).get("source", "") for hook_name in _extract_hook_names(snippet): hook_file_name = _hook_name_to_file_name(hook_name) hook_path = hooks_dir / hook_file_name if hook_path.exists(): continue hook_data = data.get("hook") or {} if hook_data.get("name") == hook_name and hook_data.get("source"): hook_file_path = hook_data.get("filePath") or f"src/hooks/{hook_file_name}" _write_if_content(out_dir / hook_file_path, hook_data["source"]) continue missing_hooks.append((hook_name, hook_file_name, target)) components.append( { "name": component_name, "category": target.category, "snippet": snippet, } ) for hook_name, hook_file_name, target in missing_hooks: hook_path = hooks_dir / hook_file_name if hook_path.exists(): continue component_name = target.name tsx = target.path.read_text(encoding="utf-8") hook_file_path = f"src/hooks/{hook_file_name}" prompt = HOOK_REPAIR_TEMPLATE.format( hook_name=hook_name, hook_file_path=hook_file_path, component_name=component_name, category=target.category, tsx=tsx, ) result = _run_with_retries( _build_hook_repair_agent(), prompt, f"hook-repair:{component_name}:{hook_name}", ) output = getattr(result, "final_output", None) if output is None: output = str(result) output = _extract_json_payload(str(output)) repaired = _parse_json_output( output, f"hook-repair:{component_name}:{hook_name}", True ) hook = repaired.get("hook") if hook and hook.get("source"): hook_path.parent.mkdir(parents=True, exist_ok=True) hook_path.write_text(hook["source"], encoding="utf-8") else: print( ( f"[warn] hook repair did not return hook source for " f"{component_name} ({hook_name})" ), file=sys.stderr, ) _build_json_components_file(out_dir, components) _ensure_interfaces_index(out_dir) _ensure_hooks_index(out_dir) _ensure_hooks_registry(out_dir) _ensure_components_indices(out_dir, components) def _write_if_content(path: Path, content: str) -> None: if not content.strip(): return path.parent.mkdir(parents=True, exist_ok=True) path.write_text(content, encoding="utf-8") def _merge_snippet(path: Path, content: str) -> None: if not content.strip(): return path.parent.mkdir(parents=True, exist_ok=True) existing = path.read_text(encoding="utf-8") if path.exists() else "" existing_lines = existing.splitlines() new_lines = content.strip().splitlines() if not new_lines: return matcher = difflib.SequenceMatcher(None, existing_lines, new_lines) match = matcher.find_longest_match(0, len(existing_lines), 0, len(new_lines)) if match.size == len(new_lines): return if match.a + match.size == len(existing_lines) and match.b == 0: merged_lines = existing_lines + new_lines[match.size:] else: merged_lines = existing_lines + ([""] if existing_lines else []) + new_lines merged_text = "\n".join(merged_lines).rstrip() + "\n" if merged_text == existing: return path.write_text(merged_text, encoding="utf-8") def _apply_unified_diff(original: str, diff_lines: List[str]) -> str: if not diff_lines: return original lines = original.splitlines() out: List[str] = [] i = 0 idx = 0 applied = False header_re = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@") while idx < len(diff_lines): line = diff_lines[idx] if line.startswith("---") or line.startswith("+++"): idx += 1 continue if not line.startswith("@@"): idx += 1 continue match = header_re.match(line) if not match: raise ValueError(f"Invalid diff header: {line!r}") old_start = int(match.group(1)) old_start_index = max(old_start - 1, 0) out.extend(lines[i:old_start_index]) i = old_start_index idx += 1 applied = True while idx < len(diff_lines) and not diff_lines[idx].startswith("@@"): hunk_line = diff_lines[idx] if hunk_line.startswith(" "): expected = hunk_line[1:] if i >= len(lines) or lines[i] != expected: raise ValueError("Diff context mismatch while applying patch.") out.append(lines[i]) i += 1 elif hunk_line.startswith("-"): expected = hunk_line[1:] if i >= len(lines) or lines[i] != expected: raise ValueError("Diff delete mismatch while applying patch.") i += 1 elif hunk_line.startswith("+"): out.append(hunk_line[1:]) elif hunk_line.startswith("\\"): pass idx += 1 if not applied: raise ValueError("No diff hunks found to apply.") out.extend(lines[i:]) return "\n".join(out) + ("\n" if original.endswith("\n") else "") def write_output(out_dir: Path, data: Dict[str, Any], target: ComponentTarget) -> None: component_name = data.get("componentName") or target.name or "unknown-component" report_path = out_dir / "migration-reports" / f"{component_name}.json" report_path.parent.mkdir(parents=True, exist_ok=True) report_path.write_text( json.dumps(data, indent=2, sort_keys=True), encoding="utf-8", ) hook = data.get("hook") if hook and hook.get("source"): hook_path = out_dir / hook["filePath"] _write_if_content(hook_path, hook["source"]) json_def = data.get("jsonDefinition") or {} json_def_path = ( out_dir / "src" / "components" / "json-definitions" / f"{component_name}.json" ) json_def_source = json.dumps( json_def.get("source", {}), indent=2, sort_keys=True ) _write_if_content(json_def_path, json_def_source) _validate_and_repair_json_file( json_def_path, f"json-definition:{component_name}", False ) interface = data.get("interface") or {} interface_path = out_dir / interface.get( "filePath", f"src/lib/json-ui/interfaces/{component_name}.ts" ) _write_if_content(interface_path, interface.get("source", "")) diffs = data.get("diffs") or [] had_diffs = False if isinstance(diffs, list) and diffs: had_diffs = True for diff_entry in diffs: path_value = diff_entry.get("path") diff_lines = diff_entry.get("diffLines") or [] if not path_value or not isinstance(diff_lines, list): continue target_path = (out_dir / path_value).resolve() if not target_path.is_relative_to(out_dir.resolve()): raise ValueError(f"Diff path escapes output directory: {path_value}") existing = target_path.read_text(encoding="utf-8") if target_path.exists() else "" try: merged = _apply_unified_diff(existing, diff_lines) except ValueError as exc: print( f"[warn] diff apply failed for {path_value}: {exc}; attempting AI resolution.", file=sys.stderr, ) merged = _resolve_diff_with_agent( path_value, existing, diff_lines, debug=True ) _write_if_content(target_path, merged) if target_path.suffix == ".json": _validate_and_repair_json_file( target_path, f"diff-json:{path_value}", True ) if not had_diffs: json_component_export = (data.get("jsonComponentExport") or {}).get("source", "") _merge_snippet( out_dir / "src/lib/json-ui/json-components.ts", json_component_export, ) exports = data.get("exports") or {} _merge_snippet( out_dir / "src/lib/json-ui/interfaces/index.ts", exports.get("interfacesIndex", ""), ) _merge_snippet( out_dir / "src/hooks/index.ts", exports.get("hooksIndex", "") or "" ) _merge_snippet( out_dir / "src/lib/json-ui/hooks-registry.ts", exports.get("hooksRegistry", "") or "", ) components_index_path = out_dir / "src/components" / target.category / "index.ts" _merge_snippet(components_index_path, exports.get("componentsIndex", "")) config_name = f"{_to_kebab_case(component_name)}.json" config_path = ( out_dir / "src" / "config" / "pages" / target.category / config_name ) _ensure_config_page_schema( config_path, component_name, target.category, data, False ) def main() -> int: out_dir = (ROOT / "migration-out").resolve() workers = 4 targets = list_components(COMPONENT_DIRS) out_dir.mkdir(parents=True, exist_ok=True) failures: List[str] = [] processed: List[Tuple[ComponentTarget, Dict[str, Any]]] = [] with ThreadPoolExecutor(max_workers=workers) as executor: futures = { executor.submit(run_agent_for_component, t, out_dir, True): t for t in targets } for future in as_completed(futures): target = futures[future] try: data = future.result() write_output(out_dir, data, target) processed.append((target, data)) print(f"ok: {target.name}") except Exception as exc: failures.append(f"{target.name}: {exc}") print(f"error: {target.name}: {exc}", file=sys.stderr) if failures: print("\nFailures:", file=sys.stderr) for failure in failures: print(f"- {failure}", file=sys.stderr) return 1 _post_process_outputs(out_dir, processed) _ensure_registry_schema(out_dir / "json-components-registry.json", True) return 0 if __name__ == "__main__": raise SystemExit(main())