Skip to content

Commit 24c20d3

Browse files
committed
fix(deps): validate shrinkwrap restore dependency ranges
1 parent 343ac39 commit 24c20d3

2 files changed

Lines changed: 109 additions & 3 deletions

File tree

scripts/generate-npm-shrinkwrap.mjs

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -886,6 +886,62 @@ function isStablePatchDrift(generatedVersion, currentVersion) {
886886
);
887887
}
888888

889+
function compareStableVersions(leftVersion, rightVersion) {
890+
const left = stableVersionParts(leftVersion);
891+
const right = stableVersionParts(rightVersion);
892+
if (!left || !right) {
893+
return null;
894+
}
895+
return left.major - right.major || left.minor - right.minor || left.patch - right.patch;
896+
}
897+
898+
function versionSatisfiesSimpleSpec(version, spec) {
899+
const normalized = typeof spec === "string" ? spec.trim() : "";
900+
if (normalized === "" || normalized === "*") {
901+
return true;
902+
}
903+
const match = normalized.match(/^(?<operator>\^|~|>=)?(?<version>\d+\.\d+\.\d+)$/u);
904+
if (!match?.groups) {
905+
return normalized === version;
906+
}
907+
const minimumVersion = match.groups.version;
908+
const comparison = compareStableVersions(version, minimumVersion);
909+
if (comparison === null || comparison < 0) {
910+
return false;
911+
}
912+
const candidate = stableVersionParts(version);
913+
const minimum = stableVersionParts(minimumVersion);
914+
if (!candidate || !minimum) {
915+
return false;
916+
}
917+
switch (match.groups.operator) {
918+
case "^":
919+
return minimum.major > 0
920+
? candidate.major === minimum.major
921+
: minimum.minor > 0
922+
? candidate.major === 0 && candidate.minor === minimum.minor
923+
: candidate.major === 0 && candidate.minor === 0 && candidate.patch === minimum.patch;
924+
case "~":
925+
return candidate.major === minimum.major && candidate.minor === minimum.minor;
926+
case ">=":
927+
return true;
928+
default:
929+
return comparison === 0;
930+
}
931+
}
932+
933+
function dependencySpecForLockPath(packages, lockPath, dependencyName) {
934+
const packagePath = parseLockPackagePath(lockPath);
935+
const parentPath = packagePath.at(-2)?.path ?? "";
936+
const parent = packages[parentPath];
937+
return (
938+
parent?.dependencies?.[dependencyName] ??
939+
parent?.optionalDependencies?.[dependencyName] ??
940+
parent?.peerDependencies?.[dependencyName] ??
941+
null
942+
);
943+
}
944+
889945
function restoreCurrentPnpmLockedPackages(
890946
generated,
891947
current,
@@ -922,6 +978,10 @@ function restoreCurrentPnpmLockedPackages(
922978
!currentMetadata.version ||
923979
currentPackageName !== packageName ||
924980
!isStablePatchDrift(metadata.version, currentMetadata.version) ||
981+
!versionSatisfiesSimpleSpec(
982+
currentMetadata.version,
983+
dependencySpecForLockPath(generatedPackages, lockPath, packageName),
984+
) ||
925985
!pnpmLockPackages.has(`${packageName}@${currentMetadata.version}`)
926986
) {
927987
continue;

test/scripts/generate-npm-shrinkwrap.test.ts

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,11 @@ describe("generate-npm-shrinkwrap", () => {
8888
it("disables embedded shrinkwraps that hide workspace overrides", () => {
8989
const lockfile = {
9090
packages: {
91-
"": {},
91+
"": {
92+
dependencies: {
93+
"lru-cache": "^11.5.0",
94+
},
95+
},
9296
"node_modules/@earendil-works/pi-coding-agent": {
9397
version: "0.75.4",
9498
hasShrinkwrap: true,
@@ -147,7 +151,11 @@ describe("generate-npm-shrinkwrap", () => {
147151
it("restores current shrinkwrap entries when npm floats past pnpm's lock", () => {
148152
const generated = {
149153
packages: {
150-
"": {},
154+
"": {
155+
dependencies: {
156+
"lru-cache": "^11.5.0",
157+
},
158+
},
151159
"node_modules/lru-cache": {
152160
version: "11.5.1",
153161
resolved: "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz",
@@ -177,7 +185,45 @@ describe("generate-npm-shrinkwrap", () => {
177185
};
178186
const pnpmPackages = new Set(["[email protected]", "[email protected]"]);
179187

180-
expect(restoreCurrentPnpmLockedPackages(generated, current, pnpmPackages)).toEqual(current);
188+
expect(restoreCurrentPnpmLockedPackages(generated, current, pnpmPackages)).toEqual({
189+
packages: {
190+
"": {
191+
dependencies: {
192+
"lru-cache": "^11.5.0",
193+
},
194+
},
195+
"node_modules/lru-cache": current.packages["node_modules/lru-cache"],
196+
"node_modules/lru-memoizer/node_modules/lru-cache":
197+
current.packages["node_modules/lru-memoizer/node_modules/lru-cache"],
198+
},
199+
});
200+
});
201+
202+
it("does not restore versions that no longer satisfy the dependency edge", () => {
203+
const generated = {
204+
packages: {
205+
"": {
206+
dependencies: {
207+
"lru-cache": "^11.5.1",
208+
},
209+
},
210+
"node_modules/lru-cache": {
211+
version: "11.5.1",
212+
},
213+
},
214+
};
215+
const current = {
216+
packages: {
217+
"": {},
218+
"node_modules/lru-cache": {
219+
version: "11.5.0",
220+
},
221+
},
222+
};
223+
224+
expect(
225+
restoreCurrentPnpmLockedPackages(generated, current, new Set(["[email protected]"])),
226+
).toEqual(generated);
181227
});
182228

183229
it("does not restore incompatible generated shrinkwrap versions", () => {

0 commit comments

Comments
 (0)