@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,52 @@
1
+ import { gate, problem } from "../../../lib/ast.js";
2
+ import { isObservableRef, textOf } from "./shared.js";
3
+ export const noUntrackedGetInJsx = problem("A `get()` placed directly in a JSX expression container is a plain read. The value renders once and never updates.", {
4
+ createOnce(context) {
5
+ let functionDepth = 0;
6
+ let functionDepthAtContainerStart = [];
7
+ const enterFunction = () => {
8
+ functionDepth++;
9
+ };
10
+ const exitFunction = () => {
11
+ functionDepth--;
12
+ };
13
+ const directlyInJsxContainer = () => functionDepthAtContainerStart.length > 0 &&
14
+ functionDepthAtContainerStart[functionDepthAtContainerStart.length - 1] === functionDepth;
15
+ return {
16
+ before() {
17
+ functionDepth = 0;
18
+ functionDepthAtContainerStart = [];
19
+ return gate(context, ".get()");
20
+ },
21
+ FunctionDeclaration: enterFunction,
22
+ "FunctionDeclaration:exit": exitFunction,
23
+ FunctionExpression: enterFunction,
24
+ "FunctionExpression:exit": exitFunction,
25
+ ArrowFunctionExpression: enterFunction,
26
+ "ArrowFunctionExpression:exit": exitFunction,
27
+ JSXExpressionContainer() {
28
+ functionDepthAtContainerStart.push(functionDepth);
29
+ },
30
+ "JSXExpressionContainer:exit"() {
31
+ functionDepthAtContainerStart.pop();
32
+ },
33
+ CallExpression(node) {
34
+ if (!directlyInJsxContainer())
35
+ return;
36
+ const { callee } = node;
37
+ if (callee.type !== "MemberExpression" || callee.computed)
38
+ return;
39
+ if (callee.property.type !== "Identifier" || callee.property.name !== "get")
40
+ return;
41
+ const { object } = callee;
42
+ if (!isObservableRef(object))
43
+ return;
44
+ const target = textOf(context, object);
45
+ context.report({
46
+ node,
47
+ message: `Read it with \`useValue(${target})\` at the top of the component, or wrap this fragment in \`<Memo>\` so the read happens inside a tracking context. A \`get()\` here is a plain read, so it renders the first value and never updates.`,
48
+ });
49
+ },
50
+ };
51
+ },
52
+ });
@@ -0,0 +1,9 @@
1
+ import type { AstNode, RuleContext } from "../../../lib/types.js";
2
+ export declare const OBSERVABLE_FACTORIES: Set<string>;
3
+ /** The trailing `$` that marks an observable binding. */
4
+ export declare const OBS: RegExp;
5
+ export declare const isObservableRef: (node: AstNode | null | undefined) => boolean;
6
+ /** The factory name when this call is `observable(...)` or `useObservable(...)`. */
7
+ export declare const factoryCalled: (node: AstNode | null | undefined) => string | null;
8
+ /** Source text for a node, for quoting it back in a message. */
9
+ export declare const textOf: (context: RuleContext, node: AstNode) => string;
@@ -0,0 +1,23 @@
1
+ export const OBSERVABLE_FACTORIES = new Set(["observable", "useObservable"]);
2
+ /** The trailing `$` that marks an observable binding. */
3
+ export const OBS = /\$$/;
4
+ /** The identifier a member chain is rooted at: `count$`, or the `settings$` of `settings$.theme.color`. */
5
+ const rootName = (node) => {
6
+ let current = node;
7
+ while (current?.type === "MemberExpression")
8
+ current = current.object;
9
+ return current?.type === "Identifier" ? current.name : null;
10
+ };
11
+ export const isObservableRef = (node) => {
12
+ const root = rootName(node);
13
+ return root != null && OBS.test(root);
14
+ };
15
+ /** The factory name when this call is `observable(...)` or `useObservable(...)`. */
16
+ export const factoryCalled = (node) => {
17
+ if (node?.type !== "CallExpression")
18
+ return null;
19
+ const { callee } = node;
20
+ return callee.type === "Identifier" && OBSERVABLE_FACTORIES.has(callee.name) ? callee.name : null;
21
+ };
22
+ /** Source text for a node, for quoting it back in a message. */
23
+ export const textOf = (context, node) => context.sourceCode.getText(node);
@@ -0,0 +1,2 @@
1
+ import type { Rule } from "../../../lib/types.js";
2
+ export declare const hoistStatelessFunction: Rule;
@@ -0,0 +1,59 @@
1
+ import { ancestors, COMPONENT_OR_HOOK, isFunction, problem } from "../../../lib/ast.js";
2
+ const COMPONENT_NAME = /^[A-Z]/;
3
+ const boundFunctionName = (node) => {
4
+ if (node.type === "FunctionDeclaration")
5
+ return node.id?.name ?? null;
6
+ const { parent } = node;
7
+ if (parent === null || parent.type !== "VariableDeclarator" || parent.id.type !== "Identifier")
8
+ return null;
9
+ return parent.id.name;
10
+ };
11
+ const enclosingReactFunction = (node) => {
12
+ for (const current of ancestors(node)) {
13
+ if (!isFunction(current))
14
+ continue;
15
+ const name = boundFunctionName(current);
16
+ if (name !== null && COMPONENT_OR_HOOK.test(name))
17
+ return current;
18
+ }
19
+ return null;
20
+ };
21
+ const scopeContains = (scope, ancestor) => {
22
+ for (let current = scope; current !== null && current !== undefined; current = current.upper) {
23
+ if (current === ancestor)
24
+ return true;
25
+ }
26
+ return false;
27
+ };
28
+ const readsComponentScope = (scope, componentScope) => {
29
+ for (const reference of scope.through) {
30
+ if (scopeContains(reference.resolved?.scope, componentScope))
31
+ return true;
32
+ }
33
+ return false;
34
+ };
35
+ export const hoistStatelessFunction = problem("Require module scope for a non-component function that reads nothing from the component around it. Out there it is created once, keeps a stable identity, and can be tested without rendering.", {
36
+ createOnce(context) {
37
+ const check = (node) => {
38
+ const name = boundFunctionName(node);
39
+ if (name === null || COMPONENT_NAME.test(name))
40
+ return;
41
+ const component = enclosingReactFunction(node);
42
+ if (component === null)
43
+ return;
44
+ const scope = context.sourceCode.getScope(node);
45
+ const componentScope = context.sourceCode.getScope(component);
46
+ if (readsComponentScope(scope, componentScope))
47
+ return;
48
+ context.report({
49
+ node,
50
+ message: `Move \`${name}\` to module scope: it reads nothing from the component, so out there it is created once and keeps a stable identity without memoising. If it was meant to read a prop or a piece of state, wire that read up instead.`,
51
+ });
52
+ };
53
+ return {
54
+ FunctionDeclaration: check,
55
+ FunctionExpression: check,
56
+ ArrowFunctionExpression: check,
57
+ };
58
+ },
59
+ });
@@ -0,0 +1,2 @@
1
+ declare const _default: import("../../../index.js").ModuleManifest;
2
+ export default _default;
@@ -0,0 +1,50 @@
1
+ import { defineModule } from "../../../lib/module.js";
2
+ import { hoistStatelessFunction } from "./hoist-stateless-function.js";
3
+ import { noConditionalStyleArray } from "./no-conditional-style-array.js";
4
+ import { noDynamicImport } from "./no-dynamic-import.js";
5
+ import { noKeyboardWillEvents } from "./no-keyboard-will-events.js";
6
+ import { noLeakedRender } from "./no-leaked-render.js";
7
+ import { noManualMemo } from "./no-manual-memo.js";
8
+ import { noRedundantViewNesting } from "./no-redundant-view-nesting.js";
9
+ import { noRnImageNetworkSource } from "./no-rn-image-network-source.js";
10
+ import { noRnNamespaceImport } from "./no-rn-namespace-import.js";
11
+ import { noScrollPositionState } from "./no-scroll-position-state.js";
12
+ import { noUnlabeledIconPressable } from "./no-unlabeled-icon-pressable.js";
13
+ export default defineModule({
14
+ meta: { name: "@ashstack/react-native" },
15
+ url: import.meta.url,
16
+ docsWhen: "always on via `reactNative()`",
17
+ rules: {
18
+ "no-keyboard-will-events": noKeyboardWillEvents,
19
+ "no-scroll-position-state": noScrollPositionState,
20
+ "no-conditional-style-array": noConditionalStyleArray,
21
+ "no-leaked-render": noLeakedRender,
22
+ "no-rn-image-network-source": noRnImageNetworkSource,
23
+ "no-redundant-view-nesting": noRedundantViewNesting,
24
+ "no-rn-namespace-import": noRnNamespaceImport,
25
+ "no-unlabeled-icon-pressable": noUnlabeledIconPressable,
26
+ "hoist-stateless-function": hoistStatelessFunction,
27
+ "no-manual-memo": noManualMemo,
28
+ "no-dynamic-import": noDynamicImport,
29
+ },
30
+ restrictedImports: {
31
+ paths: [
32
+ {
33
+ name: "react",
34
+ importNames: ["forwardRef"],
35
+ message: "Accept `ref` as a regular prop and delete the `forwardRef` wrapper — React 19 passes `ref` like any other prop.",
36
+ },
37
+ {
38
+ name: "react",
39
+ importNames: ["useContext"],
40
+ message: "Call `use(Context)` instead of `useContext(Context)`; React 19 replaces the hook with it.",
41
+ },
42
+ ],
43
+ patterns: [
44
+ {
45
+ group: ["expo-linear-gradient", "react-native-linear-gradient"],
46
+ message: "Apply the gradient through the `experimental_backgroundImage` style property instead of a gradient component.",
47
+ },
48
+ ],
49
+ },
50
+ });
@@ -0,0 +1,2 @@
1
+ import type { Rule } from "../../../lib/types.js";
2
+ export declare const noConditionalStyleArray: Rule;
@@ -0,0 +1,23 @@
1
+ import { problem } from "../../../lib/ast.js";
2
+ const MESSAGE = "Move the condition into a Unistyles dynamic style function: `card: (active: boolean) => ({ ... })`, then `style={styles.card(active)}`. A conditional entry can evaluate to a falsy hole, which shifts the array indices and breaks the Unistyles C++ proxy.";
3
+ export const noConditionalStyleArray = problem("Disallow conditional and logical entries inside a JSX `style` array. A falsy entry leaves a hole that breaks the Unistyles C++ proxy.", {
4
+ createOnce(context) {
5
+ return {
6
+ JSXAttribute(node) {
7
+ if (node.name.type !== "JSXIdentifier" || node.name.name !== "style")
8
+ return;
9
+ const { value } = node;
10
+ if (value?.type !== "JSXExpressionContainer")
11
+ return;
12
+ const array = value.expression;
13
+ if (array.type !== "ArrayExpression")
14
+ return;
15
+ for (const element of array.elements) {
16
+ if (element?.type !== "ConditionalExpression" && element?.type !== "LogicalExpression")
17
+ continue;
18
+ context.report({ node: element, message: MESSAGE });
19
+ }
20
+ },
21
+ };
22
+ },
23
+ });
@@ -0,0 +1,2 @@
1
+ import type { Rule } from "../../../lib/types.js";
2
+ export declare const noDynamicImport: Rule;
@@ -0,0 +1,18 @@
1
+ import { calleeName, gate, hasAncestor, problem } from "../../../lib/ast.js";
2
+ const LAZY_WRAPPERS = new Set(["lazy", "dynamic"]);
3
+ const MESSAGE = "Turn this into a static `import` at the top of the file. Metro inlines `import()` into the same bundle, so it buys no laziness and only hides the module from the typechecker; wrapping it in `React.lazy(() => import(...))` or `dynamic(() => import(...))` stays allowed.";
4
+ export const noDynamicImport = problem("Disallow dynamic `import()` outside a `React.lazy` or `dynamic` wrapper. Metro inlines it into the same bundle, so nothing is deferred and the module is hidden from the typechecker.", {
5
+ createOnce(context) {
6
+ return {
7
+ before() {
8
+ return gate(context, "import(");
9
+ },
10
+ ImportExpression(node) {
11
+ if (hasAncestor(node, current => current.type === "CallExpression" && LAZY_WRAPPERS.has(calleeName(current)))) {
12
+ return;
13
+ }
14
+ context.report({ node, message: MESSAGE });
15
+ },
16
+ };
17
+ },
18
+ });
@@ -0,0 +1,2 @@
1
+ import type { Rule } from "../../../lib/types.js";
2
+ export declare const noKeyboardWillEvents: Rule;
@@ -0,0 +1,18 @@
1
+ import { gate, problem } from "../../../lib/ast.js";
2
+ const IOS_ONLY_EVENT_NAMES = new Set(["keyboardWillShow", "keyboardWillHide", "keyboardWillChangeFrame"]);
3
+ const MESSAGE = "Drive this from `rt.insets.ime`, which updates per frame on both platforms, or from a `react-native-keyboard-controller` component. `keyboardWill*` events are iOS-only, so on Android the listener registers and never fires.";
4
+ const isIosOnlyEventName = (node) => node.type === "Literal" && typeof node.value === "string" && IOS_ONLY_EVENT_NAMES.has(node.value);
5
+ export const noKeyboardWillEvents = problem("Disallow the `keyboardWill*` event names. They are iOS-only, so on Android the listener registers and never fires.", {
6
+ createOnce(context) {
7
+ return {
8
+ before() {
9
+ return gate(context, "keyboardWill");
10
+ },
11
+ Literal(node) {
12
+ if (!isIosOnlyEventName(node))
13
+ return;
14
+ context.report({ node, message: MESSAGE });
15
+ },
16
+ };
17
+ },
18
+ });
@@ -0,0 +1,2 @@
1
+ import type { Rule } from "../../../lib/types.js";
2
+ export declare const noLeakedRender: Rule;
@@ -0,0 +1,43 @@
1
+ import { problem } from "../../../lib/ast.js";
2
+ const MESSAGE = 'Compare explicitly (`list.length > 0 &&`), coerce with `!!`, or use a ternary ending in `null`. A falsy number or string leaks into the tree: `0` renders a bare zero, which crashes React Native with "Text strings must be rendered within a <Text> component".';
3
+ const COUNT_PROPERTIES = new Set(["length", "size"]);
4
+ const ARITHMETIC = new Set(["+", "-", "*", "/", "%", "**"]);
5
+ /**
6
+ * A guard this rule can see is a number or a string, rather than one it merely
7
+ * cannot prove is a boolean. `{isOpen && …}` stays quiet; `{count - 1 && …}`
8
+ * does not. Only the right side of an `&&` chain decides, since that is the
9
+ * value that reaches the tree.
10
+ */
11
+ const leaksAValue = (node) => {
12
+ if (node === undefined)
13
+ return false;
14
+ if (node.type === "MemberExpression") {
15
+ return node.property.type === "Identifier" && COUNT_PROPERTIES.has(node.property.name);
16
+ }
17
+ if (node.type === "Literal")
18
+ return typeof node.value === "number" || typeof node.value === "string";
19
+ if (node.type === "BinaryExpression")
20
+ return ARITHMETIC.has(node.operator);
21
+ if (node.type === "TemplateLiteral")
22
+ return true;
23
+ if (node.type === "LogicalExpression" && node.operator === "&&")
24
+ return leaksAValue(node.right);
25
+ return false;
26
+ };
27
+ export const noLeakedRender = problem('Disallow a `&&` guard in JSX on a value this rule can see is a number or a string — a `.length`, a `.size`, arithmetic, a literal. The falsy left operand leaks into the output, and a bare `0` crashes React Native with "Text strings must be rendered within a <Text> component".', {
28
+ createOnce(context) {
29
+ return {
30
+ JSXExpressionContainer(node) {
31
+ const parentType = node.parent.type;
32
+ if (parentType !== "JSXElement" && parentType !== "JSXFragment")
33
+ return;
34
+ const expression = node.expression;
35
+ if (expression.type !== "LogicalExpression" || expression.operator !== "&&")
36
+ return;
37
+ if (!leaksAValue(expression.left))
38
+ return;
39
+ context.report({ node: expression, message: MESSAGE });
40
+ },
41
+ };
42
+ },
43
+ });
@@ -0,0 +1,2 @@
1
+ import type { Rule } from "../../../lib/types.js";
2
+ export declare const noManualMemo: Rule;
@@ -0,0 +1,87 @@
1
+ import { gate, problem } from "../../../lib/ast.js";
2
+ const MEMO_APIS = new Set(["useMemo", "useCallback", "memo"]);
3
+ const REACT_COMPILER_PACKAGES = ["babel-plugin-react-compiler", "react-compiler-runtime", "react-compiler-marker"];
4
+ const WHY_COMMENT = /^why:/i;
5
+ const NEWLINE = 10;
6
+ const bareMemoName = (callee) => callee.type === "Identifier" && MEMO_APIS.has(callee.name) ? callee.name : null;
7
+ const reactNamespacedMemoName = (callee) => {
8
+ if (callee.type !== "MemberExpression" || callee.computed)
9
+ return null;
10
+ if (callee.object.type !== "Identifier" || callee.object.name !== "React")
11
+ return null;
12
+ const property = callee.property.name;
13
+ return MEMO_APIS.has(property) ? property : null;
14
+ };
15
+ const memoCalleeName = (node) => {
16
+ if (node.type !== "CallExpression")
17
+ return null;
18
+ const { callee } = node;
19
+ if (callee.type === "Identifier")
20
+ return bareMemoName(callee);
21
+ if (callee.type === "MemberExpression")
22
+ return reactNamespacedMemoName(callee);
23
+ return null;
24
+ };
25
+ const lineStartOffsets = (text) => {
26
+ const starts = [0];
27
+ for (let index = 0; index < text.length; index++) {
28
+ if (text.charCodeAt(index) === NEWLINE)
29
+ starts.push(index + 1);
30
+ }
31
+ return starts;
32
+ };
33
+ const lineContaining = (starts, offset) => {
34
+ let low = 0;
35
+ let high = starts.length - 1;
36
+ while (low < high) {
37
+ const middle = Math.floor((low + high + 1) / 2);
38
+ if ((starts[middle] ?? 0) <= offset)
39
+ low = middle;
40
+ else
41
+ high = middle - 1;
42
+ }
43
+ return low;
44
+ };
45
+ const linesCoveredByWhyComments = (comments, lineOf) => {
46
+ const covered = new Set();
47
+ for (const comment of comments) {
48
+ if (!WHY_COMMENT.test(comment.value.trim()))
49
+ continue;
50
+ for (let line = lineOf(comment.start); line <= lineOf(comment.end); line++)
51
+ covered.add(line);
52
+ }
53
+ return covered;
54
+ };
55
+ export const noManualMemo = problem("Disallow `useMemo`, `useCallback` and `memo` unless a `why:` comment on the line above states the case the React Compiler cannot see: something rendered per list row, or a cost that was measured.", {
56
+ meta: { packages: REACT_COMPILER_PACKAGES },
57
+ createOnce(context) {
58
+ let calls = [];
59
+ return {
60
+ before() {
61
+ calls = [];
62
+ return gate(context, "useMemo", "useCallback", "memo");
63
+ },
64
+ CallExpression(node) {
65
+ const name = memoCalleeName(node);
66
+ if (name !== null)
67
+ calls.push({ node, name });
68
+ },
69
+ "Program:exit"() {
70
+ if (calls.length === 0)
71
+ return;
72
+ const lineStarts = lineStartOffsets(context.sourceCode.getText());
73
+ const lineOf = (offset) => lineContaining(lineStarts, offset);
74
+ const justified = linesCoveredByWhyComments(context.sourceCode.getAllComments(), lineOf);
75
+ for (const { node, name } of calls) {
76
+ const line = lineOf(node.start);
77
+ if (justified.has(line) || justified.has(line - 1))
78
+ continue;
79
+ context.report({
80
+ node,
81
+ message: `Drop this \`${name}\` and let the React Compiler memoise it. Keep it only for what the compiler cannot see — something rendered per row in a list, or a computation you measured as heavy — and write a \`why:\` comment on the line above saying which of the two it is.`,
82
+ });
83
+ }
84
+ },
85
+ };
86
+ },
87
+ });
@@ -0,0 +1,2 @@
1
+ import type { Rule } from "../../../lib/types.js";
2
+ export declare const noRedundantViewNesting: Rule;
@@ -0,0 +1,36 @@
1
+ import { problem, tagPath } from "../../../lib/ast.js";
2
+ const LAYOUT_ONLY_PROPS = new Set(["style"]);
3
+ const MERGEABLE_WRAPPERS = new Set(["View", "Animated.View"]);
4
+ const elementChildren = (children) => children.filter(child => child.type !== "JSXText" || child.value.trim() !== "");
5
+ const onlyLayoutProps = (attributes) => attributes.every(attribute => attribute.type === "JSXAttribute" &&
6
+ attribute.name.type === "JSXIdentifier" &&
7
+ LAYOUT_ONLY_PROPS.has(attribute.name.name));
8
+ export const noRedundantViewNesting = problem("Disallow a View or Animated.View that wraps an identical view when neither carries anything but a style. Every extra host view is a real node in the native tree.", {
9
+ createOnce(context) {
10
+ return {
11
+ JSXElement(node) {
12
+ const opening = node.openingElement;
13
+ const tag = tagPath(opening.name);
14
+ if (!MERGEABLE_WRAPPERS.has(tag))
15
+ return;
16
+ if (!onlyLayoutProps(opening.attributes))
17
+ return;
18
+ const children = elementChildren(node.children);
19
+ if (children.length !== 1)
20
+ return;
21
+ const child = children[0];
22
+ if (child?.type !== "JSXElement")
23
+ return;
24
+ const childOpening = child.openingElement;
25
+ if (tagPath(childOpening.name) !== tag)
26
+ return;
27
+ if (!onlyLayoutProps(childOpening.attributes))
28
+ return;
29
+ context.report({
30
+ node: opening.name,
31
+ message: `Merge these two <${tag}> style objects into the inner one and delete the outer wrapper — each carries nothing but a style, and every extra host view is a real node in the native tree.`,
32
+ });
33
+ },
34
+ };
35
+ },
36
+ });
@@ -0,0 +1,2 @@
1
+ import type { Rule } from "../../../lib/types.js";
2
+ export declare const noRnImageNetworkSource: Rule;
@@ -0,0 +1,58 @@
1
+ import { componentName, gate, importedSpecifiers, problem } from "../../../lib/ast.js";
2
+ const MESSAGE = "Render this network image with `TurboImage` (react-native-turbo-image), passing `resize` and `cachePolicy`; keep react-native `<Image>` for local `require(...)` assets. RN `<Image>` has no disk cache and no decode sizing, so it re-downloads and decodes at full size on every cold start.";
3
+ /** A property key reads by name (`{ uri }`) or, for a string key, by value (`{ "uri" }`). */
4
+ const keyName = (key) => {
5
+ if (key.type === "Identifier")
6
+ return key.name;
7
+ return key.type === "Literal" && typeof key.value === "string" ? key.value : undefined;
8
+ };
9
+ const hasUriProperty = (attribute) => {
10
+ if (attribute.type !== "JSXAttribute")
11
+ return false;
12
+ const { value } = attribute;
13
+ const expression = value?.type === "JSXExpressionContainer" ? value.expression : undefined;
14
+ if (expression?.type !== "ObjectExpression")
15
+ return false;
16
+ return expression.properties.some(property => property.type === "Property" && keyName(property.key) === "uri");
17
+ };
18
+ export const noRnImageNetworkSource = problem("Disallow a react-native `<Image>` with a network `{ uri }` source. It has no disk cache and no decode sizing, so the image re-downloads at full size on every cold start.", {
19
+ createOnce(context) {
20
+ const imageBindings = new Set();
21
+ const candidates = [];
22
+ return {
23
+ before() {
24
+ imageBindings.clear();
25
+ candidates.length = 0;
26
+ return gate(context, "source");
27
+ },
28
+ ImportDeclaration(node) {
29
+ for (const specifier of importedSpecifiers(node, "react-native")) {
30
+ if (specifier.type !== "ImportSpecifier")
31
+ continue;
32
+ const { imported } = specifier;
33
+ if (imported.type === "Identifier" && imported.name === "Image") {
34
+ imageBindings.add(specifier.local.name);
35
+ }
36
+ }
37
+ },
38
+ JSXOpeningElement(node) {
39
+ const tag = componentName(node.name);
40
+ if (tag === "")
41
+ return;
42
+ const source = node.attributes.find(attribute => attribute.type === "JSXAttribute" &&
43
+ attribute.name.type === "JSXIdentifier" &&
44
+ attribute.name.name === "source");
45
+ if (!source || !hasUriProperty(source))
46
+ return;
47
+ candidates.push({ node: node.name, tag });
48
+ },
49
+ "Program:exit"() {
50
+ for (const candidate of candidates) {
51
+ if (!imageBindings.has(candidate.tag))
52
+ continue;
53
+ context.report({ node: candidate.node, message: MESSAGE });
54
+ }
55
+ },
56
+ };
57
+ },
58
+ });
@@ -0,0 +1,2 @@
1
+ import type { Rule } from "../../../lib/types.js";
2
+ export declare const noRnNamespaceImport: Rule;
@@ -0,0 +1,38 @@
1
+ import { gate, problem } from "../../../lib/ast.js";
2
+ const MESSAGES = {
3
+ namespaceImport: 'Import the react-native APIs by name instead: `import { View, Platform } from "react-native"`. A namespace import defeats Metro platform shaking, so dead `Platform.OS` branches ship in both bundles.',
4
+ platformReExport: "Drop this re-export and have each consumer import `Platform` straight from react-native; re-exporting it defeats Metro platform shaking.",
5
+ };
6
+ export const noRnNamespaceImport = problem("Disallow a namespace import of react-native and a re-export of its `Platform`. Both defeat Metro's platform shaking, so dead `Platform.OS` branches ship in both bundles.", {
7
+ createOnce(context) {
8
+ return {
9
+ before() {
10
+ return gate(context, "react-native");
11
+ },
12
+ ImportDeclaration(node) {
13
+ if (node.source.value !== "react-native")
14
+ return;
15
+ for (const specifier of node.specifiers) {
16
+ if (specifier.type !== "ImportNamespaceSpecifier")
17
+ continue;
18
+ context.report({ node: specifier, message: MESSAGES.namespaceImport });
19
+ }
20
+ },
21
+ ExportAllDeclaration(node) {
22
+ if (node.source.value !== "react-native")
23
+ return;
24
+ context.report({ node, message: MESSAGES.namespaceImport });
25
+ },
26
+ ExportNamedDeclaration(node) {
27
+ if (node.source?.value !== "react-native")
28
+ return;
29
+ for (const specifier of node.specifiers) {
30
+ const { local } = specifier;
31
+ if (local.type !== "Identifier" || local.name !== "Platform")
32
+ continue;
33
+ context.report({ node: specifier, message: MESSAGES.platformReExport });
34
+ }
35
+ },
36
+ };
37
+ },
38
+ });
@@ -0,0 +1,2 @@
1
+ import type { Rule } from "../../../lib/types.js";
2
+ export declare const noScrollPositionState: Rule;
@@ -0,0 +1,26 @@
1
+ import { attributeName, gate, problem, subtreeHas } from "../../../lib/ast.js";
2
+ const SCROLL_HANDLERS = new Set(["onScroll", "onScrollBeginDrag", "onScrollEndDrag", "onMomentumScrollEnd"]);
3
+ const STATE_SETTER = /^set[A-Z]/;
4
+ const MESSAGE = "Use `useAnimatedScrollHandler` with a shared value when this drives an animation, or a ref when nothing renders from it. Scroll fires every frame, so a state setter here re-renders the screen every frame.";
5
+ export const noScrollPositionState = problem("Disallow a React state setter inside a scroll handler prop. Scroll fires every frame, and so would the re-render.", {
6
+ createOnce(context) {
7
+ return {
8
+ before() {
9
+ return gate(context, ...SCROLL_HANDLERS);
10
+ },
11
+ JSXAttribute(node) {
12
+ if (!SCROLL_HANDLERS.has(attributeName(node)))
13
+ return;
14
+ const { value } = node;
15
+ const expression = value?.type === "JSXExpressionContainer" ? value.expression : undefined;
16
+ if (expression?.type !== "ArrowFunctionExpression" && expression?.type !== "FunctionExpression")
17
+ return;
18
+ const setsState = subtreeHas(expression.body, current => current.type === "CallExpression" &&
19
+ current.callee.type === "Identifier" &&
20
+ STATE_SETTER.test(current.callee.name));
21
+ if (setsState)
22
+ context.report({ node, message: MESSAGE });
23
+ },
24
+ };
25
+ },
26
+ });
@@ -0,0 +1,2 @@
1
+ import type { Rule } from "../../../lib/types.js";
2
+ export declare const noUnlabeledIconPressable: Rule;