Setting the file. One moment.
Lint Source · Remotion To Hyperframes · heygen-com/hyperframes · Skills Docs
ContentsBack to the top of the page
Number 32.14
Position 14 of 76
Type Python
Size 13 KB
Lines 370 scripts/ lint_source.py
Python · 370 lines · 13 KB
- message
17 - recommendation
18
19 Blockers (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)
25
26 Warnings (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
32
33 Info (translate and document):
34 - r2hf/static-file staticFile("x") — convert to relative path
35 - r2hf/interpolate-colors interpolateColors — translate to GSAP color tween
36 """
37
38 from __future__ import annotations
39
40 import argparse
41 import json
42 import re
43 import sys
44 from collections.abc import Iterable
45 from dataclasses import dataclass, asdict
46 from pathlib import Path
47 from typing import Callable
48
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.
55 for _stream in (sys.stdout, sys.stderr):
56 if hasattr (_stream, "reconfigure" ):
57 _stream.reconfigure( encoding = "utf-8" , errors = _stream.errors)
58
59 BLOCKER = "blocker"
60 WARNING = "warning"
61 INFO = "info"
62
63 THIRD_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" ,
72 }
73
74
75 @dataclass
76 class Finding :
77 file : str
78 line: int
79 column: int
80 severity: str
81 rule: str
82 message: str
83 recommendation: str
84
85
86 @dataclass
87 class Rule :
88 """A lint rule: a matcher that yields hits, plus the metadata each hit gets.
89
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 """
95
96 rule_id: str
97 severity: str
98 matcher: Callable[[ str ], Iterable[tuple[ int , str | None ]]]
99 message: str
100 recommendation: str
101
102
103 def _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
107
108 return _match
109
110
111 def _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 " \b use (?: 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
122
123
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 ,
127 )
128 _REMOTION_BUILTIN_HOOKS = { "useCurrentFrame" , "useVideoConfig" }
129
130
131 def _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"
137
138
139 _IMPORT_FROM = re.compile( r "from \s + [ ' \" ]([ ^' \" ] + )[ ' \" ] " )
140
141
142 def _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"
147
148
149 RULES : list[Rule] = [
150 Rule(
151 "r2hf/use-state" ,
152 BLOCKER ,
153 _regex_matcher(re.compile( r " \b useState \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 " \b useReducer \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 " \b delayRender \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 " \b useCallback \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 " \b useMemo \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 " \b staticFile \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 " \b interpolateColors \s * \( " )),
238 "interpolateColors() — translate to a GSAP color tween" ,
239 "See references/timing.md for the GSAP equivalent" ,
240 ),
241 ]
242
243
244 def _find_matching_paren (src: str , open_idx: int ) -> int | None :
245 """Given the index of an open `(`, return the index of its matching `)`.
246
247 Skips parens that appear inside `'...'`, `"..."`, or `` `...` `` string
248 literals. Returns None if no matching close paren is found.
249
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
282
283
284 def 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] = []
289
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
294
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 )
309
310 findings.sort( key =lambda f: (f.file, f.line, f.column))
311 return findings
312
313
314 def 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()
319
320 if not args.path.exists():
321 print ( f "error: { args.path } does not exist" , file = sys.stderr)
322 return 2
323
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 )
335
336 all_findings: list[Finding] = []
337 for f in files:
338 all_findings.extend(lint_file(f))
339
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 )
343
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." )
365
366 return 1 if blockers else 0
367
368
369 if __name__ == "__main__" :
370 sys.exit(main())