aronrepo 3.0.0 → 3.0.2

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,34 @@ 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);
201
208
  return {
202
209
  mode,
203
- ...config.release?.paths !== void 0 ? { paths: config.release.paths } : {}
210
+ ...paths !== void 0 ? { paths } : {}
204
211
  };
205
212
  }
213
+ function validateReleasePaths(paths) {
214
+ const isGlobs = (value) => value === void 0 || Array.isArray(value) && value.every((glob) => typeof glob === "string");
215
+ 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.");
216
+ const { include, exclude } = paths;
217
+ if (!isGlobs(include)) throw new Error("`release.paths.include` must be an array of globs.");
218
+ if (!isGlobs(exclude)) throw new Error("`release.paths.exclude` must be an array of globs.");
219
+ }
206
220
  const defaultReleasePolicy = { mode: defaultReleaseMode };
207
- /** Returns a matcher for release paths, or `undefined` when every path may release. */
221
+ /**
222
+ * Returns a matcher for release paths, or `undefined` when every path may release.
223
+ * `include` defaults to every path; `!glob` entries in `include` exclude, as in
224
+ * `package.json` `files`, instead of matching everything else.
225
+ */
208
226
  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 });
227
+ if (!paths) return void 0;
228
+ const globs = paths.include ?? ["**"];
229
+ const positive = globs.filter((glob) => !glob.startsWith("!"));
230
+ const negated = globs.filter((glob) => glob.startsWith("!")).map((glob) => glob.slice(1));
231
+ if (!positive.length) return () => false;
232
+ const include = picomatch(positive, { dot: true });
233
+ const exclude = picomatch([...paths.exclude ?? defaultReleasePathExcludes, ...negated], { dot: true });
212
234
  return (file) => include(file) && !exclude(file);
213
235
  }
