diff --git a/scripts/generate-npm-shrinkwrap.mjs b/scripts/generate-npm-shrinkwrap.mjs index 2691145c1f38..3044c45f8e52 100644 --- a/scripts/generate-npm-shrinkwrap.mjs +++ b/scripts/generate-npm-shrinkwrap.mjs @@ -305,11 +305,60 @@ function mergeOverrideEntry(merged, name, spec) { } return; } + if ( + typeof current === "string" && + isPlainObject(spec) && + typeof spec["."] === "string" && + exactOverrideVersionsMatch(current, spec["."]) + ) { + merged[name] = { ".": preferredExactOverrideRootSpec(current, spec["."]) }; + for (const [nestedName, nestedSpec] of Object.entries(spec)) { + if (nestedName === ".") { + continue; + } + mergeOverrideEntry(merged[name], nestedName, nestedSpec); + } + return; + } + if ( + isPlainObject(current) && + typeof spec === "string" && + typeof current["."] === "string" && + exactOverrideVersionsMatch(current["."], spec) + ) { + current["."] = preferredExactOverrideRootSpec(current["."], spec); + return; + } if (JSON.stringify(current) !== JSON.stringify(spec)) { throw new Error(`package.json overrides.${name} conflicts with pnpm lock policy for ${name}`); } } +function preferredExactOverrideRootSpec(current, incoming) { + return incoming.startsWith("npm:") ? incoming : current; +} + +function exactOverrideVersionsMatch(left, right) { + const leftVersion = exactVersionFromOverrideSpec(left); + if (leftVersion === null || leftVersion !== exactVersionFromOverrideSpec(right)) { + return false; + } + const leftAlias = parseNpmAliasOverrideSpec(left); + const rightAlias = parseNpmAliasOverrideSpec(right); + return !leftAlias || !rightAlias || leftAlias.name === rightAlias.name; +} + +function parseNpmAliasOverrideSpec(spec) { + if (!spec.startsWith("npm:")) { + return null; + } + const versionIndex = spec.lastIndexOf("@"); + if (versionIndex <= "npm:".length) { + return null; + } + return { name: spec.slice("npm:".length, versionIndex) }; +} + function mergeOverrides(packageOverrides, workspaceOverrides, pnpmLockOverrides) { const merged = normalizeOverrides(packageOverrides); for (const [name, spec] of [ @@ -1085,6 +1134,7 @@ export { disableShrinkwrappedOverrideConflictSources, exactOverrideRulesFromOverrides, exactVersionFromOverrideSpec, + mergeOverrides, applyPackageExtensionPeerMetadata, normalizeNpmVersionDrift, packageJsonForShrinkwrap, diff --git a/test/package-manager-config.test.ts b/test/package-manager-config.test.ts index ec65d8e75387..149f908f3ca6 100644 --- a/test/package-manager-config.test.ts +++ b/test/package-manager-config.test.ts @@ -4,6 +4,7 @@ import { parse } from "yaml"; import { collectCurrentShrinkwrapOverrides, collectPnpmLockViolations, + mergeOverrides, parsePnpmPackageKey, readShrinkwrapOverrides, } from "../scripts/generate-npm-shrinkwrap.mjs"; @@ -132,6 +133,82 @@ describe("package manager build policy", () => { }); }); + it("merges exact current shrinkwrap pins with nested lock-derived pins", () => { + expect( + mergeOverrides( + { "@mistralai/mistralai": "2.2.1" }, + { "@mistralai/mistralai": { ".": "2.2.1", zod: "4.4.3" } }, + {}, + ), + ).toEqual({ + "@mistralai/mistralai": { ".": "2.2.1", zod: "4.4.3" }, + }); + }); + + it("preserves npm alias pins when merging nested lock-derived pins", () => { + expect( + mergeOverrides( + { "node-domexception": "npm:@nolyfill/domexception@1.0.28" }, + { "node-domexception": { ".": "1.0.28", child: "2.0.0" } }, + {}, + ), + ).toEqual({ + "node-domexception": { + ".": "npm:@nolyfill/domexception@1.0.28", + child: "2.0.0", + }, + }); + }); + + it("preserves later npm alias pins when nested pins are already merged", () => { + expect( + mergeOverrides( + { "node-domexception": { ".": "1.0.28", child: "2.0.0" } }, + { "node-domexception": "npm:@nolyfill/domexception@1.0.28" }, + {}, + ), + ).toEqual({ + "node-domexception": { + ".": "npm:@nolyfill/domexception@1.0.28", + child: "2.0.0", + }, + }); + }); + + it("rejects non-exact root pins when merging nested pins", () => { + expect(() => + mergeOverrides( + { "floating-package": "^1.0.0" }, + { "floating-package": { ".": "~1.0.0", child: "2.0.0" } }, + {}, + ), + ).toThrow(/conflicts with pnpm lock policy/u); + expect(() => + mergeOverrides( + { "floating-package": { ".": "^1.0.0", child: "2.0.0" } }, + { "floating-package": "~1.0.0" }, + {}, + ), + ).toThrow(/conflicts with pnpm lock policy/u); + }); + + it("rejects distinct npm alias targets with matching versions", () => { + expect(() => + mergeOverrides( + { "aliased-package": "npm:@safe/foo@1.0.0" }, + { "aliased-package": { ".": "npm:@other/foo@1.0.0", child: "2.0.0" } }, + {}, + ), + ).toThrow(/conflicts with pnpm lock policy/u); + expect(() => + mergeOverrides( + { "aliased-package": { ".": "npm:@safe/foo@1.0.0", child: "2.0.0" } }, + { "aliased-package": "npm:@other/foo@1.0.0" }, + {}, + ), + ).toThrow(/conflicts with pnpm lock policy/u); + }); + it("keeps npm shrinkwrap package versions inside the pnpm lock graph", () => { const pnpmLockPackages = collectPnpmLockPackages(); const shrinkwrapPaths = [