Skip to content

feat(react-doctor): typed errors + config rootDir for diagnose() targeting#200

Merged
aidenybai merged 6 commits into
mainfrom
cursor/add-errors-module-ba36
May 10, 2026
Merged

feat(react-doctor): typed errors + config rootDir for diagnose() targeting#200
aidenybai merged 6 commits into
mainfrom
cursor/add-errors-module-ba36

Conversation

@aidenybai

@aidenybai aidenybai commented May 10, 2026

Copy link
Copy Markdown
Member

Summary

Three related changes to how react-doctor resolves which project to scan, plus the typed error vocabulary that goes with them. Each commit on the branch is independently shippable.

1. Typed error classes for the node API

react-doctor/api now exports named subclasses of ReactDoctorError instead of plain Error:

  • ReactDoctorError — base class
  • ProjectNotFoundError — no React project (root or nested) under the requested directory
  • NoReactDependencyError — package found but no react dep
  • PackageJsonNotFoundErrordiscoverProject() got a directory with no package.json
  • AmbiguousProjectError — multiple nested React subprojects under a directory with no root package.json; carries candidates: readonly string[]
  • isReactDoctorError(value)value is ReactDoctorError type guard

Each error preserves its message text (existing message-substring tests still pass) and sets name to the class name so it round-trips through JSON.stringify and error.name checks.

2. Refuse ambiguous diagnose() targets

The fallback added in #193 silently picked reactSubprojects[0] when the requested directory had no root package.json. That works for single-subproject roots (the Vercel AI Code Review use case) but in true monorepo trees it just guesses — non-deterministic across filesystems.

New behavior:

The CLI is unaffected — it continues to use selectProjects for prompt-or-scan-all.

3. rootDir in react-doctor.config.json

The declarative answer to "I have a monorepo with apps/web and want a single root-level config to route every invocation there." Setting "rootDir": "apps/web" in react-doctor.config.json (or the reactDoctor key in package.json) redirects the CLI / scan() / diagnose() to that subdirectory.

Resolution rules:

  • Resolved relative to the config file's directory, not the CWD — stable regardless of where the user invokes from.
  • Absolute paths used verbatim.
  • Missing / non-directory paths fall back to the requested directory with a logger.warn.
  • Non-string values are stripped by validateConfigTypes with the same stderr warning shape used for the boolean fields.
  • CLI prints a one-line dimmed Redirected to <relative> via react-doctor config "rootDir" notice (suppressed under --json / --score).

This is also the proper escape hatch for the ambiguous-wrapper case from change #2: a repo with no root package.json and apps/web + apps/admin can declare its intent with rootDir: "apps/web" instead of relying on a CLI argument every time.

Mechanically: refactored load-config to add loadConfigWithSource() (config + the directory it loaded from), kept loadConfig as a thin wrapper for backward compatibility, added utils/resolve-config-root-dir.ts for the resolution + sanity checks, and wired the redirect into all three entry points. When the CLI passes configOverride into scan(), scan() trusts that the caller already applied the redirect to avoid double-redirecting.

Verification

  • pnpm typecheck — pass
  • pnpm lint — pass (0 warnings, 0 errors on 178 files)
  • pnpm format — clean
  • pnpm test (in packages/react-doctor) — 695/695 pass
    • Pre-existing: 685
    • +1 ambiguous-project regression
    • +9 covering rootDir resolution (relative, absolute, ancestor config location, non-existent fallback, non-string validation, source-directory exposure)

Example: end-to-end

// repo-root/react-doctor.config.json
{
  "rootDir": "apps/web",
  "ignore": { "rules": ["jsx-a11y/no-autofocus"] }
}
import {
  diagnose,
  AmbiguousProjectError,
  NoReactDependencyError,
  isReactDoctorError,
} from "react-doctor/api";

try {
  // Resolves the rootDir redirect: scans repo-root/apps/web
  await diagnose(repoRoot);
} catch (error) {
  if (error instanceof AmbiguousProjectError) {
    for (const candidate of error.candidates) {
      await diagnose(path.join(error.directory, candidate));
    }
  } else if (error instanceof NoReactDependencyError) {
    // error.directory is typed as string
  } else if (isReactDoctorError(error)) {
    // any other known react-doctor failure
  } else {
    throw error;
  }
}
Open in Web Open in Cursor 

Introduces a new errors.ts module exporting ReactDoctorError (base),
ProjectNotFoundError, NoReactDependencyError, PackageJsonNotFoundError,
and an isReactDoctorError type-guard. The diagnose() entry, scan(), and
discoverProject() now throw these typed errors instead of plain Error
instances so programmatic API consumers can branch on instanceof / name
without parsing message strings. Each error exposes the offending
directory as a readable property.

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

vercel Bot commented May 10, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
react-doctor-website Ready Ready Preview, Comment May 10, 2026 4:35am

@reactreview

reactreview Bot commented May 10, 2026

Copy link
Copy Markdown

React Review found 0 ❌ errors and 0 ⚠️ warnings. This PR leaves the React health score unchanged.

Copy prompt for agent
Check if these React Review issues are valid. If so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.

