Files
2026-07-31 13:12:54 -04:00

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