react-code-smell-detector 1.0.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.
Files changed (47) hide show
  1. package/README.md +179 -0
  2. package/dist/analyzer.d.ts +10 -0
  3. package/dist/analyzer.d.ts.map +1 -0
  4. package/dist/analyzer.js +169 -0
  5. package/dist/cli.d.ts +3 -0
  6. package/dist/cli.d.ts.map +1 -0
  7. package/dist/cli.js +113 -0
  8. package/dist/detectors/index.d.ts +5 -0
  9. package/dist/detectors/index.d.ts.map +1 -0
  10. package/dist/detectors/index.js +4 -0
  11. package/dist/detectors/largeComponent.d.ts +4 -0
  12. package/dist/detectors/largeComponent.d.ts.map +1 -0
  13. package/dist/detectors/largeComponent.js +51 -0
  14. package/dist/detectors/memoization.d.ts +4 -0
  15. package/dist/detectors/memoization.d.ts.map +1 -0
  16. package/dist/detectors/memoization.js +150 -0
  17. package/dist/detectors/propDrilling.d.ts +5 -0
  18. package/dist/detectors/propDrilling.d.ts.map +1 -0
  19. package/dist/detectors/propDrilling.js +82 -0
  20. package/dist/detectors/useEffect.d.ts +4 -0
  21. package/dist/detectors/useEffect.d.ts.map +1 -0
  22. package/dist/detectors/useEffect.js +101 -0
  23. package/dist/index.d.ts +5 -0
  24. package/dist/index.d.ts.map +1 -0
  25. package/dist/index.js +4 -0
  26. package/dist/parser/index.d.ts +29 -0
  27. package/dist/parser/index.d.ts.map +1 -0
  28. package/dist/parser/index.js +151 -0
  29. package/dist/reporter.d.ts +8 -0
  30. package/dist/reporter.d.ts.map +1 -0
  31. package/dist/reporter.js +217 -0
  32. package/dist/types/index.d.ts +64 -0
  33. package/dist/types/index.d.ts.map +1 -0
  34. package/dist/types/index.js +7 -0
  35. package/package.json +45 -0
  36. package/src/analyzer.ts +216 -0
  37. package/src/cli.ts +125 -0
  38. package/src/detectors/index.ts +4 -0
  39. package/src/detectors/largeComponent.ts +63 -0
  40. package/src/detectors/memoization.ts +177 -0
  41. package/src/detectors/propDrilling.ts +103 -0
  42. package/src/detectors/useEffect.ts +117 -0
  43. package/src/index.ts +4 -0
  44. package/src/parser/index.ts +195 -0
  45. package/src/reporter.ts +248 -0
  46. package/src/types/index.ts +86 -0
  47. package/tsconfig.json +19 -0
