react-code-smell-detector 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +179 -0
- package/dist/analyzer.d.ts +10 -0
- package/dist/analyzer.d.ts.map +1 -0
- package/dist/analyzer.js +169 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +113 -0
- package/dist/detectors/index.d.ts +5 -0
- package/dist/detectors/index.d.ts.map +1 -0
- package/dist/detectors/index.js +4 -0
- package/dist/detectors/largeComponent.d.ts +4 -0
- package/dist/detectors/largeComponent.d.ts.map +1 -0
- package/dist/detectors/largeComponent.js +51 -0
- package/dist/detectors/memoization.d.ts +4 -0
- package/dist/detectors/memoization.d.ts.map +1 -0
- package/dist/detectors/memoization.js +150 -0
- package/dist/detectors/propDrilling.d.ts +5 -0
- package/dist/detectors/propDrilling.d.ts.map +1 -0
- package/dist/detectors/propDrilling.js +82 -0
- package/dist/detectors/useEffect.d.ts +4 -0
- package/dist/detectors/useEffect.d.ts.map +1 -0
- package/dist/detectors/useEffect.js +101 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4 -0
- package/dist/parser/index.d.ts +29 -0
- package/dist/parser/index.d.ts.map +1 -0
- package/dist/parser/index.js +151 -0
- package/dist/reporter.d.ts +8 -0
- package/dist/reporter.d.ts.map +1 -0
- package/dist/reporter.js +217 -0
- package/dist/types/index.d.ts +64 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +7 -0
- package/package.json +45 -0
- package/src/analyzer.ts +216 -0
- package/src/cli.ts +125 -0
- package/src/detectors/index.ts +4 -0
- package/src/detectors/largeComponent.ts +63 -0
- package/src/detectors/memoization.ts +177 -0
- package/src/detectors/propDrilling.ts +103 -0
- package/src/detectors/useEffect.ts +117 -0
- package/src/index.ts +4 -0
- package/src/parser/index.ts +195 -0
- package/src/reporter.ts +248 -0
- package/src/types/index.ts +86 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import * as t from '@babel/types';
|
|
2
|
+
import { getCodeSnippet } from '../parser/index.js';
|
|
3
|
+
import { DEFAULT_CONFIG } from '../types/index.js';
|
|
4
|
+
export function detectUnmemoizedCalculations(component, filePath, sourceCode, config = DEFAULT_CONFIG) {
|
|
5
|
+
if (!config.checkMemoization)
|
|
6
|
+
return [];
|
|
7
|
+
const smells = [];
|
|
8
|
+
// Look for expensive operations not wrapped in useMemo
|
|
9
|
+
component.path.traverse({
|
|
10
|
+
VariableDeclarator(varPath) {
|
|
11
|
+
const init = varPath.node.init;
|
|
12
|
+
if (!init)
|
|
13
|
+
return;
|
|
14
|
+
const loc = init.loc;
|
|
15
|
+
if (!loc)
|
|
16
|
+
return;
|
|
17
|
+
// Check if this is inside useMemo/useCallback
|
|
18
|
+
let isInsideHook = false;
|
|
19
|
+
let currentPath = varPath.parentPath;
|
|
20
|
+
while (currentPath) {
|
|
21
|
+
if (currentPath.isCallExpression() &&
|
|
22
|
+
t.isIdentifier(currentPath.node.callee) &&
|
|
23
|
+
['useMemo', 'useCallback'].includes(currentPath.node.callee.name)) {
|
|
24
|
+
isInsideHook = true;
|
|
25
|
+
break;
|
|
26
|
+
}
|
|
27
|
+
currentPath = currentPath.parentPath;
|
|
28
|
+
}
|
|
29
|
+
if (isInsideHook)
|
|
30
|
+
return;
|
|
31
|
+
// Detect expensive operations
|
|
32
|
+
let isExpensive = false;
|
|
33
|
+
let reason = '';
|
|
34
|
+
// Check for .map(), .filter(), .reduce(), .sort() on arrays
|
|
35
|
+
if (t.isCallExpression(init) && t.isMemberExpression(init.callee)) {
|
|
36
|
+
const prop = init.callee.property;
|
|
37
|
+
if (t.isIdentifier(prop)) {
|
|
38
|
+
const expensiveMethods = ['map', 'filter', 'reduce', 'sort', 'find', 'findIndex', 'flatMap'];
|
|
39
|
+
if (expensiveMethods.includes(prop.name)) {
|
|
40
|
+
isExpensive = true;
|
|
41
|
+
reason = `.${prop.name}() creates a new array on every render`;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
// Check for object/array literals with complex computations
|
|
46
|
+
if (t.isArrayExpression(init) && init.elements.length > 5) {
|
|
47
|
+
isExpensive = true;
|
|
48
|
+
reason = 'Large array literal recreated on every render';
|
|
49
|
+
}
|
|
50
|
+
if (t.isObjectExpression(init) && init.properties.length > 5) {
|
|
51
|
+
isExpensive = true;
|
|
52
|
+
reason = 'Large object literal recreated on every render';
|
|
53
|
+
}
|
|
54
|
+
// Check for JSON operations
|
|
55
|
+
if (t.isCallExpression(init) && t.isMemberExpression(init.callee)) {
|
|
56
|
+
const obj = init.callee.object;
|
|
57
|
+
const prop = init.callee.property;
|
|
58
|
+
if (t.isIdentifier(obj) &&
|
|
59
|
+
obj.name === 'JSON' &&
|
|
60
|
+
t.isIdentifier(prop) &&
|
|
61
|
+
['parse', 'stringify'].includes(prop.name)) {
|
|
62
|
+
isExpensive = true;
|
|
63
|
+
reason = `JSON.${prop.name}() is expensive and runs on every render`;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
// Check for spread operations creating new objects
|
|
67
|
+
if (t.isObjectExpression(init)) {
|
|
68
|
+
const hasSpread = init.properties.some(p => t.isSpreadElement(p));
|
|
69
|
+
if (hasSpread && init.properties.length > 3) {
|
|
70
|
+
isExpensive = true;
|
|
71
|
+
reason = 'Spread operation creates new object reference on every render';
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (isExpensive) {
|
|
75
|
+
const id = varPath.node.id;
|
|
76
|
+
const varName = t.isIdentifier(id) ? id.name : 'variable';
|
|
77
|
+
smells.push({
|
|
78
|
+
type: 'unmemoized-calculation',
|
|
79
|
+
severity: 'warning',
|
|
80
|
+
message: `Unmemoized calculation "${varName}" in "${component.name}": ${reason}`,
|
|
81
|
+
file: filePath,
|
|
82
|
+
line: loc.start.line,
|
|
83
|
+
column: loc.start.column,
|
|
84
|
+
suggestion: `Wrap in useMemo: const ${varName} = useMemo(() => ..., [dependencies])`,
|
|
85
|
+
codeSnippet: getCodeSnippet(sourceCode, loc.start.line),
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
// Check for inline function props (common performance issue)
|
|
91
|
+
component.path.traverse({
|
|
92
|
+
JSXAttribute(attrPath) {
|
|
93
|
+
const value = attrPath.node.value;
|
|
94
|
+
if (!t.isJSXExpressionContainer(value))
|
|
95
|
+
return;
|
|
96
|
+
const expr = value.expression;
|
|
97
|
+
const loc = expr.loc;
|
|
98
|
+
if (!loc)
|
|
99
|
+
return;
|
|
100
|
+
// Check for inline arrow functions
|
|
101
|
+
if (t.isArrowFunctionExpression(expr) || t.isFunctionExpression(expr)) {
|
|
102
|
+
const attrName = t.isJSXIdentifier(attrPath.node.name)
|
|
103
|
+
? attrPath.node.name.name
|
|
104
|
+
: 'unknown';
|
|
105
|
+
// Only warn for non-trivial handlers
|
|
106
|
+
if (attrName.startsWith('on') && t.isArrowFunctionExpression(expr)) {
|
|
107
|
+
const body = expr.body;
|
|
108
|
+
// Skip simple one-liner callbacks that just call a function
|
|
109
|
+
if (t.isCallExpression(body)) {
|
|
110
|
+
// These are usually fine: onClick={() => doSomething()}
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (t.isBlockStatement(body) && body.body.length > 1) {
|
|
114
|
+
smells.push({
|
|
115
|
+
type: 'inline-function-prop',
|
|
116
|
+
severity: 'info',
|
|
117
|
+
message: `Inline function for "${attrName}" creates new reference on every render`,
|
|
118
|
+
file: filePath,
|
|
119
|
+
line: loc.start.line,
|
|
120
|
+
column: loc.start.column,
|
|
121
|
+
suggestion: 'Extract to useCallback or define outside render',
|
|
122
|
+
codeSnippet: getCodeSnippet(sourceCode, loc.start.line),
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
// Check for inline objects/arrays passed as props
|
|
128
|
+
if (t.isObjectExpression(expr) || t.isArrayExpression(expr)) {
|
|
129
|
+
const attrName = t.isJSXIdentifier(attrPath.node.name)
|
|
130
|
+
? attrPath.node.name.name
|
|
131
|
+
: 'unknown';
|
|
132
|
+
// style prop is a common pattern, only warn for complex ones
|
|
133
|
+
if (attrName === 'style' && t.isObjectExpression(expr) && expr.properties.length <= 3) {
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
smells.push({
|
|
137
|
+
type: 'unmemoized-calculation',
|
|
138
|
+
severity: 'info',
|
|
139
|
+
message: `Inline ${t.isObjectExpression(expr) ? 'object' : 'array'} for "${attrName}" prop creates new reference on every render`,
|
|
140
|
+
file: filePath,
|
|
141
|
+
line: loc.start.line,
|
|
142
|
+
column: loc.start.column,
|
|
143
|
+
suggestion: 'Extract to a constant or useMemo',
|
|
144
|
+
codeSnippet: getCodeSnippet(sourceCode, loc.start.line),
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
return smells;
|
|
150
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { ParsedComponent } from '../parser/index.js';
|
|
2
|
+
import { CodeSmell, DetectorConfig } from '../types/index.js';
|
|
3
|
+
export declare function detectPropDrilling(component: ParsedComponent, filePath: string, sourceCode: string, config?: DetectorConfig): CodeSmell[];
|
|
4
|
+
export declare function analyzePropDrillingDepth(components: ParsedComponent[], filePath: string, sourceCode: string, config?: DetectorConfig): CodeSmell[];
|
|
5
|
+
//# sourceMappingURL=propDrilling.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"propDrilling.d.ts","sourceRoot":"","sources":["../../src/detectors/propDrilling.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAkB,MAAM,oBAAoB,CAAC;AACrE,OAAO,EAAE,SAAS,EAAE,cAAc,EAAkB,MAAM,mBAAmB,CAAC;AAE9E,wBAAgB,kBAAkB,CAChC,SAAS,EAAE,eAAe,EAC1B,QAAQ,EAAE,MAAM,EAChB,UAAU,EAAE,MAAM,EAClB,MAAM,GAAE,cAA+B,GACtC,SAAS,EAAE,CAqDb;AAED,wBAAgB,wBAAwB,CACtC,UAAU,EAAE,eAAe,EAAE,EAC7B,QAAQ,EAAE,MAAM,EAChB,UAAU,EAAE,MAAM,EAClB,MAAM,GAAE,cAA+B,GACtC,SAAS,EAAE,CAkCb"}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { getCodeSnippet } from '../parser/index.js';
|
|
2
|
+
import { DEFAULT_CONFIG } from '../types/index.js';
|
|
3
|
+
export function detectPropDrilling(component, filePath, sourceCode, config = DEFAULT_CONFIG) {
|
|
4
|
+
const smells = [];
|
|
5
|
+
// Check for too many props
|
|
6
|
+
if (component.props.length > config.maxPropsCount) {
|
|
7
|
+
smells.push({
|
|
8
|
+
type: 'prop-drilling',
|
|
9
|
+
severity: 'warning',
|
|
10
|
+
message: `Component "${component.name}" receives ${component.props.length} props (max recommended: ${config.maxPropsCount})`,
|
|
11
|
+
file: filePath,
|
|
12
|
+
line: component.startLine,
|
|
13
|
+
column: 0,
|
|
14
|
+
suggestion: 'Consider using Context, a state management library, or component composition',
|
|
15
|
+
codeSnippet: getCodeSnippet(sourceCode, component.startLine),
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
// Check for props that are just passed through (prop drilling indicators)
|
|
19
|
+
const passThroughProps = component.props.filter(prop => {
|
|
20
|
+
// Common patterns that indicate prop drilling
|
|
21
|
+
const drillingPatterns = ['data', 'config', 'settings', 'options', 'state', 'handlers', 'callbacks'];
|
|
22
|
+
return drillingPatterns.some(pattern => prop.toLowerCase().includes(pattern));
|
|
23
|
+
});
|
|
24
|
+
if (passThroughProps.length >= 3) {
|
|
25
|
+
smells.push({
|
|
26
|
+
type: 'prop-drilling',
|
|
27
|
+
severity: 'info',
|
|
28
|
+
message: `Component "${component.name}" may be experiencing prop drilling: ${passThroughProps.join(', ')}`,
|
|
29
|
+
file: filePath,
|
|
30
|
+
line: component.startLine,
|
|
31
|
+
column: 0,
|
|
32
|
+
suggestion: 'Consider using React Context or a state management solution to avoid passing data through intermediate components',
|
|
33
|
+
codeSnippet: getCodeSnippet(sourceCode, component.startLine),
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
// Check for spread props (often indicates forwarding)
|
|
37
|
+
const spreadProps = component.props.filter(p => p.startsWith('...'));
|
|
38
|
+
if (spreadProps.length > 0 && component.props.length > 5) {
|
|
39
|
+
smells.push({
|
|
40
|
+
type: 'prop-drilling',
|
|
41
|
+
severity: 'info',
|
|
42
|
+
message: `Component "${component.name}" uses spread props with many other props - potential prop drilling`,
|
|
43
|
+
file: filePath,
|
|
44
|
+
line: component.startLine,
|
|
45
|
+
column: 0,
|
|
46
|
+
suggestion: 'Consider composing components differently or using Context for shared state',
|
|
47
|
+
codeSnippet: getCodeSnippet(sourceCode, component.startLine),
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
return smells;
|
|
51
|
+
}
|
|
52
|
+
export function analyzePropDrillingDepth(components, filePath, sourceCode, config = DEFAULT_CONFIG) {
|
|
53
|
+
const smells = [];
|
|
54
|
+
// Track props that appear in multiple components
|
|
55
|
+
const propUsage = new Map();
|
|
56
|
+
components.forEach(comp => {
|
|
57
|
+
comp.props.forEach(prop => {
|
|
58
|
+
if (!prop.startsWith('...')) {
|
|
59
|
+
const existing = propUsage.get(prop) || [];
|
|
60
|
+
existing.push(comp.name);
|
|
61
|
+
propUsage.set(prop, existing);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
// Find props used in more than maxPropDrillingDepth components
|
|
66
|
+
propUsage.forEach((componentNames, propName) => {
|
|
67
|
+
if (componentNames.length > config.maxPropDrillingDepth) {
|
|
68
|
+
const firstComp = components.find(c => c.name === componentNames[0]);
|
|
69
|
+
smells.push({
|
|
70
|
+
type: 'prop-drilling',
|
|
71
|
+
severity: 'warning',
|
|
72
|
+
message: `Prop "${propName}" is passed through ${componentNames.length} components: ${componentNames.join(' → ')}`,
|
|
73
|
+
file: filePath,
|
|
74
|
+
line: firstComp?.startLine || 1,
|
|
75
|
+
column: 0,
|
|
76
|
+
suggestion: `Move "${propName}" to Context or a state management solution`,
|
|
77
|
+
codeSnippet: firstComp ? getCodeSnippet(sourceCode, firstComp.startLine) : undefined,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
return smells;
|
|
82
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { ParsedComponent } from '../parser/index.js';
|
|
2
|
+
import { CodeSmell, DetectorConfig } from '../types/index.js';
|
|
3
|
+
export declare function detectUseEffectOveruse(component: ParsedComponent, filePath: string, sourceCode: string, config?: DetectorConfig): CodeSmell[];
|
|
4
|
+
//# sourceMappingURL=useEffect.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"useEffect.d.ts","sourceRoot":"","sources":["../../src/detectors/useEffect.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,eAAe,EAAkB,MAAM,oBAAoB,CAAC;AACrE,OAAO,EAAE,SAAS,EAAE,cAAc,EAAkB,MAAM,mBAAmB,CAAC;AAE9E,wBAAgB,sBAAsB,CACpC,SAAS,EAAE,eAAe,EAC1B,QAAQ,EAAE,MAAM,EAChB,UAAU,EAAE,MAAM,EAClB,MAAM,GAAE,cAA+B,GACtC,SAAS,EAAE,CA2Gb"}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import * as t from '@babel/types';
|
|
2
|
+
import { getCodeSnippet } from '../parser/index.js';
|
|
3
|
+
import { DEFAULT_CONFIG } from '../types/index.js';
|
|
4
|
+
export function detectUseEffectOveruse(component, filePath, sourceCode, config = DEFAULT_CONFIG) {
|
|
5
|
+
const smells = [];
|
|
6
|
+
const { useEffect } = component.hooks;
|
|
7
|
+
// Check for too many useEffects
|
|
8
|
+
if (useEffect.length > config.maxUseEffectsPerComponent) {
|
|
9
|
+
smells.push({
|
|
10
|
+
type: 'useEffect-overuse',
|
|
11
|
+
severity: 'warning',
|
|
12
|
+
message: `Component "${component.name}" has ${useEffect.length} useEffect hooks (max recommended: ${config.maxUseEffectsPerComponent})`,
|
|
13
|
+
file: filePath,
|
|
14
|
+
line: component.startLine,
|
|
15
|
+
column: 0,
|
|
16
|
+
suggestion: 'Consider extracting logic into custom hooks or combining related effects',
|
|
17
|
+
codeSnippet: getCodeSnippet(sourceCode, component.startLine),
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
// Check each useEffect for issues
|
|
21
|
+
useEffect.forEach((effect, index) => {
|
|
22
|
+
const loc = effect.loc;
|
|
23
|
+
if (!loc)
|
|
24
|
+
return;
|
|
25
|
+
// Check for empty dependency array with async operations
|
|
26
|
+
const deps = effect.arguments[1];
|
|
27
|
+
const callback = effect.arguments[0];
|
|
28
|
+
if (t.isArrayExpression(deps) && deps.elements.length === 0) {
|
|
29
|
+
// Empty dependency array - check if it's justified
|
|
30
|
+
let hasAsyncOperation = false;
|
|
31
|
+
let hasStateUpdate = false;
|
|
32
|
+
if (t.isArrowFunctionExpression(callback) || t.isFunctionExpression(callback)) {
|
|
33
|
+
const body = callback.body;
|
|
34
|
+
const checkNode = (node) => {
|
|
35
|
+
if (t.isAwaitExpression(node))
|
|
36
|
+
hasAsyncOperation = true;
|
|
37
|
+
if (t.isCallExpression(node)) {
|
|
38
|
+
const callee = node.callee;
|
|
39
|
+
if (t.isIdentifier(callee) && callee.name.startsWith('set')) {
|
|
40
|
+
hasStateUpdate = true;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
if (t.isBlockStatement(body)) {
|
|
45
|
+
body.body.forEach(stmt => {
|
|
46
|
+
t.traverseFast(stmt, checkNode);
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if (hasAsyncOperation && hasStateUpdate) {
|
|
51
|
+
smells.push({
|
|
52
|
+
type: 'useEffect-overuse',
|
|
53
|
+
severity: 'info',
|
|
54
|
+
message: `useEffect #${index + 1} in "${component.name}" has empty deps but contains async state updates`,
|
|
55
|
+
file: filePath,
|
|
56
|
+
line: loc.start.line,
|
|
57
|
+
column: loc.start.column,
|
|
58
|
+
suggestion: 'Consider using React Query, SWR, or a custom hook for data fetching',
|
|
59
|
+
codeSnippet: getCodeSnippet(sourceCode, loc.start.line),
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
// Check for missing cleanup
|
|
64
|
+
if (t.isArrowFunctionExpression(callback) || t.isFunctionExpression(callback)) {
|
|
65
|
+
let hasSubscription = false;
|
|
66
|
+
let hasCleanup = false;
|
|
67
|
+
const checkSubscription = (node) => {
|
|
68
|
+
if (t.isCallExpression(node) && t.isMemberExpression(node.callee)) {
|
|
69
|
+
const prop = node.callee.property;
|
|
70
|
+
if (t.isIdentifier(prop)) {
|
|
71
|
+
if (['addEventListener', 'subscribe', 'on', 'setInterval', 'setTimeout'].includes(prop.name)) {
|
|
72
|
+
hasSubscription = true;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
const body = callback.body;
|
|
78
|
+
if (t.isBlockStatement(body)) {
|
|
79
|
+
body.body.forEach(stmt => {
|
|
80
|
+
t.traverseFast(stmt, checkSubscription);
|
|
81
|
+
if (t.isReturnStatement(stmt) && stmt.argument) {
|
|
82
|
+
hasCleanup = true;
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
if (hasSubscription && !hasCleanup) {
|
|
87
|
+
smells.push({
|
|
88
|
+
type: 'useEffect-overuse',
|
|
89
|
+
severity: 'error',
|
|
90
|
+
message: `useEffect in "${component.name}" sets up subscription but has no cleanup function`,
|
|
91
|
+
file: filePath,
|
|
92
|
+
line: loc.start.line,
|
|
93
|
+
column: loc.start.column,
|
|
94
|
+
suggestion: 'Add a cleanup function to remove event listeners/subscriptions',
|
|
95
|
+
codeSnippet: getCodeSnippet(sourceCode, loc.start.line),
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
return smells;
|
|
101
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { analyzeProject, type AnalyzerOptions } from './analyzer.js';
|
|
2
|
+
export { reportResults, type ReporterOptions } from './reporter.js';
|
|
3
|
+
export * from './types/index.js';
|
|
4
|
+
export { parseFile, parseCode, type ParseResult, type ParsedComponent } from './parser/index.js';
|
|
5
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,KAAK,eAAe,EAAE,MAAM,eAAe,CAAC;AACrE,OAAO,EAAE,aAAa,EAAE,KAAK,eAAe,EAAE,MAAM,eAAe,CAAC;AACpE,cAAc,kBAAkB,CAAC;AACjC,OAAO,EAAE,SAAS,EAAE,SAAS,EAAE,KAAK,WAAW,EAAE,KAAK,eAAe,EAAE,MAAM,mBAAmB,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { NodePath } from '@babel/traverse';
|
|
2
|
+
import * as t from '@babel/types';
|
|
3
|
+
export interface ParsedComponent {
|
|
4
|
+
name: string;
|
|
5
|
+
startLine: number;
|
|
6
|
+
endLine: number;
|
|
7
|
+
node: t.FunctionDeclaration | t.ArrowFunctionExpression | t.FunctionExpression;
|
|
8
|
+
path: NodePath;
|
|
9
|
+
hooks: {
|
|
10
|
+
useEffect: t.CallExpression[];
|
|
11
|
+
useState: t.CallExpression[];
|
|
12
|
+
useMemo: t.CallExpression[];
|
|
13
|
+
useCallback: t.CallExpression[];
|
|
14
|
+
useRef: t.CallExpression[];
|
|
15
|
+
custom: t.CallExpression[];
|
|
16
|
+
};
|
|
17
|
+
props: string[];
|
|
18
|
+
jsxDepth: number;
|
|
19
|
+
}
|
|
20
|
+
export interface ParseResult {
|
|
21
|
+
ast: t.File;
|
|
22
|
+
components: ParsedComponent[];
|
|
23
|
+
imports: string[];
|
|
24
|
+
sourceCode: string;
|
|
25
|
+
}
|
|
26
|
+
export declare function parseFile(filePath: string): Promise<ParseResult>;
|
|
27
|
+
export declare function parseCode(sourceCode: string, filePath?: string): ParseResult;
|
|
28
|
+
export declare function getCodeSnippet(sourceCode: string, line: number, context?: number): string;
|
|
29
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/parser/index.ts"],"names":[],"mappings":"AACA,OAAkB,EAAE,QAAQ,EAAE,MAAM,iBAAiB,CAAC;AACtD,OAAO,KAAK,CAAC,MAAM,cAAc,CAAC;AAMlC,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,CAAC,CAAC,mBAAmB,GAAG,CAAC,CAAC,uBAAuB,GAAG,CAAC,CAAC,kBAAkB,CAAC;IAC/E,IAAI,EAAE,QAAQ,CAAC;IACf,KAAK,EAAE;QACL,SAAS,EAAE,CAAC,CAAC,cAAc,EAAE,CAAC;QAC9B,QAAQ,EAAE,CAAC,CAAC,cAAc,EAAE,CAAC;QAC7B,OAAO,EAAE,CAAC,CAAC,cAAc,EAAE,CAAC;QAC5B,WAAW,EAAE,CAAC,CAAC,cAAc,EAAE,CAAC;QAChC,MAAM,EAAE,CAAC,CAAC,cAAc,EAAE,CAAC;QAC3B,MAAM,EAAE,CAAC,CAAC,cAAc,EAAE,CAAC;KAC5B,CAAC;IACF,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,WAAW;IAC1B,GAAG,EAAE,CAAC,CAAC,IAAI,CAAC;IACZ,UAAU,EAAE,eAAe,EAAE,CAAC;IAC9B,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,wBAAsB,SAAS,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,CAAC,CAGtE;AAED,wBAAgB,SAAS,CAAC,UAAU,EAAE,MAAM,EAAE,QAAQ,GAAE,MAAkB,GAAG,WAAW,CA4CvF;AAmGD,wBAAgB,cAAc,CAAC,UAAU,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,GAAE,MAAU,GAAG,MAAM,CAa5F"}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import * as parser from '@babel/parser';
|
|
2
|
+
import _traverse from '@babel/traverse';
|
|
3
|
+
import * as t from '@babel/types';
|
|
4
|
+
import fs from 'fs/promises';
|
|
5
|
+
// Handle ESM/CJS interop
|
|
6
|
+
const traverse = _traverse.default || _traverse;
|
|
7
|
+
export async function parseFile(filePath) {
|
|
8
|
+
const sourceCode = await fs.readFile(filePath, 'utf-8');
|
|
9
|
+
return parseCode(sourceCode, filePath);
|
|
10
|
+
}
|
|
11
|
+
export function parseCode(sourceCode, filePath = 'unknown') {
|
|
12
|
+
const ast = parser.parse(sourceCode, {
|
|
13
|
+
sourceType: 'module',
|
|
14
|
+
plugins: [
|
|
15
|
+
'jsx',
|
|
16
|
+
'typescript',
|
|
17
|
+
'decorators-legacy',
|
|
18
|
+
'classProperties',
|
|
19
|
+
'optionalChaining',
|
|
20
|
+
'nullishCoalescingOperator',
|
|
21
|
+
],
|
|
22
|
+
sourceFilename: filePath,
|
|
23
|
+
});
|
|
24
|
+
const components = [];
|
|
25
|
+
const imports = [];
|
|
26
|
+
traverse(ast, {
|
|
27
|
+
ImportDeclaration(path) {
|
|
28
|
+
imports.push(path.node.source.value);
|
|
29
|
+
},
|
|
30
|
+
FunctionDeclaration(path) {
|
|
31
|
+
if (isReactComponent(path.node.id?.name, path)) {
|
|
32
|
+
components.push(extractComponentInfo(path.node.id?.name || 'Anonymous', path));
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
VariableDeclarator(path) {
|
|
36
|
+
const init = path.node.init;
|
|
37
|
+
const id = path.node.id;
|
|
38
|
+
if (t.isIdentifier(id) &&
|
|
39
|
+
(t.isArrowFunctionExpression(init) || t.isFunctionExpression(init))) {
|
|
40
|
+
if (isReactComponent(id.name, path)) {
|
|
41
|
+
components.push(extractComponentInfo(id.name, path, init));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
return { ast, components, imports, sourceCode };
|
|
47
|
+
}
|
|
48
|
+
function isReactComponent(name, path) {
|
|
49
|
+
if (!name)
|
|
50
|
+
return false;
|
|
51
|
+
// Component names start with uppercase
|
|
52
|
+
if (!/^[A-Z]/.test(name))
|
|
53
|
+
return false;
|
|
54
|
+
// Check if it returns JSX
|
|
55
|
+
let hasJSX = false;
|
|
56
|
+
path.traverse({
|
|
57
|
+
JSXElement() {
|
|
58
|
+
hasJSX = true;
|
|
59
|
+
},
|
|
60
|
+
JSXFragment() {
|
|
61
|
+
hasJSX = true;
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
return hasJSX;
|
|
65
|
+
}
|
|
66
|
+
function extractComponentInfo(name, path, node) {
|
|
67
|
+
const actualNode = node || path.node;
|
|
68
|
+
const loc = actualNode.loc;
|
|
69
|
+
const hooks = {
|
|
70
|
+
useEffect: [],
|
|
71
|
+
useState: [],
|
|
72
|
+
useMemo: [],
|
|
73
|
+
useCallback: [],
|
|
74
|
+
useRef: [],
|
|
75
|
+
custom: [],
|
|
76
|
+
};
|
|
77
|
+
const props = [];
|
|
78
|
+
let jsxDepth = 0;
|
|
79
|
+
// Extract hooks
|
|
80
|
+
path.traverse({
|
|
81
|
+
CallExpression(callPath) {
|
|
82
|
+
const callee = callPath.node.callee;
|
|
83
|
+
if (t.isIdentifier(callee)) {
|
|
84
|
+
const hookName = callee.name;
|
|
85
|
+
if (hookName === 'useEffect')
|
|
86
|
+
hooks.useEffect.push(callPath.node);
|
|
87
|
+
else if (hookName === 'useState')
|
|
88
|
+
hooks.useState.push(callPath.node);
|
|
89
|
+
else if (hookName === 'useMemo')
|
|
90
|
+
hooks.useMemo.push(callPath.node);
|
|
91
|
+
else if (hookName === 'useCallback')
|
|
92
|
+
hooks.useCallback.push(callPath.node);
|
|
93
|
+
else if (hookName === 'useRef')
|
|
94
|
+
hooks.useRef.push(callPath.node);
|
|
95
|
+
else if (hookName.startsWith('use'))
|
|
96
|
+
hooks.custom.push(callPath.node);
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
// Extract props
|
|
101
|
+
const params = t.isFunctionDeclaration(actualNode)
|
|
102
|
+
? actualNode.params
|
|
103
|
+
: actualNode.params;
|
|
104
|
+
if (params.length > 0) {
|
|
105
|
+
const firstParam = params[0];
|
|
106
|
+
if (t.isObjectPattern(firstParam)) {
|
|
107
|
+
firstParam.properties.forEach(prop => {
|
|
108
|
+
if (t.isObjectProperty(prop) && t.isIdentifier(prop.key)) {
|
|
109
|
+
props.push(prop.key.name);
|
|
110
|
+
}
|
|
111
|
+
else if (t.isRestElement(prop) && t.isIdentifier(prop.argument)) {
|
|
112
|
+
props.push(`...${prop.argument.name}`);
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
else if (t.isIdentifier(firstParam)) {
|
|
117
|
+
props.push(firstParam.name);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
// Calculate JSX nesting depth
|
|
121
|
+
path.traverse({
|
|
122
|
+
JSXElement: {
|
|
123
|
+
enter() {
|
|
124
|
+
jsxDepth++;
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
return {
|
|
129
|
+
name,
|
|
130
|
+
startLine: loc?.start.line || 0,
|
|
131
|
+
endLine: loc?.end.line || 0,
|
|
132
|
+
node: actualNode,
|
|
133
|
+
path,
|
|
134
|
+
hooks,
|
|
135
|
+
props,
|
|
136
|
+
jsxDepth: Math.floor(jsxDepth / 2), // Approximate depth
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
export function getCodeSnippet(sourceCode, line, context = 2) {
|
|
140
|
+
const lines = sourceCode.split('\n');
|
|
141
|
+
const start = Math.max(0, line - context - 1);
|
|
142
|
+
const end = Math.min(lines.length, line + context);
|
|
143
|
+
return lines
|
|
144
|
+
.slice(start, end)
|
|
145
|
+
.map((l, i) => {
|
|
146
|
+
const lineNum = start + i + 1;
|
|
147
|
+
const marker = lineNum === line ? '>' : ' ';
|
|
148
|
+
return `${marker} ${lineNum.toString().padStart(4)} | ${l}`;
|
|
149
|
+
})
|
|
150
|
+
.join('\n');
|
|
151
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { AnalysisResult } from './types/index.js';
|
|
2
|
+
export interface ReporterOptions {
|
|
3
|
+
format: 'console' | 'json' | 'markdown';
|
|
4
|
+
showCodeSnippets: boolean;
|
|
5
|
+
rootDir: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function reportResults(result: AnalysisResult, options: ReporterOptions): string;
|
|
8
|
+
//# sourceMappingURL=reporter.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"reporter.d.ts","sourceRoot":"","sources":["../src/reporter.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,cAAc,EAA4B,MAAM,kBAAkB,CAAC;AAG5E,MAAM,WAAW,eAAe;IAC9B,MAAM,EAAE,SAAS,GAAG,MAAM,GAAG,UAAU,CAAC;IACxC,gBAAgB,EAAE,OAAO,CAAC;IAC1B,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,wBAAgB,aAAa,CAAC,MAAM,EAAE,cAAc,EAAE,OAAO,EAAE,eAAe,GAAG,MAAM,CAUtF"}
|