diff --git a/packages/lint/src/rules/composition.test.ts b/packages/lint/src/rules/composition.test.ts index 701807f230..91452e58ac 100644 --- a/packages/lint/src/rules/composition.test.ts +++ b/packages/lint/src/rules/composition.test.ts @@ -474,6 +474,94 @@ describe("composition rules", () => { expect(findings[0]?.fixHint).toContain('[data-composition-id="scene"][data-start="0"]'); }); + it("keeps reporting a missing duration source when the same script defeats the lexer", async () => { + const html = ` + + +
+ +`; + const result = await lintHyperframeHtml(html, { filePath: "compositions/main.html" }); + const findings = result.findings.filter( + (f) => f.code === "root_composition_missing_duration_source", + ); + expect(findings.length).toBe(1); + expect(findings[0]?.severity).toBe("error"); + }); + + it("reports a split data-attribute selector that lives only in a script string literal", async () => { + const html = ` + + +
+ +`; + const result = await lintHyperframeHtml(html, { filePath: "compositions/main.html" }); + const findings = result.findings.filter((f) => f.code === "split_data_attribute_selector"); + expect(findings.length).toBe(1); + expect(findings[0]?.severity).toBe("error"); + }); + + it("does not report a template-literal selector that only appears in a comment or a string", async () => { + const 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 = ` + +
+ +`; + 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 = ` + +
+ + +`; + 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 = ` @@ -768,6 +856,41 @@ describe("composition rules", () => { ); expect(finding).toBeUndefined(); }); + + it("does not flag a call the composition only renders as on-screen text", async () => { + const 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 = ` + +
+ +`; + const result = await lintHyperframeHtml(html); + expect( + result.findings.find((f) => f.code === "requestanimationframe_in_composition")?.severity, + ).toBe("error"); + }); }); describe("missing_data_no_timeline", () => { diff --git a/packages/lint/src/rules/composition.ts b/packages/lint/src/rules/composition.ts index d72a10575a..46b3cc084a 100644 --- a/packages/lint/src/rules/composition.ts +++ b/packages/lint/src/rules/composition.ts @@ -4,7 +4,9 @@ import { readAttr, readDecodedAttr, readJsonAttr, + stripCssComments, stripJsComments, + stripJsCode, truncateSnippet, WINDOW_TIMELINE_ASSIGN_PATTERN, } from "../utils"; @@ -487,8 +489,8 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding }); } }; - for (const style of styles) scan(style.content); - for (const script of scripts) scan(script.content); + for (const style of styles) scan(stripCssComments(style.content)); + for (const script of scripts) scan(stripJsComments(script.content)); return findings; }, @@ -498,8 +500,9 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding for (const script of scripts) { const templateLiteralSelectorPattern = /(?:querySelector|querySelectorAll)\s*\(\s*`[^`]*\$\{[^}]+\}[^`]*`\s*\)/g; + const scanned = stripJsCode(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", @@ -508,7 +511,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), + ), }); } } @@ -648,7 +653,7 @@ 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); + const stripped = stripJsCode(script.content); if (/requestAnimationFrame\s*\(/.test(stripped)) { findings.push({ code: "requestanimationframe_in_composition", diff --git a/packages/lint/src/utils.test.ts b/packages/lint/src/utils.test.ts new file mode 100644 index 0000000000..a024e19bad --- /dev/null +++ b/packages/lint/src/utils.test.ts @@ -0,0 +1,149 @@ +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); + }); + + 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); + }); + + 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); + }); + + 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.each([ + "foo(/abc\nrequestAnimationFrame(step);", + "var of = 2;\nvar r = of /2;\nrequestAnimationFrame(step);", + "var q = 1;\nx = /a\\\nrequestAnimationFrame(step);", + ])("falls back to the source when a slash never closes on its line: %j", (src) => { + expect(scan(src)).toBe(src); + expect(findsRaf(src)).toBe(true); + }); + + it("falls back to the source when a backslash ends a mis-read regex line", () => { + const src = "var a = b in /x\\\n y = 'requestAnimationFrame(' / z /;"; + expect(scan(src)).toBe(src); + expect(findsRaf(src)).toBe(true); + }); + + it("falls back to the source when the scan ends mid-literal", () => { + 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", () => { + it("stays linear in slash-dense input", () => { + 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); + expect(large / small).toBeLessThan(24); + expect(large).toBeLessThan(2_000); + }); +}); + +describe("stripJsComments", () => { + const strip = (src: string) => stripJsComments(src); + + it("keeps a regex literal that ends in an escaped slash from opening a comment", () => { + const src = 'var proto = /^https?:\\/\\//; var el = document.querySelector("#hero");'; + expect(strip(src)).toBe(src); + }); + + it("still strips a real comment that follows a regex literal", () => { + const src = "var proto = /^https?:\\/\\//; // trailing note\nvar x = 1;"; + const out = strip(src); + expect(out).toContain("/^https?:\\/\\//;"); + expect(out).not.toContain("trailing note"); + expect(out).toHaveLength(src.length); + }); + + it("does not read a slash inside a string as a comment", () => { + const src = 'var s = "a // b"; var t = 1;'; + expect(strip(src)).toBe(src); + }); + + it("falls back to the source when a slash never closes on its line", () => { + const src = "var of = 2;\nvar r = of /2; // note\n"; + expect(strip(src)).toBe(src); + }); +}); + +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}"); + } + }); +}); diff --git a/packages/lint/src/utils.ts b/packages/lint/src/utils.ts index 4487d7f812..f6bc4b0e7a 100644 --- a/packages/lint/src/utils.ts +++ b/packages/lint/src/utils.ts @@ -335,24 +335,68 @@ export function stripStringLiterals(source: string): string { } // fallow-ignore-next-line complexity -export function stripJsComments(source: string): string { +function scanJsComments(source: string): { out: string; balanced: boolean } { let out = ""; let i = 0; let quote: "'" | '"' | "`" | null = null; let escaped = false; + let inRegex = false; + let inRegexClass = false; + let regexMisread = false; + const ctx = new CodeContext(); + + const emitCode = (ch: string) => { + out += ch; + ctx.push(ch); + }; + const emitOpaque = (ch: string) => { + out += ch; + ctx.push(" "); + }; while (i < source.length) { const ch = source[i] ?? ""; const next = source[i + 1] ?? ""; - if (quote) { + if (inRegex) { out += ch; if (escaped) { escaped = false; + if (ch === "\n" || ch === "\r") { + inRegex = false; + inRegexClass = false; + regexMisread = true; + } + } else if (ch === "\\") { + escaped = true; + } else if (ch === "[") { + inRegexClass = true; + } else if (ch === "]") { + inRegexClass = false; + } else if (ch === "/" && !inRegexClass) { + inRegex = false; + ctx.push(ch); + } else if (ch === "\n" || ch === "\r") { + inRegex = false; + inRegexClass = false; + regexMisread = true; + } + i += 1; + continue; + } + + if (quote) { + if (escaped) { + escaped = false; + emitOpaque(ch); } else if (ch === "\\") { escaped = true; + emitOpaque(ch); } else if (ch === quote) { quote = null; + emitCode(ch); + } else { + emitOpaque(ch); } i += 1; continue; @@ -360,16 +404,19 @@ export function stripJsComments(source: string): string { if (ch === "'" || ch === '"' || ch === "`") { quote = ch; - out += ch; + emitCode(ch); i += 1; continue; } if (ch === "/" && next === "/") { out += " "; + ctx.push(" "); + ctx.push(" "); i += 2; while (i < source.length && source[i] !== "\n" && source[i] !== "\r") { out += " "; + ctx.push(" "); i += 1; } continue; @@ -377,21 +424,272 @@ export function stripJsComments(source: string): string { if (ch === "/" && next === "*") { out += " "; + ctx.push(" "); + ctx.push(" "); i += 2; while (i < source.length) { const blockCh = source[i] ?? ""; const blockNext = source[i + 1] ?? ""; if (blockCh === "*" && blockNext === "/") { out += " "; + ctx.push(" "); + ctx.push(" "); i += 2; break; } - out += blockCh === "\n" || blockCh === "\r" ? blockCh : " "; + const kept = blockCh === "\n" || blockCh === "\r" ? blockCh : " "; + out += kept; + ctx.push(kept); i += 1; } continue; } + if (ch === "/" && ctx.startsRegexLiteral()) { + inRegex = true; + emitCode(ch); + i += 1; + continue; + } + + emitCode(ch); + i += 1; + } + + return { out, balanced: quote === null && !inRegex && !regexMisread }; +} + +export function stripJsComments(source: string): string { + return scanJsComments(source).out; +} + +export function stripJsCode(source: string): string { + const { out, balanced } = scanJsComments(source); + return balanced ? stripJsStringLiterals(out) : source; +} + +const REGEX_ALLOWED_BEFORE = new Set("=(,:[!&|?{};+-*%^~<>"); +const REGEX_ALLOWED_KEYWORDS = new Set([ + "return", + "typeof", + "instanceof", + "in", + "of", + "new", + "delete", + "void", + "case", + "do", + "else", + "yield", + "await", +]); + +const WORD_CHAR = /[A-Za-z0-9_$]/; + +/** + * Tracks just enough emitted context to tell a regex literal from a division: the last + * two significant characters and the trailing identifier. Carried incrementally because + * re-scanning the accumulated output per candidate slash is quadratic — a composition + * with one inlined vendor bundle took 58x longer to lint. + */ +class CodeContext { + private last = ""; + private prev = ""; + private word = ""; + private wordEnded = false; + private wordAfterDot = false; + + push(ch: string): void { + if (ch === " " || ch === "\t" || ch === "\n" || ch === "\r") { + this.wordEnded = true; + return; + } + if (WORD_CHAR.test(ch)) { + if (this.wordEnded || this.word === "") this.wordAfterDot = this.last === "."; + this.word = this.wordEnded ? ch : this.word + ch; + } else { + this.word = ""; + this.wordAfterDot = false; + } + this.wordEnded = false; + this.prev = this.last; + this.last = ch; + } + + startsRegexLiteral(): boolean { + if (this.last === "") return true; + if (WORD_CHAR.test(this.last)) + return !this.wordAfterDot && REGEX_ALLOWED_KEYWORDS.has(this.word); + if ((this.last === "+" || this.last === "-") && this.prev === this.last) return false; + return REGEX_ALLOWED_BEFORE.has(this.last); + } +} + +/** + * Blanks string, template-literal and regex-literal *contents* (delimiters, length + * and newline positions kept) so a rule scanning for an API call does not match one + * a composition merely renders as on-screen text. Template `${…}` expressions stay — + * they are code. Returns the source untouched if the scan ends mid-literal, so a + * parse this scanner cannot model degrades to the caller's pre-existing behaviour + * rather than silently blanking real code on an `error`-severity gate. + */ +// fallow-ignore-next-line complexity +export function stripJsStringLiterals(source: string): string { + let out = ""; + let i = 0; + const templateBraces: number[] = []; + const ctx = new CodeContext(); + let quote: "'" | '"' | "`" | null = null; + let escaped = false; + let inRegex = false; + let inRegexClass = false; + let regexMisread = false; + + const blank = (ch: string) => (ch === "\n" || ch === "\r" ? ch : " "); + const emit = (text: string) => { + out += text; + for (const ch of text) ctx.push(ch); + }; + + while (i < source.length) { + const ch = source[i] ?? ""; + const next = source[i + 1] ?? ""; + + if (inRegex) { + if (escaped) { + escaped = false; + if (ch === "\n" || ch === "\r") { + inRegex = false; + inRegexClass = false; + regexMisread = true; + } + emit(blank(ch)); + } else if (ch === "\\") { + escaped = true; + emit(" "); + } else if (ch === "[") { + inRegexClass = true; + emit(" "); + } else if (ch === "]") { + inRegexClass = false; + emit(" "); + } else if (ch === "/" && !inRegexClass) { + inRegex = false; + emit(ch); + } else if (ch === "\n" || ch === "\r") { + inRegex = false; + inRegexClass = false; + escaped = false; + regexMisread = true; + emit(ch); + } else { + emit(" "); + } + i += 1; + continue; + } + + if (quote) { + if (escaped) { + escaped = false; + emit(blank(ch)); + } else if (ch === "\\") { + escaped = true; + emit(" "); + } else if (ch === quote) { + quote = null; + emit(ch); + } else if (ch === "`" || quote !== "`" || ch !== "$" || next !== "{") { + emit(blank(ch)); + } else { + templateBraces.push(0); + quote = null; + emit("${"); + i += 2; + continue; + } + i += 1; + continue; + } + + if (ch === "'" || ch === '"' || ch === "`") { + quote = ch; + emit(ch); + i += 1; + continue; + } + + if (ch === "/" && next !== "/" && next !== "*" && ctx.startsRegexLiteral()) { + inRegex = true; + emit(ch); + i += 1; + continue; + } + + if (templateBraces.length > 0) { + const depth = templateBraces[templateBraces.length - 1] ?? 0; + if (ch === "{") templateBraces[templateBraces.length - 1] = depth + 1; + else if (ch === "}") { + if (depth === 0) { + templateBraces.pop(); + quote = "`"; + emit(ch); + i += 1; + continue; + } + templateBraces[templateBraces.length - 1] = depth - 1; + } + } + + emit(ch); + i += 1; + } + + if (quote !== null || templateBraces.length > 0 || inRegex || regexMisread) return source; + return out; +} + +/** + * Drops CSS comments without following a `/*` that only appears inside a string — + * a slide printing comment markers as content otherwise pairs two of them and + * deletes the real rules in between. + */ +// fallow-ignore-next-line complexity +export function stripCssComments(source: string): string { + let out = ""; + let i = 0; + let quote: "'" | '"' | null = null; + + while (i < source.length) { + const ch = source[i] ?? ""; + if (quote) { + out += ch; + if (ch === "\\") { + out += source[i + 1] ?? ""; + i += 2; + continue; + } + if (ch === quote) quote = null; + i += 1; + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + out += ch; + i += 1; + continue; + } + if (ch === "/" && source[i + 1] === "*") { + const end = source.indexOf("*/", i + 2); + const stop = end === -1 ? source.length : end + 2; + for (let j = i; j < stop; j += 1) { + const c = source[j] ?? ""; + out += c === "\n" || c === "\r" ? c : " "; + } + i = stop; + continue; + } out += ch; i += 1; }