diff --git a/packages/react-doctor/src/utils/discover-project.ts b/packages/react-doctor/src/utils/discover-project.ts index c9f3c26..18fd721 100644 --- a/packages/react-doctor/src/utils/discover-project.ts +++ b/packages/react-doctor/src/utils/discover-project.ts @@ -133,6 +133,12 @@ const detectFramework = (dependencies: Record): Framework => { const isCatalogReference = (version: string): boolean => version.startsWith("catalog:"); +const getCatalogName = (version: string): string | null => { + if (!isCatalogReference(version)) return null; + const catalogName = version.slice("catalog:".length).trim(); + return catalogName.length > 0 ? catalogName : null; +}; + const resolveVersionFromCatalog = ( catalog: Record, packageName: string, @@ -162,42 +168,132 @@ const resolveCatalogVersion = (packageJson: PackageJson, packageName: string): s return null; }; -const extractDependencyInfo = (packageJson: PackageJson): DependencyInfo => { - const allDependencies = collectAllDependencies(packageJson); - const rawVersion = allDependencies.react ?? null; - const reactVersion = rawVersion && !isCatalogReference(rawVersion) ? rawVersion : null; - return { - reactVersion, - framework: detectFramework(allDependencies), - }; +type PnpmWorkspaceConfig = { + packages: string[]; + catalog: Record; + catalogs: Record>; +}; + +const stripYamlComment = (line: string): string => { + const commentIndex = line.indexOf("#"); + return commentIndex >= 0 ? line.slice(0, commentIndex) : line; }; -const parsePnpmWorkspacePatterns = (rootDirectory: string): string[] => { +const trimYamlValue = (value: string): string => value.trim().replace(/["']/g, ""); + +const parsePnpmWorkspaceConfig = (rootDirectory: string): PnpmWorkspaceConfig => { const workspacePath = path.join(rootDirectory, "pnpm-workspace.yaml"); - if (!isFile(workspacePath)) return []; + if (!isFile(workspacePath)) { + return { packages: [], catalog: {}, catalogs: {} }; + } const content = fs.readFileSync(workspacePath, "utf-8"); - const patterns: string[] = []; - let isInsidePackagesBlock = false; + const config: PnpmWorkspaceConfig = { packages: [], catalog: {}, catalogs: {} }; + + let section: "packages" | "catalog" | "catalogs" | null = null; + let currentNamedCatalog: string | null = null; + + for (const rawLine of content.split("\n")) { + const line = stripYamlComment(rawLine); + if (line.trim().length === 0) continue; - for (const line of content.split("\n")) { + const indent = line.match(/^\s*/)![0].length; const trimmed = line.trim(); - if (trimmed === "packages:") { - isInsidePackagesBlock = true; + + if (indent === 0) { + currentNamedCatalog = null; + if (trimmed === "packages:") { + section = "packages"; + continue; + } + if (trimmed === "catalog:") { + section = "catalog"; + continue; + } + if (trimmed === "catalogs:") { + section = "catalogs"; + continue; + } + section = null; + continue; + } + + if (section === "packages") { + if (trimmed.startsWith("-")) { + config.packages.push(trimYamlValue(trimmed.replace(/^[-]\s*/, ""))); + } + continue; + } + + if (section === "catalog" && indent >= 2) { + const separatorIndex = trimmed.indexOf(":"); + if (separatorIndex > 0) { + const packageKey = trimmed.slice(0, separatorIndex).trim(); + const version = trimYamlValue(trimmed.slice(separatorIndex + 1)); + if (version.length > 0) config.catalog[packageKey] = version; + } continue; } - if (isInsidePackagesBlock && trimmed.startsWith("-")) { - patterns.push(trimmed.replace(/^-\s*/, "").replace(/["']/g, "")); - } else if (isInsidePackagesBlock && trimmed.length > 0 && !trimmed.startsWith("#")) { - isInsidePackagesBlock = false; + + if (section === "catalogs") { + if (indent === 2 && trimmed.endsWith(":")) { + currentNamedCatalog = trimmed.slice(0, -1).trim(); + config.catalogs[currentNamedCatalog] = {}; + continue; + } + + if (indent >= 4 && currentNamedCatalog) { + const separatorIndex = trimmed.indexOf(":"); + if (separatorIndex > 0) { + const packageKey = trimmed.slice(0, separatorIndex).trim(); + const version = trimYamlValue(trimmed.slice(separatorIndex + 1)); + if (version.length > 0) config.catalogs[currentNamedCatalog][packageKey] = version; + } + } } } - return patterns; + return config; +}; + +const resolveReactVersion = ( + packageJson: PackageJson, + workspaceConfig?: PnpmWorkspaceConfig, +): string | null => { + const allDependencies = collectAllDependencies(packageJson); + const rawVersion = allDependencies.react ?? null; + + if (!rawVersion) { + return resolveCatalogVersion(packageJson, "react"); + } + + if (!isCatalogReference(rawVersion)) { + return rawVersion; + } + + const catalogName = getCatalogName(rawVersion); + if (!workspaceConfig) return null; + + if (!catalogName) { + return resolveVersionFromCatalog(workspaceConfig.catalog, "react"); + } + + return resolveVersionFromCatalog(workspaceConfig.catalogs[catalogName] ?? {}, "react"); +}; + +const extractDependencyInfo = ( + packageJson: PackageJson, + workspaceConfig?: PnpmWorkspaceConfig, +): DependencyInfo => { + const allDependencies = collectAllDependencies(packageJson); + return { + reactVersion: resolveReactVersion(packageJson, workspaceConfig), + framework: detectFramework(allDependencies), + }; }; const getWorkspacePatterns = (rootDirectory: string, packageJson: PackageJson): string[] => { - const pnpmPatterns = parsePnpmWorkspacePatterns(rootDirectory); + const pnpmPatterns = parsePnpmWorkspaceConfig(rootDirectory).packages; if (pnpmPatterns.length > 0) return pnpmPatterns; if (Array.isArray(packageJson.workspaces)) { @@ -249,17 +345,21 @@ const findDependencyInfoFromMonorepoRoot = (directory: string): DependencyInfo = if (!isFile(monorepoPackageJsonPath)) return { reactVersion: null, framework: "unknown" }; const rootPackageJson = readPackageJson(monorepoPackageJsonPath); - const rootInfo = extractDependencyInfo(rootPackageJson); - const catalogVersion = resolveCatalogVersion(rootPackageJson, "react"); - const workspaceInfo = findReactInWorkspaces(monorepoRoot, rootPackageJson); + const workspaceConfig = parsePnpmWorkspaceConfig(monorepoRoot); + const rootInfo = extractDependencyInfo(rootPackageJson, workspaceConfig); + const workspaceInfo = findReactInWorkspaces(monorepoRoot, rootPackageJson, workspaceConfig); return { - reactVersion: rootInfo.reactVersion ?? catalogVersion ?? workspaceInfo.reactVersion, + reactVersion: rootInfo.reactVersion ?? workspaceInfo.reactVersion, framework: rootInfo.framework !== "unknown" ? rootInfo.framework : workspaceInfo.framework, }; }; -const findReactInWorkspaces = (rootDirectory: string, packageJson: PackageJson): DependencyInfo => { +const findReactInWorkspaces = ( + rootDirectory: string, + packageJson: PackageJson, + workspaceConfig = parsePnpmWorkspaceConfig(rootDirectory), +): DependencyInfo => { const patterns = getWorkspacePatterns(rootDirectory, packageJson); const result: DependencyInfo = { reactVersion: null, framework: "unknown" }; @@ -268,7 +368,7 @@ const findReactInWorkspaces = (rootDirectory: string, packageJson: PackageJson): for (const workspaceDirectory of directories) { const workspacePackageJson = readPackageJson(path.join(workspaceDirectory, "package.json")); - const info = extractDependencyInfo(workspacePackageJson); + const info = extractDependencyInfo(workspacePackageJson, workspaceConfig); if (info.reactVersion && !result.reactVersion) { result.reactVersion = info.reactVersion; @@ -401,14 +501,11 @@ export const discoverProject = (directory: string): ProjectInfo => { } const packageJson = readPackageJson(packageJsonPath); - let { reactVersion, framework } = extractDependencyInfo(packageJson); - - if (!reactVersion) { - reactVersion = resolveCatalogVersion(packageJson, "react"); - } + const workspaceConfig = parsePnpmWorkspaceConfig(directory); + let { reactVersion, framework } = extractDependencyInfo(packageJson, workspaceConfig); if (!reactVersion || framework === "unknown") { - const workspaceInfo = findReactInWorkspaces(directory, packageJson); + const workspaceInfo = findReactInWorkspaces(directory, packageJson, workspaceConfig); if (!reactVersion && workspaceInfo.reactVersion) { reactVersion = workspaceInfo.reactVersion; } diff --git a/packages/react-doctor/tests/discover-project.test.ts b/packages/react-doctor/tests/discover-project.test.ts index 44f9182..f097565 100644 --- a/packages/react-doctor/tests/discover-project.test.ts +++ b/packages/react-doctor/tests/discover-project.test.ts @@ -33,6 +33,67 @@ describe("discoverProject", () => { expect(projectInfo.reactVersion).toBe("^18.0.0 || ^19.0.0"); }); + it("resolves React version from the default pnpm workspace catalog", () => { + const rootDirectory = path.join(tempDirectory, "pnpm-default-catalog"); + const appDirectory = path.join(rootDirectory, "packages", "app"); + fs.mkdirSync(appDirectory, { recursive: true }); + fs.writeFileSync( + path.join(rootDirectory, "package.json"), + JSON.stringify({ name: "workspace-root", private: true }), + ); + fs.writeFileSync( + path.join(rootDirectory, "pnpm-workspace.yaml"), + [ + "packages:", + ' - "packages/*"', + "catalog:", + ' react: "^19.1.0"', + ].join("\n"), + ); + fs.writeFileSync( + path.join(appDirectory, "package.json"), + JSON.stringify({ + name: "app", + dependencies: { react: "catalog:", vite: "^7.1.0" }, + }), + ); + + const projectInfo = discoverProject(appDirectory); + expect(projectInfo.reactVersion).toBe("^19.1.0"); + expect(projectInfo.framework).toBe("vite"); + }); + + it("resolves React version from a named pnpm workspace catalog", () => { + const rootDirectory = path.join(tempDirectory, "pnpm-named-catalog"); + const appDirectory = path.join(rootDirectory, "packages", "app"); + fs.mkdirSync(appDirectory, { recursive: true }); + fs.writeFileSync( + path.join(rootDirectory, "package.json"), + JSON.stringify({ name: "workspace-root", private: true }), + ); + fs.writeFileSync( + path.join(rootDirectory, "pnpm-workspace.yaml"), + [ + "packages:", + ' - "packages/*"', + "catalogs:", + " react19:", + ' react: "^19.2.0"', + ].join("\n"), + ); + fs.writeFileSync( + path.join(appDirectory, "package.json"), + JSON.stringify({ + name: "app", + dependencies: { react: "catalog:react19", vite: "^7.1.0" }, + }), + ); + + const projectInfo = discoverProject(appDirectory); + expect(projectInfo.reactVersion).toBe("^19.2.0"); + expect(projectInfo.framework).toBe("vite"); + }); + it("throws when package.json is missing", () => { expect(() => discoverProject("/nonexistent/path")).toThrow("No package.json found"); });