fix(lint): catch CSS↔GSAP transform conflicts in scoped selectors and frame sub-compositions

gsap_css_transform_conflict existed but missed the most common real-world
shape (a label centered with CSS translateX(-50%) plus a GSAP xPercent that
stacks to -100% in the capture path), for three independent reasons:

- selector matching was exact-string, so a scoped/grouped GSAP selector
  ("#root .label, #root .sub") never matched a CSS class rule (.label)
- the acorn parser only captures timeline-rooted calls (tl.to/tl.set), so a
  standalone gsap.set("#root .label", { xPercent: -50 }) was invisible to it
- lintProject read compositions/ non-recursively, so per-frame compositions
  in compositions/frames/*.html were never linted at all

Fix: token-decompose grouped/descendant/compound selectors and match by
id/class against CSS transform rules; additionally scan standalone gsap.*
transform calls; and recurse into compositions/ subdirectories so frame
sub-compositions are linted.

Adds unit tests (grouped gsap.set repro, descendant tl.to, negative case) and
an end-to-end lintProject test that writes compositions/frames/04-*.html and
asserts the conflict is reported there.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Miao Yang
2026-06-23 15:53:36 +08:00
co-authored by Claude Opus 4.8
parent d596379e52
commit 9ccae86372
4 changed files with 220 additions and 11 deletions
@@ -82,6 +82,47 @@ describe("lintProject", () => {
expect(mediaFinding).toBeDefined();
});
it("recurses into compositions/frames/ and flags a CSS↔GSAP transform conflict there", async () => {
// End-to-end guard: a per-frame composition under compositions/frames/ that
// seats centering via a standalone gsap.set on a grouped #root-scoped selector
// against a CSS class transform — the exact shape that shipped off-centre.
// Both the recursive discovery and the strengthened rule must fire.
const dir = tmpProject("lint-frames");
dirs.push(dir);
writeFileSync(join(dir, "index.html"), validHtml());
const framesDir = join(dir, "compositions", "frames");
mkdirSync(framesDir, { recursive: true });
const frameHtml = `<template data-composition-id="04-mechanism">
<div id="m04-root" data-width="1920" data-height="1080">
<div class="m04-label">edit op</div>
</div>
<style> .m04-label { position: absolute; left: 960px; transform: translateX(-50%); } </style>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
gsap.set("#m04-root .m04-label", { xPercent: -50 });
tl.to(".m04-label", { y: 0, opacity: 1, duration: 0.4 }, 0.5);
window.__timelines["04-mechanism"] = tl;
</script>
</template>`;
writeFileSync(join(framesDir, "04-mechanism.html"), frameHtml);
const project: ProjectDir = {
dir,
name: "test-project",
indexPath: join(dir, "index.html"),
};
const { results } = await lintProject(project);
const frameResult = results.find((r) => r.file === "compositions/frames/04-mechanism.html");
expect(frameResult).toBeDefined();
const conflict = frameResult?.result.findings.find(
(f) => f.code === "gsap_css_transform_conflict",
);
expect(conflict).toBeDefined();
});
it("lints sub-compositions in compositions/ directory", async () => {
const project = makeProject(validHtml(), {
"captions.html": htmlWithMissingMediaId(),
+13 -1
View File
@@ -200,7 +200,19 @@ export async function lintProject(project: ProjectDir): Promise<ProjectLintResul
const allHtmlSources: HtmlSource[] = [{ html: rootHtml }];
const compositionsDir = resolve(project.dir, "compositions");
if (existsSync(compositionsDir)) {
const files = readdirSync(compositionsDir).filter((f) => f.endsWith(".html"));
// Recurse: per-frame compositions live in nested dirs (e.g. compositions/frames/*.html).
// A non-recursive readdir silently skipped them, so sub-composition rules never ran on
// the frames that make up the video. Walk the whole tree; keep posix-style src paths.
const collectHtmlFiles = (dir: string, rel: string): string[] => {
const out: string[] = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const relPath = rel ? `${rel}/${entry.name}` : entry.name;
if (entry.isDirectory()) out.push(...collectHtmlFiles(join(dir, entry.name), relPath));
else if (entry.isFile() && entry.name.endsWith(".html")) out.push(relPath);
}
return out;
};
const files = collectHtmlFiles(compositionsDir, "").sort();
for (const file of files) {
const filePath = join(compositionsDir, file);
const html = readFileSync(filePath, "utf-8");