rman 1.0.12 → 1.2.1

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.
Files changed (102) hide show
  1. package/README.md +90 -70
  2. package/cli.js +226 -14
  3. package/commands/build.command.js +1 -0
  4. package/commands/changed.command.js +2 -2
  5. package/commands/changelog.command.js +13 -15
  6. package/commands/config.command.js +61 -0
  7. package/commands/diff.command.js +9 -4
  8. package/commands/exec.command.js +2 -8
  9. package/commands/github-release.command.js +1 -0
  10. package/commands/info.command.d.ts +9 -0
  11. package/commands/info.command.js +12 -2
  12. package/commands/run.command.js +5 -8
  13. package/commands/test.command.js +1 -0
  14. package/commands/version.command.js +53 -14
  15. package/constants.js +1 -1
  16. package/core/config.d.ts +265 -17
  17. package/core/config.js +651 -76
  18. package/core/custom-command.d.ts +133 -0
  19. package/core/custom-command.js +99 -0
  20. package/core/extends-config.d.ts +27 -0
  21. package/core/extends-config.js +89 -0
  22. package/core/manifest.d.ts +222 -0
  23. package/core/manifest.js +150 -0
  24. package/core/merge-config.d.ts +70 -0
  25. package/core/merge-config.js +193 -0
  26. package/core/package.d.ts +73 -7
  27. package/core/package.js +86 -24
  28. package/core/plugin.d.ts +112 -0
  29. package/core/plugin.js +189 -0
  30. package/core/repository.d.ts +91 -1
  31. package/core/repository.js +277 -132
  32. package/core/resolve-target.d.ts +12 -0
  33. package/core/resolve-target.js +33 -0
  34. package/core/run-step.d.ts +75 -0
  35. package/core/run-step.js +1 -0
  36. package/core/version-scheme.d.ts +134 -0
  37. package/core/version-scheme.js +148 -0
  38. package/core/workspace.d.ts +68 -0
  39. package/core/workspace.js +83 -0
  40. package/index.d.ts +55 -8
  41. package/index.js +42 -7
  42. package/interfaces/rman-config.interface.d.ts +222 -46
  43. package/package.json +16 -7
  44. package/services/change-hash.service.d.ts +88 -0
  45. package/services/change-hash.service.js +112 -0
  46. package/services/changelog.service.d.ts +8 -13
  47. package/services/changelog.service.js +12 -11
  48. package/services/conventional-commits.service.d.ts +73 -0
  49. package/services/conventional-commits.service.js +116 -0
  50. package/services/docker-publish.service.js +1 -1
  51. package/services/exec.service.js +1 -1
  52. package/services/github-release.service.d.ts +2 -2
  53. package/services/github-release.service.js +10 -5
  54. package/services/list.service.js +5 -2
  55. package/services/run.service.d.ts +112 -6
  56. package/services/run.service.js +265 -89
  57. package/services/system-info.d.ts +22 -7
  58. package/services/system-info.js +8 -23
  59. package/services/version-plan.service.d.ts +244 -0
  60. package/services/version-plan.service.js +414 -0
  61. package/services/version.service.d.ts +102 -82
  62. package/services/version.service.js +226 -434
  63. package/services.d.ts +5 -3
  64. package/services.js +5 -3
  65. package/utils/bin-path.d.ts +59 -0
  66. package/utils/bin-path.js +82 -0
  67. package/utils/child-tracker.d.ts +16 -0
  68. package/utils/child-tracker.js +30 -0
  69. package/utils/exec.d.ts +13 -2
  70. package/utils/exec.js +17 -17
  71. package/utils/git.d.ts +9 -3
  72. package/utils/git.js +10 -2
  73. package/utils/package-filter.d.ts +33 -2
  74. package/utils/package-filter.js +47 -7
  75. package/utils/printable-config.d.ts +15 -0
  76. package/utils/printable-config.js +42 -0
  77. package/utils/release-version.js +3 -3
  78. package/utils/run-bin.d.ts +46 -0
  79. package/utils/run-bin.js +63 -0
  80. package/utils/version-stamp.d.ts +14 -6
  81. package/utils/version-stamp.js +25 -13
  82. package/commands/ci.command.js +0 -30
  83. package/commands/clean.command.d.ts +0 -3
  84. package/commands/clean.command.js +0 -36
  85. package/commands/publish.command.d.ts +0 -3
  86. package/commands/publish.command.js +0 -225
  87. package/rmanrc.schema.json +0 -392
  88. package/services/ci.service.d.ts +0 -40
  89. package/services/ci.service.js +0 -204
  90. package/services/clean.service.d.ts +0 -42
  91. package/services/clean.service.js +0 -226
  92. package/services/publish.service.d.ts +0 -79
  93. package/services/publish.service.js +0 -273
  94. package/utils/change-hash.d.ts +0 -68
  95. package/utils/change-hash.js +0 -98
  96. package/utils/conventional-commits.d.ts +0 -52
  97. package/utils/conventional-commits.js +0 -90
  98. package/utils/npm-run-path.d.ts +0 -67
  99. package/utils/npm-run-path.js +0 -63
  100. package/utils/workspace-range.d.ts +0 -17
  101. package/utils/workspace-range.js +0 -28
  102. /package/commands/{ci.command.d.ts → config.command.d.ts} +0 -0
