Skip to content
Open
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
4 changes: 3 additions & 1 deletion packages/react-doctor/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ React Doctor detects your framework (Next.js, Vite, Remix, etc.), React version,
1. **Lint**: Checks 60+ rules across state & effects, performance, architecture, bundle size, security, correctness, accessibility, and framework-specific categories (Next.js, React Native). Rules are toggled automatically based on your project setup.
2. **Dead code**: Detects unused files, exports, types, and duplicates.

Diagnostics are filtered through your config, then scored by severity (errors weigh more than warnings) to produce a **0–100 health score** (75+ Great, 50–74 Needs work, <50 Critical).
Diagnostics are filtered through your config, then scored by severity (errors weigh more than warnings) to produce a **0–100 health score** (75+ Great, 50–74 Needs work, <50 Critical). When issues are found, a **diagnostics.json** and an **HTML report** (report.html) are written to a temporary directory; use `--open-report` to open the report in your browser after the scan.

## Install

Expand Down Expand Up @@ -62,6 +62,8 @@ Options:
-y, --yes skip prompts, scan all workspace projects
--project <name> select workspace project (comma-separated for multiple)
--diff [base] scan only files changed vs base branch
--offline skip telemetry (score not calculated)
--open-report open the HTML report in the default browser after scan
--no-ami skip Ami-related prompts
--fix open Ami to auto-fix all issues
-h, --help display help for command
Expand Down
3 changes: 3 additions & 0 deletions packages/react-doctor/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ interface CliFlags {
fix: boolean;
yes: boolean;
offline: boolean;
openReport?: boolean;
ami: boolean;
project?: string;
diff?: boolean | string;
Expand Down Expand Up @@ -77,6 +78,7 @@ const resolveCliScanOptions = (
verbose: isCliOverride("verbose") ? Boolean(flags.verbose) : (userConfig?.verbose ?? false),
scoreOnly: flags.score,
offline: flags.offline,
openReport: flags.openReport ?? false,
};
};

