current commit
085f9f61b · fix(patch): stop a workdir cleanup race from masking update errors (#9798)
const { resolve, relative, join, dirname, basename, isAbsolute, sep } = require('node:path')
const { tmpdir } = require('node:os')
const { cp, mkdir, mkdtemp, readFile, rm, writeFile } = require('node:fs/promises')
const pacote = require('pacote')
const npa = require('npm-package-arg')
const semver = require('semver')
const git = require('@npmcli/git')
const PackageJson = require('@npmcli/package-json')
const { log, output } = require('proc-log')
const { matchSelector, parseSelector } = require('@npmcli/arborist/lib/patched-dependencies.js')
const { applyPatchToDir } = require('@npmcli/arborist/lib/patch.js')
const { patchRelaxOpts } = require('../utils/cli-only-flag.js')
const BaseCommand = require('../base-cmd.js')
const { diffDirs } = require('../utils/patch-diff.js')
const reifyFinish = require('../utils/reify-finish.js')

const SUBCOMMANDS = ['add', 'commit', 'update', 'ls', 'rm']

// Marker left in a conflicted update edit dir so the finalizing commit drops the old exact selector.
const UPDATE_MARKER = '.npm-patch-update.json'

// fs.cp filter that skips a git working directory.
const notGitDir = src => !src.split(sep).includes('.git')

// Build the selector key stored in patchedDependencies, e.g. lodash@4.17.21.
const selectorKey = (name, version) => `${name}@${version}`

// Posix-relative path to a patch file inside patches-dir for name@version.
const patchFilePath = (patchesDir, name, version) =>
  `${patchesDir}/${name}@${version}.patch`.split('\\').join('/')

// Recover the exact version a patch file was authored against from its name.
// commit always writes <name>@<version>.patch, so the baseline is encoded there.
const patchFileVersion = patchPath => {
  const { spec } = parseSelector(basename(patchPath).replace(/\.patch$/, ''))
  return semver.valid(spec)
}

const patchErr = (message, code, extra = {}) =>
  Object.assign(new Error(message), { code, ...extra })

// The project-root-relative posix path for abs, or null if abs escapes the root.
const containedRelative = (root, abs) => {
  const rel = relative(root, abs).split('\\').join('/')
  return (!rel || rel.startsWith('..') || isAbsolute(rel)) ? null : rel
}

class Patch extends BaseCommand {
  static description = 'Apply local patches to installed dependencies'
  static name = 'patch'
  static params = [
    'patches-dir',
    'allow-unused-patches',
    'ignore-patch-failures',
    'edit-dir',
    'ignore-existing',
    'keep-edit-dir',
    'to',
    'registry',
  ]

  static usage = [
    '<pkg>[@<version>]',
    'add <pkg>[@<version>] [--edit-dir <path>] [--ignore-existing]',
    'commit <edit-dir> [--patches-dir <dir>] [--keep-edit-dir]',
    'update <pkg>[@<old-version>] [--to <new-version>] [--patches-dir <dir>]',
    'ls',
    'rm <pkg>[@<version>]',
  ]

  static async completion (opts) {
    if (opts.conf.argv.remain.length === 2) {
      return SUBCOMMANDS
    }
    return []
  }

  async exec (args) {
    const [sub, ...rest] = args
    if (!sub) {
      throw this.usageError()
    }
    // explicit subcommand, else treat the bare arg as `patch add <pkg>`
    if (SUBCOMMANDS.includes(sub)) {
      return this[sub](rest)
    }
    return this.add(args)
  }

  get #root () {
    return this.npm.localPrefix
  }

  #newArborist (opts = {}) {
    const Arborist = require('@npmcli/arborist')
    return new Arborist({
      ...this.npm.flatOptions,
      ...patchRelaxOpts(this.npm.config),
      path: this.#root,
      ...opts,
    })
  }

  async #loadActual () {
    return this.#newArborist().loadActual()
  }

  // Resolve a user spec to a concrete registry name@version to patch.
  async #resolveTarget (spec) {
    const parsed = npa(spec)
    if (parsed.type && !parsed.registry) {
      throw this.#nonRegistryError(spec)
    }

    const { name } = parsed
    const tree = await this.#loadActual()
    // group every installed node by version so mixed-source duplicates are seen
    const installed = new Map()
    for (const node of tree.inventory.values()) {
      if (node.name === name && !node.isProjectRoot && node.version) {
        const nodes = installed.get(node.version) || []
        nodes.push(node)
        installed.set(node.version, nodes)
      }
    }

    // a version cannot be patched if a consumer depends on it through a non-registry spec (file:, git:, http(s)); npm: aliases stay registry.
    // checking the edges (not isRegistryDependency) avoids rejecting edgeless store nodes and linked symlinks, which are registry deps.
    const ensureRegistry = version => {
      const nodes = installed.get(version) || []
      if (nodes.some(n => [...n.edgesIn].some(e => e.spec && !npa(e.spec).registry))) {
        throw this.#nonRegistryError(`${name}@${version}`)
      }
    }

    // an explicit version/range is honored even when not present in the tree
    if (parsed.rawSpec && parsed.rawSpec !== '*' && parsed.rawSpec !== 'latest') {
      const exact = semver.valid(parsed.fetchSpec)
      if (exact) {
        ensureRegistry(exact)
        return { name, version: exact }
      }
      const matches = [...installed.keys()].filter(v => semver.satisfies(v, parsed.fetchSpec))
      if (matches.length > 1) {
        throw this.#ambiguousError(name, matches, installed)
      }
      if (matches.length === 1) {
        ensureRegistry(matches[0])
        return { name, version: matches[0] }
      }
      // resolve the range against the registry
      const mani = await pacote.manifest(spec, this.npm.flatOptions)
      return { name: mani.name, version: mani.version }
    }

    if (installed.size === 0) {
      throw Object.assign(
        new Error(`No installed version of "${name}" found. ` +
          `Run "npm install" first, or pass an explicit version.`),
        { code: 'EPATCHNOTINSTALLED' }
      )
    }
    if (installed.size > 1) {
      throw this.#ambiguousError(name, [...installed.keys()], installed)
    }
    const [version] = [...installed.keys()]
    ensureRegistry(version)
    return { name, version }
  }

  #nonRegistryError (label) {
    return Object.assign(
      new Error(`Cannot patch non-registry dependency "${label}". ` +
        `Only registry dependencies can be patched; edit the source directly.`),
      { code: 'EPATCHNONREGISTRY' }
    )
  }

  #ambiguousError (name, versions, installed) {
    const lines = versions.map(version => {
      const node = installed.get(version)[0]
      const dependant = [...node.edgesIn][0]?.from?.location || '(root)'
      return `  ${selectorKey(name, version)}  (via ${dependant})`
    })
    return Object.assign(
      new Error(`Multiple versions of "${name}" are installed:\n${lines.join('\n')}\n` +
        `Re-run with an exact selector, e.g. "npm patch add ${selectorKey(name, versions[0])}".`),
      { code: 'EPATCHAMBIGUOUS' }
    )
  }

  // Create (or reuse, with --edit-dir) the directory a package is extracted into for editing.
  async #makeEditDir (name, version) {
    let editDir = this.npm.config.get('edit-dir')
    if (!editDir) {
      const base = join(tmpdir(), 'npm-patch')
      await mkdir(base, { recursive: true })
      return mkdtemp(join(base, `${name.replace(/\//g, '+')}@${version}-`))
    }
    editDir = resolve(editDir)
    if (this.npm.config.get('ignore-existing')) {
      await rm(editDir, { recursive: true, force: true })
    }
    await mkdir(editDir, { recursive: true })
    return editDir
  }

  async add (args) {
    if (args.length !== 1) {
      throw this.usageError()
    }
    const { name, version } = await this.#resolveTarget(args[0])
    const editDir = await this.#makeEditDir(name, version)

    await pacote.extract(selectorKey(name, version), editDir, this.npm.flatOptions)

    output.standard(`You can now edit the following directory: ${editDir}`)
    output.standard(`When done, run: npm patch commit ${editDir}`)
  }

  async commit (args) {
    if (args.length !== 1) {
      throw this.usageError()
    }
    const editDir = resolve(args[0])
    const { content: edited } = await PackageJson.normalize(editDir).catch(() => {
      throw Object.assign(
        new Error(`No package.json found in edit directory: ${editDir}`),
        { code: 'EPATCHNOEDITDIR' }
      )
    })
    const { name, version } = edited
    if (!name || !version) {
      throw new Error(`Edit directory package.json is missing name or version: ${editDir}`)
    }

    // a conflicted `patch update` leaves a marker so this commit drops the renamed-from selector
    // it is kept on disk (and excluded from the diff) so a re-run after a no-op resolution still finalizes the update
    const markerPath = join(editDir, UPDATE_MARKER)
    const markerRaw = await readFile(markerPath, 'utf8').catch(() => null)
    let marker = null
    if (markerRaw !== null) {
      try {
        marker = JSON.parse(markerRaw)
      } catch {
        throw patchErr(`invalid update marker in ${editDir}`, 'EPATCHBADMARKER')
      }
    }

    // extract a clean baseline to diff against
    const base = await mkdtemp(join(tmpdir(), 'npm-patch-base-'))
    let diff, packageJsonChanged
    try {
      await pacote.extract(selectorKey(name, version), base, this.npm.flatOptions)
      ;({ diff, packageJsonChanged } = await diffDirs(base, editDir, new Set([UPDATE_MARKER])))
    } finally {
      await rm(base, { recursive: true, force: true })
    }

    if (!diff) {
      // package.json is excluded from patches, so an edit limited to it captures nothing
      const reason = packageJsonChanged
        ? `only package.json changed in ${editDir}, which is not patchable; nothing to commit`
        : `no changes detected in ${editDir}; nothing to commit`
      log.warn('patch', reason)
      return
    }
    if (packageJsonChanged) {
      log.warn('patch', 'changes to package.json are not included in patches and were ignored')
    }

    const patchesDir = this.npm.config.get('patches-dir')
    const absPatch = resolve(this.#root, patchFilePath(patchesDir, name, version))
    // refuse to write outside the project so the patch set stays in the repo
    const relPatch = containedRelative(this.#root, absPatch)
    if (!relPatch) {
      throw Object.assign(
        new Error(`patches-dir "${patchesDir}" resolves outside the project root.`),
        { code: 'EPATCHUNSAFE' }
      )
    }
    await mkdir(dirname(absPatch), { recursive: true })
    await writeFile(absPatch, diff)

    const pkgJson = await PackageJson.load(this.#root)
    const patchedDependencies = { ...pkgJson.content.patchedDependencies }
    const newKey = selectorKey(name, version)
    patchedDependencies[newKey] = relPatch
    // a marker left by a conflicted `patch update` for this package means a metadata-only finalize.
    // its name is checked so a stray or forged marker can never hijack a normal commit.
    const updateFinalize = !!marker && marker.name === name
    let orphan = null
    // an exact rename drops the renamed-from selector; a fork carries removeKey null and keeps the old entry.
    // removeKey must be a real, same-package selector string, so a malformed marker can neither crash nor drop the wrong entry.
    if (updateFinalize && typeof marker.removeKey === 'string' && marker.removeKey !== newKey &&
      patchedDependencies[marker.removeKey] !== undefined &&
      parseSelector(marker.removeKey).name === name) {
      orphan = this.#dropSelector(patchedDependencies, marker.removeKey)
    }
    pkgJson.update({ patchedDependencies })
    await pkgJson.save()

    // finishing a conflicted update is metadata-only (like update itself): the new version may not be installed yet
    const arb = updateFinalize
      ? this.#newArborist({ packageLockOnly: true, allowUnusedPatches: true, audit: false })
      : this.#newArborist()
    await arb.reify(arb.options)
    await reifyFinish(this.npm, arb)

    // remove the renamed-from patch file only after the lockfile is durable
    await this.#removePatchFile(orphan)

    if (!this.npm.config.get('keep-edit-dir')) {
      await rm(editDir, { recursive: true, force: true })
    }

    output.standard(`Patched ${newKey} -> ${relPatch}`)
  }

  // Remove a selector from the map; return its patch file path if nothing else references it.
  #dropSelector (patched, key) {
    const patchPath = patched[key]
    delete patched[key]
    /* istanbul ignore next - the shared-file branch only fires when two selectors point at one patch */
    return (patchPath && !Object.values(patched).includes(patchPath)) ? patchPath : null
  }

  // Delete an orphaned patch file, but never one that escapes the project root.
  async #removePatchFile (patchPath) {
    if (!patchPath) {
      return
    }
    const abs = resolve(this.#root, patchPath)
    /* istanbul ignore else - defensive: orphaned paths come from the manifest and stay in-project */
    if (containedRelative(this.#root, abs)) {
      await rm(abs, { force: true })
    }
  }

  async update (args) {
    if (args.length !== 1) {
      throw this.usageError()
    }

    const pkgJson = await PackageJson.load(this.#root)
    const patched = { ...pkgJson.content.patchedDependencies }
    const entry = this.#resolveUpdateEntry(args[0], patched)

    const baseVersion = patchFileVersion(entry.patchPath)
    if (!baseVersion) {
      throw patchErr(
        `cannot determine the version "${entry.patchPath}" was authored against; rebase by hand`,
        'EPATCHBASE'
      )
    }

    const installedVersions = await this.#installedVersions(entry.name)
    const newVersion = this.#resolveNewVersion(entry, installedVersions)
    if (newVersion === baseVersion) {
      throw patchErr(`nothing to update: the patch already targets ${selectorKey(entry.name, newVersion)}`, 'EPATCHNOOP')
    }
    const newKey = selectorKey(entry.name, newVersion)
    if (patched[newKey] && newKey !== entry.key) {
      throw patchErr(
        `an entry already exists for ${newKey}; use "npm patch rm" first or rebase manually`,
        'EPATCHEXISTS'
      )
    }

    const rebase = await this.#rebasePatch({
      name: entry.name,
      baseVersion,
      newVersion,
      patchAbs: resolve(this.#root, entry.patchPath),
    })
    try {
      if (rebase.conflicted) {
        const editDir = await this.#makeEditDir(entry.name, newVersion)
        try {
          await cp(rebase.repo, editDir, { recursive: true, filter: notGitDir })
          // mark the conflict so the finalizing commit knows this is an update finalize.
          // an exact selector is a rename (drop the old key); a range/name-only is a fork (keep it).
          const removeKey = entry.spec && semver.valid(entry.spec) ? entry.key : null
          await writeFile(join(editDir, UPDATE_MARKER), JSON.stringify({ name: entry.name, removeKey }) + '\n')
        } catch (er) {
          // discard a temp edit dir we created if the copy or marker write fails; never a user-supplied --edit-dir
          /* istanbul ignore next - a failing copy/marker write is not deterministically reproducible */
          if (!this.npm.config.get('edit-dir')) {
            await rm(editDir, { recursive: true, force: true })
          }
          /* istanbul ignore next */
          throw er
        }
        output.standard(`Patch did not apply cleanly to ${newKey}.`)
        output.standard(`Resolve the conflicts in: ${editDir}`)
        output.standard(`When done, run: npm patch commit ${editDir}`)
        return
      }

      const { diff } = await diffDirs(rebase.newDir, rebase.repo)
      if (!diff) {
        throw patchErr(
          `the patch no longer changes ${newKey}; remove it with "npm patch rm" instead`,
          'EPATCHEMPTY'
        )
      }
      await this.#finalizeUpdate({
        pkgJson, patched, entry, name: entry.name, newVersion, newKey, diff, installedVersions,
      })
    } finally {
      await this.#removeWork(rebase.work)
    }
  }

  // Identify the single patchedDependencies entry the update targets.
  #resolveUpdateEntry (spec, patched) {
    const target = npa(spec)
    const name = target.name
    const oldSpec = target.rawSpec && target.rawSpec !== '*' ? target.fetchSpec : null
    const keys = Object.keys(patched)

    if (oldSpec) {
      const key = selectorKey(name, oldSpec)
      if (!patched[key]) {
        throw patchErr(`no patch registered for ${key}`, 'EPATCHNOTFOUND')
      }
      return { key, name, spec: oldSpec, patchPath: patched[key] }
    }

    const matches = keys.filter(k => parseSelector(k).name === name)
    if (!matches.length) {
      throw patchErr(`no patch to update for "${name}"`, 'EPATCHNOTFOUND')
    }
    if (matches.length > 1) {
      throw patchErr(
        `multiple patches match "${name}":\n${matches.map(k => `  ${k}`).join('\n')}\n` +
          `Re-run with an explicit selector, e.g. "npm patch update ${matches[0]}".`,
        'EPATCHAMBIGUOUS'
      )
    }
    const key = matches[0]
    return { key, name, spec: parseSelector(key).spec, patchPath: patched[key] }
  }

  // Determine the version to rebase onto: --to, else the installed version matching the selector.
  #resolveNewVersion (entry, installedVersions) {
    const to = this.npm.config.get('to')
    if (to) {
      const valid = semver.valid(to)
      if (!valid) {
        throw patchErr(`--to "${to}" is not a valid version`, 'EPATCHBADTO')
      }
      // update finalizes with allowUnusedPatches, so a --to absent from the tree records an unused patch silently.
      // The next plain install then rejects with EPATCHUNUSED, so warn now to surface the mismatch early.
      if (installedVersions !== null && !installedVersions.includes(valid)) {
        log.warn('patch',
          `${selectorKey(entry.name, valid)} is not installed; ` +
            `bump the dependency and reinstall, or the next "npm install" will fail with EPATCHUNUSED.`)
      }
      return valid
    }

    if (installedVersions === null) {
      throw patchErr(
        `could not read the lockfile for "${entry.name}"; ` +
          `run "npm install" first or pass --to <new-version>`,
        'EPATCHSTALE'
      )
    }
    const { spec } = entry
    let matching
    if (spec && semver.valid(spec)) {
      matching = installedVersions.filter(v => semver.eq(v, spec))
    } else if (spec) {
      matching = installedVersions.filter(v => semver.satisfies(v, spec))
    } else {
      matching = installedVersions
    }
    if (!matching.length) {
      throw patchErr(
        `no installed version matches the patch selector "${entry.key}"; ` +
          `pass --to <new-version> to rebase onto a specific version`,
        'EPATCHSTALE'
      )
    }
    return matching.sort(semver.rcompare)[0]
  }

  // Distinct registry-installed versions of a package from the lockfile, or null when it cannot be loaded.
  // Uses loadVirtual so the lockfile's own validity gates staleness and links/workspaces are excluded.
  async #installedVersions (name) {
    let tree
    try {
      tree = await this.#newArborist().loadVirtual()
    } catch {
      return null
    }
    const versions = new Set()
    for (const node of tree.inventory.values()) {
      if (node.isProjectRoot || node.isWorkspace || node.isLink || !node.version) {
        continue
      }
      /* istanbul ignore else - other-named nodes are skipped; only exercised with multi-package trees */
      if (node.name === name) {
        versions.add(node.version)
      }
    }
    return [...versions]
  }

  // 3-way merge the existing patch onto the new version via a throwaway git repo.
  // Returns { work, repo, newDir, conflicted }; the caller removes work on success.
  async #rebasePatch ({ name, baseVersion, newVersion, patchAbs }) {
    const work = await mkdtemp(join(tmpdir(), 'npm-patch-rebase-'))
    try {
      const baseDir = join(work, 'base')
      const newDir = join(work, 'new')
      const repo = join(work, 'repo')
      await pacote.extract(selectorKey(name, baseVersion), baseDir, this.npm.flatOptions)
      await pacote.extract(selectorKey(name, newVersion), newDir, this.npm.flatOptions)
      await mkdir(repo, { recursive: true })

      const id = ['-c', 'user.name=npm', '-c', 'user.email=npm@npmjs.com', '-c', 'commit.gpgsign=false']
      await this.#git(['init', '-q', '-b', 'base'], repo)
      // base = the version the patch was authored against
      await cp(baseDir, repo, { recursive: true })
      await this.#git(['add', '-Af'], repo)
      await this.#git([...id, 'commit', '-qm', 'base', '--allow-empty'], repo)
      // theirs = base + the existing patch, applied with the same jsdiff helper installs use
      await this.#git(['checkout', '-q', '-b', 'theirs'], repo)
      try {
        await applyPatchToDir({ patch: await readFile(patchAbs), cwd: repo })
      } catch (er) {
        throw patchErr(
          `the existing patch no longer applies to its baseline ${selectorKey(name, baseVersion)}; rebase by hand`,
          'EPATCHBASE',
          { cause: er }
        )
      }
      await this.#git(['add', '-Af'], repo)
      await this.#git([...id, 'commit', '-qm', 'theirs', '--allow-empty'], repo)
      // ours = the new version, branched from base
      await this.#git(['checkout', '-q', '-b', 'ours', 'base'], repo)
      await this.#git(['rm', '-rqf', '.'], repo)
      await cp(newDir, repo, { recursive: true })
      await this.#git(['add', '-Af'], repo)
      await this.#git([...id, 'commit', '-qm', 'ours', '--allow-empty'], repo)
      // replay the patch's intent onto the new version
      let conflicted = false
      try {
        await this.#git([...id, 'merge', '--no-edit', 'theirs'], repo)
      } catch (er) {
        // a genuine conflict leaves unmerged paths; any other non-zero exit is a real git failure
        const unmerged = await this.#git(['ls-files', '--unmerged'], repo)
        /* istanbul ignore else - a non-conflict merge failure is a git/environment fault */
        if (String(unmerged.stdout).trim()) {
          conflicted = true
        } else {
          throw er
        }
      }
      return { work, repo, newDir, conflicted }
    } catch (er) {
      await this.#removeWork(work)
      throw er
    }
  }

  // Remove the throwaway rebase workdir.
  // Retries because git can briefly hold files there, and never throws so a cleanup failure cannot mask the real error.
  async #removeWork (work) {
    try {
      await rm(work, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 })
    } catch (er) {
      log.verbose('patch', `could not remove ${work}: ${er.message}`)
    }
  }

  #git (args, cwd) {
    // The throwaway repo never needs maintenance and a detached auto-gc would race the workdir cleanup.
    return git.spawn(['-c', 'gc.auto=0', '-c', 'maintenance.auto=false', ...args], {
      cwd,
      env: { ...process.env, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_SYSTEM: '/dev/null' },
    })
  }

  // Apply the selector rules, write the new patch, and sync the lockfile without touching node_modules.
  async #finalizeUpdate (opts) {
    const { pkgJson, patched, entry, name, newVersion, newKey, diff, installedVersions } = opts
    const patchesDir = this.npm.config.get('patches-dir')
    const absPatch = resolve(this.#root, patchFilePath(patchesDir, name, newVersion))
    const relPatch = containedRelative(this.#root, absPatch)
    if (!relPatch) {
      throw patchErr(`patches-dir "${patchesDir}" resolves outside the project root.`, 'EPATCHUNSAFE')
    }
    // write the new patch first so no selector points at a missing file
    await mkdir(dirname(absPatch), { recursive: true })
    await writeFile(absPatch, diff)

    patched[newKey] = relPatch
    // drop the old selector only when it no longer wins any installed node, so a still-needed range survives
    let orphan = null
    if (entry.key !== newKey && this.#selectorUnused(patched, entry.key, name, installedVersions)) {
      orphan = this.#dropSelector(patched, entry.key)
    }

    pkgJson.update({ patchedDependencies: patched })
    await pkgJson.save()

    // record the new patch integrity in the lockfile only; update never touches node_modules
    const arb = this.#newArborist({ packageLockOnly: true, allowUnusedPatches: true, audit: false })
    await arb.reify(arb.options)
    await reifyFinish(this.npm, arb)

    // remove the now-orphaned old patch file only after the lockfile is durable
    await this.#removePatchFile(orphan)

    output.standard(`Updated ${entry.key} -> ${newKey} (${relPatch})`)
  }

  // Whether `key` should be dropped once the new selector is added.
  // An exact selector is a straight rename. A range/name-only selector is dropped only when it no longer
  // wins any installed node under the installer's exact > range > name-only precedence, so a kept one is still used.
  #selectorUnused (patched, key, name, installedVersions) {
    const { spec } = parseSelector(key)
    if (spec && semver.valid(spec)) {
      return true
    }
    if (installedVersions === null) {
      return false
    }
    const selectors = Object.keys(patched)
      .filter(k => parseSelector(k).name === name)
      .map(k => ({ ...parseSelector(k), key: k }))
    for (const version of installedVersions) {
      let winner
      try {
        winner = matchSelector(selectors, { name, version })
      } catch {
        /* istanbul ignore next - ambiguous overlapping ranges: keep the selector to be safe */
        return false
      }
      /* istanbul ignore if - a still-winning selector is kept; only exercised with multiple installed versions */
      if (winner?.key === key) {
        return false
      }
    }
    return true
  }

  async ls () {
    const pkgJson = await PackageJson.normalize(this.#root).catch(() => ({ content: {} }))
    const patched = pkgJson.content.patchedDependencies || {}
    const keys = Object.keys(patched)
    if (!keys.length) {
      return
    }

    // count nodes per patch using the same precedence Arborist applies at install
    const tree = await this.#loadActual()
    const selectors = keys.map(key => ({ ...parseSelector(key), key, patchPath: patched[key] }))
    const counts = new Map(keys.map(key => [key, 0]))
    // only the overlapping range selectors that actually conflict on a node
    const ambiguous = new Set()
    for (const node of tree.inventory.values()) {
      if (node.isProjectRoot || node.isLink || !node.version) {
        continue
      }
      let winner = null
      try {
        winner = matchSelector(selectors, node)
      } catch {
        for (const s of selectors) {
          if (s.name === node.name && s.spec && !semver.valid(s.spec) &&
            semver.satisfies(node.version, s.spec)) {
            ambiguous.add(s.key)
          }
        }
        continue
      }
      if (winner) {
        counts.set(winner.key, counts.get(winner.key) + 1)
      }
    }
    for (const key of keys) {
      if (ambiguous.has(key)) {
        output.standard(`${patched[key]}\t${key}\t(error: ambiguous selectors)`)
        continue
      }
      const n = counts.get(key)
      output.standard(`${patched[key]}\t${key}\t(${n} node${n === 1 ? '' : 's'})`)
    }
  }

  async rm (args) {
    if (args.length !== 1) {
      throw this.usageError()
    }
    const target = npa(args[0])
    const targetName = target.name
    const targetVersion = target.rawSpec && target.rawSpec !== '*' ? target.fetchSpec : null

    const pkgJson = await PackageJson.load(this.#root)
    const patched = { ...pkgJson.content.patchedDependencies }
    const removed = []
    for (const key of Object.keys(patched)) {
      const { name, spec } = parseSelector(key)
      if (name === targetName && (!targetVersion || spec === targetVersion)) {
        removed.push(key)
      }
    }
    if (!removed.length) {
      throw Object.assign(
        new Error(`No registered patch found for "${args[0]}".`),
        { code: 'EPATCHNOTFOUND' }
      )
    }

    for (const key of removed) {
      const patchPath = patched[key]
      delete patched[key]
      // only delete the file when no remaining selector references it
      if (!Object.values(patched).includes(patchPath)) {
        const abs = resolve(this.#root, patchPath)
        // never delete a path that escapes the project root
        if (!containedRelative(this.#root, abs)) {
          throw Object.assign(
            new Error(`Refusing to delete patch outside the project root: ${patchPath}`),
            { code: 'EPATCHUNSAFE' }
          )
        }
        await rm(abs, { force: true })
      }
    }

    if (Object.keys(patched).length) {
      pkgJson.update({ patchedDependencies: patched })
    } else {
      delete pkgJson.content.patchedDependencies
    }
    await pkgJson.save()

    const arb = this.#newArborist()
    await arb.reify(arb.options)
    await reifyFinish(this.npm, arb)

    output.standard(`Removed patch${removed.length === 1 ? '' : 'es'}: ${removed.join(', ')}`)
  }
}

module.exports = Patch