@@ -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