@oorabona/release-it-preset 0.13.1 → 0.15.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.
package/README.md CHANGED
@@ -556,6 +556,45 @@ pnpm release-it-preset check
556
556
 
557
557
  Useful for debugging release issues.
558
558
 
559
+ #### `doctor` - Release Readiness Diagnostic
560
+
561
+ Runs a structured checklist across four categories and outputs a readiness score:
562
+
563
+ ```bash
564
+ pnpm release-it-preset doctor
565
+ pnpm release-it-preset doctor --json
566
+ ```
567
+
568
+ **What it checks:**
569
+
570
+ | Category | Checks |
571
+ |----------|--------|
572
+ | Environment | Known env vars, source (env / default / unset), publish-mode consistency |
573
+ | Repository | Git repo presence, branch vs `GIT_REQUIRE_BRANCH`, latest tag, commit count, dirty WD, upstream tracking, remote URL |
574
+ | Configuration | `CHANGELOG.md` exists + Keep a Changelog format + `[Unreleased]` content, `.release-it.json` parseable + `extends` field, `package.json` valid semver version |
575
+ | Readiness Summary | `PASS`/`WARN`/`FAIL` counts, score `N/M checks passing`, status (`READY`/`WARNINGS`/`BLOCKED`), actionable recommendations |
576
+
577
+ **Exit codes:**
578
+ - `0` — status is `READY` or `WARNINGS`
579
+ - `1` — status is `BLOCKED` (at least one `FAIL`)
580
+
581
+ **`--json` output shape:**
582
+ ```json
583
+ {
584
+ "environment": { "checks": [...], "vars": [...], "status": "PASS" },
585
+ "repository": { "checks": [...], "status": "WARN" },
586
+ "configuration": { "checks": [...], "status": "PASS" },
587
+ "summary": {
588
+ "pass": 10, "warn": 2, "fail": 0, "total": 12,
589
+ "score": "10/12 checks passing",
590
+ "status": "WARNINGS",
591
+ "recommendations": ["Review 2 warning(s) before releasing"]
592
+ }
593
+ }
594
+ ```
595
+
596
+ Use `doctor` as a pre-release sanity check, and `check` for the full verbose configuration dump.
597
+
559
598
  #### `check-pr` - Pull Request Hygiene
560
599
 
561
600
  Evaluates PR readiness by analysing commits and changelog changes. Designed for CI usage but safe locally when the required environment variables are set (`PR_BASE_REF`, `PR_HEAD_REF`).
@@ -662,6 +701,30 @@ Customize behavior with environment variables:
662
701
  - `CHANGELOG_FILE` - Changelog file path (default: `CHANGELOG.md`)
663
702
  - `GIT_CHANGELOG_PATH` - Optional. When set to a repository-relative path (e.g. `packages/tar-xz`), restrict changelog generation to commits touching that path. Useful for monorepo per-package CHANGELOG files. Empty / unset = repository-wide (default).
664
703
  - `GIT_CHANGELOG_SINCE` - Optional. Override the `since` baseline for changelog generation (any git ref: SHA, tag, branch). When set, bypasses both the per-package release-commit detection and the `git describe --tags` fallback. Useful for monorepo workspaces with non-standard release commit patterns. Empty / unset = use auto-detection.
704
+ - `CHANGELOG_TYPE_MAP` - Optional. JSON string mapping commit types to CHANGELOG section headings. Merged on top of `.changelog-types.json` (if present) and the built-in defaults. Use `false` as a value to suppress a type entirely. Example: `CHANGELOG_TYPE_MAP='{"ops":"### Operations","deps":"### Dependencies"}'`.
705
+
706
+ ### Custom type map (`.changelog-types.json`)
707
+
708
+ Create a `.changelog-types.json` file in your project root to override or extend the built-in commit-type → section mapping at the project level. The file is merged on top of the built-in defaults; individual keys can be overridden without touching the rest.
709
+
710
+ **Resolution order** (highest priority wins):
711
+ 1. `CHANGELOG_TYPE_MAP` env var (runtime override, e.g. in CI)
712
+ 2. `.changelog-types.json` project file
713
+ 3. Built-in defaults
714
+
715
+ **Example `.changelog-types.json`:**
716
+ ```json
717
+ {
718
+ "deps": "### Dependencies",
719
+ "ops": "### Operations",
720
+ "ci": false
721
+ }
722
+ ```
723
+ - String values must be a valid `### Section Heading`.
724
+ - `false` suppresses the type (no CHANGELOG entry emitted).
725
+ - Malformed JSON or invalid values → warning logged, layer ignored, lower-priority map used.
726
+
727
+ **BREAKING CHANGE: footer parsing** (Conventional Commits 1.0.0 §6): `BREAKING CHANGE:` is recognised as a footer only when it appears after a blank-line separator from the preceding paragraph. Mid-body occurrences without the blank line do not promote the commit to breaking. Multiple `BREAKING CHANGE:` lines in the same footer paragraph each emit a separate entry under `### ⚠️ BREAKING CHANGES`.
665
728
 
666
729
  ### Git
667
730
  - `GIT_COMMIT_MESSAGE` - Commit message template (default: `release: bump v${version}`)
package/bin/cli.js CHANGED
@@ -48,6 +48,7 @@ const UTILITY_COMMANDS = {
48
48
  update: 'populate-unreleased-changelog',
49
49
  validate: 'validate-release',
50
50
  check: 'check-config',
51
+ doctor: 'doctor',
51
52
  'check-pr': 'check-pr-status',
52
53
  'retry-publish-preflight': 'retry-publish',
53
54
  };
@@ -77,6 +78,7 @@ Utility Commands:
77
78
  update Update [Unreleased] section from commits
78
79
  validate [--allow-dirty] Validate project is ready for release
79
80
  check Display configuration and project status
81
+ doctor Run diagnostic checklist and show readiness score
80
82
  check-pr Evaluate PR hygiene (branch diff, changelog status, conventions)
81
83
  retry-publish-preflight Run retry publish safety checks without executing release
82
84
 
