@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
@@ -15,11 +15,14 @@ export declare function readRootPackageVersion(): {
15
15
  /** The path where the consumer-facing config file is expected. */
16
16
  export declare const CONFIG_FILE_PATH = ".config/release-kit.config.ts";
17
17
  /**
18
- * Loads the config file at `.config/release-kit.config.ts`, returning the raw config object, or `undefined` when the
19
- * file does not exist. Throws when the file exists but cannot be imported, or exports neither a default nor a named
20
- * `config`.
18
+ * Loads the config file, returning the raw config object.
19
+ *
20
+ * `configPath` is resolved against the working directory and defaults to `CONFIG_FILE_PATH`. An absent default
21
+ * path returns `undefined`, because a repo that declares no config is a supported state; an absent named path
22
+ * throws, because the caller asked for that file by name. Also throws when the file exists but cannot be
23
+ * imported, or exports neither a default nor a named `config`.
21
24
  */
22
- export declare function loadConfig(): Promise<unknown>;
25
+ export declare function loadConfig(configPath?: string): Promise<unknown>;
23
26
  /**
24
27
  * Information about the root `package.json` passed into `mergeMonorepoConfig` when a
25
28
  * `project` block is configured. The async I/O lives in `readRootPackageVersion`; this
@@ -37,7 +40,7 @@ export interface RootPackageInfo {
37
40
  * removes the workspace; unlisted packages keep defaults.
38
41
  * - `workTypes`: shallow merge — consumer entries override or add to defaults by key.
39
42
  * - `versionPatterns`: consumer value replaces defaults entirely.
40
- * - `formatCommand`, `cliffConfigPath`, `scopeAliases`: consumer value wins.
43
+ * - `formatCommand`, `scopeAliases`: consumer value wins.
41
44
  * - `project`: present iff `userConfig.project` is declared. Resolves `tagPrefix` to
42
45
  * `DEFAULT_PROJECT_TAG_PREFIX` and `paths` to the union of the retained workspaces' `paths`
43
46
  * when omitted. Requires `rootPackage` to be passed and to contain a valid `version` field;
@@ -39,10 +39,13 @@ export function readRootPackageVersion() {
39
39
  return { exists: true, version: typeof parsed['version'] === 'string' ? parsed['version'] : undefined };
40
40
  }
41
41
  export const CONFIG_FILE_PATH = '.config/release-kit.config.ts';
42
- export async function loadConfig() {
43
- const absoluteConfigPath = path.resolve(process.cwd(), CONFIG_FILE_PATH);
42
+ export async function loadConfig(configPath) {
43
+ const absoluteConfigPath = path.resolve(process.cwd(), configPath ?? CONFIG_FILE_PATH);
44
44
  if (!existsSync(absoluteConfigPath)) {
45
- return undefined;
45
+ if (configPath === undefined) {
46
+ return undefined;
47
+ }
48
+ throw new Error(`Config file not found: ${absoluteConfigPath}`);
46
49
  }
47
50
  const imported = await import(__rewriteRelativeImportExtension(pathToFileURL(absoluteConfigPath).href));
48
51
  const resolved = isRecord(imported) ? (imported['default'] ?? imported['config']) : undefined;
@@ -96,9 +99,6 @@ function applyOptionalPassthroughFields(result, userConfig) {
96
99
  if (userConfig?.formatCommand !== undefined) {
97
100
  result.formatCommand = userConfig.formatCommand;
98
101
  }
99
- if (userConfig?.cliffConfigPath !== undefined) {
100
- result.cliffConfigPath = userConfig.cliffConfigPath;
101
- }
102
102
  if (userConfig?.scopeAliases !== undefined) {
103
103
  result.scopeAliases = userConfig.scopeAliases;
104
104
  }
@@ -212,7 +212,7 @@ function assertNoTagPrefixCollisions(workspaces, retiredPackages, project) {
212
212
  if (isPrefixCollision(a.prefix, b.prefix)) {
213
213
  throw new Error(`Tag prefix collision: '${a.prefix}' (${a.label}) and '${b.prefix}' (${b.label}). ` +
214
214
  'One prefix is identical to or a strict prefix of the other; ' +
215
- 'this would cause `git describe --match=<prefix>*` to return cross-matches.');
215
+ 'a tag under the longer prefix can then match the shorter one, whose releases would absorb it.');
216
216
  }
217
217
  }
218
218
  }
@@ -1,19 +1,60 @@
1
1
  import { type OutputStyle } from '@williamthorsen/nmr-core';
2
2
  import type { ReleaseKitConfig } from './types.js';
3
3
  /**
4
- * Outcome of loading and validating the consumer config file. `missing` is not reported
5
- * here — whether an absent config file is an error is the caller's call.
4
+ * Why a config file could not be used: the import threw, or the contents failed the schema.
5
+ *
6
+ * The distinction exists so that a caller can render the two differently. Every caller aborts on either.
7
+ */
8
+ export type ConfigProblem = {
9
+ kind: 'load';
10
+ message: string;
11
+ } | {
12
+ kind: 'validation';
13
+ errors: string[];
14
+ };
15
+ /**
16
+ * Outcome of loading and validating the consumer config file. `missing` is not an error
17
+ * here — whether an absent config file is one is the caller's call.
18
+ *
19
+ * `configFilePath` names the file the run read: the caller's `configPath`, or `CONFIG_FILE_PATH` when none was
20
+ * given. It stays relative to the working directory, so a message or a generated artifact that splices it reads
21
+ * the same on every machine.
6
22
  */
7
23
  export type LoadValidatedConfigResult = {
8
24
  status: 'invalid';
25
+ configFilePath: string;
26
+ problem: ConfigProblem;
9
27
  } | {
10
28
  status: 'missing';
29
+ configFilePath: string;
11
30
  } | {
12
31
  status: 'ok';
13
32
  config: ReleaseKitConfig;
33
+ configFilePath: string;
34
+ warnings: string[];
14
35
  };
15
36
  /**
16
- * Load `.config/release-kit.config.ts` and validate it against the shared schema,
17
- * reporting load failures and validation errors to stderr.
37
+ * Loads the consumer config file and validates it against the shared schema, returning the outcome.
38
+ *
39
+ * Writes nothing to any stream: the caller picks the sink, which is what lets a command that must stay silent
40
+ * reuse this loader. `reportConfigProblem` and `reportConfigWarnings` render the stderr form.
41
+ *
42
+ * `configPath` defaults to `CONFIG_FILE_PATH`. A named path that does not exist is a load failure, so it reports
43
+ * as `invalid` rather than `missing`.
44
+ */
45
+ export declare function loadValidatedConfig(configPath?: string): Promise<LoadValidatedConfigResult>;
46
+ /**
47
+ * Loads and validates the config, reporting the problem and exiting 1 when it is unusable, so that a command
48
+ * whose later work may never reach a loader still fails on a config that does.
49
+ *
50
+ * An absent default config returns cleanly, which is what keeps a repo that declares no config a supported state.
51
+ * Reports problems but never warnings: the command's own later load stays the single place that emits those, so
52
+ * a warning is not printed twice.
53
+ *
54
+ * The second load costs nothing: `import()` caches by URL and `validateConfig` is pure.
18
55
  */
19
- export declare function loadValidatedConfig(stderrStyle: OutputStyle): Promise<LoadValidatedConfigResult>;
56
+ export declare function assertConfigUsable(stderrStyle: OutputStyle, configPath?: string): Promise<void>;
57
+ /** Writes a config problem to stderr in the form every CLI command uses. */
58
+ export declare function reportConfigProblem(problem: ConfigProblem, stderrStyle: OutputStyle): void;
59
+ /** Writes validation warnings to stderr as indented status lines. */
60
+ export declare function reportConfigWarnings(warnings: readonly string[], stderrStyle: OutputStyle): void;
@@ -1,27 +1,44 @@
1
- import { printError, reportError } from '@williamthorsen/nmr-core';
1
+ import { formatStatusLine, printError, reportError } from '@williamthorsen/nmr-core';
2
2
  import { describeError } from '@williamthorsen/toolbelt.errors';
3
- import { loadConfig } from "./loadConfig.js";
3
+ import { CONFIG_FILE_PATH, loadConfig } from "./loadConfig.js";
4
4
  import { validateConfig } from "./validateConfig.js";
5
- export async function loadValidatedConfig(stderrStyle) {
5
+ export async function loadValidatedConfig(configPath) {
6
+ const configFilePath = configPath ?? CONFIG_FILE_PATH;
6
7
  let raw;
7
8
  try {
8
- raw = await loadConfig();
9
+ raw = await loadConfig(configPath);
9
10
  }
10
11
  catch (error) {
11
- const message = describeError(error);
12
- reportError(`Failed to load config: ${message}`);
13
- return { status: 'invalid' };
12
+ return { status: 'invalid', configFilePath, problem: { kind: 'load', message: describeError(error) } };
14
13
  }
15
14
  if (raw === undefined) {
16
- return { status: 'missing' };
15
+ return { status: 'missing', configFilePath };
17
16
  }
18
- const { config, errors } = validateConfig(raw);
17
+ const { config, errors, warnings } = validateConfig(raw);
19
18
  if (errors.length > 0) {
20
- process.stderr.write('Invalid config:\n');
21
- for (const err of errors) {
22
- printError(err, stderrStyle);
23
- }
24
- return { status: 'invalid' };
19
+ return { status: 'invalid', configFilePath, problem: { kind: 'validation', errors } };
20
+ }
21
+ return { status: 'ok', config, configFilePath, warnings };
22
+ }
23
+ export async function assertConfigUsable(stderrStyle, configPath) {
24
+ const result = await loadValidatedConfig(configPath);
25
+ if (result.status === 'invalid') {
26
+ reportConfigProblem(result.problem, stderrStyle);
27
+ process.exit(1);
28
+ }
29
+ }
30
+ export function reportConfigProblem(problem, stderrStyle) {
31
+ if (problem.kind === 'load') {
32
+ reportError(`Failed to load config: ${problem.message}`);
33
+ return;
34
+ }
35
+ process.stderr.write('Invalid config:\n');
36
+ for (const error of problem.errors) {
37
+ printError(error, stderrStyle);
38
+ }
39
+ }
40
+ export function reportConfigWarnings(warnings, stderrStyle) {
41
+ for (const warning of warnings) {
42
+ console.warn(` ${formatStatusLine(stderrStyle, 'warning', warning)}`);
25
43
  }
26
- return { status: 'ok', config };
27
44
  }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Reads the last `change-record` block in a commit message into the change entries that it records.
3
+ *
4
+ * The block's grammar is codeassembly's `change-record.md`: a fence whose info string is exactly `change-record`,
5
+ * containing a YAML mapping. A merge commit's block carries `entries` plus the top-level `pr_number` and `ticket_ref`.
6
+ * A key that the grammar does not declare is ignored, and a declared key whose value is null reads as absent. Any
7
+ * defect makes the whole block malformed; no entry is salvaged from a defective list.
8
+ */
9
+ export declare function parseChangeRecordBlock(message: string): ChangeRecordBlockReading;
10
+ /**
11
+ * Removes every `change-record` block from a message, fences included. A block that never closes runs to the end of
12
+ * the message, as a Markdown renderer reads it.
13
+ */
14
+ export declare function stripChangeRecordBlocks(message: string): string;
15
+ /**
16
+ * What a commit's last `change-record` block reads as: absent, malformed with the defect named, or the entries that
17
+ * it records with the merge's pull-request number and ticket reference when the block declares them.
18
+ */
19
+ export type ChangeRecordBlockReading = {
20
+ kind: 'absent';
21
+ } | {
22
+ kind: 'malformed';
23
+ reason: string;
24
+ } | {
25
+ kind: 'read';
26
+ entries: ChangeRecordEntry[];
27
+ prNumber?: number;
28
+ ticketRef?: string;
29
+ };
30
+ /** One outcome of a change, as the block records it. */
31
+ export interface ChangeRecordEntry {
32
+ breaking: boolean;
33
+ migration?: string;
34
+ scopes: string[];
35
+ text: string;
36
+ type: string;
37
+ }
@@ -0,0 +1,134 @@
1
+ import { parseDocument } from 'yaml';
2
+ import { isRecord } from "./typeGuards.js";
3
+ export function parseChangeRecordBlock(message) {
4
+ const lines = message.split(/\r?\n/);
5
+ const fence = findFences(lines).at(-1);
6
+ if (fence === undefined) {
7
+ return { kind: 'absent' };
8
+ }
9
+ if (fence.close === undefined) {
10
+ return { kind: 'malformed', reason: 'the block opens but never closes' };
11
+ }
12
+ const document = parseDocument(lines.slice(fence.open + 1, fence.close).join('\n'));
13
+ const [error] = document.errors;
14
+ if (error !== undefined) {
15
+ const detail = (error.message.split('\n', 1)[0] ?? '').replace(/:$/, '');
16
+ return { kind: 'malformed', reason: `the payload is not valid YAML: ${detail}` };
17
+ }
18
+ return readPayload(document.toJS());
19
+ }
20
+ export function stripChangeRecordBlocks(message) {
21
+ const lines = message.split(/\r?\n/);
22
+ const fences = findFences(lines);
23
+ return lines
24
+ .filter((_line, index) => fences.every((fence) => index < fence.open || index > (fence.close ?? lines.length)))
25
+ .join('\n');
26
+ }
27
+ const FENCE = '```';
28
+ function findFences(lines) {
29
+ const fences = [];
30
+ let open;
31
+ for (const [index, line] of lines.entries()) {
32
+ if (open === undefined) {
33
+ if (line.trim() === `${FENCE}${INFO_STRING}`) {
34
+ open = index;
35
+ }
36
+ }
37
+ else if (line.trim() === FENCE) {
38
+ fences.push({ close: index, open });
39
+ open = undefined;
40
+ }
41
+ }
42
+ if (open !== undefined) {
43
+ fences.push({ open });
44
+ }
45
+ return fences;
46
+ }
47
+ const INFO_STRING = 'change-record';
48
+ function readEntry(item, index) {
49
+ const at = `entries[${index}]`;
50
+ if (!isRecord(item)) {
51
+ return { reason: `\`${at}\` is not a mapping` };
52
+ }
53
+ const { breaking, migration, scopes } = item;
54
+ if (breaking !== undefined && breaking !== null && typeof breaking !== 'boolean') {
55
+ return { reason: `\`${at}.breaking\` is not a boolean` };
56
+ }
57
+ if (migration !== undefined && migration !== null && typeof migration !== 'string') {
58
+ return { reason: `\`${at}.migration\` is not a string` };
59
+ }
60
+ if (scopes !== undefined && scopes !== null && !Array.isArray(scopes)) {
61
+ return { reason: `\`${at}.scopes\` is not a list` };
62
+ }
63
+ const declaredScopes = scopes ?? [];
64
+ const readScopes = [];
65
+ for (const [position, scope] of declaredScopes.entries()) {
66
+ if (typeof scope !== 'string') {
67
+ return { reason: `\`${at}.scopes[${position}]\` is not a string` };
68
+ }
69
+ readScopes.push(scope.trim());
70
+ }
71
+ const type = readRequiredString(item, at, 'type');
72
+ if ('reason' in type) {
73
+ return type;
74
+ }
75
+ const text = readRequiredString(item, at, 'text');
76
+ if ('reason' in text) {
77
+ return text;
78
+ }
79
+ const trimmedMigration = migration?.trim();
80
+ return {
81
+ entry: {
82
+ breaking: breaking === true,
83
+ ...(trimmedMigration !== undefined && trimmedMigration !== '' && { migration: trimmedMigration }),
84
+ scopes: readScopes,
85
+ text: text.value,
86
+ type: type.value,
87
+ },
88
+ };
89
+ }
90
+ function readPayload(payload) {
91
+ if (!isRecord(payload)) {
92
+ return { kind: 'malformed', reason: 'the payload is not a mapping' };
93
+ }
94
+ const { entries, pr_number: prNumber, ticket_ref: ticketRef } = payload;
95
+ if (prNumber !== undefined &&
96
+ prNumber !== null &&
97
+ (typeof prNumber !== 'number' || !Number.isSafeInteger(prNumber) || prNumber <= 0)) {
98
+ return { kind: 'malformed', reason: '`pr_number` is not a positive integer' };
99
+ }
100
+ if (ticketRef !== undefined && ticketRef !== null && typeof ticketRef !== 'string') {
101
+ return { kind: 'malformed', reason: '`ticket_ref` is not a string' };
102
+ }
103
+ const readEntries = [];
104
+ if (entries !== undefined && entries !== null) {
105
+ if (!Array.isArray(entries)) {
106
+ return { kind: 'malformed', reason: '`entries` is not a list' };
107
+ }
108
+ for (const [index, item] of entries.entries()) {
109
+ const read = readEntry(item, index);
110
+ if ('reason' in read) {
111
+ return { kind: 'malformed', reason: read.reason };
112
+ }
113
+ readEntries.push(read.entry);
114
+ }
115
+ }
116
+ const trimmedTicketRef = ticketRef?.trim();
117
+ return {
118
+ kind: 'read',
119
+ entries: readEntries,
120
+ ...(typeof prNumber === 'number' && { prNumber }),
121
+ ...(trimmedTicketRef !== undefined && trimmedTicketRef !== '' && { ticketRef: trimmedTicketRef }),
122
+ };
123
+ }
124
+ function readRequiredString(item, at, name) {
125
+ const value = item[name];
126
+ if (value === undefined || value === null) {
127
+ return { reason: `\`${at}.${name}\` is missing` };
128
+ }
129
+ if (typeof value !== 'string') {
130
+ return { reason: `\`${at}.${name}\` is not a string` };
131
+ }
132
+ const trimmed = value.trim();
133
+ return trimmed === '' ? { reason: `\`${at}.${name}\` is blank` } : { value: trimmed };
134
+ }
@@ -6,18 +6,20 @@ export declare const COMMIT_PREPROCESSOR_PATTERNS: readonly RegExp[];
6
6
  *
7
7
  * Deliberately permissive: `*` is the sanctioned structural scope for changes spanning every
8
8
  * workspace, so the atom admits any run of non-pipe characters rather than word characters.
9
- * `cliff.toml.template` embeds the same atom in every ticketed commit parser, and
10
- * `cliffConfigAlignment.unit.test.ts` asserts that parity — exported so neither side can drift.
9
+ * Exported because `buildChangelogEntries.ts` builds its breaking-marker pattern from the same atom.
11
10
  */
12
11
  export declare const PIPE_SCOPE_SOURCE = "[^|]+";
13
- /** Surface where a `!`/`BREAKING CHANGE:` policy violation was detected. */
14
- export type PolicyViolationSurface = 'prefix' | 'body';
12
+ /**
13
+ * Surface where a breaking-policy violation was detected: a subject's `!`, a `BREAKING CHANGE:` footer, or a
14
+ * change-record entry's `breaking`.
15
+ */
16
+ export type PolicyViolationSurface = 'prefix' | 'body' | 'entry';
15
17
  /**
16
18
  * Callback invoked when `parseCommitMessage` detects a `!`-policy violation.
17
19
  *
18
20
  * The parser warns and continues on a violation, so legacy log entries don't block releases.
19
- * Callers (`decideRelease`, `releasePrepareMono`, etc.) collect callback invocations and
20
- * surface them in the release report.
21
+ * `readReleaseHistory` collects the invocations for the unreleased window, and the release
22
+ * report lists them.
21
23
  */
22
24
  export type PolicyViolationHandler = (commit: Commit, type: string, surface: PolicyViolationSurface) => void;
23
25
  /** Optional configuration for `parseCommitMessage` beyond the core inputs. */
@@ -49,3 +51,26 @@ export interface ParseCommitMessageOptions {
49
51
  * same warning path.
50
52
  */
51
53
  export declare function parseCommitMessage(message: string, hash: string, workTypes: Record<string, WorkTypeConfig>, scopeAliases?: Record<string, string>, options?: ParseCommitMessageOptions): ParsedCommit | undefined;
54
+ /** Inputs for {@link evaluateBreakingPolicy}. */
55
+ export interface BreakingPolicyInputs {
56
+ commit: Commit;
57
+ resolvedType: string;
58
+ /** Whether the marker that `prefixSurface` names is present. */
59
+ hasPrefixBreaking: boolean;
60
+ hasFooterBreaking: boolean;
61
+ policy: 'forbidden' | 'optional' | 'required';
62
+ onPolicyViolation: PolicyViolationHandler | undefined;
63
+ /** The surface reported for a violation of the marker; `'prefix'` when omitted. */
64
+ prefixSurface?: PolicyViolationSurface;
65
+ }
66
+ /**
67
+ * Applies the `!`-policy rules and returns the effective `breaking` flag.
68
+ *
69
+ * A violation invokes `onPolicyViolation` and drops the marker (`breaking: false`); the
70
+ * commit still parses.
71
+ */
72
+ export declare function evaluateBreakingPolicy(inputs: BreakingPolicyInputs): boolean;
73
+ /**
74
+ * Resolves a raw type string to its canonical type name using the record keys and aliases.
75
+ */
76
+ export declare function resolveType(rawType: string, workTypes: Record<string, WorkTypeConfig>): string | undefined;
@@ -20,7 +20,7 @@ export function parseCommitMessage(message, hash, workTypes, scopeAliases, optio
20
20
  if (resolvedType === undefined) {
21
21
  return undefined;
22
22
  }
23
- const commit = { message, hash };
23
+ const commit = { message, subject: firstLine, hash };
24
24
  const breaking = evaluateBreakingPolicy({
25
25
  commit,
26
26
  resolvedType,
@@ -40,11 +40,12 @@ export function parseCommitMessage(message, hash, workTypes, scopeAliases, optio
40
40
  ...(resolvedScope !== undefined && { scope: resolvedScope }),
41
41
  };
42
42
  }
43
- function evaluateBreakingPolicy(inputs) {
43
+ export function evaluateBreakingPolicy(inputs) {
44
44
  const { commit, resolvedType, hasPrefixBreaking, hasFooterBreaking, policy, onPolicyViolation } = inputs;
45
+ const prefixSurface = inputs.prefixSurface ?? 'prefix';
45
46
  if (policy === 'forbidden') {
46
47
  if (hasPrefixBreaking) {
47
- onPolicyViolation?.(commit, resolvedType, 'prefix');
48
+ onPolicyViolation?.(commit, resolvedType, prefixSurface);
48
49
  }
49
50
  if (hasFooterBreaking) {
50
51
  onPolicyViolation?.(commit, resolvedType, 'body');
@@ -53,14 +54,14 @@ function evaluateBreakingPolicy(inputs) {
53
54
  }
54
55
  if (policy === 'required') {
55
56
  if (!hasPrefixBreaking) {
56
- onPolicyViolation?.(commit, resolvedType, 'prefix');
57
+ onPolicyViolation?.(commit, resolvedType, prefixSurface);
57
58
  return false;
58
59
  }
59
60
  return true;
60
61
  }
61
62
  return hasPrefixBreaking || hasFooterBreaking;
62
63
  }
63
- function resolveType(rawType, workTypes) {
64
+ export function resolveType(rawType, workTypes) {
64
65
  const lowered = rawType.toLowerCase();
65
66
  for (const [key, config] of Object.entries(workTypes)) {
66
67
  if (key === lowered) {
@@ -30,9 +30,14 @@ export declare const prepareFlagSchema: {
30
30
  long: string;
31
31
  type: "boolean";
32
32
  };
33
+ config: {
34
+ long: string;
35
+ type: "string";
36
+ };
33
37
  };
34
38
  /** Parses CLI arguments into structured options. Prints a usage error and exits on invalid input. */
35
39
  export declare function parseArgs(argv: string[]): {
40
+ configPath: string | undefined;
36
41
  dryRun: boolean;
37
42
  force: boolean;
38
43
  noGitChecks: boolean;
@@ -1,19 +1,21 @@
1
1
  import { execSync } from 'node:child_process';
2
- import { formatGlyphLine, formatStatusLine, parseArgsOrExit, printError, reportError, } from '@williamthorsen/nmr-core';
2
+ import { formatGlyphLine, parseArgsOrExit, reportError, } from '@williamthorsen/nmr-core';
3
3
  import { describeError } from '@williamthorsen/toolbelt.errors';
4
4
  import { assertCleanWorkingTree } from "./assertCleanWorkingTree.js";
5
+ import { readReleaseHistory } from "./buildChangelogEntries.js";
5
6
  import { buildDependencyGraph } from "./buildDependencyGraph.js";
6
- import { discoverWorkspaces } from "./discoverWorkspaces.js";
7
+ import { configFlagSchema } from "./configFlagSchema.js";
8
+ import { describeEmptyWorkspace, discoverWorkspaces } from "./discoverWorkspaces.js";
7
9
  import { dim } from "./format.js";
8
- import { getCommitsSinceTarget } from "./getCommitsSinceTarget.js";
10
+ import { getAllTagPrefixes } from "./generateChangelogs.js";
9
11
  import { RELEASE_GLYPHS } from "./glyphs.js";
10
- import { loadConfig, mergeMonorepoConfig, mergeSinglePackageConfig, readRootPackageVersion } from "./loadConfig.js";
12
+ import { mergeMonorepoConfig, mergeSinglePackageConfig, readRootPackageVersion } from "./loadConfig.js";
13
+ import { loadValidatedConfig, reportConfigProblem, reportConfigWarnings } from "./loadValidatedConfig.js";
11
14
  import { RELEASE_SUMMARY_FILE, RELEASE_TAGS_FILE } from "./releaseFiles.js";
12
15
  import { applyReleasePlan } from "./releasePlan.js";
13
16
  import { releasePrepare } from "./releasePrepare.js";
14
17
  import { releasePrepareMono } from "./releasePrepareMono.js";
15
18
  import { reportPrepare } from "./reportPrepare.js";
16
- import { validateConfig } from "./validateConfig.js";
17
19
  import { validateOnlyExcludesStrandedDependents } from "./validateOnlyExcludesStrandedDependents.js";
18
20
  const VALID_BUMP_TYPES = ['major', 'minor', 'patch'];
19
21
  const CANONICAL_SEMVER_PATTERN = /^\d+\.\d+\.\d+$/;
@@ -21,6 +23,7 @@ function isReleaseType(value) {
21
23
  return VALID_BUMP_TYPES.includes(value);
22
24
  }
23
25
  export const prepareFlagSchema = {
26
+ ...configFlagSchema,
24
27
  dryRun: { long: '--dry-run', type: 'boolean' },
25
28
  force: { long: '--force', type: 'boolean' },
26
29
  noGitChecks: {
@@ -64,6 +67,7 @@ export function parseArgs(argv) {
64
67
  process.exit(1);
65
68
  }
66
69
  return {
70
+ configPath: flags.config,
67
71
  dryRun: flags.dryRun,
68
72
  force: flags.force,
69
73
  noGitChecks: flags.noGitChecks,
@@ -74,7 +78,7 @@ export function parseArgs(argv) {
74
78
  };
75
79
  }
76
80
  export async function prepareCommand(argv, styles) {
77
- const { dryRun, force, noGitChecks, bumpOverride, only, setVersion, withReleaseNotes } = parseArgs(argv);
81
+ const { configPath, dryRun, force, noGitChecks, bumpOverride, only, setVersion, withReleaseNotes } = parseArgs(argv);
78
82
  const options = {
79
83
  force,
80
84
  ...(bumpOverride !== undefined && { bumpOverride }),
@@ -93,20 +97,33 @@ export async function prepareCommand(argv, styles) {
93
97
  process.exit(1);
94
98
  }
95
99
  }
96
- const userConfig = await loadAndValidateConfig(styles.stderr);
97
- let discoveredPaths;
100
+ const configResult = await loadValidatedConfig(configPath);
101
+ if (configResult.status === 'invalid') {
102
+ reportConfigProblem(configResult.problem, styles.stderr);
103
+ process.exit(1);
104
+ }
105
+ let userConfig;
106
+ if (configResult.status === 'ok') {
107
+ reportConfigWarnings(configResult.warnings, styles.stderr);
108
+ userConfig = configResult.config;
109
+ }
110
+ let workspace;
98
111
  try {
99
- discoveredPaths = await discoverWorkspaces();
112
+ workspace = discoverWorkspaces();
100
113
  }
101
114
  catch (error) {
102
115
  reportError(`Failed to discover workspaces: ${describeError(error)}`);
103
116
  process.exit(1);
104
117
  }
105
- if (discoveredPaths === undefined) {
118
+ if (workspace.kind === 'empty') {
119
+ reportError(`No workspace package to release. ${describeEmptyWorkspace(workspace)}`);
120
+ process.exit(1);
121
+ }
122
+ if (workspace.kind === 'single-package') {
106
123
  runSinglePackageMode(userConfig, options, only, dryRun, styles.stdout);
107
124
  }
108
125
  else {
109
- runMonorepoMode(discoveredPaths, userConfig, options, only, setVersion, dryRun, styles.stdout);
126
+ runMonorepoMode(workspace.packageDirs, userConfig, options, only, setVersion, dryRun, styles.stdout);
110
127
  }
111
128
  }
112
129
  function runSinglePackageMode(userConfig, options, only, dryRun, style) {
@@ -114,11 +131,6 @@ function runSinglePackageMode(userConfig, options, only, dryRun, style) {
114
131
  reportError('--only is only supported for monorepo configurations');
115
132
  process.exit(1);
116
133
  }
117
- if (options.force && options.bumpOverride === undefined) {
118
- reportError('--force without --bump is only supported for monorepo configurations. ' +
119
- 'Use --bump=major|minor|patch to set the level for a single-package release.');
120
- process.exit(1);
121
- }
122
134
  const config = mergeSinglePackageConfig(userConfig);
123
135
  runAndReport(() => releasePrepare(config, options), dryRun, style);
124
136
  }
@@ -149,12 +161,8 @@ function runMonorepoMode(discoveredPaths, userConfig, options, only, setVersion,
149
161
  }
150
162
  const graph = buildDependencyGraph(config.workspaces);
151
163
  const violations = validateOnlyExcludesStrandedDependents(config.workspaces, only, graph, (workspace) => {
152
- const tagPrefixes = [
153
- workspace.tagPrefix,
154
- ...(workspace.legacyIdentities?.map((identity) => identity.tagPrefix) ?? []),
155
- ];
156
- const result = getCommitsSinceTarget(tagPrefixes, workspace.paths);
157
- return { has: result.commits.length > 0, tag: result.tag };
164
+ const history = readReleaseHistory(config, { tagPrefixes: getAllTagPrefixes(workspace), paths: workspace.paths });
165
+ return { has: history.unreleased.commits.length > 0, tag: history.previousTag };
158
166
  });
159
167
  if (violations !== undefined) {
160
168
  reportError('--only excludes packages with changes that would be stranded by the release.');
@@ -180,31 +188,6 @@ function runMonorepoMode(discoveredPaths, userConfig, options, only, setVersion,
180
188
  }
181
189
  runAndReport(() => releasePrepareMono(config, { ...options, ...(only !== undefined && { only }) }), dryRun, style);
182
190
  }
183
- async function loadAndValidateConfig(stderrStyle) {
184
- let rawConfig;
185
- try {
186
- rawConfig = await loadConfig();
187
- }
188
- catch (error) {
189
- reportError(`Failed to load config: ${describeError(error)}`);
190
- process.exit(1);
191
- }
192
- if (rawConfig === undefined) {
193
- return undefined;
194
- }
195
- const { config, errors, warnings } = validateConfig(rawConfig);
196
- if (errors.length > 0) {
197
- process.stderr.write('Invalid config:\n');
198
- for (const err of errors) {
199
- printError(err, stderrStyle);
200
- }
201
- process.exit(1);
202
- }
203
- for (const warning of warnings) {
204
- console.warn(` ${formatStatusLine(stderrStyle, 'warning', warning)}`);
205
- }
206
- return config;
207
- }
208
191
  function runAndReport(computePlan, dryRun, style) {
209
192
  let plan;
210
193
  try {
@@ -1,4 +1,5 @@
1
1
  import { type UndeclaredTagPrefix } from './detectUndeclaredTagPrefixes.js';
2
+ import type { ReleaseKitConfig } from './types.js';
2
3
  /** One workspace's preview row in the tag-prefix preview. */
3
4
  export interface TagPrefixPreviewRow {
4
5
  /** Workspace-relative directory path (e.g., `packages/core`). */
@@ -41,10 +42,12 @@ export interface TagPrefixPreview {
41
42
  /**
42
43
  * Build a structured preview of tag-prefix state for every discovered workspace.
43
44
  *
44
- * Discovers workspaces via `discoverWorkspaces()`, derives each workspace's tag prefix
45
- * via `deriveWorkspaceConfig()`, and records the derivation error on failure rather than aborting.
46
- * Loads `.config/release-kit.config.ts` to surface declared legacy prefixes per workspace,
47
- * scans local git tags for undeclared candidate prefixes via `detectUndeclaredTagPrefixes`,
48
- * and reports collisions across successfully-derived prefixes.
45
+ * Derives each workspace's tag prefix via `deriveWorkspaceConfig()`, recording the derivation error on failure
46
+ * rather than aborting. A repo releasing as a single package previews no row; one declaring patterns that
47
+ * resolve to no package throws, because the manifest is then what needs repairing and a clean table would
48
+ * hide that.
49
+ * Reads the already-validated `config` to surface declared legacy prefixes per workspace, scans local
50
+ * git tags for undeclared candidate prefixes via `detectUndeclaredTagPrefixes`, and reports collisions
51
+ * across successfully-derived prefixes.
49
52
  */
50
- export declare function previewTagPrefixes(): Promise<TagPrefixPreview>;
53
+ export declare function previewTagPrefixes(config?: ReleaseKitConfig): TagPrefixPreview;