react-code-smell-detector 1.0.1 → 1.2.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 +69 -11
- package/dist/analyzer.d.ts.map +1 -1
- package/dist/analyzer.js +78 -2
- package/dist/cli.js +43 -9
- package/dist/detectors/accessibility.d.ts +12 -0
- package/dist/detectors/accessibility.d.ts.map +1 -0
- package/dist/detectors/accessibility.js +191 -0
- package/dist/detectors/debug.d.ts +10 -0
- package/dist/detectors/debug.d.ts.map +1 -0
- package/dist/detectors/debug.js +87 -0
- package/dist/detectors/index.d.ts +8 -0
- package/dist/detectors/index.d.ts.map +1 -1
- package/dist/detectors/index.js +10 -0
- package/dist/detectors/javascript.d.ts +11 -0
- package/dist/detectors/javascript.d.ts.map +1 -0
- package/dist/detectors/javascript.js +148 -0
- package/dist/detectors/nextjs.d.ts +11 -0
- package/dist/detectors/nextjs.d.ts.map +1 -0
- package/dist/detectors/nextjs.js +103 -0
- package/dist/detectors/nodejs.d.ts +11 -0
- package/dist/detectors/nodejs.d.ts.map +1 -0
- package/dist/detectors/nodejs.js +169 -0
- package/dist/detectors/reactNative.d.ts +10 -0
- package/dist/detectors/reactNative.d.ts.map +1 -0
- package/dist/detectors/reactNative.js +135 -0
- package/dist/detectors/security.d.ts +12 -0
- package/dist/detectors/security.d.ts.map +1 -0
- package/dist/detectors/security.js +161 -0
- package/dist/detectors/typescript.d.ts +11 -0
- package/dist/detectors/typescript.d.ts.map +1 -0
- package/dist/detectors/typescript.js +135 -0
- package/dist/htmlReporter.d.ts +6 -0
- package/dist/htmlReporter.d.ts.map +1 -0
- package/dist/htmlReporter.js +453 -0
- package/dist/reporter.js +37 -0
- package/dist/types/index.d.ts +10 -1
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.js +11 -0
- package/package.json +2 -2
- package/src/analyzer.ts +91 -1
- package/src/cli.ts +43 -9
- package/src/detectors/accessibility.ts +212 -0
- package/src/detectors/debug.ts +103 -0
- package/src/detectors/index.ts +10 -0
- package/src/detectors/javascript.ts +169 -0
- package/src/detectors/nextjs.ts +124 -0
- package/src/detectors/nodejs.ts +199 -0
- package/src/detectors/reactNative.ts +154 -0
- package/src/detectors/security.ts +179 -0
- package/src/detectors/typescript.ts +151 -0
- package/src/htmlReporter.ts +464 -0
- package/src/reporter.ts +37 -0
- package/src/types/index.ts +61 -2
|
@@ -0,0 +1,124 @@
|
|
|
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 Next.js-specific code smells:
|
|
7
|
+
* - Missing 'use client' / 'use server' directives
|
|
8
|
+
* - Unoptimized images (using <img> instead of next/image)
|
|
9
|
+
* - Router misuse patterns
|
|
10
|
+
* - Missing metadata exports
|
|
11
|
+
*/
|
|
12
|
+
export function detectNextjsIssues(
|
|
13
|
+
component: ParsedComponent,
|
|
14
|
+
filePath: string,
|
|
15
|
+
sourceCode: string,
|
|
16
|
+
config: DetectorConfig = DEFAULT_CONFIG,
|
|
17
|
+
imports: string[] = []
|
|
18
|
+
): CodeSmell[] {
|
|
19
|
+
if (!config.checkNextjs) return [];
|
|
20
|
+
|
|
21
|
+
// Only run on Next.js projects (check for next imports)
|
|
22
|
+
const isNextProject = imports.some(imp =>
|
|
23
|
+
imp.includes('next/') || imp.includes('next')
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
// Also check file path patterns
|
|
27
|
+
const isAppRouter = filePath.includes('/app/') &&
|
|
28
|
+
(filePath.endsWith('page.tsx') || filePath.endsWith('page.jsx') ||
|
|
29
|
+
filePath.endsWith('layout.tsx') || filePath.endsWith('layout.jsx'));
|
|
30
|
+
|
|
31
|
+
const smells: CodeSmell[] = [];
|
|
32
|
+
|
|
33
|
+
// Check for unoptimized images (using <img> instead of next/image)
|
|
34
|
+
component.path.traverse({
|
|
35
|
+
JSXOpeningElement(path) {
|
|
36
|
+
if (t.isJSXIdentifier(path.node.name) && path.node.name.name === 'img') {
|
|
37
|
+
const loc = path.node.loc;
|
|
38
|
+
smells.push({
|
|
39
|
+
type: 'nextjs-image-unoptimized',
|
|
40
|
+
severity: 'warning',
|
|
41
|
+
message: `Using native <img> instead of next/image in "${component.name}"`,
|
|
42
|
+
file: filePath,
|
|
43
|
+
line: loc?.start.line || 0,
|
|
44
|
+
column: loc?.start.column || 0,
|
|
45
|
+
suggestion: 'Use next/image for automatic image optimization: import Image from "next/image"',
|
|
46
|
+
codeSnippet: getCodeSnippet(sourceCode, loc?.start.line || 0),
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// Check for client-side hooks in server components (App Router)
|
|
53
|
+
if (isAppRouter && !sourceCode.includes("'use client'") && !sourceCode.includes('"use client"')) {
|
|
54
|
+
const clientHooks = ['useState', 'useEffect', 'useContext', 'useReducer', 'useRef'];
|
|
55
|
+
const usedClientHooks: string[] = [];
|
|
56
|
+
|
|
57
|
+
component.path.traverse({
|
|
58
|
+
CallExpression(path) {
|
|
59
|
+
if (t.isIdentifier(path.node.callee) && clientHooks.includes(path.node.callee.name)) {
|
|
60
|
+
usedClientHooks.push(path.node.callee.name);
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
if (usedClientHooks.length > 0) {
|
|
66
|
+
const loc = component.node.loc;
|
|
67
|
+
smells.push({
|
|
68
|
+
type: 'nextjs-client-server-boundary',
|
|
69
|
+
severity: 'error',
|
|
70
|
+
message: `Client hooks (${usedClientHooks.join(', ')}) used without 'use client' directive in "${component.name}"`,
|
|
71
|
+
file: filePath,
|
|
72
|
+
line: loc?.start.line || 1,
|
|
73
|
+
column: 0,
|
|
74
|
+
suggestion: "Add 'use client' at the top of the file, or move client logic to a separate component",
|
|
75
|
+
codeSnippet: getCodeSnippet(sourceCode, 1),
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Check for missing metadata in page/layout files
|
|
81
|
+
if (isAppRouter && filePath.includes('page.')) {
|
|
82
|
+
// This would require checking exports, which needs file-level analysis
|
|
83
|
+
const hasMetadata = sourceCode.includes('export const metadata') ||
|
|
84
|
+
sourceCode.includes('export function generateMetadata');
|
|
85
|
+
|
|
86
|
+
if (!hasMetadata && component.name === 'default') {
|
|
87
|
+
smells.push({
|
|
88
|
+
type: 'nextjs-missing-metadata',
|
|
89
|
+
severity: 'info',
|
|
90
|
+
message: 'Page component missing metadata export',
|
|
91
|
+
file: filePath,
|
|
92
|
+
line: 1,
|
|
93
|
+
column: 0,
|
|
94
|
+
suggestion: 'Add metadata for SEO: export const metadata = { title: "...", description: "..." }',
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Check for router misuse (using window.location instead of next/router)
|
|
100
|
+
component.path.traverse({
|
|
101
|
+
MemberExpression(path) {
|
|
102
|
+
if (
|
|
103
|
+
t.isIdentifier(path.node.object) &&
|
|
104
|
+
path.node.object.name === 'window' &&
|
|
105
|
+
t.isIdentifier(path.node.property) &&
|
|
106
|
+
path.node.property.name === 'location'
|
|
107
|
+
) {
|
|
108
|
+
const loc = path.node.loc;
|
|
109
|
+
smells.push({
|
|
110
|
+
type: 'nextjs-router-misuse',
|
|
111
|
+
severity: 'warning',
|
|
112
|
+
message: `Using window.location instead of Next.js router in "${component.name}"`,
|
|
113
|
+
file: filePath,
|
|
114
|
+
line: loc?.start.line || 0,
|
|
115
|
+
column: loc?.start.column || 0,
|
|
116
|
+
suggestion: 'Use next/navigation: import { useRouter } from "next/navigation"',
|
|
117
|
+
codeSnippet: getCodeSnippet(sourceCode, loc?.start.line || 0),
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
return smells;
|
|
124
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import * as t from '@babel/types';
|
|
2
|
+
import { NodePath } from '@babel/traverse';
|
|
3
|
+
import { ParsedComponent, getCodeSnippet } from '../parser/index.js';
|
|
4
|
+
import { CodeSmell, DetectorConfig, DEFAULT_CONFIG } from '../types/index.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Detects Node.js-specific code smells:
|
|
8
|
+
* - Callback hell (deeply nested callbacks)
|
|
9
|
+
* - Unhandled promise rejections
|
|
10
|
+
* - Synchronous I/O operations
|
|
11
|
+
* - Missing error handling
|
|
12
|
+
*/
|
|
13
|
+
export function detectNodejsIssues(
|
|
14
|
+
component: ParsedComponent,
|
|
15
|
+
filePath: string,
|
|
16
|
+
sourceCode: string,
|
|
17
|
+
config: DetectorConfig = DEFAULT_CONFIG,
|
|
18
|
+
imports: string[] = []
|
|
19
|
+
): CodeSmell[] {
|
|
20
|
+
if (!config.checkNodejs) return [];
|
|
21
|
+
|
|
22
|
+
// Check if this looks like a Node.js file
|
|
23
|
+
const isNodeFile = imports.some(imp =>
|
|
24
|
+
imp.includes('fs') || imp.includes('path') || imp.includes('http') ||
|
|
25
|
+
imp.includes('express') || imp.includes('child_process') ||
|
|
26
|
+
imp.includes('crypto') || imp.includes('os') || imp.includes('stream')
|
|
27
|
+
) || filePath.includes('.server.') || filePath.includes('/api/');
|
|
28
|
+
|
|
29
|
+
if (!isNodeFile) return [];
|
|
30
|
+
|
|
31
|
+
const smells: CodeSmell[] = [];
|
|
32
|
+
|
|
33
|
+
// Detect callback hell (nested callbacks > maxCallbackDepth)
|
|
34
|
+
component.path.traverse({
|
|
35
|
+
CallExpression(path) {
|
|
36
|
+
const depth = getCallbackDepth(path);
|
|
37
|
+
|
|
38
|
+
if (depth > config.maxCallbackDepth) {
|
|
39
|
+
const loc = path.node.loc;
|
|
40
|
+
smells.push({
|
|
41
|
+
type: 'nodejs-callback-hell',
|
|
42
|
+
severity: 'warning',
|
|
43
|
+
message: `Callback hell detected (depth: ${depth}) in "${component.name}"`,
|
|
44
|
+
file: filePath,
|
|
45
|
+
line: loc?.start.line || 0,
|
|
46
|
+
column: loc?.start.column || 0,
|
|
47
|
+
suggestion: 'Refactor to async/await or use Promise.all() for parallel operations',
|
|
48
|
+
codeSnippet: getCodeSnippet(sourceCode, loc?.start.line || 0),
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// Detect unhandled promise rejections (Promise without .catch or try/catch)
|
|
55
|
+
component.path.traverse({
|
|
56
|
+
CallExpression(path) {
|
|
57
|
+
// Check for .then() without .catch()
|
|
58
|
+
if (t.isMemberExpression(path.node.callee) &&
|
|
59
|
+
t.isIdentifier(path.node.callee.property) &&
|
|
60
|
+
path.node.callee.property.name === 'then') {
|
|
61
|
+
|
|
62
|
+
// Check if followed by .catch() in chain
|
|
63
|
+
const parent = path.parent;
|
|
64
|
+
let hasCatch = false;
|
|
65
|
+
|
|
66
|
+
if (t.isMemberExpression(parent)) {
|
|
67
|
+
const prop = (parent as t.MemberExpression).property;
|
|
68
|
+
if (t.isIdentifier(prop) && prop.name === 'catch') {
|
|
69
|
+
hasCatch = true;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Check if inside try block
|
|
74
|
+
let current: NodePath | null = path;
|
|
75
|
+
while (current) {
|
|
76
|
+
if (t.isTryStatement(current.node)) {
|
|
77
|
+
hasCatch = true;
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
current = current.parentPath;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (!hasCatch) {
|
|
84
|
+
const loc = path.node.loc;
|
|
85
|
+
smells.push({
|
|
86
|
+
type: 'nodejs-unhandled-promise',
|
|
87
|
+
severity: 'warning',
|
|
88
|
+
message: `.then() without .catch() in "${component.name}"`,
|
|
89
|
+
file: filePath,
|
|
90
|
+
line: loc?.start.line || 0,
|
|
91
|
+
column: loc?.start.column || 0,
|
|
92
|
+
suggestion: 'Add .catch() to handle rejections, or use try/catch with async/await',
|
|
93
|
+
codeSnippet: getCodeSnippet(sourceCode, loc?.start.line || 0),
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// Detect synchronous file operations
|
|
101
|
+
const syncMethods = ['readFileSync', 'writeFileSync', 'appendFileSync', 'readdirSync',
|
|
102
|
+
'statSync', 'mkdirSync', 'rmdirSync', 'unlinkSync', 'existsSync'];
|
|
103
|
+
|
|
104
|
+
component.path.traverse({
|
|
105
|
+
CallExpression(path) {
|
|
106
|
+
const callee = path.node.callee;
|
|
107
|
+
|
|
108
|
+
if (t.isMemberExpression(callee) && t.isIdentifier(callee.property)) {
|
|
109
|
+
if (syncMethods.includes(callee.property.name)) {
|
|
110
|
+
const loc = path.node.loc;
|
|
111
|
+
smells.push({
|
|
112
|
+
type: 'nodejs-sync-io',
|
|
113
|
+
severity: 'warning',
|
|
114
|
+
message: `Synchronous file operation "${callee.property.name}" blocks event loop`,
|
|
115
|
+
file: filePath,
|
|
116
|
+
line: loc?.start.line || 0,
|
|
117
|
+
column: loc?.start.column || 0,
|
|
118
|
+
suggestion: `Use async version: ${callee.property.name.replace('Sync', '')} with await or promises`,
|
|
119
|
+
codeSnippet: getCodeSnippet(sourceCode, loc?.start.line || 0),
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Direct function call
|
|
125
|
+
if (t.isIdentifier(callee) && syncMethods.includes(callee.name)) {
|
|
126
|
+
const loc = path.node.loc;
|
|
127
|
+
smells.push({
|
|
128
|
+
type: 'nodejs-sync-io',
|
|
129
|
+
severity: 'warning',
|
|
130
|
+
message: `Synchronous file operation "${callee.name}" blocks event loop`,
|
|
131
|
+
file: filePath,
|
|
132
|
+
line: loc?.start.line || 0,
|
|
133
|
+
column: loc?.start.column || 0,
|
|
134
|
+
suggestion: `Use async version: ${callee.name.replace('Sync', '')} with await or promises`,
|
|
135
|
+
codeSnippet: getCodeSnippet(sourceCode, loc?.start.line || 0),
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// Detect missing error handling in async functions
|
|
142
|
+
component.path.traverse({
|
|
143
|
+
AwaitExpression(path) {
|
|
144
|
+
// Check if inside try block
|
|
145
|
+
let insideTry = false;
|
|
146
|
+
let current: NodePath | null = path;
|
|
147
|
+
|
|
148
|
+
while (current) {
|
|
149
|
+
if (t.isTryStatement(current.node)) {
|
|
150
|
+
insideTry = true;
|
|
151
|
+
break;
|
|
152
|
+
}
|
|
153
|
+
// Stop at function boundary
|
|
154
|
+
if (t.isFunction(current.node)) break;
|
|
155
|
+
current = current.parentPath;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (!insideTry) {
|
|
159
|
+
// Check if the parent function has error handling at call site
|
|
160
|
+
// This is a simplified check - in practice you'd want more context
|
|
161
|
+
const loc = path.node.loc;
|
|
162
|
+
smells.push({
|
|
163
|
+
type: 'nodejs-missing-error-handling',
|
|
164
|
+
severity: 'info',
|
|
165
|
+
message: `await without try/catch may cause unhandled rejections`,
|
|
166
|
+
file: filePath,
|
|
167
|
+
line: loc?.start.line || 0,
|
|
168
|
+
column: loc?.start.column || 0,
|
|
169
|
+
suggestion: 'Wrap await in try/catch or handle errors at the call site',
|
|
170
|
+
codeSnippet: getCodeSnippet(sourceCode, loc?.start.line || 0),
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
return smells;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Calculate the depth of nested callbacks
|
|
181
|
+
*/
|
|
182
|
+
function getCallbackDepth(path: NodePath): number {
|
|
183
|
+
let depth = 0;
|
|
184
|
+
let current: NodePath | null = path;
|
|
185
|
+
|
|
186
|
+
while (current) {
|
|
187
|
+
const node = current.node;
|
|
188
|
+
|
|
189
|
+
// Count function expressions that are arguments to calls
|
|
190
|
+
if ((t.isFunctionExpression(node) || t.isArrowFunctionExpression(node)) &&
|
|
191
|
+
t.isCallExpression(current.parent)) {
|
|
192
|
+
depth++;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
current = current.parentPath;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return depth;
|
|
199
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import * as t from '@babel/types';
|
|
2
|
+
import { NodePath } from '@babel/traverse';
|
|
3
|
+
import { ParsedComponent, getCodeSnippet } from '../parser/index.js';
|
|
4
|
+
import { CodeSmell, DetectorConfig, DEFAULT_CONFIG } from '../types/index.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Detects React Native-specific code smells:
|
|
8
|
+
* - Inline styles instead of StyleSheet
|
|
9
|
+
* - Missing accessibility props
|
|
10
|
+
* - Performance anti-patterns
|
|
11
|
+
*/
|
|
12
|
+
export function detectReactNativeIssues(
|
|
13
|
+
component: ParsedComponent,
|
|
14
|
+
filePath: string,
|
|
15
|
+
sourceCode: string,
|
|
16
|
+
config: DetectorConfig = DEFAULT_CONFIG,
|
|
17
|
+
imports: string[] = []
|
|
18
|
+
): CodeSmell[] {
|
|
19
|
+
if (!config.checkReactNative) return [];
|
|
20
|
+
|
|
21
|
+
// Only run on React Native projects
|
|
22
|
+
const isRNProject = imports.some(imp =>
|
|
23
|
+
imp.includes('react-native') || imp.includes('@react-native')
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
if (!isRNProject) return [];
|
|
27
|
+
|
|
28
|
+
const smells: CodeSmell[] = [];
|
|
29
|
+
const inlineStyleLines = new Set<number>();
|
|
30
|
+
|
|
31
|
+
// Check for inline styles instead of StyleSheet
|
|
32
|
+
component.path.traverse({
|
|
33
|
+
JSXAttribute(path) {
|
|
34
|
+
if (!t.isJSXIdentifier(path.node.name)) return;
|
|
35
|
+
if (path.node.name.name !== 'style') return;
|
|
36
|
+
|
|
37
|
+
const value = path.node.value;
|
|
38
|
+
if (!t.isJSXExpressionContainer(value)) return;
|
|
39
|
+
|
|
40
|
+
// Check if it's an inline object (not a StyleSheet reference)
|
|
41
|
+
if (t.isObjectExpression(value.expression)) {
|
|
42
|
+
const loc = path.node.loc;
|
|
43
|
+
const line = loc?.start.line || 0;
|
|
44
|
+
|
|
45
|
+
if (!inlineStyleLines.has(line)) {
|
|
46
|
+
inlineStyleLines.add(line);
|
|
47
|
+
smells.push({
|
|
48
|
+
type: 'rn-inline-style',
|
|
49
|
+
severity: 'warning',
|
|
50
|
+
message: `Inline style object in "${component.name}" - prefer StyleSheet.create()`,
|
|
51
|
+
file: filePath,
|
|
52
|
+
line,
|
|
53
|
+
column: loc?.start.column || 0,
|
|
54
|
+
suggestion: 'Use StyleSheet.create() for better performance: const styles = StyleSheet.create({ ... })',
|
|
55
|
+
codeSnippet: getCodeSnippet(sourceCode, line),
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// Check for missing accessibility props on interactive elements
|
|
63
|
+
const interactiveComponents = ['TouchableOpacity', 'TouchableHighlight', 'Pressable', 'Button', 'TouchableWithoutFeedback'];
|
|
64
|
+
|
|
65
|
+
component.path.traverse({
|
|
66
|
+
JSXOpeningElement(path) {
|
|
67
|
+
if (!t.isJSXIdentifier(path.node.name)) return;
|
|
68
|
+
|
|
69
|
+
const componentName = path.node.name.name;
|
|
70
|
+
if (!interactiveComponents.includes(componentName)) return;
|
|
71
|
+
|
|
72
|
+
// Check for accessibility props
|
|
73
|
+
const hasAccessibilityLabel = path.node.attributes.some(attr => {
|
|
74
|
+
if (t.isJSXAttribute(attr) && t.isJSXIdentifier(attr.name)) {
|
|
75
|
+
return ['accessibilityLabel', 'accessible', 'accessibilityRole', 'accessibilityHint'].includes(attr.name.name);
|
|
76
|
+
}
|
|
77
|
+
return false;
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
if (!hasAccessibilityLabel) {
|
|
81
|
+
const loc = path.node.loc;
|
|
82
|
+
smells.push({
|
|
83
|
+
type: 'rn-missing-accessibility',
|
|
84
|
+
severity: 'info',
|
|
85
|
+
message: `${componentName} missing accessibility props in "${component.name}"`,
|
|
86
|
+
file: filePath,
|
|
87
|
+
line: loc?.start.line || 0,
|
|
88
|
+
column: loc?.start.column || 0,
|
|
89
|
+
suggestion: 'Add accessibilityLabel and accessibilityRole for screen readers',
|
|
90
|
+
codeSnippet: getCodeSnippet(sourceCode, loc?.start.line || 0),
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// Check for performance anti-patterns
|
|
97
|
+
component.path.traverse({
|
|
98
|
+
// Detect creating anonymous functions in render (for onPress, etc.)
|
|
99
|
+
JSXAttribute(path) {
|
|
100
|
+
if (!t.isJSXIdentifier(path.node.name)) return;
|
|
101
|
+
|
|
102
|
+
const propName = path.node.name.name;
|
|
103
|
+
if (!['onPress', 'onPressIn', 'onPressOut', 'onLongPress'].includes(propName)) return;
|
|
104
|
+
|
|
105
|
+
const value = path.node.value;
|
|
106
|
+
if (!t.isJSXExpressionContainer(value)) return;
|
|
107
|
+
|
|
108
|
+
// Check for arrow functions or function expressions
|
|
109
|
+
if (t.isArrowFunctionExpression(value.expression) || t.isFunctionExpression(value.expression)) {
|
|
110
|
+
const loc = path.node.loc;
|
|
111
|
+
smells.push({
|
|
112
|
+
type: 'rn-performance-issue',
|
|
113
|
+
severity: 'info',
|
|
114
|
+
message: `Inline function for ${propName} in "${component.name}" creates new reference each render`,
|
|
115
|
+
file: filePath,
|
|
116
|
+
line: loc?.start.line || 0,
|
|
117
|
+
column: loc?.start.column || 0,
|
|
118
|
+
suggestion: 'Extract to useCallback or class method to prevent unnecessary re-renders',
|
|
119
|
+
codeSnippet: getCodeSnippet(sourceCode, loc?.start.line || 0),
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
|
|
124
|
+
// Detect array spreads in render that could be memoized
|
|
125
|
+
SpreadElement(path) {
|
|
126
|
+
const loc = path.node.loc;
|
|
127
|
+
// Only flag if inside JSX or return statement
|
|
128
|
+
let inJSX = false;
|
|
129
|
+
let current: NodePath | null = path.parentPath;
|
|
130
|
+
while (current) {
|
|
131
|
+
if (t.isJSXElement(current.node) || t.isJSXFragment(current.node)) {
|
|
132
|
+
inJSX = true;
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
current = current.parentPath;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (inJSX && t.isArrayExpression(path.parent)) {
|
|
139
|
+
smells.push({
|
|
140
|
+
type: 'rn-performance-issue',
|
|
141
|
+
severity: 'info',
|
|
142
|
+
message: `Array spread in render may cause performance issues in "${component.name}"`,
|
|
143
|
+
file: filePath,
|
|
144
|
+
line: loc?.start.line || 0,
|
|
145
|
+
column: loc?.start.column || 0,
|
|
146
|
+
suggestion: 'Consider memoizing with useMemo if this array is passed as a prop',
|
|
147
|
+
codeSnippet: getCodeSnippet(sourceCode, loc?.start.line || 0),
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
return smells;
|
|
154
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
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 security vulnerabilities:
|
|
7
|
+
* - dangerouslySetInnerHTML usage
|
|
8
|
+
* - eval() and Function() constructor
|
|
9
|
+
* - innerHTML assignments
|
|
10
|
+
* - Unsafe URLs (javascript:, data:)
|
|
11
|
+
* - Exposed secrets/API keys
|
|
12
|
+
*/
|
|
13
|
+
export function detectSecurityIssues(
|
|
14
|
+
component: ParsedComponent,
|
|
15
|
+
filePath: string,
|
|
16
|
+
sourceCode: string,
|
|
17
|
+
config: DetectorConfig = DEFAULT_CONFIG
|
|
18
|
+
): CodeSmell[] {
|
|
19
|
+
if (!config.checkSecurity) return [];
|
|
20
|
+
|
|
21
|
+
const smells: CodeSmell[] = [];
|
|
22
|
+
|
|
23
|
+
// Detect dangerouslySetInnerHTML
|
|
24
|
+
component.path.traverse({
|
|
25
|
+
JSXAttribute(path) {
|
|
26
|
+
if (t.isJSXIdentifier(path.node.name) &&
|
|
27
|
+
path.node.name.name === 'dangerouslySetInnerHTML') {
|
|
28
|
+
const loc = path.node.loc;
|
|
29
|
+
smells.push({
|
|
30
|
+
type: 'security-xss',
|
|
31
|
+
severity: 'error',
|
|
32
|
+
message: `dangerouslySetInnerHTML is a security risk in "${component.name}"`,
|
|
33
|
+
file: filePath,
|
|
34
|
+
line: loc?.start.line || 0,
|
|
35
|
+
column: loc?.start.column || 0,
|
|
36
|
+
suggestion: 'Sanitize HTML with DOMPurify or use a safe alternative like converting to React elements',
|
|
37
|
+
codeSnippet: getCodeSnippet(sourceCode, loc?.start.line || 0),
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// Detect eval() and Function() constructor
|
|
44
|
+
component.path.traverse({
|
|
45
|
+
CallExpression(path) {
|
|
46
|
+
const { callee } = path.node;
|
|
47
|
+
|
|
48
|
+
// eval()
|
|
49
|
+
if (t.isIdentifier(callee) && callee.name === 'eval') {
|
|
50
|
+
const loc = path.node.loc;
|
|
51
|
+
smells.push({
|
|
52
|
+
type: 'security-eval',
|
|
53
|
+
severity: 'error',
|
|
54
|
+
message: `eval() is a critical security risk in "${component.name}"`,
|
|
55
|
+
file: filePath,
|
|
56
|
+
line: loc?.start.line || 0,
|
|
57
|
+
column: loc?.start.column || 0,
|
|
58
|
+
suggestion: 'Never use eval(). Parse JSON with JSON.parse() or restructure logic to avoid dynamic code execution.',
|
|
59
|
+
codeSnippet: getCodeSnippet(sourceCode, loc?.start.line || 0),
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
// new Function()
|
|
65
|
+
NewExpression(path) {
|
|
66
|
+
const { callee } = path.node;
|
|
67
|
+
if (t.isIdentifier(callee) && callee.name === 'Function') {
|
|
68
|
+
const loc = path.node.loc;
|
|
69
|
+
smells.push({
|
|
70
|
+
type: 'security-eval',
|
|
71
|
+
severity: 'error',
|
|
72
|
+
message: `new Function() is equivalent to eval() and is a security risk`,
|
|
73
|
+
file: filePath,
|
|
74
|
+
line: loc?.start.line || 0,
|
|
75
|
+
column: loc?.start.column || 0,
|
|
76
|
+
suggestion: 'Avoid creating functions from strings. Restructure to use static function definitions.',
|
|
77
|
+
codeSnippet: getCodeSnippet(sourceCode, loc?.start.line || 0),
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// Detect innerHTML assignments
|
|
84
|
+
component.path.traverse({
|
|
85
|
+
AssignmentExpression(path) {
|
|
86
|
+
const { left } = path.node;
|
|
87
|
+
|
|
88
|
+
if (t.isMemberExpression(left) && t.isIdentifier(left.property)) {
|
|
89
|
+
if (left.property.name === 'innerHTML' || left.property.name === 'outerHTML') {
|
|
90
|
+
const loc = path.node.loc;
|
|
91
|
+
smells.push({
|
|
92
|
+
type: 'security-xss',
|
|
93
|
+
severity: 'warning',
|
|
94
|
+
message: `Direct ${left.property.name} assignment can lead to XSS`,
|
|
95
|
+
file: filePath,
|
|
96
|
+
line: loc?.start.line || 0,
|
|
97
|
+
column: loc?.start.column || 0,
|
|
98
|
+
suggestion: 'Use textContent for plain text, or sanitize HTML with DOMPurify',
|
|
99
|
+
codeSnippet: getCodeSnippet(sourceCode, loc?.start.line || 0),
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// Detect unsafe URLs (javascript:, data:)
|
|
107
|
+
component.path.traverse({
|
|
108
|
+
JSXAttribute(path) {
|
|
109
|
+
if (!t.isJSXIdentifier(path.node.name)) return;
|
|
110
|
+
|
|
111
|
+
const propName = path.node.name.name;
|
|
112
|
+
if (!['href', 'src', 'action'].includes(propName)) return;
|
|
113
|
+
|
|
114
|
+
const value = path.node.value;
|
|
115
|
+
if (t.isStringLiteral(value)) {
|
|
116
|
+
const url = value.value.toLowerCase().trim();
|
|
117
|
+
|
|
118
|
+
if (url.startsWith('javascript:')) {
|
|
119
|
+
const loc = path.node.loc;
|
|
120
|
+
smells.push({
|
|
121
|
+
type: 'security-xss',
|
|
122
|
+
severity: 'error',
|
|
123
|
+
message: `javascript: URLs are a security risk`,
|
|
124
|
+
file: filePath,
|
|
125
|
+
line: loc?.start.line || 0,
|
|
126
|
+
column: loc?.start.column || 0,
|
|
127
|
+
suggestion: 'Use onClick handlers instead of javascript: URLs',
|
|
128
|
+
codeSnippet: getCodeSnippet(sourceCode, loc?.start.line || 0),
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (url.startsWith('data:') && propName === 'href') {
|
|
133
|
+
const loc = path.node.loc;
|
|
134
|
+
smells.push({
|
|
135
|
+
type: 'security-xss',
|
|
136
|
+
severity: 'warning',
|
|
137
|
+
message: `data: URLs in href can be a security risk`,
|
|
138
|
+
file: filePath,
|
|
139
|
+
line: loc?.start.line || 0,
|
|
140
|
+
column: loc?.start.column || 0,
|
|
141
|
+
suggestion: 'Validate and sanitize data URLs, or use blob URLs instead',
|
|
142
|
+
codeSnippet: getCodeSnippet(sourceCode, loc?.start.line || 0),
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// Detect potential exposed secrets
|
|
150
|
+
const secretPatterns = [
|
|
151
|
+
{ pattern: /['"](?:sk[-_]live|pk[-_]live|api[-_]?key|secret[-_]?key|access[-_]?token|auth[-_]?token)['"]\s*[:=]\s*['"][a-zA-Z0-9-_]{20,}/i, name: 'API key' },
|
|
152
|
+
{ pattern: /['"](?:ghp|gho|ghu|ghs|ghr)_[a-zA-Z0-9]{36,}['"]/i, name: 'GitHub token' },
|
|
153
|
+
{ pattern: /['"]AKIA[A-Z0-9]{16}['"]/i, name: 'AWS access key' },
|
|
154
|
+
{ pattern: /password\s*[:=]\s*['"][^'"]{8,}['"]/i, name: 'Hardcoded password' },
|
|
155
|
+
];
|
|
156
|
+
|
|
157
|
+
const lines = sourceCode.split('\n');
|
|
158
|
+
lines.forEach((line, index) => {
|
|
159
|
+
const lineNum = index + 1;
|
|
160
|
+
if (lineNum < component.startLine || lineNum > component.endLine) return;
|
|
161
|
+
|
|
162
|
+
secretPatterns.forEach(({ pattern, name }) => {
|
|
163
|
+
if (pattern.test(line)) {
|
|
164
|
+
smells.push({
|
|
165
|
+
type: 'security-secrets',
|
|
166
|
+
severity: 'error',
|
|
167
|
+
message: `Potential ${name} exposed in code`,
|
|
168
|
+
file: filePath,
|
|
169
|
+
line: lineNum,
|
|
170
|
+
column: 0,
|
|
171
|
+
suggestion: 'Move secrets to environment variables (.env) and never commit them to version control',
|
|
172
|
+
codeSnippet: getCodeSnippet(sourceCode, lineNum),
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
return smells;
|
|
179
|
+
}
|