Skip to content

[pull] latest from npm:latest#10

Open
pull[bot] wants to merge 2160 commits into
anhy999:latestfrom
npm:latest
Open

[pull] latest from npm:latest#10
pull[bot] wants to merge 2160 commits into
anhy999:latestfrom
npm:latest

Conversation

@pull

@pull pull Bot commented Aug 8, 2022

Copy link
Copy Markdown

See Commits and Changes for more details.


Created by pull[bot]

Can you help keep this open source service alive? 💖 Please sponsor : )

@changeset-bot

changeset-bot Bot commented Aug 8, 2022

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 86416a6

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

manzoorwanijk and others added 15 commits March 16, 2026 14:01
# fix: clear exclusive sibling configs from env when one is set via CLI

## What's the problem?

If you set an exclusive param via CLI (e.g. `--save-prod`) but a sibling
(`npm_config_save_dev=true`) is already in the environment, child
processes
inherit both and crash with a conflict. This was also the root cause of
the
`--min-release-age` + `--before` issue in #9005.

## What changed

When `setEnvs` exports a non-default exclusive config, it now resets
that
param's siblings to their defaults in the env — so child processes never
see a conflict. Works generically for all exclusive pairs, not just this
one.

## Tests

Added a test for the case where `save-prod` is set via CLI while
`save-dev`
is already in env — verifies `save-dev` gets reset to its default.

## References

Fixes #9005
manzoorwanijk and others added 30 commits June 19, 2026 07:42
)

