From da2f874bd9573f0d643c729e8437121d7f959b5b Mon Sep 17 00:00:00 2001 From: Nikita Bayev Date: Fri, 20 Feb 2026 11:51:30 +0500 Subject: [PATCH] feat: HTML diagnostic report and --open-report flag - Add self-contained report.html next to diagnostics.json when issues are found - Report shows score (when available), summary counts, and diagnostics grouped by rule - Add --open-report to open the HTML report in the default browser after scan - Add openFile() util for cross-platform file opening (macOS, Windows, Linux) - Update README, llms.txt, and react-doctor skill docs with new options and report behavior Co-authored-by: Cursor --- packages/react-doctor/README.md | 4 +- packages/react-doctor/src/cli.ts | 3 + packages/react-doctor/src/scan.ts | 35 ++- packages/react-doctor/src/types.ts | 8 + packages/react-doctor/src/utils/open-file.ts | 18 ++ .../react-doctor/src/utils/report-template.ts | 199 ++++++++++++++++++ .../tests/report-template.test.ts | 131 ++++++++++++ packages/react-doctor/tests/scan.test.ts | 19 ++ packages/website/public/llms.txt | 8 + skills/react-doctor/SKILL.md | 8 +- 10 files changed, 428 insertions(+), 5 deletions(-) create mode 100644 packages/react-doctor/src/utils/open-file.ts create mode 100644 packages/react-doctor/src/utils/report-template.ts create mode 100644 packages/react-doctor/tests/report-template.test.ts diff --git a/packages/react-doctor/README.md b/packages/react-doctor/README.md index 9751db5..976c2ef 100644 --- a/packages/react-doctor/README.md +++ b/packages/react-doctor/README.md @@ -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 @@ -62,6 +62,8 @@ Options: -y, --yes skip prompts, scan all workspace projects --project 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 diff --git a/packages/react-doctor/src/cli.ts b/packages/react-doctor/src/cli.ts index 269ec87..5a1243a 100644 --- a/packages/react-doctor/src/cli.ts +++ b/packages/react-doctor/src/cli.ts @@ -34,6 +34,7 @@ interface CliFlags { fix: boolean; yes: boolean; offline: boolean; + openReport?: boolean; ami: boolean; project?: string; diff?: boolean | string; @@ -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, }; }; @@ -128,6 +130,7 @@ const program = new Command() .option("--project ", "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) => { diff --git a/packages/react-doctor/src/scan.ts b/packages/react-doctor/src/scan.ts index 517631f..5f7b910 100644 --- a/packages/react-doctor/src/scan.ts +++ b/packages/react-doctor/src/scan.ts @@ -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"; @@ -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); @@ -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; }; @@ -312,6 +326,8 @@ const buildCountsSummaryLine = ( return createFramedLine(plainParts.join(" "), renderedParts.join(" ")); }; +const REPORT_HTML_FILENAME = "report.html"; + const printSummary = ( diagnostics: Diagnostic[], elapsedMilliseconds: number, @@ -319,6 +335,7 @@ const printSummary = ( projectName: string, totalSourceFileCount: number, noScoreMessage: string, + openReport: boolean, ): void => { const summaryFramedLines = [ ...buildBrandingLines(scoreResult, noScoreMessage), @@ -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(); } @@ -403,6 +429,7 @@ interface ResolvedScanOptions { verbose: boolean; scoreOnly: boolean; offline: boolean; + openReport: boolean; includePaths: string[]; } @@ -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 ?? [], }); @@ -595,6 +623,7 @@ export const scan = async ( projectInfo.projectName, displayedSourceFileCount, noScoreMessage, + options.openReport, ); if (hasSkippedChecks) { diff --git a/packages/react-doctor/src/types.ts b/packages/react-doctor/src/types.ts index 7996b0e..2aa0fb3 100644 --- a/packages/react-doctor/src/types.ts +++ b/packages/react-doctor/src/types.ts @@ -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; @@ -102,6 +109,7 @@ export interface ScanOptions { verbose?: boolean; scoreOnly?: boolean; offline?: boolean; + openReport?: boolean; includePaths?: string[]; } diff --git a/packages/react-doctor/src/utils/open-file.ts b/packages/react-doctor/src/utils/open-file.ts new file mode 100644 index 0000000..eeb5df3 --- /dev/null +++ b/packages/react-doctor/src/utils/open-file.ts @@ -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" }); +}; diff --git a/packages/react-doctor/src/utils/report-template.ts b/packages/react-doctor/src/utils/report-template.ts new file mode 100644 index 0000000..db7618b --- /dev/null +++ b/packages/react-doctor/src/utils/report-template.ts @@ -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, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + +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 => { + const groups = new Map(); + 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 => { + const fileLines = new Map(); + 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( + `\u2717 ${errorCount} error${errorCount === 1 ? "" : "s"}`, + ); + } + if (warningCount > 0) { + summaryParts.push( + `\u26A0 ${warningCount} warning${warningCount === 1 ? "" : "s"}`, + ); + } + summaryParts.push( + `across ${affectedFileCount} file${affectedFileCount === 1 ? "" : "s"}`, + ); + + 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 += `
  • ${escapeHtml(filePath)}${escapeHtml(lineLabel)}
  • `; + } + + const helpHtml = firstDiagnostic.help + ? `

    ${escapeHtml(firstDiagnostic.help)}

    ` + : ""; + + diagnosticsSections += ` +
    +

    + + ${escapeHtml(firstDiagnostic.message)}${escapeHtml(countLabel)} +

    +

    ${escapeHtml(ruleKey)}

    + ${helpHtml} +
      ${locationsHtml}
    +
    `; + } + + const scoreSection = + score !== null + ? ` +
    + +
    + ${score} / ${PERFECT_SCORE} + ${escapeHtml(displayLabel ?? "")} +
    +
    +
    +
    +
    ` + : ` +
    +

    Score unavailable (offline or error).

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

    React Doctor

    +

    www.react.doctor

    + ${scoreSection} +

    ${summaryParts.join(" ")}

    +
    +
    +

    Diagnostics

    + ${diagnosticsSections} +
    + +`; +}; diff --git a/packages/react-doctor/tests/report-template.test.ts b/packages/react-doctor/tests/report-template.test.ts new file mode 100644 index 0000000..171b8d1 --- /dev/null +++ b/packages/react-doctor/tests/report-template.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from "vitest"; +import type { Diagnostic, ReportPayload } from "../src/types.js"; +import { buildReportHtml } from "../src/utils/report-template.js"; + +const createDiagnostic = (overrides: Partial = {}): Diagnostic => ({ + filePath: "src/App.tsx", + plugin: "basic-react", + rule: "no-danger", + severity: "error", + message: "Avoid using dangerouslySetInnerHTML.", + help: "Use a sanitization library or render text instead.", + line: 10, + column: 5, + category: "Security", + ...overrides, +}); + +describe("buildReportHtml", () => { + it("includes document structure and React Doctor header", () => { + const payload: ReportPayload = { + diagnostics: [], + score: null, + label: null, + projectName: "test-project", + }; + const html = buildReportHtml(payload); + expect(html).toContain(""); + expect(html).toContain("test-project \u2014 React Doctor"); + expect(html).toContain("

    React Doctor

    "); + expect(html).toContain("www.react.doctor"); + }); + + it("includes score section when score is present", () => { + const payload: ReportPayload = { + diagnostics: [], + score: 85, + label: "Great", + projectName: "my-app", + }; + const html = buildReportHtml(payload); + expect(html).toContain("85"); + expect(html).toContain("/ 100"); + expect(html).toContain("Great"); + expect(html).toContain("score-bar-fill"); + }); + + it("shows score unavailable when score is null", () => { + const payload: ReportPayload = { + diagnostics: [createDiagnostic()], + score: null, + label: null, + projectName: "offline-run", + }; + const html = buildReportHtml(payload); + expect(html).toContain("Score unavailable"); + expect(html).toContain("score-unavailable"); + }); + + it("includes summary counts for errors and warnings", () => { + const payload: ReportPayload = { + diagnostics: [ + createDiagnostic({ severity: "error" }), + createDiagnostic({ severity: "error" }), + createDiagnostic({ severity: "warning" }), + ], + score: 70, + label: "Needs work", + projectName: "counts-test", + }; + const html = buildReportHtml(payload); + expect(html).toContain("2 errors"); + expect(html).toContain("1 warning"); + }); + + it("includes diagnostic groups with rule key, message, help, and locations", () => { + const payload: ReportPayload = { + diagnostics: [ + createDiagnostic({ + message: "Avoid using dangerouslySetInnerHTML.", + help: "Use a sanitization library.", + filePath: "src/App.tsx", + line: 10, + }), + ], + score: null, + label: null, + projectName: "diagnostics-test", + }; + const html = buildReportHtml(payload); + expect(html).toContain("Avoid using dangerouslySetInnerHTML."); + expect(html).toContain("Use a sanitization library."); + expect(html).toContain("basic-react/no-danger"); + expect(html).toContain("src/App.tsx"); + expect(html).toContain(":10"); + }); + + it("escapes HTML in diagnostic message and help", () => { + const payload: ReportPayload = { + diagnostics: [ + createDiagnostic({ + message: "Message with tags", + help: 'Help with "quotes" and HTML', + }), + ], + score: null, + label: null, + projectName: "escape-test", + }; + const html = buildReportHtml(payload); + expect(html).not.toContain(""); + expect(html).toContain("<script>"); + expect(html).toContain(""quotes""); + expect(html).toContain("<b>HTML</b>"); + }); + + it("groups multiple diagnostics by plugin/rule and shows count", () => { + const payload: ReportPayload = { + diagnostics: [ + createDiagnostic({ filePath: "src/A.tsx", line: 1 }), + createDiagnostic({ filePath: "src/B.tsx", line: 2 }), + ], + score: 50, + label: "Needs work", + projectName: "group-test", + }; + const html = buildReportHtml(payload); + expect(html).toContain("(2)"); + expect(html).toContain("src/A.tsx"); + expect(html).toContain("src/B.tsx"); + }); +}); diff --git a/packages/react-doctor/tests/scan.test.ts b/packages/react-doctor/tests/scan.test.ts index 7bf6875..f27b3c7 100644 --- a/packages/react-doctor/tests/scan.test.ts +++ b/packages/react-doctor/tests/scan.test.ts @@ -81,4 +81,23 @@ describe("scan", () => { consoleSpy.mockRestore(); } }); + + it("writes report.html and logs report path when diagnostics exist", async () => { + const logCalls: string[] = []; + const consoleSpy = vi.spyOn(console, "log").mockImplementation((message: string) => { + logCalls.push(message); + }); + try { + await scan(path.join(FIXTURES_DIRECTORY, "basic-react"), { + lint: true, + deadCode: false, + }); + const hasReportWrittenMessage = logCalls.some((call) => call.includes("report written to")); + const hasReportOpenMessage = logCalls.some((call) => call.includes("report.html")); + expect(hasReportWrittenMessage).toBe(true); + expect(hasReportOpenMessage).toBe(true); + } finally { + consoleSpy.mockRestore(); + } + }); }); diff --git a/packages/website/public/llms.txt b/packages/website/public/llms.txt index 8a07570..349cd3b 100644 --- a/packages/website/public/llms.txt +++ b/packages/website/public/llms.txt @@ -34,6 +34,12 @@ Use `-y` to skip prompts (required for non-interactive environments like CI or c npx -y react-doctor@latest . -y ``` +When issues are found, diagnostics and an HTML report are written to a temp directory. Use `--open-report` to open the report in the browser after the scan: + +```bash +npx -y react-doctor@latest . --open-report +``` + ## Options ``` @@ -48,6 +54,8 @@ Options: -y, --yes skip prompts, scan all workspace projects --project select workspace project (comma-separated for multiple) --diff [base] scan only files changed vs base branch or uncommitted changes + --offline skip telemetry (score not calculated) + --open-report open the HTML report in the default browser after scan --fix open Ami to auto-fix all issues -h, --help display help for command ``` diff --git a/skills/react-doctor/SKILL.md b/skills/react-doctor/SKILL.md index 8cc27cf..ddf160e 100644 --- a/skills/react-doctor/SKILL.md +++ b/skills/react-doctor/SKILL.md @@ -6,7 +6,7 @@ version: 1.0.0 # React Doctor -Scans your React codebase for security, performance, correctness, and architecture issues. Outputs a 0-100 score with actionable diagnostics. +Scans your React codebase for security, performance, correctness, and architecture issues. Outputs a 0-100 score with actionable diagnostics. Writes diagnostics.json and an HTML report (report.html) when issues are found; use `--open-report` to open the report in the browser. ## Usage @@ -14,6 +14,12 @@ Scans your React codebase for security, performance, correctness, and architectu npx -y react-doctor@latest . --verbose --diff ``` +Open the HTML report after the scan: + +```bash +npx -y react-doctor@latest . --open-report +``` + ## Workflow Run after making changes to catch issues early. Fix errors first, then re-run to verify the score improved.