From c20a76a6b7ba4a28f40ff4502b7a119d1d245604 Mon Sep 17 00:00:00 2001 From: Daniel Villalobos Date: Wed, 18 Feb 2026 12:35:58 +0100 Subject: [PATCH 1/2] Added new flag to support package.json to be in other directory --- packages/react-doctor/src/cli.ts | 8 ++++ packages/react-doctor/src/index.ts | 8 +++- packages/react-doctor/src/scan.ts | 2 +- packages/react-doctor/src/types.ts | 1 + .../src/utils/discover-project.ts | 39 ++++++++++++------- .../react-doctor/src/utils/select-projects.ts | 5 ++- 6 files changed, 45 insertions(+), 18 deletions(-) diff --git a/packages/react-doctor/src/cli.ts b/packages/react-doctor/src/cli.ts index b151c6a..4a7aa16 100644 --- a/packages/react-doctor/src/cli.ts +++ b/packages/react-doctor/src/cli.ts @@ -26,6 +26,7 @@ interface CliFlags { prompt: boolean; yes: boolean; project?: string; + packageJson?: string; } process.on("SIGINT", () => process.exit(0)); @@ -42,6 +43,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("--fix", "open Ami to auto-fix all issues") .option("--prompt", "copy latest scan output to clipboard") .action(async (directory: string, flags: CliFlags) => { @@ -60,11 +62,16 @@ const program = new Command() logger.break(); } + const resolvedPackageJsonDirectory = flags.packageJson + ? path.resolve(flags.packageJson) + : undefined; + const scanOptions: ScanOptions = { lint: flags.lint, deadCode: flags.deadCode, verbose: flags.prompt || Boolean(flags.verbose), scoreOnly: isScoreOnly, + packageJsonDirectory: resolvedPackageJsonDirectory, }; const isAutomatedEnvironment = [ @@ -81,6 +88,7 @@ const program = new Command() resolvedDirectory, flags.project, shouldSkipPrompts, + resolvedPackageJsonDirectory, ); for (const projectDirectory of projectDirectories) { diff --git a/packages/react-doctor/src/index.ts b/packages/react-doctor/src/index.ts index d631314..487782d 100644 --- a/packages/react-doctor/src/index.ts +++ b/packages/react-doctor/src/index.ts @@ -12,6 +12,7 @@ export type { Diagnostic, ProjectInfo, ScoreResult }; export interface DiagnoseOptions { lint?: boolean; deadCode?: boolean; + packageJsonDirectory?: string; } export interface DiagnoseResult { @@ -25,11 +26,14 @@ export const diagnose = async ( directory: string, options: DiagnoseOptions = {}, ): Promise => { - const { lint = true, deadCode = true } = options; + const { lint = true, deadCode = true, packageJsonDirectory } = options; 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); if (!projectInfo.reactVersion) { throw new Error("No React dependency found in package.json"); diff --git a/packages/react-doctor/src/scan.ts b/packages/react-doctor/src/scan.ts index f40ce73..e5a7f48 100644 --- a/packages/react-doctor/src/scan.ts +++ b/packages/react-doctor/src/scan.ts @@ -347,7 +347,7 @@ const printSummary = ( export const scan = async (directory: string, options: ScanOptions): Promise => { const startTime = performance.now(); - const projectInfo = discoverProject(directory); + const projectInfo = discoverProject(directory, options.packageJsonDirectory); if (!projectInfo.reactVersion) { throw new Error("No React dependency found in package.json"); diff --git a/packages/react-doctor/src/types.ts b/packages/react-doctor/src/types.ts index 7e325fa..c4b6f5a 100644 --- a/packages/react-doctor/src/types.ts +++ b/packages/react-doctor/src/types.ts @@ -87,6 +87,7 @@ export interface ScanOptions { deadCode: boolean; verbose: boolean; scoreOnly: boolean; + packageJsonDirectory?: string; } export interface ClipboardCommand { diff --git a/packages/react-doctor/src/utils/discover-project.ts b/packages/react-doctor/src/utils/discover-project.ts index 6225c72..72bf3b3 100644 --- a/packages/react-doctor/src/utils/discover-project.ts +++ b/packages/react-doctor/src/utils/discover-project.ts @@ -232,10 +232,15 @@ 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 entries = fs.readdirSync(rootDirectory, { withFileTypes: true }); + const entries = fs.readdirSync(effectiveDirectory, { withFileTypes: true }); const packages: WorkspacePackage[] = []; for (const entry of entries) { @@ -243,7 +248,7 @@ export const discoverReactSubprojects = (rootDirectory: string): WorkspacePackag continue; } - const subdirectory = path.join(rootDirectory, entry.name); + const subdirectory = path.join(effectiveDirectory, entry.name); const packageJsonPath = path.join(subdirectory, "package.json"); if (!fs.existsSync(packageJsonPath)) continue; @@ -257,12 +262,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 (!fs.existsSync(packageJsonPath)) return []; const packageJson = readPackageJson(packageJsonPath); - const patterns = getWorkspacePatterns(rootDirectory, packageJson); + const patterns = getWorkspacePatterns(effectiveDirectory, packageJson); if (patterns.length === 0) return []; const packages: WorkspacePackage[] = []; @@ -320,17 +329,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 (!fs.existsSync(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; } @@ -339,8 +352,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]; From 2d00cde9331e9419a8a96ede77243cf20498f22c Mon Sep 17 00:00:00 2001 From: Daniel Villalobos Date: Wed, 18 Feb 2026 12:36:34 +0100 Subject: [PATCH 2/2] Test for new flag --- .../tests/discover-project.test.ts | 76 ++++++++++++++++++- .../split-project/metadata/package.json | 8 ++ .../fixtures/split-project/source/src/app.tsx | 1 + .../split-project/source/tsconfig.json | 9 +++ packages/react-doctor/tests/scan.test.ts | 28 +++++++ 5 files changed, 121 insertions(+), 1 deletion(-) create mode 100644 packages/react-doctor/tests/fixtures/split-project/metadata/package.json create mode 100644 packages/react-doctor/tests/fixtures/split-project/source/src/app.tsx create mode 100644 packages/react-doctor/tests/fixtures/split-project/source/tsconfig.json diff --git a/packages/react-doctor/tests/discover-project.test.ts b/packages/react-doctor/tests/discover-project.test.ts index 3a4ec9a..0ea8d5e 100644 --- a/packages/react-doctor/tests/discover-project.test.ts +++ b/packages/react-doctor/tests/discover-project.test.ts @@ -1,6 +1,11 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; -import { discoverProject, formatFrameworkName } from "../src/utils/discover-project.js"; +import { + discoverProject, + discoverReactSubprojects, + formatFrameworkName, + listWorkspacePackages, +} from "../src/utils/discover-project.js"; const FIXTURES_DIRECTORY = path.resolve(import.meta.dirname, "fixtures"); const VALID_FRAMEWORKS = ["nextjs", "vite", "cra", "remix", "gatsby", "unknown"]; @@ -24,6 +29,75 @@ describe("discoverProject", () => { it("throws when package.json is missing", () => { expect(() => discoverProject("/nonexistent/path")).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([]); + }); +}); + +describe("discoverReactSubprojects", () => { + 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 {