Adds a regression test for #8875. The fix is in npm-profile
(npm/npm-profile#191).

This test is expected to be red until bundled `npm-profile` is bumped to
the release with the fix, and turns green after that.

## Why

`npm login --auth-type=web` silently fails behind a proxy/mirror: the
returned `doneUrl` points at the canonical `registry.npmjs.org` instead
of the proxy, so npm polls the wrong host, gets a `403`, and falls back
to couch auth (which also fails). Fixed in npm-profile by rewriting
`doneUrl` to the configured registry origin.

## How

- `@npmcli/mock-registry`: `weblogin()` gains an optional `doneRegistry`
to emulate a proxy advertising a `doneUrl` on another origin.
- `test/lib/commands/login.js`: proxy registry whose `doneUrl` is on
`registry.npmjs.org`; asserts the token is saved with no couch fallback.
Fails with the current bundled npm-profile, passes once it is bumped.

## References

Fixes #8875
Depends on: npm/npm-profile#191
Related: #9550
…the root (#9592)

`npm link <path> --workspace=<ws>` did not scope the operation to the
target workspace.
The linked dependency was attached to the root project instead, and with
`--save` (or `--save-dev`/`--save-optional`/etc.) the `file:` spec was
written to the root `package.json` rather than `<ws>/package.json`.
`npm install <path> --workspace=<ws> --save` behaved correctly, so only
`npm link` was affected.

The cause is in `Link.linkInstall()` in `lib/commands/link.js`, which
built the local Arborist without the `workspaces` option and passed
`workspaces` only to `reify()`.
Arborist decides which node receives the `add` request from
`this.options.workspaces`, which is captured at construction time, not
from the reify-time option.
`buildIdealTree` merges reify options into a local variable and
`#parseSettings` never copies `options.workspaces` into `this.options`,
so the reify-time value never reached `#applyUserRequests`.
With `this.options.workspaces` left empty, the `add` and the save were
applied to the root node.

The fix passes `workspaces: this.workspaceNames` to the local Arborist
constructor, matching how `lib/commands/install.js` already does it.
Physical placement is unchanged: under the default `hoisted` strategy
the symlink still hoists to the root `node_modules`, identical to `npm
install --workspace`.

## References

<!-- List any relevant issues, pull requests, or external references -->
Fixes #9590
Related #9589
…irs (#9586)

Implements the accepted RFC
[npm/rfcs#903](npm/rfcs#903): a root-owned
`.npm-extension.mjs` / `.npm-extension.cjs` file exporting
`transformManifest(pkg, context)` that imperatively repairs third-party
dependency manifests **before** Arborist finalizes the ideal tree. It is
the imperative counterpart to [`packageExtensions`
(#9496)](#9496) and operates in the same
pre-resolution phase, running **before** `packageExtensions`.

```js
// .npm-extension.mjs
export function transformManifest(pkg, context) {
  if (pkg.name === "foo" && pkg.version.startsWith("1.")) {
    pkg.dependencies = { ...pkg.dependencies, bar: "^2.0.0" };
    context.log("added bar to foo@1");
  }
  return pkg;
}
```

## Why

`packageExtensions` is declarative JSON: it cannot carry comments or
issue links, repeats itself across many packages, is add-only, and lives
in `package.json` (so public packages cannot publish while it is
present). `.npm-extension` covers the gap for advanced projects that
need conditional repairs, deletion, range rewrites, repeated rules
expressed as code, stale-repair guards, and a policy location outside
the published manifest.

## What it does

- **Discovery** — one root `.npm-extension.mjs` or `.npm-extension.cjs`
(both present is an error). The `extension-file` config overrides
discovery with a project-local path that must resolve inside the project
root and use `.mjs`/`.cjs`.
- **`transformManifest(pkg, context)`** — receives a deeply isolated
copy of the normalized manifest; `context` exposes `log`, `root`, and
`extensionPoint`. Must return a manifest synchronously;
`null`/primitive/array/promise returns and throws fail the install with
a `.npm-extension`-named error.
- **Allowlist** — only `dependencies`, `optionalDependencies`,
`peerDependencies`, and `peerDependenciesMeta` may change (add, replace,
or delete). Any other changed field (`scripts`, `bin`, …) is rejected.
pacote's cached manifest is never mutated.
- **Caching** — runs at most once per resolved package identity
(integrity, else resolved source + name@version); the entry file is
hash-keyed so a changed file is reloaded rather than served stale from
the module cache.
- **Lockfile** — the root entry records `npmExtensionHash` (a
format-tagged digest of the file bytes); affected entries record minimal
`npmExtensionApplied` provenance. Extension state reuses the existing
`lockfileVersion: 4` threshold.
- **Re-resolution** — changing or removing the file re-resolves the
affected packages on the next `npm install`, reverting transforms that
no longer apply.
- **`npm ci`** — never imports or executes the file; it validates the
recorded hash and reifies the locked graph (which already carries the
extension-influenced edges).
- **Configs** — `ignore-extension` disables import/execution;
`ignore-scripts` implies it; `extension-file` is honored only from
project config or the command line, never from user/global/builtin
sources.
- **Workspaces** — a `.npm-extension` file in a non-root workspace is
ignored with a warning; only the workspace root's file is honored.
- **Visibility** — `npm explain` annotates extension-changed edges and
`npm ls` (human + `--json`) surfaces the provenance.
- **Publish** — companion change in `npm-packlist` force-excludes root
`.npm-extension.{mjs,cjs}` from package tarballs.

## Companion change

Requires
[npm/npm-packlist#294](npm/npm-packlist#294) to
exclude root `.npm-extension.{mjs,cjs}` from tarballs. `pacote`/CLI will
pick this up via a version bump once that publishes.

## Notes / out of scope for this PR

One item is deferred for a genuine structural reason the RFC itself
flags:

- **Local `file:`/`link:`/directory sources.** `transformManifest`
applies to fetched manifests (registry, git, remote tarball, `file:`
tarballs) and is re-derived on the installed tree across all install
strategies including `install-strategy=linked`. It is **not yet**
applied to local sources that create `Link` nodes directly and bypass
the fetch phase — the RFC flags this as net-new wiring ("npm must add an
analogous pre-edge-read transform path for the `Link` target").
Follow-up.

## References

Implements npm/rfcs#903
Builds on #9496
Companion: npm/npm-packlist#294
…#9597)

Fixes #9562.

`strict-allow-scripts` walked the ideal tree and rejected inert optional
deps like `fsevents` on Linux, even though reify removes them before any
install script runs. The shared `collectUnreviewedScripts` walk now
skips inert nodes, so the strict check matches what actually installs.
`approve-scripts` (actual tree) was already correct.
Fixes #9558.

Registry deps with no `resolved` URL in `package-lock.json` have no
trustable version, so `approve-scripts` cannot pin them. It was silently
writing nothing and leaving them pending; now it approves them by name
(`pkg: true`) and warns why.
…9625)

In continuation of our exploration of using `install-strategy=linked` in
the [Gutenberg
monorepo](WordPress/gutenberg#75814), which

Under `install-strategy=linked`, `npm install --audit` reported `found 0
vulnerabilities` even with a known-vulnerable package installed, while
standalone `npm audit` reported it correctly. Only the install-time
audit was affected.

## Why

A linked reify swaps `idealTree` for the isolated tree
(`createIsolatedTree()`) before the quick audit runs, so
`_submitQuickAudit()` audited the isolated tree. That tree cannot be
audited: its inventory had a stub `query()` that always returned `[]`,
and its edges route through symlink `Link`s instead of real package
nodes. So `AuditReport.prepareBulkData()` produced an empty bulk request
and the registry was never asked about any installed version. Standalone
`npm audit` was unaffected because it audits the regular tree loaded
from the lockfile.

## How

`reify.js` stashes the original non-isolated ideal tree in
`#linkedIdealForAudit` during the linked swap, and `_submitQuickAudit()`
now audits `this.#linkedIdealForAudit || this.idealTree` — the same tree
standalone `npm audit` uses, with a queryable inventory and real package
nodes. The `_diffTrees()`/`#reifyPackages()`/orphan-sweep block is
wrapped in `try/finally` that restores `idealTree` and clears the
stashed references even if reify throws, so a reused Arborist never
audits or diffs a stale isolated tree. `isolated-classes.js` drops the
now-unused `IsolatedInventory` class (its only caller was the rerouted
audit path) in favor of a plain `Map`; the `query()` stub returning `[]`
was the silent-empty behavior behind this bug.

## References

Fixes #9609
Part of #9608
… edges (#9626)

In continuation of our exploration of using `install-strategy=linked` in
the [Gutenberg
monorepo](WordPress/gutenberg#75814), which
powers the WordPress Block Editor.

Under `install-strategy=linked`, `npm sbom` exited non-zero with
`ESBOMPROBLEMS`, reporting the devDependencies of transitive packages
(e.g. `matcha`, `tape`) as `missing: ... required by ...`. Those dev
dependencies are correctly not installed (the same is true under
hoisted), yet only the linked strategy treated them as
missing-and-required. Hoisted produced a clean SBOM for the same
dependency set.

## Why

A package in the linked strategy's store lives at
`node_modules/.store/<key>/node_modules/<pkg>`, which makes it a
structural tree top (`isTop`, no parent). `Node._loadDeps` loads
devDependencies for every top node, so each store package — a transitive
dependency whose devDependencies are never installed — gained required
`dev` edges. `npm sbom` reads the filesystem tree via `loadActual`,
queries it, and flags every non-optional missing edge, so those spurious
dev edges surfaced as `ESBOMPROBLEMS`. Standalone `npm audit` was
unaffected because it audits the virtual tree from the lockfile. Hoisted
was unaffected because its transitive packages have a real parent, so
they are not tops and never load devDependencies.

## How

`load-actual.js` now flags a node as `isInStore` when its realpath sits
inside a `node_modules/.store/` directory, matching the flag the
isolated reifier already sets on ideal-tree store nodes.
`Node._loadDeps` excludes store nodes from the devDependency load
(`isTop && !globalTop && path && !this.isInStore`), so a store package —
a transitive dependency by definition — never gains required dev edges.
This makes the linked actual tree's edge semantics match hoisted.

## References

Fixes #9610
Part of #9608
…ripts (#9629)

Add a namespaced `npm install-scripts` command that groups the
install-script approval operations, following npm's existing `npm cache
<cmd>` / `npm token <cmd>` convention:

- `npm install-scripts approve <pkg>... | --all`
- `npm install-scripts deny <pkg>... | --all`
- `npm install-scripts ls` (list packages with unreviewed install
scripts)

The shipped `npm approve-scripts` and `npm deny-scripts` commands keep
working as aliases for `approve` and `deny`, so this is additive and
backwards compatible. The shared `AllowScriptsCmd` base now dispatches
through `runMode(mode, args)`; the standalone commands route through it
via `static verb`. The `--allow-scripts-pending` flag is only honored by
the commands that declare it, so the namespace lists exclusively through
`ls`.

## References

Closes #9545
Follow-up from RFC npm/rfcs#868.
…9603)

**Description:**
Fixes #8989

**Bug:**
`npm audit` was occasionally generating non-deterministic output with
missing vulnerabilities when metavulns from identical-range dependencies
caused a collision.

**Root Cause:**
In `AuditReport#init`, the deduplication logic (`if (!seen.has(k))`) was
aggressively dropping identically-computed metavuln advisory ranges
across different dependency paths. Because `vuln.addVia()` was invoked
*inside* this block, a duplicated range collision would simply skip the
execution and permanently drop the `via` link (and its corresponding
`effects` link). Consequently, depending on the random `Promise.all`
resolution order of the metavuln calculator across network calls,
different `via` branches were being non-deterministically truncated.

**Fix:**
This PR extracts the `addVia` operation from the `seen` deduplication
block and implements a comprehensive post-loop reconciliation pass. We
ensure that:
1. `via` and `effects` links are deterministically established *after*
all dependencies and metavulns are cleanly instantiated.
2. The reconciliation ignores already deleted advisories, guaranteeing
that the cleanup lifecycle (`deleteAdvisory`) cannot be accidentally
resurrected.
3. Because we aren't repeatedly calling the `fixAvailable` setter during
the primary loop iterations, performance overhead remains minimized.

**Testing:**
- Added a new strict programmatic determinism test in `audit-report.js`
covering the identical range metavuln fallback (`*`) condition.
- Confirmed the new test correctly reproduces the regression (fails
deterministically on unfixed code by dropping the `via` path).
- All 27 existing suite tests pass flawlessly.
…9605)

## Problem

Arborist currently treats `save=false` as a signal that invalid
`peerOptional` edges from the lockfile can be trusted and skipped. That
is correct for non-mutating lockfile reads such as `npm ci`, but it is
wrong for commands that still mutate the ideal tree and write a new
lockfile while leaving `package.json` unchanged.

For example, `npm update` runs with `save=false`, but it can still
update package versions in `package-lock.json`. If one of those updates
introduces or exposes an invalid optional peer relationship, Arborist
can finish successfully and write a lockfile that a later plain `npm
install` rejects with `ERESOLVE`.

## Fix

This separates two concepts that were previously conflated:

- `#requestedTreeMutation`: whether the command explicitly requested an
add, remove, or update operation.
- `#mutateTree`: whether the ideal-tree build has actually changed the
tree during placement.

Invalid `peerOptional` policy now uses `#requestedTreeMutation` instead
of `#mutateTree`:

- `save=false` + explicit add/rm/update: invalid `peerOptional` edges
are treated as resolver problems.
- `save=false` + no requested mutation: invalid `peerOptional` edges
remain trusted, preserving the existing `npm ci`-style behavior.

The requeue path also uses `#requestedTreeMutation`, so a `save=false`
update can reprocess an already-seen node when a later placement
invalidates one of its `peerOptional` edges.

## Behavior Changed

This fixes bad lockfile output for `save=false` commands that mutate the
dependency graph, including:

- `npm update`
- `npm install <pkg> --no-save`
- other explicit add/rm/update Arborist builds with `save=false`

Those commands should not silently produce an ideal tree or lockfile
containing invalid `peerOptional` edges that a subsequent install
rejects.

## Behavior Preserved

Non-mutating `save=false` builds continue to trust the lockfile. In
those cases, an invalid `peerOptional` edge is still ignored rather than
forcing re-resolution, preserving the behavior needed by `npm ci`-style
lockfile reads.

Fixes #9604.

## Tests

Added regression coverage for:

- `npm update`-style `save=false` mutation rejecting an invalid
`peerOptional` graph instead of writing a bad lockfile.
- `npm install <pkg> --no-save`-style mutation rejecting the same class
of invalid graph.
- `save=false` update requeueing an already-seen node when later
placement invalidates its `peerOptional` edge.
- non-mutating `save=false` builds continuing to ignore invalid
`peerOptional` problem edges.

Validated with:

```sh
git diff --check
npm exec -- tap # from workspaces/arborist
npm run lint --workspace=@npmcli/arborist
```

Co-authored-by: Dale Lakes <6843636+spitfire55@users.noreply.github.com>
…#9630)

In continuation of our exploration of using `install-strategy=linked` in
the [Gutenberg
monorepo](WordPress/gutenberg#75814), which
powers the WordPress Block Editor.

Under `install-strategy=linked`, the hidden lockfile
`node_modules/.package-lock.json` recorded the hoisted logical layout
(`node_modules/<pkg>`) instead of the actual on-disk `.store`/symlink
layout. The hidden lockfile is meant to cache what `loadActual()` finds
on disk so the actual tree can be validated cheaply, but because it
recorded the wrong layout it was rejected on every reload, so it never
served as a cache and misrepresented the installed layout.

## Why

A linked reify swaps `idealTree` for the isolated tree, materializes the
`.store`/symlink layout, then swaps the logical tree back before saving.
The hidden lockfile was serialized from that logical tree, so it listed
packages at their hoisted paths. On the next load, `assertNoNewer()`
walked the real `node_modules` (the root symlink plus `.store/`) and
could not reconcile it with the hoisted entries, throwing `missing from
lockfile`, so `loadActual()` always fell back to a full filesystem scan.

## How

`reify.js` serializes the hidden lockfile from the isolated tree, which
mirrors the on-disk layout, while `package-lock.json` still comes from
the logical tree. It records every store package directory and symlink,
adds an entry for each `.store/<key>` container directory (these are the
fsParents `loadVirtual()` needs so a store package can resolve its
sibling deps), includes the workspace directories, and skips tree-only
undeclared-workspace self-links that are never materialized on disk.

`assertNoNewer()` additionally validates the directories the plain
`node_modules` walk cannot reach under the linked strategy: a store
package's deps live as symlinked siblings under
`.store/<key>/node_modules` (and `.store` is skipped as a dot-dir), and
an undeclared workspace is not symlinked into the root `node_modules` at
all. These directories are derived from the lockfile entries. A
workspace directory is only walked when it is not the target of a link
entry, so the hoisted strategy keeps its existing, stricter validation
unchanged — a stale workspace symlink that points at the wrong target
still surfaces as a missing entry and rejects the cache.

## References

Fixes #9612
Part of #9608
…9632)

In continuation of our exploration of using `install-strategy=linked` in
the [Gutenberg
monorepo](WordPress/gutenberg#75814), which
powers the WordPress Block Editor.

Under `install-strategy=linked`, `npm uninstall <pkg>` removed the
package's top-level symlink and its `.store` entry but left its shim in
`node_modules/.bin` behind as a dangling link. The leftover shim can
break tools that enumerate `node_modules/.bin`, shadow a later-installed
binary of the same name, and is not healed by a subsequent `npm
install`.

## Why

A linked reify builds the actual tree for the diff from the ideal tree
(`#buildLinkedActualForDiff`), so a removed dependency is never compared
against what is on disk and the diff emits no action to drop its bin
shim. The top-level symlink and store entry are already cleaned by the
post-reify sweep, but bin shims were not covered by it.

## How

`reify.js` `#cleanOrphanedStoreEntries` now also builds `binsByDir`:
while collecting the valid top-level links per `node_modules` directory,
it records the bin names each still-linked package provides from
`child.package.bin`. The new `#cleanStaleBinLinks` then removes any
`.bin` entry whose base name (after stripping a `.cmd`/`.ps1` suffix) is
not provided by a surviving package, or which is a dangling symlink.
Matching by name keeps the check cross-platform across POSIX symlink
shims and Windows `.cmd`/`.ps1` shim files, and reuses data already in
the ideal tree without adding a dependency. Shims for packages that
survive the uninstall are preserved.

## References

Fixes #9613
…ategy (#9628)

In continuation of our exploration of using `install-strategy=linked` in
the [Gutenberg
monorepo](WordPress/gutenberg#75814), which
powers the WordPress Block Editor.

Under `install-strategy=linked`, if a top-level `node_modules/<dep>`
symlink points to a store key that exists on disk but is the **wrong
version**, re-running `npm install` does not repair it. npm reports
success and leaves the dependency resolving to the wrong version. This
is the state an interrupted update leaves behind: the new store key is
extracted but the symlink has not yet been repointed.

A symlink pointing at a **non-existent** target is already repaired on
reinstall; only a wrong-but-existing target slips through, because
cleanup validates the link name, not its target.

## Why

For linked installs, `#buildLinkedActualForDiff` synthesizes the
"actual" tree the diff compares against from the **ideal** children,
never reading the real on-disk symlink target. So a link whose on-disk
target is a valid-but-wrong store key looks identical to the ideal node,
the diff reports no change, and the symlink is left untouched. The
hoisted strategy is unaffected because it self-heals the analogous
corruption.

## How

In `#buildLinkedActualForDiff`, when an existing link's resolved on-disk
target differs from its ideal target, skip creating a synthetic actual
entry for it. With no actual entry to match, the diff treats the link as
an `ADD`, and `#reifyNode` removes the old symlink and recreates it
pointing at the correct store key. A new `#linkTargetMismatch` helper
compares the two resolved targets; it runs only after the existing
`existsSync` guards, so both paths are known to exist.

This repairs both the top-level symlink and wrong transitive/sibling
links inside the store, and leaves already-correct trees untouched (no
spurious relinking on an idempotent reinstall).

## References

Fixes #9611
)

In continuation of our exploration of using `install-strategy=linked` in
the [Gutenberg
monorepo](WordPress/gutenberg#75814), which
powers the WordPress Block Editor.

Under `install-strategy=linked`, several common installs failed with
`npm error invalid filterNode: outside idealTree/actualTree`, with no
workaround besides dropping the linked strategy. Hoisted handled all of
them. This fixes two distinct paths that both produced that error.

## Why

A linked reify diffs the ideal tree against a synthesized actual wrapper
(`#linkedActualForDiff`) rather than `this.actualTree`. `Diff.calculate`
rejects any filter node whose root is neither the ideal nor the actual
it was given, so a filter node taken from the real `this.actualTree` is
"outside" the diff and throws.

Two places fed it such nodes:

- `--workspaces=false` and `-w <ws> --include-workspace-root` go through
the `includeRootDeps` branch of `_diffTrees()`, which collected root-dep
edge targets from both `this.idealTree` and `this.actualTree`. The
actual-side targets are rooted at the real actual tree, not the wrapper,
so they tripped the guard. The sibling `includeWorkspaces` branch
already accounted for this; the root-dep branch did not.

- A global install with a per-call `installStrategy: 'linked'`
re-engaged the linked path even though the constructor normalizes global
installs to `shallow` (the linked layout is unsupported for globals).
Re-installing an already-present global package then hit the global
explicit-request branch, which pushes actual-side nodes, and tripped the
same guard. Suppressing the crash there was worse: the isolated reifier
does not materialize the global layout and removed the package instead.

## How

`_diffTrees()` now iterates only the ideal tree for root-dep filter
nodes when the linked wrapper is in use, matching the existing
workspace-node handling. The ideal-side nodes are sufficient to scope
the diff, and the post-reify orphan sweep continues to prune deps
removed from the manifest.

`reify()` now honors the constructor's global-to-shallow normalization
when deriving the `linked` flag, so a global install never engages the
linked path regardless of a per-call `installStrategy`. Global installs
fall back to shallow, which materializes and upgrades packages
correctly. No change to the global explicit-request branch is needed
once global is never linked.

## References

Fixes #9614
Part of #9608
Restores the global 100% coverage gate on `latest`, which broke after
#9626.

`filterLinkedStrategyEdges` in `lib/commands/ls.js` skips dev edges on
non-root packages — a guard added in #9095 to suppress false `UNMET
DEPENDENCY` output in the linked strategy. #9626 fixed the root cause
for store packages (they no longer load `devDependencies` as required
edges), so the store-based test no longer produces a dev edge, leaving
that branch unexercised.

Rather than ignore the line, this adds a regression test that genuinely
exercises the guard: arborist still loads dev edges for a `file:`-linked
transitive package, so listing one with `--all` under the linked
strategy reaches the guard at depth > 0 and confirms its devDependency
is suppressed instead of reported as `UNMET DEPENDENCY`. The full test
suite passes at 100%.

This is the `latest` counterpart of #9636, which restored coverage on
`release/v11` via an `istanbul ignore`.

## References

Follows up #9626
…egy (#9639)

In continuation of our exploration of using `install-strategy=linked` in
the [Gutenberg
monorepo](WordPress/gutenberg#75814), which
powers the WordPress Block Editor.

Under `install-strategy=linked`, `npm exec -w <ws> -- <bin>` ignored a
workspace-local bin (provided by a sibling workspace dependency) and
fell through to the registry, producing a spurious `E404`. The hoisted
strategy ran the local bin correctly.

## Why

For a workspace exec, the command computed the local bin directory as
`resolve(this.npm.localDir, name, 'node_modules', '.bin')`, i.e.
`<root>/node_modules/<name>/node_modules/.bin`. That path only resolves
when the workspace is symlinked into the root `node_modules` as
`<name>`, which is how the hoisted strategy lays workspaces out. The
linked strategy does not hoist workspaces into the root `node_modules`;
the workspace's real bin lives at `<workspace>/node_modules/.bin`.
libnpmexec walks up from the given bin directory looking for
`node_modules/.bin/<bin>`, so starting from the nonexistent hoisted path
never reached the workspace's actual bin and the lookup fell back to the
registry.

## How

Base the local bin directory on the workspace's own path (`runPath`)
instead of the hoisted `localDir/<name>` location. This is correct under
both strategies: linked finds the bin in the workspace's
`node_modules/.bin`, and hoisted still finds the root-hoisted bin
because the walk-up continues from the workspace directory to the root
`node_modules/.bin`.

## References

Fixes #9616
…tch (#9647)

In continuation of our exploration of using `install-strategy=linked` in
the [Gutenberg
monorepo](WordPress/gutenberg#75814), which
powers the WordPress Block Editor.

Switching `install-strategy` in the same project directory left behind
the previous strategy's layout. Going hoisted → linked kept the stale
real top-level transitive directories alongside the new `.store/` and
symlinks; going linked → hoisted kept the entire `node_modules/.store/`
directory. A fresh install of either strategy was already clean — only
the switch was affected.

## Why

Under the linked strategy the actual tree the diff compares against is
synthesized from the ideal tree (`#buildLinkedActualForDiff`), so real
directories left over from a prior hoisted layout are never seen, and
`#cleanOrphanedTopLevelLinks` only removed symlinks. In the other
direction `load-actual` ignores dot-directories, so the hoisted diff
never sees `node_modules/.store` and never removes it.

## How

`reify.js` now removes the leftover `.store` on a non-linked reify via
`#removeStaleStoreDir`. The store lives only at the project root and is
exclusively a linked artifact, so a single removal covers the project.
It runs only for a full-project install — a workspace-filtered or
`--workspaces=false` install is skipped, because out-of-scope workspaces
may still link into the store.

`#cleanOrphanedTopLevelLinks` (run only under linked) additionally
removes stale real package directories — a directory containing a
`package.json` that is not in the ideal tree's valid top-level set — and
prunes an emptied `@scope` directory afterward. Non-package real
directories and symlinks pointing outside the project are still
preserved.

The valid-top-level collection in `#cleanOrphanedStoreEntries` no longer
skips non-link nodes, so the root's bundled dependencies — materialized
as real top-level directories under linked — are recorded as valid and
never swept as stale.

## References

Fixes #9615
Part of #9608
`npm install-scripts prune` removes `allowScripts` entries that no
longer apply: the package isn't installed, or it no longer has an
install script. Covers both approvals and denials, and `--dry-run`
previews without writing.

Closes #9561
Three allowScripts (install-script policy) fixes:

- Version-pinned deny fails closed when the lockfile omits `resolved`.
- `npm link <pkg>` gates the global install of a missing package.
- Regression test: bundled-dep scripts stay blocked under the gate.
In continuation of our exploration of using `install-strategy=linked` in
the [Gutenberg
monorepo](WordPress/gutenberg#75814), which
powers the WordPress Block Editor.

Under `install-strategy=linked`, `npm query ':root > *'` reported a
direct dependency at its `node_modules/.store/<key>/node_modules/<pkg>`
backing path instead of the logical `node_modules/<pkg>` location,
diverging from the hoisted strategy.

There are two root causes.
In `hasParent` (query-selector-all.js), a store-backing node is `isTop`,
has `resolveParent === root`, and has a top-level symlink whose parent
is root, so it matched as a direct child of root through the `linksIn`
logical-parent block that exists for workspaces.
This returned the store node alongside its logical `Link`.
In the `query` command, `QuerySelectorItem` read `node.target.location`,
which for a linked dep resolves to the store node.

The fix skips store-backing nodes in the `linksIn` parent check, so a
store node is reached only through its logical `Link` (matched via the
`edgesIn` branch) rather than as a direct child of root.
The `query` command now reports the node's own logical `location`/`path`
when the target is in the store, while workspaces and regular nodes keep
the target location (for example `packages/<ws>`).
Deduplication now keys on the target's physical location and ranks
competing representations of the same package, so a top-level placement
wins over the canonical store node, which wins over an internal store
symlink.
This keeps direct deps logical and transitive deps at their canonical
store key for selectors such as `:root *`, and leaves the hoisted
strategy unchanged.

## References

Fixes #9617
…9654)

In continuation of our exploration of using `install-strategy=linked` in
the [Gutenberg
monorepo](WordPress/gutenberg#75814), which
powers the WordPress Block Editor.

Under `install-strategy=linked`, an installed transitive optional
dependency was missing from the actual tree built by `loadActual` when
scanning the filesystem (`forceActual: true`), the path `npm sbom` and
`npm query` use. On disk the dep is correct — extracted in `.store` and
symlinked as a store sibling of its consumer — but `npm sbom` omitted it
(e.g. `chokidar` → `fsevents`: 14 components vs the hoisted strategy's
15; `esbuild` → `@esbuild/darwin-arm64` likewise).

In `#findMissingEdges()`, the skip condition treated an edge as already
resolved when `!edge.missing`. An unresolved optional edge has no target
yet reports `missing === false` (`Edge.error` returns `null` for an
optional edge with no target), so it was skipped and the on-disk store
sibling was never walked or loaded.

This changes the check to walk any edge whose target is unresolved,
including optional ones. The walk only loads a package that actually
exists in an ancestor `node_modules`, so genuinely-uninstalled optionals
(impossible platform) stay absent, and behavior is unchanged for
required, missing, dummy, and hoisted-ancestor edges. The linked SBOM
now matches the hoisted strategy.

## References

Fixes #9627
…nked strategy (#9655)

In continuation of our exploration of using `install-strategy=linked` in
the [Gutenberg
monorepo](WordPress/gutenberg#75814), which
powers the WordPress Block Editor.

Under `install-strategy=linked`, `npm query` reports the wrong
`dev`/`prod` flags for workspaces and their dependencies. In a workspace
project the entire non-root tree is flagged `dev`, so `:is(.prod)`
returns almost nothing and `:is(.dev)` returns almost everything — the
opposite of the hoisted strategy. This breaks tooling that classifies
dependencies via `npm query`, e.g. a license checker that selects
`.prod` dependencies.

## Why

Two compounding defects, both exercised only by the linked layout.

First, the linked strategy does not symlink undeclared workspaces into
the root's `node_modules`, so the root's `workspace` edges resolve to
`null`. `calcDepFlags` walks outward from the root via edges, dead-ends
immediately, and never reaches any workspace or its transitive deps,
leaving them at their default `dev=true`.

Second, the `node.isLink` branch in `calcDepFlags` assigned target flags
unconditionally (`target.dev = link.dev`), unlike every other flag in
that file which is only ever unset (true to false). When a target is
reachable through more than one link — the norm under linked, where each
workspace's own `node_modules` links to a shared target — the last link
visited could overwrite an already-correct `dev=false` back to `true`.

## How

Make the `calcDepFlags` link branch monotonic: only unset flags,
matching the edge walk below it, and queue the target on first visit so
its own deps are still walked. A target reachable through multiple links
now keeps the most permissive flags regardless of visit order.

In `loadActual`, when the install strategy is linked, synthesize the
missing root-to-workspace links from the already-loaded workspace
targets so the root's workspace edges resolve and flags propagate. The
synthesis is gated to linked because under hoisted an unresolved
workspace edge is a genuinely missing symlink that reify must recreate,
not synthesize. Workspaces already linked into the root `node_modules`
are skipped.

This targets the path used by `npm query` and non-lockfile `npm sbom`,
which force a filesystem read of the actual tree. Commands that load
from the hidden lockfile (`npm ls`, `npm outdated`, `npm audit
signatures`) are unchanged; their separate, pre-existing linked flag gap
is left for a follow-up.

## References

Fixes #9100
#9658)

In continuation of our exploration of using `install-strategy=linked` in
the [Gutenberg
monorepo](WordPress/gutenberg#75814), which
powers the WordPress Block Editor.

Under `install-strategy=linked`, an override that forces a
**transitive** dependency to a version outside its dependent's declared
range was applied on disk but reported as `invalid` by `npm ls --all`,
which then exited 1 (`ELSPROBLEMS`). The hoisted strategy reports the
same edge as `overridden` and exits 0. The bug only surfaced when the
overridden package's dependent was itself a transitive (store) package —
a direct dependency of the root was handled correctly.

`npm ls` rebuilds the actual tree from the `.store` layout, and an
OverrideSet propagates down the tree through
`Link.recalculateOutEdgesOverrides`, which forwards the set from a store
symlink to its target node. A prior fix (#9357) gated that
forwarding on a rule naming a **direct** dependency of the target, to
avoid flipping an unrelated target to "has overrides" and making `npm
ci` re-resolve lockfile-pinned edges. That gate was too narrow: a store
link whose own direct deps do not name the overridden package never
forwarded the set, so the chain to the deeper `dependent → overridden`
edge never received the rule and was reported `invalid`.

The fix walks the target's subtree (following resolved edges,
dereferencing links) and forwards the set when an override rule actually
applies to any reachable edge, matched via `getEdgeRule` on name and
spec so a non-applicable version-qualified rule still does not flip an
intermediate node. Because override propagation is event-driven during
load, a store link can run its check before its subtree is resolved;
`loadActual` therefore re-forwards through links once the tree is
complete, so the filesystem-scan path resolves transitive overrides as
`overridden` rather than `invalid`.

## References

Fixes #9619
Under `install-strategy=linked`, a fresh `npm install` that actually
creates `.store` entries and symlinks printed `up to date, audited N
packages` instead of `added N packages`, misleading users and CI into
thinking nothing changed.

`reify-output` counts adds with `actualTree.inventory.has(d.ideal)`.
Under the linked strategy the diff's `ideal` nodes belong to the
isolated store tree (locations under
`node_modules/.store/<key>/node_modules/<name>`), while the `actualTree`
returned after reify is the logical hoisted-layout tree. The two never
share node identity, so the lookup always failed and `added` stayed `0`.

The ADD branch now also counts a node when it is a real store package
node (`isInStore && !isLink`), which maps 1:1 to a hoisted package add.
This excludes the internal store symlinks and the top-level consumer
symlink, so the count matches hoisted for registry dependencies
(verified: `minimatch` 4/4, `rimraf` 12/12, `express` 69/69).
Omitted/incompatible optional deps have no store entry and are still not
counted, and hoisted behavior is unchanged.

Known limitations (tracked separately): workspace and `file:`/`npm link`
additions are represented as links rather than store nodes under linked,
so they are not yet counted here — counting them is entangled with the
self-link bug (#9398) and the undeclared-workspaces bug (#9618). The
`--json` `add[].path` for a counted store package points to its `.store`
realpath rather than a logical `node_modules/<name>` path.

## References

Fixes #9623
#9657)

In continuation of our exploration of using `install-strategy=linked` in
the [Gutenberg
monorepo](WordPress/gutenberg#75814), which
powers the WordPress Block Editor.

Under `install-strategy=linked`, a declared workspace that the root does
not depend on is installed on disk but was invisible to `npm ls --all`,
`npm ls --workspaces`, and `npm query ':root > *'`, all of which
reported an empty tree. The hoisted strategy lists every workspace.

Undeclared workspaces are intentionally not symlinked into the root
`node_modules` under the linked strategy, so when `loadActual` rebuilds
the actual tree (from the filesystem scan or the hidden lockfile,
neither of which records a `node_modules/<ws>` entry) the root's
`workspace` edges resolve to nothing and the workspaces drop out of the
tree that `ls` and `query` traverse. `npm ls` then hid those missing
edges entirely to avoid a false `UNMET DEPENDENCY`.

The fix adds `loadActual` post-processing that, for the linked strategy,
synthesizes an in-memory `Link` at `node_modules/<ws>` for each
undeclared workspace so its root edge resolves, matching the logical
layout the regular `package-lock.json` already records. This is
introspection-only and writes nothing to disk. A declared workspace
whose root link is genuinely missing is left alone so it still reports
`UNMET`, an existing root child of the same name is never replaced, and
a workspace that was not loaded is skipped. With the edges now
resolving, the workspace-skip branch that `npm ls` used to suppress them
is removed.

## References

Fixes #9618
…#9671)

A root `overrides` entry targeting a transitive dependency was silently
dropped when the path to that dependency crossed a `file:`/workspace
link, so the dependency resolved to its un-overridden version and the
lockfile pinned the wrong version. It reproduced under both the
`hoisted` and `linked` install strategies, while the same override
applied correctly when the dependency was reached without crossing a
link.

Override rules propagate through dependency edges, but a Link and its
target are not edge-connected, so they are bridged by forwarding the
Link's `OverrideSet` to its target. That forwarding ran while the
target's subtree was still unbuilt, so its guard found no matching rule
and never forwarded, leaving the target and its descendant edges without
the rule.

`buildIdealTree` now forwards a link's overrides to its target before
the target's subtree is resolved, so descendant edges inherit the rule
as they are added, matching how a registry node always inherits its
ancestor's `OverrideSet`. `loadActual` now repropagates overrides
through links once all edges are resolved, so a transitive override
reached through a `file:` link is reported as `overridden` rather than
`invalid` by `npm ls`.

## References

Fixes #9659
#9674)

Removes the experimental designation from `install-strategy=linked`
(isolated mode): drops the install-time warning and the `(experimental)`
note in the config docs. `linked` is now a supported, opt-in install
strategy. The default stays `hoisted`.

## Why

`install-strategy=linked` (RFC-0042) has been experimental since it
shipped, warning on every install. It has since been hardened
extensively — the discrepancies tracked in #9608, plus ~50 earlier PRs,
are resolved — and it now produces hoisted-equivalent results across
`install`/`ci`/`ls`/`query`/`explain`/`audit`/`sbom`/`exec`/`run`/`link`/`uninstall`,
the supply-chain controls (`allow-scripts`/`allow-remote`/`allow-git`,
`--strict-allow-scripts`), and the v12 features (`npm patch`,
`packageExtensions`, `.npm-extension`), with a project lockfile
identical to hoisted. It has also been exercised against the [Gutenberg
monorepo](WordPress/gutenberg#75814), which
powers the WordPress Block Editor. The experimental warning no longer
reflects its state.

## How

- `@npmcli/arborist` (`reify.js`): remove the `The "linked" install
strategy is EXPERIMENTAL and may contain bugs.` warning emitted on every
linked install.
- `@npmcli/config` (`definitions.js`): drop `(experimental)` from the
`install-strategy` description for `linked`.
- Regenerate the config docs snapshot to match.

The `node_modules/.store/` layout remains an internal implementation
detail. This does not change the default install strategy.

## References

- Hardening tracked in #9608
- RFC-0042 (isolated mode):
https://github.com/npm/rfcs/blob/main/accepted/0042-isolated-mode.md
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.