Skip to content

feat: .npm-extension transformManifest for imperative manifest repairs#9586

Merged
owlstronaut merged 9 commits into
npm:latestfrom
manzoorwanijk:feat/npm-extension-manifest-repairs
Jun 22, 2026
Merged

feat: .npm-extension transformManifest for imperative manifest repairs#9586
owlstronaut merged 9 commits into
npm:latestfrom
manzoorwanijk:feat/npm-extension-manifest-repairs

Conversation

@manzoorwanijk

@manzoorwanijk manzoorwanijk commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

Implements the accepted RFC 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) and operates in the same pre-resolution phase, running before packageExtensions.

// .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).
  • Configsignore-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.
  • Visibilitynpm 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 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

@manzoorwanijk manzoorwanijk force-pushed the feat/npm-extension-manifest-repairs branch 2 times, most recently from ae8e7ac to f29b151 Compare June 19, 2026 21:35
@manzoorwanijk manzoorwanijk marked this pull request as ready for review June 19, 2026 21:44
@manzoorwanijk manzoorwanijk requested review from a team as code owners June 19, 2026 21:44
@manzoorwanijk manzoorwanijk marked this pull request as draft June 19, 2026 21:51
@manzoorwanijk manzoorwanijk force-pushed the feat/npm-extension-manifest-repairs branch 2 times, most recently from 64e45c5 to 7a92e3a Compare June 19, 2026 22:19
@manzoorwanijk manzoorwanijk marked this pull request as ready for review June 19, 2026 22:23
@manzoorwanijk

Copy link
Copy Markdown
Contributor Author

CC: @owlstronaut

owlstronaut pushed a commit to npm/npm-packlist that referenced this pull request Jun 22, 2026
…294)

Force-excludes root `.npm-extension.mjs` and `.npm-extension.cjs` from
the package tarball, even when a package's `files` array would otherwise
include them.

These files are root-owned npm install policy (the imperative
`transformManifest` extension point from
[npm/rfcs#903](npm/rfcs#903)), not package
contents — the same category as `.npmrc`, VCS metadata, lockfiles, and
native-patch files, which npm-packlist already strips unconditionally.
Keeping them out of the tarball lets a public package use
`.npm-extension` locally (for its own install, tests, and linked-install
migration) without publishing that policy to consumers or bloating its
packument.

## What

- Add `/.npm-extension.mjs` and `/.npm-extension.cjs` to the strict
(forced) exclusion list, alongside the existing lockfile/`.npmrc`/VCS
entries.
- Root-anchored: only the package's own root file is excluded; nested
paths are unaffected (a dependency's `.npm-extension` is never npm's
concern at pack time).

## Test

- `test/cannot-include-npm-extension.js`: a package whose `files` array
lists both `.npm-extension.mjs` and `.npm-extension.cjs` still excludes
them from the packed file list.

## References

Supports npm/rfcs#903
Consumed by the npm CLI `.npm-extension` implementation
npm/cli#9586 (pacote will pick this up via a
dependency bump once released).
@manzoorwanijk manzoorwanijk force-pushed the feat/npm-extension-manifest-repairs branch from 7a92e3a to 5a62f31 Compare June 22, 2026 16:44

@owlstronaut owlstronaut left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work. I ran this through install/ci/ls/explain, workspaces, all the allowlist fields, and a real end-to-end CLI run, and it behaves as documented. One thing to fix before merge: the  extension-file  source check works but its error is swallowed. set it from a disallowed source (user/global/env) and every npm command silently exits 1 with no output.

Minor, doc-only:  loadActual  executes the root file on the stale/absent hidden-lockfile path, and the feature forces  lockfileVersion: 4

@manzoorwanijk

Copy link
Copy Markdown
Contributor Author

Thank you for the review

the  extension-file  source check works but its error is swallowed. set it from a disallowed source (user/global/env) and every

Let me look into it.

@manzoorwanijk

Copy link
Copy Markdown
Contributor Author

the  extension-file  source check works but its error is swallowed. set it from a disallowed source (user/global/env) and every npm command silently exits 1 with no output.

That is now fixed. Thank you. Nice catch

Minor, doc-only:  loadActual  executes the root file on the stale/absent hidden-lockfile path, and the feature forces  lockfileVersion: 4

  • loadActual executing the root file on the filesystem-scan path is intentional re-derivation (package.json is never rewritten); it's gated by ignore-extension/ignore-scripts, and npm ci forces it off. Documented in the .npm-extension docs page and the load-actual.js comment.
  • The forced lockfileVersion: 4 is the shared packageExtensions/patch threshold; documented in the docs page and shrinkwrap.js.

Should we change anything about that>

@owlstronaut

Copy link
Copy Markdown
Contributor

Should we change anything about that

Nope, I think those look good

@owlstronaut owlstronaut merged commit 58cd8f5 into npm:latest Jun 22, 2026
69 checks passed
owlstronaut pushed a commit to npm/rfcs that referenced this pull request Jun 22, 2026
Bot-generated transition of RFC **#56** to status `implemented`.

Moved to `implemented/0056-npm-extension-manifest-repairs.md`.
Front-matter `status` and the relevant date field were updated.
`INDEX.md` was regenerated.

Implementation: npm/cli#9586

Co-authored-by: npm CLI robot <npm-cli+bot@github.com>
@manzoorwanijk manzoorwanijk deleted the feat/npm-extension-manifest-repairs branch June 23, 2026 01:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants