npm
Version:
a package manager for JavaScript
169 lines (151 loc) • 6.5 kB
JavaScript
// Resolve the root patchedDependencies map against an ideal tree.
// Attaches node.patched = { path, integrity } to each matched node.
// Enforces the failure modes (workspace-member entry, missing file, unused patch, non-registry target, ambiguous selectors) as hard errors.
const semver = require('semver')
const npa = require('npm-package-arg')
const { log } = require('proc-log')
const { resolve, relative, isAbsolute } = require('node:path')
const { readFile } = require('node:fs/promises')
const { patchIntegrity } = require('./patch.js')
// Split a selector key into { name, spec }. spec is null for a name-only key.
const parseSelector = key => {
const at = key.indexOf('@', 1)
return at === -1
? { name: key, spec: null }
: { name: key.slice(0, at), spec: key.slice(at + 1) }
}
const err = (message, code, extra = {}) =>
Object.assign(new Error(message), { code, ...extra })
// Pick the most specific range among several that all match a version.
// Returns the strict subset, or throws when ordering is ambiguous.
// semver.subset is transitive, so the running minimum is a subset of every range it did not throw on.
const pickRange = (ranges, name, version) => {
let best = ranges[0]
for (const r of ranges.slice(1)) {
if (semver.subset(r.spec, best.spec, { loose: true })) {
best = r
} else if (!semver.subset(best.spec, r.spec, { loose: true })) {
throw err(
`Ambiguous patch selectors for ${name}@${version}: ` +
`"${name}@${best.spec}" and "${name}@${r.spec}" overlap but neither ` +
`is a subset. Add an exact "${name}@${version}" entry to disambiguate.`,
'EPATCHAMBIGUOUS'
)
}
}
return best
}
// Choose the winning selector for a node: exact > range subset > name-only.
const matchSelector = (selectors, node) => {
const { name, version } = node
const matches = selectors.filter(s => s.name === name)
if (!matches.length) {
return null
}
const exact = matches.find(s =>
s.spec && semver.valid(s.spec) && semver.eq(s.spec, version, { loose: true }))
if (exact) {
return exact
}
const ranges = matches.filter(s =>
s.spec && !semver.valid(s.spec) && semver.satisfies(version, s.spec, { loose: true }))
if (ranges.length) {
return pickRange(ranges, name, version)
}
return matches.find(s => s.spec === null) || null
}
const resolvePatchedDependencies = async (tree, { path, allowUnusedPatches, rm }) => {
const patchedDependencies = tree.package?.patchedDependencies || {}
const selectors = Object.entries(patchedDependencies)
.map(([key, patchPath]) => ({ ...parseSelector(key), key, patchPath }))
// cache patch file integrity by path so shared diffs are read once
const integrityCache = new Map()
const readPatch = async patchPath => {
if (integrityCache.has(patchPath)) {
return integrityCache.get(patchPath)
}
// patch files must live inside the project so the patch set stays auditable
const abs = resolve(path, patchPath)
const rel = relative(path, abs)
if (!rel || rel.startsWith('..') || isAbsolute(rel)) {
throw err(`patch path escapes the project: ${patchPath}`, 'EPATCHUNSAFE', { path: patchPath })
}
let contents
try {
contents = await readFile(abs)
} catch {
throw err(`patch file not found: ${patchPath}`, 'EPATCHNOTFOUND', { path: patchPath })
}
const integrity = patchIntegrity(contents)
integrityCache.set(patchPath, integrity)
return integrity
}
const usedKeys = new Set()
for (const node of tree.inventory.values()) {
// patchedDependencies is honoured only in the root manifest
if (node.isWorkspace) {
// Link.package already delegates to its target's package
const pkg = node.package
if (pkg?.patchedDependencies && Object.keys(pkg.patchedDependencies).length) {
throw err(
`patchedDependencies is only supported in the root package.json, ` +
`but was found in workspace "${node.name}". Move the entry to the root.`,
'EPATCHWORKSPACE',
{ workspace: node.name }
)
}
continue
}
if (node.isProjectRoot) {
continue
}
const selector = matchSelector(selectors, node)
if (!selector) {
// a node that was patched but no longer matches a selector must be re-extracted to revert its files
if (node.patched) {
node.patchRemoved = true
}
node.patched = null
continue
}
// a non-registry consumer edge (file:, git:, http(s)) means there is no registry tarball to patch; npm: aliases stay registry.
// checking edges (not isRegistryDependency) avoids rejecting an edgeless node, which is still a registry dep.
if ([...node.edgesIn].some(e => e.spec && !npa(e.spec).registry)) {
throw err(
`Cannot patch non-registry dependency ${node.name}@${node.version} ` +
`(selector "${selector.key}"). Only registry dependencies can be patched.`,
'EPATCHNONREGISTRY',
{ node: node.name }
)
}
const integrity = await readPatch(selector.patchPath)
node.patched = { path: selector.patchPath, integrity }
usedKeys.add(selector.key)
}
if (selectors.length) {
const unused = selectors.filter(s => !usedKeys.has(s.key))
// an explicit `npm uninstall <name>` orphans that package's patch entry, so drop it instead of failing
const removed = new Set(rm)
const dropped = unused.filter(s => removed.has(s.name))
if (dropped.length) {
const patched = { ...tree.package.patchedDependencies }
for (const s of dropped) {
delete patched[s.key]
log.notice('patch', `Removing patch entry "${s.key}" for uninstalled ${s.name}; left ${s.patchPath} in place.`)
}
// undefined drops the key entirely when reify writes package.json
tree.package.patchedDependencies = Object.keys(patched).length ? patched : undefined
}
const stillUnused = unused.filter(s => !removed.has(s.name))
if (stillUnused.length && !allowUnusedPatches) {
throw err(
`The following patches were registered but matched no installed ` +
`package:\n${stillUnused.map(s => ` ${s.key} -> ${s.patchPath}`).join('\n')}\n` +
`Use --allow-unused-patches to install anyway.`,
'EPATCHUNUSED',
{ unused: stillUnused.map(s => s.key) }
)
}
}
}
module.exports = { resolvePatchedDependencies, matchSelector, parseSelector }