Files
low-code-react-app-b/scripts/component_migration_orchestrator.py
2026-01-19 13:17:20 +00:00

805 lines
27 KiB
Python

#!/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
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]
DEFAULT_MODEL = os.getenv("CODEX_MODEL") or os.getenv("OPENAI_MODEL") or "gpt-4o-mini"
API_CALL_DELAY_SECONDS = 2.0
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-<component>.ts.
3) Create JSON definition under src/components/json-definitions/<component>.json.
4) Create interface in src/lib/json-ui/interfaces/<component>.ts.
5) Create config page schema under src/config/pages/<category>/<component-name-kebab>.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/<category>/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<ComponentNameProps>(componentNameDef)
OR
export const ComponentName = createJsonComponentWithHooks<ComponentNameProps>(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.
- Include diffs for the config page schema file and json-components-registry.json.
Config page schema format (src/config/pages/<category>/<component-name-kebab>.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/<category>/ComponentName",
"export": "ComponentName"
}},
"props": {{ ... }},
"metadata": {{
"notes": "Contains hooks - needs wrapper"
}}
}}
JSON definition schema (src/components/json-definitions/<component>.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.
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",
"jsonCompatible": true|false,
"wrapperRequired": true|false,
"load": {{
"path": "@/components/<category>/ComponentName",
"export": "ComponentName"
}},
"metadata": {{
"conversionDate": "YYYY-MM-DD",
"autoGenerated": true,
"notes": "Optional notes"
}}
}}
Omit optional fields when not applicable.
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..."
}},
"configPageSchema": {{
"filePath": "src/config/pages/<category>/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}
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}
"""
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 _to_kebab_case(name: str) -> str:
if not name:
return ""
return re.sub(r"([A-Z])", r"-\1", name).lower().lstrip("-")
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 _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 _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 = Runner.run_sync(agent, prompt)
print(
f"[info] {label} attempt {attempt}/{max_attempts} completed",
file=sys.stderr,
)
return result
except Exception as exc:
if not _is_rate_limited(exc):
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)
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"
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,
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:
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 _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 []
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)
if target_path.suffix == ".json":
_validate_and_repair_json_file(
target_path, f"diff-json:{path_value}", True
)
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())