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.