Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
24 changes: 19 additions & 5 deletions packages/react-doctor/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ import { filterSourceFiles, getDiffInfo } from "./utils/get-diff-files.js";
import { getStagedSourceFiles, materializeStagedFiles } from "./utils/get-staged-files.js";
import { handleError } from "./utils/handle-error.js";
import { highlighter } from "./utils/highlighter.js";
import { loadConfig } from "./utils/load-config.js";
import { loadConfigWithSource } from "./utils/load-config.js";
import { resolveConfigRootDir } from "./utils/resolve-config-root-dir.js";
import { logger, setLoggerSilent } from "./utils/logger.js";
import { encodeAnnotationProperty, encodeAnnotationMessage } from "./utils/annotation-encoding.js";
import { findOwningProjectDirectory } from "./utils/find-owning-project.js";
Expand Down Expand Up @@ -413,12 +414,12 @@ const program = new Command()
const isScoreOnly = flags.score;
const isJsonMode = flags.json;
const isQuiet = isScoreOnly || isJsonMode;
const resolvedDirectory = path.resolve(directory);
const requestedDirectory = path.resolve(directory);
const jsonStartTime = performance.now();

isJsonModeActive = isJsonMode;
isCompactJsonOutput = Boolean(flags.jsonCompact);
resolvedDirectoryForCancel = resolvedDirectory;
resolvedDirectoryForCancel = requestedDirectory;
cancelStartTime = jsonStartTime;