@@ -0,0 +1,63 @@
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
+ }
@@ -0,0 +1,177 @@
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
+ }
@@ -0,0 +1,103 @@
1
+ import { ParsedComponent, getCodeSnippet } from '../parser/index.js';
2
+ import { CodeSmell, DetectorConfig, DEFAULT_CONFIG } from '../types/index.js';
3
+
4
+ export function detectPropDrilling(
5
+ component: ParsedComponent,
6
+ filePath: string,
7
+ sourceCode: string,
8
+ config: DetectorConfig = DEFAULT_CONFIG
9
+ ): CodeSmell[] {
10
+ const smells: CodeSmell[] = [];
11
+
12
+ // Check for too many props
13
+ if (component.props.length > config.maxPropsCount) {
14
+ smells.push({
15
+ type: 'prop-drilling',
16
+ severity: 'warning',
17
+ message: `Component "${component.name}" receives ${component.props.length} props (max recommended: ${config.maxPropsCount})`,
18
+ file: filePath,
19
+ line: component.startLine,
20
+ column: 0,
21
+ suggestion: 'Consider using Context, a state management library, or component composition',
22
+ codeSnippet: getCodeSnippet(sourceCode, component.startLine),
23
+ });
24
+ }
25
+
26
+ // Check for props that are just passed through (prop drilling indicators)
27
+ const passThroughProps = component.props.filter(prop => {
28
+ // Common patterns that indicate prop drilling
29
+ const drillingPatterns = ['data', 'config', 'settings', 'options', 'state', 'handlers', 'callbacks'];
30
+ return drillingPatterns.some(pattern => prop.toLowerCase().includes(pattern));
31
+ });
32
+
33
+ if (passThroughProps.length >= 3) {
34
+ smells.push({
35
+ type: 'prop-drilling',
36
+ severity: 'info',
37
+ message: `Component "${component.name}" may be experiencing prop drilling: ${passThroughProps.join(', ')}`,
38
+ file: filePath,
39
+ line: component.startLine,
40
+ column: 0,
41
+ suggestion: 'Consider using React Context or a state management solution to avoid passing data through intermediate components',
42
+ codeSnippet: getCodeSnippet(sourceCode, component.startLine),
43
+ });
44
+ }
45
+
46
+ // Check for spread props (often indicates forwarding)
47
+ const spreadProps = component.props.filter(p => p.startsWith('...'));
48
+ if (spreadProps.length > 0 && component.props.length > 5) {
49
+ smells.push({
50
+ type: 'prop-drilling',
51
+ severity: 'info',
52
+ message: `Component "${component.name}" uses spread props with many other props - potential prop drilling`,
53
+ file: filePath,
54
+ line: component.startLine,
55
+ column: 0,
56
+ suggestion: 'Consider composing components differently or using Context for shared state',
57
+ codeSnippet: getCodeSnippet(sourceCode, component.startLine),
58
+ });
59
+ }
60
+
61
+ return smells;
62
+ }
63
+
64
+ export function analyzePropDrillingDepth(
65
+ components: ParsedComponent[],
66
+ filePath: string,
67
+ sourceCode: string,
68
+ config: DetectorConfig = DEFAULT_CONFIG
69
+ ): CodeSmell[] {
70
+ const smells: CodeSmell[] = [];
71
+
72
+ // Track props that appear in multiple components
73
+ const propUsage = new Map<string, string[]>();
74
+
75
+ components.forEach(comp => {
76
+ comp.props.forEach(prop => {
77
+ if (!prop.startsWith('...')) {
78
+ const existing = propUsage.get(prop) || [];
79
+ existing.push(comp.name);
80
+ propUsage.set(prop, existing);
81
+ }
82
+ });
83
+ });
84
+
85
+ // Find props used in more than maxPropDrillingDepth components
86
+ propUsage.forEach((componentNames, propName) => {
87
+ if (componentNames.length > config.maxPropDrillingDepth) {
88
+ const firstComp = components.find(c => c.name === componentNames[0]);
89
+ smells.push({
90
+ type: 'prop-drilling',
91
+ severity: 'warning',
92
+ message: `Prop "${propName}" is passed through ${componentNames.length} components: ${componentNames.join(' → ')}`,
93
+ file: filePath,
94
+ line: firstComp?.startLine || 1,
95
+ column: 0,
96
+ suggestion: `Move "${propName}" to Context or a state management solution`,
97
+ codeSnippet: firstComp ? getCodeSnippet(sourceCode, firstComp.startLine) : undefined,
98
+ });
99
+ }
100
+ });
101
+
102
+ return smells;
103
+ }
@@ -0,0 +1,117 @@
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
+ export function detectUseEffectOveruse(
6
+ component: ParsedComponent,
7
+ filePath: string,
8
+ sourceCode: string,
9
+ config: DetectorConfig = DEFAULT_CONFIG
10
+ ): CodeSmell[] {
11
+ const smells: CodeSmell[] = [];
12
+ const { useEffect } = component.hooks;
13
+
14
+ // Check for too many useEffects
15
+ if (useEffect.length > config.maxUseEffectsPerComponent) {
16
+ smells.push({
17
+ type: 'useEffect-overuse',
18
+ severity: 'warning',
19
+ message: `Component "${component.name}" has ${useEffect.length} useEffect hooks (max recommended: ${config.maxUseEffectsPerComponent})`,
20
+ file: filePath,
21
+ line: component.startLine,
22
+ column: 0,
23
+ suggestion: 'Consider extracting logic into custom hooks or combining related effects',
24
+ codeSnippet: getCodeSnippet(sourceCode, component.startLine),
25
+ });
26
+ }
27
+
28
+ // Check each useEffect for issues
29
+ useEffect.forEach((effect, index) => {
30
+ const loc = effect.loc;
31
+ if (!loc) return;
32
+
33
+ // Check for empty dependency array with async operations
34
+ const deps = effect.arguments[1];
35
+ const callback = effect.arguments[0];
36
+
37
+ if (t.isArrayExpression(deps) && deps.elements.length === 0) {
38
+ // Empty dependency array - check if it's justified
39
+ let hasAsyncOperation = false;
40
+ let hasStateUpdate = false;
41
+
42
+ if (t.isArrowFunctionExpression(callback) || t.isFunctionExpression(callback)) {
43
+ const body = callback.body;
44
+ const checkNode = (node: t.Node) => {
45
+ if (t.isAwaitExpression(node)) hasAsyncOperation = true;
46
+ if (t.isCallExpression(node)) {
47
+ const callee = node.callee;
48
+ if (t.isIdentifier(callee) && callee.name.startsWith('set')) {
49
+ hasStateUpdate = true;
50
+ }
51
+ }
52
+ };
53
+
54
+ if (t.isBlockStatement(body)) {
55
+ body.body.forEach(stmt => {
56
+ t.traverseFast(stmt, checkNode);
57
+ });
58
+ }
59
+ }
60
+
61
+ if (hasAsyncOperation && hasStateUpdate) {
62
+ smells.push({
63
+ type: 'useEffect-overuse',
64
+ severity: 'info',
65
+ message: `useEffect #${index + 1} in "${component.name}" has empty deps but contains async state updates`,
66
+ file: filePath,
67
+ line: loc.start.line,
68
+ column: loc.start.column,
69
+ suggestion: 'Consider using React Query, SWR, or a custom hook for data fetching',
70
+ codeSnippet: getCodeSnippet(sourceCode, loc.start.line),
71
+ });
72
+ }
73
+ }
74
+
75
+ // Check for missing cleanup
76
+ if (t.isArrowFunctionExpression(callback) || t.isFunctionExpression(callback)) {
77
+ let hasSubscription = false;
78
+ let hasCleanup = false;
79
+
80
+ const checkSubscription = (node: t.Node) => {
81
+ if (t.isCallExpression(node) && t.isMemberExpression(node.callee)) {
82
+ const prop = node.callee.property;
83
+ if (t.isIdentifier(prop)) {
84
+ if (['addEventListener', 'subscribe', 'on', 'setInterval', 'setTimeout'].includes(prop.name)) {
85
+ hasSubscription = true;
86
+ }
87
+ }
88
+ }
89
+ };
90
+
91
+ const body = callback.body;
92
+ if (t.isBlockStatement(body)) {
93
+ body.body.forEach(stmt => {
94
+ t.traverseFast(stmt, checkSubscription);
95
+ if (t.isReturnStatement(stmt) && stmt.argument) {
96
+ hasCleanup = true;
97
+ }
98
+ });
99
+ }
100
+
101
+ if (hasSubscription && !hasCleanup) {
102
+ smells.push({
103
+ type: 'useEffect-overuse',
104
+ severity: 'error',
105
+ message: `useEffect in "${component.name}" sets up subscription but has no cleanup function`,
106
+ file: filePath,
107
+ line: loc.start.line,
108
+ column: loc.start.column,
109
+ suggestion: 'Add a cleanup function to remove event listeners/subscriptions',
110
+ codeSnippet: getCodeSnippet(sourceCode, loc.start.line),
111
+ });
112
+ }
113
+ }
114
+ });
115
+
116
+ return smells;
117
+ }
package/src/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ export { analyzeProject, type AnalyzerOptions } from './analyzer.js';
2
+ export { reportResults, type ReporterOptions } from './reporter.js';
3
+ export * from './types/index.js';
4
+ export { parseFile, parseCode, type ParseResult, type ParsedComponent } from './parser/index.js';
@@ -0,0 +1,195 @@
1
+ import * as parser from '@babel/parser';
2
+ import _traverse, { NodePath } from '@babel/traverse';
3
+ import * as t from '@babel/types';
4
+ import fs from 'fs/promises';
5
+
6
+ // Handle ESM/CJS interop
7
+ const traverse = (_traverse as unknown as { default: typeof _traverse }).default || _traverse;
8
+
9
+ export interface ParsedComponent {
10
+ name: string;
11
+ startLine: number;
12
+ endLine: number;
13
+ node: t.FunctionDeclaration | t.ArrowFunctionExpression | t.FunctionExpression;
14
+ path: NodePath;
15
+ hooks: {
16
+ useEffect: t.CallExpression[];
17
+ useState: t.CallExpression[];
18
+ useMemo: t.CallExpression[];
19
+ useCallback: t.CallExpression[];
20
+ useRef: t.CallExpression[];
21
+ custom: t.CallExpression[];
22
+ };
23
+ props: string[];
24
+ jsxDepth: number;
25
+ }
26
+
27
+ export interface ParseResult {
28
+ ast: t.File;
29
+ components: ParsedComponent[];
30
+ imports: string[];
31
+ sourceCode: string;
32
+ }
33
+
34
+ export async function parseFile(filePath: string): Promise<ParseResult> {
35
+ const sourceCode = await fs.readFile(filePath, 'utf-8');
36
+ return parseCode(sourceCode, filePath);
37
+ }
38
+
39
+ export function parseCode(sourceCode: string, filePath: string = 'unknown'): ParseResult {
40
+ const ast = parser.parse(sourceCode, {
41
+ sourceType: 'module',
42
+ plugins: [
43
+ 'jsx',
44
+ 'typescript',
45
+ 'decorators-legacy',
46
+ 'classProperties',
47
+ 'optionalChaining',
48
+ 'nullishCoalescingOperator',
49
+ ],
50
+ sourceFilename: filePath,
51
+ });
52
+
53
+ const components: ParsedComponent[] = [];
54
+ const imports: string[] = [];
55
+
56
+ traverse(ast, {
57
+ ImportDeclaration(path) {
58
+ imports.push(path.node.source.value);
59
+ },
60
+
61
+ FunctionDeclaration(path) {
62
+ if (isReactComponent(path.node.id?.name, path)) {
63
+ components.push(extractComponentInfo(path.node.id?.name || 'Anonymous', path));
64
+ }
65
+ },
66
+
67
+ VariableDeclarator(path) {
68
+ const init = path.node.init;
69
+ const id = path.node.id;
70
+
71
+ if (
72
+ t.isIdentifier(id) &&
73
+ (t.isArrowFunctionExpression(init) || t.isFunctionExpression(init))
74
+ ) {
75
+ if (isReactComponent(id.name, path)) {
76
+ components.push(extractComponentInfo(id.name, path, init));
77
+ }
78
+ }
79
+ },
80
+ });
81
+
82
+ return { ast, components, imports, sourceCode };
83
+ }
84
+
85
+ function isReactComponent(name: string | undefined, path: NodePath): boolean {
86
+ if (!name) return false;
87
+
88
+ // Component names start with uppercase
89
+ if (!/^[A-Z]/.test(name)) return false;
90
+
91
+ // Check if it returns JSX
92
+ let hasJSX = false;
93
+ path.traverse({
94
+ JSXElement() {
95
+ hasJSX = true;
96
+ },
97
+ JSXFragment() {
98
+ hasJSX = true;
99
+ },
100
+ });
101
+
102
+ return hasJSX;
103
+ }
104
+
105
+ function extractComponentInfo(
106
+ name: string,
107
+ path: NodePath,
108
+ node?: t.ArrowFunctionExpression | t.FunctionExpression
109
+ ): ParsedComponent {
110
+ const actualNode = node || (path.node as t.FunctionDeclaration);
111
+ const loc = actualNode.loc;
112
+
113
+ const hooks = {
114
+ useEffect: [] as t.CallExpression[],
115
+ useState: [] as t.CallExpression[],
116
+ useMemo: [] as t.CallExpression[],
117
+ useCallback: [] as t.CallExpression[],
118
+ useRef: [] as t.CallExpression[],
119
+ custom: [] as t.CallExpression[],
120
+ };
121
+
122
+ const props: string[] = [];
123
+ let jsxDepth = 0;
124
+
125
+ // Extract hooks
126
+ path.traverse({
127
+ CallExpression(callPath) {
128
+ const callee = callPath.node.callee;
129
+ if (t.isIdentifier(callee)) {
130
+ const hookName = callee.name;
131
+ if (hookName === 'useEffect') hooks.useEffect.push(callPath.node);
132
+ else if (hookName === 'useState') hooks.useState.push(callPath.node);
133
+ else if (hookName === 'useMemo') hooks.useMemo.push(callPath.node);
134
+ else if (hookName === 'useCallback') hooks.useCallback.push(callPath.node);
135
+ else if (hookName === 'useRef') hooks.useRef.push(callPath.node);
136
+ else if (hookName.startsWith('use')) hooks.custom.push(callPath.node);
137
+ }
138
+ },
139
+ });
140
+
141
+ // Extract props
142
+ const params = t.isFunctionDeclaration(actualNode)
143
+ ? actualNode.params
144
+ : actualNode.params;
145
+
146
+ if (params.length > 0) {
147
+ const firstParam = params[0];
148
+ if (t.isObjectPattern(firstParam)) {
149
+ firstParam.properties.forEach(prop => {
150
+ if (t.isObjectProperty(prop) && t.isIdentifier(prop.key)) {
151
+ props.push(prop.key.name);
152
+ } else if (t.isRestElement(prop) && t.isIdentifier(prop.argument)) {
153
+ props.push(`...${prop.argument.name}`);
154
+ }
155
+ });
156
+ } else if (t.isIdentifier(firstParam)) {
157
+ props.push(firstParam.name);
158
+ }
159
+ }
160
+
161
+ // Calculate JSX nesting depth
162
+ path.traverse({
163
+ JSXElement: {
164
+ enter() {
165
+ jsxDepth++;
166
+ },
167
+ },
168
+ });
169
+
170
+ return {
171
+ name,
172
+ startLine: loc?.start.line || 0,
173
+ endLine: loc?.end.line || 0,
174
+ node: actualNode as any,
175
+ path,
176
+ hooks,
177
+ props,
178
+ jsxDepth: Math.floor(jsxDepth / 2), // Approximate depth
179
+ };
180
+ }
181
+
182
+ export function getCodeSnippet(sourceCode: string, line: number, context: number = 2): string {
183
+ const lines = sourceCode.split('\n');
184
+ const start = Math.max(0, line - context - 1);
185
+ const end = Math.min(lines.length, line + context);
186
+
187
+ return lines
188
+ .slice(start, end)
189
+ .map((l, i) => {
190
+ const lineNum = start + i + 1;
191
+ const marker = lineNum === line ? '>' : ' ';
192
+ return `${marker} ${lineNum.toString().padStart(4)} | ${l}`;
193
+ })
194
+ .join('\n');
195
+ }