rman 2.13.0 → 2.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,116 @@
1
+ import type { Package } from '../../../core/classes/package.js';
2
+ import { DependencyUpdater } from '../../../core/interfaces/dependency-updater.js';
3
+ import { type NpmRelease, type NpmReleases } from '../../publish-targets/npm/npm-view.js';
4
+ /**
5
+ * npm's answer to `rman deps`: which of a `package.json`'s dependencies have a newer version on the
6
+ * registry, and which of those can be taken without breaking a rule another dependency states.
7
+ *
8
+ * Only a caret or tilde range on a plain version moves (`^1.2.3`, `~1.2.3`), keeping its prefix. An
9
+ * exact version, a compound range (`>=1 <2`, `a || b`), a dist-tag, a URL, an alias and an in-repo
10
+ * package are left as they are - but every one of them still counts as a rule the others must keep.
11
+ */
12
+ export declare class NodeDependencyUpdater implements DependencyUpdater {
13
+ /** `.rmanrc "deps.types"`'s short spellings, as `npm-check-updates` named them. The field names
14
+ * themselves are accepted too. */
15
+ protected readonly kindAliases: Readonly<Record<string, string>>;
16
+ /** The ranges that move: `^` or `~`, then a plain version. The prefix is kept on the rewrite. */
17
+ protected readonly movablePattern: RegExp;
18
+ /** The plan each `'update'` entry was made from, for `applyPlan`. */
19
+ protected readonly planned: WeakMap<DependencyUpdater.Entry, NodeDependencyUpdater.Decision>;
20
+ getPlan(ctx: DependencyUpdater.Context, packages: readonly Package[]): Promise<DependencyUpdater.Entry[]>;
21
+ applyPlan(ctx: DependencyUpdater.Context, plan: readonly DependencyUpdater.Entry[]): Promise<DependencyUpdater.Applied>;
22
+ /**
23
+ * `npm install --dry-run` at the repository root: npm's own resolver, which refuses a peer
24
+ * conflict anywhere in the tree with `ERESOLVE`, and writes nothing.
25
+ *
26
+ * Another package manager is not asked - its dry run is not the same question - and a note says so.
27
+ */
28
+ verify(ctx: DependencyUpdater.Context): Promise<string | undefined>;
29
+ /** One decision per dependency name `pkg` declares, whether or not it may move - one that may not
30
+ * still states rules the others have to keep. */
31
+ protected collect(pkg: Package, settings: DependencyUpdater.Settings, options: DependencyUpdater.Options, inRepo: ReadonlySet<string>): NodeDependencyUpdater.Decision[];
32
+ /** Every name's releases, from the lowest version any declaration of it can mean - once per name,
33
+ * however many packages declare it, `concurrency` at a time. */
34
+ protected fetchAll(ctx: DependencyUpdater.Context, decisions: readonly NodeDependencyUpdater.Decision[], withTime: boolean): Promise<Map<string, NpmReleases | undefined>>;
35
+ /** The registry's answer about one name - the seam a spec replaces. */
36
+ protected fetchReleases(name: string, range: string, cwd: string, withTime: boolean): Promise<NpmReleases | undefined>;
37
+ /** Fills in what `d` may move to, from its own settings and the rules that do not depend on what
38
+ * else moves (steps 1-3). */
39
+ protected prepare(d: NodeDependencyUpdater.Decision, info: NpmReleases | undefined): void;
40
+ /** Whether the registry offers `release` as a move at all: newer than what is declared, not
41
+ * deprecated, not above the `latest` tag, and not a prerelease unless the range is on one. */
42
+ protected offerable(d: NodeDependencyUpdater.Decision, release: NpmRelease): boolean;
43
+ /** Step 1: why the package's own settings leave `release` out - `deps.target` or `deps.minAge` -
44
+ * or `undefined` when they do not. */
45
+ protected settingRefusal(d: NodeDependencyUpdater.Decision, release: NpmRelease, time?: Record<string, string>): string | undefined;
46
+ /**
47
+ * How big a move from `from` to `to` is, **as npm's caret reads it**: anything a `^from` range
48
+ * would not accept is a `major`, so `0.1.0 -> 0.2.0` is one.
49
+ */
50
+ protected sizeOf(from: string, to: string): string;
51
+ /** Steps 2 and 3: the package's own other declarations of this name, and the runtime it supports.
52
+ * The rule `release` breaks, or `undefined`. */
53
+ protected staticRefusal(d: NodeDependencyUpdater.Decision, release: NpmRelease): string | undefined;
54
+ /**
55
+ * The Node versions a newer release has to keep covering: those the package supports **and** the
56
+ * lowest release its declaration allows now (`current`) already runs on.
57
+ */
58
+ protected nodeCoverage(pkg: Package, current: NpmRelease | undefined): string | undefined;
59
+ /** The Node versions `pkg` supports - its own `engines.node`, else the repository root's. */
60
+ protected supportedNode(pkg: Package): string | undefined;
61
+ /**
62
+ * Steps 4 and 5: while any peer range is broken by a version that moved, one side steps down.
63
+ *
64
+ * The side that steps down is the one the range is *about* - for "`parser` needs `typescript`
65
+ * below 6", `typescript` - to the newest candidate the range allows; only when none is left does
66
+ * the side stating the range step down instead.
67
+ */
68
+ protected resolve(decisions: readonly NodeDependencyUpdater.Decision[], ctx: DependencyUpdater.Context): void;
69
+ /** One broken rule, mended by one step down. Whether anything moved. */
70
+ protected settle(about: NodeDependencyUpdater.Decision, stating: NodeDependencyUpdater.Decision | undefined, range: string, rule: string): boolean;
71
+ /** Moves `d` down to `index` (`-1` keeps what is declared), remembering why it left its newest. */
72
+ protected stepTo(d: NodeDependencyUpdater.Decision, index: number, rule: string): void;
73
+ /** The version `d` resolves to: the one it moves to, or else the newest release its declarations
74
+ * already allow, which is what an install picks. */
75
+ protected effective(d: NodeDependencyUpdater.Decision): string | undefined;
76
+ /** What `d` declares in `field` once it has moved. */
77
+ protected rangeFor(d: NodeDependencyUpdater.Decision, field: string): string;
78
+ /** The in-repo packages `pkg` declares a dependency on. */
79
+ protected siblingsOf(pkg: Package, repoPackages: ReadonlyMap<string, Package>): Package[];
80
+ protected toEntry(d: NodeDependencyUpdater.Decision): DependencyUpdater.Entry;
81
+ protected manifestFile(pkg: Package): string;
82
+ /** `fn` over `items`, at most `limit` at a time. */
83
+ protected mapConcurrent<T>(items: readonly T[], limit: number, fn: (item: T) => Promise<void>): Promise<void>;
84
+ }
85
+ export declare namespace NodeDependencyUpdater {
86
+ /** One dependency name of one package, and where planning has got to with it. */
87
+ interface Decision {
88
+ pkg: Package;
89
+ name: string;
90
+ settings: DependencyUpdater.Settings;
91
+ /** Every field declaring the name, and its range there. */
92
+ fields: Map<string, string>;
93
+ /** The fields whose range is rewritten. */
94
+ movable: string[];
95
+ /** Whether this name is planned at all - rather than only stating rules for the others. */
96
+ wanted: boolean;
97
+ /** What is declared now, as a version: the highest floor among the movable ranges. */
98
+ floor?: string;
99
+ /** The newest version the registry offers at all, before the package's own settings. */
100
+ available?: string;
101
+ /** Why the settings leave `available` out, when they do. */
102
+ skipReason?: string;
103
+ /** Newer versions the package's own settings allow, newest first. */
104
+ allowed: string[];
105
+ /** `allowed`, less the ones another of the package's own rules refuses. */
106
+ candidates: string[];
107
+ /** Into `candidates`; `-1` keeps what is declared. */
108
+ index: number;
109
+ releases?: Map<string, NpmRelease>;
110
+ latestTag?: string;
111
+ /** The Node versions a newer release has to keep covering - see `nodeCoverage`. */
112
+ baselineNode?: string;
113
+ reason?: string;
114
+ error?: string;
115
+ }
116
+ }
@@ -0,0 +1,452 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import colors from 'ansi-colors';
4
+ import micromatch from 'micromatch';
5
+ import semver from 'semver';
6
+ import { DependencyUpdater } from '../../../core/interfaces/dependency-updater.js';
7
+ import { Manifest } from '../../../core/interfaces/manifest.js';
8
+ import { exec } from '../../../utils/exec.js';
9
+ import { npmViewReleases } from '../../publish-targets/npm/npm-view.js';
10
+ import { DEPENDENCY_KEYS } from './node-manifest.provider.js';
11
+ import { CiService } from './services/ci.service.js';
12
+ import { parseWorkspaceRange } from './utils/workspace-range.js';
13
+ /**
14
+ * npm's answer to `rman deps`: which of a `package.json`'s dependencies have a newer version on the
15
+ * registry, and which of those can be taken without breaking a rule another dependency states.
16
+ *
17
+ * Only a caret or tilde range on a plain version moves (`^1.2.3`, `~1.2.3`), keeping its prefix. An
18
+ * exact version, a compound range (`>=1 <2`, `a || b`), a dist-tag, a URL, an alias and an in-repo
19
+ * package are left as they are - but every one of them still counts as a rule the others must keep.
20
+ */
21
+ /* **The default is the policy every `.ncurc.yml` in this organization wrote by hand.** Twenty
22
+ * repositories carried the same `rejectVersion: "/(\|\|)|(&&)|>|<|^[0-9]/"` - no `||`, no `&&`, no
23
+ * comparator, no bare version - which leaves exactly a `^` or `~` range. Built in, it is a line
24
+ * nobody has to copy.
25
+ *
26
+ * **What a version has to get past, in order**, and only the last two depend on what else moves:
27
+ *
28
+ * 1. the package's own settings - `deps.target`, `reject`, `minAge`, and the registry: deprecated,
29
+ * above the `latest` dist-tag, or a prerelease when the range is not on one;
30
+ * 2. the package's own other declarations of the same name - a `devDependencies` caret cannot move
31
+ * outside the package's own `peerDependencies` range for that name;
32
+ * 3. the runtime: a version whose `engines.node` does not cover every Node the package supports;
33
+ * 4. the peer ranges of the other dependencies the package names, at the versions *they* move to;
34
+ * 5. a sibling package's peer range, for a package naming that sibling.
35
+ *
36
+ * Steps 4 and 5 are solved together: everything starts at its newest candidate, and while any rule
37
+ * is broken one side of it steps down. A version only ever steps down and never below what is
38
+ * declared now, so it ends. A rule broken by two versions neither of which moved is the
39
+ * repository's existing state and is not this command's to fix.
40
+ *
41
+ * **Peer ranges are what `npm-check-updates`' `peer: true` checked**, which every `.ncurc.yml` here
42
+ * turned on. It held a version back silently; this says which rule did.
43
+ *
44
+ * Out of sight, deliberately: a dependency's own dependency carrying a peer range. That is the
45
+ * resolver's job, and `verify` asks the real one. */
46
+ export class NodeDependencyUpdater {
47
+ /** `.rmanrc "deps.types"`'s short spellings, as `npm-check-updates` named them. The field names
48
+ * themselves are accepted too. */
49
+ kindAliases = {
50
+ prod: 'dependencies',
51
+ dev: 'devDependencies',
52
+ optional: 'optionalDependencies',
53
+ peer: 'peerDependencies',
54
+ };
55
+ /** The ranges that move: `^` or `~`, then a plain version. The prefix is kept on the rewrite. */
56
+ movablePattern = /^([\^~])(\d+(?:\.\d+){0,2}(?:-[0-9A-Za-z.-]+)?)$/;
57
+ /** The plan each `'update'` entry was made from, for `applyPlan`. */
58
+ planned = new WeakMap();
59
+ async getPlan(ctx, packages) {
60
+ const inRepo = new Set([ctx.repository.rootPackage, ...ctx.repository.packages].map(p => p.name));
61
+ const decisions = packages.flatMap(pkg => this.collect(pkg, DependencyUpdater.settingsFor(pkg, ctx.options), ctx.options, inRepo));
62
+ const withTime = decisions.some(d => d.settings.minAge > 0);
63
+ const registry = await this.fetchAll(ctx, decisions, withTime);
64
+ for (const d of decisions)
65
+ this.prepare(d, registry.get(d.name));
66
+ this.resolve(decisions, ctx);
67
+ return decisions.filter(d => d.wanted).map(d => this.toEntry(d));
68
+ }
69
+ async applyPlan(ctx, plan) {
70
+ const originals = new Map();
71
+ for (const entry of plan) {
72
+ const d = this.planned.get(entry);
73
+ if (entry.status !== 'update' || !d)
74
+ continue;
75
+ const version = d.candidates[d.index];
76
+ if (!originals.has(d.pkg))
77
+ originals.set(d.pkg, fs.readFileSync(this.manifestFile(d.pkg), 'utf-8'));
78
+ for (const field of d.movable) {
79
+ const deps = d.pkg.manifest.raw[field];
80
+ deps[d.name] = this.movablePattern.exec(deps[d.name])[1] + version;
81
+ }
82
+ }
83
+ for (const pkg of originals.keys())
84
+ Manifest.write(ctx.app, pkg.dirname, pkg.manifest);
85
+ return {
86
+ files: [...originals.keys()].map(pkg => this.manifestFile(pkg)),
87
+ restore: () => {
88
+ for (const [pkg, text] of originals) {
89
+ fs.writeFileSync(this.manifestFile(pkg), text, 'utf-8');
90
+ pkg.manifest.raw = JSON.parse(text);
91
+ }
92
+ },
93
+ };
94
+ }
95
+ /**
96
+ * `npm install --dry-run` at the repository root: npm's own resolver, which refuses a peer
97
+ * conflict anywhere in the tree with `ERESOLVE`, and writes nothing.
98
+ *
99
+ * Another package manager is not asked - its dry run is not the same question - and a note says so.
100
+ */
101
+ /* Measured on a manifest pairing `typescript@^7` with `@typescript-eslint/parser@8.40.0`, whose
102
+ * peer range stops below 6: `--dry-run` exits 1 with `ERESOLVE unable to resolve dependency tree`
103
+ * and the offending `peer typescript@">=4.8.4 <6.0.0"` line, as `--package-lock-only` does, but
104
+ * without touching the lockfile. */
105
+ async verify(ctx) {
106
+ const manager = CiService.resolvePackageManager(ctx.repository);
107
+ if (manager !== 'npm') {
108
+ console.error(colors.yellow(`Not verified: "${manager}" has no dry-run install to ask.`));
109
+ return undefined;
110
+ }
111
+ const result = await exec('npm install --dry-run --ignore-scripts --no-audit --no-fund', {
112
+ cwd: ctx.repository.dirname,
113
+ app: ctx.app,
114
+ throwOnError: false,
115
+ });
116
+ if (!result.code)
117
+ return undefined;
118
+ const lines = (result.stdout ?? '')
119
+ .split('\n')
120
+ .filter(line => /^npm (error|ERR!)/.test(line) && !/complete log|_logs/.test(line))
121
+ .map(line => line.replace(/^npm (error|ERR!)\s?/, ' '))
122
+ .filter(line => line.trim());
123
+ return lines.slice(0, 20).join('\n') || ` npm install --dry-run exited ${result.code}`;
124
+ }
125
+ /** One decision per dependency name `pkg` declares, whether or not it may move - one that may not
126
+ * still states rules the others have to keep. */
127
+ collect(pkg, settings, options, inRepo) {
128
+ const kinds = settings.types?.map(t => this.kindAliases[t] ?? t);
129
+ const byName = new Map();
130
+ for (const field of DEPENDENCY_KEYS) {
131
+ const declared = pkg.manifest.raw[field];
132
+ if (!declared || typeof declared !== 'object')
133
+ continue;
134
+ for (const [name, range] of Object.entries(declared)) {
135
+ if (typeof range !== 'string' || inRepo.has(name) || parseWorkspaceRange(range))
136
+ continue;
137
+ let d = byName.get(name);
138
+ if (!d) {
139
+ d = {
140
+ pkg,
141
+ name,
142
+ settings,
143
+ fields: new Map(),
144
+ movable: [],
145
+ wanted: false,
146
+ allowed: [],
147
+ candidates: [],
148
+ index: -1,
149
+ };
150
+ byName.set(name, d);
151
+ }
152
+ d.fields.set(field, range);
153
+ if (this.movablePattern.test(range) && (!kinds || kinds.includes(field)))
154
+ d.movable.push(field);
155
+ }
156
+ }
157
+ for (const d of byName.values()) {
158
+ const picked = !options.names?.length || micromatch.isMatch(d.name, options.names);
159
+ const rejected = settings.reject.length > 0 && micromatch.isMatch(d.name, settings.reject);
160
+ d.wanted = d.movable.length > 0 && picked && !rejected;
161
+ if (d.wanted) {
162
+ d.floor = d.movable.map(f => semver.minVersion(d.fields.get(f)).version).sort(semver.rcompare)[0];
163
+ }
164
+ }
165
+ return [...byName.values()];
166
+ }
167
+ /** Every name's releases, from the lowest version any declaration of it can mean - once per name,
168
+ * however many packages declare it, `concurrency` at a time. */
169
+ async fetchAll(ctx, decisions, withTime) {
170
+ const floors = new Map();
171
+ for (const d of decisions) {
172
+ for (const range of d.fields.values()) {
173
+ const min = semver.validRange(range) ? semver.minVersion(range)?.version : undefined;
174
+ if (!min)
175
+ continue;
176
+ const known = floors.get(d.name);
177
+ if (!known || semver.lt(min, known))
178
+ floors.set(d.name, min);
179
+ }
180
+ }
181
+ const names = [...floors.keys()];
182
+ const result = new Map();
183
+ let done = 0;
184
+ await this.mapConcurrent(names, ctx.options.concurrency, async (name) => {
185
+ result.set(name, await this.fetchReleases(name, `>=${floors.get(name)}`, ctx.repository.dirname, withTime));
186
+ ctx.options.onProgress?.(++done, names.length);
187
+ });
188
+ return result;
189
+ }
190
+ /** The registry's answer about one name - the seam a spec replaces. */
191
+ fetchReleases(name, range, cwd, withTime) {
192
+ return npmViewReleases(name, range, cwd, { withTime });
193
+ }
194
+ /** Fills in what `d` may move to, from its own settings and the rules that do not depend on what
195
+ * else moves (steps 1-3). */
196
+ prepare(d, info) {
197
+ d.releases = new Map((info?.releases ?? []).map(r => [r.version, r]));
198
+ d.latestTag = info?.latest;
199
+ if (!d.wanted)
200
+ return;
201
+ if (!info) {
202
+ d.error = 'the registry gave no answer';
203
+ return;
204
+ }
205
+ const newer = (info?.releases ?? [])
206
+ .filter(r => this.offerable(d, r))
207
+ .sort((a, b) => semver.rcompare(a.version, b.version));
208
+ d.available = newer[0]?.version;
209
+ d.allowed = newer.filter(r => !this.settingRefusal(d, r, info.time)).map(r => r.version);
210
+ if (d.available && d.available !== d.allowed[0]) {
211
+ d.skipReason = this.settingRefusal(d, newer[0], info.time);
212
+ }
213
+ d.baselineNode = this.nodeCoverage(d.pkg, d.releases.get(d.floor));
214
+ for (const version of d.allowed) {
215
+ const refusal = this.staticRefusal(d, d.releases.get(version));
216
+ if (!refusal)
217
+ d.candidates.push(version);
218
+ else if (version === d.allowed[0])
219
+ d.reason = `${version} refused: ${refusal}`;
220
+ }
221
+ d.index = d.candidates.length ? 0 : -1;
222
+ }
223
+ /** Whether the registry offers `release` as a move at all: newer than what is declared, not
224
+ * deprecated, not above the `latest` tag, and not a prerelease unless the range is on one. */
225
+ offerable(d, release) {
226
+ const v = semver.parse(release.version);
227
+ const floor = semver.parse(d.floor);
228
+ if (!v || release.deprecated || !semver.gt(v, floor))
229
+ return false;
230
+ if (d.latestTag && semver.valid(d.latestTag) && semver.gt(v, d.latestTag))
231
+ return false;
232
+ return !v.prerelease.length || floor.prerelease.length > 0;
233
+ }
234
+ /** Step 1: why the package's own settings leave `release` out - `deps.target` or `deps.minAge` -
235
+ * or `undefined` when they do not. */
236
+ settingRefusal(d, release, time) {
237
+ const sizes = d.pkg.versionScheme.bumpNames;
238
+ const size = this.sizeOf(d.floor, release.version);
239
+ const target = DependencyUpdater.targetFor(d.settings, d.name);
240
+ if (sizes.indexOf(size) > sizes.indexOf(target.size))
241
+ return `${size} - ${target.from} is "${target.size}"`;
242
+ if (d.settings.minAge > 0) {
243
+ const published = time?.[release.version];
244
+ if (!published)
245
+ return `no publish date to check deps.minAge against`;
246
+ const days = Math.floor((Date.now() - Date.parse(published)) / 86_400_000);
247
+ if (days < d.settings.minAge)
248
+ return `published ${days} day(s) ago - deps.minAge is ${d.settings.minAge}`;
249
+ }
250
+ return undefined;
251
+ }
252
+ /**
253
+ * How big a move from `from` to `to` is, **as npm's caret reads it**: anything a `^from` range
254
+ * would not accept is a `major`, so `0.1.0 -> 0.2.0` is one.
255
+ */
256
+ /* `semver.diff` alone calls `0.1.0 -> 0.2.0` a minor, and under `deps.target: minor` that would
257
+ * take a break the ecosystem itself treats as one - `^0.1.0` stops below `0.2.0` precisely
258
+ * because a 0.x minor is allowed to break. */
259
+ sizeOf(from, to) {
260
+ if (!semver.satisfies(to, `^${from}`, { includePrerelease: true }))
261
+ return 'major';
262
+ const diff = semver.diff(from, to) ?? 'patch';
263
+ return diff.replace(/^pre(?!release)/, '') === 'prerelease' ? 'patch' : diff.replace(/^pre(?!release)/, '');
264
+ }
265
+ /** Steps 2 and 3: the package's own other declarations of this name, and the runtime it supports.
266
+ * The rule `release` breaks, or `undefined`. */
267
+ staticRefusal(d, release) {
268
+ for (const [field, range] of d.fields) {
269
+ if (d.movable.includes(field) || !semver.validRange(range))
270
+ continue;
271
+ if (!semver.satisfies(release.version, range, { includePrerelease: true })) {
272
+ return `${d.pkg.name} declares ${d.name} ${range} in ${field}`;
273
+ }
274
+ }
275
+ const covered = d.baselineNode;
276
+ const needed = release.engines?.node;
277
+ if (covered && needed && semver.validRange(needed) && !semver.subset(covered, needed)) {
278
+ return `${d.name}@${release.version} needs node ${needed}, ${d.pkg.name} supports ${this.supportedNode(d.pkg)}`;
279
+ }
280
+ return undefined;
281
+ }
282
+ /**
283
+ * The Node versions a newer release has to keep covering: those the package supports **and** the
284
+ * lowest release its declaration allows now (`current`) already runs on.
285
+ */
286
+ /* **Not the package's whole range**, and that was measured wrong first. A package declaring
287
+ * `node >=18` with `eslint ^9` was offered no 9.x at all, because every one of them needs
288
+ * `^18.18.0` - but so does the 9.x it already installs. A rule the current version breaks is not
289
+ * one a newer version can be held to; only taking away a Node that works today is a refusal.
290
+ *
291
+ * **The floor, not the newest release the range allows.** `^1.0.0` installs 1.2.0 today, so
292
+ * measuring against that would wave 1.2.0 through - but raising the floor to it is exactly what
293
+ * stops a Node that 1.0.0 still served. */
294
+ nodeCoverage(pkg, current) {
295
+ const supported = this.supportedNode(pkg);
296
+ const runs = current?.engines?.node;
297
+ if (!supported || !runs || !semver.validRange(runs))
298
+ return supported;
299
+ const sets = new semver.Range(supported).set.flatMap(a => new semver.Range(runs).set.map(b => [...a, ...b].map(c => c.value).join(' ')));
300
+ const both = sets.filter(set => semver.validRange(set) && semver.minVersion(set)).join(' || ');
301
+ return both || supported;
302
+ }
303
+ /** The Node versions `pkg` supports - its own `engines.node`, else the repository root's. */
304
+ supportedNode(pkg) {
305
+ const own = pkg.manifest.raw?.engines?.node;
306
+ const root = pkg.repository?.rootPackage.manifest.raw?.engines?.node;
307
+ const range = typeof own === 'string' ? own : typeof root === 'string' ? root : undefined;
308
+ return range && semver.validRange(range) ? range : undefined;
309
+ }
310
+ /**
311
+ * Steps 4 and 5: while any peer range is broken by a version that moved, one side steps down.
312
+ *
313
+ * The side that steps down is the one the range is *about* - for "`parser` needs `typescript`
314
+ * below 6", `typescript` - to the newest candidate the range allows; only when none is left does
315
+ * the side stating the range step down instead.
316
+ */
317
+ resolve(decisions, ctx) {
318
+ const byPackage = new Map();
319
+ for (const d of decisions) {
320
+ if (!byPackage.has(d.pkg))
321
+ byPackage.set(d.pkg, new Map());
322
+ byPackage.get(d.pkg).set(d.name, d);
323
+ }
324
+ const repoPackages = new Map([ctx.repository.rootPackage, ...ctx.repository.packages].map(p => [p.name, p]));
325
+ for (let changed = true; changed;) {
326
+ changed = false;
327
+ for (const [pkg, own] of byPackage) {
328
+ for (const stating of own.values()) {
329
+ const version = this.effective(stating);
330
+ const peers = version ? stating.releases?.get(version)?.peerDependencies : undefined;
331
+ for (const [name, range] of Object.entries(peers ?? {})) {
332
+ const about = own.get(name);
333
+ if (about && this.settle(about, stating, range, `${stating.name}@${version} needs ${name} ${range}`)) {
334
+ changed = true;
335
+ }
336
+ }
337
+ }
338
+ for (const sibling of this.siblingsOf(pkg, repoPackages)) {
339
+ const theirs = byPackage.get(sibling);
340
+ for (const [name, declared] of Object.entries(sibling.manifest.raw.peerDependencies ?? {})) {
341
+ const about = own.get(name);
342
+ const stating = theirs?.get(name);
343
+ const range = stating ? this.rangeFor(stating, 'peerDependencies') : declared;
344
+ if (about && this.settle(about, stating, range, `${sibling.name} needs ${name} ${range}`))
345
+ changed = true;
346
+ }
347
+ }
348
+ }
349
+ }
350
+ }
351
+ /** One broken rule, mended by one step down. Whether anything moved. */
352
+ settle(about, stating, range, rule) {
353
+ if (typeof range !== 'string' || !semver.validRange(range))
354
+ return false;
355
+ const version = this.effective(about);
356
+ if (!version || semver.satisfies(version, range, { includePrerelease: true }))
357
+ return false;
358
+ if (about.index >= 0) {
359
+ const fits = about.candidates.findIndex((v, i) => i > about.index && semver.satisfies(v, range));
360
+ this.stepTo(about, fits, rule);
361
+ return true;
362
+ }
363
+ if (stating && stating.index >= 0) {
364
+ this.stepTo(stating, stating.index + 1 < stating.candidates.length ? stating.index + 1 : -1, rule);
365
+ return true;
366
+ }
367
+ return false;
368
+ }
369
+ /** Moves `d` down to `index` (`-1` keeps what is declared), remembering why it left its newest. */
370
+ stepTo(d, index, rule) {
371
+ if (d.index === 0 && !d.reason)
372
+ d.reason = `${d.candidates[0]} refused: ${rule}`;
373
+ d.index = index;
374
+ }
375
+ /** The version `d` resolves to: the one it moves to, or else the newest release its declarations
376
+ * already allow, which is what an install picks. */
377
+ effective(d) {
378
+ if (d.index >= 0)
379
+ return d.candidates[d.index];
380
+ if (!d.releases?.size)
381
+ return undefined;
382
+ const ranges = [...d.fields.values()].filter(r => semver.validRange(r));
383
+ if (!ranges.length)
384
+ return undefined;
385
+ return [...d.releases.keys()]
386
+ .filter(v => !d.latestTag || !semver.valid(d.latestTag) || semver.lte(v, d.latestTag))
387
+ .filter(v => ranges.every(r => semver.satisfies(v, r)))
388
+ .sort(semver.rcompare)[0];
389
+ }
390
+ /** What `d` declares in `field` once it has moved. */
391
+ rangeFor(d, field) {
392
+ const declared = d.fields.get(field) ?? '';
393
+ if (d.index < 0 || !d.movable.includes(field))
394
+ return declared;
395
+ return this.movablePattern.exec(declared)[1] + d.candidates[d.index];
396
+ }
397
+ /** The in-repo packages `pkg` declares a dependency on. */
398
+ siblingsOf(pkg, repoPackages) {
399
+ const result = new Set();
400
+ for (const field of DEPENDENCY_KEYS) {
401
+ for (const name of Object.keys(pkg.manifest.raw[field] ?? {})) {
402
+ const sibling = repoPackages.get(name);
403
+ if (sibling && sibling !== pkg)
404
+ result.add(sibling);
405
+ }
406
+ }
407
+ return [...result];
408
+ }
409
+ toEntry(d) {
410
+ const entry = {
411
+ package: d.pkg,
412
+ name: d.name,
413
+ types: [...d.fields.keys()],
414
+ current: d.fields.get(d.movable[0]),
415
+ status: 'up-to-date',
416
+ latest: d.allowed[0],
417
+ available: d.available,
418
+ };
419
+ if (d.error)
420
+ return { ...entry, status: 'error', reason: d.error };
421
+ if (!d.available)
422
+ return entry;
423
+ if (!d.allowed.length)
424
+ return { ...entry, status: 'skipped', reason: d.skipReason };
425
+ if (d.index < 0)
426
+ return { ...entry, status: 'held', reason: d.reason };
427
+ const version = d.candidates[d.index];
428
+ const result = {
429
+ ...entry,
430
+ status: 'update',
431
+ target: this.rangeFor(d, d.movable[0]),
432
+ bump: this.sizeOf(d.floor, version),
433
+ /** What stopped it short of `available`, if anything did - another rule first, since that is
434
+ * the nearer reason. */
435
+ reason: version !== d.allowed[0] ? d.reason : version !== d.available ? d.skipReason : undefined,
436
+ };
437
+ this.planned.set(result, d);
438
+ return result;
439
+ }
440
+ manifestFile(pkg) {
441
+ return path.join(pkg.dirname, 'package.json');
442
+ }
443
+ /** `fn` over `items`, at most `limit` at a time. */
444
+ async mapConcurrent(items, limit, fn) {
445
+ let next = 0;
446
+ const worker = async () => {
447
+ while (next < items.length)
448
+ await fn(items[next++]);
449
+ };
450
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
451
+ }
452
+ }
@@ -1,6 +1,7 @@
1
1
  import type { Package } from '../../../core/classes/package.js';
