From 5a4f66878d2f2bf288ed1c375bcf316633825928 Mon Sep 17 00:00:00 2001 From: Trevor Burnham Date: Sun, 5 Oct 2025 11:39:54 -0400 Subject: [PATCH] fix(arborist): apply peer dependency constraints from added packages --- .../arborist/lib/arborist/build-ideal-tree.js | 43 ++++++++++++++++ .../test/arborist/build-ideal-tree.js | 49 +++++++++++++++++++ 2 files changed, 92 insertions(+) diff --git a/workspaces/arborist/lib/arborist/build-ideal-tree.js b/workspaces/arborist/lib/arborist/build-ideal-tree.js index 3a066d9b6d336..524a7927761a4 100644 --- a/workspaces/arborist/lib/arborist/build-ideal-tree.js +++ b/workspaces/arborist/lib/arborist/build-ideal-tree.js @@ -504,6 +504,49 @@ module.exports = cls => class IdealTreeBuilder extends cls { tree.package = tree.package } + // Fix for https://github.com/npm/cli/issues/8492 + // When packages are explicitly added, check if any have peer dependencies + // on other packages being added. If so, update the specs to satisfy those + // peer dependencies to avoid order-dependent resolution. + if (add && add.length > 1) { + // Build a map of package name -> spec for packages being added to this tree + const addedSpecs = new Map() + for (const spec of this[_resolvedAdd]) { + if (spec.tree === tree) { + addedSpecs.set(spec.name, spec) + } + } + + // Find peer dependency constraints between packages being added + let hasConstraints = false + for (const edge of tree.edgesOut.values()) { + // Check if this is a peer dep from one added package to another + if (edge.peer && addedSpecs.has(edge.from.name) && addedSpecs.has(edge.name)) { + const spec = addedSpecs.get(edge.name) + // Apply constraint if package was added without a specific version + if (spec.type === 'tag' && spec.fetchSpec === 'latest' && + edge.spec && edge.spec !== '*') { + // Find the dependency in package.json and update it + for (const depType of ['dependencies', 'devDependencies', 'optionalDependencies']) { + if (tree.package[depType]?.[edge.name]) { + tree.package[depType][edge.name] = edge.spec + hasConstraints = true + debug(() => { + log.silly('idealTree', `Applying peer constraint ${edge.spec} to ${edge.name} from ${edge.from.name}`) + }) + break + } + } + } + } + } + + // Refresh edges if we updated any constraints + if (hasConstraints) { + tree.package = tree.package + } + } + for (const spec of this[_resolvedAdd]) { if (spec.tree === tree) { this.#explicitRequests.add(tree.edgesOut.get(spec.name)) diff --git a/workspaces/arborist/test/arborist/build-ideal-tree.js b/workspaces/arborist/test/arborist/build-ideal-tree.js index e56daa7ed76ad..4e817059a6e97 100644 --- a/workspaces/arborist/test/arborist/build-ideal-tree.js +++ b/workspaces/arborist/test/arborist/build-ideal-tree.js @@ -4460,3 +4460,52 @@ t.test('installLinks behavior with project-internal file dependencies', async t t.equal(edgeToA.to, packageA, 'the edge from b should point to package a') }) }) + +t.test('peer dependency resolution is order-independent', async t => { + // Test for https://github.com/npm/cli/issues/8492 + // Ensures that installing packages in different orders produces + // the same dependency tree (deterministic peer dependency resolution) + + createRegistry(t, true) + + // Create two identical test directories + const createTestDir = () => t.testdir({ + 'package.json': JSON.stringify({ + name: 'test-app', + version: '1.0.0', + dependencies: {} + }) + }) + + const path1 = createTestDir() + const path2 = createTestDir() + + // Install packages in different orders + // Order 1: a, b, c + const arb1 = newArb(path1) + await arb1.buildIdealTree({ + add: ['abbrev', 'wrappy', 'once'] + }) + + // Order 2: c, b, a (reverse) + const arb2 = newArb(path2) + await arb2.buildIdealTree({ + add: ['once', 'wrappy', 'abbrev'] + }) + + // Both trees should have the same structure + const tree1 = printTree(arb1.idealTree) + const tree2 = printTree(arb2.idealTree) + + t.equal(tree1, tree2, 'trees should be identical regardless of install order') + + // Verify package.json has packages in alphabetical order + const pkg1 = JSON.parse(fs.readFileSync(join(path1, 'package.json'), 'utf8')) + const pkg2 = JSON.parse(fs.readFileSync(join(path2, 'package.json'), 'utf8')) + + const deps1 = Object.keys(pkg1.dependencies || {}) + const deps2 = Object.keys(pkg2.dependencies || {}) + + t.same(deps1, deps2, 'package.json dependencies should be in same order') + t.same(deps1, deps1.slice().sort(), 'dependencies should be in alphabetical order') +})