@amritk/nish 0.11.0 → 0.13.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.
@@ -54,11 +54,11 @@
54
54
  // tool behaved before. Use it for the first release after the convention
55
55
  // lands, or write the history up by hand in `changelog/<version>.intro.md`,
56
56
  // which renders above the sections.
57
- import fs from "node:fs";
58
- import path from "node:path";
59
- import { execFileSync } from "node:child_process";
57
+ import fs from "node:fs"
58
+ import path from "node:path"
59
+ import { execFileSync } from "node:child_process"
60
60
 
61
- const root = path.resolve(import.meta.dirname, "..");
61
+ const root = path.resolve(import.meta.dirname, "..")
62
62
 
63
63
  /**
64
64
  * The repository the entries link into, from `package.json#repository`. Read
@@ -68,14 +68,14 @@ const root = path.resolve(import.meta.dirname, "..");
68
68
  */
69
69
  const REPO_URL = (() => {
70
70
  try {
71
- const { repository } = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8"));
72
- const url = typeof repository === "string" ? repository : repository?.url;
73
- const m = /github\.com[/:]([^/]+\/[^/]+?)(?:\.git)?$/.exec(url ?? "");
74
- return m ? `https://github.com/${m[1]}` : undefined;
71
+ const { repository } = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8"))
72
+ const url = typeof repository === "string" ? repository : repository?.url
73
+ const m = /github\.com[/:]([^/]+\/[^/]+?)(?:\.git)?$/.exec(url ?? "")
74
+ return m ? `https://github.com/${m[1]}` : undefined
75
75
  } catch {
76
- return undefined;
76
+ return undefined
77
77
  }
78
- })();
78
+ })()
79
79
 
80
80
  /** Conventional-commit types, in the order a release renders them. */
81
81
  const TYPES = [
@@ -91,20 +91,20 @@ const TYPES = [
91
91
  // Only reachable under --include-unconventional; ordinarily a subject that
92
92
  // does not classify is skipped rather than filed here.
93
93
  ["other", "Uncategorised"],
94
- ];
94
+ ]
95
95
 
96
96
  /** The types a subject may name. `other` is this tool's bucket, not a type. */
97
- const CONVENTIONAL_TYPES = TYPES.filter(([k]) => k !== "other").map(([k]) => k);
97
+ const CONVENTIONAL_TYPES = TYPES.filter(([k]) => k !== "other").map(([k]) => k)
98
98
 
99
99
  /**
100
100
  * Trailers that are bookkeeping rather than content. They are stripped from
101
101
  * every body: a release note is for the reader, and who co-authored a commit
102
102
  * or which session produced it is not part of what changed.
103
103
  */
104
- const DROPPED_TRAILERS = /^(Co-Authored-By|Claude-Session|Signed-off-by|Reviewed-by):/i;
104
+ const DROPPED_TRAILERS = /^(Co-Authored-By|Claude-Session|Signed-off-by|Reviewed-by):/i
105
105
 
106
106
  /** Trailers this tool reads. Everything else is left in the body. */
107
- const KNOWN_TRAILERS = /^(Measured|Refs|Tests|Release-Note|Release-As|BREAKING[ -]CHANGE):\s*(.*)$/i;
107
+ const KNOWN_TRAILERS = /^(Measured|Refs|Tests|Release-Note|Release-As|BREAKING[ -]CHANGE):\s*(.*)$/i
108
108
 
109
109
  /**
110
110
  * GitHub ends a squashed body with a rule when the branch had more than one
@@ -112,10 +112,10 @@ const KNOWN_TRAILERS = /^(Measured|Refs|Tests|Release-Note|Release-As|BREAKING[
112
112
  * so it goes the way the bookkeeping trailers do. It sits above the
113
113
  * co-authorship trailers rather than at the end, so it comes off the prose.
114
114
  */
115
- const SQUASH_RULE = /\n[ \t]*\n[ \t]*-{3,}[ \t]*$/;
115
+ const SQUASH_RULE = /\n[ \t]*\n[ \t]*-{3,}[ \t]*$/
116
116
 
