From 7e8f31745364f112733bf4a392d94162f6e2cc1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sat, 5 Sep 2026 18:58:40 +0000 Subject: [PATCH] fix(lint): block executed invalid raw selectors --- packages/lint/src/rules/gsap.test.ts | 75 +++++++++++++++++++++ packages/lint/src/rules/gsap.ts | 50 ++++++++++++++ packages/parsers/src/gsapParserAcorn.ts | 88 +++++++++++++++++++++++++ 3 files changed, 213 insertions(+) diff --git a/packages/lint/src/rules/gsap.test.ts b/packages/lint/src/rules/gsap.test.ts index 97511a7cff..47f4d22e1a 100644 --- a/packages/lint/src/rules/gsap.test.ts +++ b/packages/lint/src/rules/gsap.test.ts @@ -3,6 +3,81 @@ import { describe, it, expect } from "vitest"; import { lintHyperframeHtml } from "../hyperframeLinter.js"; describe("GSAP rules", () => { + it("errors when a parsed GSAP tween executes a raw digit-leading id selector", async () => { + const html = ` + +
+
+
+ +`; + + const result = await lintHyperframeHtml(html); + + expect( + result.findings.find((finding) => finding.code === "invalid_raw_selector_execution"), + ).toMatchObject({ severity: "error", elementId: "123-frame", selector: "#123-frame" }); + expect( + result.findings.find((finding) => finding.code === "id_requires_css_escape")?.severity, + ).toBe("warning"); + }); + + it("errors for literal querySelector and querySelectorAll calls with raw unsafe ids", async () => { + const html = ` + +
+
+
+
+ +`; + + const result = await lintHyperframeHtml(html); + const findings = result.findings.filter( + (finding) => finding.code === "invalid_raw_selector_execution", + ); + + expect(findings.map((finding) => finding.elementId).sort()).toEqual(["123-frame", "456-card"]); + }); + + it("keeps warning-only behavior for unused and safely escaped digit-leading ids", async () => { + const html = ` + +
+
+
+
+
+ +`; + + const result = await lintHyperframeHtml(html); + + expect( + result.findings.filter((finding) => finding.code === "invalid_raw_selector_execution"), + ).toEqual([]); + expect( + result.findings.filter((finding) => finding.code === "id_requires_css_escape"), + ).toHaveLength(3); + }); + it("errors when window.__timelines is registered BEFORE the fonts.ready build", async () => { const html = ` diff --git a/packages/lint/src/rules/gsap.ts b/packages/lint/src/rules/gsap.ts index 6cfa54b17d..2f098eaef7 100644 --- a/packages/lint/src/rules/gsap.ts +++ b/packages/lint/src/rules/gsap.ts @@ -27,6 +27,20 @@ async function loadParseGsapScript(): Promise<(script: string) => LintParsedGsap return mod.parseGsapScriptAcorn as unknown as (script: string) => LintParsedGsap; } +async function loadExtractLiteralQuerySelectorCalls(): Promise< + (script: string) => Array<{ selector: string; raw: string }> +> { + const mod = await import("@hyperframes/parsers/gsap-parser-acorn"); + return mod.extractLiteralQuerySelectorCalls; +} + +async function loadExtractLiteralGsapSelectorCalls(): Promise< + (script: string) => Array<{ selector: string; raw: string }> +> { + const mod = await import("@hyperframes/parsers/gsap-parser-acorn"); + return mod.extractLiteralGsapSelectorCalls; +} + async function loadGsapScriptMotionPathFirstUseIndex(): Promise<(script: string) => number | null> { const mod = await import("@hyperframes/parsers/gsap-parser-acorn"); return mod.gsapScriptMotionPathFirstUseIndex; @@ -1040,6 +1054,42 @@ function collectCssOpacityZeroSelectors( // fallow-ignore-next-line complexity export const gsapRules: LintRule[] = [ + // invalid_raw_selector_execution + async ({ tags, scripts }) => { + const unsafeIds = tags + .map((tag) => readAttr(tag.raw, "id")) + .filter((id): id is string => Boolean(id && /^\d/.test(id))); + if (unsafeIds.length === 0) return []; + + const findUnsafeId = (selector: string): string | undefined => + unsafeIds.find((id) => new RegExp(`#${escapeRegExp(id)}(?![\\w-])`).test(selector)); + const findings: HyperframeLintFinding[] = []; + const reportedIds = new Set(); + const report = (selector: string, snippet: string) => { + const id = findUnsafeId(selector); + if (!id || reportedIds.has(id)) return; + reportedIds.add(id); + findings.push({ + code: "invalid_raw_selector_execution", + severity: "error", + message: `The raw selector "#${id}" is executed, but digit-leading IDs are not valid unescaped CSS selectors and throw a SyntaxError in the browser.`, + selector: `#${id}`, + elementId: id, + fixHint: + "Rename the id to start with a letter (recommended), or construct the selector with `#${CSS.escape(id)}` before passing it to GSAP/querySelector.", + snippet: truncateSnippet(snippet), + }); + }; + + const extractQueryCalls = await loadExtractLiteralQuerySelectorCalls(); + const extractGsapCalls = await loadExtractLiteralGsapSelectorCalls(); + for (const script of scripts) { + for (const call of extractGsapCalls(script.content)) report(call.selector, call.raw); + for (const call of extractQueryCalls(script.content)) report(call.selector, call.raw); + } + return findings; + }, + // overlapping_gsap_tweens + gsap_animates_clip_element // fallow-ignore-next-line complexity async ({ source, tags, scripts, styles, rootCompositionId }) => { diff --git a/packages/parsers/src/gsapParserAcorn.ts b/packages/parsers/src/gsapParserAcorn.ts index f8f4ef962c..9c1785dfea 100644 --- a/packages/parsers/src/gsapParserAcorn.ts +++ b/packages/parsers/src/gsapParserAcorn.ts @@ -62,6 +62,54 @@ function parseProgram(script: string): any { } } +export interface LiteralQuerySelectorCall { + selector: string; + raw: string; +} + +export interface LiteralGsapSelectorCall { + selector: string; + raw: string; +} + +/** + * Return statically literal selectors passed to querySelector/querySelectorAll. + * Dynamic expressions are intentionally omitted: callers use this only when + * the browser-invalid selector can be proven from source. + */ +export function extractLiteralQuerySelectorCalls(script: string): LiteralQuerySelectorCall[] { + try { + const ast = parseProgram(script); + const calls: LiteralQuerySelectorCall[] = []; + acornWalk.simple(ast, { + CallExpression(node: any) { + const callee = node.callee; + if (callee?.type !== "MemberExpression") return; + const method = callee.computed + ? callee.property?.type === "Literal" + ? callee.property.value + : undefined + : callee.property?.type === "Identifier" + ? callee.property.name + : undefined; + if (method !== "querySelector" && method !== "querySelectorAll") return; + const arg = node.arguments?.[0]; + const selector = + arg?.type === "Literal" && typeof arg.value === "string" + ? arg.value + : arg?.type === "TemplateLiteral" && arg.expressions?.length === 0 + ? arg.quasis?.[0]?.value?.cooked + : undefined; + if (typeof selector !== "string") return; + calls.push({ selector, raw: script.slice(node.start, node.end) }); + }, + }); + return calls; + } catch { + return []; + } +} + // ── Types ──────────────────────────────────────────────────────────────────── type ScopeBindings = ReadonlyMap; @@ -1850,6 +1898,46 @@ export function parseGsapScriptAcornForWrite(script: string): ParsedGsapAcornFor // ── Public API ──────────────────────────────────────────────────────────────── +/** + * Return only GSAP tween calls whose target argument is itself a string + * literal. Element variables and computed selectors are omitted, even when + * the full parser can resolve them to an equivalent selector. + */ +export function extractLiteralGsapSelectorCalls(script: string): LiteralGsapSelectorCall[] { + try { + const ast = parseProgram(script); + const scope = collectScopeBindings(ast); + const detection = findTimelineVar(ast, scope); + const ref: TimelineRef = detection.ref ?? { kind: "identifier", name: "tl" }; + const timelineVar = timelineRootSource(ref, script); + if (ref.kind === "identifier") { + try { + inlineComputedTimelines(ast, timelineVar, (node) => resolveNode(node, scope)); + } catch { + // Fall back to calls visible in the original AST. + } + } + const identifierBindings = collectIdentifierBindingIndex(ast); + const targetBindings = collectTargetBindings(ast, scope, identifierBindings); + const calls = findAllTweenCalls(ast, ref, scope, targetBindings); + const result: LiteralGsapSelectorCall[] = []; + for (const call of calls) { + const target = call.node.arguments?.[0]; + const selector = + target?.type === "Literal" && typeof target.value === "string" + ? target.value + : target?.type === "TemplateLiteral" && target.expressions?.length === 0 + ? target.quasis?.[0]?.value?.cooked + : undefined; + if (typeof selector !== "string") continue; + result.push({ selector, raw: script.slice(call.node.start, call.node.end) }); + } + return result; + } catch { + return []; + } +} + /** * Browser-safe equivalent of `parseGsapScript` (gsapParser.ts). * Uses acorn + acorn-walk instead of recast + @babel/parser.