Skip to content
Draft
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
3 changes: 3 additions & 0 deletions .changeset/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
"fixed": [
[
"react-doctor",
"tui-doctor",
"ui-doctor",
"threejs-doctor",
"eslint-plugin-react-doctor",
"oxlint-plugin-react-doctor",
"@react-doctor/core",
Expand Down
8 changes: 8 additions & 0 deletions .changeset/focused-doctors-share.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"react-doctor": patch
"tui-doctor": patch
"ui-doctor": patch
"threejs-doctor": patch
---

Add separately installable TUI Doctor, UI Doctor, and Three.js Doctor CLIs backed by React Doctor's synchronized engine and filtered rule registry.
2 changes: 1 addition & 1 deletion .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ jobs:
env:
DEV_VERSION: ${{ steps.dev-version.outputs.version }}
run: |
for package_dir in react-doctor oxlint-plugin-react-doctor eslint-plugin-react-doctor; do
for package_dir in react-doctor tui-doctor ui-doctor threejs-doctor oxlint-plugin-react-doctor eslint-plugin-react-doctor; do
node -e "const fs = require('node:fs'); const path = 'packages/${package_dir}/package.json'; const manifest = JSON.parse(fs.readFileSync(path, 'utf8')); manifest.version = process.env.DEV_VERSION; fs.writeFileSync(path, JSON.stringify(manifest, null, 2) + '\n');"
done

Expand Down
10 changes: 10 additions & 0 deletions packages/react-doctor/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,16 @@ Scans JavaScript, TypeScript, and Astro source files plus JavaScript in standard

[Website →](https://react.doctor/docs)

## Focused packages

The same engine and `doctor.config.*` configuration are also available through focused, independently installable CLIs:

- [`ui-doctor`](https://npmjs.com/package/ui-doctor) runs every UI design rule.
- [`threejs-doctor`](https://npmjs.com/package/threejs-doctor) runs the Three.js and React Three Fiber rules.
- [`tui-doctor`](https://npmjs.com/package/tui-doctor) runs the Ink terminal UI rules.

Each focused package skips unrelated rules, dead-code analysis, supply-chain checks, and the React health score.

## Install

### 1. Quick start
Expand Down
6 changes: 4 additions & 2 deletions packages/react-doctor/src/cli/commands/inspect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ import { validateIncludeUntrackedScope, validateModeFlags } from "../utils/valid
import { VERSION } from "../utils/version.js";
import { findStagedSnapshotDivergences } from "../utils/find-staged-snapshot-divergences.js";
import { CliInputError } from "../utils/cli-input-error.js";
import { getDoctorProduct } from "../utils/doctor-product.js";

interface CompletedScan {
directory: string;
Expand Down Expand Up @@ -190,10 +191,11 @@ const finalizeScans = (input: FinalizeScansInput): void => {
const baselineDegraded = input.baselineIntended && !baselineComputed;
const mode: JsonReportMode = baselineDegraded ? "diff" : input.mode;
const isReactDetected = input.completedScans.some((scan) => hasReactRuntime(scan.result.project));
if (input.completedScans.length > 0 && !isReactDetected) {
const doctorProduct = getDoctorProduct();
if (input.completedScans.length > 0 && !isReactDetected && doctorProduct.requiresReactRuntime) {
recordCount(METRIC.scanNoReactDetected, 1);
logger.warn(
`No React project detected at ${input.resolvedDirectory} — React rules were gated off; this is not the same as a clean scan.`,
`No React project detected at ${input.resolvedDirectory} — ${doctorProduct.displayName} rules were gated off; this is not the same as a clean scan.`,
);
}
const jsonCompletedScans = filterCompletedScansByCategories(
Expand Down
3 changes: 2 additions & 1 deletion packages/react-doctor/src/cli/commands/version.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { METRIC } from "../utils/constants.js";
import { recordCount } from "../utils/record-metric.js";
import { getDoctorProduct } from "../utils/doctor-product.js";
import { VERSION } from "../utils/version.js";

/**
Expand All @@ -10,7 +11,7 @@ import { VERSION } from "../utils/version.js";
* number) so scripts can parse them.
*/
export const buildVersionString = (): string =>
`react-doctor/${VERSION} ${process.platform}-${process.arch} node-${process.version}`;
`${getDoctorProduct().packageName}/${VERSION} ${process.platform}-${process.arch} node-${process.version}`;

export const versionAction = (): void => {
recordCount(METRIC.cliInvoked, 1, { command: "version" });
Expand Down
92 changes: 61 additions & 31 deletions packages/react-doctor/src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ import { reportErrorToSentry } from "./utils/report-error.js";
import { stripUnknownCliFlags } from "./utils/strip-unknown-cli-flags.js";
import { unrefStdin } from "./utils/unref-stdin.js";
import { VERSION } from "./utils/version.js";
import { getDoctorProduct } from "./utils/doctor-product.js";

const doctorProduct = getDoctorProduct();
const isFocusedProduct = doctorProduct.includedTags.length > 0;

initializeSentry();

Expand Down Expand Up @@ -70,21 +74,24 @@ const formatExampleLines = (
// clig.dev (Help): "Lead with examples." Epilogs are functions, not
// pre-built strings, so they render after `applyColorPreference` runs and
// honor `--no-color` in a TTY.
const renderRootHelpEpilog = (): string => `
const renderReactDoctorHelpEpilog = (): string => `
${highlighter.dim("Examples:")}
${formatExampleLines([
["react-doctor", "scan the current project"],
["react-doctor ./apps/web", "scan a specific directory"],
["react-doctor --scope changed --base main", "scan only new issues vs. main"],
["react-doctor --project modules/a,modules/b", "score each module separately (names or paths)"],
["react-doctor --staged", "scan staged files (pre-commit hook)"],
["react-doctor design", "run the focused UI design audit"],
["react-doctor --category Security", "show only one diagnostic category"],
["react-doctor --blocking warning", "fail CI on warnings too (default: error)"],
["react-doctor --json > report.json", "write a machine-readable report"],
["react-doctor why src/App.tsx:42", "explain why a rule fired there"],
["react-doctor ci install", "scan every pull request in CI"],
["react-doctor install", "set up the agent skill and git hook"],
[doctorProduct.packageName, "scan the current project"],
[`${doctorProduct.packageName} ./apps/web`, "scan a specific directory"],
[`${doctorProduct.packageName} --scope changed --base main`, "scan only new issues vs. main"],
[
`${doctorProduct.packageName} --project modules/a,modules/b`,
"score each module separately (names or paths)",
],
[`${doctorProduct.packageName} --staged`, "scan staged files (pre-commit hook)"],
[`${doctorProduct.packageName} design`, "run the focused UI design audit"],
[`${doctorProduct.packageName} --category Security`, "show only one diagnostic category"],
[`${doctorProduct.packageName} --blocking warning`, "fail CI on warnings too (default: error)"],
[`${doctorProduct.packageName} --json > report.json`, "write a machine-readable report"],
[`${doctorProduct.packageName} why src/App.tsx:42`, "explain why a rule fired there"],
[`${doctorProduct.packageName} ci install`, "scan every pull request in CI"],
[`${doctorProduct.packageName} install`, "set up the agent skill and git hook"],
])}

${highlighter.dim("Configuration:")}
Expand All @@ -98,6 +105,29 @@ ${highlighter.dim("Learn more:")}
${highlighter.info(CANONICAL_GITHUB_URL)}
`;

const renderFocusedProductHelpEpilog = (): string => `
${highlighter.dim("Examples:")}
${formatExampleLines([
[doctorProduct.packageName, "scan the current project"],
[`${doctorProduct.packageName} ./apps/web`, "scan a specific directory"],
[`${doctorProduct.packageName} --scope changed --base main`, "scan only new issues vs. main"],
[`${doctorProduct.packageName} --verbose`, "show every finding"],
[`${doctorProduct.packageName} --json > report.json`, "write a machine-readable report"],
])}

${highlighter.dim("Scope:")}
Runs React Doctor rules tagged ${doctorProduct.includedTags.map((tag) => highlighter.info(tag)).join(" or ")}.
Dead-code, supply-chain, external lint-config, custom-plugin, and health-score passes are skipped.
${doctorProduct.displayName} respects ${highlighter.info("doctor.config.ts")}, inline disables, project selection, diff scopes, and JSON output.

${highlighter.dim("Feedback & bug reports:")}
${highlighter.info(`${CANONICAL_GITHUB_URL}/issues`)}
`;

const renderRootHelpEpilog = isFocusedProduct
? renderFocusedProductHelpEpilog
: renderReactDoctorHelpEpilog;

const renderInstallHelpEpilog = (): string => `
${highlighter.dim("Examples:")}
${formatExampleLines([
Expand Down Expand Up @@ -153,9 +183,18 @@ const collectCategoryOption = (value: string, previousValues: string[] | undefin
value,
];

interface ExperimentalTuiOptions {
readonly blocking?: string;
readonly deadCode?: boolean;
readonly score?: boolean;
readonly supplyChain?: boolean;
readonly project?: string;
readonly yes?: boolean;
}

const program = new Command()
.name("react-doctor")
.description("Diagnose React codebase health")
.name(doctorProduct.packageName)
.description(doctorProduct.description)
.version(VERSION, "-v, --version", "display the version number")
.argument("[directory]", "project directory to scan", ".")
.option("--lint", "enable linting")
Expand Down Expand Up @@ -258,15 +297,15 @@ const program = new Command()
program.action(inspectAction);

program
.command("design [directory]")
.command("design [directory]", { hidden: isFocusedProduct })
.description("Run only the focused UI design diagnostics")
.addHelpText("after", renderDesignHelpEpilog)
.action((directory, _options, command) =>
designAction(directory ?? ".", command.optsWithGlobals()),
);

program
.command("why <location>")
.command("why <location>", { hidden: isFocusedProduct })
.description("Explain why a rule fired (or why a suppression didn't apply) at a file:line")
.option(
"--project <name>",
Expand All @@ -278,7 +317,7 @@ program
.action((location, options) => whyAction(location, options));

program
.command("install")
.command("install", { hidden: isFocusedProduct })
.alias("setup")
.description("Install the react-doctor skill into your coding agents and optional git hook")
.option("-y, --yes", "skip prompts, install for all detected agents")
Expand All @@ -305,7 +344,7 @@ const prOption: [string, string] = [
// action through `optsWithGlobals()` so the merged option set (subcommand +
// inherited globals) is what the action sees — mirroring the `rules` group.
const ci = program
.command("ci")
.command("ci", { hidden: isFocusedProduct })
.description("Set up, upgrade, and configure React Doctor in your CI");

ci.command("install")
Expand Down Expand Up @@ -358,14 +397,14 @@ ci.command("upgrade")
.action((_options, command) => ciUpgradeAction(command.optsWithGlobals()));

program
.command("version")
.command("version", { hidden: isFocusedProduct })
.description("show the version with Node and platform info")
.option("--color", "force colored output")
.option("--no-color", "disable colored output (also honors NO_COLOR)")
.action(versionAction);

const rules = program
.command("rules")
.command("rules", { hidden: isFocusedProduct })
.description("List, explain, and configure which React Doctor rules run");

// HACK: `--json` is also declared on the root program (for the default
Expand Down Expand Up @@ -440,20 +479,11 @@ rules
// It's gated behind the `experimental-` prefix because the editor language
// server is still unstable (protocol, caching, and diagnostics may change).
program
.command("experimental-lsp", { hidden: false })
.command("experimental-lsp", { hidden: isFocusedProduct })
.description("[experimental] run the React Doctor language server over stdio (for editors)")
.allowUnknownOption()
.action(() => {});

interface ExperimentalTuiOptions {
readonly blocking?: string;
readonly deadCode?: boolean;
readonly score?: boolean;
readonly supplyChain?: boolean;
readonly project?: string;
readonly yes?: boolean;
}

program
.command("experimental-tui [directory]", { hidden: true })
.description("[experimental] interactive, scrollable scan report")
Expand Down
4 changes: 3 additions & 1 deletion packages/react-doctor/src/cli/utils/build-handoff-payload.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { TOP_ERRORS_DISPLAY_COUNT } from "@react-doctor/core";
import type { Diagnostic } from "@react-doctor/core";
import { getDoctorProduct } from "./doctor-product.js";
import { HANDOFF_MAX_FILES_PER_RULE } from "./constants.js";
import {
buildSortedRuleGroups,
Expand All @@ -20,6 +21,7 @@ export interface HandoffPayloadInput {
// per rule) for follow-up. Keeps the first pass small & high-signal rather
// than dumping every issue inline.
export const buildHandoffPayload = (input: HandoffPayloadInput): string => {
const doctorProduct = getDoctorProduct();
const topGroups = buildSortedRuleGroups(input.diagnostics).slice(0, TOP_ERRORS_DISPLAY_COUNT);
const migrationScaleBuckets = new Map(
findMigrationScaleBuckets(input.diagnostics).map((bucket) => [bucket.ruleKey, bucket]),
Expand All @@ -31,7 +33,7 @@ export const buildHandoffPayload = (input: HandoffPayloadInput): string => {
} catch {}

const lines: string[] = [
`Fix the top ${topGroups.length} React Doctor ${topGroups.length === 1 ? "issue" : "issues"} in ${input.projectName} on this pass — leave the rest for a follow-up.`,
`Fix the top ${topGroups.length} ${doctorProduct.displayName} ${topGroups.length === 1 ? "issue" : "issues"} in ${input.projectName} on this pass — leave the rest for a follow-up.`,
"",
];

Expand Down
3 changes: 3 additions & 0 deletions packages/react-doctor/src/cli/utils/build-run-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,11 @@ import { isNonInteractiveEnvironment } from "./is-non-interactive-environment.js
import { isJsonModeActive } from "./json-mode.js";
import { getRunId } from "./run-id.js";
import { VERSION } from "./version.js";
import { getDoctorProduct } from "./doctor-product.js";

export interface RunContext {
version: string;
product: string;
// Random per-run (per-process) id, carried on events/spans (via
// `contexts.run`) only — never a tag.
runId: string;
Expand Down Expand Up @@ -98,6 +100,7 @@ export const buildRunContext = (): RunContext => {
const userArguments = process.argv.slice(2);
return {
version: VERSION,
product: getDoctorProduct().packageName,
runId: getRunId(),
origin: detectOrigin(),
command: detectCommand(userArguments),
Expand Down
1 change: 1 addition & 0 deletions packages/react-doctor/src/cli/utils/build-sentry-scope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export interface SentryScope {
*/
export const buildSentryScope = (runContext: RunContext = buildRunContext()): SentryScope => {
const tags: Record<string, string | number | boolean | null> = {
product: runContext.product,
origin: runContext.origin,
command: runContext.command,
ci: runContext.ci,
Expand Down
52 changes: 52 additions & 0 deletions packages/react-doctor/src/cli/utils/doctor-product.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
export interface DoctorProduct {
readonly packageName: string;
readonly displayName: string;
readonly description: string;
readonly tagline: string;
readonly includedTags: readonly string[];
readonly scoreDisabledMessage?: string;
readonly requiresReactRuntime: boolean;
}

const REACT_DOCTOR_PRODUCT: DoctorProduct = {
packageName: "react-doctor",
displayName: "React Doctor",
description: "Diagnose React codebase health",
tagline: "I diagnose your React code for bugs, security & performance.",
includedTags: [],
requiresReactRuntime: true,
};

const DOCTOR_PRODUCTS: Record<string, DoctorProduct> = {
"react-doctor": REACT_DOCTOR_PRODUCT,
"tui-doctor": {
packageName: "tui-doctor",
displayName: "TUI Doctor",
description: "Diagnose Ink terminal UI code",
tagline: "I diagnose your Ink terminal UI for bugs and performance.",
includedTags: ["ink"],
scoreDisabledMessage: "TUI Doctor scans do not affect the React health score.",
requiresReactRuntime: true,
},
"ui-doctor": {
packageName: "ui-doctor",
displayName: "UI Doctor",
description: "Diagnose React UI design quality",
tagline: "I diagnose your React UI for design and usability issues.",
includedTags: ["design"],
scoreDisabledMessage: "UI Doctor scans do not affect the React health score.",
requiresReactRuntime: true,
},
"threejs-doctor": {
packageName: "threejs-doctor",
displayName: "Three.js Doctor",
description: "Diagnose Three.js and React Three Fiber code",
tagline: "I diagnose your Three.js code for bugs and performance.",
includedTags: ["three", "r3f"],
scoreDisabledMessage: "Three.js Doctor scans do not affect the React health score.",
requiresReactRuntime: false,
},
};

export const getDoctorProduct = (): DoctorProduct =>
DOCTOR_PRODUCTS[process.env.REACT_DOCTOR_PRODUCT ?? ""] ?? REACT_DOCTOR_PRODUCT;
3 changes: 2 additions & 1 deletion packages/react-doctor/src/cli/utils/handle-error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
isReactDoctorError,
} from "@react-doctor/core";
import type { HandleErrorOptions } from "@react-doctor/core";
import { getDoctorProduct } from "./doctor-product.js";
import { VERSION } from "./version.js";
import { METRIC } from "./constants.js";
import { formatEnvironmentError, isEnvironmentError } from "./is-environment-error.js";
Expand Down Expand Up @@ -70,7 +71,7 @@ const buildErrorIssueBody = (
"",
"## Runtime",
"",
`- react-doctor version: ${VERSION}`,
`- ${getDoctorProduct().packageName} version: ${VERSION}`,
`- node: ${context.nodeVersion}`,
`- platform: ${context.platform} ${context.architecture}`,
`- cwd: ${context.cwd}`,
Expand Down
6 changes: 5 additions & 1 deletion packages/react-doctor/src/cli/utils/print-branded-header.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as Console from "effect/Console";
import * as Effect from "effect/Effect";
import { highlighter } from "@react-doctor/core";
import { getDoctorProduct } from "./doctor-product.js";
import { VERSION } from "./version.js";

/**
Expand All @@ -13,6 +14,9 @@ import { VERSION } from "./version.js";
* they're still inside an imperative function.
*/
export const printBrandedHeader: Effect.Effect<void> = Effect.gen(function* () {
yield* Console.log(`${highlighter.bold("React Doctor")} ${highlighter.dim(`v${VERSION}`)}`);
const doctorProduct = getDoctorProduct();
yield* Console.log(
`${highlighter.bold(doctorProduct.displayName)} ${highlighter.dim(`v${VERSION}`)}`,
);
yield* Console.log("");
});
Loading
Loading