Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions workspaces/arborist/lib/arborist/build-ideal-tree.js
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
49 changes: 49 additions & 0 deletions workspaces/arborist/test/arborist/build-ideal-tree.js
Original file line number Diff line number Diff line change
Expand Up @@ -4460,3 +4460,52 @@
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: {}

Check failure on line 4476 in workspaces/arborist/test/arborist/build-ideal-tree.js

View workflow job for this annotation

GitHub Actions / Lint

Missing trailing comma
})

Check failure on line 4477 in workspaces/arborist/test/arborist/build-ideal-tree.js

View workflow job for this annotation

GitHub Actions / Lint

Missing trailing comma
})

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']

Check failure on line 4487 in workspaces/arborist/test/arborist/build-ideal-tree.js

View workflow job for this annotation

GitHub Actions / Lint

Missing trailing comma
})

// Order 2: c, b, a (reverse)
const arb2 = newArb(path2)
await arb2.buildIdealTree({
add: ['once', 'wrappy', 'abbrev']

Check failure on line 4493 in workspaces/arborist/test/arborist/build-ideal-tree.js

View workflow job for this annotation

GitHub Actions / Lint

Missing trailing comma
})

// 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')
})
Loading