117
- function git(args, quiet = false) {
118
- return execFileSync("git", args, {
117
+ const git = (args, quiet = false) =>
118
+ execFileSync("git", args, {
119
119
  cwd: root,
120
120
  encoding: "utf8",
121
121
  maxBuffer: 64 * 1024 * 1024,
@@ -124,15 +124,14 @@ function git(args, quiet = false) {
124
124
  // below -- but inheriting stderr would print a fatal error during a run
125
125
  // that succeeded, which is how a green log gets read as a broken one.
126
126
  stdio: quiet ? ["ignore", "pipe", "ignore"] : undefined,
127
- });
128
- }
127
+ })
129
128
 
130
129
  /** The previous release tag, or undefined when this is the first release. */
131
- function lastTag() {
130
+ const lastTag = () => {
132
131
  try {
133
- return git(["describe", "--tags", "--abbrev=0", "--match", "v*"], true).trim() || undefined;
132
+ return git(["describe", "--tags", "--abbrev=0", "--match", "v*"], true).trim() || undefined
134
133
  } catch {
135
- return undefined; // no tags yet: the first release covers the whole history
134
+ return undefined // no tags yet: the first release covers the whole history
136
135
  }
137
136
  }
138
137
 
@@ -140,53 +139,83 @@ function lastTag() {
140
139
  * Split a commit body into prose and trailers. Only a trailing run of
141
140
  * trailer-shaped lines counts, so a colon inside the prose is safe.
142
141
  */
143
- function splitTrailers(body) {
144
- const lines = body.replace(/\r\n/g, "\n").split("\n");
145
- const trailers = [];
146
- let end = lines.length;
142
+ const splitTrailers = (body) => {
143
+ const lines = body.replace(/\r\n/g, "\n").split("\n")
144
+ const trailers = []
145
+ let end = lines.length
147
146
  while (end > 0) {
148
- const line = lines[end - 1];
147
+ const line = lines[end - 1]
149
148
  if (line.trim() === "") {
150
- end -= 1;
151
- continue;
149
+ end -= 1
150
+ continue
152
151
  }
153
152
  if (DROPPED_TRAILERS.test(line) || KNOWN_TRAILERS.test(line)) {
154
- trailers.unshift(line);
155
- end -= 1;
156
- continue;
153
+ trailers.unshift(line)
154
+ end -= 1
155
+ continue
157
156
  }
158
- break;
157
+ break
159
158
  }
160
- return { prose: lines.slice(0, end).join("\n").trim(), trailers };
159
+ return { prose: lines.slice(0, end).join("\n").trim(), trailers }
161
160
  }
162
161
 
163
- function parseTrailers(lines) {
164
- const out = { metrics: [], refs: [], tests: [], releaseNote: undefined, releaseAs: [], breaking: undefined };
162
+ const parseTrailers = (lines) => {
163
+ const out = {
164
+ metrics: [],
165
+ refs: [],
166
+ tests: [],
167
+ releaseNote: undefined,
168
+ releaseAs: [],
169
+ breaking: undefined,
170
+ }
165
171
  for (const line of lines) {
166
- if (DROPPED_TRAILERS.test(line)) continue;
167
- const m = line.match(KNOWN_TRAILERS);
168
- if (!m) continue;
169
- const key = m[1].toLowerCase().replace(/[ -]/g, "");
170
- const value = m[2].trim();
171
- if (key === "measured") out.metrics.push(value);
172
- else if (key === "refs") out.refs.push(...value.split(",").map((s) => s.trim()).filter(Boolean));
173
- else if (key === "tests") out.tests.push(...value.split(",").map((s) => s.trim()).filter(Boolean));
174
- else if (key === "releasenote") out.releaseNote = value;
175
- else if (key === "releaseas") out.releaseAs.push(value);
176
- else if (key === "breakingchange") out.breaking = value;
172
+ if (DROPPED_TRAILERS.test(line)) {
173
+ continue
174
+ }
175
+ const m = line.match(KNOWN_TRAILERS)
176
+ if (!m) {
177
+ continue
178
+ }
179
+ const key = m[1].toLowerCase().replace(/[ -]/g, "")
180
+ const value = m[2].trim()
181
+ if (key === "measured") {
182
+ out.metrics.push(value)
183
+ } else if (key === "refs") {
184
+ out.refs.push(
185
+ ...value
186
+ .split(",")
187
+ .map((s) => s.trim())
188
+ .filter(Boolean)
189
+ )
190
+ } else if (key === "tests") {
191
+ out.tests.push(
192
+ ...value
193
+ .split(",")
194
+ .map((s) => s.trim())
195
+ .filter(Boolean)
196
+ )
197
+ } else if (key === "releasenote") {
198
+ out.releaseNote = value
199
+ } else if (key === "releaseas") {
200
+ out.releaseAs.push(value)
201
+ } else if (key === "breakingchange") {
202
+ out.breaking = value
203
+ }
177
204
  }
178
- return out;
205
+ return out
179
206
  }
180
207
 
181
208
  /** `type(scope)!: subject`, or undefined when the subject is not conventional. */
182
- function parseSubject(subject) {
183
- const m = subject.match(/^([a-z]+)(?:\(([^)]+)\))?(!)?:\s*(.+)$/);
184
- if (!m) return undefined;
185
- return { type: m[1], scope: m[2], bang: Boolean(m[3]), title: m[4].trim() };
209
+ const parseSubject = (subject) => {
210
+ const m = subject.match(/^([a-z]+)(?:\(([^)]+)\))?(!)?:\s*(.+)$/)
211
+ if (!m) {
212
+ return undefined
213
+ }
214
+ return { type: m[1], scope: m[2], bang: Boolean(m[3]), title: m[4].trim() }
186
215
  }
187
216
 
188
217
  /** A stable, human-readable anchor. The website links entries by this. */
189
- function slug(title, taken) {
218
+ const slug = (title, taken) => {
190
219
  const base =
191
220
  title
192
221
  .toLowerCase()
@@ -195,11 +224,13 @@ function slug(title, taken) {
195
224
  .replace(/^-|-$/g, "")
196
225
  .split("-")
197
226
  .slice(0, 8)
198
- .join("-") || "entry";
199
- let id = base;
200
- for (let n = 2; taken.has(id); n += 1) id = `${base}-${n}`;
201
- taken.add(id);
202
- return id;
227
+ .join("-") || "entry"
228
+ let id = base
229
+ for (let n = 2; taken.has(id); n += 1) {
230
+ id = `${base}-${n}`
231
+ }
232
+ taken.add(id)
233
+ return id
203
234
  }
204
235
 
205
236
  /**
@@ -218,35 +249,41 @@ function slug(title, taken) {
218
249
  * someone asked for is not lost because the commit that asked was the
219
250
  * work-in-progress half of a merge.
220
251
  */
221
- function collect(from, to, includeUnconventional = false) {
222
- const range = from ? `${from}..${to}` : to;
252
+ const collect = (fromRef, toRef, withUnconventional = false) => {
253
+ const range = fromRef ? `${fromRef}..${toRef}` : toRef
223
254
  // \x00 between fields and \x1e between records: a commit body contains
224
255
  // newlines and may contain anything else, so the separators must be bytes
225
256
  // that cannot appear in one.
226
- const raw = git(["log", "--no-merges", "--reverse", `--format=%H%x00%an%x00%aI%x00%s%x00%b%x1e`, range]);
227
- const taken = new Set();
228
- const entries = [];
229
- const skipped = [];
230
- const releaseAs = [];
257
+ const raw = git(["log", "--no-merges", "--reverse", "--format=%H%x00%an%x00%aI%x00%s%x00%b%x1e", range])
258
+ const taken = new Set()
259
+ const entries = []
260
+ const skipped = []
261
+ const releaseAs = []
231
262
 
232
263
  for (const record of raw.split("\x1e")) {
233
- const text = record.replace(/^\n/, "");
234
- if (!text.trim()) continue;
235
- const [sha, author, date, subject, body = ""] = text.split("\x00");
236
- const { prose: rawProse, trailers } = splitTrailers(body);
237
- const t = parseTrailers(trailers);
238
- for (const version of t.releaseAs) releaseAs.push({ version, commit: `${sha.slice(0, 7)} ${subject}` });
239
-
240
- const parsed = parseSubject(subject);
241
- const classified = parsed !== undefined && CONVENTIONAL_TYPES.includes(parsed.type);
264
+ const text = record.replace(/^\n/, "")
265
+ if (!text.trim()) {
266
+ continue
267
+ }
268
+ const [sha, author, date, subject, body = ""] = text.split("\x00")
269
+ const { prose: rawProse, trailers } = splitTrailers(body)
270
+ const t = parseTrailers(trailers)
271
+ for (const version of t.releaseAs) {
272
+ releaseAs.push({ version, commit: `${sha.slice(0, 7)} ${subject}` })
273
+ }
274
+
275
+ const parsed = parseSubject(subject)
276
+ const classified = parsed !== undefined && CONVENTIONAL_TYPES.includes(parsed.type)
242
277
  if (!classified) {
243
- skipped.push(`${sha.slice(0, 7)} ${subject}`);
244
- if (!includeUnconventional) continue;
278
+ skipped.push(`${sha.slice(0, 7)} ${subject}`)
279
+ if (!withUnconventional) {
280
+ continue
281
+ }
245
282
  }
246
283
 
247
- const prose = rawProse.replace(SQUASH_RULE, "");
248
- const title = parsed ? parsed.title : subject;
249
- const pr = /\(#(\d+)\)\s*$/.exec(subject)?.[1];
284
+ const prose = rawProse.replace(SQUASH_RULE, "")
285
+ const title = parsed ? parsed.title : subject
286
+ const pr = /\(#(\d+)\)\s*$/.exec(subject)?.[1]
250
287
 
251
288
  entries.push({
252
289
  id: slug(title, taken),
@@ -263,26 +300,35 @@ function collect(from, to, includeUnconventional = false) {
263
300
  pr: pr ? Number(pr) : undefined,
264
301
  author,
265
302
  date: date.slice(0, 10),
266
- });
303
+ })
267
304
  }
268
- return { entries, skipped, releaseAs };
305
+ return { entries, skipped, releaseAs }
269
306
  }
270
307
 
271
- function renderMarkdown(release) {
272
- const out = [];
273
- if (release.intro) out.push(release.intro.trim(), "");
308
+ const renderMarkdown = (releaseData) => {
309
+ const out = []
310
+ if (releaseData.intro) {
311
+ out.push(releaseData.intro.trim(), "")
312
+ }
274
313
 
275
- const group = (predicate) => release.entries.filter(predicate).map(renderEntry);
314
+ const group = (predicate) => releaseData.entries.filter(predicate).map(renderEntry)
276
315
 
277
- const breaking = group((e) => e.breaking);
278
- if (breaking.length > 0) out.push("### Breaking changes", "", ...breaking, "");
316
+ const breaking = group((e) => e.breaking)
317
+ if (breaking.length > 0) {
318
+ out.push("### Breaking changes", "", ...breaking, "")
319
+ }
279
320
 
280
321
  for (const [type, heading] of TYPES) {
281
- const lines = group((e) => e.type === type && !e.breaking);
282
- if (lines.length === 0) continue;
283
- out.push(`### ${heading}`, "", ...lines, "");
322
+ const lines = group((e) => e.type === type && !e.breaking)
323
+ if (lines.length === 0) {
324
+ continue
325
+ }
326
+ out.push(`### ${heading}`, "", ...lines, "")
284
327
  }
285
- return `${out.join("\n").replace(/\n{3,}/g, "\n\n").trim()}\n`;
328
+ return `${out
329
+ .join("\n")
330
+ .replace(/\n{3,}/g, "\n\n")
331
+ .trim()}\n`
286
332
  }
287
333
 
288
334
  /**
@@ -290,11 +336,15 @@ function renderMarkdown(release) {
290
336
  * commit when it landed without one. Every line carries one, so the index
291
337
  * always leads somewhere.
292
338
  */
293
- function entryLink(e) {
294
- if (e.pr) return REPO_URL ? `[#${e.pr}](${REPO_URL}/pull/${e.pr})` : `#${e.pr}`;
295
- const sha = e.commits[0];
296
- if (!sha) return undefined;
297
- return REPO_URL ? `[\`${sha}\`](${REPO_URL}/commit/${sha})` : `\`${sha}\``;
339
+ const entryLink = (e) => {
340
+ if (e.pr) {
341
+ return REPO_URL ? `[#${e.pr}](${REPO_URL}/pull/${e.pr})` : `#${e.pr}`
342
+ }
343
+ const sha = e.commits[0]
344
+ if (!sha) {
345
+ return undefined
346
+ }
347
+ return REPO_URL ? `[\`${sha}\`](${REPO_URL}/commit/${sha})` : `\`${sha}\``
298
348
  }
299
349
 
300
350
  /**
@@ -303,10 +353,10 @@ function entryLink(e) {
303
353
  * title are joined and the first letter is raised -- unless the title starts
304
354
  * with `code`, which keeps its backtick and its case.
305
355
  */
306
- function renderEntry(e) {
307
- const title = e.title.startsWith("`") ? e.title : e.title.charAt(0).toUpperCase() + e.title.slice(1);
308
- const link = entryLink(e);
309
- return `- ${e.scope ? `${e.scope}: ` : ""}${title}${link ? ` (${link})` : ""}`;
356
+ const renderEntry = (e) => {
357
+ const title = e.title.startsWith("`") ? e.title : e.title.charAt(0).toUpperCase() + e.title.slice(1)
358
+ const link = entryLink(e)
359
+ return `- ${e.scope ? `${e.scope}: ` : ""}${title}${link ? ` (${link})` : ""}`
310
360
  }
311
361
 
312
362
  /**
@@ -319,29 +369,33 @@ function renderEntry(e) {
319
369
  * same branch is ordinary semver -- a break moves the major, a `feat` the
320
370
  * minor, anything else the patch -- so nothing changes here when 1.0 is cut.
321
371
  */
322
- function impliedVersion(current, entries, previousTag) {
372
+ const impliedVersion = (current, commits, previousTag) => {
323
373
  // The first release is 0.1.0 whatever the commits say. Every commit before
324
374
  // the convention existed is typed `other`, so a type-driven bump would read
325
375
  // the entire history as a patch and ship 0.0.1 — a number that would claim
326
376
  // the compiler is a bug-fix on nothing. 0.1.0 is also what the seed policy
327
377
  // already names as the base case (docs/wp12-release.md, "The bootstrap seed").
328
- if (!previousTag) return [0, 1, 0];
329
- const [major, minor, patch] = current.split(".").map(Number);
330
- if (entries.some((e) => e.breaking)) return major === 0 ? [0, minor + 1, 0] : [major + 1, 0, 0];
331
- if (entries.some((e) => e.type === "feat")) return [major, minor + 1, 0];
332
- return [major, minor, patch + 1];
378
+ if (!previousTag) {
379
+ return [0, 1, 0]
380
+ }
381
+ const [major, minor, patch] = current.split(".").map(Number)
382
+ if (commits.some((e) => e.breaking)) {
383
+ return major === 0 ? [0, minor + 1, 0] : [major + 1, 0, 0]
384
+ }
385
+ if (commits.some((e) => e.type === "feat")) {
386
+ return [major, minor + 1, 0]
387
+ }
388
+ return [major, minor, patch + 1]
333
389
  }
334
390
 
335
391
  /** `X.Y.Z` as three numbers, or undefined when it is not one. No `v`, no pre-release, no leading zeros. */
336
- function parseVersion(text) {
337
- const m = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.exec(text);
338
- return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : undefined;
392
+ const parseVersion = (text) => {
393
+ const m = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.exec(text)
394
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : undefined
339
395
  }
340
396
 
341
397
  /** Negative, zero or positive as `a` is below, at or above `b`. */
342
- function compareVersions(a, b) {
343
- return a[0] - b[0] || a[1] - b[1] || a[2] - b[2];
344
- }
398
+ const compareVersions = (a, b) => a[0] - b[0] || a[1] - b[1] || a[2] - b[2]
345
399
 
346
400
  /**
347
401
  * The next version: the one the commits imply, unless a `Release-As: X.Y.Z`
@@ -357,133 +411,145 @@ function compareVersions(a, b) {
357
411
  * release PR proposing some other number without saying so would be worse than
358
412
  * the train stopping.
359
413
  */
360
- function nextVersion(current, entries, previousTag, releaseAs = []) {
361
- const implied = impliedVersion(current, entries, previousTag);
362
- let chosen;
363
- const byCommit = new Map();
364
- for (const request of releaseAs) {
365
- const parsed = parseVersion(request.version);
414
+ const nextVersion = (current, commits, previousTag, releaseAsTrailers = []) => {
415
+ const implied = impliedVersion(current, commits, previousTag)
416
+ let chosen
417
+ const byCommit = new Map()
418
+ for (const request of releaseAsTrailers) {
419
+ const parsed = parseVersion(request.version)
366
420
  if (!parsed) {
367
421
  return {
368
422
  error: `malformed trailer \`Release-As: ${request.version}\` on ${request.commit}; the value is a version, X.Y.Z`,
369
- };
423
+ }
370
424
  }
371
- const earlier = byCommit.get(request.commit);
425
+ const earlier = byCommit.get(request.commit)
372
426
  if (earlier !== undefined && earlier !== request.version) {
373
427
  return {
374
428
  error: `two trailers on ${request.commit} disagree: \`Release-As: ${earlier}\` and \`Release-As: ${request.version}\`; a commit asks for one version`,
375
- };
429
+ }
430
+ }
431
+ byCommit.set(request.commit, request.version)
432
+ if (!chosen || compareVersions(parsed, chosen.parsed) > 0) {
433
+ chosen = { ...request, parsed }
376
434
  }
377
- byCommit.set(request.commit, request.version);
378
- if (!chosen || compareVersions(parsed, chosen.parsed) > 0) chosen = { ...request, parsed };
379
435
  }
380
- if (!chosen) return { version: implied.join(".") };
436
+ if (!chosen) {
437
+ return { version: implied.join(".") }
438
+ }
381
439
  if (compareVersions(chosen.parsed, implied) < 0) {
382
440
  return {
383
441
  error:
384
442
  `trailer \`Release-As: ${chosen.version}\` on ${chosen.commit} is below ${implied.join(".")}, ` +
385
443
  `the version the commits since ${previousTag ?? "the start of the history"} imply; ` +
386
444
  "a trailer may raise the next version, never lower it",
387
- };
445
+ }
388
446
  }
389
- return { version: chosen.version };
447
+ return { version: chosen.version }
390
448
  }
391
449
 
392
450
  // ---- CLI ---------------------------------------------------------------------------------------
393
451
 
394
- const argv = process.argv.slice(2);
395
- function flag(name, fallback) {
396
- const i = argv.indexOf(`--${name}`);
397
- return i === -1 ? fallback : argv[i + 1];
452
+ const argv = process.argv.slice(2)
453
+ const flag = (name, fallback) => {
454
+ const i = argv.indexOf(`--${name}`)
455
+ return i === -1 ? fallback : argv[i + 1]
398
456
  }
399
457
 
400
458
  // Validating one subject line. This lives here rather than in the workflow so
401
459
  // that what CI enforces and what the generator parses are the same rule: a
402
460
  // title the check accepts and the generator files under "Uncategorised" would
403
461
  // be worse than no check at all.
404
- const checkSubject = flag("check-subject", undefined);
462
+ const checkSubject = flag("check-subject", undefined)
405
463
  if (checkSubject !== undefined) {
406
- const parsed = parseSubject(checkSubject);
464
+ const parsed = parseSubject(checkSubject)
407
465
  if (!parsed) {
408
- console.error(`not a conventional commit subject:\n\n ${checkSubject}\n`);
409
- console.error(`Expected \`type(scope): subject\`, where type is one of: ${CONVENTIONAL_TYPES.join(", ")}.`);
410
- console.error(`A \`!\` after the type or scope marks a breaking change.\n`);
411
- console.error(`Examples:\n feat(checker): accept non-generic type aliases`);
412
- console.error(` perf(codegen)!: hoist the array header out of element loops`);
413
- console.error(`\nThe subject becomes the heading in the release notes, so write it for a reader.`);
414
- process.exit(1);
466
+ console.error(`not a conventional commit subject:\n\n ${checkSubject}\n`)
467
+ console.error(
468
+ `Expected \`type(scope): subject\`, where type is one of: ${CONVENTIONAL_TYPES.join(", ")}.`
469
+ )
470
+ console.error("A `!` after the type or scope marks a breaking change.\n")
471
+ console.error("Examples:\n feat(checker): accept non-generic type aliases")
472
+ console.error(" perf(codegen)!: hoist the array header out of element loops")
473
+ console.error("\nThe subject becomes the heading in the release notes, so write it for a reader.")
474
+ process.exit(1)
415
475
  }
416
476
  if (!CONVENTIONAL_TYPES.includes(parsed.type)) {
417
- console.error(`unknown type \`${parsed.type}\` in:\n\n ${checkSubject}\n`);
418
- console.error(`Use one of: ${CONVENTIONAL_TYPES.join(", ")}.`);
419
- process.exit(1);
477
+ console.error(`unknown type \`${parsed.type}\` in:\n\n ${checkSubject}\n`)
478
+ console.error(`Use one of: ${CONVENTIONAL_TYPES.join(", ")}.`)
479
+ process.exit(1)
420
480
  }
421
481
  if (/[.]$/.test(parsed.title)) {
422
- console.error(`subject ends with a full stop:\n\n ${checkSubject}\n`);
423
- console.error("It is a heading, not a sentence.");
424
- process.exit(1);
482
+ console.error(`subject ends with a full stop:\n\n ${checkSubject}\n`)
483
+ console.error("It is a heading, not a sentence.")
484
+ process.exit(1)
425
485
  }
426
- console.log(`ok: ${parsed.type}${parsed.scope ? `(${parsed.scope})` : ""}${parsed.bang ? "!" : ""} — ${parsed.title}`);
427
- process.exit(0);
486
+ console.log(
487
+ `ok: ${parsed.type}${parsed.scope ? `(${parsed.scope})` : ""}${parsed.bang ? "!" : ""} — ${parsed.title}`
488
+ )
489
+ process.exit(0)
428
490
  }
429
491
 
430
492
  // Rendering an already-written release: the release job must publish exactly
431
493
  // the file the release PR was reviewed with, not a fresh walk of the log that
432
494
  // could differ by a commit.
433
- const fromJson = flag("from-json", undefined);
495
+ const fromJson = flag("from-json", undefined)
434
496
  if (fromJson) {
435
- const stored = JSON.parse(fs.readFileSync(fromJson, "utf8"));
436
- process.stdout.write(renderMarkdown(stored));
437
- process.exit(0);
497
+ const stored = JSON.parse(fs.readFileSync(fromJson, "utf8"))
498
+ process.stdout.write(renderMarkdown(stored))
499
+ process.exit(0)
438
500
  }
439
501
 
440
- const pkgVersion = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8")).version;
441
- const version = flag("version", pkgVersion);
442
- const from = flag("from", lastTag());
443
- const to = flag("to", "HEAD");
444
- const stdoutKind = flag("stdout", "md");
445
- const write = argv.includes("--write");
446
- const includeUnconventional = argv.includes("--include-unconventional");
502
+ const pkgVersion = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8")).version
503
+ const version = flag("version", pkgVersion)
504
+ const from = flag("from", lastTag())
505
+ const to = flag("to", "HEAD")
506
+ const stdoutKind = flag("stdout", "md")
507
+ const write = argv.includes("--write")
508
+ const includeUnconventional = argv.includes("--include-unconventional")
447
509
 
448
- const { entries, skipped, releaseAs } = collect(from, to, includeUnconventional);
510
+ const { entries, skipped, releaseAs } = collect(from, to, includeUnconventional)
449
511
 
450
512
  // `--next` answers the version and nothing else, for the release PR to name
451
513
  // itself and to bump package.json with. Everything release-pr.yml writes reads
452
514
  // this one line, so a `Release-As:` trailer reaches all of it here.
453
515
  if (argv.includes("--next")) {
454
- const next = nextVersion(pkgVersion, entries, from, releaseAs);
516
+ const next = nextVersion(pkgVersion, entries, from, releaseAs)
455
517
  if (next.error) {
456
- console.error(`changelog-gen: ${next.error}`);
457
- process.exit(1);
518
+ console.error(`changelog-gen: ${next.error}`)
519
+ process.exit(1)
458
520
  }
459
- process.stdout.write(`${next.version}\n`);
460
- process.exit(0);
521
+ process.stdout.write(`${next.version}\n`)
522
+ process.exit(0)
461
523
  }
462
524
 
463
525
  // Skipped is not silent: the subjects are named, because a change that belongs
464
526
  // in the notes and was written without a type is a defect to fix in the
465
527
  // commit, not something for a reader of the notes to discover missing.
466
528
  if (skipped.length > 0 && !includeUnconventional) {
467
- const shown = skipped.slice(0, 20);
529
+ const shown = skipped.slice(0, 20)
468
530
  console.error(
469
531
  `changelog-gen: skipped ${skipped.length} commit${skipped.length === 1 ? "" : "s"} whose subject is not a conventional commit:`
470
- );
471
- for (const line of shown) console.error(` ${line}`);
472
- if (skipped.length > shown.length) console.error(` ... and ${skipped.length - shown.length} more`);
473
- console.error("Pass --include-unconventional to file them under \"Uncategorised\" instead.");
532
+ )
533
+ for (const line of shown) {
534
+ console.error(` ${line}`)
535
+ }
536
+ if (skipped.length > shown.length) {
537
+ console.error(` ... and ${skipped.length - shown.length} more`)
538
+ }
539
+ console.error('Pass --include-unconventional to file them under "Uncategorised" instead.')
474
540
  }
475
541
 
476
542
  if (entries.length === 0) {
477
- const range = from ? `${from}..${to}` : to;
543
+ const range = from ? `${from}..${to}` : to
478
544
  console.error(
479
545
  skipped.length > 0
480
546
  ? `changelog-gen: no commit in ${range} carries a conventional subject (${skipped.length} skipped, listed above), so the release has no entries; fix the subjects or pass --include-unconventional`
481
547
  : `changelog-gen: no commits in ${range}; a release with no changes is a mistake, not an empty section`
482
- );
483
- process.exit(1);
548
+ )
549
+ process.exit(1)
484
550
  }
485
551
 
486
- const introPath = path.join(root, "changelog", `${version}.intro.md`);
552
+ const introPath = path.join(root, "changelog", `${version}.intro.md`)
487
553
  const release = {
488
554
  version,
489
555
  date: new Date().toISOString().slice(0, 10),
@@ -491,38 +557,40 @@ const release = {
491
557
  range: { from: from ?? null, to: git(["rev-parse", "--short", to]).trim() },
492
558
  intro: fs.existsSync(introPath) ? fs.readFileSync(introPath, "utf8").trim() : undefined,
493
559
  entries,
494
- };
560
+ }
495
561
 
496
- const jsonDir = path.join(root, "changelog");
497
- const jsonPath = path.join(jsonDir, `${version}.json`);
498
- const markdown = renderMarkdown(release);
562
+ const jsonDir = path.join(root, "changelog")
563
+ const jsonPath = path.join(jsonDir, `${version}.json`)
564
+ const markdown = renderMarkdown(release)
499
565
 
500
566
  if (write) {
501
- fs.mkdirSync(jsonDir, { recursive: true });
502
- fs.writeFileSync(jsonPath, `${JSON.stringify(release, null, 2)}\n`);
567
+ fs.mkdirSync(jsonDir, { recursive: true })
568
+ fs.writeFileSync(jsonPath, `${JSON.stringify(release, null, 2)}\n`)
503
569
 
504
570
  // Splice the rendered section into CHANGELOG.md under `## [Unreleased]`,
505
571
  // which is where the file's own header says a release section goes.
506
- const changelogPath = path.join(root, "CHANGELOG.md");
507
- const current = fs.readFileSync(changelogPath, "utf8");
508
- const marker = "## [Unreleased]";
572
+ const changelogPath = path.join(root, "CHANGELOG.md")
573
+ const current = fs.readFileSync(changelogPath, "utf8")
574
+ const marker = "## [Unreleased]"
509
575
  if (!current.includes(marker)) {
510
- console.error(`changelog-gen: CHANGELOG.md has no \`${marker}\` heading to write under`);
511
- process.exit(1);
576
+ console.error(`changelog-gen: CHANGELOG.md has no \`${marker}\` heading to write under`)
577
+ process.exit(1)
578
+ }
579
+ const section = `${marker}\n\n## [${version}] - ${release.date}\n\n${markdown}`
580
+ let next = current.replace(marker, section)
581
+ const link = `[${version}]: https://github.com/amritk/nish/releases/tag/v${version}`
582
+ if (!next.includes(link)) {
583
+ next = `${next.trimEnd()}\n${link}\n`
512
584
  }
513
- const section = `${marker}\n\n## [${version}] - ${release.date}\n\n${markdown}`;
514
- let next = current.replace(marker, section);
515
- const link = `[${version}]: https://github.com/amritk/nish/releases/tag/v${version}`;
516
- if (!next.includes(link)) next = `${next.trimEnd()}\n${link}\n`;
517
- fs.writeFileSync(changelogPath, next);
585
+ fs.writeFileSync(changelogPath, next)
518
586
 
519
- console.error(`changelog-gen: wrote changelog/${version}.json and the CHANGELOG.md section`);
587
+ console.error(`changelog-gen: wrote changelog/${version}.json and the CHANGELOG.md section`)
520
588
  }
521
589
 
522
590
  if (includeUnconventional && skipped.length > 0) {
523
591
  console.error(
524
592
  `changelog-gen: ${skipped.length} of ${entries.length} commits are not conventional and landed under "Uncategorised"`
525
- );
593
+ )
526
594
  }
527
595
 
528
- process.stdout.write(stdoutKind === "json" ? `${JSON.stringify(release, null, 2)}\n` : markdown);
596
+ process.stdout.write(stdoutKind === "json" ? `${JSON.stringify(release, null, 2)}\n` : markdown)