diff --git a/packages/lint/src/rules/gsap.test.ts b/packages/lint/src/rules/gsap.test.ts
index 687b36d2e6..2724880427 100644
--- a/packages/lint/src/rules/gsap.test.ts
+++ b/packages/lint/src/rules/gsap.test.ts
@@ -2879,6 +2879,1156 @@ describe("GSAP seek-order safety rules", () => {
const finding = result.findings.find((f) => f.code === "gsap_callback_dom_measurement");
expect(finding).toBeUndefined();
});
+
+ it("gsap_callback_dom_measurement: flags a shared helper's measuring branch but clears its non-measuring branch (boolean)", async () => {
+ // Distilled from a production report: a shared style-update helper's `if`
+ // branch measures, its `else` branch only writes style. A caller passing a
+ // literal `true`/`false` for that branch's own parameter should be judged
+ // by which branch it actually reaches, not by "does the helper measure ANYWHERE."
+ const html = `
+
+
+
+`;
+ const result = await lintHyperframeHtml(html);
+ const findings = result.findings.filter((f) => f.code === "gsap_callback_dom_measurement");
+ expect(findings.length).toBe(1);
+ expect(findings[0]?.selector).toContain("cardA");
+ });
+
+ it("gsap_callback_dom_measurement: resolves a switch/string-mode shared helper's literal branch the same way", async () => {
+ const html = `
+
+
+
+`;
+ const result = await lintHyperframeHtml(html);
+ const findings = result.findings.filter((f) => f.code === "gsap_callback_dom_measurement");
+ expect(findings.length).toBe(1);
+ expect(findings[0]?.selector).toContain("cardA");
+ });
+
+ it("gsap_callback_dom_measurement: does NOT flag a bare, uncalled mention of a measuring function's name", async () => {
+ // A callback that merely references a tainted helper's identifier (no
+ // invocation) never reaches its measurement — unlike the transitive-call
+ // propagation between named functions, which already requires a call.
+ const html = `
+
+
+
+`;
+ const result = await lintHyperframeHtml(html);
+ const finding = result.findings.find((f) => f.code === "gsap_callback_dom_measurement");
+ expect(finding).toBeUndefined();
+ });
+
+ it("gsap_callback_dom_measurement: still conservatively flags a shared-helper call whose branch argument isn't a literal", async () => {
+ const html = `
+
+
+
+`;
+ const result = await lintHyperframeHtml(html);
+ const finding = result.findings.find((f) => f.code === "gsap_callback_dom_measurement");
+ expect(finding).toBeDefined();
+ });
+
+ it("gsap_callback_dom_measurement: a comma inside a string argument doesn't misalign later literal arguments", async () => {
+ // A string argument whose text itself contains a bare "true"/"false"
+ // token between commas (e.g. "x, true, y") must not let a naive
+ // (non-string-aware) comma split misread that inner token as a LATER
+ // param's literal — here `useMeasurement` is really `false` (real
+ // branch: getBoundingClientRect, must flag), but a split that doesn't
+ // track string boundaries produces a stray " true" fragment that lands
+ // on useMeasurement's index and incorrectly clears this call.
+ const html = `
+
+
+
+`;
+ const result = await lintHyperframeHtml(html);
+ const finding = result.findings.find((f) => f.code === "gsap_callback_dom_measurement");
+ expect(finding).toBeDefined();
+ });
+
+ it("gsap_callback_dom_measurement: a comma inside an array-literal argument doesn't misalign a later literal argument", async () => {
+ // An array-literal argument (`[1, 2]`) contains a comma that must NOT be
+ // treated as a top-level argument separator — bracket-depth tracking has
+ // to count `[`/`]` just like `(`/`)`/`{`/`}`, or the split misaligns
+ // every argument after it. Here `useMeasurement` (real value: `true`,
+ // selecting the SAFE branch) would land one position later than the real
+ // literal reaches with a broken split, leaving it unresolved and
+ // conservatively (here, incorrectly) flagged.
+ const html = `
+
+
+
+`;
+ const result = await lintHyperframeHtml(html);
+ const finding = result.findings.find((f) => f.code === "gsap_callback_dom_measurement");
+ expect(finding).toBeUndefined();
+ });
+
+ it("gsap_callback_dom_measurement: a param reassigned before the branch is not substituted (fails closed)", async () => {
+ // useMeasurement is flipped before the `if`, so the caller's literal
+ // `false` no longer reflects what the branch actually tests (it becomes
+ // `true`, which measures). A naive substitution of the caller's raw
+ // literal would resolve the (wrong) non-measuring branch and clear this
+ // — the fix must detect the reassignment and fall back to conservative.
+ const html = `
+
+
+
+`;
+ const result = await lintHyperframeHtml(html);
+ const finding = result.findings.find((f) => f.code === "gsap_callback_dom_measurement");
+ expect(finding).toBeDefined();
+ });
+
+ it("gsap_callback_dom_measurement: a param shadowed by a block-scoped let/const/var before the branch is not substituted (fails closed)", async () => {
+ // The `if (useMeasurement)` inside the block reads the block-scoped
+ // `let useMeasurement` shadow (always undefined/falsy), NOT the outer
+ // parameter — so its branch is fixed at runtime regardless of the
+ // caller's argument (always takes the else branch here, which measures).
+ // Naively substituting the caller's literal `true` for the (wrongly
+ // assumed to be) same binding would resolve the then-branch
+ // (non-measuring) and incorrectly clear this call. Deliberately no
+ // initializer (`let useMeasurement;`, not `= false`): an initializer like
+ // `= false` would ALSO match the plain-reassignment check on its own
+ // (`useMeasurement =` looks like a reassignment regardless of the `let`
+ // in front of it), so it wouldn't isolate the redeclaration guard
+ // specifically. Unlike an unconditional measurement elsewhere in the
+ // function, this isn't caught by the "measures outside the statement"
+ // check either — the only measurement is INSIDE this very if/else.
+ const html = `
+
+
+
+`;
+ const result = await lintHyperframeHtml(html);
+ const finding = result.findings.find((f) => f.code === "gsap_callback_dom_measurement");
+ expect(finding).toBeDefined();
+ });
+
+ it("gsap_callback_dom_measurement: a param shadowed by a nested arrow's own parameter is not substituted (fails closed)", async () => {
+ // The IIFE always runs with a fixed `false` — real behavior (always
+ // measures) is independent of what the caller passes to applyPhaseStyle.
+ // The only textual `if (useMeasurement)` in the body belongs to the
+ // arrow's own shadowed parameter; naively substituting the caller's
+ // literal `true` for it would resolve the non-measuring branch and
+ // incorrectly clear. (Deliberately an IIFE, not a separately-named
+ // helper: a named helper gets its own whole-function-taint entry and
+ // its call site would be caught by the "measures outside the statement"
+ // guard regardless of shadow detection, which wouldn't isolate this
+ // guard specifically.)
+ const html = `
+
+
+
+`;
+ const result = await lintHyperframeHtml(html);
+ const finding = result.findings.find((f) => f.code === "gsap_callback_dom_measurement");
+ expect(finding).toBeDefined();
+ });
+
+ it("gsap_callback_dom_measurement: a param shadowed by a nested function's own parameter is not substituted (fails closed)", async () => {
+ // Same idea as the nested-arrow case above, using an IIFE'd `function`
+ // expression's parameter instead of an arrow's.
+ const html = `
+
+
+
+`;
+ const result = await lintHyperframeHtml(html);
+ const finding = result.findings.find((f) => f.code === "gsap_callback_dom_measurement");
+ expect(finding).toBeDefined();
+ });
+
+ it("gsap_callback_dom_measurement: a param shadowed by a destructured binding before the branch is not substituted (fails closed)", async () => {
+ const html = `
+
+
+
+`;
+ const result = await lintHyperframeHtml(html);
+ const finding = result.findings.find((f) => f.code === "gsap_callback_dom_measurement");
+ expect(finding).toBeDefined();
+ });
+
+ it("gsap_callback_dom_measurement: a fall-through case (no break) is not resolved as just its own body (fails closed)", async () => {
+ // Case "safe" has no break, so it falls through into case "measure" at
+ // runtime — the real behavior for mode "safe" includes the measurement.
+ // Resolving it as just its own (non-measuring) slice would clear it.
+ const html = `
+
+
+
+`;
+ const result = await lintHyperframeHtml(html);
+ const finding = result.findings.find((f) => f.code === "gsap_callback_dom_measurement");
+ expect(finding).toBeDefined();
+ });
+
+ it("gsap_callback_dom_measurement: a 'break' inside a string literal doesn't look like a real terminator", async () => {
+ // Case "safe" has no ACTUAL break — `el.setAttribute('data-mode', 'break')`
+ // just happens to contain the word "break" inside a string argument — so
+ // it still falls through into case "measure" at runtime. A terminator
+ // check that isn't string-literal-aware would be fooled by that
+ // substring into treating "safe" as validly terminated.
+ const html = `
+
+
+
+`;
+ const result = await lintHyperframeHtml(html);
+ const finding = result.findings.find((f) => f.code === "gsap_callback_dom_measurement");
+ expect(finding).toBeDefined();
+ });
+
+ it("gsap_callback_dom_measurement: a param shadowed by an object method shorthand's own parameter is not substituted (fails closed)", async () => {
+ // `handlers.run` is an object-literal shorthand method whose OWN
+ // parameter shadows the outer `useMeasurement` — unlike a `function`
+ // declaration or an arrow, a shorthand method has neither the `function`
+ // keyword nor `=>`, so it needs its own shadow-detection pattern. The
+ // IIFE-style immediate call (rather than a separately named/exported
+ // helper) keeps this isolated from the "measures outside the statement"
+ // guard, same reasoning as the nested-arrow/nested-function tests above.
+ const html = `
+
+
+
+`;
+ const result = await lintHyperframeHtml(html);
+ const finding = result.findings.find((f) => f.code === "gsap_callback_dom_measurement");
+ expect(finding).toBeDefined();
+ });
+
+ it("gsap_callback_dom_measurement: a param shadowed by a catch clause's own parameter is not substituted (fails closed)", async () => {
+ // The caught error object is always truthy, so `if (useMeasurement)`
+ // ALWAYS takes the measuring branch here, regardless of what the caller
+ // passes for the outer `useMeasurement` parameter. Naively substituting
+ // the caller's literal `false` would resolve the non-measuring else
+ // branch and incorrectly clear a call that always measures.
+ const html = `
+
+
+
+`;
+ const result = await lintHyperframeHtml(html);
+ const finding = result.findings.find((f) => f.code === "gsap_callback_dom_measurement");
+ expect(finding).toBeDefined();
+ });
+
+ it("gsap_callback_dom_measurement: a case/default match doesn't fire mid-identifier", async () => {
+ // "breakcase" contains "case" as a suffix. Without a leading word-boundary
+ // check, the case-body slice truncates right there — and because the cut
+ // lands immediately after "break" (the "break" prefix of "breakcase"),
+ // the truncated fragment spuriously LOOKS like a valid `...break` ending,
+ // so it isn't caught by the separate no-fallthrough terminator check
+ // either. The real getBoundingClientRect() call (after the "breakcase"
+ // declaration) is silently dropped from the analyzed case body.
+ const html = `
+
+
+
+`;
+ const result = await lintHyperframeHtml(html);
+ const finding = result.findings.find((f) => f.code === "gsap_callback_dom_measurement");
+ expect(finding).toBeDefined();
+ });
+
+ it("gsap_callback_dom_measurement: an unconditional measurement outside the branch taints every call", async () => {
+ // getBoundingClientRect() runs unconditionally before the if/else, so
+ // both branches are effectively measuring regardless of the argument.
+ const html = `
+
+
+
+`;
+ const result = await lintHyperframeHtml(html);
+ const finding = result.findings.find((f) => f.code === "gsap_callback_dom_measurement");
+ expect(finding).toBeDefined();
+ });
+
+ it("gsap_callback_dom_measurement: an empty fall-through case body (no statements of its own) is unresolved", async () => {
+ // case "a" has no body of its own before falling into case "b" — this is
+ // distinct from the "no break" test above (which has a non-empty body).
+ const html = `
+
+
+
+`;
+ const result = await lintHyperframeHtml(html);
+ const finding = result.findings.find((f) => f.code === "gsap_callback_dom_measurement");
+ expect(finding).toBeDefined();
+ });
+
+ it("gsap_callback_dom_measurement: a negated if/else (if (!param)) resolves each literal to the correct branch", async () => {
+ const html = `
+
+
+
+`;
+ const result = await lintHyperframeHtml(html);
+ const findings = result.findings.filter((f) => f.code === "gsap_callback_dom_measurement");
+ expect(findings.length).toBe(1);
+ expect(findings[0]?.selector).toContain("cardA");
+ });
+
+ it("gsap_callback_dom_measurement: a break nested inside a conditional does not terminate the case (fails closed)", async () => {
+ // The `break` only fires when `window.__cond` is true — it does NOT
+ // unconditionally end case "a", so the case can still fall through into
+ // case "b"'s measurement. A terminator check that only looks at the
+ // TRAILING text (not whether the terminator sits at the case's own
+ // top level) would be fooled by the nested `break` into treating "a" as
+ // safely terminated.
+ const html = `
+
+
+
+`;
+ const result = await lintHyperframeHtml(html);
+ const finding = result.findings.find((f) => f.code === "gsap_callback_dom_measurement");
+ expect(finding).toBeDefined();
+ });
+
+ it("gsap_callback_dom_measurement: a case search is depth-aware and doesn't match a same-valued case nested in a sibling case (fails closed)", async () => {
+ // The REAL case "b" (top-level, measures) sits after case "a", which
+ // itself contains an unrelated NESTED switch with its own (non-measuring)
+ // case "b". A depth-unaware search for `case "b":` would match the
+ // nested one first — the first TEXTUAL occurrence — and wrongly resolve
+ // using its body instead of the real top-level case's.
+ const html = `
+
+
+
+`;
+ const result = await lintHyperframeHtml(html);
+ const finding = result.findings.find((f) => f.code === "gsap_callback_dom_measurement");
+ expect(finding).toBeDefined();
+ });
+
+ it("gsap_callback_dom_measurement: a literal with no matching TOP-LEVEL case anywhere in the switch bails closed (every occurrence is nested)", async () => {
+ // Every textual occurrence of `case "b":` is nested inside case "a"'s own
+ // inner switch — there is no top-level case "b" at all. findTopLevelCaseMatch
+ // must reject every candidate and return null, and the caller must treat
+ // that as unresolved (conservative) rather than as "case not found, so
+ // nothing to worry about." The measurement lives in case "c" (unreached
+ // by this call's "b" argument) purely so `alignPanel` enters the
+ // `measuring` set to begin with — without ANY measurement anywhere in the
+ // body, the function is never tainted in the first place and this
+ // resolution path never runs.
+ const html = `
+
+
+
+`;
+ const result = await lintHyperframeHtml(html);
+ const finding = result.findings.find((f) => f.code === "gsap_callback_dom_measurement");
+ expect(finding).toBeDefined();
+ });
+
+ it("gsap_callback_dom_measurement: a spread argument before a literal is not substituted (fails closed)", async () => {
+ // `...args` can expand to any number of elements at runtime, so the
+ // textually-later `false` doesn't reliably bind to `useMeasurement` —
+ // resolving it anyway (as if positions were unaffected by the spread)
+ // could substitute the wrong branch.
+ const html = `
+
+
+
+`;
+ const result = await lintHyperframeHtml(html);
+ const finding = result.findings.find((f) => f.code === "gsap_callback_dom_measurement");
+ expect(finding).toBeDefined();
+ });
+
+ it("gsap_callback_dom_measurement: a rebind inside a loop-wrapped branch is not substituted, even though it's textually after the if (fails closed)", async () => {
+ // On the first loop iteration `useMeasurement` is false (style only), but
+ // the else branch flips it to true — so the SECOND iteration's `if`
+ // measures. A rebind scan limited to the text BEFORE the if misses this,
+ // since the reassignment sits inside the else branch, textually after.
+ const html = `
+
+
+
+`;
+ const result = await lintHyperframeHtml(html);
+ const finding = result.findings.find((f) => f.code === "gsap_callback_dom_measurement");
+ expect(finding).toBeDefined();
+ });
+
+ it("gsap_callback_dom_measurement: a param shadowed by a nested function DECLARATION reusing its name is not substituted (fails closed)", async () => {
+ // `function useMeasurement() {}` re-declares the identifier inside the
+ // function's own scope — the shadow patterns for nested parameters
+ // (`function foo(useMeasurement)`) don't cover a nested function whose
+ // own NAME, not parameter, reuses the outer param.
+ const html = `
+
+
+
+`;
+ const result = await lintHyperframeHtml(html);
+ const finding = result.findings.find((f) => f.code === "gsap_callback_dom_measurement");
+ expect(finding).toBeDefined();
+ });
+
+ it("gsap_callback_dom_measurement: a default parameter value containing '(' doesn't throw off body extraction for an unrelated helper", async () => {
+ // `a = "("` puts an unbalanced `(` inside a string default value —
+ // a matchBalanced that isn't string-literal-aware over-counts depth and
+ // mis-locates (or fails to find) this function's own body, so it's never
+ // collected as measuring and a caller invoking it is missed entirely.
+ const html = `
+
+
+
+`;
+ const result = await lintHyperframeHtml(html);
+ const finding = result.findings.find((f) => f.code === "gsap_callback_dom_measurement");
+ expect(finding).toBeDefined();
+ });
+
+ it("gsap_callback_dom_measurement: a regex literal containing a quote (e.g. a sanitizer /'/g) doesn't desync bracket matching", async () => {
+ // A quote character inside a regex literal (a very common sanitizer
+ // shape) used to open a string that never closed under the prior
+ // hand-rolled string scanner, since it only recognized `"`/`'`/`` ` ``
+ // delimiters and had no concept of regex literals at all — matchBalanced
+ // then silently failed to find the function's closing brace, dropping it
+ // from the taint set entirely. Delegating to the shared
+ // stripJsStringLiterals utility (which already disambiguates regex
+ // literals from division and blanks their contents) fixes this class of
+ // bug for every bracket-depth-counting helper in this file at once.
+ const html = `
+
+
+
+`;
+ const result = await lintHyperframeHtml(html);
+ const finding = result.findings.find((f) => f.code === "gsap_callback_dom_measurement");
+ expect(finding).toBeDefined();
+ });
+
+ it("gsap_callback_dom_measurement: a case-label-shaped substring inside a template literal at depth 0 is not mistaken for the real case", async () => {
+ // findTopLevelCaseMatch's label regex runs against the ORIGINAL text (it
+ // has to read the quoted value literally), so a template literal like
+ // `` label = `case "b":` `` sitting at the switch's own top level (no
+ // enclosing brackets of its own) reads as depth 0 — same as the genuine
+ // `case "b":` label — and bracket depth alone can't tell them apart.
+ // Requiring the match SITE itself to be unmasked (masked[match.index]
+ // must still read "c", not a blanked space) rejects the bogus match.
+ const html = `
+
+
+
+`;
+ const result = await lintHyperframeHtml(html);
+ const finding = result.findings.find((f) => f.code === "gsap_callback_dom_measurement");
+ expect(finding).toBeDefined();
+ });
+
+ it("gsap_callback_dom_measurement: an unrelated regex/division ambiguity EARLIER in the script doesn't poison masking for a later, unrelated function", async () => {
+ // `const ratio = {}/2;` is ordinary, valid JS that trips stripJsStringLiterals's
+ // regex-vs-division ambiguity: the `/` right after `}` defaults to "regex
+ // allowed" and then finds no closing `/` before the line ends. That misread is
+ // recovered LOCALLY (see the scanner's own doc comment), so it must not affect
+ // masking for any OTHER function in the same script — here one whose real
+ // closing brace this rule can only find by bracket-depth counting, which needs
+ // the genuine `}` inside `"clo}se"` to stay masked.
+ const html = `
+
+
+
+`;
+ const result = await lintHyperframeHtml(html);
+ const finding = result.findings.find((f) => f.code === "gsap_callback_dom_measurement");
+ expect(finding).toBeDefined();
+ });
+
+ it("gsap_callback_dom_measurement: an unrelated regex/division ambiguity LATER in the script doesn't poison masking for an earlier function", async () => {
+ // The mirror of the test above, and the direction that needs the recovery to be
+ // local rather than merely scoped: matchBalanced scans FORWARD from a function's
+ // own start index to the end of the script, so a misread sitting after that
+ // function is inside the span it masks no matter how narrowly the caller slices.
+ // Only correcting the misread in place keeps it away from `"clo}se"`.
+ const html = `
+
+
+
+`;
+ const result = await lintHyperframeHtml(html);
+ const finding = result.findings.find((f) => f.code === "gsap_callback_dom_measurement");
+ expect(finding).toBeDefined();
+ });
+
+ it("gsap_callback_dom_measurement: an escaped backslash right before a string's closing quote doesn't mis-locate the closing quote", async () => {
+ // `"a\\"` (the string `a\`) has a literal backslash immediately before
+ // the real closing quote. Escape tracking based on "is the previous
+ // character a backslash" misreads this as an escaped closing quote,
+ // extending the string past its real end and potentially corrupting
+ // downstream branch resolution. stripJsStringLiterals tracks a true
+ // escaped flag (odd/even backslash parity) rather than a naive
+ // prev-char check, so this resolves correctly.
+ const html = `
+
+
+
+`;
+ const result = await lintHyperframeHtml(html);
+ const finding = result.findings.find((f) => f.code === "gsap_callback_dom_measurement");
+ expect(finding).toBeUndefined();
+ });
+
+ it("gsap_callback_dom_measurement: measureFn.call/.apply/.bind() are direct invocations and must still be flagged", async () => {
+ const html = `
+
+
+
+`;
+ const result = await lintHyperframeHtml(html);
+ const findings = result.findings.filter((f) => f.code === "gsap_callback_dom_measurement");
+ expect(findings.length).toBe(3);
+ });
+
+ it("gsap_callback_dom_measurement: a named wrapper that reaches measurement only via .call/.apply/.bind is still flagged when passed by reference", async () => {
+ // Exercises the OTHER call/apply/bind path: not the leaf-callback check in
+ // expressionReachesMeasurement (covered above), but the two-hop named-
+ // function taint closure (collectMeasuringFunctionNames -> invokesName),
+ // where `wrapper` itself measures only indirectly through `.call`.
+ const html = `
+
+
+
+`;
+ const result = await lintHyperframeHtml(html);
+ const finding = result.findings.find((f) => f.code === "gsap_callback_dom_measurement");
+ expect(finding).toBeDefined();
+ });
+
+ it("gsap_callback_dom_measurement: measureFn?.() and measureFn?.call(this) (optional chaining) are still flagged (fails closed)", async () => {
+ const html = `
+
+
+
+`;
+ const result = await lintHyperframeHtml(html);
+ const findings = result.findings.filter((f) => f.code === "gsap_callback_dom_measurement");
+ expect(findings.length).toBe(2);
+ });
+
+ it("gsap_callback_dom_measurement: a for-of/for-in reuse of the param as the loop variable is not substituted (fails closed)", async () => {
+ const html = `
+
+
+
+`;
+ const result = await lintHyperframeHtml(html);
+ const finding = result.findings.find((f) => f.code === "gsap_callback_dom_measurement");
+ expect(finding).toBeDefined();
+ });
+
+ it("gsap_callback_dom_measurement: an unrelated post-branch rebind with NO loop anywhere in the function is still conservatively flagged (documented, accepted trade-off)", async () => {
+ // paramMayBeRebound scans the WHOLE body unconditionally, on purpose: an
+ // earlier draft tried to narrow this to "only widen the scan when the
+ // function contains a loop/repetition construct," but two independent
+ // adversarial reviews each found a real repetition shape that narrower
+ // gate missed (see git history). Enumerating every way a branch might
+ // re-execute is an open-ended text-only classification problem, so this
+ // rule always takes the conservative path instead: an ordinary,
+ // unrelated reassignment after the branch — even with no loop anywhere
+ // in the function — still forces a bail to conservative flagging. A
+ // false positive here is tolerated by design; a false negative is not.
+ const html = `
+
+
+
+`;
+ const result = await lintHyperframeHtml(html);
+ const finding = result.findings.find((f) => f.code === "gsap_callback_dom_measurement");
+ expect(finding).toBeDefined();
+ });
+
+ it("gsap_callback_dom_measurement: a rebind inside an Array#forEach/map-based repetition is not substituted (fails closed)", async () => {
+ // paramMayBeRebound's whole-body scan catches this regardless of which
+ // repetition construct (for/while/do, .forEach/.map, recursion, ...) the
+ // rebind sits inside — the scan doesn't try to classify the repetition
+ // shape at all (see the comment above), it just always looks at the
+ // whole body.
+ const html = `
+
+
+
+`;
+ const result = await lintHyperframeHtml(html);
+ const finding = result.findings.find((f) => f.code === "gsap_callback_dom_measurement");
+ expect(finding).toBeDefined();
+ });
+
+ it("gsap_callback_dom_measurement: does NOT flag a by-reference handoff to setTimeout or Array#forEach", async () => {
+ // Deliberate, documented scope trade: unlike .call/.apply/.bind (direct
+ // invocations, flagged above), passing a tainted name BY REFERENCE to
+ // something else that invokes it later has no call syntax in the
+ // callback's own text to resolve.
+ const html = `
+
+
+
+`;
+ const result = await lintHyperframeHtml(html);
+ const finding = result.findings.find((f) => f.code === "gsap_callback_dom_measurement");
+ expect(finding).toBeUndefined();
+ });
+
+ it("gsap_callback_dom_measurement: mid-identifier case match still fires when preceded by a unicode identifier character", async () => {
+ const html = `
+
+
+
+`;
+ const result = await lintHyperframeHtml(html);
+ const finding = result.findings.find((f) => f.code === "gsap_callback_dom_measurement");
+ expect(finding).toBeDefined();
+ });
});
describe("SVG draw-on rules", () => {
diff --git a/packages/lint/src/rules/gsap.ts b/packages/lint/src/rules/gsap.ts
index e1abb94f6a..2a8265428f 100644
--- a/packages/lint/src/rules/gsap.ts
+++ b/packages/lint/src/rules/gsap.ts
@@ -39,6 +39,7 @@ import {
readDecodedAttr,
truncateSnippet,
stripJsComments,
+ stripJsStringLiterals,
hasCaptionStyles,
WINDOW_TIMELINE_ASSIGN_PATTERN,
TIMELINE_REGISTRY_OBJECT_LITERAL_PATTERN,
@@ -735,16 +736,70 @@ function targetsShareElement(
return false;
}
-/** Source from the delimiter at `openIndex` to its matching closer, inclusive. */
+/**
+ * The structure-only view of `source`: string, template and regex literal
+ * CONTENTS blanked to spaces, with delimiters, length and newline positions
+ * preserved. Every text scanner below shares one shape — mask once, take each
+ * structural decision (bracket depth, keyword, delimiter) from `masked[i]`, and
+ * slice the ORIGINAL text, which the preserved length keeps index-aligned 1:1.
+ *
+ * The literal awareness itself is delegated, never re-derived here:
+ * `stripJsStringLiterals` already handles escape sequences and, crucially,
+ * regex-vs-division disambiguation — including recovering locally from a
+ * misread — so neither a quote inside a sanitizer regex (`/'/g`) nor an
+ * unrelated `{}/2`-shaped division elsewhere in the script can desync the
+ * depth counts below.
+ *
+ * Cached by exact input string, since the scanners below mask the SAME text
+ * repeatedly (once per regex match, once per function signature, ...) and
+ * `stripJsStringLiterals` walks a character at a time, so re-deriving it per
+ * call is quadratic in script size. This is a small LRU, not a single slot:
+ * callers legitimately interleave masking the whole script, one function's
+ * body, and a small param/arg list within the same pass, and a single-slot
+ * cache would evict the (expensive) whole-script entry on every (cheap) small
+ * one — quadratic again, just with a smaller constant. A handful of entries
+ * comfortably covers that real interleaving without unbounded growth.
+ */
+const MASK_CACHE_LIMIT = 16;
+const maskCache = new Map();
+function maskLiterals(source: string): string {
+ const cached = maskCache.get(source);
+ if (cached !== undefined) {
+ // Re-inserting moves this key to the END of the Map's iteration order —
+ // Maps preserve insertion order — so eviction below (oldest-first) is
+ // true least-recently-USED, not merely least-recently-inserted. Without
+ // this, a frequently reused entry (typically the whole script, masked
+ // once but read on nearly every call) could still be evicted the moment
+ // enough OTHER distinct strings are masked, even though it was just used.
+ maskCache.delete(source);
+ maskCache.set(source, cached);
+ return cached;
+ }
+ const masked = stripJsStringLiterals(source);
+ maskCache.set(source, masked);
+ if (maskCache.size > MASK_CACHE_LIMIT) {
+ const oldestKey = maskCache.keys().next().value;
+ if (oldestKey !== undefined) maskCache.delete(oldestKey);
+ }
+ return masked;
+}
+
+/**
+ * Source from the delimiter at `openIndex` to its matching closer, inclusive.
+ * Only REAL brackets count toward depth — one inside a string, template or
+ * regex literal (a default value `a = "("`, a sanitizer regex `/{/g`) has been
+ * masked out before the scan.
+ */
function matchBalanced(
source: string,
openIndex: number,
open: string,
close: string,
): string | null {
+ const masked = maskLiterals(source);
let depth = 0;
for (let i = openIndex; i < source.length; i++) {
- const ch = source[i];
+ const ch = masked[i] ?? "";
if (ch === open) depth++;
else if (ch === close) {
depth--;
@@ -754,67 +809,82 @@ function matchBalanced(
return null;
}
-/** The nearest object literal `{...}` enclosing `index` (comment-stripped source). */
-function enclosingObjectLiteral(source: string, index: number): string | null {
+/**
+ * +1 for an opening bracket, -1 for a closing one, 0 otherwise — the one
+ * definition of "which masked characters move nesting depth" that every
+ * depth-tracking scanner below shares, instead of each re-testing its own copy
+ * of the `({[` / `)}]` character sets.
+ */
+function bracketDelta(ch: string): number {
+ if ("({[".includes(ch)) return 1;
+ if (")}]".includes(ch)) return -1;
+ return 0;
+}
+
+/**
+ * Index of the `{` opening the innermost object literal or block enclosing
+ * `index`, or -1 when `index` sits inside none. Scans masked text backwards, so
+ * a brace that is really literal content opens and closes nothing.
+ */
+function enclosingOpenBraceIndex(masked: string, index: number): number {
let depth = 0;
for (let i = index; i >= 0; i--) {
- const ch = source[i];
+ const ch = masked[i];
if (ch === "}") depth++;
else if (ch === "{") {
- if (depth === 0) return matchBalanced(source, i, "{", "}");
+ if (depth === 0) return i;
depth--;
}
}
- return null;
+ return -1;
+}
+
+/** The nearest object literal `{...}` enclosing `index` (comment-stripped source). */
+function enclosingObjectLiteral(source: string, index: number): string | null {
+ const braceIndex = enclosingOpenBraceIndex(maskLiterals(source), index);
+ if (braceIndex < 0) return null;
+ return matchBalanced(source, braceIndex, "{", "}");
}
function objectLiteralHasTopLevelRelativeValue(objectLiteral: string): boolean {
+ const masked = maskLiterals(objectLiteral);
let depth = 0;
- let inString: '"' | "'" | "`" | null = null;
for (let i = 0; i < objectLiteral.length; i++) {
- const ch = objectLiteral[i] ?? "";
- const prev = objectLiteral[i - 1] ?? "";
- if (inString) {
- if (ch === inString && prev !== "\\") inString = null;
- continue;
- }
+ const ch = masked[i] ?? "";
if (ch === '"' || ch === "'" || ch === "`") {
- inString = ch;
+ // Masking blanks literal contents but keeps their delimiters, so every
+ // quote still standing here is a genuine one. A relative value is always
+ // a string (`x: "+=10"`), and its contents have to be read back from the
+ // ORIGINAL text. Testing at closing quotes too is harmless: only an
+ // OPENING quote can be followed by `+=`/`-=`.
if (depth === 1 && /^[+-]=/.test(objectLiteral.slice(i + 1))) return true;
- continue;
+ } else {
+ depth += bracketDelta(ch);
}
- if (ch === "{" || ch === "(" || ch === "[") depth++;
- else if (ch === "}" || ch === ")" || ch === "]") depth--;
}
return false;
}
function isInsideGsapTweenVars(source: string, index: number, timelineVars: string[]): boolean {
- let depth = 0;
- for (let i = index; i >= 0; i--) {
- const ch = source[i];
- if (ch === "}") depth++;
- else if (ch === "{") {
- if (depth === 0) {
- const before = source.slice(Math.max(0, i - 240), i).replace(/\s+/g, " ");
- const receivers = ["gsap", ...timelineVars].map(escapeRegExp).join("|");
- return new RegExp(`(?:${receivers})\\.(?:set|to|from|fromTo|timeline)\\b[\\s\\S]*$`).test(
- before,
- );
- }
- depth--;
- }
- }
- return false;
+ const braceIndex = enclosingOpenBraceIndex(maskLiterals(source), index);
+ if (braceIndex < 0) return false;
+ const before = source.slice(Math.max(0, braceIndex - 240), braceIndex).replace(/\s+/g, " ");
+ const receivers = ["gsap", ...timelineVars].map(escapeRegExp).join("|");
+ return new RegExp(`(?:${receivers})\\.(?:set|to|from|fromTo|timeline)\\b[\\s\\S]*$`).test(before);
}
-/** An expression starting at `start`, ending at the first `,` / closer at depth 0. */
+/**
+ * An expression starting at `start`, ending at the first `,` / closer that sits
+ * at depth 0 outside any literal.
+ */
function sliceExpression(source: string, start: number): string {
+ const masked = maskLiterals(source);
let depth = 0;
for (let i = start; i < source.length; i++) {
- const ch = source[i] ?? "";
- if ("({[".includes(ch)) depth++;
- else if (")}]".includes(ch)) {
+ const ch = masked[i] ?? "";
+ const delta = bracketDelta(ch);
+ if (delta > 0) depth++;
+ else if (delta < 0) {
if (depth === 0) return source.slice(start, i);
depth--;
} else if (ch === "," && depth === 0) return source.slice(start, i);
@@ -884,17 +954,64 @@ function collectTimelineVarNames(source: string): string[] {
.filter(Boolean);
}
-// Named function bodies in a script (declarations plus `const f = ...` function
+type FunctionSignature = { params: Array; body: string };
+
+/** Split `text` on top-level commas — depth-aware, so a comma inside `(...)`/`[...]`/`{...}` doesn't split. */
+function splitTopLevelByComma(text: string): string[] {
+ const masked = maskLiterals(text);
+ const parts: string[] = [];
+ let depth = 0;
+ let start = 0;
+ for (let i = 0; i < text.length; i++) {
+ const ch = masked[i] ?? "";
+ if (ch === "," && depth === 0) {
+ parts.push(text.slice(start, i));
+ start = i + 1;
+ } else {
+ depth += bracketDelta(ch);
+ }
+ }
+ parts.push(text.slice(start));
+ return parts;
+}
+
+/** Parameter names of a parameter list (without its parentheses); null per destructured/rest param. */
+function paramNames(paramList: string): Array {
+ return splitTopLevelByComma(paramList).map(normalizeFirstParam);
+}
+
+// Parameter names for a `const name = ...` prefix already matched by
+// `assignPattern` below — mirrors that pattern's own three shapes
+// (function expression / parenthesized arrow / bare-identifier arrow), the
+// same three shapes `parseFunctionValueSource` also matches independently.
+// If a shape is ever added to one, it won't automatically apply to the
+// others; an unmatched shape here just yields `[]` (params unresolved),
+// which falls back to the old conservative whole-function taint rather than
+// silently misbehaving.
+function extractAssignedParamNames(prefix: string): Array {
+ const match =
+ prefix.match(/function\b[^(]*\(([^)]*)\)/) ??
+ prefix.match(/\(([^)]*)\)\s*=>\s*$/) ??
+ prefix.match(/([A-Za-z_$][\w$]*)\s*=>\s*$/);
+ return match ? paramNames(match[1] ?? "") : [];
+}
+
+// Named function signatures in a script (declarations plus `const f = ...` function
// expressions and arrows). Expression-bodied arrows keep their single line.
-function collectNamedFunctionBodies(source: string): Map {
- const bodies = new Map();
+function collectNamedFunctionSignatures(source: string): Map {
+ const signatures = new Map();
const declPattern = /(?:^|[^.\w$])function\s+([A-Za-z_$][\w$]*)\s*\(/g;
let match: RegExpExecArray | null;
while ((match = declPattern.exec(source)) !== null) {
- const braceIndex = source.indexOf("{", declPattern.lastIndex);
+ const parenIndex = declPattern.lastIndex - 1;
+ const paramsWithParens = matchBalanced(source, parenIndex, "(", ")");
+ if (!paramsWithParens) continue;
+ const braceIndex = source.indexOf("{", parenIndex + paramsWithParens.length);
if (braceIndex < 0) continue;
const body = matchBalanced(source, braceIndex, "{", "}");
- if (body) bodies.set(match[1] ?? "", body);
+ if (!body) continue;
+ const params = paramNames(paramsWithParens.slice(1, -1));
+ signatures.set(match[1] ?? "", { params, body });
}
const assignPattern =
/(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?(?:function\b[^{]*|\([^)]*\)\s*=>\s*|[A-Za-z_$][\w$]*\s*=>\s*)/g;
@@ -904,39 +1021,404 @@ function collectNamedFunctionBodies(source: string): Map {
source[bodyStart] === "{"
? matchBalanced(source, bodyStart, "{", "}")
: sliceExpression(source, bodyStart);
- if (body) bodies.set(match[1] ?? "", body);
+ if (!body) continue;
+ const params = extractAssignedParamNames(match[0]);
+ signatures.set(match[1] ?? "", { params, body });
+ }
+ return signatures;
+}
+
+// The regex source for a direct call `name(...)`, tolerating optional
+// chaining (`name?.(...)`) — still a real, synchronous call site, just one
+// that no-ops instead of throwing when `name` is nullish.
+function directCallPatternSource(name: string): string {
+ return `\\b${escapeRegExp(name)}\\s*(?:\\?\\.)?\\(`;
+}
+
+// `name.call(...)` / `name.apply(...)` / `name.bind(...)()`, and their
+// optional-chained forms `name?.call(...)` etc. — the Function.prototype
+// invocation forms. Split out from `invokesName` because
+// `expressionReachesMeasurement` needs this half on its own.
+function invokesNameViaCallApplyBind(text: string, name: string): boolean {
+ return new RegExp(
+ `\\b${escapeRegExp(name)}\\s*(?:\\?\\.|\\.)\\s*(?:call|apply|bind)\\s*\\(`,
+ ).test(text);
+}
+
+// Does `text` invoke `name` directly — `name(...)` or one of the
+// Function.prototype forms above? All of them call the function synchronously,
+// right here. Only a BY-REFERENCE handoff (`setTimeout(name, 0)`,
+// `[1].forEach(name)`) is not a call and is deliberately excluded: the
+// invocation there happens inside setTimeout's/forEach's own implementation,
+// out of view here.
+function invokesName(text: string, name: string): boolean {
+ return (
+ new RegExp(directCallPatternSource(name)).test(text) || invokesNameViaCallApplyBind(text, name)
+ );
+}
+
+/** Does `text` measure directly, or call an already-tainted function? Requires a call — a bare name mention is not enough. */
+function textReachesMeasurement(text: string, measuring: Set): boolean {
+ if (CALLBACK_MEASUREMENT_PATTERN.test(text)) return true;
+ for (const name of measuring) {
+ if (invokesName(text, name)) return true;
}
- return bodies;
+ return false;
}
// Two-hop closure: functions whose body measures the DOM directly, plus
// functions that call one of those (bounded fixpoint — no deep recursion).
-function collectMeasuringFunctionNames(bodies: Map): Set {
+function collectMeasuringFunctionNames(signatures: Map): Set {
const measuring = new Set();
- for (const [name, body] of bodies) {
+ for (const [name, { body }] of signatures) {
if (CALLBACK_MEASUREMENT_PATTERN.test(body)) measuring.add(name);
}
for (let pass = 0; pass < 3; pass++) {
let grew = false;
- for (const [name, body] of bodies) {
+ for (const [name, { body }] of signatures) {
if (measuring.has(name)) continue;
- for (const measured of measuring) {
- if (new RegExp(`\\b${escapeRegExp(measured)}\\s*\\(`).test(body)) {
- measuring.add(name);
- grew = true;
- break;
- }
- }
+ if (!textReachesMeasurement(body, measuring)) continue;
+ measuring.add(name);
+ grew = true;
}
if (!grew) break;
}
return measuring;
}
-function expressionReachesMeasurement(expression: string, measuring: Set): boolean {
+type LiteralArg = { kind: "boolean"; value: boolean } | { kind: "string"; value: string };
+
+function parseLiteralArg(argText: string): LiteralArg | null {
+ const trimmed = argText.trim();
+ if (trimmed === "true") return { kind: "boolean", value: true };
+ if (trimmed === "false") return { kind: "boolean", value: false };
+ const stringMatch = trimmed.match(/^(["'])([^"'`]*)\1$/);
+ if (stringMatch) return { kind: "string", value: stringMatch[2] ?? "" };
+ return null;
+}
+
+/** Body text of the case matching `value` in a `switch (paramName)`, up to the next `case`/`default`. */
+function sliceUntilNextCase(text: string, start: number): string {
+ const masked = maskLiterals(text);
+ let depth = 0;
+ for (let i = start; i < text.length; i++) {
+ const ch = masked[i] ?? "";
+ const prev = masked[i - 1] ?? "";
+ const delta = bracketDelta(ch);
+ if (delta > 0) depth++;
+ else if (delta < 0) {
+ if (depth === 0) return text.slice(start, i);
+ depth--;
+ } else if (depth === 0 && !/[\w$]/.test(prev) && /^(?:case|default)\b/.test(masked.slice(i))) {
+ // The LEADING word-boundary check (`prev`) matters as much as the
+ // trailing `\b`: without it the "case" inside an identifier such as
+ // "lowercase(" reads as a case label and truncates the body here,
+ // silently dropping the rest — including any measurement — from both
+ // this slice and the caller's outside-the-statement remainder scan
+ // (which only covers text outside the whole switch statement). Both
+ // checks read `masked`, so a "case"/"default"-shaped substring inside a
+ // string or regex literal cannot be mistaken for a real case label.
+ return text.slice(start, i);
+ }
+ }
+ return text.slice(start);
+}
+
+// A branch is only decisive when nothing OUTSIDE the branching statement (the
+// half-open span `[start, end)` of `body`) measures: an unconditional
+// measurement elsewhere in the function applies whichever branch a call took.
+function measuresOutsideStatement(
+ body: string,
+ start: number,
+ end: number,
+ measuring: Set,
+): boolean {
+ return textReachesMeasurement(body.slice(0, start) + body.slice(end), measuring);
+}
+
+// Substituting the caller's literal for `paramName` is only sound if nothing
+// in the WHOLE function body could have changed what that name refers to or
+// holds: a reassignment (`m = !m`, `m ||= x`, `m++`), or a shadowing rebinding
+// (a nested function/arrow/method/catch parameter, a function declaration, a
+// for-of/for-in reuse of the name as the loop variable, or a
+// let/const/var/destructured declaration reusing the name). Either breaks
+// the "still holds the caller's literal" assumption branch resolution
+// depends on, so callers bail to unresolved rather than risk the wrong
+// branch.
+//
+// The scan covers the whole body, not just the text before the branch: a
+// rebind that appears textually AFTER the branch still matters whenever the
+// branch can re-execute — inside a `for`/`while`/`do` loop, an
+// `Array#forEach`/`map`/etc. callback, a recursive call, or any other
+// repetition shape — since a later pass re-reads the (now-mutated) value.
+// Enumerating every repetition construct a real author might use to decide
+// "does this specific rebind matter" is an open-ended, unwinnable text-only
+// classification problem (a prior, narrower version of this function tried
+// exactly that — gating the wide scan behind a loop-keyword check — and two
+// independent reviews each found a real repetition shape it missed). Always
+// scanning the whole body costs some precision (an ordinary, unrelated
+// reassignment after the branch with no repetition anywhere still forces a
+// conservative bail), but that's a tolerated false positive, not a false
+// negative — consistent with this rule's fail-closed design.
+function paramMayBeRebound(body: string, paramName: string): boolean {
+ const name = escapeRegExp(paramName);
+ const assignmentPattern = new RegExp(
+ `\\b${name}\\s*(?:=(?!=)|\\+=|-=|\\*\\*=|\\*=|/=|%=|<<=|>>>=|>>=|&=|\\|=|\\^=|\\|\\|=|&&=|\\?\\?=|\\+\\+|--)` +
+ `|(?:\\+\\+|--)\\s*${name}\\b`,
+ );
+ const inParamList = `\\([^)]*\\b${name}\\b[^)]*\\)`;
+ const shadowPattern = new RegExp(
+ [
+ `\\bfunction\\b[^(]*${inParamList}`, // nested function's parameter
+ `\\bfunction\\s+${name}\\s*\\(`, // nested function DECLARATION reusing the name
+ `${inParamList}\\s*=>`, // arrow's parenthesized parameter
+ `\\b${name}\\s*=>`, // arrow's bare single parameter
+ `\\b(?:let|const|var)\\s+${name}\\b`, // redeclaration
+ `[{[][^}\\]]*\\b${name}\\b[^}\\]]*[}\\]]\\s*=`, // destructured binding
+ `\\bcatch\\s*${inParamList}`, // catch clause parameter
+ // `for (name of ...)` / `for (name in ...)` reusing the param as the
+ // loop variable — reassigns on every iteration without ever matching
+ // the assignment or declaration alternatives above (no `=`, no
+ // let/const/var).
+ `\\bfor\\s*(?:await\\s+)?\\(\\s*${name}\\s+(?:of|in)\\s+`,
+ // Object/class method shorthand parameter (`run(paramName) {`) — has
+ // neither `function` nor `=>`, so it needs its own alternative. The
+ // control-flow keywords are excluded so an EARLIER, unrelated
+ // if/while/switch/for/catch using the same real (non-shadowed) name
+ // doesn't force a gratuitous bail.
+ `\\b(?!if\\b|while\\b|switch\\b|for\\b|catch\\b|function\\b)[A-Za-z_$][\\w$]*\\s*${inParamList}\\s*\\{`,
+ ].join("|"),
+ );
+ return assignmentPattern.test(body) || shadowPattern.test(body);
+}
+
+// A literal boolean argument resolves `if (paramName) {A} else {B}` (or its
+// negation) to exactly one branch.
+function resolveBooleanIfElseBranch(
+ body: string,
+ paramName: string,
+ value: boolean,
+ measuring: Set,
+): string | null {
+ const ifPattern = new RegExp(`\\bif\\s*\\(\\s*(!)?\\s*${escapeRegExp(paramName)}\\s*\\)\\s*\\{`);
+ const ifMatch = ifPattern.exec(body);
+ if (!ifMatch) return null;
+ if (paramMayBeRebound(body, paramName)) return null;
+ const thenBraceIndex = ifMatch.index + ifMatch[0].length - 1;
+ const thenBlock = matchBalanced(body, thenBraceIndex, "{", "}");
+ if (!thenBlock) return null;
+ const elseMatch = /^\s*else\s*\{/.exec(body.slice(thenBraceIndex + thenBlock.length));
+ if (!elseMatch) return null;
+ const elseBraceIndex = thenBraceIndex + thenBlock.length + elseMatch[0].length - 1;
+ const elseBlock = matchBalanced(body, elseBraceIndex, "{", "}");
+ if (!elseBlock) return null;
+ const statementEnd = elseBraceIndex + elseBlock.length;
+ if (measuresOutsideStatement(body, ifMatch.index, statementEnd, measuring)) return null;
+ const conditionHolds = ifMatch[1] === "!" ? !value : value;
+ return conditionHolds ? thenBlock : elseBlock;
+}
+
+// Finds `case "value":` at depth 0 of `switchBody` — i.e. belonging to THIS
+// switch, not to a switch/if/object-literal nested inside an earlier case's
+// own block. A plain (non-depth-aware) regex `.exec` would match the first
+// TEXTUAL occurrence, which can be a same-valued case label nested inside a
+// sibling case's inner switch. The label pattern runs against the ORIGINAL text
+// (its own quoted value has to read literally, not blanked), while the depth it
+// is judged at comes from `masked` — a literal's bracket-like characters (a
+// `{n,m}` regex quantifier, say) must not desync the count.
+function findTopLevelCaseMatch(switchBody: string, value: string): RegExpExecArray | null {
+ const masked = maskLiterals(switchBody);
+ const casePattern = new RegExp(`case\\s*(["'])${escapeRegExp(value)}\\1\\s*:`, "g");
+ // Starts at -1, not 0: `switchBody` itself begins with the switch's own
+ // wrapping `{`, so depth reaches 0 only once we're immediately inside it —
+ // that's what "top-level" (belonging to THIS switch) means here.
+ let depth = -1;
+ // Forward-only: each match resumes the depth scan where the previous one
+ // stopped, so every character is counted exactly once, in order.
+ let cursor = 0;
+ let match: RegExpExecArray | null;
+ while ((match = casePattern.exec(switchBody)) !== null) {
+ for (; cursor < match.index; cursor++) {
+ const ch = masked[cursor] ?? "";
+ depth += bracketDelta(ch);
+ }
+ // The match itself must sit OUTSIDE any literal too: masking blanks a
+ // literal's contents to spaces, so a case-label-shaped substring inside
+ // a string/template literal (e.g. `` label = `case "b":` ``) reads as
+ // whitespace at `match.index` in `masked` rather than a real `c` —
+ // depth alone can't tell the two apart, since a same-level string sitting
+ // at the switch's own top level doesn't touch bracket depth at all.
+ if (depth === 0 && masked[match.index] === "c") return match;
+ }
+ return null;
+}
+
+// Splits `text` into its top-level (depth-0) statements: `;`-terminated
+// spans, and `{...}` BLOCK statements (if/for/while/switch/try/a bare block)
+// — but not a `(...)`/`[...]` closing back to depth 0, which doesn't end a
+// statement (e.g. an `if (x)` condition's own closing paren).
+function splitTopLevelStatements(text: string): string[] {
+ const masked = maskLiterals(text);
+ const statements: string[] = [];
+ const stack: string[] = [];
+ let start = 0;
+ for (let i = 0; i < text.length; i++) {
+ const ch = masked[i] ?? "";
+ const delta = bracketDelta(ch);
+ if (delta > 0) {
+ stack.push(ch);
+ } else if (delta < 0) {
+ const opener = stack.pop();
+ if (stack.length === 0 && opener === "{" && ch === "}") {
+ statements.push(text.slice(start, i + 1));
+ start = i + 1;
+ }
+ } else if (ch === ";" && stack.length === 0) {
+ statements.push(text.slice(start, i + 1));
+ start = i + 1;
+ }
+ }
+ const rest = text.slice(start).trim();
+ if (rest) statements.push(rest);
+ return statements;
+}
+
+// True when `text`'s own LAST top-level statement is an unconditional
+// break/return/throw. Checking the last STATEMENT (rather than the trailing
+// text) is what keeps a conditional terminator — `if (x) { break; }`, which
+// still falls through when `x` is false — from reading as termination of the
+// case itself. When the case body is one whole wrapping block
+// (`case "x": { ...; break; }`), the check recurses into that block's own
+// last statement.
+function lastStatementIsTerminator(text: string): boolean {
+ const statements = splitTopLevelStatements(text)
+ .map((s) => s.trim())
+ .filter(Boolean);
+ const last = statements[statements.length - 1];
+ if (!last) return false;
+ if (last.startsWith("{") && last.endsWith("}")) {
+ return lastStatementIsTerminator(last.slice(1, -1));
+ }
+ return /^(?:break|return|throw)\b/.test(last);
+}
+
+// Same idea for `switch (paramName) { case "value": ... }` — only resolved
+// when the literal's case doesn't fall through into the next case's body.
+function resolveSwitchCaseBranch(
+ body: string,
+ paramName: string,
+ value: string,
+ measuring: Set,
+): string | null {
+ const switchPattern = new RegExp(`\\bswitch\\s*\\(\\s*${escapeRegExp(paramName)}\\s*\\)\\s*\\{`);
+ const switchMatch = switchPattern.exec(body);
+ if (!switchMatch) return null;
+ if (paramMayBeRebound(body, paramName)) return null;
+ const braceIndex = switchMatch.index + switchMatch[0].length - 1;
+ const switchBody = matchBalanced(body, braceIndex, "{", "}");
+ if (!switchBody) return null;
+ const caseMatch = findTopLevelCaseMatch(switchBody, value);
+ if (!caseMatch) return null;
+ const caseBody = sliceUntilNextCase(switchBody, caseMatch.index + caseMatch[0].length);
+ // Both guards below mean the same thing: the matched case falls through into
+ // the NEXT case at runtime, which this slice does not include and which may
+ // measure — so it can't be resolved as "just this case's body."
+ const trimmedCaseBody = caseBody.trim();
+ if (!trimmedCaseBody) return null; // no body of its own
+ if (!lastStatementIsTerminator(trimmedCaseBody)) return null;
+ const statementEnd = braceIndex + switchBody.length;
+ if (measuresOutsideStatement(body, switchMatch.index, statementEnd, measuring)) return null;
+ return caseBody;
+}
+
+// Resolves whether ONE specific call to a tainted helper reaches measurement,
+// by substituting any literal argument into the helper's own param-keyed
+// branch. Returns null when the call can't be resolved this way (non-literal
+// argument, or no recognized branch shape) — callers fall back to the old,
+// conservative whole-function taint in that case.
+//
+// Two exotic shapes are known misses, documented rather than handled: (1) a
+// measurement reached only through a sibling case LABEL's own expression
+// (`case getMeasurement():`) — this resolver reads case BODIES, never case
+// expressions; (2) sloppy-mode `arguments[1] = true` aliasing a named
+// parameter — `arguments` is a live view onto non-strict parameters, so it can
+// flip which literal a parameter holds without the assignment-to-that-name
+// this resolver looks for ever appearing. Closing either needs AST-level
+// analysis, not text scanning.
+function resolveCallSiteMeasurement(
+ calleeName: string,
+ argsText: string,
+ signatures: Map,
+ measuring: Set,
+): boolean | null {
+ const signature = signatures.get(calleeName);
+ if (!signature) return null;
+ const args = splitTopLevelByComma(argsText);
+ for (let i = 0; i < signature.params.length; i++) {
+ const argText = (args[i] ?? "").trim();
+ // A spread argument (`h(...args, false)`) makes every POSITIONAL argument
+ // from here on unreliable — the spread can expand to any number of
+ // elements at runtime, so a textually-later literal doesn't necessarily
+ // bind to the param at its textual index. Positions before the spread are
+ // unaffected and have already been resolved by the iterations above.
+ if (argText.startsWith("...")) break;
+ const paramName = signature.params[i];
+ if (!paramName) continue;
+ const literal = parseLiteralArg(argText);
+ if (!literal) continue;
+ const branch =
+ literal.kind === "boolean"
+ ? resolveBooleanIfElseBranch(signature.body, paramName, literal.value, measuring)
+ : resolveSwitchCaseBranch(signature.body, paramName, literal.value, measuring);
+ if (branch !== null) return textReachesMeasurement(branch, measuring);
+ }
+ return null;
+}
+
+// Requires a CALL, the same standard the taint-propagation closure above
+// applies between named functions (see `invokesName`) — a bare, uncalled
+// mention of a tainted name is not enough to trip the rule.
+//
+// Direct invocation forms (`measureFn()`, `measureFn.call(this)`,
+// `.apply(...)`, `.bind(this)()`) are flagged: they run the tainted function
+// synchronously, right here. A BY-REFERENCE handoff
+// (`onUpdate: () => setTimeout(measureFn, 0)`, `[el].forEach(measureFn)`) is a
+// deliberate, documented scope trade — there is no call syntax in the
+// callback's own text to resolve, because the invocation (and whatever
+// arguments it passes) happens inside setTimeout's/forEach's implementation.
+//
+// Known scaling characteristic, pre-existing and out of scope here: this is
+// called once per callback call-site, and loops `measuring` (every DOM-touching
+// named function in the WHOLE script) on each call — O(call sites × measuring
+// names). A script with many call sites, each naming its OWN distinct measuring
+// function, scales quadratically; one with many call sites sharing a SMALL,
+// shared set of measuring functions (the realistic shape) does not. Not a
+// regression from this pass's `maskLiterals` caching change — a follow-up would
+// need to invert the loop (index calls by name once, not per callback).
+function expressionReachesMeasurement(
+ expression: string,
+ measuring: Set,
+ signatures: Map,
+): boolean {
if (CALLBACK_MEASUREMENT_PATTERN.test(expression)) return true;
for (const name of measuring) {
- if (new RegExp(`\\b${escapeRegExp(name)}\\b`).test(expression)) return true;
+ const callPattern = new RegExp(directCallPatternSource(name), "g");
+ let match: RegExpExecArray | null;
+ while ((match = callPattern.exec(expression)) !== null) {
+ const parenIndex = match.index + match[0].length - 1;
+ const argsWithParens = matchBalanced(expression, parenIndex, "(", ")");
+ const resolved = argsWithParens
+ ? resolveCallSiteMeasurement(name, argsWithParens.slice(1, -1), signatures, measuring)
+ : null;
+ // null (unresolved) and true (resolved branch measures) both taint this
+ // callback; only a resolved, provably-safe branch (false) clears it.
+ if (resolved !== false) return true;
+ }
+ // `.call`/`.apply`/`.bind` are direct invocations too, but their argument
+ // shape (a leading `this`, or a further `()` for bind) doesn't match the
+ // plain-call branch-resolution shape above — always conservative here,
+ // same as an unresolved plain call would be.
+ if (invokesNameViaCallApplyBind(expression, name)) return true;
}
return false;
}
@@ -2222,8 +2704,8 @@ export const gsapRules: LintRule[] = [
for (const script of scripts) {
const source = stripJsComments(script.content);
if (!/gsap\.timeline/.test(source)) continue;
- const bodies = collectNamedFunctionBodies(source);
- const measuring = collectMeasuringFunctionNames(bodies);
+ const signatures = collectNamedFunctionSignatures(source);
+ const measuring = collectMeasuringFunctionNames(signatures);
// A callback argument is hazardous when it is an inline function whose body
// reaches a measurement, or a bare reference to a measuring function. Call
@@ -2232,7 +2714,7 @@ export const gsapRules: LintRule[] = [
const callbackExpressionHazard = (expression: string): boolean => {
const trimmed = expression.trim();
const inline = parseFunctionValueSource(trimmed);
- if (inline) return expressionReachesMeasurement(inline.body, measuring);
+ if (inline) return expressionReachesMeasurement(inline.body, measuring, signatures);
if (/^[A-Za-z_$][\w$]*$/.test(trimmed)) return measuring.has(trimmed);
return false;
};
@@ -2267,7 +2749,12 @@ export const gsapRules: LintRule[] = [
const parenIndex = match.index + match[0].length - 1;
const argsWithParens = matchBalanced(source, parenIndex, "(", ")");
if (!argsWithParens) continue;
- const firstArg = sliceExpression(argsWithParens.slice(1, -1), 0);
+ // Passes `source` + an absolute index, like every sibling call below —
+ // never a fresh slice (`argsWithParens.slice(1, -1)`), which would mask
+ // a brand-new string: an unnecessary `stripJsStringLiterals` scan of the
+ // whole script on every call, and one more entry competing for space in
+ // `maskLiterals`'s LRU alongside `source`'s own (already cached) mask.
+ const firstArg = sliceExpression(source, parenIndex + 1);
const site = match[0] + firstArg + ", ...)";
if (callbackExpressionHazard(firstArg)) report(site, site);
}
diff --git a/packages/lint/src/utils.test.ts b/packages/lint/src/utils.test.ts
index 41663ff9d8..c527900412 100644
--- a/packages/lint/src/utils.test.ts
+++ b/packages/lint/src/utils.test.ts
@@ -51,17 +51,76 @@ describe("stripJsStringLiterals", () => {
"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) => {
+ ])("recovers a slash that never closes on its line without losing %j", (src) => {
+ // None of these misread spans contain a real string/regex literal of their
+ // own, so re-walking them as ordinary (division) code reconstructs the input
+ // exactly — the scan recovers here, it does not give up and return `source`
+ // the way the mid-literal case below does.
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", () => {
+ it("recovers a mis-read regex line and still masks a real string literal around it", () => {
+ // "b in /x...\" misreads as a regex start (`in` is regex-allowed);
+ // the genuine string literal 'requestAnimationFrame(' sits right after the
+ // recovered span and must still come back masked — proving real recovery,
+ // rather than an identity result that only looks like it.
const src = "var a = b in /x\\\n y = 'requestAnimationFrame(' / z /;";
- expect(scan(src)).toBe(src);
+ const out = scan(src);
+ expect(out).not.toBe(src);
+ expect(out).toContain("var a = b in /x\\\n y = '");
+ expect(out).toContain("' / z /;");
+ expect(findsRaf(src)).toBe(false);
+ });
+
+ // A misread costs only its own span: the ambiguous `{}/2` division is ordinary,
+ // valid JS, and must not un-mask an unrelated string carrying bracket-like content
+ // ANYWHERE else in the same script — on either side of it.
+ it.each([
+ {
+ label: "before a LATER misread",
+ src: 'const label = "clo}se"; const ratio = {}/2;\nrequestAnimationFrame(step);',
+ },
+ {
+ label: "after an EARLIER misread",
+ src: 'const ratio = {}/2;\nconst label = "clo}se";\nrequestAnimationFrame(step);',
+ },
+ ])("keeps an unrelated string masked $label", ({ src }) => {
+ const out = scan(src);
+ expect(out).toContain('const label = " ";');
expect(findsRaf(src)).toBe(true);
});
+ it("recovers a misread regex that runs all the way to end-of-input with no trailing newline", () => {
+ // A real regex literal can't extend past EOF either, so a "/" whose guessed
+ // regex reaches the very end of `source` without a newline in between is
+ // just as much a misread as one that hits a mid-file line boundary — it
+ // must get the same local recovery, not the whole-input bail (there is no
+ // newline left for the OLD newline-only recovery trigger to ever fire on).
+ const src = 'const label = "clo}se";\nconst ratio = {}/2;';
+ expect(scan(src)).toBe('const label = " ";\nconst ratio = {}/2;');
+ });
+
+ it("keeps output length equal to input length across a misread immediately followed by a real regex", () => {
+ // Guards `recoverFromMisread` actually clearing its buffered guess content:
+ // if a misread's `regexBuffer` were ever left un-cleared, its stale
+ // characters would leak into the very next CONFIRMED regex literal's own
+ // buffered content, inflating the output past the input's length.
+ const src = "a = {}/2;\nr = /x/;\nrequestAnimationFrame(step);";
+ const out = scan(src);
+ expect(out.length).toBe(src.length);
+ expect(findsRaf(src)).toBe(true);
+ });
+
+ it("keeps a backslash-escaped quote immediately followed by a real bracket masked as string content", () => {
+ // If escape-tracking on this branch were ever dropped, the escaped quote
+ // in `"x\"}"` would misread as the string's REAL closing quote, exposing
+ // the following `}` as ordinary code instead of masked string content —
+ // corrupting any bracket-depth count a caller derives from the mask.
+ const src = 'const s = "x\\"}"; requestAnimationFrame(step);';
+ expect(scan(src)).toBe('const s = " "; requestAnimationFrame(step);');
+ });
+
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);
diff --git a/packages/lint/src/utils.ts b/packages/lint/src/utils.ts
index ef24a3a09f..9a27dd3cdc 100644
--- a/packages/lint/src/utils.ts
+++ b/packages/lint/src/utils.ts
@@ -515,6 +515,15 @@ const REGEX_ALLOWED_KEYWORDS = new Set([
const WORD_CHAR = /[A-Za-z0-9_$]/;
+/** A copy of `CodeContext`'s incremental state, so a wrong regex guess can be rewound. */
+type CodeContextSnapshot = {
+ last: string;
+ prev: string;
+ word: string;
+ wordEnded: boolean;
+ wordAfterDot: boolean;
+};
+
/**
* 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
@@ -552,45 +561,122 @@ class CodeContext {
if ((this.last === "+" || this.last === "-") && this.prev === this.last) return false;
return REGEX_ALLOWED_BEFORE.has(this.last);
}
+
+ snapshot(): CodeContextSnapshot {
+ return {
+ last: this.last,
+ prev: this.prev,
+ word: this.word,
+ wordEnded: this.wordEnded,
+ wordAfterDot: this.wordAfterDot,
+ };
+ }
+
+ restore(snapshot: CodeContextSnapshot): void {
+ this.last = snapshot.last;
+ this.prev = snapshot.prev;
+ this.word = snapshot.word;
+ this.wordEnded = snapshot.wordEnded;
+ this.wordAfterDot = snapshot.wordAfterDot;
+ }
}
+/**
+ * A "/" currently being scanned AS a regex literal, carrying the state to rewind to
+ * if that guess turns out wrong. Non-null exactly while the scan sits inside a
+ * candidate regex — the same "value doubles as the mode flag" shape `quote` uses for
+ * strings below.
+ */
+type RegexGuess = { index: number; context: CodeContextSnapshot };
+
/**
* 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.
+ * they are code. Returns the source untouched if the scan ends mid-literal (an
+ * unterminated string/template), 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.
+ *
+ * A candidate "/" is only ever a GUESS at starting a regex literal, since regex and
+ * division are genuinely ambiguous in text. Real regex literals can't span a line —
+ * or run past the end of input — so a "/" whose "regex" reaches either was misread,
+ * and everything blanked since it is ordinary (division) code. The scan then rewinds
+ * to that "/" and re-walks the span as code (see `recoverFromMisread`), so a misread
+ * costs only its own span — every literal correctly masked elsewhere in `source`,
+ * before it or after it, stays masked. Because end-of-input gets the same recovery
+ * as a line boundary, a still-open guess can never survive to the final fail-safe
+ * check below — only a genuinely unterminated string/template can.
*/
// fallow-ignore-next-line complexity
export function stripJsStringLiterals(source: string): string {
let out = "";
+ // Content emitted WHILE a regex guess is live, held apart from `out` rather
+ // than appended to it directly. `out` is built by repeated `+=` (a rope of
+ // chunks under the hood), so slicing it back on a misread would force it to
+ // flatten to a plain string proportional to `out`'s ENTIRE length so far, on
+ // every single misread — quadratic for input with many of them, even though
+ // each misread's own span is bounded (it can't cross a line). Buffering the
+ // guess's content separately makes recovery an O(1) discard instead: only
+ // `regexBuffer` (bounded by one line) is ever thrown away, `out` is never
+ // touched until a guess is confirmed real.
+ let regexBuffer = "";
let i = 0;
const templateBraces: number[] = [];
const ctx = new CodeContext();
let quote: "'" | '"' | "`" | null = null;
let escaped = false;
- let inRegex = false;
+ let regexGuess: RegexGuess | null = null;
let inRegexClass = false;
- let regexMisread = false;
+ // The one "/" a misread has already proven to be division, so the re-walk reads it
+ // as ordinary code instead of guessing "regex" at it again. A single slot is enough:
+ // a rewind only ever returns to the guess it is about to step past, so a resolved
+ // index can never come round a second time.
+ let knownDivisionIndex = -1;
const blank = (ch: string) => (ch === "\n" || ch === "\r" ? ch : " ");
const emit = (text: string) => {
- out += text;
+ if (regexGuess) regexBuffer += text;
+ else out += text;
for (const ch of text) ctx.push(ch);
};
- while (i < source.length) {
+ // Undoes a regex guess a line boundary just disproved: discard the buffered
+ // guess content, rewind `ctx` and `i` to the "/" itself so the span is
+ // re-walked as code, leaving no regex-scan flag still set. `out` is never
+ // touched (nothing of the guess ever reached it). `quote`/`templateBraces`
+ // need no rewinding either — neither can change while inside a regex guess.
+ const recoverFromMisread = (guess: RegexGuess): void => {
+ regexBuffer = "";
+ ctx.restore(guess.context);
+ knownDivisionIndex = guess.index;
+ i = guess.index;
+ regexGuess = null;
+ inRegexClass = false;
+ escaped = false;
+ };
+
+ while (true) {
+ if (i >= source.length) {
+ // End of input while still guessing a regex is the same misread signal
+ // as a line boundary — a real regex literal can't extend past EOF
+ // either — so it gets the identical recovery, not a silent whole-input
+ // bail: without this, a misread on the source's LAST line (no trailing
+ // newline) would never be caught, since the newline check below is the
+ // only other place recovery triggers.
+ if (!regexGuess) break;
+ recoverFromMisread(regexGuess);
+ continue;
+ }
const ch = source[i] ?? "";
const next = source[i + 1] ?? "";
- if (inRegex) {
+ if (regexGuess) {
if (escaped) {
escaped = false;
if (ch === "\n" || ch === "\r") {
- inRegex = false;
- inRegexClass = false;
- regexMisread = true;
+ recoverFromMisread(regexGuess);
+ continue;
}
emit(blank(ch));
} else if (ch === "\\") {
@@ -603,14 +689,16 @@ export function stripJsStringLiterals(source: string): string {
inRegexClass = false;
emit(" ");
} else if (ch === "/" && !inRegexClass) {
- inRegex = false;
+ // Confirmed a real regex: fold its buffered content into `out` before
+ // clearing the guess, so `emit` below (now that `regexGuess` is null)
+ // appends the closing "/" straight to `out` right after it.
+ out += regexBuffer;
+ regexBuffer = "";
+ regexGuess = null;
emit(ch);
} else if (ch === "\n" || ch === "\r") {
- inRegex = false;
- inRegexClass = false;
- escaped = false;
- regexMisread = true;
- emit(ch);
+ recoverFromMisread(regexGuess);
+ continue;
} else {
emit(" ");
}
@@ -648,8 +736,14 @@ export function stripJsStringLiterals(source: string): string {
continue;
}
- if (ch === "/" && next !== "/" && next !== "*" && ctx.startsRegexLiteral()) {
- inRegex = true;
+ if (
+ ch === "/" &&
+ next !== "/" &&
+ next !== "*" &&
+ i !== knownDivisionIndex &&
+ ctx.startsRegexLiteral()
+ ) {
+ regexGuess = { index: i, context: ctx.snapshot() };
emit(ch);
i += 1;
continue;
@@ -674,7 +768,11 @@ export function stripJsStringLiterals(source: string): string {
i += 1;
}
- if (quote !== null || templateBraces.length > 0 || inRegex || regexMisread) return source;
+ // `regexGuess` can never be non-null here: the only way out of the loop
+ // above is its `break`, which is itself gated on `!regexGuess` — a guess
+ // still open at EOF is recovered (and re-walked) exactly like one open at
+ // a line boundary, never left to fall out of the loop.
+ if (quote !== null || templateBraces.length > 0) return source;
return out;
}