From 58e6150fde40064698d711fdfb2f1acddf66d666 Mon Sep 17 00:00:00 2001 From: Sanjays2402 <51058514+Sanjays2402@users.noreply.github.com> Date: Tue, 30 Jun 2026 07:01:52 -0700 Subject: [PATCH] fix(outdated): report aliased global packages against the aliased registry name Closes #9706 When a package is installed globally via an alias spec (`npm install -g cat@npm:dog@1.0.1`), the outdated command iterated the global root's children as Nodes rather than Edges, so `edge.spec` was undefined and the existing alias detection path was skipped. The lookup then fell through to the wrapper directory name, which either resolved against the wrong packument or produced no row at all. Detect this case by checking that the wrapper directory name (`node.name`) differs from the actual package name (`node.packageName`), synthesize an `npm:@` spec, and reuse the existing alias code path so Wanted / Latest resolve against the aliased package's packument. The row name now reads `@npm:@` so users can tell which on-disk package is being reported on. --- lib/commands/outdated.js | 26 ++++++++++++++-- test/lib/commands/outdated.js | 56 +++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 3 deletions(-) diff --git a/lib/commands/outdated.js b/lib/commands/outdated.js index 714b3ab64da4d..34410d938f72a 100644 --- a/lib/commands/outdated.js +++ b/lib/commands/outdated.js @@ -156,7 +156,23 @@ class Outdated extends ArboristWorkspaceCmd { } async #getOutdatedInfo (edge) { - const alias = safeNpa(edge.spec)?.subSpec + // Global packages are iterated as Nodes (no parent declares them as deps), + // so `edge.spec` is undefined. Detect alias installs (e.g. + // `npm install -g cat@npm:dog@1.0.1`) by comparing the directory name + // (`node.name`) to the package's own name (`node.packageName`), and + // synthesize an alias spec so the registry lookup below targets the + // aliased package instead of the wrapper directory name. + // See https://github.com/npm/cli/issues/9706 + const isGlobalAliasNode = !edge.spec + && edge.packageName + && edge.name !== edge.packageName + && edge.version + const synthesizedSpec = isGlobalAliasNode + ? `npm:${edge.packageName}@${edge.version}` + : null + const effectiveEdgeSpec = synthesizedSpec ?? edge.spec + + const alias = safeNpa(effectiveEdgeSpec)?.subSpec const spec = npa(alias ? alias.name : edge.name) const node = edge.to || edge const { path, location, package: { version: current } = {} } = node @@ -178,7 +194,7 @@ class Outdated extends ArboristWorkspaceCmd { } // if it's not a range, version, or tag, skip it - if (!safeNpa(`${edge.name}@${edge.spec}`)?.registry) { + if (!safeNpa(`${edge.name}@${effectiveEdgeSpec}`)?.registry) { return null } @@ -195,7 +211,11 @@ class Outdated extends ArboristWorkspaceCmd { const latest = pickManifest(packument, '*', pickOpts) if (!current || current !== wanted.version || wanted.version !== latest.version) { this.#list.push({ - name: alias ? edge.spec.replace('npm', edge.name) : edge.name, + name: alias + ? (synthesizedSpec + ? `${edge.name}@${synthesizedSpec}` + : edge.spec.replace('npm', edge.name)) + : edge.name, path, type, current, diff --git a/test/lib/commands/outdated.js b/test/lib/commands/outdated.js index 34b8ea75190fc..35b5ad780dba1 100644 --- a/test/lib/commands/outdated.js +++ b/test/lib/commands/outdated.js @@ -803,3 +803,59 @@ t.test('min-release-age-exclude', async t => { t.match(joinedOutput(), '2.0.0', 'glob-excluded package shows newer version') }) }) + +t.test('global alias packages report alias spec, not aliased package version', async t => { + // Regression test for https://github.com/npm/cli/issues/9706 + // When a package is installed globally via an alias spec + // (e.g. `npm install -g cowsay@npm:dog@1.0.1`), `npm outdated -g` was + // resolving the wrapper directory name against the registry instead of + // the aliased package, so the `Current` column showed the aliased + // package version while `Wanted`/`Latest` came from a totally + // different packument. + const globalAliasFixture = { + node_modules: { + cat: { + 'package.json': JSON.stringify({ + name: 'dog', + version: '1.0.1', + _from: 'cat@npm:dog@1.0.1', + _requested: { + type: 'alias', + name: 'cat', + escapedName: 'cat', + rawSpec: 'npm:dog@1.0.1', + saveSpec: 'npm:dog@1.0.1', + fetchSpec: 'latest', + subSpec: { + type: 'version', + registry: true, + name: 'dog', + escapedName: 'dog', + rawSpec: '1.0.1', + saveSpec: null, + fetchSpec: '1.0.1', + }, + }, + }, null, 2), + }, + }, + } + + const { outdated, joinedOutput } = await mockNpm(t, { + globalPrefixDir: globalAliasFixture, + config: { global: true }, + }) + await outdated.exec([]) + + const out = joinedOutput() + // The row must describe the alias (`cat@npm:dog`) so users can tell + // which package on disk is being reported, and the Latest column must + // come from the aliased package's packument (`dog`@2.0.0), not from + // the wrapper name (`cat` would resolve to its own 1.0.1 latest). + t.match(out, /cat@npm:dog/, + 'output identifies the row by its alias spec') + t.match(out, /npm:dog@1\.0\.1/, + 'Current column reflects the aliased package version with alias prefix') + t.match(out, /\b2\.0\.0\b/, + 'Latest column comes from the aliased package\'s packument (dog@2.0.0)') +})