diff --git a/.changeset/internal-range-policy.md b/.changeset/internal-range-policy.md new file mode 100644 index 000000000..51bd3946b --- /dev/null +++ b/.changeset/internal-range-policy.md @@ -0,0 +1,5 @@ +--- +"@croco/frontend-react": patch +--- + +Published manifests no longer carry a stale semver range for the internal `@croco/meta-vite` peer; internal Croco source manifest references are checked against the workspace range policy before release. diff --git a/docs/release/package-entrypoint-contract.md b/docs/release/package-entrypoint-contract.md index b8bad65e1..2c5c58763 100644 --- a/docs/release/package-entrypoint-contract.md +++ b/docs/release/package-entrypoint-contract.md @@ -19,6 +19,16 @@ imports `reflect-metadata`, that package must declare `reflect-metadata` in runt `dependencies`. Keeping it only in `devDependencies`, or relying on another Croco package to hoist it for consumers, is not valid because strict package managers may isolate transitive dependencies. +Internal Croco package references use `workspace:*` in source manifests across `dependencies`, +`devDependencies`, `peerDependencies`, and `optionalDependencies`. This includes internal peer +dependencies: pnpm rewrites `workspace:*` to the target workspace package version during +[pack and publish](https://pnpm.io/workspaces#publishing-workspace-packages), so source manifests do +not need hand-written internal semver ranges. If a future public peer compatibility range must stay +as semver in source, add an exact checked exception to +`scripts/internal-peer-dependency-range-exceptions.json` with the package name, `peerDependencies` +section, internal dependency name, range, and compatibility rationale. Exceptions are peer-only; +internal `dependencies`, `devDependencies`, and `optionalDependencies` still use `workspace:*`. + The contract is enforced by: ```bash diff --git a/packages/frontend-react/package.json b/packages/frontend-react/package.json index f66da7f90..5946b131b 100644 --- a/packages/frontend-react/package.json +++ b/packages/frontend-react/package.json @@ -35,7 +35,7 @@ "@croco/problems-core": "workspace:*" }, "devDependencies": { - "@croco/meta-vite": "^0.0.4", + "@croco/meta-vite": "workspace:*", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "react": "^19.0.0", @@ -43,7 +43,7 @@ "vitest": "4.0.16" }, "peerDependencies": { - "@croco/meta-vite": "^0.0.4", + "@croco/meta-vite": "workspace:*", "react": "^19.0.0", "react-dom": "^19.0.0" }, diff --git a/scripts/internal-peer-dependency-range-exceptions.json b/scripts/internal-peer-dependency-range-exceptions.json new file mode 100644 index 000000000..fe51488c7 --- /dev/null +++ b/scripts/internal-peer-dependency-range-exceptions.json @@ -0,0 +1 @@ +[] diff --git a/scripts/normalize-packages.mjs b/scripts/normalize-packages.mjs index 750a27820..f4777643e 100644 --- a/scripts/normalize-packages.mjs +++ b/scripts/normalize-packages.mjs @@ -43,7 +43,24 @@ const DRIZZLE_ORM_DEPENDENCY_SECTIONS = [ "peerDependencies", "optionalDependencies", ]; +const DEPENDENCY_RANGE_POLICY_SECTIONS = [ + "dependencies", + "devDependencies", + "peerDependencies", + "optionalDependencies", +]; const RUNTIME_DEPENDENCY_SECTIONS = ["dependencies", "peerDependencies", "optionalDependencies"]; +const INTERNAL_CROCO_PACKAGE_PREFIX = "@croco/"; +const INTERNAL_WORKSPACE_DEPENDENCY_RANGE = "workspace:*"; +const INTERNAL_PEER_DEPENDENCY_RANGE_EXCEPTIONS_PATH = + "scripts/internal-peer-dependency-range-exceptions.json"; +const SEMVER_NUMERIC_IDENTIFIER_PATTERN = "0|[1-9]\\d*"; +const SEMVER_XRANGE_IDENTIFIER_PATTERN = `(?:${SEMVER_NUMERIC_IDENTIFIER_PATTERN}|x|X|\\*)`; +const SEMVER_NUMERIC_IDENTIFIER_RE = new RegExp(`^(?:${SEMVER_NUMERIC_IDENTIFIER_PATTERN})$`, "u"); +const SEMVER_VERSION_RE = new RegExp( + `^(?${SEMVER_XRANGE_IDENTIFIER_PATTERN})(?:\\.(?${SEMVER_XRANGE_IDENTIFIER_PATTERN})(?:\\.(?${SEMVER_XRANGE_IDENTIFIER_PATTERN}))?)?(?-[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?(?\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?$`, + "u", +); const nodeBuiltinModules = new Set([ ...builtinModules, ...builtinModules.map((moduleName) => `node:${moduleName}`), @@ -55,9 +72,21 @@ main(); function main() { const rootDir = mode.rootDir; - const packagesDir = path.join(rootDir, "packages"); - const packageJsonFiles = findPackageJsonFiles(packagesDir); + const packageJsonFiles = findWorkspacePackageJsonFiles(rootDir); const violations = []; + const workspacePackageNames = readWorkspacePackageNames(packageJsonFiles); + const internalWorkspacePackageNames = new Set( + Array.from(workspacePackageNames).filter((packageName) => + packageName.startsWith(INTERNAL_CROCO_PACKAGE_PREFIX), + ), + ); + const internalPeerDependencyRangeExceptions = readInternalPeerDependencyRangeExceptions( + rootDir, + workspacePackageNames, + internalWorkspacePackageNames, + violations, + ); + const usedInternalPeerDependencyRangeExceptions = new Set(); let checkedCount = 0; let skippedCount = 0; let modifiedCount = 0; @@ -67,6 +96,14 @@ function main() { const content = fs.readFileSync(pkgPath, "utf-8"); const pkg = JSON.parse(content); + for (const violation of validateWorkspacePackagePolicy(pkg, { + internalPeerDependencyRangeExceptions, + internalWorkspacePackageNames, + usedInternalPeerDependencyRangeExceptions, + })) { + violations.push(`${relativePath}: ${violation}`); + } + if (pkg.private === true) { skippedCount++; continue; @@ -98,6 +135,16 @@ function main() { } } + for (const exception of internalPeerDependencyRangeExceptions.values()) { + if (usedInternalPeerDependencyRangeExceptions.has(exception.key)) { + continue; + } + + violations.push( + `${INTERNAL_PEER_DEPENDENCY_RANGE_EXCEPTIONS_PATH}: unused internal peer dependency range exception ${formatInternalPeerDependencyRangeException(exception)}`, + ); + } + console.log(""); console.log("=== Package manifest summary ==="); console.log(`Checked: ${checkedCount}`); @@ -118,6 +165,17 @@ function main() { console.log("✓ Package manifest contracts are normalized."); } +function findWorkspacePackageJsonFiles(rootDir) { + const workspacePackageDirs = [path.join(rootDir, "packages"), path.join(rootDir, "examples")]; + return Array.from( + new Set( + workspacePackageDirs.flatMap((workspacePackageDir) => + findPackageJsonFiles(workspacePackageDir), + ), + ), + ).sort(); +} + function parseArgs(args) { let check = false; let write = false; @@ -266,6 +324,19 @@ function normalizeExportTypes(exportsValue) { } } +function readWorkspacePackageNames(packageJsonFiles) { + const workspacePackageNames = new Set(); + + for (const pkgPath of packageJsonFiles) { + const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8")); + if (typeof pkg.name === "string" && pkg.name.length > 0) { + workspacePackageNames.add(pkg.name); + } + } + + return workspacePackageNames; +} + function normalizeDistSpecifier(value) { if (typeof value === "string" && value.startsWith("dist/")) { return `./${value}`; @@ -347,6 +418,12 @@ function directRootExportFor(pkg) { }; } +function validateWorkspacePackagePolicy(pkg, policyContext) { + const violations = []; + validateInternalDependencyRangePolicy(pkg, policyContext, violations); + return violations; +} + function validatePackage(pkg, pkgPath, rootDir) { const hasSourceEntrypoint = packageHasSourceEntrypoint(pkgPath); const violations = []; @@ -409,6 +486,270 @@ function validatePackage(pkg, pkgPath, rootDir) { return violations; } +function readInternalPeerDependencyRangeExceptions( + rootDir, + workspacePackageNames, + internalWorkspacePackageNames, + violations, +) { + const exceptionPath = path.join(rootDir, INTERNAL_PEER_DEPENDENCY_RANGE_EXCEPTIONS_PATH); + if (!fs.existsSync(exceptionPath)) { + return new Map(); + } + + let parsed; + try { + parsed = JSON.parse(fs.readFileSync(exceptionPath, "utf-8")); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + violations.push( + `${INTERNAL_PEER_DEPENDENCY_RANGE_EXCEPTIONS_PATH}: must contain valid JSON: ${message}`, + ); + return new Map(); + } + + if (!Array.isArray(parsed)) { + violations.push(`${INTERNAL_PEER_DEPENDENCY_RANGE_EXCEPTIONS_PATH}: must be an array`); + return new Map(); + } + + const exceptions = new Map(); + for (const [index, entry] of parsed.entries()) { + const fieldName = `${INTERNAL_PEER_DEPENDENCY_RANGE_EXCEPTIONS_PATH}[${index}]`; + const exception = readInternalPeerDependencyRangeException( + entry, + fieldName, + workspacePackageNames, + internalWorkspacePackageNames, + violations, + ); + + if (!exception) { + continue; + } + + if (exceptions.has(exception.key)) { + violations.push( + `${fieldName}: duplicate exception ${formatInternalPeerDependencyRangeException(exception)}`, + ); + continue; + } + + exceptions.set(exception.key, exception); + } + + return exceptions; +} + +function readInternalPeerDependencyRangeException( + entry, + fieldName, + workspacePackageNames, + internalWorkspacePackageNames, + violations, +) { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) { + violations.push(`${fieldName}: must be an object`); + return undefined; + } + + const packageName = readRequiredStringField(entry, "package", fieldName, violations); + const sectionName = readRequiredStringField(entry, "section", fieldName, violations); + const dependencyName = readRequiredStringField(entry, "dependency", fieldName, violations); + const range = readRequiredStringField(entry, "range", fieldName, violations); + const rationale = readRequiredStringField(entry, "rationale", fieldName, violations); + + if (packageName && !workspacePackageNames.has(packageName)) { + violations.push( + `${fieldName}.package must name a workspace package, not ${JSON.stringify(packageName)}`, + ); + } + + if (sectionName && sectionName !== "peerDependencies") { + violations.push( + `${fieldName}.section must be "peerDependencies"; internal semver exceptions are peer-only`, + ); + } + + if (dependencyName && !internalWorkspacePackageNames.has(dependencyName)) { + violations.push( + `${fieldName}.dependency must name an internal @croco/* workspace package, not ${JSON.stringify(dependencyName)}`, + ); + } + + if (rationale !== undefined && rationale.trim().length === 0) { + violations.push(`${fieldName}.rationale must be nonempty`); + } + + if (range !== undefined && range.trim().length === 0) { + violations.push(`${fieldName}.range must be nonempty`); + } + + if (range?.trim() && !isSemverCompatibilityRange(range)) { + violations.push(`${fieldName}.range must be a semver compatibility range`); + return undefined; + } + + if (!packageName || !sectionName || !dependencyName || !range?.trim() || !rationale?.trim()) { + return undefined; + } + + if ( + !workspacePackageNames.has(packageName) || + sectionName !== "peerDependencies" || + !internalWorkspacePackageNames.has(dependencyName) + ) { + return undefined; + } + + return { + dependencyName, + key: internalPeerDependencyRangeExceptionKey(packageName, dependencyName, range), + packageName, + range, + sectionName, + }; +} + +function readRequiredStringField(entry, propertyName, fieldName, violations) { + const value = entry[propertyName]; + if (typeof value !== "string") { + violations.push(`${fieldName}.${propertyName} must be a string`); + return undefined; + } + + return value; +} + +function isSemverCompatibilityRange(range) { + const trimmed = range.trim(); + if (trimmed.length === 0 || trimmed.includes(":")) { + return false; + } + + const alternatives = trimmed.split("||").map((alternative) => alternative.trim()); + if (alternatives.some((alternative) => alternative.length === 0)) { + return false; + } + + return alternatives.every(isSemverCompatibilityRangeAlternative); +} + +function isSemverCompatibilityRangeAlternative(alternative) { + if (alternative === "*") { + return true; + } + + const hyphenRangeParts = alternative.split(/\s+-\s+/u); + if (hyphenRangeParts.length === 2) { + return hyphenRangeParts.every(isSemverVersionPattern); + } + + if (hyphenRangeParts.length > 2) { + return false; + } + + return alternative.split(/\s+/u).every(isSemverComparatorPattern); +} + +function isSemverComparatorPattern(comparator) { + const match = comparator.match(/^(?:<=|>=|<|>|=|\^|~)?(.+)$/u); + return Boolean(match?.[1] && isSemverVersionPattern(match[1])); +} + +function isSemverVersionPattern(version) { + const match = SEMVER_VERSION_RE.exec(version); + if (!match?.groups) { + return false; + } + + const { build, major, minor, patch, prerelease } = match.groups; + if (!hasValidWildcardOrder(major, minor, patch)) { + return false; + } + + const hasMetadata = Boolean(prerelease || build); + const hasCompleteNumericVersion = + Boolean(minor && patch) && + ![major, minor, patch].some((versionPart) => isSemverWildcardVersionPart(versionPart)); + if (hasMetadata && !hasCompleteNumericVersion) { + return false; + } + + if (!prerelease) { + return true; + } + + return prerelease + .slice(1) + .split(".") + .every((identifier) => !isInvalidNumericPrereleaseIdentifier(identifier)); +} + +function hasValidWildcardOrder(major, minor, patch) { + if (isSemverWildcardVersionPart(major) && minor && !isSemverWildcardVersionPart(minor)) { + return false; + } + + if (minor && isSemverWildcardVersionPart(minor) && patch && !isSemverWildcardVersionPart(patch)) { + return false; + } + + return true; +} + +function isSemverWildcardVersionPart(versionPart) { + return versionPart === "*" || versionPart === "x" || versionPart === "X"; +} + +function isInvalidNumericPrereleaseIdentifier(identifier) { + return /^\d+$/u.test(identifier) && !SEMVER_NUMERIC_IDENTIFIER_RE.test(identifier); +} + +function validateInternalDependencyRangePolicy(pkg, policyContext, violations) { + if (!pkg.name || typeof pkg.name !== "string") { + return; + } + + for (const sectionName of DEPENDENCY_RANGE_POLICY_SECTIONS) { + const dependencyMap = pkg[sectionName]; + if (!dependencyMap || typeof dependencyMap !== "object" || Array.isArray(dependencyMap)) { + continue; + } + + for (const [dependencyName, range] of Object.entries(dependencyMap)) { + if (!policyContext.internalWorkspacePackageNames.has(dependencyName)) { + continue; + } + + if (range === INTERNAL_WORKSPACE_DEPENDENCY_RANGE) { + continue; + } + + const exceptionKey = internalPeerDependencyRangeExceptionKey(pkg.name, dependencyName, range); + if ( + sectionName === "peerDependencies" && + policyContext.internalPeerDependencyRangeExceptions.has(exceptionKey) + ) { + policyContext.usedInternalPeerDependencyRangeExceptions.add(exceptionKey); + continue; + } + + violations.push( + `${sectionName}.${dependencyName} must use ${INTERNAL_WORKSPACE_DEPENDENCY_RANGE} for internal Croco workspace packages, not ${JSON.stringify(range)}`, + ); + } + } +} + +function internalPeerDependencyRangeExceptionKey(packageName, dependencyName, range) { + return `${packageName}\0${dependencyName}\0${range}`; +} + +function formatInternalPeerDependencyRangeException(exception) { + return `${exception.packageName} peerDependencies.${exception.dependencyName}=${JSON.stringify(exception.range)}`; +} + function validateDirectDistEntrypoints(pkg, violations) { if (!DIRECT_DIST_ENTRYPOINT_PACKAGES.has(pkg.name)) { return; diff --git a/scripts/tests/normalize-packages.spec.ts b/scripts/tests/normalize-packages.spec.ts index 25e998f4b..d1529fbbc 100644 --- a/scripts/tests/normalize-packages.spec.ts +++ b/scripts/tests/normalize-packages.spec.ts @@ -582,6 +582,251 @@ describe("normalize-packages.mjs", () => { expect(result.stdout).toContain("drizzle-orm must be declared with catalog:"); }); + it("requires internal Croco workspace package ranges to use workspace:*", () => { + const root = createTempRoot(); + writePackage(root, "internal-runtime", publishablePackage("@croco/internal-runtime")); + writePackage( + root, + "range-drift", + publishablePackage("@croco/range-drift", { + dependencies: { + "@croco/internal-runtime": "^0.0.3", + }, + devDependencies: { + "@croco/internal-runtime": "^0.0.3", + }, + optionalDependencies: { + "@croco/internal-runtime": "^0.0.3", + }, + peerDependencies: { + "@croco/internal-runtime": "^0.0.3", + }, + }), + ); + writePackage( + root, + "workspace-ranges", + publishablePackage("@croco/workspace-ranges", { + dependencies: { + "@croco/internal-runtime": "workspace:*", + }, + devDependencies: { + "@croco/internal-runtime": "workspace:*", + }, + optionalDependencies: { + "@croco/internal-runtime": "workspace:*", + }, + peerDependencies: { + "@croco/internal-runtime": "workspace:*", + }, + }), + ); + writePackage( + root, + "external-croco-scope", + publishablePackage("@croco/external-croco-scope", { + dependencies: { + "@croco/not-in-workspace": "^1.0.0", + }, + }), + ); + writePackage( + root, + "private-range-drift", + { + name: "@croco/private-range-drift", + private: true, + version: "0.0.3", + dependencies: { + "@croco/internal-runtime": "^0.0.3", + }, + }, + { + sourceIndex: false, + }, + ); + writeExamplePackage(root, "range-drift-example", { + name: "@croco-example/range-drift", + private: true, + version: "0.0.0", + dependencies: { + "@croco/internal-runtime": "^0.0.3", + }, + }); + + const result = runScript(root, "--check"); + + expect(result.status).toBe(1); + expect(result.stdout).toContain("range-drift/package.json"); + expect(result.stdout).toContain( + 'dependencies.@croco/internal-runtime must use workspace:* for internal Croco workspace packages, not "^0.0.3"', + ); + expect(result.stdout).toContain( + 'devDependencies.@croco/internal-runtime must use workspace:* for internal Croco workspace packages, not "^0.0.3"', + ); + expect(result.stdout).toContain( + 'optionalDependencies.@croco/internal-runtime must use workspace:* for internal Croco workspace packages, not "^0.0.3"', + ); + expect(result.stdout).toContain( + 'peerDependencies.@croco/internal-runtime must use workspace:* for internal Croco workspace packages, not "^0.0.3"', + ); + expect(result.stdout).toContain("private-range-drift/package.json"); + expect(result.stdout).toContain("examples/range-drift-example/package.json"); + expect(result.stdout).not.toContain("workspace-ranges/package.json"); + expect(result.stdout).not.toContain("external-croco-scope/package.json"); + }); + + it("allows checked peer-only internal semver range exceptions", () => { + const root = createTempRoot(); + writePackage(root, "internal-runtime", publishablePackage("@croco/internal-runtime")); + writePackage( + root, + "peer-compat", + publishablePackage("@croco/peer-compat", { + peerDependencies: { + "@croco/internal-runtime": "^0.0.3", + }, + }), + ); + writeInternalPeerDependencyRangeExceptions(root, [ + { + package: "@croco/peer-compat", + section: "peerDependencies", + dependency: "@croco/internal-runtime", + range: "^0.0.3", + rationale: "Published peers intentionally accept the current compatible alpha line.", + }, + ]); + + const result = runScript(root, "--check"); + + expect(result.status).toBe(0); + }); + + it("rejects malformed, empty, non-peer, and unused internal range exceptions", () => { + const root = createTempRoot(); + writePackage(root, "internal-runtime", publishablePackage("@croco/internal-runtime")); + writePackage( + root, + "peer-compat", + publishablePackage("@croco/peer-compat", { + peerDependencies: { + "@croco/internal-runtime": "^0.0.3", + }, + }), + ); + writePackage( + root, + "dev-range", + publishablePackage("@croco/dev-range", { + devDependencies: { + "@croco/internal-runtime": "^0.0.3", + }, + }), + ); + writePackage( + root, + "invalid-peer-range", + publishablePackage("@croco/invalid-peer-range", { + peerDependencies: { + "@croco/internal-runtime": "file:../internal-runtime", + }, + }), + ); + writeInternalPeerDependencyRangeExceptions(root, [ + { + package: "@croco/peer-compat", + section: "peerDependencies", + dependency: "@croco/internal-runtime", + range: "^0.0.3", + rationale: "", + }, + { + package: "@croco/dev-range", + section: "devDependencies", + dependency: "@croco/internal-runtime", + range: "^0.0.3", + rationale: "Dev dependency ranges are not allowed to use semver exceptions.", + }, + { + package: "@croco/peer-compat", + section: "peerDependencies", + dependency: 42, + range: "^0.0.3", + rationale: "Malformed entries must be rejected instead of ignored.", + }, + { + package: "@croco/peer-compat", + section: "peerDependencies", + dependency: "@croco/internal-runtime", + range: "", + rationale: "Blank ranges are not valid published compatibility ranges.", + }, + { + package: "@croco/invalid-peer-range", + section: "peerDependencies", + dependency: "@croco/internal-runtime", + range: "file:../internal-runtime", + rationale: "Non-semver dependency specs are not published peer compatibility ranges.", + }, + { + package: "@croco/peer-compat", + section: "peerDependencies", + dependency: "@croco/internal-runtime", + range: "1.2.3-.", + rationale: "Malformed prerelease identifiers are not compatibility ranges.", + }, + { + package: "@croco/peer-compat", + section: "peerDependencies", + dependency: "@croco/internal-runtime", + range: "1.2.3+.", + rationale: "Malformed build identifiers are not compatibility ranges.", + }, + { + package: "@croco/peer-compat", + section: "peerDependencies", + dependency: "@croco/internal-runtime", + range: ">=0.0.3", + rationale: "This valid exception is intentionally unused by any manifest entry.", + }, + ]); + + const result = runScript(root, "--check"); + + expect(result.status).toBe(1); + expect(result.stdout).toContain( + "scripts/internal-peer-dependency-range-exceptions.json[0].rationale must be nonempty", + ); + expect(result.stdout).toContain( + 'scripts/internal-peer-dependency-range-exceptions.json[1].section must be "peerDependencies"; internal semver exceptions are peer-only', + ); + expect(result.stdout).toContain( + "scripts/internal-peer-dependency-range-exceptions.json[2].dependency must be a string", + ); + expect(result.stdout).toContain( + "scripts/internal-peer-dependency-range-exceptions.json[3].range must be nonempty", + ); + expect(result.stdout).toContain( + "scripts/internal-peer-dependency-range-exceptions.json[4].range must be a semver compatibility range", + ); + expect(result.stdout).toContain( + "scripts/internal-peer-dependency-range-exceptions.json[5].range must be a semver compatibility range", + ); + expect(result.stdout).toContain( + "scripts/internal-peer-dependency-range-exceptions.json[6].range must be a semver compatibility range", + ); + expect(result.stdout).toContain( + 'scripts/internal-peer-dependency-range-exceptions.json: unused internal peer dependency range exception @croco/peer-compat peerDependencies.@croco/internal-runtime=">=0.0.3"', + ); + expect(result.stdout).toContain("peer-compat/package.json"); + expect(result.stdout).toContain("dev-range/package.json"); + expect(result.stdout).toContain("invalid-peer-range/package.json"); + expect(result.stdout).toContain( + 'peerDependencies.@croco/internal-runtime must use workspace:* for internal Croco workspace packages, not "file:../internal-runtime"', + ); + }); + it("requires production source runtime imports to be declared as published dependencies", () => { const root = createTempRoot(); @@ -779,6 +1024,31 @@ function writePackage( return packagePath; } +function writeExamplePackage( + root: string, + exampleDirName: string, + pkg: Record, +): string { + const packageDir = join(root, "examples", exampleDirName); + mkdirSync(packageDir, { recursive: true }); + + const packagePath = join(packageDir, "package.json"); + writeFileSync(packagePath, `${JSON.stringify(pkg, null, 2)}\n`); + + return packagePath; +} + +function writeInternalPeerDependencyRangeExceptions( + root: string, + entries: readonly Record[], +): string { + const exceptionPath = join(root, "scripts", "internal-peer-dependency-range-exceptions.json"); + mkdirSync(dirname(exceptionPath), { recursive: true }); + writeFileSync(exceptionPath, `${JSON.stringify(entries, null, 2)}\n`); + + return exceptionPath; +} + function withRepositoryMetadata( pkg: Record, repository: Record,