214
236
  /**
@@ -224,61 +246,92 @@ function capRelease(release, type, policy) {
224
246
  if (policy.mode === "explicit" && type !== "Bump" && (release === "major" || release === "minor")) return "patch";
225
247
  return release;
226
248
  }
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;
249
+ /**
250
+ * The first character of a scope or summary: an uppercase letter, a letter
251
+ * without case (such as CJK), a digit, or a code, issue, or emphasis marker.
252
+ */
253
+ const startCharacter = String.raw`[\p{Lu}\p{Lt}\p{Lo}\p{Nd}\x60_#~]`;
254
+ const startPattern = new RegExp(`^${startCharacter}`, "u");
255
+ const headerPattern = new RegExp(String.raw`^([A-Z]\w*)(?:\((${startCharacter}[^)]*)\))?: (${startCharacter}.*)$`, "u");
256
+ const breakingHeaderPattern = new RegExp(String.raw`^([A-Z]\w*)(?:\((${startCharacter}[^)]*)\))?!: (${startCharacter}.*)$`, "u");
257
+ const footerStartPattern = /^BREAKING[ -]CHANGE:[ \t]*(.*)$/;
258
+ const githubRevertPattern = /^(Revert|Reapply) "([\s\S]+)"(?: \(#\d+\))?$/;
259
+ const revertHashPattern = /This reverts commit ([0-9a-f]{7,40})/gi;
260
+ const fencePattern = /^\s*(```|~~~)/;
233
261
  const trailerPattern = /^[\w-]+: /;
234
- /** `BREAKING CHANGE:` footer texts; each runs until a blank line, another footer, or a git trailer. */
235
- function readBreakingNotes(lines) {
262
+ /**
263
+ * `BREAKING CHANGE:` footers outside code fences. Each runs, line breaks kept,
264
+ * until another footer or a git trailer, as Conventional Commits footers do.
265
+ */
266
+ function readBreakingFooters(lines) {
236
267
  const notes = [];
268
+ const starts = [];
237
269
  let current;
238
- for (const line of lines) {
239
- const start = /^BREAKING[ -]CHANGE:\s*(.*)$/.exec(line);
270
+ let fenced = false;
271
+ const finish = () => {
272
+ if (current) notes.push(current.join("\n").trim());
273
+ current = void 0;
274
+ };
275
+ lines.forEach((line, index) => {
276
+ if (fencePattern.test(line)) fenced = !fenced;
277
+ const start = fenced ? null : footerStartPattern.exec(line);
240
278
  if (start) {
241
- if (current) notes.push(current.join(" ").trim());
279
+ finish();
280
+ starts.push(index);
242
281
  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);
282
+ } else if (current && !fenced && trailerPattern.test(line)) finish();
283
+ else if (current) current.push(line.trimEnd());
284
+ });
285
+ finish();
286
+ return {
287
+ starts,
288
+ notes: notes.filter(Boolean)
289
+ };
290
+ }
291
+ function parseHeader(header) {
292
+ const regular = headerPattern.exec(header);
293
+ const match = regular ?? breakingHeaderPattern.exec(header);
294
+ return match ? {
295
+ type: match[1],
296
+ scope: match[2],
297
+ subject: match[3],
298
+ breaking: !regular
299
+ } : void 0;
251
300
  }
252
301
  function parseCommit(message, hash) {
253
- const [header = "", ...rest] = message.replace(/\r\n/g, "\n").trim().split("\n");
302
+ const [rawHeader = "", ...rest] = message.replace(/\r\n?/g, "\n").trim().split("\n");
303
+ const header = rawHeader.trimEnd();
254
304
  const body = rest.join("\n").trim();
255
305
  const parsed = {
256
306
  header,
257
307
  body,
258
308
  ...hash ? { hash } : {}
259
309
  };
310
+ const footers = readBreakingFooters(rest);
260
311
  const layout = [];
261
312
  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");
313
+ if (footers.starts.some((index) => index > 0 && rest[index - 1].trim())) layout.push("footer-leading-blank");
263
314
  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);
315
+ if (footers.notes.length) parsed.breakingNotes = footers.notes;
316
+ const match = parseHeader(header);
268
317
  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";
318
+ parsed.type = match.type;
319
+ if (match.scope) parsed.scope = match.scope;
320
+ parsed.subject = match.subject;
321
+ if (match.breaking) parsed.breaking = "header";
273
322
  }
274
- if (!parsed.breaking && breakingNotePattern.test(body)) parsed.breaking = "footer";
323
+ if (!parsed.breaking && footers.starts.length) parsed.breaking = "footer";
275
324
  const githubRevert = githubRevertPattern.exec(header);
276
325
  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];
326
+ const revertedHeader = githubRevert?.[2] ?? (parsed.subject && parseHeader(parsed.subject) ? parsed.subject : void 0);
327
+ const hashes = [...new Set([...body.matchAll(revertHashPattern)].map((found) => found[1].toLowerCase()))];
279
328
  parsed.revert = {
280
329
  ...revertedHeader ? { header: revertedHeader } : {},
281
- ...revertedHash ? { hash: revertedHash } : {}
330
+ ...hashes.length ? {
331
+ hash: hashes[0],
332
+ hashes
333
+ } : {},
334
+ ...githubRevert?.[1] === "Reapply" ? { reapply: true } : {}
282
335
  };
283
336
  }
284
337
  return parsed;
@@ -295,7 +348,7 @@ const nonReleaseAlternatives = [
295
348
  "Example"
296
349
  ];
297
350
  function suggestNonReleaseHeader(commit) {
298
- const subject = commit.subject ?? "";
351
+ const subject = (commit.subject ?? commit.header).replace(/\.+$/, "");
299
352
  const scope = commit.scope ? `(${commit.scope})` : "";
300
353
  switch (commit.type) {
301
354
  case "Docs": return `Docs: ${subject}`;
@@ -303,17 +356,18 @@ function suggestNonReleaseHeader(commit) {
303
356
  default: return `Chore${scope}: ${subject}`;
304
357
  }
305
358
  }
306
- const looseHeaderPattern = /^(\w+)(?:\(([^)]*)\))?(!)?: ?(.*)$/;
307
- function diagnoseHeader(header) {
359
+ const looseHeaderPattern = /^(\w+)(?:\(([^)]*)\))?(!)?:(\s*)(.*)$/;
360
+ function diagnoseHeader(header, mode) {
308
361
  const format = "Use `Type(Scope): Summary` or `Type: Summary` with an Aronrepo commit type.";
309
362
  const match = looseHeaderPattern.exec(header);
310
363
  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}`;
364
+ const [, type, scope, bang, space, subject] = match;
365
+ if (!/^[A-Z]/.test(type)) return `The type \`${type}\` must be PascalCase, such as \`${normalizeType(`${type[0].toUpperCase()}${type.slice(1)}`)}\`. ${format}`;
366
+ if (bang && mode === "explicit") return `Aronrepo headers do not use \`!\` in explicit mode. ${format}`;
367
+ if (scope !== void 0 && !startPattern.test(scope)) return `The scope \`${scope}\` must start with an uppercase letter or digit. ${format}`;
368
+ if (space !== " ") return `Put exactly one space after the colon. ${format}`;
315
369
  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}`;
370
+ 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
371
  return format;
318
372
  }
319
373
  function formatRelease(release) {
@@ -328,6 +382,7 @@ function resolveCommitRelease(input, policy = defaultReleasePolicy, options = {}
328
382
  ...commit.scope ? { scope: commit.scope } : {},
329
383
  ...commit.subject ? { subject: commit.subject } : {},
330
384
  ...commit.breakingNotes ? { breakingNotes: commit.breakingNotes } : {},
385
+ ...commit.breaking ? { breaking: commit.breaking } : {},
331
386
  release: false,
332
387
  reasons: [],
333
388
  violations: []
@@ -337,7 +392,7 @@ function resolveCommitRelease(input, policy = defaultReleasePolicy, options = {}
337
392
  decision.reasons.push("Header does not follow `Type(Scope): Summary`; no release.");
338
393
  decision.violations.push({
339
394
  rule: "header-format",
340
- message: diagnoseHeader(commit.header)
395
+ message: diagnoseHeader(commit.header, policy.mode)
341
396
  });
342
397
  return decision;
343
398
  } else if (!types.includes(commit.type) && !legacyTypes.includes(commit.type)) {
@@ -348,13 +403,21 @@ function resolveCommitRelease(input, policy = defaultReleasePolicy, options = {}
348
403
  });
349
404
  return decision;
350
405
  } else if (!findCommitRule(commit.type, commit.scope)) {
406
+ const scoped = commits.filter(({ type }) => type === commit.type).map(({ type, scope }) => `\`${type}(${scope})\``).join(", ");
351
407
  decision.reasons.push(`\`${commit.type}${commit.scope ? `(${commit.scope})` : ""}\` is not an Aronrepo commit type; no release.`);
352
408
  decision.violations.push({
353
409
  rule: "unknown-type",
354
- message: `\`${commit.type}\` needs a scope: ${commits.filter(({ type }) => type === commit.type).map(({ type, scope }) => `\`${type}(${scope})\``).join(", ")}.`
410
+ message: commit.scope ? `\`${commit.type}(${commit.scope})\` has an unknown scope. Use one of: ${scoped}.` : `\`${commit.type}\` needs a scope: ${scoped}.`
355
411
  });
