107 lines
5.3 KiB
JavaScript
107 lines
5.3 KiB
JavaScript
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
|