Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions benchmarks/reports/report.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
3 changes: 2 additions & 1 deletion packages/core/src/plugin/registry.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -16,5 +17,5 @@ export function registerPlugin(
}

export function getDefaultPluginRegistry(): PluginRegistry {
return createPluginRegistry([typescriptPlugin, genericPlugin]);
return createPluginRegistry([typescriptPlugin, reactPlugin, genericPlugin]);
}
272 changes: 272 additions & 0 deletions packages/core/src/plugins/react.ts
Original file line number Diff line number Diff line change
@@ -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<ReactContext, ReactData> = {
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),
};
},
};
36 changes: 13 additions & 23 deletions packages/core/src/plugins/typescript.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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<T>(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,
Expand Down Expand Up @@ -172,15 +162,15 @@ function parseLocation(
}

function extractDiagnostics(input: CoreAnalysisInput): TypeScriptDiagnostic[] {
const text = readLogText(input);
const text = readAnalysisLogText(input);
const diagnostics: TypeScriptDiagnostic[] = [];
const seen = new Set<string>();
const diagnosticPattern =
/(?:(?<location>(?:\.{0,2}\/)?(?:[\w@%+=:,.-]+\/)*[\w@%+=,.-]+\.(?:ts|tsx|mts|cts|vue)(?::\d+:\d+|\(\d+,\d+\)))\s*(?:-|:)?\s*)?(?:error\s+)?(?<code>TS\d{3,5}):\s*(?<message>[^\n]+)/gi;

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;
}
Expand Down Expand Up @@ -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;
Expand Down
Loading