Skill Tracer
Skills › HyperFrames › remotion-to-hyperframes › lint_source.py

lint_source.py

370 lines · skills/remotion-to-hyperframes/scripts/lint_source.py at a595755

1#!/usr/bin/env python3
2"""Lint a Remotion project for patterns that don't translate cleanly to HyperFrames.
3
4The skill should run this *before* attempting a translation. If any blocker
5findings come back, the recommendation is to use the runtime interop pattern
6from PR #214 instead of producing broken HTML.
7
8Usage:
9 lint_source.py <path-to-remotion-src> [--json]
11Output (default human-readable, --json for machine-readable):
12 For each .ts/.tsx file, a list of findings with:
13 - severity: blocker | warning | info
14 - line, column
15 - rule id
16 - message
17 - recommendation
19Blockers (skill should refuse to translate):
20 - r2hf/use-state React state machine drives animation
21 - r2hf/use-effect-deps useEffect/useLayoutEffect with non-empty deps (side effects)
22 - r2hf/use-reducer useReducer drives animation
23 - r2hf/async-metadata calculateMetadata returns a Promise
24 - r2hf/third-party-react-ui Imports a React UI library (shadcn, mui, antd, mantine, chakra)
26Warnings (translate but flag — drop the construct, keep the rest):
27 - r2hf/lambda-import @remotion/lambda configuration — drop, HF is single-machine
28 - r2hf/delay-render delayRender() — HF handles asset loading differently
29 - r2hf/use-callback useCallback — usually decorative, drop
30 - r2hf/use-memo useMemo — usually decorative, drop
31 - r2hf/custom-hook Custom hook (use*) defined locally; may need manual rewrite
33Info (translate and document):
34 - r2hf/static-file staticFile("x") — convert to relative path
35 - r2hf/interpolate-colors interpolateColors — translate to GSAP color tween
36"""
38from __future__ import annotations
40import argparse
41import json
42import re
43import sys
44from collections.abc import Iterable
45from dataclasses import dataclass, asdict
46from pathlib import Path
47from typing import Callable
49# Windows sizes stdio to the ANSI code page (cp1252), which cannot encode every glyph
50# a finding message carries. These scripts emit UTF-8 on every platform; say so. Carry
51# `errors` across: reconfigure() resets it to "strict", and CPython deliberately gives
52# stderr "backslashreplace" so the diagnostic path can never itself raise — this script
53# prints scanned filenames, and an unpaired surrogate in one would otherwise crash the
54# reporter instead of being escaped.
55for _stream in (sys.stdout, sys.stderr):
56 if hasattr(_stream, "reconfigure"):
57 _stream.reconfigure(encoding="utf-8", errors=_stream.errors)
59BLOCKER = "blocker"
60WARNING = "warning"
61INFO = "info"
63THIRD_PARTY_UI_PACKAGES = {
64 "@mui/material",
65 "@mui/icons-material",
66 "@chakra-ui/react",
67 "@mantine/core",
68 "antd",
69 "@shadcn/ui",
70 "@radix-ui",
71 "@nextui-org/react",
75@dataclass
76class Finding:
77 file: str
78 line: int
79 column: int
80 severity: str
81 rule: str
82 message: str
83 recommendation: str
86@dataclass
87class Rule:
88 """A lint rule: a matcher that yields hits, plus the metadata each hit gets.
90 A matcher is a function `src -> Iterable[(offset, override_message)]`. If
91 `override_message` is None, the rule's default `message` is used; matchers
92 that need to embed the matched text (custom-hook name, third-party package
93 name) return the customized message instead.
94 """
96 rule_id: str
97 severity: str
98 matcher: Callable[[str], Iterable[tuple[int, str | None]]]
99 message: str
100 recommendation: str
103def _regex_matcher(pattern: re.Pattern[str]) -> Callable[[str], Iterable[tuple[int, str | None]]]:
104 def _match(src: str) -> Iterable[tuple[int, str | None]]:
105 for m in pattern.finditer(src):
106 yield m.start(), None
108 return _match
111def _use_effect_with_deps(src: str) -> Iterable[tuple[int, str | None]]:
112 # Find use(Layout)?Effect(, walk to its matching ), and check if the call
113 # ends with `, [<non-empty>])`. Empty `[]` is mount-only, allowed.
114 for m in re.finditer(r"\buse(?:Layout)?Effect\s*\(", src):
115 end = _find_matching_paren(src, m.end() - 1)
116 if end is None:
117 continue
118 call = src[m.start() : end + 1]
119 m2 = re.search(r",\s*\[([^\]]*)\]\s*$", call[:-1])
120 if m2 and m2.group(1).strip():
121 yield m.start(), None
124_CUSTOM_HOOK_DECL = re.compile(
125 r"^\s*(?:export\s+(?:default\s+)?)?(?:function|const|let|var)\s+(use[A-Z]\w+)\b",
126 re.MULTILINE,
128_REMOTION_BUILTIN_HOOKS = {"useCurrentFrame", "useVideoConfig"}
131def _custom_hook(src: str) -> Iterable[tuple[int, str | None]]:
132 for m in _CUSTOM_HOOK_DECL.finditer(src):
133 name = m.group(1)
134 if name in _REMOTION_BUILTIN_HOOKS:
135 continue
136 yield m.start(), f"Custom hook `{name}` defined locally — may need manual rewrite"
139_IMPORT_FROM = re.compile(r"from\s+['\"]([^'\"]+)['\"]")
142def _third_party_react_ui(src: str) -> Iterable[tuple[int, str | None]]:
143 for m in _IMPORT_FROM.finditer(src):
144 pkg = m.group(1)
145 if any(pkg.startswith(p) for p in THIRD_PARTY_UI_PACKAGES):
146 yield m.start(), f"Imports `{pkg}` — third-party React UI library has no HF equivalent"
149RULES: list[Rule] = [
150 Rule(
151 "r2hf/use-state",
152 BLOCKER,
153 _regex_matcher(re.compile(r"\buseState\s*[(<]")),
154 "useState detected — Remotion compositions that drive animation via React state are not deterministic frame-capture targets in HyperFrames",
155 "Use the runtime interop pattern from PR #214 instead of attempting a translation",
156 ),
157 Rule(
158 "r2hf/use-reducer",
159 BLOCKER,
160 _regex_matcher(re.compile(r"\buseReducer\s*[(<]")),
161 "useReducer detected — same issue as useState",
162 "Use the runtime interop pattern from PR #214",
163 ),
164 Rule(
165 "r2hf/use-effect-deps",
166 BLOCKER,
167 _use_effect_with_deps,
168 "useEffect/useLayoutEffect with non-empty deps — side effects don't translate to HF's seek-driven model",
169 "Move the side-effect work into a build step, or use the runtime interop pattern",
170 ),
171 Rule(
172 "r2hf/async-metadata",
173 BLOCKER,
174 _regex_matcher(
175 re.compile(
176 r"calculateMetadata[^=]*=\s*async\b|async\s+calculateMetadata\b|calculateMetadata\s*:\s*async"
177 )
178 ),
179 "calculateMetadata returns a Promise — HF needs composition metadata up front",
180 "Resolve metadata at build time and pass concrete values, or use runtime interop",
181 ),
182 Rule(
183 "r2hf/third-party-react-ui",
184 BLOCKER,
185 _third_party_react_ui,
186 "Imports a third-party React UI library — no HF equivalent",
187 "Use runtime interop, or rewrite the affected components as HTML+CSS",
188 ),
189 # Lambda is a warning, not a blocker: it's deployment config, orthogonal
190 # to the rendered composition. The skill drops the import and translates
191 # the rest. See references/escape-hatch.md.
192 Rule(
193 "r2hf/lambda-import",
194 WARNING,
195 _regex_matcher(re.compile(r"from\s+['\"]@remotion/lambda['\"]")),
196 "@remotion/lambda is Remotion-specific distributed rendering — no HF equivalent today",
197 "Drop the Lambda config; HF runs single-machine. Document the gap in TRANSLATION_NOTES.md.",
198 ),
199 Rule(
200 "r2hf/delay-render",
201 WARNING,
202 _regex_matcher(re.compile(r"\bdelayRender\s*\(")),
203 "delayRender() — HF waits on asset readiness via the Frame Adapter pattern",
204 "Drop the call; HF handles this transparently",
205 ),
206 Rule(
207 "r2hf/use-callback",
208 WARNING,
209 _regex_matcher(re.compile(r"\buseCallback\s*\(")),
210 "useCallback — typically decorative for render performance, no HF equivalent needed",
211 "Drop the wrapper, inline the function",
212 ),
213 Rule(
214 "r2hf/use-memo",
215 WARNING,
216 _regex_matcher(re.compile(r"\buseMemo\s*\(")),
217 "useMemo — typically decorative, no HF equivalent needed",
218 "Drop the wrapper, compute inline",
219 ),
220 Rule(
221 "r2hf/custom-hook",
222 WARNING,
223 _custom_hook,
224 "Custom hook defined locally — may need manual rewrite",
225 "Inline the hook body if pure; bow out to runtime interop if it uses useState/useEffect",
226 ),
227 Rule(
228 "r2hf/static-file",
229 INFO,
230 _regex_matcher(re.compile(r"\bstaticFile\s*\(")),
231 "staticFile() reference — convert to a relative path in the HF composition",
232 "Replace `staticFile(\"x.png\")` with `\"x.png\"` and copy the asset alongside the HTML",
233 ),
234 Rule(
235 "r2hf/interpolate-colors",
236 INFO,
237 _regex_matcher(re.compile(r"\binterpolateColors\s*\(")),
238 "interpolateColors() — translate to a GSAP color tween",
239 "See references/timing.md for the GSAP equivalent",
240 ),
244def _find_matching_paren(src: str, open_idx: int) -> int | None:
245 """Given the index of an open `(`, return the index of its matching `)`.
247 Skips parens that appear inside `'...'`, `"..."`, or `` `...` `` string
248 literals. Returns None if no matching close paren is found.
250 This is good enough for hand-written Remotion source. It does not handle
251 template-literal interpolations `${...}` recursively or comments — both
252 are uncommon in Remotion code we expect to lint and would only matter
253 if the unbalanced paren landed inside such a region.
254 """
255 if open_idx >= len(src) or src[open_idx] != "(":
256 return None
257 depth = 0
258 i = open_idx
259 in_str: str | None = None
260 while i < len(src):
261 c = src[i]
262 if in_str is not None:
263 if c == "\\":
264 i += 2
265 continue
266 if c == in_str:
267 in_str = None
268 i += 1
269 continue
270 if c in ("'", '"', "`"):
271 in_str = c
272 i += 1
273 continue
274 if c == "(":
275 depth += 1
276 elif c == ")":
277 depth -= 1
278 if depth == 0:
279 return i
280 i += 1
281 return None
284def lint_file(path: Path) -> list[Finding]:
285 # Remotion sources are UTF-8. Left to the platform default, a source carrying an
286 # em dash or a curly quote raises UnicodeDecodeError on Windows before any rule runs.
287 src = path.read_text(encoding="utf-8")
288 findings: list[Finding] = []
290 def loc(offset: int) -> tuple[int, int]:
291 line = src.count("\n", 0, offset) + 1
292 col = offset - (src.rfind("\n", 0, offset) + 1) + 1
293 return line, col
295 for rule in RULES:
296 for offset, override_message in rule.matcher(src):
297 line, col = loc(offset)
298 findings.append(
299 Finding(
300 str(path),
301 line,
302 col,
303 rule.severity,
304 rule.rule_id,
305 override_message or rule.message,
306 rule.recommendation,
307 )
308 )
310 findings.sort(key=lambda f: (f.file, f.line, f.column))
311 return findings
314def main() -> int:
315 ap = argparse.ArgumentParser()
316 ap.add_argument("path", type=Path, help="Directory or file to lint")
317 ap.add_argument("--json", action="store_true", help="Emit JSON instead of human-readable output")
318 args = ap.parse_args()
320 if not args.path.exists():
321 print(f"error: {args.path} does not exist", file=sys.stderr)
322 return 2
324 files: list[Path]
325 if args.path.is_file():
326 files = [args.path]
327 else:
328 files = sorted(
329 p
330 for p in args.path.rglob("*")
331 if p.is_file()
332 and p.suffix in {".ts", ".tsx", ".jsx", ".js"}
333 and "node_modules" not in p.parts
334 )
336 all_findings: list[Finding] = []
337 for f in files:
338 all_findings.extend(lint_file(f))
340 blockers = sum(1 for f in all_findings if f.severity == BLOCKER)
341 warnings = sum(1 for f in all_findings if f.severity == WARNING)
342 infos = sum(1 for f in all_findings if f.severity == INFO)
344 if args.json:
345 json.dump(
346 {
347 "files_scanned": len(files),
348 "blockers": blockers,
349 "warnings": warnings,
350 "infos": infos,
351 "findings": [asdict(f) for f in all_findings],
352 },
353 sys.stdout,
354 indent=2,
355 )
356 sys.stdout.write("\n")
357 else:
358 for f in all_findings:
359 print(f"{f.file}:{f.line}:{f.column} [{f.severity}] {f.rule}: {f.message}")
360 print(f" -> {f.recommendation}")
361 print()
362 print(f"{len(files)} files scanned · {blockers} blocker · {warnings} warning · {infos} info")
363 if blockers:
364 print("RECOMMENDATION: do not attempt translation. Use the runtime interop pattern from PR #214.")
366 return 1 if blockers else 0
369if __name__ == "__main__":
370 sys.exit(main())