From 15d9ee021aecb9919803edb0b49b63c629a77557 Mon Sep 17 00:00:00 2001 From: Mihhail Solovjov Date: Sat, 10 Oct 2026 17:01:59 +0300 Subject: [PATCH 1/2] fix(apply): hold packages with a version-pinned pnpm patch A pnpm patch keyed to an exact version (`patchedDependencies: next@16.3.5`) stops matching once the package is bumped, and pnpm refuses to install with an unused patch (ERR_PNPM_UNUSED_PATCH). `--apply` wrote the bump anyway, so the install failed and took every other upgrade in the run with it. The detector now reads the pinned patches of each project and `--apply` skips a package whose target version none of its patches cover. The hold is reported rather than silent: a warning on stderr, `heldByPatch` on the `--json` entry, and a "Held by pnpm patch" table in the Action's PR body. Name-only patches and ranges that still cover the target hold nothing. --- CHANGELOG.md | 4 + action/render-pr-body.mjs | 34 +++++- docs/guide/ci.md | 2 + docs/guide/github-action.md | 2 + docs/guide/json-schema.md | 1 + src/features/headless/headless-runner.ts | 37 ++++++- src/features/headless/report.ts | 1 + src/features/headless/types.ts | 1 + src/features/upgrade/package-detector.ts | 16 ++- src/shared/pnpm-patches.ts | 103 ++++++++++++++++++ src/shared/types/domain.ts | 2 + test/unit/action/render-pr-body.test.ts | 28 ++++- .../features/headless/headless-runner.test.ts | 61 +++++++++++ .../features/upgrade/package-detector.test.ts | 25 +++++ test/unit/shared/pnpm-patches.test.ts | 90 +++++++++++++++ 15 files changed, 398 insertions(+), 9 deletions(-) create mode 100644 src/shared/pnpm-patches.ts create mode 100644 test/unit/shared/pnpm-patches.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 07b2121..da92e88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Fixed + +- `--apply` and the GitHub Action no longer fail with `ERR_PNPM_UNUSED_PATCH` when a package has a pnpm patch pinned to its installed version (`patchedDependencies: { next@16.3.5: … }`). Such a package is now left alone and the rest of the upgrades go through. It is named in a warning, gets `heldByPatch` in the `--json` report and its own table in the Action's PR body, so you know to upgrade it by hand and re-create the patch. + ## [1.8.4] - 2026-09-23 ### Added diff --git a/action/render-pr-body.mjs b/action/render-pr-body.mjs index 78b4bd1..bafc57a 100644 --- a/action/render-pr-body.mjs +++ b/action/render-pr-body.mjs @@ -56,10 +56,12 @@ export function applyVersionPrefix(current, target) { * no explicit "applied" flag, so we derive it: under minor/patch, `range` is the version satisfying * the current spec, and it's only a real change when it differs from the current spec's version * (prefix stripped). Entries where only a major exists have `range === current` and aren't applied. - * Peer ranges never are: `--apply` does not rewrite peerDependencies. + * Peer ranges never are: `--apply` does not rewrite peerDependencies. Neither is a package held by + * a pinned pnpm patch. */ export function wasApplied(e) { if (e.type === 'peerDependencies') return false + if (e.heldByPatch) return false const cleanCurrent = stripVersionPrefix(e.current) return e.range !== cleanCurrent && e.range !== e.current } @@ -152,6 +154,33 @@ export function heldSection(report) { return lines } +/** + * Packages `--apply` left alone because a pnpm patch is pinned to the installed version. + * + * pnpm refuses to install when a patch matches no package, so these are never part of the PR. + * Listed so the reviewer knows the update exists and is theirs to do by hand. + */ +export function patchedSection(entries) { + const held = entries.filter((e) => e.heldByPatch) + if (held.length === 0) return [] + + const lines = ['### 🩹 Held by pnpm patch (not applied)', ''] + lines.push( + 'These packages have a pnpm patch pinned to the installed version. Upgrading would leave the patch unused, ' + + 'which pnpm refuses to install, so they are **never upgraded automatically**. Upgrade them by hand and re-create the patch with `pnpm patch`.' + ) + lines.push('') + lines.push('| Package | Current | Available | Patch |') + lines.push('|---|---|---|---|') + for (const e of held) { + lines.push( + `| \`${escapeCell(e.name)}\` | ${escapeCell(e.current)} | ${escapeCell(e.latest)} | \`${escapeCell(e.heldByPatch)}\` |` + ) + } + lines.push('') + return lines +} + export function render(report) { const { summary } = report // Collapse monorepo duplicates up front so every section below counts and lists unique upgrades. @@ -203,7 +232,7 @@ export function render(report) { } lines.push('') } else { - lines.push('_No in-range upgrades were applied — see skipped majors below._') + lines.push('_No upgrades were applied — see the sections below._') lines.push('') } @@ -261,6 +290,7 @@ export function render(report) { lines.push('') } + lines.push(...patchedSection(outdated)) lines.push(...heldSection(report)) lines.push('---') diff --git a/docs/guide/ci.md b/docs/guide/ci.md index 25b2c37..c4a1a33 100644 --- a/docs/guide/ci.md +++ b/docs/guide/ci.md @@ -34,6 +34,8 @@ Anything newer than that target is still reported as the latest update (`hasMajo `--apply` never rewrites `peerDependencies`, at any target. A peer range says which versions of the host your package supports, and raising its floor would quietly drop support for everything below it. Outdated peer ranges still show up in the report so you can widen them yourself. +With pnpm, `--apply` also skips a package whose patch in `patchedDependencies` is pinned to a version the upgrade would leave (`next@16.3.5`). pnpm refuses to install with an unused patch, so the upgrade is left to you: bump the package and re-create the patch with `pnpm patch`. The skipped packages are named in a warning on stderr and carry `heldByPatch` in the `--json` report. + It honors [`.inuprc`](configuration.md) exactly as the report does — a package the config excludes is never written. With `--apply --json`, the install output goes to stderr so stdout stays pure JSON. The install runs once in each project that changed: in its workspace root, or in the project's own directory when it isn't part of a workspace. If an install fails or the package manager isn't installed, `--apply` exits `2` and names each directory with the command to run there. The version bumps stay written. diff --git a/docs/guide/github-action.md b/docs/guide/github-action.md index abf7ba5..1ee83dd 100644 --- a/docs/guide/github-action.md +++ b/docs/guide/github-action.md @@ -36,6 +36,8 @@ When inup finds applicable upgrades, the workflow commits the changed manifest/l Peer ranges (`peerDependencies`) are never rewritten, so publishing from the upgraded branch can't quietly narrow the host versions your package supports. Outdated peer ranges still appear in the table, never marked as applied. +Packages with a pnpm patch pinned to a version (`patchedDependencies: { next@16.3.5: … }`) are never upgraded automatically. pnpm refuses to install when a patch no longer matches any package, so the bump would fail the whole run. They are listed in a **Held by pnpm patch** table in the PR body; upgrade them by hand and re-create the patch with `pnpm patch`. Patches keyed by name only (`next`), or by a range that still covers the new version, don't hold anything back. + Catalog-sourced upgrades (pnpm `catalog:` deps) are applied to `pnpm-workspace.yaml` and marked `catalog:` in the PR body so reviewers know which file the diff touches. With `minimum-release-age` set, the PR body also gets a **Held by release-age cooldown** table listing versions that exist but were deliberately not applied, and how old each one is. A cooldown that skipped silently would read to a reviewer as "nothing newer available" — the opposite of what the control means. diff --git a/docs/guide/json-schema.md b/docs/guide/json-schema.md index b779aac..df90a10 100644 --- a/docs/guide/json-schema.md +++ b/docs/guide/json-schema.md @@ -66,6 +66,7 @@ One entry per outdated package. | `enginesNode` | `string?` | Declared `engines.node` range for `latest`, if any. | | `vulnerability` | `Vulnerability?` | Present only when the installed version has ≥1 known advisory. | | `heldByCooldown` | `CooldownHold?` | Present when `minimumReleaseAge` withheld a newer version of this package. | +| `heldByPatch` | `string?` | Present only with `--apply`: the pnpm `patchedDependencies` key (e.g. `next@16.3.5`) pinned to the installed version. The package was **not** upgraded. | ## `CooldownHold` diff --git a/src/features/headless/headless-runner.ts b/src/features/headless/headless-runner.ts index ed1b00e..f6dd80d 100644 --- a/src/features/headless/headless-runner.ts +++ b/src/features/headless/headless-runner.ts @@ -1,5 +1,6 @@ import chalk from 'chalk' import { PackageManagerDetector } from '../../shared/package-manager' +import { findBlockingPatch } from '../../shared/pnpm-patches' import { ConsoleUtils, truncatePlainText } from '../../shared/terminal' import type { PackageInfo, PackageUpgradeChoice, UpgradeOptions } from '../../shared/types' import { applyVersionPrefix, findHighestPatchVersion } from '../../shared/versions' @@ -86,6 +87,11 @@ export class HeadlessRunner { // advisory against the upgrade targets, so the report says whether upgrading *fixes* it. const vulnerabilities = await auditVulnerabilities(outdated, advisories) + // Before the report is built, so it can say which packages --apply left alone and why. + if (options.apply) { + this.holdPatchedPackages(outdated, options.target ?? 'minor') + } + // Build the report from the *pre-apply* outdated set: it describes what this run addressed. const cooldown = this.detector.getCooldownDiagnostics() const report = buildHeadlessReport(packages, outdated, vulnerabilities, cooldown) @@ -159,6 +165,33 @@ export class HeadlessRunner { await upgrader.upgradePackages(choices) } + /** + * Mark the packages --apply must not bump because a pnpm patch is pinned to the version they + * would leave. pnpm refuses to install with a patch that matches nothing, so writing the bump + * would fail the whole run — every other upgrade with it. Said out loud on stderr: a package + * skipped in silence looks exactly like one that is up to date. + */ + private holdPatchedPackages(outdated: PackageInfo[], target: ApplyTarget): void { + const held = new Set() + for (const pkg of outdated) { + if (pkg.type === 'peerDependencies') continue + const targetVersion = this.resolveTargetVersion(pkg, target) + if (!targetVersion) continue + const patch = findBlockingPatch(pkg.name, pkg.patchPins, targetVersion) + if (!patch) continue + pkg.heldByPatch = patch + held.add(`${pkg.name} (patch ${patch})`) + } + + if (held.size > 0) { + console.error( + chalk.yellow( + `Warning: ${held.size} package(s) were not upgraded — a pnpm patch is pinned to the installed version, and upgrading would leave it unused: ${[...held].join(', ')}. Upgrade these by hand and re-create the patch.` + ) + ) + } + } + /** * Build `PackageUpgradeChoice[]` from the outdated set per the version policy. Mirrors * `createUpgradeChoices` in the TUI: preserves the original range prefix (^/~) unless --save-exact. @@ -169,7 +202,8 @@ export class HeadlessRunner { * update crosses a minor (or major) boundary. Uses upgradeType 'range'. * - latest: take `latestVersion`; uses upgradeType 'latest' (majors included). * - * peerDependencies are never written, at any target: they stay in the report only. + * peerDependencies are never written, at any target: they stay in the report only. Neither are + * packages held by a pinned pnpm patch. */ private buildChoices(outdated: PackageInfo[], target: ApplyTarget): PackageUpgradeChoice[] { const saveExact = this.options?.saveExact ?? false @@ -179,6 +213,7 @@ export class HeadlessRunner { // A peer range says which host versions a library supports; it is not a version to install. // Raising its floor silently drops support for every older host, so that stays a person's call. if (pkg.type === 'peerDependencies') continue + if (pkg.heldByPatch) continue const targetVersion = this.resolveTargetVersion(pkg, target) if (!targetVersion) continue diff --git a/src/features/headless/report.ts b/src/features/headless/report.ts index 3e16fd9..52dbcbb 100644 --- a/src/features/headless/report.ts +++ b/src/features/headless/report.ts @@ -75,6 +75,7 @@ export function buildHeadlessReport( if (pkg.deprecated) entry.deprecated = pkg.deprecated if (pkg.enginesNode) entry.enginesNode = pkg.enginesNode if (pkg.heldByCooldown) entry.heldByCooldown = pkg.heldByCooldown + if (pkg.heldByPatch) entry.heldByPatch = pkg.heldByPatch const vulnerability = vulnerabilities.get(pkg) if (vulnerability) entry.vulnerability = vulnerability return entry diff --git a/src/features/headless/types.ts b/src/features/headless/types.ts index cb24d8a..eaa828d 100644 --- a/src/features/headless/types.ts +++ b/src/features/headless/types.ts @@ -55,6 +55,7 @@ export interface HeadlessReportEntry { enginesNode?: string // declared engines.node range for the latest version, if any vulnerability?: HeadlessVulnerability // Advisories on the current version + whether upgrading clears them heldByCooldown?: CooldownHold // A newer version exists but minimumReleaseAge withheld it + heldByPatch?: string // --apply skipped it: this pnpm patch key is pinned to a version the bump would leave } export interface HeadlessReport { diff --git a/src/features/upgrade/package-detector.ts b/src/features/upgrade/package-detector.ts index bab6d45..2110f11 100644 --- a/src/features/upgrade/package-detector.ts +++ b/src/features/upgrade/package-detector.ts @@ -1,3 +1,4 @@ +import { dirname } from 'node:path' import * as semver from 'semver' import { isPackageIgnored } from '../../shared/config' import { configManager } from '../../shared/config/user-config' @@ -11,6 +12,7 @@ import { } from '../../shared/fs' import type { ControlTick } from '../../shared/http/hill-climb-controller' import { isCatalogReference, PnpmCatalogs } from '../../shared/pnpm-catalogs' +import { PnpmPatches } from '../../shared/pnpm-patches' import { fetchPackageVersions, type PackageVersionData, @@ -55,14 +57,16 @@ interface PreparedDependencies { /** The fields a PackageInfo carries over from where the dependency is declared. */ function declarationFields( - dep: DependencyEntry + dep: DependencyEntry, + patches: PnpmPatches ): Pick< PackageInfo, - 'type' | 'packageJsonPath' | 'catalog' | 'catalogEntries' | 'catalogReferencedBy' + 'type' | 'packageJsonPath' | 'patchPins' | 'catalog' | 'catalogEntries' | 'catalogReferencedBy' > { return { type: dep.type, packageJsonPath: dep.packageJsonPath, + patchPins: patches.pinsFor(dirname(dep.packageJsonPath), dep.name), catalog: dep.catalog, catalogEntries: dep.catalogEntries, catalogReferencedBy: dep.catalogReferencedBy, @@ -84,6 +88,8 @@ export class PackageDetector { /** Pinned parallelism (flag / .inuprc); undefined lets the controller adapt. */ private readonly concurrency?: number private readonly networkProfile: NetworkProfile | null + /** pnpm patches pinned to a version: read once per project, stamped on every declaration. */ + private readonly patches = new PnpmPatches() /** Latest control decision of the current run, for the slow-network hint. */ private lastControlTick: ControlTick | null = null @@ -425,7 +431,7 @@ export class PackageDetector { return dependencies.map((dep) => { const cached = resolvedBySpecifier.get(dep.version) if (cached) { - return { ...cached, ...declarationFields(dep) } + return { ...cached, ...declarationFields(dep, this.patches) } } try { // Only simple specifiers reach this point and every one of them parses, so the null @@ -506,7 +512,7 @@ export class PackageDetector { currentVersion: dep.version, rangeVersion: rangeTargetVersion || dep.version, latestVersion: effectiveLatest, - ...declarationFields(dep), + ...declarationFields(dep, this.patches), isOutdated, hasRangeUpdate, hasMajorUpdate, @@ -614,7 +620,7 @@ export class PackageDetector { currentVersion: dep.version, rangeVersion: 'unknown', latestVersion: 'unknown', - ...declarationFields(dep), + ...declarationFields(dep, this.patches), isOutdated: false, hasRangeUpdate: false, hasMajorUpdate: false, diff --git a/src/shared/pnpm-patches.ts b/src/shared/pnpm-patches.ts new file mode 100644 index 0000000..4ad4b8e --- /dev/null +++ b/src/shared/pnpm-patches.ts @@ -0,0 +1,103 @@ +import { existsSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import semver from 'semver' +import { parse } from 'yaml' +import { debugLog } from './debug-logger' +import { findUp } from './fs/find-up' +import { stripBom } from './fs/io' +import { PNPM_WORKSPACE_FILE } from './pnpm-catalogs' + +/** package name → the version selectors its patches are pinned to (`16.3.5`, `^2.0.0`). */ +type PatchSelectors = Map + +/** + * The version-pinned entries of pnpm's `patchedDependencies`. + * + * A patch keyed `next@16.3.5` applies to that version only. Bump `next` past it and the patch + * matches nothing, which pnpm refuses to install (ERR_PNPM_UNUSED_PATCH) — and installing + * without the patch would silently drop a fix someone wrote on purpose. So such a package is + * never upgraded unattended, and the picker says so before a person selects it. + * + * Name-only keys (`next`) apply to every version and never block. + */ +export class PnpmPatches { + /** Keyed by the directory asked about; workspace members share their root's entry. */ + private readonly byDir = new Map() + + /** The version selectors `name`'s patches are pinned to, for the project owning `startDir`. */ + pinsFor(startDir: string, name: string): string[] | undefined { + let selectors = this.byDir.get(startDir) + if (!selectors) { + const rootDir = findPatchRoot(startDir) + selectors = this.byDir.get(rootDir) ?? loadPatchSelectors(rootDir) + this.byDir.set(rootDir, selectors) + this.byDir.set(startDir, selectors) + } + return selectors.get(name) + } +} + +/** + * The patch key that upgrading `name` to `version` would orphan, or null when the upgrade is + * safe: the package has no pinned patch, or one of them still covers the new version. + */ +export function findBlockingPatch( + name: string, + pins: readonly string[] | undefined, + version: string +): string | null { + if (!pins || pins.some((selector) => semver.satisfies(version, selector))) return null + return `${name}@${pins[0]}` +} + +/** The directory holding the nearest pnpm-workspace.yaml, else `startDir` itself. */ +function findPatchRoot(startDir: string): string { + return ( + findUp(startDir, (dir) => (existsSync(join(dir, PNPM_WORKSPACE_FILE)) ? dir : undefined)) ?? + startDir + ) +} + +/** + * Read `patchedDependencies` from the project rooted at `rootDir`: its pnpm-workspace.yaml, and + * the `pnpm` field of the package.json beside it (where pnpm < 10 keeps the setting). + * A missing or broken file means no patches — it must never break an upgrade. + */ +function loadPatchSelectors(rootDir: string): PatchSelectors { + const selectors: PatchSelectors = new Map() + const collect = (path: string, read: (raw: string) => unknown) => { + try { + const patched = read(readFileSync(path, 'utf8')) + if (!patched || typeof patched !== 'object' || Array.isArray(patched)) return + for (const key of Object.keys(patched)) { + // A leading `@` belongs to a scoped name; only a later one separates the selector. + const at = key.lastIndexOf('@') + if (at <= 0) continue + const selector = key.slice(at + 1) + if (semver.validRange(selector) === null) continue + const name = key.slice(0, at) + selectors.set(name, [...(selectors.get(name) ?? []), selector]) + } + } catch (error) { + debugLog.warn('PnpmPatches', `failed to read patchedDependencies from ${path}: ${error}`) + } + } + + const workspaceFile = join(rootDir, PNPM_WORKSPACE_FILE) + if (existsSync(workspaceFile)) { + collect( + workspaceFile, + (raw) => (parse(raw) as { patchedDependencies?: unknown } | null)?.patchedDependencies + ) + } + const manifestPath = join(rootDir, 'package.json') + if (existsSync(manifestPath)) { + collect( + manifestPath, + (raw) => + (JSON.parse(stripBom(raw)) as { pnpm?: { patchedDependencies?: unknown } }).pnpm + ?.patchedDependencies + ) + } + return selectors +} diff --git a/src/shared/types/domain.ts b/src/shared/types/domain.ts index 7b70dc7..4d3ac06 100644 --- a/src/shared/types/domain.ts +++ b/src/shared/types/domain.ts @@ -61,6 +61,8 @@ export interface PackageInfo { vulnerability?: VulnerabilitySummary // Security vulnerability info (loaded on demand) allVersions?: string[] // All available versions from registry heldByCooldown?: CooldownHold // A newer version exists but minimumReleaseAge withheld it + patchPins?: string[] // Version selectors of the pnpm patches pinned to this package (`16.3.5`, `^2.0.0`) + heldByPatch?: string // pnpm patch key (`next@16.3.5`) the --apply bump would orphan, so it is skipped lookupFailed?: boolean // The registry returned nothing (network, auth, not found): updates unknown } diff --git a/test/unit/action/render-pr-body.test.ts b/test/unit/action/render-pr-body.test.ts index de083ca..a43a7c7 100644 --- a/test/unit/action/render-pr-body.test.ts +++ b/test/unit/action/render-pr-body.test.ts @@ -139,7 +139,7 @@ describe('render-pr-body action helper', () => { ], }) - expect(body).toContain('_No in-range upgrades were applied — see skipped majors below._') + expect(body).toContain('_No upgrades were applied — see the sections below._') expect(body).toContain( '| `major-only` | ^4.0.0 | ^4.0.0 | 5.0.0 | dependencies | — | ⚠️ yes | — |' ) @@ -594,4 +594,30 @@ describe('render-pr-body release-age cooldown section', () => { expect(body).toContain('Everything is up to date. 🎉') expect(body).not.toContain('Held by release-age cooldown') }) + + it('lists packages held by a pinned pnpm patch and never marks them applied', async () => { + const body = await renderPrBody({ + schemaVersion: 2, + summary: { ...baseSummary, total: 2, outdated: 1 }, + outdated: [ + { + name: 'next', + current: '^16.3.5', + range: '16.4.0', + latest: '16.4.0', + type: 'dependencies', + packageJsonPath: '/repo/package.json', + hasMajorUpdate: false, + heldByPatch: 'next@16.3.5', + }, + ], + }) + + expect(body).not.toContain('### ✅ Applied in this PR') + expect(body).toContain('_No upgrades were applied — see the sections below._') + expect(body).toContain('| `next` | ^16.3.5 | ^16.4.0 | 16.4.0 | dependencies | — | — | — |') + expect(body).toContain('### 🩹 Held by pnpm patch (not applied)') + expect(body).toContain('**never upgraded automatically**') + expect(body).toContain('| `next` | ^16.3.5 | 16.4.0 | `next@16.3.5` |') + }) }) diff --git a/test/unit/features/headless/headless-runner.test.ts b/test/unit/features/headless/headless-runner.test.ts index 04dc0a3..0e29281 100644 --- a/test/unit/features/headless/headless-runner.test.ts +++ b/test/unit/features/headless/headless-runner.test.ts @@ -623,6 +623,67 @@ describe('HeadlessRunner.run', () => { logSpy.mockRestore() }) + describe('with a pnpm patch pinned to the installed version', () => { + // The detector stamps the pins; axios is patched at exactly the version it is on. + // A fresh object per test: the runner marks the hold on the package itself. + let patched: typeof OUTDATED & { patchPins: string[] } + let errorSpy: ReturnType + let logSpy: ReturnType + + beforeEach(() => { + patched = { ...OUTDATED, patchPins: ['0.27.0'] } + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + }) + + afterEach(() => { + errorSpy.mockRestore() + logSpy.mockRestore() + }) + + it('skips the patched package, upgrades the rest, and reports the hold', async () => { + // pnpm fails the install on an unused patch, which would take every other bump with it. + const peer = { ...patched, type: 'peerDependencies' } + const majorOnly = { ...MAJOR_ONLY, patchPins: ['4.1.2'] } + mocks.scanResult.mockResolvedValue([patched, peer, MINOR_ONLY, majorOnly]) + + await new HeadlessRunner({ cwd: '/repo' }).run({ apply: true, json: true }) + + const choices = mocks.upgradePackages.mock.calls[0][0] + expect(choices.map((c: { name: string }) => c.name)).toEqual(['lodash-ish']) + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining('1 package(s) were not upgraded') + ) + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('axios (patch axios@0.27.0)')) + const report = JSON.parse(logSpy.mock.calls[0][0] as string) + expect(report.outdated.map((e: { heldByPatch?: string }) => e.heldByPatch)).toEqual([ + 'axios@0.27.0', + undefined, + undefined, + undefined, + ]) + }) + + it('upgrades a package whose patch still covers the target', async () => { + mocks.scanResult.mockResolvedValue([{ ...OUTDATED, patchPins: ['^0.27.0'] }]) + + await new HeadlessRunner({ cwd: '/repo' }).run({ apply: true }) + + expect(mocks.upgradePackages.mock.calls[0][0]).toHaveLength(1) + expect(errorSpy).not.toHaveBeenCalled() + }) + + it('leaves a report-only run untouched', async () => { + mocks.scanResult.mockResolvedValue([patched]) + + await new HeadlessRunner({ cwd: '/repo' }).run({ json: true }) + + const report = JSON.parse(logSpy.mock.calls[0][0] as string) + expect(report.outdated[0]).not.toHaveProperty('heldByPatch') + expect(errorSpy).not.toHaveBeenCalled() + }) + }) + it('target=latest holds ignoreMajor packages to their in-range bump', async () => { // Detector-level suppression already cleared hasMajorUpdate and set // majorIgnored; latest must not resurrect the major via latestVersion. diff --git a/test/unit/features/upgrade/package-detector.test.ts b/test/unit/features/upgrade/package-detector.test.ts index 505b4a0..97adaae 100644 --- a/test/unit/features/upgrade/package-detector.test.ts +++ b/test/unit/features/upgrade/package-detector.test.ts @@ -583,6 +583,31 @@ describe('PackageDetector edge paths', () => { ) }) + it('stamps the pnpm patches pinned to a package on each declaration', async () => { + const repo = mkdtempSync(join(tmpdir(), 'inup-detector-patches-')) + try { + writeFileSync( + join(repo, 'pnpm-workspace.yaml'), + 'patchedDependencies:\n zod@1.0.0: patches/zod@1.0.0.patch\n' + ) + mocks.collectAllDependenciesAsync.mockResolvedValue([ + dep('zod', '^1.0.0', join(repo, 'package.json')), + dep('zod', '^1.0.0', join(repo, 'apps', 'web', 'package.json')), + dep('yup', '^1.0.0', join(repo, 'package.json')), + ]) + + const packages = await new PackageDetector({ cwd: '/repo' }).streamOutdatedPackages(() => {}) + + expect(packages.map((pkg) => [pkg.name, pkg.patchPins])).toEqual([ + ['yup', undefined], + ['zod', ['1.0.0']], + ['zod', ['1.0.0']], + ]) + } finally { + rmSync(repo, { recursive: true, force: true }) + } + }) + it('defaults cwd to process.cwd()', () => { const detector = new PackageDetector() diff --git a/test/unit/shared/pnpm-patches.test.ts b/test/unit/shared/pnpm-patches.test.ts new file mode 100644 index 0000000..56df261 --- /dev/null +++ b/test/unit/shared/pnpm-patches.test.ts @@ -0,0 +1,90 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { findBlockingPatch, PnpmPatches } from '../../../src/shared/pnpm-patches' + +// The two halves as callers combine them: read the pins, then test a target against them. +class Patches { + private readonly patches = new PnpmPatches() + findBlockingPatch(dir: string, name: string, version: string): string | null { + return findBlockingPatch(name, this.patches.pinsFor(dir, name), version) + } +} + +describe('PnpmPatches', () => { + let root: string + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'inup-patches-')) + }) + + afterEach(() => { + rmSync(root, { recursive: true, force: true }) + }) + + const workspace = (yaml: string) => writeFileSync(join(root, 'pnpm-workspace.yaml'), yaml) + const manifest = (json: unknown) => + writeFileSync(join(root, 'package.json'), JSON.stringify(json)) + + it('blocks a bump that leaves the version a patch is pinned to', () => { + workspace('patchedDependencies:\n next@16.3.5: patches/next@16.3.5.patch\n') + const patches = new Patches() + expect(patches.findBlockingPatch(root, 'next', '16.4.0')).toBe('next@16.3.5') + expect(patches.findBlockingPatch(root, 'next', '16.3.5')).toBeNull() + expect(patches.findBlockingPatch(root, 'react', '19.0.0')).toBeNull() + }) + + it('reads the pnpm field of package.json, and scoped names', () => { + manifest({ pnpm: { patchedDependencies: { '@scope/pkg@1.0.0': 'patches/a.patch' } } }) + const patches = new Patches() + expect(patches.findBlockingPatch(root, '@scope/pkg', '1.0.1')).toBe('@scope/pkg@1.0.0') + }) + + it('finds the workspace root from a member directory', () => { + workspace('patchedDependencies:\n next@16.3.5: patches/next.patch\n') + const member = join(root, 'apps', 'web') + mkdirSync(member, { recursive: true }) + expect(new Patches().findBlockingPatch(member, 'next', '16.4.0')).toBe('next@16.3.5') + }) + + it('lets a bump through when a range patch still covers the target', () => { + workspace( + 'patchedDependencies:\n foo@^2.0.0: patches/foo-2.patch\n foo@1.0.0: patches/foo-1.patch\n' + ) + const patches = new Patches() + expect(patches.findBlockingPatch(root, 'foo', '2.4.0')).toBeNull() + expect(patches.findBlockingPatch(root, 'foo', '3.0.0')).toBe('foo@^2.0.0') + }) + + it('never blocks on name-only patches or selectors that are not a range', () => { + workspace( + 'patchedDependencies:\n bar: patches/bar.patch\n "@scope/baz": patches/baz.patch\n qux@not a range: patches/qux.patch\n' + ) + const patches = new Patches() + expect(patches.findBlockingPatch(root, 'bar', '9.0.0')).toBeNull() + expect(patches.findBlockingPatch(root, '@scope/baz', '9.0.0')).toBeNull() + expect(patches.findBlockingPatch(root, 'qux', '9.0.0')).toBeNull() + }) + + it('treats missing, malformed or patch-free config as no patches', () => { + const patches = new Patches() + expect(patches.findBlockingPatch(root, 'next', '16.4.0')).toBeNull() + + workspace('patchedDependencies: [oops\n') + manifest({ pnpm: { patchedDependencies: ['next@16.3.5'] } }) + expect(new Patches().findBlockingPatch(root, 'next', '16.4.0')).toBeNull() + + workspace('packages:\n - apps/*\n') + writeFileSync(join(root, 'package.json'), '{ not json') + expect(new Patches().findBlockingPatch(root, 'next', '16.4.0')).toBeNull() + }) + + it('reads each directory once', () => { + workspace('patchedDependencies:\n next@16.3.5: patches/next.patch\n') + const patches = new Patches() + expect(patches.findBlockingPatch(root, 'next', '16.4.0')).toBe('next@16.3.5') + rmSync(join(root, 'pnpm-workspace.yaml')) + expect(patches.findBlockingPatch(root, 'next', '16.4.0')).toBe('next@16.3.5') + }) +}) From 9985f5e75e65547da8560998aab1312b2ba36fc0 Mon Sep 17 00:00:00 2001 From: Mihhail <30833239+donfear@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:05:36 +0300 Subject: [PATCH 2/2] feat(tui): flag packages held by a pinned pnpm patch in the picker (#177) Selecting a package whose pnpm patch is pinned to the installed version fails the install after the manifests are already written. The picker now shows a `[PATCH]` badge on such a row, names the patch in the info modal, and leaves it out of the `m` / `l` bulk selections. It can still be picked by hand. The check is per option, so a range patch that covers the in-range bump only blocks the major. --- CHANGELOG.md | 4 ++ README.md | 4 +- docs/guide/ci.md | 2 +- src/features/interactive/keymap.ts | 4 +- .../modal/package-info-sections/sections.ts | 9 ++++ src/features/interactive/presenters/health.ts | 11 +++-- .../session/selection-state-builder.ts | 14 +++++++ .../interactive/state/state-manager.ts | 11 ++--- src/shared/types/ui.ts | 6 +++ .../interactive/health-presenter.test.ts | 11 +++++ .../interactive/package-info-sections.test.ts | 20 +++++++++ .../selection-state-builder.test.ts | 41 +++++++++++++++++++ .../interactive/state-manager.test.ts | 19 +++++++++ 13 files changed, 143 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index da92e88..2e3c14c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Added + +- The picker marks a package with `[PATCH]` when upgrading it would leave a pinned pnpm patch unused, and `i` shows which patch. `m` and `l` skip these packages; you can still select one by hand. + ### Fixed - `--apply` and the GitHub Action no longer fail with `ERR_PNPM_UNUSED_PATCH` when a package has a pnpm patch pinned to its installed version (`patchedDependencies: { next@16.3.5: … }`). Such a package is now left alone and the rest of the upgrades go through. It is named in a warning, gets `heldByPatch` in the `--json` report and its own table in the Action's PR body, so you know to upgrade it by hand and re-create the patch. diff --git a/README.md b/README.md index d8fda9b..e8f3094 100644 --- a/README.md +++ b/README.md @@ -51,8 +51,8 @@ Review the diff and run your tests after upgrading. | `←` | Cycle selection left (latest → range → none) | | `→` | Cycle selection right (none → range → latest) | | `Space` | Toggle the current package on/off | -| `m` | Select all minor/patch updates, except peers | -| `l` | Select all latest updates (including major), except peers | +| `m` | Select all minor/patch updates, except peers and patched packages | +| `l` | Select all latest updates (including major), except peers and patched packages | | `u` | Unselect all packages | | `Enter` | Confirm selection and upgrade | | `/` | Search packages by name | diff --git a/docs/guide/ci.md b/docs/guide/ci.md index c4a1a33..f658434 100644 --- a/docs/guide/ci.md +++ b/docs/guide/ci.md @@ -34,7 +34,7 @@ Anything newer than that target is still reported as the latest update (`hasMajo `--apply` never rewrites `peerDependencies`, at any target. A peer range says which versions of the host your package supports, and raising its floor would quietly drop support for everything below it. Outdated peer ranges still show up in the report so you can widen them yourself. -With pnpm, `--apply` also skips a package whose patch in `patchedDependencies` is pinned to a version the upgrade would leave (`next@16.3.5`). pnpm refuses to install with an unused patch, so the upgrade is left to you: bump the package and re-create the patch with `pnpm patch`. The skipped packages are named in a warning on stderr and carry `heldByPatch` in the `--json` report. +With pnpm, `--apply` also skips a package whose patch in `patchedDependencies` is pinned to a version the upgrade would leave (`next@16.3.5`). pnpm refuses to install with an unused patch, so the upgrade is left to you: bump the package and re-create the patch with `pnpm patch`. The skipped packages are named in a warning on stderr and carry `heldByPatch` in the `--json` report. In the picker such a package has a `[PATCH]` badge (press `i` for the patch it is tied to), and `m` and `l` skip it; you can still select it by hand. It honors [`.inuprc`](configuration.md) exactly as the report does — a package the config excludes is never written. With `--apply --json`, the install output goes to stderr so stdout stays pure JSON. diff --git a/src/features/interactive/keymap.ts b/src/features/interactive/keymap.ts index 18700ed..6366130 100644 --- a/src/features/interactive/keymap.ts +++ b/src/features/interactive/keymap.ts @@ -111,7 +111,7 @@ export const KEY_BINDINGS: KeyBinding[] = [ tokens: ['m'], action: { type: 'bulk_select_minor' }, displayKeys: 'm', - help: 'Select all minor/patch updates, except peers', + help: 'Select all minor/patch updates, except peers and patched packages', group: 'Selection', footer: { keyLabel: 'm', label: 'Minor' }, }, @@ -119,7 +119,7 @@ export const KEY_BINDINGS: KeyBinding[] = [ tokens: ['l'], action: { type: 'bulk_select_latest' }, displayKeys: 'l', - help: 'Select all latest updates (including major), except peers', + help: 'Select all latest updates (including major), except peers and patched packages', group: 'Selection', footer: { keyLabel: 'l', label: 'Latest' }, }, diff --git a/src/features/interactive/modal/package-info-sections/sections.ts b/src/features/interactive/modal/package-info-sections/sections.ts index 0eaf93b..6eebffc 100644 --- a/src/features/interactive/modal/package-info-sections/sections.ts +++ b/src/features/interactive/modal/package-info-sections/sections.ts @@ -163,6 +163,15 @@ export function buildPackageInfoSections( if (engineWarning) { warningRows.push(getThemeColor('warning')(`Hold: ${engineWarning}`)) } + if (state.patchHold) { + const patch = state.patchHold.latest ?? state.patchHold.range + for (const line of wrapPlainText( + `Patched: pnpm patch ${patch} is pinned to the installed version. Upgrading leaves it unused and pnpm refuses to install — upgrade this package on its own, then re-create the patch with pnpm patch.`, + warningContentWidth + )) { + warningRows.push(getThemeColor('warning')(line)) + } + } if (state.heldByCooldown) { // The row badge can only say [HELD]; this is where a user goes to find out // WHICH version is being withheld and how close it is to becoming eligible. diff --git a/src/features/interactive/presenters/health.ts b/src/features/interactive/presenters/health.ts index 9bbc012..d66d5df 100644 --- a/src/features/interactive/presenters/health.ts +++ b/src/features/interactive/presenters/health.ts @@ -4,17 +4,19 @@ import { getThemeColor } from '../themes-colors' export type HealthState = Pick< PackageSelectionState, - 'deprecated' | 'enginesNode' | 'heldByCooldown' + 'deprecated' | 'enginesNode' | 'heldByCooldown' | 'patchHold' > /** * A compact badge flagging a package's health in the list: * - `[DEPR]` when the latest version is deprecated (highest priority), - * - `[ENG]` when its `engines.node` is incompatible with the running Node, or + * - `[ENG]` when its `engines.node` is incompatible with the running Node, + * - `[PATCH]` when an offered upgrade would leave a pinned pnpm patch unused, or * - `[HELD]` when the release-age cooldown withheld a newer version. * * Returns an empty string when none applies. Deprecation wins because an - * engines mismatch on an abandoned package is moot; the cooldown ranks last + * engines mismatch on an abandoned package is moot; the patch outranks the + * cooldown because taking the upgrade fails the install; the cooldown ranks last * because it reports a deliberate, benign choice rather than a problem. * All render in the theme's (amber) warning color — caution, not alarm. */ @@ -25,6 +27,9 @@ export function getHealthBadge(state: HealthState): string { if (checkNodeEngineCompatibility(state.enginesNode)) { return getThemeColor('warning')('[ENG]') } + if (state.patchHold) { + return getThemeColor('warning')('[PATCH]') + } if (state.heldByCooldown) { return getThemeColor('warning')('[HELD]') } diff --git a/src/features/interactive/session/selection-state-builder.ts b/src/features/interactive/session/selection-state-builder.ts index 52fa485..5d6d5ad 100644 --- a/src/features/interactive/session/selection-state-builder.ts +++ b/src/features/interactive/session/selection-state-builder.ts @@ -1,3 +1,4 @@ +import { findBlockingPatch } from '../../../shared/pnpm-patches' import type { PackageInfo, PackageSelectionState, @@ -87,6 +88,12 @@ export function createSelectionStates( const latestClean = toComparableVersion(pkg.latestVersion) || pkg.latestVersion const key = selectionKey(pkg.name, pkg.currentVersion, pkg.type, pkg.catalog) const previousSelection = previousSelections?.get(key) || 'none' + const rangePatch = pkg.hasRangeUpdate + ? findBlockingPatch(pkg.name, pkg.patchPins, rangeClean) + : null + const latestPatch = pkg.hasMajorUpdate + ? findBlockingPatch(pkg.name, pkg.patchPins, latestClean) + : null return { name: pkg.name, @@ -106,6 +113,13 @@ export function createSelectionStates( deprecated: pkg.deprecated, enginesNode: pkg.enginesNode, heldByCooldown: pkg.heldByCooldown, + patchHold: + rangePatch || latestPatch + ? { + ...(rangePatch && { range: rangePatch }), + ...(latestPatch && { latest: latestPatch }), + } + : undefined, heldOnly: !pkg.isOutdated && pkg.heldByCooldown !== undefined, vulnerability: getCachedSummary(pkg.name, pkg.currentVersion, pkg.type), allVersions: pkg.allVersions, diff --git a/src/features/interactive/state/state-manager.ts b/src/features/interactive/state/state-manager.ts index c53669c..a949bd0 100644 --- a/src/features/interactive/state/state-manager.ts +++ b/src/features/interactive/state/state-manager.ts @@ -211,11 +211,13 @@ export class StateManager { // Bulk selection skips peer rows (and leaves a manual peer choice as it is). Raising a peer // range's floor drops support for older hosts, so it should only happen when picked by hand. + // The same goes for an upgrade that would leave a pinned pnpm patch unused: the install fails + // until someone re-creates the patch, so that is a choice to make one package at a time. bulkSelectMinor(states: PackageSelectionState[]): void { if (states.length === 0) return states.forEach((state) => { if (state.type === 'peerDependencies') return - if (state.hasRangeUpdate) { + if (state.hasRangeUpdate && !state.patchHold?.range) { state.selectedOption = 'range' } }) @@ -225,10 +227,9 @@ export class StateManager { if (states.length === 0) return states.forEach((state) => { if (state.type === 'peerDependencies') return - if (state.hasMajorUpdate) { - state.selectedOption = 'latest' - } else if (state.hasRangeUpdate) { - state.selectedOption = 'range' + const option = state.hasMajorUpdate ? 'latest' : state.hasRangeUpdate ? 'range' : null + if (option && !state.patchHold?.[option]) { + state.selectedOption = option } }) } diff --git a/src/shared/types/ui.ts b/src/shared/types/ui.ts index deb9245..aa0b6fc 100644 --- a/src/shared/types/ui.ts +++ b/src/shared/types/ui.ts @@ -49,6 +49,12 @@ export interface PackageSelectionState { deprecated?: string // npm deprecation message for the latest version (loaded on demand) enginesNode?: string // declared engines.node range for the latest version (loaded on demand) heldByCooldown?: CooldownHold // A newer version exists but minimumReleaseAge withheld it + /** + * The pnpm patch key (`next@16.3.5`) each offered upgrade would leave unused, per option. + * pnpm refuses to install with an unused patch, so taking that option means re-creating the + * patch by hand. Absent when no offered upgrade is affected. + */ + patchHold?: { range?: string; latest?: string } /** * The row exists only to surface a cooldown hold: every version newer than the installed * one is inside the window, so there is nothing to select. Hidden until `c` reveals them, diff --git a/test/unit/features/interactive/health-presenter.test.ts b/test/unit/features/interactive/health-presenter.test.ts index 8638909..98fb1f6 100644 --- a/test/unit/features/interactive/health-presenter.test.ts +++ b/test/unit/features/interactive/health-presenter.test.ts @@ -30,6 +30,17 @@ describe('getHealthBadge', () => { expect(getHealthBadge({ heldByCooldown: hold })).toContain('[HELD]') }) + it('flags an upgrade that would leave a pinned pnpm patch unused with [PATCH]', () => { + expect(getHealthBadge({ patchHold: { range: 'next@16.3.5' } })).toContain('[PATCH]') + // Taking it fails the install, so it outranks the benign cooldown hold. + expect( + getHealthBadge({ patchHold: { latest: 'next@16.3.5' }, heldByCooldown: hold }) + ).toContain('[PATCH]') + expect(getHealthBadge({ deprecated: 'gone', patchHold: { latest: 'a@1.0.0' } })).toContain( + '[DEPR]' + ) + }) + it('ranks [HELD] below the problem signals', () => { // A deliberate, benign hold must not mask a deprecation or engines mismatch. expect(getHealthBadge({ deprecated: 'gone', heldByCooldown: hold })).toContain('[DEPR]') diff --git a/test/unit/features/interactive/package-info-sections.test.ts b/test/unit/features/interactive/package-info-sections.test.ts index 8dc6c4e..9a712e7 100644 --- a/test/unit/features/interactive/package-info-sections.test.ts +++ b/test/unit/features/interactive/package-info-sections.test.ts @@ -195,6 +195,26 @@ describe('buildPackageInfoSections (info tab)', () => { expect(text).toContain('Hold: requires Node >=99') }) + it('names the pnpm patch an upgrade would leave unused', () => { + const latest = plain( + buildPackageInfoSections( + makeSelectionState({ patchHold: { range: 'next@16.3.5', latest: 'next@16.3.5' } }), + MODAL_WIDTH, + 'info' + ) + ) + expect(latest).toContain('Patched: pnpm patch next@16.3.5 is pinned') + + const rangeOnly = plain( + buildPackageInfoSections( + makeSelectionState({ patchHold: { range: 'foo@1.0.0' } }), + MODAL_WIDTH, + 'info' + ) + ) + expect(rangeOnly).toContain('pnpm patch foo@1.0.0') + }) + it('names the version the cooldown withheld', () => { // The row badge can only say [HELD]; the modal is where a user goes to find // out which version is being held and how close it is to becoming eligible. diff --git a/test/unit/features/interactive/selection-state-builder.test.ts b/test/unit/features/interactive/selection-state-builder.test.ts index 88b86bc..3946545 100644 --- a/test/unit/features/interactive/selection-state-builder.test.ts +++ b/test/unit/features/interactive/selection-state-builder.test.ts @@ -235,6 +235,47 @@ describe('createSelectionStates', () => { expect(states.map((s) => s.name)).toEqual(['stale']) }) + it('marks each offered upgrade that would leave a pinned pnpm patch unused', () => { + const base = { + currentVersion: '^1.2.3', + rangeVersion: '1.4.0', + latestVersion: '2.0.0', + isOutdated: true, + hasRangeUpdate: true, + hasMajorUpdate: true, + } + const states = createSelectionStates( + [ + makePackageInfo({ ...base, name: 'a-exact', patchPins: ['1.2.3'] }), + makePackageInfo({ ...base, name: 'b-range', patchPins: ['^1.0.0'] }), + makePackageInfo({ + ...base, + name: 'c-minor-only', + hasMajorUpdate: false, + patchPins: ['1.2.3'], + }), + makePackageInfo({ + ...base, + name: 'd-major-only', + hasRangeUpdate: false, + patchPins: ['1.2.3'], + }), + makePackageInfo({ ...base, name: 'e-covered', patchPins: ['*'] }), + makePackageInfo({ ...base, name: 'f-unpatched' }), + ], + noSummary + ) + + expect(states.map((s) => s.patchHold)).toEqual([ + { range: 'a-exact@1.2.3', latest: 'a-exact@1.2.3' }, + { latest: 'b-range@^1.0.0' }, + { range: 'c-minor-only@1.2.3' }, + { latest: 'd-major-only@1.2.3' }, + undefined, + undefined, + ]) + }) + it('attaches cached vulnerability summaries', () => { const summary = { count: 1, highestSeverity: 'high' as const, detailsUrl: 'x', advisories: [] } const getCachedSummary = vi.fn().mockReturnValue(summary) diff --git a/test/unit/features/interactive/state-manager.test.ts b/test/unit/features/interactive/state-manager.test.ts index 913f956..d26235c 100644 --- a/test/unit/features/interactive/state-manager.test.ts +++ b/test/unit/features/interactive/state-manager.test.ts @@ -241,6 +241,25 @@ describe('StateManager bulk selection edge cases', () => { expect(states.map((s) => s.selectedOption)).toEqual(['latest', 'none', 'range']) }) + it('bulk minor and latest skip an option that would leave a pnpm patch unused', () => { + const sm = new StateManager(0, 24) + const make = () => [ + ready({ selectedOption: 'none', patchHold: { range: 'a@1.0.0', latest: 'a@1.0.0' } }), + // A range patch that still covers the in-range bump, but not the next major. + ready({ selectedOption: 'none', patchHold: { latest: 'b@^1.0.0' } }), + ready({ selectedOption: 'none', hasMajorUpdate: false, patchHold: { range: 'c@1.0.0' } }), + ready({ selectedOption: 'none' }), + ] + + const minor = make() + sm.bulkSelectMinor(minor) + expect(minor.map((s) => s.selectedOption)).toEqual(['none', 'range', 'none', 'range']) + + const latest = make() + sm.bulkSelectLatest(latest) + expect(latest.map((s) => s.selectedOption)).toEqual(['none', 'none', 'none', 'latest']) + }) + it('bulk unselect clears every row', () => { const sm = new StateManager(0, 24) const states = [ready({ selectedOption: 'latest' }), ready({ selectedOption: 'range' })]