rman 2.0.0-beta.2 → 2.0.0-beta.3

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 (164) hide show
  1. package/README.md +2 -12
  2. package/{plugins/node/augmentation/rman.augmentation.d.ts → builtins/platforms/node/augmentation/rmanrc.augmentation.d.ts} +4 -5
  3. package/{plugins → builtins/platforms}/node/augmentation/system-info.augmentation.js +1 -1
  4. package/{plugins → builtins/platforms}/node/commands/ci.command.d.ts +5 -3
  5. package/{plugins → builtins/platforms}/node/commands/ci.command.js +6 -5
  6. package/{plugins → builtins/platforms}/node/commands/clean.command.d.ts +6 -4
  7. package/{plugins → builtins/platforms}/node/commands/clean.command.js +6 -5
  8. package/builtins/platforms/node/index.d.ts +8 -0
  9. package/builtins/platforms/node/index.js +8 -0
  10. package/{plugins → builtins/platforms}/node/node-config.interface.d.ts +1 -2
  11. package/{plugins → builtins/platforms}/node/node-manifest.provider.d.ts +3 -3
  12. package/{plugins → builtins/platforms}/node/node-manifest.provider.js +3 -3
  13. package/{plugins → builtins/platforms}/node/node.platform.d.ts +3 -3
  14. package/{plugins → builtins/platforms}/node/services/ci.service.d.ts +3 -3
  15. package/{plugins → builtins/platforms}/node/services/ci.service.js +4 -4
  16. package/{plugins → builtins/platforms}/node/services/clean.service.d.ts +3 -3
  17. package/{plugins → builtins/platforms}/node/services/clean.service.js +3 -3
  18. package/{plugins → builtins/platforms}/node/services/publish.service.d.ts +4 -4
  19. package/{plugins → builtins/platforms}/node/services/publish.service.js +5 -5
  20. package/{plugins → builtins/platforms}/node/services/version-plan.service.d.ts +3 -3
  21. package/{plugins → builtins/platforms}/node/services/version-plan.service.js +2 -2
  22. package/builtins/presets/index.d.ts +15 -0
  23. package/builtins/presets/index.js +57 -0
  24. package/builtins/presets/node.d.ts +16 -0
  25. package/builtins/presets/node.js +57 -0
  26. package/{services → builtins/publish-targets/docker}/docker-publish.service.d.ts +6 -6
  27. package/{services → builtins/publish-targets/docker}/docker-publish.service.js +9 -9
  28. package/{targets → builtins/publish-targets/docker}/docker.target.d.ts +6 -5
  29. package/{targets → builtins/publish-targets/docker}/docker.target.js +1 -1
  30. package/builtins/publish-targets/docker/index.d.ts +2 -0
  31. package/builtins/publish-targets/docker/index.js +2 -0
  32. package/builtins/publish-targets/npm/index.d.ts +2 -0
  33. package/builtins/publish-targets/npm/index.js +2 -0
  34. package/{plugins/node → builtins/publish-targets/npm}/npm-publish-target.d.ts +4 -4
  35. package/{plugins/node → builtins/publish-targets/npm}/npm-publish-target.js +4 -5
  36. package/cli.d.ts +3 -0
  37. package/cli.js +16 -7
  38. package/commands/changelog.command.d.ts +106 -2
  39. package/commands/changelog.command.js +40 -0
  40. package/commands/config.command.js +23 -5
  41. package/commands/lint.command.d.ts +176 -0
  42. package/commands/lint.command.js +34 -0
  43. package/commands/list.command.js +1 -1
  44. package/commands/publish.command.d.ts +1 -1
  45. package/commands/publish.command.js +1 -1
  46. package/commands/run.command.js +1 -1
  47. package/commands/version.command.d.ts +16 -3
  48. package/commands/version.command.js +143 -9
  49. package/commands.d.ts +1 -1
  50. package/commands.js +1 -1
  51. package/constants.js +1 -1
  52. package/core/application.d.ts +10 -10
  53. package/core/application.js +28 -11
  54. package/core/{package.d.ts → classes/package.d.ts} +12 -4
  55. package/core/{package.js → classes/package.js} +17 -1
  56. package/core/{repository.d.ts → classes/repository.d.ts} +22 -36
  57. package/core/{repository.js → classes/repository.js} +112 -241
  58. package/core/{service.d.ts → classes/service.d.ts} +2 -2
  59. package/core/classes/workspace.d.ts +275 -0
  60. package/core/classes/workspace.js +537 -0
  61. package/core/command-builder.js +17 -1
  62. package/core/config/config-file-scope.d.ts +107 -0
  63. package/core/config/config-file-scope.js +226 -0
  64. package/core/config/config-interpolator.d.ts +164 -0
  65. package/core/config/config-interpolator.js +577 -0
  66. package/core/config/config-paths.d.ts +65 -0
  67. package/core/config/config-paths.js +78 -0
  68. package/core/config/config-reader.d.ts +192 -0
  69. package/core/config/config-reader.js +727 -0
  70. package/core/{merge-config.js → config/merge-config.js} +4 -8
  71. package/core/{custom-command.d.ts → interfaces/custom-command.d.ts} +5 -5
  72. package/core/{manifest.d.ts → interfaces/manifest.d.ts} +3 -3
  73. package/core/{manifest.js → interfaces/manifest.js} +2 -2
  74. package/core/{plugin.d.ts → interfaces/plugin.d.ts} +44 -27
  75. package/core/{plugin.js → interfaces/plugin.js} +48 -10
  76. package/core/{publish-target.d.ts → interfaces/publish-target.d.ts} +24 -12
  77. package/core/{publish-target.js → interfaces/publish-target.js} +16 -3
  78. package/core/{run-step.d.ts → interfaces/run-step.d.ts} +4 -4
  79. package/core/plugin-loader.d.ts +11 -51
  80. package/core/plugin-loader.js +21 -202
  81. package/index.d.ts +29 -28
  82. package/index.js +20 -46
  83. package/interfaces/config-scope.interface.d.ts +223 -0
  84. package/interfaces/config-scope.interface.js +1 -0
  85. package/interfaces/rman-config.interface.d.ts +100 -5
  86. package/interfaces/rman-config.interface.js +17 -0
  87. package/package.json +2 -1
  88. package/services/change-hash.service.d.ts +40 -6
  89. package/services/change-hash.service.js +90 -11
  90. package/services/changelog.service.d.ts +31 -3
  91. package/services/changelog.service.js +448 -65
  92. package/services/config-service.d.ts +8 -0
  93. package/services/config-service.js +3 -0
  94. package/services/exec.service.d.ts +2 -2
  95. package/services/exec.service.js +1 -1
  96. package/services/github-release.service.d.ts +3 -3
  97. package/services/github-release.service.js +3 -3
  98. package/services/import.service.d.ts +2 -2
  99. package/services/import.service.js +1 -1
  100. package/services/list.service.d.ts +9 -7
  101. package/services/list.service.js +2 -2
  102. package/services/run.service.d.ts +15 -11
  103. package/services/run.service.js +62 -18
  104. package/services/system-info.d.ts +1 -1
  105. package/services/version-plan.service.d.ts +8 -4
  106. package/services/version-plan.service.js +54 -21
  107. package/services/version.service.d.ts +5 -5
  108. package/services/version.service.js +7 -4
  109. package/services.d.ts +1 -1
  110. package/services.js +1 -1
  111. package/utils/branch-guard.d.ts +1 -1
  112. package/utils/color-yaml.d.ts +8 -0
  113. package/utils/color-yaml.js +86 -0
  114. package/utils/git.d.ts +26 -0
  115. package/utils/git.js +65 -0
  116. package/utils/logger.d.ts +1 -1
  117. package/utils/package-filter.d.ts +8 -1
  118. package/utils/package-filter.js +7 -0
  119. package/utils/printable-config.d.ts +8 -0
  120. package/utils/printable-config.js +45 -0
  121. package/utils/progress-panel.d.ts +9 -0
  122. package/utils/progress-panel.js +23 -1
  123. package/utils/release-version.d.ts +1 -1
  124. package/utils/run-options.js +1 -1
  125. package/utils/version-group.d.ts +31 -0
  126. package/utils/version-group.js +43 -0
  127. package/commands/changed.command.d.ts +0 -80
  128. package/commands/changed.command.js +0 -36
  129. package/core/config.d.ts +0 -530
  130. package/core/config.js +0 -1123
  131. package/core/core-services.d.ts +0 -14
  132. package/core/core-services.js +0 -30
  133. package/core/core-targets.d.ts +0 -14
  134. package/core/core-targets.js +0 -16
  135. package/core/extends-config.d.ts +0 -35
  136. package/core/extends-config.js +0 -129
  137. package/core/load-config-module.d.ts +0 -28
  138. package/core/load-config-module.js +0 -42
  139. package/core/workspace.d.ts +0 -119
  140. package/core/workspace.js +0 -124
  141. package/plugins/builtins.d.ts +0 -44
  142. package/plugins/builtins.js +0 -33
  143. package/plugins/detect.d.ts +0 -78
  144. package/plugins/detect.js +0 -70
  145. package/plugins/node/index.d.ts +0 -29
  146. package/plugins/node/index.js +0 -40
  147. /package/{core/run-step.js → builtins/platforms/node/augmentation/rmanrc.augmentation.js} +0 -0
  148. /package/{plugins → builtins/platforms}/node/augmentation/system-info.augmentation.d.ts +0 -0
  149. /package/{plugins → builtins/platforms}/node/node-config.interface.js +0 -0
  150. /package/{plugins → builtins/platforms}/node/node.platform.js +0 -0
  151. /package/{plugins → builtins/platforms}/node/utils/workspace-range.d.ts +0 -0
  152. /package/{plugins → builtins/platforms}/node/utils/workspace-range.js +0 -0
  153. /package/{plugins/node/utils → builtins/publish-targets/npm}/npm-view.d.ts +0 -0
  154. /package/{plugins/node/utils → builtins/publish-targets/npm}/npm-view.js +0 -0
  155. /package/core/{registry.d.ts → classes/registry.d.ts} +0 -0
  156. /package/core/{registry.js → classes/registry.js} +0 -0
  157. /package/core/{service.js → classes/service.js} +0 -0
  158. /package/core/{version-scheme.d.ts → classes/version-scheme.d.ts} +0 -0
  159. /package/core/{version-scheme.js → classes/version-scheme.js} +0 -0
  160. /package/core/{merge-config.d.ts → config/merge-config.d.ts} +0 -0
  161. /package/core/{resolve-target.d.ts → config/resolve-config-target.d.ts} +0 -0
  162. /package/core/{resolve-target.js → config/resolve-config-target.js} +0 -0
  163. /package/core/{custom-command.js → interfaces/custom-command.js} +0 -0
  164. /package/{plugins/node/augmentation/rman.augmentation.js → core/interfaces/run-step.js} +0 -0
