import fs from 'node:fs'; import path from 'node:path'; import { z } from 'zod'; import { errorResult, jsonResult, toErrorMessage } from '../shared.js'; import { requireReact } from './requireReact.js'; export function registerAnalyzeReactComponentTool(server) { server.registerTool('analyze_react_component', { title: 'Analyze React Component', description: 'Static scan of a React component file for common issues: missing `key` in lists, hook-rule violations, unused props, and Next.js App Router heuristics (hooks/events without "use client").', inputSchema: { file: z .string() .min(1) .describe('Path to the component file, relative to the React project root.'), }, }, async ({ file }) => { try { const react = requireReact(); const absolutePath = path.resolve(react.root, file); if (!absolutePath.startsWith(react.root + path.sep)) { return errorResult('Refusing to analyze a file outside the React project root.'); } if (!fs.existsSync(absolutePath)) { return errorResult(`File not found: ${file}`); } const source = fs.readFileSync(absolutePath, 'utf8'); const issues = []; const lines = source.split('\n'); const hasUseClient = /^\s*['"]use client['"]\s*;?/m.test(source); const usesClientOnlyApis = /\bon[A-Z][A-Za-z]+\s*=/.test(source) || /\buse(State|Effect|Reducer|Ref|LayoutEffect|Callback|Memo|Context)\s*\(/.test(source); if (!hasUseClient && usesClientOnlyApis) { issues.push({ type: 'missing-use-client', line: 1, message: 'File uses client-only APIs (hooks or event handlers) but is missing a "use client" directive. ' + 'Required for Next.js App Router Client Components.', }); } for (let i = 0; i < lines.length; i++) { const line = lines[i] ?? ''; const lineNumber = i + 1; // Missing key in array maps (heuristic). if (/(\w+\s*\.\s*map|React\.Children\.map)\s*\(/.test(line) && !/key\s*=/.test(line)) { issues.push({ type: 'missing-key', line: lineNumber, message: 'Array.map without a key prop on the returned element.', }); } // Conditional or loop hook calls (simple heuristics). if (/\buse[A-Z][A-Za-z0-9]*\s*\(/.test(line)) { if (/\b(if|while|for|switch)\s*\(/.test(line)) { issues.push({ type: 'rules-of-hooks', line: lineNumber, message: 'Hook call inside a conditional or loop block.', }); } } // Functions / non-serializable values passed into imported components from a Server Component. if (!hasUseClient && /<(?:[A-Z][\w.]*)\b[^>]*\{[^}]*=>/.test(line)) { issues.push({ type: 'server-to-client-props', line: lineNumber, message: 'Looks like a function/arrow is passed as a prop from a Server Component. ' + 'Move interactive children into a Client Component ("use client").', }); } } // Unused props: find destructured props and see if they are referenced. const propDestructuring = source.match(/function\s+\w+\s*\(\s*\{\s*([^}]+)\s*\}\s*\)/); if (propDestructuring && propDestructuring[1]) { const rawProps = propDestructuring[1]; const props = rawProps .split(',') .map((p) => p.trim().split(/\s*:\s*/)[0]?.replace(/\?|:.*$/, '').trim() ?? '') .filter(Boolean); const destructuringIndex = propDestructuring.index ?? 0; for (const prop of props) { const propRegex = new RegExp(`\\b${prop}\\b`, 'g'); const matches = [...source.matchAll(propRegex)].length; // The declaration itself counts as one match, plus the original destructuring pattern may add another. if (matches <= 2) { issues.push({ type: 'unused-prop', line: source.substring(0, destructuringIndex).split('\n').length, message: `Prop "${prop}" appears unused in the component body.`, }); } } } return jsonResult({ file, projectRoot: react.root, hasUseClient, issues, issueCount: issues.length, note: 'This is a heuristic static scan, not a full ESLint run. Use run_lint for comprehensive checks.', }); } catch (error) { return errorResult(toErrorMessage(error)); } }); } //# sourceMappingURL=analyzeComponent.js.map