#!/usr/bin/env python3 """ Normalize Flow codemod syntax to Babel-compatible Flow syntax. Flow 0.299 annotate-exports introduces syntax that older Babel/Flow tooling in this project cannot parse: - component(...) type annotations - renders type operator - as-casts This script rewrites those constructs while preserving runtime behavior and as much type information as possible: - component(...) -> React.ComponentType - React.AbstractComponent -> React.ComponentType<{...Props, ref?: React.RefSetter}> - renders T -> T (with renders any/mixed/empty/Fragment normalized to React.Node) - expr as T -> (expr: T) The script is idempotent. """ from __future__ import annotations import json import os import pathlib import re import subprocess import sys from typing import Any, Dict, List, Optional, Tuple APP_DIR = os.environ.get( "APP_DIR", os.path.dirname(os.path.dirname(os.path.abspath(__file__))), ) SRC_DIR = pathlib.Path(APP_DIR) / "src" FLOW_BIN = pathlib.Path(APP_DIR) / "node_modules" / ".bin" / "flow" FLOW_AST_CMD = [str(FLOW_BIN), "ast"] if FLOW_BIN.exists() else ["npx", "flow", "ast"] def _extract_json(output: str) -> Optional[Dict[str, Any]]: start = output.find("{") if start == -1: return None try: return json.loads(output[start:]) except json.JSONDecodeError: return None def _run_flow_ast(relative_path: str, content_text: str) -> Optional[Dict[str, Any]]: result = subprocess.run( FLOW_AST_CMD + ["--path", relative_path], input=content_text.encode("utf-8"), capture_output=True, cwd=APP_DIR, ) stdout = result.stdout.decode("utf-8", errors="replace") stderr = result.stderr.decode("utf-8", errors="replace") data = _extract_json(stdout) if data is not None: return data # Flow may occasionally emit JSON to stderr on failure. return _extract_json(stderr) def _walk(node: Any, visit, parent_type: Optional[str] = None) -> None: if isinstance(node, dict): node_type = node.get("type") visit(node, parent_type) for value in node.values(): _walk(value, visit, node_type) elif isinstance(node, list): for item in node: _walk(item, visit, parent_type) def _node_text(content: bytes, node: Dict[str, Any]) -> str: start, end = node["range"] return content[start:end].decode("utf-8") def _is_render_node_like(type_text: str) -> bool: normalized = "".join(type_text.split()) if normalized in { "any", "mixed", "empty", "void", "null", "Fragment", "React.Node", "React$Node", "React.MixedElement", }: return True return ( "React.Node" in normalized or "React$Node" in normalized or "React.MixedElement" in normalized ) def _normalize_renders_target(type_text: str) -> str: stripped = type_text.strip() if stripped in {"any", "mixed", "empty", "Fragment"}: return "React.Node" return stripped def _object_property_key_name(property_node: Dict[str, Any]) -> Optional[str]: key = property_node.get("key") if not isinstance(key, dict): return None key_type = key.get("type") if key_type == "Identifier": return key.get("name") if key_type == "Literal": return key.get("value") return None def _object_type_has_ref_property(object_type_node: Dict[str, Any]) -> bool: for prop in object_type_node.get("properties", []): if not isinstance(prop, dict): continue if prop.get("type") != "ObjectTypeProperty": continue if _object_property_key_name(prop) == "ref": return True return False def _qualified_type_identifier_name(type_id_node: Any) -> Optional[str]: if not isinstance(type_id_node, dict): return None node_type = type_id_node.get("type") if node_type == "Identifier": return type_id_node.get("name") if node_type == "QualifiedTypeIdentifier": left = _qualified_type_identifier_name(type_id_node.get("qualification")) right = _qualified_type_identifier_name(type_id_node.get("id")) if left and right: return f"{left}.{right}" return right return None def _is_react_abstract_component_type(type_id_node: Any) -> bool: type_name = _qualified_type_identifier_name(type_id_node) return type_name in {"React.AbstractComponent", "React$AbstractComponent"} def _type_parameter_nodes(type_parameters_node: Any) -> List[Dict[str, Any]]: if not isinstance(type_parameters_node, dict): return [] params = type_parameters_node.get("params") if not isinstance(params, list): return [] return [param for param in params if isinstance(param, dict)] def _is_ref_setter_type(type_text: str) -> bool: stripped = type_text.strip() return stripped.startswith("React.RefSetter<") or stripped.startswith( "React$RefSetter<" ) def _to_ref_setter_type(instance_type_text: str) -> str: stripped = instance_type_text.strip() if not stripped: return "React.RefSetter" if _is_ref_setter_type(stripped): return stripped.replace("React$RefSetter", "React.RefSetter") return f"React.RefSetter<{stripped}>" def _props_text_has_ref_property(props_type_text: str) -> bool: return ( re.search(r"(^|[,{]\s*)\+?ref\??\s*:", props_type_text, re.MULTILINE) is not None ) def _append_ref_property_to_object_type( props_type_text: str, ref_prop_type: str ) -> str: original = props_type_text stripped = original.rstrip() trailing_whitespace = original[len(stripped) :] if stripped.startswith("{|") and stripped.endswith("|}"): open_token, close_token = "{|", "|}" elif stripped.startswith("{") and stripped.endswith("}"): open_token, close_token = "{", "}" else: return f"{{ ...{props_type_text.strip()}, +ref?: {ref_prop_type} }}" inner = stripped[len(open_token) : -len(close_token)] if _props_text_has_ref_property(inner): return props_type_text is_multiline = "\n" in stripped inner_content = inner.strip() if not is_multiline: if inner_content: separator = "," if not inner_content.endswith(",") else "" return ( f"{open_token} {inner_content}{separator} +ref?: {ref_prop_type} " f"{close_token}{trailing_whitespace}" ) return ( f"{open_token} +ref?: {ref_prop_type} " f"{close_token}{trailing_whitespace}" ) # Multiline object type: place the new property on its own line and keep # existing indentation style if possible. closing_indent_match = re.search(r"\n([ \t]*)\s*$", inner) closing_indent = closing_indent_match.group(1) if closing_indent_match else "" property_indent_match = re.search(r"\n([ \t]+)\S", inner) property_indent = ( property_indent_match.group(1) if property_indent_match else f"{closing_indent} " ) inner_without_trailing_space = inner.rstrip() if inner_content: if not inner_without_trailing_space.endswith(","): inner_without_trailing_space += "," if not inner_without_trailing_space.endswith("\n"): inner_without_trailing_space += "\n" new_inner = ( f"{inner_without_trailing_space}" f"{property_indent}+ref?: {ref_prop_type},\n" f"{closing_indent}" ) else: new_inner = ( f"\n{property_indent}+ref?: {ref_prop_type},\n" f"{closing_indent}" ) return f"{open_token}{new_inner}{close_token}{trailing_whitespace}" def _with_optional_ref_property( props_type_text: str, props_type_node: Optional[Dict[str, Any]], instance_type_text: Optional[str], ) -> str: if not instance_type_text: return props_type_text if _props_text_has_ref_property(props_type_text): return props_type_text ref_prop_type = _to_ref_setter_type(instance_type_text) if props_type_node and props_type_node.get("type") == "ObjectTypeAnnotation": if _object_type_has_ref_property(props_type_node): return props_type_text return _append_ref_property_to_object_type(props_type_text, ref_prop_type) if props_type_text.strip() == "any": return f"{{ +ref?: {ref_prop_type}, ... }}" return f"{{ ...{props_type_text}, +ref?: {ref_prop_type} }}" def _abstract_component_to_component_type( node: Dict[str, Any], content: bytes ) -> Optional[str]: if node.get("type") != "GenericTypeAnnotation": return None if not _is_react_abstract_component_type(node.get("id")): return None type_params = _type_parameter_nodes(node.get("typeParameters")) if not type_params: return "React.ComponentType" props_node = type_params[0] props_type = ( _node_text(content, props_node).strip() if "range" in props_node else "any" ) if not props_type: props_type = "any" instance_type = None if len(type_params) >= 2: instance_node = type_params[1] if "range" in instance_node: instance_type = _node_text(content, instance_node).strip() props_type = _with_optional_ref_property(props_type, props_node, instance_type) return f"React.ComponentType<{props_type}>" def _extract_ref_type_from_component( component_node: Dict[str, Any], content: bytes ) -> Optional[str]: # Form: component(ref: Type, ...Props) for param in component_node.get("params", []): if not isinstance(param, dict): continue if param.get("name") == "ref": type_annotation = param.get("typeAnnotation") if isinstance(type_annotation, dict) and "range" in type_annotation: return _node_text(content, type_annotation).strip() # Form: component(...{ ...Props, +ref?: Type }) rest = component_node.get("rest") if not isinstance(rest, dict): return None rest_type = rest.get("typeAnnotation") if not isinstance(rest_type, dict): return None if rest_type.get("type") != "ObjectTypeAnnotation": return None for prop in rest_type.get("properties", []): if not isinstance(prop, dict): continue if prop.get("type") != "ObjectTypeProperty": continue if _object_property_key_name(prop) != "ref": continue value = prop.get("value") if isinstance(value, dict) and "range" in value: return _node_text(content, value).strip() return None def _component_to_legacy_type( component_node: Dict[str, Any], content: bytes ) -> str: props_type = "any" props_type_node: Optional[Dict[str, Any]] = None rest = component_node.get("rest") if isinstance(rest, dict): rest_type = rest.get("typeAnnotation") if isinstance(rest_type, dict) and "range" in rest_type: props_type_node = rest_type extracted_props = _node_text(content, rest_type).strip() if extracted_props: props_type = extracted_props ref_type = _extract_ref_type_from_component(component_node, content) renders_type = None renders = component_node.get("rendersType") if isinstance(renders, dict): rendered = renders.get("typeAnnotation") if isinstance(rendered, dict) and "range" in rendered: renders_type = _node_text(content, rendered).strip() ref_instance_type = ref_type if not ref_instance_type and renders_type and not _is_render_node_like(renders_type): # Non-React-node renders targets generally represent a component # interface/instance type. In modern Flow this is represented with a # `ref` prop typed with React.RefSetter. ref_instance_type = renders_type props_type = _with_optional_ref_property( props_type, props_type_node, ref_instance_type ) return f"React.ComponentType<{props_type}>" def _apply_replacements( content: bytes, replacements: List[Tuple[int, int, str]] ) -> Tuple[bytes, int]: valid: List[Tuple[int, int, bytes]] = [] for start, end, replacement in replacements: if start >= end: continue replacement_bytes = replacement.encode("utf-8") if content[start:end] == replacement_bytes: continue valid.append((start, end, replacement_bytes)) if not valid: return content, 0 # Prefer outer nodes first when ranges overlap. valid.sort(key=lambda item: (item[0], -(item[1] - item[0]))) non_overlapping: List[Tuple[int, int, bytes]] = [] current_end = -1 for start, end, replacement_bytes in valid: if start < current_end: continue non_overlapping.append((start, end, replacement_bytes)) current_end = end updated = bytearray(content) for start, end, replacement_bytes in reversed(non_overlapping): updated[start:end] = replacement_bytes return bytes(updated), len(non_overlapping) def _collect_component_and_renders_replacements( ast: Dict[str, Any], content: bytes ) -> List[Tuple[int, int, str]]: replacements: List[Tuple[int, int, str]] = [] def visit(node: Dict[str, Any], parent_type: Optional[str]) -> None: node_type = node.get("type") if node_type == "ComponentTypeAnnotation" and "range" in node: start, end = node["range"] replacements.append((start, end, _component_to_legacy_type(node, content))) return if node_type == "GenericTypeAnnotation" and "range" in node: replacement = _abstract_component_to_component_type(node, content) if replacement: start, end = node["range"] replacements.append((start, end, replacement)) if ( node_type == "TypeOperator" and node.get("operator") == "renders" and parent_type != "ComponentTypeAnnotation" and "range" in node ): type_annotation = node.get("typeAnnotation") if isinstance(type_annotation, dict) and "range" in type_annotation: rendered_type = _node_text(content, type_annotation) replacements.append( ( node["range"][0], node["range"][1], _normalize_renders_target(rendered_type), ) ) _walk(ast, visit) return replacements def _collect_as_replacements( ast: Dict[str, Any], content: bytes ) -> List[Tuple[int, int, str]]: replacements: List[Tuple[int, int, str]] = [] def visit(node: Dict[str, Any], _parent_type: Optional[str]) -> None: if node.get("type") != "AsExpression": return if "range" not in node: return expression = node.get("expression") type_annotation = node.get("typeAnnotation") if not isinstance(expression, dict) or "range" not in expression: return if not isinstance(type_annotation, dict) or "range" not in type_annotation: return start, end = node["range"] expr_start, expr_end = expression["range"] type_start, _type_end = type_annotation["range"] type_text = _node_text(content, type_annotation) leading = content[start:expr_start].decode("utf-8") expr_core = content[expr_start:expr_end].decode("utf-8") between = content[expr_end:type_start].decode("utf-8") as_index = between.rfind(" as ") if as_index == -1: return between_before_as = between[:as_index] trailing_comment = "" line_comment_index = between_before_as.rfind("//") block_comment_index = between_before_as.rfind("/*") if line_comment_index != -1: trailing_comment = between_before_as[line_comment_index:].strip() between_before_as = between_before_as[:line_comment_index] elif block_comment_index != -1 and "*/" in between_before_as[block_comment_index:]: trailing_comment = between_before_as[block_comment_index:].strip() between_before_as = between_before_as[:block_comment_index] expression_text = f"{leading}{expr_core}{between_before_as}".rstrip() replacement = f"({expression_text}: {type_text})" if trailing_comment: replacement = f"{replacement} {trailing_comment}" replacements.append((start, end, replacement)) _walk(ast, visit) return replacements def _remove_stale_prop_missing_fixmes(content: bytes) -> Tuple[bytes, int]: try: text = content.decode("utf-8") except UnicodeDecodeError: return content, 0 lines = text.splitlines(keepends=True) keep_lines: List[str] = [] removed = 0 specific_fixme_re = re.compile(r"^\s*// \$FlowFixMe\[prop-missing\]\s*$") any_fixme_re = re.compile(r"^\s*// \$FlowFixMe\[[^\]]+\]\s*$") i = 0 while i < len(lines): current_stripped = lines[i].strip() if specific_fixme_re.match(current_stripped): j = i + 1 inspected = 0 remove_current = False while j < len(lines) and inspected < 8: candidate = lines[j].strip() inspected += 1 if not candidate: j += 1 continue if any_fixme_re.match(candidate): j += 1 continue if "React.ComponentType<" in lines[j]: remove_current = True break if remove_current: removed += 1 i += 1 continue keep_lines.append(lines[i]) i += 1 if removed == 0: return content, 0 return "".join(keep_lines).encode("utf-8"), removed def _process_file(path: pathlib.Path) -> Tuple[bool, int]: original = path.read_bytes() # Quick pre-filter to avoid expensive AST parsing on unrelated files. has_componenttype_prop_missing = ( b"$FlowFixMe[prop-missing]" in original and b"React.ComponentType<" in original ) if ( b"component(" not in original and b"renders " not in original and b" as " not in original and b"AbstractComponent" not in original and b"React$AbstractComponent" not in original and not has_componenttype_prop_missing ): return False, 0 try: text = original.decode("utf-8") except UnicodeDecodeError: return False, 0 relative_path = os.path.relpath(path, APP_DIR) ast = _run_flow_ast(relative_path, text) if ast is None: return False, 0 content = original total_replacements = 0 # Pass 1: component(...) + renders. pass1_replacements = _collect_component_and_renders_replacements(ast, content) content, applied = _apply_replacements(content, pass1_replacements) total_replacements += applied # Pass 2: "as" casts. Run iteratively to handle nested casts. for _ in range(10): if b" as " not in content: break try: content_text = content.decode("utf-8") except UnicodeDecodeError: break as_ast = _run_flow_ast(relative_path, content_text) if as_ast is None: break as_replacements = _collect_as_replacements(as_ast, content) content, applied = _apply_replacements(content, as_replacements) total_replacements += applied if applied == 0: break content, removed_fixmes = _remove_stale_prop_missing_fixmes(content) total_replacements += removed_fixmes if content != original: path.write_bytes(content) return True, total_replacements return False, 0 def main() -> int: files_modified = 0 replacements_applied = 0 for path in sorted(SRC_DIR.rglob("*.js")): if not path.is_file(): continue modified, replacements = _process_file(path) if modified: files_modified += 1 replacements_applied += replacements print(f" Modified {files_modified} files ({replacements_applied} replacements)") return 0 if __name__ == "__main__": sys.exit(main())