eslint-plugin-absolute 0.0.1
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/.prettierignore +4 -0
- package/.prettierrc.json +8 -0
- package/LICENSE +23 -0
- package/README.md +3 -0
- package/dist/index.js +1882 -0
- package/package.json +29 -0
- package/src/index.js +47 -0
- package/src/rules/explicit-object-types.js +54 -0
- package/src/rules/inline-style-limit.js +77 -0
- package/src/rules/localize-react-props.js +418 -0
- package/src/rules/max-depth-extended.js +115 -0
- package/src/rules/max-jsx-nesting.js +60 -0
- package/src/rules/min-var-length.js +300 -0
- package/src/rules/no-button-navigation.js +114 -0
- package/src/rules/no-explicit-return-types.js +64 -0
- package/src/rules/no-inline-prop-types.js +55 -0
- package/src/rules/no-multi-style-objects.js +70 -0
- package/src/rules/no-nested-jsx-return.js +154 -0
- package/src/rules/no-or-none-component.js +50 -0
- package/src/rules/no-transition-cssproperties.js +102 -0
- package/src/rules/no-type-cast.js +50 -0
- package/src/rules/no-unnecessary-div.js +40 -0
- package/src/rules/no-unnecessary-key.js +128 -0
- package/src/rules/no-useless-function.js +43 -0
- package/src/rules/seperate-style-files.js +62 -0
- package/src/rules/sort-exports.js +397 -0
- package/src/rules/sort-keys-fixable.js +375 -0
- package/src/rules/spring-naming-convention.js +111 -0
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "eslint-plugin-absolute",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "ESLint plugin for AbsoluteJS",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "https://github.com/absolutejs/eslint-plugin-absolute.git"
|
|
8
|
+
},
|
|
9
|
+
"type": "module",
|
|
10
|
+
"main": "./dist/index.js",
|
|
11
|
+
"license": "CC BY-NC 4.0",
|
|
12
|
+
"author": "Alex Kahn",
|
|
13
|
+
"scripts": {
|
|
14
|
+
"test": "echo \"Error: no test specified\" && exit 1",
|
|
15
|
+
"build": "rm -rf dist && bun build src/index.js --outdir dist --splitting --target=bun",
|
|
16
|
+
"format": "prettier --write \"./**/*.{js,jsx,ts,tsx,css,json,mjs}\"",
|
|
17
|
+
"dev": "bun run --watch example/server.ts",
|
|
18
|
+
"release": "bun run format && bun run build && bun publish",
|
|
19
|
+
"prune": "ts-prune --error",
|
|
20
|
+
"type-check": "bun run tsc --noEmit"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"eslint": "9.23.0",
|
|
24
|
+
"prettier": "3.5.3",
|
|
25
|
+
"ts-prune": "0.10.3",
|
|
26
|
+
"typescript": "5.8.2",
|
|
27
|
+
"typescript-eslint": "8.28.0"
|
|
28
|
+
}
|
|
29
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import noNestedJsxReturn from "./rules/no-nested-jsx-return.js";
|
|
2
|
+
import explicitObjectTypes from "./rules/explicit-object-types.js";
|
|
3
|
+
import noTypeCast from "./rules/no-type-cast.js";
|
|
4
|
+
import sortKeysFixable from "./rules/sort-keys-fixable.js";
|
|
5
|
+
import noTransitionCssproperties from "./rules/no-transition-cssproperties.js";
|
|
6
|
+
import noExplicitReturnTypes from "./rules/no-explicit-return-types.js";
|
|
7
|
+
import maxJsxNesting from "./rules/max-jsx-nesting.js";
|
|
8
|
+
import seperateStyleFiles from "./rules/seperate-style-files.js";
|
|
9
|
+
import noUnnecessaryKey from "./rules/no-unnecessary-key.js";
|
|
10
|
+
import sortExports from "./rules/sort-exports.js";
|
|
11
|
+
import localizeReactProps from "./rules/localize-react-props.js";
|
|
12
|
+
import noOrNoneComponent from "./rules/no-or-none-component.js";
|
|
13
|
+
import noButtonNavigation from "./rules/no-button-navigation.js";
|
|
14
|
+
import noMultiStyleObjects from "./rules/no-multi-style-objects.js";
|
|
15
|
+
import noUselessFunction from "./rules/no-useless-function.js";
|
|
16
|
+
import minVarLength from "./rules/min-var-length.js";
|
|
17
|
+
import maxDepthExtended from "./rules/max-depth-extended.js";
|
|
18
|
+
import springNamingConvention from "./rules/spring-naming-convention.js";
|
|
19
|
+
import inlineStyleLimit from "./rules/inline-style-limit.js";
|
|
20
|
+
import noInlinePropTypes from "./rules/no-inline-prop-types.js";
|
|
21
|
+
import noUnnecessaryDiv from "./rules/no-unnecessary-div.js";
|
|
22
|
+
|
|
23
|
+
export default {
|
|
24
|
+
rules: {
|
|
25
|
+
"no-nested-jsx-return": noNestedJsxReturn,
|
|
26
|
+
"explicit-object-types": explicitObjectTypes,
|
|
27
|
+
"no-type-cast": noTypeCast,
|
|
28
|
+
"sort-keys-fixable": sortKeysFixable,
|
|
29
|
+
"no-transition-cssproperties": noTransitionCssproperties,
|
|
30
|
+
"no-explicit-return-type": noExplicitReturnTypes,
|
|
31
|
+
"max-jsxnesting": maxJsxNesting,
|
|
32
|
+
"seperate-style-files": seperateStyleFiles,
|
|
33
|
+
"no-unnecessary-key": noUnnecessaryKey,
|
|
34
|
+
"sort-exports": sortExports,
|
|
35
|
+
"localize-react-props": localizeReactProps,
|
|
36
|
+
"no-or-none-component": noOrNoneComponent,
|
|
37
|
+
"no-button-navigation": noButtonNavigation,
|
|
38
|
+
"no-multi-style-objects": noMultiStyleObjects,
|
|
39
|
+
"no-useless-function": noUselessFunction,
|
|
40
|
+
"min-var-length": minVarLength,
|
|
41
|
+
"max-depth-extended": maxDepthExtended,
|
|
42
|
+
"spring-naming-convention": springNamingConvention,
|
|
43
|
+
"inline-style-limit": inlineStyleLimit,
|
|
44
|
+
"no-inline-prop-types": noInlinePropTypes,
|
|
45
|
+
"no-unnecessary-div": noUnnecessaryDiv
|
|
46
|
+
}
|
|
47
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export default {
|
|
2
|
+
meta: {
|
|
3
|
+
type: "problem",
|
|
4
|
+
docs: {
|
|
5
|
+
description:
|
|
6
|
+
"Require explicit type annotations for object literals and arrays of object literals",
|
|
7
|
+
recommended: false
|
|
8
|
+
},
|
|
9
|
+
schema: []
|
|
10
|
+
},
|
|
11
|
+
create(context) {
|
|
12
|
+
/**
|
|
13
|
+
* Returns true if the node is an object literal.
|
|
14
|
+
* @param {ASTNode} node The AST node to check.
|
|
15
|
+
*/
|
|
16
|
+
function isObjectLiteral(node) {
|
|
17
|
+
return node && node.type === "ObjectExpression";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return {
|
|
21
|
+
VariableDeclarator(node) {
|
|
22
|
+
// Skip if there's no initializer.
|
|
23
|
+
if (!node.init) return;
|
|
24
|
+
|
|
25
|
+
// Skip if the variable already has a type annotation.
|
|
26
|
+
if (node.id && node.id.typeAnnotation) return;
|
|
27
|
+
|
|
28
|
+
// Check if the initializer is an object literal.
|
|
29
|
+
if (isObjectLiteral(node.init)) {
|
|
30
|
+
context.report({
|
|
31
|
+
node: node.id,
|
|
32
|
+
message:
|
|
33
|
+
"Object literal must have an explicit type annotation."
|
|
34
|
+
});
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Check if the initializer is an array literal containing any object literals.
|
|
39
|
+
if (node.init.type === "ArrayExpression") {
|
|
40
|
+
const hasObjectLiteral = node.init.elements.some(
|
|
41
|
+
(element) => element && isObjectLiteral(element)
|
|
42
|
+
);
|
|
43
|
+
if (hasObjectLiteral) {
|
|
44
|
+
context.report({
|
|
45
|
+
node: node.id,
|
|
46
|
+
message:
|
|
47
|
+
"Array of object literals must have an explicit type annotation."
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
export default {
|
|
2
|
+
meta: {
|
|
3
|
+
type: "suggestion",
|
|
4
|
+
docs: {
|
|
5
|
+
description:
|
|
6
|
+
"Disallow inline style objects with too many keys and encourage extracting them",
|
|
7
|
+
category: "Best Practices",
|
|
8
|
+
recommended: false
|
|
9
|
+
},
|
|
10
|
+
schema: [
|
|
11
|
+
{
|
|
12
|
+
anyOf: [
|
|
13
|
+
{
|
|
14
|
+
type: "number"
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
type: "object",
|
|
18
|
+
properties: {
|
|
19
|
+
maxKeys: {
|
|
20
|
+
type: "number",
|
|
21
|
+
description:
|
|
22
|
+
"Maximum number of keys allowed in an inline style object before it must be extracted."
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
additionalProperties: false
|
|
26
|
+
}
|
|
27
|
+
]
|
|
28
|
+
}
|
|
29
|
+
],
|
|
30
|
+
messages: {
|
|
31
|
+
extractStyle:
|
|
32
|
+
"Inline style objects should be extracted into a separate object or file when containing more than {{max}} keys."
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
|
|
36
|
+
create(context) {
|
|
37
|
+
const option = context.options[0];
|
|
38
|
+
// If a number is passed directly, use it as maxKeys; otherwise, extract maxKeys from the object (default to 3)
|
|
39
|
+
const maxKeys =
|
|
40
|
+
typeof option === "number"
|
|
41
|
+
? option
|
|
42
|
+
: (option && option.maxKeys) || 3;
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
JSXAttribute(node) {
|
|
46
|
+
// Check if the attribute name is 'style'
|
|
47
|
+
if (node.name.name !== "style") {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Ensure the value is a JSX expression container with an object literal
|
|
52
|
+
if (
|
|
53
|
+
node.value &&
|
|
54
|
+
node.value.type === "JSXExpressionContainer" &&
|
|
55
|
+
node.value.expression &&
|
|
56
|
+
node.value.expression.type === "ObjectExpression"
|
|
57
|
+
) {
|
|
58
|
+
const styleObject = node.value.expression;
|
|
59
|
+
|
|
60
|
+
// Count only "Property" nodes (ignoring spread elements or others)
|
|
61
|
+
const keyCount = styleObject.properties.filter(
|
|
62
|
+
(prop) => prop.type === "Property"
|
|
63
|
+
).length;
|
|
64
|
+
|
|
65
|
+
// Report only if the number of keys exceeds the allowed maximum
|
|
66
|
+
if (keyCount > maxKeys) {
|
|
67
|
+
context.report({
|
|
68
|
+
node,
|
|
69
|
+
messageId: "extractStyle",
|
|
70
|
+
data: { max: maxKeys }
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
};
|
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
export default {
|
|
2
|
+
meta: {
|
|
3
|
+
type: "suggestion",
|
|
4
|
+
docs: {
|
|
5
|
+
description:
|
|
6
|
+
"Disallow variables that are only passed to a single custom child component. For useState, only report if both the state and its setter are exclusively passed to a single custom child. For general variables, only report if a given child receives exactly one such candidate – if two or more are passed to the same component type, they’re assumed to be settings that belong on the parent.",
|
|
7
|
+
category: "Best Practices",
|
|
8
|
+
recommended: false
|
|
9
|
+
}
|
|
10
|
+
},
|
|
11
|
+
create(context) {
|
|
12
|
+
// A list of candidate variables for reporting (for general variables only).
|
|
13
|
+
const candidateVariables = [];
|
|
14
|
+
|
|
15
|
+
// Helper: Extract the component name from a JSXElement.
|
|
16
|
+
function getJSXElementName(jsxElement) {
|
|
17
|
+
if (
|
|
18
|
+
!jsxElement ||
|
|
19
|
+
!jsxElement.openingElement ||
|
|
20
|
+
!jsxElement.openingElement.name
|
|
21
|
+
) {
|
|
22
|
+
return "";
|
|
23
|
+
}
|
|
24
|
+
const nameNode = jsxElement.openingElement.name;
|
|
25
|
+
if (nameNode.type === "JSXIdentifier") {
|
|
26
|
+
return nameNode.name;
|
|
27
|
+
}
|
|
28
|
+
if (nameNode.type === "JSXMemberExpression") {
|
|
29
|
+
// Traverse to the rightmost identifier.
|
|
30
|
+
let current = nameNode;
|
|
31
|
+
while (current.property) {
|
|
32
|
+
current = current.property;
|
|
33
|
+
}
|
|
34
|
+
if (current && current.type === "JSXIdentifier") {
|
|
35
|
+
return current.name;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return "";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Helper: Check if the node is a call to useState.
|
|
42
|
+
function isUseStateCall(node) {
|
|
43
|
+
return (
|
|
44
|
+
node &&
|
|
45
|
+
node.type === "CallExpression" &&
|
|
46
|
+
node.callee &&
|
|
47
|
+
((node.callee.type === "Identifier" &&
|
|
48
|
+
node.callee.name === "useState") ||
|
|
49
|
+
(node.callee.type === "MemberExpression" &&
|
|
50
|
+
node.callee.property &&
|
|
51
|
+
node.callee.property.name === "useState"))
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Helper: Check if a call expression is a hook call (other than useState).
|
|
56
|
+
function isHookCall(node) {
|
|
57
|
+
return (
|
|
58
|
+
node &&
|
|
59
|
+
node.type === "CallExpression" &&
|
|
60
|
+
node.callee &&
|
|
61
|
+
node.callee.type === "Identifier" &&
|
|
62
|
+
/^use[A-Z]/.test(node.callee.name) &&
|
|
63
|
+
node.callee.name !== "useState"
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Helper: Walk upward to find the closest JSXElement ancestor.
|
|
68
|
+
function getJSXAncestor(node) {
|
|
69
|
+
let current = node.parent;
|
|
70
|
+
while (current) {
|
|
71
|
+
if (current.type === "JSXElement") {
|
|
72
|
+
return current;
|
|
73
|
+
}
|
|
74
|
+
current = current.parent;
|
|
75
|
+
}
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Helper: Check whether the given node is inside a JSXAttribute "value"
|
|
80
|
+
// that belongs to a context-like component (i.e. tag name ends with Provider or Context).
|
|
81
|
+
function isContextProviderValueProp(node) {
|
|
82
|
+
let current = node.parent;
|
|
83
|
+
while (current) {
|
|
84
|
+
if (
|
|
85
|
+
current.type === "JSXAttribute" &&
|
|
86
|
+
current.name &&
|
|
87
|
+
current.name.name === "value"
|
|
88
|
+
) {
|
|
89
|
+
// current.parent should be a JSXOpeningElement.
|
|
90
|
+
if (
|
|
91
|
+
current.parent &&
|
|
92
|
+
current.parent.type === "JSXOpeningElement"
|
|
93
|
+
) {
|
|
94
|
+
const nameNode = current.parent.name;
|
|
95
|
+
if (nameNode.type === "JSXIdentifier") {
|
|
96
|
+
const tagName = nameNode.name;
|
|
97
|
+
if (
|
|
98
|
+
tagName.endsWith("Provider") ||
|
|
99
|
+
tagName.endsWith("Context")
|
|
100
|
+
) {
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
} else if (nameNode.type === "JSXMemberExpression") {
|
|
104
|
+
// Get the rightmost identifier.
|
|
105
|
+
let currentMember = nameNode;
|
|
106
|
+
while (
|
|
107
|
+
currentMember.type === "JSXMemberExpression"
|
|
108
|
+
) {
|
|
109
|
+
currentMember = currentMember.property;
|
|
110
|
+
}
|
|
111
|
+
if (
|
|
112
|
+
currentMember &&
|
|
113
|
+
currentMember.type === "JSXIdentifier"
|
|
114
|
+
) {
|
|
115
|
+
if (
|
|
116
|
+
currentMember.name.endsWith("Provider") ||
|
|
117
|
+
currentMember.name.endsWith("Context")
|
|
118
|
+
) {
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
current = current.parent;
|
|
126
|
+
}
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Helper: Determine if a JSXElement is a custom component (tag name begins with an uppercase letter).
|
|
131
|
+
function isCustomJSXElement(jsxElement) {
|
|
132
|
+
if (
|
|
133
|
+
!jsxElement ||
|
|
134
|
+
!jsxElement.openingElement ||
|
|
135
|
+
!jsxElement.openingElement.name
|
|
136
|
+
) {
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
const nameNode = jsxElement.openingElement.name;
|
|
140
|
+
if (nameNode.type === "JSXIdentifier") {
|
|
141
|
+
return /^[A-Z]/.test(nameNode.name);
|
|
142
|
+
}
|
|
143
|
+
if (nameNode.type === "JSXMemberExpression") {
|
|
144
|
+
let current = nameNode;
|
|
145
|
+
while (current.object) {
|
|
146
|
+
current = current.object;
|
|
147
|
+
}
|
|
148
|
+
return (
|
|
149
|
+
current.type === "JSXIdentifier" &&
|
|
150
|
+
/^[A-Z]/.test(current.name)
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Helper: Find the nearest enclosing function (assumed to be the component).
|
|
157
|
+
function getComponentFunction(node) {
|
|
158
|
+
let current = node;
|
|
159
|
+
while (current) {
|
|
160
|
+
if (
|
|
161
|
+
current.type === "FunctionDeclaration" ||
|
|
162
|
+
current.type === "FunctionExpression" ||
|
|
163
|
+
current.type === "ArrowFunctionExpression"
|
|
164
|
+
) {
|
|
165
|
+
return current;
|
|
166
|
+
}
|
|
167
|
+
current = current.parent;
|
|
168
|
+
}
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Analyze variable usage by iteratively traversing the component function's AST.
|
|
173
|
+
// Only count a usage if it occurs inside a custom JSX element (and is not inside a context provider's "value" prop).
|
|
174
|
+
function analyzeVariableUsage(
|
|
175
|
+
declarationNode,
|
|
176
|
+
varName,
|
|
177
|
+
componentFunction
|
|
178
|
+
) {
|
|
179
|
+
const usage = { jsxUsageSet: new Set(), hasOutsideUsage: false };
|
|
180
|
+
const sourceCode = context.getSourceCode();
|
|
181
|
+
const visitorKeys = sourceCode.visitorKeys || {};
|
|
182
|
+
|
|
183
|
+
// Iterative traversal using a stack.
|
|
184
|
+
const stack = [];
|
|
185
|
+
if (componentFunction.body.type === "BlockStatement") {
|
|
186
|
+
for (let i = 0; i < componentFunction.body.body.length; i++) {
|
|
187
|
+
stack.push(componentFunction.body.body[i]);
|
|
188
|
+
}
|
|
189
|
+
} else {
|
|
190
|
+
stack.push(componentFunction.body);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
while (stack.length) {
|
|
194
|
+
const currentNode = stack.pop();
|
|
195
|
+
if (!currentNode) continue;
|
|
196
|
+
|
|
197
|
+
if (
|
|
198
|
+
currentNode.type === "Identifier" &&
|
|
199
|
+
currentNode.name === varName &&
|
|
200
|
+
currentNode !== declarationNode
|
|
201
|
+
) {
|
|
202
|
+
// If the identifier is inside a "value" prop on a context-like component, ignore it.
|
|
203
|
+
if (isContextProviderValueProp(currentNode)) {
|
|
204
|
+
// Do not count this usage.
|
|
205
|
+
} else {
|
|
206
|
+
const jsxAncestor = getJSXAncestor(currentNode);
|
|
207
|
+
if (jsxAncestor && isCustomJSXElement(jsxAncestor)) {
|
|
208
|
+
usage.jsxUsageSet.add(jsxAncestor);
|
|
209
|
+
} else {
|
|
210
|
+
usage.hasOutsideUsage = true;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Skip nested functions that shadow the variable.
|
|
216
|
+
const isFunction =
|
|
217
|
+
currentNode.type === "FunctionDeclaration" ||
|
|
218
|
+
currentNode.type === "FunctionExpression" ||
|
|
219
|
+
currentNode.type === "ArrowFunctionExpression";
|
|
220
|
+
if (isFunction && currentNode !== componentFunction) {
|
|
221
|
+
let shadows = false;
|
|
222
|
+
if (currentNode.params && currentNode.params.length > 0) {
|
|
223
|
+
for (let i = 0; i < currentNode.params.length; i++) {
|
|
224
|
+
const param = currentNode.params[i];
|
|
225
|
+
if (
|
|
226
|
+
param.type === "Identifier" &&
|
|
227
|
+
param.name === varName
|
|
228
|
+
) {
|
|
229
|
+
shadows = true;
|
|
230
|
+
break;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
if (shadows) continue;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const keys = visitorKeys[currentNode.type] || [];
|
|
238
|
+
for (let i = 0; i < keys.length; i++) {
|
|
239
|
+
const key = keys[i];
|
|
240
|
+
const child = currentNode[key];
|
|
241
|
+
if (Array.isArray(child)) {
|
|
242
|
+
for (let j = 0; j < child.length; j++) {
|
|
243
|
+
if (child[j] && typeof child[j].type === "string") {
|
|
244
|
+
stack.push(child[j]);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
} else if (child && typeof child.type === "string") {
|
|
248
|
+
stack.push(child);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return usage;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Manage hook-derived variables.
|
|
256
|
+
const componentHookVars = new WeakMap();
|
|
257
|
+
function getHookSet(componentFunction) {
|
|
258
|
+
if (!componentHookVars.has(componentFunction)) {
|
|
259
|
+
componentHookVars.set(componentFunction, new Set());
|
|
260
|
+
}
|
|
261
|
+
return componentHookVars.get(componentFunction);
|
|
262
|
+
}
|
|
263
|
+
function hasHookDependency(node, hookSet) {
|
|
264
|
+
const sourceCode = context.getSourceCode();
|
|
265
|
+
const visitorKeys = sourceCode.visitorKeys || {};
|
|
266
|
+
const stack = [node];
|
|
267
|
+
while (stack.length) {
|
|
268
|
+
const currentNode = stack.pop();
|
|
269
|
+
if (!currentNode) continue;
|
|
270
|
+
if (currentNode.type === "Identifier") {
|
|
271
|
+
if (hookSet.has(currentNode.name)) {
|
|
272
|
+
return true;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
const keys = visitorKeys[currentNode.type] || [];
|
|
276
|
+
for (let i = 0; i < keys.length; i++) {
|
|
277
|
+
const key = keys[i];
|
|
278
|
+
const child = currentNode[key];
|
|
279
|
+
if (Array.isArray(child)) {
|
|
280
|
+
for (let j = 0; j < child.length; j++) {
|
|
281
|
+
if (child[j] && typeof child[j].type === "string") {
|
|
282
|
+
stack.push(child[j]);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
} else if (child && typeof child.type === "string") {
|
|
286
|
+
stack.push(child);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return false;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return {
|
|
294
|
+
VariableDeclarator(node) {
|
|
295
|
+
const componentFunction = getComponentFunction(node);
|
|
296
|
+
if (!componentFunction || !componentFunction.body) return;
|
|
297
|
+
|
|
298
|
+
// Record hook-derived variables (for hooks other than useState).
|
|
299
|
+
if (
|
|
300
|
+
node.init &&
|
|
301
|
+
node.id &&
|
|
302
|
+
node.id.type === "Identifier" &&
|
|
303
|
+
node.init.type === "CallExpression" &&
|
|
304
|
+
isHookCall(node.init)
|
|
305
|
+
) {
|
|
306
|
+
const hookSet = getHookSet(componentFunction);
|
|
307
|
+
hookSet.add(node.id.name);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Case 1: useState destructuring (state & setter).
|
|
311
|
+
if (
|
|
312
|
+
node.init &&
|
|
313
|
+
isUseStateCall(node.init) &&
|
|
314
|
+
node.id.type === "ArrayPattern" &&
|
|
315
|
+
node.id.elements.length >= 2
|
|
316
|
+
) {
|
|
317
|
+
const stateElem = node.id.elements[0];
|
|
318
|
+
const setterElem = node.id.elements[1];
|
|
319
|
+
if (
|
|
320
|
+
!stateElem ||
|
|
321
|
+
stateElem.type !== "Identifier" ||
|
|
322
|
+
!setterElem ||
|
|
323
|
+
setterElem.type !== "Identifier"
|
|
324
|
+
) {
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
const stateVarName = stateElem.name;
|
|
328
|
+
const setterVarName = setterElem.name;
|
|
329
|
+
|
|
330
|
+
const stateUsage = analyzeVariableUsage(
|
|
331
|
+
stateElem,
|
|
332
|
+
stateVarName,
|
|
333
|
+
componentFunction
|
|
334
|
+
);
|
|
335
|
+
const setterUsage = analyzeVariableUsage(
|
|
336
|
+
setterElem,
|
|
337
|
+
setterVarName,
|
|
338
|
+
componentFunction
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
const stateExclusivelySingleJSX =
|
|
342
|
+
!stateUsage.hasOutsideUsage &&
|
|
343
|
+
stateUsage.jsxUsageSet.size === 1;
|
|
344
|
+
const setterExclusivelySingleJSX =
|
|
345
|
+
!setterUsage.hasOutsideUsage &&
|
|
346
|
+
setterUsage.jsxUsageSet.size === 1;
|
|
347
|
+
// Report immediately if both the state and setter are used exclusively
|
|
348
|
+
// in the same single custom JSX element.
|
|
349
|
+
if (
|
|
350
|
+
stateExclusivelySingleJSX &&
|
|
351
|
+
setterExclusivelySingleJSX &&
|
|
352
|
+
[...stateUsage.jsxUsageSet][0] ===
|
|
353
|
+
[...setterUsage.jsxUsageSet][0]
|
|
354
|
+
) {
|
|
355
|
+
context.report({
|
|
356
|
+
node: node,
|
|
357
|
+
message:
|
|
358
|
+
"State variable '{{stateVarName}}' and its setter '{{setterVarName}}' are only passed to a single custom child component. Consider moving the state into that component.",
|
|
359
|
+
data: { stateVarName, setterVarName }
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
// Case 2: General variable.
|
|
364
|
+
else if (node.id && node.id.type === "Identifier") {
|
|
365
|
+
const varName = node.id.name;
|
|
366
|
+
// Exempt variables that depend on hooks.
|
|
367
|
+
if (node.init) {
|
|
368
|
+
const hookSet = getHookSet(componentFunction);
|
|
369
|
+
if (hasHookDependency(node.init, hookSet)) {
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
const usage = analyzeVariableUsage(
|
|
374
|
+
node.id,
|
|
375
|
+
varName,
|
|
376
|
+
componentFunction
|
|
377
|
+
);
|
|
378
|
+
// Instead of reporting immediately, add a candidate if the variable is used exclusively in a single custom JSX element.
|
|
379
|
+
if (
|
|
380
|
+
!usage.hasOutsideUsage &&
|
|
381
|
+
usage.jsxUsageSet.size === 1
|
|
382
|
+
) {
|
|
383
|
+
const target = [...usage.jsxUsageSet][0];
|
|
384
|
+
const componentName = getJSXElementName(target);
|
|
385
|
+
candidateVariables.push({
|
|
386
|
+
node,
|
|
387
|
+
varName,
|
|
388
|
+
componentName
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
},
|
|
393
|
+
// At the end of the traversal, group candidate variables by the target component name.
|
|
394
|
+
"Program:exit"() {
|
|
395
|
+
const groups = new Map();
|
|
396
|
+
candidateVariables.forEach((candidate) => {
|
|
397
|
+
const key = candidate.componentName;
|
|
398
|
+
if (!groups.has(key)) {
|
|
399
|
+
groups.set(key, []);
|
|
400
|
+
}
|
|
401
|
+
groups.get(key).push(candidate);
|
|
402
|
+
});
|
|
403
|
+
// Only report candidates for a given component type if there is exactly one candidate.
|
|
404
|
+
groups.forEach((candidates) => {
|
|
405
|
+
if (candidates.length === 1) {
|
|
406
|
+
const candidate = candidates[0];
|
|
407
|
+
context.report({
|
|
408
|
+
node: candidate.node,
|
|
409
|
+
message:
|
|
410
|
+
"Variable '{{varName}}' is only passed to a single custom child component. Consider moving it to that component.",
|
|
411
|
+
data: { varName: candidate.varName }
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
};
|