@hypersolid-dev/eslint-config 4.0.0 → 5.0.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/CHANGELOG.md +24 -0
- package/README.md +37 -6
- package/dist/base.js +52 -7
- package/dist/plugins/hypersolid.d.ts +3 -0
- package/dist/plugins/hypersolid.js +38 -0
- package/dist/plugins/props-destructuring.js +2 -1
- package/dist/plugins/react-native-a11y/index.js +4 -2
- package/dist/plugins/react-native-a11y/rules/accessibility-hint.js +4 -2
- package/dist/plugins/react-native-a11y/rules/accessibility-label.js +8 -4
- package/dist/plugins/react-native-a11y/rules/accessibility-role.js +4 -2
- package/dist/plugins/react-native-a11y/rules/gate-announce-for-accessibility.js +10 -6
- package/dist/plugins/react-native-a11y/rules/input-purpose.js +8 -4
- package/dist/plugins/react-native-a11y/rules/no-unnamed-interactive.js +4 -2
- package/dist/plugins/react-native-a11y/rules/require-invert-colors-opt-out.js +6 -4
- package/dist/plugins/react-native-a11y/rules/require-modal-a11y-on-sheets.js +5 -3
- package/dist/plugins/react-native-a11y/utils/get-amount-of-text-children.js +8 -4
- package/dist/plugins/react-native-a11y/utils/get-element-name.js +1 -1
- package/dist/plugins/rules/boolean-guard.d.ts +30 -0
- package/dist/plugins/rules/boolean-guard.js +143 -0
- package/dist/plugins/rules/comment-style.d.ts +26 -0
- package/dist/plugins/rules/comment-style.js +158 -0
- package/dist/plugins/rules/effect-takes-callback.d.ts +20 -0
- package/dist/plugins/rules/effect-takes-callback.js +106 -0
- package/dist/plugins/rules/jsdoc-for-props.d.ts +18 -0
- package/dist/plugins/rules/jsdoc-for-props.js +57 -0
- package/dist/plugins/rules/no-callback-payload.d.ts +21 -0
- package/dist/plugins/rules/no-callback-payload.js +214 -0
- package/dist/plugins/rules/no-markup-variable.d.ts +22 -0
- package/dist/plugins/rules/no-markup-variable.js +117 -0
- package/dist/plugins/rules/no-unnecessary-use-callback.d.ts +24 -0
- package/dist/plugins/rules/no-unnecessary-use-callback.js +117 -0
- package/dist/plugins/rules/regex-pattern-const.d.ts +19 -0
- package/dist/plugins/rules/regex-pattern-const.js +82 -0
- package/dist/plugins/rules/test-id-default.d.ts +4 -0
- package/dist/plugins/rules/test-id-default.js +186 -0
- package/dist/plugins/rules/test-id-format.d.ts +4 -0
- package/dist/plugins/rules/test-id-format.js +122 -0
- package/dist/plugins/rules/test-id-from-props.d.ts +4 -0
- package/dist/plugins/rules/test-id-from-props.js +94 -0
- package/dist/plugins/rules/undefined-check.d.ts +5 -0
- package/dist/plugins/rules/undefined-check.js +64 -0
- package/dist/plugins/rules/webview-allow-list.d.ts +5 -0
- package/dist/plugins/rules/webview-allow-list.js +109 -0
- package/dist/plugins/utils/comments.d.ts +15 -0
- package/dist/plugins/utils/comments.js +15 -0
- package/dist/plugins/utils/component-name.d.ts +11 -0
- package/dist/plugins/utils/component-name.js +32 -0
- package/dist/plugins/utils/create-rule.d.ts +5 -0
- package/dist/plugins/utils/create-rule.js +4 -0
- package/dist/plugins/utils/expressions.d.ts +52 -0
- package/dist/plugins/utils/expressions.js +274 -0
- package/dist/plugins/utils/types.d.ts +20 -0
- package/dist/plugins/utils/types.js +89 -0
- package/dist/react-native.js +65 -12
- package/dist/shared.d.ts +19 -0
- package/dist/shared.js +72 -1
- package/package.json +2 -2
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { AST_NODE_TYPES } from "@typescript-eslint/utils";
|
|
2
|
+
import createRule from "../utils/create-rule.js";
|
|
3
|
+
import { componentName, isWrapperCall } from "../utils/component-name.js";
|
|
4
|
+
const CALLBACK_PATTERN = /^on[A-Z]/;
|
|
5
|
+
/** The first parameter's bindings: each prop it destructures, and the props object itself or a rest of it. */
|
|
6
|
+
const propertyBindings = (parameter) => {
|
|
7
|
+
const props = new Set();
|
|
8
|
+
const objects = new Set();
|
|
9
|
+
const target = parameter?.type === AST_NODE_TYPES.AssignmentPattern ? parameter.left : parameter;
|
|
10
|
+
if (target?.type === AST_NODE_TYPES.Identifier) {
|
|
11
|
+
props.add(target);
|
|
12
|
+
objects.add(target);
|
|
13
|
+
}
|
|
14
|
+
if (target?.type === AST_NODE_TYPES.ObjectPattern) {
|
|
15
|
+
for (const property of target.properties) {
|
|
16
|
+
const value = property.type === AST_NODE_TYPES.RestElement ? property.argument : property.value;
|
|
17
|
+
const binding = value.type === AST_NODE_TYPES.AssignmentPattern ? value.left : value;
|
|
18
|
+
if (binding.type !== AST_NODE_TYPES.Identifier)
|
|
19
|
+
continue;
|
|
20
|
+
props.add(binding);
|
|
21
|
+
if (property.type === AST_NODE_TYPES.RestElement)
|
|
22
|
+
objects.add(binding);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return { props, objects };
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* A component forwards an event; the caller builds the payload.
|
|
29
|
+
*
|
|
30
|
+
* Reports, in an exported component (a function whose name starts with a
|
|
31
|
+
* capital, also inside `memo`/`forwardRef`), a call of one of its own `on*`
|
|
32
|
+
* props when every argument is built only from its props and literals and at
|
|
33
|
+
* least one prop is handed straight back (`onPress(teamId)`,
|
|
34
|
+
* `onPlay({ mediaId })`). Left alone: a value the component produced itself
|
|
35
|
+
* (an index it tracks, input text, `!isOn`), a callback called with different
|
|
36
|
+
* arguments in different places (the argument says which control was
|
|
37
|
+
* pressed), and components the file does not export.
|
|
38
|
+
*
|
|
39
|
+
* Props are the first parameter's bindings, resolved through scope, so a local
|
|
40
|
+
* that shadows a prop's name is the component's own value. Limits: a value
|
|
41
|
+
* derived into a local first (`const id = item.id`) is not followed.
|
|
42
|
+
*/
|
|
43
|
+
const noCallbackPayload = createRule({
|
|
44
|
+
name: "no-callback-payload",
|
|
45
|
+
meta: {
|
|
46
|
+
defaultOptions: [],
|
|
47
|
+
type: "suggestion",
|
|
48
|
+
docs: {
|
|
49
|
+
description: "A component forwards an event; the caller builds the payload from what it passed in.",
|
|
50
|
+
},
|
|
51
|
+
schema: [],
|
|
52
|
+
messages: {
|
|
53
|
+
payload: "`{{ callback }}` is called with values `{{ component }}` only received from its parent. Let the parent bind them where it passes `{{ callback }}`, and call it with what this component itself knows (or nothing), so the component stays free of the payload's domain shape.",
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
create(context) {
|
|
57
|
+
const { sourceCode } = context;
|
|
58
|
+
const exported = new Set();
|
|
59
|
+
const pending = [];
|
|
60
|
+
const check = (fn) => {
|
|
61
|
+
const component = componentName(fn);
|
|
62
|
+
if (!component)
|
|
63
|
+
return;
|
|
64
|
+
const { props, objects } = propertyBindings(fn.params[0]);
|
|
65
|
+
if (props.size === 0)
|
|
66
|
+
return;
|
|
67
|
+
/** Whether a name resolves, through scope, to one of these bindings rather than a local that shadows it. */
|
|
68
|
+
const isBoundTo = (identifier, bindings) => {
|
|
69
|
+
for (let scope = sourceCode.getScope(identifier); scope; scope = scope.upper) {
|
|
70
|
+
const variable = scope.set.get(identifier.name);
|
|
71
|
+
if (variable)
|
|
72
|
+
return variable.defs.some((definition) => bindings.has(definition.name));
|
|
73
|
+
}
|
|
74
|
+
return false;
|
|
75
|
+
};
|
|
76
|
+
/** Built only from props and literals: something the parent already holds. */
|
|
77
|
+
const isKnown = (node) => {
|
|
78
|
+
switch (node?.type) {
|
|
79
|
+
case AST_NODE_TYPES.Literal:
|
|
80
|
+
return true;
|
|
81
|
+
case AST_NODE_TYPES.Identifier:
|
|
82
|
+
return isBoundTo(node, props) || node.name === "undefined";
|
|
83
|
+
case AST_NODE_TYPES.TemplateLiteral:
|
|
84
|
+
return node.expressions.every(isKnown);
|
|
85
|
+
case AST_NODE_TYPES.MemberExpression:
|
|
86
|
+
return isKnown(node.object) && (!node.computed || isKnown(node.property));
|
|
87
|
+
case AST_NODE_TYPES.ObjectExpression:
|
|
88
|
+
return node.properties.every((property) => property.type === AST_NODE_TYPES.SpreadElement
|
|
89
|
+
? isKnown(property.argument)
|
|
90
|
+
: (!property.computed || isKnown(property.key)) && isKnown(property.value));
|
|
91
|
+
case AST_NODE_TYPES.ArrayExpression:
|
|
92
|
+
return node.elements.every((element) => element === null || isKnown(element));
|
|
93
|
+
case AST_NODE_TYPES.SpreadElement:
|
|
94
|
+
return isKnown(node.argument);
|
|
95
|
+
case AST_NODE_TYPES.LogicalExpression:
|
|
96
|
+
case AST_NODE_TYPES.BinaryExpression:
|
|
97
|
+
return isKnown(node.left) && isKnown(node.right);
|
|
98
|
+
case AST_NODE_TYPES.ConditionalExpression:
|
|
99
|
+
return isKnown(node.test) && isKnown(node.consequent) && isKnown(node.alternate);
|
|
100
|
+
case AST_NODE_TYPES.TSAsExpression:
|
|
101
|
+
case AST_NODE_TYPES.TSNonNullExpression:
|
|
102
|
+
case AST_NODE_TYPES.ChainExpression:
|
|
103
|
+
return isKnown(node.expression);
|
|
104
|
+
default:
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
/** A prop passed straight back: `teamId`, `item.id`. */
|
|
109
|
+
const isPropertyRead = (node) => (node.type === AST_NODE_TYPES.Identifier && isBoundTo(node, props)) ||
|
|
110
|
+
(node.type === AST_NODE_TYPES.MemberExpression && !node.computed && isPropertyRead(node.object));
|
|
111
|
+
/** A prop handed back as an argument, or as a value in an object or array argument. */
|
|
112
|
+
const isPassingPropertyUp = (node) => {
|
|
113
|
+
if (isPropertyRead(node))
|
|
114
|
+
return true;
|
|
115
|
+
if (node.type === AST_NODE_TYPES.ObjectExpression) {
|
|
116
|
+
return node.properties.some((property) => property.type === AST_NODE_TYPES.Property && isPropertyRead(property.value));
|
|
117
|
+
}
|
|
118
|
+
if (node.type === AST_NODE_TYPES.ArrayExpression) {
|
|
119
|
+
return node.elements.some((element) => element !== null && isPropertyRead(element));
|
|
120
|
+
}
|
|
121
|
+
return false;
|
|
122
|
+
};
|
|
123
|
+
const callbackName = (callee) => {
|
|
124
|
+
if (callee.type === AST_NODE_TYPES.Identifier && isBoundTo(callee, props) && CALLBACK_PATTERN.test(callee.name)) {
|
|
125
|
+
return callee.name;
|
|
126
|
+
}
|
|
127
|
+
if (callee.type === AST_NODE_TYPES.MemberExpression &&
|
|
128
|
+
!callee.computed &&
|
|
129
|
+
callee.object.type === AST_NODE_TYPES.Identifier &&
|
|
130
|
+
callee.property.type === AST_NODE_TYPES.Identifier &&
|
|
131
|
+
isBoundTo(callee.object, objects) &&
|
|
132
|
+
CALLBACK_PATTERN.test(callee.property.name)) {
|
|
133
|
+
return callee.property.name;
|
|
134
|
+
}
|
|
135
|
+
return undefined;
|
|
136
|
+
};
|
|
137
|
+
/** Every call of each callback prop, so one called two ways can be told apart. */
|
|
138
|
+
const calls = new Map();
|
|
139
|
+
const visit = (node) => {
|
|
140
|
+
if (!node || typeof node !== "object")
|
|
141
|
+
return;
|
|
142
|
+
const typed = node;
|
|
143
|
+
if (typed.type === AST_NODE_TYPES.CallExpression) {
|
|
144
|
+
const callback = callbackName(typed.callee);
|
|
145
|
+
if (callback)
|
|
146
|
+
calls.set(callback, [...(calls.get(callback) ?? []), typed]);
|
|
147
|
+
}
|
|
148
|
+
for (const [key, value] of Object.entries(node)) {
|
|
149
|
+
if (key === "parent")
|
|
150
|
+
continue;
|
|
151
|
+
if (Array.isArray(value)) {
|
|
152
|
+
for (const item of value)
|
|
153
|
+
visit(item);
|
|
154
|
+
}
|
|
155
|
+
else if (value && typeof value.type === "string")
|
|
156
|
+
visit(value);
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
visit(fn.body);
|
|
160
|
+
for (const [callback, nodes] of calls) {
|
|
161
|
+
const payloads = new Set(nodes.map((node) => node.arguments.map((argument) => sourceCode.getText(argument)).join(",")));
|
|
162
|
+
if (payloads.size > 1)
|
|
163
|
+
continue;
|
|
164
|
+
for (const node of nodes) {
|
|
165
|
+
const args = node.arguments;
|
|
166
|
+
if (args.every(isKnown) && args.some(isPassingPropertyUp)) {
|
|
167
|
+
pending.push({ node, data: { callback, component } });
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
return {
|
|
173
|
+
ArrowFunctionExpression: check,
|
|
174
|
+
FunctionDeclaration: check,
|
|
175
|
+
FunctionExpression: check,
|
|
176
|
+
ExportDefaultDeclaration(node) {
|
|
177
|
+
// `export default memo(Row)` exports `Row`, however many wrappers it is under.
|
|
178
|
+
let declaration = node.declaration;
|
|
179
|
+
while (isWrapperCall(declaration))
|
|
180
|
+
declaration = declaration.arguments.at(0);
|
|
181
|
+
if (declaration?.type === AST_NODE_TYPES.Identifier)
|
|
182
|
+
exported.add(declaration.name);
|
|
183
|
+
if ((declaration?.type === AST_NODE_TYPES.FunctionDeclaration ||
|
|
184
|
+
declaration?.type === AST_NODE_TYPES.FunctionExpression ||
|
|
185
|
+
declaration?.type === AST_NODE_TYPES.ClassDeclaration) &&
|
|
186
|
+
declaration.id) {
|
|
187
|
+
exported.add(declaration.id.name);
|
|
188
|
+
}
|
|
189
|
+
},
|
|
190
|
+
ExportNamedDeclaration(node) {
|
|
191
|
+
for (const specifier of node.specifiers) {
|
|
192
|
+
if (specifier.local.type === AST_NODE_TYPES.Identifier)
|
|
193
|
+
exported.add(specifier.local.name);
|
|
194
|
+
}
|
|
195
|
+
const { declaration } = node;
|
|
196
|
+
if (declaration?.type === AST_NODE_TYPES.FunctionDeclaration && declaration.id)
|
|
197
|
+
exported.add(declaration.id.name);
|
|
198
|
+
if (declaration?.type === AST_NODE_TYPES.VariableDeclaration) {
|
|
199
|
+
for (const declarator of declaration.declarations) {
|
|
200
|
+
if (declarator.id.type === AST_NODE_TYPES.Identifier)
|
|
201
|
+
exported.add(declarator.id.name);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
},
|
|
205
|
+
"Program:exit"() {
|
|
206
|
+
for (const { node, data } of pending) {
|
|
207
|
+
if (exported.has(data.component))
|
|
208
|
+
context.report({ node, messageId: "payload", data });
|
|
209
|
+
}
|
|
210
|
+
},
|
|
211
|
+
};
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
export default noCallbackPayload;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
type MessageIds = "extract" | "inline";
|
|
2
|
+
/**
|
|
3
|
+
* Styled markup built in a component's body is a component of its own.
|
|
4
|
+
*
|
|
5
|
+
* Reports a `const`/`let` declared directly in a component's body (a function
|
|
6
|
+
* whose name starts with a capital, also inside `memo`/`forwardRef`) whose
|
|
7
|
+
* value is JSX, or a ternary or `&&` yielding JSX, when that JSX contains an
|
|
8
|
+
* element with a `style` prop anywhere. A variable that only arranges
|
|
9
|
+
* components is left alone.
|
|
10
|
+
*
|
|
11
|
+
* A const holding unstyled JSX that is read once, as a child of the JSX it
|
|
12
|
+
* renders into, is reported too. Left alone: one read two or more times, one
|
|
13
|
+
* handed to a slot (`bar={bar}`), and a `let` reassigned per branch.
|
|
14
|
+
*
|
|
15
|
+
* Limits: markup returned from a helper function or a render prop is not
|
|
16
|
+
* followed, and markup styled only through a wrapper component's own props is
|
|
17
|
+
* not markup by this rule.
|
|
18
|
+
*/
|
|
19
|
+
declare const noMarkupVariable: import("@typescript-eslint/utils/ts-eslint").RuleModule<MessageIds, [], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
|
|
20
|
+
name: string;
|
|
21
|
+
};
|
|
22
|
+
export default noMarkupVariable;
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { AST_NODE_TYPES } from "@typescript-eslint/utils";
|
|
2
|
+
import createRule from "../utils/create-rule.js";
|
|
3
|
+
import { componentName } from "../utils/component-name.js";
|
|
4
|
+
/** The JSX a declarator's value draws: itself, or a branch of a ternary or `&&`. */
|
|
5
|
+
const drawnJsx = (init) => {
|
|
6
|
+
if (!init)
|
|
7
|
+
return [];
|
|
8
|
+
if (init.type === AST_NODE_TYPES.JSXElement || init.type === AST_NODE_TYPES.JSXFragment)
|
|
9
|
+
return [init];
|
|
10
|
+
if (init.type === AST_NODE_TYPES.ConditionalExpression) {
|
|
11
|
+
return [...drawnJsx(init.consequent), ...drawnJsx(init.alternate)];
|
|
12
|
+
}
|
|
13
|
+
if (init.type === AST_NODE_TYPES.LogicalExpression)
|
|
14
|
+
return drawnJsx(init.right);
|
|
15
|
+
return [];
|
|
16
|
+
};
|
|
17
|
+
/** Whether any element in the tree takes a `style` prop. */
|
|
18
|
+
const hasStyledElement = (node) => {
|
|
19
|
+
if (!node || typeof node !== "object")
|
|
20
|
+
return false;
|
|
21
|
+
const { type, name } = node;
|
|
22
|
+
if (type === AST_NODE_TYPES.JSXAttribute) {
|
|
23
|
+
return name?.type === AST_NODE_TYPES.JSXIdentifier && name.name === "style";
|
|
24
|
+
}
|
|
25
|
+
for (const [key, value] of Object.entries(node)) {
|
|
26
|
+
if (key === "parent")
|
|
27
|
+
continue;
|
|
28
|
+
if (Array.isArray(value) ? value.some(hasStyledElement) : hasStyledElement(value)) {
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return false;
|
|
33
|
+
};
|
|
34
|
+
const toComponentName = (name) => name.charAt(0).toUpperCase() + name.slice(1);
|
|
35
|
+
/**
|
|
36
|
+
* Styled markup built in a component's body is a component of its own.
|
|
37
|
+
*
|
|
38
|
+
* Reports a `const`/`let` declared directly in a component's body (a function
|
|
39
|
+
* whose name starts with a capital, also inside `memo`/`forwardRef`) whose
|
|
40
|
+
* value is JSX, or a ternary or `&&` yielding JSX, when that JSX contains an
|
|
41
|
+
* element with a `style` prop anywhere. A variable that only arranges
|
|
42
|
+
* components is left alone.
|
|
43
|
+
*
|
|
44
|
+
* A const holding unstyled JSX that is read once, as a child of the JSX it
|
|
45
|
+
* renders into, is reported too. Left alone: one read two or more times, one
|
|
46
|
+
* handed to a slot (`bar={bar}`), and a `let` reassigned per branch.
|
|
47
|
+
*
|
|
48
|
+
* Limits: markup returned from a helper function or a render prop is not
|
|
49
|
+
* followed, and markup styled only through a wrapper component's own props is
|
|
50
|
+
* not markup by this rule.
|
|
51
|
+
*/
|
|
52
|
+
const noMarkupVariable = createRule({
|
|
53
|
+
name: "no-markup-variable",
|
|
54
|
+
meta: {
|
|
55
|
+
defaultOptions: [],
|
|
56
|
+
type: "suggestion",
|
|
57
|
+
docs: {
|
|
58
|
+
description: "Styled markup built in a component's body is a module-level component of its own.",
|
|
59
|
+
},
|
|
60
|
+
schema: [],
|
|
61
|
+
messages: {
|
|
62
|
+
extract: "`{{ name }}` holds styled markup built while `{{ component }}` renders. Make it a component above this one (for example `{{ suggestion }}`) and render that instead: a named unit with its own props can be memoised, tested and read on its own.",
|
|
63
|
+
inline: "`{{ name }}` holds JSX that `{{ component }}` reads only once. Write it where it is rendered.",
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
create(context) {
|
|
67
|
+
const check = (fn) => {
|
|
68
|
+
const component = componentName(fn);
|
|
69
|
+
if (!component || fn.body.type !== AST_NODE_TYPES.BlockStatement)
|
|
70
|
+
return;
|
|
71
|
+
for (const statement of fn.body.body) {
|
|
72
|
+
if (statement.type !== AST_NODE_TYPES.VariableDeclaration)
|
|
73
|
+
continue;
|
|
74
|
+
for (const declarator of statement.declarations) {
|
|
75
|
+
if (declarator.id.type !== AST_NODE_TYPES.Identifier)
|
|
76
|
+
continue;
|
|
77
|
+
const jsx = drawnJsx(declarator.init);
|
|
78
|
+
if (jsx.length === 0)
|
|
79
|
+
continue;
|
|
80
|
+
if (!jsx.some(hasStyledElement)) {
|
|
81
|
+
const variable = context.sourceCode.getDeclaredVariables(declarator).at(0);
|
|
82
|
+
const references = variable?.references ?? [];
|
|
83
|
+
const reads = references.filter((ref) => ref.isRead());
|
|
84
|
+
const isReassigned = references.some((ref) => ref.isWrite() && ref.identifier !== declarator.id);
|
|
85
|
+
const container = reads.at(0)?.identifier.parent;
|
|
86
|
+
if (reads.length === 1 &&
|
|
87
|
+
!isReassigned &&
|
|
88
|
+
container?.type === AST_NODE_TYPES.JSXExpressionContainer &&
|
|
89
|
+
container.parent.type !== AST_NODE_TYPES.JSXAttribute) {
|
|
90
|
+
context.report({
|
|
91
|
+
node: declarator.id,
|
|
92
|
+
messageId: "inline",
|
|
93
|
+
data: { name: declarator.id.name, component },
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
context.report({
|
|
99
|
+
node: declarator.id,
|
|
100
|
+
messageId: "extract",
|
|
101
|
+
data: {
|
|
102
|
+
name: declarator.id.name,
|
|
103
|
+
component,
|
|
104
|
+
suggestion: `${component}${toComponentName(declarator.id.name)}`,
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
return {
|
|
111
|
+
ArrowFunctionExpression: check,
|
|
112
|
+
FunctionDeclaration: check,
|
|
113
|
+
FunctionExpression: check,
|
|
114
|
+
};
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
export default noMarkupVariable;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { TSESLint } from "@typescript-eslint/utils";
|
|
2
|
+
/**
|
|
3
|
+
* A `useCallback` that captures nothing from the render is a module-level
|
|
4
|
+
* function written the slow way.
|
|
5
|
+
*
|
|
6
|
+
* Reports a call to React's `useCallback` (imported by name under any local
|
|
7
|
+
* name, or as `React.useCallback`/`React["useCallback"]` through a default or
|
|
8
|
+
* namespace import) with exactly a function expression and an empty dependency
|
|
9
|
+
* array literal, where the function references nothing declared inside a
|
|
10
|
+
* component or hook. Module-level values, imports and globals are not captures.
|
|
11
|
+
* There is no fixer: where the function should go is the author's call.
|
|
12
|
+
*
|
|
13
|
+
* Leaves alone a non-empty or variable dependency list, a `useCallback` not
|
|
14
|
+
* imported from `react`, a callback passed by reference, and a callback using
|
|
15
|
+
* `this`, `super`, `arguments`, `new.target` or `eval`. A consumer that needs
|
|
16
|
+
* one identity per instance (a `Set` subscriber) states it in a scoped
|
|
17
|
+
* suppression.
|
|
18
|
+
*
|
|
19
|
+
* Limits: only the empty-array form is judged; `useMemo(() => fn, [])` is not.
|
|
20
|
+
*/
|
|
21
|
+
declare const noUnnecessaryUseCallback: TSESLint.RuleModule<"unnecessary", [], unknown, TSESLint.RuleListener> & {
|
|
22
|
+
name: string;
|
|
23
|
+
};
|
|
24
|
+
export default noUnnecessaryUseCallback;
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { AST_NODE_TYPES, TSESLint } from "@typescript-eslint/utils";
|
|
2
|
+
import createRule from "../utils/create-rule.js";
|
|
3
|
+
const HAZARD_NAMES = new Set(["arguments", "eval"]);
|
|
4
|
+
/** The import specifier a name resolves to, when it is a value import from `react`. */
|
|
5
|
+
const reactImport = (sourceCode, identifier) => {
|
|
6
|
+
for (let scope = sourceCode.getScope(identifier); scope; scope = scope.upper) {
|
|
7
|
+
const variable = scope.set.get(identifier.name);
|
|
8
|
+
if (!variable)
|
|
9
|
+
continue;
|
|
10
|
+
const definition = variable.defs.at(0);
|
|
11
|
+
if (definition?.type !== TSESLint.Scope.DefinitionType.ImportBinding || definition.parent.type !== AST_NODE_TYPES.ImportDeclaration)
|
|
12
|
+
return;
|
|
13
|
+
const declaration = definition.parent;
|
|
14
|
+
const specifier = definition.node;
|
|
15
|
+
const isTypeOnly = declaration.importKind === "type" ||
|
|
16
|
+
(specifier.type === AST_NODE_TYPES.ImportSpecifier && specifier.importKind === "type");
|
|
17
|
+
return declaration.source.value === "react" && !isTypeOnly ? specifier : undefined;
|
|
18
|
+
}
|
|
19
|
+
return;
|
|
20
|
+
};
|
|
21
|
+
const isUseCallback = (sourceCode, callee) => {
|
|
22
|
+
if (callee.type === AST_NODE_TYPES.Identifier) {
|
|
23
|
+
const imported = reactImport(sourceCode, callee);
|
|
24
|
+
if (imported?.type !== AST_NODE_TYPES.ImportSpecifier)
|
|
25
|
+
return false;
|
|
26
|
+
const name = imported.imported.type === AST_NODE_TYPES.Identifier ? imported.imported.name : imported.imported.value;
|
|
27
|
+
return name === "useCallback";
|
|
28
|
+
}
|
|
29
|
+
if (callee.type !== AST_NODE_TYPES.MemberExpression || callee.object.type !== AST_NODE_TYPES.Identifier)
|
|
30
|
+
return false;
|
|
31
|
+
const { property } = callee;
|
|
32
|
+
const isNamed = callee.computed
|
|
33
|
+
? property.type === AST_NODE_TYPES.Literal && property.value === "useCallback"
|
|
34
|
+
: property.type === AST_NODE_TYPES.Identifier && property.name === "useCallback";
|
|
35
|
+
if (!isNamed)
|
|
36
|
+
return false;
|
|
37
|
+
const imported = reactImport(sourceCode, callee.object);
|
|
38
|
+
return imported?.type === AST_NODE_TYPES.ImportDefaultSpecifier || imported?.type === AST_NODE_TYPES.ImportNamespaceSpecifier;
|
|
39
|
+
};
|
|
40
|
+
/** Whether moving the function out would change what `this`, `super`, `arguments`, `new.target` or `eval` mean. */
|
|
41
|
+
const hasLexicalHazard = (sourceCode, node) => {
|
|
42
|
+
if (node.type === AST_NODE_TYPES.ThisExpression ||
|
|
43
|
+
node.type === AST_NODE_TYPES.Super ||
|
|
44
|
+
node.type === AST_NODE_TYPES.MetaProperty ||
|
|
45
|
+
(node.type === AST_NODE_TYPES.Identifier && HAZARD_NAMES.has(node.name))) {
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
const record = node;
|
|
49
|
+
return (sourceCode.visitorKeys[node.type] ?? []).some((key) => {
|
|
50
|
+
const value = record[key];
|
|
51
|
+
const children = Array.isArray(value) ? value : [value];
|
|
52
|
+
return children.some((child) => Boolean(child) && hasLexicalHazard(sourceCode, child));
|
|
53
|
+
});
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* A `useCallback` that captures nothing from the render is a module-level
|
|
57
|
+
* function written the slow way.
|
|
58
|
+
*
|
|
59
|
+
* Reports a call to React's `useCallback` (imported by name under any local
|
|
60
|
+
* name, or as `React.useCallback`/`React["useCallback"]` through a default or
|
|
61
|
+
* namespace import) with exactly a function expression and an empty dependency
|
|
62
|
+
* array literal, where the function references nothing declared inside a
|
|
63
|
+
* component or hook. Module-level values, imports and globals are not captures.
|
|
64
|
+
* There is no fixer: where the function should go is the author's call.
|
|
65
|
+
*
|
|
66
|
+
* Leaves alone a non-empty or variable dependency list, a `useCallback` not
|
|
67
|
+
* imported from `react`, a callback passed by reference, and a callback using
|
|
68
|
+
* `this`, `super`, `arguments`, `new.target` or `eval`. A consumer that needs
|
|
69
|
+
* one identity per instance (a `Set` subscriber) states it in a scoped
|
|
70
|
+
* suppression.
|
|
71
|
+
*
|
|
72
|
+
* Limits: only the empty-array form is judged; `useMemo(() => fn, [])` is not.
|
|
73
|
+
*/
|
|
74
|
+
const noUnnecessaryUseCallback = createRule({
|
|
75
|
+
name: "no-unnecessary-use-callback",
|
|
76
|
+
meta: {
|
|
77
|
+
defaultOptions: [],
|
|
78
|
+
type: "suggestion",
|
|
79
|
+
docs: {
|
|
80
|
+
description: "A `useCallback` with an empty dependency array that captures no render-local value is a module-level function written the slow way.",
|
|
81
|
+
},
|
|
82
|
+
schema: [],
|
|
83
|
+
messages: {
|
|
84
|
+
unnecessary: "This callback captures no render-local values. Use an existing function or define it at module scope, unless a proven per-instance identity contract requires memoization; explain that contract in a scoped suppression.",
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
create(context) {
|
|
88
|
+
const { sourceCode } = context;
|
|
89
|
+
return {
|
|
90
|
+
CallExpression(node) {
|
|
91
|
+
if (!isUseCallback(sourceCode, node.callee))
|
|
92
|
+
return;
|
|
93
|
+
const [callback, dependencies] = [node.arguments.at(0), node.arguments.at(1)];
|
|
94
|
+
if (node.arguments.length !== 2 ||
|
|
95
|
+
!callback ||
|
|
96
|
+
(callback.type !== AST_NODE_TYPES.ArrowFunctionExpression && callback.type !== AST_NODE_TYPES.FunctionExpression) ||
|
|
97
|
+
dependencies?.type !== AST_NODE_TYPES.ArrayExpression ||
|
|
98
|
+
dependencies.elements.length > 0 ||
|
|
99
|
+
hasLexicalHazard(sourceCode, callback)) {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
const [start, end] = callback.range;
|
|
103
|
+
const hasCapture = sourceCode.getScope(callback).through.some((reference) => {
|
|
104
|
+
const resolved = reference.resolved;
|
|
105
|
+
if (!resolved)
|
|
106
|
+
return false;
|
|
107
|
+
const { type, block } = resolved.scope;
|
|
108
|
+
const isOwn = block.range[0] >= start && block.range[1] <= end;
|
|
109
|
+
return type !== TSESLint.Scope.ScopeType.module && type !== TSESLint.Scope.ScopeType.global && !isOwn;
|
|
110
|
+
});
|
|
111
|
+
if (!hasCapture)
|
|
112
|
+
context.report({ node, messageId: "unnecessary" });
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
export default noUnnecessaryUseCallback;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A regular expression lives in a module-level `const <NAME>_PATTERN`, so its name says what it matches and it is built once.
|
|
3
|
+
*
|
|
4
|
+
* Reports a regex literal, or a `RegExp(...)` / `new RegExp(...)` whose
|
|
5
|
+
* arguments are all static (string literals, templates or `String.raw` with
|
|
6
|
+
* nothing interpolated). Anywhere but the initialiser of a module-level `const`
|
|
7
|
+
* it is `hoist`; there, with a name not SCREAMING_SNAKE_CASE ending in
|
|
8
|
+
* `_PATTERN`, it is `rename`. A cast or `satisfies` around it is looked through.
|
|
9
|
+
*
|
|
10
|
+
* Accepts a `RegExp` built from a runtime value, `RegExp()` with no argument,
|
|
11
|
+
* and a string that merely looks like a regex.
|
|
12
|
+
*
|
|
13
|
+
* Limits: `RegExp` is matched by its global name, so an alias or a pattern
|
|
14
|
+
* built from concatenated strings is not seen.
|
|
15
|
+
*/
|
|
16
|
+
declare const _default: import("@typescript-eslint/utils/ts-eslint").RuleModule<"hoist" | "rename", never[], unknown, import("@typescript-eslint/utils/ts-eslint").RuleListener> & {
|
|
17
|
+
name: string;
|
|
18
|
+
};
|
|
19
|
+
export default _default;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { AST_NODE_TYPES } from "@typescript-eslint/utils";
|
|
2
|
+
import createRule from "../utils/create-rule.js";
|
|
3
|
+
const NAME_PATTERN = /^[A-Z][A-Z0-9_]*_PATTERN$/;
|
|
4
|
+
/** A string literal, or a template (plain or `String.raw`) with nothing interpolated. */
|
|
5
|
+
const isStatic = (node) => (node.type === AST_NODE_TYPES.Literal && typeof node.value === "string") ||
|
|
6
|
+
(node.type === AST_NODE_TYPES.TemplateLiteral && node.expressions.length === 0) ||
|
|
7
|
+
(node.type === AST_NODE_TYPES.TaggedTemplateExpression &&
|
|
8
|
+
node.tag.type === AST_NODE_TYPES.MemberExpression &&
|
|
9
|
+
!node.tag.computed &&
|
|
10
|
+
node.tag.object.type === AST_NODE_TYPES.Identifier &&
|
|
11
|
+
node.tag.object.name === "String" &&
|
|
12
|
+
node.tag.property.type === AST_NODE_TYPES.Identifier &&
|
|
13
|
+
node.tag.property.name === "raw" &&
|
|
14
|
+
node.quasi.expressions.length === 0);
|
|
15
|
+
const isRegex = (node) => node.type === AST_NODE_TYPES.Literal
|
|
16
|
+
? "regex" in node
|
|
17
|
+
: node.callee.type === AST_NODE_TYPES.Identifier &&
|
|
18
|
+
node.callee.name === "RegExp" &&
|
|
19
|
+
node.arguments.length > 0 &&
|
|
20
|
+
node.arguments.every(isStatic);
|
|
21
|
+
/** The `const X = …` holding this regex at module level, if that is where it sits. */
|
|
22
|
+
const moduleConst = (node) => {
|
|
23
|
+
let held = node;
|
|
24
|
+
while ((held.parent?.type === AST_NODE_TYPES.TSAsExpression ||
|
|
25
|
+
held.parent?.type === AST_NODE_TYPES.TSSatisfiesExpression ||
|
|
26
|
+
held.parent?.type === AST_NODE_TYPES.TSNonNullExpression) &&
|
|
27
|
+
held.parent.expression === held) {
|
|
28
|
+
held = held.parent;
|
|
29
|
+
}
|
|
30
|
+
const declarator = held.parent;
|
|
31
|
+
if (declarator?.type !== AST_NODE_TYPES.VariableDeclarator || declarator.init !== held)
|
|
32
|
+
return undefined;
|
|
33
|
+
if (declarator.id.type !== AST_NODE_TYPES.Identifier)
|
|
34
|
+
return undefined;
|
|
35
|
+
const declaration = declarator.parent;
|
|
36
|
+
const holder = declaration.parent;
|
|
37
|
+
const isAtTop = holder.type === AST_NODE_TYPES.Program || holder.type === AST_NODE_TYPES.ExportNamedDeclaration;
|
|
38
|
+
return declaration.kind === "const" && isAtTop ? declarator.id : undefined;
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* A regular expression lives in a module-level `const <NAME>_PATTERN`, so its name says what it matches and it is built once.
|
|
42
|
+
*
|
|
43
|
+
* Reports a regex literal, or a `RegExp(...)` / `new RegExp(...)` whose
|
|
44
|
+
* arguments are all static (string literals, templates or `String.raw` with
|
|
45
|
+
* nothing interpolated). Anywhere but the initialiser of a module-level `const`
|
|
46
|
+
* it is `hoist`; there, with a name not SCREAMING_SNAKE_CASE ending in
|
|
47
|
+
* `_PATTERN`, it is `rename`. A cast or `satisfies` around it is looked through.
|
|
48
|
+
*
|
|
49
|
+
* Accepts a `RegExp` built from a runtime value, `RegExp()` with no argument,
|
|
50
|
+
* and a string that merely looks like a regex.
|
|
51
|
+
*
|
|
52
|
+
* Limits: `RegExp` is matched by its global name, so an alias or a pattern
|
|
53
|
+
* built from concatenated strings is not seen.
|
|
54
|
+
*/
|
|
55
|
+
export default createRule({
|
|
56
|
+
name: "regex-pattern-const",
|
|
57
|
+
meta: {
|
|
58
|
+
defaultOptions: [],
|
|
59
|
+
type: "suggestion",
|
|
60
|
+
docs: { description: "Declare regular expressions as module-level `const <NAME>_PATTERN`." },
|
|
61
|
+
schema: [],
|
|
62
|
+
messages: {
|
|
63
|
+
hoist: "Move this regular expression to a module-level `const <NAME>_PATTERN` named for what it matches.",
|
|
64
|
+
rename: "Rename `{{name}}` to end in `_PATTERN` (SCREAMING_SNAKE_CASE), so the name says it is a regular expression.",
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
create(context) {
|
|
68
|
+
const check = (node) => {
|
|
69
|
+
if (!isRegex(node))
|
|
70
|
+
return;
|
|
71
|
+
const id = moduleConst(node);
|
|
72
|
+
if (!id) {
|
|
73
|
+
context.report({ node, messageId: "hoist" });
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (!NAME_PATTERN.test(id.name)) {
|
|
77
|
+
context.report({ node: id, messageId: "rename", data: { name: id.name } });
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
return { Literal: check, NewExpression: check, CallExpression: check };
|
|
81
|
+
},
|
|
82
|
+
});
|