diff --git a/packages/react-doctor/README.md b/packages/react-doctor/README.md index 23b211d..c572ade 100644 --- a/packages/react-doctor/README.md +++ b/packages/react-doctor/README.md @@ -38,6 +38,12 @@ Use `--verbose` to see affected files and line numbers: npx -y react-doctor@latest . --verbose ``` +Write a Markdown report to a file: + +```bash +npx -y react-doctor@latest . --report-md react-doctor-report.md +``` + ## Install for your coding agent Teach your coding agent all 47+ React best practice rules: @@ -82,6 +88,7 @@ Options: --no-dead-code skip dead code detection --verbose show file details per rule --score output only the score + --report-md write a markdown report to file -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 diff --git a/packages/react-doctor/package.json b/packages/react-doctor/package.json index ebda83d..4d86b79 100644 --- a/packages/react-doctor/package.json +++ b/packages/react-doctor/package.json @@ -43,7 +43,7 @@ }, "scripts": { "dev": "tsdown --watch", - "build": "rm -rf dist && NODE_ENV=production tsdown", + "build": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsdown", "typecheck": "tsc --noEmit", "test": "pnpm build && vitest run" }, diff --git a/packages/react-doctor/src/cli.ts b/packages/react-doctor/src/cli.ts index 5b6cec0..0a534fc 100644 --- a/packages/react-doctor/src/cli.ts +++ b/packages/react-doctor/src/cli.ts @@ -10,6 +10,7 @@ import type { DiffInfo, EstimatedScoreResult, FailOnLevel, + MarkdownReportProject, ReactDoctorConfig, ScanOptions, } from "./types.js"; @@ -24,6 +25,7 @@ import { logger } from "./utils/logger.js"; import { clearSelectBanner, prompts, setSelectBanner } from "./utils/prompts.js"; import { selectProjects } from "./utils/select-projects.js"; import { maybePromptSkillInstall } from "./utils/skill-prompt.js"; +import { writeMarkdownReport } from "./utils/write-markdown-report.js"; const VERSION = process.env.VERSION ?? "0.0.0"; @@ -37,6 +39,7 @@ interface CliFlags { offline: boolean; ami: boolean; project?: string; + reportMd?: string; diff?: boolean | string; failOn: string; } @@ -139,6 +142,7 @@ const program = new Command() .option("--no-dead-code", "skip dead code detection") .option("--verbose", "show file details per rule") .option("--score", "output only the score") + .option("--report-md ", "write a markdown report to file") .option("-y, --yes", "skip prompts, scan all workspace projects") .option("--project ", "select workspace project (comma-separated for multiple)") .option("--diff [base]", "scan only files changed vs base branch") @@ -190,6 +194,7 @@ const program = new Command() } const allDiagnostics: Diagnostic[] = []; + const markdownReportProjects: MarkdownReportProject[] = []; for (const projectDirectory of projectDirectories) { let includePaths: string[] | undefined; @@ -214,11 +219,45 @@ const program = new Command() } const scanResult = await scan(projectDirectory, { ...scanOptions, includePaths }); allDiagnostics.push(...scanResult.diagnostics); + markdownReportProjects.push({ + projectDirectory, + projectName: scanResult.project.projectName, + framework: scanResult.project.framework, + reactVersion: scanResult.project.reactVersion, + sourceFileCount: scanResult.project.sourceFileCount, + diagnostics: scanResult.diagnostics, + scoreResult: scanResult.scoreResult, + skippedChecks: scanResult.skippedChecks, + elapsedMilliseconds: scanResult.elapsedMilliseconds, + }); if (!isScoreOnly) { logger.break(); } } + if (flags.reportMd) { + const markdownReportPath = writeMarkdownReport( + { + generatedAtIso: new Date().toISOString(), + rootDirectory: resolvedDirectory, + isDiffMode, + isOffline: scanOptions.offline ?? false, + isScoreOnly, + isLintEnabled: scanOptions.lint ?? true, + isDeadCodeEnabled: scanOptions.deadCode ?? true, + isVerboseEnabled: scanOptions.verbose ?? false, + diagnostics: allDiagnostics, + projects: markdownReportProjects, + }, + flags.reportMd, + ); + + if (!isScoreOnly) { + logger.break(); + logger.dim(`Markdown report written to ${markdownReportPath}`); + } + } + const resolvedFailOn = program.getOptionValueSource("failOn") === "cli" ? flags.failOn diff --git a/packages/react-doctor/src/plugin/rules/nextjs.ts b/packages/react-doctor/src/plugin/rules/nextjs.ts index 713177d..0577b56 100644 --- a/packages/react-doctor/src/plugin/rules/nextjs.ts +++ b/packages/react-doctor/src/plugin/rules/nextjs.ts @@ -1,5 +1,4 @@ import { - APP_DIRECTORY_PATTERN, EFFECT_HOOK_NAMES, EXECUTABLE_SCRIPT_TYPES, GOOGLE_FONTS_PATTERN, @@ -7,11 +6,7 @@ import { MUTATING_ROUTE_SEGMENTS, NEXTJS_NAVIGATION_FUNCTIONS, OG_ROUTE_PATTERN, - PAGE_FILE_PATTERN, - PAGE_OR_LAYOUT_FILE_PATTERN, - PAGES_DIRECTORY_PATTERN, POLYFILL_SCRIPT_PATTERN, - ROUTE_HANDLER_FILE_PATTERN, } from "../constants.js"; import { containsFetchCall, @@ -28,9 +23,53 @@ import { } from "../helpers.js"; import type { EsTreeNode, Rule, RuleContext } from "../types.js"; +const normalizeFilePath = (filename: string): string => filename.replaceAll("\\", "/"); + +const PAGE_FILE_BASENAME_PATTERN = /^page\.(tsx?|jsx?)$/; +const PAGE_OR_LAYOUT_BASENAME_PATTERN = /^(page|layout)\.(tsx?|jsx?)$/; +const ROUTE_HANDLER_BASENAME_PATTERN = /^route\.(tsx?|jsx?)$/; +const PAGE_OR_LAYOUT_COMPONENT_NAMES = new Set(["Page", "Layout"]); + +const getNormalizedPathSegments = (filename: string): string[] => + normalizeFilePath(filename).split("/").filter(Boolean); + +const getFileBasename = (filename: string): string => { + const pathSegments = getNormalizedPathSegments(filename); + return pathSegments[pathSegments.length - 1] ?? ""; +}; + +const hasPathSegment = (filename: string, segment: string): boolean => + getNormalizedPathSegments(filename).includes(segment); + +const isPageOrLayoutFile = (filename: string): boolean => + PAGE_OR_LAYOUT_BASENAME_PATTERN.test(getFileBasename(filename)); + +const isPageFile = (filename: string): boolean => + PAGE_FILE_BASENAME_PATTERN.test(getFileBasename(filename)); + +const isRouteHandlerFile = (filename: string): boolean => + ROUTE_HANDLER_BASENAME_PATTERN.test(getFileBasename(filename)); + +const hasDefaultExportedPageOrLayout = (programNode: EsTreeNode): boolean => + Boolean( + programNode.body?.some((statement: EsTreeNode) => { + if (statement.type !== "ExportDefaultDeclaration") return false; + const declaration = statement.declaration; + if (declaration?.type === "Identifier") { + return PAGE_OR_LAYOUT_COMPONENT_NAMES.has(declaration.name); + } + if (declaration?.type === "FunctionDeclaration") { + return Boolean( + declaration.id?.name && PAGE_OR_LAYOUT_COMPONENT_NAMES.has(declaration.id.name), + ); + } + return false; + }), + ); + export const nextjsNoImgElement: Rule = { create: (context: RuleContext) => { - const filename = context.getFilename?.() ?? ""; + const filename = normalizeFilePath(context.getFilename?.() ?? ""); const isOgRoute = OG_ROUTE_PATTERN.test(filename); return { @@ -121,10 +160,12 @@ export const nextjsNoUseSearchParamsWithoutSuspense: Rule = { export const nextjsNoClientFetchForServerData: Rule = { create: (context: RuleContext) => { let fileHasUseClient = false; + let hasPageOrLayoutDefaultExport = false; return { Program(programNode: EsTreeNode) { fileHasUseClient = hasDirective(programNode, "use client"); + hasPageOrLayoutDefaultExport = hasDefaultExportedPageOrLayout(programNode); }, CallExpression(node: EsTreeNode) { if (!fileHasUseClient || !isHookCall(node, EFFECT_HOOK_NAMES)) return; @@ -133,10 +174,10 @@ export const nextjsNoClientFetchForServerData: Rule = { if (!callback || !containsFetchCall(callback)) return; const filename = context.getFilename?.() ?? ""; - const isPageOrLayoutFile = - PAGE_OR_LAYOUT_FILE_PATTERN.test(filename) || PAGES_DIRECTORY_PATTERN.test(filename); + const isPageOrLayoutSourceFile = + isPageOrLayoutFile(filename) || hasPathSegment(filename, "pages"); - if (isPageOrLayoutFile) { + if (isPageOrLayoutSourceFile || hasPageOrLayoutDefaultExport) { context.report({ node, message: @@ -152,8 +193,12 @@ export const nextjsMissingMetadata: Rule = { create: (context: RuleContext) => ({ Program(programNode: EsTreeNode) { const filename = context.getFilename?.() ?? ""; - if (!PAGE_FILE_PATTERN.test(filename)) return; - if (INTERNAL_PAGE_PATH_PATTERN.test(filename)) return; + const normalizedFilename = normalizeFilePath(filename); + const shouldCheckForMetadata = + isPageFile(normalizedFilename) || hasDefaultExportedPageOrLayout(programNode); + + if (!shouldCheckForMetadata) return; + if (INTERNAL_PAGE_PATH_PATTERN.test(normalizedFilename)) return; const hasMetadataExport = programNode.body?.some((statement: EsTreeNode) => { if (statement.type !== "ExportNamedDeclaration") return false; @@ -373,7 +418,7 @@ export const nextjsNoHeadImport: Rule = { if (node.source?.value !== "next/head") return; const filename = context.getFilename?.() ?? ""; - if (!APP_DIRECTORY_PATTERN.test(filename)) return; + if (filename && !hasPathSegment(filename, "app")) return; context.report({ node, @@ -384,8 +429,8 @@ export const nextjsNoHeadImport: Rule = { }; const extractMutatingRouteSegment = (filename: string): string | null => { - const segments = filename.split("/"); - for (const segment of segments) { + const pathSegments = getNormalizedPathSegments(filename); + for (const segment of pathSegments) { const cleaned = segment.replace(/^\[.*\]$/, ""); if (MUTATING_ROUTE_SEGMENTS.has(cleaned)) return cleaned; } @@ -421,7 +466,7 @@ export const nextjsNoSideEffectInGetHandler: Rule = { create: (context: RuleContext) => ({ ExportNamedDeclaration(node: EsTreeNode) { const filename = context.getFilename?.() ?? ""; - if (!ROUTE_HANDLER_FILE_PATTERN.test(filename)) return; + if (filename && !isRouteHandlerFile(filename)) return; const handlerBody = getExportedGetHandlerBody(node); if (!handlerBody) return; diff --git a/packages/react-doctor/src/scan.ts b/packages/react-doctor/src/scan.ts index 7dd8749..d7428f5 100644 --- a/packages/react-doctor/src/scan.ts +++ b/packages/react-doctor/src/scan.ts @@ -482,60 +482,60 @@ export const scan = async ( const lintPromise = resolvedNodeBinaryPath ? (async () => { - const lintSpinner = options.scoreOnly ? null : spinner("Running lint checks...").start(); - try { - const lintDiagnostics = await runOxlint( - directory, - projectInfo.hasTypeScript, - projectInfo.framework, - projectInfo.hasReactCompiler, - jsxIncludePaths, - resolvedNodeBinaryPath, - ); - lintSpinner?.succeed("Running lint checks."); - return lintDiagnostics; - } catch (error) { - didLintFail = true; - if (!options.scoreOnly) { - const errorMessage = error instanceof Error ? error.message : String(error); - const isNativeBindingError = errorMessage.includes("native binding"); - - if (isNativeBindingError) { - lintSpinner?.fail( - `Lint checks failed — oxlint native binding not found (Node ${process.version}).`, - ); - logger.dim( - ` Upgrade to Node ${OXLINT_NODE_REQUIREMENT} or run: npx -p oxlint@latest react-doctor@latest`, - ); - } else { - lintSpinner?.fail("Lint checks failed (non-fatal, skipping)."); - logger.error(errorMessage); - } + const lintSpinner = options.scoreOnly ? null : spinner("Running lint checks...").start(); + try { + const lintDiagnostics = await runOxlint( + directory, + projectInfo.hasTypeScript, + projectInfo.framework, + projectInfo.hasReactCompiler, + jsxIncludePaths, + resolvedNodeBinaryPath, + ); + lintSpinner?.succeed("Running lint checks."); + return lintDiagnostics; + } catch (error) { + didLintFail = true; + if (!options.scoreOnly) { + const errorMessage = error instanceof Error ? error.message : String(error); + const isNativeBindingError = errorMessage.includes("native binding"); + + if (isNativeBindingError) { + lintSpinner?.fail( + `Lint checks failed — oxlint native binding not found (Node ${process.version}).`, + ); + logger.dim( + ` Upgrade to Node ${OXLINT_NODE_REQUIREMENT} or run: npx -p oxlint@latest react-doctor@latest`, + ); + } else { + lintSpinner?.fail("Lint checks failed (non-fatal, skipping)."); + logger.error(errorMessage); } - return []; } - })() + return []; + } + })() : Promise.resolve([]); const deadCodePromise = options.deadCode && !isDiffMode ? (async () => { - const deadCodeSpinner = options.scoreOnly - ? null - : spinner("Detecting dead code...").start(); - try { - const knipDiagnostics = await runKnip(directory); - deadCodeSpinner?.succeed("Detecting dead code."); - return knipDiagnostics; - } catch (error) { - didDeadCodeFail = true; - if (!options.scoreOnly) { - deadCodeSpinner?.fail("Dead code detection failed (non-fatal, skipping)."); - logger.error(String(error)); - } - return []; + const deadCodeSpinner = options.scoreOnly + ? null + : spinner("Detecting dead code...").start(); + try { + const knipDiagnostics = await runKnip(directory); + deadCodeSpinner?.succeed("Detecting dead code."); + return knipDiagnostics; + } catch (error) { + didDeadCodeFail = true; + if (!options.scoreOnly) { + deadCodeSpinner?.fail("Dead code detection failed (non-fatal, skipping)."); + logger.error(String(error)); } - })() + return []; + } + })() : Promise.resolve([]); const [lintDiagnostics, deadCodeDiagnostics] = await Promise.all([lintPromise, deadCodePromise]); @@ -563,7 +563,13 @@ export const scan = async ( } else { logger.dim(noScoreMessage); } - return { diagnostics, scoreResult, skippedChecks }; + return { + diagnostics, + scoreResult, + skippedChecks, + project: projectInfo, + elapsedMilliseconds, + }; } if (diagnostics.length === 0) { @@ -585,7 +591,13 @@ export const scan = async ( } else { logger.dim(` ${noScoreMessage}`); } - return { diagnostics, scoreResult, skippedChecks }; + return { + diagnostics, + scoreResult, + skippedChecks, + project: projectInfo, + elapsedMilliseconds, + }; } printDiagnostics(diagnostics, options.verbose); @@ -607,5 +619,11 @@ export const scan = async ( logger.warn(` Note: ${skippedLabel} checks failed — score may be incomplete.`); } - return { diagnostics, scoreResult, skippedChecks }; + return { + diagnostics, + scoreResult, + skippedChecks, + project: projectInfo, + elapsedMilliseconds, + }; }; diff --git a/packages/react-doctor/src/types.ts b/packages/react-doctor/src/types.ts index b0c4827..2073267 100644 --- a/packages/react-doctor/src/types.ts +++ b/packages/react-doctor/src/types.ts @@ -97,6 +97,8 @@ export interface ScanResult { diagnostics: Diagnostic[]; scoreResult: ScoreResult | null; skippedChecks: string[]; + project: ProjectInfo; + elapsedMilliseconds: number; } export interface EstimatedScoreResult { @@ -175,3 +177,28 @@ export interface ReactDoctorConfig { diff?: boolean | string; failOn?: FailOnLevel; } + +export interface MarkdownReportProject { + projectDirectory: string; + projectName: string; + framework: Framework; + reactVersion: string | null; + sourceFileCount: number; + diagnostics: Diagnostic[]; + scoreResult: ScoreResult | null; + skippedChecks: string[]; + elapsedMilliseconds: number; +} + +export interface MarkdownReportData { + generatedAtIso: string; + rootDirectory: string; + isDiffMode: boolean; + isOffline: boolean; + isScoreOnly: boolean; + isLintEnabled: boolean; + isDeadCodeEnabled: boolean; + isVerboseEnabled: boolean; + diagnostics: Diagnostic[]; + projects: MarkdownReportProject[]; +} diff --git a/packages/react-doctor/src/utils/write-markdown-report.ts b/packages/react-doctor/src/utils/write-markdown-report.ts new file mode 100644 index 0000000..765c01b --- /dev/null +++ b/packages/react-doctor/src/utils/write-markdown-report.ts @@ -0,0 +1,209 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { MILLISECONDS_PER_SECOND } from "../constants.js"; +import type { Diagnostic, MarkdownReportData } from "../types.js"; +import { formatFrameworkName } from "./discover-project.js"; +import { groupBy } from "./group-by.js"; + +const SEVERITY_SORT_KEYS: Record = { + error: "error", + warning: "warning", +}; + +const sanitizeInlineText = (value: string): string => value.replace(/\s+/g, " ").trim(); + +const collectAffectedFileCount = (diagnostics: Diagnostic[]): number => + new Set(diagnostics.map((diagnostic) => diagnostic.filePath)).size; + +const countErrorDiagnostics = (diagnostics: Diagnostic[]): number => + diagnostics.filter((diagnostic) => diagnostic.severity === "error").length; + +const countWarningDiagnostics = (diagnostics: Diagnostic[]): number => + diagnostics.filter((diagnostic) => diagnostic.severity === "warning").length; + +const formatElapsedTime = (elapsedMilliseconds: number): string => { + if (elapsedMilliseconds < MILLISECONDS_PER_SECOND) { + return `${Math.round(elapsedMilliseconds)}ms`; + } + return `${(elapsedMilliseconds / MILLISECONDS_PER_SECOND).toFixed(1)}s`; +}; + +const buildFileLineMap = (diagnostics: Diagnostic[]): Map => { + const fileLineMap = new Map(); + + for (const diagnostic of diagnostics) { + const currentLines = fileLineMap.get(diagnostic.filePath) ?? []; + if (diagnostic.line > 0) { + currentLines.push(diagnostic.line); + } + fileLineMap.set(diagnostic.filePath, currentLines); + } + + return fileLineMap; +}; + +const formatFileLineReferences = (diagnostics: Diagnostic[]): string[] => { + const fileLineMap = buildFileLineMap(diagnostics); + const sortedEntries = [...fileLineMap.entries()].toSorted(([filePathA], [filePathB]) => + filePathA.localeCompare(filePathB), + ); + + return sortedEntries.map(([filePath, lineNumbers]) => { + const uniqueSortedLineNumbers = [...new Set(lineNumbers)].toSorted((lineA, lineB) => lineA - lineB); + const lineSuffix = + uniqueSortedLineNumbers.length > 0 ? `:${uniqueSortedLineNumbers.join(",")}` : ""; + return `\`${filePath}${lineSuffix}\``; + }); +}; + +const sortRuleGroups = (ruleGroups: [string, Diagnostic[]][]): [string, Diagnostic[]][] => + ruleGroups.toSorted(([ruleKeyA, diagnosticsA], [ruleKeyB, diagnosticsB]) => { + const severitySortKeyA = SEVERITY_SORT_KEYS[diagnosticsA[0].severity]; + const severitySortKeyB = SEVERITY_SORT_KEYS[diagnosticsB[0].severity]; + const severityComparison = severitySortKeyA.localeCompare(severitySortKeyB); + + if (severityComparison !== 0) return severityComparison; + return ruleKeyA.localeCompare(ruleKeyB); + }); + +const buildFindingsSectionLines = (diagnostics: Diagnostic[]): string[] => { + if (diagnostics.length === 0) { + return ["No diagnostics found."]; + } + + const findingsLines: string[] = []; + const ruleGroups = groupBy( + diagnostics, + (diagnostic) => `${diagnostic.plugin}/${diagnostic.rule}`, + ); + const sortedRuleGroups = sortRuleGroups([...ruleGroups.entries()]); + + for (const [ruleKey, ruleDiagnostics] of sortedRuleGroups) { + const firstDiagnostic = ruleDiagnostics[0]; + const fileLineReferences = formatFileLineReferences(ruleDiagnostics); + + findingsLines.push(`##### ${ruleKey}`); + findingsLines.push(`- Severity: ${firstDiagnostic.severity}`); + findingsLines.push(`- Category: ${firstDiagnostic.category}`); + findingsLines.push(`- Count: ${ruleDiagnostics.length}`); + findingsLines.push(`- Message: ${sanitizeInlineText(firstDiagnostic.message)}`); + if (firstDiagnostic.help) { + findingsLines.push(`- Suggestion: ${sanitizeInlineText(firstDiagnostic.help)}`); + } + findingsLines.push("- Files:"); + for (const fileLineReference of fileLineReferences) { + findingsLines.push(` - ${fileLineReference}`); + } + findingsLines.push(""); + } + + if (findingsLines[findingsLines.length - 1] === "") { + findingsLines.pop(); + } + + return findingsLines; +}; + +const buildProjectSectionLines = (markdownReportData: MarkdownReportData): string[] => { + const projectSectionLines: string[] = ["## Projects", ""]; + + if (markdownReportData.projects.length === 0) { + projectSectionLines.push("No projects were scanned."); + return projectSectionLines; + } + + for (const project of markdownReportData.projects) { + const errorCount = countErrorDiagnostics(project.diagnostics); + const warningCount = countWarningDiagnostics(project.diagnostics); + const affectedFileCount = collectAffectedFileCount(project.diagnostics); + const skippedChecksLabel = + project.skippedChecks.length > 0 ? project.skippedChecks.join(", ") : "none"; + const scoreLabel = project.scoreResult + ? `${project.scoreResult.score} (${project.scoreResult.label})` + : "Not calculated"; + + projectSectionLines.push(`### ${project.projectName}`); + projectSectionLines.push(""); + projectSectionLines.push(`- Directory: \`${project.projectDirectory}\``); + projectSectionLines.push(`- Framework: ${formatFrameworkName(project.framework)}`); + projectSectionLines.push(`- React Version: ${project.reactVersion ?? "Not detected"}`); + projectSectionLines.push(`- Score: ${scoreLabel}`); + projectSectionLines.push( + `- Diagnostics: ${project.diagnostics.length} (${errorCount} errors, ${warningCount} warnings)`, + ); + projectSectionLines.push( + `- Affected Files: ${affectedFileCount}/${project.sourceFileCount}`, + ); + projectSectionLines.push(`- Elapsed: ${formatElapsedTime(project.elapsedMilliseconds)}`); + projectSectionLines.push(`- Skipped Checks: ${skippedChecksLabel}`); + projectSectionLines.push(""); + projectSectionLines.push("#### Findings"); + projectSectionLines.push(""); + projectSectionLines.push(...buildFindingsSectionLines(project.diagnostics)); + projectSectionLines.push(""); + } + + if (projectSectionLines[projectSectionLines.length - 1] === "") { + projectSectionLines.pop(); + } + + return projectSectionLines; +}; + +const buildMarkdownReportContent = (markdownReportData: MarkdownReportData): string => { + const errorCount = countErrorDiagnostics(markdownReportData.diagnostics); + const warningCount = countWarningDiagnostics(markdownReportData.diagnostics); + const affectedFileCount = collectAffectedFileCount(markdownReportData.diagnostics); + const modeLabel = markdownReportData.isDiffMode ? "diff" : "full"; + + const reportLines = [ + "# React Doctor Report", + "", + "## Run", + `- Generated At: ${markdownReportData.generatedAtIso}`, + `- Root Directory: \`${markdownReportData.rootDirectory}\``, + `- Projects Scanned: ${markdownReportData.projects.length}`, + `- Mode: ${modeLabel}`, + `- Lint: ${markdownReportData.isLintEnabled ? "enabled" : "disabled"}`, + `- Dead Code: ${markdownReportData.isDeadCodeEnabled ? "enabled" : "disabled"}`, + `- Verbose: ${markdownReportData.isVerboseEnabled ? "enabled" : "disabled"}`, + `- Score Only: ${markdownReportData.isScoreOnly ? "enabled" : "disabled"}`, + `- Offline: ${markdownReportData.isOffline ? "enabled" : "disabled"}`, + "", + "## Totals", + `- Diagnostics: ${markdownReportData.diagnostics.length}`, + `- Errors: ${errorCount}`, + `- Warnings: ${warningCount}`, + `- Affected Files: ${affectedFileCount}`, + "", + ...buildProjectSectionLines(markdownReportData), + ]; + + return `${reportLines.join("\n").trimEnd()}\n`; +}; + +const resolveMarkdownReportPath = ( + markdownReportData: MarkdownReportData, + markdownReportPath: string, +): string => + path.isAbsolute(markdownReportPath) + ? markdownReportPath + : path.resolve(markdownReportData.rootDirectory, markdownReportPath); + +export const writeMarkdownReport = ( + markdownReportData: MarkdownReportData, + markdownReportPath: string, +): string => { + const resolvedMarkdownReportPath = resolveMarkdownReportPath( + markdownReportData, + markdownReportPath, + ); + const reportDirectoryPath = path.dirname(resolvedMarkdownReportPath); + mkdirSync(reportDirectoryPath, { recursive: true }); + writeFileSync( + resolvedMarkdownReportPath, + buildMarkdownReportContent(markdownReportData), + "utf-8", + ); + return resolvedMarkdownReportPath; +}; diff --git a/packages/react-doctor/tests/write-markdown-report.test.ts b/packages/react-doctor/tests/write-markdown-report.test.ts new file mode 100644 index 0000000..4073697 --- /dev/null +++ b/packages/react-doctor/tests/write-markdown-report.test.ts @@ -0,0 +1,99 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import type { Diagnostic, MarkdownReportData } from "../src/types.js"; +import { writeMarkdownReport } from "../src/utils/write-markdown-report.js"; + +const temporaryDirectories: string[] = []; +const DEFAULT_LINE_NUMBER = 12; +const SECOND_DIAGNOSTIC_LINE_NUMBER = 18; +const DEFAULT_COLUMN_NUMBER = 4; +const PROJECT_SOURCE_FILE_COUNT = 20; +const PROJECT_ELAPSED_TIME_MS = 1240; + +const createTemporaryDirectory = (): string => { + const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "react-doctor-report-test-")); + temporaryDirectories.push(temporaryDirectory); + return temporaryDirectory; +}; + +const createDiagnostic = (overrides: Partial = {}): Diagnostic => ({ + filePath: "src/app.tsx", + plugin: "react", + rule: "no-danger", + severity: "error", + message: "Avoid dangerouslySetInnerHTML.", + help: "Use safe rendering patterns.", + line: DEFAULT_LINE_NUMBER, + column: DEFAULT_COLUMN_NUMBER, + category: "Security", + ...overrides, +}); + +const createMarkdownReportData = ( + rootDirectory: string, + diagnostics: Diagnostic[], +): MarkdownReportData => ({ + generatedAtIso: "2026-02-25T10:00:00.000Z", + rootDirectory, + isDiffMode: false, + isOffline: false, + isScoreOnly: false, + isLintEnabled: true, + isDeadCodeEnabled: true, + isVerboseEnabled: true, + diagnostics, + projects: [ + { + projectDirectory: rootDirectory, + projectName: "web-app", + framework: "nextjs", + reactVersion: "^19.0.0", + sourceFileCount: PROJECT_SOURCE_FILE_COUNT, + diagnostics, + scoreResult: { score: 82, label: "Great" }, + skippedChecks: [], + elapsedMilliseconds: PROJECT_ELAPSED_TIME_MS, + }, + ], +}); + +afterEach(() => { + for (const temporaryDirectory of temporaryDirectories) { + fs.rmSync(temporaryDirectory, { recursive: true, force: true }); + } + temporaryDirectories.length = 0; +}); + +describe("writeMarkdownReport", () => { + it("writes a markdown report to the requested relative path", () => { + const temporaryDirectory = createTemporaryDirectory(); + const diagnostics = [createDiagnostic(), createDiagnostic({ line: SECOND_DIAGNOSTIC_LINE_NUMBER })]; + const markdownReportData = createMarkdownReportData(temporaryDirectory, diagnostics); + + const outputPath = writeMarkdownReport(markdownReportData, "reports/react-doctor-report.md"); + const reportContent = fs.readFileSync(outputPath, "utf-8"); + + expect(outputPath).toBe(path.join(temporaryDirectory, "reports", "react-doctor-report.md")); + expect(reportContent).toContain("# React Doctor Report"); + expect(reportContent).toContain("## Totals"); + expect(reportContent).toContain("- Diagnostics: 2"); + expect(reportContent).toContain("### web-app"); + expect(reportContent).toContain("##### react/no-danger"); + expect(reportContent).toContain("`src/app.tsx:12,18`"); + }); + + it("writes an empty findings section when no diagnostics are present", () => { + const temporaryDirectory = createTemporaryDirectory(); + const markdownReportData = createMarkdownReportData(temporaryDirectory, []); + + const outputPath = writeMarkdownReport(markdownReportData, "report.md"); + const reportContent = fs.readFileSync(outputPath, "utf-8"); + + expect(reportContent).toContain("No diagnostics found."); + expect(reportContent).toContain("- Diagnostics: 0"); + expect(reportContent).toContain("- Errors: 0"); + expect(reportContent).toContain("- Warnings: 0"); + }); +});