Skip to content

Commit 9e0e58d

Browse files
committed
fix(shrinkwrap-extractor): Handle dependencies hoisted to the workspace root for packages other than the target
1 parent e4f22b4 commit 9e0e58d

File tree

2 files changed

+81
-29
lines changed

2 files changed

+81
-29
lines changed

internal/shrinkwrap-extractor/lib/convertPackageLockToShrinkwrap.js

Lines changed: 46 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,12 @@ export default async function convertPackageLockToShrinkwrap(workspaceRootDir, t
6060

6161
// Using the keys, extract relevant package-entries from package-lock.json
6262
const extractedPackages = Object.create(null);
63+
const resolvedConflicts = new Set();
6364
for (let [packageLoc, node] of relevantPackageLocations) {
65+
if (resolvedConflicts.has(packageLoc)) {
66+
// This package location was already moved due to a conflict
67+
continue;
68+
}
6469
let pkg = packageLockJson.packages[packageLoc];
6570
if (pkg.link) {
6671
pkg = packageLockJson.packages[pkg.resolved];
@@ -72,15 +77,47 @@ export default async function convertPackageLockToShrinkwrap(workspaceRootDir, t
7277
throw new Error(`Duplicate root package entry for "${targetPackageName}"`);
7378
}
7479
} else {
75-
packageLoc = normalizePackageLocation(packageLoc, node, targetPackageName, tree.packageName);
76-
}
77-
if (packageLoc !== "" && !pkg.resolved) {
78-
// For all but the root package, ensure that "resolved" and "integrity" fields are present
79-
// These are always missing for locally linked packages, but sometimes also for others (e.g. if installed
80-
// from local cache)
81-
const {resolved, integrity} = await fetchPackageMetadata(node.packageName, node.version, workspaceRootDir);
82-
pkg.resolved = resolved;
83-
pkg.integrity = integrity;
80+
if (!pkg.resolved) {
81+
// For all but the root package, ensure that "resolved" and "integrity" fields are present
82+
// These are always missing for locally linked packages, but sometimes also for others
83+
// (e.g. if installed from local cache)
84+
const {resolved, integrity} = await fetchPackageMetadata(
85+
node.packageName, node.version, workspaceRootDir);
86+
pkg.resolved = resolved;
87+
pkg.integrity = integrity;
88+
}
89+
// Align package locations with new target
90+
const newPackageLoc = normalizePackageLocation(packageLoc, node, targetPackageName, tree.packageName);
91+
// Detect conflicts with dependencies hoisted to root level for packages other than the target
92+
const existingPackageAtNewLocation = relevantPackageLocations.get(newPackageLoc);
93+
if (newPackageLoc !== packageLoc && existingPackageAtNewLocation) {
94+
if (pkg.version !== existingPackageAtNewLocation.version) {
95+
console.log(
96+
`Hoisting conflict: Package "${node.packageName}" (from "${packageLoc}") already exists at ` +
97+
`new location ${newPackageLoc} in version ${existingPackageAtNewLocation.version}`);
98+
resolvedConflicts.add(newPackageLoc);
99+
const conflictPkg = packageLockJson.packages[newPackageLoc];
100+
if (!conflictPkg.resolved) {
101+
// For all but the root package, ensure that "resolved" and "integrity" fields are present
102+
// These are always missing for locally linked packages, but sometimes also for others
103+
// (e.g. if installed from local cache)
104+
const {resolved, integrity} = await fetchPackageMetadata(
105+
existingPackageAtNewLocation.packageName, existingPackageAtNewLocation.version,
106+
workspaceRootDir);
107+
conflictPkg.resolved = resolved;
108+
conflictPkg.integrity = integrity;
109+
}
110+
// Move existing package to a package-specific subdirectories to avoid conflict
111+
for (const edge of existingPackageAtNewLocation.edgesIn) {
112+
const parentPackage = edge.from.top.packageName;
113+
console.log(`Moving conflicting package "${node.packageName}" under ` +
114+
`"node_modules/${parentPackage}/node_modules/"`);
115+
const subPath = `node_modules/${parentPackage}/node_modules/${node.packageName}`;
116+
extractedPackages[subPath] = structuredClone(conflictPkg);
117+
}
118+
}
119+
}
120+
packageLoc = newPackageLoc;
84121
}
85122
extractedPackages[packageLoc] = pkg;
86123
}

internal/shrinkwrap-extractor/test/lib/convertToShrinkwrap.js

Lines changed: 35 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ function setupPacoteMock() {
2727
*/
2828
async function setupFixtureSymlink(fixtureDir) {
2929
const symlinkPath = path.join(fixtureDir, "package-lock.json");
30+
await await unlink(symlinkPath).catch(() => {});
3031
const targetPath = "package-lock.fixture.json";
3132
await symlink(targetPath, symlinkPath);
3233
return symlinkPath;
@@ -86,37 +87,51 @@ test("Convert package-lock.json to shrinkwrap", async (t) => {
8687

8788
test("Workspace paths should be normalized to node_modules format", async (t) => {
8889
const __dirname = import.meta.dirname;
89-
9090
const cwd = path.join(__dirname, "..", "fixture", "project.a");
9191
const symlinkPath = await setupFixtureSymlink(cwd);
9292
t.after(async () => await unlink(symlinkPath).catch(() => {}));
9393

94-
const targetPackageName = "@ui5/cli";
95-
const shrinkwrapJson = await convertPackageLockToShrinkwrap(cwd, targetPackageName);
96-
97-
// Verify that no package paths contain workspace prefixes like "packages/cli/node_modules/..."
98-
const packagePaths = Object.keys(shrinkwrapJson.packages);
94+
const shrinkwrapJson = await convertPackageLockToShrinkwrap(cwd, "@ui5/cli");
95+
const packagePaths = Object.keys(shrinkwrapJson.packages).filter((p) => p !== "");
9996

97+
// All paths must start with node_modules/, never with packages/
10098
for (const packagePath of packagePaths) {
101-
// Skip root package (empty string)
102-
if (packagePath === "") continue;
103-
104-
// Assert that no path starts with "packages/"
10599
assert.ok(!packagePath.startsWith("packages/"),
106-
`Package path "${packagePath}" should not start with "packages/" prefix`);
107-
108-
// Assert that non-root paths start with "node_modules/"
100+
`Path "${packagePath}" should not contain workspace prefix`);
109101
assert.ok(packagePath.startsWith("node_modules/"),
110-
`Package path "${packagePath}" should start with "node_modules/" prefix`);
102+
`Path "${packagePath}" should start with node_modules/`);
111103
}
112104

113-
// Specifically check a package that would have been under packages/cli/node_modules in the monorepo
114-
// The "@npmcli/config" package is a direct dependency that exists in the CLI's node_modules
115-
const npmCliConfigPackage = shrinkwrapJson.packages["node_modules/@npmcli/config"];
116-
assert.ok(npmCliConfigPackage, "The '@npmcli/config' package should be present at normalized path");
117-
assert.equal(npmCliConfigPackage.version, "9.0.0", "@npmcli/config package should have correct version");
105+
// Verify a CLI dependency was normalized correctly
106+
const npmCliConfig = shrinkwrapJson.packages["node_modules/@npmcli/config"];
107+
assert.ok(npmCliConfig, "@npmcli/config should be at normalized path");
108+
assert.equal(npmCliConfig.version, "9.0.0");
109+
110+
console.log(`✓ All ${packagePaths.length} package paths correctly normalized`);
111+
});
112+
113+
test("Version collisions: root packages get priority at top level", async (t) => {
114+
const __dirname = import.meta.dirname;
115+
const cwd = path.join(__dirname, "..", "fixture", "project.b");
116+
const symlinkPath = await setupFixtureSymlink(cwd);
117+
t.after(async () => await unlink(symlinkPath).catch(() => {}));
118+
119+
const shrinkwrapJson = await convertPackageLockToShrinkwrap(cwd, "@ui5/cli");
120+
121+
// ansi-regex: root has v6.2.2, CLI workspace has v5.0.1
122+
// Root version should be at top level, workspace version nested
123+
const rootAnsiRegex = shrinkwrapJson.packages["node_modules/ansi-regex"];
124+
assert.equal(rootAnsiRegex?.version, "6.2.2", "Root ansi-regex at top level");
125+
126+
const cliAnsiRegex = shrinkwrapJson.packages["node_modules/@ui5/cli/node_modules/ansi-regex"];
127+
assert.equal(cliAnsiRegex?.version, "5.0.1", "Workspace ansi-regex nested under @ui5/cli");
128+
129+
// Verify root version satisfies dependents
130+
const stripAnsi = shrinkwrapJson.packages["node_modules/strip-ansi"];
131+
assert.equal(stripAnsi.dependencies["ansi-regex"], "^6.0.1");
132+
assert.ok(rootAnsiRegex.version.startsWith("6."), "Root v6.2.2 satisfies ^6.0.1");
118133

119-
console.log(`✓ All ${packagePaths.length - 1} package paths correctly normalized`);
134+
console.log("✓ Root package prioritized at top level, workspace version nested");
120135
});
121136

122137
test("Compare generated shrinkwrap with expected result: package.a", async (t) => {

0 commit comments

Comments
 (0)