@mehshekari/gsap-motion 3.0.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,11 +11,92 @@
11
11
  * `{ index, message, hint }`. `level` is "error" (a bug), "warn" (a likely
12
12
  * bug or a real cost) or "info" (worth a look, higher false-positive rate).
13
13
  *
14
+ * Rules read `file.ast`, the syntax tree `ast.mjs` builds. A file that does not
15
+ * parse never reaches a rule: the runner reports it as `not-parsed`.
16
+ *
14
17
  * Every rule has fixtures in `scripts/test/rules.test.mjs`: a case it must fire
15
18
  * on and a case it must stay quiet on. A wrong finding in either direction is
16
19
  * fixed by adding the case there first and watching it fail.
17
20
  */
18
- import { blockAfter, parenSpan, within } from "./source.mjs";
21
+ import {
22
+ ancestorsOf,
23
+ calleeName,
24
+ chainStart,
25
+ contains,
26
+ dottedName,
27
+ findAll,
28
+ gsapContexts,
29
+ isFunction,
30
+ isGsapCall,
31
+ keptUnder,
32
+ keyName,
33
+ methodName,
34
+ moduleSource,
35
+ numberValue,
36
+ ownVars,
37
+ parentOf,
38
+ propertyOf,
39
+ resolveFunction,
40
+ scopeOf,
41
+ staticString,
42
+ timelineLinks,
43
+ tweenMethod,
44
+ unwrap,
45
+ varsObjects,
46
+ } from "./ast.mjs";
47
+
48
+ /** What each rule says, in one place. */
49
+ const HINT = {
50
+ orphanTween:
51
+ "Nothing reverts it on unmount, and React StrictMode runs it twice in development. Move it into the useGSAP body, or wrap the handler in contextSafe. A helper counts as inside only when every use of it is a call from inside a context.",
52
+ unmanagedInstance:
53
+ "Inside a context these register themselves and are reverted with it. Out here nothing is watching: move it into the useGSAP body, or keep the instance and revert/kill it from the cleanup.",
54
+ danglingListener:
55
+ "A listener that survives unmount keeps the component's closure — and its DOM nodes — alive. Name the handler and remove it in the cleanup, or pass `{ signal }` from an AbortController and abort it there.",
56
+ tweenPerEvent:
57
+ "One tween allocated per event, each fighting the last. Create a `gsap.quickTo()` once and call it from the handler — or, for work that only matters once resizing stops, debounce it.",
58
+ statePerEvent:
59
+ "A re-render per frame so one element can move a few pixels. State owns what exists; GSAP owns how it moves — use a ref and quickTo.",
60
+ triggerPerItem:
61
+ "Each carries its own start/end maths on every scroll frame. If the items belong to one visual moment, use one trigger with a stagger. Per-item is right only when they genuinely reveal independently — pinned card stacks, for instance.",
62
+ easedLoop:
63
+ 'The tween decelerates into its own restart, so the loop seam becomes visible. Use `ease: "none"`, or `yoyo: true` if it should breathe.',
64
+ easedScrub:
65
+ "The scrollbar is the playhead, so an ease fights the visitor's own scrolling and reads as lag. Use `ease: \"none\"` on the children and let `scrub: 1` do the smoothing.",
66
+ neverCompletesOwn:
67
+ "An endless repeat never completes. Use `onRepeat` for something that should happen every cycle, or give it a finite `repeat`.",
68
+ neverCompletesChild:
69
+ 'The child gives the timeline an effectively infinite duration, and a tween added after it does not end it. Hand over at a position instead — `.call(fn, [], "label+=0.5")` fires when the playhead passes — or run the loop as its own tween outside the timeline.',
70
+ lateTransformOrigin:
71
+ "On an SVG element GSAP holds the element in place when its origin changes, and that correction outlives the tween: it ends offset, silently — a circle grown from 0 about its centre lands a whole radius off. Put `transformOrigin` or `svgOrigin` in the from-vars, or set it before the tween. HTML elements are unaffected.",
72
+ sharedPluginId:
73
+ "useGSAP scopes the selectors it resolves itself, but MotionPath and MorphSVG resolve their own config against the whole document. Mount this component twice and both instances drive the first one's geometry, silently. Namespace it with useId().",
74
+ unregisteredPlugin:
75
+ "An unregistered plugin's properties are ignored without an error — the animation simply does nothing.",
76
+ missingReducedMotion:
77
+ "Reduced motion is a design, not an off switch: decide what the motion was saying and say it without the travel. Note that the reduced branch must set the END state — a `from` that never runs leaves its target invisible.",
78
+ devToolImport:
79
+ "A static import ships regardless of any `if` around the call. Load it with a dynamic `import()` behind a NODE_ENV check, or bake its output in and delete it.",
80
+ markers: 'Gate it: `markers: process.env.NODE_ENV === "development"`.',
81
+ barrelImport:
82
+ "Import each from its own subpath — `gsap/ScrollTrigger` — so the bundler can drop the ones this route does not use.",
83
+ };
84
+
85
+ /** Messages that do not vary with what was found. */
86
+ const MESSAGE = {
87
+ easedLoop: "An infinite repeat with an ease other than `none`.",
88
+ easedScrub: "Easing inside a scrubbed ScrollTrigger timeline.",
89
+ neverCompletesChild:
90
+ "An endlessly repeating child in a timeline with an `onComplete`, which therefore never fires.",
91
+ lateTransformOrigin:
92
+ "The transform origin is only in this `fromTo`'s to-vars, while its from-vars scale, rotate or skew.",
93
+ };
94
+
95
+ /** Whether a string, template chunk or JSX text spells `text`. Comments are not nodes. */
96
+ const spells = (node, text) =>
97
+ (node.type === "Literal" && typeof node.raw === "string" && node.raw.includes(text)) ||
98
+ (node.type === "TemplateElement" && node.value.raw.includes(text)) ||
99
+ (node.type === "JSXText" && node.value.includes(text));
19
100
 
20
101
  /** Properties whose animation forces the browser to re-run layout. */
