aronrepo 3.0.1 → 3.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -20,7 +20,7 @@ function maxRelease(...releases) {
20
20
  *
21
21
  * Release notes list the groups in this order.
22
22
  */
23
- const commits = [
23
+ const commits = Object.freeze([
24
24
  {
25
25
  type: "Bump",
26
26
  scope: "Major",
@@ -139,12 +139,12 @@ const commits = [
139
139
  release: false,
140
140
  description: "Maintenance: development dependencies, lockfiles, metadata, agent guidance, and unpublished workspaces."
141
141
  }
142
- ];
142
+ ].map((commit) => Object.freeze(commit)));
143
143
  /**
144
144
  * Types from earlier Aronrepo versions. They still resolve in history so old
145
145
  * commits release as before, but `aronrepo check` rejects them in new commits.
146
146
  */
147
- const legacyCommits = [
147
+ const legacyCommits = Object.freeze([
148
148
  {
149
149
  type: "Feat",
150
150
  replacement: "Add"
@@ -161,7 +161,7 @@ const legacyCommits = [
161
161
  type: "Misc",
162
162
  replacement: "Chore"
163
163
  }
164
- ];
164
+ ].map((commit) => Object.freeze(commit)));
165
165
  const types = [...new Set(commits.map(({ type }) => type))];
166
166
  const legacyTypes = legacyCommits.map(({ type }) => type);
167
167
  const releaseCommits = commits.filter(({ release }) => release !== false);
@@ -186,6 +186,11 @@ const defaultReleasePathExcludes = [
186
186
  "**/test/**",
187
187
  "**/__tests__/**",
188
188
  "**/*.{test,spec}.*",
189
+ "**/*.test-d.*",
190
+ "**/__snapshots__/**",
191
+ "**/__fixtures__/**",
192
+ "**/__mocks__/**",
193
+ "**/fixtures/**",
189
194
  "**/benchmarks/**",
190
195
  "**/examples/**",
191
196
  "**/CHANGELOG.md",
@@ -198,17 +203,40 @@ function defineConfig(config) {
198
203
  function getReleasePolicy(config = {}) {
199
204
  const mode = config.release?.mode ?? "explicit";
200
205
  if (!releaseModes.includes(mode)) throw new Error(`Unknown Aronrepo release mode "${String(mode)}". Use one of: ${releaseModes.join(", ")}.`);
206
+ const paths = config.release?.paths;
207
+ if (paths !== void 0 && paths !== false) validateReleasePaths(paths);
208
+ const assets = config.release?.assets;
209
+ if (assets !== void 0) validateReleaseAssets(assets);
201
210
  return {
202
211
  mode,
203
- ...config.release?.paths !== void 0 ? { paths: config.release.paths } : {}
212
+ ...paths !== void 0 ? { paths } : {},
213
+ ...assets !== void 0 ? { assets } : {}
204
214
  };
205
215
  }
216
+ function validateReleaseAssets(assets) {
217
+ if (!(Array.isArray(assets) && assets.every((asset) => typeof asset === "object" && asset !== null && typeof asset.path === "string" && ["name", "label"].every((key) => asset[key] === void 0 || typeof asset[key] === "string")))) throw new Error("`release.assets` must be an array of `{ path, name?, label? }` objects.");
218
+ }
219
+ function validateReleasePaths(paths) {
220
+ const isGlobs = (value) => value === void 0 || Array.isArray(value) && value.every((glob) => typeof glob === "string");
221
+ if (typeof paths !== "object" || paths === null || Array.isArray(paths)) throw new Error("`release.paths` must be `false` or an object with `include` and `exclude` glob arrays.");
222
+ const { include, exclude } = paths;
223
+ if (!isGlobs(include)) throw new Error("`release.paths.include` must be an array of globs.");
224
+ if (!isGlobs(exclude)) throw new Error("`release.paths.exclude` must be an array of globs.");
225
+ }
206
226
  const defaultReleasePolicy = { mode: defaultReleaseMode };
207
- /** Returns a matcher for release paths, or `undefined` when every path may release. */
227
+ /**
228
+ * Returns a matcher for release paths, or `undefined` when every path may release.
229
+ * `include` defaults to every path; `!glob` entries in `include` exclude, as in
230
+ * `package.json` `files`, instead of matching everything else.
231
+ */
208
232
  function createReleasePathMatcher(paths) {
209
- if (!paths || !paths.include) return void 0;
210
- const include = picomatch(paths.include.length ? paths.include : ["!**"], { dot: true });
211
- const exclude = picomatch(paths.exclude ?? defaultReleasePathExcludes, { dot: true });
233
+ if (!paths) return void 0;
234
+ const globs = paths.include ?? ["**"];
235
+ const positive = globs.filter((glob) => !glob.startsWith("!"));
236
+ const negated = globs.filter((glob) => glob.startsWith("!")).map((glob) => glob.slice(1));
237
+ if (!positive.length) return () => false;
238
+ const include = picomatch(positive, { dot: true });
239
+ const exclude = picomatch([...paths.exclude ?? defaultReleasePathExcludes, ...negated], { dot: true });
212
240
  return (file) => include(file) && !exclude(file);
213
241
  }
214
242
  /**
@@ -224,61 +252,92 @@ function capRelease(release, type, policy) {
224
252
  if (policy.mode === "explicit" && type !== "Bump" && (release === "major" || release === "minor")) return "patch";
225
253
  return release;
226
254
  }
227
- const headerPattern = /^([A-Z]\w*)(?:\(([0-9A-Z`_#~].*)\))?: ([0-9A-Z`_#~].*)$/;
228
- const breakingHeaderPattern = /^([A-Z]\w*)(?:\(([^)]*)\))?!: (.+)$/;
229
- const breakingNotePattern = /^BREAKING[ -]CHANGE:/m;
230
- const footerLinePattern = /^BREAKING[ -]CHANGE:/;
231
- const githubRevertPattern = /^Revert "([\s\S]+)"$/;
232
- const revertHashPattern = /This reverts commit ([0-9a-f]{7,40})/i;
255
+ /**
256
+ * The first character of a scope or summary: an uppercase letter, a letter
257
+ * without case (such as CJK), a digit, or a code, issue, or emphasis marker.
258
+ */
259
+ const startCharacter = String.raw`[\p{Lu}\p{Lt}\p{Lo}\p{Nd}\x60_#~]`;
260
+ const startPattern = new RegExp(`^${startCharacter}`, "u");
261
+ const headerPattern = new RegExp(String.raw`^([A-Z]\w*)(?:\((${startCharacter}[^)]*)\))?: (${startCharacter}.*)$`, "u");
262
+ const breakingHeaderPattern = new RegExp(String.raw`^([A-Z]\w*)(?:\((${startCharacter}[^)]*)\))?!: (${startCharacter}.*)$`, "u");
263
+ const footerStartPattern = /^BREAKING[ -]CHANGE:[ \t]*(.*)$/;
264
+ const githubRevertPattern = /^(Revert|Reapply) "([\s\S]+)"(?: \(#\d+\))?$/;
265
+ const revertHashPattern = /This reverts commit ([0-9a-f]{7,40})/gi;
266
+ const fencePattern = /^\s*(```|~~~)/;
233
267
  const trailerPattern = /^[\w-]+: /;
234
- /** `BREAKING CHANGE:` footer texts; each runs until a blank line, another footer, or a git trailer. */
235
- function readBreakingNotes(lines) {
268
+ /**
269
+ * `BREAKING CHANGE:` footers outside code fences. Each runs, line breaks kept,
270
+ * until another footer or a git trailer, as Conventional Commits footers do.
271
+ */
272
+ function readBreakingFooters(lines) {
236
273
  const notes = [];
274
+ const starts = [];
237
275
  let current;
238
- for (const line of lines) {
239
- const start = /^BREAKING[ -]CHANGE:\s*(.*)$/.exec(line);
276
+ let fenced = false;
277
+ const finish = () => {
278
+ if (current) notes.push(current.join("\n").trim());
279
+ current = void 0;
280
+ };
281
+ lines.forEach((line, index) => {
282
+ if (fencePattern.test(line)) fenced = !fenced;
283
+ const start = fenced ? null : footerStartPattern.exec(line);
240
284
  if (start) {
241
- if (current) notes.push(current.join(" ").trim());
285
+ finish();
286
+ starts.push(index);
242
287
  current = [start[1]];
243
- } else if (current && line.trim() && !trailerPattern.test(line)) current.push(line.trim());
244
- else if (current) {
245
- notes.push(current.join(" ").trim());
246
- current = void 0;
247
- }
248
- }
249
- if (current) notes.push(current.join(" ").trim());
250
- return notes.filter(Boolean);
288
+ } else if (current && !fenced && trailerPattern.test(line)) finish();
289
+ else if (current) current.push(line.trimEnd());
290
+ });
291
+ finish();
292
+ return {
293
+ starts,
294
+ notes: notes.filter(Boolean)
295
+ };
296
+ }
297
+ function parseHeader(header) {
298
+ const regular = headerPattern.exec(header);
299
+ const match = regular ?? breakingHeaderPattern.exec(header);
300
+ return match ? {
301
+ type: match[1],
302
+ scope: match[2],
303
+ subject: match[3],
304
+ breaking: !regular
305
+ } : void 0;
251
306
  }
252
307
  function parseCommit(message, hash) {
253
- const [header = "", ...rest] = message.replace(/\r\n/g, "\n").trim().split("\n");
308
+ const [rawHeader = "", ...rest] = message.replace(/\r\n?/g, "\n").trim().split("\n");
309
+ const header = rawHeader.trimEnd();
254
310
  const body = rest.join("\n").trim();
255
311
  const parsed = {
256
312
  header,
257
313
  body,
258
314
  ...hash ? { hash } : {}
259
315
  };
316
+ const footers = readBreakingFooters(rest);
260
317
  const layout = [];
261
318
  if (rest.length && rest[0].trim()) layout.push("body-leading-blank");
262
- if (rest.some((line, index) => index > 0 && footerLinePattern.test(line) && rest[index - 1].trim())) layout.push("footer-leading-blank");
319
+ if (footers.starts.some((index) => index > 0 && rest[index - 1].trim())) layout.push("footer-leading-blank");
263
320
  if (layout.length) parsed.layout = layout;
264
- const breakingNotes = readBreakingNotes(rest);
265
- if (breakingNotes.length) parsed.breakingNotes = breakingNotes;
266
- const regular = headerPattern.exec(header);
267
- const match = regular ?? breakingHeaderPattern.exec(header);
321
+ if (footers.notes.length) parsed.breakingNotes = footers.notes;
322
+ const match = parseHeader(header);
268
323
  if (match) {
269
- parsed.type = match[1];
270
- if (match[2]) parsed.scope = match[2];
271
- parsed.subject = match[3];
272
- if (!regular) parsed.breaking = "header";
324
+ parsed.type = match.type;
325
+ if (match.scope) parsed.scope = match.scope;
326
+ parsed.subject = match.subject;
327
+ if (match.breaking) parsed.breaking = "header";
273
328
  }
274
- if (!parsed.breaking && breakingNotePattern.test(body)) parsed.breaking = "footer";
329
+ if (!parsed.breaking && footers.starts.length) parsed.breaking = "footer";
275
330
  const githubRevert = githubRevertPattern.exec(header);
276
331
  if (githubRevert || parsed.type === "Revert") {
277
- const revertedHeader = githubRevert?.[1] ?? (parsed.subject && headerPattern.test(parsed.subject) ? parsed.subject : void 0);
278
- const revertedHash = revertHashPattern.exec(body)?.[1];
332
+ const revertedHeader = githubRevert?.[2] ?? (parsed.subject && parseHeader(parsed.subject) ? parsed.subject : void 0);
333
+ const hashes = [...new Set([...body.matchAll(revertHashPattern)].map((found) => found[1].toLowerCase()))];
279
334
  parsed.revert = {
280
335
  ...revertedHeader ? { header: revertedHeader } : {},
281
- ...revertedHash ? { hash: revertedHash } : {}
336
+ ...hashes.length ? {
337
+ hash: hashes[0],
338
+ hashes
339
+ } : {},
340
+ ...githubRevert?.[1] === "Reapply" ? { reapply: true } : {}
282
341
  };
283
342
  }
284
343
  return parsed;
@@ -295,7 +354,7 @@ const nonReleaseAlternatives = [
295
354
  "Example"
296
355
  ];
297
356
  function suggestNonReleaseHeader(commit) {
298
- const subject = commit.subject ?? "";
357
+ const subject = (commit.subject ?? commit.header).replace(/\.+$/, "");
299
358
  const scope = commit.scope ? `(${commit.scope})` : "";
300
359
  switch (commit.type) {
301
360
  case "Docs": return `Docs: ${subject}`;
@@ -303,17 +362,18 @@ function suggestNonReleaseHeader(commit) {
303
362
  default: return `Chore${scope}: ${subject}`;
304
363
  }
305
364
  }
306
- const looseHeaderPattern = /^(\w+)(?:\(([^)]*)\))?(!)?: ?(.*)$/;
307
- function diagnoseHeader(header) {
365
+ const looseHeaderPattern = /^(\w+)(?:\(([^)]*)\))?(!)?:(\s*)(.*)$/;
366
+ function diagnoseHeader(header, mode) {
308
367
  const format = "Use `Type(Scope): Summary` or `Type: Summary` with an Aronrepo commit type.";
309
368
  const match = looseHeaderPattern.exec(header);
310
369
  if (!match) return format;
311
- const [, type, scope, bang, subject] = match;
312
- if (!/^[A-Z]/.test(type)) return `The type \`${type}\` must be PascalCase, such as \`${type[0].toUpperCase()}${type.slice(1)}\`. ${format}`;
313
- if (bang) return `Aronrepo headers do not use \`!\`. ${format}`;
314
- if (scope !== void 0 && !/^[0-9A-Z`_#~]/.test(scope)) return `The scope \`${scope}\` must start with an uppercase letter or digit. ${format}`;
370
+ const [, type, scope, bang, space, subject] = match;
371
+ if (!/^[A-Z]/.test(type)) return `The type \`${type}\` must be PascalCase, such as \`${normalizeType(`${type[0].toUpperCase()}${type.slice(1)}`)}\`. ${format}`;
372
+ if (bang && mode === "explicit") return `Aronrepo headers do not use \`!\` in explicit mode. ${format}`;
373
+ if (scope !== void 0 && !startPattern.test(scope)) return `The scope \`${scope}\` must start with an uppercase letter or digit. ${format}`;
374
+ if (space !== " ") return `Put exactly one space after the colon. ${format}`;
315
375
  if (!subject) return `The summary is empty. ${format}`;
316
- if (!/^[0-9A-Z`_#~]/.test(subject)) return `The summary must start with an uppercase letter or digit. ${format}`;
376
+ if (!startPattern.test(subject)) return `The summary must start with an uppercase letter, a digit, or a letter without case such as CJK. ${format}`;
317
377
  return format;
318
378
  }
319
379
  function formatRelease(release) {
@@ -328,6 +388,7 @@ function resolveCommitRelease(input, policy = defaultReleasePolicy, options = {}
328
388
  ...commit.scope ? { scope: commit.scope } : {},
329
389
  ...commit.subject ? { subject: commit.subject } : {},
330
390
  ...commit.breakingNotes ? { breakingNotes: commit.breakingNotes } : {},
391
+ ...commit.breaking ? { breaking: commit.breaking } : {},
331
392
  release: false,
332
393
  reasons: [],
333
394
  violations: []
@@ -337,7 +398,7 @@ function resolveCommitRelease(input, policy = defaultReleasePolicy, options = {}
337
398
  decision.reasons.push("Header does not follow `Type(Scope): Summary`; no release.");
338
399
  decision.violations.push({
339
400
  rule: "header-format",
340
- message: diagnoseHeader(commit.header)
401
+ message: diagnoseHeader(commit.header, policy.mode)
341
402
  });
342
403
  return decision;
343
404
  } else if (!types.includes(commit.type) && !legacyTypes.includes(commit.type)) {
@@ -348,13 +409,21 @@ function resolveCommitRelease(input, policy = defaultReleasePolicy, options = {}
348
409
  });
349
410
  return decision;
350
411
  } else if (!findCommitRule(commit.type, commit.scope)) {
412
+ const scoped = commits.filter(({ type }) => type === commit.type).map(({ type, scope }) => `\`${type}(${scope})\``).join(", ");
351
413
  decision.reasons.push(`\`${commit.type}${commit.scope ? `(${commit.scope})` : ""}\` is not an Aronrepo commit type; no release.`);
352
414
  decision.violations.push({
353
415
  rule: "unknown-type",
354
- message: `\`${commit.type}\` needs a scope: ${commits.filter(({ type }) => type === commit.type).map(({ type, scope }) => `\`${type}(${scope})\``).join(", ")}.`
416
+ message: commit.scope ? `\`${commit.type}(${commit.scope})\` has an unknown scope. Use one of: ${scoped}.` : `\`${commit.type}\` needs a scope: ${scoped}.`
355
417
  });
356
418
  return decision;
357
419
  }
420
+ const knownScope = commit.scope && commits.find(({ type, scope }) => type === normalizeType(commit.type) && scope && scope !== commit.scope && scope.toLowerCase() === commit.scope.toLowerCase())?.scope;
421
+ if (knownScope) decision.violations.push({
422
+ rule: "scope-case",
423
+ severity: "warning",
424
+ message: `\`${commit.type}(${commit.scope})\` is treated as \`${commit.type}\`. Write \`${commit.type}(${knownScope})\` for that rule.`,
425
+ suggestion: `${commit.type}(${knownScope}): ${commit.subject ?? ""}`
426
+ });
358
427
  if (commit.subject?.endsWith(".")) decision.violations.push({
359
428
  rule: "subject-full-stop",
360
429
  message: "The summary must not end with a period.",
@@ -377,8 +446,15 @@ function resolveCommitRelease(input, policy = defaultReleasePolicy, options = {}
377
446
  }
378
447
  let release;
379
448
  if (decision.type === "Revert" && commit.revert?.header) {
380
- release = resolveCommitRelease(commit.revert.header, policy, options).release;
381
- decision.reasons.push(`Reverts \`${commit.revert.header}\`, which releases ${formatRelease(release)}.`);
449
+ const reverted = resolveCommitRelease(commit.revert.header, policy, options);
450
+ const verb = commit.revert.reapply ? "Reapplies" : "Reverts";
451
+ if (reverted.type === "Bump" && reverted.release) {
452
+ release = "patch";
453
+ decision.reasons.push(`${verb} \`${commit.revert.header}\`; reverting a \`Bump\` releases patch.`);
454
+ } else {
455
+ release = reverted.release;
456
+ decision.reasons.push(`${verb} \`${commit.revert.header}\`, which releases ${formatRelease(release)}.`);
457
+ }
382
458
  } else {
383
459
  const rule = findCommitRule(decision.type, decision.scope);
384
460
  release = capRelease(rule.release, decision.type, policy);
@@ -402,10 +478,16 @@ function resolveCommitRelease(input, policy = defaultReleasePolicy, options = {}
402
478
  const isBump = decision.type === "Bump";
403
479
  if (release && !isBump && options.files && options.isReleasePath) {
404
480
  if (!options.files.filter(options.isReleasePath).length) {
481
+ const label = `\`${decision.type}${decision.scope ? `(${decision.scope})` : ""}\``;
482
+ const onlyBreaking = commit.type !== void 0 && findCommitRule(commit.type, commit.scope)?.release === false;
405
483
  decision.reasons.push("No changed file ships in a published package; no release.");
406
- decision.violations.push({
484
+ decision.violations.push(onlyBreaking ? {
485
+ rule: "release-paths",
486
+ message: `${label} releases ${formatRelease(release)} only because of its breaking marker, but the commit changes no release paths. Remove the breaking marker.`,
487
+ ...commit.breaking === "header" ? { suggestion: commit.header.replace("!:", ":") } : {}
488
+ } : {
407
489
  rule: "release-paths",
408
- message: `\`${decision.type}${decision.scope ? `(${decision.scope})` : ""}\` releases ${formatRelease(release)}, but the commit changes no release paths. Use a non-release type: ${nonReleaseAlternatives.join(", ")}.`,
490
+ message: `${label} releases ${formatRelease(release)}, but the commit changes no release paths. Use a non-release type: ${nonReleaseAlternatives.join(", ")}.`,
409
491
  suggestion: suggestNonReleaseHeader({
410
492
  ...commit,
411
493
  type: decision.type
@@ -423,16 +505,27 @@ function resolveRelease(inputs, policy = defaultReleasePolicy, options = {}) {
423
505
  ...options,
424
506
  ...inputs[index].files ? { files: inputs[index].files } : {}
425
507
  }));
426
- parsed.forEach((commit, index) => {
427
- const revertedHash = commit.revert?.hash;
428
- if (!revertedHash) return;
429
- const revertedIndex = parsed.findIndex((candidate) => candidate.hash?.startsWith(revertedHash) || candidate.hash && revertedHash.startsWith(candidate.hash));
430
- if (revertedIndex === -1 || revertedIndex === index) return;
431
- for (const [target, other] of [[index, revertedIndex], [revertedIndex, index]]) {
432
- decisions[target].release = false;
433
- decisions[target].reasons.push(`Cancelled by \`${parsed[other].header}\` in the same release.`);
508
+ const cancelled = /* @__PURE__ */ new Set();
509
+ const cancel = (target, other) => {
510
+ cancelled.add(target);
511
+ decisions[target].release = false;
512
+ decisions[target].reasons.push(`Cancelled by \`${parsed[other].header}\` in the same release.`);
513
+ };
514
+ for (let index = parsed.length - 1; index >= 0; index--) {
515
+ const hashes = parsed[index].revert?.hashes ?? [];
516
+ if (!hashes.length || cancelled.has(index)) continue;
517
+ const targets = hashes.map((revertedHash) => {
518
+ const matches = parsed.flatMap((candidate, candidateIndex) => candidateIndex < index && candidate.hash?.toLowerCase().startsWith(revertedHash) ? [candidateIndex] : []);
519
+ return matches.length === 1 && !cancelled.has(matches[0]) ? matches[0] : void 0;
520
+ });
521
+ for (const target of targets) if (target !== void 0) cancel(target, index);
522
+ if (targets.every((target) => target !== void 0)) {
523
+ const reverted = targets.map((target) => parsed[target].header).join("`, `");
524
+ cancelled.add(index);
525
+ decisions[index].release = false;
526
+ decisions[index].reasons.push(`Cancelled by \`${reverted}\` in the same release.`);
434
527
  }
435
- });
528
+ }
436
529
  return {
437
530
  release: maxRelease(...decisions.map(({ release }) => release)),
438
531
  commits: decisions
@@ -570,7 +663,7 @@ function getReleaseTable(policy = defaultReleasePolicy) {
570
663
  none: []
571
664
  };
572
665
  for (const { type, scope } of commits) rows[getReleaseType(type, scope, policy) || "none"].push(formatRule(type, scope));
573
- if (policy.mode === "auto") rows.major.push("`BREAKING CHANGE:` footer");
666
+ if (policy.mode === "auto") rows.major.push("`BREAKING CHANGE:` footer", "`Type!:` header");
574
667
  return rows;
575
668
  }
576
669
  const agentsBlockStart = "<!-- aronrepo:commit-policy:start -->";
@@ -578,7 +671,8 @@ const agentsBlockEnd = "<!-- aronrepo:commit-policy:end -->";
578
671
  /** Renders the generated commit policy block for AGENTS.md, including its markers. */
579
672
  function renderAgentsBlock(policy = defaultReleasePolicy, options = {}) {
580
673
  const agent = getAgentCommitPolicy(policy, options);
581
- const paths = options.releasePaths;
674
+ const paths = options.releasePaths ?? (policy.paths || void 0);
675
+ const excludes = paths ? paths.exclude ?? defaultReleasePathExcludes : [];
582
676
  const typeRows = commits.map(({ type, scope, description }) => `| ${formatRule(type, scope)} | ${getReleaseType(type, scope, policy) || "none"} | ${description} |`);
583
677
  return [
584
678
  agentsBlockStart,
@@ -586,7 +680,7 @@ function renderAgentsBlock(policy = defaultReleasePolicy, options = {}) {
586
680
  "",
587
681
  "## Commit and Release Policy",
588
682
  "",
589
- `Release mode: **${policy.mode}**. ${policy.mode === "explicit" ? "Only the repository owner decides minor and major versions, with `Bump(Minor)` and `Bump(Major)` commits." : "`Add` releases minor versions, and `BREAKING CHANGE:` footers release major versions."}`,
683
+ `Release mode: **${policy.mode}**. ${policy.mode === "explicit" ? "Only the repository owner decides minor and major versions, with `Bump(Minor)` and `Bump(Major)` commits." : "`Add` releases minor versions, and `BREAKING CHANGE:` footers and `Type!:` headers release major versions."}`,
590
684
  "",
591
685
  "Commit headers and PR titles use `Type(Scope): Summary` or `Type: Summary`: a PascalCase type, an optional scope for the package or area, and a sentence-case summary without a trailing period.",
592
686
  "",
@@ -613,13 +707,11 @@ function renderAgentsBlock(policy = defaultReleasePolicy, options = {}) {
613
707
  "",
614
708
  "### Release paths",
615
709
  "",
616
- ...paths?.include ? [
710
+ ...!paths ? ["Every path can release a version in this repository."] : paths.include?.length === 0 ? ["No path can release a version in this repository."] : [...paths.include ? [
617
711
  "Only changes to these files can release a version:",
618
712
  "",
619
- ...paths.include.map((glob) => `- \`${glob}\``),
620
- "",
621
- `Excluding: ${(paths.exclude ?? defaultReleasePathExcludes).map((glob) => `\`${glob}\``).join(", ")}.`
622
- ] : ["Every path can release a version in this repository."],
713
+ ...paths.include.map((glob) => `- \`${glob}\``)
714
+ ] : ["Every path can release a version, except the excluded ones."], ...excludes.length ? ["", `Excluding: ${excludes.map((glob) => `\`${glob}\``).join(", ")}.`] : []],
623
715
  "",
624
716
  "### Before handing off commits",
625
717
  "",
@@ -711,34 +803,100 @@ function getNextVersion(channel, release, tags) {
711
803
  const target = stable ? semver.inc(stable, release) : initialVersion;
712
804
  if (!channel.prerelease) return target;
713
805
  const previous = highest(channelVersions(reachable, channel.prerelease).filter((version) => !stable || semver.gt(version, stable)));
714
- const core = previous && semver.gte(coreVersion(previous), target) ? coreVersion(previous) : target;
715
- const numbers = channelVersions(versionsOf(tags.all), channel.prerelease).filter((version) => coreVersion(version) === core).map((version) => Number(semver.prerelease(version)?.[1] ?? 0));
806
+ let core = previous && semver.gte(coreVersion(previous), target) ? coreVersion(previous) : target;
807
+ const all = versionsOf(tags.all);
808
+ const released = highest(stableVersions(all));
809
+ if (released && semver.lte(core, released)) core = semver.inc(released, release);
810
+ const numbers = channelVersions(all, channel.prerelease).filter((version) => coreVersion(version) === core).map((version) => semver.prerelease(version)?.[1] ?? 0).filter((number) => Number.isInteger(number));
716
811
  return `${core}-${channel.prerelease}.${Math.max(0, ...numbers) + 1}`;
717
812
  }
718
813
  // src/notes.ts
719
814
  const breakingTitle = "⚠ Breaking Changes";
720
815
  function groupOf(decision) {
721
- if (decision.type === "Revert") return "Reverts";
722
816
  return findCommitRule(decision.type, decision.scope)?.group;
723
817
  }
818
+ /** Splits text into plain parts and CommonMark code spans: a backtick run closed by a run of the same length. */
819
+ function splitCodeSpans(text) {
820
+ const parts = [];
821
+ let plain = "";
822
+ let index = 0;
823
+ while (index < text.length) {
824
+ if (text[index] === "\\" && text[index + 1] === "`") {
825
+ plain += text.slice(index, index + 2);
826
+ index += 2;
827
+ continue;
828
+ }
829
+ if (text[index] !== "`") {
830
+ plain += text[index++];
831
+ continue;
832
+ }
833
+ const run = /^`+/.exec(text.slice(index))[0];
834
+ const close = new RegExp(`(?<!\`)${run}(?!\`)`).exec(text.slice(index + run.length));
835
+ if (!close) {
836
+ plain += run;
837
+ index += run.length;
838
+ continue;
839
+ }
840
+ if (plain) parts.push({
841
+ code: false,
842
+ text: plain
843
+ });
844
+ plain = "";
845
+ const end = index + run.length + close.index + run.length;
846
+ parts.push({
847
+ code: true,
848
+ text: text.slice(index, end)
849
+ });
850
+ index = end;
851
+ }
852
+ if (plain) parts.push({
853
+ code: false,
854
+ text: plain
855
+ });
856
+ return parts;
857
+ }
858
+ /**
859
+ * Keeps commit text from acting on GitHub outside code spans: `<` would start
860
+ * HTML, as in `Array<string>`, and `@name` or `@scope/pkg` would notify a user
861
+ * or team on every release.
862
+ */
863
+ function escapeMarkdown(text) {
864
+ return splitCodeSpans(text).map(({ code, text: part }) => code ? part : part.replace(/</g, "&lt;").replace(/(^|[^\w`])(@[A-Za-z0-9][\w-]*(?:\/[\w.-]+)?)/g, "$1`$2`")).join("");
865
+ }
866
+ /** A list item; continuation lines are indented so they stay in the item. */
867
+ function listItem(text) {
868
+ return `- ${text.split("\n").map((line, index) => index && line ? ` ${line}` : line).join("\n")}`;
869
+ }
724
870
  function formatEntry(decision, repositoryUrl) {
725
- const summary = decision.subject ?? decision.header;
726
- const scope = decision.scope ? `**${decision.scope}:** ` : "";
871
+ const summary = escapeMarkdown(decision.subject ?? decision.header);
872
+ const scope = decision.scope ? `**${escapeMarkdown(decision.scope)}:** ` : "";
727
873
  const hash = decision.hash ? decision.hash.slice(0, 7) : void 0;
728
874
  return `- ${scope}${summary}${hash ? repositoryUrl ? ` ([${hash}](${repositoryUrl}/commit/${decision.hash}))` : ` (${hash})` : ""}`;
729
875
  }
876
+ /** The page that lists every change of a release: the compare view, or the commits of a first release. */
877
+ function getChangelogUrl(repositoryUrl, version, previousTag) {
878
+ const base = repositoryUrl.replace(/\/+$/, "");
879
+ const tag = `v${version.replace(/^v/, "")}`;
880
+ return previousTag ? `${base}/compare/${previousTag}...${tag}` : `${base}/commits/${tag}`;
881
+ }
730
882
  /**
731
883
  * Markdown release notes. Only commits that release appear, grouped by type in
732
884
  * the order of the commit table; `BREAKING CHANGE:` footers of major releases
733
885
  * come first.
734
886
  */
735
887
  function renderReleaseNotes(options) {
736
- const { version, previousTag, repositoryUrl } = options;
737
- const tag = `v${version}`;
738
- const lines = [`## ${repositoryUrl && previousTag ? `[${version}](${repositoryUrl}/compare/${previousTag}...${tag})` : version}${options.date ? ` (${options.date})` : ""}`];
888
+ const { previousTag } = options;
889
+ const version = options.version.replace(/^v/, "");
890
+ const repositoryUrl = options.repositoryUrl?.replace(/\/+$/, "");
891
+ const changelogUrl = repositoryUrl ? getChangelogUrl(repositoryUrl, version, previousTag) : void 0;
892
+ const sections = [];
739
893
  const released = options.commits.filter(({ release }) => release);
740
- const breaking = released.filter(({ release }) => release === "major").flatMap(({ breakingNotes }) => breakingNotes ?? []);
741
- if (breaking.length) lines.push("", `### ${breakingTitle}`, "", ...breaking.map((note) => `- ${note}`));
894
+ const breaking = released.filter(({ release }) => release === "major").flatMap(({ breakingNotes, breaking: marker, subject }) => breakingNotes?.length ? breakingNotes : marker === "header" && subject ? [subject] : []);
895
+ if (breaking.length) sections.push([
896
+ `### ${breakingTitle}`,
897
+ "",
898
+ ...breaking.map((note) => listItem(escapeMarkdown(note)))
899
+ ]);
742
900
  const groups = /* @__PURE__ */ new Map();
743
901
  for (const decision of released) {
744
902
  const group = groupOf(decision);
@@ -749,11 +907,19 @@ function renderReleaseNotes(options) {
749
907
  const entries = groups.get(group);
750
908
  if (!entries) continue;
751
909
  const sorted = [...entries].sort((a, b) => (a.scope ?? "").localeCompare(b.scope ?? "", "en") || (a.subject ?? a.header).localeCompare(b.subject ?? b.header, "en"));
752
- lines.push("", `### ${group}`, "", ...sorted.map((decision) => formatEntry(decision, repositoryUrl)));
910
+ sections.push([
911
+ `### ${group}`,
912
+ "",
913
+ ...sorted.map((decision) => formatEntry(decision, repositoryUrl))
914
+ ]);
753
915
  }
754
- if (!breaking.length && !groups.size) lines.push("", "No changes to published packages.");
755
- return `${lines.join("\n")}\n`;
756
- }
757
- export { agentCommitPolicy, agentsBlockEnd, agentsBlockStart, commits, compareRelease, createReleasePathMatcher, defaultReleaseMode, defaultReleasePathExcludes, defaultReleasePolicy, defineConfig, findCommitRule, getAgentCommitPolicy, getBaseTag, getLatestStableVersion, getNextVersion, getReleaseChannel, getReleasePolicy, getReleaseTable, getReleaseType, headerPattern, initialVersion, legacyCommits, legacyTypes, maxRelease, nonReleaseAlternatives, nonReleaseCommits, nonReleaseTypes, normalizeType, parseCommit, parseVersionTag, prereleaseChannels, releaseBranches, releaseCommits, releaseGroups, releaseLevels, releaseModes, releaseTypes, renderAgentsBlock, renderReleaseNotes, resolveCommitRelease, resolveRelease, toVersionTag, types };
916
+ if (!sections.length) sections.push([released.length ? "Version bump only; no other changes to published packages." : "No changes to published packages."]);
917
+ if (options.heading) {
918
+ const title = changelogUrl ? `[${version}](${changelogUrl})` : version;
919
+ sections.unshift([`## ${title}${options.date ? ` (${options.date})` : ""}`]);
920
+ } else if (changelogUrl) sections.push([`**Full Changelog**: ${changelogUrl}`]);
921
+ return `${sections.map((section) => section.join("\n")).join("\n\n")}\n`;
922
+ }
923
+ export { agentCommitPolicy, agentsBlockEnd, agentsBlockStart, commits, compareRelease, createReleasePathMatcher, defaultReleaseMode, defaultReleasePathExcludes, defaultReleasePolicy, defineConfig, findCommitRule, getAgentCommitPolicy, getBaseTag, getChangelogUrl, getLatestStableVersion, getNextVersion, getReleaseChannel, getReleasePolicy, getReleaseTable, getReleaseType, headerPattern, initialVersion, legacyCommits, legacyTypes, maxRelease, nonReleaseAlternatives, nonReleaseCommits, nonReleaseTypes, normalizeType, parseCommit, parseVersionTag, prereleaseChannels, releaseBranches, releaseCommits, releaseGroups, releaseLevels, releaseModes, releaseTypes, renderAgentsBlock, renderReleaseNotes, resolveCommitRelease, resolveRelease, toVersionTag, types };
758
924
 
759
925
  //# sourceMappingURL=index.js.map