@@ -1,6 +1,6 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
- import { Service } from '../core/service.js';
3
+ import { Service } from '../core/classes/service.js';
4
4
  import { GitHelper } from '../utils/git.js';
5
5
  import { filterPackages } from '../utils/package-filter.js';
6
6
  import { ChangeHashService } from './change-hash.service.js';
@@ -18,9 +18,24 @@ export class ChangelogService extends Service {
18
18
  * the CLI command - printing what happened is the command's job.
19
19
  */
20
20
  async generateToFile(options = {}) {
21
- const entries = await this.getEntries(options);
22
- for (const entry of entries)
23
- prependToChangelogFile(entry.package, entry.filePath, entry.content);
21
+ const entries = await this.getEntries({ ...options, write: true });
22
+ /**
23
+ * **Where each file is documented up to, recorded in the file itself.** Read back by
24
+ * `resolveBoundary` on the next run, which is what makes a second `--write` append the commits
25
+ * since this one rather than re-emitting everything since the last tag.
26
+ *
27
+ * **Each entry's own last commit, not HEAD** - and that distinction is not bookkeeping.
28
+ * Measured with HEAD: under `changelog.unreleased: false` the run wrote the released history
29
+ * and then marked the file as documented up to HEAD, so the commits it had deliberately *not*
30
+ * written fell behind the boundary. The release that followed found nothing after the marker
31
+ * and produced no entry at all - the work silently gone from the changelog for good.
32
+ *
33
+ * Entries are ordered oldest first and each prepend rewrites the one marker, so the last write
34
+ * for a file leaves the newest documented commit behind it.
35
+ */
36
+ for (const entry of entries) {
37
+ prependToChangelogFile(entry.package, entry.filePath, entry.content, entry.documentedUpTo);
38
+ }
24
39
  return entries;
25
40
  }
26
41
  /**
@@ -83,42 +98,71 @@ export class ChangelogService extends Service {
83
98
  // No boundary at all means nothing has ever been released, so everything so far is
84
99
  // unreleased - the same fallback `VersionService` makes. (Not "not yet pushed": that reads
85
100
  // as empty the moment a first release is pushed, and for a repo with no remote at all.)
86
- promise = (hash ? git.listCommits({ hash }) : git.listAllCommits()).then(dropVersionBumps);
101
+ //
102
+ /**
103
+ * **Release markers are dropped per segment, not here.** A release tag sits on exactly the
104
+ * commit this filter removes, so dropping first leaves `splitByRelease` matching tags
105
+ * against shas that are no longer in the list - every cut silently missed, and the whole
106
+ * range rendered as one release. Measured while writing it.
107
+ */
108
+ promise = hash ? git.listCommits({ hash }) : git.listAllCommits();
87
109
  commitsByHash.set(key, promise);
88
110
  }