Run this before and after your changes to verify the result:
npx react-doctor@latest --verbose --diff

Do not modify the react-doctor configuration unless explicitly asked.
Fix the underlying code issues instead of changing or suppressing the rules.

React Review found 0 errors and 0 warnings. This PR leaves the React health score unchanged.

No net-new issues were introduced.

Reviewed by react-review for commit af22448. Configure here.

…ProjectError

The fallback added in #193 silently picked the first nested React
subproject when the requested directory had no root package.json. That
worked for the single-subproject case (apps/web with no root manifest)
but in true monorepo trees with multiple React projects it would just
pick whichever one readdirSync returned first — no signal to the
caller, and effectively non-deterministic across filesystems.

The CLI surface already has the right ergonomics here via
selectProjects: prompt in TTY, scan all under --yes / non-interactive.
diagnose() can't reasonably mirror that — it returns a single
DiagnoseResult, runs from non-TTY contexts (e.g. the Vercel AI Code
Review sandbox), and shouldn't pull prompts into the library bundle.

Instead, surface the ambiguity:
- 0 nested candidates: ProjectNotFoundError (unchanged)
- 1 nested candidate: auto-resolve (preserves the #193 fix)
- 2+ nested candidates: throw AmbiguousProjectError with a
  candidates[] field so callers can iterate or disambiguate.

Adds a regression test covering the ambiguous case and exports the new
error from the node API.

Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
@cursor cursor Bot changed the title feat(react-doctor): add typed error classes to node api feat(react-doctor): typed errors for diagnose() + refuse ambiguous targets May 10, 2026
A monorepo's only react-doctor config typically lives at the repo root
(so editor tooling and child commands all discover it via the standard
ancestor walk), but the React app frequently lives in a subfolder like
apps/web. Today users must either cd into that subfolder before
running react-doctor, pass an explicit path on every invocation, or
duplicate the config inside the subproject — and the recently-added
AmbiguousProjectError surface makes the silent guess go away but
doesn't give them a way to declare the right answer.

Add a rootDir config field (mirroring tsconfig's rootDir semantics):
when set, the CLI / scan() / diagnose() redirect their target
directory to <configDirectory>/<rootDir> (or to rootDir itself when
absolute) so a single root-level config can route every invocation at
the right project.

Resolution rules:
- Resolved relative to the config file's directory, not the CWD, so
  the redirect is stable regardless of where react-doctor is invoked.
- Absolute paths used verbatim.
- Missing or non-directory paths fall back to the originally requested
  directory with a logger.warn.
- Non-string rootDir values are stripped by validateConfigTypes with
  the same stderr warning shape used for the boolean fields.
- The CLI prints a one-line dimmed redirect notice (suppressed under
  --json / --score so they remain pure data sinks).

Mechanically:
- Add rootDir?: string to ReactDoctorConfig.
- Refactor load-config to expose loadConfigWithSource (config plus
  the directory it was loaded from); keep the old loadConfig as a
  thin wrapper for backward compatibility with the existing 30+
  callsites and tests that only need the config.
- Add resolve-config-root-dir util that does the resolution + sanity
  checks and emits the warning on bad input.
- Wire the redirect into all three entry points (diagnose, scan,
  CLI). When the CLI passes configOverride into scan(), scan()
  trusts that the caller already applied the redirect to avoid
  double-redirecting.

Tests cover: relative + absolute rootDir, ancestor config resolution,
disambiguation of an otherwise-AmbiguousProjectError wrapper,
non-existent rootDir fallback, and validateConfigTypes stripping a
non-string rootDir.

Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
@cursor cursor Bot changed the title feat(react-doctor): typed errors for diagnose() + refuse ambiguous targets feat(react-doctor): typed errors + config rootDir for diagnose() targeting May 10, 2026
Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
@aidenybai aidenybai marked this pull request as ready for review May 10, 2026 04:18
react-review flagged the [...array].sort() spread allocation on the
new test. toSorted() is the ES2023 immutable sort that avoids the
intermediate spread copy and is what the js-tosorted-immutable rule
asks for.

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

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 3568c8c. Configure here.

Comment thread packages/react-doctor/src/cli.ts
Bugbot caught that the main scan loop in cli.ts called scan() without
configOverride, while the staged-mode and explain-mode loops both
passed it. The asymmetry meant scan() would re-load the config from
each projectDirectory via the ancestor walk and re-apply the rootDir
redirect — so a workspace package nested beneath a redirected target
(e.g. apps/web/packages/ui under a root config that sets
rootDir: "apps/web") would get redirected back to apps/web and
scanned at the wrong directory.

Pass configOverride: userConfig from the main loop so scan() trusts
the CLI's already-resolved directory and config, matching the other
two callsites.

Adds two scan() regression tests:
- with configOverride: rootDir is NOT re-applied
- without configOverride: rootDir IS honored (preserves the
  programmatic-scan contract from the previous commit)

Co-authored-by: Aiden Bai <aidenybai@users.noreply.github.com>
@aidenybai aidenybai merged commit 5f0f9f9 into main May 10, 2026
7 checks passed
@aidenybai aidenybai deleted the cursor/add-errors-module-ba36 branch May 10, 2026 04:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants