@jpeakai/prose-gates 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/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "@jpeakai/prose-gates",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Deterministic prose gates for markdown documentation",
6
+ "license": "MIT",
7
+ "keywords": [
8
+ "markdown",
9
+ "prose",
10
+ "lint",
11
+ "documentation"
12
+ ],
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/jpeakai/jpai-prose-gates.git"
16
+ },
17
+ "homepage": "https://github.com/jpeakai/jpai-prose-gates#readme",
18
+ "bugs": "https://github.com/jpeakai/jpai-prose-gates/issues",
19
+ "packageManager": "bun@1.3.12",
20
+ "publishConfig": {
21
+ "access": "public"
22
+ },
23
+ "scripts": {
24
+ "prepublishOnly": "make check",
25
+ "prepack": "make build"
26
+ },
27
+ "bin": {
28
+ "prose-gates": "dist/bin.js"
29
+ },
30
+ "exports": {
31
+ ".": {
32
+ "bun": "./src/index.ts",
33
+ "types": "./src/index.ts",
34
+ "default": "./dist/index.js"
35
+ }
36
+ },
37
+ "engines": {
38
+ "node": ">=20"
39
+ },
40
+ "files": [
41
+ "src",
42
+ "dist"
43
+ ],
44
+ "dependencies": {
45
+ "mdast-util-from-markdown": "^2.0.3",
46
+ "mdast-util-frontmatter": "^2.0.1",
47
+ "mdast-util-gfm": "^3.1.0",
48
+ "micromark-extension-frontmatter": "^2.0.0",
49
+ "micromark-extension-gfm": "^3.0.0",
50
+ "unist-util-visit-parents": "^6.0.2"
51
+ },
52
+ "devDependencies": {
53
+ "@biomejs/biome": "^2",
54
+ "@types/bun": "latest",
55
+ "@types/mdast": "^4",
56
+ "fast-check": "^4.10.0",
57
+ "typescript": "^5"
58
+ }
59
+ }
package/src/bin.ts ADDED
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env node
2
+ import { main } from "./cli.ts";
3
+
4
+ main().then(
5
+ (code) => process.exit(code),
6
+ (err: unknown) => {
7
+ console.error(`error: ${err instanceof Error ? err.message : String(err)}`);
8
+ process.exit(1);
9
+ },
10
+ );
package/src/check.ts ADDED
@@ -0,0 +1,24 @@
1
+ // Checking: build the model once, run every rule over it, and audit
2
+ // embedded markdown fences as documents of their own.
3
+
4
+ import { buildDocModel, type DocModel } from "./model.ts";
5
+ import { RULES } from "./rules/index.ts";
6
+ import { interpunctRuns } from "./rules/interpunct.ts";
7
+ import type { Finding } from "./rules/types.ts";
8
+
9
+ export const DEFAULT_MAX_WORDS = 25;
10
+
11
+ export const checkModel = (doc: DocModel, file: string, maxWords: number): Finding[] => {
12
+ const ctx = { doc, file, maxWords, runs: interpunctRuns(doc) };
13
+ const findings = RULES.flatMap((rule) => rule.check(ctx));
14
+ // The fence body starts one line below the opening fence.
15
+ for (const fence of doc.fences) {
16
+ for (const f of checkModel(buildDocModel(fence.value), file, maxWords)) {
17
+ findings.push({ ...f, line: fence.line + f.line });
18
+ }
19
+ }
20
+ return findings.sort((a, b) => a.line - b.line || a.rule.localeCompare(b.rule));
21
+ };
22
+
23
+ export const checkMarkdown = (src: string, file: string, maxWords: number = DEFAULT_MAX_WORDS): Finding[] =>
24
+ checkModel(buildDocModel(src), file, maxWords);
package/src/cli.ts ADDED
@@ -0,0 +1,82 @@
1
+ // The command line: check files, optionally fix them first. The process
2
+ // glue lives in bin.ts so tests can call main() directly.
3
+
4
+ import { readFile, writeFile } from "node:fs/promises";
5
+ import { parseArgs } from "node:util";
6
+ import { checkModel, DEFAULT_MAX_WORDS } from "./check.ts";
7
+ import { fixMarkdown } from "./fix/engine.ts";
8
+ import { buildDocModel } from "./model.ts";
9
+ import { RULES } from "./rules/index.ts";
10
+ import { CATEGORIES } from "./rules/types.ts";
11
+
12
+ const USAGE = `usage: prose-gates <file.md> [...more] [--fix] [--json] [--max-words N]
13
+
14
+ Deterministic prose gates (* = autofixable):
15
+ ${CATEGORIES.map((c) =>
16
+ [` ${c}:`, ...RULES.filter((r) => r.category === c).map((r) => ` ${r.id}${r.fix ? "*" : " "} ${r.summary}`)].join(
17
+ "\n",
18
+ ),
19
+ ).join("\n")}
20
+
21
+ Examples of every rule, with fixes: RULES.md
22
+
23
+ Code blocks are exempt, except fences tagged markdown/md: those hold
24
+ markdown templates and their body is audited recursively (check-only).
25
+
26
+ --fix applies every fix it can prove safe, then reports what remains.
27
+ A fixer that is unsure leaves the text alone and the finding stays.
28
+ Exits 1 when findings remain, 2 on usage error.`;
29
+
30
+ export const main = async (argv: string[] = process.argv.slice(2)): Promise<number> => {
31
+ let values: { fix?: boolean; json?: boolean; help?: boolean; "max-words"?: string };
32
+ let positionals: string[];
33
+ try {
34
+ ({ values, positionals } = parseArgs({
35
+ args: argv,
36
+ options: {
37
+ fix: { type: "boolean", default: false },
38
+ json: { type: "boolean", default: false },
39
+ help: { type: "boolean", short: "h", default: false },
40
+ "max-words": { type: "string" },
41
+ },
42
+ allowPositionals: true,
43
+ strict: true,
44
+ }));
45
+ } catch (err) {
46
+ console.error(`error: ${err instanceof Error ? err.message : String(err)}`);
47
+ console.error(USAGE);
48
+ return 2;
49
+ }
50
+ if (values.help) {
51
+ console.log(USAGE);
52
+ return 0;
53
+ }
54
+ const maxWords = values["max-words"] === undefined ? DEFAULT_MAX_WORDS : Number(values["max-words"]);
55
+ if (positionals.length === 0 || Number.isNaN(maxWords) || maxWords < 1) {
56
+ console.error(USAGE);
57
+ return 2;
58
+ }
59
+
60
+ const perFile = await Promise.all(
61
+ positionals.map(async (file) => {
62
+ let src = await readFile(file, "utf8");
63
+ if (values.fix) {
64
+ const fixed = fixMarkdown(src);
65
+ if (fixed !== src) {
66
+ await writeFile(file, fixed);
67
+ src = fixed;
68
+ }
69
+ }
70
+ return checkModel(buildDocModel(src), file, maxWords);
71
+ }),
72
+ );
73
+ const all = perFile.flat();
74
+
75
+ if (values.json) {
76
+ console.log(JSON.stringify({ findings: all, files: positionals.length }, null, 2));
77
+ } else {
78
+ for (const f of all) console.log(`${f.file}:${f.line} ${f.rule} ${f.message}`);
79
+ console.log(`${all.length} finding(s) in ${positionals.length} file(s)`);
80
+ }
81
+ return all.length > 0 ? 1 : 0;
82
+ };
@@ -0,0 +1,84 @@
1
+ // The fix engine: a fixpoint loop over splice edits. Each pass rebuilds the
2
+ // model, asks the fixers for edits in priority order, and keeps the first
3
+ // edit that survives verification. Offsets are only ever valid for the
4
+ // source they were computed from, so every accepted edit restarts the pass.
5
+
6
+ import { buildDocModel, parse } from "../model.ts";
7
+ import { FIX_ORDER } from "../rules/index.ts";
8
+ import type { Edit } from "../rules/types.ts";
9
+ import { verify } from "./verify.ts";
10
+
11
+ const MAX_PASSES = 10_000;
12
+
13
+ export interface FixReport {
14
+ output: string;
15
+ applied: Edit[];
16
+ refused: Edit[];
17
+ }
18
+
19
+ const splice = (src: string, edits: Edit[]): string => {
20
+ let out = src;
21
+ for (const e of [...edits].sort((a, b) => b.start - a.start)) out = out.slice(0, e.start) + e.text + out.slice(e.end);
22
+ return out;
23
+ };
24
+
25
+ const overlaps = (edits: Edit[]): boolean => {
26
+ const sorted = [...edits].sort((a, b) => a.start - b.start);
27
+ return sorted.some((e, i) => i > 0 && e.start < (sorted[i - 1] as Edit).end);
28
+ };
29
+
30
+ export const fixMarkdownReport = (src: string): FixReport => {
31
+ const applied: Edit[] = [];
32
+ const refused: Edit[] = [];
33
+ // An edit refused once stays refused while its source slice is unchanged.
34
+ const refusedKeys = new Set<string>();
35
+ let current = src;
36
+
37
+ for (let pass = 0; pass < MAX_PASSES; pass++) {
38
+ const doc = buildDocModel(current);
39
+ const before = { src: current, tree: doc.tree };
40
+ const keyOf = (e: Edit): string => `${e.rule}\u0000${current.slice(e.start, e.end)}\u0000${e.text}`;
41
+
42
+ const attempt = (edits: Edit[]): boolean => {
43
+ const next = splice(current, edits);
44
+ const afterTree = parse(next);
45
+ if (edits.every((e) => verify(e, before, afterTree) === "accept")) {
46
+ current = next;
47
+ applied.push(...edits);
48
+ return true;
49
+ }
50
+ return false;
51
+ };
52
+
53
+ let progressed = false;
54
+ for (const rule of FIX_ORDER) {
55
+ const edits = (rule.fix?.(doc) ?? []).filter(
56
+ (e) => current.slice(e.start, e.end) !== e.text && !refusedKeys.has(keyOf(e)),
57
+ );
58
+ if (edits.length === 0) continue;
59
+
60
+ // Whitespace and glyph edits leave the tree's shape alone, so a rule's
61
+ // whole batch can be proven in one re-parse. A batch that fails falls
62
+ // back to one edit at a time, so one bad edit never blocks the rest.
63
+ const batchable =
64
+ edits.length > 1 && !overlaps(edits) && edits.every((e) => e.expect.kind !== "replace-paragraph");
65
+ if (batchable && attempt(edits)) {
66
+ progressed = true;
67
+ break;
68
+ }
69
+ for (const edit of edits) {
70
+ if (attempt([edit])) {
71
+ progressed = true;
72
+ break;
73
+ }
74
+ refusedKeys.add(keyOf(edit));
75
+ refused.push(edit);
76
+ }
77
+ if (progressed) break;
78
+ }
79
+ if (!progressed) return { output: current, applied, refused };
80
+ }
81
+ throw new Error(`fix did not converge after ${MAX_PASSES} passes; a fixer keeps proposing edits`);
82
+ };
83
+
84
+ export const fixMarkdown = (src: string): string => fixMarkdownReport(src).output;
@@ -0,0 +1,107 @@
1
+ // List promotion: the shared fix for every "hidden list" rule. A sentence
2
+ // that strings items together inline becomes a lead-in line and a real
3
+ // markdown list. Item text is sliced from the source, never re-serialised,
4
+ // so links, emphasis, escapes and code spans travel unchanged.
5
+
6
+ import type { ParagraphView, Range } from "../model.ts";
7
+ import type { Edit, RuleId } from "../rules/types.ts";
8
+ import { collapse } from "./spans.ts";
9
+
10
+ export interface PromotedItem {
11
+ text: string;
12
+ children?: string[];
13
+ }
14
+
15
+ export interface Promotion {
16
+ rule: RuleId;
17
+ src: string;
18
+ view: ParagraphView;
19
+ span: Range; // the sentences the list replaces
20
+ leadIn: string | null;
21
+ items: PromotedItem[];
22
+ ordered: boolean;
23
+ }
24
+
25
+ // A line that would parse as something other than paragraph text when it
26
+ // starts a list item: a heading, quote, nested bullet, ordered marker,
27
+ // fence, thematic break, table pipe or raw HTML.
28
+ const UNSAFE_START =
29
+ /^(?:[-+*](?:\s|$)|#{1,6}(?:\s|$)|>|\d{1,9}[.)](?:\s|$)|`{3}|~{3}|<|\||[-_*=](?:\s*[-_*=]){2,}\s*$)/;
30
+
31
+ const CONJUNCTION_HEAD = /^(?:and|or)\s+/i;
32
+ const CONJUNCTION_TAIL = /\s+(?:and|or)$/i;
33
+
34
+ export const cleanItem = (raw: string, { last }: { last: boolean }): string => {
35
+ let s = collapse(raw).trim();
36
+ // "b, and" needs the conjunction and the comma gone, in either order. Only
37
+ // the last item may open with the joining conjunction: "or" at the head of
38
+ // a middle item is content the list would otherwise swallow.
39
+ for (let prev = ""; prev !== s; ) {
40
+ prev = s;
41
+ s = s.replace(/[;,]+$/, "").trim();
42
+ s = s.replace(CONJUNCTION_TAIL, "").trim();
43
+ if (last) s = s.replace(CONJUNCTION_HEAD, "").trim();
44
+ }
45
+ if (last) s = s.replace(/(?<!\.)\.$/, "").trim();
46
+ return s;
47
+ };
48
+
49
+ export const cleanLeadIn = (raw: string): string | null => {
50
+ const s = collapse(raw)
51
+ .trim()
52
+ .replace(/[\s:;,—-]+$/, "")
53
+ .trim();
54
+ return s.length > 0 ? `${s}:` : null;
55
+ };
56
+
57
+ const dedent = (raw: string, indent: string): string =>
58
+ raw
59
+ .split("\n")
60
+ .map((line, i) => (i > 0 && line.startsWith(indent) ? line.slice(indent.length) : line.trimStart()))
61
+ .join("\n");
62
+
63
+ export const promote = (p: Promotion): Edit | null => {
64
+ const { view, src, span } = p;
65
+ if (view.inTable || view.inBlockquote || view.hasBreak) return null;
66
+ if (p.items.length < 2) return null;
67
+
68
+ const texts = [...p.items.flatMap((i) => [i.text, ...(i.children ?? [])]), ...(p.leadIn ? [p.leadIn] : [])];
69
+ if (texts.some((t) => t.length === 0 || UNSAFE_START.test(t))) return null;
70
+
71
+ const indent = " ".repeat(view.startColumn - 1);
72
+ const before = dedent(src.slice(view.startOffset, span[0]), indent).trim();
73
+ const after = dedent(src.slice(span[1], view.endOffset), indent).trim();
74
+ const head = [before, p.leadIn].filter((s): s is string => s !== null && s.length > 0).join("\n");
75
+
76
+ const list = p.items
77
+ .map((item, i) => {
78
+ const marker = p.ordered ? `${i + 1}. ` : "- ";
79
+ const pad = " ".repeat(marker.length);
80
+ return [`${marker}${item.text}`, ...(item.children ?? []).map((c) => `${pad}- ${c}`)].join("\n");
81
+ })
82
+ .join("\n");
83
+
84
+ let fragment: string;
85
+ if (view.inListItem) {
86
+ // Inside a list item a blank line would loosen the parent list, and text
87
+ // after the nested list would become lazy continuation. Both change what
88
+ // renders, so only a lead-in followed by the list is promotable here.
89
+ if (head.length === 0 || after.length > 0) return null;
90
+ fragment = `${head}\n${list}`;
91
+ } else {
92
+ fragment = [head, list, after].filter((s) => s.length > 0).join("\n\n");
93
+ }
94
+
95
+ const text = fragment
96
+ .split("\n")
97
+ .map((line, i) => (i === 0 || line.length === 0 ? line : indent + line))
98
+ .join("\n");
99
+
100
+ return {
101
+ rule: p.rule,
102
+ start: view.startOffset,
103
+ end: view.endOffset,
104
+ text,
105
+ expect: { kind: "replace-paragraph", view, fragment, listItems: p.items.length },
106
+ };
107
+ };
@@ -0,0 +1,75 @@
1
+ // Source-offset helpers shared by fixers. Everything here works on absolute
2
+ // offsets into the document source, and only ever looks inside text the
3
+ // parser proved is prose.
4
+
5
+ import type { DirectText, ParagraphView, Range } from "../model.ts";
6
+
7
+ // Absolute offsets of every match of `pattern` inside a text node, or null
8
+ // when the source slice and the parsed value disagree (an entity like
9
+ // `&middot;` or an escape). A fixer that cannot map a glyph back to the
10
+ // source refuses rather than guesses.
11
+ export const matchesIn = (src: string, text: DirectText, pattern: RegExp): Range[] | null => {
12
+ const slice = src.slice(text.start, text.end);
13
+ const flags = pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`;
14
+ const inSlice = [...slice.matchAll(new RegExp(pattern.source, flags))];
15
+ const inValue = [...text.value.matchAll(new RegExp(pattern.source, flags))];
16
+ if (inSlice.length !== inValue.length) return null;
17
+ return inSlice.map((m) => [text.start + (m.index ?? 0), text.start + (m.index ?? 0) + m[0].length]);
18
+ };
19
+
20
+ export const matchesInAll = (src: string, texts: DirectText[], pattern: RegExp): Range[] | null => {
21
+ const out: Range[] = [];
22
+ for (const t of texts) {
23
+ const found = matchesIn(src, t, pattern);
24
+ if (found === null) return null;
25
+ out.push(...found);
26
+ }
27
+ return out;
28
+ };
29
+
30
+ // Sentence boundaries a fixer trusts: a terminal followed by whitespace,
31
+ // inside a direct text node. Terminals inside links, emphasis or code never
32
+ // split a sentence. Each boundary is [end of terminal, start of next sentence].
33
+ const boundaries = (src: string, view: ParagraphView): Range[] => {
34
+ const out: Range[] = [];
35
+ for (const t of view.directTexts) {
36
+ const slice = src.slice(t.start, t.end);
37
+ for (const m of slice.matchAll(/[.!?]+(\s+)/g)) {
38
+ const at = t.start + (m.index ?? 0);
39
+ out.push([at + m[0].length - (m[1]?.length ?? 0), at + m[0].length]);
40
+ }
41
+ }
42
+ return out.sort((a, b) => a[0] - b[0]);
43
+ };
44
+
45
+ // The sentence span that covers [from, to): from the start of the sentence
46
+ // holding `from` to the end of the sentence holding `to`.
47
+ export const sentenceSpan = (src: string, view: ParagraphView, from: number, to: number): Range => {
48
+ let start = view.startOffset;
49
+ let end = view.endOffset;
50
+ for (const [terminalEnd, nextStart] of boundaries(src, view)) {
51
+ if (nextStart <= from) start = nextStart;
52
+ if (terminalEnd >= to && end === view.endOffset) end = terminalEnd;
53
+ }
54
+ return [start, end];
55
+ };
56
+
57
+ // Every sentence span of a paragraph, in order.
58
+ export const sentenceSpans = (src: string, view: ParagraphView): Range[] => {
59
+ const spans: Range[] = [];
60
+ let start = view.startOffset;
61
+ for (const [terminalEnd, nextStart] of boundaries(src, view)) {
62
+ spans.push([start, terminalEnd]);
63
+ start = nextStart;
64
+ }
65
+ spans.push([start, view.endOffset]);
66
+ return spans;
67
+ };
68
+
69
+ export const within = (r: Range, span: Range): boolean => r[0] >= span[0] && r[1] <= span[1];
70
+
71
+ // Soft line breaks collapse to single spaces; nothing else in the slice moves.
72
+ export const collapse = (s: string): string => s.replace(/[ \t]*\r?\n[ \t]*/g, " ");
73
+
74
+ // A glyph the author escaped is literal text, never a separator to rewrite.
75
+ export const escaped = (src: string, offset: number): boolean => src[offset - 1] === "\\";
@@ -0,0 +1,134 @@
1
+ // Proof obligations for a fix. The engine applies one edit, re-parses, and
2
+ // checks two things: no word was lost (a fixer bug, so it throws), and the
3
+ // document's structure changed exactly as the edit declared (a context the
4
+ // fixer could not see, so the edit is refused and the finding stays).
5
+
6
+ import type { Node, Parent, Root } from "mdast";
7
+ import { visitParents as visit } from "unist-util-visit-parents";
8
+ import { parse } from "../model.ts";
9
+ import type { Edit } from "../rules/types.ts";
10
+
11
+ export class FixInvariantError extends Error {
12
+ constructor(edit: Edit, detail: string) {
13
+ super(`${edit.rule} fixer lost content (${detail}); this is a bug in the fixer, not the document`);
14
+ this.name = "FixInvariantError";
15
+ }
16
+ }
17
+
18
+ // Words carried by the document: text, code, alt text and every URL. The
19
+ // punctuation a fixer is allowed to rewrite (separators, enumeration markers,
20
+ // joining conjunctions) is excluded so the comparison proves only that no
21
+ // real content moved or vanished. A marker is recognised whatever follows
22
+ // it: a separator swap that turns "(1) —" into "(1):" must not make the
23
+ // marker count as a word it never was.
24
+ const ENUM_MARKER = /(?<![\p{L}\p{N}_])\((?:[a-z]|[ivx]+|\d+)\)/giu;
25
+
26
+ export const fingerprint = (tree: Root): string[] => {
27
+ const words: string[] = [];
28
+ const take = (s: string | null | undefined, stripMarkers = false): void => {
29
+ if (!s) return;
30
+ const text = stripMarkers ? s.replace(ENUM_MARKER, " ") : s;
31
+ for (const m of text.matchAll(/[\p{L}\p{N}_]+/gu)) {
32
+ const w = m[0];
33
+ if (!/^(?:and|or)$/i.test(w)) words.push(w);
34
+ }
35
+ };
36
+ visit(tree, (node: Node) => {
37
+ const n = node as Node & { value?: string; url?: string; alt?: string; title?: string };
38
+ if (node.type === "text") take(n.value, true);
39
+ else if (node.type === "inlineCode" || node.type === "code" || node.type === "html") take(n.value);
40
+ else if (node.type === "yaml" || node.type === "toml") take(n.value);
41
+ take(n.url);
42
+ take(n.alt);
43
+ take(n.title);
44
+ });
45
+ return words;
46
+ };
47
+
48
+ type Plain = { [k: string]: unknown };
49
+
50
+ const OMIT = new Set(["position", "data"]);
51
+
52
+ const normaliseValue = (node: Node, value: string, blankText: boolean): string => {
53
+ if (node.type !== "text") return value;
54
+ return blankText ? "" : value.replace(/\s+/g, " ").trim();
55
+ };
56
+
57
+ // A position-free copy of the tree for structural comparison. `swap`
58
+ // replaces one node with a list of nodes, which is how the expected tree for
59
+ // a paragraph replacement is built.
60
+ export const normalise = (node: Node, opts: { blankText?: boolean; swap?: [Node, Node[]] } = {}): Plain => {
61
+ const out: Plain = {};
62
+ for (const [key, value] of Object.entries(node)) {
63
+ if (OMIT.has(key)) continue;
64
+ if (key === "children" && Array.isArray(value)) {
65
+ out.children = (value as Node[]).flatMap((child) =>
66
+ opts.swap && child === opts.swap[0]
67
+ ? opts.swap[1].map((c) => normalise(c, { blankText: opts.blankText }))
68
+ : [normalise(child, opts)],
69
+ );
70
+ } else if (key === "value" && typeof value === "string") {
71
+ out.value = normaliseValue(node, value, opts.blankText ?? false);
72
+ } else {
73
+ out[key] = value;
74
+ }
75
+ }
76
+ return out;
77
+ };
78
+
79
+ const sameJson = (a: unknown, b: unknown): boolean => JSON.stringify(a) === JSON.stringify(b);
80
+
81
+ // Definitions resolve references document-wide, so a fragment parsed on its
82
+ // own borrows every definition from the document it came from.
83
+ const definitionsOf = (src: string, tree: Root): string[] => {
84
+ const defs: string[] = [];
85
+ visit(tree, (node: Node) => {
86
+ if ((node.type === "definition" || node.type === "footnoteDefinition") && node.position) {
87
+ const { start, end } = node.position;
88
+ if (start.offset != null && end.offset != null) defs.push(src.slice(start.offset, end.offset));
89
+ }
90
+ });
91
+ return defs;
92
+ };
93
+
94
+ const parseFragment = (fragment: string, defs: string[]): Node[] => {
95
+ const tree = parse([fragment, ...defs].join("\n\n"));
96
+ return tree.children.slice(0, tree.children.length - defs.length);
97
+ };
98
+
99
+ // The fragment must be the shape the fixer meant: optional paragraphs around
100
+ // exactly one list whose items each open with paragraph text.
101
+ const fragmentShapeOk = (nodes: Node[], listItems: number): boolean => {
102
+ const lists = nodes.filter((n) => n.type === "list") as Parent[];
103
+ if (lists.length !== 1 || nodes.some((n) => n.type !== "list" && n.type !== "paragraph")) return false;
104
+ const items = (lists[0] as Parent).children as Parent[];
105
+ return items.length === listItems && items.every((i) => i.children[0]?.type === "paragraph");
106
+ };
107
+
108
+ export type Verdict = "accept" | "refuse";
109
+
110
+ export const verify = (edit: Edit, before: { src: string; tree: Root }, afterTree: Root): Verdict => {
111
+ const lost = fingerprint(before.tree).join(" ");
112
+ const kept = fingerprint(afterTree).join(" ");
113
+ if (lost !== kept) {
114
+ const a = lost.split(" ");
115
+ const b = kept.split(" ");
116
+ const diverge = a.findIndex((w, i) => w !== b[i]);
117
+ const at = diverge === -1 ? a.length : diverge;
118
+ throw new FixInvariantError(edit, `word ${at} was "${a[at] ?? ""}", now "${b[at] ?? ""}"`);
119
+ }
120
+
121
+ const expect = edit.expect;
122
+ if (expect.kind === "same-tree") {
123
+ return sameJson(normalise(before.tree), normalise(afterTree)) ? "accept" : "refuse";
124
+ }
125
+ if (expect.kind === "same-shape") {
126
+ return sameJson(normalise(before.tree, { blankText: true }), normalise(afterTree, { blankText: true }))
127
+ ? "accept"
128
+ : "refuse";
129
+ }
130
+ const nodes = parseFragment(expect.fragment, definitionsOf(before.src, before.tree));
131
+ if (!fragmentShapeOk(nodes, expect.listItems)) return "refuse";
132
+ const expected = normalise(before.tree, { swap: [expect.view.node, nodes] });
133
+ return sameJson(expected, normalise(afterTree)) ? "accept" : "refuse";
134
+ };
package/src/index.ts ADDED
@@ -0,0 +1,10 @@
1
+ // Public API.
2
+
3
+ export { checkMarkdown, checkModel, DEFAULT_MAX_WORDS } from "./check.ts";
4
+ export { main } from "./cli.ts";
5
+ export { type FixReport, fixMarkdown, fixMarkdownReport } from "./fix/engine.ts";
6
+ export { FixInvariantError } from "./fix/verify.ts";
7
+ export { buildDocModel, type DocModel } from "./model.ts";
8
+ export { FIX_ORDER, RULES } from "./rules/index.ts";
9
+ export type { Category, Edit, Finding, Rule, RuleId } from "./rules/types.ts";
10
+ export { CATEGORIES, RULE } from "./rules/types.ts";