@@ -222,7 +224,7 @@ function handleUtilityCommand(commandName, args) {
222
224
  const compiledPath = join(__dirname, '..', 'dist', 'scripts', `${base}.js`);
223
225
  const sourcePath = join(__dirname, '..', 'scripts', `${base}.ts`);
224
226
 
225
- console.log(`🔧 Running utility command: ${commandName}\n`);
227
+ console.error(`🔧 Running utility command: ${commandName}\n`);
226
228
 
227
229
  // Prefer compiled script; fallback to tsx source if not built yet (developer convenience)
228
230
  import('node:fs').then(fs => {
@@ -230,7 +232,7 @@ function handleUtilityCommand(commandName, args) {
230
232
  const runner = useCompiled ? 'node' : 'tsx';
231
233
  const target = useCompiled ? compiledPath : sourcePath;
232
234
  if (!useCompiled) {
233
- console.log('ℹ️ Compiled script not found, falling back to tsx source execution (dev mode).');
235
+ console.error('ℹ️ Compiled script not found, falling back to tsx source execution (dev mode).');
234
236
  }
235
237
 
236
238
  const child = spawn(runner, [target, ...args], {
@@ -0,0 +1,489 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Doctor — diagnostic checklist + readiness score for release-it-preset
4
+ *
5
+ * Inspects four categories:
6
+ * 1. Environment — all known env vars, source (env vs default)
7
+ * 2. Repository — git state (branch, tag, dirty WD, upstream)
8
+ * 3. Configuration — CHANGELOG.md, .release-it.json, package.json
9
+ * 4. Summary — READY / WARNINGS / BLOCKED + score
10
+ *
11
+ * Usage:
12
+ * node dist/scripts/doctor.js
13
+ * node dist/scripts/doctor.js --json
14
+ */
15
+ import { execSync } from 'node:child_process';
16
+ import { existsSync, readFileSync } from 'node:fs';
17
+ import { isValidSemver } from './lib/semver-utils.js';
18
+ // ---------------------------------------------------------------------------
19
+ // Helpers
20
+ // ---------------------------------------------------------------------------
21
+ export function safeExec(command, deps) {
22
+ try {
23
+ return deps.execSync(command, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
24
+ }
25
+ catch {
26
+ return null;
27
+ }
28
+ }
29
+ function worstStatus(statuses) {
30
+ if (statuses.includes('FAIL'))
31
+ return 'FAIL';
32
+ if (statuses.includes('WARN'))
33
+ return 'WARN';
34
+ return 'PASS';
35
+ }
36
+ // ---------------------------------------------------------------------------
37
+ // ENV VAR CATALOG
38
+ // ---------------------------------------------------------------------------
39
+ const ENV_VAR_CATALOG = [
40
+ { name: 'CHANGELOG_FILE', defaultValue: 'CHANGELOG.md' },
41
+ { name: 'GIT_CHANGELOG_PATH' },
42
+ { name: 'GIT_CHANGELOG_SINCE' },
43
+ { name: 'GIT_COMMIT_MESSAGE', defaultValue: 'release: bump v${version}' },
44
+ { name: 'GIT_TAG_NAME', defaultValue: 'v${version}' },
45
+ { name: 'GIT_REQUIRE_BRANCH', defaultValue: 'main' },
46
+ { name: 'GIT_REQUIRE_UPSTREAM', defaultValue: 'false' },
47
+ { name: 'GIT_REQUIRE_CLEAN', defaultValue: 'false' },
48
+ { name: 'GIT_REMOTE', defaultValue: 'origin' },
49
+ { name: 'GIT_CHANGELOG_COMMAND' },
50
+ { name: 'GIT_CHANGELOG_DESCRIBE_COMMAND' },
51
+ { name: 'GITHUB_RELEASE', defaultValue: 'false' },
52
+ { name: 'GITHUB_REPOSITORY' },
53
+ { name: 'NPM_PUBLISH', defaultValue: 'false' },
54
+ { name: 'NPM_SKIP_CHECKS', defaultValue: 'false' },
55
+ { name: 'NPM_ACCESS', defaultValue: 'public' },
56
+ { name: 'NPM_TAG' },
57
+ ];
58
+ // ---------------------------------------------------------------------------
59
+ // 1. Environment
60
+ // ---------------------------------------------------------------------------
61
+ export function collectEnvironment(deps) {
62
+ const vars = ENV_VAR_CATALOG.map(({ name, defaultValue }) => {
63
+ const value = deps.getEnv(name);
64
+ if (value !== undefined) {
65
+ return { name, value, source: 'env' };
66
+ }
67
+ if (defaultValue !== undefined) {
68
+ return { name, value: defaultValue, source: 'default', defaultValue };
69
+ }
70
+ return { name, value: undefined, source: 'unset', defaultValue };
71
+ });
72
+ const checks = [];
73
+ const githubRelease = deps.getEnv('GITHUB_RELEASE');
74
+ const githubRepo = deps.getEnv('GITHUB_REPOSITORY');
75
+ const npmPublish = deps.getEnv('NPM_PUBLISH');
76
+ if (githubRelease === 'true' && !githubRepo) {
77
+ checks.push({
78
+ name: 'GITHUB_REPOSITORY set when GITHUB_RELEASE=true',
79
+ status: 'WARN',
80
+ value: '<unset>',
81
+ detail: 'Set GITHUB_REPOSITORY=owner/repo to enable GitHub releases',
82
+ });
83
+ }
84
+ else {
85
+ checks.push({
86
+ name: 'GitHub release configuration',
87
+ status: 'PASS',
88
+ value: githubRelease === 'true' ? 'enabled' : 'disabled (default)',
89
+ });
90
+ }
91
+ if (npmPublish === 'true') {
92
+ checks.push({
93
+ name: 'npm publish configuration',
94
+ status: 'PASS',
95
+ value: 'enabled (NPM_PUBLISH=true)',
96
+ });
97
+ }
98
+ else {
99
+ checks.push({
100
+ name: 'npm publish configuration',
101
+ status: 'PASS',
102
+ value: 'disabled (default — safe for local runs)',
103
+ });
104
+ }
105
+ return {
106
+ vars,
107
+ checks,
108
+ status: worstStatus(checks.map((c) => c.status)),
109
+ };
110
+ }
111
+ // ---------------------------------------------------------------------------
112
+ // 2. Repository
113
+ // ---------------------------------------------------------------------------
114
+ export function inspectRepository(deps) {
115
+ const checks = [];
116
+ const isGitRepo = safeExec('git rev-parse --git-dir', deps) !== null;
117
+ if (!isGitRepo) {
118
+ checks.push({
119
+ name: 'Git repository',
120
+ status: 'FAIL',
121
+ value: 'not a git repository',
122
+ detail: 'Run doctor from inside a git repository',
123
+ });
124
+ return { checks, status: 'FAIL' };
125
+ }
126
+ checks.push({ name: 'Git repository', status: 'PASS', value: 'yes' });
127
+ const branch = safeExec('git rev-parse --abbrev-ref HEAD', deps);
128
+ const requiredBranch = deps.getEnv('GIT_REQUIRE_BRANCH') ?? 'main';
129
+ if (!branch) {
130
+ checks.push({
131
+ name: 'Current branch',
132
+ status: 'WARN',
133
+ value: 'unknown',
134
+ detail: 'Could not determine current branch',
135
+ });
136
+ }
137
+ else if (requiredBranch && branch !== requiredBranch) {
138
+ checks.push({
139
+ name: 'Current branch',
140
+ status: 'WARN',
141
+ value: branch,
142
+ detail: `GIT_REQUIRE_BRANCH is "${requiredBranch}" — release will fail on this branch`,
143
+ });
144
+ }
145
+ else {
146
+ checks.push({ name: 'Current branch', status: 'PASS', value: branch });
147
+ }
148
+ const latestTag = safeExec('git describe --tags --abbrev=0', deps);
149
+ if (!latestTag) {
150
+ checks.push({
151
+ name: 'Latest tag',
152
+ status: 'WARN',
153
+ value: 'none',
154
+ detail: 'No tags found — first release scenario',
155
+ });
156
+ }
157
+ else {
158
+ checks.push({ name: 'Latest tag', status: 'PASS', value: latestTag });
159
+ }
160
+ let commitCount = 0;
161
+ if (latestTag) {
162
+ const countStr = safeExec(`git rev-list "${latestTag}"..HEAD --count`, deps);
163
+ commitCount = countStr ? parseInt(countStr, 10) : 0;
164
+ }
165
+ else {
166
+ const countStr = safeExec('git rev-list HEAD --count', deps);
167
+ commitCount = countStr ? parseInt(countStr, 10) : 0;
168
+ }
169
+ checks.push({
170
+ name: 'Commits since last tag',
171
+ status: 'PASS',
172
+ value: String(commitCount),
173
+ });
174
+ const dirtyOutput = safeExec('git status --porcelain', deps);
175
+ const isDirty = dirtyOutput !== null && dirtyOutput.length > 0;
176
+ if (isDirty) {
177
+ checks.push({
178
+ name: 'Working directory clean',
179
+ status: 'WARN',
180
+ value: 'dirty',
181
+ detail: 'Uncommitted changes present — release may fail if GIT_REQUIRE_CLEAN=true',
182
+ });
183
+ }
184
+ else {
185
+ checks.push({ name: 'Working directory clean', status: 'PASS', value: 'yes' });
186
+ }
187
+ const upstream = safeExec('git rev-parse --abbrev-ref @{u}', deps);
188
+ if (!upstream) {
189
+ checks.push({
190
+ name: 'Upstream tracking branch',
191
+ status: 'WARN',
192
+ value: 'none',
193
+ detail: 'No upstream set — git push will fail. Run: git push -u origin <branch>',
194
+ });
195
+ }
196
+ else {
197
+ checks.push({ name: 'Upstream tracking branch', status: 'PASS', value: upstream });
198
+ }
199
+ const remote = deps.getEnv('GIT_REMOTE') ?? 'origin';
200
+ const remoteUrl = safeExec(`git config --get remote.${remote}.url`, deps);
201
+ if (!remoteUrl) {
202
+ checks.push({
203
+ name: `Git remote (${remote})`,
204
+ status: 'WARN',
205
+ value: 'not configured',
206
+ detail: `Remote "${remote}" not found. Set GIT_REMOTE or run: git remote add origin <url>`,
207
+ });
208
+ }
209
+ else {
210
+ checks.push({ name: `Git remote (${remote})`, status: 'PASS', value: remoteUrl });
211
+ }
212
+ return { checks, status: worstStatus(checks.map((c) => c.status)) };
213
+ }
214
+ // ---------------------------------------------------------------------------
215
+ // 3. Configuration
216
+ // ---------------------------------------------------------------------------
217
+ export function validateConfiguration(deps) {
218
+ const checks = [];
219
+ const changelogPath = deps.getEnv('CHANGELOG_FILE') ?? 'CHANGELOG.md';
220
+ if (!deps.existsSync(changelogPath)) {
221
+ checks.push({
222
+ name: `${changelogPath} exists`,
223
+ status: 'FAIL',
224
+ value: 'missing',
225
+ detail: `Run: release-it-preset init OR create ${changelogPath} manually`,
226
+ });
227
+ }
228
+ else {
229
+ checks.push({ name: `${changelogPath} exists`, status: 'PASS', value: 'yes' });
230
+ const content = deps.readFileSync(changelogPath, 'utf8');
231
+ const hasKacHeader = /^# Changelog/m.test(content);
232
+ if (!hasKacHeader) {
233
+ checks.push({
234
+ name: 'Keep a Changelog format',
235
+ status: 'FAIL',
236
+ value: 'invalid',
237
+ detail: 'CHANGELOG.md must start with "# Changelog" (Keep a Changelog format)',
238
+ });
239
+ }
240
+ else {
241
+ checks.push({ name: 'Keep a Changelog format', status: 'PASS', value: 'valid' });
242
+ }
243
+ const unreleasedMatch = content.match(/## \[Unreleased\]([\s\S]*?)(?=## \[|$)/);
244
+ if (!unreleasedMatch) {
245
+ checks.push({
246
+ name: '[Unreleased] section',
247
+ status: 'FAIL',
248
+ value: 'missing',
249
+ detail: 'Add "## [Unreleased]" section — run: release-it-preset update',
250
+ });
251
+ }
252
+ else {
253
+ const unreleasedContent = unreleasedMatch[1].trim();
254
+ const hasChanges = /^-/m.test(unreleasedContent);
255
+ if (!unreleasedContent || !hasChanges) {
256
+ checks.push({
257
+ name: '[Unreleased] section',
258
+ status: 'WARN',
259
+ value: 'empty',
260
+ detail: 'No entries yet — run: release-it-preset update',
261
+ });
262
+ }
263
+ else {
264
+ checks.push({ name: '[Unreleased] section', status: 'PASS', value: 'has content' });
265
+ }
266
+ }
267
+ }
268
+ const hasReleaseItJson = deps.existsSync('.release-it.json');
269
+ if (!hasReleaseItJson) {
270
+ checks.push({
271
+ name: '.release-it.json exists',
272
+ status: 'WARN',
273
+ value: 'missing',
274
+ detail: 'Optional but recommended. Run: release-it-preset init',
275
+ });
276
+ }
277
+ else {
278
+ checks.push({ name: '.release-it.json exists', status: 'PASS', value: 'yes' });
279
+ try {
280
+ const raw = deps.readFileSync('.release-it.json', 'utf8');
281
+ const config = JSON.parse(raw);
282
+ const extendsField = config.extends;
283
+ if (!extendsField) {
284
+ checks.push({
285
+ name: '.release-it.json extends preset',
286
+ status: 'WARN',
287
+ value: 'no extends field',
288
+ detail: 'Add "extends": "@oorabona/release-it-preset/config/<name>" for CLI auto-detection',
289
+ });
290
+ }
291
+ else if (!/@oorabona\/release-it-preset\/config\/[\w-]+/.test(extendsField)) {
292
+ checks.push({
293
+ name: '.release-it.json extends preset',
294
+ status: 'WARN',
295
+ value: extendsField,
296
+ detail: 'extends does not point to @oorabona/release-it-preset/config/<name>',
297
+ });
298
+ }
299
+ else {
300
+ checks.push({
301
+ name: '.release-it.json extends preset',
302
+ status: 'PASS',
303
+ value: extendsField,
304
+ });
305
+ }
306
+ }
307
+ catch {
308
+ checks.push({
309
+ name: '.release-it.json parseable',
310
+ status: 'FAIL',
311
+ value: 'parse error',
312
+ detail: '.release-it.json contains invalid JSON',
313
+ });
314
+ }
315
+ }
316
+ if (!deps.existsSync('package.json')) {
317
+ checks.push({
318
+ name: 'package.json exists',
319
+ status: 'FAIL',
320
+ value: 'missing',
321
+ detail: 'package.json is required for release-it',
322
+ });
323
+ }
324
+ else {
325
+ try {
326
+ const raw = deps.readFileSync('package.json', 'utf8');
327
+ const pkg = JSON.parse(raw);
328
+ const version = pkg.version;
329
+ if (!version) {
330
+ checks.push({
331
+ name: 'package.json version',
332
+ status: 'FAIL',
333
+ value: 'missing',
334
+ detail: 'Add "version" field to package.json',
335
+ });
336
+ }
337
+ else if (!isValidSemver(version)) {
338
+ checks.push({
339
+ name: 'package.json version',
340
+ status: 'FAIL',
341
+ value: version,
342
+ detail: `"${version}" is not a valid semver string`,
343
+ });
344
+ }
345
+ else {
346
+ checks.push({ name: 'package.json version', status: 'PASS', value: version });
347
+ }
348
+ }
349
+ catch {
350
+ checks.push({
351
+ name: 'package.json parseable',
352
+ status: 'FAIL',
353
+ value: 'parse error',
354
+ detail: 'package.json contains invalid JSON',
355
+ });
356
+ }
357
+ }
358
+ return { checks, status: worstStatus(checks.map((c) => c.status)) };
359
+ }
360
+ // ---------------------------------------------------------------------------
361
+ // 4. Summary
362
+ // ---------------------------------------------------------------------------
363
+ export function summarize(report) {
364
+ const allChecks = [
365
+ ...report.environment.checks,
366
+ ...report.repository.checks,
367
+ ...report.configuration.checks,
368
+ ];
369
+ const pass = allChecks.filter((c) => c.status === 'PASS').length;
370
+ const warn = allChecks.filter((c) => c.status === 'WARN').length;
371
+ const fail = allChecks.filter((c) => c.status === 'FAIL').length;
372
+ const total = allChecks.length;
373
+ const score = `${pass}/${total} checks passing`;
374
+ let status;
375
+ if (fail > 0) {
376
+ status = 'BLOCKED';
377
+ }
378
+ else if (warn > 0) {
379
+ status = 'WARNINGS';
380
+ }
381
+ else {
382
+ status = 'READY';
383
+ }
384
+ const recommendations = [];
385
+ if (fail > 0) {
386
+ const failedNames = allChecks.filter((c) => c.status === 'FAIL').map((c) => c.name);
387
+ const preview = failedNames.slice(0, 2).join(', ') + (failedNames.length > 2 ? '...' : '');
388
+ recommendations.push(`Fix ${fail} blocking issue(s): ${preview}`);
389
+ }
390
+ if (warn > 0) {
391
+ recommendations.push(`Review ${warn} warning(s) before releasing`);
392
+ }
393
+ if (status === 'READY') {
394
+ recommendations.push('All checks pass — run: release-it-preset validate && release-it-preset default');
395
+ }
396
+ return { pass, warn, fail, total, score, status, recommendations };
397
+ }
398
+ // ---------------------------------------------------------------------------
399
+ // Main exported function (DI)
400
+ // ---------------------------------------------------------------------------
401
+ export function runDoctor(deps) {
402
+ const environment = collectEnvironment(deps);
403
+ const repository = inspectRepository(deps);
404
+ const configuration = validateConfiguration(deps);
405
+ const summary = summarize({ environment, repository, configuration });
406
+ return { environment, repository, configuration, summary };
407
+ }
408
+ // ---------------------------------------------------------------------------
409
+ // Formatting
410
+ // ---------------------------------------------------------------------------
411
+ const ICONS = {
412
+ PASS: '[PASS]',
413
+ WARN: '[WARN]',
414
+ FAIL: '[FAIL]',
415
+ };
416
+ const STATUS_LABELS = {
417
+ READY: 'READY',
418
+ WARNINGS: 'WARNINGS',
419
+ BLOCKED: 'BLOCKED',
420
+ };
421
+ export function formatHuman(report) {
422
+ const lines = [];
423
+ function sectionHeader(title) {
424
+ lines.push('');
425
+ lines.push(title);
426
+ lines.push('-'.repeat(60));
427
+ }
428
+ function renderChecks(checks) {
429
+ for (const check of checks) {
430
+ const icon = ICONS[check.status];
431
+ lines.push(` ${icon} ${check.name}: ${check.value}`);
432
+ if (check.detail) {
433
+ lines.push(` ${check.detail}`);
434
+ }
435
+ }
436
+ }
437
+ lines.push('');
438
+ lines.push('release-it-preset doctor');
439
+ lines.push('='.repeat(60));
440
+ sectionHeader('1. Environment');
441
+ renderChecks(report.environment.checks);
442
+ lines.push('');
443
+ lines.push(` Environment variables (${report.environment.vars.length} total):`);
444
+ for (const v of report.environment.vars) {
445
+ const srcLabel = v.source === 'env' ? '(env)' : v.source === 'default' ? '(default)' : '(unset)';
446
+ const displayVal = v.source === 'unset' ? '<not set>' : (v.value ?? '<not set>');
447
+ lines.push(` ${v.name.padEnd(35)} ${displayVal.padEnd(30)} ${srcLabel}`);
448
+ }
449
+ sectionHeader('2. Repository');
450
+ renderChecks(report.repository.checks);
451
+ sectionHeader('3. Configuration');
452
+ renderChecks(report.configuration.checks);
453
+ sectionHeader('4. Readiness Summary');
454
+ const { summary } = report;
455
+ lines.push(` Status : ${STATUS_LABELS[summary.status]}`);
456
+ lines.push(` Score : ${summary.score} (PASS: ${summary.pass}, WARN: ${summary.warn}, FAIL: ${summary.fail})`);
457
+ if (summary.recommendations.length > 0) {
458
+ lines.push('');
459
+ lines.push(' Recommendations:');
460
+ for (const rec of summary.recommendations) {
461
+ lines.push(` * ${rec}`);
462
+ }
463
+ }
464
+ lines.push('');
465
+ return lines.join('\n');
466
+ }
467
+ export function formatJson(report) {
468
+ return JSON.stringify(report, null, 2);
469
+ }
470
+ // ---------------------------------------------------------------------------
471
+ // CLI entry (guarded)
472
+ // ---------------------------------------------------------------------------
473
+ if (import.meta.url === `file://${process.argv[1]}`) {
474
+ const isJson = process.argv.includes('--json');
475
+ const deps = {
476
+ execSync,
477
+ existsSync,
478
+ readFileSync,
479
+ getEnv: (key) => process.env[key],
480
+ };
481
+ const report = runDoctor(deps);
482
+ if (isJson) {
483
+ process.stdout.write(formatJson(report) + '\n');
484
+ }
485
+ else {
486
+ process.stdout.write(formatHuman(report));
487
+ }
488
+ process.exit(report.summary.status === 'BLOCKED' ? 1 : 0);
489
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Configurable commit-type → CHANGELOG section mapping.
3
+ *
4
+ * Resolution order (highest priority wins):
5
+ * 1. CHANGELOG_TYPE_MAP env var (JSON string)
6
+ * 2. .changelog-types.json file (project root)
7
+ * 3. Built-in defaults below
8
+ *
9
+ * A value of `false` means "skip this type entirely" (no changelog entry).
10
+ */
11
+ /**
12
+ * Built-in commit-type → CHANGELOG section map.
13
+ * Values are `### SectionName` strings or `false` to suppress.
14
+ */
15
+ export const BUILTIN_TYPE_MAP = {
16
+ feat: '### Added',
17
+ feature: '### Added',
18
+ add: '### Added',
19
+ fix: '### Fixed',
20
+ bugfix: '### Fixed',
21
+ security: '### Security',
22
+ perf: '### Changed',
23
+ refactor: '### Changed',
24
+ style: '### Changed',
25
+ docs: '### Changed',
26
+ test: '### Changed',
27
+ chore: '### Changed',
28
+ build: '### Changed',
29
+ deps: '### Changed',
30
+ dependency: '### Changed',
31
+ dependencies: '### Changed',
32
+ revert: '### Changed',
33
+ remove: '### Removed',
34
+ removed: '### Removed',
35
+ delete: '### Removed',
36
+ deleted: '### Removed',
37
+ ci: false,
38
+ release: false,
39
+ hotfix: false,
40
+ misc: '### Changed',
41
+ };
42
+ const CHANGELOG_TYPES_FILE = '.changelog-types.json';
43
+ /**
44
+ * Validate that every value in the map is either a string or false.
45
+ * Throws on the first invalid entry.
46
+ */
47
+ function validateMapStructure(map) {
48
+ if (typeof map !== 'object' || map === null || Array.isArray(map)) {
49
+ throw new TypeError('Type map must be a plain object');
50
+ }
51
+ for (const [key, value] of Object.entries(map)) {
52
+ if (typeof value !== 'string' && value !== false) {
53
+ throw new TypeError(`Invalid value for key "${key}": expected string or false, got ${typeof value}`);
54
+ }
55
+ }
56
+ }
57
+ /**
58
+ * Load the commit-type → CHANGELOG section mapping.
59
+ *
60
+ * Priority:
61
+ * 1. CHANGELOG_TYPE_MAP env var (JSON, merged on top of file + built-in)
62
+ * 2. .changelog-types.json project file (merged on top of built-in)
63
+ * 3. BUILTIN_TYPE_MAP (base)
64
+ *
65
+ * Malformed JSON or invalid structure → WARN + ignore that layer (fall back to lower priority).
66
+ */
67
+ export function loadChangelogTypeMap(deps) {
68
+ let resolved = { ...BUILTIN_TYPE_MAP };
69
+ // Layer 1: project-level file override
70
+ let fileContent;
71
+ try {
72
+ fileContent = deps.readFileSync(CHANGELOG_TYPES_FILE, 'utf8');
73
+ }
74
+ catch (err) {
75
+ // ENOENT (file does not exist) is the expected case — silently skip.
76
+ // Other I/O errors (EACCES permission denied, EISDIR is a directory, etc.)
77
+ // are real problems and surfaced as a WARN so the user can investigate.
78
+ const e = err;
79
+ if (e?.code !== 'ENOENT') {
80
+ deps.warn(`Cannot read ${CHANGELOG_TYPES_FILE}: ${e?.message ?? String(err)}. Skipping file override.`);
81
+ }
82
+ fileContent = undefined;
83
+ }
84
+ if (fileContent !== undefined) {
85
+ try {
86
+ const parsed = JSON.parse(fileContent);
87
+ validateMapStructure(parsed);
88
+ resolved = { ...resolved, ...parsed };
89
+ }
90
+ catch (err) {
91
+ deps.warn(`Invalid ${CHANGELOG_TYPES_FILE}: ${err.message}. Using built-in type map.`);
92
+ }
93
+ }
94
+ // Layer 2: env var override (highest priority)
95
+ const envValue = deps.getEnv('CHANGELOG_TYPE_MAP');
96
+ if (envValue) {
97
+ try {
98
+ const parsed = JSON.parse(envValue);
99
+ validateMapStructure(parsed);
100
+ resolved = { ...resolved, ...parsed };
101
+ }
102
+ catch (err) {
103
+ deps.warn(`Invalid CHANGELOG_TYPE_MAP env var: ${err.message}. Using file/built-in type map.`);
104
+ }
105
+ }
106
+ return resolved;
107
+ }
@@ -23,6 +23,7 @@ import { getGitHubRepoUrl } from './lib/git-utils.js';
23
23
  import { CONVENTIONAL_COMMIT_REGEX } from './lib/commit-parser.js';
24
24
  import { runScript } from './lib/run-script.js';
25
25
  import { ValidationError } from './lib/errors.js';
26
+ import { BUILTIN_TYPE_MAP, loadChangelogTypeMap } from './lib/changelog-types.js';
26
27
  /**
27
28
  * Extract all conventional commit patterns from a commit body
28
29
  */
@@ -46,43 +47,19 @@ export function extractConventionalCommitParts(commitBody, sha) {
46
47
  return parts;
47
48
  }
48
49
  /**
49
- * Normalize commit types to standard changelog categories
50
+ * Normalize a commit type to a CHANGELOG section heading.
51
+ * Uses `typeMap` (defaults to BUILTIN_TYPE_MAP) so callers can inject
52
+ * a custom or project-level override without touching this function.
53
+ * Returns false when the type should be suppressed entirely.
50
54
  */
51
- export function normalizeCommitType(type) {
52
- const typeMap = {
53
- feat: '### Added',
54
- feature: '### Added',
55
- add: '### Added',
56
- fix: '### Fixed',
57
- bugfix: '### Fixed',
58
- security: '### Security',
59
- perf: '### Changed',
60
- refactor: '### Changed',
61
- style: '### Changed',
62
- docs: '### Changed',
63
- test: '### Changed',
64
- chore: '### Changed',
65
- build: '### Changed',
66
- deps: '### Changed',
67
- dependency: '### Changed',
68
- dependencies: '### Changed',
69
- revert: '### Changed',
70
- remove: '### Removed',
71
- removed: '### Removed',
72
- delete: '### Removed',
73
- deleted: '### Removed',
74
- ci: false,
75
- release: false,
76
- hotfix: false,
77
- misc: '### Changed',
78
- };
55
+ export function normalizeCommitType(type, typeMap = BUILTIN_TYPE_MAP) {
79
56
  const result = typeMap[type.toLowerCase()];
80
57
  return result !== undefined ? result : '### Changed';
81
58
  }
82
59
  /**
83
60
  * Parse git log output and extract all conventional commit parts
84
61
  */
85
- export function parseCommitsWithMultiplePrefixes(gitOutput, repoUrl) {
62
+ export function parseCommitsWithMultiplePrefixes(gitOutput, repoUrl, typeMap = BUILTIN_TYPE_MAP) {
86
63
  if (!gitOutput)
87
64
  return '';
88
65
  const commitEntries = gitOutput.split('|||END|||').filter((entry) => entry.trim());
@@ -112,19 +89,35 @@ export function parseCommitsWithMultiplePrefixes(gitOutput, repoUrl) {
112
89
  return acc;
113
90
  }, { lines: [], done: false }).lines.join('\n');
114
91
  const parts = extractConventionalCommitParts(headerBlock, shortSha);
115
- // Detect "BREAKING CHANGE:" trailer in the full body (not just header).
116
- // This handles the footer-style breaking annotation per Conventional Commits spec.
117
- const breakingFooterMatch = /^BREAKING[- ]CHANGE:\s*(.+)/m.exec(body);
118
- if (breakingFooterMatch) {
92
+ // F-003: Detect "BREAKING CHANGE:" trailers only in the LAST paragraph of the body,
93
+ // AND only when the body has more than one paragraph (i.e., there is at least one
94
+ // blank-line separator). Per Conventional Commits 1.0.0 §6, a footer requires a
95
+ // blank line separating it from the preceding content. A "BREAKING CHANGE:" that
96
+ // appears on a line immediately after the subject line (no blank line) is mid-body
97
+ // prose, NOT a footer, and must NOT promote the commit to breaking.
98
+ //
99
+ // F-004: Use matchAll() so multiple BREAKING CHANGE: lines in the same last
100
+ // paragraph each emit a separate breaking entry.
101
+ // CRLF safety: accept both LF and CRLF line endings so commits authored on
102
+ // Windows produce the same output (a paragraph separator can be \n\n or \r\n\r\n).
103
+ const paragraphs = body.split(/\r?\n[ \t]*\r?\n/);
104
+ const hasFooterSection = paragraphs.length > 1;
105
+ const breakingFooterMatches = hasFooterSection
106
+ ? [...(paragraphs[paragraphs.length - 1] ?? '').matchAll(/^BREAKING[- ]CHANGE:\s*(.+)$/gm)]
107
+ : [];
108
+ if (breakingFooterMatches.length > 0) {
119
109
  if (parts.length > 0) {
120
- // Promote the first emitted part to breaking.
110
+ // Promote the first conventional-commit part to breaking so it appears in
111
+ // the BREAKING CHANGES section with the commit's own description.
121
112
  parts[0] = { ...parts[0], breaking: true };
122
113
  }
123
- else {
124
- // No leading conventional prefix found; emit a standalone breaking entry.
114
+ // Each BREAKING CHANGE: footer line emits its own breaking entry with the
115
+ // footer's description (distinct from the commit subject).
116
+ // F-004: Multiple footer lines → multiple entries.
117
+ for (const m of breakingFooterMatches) {
125
118
  parts.push({
126
119
  type: 'misc',
127
- description: breakingFooterMatch[1].trim(),
120
+ description: m[1].trim(),
128
121
  sha: shortSha,
129
122
  breaking: true,
130
123
  });
@@ -161,11 +154,16 @@ export function parseCommitsWithMultiplePrefixes(gitOutput, repoUrl) {
161
154
  const groupedParts = {};
162
155
  const breakingChanges = [];
163
156
  for (const part of allParts) {
164
- // Collect breaking changes separately
157
+ // F-002: Breaking parts go ONLY into the BREAKING CHANGES section.
158
+ // They are NOT also added to their native section (e.g. ### Added), which
159
+ // would produce duplicate entries. The breaking indicator in the native
160
+ // section was confusing — the dedicated ### ⚠️ BREAKING CHANGES section
161
+ // already provides full visibility.
165
162
  if (part.breaking) {
166
163
  breakingChanges.push(part);
164
+ continue;
167
165
  }
168
- const sectionName = normalizeCommitType(part.type);
166
+ const sectionName = normalizeCommitType(part.type, typeMap);
169
167
  if (sectionName === false) {
170
168
  continue;
171
169
  }
@@ -174,8 +172,19 @@ export function parseCommitsWithMultiplePrefixes(gitOutput, repoUrl) {
174
172
  }
175
173
  groupedParts[sectionName].push(part);
176
174
  }
175
+ // Build the final ordered section list.
176
+ // Custom sections (from typeMap overrides) are appended after the standard order.
177
177
  const sections = [];
178
- const sectionOrder = ['### Added', '### Fixed', '### Changed', '### Removed', '### Security'];
178
+ const standardSectionOrder = [
179
+ '### Added',
180
+ '### Fixed',
181
+ '### Changed',
182
+ '### Removed',
183
+ '### Security',
184
+ ];
185
+ // Collect any custom section names not in the standard order
186
+ const customSections = Object.keys(groupedParts).filter((s) => !standardSectionOrder.includes(s));
187
+ const sectionOrder = [...standardSectionOrder, ...customSections];
179
188
  // Add BREAKING CHANGES section first if there are any
180
189
  if (breakingChanges.length > 0) {
181
190
  sections.push('### ⚠️ BREAKING CHANGES');
@@ -191,9 +200,8 @@ export function parseCommitsWithMultiplePrefixes(gitOutput, repoUrl) {
191
200
  sections.push(sectionTitle);
192
201
  sections.push(...groupedParts[sectionTitle].map((part) => {
193
202
  const scopePart = part.scope ? ` (${part.scope})` : '';
194
- const breakingIndicator = part.breaking ? ' ⚠️ BREAKING' : '';
195
203
  const linkPart = repoUrl ? ` ([${part.sha}](${repoUrl}/commit/${part.sha}))` : ` (${part.sha})`;
196
- return `- ${part.description}${scopePart}${breakingIndicator}${linkPart}`;
204
+ return `- ${part.description}${scopePart}${linkPart}`;
197
205
  }));
198
206
  sections.push('');
199
207
  }
@@ -289,7 +297,12 @@ export function populateChangelog(deps) {
289
297
  getEnv: deps.getEnv,
290
298
  warn: deps.warn,
291
299
  });
292
- const commits = parseCommitsWithMultiplePrefixes(gitOutput, repoUrl);
300
+ const typeMap = loadChangelogTypeMap({
301
+ readFileSync: deps.readFileSync,
302
+ getEnv: deps.getEnv,
303
+ warn: deps.warn,
304
+ });
305
+ const commits = parseCommitsWithMultiplePrefixes(gitOutput, repoUrl, typeMap);
293
306
  const changelog = deps.readFileSync(changelogPath, 'utf8');
294
307
  const unreleasedContent = commits && commits.trim() ? commits : 'No changes yet.';
295
308
  const unreleasedRegex = /## \[Unreleased\][\s\S]*?(?=## \[|$)/;
@@ -0,0 +1,71 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Doctor — diagnostic checklist + readiness score for release-it-preset
4
+ *
5
+ * Inspects four categories:
6
+ * 1. Environment — all known env vars, source (env vs default)
7
+ * 2. Repository — git state (branch, tag, dirty WD, upstream)
8
+ * 3. Configuration — CHANGELOG.md, .release-it.json, package.json
9
+ * 4. Summary — READY / WARNINGS / BLOCKED + score
10
+ *
11
+ * Usage:
12
+ * node dist/scripts/doctor.js
13
+ * node dist/scripts/doctor.js --json
14
+ */
15
+ import type { ExecSyncOptions } from 'node:child_process';
16
+ import { existsSync, readFileSync } from 'node:fs';
17
+ export type CheckStatus = 'PASS' | 'WARN' | 'FAIL';
18
+ export interface CheckResult {
19
+ name: string;
20
+ status: CheckStatus;
21
+ value: string;
22
+ detail?: string;
23
+ }
24
+ export interface EnvVarInfo {
25
+ name: string;
26
+ value: string | undefined;
27
+ source: 'env' | 'default' | 'unset';
28
+ defaultValue?: string;
29
+ }
30
+ export interface EnvironmentSection {
31
+ checks: CheckResult[];
32
+ vars: EnvVarInfo[];
33
+ status: CheckStatus;
34
+ }
35
+ export interface RepositorySection {
36
+ checks: CheckResult[];
37
+ status: CheckStatus;
38
+ }
39
+ export interface ConfigurationSection {
40
+ checks: CheckResult[];
41
+ status: CheckStatus;
42
+ }
43
+ export interface DoctorSummary {
44
+ pass: number;
45
+ warn: number;
46
+ fail: number;
47
+ total: number;
48
+ score: string;
49
+ status: 'READY' | 'WARNINGS' | 'BLOCKED';
50
+ recommendations: string[];
51
+ }
52
+ export interface DoctorReport {
53
+ environment: EnvironmentSection;
54
+ repository: RepositorySection;
55
+ configuration: ConfigurationSection;
56
+ summary: DoctorSummary;
57
+ }
58
+ export interface DoctorDeps {
59
+ execSync: (command: string, options?: ExecSyncOptions) => Buffer | string;
60
+ existsSync: typeof existsSync;
61
+ readFileSync: typeof readFileSync;
62
+ getEnv: (key: string) => string | undefined;
63
+ }
64
+ export declare function safeExec(command: string, deps: DoctorDeps): string | null;
65
+ export declare function collectEnvironment(deps: DoctorDeps): EnvironmentSection;
66
+ export declare function inspectRepository(deps: DoctorDeps): RepositorySection;
67
+ export declare function validateConfiguration(deps: DoctorDeps): ConfigurationSection;
68
+ export declare function summarize(report: Omit<DoctorReport, 'summary'>): DoctorSummary;
69
+ export declare function runDoctor(deps: DoctorDeps): DoctorReport;
70
+ export declare function formatHuman(report: DoctorReport): string;
71
+ export declare function formatJson(report: DoctorReport): string;
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Configurable commit-type → CHANGELOG section mapping.
3
+ *
4
+ * Resolution order (highest priority wins):
5
+ * 1. CHANGELOG_TYPE_MAP env var (JSON string)
6
+ * 2. .changelog-types.json file (project root)
7
+ * 3. Built-in defaults below
8
+ *
9
+ * A value of `false` means "skip this type entirely" (no changelog entry).
10
+ */
11
+ import type { readFileSync as ReadFileSyncFn } from 'node:fs';
12
+ /**
13
+ * Dependencies for loadChangelogTypeMap — follows the project DI pattern.
14
+ */
15
+ export interface ChangelogTypeDeps {
16
+ readFileSync: typeof ReadFileSyncFn;
17
+ getEnv: (key: string) => string | undefined;
18
+ warn: (message: string) => void;
19
+ }
20
+ /**
21
+ * Built-in commit-type → CHANGELOG section map.
22
+ * Values are `### SectionName` strings or `false` to suppress.
23
+ */
24
+ export declare const BUILTIN_TYPE_MAP: Record<string, string | false>;
25
+ /**
26
+ * Load the commit-type → CHANGELOG section mapping.
27
+ *
28
+ * Priority:
29
+ * 1. CHANGELOG_TYPE_MAP env var (JSON, merged on top of file + built-in)
30
+ * 2. .changelog-types.json project file (merged on top of built-in)
31
+ * 3. BUILTIN_TYPE_MAP (base)
32
+ *
33
+ * Malformed JSON or invalid structure → WARN + ignore that layer (fall back to lower priority).
34
+ */
35
+ export declare function loadChangelogTypeMap(deps: ChangelogTypeDeps): Record<string, string | false>;
@@ -43,13 +43,16 @@ export interface CommitPart {
43
43
  */
44
44
  export declare function extractConventionalCommitParts(commitBody: string, sha: string): CommitPart[];
45
45
  /**
46
- * Normalize commit types to standard changelog categories
46
+ * Normalize a commit type to a CHANGELOG section heading.
47
+ * Uses `typeMap` (defaults to BUILTIN_TYPE_MAP) so callers can inject
48
+ * a custom or project-level override without touching this function.
49
+ * Returns false when the type should be suppressed entirely.
47
50
  */
48
- export declare function normalizeCommitType(type: string): string | false;
51
+ export declare function normalizeCommitType(type: string, typeMap?: Record<string, string | false>): string | false;
49
52
  /**
50
53
  * Parse git log output and extract all conventional commit parts
51
54
  */
52
- export declare function parseCommitsWithMultiplePrefixes(gitOutput: string, repoUrl: string): string;
55
+ export declare function parseCommitsWithMultiplePrefixes(gitOutput: string, repoUrl: string, typeMap?: Record<string, string | false>): string;
53
56
  /**
54
57
  * Resolve the `since` baseline for changelog generation.
55
58
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oorabona/release-it-preset",
3
- "version": "0.13.1",
3
+ "version": "0.15.0",
4
4
  "description": "Shared release-it preset with OIDC trusted publishing, smart npm dist-tag selection, and monorepo per-package changelog support",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -96,6 +96,7 @@
96
96
  "rimraf": "^6.1.3",
97
97
  "tsx": "^4.21.0",
98
98
  "typescript": "^6.0.3",
99
+ "vite": "^7.3.2",
99
100
  "vitest": "^4.1.5"
100
101
  },
101
102
  "engines": {