react-code-smell-detector 1.2.0 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +46 -0
  3. package/dist/analyzer.d.ts.map +1 -1
  4. package/dist/analyzer.js +18 -1
  5. package/dist/cli.js +93 -26
  6. package/dist/detectors/complexity.d.ts +17 -0
  7. package/dist/detectors/complexity.d.ts.map +1 -0
  8. package/dist/detectors/complexity.js +69 -0
  9. package/dist/detectors/imports.d.ts +22 -0
  10. package/dist/detectors/imports.d.ts.map +1 -0
  11. package/dist/detectors/imports.js +210 -0
  12. package/dist/detectors/index.d.ts +3 -0
  13. package/dist/detectors/index.d.ts.map +1 -1
  14. package/dist/detectors/index.js +4 -0
  15. package/dist/detectors/memoryLeak.d.ts +7 -0
  16. package/dist/detectors/memoryLeak.d.ts.map +1 -0
  17. package/dist/detectors/memoryLeak.js +111 -0
  18. package/dist/fixer.d.ts +23 -0
  19. package/dist/fixer.d.ts.map +1 -0
  20. package/dist/fixer.js +133 -0
  21. package/dist/git.d.ts +28 -0
  22. package/dist/git.d.ts.map +1 -0
  23. package/dist/git.js +117 -0
  24. package/dist/reporter.js +13 -0
  25. package/dist/types/index.d.ts +7 -1
  26. package/dist/types/index.d.ts.map +1 -1
  27. package/dist/types/index.js +9 -0
  28. package/dist/watcher.d.ts +16 -0
  29. package/dist/watcher.d.ts.map +1 -0
  30. package/dist/watcher.js +89 -0
  31. package/package.json +8 -2
  32. package/src/analyzer.ts +0 -324
  33. package/src/cli.ts +0 -159
  34. package/src/detectors/accessibility.ts +0 -212
  35. package/src/detectors/deadCode.ts +0 -163
  36. package/src/detectors/debug.ts +0 -103
  37. package/src/detectors/dependencyArray.ts +0 -176
  38. package/src/detectors/hooksRules.ts +0 -101
  39. package/src/detectors/index.ts +0 -20
  40. package/src/detectors/javascript.ts +0 -169
  41. package/src/detectors/largeComponent.ts +0 -63
  42. package/src/detectors/magicValues.ts +0 -114
  43. package/src/detectors/memoization.ts +0 -177
  44. package/src/detectors/missingKey.ts +0 -105
  45. package/src/detectors/nestedTernary.ts +0 -75
  46. package/src/detectors/nextjs.ts +0 -124
  47. package/src/detectors/nodejs.ts +0 -199
  48. package/src/detectors/propDrilling.ts +0 -103
  49. package/src/detectors/reactNative.ts +0 -154
  50. package/src/detectors/security.ts +0 -179
  51. package/src/detectors/typescript.ts +0 -151
  52. package/src/detectors/useEffect.ts +0 -117
  53. package/src/htmlReporter.ts +0 -464
  54. package/src/index.ts +0 -4
  55. package/src/parser/index.ts +0 -195
  56. package/src/reporter.ts +0 -291
  57. package/src/types/index.ts +0 -165
  58. package/tsconfig.json +0 -19
