md-verified 0.1.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/dist/check.js ADDED
@@ -0,0 +1,342 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * CLI entry point.
4
+ *
5
+ * bun run check.ts examples/spec.md
6
+ * bun run check.ts examples/*.md --write
7
+ * bun run check.ts examples/spec.md --json
8
+ */
9
+ import { existsSync } from 'node:fs';
10
+ import { glob, readFile, writeFile } from 'node:fs/promises';
11
+ import { basename, dirname, extname, resolve } from 'node:path';
12
+ /** Where documents live when the caller does not say. */
13
+ const DEFAULT_DOCS = '**/*.md';
14
+ /** Never walk into these while globbing. */
15
+ const IGNORED_DIRS = /(^|\/)(node_modules|\.git|dist|build|coverage|\.next|out)(\/|$)/;
16
+ import { loadGlue, resolveGlue, runFile } from "./src/runner.js";
17
+ import { c, formatRun, rewriteFromRun, setColor } from "./src/report.js";
18
+ import { verify } from "./src/framework.js";
19
+ import { parseMarkdown } from "./src/parser.js";
20
+ const USAGE = `
21
+ md-verified - executable specifications from native Markdown
22
+
23
+ USAGE
24
+ md-verified <file.md|glob> [...] [options]
25
+
26
+ Globs are expanded by the tool, so quoting them is safe:
27
+ md-verified 'docs/**/*.md'
28
+
29
+ OPTIONS
30
+ --glue <path> Glue module to load. Defaults to a <!-- verify: --> hint in
31
+ the document, then <name>.verify.ts next to it.
32
+ --write, -w Write status glyphs and error comments back into the file.
33
+ --report Print the annotated Markdown to stdout instead of writing.
34
+ --reset Return every anchor to its unrun state and drop our comments.
35
+ --json Emit machine-readable results (for agents and CI).
36
+ --stamp Record the current digest on every review, marking the
37
+ prose as read. Deliberately separate from --write.
38
+ --covering <path> Instead of checking, list the reviews that cover <path>.
39
+ Answers "which documents describe this code?" without
40
+ putting a marker in the code itself. Searches the documents
41
+ given, or **/*.md when none are.
42
+ --no-links Skip link, anchor and symbol checking.
43
+ --no-reviews Skip review staleness checking.
44
+ --no-symbols Check links, but do not import modules to check symbols.
45
+ --only <id> Run only this anchor. Repeatable.
46
+ --bail Stop at the first failure.
47
+ --timeout <ms> Per-case timeout. Default 5000, 0 to disable.
48
+ --verbose, -v Show passing cases and stack frames.
49
+ --no-color Disable ANSI colour.
50
+ --help, -h Show this message.
51
+
52
+ EXIT CODE
53
+ 0 when every anchor passed, 1 otherwise.
54
+ `.trim();
55
+ function parseArgs(argv) {
56
+ const flags = {
57
+ files: [],
58
+ write: false,
59
+ report: false,
60
+ reset: false,
61
+ stamp: false,
62
+ json: false,
63
+ verbose: false,
64
+ help: false,
65
+ only: [],
66
+ bail: false,
67
+ timeout: 5000,
68
+ };
69
+ for (let i = 0; i < argv.length; i++) {
70
+ const arg = argv[i];
71
+ switch (arg) {
72
+ case '--glue':
73
+ flags.glue = argv[++i];
74
+ break;
75
+ case '--only':
76
+ flags.only.push(argv[++i]);
77
+ break;
78
+ case '--timeout':
79
+ flags.timeout = Number(argv[++i]);
80
+ break;
81
+ case '--write':
82
+ case '-w':
83
+ flags.write = true;
84
+ break;
85
+ case '--report':
86
+ flags.report = true;
87
+ break;
88
+ case '--reset':
89
+ flags.reset = true;
90
+ break;
91
+ case '--json':
92
+ flags.json = true;
93
+ break;
94
+ case '--verbose':
95
+ case '-v':
96
+ flags.verbose = true;
97
+ break;
98
+ case '--no-color':
99
+ setColor(false);
100
+ break;
101
+ case '--stamp':
102
+ flags.stamp = true;
103
+ break;
104
+ case '--covering':
105
+ flags.covering = argv[++i];
106
+ break;
107
+ case '--no-links':
108
+ flags.links = false;
109
+ break;
110
+ case '--no-reviews':
111
+ flags.reviews = false;
112
+ break;
113
+ case '--no-symbols':
114
+ flags.symbols = false;
115
+ break;
116
+ case '--help':
117
+ case '-h':
118
+ flags.help = true;
119
+ break;
120
+ default:
121
+ if (arg.startsWith('-'))
122
+ throw new Error(`unknown option: ${arg}`);
123
+ flags.files.push(arg);
124
+ }
125
+ }
126
+ return flags;
127
+ }
128
+ async function main() {
129
+ const flags = parseArgs(process.argv.slice(2));
130
+ if (flags.help) {
131
+ console.log(USAGE);
132
+ return 0;
133
+ }
134
+ // JSON goes to stdout alone, so it stays pipeable.
135
+ if (flags.json)
136
+ setColor(false);
137
+ if (flags.covering) {
138
+ // Answering "which docs describe this file?" should not require the caller
139
+ // to remember where the documents live. Finding nothing is an answer here,
140
+ // not an error.
141
+ const searched = await expand(flags.files.length ? flags.files : [DEFAULT_DOCS]);
142
+ return await listCovering(flags, searched.files);
143
+ }
144
+ if (flags.files.length === 0) {
145
+ console.log(USAGE);
146
+ return 1;
147
+ }
148
+ const { files, unmatched } = await expand(flags.files);
149
+ const runs = [];
150
+ // A pattern that matches nothing is a failure, not a quiet no-op: in CI it
151
+ // would otherwise turn a moved or misspelled document path into a pass.
152
+ let failures = unmatched.length;
153
+ for (const pattern of unmatched) {
154
+ console.error(`md-verified: no files match ${pattern}`);
155
+ }
156
+ for (const file of files) {
157
+ try {
158
+ const run = await checkOne(file, flags);
159
+ runs.push(run);
160
+ if (!run.ok)
161
+ failures++;
162
+ }
163
+ catch (err) {
164
+ // One unusable document must not stop the others being reported.
165
+ console.error(`${file}: ${err.message}`);
166
+ failures++;
167
+ }
168
+ }
169
+ if (flags.json) {
170
+ console.log(JSON.stringify({
171
+ ok: failures === 0,
172
+ files: runs.map((r) => ({
173
+ file: r.file,
174
+ ok: r.ok,
175
+ summary: r.summary,
176
+ problems: r.problems,
177
+ reviews: r.reviews,
178
+ anchors: r.anchors.map((a) => ({
179
+ id: a.id,
180
+ kind: a.kind,
181
+ line: a.line,
182
+ status: a.status,
183
+ reason: a.reason,
184
+ cases: a.cases.map((cse) => ({
185
+ name: cse.name,
186
+ status: cse.status,
187
+ line: cse.line,
188
+ error: cse.error,
189
+ })),
190
+ })),
191
+ })),
192
+ }, null, 2));
193
+ }
194
+ return failures === 0 ? 0 : 1;
195
+ }
196
+ /** Check one document. Throws only when the document cannot be run at all. */
197
+ async function checkOne(file, flags) {
198
+ // Each document gets a clean registry, so ids only need to be unique per file.
199
+ verify.reset();
200
+ if (!existsSync(file))
201
+ throw new Error('no such file');
202
+ const source = await readFile(file, 'utf8');
203
+ const gluePath = resolveGlue(file, flags.glue, source);
204
+ // Glue is only required by anchors. A document that is prose plus reviews
205
+ // has nothing to execute, and should not be nagged for a handler file.
206
+ if (!gluePath && !flags.reset && parseMarkdown(source, file).anchors.length > 0) {
207
+ throw new Error(`no glue code found. Add a <!-- verify: ./x.verify.ts --> hint, ` +
208
+ `create ${basename(file, extname(file))}.verify.ts next to it, or pass --glue.`);
209
+ }
210
+ if (gluePath)
211
+ await loadGlue(gluePath);
212
+ const { run, parsed } = await runFile(file, {
213
+ only: flags.only.length ? flags.only : undefined,
214
+ bail: flags.bail,
215
+ timeout: flags.timeout,
216
+ links: flags.links,
217
+ symbols: flags.symbols,
218
+ reviews: flags.reviews,
219
+ });
220
+ if (flags.write || flags.report || flags.reset || flags.stamp) {
221
+ const next = rewriteFromRun(run, parsed, { reset: flags.reset, stamp: flags.stamp });
222
+ if (flags.report) {
223
+ if (!flags.json)
224
+ console.log(next);
225
+ }
226
+ else if (next !== run.source) {
227
+ await writeFile(file, next);
228
+ }
229
+ }
230
+ // A stamped review is current from this moment on. Reporting it as stale --
231
+ // and exiting non-zero -- would be complaining about the very thing the
232
+ // command just resolved, and would make `--stamp && ...` impossible.
233
+ const settled = flags.stamp ? afterStamping(run) : run;
234
+ if (!flags.json && !flags.report) {
235
+ console.log(formatRun(settled, { verbose: flags.verbose }));
236
+ console.log('');
237
+ }
238
+ return settled;
239
+ }
240
+ /**
241
+ * Fold a `--stamp` into the result: reviews that received a digest are now
242
+ * current. Reviews that failed for a reason stamping cannot fix -- covering a
243
+ * file that does not exist, declaring no targets -- are left failing.
244
+ */
245
+ function afterStamping(run) {
246
+ const reviews = run.reviews.map((review) => review.status === 'failed' && review.digest !== null
247
+ ? { ...review, status: 'passed', reason: null, current: true }
248
+ : review);
249
+ const reviewsStale = reviews.filter((r) => r.status === 'failed').length;
250
+ const summary = { ...run.summary, reviewsStale };
251
+ return {
252
+ ...run,
253
+ reviews,
254
+ summary,
255
+ ok: summary.failed === 0 && reviewsStale === 0 && run.problems.length === 0,
256
+ };
257
+ }
258
+ /**
259
+ * Expand the file arguments, globbing any that need it.
260
+ *
261
+ * Shells do not always expand a pattern -- it may be quoted, or there may be no
262
+ * matching file in the current directory -- and passing `docs/*.md` through
263
+ * verbatim produced a raw `ENOENT` on the literal string.
264
+ */
265
+ async function expand(patterns) {
266
+ const found = [];
267
+ const unmatched = [];
268
+ const seen = new Set();
269
+ for (const pattern of patterns) {
270
+ if (!/[*?[\]{}]/.test(pattern)) {
271
+ if (!seen.has(pattern)) {
272
+ seen.add(pattern);
273
+ found.push(pattern);
274
+ }
275
+ continue;
276
+ }
277
+ const matches = [];
278
+ for await (const match of glob(pattern)) {
279
+ const path = String(match);
280
+ // Skip dotfiles and vendored trees explicitly, so behaviour does not
281
+ // depend on which runtime's glob defaults are in play.
282
+ if (IGNORED_DIRS.test(path) || /(^|\/)\./.test(path))
283
+ continue;
284
+ matches.push(path);
285
+ }
286
+ if (matches.length === 0) {
287
+ unmatched.push(pattern);
288
+ continue;
289
+ }
290
+ for (const match of matches.sort()) {
291
+ if (seen.has(match))
292
+ continue;
293
+ seen.add(match);
294
+ found.push(match);
295
+ }
296
+ }
297
+ return { files: found, unmatched };
298
+ }
299
+ /**
300
+ * The reverse of `Covers:`.
301
+ *
302
+ * The mapping from prose to code already exists in the documents, so the
303
+ * question "which docs describe this file?" can be answered by reading them --
304
+ * no marker in the source, nothing extra to keep in sync.
305
+ */
306
+ async function listCovering(flags, files) {
307
+ const target = resolve(flags.covering);
308
+ const hits = [];
309
+ for (const file of files) {
310
+ if (!existsSync(file))
311
+ continue;
312
+ const parsed = parseMarkdown(await readFile(file, 'utf8'), file);
313
+ const dir = dirname(resolve(file));
314
+ for (const review of parsed.reviews) {
315
+ for (const cover of review.covers) {
316
+ const [path] = cover.split('#');
317
+ if (resolve(dir, (path ?? '').trim()) === target) {
318
+ hits.push({ file, id: review.id, line: review.line, target: cover });
319
+ }
320
+ }
321
+ }
322
+ }
323
+ if (flags.json) {
324
+ console.log(JSON.stringify({ covering: flags.covering, reviews: hits }, null, 2));
325
+ }
326
+ else if (hits.length === 0) {
327
+ console.log(`No review covers ${flags.covering} (searched ${files.length} document${files.length === 1 ? '' : 's'}).`);
328
+ }
329
+ else {
330
+ console.log(`Reviews covering ${flags.covering}:`);
331
+ for (const hit of hits) {
332
+ console.log(` ${hit.file}:${hit.line} ${hit.id} ${c.dim(hit.target)}`);
333
+ }
334
+ console.log('');
335
+ console.log(c.dim('Changing this file may make the prose above wrong. Re-read it, then --stamp.'));
336
+ }
337
+ return 0;
338
+ }
339
+ main().then((code) => process.exit(code), (err) => {
340
+ console.error(`md-verified: ${err.message}`);
341
+ process.exit(1);
342
+ });
@@ -0,0 +1,25 @@
1
+ /** Throw with `message` unless `condition` holds. */
2
+ export declare function assert(condition: unknown, message: string): asserts condition;
3
+ /**
4
+ * Assert that a computed value matches the documented one.
5
+ *
6
+ * ```ts
7
+ * equals(calculateTotal(row.items, row.tax), row.total, 'total');
8
+ * // -> "total: expected 16, got 15"
9
+ * ```
10
+ *
11
+ * Objects and arrays are compared structurally. `what` names the thing being
12
+ * checked; without it the message is just "expected 16, got 15".
13
+ */
14
+ export declare function equals(actual: unknown, expected: unknown, what?: string): void;
15
+ /**
16
+ * Assert that a value is one of a documented set.
17
+ *
18
+ * ```ts
19
+ * oneOf(row.status, ['active', 'paused'], 'status');
20
+ * // -> "status: \"archived\" is not one of active, paused"
21
+ * ```
22
+ */
23
+ export declare function oneOf(value: unknown, allowed: Iterable<unknown>, what?: string): void;
24
+ /** Render a value compactly enough to sit inside a one-line comment. */
25
+ export declare function format(value: unknown): string;
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Assertions, written for a document rather than a terminal.
3
+ *
4
+ * Any assertion library works here -- a handler fails by throwing, and that is
5
+ * the whole contract. But there is a constraint no test framework has: the
6
+ * failure message is written *into the Markdown file* and read as
7
+ * documentation. A terminal-shaped message is wrong for that medium:
8
+ *
9
+ * <!-- ERROR: row 1: expect(received).toBe(expected) [nl] Expected: 15 [nl] Received: 16 -->
10
+ * <!-- ERROR: row 1: total should be $15.00, got $16.00 -->
11
+ *
12
+ * So these stay deliberately few, and each one produces a single self-contained
13
+ * line phrased in terms of the claim the document is making. Reach for `assert`
14
+ * whenever you can say it better yourself -- which is often, and is the point.
15
+ */
16
+ import { isDeepStrictEqual } from 'node:util';
17
+ /** Throw with `message` unless `condition` holds. */
18
+ export function assert(condition, message) {
19
+ if (!condition)
20
+ throw new Error(message);
21
+ }
22
+ /**
23
+ * Assert that a computed value matches the documented one.
24
+ *
25
+ * ```ts
26
+ * equals(calculateTotal(row.items, row.tax), row.total, 'total');
27
+ * // -> "total: expected 16, got 15"
28
+ * ```
29
+ *
30
+ * Objects and arrays are compared structurally. `what` names the thing being
31
+ * checked; without it the message is just "expected 16, got 15".
32
+ */
33
+ export function equals(actual, expected, what) {
34
+ if (same(actual, expected))
35
+ return;
36
+ const subject = what ? `${what}: ` : '';
37
+ throw new Error(`${subject}expected ${format(expected)}, got ${format(actual)}`);
38
+ }
39
+ /**
40
+ * Assert that a value is one of a documented set.
41
+ *
42
+ * ```ts
43
+ * oneOf(row.status, ['active', 'paused'], 'status');
44
+ * // -> "status: \"archived\" is not one of active, paused"
45
+ * ```
46
+ */
47
+ export function oneOf(value, allowed, what) {
48
+ const options = [...allowed];
49
+ if (options.some((option) => same(value, option)))
50
+ return;
51
+ const subject = what ? `${what}: ` : '';
52
+ throw new Error(`${subject}${format(value)} is not one of ${options.map((o) => String(o)).join(', ')}`);
53
+ }
54
+ function same(a, b) {
55
+ if (Object.is(a, b))
56
+ return true;
57
+ if (a instanceof Date && b instanceof Date)
58
+ return a.getTime() === b.getTime();
59
+ if (a !== null && b !== null && typeof a === 'object' && typeof b === 'object') {
60
+ return isDeepStrictEqual(a, b);
61
+ }
62
+ return false;
63
+ }
64
+ /** How many characters of a formatted value we are willing to put in a line. */
65
+ const MAX_VALUE = 60;
66
+ /** Render a value compactly enough to sit inside a one-line comment. */
67
+ export function format(value) {
68
+ if (typeof value === 'string')
69
+ return JSON.stringify(value);
70
+ if (typeof value === 'bigint')
71
+ return `${value}n`;
72
+ if (value instanceof Date)
73
+ return value.toISOString();
74
+ if (value === null || value === undefined || typeof value !== 'object')
75
+ return String(value);
76
+ let text;
77
+ try {
78
+ text = JSON.stringify(value) ?? String(value);
79
+ }
80
+ catch {
81
+ text = String(value);
82
+ }
83
+ return text.length > MAX_VALUE ? `${text.slice(0, MAX_VALUE - 1)}…` : text;
84
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * The value-type registry backing `**Schema:**` declarations.
3
+ *
4
+ * A schema turns opaque cell text into real JavaScript values, so glue code
5
+ * compares numbers to numbers instead of re-parsing `"$16.00"` by hand.
6
+ */
7
+ /** Thrown when a cell cannot be read as its declared type. */
8
+ export declare class CoercionError extends Error {
9
+ readonly value: string;
10
+ readonly type: string;
11
+ constructor(value: string, type: string, detail?: string);
12
+ }
13
+ export type Coercer = (raw: string, typeName: string) => unknown;
14
+ /** Register a value type usable from a `**Schema:**` line. Case-insensitive. */
15
+ export declare function registerType(name: string, coerce: Coercer): void;
16
+ export declare function hasType(name: string): boolean;
17
+ export declare function knownTypes(): string[];
18
+ /** Coerce one cell. `optional` lets blank cells through as `null`. */
19
+ export declare function coerce(raw: string, type: string, optional?: boolean): unknown;
@@ -0,0 +1,122 @@
1
+ /**
2
+ * The value-type registry backing `**Schema:**` declarations.
3
+ *
4
+ * A schema turns opaque cell text into real JavaScript values, so glue code
5
+ * compares numbers to numbers instead of re-parsing `"$16.00"` by hand.
6
+ */
7
+ /** Thrown when a cell cannot be read as its declared type. */
8
+ export class CoercionError extends Error {
9
+ // Assigned in the body rather than declared as constructor parameter
10
+ // properties: those are not supported by Node's strip-only type stripping,
11
+ // and this file is imported by glue code that Node may have to load as TS.
12
+ value;
13
+ type;
14
+ constructor(value, type, detail) {
15
+ super(`cannot read ${JSON.stringify(value)} as ${type}${detail ? ` (${detail})` : ''}`);
16
+ this.name = 'CoercionError';
17
+ this.value = value;
18
+ this.type = type;
19
+ }
20
+ }
21
+ const registry = new Map();
22
+ /** Register a value type usable from a `**Schema:**` line. Case-insensitive. */
23
+ export function registerType(name, coerce) {
24
+ registry.set(name.toLowerCase(), coerce);
25
+ }
26
+ export function hasType(name) {
27
+ return registry.has(name.toLowerCase());
28
+ }
29
+ export function knownTypes() {
30
+ return [...registry.keys()].sort();
31
+ }
32
+ /** Coerce one cell. `optional` lets blank cells through as `null`. */
33
+ export function coerce(raw, type, optional = false) {
34
+ const text = raw.trim();
35
+ if (optional && (text === '' || text === '-' || text === '—'))
36
+ return null;
37
+ const fn = registry.get(type.toLowerCase());
38
+ if (!fn) {
39
+ throw new CoercionError(text, type, `unknown type; known types: ${knownTypes().join(', ')}`);
40
+ }
41
+ return fn(text, type);
42
+ }
43
+ // ---------------------------------------------------------------------------
44
+ // Built-ins
45
+ // ---------------------------------------------------------------------------
46
+ /** `$1,234.50`, `(1,234.50)` and `-€12` all become numbers. */
47
+ function parseNumeric(raw, type) {
48
+ let text = raw.trim();
49
+ let sign = 1;
50
+ // Accounting-style negatives: (12.00)
51
+ const paren = /^\((.*)\)$/.exec(text);
52
+ if (paren) {
53
+ sign = -1;
54
+ text = paren[1].trim();
55
+ }
56
+ text = text.replace(/[$€£¥₹]|\b(?:USD|EUR|GBP|JPY|DKK)\b/gi, '').replace(/[,\s_]/g, '');
57
+ if (text.startsWith('-')) {
58
+ sign *= -1;
59
+ text = text.slice(1);
60
+ }
61
+ else if (text.startsWith('+')) {
62
+ text = text.slice(1);
63
+ }
64
+ if (text === '' || !/^\d*\.?\d+(?:e[+-]?\d+)?$/i.test(text)) {
65
+ throw new CoercionError(raw, type);
66
+ }
67
+ return sign * Number(text);
68
+ }
69
+ registerType('Currency', (raw, type) => {
70
+ const n = parseNumeric(raw, type);
71
+ // Money is compared for equality constantly; keep it off the float knife-edge.
72
+ return Math.round(n * 1e6) / 1e6;
73
+ });
74
+ registerType('Percentage', (raw, type) => {
75
+ const hasSign = raw.includes('%');
76
+ const n = parseNumeric(raw.replace('%', ''), type);
77
+ // `10%` -> 0.1 so it multiplies directly. A bare `0.1` is already a fraction.
78
+ return hasSign ? Math.round((n / 100) * 1e6) / 1e6 : n;
79
+ });
80
+ registerType('Number', parseNumeric);
81
+ registerType('Float', parseNumeric);
82
+ registerType('Decimal', parseNumeric);
83
+ registerType('Integer', (raw, type) => {
84
+ const n = parseNumeric(raw, type);
85
+ if (!Number.isInteger(n))
86
+ throw new CoercionError(raw, type, 'not a whole number');
87
+ return n;
88
+ });
89
+ registerType('Int', (raw, type) => coerce(raw, 'Integer'));
90
+ const TRUE = new Set(['true', 'yes', 'y', '1', 'on', '✅', 'x', '☑', '✓']);
91
+ const FALSE = new Set(['false', 'no', 'n', '0', 'off', '❌', '', '☐', '-']);
92
+ registerType('Boolean', (raw, type) => {
93
+ const key = raw.trim().toLowerCase();
94
+ if (TRUE.has(key))
95
+ return true;
96
+ if (FALSE.has(key))
97
+ return false;
98
+ throw new CoercionError(raw, type);
99
+ });
100
+ registerType('Bool', (raw) => coerce(raw, 'Boolean'));
101
+ registerType('Date', (raw, type) => {
102
+ const d = new Date(raw.trim());
103
+ if (Number.isNaN(d.getTime()))
104
+ throw new CoercionError(raw, type);
105
+ return d;
106
+ });
107
+ registerType('String', (raw) => raw.trim());
108
+ registerType('Text', (raw) => raw.trim());
109
+ registerType('JSON', (raw, type) => {
110
+ try {
111
+ return JSON.parse(raw.trim());
112
+ }
113
+ catch (err) {
114
+ throw new CoercionError(raw, type, err.message);
115
+ }
116
+ });
117
+ /** `a, b, c` -> `['a', 'b', 'c']`. */
118
+ registerType('List', (raw) => raw
119
+ .trim()
120
+ .split(',')
121
+ .map((s) => s.trim())
122
+ .filter(Boolean));
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Set assertions: does the document describe *all* of the thing?
3
+ *
4
+ * Per-element handlers only ever check elements that exist. If the code grows
5
+ * a fifth payment method and nobody adds a row, every row still passes and the
6
+ * specification is quietly wrong. `covers()` is the assertion that catches
7
+ * that -- and because a *missing* element is machine-identifiable, it is the
8
+ * failure an agent can act on directly.
9
+ */
10
+ export interface CoversOptions {
11
+ /**
12
+ * Message for something present in `actual` but absent from the document.
13
+ * Pass `false` to allow the document to describe a subset.
14
+ */
15
+ missing?: ((key: string) => string) | false;
16
+ /**
17
+ * Message for something documented that does not exist.
18
+ * Pass `false` to allow the document to describe extras.
19
+ */
20
+ extra?: ((key: string) => string) | false;
21
+ /** Flag keys the document lists more than once. Default `true`. */
22
+ duplicates?: boolean;
23
+ /** Noun used in the default messages. Default `"entry"`. */
24
+ noun?: string;
25
+ }
26
+ /**
27
+ * Assert that the keys a document lists are exactly the keys that exist.
28
+ *
29
+ * ```ts
30
+ * covers(graph.edges.map((e) => `${e.from}>${e.to}`), allowedTransitions(), {
31
+ * missing: (k) => `${k} is allowed in code but absent from the diagram`,
32
+ * });
33
+ * ```
34
+ *
35
+ * Throws once, listing everything wrong, so a single run tells you the whole
36
+ * gap rather than one element of it.
37
+ */
38
+ export declare function covers(documented: Iterable<unknown>, actual: Iterable<unknown>, options?: CoversOptions): void;
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Set assertions: does the document describe *all* of the thing?
3
+ *
4
+ * Per-element handlers only ever check elements that exist. If the code grows
5
+ * a fifth payment method and nobody adds a row, every row still passes and the
6
+ * specification is quietly wrong. `covers()` is the assertion that catches
7
+ * that -- and because a *missing* element is machine-identifiable, it is the
8
+ * failure an agent can act on directly.
9
+ */
10
+ /**
11
+ * Assert that the keys a document lists are exactly the keys that exist.
12
+ *
13
+ * ```ts
14
+ * covers(graph.edges.map((e) => `${e.from}>${e.to}`), allowedTransitions(), {
15
+ * missing: (k) => `${k} is allowed in code but absent from the diagram`,
16
+ * });
17
+ * ```
18
+ *
19
+ * Throws once, listing everything wrong, so a single run tells you the whole
20
+ * gap rather than one element of it.
21
+ */
22
+ export function covers(documented, actual, options = {}) {
23
+ const noun = options.noun ?? 'entry';
24
+ const doc = [...documented].map(String);
25
+ const act = [...actual].map(String);
26
+ const docSet = new Set(doc);
27
+ const actSet = new Set(act);
28
+ const problems = [];
29
+ if (options.missing !== false) {
30
+ const say = options.missing ?? ((k) => `missing ${noun}: ${k}`);
31
+ for (const key of act) {
32
+ if (!docSet.has(key))
33
+ problems.push(say(key));
34
+ }
35
+ }
36
+ if (options.extra !== false) {
37
+ const say = options.extra ?? ((k) => `unexpected ${noun}: ${k}`);
38
+ for (const key of docSet) {
39
+ if (!actSet.has(key))
40
+ problems.push(say(key));
41
+ }
42
+ }
43
+ if (options.duplicates !== false) {
44
+ const counts = new Map();
45
+ for (const key of doc)
46
+ counts.set(key, (counts.get(key) ?? 0) + 1);
47
+ for (const [key, n] of counts) {
48
+ if (n > 1)
49
+ problems.push(`duplicate ${noun}: ${key} (listed ${n} times)`);
50
+ }
51
+ }
52
+ if (problems.length) {
53
+ throw new Error(dedupe(problems).join('; '));
54
+ }
55
+ }
56
+ /** Preserve order, drop repeats -- `actual` may itself contain duplicates. */
57
+ function dedupe(items) {
58
+ return [...new Set(items)];
59
+ }