@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,35 @@
|
|
|
1
|
+
import { attributeName, gate, problem } from "../../../lib/ast.js";
|
|
2
|
+
import { literalKind } from "./shared.js";
|
|
3
|
+
export const noInlineRenderItemProps = problem("Disallow inline object and array literals on props nested inside `renderItem`. A row whose props take a new identity every render can never be skipped.", {
|
|
4
|
+
createOnce(context) {
|
|
5
|
+
let depth = 0;
|
|
6
|
+
return {
|
|
7
|
+
before() {
|
|
8
|
+
depth = 0;
|
|
9
|
+
return gate(context, "renderItem");
|
|
10
|
+
},
|
|
11
|
+
JSXAttribute(node) {
|
|
12
|
+
if (attributeName(node) === "renderItem") {
|
|
13
|
+
depth++;
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
if (depth === 0)
|
|
17
|
+
return;
|
|
18
|
+
const { value } = node;
|
|
19
|
+
if (value?.type !== "JSXExpressionContainer")
|
|
20
|
+
return;
|
|
21
|
+
const kind = literalKind(value.expression);
|
|
22
|
+
if (kind === null)
|
|
23
|
+
return;
|
|
24
|
+
context.report({
|
|
25
|
+
node: value,
|
|
26
|
+
message: `Pass \`item\` or its primitive fields and build this ${kind} inside the row, or hoist it to module scope when it is static. A fresh ${kind} each render means the row can never be skipped, so typing anywhere re-renders every visible row.`,
|
|
27
|
+
});
|
|
28
|
+
},
|
|
29
|
+
"JSXAttribute:exit"(node) {
|
|
30
|
+
if (attributeName(node) === "renderItem")
|
|
31
|
+
depth--;
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
},
|
|
35
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { gate, problem } from "../../../lib/ast.js";
|
|
2
|
+
import { attributeNamed, isListElement, LIST } from "./shared.js";
|
|
3
|
+
const rendersRealChild = (node) => node.type === "JSXElement" &&
|
|
4
|
+
node.children.some(child => child.type === "JSXElement" ||
|
|
5
|
+
child.type === "JSXFragment" ||
|
|
6
|
+
(child.type === "JSXText" && child.value.trim() !== "") ||
|
|
7
|
+
(child.type === "JSXExpressionContainer" && child.expression.type !== "JSXEmptyExpression"));
|
|
8
|
+
export const noMixedChildren = problem("Disallow passing both `data` and real children to a Legend List. The combination is unsupported and one of the two is dropped without a warning.", {
|
|
9
|
+
createOnce: context => ({
|
|
10
|
+
before: () => gate(context, LIST),
|
|
11
|
+
JSXElement(node) {
|
|
12
|
+
if (!isListElement(node))
|
|
13
|
+
return;
|
|
14
|
+
if (!attributeNamed(node, "data"))
|
|
15
|
+
return;
|
|
16
|
+
if (!rendersRealChild(node))
|
|
17
|
+
return;
|
|
18
|
+
context.report({
|
|
19
|
+
node: node.openingElement,
|
|
20
|
+
message: "Keep either `data` with `renderItem` or children mode here, and remove the other. Passing both fails silently, with no guarantee about which one is ignored.",
|
|
21
|
+
});
|
|
22
|
+
},
|
|
23
|
+
}),
|
|
24
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { gate, problem } from "../../../lib/ast.js";
|
|
2
|
+
import { attributeNamed, isListElement, LIST } from "./shared.js";
|
|
3
|
+
export const noRemountKey = problem("Disallow `key` on a Legend List, which remounts on any key change and loses its measurements and scroll position. Pass `dataKey` instead.", {
|
|
4
|
+
createOnce: context => ({
|
|
5
|
+
before: () => gate(context, LIST),
|
|
6
|
+
JSXElement(node) {
|
|
7
|
+
if (!isListElement(node))
|
|
8
|
+
return;
|
|
9
|
+
const key = attributeNamed(node, "key");
|
|
10
|
+
if (!key)
|
|
11
|
+
return;
|
|
12
|
+
context.report({
|
|
13
|
+
node: key,
|
|
14
|
+
message: "Pass `dataKey` instead of `key` here: it re-initialises the list for a different dataset from the inside, without the remount that discards every measurement, cached size and scroll position.",
|
|
15
|
+
});
|
|
16
|
+
},
|
|
17
|
+
}),
|
|
18
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { componentName, gate, problem } from "../../../lib/ast.js";
|
|
2
|
+
export const noScrollviewMap = problem("A `ScrollView` mounts every child up front, so a mapped collection does not belong in its children.", {
|
|
3
|
+
createOnce: context => ({
|
|
4
|
+
before: () => gate(context, "ScrollView"),
|
|
5
|
+
JSXElement(node) {
|
|
6
|
+
if (componentName(node.openingElement.name) !== "ScrollView")
|
|
7
|
+
return;
|
|
8
|
+
for (const child of node.children) {
|
|
9
|
+
if (child.type !== "JSXExpressionContainer")
|
|
10
|
+
continue;
|
|
11
|
+
const call = child.expression;
|
|
12
|
+
if (call.type !== "CallExpression")
|
|
13
|
+
continue;
|
|
14
|
+
const { callee } = call;
|
|
15
|
+
if (callee.type !== "MemberExpression")
|
|
16
|
+
continue;
|
|
17
|
+
if (callee.property.type !== "Identifier" || callee.property.name !== "map")
|
|
18
|
+
continue;
|
|
19
|
+
context.report({
|
|
20
|
+
node: child,
|
|
21
|
+
message: "Render this collection with `LegendList` from '@legendapp/list/react-native', passing the array as `data` and the mapped body as `renderItem`. A ScrollView mounts every child up front, so 200 rows cost 200 mounts to show ten.",
|
|
22
|
+
});
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
}),
|
|
27
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { attributeName, gate, problem } from "../../../lib/ast.js";
|
|
2
|
+
import { attributesOf, isListElement, LIST } from "./shared.js";
|
|
3
|
+
const UNSUPPORTED_PROPS = new Set([
|
|
4
|
+
"masonry",
|
|
5
|
+
"optimizeItemArrangement",
|
|
6
|
+
"inverted",
|
|
7
|
+
"onBlankArea",
|
|
8
|
+
"disableAutoLayout",
|
|
9
|
+
"CellRendererComponent",
|
|
10
|
+
]);
|
|
11
|
+
export const noUnsupportedProps = problem("Disallow FlashList and FlatList props that Legend List v3 does not have. It ignores them rather than rejecting them, so the feature looks broken.", {
|
|
12
|
+
createOnce: context => ({
|
|
13
|
+
before: () => gate(context, LIST),
|
|
14
|
+
JSXElement(node) {
|
|
15
|
+
if (!isListElement(node))
|
|
16
|
+
return;
|
|
17
|
+
for (const attribute of attributesOf(node)) {
|
|
18
|
+
if (attribute.type !== "JSXAttribute")
|
|
19
|
+
continue;
|
|
20
|
+
if (!UNSUPPORTED_PROPS.has(attributeName(attribute)))
|
|
21
|
+
continue;
|
|
22
|
+
context.report({
|
|
23
|
+
node: attribute,
|
|
24
|
+
message: "Remove this prop: Legend List v3 has no such prop and ignores it silently rather than rejecting it. Build inverted chat lists from `maintainScrollAtEnd` / `initialScrollAtEnd` / `maintainVisibleContentPosition` instead.",
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
}),
|
|
29
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { gate, problem } from "../../../lib/ast.js";
|
|
2
|
+
import { attributeNamed, hasSpread, isListElement, LIST } from "./shared.js";
|
|
3
|
+
export const requiredProps = problem("Require `keyExtractor` and an explicit `recycleItems` on a Legend List. Without a key extractor the list keys its rows by index.", {
|
|
4
|
+
createOnce: context => ({
|
|
5
|
+
before: () => gate(context, LIST),
|
|
6
|
+
JSXElement(node) {
|
|
7
|
+
if (!isListElement(node) || hasSpread(node))
|
|
8
|
+
return;
|
|
9
|
+
if (!attributeNamed(node, "keyExtractor")) {
|
|
10
|
+
context.report({
|
|
11
|
+
node: node.openingElement,
|
|
12
|
+
message: "Add `keyExtractor={item => item.id}` returning a stable per-item identity. The index fallback pins cached measurements and recycled row state to a position, so a prepend leaves rows showing the previous row's data.",
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
if (!attributeNamed(node, "recycleItems")) {
|
|
16
|
+
context.report({
|
|
17
|
+
node: node.openingElement,
|
|
18
|
+
message: "Add `recycleItems={true}` — recycling is where most of the list's native speed comes from. If a row genuinely cannot be recycled, pass `recycleItems={false}` and state which part of the row required it.",
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
}),
|
|
23
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { AstNode } from "../../../lib/types.js";
|
|
2
|
+
/** A JSX element node, the only shape the list rules look at. */
|
|
3
|
+
export type ListElement = Extract<AstNode, {
|
|
4
|
+
type: "JSXElement";
|
|
5
|
+
}>;
|
|
6
|
+
type Attribute = Extract<AstNode, {
|
|
7
|
+
type: "JSXAttribute";
|
|
8
|
+
}>;
|
|
9
|
+
type AttributeItem = Extract<AstNode, {
|
|
10
|
+
type: "JSXAttribute" | "JSXSpreadAttribute";
|
|
11
|
+
}>;
|
|
12
|
+
export declare const LIST = "LegendList";
|
|
13
|
+
export declare const isListElement: (node: AstNode | null | undefined) => boolean;
|
|
14
|
+
export declare const attributesOf: (node: AstNode) => AttributeItem[];
|
|
15
|
+
export declare const attributeNamed: (node: AstNode, wanted: string) => Attribute | undefined;
|
|
16
|
+
export declare const hasSpread: (node: AstNode) => boolean;
|
|
17
|
+
export declare const expressionOf: (attribute: AstNode | null | undefined) => AstNode | null;
|
|
18
|
+
export declare const literalKind: (node: AstNode | null | undefined) => "object" | "array" | null;
|
|
19
|
+
export {};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { attributeName, componentName } from "../../../lib/ast.js";
|
|
2
|
+
export const LIST = "LegendList";
|
|
3
|
+
export const isListElement = (node) => node?.type === "JSXElement" && componentName(node.openingElement.name) === LIST;
|
|
4
|
+
export const attributesOf = (node) => node.type === "JSXElement" ? node.openingElement.attributes : [];
|
|
5
|
+
export const attributeNamed = (node, wanted) => {
|
|
6
|
+
for (const attribute of attributesOf(node)) {
|
|
7
|
+
if (attribute.type === "JSXAttribute" && attributeName(attribute) === wanted)
|
|
8
|
+
return attribute;
|
|
9
|
+
}
|
|
10
|
+
return undefined;
|
|
11
|
+
};
|
|
12
|
+
export const hasSpread = (node) => attributesOf(node).some(attribute => attribute.type === "JSXSpreadAttribute");
|
|
13
|
+
export const expressionOf = (attribute) => {
|
|
14
|
+
if (attribute?.type !== "JSXAttribute")
|
|
15
|
+
return null;
|
|
16
|
+
const { value } = attribute;
|
|
17
|
+
return value?.type === "JSXExpressionContainer" ? value.expression : null;
|
|
18
|
+
};
|
|
19
|
+
export const literalKind = (node) => {
|
|
20
|
+
if (node?.type === "ObjectExpression")
|
|
21
|
+
return "object";
|
|
22
|
+
if (node?.type === "ArrayExpression")
|
|
23
|
+
return "array";
|
|
24
|
+
return null;
|
|
25
|
+
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { gate, problem, subtreeHas } from "../../../lib/ast.js";
|
|
2
|
+
import { attributeNamed, expressionOf, hasSpread, isListElement, LIST } from "./shared.js";
|
|
3
|
+
const branchesOnItemType = (node) => subtreeHas(node, current => current.type === "MemberExpression" && current.property.type === "Identifier" && current.property.name === "type");
|
|
4
|
+
export const typedItemsNeedItemType = problem("Require `getItemType` when a row branches on `item.type`. Without it every layout shares one recycling pool and one size average.", {
|
|
5
|
+
createOnce(context) {
|
|
6
|
+
let rowRenderersByName;
|
|
7
|
+
let listsWithoutItemType;
|
|
8
|
+
const reportMissingItemTypeAtEndOfFile = () => {
|
|
9
|
+
for (const { node, renderItem } of listsWithoutItemType) {
|
|
10
|
+
const expression = expressionOf(renderItem);
|
|
11
|
+
const body = expression?.type === "Identifier" ? rowRenderersByName.get(expression.name) : expression;
|
|
12
|
+
if (body === undefined || body === null || !branchesOnItemType(body))
|
|
13
|
+
continue;
|
|
14
|
+
context.report({
|
|
15
|
+
node: node.openingElement,
|
|
16
|
+
message: "Add `getItemType={item => item.type}` to match the branch this row makes on `item.type`. It gives each layout its own recycling pool and its own measured-size average, instead of handing a header's view to a photo row.",
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
return {
|
|
21
|
+
before() {
|
|
22
|
+
rowRenderersByName = new Map();
|
|
23
|
+
listsWithoutItemType = [];
|
|
24
|
+
return gate(context, LIST);
|
|
25
|
+
},
|
|
26
|
+
FunctionDeclaration(node) {
|
|
27
|
+
if (node.id?.type === "Identifier")
|
|
28
|
+
rowRenderersByName.set(node.id.name, node);
|
|
29
|
+
},
|
|
30
|
+
VariableDeclarator(node) {
|
|
31
|
+
const { id, init } = node;
|
|
32
|
+
if (id.type !== "Identifier" || init === null)
|
|
33
|
+
return;
|
|
34
|
+
rowRenderersByName.set(id.name, init);
|
|
35
|
+
},
|
|
36
|
+
JSXElement(node) {
|
|
37
|
+
if (!isListElement(node) || hasSpread(node))
|
|
38
|
+
return;
|
|
39
|
+
if (attributeNamed(node, "getItemType"))
|
|
40
|
+
return;
|
|
41
|
+
const renderItem = attributeNamed(node, "renderItem");
|
|
42
|
+
if (!renderItem)
|
|
43
|
+
return;
|
|
44
|
+
listsWithoutItemType.push({ node, renderItem });
|
|
45
|
+
},
|
|
46
|
+
"Program:exit": reportMissingItemTypeAtEndOfFile,
|
|
47
|
+
};
|
|
48
|
+
},
|
|
49
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { defineModule } from "../../../lib/module.js";
|
|
2
|
+
import { naming } from "./naming.js";
|
|
3
|
+
import { noAssignment } from "./no-assignment.js";
|
|
4
|
+
import { noNestedObservable } from "./no-nested-observable.js";
|
|
5
|
+
import { noObjectSelector } from "./no-object-selector.js";
|
|
6
|
+
import { noObservableInComponent } from "./no-observable-in-component.js";
|
|
7
|
+
import { noPeekInSelector } from "./no-peek-in-selector.js";
|
|
8
|
+
import { noReactMirror } from "./no-react-mirror.js";
|
|
9
|
+
import { noUntrackedGetInJsx } from "./no-untracked-get-in-jsx.js";
|
|
10
|
+
export default defineModule({
|
|
11
|
+
meta: { name: "@ashstack/legend-state" },
|
|
12
|
+
url: import.meta.url,
|
|
13
|
+
packages: ["@legendapp/state"],
|
|
14
|
+
option: "legendState",
|
|
15
|
+
docsWhen: "auto-enabled when `@legendapp/state` is a dependency",
|
|
16
|
+
rules: {
|
|
17
|
+
"no-assignment": noAssignment,
|
|
18
|
+
naming: naming,
|
|
19
|
+
"no-nested-observable": noNestedObservable,
|
|
20
|
+
"no-react-mirror": noReactMirror,
|
|
21
|
+
"no-untracked-get-in-jsx": noUntrackedGetInJsx,
|
|
22
|
+
"no-peek-in-selector": noPeekInSelector,
|
|
23
|
+
"no-object-selector": noObjectSelector,
|
|
24
|
+
"no-observable-in-component": noObservableInComponent,
|
|
25
|
+
},
|
|
26
|
+
restrictedImports: {
|
|
27
|
+
paths: [
|
|
28
|
+
{
|
|
29
|
+
name: "@legendapp/state/react",
|
|
30
|
+
importNames: ["use$", "useSelector"],
|
|
31
|
+
message: "Use `useValue` from `@legendapp/state/react`; `use$` and `useSelector` are deprecated.",
|
|
32
|
+
},
|
|
33
|
+
],
|
|
34
|
+
},
|
|
35
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { gate, problem } from "../../../lib/ast.js";
|
|
2
|
+
import { factoryCalled, OBS } from "./shared.js";
|
|
3
|
+
export const naming = problem("A variable initialized from `observable()` or `useObservable()` needs a trailing `$`. The other rules in this module key off that suffix.", {
|
|
4
|
+
createOnce(context) {
|
|
5
|
+
return {
|
|
6
|
+
before() {
|
|
7
|
+
return gate(context, "observable", "useObservable");
|
|
8
|
+
},
|
|
9
|
+
VariableDeclarator(node) {
|
|
10
|
+
const { id, init } = node;
|
|
11
|
+
const factory = factoryCalled(init);
|
|
12
|
+
if (!factory || id.type !== "Identifier")
|
|
13
|
+
return;
|
|
14
|
+
const { name } = id;
|
|
15
|
+
if (OBS.test(name))
|
|
16
|
+
return;
|
|
17
|
+
context.report({
|
|
18
|
+
node: id,
|
|
19
|
+
message: name.includes("$")
|
|
20
|
+
? `Give this observable a trailing \`$\` and no other. The suffix is how a reader tells an observable from a plain value, and every other observable rule keys off it.`
|
|
21
|
+
: `Rename this to \`${name}$\`. The trailing \`$\` is how a reader tells an observable from a plain value, and every other observable rule keys off it.`,
|
|
22
|
+
});
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
},
|
|
26
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { gate, problem } from "../../../lib/ast.js";
|
|
2
|
+
import { isObservableRef, textOf } from "./shared.js";
|
|
3
|
+
export const noAssignment = problem("Write an observable with `.set(...)` or `.assign({...})`. Assigning or incrementing it with an operator is a silent no-op.", {
|
|
4
|
+
createOnce(context) {
|
|
5
|
+
return {
|
|
6
|
+
before() {
|
|
7
|
+
return gate(context, "$");
|
|
8
|
+
},
|
|
9
|
+
AssignmentExpression(node) {
|
|
10
|
+
const { left } = node;
|
|
11
|
+
if (!isObservableRef(left))
|
|
12
|
+
return;
|
|
13
|
+
const target = textOf(context, left);
|
|
14
|
+
context.report({
|
|
15
|
+
node,
|
|
16
|
+
message: `Write through \`${target}.set(...)\`, or \`.assign({...})\` to merge several fields. Assigning to an observable with \`${node.operator}\` replaces the observable in place of its value, so nothing downstream ever updates.`,
|
|
17
|
+
});
|
|
18
|
+
},
|
|
19
|
+
UpdateExpression(node) {
|
|
20
|
+
const { argument, operator } = node;
|
|
21
|
+
if (!isObservableRef(argument))
|
|
22
|
+
return;
|
|
23
|
+
const target = textOf(context, argument);
|
|
24
|
+
context.report({
|
|
25
|
+
node,
|
|
26
|
+
message: `Use \`${target}.set(v => v ${operator[0]} 1)\` — \`${operator}\` on an observable is a silent no-op.`,
|
|
27
|
+
});
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
},
|
|
31
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { gate, problem } from "../../../lib/ast.js";
|
|
2
|
+
import { factoryCalled, isObservableRef } from "./shared.js";
|
|
3
|
+
export const noNestedObservable = problem("Never pass an observable to `observable()` or `useObservable()`. The wrapper is a second node, and reads and writes on it never reach the original.", {
|
|
4
|
+
createOnce(context) {
|
|
5
|
+
return {
|
|
6
|
+
before() {
|
|
7
|
+
return gate(context, "observable", "useObservable");
|
|
8
|
+
},
|
|
9
|
+
CallExpression(node) {
|
|
10
|
+
const factory = factoryCalled(node);
|
|
11
|
+
if (!factory)
|
|
12
|
+
return;
|
|
13
|
+
const argument = node.arguments[0];
|
|
14
|
+
if (!argument || !isObservableRef(argument))
|
|
15
|
+
return;
|
|
16
|
+
context.report({
|
|
17
|
+
node: argument,
|
|
18
|
+
message: `Use the existing observable reference directly instead of passing it to \`${factory}()\`; wrapping it creates a second node whose reads and writes never reach the original.`,
|
|
19
|
+
});
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
},
|
|
23
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { FUNCTION_TYPES, crossesFunctionBefore, findInSubtree, gate, problem } from "../../../lib/ast.js";
|
|
2
|
+
const literalKind = (node) => {
|
|
3
|
+
if (node.type === "ObjectExpression")
|
|
4
|
+
return "object";
|
|
5
|
+
if (node.type === "ArrayExpression")
|
|
6
|
+
return "array";
|
|
7
|
+
return null;
|
|
8
|
+
};
|
|
9
|
+
/** What a selector hands back, whether written as an expression body or as a `return`. */
|
|
10
|
+
const returnedValue = (fn) => {
|
|
11
|
+
const { body } = fn.type === "ArrowFunctionExpression" || fn.type === "FunctionExpression" ? fn : { body: null };
|
|
12
|
+
if (body === null)
|
|
13
|
+
return null;
|
|
14
|
+
if (body.type !== "BlockStatement")
|
|
15
|
+
return body;
|
|
16
|
+
const found = findInSubtree(body, (current) => current.type === "ReturnStatement");
|
|
17
|
+
if (found?.type !== "ReturnStatement" || crossesFunctionBefore(found, body, FUNCTION_TYPES))
|
|
18
|
+
return null;
|
|
19
|
+
return found.argument;
|
|
20
|
+
};
|
|
21
|
+
export const noObjectSelector = problem("A `useValue` selector that builds a new object or array returns a fresh identity every run. The component then re-renders on every store change.", {
|
|
22
|
+
createOnce(context) {
|
|
23
|
+
return {
|
|
24
|
+
before() {
|
|
25
|
+
return gate(context, "useValue");
|
|
26
|
+
},
|
|
27
|
+
CallExpression(node) {
|
|
28
|
+
const { callee } = node;
|
|
29
|
+
if (callee.type !== "Identifier" || callee.name !== "useValue")
|
|
30
|
+
return;
|
|
31
|
+
const argument = node.arguments[0];
|
|
32
|
+
if (argument?.type !== "ArrowFunctionExpression" && argument?.type !== "FunctionExpression")
|
|
33
|
+
return;
|
|
34
|
+
const returned = returnedValue(argument);
|
|
35
|
+
if (returned === null)
|
|
36
|
+
return;
|
|
37
|
+
const kind = literalKind(returned);
|
|
38
|
+
if (kind === null)
|
|
39
|
+
return;
|
|
40
|
+
context.report({
|
|
41
|
+
node: returned,
|
|
42
|
+
message: `Return the primitive that decides the render from this selector, or call \`useValue\` once per field. A new ${kind} each run has a new identity, so the component re-renders on every store change.`,
|
|
43
|
+
});
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
},
|
|
47
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { COMPONENT_OR_HOOK, gate, isFunction, problem } from "../../../lib/ast.js";
|
|
2
|
+
export const noObservableInComponent = problem("Calling `observable()` inside a component or hook makes a new observable on every render. Use `useObservable()` or a module-level store instead.", {
|
|
3
|
+
createOnce(context) {
|
|
4
|
+
let componentDepth = 0;
|
|
5
|
+
const enter = (name) => {
|
|
6
|
+
if (name !== undefined && COMPONENT_OR_HOOK.test(name))
|
|
7
|
+
componentDepth++;
|
|
8
|
+
};
|
|
9
|
+
const exit = (name) => {
|
|
10
|
+
if (name !== undefined && COMPONENT_OR_HOOK.test(name))
|
|
11
|
+
componentDepth--;
|
|
12
|
+
};
|
|
13
|
+
const declaredName = (id) => id?.type === "Identifier" ? id.name : undefined;
|
|
14
|
+
return {
|
|
15
|
+
before() {
|
|
16
|
+
componentDepth = 0;
|
|
17
|
+
return gate(context, "observable");
|
|
18
|
+
},
|
|
19
|
+
FunctionDeclaration(node) {
|
|
20
|
+
enter(declaredName(node.id));
|
|
21
|
+
},
|
|
22
|
+
"FunctionDeclaration:exit"(node) {
|
|
23
|
+
exit(declaredName(node.id));
|
|
24
|
+
},
|
|
25
|
+
VariableDeclarator(node) {
|
|
26
|
+
if (isFunction(node.init))
|
|
27
|
+
enter(declaredName(node.id));
|
|
28
|
+
},
|
|
29
|
+
"VariableDeclarator:exit"(node) {
|
|
30
|
+
if (isFunction(node.init))
|
|
31
|
+
exit(declaredName(node.id));
|
|
32
|
+
},
|
|
33
|
+
CallExpression(node) {
|
|
34
|
+
if (componentDepth === 0)
|
|
35
|
+
return;
|
|
36
|
+
const { callee } = node;
|
|
37
|
+
if (callee.type !== "Identifier" || callee.name !== "observable")
|
|
38
|
+
return;
|
|
39
|
+
context.report({
|
|
40
|
+
node,
|
|
41
|
+
message: "Use `useObservable()` for component-lifetime state, or move this observable into a store in `src/stores` and import it. `observable()` inside a component makes a new observable every render, so nothing that read the previous one is listening.",
|
|
42
|
+
});
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
},
|
|
46
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { gate, isFunction, problem } from "../../../lib/ast.js";
|
|
2
|
+
import { isObservableRef, textOf } from "./shared.js";
|
|
3
|
+
const TRACKING_CONTEXT_CALLEES = new Set([
|
|
4
|
+
"useValue",
|
|
5
|
+
"observe",
|
|
6
|
+
"useObserve",
|
|
7
|
+
"useObserveEffect",
|
|
8
|
+
"when",
|
|
9
|
+
"whenReady",
|
|
10
|
+
]);
|
|
11
|
+
const trackingCallbackOf = (node) => {
|
|
12
|
+
const { callee } = node;
|
|
13
|
+
if (callee.type !== "Identifier")
|
|
14
|
+
return null;
|
|
15
|
+
if (!TRACKING_CONTEXT_CALLEES.has(callee.name))
|
|
16
|
+
return null;
|
|
17
|
+
const argument = node.arguments[0];
|
|
18
|
+
return argument && isFunction(argument) ? argument : null;
|
|
19
|
+
};
|
|
20
|
+
const peekedObservable = (callee) => {
|
|
21
|
+
if (callee.type !== "MemberExpression" || callee.computed)
|
|
22
|
+
return null;
|
|
23
|
+
if (callee.property.type !== "Identifier" || callee.property.name !== "peek")
|
|
24
|
+
return null;
|
|
25
|
+
const { object } = callee;
|
|
26
|
+
return isObservableRef(object) ? object : null;
|
|
27
|
+
};
|
|
28
|
+
export const noPeekInSelector = problem("`peek()` never subscribes, so a selector or tracking callback that uses it never re-runs. Call `get()` there and keep `peek()` for handlers.", {
|
|
29
|
+
createOnce(context) {
|
|
30
|
+
let trackingCallbacks = new WeakSet();
|
|
31
|
+
let depth = 0;
|
|
32
|
+
const enterIfTracking = (node) => {
|
|
33
|
+
if (trackingCallbacks.has(node))
|
|
34
|
+
depth++;
|
|
35
|
+
};
|
|
36
|
+
const exitIfTracking = (node) => {
|
|
37
|
+
if (trackingCallbacks.has(node))
|
|
38
|
+
depth--;
|
|
39
|
+
};
|
|
40
|
+
return {
|
|
41
|
+
before() {
|
|
42
|
+
trackingCallbacks = new WeakSet();
|
|
43
|
+
depth = 0;
|
|
44
|
+
return gate(context, ".peek()");
|
|
45
|
+
},
|
|
46
|
+
CallExpression(node) {
|
|
47
|
+
const callback = trackingCallbackOf(node);
|
|
48
|
+
if (callback)
|
|
49
|
+
trackingCallbacks.add(callback);
|
|
50
|
+
if (depth === 0)
|
|
51
|
+
return;
|
|
52
|
+
const observable = peekedObservable(node.callee);
|
|
53
|
+
if (!observable)
|
|
54
|
+
return;
|
|
55
|
+
const target = textOf(context, observable);
|
|
56
|
+
context.report({
|
|
57
|
+
node,
|
|
58
|
+
message: `Use \`${target}.get()\` inside this selector and keep \`peek()\` for handlers and async work. \`peek()\` never subscribes, so nothing re-runs when the value changes and the first read is the only one.`,
|
|
59
|
+
});
|
|
60
|
+
},
|
|
61
|
+
ArrowFunctionExpression: enterIfTracking,
|
|
62
|
+
"ArrowFunctionExpression:exit": exitIfTracking,
|
|
63
|
+
FunctionExpression: enterIfTracking,
|
|
64
|
+
"FunctionExpression:exit": exitIfTracking,
|
|
65
|
+
};
|
|
66
|
+
},
|
|
67
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { gate, problem } from "../../../lib/ast.js";
|
|
2
|
+
import { isObservableRef } from "./shared.js";
|
|
3
|
+
const OBSERVABLE_READS = new Set(["get", "peek"]);
|
|
4
|
+
const readsObservable = (node) => {
|
|
5
|
+
if (node?.type !== "CallExpression")
|
|
6
|
+
return false;
|
|
7
|
+
const { callee } = node;
|
|
8
|
+
if (callee.type !== "MemberExpression" || callee.computed)
|
|
9
|
+
return false;
|
|
10
|
+
if (callee.property.type !== "Identifier" || !OBSERVABLE_READS.has(callee.property.name))
|
|
11
|
+
return false;
|
|
12
|
+
return isObservableRef(callee.object);
|
|
13
|
+
};
|
|
14
|
+
export const noReactMirror = problem("Seeding `useState` from an observable's `get()` or `peek()` gives the value two owners. Read it with `useValue(...)` where it renders.", {
|
|
15
|
+
createOnce(context) {
|
|
16
|
+
return {
|
|
17
|
+
before() {
|
|
18
|
+
return gate(context, "useState");
|
|
19
|
+
},
|
|
20
|
+
CallExpression(node) {
|
|
21
|
+
const { callee } = node;
|
|
22
|
+
if (callee.type !== "Identifier" || callee.name !== "useState")
|
|
23
|
+
return;
|
|
24
|
+
if (!readsObservable(node.arguments[0]))
|
|
25
|
+
return;
|
|
26
|
+
context.report({
|
|
27
|
+
node,
|
|
28
|
+
message: "Drop this `useState` mirror and read the observable with `useValue(...)` where it renders, so the observable stays the single owner of the value.",
|
|
29
|
+
});
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
},
|
|
33
|
+
});
|