From 36f960d14464bd147fe6e75b41186323a071140b Mon Sep 17 00:00:00 2001 From: GRAMMAC Date: Tue, 7 Jul 2026 13:48:11 +0800 Subject: [PATCH] refactor: use rule-based frontend diagnostics --- benchmarks/reports/report.md | 18 +- packages/core/src/analysis/aggregate.ts | 89 +++-- packages/core/src/diagnosis/evidence.ts | 80 ++++ packages/core/src/diagnosis/rank.ts | 111 ++++++ packages/core/src/index.ts | 9 + packages/core/src/plugin/registry.ts | 12 +- .../core/src/plugins/dependency-resolution.ts | 125 +++++++ .../core/src/plugins/frontend-compiler.ts | 301 +++++++++++++++ packages/core/src/plugins/frontend-runtime.ts | 172 +++++++++ packages/core/src/plugins/generic.ts | 31 +- packages/core/src/plugins/react.ts | 272 -------------- packages/core/src/plugins/rule-utils.ts | 115 ++++++ packages/core/src/plugins/typescript.ts | 348 ------------------ packages/core/src/plugins/vue.ts | 300 --------------- test/plugin-runner.test.ts | 34 +- test/react-plugin.test.ts | 55 ++- test/typescript-plugin.test.ts | 65 ++-- test/vue-plugin.test.ts | 87 +++-- 18 files changed, 1138 insertions(+), 1086 deletions(-) create mode 100644 packages/core/src/diagnosis/evidence.ts create mode 100644 packages/core/src/diagnosis/rank.ts create mode 100644 packages/core/src/plugins/dependency-resolution.ts create mode 100644 packages/core/src/plugins/frontend-compiler.ts create mode 100644 packages/core/src/plugins/frontend-runtime.ts delete mode 100644 packages/core/src/plugins/react.ts create mode 100644 packages/core/src/plugins/rule-utils.ts delete mode 100644 packages/core/src/plugins/typescript.ts delete mode 100644 packages/core/src/plugins/vue.ts diff --git a/benchmarks/reports/report.md b/benchmarks/reports/report.md index d59b9b0..1a189dc 100644 --- a/benchmarks/reports/report.md +++ b/benchmarks/reports/report.md @@ -2,15 +2,15 @@ - Cases: 7 - Signal matches: 21/26 -- Average reduction: 59.3% -- Average total MCP ratio: 40.7% +- Average reduction: 56.9% +- Average total MCP ratio: 43.1% | 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.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.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.5 | 0.0 | 1.5 | 70.0% | 1 | 0.90 | 4/4 | 1/1 | n/a | +| dipper-react-module-not-found | 2.1 | 1.6 | 0.0 | 1.6 | 22.8% | 1 | 0.90 | 2/3 | 1/1 | n/a | +| filament-tailwind-vite-build-failure | 4.5 | 1.9 | 0.0 | 1.9 | 58.8% | 1 | 0.80 | 3/3 | 1/1 | n/a | +| frappe-hrms-vite-pwa-build-failure | 5.1 | 2.1 | 0.0 | 2.1 | 59.0% | 1 | 0.80 | 4/4 | 1/1 | n/a | +| react-scan-next-build-failure | 5.0 | 1.2 | 0.0 | 1.2 | 75.4% | 1 | 0.80 | 0/4 | 2/2 | n/a | +| satellite-js-vite-build-failure | 13.1 | 3.5 | 0.0 | 3.5 | 73.0% | 1 | 0.80 | 4/4 | 1/1 | 1/1 | +| svelte-vite-bindable-build-failure | 1.1 | 0.7 | 0.0 | 0.7 | 39.8% | 1 | 0.80 | 4/4 | 1/1 | 1/1 | +| tanstack-vite-devtools-build-failure | 4.9 | 1.5 | 0.0 | 1.5 | 69.6% | 1 | 0.80 | 4/4 | 1/1 | n/a | diff --git a/packages/core/src/analysis/aggregate.ts b/packages/core/src/analysis/aggregate.ts index 2087622..396ff7d 100644 --- a/packages/core/src/analysis/aggregate.ts +++ b/packages/core/src/analysis/aggregate.ts @@ -1,23 +1,29 @@ +import { + type FailureEvidence, + formatEvidenceSummary, + hasFailureEvidenceData, +} from '../diagnosis/evidence.js'; +import { rankEvidence, reduceEvidence } from '../diagnosis/rank.js'; import type { CoreAnalysis, CoreAnalysisInput } from '../types/core.js'; import type { PluginResult } from '../types/plugin.js'; import { firstNonEmptyLine, uniqueNonEmptyStrings } from '../utils/text.js'; -function pickLeadResult( +function collectEvidence( pluginResults: PluginResult[], -): PluginResult | undefined { - return ( - pluginResults.find( - (result) => result.matched && result.plugin !== 'builtin-generic', - ) ?? pluginResults.find((result) => result.matched) - ); +): FailureEvidence[] { + return pluginResults + .filter((result) => result.matched && hasFailureEvidenceData(result.data)) + .flatMap((result) => + hasFailureEvidenceData(result.data) ? result.data.evidences : [], + ); } function buildSummary( input: CoreAnalysisInput, - leadResult: PluginResult | undefined, + leadEvidence: FailureEvidence | undefined, ): string { return ( - leadResult?.summary ?? + (leadEvidence ? formatEvidenceSummary(leadEvidence) : undefined) ?? firstNonEmptyLine(input.signals.snippet) ?? firstNonEmptyLine(input.capture.stderr) ?? firstNonEmptyLine(input.capture.stdout) ?? @@ -27,12 +33,12 @@ function buildSummary( function buildLikelyCauses( input: CoreAnalysisInput, - pluginResults: PluginResult[], + rankedEvidence: FailureEvidence[], ): string[] { - const causes = pluginResults - .filter((result) => result.matched) - .map((result) => result.summary ?? '') - .filter(Boolean); + const causes = rankedEvidence + .filter((evidence) => evidence.source !== 'generic') + .slice(0, 3) + .map(formatEvidenceSummary); if (input.signals.keywords.length > 0) { causes.push( @@ -51,42 +57,57 @@ function buildLikelyCauses( function buildNextSteps( input: CoreAnalysisInput, - pluginResults: PluginResult[], + rankedEvidence: FailureEvidence[], ): string[] { - const pluginSuggestions = pluginResults.flatMap( - (result) => result.suggestions ?? [], - ); - + const leadEvidence = rankedEvidence[0]; + const firstFile = leadEvidence?.file ?? input.signals.relatedFiles[0]; const genericSteps = [ - input.signals.relatedFiles[0] - ? `Inspect ${input.signals.relatedFiles[0]} first.` + firstFile + ? `Inspect ${firstFile} first.` : 'Inspect the first high-signal error line in stderr.', - input.signals.stackLines.length > 0 - ? 'Trace the top stack frame back to the application code path.' - : 'Re-run the command with more verbose logging if the failure is still ambiguous.', ]; - return uniqueNonEmptyStrings([...pluginSuggestions, ...genericSteps]).slice( - 0, - 6, - ); + if (leadEvidence?.source === 'dependency') { + genericSteps.push( + 'Check dependency installation, package exports, module format, and workspace aliases before chasing framework stack frames.', + ); + } else if (leadEvidence?.source === 'runtime') { + genericSteps.push( + 'Start from the first application component frame rather than framework internals.', + ); + } else if (leadEvidence?.source === 'compiler') { + genericSteps.push( + 'Fix the earliest precise compiler diagnostic before chasing later cascade errors.', + ); + } else if (input.signals.stackLines.length > 0) { + genericSteps.push( + 'Trace the top stack frame back to the application code path.', + ); + } + + return uniqueNonEmptyStrings(genericSteps).slice(0, 4); } export function aggregateCoreAnalysis( input: CoreAnalysisInput, pluginResults: PluginResult[], ): CoreAnalysis { - const leadResult = pickLeadResult(pluginResults); + const rankedEvidence = rankEvidence( + reduceEvidence(collectEvidence(pluginResults)), + ); + const leadEvidence = rankedEvidence[0]; return { host: input.capture.host, - summary: buildSummary(input, leadResult), - keySnippet: leadResult?.keySnippet ?? input.signals.snippet, - likelyCauses: buildLikelyCauses(input, pluginResults), - nextSteps: buildNextSteps(input, pluginResults), + summary: buildSummary(input, leadEvidence), + keySnippet: leadEvidence?.snippet ?? input.signals.snippet, + likelyCauses: buildLikelyCauses(input, rankedEvidence), + nextSteps: buildNextSteps(input, rankedEvidence), relatedFiles: uniqueNonEmptyStrings([ + ...rankedEvidence.flatMap((evidence) => + evidence.file ? [evidence.file] : [], + ), ...input.signals.relatedFiles, - ...pluginResults.flatMap((result) => result.relatedFiles ?? []), ]).slice(0, 10), pluginResults, }; diff --git a/packages/core/src/diagnosis/evidence.ts b/packages/core/src/diagnosis/evidence.ts new file mode 100644 index 0000000..52e8590 --- /dev/null +++ b/packages/core/src/diagnosis/evidence.ts @@ -0,0 +1,80 @@ +export type FailureEvidenceSource = + | 'compiler' + | 'runtime' + | 'dependency' + | 'generic'; + +export interface FailureEvidence { + id: string; + ruleId: string; + source: FailureEvidenceSource; + category: string; + framework?: string; + message: string; + file?: string; + line?: number; + column?: number; + rawLine?: number; + confidence: number; + priority: number; + snippet?: string; +} + +export interface FailureEvidencePluginData { + evidences: FailureEvidence[]; +} + +export function hasFailureEvidenceData( + data: unknown, +): data is FailureEvidencePluginData { + return ( + typeof data === 'object' && + data !== null && + Array.isArray((data as { evidences?: unknown }).evidences) + ); +} + +export function formatEvidenceLocation( + evidence: Pick, +): string | undefined { + if (!evidence.file) { + return undefined; + } + if (evidence.line && evidence.column) { + return `${evidence.file}:${evidence.line}:${evidence.column}`; + } + if (evidence.line) { + return `${evidence.file}:${evidence.line}`; + } + return evidence.file; +} + +export function formatEvidenceSummary(evidence: FailureEvidence): string { + const source = formatEvidenceLabel(evidence.framework ?? evidence.source); + const category = evidence.category.replaceAll('_', ' '); + const location = formatEvidenceLocation(evidence); + const prefix = location + ? `${source} ${category} in ${location}` + : `${source} ${category}`; + + return `${prefix}: ${evidence.message}`; +} + +function formatEvidenceLabel(label: string): string { + switch (label.toLowerCase()) { + case 'typescript': + return 'TypeScript'; + case 'react': + return 'React'; + case 'vue': + return 'Vue'; + case 'svelte': + return 'Svelte'; + case 'next': + return 'Next'; + case 'nuxt': + return 'Nuxt'; + default: + return label; + } +} diff --git a/packages/core/src/diagnosis/rank.ts b/packages/core/src/diagnosis/rank.ts new file mode 100644 index 0000000..95d6e05 --- /dev/null +++ b/packages/core/src/diagnosis/rank.ts @@ -0,0 +1,111 @@ +import type { FailureEvidence } from './evidence.js'; + +function isApplicationFile(file: string | undefined): boolean { + return Boolean(file && !/node_modules|dist|build|\.next|\.nuxt/.test(file)); +} + +function hasPreciseLocation(evidence: FailureEvidence): boolean { + return Boolean(evidence.file && evidence.line); +} + +function scoreEvidence(evidence: FailureEvidence): number { + let score = evidence.priority + evidence.confidence; + + if (hasPreciseLocation(evidence)) { + score += 12; + } else if (evidence.file) { + score += 6; + } + + if (isApplicationFile(evidence.file)) { + score += 8; + } + + if (/\b(?:TS\d{3,5}|ERR_[A-Z_]+)\b/.test(evidence.message)) { + score += 8; + } + + if ( + /node_modules|webpack|rollup|vite|esbuild|tsx|tsc/i.test( + evidence.file ?? '', + ) + ) { + score -= 8; + } + + if (evidence.category === 'build_wrapper') { + score -= 12; + } + + if (evidence.source === 'generic') { + score -= 40; + } + + return score; +} + +function evidenceKey(evidence: FailureEvidence): string { + return [ + evidence.ruleId, + evidence.file ?? '', + evidence.line ?? '', + evidence.column ?? '', + evidence.message.toLowerCase(), + ].join('|'); +} + +function overlapKey(evidence: FailureEvidence): string { + return [ + evidence.file ?? '', + evidence.line ?? '', + evidence.message.toLowerCase().slice(0, 120), + ].join('|'); +} + +export function reduceEvidence( + evidences: FailureEvidence[], +): FailureEvidence[] { + const exact = new Map(); + + for (const evidence of evidences) { + const key = evidenceKey(evidence); + const existing = exact.get(key); + if (!existing || scoreEvidence(evidence) > scoreEvidence(existing)) { + exact.set(key, evidence); + } + } + + const overlapped = new Map(); + for (const evidence of exact.values()) { + const key = overlapKey(evidence); + const existing = overlapped.get(key); + if (!existing || scoreEvidence(evidence) > scoreEvidence(existing)) { + overlapped.set(key, evidence); + } + } + + return [...overlapped.values()]; +} + +export function rankEvidence(evidences: FailureEvidence[]): FailureEvidence[] { + return [...evidences].sort((left, right) => { + const scoreDelta = scoreEvidence(right) - scoreEvidence(left); + if (scoreDelta !== 0) { + return scoreDelta; + } + + const priorityDelta = right.priority - left.priority; + if (priorityDelta !== 0) { + return priorityDelta; + } + + const rawLineDelta = + (left.rawLine ?? Number.MAX_SAFE_INTEGER) - + (right.rawLine ?? Number.MAX_SAFE_INTEGER); + if (rawLineDelta !== 0) { + return rawLineDelta; + } + + return left.ruleId.localeCompare(right.ruleId); + }); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 88483e1..42a13eb 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -26,6 +26,15 @@ export type { export { aggregateCoreAnalysis } from './analysis/aggregate.js'; export { buildCoreAnalysisInput } from './analysis/build-input.js'; export { diagnoseCapture } from './analysis/diagnose.js'; +export { + formatEvidenceLocation, + formatEvidenceSummary, + hasFailureEvidenceData, + type FailureEvidence, + type FailureEvidencePluginData, + type FailureEvidenceSource, +} from './diagnosis/evidence.js'; +export { rankEvidence, reduceEvidence } from './diagnosis/rank.js'; export { extractSignals, normalizeLogs, diff --git a/packages/core/src/plugin/registry.ts b/packages/core/src/plugin/registry.ts index e4cabee..64b1f20 100644 --- a/packages/core/src/plugin/registry.ts +++ b/packages/core/src/plugin/registry.ts @@ -1,7 +1,7 @@ +import { dependencyResolutionPlugin } from '../plugins/dependency-resolution.js'; +import { frontendCompilerPlugin } from '../plugins/frontend-compiler.js'; +import { frontendRuntimePlugin } from '../plugins/frontend-runtime.js'; import { genericPlugin } from '../plugins/generic.js'; -import { reactPlugin } from '../plugins/react.js'; -import { typescriptPlugin } from '../plugins/typescript.js'; -import { vuePlugin } from '../plugins/vue.js'; import type { Error2FixPlugin, PluginRegistry } from '../types/plugin.js'; export function createPluginRegistry( @@ -19,9 +19,9 @@ export function registerPlugin( export function getDefaultPluginRegistry(): PluginRegistry { return createPluginRegistry([ - typescriptPlugin, - reactPlugin, - vuePlugin, + frontendCompilerPlugin, + frontendRuntimePlugin, + dependencyResolutionPlugin, genericPlugin, ]); } diff --git a/packages/core/src/plugins/dependency-resolution.ts b/packages/core/src/plugins/dependency-resolution.ts new file mode 100644 index 0000000..35ad5a4 --- /dev/null +++ b/packages/core/src/plugins/dependency-resolution.ts @@ -0,0 +1,125 @@ +import type { FailureEvidence } from '../diagnosis/evidence.js'; +import type { CoreAnalysisInput } from '../types/core.js'; +import type { Error2FixPlugin } from '../types/plugin.js'; +import { readAnalysisLogText, unique } from '../utils/text.js'; +import { type RegexRule, matchRegexRules } from './rule-utils.js'; + +interface DependencyResolutionContext { + packageManagerFiles: string[]; +} + +interface DependencyResolutionData { + evidences: FailureEvidence[]; +} + +const PACKAGE_MANAGER_FILES = new Set([ + 'package.json', + 'pnpm-lock.yaml', + 'package-lock.json', + 'yarn.lock', +]); + +const DEPENDENCY_RULES: RegexRule[] = [ + { + id: 'dependency.module-not-found', + source: 'dependency', + category: 'module_resolution', + pattern: + /(?:Cannot find module ['"][^'"]+['"][^\n]*|Module not found: Error: Can't resolve ['"][^'"]+['"][^\n]*|Could not resolve ['"][^'"]+['"][^\n]*)/gi, + priority: 92, + confidence: 84, + }, + { + id: 'dependency.missing-type-declaration', + source: 'dependency', + category: 'type_declaration', + pattern: + /(?:Could not find a declaration file for module ['"][^'"]+['"][^\n]*|Cannot find type definition file for ['"][^'"]+['"][^\n]*|or its corresponding type declarations[^\n]*)/gi, + priority: 88, + confidence: 80, + }, + { + id: 'dependency.package-exports', + source: 'dependency', + category: 'package_exports', + pattern: + /(?:Package subpath ['"][^'"]+['"] is not defined by "exports"[^\n]*|No "exports" main defined[^\n]*|ERR_PACKAGE_PATH_NOT_EXPORTED[^\n]*)/gi, + priority: 90, + confidence: 82, + }, + { + id: 'dependency.esm-cjs', + source: 'dependency', + category: 'module_format', + pattern: + /(?:ERR_REQUIRE_ESM[^\n]*|require\(\) of ES Module[^\n]*|Cannot use import statement outside a module[^\n]*|Named export ['"][^'"]+['"] not found[^\n]*)/gi, + priority: 86, + confidence: 78, + }, + { + id: 'dependency.peer-deps', + source: 'dependency', + category: 'peer_dependency', + pattern: + /(?:unmet peer dependency[^\n]*|ERESOLVE unable to resolve dependency tree[^\n]*|Conflicting peer dependency[^\n]*|peer [^\n]* missing[^\n]*)/gi, + priority: 84, + confidence: 74, + }, + { + id: 'dependency.workspace-path', + source: 'dependency', + category: 'workspace_resolution', + pattern: + /(?:workspace:[^\n]*(?:not found|cannot|failed)[^\n]*|No matching version found for [^\n]*workspace[^\n]*|Cannot find package ['"][^'"]+['"][^\n]*)/gi, + priority: 82, + confidence: 72, + }, +]; + +function hasDependencySignal(input: CoreAnalysisInput): boolean { + const text = readAnalysisLogText(input); + return /(?:Cannot find module|Module not found|Could not resolve|ERR_PACKAGE_PATH_NOT_EXPORTED|ERR_REQUIRE_ESM|peer dependency|ERESOLVE|workspace:)/i.test( + text, + ); +} + +export const dependencyResolutionPlugin: Error2FixPlugin< + DependencyResolutionContext, + DependencyResolutionData +> = { + meta: { + name: 'dependency-resolution', + displayName: 'Dependency Resolution Diagnostics', + }, + detect(input) { + return hasDependencySignal(input); + }, + collectContext(input) { + return { + packageManagerFiles: input.workspace.files.filter((file) => + PACKAGE_MANAGER_FILES.has(file), + ), + }; + }, + analyze(input, context) { + const evidences = matchRegexRules(input, DEPENDENCY_RULES).slice(0, 10); + const lead = evidences[0]; + + return { + plugin: 'dependency-resolution', + matched: evidences.length > 0, + summary: lead?.message, + keySnippet: lead?.snippet ?? input.signals.snippet, + relatedFiles: unique([ + ...evidences.flatMap((evidence) => + evidence.file ? [evidence.file] : [], + ), + ...input.signals.relatedFiles, + ]).slice(0, 8), + context, + data: { + evidences, + }, + }; + }, +}; diff --git a/packages/core/src/plugins/frontend-compiler.ts b/packages/core/src/plugins/frontend-compiler.ts new file mode 100644 index 0000000..6df1002 --- /dev/null +++ b/packages/core/src/plugins/frontend-compiler.ts @@ -0,0 +1,301 @@ +import type { FailureEvidence } from '../diagnosis/evidence.js'; +import type { CoreAnalysisInput } from '../types/core.js'; +import type { Error2FixPlugin } from '../types/plugin.js'; +import { + normalizeDiagnosticMessage, + readAnalysisLogText, + unique, +} from '../utils/text.js'; +import { + type RegexRule, + firstRelatedSourceFile, + lineNumberForIndex, + matchRegexRules, + snippetFromLine, +} from './rule-utils.js'; + +interface FrontendCompilerContext { + configFiles: string[]; +} + +interface FrontendCompilerData { + evidences: FailureEvidence[]; +} + +const TS_DIAGNOSTIC_PATTERN = + /(?:(?(?:\.{0,2}\/)?(?:[\w@%+=:,.-]+\/)*[\w@%+=,.-]+\.(?:ts|tsx|mts|cts|vue|svelte)(?::\d+:\d+|\(\d+,\d+\)))\s*(?:-|:)?\s*)?(?:error\s+)?(?TS\d{3,5}):\s*(?[^\n]+)/gi; + +const FRONTEND_CONFIG_PATTERN = + /^(tsconfig(?:\..+)?\.json|(?:vite|next|nuxt|vue|svelte|astro|vitest|jest|tailwind|postcss)\.config\.(ts|mts|cts|js|mjs|cjs))$/; + +const COMPILER_RULES: RegexRule[] = [ + { + id: 'vue.sfc.compile', + source: 'compiler', + category: 'sfc_compile', + framework: 'vue', + pattern: + /(?:\[plugin:vite:vue\][^\n]*|@vue\/compiler-sfc[^\n]*|VueCompilerError:[^\n]*|