react-code-smell-detector 1.1.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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "react-code-smell-detector",
3
- "version": "1.1.1",
4
- "description": "Detect code smells in React projects - useEffect overuse, prop drilling, large components, and more",
3
+ "version": "1.2.0",
4
+ "description": "Detect code smells in React projects - useEffect overuse, prop drilling, large components, security issues, accessibility, and more",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
7
7
  "react-smell": "dist/cli.js"
package/src/analyzer.ts CHANGED
@@ -18,6 +18,9 @@ import {
18
18
  detectNodejsIssues,
19
19
  detectJavascriptIssues,
20
20
  detectTypescriptIssues,
21
+ detectDebugStatements,
22
+ detectSecurityIssues,
23
+ detectAccessibilityIssues,
21
24
  } from './detectors/index.js';
22
25
  import {
23
26
  AnalysisResult,
@@ -123,19 +126,58 @@ function analyzeFile(parseResult: ParseResult, filePath: string, config: Detecto
123
126
  smells.push(...detectNodejsIssues(component, filePath, sourceCode, config, imports));
124
127
  smells.push(...detectJavascriptIssues(component, filePath, sourceCode, config));
125
128
  smells.push(...detectTypescriptIssues(component, filePath, sourceCode, config));
129
+ // Debug, Security, Accessibility
130
+ smells.push(...detectDebugStatements(component, filePath, sourceCode, config));
131
+ smells.push(...detectSecurityIssues(component, filePath, sourceCode, config));
132
+ smells.push(...detectAccessibilityIssues(component, filePath, sourceCode, config));
126
133
  });
127
134
 
128
135
  // Run cross-component analysis
129
136
  smells.push(...analyzePropDrillingDepth(components, filePath, sourceCode, config));
130
137
 
138
+ // Filter out smells with @smell-ignore comments
139
+ const filteredSmells = filterIgnoredSmells(smells, sourceCode);
140
+
131
141
  return {
132
142
  file: filePath,
133
143
  components: componentInfos,
134
- smells,
144
+ smells: filteredSmells,
135
145
  imports,
136
146
  };
137
147
  }
138
148
 
