mandrel-platform 1.1.0 → 1.3.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.
@@ -0,0 +1,591 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * check-release-type.mjs
4
+ *
5
+ * Release-type advisory for consumer-facing changes (Story #368).
6
+ *
7
+ * ## The defect
8
+ *
9
+ * This repository merges by SQUASH, so the pull-request title becomes the
10
+ * single commit subject on `main` — and release-please derives the release
11
+ * from that subject. Several conventional-commit types are configured
12
+ * `hidden: true` in release-please-config.json (`ci`, `chore`, `docs`,
13
+ * `test`, `build`, `style`): a change landed under one of them cuts no
14
+ * release and never reaches the CHANGELOG.
15
+ *
16
+ * For an internal edit that is exactly right. For a change to the surface
17
+ * this repository PUBLISHES — the reusable workflows, the composite actions,
18
+ * and the files in the npm `files` allowlist — it means the change exists on
19
+ * `main` and nowhere a consumer can reach it. That already happened: a
20
+ * consumer-facing reusable-workflow input landed under a `ci:` title, shipped
21
+ * to nobody, and had to be re-released later under a corrected title. Nothing
22
+ * signalled it at the time, because every check was green.
23
+ *
24
+ * ## The signal is the PAIR, not the title
25
+ *
26
+ * A hidden type is only a defect when the diff touches the published surface.
27
+ * This check reports exactly that pair and nothing else:
28
+ *
29
+ * consumer-facing file(s) touched AND a title type that cannot release
30
+ *
31
+ * A diff confined to internal tooling, this repo's own tests, or `docs/`
32
+ * under a hidden type is CORRECT and stays silent. A releasable title passes
33
+ * silently whatever it touches. Silence is the common case by construction —
34
+ * a check reviewers learn to scroll past is worse than no check.
35
+ *
36
+ * ## Advisory, deliberately
37
+ *
38
+ * The classification is a heuristic over a human-written title. Blocking a
39
+ * pull request on it would be worse than the defect it prevents: an
40
+ * unappealable stop on a judgement call teaches people to route around the
41
+ * gate. So a mismatch reports through a `::warning::` annotation and the job
42
+ * summary, and the CI step deliberately TOLERATES the mismatch exit code. It
43
+ * runs as a STEP in the existing `node-scripts` job — no new job, therefore no
44
+ * new status context and no branch-protection change.
45
+ *
46
+ * ## Advisory is not the same as silent (Story #377)
47
+ *
48
+ * Advisory used to mean "exit 0 whatever happened", which collapsed three very
49
+ * different outcomes into one indistinguishable signal. `skipped` in
50
+ * particular is the dangerous one: a shallow clone, a missing pull-request
51
+ * title, or a release config that stops declaring `changelog-sections` all
52
+ * degrade this check to a skip, and a check that skips on every run forever is
53
+ * inert with nothing to notice. The three outcomes now carry three exit codes
54
+ * (see {@link EXIT}) so the caller can tell them apart; keeping the pull
55
+ * request unblocked is the CI step's job, not the exit code's.
56
+ *
57
+ * ## Where the type set comes from
58
+ *
59
+ * Nowhere in this file. The hidden-versus-releasable split is read from
60
+ * release-please-config.json's `changelog-sections` at run time, so changing
61
+ * which types are hidden changes this check with no edit here. A config that
62
+ * declares no sections leaves the question unanswerable, and the check skips
63
+ * rather than guessing at release-please's built-in defaults.
64
+ *
65
+ * A `!` breaking marker (`ci!: …`) always releases — release-please cuts a
66
+ * major for a breaking change regardless of the type's section visibility —
67
+ * so it is treated as releasable.
68
+ *
69
+ * ## Where the published surface comes from
70
+ *
71
+ * Also from configuration:
72
+ *
73
+ * • reusable workflows — a `.github/workflows/*.yml` that declares
74
+ * `workflow_call`. That is what makes it callable by a consumer;
75
+ * `ci.yml` and the other standing checks are not, and are internal.
76
+ * • composite actions — anything under `.github/actions/`.
77
+ * • published package files — anything under a `files[]` entry in
78
+ * package.json (`config/`, `default.json`, `scripts/`, `templates/`),
79
+ * minus the `*.test.mjs` siblings, which ship but are this repo's own
80
+ * tests rather than a consumer-facing contract.
81
+ *
82
+ * `package.json` itself is deliberately NOT classified: it is not in its own
83
+ * `files[]`, and release-please's own release pull request edits it under a
84
+ * `chore(main): release …` title on every single release — classifying it
85
+ * would make this check fire on the one pull request that most certainly
86
+ * does cut a release.
87
+ *
88
+ * The `workflow_call` detection reuses `walkYaml` / `isReusableWorkflow` from
89
+ * check-workflow-portability.mjs rather than growing a second YAML walker.
90
+ * This check is an INTERNAL repository lint (see Non-Goals on #368), not part
91
+ * of the published contract, so the sibling import costs no consumer anything.
92
+ *
93
+ * Usage:
94
+ * node scripts/check-release-type.mjs
95
+ * node scripts/check-release-type.mjs --title "ci: tweak" --base <sha> --head <sha>
96
+ *
97
+ * Environment (how CI supplies the context):
98
+ * PR_TITLE the pull-request title. Absent (a push run) → skip.
99
+ * BASE_SHA pull_request.base.sha
100
+ * HEAD_SHA pull_request.head.sha
101
+ *
102
+ * Exit codes: see {@link EXIT}. Only a usage error (1) is a fault in the check
103
+ * itself; 2 and 3 are findings, and the CI step tolerates them by name.
104
+ */
105
+
106
+ import { readFileSync, appendFileSync } from "node:fs";
107
+ import { execFileSync } from "node:child_process";
108
+ import { resolve, join } from "node:path";
109
+
110
+ import { parseFlags } from "./lib/args.mjs";
111
+ import { walkYaml, isReusableWorkflow } from "./check-workflow-portability.mjs";
112
+
113
+ /**
114
+ * The outcome of a run, as a POSIX exit code.
115
+ *
116
+ * `mismatch` and `skipped` are non-zero so a caller can DISTINGUISH them —
117
+ * from each other and from a clean run — without parsing log text. That does
118
+ * not make either one blocking: the ci.yml step maps 0/2/3 onto a passing step
119
+ * and only propagates anything else, which is asserted directly by
120
+ * check-release-type.test.mjs so the tolerance cannot be dropped by accident.
121
+ *
122
+ * `usage` is the one genuinely broken state — a flag this check does not
123
+ * understand means it did not run, and a check that did not run must not
124
+ * report as one that found nothing.
125
+ */
126
+ export const EXIT = Object.freeze({
127
+ ok: 0,
128
+ usage: 1,
129
+ mismatch: 2,
130
+ skipped: 3,
131
+ });
132
+
133
+ // ---------------------------------------------------------------------------
134
+ // Arg parsing
135
+ // ---------------------------------------------------------------------------
136
+
137
+ /**
138
+ * Parse the CLI argv slice (everything AFTER `node script.mjs`). Flags win
139
+ * over the environment so a local run can reproduce a CI finding exactly.
140
+ *
141
+ * @param {string[]} argv
142
+ * @returns {{title: string, base: string, head: string, cwd: string, config: string, help: boolean}}
143
+ */
144
+ export function parseArgs(argv) {
145
+ return parseFlags(argv, {
146
+ flags: {
147
+ "--title": { type: "string", dest: "title", default: process.env.PR_TITLE || "" },
148
+ "--base": { type: "string", dest: "base", default: process.env.BASE_SHA || "" },
149
+ "--head": { type: "string", dest: "head", default: process.env.HEAD_SHA || "" },
150
+ "--cwd": { type: "string", dest: "cwd", default: process.cwd() },
151
+ "--config": {
152
+ type: "string",
153
+ dest: "config",
154
+ default: "release-please-config.json",
155
+ },
156
+ "--help": { type: "boolean", dest: "help", value: true, default: false },
157
+ },
158
+ aliases: { "-h": "--help" },
159
+ onUnknown: "throw",
160
+ });
161
+ }
162
+
163
+ // ---------------------------------------------------------------------------
164
+ // Title → release type
165
+ // ---------------------------------------------------------------------------
166
+
167
+ // Conventional-commit subject: `type(optional-scope)!: description`.
168
+ const SUBJECT = /^(?<type>[A-Za-z][A-Za-z0-9]*)(?:\(([^)]*)\))?(?<bang>!)?:\s+\S/;
169
+
170
+ /**
171
+ * Parse a pull-request title into its conventional-commit type.
172
+ *
173
+ * @param {string} title
174
+ * @returns {{parsed: boolean, type: string|null, breaking: boolean}}
175
+ */
176
+ export function parseTitleType(title) {
177
+ const m = SUBJECT.exec(String(title || "").trim());
178
+ if (m === null) return { parsed: false, type: null, breaking: false };
179
+ return {
180
+ parsed: true,
181
+ type: m.groups.type.toLowerCase(),
182
+ breaking: m.groups.bang === "!",
183
+ };
184
+ }
185
+
186
+ /**
187
+ * Read the hidden-versus-releasable type split out of a release-please config
188
+ * body. A type is releasable when its changelog section is not `hidden` — that
189
+ * is the same field release-please itself reads, so the two cannot drift.
190
+ *
191
+ * Every package entry contributes: a multi-package config that hides a type in
192
+ * one package and shows it in another still RELEASES on that type somewhere,
193
+ * so the union is the correct releasable set.
194
+ *
195
+ * Returns null when the config declares no sections at all. The answer is then
196
+ * release-please's built-in default, and hardcoding a copy of that default
197
+ * here is precisely the duplication this check exists without.
198
+ *
199
+ * @param {string} configText
200
+ * @returns {{releasable: Set<string>, hidden: Set<string>} | null}
201
+ */
202
+ export function loadReleaseTypes(configText) {
203
+ let cfg;
204
+ try {
205
+ cfg = JSON.parse(configText);
206
+ } catch {
207
+ return null;
208
+ }
209
+ const releasable = new Set();
210
+ const hidden = new Set();
211
+
212
+ const sectionSets = [];
213
+ if (Array.isArray(cfg?.["changelog-sections"])) sectionSets.push(cfg["changelog-sections"]);
214
+ for (const pkg of Object.values(cfg?.packages ?? {})) {
215
+ if (Array.isArray(pkg?.["changelog-sections"])) sectionSets.push(pkg["changelog-sections"]);
216
+ }
217
+
218
+ for (const sections of sectionSets) {
219
+ for (const section of sections) {
220
+ const type = String(section?.type || "").toLowerCase();
221
+ if (!type) continue;
222
+ if (section?.hidden === true) hidden.add(type);
223
+ else releasable.add(type);
224
+ }
225
+ }
226
+
227
+ // A type visible in ANY package releases; drop it from the hidden set so the
228
+ // two are disjoint and the report cannot claim both.
229
+ for (const type of releasable) hidden.delete(type);
230
+
231
+ if (releasable.size === 0 && hidden.size === 0) return null;
232
+ return { releasable, hidden };
233
+ }
234
+
235
+ /**
236
+ * Decide whether a title can produce a release.
237
+ *
238
+ * @param {string} title
239
+ * @param {{releasable: Set<string>, hidden: Set<string>}} types
240
+ * @returns {{releasable: boolean, type: string|null, titleClass: "releasable"|"breaking"|"hidden"|"unknown-type"|"unparseable", detail: string}}
241
+ */
242
+ export function classifyTitle(title, types) {
243
+ const { parsed, type, breaking } = parseTitleType(title);
244
+ if (!parsed) {
245
+ return {
246
+ releasable: false,
247
+ type: null,
248
+ titleClass: "unparseable",
249
+ detail:
250
+ "the title is not a conventional-commit subject (`type(scope): description`), " +
251
+ "so release-please derives no release from it",
252
+ };
253
+ }
254
+ // A breaking change cuts a major whatever its section visibility.
255
+ if (breaking) {
256
+ return {
257
+ releasable: true,
258
+ type,
259
+ titleClass: "breaking",
260
+ detail: `\`${type}!\` is a breaking change — release-please cuts a major release`,
261
+ };
262
+ }
263
+ if (types.releasable.has(type)) {
264
+ return {
265
+ releasable: true,
266
+ type,
267
+ titleClass: "releasable",
268
+ detail: `\`${type}\` has a visible changelog section`,
269
+ };
270
+ }
271
+ if (types.hidden.has(type)) {
272
+ return {
273
+ releasable: false,
274
+ type,
275
+ titleClass: "hidden",
276
+ detail: `\`${type}\` is configured \`hidden: true\`, so it cuts no release and never reaches the CHANGELOG`,
277
+ };
278
+ }
279
+ return {
280
+ releasable: false,
281
+ type,
282
+ titleClass: "unknown-type",
283
+ detail: `\`${type}\` has no changelog section configured, so release-please derives no release from it`,
284
+ };
285
+ }
286
+
287
+ // ---------------------------------------------------------------------------
288
+ // Diff → consumer-facing surface
289
+ // ---------------------------------------------------------------------------
290
+
291
+ /**
292
+ * Normalise the npm `files` allowlist into path prefixes. Entries are compared
293
+ * as literal path prefixes — this repo's allowlist is four plain directory /
294
+ * file entries, and a glob entry would simply match nothing rather than
295
+ * mis-classify anything.
296
+ *
297
+ * @param {string} pkgText
298
+ * @returns {string[]}
299
+ */
300
+ export function loadPublishedPaths(pkgText) {
301
+ let pkg;
302
+ try {
303
+ pkg = JSON.parse(pkgText);
304
+ } catch {
305
+ return [];
306
+ }
307
+ if (!Array.isArray(pkg?.files)) return [];
308
+ return pkg.files
309
+ .map((f) => String(f).replace(/^\.\//, "").replace(/\/+$/, ""))
310
+ .filter((f) => f.length > 0 && !/[*?[\]]/.test(f));
311
+ }
312
+
313
+ /**
314
+ * Classify one changed path as consumer-facing or internal.
315
+ *
316
+ * `readFile` is injectable so the classification of a reusable workflow can be
317
+ * exercised without a filesystem. A workflow file that cannot be read is
318
+ * treated as consumer-facing: `.github/workflows/` is where the product lives,
319
+ * and the unreadable case is a DELETED workflow — the most consumer-breaking
320
+ * edit there is.
321
+ *
322
+ * @param {string} path Repo-relative POSIX path.
323
+ * @param {{publishedPaths: string[], readFile: (p: string) => string|null}} ctx
324
+ * @returns {{consumerFacing: boolean, surface: string|null}}
325
+ */
326
+ export function classifyFile(path, { publishedPaths = [], readFile = () => null } = {}) {
327
+ const internal = { consumerFacing: false, surface: null };
328
+
329
+ // This repo's own tests ship inside `scripts/`, but they are not a contract
330
+ // any consumer depends on.
331
+ if (/\.test\.mjs$/.test(path)) return internal;
332
+
333
+ if (/^\.github\/actions\//.test(path)) {
334
+ return { consumerFacing: true, surface: "composite action" };
335
+ }
336
+
337
+ if (/^\.github\/workflows\/[^/]+\.ya?ml$/.test(path)) {
338
+ const body = readFile(path);
339
+ if (body === null) {
340
+ return { consumerFacing: true, surface: "reusable workflow (deleted or unreadable)" };
341
+ }
342
+ if (isReusableWorkflow(walkYaml(body))) {
343
+ return { consumerFacing: true, surface: "reusable workflow" };
344
+ }
345
+ return internal;
346
+ }
347
+
348
+ for (const prefix of publishedPaths) {
349
+ if (path === prefix || path.startsWith(`${prefix}/`)) {
350
+ return { consumerFacing: true, surface: "published package file" };
351
+ }
352
+ }
353
+
354
+ return internal;
355
+ }
356
+
357
+ // ---------------------------------------------------------------------------
358
+ // Check
359
+ // ---------------------------------------------------------------------------
360
+
361
+ /**
362
+ * Score a title against a changed-file list. Pure: every input is passed in,
363
+ * so the suite drives it with no repository and no git.
364
+ *
365
+ * @param {Object} input
366
+ * @param {string} input.title
367
+ * @param {string[]} input.files Repo-relative changed paths.
368
+ * @param {{releasable: Set<string>, hidden: Set<string>}|null} input.types
369
+ * @param {string[]} [input.publishedPaths]
370
+ * @param {(p: string) => string|null} [input.readFile]
371
+ * @returns {{status: "ok"|"mismatch"|"skipped", reason?: string, title: string, type: string|null, titleClass: string, detail: string, surfaces: Array<{file: string, surface: string}>}}
372
+ */
373
+ export function runCheck({ title, files, types, publishedPaths = [], readFile = () => null }) {
374
+ const base = { title: String(title || ""), type: null, titleClass: "", detail: "", surfaces: [] };
375
+
376
+ if (!base.title.trim()) {
377
+ return { ...base, status: "skipped", reason: "no pull-request title in context" };
378
+ }
379
+ if (types === null || types === undefined) {
380
+ return {
381
+ ...base,
382
+ status: "skipped",
383
+ reason:
384
+ "no changelog-sections in the release configuration — the hidden/releasable " +
385
+ "split is unanswerable and this check will not guess it",
386
+ };
387
+ }
388
+ if (!Array.isArray(files)) {
389
+ return { ...base, status: "skipped", reason: "the changed-file list is unavailable" };
390
+ }
391
+
392
+ const verdict = classifyTitle(base.title, types);
393
+ const surfaces = [];
394
+ for (const file of files) {
395
+ const { consumerFacing, surface } = classifyFile(file, { publishedPaths, readFile });
396
+ if (consumerFacing) surfaces.push({ file, surface });
397
+ }
398
+
399
+ const result = {
400
+ ...base,
401
+ type: verdict.type,
402
+ titleClass: verdict.titleClass,
403
+ detail: verdict.detail,
404
+ surfaces,
405
+ };
406
+
407
+ // The pair — and only the pair — is the finding.
408
+ if (!verdict.releasable && surfaces.length > 0) return { ...result, status: "mismatch" };
409
+ return { ...result, status: "ok" };
410
+ }
411
+
412
+ // ---------------------------------------------------------------------------
413
+ // Git seam
414
+ // ---------------------------------------------------------------------------
415
+
416
+ /**
417
+ * List the paths a merge of `head` into `base` would change, or null when the
418
+ * range cannot be resolved (a shallow clone, an unknown sha, no git at all).
419
+ * Null degrades this check to a skip — never to a failure.
420
+ *
421
+ * @param {string} repoRoot
422
+ * @param {string} base
423
+ * @param {string} head
424
+ * @returns {string[] | null}
425
+ */
426
+ export function changedFiles(repoRoot, base, head) {
427
+ if (!base || !head) return null;
428
+ try {
429
+ const out = execFileSync("git", ["diff", "--name-only", `${base}...${head}`], {
430
+ cwd: repoRoot,
431
+ encoding: "utf8",
432
+ stdio: ["ignore", "pipe", "ignore"],
433
+ maxBuffer: 16 * 1024 * 1024,
434
+ });
435
+ return out.split("\n").map((l) => l.trim()).filter((l) => l.length > 0);
436
+ } catch {
437
+ return null;
438
+ }
439
+ }
440
+
441
+ // ---------------------------------------------------------------------------
442
+ // CLI
443
+ // ---------------------------------------------------------------------------
444
+
445
+ const USAGE =
446
+ "Usage: node scripts/check-release-type.mjs " +
447
+ "[--title <pr-title>] [--base <sha>] [--head <sha>] [--cwd <dir>] [--config <path>]";
448
+
449
+ /** Read a file relative to `root`, or null when it cannot be read. */
450
+ function reader(root) {
451
+ return (p) => {
452
+ try {
453
+ return readFileSync(join(root, p), "utf8");
454
+ } catch {
455
+ return null;
456
+ }
457
+ };
458
+ }
459
+
460
+ /**
461
+ * Render a `mismatch` result into the three surfaces the finding is reported
462
+ * on. Kept separate from {@link runCli} so the wording — which is the entire
463
+ * product of an advisory check — is asserted directly by the suite instead of
464
+ * through a git range the test would have to stage.
465
+ *
466
+ * @param {ReturnType<typeof runCheck>} result
467
+ * @returns {{lines: string[], annotation: string, summary: string}}
468
+ */
469
+ export function renderReport(result) {
470
+ const label = result.type === null ? "an unparseable title" : `title type \`${result.type}\``;
471
+
472
+ const lines = [
473
+ `[release-type] ⚠️ ${label} cannot cut a release, but this change touches ` +
474
+ `${result.surfaces.length} consumer-facing path(s):`,
475
+ ...result.surfaces.map((s) => ` • ${s.file} [${s.surface}]`),
476
+ `[release-type] ${result.detail}.`,
477
+ "[release-type] Merges here are squash, so the pull-request title IS the release " +
478
+ "type. Landing this as-is publishes the change to `main` and to no consumer. " +
479
+ "Retitle to a releasing type (e.g. `feat:` / `fix:`) if consumers need it, or " +
480
+ "keep the title and confirm the change is genuinely internal.",
481
+ `[release-type] Advisory — this reports exit ${EXIT.mismatch}, which the CI step ` +
482
+ "tolerates by name. The pull request is never failed.",
483
+ ];
484
+
485
+ // GitHub renders `%0A` as a newline inside a single-line workflow command.
486
+ const annotation =
487
+ "::warning title=Consumer-facing change under a non-releasing title::" +
488
+ [
489
+ `${label} cuts no release, but the diff touches the published surface:`,
490
+ ...result.surfaces.map((s) => `• ${s.file} [${s.surface}]`),
491
+ "Retitle to a releasing type if consumers need this change.",
492
+ ].join("%0A");
493
+
494
+ const summary = [
495
+ "### ⚠️ Consumer-facing change under a non-releasing title",
496
+ "",
497
+ `**Title:** \`${result.title}\``,
498
+ "",
499
+ `${result.detail.charAt(0).toUpperCase()}${result.detail.slice(1)}.`,
500
+ "",
501
+ "**Consumer-facing paths in this diff:**",
502
+ "",
503
+ ...result.surfaces.map((s) => `- \`${s.file}\` — ${s.surface}`),
504
+ "",
505
+ "Merges here are squash, so the pull-request title is the release type.",
506
+ "Retitle to a releasing type if consumers need this change — or keep the",
507
+ "title and confirm the change is genuinely internal. This check is",
508
+ "advisory and never fails the pull request.",
509
+ ].join("\n");
510
+
511
+ return { lines, annotation, summary };
512
+ }
513
+
514
+ /**
515
+ * Append a markdown block to the GitHub job summary when one is configured.
516
+ * Best-effort: a summary that cannot be written must never change the outcome
517
+ * of an advisory check.
518
+ */
519
+ function writeSummary(body) {
520
+ const path = process.env.GITHUB_STEP_SUMMARY;
521
+ if (!path) return;
522
+ try {
523
+ appendFileSync(path, `${body}\n`, "utf8");
524
+ } catch {
525
+ /* advisory output only */
526
+ }
527
+ }
528
+
529
+ /**
530
+ * Run the check and return a POSIX exit code from {@link EXIT}. `log` / `err`
531
+ * are injectable so the sibling node:test suite captures output without
532
+ * touching the real streams.
533
+ *
534
+ * @param {string[]} argv
535
+ * @param {{log?: Function, err?: Function}} [io]
536
+ * @returns {number} One of {@link EXIT}.
537
+ */
538
+ export function runCli(argv, { log = console.log, err = console.error } = {}) {
539
+ let opts;
540
+ try {
541
+ opts = parseArgs(argv);
542
+ } catch (e) {
543
+ err(`[release-type] ❌ ${e.message}`);
544
+ err(USAGE);
545
+ return EXIT.usage;
546
+ }
547
+ if (opts.help) {
548
+ log(USAGE);
549
+ return EXIT.ok;
550
+ }
551
+
552
+ const root = resolve(opts.cwd);
553
+ const readFile = reader(root);
554
+ const types = loadReleaseTypes(readFile(opts.config) ?? "");
555
+ const publishedPaths = loadPublishedPaths(readFile("package.json") ?? "");
556
+
557
+ const result = runCheck({
558
+ title: opts.title,
559
+ files: changedFiles(root, opts.base, opts.head),
560
+ types,
561
+ publishedPaths,
562
+ readFile,
563
+ });
564
+
565
+ if (result.status === "skipped") {
566
+ log(`[release-type] ⏭️ skipped — ${result.reason}.`);
567
+ return EXIT.skipped;
568
+ }
569
+
570
+ if (result.status === "ok") {
571
+ log(
572
+ `[release-type] ✅ title type ${result.type === null ? "(unparseable)" : `\`${result.type}\``} ` +
573
+ `— ${result.detail}; ${result.surfaces.length} consumer-facing path(s) touched.`
574
+ );
575
+ return EXIT.ok;
576
+ }
577
+
578
+ const report = renderReport(result);
579
+ for (const line of report.lines) err(line);
580
+ log(report.annotation);
581
+ writeSummary(report.summary);
582
+
583
+ return EXIT.mismatch;
584
+ }
585
+
586
+ // Only run when executed directly, not when imported by the test suite.
587
+ const invokedDirectly =
588
+ process.argv[1] && resolve(process.argv[1]).endsWith("check-release-type.mjs");
589
+ if (invokedDirectly) {
590
+ process.exit(runCli(process.argv.slice(2)));
591
+ }