feat: `.npm-extension` transformManifest for imperative manifest repairs (#9586)
Implements the accepted RFC
[npm/rfcs#903](https://github.com/npm/rfcs/pull/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)](https://github.com/npm/cli/pull/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](https://github.com/npm/npm-packlist/pull/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 https://github.com/npm/rfcs/pull/903
Builds on #9496
Companion: https://github.com/npm/npm-packlist/pull/294 M
Manzoor Wani committed
58cd8f591d0fdeafbb8f477fb8b8f5aa2e042c90
Parent: ce11dcf
Committed by GitHub <noreply@github.com>
on 6/22/2026, 6:28:47 PM