Skip to content

RFC: npm Extension for Manifest Repairs#903

Merged
owlstronaut merged 4 commits into
npm:mainfrom
manzoorwanijk:rfc-npm-extension-manifest-repairs
Jun 19, 2026
Merged

RFC: npm Extension for Manifest Repairs#903
owlstronaut merged 4 commits into
npm:mainfrom
manzoorwanijk:rfc-npm-extension-manifest-repairs

Conversation

@manzoorwanijk

Copy link
Copy Markdown
Contributor

Summary

Adds an RFC for a root-owned .npm-extension.mjs / .npm-extension.cjs file with a top-level transformManifest(pkg, context) extension point.

The proposal lets a project imperatively repair third-party package manifests before Arborist reads dependency and peer edges. It builds on the accepted Package Manifest Extensions proposal and its npm CLI implementation, which established the pre-resolution manifest repair phase, root-only authority model, lockfile visibility model, and publish isolation for local dependency metadata repairs.

packageExtensions remains the safer declarative default for common metadata repairs. .npm-extension covers the cases where projects need comments, upstream issue links, repeated transformations, conditional logic, reading existing manifest values, deletion, dependency-range replacement, or a local policy file that does not live in publishable package.json.

Motivation

install-strategy=linked makes dependency boundaries stricter by avoiding accidental hoisting. That is useful for correctness, but it exposes packages that import dependencies or type packages they did not declare. The accepted packageExtensions RFC handles the most common form of this problem: small deterministic additions to dependency and peer metadata.

Some repairs are harder to keep clear as declarative JSON. During the Package Manifest Extensions proposal discussion, a Gutenberg migration example repeated the same optional @types/react peer repair across many React-related dependencies. The declarative form worked, but the underlying policy was really a named list or predicate: "these packages import React types but do not declare an optional @types/react peer."

Other local repairs need conditional logic, such as adding a type package only when a matching runtime peer exists, copying an existing peer range into a type dependency, narrowing a known bad peer range, or throwing when upstream has fixed metadata and a local repair should be removed. packageExtensions intentionally does not support that kind of code.

This RFC proposes an explicit advanced escape hatch for those cases while preserving npm's root-owned authority model, lockfile visibility, and publish isolation.

Why a separate extension file

The RFC intentionally keeps executable policy out of package.json. A public package may need local dependency repairs for its own tests, build, or linked-install migration, but it should not publish root-only install policy to the registry manifest or packument.

.npm-extension is also deliberately not named .npmfile or shaped like pnpm's hooks.readPackage. The proposal borrows the useful manifest-transform idea from pnpm, but defines npm-specific semantics for trust, lockfile hashing, npm ci, publish exclusion, disable behavior, supported mutations, and future extension points.

Notable semantics

  • Only the root project owns .npm-extension.
  • Workspace package manifests are not extension targets; non-root workspace .npm-extension files warn and are ignored.
  • Dependency package .npm-extension files are ignored.
  • The only extension point in this RFC is top-level transformManifest(pkg, context).
  • The extension point runs synchronously before dependency and peer edges are read.
  • Supported output changes are limited to dependencies, optionalDependencies, peerDependencies, and peerDependenciesMeta.
  • Unlike packageExtensions, the imperative form can delete supported dependency entries and replace existing normal dependency ranges.
  • Unsupported field changes such as scripts, bin, exports, types, and bundleDependencies are rejected.
  • transformManifest runs for non-root, non-workspace dependency manifests from registry, git, remote tarball, local file, local directory, and symlinked dependency sources.
  • npm records an extension entry-file hash and minimal npmExtensionApplied provenance in package-lock.json.
  • npm ci verifies matching extension state without importing or executing .npm-extension.
  • Changed extension file bytes make npm install re-run transformManifest across candidate manifests rather than relying on selector-based selective re-resolution.
  • Extension-created, extension-changed, and extension-removed dependency metadata is visible through lockfile provenance and npm inspection output.
  • .npm-extension.mjs and .npm-extension.cjs are excluded from npm pack and npm publish, even when listed in files.
  • ignore-extension=true disables extension execution, and ignore-scripts=true implies ignore-extension=true for commands that would otherwise execute it.

Relationship to packageExtensions

This RFC is not trying to replace packageExtensions. The declarative feature should remain the first choice for small, reviewable manifest repairs. The imperative extension file is for cases where the declarative shape becomes repetitive, cannot express the needed local policy, or cannot live in a publishable package manifest.

The RFC keeps the two features ordered and auditable: transformManifest runs before packageExtensions, then npm reads dependency and peer edges from the resulting effective manifest. That lets imperative repairs inspect the upstream manifest before declarative repairs are applied, while preserving the accepted packageExtensions validation and provenance model.

References

Follow up of #889


Disclosure: Codex and Claude Code were used to draft the initial version of this RFC and to iterate on it during review.

@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.

Like the direction, and the security/lockfile framing is solid. My main concern is that a few things framed as inherited from the packageExtensions phase are actually new architecture:

  • npm ci "no execution": npm ci always builds an ideal tree and the apply hook lives inside that build, so as written it would execute transformManifest during ci. Needs an explicit path that skips the hook on a hash match (or fails before any manifest is re-fetched).
  • Non-registry sources: the current phase only runs in the manifest-fetch path, so file:/directory/symlinked deps take the Link branch and aren't transformed today. Covering them plus the linked actual-tree handling from #9568/#9569 is real new wiring.
  • Deep isolation: since the input is arbitrary JS that can touch any field, the "deeply isolated copy" is a hard prerequisite, not a given (the existing apply only clones the four allowlisted fields and otherwise shares references with pacote's cache).

None of these block the concept, but I think the RFC should call them out as new work and spell out the npm ci design before implementation.

@manzoorwanijk

Copy link
Copy Markdown
Contributor Author

My main concern is that a few things framed as inherited from the packageExtensions phase are actually new architecture

Thank you for flagging that. Updating...

@manzoorwanijk

Copy link
Copy Markdown
Contributor Author

@owlstronaut thank you for the review. I have updated the document.

One open item I can spell out further if useful or if you'd rather prefer it be left to implementation: For directory/symlinked dependency sources, npm currently reads the linked target's live package.json in virtual/actual tree paths, so the no-execution ci path needs to trust the lockfile's recorded effective edges over the live target manifest.

@owlstronaut

Copy link
Copy Markdown
Contributor

@manzoorwanijk Yeah, worth a sentence in the doc since linked deps are normally read live, so having no-execution ci defer to the locked effective edges over the target's live manifest is a behavior shift worth stating

@manzoorwanijk

Copy link
Copy Markdown
Contributor Author

Updated with some more clarity.

@owlstronaut owlstronaut merged commit bd298ee into npm:main Jun 19, 2026
7 checks passed
@manzoorwanijk manzoorwanijk deleted the rfc-npm-extension-manifest-repairs branch June 19, 2026 18:24
@manzoorwanijk

Copy link
Copy Markdown
Contributor Author

npm-packlist PR is ready for review - npm/npm-packlist#294

@manzoorwanijk

manzoorwanijk commented Jun 19, 2026

Copy link
Copy Markdown
Contributor Author

CLI implementation PR - npm/cli#9586

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