Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions packages/lint/src/rules/composition.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,59 @@ describe("composition rules", () => {
expect(findings[0]?.fixHint).toContain('[data-composition-id="scene"][data-start="0"]');
});

it("does not report a template-literal selector that only appears in a comment or a string", async () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"><code id="snippet"></code></div>
<script>
window.__timelines = window.__timelines || {};
// Hardcoded on purpose — do NOT use document.querySelector(\`#\${id}\`) here.
const SAMPLE = 'document.querySelector(\`[data-composition-id="\${compId}"]\`)';
document.getElementById("snippet").textContent = SAMPLE;
window.__timelines["main"] = gsap.timeline({ paused: true });
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(result.findings.find((f) => f.code === "template_literal_selector")).toBeUndefined();
});

it("reports a template-literal selector in code position and quotes the real source", async () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
<script>
window.__timelines = window.__timelines || {};
const compId = "main";
const el = document.querySelector(\`[data-composition-id="\${compId}"]\`);
window.__timelines["main"] = gsap.timeline({ paused: true });
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "template_literal_selector");
expect(finding?.severity).toBe("error");
expect(finding?.snippet).toContain("data-composition-id");
});

it("does not report a split data-attribute selector written inside a CSS comment", async () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
<style>
/* never write [data-composition-id="main" data-start="0"] — split the brackets */
#root { background: #111; }
</style>
<script>
window.__timelines = window.__timelines || {};
// and not [data-composition-id="main" data-start="0"] in a JS comment either
window.__timelines["main"] = gsap.timeline({ paused: true });
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(result.findings.filter((f) => f.code === "split_data_attribute_selector")).toHaveLength(
0,
);
});

describe("timed_element_missing_clip_class", () => {
it("flags element with data-start but no class='clip'", async () => {
const html = `
Expand Down Expand Up @@ -768,6 +821,41 @@ describe("composition rules", () => {
);
expect(finding).toBeUndefined();
});

it("does not flag a call the composition only renders as on-screen text", async () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080"><code id="snippet"></code></div>
<script>
window.__timelines = window.__timelines || {};
const fn = "step";
document.getElementById("snippet").textContent = "requestAnimationFrame(step);";
document.getElementById("snippet").title = \`requestAnimationFrame(\${fn});\`;
window.__timelines["c1"] = gsap.timeline({ paused: true });
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(
result.findings.find((f) => f.code === "requestanimationframe_in_composition"),
).toBeUndefined();
});

it("still flags a call inside a template interpolation, which is code", async () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080"><code id="snippet"></code></div>
<script>
window.__timelines = window.__timelines || {};
const label = \`frame \${requestAnimationFrame(() => {})}\`;
document.getElementById("snippet").textContent = label;
window.__timelines["c1"] = gsap.timeline({ paused: true });
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(
result.findings.find((f) => f.code === "requestanimationframe_in_composition")?.severity,
).toBe("error");
});
});

describe("missing_data_no_timeline", () => {
Expand Down
18 changes: 13 additions & 5 deletions packages/lint/src/rules/composition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import {
readAttr,
readDecodedAttr,
readJsonAttr,
stripCssComments,
stripJsComments,
stripJsStringLiterals,
truncateSnippet,
WINDOW_TIMELINE_ASSIGN_PATTERN,
} from "../utils";
Expand Down Expand Up @@ -513,8 +515,9 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
});
}
};
for (const style of styles) scan(style.content);
for (const script of scripts) scan(script.content);
// Comments only, not string literals: the genuine defect lives inside a query string.
for (const style of styles) scan(stripCssComments(style.content));
for (const script of scripts) scan(stripJsComments(script.content));
return findings;
},

Expand All @@ -524,8 +527,10 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
for (const script of scripts) {
const templateLiteralSelectorPattern =
/(?:querySelector|querySelectorAll)\s*\(\s*`[^`]*\$\{[^}]+\}[^`]*`\s*\)/g;
// Offsets survive both strippers, so the snippet still quotes the real source.
const scanned = stripJsStringLiterals(stripJsComments(script.content));
let tlMatch: RegExpExecArray | null;
while ((tlMatch = templateLiteralSelectorPattern.exec(script.content)) !== null) {
while ((tlMatch = templateLiteralSelectorPattern.exec(scanned)) !== null) {
findings.push({
code: "template_literal_selector",
severity: "error",
Expand All @@ -534,7 +539,9 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
"The HTML bundler's CSS parser crashes on these. Use a hardcoded string instead.",
fixHint:
"Replace the template literal variable with a hardcoded string. The bundler's CSS parser cannot handle interpolated variables in script content.",
snippet: truncateSnippet(tlMatch[0]),
snippet: truncateSnippet(
script.content.slice(tlMatch.index, tlMatch.index + tlMatch[0].length),
),
});
}
}
Expand Down Expand Up @@ -731,7 +738,8 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
if (isRegistrySourceFile(options.filePath) || isRegistryInstalledFile(rawSource)) return [];
const findings: HyperframeLintFinding[] = [];
for (const script of scripts) {
const stripped = stripJsComments(script.content);
// Strings too, not just comments: a code-explainer composition renders the call as text.
const stripped = stripJsStringLiterals(stripJsComments(script.content));
if (/requestAnimationFrame\s*\(/.test(stripped)) {
findings.push({
code: "requestanimationframe_in_composition",
Expand Down
121 changes: 121 additions & 0 deletions packages/lint/src/utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { describe, it, expect } from "vitest";
import { stripCssComments, stripJsComments, stripJsStringLiterals } from "./utils.js";

const scan = (src: string) => stripJsStringLiterals(stripJsComments(src));
const findsRaf = (src: string) => /requestAnimationFrame\s*\(/.test(scan(src));

describe("stripJsStringLiterals", () => {
it("blanks a call the composition only renders as text", () => {
expect(findsRaf('const CODE = "requestAnimationFrame(step);";')).toBe(false);
expect(findsRaf("const CODE = `requestAnimationFrame(${fn});`;")).toBe(false);
});

it("keeps a call in a template interpolation, which is code", () => {
expect(findsRaf("const label = `frame ${requestAnimationFrame(cb)}`;")).toBe(true);
});

// A regex literal's own quotes must not open a phantom string: `/[^a-z']/g` ships in
// registry/components, and blanking its tail hid every later call in the script.
it.each([
"const re = /[\"']/g;",
"const re = /'/;",
"const re = /[`]/;",
"const r = s.split(/['\"]/);",
'const r = s.replace(/[^a-z\']/g, "");',
"if (a) { } /'/.test(x);",
"const a = b / c / d;",
"const p = i++ / total;",
"const q = --i / total;",
])("does not let %j blank the rest of the script", (prefix) => {
expect(findsRaf(`${prefix}\nrequestAnimationFrame(step);`)).toBe(true);
});

// Whitespace must end the trailing identifier: fusing `flag` + `return` across a
// newline read the regex as a division, and two such regexes balance the quote so
// the mid-literal fallback never fires — an unbounded blanked span.
it.each([
"function a(s) {\n let ok = flag\n return /'/.test(s)\n}\n",
"for (const x of /'/.source) {}\n",
])("keeps a call bracketed by two quote-bearing regexes after %j", (prefix) => {
expect(findsRaf(`${prefix}requestAnimationFrame(step);\n${prefix}`)).toBe(true);
});

// Damage from a mis-read regex is line-bounded, so the call must sit on the SAME
// line to pin it. A reserved word used as a property name is not keyword position.
it.each([
"const clip = { in: 0.5, out: 5.5 }; const r = clip.in / clip.out;",
"const r = data.new / 2;",
"const r = list.of / 2;",
"const r = sw.case / 2;",
"const r = o?.in / 2;",
"const p = i++ / total;",
"const q = --i / total;",
])("finds a call on the same line as %j", (prefix) => {
expect(findsRaf(`${prefix} requestAnimationFrame(step);`)).toBe(true);
});

it("falls back to the source when the scan ends mid-literal", () => {
// The trailing backslash escapes the closing quote, so the string runs to EOF.
const src = 'const p = "C:\\Users\\demo\\";\nrequestAnimationFrame(step);';
expect(scan(src)).toBe(src);
expect(findsRaf(src)).toBe(true);
});

it("preserves length and newline positions", () => {
for (const src of [
'const a = "x\\\ny";\nrequestAnimationFrame(step);',
"const t = `a\nb${x}c\nd`;",
"const r = /a\\/b/g;\n",
]) {
const out = scan(src);
expect(out.length).toBe(src.length);
expect([...out].filter((c) => c === "\n").length).toBe(
[...src].filter((c) => c === "\n").length,
);
}
});
});

describe("stripJsStringLiterals scaling", () => {
// Re-scanning the accumulated output per candidate slash was quadratic: a composition
// with one inlined vendor bundle linted 58x slower.
it("stays linear in slash-dense input", () => {
// Min of three: a single sample picks up GC and CI contention, which can both
// inflate `small` (hiding a regression) and inflate `large` (failing spuriously).
const time = (n: number) => {
const src = "a=b/c;".repeat(n);
let best = Infinity;
for (let run = 0; run < 3; run += 1) {
const started = performance.now();
stripJsStringLiterals(src);
best = Math.min(best, performance.now() - started);
}
return best;
};
const small = Math.max(time(20_000), 0.5);
const large = time(160_000);
// 8x the input; quadratic would be ~64x. Generous headroom for a noisy CI box.
expect(large / small).toBeLessThan(24);
// Absolute ceiling too: a noise-inflated `small` can hide a quadratic regression.
expect(large).toBeLessThan(400);
});
});

describe("stripCssComments", () => {
it("keeps a rule sandwiched between comment markers printed as content", () => {
const css =
'#o::before{content:"/*"}\n[data-composition-id="main" data-start="0"]{color:red}\n#c::after{content:"*/"}';
const out = stripCssComments(css);
expect(out.length).toBe(css.length);
expect(out).toContain('data-composition-id="main" data-start="0"');
});

it("blanks a real comment and an unterminated one, keeping length", () => {
for (const css of ["/* gone */#a{color:red}", "#a{color:red}/* open"]) {
const out = stripCssComments(css);
expect(out.length).toBe(css.length);
expect(out).not.toContain("/*");
expect(out).toContain("#a{color:red}");
}
});
});
Loading
Loading