[pull] latest from npm:latest#10
Open
pull[bot] wants to merge 2160 commits into
Open
Conversation
|
3037d35 to
f3b0c43
Compare
591d1d1 to
9e74d3e
Compare
85ec0c9 to
26b6454
Compare
# 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
) 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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
See Commits and Changes for more details.
Created by
pull[bot]
Can you help keep this open source service alive? 💖 Please sponsor : )