diff --git a/scripts/component_migration_orchestrator.py b/scripts/component_migration_orchestrator.py index f4ae502..751202d 100644 --- a/scripts/component_migration_orchestrator.py +++ b/scripts/component_migration_orchestrator.py @@ -32,11 +32,9 @@ 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 -COMPONENT_DIRS = [ - ROOT / "src" / "components" / "atoms", - ROOT / "src" / "components" / "molecules", - ROOT / "src" / "components" / "organisms", -] +# 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) @@ -139,16 +137,17 @@ JSON definition schema (src/components/json-definitions/.json): "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", + "source": "atoms|molecules|organisms|ui|custom|misc", "jsonCompatible": true|false, "wrapperRequired": true|false, "load": {{ @@ -161,12 +160,12 @@ Registry entry format (json-components-registry.json) for the component if missi "notes": "Optional notes" }} }} -Omit optional fields when not applicable. +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": "atoms|molecules|organisms", + "category": "{category}", "isStateful": true|false, "hook": {{ "name": "useComponentName", @@ -206,6 +205,7 @@ Return ONLY valid JSON with this shape: Component category: {category} Component path: {path} +Component subpath under src/components: {component_rel_dir} Existing file contents for diffing: {existing_files} @@ -363,9 +363,18 @@ def list_components(roots: Iterable[Path]) -> List[ComponentTarget]: 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=root.name) + ComponentTarget(name=name, path=path, category=category) ) return targets @@ -584,6 +593,13 @@ def run_agent_for_component( ) -> 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" @@ -610,6 +626,7 @@ def run_agent_for_component( ) prompt = PROMPT_TEMPLATE.format( category=target.category, + component_rel_dir=rel_dir, path=target.path, tsx=tsx, existing_files=existing_files_blob, diff --git a/scripts/generate-tsx-deletion-manifest.py b/scripts/generate-tsx-deletion-manifest.py new file mode 100644 index 0000000..d83fec6 --- /dev/null +++ b/scripts/generate-tsx-deletion-manifest.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +""" +Generate a deletion manifest for legacy TSX components that have JSON equivalents. + +Heuristics (no dependency on migration-out): +- Scan all src/components/**/*.tsx (excluding tests/stories). +- Record signals (not required to include): + - Matching JSON definition at src/components/json-definitions/.json + - Matching config page at src/config/pages//.json (only for atoms/molecules/organisms) + - Matching registry entry in json-components-registry.json +- All TSX files are listed; signals provide confidence for deletion. + +Outputs: +- tsxs-to-delete.txt (one relative TSX path per line) +- tsxs-to-delete.json (array of {name, category, tsxPath, jsonDefinition, configPage?}) +""" +from __future__ import annotations + +import json +import re +from pathlib import Path +from typing import Dict, List, Optional, Set, Tuple + +ROOT = Path(__file__).resolve().parents[1] +COMPONENTS_ROOT = ROOT / "src" / "components" +JSON_DEFS_DIR = ROOT / "src" / "components" / "json-definitions" +CONFIG_PAGES_DIR = ROOT / "src" / "config" / "pages" +REGISTRY_FILE = ROOT / "json-components-registry.json" + + +def to_kebab(name: str) -> str: + if not name: + return "" + return re.sub(r"([A-Z])", r"-\1", name).lower().lstrip("-") + + +def to_pascal(name: str) -> str: + if not name: + return "" + parts = re.split(r"[-_\\s]", name) + parts = [p for p in parts if p] + if len(parts) == 1: + s = parts[0] + return s[:1].upper() + s[1:] + return "".join(p[:1].upper() + p[1:] for p in parts) + + +def list_tsx_files() -> List[Tuple[str, Path]]: + files: List[Tuple[str, Path]] = [] + if not COMPONENTS_ROOT.exists(): + return files + for path in COMPONENTS_ROOT.rglob("*.tsx"): + if ".test." in path.name or ".stories." in path.name: + continue + parts = path.relative_to(COMPONENTS_ROOT).parts + category = parts[0] if parts else "components" + files.append((category, path)) + return files + + +def load_registry_types() -> Set[str]: + if not REGISTRY_FILE.exists(): + return set() + try: + data = json.loads(REGISTRY_FILE.read_text(encoding="utf-8")) + except Exception: + return set() + return {c.get("type", "") for c in data.get("components", []) if isinstance(c, dict)} + + +def matching_json_definition(name: str) -> Optional[Path]: + kebab = to_kebab(name) + candidate = JSON_DEFS_DIR / f"{kebab}.json" + return candidate if candidate.exists() else None + + +def matching_config_page(category: str, name: str) -> Optional[Path]: + kebab = to_kebab(name) + candidate = CONFIG_PAGES_DIR / category / f"{kebab}.json" + return candidate if candidate.exists() else None + + +def build_manifest() -> List[Dict[str, str]]: + registry_types = {t.lower() for t in load_registry_types() if t} + manifest: List[Dict[str, str]] = [] + for category, tsx_path in list_tsx_files(): + name = tsx_path.stem + kebab = to_kebab(name) + pascal = to_pascal(name) + signals: Dict[str, str] = {} + + json_def = matching_json_definition(name) + if json_def: + signals["jsonDefinition"] = str(json_def.relative_to(ROOT)) + + config = matching_config_page(category, name) + if config: + signals["configPage"] = str(config.relative_to(ROOT)) + + if pascal.lower() in registry_types: + signals["registry"] = "present" + + entry: Dict[str, str] = { + "name": name, + "category": category, + "tsxPath": str(tsx_path.relative_to(ROOT)), + **signals, + } + manifest.append(entry) + return manifest + + +def write_manifest(entries: List[Dict[str, str]]) -> None: + out_dir = ROOT + txt_path = out_dir / "tsxs-to-delete.txt" + json_path = out_dir / "tsxs-to-delete.json" + if not entries: + txt_path.write_text("", encoding="utf-8") + json_path.write_text("[]\n", encoding="utf-8") + print("No TSX files matched the deletion criteria.") + return + def render_line(e: Dict[str, str]) -> str: + parts = [e["tsxPath"]] + arrows: List[str] = [] + if "jsonDefinition" in e: + arrows.append(f"JSON:{e['jsonDefinition']}") + if "configPage" in e: + arrows.append(f"CFG:{e['configPage']}") + if e.get("registry") == "present": + arrows.append("REG:yes") + if arrows: + parts.append("--> " + " | ".join(arrows)) + return " ".join(parts) + + txt_path.write_text("\n".join(render_line(e) for e in entries) + "\n", encoding="utf-8") + json_path.write_text(json.dumps(entries, indent=2) + "\n", encoding="utf-8") + print(f"Wrote {len(entries)} entries to {txt_path}") + print(f"Wrote {len(entries)} entries to {json_path}") + + +def main() -> int: + entries = build_manifest() + write_manifest(entries) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())