@ashstack/lint 0.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.
Files changed (202) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +60 -0
  3. package/RULES.md +2881 -0
  4. package/dist/core/index.d.ts +15 -0
  5. package/dist/core/index.js +222 -0
  6. package/dist/core/rules/core/components-tsx-only.d.ts +2 -0
  7. package/dist/core/rules/core/components-tsx-only.js +37 -0
  8. package/dist/core/rules/core/hoist-intl.d.ts +2 -0
  9. package/dist/core/rules/core/hoist-intl.js +32 -0
  10. package/dist/core/rules/core/index.d.ts +2 -0
  11. package/dist/core/rules/core/index.js +18 -0
  12. package/dist/core/rules/core/no-comments.d.ts +2 -0
  13. package/dist/core/rules/core/no-comments.js +194 -0
  14. package/dist/core/rules/core/no-naming-convention.d.ts +2 -0
  15. package/dist/core/rules/core/no-naming-convention.js +143 -0
  16. package/dist/core/rules/core/use-design-system.d.ts +2 -0
  17. package/dist/core/rules/core/use-design-system.js +147 -0
  18. package/dist/core/rules/zod/index.d.ts +2 -0
  19. package/dist/core/rules/zod/index.js +12 -0
  20. package/dist/core/rules/zod/prefer-enum.d.ts +2 -0
  21. package/dist/core/rules/zod/prefer-enum.js +44 -0
  22. package/dist/index.d.ts +4 -0
  23. package/dist/index.js +3 -0
  24. package/dist/lib/ast.d.ts +44 -0
  25. package/dist/lib/ast.js +162 -0
  26. package/dist/lib/detect.d.ts +9 -0
  27. package/dist/lib/detect.js +42 -0
  28. package/dist/lib/merge.d.ts +9 -0
  29. package/dist/lib/merge.js +18 -0
  30. package/dist/lib/module.d.ts +19 -0
  31. package/dist/lib/module.js +49 -0
  32. package/dist/lib/types.d.ts +114 -0
  33. package/dist/lib/types.js +1 -0
  34. package/dist/react/index.d.ts +9 -0
  35. package/dist/react/index.js +126 -0
  36. package/dist/react/rules/i18n/index.d.ts +2 -0
  37. package/dist/react/rules/i18n/index.js +16 -0
  38. package/dist/react/rules/i18n/no-bare-attrs.d.ts +2 -0
  39. package/dist/react/rules/i18n/no-bare-attrs.js +38 -0
  40. package/dist/react/rules/i18n/no-bare-text.d.ts +2 -0
  41. package/dist/react/rules/i18n/no-bare-text.js +26 -0
  42. package/dist/react/rules/i18n/no-bare-toast.d.ts +2 -0
  43. package/dist/react/rules/i18n/no-bare-toast.js +38 -0
  44. package/dist/react/rules/query/index.d.ts +2 -0
  45. package/dist/react/rules/query/index.js +20 -0
  46. package/dist/react/rules/query/next-page-param-undefined.d.ts +2 -0
  47. package/dist/react/rules/query/next-page-param-undefined.js +26 -0
  48. package/dist/react/rules/query/no-deprecated-filters.d.ts +2 -0
  49. package/dist/react/rules/query/no-deprecated-filters.js +63 -0
  50. package/dist/react/rules/query/no-fetch-in-query-fn.d.ts +2 -0
  51. package/dist/react/rules/query/no-fetch-in-query-fn.js +22 -0
  52. package/dist/react/rules/query/no-inline-keys.d.ts +2 -0
  53. package/dist/react/rules/query/no-inline-keys.js +107 -0
  54. package/dist/react/rules/query/require-destructured-hooks.d.ts +2 -0
  55. package/dist/react/rules/query/require-destructured-hooks.js +41 -0
  56. package/dist/react/rules/query/shared.d.ts +2 -0
  57. package/dist/react/rules/query/shared.js +10 -0
  58. package/dist/react/rules/zustand/index.d.ts +2 -0
  59. package/dist/react/rules/zustand/index.js +12 -0
  60. package/dist/react/rules/zustand/require-selector.d.ts +2 -0
  61. package/dist/react/rules/zustand/require-selector.js +62 -0
  62. package/dist/react-native/index.d.ts +11 -0
  63. package/dist/react-native/index.js +113 -0
  64. package/dist/react-native/rules/keyboard/index.d.ts +2 -0
  65. package/dist/react-native/rules/keyboard/index.js +18 -0
  66. package/dist/react-native/rules/legend-list/index.d.ts +2 -0
  67. package/dist/react-native/rules/legend-list/index.js +51 -0
  68. package/dist/react-native/rules/legend-list/no-flex-in-content-container.d.ts +2 -0
  69. package/dist/react-native/rules/legend-list/no-flex-in-content-container.js +77 -0
  70. package/dist/react-native/rules/legend-list/no-index-key-extractor.d.ts +2 -0
  71. package/dist/react-native/rules/legend-list/no-index-key-extractor.js +26 -0
  72. package/dist/react-native/rules/legend-list/no-inline-data.d.ts +2 -0
  73. package/dist/react-native/rules/legend-list/no-inline-data.js +25 -0
  74. package/dist/react-native/rules/legend-list/no-inline-extra-data.d.ts +2 -0
  75. package/dist/react-native/rules/legend-list/no-inline-extra-data.js +21 -0
  76. package/dist/react-native/rules/legend-list/no-inline-render-item-props.d.ts +2 -0
  77. package/dist/react-native/rules/legend-list/no-inline-render-item-props.js +35 -0
  78. package/dist/react-native/rules/legend-list/no-mixed-children.d.ts +2 -0
  79. package/dist/react-native/rules/legend-list/no-mixed-children.js +24 -0
  80. package/dist/react-native/rules/legend-list/no-remount-key.d.ts +2 -0
  81. package/dist/react-native/rules/legend-list/no-remount-key.js +18 -0
  82. package/dist/react-native/rules/legend-list/no-scrollview-map.d.ts +2 -0
  83. package/dist/react-native/rules/legend-list/no-scrollview-map.js +27 -0
  84. package/dist/react-native/rules/legend-list/no-unsupported-props.d.ts +2 -0
  85. package/dist/react-native/rules/legend-list/no-unsupported-props.js +29 -0
  86. package/dist/react-native/rules/legend-list/required-props.d.ts +2 -0
  87. package/dist/react-native/rules/legend-list/required-props.js +23 -0
  88. package/dist/react-native/rules/legend-list/shared.d.ts +19 -0
  89. package/dist/react-native/rules/legend-list/shared.js +25 -0
  90. package/dist/react-native/rules/legend-list/typed-items-need-item-type.d.ts +2 -0
  91. package/dist/react-native/rules/legend-list/typed-items-need-item-type.js +49 -0
  92. package/dist/react-native/rules/legend-state/index.d.ts +2 -0
  93. package/dist/react-native/rules/legend-state/index.js +35 -0
  94. package/dist/react-native/rules/legend-state/naming.d.ts +2 -0
  95. package/dist/react-native/rules/legend-state/naming.js +26 -0
  96. package/dist/react-native/rules/legend-state/no-assignment.d.ts +2 -0
  97. package/dist/react-native/rules/legend-state/no-assignment.js +31 -0
  98. package/dist/react-native/rules/legend-state/no-nested-observable.d.ts +2 -0
  99. package/dist/react-native/rules/legend-state/no-nested-observable.js +23 -0
  100. package/dist/react-native/rules/legend-state/no-object-selector.d.ts +2 -0
  101. package/dist/react-native/rules/legend-state/no-object-selector.js +47 -0
  102. package/dist/react-native/rules/legend-state/no-observable-in-component.d.ts +2 -0
  103. package/dist/react-native/rules/legend-state/no-observable-in-component.js +46 -0
  104. package/dist/react-native/rules/legend-state/no-peek-in-selector.d.ts +2 -0
  105. package/dist/react-native/rules/legend-state/no-peek-in-selector.js +67 -0
  106. package/dist/react-native/rules/legend-state/no-react-mirror.d.ts +2 -0
  107. package/dist/react-native/rules/legend-state/no-react-mirror.js +33 -0
  108. package/dist/react-native/rules/legend-state/no-untracked-get-in-jsx.d.ts +2 -0
  109. package/dist/react-native/rules/legend-state/no-untracked-get-in-jsx.js +52 -0
  110. package/dist/react-native/rules/legend-state/shared.d.ts +9 -0
  111. package/dist/react-native/rules/legend-state/shared.js +23 -0
  112. package/dist/react-native/rules/react-native/hoist-stateless-function.d.ts +2 -0
  113. package/dist/react-native/rules/react-native/hoist-stateless-function.js +59 -0
  114. package/dist/react-native/rules/react-native/index.d.ts +2 -0
  115. package/dist/react-native/rules/react-native/index.js +50 -0
  116. package/dist/react-native/rules/react-native/no-conditional-style-array.d.ts +2 -0
  117. package/dist/react-native/rules/react-native/no-conditional-style-array.js +23 -0
  118. package/dist/react-native/rules/react-native/no-dynamic-import.d.ts +2 -0
  119. package/dist/react-native/rules/react-native/no-dynamic-import.js +18 -0
  120. package/dist/react-native/rules/react-native/no-keyboard-will-events.d.ts +2 -0
  121. package/dist/react-native/rules/react-native/no-keyboard-will-events.js +18 -0
  122. package/dist/react-native/rules/react-native/no-leaked-render.d.ts +2 -0
  123. package/dist/react-native/rules/react-native/no-leaked-render.js +43 -0
  124. package/dist/react-native/rules/react-native/no-manual-memo.d.ts +2 -0
  125. package/dist/react-native/rules/react-native/no-manual-memo.js +87 -0
  126. package/dist/react-native/rules/react-native/no-redundant-view-nesting.d.ts +2 -0
  127. package/dist/react-native/rules/react-native/no-redundant-view-nesting.js +36 -0
  128. package/dist/react-native/rules/react-native/no-rn-image-network-source.d.ts +2 -0
  129. package/dist/react-native/rules/react-native/no-rn-image-network-source.js +58 -0
  130. package/dist/react-native/rules/react-native/no-rn-namespace-import.d.ts +2 -0
  131. package/dist/react-native/rules/react-native/no-rn-namespace-import.js +38 -0
  132. package/dist/react-native/rules/react-native/no-scroll-position-state.d.ts +2 -0
  133. package/dist/react-native/rules/react-native/no-scroll-position-state.js +26 -0
  134. package/dist/react-native/rules/react-native/no-unlabeled-icon-pressable.d.ts +2 -0
  135. package/dist/react-native/rules/react-native/no-unlabeled-icon-pressable.js +62 -0
  136. package/dist/react-native/rules/reanimated/animated-reaction-safety.d.ts +2 -0
  137. package/dist/react-native/rules/reanimated/animated-reaction-safety.js +110 -0
  138. package/dist/react-native/rules/reanimated/animated-style-needs-animated-component.d.ts +2 -0
  139. package/dist/react-native/rules/reanimated/animated-style-needs-animated-component.js +72 -0
  140. package/dist/react-native/rules/reanimated/animated-updater-purity.d.ts +2 -0
  141. package/dist/react-native/rules/reanimated/animated-updater-purity.js +38 -0
  142. package/dist/react-native/rules/reanimated/gpu-properties-only.d.ts +2 -0
  143. package/dist/react-native/rules/reanimated/gpu-properties-only.js +80 -0
  144. package/dist/react-native/rules/reanimated/hoist-layout-animation-builder.d.ts +2 -0
  145. package/dist/react-native/rules/reanimated/hoist-layout-animation-builder.js +43 -0
  146. package/dist/react-native/rules/reanimated/index.d.ts +2 -0
  147. package/dist/react-native/rules/reanimated/index.js +51 -0
  148. package/dist/react-native/rules/reanimated/interpolate-needs-clamp.d.ts +2 -0
  149. package/dist/react-native/rules/reanimated/interpolate-needs-clamp.js +41 -0
  150. package/dist/react-native/rules/reanimated/no-react-state-from-continuous-worklet.d.ts +2 -0
  151. package/dist/react-native/rules/reanimated/no-react-state-from-continuous-worklet.js +86 -0
  152. package/dist/react-native/rules/reanimated/no-shared-value-dot-value.d.ts +2 -0
  153. package/dist/react-native/rules/reanimated/no-shared-value-dot-value.js +112 -0
  154. package/dist/react-native/rules/reanimated/prefer-lazy-shared-value-initializer.d.ts +2 -0
  155. package/dist/react-native/rules/reanimated/prefer-lazy-shared-value-initializer.js +24 -0
  156. package/dist/react-native/rules/reanimated/schedule-on-rn-scope.d.ts +2 -0
  157. package/dist/react-native/rules/reanimated/schedule-on-rn-scope.js +21 -0
  158. package/dist/react-native/rules/reanimated/shared-value-usage.d.ts +2 -0
  159. package/dist/react-native/rules/reanimated/shared-value-usage.js +105 -0
  160. package/dist/react-native/rules/reanimated/shared.d.ts +5 -0
  161. package/dist/react-native/rules/reanimated/shared.js +13 -0
  162. package/dist/react-native/rules/skia/canvas-opaque.d.ts +2 -0
  163. package/dist/react-native/rules/skia/canvas-opaque.js +49 -0
  164. package/dist/react-native/rules/skia/index.d.ts +2 -0
  165. package/dist/react-native/rules/skia/index.js +21 -0
  166. package/dist/react-native/rules/turbo-image/index.d.ts +2 -0
  167. package/dist/react-native/rules/turbo-image/index.js +14 -0
  168. package/dist/react-native/rules/turbo-image/require-cache-policy.d.ts +2 -0
  169. package/dist/react-native/rules/turbo-image/require-cache-policy.js +21 -0
  170. package/dist/react-native/rules/turbo-image/require-resize.d.ts +2 -0
  171. package/dist/react-native/rules/turbo-image/require-resize.js +22 -0
  172. package/dist/react-native/rules/unistyles/animated-theme.d.ts +2 -0
  173. package/dist/react-native/rules/unistyles/animated-theme.js +37 -0
  174. package/dist/react-native/rules/unistyles/content-container.d.ts +2 -0
  175. package/dist/react-native/rules/unistyles/content-container.js +73 -0
  176. package/dist/react-native/rules/unistyles/in-sheet.d.ts +2 -0
  177. package/dist/react-native/rules/unistyles/in-sheet.js +116 -0
  178. package/dist/react-native/rules/unistyles/index.d.ts +2 -0
  179. package/dist/react-native/rules/unistyles/index.js +58 -0
  180. package/dist/react-native/rules/unistyles/insets.d.ts +2 -0
  181. package/dist/react-native/rules/unistyles/insets.js +92 -0
  182. package/dist/react-native/rules/unistyles/no-hardcoded-color.d.ts +2 -0
  183. package/dist/react-native/rules/unistyles/no-hardcoded-color.js +30 -0
  184. package/dist/react-native/rules/unistyles/no-hardcoded-spacing.d.ts +2 -0
  185. package/dist/react-native/rules/unistyles/no-hardcoded-spacing.js +78 -0
  186. package/dist/react-native/rules/unistyles/no-margin.d.ts +2 -0
  187. package/dist/react-native/rules/unistyles/no-margin.js +19 -0
  188. package/dist/react-native/rules/unistyles/no-style-spread.d.ts +2 -0
  189. package/dist/react-native/rules/unistyles/no-style-spread.js +41 -0
  190. package/dist/react-native/rules/unistyles/no-unused-styles.d.ts +2 -0
  191. package/dist/react-native/rules/unistyles/no-unused-styles.js +99 -0
  192. package/dist/react-native/rules/unistyles/rtl-style-call.d.ts +2 -0
  193. package/dist/react-native/rules/unistyles/rtl-style-call.js +42 -0
  194. package/dist/react-native/rules/unistyles/shared.d.ts +16 -0
  195. package/dist/react-native/rules/unistyles/shared.js +70 -0
  196. package/dist/react-native/rules/unistyles/theme-screen-component.d.ts +2 -0
  197. package/dist/react-native/rules/unistyles/theme-screen-component.js +22 -0
  198. package/dist/react-native/rules/unistyles/theme-style-attr.d.ts +2 -0
  199. package/dist/react-native/rules/unistyles/theme-style-attr.js +18 -0
  200. package/dist/react-native/stylesheet.d.ts +11 -0
  201. package/dist/react-native/stylesheet.js +44 -0
  202. package/package.json +70 -0
