rman 2.24.0 → 2.25.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.
@@ -33,6 +33,15 @@ export declare class NodeManifestProvider implements ManifestProvider {
33
33
  * Only an explicit version after `workspace:` needs bumping, like a plain range.
34
34
  */
35
35
  updateDependencyVersions(manifest: Manifest, bumped: ReadonlyMap<Package, string>): void;
36
+ /**
37
+ * Every field naming `dependency`, with npm's answer to whether its range accepts `version` and
38
+ * the range with its floor raised to it - see `ManifestProvider.referencesTo`.
39
+ *
40
+ * A bare `"workspace:*"`/`"^"`/`"~"` is `dynamic`: it already follows the sibling, so there is
41
+ * nothing to keep or raise. An explicit `"workspace:<range>"` is judged by the range after it.
42
+ */
43
+ referencesTo(manifest: Manifest, dependency: string, version: string): ManifestProvider.Reference[];
44
+ setReference(manifest: Manifest, field: string, dependency: string, range: string): void;
36
45
  /**
37
46
  * What the npm registry says this package's current version is - used *only* by
38
47
  * `detectChangeHash`, to guess a tag name for a package that has no git tag yet. See
@@ -55,6 +64,15 @@ export declare class NodeManifestProvider implements ManifestProvider {
55
64
  stampVersion(file: string, content: string, version: string, options?: {
56
65
  constant?: string;
57
66
  }): string | undefined;
67
+ /**
68
+ * `range` with its lower bound moved up to `version`, keeping its upper bound and its operator -
69
+ * `undefined` when the floor is already at or above it, `null` for a shape with no single floor
70
+ * to move.
71
+ *
72
+ * The shapes a hand-written range takes: `^a`, `~a`, `>=a`, `>=a <b` (or `<=b`) and `a - b`. An
73
+ * exact pin never needs moving - one that accepts `version` already is it.
74
+ */
75
+ protected raiseFloor(range: string, version: string): string | null | undefined;
58
76
  write(dir: string, manifest: Manifest): void;
59
77
  /** The peers this manifest marks `"optional": true`, which `dependencies` leaves out of the
60
78
  * graph. `optionalDependencies` is deliberately *not* read the same way: an optional peer says
@@ -1,5 +1,6 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
+ import semver from 'semver';
3
4
  import { Manifest } from '../../../core/interfaces/manifest.js';
4
5
  import { stampVersionConstant } from '../../../utils/version-stamp.js';
5
6
  import { npmViewVersion } from '../../publish-targets/npm/npm-view.js';
@@ -91,6 +92,44 @@ export class NodeManifestProvider {
91
92
  }
92
93
  }
93
94
  }
95
+ /**
96
+ * Every field naming `dependency`, with npm's answer to whether its range accepts `version` and
97
+ * the range with its floor raised to it - see `ManifestProvider.referencesTo`.
98
+ *
99
+ * A bare `"workspace:*"`/`"^"`/`"~"` is `dynamic`: it already follows the sibling, so there is
100
+ * nothing to keep or raise. An explicit `"workspace:<range>"` is judged by the range after it.
101
+ */
102
+ referencesTo(manifest, dependency, version) {
103
+ const refs = [];
104
+ for (const field of DEPENDENCY_KEYS) {
105
+ const declared = manifest.raw[field]?.[dependency];
106
+ if (typeof declared !== 'string')
107
+ continue;
108
+ const workspace = parseWorkspaceRange(declared);
109
+ if (workspace && workspace.selector !== 'explicit') {
110
+ refs.push({ field, range: declared, dynamic: true, accepts: true });
111
+ continue;
112
+ }
113
+ const range = workspace ? workspace.range : declared;
114
+ const raised = this.raiseFloor(range, version);
115
+ refs.push({
116
+ field,
117
+ range: declared,
118
+ accepts: semver.satisfies(version, range),
119
+ ...(raised === null
120
+ ? { unraisable: true }
121
+ : raised
122
+ ? { raised: workspace ? `workspace:${raised}` : raised }
123
+ : {}),
124
+ });
125
+ }
126
+ return refs;
127
+ }
128
+ setReference(manifest, field, dependency, range) {
129
+ const deps = manifest.raw[field];
130
+ if (deps && typeof deps === 'object')
131
+ deps[dependency] = range;
132
+ }
94
133
  /**
95
134
  * What the npm registry says this package's current version is - used *only* by
96
135
  * `detectChangeHash`, to guess a tag name for a package that has no git tag yet. See
@@ -115,6 +154,45 @@ export class NodeManifestProvider {
115
154
  stampVersion(file, content, version, options) {
116
155
  return stampVersionConstant(content, version, options?.constant);
117
156
  }
157
+ /**
158
+ * `range` with its lower bound moved up to `version`, keeping its upper bound and its operator -
159
+ * `undefined` when the floor is already at or above it, `null` for a shape with no single floor
160
+ * to move.
161
+ *
162
+ * The shapes a hand-written range takes: `^a`, `~a`, `>=a`, `>=a <b` (or `<=b`) and `a - b`. An
163
+ * exact pin never needs moving - one that accepts `version` already is it.
164
+ */
165
+ /* **`null` rather than a guess for anything else**, `||` above all: which alternative's floor is
166
+ * meant cannot be read off the range, and rewriting the wrong one changes what it accepts.
167
+ * `version` reports it instead, so the range can be raised by hand. */
168
+ raiseFloor(range, version) {
169
+ const r = range.trim();
170
+ let floor;
171
+ try {
172
+ floor = semver.minVersion(r);
173
+ }
174
+ catch {
175
+ return null;
176
+ }
177
+ if (!floor)
178
+ return null;
179
+ if (semver.lte(version, floor))
180
+ return undefined;
181
+ if (semver.valid(r))
182
+ return undefined;
183
+ const caret = /^([\^~])\s*v?\d[^\s|]*$/.exec(r);
184
+ if (caret)
185
+ return `${caret[1]}${version}`;
186
+ if (/^>=\s*v?\d\S*$/.test(r))
187
+ return `>=${version}`;
188
+ const bounded = /^>=\s*v?\d\S*\s+(<=?\s*v?\d\S*)$/.exec(r);
189
+ if (bounded)
190
+ return `>=${version} ${bounded[1]}`;
191
+ const hyphen = /^v?\d\S*\s+-\s+(v?\d\S*)$/.exec(r);
192
+ if (hyphen)
193
+ return `${version} - ${hyphen[1]}`;
194
+ return null;
195
+ }
118
196
  write(dir, manifest) {
119
197
  /**
120
198
  * The version is written back into `raw` before serializing, because `raw` is the document and
@@ -226,12 +226,14 @@ async function cleanGlobs(dirname, include, exclude, dryRun) {
226
226
  * **The build directory is excluded by name from the config, never as the literal `build`** - see
227
227
  * `buildDirOf`. Its contents are the *point* of a build and are removed by `clean.include` when the
228
228
  * repository asks, not by a rule about stray files. */
229
- async function cleanTsArtifacts(pkg, dryRun) {
229
+ async function cleanTsArtifacts(pkg, exclude, dryRun) {
230
230
  const dirname = pkg.dirname;
231
231
  const buildDir = buildDirOf(pkg);
232
232
  const files = await fg('**/*.{js,js.map,d.ts}', {
233
233
  cwd: dirname,
234
- ignore: ['**/node_modules/**', `${buildDir}/**`, `**/${buildDir}/**`],
234
+ /** `clean.exclude` protects a file from this sweep too - the one way to keep a file the rules
235
+ * below would read as output. */
236
+ ignore: ['**/node_modules/**', `${buildDir}/**`, `**/${buildDir}/**`, ...exclude],
235
237
  onlyFiles: true,
236
238
  dot: true,
237
239
  absolute: true,
@@ -264,17 +266,24 @@ async function cleanTsArtifacts(pkg, dryRun) {
264
266
  * - **A `.d.ts` always needs its `.ts`/`.tsx` beside it**, everywhere including `src` - a
265
267
  * hand-written declaration is an ordinary thing to keep in a source tree, and this is the rule
266
268
  * that has always been here.
267
- * - **A `.js`/`.js.map` under `src`/`test` goes regardless.** Everything there is TypeScript, so an
268
- * orphan - output whose source was renamed or deleted - is still output, and sweeping it is the
269
- * `ts-cleanup --all` behaviour this replaced.
269
+ * - **A `.js`/`.js.map` under `src`/`test` goes**, unless a `.d.ts` with no `.ts` sits beside the
270
+ * `.js`. Everything else there is TypeScript, so an orphan - output whose source was renamed or
271
+ * deleted - is still output, and sweeping it is the `ts-cleanup --all` behaviour this replaced.
270
272
  * - **A `.js`/`.js.map` anywhere else needs its source beside it**, because out there it could just
271
273
  * as easily be `index.js`, `*.config.js` or `scripts/*.js`, which nobody generated.
272
274
  */
275
+ /* **A `.js` declared by a hand-written `.d.ts` is kept**, because the first rule already reads that
276
+ * `.d.ts` as hand-written - and a declaration nobody generated describes a module nobody compiled.
277
+ * Measured on `panates/hl7v2`: `src/data/hl7-data.js`, a generated plain-JS data module with its
278
+ * `.d.ts` beside it, was deleted by the preset's `rman clean` before every build, and the next step
279
+ * failed with esbuild's `Could not resolve "./data/hl7-data.js"` - an error about the import, far
280
+ * from the cause. The cost, stated: a tsc orphan *pair* (`.js` and `.d.ts` whose `.ts` was renamed)
281
+ * now stays, where before only its `.d.ts` did. */
273
282
  function isBuildOutput(dirname, file) {
274
283
  if (file.endsWith('.d.ts'))
275
284
  return hasTsSource(file, '.d.ts');
276
285
  if (insideTsSourceDir(dirname, file))
277
- return true;
286
+ return !(file.endsWith('.js') && isDeclaredByHand(file));
278
287
  return hasTsSource(file, file.endsWith('.js.map') ? '.js.map' : '.js');
279
288
  }
280
289
  /** Directories where everything is TypeScript, so a compiled file needs no source beside it to be
@@ -293,6 +302,11 @@ function hasTsSource(file, suffix) {
293
302
  const base = file.slice(0, -suffix.length);
294
303
  return fs.existsSync(base + '.ts') || fs.existsSync(base + '.tsx');
295
304
  }
305
+ /** Whether a `.js` has a `.d.ts` beside it and no `.ts`/`.tsx` - a module with a hand-written
306
+ * declaration, not compiled output. */
307
+ function isDeclaredByHand(file) {
308
+ return fs.existsSync(file.slice(0, -'.js'.length) + '.d.ts') && !hasTsSource(file, '.js');
309
+ }
296
310
  /**
297
311
  * Removes TypeScript's incremental-build cache files (`*.tsbuildinfo`) anywhere under `dirname` -
298
312
  * `tsc --build`/`composite`/`incremental` output that `ts-cleanup` never touched, but which can
@@ -316,11 +330,11 @@ async function cleanPackage(pkg, item, panelEnabled, dryRun, logger) {
316
330
  item.status = 'running';
317
331
  item.startedAt = Date.now();
318
332
  try {
333
+ const { include, exclude } = cleanConfig(pkg);
319
334
  item.currentStep = 'ts';
320
- const tsRemoved = await cleanTsArtifacts(pkg, dryRun);
335
+ const tsRemoved = await cleanTsArtifacts(pkg, exclude, dryRun);
321
336
  const buildInfoRemoved = await cleanTsBuildInfo(pkg.dirname, dryRun);
322
337
  item.currentStep = 'glob';
323
- const { include, exclude } = cleanConfig(pkg);
324
338
  const globRemoved = await cleanGlobs(pkg.dirname, include, exclude, dryRun);
325
339
  const removed = [...tsRemoved, ...buildInfoRemoved, ...globRemoved];
326
340
  const verb = dryRun ? 'would rm' : 'rm';
@@ -86,6 +86,12 @@ declare const config: {
86
86
  type: "string";
87
87
  choices: readonly ["changed", "dependents", "group"];
88
88
  };
89
+ dependentRanges: {
90
+ target: "config";
91
+ describe: string;
92
+ type: "string";
93
+ choices: readonly ["rewrite", "keep"];
94
+ };
89
95
  releaseTagPattern: {
90
96
  target: "config";
91
97
  describe: string;
@@ -292,6 +298,12 @@ declare const versionCommand: (app: import("../index.js").RmanApplication) => {
292
298
  type: "string";
293
299
  choices: readonly ["changed", "dependents", "group"];
294
300
  };
301
+ dependentRanges: {
302
+ target: "config";
303
+ describe: string;
304
+ type: "string";
305
+ choices: readonly ["rewrite", "keep"];
306
+ };
295
307
  releaseTagPattern: {
296
308
  target: "config";
297
309
  describe: string;
@@ -5,6 +5,7 @@ import { registerCommand } from '../interfaces/rman-config.interface.js';
5
5
  import { VersionPlanService } from '../services/version-plan.service.js';
6
6
  import { assertAllowedBranch, branchGuardOptions, readBranchGuardOptions } from '../utils/branch-guard.js';
7
7
  import { fitTable } from '../utils/fit-table.js';
8
+ import { UNCOMMITTED } from '../utils/git.js';
8
9
  import { packageFilterOptions, readPackageFilterOptions } from '../utils/package-filter.js';
9
10
  import { isNamedGroupKey, isSoloGroupKey, ROOT_GROUP_KEY } from '../utils/version-group.js';
10
11
  /**
@@ -119,6 +120,17 @@ const config = {
119
120
  type: 'string',
120
121
  choices: ['changed', 'dependents', 'group'],
121
122
  },
123
+ dependentRanges: {
124
+ target: 'config',
125
+ describe: "What happens to this package's range on an in-repo dependency that is getting a new " +
126
+ 'version: "rewrite" (the default) sets it to the new version and releases this package for ' +
127
+ 'it; "keep" leaves a range that still accepts the new version alone and does not release ' +
128
+ "this package for it, refuses the run when the range does not, and raises the range's " +
129
+ 'lower bound when this package is released for changes of its own. A prerelease never ' +
130
+ "fails a range and never raises one. Per-package cascaded - the range is the package's own.",
131
+ type: 'string',
132
+ choices: ['rewrite', 'keep'],
133
+ },
122
134
  releaseTagPattern: {
123
135
  target: 'config',
124
136
  describe: "Tag naming the repository's own release, as opposed to the per-package tags " +
@@ -235,8 +247,11 @@ const versionCommand = registerCommand(app => {
235
247
  printPlan(plan);
236
248
  const errors = plan.filter(e => e.status === 'error');
237
249
  if (errors.length) {
238
- const message = `${errors.length} package(s) have uncommitted local changes ` +
239
- '(pass --ignore-dirty to exclude them instead of aborting)';
250
+ const dirty = errors.filter(e => e.reason?.startsWith(UNCOMMITTED)).length;
251
+ const message = dirty === errors.length
252
+ ? `${errors.length} package(s) have uncommitted local changes ` +
253
+ '(pass --ignore-dirty to exclude them instead of aborting)'
254
+ : `${errors.length} package(s) cannot be versioned - see the Reason column`;
240
255
  console.log(colors.red(message));
241
256
  const err = new Error(message);
242
257
  err.logged = true;
package/constants.js CHANGED
@@ -1 +1 @@
1
- export const version = '2.24.0';
1
+ export const version = '2.25.0';
@@ -159,6 +159,42 @@ export interface ManifestProvider {
159
159
  * by path.
160
160
  */
161
161
  updateDependencyVersions?(manifest: Manifest, bumped: ReadonlyMap<Package, string>): void;
162
+ /**
163
+ * Every reference this manifest makes to the in-repo package `dependency`, one per field naming
164
+ * it, each judged against `version` - whether the range written there accepts it, and the range
165
+ * with its lower bound raised to it.
166
+ *
167
+ * What `.rmanrc "version.dependentRanges": keep` asks instead of `updateDependencyVersions`: a
168
+ * hand-written range is left alone while it accepts the new version, refused when it does not,
169
+ * and has its floor raised when the package holding it is released for reasons of its own.
170
+ *
171
+ * Omit it and a package has no ranges to keep, so `keep` behaves like `rewrite` for it.
172
+ */
173
+ referencesTo?(manifest: Manifest, dependency: string, version: string): ManifestProvider.Reference[];
174
+ /**
175
+ * Writes `range` as this manifest's reference to `dependency` in `field` - one of the fields
176
+ * `referencesTo` reported. Mutates `manifest.raw`, like `updateDependencyVersions`.
177
+ */
178
+ setReference?(manifest: Manifest, field: string, dependency: string, range: string): void;
179
+ }
180
+ export declare namespace ManifestProvider {
181
+ /** One field's reference to an in-repo package - see `ManifestProvider.referencesTo`. */
182
+ interface Reference {
183
+ /** Where it is written - npm's `dependencies`, `peerDependencies`, ... */
184
+ field: string;
185
+ /** The range as written. */
186
+ range: string;
187
+ /** It follows the dependency by itself (npm's bare `"workspace:^"`) - always accepts, never
188
+ * rewritten, and keeps `rewrite`'s handling under `keep` too. */
189
+ dynamic?: boolean;
190
+ /** Whether the range accepts the version asked about. */
191
+ accepts: boolean;
192
+ /** The range with its lower bound raised to the version asked about, when that moves it.
193
+ * Unset when the floor is already there, or when `unraisable`. */
194
+ raised?: string;
195
+ /** The range's shape has no single lower bound this provider knows how to move. */
196
+ unraisable?: boolean;
197
+ }
162
198
  }
163
199
  /**
164
200
  * The registry, merged onto the `Manifest` interface so one name carries both the shape and the
@@ -218,6 +254,11 @@ export declare namespace Manifest {
218
254
  * reference each other by path has nothing here to go stale.
219
255
  */
220
256
  function updateDependencyVersions(pkg: Package, bumped: ReadonlyMap<Package, string>): void;
257
+ /** `pkg`'s references to `dependency`, judged against `version` - see
258
+ * `ManifestProvider.referencesTo`. Empty where the provider has no opinion. */
259
+ function referencesTo(pkg: Package, dependency: string, version: string): ManifestProvider.Reference[];
260
+ /** Writes one of `pkg`'s references - see `ManifestProvider.setReference`. */
261
+ function setReference(pkg: Package, field: string, dependency: string, range: string): void;
221
262
  /**
222
263
  * Rewrites a hard-coded version in `content` through `pkg`'s own ecosystem - see
223
264
  * `Plugin.stampVersion`.
@@ -88,6 +88,17 @@ export var Manifest;
88
88
  platformOf(pkg)?.manifestProvider.updateDependencyVersions?.(pkg.manifest, bumped);
89
89
  }
90
90
  Manifest.updateDependencyVersions = updateDependencyVersions;
91
+ /** `pkg`'s references to `dependency`, judged against `version` - see
92
+ * `ManifestProvider.referencesTo`. Empty where the provider has no opinion. */
93
+ function referencesTo(pkg, dependency, version) {
94
+ return platformOf(pkg)?.manifestProvider.referencesTo?.(pkg.manifest, dependency, version) ?? [];
95
+ }
96
+ Manifest.referencesTo = referencesTo;
97
+ /** Writes one of `pkg`'s references - see `ManifestProvider.setReference`. */
98
+ function setReference(pkg, field, dependency, range) {
99
+ platformOf(pkg)?.manifestProvider.setReference?.(pkg.manifest, field, dependency, range);
100
+ }
101
+ Manifest.setReference = setReference;
91
102
  /**
92
103
  * Rewrites a hard-coded version in `content` through `pkg`'s own ecosystem - see
93
104
  * `Plugin.stampVersion`.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "rman",
3
3
  "description": "Repository manager",
4
- "version": "2.24.0",
4
+ "version": "2.25.0",
5
5
  "author": "Panates",
6
6
  "license": "MIT",
7
7
  "dependencies": {
@@ -142,6 +142,26 @@ export declare abstract class VersionPlanService {
142
142
  */
143
143
  protected declaredPreid(members: Package[], label: string): string | undefined;
144
144
  protected declaredCascade(members: Package[]): VersionPlanService.Cascade | undefined;
145
+ /**
146
+ * Whether `dependent` keeps its range on `dependency` (`.rmanrc "version.dependentRanges": keep`)
147
+ * and that range still accepts `version` - so the new version reaches it without a release of
148
+ * its own. `undefined` means "release it as `rewrite` would": the key says `rewrite`, the range
149
+ * no longer accepts the version (refused later, by `checkKeptRanges`), or there is no range to
150
+ * judge - a reference that follows the dependency by itself, or an edge `.rmanrc
151
+ * "dependencies"` declared with no range at all.
152
+ *
153
+ * A prerelease is accepted whatever the range says: a range written for stable releases is not
154
+ * meant to take a beta, and the dependent keeps resolving the stable one it was built against.
155
+ */
156
+ protected rangeVerdict(dependent: Package, dependency: Package, version: string): 'accepts' | undefined;
157
+ /**
158
+ * The plan's last word on kept ranges: every package that keeps its ranges is checked against
159
+ * the versions its in-repo dependencies are getting. A range that refuses one makes the package
160
+ * an `'error'` naming the field, the range and the version, so `version` writes nothing and the
161
+ * range can be widened by hand. One left out of the release because its range still accepts says
162
+ * so in its reason, and one being released notes a range whose floor cannot be raised.
163
+ */
164
+ protected checkKeptRanges(packages: Package[], entries: Map<string, VersionPlanService.Entry>): void;
145
165
  /**
146
166
  * The largest bump `commits` ask for, in `scheme`'s own names.
147
167
  *
@@ -1,6 +1,7 @@
1
1
  import path from 'node:path';
2
2
  import { RmanApplication } from '../core/application.js';
3
3
  import { assertOneScheme, semverScheme, VersionScheme } from '../core/classes/version-scheme.js';
4
+ import { Manifest } from '../core/interfaces/manifest.js';
4
5
  import { skipReasonFor, targetsOf } from '../core/interfaces/publish-target.js';
5
6
  import { dirtyReason, filesUnder, GitHelper } from '../utils/git.js';
6
7
  import { filterPackages } from '../utils/package-filter.js';
@@ -185,6 +186,7 @@ export class VersionPlanService {
185
186
  this.computeGroupPlan(key, members, changeByPackage, explicitVersion, preid, entries);
186
187
  }
187
188
  this.rippleCrossGroup(packages, entries, options.preid);
189
+ this.checkKeptRanges(packages, entries);
188
190
  const result = packages.map(pkg => entries.get(pkg.name));
189
191
  if (repository.monorepo) {
190
192
  result.push(this.buildRootEntry(repository, result, {
@@ -364,6 +366,86 @@ export class VersionPlanService {
364
366
  return widest;
365
367
  return undefined;
366
368
  }
369
+ /**
370
+ * Whether `dependent` keeps its range on `dependency` (`.rmanrc "version.dependentRanges": keep`)
371
+ * and that range still accepts `version` - so the new version reaches it without a release of
372
+ * its own. `undefined` means "release it as `rewrite` would": the key says `rewrite`, the range
373
+ * no longer accepts the version (refused later, by `checkKeptRanges`), or there is no range to
374
+ * judge - a reference that follows the dependency by itself, or an edge `.rmanrc
375
+ * "dependencies"` declared with no range at all.
376
+ *
377
+ * A prerelease is accepted whatever the range says: a range written for stable releases is not
378
+ * meant to take a beta, and the dependent keeps resolving the stable one it was built against.
379
+ */
380
+ /* **Why `keep` exists**, asked for by `panates/syncbridge`: nine extensions declare
381
+ * `"@syncbridge/common": ">=1.1.0 <2"` so that a release of `common` does not have to release
382
+ * all nine. `rewrite` turned the range back into `^1.2.0` and released every one of them anyway.
383
+ * That narrows the cascade npm's own table asks for, and the cost is stated rather than hidden: a
384
+ * dependent that bundles its dependency keeps shipping the older code until it is released
385
+ * itself. Choosing `keep` is the repository accepting that; it is not the default. */
386
+ rangeVerdict(dependent, dependency, version) {
387
+ if (dependent.config?.version?.dependentRanges !== 'keep')
388
+ return undefined;
389
+ const refs = Manifest.referencesTo(dependent, dependency.name, version).filter(r => !r.dynamic);
390
+ if (!refs.length)
391
+ return undefined;
392
+ if (dependency.versionScheme.isPrerelease(version))
393
+ return 'accepts';
394
+ return refs.every(r => r.accepts) ? 'accepts' : undefined;
395
+ }
396
+ /**
397
+ * The plan's last word on kept ranges: every package that keeps its ranges is checked against
398
+ * the versions its in-repo dependencies are getting. A range that refuses one makes the package
399
+ * an `'error'` naming the field, the range and the version, so `version` writes nothing and the
400
+ * range can be widened by hand. One left out of the release because its range still accepts says
401
+ * so in its reason, and one being released notes a range whose floor cannot be raised.
402
+ */
403
+ /* **Every kept package, released or not - a `publish.skip` one included.** A range that no longer
404
+ * accepts its sibling breaks more than a publish: npm workspaces link a sibling only while the
405
+ * range accepts its version, and otherwise install it from the registry, silently. */
406
+ checkKeptRanges(packages, entries) {
407
+ for (const pkg of packages) {
408
+ if (pkg.config?.version?.dependentRanges !== 'keep')
409
+ continue;
410
+ const entry = entries.get(pkg.name);
411
+ if (!entry)
412
+ continue;
413
+ const refused = [];
414
+ const kept = [];
415
+ const unraisable = [];
416
+ for (const dep of pkg.dependencies) {
417
+ const depEntry = entries.get(dep.name);
418
+ const version = depEntry?.status === 'bump' ? depEntry.to : dep.version;
419
+ const refs = Manifest.referencesTo(pkg, dep.name, version).filter(r => !r.dynamic);
420
+ if (!refs.length || dep.versionScheme.isPrerelease(version))
421
+ continue;
422
+ for (const ref of refs) {
423
+ if (!ref.accepts) {
424
+ /** Only a version this run gives: a range already refusing the version on disk is not
425
+ * this run's doing, and npm reports it at install time. */
426
+ if (depEntry?.status === 'bump') {
427
+ refused.push(`${ref.field} "${dep.name}": "${ref.range}" does not accept ${version}`);
428
+ }
429
+ }
430
+ else if (entry.status === 'bump' && ref.unraisable) {
431
+ unraisable.push(`${dep.name} "${ref.range}"`);
432
+ }
433
+ }
434
+ if (depEntry?.status === 'bump' && refs.every(r => r.accepts))
435
+ kept.push(`${dep.name} "${refs[0].range}"`);
436
+ }
437
+ if (refused.length) {
438
+ entry.status = 'error';
439
+ entry.reason = `range does not accept the new version - ${refused.join('; ')}`;
440
+ }
441
+ else if (entry.status === 'no-change' && kept.length) {
442
+ entry.reason = `range ${kept.join(', ')} still satisfied`;
443
+ }
444
+ else if (unraisable.length) {
445
+ entry.reason = `${entry.reason ? `${entry.reason}; ` : ''}lower bound not raised: ${unraisable.join(', ')}`;
446
+ }
447
+ }
448
+ }
367
449
  /**
368
450
  * The largest bump `commits` ask for, in `scheme`'s own names.
369
451
  *
@@ -478,7 +560,7 @@ export class VersionPlanService {
478
560
  for (const m of members) {
479
561
  if (bumping.has(m))
480
562
  continue;
481
- if (m.dependencies.includes(cur)) {
563
+ if (m.dependencies.includes(cur) && !this.rangeVerdict(m, cur, to)) {
482
564
  bumping.add(m);
483
565
  worklist.push(m);
484
566
  }
@@ -536,6 +618,10 @@ export class VersionPlanService {
536
618
  continue;
537
619
  if (!pkg.dependencies.includes(source.package))
538
620
  continue;
621
+ /** Its range still takes the new version, so nothing it publishes is out of date; one that
622
+ * does not is refused by `checkKeptRanges`, after every bump is known. */
623
+ if (this.rangeVerdict(pkg, source.package, source.to))
624
+ continue;
539
625
  /** The group always contains `pkg` itself, so there is always at least one version here -
540
626
  * the fallback is for the type, not for a case that can happen. */
541
627
  const groupCeiling = pkg.versionScheme.highestVersion(packages
@@ -104,6 +104,13 @@ export declare namespace VersionService {
104
104
  /** `topo` is refused by default here, which is the one way this differs from `RunService`'s: a
105
105
  * version hook has no package ordering to join. */
106
106
  function normalizeScriptValue(value: unknown, at: string): RunStepValue[];
107
+ /**
108
+ * Raises the lower bound of each of `pkg`'s ranges on an in-repo dependency to the version that
109
+ * dependency now has - `bumped`'s for one this run moves, its own otherwise. Upper bounds stay.
110
+ * What `.rmanrc "version.dependentRanges": keep` does for a package released for changes of its
111
+ * own, in place of `Manifest.updateDependencyVersions`.
112
+ */
113
+ function raiseKeptFloors(pkg: Package, bumped: ReadonlyMap<Package, string>): void;
107
114
  /**
108
115
  * Keeps a package's Dockerfile `org.opencontainers.image.version` label in step with the version
109
116
  * just written, returning the absolute path when it actually changed (so the caller can fold it
@@ -98,7 +98,10 @@ export class VersionService extends Service {
98
98
  /** Which fields hold a sibling reference, and what a reference even looks like, is the
99
99
  * ecosystem's - npm's four fields and its `"workspace:"` protocol used to be spelled out
100
100
  * here. See `Plugin.updateDependencyVersions`. */
101
- Manifest.updateDependencyVersions(pkg, bumpedVersions);
101
+ if (pkg.config?.version?.dependentRanges === 'keep')
102
+ VersionService.raiseKeptFloors(pkg, bumpedVersions);
103
+ else
104
+ Manifest.updateDependencyVersions(pkg, bumpedVersions);
102
105
  await hook('exec');
103
106
  pkg.writeManifest();
104
107
  // Before the `after` hook, so a script reacting to the bump sees the whole new state.
@@ -308,6 +311,32 @@ export class VersionService extends Service {
308
311
  return RunService.normalizeScriptValue(value, at, { topo: false });
309
312
  }
310
313
  VersionService.normalizeScriptValue = normalizeScriptValue;
314
+ /**
315
+ * Raises the lower bound of each of `pkg`'s ranges on an in-repo dependency to the version that
316
+ * dependency now has - `bumped`'s for one this run moves, its own otherwise. Upper bounds stay.
317
+ * What `.rmanrc "version.dependentRanges": keep` does for a package released for changes of its
318
+ * own, in place of `Manifest.updateDependencyVersions`.
319
+ */
320
+ /* **Only when the package itself is released**, because that is the only time it can have
321
+ * started using something new: code relying on a newer dependency is a change in the dependent,
322
+ * and a change is what releases it. Left unreleased, the range it published is still true. The
323
+ * floor may end up higher than strictly needed - that errs toward what was built and tested, and
324
+ * the upper bound keeps every consumer installable.
325
+ *
326
+ * A prerelease never raises one: a stable range floored on a beta would no longer install for
327
+ * anyone on stable releases. */
328
+ function raiseKeptFloors(pkg, bumped) {
329
+ for (const dep of pkg.dependencies) {
330
+ const version = bumped.get(dep) ?? dep.version;
331
+ if (dep.versionScheme.isPrerelease(version))
332
+ continue;
333
+ for (const ref of Manifest.referencesTo(pkg, dep.name, version)) {
334
+ if (ref.raised && ref.accepts)
335
+ Manifest.setReference(pkg, ref.field, dep.name, ref.raised);
336
+ }
337
+ }
338
+ }
339
+ VersionService.raiseKeptFloors = raiseKeptFloors;
311
340
  /**
312
341
  * Keeps a package's Dockerfile `org.opencontainers.image.version` label in step with the version
313
342
  * just written, returning the absolute path when it actually changed (so the caller can fold it