@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.
- package/LICENSE +21 -0
- package/README.md +60 -0
- package/RULES.md +2881 -0
- package/dist/core/index.d.ts +15 -0
- package/dist/core/index.js +222 -0
- package/dist/core/rules/core/components-tsx-only.d.ts +2 -0
- package/dist/core/rules/core/components-tsx-only.js +37 -0
- package/dist/core/rules/core/hoist-intl.d.ts +2 -0
- package/dist/core/rules/core/hoist-intl.js +32 -0
- package/dist/core/rules/core/index.d.ts +2 -0
- package/dist/core/rules/core/index.js +18 -0
- package/dist/core/rules/core/no-comments.d.ts +2 -0
- package/dist/core/rules/core/no-comments.js +194 -0
- package/dist/core/rules/core/no-naming-convention.d.ts +2 -0
- package/dist/core/rules/core/no-naming-convention.js +143 -0
- package/dist/core/rules/core/use-design-system.d.ts +2 -0
- package/dist/core/rules/core/use-design-system.js +147 -0
- package/dist/core/rules/zod/index.d.ts +2 -0
- package/dist/core/rules/zod/index.js +12 -0
- package/dist/core/rules/zod/prefer-enum.d.ts +2 -0
- package/dist/core/rules/zod/prefer-enum.js +44 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +3 -0
- package/dist/lib/ast.d.ts +44 -0
- package/dist/lib/ast.js +162 -0
- package/dist/lib/detect.d.ts +9 -0
- package/dist/lib/detect.js +42 -0
- package/dist/lib/merge.d.ts +9 -0
- package/dist/lib/merge.js +18 -0
- package/dist/lib/module.d.ts +19 -0
- package/dist/lib/module.js +49 -0
- package/dist/lib/types.d.ts +114 -0
- package/dist/lib/types.js +1 -0
- package/dist/react/index.d.ts +9 -0
- package/dist/react/index.js +126 -0
- package/dist/react/rules/i18n/index.d.ts +2 -0
- package/dist/react/rules/i18n/index.js +16 -0
- package/dist/react/rules/i18n/no-bare-attrs.d.ts +2 -0
- package/dist/react/rules/i18n/no-bare-attrs.js +38 -0
- package/dist/react/rules/i18n/no-bare-text.d.ts +2 -0
- package/dist/react/rules/i18n/no-bare-text.js +26 -0
- package/dist/react/rules/i18n/no-bare-toast.d.ts +2 -0
- package/dist/react/rules/i18n/no-bare-toast.js +38 -0
- package/dist/react/rules/query/index.d.ts +2 -0
- package/dist/react/rules/query/index.js +20 -0
- package/dist/react/rules/query/next-page-param-undefined.d.ts +2 -0
- package/dist/react/rules/query/next-page-param-undefined.js +26 -0
- package/dist/react/rules/query/no-deprecated-filters.d.ts +2 -0
- package/dist/react/rules/query/no-deprecated-filters.js +63 -0
- package/dist/react/rules/query/no-fetch-in-query-fn.d.ts +2 -0
- package/dist/react/rules/query/no-fetch-in-query-fn.js +22 -0
- package/dist/react/rules/query/no-inline-keys.d.ts +2 -0
- package/dist/react/rules/query/no-inline-keys.js +107 -0
- package/dist/react/rules/query/require-destructured-hooks.d.ts +2 -0
- package/dist/react/rules/query/require-destructured-hooks.js +41 -0
- package/dist/react/rules/query/shared.d.ts +2 -0
- package/dist/react/rules/query/shared.js +10 -0
- package/dist/react/rules/zustand/index.d.ts +2 -0
- package/dist/react/rules/zustand/index.js +12 -0
- package/dist/react/rules/zustand/require-selector.d.ts +2 -0
- package/dist/react/rules/zustand/require-selector.js +62 -0
- package/dist/react-native/index.d.ts +11 -0
- package/dist/react-native/index.js +113 -0
- package/dist/react-native/rules/keyboard/index.d.ts +2 -0
- package/dist/react-native/rules/keyboard/index.js +18 -0
- package/dist/react-native/rules/legend-list/index.d.ts +2 -0
- package/dist/react-native/rules/legend-list/index.js +51 -0
- package/dist/react-native/rules/legend-list/no-flex-in-content-container.d.ts +2 -0
- package/dist/react-native/rules/legend-list/no-flex-in-content-container.js +77 -0
- package/dist/react-native/rules/legend-list/no-index-key-extractor.d.ts +2 -0
- package/dist/react-native/rules/legend-list/no-index-key-extractor.js +26 -0
- package/dist/react-native/rules/legend-list/no-inline-data.d.ts +2 -0
- package/dist/react-native/rules/legend-list/no-inline-data.js +25 -0
- package/dist/react-native/rules/legend-list/no-inline-extra-data.d.ts +2 -0
- package/dist/react-native/rules/legend-list/no-inline-extra-data.js +21 -0
- package/dist/react-native/rules/legend-list/no-inline-render-item-props.d.ts +2 -0
- package/dist/react-native/rules/legend-list/no-inline-render-item-props.js +35 -0
- package/dist/react-native/rules/legend-list/no-mixed-children.d.ts +2 -0
- package/dist/react-native/rules/legend-list/no-mixed-children.js +24 -0
- package/dist/react-native/rules/legend-list/no-remount-key.d.ts +2 -0
- package/dist/react-native/rules/legend-list/no-remount-key.js +18 -0
- package/dist/react-native/rules/legend-list/no-scrollview-map.d.ts +2 -0
- package/dist/react-native/rules/legend-list/no-scrollview-map.js +27 -0
- package/dist/react-native/rules/legend-list/no-unsupported-props.d.ts +2 -0
- package/dist/react-native/rules/legend-list/no-unsupported-props.js +29 -0
- package/dist/react-native/rules/legend-list/required-props.d.ts +2 -0
- package/dist/react-native/rules/legend-list/required-props.js +23 -0
- package/dist/react-native/rules/legend-list/shared.d.ts +19 -0
- package/dist/react-native/rules/legend-list/shared.js +25 -0
- package/dist/react-native/rules/legend-list/typed-items-need-item-type.d.ts +2 -0
- package/dist/react-native/rules/legend-list/typed-items-need-item-type.js +49 -0
- package/dist/react-native/rules/legend-state/index.d.ts +2 -0
- package/dist/react-native/rules/legend-state/index.js +35 -0
- package/dist/react-native/rules/legend-state/naming.d.ts +2 -0
- package/dist/react-native/rules/legend-state/naming.js +26 -0
- package/dist/react-native/rules/legend-state/no-assignment.d.ts +2 -0
- package/dist/react-native/rules/legend-state/no-assignment.js +31 -0
- package/dist/react-native/rules/legend-state/no-nested-observable.d.ts +2 -0
- package/dist/react-native/rules/legend-state/no-nested-observable.js +23 -0
- package/dist/react-native/rules/legend-state/no-object-selector.d.ts +2 -0
- package/dist/react-native/rules/legend-state/no-object-selector.js +47 -0
- package/dist/react-native/rules/legend-state/no-observable-in-component.d.ts +2 -0
- package/dist/react-native/rules/legend-state/no-observable-in-component.js +46 -0
- package/dist/react-native/rules/legend-state/no-peek-in-selector.d.ts +2 -0
- package/dist/react-native/rules/legend-state/no-peek-in-selector.js +67 -0
- package/dist/react-native/rules/legend-state/no-react-mirror.d.ts +2 -0
- package/dist/react-native/rules/legend-state/no-react-mirror.js +33 -0
- package/dist/react-native/rules/legend-state/no-untracked-get-in-jsx.d.ts +2 -0
- package/dist/react-native/rules/legend-state/no-untracked-get-in-jsx.js +52 -0
- package/dist/react-native/rules/legend-state/shared.d.ts +9 -0
- package/dist/react-native/rules/legend-state/shared.js +23 -0
- package/dist/react-native/rules/react-native/hoist-stateless-function.d.ts +2 -0
- package/dist/react-native/rules/react-native/hoist-stateless-function.js +59 -0
- package/dist/react-native/rules/react-native/index.d.ts +2 -0
- package/dist/react-native/rules/react-native/index.js +50 -0
- package/dist/react-native/rules/react-native/no-conditional-style-array.d.ts +2 -0
- package/dist/react-native/rules/react-native/no-conditional-style-array.js +23 -0
- package/dist/react-native/rules/react-native/no-dynamic-import.d.ts +2 -0
- package/dist/react-native/rules/react-native/no-dynamic-import.js +18 -0
- package/dist/react-native/rules/react-native/no-keyboard-will-events.d.ts +2 -0
- package/dist/react-native/rules/react-native/no-keyboard-will-events.js +18 -0
- package/dist/react-native/rules/react-native/no-leaked-render.d.ts +2 -0
- package/dist/react-native/rules/react-native/no-leaked-render.js +43 -0
- package/dist/react-native/rules/react-native/no-manual-memo.d.ts +2 -0
- package/dist/react-native/rules/react-native/no-manual-memo.js +87 -0
- package/dist/react-native/rules/react-native/no-redundant-view-nesting.d.ts +2 -0
- package/dist/react-native/rules/react-native/no-redundant-view-nesting.js +36 -0
- package/dist/react-native/rules/react-native/no-rn-image-network-source.d.ts +2 -0
- package/dist/react-native/rules/react-native/no-rn-image-network-source.js +58 -0
- package/dist/react-native/rules/react-native/no-rn-namespace-import.d.ts +2 -0
- package/dist/react-native/rules/react-native/no-rn-namespace-import.js +38 -0
- package/dist/react-native/rules/react-native/no-scroll-position-state.d.ts +2 -0
- package/dist/react-native/rules/react-native/no-scroll-position-state.js +26 -0
- package/dist/react-native/rules/react-native/no-unlabeled-icon-pressable.d.ts +2 -0
- package/dist/react-native/rules/react-native/no-unlabeled-icon-pressable.js +62 -0
- package/dist/react-native/rules/reanimated/animated-reaction-safety.d.ts +2 -0
- package/dist/react-native/rules/reanimated/animated-reaction-safety.js +110 -0
- package/dist/react-native/rules/reanimated/animated-style-needs-animated-component.d.ts +2 -0
- package/dist/react-native/rules/reanimated/animated-style-needs-animated-component.js +72 -0
- package/dist/react-native/rules/reanimated/animated-updater-purity.d.ts +2 -0
- package/dist/react-native/rules/reanimated/animated-updater-purity.js +38 -0
- package/dist/react-native/rules/reanimated/gpu-properties-only.d.ts +2 -0
- package/dist/react-native/rules/reanimated/gpu-properties-only.js +80 -0
- package/dist/react-native/rules/reanimated/hoist-layout-animation-builder.d.ts +2 -0
- package/dist/react-native/rules/reanimated/hoist-layout-animation-builder.js +43 -0
- package/dist/react-native/rules/reanimated/index.d.ts +2 -0
- package/dist/react-native/rules/reanimated/index.js +51 -0
- package/dist/react-native/rules/reanimated/interpolate-needs-clamp.d.ts +2 -0
- package/dist/react-native/rules/reanimated/interpolate-needs-clamp.js +41 -0
- package/dist/react-native/rules/reanimated/no-react-state-from-continuous-worklet.d.ts +2 -0
- package/dist/react-native/rules/reanimated/no-react-state-from-continuous-worklet.js +86 -0
- package/dist/react-native/rules/reanimated/no-shared-value-dot-value.d.ts +2 -0
- package/dist/react-native/rules/reanimated/no-shared-value-dot-value.js +112 -0
- package/dist/react-native/rules/reanimated/prefer-lazy-shared-value-initializer.d.ts +2 -0
- package/dist/react-native/rules/reanimated/prefer-lazy-shared-value-initializer.js +24 -0
- package/dist/react-native/rules/reanimated/schedule-on-rn-scope.d.ts +2 -0
- package/dist/react-native/rules/reanimated/schedule-on-rn-scope.js +21 -0
- package/dist/react-native/rules/reanimated/shared-value-usage.d.ts +2 -0
- package/dist/react-native/rules/reanimated/shared-value-usage.js +105 -0
- package/dist/react-native/rules/reanimated/shared.d.ts +5 -0
- package/dist/react-native/rules/reanimated/shared.js +13 -0
- package/dist/react-native/rules/skia/canvas-opaque.d.ts +2 -0
- package/dist/react-native/rules/skia/canvas-opaque.js +49 -0
- package/dist/react-native/rules/skia/index.d.ts +2 -0
- package/dist/react-native/rules/skia/index.js +21 -0
- package/dist/react-native/rules/turbo-image/index.d.ts +2 -0
- package/dist/react-native/rules/turbo-image/index.js +14 -0
- package/dist/react-native/rules/turbo-image/require-cache-policy.d.ts +2 -0
- package/dist/react-native/rules/turbo-image/require-cache-policy.js +21 -0
- package/dist/react-native/rules/turbo-image/require-resize.d.ts +2 -0
- package/dist/react-native/rules/turbo-image/require-resize.js +22 -0
- package/dist/react-native/rules/unistyles/animated-theme.d.ts +2 -0
- package/dist/react-native/rules/unistyles/animated-theme.js +37 -0
- package/dist/react-native/rules/unistyles/content-container.d.ts +2 -0
- package/dist/react-native/rules/unistyles/content-container.js +73 -0
- package/dist/react-native/rules/unistyles/in-sheet.d.ts +2 -0
- package/dist/react-native/rules/unistyles/in-sheet.js +116 -0
- package/dist/react-native/rules/unistyles/index.d.ts +2 -0
- package/dist/react-native/rules/unistyles/index.js +58 -0
- package/dist/react-native/rules/unistyles/insets.d.ts +2 -0
- package/dist/react-native/rules/unistyles/insets.js +92 -0
- package/dist/react-native/rules/unistyles/no-hardcoded-color.d.ts +2 -0
- package/dist/react-native/rules/unistyles/no-hardcoded-color.js +30 -0
- package/dist/react-native/rules/unistyles/no-hardcoded-spacing.d.ts +2 -0
- package/dist/react-native/rules/unistyles/no-hardcoded-spacing.js +78 -0
- package/dist/react-native/rules/unistyles/no-margin.d.ts +2 -0
- package/dist/react-native/rules/unistyles/no-margin.js +19 -0
- package/dist/react-native/rules/unistyles/no-style-spread.d.ts +2 -0
- package/dist/react-native/rules/unistyles/no-style-spread.js +41 -0
- package/dist/react-native/rules/unistyles/no-unused-styles.d.ts +2 -0
- package/dist/react-native/rules/unistyles/no-unused-styles.js +99 -0
- package/dist/react-native/rules/unistyles/rtl-style-call.d.ts +2 -0
- package/dist/react-native/rules/unistyles/rtl-style-call.js +42 -0
- package/dist/react-native/rules/unistyles/shared.d.ts +16 -0
- package/dist/react-native/rules/unistyles/shared.js +70 -0
- package/dist/react-native/rules/unistyles/theme-screen-component.d.ts +2 -0
- package/dist/react-native/rules/unistyles/theme-screen-component.js +22 -0
- package/dist/react-native/rules/unistyles/theme-style-attr.d.ts +2 -0
- package/dist/react-native/rules/unistyles/theme-style-attr.js +18 -0
- package/dist/react-native/stylesheet.d.ts +11 -0
- package/dist/react-native/stylesheet.js +44 -0
- package/package.json +70 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { problem } from "../../../lib/ast.js";
|
|
2
|
+
import { propertyKeyName } from "./shared.js";
|
|
3
|
+
const MESSAGES = {
|
|
4
|
+
inlineQueryKey: "Define this key in the feature's `*.keys.ts` factory and reference it: `queryKey: someKeys.scope(...)`, so invalidation elsewhere matches the same key.",
|
|
5
|
+
inlineGetQueryData: "Read through the feature's `*.keys.ts` factory: `getQueryData(someKeys.scope())`.",
|
|
6
|
+
inlineSetQueryData: "Write through the feature's `*.keys.ts` factory: `setQueryData(someKeys.scope(), updater)`.",
|
|
7
|
+
};
|
|
8
|
+
const QUERY_KEY_HOOKS = new Set([
|
|
9
|
+
"useQuery",
|
|
10
|
+
"useSuspenseQuery",
|
|
11
|
+
"useInfiniteQuery",
|
|
12
|
+
"useSuspenseInfiniteQuery",
|
|
13
|
+
"useQueries",
|
|
14
|
+
]);
|
|
15
|
+
const QUERY_KEY_METHODS = new Set([
|
|
16
|
+
"invalidateQueries",
|
|
17
|
+
"removeQueries",
|
|
18
|
+
"refetchQueries",
|
|
19
|
+
"cancelQueries",
|
|
20
|
+
"resetQueries",
|
|
21
|
+
"fetchQuery",
|
|
22
|
+
"prefetchQuery",
|
|
23
|
+
"ensureQueryData",
|
|
24
|
+
"getQueriesData",
|
|
25
|
+
"setQueriesData",
|
|
26
|
+
]);
|
|
27
|
+
const methodName = (node) => {
|
|
28
|
+
if (node.type !== "CallExpression")
|
|
29
|
+
return "";
|
|
30
|
+
const { callee } = node;
|
|
31
|
+
if (callee.type !== "MemberExpression" || callee.computed)
|
|
32
|
+
return "";
|
|
33
|
+
const { property } = callee;
|
|
34
|
+
return property.type === "Identifier" ? property.name : "";
|
|
35
|
+
};
|
|
36
|
+
const firstArgumentArray = (node) => {
|
|
37
|
+
if (node.type !== "CallExpression")
|
|
38
|
+
return undefined;
|
|
39
|
+
const [first] = node.arguments;
|
|
40
|
+
return first?.type === "ArrayExpression" ? first : undefined;
|
|
41
|
+
};
|
|
42
|
+
const takesQueryKeyOptions = (node, method) => {
|
|
43
|
+
const callee = node.type === "CallExpression" ? node.callee : undefined;
|
|
44
|
+
const isHook = callee?.type === "Identifier" && QUERY_KEY_HOOKS.has(callee.name);
|
|
45
|
+
return isHook || QUERY_KEY_METHODS.has(method);
|
|
46
|
+
};
|
|
47
|
+
const isQueryKeyProperty = (property) => {
|
|
48
|
+
if (property.type !== "Property" || property.computed)
|
|
49
|
+
return false;
|
|
50
|
+
const { key } = property;
|
|
51
|
+
if (key.type === "Identifier")
|
|
52
|
+
return key.name === "queryKey";
|
|
53
|
+
return key.type === "Literal" && key.value === "queryKey";
|
|
54
|
+
};
|
|
55
|
+
/** `useQueries({ queries: [...] })` carries its keys one level down, inside the array. */
|
|
56
|
+
const optionObjects = (options) => {
|
|
57
|
+
if (options.type !== "ObjectExpression")
|
|
58
|
+
return [];
|
|
59
|
+
const queries = options.properties.find(property => property.type === "Property" && propertyKeyName(property) === "queries");
|
|
60
|
+
if (queries?.type !== "Property" || queries.value.type !== "ArrayExpression")
|
|
61
|
+
return [options];
|
|
62
|
+
return [options, ...queries.value.elements.filter(element => element !== null)];
|
|
63
|
+
};
|
|
64
|
+
const inlineQueryKeys = (node) => {
|
|
65
|
+
if (node.type !== "CallExpression")
|
|
66
|
+
return [];
|
|
67
|
+
const [first] = node.arguments;
|
|
68
|
+
if (first === undefined)
|
|
69
|
+
return [];
|
|
70
|
+
const inline = [];
|
|
71
|
+
for (const options of optionObjects(first)) {
|
|
72
|
+
if (options.type !== "ObjectExpression")
|
|
73
|
+
continue;
|
|
74
|
+
for (const property of options.properties) {
|
|
75
|
+
if (property.type !== "Property" || !isQueryKeyProperty(property))
|
|
76
|
+
continue;
|
|
77
|
+
const { value } = property;
|
|
78
|
+
if (value.type === "ArrayExpression")
|
|
79
|
+
inline.push(value);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return inline;
|
|
83
|
+
};
|
|
84
|
+
export const noInlineKeys = problem("Disallow a query key written as an array literal at the call site instead of coming from a keys factory.", {
|
|
85
|
+
createOnce(context) {
|
|
86
|
+
return {
|
|
87
|
+
CallExpression(node) {
|
|
88
|
+
const method = methodName(node);
|
|
89
|
+
if (method === "getQueryData" || method === "setQueryData") {
|
|
90
|
+
const key = firstArgumentArray(node);
|
|
91
|
+
if (key === undefined)
|
|
92
|
+
return;
|
|
93
|
+
context.report({
|
|
94
|
+
node: key,
|
|
95
|
+
message: method === "getQueryData" ? MESSAGES.inlineGetQueryData : MESSAGES.inlineSetQueryData,
|
|
96
|
+
});
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (!takesQueryKeyOptions(node, method))
|
|
100
|
+
return;
|
|
101
|
+
for (const key of inlineQueryKeys(node)) {
|
|
102
|
+
context.report({ node: key, message: MESSAGES.inlineQueryKey });
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
},
|
|
107
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { problem } from "../../../lib/ast.js";
|
|
2
|
+
const DESTRUCTURE_QUERY_HOOK = "Destructure this hook at the call site: `const { data } = useFooQuery()` rather than binding the whole result to one name.";
|
|
3
|
+
const API_HOOK_MODULE = /^@\/api\/.*\.(?:queries|mutations)$/;
|
|
4
|
+
export const requireDestructuredHooks = problem("Require destructuring the result of a hook imported from an `@/api/*.queries` or `@/api/*.mutations` module.", {
|
|
5
|
+
createOnce(context) {
|
|
6
|
+
const apiHooks = new Set();
|
|
7
|
+
const candidates = [];
|
|
8
|
+
return {
|
|
9
|
+
before() {
|
|
10
|
+
apiHooks.clear();
|
|
11
|
+
candidates.length = 0;
|
|
12
|
+
},
|
|
13
|
+
ImportDeclaration(node) {
|
|
14
|
+
if (!API_HOOK_MODULE.test(node.source.value))
|
|
15
|
+
return;
|
|
16
|
+
for (const specifier of node.specifiers) {
|
|
17
|
+
if (specifier.type === "ImportSpecifier")
|
|
18
|
+
apiHooks.add(specifier.local.name);
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
VariableDeclarator(node) {
|
|
22
|
+
const { id, init } = node;
|
|
23
|
+
if (id.type !== "Identifier")
|
|
24
|
+
return;
|
|
25
|
+
if (init?.type !== "CallExpression")
|
|
26
|
+
return;
|
|
27
|
+
const { callee } = init;
|
|
28
|
+
if (callee.type !== "Identifier" || !/^use[A-Z]/.test(callee.name))
|
|
29
|
+
return;
|
|
30
|
+
candidates.push({ node: id, hook: callee.name });
|
|
31
|
+
},
|
|
32
|
+
"Program:exit"() {
|
|
33
|
+
for (const candidate of candidates) {
|
|
34
|
+
if (!apiHooks.has(candidate.hook))
|
|
35
|
+
continue;
|
|
36
|
+
context.report({ node: candidate.node, message: DESTRUCTURE_QUERY_HOOK });
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
},
|
|
41
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { defineModule } from "../../../lib/module.js";
|
|
2
|
+
import { requireSelector } from "./require-selector.js";
|
|
3
|
+
export default defineModule({
|
|
4
|
+
meta: { name: "@ashstack/zustand" },
|
|
5
|
+
url: import.meta.url,
|
|
6
|
+
packages: ["zustand"],
|
|
7
|
+
option: "zustand",
|
|
8
|
+
docsWhen: "auto-enabled by `react()` when `zustand` is a dependency",
|
|
9
|
+
rules: {
|
|
10
|
+
"require-selector": requireSelector,
|
|
11
|
+
},
|
|
12
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { problem } from "../../../lib/ast.js";
|
|
2
|
+
const MESSAGES = {
|
|
3
|
+
zustandBare: "Pass a selector to this store hook, for example `useSettingsStore(state => state.theme)`, so it re-renders only on that slice. Use `.getState()` instead for an imperative read.",
|
|
4
|
+
zustandUndefined: "Replace `undefined` with a selector such as `state => state.theme` — it still subscribes to the whole store otherwise. Use `.getState()` instead for an imperative read.",
|
|
5
|
+
};
|
|
6
|
+
const STORE_MODULE = /^(?:@\/stores\/|(?:\.\.?\/)+stores\/)[^"']*-store$/;
|
|
7
|
+
const STORE_HOOK = /^use[A-Za-z0-9_$]*Store$/;
|
|
8
|
+
const storeHookLocalName = (specifier) => {
|
|
9
|
+
if (specifier.type !== "ImportSpecifier")
|
|
10
|
+
return null;
|
|
11
|
+
const imported = specifier.imported.type === "Identifier" ? specifier.imported.name : "";
|
|
12
|
+
const local = specifier.local.name;
|
|
13
|
+
return STORE_HOOK.test(imported) || STORE_HOOK.test(local) ? local : null;
|
|
14
|
+
};
|
|
15
|
+
export const requireSelector = problem("Require a selector on a store hook, rather than no arguments or `undefined` in its place.", {
|
|
16
|
+
createOnce(context) {
|
|
17
|
+
const hooks = new Set();
|
|
18
|
+
const calls = [];
|
|
19
|
+
return {
|
|
20
|
+
before() {
|
|
21
|
+
hooks.clear();
|
|
22
|
+
calls.length = 0;
|
|
23
|
+
},
|
|
24
|
+
ImportDeclaration(node) {
|
|
25
|
+
if (!STORE_MODULE.test(node.source.value))
|
|
26
|
+
return;
|
|
27
|
+
for (const specifier of node.specifiers) {
|
|
28
|
+
const local = storeHookLocalName(specifier);
|
|
29
|
+
if (local !== null)
|
|
30
|
+
hooks.add(local);
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
CallExpression(node) {
|
|
34
|
+
const { callee } = node;
|
|
35
|
+
if (callee.type !== "Identifier")
|
|
36
|
+
return;
|
|
37
|
+
const { name } = callee;
|
|
38
|
+
if (!hooks.has(name) && !STORE_HOOK.test(name))
|
|
39
|
+
return;
|
|
40
|
+
const args = node.arguments;
|
|
41
|
+
if (args.length === 0) {
|
|
42
|
+
calls.push({ node, name, bare: true });
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const [first] = args;
|
|
46
|
+
if (args.length === 1 && first?.type === "Identifier" && first.name === "undefined") {
|
|
47
|
+
calls.push({ node, name, bare: false });
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
"Program:exit"() {
|
|
51
|
+
for (const call of calls) {
|
|
52
|
+
if (!hooks.has(call.name))
|
|
53
|
+
continue;
|
|
54
|
+
context.report({
|
|
55
|
+
node: call.node,
|
|
56
|
+
message: call.bare ? MESSAGES.zustandBare : MESSAGES.zustandUndefined,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
},
|
|
62
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { BanGroup, OxlintConfig, ReactNativeOptions } from "../lib/types.js";
|
|
2
|
+
export declare const reactNativeModules: import("../lib/types.js").ModuleManifest[];
|
|
3
|
+
export declare const banGroups: BanGroup[];
|
|
4
|
+
/**
|
|
5
|
+
* React Native / Expo entry — everything in react plus generic RN rules, with
|
|
6
|
+
* per-library modules (unistyles, legendList, legendState, reanimated,
|
|
7
|
+
* turboImage, skia, keyboard) auto-detected from your dependencies. Your
|
|
8
|
+
* `rules` block always overrides.
|
|
9
|
+
*/
|
|
10
|
+
declare const reactNative: (options?: ReactNativeOptions) => OxlintConfig;
|
|
11
|
+
export default reactNative;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { TEST_FILES, coreModules } from "../core/index.js";
|
|
2
|
+
import { mergeConfigs } from "../lib/merge.js";
|
|
3
|
+
import { composeModules } from "../lib/module.js";
|
|
4
|
+
import react, { reactModules } from "../react/index.js";
|
|
5
|
+
import keyboardModule from "./rules/keyboard/index.js";
|
|
6
|
+
import legendListModule from "./rules/legend-list/index.js";
|
|
7
|
+
import legendStateModule from "./rules/legend-state/index.js";
|
|
8
|
+
import reactNativeModule from "./rules/react-native/index.js";
|
|
9
|
+
import reanimatedModule from "./rules/reanimated/index.js";
|
|
10
|
+
import skiaModule from "./rules/skia/index.js";
|
|
11
|
+
import turboImageModule from "./rules/turbo-image/index.js";
|
|
12
|
+
import unistylesModule from "./rules/unistyles/index.js";
|
|
13
|
+
export const reactNativeModules = [
|
|
14
|
+
reactNativeModule,
|
|
15
|
+
unistylesModule,
|
|
16
|
+
legendListModule,
|
|
17
|
+
legendStateModule,
|
|
18
|
+
reanimatedModule,
|
|
19
|
+
turboImageModule,
|
|
20
|
+
skiaModule,
|
|
21
|
+
keyboardModule,
|
|
22
|
+
];
|
|
23
|
+
export const banGroups = [
|
|
24
|
+
{
|
|
25
|
+
packages: ["react-native-gesture-handler"],
|
|
26
|
+
restrictedImports: {
|
|
27
|
+
paths: [
|
|
28
|
+
{
|
|
29
|
+
name: "react-native",
|
|
30
|
+
importNames: [
|
|
31
|
+
"Pressable",
|
|
32
|
+
"TouchableOpacity",
|
|
33
|
+
"TouchableHighlight",
|
|
34
|
+
"TouchableWithoutFeedback",
|
|
35
|
+
"TouchableNativeFeedback",
|
|
36
|
+
],
|
|
37
|
+
message: "Import `Pressable` from `react-native-gesture-handler` so it cooperates with the gesture system instead of competing with it. Exception: inside a SwiftUI/Compose host view touches never reach `GestureHandlerRootView` — keep React Native's `Pressable` there and suppress this rule with the host named in the reason.",
|
|
38
|
+
},
|
|
39
|
+
],
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
packages: ["expo-router"],
|
|
44
|
+
restrictedImports: {
|
|
45
|
+
patterns: [
|
|
46
|
+
{
|
|
47
|
+
group: ["@react-navigation/stack", "@react-navigation/bottom-tabs"],
|
|
48
|
+
message: "Use expo-router's `Stack` / `NativeTabs` instead of a raw react-navigation navigator.",
|
|
49
|
+
},
|
|
50
|
+
],
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
packages: ["expo-font"],
|
|
55
|
+
restrictedImports: {
|
|
56
|
+
paths: [
|
|
57
|
+
{
|
|
58
|
+
name: "expo-font",
|
|
59
|
+
importNames: ["useFonts", "loadAsync"],
|
|
60
|
+
message: "Declare the fonts in the `expo-font` config plugin so they load at build time, instead of `useFonts`/`loadAsync` at runtime.",
|
|
61
|
+
},
|
|
62
|
+
],
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
packages: ["react-native-quick-crypto"],
|
|
67
|
+
restrictedImports: {
|
|
68
|
+
paths: [
|
|
69
|
+
{
|
|
70
|
+
name: "crypto-js",
|
|
71
|
+
message: "Use `react-native-quick-crypto` instead — `crypto-js` is pure JavaScript and runs orders of magnitude slower.",
|
|
72
|
+
},
|
|
73
|
+
],
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
];
|
|
77
|
+
const FORBID_EMPTY_NOOP_HANDLERS = {
|
|
78
|
+
"eslint/no-empty": "error",
|
|
79
|
+
"eslint/no-empty-function": "error",
|
|
80
|
+
};
|
|
81
|
+
const ALLOW_GESTURE_AND_ANIMATION_EFFECTS = {
|
|
82
|
+
"react-effect/no-event-handler": "off",
|
|
83
|
+
};
|
|
84
|
+
/**
|
|
85
|
+
* React Native / Expo entry — everything in react plus generic RN rules, with
|
|
86
|
+
* per-library modules (unistyles, legendList, legendState, reanimated,
|
|
87
|
+
* turboImage, skia, keyboard) auto-detected from your dependencies. Your
|
|
88
|
+
* `rules` block always overrides.
|
|
89
|
+
*/
|
|
90
|
+
const reactNative = (options = {}) => {
|
|
91
|
+
const composed = composeModules([...coreModules, ...reactModules, ...reactNativeModules], options, banGroups);
|
|
92
|
+
return mergeConfigs(react(options), {
|
|
93
|
+
jsPlugins: composed.jsPlugins,
|
|
94
|
+
globals: { __DEV__: "readonly" },
|
|
95
|
+
rules: {
|
|
96
|
+
...FORBID_EMPTY_NOOP_HANDLERS,
|
|
97
|
+
...ALLOW_GESTURE_AND_ANIMATION_EFFECTS,
|
|
98
|
+
"no-restricted-imports": ["error", composed.restricted],
|
|
99
|
+
...composed.rules,
|
|
100
|
+
},
|
|
101
|
+
ignorePatterns: ["**/.expo/**", "**/android/**", "**/ios/**"],
|
|
102
|
+
overrides: [
|
|
103
|
+
{
|
|
104
|
+
files: TEST_FILES,
|
|
105
|
+
rules: {
|
|
106
|
+
"@ashstack/react-native/no-dynamic-import": "off",
|
|
107
|
+
"@ashstack/react-native/hoist-stateless-function": "off",
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
],
|
|
111
|
+
});
|
|
112
|
+
};
|
|
113
|
+
export default reactNative;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { defineModule } from "../../../lib/module.js";
|
|
2
|
+
export default defineModule({
|
|
3
|
+
meta: { name: "@ashstack/keyboard" },
|
|
4
|
+
url: import.meta.url,
|
|
5
|
+
packages: ["react-native-keyboard-controller"],
|
|
6
|
+
option: "keyboard",
|
|
7
|
+
docsWhen: "auto-enabled when `react-native-keyboard-controller` is a dependency",
|
|
8
|
+
rules: {},
|
|
9
|
+
restrictedImports: {
|
|
10
|
+
paths: [
|
|
11
|
+
{
|
|
12
|
+
name: "react-native",
|
|
13
|
+
importNames: ["KeyboardAvoidingView"],
|
|
14
|
+
message: "Import `KeyboardAvoidingView` from `react-native-keyboard-controller` — same props, and it tracks the keyboard per frame instead of waiting for Android's post-animation `keyboardDidShow`. For a scrolling form use its `KeyboardAwareScrollView`; to just shift a view, use `rt.insets.ime`.",
|
|
15
|
+
},
|
|
16
|
+
],
|
|
17
|
+
},
|
|
18
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { defineModule } from "../../../lib/module.js";
|
|
2
|
+
import { noFlexInContentContainer } from "./no-flex-in-content-container.js";
|
|
3
|
+
import { noIndexKeyExtractor } from "./no-index-key-extractor.js";
|
|
4
|
+
import { noInlineData } from "./no-inline-data.js";
|
|
5
|
+
import { noInlineExtraData } from "./no-inline-extra-data.js";
|
|
6
|
+
import { noInlineRenderItemProps } from "./no-inline-render-item-props.js";
|
|
7
|
+
import { noMixedChildren } from "./no-mixed-children.js";
|
|
8
|
+
import { noRemountKey } from "./no-remount-key.js";
|
|
9
|
+
import { noScrollviewMap } from "./no-scrollview-map.js";
|
|
10
|
+
import { noUnsupportedProps } from "./no-unsupported-props.js";
|
|
11
|
+
import { requiredProps } from "./required-props.js";
|
|
12
|
+
import { typedItemsNeedItemType } from "./typed-items-need-item-type.js";
|
|
13
|
+
export default defineModule({
|
|
14
|
+
meta: { name: "@ashstack/legend-list" },
|
|
15
|
+
rules: {
|
|
16
|
+
"required-props": requiredProps,
|
|
17
|
+
"no-index-key-extractor": noIndexKeyExtractor,
|
|
18
|
+
"no-remount-key": noRemountKey,
|
|
19
|
+
"no-inline-data": noInlineData,
|
|
20
|
+
"no-inline-extra-data": noInlineExtraData,
|
|
21
|
+
"no-inline-render-item-props": noInlineRenderItemProps,
|
|
22
|
+
"no-mixed-children": noMixedChildren,
|
|
23
|
+
"no-flex-in-content-container": noFlexInContentContainer,
|
|
24
|
+
"typed-items-need-item-type": typedItemsNeedItemType,
|
|
25
|
+
"no-scrollview-map": noScrollviewMap,
|
|
26
|
+
"no-unsupported-props": noUnsupportedProps,
|
|
27
|
+
},
|
|
28
|
+
url: import.meta.url,
|
|
29
|
+
packages: ["@legendapp/list"],
|
|
30
|
+
option: "legendList",
|
|
31
|
+
docsWhen: "auto-enabled when `@legendapp/list` is a dependency",
|
|
32
|
+
restrictedImports: {
|
|
33
|
+
paths: [
|
|
34
|
+
{
|
|
35
|
+
name: "react-native",
|
|
36
|
+
importNames: ["FlatList", "SectionList", "VirtualizedList"],
|
|
37
|
+
message: "Render this list with `LegendList` from `@legendapp/list/react-native`; a `ScrollView` plus `map` is fine for fewer than 20 static items.",
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
name: "@shopify/flash-list",
|
|
41
|
+
message: "Import `LegendList` from `@legendapp/list/react-native` — Legend List replaced FlashList in this stack.",
|
|
42
|
+
},
|
|
43
|
+
],
|
|
44
|
+
patterns: [
|
|
45
|
+
{
|
|
46
|
+
group: ["@legendapp/list"],
|
|
47
|
+
message: "Import from the platform entrypoint `@legendapp/list/react-native`, not the package root.",
|
|
48
|
+
},
|
|
49
|
+
],
|
|
50
|
+
},
|
|
51
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { attributeName, gate, problem } from "../../../lib/ast.js";
|
|
2
|
+
import { isStyleSheetCreate, propertyName, stylesObjectOf } from "../../stylesheet.js";
|
|
3
|
+
import { expressionOf, isListElement } from "./shared.js";
|
|
4
|
+
const MESSAGE = "Move `flex` onto `style` and keep `contentContainerStyle` for padding inside the content. On the content container it sizes the content to the viewport, so the list measures as zero height and renders a blank screen with no error.";
|
|
5
|
+
const hasFlexKey = (node) => node?.type === "ObjectExpression" &&
|
|
6
|
+
node.properties.some(property => property.type === "Property" && !property.computed && propertyName(property) === "flex");
|
|
7
|
+
/** `styles.content` and `styles["content"]` both read as that pair; a dynamic key reads as null. */
|
|
8
|
+
const styleReference = (node) => {
|
|
9
|
+
if (node?.type !== "MemberExpression" || node.object.type !== "Identifier")
|
|
10
|
+
return null;
|
|
11
|
+
const sheet = node.object.name;
|
|
12
|
+
if (!node.computed && node.property.type === "Identifier")
|
|
13
|
+
return { sheet, key: node.property.name };
|
|
14
|
+
if (node.computed && node.property.type === "Literal" && typeof node.property.value === "string") {
|
|
15
|
+
return { sheet, key: node.property.value };
|
|
16
|
+
}
|
|
17
|
+
return null;
|
|
18
|
+
};
|
|
19
|
+
/** A style prop holds one style or an array of them; both read as a list. */
|
|
20
|
+
const styleParts = (value) => {
|
|
21
|
+
if (!value)
|
|
22
|
+
return [];
|
|
23
|
+
if (value.type !== "ArrayExpression")
|
|
24
|
+
return [value];
|
|
25
|
+
return value.elements.filter(element => element !== null);
|
|
26
|
+
};
|
|
27
|
+
export const noFlexInContentContainer = problem("Disallow `flex` in a Legend List's `contentContainerStyle`, where it sizes the scrolled content to the viewport and the list ends up measuring zero height.", {
|
|
28
|
+
createOnce(context) {
|
|
29
|
+
let flexKeysBySheet = new Map();
|
|
30
|
+
let candidates = [];
|
|
31
|
+
const holdsFlex = (part) => {
|
|
32
|
+
if (hasFlexKey(part))
|
|
33
|
+
return true;
|
|
34
|
+
const reference = styleReference(part);
|
|
35
|
+
return reference !== null && (flexKeysBySheet.get(reference.sheet)?.has(reference.key) ?? false);
|
|
36
|
+
};
|
|
37
|
+
return {
|
|
38
|
+
before() {
|
|
39
|
+
flexKeysBySheet = new Map();
|
|
40
|
+
candidates = [];
|
|
41
|
+
return gate(context, "contentContainerStyle");
|
|
42
|
+
},
|
|
43
|
+
VariableDeclarator(node) {
|
|
44
|
+
const { id, init } = node;
|
|
45
|
+
if (id.type !== "Identifier" || init?.type !== "CallExpression" || !isStyleSheetCreate(init))
|
|
46
|
+
return;
|
|
47
|
+
const object = stylesObjectOf(init.arguments[0]);
|
|
48
|
+
if (!object)
|
|
49
|
+
return;
|
|
50
|
+
const flexKeys = new Set();
|
|
51
|
+
for (const property of object.properties) {
|
|
52
|
+
if (property.type !== "Property" || property.computed)
|
|
53
|
+
continue;
|
|
54
|
+
const name = propertyName(property);
|
|
55
|
+
if (name !== "" && hasFlexKey(property.value))
|
|
56
|
+
flexKeys.add(name);
|
|
57
|
+
}
|
|
58
|
+
flexKeysBySheet.set(id.name, flexKeys);
|
|
59
|
+
},
|
|
60
|
+
JSXAttribute(node) {
|
|
61
|
+
if (attributeName(node) !== "contentContainerStyle")
|
|
62
|
+
return;
|
|
63
|
+
if (!isListElement(node.parent.parent))
|
|
64
|
+
return;
|
|
65
|
+
const parts = styleParts(expressionOf(node));
|
|
66
|
+
if (parts.length > 0)
|
|
67
|
+
candidates.push({ node, parts });
|
|
68
|
+
},
|
|
69
|
+
"Program:exit"() {
|
|
70
|
+
for (const { node, parts } of candidates) {
|
|
71
|
+
if (parts.some(part => holdsFlex(part)))
|
|
72
|
+
context.report({ node, message: MESSAGE });
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
},
|
|
77
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { attributeName, gate, problem, subtreeHas } from "../../../lib/ast.js";
|
|
2
|
+
import { expressionOf, isListElement } from "./shared.js";
|
|
3
|
+
export const noIndexKeyExtractor = problem("Disallow a `keyExtractor` that uses its index parameter. Cached sizes and recycled row state hang off the key, so a prepend points every measurement at the wrong item.", {
|
|
4
|
+
createOnce: context => ({
|
|
5
|
+
before: () => gate(context, "keyExtractor"),
|
|
6
|
+
JSXAttribute(node) {
|
|
7
|
+
if (attributeName(node) !== "keyExtractor")
|
|
8
|
+
return;
|
|
9
|
+
if (!isListElement(node.parent.parent))
|
|
10
|
+
return;
|
|
11
|
+
const fn = expressionOf(node);
|
|
12
|
+
if (fn?.type !== "ArrowFunctionExpression" && fn?.type !== "FunctionExpression")
|
|
13
|
+
return;
|
|
14
|
+
const indexParam = fn.params[1];
|
|
15
|
+
if (indexParam?.type !== "Identifier")
|
|
16
|
+
return;
|
|
17
|
+
if (!subtreeHas(fn.body, current => current.type === "Identifier" && current.name === indexParam.name)) {
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
context.report({
|
|
21
|
+
node: fn,
|
|
22
|
+
message: "Return a stable per-item id from this `keyExtractor` instead of the index. Legend List hangs cached sizes and recycled row state off the key, so one prepend re-points every measurement at the wrong item.",
|
|
23
|
+
});
|
|
24
|
+
},
|
|
25
|
+
}),
|
|
26
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { FUNCTION_TYPES, calleeName, crossesFunctionBefore, findInSubtree, gate, problem } from "../../../lib/ast.js";
|
|
2
|
+
import { attributeNamed, expressionOf, isListElement, LIST } from "./shared.js";
|
|
3
|
+
const MEMO_HOOKS = new Set(["useMemo", "useCallback"]);
|
|
4
|
+
const buildsFreshArray = (node) => node.type === "ArrayExpression" || (node.type === "CallExpression" && node.callee.type === "MemberExpression");
|
|
5
|
+
export const noInlineData = problem("Disallow building a Legend List's `data` inline. Each render produces a new array reference, and the list re-diffs, re-keys and drops what it had cached.", {
|
|
6
|
+
createOnce: context => ({
|
|
7
|
+
before: () => gate(context, LIST),
|
|
8
|
+
JSXElement(node) {
|
|
9
|
+
if (!isListElement(node))
|
|
10
|
+
return;
|
|
11
|
+
const data = attributeNamed(node, "data");
|
|
12
|
+
if (!data)
|
|
13
|
+
return;
|
|
14
|
+
if (MEMO_HOOKS.has(calleeName(expressionOf(data))))
|
|
15
|
+
return;
|
|
16
|
+
const built = findInSubtree(data.value, buildsFreshArray);
|
|
17
|
+
if (!built || crossesFunctionBefore(built, data, FUNCTION_TYPES))
|
|
18
|
+
return;
|
|
19
|
+
context.report({
|
|
20
|
+
node: data,
|
|
21
|
+
message: "Hoist this `data` into a `useMemo` or compute it upstream so its reference is stable. A new array each render makes the list re-diff, re-key and invalidate everything it had cached.",
|
|
22
|
+
});
|
|
23
|
+
},
|
|
24
|
+
}),
|
|
25
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { attributeName, gate, problem } from "../../../lib/ast.js";
|
|
2
|
+
import { expressionOf, isListElement, literalKind } from "./shared.js";
|
|
3
|
+
export const noInlineExtraData = problem("An inline object or array as `extraData` takes a new identity on every parent render, and every mounted row re-evaluates with it.", {
|
|
4
|
+
createOnce: context => ({
|
|
5
|
+
before: () => gate(context, "extraData"),
|
|
6
|
+
JSXAttribute(node) {
|
|
7
|
+
if (attributeName(node) !== "extraData")
|
|
8
|
+
return;
|
|
9
|
+
if (!isListElement(node.parent.parent))
|
|
10
|
+
return;
|
|
11
|
+
const value = expressionOf(node);
|
|
12
|
+
const kind = literalKind(value);
|
|
13
|
+
if (value === null || kind === null)
|
|
14
|
+
return;
|
|
15
|
+
context.report({
|
|
16
|
+
node: value,
|
|
17
|
+
message: `Pass the primitive that actually changed as \`extraData\`, or hoist this ${kind} when it is stable. A fresh ${kind} reference each render re-evaluates every mounted row.`,
|
|
18
|
+
});
|
|
19
|
+
},
|
|
20
|
+
}),
|
|
21
|
+
});
|