@ashstack/lint 0.1.1 → 0.3.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 (104) hide show
  1. package/README.md +1 -1
  2. package/dist/core/index.js +1 -222
  3. package/dist/core/rules/core/components-tsx-only.js +1 -37
  4. package/dist/core/rules/core/hoist-intl.js +1 -32
  5. package/dist/core/rules/core/index.js +1 -18
  6. package/dist/core/rules/core/no-comments.js +1 -194
  7. package/dist/core/rules/core/no-naming-convention.js +1 -143
  8. package/dist/core/rules/core/no-packed-condition.d.ts +2 -0
  9. package/dist/core/rules/core/no-packed-condition.js +1 -0
  10. package/dist/core/rules/core/use-design-system.js +1 -147
  11. package/dist/core/rules/zod/index.js +1 -12
  12. package/dist/core/rules/zod/prefer-enum.js +1 -44
  13. package/dist/index.js +1 -3
  14. package/dist/lib/ast.js +1 -162
  15. package/dist/lib/detect.js +1 -42
  16. package/dist/lib/merge.js +1 -18
  17. package/dist/lib/module.js +1 -49
  18. package/dist/lib/types.js +1 -1
  19. package/dist/react/index.js +1 -126
  20. package/dist/react/rules/i18n/index.js +1 -16
  21. package/dist/react/rules/i18n/no-bare-attrs.js +1 -38
  22. package/dist/react/rules/i18n/no-bare-text.js +1 -26
  23. package/dist/react/rules/i18n/no-bare-toast.js +1 -38
  24. package/dist/react/rules/query/index.js +1 -20
  25. package/dist/react/rules/query/next-page-param-undefined.js +1 -26
  26. package/dist/react/rules/query/no-deprecated-filters.js +1 -63
  27. package/dist/react/rules/query/no-fetch-in-query-fn.js +1 -22
  28. package/dist/react/rules/query/no-inline-keys.js +1 -107
  29. package/dist/react/rules/query/require-destructured-hooks.js +1 -41
  30. package/dist/react/rules/query/shared.js +1 -10
  31. package/dist/react/rules/zustand/index.js +1 -12
  32. package/dist/react/rules/zustand/require-selector.js +1 -62
  33. package/dist/react-native/index.js +1 -113
  34. package/dist/react-native/rules/keyboard/index.js +1 -18
  35. package/dist/react-native/rules/legend-list/index.js +1 -51
  36. package/dist/react-native/rules/legend-list/no-flex-in-content-container.js +1 -77
  37. package/dist/react-native/rules/legend-list/no-index-key-extractor.js +1 -26
  38. package/dist/react-native/rules/legend-list/no-inline-data.js +1 -25
  39. package/dist/react-native/rules/legend-list/no-inline-extra-data.js +1 -21
  40. package/dist/react-native/rules/legend-list/no-inline-render-item-props.js +1 -35
  41. package/dist/react-native/rules/legend-list/no-mixed-children.js +1 -24
  42. package/dist/react-native/rules/legend-list/no-remount-key.js +1 -18
  43. package/dist/react-native/rules/legend-list/no-scrollview-map.js +1 -27
  44. package/dist/react-native/rules/legend-list/no-unsupported-props.js +1 -29
  45. package/dist/react-native/rules/legend-list/required-props.js +1 -23
  46. package/dist/react-native/rules/legend-list/shared.js +1 -25
  47. package/dist/react-native/rules/legend-list/typed-items-need-item-type.js +1 -49
  48. package/dist/react-native/rules/legend-state/index.js +1 -35
  49. package/dist/react-native/rules/legend-state/naming.js +1 -26
  50. package/dist/react-native/rules/legend-state/no-assignment.js +1 -31
  51. package/dist/react-native/rules/legend-state/no-nested-observable.js +1 -23
  52. package/dist/react-native/rules/legend-state/no-object-selector.js +1 -47
  53. package/dist/react-native/rules/legend-state/no-observable-in-component.js +1 -46
  54. package/dist/react-native/rules/legend-state/no-peek-in-selector.js +1 -67
  55. package/dist/react-native/rules/legend-state/no-react-mirror.js +1 -33
  56. package/dist/react-native/rules/legend-state/no-untracked-get-in-jsx.js +1 -52
  57. package/dist/react-native/rules/legend-state/shared.js +1 -23
  58. package/dist/react-native/rules/react-native/hoist-stateless-function.js +1 -59
  59. package/dist/react-native/rules/react-native/index.js +1 -50
  60. package/dist/react-native/rules/react-native/no-conditional-style-array.js +1 -23
  61. package/dist/react-native/rules/react-native/no-dynamic-import.js +1 -18
  62. package/dist/react-native/rules/react-native/no-keyboard-will-events.js +1 -18
  63. package/dist/react-native/rules/react-native/no-leaked-render.js +1 -43
  64. package/dist/react-native/rules/react-native/no-manual-memo.js +1 -87
  65. package/dist/react-native/rules/react-native/no-redundant-view-nesting.js +1 -36
  66. package/dist/react-native/rules/react-native/no-rn-image-network-source.js +1 -58
  67. package/dist/react-native/rules/react-native/no-rn-namespace-import.js +1 -38
  68. package/dist/react-native/rules/react-native/no-scroll-position-state.js +1 -26
  69. package/dist/react-native/rules/react-native/no-unlabeled-icon-pressable.js +1 -62
  70. package/dist/react-native/rules/reanimated/animated-reaction-safety.js +1 -110
  71. package/dist/react-native/rules/reanimated/animated-style-needs-animated-component.js +1 -72
  72. package/dist/react-native/rules/reanimated/animated-updater-purity.js +1 -38
  73. package/dist/react-native/rules/reanimated/gpu-properties-only.js +1 -80
  74. package/dist/react-native/rules/reanimated/hoist-layout-animation-builder.js +1 -43
  75. package/dist/react-native/rules/reanimated/index.js +1 -51
  76. package/dist/react-native/rules/reanimated/interpolate-needs-clamp.js +1 -41
  77. package/dist/react-native/rules/reanimated/no-react-state-from-continuous-worklet.js +1 -86
  78. package/dist/react-native/rules/reanimated/no-shared-value-dot-value.js +1 -112
  79. package/dist/react-native/rules/reanimated/prefer-lazy-shared-value-initializer.js +1 -24
  80. package/dist/react-native/rules/reanimated/schedule-on-rn-scope.js +1 -21
  81. package/dist/react-native/rules/reanimated/shared-value-usage.js +1 -105
  82. package/dist/react-native/rules/reanimated/shared.js +1 -13
  83. package/dist/react-native/rules/skia/canvas-opaque.js +1 -49
  84. package/dist/react-native/rules/skia/index.js +1 -21
  85. package/dist/react-native/rules/turbo-image/index.js +1 -14
  86. package/dist/react-native/rules/turbo-image/require-cache-policy.js +1 -21
  87. package/dist/react-native/rules/turbo-image/require-resize.js +1 -22
  88. package/dist/react-native/rules/unistyles/animated-theme.js +1 -37
  89. package/dist/react-native/rules/unistyles/content-container.js +1 -73
  90. package/dist/react-native/rules/unistyles/in-sheet.js +1 -116
  91. package/dist/react-native/rules/unistyles/index.js +1 -58
  92. package/dist/react-native/rules/unistyles/insets.js +1 -92
  93. package/dist/react-native/rules/unistyles/no-hardcoded-color.js +1 -30
  94. package/dist/react-native/rules/unistyles/no-hardcoded-spacing.js +1 -78
  95. package/dist/react-native/rules/unistyles/no-margin.js +1 -19
  96. package/dist/react-native/rules/unistyles/no-style-spread.js +1 -41
  97. package/dist/react-native/rules/unistyles/no-unused-styles.js +1 -99
  98. package/dist/react-native/rules/unistyles/rtl-style-call.js +1 -42
  99. package/dist/react-native/rules/unistyles/shared.js +1 -70
  100. package/dist/react-native/rules/unistyles/theme-screen-component.js +1 -22
  101. package/dist/react-native/rules/unistyles/theme-style-attr.js +1 -18
  102. package/dist/react-native/stylesheet.js +1 -44
  103. package/package.json +3 -4
  104. package/RULES.md +0 -2881
