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
@@ -1,169 +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 vanilla JavaScript code smells:
7
- * - var usage (should use let/const)
8
- * - Loose equality (== instead of ===)
9
- * - Implicit type coercion
10
- * - Global variable pollution
11
- */
12
- export function detectJavascriptIssues(
13
- component: ParsedComponent,
14
- filePath: string,
15
- sourceCode: string,
16
- config: DetectorConfig = DEFAULT_CONFIG
17
- ): CodeSmell[] {
18
- if (!config.checkJavascript) return [];
19
-
20
- const smells: CodeSmell[] = [];
21
-
22
- // Detect var usage (should use let/const)
23
- component.path.traverse({
24
- VariableDeclaration(path) {
25
- if (path.node.kind === 'var') {
26
- const loc = path.node.loc;
27
- smells.push({
28
- type: 'js-var-usage',
29
- severity: 'warning',
30
- message: `Using "var" instead of "let" or "const" in "${component.name}"`,
31
- file: filePath,
32
- line: loc?.start.line || 0,
33
- column: loc?.start.column || 0,
34
- suggestion: 'Use "const" for values that never change, "let" for reassignable variables',
35
- codeSnippet: getCodeSnippet(sourceCode, loc?.start.line || 0),
36
- });
37
- }
38
- },
39
- });
40
-
41
- // Detect loose equality (== and != instead of === and !==)
42
- component.path.traverse({
43
- BinaryExpression(path) {
44
- const { operator, left, right } = path.node;
45
-
46
- // Skip if comparing with null/undefined (sometimes intentional)
47
- const isNullCheck =
48
- (t.isNullLiteral(left) || t.isNullLiteral(right)) ||
49
- (t.isIdentifier(left) && left.name === 'undefined') ||
50
- (t.isIdentifier(right) && right.name === 'undefined');
51
-
52
- if (operator === '==' && !isNullCheck) {
53
- const loc = path.node.loc;
54
- smells.push({
55
- type: 'js-loose-equality',
56
- severity: 'warning',
57
- message: `Using loose equality "==" in "${component.name}"`,
58
- file: filePath,
59
- line: loc?.start.line || 0,
60
- column: loc?.start.column || 0,
61
- suggestion: 'Use strict equality "===" to avoid type coercion bugs',
62
- codeSnippet: getCodeSnippet(sourceCode, loc?.start.line || 0),
63
- });
64
- }
65
-
66
- if (operator === '!=' && !isNullCheck) {
67
- const loc = path.node.loc;
68
- smells.push({
69
- type: 'js-loose-equality',
70
- severity: 'warning',
71
- message: `Using loose inequality "!=" in "${component.name}"`,
72
- file: filePath,
73
- line: loc?.start.line || 0,
74
- column: loc?.start.column || 0,
75
- suggestion: 'Use strict inequality "!==" to avoid type coercion bugs',
76
- codeSnippet: getCodeSnippet(sourceCode, loc?.start.line || 0),
77
- });
78
- }
79
- },
80
- });
81
-
82
- // Detect implicit type coercion patterns
83
- component.path.traverse({
84
- // +string to convert to number
85
- UnaryExpression(path) {
86
- if (path.node.operator === '+' && t.isIdentifier(path.node.argument)) {
87
- const loc = path.node.loc;
88
- smells.push({
89
- type: 'js-implicit-coercion',
90
- severity: 'info',
91
- message: `Implicit number coercion with unary + in "${component.name}"`,
92
- file: filePath,
93
- line: loc?.start.line || 0,
94
- column: loc?.start.column || 0,
95
- suggestion: 'Use explicit conversion: Number(value) or parseInt(value, 10)',
96
- codeSnippet: getCodeSnippet(sourceCode, loc?.start.line || 0),
97
- });
98
- }
99
- },
100
-
101
- // !!value for boolean coercion - detected via UnaryExpression
102
- // LogicalExpression handles &&, ||, ?? - skip for !! detection
103
-
104
- // String concatenation with + that mixes types
105
- BinaryExpression(path) {
106
- if (path.node.operator === '+') {
107
- const { left, right } = path.node;
108
- const leftIsString = t.isStringLiteral(left) || t.isTemplateLiteral(left);
109
- const rightIsString = t.isStringLiteral(right) || t.isTemplateLiteral(right);
110
- const leftIsNumber = t.isNumericLiteral(left);
111
- const rightIsNumber = t.isNumericLiteral(right);
112
-
113
- // Mixed string + number concatenation
114
- if ((leftIsString && rightIsNumber) || (leftIsNumber && rightIsString)) {
115
- const loc = path.node.loc;
116
- smells.push({
117
- type: 'js-implicit-coercion',
118
- severity: 'info',
119
- message: `Implicit string coercion in "${component.name}"`,
120
- file: filePath,
121
- line: loc?.start.line || 0,
122
- column: loc?.start.column || 0,
123
- suggestion: 'Use template literal for clarity: `${value}` or String(value)',
124
- codeSnippet: getCodeSnippet(sourceCode, loc?.start.line || 0),
125
- });
126
- }
127
- }
128
- },
129
- });
130
-
131
- // Detect potential global pollution (assignments without declaration)
132
- component.path.traverse({
133
- AssignmentExpression(path) {
134
- const { left } = path.node;
135
-
136
- if (t.isIdentifier(left)) {
137
- // Check if this identifier is declared in scope
138
- const binding = path.scope.getBinding(left.name);
139
-
140
- if (!binding) {
141
- // Check if it's a well-known global
142
- const knownGlobals = [
143
- 'window', 'document', 'console', 'localStorage', 'sessionStorage',
144
- 'setTimeout', 'setInterval', 'clearTimeout', 'clearInterval',
145
- 'fetch', 'XMLHttpRequest', 'WebSocket', 'module', 'exports',
146
- 'require', 'process', 'global', '__dirname', '__filename',
147
- 'Buffer', 'Promise', 'Map', 'Set', 'Symbol',
148
- ];
149
-
150
- if (!knownGlobals.includes(left.name)) {
151
- const loc = path.node.loc;
152
- smells.push({
153
- type: 'js-global-pollution',
154
- severity: 'error',
155
- message: `Implicit global variable "${left.name}" in "${component.name}"`,
156
- file: filePath,
157
- line: loc?.start.line || 0,
158
- column: loc?.start.column || 0,
159
- suggestion: `Declare the variable: const ${left.name} = ... or let ${left.name} = ...`,
160
- codeSnippet: getCodeSnippet(sourceCode, loc?.start.line || 0),
161
- });
162
- }
163
- }
164
- }
165
- },
166
- });
167
-
168
- return smells;
169
- }
@@ -1,63 +0,0 @@
1
- import { ParsedComponent, getCodeSnippet } from '../parser/index.js';
2
- import { CodeSmell, DetectorConfig, DEFAULT_CONFIG } from '../types/index.js';
3
-
4
- export function detectLargeComponent(
5
- component: ParsedComponent,
6
- filePath: string,
7
- sourceCode: string,
8
- config: DetectorConfig = DEFAULT_CONFIG
9
- ): CodeSmell[] {
10
- const smells: CodeSmell[] = [];
11
- const lineCount = component.endLine - component.startLine + 1;
12
-
13
- if (lineCount > config.maxComponentLines) {
14
- smells.push({
15
- type: 'large-component',
16
- severity: 'warning',
17
- message: `Component "${component.name}" has ${lineCount} lines (max recommended: ${config.maxComponentLines})`,
18
- file: filePath,
19
- line: component.startLine,
20
- column: 0,
21
- suggestion: 'Break this component into smaller, more focused components',
22
- codeSnippet: getCodeSnippet(sourceCode, component.startLine),
23
- });
24
- }
25
-
26
- // Check for complex JSX nesting
27
- if (component.jsxDepth > 10) {
28
- smells.push({
29
- type: 'deep-nesting',
30
- severity: 'info',
31
- message: `Component "${component.name}" has deeply nested JSX (depth: ~${component.jsxDepth})`,
32
- file: filePath,
33
- line: component.startLine,
34
- column: 0,
35
- suggestion: 'Extract nested elements into separate components for better readability',
36
- codeSnippet: getCodeSnippet(sourceCode, component.startLine),
37
- });
38
- }
39
-
40
- // Check for too many hooks (complexity indicator)
41
- const totalHooks =
42
- component.hooks.useEffect.length +
43
- component.hooks.useState.length +
44
- component.hooks.useMemo.length +
45
- component.hooks.useCallback.length +
46
- component.hooks.useRef.length +
47
- component.hooks.custom.length;
48
-
49
- if (totalHooks > 10) {
50
- smells.push({
51
- type: 'large-component',
52
- severity: 'warning',
53
- message: `Component "${component.name}" uses ${totalHooks} hooks - high complexity`,
54
- file: filePath,
55
- line: component.startLine,
56
- column: 0,
57
- suggestion: 'Extract related logic into custom hooks',
58
- codeSnippet: getCodeSnippet(sourceCode, component.startLine),
59
- });
60
- }
61
-
62
- return smells;
63
- }
@@ -1,114 +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 magic numbers and strings that should be constants
7
- */
8
- export function detectMagicValues(
9
- component: ParsedComponent,
10
- filePath: string,
11
- sourceCode: string,
12
- config: DetectorConfig = DEFAULT_CONFIG
13
- ): CodeSmell[] {
14
- if (!config.checkMagicValues) return [];
15
-
16
- const smells: CodeSmell[] = [];
17
- const threshold = config.magicNumberThreshold;
18
-
19
- // Track locations to avoid duplicate reports
20
- const reportedLines = new Set<number>();
21
-
22
- component.path.traverse({
23
- NumericLiteral(path) {
24
- const value = path.node.value;
25
- const loc = path.node.loc;
26
- const line = loc?.start.line || 0;
27
-
28
- // Skip common acceptable values
29
- if (value === 0 || value === 1 || value === -1 || value === 2 || value === 100) return;
30
-
31
- // Skip array indices and small numbers
32
- if (value < threshold && value > -threshold) return;
33
-
34
- // Skip if inside an object key position (e.g., style objects)
35
- if (t.isObjectProperty(path.parent) && path.parent.key === path.node) return;
36
-
37
- // Skip if it's a port number or similar config
38
- if (value === 3000 || value === 8080 || value === 80 || value === 443) return;
39
-
40
- // Skip if already reported this line
41
- if (reportedLines.has(line)) return;
42
- reportedLines.add(line);
43
-
44
- // Check context - skip if it's in a variable declaration that names the constant
45
- if (t.isVariableDeclarator(path.parent)) {
46
- const varName = t.isIdentifier(path.parent.id) ? path.parent.id.name : '';
47
- // If variable name is UPPER_CASE, it's already a constant
48
- if (varName === varName.toUpperCase() && varName.includes('_')) return;
49
- }
50
-
51
- smells.push({
52
- type: 'magic-value',
53
- severity: 'info',
54
- message: `Magic number ${value} in "${component.name}"`,
55
- file: filePath,
56
- line,
57
- column: loc?.start.column || 0,
58
- suggestion: `Extract to a named constant: const DESCRIPTIVE_NAME = ${value}`,
59
- codeSnippet: getCodeSnippet(sourceCode, line),
60
- });
61
- },
62
-
63
- StringLiteral(path) {
64
- const value = path.node.value;
65
- const loc = path.node.loc;
66
- const line = loc?.start.line || 0;
67
-
68
- // Skip short strings, empty strings, common values
69
- if (value.length < 10) return;
70
-
71
- // Skip if it looks like a URL, path, or import
72
- if (value.startsWith('http') || value.startsWith('/') || value.startsWith('.')) return;
73
-
74
- // Skip if it's an import source
75
- if (t.isImportDeclaration(path.parent)) return;
76
-
77
- // Skip JSX text content (usually intentional)
78
- if (t.isJSXText(path.parent) || t.isJSXExpressionContainer(path.parent)) return;
79
-
80
- // Skip object property keys
81
- if (t.isObjectProperty(path.parent) && path.parent.key === path.node) return;
82
-
83
- // Skip if already reported this line
84
- if (reportedLines.has(line)) return;
85
-
86
- // Skip common patterns
87
- if (
88
- value.includes('className') ||
89
- value.includes('px') ||
90
- value.includes('rem') ||
91
- value.includes('%')
92
- ) return;
93
-
94
- // Check if it looks like a user-facing message or error
95
- const looksLikeMessage = /^[A-Z][a-z]+.*[.!?]?$/.test(value) || value.includes(' ');
96
-
97
- if (looksLikeMessage && value.length > 30) {
98
- reportedLines.add(line);
99
- smells.push({
100
- type: 'magic-value',
101
- severity: 'info',
102
- message: `Hardcoded string in "${component.name}" should be in constants or i18n`,
103
- file: filePath,
104
- line,
105
- column: loc?.start.column || 0,
106
- suggestion: `Extract to a constants file or use i18n: "${value.substring(0, 30)}..."`,
107
- codeSnippet: getCodeSnippet(sourceCode, line),
108
- });
109
- }
110
- },
111
- });
112
-
113
- return smells;
114
- }
@@ -1,177 +0,0 @@
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
- export function detectUnmemoizedCalculations(
7
- component: ParsedComponent,
8
- filePath: string,
9
- sourceCode: string,
10
- config: DetectorConfig = DEFAULT_CONFIG
11
- ): CodeSmell[] {
12
- if (!config.checkMemoization) return [];
13
-
14
- const smells: CodeSmell[] = [];
15
-
16
- // Look for expensive operations not wrapped in useMemo
17
- component.path.traverse({
18
- VariableDeclarator(varPath) {
19
- const init = varPath.node.init;
20
- if (!init) return;
21
-
22
- const loc = init.loc;
23
- if (!loc) return;
24
-
25
- // Check if this is inside useMemo/useCallback
26
- let isInsideHook = false;
27
- let currentPath: NodePath | null = varPath.parentPath;
28
- while (currentPath) {
29
- if (
30
- currentPath.isCallExpression() &&
31
- t.isIdentifier(currentPath.node.callee) &&
32
- ['useMemo', 'useCallback'].includes(currentPath.node.callee.name)
33
- ) {
34
- isInsideHook = true;
35
- break;
36
- }
37
- currentPath = currentPath.parentPath;
38
- }
39
-
40
- if (isInsideHook) return;
41
-
42
- // Detect expensive operations
43
- let isExpensive = false;
44
- let reason = '';
45
-
46
- // Check for .map(), .filter(), .reduce(), .sort() on arrays
47
- if (t.isCallExpression(init) && t.isMemberExpression(init.callee)) {
48
- const prop = init.callee.property;
49
- if (t.isIdentifier(prop)) {
50
- const expensiveMethods = ['map', 'filter', 'reduce', 'sort', 'find', 'findIndex', 'flatMap'];
51
- if (expensiveMethods.includes(prop.name)) {
52
- isExpensive = true;
53
- reason = `.${prop.name}() creates a new array on every render`;
54
- }
55
- }
56
- }
57
-
58
- // Check for object/array literals with complex computations
59
- if (t.isArrayExpression(init) && init.elements.length > 5) {
60
- isExpensive = true;
61
- reason = 'Large array literal recreated on every render';
62
- }
63
-
64
- if (t.isObjectExpression(init) && init.properties.length > 5) {
65
- isExpensive = true;
66
- reason = 'Large object literal recreated on every render';
67
- }
68
-
69
- // Check for JSON operations
70
- if (t.isCallExpression(init) && t.isMemberExpression(init.callee)) {
71
- const obj = init.callee.object;
72
- const prop = init.callee.property;
73
- if (
74
- t.isIdentifier(obj) &&
75
- obj.name === 'JSON' &&
76
- t.isIdentifier(prop) &&
77
- ['parse', 'stringify'].includes(prop.name)
78
- ) {
79
- isExpensive = true;
80
- reason = `JSON.${prop.name}() is expensive and runs on every render`;
81
- }
82
- }
83
-
84
- // Check for spread operations creating new objects
85
- if (t.isObjectExpression(init)) {
86
- const hasSpread = init.properties.some(p => t.isSpreadElement(p));
87
- if (hasSpread && init.properties.length > 3) {
88
- isExpensive = true;
89
- reason = 'Spread operation creates new object reference on every render';
90
- }
91
- }
92
-
93
- if (isExpensive) {
94
- const id = varPath.node.id;
95
- const varName = t.isIdentifier(id) ? id.name : 'variable';
96
-
97
- smells.push({
98
- type: 'unmemoized-calculation',
99
- severity: 'warning',
100
- message: `Unmemoized calculation "${varName}" in "${component.name}": ${reason}`,
101
- file: filePath,
102
- line: loc.start.line,
103
- column: loc.start.column,
104
- suggestion: `Wrap in useMemo: const ${varName} = useMemo(() => ..., [dependencies])`,
105
- codeSnippet: getCodeSnippet(sourceCode, loc.start.line),
106
- });
107
- }
108
- },
109
- });
110
-
111
- // Check for inline function props (common performance issue)
112
- component.path.traverse({
113
- JSXAttribute(attrPath) {
114
- const value = attrPath.node.value;
115
- if (!t.isJSXExpressionContainer(value)) return;
116
-
117
- const expr = value.expression;
118
- const loc = expr.loc;
119
- if (!loc) return;
120
-
121
- // Check for inline arrow functions
122
- if (t.isArrowFunctionExpression(expr) || t.isFunctionExpression(expr)) {
123
- const attrName = t.isJSXIdentifier(attrPath.node.name)
124
- ? attrPath.node.name.name
125
- : 'unknown';
126
-
127
- // Only warn for non-trivial handlers
128
- if (attrName.startsWith('on') && t.isArrowFunctionExpression(expr)) {
129
- const body = expr.body;
130
- // Skip simple one-liner callbacks that just call a function
131
- if (t.isCallExpression(body)) {
132
- // These are usually fine: onClick={() => doSomething()}
133
- return;
134
- }
135
-
136
- if (t.isBlockStatement(body) && body.body.length > 1) {
137
- smells.push({
138
- type: 'inline-function-prop',
139
- severity: 'info',
140
- message: `Inline function for "${attrName}" creates new reference on every render`,
141
- file: filePath,
142
- line: loc.start.line,
143
- column: loc.start.column,
144
- suggestion: 'Extract to useCallback or define outside render',
145
- codeSnippet: getCodeSnippet(sourceCode, loc.start.line),
146
- });
147
- }
148
- }
149
- }
150
-
151
- // Check for inline objects/arrays passed as props
152
- if (t.isObjectExpression(expr) || t.isArrayExpression(expr)) {
153
- const attrName = t.isJSXIdentifier(attrPath.node.name)
154
- ? attrPath.node.name.name
155
- : 'unknown';
156
-
157
- // style prop is a common pattern, only warn for complex ones
158
- if (attrName === 'style' && t.isObjectExpression(expr) && expr.properties.length <= 3) {
159
- return;
160
- }
161
-
162
- smells.push({
163
- type: 'unmemoized-calculation',
164
- severity: 'info',
165
- message: `Inline ${t.isObjectExpression(expr) ? 'object' : 'array'} for "${attrName}" prop creates new reference on every render`,
166
- file: filePath,
167
- line: loc.start.line,
168
- column: loc.start.column,
169
- suggestion: 'Extract to a constant or useMemo',
170
- codeSnippet: getCodeSnippet(sourceCode, loc.start.line),
171
- });
172
- }
173
- },
174
- });
175
-
176
- return smells;
177
- }
@@ -1,105 +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 .map() calls that render JSX without a key prop
7
- */
8
- export function detectMissingKeys(
9
- component: ParsedComponent,
10
- filePath: string,
11
- sourceCode: string,
12
- config: DetectorConfig = DEFAULT_CONFIG
13
- ): CodeSmell[] {
14
- if (!config.checkMissingKeys) return [];
15
-
16
- const smells: CodeSmell[] = [];
17
-
18
- component.path.traverse({
19
- CallExpression(path) {
20
- // Check if it's a .map() call
21
- if (!t.isMemberExpression(path.node.callee)) return;
22
-
23
- const prop = path.node.callee.property;
24
- if (!t.isIdentifier(prop) || prop.name !== 'map') return;
25
-
26
- // Check if the callback returns JSX
27
- const callback = path.node.arguments[0];
28
- if (!callback) return;
29
-
30
- let callbackBody: t.Node | null = null;
31
-
32
- if (t.isArrowFunctionExpression(callback)) {
33
- callbackBody = callback.body;
34
- } else if (t.isFunctionExpression(callback)) {
35
- callbackBody = callback.body;
36
- }
37
-
38
- if (!callbackBody) return;
39
-
40
- // Find JSX elements in the callback
41
- let hasJSX = false;
42
- let hasKey = false;
43
- let jsxLine = path.node.loc?.start.line || 0;
44
-
45
- const checkForKey = (node: t.Node) => {
46
- if (t.isJSXElement(node)) {
47
- hasJSX = true;
48
- jsxLine = node.loc?.start.line || jsxLine;
49
-
50
- // Check if the opening element has a key prop
51
- const openingElement = node.openingElement;
52
- const keyAttr = openingElement.attributes.find(attr => {
53
- if (t.isJSXAttribute(attr)) {
54
- return t.isJSXIdentifier(attr.name) && attr.name.name === 'key';
55
- }
56
- return false;
57
- });
58
-
59
- if (keyAttr) {
60
- hasKey = true;
61
- }
62
- } else if (t.isJSXFragment(node)) {
63
- hasJSX = true;
64
- // Fragments can't have keys directly, so check children
65
- }
66
- };
67
-
68
- // Traverse the callback body to find JSX
69
- if (t.isJSXElement(callbackBody) || t.isJSXFragment(callbackBody)) {
70
- checkForKey(callbackBody);
71
- } else if (t.isBlockStatement(callbackBody)) {
72
- path.traverse({
73
- ReturnStatement(returnPath) {
74
- if (returnPath.node.argument) {
75
- checkForKey(returnPath.node.argument);
76
- }
77
- },
78
- JSXElement(jsxPath) {
79
- // Only check top-level JSX in the map callback
80
- if (jsxPath.parent === callbackBody ||
81
- (t.isReturnStatement(jsxPath.parent) && jsxPath.parent.argument === jsxPath.node)) {
82
- checkForKey(jsxPath.node);
83
- }
84
- },
85
- });
86
- }
87
-
88
- if (hasJSX && !hasKey) {
89
- const loc = path.node.loc;
90
- smells.push({
91
- type: 'missing-key',
92
- severity: 'error',
93
- message: `Missing "key" prop in .map() that renders JSX in "${component.name}"`,
94
- file: filePath,
95
- line: jsxLine || loc?.start.line || 0,
96
- column: loc?.start.column || 0,
97
- suggestion: 'Add a unique "key" prop to the root element: <Element key={item.id}>',
98
- codeSnippet: getCodeSnippet(sourceCode, loc?.start.line || 0),
99
- });
100
- }
101
- },
102
- });
103
-
104
- return smells;
105
- }