import { execFileSync } from "node:child_process"; import { z } from "zod"; export type VersionCheck = "compat-range" | "erp-kit-version" | "skills-version" | "manifest-parse"; export type VersionSeverity = "error" | "warning" | "info"; export interface VersionFinding { severity: VersionSeverity; check: VersionCheck; subject: string; message: string; } const DependencyMap = z.record(z.string(), z.string()); export const ManifestSchema = z.object({ dependencies: DependencyMap.optional(), devDependencies: DependencyMap.optional(), peerDependencies: DependencyMap.optional(), optionalDependencies: DependencyMap.optional(), }); export type Manifest = z.infer; export interface WorkspacePackage { relativePath: string; manifest: Manifest; } // Detects `:` specifiers (workspace:, npm:, git+…) that aren't a comparable semver. // `catalog:` is resolved to its underlying spec (see buildSpecifierResolver) before this runs. export function isNonSemverSpecifier(declared: string): boolean { return /^[a-z][a-z0-9+.-]*:/i.test(declared); } export function readDeclaredVersion(manifest: Manifest, pkgName: string): string | undefined { return ( manifest.dependencies?.[pkgName] ?? manifest.devDependencies?.[pkgName] ?? manifest.peerDependencies?.[pkgName] ?? manifest.optionalDependencies?.[pkgName] ); } const CATALOG_PREFIX = "catalog:"; // pnpm catalog specifiers, sourced from `pnpm config list --json`. Values are the raw // catalog specs (e.g. "0.43.0" or "^1.66.0"), not the resolved installed version, so the // exact-pin-vs-range checks stay meaningful for catalog: dependencies. export interface Catalogs { default: Record; // `catalog:` named: Record>; // `catalog:` } export const EMPTY_CATALOGS: Catalogs = { default: {}, named: {} }; const CatalogMap = z.record(z.string(), z.coerce.string()); const PnpmConfigSchema = z.object({ catalog: CatalogMap.optional(), catalogs: z.record(z.string(), CatalogMap).optional(), }); // Reads catalog config via pnpm so the source of truth (pnpm-workspace.yaml or the // package.json `pnpm.catalog` field) doesn't have to be parsed by hand. export type PnpmConfigReader = (cwd: string) => unknown; const defaultConfigReader: PnpmConfigReader = (cwd) => JSON.parse( execFileSync("pnpm", ["config", "list", "--json"], { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], maxBuffer: 16 * 1024 * 1024, }), ); export function readCatalogs( cwd: string, reader: PnpmConfigReader = defaultConfigReader, ): Catalogs { let raw: unknown; try { raw = reader(cwd); } catch { return EMPTY_CATALOGS; } const result = PnpmConfigSchema.safeParse(raw); if (!result.success) return EMPTY_CATALOGS; const named = { ...result.data.catalogs }; // pnpm reports the default catalog both as `catalog` and `catalogs.default`. return { default: result.data.catalog ?? named.default ?? {}, named }; } export interface ResolvedSpecifier { // Spec to run the semver checks against: a catalog: ref resolves to its catalog spec, // everything else passes through unchanged. spec: string; // How to render the specifier in findings messages (e.g. "catalog: → 0.43.0"). display: string; // A catalog: ref that couldn't be resolved (no catalog entry, or pnpm was unavailable). unresolvedCatalog: boolean; } export type SpecifierResolver = (declared: string, pkgName: string) => ResolvedSpecifier; // Passthrough resolver used when no catalog: dependency is present (or in unit tests): // non-catalog specs go through untouched; a catalog: ref reports as unresolved. export const passthroughResolver: SpecifierResolver = (declared) => declared.startsWith(CATALOG_PREFIX) ? { spec: declared, display: declared, unresolvedCatalog: true } : { spec: declared, display: declared, unresolvedCatalog: false }; function resolveWithCatalogs( declared: string, pkgName: string, catalogs: Catalogs, ): ResolvedSpecifier { if (!declared.startsWith(CATALOG_PREFIX)) { return { spec: declared, display: declared, unresolvedCatalog: false }; } const name = declared.slice(CATALOG_PREFIX.length); // "" => default catalog const map = name === "" || name === "default" ? catalogs.default : catalogs.named[name]; const spec = map?.[pkgName]; if (spec === undefined) { return { spec: declared, display: declared, unresolvedCatalog: true }; } return { spec, display: `${declared} → ${spec}`, unresolvedCatalog: false }; } // Builds a specifier resolver, only shelling out to pnpm when one of the checked packages // actually declares a catalog: specifier. Non-catalog repos never invoke pnpm and behave // exactly as before. export function buildSpecifierResolver( cwd: string, packages: WorkspacePackage[], pkgNames: string[], reader: PnpmConfigReader = defaultConfigReader, ): SpecifierResolver { const hasCatalogRef = packages.some((pkg) => pkgNames.some((name) => readDeclaredVersion(pkg.manifest, name)?.startsWith(CATALOG_PREFIX)), ); if (!hasCatalogRef) return passthroughResolver; const catalogs = readCatalogs(cwd, reader); return (declared, pkgName) => resolveWithCatalogs(declared, pkgName, catalogs); }