@@ -1,23 +1 @@
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
- });
1
+ import{problem as r}from"../../../lib/ast.js";const o="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.";export const noConditionalStyleArray=r("Disallow conditional and logical entries inside a JSX `style` array. A falsy entry leaves a hole that breaks the Unistyles C++ proxy.",{createOnce(i){return{JSXAttribute(e){if(e.name.type!=="JSXIdentifier"||e.name.name!=="style")return;const{value:n}=e;if(n?.type!=="JSXExpressionContainer")return;const a=n.expression;if(a.type!=="ArrayExpression")return;for(const t of a.elements){if(t?.type!=="ConditionalExpression"&&t?.type!=="LogicalExpression")continue;i.report({node:t,message:o})}}}}});
@@ -1,18 +1 @@
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
- });
1
+ import{calleeName as i,gate as r,hasAncestor as n,problem as a}from"../../../lib/ast.js";const s=new Set(["lazy","dynamic"]),m="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.";export const noDynamicImport=a("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.",{createOnce(e){return{before(){return r(e,"import(")},ImportExpression(t){if(n(t,(o)=>o.type==="CallExpression"&&s.has(i(o))))return;e.report({node:t,message:m})}}}});
@@ -1,18 +1 @@
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
- });
1
+ import{gate as o,problem as t}from"../../../lib/ast.js";const n=new Set(["keyboardWillShow","keyboardWillHide","keyboardWillChangeFrame"]),a="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.",i=(e)=>e.type==="Literal"&&typeof e.value==="string"&&n.has(e.value);export const noKeyboardWillEvents=t("Disallow the `keyboardWill*` event names. They are iOS-only, so on Android the listener registers and never fires.",{createOnce(e){return{before(){return o(e,"keyboardWill")},Literal(r){if(!i(r))return;e.report({node:r,message:a})}}}});
@@ -1,43 +1 @@
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
- });
1
+ import{problem as i}from"../../../lib/ast.js";const s='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".',p=new Set(["length","size"]),o=new Set(["+","-","*","/","%","**"]),a=(e)=>{if(e===void 0)return!1;if(e.type==="MemberExpression")return e.property.type==="Identifier"&&p.has(e.property.name);if(e.type==="Literal")return typeof e.value==="number"||typeof e.value==="string";if(e.type==="BinaryExpression")return o.has(e.operator);if(e.type==="TemplateLiteral")return!0;if(e.type==="LogicalExpression"&&e.operator==="&&")return a(e.right);return!1};export const noLeakedRender=i('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".',{createOnce(e){return{JSXExpressionContainer(r){const n=r.parent.type;if(n!=="JSXElement"&&n!=="JSXFragment")return;const t=r.expression;if(t.type!=="LogicalExpression"||t.operator!=="&&")return;if(!a(t.left))return;e.report({node:t,message:s})}}}});
@@ -1,87 +1 @@
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
- });
1
+ import{gate as l,problem as c}from"../../../lib/ast.js";const i=new Set(["useMemo","useCallback","memo"]),u=["babel-plugin-react-compiler","react-compiler-runtime","react-compiler-marker"],p=/^why:/i,h=10,f=(e)=>e.type==="Identifier"&&i.has(e.name)?e.name:null,d=(e)=>{if(e.type!=="MemberExpression"||e.computed)return null;if(e.object.type!=="Identifier"||e.object.name!=="React")return null;const t=e.property.name;return i.has(t)?t:null},C=(e)=>{if(e.type!=="CallExpression")return null;const{callee:t}=e;if(t.type==="Identifier")return f(t);if(t.type==="MemberExpression")return d(t);return null},y=(e)=>{const t=[0];for(let n=0;n<e.length;n++)if(e.charCodeAt(n)===h)t.push(n+1);return t},g=(e,t)=>{let n=0,o=e.length-1;while(n<o){const r=Math.floor((n+o+1)/2);if((e[r]??0)<=t)n=r;else o=r-1}return n},w=(e,t)=>{const n=new Set;for(const o of e){if(!p.test(o.value.trim()))continue;for(let r=t(o.start);r<=t(o.end);r++)n.add(r)}return n};export const noManualMemo=c("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.",{meta:{packages:u},createOnce(e){let t=[];return{before(){t=[];return l(e,"useMemo","useCallback","memo")},CallExpression(n){const o=C(n);if(o!==null)t.push({node:n,name:o})},"Program:exit"(){if(t.length===0)return;const n=y(e.sourceCode.getText()),o=(s)=>g(n,s),r=w(e.sourceCode.getAllComments(),o);for(const{node:s,name:m}of t){const a=o(s.start);if(r.has(a)||r.has(a-1))continue;e.report({node:s,message:`Drop this \`${m}\` 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.`})}}}}});
@@ -1,36 +1 @@
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
- });
1
+ import{problem as h,tagPath as s}from"../../../lib/ast.js";const c=new Set(["style"]),m=new Set(["View","Animated.View"]),p=(t)=>t.filter((e)=>e.type!=="JSXText"||e.value.trim()!==""),l=(t)=>t.every((e)=>e.type==="JSXAttribute"&&e.name.type==="JSXIdentifier"&&c.has(e.name.name));export const noRedundantViewNesting=h("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.",{createOnce(t){return{JSXElement(e){const n=e.openingElement,r=s(n.name);if(!m.has(r))return;if(!l(n.attributes))return;const i=p(e.children);if(i.length!==1)return;const a=i[0];if(a?.type!=="JSXElement")return;const o=a.openingElement;if(s(o.name)!==r)return;if(!l(o.attributes))return;t.report({node:n.name,message:`Merge these two <${r}> 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.`})}}}});
@@ -1,58 +1 @@
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
- });
1
+ import{componentName as s,gate as c,importedSpecifiers as d,problem as p}from"../../../lib/ast.js";const m="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.",f=(e)=>{if(e.type==="Identifier")return e.name;return e.type==="Literal"&&typeof e.value==="string"?e.value:void 0},u=(e)=>{if(e.type!=="JSXAttribute")return!1;const{value:t}=e,r=t?.type==="JSXExpressionContainer"?t.expression:void 0;if(r?.type!=="ObjectExpression")return!1;return r.properties.some((n)=>n.type==="Property"&&f(n.key)==="uri")};export const noRnImageNetworkSource=p("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.",{createOnce(e){const t=new Set,r=[];return{before(){t.clear();r.length=0;return c(e,"source")},ImportDeclaration(n){for(const o of d(n,"react-native")){if(o.type!=="ImportSpecifier")continue;const{imported:a}=o;if(a.type==="Identifier"&&a.name==="Image")t.add(o.local.name)}},JSXOpeningElement(n){const o=s(n.name);if(o==="")return;const a=n.attributes.find((i)=>i.type==="JSXAttribute"&&i.name.type==="JSXIdentifier"&&i.name.name==="source");if(!a||!u(a))return;r.push({node:n.name,tag:o})},"Program:exit"(){for(const n of r){if(!t.has(n.tag))continue;e.report({node:n.node,message:m})}}}}});
@@ -1,38 +1 @@
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
- });
1
+ import{gate as i,problem as s}from"../../../lib/ast.js";const a={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.',platformReExport:"Drop this re-export and have each consumer import `Platform` straight from react-native; re-exporting it defeats Metro platform shaking."};export const noRnNamespaceImport=s("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.",{createOnce(t){return{before(){return i(t,"react-native")},ImportDeclaration(e){if(e.source.value!=="react-native")return;for(const r of e.specifiers){if(r.type!=="ImportNamespaceSpecifier")continue;t.report({node:r,message:a.namespaceImport})}},ExportAllDeclaration(e){if(e.source.value!=="react-native")return;t.report({node:e,message:a.namespaceImport})},ExportNamedDeclaration(e){if(e.source?.value!=="react-native")return;for(const r of e.specifiers){const{local:o}=r;if(o.type!=="Identifier"||o.name!=="Platform")continue;t.report({node:r,message:a.platformReExport})}}}}});
@@ -1,26 +1 @@
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
- });
1
+ import{attributeName as a,gate as i,problem as l,subtreeHas as c}from"../../../lib/ast.js";const o=new Set(["onScroll","onScrollBeginDrag","onScrollEndDrag","onMomentumScrollEnd"]),S=/^set[A-Z]/,p="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.";export const noScrollPositionState=l("Disallow a React state setter inside a scroll handler prop. Scroll fires every frame, and so would the re-render.",{createOnce(n){return{before(){return i(n,...o)},JSXAttribute(e){if(!o.has(a(e)))return;const{value:s}=e,r=s?.type==="JSXExpressionContainer"?s.expression:void 0;if(r?.type!=="ArrowFunctionExpression"&&r?.type!=="FunctionExpression")return;if(c(r.body,(t)=>t.type==="CallExpression"&&t.callee.type==="Identifier"&&S.test(t.callee.name)))n.report({node:e,message:p})}}}});
@@ -1,62 +1 @@
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
- });
1
+ import{componentName as a,problem as l,tagIdentifier as h}from"../../../lib/ast.js";const m=new Set(["Pressable","PressableScale","TouchableOpacity","TouchableHighlight"]),i={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.",expoButton:"Add a `label`, or an `accessibilityLabel(...)` modifier, so this icon-only Expo UI `<Button>` has an accessible name."},o=(t)=>t.type==="JSXAttribute"&&t.name.type==="JSXIdentifier"?t.name.name:void 0,u=(t)=>t.some((n)=>{const e=o(n);return e==="accessibilityLabel"||e==="accessibilityHint"}),b=(t)=>{const n={hasIcon:!1,hasText:!1,hasExpression:!1};for(const e of t){if(e.type==="JSXExpressionContainer")n.hasExpression=!0;if(e.type==="JSXText"&&e.value.trim()!=="")n.hasText=!0;if(e.type!=="JSXElement")continue;const s=h(e.openingElement.name);if(s==="Text")n.hasText=!0;if(s.endsWith("Icon"))n.hasIcon=!0}return n};export const noUnlabeledIconPressable=l("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.",{createOnce(t){return{JSXElement(n){const e=n.openingElement;if(!m.has(a(e.name)))return;if(u(e.attributes))return;const{hasIcon:s,hasText:c,hasExpression:r}=b(n.children);if(!s||c||r)return;t.report({node:e.name,message:i.touchable})},JSXOpeningElement(n){if(!n.selfClosing)return;if(a(n.name)!=="Button")return;const e=new Set(n.attributes.map(o).filter(Boolean));if(!e.has("systemImage"))return;if(e.has("label")||e.has("accessibilityLabel")||e.has("accessibilityHint")||e.has("modifiers"))return;t.report({node:n.name,message:i.expoButton})}}}});
@@ -1,110 +1 @@
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
- });
1
+ import{calleeName as i,gate as c,hasAncestor as f,isMemberCall as m,isWithin as h,problem as d,receiverName as l,subtreeHas as g}from"../../../lib/ast.js";const u={hotBridgeUnguarded:"Guard this `scheduleOnRN` by comparing the current and previous prepared results, so high-frequency input does not bridge to RN every frame.",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."},p=new Set(["useAnimatedReaction"]),y=new Set(["!==","!="]),o=(e)=>e?.type==="Identifier"?e.name:null,b=(e,t,r)=>{if(e.type!=="BinaryExpression"||!y.has(e.operator))return!1;const s=o(e.left),a=o(e.right);return s===t&&a===r||s===r&&a===t},E=(e,t,r)=>{if(e.type!=="UnaryExpression"||e.operator!=="!")return!1;const{argument:s}=e;if(s.type!=="CallExpression"||i(s)!=="is")return!1;const a=s.arguments;return a.some((n)=>o(n)===t)&&a.some((n)=>o(n)===r)},N=(e,t,r)=>{if(!t||!r)return!1;return f(e,(s)=>{if(s.type!=="IfStatement")return!1;const{test:a}=s;return b(a,t,r)||E(a,t,r)})},v=(e,t)=>{if(e.type!=="CallExpression"||e.callee.type!=="MemberExpression")return!1;const r=l(e);if(!r)return!1;if(t.type!=="CallExpression")return!1;const[s,a]=t.arguments;if(!s||!a||!h(e,a))return!1;return g(s,(n)=>m(n,"get")&&l(n)===r)},R=(e)=>{if(e.type!=="CallExpression")return null;const t=e.arguments[1];if(t?.type!=="FunctionDeclaration"&&t?.type!=="FunctionExpression"&&t?.type!=="ArrowFunctionExpression")return null;return t.params},O=(e,t)=>{const r=R(t);if(!r)return null;const s=o(r[1]);if(!s)return u.hotBridgeNoPrevious;return N(e,o(r[0]),s)?null:u.hotBridgeUnguarded};export const animatedReactionSafety=d("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.",{createOnce(e){let t=[];return{before(){t=[];return c(e,"useAnimatedReaction")},CallExpression(r){const s=i(r);if(p.has(s)){t.push(r);return}const a=t.at(-1);if(!a)return;if(s==="set"||s==="modify"){if(!v(r,a))return;e.report({node:r,message:`Have this result callback ${s==="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.`});return}if(s!=="scheduleOnRN")return;const n=O(r,a);if(n!==null)e.report({node:r,message:n})},"CallExpression:exit"(r){if(p.has(i(r)))t.pop()}}}});