Aiden Bai 30f05ef532 feat(action): React Doctor Review composite action (#307)
* refactor(action): move legacy composite action to actions/inspect/

The new root action.yml will host the PR Review pipeline.

The existing 'scan + sticky comment' composite action now lives at
actions/inspect/action.yml so consumers can opt into either action
explicitly:

- millionco/react-doctor@v1            -> PR Review (new)
- millionco/react-doctor/actions/inspect@v1 -> Legacy scan flow

Updates the github-action.test.ts contract test to read from the
new path.

Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>

* feat(action): add React Doctor Review composite action

Self-hosted GitHub Action that mirrors the hosted react.review bot's
doctor-only review pipeline. No hosted service required — everything
runs in-process on the GitHub Actions runner.

What the action does on every pull_request (opened/synchronize/reopened):

- Reads the event payload from GITHUB_EVENT_PATH and extracts the
  owner/repo/PR number/head SHA/base ref. Detects fork PRs via head.repo
  vs base.repo full_name mismatch.
- Resolves the merge base via octokit.repos.compareCommitsWithBasehead,
  using ${headOwner}:${headRef} as the head identifier for fork PRs.
- Materializes the base commit alongside the runner's checkout of head
  via 'git fetch --depth=1 <remote> <baseSha>' + 'git worktree add
  --detach <baseDir> <baseSha>'. The base remote URL embeds
  https://x-access-token:<token>@github.com/... so private repos work.
- Lists changed files via octokit.pulls.listFiles (paginated) and
  derives addedLineContents: Map<lineNumber, content> per file from the
  unified-diff patch — required for inline-comment line resolution.
- Runs react-doctor's diagnose() API in-process on both the head
  checkout and the base worktree. Pre-discovers React subprojects via
  workspaces in package.json + pnpm-workspace.yaml (using
  discoverReactSubprojects from @react-doctor/project-info), fans out
  per project, combines diagnostics with paths remapped to be relative
  to the repo root, and weight-averages scores by source file count.
- Diffs head diagnostics against base diagnostics using a
  (relativePath, rule, message) key with count deltas (not boolean
  presence) so duplicate diagnostics are counted correctly.
- Inline comment candidates are net-new diagnostics with
  severity === 'error' whose line landed on a + line in the diff.
- Reconciles existing inline threads via GraphQL
  repository.pullRequest.reviewThreads. Threads whose body contains the
  INLINE_COMMENT_MARKER and whose threadKey matches a current candidate
  are skipped. Threads whose key is no longer in the candidate set get
  PATCHed with '✅ Addressed in <headSha>' and resolved via the
  resolveReviewThread GraphQL mutation.
- Posts remaining candidates via octokit.pulls.createReview({ event:
  'COMMENT' }), capped at MAX_INLINE_COMMENTS_COUNT (25) per run.
- Upserts the sticky summary comment identified by the
  STICKY_COMMENT_MARKER HTML marker. Posts a pending-review comment
  before analysis starts, swaps it for the final regression/no-issues
  body once diagnostics are computed, and falls back to an analysis-
  failure body in the catch path.
- Creates a check run on the head SHA before analysis, then completes
  it with the assessment body (score line, new-diagnostics list,
  project metadata table).

Edge cases handled:

- Fork PRs: warns when head.repo differs from base.repo and gracefully
  catches write failures on inline / sticky posts when the workflow
  token lacks write access. Consumers can pass a PAT or App
  installation token via the 'token' input to enable fork-PR review.
- NoReactDependencyError / PackageJsonNotFoundError /
  ProjectNotFoundError: concludes the check run as 'skipped' with a
  'not a React project' summary and deletes the pending comment
  instead of loud-failing.
- Diagnostic path resolution tries: absolute under root, absolute
  under project root, relative + project-prefixed, and 'already
  prefixed' in that order.
- Cleans up the base worktree via 'git worktree remove --force' in a
  finally block.

Token strategy (dual-mode):

- Default: workflow GITHUB_TOKEN (action.yml default). Comments author
  as github-actions[bot]. Same-repo PRs work out of the box; fork PRs
  get warnings and best-effort posts.
- Override: pass a PAT or App installation token via the 'token' input
  for fork-PR support or branded identity.

Not in scope (intentionally out):

- No LLM / agent layer — doctor-only review.
- No Vercel Sandbox dependency — diagnose() runs in-process.
- No Upstash rate limit / no-react cache — single-repo CI doesn't need
  multi-tenant guardrails.
- No OIDC token-broker mode — that flow lives in the hosted react.review
  service; this action is standalone.

File layout:

- action.yml at the repo root (composite, runs tsx against the
  entrypoint). Installs only the @react-doctor/review-action and
  react-doctor workspace closures via pnpm --filter to keep cold-start
  install footprint minimal.
- actions/review/ is a new workspace package
  (@react-doctor/review-action) with three TypeScript modules:
  - src/pipeline.ts — pure helpers (diff parser, diagnose-across-
    workspace, computeDiagnosticsDelta, formatters, inline-candidate
    builder, check-run assessment).
  - src/github.ts — Octokit IO (compareCommitsWithBasehead, listFiles,
    sticky upsert/delete, inline review, GraphQL thread reconciliation,
    check-run create/complete).
  - src/index.ts — entrypoint orchestration.
- src/constants.ts holds magic numbers (MAX_INLINE_COMMENTS_COUNT,
  sticky/inline markers, check-run name, GraphQL page size).

Dogfood:

- .github/workflows/doctor-action.yml runs the action against this
  repo's own PRs via 'uses: ./'.

Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>

* docs(readme): document Review and inspect actions

Adds a 'PR Review (recommended)' section showing the new root action
and keeps the previous scan + sticky comment flow under
'Scan + sticky comment (legacy)' pointing at
millionco/react-doctor/actions/inspect@main.

Existing references to 'millionco/react-doctor@main' for the legacy
inputs (directory/verbose/project/diff/github-token/fail-on/offline/
annotations/node-version) are repointed to the new
millionco/react-doctor/actions/inspect@main path so existing recipes
keep working without breaking on the new Review action's inputs.

Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>

* fix(action): disambiguate workspace filter to skip website build

Both the workspace root and packages/react-doctor are named 'react-doctor'.
'pnpm --filter react-doctor build' matched the root, whose build script
is 'turbo run build' — which fans out to every workspace, including
website. website#build then failed in CI because the filtered install
(by design) did not pull in Next.js.

Switch both pnpm invocations to path-style filters / direct cwd:

- Install: --filter './packages/react-doctor...' (instead of bare name).
- Build:   cd packages/react-doctor && pnpm build (run the package's
  vp-pack script directly, bypassing the name collision).

Verified locally:
  cd packages/react-doctor && rm -rf dist && pnpm build  # OK
  pnpm install --frozen-lockfile \
    --filter '@react-doctor/review-action...' \
    --filter './packages/react-doctor...'                 # Scope: 6 of 9

Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>

* fix(action): build workspace deps via turbo so dist/ resolves at runtime

The previous fix ran 'pnpm build' directly in packages/react-doctor,
which only bundled that one package. vp pack treats workspace deps as
external imports (Could not resolve '@react-doctor/core' ... treating
it as an external dependency), so the resulting dist/index.js still
'import { ... } from "@react-doctor/core"' at runtime. The runtime
import resolved to packages/core/dist/index.js — which was never
built — and the action crashed:

  Error [ERR_MODULE_NOT_FOUND]: Cannot find module
  '.../packages/core/dist/index.js' imported from
  '.../packages/react-doctor/dist/index.js'

Use 'turbo run build --filter=./packages/react-doctor' instead. The
path filter avoids the workspace name collision (root + packages/
react-doctor share the name 'react-doctor'), and turbo's
'dependsOn: ["^build"]' in turbo.json triggers a topological build
of the four upstream workspace deps (@react-doctor/types, /project-
info, /core, oxlint-plugin-react-doctor) before react-doctor itself.

Verified locally: 'rm -rf packages/*/dist && pnpm exec turbo run build
--filter=./packages/react-doctor' produces dist/ in all five packages.
Turbo is a root devDependency and gets installed even with the
filtered install scope, so no install-step change required.

Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>

* style: drop em-dashes from new action source and README additions

Replace em-dashes (—) with plain ASCII alternatives (':', '(' ')',
or '-') in the new action.yml, actions/review/src/index.ts,
actions/review/src/pipeline.ts, and the two README lines added for
the PR Review / inspect-action documentation.

Pre-existing em-dashes elsewhere in the README are not touched —
they live in sections this PR does not modify.

Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>

* fix(action): drop unsafe +++/--- skips from diff parser

Bugbot caught a medium-severity bug in parseAddedLineContents: the
'rawLine.startsWith("+++ ")' / 'rawLine.startsWith("--- ")' guards
were copy-pasted from full unified-diff parsing but are wrong for
GitHub's pulls.listFiles patches.

GitHub's patch field begins at the first '@@ ...' hunk header and
never includes the '+++ b/file' / '--- a/file' file headers. So the
guards do not fire on any legitimate header, but they DO fire on
legitimate added/removed lines whose payload begins with '++ ' or
'-- ' (e.g. C++ '++ i', '++ x = 1', shell '-- arg'). When they fire
on a '+' line, currentNewLine is not incremented, off-by-oneing every
subsequent line number in the hunk and silently mis-placing inline
review comments.

Removed both guards; the '@@' hunk parser is the only thing that
should set/reset currentNewLine. Also removed the unused '\\' (no
newline at end of file marker) branch since it is a no-op and not
emitted by GitHub patches anyway.

Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>

* fix(action): move @react-doctor/project-info to dependencies

Bugbot flagged that pipeline.ts imports 'discoverReactSubprojects'
from '@react-doctor/project-info' at runtime, but the manifest listed
the package under devDependencies. It worked in CI only because the
filtered install does not pass --prod and so dev deps get installed
too. Move it to dependencies so a future --prod install does not
silently break the action.

Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>

* refactor(action): dedupe isMissingReactProjectError into pipeline.ts

Bugbot flagged that isMissingReactProjectError was identically defined
in both pipeline.ts and index.ts with the same imports from
react-doctor/api. Export it once from pipeline.ts and import in
index.ts so the two copies cannot diverge. Also lets index.ts drop
the NoReactDependencyError / PackageJsonNotFoundError /
ProjectNotFoundError imports from react-doctor/api since the helper
encapsulates the instanceof checks.

Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>

* fix(action): diff diagnostics at (line, column) granularity

Bugbot caught a medium-severity bug in computeDiagnosticsDelta. The
prior implementation grouped by (relativePath, rule, message) and
sliced 'headOccurrences.slice(baseCount)' to pick the 'new'
occurrences. Linters emit diagnostics in line order, so slicing the
tail systematically picks the highest-line occurrences as new. When
a genuinely new violation appeared at a *lower* line than an
existing one with the same key, the old (higher-line) occurrence was
labelled new instead, then buildInlineCommentCandidates rejected it
(its line was not on a '+' line in the diff) and the inline comment
was silently dropped.

Diff at the (line, column) sub-key. For each (relativePath, rule,
message) key we now build a multiset of base positions, walk the
head occurrences, and only flag head positions not present in base
as new. Same path for fixed (base positions not present in head).
Line-shifted occurrences naturally fall out as one 'fixed' + one
'new' but the +/- line filter in buildInlineCommentCandidates
discards the shifted-context noise.

Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>

* chore(action): drop unused SCORE_GAUGE_DOMAIN / GITHUB_ACTIONS_BOT_LOGIN

Bugbot flagged these as dead exports — they were not referenced from
anywhere in the codebase. Removing them per the AGENTS.md 'Remove
unused code' rule.

Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>

* fix(action): tolerate spaces in thread-key file paths

Bugbot flagged that THREAD_KEY_HEADER_PATTERN matched the key as
'([^\s]+)' but buildThreadKey concatenates the raw relativePath. A
file path containing a space (say, 'src/My Component.tsx') would
yield a thread key like 'src/My Component.tsx:5|rule-name' that the
regex truncated to 'src/My' at extraction time. The truncated key
never matches any candidate so the next run silently resolves the
thread as 'addressed' instead of leaving it active.

Loosen to '([^\n]+?)' (non-greedy, single-line). Equivalent for the
99% case (paths without whitespace) and correct for the path-with-
spaces case.

Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>

* refactor(action): single inline marker for ownership + thread key

Bugbot flagged duplicated marker logic: formatInlineCommentBody
prepended a plain <!-- react-doctor-review-inline --> line, then
buildInlineCommentBodyWithKey prepended a separate keyed
<!-- react-doctor-review-inline:KEY --> line, and isOwnedReviewThread
relied on the plain marker. If the plain prefix ever drifted, thread
reconciliation would silently fail because owned threads would no
longer match.

Collapse to one marker source. INLINE_COMMENT_MARKER becomes
INLINE_COMMENT_MARKER_PREFIX (with the trailing ':'); the keyed body
builder builds the marker from that constant; ownership detection
substring-matches on the same prefix; the threadKey regex extracts
everything between the ':' and ' -->'. formatInlineCommentBody no
longer needs to seed the body with a marker because the keyed
prefix is always prepended at post time.

Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>

* fix(action): null head repo + don't conflate AmbiguousProjectError with skip (bugbot)

Two Bugbot findings on the rebased PR:

### 1. Null head repo crashes context

\`pullRequest.head.repo\` is nullable (deleted forks, restricted
fork metadata). Earlier code did
\`pullRequest.head.repo?.full_name.split("/")[1]\` — \`?.\` only
guards \`repo\`, so \`full_name\` would be undefined and \`.split\`
crashes before the review runs. The same null case also set
\`headRepoFullName\` to the base repo name, so \`isFork\` became
\`false\` and fork-specific compare / permission behavior was wrong.

Funnel every field through a single \`headRepoMeta\` guard, and
make \`isFork\` only true when \`head.repo\` is present AND its
\`full_name\` differs from the base.

### 2. ReactDoctorError conflated with skip

\`runDiagnoseAcrossWorkspace\` and the orchestrator's catch block
were collapsing any \`isReactDoctorError(error)\` to "not a React
project" / "skip this workspace entry". \`AmbiguousProjectError\`
is also a \`ReactDoctorError\` but means "multiple React roots
were found here" — silently treating it as "skip" hides a real
misconfiguration and contradicts the PR's stated handling for
\`NoReactDependencyError\` / \`PackageJsonNotFoundError\` /
\`ProjectNotFoundError\` only.

Dropped \`|| isReactDoctorError(error)\` from both catch blocks so
only \`isMissingReactProjectError\` suppresses; ambiguous /
invalid-config / scan-failure ReactDoctorError instances now
propagate as hard errors. Cleaned up the now-unused
\`isReactDoctorError\` import in \`index.ts\` and \`pipeline.ts\`.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(action): scope base worktree scan to same INPUT_DIRECTORY as head (bugbot)

Bugbot caught: when the action runs against a non-\`.\` \`directory\`
input (the monorepo / per-package scoping case), head analysis
correctly scoped to that subdirectory (\`resolveHeadDirectory\`
resolved \`workspace\` + \`INPUT_DIRECTORY\`) but base analysis ran
against the full worktree root. Diagnostic paths and counts then
disagreed across the two snapshots, so regressions, sticky
summaries, and inline comments on added lines could be wrong or
missing.

Extracted \`resolveInputDirectory()\` to centralize the
\`INPUT_DIRECTORY || "."\` lookup, and added
\`resolveBaseScanDirectory(worktreeDirectory)\` that joins
\`INPUT_DIRECTORY\` onto the worktree the same way
\`resolveHeadDirectory\` joins it onto \`GITHUB_WORKSPACE\`. The
base \`runDiagnoseAcrossWorkspace\` call now sees the same subtree
as the head call.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(action): anchor diagnostic paths to workspace root so PR matching works under INPUT_DIRECTORY (bugbot)

The previous fix corrected the scan directory for the base
worktree but exposed a path-key mismatch: \`pulls.listFiles\`
returns changed-file keys relative to the **repository root**
(e.g. \`packages/my-app/src/App.tsx\`), but \`runDiagnoseAcrossWorkspace\`
was emitting \`relativePath\`s relative to the SCAN root (e.g.
\`src/App.tsx\` when the scan was scoped to \`packages/my-app\`).
\`buildInlineCommentCandidates\`'s \`changedFilesByPath.get(diagnostic.relativePath)\`
then always missed for monorepo / scoped-directory configs, so
no inline comments got posted and \`reconcileInlineThreads\` saw
an empty active set — silently resolving real regressions while
the sticky summary still listed them.

\`runDiagnoseAcrossWorkspace(rootDirectory, pathBaseDirectory?)\`
now accepts a second arg that overrides the directory the output
\`relativePath\`s are computed against. Defaults to \`rootDirectory\`
for the standalone-CLI case; the action passes
\`resolveWorkspaceRoot()\` for HEAD and the worktree root for BASE,
so emitted paths line up with the GitHub API's repo-root keys
regardless of \`INPUT_DIRECTORY\` scoping.

Renamed \`resolveHeadDirectory\` → uses new \`resolveWorkspaceRoot()\`
helper for DRY.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(action): keep threads alive for regressions that can't be posted inline (bugbot)

\`reconcileInlineThreads\` was being passed only the threadKeys that
PASSED the inline-comment filter (errors with non-null \`patch\` on
an added line). A real regression that survives the head-vs-base
diff but can't be inline-posted — e.g. \`pulls.listFiles\` returned
a null \`patch\` (binary / rename-only / large file), the violation
line landed on a context line within the hunk instead of a \`+\`
line, or the file was modified outside the diff window — would be
omitted from \`activeThreadKeys\`. The existing review thread then
got the "Addressed" footer and resolved on the next push, even
though the regression is unchanged and the sticky summary still
lists it.

Build \`activeThreadKeysFromRegressions\` from every error-severity
entry in \`newDiagnostics\` (using the same \`buildThreadKey\` shape
as \`buildInlineCommentCandidates\`), then union with the
inline-candidate keys. The set passed to \`reconcileInlineThreads\`
now reflects "still a regression" rather than "still inline-postable",
so threads stay alive while the underlying violation persists.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(action): scan PR head SHA, not merge commit; prune stale worktrees (bugbot)

Two bugbot findings:

### 1. Scans merge commit not head (high severity)

\`actions/checkout@v5\` on a \`pull_request\` event checks out the
synthetic merge commit (\`refs/pull/N/merge\`) by default, not the
PR head. So workspace HEAD is the merge result while inline
reviews + the check run annotate \`pull_request.head.sha\` —
diagnostic line numbers can disagree with what GitHub renders
in the PR diff.

Fixes:
- README example workflow + repo fixture workflow now pass
  \`ref: \${{ github.event.pull_request.head.sha }}\` to
  \`actions/checkout\` so HEAD matches the SHA we annotate.
- Action source verifies workspace HEAD == \`pull_request.head.sha\`
  at startup and logs a loud \`::warning::\` with the exact
  \`ref: ...\` snippet to add when they disagree. The action
  still runs (so existing workflows don't break overnight),
  but the user sees the actionable diagnostic in the log.

### 2. Stale worktree breaks reruns (medium severity)

\`materializeBaseWorktree\` was \`fs.rmSync\`-ing the worktree
directory before \`git worktree add\`. If a prior run left the
worktree registered in \`.git/worktrees/\` (interrupted job,
cleanup failed), \`worktree add\` rejects with "already registered"
even though the directory is gone.

Added \`tryRunGit(["worktree", "prune"], headDirectory)\` between
the \`rmSync\` and the \`add\` — drops stale registrations
defensively. \`tryRunGit\` because a fresh repo has nothing to
prune and we don't want to abort on success.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(action): include message-hash in thread key so same-rule same-line distinct messages stay distinct (bugbot)

\`buildThreadKey\` was \`(relativePath, line, rule)\` but the
regression-diffing identity in \`computeDiagnosticsDelta\` is
\`(relativePath, rule, message)\`. So two net-new errors with the
same rule firing on one line but different messages (e.g. a
multi-arg rule that complains about each invalid prop separately)
would collapse to a single thread key — only one inline comment
gets posted, and reconciliation can't tell the second violation
ever existed when it gets fixed.

Added a compact FNV-1a 32-bit hash of the message (base36-encoded
so the key stays short, ~7 chars) and appended it to the thread
key as a fourth segment. The hash is deterministic across runs
so reconciliation continues to match the same thread on later
pushes; multiple distinct messages on the same \`(path, line, rule)\`
now keep their own threads.

Updated both call sites (\`buildInlineCommentCandidates\` in
\`pipeline.ts\` + the active-threadKeys union in \`index.ts\`) to
pass \`diagnostic.message\` through to \`buildThreadKey\`.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(action): dedupe inline candidates by threadKey + document line-shift limitation (bugbot)

Two related bugbot findings:

### 1. Duplicate inline keys in review (fixed)

\`buildInlineCommentCandidates\` could emit multiple candidates
with the same \`threadKey\` (the same \`(path, line, rule, message)\`
identity firing twice in one scan — e.g. a rule that double-fires
on identical source). \`createReview\` would either duplicate-post
the comment or reject the whole batch.

Switched the candidate collector from an array push to a
\`Map<threadKey, candidate>\` keyed by the resolved thread key.
First-seen wins; subsequent duplicates are dropped before posting.

### 2. Line shift resolves threads (documented limitation)

When a push moves a surviving violation to a different line, the
old thread key no longer matches and the previous thread gets
the "Addressed" footer + resolution even though the regression
persists. This is a pre-existing limitation of the
\`(path, line, rule, message)\` thread-key design — removing
\`line\` would conflate distinct violations of the same rule
across multiple lines, so the fix requires tracking original-line
anchors per thread (server-side state), which is out of scope
for an in-repo action.

Added a long-form \`KNOWN LIMITATION\` comment on \`buildThreadKey\`
documenting the tradeoff and what a proper fix would need so
future readers don't re-discover the issue from scratch.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
2026-05-22 20:34:08 -07:00
2026-05-22 04:49:43 -07:00
2026-02-13 04:08:01 -08:00
2026-02-13 01:02:13 -08:00
fix
2026-02-17 07:47:57 -08:00

React Doctor

version downloads

Your agent writes bad React, this catches it.

One command scans your codebase and outputs a 0 to 100 health score with actionable diagnostics.

Works with Next.js, Vite, and React Native.

See it in action →

React Doctor vs React Review. React Doctor is the local-first CLI and lint plugins in this repo: offline-friendly, scriptable, runs anywhere. React Review is the hosted product on react.doctor (GitHub App, dashboard, PR comments, baseline / delta tracking). They share the same rule set; pick the CLI when you want a one-shot scan or self-hosted CI, and add React Review when you also want a hosted dashboard and review team workflow. Already using the CLI? React Review augments it without replacing it.

Install

Run this at your project root:

npx react-doctor@latest

You'll get a score (75+ Great, 50 to 74 Needs work, under 50 Critical) and a list of issues across state & effects, performance, architecture, security, and accessibility. Rules toggle automatically based on your framework and React version.

Migration note: React Doctor used to bundle knip for dead-code detection. That integration was removed in v0.2 — if you want dead-code analysis, run npx knip directly as part of your own pre-commit or CI pipeline.

https://github.com/user-attachments/assets/07cc88d9-9589-44c3-aa73-5d603cb1c570

Install for your coding agent

Teach your coding agent React best practices so it stops writing the bad code in the first place.

npx react-doctor@latest install

You'll be prompted to pick which detected agents to install for. Pass --yes to skip prompts.

Works with Claude Code, Cursor, Codex, OpenCode, and 50+ other agents.

GitHub Actions

Two composite actions ship with this repository.

The root action mirrors the hosted react.review bot's doctor-only flow as a self-hosted action: inline diagnostics on net-new violations, a sticky summary comment, automatic thread resolution when fixes land, and a check run on the PR head SHA. No hosted service required.

name: React Doctor Review

on:
  pull_request:
    types: [opened, synchronize, reopened]

permissions:
  contents: read
  pull-requests: write
  checks: write
  issues: write

jobs:
  review:
    runs-on: ubuntu-latest
    steps:
      # The `ref` line is required: `actions/checkout` on `pull_request`
      # defaults to the synthetic merge commit, so HEAD would
      # disagree with `pull_request.head.sha` (the SHA the action
      # annotates) and diagnostic line numbers wouldn't match what
      # GitHub shows in the PR diff.
      - uses: actions/checkout@v5
        with:
          ref: ${{ github.event.pull_request.head.sha }}
          fetch-depth: 0
      - uses: millionco/react-doctor@main

Inputs: token (defaults to ${{ github.token }}; pass a PAT or App installation token for fork PRs or branded identity), directory (project directory, defaults to .), node-version (defaults to 22). See action.yml for full descriptions.

The Review action diffs head diagnostics against the merge-base, posts inline comments only on errors that landed on + lines in the PR's diff, and resolves threads with ✅ Addressed in <sha> when the underlying diagnostic disappears.

Scan + sticky comment (legacy)

The older composite action (a single npx react-doctor scan with an optional sticky PR comment) lives at actions/inspect/action.yml:

name: React Doctor

on:
  pull_request:
  push:
    branches: [main]

permissions:
  contents: read
  pull-requests: write # required to post PR comments

jobs:
  react-doctor:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
        with:
          fetch-depth: 0 # required for `diff`
      - uses: millionco/react-doctor/actions/inspect@main
        with:
          diff: main
          github-token: ${{ secrets.GITHUB_TOKEN }}

When github-token is set on pull_request events, findings are posted (and updated) as a sticky PR comment. The action also exposes a score output (0–100) you can read in subsequent steps — see PR blocking and exit codes for a score-floor recipe.

Inputs: directory, verbose, project, diff, github-token, fail-on (error / warning / none), offline, annotations, node-version. See action.yml for full descriptions.

PR feedback modes

Pick one or both; they're independent.

  • Comments only (default): set github-token.
  • Annotations only: set annotations: true.
  • Both: set github-token and annotations: true. Annotation lines are stripped from the comment body.
- uses: millionco/react-doctor/actions/inspect@main
  with:
    diff: main
    github-token: ${{ secrets.GITHUB_TOKEN }}
    annotations: true

Prefer not to add a marketplace action? The bare npx form works too:

- run: npx react-doctor@latest --fail-on warning

Release versioning

React Doctor ships a few related artifacts. Each one has its own version, and the table below is the mapping you should refer to when pinning.

Artifact Where to find the version Example pin
react-doctor npm package (the CLI and Node API) npm npx react-doctor@0.2.3
oxlint-plugin-react-doctor / eslint-plugin-react-doctor npm "oxlint-plugin-react-doctor": "^0.2.3"
millionco/react-doctor GitHub composite action git refs on this repo (@main or version tag) uses: millionco/react-doctor@main
react.doctor hosted Review the hosted dashboard auto-updates; no pin is required n/a

The composite action is referenced as @main by default, which tracks the latest commit on the canonical branch. If you want a pinned action ref instead, every published react-doctor@X.Y.Z release also pushes a matching vX.Y.Z tag and moves the floating vX / vX.Y tags to that commit, so @v0, @v0.2, and @v0.2.3 all resolve. Pin tighter when you need reproducible scores across releases (see Scoring).

Pinning the action ref doesn't pin the CLI version. The composite action invokes npx react-doctor@latest internally, so the action ref pins the workflow shape (inputs, comment plumbing, score collection), not the CLI version running underneath. For genuinely deterministic scores in CI, also pin the CLI side: either run npx react-doctor@0.2.3 ... in a bare - run: step, or add "react-doctor": "0.2.3" to your project's dev deps and invoke it through pnpm exec / npm exec.

Each release ships with the changeset-generated changelog. Material rule additions, severity changes, or score-formula tweaks are called out there so you can read the expected score impact before bumping a pin.

PR blocking and exit codes

Two independent gates can block a PR — pick one or both:

  • --fail-on <level> exits non-zero on diagnostics: error (default, any error-severity rule fires), warning (any diagnostic fires), or none (never). Runs against the ciFailure surface, so the default design-tag exclusion still applies.
  • Score floor — a follow-up step that reads the action's score output and exit 1s when it's below your threshold.

Combine --fail-on with --diff <base> to scope the gate to the PR's changed files only — that's the built-in way to fail on new regressions without dragging in baseline backlog. There is no separate --fail-on-new flag.

--annotations (bare npx only) and github-token (sticky PR comment) are visualization layers and never change the exit code.

Examples

Advisory mode — never blocks, always comments:

- uses: millionco/react-doctor/actions/inspect@main
  with:
    github-token: ${{ secrets.GITHUB_TOKEN }}
    fail-on: none

Regression-only mode — fail only on new diagnostics introduced by the PR:

- uses: actions/checkout@v5
  with:
    fetch-depth: 0 # required for `diff`
- uses: millionco/react-doctor/actions/inspect@main
  with:
    diff: main
    fail-on: warning
    github-token: ${{ secrets.GITHUB_TOKEN }}

Strict threshold mode — fail when the baseline score drops below a floor:

- id: doctor
  uses: millionco/react-doctor/actions/inspect@main
  with:
    fail-on: error
    github-token: ${{ secrets.GITHUB_TOKEN }}
- env:
    SCORE: ${{ steps.doctor.outputs.score }}
    FLOOR: "80"
  run: |
    # `score` is best-effort and may be empty (e.g. when offline is on).
    # Skip the floor when it's empty so unrelated PRs aren't blocked.
    if [ -z "$SCORE" ]; then
      echo "::notice::React Doctor score unavailable — skipping floor check"
      exit 0
    fi
    if [ "$SCORE" -lt "$FLOOR" ]; then
      echo "::error::React Doctor score $SCORE is below floor $FLOOR"
      exit 1
    fi

Pin a specific react-doctor version when using a score floor — new rule releases can lower the score even when your code hasn't changed (see Scoring).

Configuration

Create a react-doctor.config.json in your project root:

{
  "ignore": {
    "rules": ["react-doctor/no-danger", "react-doctor/no-autofocus"],
    "files": ["src/generated/**"],
    "overrides": [
      {
        "files": ["components/modules/diff/**"],
        "rules": ["react-doctor/no-array-index-as-key", "react-doctor/no-render-in-render"]
      },
      {
        "files": ["components/search/HighlightedSnippet.tsx"],
        "rules": ["react-doctor/no-danger"]
      }
    ]
  }
}

Three nested keys, three layers of granularity — pick the narrowest one that fits:

  • ignore.rules silences a rule across the whole codebase.
  • ignore.files silences every rule on the matched files (use sparingly — it loses coverage for unrelated rules).
  • ignore.overrides silences only the listed rules on the matched files, leaving every other rule active. This is what you want when a single file (or glob) legitimately needs an exemption from one or two rules but should still be scanned for everything else.

You can also use the "reactDoctor" key in package.json. CLI flags always override config values.

React Doctor respects .gitignore, .eslintignore, .oxlintignore, .prettierignore, and linguist-vendored / linguist-generated annotations in .gitattributes. Inline // eslint-disable* and // oxlint-disable* comments are honored too.

If you have a JSON oxlint or eslint config (.oxlintrc.json or .eslintrc.json), its rules get merged into the scan automatically and count toward the score. Set adoptExistingLintConfig: false to opt out.

Surface controls (CLI, PR comments, score, CI failure)

Diagnostics flow through four independent surfaces — cli, prComment, score, and ciFailure — and each one can be tuned per tag, category, or rule id. By default the design tag (Tailwind shorthand cleanup like w-5 h-5 → size-5, pure-black backgrounds, gradient text, …) stays visible on the local CLI but is excluded from the PR comment, the score, and the --fail-on gate so style cleanup can't dilute meaningful React findings:

{
  "surfaces": {
    "prComment": {
      "includeTags": ["design"],
      "excludeCategories": ["Performance"]
    },
    "score": { "includeRules": ["react-doctor/design-no-redundant-size-axes"] },
    "ciFailure": { "excludeTags": ["test-noise"] }
  }
}

Each surface accepts includeTags, excludeTags, includeCategories, excludeCategories, includeRules, and excludeRules. Include wins over exclude when both match. Run the CLI with --pr-comment (the GitHub Action passes it automatically when github-token is set) to apply the prComment surface to the printed output destined for sticky PR comments.

Rule severity (rules, categories)

Same shape as ESLint / oxlint. rules is ESLint's exact field; categories mirrors oxlint's, keyed by React Doctor display categories ("React Native", "Server", "Architecture", …).

{
  "rules": { "react-doctor/no-array-index-as-key": "error" },
  "categories": { "React Native": "warn" }
}

Per-rule wins over per-category. "off" short-circuits before the rule runs; "warn" / "error" re-stamps the diagnostic so every channel — CLI, PR comment, score, --fail-on — sees the chosen severity, including for external-plugin rules. Use surfaces instead when you only want to hide a rule from one channel; use ignore.tags to silence a whole tag-defined family ("design", "test-noise", "migration-hint") that doesn't align with a single category.

Optional companion plugins

When the following ESLint plugins are installed in the scanned project (or hoisted in your monorepo), React Doctor folds their rules into the same scan. Listed as optional peer dependencies — install only what you want.

Plugin Adds Namespace
eslint-plugin-react-hooks (v6 or v7) The React Compiler frontend's correctness rules — fired when a React Compiler is detected in the project. react-hooks-js/*

The 8 rules from eslint-plugin-react-you-might-not-need-an-effect (NickvanDyke, MIT) are now ported natively into React Doctor — they fire as react-doctor/no-derived-state, react-doctor/no-chain-state-updates, react-doctor/no-event-handler, react-doctor/no-adjust-state-on-prop-change, react-doctor/no-reset-all-state-on-prop-change, react-doctor/no-pass-live-state-to-parent, react-doctor/no-pass-data-to-parent, and react-doctor/no-initialize-state. No peer dependency required.

Inline suppressions

// react-doctor-disable-next-line react-doctor/no-cascading-set-state
useEffect(() => {
  setA(value);
  setB(value);
}, [value]);

When two rules fire on the same line, you have two equivalent options. Comma-separate the rule ids on a single comment:

// react-doctor-disable-next-line react-doctor/rerender-state-only-in-handlers, react-doctor/no-derived-useState
const [localSearch, setLocalSearch] = useState(searchQuery);

Or stack one comment per rule directly above the diagnostic. Stacked comments are honored as long as nothing but other react-doctor-disable-next-line comments sits between them and the target line:

// react-doctor-disable-next-line react-doctor/rerender-state-only-in-handlers
// react-doctor-disable-next-line react-doctor/no-derived-useState
const [localSearch, setLocalSearch] = useState(searchQuery);

A code line between stacked comments breaks the chain: only the comment immediately above the diagnostic (and any contiguous react-doctor-disable-next-line comments stacked on top of it) is honored. If a comment looks adjacent but the rule still fires, run react-doctor --explain <file:line> — it reports whether a nearby suppression was found, what rules it covers, and why it didn't apply.

Block comments work inside JSX:

{/* react-doctor-disable-next-line react-doctor/no-danger */}
<div dangerouslySetInnerHTML={{ __html }} />

For multi-line JSX, putting the comment immediately above the opening tag covers the entire attribute list (matching ESLint convention).

Custom rules (user plugins)

Drop your own oxlint-shaped plugin into react-doctor.config.json's plugins field and react-doctor runs your rules alongside the built-in ones. Useful for team-specific conventions ("no <Box> outside packages/ui", "all API calls go through lib/client", etc.) that don't belong upstream.

1. Write the plugin

Create a file anywhere in your repo. The shape is the oxlint plugin contract — a default-exported { meta: { name }, rules: { ... } }. Each rule's create(context) returns ESTree-style visitors.

// lint/team-conventions.cjs
const noBareFetchRule = {
  create: (context) => ({
    CallExpression(node) {
      if (node.callee.type === "Identifier" && node.callee.name === "fetch") {
        context.report({
          node,
          message:
            "Use `lib/client.request()` instead of bare `fetch` — keeps auth + tracing consistent.",
        });
      }
    },
  }),
};

module.exports = {
  meta: { name: "team-conventions" },
  rules: {
    "no-bare-fetch": noBareFetchRule,
  },
};

For a richer authoring experience (TypeScript types, severity defaults, recommendation strings, category metadata, the defineRule helper), install oxlint-plugin-react-doctor and import defineRule + the RulePlugin types — the built-in plugin uses the same SDK.

2. Register the plugin in react-doctor.config.json

plugins accepts either a relative path (resolved relative to the config file) or an npm package name:

{
  "plugins": ["./lint/team-conventions.cjs", "react-doctor-plugin-shopify-conventions"],
  "rules": {
    "team-conventions/no-bare-fetch": "error",
    "shopify-conventions/use-polaris-tokens": "warn"
  }
}

3. Run

npx react-doctor . picks up the plugin and flows its diagnostics through every surface (CLI / PR comment / score / --fail-on gate) the same as built-in rules.

Notes

  • Opt-in by default: a user-plugin rule only runs when rules: { "<plugin-name>/<rule>": "warn" | "error" } explicitly enables it. Mirrors how defaultEnabled: false built-in rules behave so installing a third-party plugin doesn't surprise you with a flood of new diagnostics on the first scan.
  • meta.name is required: rule keys are <plugin.meta.name>/<rule-name>. A plugin without meta.name is rejected (with a warning) so rule keys in config.rules can't silently change when a file gets renamed.
  • Failure tolerance: a plugin entry that can't be resolved or doesn't look like an oxlint plugin logs a warning to stderr and is skipped — your scan keeps going.
  • Per-rule severity / ignore / surfaces: every existing severity / ignore / surface knob (rules, categories, ignore.{paths, rules, tags}, surfaces.{cli, prComment, score, ciFailure}) treats user-plugin rules the same as built-in rules. You can demote a user-plugin rule out of CI without touching the rule's source.

Lint plugin (standalone)

The same rule set ships as both an oxlint plugin and an ESLint plugin, so you can wire it into whichever lint engine your project already runs. These are published as separate packages, so you can install just the lint integration without pulling in the full CLI.

oxlint in .oxlintrc.json (install oxlint-plugin-react-doctor):

{
  "jsPlugins": [{ "name": "react-doctor", "specifier": "oxlint-plugin-react-doctor" }],
  "rules": {
    "react-doctor/no-fetch-in-effect": "warn",
    "react-doctor/no-derived-state-effect": "warn",
  },
}

ESLint flat config (install eslint-plugin-react-doctor):

import reactDoctor from "eslint-plugin-react-doctor";

export default [
  reactDoctor.configs.recommended,
  reactDoctor.configs.next,
  reactDoctor.configs["react-native"],
  reactDoctor.configs["tanstack-start"],
  reactDoctor.configs["tanstack-query"],
];

The full rule list lives in packages/oxlint-plugin-react-doctor/src/plugin/rules.

CLI reference

Usage: react-doctor [directory] [options]

Options:
  -v, --version           display the version number
  --no-lint               skip linting
  --verbose               show every rule and per-file details (default shows top 3 rules)
  --score                 output only the score
  --json                  output a single structured JSON report
  -y, --yes               skip prompts, scan all workspace projects
  --full                  skip prompts, always run a full scan
  --project <name>        select workspace project (comma-separated for multiple)
  --diff [base]           scan only files changed vs base branch
  --staged                scan only staged files (for pre-commit hooks)
  --offline               skip the score API and share URL (no score shown)
  --fail-on <level>       exit with error on diagnostics: error, warning, none
  --annotations           output diagnostics as GitHub Actions annotations
  --pr-comment            tune CLI output for sticky PR comments (drops design
                          cleanup from the printed list and fail-on gate)
  --explain <file:line>   diagnose why a rule fired or why a suppression didn't apply
  --why <file:line>       alias for --explain
  -h, --help              display help

When a suppression isn't working, --explain <file:line> (or its alias --why <file:line>) reports what the scanner sees at that location, including why a nearby react-doctor-disable-next-line didn't apply. The diagnosis distinguishes the common failure modes — adjacent comment for a different rule (use the comma form), a code line between the comment and the diagnostic (the chain is broken), or no nearby suppression at all. The same hint surfaces inline with --verbose for every flagged site, and in --json output as diagnostic.suppressionHint, so a single scan doubles as a suppression audit without a separate flag.

--json produces a parsable object on stdout with all human-readable output suppressed. Errors still produce a JSON object with ok: false, so stdout is always a valid document.

Config keys

Key Type Default
ignore.rules string[] []
ignore.files string[] []
ignore.overrides { files, rules? }[] []
lint boolean true
verbose boolean false
diff boolean | string
failOn "error" | "warning" | "none" "none"
customRulesOnly boolean false
share boolean true
offline boolean false
textComponents string[] []
rawTextWrapperComponents string[] []
serverAuthFunctionNames string[] []
respectInlineDisables boolean true
adoptExistingLintConfig boolean true
ignore.tags string[] []

textComponents is the broad escape hatch for rn-no-raw-text — list components that themselves behave like React Native's <Text> (custom Typography, NativeTabs.Trigger.Label, etc.) and the rule will treat them as text containers regardless of what their children look like.

rawTextWrapperComponents is the narrower option for components that are not text elements but safely route string-only children through an internal <Text> (e.g. heroui-native's Button, which stringifies its children and renders them through a ButtonLabel). Listed wrappers suppress rn-no-raw-text only when their children are entirely stringifiable. A wrapper with mixed children — e.g. <Button>Save<Icon /></Button> — still reports because the wrapper can't safely route raw text alongside a sibling JSX element.

serverAuthFunctionNames teaches server-auth-actions about custom auth guards your codebase wraps around its auth library (e.g. requireWorkspaceMember, ensureSignedIn). Listed names are accepted as a valid top-of-action auth check whether called bare (requireWorkspaceMember()) or as a member (guards.requireWorkspaceMember()), and — unlike the built-in default list — are treated as distinctive so the receiver is not re-validated.

ignore.tags suppresses entire categories of rules by tag. For example, "tags": ["design"] disables all opinionated design rules (gradient text, pure black backgrounds, side tab borders, default Tailwind palettes). Available tags: "design".

offline skips the score API entirely — no score is shown and no share URL is generated. CI runs (GitHub Actions, GitLab CI, CircleCI) are not offline by default; only the share URL is suppressed. Set offline: true (or --offline) explicitly when you want zero network.

React Native support matrix

rn-* rules cover the React Native ecosystem in addition to React DOM. Detection keys off package.json dependencies (or their hoisted equivalents) plus file-extension hints:

Stack / runtime Detection signal (in package.json or workspace) rn-* rules
React Native CLI react-native dependency ON
React Native tvOS react-native-tvos ON
Expo (managed and bare) expo, expo-router, any @expo/* ON
React Native for Windows / macOS react-native-windows, react-native-macos ON
Out-of-tree React Native targets Any @react-native/* or @react-native-* package (e.g. @react-native-firebase/app, @react-native-async-storage/async-storage) ON
Metro-based projects without an expo / react-native dep Top-level "react-native" resolution field in package.json ON
Web-only React (Next.js, Vite, CRA, Gatsby, Remix, Docusaurus, Storybook, plain react-dom) next, vite, react-scripts, gatsby, @remix-run/*, @docusaurus/*, @storybook/*, react-dom (without an RN sibling) OFF
*.web.tsx / *.web.jsx files in an RN package File extension OFF (file)
*.ios.tsx / *.android.tsx / *.native.tsx files File extension ON (file)

Mixed monorepos work both ways: a web-rooted workspace with even one RN package gets the rn-* rules loaded, then file-level boundaries keep them silent on the web workspaces. The configuration knob rawTextWrapperComponents lets you teach rn-no-raw-text about library components that safely route string children through an internal <Text> (e.g. heroui-native's Button); textComponents is the broader escape hatch for components that behave like <Text> themselves.

React Native rules in mixed monorepos

rn-* rules respect per-package boundaries automatically. In a mixed React Native + web monorepo (apps/mobile alongside apps/web / apps/vite-app / packages/storybook / apps/docs), every rn-* rule walks up to the file's nearest package.json before running:

  • Packages that declare react-native, react-native-tvos, expo, expo-router, @expo/*, react-native-windows, react-native-macos, anything under the @react-native/ or @react-native- namespaces (@react-native-firebase/app, @react-native-async-storage/async-storage, …), or Metro's top-level "react-native" resolution field → rules ON.
  • Packages that declare a web-only framework (next, vite, react-scripts, gatsby, @remix-run/*, @docusaurus/*, @storybook/*, or plain react-dom without an RN sibling) → rules OFF.
  • Packages with no clear local signal → fall back to the project-level framework detection.

File extensions override the package classification when they're unambiguous: *.web.tsx / *.web.jsx are always skipped (Metro resolves these only against react-native-web); *.ios.tsx / *.android.tsx / *.native.tsx are always scanned (mobile-only).

The detection is bidirectional: a web-rooted monorepo (root package.json declares next or vite) still loads the rn-* rules when any workspace targets React Native — the file-level boundary then keeps them silent on the web workspaces and active on the mobile ones.

rn-no-raw-text additionally short-circuits raw text inside platform-fork branches:

  • if (Platform.OS === "web") { … } consequent — and the else branch of if (Platform.OS !== "web").
  • Platform.OS === "web" ? <X /> : … ternaries, Platform.OS === "web" && <X /> short-circuits, and the reversed-operand form "web" === Platform.OS.
  • switch (Platform.OS) { case "web": … } case bodies (other cases still report).
  • Platform.select({ web: <X />, default: <Y /> }) — only the web arm is exempt.
  • Platform?.OS === "web" (optional chain) and Platform.OS! === "web" (TS non-null assertion) parse the same way as the bare form.

The walker stops at function and Program boundaries — JSX defined inside a callback hoisted out of a Platform.OS branch does not inherit the parent guard. Negative platform checks like Platform.OS === "ios" are deliberately NOT treated as web exemptions; only the explicit web branch is.

Scoring

The health score formula: 100 - (unique_error_rules x 1.5) - (unique_warning_rules x 0.75).

Scoring runs on react.doctor's API and is network-dependent: without a successful API round-trip (or under --offline) the score is omitted and the rest of the report still renders normally. Score-based automation must treat an empty value as a no-op (see the strict-threshold example above). Key details:

  • The score counts unique rules triggered, not total instances. Fixing 49 of 50 no-barrel-import violations does not change the score; fixing all 50 removes the 0.75 penalty for that rule.
  • Error-severity rules cost 1.5 points each. Warning-severity rules cost 0.75 points each.
  • Category breakdowns shown in the output are for display only and do not weight the score.

Score labels: 75+ is Great, 50 to 74 is Needs work, under 50 is Critical.

Scores may decrease across releases as new rules are added. Each new rule that fires in your codebase introduces an additional penalty. This is expected: it means the tool is catching more issues, not that your code got worse. Don't chase 100/100 blindly; the score is a signal to investigate, not a target.

When a release moves your score noticeably:

  • Check the per-release changelog for added rules, severity changes, or formula tweaks. Each release ships with the changeset-generated notes.
  • Compare the unique rules triggered list against the previous run. A single new rule firing once can subtract 1.5 points.
  • Pin to a specific react-doctor version in CI (see Release versioning) if you need stable scores across upgrades. Pinning the action ref alone is not enough; pin the CLI side too.
  • If a newly fired rule looks noisy in your codebase, --explain <file:line> (or --why) prints exactly which rule it is and the suppression snippet to silence it. See Inline suppressions and Configuration.

Diff and staged modes

React Doctor can scan only changed files instead of the full project:

  • --diff [base] scans files changed vs a base branch. Auto-detects main/master, or pass an explicit branch: --diff develop. Also available as a config key: "diff": true or "diff": "develop".
  • --staged scans only files in the git staging area (index). Designed for pre-commit hooks — materializes staged file contents into a temp directory so the scan reflects exactly what will be committed.
  • --full forces a full scan, overriding any diff value in config or CLI.

When on a feature branch without explicit flags, you'll be prompted: "Only scan changed files?" This prompt is suppressed in CI, --json mode, and non-interactive environments.

--staged and --diff cannot be combined.

Pre-commit hooks with Husky + lint-staged

The most common setup is Husky for the git hook and lint-staged to filter which files run through each tool. React Doctor's --staged mode is built for this: it reads file contents from the git index (not the working tree) and materializes them into a temp directory, so partially-staged files are scanned exactly as they will be committed.

Install both, then wire them up:

npx ni -D husky lint-staged
npx husky init

husky init creates .husky/pre-commit. Replace its contents with:

npx lint-staged

Then add a lint-staged block to your package.json. Because React Doctor already filters to the staged set via --staged, do not pass the lint-staged-injected file list — invoke it with a single command and let it discover the index itself:

{
  "lint-staged": {
    "*.{ts,tsx,js,jsx}": "react-doctor --staged --fail-on warning"
  }
}

A few notes that bite people:

  • Don't append {staged-files} — lint-staged would otherwise pass the matched paths as positional arguments and you'd get the union (path filter + index scan) instead of the intent.

  • Use the function form when you only want the hook to run if any matching file is staged but still want a single project-wide scan:

    // lint-staged.config.js
    export default {
      "*.{ts,tsx,js,jsx}": () => "react-doctor --staged --fail-on warning",
    };
    
  • --fail-on warning blocks the commit on any diagnostic. Use --fail-on error for a softer gate, or --fail-on none to lint advisory-only.

  • Index vs. working tree: --staged reflects git diff --cached, not your editor buffer. If you git add half a file and keep typing, only the added half is scanned — the unstaged tail is ignored.

  • Skip in CI: lint-staged is a pre-commit concern. In CI, use the GitHub Action (above) or react-doctor --diff <base> directly; running both does duplicate work.

  • Other hook managers: the same react-doctor --staged --fail-on warning command works under Lefthook, pre-commit, or a hand-written .git/hooks/pre-commit — --staged is hook-manager-agnostic.

To bypass the hook for a one-off commit, use git commit --no-verify.

Agent and CI integration

React Doctor detects 50+ coding agents (Claude Code, Cursor, Codex, OpenCode, Windsurf, and more) and adapts its behavior automatically:

  • Install for agents: npx react-doctor@latest install writes agent-specific rule files (SKILL.md, AGENTS.md, .cursorrules) into your project so agents learn React best practices.
  • JSON output: --json produces a structured JsonReport on stdout. Errors still produce a valid JSON document with ok: false. Use --json-compact for minimal whitespace.
  • Score-only output: --score outputs just the numeric score (0-100), useful for threshold checks in agent loops.
  • GitHub Actions annotations: --annotations emits ::error / ::warning format for inline PR annotations. Annotations don't change the exit code.
  • Exit codes: --fail-on error (default) exits non-zero when error-severity diagnostics are found. Use --fail-on warning or --fail-on none to tune CI gating. See PR blocking and exit codes for the full model — including how to fail only on new regressions vs. fail on the baseline score.
  • Programmatic API: import { diagnose } from "react-doctor/api" for direct integration in scripts and automation.

In CI environments, prompts are automatically skipped. Pass --offline explicitly when you need zero network.

Non-GitHub CI (GitLab, Bitbucket, CircleCI, Jenkins, …)

The composite action is GitHub-specific, but everything it does is built on top of the CLI; there's nothing GitHub-only about React Doctor itself. For other providers, run the CLI directly and consume the JSON report from your existing tooling:

npx react-doctor@latest --diff "$CI_MERGE_REQUEST_TARGET_BRANCH_NAME" --fail-on warning --json > react-doctor.json

The JSON document is the same one the action consumes ({ ok, score, diagnostics[], summary, project }). Any CI dashboard that accepts a parsed report can read it. Three common patterns:

  • GitLab CI: invoke the CLI in a merge_request job and surface react-doctor.json as a Code Quality report artifact by translating diagnostics into the GitLab Code Quality JSON shape. Until a first-class GitLab integration ships, this is the recommended path.
  • CircleCI / Jenkins / Buildkite: fail the step on a non-zero exit (--fail-on warning) and upload react-doctor.json as an artifact. Read summary.errorCount / summary.warningCount in a follow-up step for thresholding.
  • Pre-merge gates without a dashboard: --diff <base> --fail-on warning is enough; the build log carries the diagnostics.

SARIF and a hosted GitLab integration are tracked separately; see TODOS.md if you'd like to follow along. In the meantime, the JSON output is intentionally stable: JsonReport, JsonReportSummary, and friends are exported from react-doctor/api for type-safe consumption.

Node.js API

import { diagnose, toJsonReport, summarizeDiagnostics } from "react-doctor/api";

const result = await diagnose("./path/to/your/react-project");

console.log(result.score); // { score: 82, label: "Great" } or null
console.log(result.diagnostics); // Diagnostic[]
console.log(result.project); // detected framework, React version, etc.

diagnose accepts a second argument: { lint?: boolean }.

const report = toJsonReport(result, { version: "1.0.0" });
const counts = summarizeDiagnostics(result.diagnostics);

react-doctor/api re-exports JsonReport, JsonReportSummary, JsonReportProjectEntry, JsonReportMode, plus the lower-level buildJsonReport and buildJsonReportError builders. See packages/react-doctor/src/api.ts for the full types.

Privacy and data

React Doctor runs entirely on your machine. The only network traffic is the optional score lookup and an opt-out share URL, and both can be disabled.

Network call URL Sent Disabled by
Score API https://www.react.doctor/api/score Gzipped JSON of the diagnostic list with file paths stripped (rule id, severity, message, position only). No source code, no file paths, no project name. --offline / "offline": true
Share URL (printed) https://www.react.doctor/share?p=...&s=... Query parameters only: detected project name, score, error count, warning count, affected file count. Nothing is uploaded; the link is rendered locally. --offline, "share": false, or any CI run

What --offline disables, exactly:

  • The score API call. No score is computed and the score action output / --score value is empty.
  • The share URL line in the local CLI summary.

What --offline does not affect:

  • Local rule execution, diagnostics, exit codes, JSON / annotation output, and the GitHub Actions sticky PR comment (the comment is constructed locally from the printed report).

Source code never leaves your machine. There is no telemetry pipeline, no file upload, and no usage analytics in the CLI.

For the hosted React Review product on react.doctor, the GitHub App reads repository contents through GitHub's standard installation-scoped access in order to compute the same diagnostics server-side; review the App permissions screen before installing.

Leaderboard

Top React codebases scanned by React Doctor, ranked by score. Updated automatically from millionco/react-doctor-benchmarks.

# Repo Score
1 executor 96
2 nodejs.org 86
3 tldraw 71
4 t3code 69
5 better-auth 64
6 mastra 63
7 excalidraw 62
8 payload 60
9 typebot 57
10 medusajs/admin 56

See the full leaderboard.

Resources & Contributing Back

Want to try it out? Check out the demo.

Looking to contribute back? Clone the repo, install, build, and submit a PR.

git clone https://github.com/millionco/react-doctor
cd react-doctor
pnpm install
pnpm build
node packages/react-doctor/bin/react-doctor.js /path/to/your/react-project

Find a bug? Head to the issue tracker.

License

React Doctor is MIT-licensed open-source software.

S
Description
react-doctor: Use when finishing a feature, fixing a bug, before committing React code, or when the user types /doctor, asks to scan, triage, or clean up React…; improve-react: Survey a whole React codebase as a senior React engineer, using React Doctor's scan as evidence, then produce a prioritized audit and self-contained…
Readme MIT 136 MiB
Languages
TypeScript 98.9%
JavaScript 1%