react-doctor 0.0.3 → 0.0.5
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/dist/cli.d.ts +2 -0
- package/dist/cli.js +1151 -0
- package/dist/cli.js.map +1 -0
- package/dist/react-doctor-plugin.d.ts +31 -0
- package/dist/react-doctor-plugin.d.ts.map +1 -0
- package/dist/react-doctor-plugin.js +1239 -0
- package/dist/react-doctor-plugin.js.map +1 -0
- package/package.json +14 -9
- package/dist/cli.cjs +0 -445
- package/dist/cli.d.cts +0 -2
|
@@ -0,0 +1,1239 @@
|
|
|
1
|
+
//#region src/plugin/constants.ts
|
|
2
|
+
const GIANT_COMPONENT_LINE_THRESHOLD = 200;
|
|
3
|
+
const CASCADING_SET_STATE_THRESHOLD = 2;
|
|
4
|
+
const RELATED_USE_STATE_THRESHOLD = 3;
|
|
5
|
+
const DEEP_NESTING_THRESHOLD = 3;
|
|
6
|
+
const DUPLICATE_STORAGE_READ_THRESHOLD = 2;
|
|
7
|
+
const SEQUENTIAL_AWAIT_THRESHOLD = 2;
|
|
8
|
+
const SECRET_MIN_LENGTH_CHARS = 8;
|
|
9
|
+
const AUTH_CHECK_LOOKAHEAD_STATEMENTS = 3;
|
|
10
|
+
const LAYOUT_PROPERTIES = new Set([
|
|
11
|
+
"width",
|
|
12
|
+
"height",
|
|
13
|
+
"top",
|
|
14
|
+
"left",
|
|
15
|
+
"right",
|
|
16
|
+
"bottom",
|
|
17
|
+
"padding",
|
|
18
|
+
"paddingTop",
|
|
19
|
+
"paddingRight",
|
|
20
|
+
"paddingBottom",
|
|
21
|
+
"paddingLeft",
|
|
22
|
+
"margin",
|
|
23
|
+
"marginTop",
|
|
24
|
+
"marginRight",
|
|
25
|
+
"marginBottom",
|
|
26
|
+
"marginLeft",
|
|
27
|
+
"borderWidth",
|
|
28
|
+
"fontSize",
|
|
29
|
+
"lineHeight",
|
|
30
|
+
"gap"
|
|
31
|
+
]);
|
|
32
|
+
const MOTION_ANIMATE_PROPS = new Set([
|
|
33
|
+
"animate",
|
|
34
|
+
"initial",
|
|
35
|
+
"exit",
|
|
36
|
+
"whileHover",
|
|
37
|
+
"whileTap",
|
|
38
|
+
"whileFocus",
|
|
39
|
+
"whileDrag",
|
|
40
|
+
"whileInView"
|
|
41
|
+
]);
|
|
42
|
+
const HEAVY_LIBRARIES = new Set([
|
|
43
|
+
"@monaco-editor/react",
|
|
44
|
+
"monaco-editor",
|
|
45
|
+
"recharts",
|
|
46
|
+
"@react-pdf/renderer",
|
|
47
|
+
"react-quill",
|
|
48
|
+
"@codemirror/view",
|
|
49
|
+
"@codemirror/state",
|
|
50
|
+
"chart.js",
|
|
51
|
+
"react-chartjs-2",
|
|
52
|
+
"@toast-ui/editor",
|
|
53
|
+
"draft-js"
|
|
54
|
+
]);
|
|
55
|
+
const FETCH_CALLEE_NAMES = new Set(["fetch"]);
|
|
56
|
+
const FETCH_MEMBER_OBJECTS = new Set([
|
|
57
|
+
"axios",
|
|
58
|
+
"ky",
|
|
59
|
+
"got"
|
|
60
|
+
]);
|
|
61
|
+
const INDEX_PARAMETER_NAMES = new Set([
|
|
62
|
+
"index",
|
|
63
|
+
"idx",
|
|
64
|
+
"i"
|
|
65
|
+
]);
|
|
66
|
+
const BARREL_INDEX_SUFFIXES = [
|
|
67
|
+
"/index",
|
|
68
|
+
"/index.js",
|
|
69
|
+
"/index.ts",
|
|
70
|
+
"/index.tsx",
|
|
71
|
+
"/index.mjs"
|
|
72
|
+
];
|
|
73
|
+
const PASSIVE_EVENT_NAMES = new Set([
|
|
74
|
+
"scroll",
|
|
75
|
+
"wheel",
|
|
76
|
+
"touchstart",
|
|
77
|
+
"touchmove",
|
|
78
|
+
"touchend"
|
|
79
|
+
]);
|
|
80
|
+
const LOOP_TYPES = [
|
|
81
|
+
"ForStatement",
|
|
82
|
+
"ForInStatement",
|
|
83
|
+
"ForOfStatement",
|
|
84
|
+
"WhileStatement",
|
|
85
|
+
"DoWhileStatement"
|
|
86
|
+
];
|
|
87
|
+
const AUTH_FUNCTION_NAMES = new Set([
|
|
88
|
+
"auth",
|
|
89
|
+
"getSession",
|
|
90
|
+
"getServerSession",
|
|
91
|
+
"getUser",
|
|
92
|
+
"requireAuth",
|
|
93
|
+
"checkAuth",
|
|
94
|
+
"verifyAuth",
|
|
95
|
+
"authenticate",
|
|
96
|
+
"currentUser",
|
|
97
|
+
"getAuth",
|
|
98
|
+
"validateSession"
|
|
99
|
+
]);
|
|
100
|
+
const SECRET_PATTERNS = [
|
|
101
|
+
/^sk_live_/,
|
|
102
|
+
/^sk_test_/,
|
|
103
|
+
/^AKIA[0-9A-Z]{16}$/,
|
|
104
|
+
/^ghp_[a-zA-Z0-9]{36}$/,
|
|
105
|
+
/^gho_[a-zA-Z0-9]{36}$/,
|
|
106
|
+
/^github_pat_/,
|
|
107
|
+
/^glpat-/,
|
|
108
|
+
/^xox[bporas]-/,
|
|
109
|
+
/^sk-[a-zA-Z0-9]{32,}$/
|
|
110
|
+
];
|
|
111
|
+
const SECRET_VARIABLE_PATTERN = /(?:api_?key|secret|token|password|credential|auth)/i;
|
|
112
|
+
const LOADING_STATE_PATTERN = /(?:loading|isLoading|isPending|isSubmitting|isFetching)/i;
|
|
113
|
+
const EVENT_PROP_PATTERN = /^on[A-Z]/;
|
|
114
|
+
const SETTER_PATTERN = /^set[A-Z]/;
|
|
115
|
+
const RENDER_FUNCTION_PATTERN = /^render[A-Z]/;
|
|
116
|
+
const UPPERCASE_PATTERN = /^[A-Z]/;
|
|
117
|
+
const PAGE_FILE_PATTERN = /\/page\.(tsx?|jsx?)$/;
|
|
118
|
+
const PAGE_OR_LAYOUT_FILE_PATTERN = /\/(page|layout)\.(tsx?|jsx?)$/;
|
|
119
|
+
const PAGES_DIRECTORY_PATTERN = /\/pages\//;
|
|
120
|
+
const SERVER_ACTION_FILE_PATTERN = /actions?\.(tsx?|jsx?)$/;
|
|
121
|
+
const SERVER_ACTION_DIRECTORY_PATTERN = /\/actions\//;
|
|
122
|
+
const NEXTJS_NAVIGATION_FUNCTIONS = new Set([
|
|
123
|
+
"redirect",
|
|
124
|
+
"permanentRedirect",
|
|
125
|
+
"notFound",
|
|
126
|
+
"forbidden",
|
|
127
|
+
"unauthorized"
|
|
128
|
+
]);
|
|
129
|
+
const GOOGLE_FONTS_PATTERN = /fonts\.googleapis\.com/;
|
|
130
|
+
const POLYFILL_SCRIPT_PATTERN = /polyfill\.io|polyfill\.min\.js|cdn\.polyfill/;
|
|
131
|
+
const APP_DIRECTORY_PATTERN = /\/app\//;
|
|
132
|
+
const EFFECT_HOOK_NAMES = new Set(["useEffect", "useLayoutEffect"]);
|
|
133
|
+
const HOOKS_WITH_DEPS = new Set([
|
|
134
|
+
"useEffect",
|
|
135
|
+
"useLayoutEffect",
|
|
136
|
+
"useMemo",
|
|
137
|
+
"useCallback"
|
|
138
|
+
]);
|
|
139
|
+
const CHAINABLE_ITERATION_METHODS = new Set([
|
|
140
|
+
"map",
|
|
141
|
+
"filter",
|
|
142
|
+
"forEach",
|
|
143
|
+
"flatMap"
|
|
144
|
+
]);
|
|
145
|
+
const STORAGE_OBJECTS = new Set(["localStorage", "sessionStorage"]);
|
|
146
|
+
const LARGE_BLUR_THRESHOLD_PX = 10;
|
|
147
|
+
const BLUR_VALUE_PATTERN = /blur\((\d+(?:\.\d+)?)px\)/;
|
|
148
|
+
const ANIMATION_CALLBACK_NAMES = new Set(["requestAnimationFrame", "setInterval"]);
|
|
149
|
+
|
|
150
|
+
//#endregion
|
|
151
|
+
//#region src/plugin/helpers.ts
|
|
152
|
+
const walkAst = (node, visitor) => {
|
|
153
|
+
if (!node || typeof node !== "object") return;
|
|
154
|
+
visitor(node);
|
|
155
|
+
for (const key of Object.keys(node)) {
|
|
156
|
+
if (key === "parent") continue;
|
|
157
|
+
const child = node[key];
|
|
158
|
+
if (Array.isArray(child)) {
|
|
159
|
+
for (const item of child) if (item && typeof item === "object" && item.type) walkAst(item, visitor);
|
|
160
|
+
} else if (child && typeof child === "object" && child.type) walkAst(child, visitor);
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
const isSetterIdentifier = (name) => SETTER_PATTERN.test(name);
|
|
164
|
+
const isUppercaseName = (name) => UPPERCASE_PATTERN.test(name);
|
|
165
|
+
const isMemberProperty = (node, propertyName) => node.type === "MemberExpression" && node.property?.type === "Identifier" && node.property.name === propertyName;
|
|
166
|
+
const getEffectCallback = (node) => {
|
|
167
|
+
if (!node.arguments?.length) return null;
|
|
168
|
+
const callback = node.arguments[0];
|
|
169
|
+
if (callback.type === "ArrowFunctionExpression" || callback.type === "FunctionExpression") return callback;
|
|
170
|
+
return null;
|
|
171
|
+
};
|
|
172
|
+
const getCallbackStatements = (callback) => {
|
|
173
|
+
if (callback.body?.type === "BlockStatement") return callback.body.body ?? [];
|
|
174
|
+
return callback.body ? [callback.body] : [];
|
|
175
|
+
};
|
|
176
|
+
const countSetStateCalls = (node) => {
|
|
177
|
+
let setStateCallCount = 0;
|
|
178
|
+
walkAst(node, (child) => {
|
|
179
|
+
if (child.type === "CallExpression" && child.callee?.type === "Identifier" && isSetterIdentifier(child.callee.name)) setStateCallCount++;
|
|
180
|
+
});
|
|
181
|
+
return setStateCallCount;
|
|
182
|
+
};
|
|
183
|
+
const isSimpleExpression = (node) => {
|
|
184
|
+
if (!node) return false;
|
|
185
|
+
switch (node.type) {
|
|
186
|
+
case "Identifier":
|
|
187
|
+
case "Literal":
|
|
188
|
+
case "TemplateLiteral": return true;
|
|
189
|
+
case "BinaryExpression": return isSimpleExpression(node.left) && isSimpleExpression(node.right);
|
|
190
|
+
case "UnaryExpression": return isSimpleExpression(node.argument);
|
|
191
|
+
case "MemberExpression": return !node.computed;
|
|
192
|
+
case "ConditionalExpression": return isSimpleExpression(node.test) && isSimpleExpression(node.consequent) && isSimpleExpression(node.alternate);
|
|
193
|
+
default: return false;
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
const isComponentDeclaration = (node) => node.type === "FunctionDeclaration" && Boolean(node.id?.name) && isUppercaseName(node.id.name);
|
|
197
|
+
const isComponentAssignment = (node) => node.type === "VariableDeclarator" && node.id?.type === "Identifier" && isUppercaseName(node.id.name) && Boolean(node.init) && (node.init.type === "ArrowFunctionExpression" || node.init.type === "FunctionExpression");
|
|
198
|
+
const isHookCall = (node, hookName) => node.type === "CallExpression" && node.callee?.type === "Identifier" && (typeof hookName === "string" ? node.callee.name === hookName : hookName.has(node.callee.name));
|
|
199
|
+
const hasDirective = (programNode, directive) => Boolean(programNode.body?.some((statement) => statement.type === "ExpressionStatement" && statement.expression?.type === "Literal" && statement.expression.value === directive));
|
|
200
|
+
const hasUseServerDirective = (node) => {
|
|
201
|
+
if (node.body?.type !== "BlockStatement") return false;
|
|
202
|
+
return Boolean(node.body.body?.some((statement) => statement.type === "ExpressionStatement" && statement.directive === "use server"));
|
|
203
|
+
};
|
|
204
|
+
const containsFetchCall = (node) => {
|
|
205
|
+
let didFindFetchCall = false;
|
|
206
|
+
walkAst(node, (child) => {
|
|
207
|
+
if (didFindFetchCall || child.type !== "CallExpression") return;
|
|
208
|
+
if (child.callee?.type === "Identifier" && FETCH_CALLEE_NAMES.has(child.callee.name)) didFindFetchCall = true;
|
|
209
|
+
if (child.callee?.type === "MemberExpression" && child.callee.object?.type === "Identifier" && FETCH_MEMBER_OBJECTS.has(child.callee.object.name)) didFindFetchCall = true;
|
|
210
|
+
});
|
|
211
|
+
return didFindFetchCall;
|
|
212
|
+
};
|
|
213
|
+
const findJsxAttribute = (attributes, attributeName) => attributes?.find((attr) => attr.type === "JSXAttribute" && attr.name?.type === "JSXIdentifier" && attr.name.name === attributeName);
|
|
214
|
+
const hasJsxAttribute = (attributes, attributeName) => Boolean(findJsxAttribute(attributes, attributeName));
|
|
215
|
+
const createLoopAwareVisitors = (innerVisitors) => {
|
|
216
|
+
let loopDepth = 0;
|
|
217
|
+
const incrementLoopDepth = () => {
|
|
218
|
+
loopDepth++;
|
|
219
|
+
};
|
|
220
|
+
const decrementLoopDepth = () => {
|
|
221
|
+
loopDepth--;
|
|
222
|
+
};
|
|
223
|
+
const visitors = {};
|
|
224
|
+
for (const loopType of LOOP_TYPES) {
|
|
225
|
+
visitors[loopType] = incrementLoopDepth;
|
|
226
|
+
visitors[`${loopType}:exit`] = decrementLoopDepth;
|
|
227
|
+
}
|
|
228
|
+
for (const [nodeType, handler] of Object.entries(innerVisitors)) visitors[nodeType] = (node) => {
|
|
229
|
+
if (loopDepth > 0) handler(node);
|
|
230
|
+
};
|
|
231
|
+
return visitors;
|
|
232
|
+
};
|
|
233
|
+
const extractDestructuredPropNames = (params) => {
|
|
234
|
+
const propNames = /* @__PURE__ */ new Set();
|
|
235
|
+
for (const param of params) if (param.type === "ObjectPattern") {
|
|
236
|
+
for (const property of param.properties ?? []) if (property.type === "Property" && property.key?.type === "Identifier") propNames.add(property.key.name);
|
|
237
|
+
} else if (param.type === "Identifier") propNames.add(param.name);
|
|
238
|
+
return propNames;
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
//#endregion
|
|
242
|
+
//#region src/plugin/rules/architecture.ts
|
|
243
|
+
const noGenericHandlerNames = { create: (context) => ({ JSXAttribute(node) {
|
|
244
|
+
if (node.name?.type !== "JSXIdentifier" || !EVENT_PROP_PATTERN.test(node.name.name)) return;
|
|
245
|
+
if (!node.value || node.value.type !== "JSXExpressionContainer") return;
|
|
246
|
+
const mirroredHandlerName = `handle${node.name.name.slice(2)}`;
|
|
247
|
+
const expression = node.value.expression;
|
|
248
|
+
if (expression?.type === "Identifier" && expression.name === mirroredHandlerName) context.report({
|
|
249
|
+
node,
|
|
250
|
+
message: `Non-descriptive handler name "${expression.name}" — name should describe what it does, not when it runs`
|
|
251
|
+
});
|
|
252
|
+
} }) };
|
|
253
|
+
const noGiantComponent = { create: (context) => {
|
|
254
|
+
const reportOversizedComponent = (nameNode, componentName, bodyNode) => {
|
|
255
|
+
if (!bodyNode.loc) return;
|
|
256
|
+
const lineCount = bodyNode.loc.end.line - bodyNode.loc.start.line + 1;
|
|
257
|
+
if (lineCount > GIANT_COMPONENT_LINE_THRESHOLD) context.report({
|
|
258
|
+
node: nameNode,
|
|
259
|
+
message: `Component "${componentName}" is ${lineCount} lines — consider breaking it into smaller focused components`
|
|
260
|
+
});
|
|
261
|
+
};
|
|
262
|
+
return {
|
|
263
|
+
FunctionDeclaration(node) {
|
|
264
|
+
if (!node.id?.name || !isUppercaseName(node.id.name)) return;
|
|
265
|
+
reportOversizedComponent(node.id, node.id.name, node);
|
|
266
|
+
},
|
|
267
|
+
VariableDeclarator(node) {
|
|
268
|
+
if (!isComponentAssignment(node)) return;
|
|
269
|
+
reportOversizedComponent(node.id, node.id.name, node.init);
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
} };
|
|
273
|
+
const noRenderInRender = { create: (context) => ({ JSXExpressionContainer(node) {
|
|
274
|
+
const expression = node.expression;
|
|
275
|
+
if (expression?.type !== "CallExpression") return;
|
|
276
|
+
const calleeName = expression.callee?.type === "Identifier" ? expression.callee.name : expression.callee?.type === "MemberExpression" && expression.callee.property?.type === "Identifier" ? expression.callee.property.name : null;
|
|
277
|
+
if (calleeName && RENDER_FUNCTION_PATTERN.test(calleeName)) context.report({
|
|
278
|
+
node: expression,
|
|
279
|
+
message: `Inline render function "${calleeName}()" — extract to a separate component for proper reconciliation`
|
|
280
|
+
});
|
|
281
|
+
} }) };
|
|
282
|
+
const noNestedComponentDefinition = { create: (context) => {
|
|
283
|
+
const componentStack = [];
|
|
284
|
+
return {
|
|
285
|
+
FunctionDeclaration(node) {
|
|
286
|
+
if (!isComponentDeclaration(node)) return;
|
|
287
|
+
if (componentStack.length > 0) context.report({
|
|
288
|
+
node: node.id,
|
|
289
|
+
message: `Component "${node.id.name}" defined inside "${componentStack[componentStack.length - 1]}" — creates new instance every render, destroying state`
|
|
290
|
+
});
|
|
291
|
+
componentStack.push(node.id.name);
|
|
292
|
+
},
|
|
293
|
+
"FunctionDeclaration:exit"(node) {
|
|
294
|
+
if (isComponentDeclaration(node)) componentStack.pop();
|
|
295
|
+
},
|
|
296
|
+
VariableDeclarator(node) {
|
|
297
|
+
if (!isComponentAssignment(node)) return;
|
|
298
|
+
if (componentStack.length > 0) context.report({
|
|
299
|
+
node: node.id,
|
|
300
|
+
message: `Component "${node.id.name}" defined inside "${componentStack[componentStack.length - 1]}" — creates new instance every render, destroying state`
|
|
301
|
+
});
|
|
302
|
+
componentStack.push(node.id.name);
|
|
303
|
+
},
|
|
304
|
+
"VariableDeclarator:exit"(node) {
|
|
305
|
+
if (isComponentAssignment(node)) componentStack.pop();
|
|
306
|
+
}
|
|
307
|
+
};
|
|
308
|
+
} };
|
|
309
|
+
|
|
310
|
+
//#endregion
|
|
311
|
+
//#region src/plugin/rules/bundle-size.ts
|
|
312
|
+
const noBarrelImport = { create: (context) => ({ ImportDeclaration(node) {
|
|
313
|
+
const source = node.source?.value;
|
|
314
|
+
if (typeof source !== "string" || !source.startsWith(".")) return;
|
|
315
|
+
if (BARREL_INDEX_SUFFIXES.some((suffix) => source.endsWith(suffix))) context.report({
|
|
316
|
+
node,
|
|
317
|
+
message: "Import from barrel/index file — import directly from the source module for better tree-shaking"
|
|
318
|
+
});
|
|
319
|
+
} }) };
|
|
320
|
+
const noFullLodashImport = { create: (context) => ({ ImportDeclaration(node) {
|
|
321
|
+
const source = node.source?.value;
|
|
322
|
+
if (source === "lodash" || source === "lodash-es") context.report({
|
|
323
|
+
node,
|
|
324
|
+
message: "Importing entire lodash library — import from 'lodash/functionName' instead"
|
|
325
|
+
});
|
|
326
|
+
} }) };
|
|
327
|
+
const noMoment = { create: (context) => ({ ImportDeclaration(node) {
|
|
328
|
+
if (node.source?.value === "moment") context.report({
|
|
329
|
+
node,
|
|
330
|
+
message: "moment.js is 300kb+ — use \"date-fns\" or \"dayjs\" instead"
|
|
331
|
+
});
|
|
332
|
+
} }) };
|
|
333
|
+
const preferDynamicImport = { create: (context) => ({ ImportDeclaration(node) {
|
|
334
|
+
const source = node.source?.value;
|
|
335
|
+
if (typeof source === "string" && HEAVY_LIBRARIES.has(source)) context.report({
|
|
336
|
+
node,
|
|
337
|
+
message: `"${source}" is a heavy library — use React.lazy() or next/dynamic for code splitting`
|
|
338
|
+
});
|
|
339
|
+
} }) };
|
|
340
|
+
const useLazyMotion = { create: (context) => ({ ImportDeclaration(node) {
|
|
341
|
+
const source = node.source?.value;
|
|
342
|
+
if (source !== "framer-motion" && source !== "motion/react") return;
|
|
343
|
+
if (node.specifiers?.some((specifier) => specifier.type === "ImportSpecifier" && specifier.imported?.name === "motion")) context.report({
|
|
344
|
+
node,
|
|
345
|
+
message: "Import \"m\" with LazyMotion instead of \"motion\" — saves ~30kb in bundle size"
|
|
346
|
+
});
|
|
347
|
+
} }) };
|
|
348
|
+
const noUndeferredThirdParty = { create: (context) => ({ JSXOpeningElement(node) {
|
|
349
|
+
if (node.name?.type !== "JSXIdentifier" || node.name.name !== "script") return;
|
|
350
|
+
const attributes = node.attributes ?? [];
|
|
351
|
+
if (!findJsxAttribute(attributes, "src")) return;
|
|
352
|
+
if (!hasJsxAttribute(attributes, "defer") && !hasJsxAttribute(attributes, "async")) context.report({
|
|
353
|
+
node,
|
|
354
|
+
message: "Synchronous <script> with src — add defer or async to avoid blocking first paint"
|
|
355
|
+
});
|
|
356
|
+
} }) };
|
|
357
|
+
|
|
358
|
+
//#endregion
|
|
359
|
+
//#region src/plugin/rules/client.ts
|
|
360
|
+
const clientPassiveEventListeners = { create: (context) => ({ CallExpression(node) {
|
|
361
|
+
if (!isMemberProperty(node.callee, "addEventListener")) return;
|
|
362
|
+
if (node.arguments?.length < 2) return;
|
|
363
|
+
const eventNameNode = node.arguments[0];
|
|
364
|
+
if (eventNameNode.type !== "Literal" || !PASSIVE_EVENT_NAMES.has(eventNameNode.value)) return;
|
|
365
|
+
const eventName = eventNameNode.value;
|
|
366
|
+
const optionsArgument = node.arguments[2];
|
|
367
|
+
if (!optionsArgument) {
|
|
368
|
+
context.report({
|
|
369
|
+
node,
|
|
370
|
+
message: `"${eventName}" listener without { passive: true } — blocks scrolling performance`
|
|
371
|
+
});
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
if (optionsArgument.type !== "ObjectExpression") return;
|
|
375
|
+
if (!optionsArgument.properties?.some((property) => property.type === "Property" && property.key?.type === "Identifier" && property.key.name === "passive" && property.value?.type === "Literal" && property.value.value === true)) context.report({
|
|
376
|
+
node,
|
|
377
|
+
message: `"${eventName}" listener without { passive: true } — blocks scrolling performance`
|
|
378
|
+
});
|
|
379
|
+
} }) };
|
|
380
|
+
|
|
381
|
+
//#endregion
|
|
382
|
+
//#region src/plugin/rules/correctness.ts
|
|
383
|
+
const extractIndexName = (node) => {
|
|
384
|
+
if (node.type === "Identifier" && INDEX_PARAMETER_NAMES.has(node.name)) return node.name;
|
|
385
|
+
if (node.type === "TemplateLiteral" && node.expressions?.some((expression) => expression.type === "Identifier" && INDEX_PARAMETER_NAMES.has(expression.name))) return node.expressions.find((expression) => expression.type === "Identifier" && INDEX_PARAMETER_NAMES.has(expression.name))?.name;
|
|
386
|
+
if (node.type === "CallExpression" && node.callee?.type === "MemberExpression" && node.callee.object?.type === "Identifier" && INDEX_PARAMETER_NAMES.has(node.callee.object.name) && node.callee.property?.type === "Identifier" && node.callee.property.name === "toString") return node.callee.object.name;
|
|
387
|
+
if (node.type === "CallExpression" && node.callee?.type === "Identifier" && node.callee.name === "String" && node.arguments?.[0]?.type === "Identifier" && INDEX_PARAMETER_NAMES.has(node.arguments[0].name)) return node.arguments[0].name;
|
|
388
|
+
return null;
|
|
389
|
+
};
|
|
390
|
+
const noArrayIndexAsKey = { create: (context) => ({ JSXAttribute(node) {
|
|
391
|
+
if (node.name?.type !== "JSXIdentifier" || node.name.name !== "key") return;
|
|
392
|
+
if (!node.value || node.value.type !== "JSXExpressionContainer") return;
|
|
393
|
+
const indexName = extractIndexName(node.value.expression);
|
|
394
|
+
if (indexName) context.report({
|
|
395
|
+
node,
|
|
396
|
+
message: `Array index "${indexName}" used as key — causes bugs when list is reordered or filtered`
|
|
397
|
+
});
|
|
398
|
+
} }) };
|
|
399
|
+
const PREVENT_DEFAULT_ELEMENTS = {
|
|
400
|
+
form: "onSubmit",
|
|
401
|
+
a: "onClick"
|
|
402
|
+
};
|
|
403
|
+
const containsPreventDefaultCall = (node) => {
|
|
404
|
+
let didFindPreventDefault = false;
|
|
405
|
+
walkAst(node, (child) => {
|
|
406
|
+
if (didFindPreventDefault) return;
|
|
407
|
+
if (child.type === "CallExpression" && child.callee?.type === "MemberExpression" && child.callee.property?.type === "Identifier" && child.callee.property.name === "preventDefault") didFindPreventDefault = true;
|
|
408
|
+
});
|
|
409
|
+
return didFindPreventDefault;
|
|
410
|
+
};
|
|
411
|
+
const noPreventDefault = { create: (context) => ({ JSXOpeningElement(node) {
|
|
412
|
+
const elementName = node.name?.type === "JSXIdentifier" ? node.name.name : null;
|
|
413
|
+
if (!elementName) return;
|
|
414
|
+
const targetEventProp = PREVENT_DEFAULT_ELEMENTS[elementName];
|
|
415
|
+
if (!targetEventProp) return;
|
|
416
|
+
const eventAttribute = findJsxAttribute(node.attributes ?? [], targetEventProp);
|
|
417
|
+
if (!eventAttribute?.value || eventAttribute.value.type !== "JSXExpressionContainer") return;
|
|
418
|
+
const expression = eventAttribute.value.expression;
|
|
419
|
+
if (expression?.type !== "ArrowFunctionExpression" && expression?.type !== "FunctionExpression") return;
|
|
420
|
+
if (!containsPreventDefaultCall(expression)) return;
|
|
421
|
+
const message = elementName === "form" ? "preventDefault() on <form> onSubmit — form won't work without JavaScript" : "preventDefault() on <a> onClick — use a <button> or routing component instead";
|
|
422
|
+
context.report({
|
|
423
|
+
node,
|
|
424
|
+
message
|
|
425
|
+
});
|
|
426
|
+
} }) };
|
|
427
|
+
const renderingConditionalRender = { create: (context) => ({ LogicalExpression(node) {
|
|
428
|
+
if (node.operator !== "&&") return;
|
|
429
|
+
if (!(node.right?.type === "JSXElement" || node.right?.type === "JSXFragment")) return;
|
|
430
|
+
if (node.left?.type === "MemberExpression" && node.left.property?.type === "Identifier" && node.left.property.name === "length") context.report({
|
|
431
|
+
node,
|
|
432
|
+
message: "Conditional rendering with .length can render '0' — use .length > 0 or Boolean(.length)"
|
|
433
|
+
});
|
|
434
|
+
} }) };
|
|
435
|
+
|
|
436
|
+
//#endregion
|
|
437
|
+
//#region src/plugin/rules/js-performance.ts
|
|
438
|
+
const jsCombineIterations = { create: (context) => ({ CallExpression(node) {
|
|
439
|
+
if (node.callee?.type !== "MemberExpression" || node.callee.property?.type !== "Identifier") return;
|
|
440
|
+
const outerMethod = node.callee.property.name;
|
|
441
|
+
if (!CHAINABLE_ITERATION_METHODS.has(outerMethod)) return;
|
|
442
|
+
const innerCall = node.callee.object;
|
|
443
|
+
if (innerCall?.type !== "CallExpression" || innerCall.callee?.type !== "MemberExpression" || innerCall.callee.property?.type !== "Identifier") return;
|
|
444
|
+
const innerMethod = innerCall.callee.property.name;
|
|
445
|
+
if (!CHAINABLE_ITERATION_METHODS.has(innerMethod)) return;
|
|
446
|
+
context.report({
|
|
447
|
+
node,
|
|
448
|
+
message: `.${innerMethod}().${outerMethod}() iterates the array twice — combine into a single loop with .reduce() or for...of`
|
|
449
|
+
});
|
|
450
|
+
} }) };
|
|
451
|
+
const jsTosortedImmutable = { create: (context) => ({ CallExpression(node) {
|
|
452
|
+
if (!isMemberProperty(node.callee, "sort")) return;
|
|
453
|
+
const receiver = node.callee.object;
|
|
454
|
+
if (receiver?.type === "ArrayExpression" && receiver.elements?.length === 1 && receiver.elements[0]?.type === "SpreadElement") context.report({
|
|
455
|
+
node,
|
|
456
|
+
message: "[...array].sort() — use array.toSorted() for immutable sorting (ES2023)"
|
|
457
|
+
});
|
|
458
|
+
} }) };
|
|
459
|
+
const jsHoistRegexp = { create: (context) => createLoopAwareVisitors({ NewExpression(node) {
|
|
460
|
+
if (node.callee?.type === "Identifier" && node.callee.name === "RegExp") context.report({
|
|
461
|
+
node,
|
|
462
|
+
message: "new RegExp() inside a loop — hoist to a module-level constant"
|
|
463
|
+
});
|
|
464
|
+
} }) };
|
|
465
|
+
const jsMinMaxLoop = { create: (context) => ({ MemberExpression(node) {
|
|
466
|
+
if (!node.computed) return;
|
|
467
|
+
const object = node.object;
|
|
468
|
+
if (object?.type !== "CallExpression" || !isMemberProperty(object.callee, "sort")) return;
|
|
469
|
+
const isFirstElement = node.property?.type === "Literal" && node.property.value === 0;
|
|
470
|
+
const isLastElement = node.property?.type === "BinaryExpression" && node.property.operator === "-" && node.property.right?.type === "Literal" && node.property.right.value === 1;
|
|
471
|
+
if (isFirstElement || isLastElement) {
|
|
472
|
+
const targetFunction = isFirstElement ? "min" : "max";
|
|
473
|
+
context.report({
|
|
474
|
+
node,
|
|
475
|
+
message: `array.sort()[${isFirstElement ? "0" : "length-1"}] for min/max — use Math.${targetFunction}(...array) instead (O(n) vs O(n log n))`
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
} }) };
|
|
479
|
+
const jsSetMapLookups = { create: (context) => createLoopAwareVisitors({ CallExpression(node) {
|
|
480
|
+
if (node.callee?.type !== "MemberExpression" || node.callee.property?.type !== "Identifier") return;
|
|
481
|
+
const methodName = node.callee.property.name;
|
|
482
|
+
if (methodName === "includes" || methodName === "indexOf") context.report({
|
|
483
|
+
node,
|
|
484
|
+
message: `array.${methodName}() in a loop is O(n) per call — convert to a Set for O(1) lookups`
|
|
485
|
+
});
|
|
486
|
+
} }) };
|
|
487
|
+
const jsBatchDomCss = { create: (context) => {
|
|
488
|
+
const isStyleAssignment = (node) => node.type === "ExpressionStatement" && node.expression?.type === "AssignmentExpression" && node.expression.left?.type === "MemberExpression" && node.expression.left.object?.type === "MemberExpression" && node.expression.left.object.property?.type === "Identifier" && node.expression.left.object.property.name === "style";
|
|
489
|
+
return { BlockStatement(node) {
|
|
490
|
+
const statements = node.body ?? [];
|
|
491
|
+
for (let statementIndex = 1; statementIndex < statements.length; statementIndex++) if (isStyleAssignment(statements[statementIndex]) && isStyleAssignment(statements[statementIndex - 1])) context.report({
|
|
492
|
+
node: statements[statementIndex],
|
|
493
|
+
message: "Multiple sequential element.style assignments — batch with cssText or classList for fewer reflows"
|
|
494
|
+
});
|
|
495
|
+
} };
|
|
496
|
+
} };
|
|
497
|
+
const jsIndexMaps = { create: (context) => createLoopAwareVisitors({ CallExpression(node) {
|
|
498
|
+
if (node.callee?.type !== "MemberExpression" || node.callee.property?.type !== "Identifier") return;
|
|
499
|
+
const methodName = node.callee.property.name;
|
|
500
|
+
if (methodName === "find" || methodName === "findIndex") context.report({
|
|
501
|
+
node,
|
|
502
|
+
message: `array.${methodName}() in a loop is O(n*m) — build a Map for O(1) lookups`
|
|
503
|
+
});
|
|
504
|
+
} }) };
|
|
505
|
+
const jsCacheStorage = { create: (context) => {
|
|
506
|
+
const storageReadCounts = /* @__PURE__ */ new Map();
|
|
507
|
+
return { CallExpression(node) {
|
|
508
|
+
if (!isMemberProperty(node.callee, "getItem")) return;
|
|
509
|
+
if (node.callee.object?.type !== "Identifier" || !STORAGE_OBJECTS.has(node.callee.object.name)) return;
|
|
510
|
+
if (node.arguments?.[0]?.type !== "Literal") return;
|
|
511
|
+
const storageKey = String(node.arguments[0].value);
|
|
512
|
+
const readCount = (storageReadCounts.get(storageKey) ?? 0) + 1;
|
|
513
|
+
storageReadCounts.set(storageKey, readCount);
|
|
514
|
+
if (readCount === DUPLICATE_STORAGE_READ_THRESHOLD) {
|
|
515
|
+
const storageName = node.callee.object.name;
|
|
516
|
+
context.report({
|
|
517
|
+
node,
|
|
518
|
+
message: `${storageName}.getItem("${storageKey}") called multiple times — cache the result in a variable`
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
} };
|
|
522
|
+
} };
|
|
523
|
+
const jsEarlyExit = { create: (context) => ({ IfStatement(node) {
|
|
524
|
+
if (node.consequent?.type !== "BlockStatement" || !node.consequent.body) return;
|
|
525
|
+
let nestingDepth = 0;
|
|
526
|
+
let currentBlock = node.consequent;
|
|
527
|
+
while (currentBlock?.type === "BlockStatement" && currentBlock.body?.length === 1) {
|
|
528
|
+
const innerStatement = currentBlock.body[0];
|
|
529
|
+
if (innerStatement.type !== "IfStatement") break;
|
|
530
|
+
nestingDepth++;
|
|
531
|
+
currentBlock = innerStatement.consequent;
|
|
532
|
+
}
|
|
533
|
+
if (nestingDepth >= DEEP_NESTING_THRESHOLD) context.report({
|
|
534
|
+
node,
|
|
535
|
+
message: `${nestingDepth + 1} levels of nested if statements — use early returns to flatten`
|
|
536
|
+
});
|
|
537
|
+
} }) };
|
|
538
|
+
const asyncParallel = { create: (context) => ({ BlockStatement(node) {
|
|
539
|
+
const consecutiveAwaitStatements = [];
|
|
540
|
+
const flushConsecutiveAwaits = () => {
|
|
541
|
+
if (consecutiveAwaitStatements.length >= SEQUENTIAL_AWAIT_THRESHOLD) reportIfIndependent(consecutiveAwaitStatements, context);
|
|
542
|
+
consecutiveAwaitStatements.length = 0;
|
|
543
|
+
};
|
|
544
|
+
for (const statement of node.body ?? []) if (statement.type === "VariableDeclaration" && statement.declarations?.length === 1 && statement.declarations[0].init?.type === "AwaitExpression" || statement.type === "ExpressionStatement" && statement.expression?.type === "AwaitExpression") consecutiveAwaitStatements.push(statement);
|
|
545
|
+
else flushConsecutiveAwaits();
|
|
546
|
+
flushConsecutiveAwaits();
|
|
547
|
+
} }) };
|
|
548
|
+
const reportIfIndependent = (statements, context) => {
|
|
549
|
+
const declaredNames = /* @__PURE__ */ new Set();
|
|
550
|
+
for (const statement of statements) {
|
|
551
|
+
if (statement.type !== "VariableDeclaration") continue;
|
|
552
|
+
const declarator = statement.declarations[0];
|
|
553
|
+
const awaitArgument = declarator.init?.argument;
|
|
554
|
+
let referencesEarlierResult = false;
|
|
555
|
+
walkAst(awaitArgument, (child) => {
|
|
556
|
+
if (child.type === "Identifier" && declaredNames.has(child.name)) referencesEarlierResult = true;
|
|
557
|
+
});
|
|
558
|
+
if (referencesEarlierResult) return;
|
|
559
|
+
if (declarator.id?.type === "Identifier") declaredNames.add(declarator.id.name);
|
|
560
|
+
}
|
|
561
|
+
context.report({
|
|
562
|
+
node: statements[0],
|
|
563
|
+
message: `${statements.length} sequential await statements that appear independent — use Promise.all() for parallel execution`
|
|
564
|
+
});
|
|
565
|
+
};
|
|
566
|
+
|
|
567
|
+
//#endregion
|
|
568
|
+
//#region src/plugin/rules/nextjs.ts
|
|
569
|
+
const nextjsNoImgElement = { create: (context) => ({ JSXOpeningElement(node) {
|
|
570
|
+
if (node.name?.type === "JSXIdentifier" && node.name.name === "img") context.report({
|
|
571
|
+
node,
|
|
572
|
+
message: "Use next/image instead of <img> — provides automatic optimization, lazy loading, and responsive srcset"
|
|
573
|
+
});
|
|
574
|
+
} }) };
|
|
575
|
+
const nextjsAsyncClientComponent = { create: (context) => {
|
|
576
|
+
let fileHasUseClient = false;
|
|
577
|
+
return {
|
|
578
|
+
Program(programNode) {
|
|
579
|
+
fileHasUseClient = hasDirective(programNode, "use client");
|
|
580
|
+
},
|
|
581
|
+
FunctionDeclaration(node) {
|
|
582
|
+
if (!fileHasUseClient || !node.async) return;
|
|
583
|
+
if (!node.id?.name || !isUppercaseName(node.id.name)) return;
|
|
584
|
+
context.report({
|
|
585
|
+
node,
|
|
586
|
+
message: `Async client component "${node.id.name}" — client components cannot be async`
|
|
587
|
+
});
|
|
588
|
+
},
|
|
589
|
+
VariableDeclarator(node) {
|
|
590
|
+
if (!fileHasUseClient) return;
|
|
591
|
+
if (!isComponentAssignment(node) || !node.init?.async) return;
|
|
592
|
+
context.report({
|
|
593
|
+
node,
|
|
594
|
+
message: `Async client component "${node.id.name}" — client components cannot be async`
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
};
|
|
598
|
+
} };
|
|
599
|
+
const nextjsNoAElement = { create: (context) => ({ JSXOpeningElement(node) {
|
|
600
|
+
if (node.name?.type !== "JSXIdentifier" || node.name.name !== "a") return;
|
|
601
|
+
const hrefAttribute = findJsxAttribute(node.attributes ?? [], "href");
|
|
602
|
+
if (!hrefAttribute?.value) return;
|
|
603
|
+
const hrefValue = hrefAttribute.value.type === "Literal" ? hrefAttribute.value.value : hrefAttribute.value.type === "JSXExpressionContainer" && hrefAttribute.value.expression?.type === "Literal" ? hrefAttribute.value.expression.value : null;
|
|
604
|
+
if (typeof hrefValue === "string" && hrefValue.startsWith("/")) context.report({
|
|
605
|
+
node,
|
|
606
|
+
message: "Use next/link instead of <a> for internal links — enables client-side navigation and prefetching"
|
|
607
|
+
});
|
|
608
|
+
} }) };
|
|
609
|
+
const nextjsNoUseSearchParamsWithoutSuspense = { create: (context) => ({ CallExpression(node) {
|
|
610
|
+
if (!isHookCall(node, "useSearchParams")) return;
|
|
611
|
+
context.report({
|
|
612
|
+
node,
|
|
613
|
+
message: "useSearchParams() requires a <Suspense> boundary — without one, the entire page bails out to client-side rendering"
|
|
614
|
+
});
|
|
615
|
+
} }) };
|
|
616
|
+
const nextjsNoClientFetchForServerData = { create: (context) => {
|
|
617
|
+
let fileHasUseClient = false;
|
|
618
|
+
return {
|
|
619
|
+
Program(programNode) {
|
|
620
|
+
fileHasUseClient = hasDirective(programNode, "use client");
|
|
621
|
+
},
|
|
622
|
+
CallExpression(node) {
|
|
623
|
+
if (!fileHasUseClient || !isHookCall(node, EFFECT_HOOK_NAMES)) return;
|
|
624
|
+
const callback = getEffectCallback(node);
|
|
625
|
+
if (!callback || !containsFetchCall(callback)) return;
|
|
626
|
+
const filename = context.getFilename?.() ?? "";
|
|
627
|
+
if (PAGE_OR_LAYOUT_FILE_PATTERN.test(filename) || PAGES_DIRECTORY_PATTERN.test(filename)) context.report({
|
|
628
|
+
node,
|
|
629
|
+
message: "useEffect + fetch in a page/layout — fetch data server-side with a server component instead"
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
};
|
|
633
|
+
} };
|
|
634
|
+
const nextjsMissingMetadata = { create: (context) => ({ Program(programNode) {
|
|
635
|
+
const filename = context.getFilename?.() ?? "";
|
|
636
|
+
if (!PAGE_FILE_PATTERN.test(filename)) return;
|
|
637
|
+
if (!programNode.body?.some((statement) => {
|
|
638
|
+
if (statement.type !== "ExportNamedDeclaration") return false;
|
|
639
|
+
const declaration = statement.declaration;
|
|
640
|
+
if (declaration?.type === "VariableDeclaration") return declaration.declarations?.some((declarator) => declarator.id?.type === "Identifier" && (declarator.id.name === "metadata" || declarator.id.name === "generateMetadata"));
|
|
641
|
+
if (declaration?.type === "FunctionDeclaration") return declaration.id?.name === "generateMetadata";
|
|
642
|
+
return false;
|
|
643
|
+
})) context.report({
|
|
644
|
+
node: programNode,
|
|
645
|
+
message: "Page without metadata or generateMetadata export — hurts SEO"
|
|
646
|
+
});
|
|
647
|
+
} }) };
|
|
648
|
+
const isClientSideRedirect = (node) => {
|
|
649
|
+
if (node.type === "CallExpression" && node.callee?.type === "MemberExpression") {
|
|
650
|
+
if ((node.callee.object?.type === "Identifier" ? node.callee.object.name : null) === "router" && (isMemberProperty(node.callee, "push") || isMemberProperty(node.callee, "replace"))) return true;
|
|
651
|
+
}
|
|
652
|
+
if (node.type === "AssignmentExpression" && node.left?.type === "MemberExpression") {
|
|
653
|
+
const objectName = node.left.object?.type === "Identifier" ? node.left.object.name : null;
|
|
654
|
+
const propertyName = node.left.property?.type === "Identifier" ? node.left.property.name : null;
|
|
655
|
+
if (objectName === "window" && propertyName === "location") return true;
|
|
656
|
+
if (objectName === "location" && propertyName === "href") return true;
|
|
657
|
+
}
|
|
658
|
+
return false;
|
|
659
|
+
};
|
|
660
|
+
const nextjsNoClientSideRedirect = { create: (context) => ({ CallExpression(node) {
|
|
661
|
+
if (!isHookCall(node, EFFECT_HOOK_NAMES)) return;
|
|
662
|
+
const callback = getEffectCallback(node);
|
|
663
|
+
if (!callback) return;
|
|
664
|
+
walkAst(callback, (child) => {
|
|
665
|
+
if (isClientSideRedirect(child)) context.report({
|
|
666
|
+
node: child,
|
|
667
|
+
message: "Client-side redirect in useEffect — use redirect() in a server component or middleware instead"
|
|
668
|
+
});
|
|
669
|
+
});
|
|
670
|
+
} }) };
|
|
671
|
+
const nextjsNoRedirectInTryCatch = { create: (context) => {
|
|
672
|
+
let tryCatchDepth = 0;
|
|
673
|
+
return {
|
|
674
|
+
TryStatement() {
|
|
675
|
+
tryCatchDepth++;
|
|
676
|
+
},
|
|
677
|
+
"TryStatement:exit"() {
|
|
678
|
+
tryCatchDepth--;
|
|
679
|
+
},
|
|
680
|
+
CallExpression(node) {
|
|
681
|
+
if (tryCatchDepth === 0) return;
|
|
682
|
+
if (node.callee?.type !== "Identifier") return;
|
|
683
|
+
if (!NEXTJS_NAVIGATION_FUNCTIONS.has(node.callee.name)) return;
|
|
684
|
+
context.report({
|
|
685
|
+
node,
|
|
686
|
+
message: `${node.callee.name}() inside try-catch — this throws a special error Next.js handles internally. Move it outside the try block or use unstable_rethrow() in the catch`
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
};
|
|
690
|
+
} };
|
|
691
|
+
const nextjsImageMissingSizes = { create: (context) => ({ JSXOpeningElement(node) {
|
|
692
|
+
if (node.name?.type !== "JSXIdentifier" || node.name.name !== "Image") return;
|
|
693
|
+
const attributes = node.attributes ?? [];
|
|
694
|
+
if (!hasJsxAttribute(attributes, "fill")) return;
|
|
695
|
+
if (hasJsxAttribute(attributes, "sizes")) return;
|
|
696
|
+
context.report({
|
|
697
|
+
node,
|
|
698
|
+
message: "next/image with fill but no sizes — the browser downloads the largest image. Add a sizes attribute for responsive behavior"
|
|
699
|
+
});
|
|
700
|
+
} }) };
|
|
701
|
+
const nextjsNoNativeScript = { create: (context) => ({ JSXOpeningElement(node) {
|
|
702
|
+
if (node.name?.type !== "JSXIdentifier" || node.name.name !== "script") return;
|
|
703
|
+
context.report({
|
|
704
|
+
node,
|
|
705
|
+
message: "Use next/script <Script> instead of <script> — provides loading strategy optimization and deferred loading"
|
|
706
|
+
});
|
|
707
|
+
} }) };
|
|
708
|
+
const nextjsInlineScriptMissingId = { create: (context) => ({ JSXOpeningElement(node) {
|
|
709
|
+
if (node.name?.type !== "JSXIdentifier" || node.name.name !== "Script") return;
|
|
710
|
+
const attributes = node.attributes ?? [];
|
|
711
|
+
if (hasJsxAttribute(attributes, "src")) return;
|
|
712
|
+
if (hasJsxAttribute(attributes, "id")) return;
|
|
713
|
+
context.report({
|
|
714
|
+
node,
|
|
715
|
+
message: "Inline <Script> without id — Next.js requires an id attribute to track inline scripts"
|
|
716
|
+
});
|
|
717
|
+
} }) };
|
|
718
|
+
const nextjsNoFontLink = { create: (context) => ({ JSXOpeningElement(node) {
|
|
719
|
+
if (node.name?.type !== "JSXIdentifier" || node.name.name !== "link") return;
|
|
720
|
+
const hrefAttribute = findJsxAttribute(node.attributes ?? [], "href");
|
|
721
|
+
if (!hrefAttribute?.value) return;
|
|
722
|
+
const hrefValue = hrefAttribute.value.type === "Literal" ? hrefAttribute.value.value : null;
|
|
723
|
+
if (typeof hrefValue === "string" && GOOGLE_FONTS_PATTERN.test(hrefValue)) context.report({
|
|
724
|
+
node,
|
|
725
|
+
message: "Loading Google Fonts via <link> — use next/font instead for self-hosting, zero layout shift, and no render-blocking requests"
|
|
726
|
+
});
|
|
727
|
+
} }) };
|
|
728
|
+
const nextjsNoCssLink = { create: (context) => ({ JSXOpeningElement(node) {
|
|
729
|
+
if (node.name?.type !== "JSXIdentifier" || node.name.name !== "link") return;
|
|
730
|
+
const attributes = node.attributes ?? [];
|
|
731
|
+
const relAttribute = findJsxAttribute(attributes, "rel");
|
|
732
|
+
if (!relAttribute?.value) return;
|
|
733
|
+
if ((relAttribute.value.type === "Literal" ? relAttribute.value.value : null) !== "stylesheet") return;
|
|
734
|
+
const hrefAttribute = findJsxAttribute(attributes, "href");
|
|
735
|
+
if (!hrefAttribute?.value) return;
|
|
736
|
+
const hrefValue = hrefAttribute.value.type === "Literal" ? hrefAttribute.value.value : null;
|
|
737
|
+
if (typeof hrefValue === "string" && GOOGLE_FONTS_PATTERN.test(hrefValue)) return;
|
|
738
|
+
context.report({
|
|
739
|
+
node,
|
|
740
|
+
message: "<link rel=\"stylesheet\"> tag — import CSS directly for bundling and optimization"
|
|
741
|
+
});
|
|
742
|
+
} }) };
|
|
743
|
+
const nextjsNoPolyfillScript = { create: (context) => ({ JSXOpeningElement(node) {
|
|
744
|
+
if (node.name?.type !== "JSXIdentifier") return;
|
|
745
|
+
if (node.name.name !== "script" && node.name.name !== "Script") return;
|
|
746
|
+
const srcAttribute = findJsxAttribute(node.attributes ?? [], "src");
|
|
747
|
+
if (!srcAttribute?.value) return;
|
|
748
|
+
const srcValue = srcAttribute.value.type === "Literal" ? srcAttribute.value.value : null;
|
|
749
|
+
if (typeof srcValue === "string" && POLYFILL_SCRIPT_PATTERN.test(srcValue)) context.report({
|
|
750
|
+
node,
|
|
751
|
+
message: "Polyfill CDN script — Next.js includes polyfills for fetch, Promise, Object.assign, and 50+ others automatically"
|
|
752
|
+
});
|
|
753
|
+
} }) };
|
|
754
|
+
const nextjsNoHeadImport = { create: (context) => ({ ImportDeclaration(node) {
|
|
755
|
+
if (node.source?.value !== "next/head") return;
|
|
756
|
+
const filename = context.getFilename?.() ?? "";
|
|
757
|
+
if (!APP_DIRECTORY_PATTERN.test(filename)) return;
|
|
758
|
+
context.report({
|
|
759
|
+
node,
|
|
760
|
+
message: "next/head is not supported in the App Router — use the Metadata API instead"
|
|
761
|
+
});
|
|
762
|
+
} }) };
|
|
763
|
+
|
|
764
|
+
//#endregion
|
|
765
|
+
//#region src/plugin/rules/performance.ts
|
|
766
|
+
const noUsememoSimpleExpression = { create: (context) => ({ CallExpression(node) {
|
|
767
|
+
if (!isHookCall(node, "useMemo")) return;
|
|
768
|
+
const callback = node.arguments?.[0];
|
|
769
|
+
if (!callback) return;
|
|
770
|
+
if (callback.type !== "ArrowFunctionExpression" && callback.type !== "FunctionExpression") return;
|
|
771
|
+
const returnExpression = callback.body?.type !== "BlockStatement" ? callback.body : callback.body.body?.length === 1 && callback.body.body[0].type === "ReturnStatement" ? callback.body.body[0].argument : null;
|
|
772
|
+
if (returnExpression && isSimpleExpression(returnExpression)) context.report({
|
|
773
|
+
node,
|
|
774
|
+
message: "useMemo wrapping a trivially cheap expression — memo overhead exceeds the computation"
|
|
775
|
+
});
|
|
776
|
+
} }) };
|
|
777
|
+
const noLayoutPropertyAnimation = { create: (context) => ({ JSXAttribute(node) {
|
|
778
|
+
if (node.name?.type !== "JSXIdentifier" || !MOTION_ANIMATE_PROPS.has(node.name.name)) return;
|
|
779
|
+
if (!node.value || node.value.type !== "JSXExpressionContainer") return;
|
|
780
|
+
const expression = node.value.expression;
|
|
781
|
+
if (expression?.type !== "ObjectExpression") return;
|
|
782
|
+
for (const property of expression.properties ?? []) {
|
|
783
|
+
if (property.type !== "Property") continue;
|
|
784
|
+
const propertyName = property.key?.type === "Identifier" ? property.key.name : property.key?.type === "Literal" ? property.key.value : null;
|
|
785
|
+
if (propertyName && LAYOUT_PROPERTIES.has(propertyName)) context.report({
|
|
786
|
+
node: property,
|
|
787
|
+
message: `Animating layout property "${propertyName}" triggers layout recalculation every frame — use transform/scale or the layout prop`
|
|
788
|
+
});
|
|
789
|
+
}
|
|
790
|
+
} }) };
|
|
791
|
+
const noTransitionAll = { create: (context) => ({ JSXAttribute(node) {
|
|
792
|
+
if (node.name?.type !== "JSXIdentifier" || node.name.name !== "style") return;
|
|
793
|
+
if (node.value?.type !== "JSXExpressionContainer") return;
|
|
794
|
+
const expression = node.value.expression;
|
|
795
|
+
if (expression?.type !== "ObjectExpression") return;
|
|
796
|
+
for (const property of expression.properties ?? []) {
|
|
797
|
+
if (property.type !== "Property") continue;
|
|
798
|
+
if ((property.key?.type === "Identifier" ? property.key.name : null) !== "transition") continue;
|
|
799
|
+
if (property.value?.type === "Literal" && typeof property.value.value === "string" && property.value.value.startsWith("all")) context.report({
|
|
800
|
+
node: property,
|
|
801
|
+
message: "transition: \"all\" animates every property including layout — list only the properties you animate"
|
|
802
|
+
});
|
|
803
|
+
}
|
|
804
|
+
} }) };
|
|
805
|
+
const noGlobalCssVariableAnimation = { create: (context) => ({ CallExpression(node) {
|
|
806
|
+
if (node.callee?.type !== "Identifier") return;
|
|
807
|
+
if (!ANIMATION_CALLBACK_NAMES.has(node.callee.name)) return;
|
|
808
|
+
const callback = node.arguments?.[0];
|
|
809
|
+
if (!callback) return;
|
|
810
|
+
const calleeName = node.callee.name;
|
|
811
|
+
walkAst(callback, (child) => {
|
|
812
|
+
if (child.type !== "CallExpression") return;
|
|
813
|
+
if (!isMemberProperty(child.callee, "setProperty")) return;
|
|
814
|
+
if (child.arguments?.[0]?.type !== "Literal") return;
|
|
815
|
+
const variableName = child.arguments[0].value;
|
|
816
|
+
if (typeof variableName !== "string" || !variableName.startsWith("--")) return;
|
|
817
|
+
context.report({
|
|
818
|
+
node: child,
|
|
819
|
+
message: `CSS variable "${variableName}" updated in ${calleeName} — forces style recalculation on all inheriting elements every frame`
|
|
820
|
+
});
|
|
821
|
+
});
|
|
822
|
+
} }) };
|
|
823
|
+
const noLargeAnimatedBlur = { create: (context) => ({ JSXAttribute(node) {
|
|
824
|
+
if (node.name?.type !== "JSXIdentifier") return;
|
|
825
|
+
if (node.name.name !== "style" && !MOTION_ANIMATE_PROPS.has(node.name.name)) return;
|
|
826
|
+
if (node.value?.type !== "JSXExpressionContainer") return;
|
|
827
|
+
const expression = node.value.expression;
|
|
828
|
+
if (expression?.type !== "ObjectExpression") return;
|
|
829
|
+
for (const property of expression.properties ?? []) {
|
|
830
|
+
if (property.type !== "Property") continue;
|
|
831
|
+
const key = property.key?.type === "Identifier" ? property.key.name : null;
|
|
832
|
+
if (key !== "filter" && key !== "backdropFilter" && key !== "WebkitBackdropFilter") continue;
|
|
833
|
+
if (property.value?.type !== "Literal" || typeof property.value.value !== "string") continue;
|
|
834
|
+
const match = BLUR_VALUE_PATTERN.exec(property.value.value);
|
|
835
|
+
if (!match) continue;
|
|
836
|
+
const blurRadius = Number.parseFloat(match[1]);
|
|
837
|
+
if (blurRadius > LARGE_BLUR_THRESHOLD_PX) context.report({
|
|
838
|
+
node: property,
|
|
839
|
+
message: `blur(${blurRadius}px) is expensive — cost escalates with radius and layer size, can exceed GPU memory on mobile`
|
|
840
|
+
});
|
|
841
|
+
}
|
|
842
|
+
} }) };
|
|
843
|
+
const noScaleFromZero = { create: (context) => ({ JSXAttribute(node) {
|
|
844
|
+
if (node.name?.type !== "JSXIdentifier") return;
|
|
845
|
+
if (node.name.name !== "initial" && node.name.name !== "exit") return;
|
|
846
|
+
if (node.value?.type !== "JSXExpressionContainer") return;
|
|
847
|
+
const expression = node.value.expression;
|
|
848
|
+
if (expression?.type !== "ObjectExpression") return;
|
|
849
|
+
for (const property of expression.properties ?? []) {
|
|
850
|
+
if (property.type !== "Property") continue;
|
|
851
|
+
if ((property.key?.type === "Identifier" ? property.key.name : null) !== "scale") continue;
|
|
852
|
+
if (property.value?.type === "Literal" && property.value.value === 0) context.report({
|
|
853
|
+
node: property,
|
|
854
|
+
message: "scale: 0 makes elements appear from nowhere — use scale: 0.95 with opacity: 0 for natural entrance"
|
|
855
|
+
});
|
|
856
|
+
}
|
|
857
|
+
} }) };
|
|
858
|
+
const noPermanentWillChange = { create: (context) => ({ JSXAttribute(node) {
|
|
859
|
+
if (node.name?.type !== "JSXIdentifier" || node.name.name !== "style") return;
|
|
860
|
+
if (node.value?.type !== "JSXExpressionContainer") return;
|
|
861
|
+
const expression = node.value.expression;
|
|
862
|
+
if (expression?.type !== "ObjectExpression") return;
|
|
863
|
+
for (const property of expression.properties ?? []) {
|
|
864
|
+
if (property.type !== "Property") continue;
|
|
865
|
+
if ((property.key?.type === "Identifier" ? property.key.name : null) !== "willChange") continue;
|
|
866
|
+
context.report({
|
|
867
|
+
node: property,
|
|
868
|
+
message: "Permanent will-change wastes GPU memory — apply only during active animation and remove after"
|
|
869
|
+
});
|
|
870
|
+
}
|
|
871
|
+
} }) };
|
|
872
|
+
const rerenderMemoWithDefaultValue = { create: (context) => {
|
|
873
|
+
const checkDefaultProps = (params) => {
|
|
874
|
+
for (const param of params) {
|
|
875
|
+
if (param.type !== "ObjectPattern") continue;
|
|
876
|
+
for (const property of param.properties ?? []) {
|
|
877
|
+
if (property.type !== "Property" || property.value?.type !== "AssignmentPattern") continue;
|
|
878
|
+
const defaultValue = property.value.right;
|
|
879
|
+
if (defaultValue?.type === "ObjectExpression" && defaultValue.properties?.length === 0) context.report({
|
|
880
|
+
node: defaultValue,
|
|
881
|
+
message: "Default prop value {} creates a new object reference every render — extract to a module-level constant"
|
|
882
|
+
});
|
|
883
|
+
if (defaultValue?.type === "ArrayExpression" && defaultValue.elements?.length === 0) context.report({
|
|
884
|
+
node: defaultValue,
|
|
885
|
+
message: "Default prop value [] creates a new array reference every render — extract to a module-level constant"
|
|
886
|
+
});
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
};
|
|
890
|
+
return {
|
|
891
|
+
FunctionDeclaration(node) {
|
|
892
|
+
if (!node.id?.name || !isUppercaseName(node.id.name)) return;
|
|
893
|
+
checkDefaultProps(node.params ?? []);
|
|
894
|
+
},
|
|
895
|
+
VariableDeclarator(node) {
|
|
896
|
+
if (!isComponentAssignment(node)) return;
|
|
897
|
+
checkDefaultProps(node.init.params ?? []);
|
|
898
|
+
}
|
|
899
|
+
};
|
|
900
|
+
} };
|
|
901
|
+
const renderingAnimateSvgWrapper = { create: (context) => ({ JSXOpeningElement(node) {
|
|
902
|
+
if (node.name?.type !== "JSXIdentifier" || node.name.name !== "svg") return;
|
|
903
|
+
if (node.attributes?.some((attribute) => attribute.type === "JSXAttribute" && attribute.name?.type === "JSXIdentifier" && MOTION_ANIMATE_PROPS.has(attribute.name.name))) context.report({
|
|
904
|
+
node,
|
|
905
|
+
message: "Animation props directly on <svg> — wrap in a <div> or <motion.div> for better rendering performance"
|
|
906
|
+
});
|
|
907
|
+
} }) };
|
|
908
|
+
const renderingUsetransitionLoading = { create: (context) => ({ VariableDeclarator(node) {
|
|
909
|
+
if (node.id?.type !== "ArrayPattern" || !node.id.elements?.length) return;
|
|
910
|
+
if (!node.init || !isHookCall(node.init, "useState")) return;
|
|
911
|
+
if (!node.init.arguments?.length) return;
|
|
912
|
+
const initializer = node.init.arguments[0];
|
|
913
|
+
if (initializer.type !== "Literal" || initializer.value !== false) return;
|
|
914
|
+
const stateVariableName = node.id.elements[0]?.name;
|
|
915
|
+
if (!stateVariableName || !LOADING_STATE_PATTERN.test(stateVariableName)) return;
|
|
916
|
+
context.report({
|
|
917
|
+
node: node.init,
|
|
918
|
+
message: `useState for "${stateVariableName}" — consider useTransition for non-urgent loading states`
|
|
919
|
+
});
|
|
920
|
+
} }) };
|
|
921
|
+
const renderingHydrationNoFlicker = { create: (context) => ({ CallExpression(node) {
|
|
922
|
+
if (!isHookCall(node, EFFECT_HOOK_NAMES) || node.arguments?.length < 2) return;
|
|
923
|
+
const depsNode = node.arguments[1];
|
|
924
|
+
if (depsNode.type !== "ArrayExpression" || depsNode.elements?.length !== 0) return;
|
|
925
|
+
const callback = getEffectCallback(node);
|
|
926
|
+
if (!callback) return;
|
|
927
|
+
const bodyStatements = callback.body?.type === "BlockStatement" ? callback.body.body : [callback.body];
|
|
928
|
+
if (!bodyStatements || bodyStatements.length !== 1) return;
|
|
929
|
+
const soleStatement = bodyStatements[0];
|
|
930
|
+
if (soleStatement?.type === "ExpressionStatement" && soleStatement.expression?.type === "CallExpression" && soleStatement.expression.callee?.type === "Identifier" && SETTER_PATTERN.test(soleStatement.expression.callee.name)) context.report({
|
|
931
|
+
node,
|
|
932
|
+
message: "useEffect(setState, []) on mount causes a flash — consider useSyncExternalStore or suppressHydrationWarning"
|
|
933
|
+
});
|
|
934
|
+
} }) };
|
|
935
|
+
|
|
936
|
+
//#endregion
|
|
937
|
+
//#region src/plugin/rules/security.ts
|
|
938
|
+
const noEval = { create: (context) => ({
|
|
939
|
+
CallExpression(node) {
|
|
940
|
+
if (node.callee?.type === "Identifier" && node.callee.name === "eval") {
|
|
941
|
+
context.report({
|
|
942
|
+
node,
|
|
943
|
+
message: "eval() is a code injection risk — avoid dynamic code execution"
|
|
944
|
+
});
|
|
945
|
+
return;
|
|
946
|
+
}
|
|
947
|
+
if (node.callee?.type === "Identifier" && (node.callee.name === "setTimeout" || node.callee.name === "setInterval") && node.arguments?.[0]?.type === "Literal" && typeof node.arguments[0].value === "string") context.report({
|
|
948
|
+
node,
|
|
949
|
+
message: `${node.callee.name}() with string argument executes code dynamically — use a function instead`
|
|
950
|
+
});
|
|
951
|
+
},
|
|
952
|
+
NewExpression(node) {
|
|
953
|
+
if (node.callee?.type === "Identifier" && node.callee.name === "Function") context.report({
|
|
954
|
+
node,
|
|
955
|
+
message: "new Function() is a code injection risk — avoid dynamic code execution"
|
|
956
|
+
});
|
|
957
|
+
}
|
|
958
|
+
}) };
|
|
959
|
+
const noSecretsInClientCode = { create: (context) => ({ VariableDeclarator(node) {
|
|
960
|
+
if (node.id?.type !== "Identifier") return;
|
|
961
|
+
if (node.init?.type !== "Literal" || typeof node.init.value !== "string") return;
|
|
962
|
+
const variableName = node.id.name;
|
|
963
|
+
const literalValue = node.init.value;
|
|
964
|
+
if (SECRET_VARIABLE_PATTERN.test(variableName) && literalValue.length > SECRET_MIN_LENGTH_CHARS) {
|
|
965
|
+
context.report({
|
|
966
|
+
node,
|
|
967
|
+
message: `Possible hardcoded secret in "${variableName}" — use environment variables instead`
|
|
968
|
+
});
|
|
969
|
+
return;
|
|
970
|
+
}
|
|
971
|
+
if (SECRET_PATTERNS.some((pattern) => pattern.test(literalValue))) context.report({
|
|
972
|
+
node,
|
|
973
|
+
message: "Hardcoded secret detected — use environment variables instead"
|
|
974
|
+
});
|
|
975
|
+
} }) };
|
|
976
|
+
|
|
977
|
+
//#endregion
|
|
978
|
+
//#region src/plugin/rules/server.ts
|
|
979
|
+
const containsAuthCheck = (statements) => {
|
|
980
|
+
let foundAuthCall = false;
|
|
981
|
+
for (const statement of statements) walkAst(statement, (child) => {
|
|
982
|
+
if (foundAuthCall) return;
|
|
983
|
+
const callNode = child.type === "CallExpression" ? child : child.type === "AwaitExpression" && child.argument?.type === "CallExpression" ? child.argument : null;
|
|
984
|
+
if (callNode?.callee?.type === "Identifier" && AUTH_FUNCTION_NAMES.has(callNode.callee.name)) foundAuthCall = true;
|
|
985
|
+
});
|
|
986
|
+
return foundAuthCall;
|
|
987
|
+
};
|
|
988
|
+
const serverAuthActions = { create: (context) => {
|
|
989
|
+
let fileHasUseServerDirective = false;
|
|
990
|
+
return {
|
|
991
|
+
Program(programNode) {
|
|
992
|
+
fileHasUseServerDirective = hasDirective(programNode, "use server");
|
|
993
|
+
},
|
|
994
|
+
ExportNamedDeclaration(node) {
|
|
995
|
+
const declaration = node.declaration;
|
|
996
|
+
if (declaration?.type !== "FunctionDeclaration" || !declaration?.async) return;
|
|
997
|
+
if (!(fileHasUseServerDirective || hasUseServerDirective(declaration))) return;
|
|
998
|
+
if (!containsAuthCheck((declaration.body?.body ?? []).slice(0, AUTH_CHECK_LOOKAHEAD_STATEMENTS))) {
|
|
999
|
+
const functionName = declaration.id?.name ?? "anonymous";
|
|
1000
|
+
context.report({
|
|
1001
|
+
node: declaration.id ?? node,
|
|
1002
|
+
message: `Server action "${functionName}" — add auth check (auth(), getSession(), etc.) at the top`
|
|
1003
|
+
});
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
};
|
|
1007
|
+
} };
|
|
1008
|
+
const serverAfterNonblocking = { create: (context) => ({ CallExpression(node) {
|
|
1009
|
+
if (node.callee?.type !== "MemberExpression") return;
|
|
1010
|
+
if (node.callee.property?.type !== "Identifier") return;
|
|
1011
|
+
const objectName = node.callee.object?.type === "Identifier" ? node.callee.object.name : null;
|
|
1012
|
+
if (!objectName) return;
|
|
1013
|
+
const methodName = node.callee.property.name;
|
|
1014
|
+
if (!(objectName === "console" && (methodName === "log" || methodName === "info" || methodName === "warn") || objectName === "analytics" && (methodName === "track" || methodName === "identify" || methodName === "page"))) return;
|
|
1015
|
+
const filename = context.getFilename?.() ?? "";
|
|
1016
|
+
if (SERVER_ACTION_FILE_PATTERN.test(filename) || SERVER_ACTION_DIRECTORY_PATTERN.test(filename)) context.report({
|
|
1017
|
+
node,
|
|
1018
|
+
message: `${objectName}.${methodName}() in server action — use after() for non-blocking logging/analytics`
|
|
1019
|
+
});
|
|
1020
|
+
} }) };
|
|
1021
|
+
|
|
1022
|
+
//#endregion
|
|
1023
|
+
//#region src/plugin/rules/state-and-effects.ts
|
|
1024
|
+
const noDerivedStateEffect = { create: (context) => ({ CallExpression(node) {
|
|
1025
|
+
if (!isHookCall(node, EFFECT_HOOK_NAMES) || node.arguments.length < 2) return;
|
|
1026
|
+
const callback = getEffectCallback(node);
|
|
1027
|
+
if (!callback) return;
|
|
1028
|
+
const depsNode = node.arguments[1];
|
|
1029
|
+
if (depsNode.type !== "ArrayExpression" || !depsNode.elements?.length) return;
|
|
1030
|
+
const dependencyNames = new Set(depsNode.elements.filter((element) => element?.type === "Identifier").map((element) => element.name));
|
|
1031
|
+
if (dependencyNames.size === 0) return;
|
|
1032
|
+
const statements = getCallbackStatements(callback);
|
|
1033
|
+
if (statements.length === 0) return;
|
|
1034
|
+
if (!statements.every((statement) => statement.type === "ExpressionStatement" && statement.expression?.type === "CallExpression" && statement.expression.callee?.type === "Identifier" && isSetterIdentifier(statement.expression.callee.name))) return;
|
|
1035
|
+
let allArgumentsDeriveFromDeps = true;
|
|
1036
|
+
for (const statement of statements) {
|
|
1037
|
+
const setStateArguments = statement.expression.arguments;
|
|
1038
|
+
if (!setStateArguments?.length) continue;
|
|
1039
|
+
const referencedIdentifiers = [];
|
|
1040
|
+
walkAst(setStateArguments[0], (child) => {
|
|
1041
|
+
if (child.type === "Identifier") referencedIdentifiers.push(child.name);
|
|
1042
|
+
});
|
|
1043
|
+
if (referencedIdentifiers.some((name) => !dependencyNames.has(name) && !isSetterIdentifier(name))) {
|
|
1044
|
+
allArgumentsDeriveFromDeps = false;
|
|
1045
|
+
break;
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
if (allArgumentsDeriveFromDeps) context.report({
|
|
1049
|
+
node,
|
|
1050
|
+
message: "Derived state in useEffect — compute during render instead"
|
|
1051
|
+
});
|
|
1052
|
+
} }) };
|
|
1053
|
+
const noFetchInEffect = { create: (context) => ({ CallExpression(node) {
|
|
1054
|
+
if (!isHookCall(node, EFFECT_HOOK_NAMES)) return;
|
|
1055
|
+
const callback = getEffectCallback(node);
|
|
1056
|
+
if (!callback) return;
|
|
1057
|
+
if (containsFetchCall(callback)) context.report({
|
|
1058
|
+
node,
|
|
1059
|
+
message: "fetch() inside useEffect — use a data fetching library (react-query, SWR) or server component"
|
|
1060
|
+
});
|
|
1061
|
+
} }) };
|
|
1062
|
+
const noCascadingSetState = { create: (context) => ({ CallExpression(node) {
|
|
1063
|
+
if (!isHookCall(node, EFFECT_HOOK_NAMES)) return;
|
|
1064
|
+
const callback = getEffectCallback(node);
|
|
1065
|
+
if (!callback) return;
|
|
1066
|
+
const setStateCallCount = countSetStateCalls(callback);
|
|
1067
|
+
if (setStateCallCount >= CASCADING_SET_STATE_THRESHOLD) context.report({
|
|
1068
|
+
node,
|
|
1069
|
+
message: `${setStateCallCount} setState calls in a single useEffect — consider using useReducer or deriving state`
|
|
1070
|
+
});
|
|
1071
|
+
} }) };
|
|
1072
|
+
const noEffectEventHandler = { create: (context) => ({ CallExpression(node) {
|
|
1073
|
+
if (!isHookCall(node, EFFECT_HOOK_NAMES) || node.arguments.length < 2) return;
|
|
1074
|
+
const callback = getEffectCallback(node);
|
|
1075
|
+
if (!callback) return;
|
|
1076
|
+
const depsNode = node.arguments[1];
|
|
1077
|
+
if (depsNode.type !== "ArrayExpression" || !depsNode.elements?.length) return;
|
|
1078
|
+
const dependencyNames = new Set(depsNode.elements.filter((element) => element?.type === "Identifier").map((element) => element.name));
|
|
1079
|
+
if (getCallbackStatements(callback).some((statement) => statement.type === "IfStatement" && statement.test?.type === "Identifier" && dependencyNames.has(statement.test.name))) context.report({
|
|
1080
|
+
node,
|
|
1081
|
+
message: "useEffect simulating an event handler — move logic to an actual event handler instead"
|
|
1082
|
+
});
|
|
1083
|
+
} }) };
|
|
1084
|
+
const noDerivedUseState = { create: (context) => {
|
|
1085
|
+
const componentPropNames = /* @__PURE__ */ new Set();
|
|
1086
|
+
return {
|
|
1087
|
+
FunctionDeclaration(node) {
|
|
1088
|
+
if (!node.id?.name || !isUppercaseName(node.id.name)) return;
|
|
1089
|
+
for (const name of extractDestructuredPropNames(node.params ?? [])) componentPropNames.add(name);
|
|
1090
|
+
},
|
|
1091
|
+
VariableDeclarator(node) {
|
|
1092
|
+
if (!isComponentAssignment(node)) return;
|
|
1093
|
+
for (const name of extractDestructuredPropNames(node.init?.params ?? [])) componentPropNames.add(name);
|
|
1094
|
+
},
|
|
1095
|
+
CallExpression(node) {
|
|
1096
|
+
if (!isHookCall(node, "useState") || !node.arguments?.length) return;
|
|
1097
|
+
const initializer = node.arguments[0];
|
|
1098
|
+
if (initializer.type !== "Identifier") return;
|
|
1099
|
+
if (componentPropNames.has(initializer.name)) context.report({
|
|
1100
|
+
node,
|
|
1101
|
+
message: `useState initialized from prop "${initializer.name}" — derive it during render instead of syncing with state`
|
|
1102
|
+
});
|
|
1103
|
+
}
|
|
1104
|
+
};
|
|
1105
|
+
} };
|
|
1106
|
+
const preferUseReducer = { create: (context) => {
|
|
1107
|
+
const reportExcessiveUseState = (body, componentName) => {
|
|
1108
|
+
if (body.type !== "BlockStatement") return;
|
|
1109
|
+
let useStateCount = 0;
|
|
1110
|
+
for (const statement of body.body ?? []) {
|
|
1111
|
+
if (statement.type !== "VariableDeclaration") continue;
|
|
1112
|
+
for (const declarator of statement.declarations ?? []) if (isHookCall(declarator.init, "useState")) useStateCount++;
|
|
1113
|
+
}
|
|
1114
|
+
if (useStateCount >= RELATED_USE_STATE_THRESHOLD) context.report({
|
|
1115
|
+
node: body,
|
|
1116
|
+
message: `Component "${componentName}" has ${useStateCount} useState calls — consider useReducer for related state`
|
|
1117
|
+
});
|
|
1118
|
+
};
|
|
1119
|
+
return {
|
|
1120
|
+
FunctionDeclaration(node) {
|
|
1121
|
+
if (!node.id?.name || !isUppercaseName(node.id.name)) return;
|
|
1122
|
+
reportExcessiveUseState(node.body, node.id.name);
|
|
1123
|
+
},
|
|
1124
|
+
VariableDeclarator(node) {
|
|
1125
|
+
if (!isComponentAssignment(node)) return;
|
|
1126
|
+
reportExcessiveUseState(node.init.body, node.id.name);
|
|
1127
|
+
}
|
|
1128
|
+
};
|
|
1129
|
+
} };
|
|
1130
|
+
const rerenderLazyStateInit = { create: (context) => ({ CallExpression(node) {
|
|
1131
|
+
if (!isHookCall(node, "useState") || !node.arguments?.length) return;
|
|
1132
|
+
const initializer = node.arguments[0];
|
|
1133
|
+
if (initializer.type !== "CallExpression") return;
|
|
1134
|
+
const calleeName = initializer.callee?.type === "Identifier" ? initializer.callee.name : initializer.callee?.property?.name ?? "fn";
|
|
1135
|
+
context.report({
|
|
1136
|
+
node: initializer,
|
|
1137
|
+
message: `useState(${calleeName}()) calls initializer on every render — use useState(() => ${calleeName}()) for lazy initialization`
|
|
1138
|
+
});
|
|
1139
|
+
} }) };
|
|
1140
|
+
const rerenderFunctionalSetstate = { create: (context) => ({ CallExpression(node) {
|
|
1141
|
+
if (node.callee?.type !== "Identifier" || !isSetterIdentifier(node.callee.name)) return;
|
|
1142
|
+
if (!node.arguments?.length) return;
|
|
1143
|
+
const argument = node.arguments[0];
|
|
1144
|
+
if (argument.type === "BinaryExpression" && (argument.operator === "+" || argument.operator === "-") && argument.left?.type === "Identifier") context.report({
|
|
1145
|
+
node,
|
|
1146
|
+
message: `${node.callee.name}(${argument.left.name} ${argument.operator} ...) — use functional update to avoid stale closures`
|
|
1147
|
+
});
|
|
1148
|
+
} }) };
|
|
1149
|
+
const rerenderDependencies = { create: (context) => ({ CallExpression(node) {
|
|
1150
|
+
if (!isHookCall(node, HOOKS_WITH_DEPS) || node.arguments.length < 2) return;
|
|
1151
|
+
const depsNode = node.arguments[1];
|
|
1152
|
+
if (depsNode.type !== "ArrayExpression") return;
|
|
1153
|
+
for (const element of depsNode.elements ?? []) {
|
|
1154
|
+
if (!element) continue;
|
|
1155
|
+
if (element.type === "ObjectExpression") context.report({
|
|
1156
|
+
node: element,
|
|
1157
|
+
message: "Object literal in useEffect deps — creates new reference every render, causing infinite re-runs"
|
|
1158
|
+
});
|
|
1159
|
+
if (element.type === "ArrayExpression") context.report({
|
|
1160
|
+
node: element,
|
|
1161
|
+
message: "Array literal in useEffect deps — creates new reference every render, causing infinite re-runs"
|
|
1162
|
+
});
|
|
1163
|
+
}
|
|
1164
|
+
} }) };
|
|
1165
|
+
|
|
1166
|
+
//#endregion
|
|
1167
|
+
//#region src/plugin/index.ts
|
|
1168
|
+
const plugin = {
|
|
1169
|
+
meta: { name: "react-doctor" },
|
|
1170
|
+
rules: {
|
|
1171
|
+
"no-derived-state-effect": noDerivedStateEffect,
|
|
1172
|
+
"no-fetch-in-effect": noFetchInEffect,
|
|
1173
|
+
"no-cascading-set-state": noCascadingSetState,
|
|
1174
|
+
"no-effect-event-handler": noEffectEventHandler,
|
|
1175
|
+
"no-derived-useState": noDerivedUseState,
|
|
1176
|
+
"prefer-useReducer": preferUseReducer,
|
|
1177
|
+
"rerender-lazy-state-init": rerenderLazyStateInit,
|
|
1178
|
+
"rerender-functional-setstate": rerenderFunctionalSetstate,
|
|
1179
|
+
"rerender-dependencies": rerenderDependencies,
|
|
1180
|
+
"no-generic-handler-names": noGenericHandlerNames,
|
|
1181
|
+
"no-giant-component": noGiantComponent,
|
|
1182
|
+
"no-render-in-render": noRenderInRender,
|
|
1183
|
+
"no-nested-component-definition": noNestedComponentDefinition,
|
|
1184
|
+
"no-usememo-simple-expression": noUsememoSimpleExpression,
|
|
1185
|
+
"no-layout-property-animation": noLayoutPropertyAnimation,
|
|
1186
|
+
"rerender-memo-with-default-value": rerenderMemoWithDefaultValue,
|
|
1187
|
+
"rendering-animate-svg-wrapper": renderingAnimateSvgWrapper,
|
|
1188
|
+
"rendering-usetransition-loading": renderingUsetransitionLoading,
|
|
1189
|
+
"rendering-hydration-no-flicker": renderingHydrationNoFlicker,
|
|
1190
|
+
"no-transition-all": noTransitionAll,
|
|
1191
|
+
"no-global-css-variable-animation": noGlobalCssVariableAnimation,
|
|
1192
|
+
"no-large-animated-blur": noLargeAnimatedBlur,
|
|
1193
|
+
"no-scale-from-zero": noScaleFromZero,
|
|
1194
|
+
"no-permanent-will-change": noPermanentWillChange,
|
|
1195
|
+
"no-eval": noEval,
|
|
1196
|
+
"no-secrets-in-client-code": noSecretsInClientCode,
|
|
1197
|
+
"no-barrel-import": noBarrelImport,
|
|
1198
|
+
"no-full-lodash-import": noFullLodashImport,
|
|
1199
|
+
"no-moment": noMoment,
|
|
1200
|
+
"prefer-dynamic-import": preferDynamicImport,
|
|
1201
|
+
"use-lazy-motion": useLazyMotion,
|
|
1202
|
+
"no-undeferred-third-party": noUndeferredThirdParty,
|
|
1203
|
+
"no-array-index-as-key": noArrayIndexAsKey,
|
|
1204
|
+
"rendering-conditional-render": renderingConditionalRender,
|
|
1205
|
+
"no-prevent-default": noPreventDefault,
|
|
1206
|
+
"nextjs-no-img-element": nextjsNoImgElement,
|
|
1207
|
+
"nextjs-async-client-component": nextjsAsyncClientComponent,
|
|
1208
|
+
"nextjs-no-a-element": nextjsNoAElement,
|
|
1209
|
+
"nextjs-no-use-search-params-without-suspense": nextjsNoUseSearchParamsWithoutSuspense,
|
|
1210
|
+
"nextjs-no-client-fetch-for-server-data": nextjsNoClientFetchForServerData,
|
|
1211
|
+
"nextjs-missing-metadata": nextjsMissingMetadata,
|
|
1212
|
+
"nextjs-no-client-side-redirect": nextjsNoClientSideRedirect,
|
|
1213
|
+
"nextjs-no-redirect-in-try-catch": nextjsNoRedirectInTryCatch,
|
|
1214
|
+
"nextjs-image-missing-sizes": nextjsImageMissingSizes,
|
|
1215
|
+
"nextjs-no-native-script": nextjsNoNativeScript,
|
|
1216
|
+
"nextjs-inline-script-missing-id": nextjsInlineScriptMissingId,
|
|
1217
|
+
"nextjs-no-font-link": nextjsNoFontLink,
|
|
1218
|
+
"nextjs-no-css-link": nextjsNoCssLink,
|
|
1219
|
+
"nextjs-no-polyfill-script": nextjsNoPolyfillScript,
|
|
1220
|
+
"nextjs-no-head-import": nextjsNoHeadImport,
|
|
1221
|
+
"server-auth-actions": serverAuthActions,
|
|
1222
|
+
"server-after-nonblocking": serverAfterNonblocking,
|
|
1223
|
+
"client-passive-event-listeners": clientPassiveEventListeners,
|
|
1224
|
+
"js-combine-iterations": jsCombineIterations,
|
|
1225
|
+
"js-tosorted-immutable": jsTosortedImmutable,
|
|
1226
|
+
"js-hoist-regexp": jsHoistRegexp,
|
|
1227
|
+
"js-min-max-loop": jsMinMaxLoop,
|
|
1228
|
+
"js-set-map-lookups": jsSetMapLookups,
|
|
1229
|
+
"js-batch-dom-css": jsBatchDomCss,
|
|
1230
|
+
"js-index-maps": jsIndexMaps,
|
|
1231
|
+
"js-cache-storage": jsCacheStorage,
|
|
1232
|
+
"js-early-exit": jsEarlyExit,
|
|
1233
|
+
"async-parallel": asyncParallel
|
|
1234
|
+
}
|
|
1235
|
+
};
|
|
1236
|
+
|
|
1237
|
+
//#endregion
|
|
1238
|
+
export { plugin as default };
|
|
1239
|
+
//# sourceMappingURL=react-doctor-plugin.js.map
|