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
package/README.md
CHANGED
|
@@ -7,7 +7,11 @@ A CLI tool that analyzes React projects and detects common code smells, providin
|
|
|
7
7
|
- 🔍 **Detect Code Smells**: Identifies common React anti-patterns
|
|
8
8
|
- 📊 **Technical Debt Score**: Grades your codebase from A to F
|
|
9
9
|
- 💡 **Refactoring Suggestions**: Actionable recommendations for each issue
|
|
10
|
-
- 📝 **Multiple Output Formats**: Console (colored), JSON, and
|
|
10
|
+
- 📝 **Multiple Output Formats**: Console (colored), JSON, Markdown, and HTML
|
|
11
|
+
- 🔒 **Security Scanning**: Detects XSS vulnerabilities, eval usage, exposed secrets
|
|
12
|
+
- ♿ **Accessibility Checks**: Missing alt text, labels, ARIA attributes
|
|
13
|
+
- 🐛 **Debug Cleanup**: console.log, debugger, TODO/FIXME detection
|
|
14
|
+
- 🤖 **CI/CD Ready**: Exit codes and flags for pipeline integration
|
|
11
15
|
|
|
12
16
|
### Detected Code Smells
|
|
13
17
|
|
|
@@ -18,6 +22,10 @@ A CLI tool that analyzes React projects and detects common code smells, providin
|
|
|
18
22
|
| **Large Components** | Components over 300 lines, deep JSX nesting |
|
|
19
23
|
| **Unmemoized Calculations** | `.map()`, `.filter()`, `.reduce()`, `JSON.parse()` without `useMemo` |
|
|
20
24
|
| **Inline Functions** | Callbacks defined inline in JSX props |
|
|
25
|
+
| **Security Issues** | dangerouslySetInnerHTML, eval(), innerHTML, exposed API keys |
|
|
26
|
+
| **Accessibility** | Missing alt text, form labels, keyboard handlers, semantic HTML |
|
|
27
|
+
| **Debug Statements** | console.log, debugger statements, TODO/FIXME comments |
|
|
28
|
+
| **Framework-Specific** | Next.js, React Native, Node.js, TypeScript issues |
|
|
21
29
|
|
|
22
30
|
## Installation
|
|
23
31
|
|
|
@@ -56,6 +64,9 @@ react-smell ./src -f json
|
|
|
56
64
|
|
|
57
65
|
# Markdown (for documentation)
|
|
58
66
|
react-smell ./src -f markdown -o report.md
|
|
67
|
+
|
|
68
|
+
# HTML (beautiful visual report)
|
|
69
|
+
react-smell ./src -f html -o report.html
|
|
59
70
|
```
|
|
60
71
|
|
|
61
72
|
### Configuration
|
|
@@ -81,9 +92,11 @@ Or create manually:
|
|
|
81
92
|
|
|
82
93
|
| Option | Description | Default |
|
|
83
94
|
|--------|-------------|---------|
|
|
84
|
-
| `-f, --format <format>` | Output format: console, json, markdown | `console` |
|
|
95
|
+
| `-f, --format <format>` | Output format: console, json, markdown, html | `console` |
|
|
85
96
|
| `-s, --snippets` | Show code snippets in output | `false` |
|
|
86
97
|
| `-c, --config <file>` | Path to config file | `.smellrc.json` |
|
|
98
|
+
| `--ci` | CI mode: exit with code 1 if any issues | `false` |
|
|
99
|
+
| `--fail-on <severity>` | Exit code 1 threshold: error, warning, info | `error` |
|
|
87
100
|
| `--max-effects <number>` | Max useEffects per component | `3` |
|
|
88
101
|
| `--max-props <number>` | Max props before warning | `7` |
|
|
89
102
|
| `--max-lines <number>` | Max lines per component | `300` |
|
|
@@ -143,17 +156,62 @@ const report = reportResults(result, {
|
|
|
143
156
|
|
|
144
157
|
## CI/CD Integration
|
|
145
158
|
|
|
146
|
-
|
|
159
|
+
The tool provides flexible exit codes for CI/CD pipelines:
|
|
160
|
+
|
|
161
|
+
```bash
|
|
162
|
+
# Fail on any issues (strict mode)
|
|
163
|
+
react-smell ./src --ci
|
|
164
|
+
|
|
165
|
+
# Fail on warnings and errors (default: errors only)
|
|
166
|
+
react-smell ./src --fail-on warning
|
|
167
|
+
|
|
168
|
+
# Generate HTML report and fail on errors
|
|
169
|
+
react-smell ./src -f html -o report.html --fail-on error
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
### GitHub Actions Example
|
|
147
173
|
|
|
148
174
|
```yaml
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
175
|
+
name: Code Quality
|
|
176
|
+
|
|
177
|
+
on: [push, pull_request]
|
|
178
|
+
|
|
179
|
+
jobs:
|
|
180
|
+
code-smells:
|
|
181
|
+
runs-on: ubuntu-latest
|
|
182
|
+
steps:
|
|
183
|
+
- uses: actions/checkout@v4
|
|
184
|
+
- uses: actions/setup-node@v4
|
|
185
|
+
with:
|
|
186
|
+
node-version: '20'
|
|
187
|
+
|
|
188
|
+
- name: Install dependencies
|
|
189
|
+
run: npm ci
|
|
190
|
+
|
|
191
|
+
- name: Check code smells
|
|
192
|
+
run: npx react-smell ./src -f html -o smell-report.html --fail-on warning
|
|
193
|
+
|
|
194
|
+
- name: Upload report
|
|
195
|
+
uses: actions/upload-artifact@v4
|
|
196
|
+
if: always()
|
|
197
|
+
with:
|
|
198
|
+
name: code-smell-report
|
|
199
|
+
path: smell-report.html
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
## Ignoring Issues
|
|
203
|
+
|
|
204
|
+
Use `@smell-ignore` comments to suppress specific issues:
|
|
205
|
+
|
|
206
|
+
```tsx
|
|
207
|
+
// @smell-ignore
|
|
208
|
+
console.log('This debug statement is ignored');
|
|
209
|
+
|
|
210
|
+
// @smell-ignore debug-statement
|
|
211
|
+
console.log('Only ignores debug-statement type');
|
|
212
|
+
|
|
213
|
+
// @smell-ignore *
|
|
214
|
+
const risky = eval('1+1'); // All issues on next line ignored
|
|
157
215
|
```
|
|
158
216
|
|
|
159
217
|
## Technical Debt Score
|
package/dist/analyzer.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"analyzer.d.ts","sourceRoot":"","sources":["../src/analyzer.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"analyzer.d.ts","sourceRoot":"","sources":["../src/analyzer.ts"],"names":[],"mappings":"AAwBA,OAAO,EACL,cAAc,EAMd,cAAc,EAIf,MAAM,kBAAkB,CAAC;AAE1B,MAAM,WAAW,eAAe;IAC9B,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;IACnB,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;IACnB,MAAM,CAAC,EAAE,OAAO,CAAC,cAAc,CAAC,CAAC;CAClC;AAED,wBAAsB,cAAc,CAAC,OAAO,EAAE,eAAe,GAAG,OAAO,CAAC,cAAc,CAAC,CA0CtF;AA6OD,OAAO,EAAE,cAAc,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC"}
|
package/dist/analyzer.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fg from 'fast-glob';
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import { parseFile } from './parser/index.js';
|
|
4
|
-
import { detectUseEffectOveruse, detectPropDrilling, analyzePropDrillingDepth, detectLargeComponent, detectUnmemoizedCalculations, detectMissingKeys, detectHooksRulesViolations, detectDependencyArrayIssues, detectNestedTernaries, detectDeadCode, detectMagicValues, } from './detectors/index.js';
|
|
4
|
+
import { detectUseEffectOveruse, detectPropDrilling, analyzePropDrillingDepth, detectLargeComponent, detectUnmemoizedCalculations, detectMissingKeys, detectHooksRulesViolations, detectDependencyArrayIssues, detectNestedTernaries, detectDeadCode, detectMagicValues, detectNextjsIssues, detectReactNativeIssues, detectNodejsIssues, detectJavascriptIssues, detectTypescriptIssues, detectDebugStatements, detectSecurityIssues, detectAccessibilityIssues, } from './detectors/index.js';
|
|
5
5
|
import { DEFAULT_CONFIG, } from './types/index.js';
|
|
6
6
|
export async function analyzeProject(options) {
|
|
7
7
|
const { rootDir, include = ['**/*.tsx', '**/*.jsx'], exclude = ['**/node_modules/**', '**/dist/**', '**/build/**', '**/*.test.*', '**/*.spec.*'], config: userConfig = {}, } = options;
|
|
@@ -68,16 +68,55 @@ function analyzeFile(parseResult, filePath, config) {
|
|
|
68
68
|
smells.push(...detectNestedTernaries(component, filePath, sourceCode, config));
|
|
69
69
|
smells.push(...detectDeadCode(component, filePath, sourceCode, config));
|
|
70
70
|
smells.push(...detectMagicValues(component, filePath, sourceCode, config));
|
|
71
|
+
// Framework-specific detectors
|
|
72
|
+
smells.push(...detectNextjsIssues(component, filePath, sourceCode, config, imports));
|
|
73
|
+
smells.push(...detectReactNativeIssues(component, filePath, sourceCode, config, imports));
|
|
74
|
+
smells.push(...detectNodejsIssues(component, filePath, sourceCode, config, imports));
|
|
75
|
+
smells.push(...detectJavascriptIssues(component, filePath, sourceCode, config));
|
|
76
|
+
smells.push(...detectTypescriptIssues(component, filePath, sourceCode, config));
|
|
77
|
+
// Debug, Security, Accessibility
|
|
78
|
+
smells.push(...detectDebugStatements(component, filePath, sourceCode, config));
|
|
79
|
+
smells.push(...detectSecurityIssues(component, filePath, sourceCode, config));
|
|
80
|
+
smells.push(...detectAccessibilityIssues(component, filePath, sourceCode, config));
|
|
71
81
|
});
|
|
72
82
|
// Run cross-component analysis
|
|
73
83
|
smells.push(...analyzePropDrillingDepth(components, filePath, sourceCode, config));
|
|
84
|
+
// Filter out smells with @smell-ignore comments
|
|
85
|
+
const filteredSmells = filterIgnoredSmells(smells, sourceCode);
|
|
74
86
|
return {
|
|
75
87
|
file: filePath,
|
|
76
88
|
components: componentInfos,
|
|
77
|
-
smells,
|
|
89
|
+
smells: filteredSmells,
|
|
78
90
|
imports,
|
|
79
91
|
};
|
|
80
92
|
}
|
|
93
|
+
/**
|
|
94
|
+
* Filter out smells that have @smell-ignore comment on the preceding line
|
|
95
|
+
* Supports: // @smell-ignore, block comments with @smell-ignore, @smell-ignore [type]
|
|
96
|
+
*/
|
|
97
|
+
function filterIgnoredSmells(smells, sourceCode) {
|
|
98
|
+
const lines = sourceCode.split('\n');
|
|
99
|
+
return smells.filter(smell => {
|
|
100
|
+
if (smell.line <= 1)
|
|
101
|
+
return true;
|
|
102
|
+
// Check the line before and the same line for ignore comments
|
|
103
|
+
const lineIndex = smell.line - 1; // Convert to 0-indexed
|
|
104
|
+
const prevLine = lines[lineIndex - 1]?.trim() || '';
|
|
105
|
+
const currentLine = lines[lineIndex]?.trim() || '';
|
|
106
|
+
// Check for @smell-ignore patterns
|
|
107
|
+
const ignorePatterns = [
|
|
108
|
+
/@smell-ignore\s*$/, // @smell-ignore (ignore all)
|
|
109
|
+
/@smell-ignore\s+\*/, // @smell-ignore * (ignore all)
|
|
110
|
+
new RegExp(`@smell-ignore\\s+${smell.type}`), // @smell-ignore [specific-type]
|
|
111
|
+
];
|
|
112
|
+
for (const pattern of ignorePatterns) {
|
|
113
|
+
if (pattern.test(prevLine) || pattern.test(currentLine)) {
|
|
114
|
+
return false; // Filter out this smell
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return true; // Keep this smell
|
|
118
|
+
});
|
|
119
|
+
}
|
|
81
120
|
function calculateSummary(files) {
|
|
82
121
|
const smellsByType = {
|
|
83
122
|
'useEffect-overuse': 0,
|
|
@@ -94,6 +133,43 @@ function calculateSummary(files) {
|
|
|
94
133
|
'nested-ternary': 0,
|
|
95
134
|
'dead-code': 0,
|
|
96
135
|
'magic-value': 0,
|
|
136
|
+
// Next.js
|
|
137
|
+
'nextjs-client-server-boundary': 0,
|
|
138
|
+
'nextjs-missing-metadata': 0,
|
|
139
|
+
'nextjs-image-unoptimized': 0,
|
|
140
|
+
'nextjs-router-misuse': 0,
|
|
141
|
+
// React Native
|
|
142
|
+
'rn-inline-style': 0,
|
|
143
|
+
'rn-missing-accessibility': 0,
|
|
144
|
+
'rn-performance-issue': 0,
|
|
145
|
+
// Node.js
|
|
146
|
+
'nodejs-callback-hell': 0,
|
|
147
|
+
'nodejs-unhandled-promise': 0,
|
|
148
|
+
'nodejs-sync-io': 0,
|
|
149
|
+
'nodejs-missing-error-handling': 0,
|
|
150
|
+
// JavaScript
|
|
151
|
+
'js-var-usage': 0,
|
|
152
|
+
'js-loose-equality': 0,
|
|
153
|
+
'js-implicit-coercion': 0,
|
|
154
|
+
'js-global-pollution': 0,
|
|
155
|
+
// TypeScript
|
|
156
|
+
'ts-any-usage': 0,
|
|
157
|
+
'ts-missing-return-type': 0,
|
|
158
|
+
'ts-non-null-assertion': 0,
|
|
159
|
+
'ts-type-assertion': 0,
|
|
160
|
+
// Debug statements
|
|
161
|
+
'debug-statement': 0,
|
|
162
|
+
'todo-comment': 0,
|
|
163
|
+
// Security
|
|
164
|
+
'security-xss': 0,
|
|
165
|
+
'security-eval': 0,
|
|
166
|
+
'security-secrets': 0,
|
|
167
|
+
// Accessibility
|
|
168
|
+
'a11y-missing-alt': 0,
|
|
169
|
+
'a11y-missing-label': 0,
|
|
170
|
+
'a11y-interactive-role': 0,
|
|
171
|
+
'a11y-keyboard': 0,
|
|
172
|
+
'a11y-semantic': 0,
|
|
97
173
|
};
|
|
98
174
|
const smellsBySeverity = {
|
|
99
175
|
error: 0,
|
package/dist/cli.js
CHANGED
|
@@ -5,16 +5,19 @@ import ora from 'ora';
|
|
|
5
5
|
import path from 'path';
|
|
6
6
|
import { analyzeProject, DEFAULT_CONFIG } from './analyzer.js';
|
|
7
7
|
import { reportResults } from './reporter.js';
|
|
8
|
+
import { generateHTMLReport } from './htmlReporter.js';
|
|
8
9
|
import fs from 'fs/promises';
|
|
9
10
|
const program = new Command();
|
|
10
11
|
program
|
|
11
12
|
.name('react-smell')
|
|
12
13
|
.description('Detect code smells in React projects')
|
|
13
|
-
.version('1.
|
|
14
|
+
.version('1.2.0')
|
|
14
15
|
.argument('[directory]', 'Directory to analyze', '.')
|
|
15
|
-
.option('-f, --format <format>', 'Output format: console, json, markdown', 'console')
|
|
16
|
+
.option('-f, --format <format>', 'Output format: console, json, markdown, html', 'console')
|
|
16
17
|
.option('-s, --snippets', 'Show code snippets in output', false)
|
|
17
18
|
.option('-c, --config <file>', 'Path to config file')
|
|
19
|
+
.option('--ci', 'CI mode: exit with code 1 if any issues found')
|
|
20
|
+
.option('--fail-on <severity>', 'Exit with code 1 if issues of this severity or higher (error, warning, info)', 'error')
|
|
18
21
|
.option('--max-effects <number>', 'Max useEffects per component', parseInt)
|
|
19
22
|
.option('--max-props <number>', 'Max props before warning', parseInt)
|
|
20
23
|
.option('--max-lines <number>', 'Max lines per component', parseInt)
|
|
@@ -63,11 +66,21 @@ program
|
|
|
63
66
|
config,
|
|
64
67
|
});
|
|
65
68
|
spinner.stop();
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
69
|
+
let output;
|
|
70
|
+
if (options.format === 'html') {
|
|
71
|
+
output = generateHTMLReport(result, rootDir);
|
|
72
|
+
// Auto-set output file for HTML if not specified
|
|
73
|
+
if (!options.output) {
|
|
74
|
+
options.output = 'code-smell-report.html';
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
output = reportResults(result, {
|
|
79
|
+
format: options.format,
|
|
80
|
+
showCodeSnippets: options.snippets,
|
|
81
|
+
rootDir,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
71
84
|
if (options.output) {
|
|
72
85
|
const outputPath = path.resolve(process.cwd(), options.output);
|
|
73
86
|
await fs.writeFile(outputPath, output, 'utf-8');
|
|
@@ -76,8 +89,29 @@ program
|
|
|
76
89
|
else {
|
|
77
90
|
console.log(output);
|
|
78
91
|
}
|
|
79
|
-
//
|
|
80
|
-
|
|
92
|
+
// CI/CD exit code handling
|
|
93
|
+
const { smellsBySeverity } = result.summary;
|
|
94
|
+
let shouldFail = false;
|
|
95
|
+
if (options.ci) {
|
|
96
|
+
// CI mode: fail on any issues
|
|
97
|
+
shouldFail = result.summary.totalSmells > 0;
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
// Check fail-on threshold
|
|
101
|
+
switch (options.failOn) {
|
|
102
|
+
case 'info':
|
|
103
|
+
shouldFail = smellsBySeverity.info > 0 || smellsBySeverity.warning > 0 || smellsBySeverity.error > 0;
|
|
104
|
+
break;
|
|
105
|
+
case 'warning':
|
|
106
|
+
shouldFail = smellsBySeverity.warning > 0 || smellsBySeverity.error > 0;
|
|
107
|
+
break;
|
|
108
|
+
case 'error':
|
|
109
|
+
default:
|
|
110
|
+
shouldFail = smellsBySeverity.error > 0;
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (shouldFail) {
|
|
81
115
|
process.exit(1);
|
|
82
116
|
}
|
|
83
117
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { ParsedComponent } from '../parser/index.js';
|
|
2
|
+
import { CodeSmell, DetectorConfig } from '../types/index.js';
|
|
3
|
+
/**
|
|
4
|
+
* Detects accessibility (a11y) issues:
|
|
5
|
+
* - Images without alt text
|
|
6
|
+
* - Form inputs without labels
|
|
7
|
+
* - Missing ARIA attributes on interactive elements
|
|
8
|
+
* - Click handlers without keyboard support
|
|
9
|
+
* - Improper heading hierarchy
|
|
10
|
+
*/
|
|
11
|
+
export declare function detectAccessibilityIssues(component: ParsedComponent, filePath: string, sourceCode: string, config?: DetectorConfig): CodeSmell[];
|
|
12
|
+
//# sourceMappingURL=accessibility.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"accessibility.d.ts","sourceRoot":"","sources":["../../src/detectors/accessibility.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,eAAe,EAAkB,MAAM,oBAAoB,CAAC;AACrE,OAAO,EAAE,SAAS,EAAE,cAAc,EAAkB,MAAM,mBAAmB,CAAC;AAE9E;;;;;;;GAOG;AACH,wBAAgB,yBAAyB,CACvC,SAAS,EAAE,eAAe,EAC1B,QAAQ,EAAE,MAAM,EAChB,UAAU,EAAE,MAAM,EAClB,MAAM,GAAE,cAA+B,GACtC,SAAS,EAAE,CAkMb"}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import * as t from '@babel/types';
|
|
2
|
+
import { getCodeSnippet } from '../parser/index.js';
|
|
3
|
+
import { DEFAULT_CONFIG } from '../types/index.js';
|
|
4
|
+
/**
|
|
5
|
+
* Detects accessibility (a11y) issues:
|
|
6
|
+
* - Images without alt text
|
|
7
|
+
* - Form inputs without labels
|
|
8
|
+
* - Missing ARIA attributes on interactive elements
|
|
9
|
+
* - Click handlers without keyboard support
|
|
10
|
+
* - Improper heading hierarchy
|
|
11
|
+
*/
|
|
12
|
+
export function detectAccessibilityIssues(component, filePath, sourceCode, config = DEFAULT_CONFIG) {
|
|
13
|
+
if (!config.checkAccessibility)
|
|
14
|
+
return [];
|
|
15
|
+
const smells = [];
|
|
16
|
+
component.path.traverse({
|
|
17
|
+
JSXOpeningElement(path) {
|
|
18
|
+
if (!t.isJSXIdentifier(path.node.name))
|
|
19
|
+
return;
|
|
20
|
+
const elementName = path.node.name.name;
|
|
21
|
+
const attributes = path.node.attributes;
|
|
22
|
+
const loc = path.node.loc;
|
|
23
|
+
const line = loc?.start.line || 0;
|
|
24
|
+
// Helper to check if attribute exists
|
|
25
|
+
const hasAttr = (name) => {
|
|
26
|
+
return attributes.some(attr => {
|
|
27
|
+
if (t.isJSXAttribute(attr) && t.isJSXIdentifier(attr.name)) {
|
|
28
|
+
return attr.name.name === name;
|
|
29
|
+
}
|
|
30
|
+
// Handle spread attributes - assume they might contain the attribute
|
|
31
|
+
return t.isJSXSpreadAttribute(attr);
|
|
32
|
+
});
|
|
33
|
+
};
|
|
34
|
+
// Helper to get attribute value
|
|
35
|
+
const getAttrValue = (name) => {
|
|
36
|
+
for (const attr of attributes) {
|
|
37
|
+
if (t.isJSXAttribute(attr) && t.isJSXIdentifier(attr.name) && attr.name.name === name) {
|
|
38
|
+
if (t.isStringLiteral(attr.value)) {
|
|
39
|
+
return attr.value.value;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
};
|
|
45
|
+
// Check for images without alt text
|
|
46
|
+
if (elementName === 'img') {
|
|
47
|
+
if (!hasAttr('alt')) {
|
|
48
|
+
smells.push({
|
|
49
|
+
type: 'a11y-missing-alt',
|
|
50
|
+
severity: 'error',
|
|
51
|
+
message: `<img> missing alt attribute in "${component.name}"`,
|
|
52
|
+
file: filePath,
|
|
53
|
+
line,
|
|
54
|
+
column: loc?.start.column || 0,
|
|
55
|
+
suggestion: 'Add alt="description" for content images, or alt="" for decorative images',
|
|
56
|
+
codeSnippet: getCodeSnippet(sourceCode, line),
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
const altValue = getAttrValue('alt');
|
|
61
|
+
if (altValue !== null && altValue.toLowerCase().includes('image')) {
|
|
62
|
+
smells.push({
|
|
63
|
+
type: 'a11y-missing-alt',
|
|
64
|
+
severity: 'info',
|
|
65
|
+
message: `<img> alt text shouldn't contain "image" - it's redundant`,
|
|
66
|
+
file: filePath,
|
|
67
|
+
line,
|
|
68
|
+
column: loc?.start.column || 0,
|
|
69
|
+
suggestion: 'Describe what the image shows, not that it is an image',
|
|
70
|
+
codeSnippet: getCodeSnippet(sourceCode, line),
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
// Check for form inputs without associated labels
|
|
76
|
+
if (elementName === 'input' || elementName === 'textarea' || elementName === 'select') {
|
|
77
|
+
const inputType = getAttrValue('type') || 'text';
|
|
78
|
+
// Skip hidden inputs
|
|
79
|
+
if (inputType === 'hidden')
|
|
80
|
+
return;
|
|
81
|
+
const hasLabel = hasAttr('aria-label') ||
|
|
82
|
+
hasAttr('aria-labelledby') ||
|
|
83
|
+
hasAttr('id'); // Assume id might be linked to a label
|
|
84
|
+
if (!hasLabel) {
|
|
85
|
+
smells.push({
|
|
86
|
+
type: 'a11y-missing-label',
|
|
87
|
+
severity: 'warning',
|
|
88
|
+
message: `<${elementName}> without accessible label in "${component.name}"`,
|
|
89
|
+
file: filePath,
|
|
90
|
+
line,
|
|
91
|
+
column: loc?.start.column || 0,
|
|
92
|
+
suggestion: 'Add aria-label, aria-labelledby, or associate with a <label> element',
|
|
93
|
+
codeSnippet: getCodeSnippet(sourceCode, line),
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
// Check for interactive divs/spans without proper role and keyboard support
|
|
98
|
+
if (elementName === 'div' || elementName === 'span') {
|
|
99
|
+
const hasOnClick = hasAttr('onClick');
|
|
100
|
+
const hasRole = hasAttr('role');
|
|
101
|
+
const hasTabIndex = hasAttr('tabIndex');
|
|
102
|
+
const hasKeyboardHandler = hasAttr('onKeyDown') || hasAttr('onKeyUp') || hasAttr('onKeyPress');
|
|
103
|
+
if (hasOnClick) {
|
|
104
|
+
if (!hasRole) {
|
|
105
|
+
smells.push({
|
|
106
|
+
type: 'a11y-interactive-role',
|
|
107
|
+
severity: 'warning',
|
|
108
|
+
message: `Clickable <${elementName}> without role attribute in "${component.name}"`,
|
|
109
|
+
file: filePath,
|
|
110
|
+
line,
|
|
111
|
+
column: loc?.start.column || 0,
|
|
112
|
+
suggestion: 'Add role="button" or use a <button> element instead',
|
|
113
|
+
codeSnippet: getCodeSnippet(sourceCode, line),
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
if (!hasTabIndex) {
|
|
117
|
+
smells.push({
|
|
118
|
+
type: 'a11y-interactive-role',
|
|
119
|
+
severity: 'warning',
|
|
120
|
+
message: `Clickable <${elementName}> not focusable in "${component.name}"`,
|
|
121
|
+
file: filePath,
|
|
122
|
+
line,
|
|
123
|
+
column: loc?.start.column || 0,
|
|
124
|
+
suggestion: 'Add tabIndex={0} to make the element focusable',
|
|
125
|
+
codeSnippet: getCodeSnippet(sourceCode, line),
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
if (!hasKeyboardHandler) {
|
|
129
|
+
smells.push({
|
|
130
|
+
type: 'a11y-keyboard',
|
|
131
|
+
severity: 'info',
|
|
132
|
+
message: `Clickable <${elementName}> without keyboard handler in "${component.name}"`,
|
|
133
|
+
file: filePath,
|
|
134
|
+
line,
|
|
135
|
+
column: loc?.start.column || 0,
|
|
136
|
+
suggestion: 'Add onKeyDown to handle Enter/Space for keyboard users',
|
|
137
|
+
codeSnippet: getCodeSnippet(sourceCode, line),
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
// Check for anchor tags without href (should be buttons)
|
|
143
|
+
if (elementName === 'a') {
|
|
144
|
+
if (!hasAttr('href')) {
|
|
145
|
+
smells.push({
|
|
146
|
+
type: 'a11y-semantic',
|
|
147
|
+
severity: 'warning',
|
|
148
|
+
message: `<a> without href should be a <button> in "${component.name}"`,
|
|
149
|
+
file: filePath,
|
|
150
|
+
line,
|
|
151
|
+
column: loc?.start.column || 0,
|
|
152
|
+
suggestion: 'Use <button> for actions and <a href="..."> for navigation',
|
|
153
|
+
codeSnippet: getCodeSnippet(sourceCode, line),
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
// Check for proper button usage
|
|
158
|
+
if (elementName === 'button') {
|
|
159
|
+
if (!hasAttr('type')) {
|
|
160
|
+
smells.push({
|
|
161
|
+
type: 'a11y-semantic',
|
|
162
|
+
severity: 'info',
|
|
163
|
+
message: `<button> should have explicit type attribute`,
|
|
164
|
+
file: filePath,
|
|
165
|
+
line,
|
|
166
|
+
column: loc?.start.column || 0,
|
|
167
|
+
suggestion: 'Add type="button" or type="submit" to clarify button behavior',
|
|
168
|
+
codeSnippet: getCodeSnippet(sourceCode, line),
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
// Check for icons that might need labels
|
|
173
|
+
if (elementName === 'svg' || elementName === 'Icon' || elementName.endsWith('Icon')) {
|
|
174
|
+
const hasAriaLabel = hasAttr('aria-label') || hasAttr('aria-hidden') || hasAttr('title');
|
|
175
|
+
if (!hasAriaLabel) {
|
|
176
|
+
smells.push({
|
|
177
|
+
type: 'a11y-missing-label',
|
|
178
|
+
severity: 'info',
|
|
179
|
+
message: `Icon/SVG may need aria-label or aria-hidden in "${component.name}"`,
|
|
180
|
+
file: filePath,
|
|
181
|
+
line,
|
|
182
|
+
column: loc?.start.column || 0,
|
|
183
|
+
suggestion: 'Add aria-label for meaningful icons, or aria-hidden="true" for decorative ones',
|
|
184
|
+
codeSnippet: getCodeSnippet(sourceCode, line),
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
},
|
|
189
|
+
});
|
|
190
|
+
return smells;
|
|
191
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { ParsedComponent } from '../parser/index.js';
|
|
2
|
+
import { CodeSmell, DetectorConfig } from '../types/index.js';
|
|
3
|
+
/**
|
|
4
|
+
* Detects debug statements that should be removed:
|
|
5
|
+
* - console.log/warn/error/debug
|
|
6
|
+
* - debugger statements
|
|
7
|
+
* - TODO/FIXME/HACK comments (detected via source code)
|
|
8
|
+
*/
|
|
9
|
+
export declare function detectDebugStatements(component: ParsedComponent, filePath: string, sourceCode: string, config?: DetectorConfig): CodeSmell[];
|
|
10
|
+
//# sourceMappingURL=debug.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"debug.d.ts","sourceRoot":"","sources":["../../src/detectors/debug.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,eAAe,EAAkB,MAAM,oBAAoB,CAAC;AACrE,OAAO,EAAE,SAAS,EAAE,cAAc,EAAkB,MAAM,mBAAmB,CAAC;AAE9E;;;;;GAKG;AACH,wBAAgB,qBAAqB,CACnC,SAAS,EAAE,eAAe,EAC1B,QAAQ,EAAE,MAAM,EAChB,UAAU,EAAE,MAAM,EAClB,MAAM,GAAE,cAA+B,GACtC,SAAS,EAAE,CAuFb"}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import * as t from '@babel/types';
|
|
2
|
+
import { getCodeSnippet } from '../parser/index.js';
|
|
3
|
+
import { DEFAULT_CONFIG } from '../types/index.js';
|
|
4
|
+
/**
|
|
5
|
+
* Detects debug statements that should be removed:
|
|
6
|
+
* - console.log/warn/error/debug
|
|
7
|
+
* - debugger statements
|
|
8
|
+
* - TODO/FIXME/HACK comments (detected via source code)
|
|
9
|
+
*/
|
|
10
|
+
export function detectDebugStatements(component, filePath, sourceCode, config = DEFAULT_CONFIG) {
|
|
11
|
+
if (!config.checkDebugStatements)
|
|
12
|
+
return [];
|
|
13
|
+
const smells = [];
|
|
14
|
+
const reportedLines = new Set();
|
|
15
|
+
// Detect console.* calls
|
|
16
|
+
component.path.traverse({
|
|
17
|
+
CallExpression(path) {
|
|
18
|
+
const { callee } = path.node;
|
|
19
|
+
if (t.isMemberExpression(callee) &&
|
|
20
|
+
t.isIdentifier(callee.object) &&
|
|
21
|
+
callee.object.name === 'console') {
|
|
22
|
+
const method = t.isIdentifier(callee.property) ? callee.property.name : '';
|
|
23
|
+
const debugMethods = ['log', 'warn', 'error', 'debug', 'info', 'trace', 'dir', 'table'];
|
|
24
|
+
if (debugMethods.includes(method)) {
|
|
25
|
+
const loc = path.node.loc;
|
|
26
|
+
const line = loc?.start.line || 0;
|
|
27
|
+
if (!reportedLines.has(line)) {
|
|
28
|
+
reportedLines.add(line);
|
|
29
|
+
smells.push({
|
|
30
|
+
type: 'debug-statement',
|
|
31
|
+
severity: 'warning',
|
|
32
|
+
message: `console.${method}() should be removed before production`,
|
|
33
|
+
file: filePath,
|
|
34
|
+
line,
|
|
35
|
+
column: loc?.start.column || 0,
|
|
36
|
+
suggestion: 'Remove console statement or use a logging library with environment-based filtering',
|
|
37
|
+
codeSnippet: getCodeSnippet(sourceCode, line),
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
// Detect debugger statements
|
|
44
|
+
DebuggerStatement(path) {
|
|
45
|
+
const loc = path.node.loc;
|
|
46
|
+
smells.push({
|
|
47
|
+
type: 'debug-statement',
|
|
48
|
+
severity: 'error',
|
|
49
|
+
message: 'debugger statement must be removed before production',
|
|
50
|
+
file: filePath,
|
|
51
|
+
line: loc?.start.line || 0,
|
|
52
|
+
column: loc?.start.column || 0,
|
|
53
|
+
suggestion: 'Remove the debugger statement',
|
|
54
|
+
codeSnippet: getCodeSnippet(sourceCode, loc?.start.line || 0),
|
|
55
|
+
});
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
// Detect TODO/FIXME/HACK comments in source
|
|
59
|
+
const todoPatterns = [
|
|
60
|
+
{ pattern: /\/\/\s*(TODO|FIXME|HACK|XXX|BUG)[:.\s]/gi, type: 'todo-comment' },
|
|
61
|
+
{ pattern: /\/\*\s*(TODO|FIXME|HACK|XXX|BUG)[:.\s]/gi, type: 'todo-comment' },
|
|
62
|
+
];
|
|
63
|
+
const lines = sourceCode.split('\n');
|
|
64
|
+
lines.forEach((line, index) => {
|
|
65
|
+
const lineNum = index + 1;
|
|
66
|
+
// Only check within component bounds
|
|
67
|
+
if (lineNum < component.startLine || lineNum > component.endLine)
|
|
68
|
+
return;
|
|
69
|
+
todoPatterns.forEach(({ pattern }) => {
|
|
70
|
+
const match = line.match(pattern);
|
|
71
|
+
if (match) {
|
|
72
|
+
const tag = match[0].replace(/[/\*\s:]/g, '').toUpperCase();
|
|
73
|
+
smells.push({
|
|
74
|
+
type: 'todo-comment',
|
|
75
|
+
severity: 'info',
|
|
76
|
+
message: `${tag} comment found in "${component.name}"`,
|
|
77
|
+
file: filePath,
|
|
78
|
+
line: lineNum,
|
|
79
|
+
column: 0,
|
|
80
|
+
suggestion: `Address the ${tag} or create a ticket to track it`,
|
|
81
|
+
codeSnippet: getCodeSnippet(sourceCode, lineNum),
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
return smells;
|
|
87
|
+
}
|
|
@@ -8,4 +8,12 @@ export { detectDependencyArrayIssues } from './dependencyArray.js';
|
|
|
8
8
|
export { detectNestedTernaries } from './nestedTernary.js';
|
|
9
9
|
export { detectDeadCode, detectUnusedImports } from './deadCode.js';
|
|
10
10
|
export { detectMagicValues } from './magicValues.js';
|
|
11
|
+
export { detectNextjsIssues } from './nextjs.js';
|
|
12
|
+
export { detectReactNativeIssues } from './reactNative.js';
|
|
13
|
+
export { detectNodejsIssues } from './nodejs.js';
|
|
14
|
+
export { detectJavascriptIssues } from './javascript.js';
|
|
15
|
+
export { detectTypescriptIssues } from './typescript.js';
|
|
16
|
+
export { detectDebugStatements } from './debug.js';
|
|
17
|
+
export { detectSecurityIssues } from './security.js';
|
|
18
|
+
export { detectAccessibilityIssues } from './accessibility.js';
|
|
11
19
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/detectors/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,sBAAsB,EAAE,MAAM,gBAAgB,CAAC;AACxD,OAAO,EAAE,kBAAkB,EAAE,wBAAwB,EAAE,MAAM,mBAAmB,CAAC;AACjF,OAAO,EAAE,oBAAoB,EAAE,MAAM,qBAAqB,CAAC;AAC3D,OAAO,EAAE,4BAA4B,EAAE,MAAM,kBAAkB,CAAC;AAChE,OAAO,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AACpD,OAAO,EAAE,0BAA0B,EAAE,MAAM,iBAAiB,CAAC;AAC7D,OAAO,EAAE,2BAA2B,EAAE,MAAM,sBAAsB,CAAC;AACnE,OAAO,EAAE,qBAAqB,EAAE,MAAM,oBAAoB,CAAC;AAC3D,OAAO,EAAE,cAAc,EAAE,mBAAmB,EAAE,MAAM,eAAe,CAAC;AACpE,OAAO,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/detectors/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,sBAAsB,EAAE,MAAM,gBAAgB,CAAC;AACxD,OAAO,EAAE,kBAAkB,EAAE,wBAAwB,EAAE,MAAM,mBAAmB,CAAC;AACjF,OAAO,EAAE,oBAAoB,EAAE,MAAM,qBAAqB,CAAC;AAC3D,OAAO,EAAE,4BAA4B,EAAE,MAAM,kBAAkB,CAAC;AAChE,OAAO,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AACpD,OAAO,EAAE,0BAA0B,EAAE,MAAM,iBAAiB,CAAC;AAC7D,OAAO,EAAE,2BAA2B,EAAE,MAAM,sBAAsB,CAAC;AACnE,OAAO,EAAE,qBAAqB,EAAE,MAAM,oBAAoB,CAAC;AAC3D,OAAO,EAAE,cAAc,EAAE,mBAAmB,EAAE,MAAM,eAAe,CAAC;AACpE,OAAO,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AAErD,OAAO,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAC;AACjD,OAAO,EAAE,uBAAuB,EAAE,MAAM,kBAAkB,CAAC;AAC3D,OAAO,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAC;AACjD,OAAO,EAAE,sBAAsB,EAAE,MAAM,iBAAiB,CAAC;AACzD,OAAO,EAAE,sBAAsB,EAAE,MAAM,iBAAiB,CAAC;AAEzD,OAAO,EAAE,qBAAqB,EAAE,MAAM,YAAY,CAAC;AACnD,OAAO,EAAE,oBAAoB,EAAE,MAAM,eAAe,CAAC;AACrD,OAAO,EAAE,yBAAyB,EAAE,MAAM,oBAAoB,CAAC"}
|
package/dist/detectors/index.js
CHANGED
|
@@ -8,3 +8,13 @@ export { detectDependencyArrayIssues } from './dependencyArray.js';
|
|
|
8
8
|
export { detectNestedTernaries } from './nestedTernary.js';
|
|
9
9
|
export { detectDeadCode, detectUnusedImports } from './deadCode.js';
|
|
10
10
|
export { detectMagicValues } from './magicValues.js';
|
|
11
|
+
// Framework-specific detectors
|
|
12
|
+
export { detectNextjsIssues } from './nextjs.js';
|
|
13
|
+
export { detectReactNativeIssues } from './reactNative.js';
|
|
14
|
+
export { detectNodejsIssues } from './nodejs.js';
|
|
15
|
+
export { detectJavascriptIssues } from './javascript.js';
|
|
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';
|