From 3077b89acb350250f80b90f1cbef9146dd06c058 Mon Sep 17 00:00:00 2001 From: Vishal Doshi Date: Tue, 5 May 2026 00:57:57 +0530 Subject: [PATCH] fix(arborist): ignore hidden entries in global update (#9299) ## Description Fixes #9298. `buildIdealTree` handles global updates by reading the global `node_modules` folder directly and adding each entry as a synthetic top-level dependency. That path did not ignore hidden entries, so a directory like `.hidden-non-package` was converted to `.hidden-non-package@*` and rejected by `npm-package-arg`. This applies the same hidden-entry filter already used by `loadActual`, so hidden directories and retired scoped package folders are not treated as installed global packages during `npm up -g`. ## Testing ```sh node node_modules/tap/bin/run.js workspaces/arborist/test/arborist/build-ideal-tree.js -g "update global ignores hidden" ``` ```sh npm_config_prefix=/tmp/npm-hidden-global-patched.azXmRd node . up -g ``` ```sh node . run eslint -- workspaces/arborist/lib/arborist/build-ideal-tree.js workspaces/arborist/test/arborist/build-ideal-tree.js ``` (cherry picked from commit 32940e231e34121aaf378309361b4b34fafdf939) --- .../arborist/lib/arborist/build-ideal-tree.js | 5 +++ .../test/arborist/build-ideal-tree.js | 38 +++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/workspaces/arborist/lib/arborist/build-ideal-tree.js b/workspaces/arborist/lib/arborist/build-ideal-tree.js index 6f6139c20fa36..b5970e60d1232 100644 --- a/workspaces/arborist/lib/arborist/build-ideal-tree.js +++ b/workspaces/arborist/lib/arborist/build-ideal-tree.js @@ -446,6 +446,11 @@ module.exports = cls => class IdealTreeBuilder extends cls { const paths = await readdirScoped(nm).catch(() => []) for (const p of paths) { const name = p.replace(/\\/g, '/') + // Match loadActual behavior: hidden entries and retired scoped package + // folders are not installed global packages. + if (/^(@[^/]+\/)?\./.test(name)) { + continue + } const updateName = this[_updateNames].includes(name) if (this[_updateAll] || updateName) { if (updateName) { diff --git a/workspaces/arborist/test/arborist/build-ideal-tree.js b/workspaces/arborist/test/arborist/build-ideal-tree.js index a25c5798fdca4..53abe5967f4b2 100644 --- a/workspaces/arborist/test/arborist/build-ideal-tree.js +++ b/workspaces/arborist/test/arborist/build-ideal-tree.js @@ -2278,6 +2278,44 @@ t.test('update global when nothing in global', async t => { 'update with empty node_modules') }) +t.test('update global ignores hidden node_modules entries', async t => { + const path = t.testdir({ + node_modules: { + '.hidden-non-package': { + node_modules: {}, + }, + '@scope': { + '.retired-package': { + node_modules: {}, + }, + }, + once: { + 'package.json': JSON.stringify({ + name: 'once', + version: '1.3.1', + dependencies: { + wrappy: '1', + }, + }), + node_modules: { + wrappy: { + 'package.json': JSON.stringify({ + name: 'wrappy', + version: '1.0.1', + }), + }, + }, + }, + }, + }) + createRegistry(t, true) + const tree = await buildIdeal(path, { global: true, update: true }) + const deps = tree.target.package.dependencies + t.notOk(deps['.hidden-non-package'], 'hidden entries are not queued for global update') + t.notOk(deps['@scope/.retired-package'], 'retired scoped entries are not queued for global update') + t.equal(deps.once, '*', 'visible global packages are queued for global update') +}) + t.test('peer dep that needs to be replaced', async t => { // this verifies that the webpack 5 that gets placed by default for // the initial dep will be successfully replaced by webpack 4 that