Skip to content
Merged
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
111 changes: 94 additions & 17 deletions packages/react-doctor/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,23 +81,36 @@ The action outputs a `score` (0–100) you can use in subsequent steps.
Usage: react-doctor [directory] [options]

Options:
-v, --version display the version number
--no-lint skip linting
--no-dead-code skip dead code detection
--verbose show file details per rule
--score output only the score
--json output a single structured JSON report (suppresses other output)
-y, --yes skip prompts, scan all workspace projects
--full skip prompts, always run a full scan (decline diff-only)
--project <name> select workspace project (comma-separated for multiple)
--diff [base] scan only files changed vs base branch
--offline skip telemetry (anonymous, not stored, only used to calculate score)
--staged scan only staged (git index) files for pre-commit hooks
--fail-on <level> exit with error code on diagnostics: error, warning, none
--annotations output diagnostics as GitHub Actions annotations
-h, --help display help for command
-v, --version display the version number
--no-lint skip linting
--no-dead-code skip dead code detection
--verbose show file details per rule
--score output only the score
--json output a single structured JSON report (suppresses other output)
-y, --yes skip prompts, scan all workspace projects
--full skip prompts, always run a full scan (decline diff-only)
--project <name> select workspace project (comma-separated for multiple)
--diff [base] scan only files changed vs base branch
--offline skip telemetry (anonymous, not stored, only used to calculate score)
--staged scan only staged (git index) files for pre-commit hooks
--fail-on <level> exit with error code on diagnostics: error, warning, none
--annotations output diagnostics as GitHub Actions annotations
--explain <file:line> diagnose why a rule fired or why a suppression didn't apply at a specific location (alias: --why)
-h, --help display help for command
```

### `--explain <file:line>`

When a rule keeps firing despite a `react-doctor-disable-next-line` you wrote, pass `--explain <file:line>` (mirroring `rustc --explain <error-code>`) to ask the scanner what it sees:

```bash
npx -y react-doctor@latest --explain components/projects/Snapshot.tsx:254
```

`--why` is a hidden alias of `--explain` for users coming from the issue's vocabulary.

Output names the rule, prints any nearby suppression comment that didn't apply, and explains why — wrong rule list (suggesting the comma form), or a code-line gap (suggesting moving the comment or extracting the surrounding code into a helper). The same hint is also attached to each diagnostic inline when running with `--verbose` and is included in `--json` output as `diagnostic.suppressionHint`.

## JSON output

Pass `--json` to get a single, parsable JSON object on stdout. All human-readable output, prompts, and the share link are suppressed; pipe straight into `jq`, `node`, or any other tool:
Expand Down Expand Up @@ -158,7 +171,13 @@ Create a `react-doctor.config.json` in your project root to customize behavior:
{
"ignore": {
"rules": ["react/no-danger", "jsx-a11y/no-autofocus", "knip/exports"],
"files": ["src/generated/**"]
"files": ["src/generated/**"],
"overrides": [
{
"files": ["components/diff/**"],
"rules": ["react-doctor/no-array-index-as-key"]
}
]
}
}
```
Expand All @@ -177,6 +196,29 @@ You can also use the `"reactDoctor"` key in your `package.json` instead:

If both exist, `react-doctor.config.json` takes precedence.

### Per-file rule overrides

`ignore.files` is all-or-nothing — every rule is silenced for matched files. When a single rule is legitimately violated in one directory but the rest of the rule set should still apply there, use `ignore.overrides` instead:

```json
{
"ignore": {
"overrides": [
{
"files": ["components/diff/**"],
"rules": ["react-doctor/no-array-index-as-key"]
},
{
"files": ["components/search/HighlightedSnippet.tsx"],
"rules": ["react/no-danger"]
}
]
}
}
```

Each entry is `{ files: string[], rules?: string[] }`. A diagnostic is dropped when its file matches any entry's globs AND its `plugin/rule` id is listed in that entry's rules. Omit `rules` (or pass `[]`) to suppress every rule for the matched files.

