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
7 changes: 7 additions & 0 deletions packages/react-doctor/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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 <path> write a markdown report to file
-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
Expand Down
2 changes: 1 addition & 1 deletion packages/react-doctor/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
39 changes: 39 additions & 0 deletions packages/react-doctor/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
DiffInfo,
EstimatedScoreResult,
FailOnLevel,
MarkdownReportProject,
ReactDoctorConfig,
ScanOptions,
} from "./types.js";
Expand All @@ -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";

Expand All @@ -37,6 +39,7 @@ interface CliFlags {
offline: boolean;
ami: boolean;
project?: string;
reportMd?: string;
diff?: boolean | string;
failOn: string;
}
Expand Down Expand Up @@ -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 <path>", "write a markdown report to file")
.option("-y, --yes", "skip prompts, scan all workspace projects")
.option("--project <name>", "select workspace project (comma-separated for multiple)")
.option("--diff [base]", "scan only files changed vs base branch")
Expand Down Expand Up @@ -190,6 +194,7 @@ const program = new Command()
}

const allDiagnostics: Diagnostic[] = [];
const markdownReportProjects: MarkdownReportProject[] = [];

for (const projectDirectory of projectDirectories) {
let includePaths: string[] | undefined;
Expand All @@ -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
Expand Down
75 changes: 60 additions & 15 deletions packages/react-doctor/src/plugin/rules/nextjs.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,12 @@
import {
APP_DIRECTORY_PATTERN,
EFFECT_HOOK_NAMES,
EXECUTABLE_SCRIPT_TYPES,
GOOGLE_FONTS_PATTERN,
INTERNAL_PAGE_PATH_PATTERN,
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,
Expand All @@ -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 {
Expand Down Expand Up @@ -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;
Expand All @@ -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:
Expand All @@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -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;
}
Expand Down Expand Up @@ -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;
Expand Down
Loading