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
163 changes: 130 additions & 33 deletions packages/react-doctor/src/utils/discover-project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,12 @@ const detectFramework = (dependencies: Record<string, string>): 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<string, unknown>,
packageName: string,
Expand Down Expand Up @@ -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<string, string>;
catalogs: Record<string, Record<string, string>>;
};

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)) {
Expand Down Expand Up @@ -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" };

Expand All @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down
61 changes: 61 additions & 0 deletions packages/react-doctor/tests/discover-project.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
Expand Down