### Inline suppressions

Suppress a rule on a specific line with `// react-doctor-disable-line` or the next line with `// react-doctor-disable-next-line`:
Expand All @@ -192,6 +234,22 @@ useEffect(() => {
const value = expensiveComputation(); // react-doctor-disable-line react-doctor/no-usememo-simple-expression
```

#### Multiple rules on one comment

When two rules co-fire on the same line, **comma- or space-separate the rule ids on a single comment** — stacking two single-rule comments does not work, and the inner comment shadows the outer one:

```tsx
// react-doctor-disable-next-line react-doctor/no-derived-state-effect, react-doctor/no-fetch-in-effect
useEffect(() => {
setFull(`${first} ${last}`);
fetch(`/users/${id}`);
}, [first, last, id]);
```

A bare comment with no rule id suppresses every diagnostic on the targeted line.

#### Block comments and JSX

Block comments work too — useful inside JSX where `//` line comments aren't legal:

<!-- prettier-ignore -->
Expand All @@ -200,7 +258,22 @@ Block comments work too — useful inside JSX where `//` line comments aren't le
<div dangerouslySetInnerHTML={{ __html }} />
```

Comma- or space-separate multiple rule ids on the same comment. With no rule id, the comment suppresses every diagnostic on that line.
#### Multi-line JSX elements

When a rule reports an attribute on a later line of a multi-line JSX element, putting the comment **immediately above the opening tag** suppresses diagnostics anywhere inside the tag's attribute list (matching the ESLint convention). You don't need to inline a JSX comment between the `<Tag` and the offending attribute:

<!-- prettier-ignore -->
```tsx
{/* react-doctor-disable-next-line react-doctor/no-array-index-as-key */}
<li
key={`item-${index}`}
role="button"
>
{item.label}
</li>
```

Coverage extends through the closing `>` of the opening tag, not into children — children of the element keep their normal lint coverage.

### Respecting your existing project ignores

Expand Down Expand Up @@ -267,6 +340,7 @@ To opt out completely, set:
| ------------------------- | -------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `ignore.rules` | `string[]` | `[]` | Rules to suppress, using the `plugin/rule` format shown in diagnostic output (e.g. `react/no-danger`, `knip/exports`, `knip/types`) |
| `ignore.files` | `string[]` | `[]` | File paths to exclude, supports glob patterns (`src/generated/**`, `**/*.test.tsx`) |
| `ignore.overrides` | `Override[]` | `[]` | Per-glob rule ignore. Each entry pairs a `files` glob list with a `rules` list — diagnostics matching both are dropped. Lets you turn off one noisy rule for one directory without losing coverage of unrelated rules. Omit `rules` (or pass `[]`) to suppress every rule for the matched files (equivalent to extending `ignore.files`). |
| `lint` | `boolean` | `true` | Enable/disable lint checks (same as `--no-lint`) |
| `deadCode` | `boolean` | `true` | Enable/disable dead code detection (same as `--no-dead-code`) |
| `verbose` | `boolean` | `false` | Show file details per rule (same as `--verbose`) |
Expand Down Expand Up @@ -316,6 +390,9 @@ interface Diagnostic {
line: number;
column: number;
category: string;
// Populated when a `react-doctor-disable-next-line` exists nearby
// but didn't apply — explains why so users can fix the suppression.
suppressionHint?: string;
}
```

Expand Down
69 changes: 68 additions & 1 deletion packages/react-doctor/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { performance } from "node:perf_hooks";
import { Command } from "commander";
import { Command, Option } from "commander";
import { CANONICAL_GITHUB_URL } from "./constants.js";
import { runInstallSkill } from "./install-skill.js";
import { scan } from "./scan.js";
Expand All @@ -25,8 +25,10 @@ import { highlighter } from "./utils/highlighter.js";
import { loadConfig } from "./utils/load-config.js";
import { logger, setLoggerSilent } from "./utils/logger.js";
import { encodeAnnotationProperty, encodeAnnotationMessage } from "./utils/annotation-encoding.js";
import { parseFileLineArgument } from "./utils/parse-file-line-argument.js";
import { prompts } from "./utils/prompts.js";
import { selectProjects } from "./utils/select-projects.js";
import { toRelativePath } from "./utils/to-relative-path.js";

const VERSION = process.env.VERSION ?? "0.0.0";

Expand All @@ -45,6 +47,8 @@ interface CliFlags {
respectInlineDisables: boolean;
project?: string;
diff?: boolean | string;
explain?: string;
why?: string;
failOn: string;
}

Expand Down Expand Up @@ -255,6 +259,46 @@ const resolveDiffMode = async (
return Boolean(shouldScanChangedOnly);
};

const runExplain = async (
fileLineArgument: string,
resolvedDirectory: string,
userConfig: ReactDoctorConfig | null,
): Promise<void> => {
const { filePath, line } = parseFileLineArgument(fileLineArgument);
const scanResult = await scan(resolvedDirectory, {
silent: true,
offline: true,
configOverride: userConfig,
});

const requestedRelativePath = toRelativePath(filePath, resolvedDirectory);
const matchingDiagnostics = scanResult.diagnostics.filter(
(diagnostic) =>
diagnostic.line === line &&
toRelativePath(diagnostic.filePath, resolvedDirectory) === requestedRelativePath,
);

if (matchingDiagnostics.length === 0) {
logger.log(`No react-doctor diagnostics at ${filePath}:${line}.`);
return;
}

for (const diagnostic of matchingDiagnostics) {
const ruleIdentifier = `${diagnostic.plugin}/${diagnostic.rule}`;
logger.log(`${highlighter.error(ruleIdentifier)} — ${diagnostic.message}`);
if (diagnostic.help) logger.dim(` ${diagnostic.help}`);
if (diagnostic.suppressionHint) {
logger.break();
logger.log(` Suppression diagnosis: ${diagnostic.suppressionHint}`);
} else {
logger.dim(
" No nearby react-doctor-disable-next-line comment was detected — add one immediately above this line to suppress.",
);
}
logger.break();
}
};

const validateModeFlags = (flags: CliFlags): void => {
// HACK: use the same coercion as resolveEffectiveDiff so a bare
// `--diff false` (or `--diff ""`) is treated as "no diff" and doesn't
Expand All @@ -277,6 +321,18 @@ const validateModeFlags = (flags: CliFlags): void => {
if (flags.annotations && (flags.json || flags.score)) {
throw new Error("--annotations cannot be combined with --json or --score.");
}
if (flags.explain !== undefined && flags.why !== undefined) {
throw new Error("Use --explain or --why, not both — they're aliases of the same flag.");
}
const explainArgument = flags.explain ?? flags.why;
if (
explainArgument !== undefined &&
(flags.json || flags.score || flags.annotations || flags.staged)
) {
throw new Error(
"--explain cannot be combined with --json, --score, --annotations, or --staged.",
);
}
};

const program = new Command()
Expand All @@ -303,6 +359,11 @@ const program = new Command()
.option("--staged", "scan only staged (git index) files for pre-commit hooks")
.option("--fail-on <level>", "exit with error code on diagnostics: error, warning, none", "error")
.option("--annotations", "output diagnostics as GitHub Actions annotations")
.option(
"--explain <file:line>",
"diagnose why a rule fired or why a suppression didn't apply at a specific location",
)
.addOption(new Option("--why <file:line>", "alias for --explain").hideHelp())
.option(
"--respect-inline-disables",
"respect inline `// eslint-disable*` / `// oxlint-disable*` comments (default)",
Expand Down Expand Up @@ -332,6 +393,12 @@ const program = new Command()

const userConfig = loadConfig(resolvedDirectory);

const explainArgument = flags.explain ?? flags.why;
if (explainArgument !== undefined) {
await runExplain(explainArgument, resolvedDirectory, userConfig);
return;
}

if (!isQuiet) {
logger.log(`react-doctor v${VERSION}`);
logger.break();
Expand Down
8 changes: 8 additions & 0 deletions packages/react-doctor/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,3 +88,11 @@ export const buildNoReactDependencyError = (directory: string): string =>
export const REACT_19_DEPRECATION_MIN_MAJOR = 19;

export const REACT_DOM_LEGACY_API_MIN_MAJOR = 18;

// HACK: lookahead cap for JSX opener-span scanning; bounds worst-case
// work on pathological files. Real openers stay well under this.
export const JSX_OPENER_SCAN_MAX_LINES = 32;

// HACK: lookback cap for stacked / near-miss disable-next-line scanning.
// Larger gaps stop being intentional suppressions and become noise.
export const SUPPRESSION_NEAR_MISS_MAX_LINES = 10;
45 changes: 28 additions & 17 deletions packages/react-doctor/src/scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,16 +70,21 @@ const sortBySeverity = (diagnosticGroups: [string, Diagnostic[]][]): [string, Di
const collectAffectedFiles = (diagnostics: Diagnostic[]): Set<string> =>
new Set(diagnostics.map((diagnostic) => diagnostic.filePath));

const buildFileLineMap = (diagnostics: Diagnostic[]): Map<string, number[]> => {
const fileLines = new Map<string, number[]>();
interface VerboseSiteEntry {
line: number;
suppressionHint?: string;
}

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

const printDiagnostics = (diagnostics: Diagnostic[], isVerbose: boolean): void => {
Expand All @@ -103,12 +108,15 @@ const printDiagnostics = (diagnostics: Diagnostic[], isVerbose: boolean): void =
}

if (isVerbose) {
const fileLines = buildFileLineMap(ruleDiagnostics);

for (const [filePath, lines] of fileLines) {
if (lines.length > 0) {
for (const line of lines) {
logger.dim(` ${filePath}:${line}`);
const fileSites = buildVerboseSiteMap(ruleDiagnostics);

for (const [filePath, sites] of fileSites) {
if (sites.length > 0) {
for (const site of sites) {
logger.dim(` ${filePath}:${site.line}`);
if (site.suppressionHint) {
logger.dim(` ↳ ${site.suppressionHint}`);
}
}
} else {
logger.dim(` ${filePath}`);
Expand All @@ -129,7 +137,6 @@ const formatElapsedTime = (elapsedMilliseconds: number): string => {

const formatRuleSummary = (ruleKey: string, ruleDiagnostics: Diagnostic[]): string => {
const firstDiagnostic = ruleDiagnostics[0];
const fileLines = buildFileLineMap(ruleDiagnostics);

const sections = [
`Rule: ${ruleKey}`,
Expand All @@ -145,10 +152,14 @@ const formatRuleSummary = (ruleKey: string, ruleDiagnostics: Diagnostic[]): stri
}

sections.push("", "Files:");
for (const [filePath, lines] of fileLines) {
if (lines.length > 0) {
for (const line of lines) {
sections.push(` ${filePath}:${line}`);
const fileSites = buildVerboseSiteMap(ruleDiagnostics);
for (const [filePath, sites] of fileSites) {
if (sites.length > 0) {
for (const site of sites) {
sections.push(` ${filePath}:${site.line}`);
if (site.suppressionHint) {
sections.push(` ${site.suppressionHint}`);
}
}
} else {
sections.push(` ${filePath}`);
Expand Down
7 changes: 7 additions & 0 deletions packages/react-doctor/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ export interface Diagnostic {
line: number;
column: number;
category: string;
suppressionHint?: string;
}

export interface PackageJson {
Expand Down Expand Up @@ -182,9 +183,15 @@ export interface CleanedDiagnostic {
help: string;
}

export interface ReactDoctorIgnoreOverride {
files: string[];
rules?: string[];
}

interface ReactDoctorIgnoreConfig {
rules?: string[];
files?: string[];
overrides?: ReactDoctorIgnoreOverride[];
}

export interface ReactDoctorConfig {
Expand Down
Loading
Loading