@@ -0,0 +1,62 @@
1
+ import { componentName, problem, tagIdentifier } from "../../../lib/ast.js";
2
+ const TOUCHABLES = new Set(["Pressable", "PressableScale", "TouchableOpacity", "TouchableHighlight"]);
3
+ const MESSAGES = {
4
+ touchable: "Add an `accessibilityLabel` (plus an `accessibilityHint` when the outcome is not obvious), or render a visible `<Text>` child, so this icon-only touchable has an accessible name.",
5
+ expoButton: "Add a `label`, or an `accessibilityLabel(...)` modifier, so this icon-only Expo UI `<Button>` has an accessible name.",
6
+ };
7
+ /** A plain (non-namespaced) attribute's name; anything else reads as `undefined`. */
8
+ const plainAttributeName = (attribute) => attribute.type === "JSXAttribute" && attribute.name.type === "JSXIdentifier" ? attribute.name.name : undefined;
9
+ const hasAccessibleName = (attributes) => attributes.some(attribute => {
10
+ const name = plainAttributeName(attribute);
11
+ return name === "accessibilityLabel" || name === "accessibilityHint";
12
+ });
13
+ const childKinds = (children) => {
14
+ const kinds = { hasIcon: false, hasText: false, hasExpression: false };
15
+ for (const child of children) {
16
+ if (child.type === "JSXExpressionContainer")
17
+ kinds.hasExpression = true;
18
+ if (child.type === "JSXText" && child.value.trim() !== "")
19
+ kinds.hasText = true;
20
+ if (child.type !== "JSXElement")
21
+ continue;
22
+ const childTag = tagIdentifier(child.openingElement.name);
23
+ if (childTag === "Text")
24
+ kinds.hasText = true;
25
+ if (childTag.endsWith("Icon"))
26
+ kinds.hasIcon = true;
27
+ }
28
+ return kinds;
29
+ };
30
+ export const noUnlabeledIconPressable = problem("Require an accessible name on an icon-only touchable or an icon-only Expo UI `<Button>`. Without a label, hint or visible text, a screen reader cannot reach the control.", {
31
+ createOnce(context) {
32
+ return {
33
+ JSXElement(node) {
34
+ const opening = node.openingElement;
35
+ if (!TOUCHABLES.has(componentName(opening.name)))
36
+ return;
37
+ if (hasAccessibleName(opening.attributes))
38
+ return;
39
+ const { hasIcon, hasText, hasExpression } = childKinds(node.children);
40
+ if (!hasIcon || hasText || hasExpression)
41
+ return;
42
+ context.report({ node: opening.name, message: MESSAGES.touchable });
43
+ },
44
+ JSXOpeningElement(node) {
45
+ if (!node.selfClosing)
46
+ return;
47
+ if (componentName(node.name) !== "Button")
48
+ return;
49
+ const names = new Set(node.attributes.map(plainAttributeName).filter(Boolean));
50
+ if (!names.has("systemImage"))
51
+ return;
52
+ if (names.has("label") ||
53
+ names.has("accessibilityLabel") ||
54
+ names.has("accessibilityHint") ||
55
+ names.has("modifiers")) {
56
+ return;
57
+ }
58
+ context.report({ node: node.name, message: MESSAGES.expoButton });
59
+ },
60
+ };
61
+ },
62
+ });
@@ -0,0 +1,2 @@
1
+ import type { Rule } from "../../../lib/types.js";
2
+ export declare const animatedReactionSafety: Rule;
@@ -0,0 +1,110 @@
1
+ import { calleeName, gate, hasAncestor, isMemberCall, isWithin, problem, receiverName, subtreeHas, } from "../../../lib/ast.js";
2
+ const MESSAGES = {
3
+ hotBridgeUnguarded: "Guard this `scheduleOnRN` by comparing the current and previous prepared results, so high-frequency input does not bridge to RN every frame.",
4
+ hotBridgeNoPrevious: "Take the previous prepared result as the callback's second parameter and guard `scheduleOnRN` on it differing from the current one, so high-frequency input does not bridge to RN every frame.",
5
+ };
6
+ const REACTION_HOOKS = new Set(["useAnimatedReaction"]);
7
+ const NOT_EQUAL_OPERATORS = new Set(["!==", "!="]);
8
+ const identifierName = (node) => (node?.type === "Identifier" ? node.name : null);
9
+ const comparesWithNotEqual = (test, first, second) => {
10
+ if (test.type !== "BinaryExpression" || !NOT_EQUAL_OPERATORS.has(test.operator))
11
+ return false;
12
+ const left = identifierName(test.left);
13
+ const right = identifierName(test.right);
14
+ return (left === first && right === second) || (left === second && right === first);
15
+ };
16
+ const comparesWithNegatedIs = (test, first, second) => {
17
+ if (test.type !== "UnaryExpression" || test.operator !== "!")
18
+ return false;
19
+ const { argument } = test;
20
+ if (argument.type !== "CallExpression" || calleeName(argument) !== "is")
21
+ return false;
22
+ const compared = argument.arguments;
23
+ return (compared.some(entry => identifierName(entry) === first) && compared.some(entry => identifierName(entry) === second));
24
+ };
25
+ /** True when an enclosing `if` tests the two callback parameters against each other. */
26
+ const guardsComparison = (node, first, second) => {
27
+ if (!first || !second)
28
+ return false;
29
+ return hasAncestor(node, current => {
30
+ if (current.type !== "IfStatement")
31
+ return false;
32
+ const { test } = current;
33
+ return comparesWithNotEqual(test, first, second) || comparesWithNegatedIs(test, first, second);
34
+ });
35
+ };
36
+ /** True when a `.set()`/`.modify()` in the result callback writes what the prepare callback reads. */
37
+ const feedsOwnInput = (node, reaction) => {
38
+ if (node.type !== "CallExpression" || node.callee.type !== "MemberExpression")
39
+ return false;
40
+ const shared = receiverName(node);
41
+ if (!shared)
42
+ return false;
43
+ if (reaction.type !== "CallExpression")
44
+ return false;
45
+ const [prepare, react] = reaction.arguments;
46
+ if (!prepare || !react || !isWithin(node, react))
47
+ return false;
48
+ return subtreeHas(prepare, current => isMemberCall(current, "get") && receiverName(current) === shared);
49
+ };
50
+ /** The parameters of the reaction's result callback, or null when the second argument is not a function. */
51
+ const resultCallbackParams = (reaction) => {
52
+ if (reaction.type !== "CallExpression")
53
+ return null;
54
+ const callback = reaction.arguments[1];
55
+ if (callback?.type !== "FunctionDeclaration" &&
56
+ callback?.type !== "FunctionExpression" &&
57
+ callback?.type !== "ArrowFunctionExpression") {
58
+ return null;
59
+ }
60
+ return callback.params;
61
+ };
62
+ /** What is wrong with a `scheduleOnRN` inside a reaction, or null when it is guarded. */
63
+ const hotBridgeProblem = (node, reaction) => {
64
+ const params = resultCallbackParams(reaction);
65
+ if (!params)
66
+ return null;
67
+ const previous = identifierName(params[1]);
68
+ if (!previous)
69
+ return MESSAGES.hotBridgeNoPrevious;
70
+ return guardsComparison(node, identifierName(params[0]), previous) ? null : MESSAGES.hotBridgeUnguarded;
71
+ };
72
+ export const animatedReactionSafety = problem("A `useAnimatedReaction` result callback loops forever if it writes a shared value the prepare callback reads. Guard `scheduleOnRN` there on the current result differing from the previous one.", {
73
+ createOnce(context) {
74
+ let stack = [];
75
+ return {
76
+ before() {
77
+ stack = [];
78
+ return gate(context, "useAnimatedReaction");
79
+ },
80
+ CallExpression(node) {
81
+ const name = calleeName(node);
82
+ if (REACTION_HOOKS.has(name)) {
83
+ stack.push(node);
84
+ return;
85
+ }
86
+ const reaction = stack.at(-1);
87
+ if (!reaction)
88
+ return;
89
+ if (name === "set" || name === "modify") {
90
+ if (!feedsOwnInput(node, reaction))
91
+ return;
92
+ context.report({
93
+ node,
94
+ message: `Have this result callback ${name === "set" ? "write" : "modify"} a shared value its prepare callback does not read, or gate the call on a comparison; feeding its own input loops forever.`,
95
+ });
96
+ return;
97
+ }
98
+ if (name !== "scheduleOnRN")
99
+ return;
100
+ const problemMessage = hotBridgeProblem(node, reaction);
101
+ if (problemMessage !== null)
102
+ context.report({ node, message: problemMessage });
103
+ },
104
+ "CallExpression:exit"(node) {
105
+ if (REACTION_HOOKS.has(calleeName(node)))
106
+ stack.pop();
107
+ },
108
+ };
109
+ },
110
+ });
@@ -0,0 +1,2 @@
1
+ import type { Rule } from "../../../lib/types.js";
2
+ export declare const animatedStyleNeedsAnimatedComponent: Rule;
@@ -0,0 +1,72 @@
1
+ import { attributeName, calleeName, gate, problem, tagPath } from "../../../lib/ast.js";
2
+ import { ANIMATED_STYLE_HOOKS } from "./shared.js";
3
+ const NEEDS_ANIMATED_COMPONENT = "Render this with the matching `Animated.*` component so the animated style takes effect. On a plain element the style is applied once at mount and never updates, and nothing errors.";
4
+ const ANIMATED_COMPONENT_FACTORIES = new Set(["createAnimatedComponent", "withUnistyles", "withAnimated"]);
5
+ /** The tag name node of an element, whether handed the element or its opening tag. */
6
+ const elementName = (node) => {
7
+ if (node.type === "JSXElement")
8
+ return node.openingElement.name;
9
+ if (node.type === "JSXOpeningElement")
10
+ return node.name;
11
+ return null;
12
+ };
13
+ /** The identifiers a `style` prop value names, whether it holds one style or an array of them. */
14
+ const referencedStyleNames = (value) => {
15
+ if (value?.type !== "JSXExpressionContainer")
16
+ return [];
17
+ const { expression } = value;
18
+ if (expression.type === "Identifier")
19
+ return [expression.name];
20
+ if (expression.type !== "ArrayExpression")
21
+ return [];
22
+ return expression.elements.flatMap(element => (element?.type === "Identifier" ? [element.name] : []));
23
+ };
24
+ export const animatedStyleNeedsAnimatedComponent = problem("An animated style only takes effect on an `Animated.*` component. A plain element applies it once at mount and then never updates.", {
25
+ createOnce(context) {
26
+ let animatedStyles = new Set();
27
+ let animatedComponents = new Set();
28
+ let candidates = [];
29
+ return {
30
+ before() {
31
+ animatedStyles = new Set();
32
+ animatedComponents = new Set();
33
+ candidates = [];
34
+ return gate(context, "useAnimatedStyle", "useAnimatedProps");
35
+ },
36
+ VariableDeclarator(node) {
37
+ const { id, init } = node;
38
+ if (id.type !== "Identifier")
39
+ return;
40
+ if (init?.type !== "CallExpression")
41
+ return;
42
+ const callee = calleeName(init);
43
+ if (ANIMATED_STYLE_HOOKS.has(callee))
44
+ animatedStyles.add(id.name);
45
+ else if (ANIMATED_COMPONENT_FACTORIES.has(callee))
46
+ animatedComponents.add(id.name);
47
+ },
48
+ "Program:exit"() {
49
+ if (animatedStyles.size === 0)
50
+ return;
51
+ for (const { node, tag, referenced } of candidates) {
52
+ if (animatedComponents.has(tag))
53
+ continue;
54
+ if (!referenced.some(name => animatedStyles.has(name)))
55
+ continue;
56
+ context.report({ node, message: NEEDS_ANIMATED_COMPONENT });
57
+ }
58
+ },
59
+ JSXAttribute(node) {
60
+ if (attributeName(node) !== "style")
61
+ return;
62
+ const tag = tagPath(elementName(node.parent));
63
+ if (tag === "" || tag.startsWith("Animated"))
64
+ return;
65
+ const referenced = referencedStyleNames(node.value);
66
+ if (referenced.length === 0)
67
+ return;
68
+ candidates.push({ node, tag, referenced });
69
+ },
70
+ };
71
+ },
72
+ });
@@ -0,0 +1,2 @@
1
+ import type { Rule } from "../../../lib/types.js";
2
+ export declare const animatedUpdaterPurity: Rule;
@@ -0,0 +1,38 @@
1
+ import { calleeName, problem } from "../../../lib/ast.js";
2
+ import { ANIMATED_STYLE_HOOKS } from "./shared.js";
3
+ const UPDATER_SIDE_EFFECT = "Schedule this RN side effect from an animation completion callback or `useAnimatedReaction` instead; `useAnimatedStyle`/`useAnimatedProps` updaters must stay pure.";
4
+ export const animatedUpdaterPurity = problem("An updater passed to `useAnimatedStyle` or `useAnimatedProps` must stay pure, so it may not write a shared value or call `scheduleOnRN`.", {
5
+ createOnce(context) {
6
+ let depth = 0;
7
+ return {
8
+ before() {
9
+ depth = 0;
10
+ },
11
+ CallExpression(node) {
12
+ const name = calleeName(node);
13
+ if (ANIMATED_STYLE_HOOKS.has(name)) {
14
+ depth += 1;
15
+ return;
16
+ }
17
+ if (depth === 0)
18
+ return;
19
+ if (name === "set" || name === "modify") {
20
+ if (node.callee.type !== "MemberExpression")
21
+ return;
22
+ context.report({
23
+ node,
24
+ message: `Move this \`.${name}()\` write out to an event handler, an effect, a derived value, or \`useAnimatedReaction\`; \`useAnimatedStyle\`/\`useAnimatedProps\` updaters must stay pure.`,
25
+ });
26
+ return;
27
+ }
28
+ if (name !== "scheduleOnRN")
29
+ return;
30
+ context.report({ node, message: UPDATER_SIDE_EFFECT });
31
+ },
32
+ "CallExpression:exit"(node) {
33
+ if (ANIMATED_STYLE_HOOKS.has(calleeName(node)))
34
+ depth -= 1;
35
+ },
36
+ };
37
+ },
38
+ });
@@ -0,0 +1,2 @@
1
+ import type { Rule } from "../../../lib/types.js";
2
+ export declare const gpuPropertiesOnly: Rule;
@@ -0,0 +1,80 @@
1
+ import { calleeName, gate, problem } from "../../../lib/ast.js";
2
+ import { ANIMATED_STYLE_HOOKS } from "./shared.js";
3
+ const GPU_PROPERTIES_ONLY = "Animate `transform` and `opacity` instead: a panel that grows is `scaleY` with `transformOrigin`, a thing that slides is `translateY`. This property recalculates layout on every frame.";
4
+ /** Style properties whose animation forces a layout pass. */
5
+ const LAYOUT_PROPS = new Set([
6
+ "width",
7
+ "height",
8
+ "minWidth",
9
+ "minHeight",
10
+ "maxWidth",
11
+ "maxHeight",
12
+ "top",
13
+ "left",
14
+ "right",
15
+ "bottom",
16
+ "start",
17
+ "end",
18
+ "flex",
19
+ "flexBasis",
20
+ "padding",
21
+ "paddingTop",
22
+ "paddingBottom",
23
+ "paddingLeft",
24
+ "paddingRight",
25
+ "paddingStart",
26
+ "paddingEnd",
27
+ "paddingHorizontal",
28
+ "paddingVertical",
29
+ "margin",
30
+ "marginTop",
31
+ "marginBottom",
32
+ "marginLeft",
33
+ "marginRight",
34
+ "marginStart",
35
+ "marginEnd",
36
+ "marginHorizontal",
37
+ "marginVertical",
38
+ "gap",
39
+ "rowGap",
40
+ "columnGap",
41
+ ]);
42
+ const styleKeyName = (property) => {
43
+ if (property.type !== "Property")
44
+ return null;
45
+ const { key } = property;
46
+ if (key.type === "Identifier")
47
+ return key.name;
48
+ if (key.type === "Literal")
49
+ return String(key.value);
50
+ return null;
51
+ };
52
+ export const gpuPropertiesOnly = problem("Animate `transform` and `opacity` in `useAnimatedStyle` and `useAnimatedProps`. Layout properties such as `width` or `margin` recalculate layout every frame.", {
53
+ createOnce(context) {
54
+ let depth = 0;
55
+ return {
56
+ before() {
57
+ depth = 0;
58
+ return gate(context, "useAnimatedStyle", "useAnimatedProps");
59
+ },
60
+ CallExpression(node) {
61
+ if (ANIMATED_STYLE_HOOKS.has(calleeName(node)))
62
+ depth += 1;
63
+ },
64
+ "CallExpression:exit"(node) {
65
+ if (ANIMATED_STYLE_HOOKS.has(calleeName(node)))
66
+ depth -= 1;
67
+ },
68
+ Property(node) {
69
+ if (depth === 0)
70
+ return;
71
+ if (node.parent.type !== "ObjectExpression")
72
+ return;
73
+ const name = styleKeyName(node);
74
+ if (name !== null && LAYOUT_PROPS.has(name)) {
75
+ context.report({ node, message: GPU_PROPERTIES_ONLY });
76
+ }
77
+ },
78
+ };
79
+ },
80
+ });
@@ -0,0 +1,2 @@
1
+ import type { Rule } from "../../../lib/types.js";
2
+ export declare const hoistLayoutAnimationBuilder: Rule;
@@ -0,0 +1,43 @@
1
+ import { calleeName, findInSubtree, FUNCTION_TYPES, hasAncestor, problem } from "../../../lib/ast.js";
2
+ const LAYOUT_BUILDER = "Build this layout animation at module scope when it is static, or memoize it when it depends on component values.";
3
+ const LAYOUT_ATTRIBUTES = new Set(["entering", "exiting", "layout"]);
4
+ const LAYOUT_BUILDER_METHODS = new Set([
5
+ "duration",
6
+ "delay",
7
+ "springify",
8
+ "damping",
9
+ "stiffness",
10
+ "mass",
11
+ "easing",
12
+ "withCallback",
13
+ "withInitialValues",
14
+ "withTargetValues",
15
+ ]);
16
+ /** The builder call inside a layout-animation prop value, if there is one. */
17
+ const findBuilderCall = (value) => findInSubtree(value, current => {
18
+ if (current.type !== "CallExpression" || current.callee.type !== "MemberExpression")
19
+ return false;
20
+ const { property } = current.callee;
21
+ return property.type === "Identifier" && LAYOUT_BUILDER_METHODS.has(property.name);
22
+ });
23
+ const MEMO_HOOKS = new Set(["useMemo", "useCallback"]);
24
+ export const hoistLayoutAnimationBuilder = problem("A layout animation belongs at module scope, or inside a memo when it depends on component values. The `entering`/`exiting`/`layout` props otherwise rebuild it on every render.", {
25
+ createOnce(context) {
26
+ return {
27
+ JSXAttribute(node) {
28
+ if (node.name.type !== "JSXIdentifier")
29
+ return;
30
+ if (!LAYOUT_ATTRIBUTES.has(node.name.name))
31
+ return;
32
+ if (!hasAncestor(node, current => FUNCTION_TYPES.has(current.type)))
33
+ return;
34
+ const builderCall = findBuilderCall(node.value);
35
+ if (!builderCall)
36
+ return;
37
+ if (hasAncestor(builderCall, current => MEMO_HOOKS.has(calleeName(current))))
38
+ return;
39
+ context.report({ node: builderCall, message: LAYOUT_BUILDER });
40
+ },
41
+ };
42
+ },
43
+ });
@@ -0,0 +1,2 @@
1
+ declare const _default: import("../../../index.js").ModuleManifest;
2
+ export default _default;
@@ -0,0 +1,51 @@
1
+ import { defineModule } from "../../../lib/module.js";
2
+ import { animatedReactionSafety } from "./animated-reaction-safety.js";
3
+ import { animatedStyleNeedsAnimatedComponent } from "./animated-style-needs-animated-component.js";
4
+ import { animatedUpdaterPurity } from "./animated-updater-purity.js";
5
+ import { gpuPropertiesOnly } from "./gpu-properties-only.js";
6
+ import { hoistLayoutAnimationBuilder } from "./hoist-layout-animation-builder.js";
7
+ import { interpolateNeedsClamp } from "./interpolate-needs-clamp.js";
8
+ import { noReactStateFromContinuousWorklet } from "./no-react-state-from-continuous-worklet.js";
9
+ import { noSharedValueDotValue } from "./no-shared-value-dot-value.js";
10
+ import { preferLazySharedValueInitializer } from "./prefer-lazy-shared-value-initializer.js";
11
+ import { scheduleOnRnScope } from "./schedule-on-rn-scope.js";
12
+ import { sharedValueUsage } from "./shared-value-usage.js";
13
+ export default defineModule({
14
+ meta: { name: "@ashstack/reanimated" },
15
+ url: import.meta.url,
16
+ packages: ["react-native-reanimated"],
17
+ option: "reanimated",
18
+ docsWhen: "auto-enabled when `react-native-reanimated` is a dependency",
19
+ rules: {
20
+ "animated-reaction-safety": animatedReactionSafety,
21
+ "animated-style-needs-animated-component": animatedStyleNeedsAnimatedComponent,
22
+ "animated-updater-purity": animatedUpdaterPurity,
23
+ "gpu-properties-only": gpuPropertiesOnly,
24
+ "hoist-layout-animation-builder": hoistLayoutAnimationBuilder,
25
+ "interpolate-needs-clamp": interpolateNeedsClamp,
26
+ "no-shared-value-dot-value": noSharedValueDotValue,
27
+ "no-react-state-from-continuous-worklet": noReactStateFromContinuousWorklet,
28
+ "prefer-lazy-shared-value-initializer": preferLazySharedValueInitializer,
29
+ "schedule-on-rn-scope": scheduleOnRnScope,
30
+ "shared-value-usage": sharedValueUsage,
31
+ },
32
+ restrictedImports: {
33
+ paths: [
34
+ {
35
+ name: "react-native",
36
+ importNames: ["Animated"],
37
+ message: "Use `react-native-reanimated` instead of React Native's `Animated` API.",
38
+ },
39
+ {
40
+ name: "react-native-reanimated",
41
+ importNames: ["runOnJS", "runOnUI"],
42
+ message: "Use `scheduleOnRN` and `scheduleOnUI` from `react-native-worklets`; `runOnJS` and `runOnUI` are deprecated.",
43
+ },
44
+ {
45
+ name: "react-native-worklets",
46
+ importNames: ["runOnJS", "runOnUI"],
47
+ message: "Use `scheduleOnRN` and `scheduleOnUI` from `react-native-worklets`; `runOnJS` and `runOnUI` are deprecated.",
48
+ },
49
+ ],
50
+ },
51
+ });
@@ -0,0 +1,2 @@
1
+ import type { Rule } from "../../../lib/types.js";
2
+ export declare const interpolateNeedsClamp: Rule;
@@ -0,0 +1,41 @@
1
+ import { gate, problem } from "../../../lib/ast.js";
2
+ const MISSING = "Pass `Extrapolation.CLAMP` as the fourth argument to `interpolate`. Without it the output keeps extrapolating past the input range, so a scroll offset of 400 against `[0, 100]` runs well past where it should stop.";
3
+ const NOT_CLAMPING = "Change this fourth argument to `Extrapolation.CLAMP`. `EXTEND` and `IDENTITY` both keep extrapolating past the input range, so a scroll offset of 400 against `[0, 100]` runs well past where it should stop.";
4
+ const NON_CLAMPING = new Set(["EXTEND", "IDENTITY", "extend", "identity"]);
5
+ /** An extrapolation this rule can see is not clamping. An opaque one — a variable, a call — reports nothing. */
6
+ const isNonClamping = (node) => {
7
+ if (node.type === "Literal")
8
+ return typeof node.value === "string" && NON_CLAMPING.has(node.value);
9
+ if (node.type === "Identifier")
10
+ return NON_CLAMPING.has(node.name);
11
+ if (node.type === "MemberExpression") {
12
+ return node.property.type === "Identifier" && NON_CLAMPING.has(node.property.name);
13
+ }
14
+ if (node.type === "ObjectExpression") {
15
+ return node.properties.some(property => property.type === "Property" && isNonClamping(property.value));
16
+ }
17
+ return false;
18
+ };
19
+ export const interpolateNeedsClamp = problem("Give `interpolate()` a fourth argument that clamps. Without one, or with `EXTEND` or `IDENTITY`, the output keeps going past the ends of the input range.", {
20
+ createOnce(context) {
21
+ return {
22
+ before() {
23
+ return gate(context, "interpolate");
24
+ },
25
+ CallExpression(node) {
26
+ const { callee, arguments: args } = node;
27
+ if (callee.type !== "Identifier" || callee.name !== "interpolate")
28
+ return;
29
+ if (args.length < 3 || args.length > 4)
30
+ return;
31
+ if (args.some(argument => argument.type === "SpreadElement"))
32
+ return;
33
+ const extrapolation = args[3];
34
+ if (extrapolation === undefined)
35
+ context.report({ node, message: MISSING });
36
+ else if (isNonClamping(extrapolation))
37
+ context.report({ node: extrapolation, message: NOT_CLAMPING });
38
+ },
39
+ };
40
+ },
41
+ });
@@ -0,0 +1,2 @@
1
+ import type { Rule } from "../../../lib/types.js";
2
+ export declare const noReactStateFromContinuousWorklet: Rule;
@@ -0,0 +1,86 @@
1
+ import { calleeName, enclosingCall, gate, importedSpecifiers, isFunction, problem, subtreeHas, } from "../../../lib/ast.js";
2
+ const CONTINUOUS_WORKLET_STATE = "Keep this gate in shared or native state, or apply it through an imperative ref. A continuously evaluated worklet that schedules React state can land a Fabric commit or a Skia re-recording on an animation frame.";
3
+ /** Hooks whose callback is re-evaluated as the animation runs. */
4
+ const CONTINUOUS_HOOKS = new Set([
5
+ "useAnimatedReaction",
6
+ "useFrameCallback",
7
+ "useDerivedValue",
8
+ "useAnimatedStyle",
9
+ "useAnimatedProps",
10
+ "useAnimatedScrollHandler",
11
+ ]);
12
+ /** True for `import { name } from ...` — a named import, not a default or namespace one. */
13
+ const importsName = (specifier, name) => specifier.type === "ImportSpecifier" && specifier.imported.type === "Identifier" && specifier.imported.name === name;
14
+ /** The setters this argument names, whether handed over directly or called inside an inline callback. */
15
+ const settersReached = (target) => {
16
+ if (target.type === "Identifier")
17
+ return [target.name];
18
+ if (!isFunction(target))
19
+ return [];
20
+ const called = [];
21
+ subtreeHas(target, current => {
22
+ if (current.type === "CallExpression" && current.callee.type === "Identifier")
23
+ called.push(current.callee.name);
24
+ return false;
25
+ });
26
+ return called;
27
+ };
28
+ export const noReactStateFromContinuousWorklet = problem("A worklet that runs every frame must not send a React state setter through `scheduleOnRN`. That puts a Fabric commit on an animation frame.", {
29
+ createOnce(context) {
30
+ const setters = new Set();
31
+ const pending = [];
32
+ let useStateFromReact = false;
33
+ let scheduleFromWorklets = false;
34
+ return {
35
+ before() {
36
+ setters.clear();
37
+ pending.length = 0;
38
+ useStateFromReact = false;
39
+ scheduleFromWorklets = false;
40
+ return gate(context, "scheduleOnRN");
41
+ },
42
+ ImportDeclaration(node) {
43
+ for (const specifier of importedSpecifiers(node, "react")) {
44
+ if (importsName(specifier, "useState"))
45
+ useStateFromReact = true;
46
+ }
47
+ for (const specifier of importedSpecifiers(node, "react-native-worklets")) {
48
+ if (importsName(specifier, "scheduleOnRN"))
49
+ scheduleFromWorklets = true;
50
+ }
51
+ },
52
+ VariableDeclarator(node) {
53
+ const { id, init } = node;
54
+ if (id.type !== "ArrayPattern")
55
+ return;
56
+ if (init?.type !== "CallExpression")
57
+ return;
58
+ if (init.callee.type !== "Identifier" || init.callee.name !== "useState")
59
+ return;
60
+ const setter = id.elements[1];
61
+ if (setter?.type === "Identifier")
62
+ setters.add(setter.name);
63
+ },
64
+ CallExpression(node) {
65
+ if (calleeName(node) !== "scheduleOnRN")
66
+ return;
67
+ const target = node.arguments[0];
68
+ if (target === undefined)
69
+ return;
70
+ if (!enclosingCall(node, CONTINUOUS_HOOKS))
71
+ return;
72
+ for (const name of settersReached(target))
73
+ pending.push({ node, name });
74
+ },
75
+ "Program:exit"() {
76
+ if (!useStateFromReact || !scheduleFromWorklets)
77
+ return;
78
+ for (const entry of pending) {
79
+ if (!setters.has(entry.name))
80
+ continue;
81
+ context.report({ node: entry.node, message: CONTINUOUS_WORKLET_STATE });
82
+ }
83
+ },
84
+ };
85
+ },
86
+ });
@@ -0,0 +1,2 @@
1
+ import type { Rule } from "../../../lib/types.js";
2
+ export declare const noSharedValueDotValue: Rule;