@@ -8,9 +8,10 @@ const promiseAllRejectLate = require('promise-all-reject-late')
88const runScript = require ( '@npmcli/run-script' )
99const { callLimit : promiseCallLimit } = require ( 'promise-call-limit' )
1010const { depth : dfwalk } = require ( 'treeverse' )
11- const { dirname, resolve, relative, join } = require ( 'node:path' )
11+ const { dirname, resolve, relative, join, sep } = require ( 'node:path' )
1212const { log, time } = require ( 'proc-log' )
13- const { lstat, mkdir, rm, symlink } = require ( 'node:fs/promises' )
13+ const { existsSync } = require ( 'node:fs' )
14+ const { lstat, mkdir, readdir, rm, symlink } = require ( 'node:fs/promises' )
1415const { moveFile } = require ( '@npmcli/fs' )
1516const { subset, intersects } = require ( 'semver' )
1617const { walkUp } = require ( 'walk-up-path' )
@@ -73,6 +74,7 @@ module.exports = cls => class Reifier extends cls {
7374 #shrinkwrapInflated = new Set ( )
7475 #sparseTreeDirs = new Set ( )
7576 #sparseTreeRoots = new Set ( )
77+ #linkedActualForDiff = null
7678
7779 constructor ( options ) {
7880 super ( options )
@@ -114,15 +116,20 @@ module.exports = cls => class Reifier extends cls {
114116 // of Node/Link trees
115117 log . warn ( 'reify' , 'The "linked" install strategy is EXPERIMENTAL and may contain bugs.' )
116118 this . idealTree = await this . createIsolatedTree ( )
119+ this . #linkedActualForDiff = this . #buildLinkedActualForDiff(
120+ this . idealTree , this . actualTree
121+ )
117122 }
118123 await this [ _diffTrees ] ( )
119124 await this . #reifyPackages( )
120125 if ( linked ) {
126+ await this . #cleanOrphanedStoreEntries( )
121127 // swap back in the idealTree
122128 // so that the lockfile is preserved
123129 this . idealTree = oldTree
124130 }
125131 await this [ _saveIdealTree ] ( options )
132+ this . #linkedActualForDiff = null
126133 // clean inert
127134 for ( const node of this . idealTree . inventory . values ( ) ) {
128135 if ( node . inert ) {
@@ -424,9 +431,14 @@ module.exports = cls => class Reifier extends cls {
424431 if ( ideal ) {
425432 filterNodes . push ( ideal )
426433 }
427- const actual = this . actualTree . children . get ( ws )
428- if ( actual ) {
429- filterNodes . push ( actual )
434+ // Skip actual-side filterNodes when using the linked diff wrapper.
435+ // Those nodes have root===actualTree, not root===linkedActualForDiff, and Diff.calculate requires filterNode.root to match actual.
436+ // The ideal filterNode alone is sufficient to scope the workspace diff.
437+ if ( ! this . #linkedActualForDiff) {
438+ const actual = this . actualTree . children . get ( ws )
439+ if ( actual ) {
440+ filterNodes . push ( actual )
441+ }
430442 }
431443 }
432444 }
@@ -448,7 +460,7 @@ module.exports = cls => class Reifier extends cls {
448460 omit : this . #omit,
449461 shrinkwrapInflated : this . #shrinkwrapInflated,
450462 filterNodes,
451- actual : this . actualTree ,
463+ actual : this . #linkedActualForDiff || this . actualTree ,
452464 ideal : this . idealTree ,
453465 } )
454466
@@ -571,6 +583,7 @@ module.exports = cls => class Reifier extends cls {
571583 // if the directory already exists, made will be undefined. if that's the case
572584 // we don't want to remove it because we aren't the ones who created it so we
573585 // omit it from the #sparseTreeRoots
586+ /* istanbul ignore next -- pre-existing: mkdir returns undefined when dir exists, covered in reify tests but lost in aggregate coverage merge */
574587 if ( made ) {
575588 this . #sparseTreeRoots. add ( made )
576589 }
@@ -787,6 +800,100 @@ module.exports = cls => class Reifier extends cls {
787800 return join ( filePath )
788801 }
789802
803+ // Build a flat actual tree wrapper for linked installs so the diff can correctly match store entries that already exist on disk.
804+ // The proxy tree from createIsolatedTree() is flat (all children on root), but loadActual() produces a nested tree where store entries are deep link targets.
805+ // This wrapper surfaces them at the root level for comparison.
806+ #buildLinkedActualForDiff ( idealTree , actualTree ) {
807+ // Combined Map keyed by path (how allChildren() in diff.js keys)
808+ const combined = new Map ( )
809+
810+ // Add actual tree's children (the top-level symlinks)
811+ for ( const child of actualTree . children . values ( ) ) {
812+ combined . set ( child . path , child )
813+ }
814+
815+ // Add synthetic entries for store nodes and store links that exist on disk.
816+ // The proxy tree is flat: all store entries (isInStore) and store links (isStoreLink) are direct children of root.
817+ // The actual tree only has top-level links as root children, so store entries need synthetic actual entries for the diff to match them.
818+ for ( const child of idealTree . children . values ( ) ) {
819+ if ( ! combined . has ( child . path ) && ( child . isInStore || child . isStoreLink ) &&
820+ existsSync ( child . path ) ) {
821+ const entry = {
822+ global : false ,
823+ globalTop : false ,
824+ isProjectRoot : false ,
825+ isTop : false ,
826+ location : child . location ,
827+ name : child . name ,
828+ optional : child . optional ,
829+ top : child . top ,
830+ children : [ ] ,
831+ edgesIn : new Set ( ) ,
832+ edgesOut : new Map ( ) ,
833+ binPaths : [ ] ,
834+ fsChildren : [ ] ,
835+ /* istanbul ignore next -- emulate Node */
836+ getBundler ( ) {
837+ return null
838+ } ,
839+ hasShrinkwrap : false ,
840+ inDepBundle : false ,
841+ integrity : null ,
842+ isLink : Boolean ( child . isLink ) ,
843+ isRoot : false ,
844+ isInStore : Boolean ( child . isInStore ) ,
845+ path : child . path ,
846+ realpath : child . realpath ,
847+ resolved : child . resolved ,
848+ version : child . version ,
849+ package : child . package ,
850+ }
851+ entry . target = entry
852+ if ( child . isLink && combined . has ( child . realpath ) ) {
853+ entry . target = combined . get ( child . realpath )
854+ }
855+ combined . set ( child . path , entry )
856+ }
857+ }
858+
859+ // Proxy .get(name) to original actual tree for filterNodes compatibility
860+ // (scoped workspace installs use .get(name), allChildren uses .values())
861+ const origGet = actualTree . children . get . bind ( actualTree . children )
862+ const combinedGet = combined . get . bind ( combined )
863+ /* istanbul ignore next -- only reached during scoped workspace installs */
864+ combined . get = ( key ) => combinedGet ( key ) || origGet ( key )
865+
866+ const wrapper = {
867+ isRoot : true ,
868+ isLink : actualTree . isLink ,
869+ target : actualTree . target ,
870+ fsChildren : actualTree . fsChildren ,
871+ path : actualTree . path ,
872+ realpath : actualTree . realpath ,
873+ edgesOut : actualTree . edgesOut ,
874+ inventory : actualTree . inventory ,
875+ package : actualTree . package ,
876+ resolved : actualTree . resolved ,
877+ version : actualTree . version ,
878+ integrity : actualTree . integrity ,
879+ binPaths : actualTree . binPaths ,
880+ hasShrinkwrap : false ,
881+ inDepBundle : false ,
882+ parent : null ,
883+ children : combined ,
884+ }
885+
886+ // Set parent/root on synthetic entries for consistency
887+ for ( const child of combined . values ( ) ) {
888+ if ( ! child . parent ) {
889+ child . parent = wrapper
890+ child . root = wrapper
891+ }
892+ }
893+
894+ return wrapper
895+ }
896+
790897 #registryResolved ( resolved ) {
791898 // the default registry url is a magic value meaning "the currently
792899 // configured registry".
@@ -1245,6 +1352,41 @@ module.exports = cls => class Reifier extends cls {
12451352 timeEnd ( )
12461353 }
12471354
1355+ // After a linked install, scan node_modules/.store/ and remove any directories that are not referenced by the current ideal tree.
1356+ // Store entries become orphaned when dependencies are updated or removed, because the diff never sees the old store keys.
1357+ async #cleanOrphanedStoreEntries ( ) {
1358+ const storeDir = resolve ( this . path , 'node_modules' , '.store' )
1359+ let entries
1360+ try {
1361+ entries = await readdir ( storeDir )
1362+ } catch {
1363+ return
1364+ }
1365+
1366+ // Collect valid store keys from the isolated ideal tree (location: node_modules/.store/{key}/node_modules/{pkg})
1367+ const validKeys = new Set ( )
1368+ for ( const child of this . idealTree . children . values ( ) ) {
1369+ if ( child . isInStore ) {
1370+ const key = child . location . split ( sep ) [ 2 ]
1371+ validKeys . add ( key )
1372+ }
1373+ }
1374+
1375+ const orphaned = entries . filter ( e => ! validKeys . has ( e ) )
1376+ if ( ! orphaned . length ) {
1377+ return
1378+ }
1379+
1380+ log . silly ( 'reify' , 'cleaning orphaned store entries' , orphaned )
1381+ await promiseAllRejectLate (
1382+ orphaned . map ( e =>
1383+ rm ( resolve ( storeDir , e ) , { recursive : true , force : true } )
1384+ . catch ( /* istanbul ignore next -- rm with force rarely fails */
1385+ er => log . warn ( 'cleanup' , `Failed to remove orphaned store entry ${ e } ` , er ) )
1386+ )
1387+ )
1388+ }
1389+
12481390 // last but not least, we save the ideal tree metadata to the package-lock
12491391 // or shrinkwrap file, and any additions or removals to package.json
12501392 async [ _saveIdealTree ] ( options ) {
0 commit comments