Skip to content

Commit ce7681f

Browse files
feat: packageExtensions for root-owned dependency manifest repairs (#9496)
Implements package manifest extensions per [RFC #889](npm/rfcs#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. ```json { "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](#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](#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
1 parent 5ddf6cc commit ce7681f

31 files changed

Lines changed: 1673 additions & 6 deletions

DEPENDENCIES.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,7 @@ graph LR;
184184
npmcli-arborist-->proggy;
185185
npmcli-arborist-->semver;
186186
npmcli-arborist-->ssri;
187+
npmcli-arborist-->validate-npm-package-name;
187188
npmcli-config-->ini;
188189
npmcli-config-->nopt;
189190
npmcli-config-->npmcli-eslint-config["@npmcli/eslint-config"];
@@ -579,6 +580,7 @@ graph LR;
579580
npmcli-arborist-->tar-stream;
580581
npmcli-arborist-->tcompare;
581582
npmcli-arborist-->treeverse;
583+
npmcli-arborist-->validate-npm-package-name;
582584
npmcli-arborist-->walk-up-path;
583585
npmcli-config-->ci-info;
584586
npmcli-config-->ini;

docs/lib/content/configuring-npm/package-json.md

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1033,6 +1033,66 @@ For example, to replace a transitive dependency with a fork:
10331033
}
10341034
```
10351035
1036+
### packageExtensions
1037+
1038+
`packageExtensions` lets a project apply small, declarative repairs to the manifests of third-party dependencies before npm resolves the dependency tree.
1039+
Use it to add a missing `dependencies`, `optionalDependencies`, or `peerDependencies` entry, or to correct `peerDependencies` and `peerDependenciesMeta`, while you wait for the upstream package to publish a fix.
1040+
1041+
This is especially useful with [`install-strategy=linked`](/using-npm/config#install-strategy), where dependencies are fully isolated and a package only sees what it actually declared.
1042+
A package that worked under a hoisted layout because a dependency happened to be hoisted above it can fail under `linked`; `packageExtensions` records the missing edge as explicit, reviewable, root-owned policy.
1043+
1044+
`packageExtensions` complements [`overrides`](#overrides): `overrides` changes what an existing dependency edge resolves to, while `packageExtensions` adds or corrects the dependency metadata that creates the edge in the first place.
1045+
For changing the resolved version of a dependency that is already declared, use `overrides`.
1046+
1047+
Like `overrides`, `packageExtensions` is only honored in the root `package.json` of a project (the workspace root in a workspace).
1048+
The field in installed dependencies and in non-root workspace packages is ignored.
1049+
Because it is root-only project policy, npm refuses to publish a non-private package that contains `packageExtensions`; it remains available to private packages and unpublished local projects.
1050+
1051+
Each key is a package selector: a package name with an optional semver range.
1052+
1053+
```json
1054+
{
1055+
"packageExtensions": {
1056+
"broken-package@1": {
1057+
"dependencies": {
1058+
"missing-runtime-dep": "^2.0.0"
1059+
}
1060+
},
1061+
"typescript-plugin@4.3.0": {
1062+
"peerDependencies": {
1063+
"typescript": ">=5"
1064+
},
1065+
"peerDependenciesMeta": {
1066+
"typescript": {
1067+
"optional": true
1068+
}
1069+
}
1070+
},
1071+
"@scope/uses-types@2": {
1072+
"dependencies": {
1073+
"@types/node": "^22.0.0"
1074+
}
1075+
}
1076+
}
1077+
}
1078+
```
1079+
1080+
- `"foo"` matches all versions of `foo`.
1081+
- `"foo@1"` matches versions satisfying `1`.
1082+
- `"@scope/foo@^2.3.0"` matches versions satisfying `^2.3.0`.
1083+
1084+
Selectors match a candidate package's own `name` and `version`. They do not accept dist-tags, git, file, directory, URL, or `npm:` alias specs. For aliases, the selector matches the underlying package name. At most one selector may match a given package; overlapping selectors that both match the same package fail the install.
1085+
1086+
Only `dependencies`, `optionalDependencies`, `peerDependencies`, and `peerDependenciesMeta` may be extended. The merge rules are:
1087+
1088+
- `dependencies` and `optionalDependencies` entries add a missing dependency only. Adding a name that the package already declares in either field is an error; use `overrides` to change a version.
1089+
- `peerDependencies` entries are merged by name, replacing an existing range.
1090+
- `peerDependenciesMeta` entries are merged by name and then by key, so you can add `optional: true` without dropping other metadata. Every `peerDependenciesMeta` entry must correspond to a `peerDependencies` entry.
1091+
1092+
Deletion is not supported; a `null`, `false`, or `"-"` value is an error.
1093+
1094+
`packageExtensions` does not rewrite the installed package's `package.json` on disk and does not modify `bundleDependencies`. Affected packages are recorded in `package-lock.json` and surfaced by [`npm explain`](/commands/npm-explain) and [`npm ls`](/commands/npm-ls), so each repair is easy to audit and to remove once upstream is fixed.
1095+
10361096
### engines
10371097
10381098
You can specify the version of node that your stuff works on:

lib/commands/ci.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ const fs = require('node:fs/promises')
66
const path = require('node:path')
77
const { log, time } = require('proc-log')
88
const validateLockfile = require('../utils/validate-lockfile.js')
9+
const { validatePackageExtensions } = require('../utils/validate-lockfile.js')
910
const ArboristWorkspaceCmd = require('../arborist-cmd.js')
1011
const getWorkspaces = require('../utils/get-workspaces.js')
1112

@@ -89,6 +90,8 @@ class CI extends ArboristWorkspaceCmd {
8990

9091
// Verifies that the packages from the ideal tree will match the same versions that are present in the virtual tree (lock file).
9192
const errors = validateLockfile(virtualInventory, arb.idealTree.inventory)
93+
// Verifies that the root packageExtensions state matches the lockfile and is still consistent with the locked tree.
94+
errors.push(...validatePackageExtensions(virtualArb.virtualTree, arb.idealTree))
9295
if (errors.length) {
9396
throw this.usageError(
9497
'`npm ci` can only install packages when your package.json and package-lock.json are in sync. ' +

lib/commands/ls.js

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,21 @@ const augmentItemWithIncludeMetadata = (node, item) => {
278278
return item
279279
}
280280

281+
// Render a node's packageExtensions provenance as a short "field.name" list, empty when none.
282+
const formatPackageExtensions = (applied) => {
283+
if (!applied) {
284+
return ''
285+
}
286+
const fields = ['dependencies', 'optionalDependencies', 'peerDependencies', 'peerDependenciesMeta']
287+
const parts = []
288+
for (const field of fields) {
289+
for (const name of applied[field] || []) {
290+
parts.push(`${field}.${name}`)
291+
}
292+
}
293+
return parts.join(', ')
294+
}
295+
281296
const getHumanOutputItem = (node, { args, chalk, global, long }) => {
282297
const { pkgid, path } = node
283298
const workspacePkgId = chalk.blueBright(pkgid)
@@ -338,6 +353,11 @@ const getHumanOutputItem = (node, { args, chalk, global, long }) => {
338353
? ' ' + chalk.cyan(`[patched: ${node.patched.path}]`)
339354
: ''
340355
) +
356+
(
357+
formatPackageExtensions(node.packageExtensionsApplied)
358+
? ' ' + chalk.dim(`packageExtensions: ${formatPackageExtensions(node.packageExtensionsApplied)}`)
359+
: ''
360+
) +
341361
(isGitNode(node) ? ` (${node.resolved})` : '') +
342362
(node.isLink ? ` -> ${relativePrefix}${targetLocation}` : '') +
343363
(long ? `\n${node.package.description || ''}` : '')
@@ -362,6 +382,10 @@ const getJsonOutputItem = (node, { global, long }) => {
362382
item.overridden = node.overridden
363383
}
364384

385+
if (node.packageExtensionsApplied) {
386+
item.packageExtensionsApplied = node.packageExtensionsApplied
387+
}
388+
365389
item[_name] = node.name
366390

367391
// special formatting for top-level package name

lib/commands/publish.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,9 @@ class Publish extends BaseCommand {
9696
const spec = npa(args[0])
9797
let manifest = await this.#getManifest(spec, opts)
9898

99+
// packageExtensions is root-only project policy and must never be published; fail fast so dry-run reports it too
100+
this.#assertNoPackageExtensions(manifest)
101+
99102
// only run scripts for directory type publishes
100103
if (spec.type === 'directory' && !ignoreScripts) {
101104
await runScript({
@@ -122,6 +125,8 @@ class Publish extends BaseCommand {
122125

123126
// The purpose of re-reading the manifest is in case it changed, so that we send the latest and greatest thing to the registry note that publishConfig might have changed as well!
124127
manifest = await this.#getManifest(spec, opts, true)
128+
// re-check the authoritative manifest in case a lifecycle script introduced packageExtensions
129+
this.#assertNoPackageExtensions(manifest)
125130
const force = this.npm.config.get('force')
126131
const isDefaultTag = this.npm.config.isDefault('tag') && !manifest.publishConfig?.tag
127132

@@ -273,6 +278,16 @@ class Publish extends BaseCommand {
273278
}
274279
}
275280

281+
// packageExtensions is root-only project policy and must never reach the registry; private packages may keep it for local use
282+
#assertNoPackageExtensions (manifest) {
283+
if (!manifest.private && manifest.packageExtensions !== undefined) {
284+
throw Object.assign(
285+
new Error('packageExtensions is only honored at the project root and must not be published.'),
286+
{ code: 'EPACKAGEEXTENSIONS' }
287+
)
288+
}
289+
}
290+
276291
// if it's a directory, read it from the file system
277292
// otherwise, get the full metadata from whatever it is
278293
// XXX can't pacote read the manifest from a directory?

lib/utils/explain-dep.js

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ const explainDependents = ({ dependents }, depth, chalk, seen) => {
7676
}
7777

7878
const explainEdge = (
79-
{ name, type, bundled, from, spec, rawSpec, overridden },
79+
{ name, type, bundled, from, spec, rawSpec, overridden, packageExtensions },
8080
depth, chalk, seen = new Set()
8181
) => {
8282
let dep = type === 'workspace'
@@ -88,9 +88,14 @@ const explainEdge = (
8888

8989
const fromMsg = ` from ${explainFrom(from, depth, chalk, seen)}`
9090

91+
// note an edge created by a root packageExtensions repair
92+
const extMsg = packageExtensions
93+
? chalk.dim(` (added by packageExtensions["${packageExtensions.selector}"].${packageExtensions.field}.${name})`)
94+
: ''
95+
9196
return (type === 'prod' ? '' : `${colorType(type, chalk)} `) +
9297
(bundled ? `${colorType('bundled', chalk)} ` : '') +
93-
`${dep}${fromMsg}`
98+
`${dep}${fromMsg}${extMsg}`
9499
}
95100

96101
const explainFrom = (from, depth, chalk, seen) => {

lib/utils/validate-lockfile.js

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,4 +33,59 @@ function validateLockfile (virtualTree, idealTree) {
3333
return errors
3434
}
3535

36+
// validates that the root packageExtensions state matches what the lockfile recorded, and that the locked tree is still consistent with the rule set.
37+
// Returns an array of human-readable error strings, empty when valid.
38+
function validatePackageExtensions (virtualTree, idealTree) {
39+
const errors = []
40+
const lockHash = virtualTree.meta?.packageExtensionsHash || null
41+
const idealHash = idealTree.meta?.packageExtensionsHash || null
42+
43+
if (idealHash !== lockHash) {
44+
if (idealHash && !lockHash) {
45+
errors.push('Missing: packageExtensions state from lock file')
46+
} else if (!idealHash && lockHash) {
47+
errors.push('Invalid: lock file records packageExtensions state but package.json has none')
48+
} else {
49+
errors.push('Invalid: packageExtensions in package.json do not match the lock file')
50+
}
51+
// once the canonical hashes diverge, the deeper per-node checks are moot
52+
return errors
53+
}
54+
55+
// the hashes match, so validate the locked tree's own consistency against the rules
56+
const { PackageExtensions } = require('@npmcli/arborist')
57+
const root = idealTree.target || idealTree
58+
let pe
59+
try {
60+
pe = new PackageExtensions(root.package?.packageExtensions)
61+
} catch (err) {
62+
return [`Invalid: ${err.message}`]
63+
}
64+
65+
for (const node of virtualTree.inventory.values()) {
66+
if (node.isProjectRoot || node.isWorkspace) {
67+
continue
68+
}
69+
// selectors match the underlying package identity, which is the alias target for aliased installs
70+
const name = node.packageName || node.name
71+
// a locked package identity must not match more than one selector
72+
try {
73+
pe.match(name, node.version)
74+
} catch (err) {
75+
errors.push(`Invalid: ${err.message}`)
76+
}
77+
// recorded provenance must still correspond to a selector that matches the node
78+
const applied = node.packageExtensionsApplied
79+
if (applied) {
80+
const sel = pe.selectors.find(s => s.key === applied.selector)
81+
if (!sel || !pe.wouldMatch(name, node.version)) {
82+
errors.push(
83+
`Invalid: stale packageExtensions provenance for ${node.name}@${node.version} (selector "${applied.selector}")`)
84+
}
85+
}
86+
}
87+
return errors
88+
}
89+
3690
module.exports = validateLockfile
91+
module.exports.validatePackageExtensions = validatePackageExtensions

package-lock.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16915,6 +16915,7 @@
1691516915
"semver": "^7.3.7",
1691616916
"ssri": "^14.0.0",
1691716917
"treeverse": "^3.0.0",
16918+
"validate-npm-package-name": "^7.0.2",
1691816919
"walk-up-path": "^4.0.0"
1691916920
},
1692016921
"bin": {
@@ -16935,6 +16936,15 @@
1693516936
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
1693616937
}
1693716938
},
16939+
"workspaces/arborist/node_modules/validate-npm-package-name": {
16940+
"version": "7.0.2",
16941+
"resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-7.0.2.tgz",
16942+
"integrity": "sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A==",
16943+
"license": "ISC",
16944+
"engines": {
16945+
"node": "^20.17.0 || >=22.9.0"
16946+
}
16947+
},
1693816948
"workspaces/config": {
1693916949
"name": "@npmcli/config",
1694016950
"version": "11.0.0-pre.0",

tap-snapshots/test/lib/commands/ls.js.test.cjs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -556,6 +556,12 @@ exports[`test/lib/commands/ls.js TAP ls overridden dep w/ color > should contain
556556

557557
`
558558

559+
exports[`test/lib/commands/ls.js TAP ls packageExtensions dep > human output annotates the extended node 1`] = `
560+
test-package-extensions@1.0.0 {CWD}/prefix
561+
\`-- foo@1.0.0 packageExtensions: dependencies.bar
562+
\`-- bar@1.0.0
563+
`
564+
559565
exports[`test/lib/commands/ls.js TAP ls print deduped symlinks > should output tree containing linked deps 1`] = `
560566
print-deduped-symlinks@1.0.0 {CWD}/prefix
561567
+-- a@1.0.0

tap-snapshots/test/lib/utils/explain-dep.js.test.cjs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,30 @@ overridden-root@1.0.0 overridden
205205
node_modules/overridden-root
206206
`
207207

208+
exports[`test/lib/utils/explain-dep.js TAP basic packageExtensions > explain color deep 1`] = `
209+
bar@1.2.3
210+
node_modules/bar
211+
bar@"^1.0.0" from foo@1.0.0
212+
node_modules/foo (added by packageExtensions["foo@1"].dependencies.bar)
213+
`
214+
215+
exports[`test/lib/utils/explain-dep.js TAP basic packageExtensions > explain nocolor shallow 1`] = `
216+
bar@1.2.3
217+
node_modules/bar
218+
bar@"^1.0.0" from foo@1.0.0
219+
node_modules/foo (added by packageExtensions["foo@1"].dependencies.bar)
220+
`
221+
222+
exports[`test/lib/utils/explain-dep.js TAP basic packageExtensions > print color 1`] = `
223+
bar@1.2.3
224+
node_modules/bar
225+
`
226+
227+
exports[`test/lib/utils/explain-dep.js TAP basic packageExtensions > print nocolor 1`] = `
228+
bar@1.2.3
229+
node_modules/bar
230+
`
231+
208232
exports[`test/lib/utils/explain-dep.js TAP basic peer > explain color deep 1`] = `
209233
peer@1.0.0 peer
210234
node_modules/peer

0 commit comments

Comments
 (0)