356
412
  return decision;
357
413
  }
414
+ const knownScope = commit.scope && commits.find(({ type, scope }) => type === normalizeType(commit.type) && scope && scope !== commit.scope && scope.toLowerCase() === commit.scope.toLowerCase())?.scope;
415
+ if (knownScope) decision.violations.push({
416
+ rule: "scope-case",
417
+ severity: "warning",
418
+ message: `\`${commit.type}(${commit.scope})\` is treated as \`${commit.type}\`. Write \`${commit.type}(${knownScope})\` for that rule.`,
419
+ suggestion: `${commit.type}(${knownScope}): ${commit.subject ?? ""}`
420
+ });
358
421
  if (commit.subject?.endsWith(".")) decision.violations.push({
359
422
  rule: "subject-full-stop",
360
423
  message: "The summary must not end with a period.",
@@ -377,8 +440,15 @@ function resolveCommitRelease(input, policy = defaultReleasePolicy, options = {}
377
440
  }
378
441
  let release;
379
442
  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)}.`);
443
+ const reverted = resolveCommitRelease(commit.revert.header, policy, options);
444
+ const verb = commit.revert.reapply ? "Reapplies" : "Reverts";
445
+ if (reverted.type === "Bump" && reverted.release) {
446
+ release = "patch";
447
+ decision.reasons.push(`${verb} \`${commit.revert.header}\`; reverting a \`Bump\` releases patch.`);
448
+ } else {
449
+ release = reverted.release;
450
+ decision.reasons.push(`${verb} \`${commit.revert.header}\`, which releases ${formatRelease(release)}.`);
451
+ }
382
452
  } else {
383
453
  const rule = findCommitRule(decision.type, decision.scope);
384
454
  release = capRelease(rule.release, decision.type, policy);
@@ -402,10 +472,16 @@ function resolveCommitRelease(input, policy = defaultReleasePolicy, options = {}
402
472
  const isBump = decision.type === "Bump";
403
473
  if (release && !isBump && options.files && options.isReleasePath) {
404
474
  if (!options.files.filter(options.isReleasePath).length) {
475
+ const label = `\`${decision.type}${decision.scope ? `(${decision.scope})` : ""}\``;
476
+ const onlyBreaking = commit.type !== void 0 && findCommitRule(commit.type, commit.scope)?.release === false;
405
477
  decision.reasons.push("No changed file ships in a published package; no release.");
406
- decision.violations.push({
478
+ decision.violations.push(onlyBreaking ? {
479
+ rule: "release-paths",
480
+ message: `${label} releases ${formatRelease(release)} only because of its breaking marker, but the commit changes no release paths. Remove the breaking marker.`,
481
+ ...commit.breaking === "header" ? { suggestion: commit.header.replace("!:", ":") } : {}
482
+ } : {
407
483
  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(", ")}.`,
484
+ message: `${label} releases ${formatRelease(release)}, but the commit changes no release paths. Use a non-release type: ${nonReleaseAlternatives.join(", ")}.`,
409
485
  suggestion: suggestNonReleaseHeader({
410
486
  ...commit,
411
487
  type: decision.type
@@ -423,16 +499,27 @@ function resolveRelease(inputs, policy = defaultReleasePolicy, options = {}) {
423
499
  ...options,
424
500
  ...inputs[index].files ? { files: inputs[index].files } : {}
425
501
  }));
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.`);
502
+ const cancelled = /* @__PURE__ */ new Set();
503
+ const cancel = (target, other) => {
504
+ cancelled.add(target);
505
+ decisions[target].release = false;
506
+ decisions[target].reasons.push(`Cancelled by \`${parsed[other].header}\` in the same release.`);
507
+ };
508
+ for (let index = parsed.length - 1; index >= 0; index--) {
509
+ const hashes = parsed[index].revert?.hashes ?? [];
510
+ if (!hashes.length || cancelled.has(index)) continue;
511
+ const targets = hashes.map((revertedHash) => {
512
+ const matches = parsed.flatMap((candidate, candidateIndex) => candidateIndex < index && candidate.hash?.toLowerCase().startsWith(revertedHash) ? [candidateIndex] : []);
513
+ return matches.length === 1 && !cancelled.has(matches[0]) ? matches[0] : void 0;
514
+ });
515
+ for (const target of targets) if (target !== void 0) cancel(target, index);
516
+ if (targets.every((target) => target !== void 0)) {
517
+ const reverted = targets.map((target) => parsed[target].header).join("`, `");
518
+ cancelled.add(index);
519
+ decisions[index].release = false;
520
+ decisions[index].reasons.push(`Cancelled by \`${reverted}\` in the same release.`);
434
521
  }
435
- });
522
+ }
436
523
  return {
437
524
  release: maxRelease(...decisions.map(({ release }) => release)),
438
525
  commits: decisions
@@ -570,7 +657,7 @@ function getReleaseTable(policy = defaultReleasePolicy) {
570
657
  none: []
571
658
  };
572
659
  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");
660
+ if (policy.mode === "auto") rows.major.push("`BREAKING CHANGE:` footer", "`Type!:` header");
574
661
  return rows;
575
662
  }
576
663
  const agentsBlockStart = "<!-- aronrepo:commit-policy:start -->";
@@ -578,7 +665,8 @@ const agentsBlockEnd = "<!-- aronrepo:commit-policy:end -->";
578
665
  /** Renders the generated commit policy block for AGENTS.md, including its markers. */
579
666
  function renderAgentsBlock(policy = defaultReleasePolicy, options = {}) {
580
667
  const agent = getAgentCommitPolicy(policy, options);
581
- const paths = options.releasePaths;
668
+ const paths = options.releasePaths ?? (policy.paths || void 0);
669
+ const excludes = paths ? paths.exclude ?? defaultReleasePathExcludes : [];
582
670
  const typeRows = commits.map(({ type, scope, description }) => `| ${formatRule(type, scope)} | ${getReleaseType(type, scope, policy) || "none"} | ${description} |`);
583
671
  return [
584
672
  agentsBlockStart,
@@ -586,7 +674,7 @@ function renderAgentsBlock(policy = defaultReleasePolicy, options = {}) {
586
674
  "",
587
675
  "## Commit and Release Policy",
588
676
  "",
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."}`,
677
+ `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
678
  "",
591
679
  "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
680
  "",
@@ -613,13 +701,11 @@ function renderAgentsBlock(policy = defaultReleasePolicy, options = {}) {
613
701
  "",
614
702
  "### Release paths",
615
703
  "",
616
- ...paths?.include ? [
704
+ ...!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
705
  "Only changes to these files can release a version:",
618
706
  "",
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."],
707
+ ...paths.include.map((glob) => `- \`${glob}\``)
708
+ ] : ["Every path can release a version, except the excluded ones."], ...excludes.length ? ["", `Excluding: ${excludes.map((glob) => `\`${glob}\``).join(", ")}.`] : []],
623
709
  "",
624
710
  "### Before handing off commits",
625
711
  "",
@@ -711,34 +797,100 @@ function getNextVersion(channel, release, tags) {
711
797
  const target = stable ? semver.inc(stable, release) : initialVersion;
712
798
  if (!channel.prerelease) return target;
713
799
  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));
800
+ let core = previous && semver.gte(coreVersion(previous), target) ? coreVersion(previous) : target;
801
+ const all = versionsOf(tags.all);
802
+ const released = highest(stableVersions(all));
803
+ if (released && semver.lte(core, released)) core = semver.inc(released, release);
804
+ const numbers = channelVersions(all, channel.prerelease).filter((version) => coreVersion(version) === core).map((version) => semver.prerelease(version)?.[1] ?? 0).filter((number) => Number.isInteger(number));
716
805
  return `${core}-${channel.prerelease}.${Math.max(0, ...numbers) + 1}`;
717
806
  }
718
807
  // src/notes.ts
719
808
  const breakingTitle = "⚠ Breaking Changes";
720
809
  function groupOf(decision) {
721
- if (decision.type === "Revert") return "Reverts";
722
810
  return findCommitRule(decision.type, decision.scope)?.group;
723
811
  }
812
+ /** Splits text into plain parts and CommonMark code spans: a backtick run closed by a run of the same length. */
813
+ function splitCodeSpans(text) {
814
+ const parts = [];
815
+ let plain = "";
816
+ let index = 0;
817
+ while (index < text.length) {
818
+ if (text[index] === "\\" && text[index + 1] === "`") {
819
+ plain += text.slice(index, index + 2);
820
+ index += 2;
821
+ continue;
822
+ }
823
+ if (text[index] !== "`") {
824
+ plain += text[index++];
825
+ continue;
826
+ }
827
+ const run = /^`+/.exec(text.slice(index))[0];
828
+ const close = new RegExp(`(?<!\`)${run}(?!\`)`).exec(text.slice(index + run.length));
829
+ if (!close) {
830
+ plain += run;
831
+ index += run.length;
832
+ continue;
833
+ }
834
+ if (plain) parts.push({
835
+ code: false,
836
+ text: plain
837
+ });
838
+ plain = "";
839
+ const end = index + run.length + close.index + run.length;
840
+ parts.push({
841
+ code: true,
842
+ text: text.slice(index, end)
843
+ });
844
+ index = end;
845
+ }
846
+ if (plain) parts.push({
847
+ code: false,
848
+ text: plain
849
+ });
850
+ return parts;
851
+ }
852
+ /**
853
+ * Keeps commit text from acting on GitHub outside code spans: `<` would start
854
+ * HTML, as in `Array<string>`, and `@name` or `@scope/pkg` would notify a user
855
+ * or team on every release.
856
+ */
857
+ function escapeMarkdown(text) {
858
+ 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("");
859
+ }
860
+ /** A list item; continuation lines are indented so they stay in the item. */
861
+ function listItem(text) {
862
+ return `- ${text.split("\n").map((line, index) => index && line ? ` ${line}` : line).join("\n")}`;
863
+ }
724
864
  function formatEntry(decision, repositoryUrl) {
725
- const summary = decision.subject ?? decision.header;
726
- const scope = decision.scope ? `**${decision.scope}:** ` : "";
865
+ const summary = escapeMarkdown(decision.subject ?? decision.header);
866
+ const scope = decision.scope ? `**${escapeMarkdown(decision.scope)}:** ` : "";
727
867
  const hash = decision.hash ? decision.hash.slice(0, 7) : void 0;
728
868
  return `- ${scope}${summary}${hash ? repositoryUrl ? ` ([${hash}](${repositoryUrl}/commit/${decision.hash}))` : ` (${hash})` : ""}`;
729
869
  }
870
+ /** The page that lists every change of a release: the compare view, or the commits of a first release. */
871
+ function getChangelogUrl(repositoryUrl, version, previousTag) {
872
+ const base = repositoryUrl.replace(/\/+$/, "");
873
+ const tag = `v${version.replace(/^v/, "")}`;
874
+ return previousTag ? `${base}/compare/${previousTag}...${tag}` : `${base}/commits/${tag}`;
875
+ }
730
876
  /**
731
877
  * Markdown release notes. Only commits that release appear, grouped by type in
732
878
  * the order of the commit table; `BREAKING CHANGE:` footers of major releases
733
879
  * come first.
734
880
  */
735
881
  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})` : ""}`];
