react-doctor-cli-dev 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/backend/.env +3 -0
- package/backend/dist/index.js +43 -0
- package/backend/dist/middleware/auth.js +16 -0
- package/backend/dist/routes/reports.js +93 -0
- package/backend/package-lock.json +2000 -0
- package/backend/package.json +30 -0
- package/backend/src/db.ts +24 -0
- package/backend/src/index.ts +49 -0
- package/backend/src/middleware/auth.ts +21 -0
- package/backend/src/routes/reports.ts +110 -0
- package/backend/tsconfig.json +12 -0
- package/cli/bin/react-doctor.js +29 -0
- package/cli/dist/commands/analyze.js +125 -0
- package/cli/dist/commands/full.js +366 -0
- package/cli/dist/commands/install.js +138 -0
- package/cli/dist/commands/profile.js +166 -0
- package/cli/dist/index.js +78 -0
- package/cli/dist/ui.js +113 -0
- package/cli/package-lock.json +936 -0
- package/cli/package.json +34 -0
- package/cli/src/commands/analyze.ts +162 -0
- package/cli/src/commands/full.ts +574 -0
- package/cli/src/commands/install.ts +163 -0
- package/cli/src/commands/profile.ts +246 -0
- package/cli/src/index.ts +84 -0
- package/cli/src/ui.ts +120 -0
- package/cli/tsconfig.json +16 -0
- package/core/report-compiler/index.ts +359 -0
- package/core/report-compiler/test-report-compiler.ts +126 -0
- package/core/rule-engine/context-builder.ts +146 -0
- package/core/rule-engine/evaluator.ts +131 -0
- package/core/rule-engine/index.ts +222 -0
- package/core/rule-engine/rules.json +304 -0
- package/core/rule-engine/suggestion-builder.ts +209 -0
- package/core/rule-engine/test-rule-engine.ts +144 -0
- package/core/rule-engine/types.ts +202 -0
- package/core/runtime/profiler/browser.ts +121 -0
- package/core/runtime/profiler/collectors.ts +216 -0
- package/core/runtime/profiler/index.ts +311 -0
- package/core/runtime/profiler/porfiler.ts +967 -0
- package/core/runtime/profiler/route-scanner.ts +76 -0
- package/core/runtime/profiler/score.ts +59 -0
- package/core/runtime/profiler/server.ts +115 -0
- package/core/runtime/profiler/types.ts +65 -0
- package/core/runtime/test-runtime-profiler.ts +226 -0
- package/core/static-ana/static/analyzer.ts +145 -0
- package/core/static-ana/static/ast-parser.ts +31 -0
- package/core/static-ana/static/detectors/console-log.ts +49 -0
- package/core/static-ana/static/detectors/dead-code.ts +51 -0
- package/core/static-ana/static/detectors/effect-loop.ts +45 -0
- package/core/static-ana/static/detectors/index.ts +16 -0
- package/core/static-ana/static/detectors/inline-function.ts +59 -0
- package/core/static-ana/static/detectors/inline-style.ts +52 -0
- package/core/static-ana/static/detectors/large-component.ts +79 -0
- package/core/static-ana/static/detectors/missing-key.ts +56 -0
- package/core/static-ana/static/detectors/missing-memo.ts +59 -0
- package/core/static-ana/static/detectors/prop-drilling.ts +66 -0
- package/core/static-ana/static/helpers.ts +81 -0
- package/core/static-ana/static/scanner.ts +93 -0
- package/core/static-ana/test-analyzer.ts +115 -0
- package/core/static-ana/types.ts +25 -0
- package/core/tests/mock-react-project/src/app.tsx +22 -0
- package/core/tests/mock-react-project/src/components/Button.tsx +9 -0
- package/core/tests/mock-react-project/src/components/Header.tsx +3 -0
- package/core/tests/mock-react-project/src/components/ListTesting.tsx +51 -0
- package/core/tests/mock-react-project/src/components/UserDashboard.tsx +66 -0
- package/core/tests/mock-react-project/src/utils.ts +4 -0
- package/package.json +55 -0
- package/react-doctor-cli-dev-1.0.0.tgz +0 -0
- package/shared/dist/index.d.ts +2 -0
- package/shared/dist/index.js +19 -0
- package/shared/dist/schemas.d.ts +91 -0
- package/shared/dist/schemas.js +82 -0
- package/shared/dist/types.d.ts +44 -0
- package/shared/dist/types.js +2 -0
- package/shared/package-lock.json +47 -0
- package/shared/package.json +21 -0
- package/shared/src/index.ts +4 -0
- package/shared/src/schemas.ts +136 -0
- package/shared/src/types.ts +137 -0
- package/shared/tsconfig.json +15 -0
- package/tsconfig.json +25 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import * as parser from '@babel/parser';
|
|
2
|
+
import { File } from '@babel/types';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* AST Parser - Converts JavaScript/TypeScript code into an Abstract Syntax Tree
|
|
6
|
+
*/
|
|
7
|
+
export class ASTParser {
|
|
8
|
+
/**
|
|
9
|
+
* Parse source code into an AST
|
|
10
|
+
* @param code - Source code as string
|
|
11
|
+
* @param filePath - File path (for better error messages)
|
|
12
|
+
* @returns Parsed AST
|
|
13
|
+
*/
|
|
14
|
+
parse(code: string, filePath: string): File {
|
|
15
|
+
try {
|
|
16
|
+
return parser.parse(code, {
|
|
17
|
+
sourceType: 'module',
|
|
18
|
+
plugins: [
|
|
19
|
+
'jsx', // Parse JSX syntax
|
|
20
|
+
'typescript', // Parse TypeScript syntax
|
|
21
|
+
'decorators-legacy', // Support decorators
|
|
22
|
+
'classProperties', // Support class properties
|
|
23
|
+
'dynamicImport', // Support dynamic imports
|
|
24
|
+
],
|
|
25
|
+
});
|
|
26
|
+
} catch (error) {
|
|
27
|
+
const errorMessage = error instanceof Error ? error.message : 'Unknown parsing error';
|
|
28
|
+
throw new Error(`Failed to parse ${filePath}: ${errorMessage}`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import traverse from '@babel/traverse';
|
|
2
|
+
import { File } from '@babel/types';
|
|
3
|
+
import { ComponentIssue } from '../../../../shared/src/types';
|
|
4
|
+
import { getParentComponentName, generateIssueId } from '../helpers';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Detect console.log, console.warn, console.error statements
|
|
8
|
+
*
|
|
9
|
+
* Why this is bad:
|
|
10
|
+
* - Performance impact in production
|
|
11
|
+
* - Exposes internal information
|
|
12
|
+
* - Clutters browser console
|
|
13
|
+
*/
|
|
14
|
+
export function detectConsoleLogs(ast: File, filePath: string): ComponentIssue[] {
|
|
15
|
+
const issues: ComponentIssue[] = [];
|
|
16
|
+
|
|
17
|
+
traverse(ast, {
|
|
18
|
+
CallExpression(path) { // 1. Guard finds a function call
|
|
19
|
+
const node = path.node;
|
|
20
|
+
|
|
21
|
+
// Check if it's a console.* call
|
|
22
|
+
if (
|
|
23
|
+
node.callee.type === 'MemberExpression' && // 2. Is it a "Something.Something"?
|
|
24
|
+
node.callee.object.type === 'Identifier' &&
|
|
25
|
+
node.callee.object.name === 'console' // 3. Is the first "Something" 'console'?
|
|
26
|
+
) {
|
|
27
|
+
const method = node.callee.property.type === 'Identifier'
|
|
28
|
+
? node.callee.property.name
|
|
29
|
+
: 'unknown';
|
|
30
|
+
|
|
31
|
+
const line = node.loc?.start.line || 0;
|
|
32
|
+
const component = getParentComponentName(path);
|
|
33
|
+
|
|
34
|
+
issues.push({
|
|
35
|
+
id: generateIssueId('console', filePath, line),
|
|
36
|
+
component,
|
|
37
|
+
file: filePath,
|
|
38
|
+
line,
|
|
39
|
+
column: node.loc?.start.column,
|
|
40
|
+
severity: 'info',
|
|
41
|
+
message: `console.${method}() statement found`,
|
|
42
|
+
suggestion: 'Remove console statements before deploying to production',
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
return issues;
|
|
49
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import traverse from '@babel/traverse';
|
|
2
|
+
import { File } from '@babel/types';
|
|
3
|
+
import { ComponentIssue } from '../../../../shared/src/types';
|
|
4
|
+
import { generateIssueId } from '../helpers';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Detect unused variables and imports
|
|
8
|
+
*/
|
|
9
|
+
export function detectDeadCode(ast: File, filePath: string): ComponentIssue[] {
|
|
10
|
+
const issues: ComponentIssue[] = [];
|
|
11
|
+
|
|
12
|
+
traverse(ast, {
|
|
13
|
+
Program(path) {
|
|
14
|
+
// Babel automatically collects all "bindings" (variables/imports)
|
|
15
|
+
// in the current scope.
|
|
16
|
+
const bindings = path.scope.getAllBindings();
|
|
17
|
+
|
|
18
|
+
for (const name in bindings) {
|
|
19
|
+
const binding = bindings[name];
|
|
20
|
+
|
|
21
|
+
// 1. Check if the variable was ever referenced (used)
|
|
22
|
+
if (!binding.referenced) {
|
|
23
|
+
|
|
24
|
+
// 2. Ignore variables that start with "_"
|
|
25
|
+
// (Standard dev practice for "intentionally unused")
|
|
26
|
+
if (name.startsWith('_')) continue;
|
|
27
|
+
|
|
28
|
+
// 3. Identify if it's an Import or a local Variable
|
|
29
|
+
const isImport = binding.kind === 'module';
|
|
30
|
+
const typeLabel = isImport ? 'Import' : 'Variable';
|
|
31
|
+
|
|
32
|
+
const node = binding.path.node;
|
|
33
|
+
const line = node.loc?.start.line || 0;
|
|
34
|
+
|
|
35
|
+
issues.push({
|
|
36
|
+
id: generateIssueId('dead-code', filePath, line),
|
|
37
|
+
component: 'Global/Module', // Dead code is often outside components
|
|
38
|
+
file: filePath,
|
|
39
|
+
line,
|
|
40
|
+
column: node.loc?.start.column,
|
|
41
|
+
severity: 'warning',
|
|
42
|
+
message: `Unused ${typeLabel} found: "${name}"`,
|
|
43
|
+
suggestion: `Remove the unused ${typeLabel.toLowerCase()} to clean up the code and reduce bundle size.`,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
return issues;
|
|
51
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import traverse from '@babel/traverse';
|
|
2
|
+
import { File } from '@babel/types';
|
|
3
|
+
import { ComponentIssue } from '../../../../shared/src/types';
|
|
4
|
+
import { getParentComponentName, generateIssueId } from '../helpers';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Detect potential infinite loops in useEffect hooks
|
|
8
|
+
*/
|
|
9
|
+
export function detectInfiniteLoops(ast: File, filePath: string): ComponentIssue[] {
|
|
10
|
+
const issues: ComponentIssue[] = [];
|
|
11
|
+
|
|
12
|
+
traverse(ast, {
|
|
13
|
+
CallExpression(path) {
|
|
14
|
+
const node = path.node;
|
|
15
|
+
|
|
16
|
+
// 1. Is the function being called named 'useEffect'?
|
|
17
|
+
if (
|
|
18
|
+
node.callee.type === 'Identifier' &&
|
|
19
|
+
node.callee.name === 'useEffect'
|
|
20
|
+
) {
|
|
21
|
+
|
|
22
|
+
// 2. Check the number of arguments
|
|
23
|
+
// useEffect(callback, dependencies) should have 2 arguments.
|
|
24
|
+
// If it only has 1, it runs on EVERY render.
|
|
25
|
+
if (node.arguments.length === 1) {
|
|
26
|
+
const line = node.loc?.start.line || 0;
|
|
27
|
+
const component = getParentComponentName(path);
|
|
28
|
+
|
|
29
|
+
issues.push({
|
|
30
|
+
id: generateIssueId('infinite-loop', filePath, line),
|
|
31
|
+
component,
|
|
32
|
+
file: filePath,
|
|
33
|
+
line,
|
|
34
|
+
column: node.loc?.start.column,
|
|
35
|
+
severity: 'critical', // This is a "Critical" issue!
|
|
36
|
+
message: `Potential Infinite Loop: "useEffect" is missing a dependency array`,
|
|
37
|
+
suggestion: 'Add a dependency array (e.g., [], [data]) as the second argument to control when the effect runs.',
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
return issues;
|
|
45
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Central export for all detectors
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export { detectConsoleLogs } from './console-log';
|
|
6
|
+
export { detectLargeComponents } from './large-component';
|
|
7
|
+
export { detectInlineFunctions } from './inline-function';
|
|
8
|
+
export { detectMissingKeys } from './missing-key';
|
|
9
|
+
export { detectInfiniteLoops } from './effect-loop';
|
|
10
|
+
export { detectPropDrilling } from './prop-drilling';
|
|
11
|
+
export { detectMissingMemo } from './missing-memo';
|
|
12
|
+
export { detectInlineStyles } from './inline-style';
|
|
13
|
+
export { detectDeadCode } from './dead-code';
|
|
14
|
+
// As you build more detectors, export them here
|
|
15
|
+
// export { detectMissingKeys } from './missing-key';
|
|
16
|
+
// export { detectMissingMemo } from './missing-memo';
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import traverse from '@babel/traverse';
|
|
2
|
+
import { File } from '@babel/types';
|
|
3
|
+
import { ComponentIssue } from '../../../../shared/src/types';
|
|
4
|
+
import { getParentComponentName, generateIssueId } from '../helpers';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Detect inline functions in JSX props
|
|
8
|
+
*
|
|
9
|
+
* Example of bad code:
|
|
10
|
+
* <button onClick={() => handleClick()}>Click</button>
|
|
11
|
+
*
|
|
12
|
+
* Why this is bad:
|
|
13
|
+
* - Creates a new function on every render
|
|
14
|
+
* - Causes child components to re-render unnecessarily
|
|
15
|
+
* - Performance impact
|
|
16
|
+
*
|
|
17
|
+
* Better approach:
|
|
18
|
+
* const handleClick = useCallback(() => {...}, []);
|
|
19
|
+
* <button onClick={handleClick}>Click</button>
|
|
20
|
+
*/
|
|
21
|
+
export function detectInlineFunctions(ast: File, filePath: string): ComponentIssue[] {
|
|
22
|
+
const issues: ComponentIssue[] = [];
|
|
23
|
+
|
|
24
|
+
traverse(ast, {
|
|
25
|
+
JSXAttribute(path) {
|
|
26
|
+
const node = path.node;
|
|
27
|
+
|
|
28
|
+
// Check if the attribute value is an expression container
|
|
29
|
+
if (!node.value || node.value.type !== 'JSXExpressionContainer') {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const expression = node.value.expression;
|
|
34
|
+
|
|
35
|
+
// Check if the expression is an inline function
|
|
36
|
+
if (
|
|
37
|
+
expression.type === 'ArrowFunctionExpression' ||
|
|
38
|
+
expression.type === 'FunctionExpression'
|
|
39
|
+
) {
|
|
40
|
+
const attributeName = node.name.type === 'JSXIdentifier' ? node.name.name : 'unknown';
|
|
41
|
+
const line = node.loc?.start.line || 0;
|
|
42
|
+
const component = getParentComponentName(path);
|
|
43
|
+
|
|
44
|
+
issues.push({
|
|
45
|
+
id: generateIssueId('inline-function', filePath, line),
|
|
46
|
+
component,
|
|
47
|
+
file: filePath,
|
|
48
|
+
line,
|
|
49
|
+
column: node.loc?.start.column,
|
|
50
|
+
severity: 'info',
|
|
51
|
+
message: `Inline ${expression.type} in prop "${attributeName}"`,
|
|
52
|
+
suggestion: 'Use useCallback or define function outside component to prevent re-renders',
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
return issues;
|
|
59
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import traverse from '@babel/traverse';
|
|
2
|
+
import { File } from '@babel/types';
|
|
3
|
+
import { ComponentIssue } from '../../../../shared/src/types';
|
|
4
|
+
import { getParentComponentName, generateIssueId } from '../helpers';
|
|
5
|
+
|
|
6
|
+
// Threshold: If an inline style has more than this many properties, flag it.
|
|
7
|
+
const MAX_STYLE_PROPS = 5;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Detect large inline style objects in JSX
|
|
11
|
+
*/
|
|
12
|
+
export function detectInlineStyles(ast: File, filePath: string): ComponentIssue[] {
|
|
13
|
+
const issues: ComponentIssue[] = [];
|
|
14
|
+
|
|
15
|
+
traverse(ast, {
|
|
16
|
+
JSXAttribute(path) {
|
|
17
|
+
const node = path.node;
|
|
18
|
+
|
|
19
|
+
// 1. Is the attribute named "style"?
|
|
20
|
+
if (node.name.name !== 'style') return;
|
|
21
|
+
|
|
22
|
+
// 2. Is the value an expression like {{ ... }}?
|
|
23
|
+
if (node.value?.type === 'JSXExpressionContainer') {
|
|
24
|
+
const expression = node.value.expression;
|
|
25
|
+
|
|
26
|
+
// 3. Is it an object (ObjectExpression)?
|
|
27
|
+
if (expression.type === 'ObjectExpression') {
|
|
28
|
+
const propCount = expression.properties.length;
|
|
29
|
+
|
|
30
|
+
// 4. Check if it's too big
|
|
31
|
+
if (propCount > MAX_STYLE_PROPS) {
|
|
32
|
+
const line = node.loc?.start.line || 0;
|
|
33
|
+
const component = getParentComponentName(path);
|
|
34
|
+
|
|
35
|
+
issues.push({
|
|
36
|
+
id: generateIssueId('inline-style', filePath, line),
|
|
37
|
+
component,
|
|
38
|
+
file: filePath,
|
|
39
|
+
line,
|
|
40
|
+
column: node.loc?.start.column,
|
|
41
|
+
severity: 'warning',
|
|
42
|
+
message: `Large inline style object found (${propCount} properties)`,
|
|
43
|
+
suggestion: 'Move large style objects to a constant or use CSS/Styled-components to improve performance and readability.',
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
return issues;
|
|
52
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import * as traverse from '@babel/traverse';
|
|
2
|
+
import { File } from '@babel/types';
|
|
3
|
+
import { ComponentIssue } from '../../../../shared/src/types';
|
|
4
|
+
import { getLineCount, returnsJSX, generateIssueId } from '../helpers';
|
|
5
|
+
|
|
6
|
+
// Threshold: components over this many lines are considered large
|
|
7
|
+
const MAX_COMPONENT_LINES = 300;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Detect components that are too large (>300 lines)
|
|
11
|
+
*
|
|
12
|
+
* Why this is bad:
|
|
13
|
+
* - Hard to understand and maintain
|
|
14
|
+
* - Likely doing too many things (violates Single Responsibility)
|
|
15
|
+
* - Hard to test
|
|
16
|
+
* - Harder to reuse
|
|
17
|
+
*/
|
|
18
|
+
export function detectLargeComponents(ast: File, filePath: string): ComponentIssue[] {
|
|
19
|
+
const issues: ComponentIssue[] = [];
|
|
20
|
+
|
|
21
|
+
traverse.default(ast, {
|
|
22
|
+
// Check function declarations
|
|
23
|
+
FunctionDeclaration(path) {
|
|
24
|
+
const node = path.node;
|
|
25
|
+
|
|
26
|
+
// Only check if it's actually a React component (returns JSX)
|
|
27
|
+
if (!returnsJSX(path)) return;
|
|
28
|
+
|
|
29
|
+
const lineCount = getLineCount(node);
|
|
30
|
+
|
|
31
|
+
if (lineCount > MAX_COMPONENT_LINES) {
|
|
32
|
+
const componentName = node.id?.name || 'Unknown';
|
|
33
|
+
const line = node.loc?.start.line || 0;
|
|
34
|
+
|
|
35
|
+
issues.push({
|
|
36
|
+
id: generateIssueId('large-component', filePath, line),
|
|
37
|
+
component: componentName,
|
|
38
|
+
file: filePath,
|
|
39
|
+
line,
|
|
40
|
+
column: node.loc?.start.column,
|
|
41
|
+
severity: 'warning',
|
|
42
|
+
message: `Component "${componentName}" is ${lineCount} lines long`,
|
|
43
|
+
suggestion: `Split into smaller components (recommended max: ${MAX_COMPONENT_LINES} lines)`,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
|
|
48
|
+
// Check arrow function components (const Component = () => ...)
|
|
49
|
+
VariableDeclarator(path) {
|
|
50
|
+
const node = path.node;
|
|
51
|
+
|
|
52
|
+
// Check if it's an arrow function
|
|
53
|
+
if (node.init?.type !== 'ArrowFunctionExpression') return;
|
|
54
|
+
|
|
55
|
+
// Check if it returns JSX (now works with updated helper!)
|
|
56
|
+
if (!returnsJSX(path.get('init'))) return;
|
|
57
|
+
|
|
58
|
+
const lineCount = getLineCount(node.init);
|
|
59
|
+
|
|
60
|
+
if (lineCount > MAX_COMPONENT_LINES) {
|
|
61
|
+
const componentName = node.id.type === 'Identifier' ? node.id.name : 'Unknown';
|
|
62
|
+
const line = node.loc?.start.line || 0;
|
|
63
|
+
|
|
64
|
+
issues.push({
|
|
65
|
+
id: generateIssueId('large-component', filePath, line),
|
|
66
|
+
component: componentName,
|
|
67
|
+
file: filePath,
|
|
68
|
+
line,
|
|
69
|
+
column: node.loc?.start.column,
|
|
70
|
+
severity: 'warning',
|
|
71
|
+
message: `Component "${componentName}" is ${lineCount} lines long`,
|
|
72
|
+
suggestion: `Split into smaller components (recommended max: ${MAX_COMPONENT_LINES} lines)`,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
return issues;
|
|
79
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import traverse from '@babel/traverse';
|
|
2
|
+
import { File, isJSXElement, isJSXAttribute, isIdentifier, isArrayExpression } from '@babel/types';
|
|
3
|
+
import { ComponentIssue } from '../../../../shared/src/types';
|
|
4
|
+
import { generateIssueId, getParentComponentName } from '../helpers';
|
|
5
|
+
|
|
6
|
+
export function detectMissingKeys(ast: File, filePath: string): ComponentIssue[] {
|
|
7
|
+
const issues: ComponentIssue[] = [];
|
|
8
|
+
|
|
9
|
+
traverse(ast, {
|
|
10
|
+
// 1. Catch Literal Arrays: {[ <div />, <span /> ]}
|
|
11
|
+
ArrayExpression(path) {
|
|
12
|
+
const hasJSX = path.node.elements.some(el => isJSXElement(el));
|
|
13
|
+
if (!hasJSX) return;
|
|
14
|
+
|
|
15
|
+
path.node.elements.forEach((el) => {
|
|
16
|
+
if (isJSXElement(el)) {
|
|
17
|
+
const hasKey = el.openingElement.attributes.some(attr =>
|
|
18
|
+
isJSXAttribute(attr) && isIdentifier(attr.name, { name: 'key' })
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
if (!hasKey) {
|
|
22
|
+
pushIssue(el, issues, filePath, path);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
},
|
|
27
|
+
|
|
28
|
+
// 2. Catch .map(), .filter(), .concat() that return JSX
|
|
29
|
+
CallExpression(path) {
|
|
30
|
+
const { node } = path;
|
|
31
|
+
// Look for array methods
|
|
32
|
+
if (node.callee.type === 'MemberExpression' && isIdentifier(node.callee.property)) {
|
|
33
|
+
const methodName = node.callee.property.name;
|
|
34
|
+
if (['map', 'filter', 'concat', 'from'].includes(methodName)) {
|
|
35
|
+
// ... logic to check if the result is JSX (similar to previous code)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
return issues;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Helper to keep code clean
|
|
45
|
+
function pushIssue(node: any, issues: any[], filePath: string, path: any) {
|
|
46
|
+
const line = node.loc?.start.line || 0;
|
|
47
|
+
issues.push({
|
|
48
|
+
id: generateIssueId('missing-key', filePath, line),
|
|
49
|
+
component: getParentComponentName(path),
|
|
50
|
+
file: filePath,
|
|
51
|
+
line,
|
|
52
|
+
severity: 'warning',
|
|
53
|
+
message: 'Missing "key" prop in an array of elements',
|
|
54
|
+
suggestion: 'Every element in an array or iterator must have a unique "key" prop.',
|
|
55
|
+
});
|
|
56
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import traverse from '@babel/traverse';
|
|
2
|
+
import { File, isCallExpression, isIdentifier } from '@babel/types';
|
|
3
|
+
import { ComponentIssue } from '../../../../shared/src/types';
|
|
4
|
+
import { generateIssueId } from '../helpers';
|
|
5
|
+
|
|
6
|
+
// Threshold: Only suggest memo if the component is over 40 lines
|
|
7
|
+
const MIN_LINES_FOR_MEMO = 40;
|
|
8
|
+
|
|
9
|
+
export function detectMissingMemo(ast: File, filePath: string): ComponentIssue[] {
|
|
10
|
+
const issues: ComponentIssue[] = [];
|
|
11
|
+
|
|
12
|
+
traverse(ast, {
|
|
13
|
+
FunctionDeclaration(path) {
|
|
14
|
+
const node = path.node;
|
|
15
|
+
|
|
16
|
+
// 1. Check if it looks like a React Component (Capitalized name)
|
|
17
|
+
const name = node.id?.name;
|
|
18
|
+
if (!name || !/^[A-Z]/.test(name)) return;
|
|
19
|
+
|
|
20
|
+
// 2. Check size: Small components don't always need memo
|
|
21
|
+
const startLine = node.loc?.start.line || 0;
|
|
22
|
+
const endLine = node.loc?.end.line || 0;
|
|
23
|
+
const lineCount = endLine - startLine;
|
|
24
|
+
if (lineCount < MIN_LINES_FOR_MEMO) return;
|
|
25
|
+
|
|
26
|
+
// 3. Check if it's already wrapped in memo (if exported separately)
|
|
27
|
+
// or if it's an ExportDefaultDeclaration wrapped in memo
|
|
28
|
+
let isMemoized = false;
|
|
29
|
+
path.scope.path.parentPath?.traverse({
|
|
30
|
+
CallExpression(innerPath) {
|
|
31
|
+
if (
|
|
32
|
+
isIdentifier(innerPath.node.callee, { name: 'memo' }) ||
|
|
33
|
+
(innerPath.node.callee.type === 'MemberExpression' &&
|
|
34
|
+
isIdentifier(innerPath.node.callee.property, { name: 'memo' }))
|
|
35
|
+
) {
|
|
36
|
+
// Check if our component name is passed to this memo()
|
|
37
|
+
if (innerPath.node.arguments.some(arg => isIdentifier(arg, { name }))) {
|
|
38
|
+
isMemoized = true;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
if (!isMemoized) {
|
|
45
|
+
issues.push({
|
|
46
|
+
id: generateIssueId('missing-memo', filePath, startLine),
|
|
47
|
+
component: name,
|
|
48
|
+
file: filePath,
|
|
49
|
+
line: startLine,
|
|
50
|
+
severity: 'info',
|
|
51
|
+
message: `Large component "${name}" is not memoized.`,
|
|
52
|
+
suggestion: 'Wrap this component in React.memo() to prevent unnecessary re-renders when parent props change.',
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
return issues;
|
|
59
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import traverse from '@babel/traverse';
|
|
2
|
+
import { File, isFunctionDeclaration, isArrowFunctionExpression, isObjectProperty, isIdentifier } from '@babel/types';
|
|
3
|
+
import { ComponentIssue } from '../../../../shared/src/types';
|
|
4
|
+
import { generateIssueId } from '../helpers';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Detect potential prop drilling (props passed through without being used)
|
|
8
|
+
*/
|
|
9
|
+
export function detectPropDrilling(ast: File, filePath: string): ComponentIssue[] {
|
|
10
|
+
const issues: ComponentIssue[] = [];
|
|
11
|
+
|
|
12
|
+
traverse(ast, {
|
|
13
|
+
// We target both declaration styles
|
|
14
|
+
"FunctionDeclaration|ArrowFunctionExpression"(path) {
|
|
15
|
+
const node = path.node;
|
|
16
|
+
|
|
17
|
+
// 1. Tell TypeScript: "Trust me, this node has params"
|
|
18
|
+
if (!isFunctionDeclaration(node) && !isArrowFunctionExpression(node)) return;
|
|
19
|
+
|
|
20
|
+
const params = node.params;
|
|
21
|
+
if (params.length === 0) return;
|
|
22
|
+
|
|
23
|
+
const propsNode = params[0];
|
|
24
|
+
let propNames: string[] = [];
|
|
25
|
+
|
|
26
|
+
// Handle destructured props: ({ user, theme })
|
|
27
|
+
if (propsNode.type === 'ObjectPattern') {
|
|
28
|
+
propNames = propsNode.properties
|
|
29
|
+
.filter((p): p is any => isObjectProperty(p) && isIdentifier(p.key))
|
|
30
|
+
.map(p => (p.key as any).name);
|
|
31
|
+
}
|
|
32
|
+
// Handle single prop object: (props)
|
|
33
|
+
else if (propsNode.type === 'Identifier') {
|
|
34
|
+
propNames = [propsNode.name];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// 2. For each prop, check its references in the component scope
|
|
38
|
+
propNames.forEach(name => {
|
|
39
|
+
const binding = path.scope.getBinding(name);
|
|
40
|
+
if (!binding) return;
|
|
41
|
+
|
|
42
|
+
const totalRefs = binding.referencePaths.length;
|
|
43
|
+
const jsxRefs = binding.referencePaths.filter(refPath =>
|
|
44
|
+
refPath.findParent(p => p.isJSXAttribute())
|
|
45
|
+
).length;
|
|
46
|
+
|
|
47
|
+
// If used, but ONLY as a pass-through to a JSX attribute
|
|
48
|
+
if (totalRefs > 0 && totalRefs === jsxRefs) {
|
|
49
|
+
const line = node.loc?.start.line || 0;
|
|
50
|
+
|
|
51
|
+
issues.push({
|
|
52
|
+
id: generateIssueId('prop-drilling', filePath, line),
|
|
53
|
+
component: (node as any).id?.name || 'UnknownComponent',
|
|
54
|
+
file: filePath,
|
|
55
|
+
line,
|
|
56
|
+
severity: 'info',
|
|
57
|
+
message: `Potential Prop Drilling: The prop "${name}" is passed through this component without being used locally.`,
|
|
58
|
+
suggestion: 'Consider using React Context or a State Management library (Redux/Zustand) to avoid deep prop drilling.',
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
return issues;
|
|
66
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { NodePath } from '@babel/traverse';
|
|
2
|
+
import { Node } from '@babel/types';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Get the name of the parent component
|
|
6
|
+
* Walks up the AST tree to find the nearest component name
|
|
7
|
+
*/
|
|
8
|
+
export function getParentComponentName(path: NodePath): string {
|
|
9
|
+
let current: NodePath | null = path;
|
|
10
|
+
|
|
11
|
+
while (current) {
|
|
12
|
+
// Check for FunctionDeclaration with a name
|
|
13
|
+
if (current.isFunctionDeclaration() && current.node.id) {
|
|
14
|
+
return current.node.id.name;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Check for VariableDeclarator (const Component = ...)
|
|
18
|
+
if (current.isVariableDeclarator() && current.node.id.type === 'Identifier') {
|
|
19
|
+
return current.node.id.name;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
current = current.parentPath;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return 'Unknown';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Check if a function/component returns JSX
|
|
30
|
+
* Safely handles null/undefined paths
|
|
31
|
+
*
|
|
32
|
+
* @param path - NodePath to check (can be null/undefined)
|
|
33
|
+
* @returns true if the path contains JSX elements or fragments
|
|
34
|
+
*/
|
|
35
|
+
export function returnsJSX(path: NodePath | NodePath<Node | null | undefined> | null | undefined): boolean {
|
|
36
|
+
// Guard against null/undefined
|
|
37
|
+
if (!path || !path.node) return false;
|
|
38
|
+
|
|
39
|
+
let hasJSX = false;
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
path.traverse({
|
|
43
|
+
JSXElement() {
|
|
44
|
+
hasJSX = true;
|
|
45
|
+
},
|
|
46
|
+
JSXFragment() {
|
|
47
|
+
hasJSX = true;
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
} catch (error) {
|
|
51
|
+
// If traversal fails for any reason, safely assume no JSX
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return hasJSX;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Count the number of lines in a node
|
|
60
|
+
*
|
|
61
|
+
* @param node - AST node with location info
|
|
62
|
+
* @returns number of lines, or 0 if no location info
|
|
63
|
+
*/
|
|
64
|
+
export function getLineCount(node: any): number {
|
|
65
|
+
if (!node || !node.loc) return 0;
|
|
66
|
+
return node.loc.end.line - node.loc.start.line;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Generate a unique issue ID
|
|
71
|
+
*
|
|
72
|
+
* @param type - Type of issue (e.g., 'console', 'large-component')
|
|
73
|
+
* @param filePath - File path where issue was found
|
|
74
|
+
* @param line - Line number
|
|
75
|
+
* @returns Unique identifier string
|
|
76
|
+
*/
|
|
77
|
+
export function generateIssueId(type: string, filePath: string, line: number): string {
|
|
78
|
+
// Sanitize file path to create valid ID
|
|
79
|
+
const sanitizedPath = filePath.replace(/[^a-zA-Z0-9]/g, '_');
|
|
80
|
+
return `${type}-${sanitizedPath}-${line}`;
|
|
81
|
+
}
|