89
111
  return promise;
90
112
  };
91
113
  const commitsByTarget = await Promise.all(targets.map(async (pkg) => {
92
- const catchUpFile = path.join(pkg.dirname, resolveFilePath(pkg, options.filePath));
93
- const from = await ChangeHashService.detect(git, pkg, {
94
- from: options.from,
95
- catchUpFile: fs.existsSync(catchUpFile) ? catchUpFile : undefined,
96
- });
114
+ const changelogFile = path.join(pkg.dirname, resolveFilePath(pkg, options.filePath));
115
+ const from = await resolveBoundary(git, pkg, changelogFile, options);
97
116
  return listCommitsCached(from);
98
117
  }));
99
118
  const entries = [];
100
119
  for (let i = 0; i < targets.length; i++) {
101
120
  const pkg = targets[i];
102
- const ownCommits = commitsByTarget[i].filter(c => ownersOf(repository, c).has(pkg));
103
- if (!ownCommits.length)
104
- continue;
105
- const grouped = groupCommits(ownCommits.map(c => c.subject), ignoreTypesConfig(pkg));
106
- // every commit could have been dropped by ignoreTypes - skip this package's entry entirely
107
- // rather than rendering a heading with nothing real underneath it.
108
- if (!grouped.features.length && !grouped.fixes.length && !grouped.other.length)
109
- continue;
110
121
  const label = pkg === repository.rootPackage ? `${path.basename(repository.dirname)} repository` : pkg.name;
111
- const { version, content } = await renderEntry(repository, pkg, label, grouped, git, options.version);
112
- entries.push({
113
- package: pkg,
114
- label,
115
- version,
116
- features: grouped.features,
117
- fixes: grouped.fixes,
118
- other: grouped.other,
119
- content,
120
- filePath: resolveFilePath(pkg, options.filePath),
121
- });
122
+ /**
123
+ * **One entry per release in the range, not one entry per package.** The range is whatever
124
+ * the boundary opened up, and a release tag inside it ends a release: a changelog that does
125
+ * not cut there is not a changelog, it is a commit list. Measured on a real repository - a
126
+ * first `--write` with no changelog file reaches back through twelve tags, and every commit
127
+ * in all of them landed under a single `v2.1.6` heading.
128
+ *
129
+ * Ordinary runs are unaffected by construction: the boundary is the last release, so no tag
130
+ * falls inside the range and there is exactly one segment - which is what `version
131
+ * --changelog` and `github-release` see too, since both pass an explicit boundary.
132
+ */
133
+ const floor = await resolveStartingPoint(git, pkg, options);
134
+ const floorIndex = floor?.sha ? commitsByTarget[i].findIndex(c => c.sha === floor.sha) : -1;
135
+ const withUnreleased = resolveUnreleased(pkg, options);
136
+ const titles = resolveTitles(pkg);
137
+ const order = resolveOrder(pkg, titles);
138
+ for (const segment of await splitByRelease(git, pkg, commitsByTarget[i])) {
139
+ if (!segment.tag && !withUnreleased)
140
+ continue;
141
+ if (await isBelowStartingPoint(git, pkg, segment, floor, floorIndex))
142
+ continue;
143
+ const ownCommits = dropVersionBumps(segment.commits).filter(c => ownersOf(repository, c).has(pkg));
144
+ if (!ownCommits.length)
145
+ continue;
146
+ const sections = groupCommits(ownCommits.map(c => c.subject), ignoreTypesConfig(pkg), titles, order);
147
+ // every commit could have been dropped by ignoreTypes - skip this package's entry entirely
148
+ // rather than rendering a heading with nothing real underneath it.
149
+ if (!sections.length)
150
+ continue;
151
+ const { version, content } = await renderEntry(repository, pkg, label, sections, titles, git, options.version, segment.tag);
152
+ entries.push({
153
+ package: pkg,
154
+ label,
155
+ version,
156
+ /** The last commit this entry covers - what the file's marker records. For a tagged
157
+ * segment that is the release commit itself, so everything up to and including the
158
+ * release is documented and nothing after it is claimed. */
159
+ documentedUpTo: segment.commits[segment.commits.length - 1].sha,
160
+ sections,
161
+ ...legacyBuckets(sections, titles),
162
+ content,
163
+ filePath: resolveFilePath(pkg, options.filePath),
164
+ });
165
+ }
122
166
  }