if (isJsonMode) {
Expand All @@ -428,7 +429,20 @@ const program = new Command()
try {
validateModeFlags(flags);

const userConfig = loadConfig(resolvedDirectory);
const loadedConfig = loadConfigWithSource(requestedDirectory);
const userConfig = loadedConfig?.config ?? null;
const redirectedDirectory = resolveConfigRootDir(
loadedConfig?.config ?? null,
loadedConfig?.sourceDirectory ?? null,
);
const resolvedDirectory = redirectedDirectory ?? requestedDirectory;
resolvedDirectoryForCancel = resolvedDirectory;
if (redirectedDirectory && !isQuiet) {
logger.dim(
`Redirected to ${highlighter.info(toRelativePath(resolvedDirectory, requestedDirectory))} via react-doctor config "rootDir".`,
);
logger.break();
}

const explainArgument = flags.explain ?? flags.why;
if (explainArgument !== undefined) {
Expand Down Expand Up @@ -648,7 +662,7 @@ const program = new Command()
writeJsonReport(
buildJsonReportError({
version: VERSION,
directory: resolvedDirectory,
Comment thread
cursor[bot] marked this conversation as resolved.
directory: resolvedDirectoryForCancel ?? requestedDirectory,
error,
elapsedMilliseconds: performance.now() - jsonStartTime,
mode: currentReportMode,
Expand Down
61 changes: 61 additions & 0 deletions packages/react-doctor/src/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { buildNoReactDependencyError } from "./constants.js";

export class ReactDoctorError extends Error {
override readonly name: string = "ReactDoctorError";

constructor(message: string, options?: ErrorOptions) {
super(message, options);
Object.setPrototypeOf(this, new.target.prototype);
}
}

export class ProjectNotFoundError extends ReactDoctorError {
override readonly name = "ProjectNotFoundError";
readonly directory: string;

constructor(directory: string, options?: ErrorOptions) {
super(
`No React project found in ${directory}. Expected a package.json at the directory root or a nested package.json with a React dependency.`,
options,
);
this.directory = directory;
}
}

export class NoReactDependencyError extends ReactDoctorError {
override readonly name = "NoReactDependencyError";
readonly directory: string;

constructor(directory: string, options?: ErrorOptions) {
super(buildNoReactDependencyError(directory), options);
this.directory = directory;
}
}

export class PackageJsonNotFoundError extends ReactDoctorError {
override readonly name = "PackageJsonNotFoundError";
readonly directory: string;

constructor(directory: string, options?: ErrorOptions) {
super(`No package.json found in ${directory}`, options);
this.directory = directory;
}
}

export class AmbiguousProjectError extends ReactDoctorError {
override readonly name = "AmbiguousProjectError";
readonly directory: string;
readonly candidates: readonly string[];

constructor(directory: string, candidates: readonly string[], options?: ErrorOptions) {
super(
`Multiple React projects found under ${directory} (${candidates.length} candidates): ${candidates.join(", ")}. Re-run diagnose() with one of those subdirectories, or iterate them yourself.`,
options,
);
this.directory = directory;
this.candidates = candidates;
}
}

export const isReactDoctorError = (value: unknown): value is ReactDoctorError =>
value instanceof ReactDoctorError;
38 changes: 30 additions & 8 deletions packages/react-doctor/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import path from "node:path";
import { buildNoReactDependencyError } from "./constants.js";
import { NoReactDependencyError, ProjectNotFoundError } from "./errors.js";
import type {
Diagnostic,
DiagnoseOptions,
Expand All @@ -22,11 +22,12 @@ import { checkReducedMotion } from "./utils/check-reduced-motion.js";
import { clearIgnorePatternsCache } from "./utils/collect-ignore-patterns.js";
import { clearProjectCache, discoverProject } from "./utils/discover-project.js";
import { computeJsxIncludePaths } from "./utils/jsx-include-paths.js";
import { clearConfigCache, loadConfig } from "./utils/load-config.js";
import { clearConfigCache, loadConfigWithSource } from "./utils/load-config.js";
import { mergeAndFilterDiagnostics } from "./utils/merge-and-filter-diagnostics.js";
import { parseReactMajor } from "./utils/parse-react-major.js";
import { clearPackageJsonCache } from "./utils/read-package-json.js";
import { createNodeReadFileLinesSync } from "./utils/read-file-lines-node.js";
import { resolveConfigRootDir } from "./utils/resolve-config-root-dir.js";
import { resolveDiagnoseTarget } from "./utils/resolve-diagnose-target.js";
import { resolveLintIncludePaths } from "./utils/resolve-lint-include-paths.js";
import { runKnip } from "./utils/run-knip.js";
Expand All @@ -50,6 +51,14 @@ export type {
export { getDiffInfo, filterSourceFiles } from "./utils/get-diff-files.js";
export { summarizeDiagnostics } from "./utils/summarize-diagnostics.js";
export { buildJsonReport, buildJsonReportError };
export {
ReactDoctorError,
ProjectNotFoundError,
NoReactDependencyError,
PackageJsonNotFoundError,
AmbiguousProjectError,
isReactDoctorError,
} from "./errors.js";

// HACK: programmatic API consumers (watch-mode tools, test runners,
// agentic CLI flows) call diagnose() repeatedly on the same directory.
Expand Down Expand Up @@ -107,19 +116,32 @@ export const diagnose = async (
): Promise<DiagnoseResult> => {
const startTime = globalThis.performance.now();
const requestedDirectory = path.resolve(directory);
const resolvedDirectory = resolveDiagnoseTarget(requestedDirectory);

// Load config first against the requested directory so a `rootDir`
// redirect applies BEFORE we hunt for nested React subprojects. This
// is the documented escape hatch for monorepos that hold the only
// react-doctor config at the repo root but want scans to target a
// subproject like `apps/web`.
const initialLoadedConfig = loadConfigWithSource(requestedDirectory);
const redirectedDirectory = resolveConfigRootDir(
initialLoadedConfig?.config ?? null,
initialLoadedConfig?.sourceDirectory ?? null,
);
const directoryAfterRedirect = redirectedDirectory ?? requestedDirectory;

const resolvedDirectory = resolveDiagnoseTarget(directoryAfterRedirect);
if (!resolvedDirectory) {
throw new Error(
`No React project found in ${requestedDirectory}. Expected a package.json at the directory root or a nested package.json with a React dependency.`,
);
throw new ProjectNotFoundError(directoryAfterRedirect);
}
const userConfig = loadConfig(resolvedDirectory);

const userConfig =
initialLoadedConfig?.config ?? loadConfigWithSource(resolvedDirectory)?.config ?? null;
const includePaths = options.includePaths ?? [];
const isDiffMode = includePaths.length > 0;
const projectInfo = discoverProject(resolvedDirectory);

if (!projectInfo.reactVersion) {
throw new Error(buildNoReactDependencyError(resolvedDirectory));
throw new NoReactDependencyError(resolvedDirectory);
}

const lintIncludePaths =
Expand Down
30 changes: 24 additions & 6 deletions packages/react-doctor/src/scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import {
MAX_CATEGORY_GROUPS_SHOWN_NON_VERBOSE,
MAX_RULE_GROUPS_PER_CATEGORY_NON_VERBOSE,
MILLISECONDS_PER_SECOND,
buildNoReactDependencyError,
OFFLINE_MESSAGE,
OXLINT_NODE_REQUIREMENT,
OXLINT_RECOMMENDED_NODE_MAJOR,
Expand All @@ -19,6 +18,8 @@ import {
SCORE_OK_THRESHOLD,
SHARE_BASE_URL,
} from "./constants.js";
import { NoReactDependencyError } from "./errors.js";
import { resolveConfigRootDir } from "./utils/resolve-config-root-dir.js";
import type {
Diagnostic,
ProjectInfo,
Expand All @@ -38,7 +39,7 @@ import { groupBy } from "./utils/group-by.js";
import { highlighter } from "./utils/highlighter.js";
import { indentMultilineText } from "./utils/indent-multiline-text.js";
import { toRelativePath } from "./utils/to-relative-path.js";
import { loadConfig } from "./utils/load-config.js";
import { loadConfigWithSource } from "./utils/load-config.js";
import { isLoggerSilent, logger, setLoggerSilent } from "./utils/logger.js";
import { prompts } from "./utils/prompts.js";
import { wrapIndentedText } from "./utils/wrap-indented-text.js";
Expand Down Expand Up @@ -645,8 +646,25 @@ export const scan = async (
inputOptions: ScanOptions = {},
): Promise<ScanResult> => {
const startTime = performance.now();
const userConfig =
inputOptions.configOverride !== undefined ? inputOptions.configOverride : loadConfig(directory);

// configOverride means the caller (typically the CLI) already resolved
// both the config and any rootDir redirect; trust their directory
// verbatim. Otherwise honor `rootDir` from the loaded config so direct
// programmatic `scan()` callers get the same redirect as `diagnose()`.
let scanDirectory = directory;
let userConfig: ReactDoctorConfig | null;
if (inputOptions.configOverride !== undefined) {
userConfig = inputOptions.configOverride;
} else {
const loadedConfig = loadConfigWithSource(directory);
const redirectedDirectory = resolveConfigRootDir(
loadedConfig?.config ?? null,
loadedConfig?.sourceDirectory ?? null,
);
if (redirectedDirectory) scanDirectory = redirectedDirectory;
userConfig = loadedConfig?.config ?? null;
}

const options = mergeScanOptions(inputOptions, userConfig);

const wasLoggerSilent = isLoggerSilent();
Expand All @@ -657,7 +675,7 @@ export const scan = async (
}

try {
return await runScan(directory, options, userConfig, startTime);
return await runScan(scanDirectory, options, userConfig, startTime);
} finally {
if (options.silent) {
setLoggerSilent(wasLoggerSilent);
Expand All @@ -677,7 +695,7 @@ const runScan = async (
const isDiffMode = includePaths.length > 0;

if (!projectInfo.reactVersion) {
throw new Error(buildNoReactDependencyError(directory));
throw new NoReactDependencyError(directory);
}

const jsxIncludePaths = computeJsxIncludePaths(includePaths);
Expand Down
18 changes: 18 additions & 0 deletions packages/react-doctor/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,24 @@ export interface ReactDoctorConfig {
failOn?: FailOnLevel;
customRulesOnly?: boolean;
share?: boolean;
/**
* Redirect react-doctor at a different project directory than the one
* it was invoked against. Resolved relative to the location of the
* config file that declared this field (NOT relative to the CWD), so
* the redirect is stable no matter where the CLI / `diagnose()` is
* run from. Absolute paths are used as-is.
*
* Typical use: a monorepo root holds the only `react-doctor.config.json`
* (so editor tooling and child commands all find it), but the React
* app lives in `apps/web`. Setting `"rootDir": "apps/web"` makes
* every invocation that loads this config scan that subproject
* without anyone needing to `cd` first or pass an explicit path.
*
* Ignored if the resolved path does not exist or is not a directory
* (a warning is emitted and react-doctor falls back to the originally
* requested directory).
*/
rootDir?: string;
textComponents?: string[];
/**
* Names of components that safely route string-only children through a
Expand Down
3 changes: 2 additions & 1 deletion packages/react-doctor/src/utils/discover-project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
IGNORED_DIRECTORIES,
SOURCE_FILE_PATTERN,
} from "../constants.js";
import { PackageJsonNotFoundError } from "../errors.js";
import type {
DependencyInfo,
Framework,
Expand Down Expand Up @@ -599,7 +600,7 @@ export const discoverProject = (directory: string): ProjectInfo => {

const packageJsonPath = path.join(directory, "package.json");
if (!isFile(packageJsonPath)) {
throw new Error(`No package.json found in ${directory}`);
throw new PackageJsonNotFoundError(directory);
}

const packageJson = readPackageJson(packageJsonPath);
Expand Down
30 changes: 25 additions & 5 deletions packages/react-doctor/src/utils/load-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,29 @@ import { validateConfigTypes } from "./validate-config-types.js";
const CONFIG_FILENAME = "react-doctor.config.json";
const PACKAGE_JSON_CONFIG_KEY = "reactDoctor";

const loadConfigFromDirectory = (directory: string): ReactDoctorConfig | null => {
export interface LoadedReactDoctorConfig {
config: ReactDoctorConfig;
/**
* Absolute path of the directory that contained the resolved config
* file (or `package.json` with the `reactDoctor` key). Path-valued
* config fields like `rootDir` are resolved relative to this
* directory, never the CWD.
*/
sourceDirectory: string;
}

const loadConfigFromDirectory = (directory: string): LoadedReactDoctorConfig | null => {
const configFilePath = path.join(directory, CONFIG_FILENAME);

if (isFile(configFilePath)) {
try {
const fileContent = fs.readFileSync(configFilePath, "utf-8");
const parsed: unknown = JSON.parse(fileContent);
if (isPlainObject(parsed)) {
return validateConfigTypes(parsed as ReactDoctorConfig);
return {
config: validateConfigTypes(parsed as ReactDoctorConfig),
sourceDirectory: directory,
};
}
logger.warn(`${CONFIG_FILENAME} must be a JSON object, ignoring.`);
} catch (error) {
Expand All @@ -36,7 +50,10 @@ const loadConfigFromDirectory = (directory: string): ReactDoctorConfig | null =>
if (isPlainObject(packageJson)) {
const embeddedConfig = packageJson[PACKAGE_JSON_CONFIG_KEY];
if (isPlainObject(embeddedConfig)) {
return validateConfigTypes(embeddedConfig as ReactDoctorConfig);
return {
config: validateConfigTypes(embeddedConfig as ReactDoctorConfig),
sourceDirectory: directory,
};
}
}
} catch {
Expand All @@ -53,7 +70,7 @@ const loadConfigFromDirectory = (directory: string): ReactDoctorConfig | null =>
const isProjectBoundary = (directory: string): boolean =>
fs.existsSync(path.join(directory, ".git")) || isMonorepoRoot(directory);

const cachedConfigs = new Map<string, ReactDoctorConfig | null>();
const cachedConfigs = new Map<string, LoadedReactDoctorConfig | null>();

// HACK: expose a way to clear the module-level config cache so programmatic
// API consumers (watch-mode tools, test runners, agentic CLI flows) can
Expand All @@ -65,7 +82,7 @@ export const clearConfigCache = (): void => {
cachedConfigs.clear();
};

export const loadConfig = (rootDirectory: string): ReactDoctorConfig | null => {
export const loadConfigWithSource = (rootDirectory: string): LoadedReactDoctorConfig | null => {
const cached = cachedConfigs.get(rootDirectory);
if (cached !== undefined) return cached;

Expand Down Expand Up @@ -97,3 +114,6 @@ export const loadConfig = (rootDirectory: string): ReactDoctorConfig | null => {
cachedConfigs.set(rootDirectory, null);
return null;
};

export const loadConfig = (rootDirectory: string): ReactDoctorConfig | null =>
loadConfigWithSource(rootDirectory)?.config ?? null;
Loading
Loading