Skip to content
Open
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
12 changes: 11 additions & 1 deletion packages/react-doctor/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ interface CliFlags {
offline: boolean;
ami: boolean;
project?: string;
packageJson?: string;
diff?: boolean | string;
failOn: string;
}
Expand Down Expand Up @@ -141,6 +142,7 @@ const program = new Command()
.option("--score", "output only the score")
.option("-y, --yes", "skip prompts, scan all workspace projects")
.option("--project <name>", "select workspace project (comma-separated for multiple)")
.option("--package-json <directory>", "custom directory containing package.json")
.option("--diff [base]", "scan only files changed vs base branch")
.option("--offline", "skip telemetry (anonymous, not stored, only used to calculate score)")
.option("--no-ami", "skip Ami-related prompts")
Expand All @@ -158,13 +160,21 @@ const program = new Command()
logger.break();
}

const scanOptions = resolveCliScanOptions(flags, userConfig, program);
const resolvedPackageJsonDirectory = flags.packageJson
? path.resolve(flags.packageJson)
: undefined;

const scanOptions: ScanOptions = {
...resolveCliScanOptions(flags, userConfig, program),
packageJsonDirectory: resolvedPackageJsonDirectory,
};
const shouldSkipPrompts = flags.yes || isAutomatedEnvironment() || !process.stdin.isTTY;
const shouldSkipAmiPrompts = shouldSkipPrompts || !flags.ami;
const projectDirectories = await selectProjects(
resolvedDirectory,
flags.project,
shouldSkipPrompts,
resolvedPackageJsonDirectory,
);

const isDiffCliOverride = program.getOptionValueSource("diff") === "cli";
Expand Down
8 changes: 6 additions & 2 deletions packages/react-doctor/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export { getDiffInfo, filterSourceFiles } from "./utils/get-diff-files.js";
export interface DiagnoseOptions {
lint?: boolean;
deadCode?: boolean;
packageJsonDirectory?: string;
includePaths?: string[];
}

