Files
low-code-react-app-b/scripts/component_migration_orchestrator.py
2026-01-19 12:52:24 +00:00

708 lines
24 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}
"""
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 _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}")
try:
data = json.loads(output)
except json.JSONDecodeError as exc:
data = _repair_json_output(output, str(exc), 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}")
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():
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)
try:
data = json.loads(retry_output)
except json.JSONDecodeError as exc:
snippet = retry_output.strip().replace("\n", " ")[:200]
raise ValueError(
f"Conflict resolver strict 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 _repair_json_output(output: str, error: str, label: str, debug: bool) -> Dict[str, Any]:
prompt = JSON_REPAIR_TEMPLATE.format(error=error, output=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}")
return json.loads(fixed_output)
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)
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())