From 158c085ff5cf3601925b2dad358b4a4210a24d72 Mon Sep 17 00:00:00 2001 From: Aiden Bai Date: Sat, 1 Aug 2026 19:38:03 -0700 Subject: [PATCH] add focused doctor packages --- .changeset/config.json | 3 + .changeset/focused-doctors-share.md | 8 ++ .github/workflows/publish.yml | 2 +- packages/react-doctor/README.md | 10 ++ .../react-doctor/src/cli/commands/inspect.ts | 6 +- .../react-doctor/src/cli/commands/version.ts | 3 +- packages/react-doctor/src/cli/index.ts | 92 ++++++++++++------- .../src/cli/utils/build-handoff-payload.ts | 4 +- .../src/cli/utils/build-run-context.ts | 3 + .../src/cli/utils/build-sentry-scope.ts | 1 + .../src/cli/utils/doctor-product.ts | 52 +++++++++++ .../src/cli/utils/handle-error.ts | 3 +- .../src/cli/utils/print-branded-header.ts | 6 +- .../src/cli/utils/prompt-install-setup.ts | 2 + .../src/cli/utils/render-welcome.ts | 8 +- .../cli/utils/resolve-cli-inspect-options.ts | 19 ++-- .../tests/build-run-context.test.ts | 15 ++- .../tests/build-sentry-scope.test.ts | 2 + .../react-doctor/tests/doctor-product.test.ts | 35 +++++++ .../tests/resolve-cli-inspect-options.test.ts | 28 ++++++ .../tests/specialized-packages.test.ts | 63 +++++++++++++ packages/threejs-doctor/LICENSE | 34 +++++++ packages/threejs-doctor/README.md | 11 +++ packages/threejs-doctor/bin/threejs-doctor.js | 5 + packages/threejs-doctor/package.json | 40 ++++++++ packages/tui-doctor/LICENSE | 34 +++++++ packages/tui-doctor/README.md | 11 +++ packages/tui-doctor/bin/tui-doctor.js | 5 + packages/tui-doctor/package.json | 40 ++++++++ packages/ui-doctor/LICENSE | 34 +++++++ packages/ui-doctor/README.md | 11 +++ packages/ui-doctor/bin/ui-doctor.js | 5 + packages/ui-doctor/package.json | 40 ++++++++ pnpm-lock.yaml | 18 ++++ scripts/smoke-packed-cli-install.ts | 41 +++++++-- 35 files changed, 635 insertions(+), 59 deletions(-) create mode 100644 .changeset/focused-doctors-share.md create mode 100644 packages/react-doctor/src/cli/utils/doctor-product.ts create mode 100644 packages/react-doctor/tests/doctor-product.test.ts create mode 100644 packages/react-doctor/tests/specialized-packages.test.ts create mode 100644 packages/threejs-doctor/LICENSE create mode 100644 packages/threejs-doctor/README.md create mode 100755 packages/threejs-doctor/bin/threejs-doctor.js create mode 100644 packages/threejs-doctor/package.json create mode 100644 packages/tui-doctor/LICENSE create mode 100644 packages/tui-doctor/README.md create mode 100755 packages/tui-doctor/bin/tui-doctor.js create mode 100644 packages/tui-doctor/package.json create mode 100644 packages/ui-doctor/LICENSE create mode 100644 packages/ui-doctor/README.md create mode 100755 packages/ui-doctor/bin/ui-doctor.js create mode 100644 packages/ui-doctor/package.json diff --git a/.changeset/config.json b/.changeset/config.json index e0517abd71..5f382a538a 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -5,6 +5,9 @@ "fixed": [ [ "react-doctor", + "tui-doctor", + "ui-doctor", + "threejs-doctor", "eslint-plugin-react-doctor", "oxlint-plugin-react-doctor", "@react-doctor/core", diff --git a/.changeset/focused-doctors-share.md b/.changeset/focused-doctors-share.md new file mode 100644 index 0000000000..649fb123c0 --- /dev/null +++ b/.changeset/focused-doctors-share.md @@ -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. diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 45e05958ec..acdfdeb1f9 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -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 diff --git a/packages/react-doctor/README.md b/packages/react-doctor/README.md index f79425e76d..587fe64333 100644 --- a/packages/react-doctor/README.md +++ b/packages/react-doctor/README.md @@ -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 diff --git a/packages/react-doctor/src/cli/commands/inspect.ts b/packages/react-doctor/src/cli/commands/inspect.ts index 74cb3ea7d9..58141b222f 100644 --- a/packages/react-doctor/src/cli/commands/inspect.ts +++ b/packages/react-doctor/src/cli/commands/inspect.ts @@ -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; @@ -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( diff --git a/packages/react-doctor/src/cli/commands/version.ts b/packages/react-doctor/src/cli/commands/version.ts index 926c619a5c..8ecb6b593c 100644 --- a/packages/react-doctor/src/cli/commands/version.ts +++ b/packages/react-doctor/src/cli/commands/version.ts @@ -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"; /** @@ -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" }); diff --git a/packages/react-doctor/src/cli/index.ts b/packages/react-doctor/src/cli/index.ts index f3bab12c26..baa34008e0 100644 --- a/packages/react-doctor/src/cli/index.ts +++ b/packages/react-doctor/src/cli/index.ts @@ -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(); @@ -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:")} @@ -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([ @@ -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") @@ -258,7 +297,7 @@ 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) => @@ -266,7 +305,7 @@ program ); program - .command("why ") + .command("why ", { hidden: isFocusedProduct }) .description("Explain why a rule fired (or why a suppression didn't apply) at a file:line") .option( "--project ", @@ -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") @@ -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") @@ -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 @@ -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") diff --git a/packages/react-doctor/src/cli/utils/build-handoff-payload.ts b/packages/react-doctor/src/cli/utils/build-handoff-payload.ts index 8e8b5a978c..2e74789bab 100644 --- a/packages/react-doctor/src/cli/utils/build-handoff-payload.ts +++ b/packages/react-doctor/src/cli/utils/build-handoff-payload.ts @@ -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, @@ -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]), @@ -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.`, "", ]; diff --git a/packages/react-doctor/src/cli/utils/build-run-context.ts b/packages/react-doctor/src/cli/utils/build-run-context.ts index 09a2739a7e..c76814ae32 100644 --- a/packages/react-doctor/src/cli/utils/build-run-context.ts +++ b/packages/react-doctor/src/cli/utils/build-run-context.ts @@ -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; @@ -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), diff --git a/packages/react-doctor/src/cli/utils/build-sentry-scope.ts b/packages/react-doctor/src/cli/utils/build-sentry-scope.ts index 32a3c1a259..a9491286b1 100644 --- a/packages/react-doctor/src/cli/utils/build-sentry-scope.ts +++ b/packages/react-doctor/src/cli/utils/build-sentry-scope.ts @@ -27,6 +27,7 @@ export interface SentryScope { */ export const buildSentryScope = (runContext: RunContext = buildRunContext()): SentryScope => { const tags: Record = { + product: runContext.product, origin: runContext.origin, command: runContext.command, ci: runContext.ci, diff --git a/packages/react-doctor/src/cli/utils/doctor-product.ts b/packages/react-doctor/src/cli/utils/doctor-product.ts new file mode 100644 index 0000000000..aca5430055 --- /dev/null +++ b/packages/react-doctor/src/cli/utils/doctor-product.ts @@ -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 = { + "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; diff --git a/packages/react-doctor/src/cli/utils/handle-error.ts b/packages/react-doctor/src/cli/utils/handle-error.ts index 3c94edf4a5..a7e58861f3 100644 --- a/packages/react-doctor/src/cli/utils/handle-error.ts +++ b/packages/react-doctor/src/cli/utils/handle-error.ts @@ -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"; @@ -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}`, diff --git a/packages/react-doctor/src/cli/utils/print-branded-header.ts b/packages/react-doctor/src/cli/utils/print-branded-header.ts index 177348e181..0c7fc4a6fb 100644 --- a/packages/react-doctor/src/cli/utils/print-branded-header.ts +++ b/packages/react-doctor/src/cli/utils/print-branded-header.ts @@ -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"; /** @@ -13,6 +14,9 @@ import { VERSION } from "./version.js"; * they're still inside an imperative function. */ export const printBrandedHeader: Effect.Effect = 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(""); }); diff --git a/packages/react-doctor/src/cli/utils/prompt-install-setup.ts b/packages/react-doctor/src/cli/utils/prompt-install-setup.ts index 149ef1d1b3..cc8c02896e 100644 --- a/packages/react-doctor/src/cli/utils/prompt-install-setup.ts +++ b/packages/react-doctor/src/cli/utils/prompt-install-setup.ts @@ -3,6 +3,7 @@ import { type Gate, isGatePending, recordGate } from "./cli-lifecycle.js"; import { hashProjectRoot } from "./hash-project-root.js"; import { findNearestPackageDirectory, hasDoctorScript } from "./install-doctor-script.js"; import { isCodingAgentEnvironment } from "./is-ci-environment.js"; +import { getDoctorProduct } from "./doctor-product.js"; export interface SetupPitchWriter { (line?: string): void; @@ -69,6 +70,7 @@ export interface ShouldShowAgentInstallHintOptions { } export const shouldShowAgentInstallHint = (options: ShouldShowAgentInstallHintOptions): boolean => { + if (getDoctorProduct().includedTags.length > 0) return false; if (!(options.hasCompletedScan ?? options.hasScoredScan ?? false)) return false; if (options.isJsonMode) return false; if (options.isScoreOnly) return false; diff --git a/packages/react-doctor/src/cli/utils/render-welcome.ts b/packages/react-doctor/src/cli/utils/render-welcome.ts index b0c8b1039a..82c5a5402b 100644 --- a/packages/react-doctor/src/cli/utils/render-welcome.ts +++ b/packages/react-doctor/src/cli/utils/render-welcome.ts @@ -6,6 +6,7 @@ import { WELCOME_INTER_LINE_DELAY_MS, WELCOME_TYPEWRITER_CHAR_DELAY_MS, } from "./constants.js"; +import { getDoctorProduct } from "./doctor-product.js"; import { writeStdout } from "./write-stdout.js"; const HAPPY_FACE_LINES = ["┌─────┐", "│ ◠ ◠ │", "│ ▽ │", "└─────┘"] as const; @@ -60,6 +61,7 @@ const typeLine = ( export const playWelcomeScene = (options: WelcomeSceneOptions = {}): Effect.Effect => Effect.gen(function* () { const speedMultiplier = options.speedMultiplier ?? 1; + const doctorProduct = getDoctorProduct(); const charDelayMs = WELCOME_TYPEWRITER_CHAR_DELAY_MS / speedMultiplier; const interLineDelayMs = WELCOME_INTER_LINE_DELAY_MS / speedMultiplier; const explanationHoldMs = WELCOME_EXPLANATION_HOLD_MS / speedMultiplier; @@ -75,11 +77,11 @@ export const playWelcomeScene = (options: WelcomeSceneOptions = {}): Effect.Effe terminalColumns && terminalColumns > 0 ? Math.max(0, terminalColumns - FACE_PREFIX_WIDTH - RIGHT_EDGE_SAFETY_COLUMNS) : Number.POSITIVE_INFINITY; - const greeting = clampToColumns("Welcome to React Doctor", availableTextColumns); - const tagline = clampToColumns( - "I diagnose your React code for bugs, security & performance.", + const greeting = clampToColumns( + `Welcome to ${doctorProduct.displayName}`, availableTextColumns, ); + const tagline = clampToColumns(doctorProduct.tagline, availableTextColumns); // Blank line + face box; cursor ends just below the box. yield* writeStdout(`\n${face.map((line) => `${FACE_INDENT}${line}`).join("\n")}\n`); diff --git a/packages/react-doctor/src/cli/utils/resolve-cli-inspect-options.ts b/packages/react-doctor/src/cli/utils/resolve-cli-inspect-options.ts index 1524db3f7f..b88ae26860 100644 --- a/packages/react-doctor/src/cli/utils/resolve-cli-inspect-options.ts +++ b/packages/react-doctor/src/cli/utils/resolve-cli-inspect-options.ts @@ -1,5 +1,6 @@ import type { InspectOptions, ReactDoctorConfig } from "@react-doctor/core"; import type { InspectFlags } from "./inspect-flags.js"; +import { getDoctorProduct } from "./doctor-product.js"; import { isCiEnvironment } from "./is-ci-environment.js"; import { pickBlockingLevel } from "./resolve-blocking-level.js"; import { resolveCliCategories } from "./resolve-cli-categories.js"; @@ -33,11 +34,15 @@ export const resolveCliInspectOptions = ( // The gate level itself is resolved by `resolveBlockingLevel`. const wantsWarningGate = pickBlockingLevel(flags, userConfig) === "warning"; const isDesignScan = flags.design === true; + const doctorProduct = getDoctorProduct(); + const isFocusedProductScan = doctorProduct.includedTags.length > 0; + const isFocusedScan = isDesignScan || isFocusedProductScan; + const includedTags = isDesignScan ? ["design"] : doctorProduct.includedTags; return { - lint: flags.lint, - deadCode: isDesignScan ? false : flags.deadCode, - supplyChain: isDesignScan ? false : flags.supplyChain, + lint: isFocusedScan ? (flags.lint ?? true) : flags.lint, + deadCode: isFocusedScan ? false : flags.deadCode, + supplyChain: isFocusedScan ? false : flags.supplyChain, verbose: flags.verbose, outputDirectory: flags.outputDir, // `--no-respect-inline-disables` is negatable-only, so commander defaults @@ -50,16 +55,16 @@ export const resolveCliInspectOptions = ( // `inspect()`'s merge layer inherits `userConfig.noScore` from the // per-project (module-merged) config — eagerly collapsing it here from the // ROOT config silently overrode a workspace module's own `noScore: true`. - noScore: isDesignScan || flags.score === false || flags.telemetry === false ? true : undefined, + noScore: isFocusedScan || flags.score === false || flags.telemetry === false ? true : undefined, isCi: isCiEnvironment(), silent: Boolean(flags.json), concurrency: resolveParallelFlag(flags.parallel), maxDurationMs: resolveMaxDurationFlag(flags.maxDuration), categoryFilters: resolveCliCategories(flags.category), - includedTags: isDesignScan ? new Set(["design"]) : undefined, - includeTagDefaults: isDesignScan ? true : undefined, + includedTags: isFocusedScan ? new Set(includedTags) : undefined, + includeTagDefaults: isFocusedScan ? true : undefined, scoreDisabledMessage: isDesignScan ? "Design scans do not affect the React health score." - : undefined, + : doctorProduct.scoreDisabledMessage, }; }; diff --git a/packages/react-doctor/tests/build-run-context.test.ts b/packages/react-doctor/tests/build-run-context.test.ts index 5f153af852..f335de5417 100644 --- a/packages/react-doctor/tests/build-run-context.test.ts +++ b/packages/react-doctor/tests/build-run-context.test.ts @@ -2,7 +2,11 @@ import os from "node:os"; import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; import { buildRunContext } from "../src/cli/utils/build-run-context.js"; -const CI_ENV_VARS = ["GITHUB_EVENT_NAME", "REACT_DOCTOR_GITHUB_ACTION"] as const; +const RUN_CONTEXT_ENVIRONMENT_VARIABLES = [ + "GITHUB_EVENT_NAME", + "REACT_DOCTOR_GITHUB_ACTION", + "REACT_DOCTOR_PRODUCT", +] as const; describe("buildRunContext", () => { let savedUserAgent: string | undefined; @@ -16,7 +20,7 @@ describe("buildRunContext", () => { delete process.env.REACT_DOCTOR_LINT_BATCH_ORDERING; savedArgv = process.argv; savedEnv = {}; - for (const name of CI_ENV_VARS) { + for (const name of RUN_CONTEXT_ENVIRONMENT_VARIABLES) { savedEnv[name] = process.env[name]; delete process.env[name]; } @@ -34,7 +38,7 @@ describe("buildRunContext", () => { process.env.REACT_DOCTOR_LINT_BATCH_ORDERING = savedLintBatchOrdering; } process.argv = savedArgv; - for (const name of CI_ENV_VARS) { + for (const name of RUN_CONTEXT_ENVIRONMENT_VARIABLES) { const previous = savedEnv[name]; if (previous === undefined) delete process.env[name]; else process.env[name] = previous; @@ -63,6 +67,11 @@ describe("buildRunContext", () => { expect(buildRunContext().nodeMajor).toBe(expectedMajor); }); + it("records the active doctor product", () => { + process.env.REACT_DOCTOR_PRODUCT = "ui-doctor"; + expect(buildRunContext().product).toBe("ui-doctor"); + }); + it("records the focused design command separately from a general inspection", () => { process.argv = ["node", "react-doctor", "design", ".", "--verbose"]; expect(buildRunContext().command).toBe("design"); diff --git a/packages/react-doctor/tests/build-sentry-scope.test.ts b/packages/react-doctor/tests/build-sentry-scope.test.ts index 862d7c42c9..0ad0e60385 100644 --- a/packages/react-doctor/tests/build-sentry-scope.test.ts +++ b/packages/react-doctor/tests/build-sentry-scope.test.ts @@ -6,6 +6,7 @@ import type { ProjectInfo } from "@react-doctor/core"; const baseRunContext: RunContext = { version: "1.2.3", + product: "react-doctor", runId: "run-abc123", origin: "ci", command: "inspect", @@ -64,6 +65,7 @@ describe("buildSentryScope", () => { it("maps the run context to searchable tags", () => { const { tags } = buildSentryScope(baseRunContext); expect(tags).toEqual({ + product: "react-doctor", origin: "ci", command: "inspect", ci: true, diff --git a/packages/react-doctor/tests/doctor-product.test.ts b/packages/react-doctor/tests/doctor-product.test.ts new file mode 100644 index 0000000000..a3c8cea75a --- /dev/null +++ b/packages/react-doctor/tests/doctor-product.test.ts @@ -0,0 +1,35 @@ +import { afterEach, describe, expect, it } from "vite-plus/test"; +import { getDoctorProduct } from "../src/cli/utils/doctor-product.js"; + +const previousProduct = process.env.REACT_DOCTOR_PRODUCT; + +afterEach(() => { + if (previousProduct === undefined) delete process.env.REACT_DOCTOR_PRODUCT; + else process.env.REACT_DOCTOR_PRODUCT = previousProduct; +}); + +describe("getDoctorProduct", () => { + it("falls back to React Doctor for unset and unknown products", () => { + delete process.env.REACT_DOCTOR_PRODUCT; + expect(getDoctorProduct()).toMatchObject({ + packageName: "react-doctor", + displayName: "React Doctor", + includedTags: [], + }); + + process.env.REACT_DOCTOR_PRODUCT = "untrusted-doctor"; + expect(getDoctorProduct().packageName).toBe("react-doctor"); + }); + + it.each([ + ["tui-doctor", "TUI Doctor", ["ink"]], + ["ui-doctor", "UI Doctor", ["design"]], + ["threejs-doctor", "Three.js Doctor", ["three", "r3f"]], + ])("resolves the %s launcher", (packageName, displayName, includedTags) => { + process.env.REACT_DOCTOR_PRODUCT = packageName; + const product = getDoctorProduct(); + expect(product.displayName).toBe(displayName); + expect(product.includedTags).toEqual(includedTags); + expect(product.includedTags.length).toBeGreaterThan(0); + }); +}); diff --git a/packages/react-doctor/tests/resolve-cli-inspect-options.test.ts b/packages/react-doctor/tests/resolve-cli-inspect-options.test.ts index 6a246dd50f..ea496e24ad 100644 --- a/packages/react-doctor/tests/resolve-cli-inspect-options.test.ts +++ b/packages/react-doctor/tests/resolve-cli-inspect-options.test.ts @@ -171,3 +171,31 @@ describe("resolveCliInspectOptions: design scan", () => { ); }); }); + +describe("resolveCliInspectOptions: focused products", () => { + const previousProduct = process.env.REACT_DOCTOR_PRODUCT; + + afterEach(() => { + if (previousProduct === undefined) delete process.env.REACT_DOCTOR_PRODUCT; + else process.env.REACT_DOCTOR_PRODUCT = previousProduct; + }); + + it.each([ + ["tui-doctor", ["ink"]], + ["ui-doctor", ["design"]], + ["threejs-doctor", ["three", "r3f"]], + ])("selects only the %s rule tags", (packageName, includedTags) => { + process.env.REACT_DOCTOR_PRODUCT = packageName; + const resolved = resolveCliInspectOptions( + { deadCode: true, supplyChain: true, score: true }, + null, + ); + + expect(resolved.includedTags).toEqual(new Set(includedTags)); + expect(resolved.includeTagDefaults).toBe(true); + expect(resolved.lint).toBe(true); + expect(resolved.deadCode).toBe(false); + expect(resolved.supplyChain).toBe(false); + expect(resolved.noScore).toBe(true); + }); +}); diff --git a/packages/react-doctor/tests/specialized-packages.test.ts b/packages/react-doctor/tests/specialized-packages.test.ts new file mode 100644 index 0000000000..1b3f6c52ae --- /dev/null +++ b/packages/react-doctor/tests/specialized-packages.test.ts @@ -0,0 +1,63 @@ +import { spawnSync } from "node:child_process"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { describe, expect, it } from "vite-plus/test"; + +interface SpecializedPackageExpectation { + readonly packageName: string; + readonly displayName: string; +} + +interface SpecializedPackageManifest { + readonly name: string; + readonly version: string; + readonly dependencies: Record; +} + +const REPOSITORY_ROOT = path.resolve(import.meta.dirname, "../../.."); +const SPECIALIZED_PACKAGES: readonly SpecializedPackageExpectation[] = [ + { packageName: "tui-doctor", displayName: "TUI Doctor" }, + { packageName: "ui-doctor", displayName: "UI Doctor" }, + { packageName: "threejs-doctor", displayName: "Three.js Doctor" }, +]; + +const readManifest = (packageName: string): SpecializedPackageManifest => + JSON.parse( + fs.readFileSync(path.join(REPOSITORY_ROOT, "packages", packageName, "package.json"), "utf8"), + ); + +describe("specialized doctor packages", () => { + it("keeps every launcher synchronized with React Doctor", () => { + const reactDoctorVersion = readManifest("react-doctor").version; + for (const { packageName } of SPECIALIZED_PACKAGES) { + const manifest = readManifest(packageName); + expect(manifest.name).toBe(packageName); + expect(manifest.version).toBe(reactDoctorVersion); + expect(manifest.dependencies["react-doctor"]).toBe("workspace:*"); + } + }); + + it.each(SPECIALIZED_PACKAGES)( + "brands $packageName without exposing broad React Doctor commands", + ({ packageName, displayName }) => { + const binaryPath = path.join( + REPOSITORY_ROOT, + "packages", + packageName, + "bin", + `${packageName}.js`, + ); + const result = spawnSync(process.execPath, [binaryPath, "--help", "--no-color"], { + cwd: REPOSITORY_ROOT, + encoding: "utf8", + }); + + expect(result.status).toBe(0); + expect(result.stdout).toContain(`Usage: ${packageName}`); + expect(result.stdout).toContain(displayName); + expect(result.stdout).toContain("Runs React Doctor rules tagged"); + expect(result.stdout).not.toContain("experimental-lsp"); + expect(result.stdout).not.toContain("ci install"); + }, + ); +}); diff --git a/packages/threejs-doctor/LICENSE b/packages/threejs-doctor/LICENSE new file mode 100644 index 0000000000..aef05f7022 --- /dev/null +++ b/packages/threejs-doctor/LICENSE @@ -0,0 +1,34 @@ +Modified MIT License + +Copyright (c) 2026 Million Software, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +Our only modification is that the following uses require prior written +permission from the copyright holder. To request permission, contact +founders@million.dev. + +1. Using the Software, its source code, or any derivative works thereof, in + whole or in part, as training, fine-tuning, or evaluation data, or as input + to any automated pipeline for training or improving any machine learning + model or AI system. + +2. Selling the Software, or offering it to third parties as a paid, hosted, or + managed product or service (including any commercial API or SaaS offering) + whose value derives entirely or substantially from the Software. diff --git a/packages/threejs-doctor/README.md b/packages/threejs-doctor/README.md new file mode 100644 index 0000000000..bc049d52c0 --- /dev/null +++ b/packages/threejs-doctor/README.md @@ -0,0 +1,11 @@ +# Three.js Doctor + +Focused React Doctor diagnostics for Three.js and React Three Fiber. + +```bash +npx threejs-doctor@latest +``` + +Three.js Doctor runs every rule tagged `three` or `r3f`, including animation-loop allocations, GPU resource cleanup, shaders, uniforms, render targets, frame deltas, pointer events, React Three Fiber lifecycle, and WebGPU migration checks. It works with vanilla Three.js projects as well as React Three Fiber, uses React Doctor's engine, respects `doctor.config.*` and inline disables, and supports the same project, diff-scope, staged, verbose, blocking, and JSON flags. + +Dead-code, supply-chain, custom-plugin, external lint-config, and health-score passes are intentionally excluded from this focused audit. diff --git a/packages/threejs-doctor/bin/threejs-doctor.js b/packages/threejs-doctor/bin/threejs-doctor.js new file mode 100755 index 0000000000..4380b1f0fa --- /dev/null +++ b/packages/threejs-doctor/bin/threejs-doctor.js @@ -0,0 +1,5 @@ +#!/usr/bin/env node + +process.env.REACT_DOCTOR_PRODUCT = "threejs-doctor"; + +await import("react-doctor"); diff --git a/packages/threejs-doctor/package.json b/packages/threejs-doctor/package.json new file mode 100644 index 0000000000..74a5993835 --- /dev/null +++ b/packages/threejs-doctor/package.json @@ -0,0 +1,40 @@ +{ + "name": "threejs-doctor", + "version": "0.9.3", + "description": "Focused React Doctor diagnostics for Three.js and React Three Fiber", + "keywords": [ + "3d", + "r3f", + "react-three-fiber", + "three", + "threejs", + "webgl" + ], + "homepage": "https://github.com/millionco/react-doctor#readme", + "bugs": { + "url": "https://github.com/millionco/react-doctor/issues" + }, + "license": "SEE LICENSE IN LICENSE", + "author": "Million Software, Inc", + "repository": { + "type": "git", + "url": "https://github.com/millionco/react-doctor.git", + "directory": "packages/threejs-doctor" + }, + "bin": { + "threejs-doctor": "./bin/threejs-doctor.js" + }, + "files": [ + "bin/**", + "LICENSE", + "README.md" + ], + "type": "module", + "sideEffects": false, + "dependencies": { + "react-doctor": "workspace:*" + }, + "engines": { + "node": "^20.19.0 || >=22.13.0" + } +} diff --git a/packages/tui-doctor/LICENSE b/packages/tui-doctor/LICENSE new file mode 100644 index 0000000000..aef05f7022 --- /dev/null +++ b/packages/tui-doctor/LICENSE @@ -0,0 +1,34 @@ +Modified MIT License + +Copyright (c) 2026 Million Software, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +Our only modification is that the following uses require prior written +permission from the copyright holder. To request permission, contact +founders@million.dev. + +1. Using the Software, its source code, or any derivative works thereof, in + whole or in part, as training, fine-tuning, or evaluation data, or as input + to any automated pipeline for training or improving any machine learning + model or AI system. + +2. Selling the Software, or offering it to third parties as a paid, hosted, or + managed product or service (including any commercial API or SaaS offering) + whose value derives entirely or substantially from the Software. diff --git a/packages/tui-doctor/README.md b/packages/tui-doctor/README.md new file mode 100644 index 0000000000..241bdb4555 --- /dev/null +++ b/packages/tui-doctor/README.md @@ -0,0 +1,11 @@ +# TUI Doctor + +Focused React Doctor diagnostics for [Ink](https://github.com/vadimdemedes/ink) terminal UIs. + +```bash +npx tui-doctor@latest +``` + +TUI Doctor runs every rule tagged `ink`, including terminal lifecycle, raw mode, focus, layout, text-width, animation, paste, Suspense, and accessibility checks. It uses React Doctor's engine, respects `doctor.config.*` and inline disables, and supports the same project, diff-scope, staged, verbose, blocking, and JSON flags. + +Dead-code, supply-chain, custom-plugin, external lint-config, and health-score passes are intentionally excluded from this focused audit. diff --git a/packages/tui-doctor/bin/tui-doctor.js b/packages/tui-doctor/bin/tui-doctor.js new file mode 100755 index 0000000000..f36d9913ce --- /dev/null +++ b/packages/tui-doctor/bin/tui-doctor.js @@ -0,0 +1,5 @@ +#!/usr/bin/env node + +process.env.REACT_DOCTOR_PRODUCT = "tui-doctor"; + +await import("react-doctor"); diff --git a/packages/tui-doctor/package.json b/packages/tui-doctor/package.json new file mode 100644 index 0000000000..13ce396335 --- /dev/null +++ b/packages/tui-doctor/package.json @@ -0,0 +1,40 @@ +{ + "name": "tui-doctor", + "version": "0.9.3", + "description": "Focused React Doctor diagnostics for Ink terminal UIs", + "keywords": [ + "ink", + "linter", + "react", + "terminal", + "tui", + "typescript" + ], + "homepage": "https://github.com/millionco/react-doctor#readme", + "bugs": { + "url": "https://github.com/millionco/react-doctor/issues" + }, + "license": "SEE LICENSE IN LICENSE", + "author": "Million Software, Inc", + "repository": { + "type": "git", + "url": "https://github.com/millionco/react-doctor.git", + "directory": "packages/tui-doctor" + }, + "bin": { + "tui-doctor": "./bin/tui-doctor.js" + }, + "files": [ + "bin/**", + "LICENSE", + "README.md" + ], + "type": "module", + "sideEffects": false, + "dependencies": { + "react-doctor": "workspace:*" + }, + "engines": { + "node": "^20.19.0 || >=22.13.0" + } +} diff --git a/packages/ui-doctor/LICENSE b/packages/ui-doctor/LICENSE new file mode 100644 index 0000000000..aef05f7022 --- /dev/null +++ b/packages/ui-doctor/LICENSE @@ -0,0 +1,34 @@ +Modified MIT License + +Copyright (c) 2026 Million Software, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +Our only modification is that the following uses require prior written +permission from the copyright holder. To request permission, contact +founders@million.dev. + +1. Using the Software, its source code, or any derivative works thereof, in + whole or in part, as training, fine-tuning, or evaluation data, or as input + to any automated pipeline for training or improving any machine learning + model or AI system. + +2. Selling the Software, or offering it to third parties as a paid, hosted, or + managed product or service (including any commercial API or SaaS offering) + whose value derives entirely or substantially from the Software. diff --git a/packages/ui-doctor/README.md b/packages/ui-doctor/README.md new file mode 100644 index 0000000000..a788b6fed1 --- /dev/null +++ b/packages/ui-doctor/README.md @@ -0,0 +1,11 @@ +# UI Doctor + +Focused React Doctor diagnostics for UI design quality. + +```bash +npx ui-doctor@latest +``` + +UI Doctor runs every rule tagged `design`, including typography, spacing, hierarchy, interaction, responsive layout, motion, contrast, Tailwind, styled-components, and common generated-UI anti-patterns. It uses React Doctor's engine, respects `doctor.config.*` and inline disables, and supports the same project, diff-scope, staged, verbose, blocking, and JSON flags. + +Dead-code, supply-chain, custom-plugin, external lint-config, and health-score passes are intentionally excluded from this focused audit. diff --git a/packages/ui-doctor/bin/ui-doctor.js b/packages/ui-doctor/bin/ui-doctor.js new file mode 100755 index 0000000000..57c2a166f2 --- /dev/null +++ b/packages/ui-doctor/bin/ui-doctor.js @@ -0,0 +1,5 @@ +#!/usr/bin/env node + +process.env.REACT_DOCTOR_PRODUCT = "ui-doctor"; + +await import("react-doctor"); diff --git a/packages/ui-doctor/package.json b/packages/ui-doctor/package.json new file mode 100644 index 0000000000..7f4b973d7c --- /dev/null +++ b/packages/ui-doctor/package.json @@ -0,0 +1,40 @@ +{ + "name": "ui-doctor", + "version": "0.9.3", + "description": "Focused React Doctor diagnostics for UI design quality", + "keywords": [ + "design", + "linter", + "react", + "tailwind", + "typescript", + "ui" + ], + "homepage": "https://github.com/millionco/react-doctor#readme", + "bugs": { + "url": "https://github.com/millionco/react-doctor/issues" + }, + "license": "SEE LICENSE IN LICENSE", + "author": "Million Software, Inc", + "repository": { + "type": "git", + "url": "https://github.com/millionco/react-doctor.git", + "directory": "packages/ui-doctor" + }, + "bin": { + "ui-doctor": "./bin/ui-doctor.js" + }, + "files": [ + "bin/**", + "LICENSE", + "README.md" + ], + "type": "module", + "sideEffects": false, + "dependencies": { + "react-doctor": "workspace:*" + }, + "engines": { + "node": "^20.19.0 || >=22.13.0" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 81da9e24b3..67e8afa10a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -356,6 +356,24 @@ importers: specifier: ^9.4.0 version: 9.4.0 + packages/threejs-doctor: + dependencies: + react-doctor: + specifier: workspace:* + version: link:../react-doctor + + packages/tui-doctor: + dependencies: + react-doctor: + specifier: workspace:* + version: link:../react-doctor + + packages/ui-doctor: + dependencies: + react-doctor: + specifier: workspace:* + version: link:../react-doctor + packages/vscode-react-doctor: dependencies: vscode-languageclient: diff --git a/scripts/smoke-packed-cli-install.ts b/scripts/smoke-packed-cli-install.ts index 5d424709a0..de4a15951e 100644 --- a/scripts/smoke-packed-cli-install.ts +++ b/scripts/smoke-packed-cli-install.ts @@ -26,6 +26,15 @@ const FORBIDDEN_INSTALLED_PACKAGES: readonly string[] = [ "effect", "@effect/platform-node-shared", ]; +const PACKED_PACKAGE_NAMES: readonly string[] = [ + "react-doctor", + "tui-doctor", + "ui-doctor", + "threejs-doctor", + "oxlint-plugin-react-doctor", + "deslop-js", +]; +const SPECIALIZED_CLI_NAMES: readonly string[] = ["tui-doctor", "ui-doctor", "threejs-doctor"]; const COMMAND_OUTPUT_MAX_BYTES = 50 * 1024 * 1024; const isRecord = (value: unknown): value is StringRecord => @@ -123,12 +132,7 @@ const main = (): void => { runCommand({ command: "pnpm", args: [ - "--filter", - "react-doctor", - "--filter", - "oxlint-plugin-react-doctor", - "--filter", - "deslop-js", + ...PACKED_PACKAGE_NAMES.flatMap((packageName) => ["--filter", packageName]), "pack", "--pack-destination", packDirectory, @@ -138,9 +142,9 @@ const main = (): void => { }); const tarballs = fs.readdirSync(packDirectory).filter((fileName) => fileName.endsWith(".tgz")); - if (tarballs.length !== 3) { + if (tarballs.length !== PACKED_PACKAGE_NAMES.length) { console.error( - `Expected exactly three packed tarballs in ${packDirectory}, found ${tarballs.length}.`, + `Expected ${PACKED_PACKAGE_NAMES.length} packed tarballs in ${packDirectory}, found ${tarballs.length}.`, ); process.exit(1); } @@ -184,6 +188,27 @@ const main = (): void => { process.exit(1); } + for (const packageName of SPECIALIZED_CLI_NAMES) { + const specializedBinaryPath = path.join( + installDirectory, + "node_modules", + packageName, + "bin", + `${packageName}.js`, + ); + const specializedVersionResult = runCommand({ + command: process.execPath, + args: [specializedBinaryPath, "--version"], + cwd: installDirectory, + }); + if (specializedVersionResult.stdout.trim() !== version) { + console.error( + `${packageName} version ${specializedVersionResult.stdout.trim()} does not match react-doctor ${version}.`, + ); + process.exit(1); + } + } + const scanResult = runCommand({ command: process.execPath, args: [