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.
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Reporting, in two directions.
3
+ *
4
+ * *Outward*, to a human at a terminal: coloured pass/fail lines.
5
+ *
6
+ * *Back into the document*, for the next reader -- human or agent: the anchor
7
+ * glyph is rewritten to reflect the run, and failures are recorded as HTML
8
+ * comments directly beneath the anchor. Those comments are invisible in every
9
+ * Markdown renderer, so the document still looks hand-written, but they give
10
+ * an agent the exact failure text at exactly the place it must fix.
11
+ *
12
+ * Rewriting is a surgical splice against the original source -- never a
13
+ * re-serialisation of the AST -- so every byte the author wrote that we did
14
+ * not deliberately change survives untouched.
15
+ */
16
+ import { type Anchor, type AnchorResult, type Review, type ReviewResult, type RunResult } from './types.ts';
17
+ export interface RewriteOptions {
18
+ /** Ignore results and return every anchor to its unrun state. */
19
+ reset?: boolean;
20
+ /**
21
+ * Record the current digest on every review, marking it as read.
22
+ *
23
+ * Deliberately separate from a normal write: a stamp asserts that a person
24
+ * or agent has read the section against the code it covers. Applying it as a
25
+ * side effect of `--write` would make the attestation worthless.
26
+ */
27
+ stamp?: boolean;
28
+ }
29
+ /**
30
+ * Return `source` with each anchor's glyph and error comments brought in line
31
+ * with `results`.
32
+ */
33
+ export declare function rewriteMarkdown(source: string, anchors: Anchor[], results: AnchorResult[], options?: RewriteOptions, reviews?: Review[], reviewResults?: ReviewResult[]): string;
34
+ /** Convenience: rewrite straight from a `RunResult`. */
35
+ export declare function rewriteFromRun(run: RunResult, parsed: {
36
+ anchors: Anchor[];
37
+ reviews: Review[];
38
+ }, options?: RewriteOptions): string;
39
+ /** Force colour on or off (the CLI's `--no-color` flag routes through here). */
40
+ export declare function setColor(on: boolean): void;
41
+ export declare const c: {
42
+ green: (s: string) => string;
43
+ red: (s: string) => string;
44
+ yellow: (s: string) => string;
45
+ blue: (s: string) => string;
46
+ dim: (s: string) => string;
47
+ bold: (s: string) => string;
48
+ };
49
+ /** Render a run as terminal text. */
50
+ export declare function formatRun(run: RunResult, options?: {
51
+ verbose?: boolean;
52
+ }): string;
@@ -0,0 +1,278 @@
1
+ /**
2
+ * Reporting, in two directions.
3
+ *
4
+ * *Outward*, to a human at a terminal: coloured pass/fail lines.
5
+ *
6
+ * *Back into the document*, for the next reader -- human or agent: the anchor
7
+ * glyph is rewritten to reflect the run, and failures are recorded as HTML
8
+ * comments directly beneath the anchor. Those comments are invisible in every
9
+ * Markdown renderer, so the document still looks hand-written, but they give
10
+ * an agent the exact failure text at exactly the place it must fix.
11
+ *
12
+ * Rewriting is a surgical splice against the original source -- never a
13
+ * re-serialisation of the AST -- so every byte the author wrote that we did
14
+ * not deliberately change survives untouched.
15
+ */
16
+ import { REVIEW_PENDING_GLYPH, STATUS_GLYPH, } from "./types.js";
17
+ /** Anchor first line: quote marker, optional glyph, then the bold body. */
18
+ const FIRST_LINE_RE = /^(?<prefix>\s*>\s*)(?<glyph>[^\s*`]+\s+)?(?<body>\*\*\s*Verified[\s\S]*)$/;
19
+ /** The same, for a review. */
20
+ const REVIEW_LINE_RE = /^(?<prefix>\s*>\s*)(?<glyph>[^\s*`]+\s+)?(?<body>\*\*\s*Reviewed[\s\S]*)$/;
21
+ /** A `> **Digest:** ...` line inside a review blockquote. */
22
+ const DIGEST_LINE_RE = /^(?<prefix>\s*>\s*)\*\*\s*Digest\s*:?\s*\*\*\s*:?\s*.*$/;
23
+ /** A status parenthetical we own and may replace. */
24
+ const SUFFIX_RE = /\s*\((?:Failed|Passed|Skipped|Pending|Stale)[^)]*\)\s*$/i;
25
+ /**
26
+ * A whole line holding a comment *we* wrote, and may therefore replace.
27
+ *
28
+ * Deliberately narrower than the parser's skip list: an author's
29
+ * `<!-- verify: ./glue.ts -->` hint must survive a rewrite untouched, so only
30
+ * our own `ERROR:` and `REVIEW:` comments match here.
31
+ */
32
+ const MANAGED_LINE_RE = /^[ \t]*<!--\s*(?:ERROR|REVIEW):[\s\S]*?-->[ \t]*\r?\n?/gm;
33
+ /** Most failures we will write into the document before summarising. */
34
+ const MAX_COMMENTS = 8;
35
+ /**
36
+ * Return `source` with each anchor's glyph and error comments brought in line
37
+ * with `results`.
38
+ */
39
+ export function rewriteMarkdown(source, anchors, results, options = {}, reviews = [], reviewResults = []) {
40
+ const byAnchor = new Map(results.map((r) => [r.line + ':' + r.id, r]));
41
+ const byReview = new Map(reviewResults.map((r) => [r.line + ':' + r.id, r]));
42
+ const edits = [];
43
+ for (const anchor of anchors) {
44
+ const result = byAnchor.get(anchor.line + ':' + anchor.id);
45
+ const status = options.reset ? 'pending' : (result?.status ?? 'pending');
46
+ edits.push({
47
+ start: anchor.quoteRange.start,
48
+ end: anchor.gapRange.end,
49
+ replace: () => rewriteQuote(source.slice(anchor.quoteRange.start, anchor.quoteRange.end), status, result, options.reset === true) +
50
+ rewriteGap(source.slice(anchor.gapRange.start, anchor.gapRange.end), options.reset ? [] : commentsFor(result)),
51
+ });
52
+ }
53
+ for (const review of reviews) {
54
+ const result = byReview.get(review.line + ':' + review.id);
55
+ const status = options.reset ? 'pending' : (result?.status ?? 'pending');
56
+ // Stamping resolves the very thing the comment would report, so it clears
57
+ // the note rather than writing one.
58
+ const stamped = options.stamp === true && Boolean(result?.digest);
59
+ const comments = options.reset || stamped || status !== 'failed' || !result?.reason
60
+ ? []
61
+ : [comment('REVIEW', result.reason)];
62
+ edits.push({
63
+ start: review.quoteRange.start,
64
+ end: review.gapRange.end,
65
+ replace: () => rewriteReviewQuote(source.slice(review.quoteRange.start, review.quoteRange.end), status, result, options) + rewriteGap(source.slice(review.gapRange.start, review.gapRange.end), comments),
66
+ });
67
+ }
68
+ let out = source;
69
+ for (const edit of edits.sort((a, b) => b.start - a.start)) {
70
+ out = out.slice(0, edit.start) + edit.replace() + out.slice(edit.end);
71
+ }
72
+ return out;
73
+ }
74
+ /** Convenience: rewrite straight from a `RunResult`. */
75
+ export function rewriteFromRun(run, parsed, options = {}) {
76
+ return rewriteMarkdown(run.source, parsed.anchors, run.anchors, options, parsed.reviews, run.reviews);
77
+ }
78
+ /**
79
+ * Rewrite a review's blockquote: its glyph, and -- only when stamping -- the
80
+ * digest recorded on it.
81
+ */
82
+ function rewriteReviewQuote(quote, status, result, options) {
83
+ const lines = quote.split('\n');
84
+ const head = REVIEW_LINE_RE.exec(lines[0] ?? '');
85
+ if (!head)
86
+ return quote;
87
+ const stamping = options.stamp === true && result?.digest;
88
+ const glyph = options.reset
89
+ ? REVIEW_PENDING_GLYPH
90
+ : stamping
91
+ ? STATUS_GLYPH.passed
92
+ : status === 'pending'
93
+ ? REVIEW_PENDING_GLYPH
94
+ : STATUS_GLYPH[status];
95
+ const body = head.groups.body.replace(SUFFIX_RE, '');
96
+ const suffix = options.reset || stamping || status !== 'failed' ? '' : '(Stale)';
97
+ lines[0] = head.groups.prefix + glyph + ' ' + body + (suffix ? ' ' + suffix : '');
98
+ const prefix = head.groups.prefix.replace(/\s+$/, ' ');
99
+ if (options.reset) {
100
+ return lines.filter((l) => !DIGEST_LINE_RE.test(l)).join('\n');
101
+ }
102
+ if (!stamping)
103
+ return lines.join('\n');
104
+ const digestLine = `${prefix}**Digest:** \`${result.digest}\``;
105
+ const existing = lines.findIndex((l) => DIGEST_LINE_RE.test(l));
106
+ if (existing === -1)
107
+ lines.push(digestLine);
108
+ else
109
+ lines[existing] = digestLine;
110
+ return lines.join('\n');
111
+ }
112
+ function rewriteQuote(quote, status, result, reset) {
113
+ const lines = quote.split('\n');
114
+ const m = FIRST_LINE_RE.exec(lines[0]);
115
+ if (!m)
116
+ return quote;
117
+ const body = m.groups.body.replace(SUFFIX_RE, '');
118
+ const suffix = reset ? '' : suffixFor(status, result);
119
+ lines[0] = m.groups.prefix + STATUS_GLYPH[status] + ' ' + body + (suffix ? ' ' + suffix : '');
120
+ return lines.join('\n');
121
+ }
122
+ function suffixFor(status, result) {
123
+ if (status === 'skipped')
124
+ return '(Skipped)';
125
+ if (status !== 'failed')
126
+ return '';
127
+ const total = result?.cases.length ?? 0;
128
+ const failed = result?.cases.filter((x) => x.status === 'failed').length ?? 0;
129
+ // Only worth counting when the asset fanned out into several cases.
130
+ return total > 1 ? `(Failed: ${failed} of ${total})` : '(Failed)';
131
+ }
132
+ function commentsFor(result) {
133
+ if (!result)
134
+ return [];
135
+ const lines = [];
136
+ if (result.status === 'skipped') {
137
+ if (result.reason)
138
+ lines.push(comment('VERIFY', result.reason));
139
+ return lines;
140
+ }
141
+ if (result.status !== 'failed')
142
+ return lines;
143
+ if (result.reason)
144
+ lines.push(comment('ERROR', result.reason));
145
+ // Deliberately no line numbers: writing these comments shifts the very lines
146
+ // they would cite, so citing them would make annotation non-idempotent. The
147
+ // case name identifies the case, and the comment already sits directly above
148
+ // the asset. Exact lines live in --json and the terminal report.
149
+ const failures = result.cases.filter((x) => x.status === 'failed');
150
+ for (const cse of failures.slice(0, MAX_COMMENTS)) {
151
+ lines.push(comment('ERROR', `${cse.name}: ${cse.error ?? 'failed'}`));
152
+ }
153
+ if (failures.length > MAX_COMMENTS) {
154
+ lines.push(comment('ERROR', `... and ${failures.length - MAX_COMMENTS} more failure(s)`));
155
+ }
156
+ return lines;
157
+ }
158
+ /** How many lines of a multi-line message we will write into a document. */
159
+ const MAX_COMMENT_LINES = 6;
160
+ /**
161
+ * Build one comment that cannot break out of its own delimiters.
162
+ *
163
+ * Single-line messages -- which is what the built-in assertions produce -- stay
164
+ * on one line. A message that genuinely has structure, typically from a
165
+ * third-party assertion library, keeps it: flattening a diff onto one line
166
+ * makes it unreadable in exactly the place people read it.
167
+ */
168
+ function comment(tag, message) {
169
+ const safe = (text) => text.replace(/<!--/g, '&lt;!--').replace(/-->/g, '->>').replace(/[ \t]+/g, ' ').trim();
170
+ const lines = String(message)
171
+ .split(/\r?\n/)
172
+ .map(safe)
173
+ .filter(Boolean);
174
+ if (lines.length <= 1)
175
+ return `<!-- ${tag}: ${lines[0] ?? 'failed'} -->`;
176
+ const kept = lines.slice(0, MAX_COMMENT_LINES);
177
+ if (lines.length > MAX_COMMENT_LINES) {
178
+ kept.push(`... ${lines.length - MAX_COMMENT_LINES} more line(s)`);
179
+ }
180
+ // A blank line would end the HTML block, so there are none: `filter(Boolean)`
181
+ // above drops them and every continuation line carries indentation.
182
+ return `<!-- ${tag}: ${kept[0]}\n${kept.slice(1).map((l) => ` ${l}`).join('\n')} -->`;
183
+ }
184
+ function rewriteGap(gap, comments) {
185
+ // Drop whatever we wrote last time; keep anything the author added.
186
+ const authored = gap.replace(MANAGED_LINE_RE, '').trim();
187
+ const parts = [];
188
+ if (authored)
189
+ parts.push(authored);
190
+ if (comments.length)
191
+ parts.push(comments.join('\n'));
192
+ // A blank line on both sides keeps the blockquote and the asset as separate
193
+ // blocks, which is what makes the lookahead binding stable.
194
+ return parts.length ? '\n\n' + parts.join('\n\n') + '\n\n' : '\n\n';
195
+ }
196
+ // ---------------------------------------------------------------------------
197
+ // Terminal output
198
+ // ---------------------------------------------------------------------------
199
+ const ESC = String.fromCharCode(27);
200
+ let colorEnabled = !process.env.NO_COLOR && Boolean(process.stdout.isTTY) && process.env.TERM !== 'dumb';
201
+ /** Force colour on or off (the CLI's `--no-color` flag routes through here). */
202
+ export function setColor(on) {
203
+ colorEnabled = on;
204
+ }
205
+ const paint = (code) => (s) => colorEnabled ? `${ESC}[${code}m${s}${ESC}[0m` : s;
206
+ export const c = {
207
+ green: paint('32'),
208
+ red: paint('31'),
209
+ yellow: paint('33'),
210
+ blue: paint('36'),
211
+ dim: paint('2'),
212
+ bold: paint('1'),
213
+ };
214
+ const MARK = {
215
+ passed: () => c.green('\u2714'),
216
+ failed: () => c.red('\u2716'),
217
+ skipped: () => c.yellow('\u25cb'),
218
+ };
219
+ /** Render a run as terminal text. */
220
+ export function formatRun(run, options = {}) {
221
+ const out = [];
222
+ out.push(c.bold(run.file));
223
+ for (const p of run.problems) {
224
+ const where = p.column ? `${p.line}:${p.column}` : `line ${p.line}`;
225
+ out.push(` ${c.red('\u2716')} ${c.dim(where)} ${p.message}`);
226
+ }
227
+ for (const a of run.anchors) {
228
+ const counts = a.cases.length > 1
229
+ ? c.dim(` ${a.cases.filter((x) => x.status === 'passed').length}/${a.cases.length}`)
230
+ : '';
231
+ const reason = a.status === 'skipped' && a.reason ? c.dim(` \u2014 ${a.reason}`) : '';
232
+ out.push(` ${MARK[a.status]()} ${a.id}${counts} ${c.dim(`(${a.kind}, line ${a.line})`)}${reason}`);
233
+ if (a.status === 'failed' && a.reason) {
234
+ for (const line of a.reason.split(/\r?\n/)) {
235
+ if (line.trim())
236
+ out.push(` ${c.red(line.trim())}`);
237
+ }
238
+ }
239
+ for (const cse of a.cases) {
240
+ if (cse.status === 'failed') {
241
+ const where = cse.line ? `:${cse.line}` : '';
242
+ const [first, ...rest] = (cse.error ?? 'failed').split(/\r?\n/);
243
+ out.push(` ${c.dim(cse.name + where)} ${c.red(first ?? 'failed')}`);
244
+ for (const line of rest) {
245
+ if (line.trim())
246
+ out.push(` ${c.red(line.trim())}`);
247
+ }
248
+ if (options.verbose && cse.stack) {
249
+ out.push(...cse.stack.split('\n').slice(1, 4).map((l) => c.dim(' ' + l.trim())));
250
+ }
251
+ }
252
+ else if (options.verbose) {
253
+ out.push(` ${c.green('\u00b7')} ${c.dim(cse.name)}`);
254
+ }
255
+ }
256
+ }
257
+ for (const review of run.reviews) {
258
+ const mark = review.status === 'passed' ? MARK.passed() : MARK.failed();
259
+ out.push(` ${mark} ${review.id} ${c.dim(`(review, line ${review.line})`)}`);
260
+ if (review.reason) {
261
+ for (const line of review.reason.split(/\r?\n/)) {
262
+ if (line.trim())
263
+ out.push(` ${c.red(line.trim())}`);
264
+ }
265
+ }
266
+ }
267
+ const s = run.summary;
268
+ const bits = [
269
+ s.passed ? c.green(`${s.passed} passed`) : null,
270
+ s.failed ? c.red(`${s.failed} failed`) : null,
271
+ s.skipped ? c.yellow(`${s.skipped} skipped`) : null,
272
+ ].filter(Boolean);
273
+ out.push('');
274
+ out.push(` ${bits.join(c.dim(', ')) || c.dim('nothing to verify')} ${c.dim(`(${s.cases} case${s.cases === 1 ? '' : 's'}` +
275
+ (s.reviews ? `, ${s.reviews - s.reviewsStale}/${s.reviews} reviews current` : '') +
276
+ `, ${s.durationMs.toFixed(0)}ms)`)}`);
277
+ return out.join('\n');
278
+ }
@@ -0,0 +1,19 @@
1
+ import type { ParseResult, ReviewResult } from './types.ts';
2
+ export interface ReviewOptions {
3
+ /** Skip review checking entirely. */
4
+ reviews?: boolean;
5
+ }
6
+ /** Check every review in a document against the code it covers. */
7
+ export declare function checkReviews(parsed: ParseResult, options?: ReviewOptions): ReviewResult[];
8
+ /**
9
+ * Digest the source of everything a review covers.
10
+ *
11
+ * A target is either a whole file (`./checkout.ts`) or one exported symbol
12
+ * (`./checkout.ts#calculateTotal`). Prefer the symbol form: a file-level
13
+ * digest is invalidated by every unrelated edit in that file, and a review
14
+ * that cries wolf gets stamped without being read.
15
+ *
16
+ * Leading comments are excluded from a symbol's text, so rewording a doc
17
+ * comment does not demand a re-review.
18
+ */
19
+ export declare function digestOf(covers: string[], dir: string): string | Error;
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Review staleness.
3
+ *
4
+ * An anchor executes a claim. Most of a good document is not executable --
5
+ * rationale, context, the reason a rule exists at all -- and that prose is
6
+ * usually the part worth reading. It is also the part that rots silently.
7
+ *
8
+ * A review does not attempt to verify prose. It records which code a section
9
+ * describes, and a digest of that code at the moment someone last read the two
10
+ * together. When the code changes the digest stops matching, and the section
11
+ * is flagged for a human to re-read. That is an attestation, not a proof, and
12
+ * it is deliberately the weaker claim -- the alternative is either checking
13
+ * nothing, or pretending prose can be executed.
14
+ *
15
+ * Stamping is a separate, deliberate act (`--stamp`). It is never done by
16
+ * `--write`, because a stamp applied automatically would attest to nothing.
17
+ */
18
+ import { dirname, resolve as resolvePath } from 'node:path';
19
+ import { existsSync, readFileSync } from 'node:fs';
20
+ import { createHash } from 'node:crypto';
21
+ import { exportedSymbol } from "./symbols.js";
22
+ /** How many hex characters of the digest we record. */
23
+ const DIGEST_LENGTH = 12;
24
+ /**
25
+ * Digest format version, recorded in the stamp.
26
+ *
27
+ * The algorithm is part of the file format: change it and every stamp in every
28
+ * repository stops matching. Without a version marker that would be reported
29
+ * as "the code changed" -- a lie, and one that trains people to stamp blindly.
30
+ * With it, we can say what actually happened.
31
+ */
32
+ const DIGEST_VERSION = '1';
33
+ /** Check every review in a document against the code it covers. */
34
+ export function checkReviews(parsed, options = {}) {
35
+ if (options.reviews === false)
36
+ return [];
37
+ const dir = dirname(resolvePath(parsed.file));
38
+ return parsed.reviews.map((review) => check(review, dir));
39
+ }
40
+ function check(review, dir) {
41
+ const base = { id: review.id, line: review.line };
42
+ if (review.defect) {
43
+ return { ...base, status: 'failed', reason: review.defect, digest: null, current: false };
44
+ }
45
+ const computed = digestOf(review.covers, dir);
46
+ if (computed instanceof Error) {
47
+ return { ...base, status: 'failed', reason: computed.message, digest: null, current: false };
48
+ }
49
+ if (!review.digest) {
50
+ return {
51
+ ...base,
52
+ status: 'failed',
53
+ reason: `never stamped; read this section against ${review.covers.join(', ')}, then run --stamp`,
54
+ digest: computed,
55
+ current: false,
56
+ };
57
+ }
58
+ if (!review.digest.startsWith(`${DIGEST_VERSION}:`)) {
59
+ return {
60
+ ...base,
61
+ status: 'failed',
62
+ reason: `stamped with an older digest format, so it cannot be compared; the code may well be unchanged. Re-read and run --stamp`,
63
+ digest: computed,
64
+ current: false,
65
+ };
66
+ }
67
+ if (review.digest !== computed) {
68
+ return {
69
+ ...base,
70
+ status: 'failed',
71
+ reason: `${review.covers.join(', ')} changed since this section was last read; re-read it, correct it if it is now wrong, then run --stamp`,
72
+ digest: computed,
73
+ current: false,
74
+ };
75
+ }
76
+ return { ...base, status: 'passed', reason: null, digest: computed, current: true };
77
+ }
78
+ /**
79
+ * Digest the source of everything a review covers.
80
+ *
81
+ * A target is either a whole file (`./checkout.ts`) or one exported symbol
82
+ * (`./checkout.ts#calculateTotal`). Prefer the symbol form: a file-level
83
+ * digest is invalidated by every unrelated edit in that file, and a review
84
+ * that cries wolf gets stamped without being read.
85
+ *
86
+ * Leading comments are excluded from a symbol's text, so rewording a doc
87
+ * comment does not demand a re-review.
88
+ */
89
+ export function digestOf(covers, dir) {
90
+ if (covers.length === 0) {
91
+ return new Error('declares no **Covers:** targets, so there is nothing to go stale against');
92
+ }
93
+ const hasher = createHash('sha256');
94
+ for (const target of covers) {
95
+ const text = sourceOf(target, dir);
96
+ if (text instanceof Error)
97
+ return text;
98
+ hasher.update(`${target} ${text} `);
99
+ }
100
+ return `${DIGEST_VERSION}:${hasher.digest('hex').slice(0, DIGEST_LENGTH)}`;
101
+ }
102
+ /** The source text a single `Covers:` target refers to. */
103
+ function sourceOf(target, dir) {
104
+ const hash = target.indexOf('#');
105
+ const filePart = (hash === -1 ? target : target.slice(0, hash)).trim();
106
+ const symbol = hash === -1 ? null : target.slice(hash + 1).trim();
107
+ const path = resolvePath(dir, filePart);
108
+ if (!existsSync(path)) {
109
+ return new Error(`covers ${target}, but ${filePart} does not exist`);
110
+ }
111
+ if (!symbol) {
112
+ try {
113
+ return normalise(readFileSync(path, 'utf8'));
114
+ }
115
+ catch (err) {
116
+ return new Error(`covers ${target}, but it could not be read: ${err.message}`);
117
+ }
118
+ }
119
+ const found = exportedSymbol(path, symbol);
120
+ if (found instanceof Error) {
121
+ return new Error(`covers ${target}, but ${filePart} could not be read: ${found.message}`);
122
+ }
123
+ if (!found) {
124
+ return new Error(`covers ${target}, but ${filePart} exports no \`${symbol}\``);
125
+ }
126
+ return normalise(found.text);
127
+ }
128
+ /**
129
+ * Line endings are a property of the checkout, not of the code.
130
+ *
131
+ * Without this, a team with mixed Windows and Unix working copies -- or one
132
+ * `core.autocrlf` setting -- sees every review go stale on every machine, and
133
+ * learns to stamp without reading.
134
+ */
135
+ function normalise(text) {
136
+ return text.replace(/\r\n/g, '\n');
137
+ }
@@ -0,0 +1,94 @@
1
+ import type { Anchor, AnchorKind, AnchorResult, ParseProblem, ReviewResult, ParseResult, RunResult } from './types.ts';
2
+ export interface RunOptions {
3
+ /** Only run anchors whose id is in this list. */
4
+ only?: string[];
5
+ /** Check links, in-document anchors and fragment-linked symbols. Default on. */
6
+ links?: boolean;
7
+ /** Check that fragment-linked symbols exist. Default on. */
8
+ symbols?: boolean;
9
+ /** Check review staleness. Default on. */
10
+ reviews?: boolean;
11
+ /** Stop after the first failing case. */
12
+ bail?: boolean;
13
+ /** Per-case timeout in ms. `0` disables. */
14
+ timeout?: number;
15
+ }
16
+ /** Parse and run one Markdown file against whatever is currently registered. */
17
+ export declare function runFile(file: string, options?: RunOptions): Promise<{
18
+ run: RunResult;
19
+ parsed: ParseResult;
20
+ }>;
21
+ /** Run an already-parsed document. */
22
+ export declare function runParsed(parsed: ParseResult, options?: RunOptions): Promise<RunResult>;
23
+ /** Run every case for one anchor. */ /** Run every case for one anchor. */
24
+ export declare function runAnchor(anchor: Anchor, file: string, options?: RunOptions): Promise<AnchorResult>;
25
+ /** One case, ready to run. Throwing from `run` is the failure signal. */
26
+ export interface PlannedCase {
27
+ name: string;
28
+ line: number | null;
29
+ run: () => Promise<void>;
30
+ }
31
+ export interface Plan {
32
+ /** Set when the anchor is not this run's business at all. */
33
+ skipReason: string | null;
34
+ /** Set when the anchor fails as a whole, before any case runs. */
35
+ failReason: string | null;
36
+ cases: PlannedCase[];
37
+ }
38
+ /**
39
+ * Resolve an anchor into runnable cases without executing them, so a test
40
+ * framework can own the scheduling and reporting.
41
+ *
42
+ * This is the single planning path: `runAnchor` and `bun test` both consume
43
+ * it, so a defective row fails identically under either.
44
+ */
45
+ export declare function planCases(anchor: Anchor, file: string): Plan;
46
+ /** One anchor, expanded into the cases a test runner can schedule. */
47
+ export interface DocumentSuite {
48
+ id: string;
49
+ kind: AnchorKind;
50
+ label: string;
51
+ line: number;
52
+ /** Set when the anchor has no handler. */
53
+ skipReason: string | null;
54
+ /** Set when the anchor fails as a whole, before any case runs. */
55
+ failReason: string | null;
56
+ cases: PlannedCase[];
57
+ }
58
+ export interface LoadedDocument {
59
+ file: string;
60
+ parsed: ParseResult;
61
+ suites: DocumentSuite[];
62
+ /** Parse problems and reference diagnostics, combined. */
63
+ problems: ParseProblem[];
64
+ reviews: ReviewResult[];
65
+ }
66
+ /**
67
+ * Load one document and everything needed to run it, in isolation.
68
+ *
69
+ * The registry is a module-level singleton, so two documents that happen to
70
+ * share an anchor id -- `prices` is not an unusual name -- collide if their
71
+ * glue files are simply imported into the same process. Bun shares module
72
+ * state across test files, so that is not hypothetical.
73
+ *
74
+ * This resets the registry, loads only this document's glue, and returns cases
75
+ * whose closures already hold their handler. A later `loadDocument` call may
76
+ * reset the registry again without disturbing them. Use this rather than
77
+ * importing glue directly when a process handles more than one document.
78
+ */
79
+ export declare function loadDocument(file: string, options?: RunOptions & {
80
+ glue?: string;
81
+ }): Promise<LoadedDocument>;
82
+ /**
83
+ * Find the glue file for a Markdown document: an explicit path wins, then a
84
+ * `<!-- verify: ./x.ts -->` hint in the document, then convention.
85
+ */
86
+ export declare function resolveGlue(mdPath: string, explicit?: string, source?: string): string | null;
87
+ /**
88
+ * Import a glue module, bypassing the module cache so repeat runs re-register.
89
+ *
90
+ * A glue file that throws while loading is a common authoring mistake, and the
91
+ * bare error says nothing about which file it came from. Callers add the
92
+ * document; this adds the glue file and keeps the original as `cause`.
93
+ */
94
+ export declare function loadGlue(path: string): Promise<void>;