@@ -0,0 +1,244 @@
1
+ import type { Package } from '../core/package.js';
2
+ import type { Repository } from '../core/repository.js';
3
+ import { VersionScheme } from '../core/version-scheme.js';
4
+ import { type CommitInfo, GitHelper } from '../utils/git.js';
5
+ import { type PackageFilterOptions } from '../utils/package-filter.js';
6
+ /**
7
+ * Question A, computed and nothing else: **which packages have changed since their last release,
8
+ * and what version would each get.** Reads git, the packages' current versions and `.rmanrc`;
9
+ * writes nothing, touches no manifest, makes no commit. `VersionService.applyPlan` does the writes.
10
+ *
11
+ * **Abstract, so the core cannot produce a plan on its own** - a repository gets one from the
12
+ * planner its `plugins` contribute (`rman-node`'s `NodeVersionPlanService` for a Node repository).
13
+ * That is not ceremony: what a release *is* differs by ecosystem, and two of the decisions below
14
+ * have no answer that is true of repositories in general.
15
+ *
16
+ * What stays here is what is true of any repository rman manages, and would only be copied by every
17
+ * plugin if it were moved out: groups, the reading of a commit as a `ChangeKind`, the in-group and
18
+ * cross-group cascade *mechanics*, and the monorepo root's own release identity. The bump *names*
19
+ * are not here either - they are the `VersionScheme`'s, so nothing in this file spells
20
+ * `patch`/`minor`/`major`. `getPlan` is a template holding
21
+ * those together - it is a plain method, not `final`, so a technology this shape genuinely does not
22
+ * fit overrides it outright and keeps the pieces (they are all `protected`) it still wants.
23
+ *
24
+ * The two abstract members are the ones a plugin must answer:
25
+ *
26
+ * - **`detectBoundary`** - since when is a package unreleased. Git tags are the usual answer and
27
+ * `detectChangeHash` is exported for it, but *which* registry to fall back to when a package has
28
+ * no tag yet is the ecosystem's business (npm's `npm view`, another's something else).
29
+ * - **`cascade`** - which of a group's other members a bump of a given size has to reach, named in
30
+ * the scheme's own `bumpNames` (so a planner and the scheme it ships with share one vocabulary
31
+ * and the core needs none of its own). The familiar
32
+ * patch/minor/major mapping is a statement about **npm's dependency ranges**, not about releases:
33
+ * it holds because `^1.2.0` already tolerates a patch, so a patch needs no downstream republish.
34
+ * An ecosystem pinning exact versions has to release every dependent for a patch too, and a core
35
+ * that assumed the caret would quietly be wrong for it.
36
+ */
37
+ export declare abstract class VersionPlanService {
38
+ /**
39
+ * Computes what a version bump *would* do, across every package `.rmanrc group` puts together -
40
+ * never writes anything (no manifest edits, no git commits/tags) and safe to call any time,
41
+ * including as the "preview" a bare `rman version` (no bump given) stops at.
42
+ *
43
+ * Packages are partitioned into groups by their resolved `group` value (cascaded): `true`
44
+ * (the default) puts every such package into one implicit repo-wide group; a string joins
45
+ * exactly the other packages sharing that same string, regardless of the repo's default; `false`
46
+ * makes a package its own solo group. Each group's "current version" is always the highest
47
+ * version currently found among its own members (never persisted anywhere) - see
48
+ * `resolveGroupKey`.
49
+ *
50
+ * Within a group, a member with real commits since its own last release (or an explicit `bump`)
51
+ * contributes a bump; the group takes the largest of them (`VersionScheme.highestBump`) and its
52
+ * new version is the current one advanced by that. Which members actually receive it is
53
+ * `cascade`'s answer, applied by `computeGroupPlan`.
54
+ *
55
+ * Across groups: a package depending on another group's bumped package always gets its scheme's
56
+ * **smallest** bump of its own (never inheriting the source's) - the dependency reference itself
57
+ * is the only thing that changed for it. Whether that re-triggers its own group's cascade is
58
+ * `cascade`'s answer for that smallest bump (`'changed'` under npm, so it does not), but it can
59
+ * itself ripple into a third group, and so on, until nothing new is affected - see
60
+ * `rippleCrossGroup`.
61
+ *
62
+ * A monorepo's root package is never a real member of any group (it's never published on its
63
+ * own) - it gets one trailing entry instead, carrying the repository's own release identity: the
64
+ * single group's version when there is one, a calendar version once there are several - see
65
+ * `buildRootEntry`.
66
+ *
67
+ * "Since its own last release" is `detectBoundary`'s answer, which for every plugin so far is the
68
+ * shared `detectChangeHash` - the same boundary `changelog` measures from, so the two never
69
+ * disagree about which commits are unreleased.
70
+ */
71
+ getPlan(repository: Repository, options?: VersionPlanService.Options): Promise<VersionPlanService.Entry[]>;
72
+ /**
73
+ * The commit a package's changes are measured **since** - its last release boundary, or
74
+ * `undefined` for a package that has never been released (the caller then reads the whole
75
+ * history, since nothing in it has shipped).
76
+ *
77
+ * Abstract because the fallback is: git tags answer this for any repository and
78
+ * `detectChangeHash` is exported for exactly that, but a package with no tag yet (rman adopted
79
+ * onto a repository with real release history) can only be placed by asking wherever its releases
80
+ * actually went - which is a registry only the ecosystem knows about.
81
+ *
82
+ * `options` is the object `getPlan` was called with, for a planner whose boundary depends on what
83
+ * the run asked for. A planner's *own* configuration does not come through here - it is an object
84
+ * now, so an injectable registry lookup belongs in its constructor (the `Deps` pattern
85
+ * `ChangelogService`/`PublishService` already use), where a test can supply one without every
86
+ * caller's options type having to know about it.
87
+ */
88
+ protected abstract detectBoundary(git: GitHelper, pkg: Package, options: VersionPlanService.Options): Promise<string | undefined>;
89
+ /**
90
+ * How far into its own group a bump of this size has to reach - see
91
+ * `VersionPlanService.Cascade`.
92
+ *
93
+ * Abstract because the answer is a statement about how this ecosystem's packages *refer* to each
94
+ * other, not about versions. With npm's caret ranges a dependent already accepts its dependency's
95
+ * patch, so nothing downstream needs republishing; pin exact versions instead and every dependent
96
+ * needs a release of its own for the same patch. There is no mapping that is true of both, and a
97
+ * wrong one here is invisible - it produces a plan that simply releases too little.
98
+ */
99
+ protected abstract cascade(bump: string): VersionPlanService.Cascade;
100
+ /**
101
+ * The largest bump `commits` ask for, in `scheme`'s own names.
102
+ *
103
+ * Two steps, and keeping them apart is the point. **What happened** comes from the commit message
104
+ * - a `!` marker or `BREAKING CHANGE:` footer is `'breaking'`, a `feat:` is `'feature'`, anything
105
+ * else (a `fix:`, an unrecognized type, a non-conventional subject) is `'fix'`, since something
106
+ * changed and at least the smallest release is warranted. **How the number moves** is then
107
+ * `scheme.bumpFor`'s answer, because "patch" is a sentence about a semver number and not about a
108
+ * commit.
109
+ *
110
+ * A `Release-As:` footer (see `parseReleaseAs`) replaces what that one commit's own
111
+ * subject/footers would otherwise imply - the escape hatch for a `feat:` that has to ship as a
112
+ * patch right now, without waiting for the rest of a minor's worth of work. It names a bump
113
+ * directly rather than a kind, so it is checked against `scheme.bumpNames` and a word the scheme
114
+ * does not declare falls through to what the commit itself said - which is what keeps a typo, and
115
+ * another tool's `Release-As: 1.2.3` in history rman was adopted onto, from deciding a release.
116
+ *
117
+ * Not abstract: conventional commits are a convention about *commit messages*, which no ecosystem
118
+ * owns. A planner for a repository writing them differently overrides this.
119
+ */
120
+ protected detectBump(commits: CommitInfo[], scheme: VersionScheme): string;
121
+ /** `.rmanrc group` (cascaded): `true` (the default - see `resolveConfig`'s cascade, this is what a
122
+ * package inherits when nobody sets it at all) puts a package in the one implicit repo-wide
123
+ * group; a non-empty string joins exactly the other packages sharing that string, regardless of
124
+ * the repo's own default; `false` makes it a solo group of one. */
125
+ protected resolveGroupKey(pkg: Package): string;
126
+ /** The human-readable name behind a `groupKey` - what a plan's `group` column shows. */
127
+ protected groupLabel(key: string): string;
128
+ /**
129
+ * Decides one group's new version and which of its members actually receive it, writing an
130
+ * `Entry` per member into `entries`. `changeByPackage` holds each eligible package's own detected
131
+ * bump (or `undefined` for one with no real commits since its last boundary) - `undefined`
132
+ * here always means "unchanged", never "explicit version" (that path is handled separately).
133
+ *
134
+ * How wide the bump goes is `cascade`'s answer, and only its answer: an explicit
135
+ * `rman version <v>` has no bump left to consult, so it reaches the changed members alone.
136
+ */
137
+ protected computeGroupPlan(key: string, members: Package[], changeByPackage: Map<string, VersionPlanService.Change>, explicitVersion: string | undefined, preid: string | undefined, entries: Map<string, VersionPlanService.Entry>): void;
138
+ /**
139
+ * A package depending on another group's bumped package always receives its scheme's **smallest**
140
+ * bump, computed from its *own* group's current ceiling (the highest `to`/version among its group
141
+ * right now) - never the source's version, and never the source's bump. Runs as a worklist until
142
+ * nothing new is affected, since bumping one package can itself cross into a third group, and so
143
+ * on; never touches a same-group dependent that `cascade` deliberately left alone.
144
+ *
145
+ * The smallest regardless of ecosystem, and not by oversight: nothing about the dependent changed
146
+ * except the reference it carries, so there is nothing for a larger bump to describe. Which bump
147
+ * *is* the smallest is the scheme's to say (`smallestBump`) - `patch` under semver, `revision` for
148
+ * a four-part scheme. A planner for which a changed dependency is not a release at all overrides
149
+ * this to do nothing.
150
+ */
151
+ protected rippleCrossGroup(packages: Package[], entries: Map<string, VersionPlanService.Entry>, preid: string | undefined): void;
152
+ /**
153
+ * A monorepo root is never published on its own, but its version is still the repository's release
154
+ * identity - what a GitHub Release is named after. How it's computed depends on how many version
155
+ * lines the repo has, derived rather than configured (see `usesCalendarVersion`):
156
+ *
157
+ * - **One group**: the root simply follows it, so the repo and its packages share one number.
158
+ * - **Several groups** (or a repo already on calendar): a calendar version (`2026.9.15-1430`).
159
+ * There is no meaningful shared number to report - the old "highest version among the groups"
160
+ * rule would leave the root standing still whenever a *lower* line released, so a release could
161
+ * happen with no identity of its own, and a semver-looking identity would anyway claim something
162
+ * untrue about packages sitting on entirely different lines.
163
+ *
164
+ * Reports `'no-change'` (not `'bump'`) when nothing in the repository changed at all.
165
+ */
166
+ protected buildRootEntry(repository: Repository, memberEntries: VersionPlanService.Entry[], context: {
167
+ groupCount: number;
168
+ lastReleaseVersion?: string;
169
+ now: () => Date;
170
+ }): VersionPlanService.Entry;
171
+ }
172
+ export declare namespace VersionPlanService {
173
+ /**
174
+ * How far into its own group a bump reaches - `VersionPlanService.cascade`'s answer, per bump.
175
+ *
176
+ * - `'changed'` - only the members with commits of their own.
177
+ * - `'dependents'` - those, plus every in-group package that (transitively) depends on one.
178
+ * - `'group'` - every member, changed or not.
179
+ */
180
+ type Cascade = 'changed' | 'dependents' | 'group';
181
+ /** What was found for one package before groups are resolved: the bump its own commits ask for
182
+ * (in its scheme's names, or `undefined` when an explicit version makes the question moot) and
183
+ * why, which becomes the plan entry's `reason`. */
184
+ interface Change {
185
+ bump?: string;
186
+ reason: string;
187
+ }
188
+ interface Options extends PackageFilterOptions {
189
+ /** One of the root scheme's own `bumpNames` (applied to every group that has real changes) or a
190
+ * concrete version it recognizes (applied as the literal new version wherever something
191
+ * changed) - either way, this replaces auto-detection entirely. Omit to auto-detect the bump
192
+ * per group from conventional-commit subjects since each package's/group's last release tag. */
193
+ bump?: string;
194
+ /** A package with uncommitted local changes is excluded from bumping (status `'skip'`)
195
+ * instead of aborting the whole plan (status `'error'`). Default false. */
196
+ ignoreDirty?: boolean;
197
+ /** Makes every computed bump a prerelease (`1.2.3` -> `1.3.0-beta.0` for a `minor`, say)
198
+ * tagged with this identifier, instead of a normal release - same idea as `npm version
199
+ * <type> --preid <name>`. A group already sitting on a matching prerelease (same identifier)
200
+ * just has its prerelease counter incremented instead of jumping to a new base version - see
201
+ * `VersionScheme.next`. Has no effect when `bump` is an explicit semver version rather than a keyword
202
+ * (there's no bump left to "pre-fix" at that point). */
203
+ preid?: string;
204
+ /** Clock behind a monorepo root's calendar release version - injectable so tests are
205
+ * deterministic. Default `() => new Date()`. */
206
+ now?: () => Date;
207
+ }
208
+ /** One package's outcome in a version plan - see `getPlan`. */
209
+ interface Entry {
210
+ package: Package;
211
+ /** Internal group identity packages are batched by (not for display) - see `resolveGroupKey`. */
212
+ groupKey: string;
213
+ /** Human-readable group name: `'default'` for the implicit repo-wide group, the configured
214
+ * name for a named `group`, or the package's own name when it isn't grouped with anyone. */
215
+ group: string;
216
+ status: 'bump' | 'skip' | 'error' | 'no-change';
217
+ from: string;
218
+ /** Only set when `status === 'bump'`. */
219
+ to?: string;
220
+ /** Human-readable explanation - e.g. why a package was skipped, or why it's being bumped
221
+ * despite having no commits of its own (a dependency of it changed elsewhere). */
222
+ reason?: string;
223
+ }
224
+ /**
225
+ * Registers the planner every `version`/`changed` run will use, replacing any previous one.
226
+ *
227
+ * **One slot, last registration wins** - unlike `Manifest`/`Workspace`, which keep a list and take
228
+ * the first provider that *recognizes* a repository. A planner has nothing to recognize: asked for
229
+ * a plan it always has one, so "first that answers" would just mean "first registered" and a
230
+ * repository layering its own policy plugin after `rman-node` could never take effect - which is
231
+ * the only reason to name two in the first place.
232
+ */
233
+ function setPlanner(planner: VersionPlanService): void;
234
+ /** For tests, which would otherwise leak a planner into every later case in the process. */
235
+ function clearPlanner(): void;
236
+ /**
237
+ * The registered planner. **Throws** when there is none, rather than falling back to some
238
+ * built-in default: `Manifest` and `Workspace` can degrade honestly (a package named after its
239
+ * directory, a repository that is its own single package), but there is no version plan that is
240
+ * merely a diminished one - a wrong cascade or a wrong boundary reports a release that is
241
+ * plausible and untrue.
242
+ */
243
+ function getPlanner(): VersionPlanService;
244
+ }
@@ -0,0 +1,414 @@
1
+ import path from 'node:path';
2
+ import { assertOneScheme, semverScheme, VersionScheme } from '../core/version-scheme.js';
3
+ import { GitHelper } from '../utils/git.js';
4
+ import { filterPackages } from '../utils/package-filter.js';
5
+ import { findLastReleaseVersion, formatCalendarVersion, usesCalendarVersion } from '../utils/release-version.js';
6
+ import { ConventionalCommitsService } from './conventional-commits.service.js';
7
+ /**
8
+ * Question A, computed and nothing else: **which packages have changed since their last release,
9
+ * and what version would each get.** Reads git, the packages' current versions and `.rmanrc`;
10
+ * writes nothing, touches no manifest, makes no commit. `VersionService.applyPlan` does the writes.
11
+ *
12
+ * **Abstract, so the core cannot produce a plan on its own** - a repository gets one from the
13
+ * planner its `plugins` contribute (`rman-node`'s `NodeVersionPlanService` for a Node repository).
14
+ * That is not ceremony: what a release *is* differs by ecosystem, and two of the decisions below
15
+ * have no answer that is true of repositories in general.
16
+ *
17
+ * What stays here is what is true of any repository rman manages, and would only be copied by every
18
+ * plugin if it were moved out: groups, the reading of a commit as a `ChangeKind`, the in-group and
19
+ * cross-group cascade *mechanics*, and the monorepo root's own release identity. The bump *names*
20
+ * are not here either - they are the `VersionScheme`'s, so nothing in this file spells
21
+ * `patch`/`minor`/`major`. `getPlan` is a template holding
22
+ * those together - it is a plain method, not `final`, so a technology this shape genuinely does not
23
+ * fit overrides it outright and keeps the pieces (they are all `protected`) it still wants.
24
+ *
25
+ * The two abstract members are the ones a plugin must answer:
26
+ *
27
+ * - **`detectBoundary`** - since when is a package unreleased. Git tags are the usual answer and
28
+ * `detectChangeHash` is exported for it, but *which* registry to fall back to when a package has
29
+ * no tag yet is the ecosystem's business (npm's `npm view`, another's something else).
30
+ * - **`cascade`** - which of a group's other members a bump of a given size has to reach, named in
31
+ * the scheme's own `bumpNames` (so a planner and the scheme it ships with share one vocabulary
32
+ * and the core needs none of its own). The familiar
33
+ * patch/minor/major mapping is a statement about **npm's dependency ranges**, not about releases:
34
+ * it holds because `^1.2.0` already tolerates a patch, so a patch needs no downstream republish.
35
+ * An ecosystem pinning exact versions has to release every dependent for a patch too, and a core
36
+ * that assumed the caret would quietly be wrong for it.
37
+ */
38
+ export class VersionPlanService {
39
+ /**
40
+ * Computes what a version bump *would* do, across every package `.rmanrc group` puts together -
41
+ * never writes anything (no manifest edits, no git commits/tags) and safe to call any time,
42
+ * including as the "preview" a bare `rman version` (no bump given) stops at.
43
+ *
44
+ * Packages are partitioned into groups by their resolved `group` value (cascaded): `true`
45
+ * (the default) puts every such package into one implicit repo-wide group; a string joins
46
+ * exactly the other packages sharing that same string, regardless of the repo's default; `false`
47
+ * makes a package its own solo group. Each group's "current version" is always the highest
48
+ * version currently found among its own members (never persisted anywhere) - see
49
+ * `resolveGroupKey`.
50
+ *
51
+ * Within a group, a member with real commits since its own last release (or an explicit `bump`)
52
+ * contributes a bump; the group takes the largest of them (`VersionScheme.highestBump`) and its
53
+ * new version is the current one advanced by that. Which members actually receive it is
54
+ * `cascade`'s answer, applied by `computeGroupPlan`.
55
+ *
56
+ * Across groups: a package depending on another group's bumped package always gets its scheme's
57
+ * **smallest** bump of its own (never inheriting the source's) - the dependency reference itself
58
+ * is the only thing that changed for it. Whether that re-triggers its own group's cascade is
59
+ * `cascade`'s answer for that smallest bump (`'changed'` under npm, so it does not), but it can
60
+ * itself ripple into a third group, and so on, until nothing new is affected - see
61
+ * `rippleCrossGroup`.
62
+ *
63
+ * A monorepo's root package is never a real member of any group (it's never published on its
64
+ * own) - it gets one trailing entry instead, carrying the repository's own release identity: the
65
+ * single group's version when there is one, a calendar version once there are several - see
66
+ * `buildRootEntry`.
67
+ *
68
+ * "Since its own last release" is `detectBoundary`'s answer, which for every plugin so far is the
69
+ * shared `detectChangeHash` - the same boundary `changelog` measures from, so the two never
70
+ * disagree about which commits are unreleased.
71
+ */
72
+ async getPlan(repository, options = {}) {
73
+ const bump = options.bump?.trim();
74
+ /**
75
+ * Both halves are the *root* scheme's to judge: an explicit `rman version <bump|version>` is one
76
+ * value for the whole run, so there is no per-package scheme to ask. (A group numbering
77
+ * differently from the root would have to be given its own run - `assertOneScheme` only promises
78
+ * that a group agrees with itself.)
79
+ */
80
+ const scheme = repository.rootPackage.versionScheme;
81
+ const explicitBump = bump && scheme.bumpNames.includes(bump) ? bump : undefined;
82
+ const explicitVersion = bump && !explicitBump && scheme.isValid(bump) ? bump : undefined;
83
+ if (bump && !explicitBump && !explicitVersion) {
84
+ /**
85
+ * Every word here comes from the scheme that rejected it - the bump names as well as the
86
+ * format's name. Neither is rman's to state: `patch`/`minor`/`major` are semver's words for
87
+ * how a number moves, and a `major.minor.build.revision` scheme has four sizes and no `patch`
88
+ * at all. Listing the scheme's own names also beats a vague "a valid version", which leaves
89
+ * the reader to guess which spelling they missed.
90
+ */
91
+ throw new Error(`Invalid "bump": "${bump}" (expected ${scheme.bumpNames.map(n => `"${n}"`).join(', ')}, ` +
92
+ `or a valid ${scheme.name} version)`);
93
+ }
94
+ const git = new GitHelper({ cwd: repository.dirname });
95
+ const packages = filterPackages(repository.getPackages(), options);
96
+ const dirtyFiles = await git.listDirtyFiles({ absolute: true });
97
+ const isDirty = (pkg) => dirtyFiles.some(f => !path.relative(pkg.dirname, f).startsWith('..'));
98
+ const entries = new Map();
99
+ const eligible = [];
100
+ for (const pkg of packages) {
101
+ if (isDirty(pkg)) {
102
+ entries.set(pkg.name, {
103
+ package: pkg,
104
+ groupKey: this.resolveGroupKey(pkg),
105
+ group: this.groupLabel(this.resolveGroupKey(pkg)),
106
+ status: options.ignoreDirty ? 'skip' : 'error',
107
+ from: pkg.version,
108
+ reason: 'uncommitted local changes',
109
+ });
110
+ continue;
111
+ }
112
+ eligible.push(pkg);
113
+ }
114
+ const commitMessage = repository.rootPackage.config?.version?.commitMessage;
115
+ const changeByPackage = new Map();
116
+ await Promise.all(eligible.map(async (pkg) => {
117
+ if (explicitVersion) {
118
+ changeByPackage.set(pkg.name, { bump: undefined, reason: `explicit version ${explicitVersion}` });
119
+ return;
120
+ }
121
+ const since = await this.detectBoundary(git, pkg, options);
122
+ const commits = since ? await git.listCommits({ hash: since }) : await git.listAllCommits();
123
+ const belongsToPkg = (c) => c.files.some(f => !path.relative(pkg.dirname, f).startsWith('..'));
124
+ const real = commits.filter(c => belongsToPkg(c) && !ConventionalCommitsService.isReleaseCommit(c.subject, commitMessage));
125
+ if (!real.length)
126
+ return;
127
+ changeByPackage.set(pkg.name, {
128
+ bump: explicitBump ?? this.detectBump(real, pkg.versionScheme),
129
+ reason: since ? `changed since ${since}` : 'unreleased commits',
130
+ });
131
+ }));
132
+ const groups = new Map();
133
+ for (const pkg of eligible) {
134
+ const key = this.resolveGroupKey(pkg);
135
+ const list = groups.get(key);
136
+ if (list)
137
+ list.push(pkg);
138
+ else
139
+ groups.set(key, [pkg]);
140
+ }
141
+ for (const [key, members] of groups) {
142
+ this.computeGroupPlan(key, members, changeByPackage, explicitVersion, options.preid, entries);
143
+ }
144
+ this.rippleCrossGroup(packages, entries, options.preid);
145
+ const result = packages.map(pkg => entries.get(pkg.name));
146
+ if (repository.monorepo) {
147
+ result.push(this.buildRootEntry(repository, result, {
148
+ groupCount: groups.size,
149
+ lastReleaseVersion: await findLastReleaseVersion(git, repository.rootPackage),
150
+ now: options.now ?? (() => new Date()),
151
+ }));
152
+ }
153
+ return result;
154
+ }
155
+ /**
156
+ * The largest bump `commits` ask for, in `scheme`'s own names.
157
+ *
158
+ * Two steps, and keeping them apart is the point. **What happened** comes from the commit message
159
+ * - a `!` marker or `BREAKING CHANGE:` footer is `'breaking'`, a `feat:` is `'feature'`, anything
160
+ * else (a `fix:`, an unrecognized type, a non-conventional subject) is `'fix'`, since something
161
+ * changed and at least the smallest release is warranted. **How the number moves** is then
162
+ * `scheme.bumpFor`'s answer, because "patch" is a sentence about a semver number and not about a
163
+ * commit.
164
+ *
165
+ * A `Release-As:` footer (see `parseReleaseAs`) replaces what that one commit's own
166
+ * subject/footers would otherwise imply - the escape hatch for a `feat:` that has to ship as a
167
+ * patch right now, without waiting for the rest of a minor's worth of work. It names a bump
168
+ * directly rather than a kind, so it is checked against `scheme.bumpNames` and a word the scheme
169
+ * does not declare falls through to what the commit itself said - which is what keeps a typo, and
170
+ * another tool's `Release-As: 1.2.3` in history rman was adopted onto, from deciding a release.
171
+ *
172
+ * Not abstract: conventional commits are a convention about *commit messages*, which no ecosystem
173
+ * owns. A planner for a repository writing them differently overrides this.
174
+ */
175
+ detectBump(commits, scheme) {
176
+ const asked = [scheme.bumpFor('fix')];
177
+ for (const c of commits) {
178
+ const override = ConventionalCommitsService.parseReleaseAs(c.body);
179
+ /**
180
+ * Honoured only when `scheme` actually declares it. Measured, because the obvious `if
181
+ * (override)` is wrong in a way that looks fine: a footer the scheme does not know would
182
+ * *replace* what the commit itself said, so a `feat:` carrying release-please's own
183
+ * `Release-As: 1.2.3` came out a patch instead of a minor. An unrecognized word means "no
184
+ * override", exactly as it did when only three were ever recognized.
185
+ */
186
+ if (override && scheme.bumpNames.includes(override)) {
187
+ asked.push(override);
188
+ continue;
189
+ }
190
+ asked.push(scheme.bumpFor(kindOf(c)));
191
+ }
192
+ return scheme.highestBump(asked);
193
+ }
194
+ /** `.rmanrc group` (cascaded): `true` (the default - see `resolveConfig`'s cascade, this is what a
195
+ * package inherits when nobody sets it at all) puts a package in the one implicit repo-wide
196
+ * group; a non-empty string joins exactly the other packages sharing that string, regardless of
197
+ * the repo's own default; `false` makes it a solo group of one. */
198
+ resolveGroupKey(pkg) {
199
+ const g = pkg.config?.group;
200
+ if (g === false)
201
+ return `solo:${pkg.name}`;
202
+ if (typeof g === 'string' && g)
203
+ return `named:${g}`;
204
+ return 'default';
205
+ }
206
+ /** The human-readable name behind a `groupKey` - what a plan's `group` column shows. */
207
+ groupLabel(key) {
208
+ if (key.startsWith('named:'))
209
+ return key.slice('named:'.length);
210
+ if (key.startsWith('solo:'))
211
+ return key.slice('solo:'.length);
212
+ return key;
213
+ }
214
+ /**
215
+ * Decides one group's new version and which of its members actually receive it, writing an
216
+ * `Entry` per member into `entries`. `changeByPackage` holds each eligible package's own detected
217
+ * bump (or `undefined` for one with no real commits since its last boundary) - `undefined`
218
+ * here always means "unchanged", never "explicit version" (that path is handled separately).
219
+ *
220
+ * How wide the bump goes is `cascade`'s answer, and only its answer: an explicit
221
+ * `rman version <v>` has no bump left to consult, so it reaches the changed members alone.
222
+ */
223
+ computeGroupPlan(key, members, changeByPackage, explicitVersion, preid, entries) {
224
+ const label = this.groupLabel(key);
225
+ /** One version line, so one scheme - the members are about to be compared against each other and
226
+ * bumped together, which two schemes would make meaningless. */
227
+ assertOneScheme(members.map(m => ({ packageName: m.name, scheme: m.versionScheme })), label);
228
+ const scheme = members[0]?.versionScheme ?? semverScheme;
229
+ const changed = members.filter(m => changeByPackage.has(m.name));
230
+ if (!changed.length) {
231
+ for (const m of members) {
232
+ entries.set(m.name, { package: m, groupKey: key, group: label, status: 'no-change', from: m.version });
233
+ }
234
+ return;
235
+ }
236
+ const current = scheme.highestVersion(members.map(m => m.version)) ?? members[0].version;
237
+ let to;
238
+ let bump;
239
+ if (explicitVersion) {
240
+ to = explicitVersion;
241
+ }
242
+ else {
243
+ /** The largest any changed member asked for - `changed` is non-empty here, so this always
244
+ * resolves; the `!` is for the type. */
245
+ bump = scheme.highestBump(changed.map(m => changeByPackage.get(m.name).bump));
246
+ to = scheme.next(current, bump, { preid });
247
+ }
248
+ const bumping = new Set(changed);
249
+ const cascade = bump ? this.cascade(bump) : 'changed';
250
+ if (cascade === 'group') {
251
+ for (const m of members)
252
+ bumping.add(m);
253
+ }
254
+ else if (cascade === 'dependents') {
255
+ const worklist = [...changed];
256
+ while (worklist.length) {
257
+ const cur = worklist.pop();
258
+ for (const m of members) {
259
+ if (bumping.has(m))
260
+ continue;
261
+ if (m.dependencies.includes(cur)) {
262
+ bumping.add(m);
263
+ worklist.push(m);
264
+ }
265
+ }
266
+ }
267
+ }
268
+ /** Why a member with no commits of its own is being bumped. `'group'` reaches members that
269
+ * depend on nothing at all, so calling those a "dependent" was simply untrue. */
270
+ const inherited = cascade === 'group' ? `in-group member of a ${bump} change` : `in-group dependent of a ${bump} change`;
271
+ for (const m of members) {
272
+ if (bumping.has(m)) {
273
+ const own = changeByPackage.get(m.name);
274
+ entries.set(m.name, {
275
+ package: m,
276
+ groupKey: key,
277
+ group: label,
278
+ status: 'bump',
279
+ from: m.version,
280
+ to,
281
+ reason: own?.reason ?? inherited,
282
+ });
283
+ }
284
+ else {
285
+ entries.set(m.name, { package: m, groupKey: key, group: label, status: 'no-change', from: m.version });
286
+ }
287
+ }
288
+ }
289
+ /**
290
+ * A package depending on another group's bumped package always receives its scheme's **smallest**
291
+ * bump, computed from its *own* group's current ceiling (the highest `to`/version among its group
292
+ * right now) - never the source's version, and never the source's bump. Runs as a worklist until
293
+ * nothing new is affected, since bumping one package can itself cross into a third group, and so
294
+ * on; never touches a same-group dependent that `cascade` deliberately left alone.
295
+ *
296
+ * The smallest regardless of ecosystem, and not by oversight: nothing about the dependent changed
297
+ * except the reference it carries, so there is nothing for a larger bump to describe. Which bump
298
+ * *is* the smallest is the scheme's to say (`smallestBump`) - `patch` under semver, `revision` for
299
+ * a four-part scheme. A planner for which a changed dependency is not a release at all overrides
300
+ * this to do nothing.
301
+ */
302
+ rippleCrossGroup(packages, entries, preid) {
303
+ const worklist = [...entries.values()].filter(e => e.status === 'bump');
304
+ while (worklist.length) {
305
+ const source = worklist.shift();
306
+ for (const pkg of packages) {
307
+ const entry = entries.get(pkg.name);
308
+ if (entry.status === 'bump' || entry.groupKey === source.groupKey)
309
+ continue;
310
+ if (!pkg.dependencies.includes(source.package))
311
+ continue;
312
+ /** The group always contains `pkg` itself, so there is always at least one version here -
313
+ * the fallback is for the type, not for a case that can happen. */
314
+ const groupCeiling = pkg.versionScheme.highestVersion(packages
315
+ .filter(p => entries.get(p.name).groupKey === entry.groupKey)
316
+ .map(p => entries.get(p.name).to ?? p.version)) ?? pkg.version;
317
+ const next = {
318
+ ...entry,
319
+ status: 'bump',
320
+ to: pkg.versionScheme.next(groupCeiling, pkg.versionScheme.smallestBump(), { preid }),
321
+ reason: `depends on ${source.package.name}@${source.to}`,
322
+ };
323
+ entries.set(pkg.name, next);
324
+ worklist.push(next);
325
+ }
326
+ }
327
+ }
328
+ /**
329
+ * A monorepo root is never published on its own, but its version is still the repository's release
330
+ * identity - what a GitHub Release is named after. How it's computed depends on how many version
331
+ * lines the repo has, derived rather than configured (see `usesCalendarVersion`):
332
+ *
333
+ * - **One group**: the root simply follows it, so the repo and its packages share one number.
334
+ * - **Several groups** (or a repo already on calendar): a calendar version (`2026.9.15-1430`).
335
+ * There is no meaningful shared number to report - the old "highest version among the groups"
336
+ * rule would leave the root standing still whenever a *lower* line released, so a release could
337
+ * happen with no identity of its own, and a semver-looking identity would anyway claim something
338
+ * untrue about packages sitting on entirely different lines.
339
+ *
340
+ * Reports `'no-change'` (not `'bump'`) when nothing in the repository changed at all.
341
+ */
342
+ buildRootEntry(repository, memberEntries, context) {
343
+ const root = repository.rootPackage;
344
+ const anyBumped = memberEntries.some(e => e.status === 'bump');
345
+ if (!anyBumped) {
346
+ return { package: root, groupKey: '__root__', group: 'root', status: 'no-change', from: root.version };
347
+ }
348
+ const calendar = usesCalendarVersion({
349
+ groupCount: context.groupCount,
350
+ rootVersion: root.version,
351
+ lastReleaseVersion: context.lastReleaseVersion,
352
+ });
353
+ const finalVersions = memberEntries.map(e => e.to ?? e.from);
354
+ const to = calendar
355
+ ? formatCalendarVersion(context.now())
356
+ : (root.versionScheme.highestVersion(finalVersions) ?? root.version);
357
+ return {
358
+ package: root,
359
+ groupKey: '__root__',
360
+ group: 'root',
361
+ status: 'bump',
362
+ from: root.version,
363
+ to,
364
+ reason: calendar
365
+ ? 'repository release identity - several version lines, so no shared number to report'
366
+ : 'informational - monorepo root is never published on its own',
367
+ };
368
+ }
369
+ }
370
+ (function (VersionPlanService) {
371
+ /**
372
+ * Registers the planner every `version`/`changed` run will use, replacing any previous one.
373
+ *
374
+ * **One slot, last registration wins** - unlike `Manifest`/`Workspace`, which keep a list and take
375
+ * the first provider that *recognizes* a repository. A planner has nothing to recognize: asked for
376
+ * a plan it always has one, so "first that answers" would just mean "first registered" and a
377
+ * repository layering its own policy plugin after `rman-node` could never take effect - which is
378
+ * the only reason to name two in the first place.
379
+ */
380
+ function setPlanner(planner) {
381
+ current = planner;
382
+ }
383
+ VersionPlanService.setPlanner = setPlanner;
384
+ /** For tests, which would otherwise leak a planner into every later case in the process. */
385
+ function clearPlanner() {
386
+ current = undefined;
387
+ }
388
+ VersionPlanService.clearPlanner = clearPlanner;
389
+ /**
390
+ * The registered planner. **Throws** when there is none, rather than falling back to some
391
+ * built-in default: `Manifest` and `Workspace` can degrade honestly (a package named after its
392
+ * directory, a repository that is its own single package), but there is no version plan that is
393
+ * merely a diminished one - a wrong cascade or a wrong boundary reports a release that is
394
+ * plausible and untrue.
395
+ */
396
+ function getPlanner() {
397
+ if (!current) {
398
+ throw new Error('No version planner is registered, so no version plan can be computed. Name a plugin that ' +
399
+ 'contributes one in .rmanrc "plugins" - "rman-node" for a Node repository.');
400
+ }
401
+ return current;
402
+ }
403
+ VersionPlanService.getPlanner = getPlanner;
404
+ let current;
405
+ })(VersionPlanService || (VersionPlanService = {}));
406
+ /** What one commit says happened, with no reference to any version format - `VersionScheme.bumpFor`
407
+ * turns it into a number's movement. Everything unrecognized is a `'fix'`: something changed, so
408
+ * the smallest release is still warranted. */
409
+ function kindOf(commit) {
410
+ const parsed = ConventionalCommitsService.parseSubject(commit.subject);
411
+ if (parsed?.breaking || ConventionalCommitsService.hasBreakingChangeFooter(commit.body))
412
+ return 'breaking';
413
+ return parsed?.type === 'feat' ? 'feature' : 'fix';
414
+ }