Skip to content

Commit bb135cc

Browse files
fix(arborist): fix peerOptional dependency resolution in buildIdealTree (#8981)
Fixes #8726, fixes #6787 Tested on Windows & NodeJS v24.12.0 with `package.json` from #8726 (comment): ```json { "name": "testcase", "version": "1.0.0", "devDependencies": { "addons-linter": "6.13.0", "htmlhint": "1.1.4" } } ``` --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 7aa9338 commit bb135cc

4 files changed

Lines changed: 539 additions & 6 deletions

File tree

test/lib/commands/ci.js

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,3 +308,142 @@ t.test('should use --workspace flag', async t => {
308308
assert.packageMissing('node_modules/abbrev@1.1.0')
309309
assert.packageInstalled('node_modules/lodash@1.1.1')
310310
})
311+
312+
// Issue #8726 - npm ci fails because npm install produces an out-of-sync lockfile
313+
// https://github.com/npm/cli/issues/8726
314+
//
315+
// Root cause: an optional peerDependency at an exact version causes buildIdealTree() to resolve a different version than what's in the lockfile.
316+
//
317+
// Pattern (mirrors real-world addons-linter / htmlhint / node-fetch scenario):
318+
// - scanner@1.0.0 has optional peerDep: fetcher@1.0.0 (exact version)
319+
// - hint@1.0.0 has regular dep: fetcher@^1.0.0 (semver range)
320+
// - npm install resolves fetcher to 1.1.0 (latest matching ^1.0.0)
321+
// - npm ci's buildIdealTree sees fetcher@1.1.0 doesn't satisfy the exact peerDep "1.0.0", treats it as a problem edge, resolves fetcher to 1.0.0
322+
// - validateLockfile: lockfile has 1.1.0, ideal tree has 1.0.0 → MISMATCH
323+
324+
t.test('issue-8726: npm ci with optional peerDep causing lockfile version mismatch', async t => {
325+
// Pre-built lockfile locks fetcher@1.1.0 (what npm install would pick).
326+
// scanner has optional peerDep fetcher@1.0.0 (exact version).
327+
// buildIdealTree should respect the lockfile version, but the bug causes it to resolve fetcher to 1.0.0, failing validateLockfile.
328+
const { npm, registry } = await loadMockNpm(t, {
329+
config: { audit: false, 'ignore-scripts': true },
330+
strictRegistryNock: false,
331+
prefixDir: {
332+
'linter-tarball': {
333+
'package.json': JSON.stringify({
334+
name: 'linter',
335+
version: '1.0.0',
336+
dependencies: { scanner: '1.0.0' },
337+
}),
338+
},
339+
'scanner-tarball': {
340+
'package.json': JSON.stringify({
341+
name: 'scanner',
342+
version: '1.0.0',
343+
peerDependencies: { fetcher: '1.0.0' },
344+
peerDependenciesMeta: { fetcher: { optional: true } },
345+
}),
346+
},
347+
'hint-tarball': {
348+
'package.json': JSON.stringify({
349+
name: 'hint',
350+
version: '1.0.0',
351+
dependencies: { fetcher: '^1.0.0' },
352+
}),
353+
},
354+
'fetcher-1.0.0-tarball': {
355+
'package.json': JSON.stringify({ name: 'fetcher', version: '1.0.0' }),
356+
},
357+
'fetcher-1.1.0-tarball': {
358+
'package.json': JSON.stringify({ name: 'fetcher', version: '1.1.0' }),
359+
},
360+
'package.json': JSON.stringify({
361+
name: 'test-package',
362+
version: '1.0.0',
363+
devDependencies: {
364+
linter: '1.0.0',
365+
hint: '1.0.0',
366+
},
367+
}),
368+
'package-lock.json': JSON.stringify({
369+
name: 'test-package',
370+
version: '1.0.0',
371+
lockfileVersion: 3,
372+
requires: true,
373+
packages: {
374+
'': {
375+
name: 'test-package',
376+
version: '1.0.0',
377+
devDependencies: { linter: '1.0.0', hint: '1.0.0' },
378+
},
379+
'node_modules/linter': {
380+
version: '1.0.0',
381+
resolved: 'https://registry.npmjs.org/linter/-/linter-1.0.0.tgz',
382+
dev: true,
383+
dependencies: { scanner: '1.0.0' },
384+
},
385+
'node_modules/scanner': {
386+
version: '1.0.0',
387+
resolved: 'https://registry.npmjs.org/scanner/-/scanner-1.0.0.tgz',
388+
dev: true,
389+
peerDependencies: { fetcher: '1.0.0' },
390+
peerDependenciesMeta: { fetcher: { optional: true } },
391+
},
392+
'node_modules/hint': {
393+
version: '1.0.0',
394+
resolved: 'https://registry.npmjs.org/hint/-/hint-1.0.0.tgz',
395+
dev: true,
396+
dependencies: { fetcher: '^1.0.0' },
397+
},
398+
'node_modules/fetcher': {
399+
version: '1.1.0',
400+
resolved: 'https://registry.npmjs.org/fetcher/-/fetcher-1.1.0.tgz',
401+
dev: true,
402+
},
403+
},
404+
}),
405+
},
406+
})
407+
408+
// With the fix, buildIdealTree no longer treats the invalid peerOptional edge as a problem, so npm ci proceeds to reification and needs tarballs.
409+
const linterManifest = registry.manifest({ name: 'linter' })
410+
await registry.tarball({
411+
manifest: linterManifest.versions['1.0.0'],
412+
tarball: path.join(npm.prefix, 'linter-tarball'),
413+
})
414+
415+
const scannerManifest = registry.manifest({ name: 'scanner' })
416+
await registry.tarball({
417+
manifest: scannerManifest.versions['1.0.0'],
418+
tarball: path.join(npm.prefix, 'scanner-tarball'),
419+
})
420+
421+
const hintManifest = registry.manifest({ name: 'hint' })
422+
await registry.tarball({
423+
manifest: hintManifest.versions['1.0.0'],
424+
tarball: path.join(npm.prefix, 'hint-tarball'),
425+
})
426+
427+
const fetcherManifest = registry.manifest({
428+
name: 'fetcher',
429+
versions: ['1.0.0', '1.1.0'],
430+
})
431+
await registry.tarball({
432+
manifest: fetcherManifest.versions['1.1.0'],
433+
tarball: path.join(npm.prefix, 'fetcher-1.1.0-tarball'),
434+
})
435+
436+
// npm ci should succeed - the lockfile is valid and the fix ensures the peerOptional edge doesn't cause a version mismatch.
437+
await npm.exec('ci', [])
438+
439+
const installedFetcher = JSON.parse(
440+
fs.readFileSync(
441+
path.join(npm.prefix, 'node_modules', 'fetcher', 'package.json'), 'utf8'
442+
)
443+
)
444+
t.equal(
445+
installedFetcher.version,
446+
'1.1.0',
447+
'installed the locked fetcher version, not the peer dep version'
448+
)
449+
})

0 commit comments

Comments
 (0)