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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kristian Dupont
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,493 @@
1
+ # md-verified
2
+
3
+ Executable specifications from Markdown that nobody has to learn to read.
4
+
5
+ There is no Given/When/Then, no feature-file dialect, no custom renderer. A
6
+ specification is an ordinary `.md` file with ordinary tables, lists and Mermaid
7
+ diagrams. It looks native on GitHub, in VS Code, and in any Markdown viewer you
8
+ already use. A blockquote above each asset registers it with the test runner:
9
+
10
+ ```markdown
11
+ > 🛠️ **Verified Data:** `orderTotals`
12
+ > **Schema:** `[itemsTotal: Currency, shipping: Currency, tax: Percentage, total: Currency]`
13
+
14
+ | Items Total | Shipping | Tax Rate | Total Owed |
15
+ | ----------- | -------- | -------- | ---------- |
16
+ | $10.00 | $5.00 | 10% | $16.00 |
17
+ ```
18
+
19
+ The blockquote renders as a callout. The table renders as a table. Nothing in
20
+ the document is inert markup that only a tool understands.
21
+
22
+ ### Install
23
+
24
+ ```
25
+ bun add -d md-verified # or: npm install -D md-verified
26
+ bunx md-verified 'docs/**/*.md' # or: npx md-verified 'docs/**/*.md'
27
+ ```
28
+
29
+ Runs on **Bun** and **Node 24+**. The package itself uses only `node:`
30
+ builtins, so there is one code path rather than a compatibility layer.
31
+
32
+ Or from a clone:
33
+
34
+ ```
35
+ bun install
36
+ bun run check.ts examples/spec.md
37
+ bun test
38
+ ```
39
+
40
+ The point is documents that stay *true*, which is not the same as documents
41
+ that are fully verified — see [writing documents that stay
42
+ true](./docs/writing.md) for what earns an anchor and what should stay prose.
43
+ There is an agent skill at
44
+ [`.claude/skills/verified-docs`](./.claude/skills/verified-docs/SKILL.md).
45
+
46
+ ## How binding works
47
+
48
+ An anchor is a blockquote whose first line matches:
49
+
50
+ ```
51
+ > [glyph] **Verified <Label>:** `<id>`
52
+ ```
53
+
54
+ The runner then takes the **immediately following block node** and binds it.
55
+ The label says what you expect; the node says what is actually there, and a
56
+ disagreement is reported rather than guessed at:
57
+
58
+ `Data` and `Table` bind to a GFM table, `Flow` and `Diagram` to a
59
+ ` ```mermaid ` block, `Rules` and `Checklist` to a list. The full set of labels
60
+ is listed — and verified against the source — in
61
+ [docs/anchor-reference.md](./docs/anchor-reference.md).
62
+
63
+ Lookahead skips only the HTML comments this tool writes itself, so a document
64
+ that has already been annotated with failures still binds correctly next run.
65
+ Anything else between the anchor and the asset — a stray paragraph — is an
66
+ error, not something to search past.
67
+
68
+ Blockquotes that are not anchors are left alone, so your existing callouts keep
69
+ working.
70
+
71
+ ### Schemas
72
+
73
+ The optional `**Schema:**` line names and types the columns positionally.
74
+ Handlers then receive real values instead of strings:
75
+
76
+ ```markdown
77
+ > 🛠️ **Verified Data:** `orderTotals`
78
+ > **Schema:** `[itemsTotal: Currency, shipping: Currency, tax: Percentage, total: Currency]`
79
+ ```
80
+
81
+ `$10.00` arrives as `10`, `8.5%` as `0.085`. Each value is reachable by column
82
+ header *and* by schema field name, with the author's original text preserved on
83
+ `row.$raw`:
84
+
85
+ ```ts
86
+ row['Items Total'] // 10
87
+ row.itemsTotal // 10
88
+ row.$raw['Items Total'] // '$10.00'
89
+ ```
90
+
91
+ Built-in types cover currency, percentages, numbers, booleans, dates, JSON and
92
+ comma-separated lists; the complete table, with a worked example per type, is in
93
+ [docs/anchor-reference.md](./docs/anchor-reference.md). A trailing `?`
94
+ (`discount?: Currency`) lets a blank cell through as `null`. Register your own
95
+ with `verify.type()`.
96
+
97
+ Without a `Schema:` line, cells arrive as the raw text — which is what you want
98
+ when the document's exact formatting is part of the contract.
99
+
100
+ ## Runtimes
101
+
102
+ The tool imports your `.verify.ts` glue at runtime, so what matters is how each
103
+ runtime handles TypeScript.
104
+
105
+ | | Glue TypeScript |
106
+ | --- | --- |
107
+ | Bun | Fully transformed. Everything works. |
108
+ | Node 24+ | Type **stripping** only — see below. |
109
+
110
+ Node strips types rather than transforming them, so a few TypeScript features
111
+ do not survive **in glue, or in anything glue imports as `.ts`**:
112
+
113
+ ```
114
+ enum, namespace, parameter properties (constructor(readonly x: string)), decorators
115
+ ```
116
+
117
+ Types, interfaces, generics, `as const`, `satisfies` and type-only exports are
118
+ all fine. In practice glue is plain functions, so this rarely bites — the case
119
+ that does is glue importing an `enum` from your application code.
120
+
121
+ The fix is one line:
122
+
123
+ ```bash
124
+ NODE_OPTIONS=--experimental-transform-types npx md-verified docs/thing.md
125
+ ```
126
+
127
+ The published bin uses a `node` shebang so `npx` works out of the box. To run it
128
+ under Bun instead — which has none of the above limits — use `bunx --bun
129
+ md-verified` or `bun node_modules/md-verified/dist/check.js`.
130
+
131
+ Deno is untested. It should work in principle, via `node:` compatibility and an
132
+ `npm:` specifier, but nothing here verifies that.
133
+
134
+ ## Project layout
135
+
136
+ Put documents wherever you like — beside the code they describe, or in a `docs/`
137
+ tree. The tool does not care. Your `tsconfig.json` does, and it fails in a
138
+ different way for each.
139
+
140
+ **The rule: treat `.verify.ts` exactly like `.test.ts`.** It is TypeScript that
141
+ should be *checked* but not *shipped*, which is a problem your project has
142
+ already solved once.
143
+
144
+ Concretely, two configs — a wide one for checking and the editor, a narrow one
145
+ for building:
146
+
147
+ ```jsonc
148
+ // tsconfig.json — what gets typechecked
149
+ { "compilerOptions": { "noEmit": true }, "include": ["src", "docs"] }
150
+
151
+ // tsconfig.build.json — what gets compiled
152
+ {
153
+ "extends": "./tsconfig.json",
154
+ "compilerOptions": { "noEmit": false, "outDir": "dist", "rootDir": "src" },
155
+ "include": ["src"],
156
+ "exclude": ["**/*.verify.ts", "**/*.test.ts"]
157
+ }
158
+ ```
159
+
160
+ Without that split you hit one of these:
161
+
162
+ | Layout | What goes wrong |
163
+ | --- | --- |
164
+ | `docs/` beside `src/`, `"include": ["src"]` | Glue is **never typechecked**. A real type error in a handler is invisible — Bun strips types, so the document still passes. |
165
+ | `docs/` added to `include`, with `"rootDir": "src"` | `TS6059: File 'docs/x.verify.ts' is not under 'rootDir'`. |
166
+ | Co-located `src/**/*.verify.ts` | Typechecked correctly, but the glue **compiles into your production build** (`dist/billing/billing.verify.js`). |
167
+
168
+ The first is the dangerous one, because nothing tells you.
169
+
170
+ Glue can import application code however the rest of your project does —
171
+ `tsconfig` path aliases work, since Bun reads them.
172
+
173
+ ## Glue code
174
+
175
+ ```ts
176
+ import { verify, assert } from './src/index.ts';
177
+
178
+ // Once per data row.
179
+ verify.table('orderTotals', (row) => {
180
+ const actual = calculateTotal(row.itemsTotal, row.shipping, row.tax);
181
+ assert(actual === row.total, `expected ${row.total}, got ${actual}`);
182
+ });
183
+
184
+ // Once per edge: { from, to, label, style, directed }.
185
+ verify.mermaid.edges('checkoutFlow', async (edge) => {
186
+ assert(await checkNavigation(edge.from, edge.to),
187
+ `illegal transition: ${edge.from} -> ${edge.to}`);
188
+ });
189
+
190
+ // Once per list item, nested items included.
191
+ verify.list('settlementRules', (item) => {
192
+ assert(item.checked === settlesImmediately(item.text), 'drifted');
193
+ });
194
+ ```
195
+
196
+ Return normally to pass, throw to fail. Any assertion library works, including
197
+ none.
198
+
199
+ ### Assertions
200
+
201
+ A failure message here is not a test log — it is **written into the Markdown
202
+ file** and read as documentation. Terminal-shaped output reads badly there:
203
+
204
+ ```
205
+ <!-- ERROR: row 1: expect(received).toBe(expected)
206
+ Expected: 15
207
+ Received: 16 -->
208
+
209
+ <!-- ERROR: row 1: total: expected 15, got 16 -->
210
+ ```
211
+
212
+ So the built-ins stay few, and each produces one self-contained line phrased in
213
+ terms of the claim:
214
+
215
+ | | |
216
+ | --- | --- |
217
+ | `assert(cond, message)` | the escape hatch — use it whenever you can say it better |
218
+ | `equals(actual, expected, what?)` | `total: expected 16, got 15` |
219
+ | `oneOf(value, allowed, what?)` | `status: "archived" is not one of active, paused` |
220
+ | `covers(documented, actual, opts?)` | see [Completeness](#completeness) |
221
+
222
+ Third-party libraries keep working — a handler fails by throwing and that is
223
+ not going away. Multi-line messages keep their structure in the document rather
224
+ than being flattened onto one line.
225
+
226
+ | Registration | Handler receives |
227
+ | --- | --- |
228
+ | `verify.table(id, fn)` | one `TableRow` per data row |
229
+ | `verify.table.all(id, fn)` | the whole `ParsedTable` |
230
+ | `verify.mermaid(id, fn)` | the whole `MermaidGraph` |
231
+ | `verify.mermaid.edges(id, fn)` | one `MermaidEdge` per edge |
232
+ | `verify.list(id, fn)` | one `ListItem` per item |
233
+ | `verify.list.all(id, fn)` | the whole `ParsedList` |
234
+ | `verify.type(name, fn)` | — registers a `Schema:` value type |
235
+
236
+ `MermaidGraph` carries `nodes`, `edges` and `subgraphs`, plus `node(id)`,
237
+ `from(id)`, `to(id)`, `hasEdge(a, b)`, `hasPath(a, b)`, `roots()` and
238
+ `leaves()`.
239
+
240
+ One anchor may carry both an `each` and an `all` handler — they answer
241
+ different questions about the same asset. Registering the same mode twice is
242
+ still an error, so typos are still caught.
243
+
244
+ Glue is located by, in order: `--glue`, a `<!-- verify: ./x.verify.ts -->` hint
245
+ in the document, then `<name>.verify.ts` beside the Markdown file.
246
+
247
+ ## Completeness
248
+
249
+ Per-element handlers only ever check elements that exist. If the code grows a
250
+ fifth payment method and nobody adds a row, every row still passes and the
251
+ document is quietly wrong. `covers()` is the assertion that catches it:
252
+
253
+ ```ts
254
+ verify.mermaid('checkoutFlow', (graph) => {
255
+ covers(graph.edges.map((e) => `${e.from} -> ${e.to}`), allowedTransitions(), {
256
+ noun: 'transition',
257
+ missing: (t) => `${t} is allowed by checkNavigation but is not drawn`,
258
+ extra: false, // the per-edge handler already owns this direction
259
+ });
260
+ });
261
+ ```
262
+
263
+ It throws once, listing every gap, so a single run tells you the whole story.
264
+ Options: `missing` / `extra` take a message function or `false` to allow that
265
+ direction, `duplicates` (default on) flags a key the document lists twice, and
266
+ `noun` names the thing in default messages.
267
+
268
+ This is the check worth reaching for first. A *missing* element is
269
+ machine-identifiable in a way a wrong one is not — the runner knows exactly
270
+ which row should exist, which is what makes the annotation actionable.
271
+
272
+ ## Reference checking
273
+
274
+ Anchors verify the assets. A second pass verifies the prose around them: links
275
+ to files that have moved, in-document anchors that no longer resolve, and —
276
+ where you ask for it with a fragment — symbols that no longer exist.
277
+
278
+ ```markdown
279
+ Computed by [`calculateTotal`](./checkout.ts#calculateTotal).
280
+ ```
281
+
282
+ That renders as an ordinary link, and it carries everything needed to check it:
283
+
284
+ ```
285
+ examples/broken.md
286
+ ✖ 12:24 broken symbol: ./checkout.ts#calculateTotals (no export named
287
+ `calculateTotals`) (did you mean `calculateTotal`?)
288
+ ✖ 13:19 broken link: ./appendix.md (no such file)
289
+ ```
290
+
291
+ Checked automatically: every link and image path, `#heading` anchors within the
292
+ document and into other Markdown files, and link definitions. Nothing implicit
293
+ is ever checked — bare inline code is not treated as a symbol, because
294
+ `$10.00`, `--write` and `[itemsTotal: Currency]` are all inline code in a
295
+ perfectly healthy spec. A document opts in by linking.
296
+
297
+ Only files you fragment-link are imported, and only to read their export names.
298
+ `--no-symbols` keeps the link checks but imports nothing; `--no-links` skips the
299
+ pass entirely.
300
+
301
+ ## Reviews: the parts that cannot be executed
302
+
303
+ Most of a good document is prose — rationale, context, the reason a rule exists
304
+ at all. That is usually the part worth reading, and it is the part that rots
305
+ silently.
306
+
307
+ A review does not try to verify prose. It records which code a section
308
+ describes, and a digest of that code at the moment someone last read the two
309
+ together:
310
+
311
+ ```markdown
312
+ > 👁️ **Reviewed:** `settlement`
313
+ > **Covers:** `../src/checkout.ts#paymentMethod`
314
+ > **Digest:** `1:3aaced165261`
315
+ ```
316
+
317
+ When `paymentMethod` changes, the digest stops matching and the section is
318
+ flagged for a human to re-read. That is an attestation, not a proof — the
319
+ weaker claim, deliberately, because the alternative is either checking nothing
320
+ or pretending prose can be executed.
321
+
322
+ `--stamp` records the digest. It is **separate from `--write` on purpose**: a
323
+ stamp applied as a side effect of a normal run would attest to nothing.
324
+
325
+ The `1:` prefix is the digest format version. It exists so that a future change
326
+ to the algorithm can be reported as "re-stamp needed" rather than as "the code
327
+ changed", which would be a lie and would train people to stamp blindly.
328
+
329
+ Digests ignore line endings, so a mixed Windows/Unix team does not see
330
+ everything go stale.
331
+
332
+ Point `Covers:` at a **symbol** rather than a whole file. A file-level target is
333
+ invalidated by every unrelated edit in that file, and a review that cries wolf
334
+ gets stamped without being read. Symbol targets ignore edits elsewhere in the
335
+ file, and ignore changes to leading comments.
336
+
337
+ ### Which documents describe this code?
338
+
339
+ The mapping from prose to code already lives in the documents, so there is no
340
+ need for a marker in the source:
341
+
342
+ ```
343
+ $ bun run check.ts docs/*.md --covering src/parser.ts
344
+ Reviews covering src/parser.ts:
345
+ docs/anchor-reference.md:14 binding ../src/parser.ts#parseMarkdown
346
+ ```
347
+
348
+ Run it on the files a change touched. Anything listed describes code that just
349
+ moved.
350
+
351
+ ## Bi-directional state
352
+
353
+ `--write` folds the result of a run back into the document. The glyph changes,
354
+ and each failure is recorded as an HTML comment directly above the asset that
355
+ failed:
356
+
357
+ ```markdown
358
+ > ❌ **Verified Flow:** `checkoutFlow` (Failed: 1 of 4)
359
+
360
+ <!-- ERROR: Cart -> Payment: illegal transition: Cart -> Payment -->
361
+
362
+ ```mermaid
363
+ graph TD
364
+ Cart[Cart Page] --> Shipping[Shipping Info]
365
+ Cart --> Payment[Payment Info]
366
+ ```
367
+ ```
368
+
369
+ Comments are invisible in every renderer, so the page still reads as prose. For
370
+ an agent, the failure text sits at exactly the place that has to change — no
371
+ separate log to correlate against the document.
372
+
373
+ Three properties this relies on, all covered by the test suite:
374
+
375
+ - **Surgical.** Rewriting splices the original source; it never re-serialises
376
+ the AST. Prose, table alignment and diagram indentation survive byte for byte.
377
+ - **Idempotent.** Annotating an annotated document is a no-op. (This is why the
378
+ comments carry case names rather than line numbers — writing the comments
379
+ shifts the lines they would otherwise cite. Exact lines are in `--json`.)
380
+ - **Reversible.** A later green run clears the marks; `--reset` returns the file
381
+ to its unrun state exactly.
382
+
383
+ ## CLI
384
+
385
+ ```
386
+ md-verified <file.md|glob> [...] [options]
387
+
388
+ --glue <path> Glue module to load
389
+ --write, -w Fold results back into the file
390
+ --report Print the annotated Markdown to stdout instead
391
+ --reset Return anchors to their unrun state
392
+ --json Machine-readable results, for agents and CI
393
+ --stamp Record reviews as read (never implied by --write)
394
+ --covering <p> List the reviews that cover a source file
395
+ --no-links Skip link, anchor and symbol checking
396
+ --no-symbols Skip symbol checking
397
+ --no-reviews Skip review staleness checking
398
+ --only <id> Run one anchor (repeatable)
399
+ --bail Stop at the first failure
400
+ --timeout <ms> Per-case timeout (default 5000, 0 disables)
401
+ --verbose, -v Show passing cases and stack frames
402
+ ```
403
+
404
+ Exit code is 0 only when every anchor passed, every anchor bound cleanly, every
405
+ reference resolved, and every review is current.
406
+
407
+ ### How things fail
408
+
409
+ | | Reported as | Annotated into the document |
410
+ | --- | --- | --- |
411
+ | Handler throws | a failed case | yes |
412
+ | A cell will not coerce | a failed case, that row only | yes |
413
+ | Schema or diagram malformed | a failed anchor | yes |
414
+ | No handler registered | a skipped anchor | yes |
415
+ | Broken link or symbol | a problem, with `line:col` | no — it is prose, not an anchor |
416
+ | Covered code changed | a stale review | yes |
417
+
418
+ A row whose cell will not coerce never reaches your handler, and a whole-asset
419
+ handler is never run against a table that is silently missing rows.
420
+
421
+ ## Under `bun test`
422
+
423
+ `loadDocument()` expands a document into cases without running them, so Bun's
424
+ test runner can own scheduling and reporting — one native test per table row:
425
+
426
+ ```ts
427
+ import { describe, test, expect } from 'bun:test';
428
+ import { loadDocument } from 'md-verified';
429
+
430
+ for (const file of ['docs/pricing.md', 'docs/limits.md']) {
431
+ const doc = await loadDocument(file);
432
+
433
+ describe(doc.file, () => {
434
+ test('references resolve', () => expect(doc.problems).toEqual([]));
435
+ test('reviews current', () =>
436
+ expect(doc.reviews.filter((r) => r.status === 'failed')).toEqual([]));
437
+
438
+ for (const suite of doc.suites) {
439
+ describe(suite.id, () => {
440
+ for (const c of suite.cases) test(c.name, () => c.run());
441
+ });
442
+ }
443
+ });
444
+ }
445
+ ```
446
+
447
+ Use `loadDocument` rather than importing glue files directly. Anchor ids are
448
+ unique per *document*, and Bun shares module state across test files, so two
449
+ documents that both use `prices` would otherwise collide. `loadDocument`
450
+ isolates the registry per document; cases keep their own handler afterwards.
451
+
452
+ `planCases()` is the lower-level primitive if you need it. See
453
+ [`spec.test.ts`](./spec.test.ts).
454
+
455
+ ## Layout
456
+
457
+ | File | |
458
+ | --- | --- |
459
+ | [`src/parser.ts`](./src/parser.ts) | Markdown → anchors: the AST walk, lookahead binding, schemas |
460
+ | [`src/mermaid.ts`](./src/mermaid.ts) | Mermaid flowcharts → nodes and edges |
461
+ | [`src/framework.ts`](./src/framework.ts) | The `verify` registry |
462
+ | [`src/runner.ts`](./src/runner.ts) | Execution, case planning, glue resolution |
463
+ | [`src/report.ts`](./src/report.ts) | Terminal output and the Markdown rewrite |
464
+ | [`src/references.ts`](./src/references.ts) | Link, anchor and symbol checking |
465
+ | [`src/covers.ts`](./src/covers.ts) | Set assertions for completeness |
466
+ | [`src/assertions.ts`](./src/assertions.ts) | `assert`, `equals`, `oneOf` |
467
+ | [`src/reviews.ts`](./src/reviews.ts) | Review staleness and digests |
468
+ | [`src/symbols.ts`](./src/symbols.ts) | Static symbol lookup, via the TS compiler API |
469
+ | [`src/coerce.ts`](./src/coerce.ts) | `Schema:` value types |
470
+ | [`check.ts`](./check.ts) | CLI |
471
+ | [`examples/spec.md`](./examples/spec.md) | A specification that passes |
472
+ | [`examples/broken.md`](./examples/broken.md) | The same spec, drifted, for the failure path |
473
+ | [`docs/writing.md`](./docs/writing.md) | What earns an anchor, and what does not |
474
+ | [`docs/anchor-reference.md`](./docs/anchor-reference.md) | The vocabulary, verified against the source |
475
+
476
+ ## Prototype limits
477
+
478
+ - Mermaid support covers the flowchart/graph family. Sequence and class
479
+ diagrams parse only as far as their `A --> B` statements go.
480
+ - Glue modules are loaded with a cache-busting query string, so a long-lived
481
+ process re-registering the same file will accumulate module instances.
482
+ - Anchor ids must be unique per document. The CLI clears the registry between
483
+ files; in-process, use `loadDocument()`.
484
+ - Node's type stripping cannot handle `enum`, `namespace`, parameter properties
485
+ or decorators in glue. See [Runtimes](#runtimes); `NODE_OPTIONS=--experimental-transform-types`
486
+ lifts the restriction.
487
+ - Deno is untested.
488
+ - Symbol lookup reads files rather than importing them, so nothing in the
489
+ checked project is executed and type-only exports are visible. The trade-off
490
+ is that `export * from './x'` is not followed.
491
+ - A reference with no definition (`[text][missing]`) cannot be flagged:
492
+ CommonMark leaves it as literal text, so there is no node in the tree. The
493
+ reader does see the broken brackets.
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};