react-code-smell-detector 1.0.0 → 1.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/dist/analyzer.d.ts.map +1 -1
- package/dist/analyzer.js +13 -1
- package/dist/cli.js +0 -0
- package/dist/detectors/deadCode.d.ts +14 -0
- package/dist/detectors/deadCode.d.ts.map +1 -0
- package/dist/detectors/deadCode.js +141 -0
- package/dist/detectors/dependencyArray.d.ts +7 -0
- package/dist/detectors/dependencyArray.d.ts.map +1 -0
- package/dist/detectors/dependencyArray.js +164 -0
- package/dist/detectors/hooksRules.d.ts +7 -0
- package/dist/detectors/hooksRules.d.ts.map +1 -0
- package/dist/detectors/hooksRules.js +81 -0
- package/dist/detectors/index.d.ts +6 -0
- package/dist/detectors/index.d.ts.map +1 -1
- package/dist/detectors/index.js +6 -0
- package/dist/detectors/magicValues.d.ts +7 -0
- package/dist/detectors/magicValues.d.ts.map +1 -0
- package/dist/detectors/magicValues.js +99 -0
- package/dist/detectors/missingKey.d.ts +7 -0
- package/dist/detectors/missingKey.d.ts.map +1 -0
- package/dist/detectors/missingKey.js +93 -0
- package/dist/detectors/nestedTernary.d.ts +7 -0
- package/dist/detectors/nestedTernary.d.ts.map +1 -0
- package/dist/detectors/nestedTernary.js +58 -0
- package/dist/reporter.js +6 -0
- package/dist/types/index.d.ts +8 -1
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.js +7 -0
- package/package.json +1 -1
- package/src/analyzer.ts +18 -0
- package/src/detectors/deadCode.ts +163 -0
- package/src/detectors/dependencyArray.ts +176 -0
- package/src/detectors/hooksRules.ts +101 -0
- package/src/detectors/index.ts +6 -0
- package/src/detectors/magicValues.ts +114 -0
- package/src/detectors/missingKey.ts +105 -0
- package/src/detectors/nestedTernary.ts +75 -0
- package/src/reporter.ts +6 -0
- package/src/types/index.ts +21 -1
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import * as t from '@babel/types';
|
|
2
|
+
import { getCodeSnippet } from '../parser/index.js';
|
|
3
|
+
import { DEFAULT_CONFIG } from '../types/index.js';
|
|
4
|
+
/**
|
|
5
|
+
* Detects .map() calls that render JSX without a key prop
|
|
6
|
+
*/
|
|
7
|
+
export function detectMissingKeys(component, filePath, sourceCode, config = DEFAULT_CONFIG) {
|
|
8
|
+
if (!config.checkMissingKeys)
|
|
9
|
+
return [];
|
|
10
|
+
const smells = [];
|
|
11
|
+
component.path.traverse({
|
|
12
|
+
CallExpression(path) {
|
|
13
|
+
// Check if it's a .map() call
|
|
14
|
+
if (!t.isMemberExpression(path.node.callee))
|
|
15
|
+
return;
|
|
16
|
+
const prop = path.node.callee.property;
|
|
17
|
+
if (!t.isIdentifier(prop) || prop.name !== 'map')
|
|
18
|
+
return;
|
|
19
|
+
// Check if the callback returns JSX
|
|
20
|
+
const callback = path.node.arguments[0];
|
|
21
|
+
if (!callback)
|
|
22
|
+
return;
|
|
23
|
+
let callbackBody = null;
|
|
24
|
+
if (t.isArrowFunctionExpression(callback)) {
|
|
25
|
+
callbackBody = callback.body;
|
|
26
|
+
}
|
|
27
|
+
else if (t.isFunctionExpression(callback)) {
|
|
28
|
+
callbackBody = callback.body;
|
|
29
|
+
}
|
|
30
|
+
if (!callbackBody)
|
|
31
|
+
return;
|
|
32
|
+
// Find JSX elements in the callback
|
|
33
|
+
let hasJSX = false;
|
|
34
|
+
let hasKey = false;
|
|
35
|
+
let jsxLine = path.node.loc?.start.line || 0;
|
|
36
|
+
const checkForKey = (node) => {
|
|
37
|
+
if (t.isJSXElement(node)) {
|
|
38
|
+
hasJSX = true;
|
|
39
|
+
jsxLine = node.loc?.start.line || jsxLine;
|
|
40
|
+
// Check if the opening element has a key prop
|
|
41
|
+
const openingElement = node.openingElement;
|
|
42
|
+
const keyAttr = openingElement.attributes.find(attr => {
|
|
43
|
+
if (t.isJSXAttribute(attr)) {
|
|
44
|
+
return t.isJSXIdentifier(attr.name) && attr.name.name === 'key';
|
|
45
|
+
}
|
|
46
|
+
return false;
|
|
47
|
+
});
|
|
48
|
+
if (keyAttr) {
|
|
49
|
+
hasKey = true;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
else if (t.isJSXFragment(node)) {
|
|
53
|
+
hasJSX = true;
|
|
54
|
+
// Fragments can't have keys directly, so check children
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
// Traverse the callback body to find JSX
|
|
58
|
+
if (t.isJSXElement(callbackBody) || t.isJSXFragment(callbackBody)) {
|
|
59
|
+
checkForKey(callbackBody);
|
|
60
|
+
}
|
|
61
|
+
else if (t.isBlockStatement(callbackBody)) {
|
|
62
|
+
path.traverse({
|
|
63
|
+
ReturnStatement(returnPath) {
|
|
64
|
+
if (returnPath.node.argument) {
|
|
65
|
+
checkForKey(returnPath.node.argument);
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
JSXElement(jsxPath) {
|
|
69
|
+
// Only check top-level JSX in the map callback
|
|
70
|
+
if (jsxPath.parent === callbackBody ||
|
|
71
|
+
(t.isReturnStatement(jsxPath.parent) && jsxPath.parent.argument === jsxPath.node)) {
|
|
72
|
+
checkForKey(jsxPath.node);
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
if (hasJSX && !hasKey) {
|
|
78
|
+
const loc = path.node.loc;
|
|
79
|
+
smells.push({
|
|
80
|
+
type: 'missing-key',
|
|
81
|
+
severity: 'error',
|
|
82
|
+
message: `Missing "key" prop in .map() that renders JSX in "${component.name}"`,
|
|
83
|
+
file: filePath,
|
|
84
|
+
line: jsxLine || loc?.start.line || 0,
|
|
85
|
+
column: loc?.start.column || 0,
|
|
86
|
+
suggestion: 'Add a unique "key" prop to the root element: <Element key={item.id}>',
|
|
87
|
+
codeSnippet: getCodeSnippet(sourceCode, loc?.start.line || 0),
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
return smells;
|
|
93
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { ParsedComponent } from '../parser/index.js';
|
|
2
|
+
import { CodeSmell, DetectorConfig } from '../types/index.js';
|
|
3
|
+
/**
|
|
4
|
+
* Detects deeply nested ternary expressions (complex conditional rendering)
|
|
5
|
+
*/
|
|
6
|
+
export declare function detectNestedTernaries(component: ParsedComponent, filePath: string, sourceCode: string, config?: DetectorConfig): CodeSmell[];
|
|
7
|
+
//# sourceMappingURL=nestedTernary.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"nestedTernary.d.ts","sourceRoot":"","sources":["../../src/detectors/nestedTernary.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,eAAe,EAAkB,MAAM,oBAAoB,CAAC;AACrE,OAAO,EAAE,SAAS,EAAE,cAAc,EAAkB,MAAM,mBAAmB,CAAC;AAE9E;;GAEG;AACH,wBAAgB,qBAAqB,CACnC,SAAS,EAAE,eAAe,EAC1B,QAAQ,EAAE,MAAM,EAChB,UAAU,EAAE,MAAM,EAClB,MAAM,GAAE,cAA+B,GACtC,SAAS,EAAE,CA4Bb"}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import * as t from '@babel/types';
|
|
2
|
+
import { getCodeSnippet } from '../parser/index.js';
|
|
3
|
+
import { DEFAULT_CONFIG } from '../types/index.js';
|
|
4
|
+
/**
|
|
5
|
+
* Detects deeply nested ternary expressions (complex conditional rendering)
|
|
6
|
+
*/
|
|
7
|
+
export function detectNestedTernaries(component, filePath, sourceCode, config = DEFAULT_CONFIG) {
|
|
8
|
+
const smells = [];
|
|
9
|
+
const maxDepth = config.maxTernaryDepth;
|
|
10
|
+
component.path.traverse({
|
|
11
|
+
ConditionalExpression(path) {
|
|
12
|
+
// Only check the outermost ternary
|
|
13
|
+
if (isNestedInTernary(path))
|
|
14
|
+
return;
|
|
15
|
+
const depth = getTernaryDepth(path.node);
|
|
16
|
+
if (depth > maxDepth) {
|
|
17
|
+
const loc = path.node.loc;
|
|
18
|
+
smells.push({
|
|
19
|
+
type: 'nested-ternary',
|
|
20
|
+
severity: depth > maxDepth + 1 ? 'warning' : 'info',
|
|
21
|
+
message: `Nested ternary expression (depth: ${depth}) in "${component.name}"`,
|
|
22
|
+
file: filePath,
|
|
23
|
+
line: loc?.start.line || 0,
|
|
24
|
+
column: loc?.start.column || 0,
|
|
25
|
+
suggestion: `Refactor to use if/else, switch, or extract into separate components. Max recommended depth: ${maxDepth}`,
|
|
26
|
+
codeSnippet: getCodeSnippet(sourceCode, loc?.start.line || 0),
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
return smells;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Check if a ternary is nested inside another ternary
|
|
35
|
+
*/
|
|
36
|
+
function isNestedInTernary(path) {
|
|
37
|
+
let current = path.parentPath;
|
|
38
|
+
while (current) {
|
|
39
|
+
if (t.isConditionalExpression(current.node)) {
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
current = current.parentPath;
|
|
43
|
+
}
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Calculate the depth of nested ternary expressions
|
|
48
|
+
*/
|
|
49
|
+
function getTernaryDepth(node) {
|
|
50
|
+
let maxChildDepth = 0;
|
|
51
|
+
if (t.isConditionalExpression(node.consequent)) {
|
|
52
|
+
maxChildDepth = Math.max(maxChildDepth, getTernaryDepth(node.consequent));
|
|
53
|
+
}
|
|
54
|
+
if (t.isConditionalExpression(node.alternate)) {
|
|
55
|
+
maxChildDepth = Math.max(maxChildDepth, getTernaryDepth(node.alternate));
|
|
56
|
+
}
|
|
57
|
+
return 1 + maxChildDepth;
|
|
58
|
+
}
|
package/dist/reporter.js
CHANGED
|
@@ -212,6 +212,12 @@ function formatSmellType(type) {
|
|
|
212
212
|
'state-in-loop': '🔄 State in Loop',
|
|
213
213
|
'inline-function-prop': '📎 Inline Function Prop',
|
|
214
214
|
'deep-nesting': '📊 Deep Nesting',
|
|
215
|
+
'missing-key': '🔑 Missing Key',
|
|
216
|
+
'hooks-rules-violation': '⚠️ Hooks Rules Violation',
|
|
217
|
+
'dependency-array-issue': '📋 Dependency Array Issue',
|
|
218
|
+
'nested-ternary': '❓ Nested Ternary',
|
|
219
|
+
'dead-code': '💀 Dead Code',
|
|
220
|
+
'magic-value': '🔢 Magic Value',
|
|
215
221
|
};
|
|
216
222
|
return labels[type] || type;
|
|
217
223
|
}
|
package/dist/types/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export type SmellSeverity = 'error' | 'warning' | 'info';
|
|
2
|
-
export type SmellType = 'useEffect-overuse' | 'prop-drilling' | 'large-component' | 'unmemoized-calculation' | 'missing-dependency' | 'state-in-loop' | 'inline-function-prop' | 'deep-nesting';
|
|
2
|
+
export type SmellType = 'useEffect-overuse' | 'prop-drilling' | 'large-component' | 'unmemoized-calculation' | 'missing-dependency' | 'state-in-loop' | 'inline-function-prop' | 'deep-nesting' | 'missing-key' | 'hooks-rules-violation' | 'dependency-array-issue' | 'nested-ternary' | 'dead-code' | 'magic-value';
|
|
3
3
|
export interface CodeSmell {
|
|
4
4
|
type: SmellType;
|
|
5
5
|
severity: SmellSeverity;
|
|
@@ -59,6 +59,13 @@ export interface DetectorConfig {
|
|
|
59
59
|
maxComponentLines: number;
|
|
60
60
|
maxPropsCount: number;
|
|
61
61
|
checkMemoization: boolean;
|
|
62
|
+
checkMissingKeys: boolean;
|
|
63
|
+
checkHooksRules: boolean;
|
|
64
|
+
checkDependencyArrays: boolean;
|
|
65
|
+
maxTernaryDepth: number;
|
|
66
|
+
checkDeadCode: boolean;
|
|
67
|
+
checkMagicValues: boolean;
|
|
68
|
+
magicNumberThreshold: number;
|
|
62
69
|
}
|
|
63
70
|
export declare const DEFAULT_CONFIG: DetectorConfig;
|
|
64
71
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/types/index.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,aAAa,GAAG,OAAO,GAAG,SAAS,GAAG,MAAM,CAAC;AAEzD,MAAM,MAAM,SAAS,GACjB,mBAAmB,GACnB,eAAe,GACf,iBAAiB,GACjB,wBAAwB,GACxB,oBAAoB,GACpB,eAAe,GACf,sBAAsB,GACtB,cAAc,CAAC;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/types/index.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,aAAa,GAAG,OAAO,GAAG,SAAS,GAAG,MAAM,CAAC;AAEzD,MAAM,MAAM,SAAS,GACjB,mBAAmB,GACnB,eAAe,GACf,iBAAiB,GACjB,wBAAwB,GACxB,oBAAoB,GACpB,eAAe,GACf,sBAAsB,GACtB,cAAc,GACd,aAAa,GACb,uBAAuB,GACvB,wBAAwB,GACxB,gBAAgB,GAChB,WAAW,GACX,aAAa,CAAC;AAElB,MAAM,WAAW,SAAS;IACxB,IAAI,EAAE,SAAS,CAAC;IAChB,QAAQ,EAAE,aAAa,CAAC;IACxB,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,MAAM,CAAC;IACnB,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,cAAc,EAAE,MAAM,CAAC;IACvB,aAAa,EAAE,MAAM,CAAC;IACtB,YAAY,EAAE,MAAM,CAAC;IACrB,gBAAgB,EAAE,MAAM,CAAC;IACzB,UAAU,EAAE,MAAM,CAAC;IACnB,kBAAkB,EAAE,MAAM,CAAC;IAC3B,uBAAuB,EAAE,OAAO,CAAC;CAClC;AAED,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,EAAE,aAAa,EAAE,CAAC;IAC5B,MAAM,EAAE,SAAS,EAAE,CAAC;IACpB,OAAO,EAAE,MAAM,EAAE,CAAC;CACnB;AAED,MAAM,WAAW,cAAc;IAC7B,KAAK,EAAE,YAAY,EAAE,CAAC;IACtB,OAAO,EAAE,eAAe,CAAC;IACzB,SAAS,EAAE,kBAAkB,CAAC;CAC/B;AAED,MAAM,WAAW,eAAe;IAC9B,UAAU,EAAE,MAAM,CAAC;IACnB,eAAe,EAAE,MAAM,CAAC;IACxB,WAAW,EAAE,MAAM,CAAC;IACpB,YAAY,EAAE,MAAM,CAAC,SAAS,EAAE,MAAM,CAAC,CAAC;IACxC,gBAAgB,EAAE,MAAM,CAAC,aAAa,EAAE,MAAM,CAAC,CAAC;CACjD;AAED,MAAM,WAAW,kBAAkB;IACjC,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,CAAC;IACnC,SAAS,EAAE;QACT,cAAc,EAAE,MAAM,CAAC;QACvB,iBAAiB,EAAE,MAAM,CAAC;QAC1B,kBAAkB,EAAE,MAAM,CAAC;QAC3B,gBAAgB,EAAE,MAAM,CAAC;KAC1B,CAAC;IACF,qBAAqB,EAAE,MAAM,CAAC;CAC/B;AAED,MAAM,WAAW,cAAc;IAC7B,yBAAyB,EAAE,MAAM,CAAC;IAClC,oBAAoB,EAAE,MAAM,CAAC;IAC7B,iBAAiB,EAAE,MAAM,CAAC;IAC1B,aAAa,EAAE,MAAM,CAAC;IACtB,gBAAgB,EAAE,OAAO,CAAC;IAC1B,gBAAgB,EAAE,OAAO,CAAC;IAC1B,eAAe,EAAE,OAAO,CAAC;IACzB,qBAAqB,EAAE,OAAO,CAAC;IAC/B,eAAe,EAAE,MAAM,CAAC;IACxB,aAAa,EAAE,OAAO,CAAC;IACvB,gBAAgB,EAAE,OAAO,CAAC;IAC1B,oBAAoB,EAAE,MAAM,CAAC;CAC9B;AAED,eAAO,MAAM,cAAc,EAAE,cAa5B,CAAC"}
|
package/dist/types/index.js
CHANGED
|
@@ -4,4 +4,11 @@ export const DEFAULT_CONFIG = {
|
|
|
4
4
|
maxComponentLines: 300,
|
|
5
5
|
maxPropsCount: 7,
|
|
6
6
|
checkMemoization: true,
|
|
7
|
+
checkMissingKeys: true,
|
|
8
|
+
checkHooksRules: true,
|
|
9
|
+
checkDependencyArrays: true,
|
|
10
|
+
maxTernaryDepth: 2,
|
|
11
|
+
checkDeadCode: true,
|
|
12
|
+
checkMagicValues: true,
|
|
13
|
+
magicNumberThreshold: 10,
|
|
7
14
|
};
|
package/package.json
CHANGED
package/src/analyzer.ts
CHANGED
|
@@ -7,6 +7,12 @@ import {
|
|
|
7
7
|
analyzePropDrillingDepth,
|
|
8
8
|
detectLargeComponent,
|
|
9
9
|
detectUnmemoizedCalculations,
|
|
10
|
+
detectMissingKeys,
|
|
11
|
+
detectHooksRulesViolations,
|
|
12
|
+
detectDependencyArrayIssues,
|
|
13
|
+
detectNestedTernaries,
|
|
14
|
+
detectDeadCode,
|
|
15
|
+
detectMagicValues,
|
|
10
16
|
} from './detectors/index.js';
|
|
11
17
|
import {
|
|
12
18
|
AnalysisResult,
|
|
@@ -100,6 +106,12 @@ function analyzeFile(parseResult: ParseResult, filePath: string, config: Detecto
|
|
|
100
106
|
smells.push(...detectPropDrilling(component, filePath, sourceCode, config));
|
|
101
107
|
smells.push(...detectLargeComponent(component, filePath, sourceCode, config));
|
|
102
108
|
smells.push(...detectUnmemoizedCalculations(component, filePath, sourceCode, config));
|
|
109
|
+
smells.push(...detectMissingKeys(component, filePath, sourceCode, config));
|
|
110
|
+
smells.push(...detectHooksRulesViolations(component, filePath, sourceCode, config));
|
|
111
|
+
smells.push(...detectDependencyArrayIssues(component, filePath, sourceCode, config));
|
|
112
|
+
smells.push(...detectNestedTernaries(component, filePath, sourceCode, config));
|
|
113
|
+
smells.push(...detectDeadCode(component, filePath, sourceCode, config));
|
|
114
|
+
smells.push(...detectMagicValues(component, filePath, sourceCode, config));
|
|
103
115
|
});
|
|
104
116
|
|
|
105
117
|
// Run cross-component analysis
|
|
@@ -123,6 +135,12 @@ function calculateSummary(files: FileAnalysis[]): AnalysisSummary {
|
|
|
123
135
|
'state-in-loop': 0,
|
|
124
136
|
'inline-function-prop': 0,
|
|
125
137
|
'deep-nesting': 0,
|
|
138
|
+
'missing-key': 0,
|
|
139
|
+
'hooks-rules-violation': 0,
|
|
140
|
+
'dependency-array-issue': 0,
|
|
141
|
+
'nested-ternary': 0,
|
|
142
|
+
'dead-code': 0,
|
|
143
|
+
'magic-value': 0,
|
|
126
144
|
};
|
|
127
145
|
|
|
128
146
|
const smellsBySeverity: Record<SmellSeverity, number> = {
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import * as t from '@babel/types';
|
|
2
|
+
import { ParsedComponent, getCodeSnippet } from '../parser/index.js';
|
|
3
|
+
import { CodeSmell, DetectorConfig, DEFAULT_CONFIG } from '../types/index.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Detects potentially dead code: unused variables, imports, and functions
|
|
7
|
+
*/
|
|
8
|
+
export function detectDeadCode(
|
|
9
|
+
component: ParsedComponent,
|
|
10
|
+
filePath: string,
|
|
11
|
+
sourceCode: string,
|
|
12
|
+
config: DetectorConfig = DEFAULT_CONFIG
|
|
13
|
+
): CodeSmell[] {
|
|
14
|
+
if (!config.checkDeadCode) return [];
|
|
15
|
+
|
|
16
|
+
const smells: CodeSmell[] = [];
|
|
17
|
+
|
|
18
|
+
// Track declared and used identifiers within the component
|
|
19
|
+
const declared = new Map<string, { line: number; column: number; type: string }>();
|
|
20
|
+
const used = new Set<string>();
|
|
21
|
+
|
|
22
|
+
// Collect all declared variables in the component
|
|
23
|
+
component.path.traverse({
|
|
24
|
+
VariableDeclarator(path) {
|
|
25
|
+
if (t.isIdentifier(path.node.id)) {
|
|
26
|
+
const loc = path.node.loc;
|
|
27
|
+
declared.set(path.node.id.name, {
|
|
28
|
+
line: loc?.start.line || 0,
|
|
29
|
+
column: loc?.start.column || 0,
|
|
30
|
+
type: 'variable',
|
|
31
|
+
});
|
|
32
|
+
} else if (t.isObjectPattern(path.node.id)) {
|
|
33
|
+
// Destructured variables
|
|
34
|
+
path.node.id.properties.forEach(prop => {
|
|
35
|
+
if (t.isObjectProperty(prop) && t.isIdentifier(prop.value)) {
|
|
36
|
+
const loc = prop.loc;
|
|
37
|
+
declared.set(prop.value.name, {
|
|
38
|
+
line: loc?.start.line || 0,
|
|
39
|
+
column: loc?.start.column || 0,
|
|
40
|
+
type: 'variable',
|
|
41
|
+
});
|
|
42
|
+
} else if (t.isRestElement(prop) && t.isIdentifier(prop.argument)) {
|
|
43
|
+
const loc = prop.loc;
|
|
44
|
+
declared.set(prop.argument.name, {
|
|
45
|
+
line: loc?.start.line || 0,
|
|
46
|
+
column: loc?.start.column || 0,
|
|
47
|
+
type: 'variable',
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
} else if (t.isArrayPattern(path.node.id)) {
|
|
52
|
+
// Array destructured variables
|
|
53
|
+
path.node.id.elements.forEach(elem => {
|
|
54
|
+
if (t.isIdentifier(elem)) {
|
|
55
|
+
const loc = elem.loc;
|
|
56
|
+
declared.set(elem.name, {
|
|
57
|
+
line: loc?.start.line || 0,
|
|
58
|
+
column: loc?.start.column || 0,
|
|
59
|
+
type: 'variable',
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
|
|
66
|
+
FunctionDeclaration(path) {
|
|
67
|
+
if (path.node.id) {
|
|
68
|
+
const loc = path.node.loc;
|
|
69
|
+
declared.set(path.node.id.name, {
|
|
70
|
+
line: loc?.start.line || 0,
|
|
71
|
+
column: loc?.start.column || 0,
|
|
72
|
+
type: 'function',
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// Collect all used identifiers
|
|
79
|
+
component.path.traverse({
|
|
80
|
+
Identifier(path) {
|
|
81
|
+
// Skip if it's a declaration
|
|
82
|
+
if (
|
|
83
|
+
t.isVariableDeclarator(path.parent) && path.parent.id === path.node ||
|
|
84
|
+
t.isFunctionDeclaration(path.parent) && path.parent.id === path.node ||
|
|
85
|
+
t.isObjectProperty(path.parent) && path.parent.key === path.node ||
|
|
86
|
+
t.isImportSpecifier(path.parent) ||
|
|
87
|
+
t.isImportDefaultSpecifier(path.parent)
|
|
88
|
+
) {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Skip property access (x.y - y is not a reference)
|
|
93
|
+
if (t.isMemberExpression(path.parent) && path.parent.property === path.node && !path.parent.computed) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
used.add(path.node.name);
|
|
98
|
+
},
|
|
99
|
+
|
|
100
|
+
JSXIdentifier(path) {
|
|
101
|
+
// Components used in JSX
|
|
102
|
+
if (t.isJSXOpeningElement(path.parent) || t.isJSXClosingElement(path.parent)) {
|
|
103
|
+
used.add(path.node.name);
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// Find unused declarations
|
|
109
|
+
declared.forEach((info, name) => {
|
|
110
|
+
// Skip hooks and common patterns
|
|
111
|
+
if (name.startsWith('_') || name.startsWith('set')) return;
|
|
112
|
+
|
|
113
|
+
// Skip if it's the component name itself
|
|
114
|
+
if (name === component.name) return;
|
|
115
|
+
|
|
116
|
+
if (!used.has(name)) {
|
|
117
|
+
smells.push({
|
|
118
|
+
type: 'dead-code',
|
|
119
|
+
severity: 'info',
|
|
120
|
+
message: `Unused ${info.type} "${name}" in "${component.name}"`,
|
|
121
|
+
file: filePath,
|
|
122
|
+
line: info.line,
|
|
123
|
+
column: info.column,
|
|
124
|
+
suggestion: `Remove the unused ${info.type} or use it in the component.`,
|
|
125
|
+
codeSnippet: getCodeSnippet(sourceCode, info.line),
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
return smells;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Detects unused imports at the file level
|
|
135
|
+
*/
|
|
136
|
+
export function detectUnusedImports(
|
|
137
|
+
imports: Map<string, { source: string; line: number }>,
|
|
138
|
+
usedInFile: Set<string>,
|
|
139
|
+
filePath: string,
|
|
140
|
+
sourceCode: string
|
|
141
|
+
): CodeSmell[] {
|
|
142
|
+
const smells: CodeSmell[] = [];
|
|
143
|
+
|
|
144
|
+
imports.forEach((info, name) => {
|
|
145
|
+
// Skip React imports as they might be used implicitly
|
|
146
|
+
if (name === 'React' || info.source === 'react') return;
|
|
147
|
+
|
|
148
|
+
if (!usedInFile.has(name)) {
|
|
149
|
+
smells.push({
|
|
150
|
+
type: 'dead-code',
|
|
151
|
+
severity: 'info',
|
|
152
|
+
message: `Unused import "${name}" from "${info.source}"`,
|
|
153
|
+
file: filePath,
|
|
154
|
+
line: info.line,
|
|
155
|
+
column: 0,
|
|
156
|
+
suggestion: `Remove the unused import: import { ${name} } from '${info.source}'`,
|
|
157
|
+
codeSnippet: getCodeSnippet(sourceCode, info.line),
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
return smells;
|
|
163
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import * as t from '@babel/types';
|
|
2
|
+
import { ParsedComponent, getCodeSnippet } from '../parser/index.js';
|
|
3
|
+
import { CodeSmell, DetectorConfig, DEFAULT_CONFIG } from '../types/index.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Detects potential issues with useEffect dependency arrays
|
|
7
|
+
*/
|
|
8
|
+
export function detectDependencyArrayIssues(
|
|
9
|
+
component: ParsedComponent,
|
|
10
|
+
filePath: string,
|
|
11
|
+
sourceCode: string,
|
|
12
|
+
config: DetectorConfig = DEFAULT_CONFIG
|
|
13
|
+
): CodeSmell[] {
|
|
14
|
+
if (!config.checkDependencyArrays) return [];
|
|
15
|
+
|
|
16
|
+
const smells: CodeSmell[] = [];
|
|
17
|
+
|
|
18
|
+
// Analyze each useEffect
|
|
19
|
+
component.hooks.useEffect.forEach(effectCall => {
|
|
20
|
+
const args = effectCall.arguments;
|
|
21
|
+
const callback = args[0];
|
|
22
|
+
const depsArray = args[1];
|
|
23
|
+
|
|
24
|
+
const loc = effectCall.loc;
|
|
25
|
+
const line = loc?.start.line || 0;
|
|
26
|
+
|
|
27
|
+
// Check 1: Missing dependency array entirely
|
|
28
|
+
if (!depsArray) {
|
|
29
|
+
smells.push({
|
|
30
|
+
type: 'dependency-array-issue',
|
|
31
|
+
severity: 'warning',
|
|
32
|
+
message: `useEffect without dependency array in "${component.name}" runs on every render`,
|
|
33
|
+
file: filePath,
|
|
34
|
+
line,
|
|
35
|
+
column: loc?.start.column || 0,
|
|
36
|
+
suggestion: 'Add a dependency array. Use [] for mount-only effect, or [dep1, dep2] for reactive dependencies.',
|
|
37
|
+
codeSnippet: getCodeSnippet(sourceCode, line),
|
|
38
|
+
});
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Check 2: Empty dependency array with variables used inside
|
|
43
|
+
if (t.isArrayExpression(depsArray) && depsArray.elements.length === 0) {
|
|
44
|
+
// Collect variables used in the effect callback
|
|
45
|
+
const usedVariables = new Set<string>();
|
|
46
|
+
|
|
47
|
+
if (t.isArrowFunctionExpression(callback) || t.isFunctionExpression(callback)) {
|
|
48
|
+
collectVariablesUsed(callback.body, usedVariables);
|
|
49
|
+
|
|
50
|
+
// Filter out common globals and React hooks
|
|
51
|
+
const globals = new Set(['console', 'window', 'document', 'setTimeout', 'setInterval',
|
|
52
|
+
'clearTimeout', 'clearInterval', 'fetch', 'Promise', 'JSON', 'Math', 'Date', 'Array',
|
|
53
|
+
'Object', 'String', 'Number', 'Boolean', 'Error', 'undefined', 'null', 'true', 'false']);
|
|
54
|
+
|
|
55
|
+
// Check if there are local variables that might need to be dependencies
|
|
56
|
+
const potentialMissing = [...usedVariables].filter(v =>
|
|
57
|
+
!globals.has(v) &&
|
|
58
|
+
!v.startsWith('set') && // Setters from useState are stable
|
|
59
|
+
v !== 'props' &&
|
|
60
|
+
v !== 'children'
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
// Only warn if there appear to be external variables used
|
|
64
|
+
if (potentialMissing.length > 3) {
|
|
65
|
+
smells.push({
|
|
66
|
+
type: 'dependency-array-issue',
|
|
67
|
+
severity: 'info',
|
|
68
|
+
message: `useEffect in "${component.name}" has empty deps array but uses external variables`,
|
|
69
|
+
file: filePath,
|
|
70
|
+
line,
|
|
71
|
+
column: loc?.start.column || 0,
|
|
72
|
+
suggestion: `Consider adding dependencies: [${potentialMissing.slice(0, 3).join(', ')}...]`,
|
|
73
|
+
codeSnippet: getCodeSnippet(sourceCode, line),
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Check 3: Dependency array with object/array literals
|
|
80
|
+
if (t.isArrayExpression(depsArray)) {
|
|
81
|
+
depsArray.elements.forEach((elem, index) => {
|
|
82
|
+
if (t.isObjectExpression(elem) || t.isArrayExpression(elem)) {
|
|
83
|
+
smells.push({
|
|
84
|
+
type: 'dependency-array-issue',
|
|
85
|
+
severity: 'warning',
|
|
86
|
+
message: `Object/array literal in useEffect dependency array in "${component.name}" causes infinite re-renders`,
|
|
87
|
+
file: filePath,
|
|
88
|
+
line,
|
|
89
|
+
column: loc?.start.column || 0,
|
|
90
|
+
suggestion: 'Move the object/array to a useMemo or extract to a constant outside the component.',
|
|
91
|
+
codeSnippet: getCodeSnippet(sourceCode, line),
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Check for inline functions
|
|
96
|
+
if (t.isArrowFunctionExpression(elem) || t.isFunctionExpression(elem)) {
|
|
97
|
+
smells.push({
|
|
98
|
+
type: 'dependency-array-issue',
|
|
99
|
+
severity: 'warning',
|
|
100
|
+
message: `Inline function in useEffect dependency array in "${component.name}" causes infinite re-renders`,
|
|
101
|
+
file: filePath,
|
|
102
|
+
line,
|
|
103
|
+
column: loc?.start.column || 0,
|
|
104
|
+
suggestion: 'Wrap the function in useCallback before using as a dependency.',
|
|
105
|
+
codeSnippet: getCodeSnippet(sourceCode, line),
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// Also check useMemo and useCallback
|
|
113
|
+
[...component.hooks.useMemo, ...component.hooks.useCallback].forEach(hookCall => {
|
|
114
|
+
const depsArray = hookCall.arguments[1];
|
|
115
|
+
const loc = hookCall.loc;
|
|
116
|
+
const line = loc?.start.line || 0;
|
|
117
|
+
|
|
118
|
+
if (!depsArray) {
|
|
119
|
+
const hookName = t.isIdentifier((hookCall.callee as t.Identifier))
|
|
120
|
+
? ((hookCall.callee as t.Identifier).name)
|
|
121
|
+
: 'useMemo/useCallback';
|
|
122
|
+
|
|
123
|
+
smells.push({
|
|
124
|
+
type: 'dependency-array-issue',
|
|
125
|
+
severity: 'warning',
|
|
126
|
+
message: `${hookName} without dependency array in "${component.name}"`,
|
|
127
|
+
file: filePath,
|
|
128
|
+
line,
|
|
129
|
+
column: loc?.start.column || 0,
|
|
130
|
+
suggestion: 'Add a dependency array to specify when the value should be recalculated.',
|
|
131
|
+
codeSnippet: getCodeSnippet(sourceCode, line),
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
return smells;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Helper to collect variable names used in a code block
|
|
141
|
+
*/
|
|
142
|
+
function collectVariablesUsed(node: t.Node, variables: Set<string>): void {
|
|
143
|
+
if (t.isIdentifier(node)) {
|
|
144
|
+
variables.add(node.name);
|
|
145
|
+
} else if (t.isMemberExpression(node)) {
|
|
146
|
+
if (t.isIdentifier(node.object)) {
|
|
147
|
+
variables.add(node.object.name);
|
|
148
|
+
} else {
|
|
149
|
+
collectVariablesUsed(node.object, variables);
|
|
150
|
+
}
|
|
151
|
+
} else if (t.isBlockStatement(node)) {
|
|
152
|
+
node.body.forEach(stmt => collectVariablesUsed(stmt, variables));
|
|
153
|
+
} else if (t.isExpressionStatement(node)) {
|
|
154
|
+
collectVariablesUsed(node.expression, variables);
|
|
155
|
+
} else if (t.isCallExpression(node)) {
|
|
156
|
+
if (t.isIdentifier(node.callee)) {
|
|
157
|
+
variables.add(node.callee.name);
|
|
158
|
+
}
|
|
159
|
+
node.arguments.forEach(arg => {
|
|
160
|
+
if (t.isNode(arg)) collectVariablesUsed(arg, variables);
|
|
161
|
+
});
|
|
162
|
+
} else if (t.isConditionalExpression(node)) {
|
|
163
|
+
collectVariablesUsed(node.test, variables);
|
|
164
|
+
collectVariablesUsed(node.consequent, variables);
|
|
165
|
+
collectVariablesUsed(node.alternate, variables);
|
|
166
|
+
} else if (t.isBinaryExpression(node) || t.isLogicalExpression(node)) {
|
|
167
|
+
collectVariablesUsed(node.left, variables);
|
|
168
|
+
collectVariablesUsed(node.right, variables);
|
|
169
|
+
} else if (t.isReturnStatement(node) && node.argument) {
|
|
170
|
+
collectVariablesUsed(node.argument, variables);
|
|
171
|
+
} else if (t.isIfStatement(node)) {
|
|
172
|
+
collectVariablesUsed(node.test, variables);
|
|
173
|
+
collectVariablesUsed(node.consequent, variables);
|
|
174
|
+
if (node.alternate) collectVariablesUsed(node.alternate, variables);
|
|
175
|
+
}
|
|
176
|
+
}
|