@norskvideo/ctl-dev-kit 0.2.54 → 0.2.56

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,304 @@
1
+ #!/usr/bin/env bun
2
+ // Recorded guides: the placeholder a docs site builds against when a guide's
3
+ // capture has not run, and the gate that refuses to publish one.
4
+ //
5
+ // A site's recorded guides (the Playwright doc-guide output under a gitignored
6
+ // `docs/generated/`) reach the site as SYMLINKS under its content dir --
7
+ // `website/src/content/docs/guides/<slug> -> ../../../../../docs/generated/<slug>`
8
+ // -- and adding a guide means adding one. The symlinks are the one declaration
9
+ // of which guides exist: there is no list restated here and no name glob (the
10
+ // `zero-to-hero-*` glob this once carried skipped a Manager slug, and the site
11
+ // failed to build for a guide the gate was never even asked about). Each
12
+ // symlink also names the tree it stubs into, so one module serves every site:
13
+ // norsk-ctl's, the Manager's, and each product's.
14
+ //
15
+ // stub <content-dir>... write a placeholder index.md for every declared
16
+ // guide whose target is missing, creating the
17
+ // tree, never touching a file that exists -- so
18
+ // `docs:site` builds on a checkout with no captures.
19
+ // list <content-dir>... the stub file each declared guide would get
20
+ // (absolute, each once), writing nothing; a
21
+ // Makefile takes its prerequisites from this.
22
+ // check [--allow-stub <name>[,<name>]] [--allow-missing <dir>[,<dir>]]
23
+ // [--content <content-dir>]... <generated-dir>...
24
+ // exit 1 naming each placeholder it would publish.
25
+ // A generated dir given directly must be populated
26
+ // (an unpopulated tree is not a passing one; the
27
+ // release run's captures were not restored).
28
+ // --content names a site's content dir instead:
29
+ // its symlink targets are the guides, and a site
30
+ // that declares none has nothing to refuse -- a
31
+ // product site publishes from committed prose and
32
+ // must stay green with no captures anywhere.
33
+ // --allow-stub names guides whose stub is a
34
+ // `::warning::` rather than a refusal (the cloud
35
+ // guides, captured weekly while the site publishes
36
+ // nightly); --allow-missing names generated dirs
37
+ // that may not exist yet.
38
+ // summary [--content <dir>]... <generated-dir>...
39
+ // a Markdown table for the job summary: each
40
+ // guide, live capture or stub, and what it was
41
+ // captured against (the page's front matter).
42
+ //
43
+ // Why the gate: a placeholder is right for a local build and wrong for a
44
+ // deploy. A publish that runs without the captures restored would REPLACE the
45
+ // live guides with placeholders, and nothing else would notice -- the build is
46
+ // green, the links resolve, the pages are just empty. The sentence `stub`
47
+ // writes is what `check` refuses, and both live here so they cannot drift.
48
+ import { existsSync, mkdirSync, readdirSync, readFileSync, readlinkSync, writeFileSync } from "node:fs";
49
+ import { basename, dirname, join, relative, resolve } from "node:path";
50
+
51
+ /** The sentence a placeholder carries, and the one `check` refuses. */
52
+ export const GUIDE_STUB_MARKER = "Content for this guide is recorded by the doc-guide run";
53
+
54
+ /** Generated directories that are not recorded guides: `cli docgen` writes
55
+ * cli-reference as help-all.md, so it has no index.md to stub or to be a
56
+ * stub of. */
57
+ export const NOT_A_GUIDE = new Set(["cli-reference"]);
58
+
59
+ /** A guide a site declares: the symlink's name, and the index.md its target
60
+ * implies. */
61
+ export interface DeclaredGuide {
62
+ name: string;
63
+ /** Absolute path of the symlink. */
64
+ link: string;
65
+ /** Absolute path of the target's index.md -- which may not exist. */
66
+ stub: string;
67
+ }
68
+
69
+ export interface GuideFile {
70
+ path: string;
71
+ /** The guide directory's name -- what `--allow-stub` matches. */
72
+ name: string;
73
+ text: string;
74
+ }
75
+
76
+ function isGeneratedTarget(target: string): boolean {
77
+ return target.split("/").includes("generated");
78
+ }
79
+
80
+ /** The guides a site declares: every symlink under each content dir that
81
+ * points into a `generated` tree. A content dir that does not exist yet is
82
+ * skipped. Resolved as path arithmetic, not realpath: on a fresh checkout the
83
+ * target's parents do not exist. */
84
+ export function declaredGuides(contentDirs: readonly string[]): DeclaredGuide[] {
85
+ const out: DeclaredGuide[] = [];
86
+ for (const contentDir of contentDirs) {
87
+ const dir = resolve(contentDir);
88
+ if (!existsSync(dir)) continue;
89
+ const entries = readdirSync(dir, { withFileTypes: true })
90
+ .filter((e) => e.isSymbolicLink() && !NOT_A_GUIDE.has(e.name))
91
+ .map((e) => e.name)
92
+ .sort();
93
+ for (const name of entries) {
94
+ const link = join(dir, name);
95
+ const target = readlinkSync(link);
96
+ if (!isGeneratedTarget(target)) continue;
97
+ out.push({ name, link, stub: resolve(dirname(link), target, "index.md") });
98
+ }
99
+ }
100
+ return out;
101
+ }
102
+
103
+ /** The same stub reached through two sites' symlinks is one file. */
104
+ function uniqueByStub(guides: DeclaredGuide[]): DeclaredGuide[] {
105
+ const seen = new Set<string>();
106
+ return guides.filter((g) => !seen.has(g.stub) && seen.add(g.stub));
107
+ }
108
+
109
+ export function listGuideStubs(contentDirs: readonly string[]): string[] {
110
+ return uniqueByStub(declaredGuides(contentDirs)).map((g) => g.stub);
111
+ }
112
+
113
+ export function stubText(name: string): string {
114
+ return `---\ntitle: ${name}\n---\n\n${GUIDE_STUB_MARKER}. Run \`bun run docs:guides\` locally to populate it.\n`;
115
+ }
116
+
117
+ /** Write a placeholder for every declared guide whose index.md is missing.
118
+ * A file that exists -- a real capture, or an earlier placeholder -- is never
119
+ * rewritten. */
120
+ export function stubGuides(contentDirs: readonly string[]): { written: string[]; existing: string[] } {
121
+ const written: string[] = [];
122
+ const existing: string[] = [];
123
+ for (const guide of uniqueByStub(declaredGuides(contentDirs))) {
124
+ if (existsSync(guide.stub)) {
125
+ existing.push(guide.stub);
126
+ continue;
127
+ }
128
+ mkdirSync(dirname(guide.stub), { recursive: true });
129
+ writeFileSync(guide.stub, stubText(guide.name));
130
+ written.push(guide.stub);
131
+ }
132
+ return { written, existing };
133
+ }
134
+
135
+ export function stubbedGuides(files: readonly { path: string; text: string }[]): string[] {
136
+ if (files.length === 0) throw new Error("no guides found -- docs/generated was never populated");
137
+ return files.filter((f) => f.text.includes(GUIDE_STUB_MARKER)).map((f) => f.path);
138
+ }
139
+
140
+ /** What a rendered page says it was captured against (the doc-guide renderer
141
+ * writes the block; the values are quoted strings). */
142
+ export function capturedAgainst(text: string): { norsk?: string; norskMgr?: string; when?: string } {
143
+ const head = text.split(/^---$/m)[1] ?? "";
144
+ const block = /^capturedAgainst:\n((?:[ ]{2}\w+: "[^"\n]*"\n)+)/m.exec(head)?.[1] ?? "";
145
+ const out: Record<string, string> = {};
146
+ for (const m of block.matchAll(/^[ ]{2}(\w+): "([^"\n]*)"$/gm)) out[m[1]] = m[2];
147
+ return out;
148
+ }
149
+
150
+ /** The job-summary table: one row per guide. */
151
+ export function summaryTable(files: readonly GuideFile[]): string {
152
+ const rows = files.map((f) => {
153
+ if (f.text.includes(GUIDE_STUB_MARKER)) return `| \`${f.path}\` | **stub** | — |`;
154
+ const a = capturedAgainst(f.text);
155
+ const against = [a.norskMgr && `norsk-mgr ${a.norskMgr}`, a.norsk && `Norsk Manager ${a.norsk}`]
156
+ .filter(Boolean)
157
+ .join(", ");
158
+ return `| \`${f.path}\` | live capture | ${a.when ? `${a.when}${against ? ` — ${against}` : ""}` : "(no capturedAgainst)"} |`;
159
+ });
160
+ return ["| guide | content | captured |", "| --- | --- | --- |", ...rows].join("\n");
161
+ }
162
+
163
+ /** A guide directory without its index.md is as unpublishable as a stub. */
164
+ function readGuide(path: string, name: string, file: string): GuideFile {
165
+ try {
166
+ return { path, name, text: readFileSync(file, "utf8") };
167
+ } catch {
168
+ return { path, name, text: GUIDE_STUB_MARKER };
169
+ }
170
+ }
171
+
172
+ export class GuideTreeMissing extends Error {}
173
+
174
+ export interface ReadGuidesOptions {
175
+ /** Generated dirs that may not exist yet: a warning, not a refusal. */
176
+ allowMissing?: ReadonlySet<string>;
177
+ warn?: (message: string) => void;
178
+ }
179
+
180
+ /** Every guide under each generated dir. A dir that does not exist is a
181
+ * restore failure (GuideTreeMissing) unless it is allowed missing: for a
182
+ * section whose captures come from a run that may not have happened yet,
183
+ * refusing would take the whole site down with it. */
184
+ export function readGuides(dirs: readonly string[], opts: ReadGuidesOptions = {}): GuideFile[] {
185
+ const allowMissing = opts.allowMissing ?? new Set();
186
+ const warn = opts.warn ?? ((m) => console.error(m));
187
+ const files: GuideFile[] = [];
188
+ for (const dir of dirs) {
189
+ let names: string[] = [];
190
+ try {
191
+ names = readdirSync(dir, { withFileTypes: true })
192
+ .filter((e) => e.isDirectory() && !NOT_A_GUIDE.has(e.name))
193
+ .map((e) => e.name)
194
+ .sort();
195
+ } catch {
196
+ if (allowMissing.has(resolve(dir))) {
197
+ warn(`::warning::guide check: ${dir} has never been captured -- its pages will be stubs`);
198
+ continue;
199
+ }
200
+ throw new GuideTreeMissing(`${dir} does not exist -- the release run's captures were not restored`);
201
+ }
202
+ // Paths are reported under the package's name so two roots' guides cannot
203
+ // be confused (norsk-ctl's and norsk-mgr's roots are both called
204
+ // docs/generated; the package dir two levels up tells them apart).
205
+ const label = dirs.length > 1 ? `${basename(resolve(dir, "../.."))}/${basename(dir)}/` : "";
206
+ for (const name of names) files.push(readGuide(`${label}${name}/index.md`, name, join(dir, name, "index.md")));
207
+ }
208
+ return files;
209
+ }
210
+
211
+ /** The guides a site declares, read from their symlink targets. Reported
212
+ * relative to the working directory, which is where the workflow runs. */
213
+ export function readDeclaredGuides(contentDirs: readonly string[], cwd = process.cwd()): GuideFile[] {
214
+ return uniqueByStub(declaredGuides(contentDirs)).map((g) => readGuide(relative(cwd, g.stub), g.name, g.stub));
215
+ }
216
+
217
+ function usage(): never {
218
+ console.error(
219
+ [
220
+ "usage: guide-stubs.ts stub <content-dir>...",
221
+ " guide-stubs.ts list <content-dir>...",
222
+ " guide-stubs.ts check [--allow-stub <name>[,<name>]] [--allow-missing <dir>[,<dir>]] [--content <dir>]... <generated-dir>...",
223
+ " guide-stubs.ts summary [--content <dir>]... <generated-dir>...",
224
+ ].join("\n"),
225
+ );
226
+ process.exit(2);
227
+ }
228
+
229
+ if (import.meta.main) {
230
+ const [cmd, ...rest] = process.argv.slice(2);
231
+ const allowStub = new Set<string>();
232
+ const allowMissing = new Set<string>();
233
+ const contentDirs: string[] = [];
234
+ const dirs: string[] = [];
235
+ for (let i = 0; i < rest.length; i++) {
236
+ const arg = rest[i] as string;
237
+ if (arg === "--allow-stub") {
238
+ for (const n of (rest[++i] ?? "").split(",")) if (n) allowStub.add(n);
239
+ } else if (arg === "--allow-missing") {
240
+ for (const d of (rest[++i] ?? "").split(",")) if (d) allowMissing.add(resolve(d));
241
+ } else if (arg === "--content") {
242
+ contentDirs.push(rest[++i] ?? usage());
243
+ } else if (arg.startsWith("-")) usage();
244
+ else dirs.push(arg);
245
+ }
246
+
247
+ if (cmd === "stub" || cmd === "list") {
248
+ if (contentDirs.length > 0 || dirs.length === 0) usage();
249
+ if (cmd === "list") {
250
+ for (const f of listGuideStubs(dirs)) console.log(f);
251
+ } else {
252
+ for (const f of stubGuides(dirs).written) console.log(`stubbed ${f}`);
253
+ }
254
+ process.exit(0);
255
+ }
256
+ if ((cmd !== "check" && cmd !== "summary") || (dirs.length === 0 && contentDirs.length === 0)) usage();
257
+
258
+ let files: GuideFile[];
259
+ try {
260
+ files = [...readGuides(dirs, { allowMissing }), ...readDeclaredGuides(contentDirs)];
261
+ } catch (e) {
262
+ if (!(e instanceof GuideTreeMissing)) throw e;
263
+ console.error(`::error::guide check: ${e.message}`);
264
+ process.exit(1);
265
+ }
266
+
267
+ if (cmd === "summary") {
268
+ console.log(summaryTable(files));
269
+ process.exit(0);
270
+ }
271
+
272
+ if (files.length === 0 && dirs.length === 0) {
273
+ console.log(`guide check: no recorded guides are declared by ${contentDirs.join(", ")}; nothing to refuse.`);
274
+ process.exit(0);
275
+ }
276
+
277
+ let stubs: string[];
278
+ try {
279
+ stubs = stubbedGuides(files);
280
+ } catch (e) {
281
+ console.error(`::error::guide check: ${(e as Error).message}`);
282
+ process.exit(1);
283
+ }
284
+
285
+ // A tolerated stub is a warning; a tree of nothing but tolerated stubs is
286
+ // still an unpopulated tree.
287
+ const tolerated = files.filter((f) => stubs.includes(f.path) && allowStub.has(f.name)).map((f) => f.path);
288
+ const refused = stubs.filter((s) => !tolerated.includes(s));
289
+ for (const s of tolerated) {
290
+ console.error(
291
+ `::warning::guide check: ${s} is a placeholder -- publishing without it (no capture yet, or none saved)`,
292
+ );
293
+ }
294
+ if (refused.length > 0) {
295
+ console.error(`::error::guide check: ${refused.length} guide(s) are placeholders, refusing to publish:`);
296
+ for (const s of refused) console.error(` ${s}`);
297
+ process.exit(1);
298
+ }
299
+ if (tolerated.length === files.length) {
300
+ console.error("::error::guide check: every guide is a placeholder -- docs/generated was never populated");
301
+ process.exit(1);
302
+ }
303
+ console.log(`guide check: ${files.length} guide(s), ${tolerated.length} tolerated stub(s), the rest real.`);
304
+ }
@@ -0,0 +1,217 @@
1
+ #!/usr/bin/env bun
2
+ // The journey-table lint (documentation plan, October 2026, "Who we write for").
3
+ // Each product's docs/personas.md carries one table saying where each of the
4
+ // four readers starts, which pages they read in order, and where they are
5
+ // handed off — ctl's manual for the Administrator and the Integrator. The
6
+ // table is the product's statement of its journeys; this checks that it exists,
7
+ // covers every persona once, keeps each route to five pages, names only pages
8
+ // that exist, and always ends in a hand-off. A site path `/docs/x/` resolves to
9
+ // website/src/content/docs/x.{md,mdx} or x/index.{md,mdx}; a bare `name.md`
10
+ // resolves under docs/; an https link is a hand-off and is not resolved.
11
+ //
12
+ // Usage: bun journeys.ts [--root <dir>] [--personas docs/personas.md]
13
+ import { existsSync, readFileSync } from "node:fs";
14
+ import { join } from "node:path";
15
+
16
+ export const PERSONAS = ["Evaluator", "Builder", "Integrator", "Administrator"] as const;
17
+ export const MAX_PAGES = 5;
18
+
19
+ export interface JourneyRow {
20
+ persona: string;
21
+ startsAt: string[];
22
+ next: string[];
23
+ handsOffTo: string;
24
+ /** 1-based line of the row in the personas file. */
25
+ line: number;
26
+ }
27
+
28
+ export interface JourneyTable {
29
+ rows: JourneyRow[];
30
+ /** 1-based line of the header row. */
31
+ line: number;
32
+ }
33
+
34
+ export interface JourneyFinding {
35
+ file: string;
36
+ line: number;
37
+ message: string;
38
+ }
39
+
40
+ const CELL_PATH = /`([^`]+)`|\]\(([^)]+)\)|(^|\s)(\/docs\/\S*|\S+\.mdx?)(?=\s|,|$)/g;
41
+
42
+ /** Every path-like reference in a table cell: backticked, a markdown link
43
+ * target, or a bare `/docs/...` or `*.md` token. */
44
+ export function cellPaths(cell: string): string[] {
45
+ const out: string[] = [];
46
+ for (const m of cell.matchAll(CELL_PATH)) {
47
+ const p = (m[1] ?? m[2] ?? m[4] ?? "").trim();
48
+ if (p) out.push(p);
49
+ }
50
+ return out;
51
+ }
52
+
53
+ /** A persona cell may be bold or a link to its section; the name is the text. */
54
+ function plainText(cell: string): string {
55
+ return cell
56
+ .replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
57
+ .replace(/[*_`]/g, "")
58
+ .trim();
59
+ }
60
+
61
+ function splitRow(line: string): string[] {
62
+ return line
63
+ .trim()
64
+ .replace(/^\|/, "")
65
+ .replace(/\|$/, "")
66
+ .split("|")
67
+ .map((c) => c.trim());
68
+ }
69
+
70
+ export function parseJourneyTable(markdown: string): JourneyTable | undefined {
71
+ const lines = markdown.split("\n");
72
+ for (let i = 0; i < lines.length - 1; i++) {
73
+ const line = lines[i] ?? "";
74
+ if (!line.trim().startsWith("|")) continue;
75
+ const header = splitRow(line).map((c) => c.toLowerCase());
76
+ const col = (want: string) => header.findIndex((h) => h.startsWith(want));
77
+ const persona = col("persona");
78
+ const startsAt = col("starts at");
79
+ const then = col("then");
80
+ const handsOff = col("hands off");
81
+ if (persona < 0 || startsAt < 0 || then < 0 || handsOff < 0) continue;
82
+ if (!/^\s*\|?\s*:?-+/.test(lines[i + 1] ?? "")) continue;
83
+ const rows: JourneyRow[] = [];
84
+ for (let j = i + 2; j < lines.length; j++) {
85
+ const row = lines[j] ?? "";
86
+ if (!row.trim().startsWith("|")) break;
87
+ const cells = splitRow(row);
88
+ rows.push({
89
+ persona: plainText(cells[persona] ?? ""),
90
+ startsAt: cellPaths(cells[startsAt] ?? ""),
91
+ next: cellPaths(cells[then] ?? ""),
92
+ handsOffTo: cells[handsOff] ?? "",
93
+ line: j + 1,
94
+ });
95
+ }
96
+ return { rows, line: i + 1 };
97
+ }
98
+ return undefined;
99
+ }
100
+
101
+ const SITE_DOCS = "website/src/content/docs";
102
+
103
+ function hasReaders(root: string): boolean {
104
+ return (
105
+ existsSync(join(root, SITE_DOCS)) ||
106
+ existsSync(join(root, "website", "astro.config.mjs")) ||
107
+ existsSync(join(root, "docs", "quickstart.md"))
108
+ );
109
+ }
110
+
111
+ /** Where a route's page lives in the repo, or undefined for a link that is a
112
+ * hand-off rather than a page (https, mailto). */
113
+ export function candidatePaths(ref: string): string[] | undefined {
114
+ const bare = ref.replace(/[#?].*$/, "");
115
+ if (/^[a-z]+:/i.test(bare)) return undefined;
116
+ if (bare.startsWith("/docs/") || bare === "/docs") {
117
+ const rel = bare.replace(/^\/docs\/?/, "").replace(/\/$/, "");
118
+ const base = rel === "" ? "index" : rel;
119
+ return [
120
+ `${SITE_DOCS}/${base}.mdx`,
121
+ `${SITE_DOCS}/${base}.md`,
122
+ `${SITE_DOCS}/${base}/index.mdx`,
123
+ `${SITE_DOCS}/${base}/index.md`,
124
+ ];
125
+ }
126
+ if (bare.startsWith("docs/")) return [bare];
127
+ return [`docs/${bare}`, bare];
128
+ }
129
+
130
+ export function checkJourneys(root: string, personasFile = "docs/personas.md"): JourneyFinding[] {
131
+ const file = join(root, personasFile);
132
+ if (!existsSync(file)) {
133
+ // A repo with no site and no quickstart has no readers to route: a
134
+ // backend-only turnkey ships a handover to one customer and is not asked
135
+ // for four journeys. Anything with a Start here page is.
136
+ if (!hasReaders(root)) return [];
137
+ return [
138
+ {
139
+ file: personasFile,
140
+ line: 1,
141
+ message: "missing; every product keeps its persona mapping and journey table here (conventions/personas.md).",
142
+ },
143
+ ];
144
+ }
145
+ const table = parseJourneyTable(readFileSync(file, "utf8"));
146
+ if (!table) {
147
+ return [
148
+ {
149
+ file: personasFile,
150
+ line: 1,
151
+ message:
152
+ 'no journey table: expected a table with columns Persona | Starts at | Then, in order | Hands off to (documentation plan, "Who we write for").',
153
+ },
154
+ ];
155
+ }
156
+ const findings: JourneyFinding[] = [];
157
+ const seen = new Map<string, number>();
158
+ for (const row of table.rows) {
159
+ seen.set(row.persona, (seen.get(row.persona) ?? 0) + 1);
160
+ const pages = [...row.startsAt, ...row.next];
161
+ if (pages.length > MAX_PAGES) {
162
+ findings.push({
163
+ file: personasFile,
164
+ line: row.line,
165
+ message: `${row.persona}: ${pages.length} pages on the route; the limit is ${MAX_PAGES}. A sixth page is in the wrong section or should not exist.`,
166
+ });
167
+ }
168
+ for (const ref of pages) {
169
+ const candidates = candidatePaths(ref);
170
+ if (!candidates) continue;
171
+ if (!candidates.some((c) => existsSync(join(root, c)))) {
172
+ findings.push({
173
+ file: personasFile,
174
+ line: row.line,
175
+ message: `${row.persona}: ${ref} is not a page in this repo (looked for ${candidates.join(", ")}).`,
176
+ });
177
+ }
178
+ }
179
+ if (!/\S/.test(row.handsOffTo) || cellPaths(row.handsOffTo).length === 0) {
180
+ findings.push({
181
+ file: personasFile,
182
+ line: row.line,
183
+ message: `${row.persona}: no hand-off. Every route ends in a link: the next section, or norsk-ctl's manual.`,
184
+ });
185
+ }
186
+ }
187
+ for (const persona of PERSONAS) {
188
+ const n = seen.get(persona) ?? 0;
189
+ if (n === 0)
190
+ findings.push({ file: personasFile, line: table.line, message: `${persona} has no row in the journey table.` });
191
+ else if (n > 1)
192
+ findings.push({
193
+ file: personasFile,
194
+ line: table.line,
195
+ message: `${persona} has ${n} rows in the journey table; one route each.`,
196
+ });
197
+ }
198
+ return findings;
199
+ }
200
+
201
+ if (import.meta.main) {
202
+ const argv = process.argv.slice(2);
203
+ let root = process.cwd();
204
+ let personas = "docs/personas.md";
205
+ for (let i = 0; i < argv.length; i++) {
206
+ if (argv[i] === "--root") root = argv[++i] ?? root;
207
+ else if (argv[i] === "--personas") personas = argv[++i] ?? personas;
208
+ }
209
+ const findings = checkJourneys(root, personas);
210
+ if (findings.length === 0) {
211
+ console.log("journeys: OK");
212
+ } else {
213
+ console.error(`journeys: ${findings.length} finding${findings.length === 1 ? "" : "s"}`);
214
+ for (const f of findings) console.error(` - ${f.file}:${f.line}: ${f.message}`);
215
+ process.exit(1);
216
+ }
217
+ }