2
2
  import type { Platform } from '../../../core/interfaces/plugin.js';
3
3
  import type { RunService } from '../../../services/run.service.js';
4
+ import { NodeDependencyUpdater } from './node-dependency-updater.js';
4
5
  import { NodeManifestProvider } from './node-manifest.provider.js';
5
6
  import { NodeVersionPlanService } from './services/version-plan.service.js';
6
7
  export declare class NodePlatform implements Platform {
@@ -9,6 +10,7 @@ export declare class NodePlatform implements Platform {
9
10
  name: string;
10
11
  manifestProvider: NodeManifestProvider;
11
12
  versionPlanner: NodeVersionPlanService;
13
+ dependencyUpdater: NodeDependencyUpdater;
12
14
  /**
13
15
  * npm's `workspaces` globs, resolved against `dir` - the directories directly below it that hold
14
16
  * a `package.json`.
@@ -2,6 +2,7 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import parseNpmScript from '@netlify/parse-npm-script';
4
4
  import glob from 'fast-glob';
5
+ import { NodeDependencyUpdater } from './node-dependency-updater.js';
5
6
  import { NodeManifestProvider } from './node-manifest.provider.js';
6
7
  import { NodeVersionPlanService } from './services/version-plan.service.js';
7
8
  /** Stands in for a script the package does not declare. `#` is a shell comment, so were it ever to
@@ -13,6 +14,7 @@ export class NodePlatform {
13
14
  name = 'node';
14
15
  manifestProvider = new NodeManifestProvider();
15
16
  versionPlanner = new NodeVersionPlanService();
17
+ dependencyUpdater = new NodeDependencyUpdater();
16
18
  /**
17
19
  * npm's `workspaces` globs, resolved against `dir` - the directories directly below it that hold
18
20
  * a `package.json`.
@@ -46,3 +46,34 @@ export declare function npmViewVersion(name: string, cwd: string, options?: {
46
46
  registry?: string;
47
47
  userconfig?: string;
48
48
  }): Promise<string | undefined>;
49
+ /** One published version, as `rman deps` needs it. */
50
+ export interface NpmRelease {
51
+ version: string;
52
+ /** The deprecation message, when the version carries one. */
53
+ deprecated?: string;
54
+ peerDependencies?: Record<string, string>;
55
+ peerDependenciesMeta?: Record<string, {
56
+ optional?: boolean;
57
+ }>;
58
+ engines?: Record<string, string>;
59
+ }
60
+ /** What `rman deps` asks of the registry about one package name. */
61
+ export interface NpmReleases {
62
+ /** Every version satisfying the range that was asked about, oldest first. */
63
+ releases: NpmRelease[];
64
+ /** The `latest` dist-tag. */
65
+ latest?: string;
66
+ /** When each version was published, when it was asked for. */
67
+ time?: Record<string, string>;
68
+ }
69
+ /**
70
+ * `npm view '<name>@<range>' version deprecated peerDependencies peerDependenciesMeta engines` plus
71
+ * the package's `dist-tags` (and `time`, when `withTime`) - two calls, run at once. `undefined` when
72
+ * the registry gave no answer at all.
73
+ *
74
+ * **Not catch-everything, unlike the two above.** A name the registry does not know is an answer
75
+ * `rman deps` has to report, not a state it can treat as "nothing newer".
76
+ */
77
+ export declare function npmViewReleases(name: string, range: string, cwd: string, options?: {
78
+ withTime?: boolean;
79
+ }): Promise<NpmReleases | undefined>;