@williamthorsen/release-kit 10.9.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 (97) hide show
  1. package/.meta/changelog.json +119 -2
  2. package/.readyup/kits/default.js +1 -17
  3. package/.readyup/kits/npm-auto-publish.js +42 -22
  4. package/.readyup/manifest.json +7 -7
  5. package/CHANGELOG.md +88 -0
  6. package/README.md +32 -32
  7. package/dist/esm/attachChangelogDiagnostics.d.ts +4 -0
  8. package/dist/esm/attachChangelogDiagnostics.js +11 -0
  9. package/dist/esm/bin/release-kit.js +34 -25
  10. package/dist/esm/buildChangelogEntries.d.ts +59 -17
  11. package/dist/esm/buildChangelogEntries.js +195 -111
  12. package/dist/esm/buildReleaseSummary.js +2 -2
  13. package/dist/esm/changelogOverrides.d.ts +31 -13
  14. package/dist/esm/changelogOverrides.js +138 -58
  15. package/dist/esm/classifyChangelogCommit.d.ts +38 -0
  16. package/dist/esm/classifyChangelogCommit.js +33 -0
  17. package/dist/esm/configFlagSchema.d.ts +12 -0
  18. package/dist/esm/configFlagSchema.js +3 -0
  19. package/dist/esm/createGithubReleaseCommand.js +6 -2
  20. package/dist/esm/decideRelease.d.ts +11 -37
  21. package/dist/esm/decideRelease.js +5 -27
  22. package/dist/esm/defaults.d.ts +1 -1
  23. package/dist/esm/defaults.js +3 -0
  24. package/dist/esm/determineBumpType.d.ts +11 -11
  25. package/dist/esm/determineBumpType.js +6 -7
  26. package/dist/esm/discoverWorkspaces.d.ts +53 -8
  27. package/dist/esm/discoverWorkspaces.js +57 -37
  28. package/dist/esm/enumerateReleaseWindows.d.ts +50 -0
  29. package/dist/esm/enumerateReleaseWindows.js +124 -0
  30. package/dist/esm/generateChangelogs.d.ts +5 -23
  31. package/dist/esm/generateChangelogs.js +0 -14
  32. package/dist/esm/glyphs.d.ts +1 -1
  33. package/dist/esm/help/prepareHelp.d.ts +1 -1
  34. package/dist/esm/help/prepareHelp.js +5 -6
  35. package/dist/esm/index.d.ts +1 -1
  36. package/dist/esm/init/initCommand.js +1 -1
  37. package/dist/esm/init/scaffold.d.ts +0 -2
  38. package/dist/esm/init/scaffold.js +2 -30
  39. package/dist/esm/loadConfig.d.ts +8 -5
  40. package/dist/esm/loadConfig.js +7 -7
  41. package/dist/esm/loadValidatedConfig.d.ts +46 -5
  42. package/dist/esm/loadValidatedConfig.js +32 -15
  43. package/dist/esm/parseChangeRecordBlock.d.ts +37 -0
  44. package/dist/esm/parseChangeRecordBlock.js +134 -0
  45. package/dist/esm/parseCommitMessage.d.ts +31 -6
  46. package/dist/esm/parseCommitMessage.js +6 -5
  47. package/dist/esm/prepareCommand.d.ts +5 -0
  48. package/dist/esm/prepareCommand.js +30 -47
  49. package/dist/esm/previewTagPrefixes.d.ts +9 -6
  50. package/dist/esm/previewTagPrefixes.js +12 -24
  51. package/dist/esm/publishCommand.d.ts +2 -2
  52. package/dist/esm/publishCommand.js +8 -2
  53. package/dist/esm/pushCommand.d.ts +1 -1
  54. package/dist/esm/pushCommand.js +2 -2
  55. package/dist/esm/releasePrepare.d.ts +7 -8
  56. package/dist/esm/releasePrepare.js +53 -73
  57. package/dist/esm/releasePrepareMono.js +48 -85
  58. package/dist/esm/releasePrepareProject.d.ts +1 -1
  59. package/dist/esm/releasePrepareProject.js +19 -37
  60. package/dist/esm/reportPrepare.js +45 -30
  61. package/dist/esm/resolveCommandTags.d.ts +3 -2
  62. package/dist/esm/resolveCommandTags.js +10 -6
  63. package/dist/esm/resolveReleaseNotesConfig.d.ts +9 -11
  64. package/dist/esm/resolveReleaseNotesConfig.js +9 -28
  65. package/dist/esm/showTagPrefixesCommand.d.ts +4 -2
  66. package/dist/esm/showTagPrefixesCommand.js +17 -3
  67. package/dist/esm/sync-labels/generateCommand.d.ts +16 -8
  68. package/dist/esm/sync-labels/generateCommand.js +12 -11
  69. package/dist/esm/sync-labels/initCommand.d.ts +9 -4
  70. package/dist/esm/sync-labels/initCommand.js +21 -15
  71. package/dist/esm/sync-labels/retiredConfig.d.ts +4 -2
  72. package/dist/esm/sync-labels/retiredConfig.js +2 -2
  73. package/dist/esm/types.d.ts +99 -55
  74. package/dist/esm/types.js +1 -1
  75. package/dist/esm/validateConfig.js +4 -0
  76. package/dist/esm/validateOverridesCommand.d.ts +16 -9
  77. package/dist/esm/validateOverridesCommand.js +39 -54
  78. package/docs/changelogs.md +64 -25
  79. package/docs/configuration.md +14 -3
  80. package/docs/editorial-overrides.md +75 -16
  81. package/docs/labels.md +2 -0
  82. package/docs/project-releases.md +4 -4
  83. package/docs/releasing.md +13 -8
  84. package/docs/work-types.md +13 -9
  85. package/package.json +5 -7
  86. package/schemas/changelog-overrides.json +39 -0
  87. package/cliff.toml.template +0 -65
  88. package/dist/esm/collectPolicyViolations.d.ts +0 -15
  89. package/dist/esm/collectPolicyViolations.js +0 -12
  90. package/dist/esm/determineBumpFromCommits.d.ts +0 -17
  91. package/dist/esm/determineBumpFromCommits.js +0 -28
  92. package/dist/esm/getCommitsSinceTarget.d.ts +0 -21
  93. package/dist/esm/getCommitsSinceTarget.js +0 -66
  94. package/dist/esm/resolveCliffConfigPath.d.ts +0 -11
  95. package/dist/esm/resolveCliffConfigPath.js +0 -20
  96. package/dist/esm/runGitCliff.d.ts +0 -38
  97. package/dist/esm/runGitCliff.js +0 -25