21
102
  const LAYOUT_PROPERTIES = [
@@ -62,157 +143,354 @@ const TRANSFORM_FOR = {
62
143
  const PERCENT_CAVEAT =
63
144
  "Careful with percentages: `top: 50%` is half the containing block, `yPercent: 50` is half the element itself. Either wrap it in a full-height box and translate that — the percentages then agree — or measure the container once and translate in pixels.";
64
145
 
65
- /** Events that fire many times a second. Global: every occurrence is scanned. */
66
- const HOT_EVENTS =
67
- /(pointermove|mousemove|touchmove|onMouseMove|onPointerMove|"scroll"|'scroll'|wheel)/g;
68
-
69
- /** Every `gsap.to/from/fromTo/set(` call site, as offsets. */
70
- const tweenCalls = (code) => [
71
- ...code.matchAll(/\bgsap\.(to|from|fromTo|set|timeline)\s*\(/g),
72
- ];
146
+ /**
147
+ * The hint for an animated layout property: the transform that replaces it,
148
+ * and the percentage trap when the value is a percentage.
149
+ */
150
+ function layoutHint(property, percent) {
151
+ const swap = TRANSFORM_FOR[property];
152
+ return swap
153
+ ? `Use \`${swap}\` instead — a compositor-only property.` +
154
+ (percent ? ` ${PERCENT_CAVEAT}` : "")
155
+ : "Prefer transform and opacity; they never touch layout.";
156
+ }
73
157
 
74
- const find = (code, pattern) => [...code.matchAll(pattern)];
158
+ /** Whether a transform's from-value, as written, leaves the element untransformed. */
159
+ function identityTransform(property, value) {
160
+ const v = value.trim().replace(/^["'`]|["'`]$/g, "");
161
+ return property.startsWith("scale")
162
+ ? /^1(?:\.0+)?$/.test(v)
163
+ : /^-?0(?:\.0+)?(?:deg|rad)?$/.test(v);
164
+ }
75
165
 
76
166
  /** An import from `gsap/*` that is a plugin needing `registerPlugin`. */
77
167
  const PLUGIN_NAME =
78
168
  /(?:Plugin|Trigger|Text|Flip|Observer|Draggable|Ease|Smoother|Bounce|Wiggle)$/;
79
169
 
80
170
  /**
81
- * Whether the instance constructed at `index` is kept under a name and
82
- * reverted or killed through that name.
83
- *
84
- * This used to ask whether `.revert(` or `.kill(` appeared anywhere in the
85
- * file, so one unrelated `tl.kill()` exempted every instance beside it.
171
+ * Events that fire many times a second, by the name a listener is given — not
172
+ * a substring anywhere in the file, which could not include `resize` without
173
+ * reporting every `ResizeObserver`.
86
174
  */
87
- function tornDown(code, index) {
88
- const kept = code
89
- .slice(Math.max(0, index - 200), index)
90
- .match(/([\w$][\w$.]*)\s*=\s*$/);
91
- if (!kept) return false;
92
- const name = kept[1].replace(/[.$]/g, "\\$&");
93
- return new RegExp(
94
- `(?<![\\w$.])${name}\\s*\\??\\.\\s*(?:revert|kill)\\s*\\(`,
95
- ).test(code);
96
- }
175
+ const HOT_EVENTS = new Set([
176
+ "pointermove",
177
+ "pointerrawupdate",
178
+ "mousemove",
179
+ "touchmove",
180
+ "scroll",
181
+ "wheel",
182
+ "resize",
183
+ ]);
97
184
 
98
185
  /**
99
- * Every call to `method` (`addEventListener` / `removeEventListener`), with
100
- * the event name and handler read out of its arguments.
101
- *
102
- * Pairing by event and handler rather than counting calls: two adds and two
103
- * removes used to pass even when both removes were for the same event, and an
104
- * inline arrow — which no remove can ever reach — passed whenever any remove
105
- * existed. A dynamic event name cannot be judged statically, so it is skipped.
186
+ * A resize handler that sets state is how a component learns its width: a
187
+ * render per resize, but a render deciding what exists rather than a frame of
188
+ * motion. `state-per-event` leaves it out; a tween per resize is still a tween
189
+ * per event.
106
190
  */
107
- function listenerCalls(code, method) {
108
- return find(code, new RegExp(`\\b${method}\\s*\\(`, "g")).map((m) => {
109
- const open = m.index + m[0].length - 1;
110
- const span = parenSpan(code, open);
111
- const args = span ? code.slice(open + 1, span[1]) : "";
112
- const event = args.match(/^\s*(["'`])([\w:-]+)\1\s*,\s*/);
113
- if (!event) return { index: m.index, event: null };
114
-
115
- const rest = args.slice(event[0].length);
116
- const inline = /^(?:async\b|function\b|\(|[\w$]+\s*=>)/.test(rest);
117
- return {
118
- index: m.index,
119
- event: event[2],
120
- inline,
121
- handler: inline ? null : (rest.match(/^[\w$.]+/)?.[0] ?? null),
122
- /** Removed by an AbortController, or by the browser after one call. */
123
- selfCleaning: /\bsignal\b|\bonce\s*:\s*true/.test(rest),
124
- };
125
- });
191
+ const STATE_HOT_EVENTS = new Set(
192
+ [...HOT_EVENTS].filter((event) => event !== "resize"),
193
+ );
194
+
195
+ /** `addEventListener` or `removeEventListener` when `node` calls one, on any receiver. */
196
+ function listenerMethod(node) {
197
+ if (node.type !== "CallExpression") return null;
198
+ const callee = unwrap(node.callee);
199
+ const name =
200
+ callee?.type === "Identifier"
201
+ ? callee.name
202
+ : callee?.type === "MemberExpression" && !callee.computed
203
+ ? callee.property.name
204
+ : null;
205
+ return name === "addEventListener" || name === "removeEventListener" ? name : null;
126
206
  }
127
207
 
128
208
  /**
129
- * The argument spans of the `.to(` / `.from(` / `.fromTo(` calls — or of
130
- * whichever `methods` are asked for — chained on from `cursor`, following the
131
- * chain through labels and other calls.
209
+ * Every handler for one of `events`, as `{ name, node }`: the second argument
210
+ * of an `addEventListener` whose first names that event, or the expression of a
211
+ * JSX prop for it. A wrapping call — `debounce(fn, 150)` — is kept whole. With
212
+ * `followNames`, a handler passed by name is followed to its declaration when
213
+ * the file declares exactly one function of that name.
132
214
  */
133
- function chainedTweens(code, cursor, methods = ["to", "from", "fromTo"]) {
134
- const spans = [];
135
- for (;;) {
136
- const next = code.slice(cursor).match(/^\s*\.\s*([\w$]+)\s*\(/);
137
- if (!next) return spans;
138
- const open = cursor + next[0].length - 1;
139
- const span = parenSpan(code, open);
140
- if (!span) return spans;
141
- if (methods.includes(next[1])) spans.push([open, span[1]]);
142
- cursor = span[1] + 1;
215
+ function handlersFor(ast, events, { followNames = true } = {}) {
216
+ const handler = (expression) => {
217
+ const node = unwrap(expression);
218
+ if (node?.type === "CallExpression") return node;
219
+ if (!followNames && !isFunction(node)) return null;
220
+ return resolveFunction(ast, node);
221
+ };
222
+ const handlers = [];
223
+
224
+ for (const call of findAll(ast, (node) => listenerMethod(node) === "addEventListener")) {
225
+ const event = staticString(call.arguments[0]);
226
+ if (event === null || !events.has(event) || !call.arguments[1]) continue;
227
+ const node = handler(call.arguments[1]);
228
+ if (node) handlers.push({ name: event, node });
143
229
  }
230
+
231
+ const props = findAll(
232
+ ast,
233
+ (node) => node.type === "JSXAttribute" && node.name.type === "JSXIdentifier",
234
+ );
235
+ for (const attribute of props) {
236
+ const prop = attribute.name.name.match(
237
+ /^on(PointerMove|MouseMove|TouchMove|Scroll|Wheel)(?:Capture)?$/,
238
+ );
239
+ if (!prop || !events.has(prop[1].toLowerCase())) continue;
240
+ if (attribute.value?.type !== "JSXExpressionContainer") continue;
241
+ const node = handler(attribute.value.expression);
242
+ if (node) handlers.push({ name: `on${prop[1]}`, node });
243
+ }
244
+
245
+ return handlers;
144
246
  }
145
247
 
146
248
  /**
147
- * What is added to the timeline created by the `gsap.timeline(` match `call`,
148
- * whose arguments close at `close`: the argument spans of its children, and
149
- * whether an `onComplete` is attached through `eventCallback`.
249
+ * Whether `node` runs once the events stop rather than once per event: the
250
+ * handler is a `debounce(…)` call, or `node` sits inside a `setTimeout` that
251
+ * the same handler also clears.
252
+ */
253
+ function debounced(handler, node) {
254
+ const timer = (name) => (n) => {
255
+ const called = calleeName(n);
256
+ return called === name || called === `window.${name}`;
257
+ };
258
+ const wrapper = handler.node.type === "CallExpression" ? calleeName(handler.node) : null;
259
+ if (wrapper === "debounce" || wrapper?.endsWith(".debounce")) return true;
260
+ if (!findAll(handler.node, timer("clearTimeout")).length) return false;
261
+ return findAll(handler.node, timer("setTimeout")).some(
262
+ (call) => call !== node && contains(call, node),
263
+ );
264
+ }
265
+
266
+ /** The calls that add a child to a timeline. */
267
+ const CHILDREN = new Set(["to", "from", "fromTo", "add"]);
268
+
269
+ const TEARDOWN = new Set(["kill", "revert"]);
270
+ const FINISH = new Set(["progress", "totalProgress"]);
271
+
272
+ /**
273
+ * Whether the value built at `node` is torn down by hand somewhere in the file:
274
+ * kept under a name that is `.kill()`ed or `.revert()`ed, or finished with
275
+ * `.progress(1)`, which completes it so GSAP lets it go.
150
276
  *
151
- * Both are read from the chain on the call itself and from every chain on the
152
- * variable it was kept in — including one that starts with a label, which is
153
- * how a sequence usually opens. Those uses are read only up to the next
154
- * declaration of the same name, so two functions that each keep a `tl` do not
155
- * lend each other their children.
277
+ * Followed through one helper. When a function returns the value — alone, or as
278
+ * an element of an array — every call to that function must keep the result, or
279
+ * that element, under a name that is torn down. Found on the corpus: a component
280
+ * that builds its timeline and trigger in `getProjectsSt()` and kills what the
281
+ * effect destructures from it.
156
282
  */
157
- function timelineCalls(code, call, close) {
158
- const CHILDREN = ["to", "from", "fromTo", "add"];
159
- const children = chainedTweens(code, close + 1, CHILDREN);
160
- const callbacks = chainedTweens(code, close + 1, ["eventCallback"]);
161
-
162
- const kept = code
163
- .slice(Math.max(0, call.index - 80), call.index)
164
- .match(/([\w$]+)\s*=\s*$/);
165
- if (kept) {
166
- const name = kept[1].replace(/\$/g, "\\$");
167
- const redeclared = code
168
- .slice(close)
169
- .search(new RegExp(`\\b(?:const|let|var)\\s+${name}\\b`));
170
- const end = redeclared === -1 ? code.length : close + redeclared;
171
- const uses = new RegExp(`(?<![\\w$.])${name}(?=\\s*\\.\\s*[\\w$]+\\s*\\()`, "g");
172
-
173
- for (const use of code.slice(close, end).matchAll(uses)) {
174
- const cursor = close + use.index + use[0].length;
175
- children.push(...chainedTweens(code, cursor, CHILDREN));
176
- callbacks.push(...chainedTweens(code, cursor, ["eventCallback"]));
283
+ function tornDown(ast, node) {
284
+ const endsUnder = (name) =>
285
+ Boolean(name) &&
286
+ findAll(ast, (n) => {
287
+ const method = methodName(n);
288
+ if (!method) return false;
289
+ const ends =
290
+ TEARDOWN.has(method) || (FINISH.has(method) && numberValue(n.arguments[0]) === 1);
291
+ return ends && dottedName(unwrap(n.callee).object) === name;
292
+ }).length > 0;
293
+
294
+ const kept = keptUnder(ast, node);
295
+ if (endsUnder(kept?.name)) return true;
296
+
297
+ const fn = scopeOf(ast, node);
298
+ if (!isFunction(fn)) return false;
299
+
300
+ const holds = (expression) => {
301
+ const value = unwrap(expression);
302
+ if (!value) return false;
303
+ if (value === node) return true;
304
+ if (value.type === "CallExpression" && chainStart(value) === node) return true;
305
+ return value.type === "Identifier" && kept?.name === value.name;
306
+ };
307
+
308
+ const returned = findAll(
309
+ fn,
310
+ (n) => n.type === "ReturnStatement" && n.argument && scopeOf(ast, n) === fn,
311
+ ).map((n) => n.argument);
312
+ if (fn.type === "ArrowFunctionExpression" && fn.expression) returned.push(fn.body);
313
+
314
+ let slot = null;
315
+ for (const expression of returned) {
316
+ const value = unwrap(expression);
317
+ if (holds(value)) {
318
+ slot = "whole";
319
+ break;
320
+ }
321
+ if (value?.type === "ArrayExpression") {
322
+ const index = value.elements.findIndex((element) => element && holds(element));
323
+ if (index !== -1) {
324
+ slot = index;
325
+ break;
326
+ }
177
327
  }
178
328
  }
329
+ if (slot === null) return false;
330
+
331
+ const holder = parentOf(ast, fn);
332
+ const name =
333
+ fn.type === "FunctionDeclaration"
334
+ ? fn.id?.name
335
+ : holder?.type === "VariableDeclarator" &&
336
+ holder.init === fn &&
337
+ holder.id.type === "Identifier"
338
+ ? holder.id.name
339
+ : null;
340
+ if (!name) return false;
179
341
 
180
- const completes = callbacks.some(([open, end]) =>
181
- /^\(\s*["'`]onComplete["'`]/.test(code.slice(open, end)),
342
+ const calls = findAll(
343
+ ast,
344
+ (n) =>
345
+ n.type === "CallExpression" &&
346
+ unwrap(n.callee)?.type === "Identifier" &&
347
+ unwrap(n.callee).name === name,
348
+ );
349
+ return (
350
+ calls.length > 0 &&
351
+ calls.every((call) => {
352
+ const parent = parentOf(ast, call);
353
+ const target =
354
+ parent?.type === "VariableDeclarator" && parent.init === call
355
+ ? parent.id
356
+ : parent?.type === "AssignmentExpression" && parent.right === call
357
+ ? parent.left
358
+ : null;
359
+ if (!target) return false;
360
+ if (slot === "whole") return endsUnder(dottedName(target));
361
+ const element = target.type === "ArrayPattern" ? target.elements[slot] : null;
362
+ return element?.type === "Identifier" && endsUnder(element.name);
363
+ })
182
364
  );
183
- return { children, completes };
184
365
  }
185
366
 
186
367
  /**
187
- * The top-level arguments of the call whose parentheses span `[open, close]`,
188
- * as `[start, end]` pairs. A comma inside brackets, braces or a string does not
189
- * separate arguments.
368
+ * Whether `node`, inside a high-frequency handler, runs less often than once
369
+ * per event: inside a completion callback — `onComplete`, `onStart`,
370
+ * `onReverseComplete`, `onInterrupt` — of something the handler started, or
371
+ * behind an in-flight flag the handler raises first,
372
+ * `if (!busy.current) { busy.current = true; … }`.
190
373
  */
191
- function callArgs(code, open, close) {
192
- const args = [];
193
- let depth = 0;
194
- let quote = "";
195
- let start = open + 1;
196
-
197
- for (let i = open + 1; i < close; i += 1) {
198
- const c = code[i];
199
-
200
- if (quote) {
201
- if (c === "\\") i += 1;
202
- else if (c === quote) quote = "";
203
- continue;
374
+ function notPerEvent(ast, handler, node) {
375
+ for (const ancestor of ancestorsOf(ast, node)) {
376
+ if (ancestor === handler.node) return false;
377
+ if (
378
+ ancestor.type === "Property" &&
379
+ /^on(?:Complete|Start|ReverseComplete|Interrupt)$/.test(keyName(ancestor) ?? "") &&
380
+ isFunction(unwrap(ancestor.value))
381
+ ) {
382
+ return true;
204
383
  }
205
- if (c === '"' || c === "'" || c === "`") quote = c;
206
- else if ("([{".includes(c)) depth += 1;
207
- else if (")]}".includes(c)) depth -= 1;
208
- else if (c === "," && depth === 0) {
209
- args.push([start, i]);
210
- start = i + 1;
384
+ if (ancestor.type === "IfStatement" && contains(ancestor.consequent, node)) {
385
+ const test = unwrap(ancestor.test);
386
+ const flag =
387
+ test?.type === "UnaryExpression" && test.operator === "!"
388
+ ? dottedName(test.argument)
389
+ : null;
390
+ const raised =
391
+ flag !== null &&
392
+ findAll(ancestor.consequent, (n) => {
393
+ if (n.type !== "AssignmentExpression" || dottedName(n.left) !== flag) return false;
394
+ const value = unwrap(n.right);
395
+ return value?.type === "Literal" && value.value === true;
396
+ }).length > 0;
397
+ if (raised) return true;
211
398
  }
212
399
  }
400
+ return false;
401
+ }
402
+
403
+ /** Listener targets that outlive every component: the window, the document and its root elements. */
404
+ const GLOBAL_TARGETS = new Set([
405
+ "window",
406
+ "document",
407
+ "document.body",
408
+ "document.documentElement",
409
+ "globalThis",
410
+ "self",
411
+ "visualViewport",
412
+ "window.visualViewport",
413
+ ]);
213
414
 
214
- if (code.slice(start, close).trim()) args.push([start, close]);
215
- return args;
415
+ /**
416
+ * Whether `target` is an element or object the file builds itself — every
417
+ * value it is given comes from `new …` or `createElement` — and so takes its
418
+ * listeners with it when it goes.
419
+ */
420
+ function createdHere(ast, target) {
421
+ const builds = (value) => {
422
+ const v = unwrap(value);
423
+ return (
424
+ v?.type === "NewExpression" ||
425
+ ["createElement", "createElementNS", "cloneNode"].includes(methodName(v))
426
+ );
427
+ };
428
+ if (builds(target)) return true;
429
+ if (target.type !== "Identifier") return false;
430
+
431
+ const assignments = findAll(
432
+ ast,
433
+ (n) =>
434
+ (n.type === "VariableDeclarator" &&
435
+ n.id.type === "Identifier" &&
436
+ n.id.name === target.name &&
437
+ n.init) ||
438
+ (n.type === "AssignmentExpression" && dottedName(n.left) === target.name),
439
+ );
440
+ return (
441
+ assignments.length > 0 &&
442
+ assignments.every((n) => builds(n.type === "VariableDeclarator" ? n.init : n.right))
443
+ );
444
+ }
445
+
446
+ /** A value that is the same on every call: a literal, a plain template, `undefined`. */
447
+ const isConstant = (node) => {
448
+ const value = unwrap(node);
449
+ return (
450
+ value?.type === "Literal" ||
451
+ staticString(value) !== null ||
452
+ (value?.type === "Identifier" && value.name === "undefined")
453
+ );
454
+ };
455
+
456
+ /**
457
+ * The plugins a file registers, by the names they are imported under from
458
+ * `gsap/*`, so `ScrollTrigger as ST` registered as `ST` is `ScrollTrigger`.
459
+ * Registration is global, so the runner unions these across every audited file
460
+ * before any rule runs.
461
+ */
462
+ export function pluginRegistrations(file) {
463
+ const names = new Set();
464
+ if (!file.ast) return names;
465
+
466
+ const imported = new Map();
467
+ for (const declaration of file.ast.body) {
468
+ if (declaration.type !== "ImportDeclaration") continue;
469
+ const from = declaration.source.value;
470
+ if (typeof from !== "string" || !/^gsap\/[\w/-]+$/.test(from)) continue;
471
+ for (const specifier of declaration.specifiers) {
472
+ const name =
473
+ specifier.type === "ImportSpecifier"
474
+ ? (specifier.imported.name ?? specifier.imported.value)
475
+ : specifier.type === "ImportDefaultSpecifier"
476
+ ? from.split("/").pop()
477
+ : null;
478
+ if (name) imported.set(specifier.local.name, name);
479
+ }
480
+ }
481
+
482
+ const registrations = findAll(file.ast, (node) => {
483
+ const name = calleeName(node);
484
+ return name === "registerPlugin" || name?.endsWith(".registerPlugin");
485
+ });
486
+ for (const call of registrations) {
487
+ for (const argument of call.arguments) {
488
+ for (const id of findAll(argument, (n) => n.type === "Identifier")) {
489
+ names.add(imported.get(id.name) ?? id.name);
490
+ }
491
+ }
492
+ }
493
+ return names;
216
494
  }
217
495
 
218
496
  export const RULES = [
@@ -220,29 +498,25 @@ export const RULES = [
220
498
  {
221
499
  id: "orphan-tween",
222
500
  level: "error",
501
+ /**
502
+ * A GSAP tween call inside a function but outside every context — module
503
+ * scope is not the target. Contexts are recognised under the names the file
504
+ * gives them, and a helper counts as inside when every use of it is a call
505
+ * from inside one. A value torn down by hand — see `tornDown` — is not
506
+ * reported: "nothing reverts it" would be false.
507
+ */
223
508
  test(file) {
224
509
  if (!file.isReact || !file.usesGsap) return [];
510
+ const inside = gsapContexts(file.ast);
225
511
 
226
- return tweenCalls(file.code)
227
- .filter((m) => m[1] !== "set" && !within(file.contexts, m.index))
228
- .filter((m) => {
229
- /**
230
- * Only tweens inside some function — module-scope constants are not
231
- * the target here.
232
- *
233
- * This used to read `\b(useEffect|useLayoutEffect|function|=>)\s*[({]`.
234
- * `\b` needs a word character beside it and `=>` has none, so the
235
- * arrow alternative could never match: every tween in an arrow-function
236
- * handler, which is most of React, was silently exempt. `function`
237
- * only matched the anonymous `function (`, never `function Name(`.
238
- */
239
- const before = file.code.slice(0, m.index);
240
- return /(?:\bfunction\b[^(]*\(|=>)/.test(before.slice(-400));
241
- })
242
- .map((m) => ({
243
- index: m.index,
244
- message: `\`gsap.${m[1]}\` is created outside any useGSAP/gsap.context scope.`,
245
- hint: "Nothing reverts it on unmount, and React StrictMode runs it twice in development. Move it into the useGSAP body, or wrap the handler in contextSafe. This check does not follow calls: a helper that tweens reads as outside even when only the body calls it — keep the measuring in the helper and the tween in the body.",
512
+ return findAll(file.ast, (node) => isGsapCall(node))
513
+ .filter((call) => !inside(call))
514
+ .filter((call) => ancestorsOf(file.ast, call).some(isFunction))
515
+ .filter((call) => !tornDown(file.ast, call))
516
+ .map((call) => ({
517
+ index: call.start,
518
+ message: `\`gsap.${methodName(call)}\` is created outside any useGSAP/gsap.context scope.`,
519
+ hint: HINT.orphanTween,
246
520
  }));
247
521
  },
248
522
  },
@@ -263,41 +537,97 @@ export const RULES = [
263
537
  {
264
538
  id: "unmanaged-instance",
265
539
  level: "error",
540
+ /**
541
+ * Torn down as `tornDown` reads it: kept under a name — through any chain
542
+ * called on it, or a helper's return — that has `.revert()` or `.kill()`
543
+ * called on it somewhere in the file, optional chaining included.
544
+ */
266
545
  test(file) {
267
- const constructors =
268
- /\b(?:gsap\.(matchMedia)|(Observer|Draggable|ScrollSmoother)\.create|new (SplitText))\s*\(/g;
269
-
270
- return find(file.code, constructors)
271
- .filter((m) => !within(file.contexts, m.index))
272
- .filter((m) => !tornDown(file.code, m.index))
273
- .map((m) => ({
274
- index: m.index,
275
- message: `\`${m[1] ?? m[2] ?? m[3]}\` is created outside any useGSAP/gsap.context scope and never torn down.`,
276
- hint: "Inside a context these register themselves and are reverted with it. Out here nothing is watching: move it into the useGSAP body, or keep the instance and revert/kill it from the cleanup.",
546
+ const inside = gsapContexts(file.ast);
547
+ const CREATORS = new Set(["Observer", "Draggable", "ScrollSmoother", "ScrollTrigger"]);
548
+
549
+ const label = (node) => {
550
+ if (node.type === "NewExpression") {
551
+ const callee = unwrap(node.callee);
552
+ return callee?.type === "Identifier" && callee.name === "SplitText"
553
+ ? "SplitText"
554
+ : null;
555
+ }
556
+ if (node.type !== "CallExpression") return null;
557
+ const name = calleeName(node);
558
+ if (name === "gsap.matchMedia") return "matchMedia";
559
+ const [owner, method, extra] = name?.split(".") ?? [];
560
+ return method === "create" && extra === undefined && CREATORS.has(owner)
561
+ ? owner
562
+ : null;
563
+ };
564
+ return findAll(file.ast, (node) => label(node) !== null)
565
+ .filter((node) => !inside(node))
566
+ .filter((node) => !tornDown(file.ast, node))
567
+ .map((node) => ({
568
+ index: node.start,
569
+ message: `\`${label(node)}\` is created outside any useGSAP/gsap.context scope and never torn down.`,
570
+ hint: HINT.unmanagedInstance,
277
571
  }));
278
572
  },
279
573
  },
280
574
  {
281
575
  id: "dangling-listener",
282
576
  level: "warn",
577
+ /**
578
+ * Adds and removes are paired by event and by the handler's name as
579
+ * written, across the file — not by counting calls. A handler that is not a
580
+ * plain name — an inline function, or `fn.bind(this)`, which makes a new
581
+ * function each call — can never be matched by a remove. `signal` and
582
+ * `once: true` are read from the options object. A dynamic event name
583
+ * cannot be judged statically, so it is skipped.
584
+ *
585
+ * Only a target that can outlive the code is reported. An element or object
586
+ * the file creates takes its listeners with it. Outside React, where nothing
587
+ * says when code stops running, only the window and the document can: a
588
+ * listener on a page's own button lasts exactly as long as the button.
589
+ */
283
590
  test(file) {
591
+ const listeners = (method) =>
592
+ findAll(file.ast, (node) => listenerMethod(node) === method).flatMap((call) => {
593
+ const event = staticString(call.arguments[0]);
594
+ const handler = unwrap(call.arguments[1]);
595
+ if (event === null || !/^[\w:-]+$/.test(event) || !handler) return [];
596
+ const options = unwrap(call.arguments[2]);
597
+ const once = unwrap(propertyOf(options, "once")?.value);
598
+ const callee = unwrap(call.callee);
599
+ const target = callee.type === "MemberExpression" ? unwrap(callee.object) : null;
600
+ return [
601
+ {
602
+ global: target === null || GLOBAL_TARGETS.has(dottedName(target)),
603
+ created: target !== null && createdHere(file.ast, target),
604
+ index: callee.type === "MemberExpression" ? callee.property.start : callee.start,
605
+ event,
606
+ handler: dottedName(handler),
607
+ inline: isFunction(handler) || dottedName(handler) === null,
608
+ selfCleaning:
609
+ Boolean(propertyOf(options, "signal")) ||
610
+ (once?.type === "Literal" && once.value === true),
611
+ },
612
+ ];
613
+ });
614
+
284
615
  const removed = new Set(
285
- listenerCalls(file.code, "removeEventListener")
616
+ listeners("removeEventListener")
286
617
  .filter((call) => call.handler)
287
618
  .map((call) => `${call.event}|${call.handler}`),
288
619
  );
289
620
 
290
- return listenerCalls(file.code, "addEventListener")
291
- .filter((call) => call.event && !call.selfCleaning)
292
- .filter(
293
- (call) => call.inline || !removed.has(`${call.event}|${call.handler}`),
294
- )
621
+ return listeners("addEventListener")
622
+ .filter((call) => !call.selfCleaning && !call.created)
623
+ .filter((call) => file.isReact || call.global)
624
+ .filter((call) => call.inline || !removed.has(`${call.event}|${call.handler}`))
295
625
  .map((call) => ({
296
626
  index: call.index,
297
627
  message: call.inline
298
628
  ? `An inline \`${call.event}\` listener, which no removeEventListener can reach.`
299
629
  : `\`${call.event}\` listener \`${call.handler}\` is added and never removed.`,
300
- hint: "A listener that survives unmount keeps the component's closure — and its DOM nodes — alive. Name the handler and remove it in the cleanup, or pass `{ signal }` from an AbortController and abort it there.",
630
+ hint: HINT.danglingListener,
301
631
  }));
302
632
  },
303
633
  },
@@ -306,114 +636,151 @@ export const RULES = [
306
636
  {
307
637
  id: "tween-per-event",
308
638
  level: "error",
639
+ /**
640
+ * A GSAP tween call inside a high-frequency handler, followed by name when
641
+ * it can be. A debounced one, one in a completion callback, and one behind
642
+ * an in-flight flag run less than once per event, and are not reported.
643
+ */
309
644
  test(file) {
310
- const findings = [];
311
-
312
- for (const handler of find(file.code, HOT_EVENTS)) {
313
- const span = blockAfter(file.code, handler.index);
314
- if (!span) continue;
315
-
316
- for (const call of tweenCalls(file.code)) {
317
- if (call[1] === "set" || !within([span], call.index)) continue;
318
- findings.push({
319
- index: call.index,
320
- message: `\`gsap.${call[1]}\` inside a high-frequency handler (${handler[1]}).`,
321
- hint: "One tween allocated per event, each fighting the last. Create a `gsap.quickTo()` once and call it from the handler.",
322
- });
323
- }
324
- }
645
+ const calls = findAll(file.ast, (node) => isGsapCall(node));
325
646
 
326
- return findings;
647
+ return handlersFor(file.ast, HOT_EVENTS).flatMap((handler) =>
648
+ calls
649
+ .filter((call) => contains(handler.node, call))
650
+ .filter((call) => !debounced(handler, call))
651
+ .filter((call) => !notPerEvent(file.ast, handler, call))
652
+ .map((call) => ({
653
+ index: call.start,
654
+ message: `\`gsap.${methodName(call)}\` inside a high-frequency handler (${handler.name}).`,
655
+ hint: HINT.tweenPerEvent,
656
+ })),
657
+ );
327
658
  },
328
659
  },
329
660
  {
330
661
  id: "state-per-event",
331
662
  level: "error",
663
+ /**
664
+ * A bare `setX(…)` call — not a member call such as `el.style.setProperty`,
665
+ * which is exactly what a pointer handler should do, and not a timer —
666
+ * inside an inline high-frequency handler other than resize.
667
+ *
668
+ * A handler passed by name is not followed here, unlike tween-per-event. A
669
+ * named scroll handler is usually a scroll-spy setting a discrete value — a
670
+ * section id, a boolean — and React skips the render when that value has not
671
+ * changed, so "a render per frame" would be false and, at error level, would
672
+ * fail correct code. Telling a continuous value from a discrete one is a
673
+ * precision question for the corpus, not something to guess.
674
+ *
675
+ * A setter called with a constant — `null`, a boolean, a literal — is not
676
+ * reported either: once the state holds that value React skips the render,
677
+ * so it costs one render, not one per frame. The corpus found
678
+ * `onScroll={() => setHoveredCard(null)}`.
679
+ */
332
680
  test(file) {
333
681
  if (!file.isReact) return [];
334
- const findings = [];
335
-
336
- for (const handler of find(file.code, HOT_EVENTS)) {
337
- const span = blockAfter(file.code, handler.index);
338
- if (!span) continue;
339
-
340
- for (const setter of find(file.code, /\bset[A-Z]\w*\s*\(/g)) {
341
- if (!within([span], setter.index)) continue;
342
- findings.push({
343
- index: setter.index,
344
- message: `React state setter inside a ${handler[1]} handler.`,
345
- hint: "A re-render per frame so one element can move a few pixels. State owns what exists; GSAP owns how it moves — use a ref and quickTo.",
346
- });
347
- }
348
- }
682
+ const setters = findAll(file.ast, (node) => {
683
+ if (node.type !== "CallExpression") return false;
684
+ const callee = unwrap(node.callee);
685
+ return (
686
+ callee?.type === "Identifier" &&
687
+ /^set(?!Timeout$|Interval$)[A-Z]\w*$/.test(callee.name) &&
688
+ !(node.arguments.length <= 1 && node.arguments.every(isConstant))
689
+ );
690
+ });
349
691
 
350
- return findings;
692
+ return handlersFor(file.ast, STATE_HOT_EVENTS, { followNames: false }).flatMap(
693
+ (handler) =>
694
+ setters
695
+ .filter((setter) => contains(handler.node, setter))
696
+ .filter((setter) => !debounced(handler, setter))
697
+ .map((setter) => ({
698
+ index: setter.start,
699
+ message: `React state setter inside a ${handler.name} handler.`,
700
+ hint: HINT.statePerEvent,
701
+ })),
702
+ );
351
703
  },
352
704
  },
353
705
  {
354
706
  id: "layout-property",
355
707
  level: "warn",
708
+ /**
709
+ * The layout keys of every vars object a tween carries — both of a
710
+ * `fromTo`'s — on any receiver, so a timeline child counts. One finding per
711
+ * key per call: a `fromTo` that names `height` in both vars animates one
712
+ * property, and says so once, where it first appears. `gsap.set` animates
713
+ * nothing, and is skipped: measuring with `set(el, { height: "auto" })` is
714
+ * not a frame of layout.
715
+ */
356
716
  test(file) {
357
- const pattern = new RegExp(
358
- `\\b(${LAYOUT_PROPERTIES.join("|")})\\s*:\\s*(?!["'\\s]*$)`,
359
- "g",
360
- );
717
+ const LAYOUT = new Set(LAYOUT_PROPERTIES);
718
+ const animates = (node) => {
719
+ const method = tweenMethod(node);
720
+ return method !== null && method !== "set";
721
+ };
361
722
 
362
- return tweenCalls(file.code).flatMap((call) => {
363
- const span = blockAfter(file.code, call.index);
364
- if (!span) return [];
365
- const body = file.code.slice(span[0], span[1]);
366
-
367
- return [...body.matchAll(pattern)]
368
- .filter((m) => !/^\s*(?:\/\/|\*)/.test(m[0]))
369
- .map((m) => {
370
- const swap = TRANSFORM_FOR[m[1]];
371
- /**
372
- * Whether the flagged value is a percentage of something.
373
- *
374
- * Braces are allowed through rather than treated as a terminator:
375
- * the value is very often a template literal, and `` `${n}%` ``
376
- * carries a closing brace immediately before the `%` that was
377
- * meant to be found. The comma and the newline are what actually
378
- * end a value here.
379
- */
380
- const percent = new RegExp(`\\b${m[1]}\\s*:[^,\\n]{0,80}%`).test(
381
- body,
382
- );
383
-
384
- return {
385
- index: span[0] + m.index,
386
- message: `Animating \`${m[1]}\` forces layout on every frame.`,
387
- hint: swap
388
- ? `Use \`${swap}\` instead — a compositor-only property.` +
389
- (percent ? ` ${PERCENT_CAVEAT}` : "")
390
- : "Prefer transform and opacity; they never touch layout.",
391
- };
392
- });
393
- });
723
+ return findAll(file.ast, animates).flatMap(
724
+ (call) => {
725
+ const seen = new Set();
726
+ return varsObjects(call, tweenMethod(call))
727
+ .flatMap((vars) => vars.properties)
728
+ .filter((property) => {
729
+ const key = keyName(property);
730
+ if (!LAYOUT.has(key) || staticString(property.value) === "") return false;
731
+ if (seen.has(key)) return false;
732
+ seen.add(key);
733
+ return true;
734
+ })
735
+ .map((property) => ({
736
+ index: property.start,
737
+ message: `Animating \`${keyName(property)}\` forces layout on every frame.`,
738
+ hint: layoutHint(
739
+ keyName(property),
740
+ findAll(property.value, (node) => spells(node, "%")).length > 0,
741
+ ),
742
+ }));
743
+ },
744
+ );
394
745
  },
395
746
  },
396
747
  {
397
748
  id: "trigger-per-item",
398
749
  level: "warn",
750
+ /**
751
+ * A `scrollTrigger` property inside a loop body: the callback of `.forEach`
752
+ * or `.map`, or a `for`, `for…of` or `for…in` statement. One finding per
753
+ * file, at the first.
754
+ */
399
755
  test(file) {
400
- return find(file.code, /\.(forEach|map)\s*\(/g)
401
- .flatMap((loop) => {
402
- const span = blockAfter(file.code, loop.index);
403
- if (!span) return [];
404
- const body = file.code.slice(span[0], span[1]);
405
- const hit = body.indexOf("scrollTrigger");
406
- return hit === -1
407
- ? []
408
- : [
409
- {
410
- index: span[0] + hit,
411
- message: "A ScrollTrigger created per item in a loop.",
412
- hint: "Each carries its own start/end maths on every scroll frame. If the items belong to one visual moment, use one trigger with a stagger. Per-item is right only when they genuinely reveal independently — pinned card stacks, for instance.",
413
- },
414
- ];
415
- })
416
- .slice(0, 1);
756
+ const callbacks = findAll(file.ast, (node) => {
757
+ if (node.type !== "CallExpression") return false;
758
+ const callee = unwrap(node.callee);
759
+ return (
760
+ callee?.type === "MemberExpression" &&
761
+ !callee.computed &&
762
+ (callee.property.name === "forEach" || callee.property.name === "map")
763
+ );
764
+ })
765
+ .map((call) => unwrap(call.arguments[0]))
766
+ .filter(isFunction);
767
+ const loops = findAll(file.ast, (node) =>
768
+ ["ForStatement", "ForOfStatement", "ForInStatement"].includes(node.type),
769
+ ).map((loop) => loop.body);
770
+
771
+ const [first] = [...callbacks, ...loops]
772
+ .flatMap((body) => findAll(body, (node) => keyName(node) === "scrollTrigger"))
773
+ .sort((a, b) => a.start - b.start);
774
+
775
+ return first
776
+ ? [
777
+ {
778
+ index: first.start,
779
+ message: "A ScrollTrigger created per item in a loop.",
780
+ hint: HINT.triggerPerItem,
781
+ },
782
+ ]
783
+ : [];
417
784
  },
418
785
  },
419
786
 
@@ -421,87 +788,71 @@ export const RULES = [
421
788
  {
422
789
  id: "eased-loop",
423
790
  level: "warn",
791
+ /**
792
+ * The tween's own vars — a `fromTo`'s to-vars — on any receiver, so a
793
+ * timeline child counts. Keys at the top level only: a `stagger` object's
794
+ * own `repeat` is not the tween's. `yoyo` reverses rather than restarting,
795
+ * so there is no seam to hide.
796
+ */
424
797
  test(file) {
425
- return tweenCalls(file.code).flatMap((call) => {
426
- const span = blockAfter(file.code, call.index);
427
- if (!span) return [];
428
- const body = file.code.slice(span[0], span[1]);
429
-
430
- if (!/repeat\s*:\s*-1/.test(body)) return [];
431
- /** yoyo reverses rather than restarting, so there is no seam to hide. */
432
- if (/yoyo\s*:\s*true/.test(body)) return [];
433
- if (/ease\s*:\s*["']none["']/.test(body)) return [];
434
- if (!/ease\s*:/.test(body)) return [];
435
-
436
- return [
437
- {
438
- index: span[0] + body.indexOf("ease"),
439
- message: "An infinite repeat with an ease other than `none`.",
440
- hint: "The tween decelerates into its own restart, so the loop seam becomes visible. Use `ease: \"none\"`, or `yoyo: true` if it should breathe.",
441
- },
442
- ];
443
- });
798
+ return findAll(file.ast, (node) => tweenMethod(node) !== null).flatMap(
799
+ (call) => {
800
+ const vars = ownVars(call, tweenMethod(call));
801
+ if (!vars) return [];
802
+ const repeat = propertyOf(vars, "repeat");
803
+ if (!repeat || numberValue(repeat.value) !== -1) return [];
804
+ const yoyo = unwrap(propertyOf(vars, "yoyo")?.value);
805
+ if (yoyo?.type === "Literal" && yoyo.value === true) return [];
806
+ const ease = propertyOf(vars, "ease");
807
+ if (!ease || staticString(ease.value) === "none") return [];
808
+ return [
809
+ { index: ease.start, message: MESSAGE.easedLoop, hint: HINT.easedLoop },
810
+ ];
811
+ },
812
+ );
444
813
  },
445
814
  },
446
815
  {
447
816
  id: "eased-scrub",
448
817
  level: "warn",
818
+ /**
819
+ * A GSAP call scrubbed anywhere in its arguments — `scrub: false` aside —
820
+ * with a string ease other than `"none"` in those arguments or, for a
821
+ * timeline, in a child `timelineLinks` finds: the chain, and the name it is
822
+ * kept under within its scope, `this.tl` included. One finding per file.
823
+ */
449
824
  test(file) {
450
- if (!/scrub\s*:/.test(file.code)) return [];
451
-
452
- const eased = (text) => text.search(/\bease\s*:\s*["'`](?!none["'`])/);
453
- const findings = [];
454
- const report = (index) =>
455
- findings.push({
456
- index,
457
- message: "Easing inside a scrubbed ScrollTrigger timeline.",
458
- hint: "The scrollbar is the playhead, so an ease fights the visitor's own scrolling and reads as lag. Use `ease: \"none\"` on the children and let `scrub: 1` do the smoothing.",
459
- });
460
-
461
- /**
462
- * Whitespace is allowed around the dot because a formatter breaks a
463
- * three-call chain as `gsap\n .timeline(…)\n .to(…)`. The eased child of
464
- * a scrubbed timeline is the common case, and it used to be missed: only
465
- * the timeline's own vars were read, and they rarely carry the ease.
466
- */
467
- for (const call of find(
468
- file.code,
469
- /\bgsap\s*\.\s*(to|from|fromTo|timeline)\s*\(/g,
470
- )) {
471
- const open = call.index + call[0].length - 1;
472
- const span = parenSpan(file.code, open);
473
- if (!span) continue;
474
- const args = file.code.slice(open, span[1]);
475
- if (!/scrub\s*:/.test(args)) continue;
476
-
477
- const own = eased(args);
478
- if (own !== -1) report(open + own);
479
- if (call[1] !== "timeline") continue;
480
-
481
- const children = chainedTweens(file.code, span[1] + 1);
482
- const kept = file.code
483
- .slice(Math.max(0, call.index - 80), call.index)
484
- .match(/([\w$]+)\s*=\s*$/);
485
- if (kept) {
486
- const name = kept[1].replace(/\$/g, "\\$");
487
- const uses = new RegExp(
488
- `(?<![\\w$.])${name}(?=\\s*\\.\\s*(?:to|from|fromTo)\\s*\\()`,
489
- "g",
490
- );
491
- for (const use of find(file.code, uses)) {
492
- children.push(
493
- ...chainedTweens(file.code, use.index + use[0].length),
494
- );
495
- }
496
- }
825
+ const scrubbed = (node) =>
826
+ findAll(node, (n) => {
827
+ if (keyName(n) !== "scrub") return false;
828
+ const value = unwrap(n.value);
829
+ return !(value?.type === "Literal" && value.value === false);
830
+ }).length > 0;
831
+ const eased = (nodes) =>
832
+ nodes.flatMap((node) =>
833
+ findAll(node, (n) => {
834
+ if (keyName(n) !== "ease") return false;
835
+ const value = staticString(n.value);
836
+ return value !== null && value !== "none";
837
+ }),
838
+ )[0];
497
839
 
498
- for (const [start, end] of children) {
499
- const at = eased(file.code.slice(start, end));
500
- if (at !== -1) report(start + at);
840
+ for (const call of findAll(file.ast, (node) => isGsapCall(node))) {
841
+ if (!call.arguments.some(scrubbed)) continue;
842
+ const own = eased(call.arguments);
843
+ const child =
844
+ methodName(call) === "timeline"
845
+ ? timelineLinks(file.ast, call)
846
+ .filter((link) => CHILDREN.has(methodName(link)))
847
+ .map((link) => eased(link.arguments))
848
+ .find(Boolean)
849
+ : undefined;
850
+ const at = own ?? child;
851
+ if (at) {
852
+ return [{ index: at.start, message: MESSAGE.easedScrub, hint: HINT.easedScrub }];
501
853
  }
502
854
  }
503
-
504
- return findings.slice(0, 1);
855
+ return [];
505
856
  },
506
857
  },
507
858
  /**
@@ -519,48 +870,55 @@ export const RULES = [
519
870
  {
520
871
  id: "never-completes",
521
872
  level: "error",
873
+ /**
874
+ * `onComplete` and `repeat: -1` anywhere in a GSAP call's arguments; for a
875
+ * timeline, its children and any `eventCallback("onComplete", …)` from
876
+ * `timelineLinks` — the chain, and the name it is kept under within its
877
+ * scope, `this.tl` included.
878
+ */
522
879
  test(file) {
523
- /** `-1` exactly: the boundary after it rejects `-10`. */
524
- const endless = /\brepeat\s*:\s*-1\b/;
880
+ const endless = (nodes) =>
881
+ nodes.flatMap((node) =>
882
+ findAll(node, (n) => keyName(n) === "repeat" && numberValue(n.value) === -1),
883
+ )[0];
525
884
  const findings = [];
526
885
 
527
- for (const call of find(
528
- file.code,
529
- /\bgsap\s*\.\s*(to|from|fromTo|timeline)\s*\(/g,
530
- )) {
531
- const open = call.index + call[0].length - 1;
532
- const span = parenSpan(file.code, open);
533
- if (!span) continue;
534
- const args = file.code.slice(open, span[1]);
535
- const waits = /\bonComplete\b/.test(args);
536
-
537
- const own = args.search(endless);
538
- if (waits && own !== -1) {
886
+ for (const call of findAll(file.ast, (node) => isGsapCall(node))) {
887
+ const method = methodName(call);
888
+ const waits = call.arguments.some(
889
+ (argument) => findAll(argument, (n) => keyName(n) === "onComplete").length > 0,
890
+ );
891
+ const own = endless(call.arguments);
892
+ if (waits && own) {
539
893
  findings.push({
540
- index: open + own,
541
- message: `\`gsap.${call[1]}\` repeats forever, so its \`onComplete\` can never run.`,
542
- hint: "An endless repeat never completes. Use `onRepeat` for something that should happen every cycle, or give it a finite `repeat`.",
894
+ index: own.start,
895
+ message: `\`gsap.${method}\` repeats forever, so its \`onComplete\` can never run.`,
896
+ hint: HINT.neverCompletesOwn,
543
897
  });
544
898
  continue;
545
899
  }
546
- if (call[1] !== "timeline") continue;
900
+ if (method !== "timeline") continue;
547
901
 
548
- const { children, completes } = timelineCalls(file.code, call, span[1]);
902
+ const links = timelineLinks(file.ast, call);
903
+ const completes = links.some(
904
+ (link) =>
905
+ methodName(link) === "eventCallback" &&
906
+ staticString(link.arguments[0]) === "onComplete",
907
+ );
549
908
  if (!waits && !completes) continue;
550
909
 
551
- for (const [start, end] of children) {
552
- const at = file.code.slice(start, end).search(endless);
553
- if (at === -1) continue;
910
+ const child = links
911
+ .filter((link) => CHILDREN.has(methodName(link)))
912
+ .map((link) => endless(link.arguments))
913
+ .find(Boolean);
914
+ if (child) {
554
915
  findings.push({
555
- index: start + at,
556
- message:
557
- "An endlessly repeating child in a timeline with an `onComplete`, which therefore never fires.",
558
- hint: 'The child gives the timeline an effectively infinite duration, and a tween added after it does not end it. Hand over at a position instead — `.call(fn, [], "label+=0.5")` fires when the playhead passes — or run the loop as its own tween outside the timeline.',
916
+ index: child.start,
917
+ message: MESSAGE.neverCompletesChild,
918
+ hint: HINT.neverCompletesChild,
559
919
  });
560
- break;
561
920
  }
562
921
  }
563
-
564
922
  return findings;
565
923
  },
566
924
  },
@@ -581,124 +939,159 @@ export const RULES = [
581
939
  {
582
940
  id: "late-transform-origin",
583
941
  level: "warn",
942
+ /**
943
+ * Any `.fromTo(target, fromVars, toVars)` whose two vars are object
944
+ * literals. Keys are read at the top level of each, and a transform's
945
+ * from-value as it is written: `rotation: "90deg"` and `scale: 0` move the
946
+ * element, `scale: 1` and `rotation: 0` do not.
947
+ */
584
948
  test(file) {
585
- const ORIGIN = /\b(?:transformOrigin|svgOrigin)\s*:/;
586
- const TRANSFORM = /\b(scale[XY]?|rotation|rotate|skew[XY])\s*:\s*([^,}\n]+)/g;
587
- /** Whether a from-value leaves the element untransformed. */
588
- const identity = (prop, value) => {
589
- const v = value.trim().replace(/^["'`]|["'`]$/g, "");
590
- return prop.startsWith("scale")
591
- ? /^1(?:\.0+)?$/.test(v)
592
- : /^-?0(?:\.0+)?(?:deg|rad)?$/.test(v);
593
- };
949
+ const ORIGIN = new Set(["transformOrigin", "svgOrigin"]);
950
+ const TRANSFORM = /^(?:scale[XY]?|rotation|rotate|skew[XY])$/;
951
+
952
+ const calls = findAll(file.ast, (node) => methodName(node) === "fromTo");
594
953
 
595
- return find(file.code, /\.\s*fromTo\s*\(/g).flatMap((call) => {
596
- const open = call.index + call[0].length - 1;
597
- const span = parenSpan(file.code, open);
598
- if (!span) return [];
599
- const [, from, to] = callArgs(file.code, open, span[1]);
600
- if (!from || !to) return [];
601
-
602
- const fromVars = file.code.slice(from[0], from[1]);
603
- const toVars = file.code.slice(to[0], to[1]);
604
- const origin = toVars.search(ORIGIN);
605
- if (origin === -1 || ORIGIN.test(fromVars)) return [];
606
- if (/\bsmoothOrigin\s*:\s*false\b/.test(toVars)) return [];
607
- if (
608
- [...fromVars.matchAll(TRANSFORM)].every(([, prop, value]) =>
609
- identity(prop, value),
610
- )
611
- ) {
954
+ return calls.flatMap((call) => {
955
+ const from = unwrap(call.arguments[1]);
956
+ const to = unwrap(call.arguments[2]);
957
+ if (from?.type !== "ObjectExpression" || to?.type !== "ObjectExpression") {
612
958
  return [];
613
959
  }
960
+ const origin = to.properties.find((property) => ORIGIN.has(keyName(property)));
961
+ if (!origin) return [];
962
+ if (from.properties.some((property) => ORIGIN.has(keyName(property)))) return [];
963
+ const smooth = unwrap(propertyOf(to, "smoothOrigin")?.value);
964
+ if (smooth?.type === "Literal" && smooth.value === false) return [];
614
965
 
615
- return [
616
- {
617
- index: to[0] + origin,
618
- message:
619
- "The transform origin is only in this `fromTo`'s to-vars, while its from-vars scale, rotate or skew.",
620
- hint: "On an SVG element GSAP holds the element in place when its origin changes, and that correction outlives the tween: it ends offset, silently — a circle grown from 0 about its centre lands a whole radius off. Put `transformOrigin` or `svgOrigin` in the from-vars, or set it before the tween. HTML elements are unaffected.",
621
- },
622
- ];
966
+ const moves = from.properties.some(
967
+ (property) =>
968
+ TRANSFORM.test(keyName(property) ?? "") &&
969
+ !identityTransform(
970
+ keyName(property),
971
+ file.raw.slice(property.value.start, property.value.end),
972
+ ),
973
+ );
974
+ return moves
975
+ ? [
976
+ {
977
+ index: origin.start,
978
+ message: MESSAGE.lateTransformOrigin,
979
+ hint: HINT.lateTransformOrigin,
980
+ },
981
+ ]
982
+ : [];
623
983
  });
624
984
  },
625
985
  },
626
986
  {
627
987
  id: "shared-plugin-id",
628
988
  level: "error",
989
+ /**
990
+ * Any property naming a target geometry whose value is a fixed `#id`: a
991
+ * string or a template literal with nothing interpolated, including the
992
+ * shorthands `motionPath: "#id"` and `morphSVG: "#id"`.
993
+ */
629
994
  test(file) {
630
995
  if (!file.isReact) return [];
996
+ const KEYS = new Set(["path", "shape", "align", "motionPath", "morphSVG"]);
631
997
 
632
- return find(
633
- file.code,
634
- /\b(?:path|shape|align)\s*:\s*["'](#[A-Za-z][\w-]*)["']/g,
635
- ).map((m) => ({
636
- index: m.index,
637
- message: `Hardcoded id \`${m[1]}\` in plugin config.`,
638
- hint: "useGSAP scopes the selectors it resolves itself, but MotionPath and MorphSVG resolve their own config against the whole document. Mount this component twice and both instances drive the first one's geometry, silently. Namespace it with useId().",
639
- }));
998
+ return findAll(file.ast, (node) => KEYS.has(keyName(node))).flatMap(
999
+ (property) => {
1000
+ const id = staticString(property.value)?.match(/^#[A-Za-z][\w-]*$/)?.[0];
1001
+ return id
1002
+ ? [
1003
+ {
1004
+ index: property.start,
1005
+ message: `Hardcoded id \`${id}\` in plugin config.`,
1006
+ hint: HINT.sharedPluginId,
1007
+ },
1008
+ ]
1009
+ : [];
1010
+ },
1011
+ );
640
1012
  },
641
1013
  },
642
1014
  {
643
1015
  id: "unregistered-plugin",
644
1016
  level: "error",
1017
+ /**
1018
+ * Registered means named anywhere inside the arguments of a
1019
+ * `registerPlugin` call — not mentioned anywhere after one, and under its
1020
+ * local name, so an alias registered by its alias counts. Imports are read
1021
+ * as declarations: a default import takes its plugin's name from the path,
1022
+ * and a type-only import, of the whole statement or of one specifier, ships
1023
+ * nothing and is skipped.
1024
+ */
645
1025
  test(file) {
646
- /**
647
- * The identifiers actually passed to `registerPlugin`, across every
648
- * call. This used to be "does the name appear anywhere after the first
649
- * registerPlugin", so a `ScrollTrigger.refresh()` below a registration
650
- * that never mentioned it counted as registering it — and an alias
651
- * (`ScrollTrigger as ST`) registered by its alias counted as missing.
652
- */
653
- const registered = new Set(
654
- find(file.code, /\bregisterPlugin\s*\(/g).flatMap((m) => {
655
- const open = m.index + m[0].length - 1;
656
- const span = parenSpan(file.code, open);
657
- return span
658
- ? file.code
659
- .slice(open + 1, span[1])
660
- .split(/[^\w$]+/)
661
- .filter(Boolean)
662
- : [];
663
- }),
664
- );
1026
+ const registered = new Set();
1027
+ const registrations = findAll(file.ast, (node) => {
1028
+ const name = calleeName(node);
1029
+ return name === "registerPlugin" || name?.endsWith(".registerPlugin");
1030
+ });
1031
+ for (const call of registrations) {
1032
+ for (const arg of call.arguments) {
1033
+ for (const id of findAll(arg, (node) => node.type === "Identifier")) {
1034
+ registered.add(id.name);
1035
+ }
1036
+ }
1037
+ }
665
1038
 
666
- return find(
667
- file.code,
668
- /\bimport\s*\{([^}]*)\}\s*from\s*["']gsap\/[\w/-]+["']/g,
669
- ).flatMap((m) =>
670
- m[1]
671
- .split(",")
672
- .map((specifier) => specifier.trim())
673
- .filter((specifier) => specifier && !/^type\s/.test(specifier))
674
- .flatMap((specifier) => {
675
- const [imported, local = imported] = specifier.split(/\s+as\s+/);
676
- if (!PLUGIN_NAME.test(imported) || registered.has(local)) return [];
677
- return [
678
- {
679
- index: m.index,
680
- message: `\`${imported}\` is imported but never passed to \`gsap.registerPlugin\`.`,
681
- hint: "An unregistered plugin's properties are ignored without an error — the animation simply does nothing.",
682
- },
683
- ];
684
- }),
685
- );
1039
+ const findings = [];
1040
+ for (const declaration of file.ast.body) {
1041
+ if (declaration.type !== "ImportDeclaration") continue;
1042
+ if (declaration.importKind === "type") continue;
1043
+ const from = declaration.source.value;
1044
+ if (typeof from !== "string" || !/^gsap\/[\w/-]+$/.test(from)) continue;
1045
+
1046
+ for (const specifier of declaration.specifiers) {
1047
+ if (specifier.importKind === "type") continue;
1048
+ const imported =
1049
+ specifier.type === "ImportSpecifier"
1050
+ ? (specifier.imported.name ?? specifier.imported.value)
1051
+ : specifier.type === "ImportDefaultSpecifier"
1052
+ ? from.split("/").pop()
1053
+ : null;
1054
+ if (!imported || !PLUGIN_NAME.test(imported)) continue;
1055
+ if (registered.has(specifier.local.name)) continue;
1056
+ if (file.registeredElsewhere?.has(imported)) continue;
1057
+ findings.push({
1058
+ index: declaration.start,
1059
+ message: `\`${imported}\` is imported but never passed to \`gsap.registerPlugin\`.`,
1060
+ hint: HINT.unregisteredPlugin,
1061
+ });
1062
+ }
1063
+ }
1064
+ return findings;
686
1065
  },
687
1066
  },
688
1067
  {
689
1068
  id: "missing-reduced-motion",
690
1069
  level: "warn",
1070
+ /**
1071
+ * A branch is the query written in code — a string, a template or JSX
1072
+ * text. A comment that mentions it is not a branch. A file that only sets
1073
+ * values is not animating anything.
1074
+ */
691
1075
  test(file) {
692
1076
  if (!file.usesGsap) return [];
693
- if (/prefers-reduced-motion/.test(file.code)) return [];
694
- /** A file that only sets values is not animating anything. */
695
- if (!tweenCalls(file.code).some((m) => m[1] !== "set")) return [];
1077
+ if (findAll(file.ast, (node) => spells(node, "prefers-reduced-motion")).length) {
1078
+ return [];
1079
+ }
1080
+ if (!findAll(file.ast, (node) => isGsapCall(node)).length) return [];
1081
+
1082
+ const first = findAll(
1083
+ file.ast,
1084
+ (node) =>
1085
+ node.type === "MemberExpression" &&
1086
+ unwrap(node.object)?.type === "Identifier" &&
1087
+ unwrap(node.object).name === "gsap",
1088
+ ).sort((a, b) => a.start - b.start)[0];
696
1089
 
697
1090
  return [
698
1091
  {
699
- index: file.code.search(/\bgsap\./),
1092
+ index: first.start,
700
1093
  message: "No `prefers-reduced-motion` branch in an animating file.",
701
- hint: "Reduced motion is a design, not an off switch: decide what the motion was saying and say it without the travel. Note that the reduced branch must set the END state — a `from` that never runs leaves its target invisible.",
1094
+ hint: HINT.missingReducedMotion,
702
1095
  },
703
1096
  ];
704
1097
  },
@@ -708,25 +1101,45 @@ export const RULES = [
708
1101
  {
709
1102
  id: "dev-tool-shipped",
710
1103
  level: "error",
1104
+ /**
1105
+ * A static import declaration that brings in a dev tool, by a specifier's
1106
+ * name or, for a bare or default import, by its path. A type-only import
1107
+ * ships no code. `markers: true` is the property itself.
1108
+ */
711
1109
  test(file) {
1110
+ const TOOLS = /^(?:MotionPathHelper|GSDevTools)$/;
712
1111
  const findings = [];
713
1112
 
714
- for (const m of find(
715
- file.code,
716
- /^\s*import\b[^\n]*\b(MotionPathHelper|GSDevTools)\b/gm,
717
- )) {
1113
+ for (const declaration of file.ast.body) {
1114
+ if (declaration.type !== "ImportDeclaration") continue;
1115
+ if (declaration.importKind === "type") continue;
1116
+ const values = declaration.specifiers.filter((s) => s.importKind !== "type");
1117
+ if (declaration.specifiers.length && !values.length) continue;
1118
+
1119
+ const tool =
1120
+ values
1121
+ .map((s) => s.imported?.name ?? s.local.name)
1122
+ .find((name) => TOOLS.test(name)) ??
1123
+ String(declaration.source.value).split("/").pop();
1124
+ if (!TOOLS.test(tool)) continue;
1125
+
718
1126
  findings.push({
719
- index: m.index,
720
- message: `\`${m[1]}\` is statically imported.`,
721
- hint: "A static import ships regardless of any `if` around the call. Load it with a dynamic `import()` behind a NODE_ENV check, or bake its output in and delete it.",
1127
+ index: declaration.start,
1128
+ message: `\`${tool}\` is statically imported.`,
1129
+ hint: HINT.devToolImport,
722
1130
  });
723
1131
  }
724
1132
 
725
- for (const m of find(file.code, /markers\s*:\s*true/g)) {
1133
+ const markers = findAll(file.ast, (node) => {
1134
+ if (keyName(node) !== "markers") return false;
1135
+ const value = unwrap(node.value);
1136
+ return value?.type === "Literal" && value.value === true;
1137
+ });
1138
+ for (const property of markers) {
726
1139
  findings.push({
727
- index: m.index,
1140
+ index: property.start,
728
1141
  message: "`markers: true` is unconditional.",
729
- hint: 'Gate it: `markers: process.env.NODE_ENV === "development"`.',
1142
+ hint: HINT.markers,
730
1143
  });
731
1144
  }
732
1145
 
@@ -736,12 +1149,16 @@ export const RULES = [
736
1149
  {
737
1150
  id: "barrel-import",
738
1151
  level: "warn",
1152
+ /** An import or re-export whose module is `gsap/all`. */
739
1153
  test(file) {
740
- return find(file.code, /from\s*["']gsap\/all["']/g).map((m) => ({
741
- index: m.index,
742
- message: "Importing from `gsap/all` pulls in every plugin.",
743
- hint: "Import each from its own subpath — `gsap/ScrollTrigger` — so the bundler can drop the ones this route does not use.",
744
- }));
1154
+ return file.ast.body
1155
+ .map(moduleSource)
1156
+ .filter((source) => source?.value === "gsap/all")
1157
+ .map((source) => ({
1158
+ index: source.start,
1159
+ message: "Importing from `gsap/all` pulls in every plugin.",
1160
+ hint: HINT.barrelImport,
1161
+ }));
745
1162
  },
746
1163
  },
747
1164
  ];