diff --git a/packages/react-doctor/src/cli.ts b/packages/react-doctor/src/cli.ts index 5b6cec0..58e7497 100644 --- a/packages/react-doctor/src/cli.ts +++ b/packages/react-doctor/src/cli.ts @@ -37,6 +37,7 @@ interface CliFlags { offline: boolean; ami: boolean; project?: string; + packageJson?: string; diff?: boolean | string; failOn: string; } @@ -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 ", "select workspace project (comma-separated for multiple)") + .option("--package-json ", "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") @@ -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"; diff --git a/packages/react-doctor/src/index.ts b/packages/react-doctor/src/index.ts index 14384d4..c4ffb89 100644 --- a/packages/react-doctor/src/index.ts +++ b/packages/react-doctor/src/index.ts @@ -14,6 +14,7 @@ export { getDiffInfo, filterSourceFiles } from "./utils/get-diff-files.js"; export interface DiagnoseOptions { lint?: boolean; deadCode?: boolean; + packageJsonDirectory?: string; includePaths?: string[]; } @@ -28,12 +29,15 @@ export const diagnose = async ( directory: string, options: DiagnoseOptions = {}, ): Promise => { - 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; diff --git a/packages/react-doctor/src/scan.ts b/packages/react-doctor/src/scan.ts index 7dd8749..0d3ad32 100644 --- a/packages/react-doctor/src/scan.ts +++ b/packages/react-doctor/src/scan.ts @@ -458,7 +458,7 @@ export const scan = async ( inputOptions: ScanOptions = {}, ): Promise => { 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; diff --git a/packages/react-doctor/src/types.ts b/packages/react-doctor/src/types.ts index b0c4827..d02bc8e 100644 --- a/packages/react-doctor/src/types.ts +++ b/packages/react-doctor/src/types.ts @@ -113,6 +113,7 @@ export interface ScanOptions { scoreOnly?: boolean; offline?: boolean; includePaths?: string[]; + packageJsonDirectory?: string; } export interface DiffInfo { diff --git a/packages/react-doctor/src/utils/discover-project.ts b/packages/react-doctor/src/utils/discover-project.ts index 30e7529..89554a6 100644 --- a/packages/react-doctor/src/utils/discover-project.ts +++ b/packages/react-doctor/src/utils/discover-project.ts @@ -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[] = []; @@ -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; @@ -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[] = []; @@ -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; } @@ -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; } diff --git a/packages/react-doctor/src/utils/select-projects.ts b/packages/react-doctor/src/utils/select-projects.ts index 9bbc303..021fb15 100644 --- a/packages/react-doctor/src/utils/select-projects.ts +++ b/packages/react-doctor/src/utils/select-projects.ts @@ -9,10 +9,11 @@ export const selectProjects = async ( rootDirectory: string, projectFlag: string | undefined, skipPrompts: boolean, + packageJsonDirectory?: string, ): Promise => { - 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]; diff --git a/packages/react-doctor/tests/discover-project.test.ts b/packages/react-doctor/tests/discover-project.test.ts index 44f9182..b273d6b 100644 --- a/packages/react-doctor/tests/discover-project.test.ts +++ b/packages/react-doctor/tests/discover-project.test.ts @@ -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")); @@ -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); @@ -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"); @@ -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", () => { diff --git a/packages/react-doctor/tests/fixtures/split-project/metadata/package.json b/packages/react-doctor/tests/fixtures/split-project/metadata/package.json new file mode 100644 index 0000000..01c3c69 --- /dev/null +++ b/packages/react-doctor/tests/fixtures/split-project/metadata/package.json @@ -0,0 +1,8 @@ +{ + "name": "test-split-project", + "private": true, + "dependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0" + } +} diff --git a/packages/react-doctor/tests/fixtures/split-project/source/src/app.tsx b/packages/react-doctor/tests/fixtures/split-project/source/src/app.tsx new file mode 100644 index 0000000..9f8c81e --- /dev/null +++ b/packages/react-doctor/tests/fixtures/split-project/source/src/app.tsx @@ -0,0 +1 @@ +export const App = () =>
Hello
; diff --git a/packages/react-doctor/tests/fixtures/split-project/source/tsconfig.json b/packages/react-doctor/tests/fixtures/split-project/source/tsconfig.json new file mode 100644 index 0000000..1e15af6 --- /dev/null +++ b/packages/react-doctor/tests/fixtures/split-project/source/tsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "jsx": "react-jsx", + "strict": true, + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler" + } +} diff --git a/packages/react-doctor/tests/scan.test.ts b/packages/react-doctor/tests/scan.test.ts index 7bf6875..8ec8673 100644 --- a/packages/react-doctor/tests/scan.test.ts +++ b/packages/react-doctor/tests/scan.test.ts @@ -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 {