@kontourai/corpus 0.0.0-bootstrap.0 → 0.8.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.
package/src/check.mjs ADDED
@@ -0,0 +1,520 @@
1
+ import { checkBuiltOn } from './built-on.mjs';
2
+ import { reviewedContentDocuments } from './content-freshness.mjs';
3
+ import { readFileSync } from 'node:fs';
4
+ import path from 'node:path';
5
+ import { EXIT_GATE, EXIT_OK, EXIT_USAGE, UsageError } from './errors.mjs';
6
+ import picomatch from 'picomatch';
7
+ import { createAssetPolicy } from './assets.mjs';
8
+ import { checkAdrFreeze, validateDecisions } from './decisions.mjs';
9
+ import { changedFiles, trackedFiles, verifyBase } from './git.mjs';
10
+ import { INIT_PURPOSE_PLACEHOLDER, manifestFile, duplicateRedirectKeys, globMatcher, outputPathFor, readManifestJson, splitTopicSource, validateManifest } from './manifest.mjs';
11
+ import { analyzeMarkdown, resolveLink, splitFrontMatter } from './markdown.mjs';
12
+ import { listFiles, listMarkdownFiles, statInRepo, toFsPath } from './paths.mjs';
13
+
14
+ /** Lazily lexes repo Markdown so each file is read and analyzed once per run. */
15
+ export function createDocumentReader(root) {
16
+ const cache = new Map();
17
+ return (repoPath) => {
18
+ if (!cache.has(repoPath)) {
19
+ // Front matter is stripped once here, so links, anchors, topics, the
20
+ // rendered page, and the bundle markdown all see the same body.
21
+ const { body, lineOffset, title } = splitFrontMatter(readFileSync(toFsPath(root, repoPath), 'utf8'));
22
+ cache.set(repoPath, { markdown: body, lineOffset, analysis: analyzeMarkdown(body, { lineOffset, frontMatterTitle: title }) });
23
+ }
24
+ return cache.get(repoPath);
25
+ };
26
+ }
27
+
28
+ /**
29
+ * Sources that get a page, in site order: landing first, then documents in
30
+ * manifest order, without bundle-only (`publish: false`) documents.
31
+ */
32
+ export function publishedSources(manifest) {
33
+ return [manifest.landing, ...manifest.documents.filter((document) => document.publish).map((document) => document.source)];
34
+ }
35
+
36
+ /** Every source in docs-bundle.json: the published pages plus bundle-only documents. */
37
+ export function bundledSources(manifest) {
38
+ return [manifest.landing, ...manifest.documents.map((document) => document.source)];
39
+ }
40
+
41
+ /**
42
+ * Gate 2: Markdown links and images, plus raw-HTML href/src/srcset, in
43
+ * published and bundle-only sources. External URLs are not fetched. A leading `/` resolves
44
+ * from --root. References to files under .git or (inside git) untracked or
45
+ * ignored files are unpublishable and fail.
46
+ */
47
+ export function checkLinks(root, manifest, read) {
48
+ const failures = [];
49
+ const published = new Set(publishedSources(manifest));
50
+ const assetPolicy = createAssetPolicy(root);
51
+ for (const source of bundledSources(manifest)) {
52
+ const { analysis } = read(source);
53
+ for (const link of analysis.links) {
54
+ const where = `${source}${link.line ? `:${link.line}` : ''}`;
55
+ const at =
56
+ link.kind === 'html' ? `${where}: <... ${link.attr}="${link.href}">` : `${where}: ${link.kind === 'image' ? '!' : ''}[${link.text}](${link.href})`;
57
+ const target = resolveLink(link.href, source);
58
+ if (target.kind === 'external') continue;
59
+ if (target.kind === 'invalid') {
60
+ failures.push(`${at} - ${target.reason}`);
61
+ continue;
62
+ }
63
+ if (target.kind === 'fragment') {
64
+ if (target.fragment && !analysis.anchors.has(target.fragment))
65
+ failures.push(`${at} - missing anchor #${target.fragment} in ${source}`);
66
+ continue;
67
+ }
68
+ const stat = statInRepo(root, target.path);
69
+ if (!stat) {
70
+ failures.push(`${at} - missing target ${target.path || '/'}`);
71
+ continue;
72
+ }
73
+ if (!published.has(target.path)) {
74
+ const policy = assetPolicy(target.path, stat.kind);
75
+ if (policy.action === 'unpublishable') failures.push(`${at} - unpublishable reference: ${target.path} ${policy.reason}`);
76
+ }
77
+ if (target.fragment && stat.kind === 'file' && target.path.endsWith('.md')) {
78
+ const { analysis: targetAnalysis } = read(target.path);
79
+ if (!targetAnalysis.anchors.has(target.fragment))
80
+ failures.push(`${at} - missing anchor #${target.fragment} in ${target.path}`);
81
+ }
82
+ }
83
+ }
84
+ return failures;
85
+ }
86
+
87
+ /**
88
+ * Gate 3: every docs/**\/*.md is listed (bundle-only documents count) or excluded.
89
+ * With a `decisions` block, every `*.md` directly in `decisions.dir` (the records
90
+ * and the index) is reachable without being listed: the decisions gate already
91
+ * reads and validates each one. Listing them still decides whether they are published.
92
+ */
93
+ export function checkReachability(root, manifest) {
94
+ const listed = new Set(bundledSources(manifest));
95
+ const isExcluded = globMatcher(manifest.exclude);
96
+ const registry = manifest.decisions?.dir ?? null;
97
+ const inRegistry = (file) => registry !== null && path.posix.dirname(file) === registry;
98
+ return listMarkdownFiles(root, 'docs')
99
+ .filter((file) => !listed.has(file) && !isExcluded(file) && !inRegistry(file))
100
+ .map((file) => `${file} is neither listed in documents nor matched by exclude`);
101
+ }
102
+
103
+ /** Gate 4: topic anchors resolve to headings outside blockquotes and lists (GitHub slug rules). */
104
+ export function checkTopics(manifest, read) {
105
+ const failures = [];
106
+ manifest.topics.forEach((topic) => {
107
+ const { path, anchor } = splitTopicSource(topic.source);
108
+ if (anchor === null) return;
109
+ const { analysis } = read(path);
110
+ const matches = analysis.headings.filter((heading) => heading.slug === anchor);
111
+ if (matches.length === 0) {
112
+ const known = analysis.headings.map((heading) => `#${heading.slug}`).slice(0, 12).join(', ');
113
+ failures.push(`topic "${topic.id}": #${anchor} does not match a heading in ${path}${known ? ` (headings: ${known})` : ''}`);
114
+ } else if (!matches.some((heading) => heading.topLevel)) {
115
+ failures.push(`topic "${topic.id}": #${anchor} in ${path} is a heading nested in a quote or list; topics need a heading outside blockquotes and lists`);
116
+ }
117
+ });
118
+ return failures;
119
+ }
120
+
121
+ /** Files every build writes besides pages and copied assets. */
122
+ export const SITE_FILES = ['docs-kit.css', '.nojekyll', 'docs-bundle.json', '.docs-kit-build'];
123
+
124
+ /**
125
+ * The comparison key for output paths: NFC-normalized and lowercased, so two
126
+ * spellings that one file on macOS or Windows would hold compare equal.
127
+ */
128
+ export function foldPath(file) {
129
+ return file.normalize('NFC').toLowerCase();
130
+ }
131
+
132
+ /** `a/b/c.html` -> ['a', 'a/b']. */
133
+ function ancestors(file) {
134
+ const parts = file.split('/');
135
+ return parts.slice(0, -1).map((_, index) => parts.slice(0, index + 1).join('/'));
136
+ }
137
+
138
+ /**
139
+ * Gate 5: every redirects key is written once and overwrites nothing docs-kit
140
+ * generates (compared after NFC normalization and case folding, because macOS
141
+ * and Windows checkouts
142
+ * would clobber), and every source target is published with a resolvable
143
+ * #anchor (the links-gate anchor rule). Copied assets are only known after
144
+ * rendering, so build checks those collisions itself.
145
+ */
146
+ export function checkRedirects(root, manifest, read) {
147
+ const failures = [];
148
+ if (manifest.redirects.length === 0) return failures;
149
+ const file = manifestFile(root);
150
+ for (const key of duplicateRedirectKeys(readFileSync(toFsPath(root, file), 'utf8')))
151
+ failures.push(`redirects["${key}"] appears more than once in ${file}`);
152
+
153
+ const written = new Map(); // lowercased path -> description
154
+ for (const source of publishedSources(manifest)) written.set(foldPath(outputPathFor(manifest, source)), `the page generated for ${source}`);
155
+ for (const file of SITE_FILES) written.set(foldPath(file), `the generated ${file}`);
156
+ const keys = new Map(manifest.redirects.map(({ from }) => [foldPath(from), from]));
157
+ const published = new Set(publishedSources(manifest));
158
+
159
+ for (const { from, source, anchor } of manifest.redirects) {
160
+ const at = `redirects["${from}"]`;
161
+ const lower = foldPath(from);
162
+ if (written.has(lower)) failures.push(`${at} would overwrite ${written.get(lower)}`);
163
+ else if (keys.get(lower) !== from) failures.push(`${at} differs only in case or Unicode normalization from redirects["${keys.get(lower)}"]`);
164
+ for (const dir of ancestors(lower)) {
165
+ if (written.has(dir)) failures.push(`${at} needs a directory where docs-kit writes ${written.get(dir)}`);
166
+ else if (keys.has(dir)) failures.push(`${at} needs a directory where redirects["${keys.get(dir)}"] writes a page`);
167
+ }
168
+ for (const [file, what] of written)
169
+ if (ancestors(file).includes(lower)) failures.push(`${at} would occupy a directory that holds ${what}`);
170
+
171
+ if (source === undefined) continue;
172
+ if (!statInRepo(root, source)) {
173
+ failures.push(`${at}: target "${source}" does not exist`);
174
+ continue;
175
+ }
176
+ if (!published.has(source)) {
177
+ if (manifest.documents.some((document) => document.source === source))
178
+ failures.push(`${at}: target "${source}" is bundle-only (publish: false) and has no page; a redirect needs a page or an https:// URL`);
179
+ else failures.push(
180
+ `${at}: target "${source}" is not published; a redirect must point at the landing page, a listed document, or an https:// URL`,
181
+ );
182
+ continue;
183
+ }
184
+ if (anchor !== null && !read(source).analysis.anchors.has(anchor))
185
+ failures.push(`${at}: target "${source}#${anchor}" - missing anchor #${anchor} in ${source}`);
186
+ }
187
+ return failures;
188
+ }
189
+
190
+ /**
191
+ * Absolute home-directory paths: `/Users/<name>/`, `/home/<name>/`, and
192
+ * `C:\Users\<name>\` (any drive letter, either slash, doubled backslashes as
193
+ * in escaped JSON). The lookbehind keeps `https://host/home/x/` and
194
+ * `/srv/home/x/` out; `file:///Users/x/` still matches. A name made of
195
+ * placeholder characters (`<name>`, `$USER`, `%USERNAME%`, `{user}`) is not a
196
+ * real path and does not match. `/Users/Shared/` (macOS shared folder) and
197
+ * `/home/runner/` (GitHub Actions runner) are not personal and are exempt.
198
+ */
199
+ const LOCAL_PATH = /(?<![\w.~-])(?:\/(?:Users\/(?!Shared\/)|home\/(?!runner\/))[^\s/\\<>"'`$%{}()[\]*]+\/|[A-Za-z]:(?:\\{1,2}|\/)Users(?:\\{1,2}|\/)[^\s/\\<>"'`$%{}()[\]*]+(?:\\|\/))/g;
200
+
201
+ /**
202
+ * Gate 6: no published or bundled document, nor the landing page, contains an
203
+ * absolute home-directory path, in prose, code, links, or raw HTML. The whole
204
+ * body is scanned as text (front matter is never published). Documents listed
205
+ * in `allowLocalPaths` are skipped.
206
+ */
207
+ export function checkLocalPaths(manifest, read) {
208
+ const failures = [];
209
+ const allowed = new Set(manifest.allowLocalPaths);
210
+ for (const source of bundledSources(manifest)) {
211
+ if (allowed.has(source)) continue;
212
+ const { markdown, lineOffset } = read(source);
213
+ markdown.split('\n').forEach((line, index) => {
214
+ for (const [hit] of line.matchAll(LOCAL_PATH))
215
+ failures.push(`${source}:${index + 1 + lineOffset}: local path "${hit}" (list the file in allowLocalPaths if it is a deliberate example)`);
216
+ });
217
+ }
218
+ return failures;
219
+ }
220
+
221
+ /**
222
+ * Gate 7: every `covers` glob matches at least one tracked file (every file
223
+ * outside git). A glob that matches nothing can never mark its document stale.
224
+ */
225
+ export function checkCovers(root, manifest) {
226
+ const failures = [];
227
+ let files;
228
+ manifest.documents.forEach((document) => {
229
+ document.covers.forEach((glob, index) => {
230
+ files ??= trackedFiles(root) ?? listFiles(root);
231
+ const matches = picomatch(glob, { dot: true });
232
+ if (files.some((file) => matches(file))) return;
233
+ const written = document.coversAsWritten[index];
234
+ failures.push(`${document.source}: covers "${written}" matches no tracked file${written !== glob ? ` (expanded to "${glob}")` : ''}`);
235
+ });
236
+ });
237
+ return failures;
238
+ }
239
+
240
+ /**
241
+ * Gate 8: a document whose `covers` globs match a changed file must itself be
242
+ * in the change set. Git errors throw UsageError; they never pass silently.
243
+ */
244
+ export function checkFreshness(root, manifest, base) {
245
+ const changed = changedFiles(root, base);
246
+ const changedSet = new Set(changed);
247
+ const failures = [];
248
+ const stale = [];
249
+ const coveredDocuments = manifest.documents.map((document) => ({
250
+ source: document.source,
251
+ covered: changed.filter(globMatcher(document.covers)),
252
+ }));
253
+ const reviewed = reviewedContentDocuments(root, base, coveredDocuments, changed);
254
+ for (const document of manifest.documents) {
255
+ if (document.covers.length === 0) continue;
256
+ const covered = changed.filter(globMatcher(document.covers));
257
+ if (covered.length === 0 || changedSet.has(document.source) || reviewed.has(document.source)) continue;
258
+ const sample = covered.slice(0, 5).join(', ') + (covered.length > 5 ? `, +${covered.length - 5} more` : '');
259
+ failures.push(`${document.source} is stale: covered files changed since ${base} (${sample}) but the document did not`);
260
+ stale.push({ source: document.source, kind: STALE_KIND.freshness, changed: covered });
261
+ }
262
+ return { failures, stale, changedCount: changed.length };
263
+ }
264
+
265
+ /**
266
+ * Why a document is stale, by the gate that found it. Only the freshness gate
267
+ * finds stale documents today: their `covers` sources changed and they did
268
+ * not. Every stale document in a report carries one of these kinds, never a
269
+ * bare "stale" flag.
270
+ */
271
+ export const STALE_KIND = Object.freeze({ freshness: 'docs-covers' });
272
+
273
+ /** Every gate `check` can report, in report order. `--only` accepts these names. */
274
+ export const GATE_NAMES = Object.freeze(['manifest', 'purpose', 'builtOn', 'links', 'reachability', 'topics', 'redirects', 'paths', 'covers', 'decisions', 'adr-freeze', 'freshness']);
275
+
276
+ /** Parse `--only a,b`: known gate names, at least one. */
277
+ export function parseOnly(value) {
278
+ const names = value.split(',').map((name) => name.trim()).filter(Boolean);
279
+ if (names.length === 0) throw new UsageError('--only needs at least one gate name');
280
+ const unknown = names.filter((name) => !GATE_NAMES.includes(name));
281
+ if (unknown.length > 0) throw new UsageError(`--only: unknown gate ${unknown.map((name) => `"${name}"`).join(', ')}; gates are ${GATE_NAMES.join(', ')}`);
282
+ return [...new Set(names)];
283
+ }
284
+
285
+ const ONLY_HINTS = {
286
+ freshness: 'pass --base <git-ref>',
287
+ decisions: 'the manifest declares no "decisions" block',
288
+ 'adr-freeze': 'it needs --base <git-ref> and a "decisions.adr" block',
289
+ };
290
+
291
+ /**
292
+ * Run the gates. Returns { exitCode, gates, manifest } and never throws for
293
+ * gate failures. An invalid manifest is a configuration error (exit 2) and the
294
+ * dependent gates are reported as skipped.
295
+ */
296
+ export function runChecks({ root, base = null, advisory = false, freshness = true, only = null }) {
297
+ // A flag no selected gate reads would be silently ignored, and a typo in --base never checked.
298
+ if (only !== null && base !== null && !only.includes('freshness') && !only.includes('adr-freeze'))
299
+ throw new UsageError(`--base only applies to the freshness and adr-freeze gates; --only ${only.join(',')} selects neither`);
300
+ if (only !== null && advisory && !only.includes('freshness'))
301
+ throw new UsageError(`--advisory only applies to the freshness gate; --only ${only.join(',')} does not select it`);
302
+ const result = runSelectedChecks({ root, base, advisory, freshness, only });
303
+ // A selected gate that did not run must not read as a pass: a lane that
304
+ // asks for one gate never passes by checking nothing.
305
+ if (only !== null && result.manifest !== null) {
306
+ for (const name of only) {
307
+ const gate = result.gates.find((entry) => entry.name === name);
308
+ if (gate && !gate.skipped) continue;
309
+ throw new UsageError(`--only ${name}: the gate did not run (${gate?.skipped ?? ONLY_HINTS[name] ?? 'it does not apply to this run'})`);
310
+ }
311
+ }
312
+ return result;
313
+ }
314
+
315
+ function runSelectedChecks({ root, base, advisory, freshness, only }) {
316
+ const gates = [];
317
+ let exitCode = EXIT_OK;
318
+ const raise = (code) => {
319
+ exitCode = Math.max(exitCode, code);
320
+ };
321
+ // The manifest always runs: every other gate depends on it.
322
+ const selected = (name) => only === null || only.includes(name);
323
+
324
+ const { manifest, errors } = validateManifest(readManifestJson(root), root, manifestFile(root));
325
+ gates.push({ name: 'manifest', failures: errors, failureExit: EXIT_USAGE });
326
+ if (errors.length > 0) {
327
+ raise(EXIT_USAGE);
328
+ for (const name of ['purpose', 'links', 'reachability', 'topics', 'redirects', 'paths', ...(freshness ? ['covers'] : []), ...(freshness && base ? ['freshness'] : [])].filter(selected))
329
+ gates.push({ name, skipped: `${manifestFile(root)} is invalid` });
330
+ return { exitCode, gates, manifest: null };
331
+ }
332
+
333
+ // An exact match only: a heuristic on "TODO" would also refuse real sentences.
334
+ // A gate failure (exit 1), not a manifest error, so a new adopter still sees every other gate.
335
+ if (selected('purpose')) {
336
+ const purposeFailures = manifest.purpose === INIT_PURPOSE_PLACEHOLDER
337
+ ? [`${manifestFile(root)}: purpose is still the init placeholder; replace it with one sentence saying what this product is for`]
338
+ : [];
339
+ gates.push({ name: 'purpose', failures: purposeFailures });
340
+ if (purposeFailures.length > 0) raise(EXIT_GATE);
341
+ }
342
+
343
+ if (selected('builtOn')) {
344
+ const builtOn = checkBuiltOn(root, manifest);
345
+ gates.push({ name: 'builtOn', ...builtOn });
346
+ if (builtOn.failures?.length) raise(EXIT_GATE);
347
+ }
348
+
349
+ const read = createDocumentReader(root);
350
+ for (const [name, run] of [
351
+ ['links', () => checkLinks(root, manifest, read)],
352
+ ['reachability', () => checkReachability(root, manifest)],
353
+ ['topics', () => checkTopics(manifest, read)],
354
+ ['redirects', () => checkRedirects(root, manifest, read)],
355
+ ['paths', () => checkLocalPaths(manifest, read)],
356
+ // covers only feeds freshness, so build (freshness: false) is not blocked by it.
357
+ ...(freshness ? [['covers', () => checkCovers(root, manifest)]] : []),
358
+ ]) {
359
+ if (!selected(name)) continue;
360
+ const failures = run();
361
+ gates.push({ name, failures });
362
+ if (failures.length > 0) raise(EXIT_GATE);
363
+ }
364
+
365
+ if (manifest.decisions && (selected('decisions') || selected('adr-freeze'))) {
366
+ // decisions and adr-freeze share one pass, which pushes each gate and then
367
+ // raises its code; an unselected gate is dropped, and so is its exit code.
368
+ const decisionGates = [];
369
+ runDecisionGates(root, manifest, freshness ? base : undefined, decisionGates, (code) => {
370
+ if (selected(decisionGates.at(-1).name)) raise(code);
371
+ });
372
+ gates.push(...decisionGates.filter((gate) => selected(gate.name)));
373
+ }
374
+
375
+ if (freshness && base !== null && selected('freshness')) {
376
+ try {
377
+ const { failures, stale, changedCount } = checkFreshness(root, manifest, base);
378
+ if (advisory) {
379
+ gates.push({ name: 'freshness', failures: [], warnings: failures, stale, note: `${changedCount} changed files since ${base}, advisory` });
380
+ } else {
381
+ gates.push({ name: 'freshness', failures, stale, note: `${changedCount} changed files since ${base}` });
382
+ if (failures.length > 0) raise(EXIT_GATE);
383
+ }
384
+ } catch (error) {
385
+ if (!(error instanceof UsageError)) throw error;
386
+ gates.push({ name: 'freshness', error: error.message });
387
+ raise(EXIT_USAGE);
388
+ }
389
+ }
390
+ return { exitCode, gates, manifest, read };
391
+ }
392
+
393
+ /**
394
+ * Gate 9 (`decisions`): every decision record is valid and the index is
395
+ * fresh. Gate 10 (`adr-freeze`, when `decisions.adr` is declared): no file was
396
+ * added under the frozen ADR directory since `base`. With base === null the
397
+ * freeze is reported as skipped; with base === undefined (build) it is omitted.
398
+ */
399
+ function runDecisionGates(root, manifest, base, gates, raise) {
400
+ const { records, failures } = validateDecisions(root, manifest.decisions);
401
+ gates.push({ name: 'decisions', failures, note: `${records.length} record${records.length === 1 ? '' : 's'} in ${manifest.decisions.dir}` });
402
+ if (failures.length > 0) raise(EXIT_GATE);
403
+ if (!manifest.decisions.adr || base === undefined) return;
404
+ if (base === null) {
405
+ gates.push({ name: 'adr-freeze', skipped: `pass --base <git-ref> to refuse ADRs added under ${manifest.decisions.adr.dir}` });
406
+ return;
407
+ }
408
+ try {
409
+ const frozen = checkAdrFreeze(root, manifest.decisions, base);
410
+ gates.push({ name: 'adr-freeze', failures: frozen, note: `${manifest.decisions.adr.dir} against ${base}` });
411
+ if (frozen.length > 0) raise(EXIT_GATE);
412
+ } catch (error) {
413
+ if (!(error instanceof UsageError)) throw error;
414
+ gates.push({ name: 'adr-freeze', error: error.message });
415
+ raise(EXIT_USAGE);
416
+ }
417
+ }
418
+
419
+ /**
420
+ * `corpus decisions check`: the manifest, then only the decisions gates.
421
+ * Exit codes match `check`. A manifest without a `decisions` block is a
422
+ * configuration error, so a required lane never passes by checking nothing.
423
+ */
424
+ export function runDecisionChecks({ root, base = null }) {
425
+ const gates = [];
426
+ let exitCode = EXIT_OK;
427
+ const raise = (code) => {
428
+ exitCode = Math.max(exitCode, code);
429
+ };
430
+ const file = manifestFile(root);
431
+ const { manifest, errors } = validateManifest(readManifestJson(root), root, file);
432
+ gates.push({ name: 'manifest', failures: errors, failureExit: EXIT_USAGE });
433
+ if (errors.length > 0) {
434
+ gates.push({ name: 'decisions', skipped: `${file} is invalid` });
435
+ return { exitCode: EXIT_USAGE, gates, manifest: null };
436
+ }
437
+ if (!manifest.decisions)
438
+ throw new UsageError(`${file} has no "decisions" block; add "decisions": {} to enable the decisions gate`);
439
+ runDecisionGates(root, manifest, base, gates, raise);
440
+ // With no ADR freeze declared, --base has nothing to check, but a bad ref still must not pass silently.
441
+ if (base !== null && !manifest.decisions.adr) {
442
+ try {
443
+ verifyBase(root, base);
444
+ gates.push({ name: 'adr-freeze', skipped: 'decisions.adr is not declared' });
445
+ } catch (error) {
446
+ if (!(error instanceof UsageError)) throw error;
447
+ gates.push({ name: 'adr-freeze', error: error.message });
448
+ raise(EXIT_USAGE);
449
+ }
450
+ }
451
+ return { exitCode, gates, manifest };
452
+ }
453
+
454
+ export function formatCheckReport({ gates, exitCode }, root, { title = 'docs-kit check' } = {}) {
455
+ const lines = [`${title} ${root}`];
456
+ for (const gate of gates) {
457
+ if (gate.skipped) {
458
+ lines.push(` SKIP ${gate.name} (${gate.skipped})`);
459
+ continue;
460
+ }
461
+ if (gate.error) {
462
+ lines.push(` ERROR ${gate.name}`);
463
+ lines.push(...gate.error.split('\n').map((line) => ` ${line}`));
464
+ continue;
465
+ }
466
+ const status = gate.failures.length > 0 ? 'FAIL' : gate.warnings?.length ? 'WARN' : 'PASS';
467
+ lines.push(` ${status} ${gate.name}${gate.note ? ` (${gate.note})` : ''}`);
468
+ for (const failure of gate.failures) lines.push(` - ${failure}`);
469
+ for (const warning of gate.warnings ?? []) lines.push(` warning: ${warning}`);
470
+ }
471
+ const failed = gates.reduce((sum, gate) => sum + (gate.failures?.length ?? 0) + (gate.error ? 1 : 0), 0);
472
+ if (exitCode === EXIT_OK) lines.push(`${title} passed`);
473
+ else if (exitCode === EXIT_USAGE) lines.push(`${title}: configuration error (${failed} problem${failed === 1 ? '' : 's'})`);
474
+ else lines.push(`${title} failed: ${failed} failure${failed === 1 ? '' : 's'}`);
475
+ return lines.join('\n');
476
+ }
477
+
478
+ const gateStatus = (gate) => (gate.skipped ? 'skip' : gate.error ? 'error' : gate.failures.length > 0 ? 'fail' : gate.warnings?.length ? 'warn' : 'pass');
479
+
480
+ /**
481
+ * `check --json`: the same result as one JSON object, for tools that consume
482
+ * a verdict, such as a Veritas external-tool evidence check. `verdict` is
483
+ * `pass`, `warn` (exit 0 with warnings, as with --advisory), or `fail` (exit 1
484
+ * or 2; `exitCode` tells them apart). `summary` holds numeric counts. Each
485
+ * stale document is listed once in `staleDocuments` with the kind of
486
+ * staleness, and once in `actions` with `type` set to that kind.
487
+ */
488
+ export function checkReportJson({ gates, exitCode }, { base = null, advisory = false, only = null } = {}) {
489
+ const staleDocuments = gates.flatMap((gate) =>
490
+ (gate.stale ?? []).map(({ source, kind, changed }) => ({ source, kind, gate: gate.name, severity: gate.failures.length > 0 ? 'failure' : 'warning', changed })),
491
+ );
492
+ const count = (key) => gates.reduce((sum, gate) => sum + (gate[key]?.length ?? 0), 0);
493
+ const warnings = count('warnings');
494
+ return {
495
+ schemaVersion: 1,
496
+ tool: 'corpus',
497
+ command: 'check',
498
+ verdict: exitCode !== EXIT_OK ? 'fail' : warnings > 0 ? 'warn' : 'pass',
499
+ exitCode,
500
+ base,
501
+ advisory,
502
+ only,
503
+ summary: { failures: count('failures'), warnings, errors: gates.filter((gate) => gate.error).length, staleDocuments: staleDocuments.length },
504
+ gates: gates.map((gate) => ({
505
+ name: gate.name,
506
+ status: gateStatus(gate),
507
+ ...(gate.note ? { note: gate.note } : {}),
508
+ ...(gate.skipped ? { skipped: gate.skipped } : {}),
509
+ ...(gate.error ? { error: gate.error } : {}),
510
+ failures: gate.failures ?? [],
511
+ warnings: gate.warnings ?? [],
512
+ })),
513
+ staleDocuments,
514
+ actions: staleDocuments.map(({ source, kind, changed }) => ({
515
+ type: kind,
516
+ description: `${source} is stale: ${changed.length} covered file${changed.length === 1 ? '' : 's'} changed since ${base} but the document did not`,
517
+ paths: [source, ...changed],
518
+ })),
519
+ };
520
+ }