Skip to content

feat: packageExtensions for root-owned dependency manifest repairs#9496

Open
manzoorwanijk wants to merge 10 commits into
npm:latestfrom
manzoorwanijk:feat/package-manifest-extensions
Open

feat: packageExtensions for root-owned dependency manifest repairs#9496
manzoorwanijk wants to merge 10 commits into
npm:latestfrom
manzoorwanijk:feat/package-manifest-extensions

Conversation

@manzoorwanijk
Copy link
Copy Markdown
Contributor

Implements package manifest extensions per RFC #889: a root-only packageExtensions field in package.json that applies declarative repairs to third-party dependency manifests before Arborist finalizes the ideal tree. It lets a project add missing dependencies/optionalDependencies, add or correct peerDependencies, and mark peers optional via peerDependenciesMeta, without forking and republishing a package.

{
  "packageExtensions": {
    "broken-package@1": {
      "dependencies": { "missing-runtime-dep": "^2.0.0" }
    },
    "typescript-plugin@4.3.0": {
      "peerDependencies": { "typescript": ">=5" },
      "peerDependenciesMeta": { "typescript": { "optional": true } }
    }
  }
}

Why

install-strategy=linked gives installs strong package boundaries, which is also what makes adoption hard: a package only sees what it actually declared, so one that worked under a hoisted layout because a dependency happened to be hoisted above it can fail. A root-level dependency masks this under hoisting but does not make the package available inside the isolated boundary of the importer — the repair has to be attached to the broken package's manifest before its edges are resolved. This is the pre-resolution complement to overrides (which needs an existing edge to retarget) and to native dependency patching #9439 (which edits package contents after resolution).

The field

Each key is a package selector: a name with an optional semver range (foo, foo@1, @scope/foo@^2.3.0). Selectors match a candidate's own manifest name/version (the underlying name for aliases) and reject dist-tag, git, file, URL, and npm: specs. At most one selector may match a candidate. Honored only in the root package.json (the workspace root); the field in dependencies and non-root workspaces, and selectors matching a workspace member, are ignored with a warning — matching the root-authority model of overrides.

Merge semantics

Only the four resolution-affecting fields may be extended.

  • dependencies/optionalDependencies add a missing name only; providing a name already declared in either field is an error (use overrides to change a version), which also forbids moving a name between the two.
  • peerDependencies shallow-merges by name, replacing an existing range.
  • peerDependenciesMeta merges by name then key (e.g. add optional: true); every meta entry must have a corresponding peerDependencies entry.
  • Deletion (null/false/"-") is not supported.

The extension applies to a per-tree manifest copy: the shared pacote/cache manifest is never mutated, the installed node_modules/<pkg>/package.json is not rewritten, and bundleDependencies is unchanged. overrides still controls the final resolution target of an extension-created edge.

Lockfile

The root entry stores a canonical packageExtensionsHash, and each affected entry stores minimal provenance (packageExtensionsApplied); effective dependency metadata is recorded as usual. Extension state forces lockfileVersion: 4 so older npm clients abort rather than silently dropping the repaired graph. npm install re-resolves affected packages when the rule set changes; npm ci validates the hash, selector conflicts, and stale provenance before trusting the locked metadata.

Visibility

npm explain appends (added by packageExtensions["foo@1"].dependencies.bar) to the edge; npm ls annotates the node and npm ls --json includes packageExtensionsApplied. Publishing a non-private package containing the field warns that it does not affect consumers.

Notes

  • lockfileVersion: 4 is shared with native dependency patching (#9439) as a common "older npm must not silently drop this" tripwire; both bump only when their own state is present. Whichever lands second should reuse the same maxLockfileVersion/bump constants rather than introduce a competing version.
  • Opt-in and additive, so it can ship in a minor release.

References

Implements npm/rfcs#889

@manzoorwanijk manzoorwanijk marked this pull request as ready for review June 5, 2026 13:09
@manzoorwanijk manzoorwanijk requested review from a team as code owners June 5, 2026 13:09
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