Skip to content

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

Open
manzoorwanijk wants to merge 8 commits into
npm:latestfrom
manzoorwanijk:feat/npm-extension-manifest-repairs
Open

feat: .npm-extension transformManifest for imperative manifest repairs#9586
manzoorwanijk wants to merge 8 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 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 from 868067c to 64e45c5 Compare June 19, 2026 21:56
@manzoorwanijk manzoorwanijk force-pushed the feat/npm-extension-manifest-repairs branch 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

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.

1 participant