@mehshekari/eslint-plugin-gsap-motion 3.1.1

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.
package/lib/rules.mjs ADDED
@@ -0,0 +1,1196 @@
1
+ /**
2
+ * The GSAP audit's rule registry.
3
+ *
4
+ * Every rule here earns its place by catching something that is **silent** —
5
+ * no error, no warning, often no visible symptom until much later or on a
6
+ * slower device. Craft questions (is this ease right, is the rhythm good) are
7
+ * deliberately absent: they are not mechanically decidable, and pretending
8
+ * otherwise would fill the report with noise that hides the real findings.
9
+ *
10
+ * Each rule: `{ id, level, test(file) -> findings[] }` where a finding is
11
+ * `{ index, message, hint }`. `level` is "error" (a bug), "warn" (a likely
12
+ * bug or a real cost) or "info" (worth a look, higher false-positive rate).
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
+ *
17
+ * Every rule has fixtures in `scripts/test/rules.test.mjs`: a case it must fire
18
+ * on and a case it must stay quiet on. A wrong finding in either direction is
19
+ * fixed by adding the case there first and watching it fail.
20
+ */
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));
100
+
101
+ /** Properties whose animation forces the browser to re-run layout. */
102
+ const LAYOUT_PROPERTIES = [
103
+ "left",
104
+ "top",
105
+ "right",
106
+ "bottom",
107
+ "width",
108
+ "height",
109
+ "margin",
110
+ "marginTop",
111
+ "marginLeft",
112
+ "marginRight",
113
+ "marginBottom",
114
+ "padding",
115
+ "paddingTop",
116
+ "paddingLeft",
117
+ "fontSize",
118
+ "lineHeight",
119
+ ];
120
+
121
+ const TRANSFORM_FOR = {
122
+ left: "x",
123
+ right: "x",
124
+ top: "y",
125
+ bottom: "y",
126
+ width: "scaleX (with transformOrigin)",
127
+ height: "scaleY (with transformOrigin)",
128
+ };
129
+
130
+ /**
131
+ * The caveat that makes the swap above safe, and the reason this is spelled
132
+ * out rather than left as "use a transform".
133
+ *
134
+ * `top: 50%` is half the **containing block**. `y: "50%"` and `yPercent: 50`
135
+ * are half the **element's own** size. On a 1px rule those are 0.5px, so the
136
+ * naive swap silently turns a full-height travel into nothing — which is worse
137
+ * than the layout cost it was meant to fix.
138
+ *
139
+ * Two ways out: give the moving element the same height as its container and
140
+ * translate that (the percentages then agree, and nothing has to be measured),
141
+ * or measure the container once and translate in pixels.
142
+ */
143
+ const PERCENT_CAVEAT =
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.";
145
+
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
+ }
157
+
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
+ }
165
+
166
+ /** An import from `gsap/*` that is a plugin needing `registerPlugin`. */
167
+ const PLUGIN_NAME =
168
+ /(?:Plugin|Trigger|Text|Flip|Observer|Draggable|Ease|Smoother|Bounce|Wiggle)$/;
169
+
170
+ /**
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`.
174
+ */
175
+ const HOT_EVENTS = new Set([
176
+ "pointermove",
177
+ "pointerrawupdate",
178
+ "mousemove",
179
+ "touchmove",
180
+ "scroll",
181
+ "wheel",
182
+ "resize",
183
+ ]);
184
+
185
+ /**
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.
190
+ */
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;
206
+ }
207
+
208
+ /**
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.
214
+ */
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 });
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;
246
+ }
247
+
248
+ /**
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.
276
+ *
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.
282
+ */
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
+ }
327
+ }
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;
341
+
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
+ })
364
+ );
365
+ }
366
+
367
+ /**
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; … }`.
373
+ */
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;
383
+ }
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;
398
+ }
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
+ ]);
414
+
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;
494
+ }
495
+
496
+ export const RULES = [
497
+ // --- Lifecycle ------------------------------------------------------------
498
+ {
499
+ id: "orphan-tween",
500
+ level: "error",
501
+ description:
502
+ "A tween created outside `useGSAP`, `gsap.context` or `contextSafe` in React — never reverted, and doubled by StrictMode",
503
+ /**
504
+ * A GSAP tween call inside a function but outside every context — module
505
+ * scope is not the target. Contexts are recognised under the names the file
506
+ * gives them, and a helper counts as inside when every use of it is a call
507
+ * from inside one. A value torn down by hand — see `tornDown` — is not
508
+ * reported: "nothing reverts it" would be false.
509
+ */
510
+ test(file) {
511
+ if (!file.isReact || !file.usesGsap) return [];
512
+ const inside = gsapContexts(file.ast);
513
+
514
+ return findAll(file.ast, (node) => isGsapCall(node))
515
+ .filter((call) => !inside(call))
516
+ .filter((call) => ancestorsOf(file.ast, call).some(isFunction))
517
+ .filter((call) => !tornDown(file.ast, call))
518
+ .map((call) => ({
519
+ index: call.start,
520
+ message: `\`gsap.${methodName(call)}\` is created outside any useGSAP/gsap.context scope.`,
521
+ hint: HINT.orphanTween,
522
+ }));
523
+ },
524
+ },
525
+ /**
526
+ * GSAP's own teardown is wider than it is usually given credit for, and
527
+ * these rules are narrow because of it.
528
+ *
529
+ * While a context's function runs, `_context` points at that context, and
530
+ * `MatchMedia`, `Observer`, `Draggable`, `SplitText` and `ScrollTrigger` all
531
+ * register themselves with it on construction (the `_context(this)` call in
532
+ * each constructor — verified against gsap 3.15). `context.revert()` then reverts
533
+ * them. So anything built **inside** a `useGSAP` body is already handled,
534
+ * and demanding a manual `.kill()` there would be cargo cult.
535
+ *
536
+ * Built outside one — module scope, a plain `useEffect`, a handler that never
537
+ * went through `contextSafe` — nothing is watching, and it is a real leak.
538
+ */
539
+ {
540
+ id: "unmanaged-instance",
541
+ level: "error",
542
+ description:
543
+ "`matchMedia`, `Observer`, `Draggable`, `ScrollSmoother` or `SplitText` created outside a context and never torn down",
544
+ /**
545
+ * Torn down as `tornDown` reads it: kept under a name — through any chain
546
+ * called on it, or a helper's return — that has `.revert()` or `.kill()`
547
+ * called on it somewhere in the file, optional chaining included.
548
+ */
549
+ test(file) {
550
+ const inside = gsapContexts(file.ast);
551
+ const CREATORS = new Set(["Observer", "Draggable", "ScrollSmoother", "ScrollTrigger"]);
552
+
553
+ const label = (node) => {
554
+ if (node.type === "NewExpression") {
555
+ const callee = unwrap(node.callee);
556
+ return callee?.type === "Identifier" && callee.name === "SplitText"
557
+ ? "SplitText"
558
+ : null;
559
+ }
560
+ if (node.type !== "CallExpression") return null;
561
+ const name = calleeName(node);
562
+ if (name === "gsap.matchMedia") return "matchMedia";
563
+ const [owner, method, extra] = name?.split(".") ?? [];
564
+ return method === "create" && extra === undefined && CREATORS.has(owner)
565
+ ? owner
566
+ : null;
567
+ };
568
+ return findAll(file.ast, (node) => label(node) !== null)
569
+ .filter((node) => !inside(node))
570
+ .filter((node) => !tornDown(file.ast, node))
571
+ .map((node) => ({
572
+ index: node.start,
573
+ message: `\`${label(node)}\` is created outside any useGSAP/gsap.context scope and never torn down.`,
574
+ hint: HINT.unmanagedInstance,
575
+ }));
576
+ },
577
+ },
578
+ {
579
+ id: "dangling-listener",
580
+ level: "warn",
581
+ description:
582
+ "An event listener that is never removed, including inline handlers that cannot be",
583
+ /**
584
+ * Adds and removes are paired by event and by the handler's name as
585
+ * written, across the file — not by counting calls. A handler that is not a
586
+ * plain name — an inline function, or `fn.bind(this)`, which makes a new
587
+ * function each call — can never be matched by a remove. `signal` and
588
+ * `once: true` are read from the options object. A dynamic event name
589
+ * cannot be judged statically, so it is skipped.
590
+ *
591
+ * Only a target that can outlive the code is reported. An element or object
592
+ * the file creates takes its listeners with it. Outside React, where nothing
593
+ * says when code stops running, only the window and the document can: a
594
+ * listener on a page's own button lasts exactly as long as the button.
595
+ */
596
+ test(file) {
597
+ const listeners = (method) =>
598
+ findAll(file.ast, (node) => listenerMethod(node) === method).flatMap((call) => {
599
+ const event = staticString(call.arguments[0]);
600
+ const handler = unwrap(call.arguments[1]);
601
+ if (event === null || !/^[\w:-]+$/.test(event) || !handler) return [];
602
+ const options = unwrap(call.arguments[2]);
603
+ const once = unwrap(propertyOf(options, "once")?.value);
604
+ const callee = unwrap(call.callee);
605
+ const target = callee.type === "MemberExpression" ? unwrap(callee.object) : null;
606
+ return [
607
+ {
608
+ global: target === null || GLOBAL_TARGETS.has(dottedName(target)),
609
+ created: target !== null && createdHere(file.ast, target),
610
+ index: callee.type === "MemberExpression" ? callee.property.start : callee.start,
611
+ event,
612
+ handler: dottedName(handler),
613
+ inline: isFunction(handler) || dottedName(handler) === null,
614
+ selfCleaning:
615
+ Boolean(propertyOf(options, "signal")) ||
616
+ (once?.type === "Literal" && once.value === true),
617
+ },
618
+ ];
619
+ });
620
+
621
+ const removed = new Set(
622
+ listeners("removeEventListener")
623
+ .filter((call) => call.handler)
624
+ .map((call) => `${call.event}|${call.handler}`),
625
+ );
626
+
627
+ return listeners("addEventListener")
628
+ .filter((call) => !call.selfCleaning && !call.created)
629
+ .filter((call) => file.isReact || call.global)
630
+ .filter((call) => call.inline || !removed.has(`${call.event}|${call.handler}`))
631
+ .map((call) => ({
632
+ index: call.index,
633
+ message: call.inline
634
+ ? `An inline \`${call.event}\` listener, which no removeEventListener can reach.`
635
+ : `\`${call.event}\` listener \`${call.handler}\` is added and never removed.`,
636
+ hint: HINT.danglingListener,
637
+ }));
638
+ },
639
+ },
640
+
641
+ // --- Cost -----------------------------------------------------------------
642
+ {
643
+ id: "tween-per-event",
644
+ level: "error",
645
+ description:
646
+ "A new tween allocated on every pointer, scroll or wheel event",
647
+ /**
648
+ * A GSAP tween call inside a high-frequency handler, followed by name when
649
+ * it can be. A debounced one, one in a completion callback, and one behind
650
+ * an in-flight flag run less than once per event, and are not reported.
651
+ */
652
+ test(file) {
653
+ const calls = findAll(file.ast, (node) => isGsapCall(node));
654
+
655
+ return handlersFor(file.ast, HOT_EVENTS).flatMap((handler) =>
656
+ calls
657
+ .filter((call) => contains(handler.node, call))
658
+ .filter((call) => !debounced(handler, call))
659
+ .filter((call) => !notPerEvent(file.ast, handler, call))
660
+ .map((call) => ({
661
+ index: call.start,
662
+ message: `\`gsap.${methodName(call)}\` inside a high-frequency handler (${handler.name}).`,
663
+ hint: HINT.tweenPerEvent,
664
+ })),
665
+ );
666
+ },
667
+ },
668
+ {
669
+ id: "state-per-event",
670
+ level: "error",
671
+ description:
672
+ "React state set in a high-frequency handler — a re-render per frame",
673
+ /**
674
+ * A bare `setX(…)` call — not a member call such as `el.style.setProperty`,
675
+ * which is exactly what a pointer handler should do, and not a timer —
676
+ * inside an inline high-frequency handler other than resize.
677
+ *
678
+ * A handler passed by name is not followed here, unlike tween-per-event. A
679
+ * named scroll handler is usually a scroll-spy setting a discrete value — a
680
+ * section id, a boolean — and React skips the render when that value has not
681
+ * changed, so "a render per frame" would be false and, at error level, would
682
+ * fail correct code. Telling a continuous value from a discrete one is a
683
+ * precision question for the corpus, not something to guess.
684
+ *
685
+ * A setter called with a constant — `null`, a boolean, a literal — is not
686
+ * reported either: once the state holds that value React skips the render,
687
+ * so it costs one render, not one per frame. The corpus found
688
+ * `onScroll={() => setHoveredCard(null)}`.
689
+ */
690
+ test(file) {
691
+ if (!file.isReact) return [];
692
+ const setters = findAll(file.ast, (node) => {
693
+ if (node.type !== "CallExpression") return false;
694
+ const callee = unwrap(node.callee);
695
+ return (
696
+ callee?.type === "Identifier" &&
697
+ /^set(?!Timeout$|Interval$)[A-Z]\w*$/.test(callee.name) &&
698
+ !(node.arguments.length <= 1 && node.arguments.every(isConstant))
699
+ );
700
+ });
701
+
702
+ return handlersFor(file.ast, STATE_HOT_EVENTS, { followNames: false }).flatMap(
703
+ (handler) =>
704
+ setters
705
+ .filter((setter) => contains(handler.node, setter))
706
+ .filter((setter) => !debounced(handler, setter))
707
+ .map((setter) => ({
708
+ index: setter.start,
709
+ message: `React state setter inside a ${handler.name} handler.`,
710
+ hint: HINT.statePerEvent,
711
+ })),
712
+ );
713
+ },
714
+ },
715
+ {
716
+ id: "layout-property",
717
+ level: "warn",
718
+ description:
719
+ "Animating `width`, `height`, `top`, `left`, margins or padding, which forces layout every frame",
720
+ /**
721
+ * The layout keys of every vars object a tween carries — both of a
722
+ * `fromTo`'s — on any receiver, so a timeline child counts. One finding per
723
+ * key per call: a `fromTo` that names `height` in both vars animates one
724
+ * property, and says so once, where it first appears. `gsap.set` animates
725
+ * nothing, and is skipped: measuring with `set(el, { height: "auto" })` is
726
+ * not a frame of layout.
727
+ */
728
+ test(file) {
729
+ const LAYOUT = new Set(LAYOUT_PROPERTIES);
730
+ const animates = (node) => {
731
+ const method = tweenMethod(node);
732
+ return method !== null && method !== "set";
733
+ };
734
+
735
+ return findAll(file.ast, animates).flatMap(
736
+ (call) => {
737
+ const seen = new Set();
738
+ return varsObjects(call, tweenMethod(call))
739
+ .flatMap((vars) => vars.properties)
740
+ .filter((property) => {
741
+ const key = keyName(property);
742
+ if (!LAYOUT.has(key) || staticString(property.value) === "") return false;
743
+ if (seen.has(key)) return false;
744
+ seen.add(key);
745
+ return true;
746
+ })
747
+ .map((property) => ({
748
+ index: property.start,
749
+ message: `Animating \`${keyName(property)}\` forces layout on every frame.`,
750
+ hint: layoutHint(
751
+ keyName(property),
752
+ findAll(property.value, (node) => spells(node, "%")).length > 0,
753
+ ),
754
+ }));
755
+ },
756
+ );
757
+ },
758
+ },
759
+ {
760
+ id: "trigger-per-item",
761
+ level: "warn",
762
+ description:
763
+ "A ScrollTrigger created per item in a loop",
764
+ /**
765
+ * A `scrollTrigger` property inside a loop body: the callback of `.forEach`
766
+ * or `.map`, or a `for`, `for…of` or `for…in` statement. One finding per
767
+ * file, at the first.
768
+ */
769
+ test(file) {
770
+ const callbacks = findAll(file.ast, (node) => {
771
+ if (node.type !== "CallExpression") return false;
772
+ const callee = unwrap(node.callee);
773
+ return (
774
+ callee?.type === "MemberExpression" &&
775
+ !callee.computed &&
776
+ (callee.property.name === "forEach" || callee.property.name === "map")
777
+ );
778
+ })
779
+ .map((call) => unwrap(call.arguments[0]))
780
+ .filter(isFunction);
781
+ const loops = findAll(file.ast, (node) =>
782
+ ["ForStatement", "ForOfStatement", "ForInStatement"].includes(node.type),
783
+ ).map((loop) => loop.body);
784
+
785
+ const [first] = [...callbacks, ...loops]
786
+ .flatMap((body) => findAll(body, (node) => keyName(node) === "scrollTrigger"))
787
+ .sort((a, b) => a.start - b.start);
788
+
789
+ return first
790
+ ? [
791
+ {
792
+ index: first.start,
793
+ message: "A ScrollTrigger created per item in a loop.",
794
+ hint: HINT.triggerPerItem,
795
+ },
796
+ ]
797
+ : [];
798
+ },
799
+ },
800
+
801
+ // --- Correctness that is invisible ---------------------------------------
802
+ {
803
+ id: "eased-loop",
804
+ level: "warn",
805
+ description:
806
+ "An infinite repeat with an ease, which makes its own seam visible",
807
+ /**
808
+ * The tween's own vars — a `fromTo`'s to-vars — on any receiver, so a
809
+ * timeline child counts. Keys at the top level only: a `stagger` object's
810
+ * own `repeat` is not the tween's. `yoyo` reverses rather than restarting,
811
+ * so there is no seam to hide.
812
+ */
813
+ test(file) {
814
+ return findAll(file.ast, (node) => tweenMethod(node) !== null).flatMap(
815
+ (call) => {
816
+ const vars = ownVars(call, tweenMethod(call));
817
+ if (!vars) return [];
818
+ const repeat = propertyOf(vars, "repeat");
819
+ if (!repeat || numberValue(repeat.value) !== -1) return [];
820
+ const yoyo = unwrap(propertyOf(vars, "yoyo")?.value);
821
+ if (yoyo?.type === "Literal" && yoyo.value === true) return [];
822
+ const ease = propertyOf(vars, "ease");
823
+ if (!ease || staticString(ease.value) === "none") return [];
824
+ return [
825
+ { index: ease.start, message: MESSAGE.easedLoop, hint: HINT.easedLoop },
826
+ ];
827
+ },
828
+ );
829
+ },
830
+ },
831
+ {
832
+ id: "eased-scrub",
833
+ level: "warn",
834
+ description:
835
+ "Easing inside a scrubbed ScrollTrigger, which fights the scrollbar",
836
+ /**
837
+ * A GSAP call scrubbed anywhere in its arguments — `scrub: false` aside —
838
+ * with a string ease other than `"none"` in those arguments or, for a
839
+ * timeline, in a child `timelineLinks` finds: the chain, and the name it is
840
+ * kept under within its scope, `this.tl` included. One finding per file.
841
+ */
842
+ test(file) {
843
+ const scrubbed = (node) =>
844
+ findAll(node, (n) => {
845
+ if (keyName(n) !== "scrub") return false;
846
+ const value = unwrap(n.value);
847
+ return !(value?.type === "Literal" && value.value === false);
848
+ }).length > 0;
849
+ const eased = (nodes) =>
850
+ nodes.flatMap((node) =>
851
+ findAll(node, (n) => {
852
+ if (keyName(n) !== "ease") return false;
853
+ const value = staticString(n.value);
854
+ return value !== null && value !== "none";
855
+ }),
856
+ )[0];
857
+
858
+ for (const call of findAll(file.ast, (node) => isGsapCall(node))) {
859
+ if (!call.arguments.some(scrubbed)) continue;
860
+ const own = eased(call.arguments);
861
+ const child =
862
+ methodName(call) === "timeline"
863
+ ? timelineLinks(file.ast, call)
864
+ .filter((link) => CHILDREN.has(methodName(link)))
865
+ .map((link) => eased(link.arguments))
866
+ .find(Boolean)
867
+ : undefined;
868
+ const at = own ?? child;
869
+ if (at) {
870
+ return [{ index: at.start, message: MESSAGE.easedScrub, hint: HINT.easedScrub }];
871
+ }
872
+ }
873
+ return [];
874
+ },
875
+ },
876
+ /**
877
+ * An `onComplete` that can never run. A `repeat: -1` tween never completes,
878
+ * and a timeline holding one is given an effectively infinite duration —
879
+ * 1e10 seconds, measured in gsap 3.15, where neither callback fired — so its
880
+ * `onComplete` never fires either, and a short tween added after the loop
881
+ * does not change that.
882
+ *
883
+ * Nothing reports it. Whatever the callback was meant to start simply does not
884
+ * start: behind a ceiling timer the visitor waits for the ceiling on every
885
+ * visit, and without one the curtain never lifts. The guidance described this
886
+ * for a whole release and it still shipped, which is why it is a rule.
887
+ */
888
+ {
889
+ id: "never-completes",
890
+ level: "error",
891
+ description:
892
+ "An `onComplete` that can never run: on a timeline holding a `repeat: -1` child, or on a tween or timeline that repeats forever",
893
+ /**
894
+ * `onComplete` and `repeat: -1` anywhere in a GSAP call's arguments; for a
895
+ * timeline, its children and any `eventCallback("onComplete", …)` from
896
+ * `timelineLinks` — the chain, and the name it is kept under within its
897
+ * scope, `this.tl` included.
898
+ */
899
+ test(file) {
900
+ const endless = (nodes) =>
901
+ nodes.flatMap((node) =>
902
+ findAll(node, (n) => keyName(n) === "repeat" && numberValue(n.value) === -1),
903
+ )[0];
904
+ const findings = [];
905
+
906
+ for (const call of findAll(file.ast, (node) => isGsapCall(node))) {
907
+ const method = methodName(call);
908
+ const waits = call.arguments.some(
909
+ (argument) => findAll(argument, (n) => keyName(n) === "onComplete").length > 0,
910
+ );
911
+ const own = endless(call.arguments);
912
+ if (waits && own) {
913
+ findings.push({
914
+ index: own.start,
915
+ message: `\`gsap.${method}\` repeats forever, so its \`onComplete\` can never run.`,
916
+ hint: HINT.neverCompletesOwn,
917
+ });
918
+ continue;
919
+ }
920
+ if (method !== "timeline") continue;
921
+
922
+ const links = timelineLinks(file.ast, call);
923
+ const completes = links.some(
924
+ (link) =>
925
+ methodName(link) === "eventCallback" &&
926
+ staticString(link.arguments[0]) === "onComplete",
927
+ );
928
+ if (!waits && !completes) continue;
929
+
930
+ const child = links
931
+ .filter((link) => CHILDREN.has(methodName(link)))
932
+ .map((link) => endless(link.arguments))
933
+ .find(Boolean);
934
+ if (child) {
935
+ findings.push({
936
+ index: child.start,
937
+ message: MESSAGE.neverCompletesChild,
938
+ hint: HINT.neverCompletesChild,
939
+ });
940
+ }
941
+ }
942
+ return findings;
943
+ },
944
+ },
945
+ /**
946
+ * A transform origin that arrives after the transform it belongs to.
947
+ *
948
+ * When the origin of an SVG element that is already scaled, rotated or
949
+ * skewed changes, GSAP's `smoothOrigin` adds a translate that holds the
950
+ * element where it is. A `fromTo` renders its from-vars first, so an origin
951
+ * given only in its to-vars lands on an element already at its starting
952
+ * transform, and the translate outlives the tween. Measured in gsap 3.15 in
953
+ * Chrome: a circle grown from 0 about its centre ends a whole radius up and
954
+ * left; rotation and skew offset it too; a translation, an identity start or
955
+ * an HTML element does not.
956
+ *
957
+ * `warn`, not `error`: nothing here can tell an SVG target from an HTML one.
958
+ */
959
+ {
960
+ id: "late-transform-origin",
961
+ level: "warn",
962
+ description:
963
+ "A `fromTo` whose origin is only in its to-vars while its from-vars scale, rotate or skew, which leaves an SVG element offset",
964
+ /**
965
+ * Any `.fromTo(target, fromVars, toVars)` whose two vars are object
966
+ * literals. Keys are read at the top level of each, and a transform's
967
+ * from-value as it is written: `rotation: "90deg"` and `scale: 0` move the
968
+ * element, `scale: 1` and `rotation: 0` do not.
969
+ */
970
+ test(file) {
971
+ const ORIGIN = new Set(["transformOrigin", "svgOrigin"]);
972
+ const TRANSFORM = /^(?:scale[XY]?|rotation|rotate|skew[XY])$/;
973
+
974
+ const calls = findAll(file.ast, (node) => methodName(node) === "fromTo");
975
+
976
+ return calls.flatMap((call) => {
977
+ const from = unwrap(call.arguments[1]);
978
+ const to = unwrap(call.arguments[2]);
979
+ if (from?.type !== "ObjectExpression" || to?.type !== "ObjectExpression") {
980
+ return [];
981
+ }
982
+ const origin = to.properties.find((property) => ORIGIN.has(keyName(property)));
983
+ if (!origin) return [];
984
+ if (from.properties.some((property) => ORIGIN.has(keyName(property)))) return [];
985
+ const smooth = unwrap(propertyOf(to, "smoothOrigin")?.value);
986
+ if (smooth?.type === "Literal" && smooth.value === false) return [];
987
+
988
+ const moves = from.properties.some(
989
+ (property) =>
990
+ TRANSFORM.test(keyName(property) ?? "") &&
991
+ !identityTransform(
992
+ keyName(property),
993
+ file.raw.slice(property.value.start, property.value.end),
994
+ ),
995
+ );
996
+ return moves
997
+ ? [
998
+ {
999
+ index: origin.start,
1000
+ message: MESSAGE.lateTransformOrigin,
1001
+ hint: HINT.lateTransformOrigin,
1002
+ },
1003
+ ]
1004
+ : [];
1005
+ });
1006
+ },
1007
+ },
1008
+ {
1009
+ id: "shared-plugin-id",
1010
+ level: "error",
1011
+ description:
1012
+ "A hardcoded `#id` in MotionPath or MorphSVG config, which two instances of a component will share",
1013
+ /**
1014
+ * Any property naming a target geometry whose value is a fixed `#id`: a
1015
+ * string or a template literal with nothing interpolated, including the
1016
+ * shorthands `motionPath: "#id"` and `morphSVG: "#id"`.
1017
+ */
1018
+ test(file) {
1019
+ if (!file.isReact) return [];
1020
+ const KEYS = new Set(["path", "shape", "align", "motionPath", "morphSVG"]);
1021
+
1022
+ return findAll(file.ast, (node) => KEYS.has(keyName(node))).flatMap(
1023
+ (property) => {
1024
+ const id = staticString(property.value)?.match(/^#[A-Za-z][\w-]*$/)?.[0];
1025
+ return id
1026
+ ? [
1027
+ {
1028
+ index: property.start,
1029
+ message: `Hardcoded id \`${id}\` in plugin config.`,
1030
+ hint: HINT.sharedPluginId,
1031
+ },
1032
+ ]
1033
+ : [];
1034
+ },
1035
+ );
1036
+ },
1037
+ },
1038
+ {
1039
+ id: "unregistered-plugin",
1040
+ level: "error",
1041
+ description:
1042
+ "A plugin imported and never registered, whose properties are silently ignored",
1043
+ /**
1044
+ * Registered means named anywhere inside the arguments of a
1045
+ * `registerPlugin` call — not mentioned anywhere after one, and under its
1046
+ * local name, so an alias registered by its alias counts. Imports are read
1047
+ * as declarations: a default import takes its plugin's name from the path,
1048
+ * and a type-only import, of the whole statement or of one specifier, ships
1049
+ * nothing and is skipped.
1050
+ */
1051
+ test(file) {
1052
+ const registered = new Set();
1053
+ const registrations = findAll(file.ast, (node) => {
1054
+ const name = calleeName(node);
1055
+ return name === "registerPlugin" || name?.endsWith(".registerPlugin");
1056
+ });
1057
+ for (const call of registrations) {
1058
+ for (const arg of call.arguments) {
1059
+ for (const id of findAll(arg, (node) => node.type === "Identifier")) {
1060
+ registered.add(id.name);
1061
+ }
1062
+ }
1063
+ }
1064
+
1065
+ const findings = [];
1066
+ for (const declaration of file.ast.body) {
1067
+ if (declaration.type !== "ImportDeclaration") continue;
1068
+ if (declaration.importKind === "type") continue;
1069
+ const from = declaration.source.value;
1070
+ if (typeof from !== "string" || !/^gsap\/[\w/-]+$/.test(from)) continue;
1071
+
1072
+ for (const specifier of declaration.specifiers) {
1073
+ if (specifier.importKind === "type") continue;
1074
+ const imported =
1075
+ specifier.type === "ImportSpecifier"
1076
+ ? (specifier.imported.name ?? specifier.imported.value)
1077
+ : specifier.type === "ImportDefaultSpecifier"
1078
+ ? from.split("/").pop()
1079
+ : null;
1080
+ if (!imported || !PLUGIN_NAME.test(imported)) continue;
1081
+ if (registered.has(specifier.local.name)) continue;
1082
+ if (file.registeredElsewhere?.has(imported)) continue;
1083
+ findings.push({
1084
+ index: declaration.start,
1085
+ message: `\`${imported}\` is imported but never passed to \`gsap.registerPlugin\`.`,
1086
+ hint: HINT.unregisteredPlugin,
1087
+ });
1088
+ }
1089
+ }
1090
+ return findings;
1091
+ },
1092
+ },
1093
+ {
1094
+ id: "missing-reduced-motion",
1095
+ level: "warn",
1096
+ description:
1097
+ "An animating file with no `prefers-reduced-motion` branch",
1098
+ /**
1099
+ * A branch is the query written in code — a string, a template or JSX
1100
+ * text. A comment that mentions it is not a branch. A file that only sets
1101
+ * values is not animating anything.
1102
+ */
1103
+ test(file) {
1104
+ if (!file.usesGsap) return [];
1105
+ if (findAll(file.ast, (node) => spells(node, "prefers-reduced-motion")).length) {
1106
+ return [];
1107
+ }
1108
+ if (!findAll(file.ast, (node) => isGsapCall(node)).length) return [];
1109
+
1110
+ const first = findAll(
1111
+ file.ast,
1112
+ (node) =>
1113
+ node.type === "MemberExpression" &&
1114
+ unwrap(node.object)?.type === "Identifier" &&
1115
+ unwrap(node.object).name === "gsap",
1116
+ ).sort((a, b) => a.start - b.start)[0];
1117
+
1118
+ return [
1119
+ {
1120
+ index: first.start,
1121
+ message: "No `prefers-reduced-motion` branch in an animating file.",
1122
+ hint: HINT.missingReducedMotion,
1123
+ },
1124
+ ];
1125
+ },
1126
+ },
1127
+
1128
+ // --- Shipping -------------------------------------------------------------
1129
+ {
1130
+ id: "dev-tool-shipped",
1131
+ level: "error",
1132
+ description:
1133
+ "`GSDevTools` or `MotionPathHelper` imported statically, or unconditional `markers: true`",
1134
+ /**
1135
+ * A static import declaration that brings in a dev tool, by a specifier's
1136
+ * name or, for a bare or default import, by its path. A type-only import
1137
+ * ships no code. `markers: true` is the property itself.
1138
+ */
1139
+ test(file) {
1140
+ const TOOLS = /^(?:MotionPathHelper|GSDevTools)$/;
1141
+ const findings = [];
1142
+
1143
+ for (const declaration of file.ast.body) {
1144
+ if (declaration.type !== "ImportDeclaration") continue;
1145
+ if (declaration.importKind === "type") continue;
1146
+ const values = declaration.specifiers.filter((s) => s.importKind !== "type");
1147
+ if (declaration.specifiers.length && !values.length) continue;
1148
+
1149
+ const tool =
1150
+ values
1151
+ .map((s) => s.imported?.name ?? s.local.name)
1152
+ .find((name) => TOOLS.test(name)) ??
1153
+ String(declaration.source.value).split("/").pop();
1154
+ if (!TOOLS.test(tool)) continue;
1155
+
1156
+ findings.push({
1157
+ index: declaration.start,
1158
+ message: `\`${tool}\` is statically imported.`,
1159
+ hint: HINT.devToolImport,
1160
+ });
1161
+ }
1162
+
1163
+ const markers = findAll(file.ast, (node) => {
1164
+ if (keyName(node) !== "markers") return false;
1165
+ const value = unwrap(node.value);
1166
+ return value?.type === "Literal" && value.value === true;
1167
+ });
1168
+ for (const property of markers) {
1169
+ findings.push({
1170
+ index: property.start,
1171
+ message: "`markers: true` is unconditional.",
1172
+ hint: HINT.markers,
1173
+ });
1174
+ }
1175
+
1176
+ return findings;
1177
+ },
1178
+ },
1179
+ {
1180
+ id: "barrel-import",
1181
+ level: "warn",
1182
+ description:
1183
+ "Importing from `gsap/all`, which pulls in every plugin",
1184
+ /** An import or re-export whose module is `gsap/all`. */
1185
+ test(file) {
1186
+ return file.ast.body
1187
+ .map(moduleSource)
1188
+ .filter((source) => source?.value === "gsap/all")
1189
+ .map((source) => ({
1190
+ index: source.start,
1191
+ message: "Importing from `gsap/all` pulls in every plugin.",
1192
+ hint: HINT.barrelImport,
1193
+ }));
1194
+ },
1195
+ },
1196
+ ];