From a86c6b279d05d585817fd7a52a95991021b34a6e Mon Sep 17 00:00:00 2001 From: GRAMMAC Date: Tue, 7 Jul 2026 12:43:14 +0800 Subject: [PATCH] feat: add React diagnosis plugin --- benchmarks/reports/report.md | 12 +- packages/core/src/plugin/registry.ts | 3 +- packages/core/src/plugins/react.ts | 272 ++++++++++++++++++++++++ packages/core/src/plugins/typescript.ts | 36 ++-- packages/core/src/utils/text.ts | 19 ++ test/react-plugin.test.ts | 112 ++++++++++ 6 files changed, 424 insertions(+), 30 deletions(-) create mode 100644 packages/core/src/plugins/react.ts create mode 100644 test/react-plugin.test.ts diff --git a/benchmarks/reports/report.md b/benchmarks/reports/report.md index 7198567..d59b9b0 100644 --- a/benchmarks/reports/report.md +++ b/benchmarks/reports/report.md @@ -2,15 +2,15 @@ - Cases: 7 - Signal matches: 21/26 -- Average reduction: 60.8% -- Average total MCP ratio: 39.2% +- Average reduction: 59.3% +- Average total MCP ratio: 40.7% | Case | Raw KB | Brief KB | Evidence KB | Total MCP KB | Reduction | Tool Calls | Confidence | Signal Hit | File Hit | Code Hit | |---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:| -| dipper-react-module-not-found | 2.1 | 1.3 | 0.0 | 1.3 | 37.8% | 1 | 0.80 | 2/3 | 1/1 | n/a | -| filament-tailwind-vite-build-failure | 4.5 | 1.8 | 0.0 | 1.8 | 60.8% | 1 | 0.80 | 3/3 | 1/1 | n/a | +| dipper-react-module-not-found | 2.1 | 1.4 | 0.0 | 1.4 | 33.2% | 1 | 0.90 | 2/3 | 1/1 | n/a | +| filament-tailwind-vite-build-failure | 4.5 | 1.9 | 0.0 | 1.9 | 57.3% | 1 | 0.90 | 3/3 | 1/1 | n/a | | frappe-hrms-vite-pwa-build-failure | 5.1 | 2.0 | 0.0 | 2.0 | 60.3% | 1 | 0.80 | 4/4 | 1/1 | n/a | -| react-scan-next-build-failure | 5.0 | 1.3 | 0.0 | 1.3 | 74.9% | 1 | 0.90 | 0/4 | 2/2 | n/a | +| react-scan-next-build-failure | 5.0 | 1.4 | 0.0 | 1.4 | 73.0% | 1 | 0.90 | 0/4 | 2/2 | n/a | | satellite-js-vite-build-failure | 13.1 | 3.4 | 0.0 | 3.4 | 73.7% | 1 | 0.90 | 4/4 | 1/1 | 1/1 | | svelte-vite-bindable-build-failure | 1.1 | 0.6 | 0.0 | 0.6 | 47.7% | 1 | 0.80 | 4/4 | 1/1 | 1/1 | -| tanstack-vite-devtools-build-failure | 4.9 | 1.4 | 0.0 | 1.4 | 70.6% | 1 | 0.90 | 4/4 | 1/1 | n/a | +| tanstack-vite-devtools-build-failure | 4.9 | 1.5 | 0.0 | 1.5 | 70.0% | 1 | 0.90 | 4/4 | 1/1 | n/a | diff --git a/packages/core/src/plugin/registry.ts b/packages/core/src/plugin/registry.ts index fb7ce92..f81e257 100644 --- a/packages/core/src/plugin/registry.ts +++ b/packages/core/src/plugin/registry.ts @@ -1,4 +1,5 @@ import { genericPlugin } from '../plugins/generic.js'; +import { reactPlugin } from '../plugins/react.js'; import { typescriptPlugin } from '../plugins/typescript.js'; import type { Error2FixPlugin, PluginRegistry } from '../types/plugin.js'; @@ -16,5 +17,5 @@ export function registerPlugin( } export function getDefaultPluginRegistry(): PluginRegistry { - return createPluginRegistry([typescriptPlugin, genericPlugin]); + return createPluginRegistry([typescriptPlugin, reactPlugin, genericPlugin]); } diff --git a/packages/core/src/plugins/react.ts b/packages/core/src/plugins/react.ts new file mode 100644 index 0000000..4f1c117 --- /dev/null +++ b/packages/core/src/plugins/react.ts @@ -0,0 +1,272 @@ +import type { CoreAnalysisInput } from '../types/core.js'; +import type { Error2FixPlugin } from '../types/plugin.js'; +import { + normalizeDiagnosticMessage, + readAnalysisLogText, + unique, +} from '../utils/text.js'; + +type ReactFailureKind = + | 'hook_rule' + | 'hydration_mismatch' + | 'server_client_boundary' + | 'invalid_component' + | 'jsx_runtime' + | 'next_runtime' + | 'react_runtime'; + +interface ReactDiagnostic { + kind: ReactFailureKind; + message: string; + file?: string; +} + +interface ReactContext { + framework: 'react' | 'next' | 'unknown'; + configFiles: string[]; + componentFiles: string[]; +} + +interface ReactData { + diagnostics: ReactDiagnostic[]; + failureKinds: ReactFailureKind[]; +} + +const REACT_FILE_PATTERN = /\.(tsx|jsx)$/; +const FRONTEND_SOURCE_PATTERN = /\.(ts|tsx|js|jsx|mjs|cjs)$/; +const REACT_CONFIG_PATTERN = + /^(next|vite|vitest|jest|tailwind|postcss)\.config\.(ts|mts|cts|js|mjs|cjs)$/; + +function isProjectSourceFile(file: string): boolean { + return ( + FRONTEND_SOURCE_PATTERN.test(file) && + !/node_modules|\.next|dist|build/.test(file) + ); +} + +function hasReactSignal(input: CoreAnalysisInput): boolean { + const text = readAnalysisLogText(input); + return ( + /\b(?:React|Next\.js|Next|JSX|hydration|Hydration|hook|Hook|Server Component|Client Component)\b/.test( + text, + ) || + /ReactServerComponentsError|Invalid hook call|Objects are not valid as a React child|Element type is invalid/.test( + text, + ) || + (input.signals.relatedFiles.some((file) => REACT_FILE_PATTERN.test(file)) && + /\b(?:render|component|props|hook|hydration|jsx)\b/i.test(text)) || + input.workspace.files.some((file) => file.startsWith('next.config.')) + ); +} + +function detectFramework(input: CoreAnalysisInput): ReactContext['framework'] { + const text = readAnalysisLogText(input); + if ( + /\b(?:Next\.js|next\/|ReactServerComponentsError|Server Component|Client Component)\b/.test( + text, + ) || + input.workspace.files.some((file) => file.startsWith('next.config.')) + ) { + return 'next'; + } + if (/\bReact\b/.test(text)) { + return 'react'; + } + return 'unknown'; +} + +function firstRelatedSourceFile(input: CoreAnalysisInput): string | undefined { + return ( + input.signals.relatedFiles.find((file) => REACT_FILE_PATTERN.test(file)) ?? + input.signals.relatedFiles.find(isProjectSourceFile) + ); +} + +function addDiagnostic( + diagnostics: ReactDiagnostic[], + input: CoreAnalysisInput, + kind: ReactFailureKind, + pattern: RegExp, +): void { + const text = readAnalysisLogText(input); + const match = text.match(pattern); + if (!match) { + return; + } + + diagnostics.push({ + kind, + message: normalizeDiagnosticMessage(match[0]), + file: firstRelatedSourceFile(input), + }); +} + +function extractDiagnostics(input: CoreAnalysisInput): ReactDiagnostic[] { + const diagnostics: ReactDiagnostic[] = []; + + addDiagnostic( + diagnostics, + input, + 'hook_rule', + /(?:Invalid hook call|Hooks can only be called inside[^.\n]*|React Hook [^\n]+(?:is called conditionally|has a missing dependency|cannot be called))/i, + ); + addDiagnostic( + diagnostics, + input, + 'hydration_mismatch', + /(?:Hydration failed[^\n]*|hydration mismatch[^\n]*|Text content did not match[^\n]*|server rendered HTML didn't match[^\n]*)/i, + ); + addDiagnostic( + diagnostics, + input, + 'server_client_boundary', + /(?:(?:You're importing|You are importing)[^\n]+(?:useState|useEffect|useLayoutEffect|useReducer)[^\n]*|needs ["']use client["'][^\n]*|Event handlers cannot be passed to Client Component props[^\n]*|ReactServerComponentsError[^\n]*)/i, + ); + addDiagnostic( + diagnostics, + input, + 'invalid_component', + /(?:Element type is invalid[^\n]*|Objects are not valid as a React child[^\n]*|Functions are not valid as a React child[^\n]*|Minified React error #\d+[^\n]*)/i, + ); + addDiagnostic( + diagnostics, + input, + 'jsx_runtime', + /(?:React is not defined[^\n]*|jsx-runtime[^\n]*|Cannot use JSX[^\n]*|Adjacent JSX elements must be wrapped[^\n]*)/i, + ); + addDiagnostic( + diagnostics, + input, + 'next_runtime', + /(?:next\/(?:font|image|navigation|headers)[^\n]*|Failed to compile[^\n]*Next[^\n]*|Error occurred prerendering page[^\n]*)/i, + ); + + if (diagnostics.length === 0 && hasReactSignal(input)) { + const fallbackLine = readAnalysisLogText(input) + .split(/\r?\n/) + .map((line) => line.trim()) + .find((line) => + /\b(?:React|Next|JSX|component|render|hydration|hook)\b/i.test(line), + ); + diagnostics.push({ + kind: 'react_runtime', + message: normalizeDiagnosticMessage( + fallbackLine ?? 'React-related failure detected.', + ), + file: firstRelatedSourceFile(input), + }); + } + + return unique( + diagnostics.map((diagnostic) => + JSON.stringify({ + kind: diagnostic.kind, + message: diagnostic.message, + file: diagnostic.file, + }), + ), + ) + .map((diagnostic) => JSON.parse(diagnostic) as ReactDiagnostic) + .slice(0, 5); +} + +function formatDiagnostic(diagnostic: ReactDiagnostic): string { + const location = diagnostic.file ? ` in ${diagnostic.file}` : ''; + return `React ${diagnostic.kind.replaceAll('_', ' ')}${location}: ${diagnostic.message}`; +} + +function buildKeySnippet( + input: CoreAnalysisInput, + diagnostics: ReactDiagnostic[], +): string | undefined { + const first = diagnostics[0]; + if (!first) { + return input.signals.snippet; + } + + const lines = readAnalysisLogText(input).split(/\r?\n/); + const diagnosticIndex = lines.findIndex((line) => + line.toLowerCase().includes(first.message.toLowerCase().slice(0, 48)), + ); + if (diagnosticIndex === -1) { + return input.signals.snippet; + } + + return lines + .slice(diagnosticIndex, diagnosticIndex + 4) + .map((line) => line.trimEnd()) + .join('\n') + .trim(); +} + +function buildSuggestions(diagnostics: ReactDiagnostic[]): string[] { + const suggestions = diagnostics.map((diagnostic) => { + switch (diagnostic.kind) { + case 'hook_rule': + return 'Check that hooks run unconditionally inside React function components or custom hooks, not inside callbacks, branches, or module scope.'; + case 'hydration_mismatch': + return 'Compare server-rendered and client-rendered output, especially browser-only state, dates, randomness, locale formatting, and conditional markup.'; + case 'server_client_boundary': + return 'Move interactive React code behind a client component boundary or add the required "use client" directive at the correct component entry.'; + case 'invalid_component': + return 'Check component imports/exports and rendered children; this often comes from default/named import mismatches or rendering plain objects.'; + case 'jsx_runtime': + return 'Check JSX runtime configuration, React import expectations, and framework compiler settings.'; + case 'next_runtime': + return 'Inspect the referenced Next.js route, app/page component, or Next-specific API usage before chasing framework stack frames.'; + default: + return 'Open the first referenced React component and inspect the component boundary, props, and render path.'; + } + }); + + return unique([ + ...suggestions, + 'Prefer the first application component frame over React or Next.js internals.', + ]).slice(0, 5); +} + +export const reactPlugin: Error2FixPlugin = { + meta: { + name: 'builtin-react', + displayName: 'React', + }, + detect(input) { + return hasReactSignal(input); + }, + collectContext(input) { + return { + framework: detectFramework(input), + configFiles: input.workspace.files.filter((file) => + REACT_CONFIG_PATTERN.test(file), + ), + componentFiles: input.signals.relatedFiles + .filter((file) => REACT_FILE_PATTERN.test(file)) + .slice(0, 8), + }; + }, + analyze(input, context) { + const diagnostics = extractDiagnostics(input); + const relatedFiles = unique([ + ...diagnostics.flatMap((diagnostic) => + diagnostic.file ? [diagnostic.file] : [], + ), + ...input.signals.relatedFiles.filter(isProjectSourceFile), + ]).slice(0, 8); + + return { + plugin: 'builtin-react', + matched: true, + summary: diagnostics[0] + ? formatDiagnostic(diagnostics[0]) + : 'React-related failure detected from frontend log evidence.', + keySnippet: buildKeySnippet(input, diagnostics), + relatedFiles, + context, + data: { + diagnostics, + failureKinds: unique(diagnostics.map((diagnostic) => diagnostic.kind)), + }, + suggestions: buildSuggestions(diagnostics), + }; + }, +}; diff --git a/packages/core/src/plugins/typescript.ts b/packages/core/src/plugins/typescript.ts index 32f00b5..caca800 100644 --- a/packages/core/src/plugins/typescript.ts +++ b/packages/core/src/plugins/typescript.ts @@ -1,5 +1,10 @@ import type { CoreAnalysisInput } from '../types/core.js'; import type { Error2FixPlugin } from '../types/plugin.js'; +import { + normalizeDiagnosticMessage, + readAnalysisLogText, + unique, +} from '../utils/text.js'; type TypeScriptFailureKind = | 'module_resolution' @@ -37,34 +42,19 @@ const TS_CODE_PATTERN = /\bTS\d{3,5}\b/; const FRONTEND_TS_CONFIG_PATTERN = /^(vite|next|nuxt|vitest|jest|tailwind|postcss)\.config\.(ts|mts|cts|js|mjs|cjs)$/; -function unique(values: T[]): T[] { - return [...new Set(values)]; -} - -function readLogText(input: CoreAnalysisInput): string { - return [input.capture.stderr, input.capture.stdout, input.signals.snippet] - .filter(Boolean) - .join('\n'); -} - function hasTypeScriptSignal(input: CoreAnalysisInput): boolean { - const text = readLogText(input); + const text = readAnalysisLogText(input); return ( TS_CODE_PATTERN.test(text) || /\b(?:tsc|vue-tsc|tsserver|typescript)\b/i.test(text) || input.signals.keywords.some((keyword) => /^TS\d{3,5}$/.test(keyword)) || - input.signals.relatedFiles.some((file) => TS_FILE_PATTERN.test(file)) || - input.workspace.files.some((file) => file === 'tsconfig.json') + (input.signals.relatedFiles.some((file) => TS_FILE_PATTERN.test(file)) && + /\b(?:type|typescript|tsc|TS\d{3,5})\b/i.test(text)) || + (input.workspace.files.some((file) => file === 'tsconfig.json') && + /\b(?:type|typescript|tsc|TS\d{3,5})\b/i.test(text)) ); } -function normalizeMessage(message: string): string { - return message - .trim() - .replace(/\s+/g, ' ') - .replace(/[.;\s]+$/g, '.'); -} - function classifyDiagnostic( code: string, message: string, @@ -172,7 +162,7 @@ function parseLocation( } function extractDiagnostics(input: CoreAnalysisInput): TypeScriptDiagnostic[] { - const text = readLogText(input); + const text = readAnalysisLogText(input); const diagnostics: TypeScriptDiagnostic[] = []; const seen = new Set(); const diagnosticPattern = @@ -180,7 +170,7 @@ function extractDiagnostics(input: CoreAnalysisInput): TypeScriptDiagnostic[] { for (const match of text.matchAll(diagnosticPattern)) { const code = match.groups?.code.toUpperCase(); - const message = normalizeMessage(match.groups?.message ?? ''); + const message = normalizeDiagnosticMessage(match.groups?.message ?? ''); if (!code || !message) { continue; } @@ -241,7 +231,7 @@ function buildKeySnippet( input: CoreAnalysisInput, diagnostics: TypeScriptDiagnostic[], ): string | undefined { - const text = readLogText(input); + const text = readAnalysisLogText(input); const first = diagnostics[0]; if (!first) { return input.signals.snippet; diff --git a/packages/core/src/utils/text.ts b/packages/core/src/utils/text.ts index 710c057..63cec42 100644 --- a/packages/core/src/utils/text.ts +++ b/packages/core/src/utils/text.ts @@ -1,3 +1,5 @@ +import type { CoreAnalysisInput } from '../types/core.js'; + export function firstNonEmptyLine( text: string | undefined, ): string | undefined { @@ -6,3 +8,20 @@ export function firstNonEmptyLine( .map((line) => line.trim()) .find(Boolean); } + +export function normalizeDiagnosticMessage(message: string): string { + return message + .trim() + .replace(/\s+/g, ' ') + .replace(/[.;\s]+$/g, '.'); +} + +export function readAnalysisLogText(input: CoreAnalysisInput): string { + return [input.capture.stderr, input.capture.stdout, input.signals.snippet] + .filter(Boolean) + .join('\n'); +} + +export function unique(values: T[]): T[] { + return [...new Set(values)]; +} diff --git a/test/react-plugin.test.ts b/test/react-plugin.test.ts new file mode 100644 index 0000000..3c81cfe --- /dev/null +++ b/test/react-plugin.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from 'vitest'; +import { + aggregateCoreAnalysis, + buildCoreAnalysisInput, + getDefaultPluginRegistry, + runPlugins, +} from '../packages/core/src/index.js'; +import type { LatestRawCapture } from '../packages/core/src/types/metadata.js'; + +function makeCapture(stderr: string): LatestRawCapture { + return { + metadata: { + command: 'pnpm build', + exitCode: 1, + cwd: process.cwd(), + shell: 'zsh', + timestamp: '2026-07-07T00:00:00.000Z', + }, + stdout: '', + stderr, + stdoutLogFile: '/tmp/latest.stdout.log', + stderrLogFile: '/tmp/latest.stderr.log', + }; +} + +async function analyzeReact(stderr: string) { + const input = await buildCoreAnalysisInput(makeCapture(stderr)); + const pluginResults = await runPlugins(input, getDefaultPluginRegistry()); + const reactResult = pluginResults.find( + (result) => result.plugin === 'builtin-react', + ); + const typeScriptResult = pluginResults.find( + (result) => result.plugin === 'builtin-typescript', + ); + return { + analysis: aggregateCoreAnalysis(input, pluginResults), + reactResult, + typeScriptResult, + }; +} + +describe('React plugin', () => { + it('detects invalid hook calls without routing runtime errors through TypeScript', async () => { + const { analysis, reactResult, typeScriptResult } = await analyzeReact( + [ + 'Error: Invalid hook call. Hooks can only be called inside of the body of a function component.', + ' at useUser (src/components/UserCard.tsx:12:3)', + ' at renderWithHooks (node_modules/react-dom/cjs/react-dom.development.js:16305:18)', + ].join('\n'), + ); + const data = reactResult?.data as + | { + diagnostics: Array<{ kind: string; file?: string }>; + failureKinds: string[]; + } + | undefined; + + expect(typeScriptResult?.matched).toBe(false); + expect(reactResult?.matched).toBe(true); + expect(analysis.summary).toContain('React hook rule'); + expect(analysis.relatedFiles).toContain('src/components/UserCard.tsx'); + expect(data?.diagnostics[0]).toMatchObject({ + kind: 'hook_rule', + file: 'src/components/UserCard.tsx', + }); + expect(data?.failureKinds).toContain('hook_rule'); + }); + + it('classifies Next hydration mismatches and keeps the app route file', async () => { + const { analysis, reactResult } = await analyzeReact( + [ + "Error: Hydration failed because the server rendered HTML didn't match the client.", + ' at Home (src/app/page.tsx:8:5)', + ].join('\n'), + ); + const data = reactResult?.data as + | { + diagnostics: Array<{ kind: string; file?: string }>; + } + | undefined; + + expect(reactResult?.matched).toBe(true); + expect(analysis.summary).toContain('hydration mismatch'); + expect(analysis.relatedFiles).toContain('src/app/page.tsx'); + expect(data?.diagnostics[0]).toMatchObject({ + kind: 'hydration_mismatch', + file: 'src/app/page.tsx', + }); + }); + + it('identifies Next server/client component boundary failures', async () => { + const { analysis, reactResult } = await analyzeReact( + [ + 'ReactServerComponentsError: You\'re importing a component that needs useState. It only works in a Client Component but none of its parents are marked with "use client".', + './src/app/page.tsx', + ].join('\n'), + ); + const data = reactResult?.data as + | { + diagnostics: Array<{ kind: string }>; + } + | undefined; + + expect(reactResult?.matched).toBe(true); + expect(analysis.summary).toContain('server client boundary'); + expect(analysis.relatedFiles).toContain('./src/app/page.tsx'); + expect(data?.diagnostics[0]).toMatchObject({ + kind: 'server_client_boundary', + }); + expect(reactResult?.suggestions?.join(' ')).toContain('use client'); + }); +});