mirror of
https://github.com/hanzoai/frames.git
synced 2026-09-24 00:08:03 +00:00
Every SKILL.md already declared the new name — name: frames, frames-cli, frames-core, and so on — but the directories were never moved, so the tree carried frames-* in content and hyperframes-* on disk. Everything that resolves a skill by path missed: the core-skill list, the plugin marketplace pin, and the docs the CLI reads at runtime. Eight tests failed on main because the files they open do not exist. The directories now match what the files say they are, and the guide the claude-design contract reads takes the name that contract already used.
359 lines
12 KiB
Python
Executable File
359 lines
12 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Lint a Remotion project for patterns that don't translate cleanly to HyperFrames.
|
|
|
|
The skill should run this *before* attempting a translation. If any blocker
|
|
findings come back, the recommendation is to use the runtime interop pattern
|
|
from PR #214 instead of producing broken HTML.
|
|
|
|
Usage:
|
|
lint_source.py <path-to-remotion-src> [--json]
|
|
|
|
Output (default human-readable, --json for machine-readable):
|
|
For each .ts/.tsx file, a list of findings with:
|
|
- severity: blocker | warning | info
|
|
- line, column
|
|
- rule id
|
|
- message
|
|
- recommendation
|
|
|
|
Blockers (skill should refuse to translate):
|
|
- r2hf/use-state React state machine drives animation
|
|
- r2hf/use-effect-deps useEffect/useLayoutEffect with non-empty deps (side effects)
|
|
- r2hf/use-reducer useReducer drives animation
|
|
- r2hf/async-metadata calculateMetadata returns a Promise
|
|
- r2hf/third-party-react-ui Imports a React UI library (shadcn, mui, antd, mantine, chakra)
|
|
|
|
Warnings (translate but flag — drop the construct, keep the rest):
|
|
- r2hf/lambda-import @remotion/lambda configuration — drop, HF is single-machine
|
|
- r2hf/delay-render delayRender() — HF handles asset loading differently
|
|
- r2hf/use-callback useCallback — usually decorative, drop
|
|
- r2hf/use-memo useMemo — usually decorative, drop
|
|
- r2hf/custom-hook Custom hook (use*) defined locally; may need manual rewrite
|
|
|
|
Info (translate and document):
|
|
- r2hf/static-file staticFile("x") — convert to relative path
|
|
- r2hf/interpolate-colors interpolateColors — translate to GSAP color tween
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import re
|
|
import sys
|
|
from collections.abc import Iterable
|
|
from dataclasses import dataclass, asdict
|
|
from pathlib import Path
|
|
from typing import Callable
|
|
|
|
BLOCKER = "blocker"
|
|
WARNING = "warning"
|
|
INFO = "info"
|
|
|
|
THIRD_PARTY_UI_PACKAGES = {
|
|
"@mui/material",
|
|
"@mui/icons-material",
|
|
"@chakra-ui/react",
|
|
"@mantine/core",
|
|
"antd",
|
|
"@shadcn/ui",
|
|
"@radix-ui",
|
|
"@nextui-org/react",
|
|
}
|
|
|
|
|
|
@dataclass
|
|
class Finding:
|
|
file: str
|
|
line: int
|
|
column: int
|
|
severity: str
|
|
rule: str
|
|
message: str
|
|
recommendation: str
|
|
|
|
|
|
@dataclass
|
|
class Rule:
|
|
"""A lint rule: a matcher that yields hits, plus the metadata each hit gets.
|
|
|
|
A matcher is a function `src -> Iterable[(offset, override_message)]`. If
|
|
`override_message` is None, the rule's default `message` is used; matchers
|
|
that need to embed the matched text (custom-hook name, third-party package
|
|
name) return the customized message instead.
|
|
"""
|
|
|
|
rule_id: str
|
|
severity: str
|
|
matcher: Callable[[str], Iterable[tuple[int, str | None]]]
|
|
message: str
|
|
recommendation: str
|
|
|
|
|
|
def _regex_matcher(pattern: re.Pattern[str]) -> Callable[[str], Iterable[tuple[int, str | None]]]:
|
|
def _match(src: str) -> Iterable[tuple[int, str | None]]:
|
|
for m in pattern.finditer(src):
|
|
yield m.start(), None
|
|
|
|
return _match
|
|
|
|
|
|
def _use_effect_with_deps(src: str) -> Iterable[tuple[int, str | None]]:
|
|
# Find use(Layout)?Effect(, walk to its matching ), and check if the call
|
|
# ends with `, [<non-empty>])`. Empty `[]` is mount-only, allowed.
|
|
for m in re.finditer(r"\buse(?:Layout)?Effect\s*\(", src):
|
|
end = _find_matching_paren(src, m.end() - 1)
|
|
if end is None:
|
|
continue
|
|
call = src[m.start() : end + 1]
|
|
m2 = re.search(r",\s*\[([^\]]*)\]\s*$", call[:-1])
|
|
if m2 and m2.group(1).strip():
|
|
yield m.start(), None
|
|
|
|
|
|
_CUSTOM_HOOK_DECL = re.compile(
|
|
r"^\s*(?:export\s+(?:default\s+)?)?(?:function|const|let|var)\s+(use[A-Z]\w+)\b",
|
|
re.MULTILINE,
|
|
)
|
|
_REMOTION_BUILTIN_HOOKS = {"useCurrentFrame", "useVideoConfig"}
|
|
|
|
|
|
def _custom_hook(src: str) -> Iterable[tuple[int, str | None]]:
|
|
for m in _CUSTOM_HOOK_DECL.finditer(src):
|
|
name = m.group(1)
|
|
if name in _REMOTION_BUILTIN_HOOKS:
|
|
continue
|
|
yield m.start(), f"Custom hook `{name}` defined locally — may need manual rewrite"
|
|
|
|
|
|
_IMPORT_FROM = re.compile(r"from\s+['\"]([^'\"]+)['\"]")
|
|
|
|
|
|
def _third_party_react_ui(src: str) -> Iterable[tuple[int, str | None]]:
|
|
for m in _IMPORT_FROM.finditer(src):
|
|
pkg = m.group(1)
|
|
if any(pkg.startswith(p) for p in THIRD_PARTY_UI_PACKAGES):
|
|
yield m.start(), f"Imports `{pkg}` — third-party React UI library has no HF equivalent"
|
|
|
|
|
|
RULES: list[Rule] = [
|
|
Rule(
|
|
"r2hf/use-state",
|
|
BLOCKER,
|
|
_regex_matcher(re.compile(r"\buseState\s*[(<]")),
|
|
"useState detected — Remotion compositions that drive animation via React state are not deterministic frame-capture targets in HyperFrames",
|
|
"Use the runtime interop pattern from PR #214 instead of attempting a translation",
|
|
),
|
|
Rule(
|
|
"r2hf/use-reducer",
|
|
BLOCKER,
|
|
_regex_matcher(re.compile(r"\buseReducer\s*[(<]")),
|
|
"useReducer detected — same issue as useState",
|
|
"Use the runtime interop pattern from PR #214",
|
|
),
|
|
Rule(
|
|
"r2hf/use-effect-deps",
|
|
BLOCKER,
|
|
_use_effect_with_deps,
|
|
"useEffect/useLayoutEffect with non-empty deps — side effects don't translate to HF's seek-driven model",
|
|
"Move the side-effect work into a build step, or use the runtime interop pattern",
|
|
),
|
|
Rule(
|
|
"r2hf/async-metadata",
|
|
BLOCKER,
|
|
_regex_matcher(
|
|
re.compile(
|
|
r"calculateMetadata[^=]*=\s*async\b|async\s+calculateMetadata\b|calculateMetadata\s*:\s*async"
|
|
)
|
|
),
|
|
"calculateMetadata returns a Promise — HF needs composition metadata up front",
|
|
"Resolve metadata at build time and pass concrete values, or use runtime interop",
|
|
),
|
|
Rule(
|
|
"r2hf/third-party-react-ui",
|
|
BLOCKER,
|
|
_third_party_react_ui,
|
|
"Imports a third-party React UI library — no HF equivalent",
|
|
"Use runtime interop, or rewrite the affected components as HTML+CSS",
|
|
),
|
|
# Lambda is a warning, not a blocker: it's deployment config, orthogonal
|
|
# to the rendered composition. The skill drops the import and translates
|
|
# the rest. See references/escape-hatch.md.
|
|
Rule(
|
|
"r2hf/lambda-import",
|
|
WARNING,
|
|
_regex_matcher(re.compile(r"from\s+['\"]@remotion/lambda['\"]")),
|
|
"@remotion/lambda is Remotion-specific distributed rendering — no HF equivalent today",
|
|
"Drop the Lambda config; HF runs single-machine. Document the gap in TRANSLATION_NOTES.md.",
|
|
),
|
|
Rule(
|
|
"r2hf/delay-render",
|
|
WARNING,
|
|
_regex_matcher(re.compile(r"\bdelayRender\s*\(")),
|
|
"delayRender() — HF waits on asset readiness via the Frame Adapter pattern",
|
|
"Drop the call; HF handles this transparently",
|
|
),
|
|
Rule(
|
|
"r2hf/use-callback",
|
|
WARNING,
|
|
_regex_matcher(re.compile(r"\buseCallback\s*\(")),
|
|
"useCallback — typically decorative for render performance, no HF equivalent needed",
|
|
"Drop the wrapper, inline the function",
|
|
),
|
|
Rule(
|
|
"r2hf/use-memo",
|
|
WARNING,
|
|
_regex_matcher(re.compile(r"\buseMemo\s*\(")),
|
|
"useMemo — typically decorative, no HF equivalent needed",
|
|
"Drop the wrapper, compute inline",
|
|
),
|
|
Rule(
|
|
"r2hf/custom-hook",
|
|
WARNING,
|
|
_custom_hook,
|
|
"Custom hook defined locally — may need manual rewrite",
|
|
"Inline the hook body if pure; bow out to runtime interop if it uses useState/useEffect",
|
|
),
|
|
Rule(
|
|
"r2hf/static-file",
|
|
INFO,
|
|
_regex_matcher(re.compile(r"\bstaticFile\s*\(")),
|
|
"staticFile() reference — convert to a relative path in the HF composition",
|
|
"Replace `staticFile(\"x.png\")` with `\"x.png\"` and copy the asset alongside the HTML",
|
|
),
|
|
Rule(
|
|
"r2hf/interpolate-colors",
|
|
INFO,
|
|
_regex_matcher(re.compile(r"\binterpolateColors\s*\(")),
|
|
"interpolateColors() — translate to a GSAP color tween",
|
|
"See references/timing.md for the GSAP equivalent",
|
|
),
|
|
]
|
|
|
|
|
|
def _find_matching_paren(src: str, open_idx: int) -> int | None:
|
|
"""Given the index of an open `(`, return the index of its matching `)`.
|
|
|
|
Skips parens that appear inside `'...'`, `"..."`, or `` `...` `` string
|
|
literals. Returns None if no matching close paren is found.
|
|
|
|
This is good enough for hand-written Remotion source. It does not handle
|
|
template-literal interpolations `${...}` recursively or comments — both
|
|
are uncommon in Remotion code we expect to lint and would only matter
|
|
if the unbalanced paren landed inside such a region.
|
|
"""
|
|
if open_idx >= len(src) or src[open_idx] != "(":
|
|
return None
|
|
depth = 0
|
|
i = open_idx
|
|
in_str: str | None = None
|
|
while i < len(src):
|
|
c = src[i]
|
|
if in_str is not None:
|
|
if c == "\\":
|
|
i += 2
|
|
continue
|
|
if c == in_str:
|
|
in_str = None
|
|
i += 1
|
|
continue
|
|
if c in ("'", '"', "`"):
|
|
in_str = c
|
|
i += 1
|
|
continue
|
|
if c == "(":
|
|
depth += 1
|
|
elif c == ")":
|
|
depth -= 1
|
|
if depth == 0:
|
|
return i
|
|
i += 1
|
|
return None
|
|
|
|
|
|
def lint_file(path: Path) -> list[Finding]:
|
|
src = path.read_text()
|
|
findings: list[Finding] = []
|
|
|
|
def loc(offset: int) -> tuple[int, int]:
|
|
line = src.count("\n", 0, offset) + 1
|
|
col = offset - (src.rfind("\n", 0, offset) + 1) + 1
|
|
return line, col
|
|
|
|
for rule in RULES:
|
|
for offset, override_message in rule.matcher(src):
|
|
line, col = loc(offset)
|
|
findings.append(
|
|
Finding(
|
|
str(path),
|
|
line,
|
|
col,
|
|
rule.severity,
|
|
rule.rule_id,
|
|
override_message or rule.message,
|
|
rule.recommendation,
|
|
)
|
|
)
|
|
|
|
findings.sort(key=lambda f: (f.file, f.line, f.column))
|
|
return findings
|
|
|
|
|
|
def main() -> int:
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("path", type=Path, help="Directory or file to lint")
|
|
ap.add_argument("--json", action="store_true", help="Emit JSON instead of human-readable output")
|
|
args = ap.parse_args()
|
|
|
|
if not args.path.exists():
|
|
print(f"error: {args.path} does not exist", file=sys.stderr)
|
|
return 2
|
|
|
|
files: list[Path]
|
|
if args.path.is_file():
|
|
files = [args.path]
|
|
else:
|
|
files = sorted(
|
|
p
|
|
for p in args.path.rglob("*")
|
|
if p.is_file()
|
|
and p.suffix in {".ts", ".tsx", ".jsx", ".js"}
|
|
and "node_modules" not in p.parts
|
|
)
|
|
|
|
all_findings: list[Finding] = []
|
|
for f in files:
|
|
all_findings.extend(lint_file(f))
|
|
|
|
blockers = sum(1 for f in all_findings if f.severity == BLOCKER)
|
|
warnings = sum(1 for f in all_findings if f.severity == WARNING)
|
|
infos = sum(1 for f in all_findings if f.severity == INFO)
|
|
|
|
if args.json:
|
|
json.dump(
|
|
{
|
|
"files_scanned": len(files),
|
|
"blockers": blockers,
|
|
"warnings": warnings,
|
|
"infos": infos,
|
|
"findings": [asdict(f) for f in all_findings],
|
|
},
|
|
sys.stdout,
|
|
indent=2,
|
|
)
|
|
sys.stdout.write("\n")
|
|
else:
|
|
for f in all_findings:
|
|
print(f"{f.file}:{f.line}:{f.column} [{f.severity}] {f.rule}: {f.message}")
|
|
print(f" -> {f.recommendation}")
|
|
print()
|
|
print(f"{len(files)} files scanned · {blockers} blocker · {warnings} warning · {infos} info")
|
|
if blockers:
|
|
print("RECOMMENDATION: do not attempt translation. Use the runtime interop pattern from PR #214.")
|
|
|
|
return 1 if blockers else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|