#!/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 sys import textwrap import difflib import re from concurrent.futures import ThreadPoolExecutor, as_completed from dataclasses import dataclass from pathlib import Path from typing import Any, Dict, Iterable, List 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] COMPONENT_DIRS = [ ROOT / "src" / "components" / "atoms", ROOT / "src" / "components" / "molecules", ROOT / "src" / "components" / "organisms", ] @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) 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 6) 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, {{ ... }}) - 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. Return ONLY valid JSON with this shape: {{ "componentName": "...", "category": "atoms|molecules|organisms", "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..." }}, "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} 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} """ 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")): name = path.stem targets.append( ComponentTarget(name=name, path=path, category=root.name) ) 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 _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." ), ) def _build_conflict_agent() -> Agent: return Agent( name="DiffConflictResolver", instructions=( "Resolve unified diff conflicts by producing the fully merged file content. " "Return JSON only." ), ) def run_agent_for_component( target: ComponentTarget, out_dir: Path, debug: bool = False ) -> Dict[str, Any]: tsx = target.path.read_text(encoding="utf-8") 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" ), 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, path=target.path, tsx=tsx, existing_files=existing_files_blob, ) result = Runner.run_sync(_build_agent(), prompt) 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}") try: data = json.loads(output) except json.JSONDecodeError as exc: snippet = output.strip().replace("\n", " ")[:200] raise ValueError( f"Agent output was not valid JSON: {exc}. Output starts with: {snippet!r}" ) from exc 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 = Runner.run_sync(_build_conflict_agent(), prompt) 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}") try: data = json.loads(output) except json.JSONDecodeError as exc: snippet = output.strip().replace("\n", " ")[:200] raise ValueError( f"Conflict resolver output was not valid JSON: {exc}. Output starts with: {snippet!r}" ) from exc resolved = data.get("resolvedContent") if not isinstance(resolved, str) or not resolved.strip(): raise ValueError("Conflict resolver did not return resolvedContent.") return resolved 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 "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) 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 [] if isinstance(diffs, list) and diffs: 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) return 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", "")) 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] = [] 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) 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 return 0 if __name__ == "__main__": raise SystemExit(main())