149
+ /**
150
+ * Filter out smells that have @smell-ignore comment on the preceding line
151
+ * Supports: // @smell-ignore, block comments with @smell-ignore, @smell-ignore [type]
152
+ */
153
+ function filterIgnoredSmells(smells: CodeSmell[], sourceCode: string): CodeSmell[] {
154
+ const lines = sourceCode.split('\n');
155
+
156
+ return smells.filter(smell => {
157
+ if (smell.line <= 1) return true;
158
+
159
+ // Check the line before and the same line for ignore comments
160
+ const lineIndex = smell.line - 1; // Convert to 0-indexed
161
+ const prevLine = lines[lineIndex - 1]?.trim() || '';
162
+ const currentLine = lines[lineIndex]?.trim() || '';
163
+
164
+ // Check for @smell-ignore patterns
165
+ const ignorePatterns = [
166
+ /@smell-ignore\s*$/, // @smell-ignore (ignore all)
167
+ /@smell-ignore\s+\*/, // @smell-ignore * (ignore all)
168
+ new RegExp(`@smell-ignore\\s+${smell.type}`), // @smell-ignore [specific-type]
169
+ ];
170
+
171
+ for (const pattern of ignorePatterns) {
172
+ if (pattern.test(prevLine) || pattern.test(currentLine)) {
173
+ return false; // Filter out this smell
174
+ }
175
+ }
176
+
177
+ return true; // Keep this smell
178
+ });
179
+ }
180
+
139
181
  function calculateSummary(files: FileAnalysis[]): AnalysisSummary {
140
182
  const smellsByType: Record<SmellType, number> = {
141
183
  'useEffect-overuse': 0,
@@ -176,6 +218,19 @@ function calculateSummary(files: FileAnalysis[]): AnalysisSummary {
176
218
  'ts-missing-return-type': 0,
177
219
  'ts-non-null-assertion': 0,
178
220
  'ts-type-assertion': 0,
221
+ // Debug statements
222
+ 'debug-statement': 0,
223
+ 'todo-comment': 0,
224
+ // Security
225
+ 'security-xss': 0,
226
+ 'security-eval': 0,
227
+ 'security-secrets': 0,
228
+ // Accessibility
229
+ 'a11y-missing-alt': 0,
230
+ 'a11y-missing-label': 0,
231
+ 'a11y-interactive-role': 0,
232
+ 'a11y-keyboard': 0,
233
+ 'a11y-semantic': 0,
179
234
  };
180
235
 
181
236
  const smellsBySeverity: Record<SmellSeverity, number> = {
package/src/cli.ts CHANGED
@@ -6,6 +6,7 @@ import ora from 'ora';
6
6
  import path from 'path';
7
7
  import { analyzeProject, DEFAULT_CONFIG } from './analyzer.js';
8
8
  import { reportResults } from './reporter.js';
9
+ import { generateHTMLReport } from './htmlReporter.js';
9
10
  import fs from 'fs/promises';
10
11
 
11
12
  const program = new Command();
@@ -13,11 +14,13 @@ const program = new Command();
13
14
  program
14
15
  .name('react-smell')
15
16
  .description('Detect code smells in React projects')
16
- .version('1.0.0')
17
+ .version('1.2.0')
17
18
  .argument('[directory]', 'Directory to analyze', '.')
18
- .option('-f, --format <format>', 'Output format: console, json, markdown', 'console')
19
+ .option('-f, --format <format>', 'Output format: console, json, markdown, html', 'console')
19
20
  .option('-s, --snippets', 'Show code snippets in output', false)
20
21
  .option('-c, --config <file>', 'Path to config file')
22
+ .option('--ci', 'CI mode: exit with code 1 if any issues found')
23
+ .option('--fail-on <severity>', 'Exit with code 1 if issues of this severity or higher (error, warning, info)', 'error')
21
24
  .option('--max-effects <number>', 'Max useEffects per component', parseInt)
22
25
  .option('--max-props <number>', 'Max props before warning', parseInt)
23
26
  .option('--max-lines <number>', 'Max lines per component', parseInt)
@@ -72,11 +75,20 @@ program
72
75
 
73
76
  spinner.stop();
74
77
 
75
- const output = reportResults(result, {
76
- format: options.format,
77
- showCodeSnippets: options.snippets,
78
- rootDir,
79
- });
78
+ let output: string;
79
+ if (options.format === 'html') {
80
+ output = generateHTMLReport(result, rootDir);
81
+ // Auto-set output file for HTML if not specified
82
+ if (!options.output) {
83
+ options.output = 'code-smell-report.html';
84
+ }
85
+ } else {
86
+ output = reportResults(result, {
87
+ format: options.format,
88
+ showCodeSnippets: options.snippets,
89
+ rootDir,
90
+ });
91
+ }
80
92
 
81
93
  if (options.output) {
82
94
  const outputPath = path.resolve(process.cwd(), options.output);
@@ -86,8 +98,30 @@ program
86
98
  console.log(output);
87
99
  }
88
100
 
89
- // Exit with error code if there are errors
90
- if (result.summary.smellsBySeverity.error > 0) {
101
+ // CI/CD exit code handling
102
+ const { smellsBySeverity } = result.summary;
103
+ let shouldFail = false;
104
+
105
+ if (options.ci) {
106
+ // CI mode: fail on any issues
107
+ shouldFail = result.summary.totalSmells > 0;
108
+ } else {
109
+ // Check fail-on threshold
110
+ switch (options.failOn) {
111
+ case 'info':
112
+ shouldFail = smellsBySeverity.info > 0 || smellsBySeverity.warning > 0 || smellsBySeverity.error > 0;
113
+ break;
114
+ case 'warning':
115
+ shouldFail = smellsBySeverity.warning > 0 || smellsBySeverity.error > 0;
116
+ break;
117
+ case 'error':
118
+ default:
119
+ shouldFail = smellsBySeverity.error > 0;
120
+ break;
121
+ }
122
+ }
123
+
124
+ if (shouldFail) {
91
125
  process.exit(1);
92
126
  }
93
127
  } catch (error) {
@@ -0,0 +1,212 @@
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 accessibility (a11y) issues:
7
+ * - Images without alt text
8
+ * - Form inputs without labels
9
+ * - Missing ARIA attributes on interactive elements
10
+ * - Click handlers without keyboard support
11
+ * - Improper heading hierarchy
12
+ */
13
+ export function detectAccessibilityIssues(
14
+ component: ParsedComponent,
15
+ filePath: string,
16
+ sourceCode: string,
17
+ config: DetectorConfig = DEFAULT_CONFIG
18
+ ): CodeSmell[] {
19
+ if (!config.checkAccessibility) return [];
20
+
21
+ const smells: CodeSmell[] = [];
22
+
23
+ component.path.traverse({
24
+ JSXOpeningElement(path) {
25
+ if (!t.isJSXIdentifier(path.node.name)) return;
26
+
27
+ const elementName = path.node.name.name;
28
+ const attributes = path.node.attributes;
29
+ const loc = path.node.loc;
30
+ const line = loc?.start.line || 0;
31
+
32
+ // Helper to check if attribute exists
33
+ const hasAttr = (name: string): boolean => {
34
+ return attributes.some(attr => {
35
+ if (t.isJSXAttribute(attr) && t.isJSXIdentifier(attr.name)) {
36
+ return attr.name.name === name;
37
+ }
38
+ // Handle spread attributes - assume they might contain the attribute
39
+ return t.isJSXSpreadAttribute(attr);
40
+ });
41
+ };
42
+
43
+ // Helper to get attribute value
44
+ const getAttrValue = (name: string): string | null => {
45
+ for (const attr of attributes) {
46
+ if (t.isJSXAttribute(attr) && t.isJSXIdentifier(attr.name) && attr.name.name === name) {
47
+ if (t.isStringLiteral(attr.value)) {
48
+ return attr.value.value;
49
+ }
50
+ }
51
+ }
52
+ return null;
53
+ };
54
+
55
+ // Check for images without alt text
56
+ if (elementName === 'img') {
57
+ if (!hasAttr('alt')) {
58
+ smells.push({
59
+ type: 'a11y-missing-alt',
60
+ severity: 'error',
61
+ message: `<img> missing alt attribute in "${component.name}"`,
62
+ file: filePath,
63
+ line,
64
+ column: loc?.start.column || 0,
65
+ suggestion: 'Add alt="description" for content images, or alt="" for decorative images',
66
+ codeSnippet: getCodeSnippet(sourceCode, line),
67
+ });
68
+ } else {
69
+ const altValue = getAttrValue('alt');
70
+ if (altValue !== null && altValue.toLowerCase().includes('image')) {
71
+ smells.push({
72
+ type: 'a11y-missing-alt',
73
+ severity: 'info',
74
+ message: `<img> alt text shouldn't contain "image" - it's redundant`,
75
+ file: filePath,
76
+ line,
77
+ column: loc?.start.column || 0,
78
+ suggestion: 'Describe what the image shows, not that it is an image',
79
+ codeSnippet: getCodeSnippet(sourceCode, line),
80
+ });
81
+ }
82
+ }
83
+ }
84
+
85
+ // Check for form inputs without associated labels
86
+ if (elementName === 'input' || elementName === 'textarea' || elementName === 'select') {
87
+ const inputType = getAttrValue('type') || 'text';
88
+
89
+ // Skip hidden inputs
90
+ if (inputType === 'hidden') return;
91
+
92
+ const hasLabel = hasAttr('aria-label') ||
93
+ hasAttr('aria-labelledby') ||
94
+ hasAttr('id'); // Assume id might be linked to a label
95
+
96
+ if (!hasLabel) {
97
+ smells.push({
98
+ type: 'a11y-missing-label',
99
+ severity: 'warning',
100
+ message: `<${elementName}> without accessible label in "${component.name}"`,
101
+ file: filePath,
102
+ line,
103
+ column: loc?.start.column || 0,
104
+ suggestion: 'Add aria-label, aria-labelledby, or associate with a <label> element',
105
+ codeSnippet: getCodeSnippet(sourceCode, line),
106
+ });
107
+ }
108
+ }
109
+
110
+ // Check for interactive divs/spans without proper role and keyboard support
111
+ if (elementName === 'div' || elementName === 'span') {
112
+ const hasOnClick = hasAttr('onClick');
113
+ const hasRole = hasAttr('role');
114
+ const hasTabIndex = hasAttr('tabIndex');
115
+ const hasKeyboardHandler = hasAttr('onKeyDown') || hasAttr('onKeyUp') || hasAttr('onKeyPress');
116
+
117
+ if (hasOnClick) {
118
+ if (!hasRole) {
119
+ smells.push({
120
+ type: 'a11y-interactive-role',
121
+ severity: 'warning',
122
+ message: `Clickable <${elementName}> without role attribute in "${component.name}"`,
123
+ file: filePath,
124
+ line,
125
+ column: loc?.start.column || 0,
126
+ suggestion: 'Add role="button" or use a <button> element instead',
127
+ codeSnippet: getCodeSnippet(sourceCode, line),
128
+ });
129
+ }
130
+
131
+ if (!hasTabIndex) {
132
+ smells.push({
133
+ type: 'a11y-interactive-role',
134
+ severity: 'warning',
135
+ message: `Clickable <${elementName}> not focusable in "${component.name}"`,
136
+ file: filePath,
137
+ line,
138
+ column: loc?.start.column || 0,
139
+ suggestion: 'Add tabIndex={0} to make the element focusable',
140
+ codeSnippet: getCodeSnippet(sourceCode, line),
141
+ });
142
+ }
143
+
144
+ if (!hasKeyboardHandler) {
145
+ smells.push({
146
+ type: 'a11y-keyboard',
147
+ severity: 'info',
148
+ message: `Clickable <${elementName}> without keyboard handler in "${component.name}"`,
149
+ file: filePath,
150
+ line,
151
+ column: loc?.start.column || 0,
152
+ suggestion: 'Add onKeyDown to handle Enter/Space for keyboard users',
153
+ codeSnippet: getCodeSnippet(sourceCode, line),
154
+ });
155
+ }
156
+ }
157
+ }
158
+
159
+ // Check for anchor tags without href (should be buttons)
160
+ if (elementName === 'a') {
161
+ if (!hasAttr('href')) {
162
+ smells.push({
163
+ type: 'a11y-semantic',
164
+ severity: 'warning',
165
+ message: `<a> without href should be a <button> in "${component.name}"`,
166
+ file: filePath,
167
+ line,
168
+ column: loc?.start.column || 0,
169
+ suggestion: 'Use <button> for actions and <a href="..."> for navigation',
170
+ codeSnippet: getCodeSnippet(sourceCode, line),
171
+ });
172
+ }
173
+ }
174
+
175
+ // Check for proper button usage
176
+ if (elementName === 'button') {
177
+ if (!hasAttr('type')) {
178
+ smells.push({
179
+ type: 'a11y-semantic',
180
+ severity: 'info',
181
+ message: `<button> should have explicit type attribute`,
182
+ file: filePath,
183
+ line,
184
+ column: loc?.start.column || 0,
185
+ suggestion: 'Add type="button" or type="submit" to clarify button behavior',
186
+ codeSnippet: getCodeSnippet(sourceCode, line),
187
+ });
188
+ }
189
+ }
190
+
191
+ // Check for icons that might need labels
192
+ if (elementName === 'svg' || elementName === 'Icon' || elementName.endsWith('Icon')) {
193
+ const hasAriaLabel = hasAttr('aria-label') || hasAttr('aria-hidden') || hasAttr('title');
194
+
195
+ if (!hasAriaLabel) {
196
+ smells.push({
197
+ type: 'a11y-missing-label',
198
+ severity: 'info',
199
+ message: `Icon/SVG may need aria-label or aria-hidden in "${component.name}"`,
200
+ file: filePath,
201
+ line,
202
+ column: loc?.start.column || 0,
203
+ suggestion: 'Add aria-label for meaningful icons, or aria-hidden="true" for decorative ones',
204
+ codeSnippet: getCodeSnippet(sourceCode, line),
205
+ });
206
+ }
207
+ }
208
+ },
209
+ });
210
+
211
+ return smells;
212
+ }
@@ -0,0 +1,103 @@
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 debug statements that should be removed:
7
+ * - console.log/warn/error/debug
8
+ * - debugger statements
9
+ * - TODO/FIXME/HACK comments (detected via source code)
10
+ */
11
+ export function detectDebugStatements(
12
+ component: ParsedComponent,
13
+ filePath: string,
14
+ sourceCode: string,
15
+ config: DetectorConfig = DEFAULT_CONFIG
16
+ ): CodeSmell[] {
17
+ if (!config.checkDebugStatements) return [];
18
+
19
+ const smells: CodeSmell[] = [];
20
+ const reportedLines = new Set<number>();
21
+
22
+ // Detect console.* calls
23
+ component.path.traverse({
24
+ CallExpression(path) {
25
+ const { callee } = path.node;
26
+
27
+ if (t.isMemberExpression(callee) &&
28
+ t.isIdentifier(callee.object) &&
29
+ callee.object.name === 'console') {
30
+
31
+ const method = t.isIdentifier(callee.property) ? callee.property.name : '';
32
+ const debugMethods = ['log', 'warn', 'error', 'debug', 'info', 'trace', 'dir', 'table'];
33
+
34
+ if (debugMethods.includes(method)) {
35
+ const loc = path.node.loc;
36
+ const line = loc?.start.line || 0;
37
+
38
+ if (!reportedLines.has(line)) {
39
+ reportedLines.add(line);
40
+ smells.push({
41
+ type: 'debug-statement',
42
+ severity: 'warning',
43
+ message: `console.${method}() should be removed before production`,
44
+ file: filePath,
45
+ line,
46
+ column: loc?.start.column || 0,
47
+ suggestion: 'Remove console statement or use a logging library with environment-based filtering',
48
+ codeSnippet: getCodeSnippet(sourceCode, line),
49
+ });
50
+ }
51
+ }
52
+ }
53
+ },
54
+
55
+ // Detect debugger statements
56
+ DebuggerStatement(path) {
57
+ const loc = path.node.loc;
58
+ smells.push({
59
+ type: 'debug-statement',
60
+ severity: 'error',
61
+ message: 'debugger statement must be removed before production',
62
+ file: filePath,
63
+ line: loc?.start.line || 0,
64
+ column: loc?.start.column || 0,
65
+ suggestion: 'Remove the debugger statement',
66
+ codeSnippet: getCodeSnippet(sourceCode, loc?.start.line || 0),
67
+ });
68
+ },
69
+ });
70
+
71
+ // Detect TODO/FIXME/HACK comments in source
72
+ const todoPatterns = [
73
+ { pattern: /\/\/\s*(TODO|FIXME|HACK|XXX|BUG)[:.\s]/gi, type: 'todo-comment' as const },
74
+ { pattern: /\/\*\s*(TODO|FIXME|HACK|XXX|BUG)[:.\s]/gi, type: 'todo-comment' as const },
75
+ ];
76
+
77
+ const lines = sourceCode.split('\n');
78
+ lines.forEach((line, index) => {
79
+ const lineNum = index + 1;
80
+
81
+ // Only check within component bounds
82
+ if (lineNum < component.startLine || lineNum > component.endLine) return;
83
+
84
+ todoPatterns.forEach(({ pattern }) => {
85
+ const match = line.match(pattern);
86
+ if (match) {
87
+ const tag = match[0].replace(/[/\*\s:]/g, '').toUpperCase();
88
+ smells.push({
89
+ type: 'todo-comment',
90
+ severity: 'info',
91
+ message: `${tag} comment found in "${component.name}"`,
92
+ file: filePath,
93
+ line: lineNum,
94
+ column: 0,
95
+ suggestion: `Address the ${tag} or create a ticket to track it`,
96
+ codeSnippet: getCodeSnippet(sourceCode, lineNum),
97
+ });
98
+ }
99
+ });
100
+ });
101
+
102
+ return smells;
103
+ }
@@ -14,3 +14,7 @@ export { detectReactNativeIssues } from './reactNative.js';
14
14
  export { detectNodejsIssues } from './nodejs.js';
15
15
  export { detectJavascriptIssues } from './javascript.js';
16
16
  export { detectTypescriptIssues } from './typescript.js';
17
+ // Debug, Security, Accessibility
18
+ export { detectDebugStatements } from './debug.js';
19
+ export { detectSecurityIssues } from './security.js';
20
+ export { detectAccessibilityIssues } from './accessibility.js';
@@ -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
+ }