From 8ba065b64ad0867d52b793e906a699c1a9ef80db Mon Sep 17 00:00:00 2001 From: Nisarg Patel Date: Tue, 23 Jun 2026 16:27:10 -0700 Subject: [PATCH] feat: add interactive rule triage Co-authored-by: Cursor --- .changeset/triage-rule-prompts.md | 5 + packages/core/src/types/index.ts | 7 +- packages/core/src/types/prompts.ts | 13 + .../react-doctor/src/cli/commands/triage.ts | 376 ++++++++++++++++++ packages/react-doctor/src/cli/index.ts | 71 ++++ .../src/cli/utils/build-triage-rule-prompt.ts | 77 ++++ .../react-doctor/src/cli/utils/constants.ts | 6 + .../utils/ensure-react-doctor-gitignore.ts | 25 ++ .../src/cli/utils/handoff-to-agent.ts | 33 ++ .../src/cli/utils/install-react-doctor.ts | 3 + .../react-doctor/src/cli/utils/prompts.ts | 36 +- .../src/cli/utils/run-triage-loop.ts | 220 ++++++++++ .../src/cli/utils/strip-unknown-cli-flags.ts | 33 ++ .../src/cli/utils/triage-state.ts | 95 +++++ .../cli/utils/write-diagnostics-directory.ts | 2 +- .../react-doctor/tests/triage-utils.test.ts | 85 ++++ packages/website/src/components/terminal.tsx | 5 + 17 files changed, 1089 insertions(+), 3 deletions(-) create mode 100644 .changeset/triage-rule-prompts.md create mode 100644 packages/react-doctor/src/cli/commands/triage.ts create mode 100644 packages/react-doctor/src/cli/utils/build-triage-rule-prompt.ts create mode 100644 packages/react-doctor/src/cli/utils/ensure-react-doctor-gitignore.ts create mode 100644 packages/react-doctor/src/cli/utils/run-triage-loop.ts create mode 100644 packages/react-doctor/src/cli/utils/triage-state.ts create mode 100644 packages/react-doctor/tests/triage-utils.test.ts diff --git a/.changeset/triage-rule-prompts.md b/.changeset/triage-rule-prompts.md new file mode 100644 index 000000000..6d8f7b44f --- /dev/null +++ b/.changeset/triage-rule-prompts.md @@ -0,0 +1,5 @@ +--- +"react-doctor": minor +--- + +Add `react-doctor triage`, an interactive rule-by-rule fixing flow that scans the project, writes local diagnostics to `.react-doctor/triage`, and lets developers copy a focused prompt, skip a rule, or disable it in `doctor.config`. diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts index 5e17c9b8c..c3e677f4d 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -46,7 +46,12 @@ export type { ProjectInfo, WorkspacePackage, } from "./project-info.js"; -export type { PromptMultiselectChoiceState, PromptMultiselectContext } from "./prompts.js"; +export type { + PromptMultiselectChoiceState, + PromptMultiselectContext, + PromptSelectChoiceState, + PromptSelectContext, +} from "./prompts.js"; // `isReactNativeDependencyName` / `REACT_NATIVE_DEPENDENCY_NAMES` // are intentionally NOT re-exported here — re-exporting from // `oxlint-plugin-react-doctor` would force every consumer of the diff --git a/packages/core/src/types/prompts.ts b/packages/core/src/types/prompts.ts index ac18d3555..ddcabc13e 100644 --- a/packages/core/src/types/prompts.ts +++ b/packages/core/src/types/prompts.ts @@ -10,3 +10,16 @@ export interface PromptMultiselectContext { bell: () => void; render: () => void; } + +export interface PromptSelectChoiceState { + title: string; + disabled?: boolean; +} + +export interface PromptSelectContext { + choices: PromptSelectChoiceState[]; + cursor: number; + bell: () => void; + moveCursor: (cursor: number) => void; + submit: () => void; +} diff --git a/packages/react-doctor/src/cli/commands/triage.ts b/packages/react-doctor/src/cli/commands/triage.ts new file mode 100644 index 000000000..64d838c55 --- /dev/null +++ b/packages/react-doctor/src/cli/commands/triage.ts @@ -0,0 +1,376 @@ +import { tmpdir } from "node:os"; +import * as path from "node:path"; +import * as fs from "node:fs"; +import { + DEFAULT_PROJECT_SCAN_CONCURRENCY, + getChangedLineRanges, + getDiffInfo, + highlighter, + mapWithConcurrency, + mergeReactDoctorConfigs, + resolveScanTarget, + toRelativePath, +} from "@react-doctor/core"; +import type { Diagnostic, DiffInfo, InspectResult, ReactDoctorConfig } from "@react-doctor/core"; +import { inspect } from "../../inspect.js"; +import { cliLogger as logger } from "../utils/cli-logger.js"; +import { METRIC, STAGED_FILES_TEMP_DIR_PREFIX } from "../utils/constants.js"; +import { ensureReactDoctorGitignore } from "../utils/ensure-react-doctor-gitignore.js"; +import { filterDiagnosticsByCategories } from "../utils/filter-diagnostics-by-categories.js"; +import { filterScansForSurface } from "../utils/filter-scans-for-surface.js"; +import { getStagedSourceFiles, materializeStagedFiles } from "../utils/get-staged-files.js"; +import { handleError, handleUserError } from "../utils/handle-error.js"; +import { isCiOrCodingAgentEnvironment } from "../utils/is-ci-environment.js"; +import { isExpectedUserError } from "../utils/is-expected-user-error.js"; +import type { InspectFlags } from "../utils/inspect-flags.js"; +import { resolveMergeBaseRef } from "../utils/materialize-baseline-files.js"; +import { projectManifestChanged } from "../utils/project-manifest-changed.js"; +import { readChangedFilesFrom } from "../utils/read-changed-files-from.js"; +import { recordCount } from "../utils/record-metric.js"; +import { buildRulePriorityMap } from "../utils/diagnostic-grouping.js"; +import { reportErrorToSentry } from "../utils/report-error.js"; +import { resolveCliInspectOptions } from "../utils/resolve-cli-inspect-options.js"; +import type { CliInspectOptions } from "../utils/resolve-cli-inspect-options.js"; +import { + resolveProjectChangedLineRanges, + resolveProjectDiffIncludePaths, +} from "../utils/resolve-project-diff-include-paths.js"; +import type { RequestedScope } from "../utils/resolve-scope.js"; +import { finalizeScope, resolveScope, warnDeprecatedDiff } from "../utils/resolve-scope.js"; +import { selectProjects } from "../utils/select-projects.js"; +import { shouldSkipPrompts } from "../utils/should-skip-prompts.js"; +import { isSpinnerSilent, setSpinnerSilent, spinner } from "../utils/spinner.js"; +import { runTriageLoop } from "../utils/run-triage-loop.js"; +import { validateModeFlags } from "../utils/validate-mode-flags.js"; +import { warnDeprecatedFailOn } from "../utils/warn-deprecated-fail-on.js"; +import { writeDiagnosticsDirectory } from "../utils/write-diagnostics-directory.js"; + +interface CompletedScan { + readonly directory: string; + readonly result: InspectResult; + readonly config: ReactDoctorConfig | null; +} + +const buildChangedFilesDiffInfo = (changedFiles: string[]): DiffInfo => ({ + currentBranch: process.env.GITHUB_HEAD_REF?.trim() || null, + baseBranch: process.env.GITHUB_BASE_REF?.trim() || "pull request target", + baseSha: process.env.REACT_DOCTOR_BASE_SHA?.trim() || undefined, + changedFiles, + isCurrentChanges: false, +}); + +const defaultTriageOutputDirectory = (rootDirectory: string): string => + path.join(rootDirectory, ".react-doctor", "triage"); + +const normalizeDiagnosticPath = ( + rootDirectory: string, + scanDirectory: string, + diagnostic: Diagnostic, +): Diagnostic => { + const absoluteFilePath = path.isAbsolute(diagnostic.filePath) + ? diagnostic.filePath + : path.join(scanDirectory, diagnostic.filePath); + return { ...diagnostic, filePath: toRelativePath(absoluteFilePath, rootDirectory) }; +}; + +const normalizeScanPaths = (rootDirectory: string, scan: CompletedScan): CompletedScan => ({ + ...scan, + result: { + ...scan.result, + diagnostics: scan.result.diagnostics.map((diagnostic) => + normalizeDiagnosticPath(rootDirectory, scan.directory, diagnostic), + ), + }, +}); + +const scanStagedFiles = async ( + resolvedDirectory: string, + userConfig: ReactDoctorConfig | null, + flags: InspectFlags, + scanOptions: CliInspectOptions, +): Promise => { + const stagedFiles = await getStagedSourceFiles(resolvedDirectory); + if (stagedFiles.length === 0) { + logger.dim("No staged source files found."); + return []; + } + + const scanSpinner = spinner(`Scanning ${stagedFiles.length} staged files...`).start(); + + const tempDirectory = fs.mkdtempSync(path.join(tmpdir(), STAGED_FILES_TEMP_DIR_PREFIX)); + try { + const snapshot = await materializeStagedFiles( + resolvedDirectory, + stagedFiles, + tempDirectory, + ).catch((error: unknown) => { + fs.rmSync(tempDirectory, { recursive: true, force: true }); + throw error; + }); + const stagedWantsLines = resolveScope(flags, userConfig).scope === "lines"; + const stagedLineRanges = stagedWantsLines + ? await getChangedLineRanges({ + directory: resolvedDirectory, + cached: true, + files: snapshot.stagedFiles, + }) + : null; + if (stagedWantsLines && stagedLineRanges === null) { + logger.warn( + "Could not determine staged changed lines; reporting all issues in staged files.", + ); + logger.break(); + } + + try { + const scanResult = await inspect(snapshot.tempDirectory, { + ...scanOptions, + outputDirectory: undefined, + includePaths: snapshot.stagedFiles, + configOverride: userConfig, + changedLineRanges: stagedLineRanges ?? undefined, + suppressRendering: true, + }); + const remappedDiagnostics = scanResult.diagnostics.map((diagnostic) => ({ + ...diagnostic, + filePath: path.isAbsolute(diagnostic.filePath) + ? diagnostic.filePath.replaceAll(snapshot.tempDirectory, () => resolvedDirectory) + : diagnostic.filePath, + })); + return [ + { + directory: resolvedDirectory, + result: { + ...scanResult, + diagnostics: remappedDiagnostics, + project: { ...scanResult.project, rootDirectory: resolvedDirectory }, + }, + config: userConfig, + }, + ]; + } finally { + snapshot.cleanup(); + } + } finally { + scanSpinner.stop(); + } +}; + +const scanProjects = async ( + rootDirectory: string, + projectDirectories: readonly string[], + rootConfig: ReactDoctorConfig | null, + flags: InspectFlags, + scanOptions: CliInspectOptions, +): Promise => { + const changedFilesDiffInfo = flags.changedFilesFrom + ? buildChangedFilesDiffInfo(readChangedFilesFrom(path.resolve(flags.changedFilesFrom))) + : null; + const requestedScope = resolveScope(flags, rootConfig); + const scopeRequest: RequestedScope = + requestedScope.scope === undefined && changedFilesDiffInfo !== null + ? { ...requestedScope, scope: "changed" } + : requestedScope; + const wantsDiffMode = scopeRequest.scope !== undefined && scopeRequest.scope !== "full"; + const shouldDetectDiff = changedFilesDiffInfo === null && wantsDiffMode; + const diffInfo = + changedFilesDiffInfo ?? + (shouldDetectDiff ? await getDiffInfo(rootDirectory, scopeRequest.base) : null); + const scope = await finalizeScope({ + requested: scopeRequest, + diffInfo, + skipPrompts: true, + isQuiet: true, + }); + const isDiffMode = scope !== "full"; + const comparisonBaseRef = + isDiffMode && diffInfo && !diffInfo.isCurrentChanges + ? diffInfo.baseSha + ? await resolveMergeBaseRef(rootDirectory, diffInfo.baseSha) + : (diffInfo.diffBaseRef ?? (await resolveMergeBaseRef(rootDirectory, diffInfo.baseBranch))) + : null; + const baselineRef = scope === "changed" ? comparisonBaseRef : null; + const linesBaseRef = diffInfo?.isCurrentChanges ? "HEAD" : comparisonBaseRef; + const canComputeLines = + scope === "lines" && diffInfo !== null && (diffInfo.isCurrentChanges || linesBaseRef !== null); + const changedLineRanges = + canComputeLines && diffInfo !== null + ? await getChangedLineRanges({ + directory: rootDirectory, + baseRef: linesBaseRef ?? undefined, + files: [...diffInfo.changedFiles], + }) + : null; + if (scope === "lines" && changedLineRanges === null) { + logger.warn( + "Could not determine changed lines (no base ref or git diff failed); reporting all issues in changed files.", + ); + logger.break(); + } + if (isDiffMode && diffInfo) { + if (diffInfo.isCurrentChanges) { + logger.log("Scanning uncommitted changes"); + } else { + const currentBranchLabel = diffInfo.currentBranch ?? "(detached HEAD)"; + logger.log( + `Scanning changes: ${highlighter.info(currentBranchLabel)} -> ${highlighter.info(diffInfo.baseBranch)}`, + ); + } + logger.break(); + } + + const rootScanTarget = await resolveScanTarget(rootDirectory, { allowAmbiguous: true }); + const isMultiProject = projectDirectories.length > 1; + const batchSpinner = spinner( + isMultiProject ? `Scanning ${projectDirectories.length} projects...` : "Scanning project...", + ).start(); + const wasSpinnerSilent = isSpinnerSilent(); + setSpinnerSilent(true); + let finishedProjectCount = 0; + try { + const scanOutcomes = await mapWithConcurrency( + projectDirectories, + isMultiProject ? DEFAULT_PROJECT_SCAN_CONCURRENCY : 1, + async (projectDirectory): Promise => { + const projectScanTarget = + projectDirectory === rootDirectory + ? rootScanTarget + : await resolveScanTarget(projectDirectory, { allowAmbiguous: true }); + const scanDirectory = projectScanTarget.resolvedDirectory; + const projectConfig = + projectDirectory === rootDirectory + ? rootConfig + : mergeReactDoctorConfigs(rootConfig, projectScanTarget.userConfig ?? undefined); + const projectConfigSourceDirectory = + projectScanTarget.userConfig?.plugins === undefined + ? rootScanTarget.configSourceDirectory + : projectScanTarget.configSourceDirectory; + const supplyChainEnabled = projectConfig?.supplyChain?.enabled !== false; + let includePaths: string[] | undefined; + let supplyChainManifestChanged = false; + if (isDiffMode) { + const changedSourceFiles = + diffInfo === null + ? [] + : resolveProjectDiffIncludePaths(rootDirectory, scanDirectory, diffInfo); + supplyChainManifestChanged = + supplyChainEnabled && + diffInfo !== null && + projectManifestChanged(rootDirectory, scanDirectory, diffInfo); + if (changedSourceFiles.length === 0 && !supplyChainManifestChanged) { + logger.dim(`No changed source files in ${scanDirectory}, skipping.`); + logger.break(); + return null; + } + includePaths = [...changedSourceFiles]; + if (supplyChainManifestChanged) includePaths.push("package.json"); + } + const scanResult = await inspect(scanDirectory, { + ...scanOptions, + outputDirectory: undefined, + includePaths, + configOverride: projectConfig, + configSourceDirectory: projectConfigSourceDirectory ?? undefined, + suppressRendering: true, + concurrentScan: isMultiProject, + baseline: baselineRef ? { ref: baselineRef } : undefined, + changedLineRanges: + scope === "lines" && changedLineRanges !== null + ? resolveProjectChangedLineRanges(rootDirectory, scanDirectory, changedLineRanges) + : undefined, + supplyChainManifestChanged, + }); + finishedProjectCount += 1; + if (isMultiProject) { + batchSpinner.update( + `Scanning ${projectDirectories.length} projects... (${finishedProjectCount}/${projectDirectories.length})`, + ); + } + return { directory: scanDirectory, result: scanResult, config: projectConfig }; + }, + ); + return scanOutcomes.filter((scanOutcome): scanOutcome is CompletedScan => scanOutcome !== null); + } finally { + setSpinnerSilent(wasSpinnerSilent); + batchSpinner.stop(); + } +}; + +export const triageAction = async (directory: string, flags: InspectFlags): Promise => { + recordCount(METRIC.cliInvoked, 1, { command: "triage" }); + if ( + shouldSkipPrompts({ yes: flags.yes, json: flags.json }) || + process.stdout.isTTY !== true || + isCiOrCodingAgentEnvironment() + ) { + logger.dim("React Doctor triage requires an interactive terminal."); + return; + } + + const requestedDirectory = path.resolve(directory); + try { + validateModeFlags({ ...flags, json: false }); + const scanTarget = await resolveScanTarget(requestedDirectory, { allowAmbiguous: true }); + const rootDirectory = scanTarget.resolvedDirectory; + const userConfig = scanTarget.userConfig; + warnDeprecatedFailOn(flags, userConfig); + warnDeprecatedDiff(flags, userConfig); + const outputDirectory = path.resolve( + flags.outputDir ?? defaultTriageOutputDirectory(rootDirectory), + ); + ensureReactDoctorGitignore(rootDirectory); + + const scanOptions: CliInspectOptions = { + ...resolveCliInspectOptions({ ...flags, json: false, outputDir: undefined }, userConfig), + silent: true, + outputDirectory: undefined, + }; + const completedScans = flags.staged + ? await scanStagedFiles(rootDirectory, userConfig, flags, scanOptions) + : await scanProjects( + rootDirectory, + await selectProjects(rootDirectory, flags.project, true, userConfig?.projects), + userConfig, + flags, + scanOptions, + ); + const normalizedScans = completedScans.map((scan) => normalizeScanPaths(rootDirectory, scan)); + const surfaceDiagnostics = filterScansForSurface(normalizedScans, "cli"); + const categoryFilters = new Set(scanOptions.categoryFilters ?? []); + const diagnostics = filterDiagnosticsByCategories(surfaceDiagnostics, categoryFilters); + writeDiagnosticsDirectory(diagnostics, outputDirectory); + + if (diagnostics.length === 0) { + logger.log(highlighter.success("No React Doctor diagnostics to triage.")); + return; + } + + const rulePriority = buildRulePriorityMap(normalizedScans.map((scan) => scan.result.score)); + const result = await runTriageLoop({ + diagnostics, + outputDirectory, + projectName: path.basename(rootDirectory), + rootDirectory, + rulePriority, + }); + recordCount(METRIC.triage, 1, { + totalRules: result.totalRules, + rulesPrompted: result.rulesPrompted, + rulesSkipped: result.rulesSkipped, + rulesDisabled: result.rulesDisabled, + rulesRemaining: result.rulesRemaining, + }); + logger.break(); + logger.log( + `Triage session complete. ${highlighter.info(`${result.rulesRemaining}`)} rules remaining.`, + ); + } catch (error) { + const isUserError = isExpectedUserError(error); + const sentryEventId = isUserError ? undefined : await reportErrorToSentry(error); + if (isUserError) { + handleUserError(error); + return; + } + handleError(error, { sentryEventId }); + } +}; diff --git a/packages/react-doctor/src/cli/index.ts b/packages/react-doctor/src/cli/index.ts index f8425d28e..ebf73f7f9 100644 --- a/packages/react-doctor/src/cli/index.ts +++ b/packages/react-doctor/src/cli/index.ts @@ -13,6 +13,7 @@ import { rulesSetAction, rulesUnignoreTagAction, } from "./commands/rules.js"; +import { triageAction } from "./commands/triage.js"; import { versionAction } from "./commands/version.js"; import { whyAction } from "./commands/why.js"; import { applyColorPreference } from "./utils/apply-color-preference.js"; @@ -76,6 +77,7 @@ ${formatExampleLines([ ["react-doctor --blocking warning", "fail CI on warnings too (default: error)"], ["react-doctor --json > report.json", "write a machine-readable report"], ["react-doctor why src/App.tsx:42", "explain why a rule fired there"], + ["react-doctor triage", "walk rules one by one and copy focused fix prompts"], ["react-doctor install", "set up the agent skill and git hook"], ])} @@ -195,6 +197,75 @@ const program = new Command() program.action(inspectAction); +program + .command("triage") + .description("Walk React Doctor rules one by one and copy focused fix prompts") + .argument("[directory]", "project directory to scan", ".") + .option("--lint", "enable linting") + .option("--no-lint", "skip linting") + .option("--dead-code", "enable dead-code analysis (default)") + .option( + "--no-dead-code", + "skip dead-code analysis (unused files / exports / dependencies, circular imports)", + ) + .option( + "--output-dir ", + "directory for triage diagnostics and state (default: .react-doctor/triage)", + ) + .option( + "--no-parallel", + "lint serially with one worker (default: parallel across CPU cores; set the worker count with REACT_DOCTOR_PARALLEL)", + ) + .option( + "--project ", + "select projects: workspace names or directory paths (comma-separated for multiple); default selects every workspace project", + ) + .option( + "--scope ", + "how much to scan/report: full (default), files, changed (only new issues vs base), or lines (only changed lines)", + ) + .option("--base ", "base git ref for files/changed/lines scope (auto-detected when omitted)") + .addOption( + new Option( + "--diff [base]", + "[deprecated] alias for --scope changed (pass `false` to force a full scan)", + ).hideHelp(), + ) + .addOption( + new Option( + "--changed-files-from ", + "scan source files listed in a newline-delimited changed-files file", + ).hideHelp(), + ) + .option("--no-score", "skip the score API, the share URL, and crash reporting") + .addOption( + new Option( + "--category ", + "only show diagnostics in a category (repeatable; e.g. Security)", + ).argParser(collectCategoryOption), + ) + .option( + "--no-telemetry", + "alias for --no-score (skip the score API, share URL, and crash reporting)", + ) + .option("--staged", "scan only staged (git index) files for pre-commit hooks") + .option( + "--blocking ", + "severity that fails CI: error (default), warning, or none (advisory)", + ) + .addOption( + new Option("--fail-on ", "[deprecated] alias for --blocking ").hideHelp(), + ) + .option( + "--no-respect-inline-disables", + "audit mode: neutralize inline lint suppressions before scanning", + ) + .option("--warnings", "show warning-severity diagnostics (default)") + .option("--no-warnings", "hide warning-severity diagnostics (errors only)") + .option("--color", "force colored output") + .option("--no-color", "disable colored output (also honors NO_COLOR)") + .action(triageAction); + program .command("why ") .description("Explain why a rule fired (or why a suppression didn't apply) at a file:line") diff --git a/packages/react-doctor/src/cli/utils/build-triage-rule-prompt.ts b/packages/react-doctor/src/cli/utils/build-triage-rule-prompt.ts new file mode 100644 index 000000000..c3fff9f54 --- /dev/null +++ b/packages/react-doctor/src/cli/utils/build-triage-rule-prompt.ts @@ -0,0 +1,77 @@ +import * as path from "node:path"; +import type { Diagnostic } from "@react-doctor/core"; +import { TRIAGE_PROMPT_MAX_INLINE_SITES } from "./constants.js"; +import { formatFixRecipeLine } from "./diagnostic-grouping.js"; +import { ruleDumpFileName } from "./write-diagnostics-directory.js"; + +export interface BuildTriageRulePromptInput { + readonly ruleKey: string; + readonly diagnostics: readonly Diagnostic[]; + readonly projectName: string; + readonly outputDirectory: string; +} + +const formatLocation = (diagnostic: Diagnostic): string => { + if (diagnostic.line > 0) { + return `${diagnostic.filePath}:${diagnostic.line}`; + } + return diagnostic.filePath; +}; + +const formatCommandArgument = (value: string): string => JSON.stringify(value); + +export const buildTriageRulePrompt = (input: BuildTriageRulePromptInput): string => { + const representative = input.diagnostics[0]; + if (representative === undefined) { + return `No diagnostics found for ${input.ruleKey}.`; + } + + const severityLabel = representative.severity === "error" ? "ERROR" : "WARN"; + const title = representative.title ?? input.ruleKey; + const ruleDumpPath = path.join(input.outputDirectory, ruleDumpFileName(input.ruleKey)); + const uniqueLocations = [...new Set(input.diagnostics.map(formatLocation))]; + const inlineLocations = uniqueLocations.slice(0, TRIAGE_PROMPT_MAX_INLINE_SITES); + const remainingLocationCount = uniqueLocations.length - inlineLocations.length; + const fixRecipeLine = formatFixRecipeLine(representative); + const lines = [ + `Fix exactly one React Doctor rule in ${input.projectName}:`, + "", + `${severityLabel} ${representative.category}: ${title} (${input.ruleKey}, x${input.diagnostics.length})`, + representative.message, + ]; + + if (representative.help) { + lines.push("", `Suggested fix: ${representative.help}`); + } + if (fixRecipeLine) { + lines.push("", fixRecipeLine); + } + + lines.push( + "", + "Scope:", + `- Fix only ${input.ruleKey}.`, + "- Fix the root cause; do not suppress, disable, or silence the rule.", + "- Keep unrelated refactors out of this pass.", + "", + "Affected sites:", + ); + + for (const location of inlineLocations) { + lines.push(`- ${location}`); + } + if (remainingLocationCount > 0) { + lines.push(`- +${remainingLocationCount} more sites in ${ruleDumpPath}`); + } + + lines.push( + "", + `Full per-rule diagnostics: ${ruleDumpPath}`, + "", + "Verification:", + `- Re-run \`react-doctor triage --output-dir ${formatCommandArgument(input.outputDirectory)}\` after editing.`, + `- Confirm ${input.ruleKey} is gone before moving to the next rule.`, + ); + + return lines.join("\n"); +}; diff --git a/packages/react-doctor/src/cli/utils/constants.ts b/packages/react-doctor/src/cli/utils/constants.ts index 933bca6d8..c84a831e1 100644 --- a/packages/react-doctor/src/cli/utils/constants.ts +++ b/packages/react-doctor/src/cli/utils/constants.ts @@ -48,6 +48,11 @@ export const GH_PR_LIST_MAX = 100; // compact, passable CLI argument. export const HANDOFF_MAX_FILES_PER_RULE = 3; +export const TRIAGE_STATE_SCHEMA_VERSION = 1; +export const TRIAGE_STATE_JSON_INDENT_SPACES = 2; +export const TRIAGE_PROMPT_MAX_INLINE_SITES = 30; +export const TRIAGE_DISPLAY_MAX_FILES = 8; + // Social proof for the "Add to CI" pitch (shown in the post-scan handoff // prompt and embedded in the agent-handoff prompt). export const CI_TRUST_COMPANIES = "PayPal, Rippling, and Alibaba"; @@ -173,6 +178,7 @@ export const METRIC = { scoreUnavailable: "score.unavailable", oxlintWorkers: "oxlint.workers", agentHandoff: "agent.handoff", + triage: "triage.session", agentInstallHintShown: "agent.install_hint_shown", installCompleted: "install.completed", installAgent: "install.agent", diff --git a/packages/react-doctor/src/cli/utils/ensure-react-doctor-gitignore.ts b/packages/react-doctor/src/cli/utils/ensure-react-doctor-gitignore.ts new file mode 100644 index 000000000..9d33b3195 --- /dev/null +++ b/packages/react-doctor/src/cli/utils/ensure-react-doctor-gitignore.ts @@ -0,0 +1,25 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; + +const REACT_DOCTOR_IGNORE_ENTRY = ".react-doctor/"; + +const hasReactDoctorIgnoreEntry = (content: string): boolean => + content + .split(/\r?\n/) + .map((line) => line.trim()) + .some((line) => line === REACT_DOCTOR_IGNORE_ENTRY || line === ".react-doctor"); + +export const ensureReactDoctorGitignore = (projectRoot: string): boolean => { + const gitignorePath = path.join(projectRoot, ".gitignore"); + try { + const currentContent = fs.existsSync(gitignorePath) + ? fs.readFileSync(gitignorePath, "utf8") + : ""; + if (hasReactDoctorIgnoreEntry(currentContent)) return false; + const separator = currentContent.length === 0 || currentContent.endsWith("\n") ? "" : "\n"; + fs.writeFileSync(gitignorePath, `${currentContent}${separator}${REACT_DOCTOR_IGNORE_ENTRY}\n`); + return true; + } catch { + return false; + } +}; diff --git a/packages/react-doctor/src/cli/utils/handoff-to-agent.ts b/packages/react-doctor/src/cli/utils/handoff-to-agent.ts index 4ca6d67a5..a86ffa767 100644 --- a/packages/react-doctor/src/cli/utils/handoff-to-agent.ts +++ b/packages/react-doctor/src/cli/utils/handoff-to-agent.ts @@ -1,4 +1,5 @@ import * as fs from "node:fs"; +import * as path from "node:path"; import { getSkillAgentConfig } from "agent-install"; import type { Diagnostic } from "@react-doctor/core"; import { highlighter } from "@react-doctor/core"; @@ -32,6 +33,9 @@ import { } from "./launch-agent.js"; import { prompts } from "./prompts.js"; import { spinner } from "./spinner.js"; +import { runTriageLoop } from "./run-triage-loop.js"; +import { writeDiagnosticsDirectory } from "./write-diagnostics-directory.js"; +import { ensureReactDoctorGitignore } from "./ensure-react-doctor-gitignore.js"; export interface HandoffToAgentInput { readonly diagnostics: ReadonlyArray; @@ -43,6 +47,7 @@ export interface HandoffToAgentInput { const CLIPBOARD_CHOICE = "clipboard"; const SKIP_CHOICE = "skip"; +const TRIAGE_CHOICE = "triage"; const printPayload = (payload: string): void => { logger.break(); @@ -235,6 +240,11 @@ export const handoffToAgent = async (input: HandoffToAgentInput): Promise description: "Paste into any agent or chat", value: CLIPBOARD_CHOICE, }, + { + title: "Triage rules one by one", + description: "Pick which rules to prompt, skip, or disable", + value: TRIAGE_CHOICE, + }, { title: "Skip", description: "Don't hand off", value: SKIP_CHOICE }, ]; @@ -269,6 +279,7 @@ export const handoffToAgent = async (input: HandoffToAgentInput): Promise if (handoffTarget === undefined) handoffOutcome = "cancel"; else if (handoffTarget === SKIP_CHOICE) handoffOutcome = "skip"; else if (handoffTarget === CLIPBOARD_CHOICE) handoffOutcome = "clipboard"; + else if (handoffTarget === TRIAGE_CHOICE) handoffOutcome = "triage"; recordCount(METRIC.agentHandoff, 1, { outcome: handoffOutcome, agent: handoffOutcome === "launch" ? handoffTarget : undefined, @@ -284,6 +295,28 @@ export const handoffToAgent = async (input: HandoffToAgentInput): Promise if (handoffTarget === undefined || handoffTarget === SKIP_CHOICE) return; + if (handoffTarget === TRIAGE_CHOICE) { + const outputDirectory = writeDiagnosticsDirectory( + [...input.diagnostics], + input.outputDirectory ?? path.join(input.rootDirectory, ".react-doctor", "triage"), + ); + ensureReactDoctorGitignore(input.rootDirectory); + const result = await runTriageLoop({ + diagnostics: input.diagnostics, + outputDirectory, + projectName: input.projectName, + rootDirectory: input.rootDirectory, + }); + recordCount(METRIC.triage, 1, { + totalRules: result.totalRules, + rulesPrompted: result.rulesPrompted, + rulesSkipped: result.rulesSkipped, + rulesDisabled: result.rulesDisabled, + rulesRemaining: result.rulesRemaining, + }); + return; + } + const payload = buildHandoffPayload({ diagnostics: input.diagnostics, projectName: input.projectName, diff --git a/packages/react-doctor/src/cli/utils/install-react-doctor.ts b/packages/react-doctor/src/cli/utils/install-react-doctor.ts index ad5711dfc..94b7214dd 100644 --- a/packages/react-doctor/src/cli/utils/install-react-doctor.ts +++ b/packages/react-doctor/src/cli/utils/install-react-doctor.ts @@ -27,6 +27,7 @@ import { detectDefaultBranch } from "./detect-default-branch.js"; import { hasHandledActionUpgrade, recordActionUpgradeDecision } from "./action-upgrade-prompt.js"; import { hasHandledCiPrompt, recordCiPromptDecision } from "./ci-prompt-decision.js"; import { installReactDoctorAgentHooks } from "./install-agent-hooks.js"; +import { ensureReactDoctorGitignore } from "./ensure-react-doctor-gitignore.js"; import { getReactDoctorWorkflowPath, installReactDoctorWorkflow, @@ -630,6 +631,7 @@ export const runInstallReactDoctor = async ( projectRoot, options.installDependencyRunner, ); + ensureReactDoctorGitignore(projectRoot); } // The CI decision from Step 1 lands here, after the core skill + package setup @@ -734,6 +736,7 @@ export const runInstallReactDoctor = async ( logger.dim(` Source: ${sourceDir}`); logger.dim(" Package script: doctor (or react-doctor if doctor exists)"); logger.dim(" Dev dependency: react-doctor"); + logger.dim(" Gitignore: .react-doctor/"); if (shouldInstallGitHook) { logger.dim(` Git hook: ${gitHookPath}`); } diff --git a/packages/react-doctor/src/cli/utils/prompts.ts b/packages/react-doctor/src/cli/utils/prompts.ts index 7423ed149..85a17defc 100644 --- a/packages/react-doctor/src/cli/utils/prompts.ts +++ b/packages/react-doctor/src/cli/utils/prompts.ts @@ -1,6 +1,6 @@ import { createRequire } from "node:module"; import basePrompts, { type PromptObject, type Answers } from "prompts"; -import type { PromptMultiselectContext } from "@react-doctor/core"; +import type { PromptMultiselectContext, PromptSelectContext } from "@react-doctor/core"; import { cliLogger as logger } from "./cli-logger.js"; import { shouldAutoSelectCurrentChoice } from "./should-auto-select-current-choice.js"; import { shouldSelectAllChoices } from "./should-select-all-choices.js"; @@ -8,8 +8,10 @@ import { unrefStdin } from "./unref-stdin.js"; const require = createRequire(import.meta.url); const PROMPTS_MULTISELECT_MODULE_PATH = "prompts/lib/elements/multiselect"; +const PROMPTS_SELECT_MODULE_PATH = "prompts/lib/elements/select"; let didPatchMultiselectToggleAll = false; let didPatchMultiselectSubmit = false; +let didPatchSelectKeybindSubmit = false; const onCancel = () => { logger.break(); @@ -63,12 +65,44 @@ const patchMultiselectSubmit = (): void => { }; }; +const patchSelectKeybindSubmit = (): void => { + if (didPatchSelectKeybindSubmit) return; + didPatchSelectKeybindSubmit = true; + + const selectPromptConstructor = require(PROMPTS_SELECT_MODULE_PATH); + const originalInput = selectPromptConstructor.prototype._; + + selectPromptConstructor.prototype._ = function ( + this: PromptSelectContext, + inputCharacter: string, + key: unknown, + ): void { + if (inputCharacter === " ") { + originalInput.call(this, inputCharacter, key); + return; + } + + const normalizedInput = inputCharacter.toLowerCase(); + const matchingChoiceIndex = this.choices.findIndex( + (choice) => !choice.disabled && choice.title.toLowerCase().startsWith(normalizedInput), + ); + if (matchingChoiceIndex < 0) { + originalInput.call(this, inputCharacter, key); + return; + } + + this.moveCursor(matchingChoiceIndex); + this.submit(); + }; +}; + export const prompts = ( questions: PromptObject | PromptObject[], options: CliPromptOptions = {}, ): Promise> => { patchMultiselectToggleAll(); patchMultiselectSubmit(); + patchSelectKeybindSubmit(); // HACK: each prompt re-refs stdin and never unrefs it on close, so re-unref // once it settles or the one-shot CLI hangs. See `unref-stdin.ts` for why. return basePrompts(questions, { onCancel: options.onCancel ?? onCancel }).finally(unrefStdin); diff --git a/packages/react-doctor/src/cli/utils/run-triage-loop.ts b/packages/react-doctor/src/cli/utils/run-triage-loop.ts new file mode 100644 index 000000000..65fb3a8af --- /dev/null +++ b/packages/react-doctor/src/cli/utils/run-triage-loop.ts @@ -0,0 +1,220 @@ +import * as path from "node:path"; +import { highlighter } from "@react-doctor/core"; +import type { Diagnostic } from "@react-doctor/core"; +import { buildTriageRulePrompt } from "./build-triage-rule-prompt.js"; +import { cliLogger as logger } from "./cli-logger.js"; +import { TRIAGE_DISPLAY_MAX_FILES } from "./constants.js"; +import { buildSortedRuleGroups, formatLearnMoreLine } from "./diagnostic-grouping.js"; +import { copyToClipboard } from "./launch-agent.js"; +import { prompts } from "./prompts.js"; +import { resolveRuleConfigTarget, writeRuleConfig } from "./rule-config-file.js"; +import { setRuleSeverity } from "./update-rule-config.js"; +import { + pruneTriageState, + readTriageState, + updateTriageState, + writeTriageState, + type TriageState, +} from "./triage-state.js"; + +export interface RunTriageLoopInput { + readonly diagnostics: readonly Diagnostic[]; + readonly outputDirectory: string; + readonly projectName: string; + readonly rootDirectory: string; + readonly rulePriority?: ReadonlyMap; +} + +export interface TriageLoopResult { + readonly totalRules: number; + readonly rulesPrompted: number; + readonly rulesSkipped: number; + readonly rulesDisabled: number; + readonly rulesRemaining: number; +} + +const COPY_PROMPT_CHOICE = "copy-prompt"; +const SKIP_CHOICE = "skip"; +const DISABLE_CHOICE = "disable"; + +const colorizeBySeverity = (text: string, severity: Diagnostic["severity"]): string => + severity === "error" ? highlighter.error(text) : highlighter.warn(text); + +const getRuleGroupSeverityRank = (diagnostics: readonly Diagnostic[]): number => + diagnostics.some((diagnostic) => diagnostic.severity === "error") ? 0 : 1; + +const sortRuleGroupsForTriage = (groups: [string, Diagnostic[]][]): [string, Diagnostic[]][] => + groups.toSorted( + ([, diagnosticsA], [, diagnosticsB]) => + getRuleGroupSeverityRank(diagnosticsA) - getRuleGroupSeverityRank(diagnosticsB), + ); + +const formatRuleLocation = (diagnostic: Diagnostic): string => { + if (diagnostic.line > 0) return `${diagnostic.filePath}:${diagnostic.line}`; + return diagnostic.filePath; +}; + +const collectDisplayFileEntries = (diagnostics: readonly Diagnostic[]): string[] => { + const locationByFilePath = new Map(); + for (const diagnostic of diagnostics) { + if (!locationByFilePath.has(diagnostic.filePath)) { + locationByFilePath.set(diagnostic.filePath, formatRuleLocation(diagnostic)); + } + } + return [...locationByFilePath.values()].slice(0, TRIAGE_DISPLAY_MAX_FILES); +}; + +const disableRuleInConfig = async (ruleKey: string, rootDirectory: string): Promise => { + const target = await resolveRuleConfigTarget(rootDirectory); + const nextConfig = setRuleSeverity(target.config, ruleKey, "off"); + const result = await writeRuleConfig(target, nextConfig); + return result.written; +}; + +const renderRuleCard = ( + ruleIndex: number, + totalRules: number, + ruleKey: string, + diagnostics: readonly Diagnostic[], + state: TriageState, +): void => { + const representative = diagnostics[0]; + if (representative === undefined) return; + const title = representative.title ?? ruleKey; + const docsLine = formatLearnMoreLine(representative); + const findingLabel = diagnostics.length === 1 ? "finding" : "findings"; + const severityLabel = representative.severity === "error" ? "ERROR" : "WARNING"; + const severityIcon = representative.severity === "error" ? "x" : "!"; + logger.break(); + logger.log(highlighter.bold(`Rule ${ruleIndex} of ${totalRules}`)); + logger.log( + `${colorizeBySeverity(`${severityIcon} ${severityLabel}`, representative.severity)} ${highlighter.info(ruleKey)}`, + ); + if (state.prompted.includes(ruleKey)) { + logger.dim(" Still failing after a prompt was copied earlier"); + } + logger.log( + ` ${highlighter.bold("Type:")} ${highlighter.dim(`${representative.category} / ${diagnostics.length} ${findingLabel}`)}`, + ); + logger.log(` ${highlighter.bold("Title:")} ${highlighter.dim(title)}`); + logger.log(` ${highlighter.bold("Impact:")} ${highlighter.dim(representative.message)}`); + const fileEntries = collectDisplayFileEntries(diagnostics); + if (fileEntries.length > 0) { + logger.log(` ${highlighter.bold("Files:")}`); + for (const fileEntry of fileEntries) { + logger.log(` ${highlighter.dim(fileEntry)}`); + } + const remainingFileCount = + new Set(diagnostics.map((diagnostic) => diagnostic.filePath)).size - fileEntries.length; + if (remainingFileCount > 0) { + logger.log(` ${highlighter.dim(`+${remainingFileCount} more in full diagnostics`)}`); + } + } + if (docsLine) { + logger.log(` ${highlighter.bold("Docs:")} ${highlighter.info(docsLine)}`); + } + logger.break(); +}; + +export const runTriageLoop = async (input: RunTriageLoopInput): Promise => { + const groups = sortRuleGroupsForTriage( + buildSortedRuleGroups(input.diagnostics, input.rulePriority), + ); + const activeRuleKeys = new Set(groups.map(([ruleKey]) => ruleKey)); + let state = pruneTriageState(readTriageState(input.outputDirectory), activeRuleKeys); + writeTriageState(input.outputDirectory, state); + + const skippedRuleKeys = new Set([...state.skipped, ...state.disabled]); + const handledThisSession = new Set(); + let rulesPrompted = 0; + let rulesSkipped = 0; + let rulesDisabled = 0; + + for (const [ruleKey, diagnostics] of groups) { + if (skippedRuleKeys.has(ruleKey) || handledThisSession.has(ruleKey)) continue; + const ruleIndex = groups.findIndex(([candidateRuleKey]) => candidateRuleKey === ruleKey) + 1; + renderRuleCard(ruleIndex, groups.length, ruleKey, diagnostics, state); + + const { triageAction } = await prompts<"triageAction">( + { + type: "select", + name: "triageAction", + message: "Choose an action for this rule", + hint: "Press c, s, or d. Return submits the highlighted option.", + choices: [ + { + title: "Copy fix prompt (c)", + description: "Paste into Cursor or another coding agent", + value: COPY_PROMPT_CHOICE, + }, + { + title: "Skip in triage (s)", + description: "Keep the rule enabled, but stop asking in this session", + value: SKIP_CHOICE, + }, + { + title: "Disable in doctor.config (d)", + description: "Set this rule to off so it stops running", + value: DISABLE_CHOICE, + }, + ], + initial: 0, + }, + { onCancel: () => true }, + ); + + if (triageAction === undefined) break; + + if (triageAction === COPY_PROMPT_CHOICE) { + const prompt = buildTriageRulePrompt({ + ruleKey, + diagnostics, + projectName: input.projectName, + outputDirectory: input.outputDirectory, + }); + const didCopy = await copyToClipboard(prompt); + if (didCopy) { + logger.log("Copied the focused prompt to your clipboard."); + } else { + logger.break(); + logger.log(highlighter.dim("---- Triage prompt ----")); + logger.log(prompt); + logger.log(highlighter.dim("-----------------------")); + } + state = updateTriageState(state, { prompted: [ruleKey] }); + rulesPrompted += 1; + } else if (triageAction === SKIP_CHOICE) { + state = updateTriageState(state, { skipped: [ruleKey] }); + skippedRuleKeys.add(ruleKey); + rulesSkipped += 1; + } else if (triageAction === DISABLE_CHOICE) { + const didDisable = await disableRuleInConfig(ruleKey, input.rootDirectory); + if (didDisable) { + logger.log(`Disabled ${highlighter.info(ruleKey)} in doctor.config.`); + } else { + logger.warn( + `Could not edit doctor.config automatically. Add "${ruleKey}": "off" manually.`, + ); + } + state = updateTriageState(state, { disabled: [ruleKey] }); + skippedRuleKeys.add(ruleKey); + rulesDisabled += 1; + } + + handledThisSession.add(ruleKey); + writeTriageState(input.outputDirectory, state); + logger.dim(` Full diagnostics: ${path.relative(process.cwd(), input.outputDirectory)}`); + } + + const rulesRemaining = groups.filter( + ([ruleKey]) => !skippedRuleKeys.has(ruleKey) && !handledThisSession.has(ruleKey), + ).length; + + return { + totalRules: groups.length, + rulesPrompted, + rulesSkipped, + rulesDisabled, + rulesRemaining, + }; +}; diff --git a/packages/react-doctor/src/cli/utils/strip-unknown-cli-flags.ts b/packages/react-doctor/src/cli/utils/strip-unknown-cli-flags.ts index 88502c907..913de2933 100644 --- a/packages/react-doctor/src/cli/utils/strip-unknown-cli-flags.ts +++ b/packages/react-doctor/src/cli/utils/strip-unknown-cli-flags.ts @@ -62,6 +62,38 @@ const INSTALL_FLAG_SPEC: CliFlagSpec = { shortOptionsWithRequiredValues: new Set(["-c"]), }; +const TRIAGE_FLAG_SPEC: CliFlagSpec = { + longOptionsWithoutValues: new Set([ + "--color", + "--dead-code", + "--help", + "--lint", + "--no-color", + "--no-dead-code", + "--no-lint", + "--no-parallel", + "--no-respect-inline-disables", + "--no-score", + "--no-telemetry", + "--no-warnings", + "--staged", + "--warnings", + ]), + longOptionsWithRequiredValues: new Set([ + "--base", + "--blocking", + "--category", + "--changed-files-from", + "--fail-on", + "--output-dir", + "--project", + "--scope", + ]), + longOptionsWithOptionalValues: new Set(["--diff"]), + shortOptionsWithoutValues: new Set(["-h"]), + shortOptionsWithRequiredValues: new Set(), +}; + const VERSION_FLAG_SPEC: CliFlagSpec = { longOptionsWithoutValues: new Set(["--color", "--help", "--no-color"]), longOptionsWithRequiredValues: new Set(), @@ -102,6 +134,7 @@ const WHY_FLAG_SPEC: CliFlagSpec = { const COMMAND_FLAG_SPECS = new Map([ ["install", INSTALL_FLAG_SPEC], ["setup", INSTALL_FLAG_SPEC], + ["triage", TRIAGE_FLAG_SPEC], ["version", VERSION_FLAG_SPEC], ["rules", RULES_FLAG_SPEC], ["why", WHY_FLAG_SPEC], diff --git a/packages/react-doctor/src/cli/utils/triage-state.ts b/packages/react-doctor/src/cli/utils/triage-state.ts new file mode 100644 index 000000000..7925e64f1 --- /dev/null +++ b/packages/react-doctor/src/cli/utils/triage-state.ts @@ -0,0 +1,95 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { TRIAGE_STATE_JSON_INDENT_SPACES, TRIAGE_STATE_SCHEMA_VERSION } from "./constants.js"; + +export interface TriageState { + readonly schemaVersion: number; + readonly lastScanAt: string | null; + readonly prompted: readonly string[]; + readonly skipped: readonly string[]; + readonly disabled: readonly string[]; +} + +export interface TriageStateUpdate { + readonly prompted?: readonly string[]; + readonly skipped?: readonly string[]; + readonly disabled?: readonly string[]; +} + +const TRIAGE_STATE_FILE_NAME = "triage-state.json"; +export const emptyTriageState = (): TriageState => ({ + schemaVersion: TRIAGE_STATE_SCHEMA_VERSION, + lastScanAt: null, + prompted: [], + skipped: [], + disabled: [], +}); + +export const getTriageStatePath = (outputDirectory: string): string => + path.join(outputDirectory, TRIAGE_STATE_FILE_NAME); + +const collectStringArray = (value: unknown): string[] => + Array.isArray(value) + ? value.filter((entry): entry is string => typeof entry === "string" && entry.length > 0) + : []; + +const readStateField = (value: unknown, fieldName: keyof TriageState): unknown => { + if (value === null || typeof value !== "object") return undefined; + if (fieldName === "schemaVersion" && "schemaVersion" in value) return value.schemaVersion; + if (fieldName === "lastScanAt" && "lastScanAt" in value) return value.lastScanAt; + if (fieldName === "prompted" && "prompted" in value) return value.prompted; + if (fieldName === "skipped" && "skipped" in value) return value.skipped; + if (fieldName === "disabled" && "disabled" in value) return value.disabled; + return undefined; +}; + +export const readTriageState = (outputDirectory: string): TriageState => { + try { + const parsed: unknown = JSON.parse( + fs.readFileSync(getTriageStatePath(outputDirectory), "utf8"), + ); + const schemaVersion = readStateField(parsed, "schemaVersion"); + if (schemaVersion !== TRIAGE_STATE_SCHEMA_VERSION) return emptyTriageState(); + const lastScanAt = readStateField(parsed, "lastScanAt"); + return { + schemaVersion: TRIAGE_STATE_SCHEMA_VERSION, + lastScanAt: typeof lastScanAt === "string" ? lastScanAt : null, + prompted: collectStringArray(readStateField(parsed, "prompted")), + skipped: collectStringArray(readStateField(parsed, "skipped")), + disabled: collectStringArray(readStateField(parsed, "disabled")), + }; + } catch { + return emptyTriageState(); + } +}; + +const mergeRuleKeys = ( + existingRuleKeys: readonly string[], + addedRuleKeys: readonly string[] | undefined, +): string[] => [...new Set([...existingRuleKeys, ...(addedRuleKeys ?? [])])].sort(); + +export const updateTriageState = (state: TriageState, update: TriageStateUpdate): TriageState => ({ + schemaVersion: TRIAGE_STATE_SCHEMA_VERSION, + lastScanAt: new Date().toISOString(), + prompted: mergeRuleKeys(state.prompted, update.prompted), + skipped: mergeRuleKeys(state.skipped, update.skipped), + disabled: mergeRuleKeys(state.disabled, update.disabled), +}); + +export const pruneTriageState = ( + state: TriageState, + activeRuleKeys: ReadonlySet, +): TriageState => ({ + ...state, + prompted: state.prompted.filter((ruleKey) => activeRuleKeys.has(ruleKey)), + skipped: state.skipped.filter((ruleKey) => activeRuleKeys.has(ruleKey)), + disabled: state.disabled.filter((ruleKey) => activeRuleKeys.has(ruleKey)), +}); + +export const writeTriageState = (outputDirectory: string, state: TriageState): void => { + fs.mkdirSync(outputDirectory, { recursive: true }); + fs.writeFileSync( + getTriageStatePath(outputDirectory), + `${JSON.stringify(state, null, TRIAGE_STATE_JSON_INDENT_SPACES)}\n`, + ); +}; diff --git a/packages/react-doctor/src/cli/utils/write-diagnostics-directory.ts b/packages/react-doctor/src/cli/utils/write-diagnostics-directory.ts index f0f4261d6..b3c31cc80 100644 --- a/packages/react-doctor/src/cli/utils/write-diagnostics-directory.ts +++ b/packages/react-doctor/src/cli/utils/write-diagnostics-directory.ts @@ -6,7 +6,7 @@ import { formatRuleSummary } from "./render-diagnostics.js"; import * as fs from "node:fs"; import * as path from "node:path"; -const ruleDumpFileName = (ruleKey: string): string => ruleKey.replace(/\//g, "--") + ".txt"; +export const ruleDumpFileName = (ruleKey: string): string => ruleKey.replace(/\//g, "--") + ".txt"; // Derives the rule dump files a previous run wrote into this directory from // the diagnostics.json it left behind. An absent, unreadable, or foreign diff --git a/packages/react-doctor/tests/triage-utils.test.ts b/packages/react-doctor/tests/triage-utils.test.ts new file mode 100644 index 000000000..73791f8fa --- /dev/null +++ b/packages/react-doctor/tests/triage-utils.test.ts @@ -0,0 +1,85 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { describe, expect, it } from "vite-plus/test"; +import type { Diagnostic } from "@react-doctor/core"; +import { buildTriageRulePrompt } from "../src/cli/utils/build-triage-rule-prompt.js"; +import { ensureReactDoctorGitignore } from "../src/cli/utils/ensure-react-doctor-gitignore.js"; +import { + emptyTriageState, + pruneTriageState, + readTriageState, + updateTriageState, + writeTriageState, +} from "../src/cli/utils/triage-state.js"; + +const makeDiagnostic = (overrides: Partial = {}): Diagnostic => ({ + filePath: "apps/web/src/app.tsx", + plugin: "react-doctor", + rule: "prefer-module-scope-pure-function", + severity: "warning", + title: "Hoist pure helpers", + message: "Hoist the pure helper to module scope so it is not reallocated each render.", + help: "Move the helper above the component.", + line: 12, + column: 3, + category: "Performance", + ...overrides, +}); + +describe("buildTriageRulePrompt", () => { + it("builds a focused single-rule prompt with verification instructions", () => { + const prompt = buildTriageRulePrompt({ + ruleKey: "react-doctor/prefer-module-scope-pure-function", + diagnostics: [makeDiagnostic()], + projectName: "openpaper", + outputDirectory: "/tmp/react-doctor-triage", + }); + + expect(prompt).toContain("Fix exactly one React Doctor rule in openpaper"); + expect(prompt).toContain("Fix only react-doctor/prefer-module-scope-pure-function"); + expect(prompt).toContain("apps/web/src/app.tsx:12"); + expect(prompt).toContain('react-doctor triage --output-dir "/tmp/react-doctor-triage"'); + }); +}); + +describe("triage state", () => { + it("round-trips and prunes stale rule keys", () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "react-doctor-triage-state-")); + try { + const state = updateTriageState(emptyTriageState(), { + prompted: ["react-doctor/a", "react-doctor/stale"], + skipped: ["react-doctor/b"], + disabled: ["react-doctor/c"], + }); + writeTriageState(directory, state); + + const pruned = pruneTriageState( + readTriageState(directory), + new Set(["react-doctor/a", "react-doctor/b"]), + ); + + expect(pruned.prompted).toEqual(["react-doctor/a"]); + expect(pruned.skipped).toEqual(["react-doctor/b"]); + expect(pruned.disabled).toEqual([]); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } + }); +}); + +describe("ensureReactDoctorGitignore", () => { + it("adds the local react-doctor state directory once", () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "react-doctor-gitignore-")); + try { + const didWrite = ensureReactDoctorGitignore(directory); + const didWriteAgain = ensureReactDoctorGitignore(directory); + + expect(didWrite).toBe(true); + expect(didWriteAgain).toBe(false); + expect(fs.readFileSync(path.join(directory, ".gitignore"), "utf8")).toBe(".react-doctor/\n"); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/website/src/components/terminal.tsx b/packages/website/src/components/terminal.tsx index ac2099956..648e177c3 100644 --- a/packages/website/src/components/terminal.tsx +++ b/packages/website/src/components/terminal.tsx @@ -402,6 +402,11 @@ const Terminal = () => { Star on GitHub +
+ Large backlog? Run{" "} + npx react-doctor@latest triage to walk rules + one by one and copy focused fix prompts. +
)}