Expand All @@ -28,12 +29,15 @@ export const diagnose = async (
directory: string,
options: DiagnoseOptions = {},
): Promise<DiagnoseResult> => {
const { includePaths = [] } = options;
const { packageJsonDirectory, includePaths = [] } = options;
const isDiffMode = includePaths.length > 0;

const startTime = performance.now();
const resolvedDirectory = path.resolve(directory);
const projectInfo = discoverProject(resolvedDirectory);
const resolvedPackageJsonDirectory = packageJsonDirectory
? path.resolve(packageJsonDirectory)
: undefined;
const projectInfo = discoverProject(resolvedDirectory, resolvedPackageJsonDirectory);
const userConfig = loadConfig(resolvedDirectory);

const effectiveLint = options.lint ?? userConfig?.lint ?? true;
Expand Down
2 changes: 1 addition & 1 deletion packages/react-doctor/src/scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -458,7 +458,7 @@ export const scan = async (
inputOptions: ScanOptions = {},
): Promise<ScanResult> => {
const startTime = performance.now();
const projectInfo = discoverProject(directory);
const projectInfo = discoverProject(directory, inputOptions.packageJsonDirectory);
const userConfig = loadConfig(directory);
const options = mergeScanOptions(inputOptions, userConfig);
const { includePaths } = options;
Expand Down
1 change: 1 addition & 0 deletions packages/react-doctor/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ export interface ScanOptions {
scoreOnly?: boolean;
offline?: boolean;
includePaths?: string[];
packageJsonDirectory?: string;
}

export interface DiffInfo {
Expand Down
39 changes: 26 additions & 13 deletions packages/react-doctor/src/utils/discover-project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,8 +260,13 @@ const hasReactDependency = (packageJson: PackageJson): boolean => {
);
};

export const discoverReactSubprojects = (rootDirectory: string): WorkspacePackage[] => {
if (!fs.existsSync(rootDirectory) || !fs.statSync(rootDirectory).isDirectory()) return [];
export const discoverReactSubprojects = (
rootDirectory: string,
packageJsonDirectory?: string,
): WorkspacePackage[] => {
const effectiveDirectory = packageJsonDirectory ?? rootDirectory;
if (!fs.existsSync(effectiveDirectory) || !fs.statSync(effectiveDirectory).isDirectory())
return [];

const packages: WorkspacePackage[] = [];

Expand All @@ -274,14 +279,14 @@ export const discoverReactSubprojects = (rootDirectory: string): WorkspacePackag
}
}

const entries = fs.readdirSync(rootDirectory, { withFileTypes: true });
const entries = fs.readdirSync(effectiveDirectory, { withFileTypes: true });

for (const entry of entries) {
if (!entry.isDirectory() || entry.name.startsWith(".") || entry.name === "node_modules") {
continue;
}

const subdirectory = path.join(rootDirectory, entry.name);
const subdirectory = path.join(effectiveDirectory, entry.name);
const packageJsonPath = path.join(subdirectory, "package.json");
if (!isFile(packageJsonPath)) continue;

Expand All @@ -295,12 +300,16 @@ export const discoverReactSubprojects = (rootDirectory: string): WorkspacePackag
return packages;
};

export const listWorkspacePackages = (rootDirectory: string): WorkspacePackage[] => {
const packageJsonPath = path.join(rootDirectory, "package.json");
export const listWorkspacePackages = (
rootDirectory: string,
packageJsonDirectory?: string,
): WorkspacePackage[] => {
const effectiveDirectory = packageJsonDirectory ?? rootDirectory;
const packageJsonPath = path.join(effectiveDirectory, "package.json");
if (!isFile(packageJsonPath)) return [];

const packageJson = readPackageJson(packageJsonPath);
const patterns = getWorkspacePatterns(rootDirectory, packageJson);
const patterns = getWorkspacePatterns(effectiveDirectory, packageJson);
if (patterns.length === 0) return [];

const packages: WorkspacePackage[] = [];
Expand Down Expand Up @@ -359,17 +368,21 @@ const detectReactCompiler = (directory: string, packageJson: PackageJson): boole
return false;
};

export const discoverProject = (directory: string): ProjectInfo => {
const packageJsonPath = path.join(directory, "package.json");
export const discoverProject = (
directory: string,
packageJsonDirectory?: string,
): ProjectInfo => {
const packageJsonDir = packageJsonDirectory ?? directory;
const packageJsonPath = path.join(packageJsonDir, "package.json");
if (!isFile(packageJsonPath)) {
throw new Error(`No package.json found in ${directory}`);
throw new Error(`No package.json found in ${packageJsonDir}`);
}

const packageJson = readPackageJson(packageJsonPath);
let { reactVersion, framework } = extractDependencyInfo(packageJson);

if (!reactVersion || framework === "unknown") {
const workspaceInfo = findReactInWorkspaces(directory, packageJson);
const workspaceInfo = findReactInWorkspaces(packageJsonDir, packageJson);
if (!reactVersion && workspaceInfo.reactVersion) {
reactVersion = workspaceInfo.reactVersion;
}
Expand All @@ -378,8 +391,8 @@ export const discoverProject = (directory: string): ProjectInfo => {
}
}

if ((!reactVersion || framework === "unknown") && !isMonorepoRoot(directory)) {
const monorepoInfo = findDependencyInfoFromMonorepoRoot(directory);
if ((!reactVersion || framework === "unknown") && !isMonorepoRoot(packageJsonDir)) {
const monorepoInfo = findDependencyInfoFromMonorepoRoot(packageJsonDir);
if (!reactVersion) {
reactVersion = monorepoInfo.reactVersion;
}
Expand Down
5 changes: 3 additions & 2 deletions packages/react-doctor/src/utils/select-projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,11 @@ export const selectProjects = async (
rootDirectory: string,
projectFlag: string | undefined,
skipPrompts: boolean,
packageJsonDirectory?: string,
): Promise<string[]> => {
let packages = listWorkspacePackages(rootDirectory);
let packages = listWorkspacePackages(rootDirectory, packageJsonDirectory);
if (packages.length === 0) {
packages = discoverReactSubprojects(rootDirectory);
packages = discoverReactSubprojects(rootDirectory, packageJsonDirectory);
}

if (packages.length === 0) return [rootDirectory];
Expand Down
77 changes: 71 additions & 6 deletions packages/react-doctor/tests/discover-project.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ import {
const FIXTURES_DIRECTORY = path.resolve(import.meta.dirname, "fixtures");
const VALID_FRAMEWORKS = ["nextjs", "vite", "cra", "remix", "gatsby", "unknown"];

const tempDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "react-doctor-discover-test-"));

afterAll(() => {
fs.rmSync(tempDirectory, { recursive: true, force: true });
});

describe("discoverProject", () => {
it("detects React version from package.json", () => {
const projectInfo = discoverProject(path.join(FIXTURES_DIRECTORY, "basic-react"));
Expand Down Expand Up @@ -44,9 +50,57 @@ describe("discoverProject", () => {

expect(() => discoverProject(projectDirectory)).toThrow("No package.json found");
});

describe("with packageJsonDirectory", () => {
const sourceDirectory = path.join(FIXTURES_DIRECTORY, "split-project", "source");
const metadataDirectory = path.join(FIXTURES_DIRECTORY, "split-project", "metadata");

it("reads package.json from the custom directory", () => {
const projectInfo = discoverProject(sourceDirectory, metadataDirectory);
expect(projectInfo.reactVersion).toBe("^19.0.0");
expect(projectInfo.projectName).toBe("test-split-project");
});

it("detects tsconfig from the scan directory, not the package.json directory", () => {
const projectInfo = discoverProject(sourceDirectory, metadataDirectory);
expect(projectInfo.hasTypeScript).toBe(true);
});

it("uses the scan directory as rootDirectory", () => {
const projectInfo = discoverProject(sourceDirectory, metadataDirectory);
expect(projectInfo.rootDirectory).toBe(sourceDirectory);
});

it("throws when the custom directory has no package.json", () => {
expect(() => discoverProject(sourceDirectory, "/nonexistent/path")).toThrow(
"No package.json found",
);
});

it("falls back to scan directory when packageJsonDirectory is undefined", () => {
const projectInfo = discoverProject(path.join(FIXTURES_DIRECTORY, "basic-react"), undefined);
expect(projectInfo.reactVersion).toBe("^19.0.0");
});
});
});

describe("listWorkspacePackages", () => {
it("returns empty when packageJsonDirectory has no package.json", () => {
const packages = listWorkspacePackages("/some/root", "/nonexistent/path");
expect(packages).toEqual([]);
});

it("returns empty when packageJsonDirectory has no workspaces", () => {
const metadataDirectory = path.join(FIXTURES_DIRECTORY, "split-project", "metadata");
const packages = listWorkspacePackages("/some/root", metadataDirectory);
expect(packages).toEqual([]);
});

it("falls back to rootDirectory when packageJsonDirectory is undefined", () => {
const packages = listWorkspacePackages(path.join(FIXTURES_DIRECTORY, "basic-react"));
expect(packages).toEqual([]);
});

it("resolves nested workspace patterns like apps/*/ClientApp", () => {
const packages = listWorkspacePackages(path.join(FIXTURES_DIRECTORY, "nested-workspaces"));
const packageNames = packages.map((workspacePackage) => workspacePackage.name);
Expand All @@ -57,12 +111,6 @@ describe("listWorkspacePackages", () => {
});
});

const tempDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "react-doctor-discover-test-"));

afterAll(() => {
fs.rmSync(tempDirectory, { recursive: true, force: true });
});

describe("discoverReactSubprojects", () => {
it("skips subdirectories where package.json is a directory (EISDIR)", () => {
const rootDirectory = path.join(tempDirectory, "eisdir-package-json");
Expand Down Expand Up @@ -134,6 +182,23 @@ describe("discoverReactSubprojects", () => {
const packages = discoverReactSubprojects(rootDirectory);
expect(packages).toHaveLength(1);
});

it("returns empty when packageJsonDirectory does not exist", () => {
const packages = discoverReactSubprojects("/some/root", "/nonexistent/path");
expect(packages).toEqual([]);
});

it("scans the packageJsonDirectory for subprojects instead of rootDirectory", () => {
const packages = discoverReactSubprojects("/nonexistent/root", FIXTURES_DIRECTORY);
const names = packages.map((p) => p.name);
expect(names).toContain("test-basic-react");
});

it("falls back to rootDirectory when packageJsonDirectory is undefined", () => {
const packages = discoverReactSubprojects(FIXTURES_DIRECTORY);
const names = packages.map((p) => p.name);
expect(names).toContain("test-basic-react");
});
});

describe("formatFrameworkName", () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"name": "test-split-project",
"private": true,
"dependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const App = () => <div>Hello</div>;
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"compilerOptions": {
"jsx": "react-jsx",
"strict": true,
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler"
}
}
28 changes: 28 additions & 0 deletions packages/react-doctor/tests/scan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,34 @@ describe("scan", () => {
}
});

it("reads package.json from packageJsonDirectory when provided", async () => {
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
try {
await scan(path.join(FIXTURES_DIRECTORY, "split-project", "source"), {
lint: false,
deadCode: false,
packageJsonDirectory: path.join(FIXTURES_DIRECTORY, "split-project", "metadata"),
});
} finally {
consoleSpy.mockRestore();
}
});

it("throws when packageJsonDirectory has no React dependency", async () => {
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
try {
await expect(
scan(path.join(FIXTURES_DIRECTORY, "basic-react"), {
lint: false,
deadCode: false,
packageJsonDirectory: noReactTempDirectory,
}),
).rejects.toThrow("No React dependency found");
} finally {
consoleSpy.mockRestore();
}
});

it("runs lint and dead code in parallel when both enabled", async () => {
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
try {
Expand Down