123
167
  return entries;
124
168
  }
@@ -131,41 +175,142 @@ function ignoreTypesConfig(pkg) {
131
175
  const v = pkg.config?.changelog?.ignoreTypes;
132
176
  return new Set(Array.isArray(v) ? v.map(t => String(t).toLowerCase()) : []);
133
177
  }
134
- function groupCommits(subjects, ignoreTypes = new Set()) {
135
- const grouped = { features: [], fixes: [], other: [] };
178
+ /**
179
+ * **The headings, and the order they come out in** - `.rmanrc changelog.titles`, merged **per key**
180
+ * over the defaults rather than replacing them.
181
+ *
182
+ * Merging is the `vars` rule rather than a new exception, and it is what the shape of the key asks
183
+ * for: naming `dev` adds a section, and should not silently cost a repository its `feat` and `fix`.
184
+ * Renaming `feat` leaves it where it was, so the running order survives a rename. The cost, stated:
185
+ * a default section cannot be *removed* by leaving it out - `changelog.ignoreTypes` is the key that
186
+ * drops a type entirely.
187
+ *
188
+ * `'*'` is the heading for every type nobody named, and is always last however it was declared -
189
+ * a catch-all in the middle of the order would silently swallow the sections after it.
190
+ */
191
+ function resolveTitles(pkg) {
192
+ const titles = new Map(DEFAULT_TITLES);
193
+ const cfg = pkg.config?.changelog?.titles;
194
+ if (cfg && typeof cfg === 'object') {
195
+ for (const [type, title] of Object.entries(cfg)) {
196
+ if (typeof title === 'string' && title)
197
+ titles.set(type.toLowerCase(), title);
198
+ }
199
+ }
200
+ return titles;
201
+ }
202
+ /**
203
+ * One list of lines per heading, in the heading order, empty ones dropped.
204
+ *
205
+ * **The type prefix is stripped for every section, not just two.** It used to be `push(line)` for
206
+ * `feat`/`fix` and `push(subject)` for everything else, so Features read `- the first feature`
207
+ * while Other Changes read `- chore: write changelog` - the heading naming the type and the bullet
208
+ * repeating it. With every type able to have a heading of its own that asymmetry has no defence
209
+ * left. A subject that is not Conventional Commits at all has no prefix to strip and is kept whole.
210
+ */
211
+ function groupCommits(subjects, ignoreTypes, titles, order) {
212
+ const CATCH_ALL = '*';
213
+ const byTitle = new Map();
214
+ const titleFor = (type) => titles.get(type) ?? titles.get(CATCH_ALL) ?? DEFAULT_TITLES.get(CATCH_ALL);
215
+ const push = (title, line) => {
216
+ const lines = byTitle.get(title);
217
+ if (lines)
218
+ lines.push(line);
219
+ else
220
+ byTitle.set(title, [line]);
221
+ };
136
222
  for (const subject of subjects) {
137
223
  const parsed = ConventionalCommitsService.parseSubject(subject);
138
224
  if (!parsed) {
139
- grouped.other.push(subject);
225
+ push(titleFor(CATCH_ALL), subject);
140
226
  continue;
141
227
  }
142
228
  const { type, scope, description } = parsed;
143
229
  if (ignoreTypes.has(type))
144
230
  continue;
145
- const line = scope ? `**${scope}:** ${description}` : description;
146
- if (type === 'feat')
147
- grouped.features.push(line);
148
- else if (type === 'fix')
149
- grouped.fixes.push(line);
150
- else
151
- grouped.other.push(subject);
231
+ push(titleFor(type), scope ? `**${scope}:** ${description}` : description);
232
+ }
233
+ /**
234
+ * Only a type with a heading of its own takes a position. A type nobody named resolves to the
235
+ * catch-all, so ordering by it would drag the catch-all to wherever that type was listed -
236
+ * measured: `sortTitles: ['docs', 'fix', 'feat']` with no `docs` heading put "Other Changes"
237
+ * first and swallowed the sections after it.
238
+ */
239
+ const ordered = [...new Set(order.filter(t => t !== CATCH_ALL && titles.has(t)).map(titleFor))];
240
+ ordered.push(titleFor(CATCH_ALL));
241
+ const sections = [];
242
+ for (const title of ordered) {
243
+ const lines = byTitle.get(title);
244
+ if (lines?.length)
245
+ sections.push({ title, lines });
246
+ byTitle.delete(title);
152
247
  }
153
- return grouped;
248
+ /** A heading a repository named for a type nobody used is not rendered; one it named that the
249
+ * ordering above somehow missed still is, rather than being dropped on the floor. */
250
+ for (const [title, lines] of byTitle)
251
+ if (lines.length)
252
+ sections.push({ title, lines });
253
+ return sections;
154
254
  }
255
+ /**
256
+ * **The order the sections come out in** - `.rmanrc changelog.sortTitles`, a list of commit types.
257
+ *
258
+ * Separate from `titles` because they are separate decisions. Order used to fall out of the order
259
+ * `titles` was written in, which quietly meant a repository renaming `feat` was also re-deciding
260
+ * where it sits; a key that patches wording should not move things.
261
+ *
262
+ * Types it leaves out keep their place **after** the listed ones, in the order `titles` knows them
263
+ * (the defaults first, then anything the repository added) - it is a sort, not a filter, and
264
+ * `ignoreTypes` is what drops a type. `'*'` is appended by `groupCommits` whatever this says.
265
+ */
266
+ function resolveOrder(pkg, titles) {
267
+ const cfg = pkg.config?.changelog?.sortTitles;
268
+ const listed = Array.isArray(cfg) ? cfg.map(t => String(t).toLowerCase()) : [];
269
+ return [...new Set([...listed, ...titles.keys()])];
270
+ }
271
+ /**
272
+ * `features`/`fixes`/`other` derived back out of the sections, for `{{features}}`/`{{fixes}}`/
273
+ * `{{other}}` and for `Entry`'s own three fields.
274
+ *
275
+ * Both were the public surface before headings were configurable, so they keep meaning what they
276
+ * meant: whatever `feat` and `fix` are listed under, and everything else together. A repository
277
+ * that renames those headings still gets them here; one that adds `dev` finds it in `other`, which
278
+ * is the only honest place for it in a shape that has three slots.
279
+ */
280
+ function legacyBuckets(sections, titles) {
281
+ const of = (type) => sections.find(s => s.title === titles.get(type))?.lines ?? [];
282
+ const features = of('feat');
283
+ const fixes = of('fix');
284
+ const other = sections.filter(s => s.lines !== features && s.lines !== fixes).flatMap(s => s.lines);
285
+ return { features, fixes, other };
286
+ }
287
+ /** `feat`/`fix`/everything else, which is what rman shipped before `changelog.titles` existed and
288
+ * so is what a repository that sets nothing still gets, wording and order alike. */
289
+ const DEFAULT_TITLES = new Map([
290
+ ['feat', '✨ Features'],
291
+ ['fix', '🐛 Bug Fixes'],
292
+ ['*', '🔧 Other Changes'],
293
+ ]);
155
294
  function bulletList(lines) {
156
295
  return lines.map(l => `- ${l}`).join('\n');
157
296
  }
158
- function renderCommitsBlock(grouped) {
159
- const sections = [];
160
- if (grouped.features.length)
161
- sections.push(`### ✨ Features\n\n${bulletList(grouped.features)}`);
162
- if (grouped.fixes.length)
163
- sections.push(`### 🐛 Bug Fixes\n\n${bulletList(grouped.fixes)}`);
164
- if (grouped.other.length)
165
- sections.push(`### 🔧 Other Changes\n\n${bulletList(grouped.other)}`);
166
- return sections.join('\n\n');
297
+ function renderCommitsBlock(sections) {
298
+ return sections.map(s => `### ${s.title}\n\n${bulletList(s.lines)}`).join('\n\n');
167
299
  }
168
- const DEFAULT_TEMPLATE = '## {{package}} {{version}} ({{date}})\n\n{{commits}}\n';
300
+ /**
301
+ * **`{{title}}`, not `{{package}} {{version}}`** - an entry that a release tag closes is headed by
302
+ * that tag, because the tag is what it describes.
303
+ *
304
+ * The old heading was assembled out of two things that do not always belong together, and the
305
+ * repository root is where that showed: `## panates-javascript repository 2.1.6 (2026-04-30)`
306
+ * states a version the repository does not have - its root package was `panates-style` at `0.0.5`,
307
+ * and `2.1.6` was read off the `v2.1.6` tag. It also reads as one package's notes when the tag
308
+ * covers the whole repository.
309
+ *
310
+ * `{{package}}`, `{{version}}` and `{{tag}}` are all still bound, so a repository that wants the
311
+ * old shape - or the package's name kept beside the tag - writes its own `changelog.template`.
312
+ */
313
+ const DEFAULT_TEMPLATE = '## {{title}} ({{date}})\n\n{{commits}}\n';
169
314
  /** `.rmanrc changelog.template` is a *path* to a template file (resolved relative to the
170
315
  * repository root, regardless of which level's `.rmanrc` declared it), not the template text
171
316
  * itself - keeping multi-line Markdown out of `.rmanrc` so it stays readable. Falls back to a
@@ -188,6 +333,103 @@ const DEFAULT_CHANGELOG_FILE = 'CHANGELOG.md';
188
333
  * `optionsFilePath` (`Changelog.Options.filePath`, CLI `--file-path`) applies the same way to
189
334
  * every package and wins over `.rmanrc changelog.filePath` (cascaded, per-package overridable),
190
335
  * which in turn wins over the default `'CHANGELOG.md'`. */
336
+ /**
337
+ * Whether the commits that are not released yet get an entry of their own - `.rmanrc
338
+ * changelog.unreleased`, or the flag, **defaulting to `true`**.
339
+ *
340
+ * `auto-changelog` defaults the equivalent off, and the opposite default here is not an oversight:
341
+ * that tool documents a finished history, while `rman changelog` exists to answer what is *not*
342
+ * released yet, down to the message it prints when there is none. Off by default would make the
343
+ * common case need a flag.
344
+ *
345
+ * **A named release is never dropped by it.** `options.version` means the caller is describing the
346
+ * release it is about to cut - `version --changelog` writes that entry before it commits and tags,
347
+ * so the segment is only "unreleased" for the few seconds until it is. Without this, a repository
348
+ * setting `unreleased: false` would find its releases silently documenting nothing.
349
+ */
350
+ function resolveUnreleased(pkg, options) {
351
+ if (options.version)
352
+ return true;
353
+ const cfg = pkg.config?.changelog?.unreleased;
354
+ return options.unreleased ?? (typeof cfg === 'boolean' ? cfg : true);
355
+ }
356
+ /**
357
+ * **Where this package's changelog begins** - `.rmanrc changelog.startingAt`, or the CLI flag,
358
+ * which wins as everywhere.
359
+ *
360
+ * The need is a package whose early development is noise: a first `--write` reaches back through
361
+ * every release there has ever been, and for something that has shipped for years most of that is
362
+ * not what a changelog is for. `auto-changelog` spells this as two options, `--starting-version`
363
+ * and `--starting-date`; one key taking whichever form the answer has reads better, and admits a
364
+ * third that neither covers - a commit, for a package whose history does not begin at a tag.
365
+ *
366
+ * **Three forms, decided in this order**, because the shapes overlap and a rule nobody can see is
367
+ * a trap:
368
+ *
369
+ * 1. `YYYY-MM-DD` - a date. Checked first because that shape is unambiguous.
370
+ * 2. a **version or release tag** - `2.0.0`, `v2.0.0`, `@scope/pkg@2.0.0` all resolve to a version,
371
+ * so a repository does not have to know which spelling this key wants.
372
+ * 3. a **commit** anything else git can resolve.
373
+ *
374
+ * A string that is none of the three is a configuration mistake and says so, naming all three. The
375
+ * one genuine collision is a tag whose name is also hex (`deadbee`): the tag wins, because a
376
+ * repository that named a tag that has said what it means.
377
+ *
378
+ * **Not `--from`.** That is this run's boundary, applies identically to every package and overrides
379
+ * detection; this is a lasting fact about one package, cascaded like any other config key, and
380
+ * still holds on the run after next. They read alike for a commit and are not the same thing.
381
+ */
382
+ async function resolveStartingPoint(git, pkg, options) {
383
+ const cfg = pkg.config?.changelog?.startingAt;
384
+ const value = options.startingAt ?? (typeof cfg === 'string' ? cfg : '');
385
+ if (!value)
386
+ return undefined;
387
+ if (/^\d{4}-\d{2}-\d{2}$/.test(value))
388
+ return { date: value };
389
+ if (pkg.versionScheme.isValid(value))
390
+ return { version: value };
391
+ const asTagVersion = ChangeHashService.extractVersion(value, ChangeHashService.tagPattern(pkg).replace('{name}', pkg.name));
392
+ if (pkg.versionScheme.isValid(asTagVersion))
393
+ return { version: asTagVersion };
394
+ const sha = await git.resolveCommit(value);
395
+ if (sha)
396
+ return { sha };
397
+ throw new Error(`Invalid "changelog.startingAt" for "${pkg.name}": "${value}" is not a ${pkg.versionScheme.name} ` +
398
+ 'version or release tag, a YYYY-MM-DD date, or a commit in this repository');
399
+ }
400
+ /**
401
+ * Whether this segment's release falls below the floor, and so is left out.
402
+ *
403
+ * **Only a tagged segment can**, and that is the rule rather than an edge case: the unreleased
404
+ * segment is happening now, so no past floor is above it, and dropping it would hide the very
405
+ * commits the run was asked about.
406
+ *
407
+ * All three forms are **inclusive**, matching `auto-changelog`: naming 2.0.0 keeps 2.0.0, and
408
+ * naming a commit keeps the release that commit belongs to.
409
+ */
410
+ async function isBelowStartingPoint(git, pkg, segment, floor, floorIndex) {
411
+ if (!floor || !segment.tag)
412
+ return false;
413
+ if (floor.version) {
414
+ const expanded = ChangeHashService.tagPattern(pkg).replace('{name}', pkg.name);
415
+ const version = ChangeHashService.extractVersion(segment.tag, expanded);
416
+ /** A tag carrying no version the scheme can read is kept - a floor is for leaving out history,
417
+ * never for losing what it cannot classify. */
418
+ if (pkg.versionScheme.isValid(version) && pkg.versionScheme.compare(version, floor.version) < 0)
419
+ return true;
420
+ }
421
+ if (floor.date) {
422
+ const date = await git.commitDate(segment.tag);
423
+ if (date && date < floor.date)
424
+ return true;
425
+ }
426
+ /** The commit's own place in the range decides, so no second git call: a release that ended
427
+ * before the floor commit is below it. `-1` means the floor is not in this range at all -
428
+ * older than everything here, so nothing is below it. */
429
+ if (floor.sha && floorIndex >= 0 && segment.endIndex < floorIndex)
430
+ return true;
431
+ return false;
432
+ }
191
433
  function resolveFilePath(pkg, optionsFilePath) {
192
434
  if (optionsFilePath)
193
435
  return optionsFilePath;
@@ -199,11 +441,97 @@ function resolveFilePath(pkg, optionsFilePath) {
199
441
  * `{{version}}` doc on `Changelog.getEntries`) package.json. Falls back to package.json's version
200
442
  * if no matching tag exists at all (never tagged, or a fresh package) - see `findLatestTag`.
201
443
  */
202
- async function resolveVersion(git, pkg) {
203
- const tag = await ChangeHashService.findLatestTag(git, pkg);
204
- return tag
205
- ? ChangeHashService.extractVersion(tag, ChangeHashService.tagPattern(pkg).replace('{name}', pkg.name))
206
- : pkg.version || '';
444
+ /**
445
+ * The version an entry is headed with, **and the date that release actually happened**.
446
+ *
447
+ * The two used to come from different places: the version was read back from the package's latest
448
+ * tag while the date was `new Date()`. So regenerating notes for an already-tagged release headed
449
+ * them with that release's number and *today's* date - measured on a real repository,
450
+ * `## @panates/eslint-config v2.1.6 (2026-09-25)` for a v2.1.6 tagged days earlier. Two halves of
451
+ * one heading describing two different releases.
452
+ *
453
+ * Today's date is still right for the case it was written for: a caller that passes `version`
454
+ * explicitly is describing a release that **does not exist yet** (`version --changelog` writes the
455
+ * entry before it commits and tags), so there is no tag to read a date off and the clock is the
456
+ * only answer. That is why this is decided here, beside the version, rather than at the template.
457
+ */
458
+ async function resolveHeading(git, pkg, label, versionOverride, segmentTag) {
459
+ const today = new Date().toISOString().slice(0, 10);
460
+ const expanded = ChangeHashService.tagPattern(pkg).replace('{name}', pkg.name);
461
+ /** A segment that a release tag closes is headed by **that** release, whatever the newest one
462
+ * is - which is the whole point of splitting, and the reason an override cannot win here: a
463
+ * caller naming a version is naming the *unreleased* one, the segment with no tag. */
464
+ if (segmentTag) {
465
+ return {
466
+ version: ChangeHashService.extractVersion(segmentTag, expanded),
467
+ date: (await git.commitDate(segmentTag)) ?? today,
468
+ title: segmentTag,
469
+ };
470
+ }
471
+ /**
472
+ * A caller naming the version is naming the one being **prepared** - `version --changelog` writes
473
+ * the entry before it commits and tags. So the heading is the tag that release is about to get,
474
+ * which keeps every heading in the file the same kind of thing, and the date is today because
475
+ * that is when it is happening.
476
+ */
477
+ if (versionOverride) {
478
+ return { version: versionOverride, date: today, title: ChangeHashService.expandTag(pkg, versionOverride) };
479
+ }
480
+ /**
481
+ * **Nothing else is a release**, so nothing else gets a release's heading. This used to read the
482
+ * package's latest tag and use its version *and its date* - so the commits that are not released
483
+ * yet were headed with the number and the day of the release before them. In the repository root
484
+ * that produced `## panates-javascript repository 2.1.6 (2026-04-30)` sitting above the real
485
+ * `## v2.1.6 (2026-04-30)`: the same release named twice, once wrongly.
486
+ *
487
+ * **The label stays in it**, which `Unreleased` alone loses: `rman changelog` prints every
488
+ * package's entry to one stream, and three consecutive `## Unreleased` blocks say nothing about
489
+ * which package each belongs to. Measured - 22 specs caught exactly that.
490
+ *
491
+ * `version` is still what it always was, for a template that wants it - only the heading changed.
492
+ */
493
+ return { version: pkg.version || '', date: today, title: `Unreleased — ${label}` };
494
+ }
495
+ /**
496
+ * The range cut into releases: each **release tag inside it** closes a segment, and whatever
497
+ * follows the last one is the unreleased segment (`tag: undefined`).
498
+ *
499
+ * Oldest first, because `generateToFile` prepends each entry in turn - so the last one written is
500
+ * the one that ends up at the top of the file.
501
+ *
502
+ * The tagged commit belongs to the segment it **closes**: a release tag sits on the release commit,
503
+ * which comes after the work it releases. That commit is then dropped by `dropVersionBumps` like
504
+ * any other release marker, so it heads the segment without appearing in it.
505
+ *
506
+ * A tag pointing at a commit outside the range simply never matches, which is what should happen -
507
+ * it belongs to a release this run is not describing.
508
+ */
509
+ async function splitByRelease(git, pkg, commits) {
510
+ if (!commits.length)
511
+ return [];
512
+ const tagBySha = new Map();
513
+ for (const pattern of ChangeHashService.releaseTagPatterns(pkg)) {
514
+ /** Earlier patterns win: a package with tags under its own name is described by those, and the
515
+ * shared `v*` is only there to cover the releases it predates. */
516
+ for (const { tag, sha } of await git.listTagCommits(pattern))
517
+ if (!tagBySha.has(sha))
518
+ tagBySha.set(sha, tag);
519
+ }
520
+ if (!tagBySha.size)
521
+ return [{ commits, endIndex: commits.length - 1 }];
522
+ const segments = [];
523
+ let current = [];
524
+ for (const [index, commit] of commits.entries()) {
525
+ current.push(commit);
526
+ const tag = tagBySha.get(commit.sha);
527
+ if (tag) {
528
+ segments.push({ tag, commits: current, endIndex: index });
529
+ current = [];
530
+ }
531
+ }
532
+ if (current.length)
533
+ segments.push({ commits: current, endIndex: commits.length - 1 });
534
+ return segments;
207
535
  }
208
536
  /** The most specific package whose directory contains `file` - the repository root itself as the
209
537
  * fallback for anything outside every package (e.g. root-level config files). Mirrors
@@ -249,29 +577,84 @@ function ownersOf(repository, commit) {
249
577
  }
250
578
  return owners;
251
579
  }
252
- async function renderEntry(repository, pkg, label, grouped, git, versionOverride) {
580
+ async function renderEntry(repository, pkg, label, sections, titles, git, versionOverride, segmentTag) {
253
581
  const template = resolveTemplate(repository, pkg);
254
- const version = versionOverride ?? (await resolveVersion(git, pkg));
582
+ const { version, date, title } = await resolveHeading(git, pkg, label, versionOverride, segmentTag);
255
583
  const content = render(template, {
256
584
  package: label,
257
585
  version,
258
- date: new Date().toISOString().slice(0, 10),
259
- commits: renderCommitsBlock(grouped),
260
- features: bulletList(grouped.features),
261
- fixes: bulletList(grouped.fixes),
262
- other: bulletList(grouped.other),
586
+ tag: segmentTag ?? '',
587
+ title,
588
+ date,
589
+ commits: renderCommitsBlock(sections),
590
+ ...Object.fromEntries(Object.entries(legacyBuckets(sections, titles)).map(([k, v]) => [k, bulletList(v)])),
263
591
  });
264
592
  return { version, content };
265
593
  }
594
+ /**
595
+ * **Where a changelog file says it is documented up to**, written by `--write` and read back by
596
+ * `resolveBoundary`. An HTML comment, so it renders as nothing at all.
597
+ *
598
+ * The alternative was parsing the topmost version heading back out of the file, and it fails on the
599
+ * one thing this has to survive: `changelog.template` is the *repository's*, so the heading is a
600
+ * shape rman did not choose and cannot reliably read. The other alternative - the file's own last
601
+ * modifying commit - is what `catchUpFile` already does, and it cannot be the boundary because any
602
+ * unrelated edit (a typo, a hand-written note) would move it forward and drop every commit in
603
+ * between, silently.
604
+ */
605
+ const MARKER = /^<!-- rman:documented-up-to ([0-9a-f]{7,40}) -->[ \t]*\r?\n+/m;
606
+ /**
607
+ * The boundary this package's notes start from.
608
+ *
609
+ * `--from` wins outright, as everywhere. Otherwise, **for a `--write` run only**, the changelog
610
+ * file decides - it is the thing being appended to, so where it stopped is the question:
611
+ *
612
+ * - **A marker** - start there. Measured without it: a second `--write` re-listed every commit
613
+ * since the last tag on top of the entry that already held them, so one commit appeared twice
614
+ * under two headings carrying the same version number.
615
+ * - **No file at all** - the whole history, because nothing has been documented. Measured without
616
+ * it: the first `--write` in a repository with a `v1.0.0` tag documented only the commits *after*
617
+ * that tag, and the ones before it were never written anywhere and never would be.
618
+ * - **A file with no marker** - one rman did not write, or wrote before markers existed. Fall back
619
+ * to ordinary detection, which is where `catchUpFile` still earns its place: it widens the tag
620
+ * boundary backwards to the file's last commit, so a stale changelog catches up rather than
621
+ * skipping. The next write leaves a marker, so this is the one run that has to guess.
622
+ *
623
+ * A **print** run is deliberately none of this: nothing is being appended, and answering "the notes
624
+ * for this release" with "nothing, it is all documented" would be the wrong question answered.
625
+ */
626
+ async function resolveBoundary(git, pkg, changelogFile, options) {
627
+ if (options.from && options.from !== ChangeHashService.AUTO)
628
+ return options.from;
629
+ if (options.write) {
630
+ const exists = fs.existsSync(changelogFile);
631
+ if (!exists)
632
+ return undefined;
633
+ const marker = MARKER.exec(fs.readFileSync(changelogFile, 'utf-8'));
634
+ if (marker)
635
+ return marker[1];
636
+ }
637
+ return ChangeHashService.detect(git, pkg, {
638
+ from: options.from,
639
+ catchUpFile: fs.existsSync(changelogFile) ? changelogFile : undefined,
640
+ });
641
+ }
266
642
  /** Prepends `content` right after the top-level "# Changelog" heading if the file already has
267
643
  * one, otherwise creates the file (and any missing parent directory - `relFilePath` can nest one,
268
644
  * e.g. `'docs/CHANGELOG.md'`) with one. Leaves everything already in the file untouched below it. */
269
- function prependToChangelogFile(pkg, relFilePath, content) {
645
+ function prependToChangelogFile(pkg, relFilePath, content, documentedUpTo) {
270
646
  const file = path.join(pkg.dirname, relFilePath);
271
647
  const existing = fs.existsSync(file) ? fs.readFileSync(file, 'utf-8') : '';
272
648
  const headerMatch = /^# Changelog\r?\n+/.exec(existing);
273
649
  const header = headerMatch ? headerMatch[0] : '# Changelog\n\n';
274
- const rest = headerMatch ? existing.slice(headerMatch[0].length) : existing;
650
+ /** The previous marker goes with the header it sat under - there is one per file, rewritten
651
+ * each time, never accumulated. */
652
+ const rest = (headerMatch ? existing.slice(headerMatch[0].length) : existing).replace(MARKER, '');
653
+ const marker = documentedUpTo ? `<!-- rman:documented-up-to ${documentedUpTo} -->\n\n` : '';
654
+ /** A rule **between** releases, so it is the writer's and not the template's: an entry printed
655
+ * to stdout, or handed to `github-release` as a body, has nothing below it to be separated
656
+ * from. Only written when there is something below. */
657
+ const separator = rest.trim() ? '\n---\n\n' : '';
275
658
  fs.mkdirSync(path.dirname(file), { recursive: true });
276
- fs.writeFileSync(file, header + content.trimEnd() + '\n\n' + rest);
659
+ fs.writeFileSync(file, header + marker + content.trimEnd() + '\n' + separator + rest);
277
660
  }
@@ -0,0 +1,8 @@
1
+ import { Service } from '../core/classes/service.js';
2
+ export declare class ConfigService extends Service {
3
+ }
4
+ declare module '../core/classes/service.js' {
5
+ interface ServiceMap {
6
+ config: ConfigService;
7
+ }
8
+ }
@@ -0,0 +1,3 @@
1
+ import { Service } from '../core/classes/service.js';
2
+ export class ConfigService extends Service {
3
+ }
@@ -1,4 +1,4 @@
1
- import { Service } from '../core/service.js';
1
+ import { Service } from '../core/classes/service.js';
2
2
  import { type LogLevel } from '../utils/logger.js';
3
3
  import { type PackageFilterOptions } from '../utils/package-filter.js';
4
4
  /**
@@ -39,7 +39,7 @@ export declare namespace ExecService {
39
39
  fromRoot?: boolean;
40
40
  }
41
41
  }
42
- declare module '../core/service.js' {
42
+ declare module '../core/classes/service.js' {
43
43
  interface ServiceMap {
44
44
  exec: ExecService;
45
45
  }
@@ -1,7 +1,7 @@
1
1
  import os from 'node:os';
2
2
  import colors from 'ansi-colors';
3
3
  import { Task } from 'power-tasks';
4
- import { Service } from '../core/service.js';
4
+ import { Service } from '../core/classes/service.js';
5
5
  import { exec as execCommand } from '../utils/exec.js';
6
6
  import { Logger, resolveRootLogLevel } from '../utils/logger.js';
7
7
  import { filterPackages } from '../utils/package-filter.js';
@@ -1,5 +1,5 @@
1
- import { Package } from '../core/package.js';
2
- import { Service } from '../core/service.js';
1
+ import { Package } from '../core/classes/package.js';
2
+ import { Service } from '../core/classes/service.js';
3
3
  /**
4
4
  * A service class - see `ListService` for the shape and `Service` for the three measured
5
5
  * consequences a namespace had. `repository` left the signature because the application carries it.
@@ -73,7 +73,7 @@ export declare namespace GithubReleaseService {
73
73
  reason?: string;
74
74
  }
75
75
  }
76
- declare module '../core/service.js' {
76
+ declare module '../core/classes/service.js' {
77
77
  interface ServiceMap {
78
78
  githubRelease: GithubReleaseService;
79
79
  }