882
+ const { previousTag } = options;
883
+ const version = options.version.replace(/^v/, "");
884
+ const repositoryUrl = options.repositoryUrl?.replace(/\/+$/, "");
885
+ const changelogUrl = repositoryUrl ? getChangelogUrl(repositoryUrl, version, previousTag) : void 0;
886
+ const sections = [];
739
887
  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}`));
888
+ const breaking = released.filter(({ release }) => release === "major").flatMap(({ breakingNotes, breaking: marker, subject }) => breakingNotes?.length ? breakingNotes : marker === "header" && subject ? [subject] : []);
889
+ if (breaking.length) sections.push([
890
+ `### ${breakingTitle}`,
891
+ "",
892
+ ...breaking.map((note) => listItem(escapeMarkdown(note)))
893
+ ]);
742
894
  const groups = /* @__PURE__ */ new Map();
743
895
  for (const decision of released) {
744
896
  const group = groupOf(decision);
@@ -749,11 +901,19 @@ function renderReleaseNotes(options) {
749
901
  const entries = groups.get(group);
750
902
  if (!entries) continue;
751
903
  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)));
904
+ sections.push([
905
+ `### ${group}`,
906
+ "",
907
+ ...sorted.map((decision) => formatEntry(decision, repositoryUrl))
908
+ ]);
753
909
  }
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 };
910
+ if (!sections.length) sections.push([released.length ? "Version bump only; no other changes to published packages." : "No changes to published packages."]);
911
+ if (options.heading) {
912
+ const title = changelogUrl ? `[${version}](${changelogUrl})` : version;
913
+ sections.unshift([`## ${title}${options.date ? ` (${options.date})` : ""}`]);
914
+ } else if (changelogUrl) sections.push([`**Full Changelog**: ${changelogUrl}`]);
915
+ return `${sections.map((section) => section.join("\n")).join("\n\n")}\n`;
916
+ }
917
+ 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
918
 
759
919
  //# sourceMappingURL=index.js.map