@williamthorsen/release-kit 11.0.0 → 12.0.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.
Files changed (69) hide show
  1. package/.meta/changelog.json +62 -2
  2. package/.readyup/kits/default.js +1 -17
  3. package/.readyup/manifest.json +4 -4
  4. package/CHANGELOG.md +40 -0
  5. package/README.md +25 -44
  6. package/dist/esm/attachChangelogDiagnostics.d.ts +4 -0
  7. package/dist/esm/attachChangelogDiagnostics.js +11 -0
  8. package/dist/esm/bin/release-kit.js +4 -3
  9. package/dist/esm/buildChangelogEntries.d.ts +59 -17
  10. package/dist/esm/buildChangelogEntries.js +194 -111
  11. package/dist/esm/buildReleaseSummary.js +2 -2
  12. package/dist/esm/changelogOverrides.d.ts +31 -13
  13. package/dist/esm/changelogOverrides.js +138 -58
  14. package/dist/esm/classifyChangelogCommit.d.ts +38 -0
  15. package/dist/esm/classifyChangelogCommit.js +33 -0
  16. package/dist/esm/decideRelease.d.ts +11 -37
  17. package/dist/esm/decideRelease.js +5 -27
  18. package/dist/esm/defaults.d.ts +1 -1
  19. package/dist/esm/defaults.js +3 -0
  20. package/dist/esm/determineBumpType.d.ts +11 -11
  21. package/dist/esm/determineBumpType.js +6 -7
  22. package/dist/esm/enumerateReleaseWindows.d.ts +50 -0
  23. package/dist/esm/enumerateReleaseWindows.js +124 -0
  24. package/dist/esm/generateChangelogs.d.ts +5 -23
  25. package/dist/esm/generateChangelogs.js +0 -14
  26. package/dist/esm/glyphs.d.ts +1 -1
  27. package/dist/esm/help/prepareHelp.d.ts +1 -1
  28. package/dist/esm/help/prepareHelp.js +4 -6
  29. package/dist/esm/index.d.ts +1 -1
  30. package/dist/esm/init/initCommand.js +1 -1
  31. package/dist/esm/init/scaffold.d.ts +0 -2
  32. package/dist/esm/init/scaffold.js +2 -30
  33. package/dist/esm/loadConfig.d.ts +1 -1
  34. package/dist/esm/loadConfig.js +1 -4
  35. package/dist/esm/parseChangeRecordBlock.d.ts +37 -0
  36. package/dist/esm/parseChangeRecordBlock.js +134 -0
  37. package/dist/esm/parseCommitMessage.d.ts +31 -6
  38. package/dist/esm/parseCommitMessage.js +6 -5
  39. package/dist/esm/prepareCommand.js +4 -12
  40. package/dist/esm/releasePrepare.d.ts +7 -8
  41. package/dist/esm/releasePrepare.js +53 -73
  42. package/dist/esm/releasePrepareMono.js +48 -85
  43. package/dist/esm/releasePrepareProject.d.ts +1 -1
  44. package/dist/esm/releasePrepareProject.js +19 -37
  45. package/dist/esm/reportPrepare.js +39 -28
  46. package/dist/esm/types.d.ts +99 -55
  47. package/dist/esm/types.js +1 -1
  48. package/dist/esm/validateConfig.js +4 -0
  49. package/dist/esm/validateOverridesCommand.d.ts +6 -5
  50. package/dist/esm/validateOverridesCommand.js +16 -23
  51. package/docs/changelogs.md +64 -25
  52. package/docs/configuration.md +2 -3
  53. package/docs/editorial-overrides.md +75 -16
  54. package/docs/project-releases.md +4 -4
  55. package/docs/releasing.md +13 -8
  56. package/docs/work-types.md +13 -9
  57. package/package.json +1 -2
  58. package/schemas/changelog-overrides.json +39 -0
  59. package/cliff.toml.template +0 -65
  60. package/dist/esm/collectPolicyViolations.d.ts +0 -15
  61. package/dist/esm/collectPolicyViolations.js +0 -12
  62. package/dist/esm/determineBumpFromCommits.d.ts +0 -17
  63. package/dist/esm/determineBumpFromCommits.js +0 -28
  64. package/dist/esm/getCommitsSinceTarget.d.ts +0 -21
  65. package/dist/esm/getCommitsSinceTarget.js +0 -66
  66. package/dist/esm/resolveCliffConfigPath.d.ts +0 -11
  67. package/dist/esm/resolveCliffConfigPath.js +0 -20
  68. package/dist/esm/runGitCliff.d.ts +0 -38
  69. package/dist/esm/runGitCliff.js +0 -25
@@ -6,7 +6,9 @@ import { isRecord } from "./typeGuards.js";
6
6
  const OVERRIDES_FILENAME = '.meta/changelog-overrides.json';
7
7
  const VALID_AUDIENCE_VALUES = new Set(['all', 'dev', 'skip']);