@@ -1,4 +1,8 @@
1
1
  import type { ChangelogEntry, ChangelogOverride, WorkspaceConfig } from './types.js';
2
+ /** Known fields on a single override entry; presence of any other field is a validation error. */
3
+ export declare const KNOWN_OVERRIDE_FIELDS: Set<string>;
4
+ /** Form of an override key: a lowercase hex commit hash or prefix, optionally followed by `:<n>`, a 1-based entry position. */
5
+ export declare const OVERRIDE_KEY_PATTERN: RegExp;
2
6
  /** Result of loading an override file: either parsed overrides or a list of structured errors. */
3
7
  export type LoadChangelogOverridesResult = {
4
8
  overrides: Map<string, ChangelogOverride>;
@@ -26,6 +30,11 @@ export declare function validateChangelogOverrides(raw: unknown): {
26
30
  overrides: Map<string, ChangelogOverride>;
27
31
  errors: string[];
28
32
  };
33
+ /** Split a valid override key into its hash prefix and, for an ordinal key, its 1-based entry position. */
34
+ export declare function parseOverrideKey(key: string): {
35
+ hashPrefix: string;
36
+ entry?: number;
37
+ };
29
38
  /**
30
39
  * Format the standard "stale override key" warning. Callers compute the set of stale keys
31
40
  * (keys that didn't match anywhere) and use this helper to surface them uniformly. Centralized
@@ -35,12 +44,16 @@ export declare function formatStaleOverrideKeyWarning(key: string): string;
35
44
  /**
36
45
  * Apply overrides to a `ChangelogEntry[]`, returning a new array. Pure: no mutation, no I/O.
37
46
  *
38
- * Match algorithm: each override key is treated as a string-prefix against `ChangelogItem.hash`.
47
+ * Match algorithm: each key's hash prefix is matched against the distinct `ChangelogItem.hash` values.
39
48
  * - 0 matches → key is recorded as unmatched in this batch (no warning emitted here).
40
- * - 1 match → apply each present override field to the matched item, record key as matched.
49
+ * - 1 match → a bare key resolves to every item of that commit, and an ordinal key `<hash>:<n>` to the items whose
50
+ * `entry` is `n`. A key that resolves to no item is unmatched, so an ordinal key on a title-derived item goes stale.
41
51
  * - 2+ matches → error (ambiguous prefix).
42
52
  *
43
- * `matchedKeys` lists the override keys that resolved to exactly one item in this batch.
53
+ * Two more conditions are errors, and the keys involved apply nowhere: a bare key that sets `description` or `body` on a
54
+ * commit with several items, and two keys that resolve to the same item.
55
+ *
56
+ * `matchedKeys` lists the override keys that resolved to at least one item without error in this batch.
44
57
  * Callers are responsible for computing stale-key warnings: in a monorepo run, an override
45
58
  * may target a commit that lives in another workspace, so the per-batch zero-match signal is
46
59
  * insufficient on its own. Single-package and monorepo orchestrators format warnings using
@@ -98,8 +111,8 @@ export declare function loadOverridesForScopes(scopes: {
98
111
  *
99
112
  * Byte-equal-key shadowing: when a workspace key string-equals a root key, the workspace
100
113
  * entry wins entirely (no field-level merge) and supplants the root entry in the result.
101
- * Different-prefix keys that happen to resolve to the same commit do NOT shadow here — they
102
- * fall through to the existing ambiguous-prefix error in {@link applyChangelogOverrides}.
114
+ * Non-identical keys that resolve to the same item do NOT shadow here: {@link applyChangelogOverrides}
115
+ * reports them as overlapping.
103
116
  *
104
117
  * Pure: never mutates inputs.
105
118
  */
@@ -127,12 +140,17 @@ export interface OverrideContext {
127
140
  * is a bug, and we want the prepare run to fail before any workspace begins writing.
128
141
  */
129
142
  export declare function createOverrideContext(workspaces: WorkspaceConfig[]): OverrideContext;
143
+ /** A changelog item as override matching sees it. `entry` is absent for a title-derived item. */
144
+ export interface OverrideTargetItem {
145
+ hash: string;
146
+ entry?: number;
147
+ }
130
148
  /** Per-scope input to {@link validateAllChangelogOverrides}. */
131
149
  export interface ChangelogOverrideScope {
132
150
  /** Path to the override file (relative to the repo root). Used to load the file and to attribute findings. */
133
151
  filePath: string;
134
- /** Commit hashes in this scope's history window. Each override key is matched against these. */
135
- hashes: readonly string[];
152
+ /** Items in this scope's history window. Each override key is matched against these. */
153
+ items: readonly OverrideTargetItem[];
136
154
  }
137
155
  /** Inputs to {@link validateAllChangelogOverrides}. */
138
156
  export interface ValidateAllChangelogOverridesInputs {
@@ -141,16 +159,16 @@ export interface ValidateAllChangelogOverridesInputs {
141
159
  *
142
160
  * The file at `filePath` is loaded once and used in two ways:
143
161
  * - Its overrides are composed into every workspace's apply (root-tier overrides apply globally).
144
- * - When `hashes` is provided, the project map is also applied directly to that hash universe
162
+ * - When `items` is provided, the project map is also applied directly to those items
145
163
  * (project release in monorepo mode, or the package's history in single-package mode).
146
164
  *
147
165
  * Omit when no project file exists (rare — most repos have a root file even if empty).
148
166
  */
149
167
  project?: {
150
168
  filePath: string;
151
- hashes?: readonly string[];
169
+ items?: readonly OverrideTargetItem[];
152
170
  };
153
- /** Per-workspace scopes. Each workspace's file applies only to its own hash universe. */
171
+ /** Per-workspace scopes. Each workspace's file applies only to its own items. */
154
172
  workspaces?: readonly ChangelogOverrideScope[];
155
173
  }
156
174
  /** Result of {@link validateAllChangelogOverrides}: aggregated errors and warnings, each prefixed with the file path it pertains to. */
@@ -160,16 +178,16 @@ export interface ValidateAllChangelogOverridesResult {
160
178
  }
161
179
  /**
162
180
  * End-to-end health check across every override file and scope. Pure: takes already-collected
163
- * hash universes and returns aggregated findings. The CLI command and any other consumer
181
+ * item universes and returns aggregated findings. The CLI command and any other consumer
164
182
  * (programmatic library callers, future composite checks) wrap this with discovery and I/O.
165
183
  *
166
184
  * When invoked via the standard `validateOverridesCommand` entry point, each scope's
167
- * `hashes` is built by `buildChangelogEntries` — the same path `release-kit prepare` walks —
185
+ * `items` is built by `buildChangelogEntries` — the same path `release-kit prepare` walks —
168
186
  * so the match-set is byte-equal to what `prepare` would compute. The tier asymmetry is part
169
187
  * of that contract: workspace-tier keys are stale if they don't match in their own workspace;
170
188
  * root-tier keys are stale only if they don't match in any scope (no workspace AND not the
171
189
  * project release window). Library callers that construct `inputs` directly are responsible
172
- * for supplying the same hash universes if they want this guarantee.
190
+ * for supplying the same item universes if they want this guarantee.
173
191
  *
174
192
  * Every returned string is prefixed with the relative override-file path it pertains to so
175
193
  * consumers can locate the offending file without further structuring.
@@ -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
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The `--config` flag, shared by every subcommand that reads the config file.
3
+ *
4
+ * Spread into a subcommand's own schema rather than repeated, so that seven subcommands cannot drift to seven
5
+ * spellings of one flag.
6
+ */
7
+ export declare const configFlagSchema: {
8
+ config: {
9
+ long: string;
10
+ type: "string";
11
+ };
12
+ };
@@ -0,0 +1,3 @@
1
+ export const configFlagSchema = {
2
+ config: { long: '--config', type: 'string' },
3
+ };
@@ -1,19 +1,23 @@
1
1
  import { parseArgsOrExit, reportError } from '@williamthorsen/nmr-core';
2
2
  import { describeError } from '@williamthorsen/toolbelt.errors';
3
+ import { configFlagSchema } from "./configFlagSchema.js";
3
4
  import { createGithubReleases } from "./createGithubRelease.js";
4
5
  import { formatPrivateSkip } from "./formatPrivateSkip.js";
6
+ import { assertConfigUsable } from "./loadValidatedConfig.js";
5
7
  import { parseRequestedTags } from "./parseRequestedTags.js";
6
8
  import { resolveCommandTags } from "./resolveCommandTags.js";
7
9
  import { resolveReleaseNotesConfig } from "./resolveReleaseNotesConfig.js";
8
10
  const createGithubReleaseFlagSchema = {
11
+ ...configFlagSchema,
9
12
  dryRun: { long: '--dry-run', type: 'boolean' },
10
13
  tags: { long: '--tags', type: 'string' },
11
14
  };
12
15
  export async function createGithubReleaseCommand(argv, styles) {
13
16
  const parsed = parseArgsOrExit(argv, createGithubReleaseFlagSchema);
14
17
  const { dryRun } = parsed.flags;
18
+ await assertConfigUsable(styles.stderr, parsed.flags.config);
15
19
  const requestedTags = parseRequestedTags(parsed.flags.tags);
16
- const resolvedTags = await resolveCommandTags(requestedTags);
20
+ const resolvedTags = resolveCommandTags(requestedTags);
17
21
  const publishableTags = resolvedTags.filter((resolvedTag) => resolvedTag.isPublishable);
18
22
  for (const resolvedTag of resolvedTags) {
19
23
  if (!resolvedTag.isPublishable) {
@@ -24,7 +28,7 @@ export async function createGithubReleaseCommand(argv, styles) {
24
28
  return;
25
29
  }
26
30
  const { changelogJsonOutputPath, sectionOrder } = await resolveReleaseNotesConfig(styles.stderr, {
27
- strictLoad: true,
31
+ ...(parsed.flags.config !== undefined && { configPath: parsed.flags.config }),
28
32
  });
29
33
  let outcome;
30
34
  try {
@@ -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;