Skip to content

Commit b58839d

Browse files
committed
fix: Handle package with multiple versions, nesting and order
1 parent 9e636a6 commit b58839d

File tree

4 files changed

+139
-59
lines changed

4 files changed

+139
-59
lines changed

internal/shrinkwrap-extractor/lib/convertPackageLockToShrinkwrap.js

Lines changed: 43 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -65,14 +65,17 @@ export default async function convertPackageLockToShrinkwrap(workspaceRootDir, t
6565
if (pkg.link) {
6666
pkg = packageLockJson.packages[pkg.resolved];
6767
}
68+
69+
const originalLocation = packageLoc;
6870
if (pkg.name === targetPackageName) {
6971
// Make the target package the root package
7072
packageLoc = "";
7173
if (extractedPackages[packageLoc]) {
7274
throw new Error(`Duplicate root package entry for "${targetPackageName}"`);
7375
}
7476
} else {
75-
packageLoc = normalizePackageLocation(packageLoc, node, targetPackageName, tree.packageName);
77+
packageLoc = normalizePackageLocation(packageLoc, node, targetPackageName, tree.packageName,
78+
extractedPackages);
7679
}
7780
if (packageLoc !== "" && !pkg.resolved) {
7881
// For all but the root package, ensure that "resolved" and "integrity" fields are present
@@ -82,14 +85,15 @@ export default async function convertPackageLockToShrinkwrap(workspaceRootDir, t
8285
pkg.resolved = resolved;
8386
pkg.integrity = integrity;
8487
}
85-
extractedPackages[packageLoc] = pkg;
88+
89+
extractedPackages[packageLoc] = {pkg, originalLocation};
8690
}
8791

8892
// Sort packages by key to ensure consistent order (just like the npm cli does it)
8993
const sortedExtractedPackages = Object.create(null);
9094
const sortedKeys = Object.keys(extractedPackages).sort((a, b) => a.localeCompare(b));
9195
for (const key of sortedKeys) {
92-
sortedExtractedPackages[key] = extractedPackages[key];
96+
sortedExtractedPackages[key] = extractedPackages[key].pkg;
9397
}
9498

9599
// Generate npm-shrinkwrap.json
@@ -106,26 +110,57 @@ export default async function convertPackageLockToShrinkwrap(workspaceRootDir, t
106110

107111
/**
108112
* Normalize package locations from workspace-specific paths to standard npm paths.
113+
* Automatically detects and resolves version collisions by checking source locations.
114+
*
109115
* Examples (assuming @ui5/cli is the targetPackageName):
110-
* - packages/cli/node_modules/foo -> node_modules/foo
116+
* - packages/cli/node_modules/foo -> node_modules/foo (if no collision)
117+
* - packages/cli/node_modules/foo -> node_modules/@ui5/cli/node_modules/foo (if root has different version)
111118
* - packages/fs/node_modules/bar -> node_modules/@ui5/fs/node_modules/bar
112119
*
113120
* @param {string} location - Package location from arborist
114121
* @param {object} node - Package node from arborist
115122
* @param {string} targetPackageName - Target package name for shrinkwrap file
116123
* @param {string} rootPackageName - Root / workspace package name
124+
* @param {Object<string, {pkg: object, originalLocation: string}>} extractedPackages - Already extracted packages
117125
* @returns {string} - Normalized location for npm-shrinkwrap.json
118126
*/
119-
function normalizePackageLocation(location, node, targetPackageName, rootPackageName) {
127+
function normalizePackageLocation(
128+
location, node, targetPackageName, rootPackageName, extractedPackages
129+
) {
120130
const topPackageName = node.top.packageName;
131+
const currentIsFromRoot = !location.startsWith("packages/");
132+
121133
if (topPackageName === targetPackageName) {
122-
// Remove location for packages within target package (e.g. @ui5/cli)
123-
return location.substring(node.top.location.length + 1);
134+
// Package is within target package (e.g. @ui5/cli)
135+
const normalizedPath = location.substring(node.top.location.length + 1);
136+
const existing = extractedPackages[normalizedPath];
137+
138+
if (existing && existing.pkg.version !== node.version) {
139+
// Collision detected: Check which should be at root level
140+
const existingIsFromRoot = !existing.originalLocation.startsWith("packages/");
141+
142+
// Root packages always get priority at top level
143+
if (existingIsFromRoot && !currentIsFromRoot) {
144+
// Existing is from root, current is from workspace -> nest current
145+
return `node_modules/${topPackageName}/${normalizedPath}`;
146+
} else if (!existingIsFromRoot && currentIsFromRoot) {
147+
// Current is from root, existing is from workspace -> shouldn't happen
148+
// due to processing order, but handle gracefully
149+
const msg = `Unexpected collision: root package processed after workspace ` +
150+
`package at ${normalizedPath}`;
151+
throw new Error(msg);
152+
}
153+
// Both from same type of location with different versions -> nest current
154+
return `node_modules/${topPackageName}/${normalizedPath}`;
155+
}
156+
157+
return normalizedPath;
124158
} else if (topPackageName !== rootPackageName) {
125159
// Add package within node_modules of actual package name (e.g. @ui5/fs)
126160
return `node_modules/${topPackageName}/${location.substring(node.top.location.length + 1)}`;
127161
}
128-
// If it's already within the root workspace package, keep as-is
162+
163+
// Package is within root workspace, keep as-is
129164
return location;
130165
}
131166

internal/shrinkwrap-extractor/test/expected/package.b/npm-shrinkwrap.json

Lines changed: 62 additions & 21 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

internal/shrinkwrap-extractor/test/expected/package.c/npm-shrinkwrap.json

Lines changed: 0 additions & 10 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

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

Lines changed: 34 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -86,37 +86,51 @@ test("Convert package-lock.json to shrinkwrap", async (t) => {
8686

8787
test("Workspace paths should be normalized to node_modules format", async (t) => {
8888
const __dirname = import.meta.dirname;
89-
9089
const cwd = path.join(__dirname, "..", "fixture", "project.a");
9190
const symlinkPath = await setupFixtureSymlink(cwd);
9291
t.after(async () => await unlink(symlinkPath).catch(() => {}));
9392

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);
93+
const shrinkwrapJson = await convertPackageLockToShrinkwrap(cwd, "@ui5/cli");
94+
const packagePaths = Object.keys(shrinkwrapJson.packages).filter((p) => p !== "");
9995

96+
// All paths must start with node_modules/, never with packages/
10097
for (const packagePath of packagePaths) {
101-
// Skip root package (empty string)
102-
if (packagePath === "") continue;
103-
104-
// Assert that no path starts with "packages/"
10598
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/"
99+
`Path "${packagePath}" should not contain workspace prefix`);
109100
assert.ok(packagePath.startsWith("node_modules/"),
110-
`Package path "${packagePath}" should start with "node_modules/" prefix`);
101+
`Path "${packagePath}" should start with node_modules/`);
111102
}
112103

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

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

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

0 commit comments

Comments
 (0)