Expand Down Expand Up @@ -128,6 +130,7 @@ const program = new Command()
.option("--project <name>", "select workspace project (comma-separated for multiple)")
.option("--diff [base]", "scan only files changed vs base branch")
.option("--offline", "skip telemetry (anonymous, not stored, only used to calculate score)")
.option("--open-report", "open the HTML report in the default browser after scan")
.option("--no-ami", "skip Ami-related prompts")
.option("--fix", "open Ami to auto-fix all issues")
.action(async (directory: string, flags: CliFlags) => {
Expand Down
35 changes: 32 additions & 3 deletions packages/react-doctor/src/scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ import type {
ScanResult,
ScoreResult,
} from "./types.js";
import { openFile } from "./utils/open-file.js";
import { buildReportHtml } from "./utils/report-template.js";
import { calculateScore } from "./utils/calculate-score.js";
import { colorizeByScore } from "./utils/colorize-by-score.js";
import { combineDiagnostics, computeJsxIncludePaths } from "./utils/combine-diagnostics.js";
Expand Down Expand Up @@ -144,7 +146,11 @@ const formatRuleSummary = (ruleKey: string, ruleDiagnostics: Diagnostic[]): stri
return sections.join("\n") + "\n";
};

const writeDiagnosticsDirectory = (diagnostics: Diagnostic[]): string => {
const writeReportDirectory = (
diagnostics: Diagnostic[],
scoreResult: ScoreResult | null,
projectName: string,
): string => {
const outputDirectory = join(tmpdir(), `react-doctor-${randomUUID()}`);
mkdirSync(outputDirectory);

Expand All @@ -161,6 +167,14 @@ const writeDiagnosticsDirectory = (diagnostics: Diagnostic[]): string => {

writeFileSync(join(outputDirectory, "diagnostics.json"), JSON.stringify(diagnostics, null, 2));

const payload = {
diagnostics,
score: scoreResult?.score ?? null,
label: scoreResult?.label ?? null,
projectName,
};
writeFileSync(join(outputDirectory, "report.html"), buildReportHtml(payload));

return outputDirectory;
};

Expand Down Expand Up @@ -312,13 +326,16 @@ const buildCountsSummaryLine = (
return createFramedLine(plainParts.join(" "), renderedParts.join(" "));
};

const REPORT_HTML_FILENAME = "report.html";

const printSummary = (
diagnostics: Diagnostic[],
elapsedMilliseconds: number,
scoreResult: ScoreResult | null,
projectName: string,
totalSourceFileCount: number,
noScoreMessage: string,
openReport: boolean,
): void => {
const summaryFramedLines = [
...buildBrandingLines(scoreResult, noScoreMessage),
Expand All @@ -327,9 +344,18 @@ const printSummary = (
printFramedBox(summaryFramedLines);

try {
const diagnosticsDirectory = writeDiagnosticsDirectory(diagnostics);
const reportDirectory = writeReportDirectory(diagnostics, scoreResult, projectName);
logger.break();
logger.dim(` Full diagnostics written to ${diagnosticsDirectory}`);
logger.dim(` Full diagnostics and report written to ${reportDirectory}`);
logger.dim(` Open report.html to view the report.`);

if (openReport) {
try {
openFile(join(reportDirectory, REPORT_HTML_FILENAME));
} catch {
logger.dim(` Could not open report in browser. Open report.html manually.`);
}
}
} catch {
logger.break();
}
Expand Down Expand Up @@ -403,6 +429,7 @@ interface ResolvedScanOptions {
verbose: boolean;
scoreOnly: boolean;
offline: boolean;
openReport: boolean;
includePaths: string[];
}

Expand All @@ -415,6 +442,7 @@ const mergeScanOptions = (
verbose: inputOptions.verbose ?? userConfig?.verbose ?? false,
scoreOnly: inputOptions.scoreOnly ?? false,
offline: inputOptions.offline ?? false,
openReport: inputOptions.openReport ?? false,
includePaths: inputOptions.includePaths ?? [],
});

Expand Down Expand Up @@ -595,6 +623,7 @@ export const scan = async (
projectInfo.projectName,
displayedSourceFileCount,
noScoreMessage,
options.openReport,
);

if (hasSkippedChecks) {
Expand Down
8 changes: 8 additions & 0 deletions packages/react-doctor/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,13 @@ export interface ScoreResult {
label: string;
}

export interface ReportPayload {
diagnostics: Diagnostic[];
score: number | null;
label: string | null;
projectName: string;
}

export interface ScanResult {
diagnostics: Diagnostic[];
scoreResult: ScoreResult | null;
Expand All @@ -102,6 +109,7 @@ export interface ScanOptions {
verbose?: boolean;
scoreOnly?: boolean;
offline?: boolean;
openReport?: boolean;
includePaths?: string[];
}

Expand Down
18 changes: 18 additions & 0 deletions packages/react-doctor/src/utils/open-file.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { execSync } from "node:child_process";
import path from "node:path";
import { pathToFileURL } from "node:url";

export const openFile = (filePath: string): void => {
const absolutePath = path.resolve(filePath);
const url = pathToFileURL(absolutePath).toString();

if (process.platform === "win32") {
const cmdEscapedUrl = url.replace(/%/g, "%%");
execSync(`start "" "${cmdEscapedUrl}"`, { stdio: "ignore" });
return;
}

const openCommand =
process.platform === "darwin" ? `open "${url}"` : `xdg-open "${url}"`;
execSync(openCommand, { stdio: "ignore" });
};
199 changes: 199 additions & 0 deletions packages/react-doctor/src/utils/report-template.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
import type { Diagnostic, ReportPayload } from "../types.js";

const PERFECT_SCORE = 100;
const SCORE_GOOD_THRESHOLD = 75;
const SCORE_OK_THRESHOLD = 50;

const escapeHtml = (raw: string): string =>
raw
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");

const getScoreLabel = (score: number): string => {
if (score >= SCORE_GOOD_THRESHOLD) return "Great";
if (score >= SCORE_OK_THRESHOLD) return "Needs work";
return "Critical";
};

const getScoreColor = (score: number): string => {
if (score >= SCORE_GOOD_THRESHOLD) return "#22c55e";
if (score >= SCORE_OK_THRESHOLD) return "#eab308";
return "#ef4444";
};

const getDoctorFace = (score: number): [string, string] => {
if (score >= SCORE_GOOD_THRESHOLD) return ["\u25E0 \u25E0", " \u25BD "];
if (score >= SCORE_OK_THRESHOLD) return ["\u2022 \u2022", " \u2500 "];
return ["x x", " \u25BD "];
};

const groupByRule = (diagnostics: Diagnostic[]): Map<string, Diagnostic[]> => {
const groups = new Map<string, Diagnostic[]>();
for (const diagnostic of diagnostics) {
const key = `${diagnostic.plugin}/${diagnostic.rule}`;
const existing = groups.get(key) ?? [];
existing.push(diagnostic);
groups.set(key, existing);
}
return groups;
};

const sortBySeverity = (entries: [string, Diagnostic[]][]): [string, Diagnostic[]][] => {
const order = { error: 0, warning: 1 };
return entries.toSorted(
([, diagnosticsA], [, diagnosticsB]) =>
order[diagnosticsA[0].severity] - order[diagnosticsB[0].severity],
);
};

const buildFileLineMap = (diagnostics: Diagnostic[]): Map<string, number[]> => {
const fileLines = new Map<string, number[]>();
for (const diagnostic of diagnostics) {
const lines = fileLines.get(diagnostic.filePath) ?? [];
if (diagnostic.line > 0) {
lines.push(diagnostic.line);
}
fileLines.set(diagnostic.filePath, lines);
}
return fileLines;
};

export const buildReportHtml = (payload: ReportPayload): string => {
const { diagnostics, score, label, projectName } = payload;
const errorCount = diagnostics.filter((diagnostic) => diagnostic.severity === "error").length;
const warningCount = diagnostics.filter((diagnostic) => diagnostic.severity === "warning").length;
const affectedFileCount = new Set(diagnostics.map((diagnostic) => diagnostic.filePath)).size;

const ruleGroups = groupByRule(diagnostics);
const sortedRuleGroups = sortBySeverity([...ruleGroups.entries()]);

const scoreBarPercent = score !== null ? Math.round((score / PERFECT_SCORE) * 100) : 0;
const scoreColor = score !== null ? getScoreColor(score) : "#6b7280";
const displayLabel = label ?? (score !== null ? getScoreLabel(score) : null);
const [eyes, mouth] = score !== null ? getDoctorFace(score) : ["? ?", " ? "];

const summaryParts: string[] = [];
if (errorCount > 0) {
summaryParts.push(
`<span class="summary-error">\u2717 ${errorCount} error${errorCount === 1 ? "" : "s"}</span>`,
);
}
if (warningCount > 0) {
summaryParts.push(
`<span class="summary-warning">\u26A0 ${warningCount} warning${warningCount === 1 ? "" : "s"}</span>`,
);
}
summaryParts.push(
`<span class="summary-muted">across ${affectedFileCount} file${affectedFileCount === 1 ? "" : "s"}</span>`,
);

let diagnosticsSections = "";
for (const [ruleKey, ruleDiagnostics] of sortedRuleGroups) {
const firstDiagnostic = ruleDiagnostics[0];
const severityClass =
firstDiagnostic.severity === "error" ? "severity-error" : "severity-warning";
const severitySymbol = firstDiagnostic.severity === "error" ? "\u2717" : "\u26A0";
const countLabel = ruleDiagnostics.length > 1 ? ` (${ruleDiagnostics.length})` : "";
const fileLines = buildFileLineMap(ruleDiagnostics);

let locationsHtml = "";
for (const [filePath, lines] of fileLines) {
const lineLabel = lines.length > 0 ? `:${lines.join(", ")}` : "";
locationsHtml += `<li><code>${escapeHtml(filePath)}${escapeHtml(lineLabel)}</code></li>`;
}

const helpHtml = firstDiagnostic.help
? `<p class="diagnostic-help">${escapeHtml(firstDiagnostic.help)}</p>`
: "";

diagnosticsSections += `
<section class="diagnostic-group" aria-labelledby="rule-${escapeHtml(ruleKey.replace(/\//g, "--"))}">
<h2 id="rule-${escapeHtml(ruleKey.replace(/\//g, "--"))}" class="diagnostic-heading ${severityClass}">
<span class="severity-icon" aria-hidden="true">${severitySymbol}</span>
${escapeHtml(firstDiagnostic.message)}${escapeHtml(countLabel)}
</h2>
<p class="diagnostic-rule"><code>${escapeHtml(ruleKey)}</code></p>
${helpHtml}
<ul class="diagnostic-locations">${locationsHtml}</ul>
</section>`;
}

const scoreSection =
score !== null
? `
<div class="score-section">
<pre class="doctor-face" aria-hidden="true"> \u250C\u2500\u2500\u2500\u2500\u2500\u2510
\u2502 ${eyes} \u2502
\u2502 ${mouth} \u2502
\u2514\u2500\u2500\u2500\u2500\u2500\u2518</pre>
<div class="score-gauge">
<span class="score-value">${score}</span> / ${PERFECT_SCORE}
<span class="score-label">${escapeHtml(displayLabel ?? "")}</span>
</div>
<div class="score-bar-track">
<div class="score-bar-fill" style="width: ${scoreBarPercent}%; background-color: ${scoreColor};"></div>
</div>
</div>`
: `
<div class="score-section score-unavailable">
<p>Score unavailable (offline or error).</p>
</div>`;

const projectTitle = projectName
? `${escapeHtml(projectName)} \u2014 React Doctor`
: "React Doctor";

return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${projectTitle}</title>
<style>
* { box-sizing: border-box; }
body { font-family: ui-sans-serif, system-ui, sans-serif; line-height: 1.5; color: #e5e7eb; background: #111827; margin: 0; padding: 1.5rem; max-width: 56rem; margin-left: auto; margin-right: auto; }
header { margin-bottom: 2rem; }
h1 { font-size: 1.5rem; font-weight: 600; margin: 0 0 0.5rem 0; }
.subtitle { font-size: 0.875rem; color: #9ca3af; margin-bottom: 1.5rem; }
.summary { font-size: 0.875rem; margin-bottom: 1.5rem; }
.summary-error { color: #f87171; }
.summary-warning { color: #fbbf24; }
.summary-muted { color: #9ca3af; }
.score-section { margin-bottom: 2rem; }
.score-unavailable p { color: #9ca3af; margin: 0; }
.doctor-face { font-size: 1rem; margin: 0 0 0.5rem 0; line-height: 1.2; }
.score-gauge { font-size: 1.25rem; margin-bottom: 0.5rem; }
.score-value { font-weight: 700; }
.score-label { margin-left: 0.5rem; }
.score-bar-track { height: 0.5rem; background: #374151; border-radius: 0.25rem; overflow: hidden; }
.score-bar-fill { height: 100%; border-radius: 0.25rem; }
main h2 { font-size: 1rem; margin: 0 0 0.25rem 0; }
.diagnostic-group { margin-bottom: 1.5rem; padding-bottom: 1.5rem; border-bottom: 1px solid #374151; }
.diagnostic-group:last-child { border-bottom: none; }
.diagnostic-heading { display: flex; align-items: baseline; gap: 0.5rem; }
.severity-error { color: #f87171; }
.severity-warning { color: #fbbf24; }
.severity-icon { flex-shrink: 0; }
.diagnostic-rule { font-size: 0.8125rem; color: #9ca3af; margin: 0 0 0.25rem 0; }
.diagnostic-help { font-size: 0.875rem; color: #d1d5db; margin: 0.25rem 0; }
.diagnostic-locations { font-size: 0.8125rem; color: #9ca3af; margin: 0.25rem 0; padding-left: 1.25rem; }
.diagnostic-locations code { word-break: break-all; }
</style>
</head>
<body>
<header>
<h1>React Doctor</h1>
<p class="subtitle">www.react.doctor</p>
${scoreSection}
<p class="summary">${summaryParts.join(" ")}</p>
</header>
<main>
<h2 id="diagnostics-heading">Diagnostics</h2>
${diagnosticsSections}
</main>
</body>
</html>`;
};
Loading