8
8
  const V1_SUPPORTED_AUDIENCE_VALUES = new Set(['skip']);
9
- const KNOWN_OVERRIDE_FIELDS = new Set(['audience', 'description', 'body', 'breaking']);
9
+ export const KNOWN_OVERRIDE_FIELDS = new Set(['audience', 'description', 'body', 'breaking']);
10
+ export const OVERRIDE_KEY_PATTERN = /^[0-9a-f]+(:[1-9][0-9]*)?$/;
11
+ const SCHEMA_KEY = '$schema';
10
12
  export function loadChangelogOverrides(path) {
11
13
  if (!existsSync(path)) {
12
14
  return { overrides: new Map() };
@@ -43,8 +45,14 @@ export function validateChangelogOverrides(raw) {
43
45
  return { overrides, errors };
44
46
  }
45
47
  for (const [key, rawEntry] of Object.entries(raw)) {
46
- if (key === '') {
47
- errors.push('Override file: empty-string key is not a valid commit hash');
48
+ if (key === SCHEMA_KEY) {
49
+ if (typeof rawEntry !== 'string') {
50
+ errors.push(`Override file: '${SCHEMA_KEY}' must be a string`);
51
+ }
52
+ continue;
53
+ }
54
+ if (!OVERRIDE_KEY_PATTERN.test(key)) {
55
+ errors.push(`overrides['${key}']: key must be a lowercase hex commit hash or prefix, optionally followed by ':<n>'`);
48
56
  continue;
49
57
  }
50
58
  const validated = validateSingleOverride(key, rawEntry, errors);
@@ -54,6 +62,13 @@ export function validateChangelogOverrides(raw) {
54
62
  }
55
63
  return { overrides, errors };
56
64
  }
65
+ export function parseOverrideKey(key) {
66
+ const separatorIndex = key.indexOf(':');
67
+ if (separatorIndex === -1) {
68
+ return { hashPrefix: key };
69
+ }
70
+ return { hashPrefix: key.slice(0, separatorIndex), entry: Number(key.slice(separatorIndex + 1)) };
71
+ }
57
72
  function validateSingleOverride(key, rawEntry, errors) {
58
73
  if (!isRecord(rawEntry)) {
59
74
  errors.push(`overrides['${key}']: must be an object`);
@@ -125,53 +140,27 @@ function validateAudience(key, value, errors) {
125
140
  return 'skip';
126
141
  }
127
142
  export function formatStaleOverrideKeyWarning(key) {
128
- return `Override key '${key}' did not match any commit hash in the changelog (likely a stale reference)`;
143
+ return `Override key '${key}' did not match any item in the changelog (likely a stale reference)`;
129
144
  }
130
145
  export function applyChangelogOverrides(entries, overrides) {
131
146
  const warnings = [];
132
- const errors = [];
133
- const matchedKeys = [];
134
147
  if (overrides.size === 0) {
135
- return { entries: entries.map(cloneEntry), warnings, errors, matchedKeys };
136
- }
137
- const allHashes = [];
138
- for (const entry of entries) {
139
- for (const section of entry.sections) {
140
- for (const item of section.items) {
141
- if (item.hash !== undefined) {
142
- allHashes.push(item.hash);
143
- }
144
- }
145
- }
148
+ return { entries: entries.map(cloneEntry), warnings, errors: [], matchedKeys: [] };
146
149
  }
147
- const keyToMatchedHashes = new Map();
148
- for (const overrideKey of overrides.keys()) {
149
- const matches = allHashes.filter((hash) => hash.startsWith(overrideKey));
150
- if (matches.length === 0) {
151
- continue;
152
- }
153
- if (matches.length > 1) {
154
- errors.push(`Override key '${overrideKey}' is ambiguous: matches multiple commits (${matches.join(', ')}). ` +
155
- 'Use a longer prefix or the full commit hash.');
156
- continue;
157
- }
158
- keyToMatchedHashes.set(overrideKey, matches);
159
- matchedKeys.push(overrideKey);
160
- }
161
- const hashToOverride = new Map();
162
- for (const [overrideKey, matchedHashes] of keyToMatchedHashes) {
150
+ const { errors, itemIdToKey } = resolveOverrideKeys(indexEntryPositions(entries), overrides);
151
+ const itemIdToOverride = new Map();
152
+ for (const [itemId, overrideKey] of itemIdToKey) {
163
153
  const override = overrides.get(overrideKey);
164
- if (override === undefined)
165
- continue;
166
- for (const hash of matchedHashes) {
167
- hashToOverride.set(hash, override);
154
+ if (override !== undefined) {
155
+ itemIdToOverride.set(itemId, override);
168
156
  }
169
157
  }
158
+ const matchedKeys = [...new Set(itemIdToKey.values())];
170
159
  const transformedEntries = [];
171
160
  for (const entry of entries) {
172
161
  const transformedSections = [];
173
162
  for (const section of entry.sections) {
174
- const transformedItems = applyOverridesToItems(section.items, hashToOverride);
163
+ const transformedItems = applyOverridesToItems(section.items, itemIdToOverride);
175
164
  if (transformedItems.length === 0) {
176
165
  continue;
177
166
  }
@@ -181,14 +170,93 @@ export function applyChangelogOverrides(entries, overrides) {
181
170
  }
182
171
  return { entries: transformedEntries, warnings, errors, matchedKeys };
183
172
  }
184
- function applyOverridesToItems(items, hashToOverride) {
173
+ function resolveOverrideKeys(positionsByHash, overrides) {
174
+ const errors = [];
175
+ const failedKeys = new Set();
176
+ const itemIdToKey = new Map();
177
+ for (const [overrideKey, override] of overrides) {
178
+ const resolution = resolveOverrideKey(overrideKey, positionsByHash);
179
+ if (resolution.kind === 'ambiguous') {
180
+ errors.push(`Override key '${overrideKey}' is ambiguous: matches multiple commits (${resolution.hashes.join(', ')}). ` +
181
+ 'Use a longer prefix or the full commit hash.');
182
+ continue;
183
+ }
184
+ if (resolution.kind === 'none') {
185
+ continue;
186
+ }
187
+ const { hash, positions } = resolution;
188
+ if (parseOverrideKey(overrideKey).entry === undefined && positions.length > 1) {
189
+ const fieldsSet = ['description', 'body'].filter((field) => override[field] !== undefined);
190
+ if (fieldsSet.length > 0) {
191
+ const ordinalKeys = positions.map((position) => `${overrideKey}:${position}`).join(', ');
192
+ errors.push(`Override key '${overrideKey}' sets ${fieldsSet.join(' and ')} on a commit with several items; use ${ordinalKeys}`);
193
+ continue;
194
+ }
195
+ }
196
+ for (const position of positions) {
197
+ const itemId = formatItemId(hash, position);
198
+ const claimingKey = itemIdToKey.get(itemId);
199
+ if (claimingKey === undefined) {
200
+ itemIdToKey.set(itemId, overrideKey);
201
+ continue;
202
+ }
203
+ const target = position === undefined ? `commit ${hash}` : `the item at ${hash}:${position}`;
204
+ errors.push(`Override keys '${claimingKey}' and '${overrideKey}' both match ${target}; keep one`);
205
+ failedKeys.add(claimingKey);
206
+ failedKeys.add(overrideKey);
207
+ }
208
+ }
209
+ for (const [itemId, overrideKey] of itemIdToKey) {
210
+ if (failedKeys.has(overrideKey)) {
211
+ itemIdToKey.delete(itemId);
212
+ }
213
+ }
214
+ return { errors, itemIdToKey };
215
+ }
216
+ function resolveOverrideKey(overrideKey, positionsByHash) {
217
+ const { hashPrefix, entry } = parseOverrideKey(overrideKey);
218
+ const hashes = positionsByHash
219
+ .keys()
220
+ .filter((hash) => hash.startsWith(hashPrefix))
221
+ .toArray();
222
+ const [hash] = hashes;
223
+ if (hash === undefined) {
224
+ return { kind: 'none' };
225
+ }
226
+ if (hashes.length > 1) {
227
+ return { kind: 'ambiguous', hashes };
228
+ }
229
+ const available = [...(positionsByHash.get(hash) ?? [])];
230
+ const positions = entry === undefined
231
+ ? available.toSorted((left, right) => (left ?? 0) - (right ?? 0))
232
+ : available.filter((position) => position === entry);
233
+ return positions.length === 0 ? { kind: 'none' } : { kind: 'items', hash, positions };
234
+ }
235
+ function indexEntryPositions(entries) {
236
+ return indexItemPositions(entries.flatMap((entry) => entry.sections.flatMap((section) => section.items)));
237
+ }
238
+ function indexItemPositions(items) {
239
+ const positionsByHash = new Map();
240
+ for (const item of items) {
241
+ if (item.hash === undefined)
242
+ continue;
243
+ const positions = positionsByHash.get(item.hash) ?? new Set();
244
+ positions.add(item.entry);
245
+ positionsByHash.set(item.hash, positions);
246
+ }
247
+ return positionsByHash;
248
+ }
249
+ function formatItemId(hash, entry) {
250
+ return entry === undefined ? hash : `${hash}:${entry}`;
251
+ }
252
+ function applyOverridesToItems(items, itemIdToOverride) {
185
253
  const result = [];
186
254
  for (const item of items) {
187
255
  if (item.hash === undefined) {
188
256
  result.push(cloneItem(item));
189
257
  continue;
190
258
  }
191
- const override = hashToOverride.get(item.hash);
259
+ const override = itemIdToOverride.get(formatItemId(item.hash, item.entry));
192
260
  if (override === undefined) {
193
261
  result.push(cloneItem(item));
194
262
  continue;
@@ -207,6 +275,8 @@ function applyOverrideToItem(item, override) {
207
275
  }
208
276
  if (override.body !== undefined) {
209
277
  result.body = override.body;
278
+ }
279
+ if (override.body !== undefined && item.entry === undefined) {
210
280
  const migration = extractMigration(override.body);
211
281
  if (migration === undefined) {
212
282
  delete result.migration;
@@ -288,7 +358,7 @@ export function validateAllChangelogOverrides(inputs) {
288
358
  const projectMap = loadScopeMap(projectFilePath, errors);
289
359
  const workspaceMaps = (inputs.workspaces ?? []).map((scope) => ({
290
360
  filePath: scope.filePath,
291
- hashes: scope.hashes,
361
+ items: scope.items,
292
362
  map: loadScopeMap(scope.filePath, errors),
293
363
  }));
294
364
  const globalMatchedRootKeys = new Set();
@@ -302,9 +372,9 @@ export function validateAllChangelogOverrides(inputs) {
302
372
  globalMatchedRootKeys,
303
373
  });
304
374
  }
305
- const projectHashes = inputs.project?.hashes;
306
- if (projectFilePath !== undefined && projectHashes !== undefined) {
307
- processProjectScope({ projectFilePath, projectMap, projectHashes, errors, globalMatchedRootKeys });
375
+ const projectItems = inputs.project?.items;
376
+ if (projectFilePath !== undefined && projectItems !== undefined) {
377
+ processProjectScope({ projectFilePath, projectMap, projectItems, errors, globalMatchedRootKeys });
308
378
  }
309
379
  if (projectFilePath !== undefined) {
310
380
  collectRootStaleWarnings(projectFilePath, projectMap, globalMatchedRootKeys, warnings);
@@ -313,39 +383,49 @@ export function validateAllChangelogOverrides(inputs) {
313
383
  }
314
384
  function processWorkspaceScope(args) {
315
385
  const { workspace, projectFilePath, projectMap, errors, warnings, globalMatchedRootKeys } = args;
316
- const { filePath, hashes, map } = workspace;
317
- const workspaceApplied = applyChangelogOverrides(makeValidationEntries(hashes), map);
386
+ const { filePath, items, map } = workspace;
387
+ const validationEntries = makeValidationEntries(items);
388
+ const positionsByHash = indexItemPositions(items);
389
+ const workspaceApplied = applyChangelogOverrides(validationEntries, map);
318
390
  for (const message of workspaceApplied.errors) {
319
391
  errors.push(prefixWithFilePath(filePath, message));
320
392
  }
321
393
  if (projectFilePath !== undefined && projectMap.size > 0) {
322
394
  const projectMinusShadowed = filterShadowedKeys(projectMap, map);
323
- const projectApplied = applyChangelogOverrides(makeValidationEntries(hashes), projectMinusShadowed);
395
+ const projectApplied = applyChangelogOverrides(validationEntries, projectMinusShadowed);
324
396
  for (const message of projectApplied.errors) {
325
397
  errors.push(prefixWithFilePath(projectFilePath, message));
326
398
  }
399
+ const perFileErrors = new Set([...workspaceApplied.errors, ...projectApplied.errors]);
400
+ const composedApplied = applyChangelogOverrides(validationEntries, composeOverrides(projectMap, map));
401
+ for (const message of composedApplied.errors) {
402
+ if (!perFileErrors.has(message)) {
403
+ errors.push(prefixWithFilePath(filePath, message));
404
+ }
405
+ }
327
406
  }
328
407
  for (const key of map.keys()) {
329
- if (!hasAnyMatch(key, hashes)) {
408
+ if (!hasAnyMatch(key, positionsByHash)) {
330
409
  warnings.push(formatWorkspaceStaleWarning(filePath, key));
331
410
  }
332
411
  }
333
412
  for (const key of projectMap.keys()) {
334
413
  if (map.has(key))
335
414
  continue;
336
- if (hasAnyMatch(key, hashes)) {
415
+ if (hasAnyMatch(key, positionsByHash)) {
337
416
  globalMatchedRootKeys.add(key);
338
417
  }
339
418
  }
340
419
  }
341
420
  function processProjectScope(args) {
342
- const { projectFilePath, projectMap, projectHashes, errors, globalMatchedRootKeys } = args;
343
- const applied = applyChangelogOverrides(makeValidationEntries(projectHashes), projectMap);
421
+ const { projectFilePath, projectMap, projectItems, errors, globalMatchedRootKeys } = args;
422
+ const applied = applyChangelogOverrides(makeValidationEntries(projectItems), projectMap);
344
423
  for (const message of applied.errors) {
345
424
  errors.push(prefixWithFilePath(projectFilePath, message));
346
425
  }
426
+ const positionsByHash = indexItemPositions(projectItems);
347
427
  for (const key of projectMap.keys()) {
348
- if (hasAnyMatch(key, projectHashes)) {
428
+ if (hasAnyMatch(key, positionsByHash)) {
349
429
  globalMatchedRootKeys.add(key);
350
430
  }
351
431
  }
@@ -357,8 +437,8 @@ function collectRootStaleWarnings(projectFilePath, projectMap, globalMatchedRoot
357
437
  }
358
438
  }
359
439
  }
360
- function hasAnyMatch(key, hashes) {
361
- return hashes.some((hash) => hash.startsWith(key));
440
+ function hasAnyMatch(key, positionsByHash) {
441
+ return resolveOverrideKey(key, positionsByHash).kind !== 'none';
362
442
  }
363
443
  function filterShadowedKeys(projectMap, workspaceMap) {
364
444
  const result = new Map();
@@ -382,7 +462,7 @@ function loadScopeMap(filePath, errors) {
382
462
  }
383
463
  return result.overrides;
384
464
  }
385
- function makeValidationEntries(hashes) {
465
+ function makeValidationEntries(items) {
386
466
  return [
387
467
  {
388
468
  version: '0.0.0',
@@ -391,7 +471,7 @@ function makeValidationEntries(hashes) {
391
471
  {
392
472
  title: 'Validation',
393
473
  audience: 'all',
394
- items: hashes.map((hash) => ({ description: '', hash })),
474
+ items: items.map((item) => ({ ...item, description: '' })),
395
475
  },
396
476
  ],
397
477
  },
@@ -401,10 +481,10 @@ function prefixWithFilePath(filePath, message) {
401
481
  return `${filePath}: ${message}`;
402
482
  }
403
483
  function formatWorkspaceStaleWarning(filePath, key) {
404
- return `${filePath}: Override key '${key}' did not match any commit in this workspace's history (likely a stale reference)`;
484
+ return `${filePath}: Override key '${key}' did not match any item in this workspace's history (likely a stale reference)`;
405
485
  }
406
486
  function formatRootStaleWarning(filePath, key) {
407
- return `${filePath}: Override key '${key}' did not match any commit in any scope (likely a stale reference)`;
487
+ return `${filePath}: Override key '${key}' did not match any item in any scope (likely a stale reference)`;
408
488
  }
409
489
  export function applyWorkspaceOverrides(newEntries, workspacePath, overrideContext) {
410
490
  const { project, perWorkspace, overrideWarnings, globalMatchedRootKeys } = overrideContext;
@@ -0,0 +1,38 @@
1
+ import { type ParseCommitMessageOptions } from './parseCommitMessage.js';
2
+ import type { Commit, WorkTypeConfig } from './types.js';
3
+ /**
4
+ * How a commit's title places it in a changelog: under a section header, excluded by design, or unreadable.
5
+ *
6
+ * `breaking` is the parser's flag under the breaking policies passed in, which a `BREAKING CHANGE:` footer can set.
7
+ */
8
+ export type ChangelogClassification = {
9
+ kind: 'header';
10
+ header: string;
11
+ type: string;
12
+ breaking: boolean;
13
+ } | {
14
+ kind: 'excluded';
15
+ } | {
16
+ kind: 'unparseable';
17
+ };
18
+ /**
19
+ * Classifies a commit by its title.
20
+ *
21
+ * Four gates decide, in order: a `release:` or `Merge` subject is excluded; a subject carrying no ticket-ID prefix is
22
+ * unparseable; a type that `parseCommitMessage` cannot resolve against `workTypes` is unparseable; and a type that
23
+ * `workTypes` excludes from the changelog is excluded. A commit that passes all four takes the header its type declares.
24
+ *
25
+ * `options` reaches the parse, so a policy violation is reported for every ticketed commit whose type resolves,
26
+ * excluded types included.
27
+ *
28
+ * The header carries no order encoding. `buildChangelogEntries` sorts sections by canonical priority, so
29
+ * the position a section occupies comes from the taxonomy rather than from the header string.
30
+ */
31
+ export declare function classifyChangelogCommit(commit: Pick<Commit, 'hash' | 'message'>, workTypes: Record<string, WorkTypeConfig>, options?: ParseCommitMessageOptions): ChangelogClassification;
32
+ /**
33
+ * Reports whether a subject belongs to a commit that never reaches a changelog: a `release:` commit, which records
34
+ * version bumps, or a git merge commit, whose content the merged commits already report.
35
+ */
36
+ export declare function isNonChangeSubject(subject: string): boolean;
37
+ /** Reports whether a subject belongs to a `release:` commit, which records version bumps rather than a change. */
38
+ export declare function isReleaseSubject(subject: string): boolean;
@@ -0,0 +1,33 @@
1
+ import { COMMIT_PREPROCESSOR_PATTERNS, parseCommitMessage, } from "./parseCommitMessage.js";
2
+ const RELEASE_SUBJECT_PATTERN = /^release:/;
3
+ const MERGE_SUBJECT_PATTERN = /^Merge/;
4
+ export function classifyChangelogCommit(commit, workTypes, options) {
5
+ const subject = commit.message.split('\n', 1)[0] ?? '';
6
+ if (isNonChangeSubject(subject)) {
7
+ return { kind: 'excluded' };
8
+ }
9
+ if (!hasTicketPrefix(subject)) {
10
+ return { kind: 'unparseable' };
11
+ }
12
+ const parsed = parseCommitMessage(commit.message, commit.hash, workTypes, undefined, options);
13
+ if (parsed === undefined) {
14
+ return { kind: 'unparseable' };
15
+ }
16
+ const config = workTypes[parsed.type];
17
+ if (config === undefined) {
18
+ return { kind: 'unparseable' };
19
+ }
20
+ if (config.excludedFromChangelog === true) {
21
+ return { kind: 'excluded' };
22
+ }
23
+ return { kind: 'header', header: config.header, type: parsed.type, breaking: parsed.breaking };
24
+ }
25
+ export function isNonChangeSubject(subject) {
26
+ return isReleaseSubject(subject) || MERGE_SUBJECT_PATTERN.test(subject);
27
+ }
28
+ export function isReleaseSubject(subject) {
29
+ return RELEASE_SUBJECT_PATTERN.test(subject);
30
+ }
31
+ function hasTicketPrefix(subject) {
32
+ return COMMIT_PREPROCESSOR_PATTERNS.some((pattern) => pattern.test(subject));
33
+ }
@@ -1,23 +1,17 @@
1
- import { type PolicyViolationHandler } from './parseCommitMessage.js';
2
- import type { Commit, ReleaseType, VersionPatterns, WorkTypeConfig } from './types.js';
3
- /** Inputs to the unified release decision used by both pipelines. */
1
+ import type { ReleaseType } from './types.js';
2
+ /** Inputs to the release decision that every release path makes. */
4
3
  export interface DecideReleaseArgs {
5
- commits: readonly Commit[];
4
+ /** The bump that the window's changelog items call for; undefined when they call for none. */
5
+ naturalBump: ReleaseType | undefined;
6
+ /** The number of commits in the window, which selects the skip reason. */
7
+ commitCount: number;
6
8
  /** True when `--force` was passed; treats absence of a natural bump as patch. Defaults to false. */
7
9
  force?: boolean | undefined;
8
10
  /** Explicit `--bump=X` override. When set, the level is X regardless of the natural bump. */
9
11
  bumpOverride: ReleaseType | undefined;
10
- workTypes: Record<string, WorkTypeConfig>;
11
- versionPatterns: VersionPatterns;
12
- scopeAliases: Record<string, string> | undefined;
13
- /** Per-canonical-type breaking-policy lookup. When provided, drives `!` policy enforcement in `parseCommitMessage`. */
14
- breakingPolicies?: Record<string, 'forbidden' | 'optional' | 'required'>;
15
- /** Receives policy-violation notifications from `parseCommitMessage`. */
16
- onPolicyViolation?: PolicyViolationHandler;
17
12
  /**
18
- * Skip wordings to use when no commits exist (`noCommits`) or when commits exist but
19
- * none parse to a bump-worthy work type (`noBumpWorthy`). Both pipelines pass their
20
- * own pre-rendered wordings (with `{name}` / `since` substitutions already applied).
13
+ * Skip wordings to use when no commits exist (`noCommits`) or when commits exist but none yields a changelog item
14
+ * (`noBumpWorthy`). Each path passes its own pre-rendered wordings.
21
15
  */
22
16
  skipReasons: {
23
17
  noCommits: string;
@@ -28,38 +22,18 @@ export interface DecideReleaseArgs {
28
22
  export type DecideReleaseResult = {
29
23
  outcome: 'release';
30
24
  releaseType: ReleaseType;
31
- parsedCommitCount: number;
32
- unparseableCommits: Commit[] | undefined;
33
25
  } | {
34
26
  outcome: 'skip';
35
27
  skipReason: string;
36
- parsedCommitCount: number;
37
- unparseableCommits: Commit[] | undefined;
38
28
  };
39
29
  /**
40
- * Apply the unified release-decision algorithm shared by both `releasePrepareProject`
41
- * and the per-workspace path in `releasePrepareMono`.
30
+ * Decides whether a window releases, and at which level.
42
31
  *
43
32
  * Algorithm:
44
33
  * shouldRelease = naturalBump !== undefined OR force === true
45
34
  * releaseLevel = bumpOverride ?? naturalBump ?? 'patch'
46
35
  *
47
- * "natural bump" is the raw output of `determineBumpType` over the parsed commits — it
48
- * is `undefined` when no commits exist or when none of the parsed commits map to a
49
- * bump-worthy work type. Note that this differs from `determineBumpFromCommits`, which
50
- * applies a patch floor whenever any commits exist; the patch floor is intentionally
51
- * not applied here so that `--bump=X` and `--force` remain orthogonal (the floor is
52
- * applied only when `--force` is set, by virtue of the `naturalBump ?? 'patch'`
53
- * fallback in the release-level expression above).
54
- *
55
- * `parsedCommitCount` and `unparseableCommits` are returned in both branches so callers
56
- * can surface diagnostic data uniformly regardless of skip/release outcome.
57
- *
58
- * Scope: this is the canonical algorithm for the orthogonal-flag model in the
59
- * monorepo path (`releasePrepareProject` + per-workspace `determineDirectBumps`). The
60
- * single-package executor (`releasePrepare`) deliberately retains
61
- * `determineBumpFromCommits` and the legacy patch-floor semantics; `prepareCommand`
62
- * rejects `--force` without `--bump` in single-package mode to surface that gap. A
63
- * future ticket can unify the single-package path; see #313's external plan.
36
+ * A window without a natural bump releases only under `--force`, so `--bump=X` chooses a level without triggering a
37
+ * release, and `--force` triggers one without choosing a level.
64
38
  */
65
39
  export declare function decideRelease(args: DecideReleaseArgs): DecideReleaseResult;
@@ -1,30 +1,8 @@
1
- import { determineBumpType } from "./determineBumpType.js";
2
- import { parseCommitMessage, } from "./parseCommitMessage.js";
3
1
  export function decideRelease(args) {
4
- const { commits, force = false, bumpOverride, workTypes, versionPatterns, scopeAliases, breakingPolicies, onPolicyViolation, skipReasons, } = args;
5
- const parseOptions = {
6
- ...(breakingPolicies !== undefined && { breakingPolicies }),
7
- ...(onPolicyViolation !== undefined && { onPolicyViolation }),
8
- };
9
- const parsedCommits = [];
10
- const unparseable = [];
11
- for (const commit of commits) {
12
- const parsed = parseCommitMessage(commit.message, commit.hash, workTypes, scopeAliases, parseOptions);
13
- if (parsed === undefined) {
14
- unparseable.push(commit);
15
- }
16
- else {
17
- parsedCommits.push(parsed);
18
- }
2
+ const { naturalBump, commitCount, force = false, bumpOverride, skipReasons } = args;
3
+ if (naturalBump === undefined && !force) {
4
+ const skipReason = commitCount === 0 ? skipReasons.noCommits : skipReasons.noBumpWorthy;
5
+ return { outcome: 'skip', skipReason };
19
6
  }
20
- const parsedCommitCount = parsedCommits.length;
21
- const unparseableCommits = unparseable.length > 0 ? unparseable : undefined;
22
- const naturalBump = determineBumpType(parsedCommits, workTypes, versionPatterns);
23
- const shouldRelease = naturalBump !== undefined || force;
24
- if (!shouldRelease) {
25
- const skipReason = commits.length === 0 ? skipReasons.noCommits : skipReasons.noBumpWorthy;
26
- return { outcome: 'skip', skipReason, parsedCommitCount, unparseableCommits };
27
- }
28
- const releaseType = bumpOverride ?? naturalBump ?? 'patch';
29
- return { outcome: 'release', releaseType, parsedCommitCount, unparseableCommits };
7
+ return { outcome: 'release', releaseType: bumpOverride ?? naturalBump ?? 'patch' };
30
8
  }
@@ -6,7 +6,7 @@ export { WORK_TYPES_DATA } from './workTypesData.js';
6
6
  * Compose the rendered section heading for a work-type entry as `${emoji} ${label}`.
7
7
  *
8
8
  * Single-sourced here so callers (DEFAULT_WORK_TYPES derivation, devOnlySections
9
- * derivation, cliff-template drift comparison) cannot disagree on the composition rule.
9
+ * derivation) cannot disagree on the composition rule.
10
10
  */
11
11
  export declare function composeHeader(entry: {
12
12
  emoji: string;
@@ -13,6 +13,9 @@ function deriveDefaultWorkTypes() {
13
13
  if (entry.aliases.length > 0) {
14
14
  config.aliases = [...entry.aliases];
15
15
  }
16
+ if (entry.excludedFromChangelog === true) {
17
+ config.excludedFromChangelog = true;
18
+ }
16
19
  result[entry.key] = config;
17
20
  }
18
21
  return result;
@@ -1,14 +1,14 @@
1
- import type { ParsedCommit, ReleaseType, VersionPatterns, WorkTypeConfig } from './types.js';
1
+ import type { ReleaseType, VersionPatterns, WorkTypeConfig } from './types.js';
2
+ /** The part of a changelog item or parsed commit that decides its bump: its resolved work type and breaking flag. */
3
+ export interface BumpSignal {
4
+ type: string;
5
+ breaking: boolean;
6
+ }
2
7
  /**
3
- * Determines the overall bump type from a set of parsed commits.
8
+ * Determines the overall bump type from a set of changes, returning undefined when none has a known work type.
4
9
  *
5
- * Uses `versionPatterns` to decide which commit types trigger major/minor bumps.
6
- * The `'!'` sentinel in `versionPatterns.major` means "any breaking commit triggers major".
7
- * Any recognized commit type not listed in major or minor patterns defaults to patch.
8
- *
9
- * @param commits - The parsed commits to analyze.
10
- * @param workTypes - The work type configurations to verify recognized types.
11
- * @param versionPatterns - Rules for determining major and minor bumps.
12
- * @returns The determined release type, or undefined if no commits match a known work type.
10
+ * Uses `versionPatterns` to decide which types trigger major/minor bumps.
11
+ * The `'!'` sentinel in `versionPatterns.major` means "any breaking change triggers major".
12
+ * Any recognized type not listed in major or minor patterns defaults to patch.
13
13
  */
14
- export declare function determineBumpType(commits: readonly ParsedCommit[], workTypes: Record<string, WorkTypeConfig>, versionPatterns: VersionPatterns): ReleaseType | undefined;
14
+ export declare function determineBumpType(changes: readonly BumpSignal[], workTypes: Record<string, WorkTypeConfig>, versionPatterns: VersionPatterns): ReleaseType | undefined;
@@ -3,23 +3,22 @@ const RELEASE_PRIORITY = {
3
3
  minor: 2,
4
4
  patch: 1,
5
5
  };
6
- export function determineBumpType(commits, workTypes, versionPatterns) {
6
+ export function determineBumpType(changes, workTypes, versionPatterns) {
7
7
  const knownTypes = new Set(Object.keys(workTypes));
8
8
  let highestPriority = 0;
9
9
  let result;
10
- for (const commit of commits) {
11
- if (commit.breaking && versionPatterns.major.includes('!')) {
10
+ for (const change of changes) {
11
+ if (change.breaking && versionPatterns.major.includes('!')) {
12
12
  return 'major';
13
13
  }
14
- const commitType = commit.type;
15
- if (!knownTypes.has(commitType)) {
14
+ if (!knownTypes.has(change.type)) {
16
15
  continue;
17
16
  }
18
17
  let bump;
19
- if (versionPatterns.major.includes(commitType)) {
18
+ if (versionPatterns.major.includes(change.type)) {
20
19
  bump = 'major';
21
20
  }
22
- else if (versionPatterns.minor.includes(commitType)) {
21
+ else if (versionPatterns.minor.includes(change.type)) {
23
22
  bump = 'minor';
24
23
  }
25
24
  else {
@@ -0,0 +1,50 @@
1
+ /** A commit as git records it, before any parsing or filtering. */
2
+ export interface RawCommit {
3
+ /** The full 40-character hash, which `changelogOverrides` keys on. */
4
+ hash: string;
5
+ /** The first line, with a wrapped subject folded onto one line by git. */
6
+ subject: string;
7
+ /** The message after its first blank line, empty when the commit carries no body. */
8
+ body: string;
9
+ /** The raw commit message, subject and body together. */
10
+ message: string;
11
+ }
12
+ /** The commits one release contains, with the version and date under which they shipped. */
13
+ export interface ReleaseWindow {
14
+ /** The tag name for a released window, the caller's unreleased-tag name for the newest one. */
15
+ version: string;
16
+ /** Unix seconds: the tag's creation date for a released window, the clock for the unreleased one. */
17
+ timestamp: number;
18
+ /** Oldest first. */
19
+ commits: RawCommit[];
20
+ }
21
+ /** Inputs for {@link enumerateReleaseWindows}. */
22
+ export interface EnumerateReleaseWindowsOptions {
23
+ /** Git pathspecs restricting the enumeration to commits that touch them. */
24
+ paths?: readonly string[];
25
+ /** Tag prefixes to match as a union; a matching tag name continues with a digit. */
26
+ tagPrefixes: readonly string[];
27
+ /** The version that the unreleased window reports, such as the tag that the release being prepared will write. */
28
+ unreleasedTag: string;
29
+ /** Returns the current time in epoch milliseconds; dates the unreleased window. */
30
+ now?: () => number;
31
+ }
32
+ /**
33
+ * Splits the history reachable from HEAD into release windows, newest first.
34
+ *
35
+ * The newest window is always the unreleased one, empty when HEAD sits on a tag. Released
36
+ * windows follow in descending order, one per reachable tag matching a prefix. A tag that
37
+ * HEAD cannot reach is dropped.
38
+ *
39
+ * A commit belongs to the oldest matching tag that contains it, and to the unreleased window
40
+ * when no matching tag does. Containment is read from the ancestry graph rather than from a
41
+ * position in a linear walk, because a branch commit authored before a tag and merged after it
42
+ * is not in that tag's release.
43
+ *
44
+ * Three git invocations serve any number of tags: `git rev-list --topo-order --parents` for the
45
+ * ancestry graph, `git for-each-ref` for the tag names with their creation dates and target
46
+ * commits, and one path-filtered `git log` for the commits themselves.
47
+ *
48
+ * @throws If `tagPrefixes` is empty, or if any git invocation fails.
49
+ */
50
+ export declare function enumerateReleaseWindows(options: EnumerateReleaseWindowsOptions): ReleaseWindow[];