package/src/analyzer.ts DELETED
@@ -1,324 +0,0 @@
1
- import fg from 'fast-glob';
2
- import path from 'path';
3
- import { parseFile, ParseResult } from './parser/index.js';
4
- import {
5
- detectUseEffectOveruse,
6
- detectPropDrilling,
7
- analyzePropDrillingDepth,
8
- detectLargeComponent,
9
- detectUnmemoizedCalculations,
10
- detectMissingKeys,
11
- detectHooksRulesViolations,
12
- detectDependencyArrayIssues,
13
- detectNestedTernaries,
14
- detectDeadCode,
15
- detectMagicValues,
16
- detectNextjsIssues,
17
- detectReactNativeIssues,
18
- detectNodejsIssues,
19
- detectJavascriptIssues,
20
- detectTypescriptIssues,
21
- detectDebugStatements,
22
- detectSecurityIssues,
23
- detectAccessibilityIssues,
24
- } from './detectors/index.js';
25
- import {
26
- AnalysisResult,
27
- FileAnalysis,
28
- ComponentInfo,
29
- CodeSmell,
30
- AnalysisSummary,
31
- TechnicalDebtScore,
32
- DetectorConfig,
33
- DEFAULT_CONFIG,
34
- SmellType,
35
- SmellSeverity,
36
- } from './types/index.js';
37
-
38
- export interface AnalyzerOptions {
39
- rootDir: string;
40
- include?: string[];
41
- exclude?: string[];
42
- config?: Partial<DetectorConfig>;
43
- }
44
-
45
- export async function analyzeProject(options: AnalyzerOptions): Promise<AnalysisResult> {
46
- const {
47
- rootDir,
48
- include = ['**/*.tsx', '**/*.jsx'],
49
- exclude = ['**/node_modules/**', '**/dist/**', '**/build/**', '**/*.test.*', '**/*.spec.*'],
50
- config: userConfig = {},
51
- } = options;
52
-
53
- const config: DetectorConfig = { ...DEFAULT_CONFIG, ...userConfig };
54
-
55
- // Find all React files
56
- const patterns = include.map(p => path.join(rootDir, p));
57
- const files = await fg(patterns, {
58
- ignore: exclude,
59
- absolute: true,
60
- });
61
-
62
- const fileAnalyses: FileAnalysis[] = [];
63
-
64
- // Analyze each file
65
- for (const file of files) {
66
- try {
67
- const parseResult = await parseFile(file);
68
- const analysis = analyzeFile(parseResult, file, config);
69
- if (analysis.components.length > 0 || analysis.smells.length > 0) {
70
- fileAnalyses.push(analysis);
71
- }
72
- } catch (error) {
73
- // Skip files that can't be parsed
74
- console.warn(`Warning: Could not parse ${file}: ${(error as Error).message}`);
75
- }
76
- }
77
-
78
- // Calculate summary and score
79
- const summary = calculateSummary(fileAnalyses);
80
- const debtScore = calculateTechnicalDebtScore(fileAnalyses, summary);
81
-
82
- return {
83
- files: fileAnalyses,
84
- summary,
85
- debtScore,
86
- };
87
- }
88
-
89
- function analyzeFile(parseResult: ParseResult, filePath: string, config: DetectorConfig): FileAnalysis {
90
- const { components, imports, sourceCode } = parseResult;
91
- const smells: CodeSmell[] = [];
92
- const componentInfos: ComponentInfo[] = [];
93
-
94
- // Run all detectors on each component
95
- components.forEach(component => {
96
- // Collect component info
97
- componentInfos.push({
98
- name: component.name,
99
- file: filePath,
100
- startLine: component.startLine,
101
- endLine: component.endLine,
102
- lineCount: component.endLine - component.startLine + 1,
103
- useEffectCount: component.hooks.useEffect.length,
104
- useStateCount: component.hooks.useState.length,
105
- useMemoCount: component.hooks.useMemo.length,
106
- useCallbackCount: component.hooks.useCallback.length,
107
- propsCount: component.props.length,
108
- propsDrillingDepth: 0, // Calculated separately
109
- hasExpensiveCalculation: false, // Will be set by memoization detector
110
- });
111
-
112
- // Run detectors
113
- smells.push(...detectUseEffectOveruse(component, filePath, sourceCode, config));
114
- smells.push(...detectPropDrilling(component, filePath, sourceCode, config));
115
- smells.push(...detectLargeComponent(component, filePath, sourceCode, config));
116
- smells.push(...detectUnmemoizedCalculations(component, filePath, sourceCode, config));
117
- smells.push(...detectMissingKeys(component, filePath, sourceCode, config));
118
- smells.push(...detectHooksRulesViolations(component, filePath, sourceCode, config));
119
- smells.push(...detectDependencyArrayIssues(component, filePath, sourceCode, config));
120
- smells.push(...detectNestedTernaries(component, filePath, sourceCode, config));
121
- smells.push(...detectDeadCode(component, filePath, sourceCode, config));
122
- smells.push(...detectMagicValues(component, filePath, sourceCode, config));
123
- // Framework-specific detectors
124
- smells.push(...detectNextjsIssues(component, filePath, sourceCode, config, imports));
125
- smells.push(...detectReactNativeIssues(component, filePath, sourceCode, config, imports));
126
- smells.push(...detectNodejsIssues(component, filePath, sourceCode, config, imports));
127
- smells.push(...detectJavascriptIssues(component, filePath, sourceCode, config));
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));
133
- });
134
-
135
- // Run cross-component analysis
136
- smells.push(...analyzePropDrillingDepth(components, filePath, sourceCode, config));
137
-
138
- // Filter out smells with @smell-ignore comments
139
- const filteredSmells = filterIgnoredSmells(smells, sourceCode);
140
-
141
- return {
142
- file: filePath,
143
- components: componentInfos,
144
- smells: filteredSmells,
145
- imports,
146
- };
147
- }
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
-
181
- function calculateSummary(files: FileAnalysis[]): AnalysisSummary {
182
- const smellsByType: Record<SmellType, number> = {
183
- 'useEffect-overuse': 0,
184
- 'prop-drilling': 0,
185
- 'large-component': 0,
186
- 'unmemoized-calculation': 0,
187
- 'missing-dependency': 0,
188
- 'state-in-loop': 0,
189
- 'inline-function-prop': 0,
190
- 'deep-nesting': 0,
191
- 'missing-key': 0,
192
- 'hooks-rules-violation': 0,
193
- 'dependency-array-issue': 0,
194
- 'nested-ternary': 0,
195
- 'dead-code': 0,
196
- 'magic-value': 0,
197
- // Next.js
198
- 'nextjs-client-server-boundary': 0,
199
- 'nextjs-missing-metadata': 0,
200
- 'nextjs-image-unoptimized': 0,
201
- 'nextjs-router-misuse': 0,
202
- // React Native
203
- 'rn-inline-style': 0,
204
- 'rn-missing-accessibility': 0,
205
- 'rn-performance-issue': 0,
206
- // Node.js
207
- 'nodejs-callback-hell': 0,
208
- 'nodejs-unhandled-promise': 0,
209
- 'nodejs-sync-io': 0,
210
- 'nodejs-missing-error-handling': 0,
211
- // JavaScript
212
- 'js-var-usage': 0,
213
- 'js-loose-equality': 0,
214
- 'js-implicit-coercion': 0,
215
- 'js-global-pollution': 0,
216
- // TypeScript
217
- 'ts-any-usage': 0,
218
- 'ts-missing-return-type': 0,
219
- 'ts-non-null-assertion': 0,
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,
234
- };
235
-
236
- const smellsBySeverity: Record<SmellSeverity, number> = {
237
- error: 0,
238
- warning: 0,
239
- info: 0,
240
- };
241
-
242
- let totalSmells = 0;
243
- let totalComponents = 0;
244
-
245
- files.forEach(file => {
246
- totalComponents += file.components.length;
247
- file.smells.forEach(smell => {
248
- totalSmells++;
249
- smellsByType[smell.type]++;
250
- smellsBySeverity[smell.severity]++;
251
- });
252
- });
253
-
254
- return {
255
- totalFiles: files.length,
256
- totalComponents,
257
- totalSmells,
258
- smellsByType,
259
- smellsBySeverity,
260
- };
261
- }
262
-
263
- function calculateTechnicalDebtScore(files: FileAnalysis[], summary: AnalysisSummary): TechnicalDebtScore {
264
- // Calculate individual scores (0-100, higher is better)
265
-
266
- // useEffect score: penalize based on useEffect-related issues
267
- const useEffectIssues = summary.smellsByType['useEffect-overuse'];
268
- const useEffectScore = Math.max(0, 100 - useEffectIssues * 15);
269
-
270
- // Prop drilling score
271
- const propDrillingIssues = summary.smellsByType['prop-drilling'];
272
- const propDrillingScore = Math.max(0, 100 - propDrillingIssues * 12);
273
-
274
- // Component size score
275
- const sizeIssues = summary.smellsByType['large-component'] + summary.smellsByType['deep-nesting'];
276
- const componentSizeScore = Math.max(0, 100 - sizeIssues * 10);
277
-
278
- // Memoization score
279
- const memoIssues = summary.smellsByType['unmemoized-calculation'] + summary.smellsByType['inline-function-prop'];
280
- const memoizationScore = Math.max(0, 100 - memoIssues * 8);
281
-
282
- // Overall score (weighted average)
283
- const score = Math.round(
284
- useEffectScore * 0.3 +
285
- propDrillingScore * 0.25 +
286
- componentSizeScore * 0.25 +
287
- memoizationScore * 0.2
288
- );
289
-
290
- // Determine grade
291
- let grade: 'A' | 'B' | 'C' | 'D' | 'F';
292
- if (score >= 90) grade = 'A';
293
- else if (score >= 80) grade = 'B';
294
- else if (score >= 70) grade = 'C';
295
- else if (score >= 60) grade = 'D';
296
- else grade = 'F';
297
-
298
- // Estimate refactor time
299
- const errorCount = summary.smellsBySeverity.error;
300
- const warningCount = summary.smellsBySeverity.warning;
301
- const totalIssues = errorCount * 30 + warningCount * 15; // minutes
302
-
303
- let estimatedRefactorTime: string;
304
- if (totalIssues < 30) estimatedRefactorTime = '< 30 minutes';
305
- else if (totalIssues < 60) estimatedRefactorTime = '30 min - 1 hour';
306
- else if (totalIssues < 120) estimatedRefactorTime = '1-2 hours';
307
- else if (totalIssues < 240) estimatedRefactorTime = '2-4 hours';
308
- else if (totalIssues < 480) estimatedRefactorTime = '4-8 hours';
309
- else estimatedRefactorTime = '> 1 day';
310
-
311
- return {
312
- score,
313
- grade,
314
- breakdown: {
315
- useEffectScore,
316
- propDrillingScore,
317
- componentSizeScore,
318
- memoizationScore,
319
- },
320
- estimatedRefactorTime,
321
- };
322
- }
323
-
324
- export { DEFAULT_CONFIG, DetectorConfig } from './types/index.js';
package/src/cli.ts DELETED
@@ -1,159 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- import { Command } from 'commander';
4
- import chalk from 'chalk';
5
- import ora from 'ora';
6
- import path from 'path';
7
- import { analyzeProject, DEFAULT_CONFIG } from './analyzer.js';
8
- import { reportResults } from './reporter.js';
9
- import { generateHTMLReport } from './htmlReporter.js';
10
- import fs from 'fs/promises';
11
-
12
- const program = new Command();
13
-
14
- program
15
- .name('react-smell')
16
- .description('Detect code smells in React projects')
17
- .version('1.2.0')
18
- .argument('[directory]', 'Directory to analyze', '.')
19
- .option('-f, --format <format>', 'Output format: console, json, markdown, html', 'console')
20
- .option('-s, --snippets', 'Show code snippets in output', false)
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')
24
- .option('--max-effects <number>', 'Max useEffects per component', parseInt)
25
- .option('--max-props <number>', 'Max props before warning', parseInt)
26
- .option('--max-lines <number>', 'Max lines per component', parseInt)
27
- .option('--include <patterns>', 'Glob patterns to include (comma-separated)')
28
- .option('--exclude <patterns>', 'Glob patterns to exclude (comma-separated)')
29
- .option('-o, --output <file>', 'Write output to file')
30
- .action(async (directory, options) => {
31
- const rootDir = path.resolve(process.cwd(), directory);
32
-
33
- // Check if directory exists
34
- try {
35
- await fs.access(rootDir);
36
- } catch {
37
- console.error(chalk.red(`Error: Directory "${rootDir}" does not exist.`));
38
- process.exit(1);
39
- }
40
-
41
- const spinner = ora('Analyzing React project...').start();
42
-
43
- try {
44
- // Load config file if specified
45
- let fileConfig = {};
46
- if (options.config) {
47
- try {
48
- const configPath = path.resolve(process.cwd(), options.config);
49
- const configContent = await fs.readFile(configPath, 'utf-8');
50
- fileConfig = JSON.parse(configContent);
51
- } catch (error) {
52
- spinner.fail(`Could not load config file: ${(error as Error).message}`);
53
- process.exit(1);
54
- }
55
- }
56
-
57
- // Build config from options
58
- const config = {
59
- ...DEFAULT_CONFIG,
60
- ...fileConfig,
61
- ...(options.maxEffects && { maxUseEffectsPerComponent: options.maxEffects }),
62
- ...(options.maxProps && { maxPropsCount: options.maxProps }),
63
- ...(options.maxLines && { maxComponentLines: options.maxLines }),
64
- };
65
-
66
- const include = options.include?.split(',').map((p: string) => p.trim()) || undefined;
67
- const exclude = options.exclude?.split(',').map((p: string) => p.trim()) || undefined;
68
-
69
- const result = await analyzeProject({
70
- rootDir,
71
- include,
72
- exclude,
73
- config,
74
- });
75
-
76
- spinner.stop();
77
-
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
- }
92
-
93
- if (options.output) {
94
- const outputPath = path.resolve(process.cwd(), options.output);
95
- await fs.writeFile(outputPath, output, 'utf-8');
96
- console.log(chalk.green(`✓ Report written to ${outputPath}`));
97
- } else {
98
- console.log(output);
99
- }
100
-
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) {
125
- process.exit(1);
126
- }
127
- } catch (error) {
128
- spinner.fail(`Analysis failed: ${(error as Error).message}`);
129
- if (process.env.DEBUG) {
130
- console.error(error);
131
- }
132
- process.exit(1);
133
- }
134
- });
135
-
136
- // Init command to create config file
137
- program
138
- .command('init')
139
- .description('Create a configuration file')
140
- .action(async () => {
141
- const configContent = JSON.stringify({
142
- maxUseEffectsPerComponent: DEFAULT_CONFIG.maxUseEffectsPerComponent,
143
- maxPropDrillingDepth: DEFAULT_CONFIG.maxPropDrillingDepth,
144
- maxComponentLines: DEFAULT_CONFIG.maxComponentLines,
145
- maxPropsCount: DEFAULT_CONFIG.maxPropsCount,
146
- }, null, 2);
147
-
148
- const configPath = path.join(process.cwd(), '.smellrc.json');
149
-
150
- try {
151
- await fs.access(configPath);
152
- console.log(chalk.yellow('Config file already exists at .smellrc.json'));
153
- } catch {
154
- await fs.writeFile(configPath, configContent, 'utf-8');
155
- console.log(chalk.green('✓ Created .smellrc.json'));
156
- }
157
- });
158
-
159
- program.parse();
@@ -1,212 +0,0 @@
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
- }