@docsworth/cli 0.0.0-stage → 0.3.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.
Files changed (103) hide show
  1. package/LICENSE +55 -0
  2. package/README.md +238 -2
  3. package/dist/cli.d.ts +11 -0
  4. package/dist/cli.js +458 -0
  5. package/dist/core/acl.d.ts +37 -0
  6. package/dist/core/acl.js +136 -0
  7. package/dist/core/adopt.d.ts +68 -0
  8. package/dist/core/adopt.js +665 -0
  9. package/dist/core/audit-log.d.ts +133 -0
  10. package/dist/core/audit-log.js +290 -0
  11. package/dist/core/blocks.d.ts +35 -0
  12. package/dist/core/blocks.js +417 -0
  13. package/dist/core/codeowners.d.ts +62 -0
  14. package/dist/core/codeowners.js +133 -0
  15. package/dist/core/config.d.ts +101 -0
  16. package/dist/core/config.js +175 -0
  17. package/dist/core/coverage.d.ts +25 -0
  18. package/dist/core/coverage.js +41 -0
  19. package/dist/core/duration.d.ts +20 -0
  20. package/dist/core/duration.js +60 -0
  21. package/dist/core/edits.d.ts +13 -0
  22. package/dist/core/edits.js +20 -0
  23. package/dist/core/errors.d.ts +11 -0
  24. package/dist/core/errors.js +13 -0
  25. package/dist/core/frontmatter.d.ts +29 -0
  26. package/dist/core/frontmatter.js +49 -0
  27. package/dist/core/gate.d.ts +17 -0
  28. package/dist/core/gate.js +25 -0
  29. package/dist/core/gfm.d.ts +13 -0
  30. package/dist/core/gfm.js +244 -0
  31. package/dist/core/github.d.ts +152 -0
  32. package/dist/core/github.js +234 -0
  33. package/dist/core/host.d.ts +125 -0
  34. package/dist/core/host.js +22 -0
  35. package/dist/core/identity.d.ts +39 -0
  36. package/dist/core/identity.js +127 -0
  37. package/dist/core/init.d.ts +25 -0
  38. package/dist/core/init.js +86 -0
  39. package/dist/core/links.d.ts +82 -0
  40. package/dist/core/links.js +345 -0
  41. package/dist/core/lint.d.ts +16 -0
  42. package/dist/core/lint.js +149 -0
  43. package/dist/core/mcp/audit-transport.d.ts +16 -0
  44. package/dist/core/mcp/audit-transport.js +238 -0
  45. package/dist/core/mcp/server.d.ts +19 -0
  46. package/dist/core/mcp/server.js +216 -0
  47. package/dist/core/mcp/text.d.ts +14 -0
  48. package/dist/core/mcp/text.js +43 -0
  49. package/dist/core/mirror-view.d.ts +68 -0
  50. package/dist/core/mirror-view.js +367 -0
  51. package/dist/core/new-page.d.ts +52 -0
  52. package/dist/core/new-page.js +49 -0
  53. package/dist/core/policy.d.ts +10 -0
  54. package/dist/core/policy.js +30 -0
  55. package/dist/core/polish.d.ts +42 -0
  56. package/dist/core/polish.js +1000 -0
  57. package/dist/core/printable.d.ts +4 -0
  58. package/dist/core/printable.js +16 -0
  59. package/dist/core/references.d.ts +87 -0
  60. package/dist/core/references.js +1744 -0
  61. package/dist/core/repo.d.ts +46 -0
  62. package/dist/core/repo.js +118 -0
  63. package/dist/core/schema.d.ts +50 -0
  64. package/dist/core/schema.js +51 -0
  65. package/dist/core/search.d.ts +32 -0
  66. package/dist/core/search.js +132 -0
  67. package/dist/core/serve/auth.d.ts +23 -0
  68. package/dist/core/serve/auth.js +38 -0
  69. package/dist/core/serve/server.d.ts +21 -0
  70. package/dist/core/serve/server.js +138 -0
  71. package/dist/core/serve/sessions.d.ts +40 -0
  72. package/dist/core/serve/sessions.js +203 -0
  73. package/dist/core/serve/token.d.ts +10 -0
  74. package/dist/core/serve/token.js +96 -0
  75. package/dist/core/service.d.ts +473 -0
  76. package/dist/core/service.js +973 -0
  77. package/dist/core/sla.d.ts +16 -0
  78. package/dist/core/sla.js +20 -0
  79. package/dist/core/stale.d.ts +16 -0
  80. package/dist/core/stale.js +24 -0
  81. package/dist/core/tool-registry.d.ts +31 -0
  82. package/dist/core/tool-registry.js +80 -0
  83. package/dist/core/url.d.ts +4 -0
  84. package/dist/core/url.js +31 -0
  85. package/dist/core/verify-pages.d.ts +19 -0
  86. package/dist/core/verify-pages.js +110 -0
  87. package/dist/core/version.d.ts +22 -0
  88. package/dist/core/version.js +32 -0
  89. package/dist/core/writes.d.ts +58 -0
  90. package/dist/core/writes.js +113 -0
  91. package/dist/web/assets/index-8J-FUbb0.js +29 -0
  92. package/dist/web/assets/index-BGpAZzeM.css +1 -0
  93. package/dist/web/index.html +14 -0
  94. package/docs/format-spec.md +349 -0
  95. package/package.json +75 -4
  96. package/plugin/.claude-plugin/plugin.json +8 -0
  97. package/plugin/.mcp.json +8 -0
  98. package/plugin/bin/docsworth-mcp.mjs +46 -0
  99. package/plugin/skills/draft-runbook/SKILL.md +75 -0
  100. package/plugin/skills/polish-page/SKILL.md +64 -0
  101. package/plugin/skills/update-after-incident/SKILL.md +59 -0
  102. package/plugin/skills/verify-page/SKILL.md +46 -0
  103. package/templates/github/knowledge.yml +51 -0
@@ -0,0 +1,1000 @@
1
+ // The polish guard (v0.2 L1). `propose_edit` with `polish: true` may restructure and
2
+ // reword a page, but not change the things a reader acts on. This is a structural check,
3
+ // not proof that the meaning is unchanged. It compares:
4
+ // - the frontmatter, byte for byte
5
+ // - code blocks (fenced and indented, as CommonMark finds them), in order
6
+ // - inline code, effective link targets and bare URLs, as multisets
7
+ // - struck-through text, as a multiset
8
+ // - each number (with its sign) and the word after it ("5 minutes"), so swaps show
9
+ // - the count of each negation and modal word ("not", "never", "must", ...)
10
+ // What it can't see (a renamed service, two same-unit numbers trading places, a command
11
+ // in plain prose, a reworded sentence that now means something else) is why the PR
12
+ // still goes to the owner team.
13
+ import { FENCE_MARK, codeBlocks } from "./blocks.js";
14
+ const FRONTMATTER = /^---\r?\n[\s\S]*?\r?\n---[ \t]*(?:\r?\n|$)/;
15
+ // An HTML comment renders as nothing, so it's compared on its own and kept out of every
16
+ // other fact: a fact moved into a comment has left the page a reader sees.
17
+ /**
18
+ * Spans from `open` to the next `close`, found with indexOf: linear, where a lazy regex
19
+ * would rescan to the end for every unclosed opener. Once a close is missing, no later
20
+ * opener can be closed either, so the scan stops.
21
+ */
22
+ function spans(text, open, close) {
23
+ const out = [];
24
+ for (let i = text.indexOf(open); i >= 0;) {
25
+ const j = text.indexOf(close, i + open.length);
26
+ if (j < 0)
27
+ break;
28
+ out.push([i, j + close.length]);
29
+ i = text.indexOf(open, j + close.length);
30
+ }
31
+ return out;
32
+ }
33
+ const cut = (text, ss, keep) => {
34
+ let out = "";
35
+ let at = 0;
36
+ for (const [i, j] of ss) {
37
+ if (i < at)
38
+ continue; // inside one already cut
39
+ out += text.slice(at, i) + keep(text.slice(i, j));
40
+ at = j;
41
+ }
42
+ return out + text.slice(at);
43
+ };
44
+ // A code span: a run of backticks, closed by a run of the same length. It may wrap onto
45
+ // the next line, but not across a blank one (that ends the paragraph).
46
+ const INLINE_CODE = /(?<!`)(`+)(?!`)((?:[^\n]|\n(?![ \t]*\n))+?)(?<!`)\1(?!`)/g;
47
+ // A reference definition; its destination may sit on the next line (CommonMark).
48
+ // Not a footnote (`[^1]: …`), whose text renders and so is read like any other.
49
+ // Container markers (quotes, list items) may come first, on the line and on its
50
+ // continuation lines: `> [x]: safe.md` defines a link too. A title must close, and
51
+ // nothing may follow it: otherwise the line isn't a definition, renders as text, and is
52
+ // compared as text.
53
+ // Each space has one place to match: the spaces before a marker go with it, and the
54
+ // pattern after the markers takes the rest. (`>[ \t]?` then `[ \t]*` could split a
55
+ // space two ways per marker, and `> > > …` backtracked exponentially.) A list marker
56
+ // still needs a space after it; four spaces or a tab anywhere in the prefix refuses the
57
+ // polish (definitionSpans), so `[ \t]{0,3}` after the markers loses nothing.
58
+ const CONTAINER = String.raw `(?:[ \t]*(?:>|(?:[-*+]|\d{1,9}[.)])(?=[ \t])))*`;
59
+ const CONTINUE = String.raw `(?:[ \t]*>)*`;
60
+ const REF_DEF = new RegExp(String.raw `^${CONTAINER}[ \t]{0,3}\[(?!\^)((?:\\.|[^\[\]\\\n])+)\]:[ \t]*(?:\n${CONTINUE}[ \t]*)?(<(?:\\.|[^<>\\\n])*>|\S+)(?:(?:[ \t]+|[ \t]*\n${CONTINUE}[ \t]+)("(?:\\.|[^"\\\n])*"|'(?:\\.|[^'\\\n])*'|\((?:\\.|[^()\\\n])*\)))?[ \t]*$`, "gm");
61
+ // Raw HTML tags. Markdown can't hide text; HTML can (`<span hidden>`, a style, a
62
+ // <template>), so a polish may not add, drop or change a single tag.
63
+ // Every raw HTML form CommonMark passes through: tags, processing instructions
64
+ // (`<? … ?>`), declarations (`<!DOCTYPE …>`) and CDATA. Comments are compared on their own.
65
+ // Every raw HTML opener, closed or not: an unclosed `<!--`, `<?` or `<div` hides what
66
+ // follows it, so openers are counted whether or not a closer comes.
67
+ const OPENER = /<(?:!--|!\[CDATA\[|![a-z]|\?|\/?[a-z][a-z0-9-]*)/gi;
68
+ // Processing instructions and CDATA are found by `spans` (see rawHtml).
69
+ const TAG = /<(?:\/?[a-z][a-z0-9-]*(?:\s[^<>]*)?\/?|![a-z][^<>]*)>/gi;
70
+ // Characters a reader can't tell apart from ASCII, or can't see at all.
71
+ // Allowed: ASCII, Latin-1 (not the invisible soft hyphen), typographic punctuation,
72
+ // arrows, maths symbols and box drawing.
73
+ // (ASCII controls other than tab and line breaks count as unusual: NUL, ESC, DEL.)
74
+ const UNUSUAL = /[^\t\n\r\x20-\x7e\u00a0-\u00ac\u00ae-\u00ff\u2010-\u2027\u2030-\u205e\u2190-\u21ff\u2200-\u22ff\u2500-\u25ff]/gu;
75
+ // Labels hold no unescaped brackets, so a scan stops at the next one (linear).
76
+ // (Backslash escapes, such as `\]`, count as label text.)
77
+ // The shown text may hold one level of brackets (`[the [production] guide][a]`).
78
+ // A label or link text can't hold a blank line: that ends the paragraph.
79
+ const FULL_REF = /(?<=(?:^|[^\\])(?:\\\\)*)\[((?:\\.|[^\[\]\\\n]|\n(?!\n)|\[(?:\\.|[^\[\]\\\n]|\n(?!\n))*\])*)\]\[((?:\\.|[^\[\]\\\n]|\n(?!\n))*)\]/g;
80
+ // \u0000 marks where an inline link's `](target)` was, so its text isn't read as a use.
81
+ // `[a]: x` in paragraph text is a use of `[a]` too: only a real definition is cut out.
82
+ // Nor does a `(` that didn't open an inline link stop one (those are cut out first), nor
83
+ // a `[` that doesn't open a well-formed label (`[prod][` then a blank line).
84
+ // Neither starts at an escaped `\[`.
85
+ const SHORTCUT_REF = /(?<=(?:^|[^\\])(?:\\\\)*)\[((?:\\.|[^\[\]\\\n]|\n(?!\n))+)\](?!\[(?:\\.|[^\[\]\\\n]|\n(?!\n))*\]|\u0000)/g;
86
+ // A scheme is case-insensitive: HTTPS://… is a URL too.
87
+ // Where GFM autolinks one: at a line's start or after a space, `*`, `_`, `~` or `(`
88
+ // (`\https://…` is text), and as far as it runs: to the next space or `<` (trimUrl takes
89
+ // the tail off). Both ways matter: a link that appears or goes is a change.
90
+ const URL = /(?<=^|[\s*_~(])https?:\/\/[^\s<]+/gim;
91
+ // Autolinks: `<scheme:anything>` and `<user@host>` (CommonMark), bare emails and `www.`
92
+ // links (GFM), and HTML anchors and images. Each is a target a reader can follow.
93
+ // Not after an odd run of backslashes: `\<https://…>` is text.
94
+ const AUTOLINK = /(?<=(?:^|[^\\])(?:\\\\)*)<([a-z][a-z0-9+.-]{1,31}:[^\s<>]*|[a-z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-z0-9-]+(?:\.[a-z0-9-]+)+)>/gi;
95
+ const BARE_EMAIL = /(?<![\w.+-])[a-z0-9.+_-]+@[a-z0-9-]+(?:\.[a-z0-9-]+)+/gi;
96
+ const WWW = /(?<=^|[\s*_~(])www\.[^\s<]+/gim;
97
+ // The attribute value quoted or not: `<a href=/safe>` is a link too.
98
+ const HTML_LINK = /<(?:a|img)\b[^<>]*?\b(?:href|src)\s*=\s*(?:"([^"<>]*)"|'([^'<>]*)'|([^\s"'=<>`]+))[^<>]*>/gi;
99
+ const STRUCK = /~~(?!~)([\s\S]+?)~~|<(del|s)>([^<]*)<\/\2>/gi;
100
+ // A number, with a sign only where one can be (not the hyphen of a range like 5-10), and
101
+ // the word after it. That word may sit on the next line of the same paragraph (rewrapping
102
+ // is a polish), but not across a blank line, a list item or a heading.
103
+ // A hyphen joins a unit too: `5-minute` is 5 with minute.
104
+ // The token is the whole run from its first digit (`1e3`, `0xff`, `30s`, `10:30`), so
105
+ // no part of it can change unseen.
106
+ // An identifier's digits (`ADR-0001`, `INC-77`) are taken with its prefix, and never
107
+ // paired with a unit: "ADR-0001 and" says nothing about "and".
108
+ // Any script's digits (`\p{Nd}`): `٥` to `٩` changes a number as `5` to `9` does.
109
+ const NUMBER = /(?<!\w)(?<!\p{Nd}\.)((?:[a-z][a-z0-9]*-(?=\p{Nd}))?(?:[-+\u2212](?=\.?\p{Nd}))?(?:\p{Nd}|\.\p{Nd})(?:[\w%]|\p{Nd}|[.,:](?=\p{Nd}))*)(?:(?:[ \t]+|[ \t]*\n[ \t]*|-(?=\p{L}))(\p{L}+))?/giu;
110
+ // A plural is the same unit: `5-minute timeout` and `5 minutes` say the same thing.
111
+ // Case counts in a unit (`MB` and `Mb`, `mW` and `MW`), except that a word of three
112
+ // letters or more may be capitalised (`5 Minutes` in a heading is `5 minutes`; merge
113
+ // gate, PR 3, round 36).
114
+ const unit = (w) => (/^\p{Lu}?\p{Ll}{2,}$/u.test(w) ? w.toLowerCase() : w).replace(/(?<=\w\w)s$/, "");
115
+ // Bold and italics are presentation: `5 **minutes**` says what `5 minutes` says.
116
+ // Strikethrough isn't (it withdraws the text), so it's compared, not stripped.
117
+ const EMPHASIS = /\*+|(?<!\w)_+|_+(?!\w)/g;
118
+ export const NEGATIONS = ["not", "never", "no", "must", "should", "cannot", "can't", "don't", "doesn't", "won't", "shouldn't", "mustn't", "isn't", "aren't", "only", "without", "unless", "except", "nor", "none", "neither", "always"];
119
+ const SCRIPTS = ["Latin", "Cyrillic", "Greek", "Armenian", "Georgian", "Hebrew", "Arabic", "Devanagari", "Bengali", "Tamil", "Thai", "Hangul", "Hiragana", "Katakana", "Han"];
120
+ const SCRIPT_RES = SCRIPTS.map((name) => [name, new RegExp(`\\p{Script=${name}}`, "u")]);
121
+ /** The script of a letter, if it's one of the common ones. */
122
+ const scriptOf = (c) => SCRIPT_RES.find(([, re]) => re.test(c))?.[0];
123
+ function frontmatter(text) {
124
+ return FRONTMATTER.exec(text)?.[0] ?? "";
125
+ }
126
+ /**
127
+ * Fenced code blocks and the prose around them. Line by line, as CommonMark does: a fence
128
+ * of 3+ backticks or tildes, closed by a fence of the same character at least as long;
129
+ * an unclosed fence runs to the end. Each content line loses up to the opening fence's
130
+ * own indentation, as CommonMark does, so moving a block into a list item (which indents
131
+ * fence and content together) is a polish, while shifting the code inside it isn't.
132
+ * Trailing whitespace is ignored.
133
+ */
134
+ /** Columns of leading whitespace, a tab reaching the next multiple of 4 (CommonMark). */
135
+ const columns = (ws) => [...ws].reduce((c, ch) => (ch === "\t" ? c + 4 - (c % 4) : c + 1), 0);
136
+ /**
137
+ * Code spans and HTML comments in one left-to-right pass, as CommonMark reads them:
138
+ * whichever starts first wins (a comment inside a code span is code, and a backtick
139
+ * inside a comment is comment). Each code span becomes CODE_MARK plus a private-use
140
+ * character naming it (no letter or digit, so no other check reads it), keeping its
141
+ * line breaks; each comment becomes its line breaks. Linear: a run length or a comment
142
+ * opener that found no closer once is not searched for again in that paragraph.
143
+ */
144
+ const CODE_MARK = "\u0004";
145
+ const CODE_REF = /\u0004([\u{F0000}-\u{FFFFD}])/gu;
146
+ function inlineTokens(text) {
147
+ const codes = [];
148
+ const raws = []; // each span as written, backticks and all
149
+ const comments = [];
150
+ let commentHidesCode = false;
151
+ let out = "";
152
+ let at = 0;
153
+ let noCommentClose = false;
154
+ const noCloser = new Map(); // run length → paragraph end it failed in
155
+ let paraEnd = -1;
156
+ const keepBreaks = (t) => t.replace(/[^\n]/g, "");
157
+ // Open brackets in this paragraph: at a `]` that closes one, CommonMark parses a link's
158
+ // `(destination)` before it reads any backtick inside it, so `](a`b)` isn't code.
159
+ let open = 0;
160
+ const linkTail = linkTails(text);
161
+ for (let i = 0; i < text.length;) {
162
+ const c = text[i];
163
+ if (c === "\\") {
164
+ i += 2;
165
+ continue;
166
+ }
167
+ if (c === "\n" && text[i + 1] === "\n")
168
+ open = 0;
169
+ else if (c === "[")
170
+ open++;
171
+ else if (c === "]" && open > 0) {
172
+ open--;
173
+ const tail = text[i + 1] === "(" ? linkTail(i) : null;
174
+ if (tail) {
175
+ i = tail.end;
176
+ continue;
177
+ }
178
+ }
179
+ if (c === "<" && !noCommentClose && text.startsWith("<!--", i)) {
180
+ const j = text.indexOf("-->", i + 4);
181
+ if (j < 0) {
182
+ noCommentClose = true;
183
+ i += 4;
184
+ continue;
185
+ }
186
+ const comment = text.slice(i, j + 3);
187
+ comments.push(comment.replace(/\s+/g, " "));
188
+ if (comment.includes(FENCE_MARK))
189
+ commentHidesCode = true;
190
+ out += text.slice(at, i) + keepBreaks(comment);
191
+ at = i = j + 3;
192
+ continue;
193
+ }
194
+ if (c === "`") {
195
+ let n = 0;
196
+ while (text[i + n] === "`")
197
+ n++;
198
+ if (i >= paraEnd) {
199
+ const para = text.indexOf("\n\n", i); // positions only move forward: linear
200
+ paraEnd = para < 0 ? text.length : para;
201
+ }
202
+ const end = paraEnd;
203
+ if ((noCloser.get(n) ?? -1) === end) {
204
+ i += n;
205
+ continue;
206
+ }
207
+ let close = -1;
208
+ for (let k = i + n; k < end;) {
209
+ if (text[k] !== "`") {
210
+ k++;
211
+ continue;
212
+ }
213
+ let m = 0;
214
+ while (text[k + m] === "`")
215
+ m++;
216
+ if (m === n) {
217
+ close = k;
218
+ break;
219
+ }
220
+ k += m;
221
+ }
222
+ if (close < 0) {
223
+ noCloser.set(n, end);
224
+ i += n;
225
+ continue;
226
+ }
227
+ const code = text.slice(i + n, close);
228
+ out += text.slice(at, i) + CODE_MARK + String.fromCodePoint(0xf0000 + codes.length) + keepBreaks(code);
229
+ // As CommonMark renders it: the line break becomes a space, and spaces before it
230
+ // stay. A continuation line's indentation is already gone by then: a paragraph's
231
+ // raw content is "formed by concatenating the lines and removing initial and final
232
+ // spaces or tabs" (spec §4.8; `aaa\n bbb` renders `aaa\nbbb`), before inline
233
+ // parsing finds the span. So `printf 'a\n b'` renders `printf 'a b'`.
234
+ codes.push(code.replace(/\n[ \t]*/g, " "));
235
+ raws.push(text.slice(i, close + n));
236
+ at = i = close + n;
237
+ continue;
238
+ }
239
+ i++;
240
+ }
241
+ return { masked: out + text.slice(at), codes, raws, comments, commentHidesCode };
242
+ }
243
+ /**
244
+ * A destination CommonMark takes: `<…>`, or a bare one that doesn't start with `<` and
245
+ * holds no space or control character, with its parentheses balanced (escaped ones
246
+ * aside) to cmark's depth of 32. Otherwise the line isn't a definition: it renders as
247
+ * text and is compared as text (merge gate, PR 3, round 31).
248
+ */
249
+ function destination(d) {
250
+ if (d.startsWith("<"))
251
+ return /^<(?:\\.|[^<>\\\n])*>$/.test(d);
252
+ if (/[\x00-\x20\x7f]/.test(d))
253
+ return false; // ASCII space and controls only, as cmark
254
+ let depth = 0;
255
+ for (let i = 0; i < d.length; i++) {
256
+ if (d[i] === "\\")
257
+ i++;
258
+ else if (d[i] === "(" && ++depth > 32)
259
+ return false;
260
+ else if (d[i] === ")" && depth-- === 0)
261
+ return false;
262
+ }
263
+ return depth === 0;
264
+ }
265
+ // The tail of a label that began on an earlier line (`[foo` then `bar]: …`).
266
+ const LABEL_TAIL = /^[^\[\n]*\]:/m;
267
+ // A definition's title that opens, on its line or the next, and maybe closes later: a
268
+ // title may span lines, and then it's hidden text (merge gate, PR 3, round 33).
269
+ const TITLE_OPEN = new RegExp(String.raw `^${CONTAINER}[ \t]{0,3}\[(?!\^)(?:\\.|[^\[\]\\\n])+\]:[ \t]*(?:\n${CONTINUE}[ \t]*)?(<(?:\\.|[^<>\\\n])*>|\S+)(?:[ \t]+|[ \t]*\n${CONTINUE}[ \t]*)(["'(])([^\n]*)$`, "gm");
270
+ const closes = (rest, open) => {
271
+ const close = open === "(" ? ")" : open;
272
+ for (let i = 0; i < rest.length; i++)
273
+ if (rest[i] === "\\")
274
+ i++;
275
+ else if (rest[i] === close)
276
+ return true;
277
+ return false;
278
+ };
279
+ // Quote and list markers at a line's start: what's left is the block the line holds.
280
+ /**
281
+ * Reference definitions that CommonMark would take as such: not interrupting a
282
+ * paragraph (the line before holds nothing, a heading, a fence, a thematic break or
283
+ * another definition, or the definition opens a new quote or list item). One that
284
+ * follows paragraph text renders as text, and is compared as text.
285
+ *
286
+ * `unsure` is set where telling the two apart takes more structure than this reads: a
287
+ * definition-like line indented by a tab or four spaces in prose (a deep list item's),
288
+ * a label or title that spans lines, or a definition after a `===` line (a setext
289
+ * heading, or paragraph text). A polish refuses those pages rather than guess (merge
290
+ * gate, PR 3, round 32). Indented code is found by the block pass and isn't prose.
291
+ */
292
+ // A definition on a line's content (its container markers off), as commonmark.js reads
293
+ // it: up to three spaces, the label, a colon, spaces (and one line break), the
294
+ // destination, then a title after spaces or a line break, and nothing after it.
295
+ const DEF_AT = /^ {0,3}\[(?!\^)((?:\\.|[^\[\]\\\n])+)\]:[ ]*(?:\n[ ]*)?(<(?:\\.|[^<>\\\n])*>|\S+)(?:(?:[ ]+|[ ]*\n[ ]*)("(?:\\.|[^"\\\n])*"|'(?:\\.|[^'\\\n])*'|\((?:\\.|[^()\\\n])*\)))?[ ]*(?=\n|$)/;
296
+ // A title that opens on a candidate's line (or the next) and doesn't close there may
297
+ // span lines, as CommonMark allows; checked on the same view the definitions are read
298
+ // on, so an indented continuation definition is seen too (found by fuzzing).
299
+ const TITLE_AT = /^ {0,3}\[(?!\^)(?:\\.|[^\[\]\\\n])+\]:[ ]*(?:\n[ ]*)?(<(?:\\.|[^<>\\\n])*>|\S+)(?:[ ]+|[ ]*\n[ ]*)(["'(])([^\n]*)/;
300
+ /**
301
+ * Reference definitions as CommonMark takes them. One may begin only where a paragraph
302
+ * begins (the block pass says where), or right after another; it's read on the line's
303
+ * content (the text given is past quote and list markers); its destination or title may sit on the
304
+ * next line only if that line continues the paragraph. One that follows paragraph text
305
+ * renders as text, and is compared as text.
306
+ *
307
+ * `unsure` is set where CommonMark implementations differ or this doesn't read the
308
+ * form: a tab between a definition's parts (commonmark.js takes spaces only), or a
309
+ * label or title that spans lines. A polish refuses those pages rather than guess.
310
+ */
311
+ function definitionSpans(text, unmask, blocks) {
312
+ const spans = [];
313
+ const defs = [];
314
+ let unsure = LABEL_TAIL.test(text);
315
+ for (const m of text.matchAll(TITLE_OPEN))
316
+ if (destination(unmask(m[1])) && !closes(m[3], m[2]))
317
+ unsure = true;
318
+ const lines = text.split("\n");
319
+ const at = [];
320
+ for (let k = 0, o = 0; k < lines.length; o += lines[k].length + 1, k++)
321
+ at.push(o);
322
+ // `text` is the content view: each line past its container markers already.
323
+ const body = (k) => lines[k];
324
+ // A line continues the paragraph the previous one is in.
325
+ const continues = (k) => k < lines.length && Boolean(blocks.para[k]) && !blocks.paraStart[k];
326
+ let afterDef = false;
327
+ for (let k = 0; k < lines.length;) {
328
+ if (!(blocks.paraStart[k] || (afterDef && continues(k)))) {
329
+ afterDef = false;
330
+ k++;
331
+ continue;
332
+ }
333
+ // A paragraph's continuation lines lose their indentation before definitions are
334
+ // read: ` [a]: x` right after another definition is one (CommonMark §4.8).
335
+ let view = blocks.paraStart[k] ? body(k) : body(k).replace(/^[ \t]+/, "");
336
+ if (/^ {0,3}\[(?!\^)[^\]\n]*\]:/.test(view) && view.includes("\t"))
337
+ unsure = true;
338
+ if (continues(k + 1))
339
+ view += `\n${body(k + 1)}`;
340
+ if (continues(k + 1) && continues(k + 2))
341
+ view += `\n${body(k + 2)}`;
342
+ const opened = TITLE_AT.exec(view);
343
+ if (opened && destination(unmask(opened[1])) && !closes(opened[3], opened[2]))
344
+ unsure = true;
345
+ const m = DEF_AT.exec(view);
346
+ const name = m ? unmask(m[1]) : "";
347
+ // A label holds something other than spaces, and at most 999 characters.
348
+ if (!m || !/\S/.test(name) || name.length > 999 || !destination(unmask(m[2]))) {
349
+ afterDef = false;
350
+ k++;
351
+ continue;
352
+ }
353
+ const used = m[0].split("\n").length;
354
+ // A setext underline after a paragraph of only definitions isn't one in CommonMark:
355
+ // the definitions come out first, and `-` or `===` starts a new paragraph. The block
356
+ // pass doesn't read definitions, so such a page isn't polished.
357
+ if (k + used < lines.length && !blocks.para[k + used] && /^ {0,3}(?:=+|-+)[ \t]*$/.test(lines[k + used]))
358
+ unsure = true;
359
+ spans.push([at[k], at[k + used - 1] + lines[k + used - 1].length]);
360
+ defs.push({ label: m[1], dest: m[2], title: m[3] ?? "" });
361
+ afterDef = true;
362
+ k += used;
363
+ }
364
+ return { spans, defs, unsure };
365
+ }
366
+ // Code in reading order: a code block's mark, or an inline code span's.
367
+ const CODE_IN_ORDER = /\u0002|\u0004([\u{F0000}-\u{FFFFD}])/gu;
368
+ // Code blocks as CommonMark finds them, fenced and indented, in containers or not.
369
+ const splitFences = codeBlocks;
370
+ /**
371
+ * A parser for the `(destination "title")` after a `]`, as CommonMark parses it (null
372
+ * when it doesn't). `tab` says a tab sat around the destination or title (commonmark.js
373
+ * takes spaces only). Linear over a text: a title that ran to its paragraph's end
374
+ * without closing isn't scanned again from a later `](` (`[a](b (t` repeated took 1.8 s).
375
+ * A `<destination` scan stops at the next `<`, so it's linear already.
376
+ */
377
+ function linkTails(text) {
378
+ const titleFail = {}; // no closer before this paragraph end
379
+ return (i) => linkTail(text, i, titleFail);
380
+ }
381
+ function linkTail(text, i, titleFail) {
382
+ let tab = false;
383
+ const space = (j) => {
384
+ const from = j;
385
+ while (j < text.length && /[ \t]/.test(text[j]))
386
+ j++;
387
+ if (text[j] === "\n")
388
+ j++;
389
+ while (j < text.length && /[ \t]/.test(text[j]))
390
+ j++;
391
+ if (text.slice(from, j).includes("\t"))
392
+ tab = true;
393
+ return j;
394
+ };
395
+ let j = space(i + 2);
396
+ let dest;
397
+ if (text[j] === "<") {
398
+ let k = j + 1;
399
+ for (; k < text.length && text[k] !== ">" && text[k] !== "\n" && text[k] !== "<"; k++)
400
+ if (text[k] === "\\")
401
+ k++;
402
+ if (text[k] !== ">")
403
+ return null;
404
+ dest = text.slice(j + 1, k);
405
+ j = k + 1;
406
+ }
407
+ else {
408
+ const from = j;
409
+ let depth = 0;
410
+ for (; j < text.length; j++) {
411
+ const c = text[j];
412
+ if (c === "\\")
413
+ j++;
414
+ else if (/\s/.test(c))
415
+ break;
416
+ else if (c === "(") {
417
+ if (++depth > 32)
418
+ return null; // cmark's own limit; deeper isn't a link
419
+ }
420
+ else if (c === ")") {
421
+ if (depth === 0)
422
+ break;
423
+ depth--;
424
+ }
425
+ }
426
+ if (depth !== 0)
427
+ return null;
428
+ dest = text.slice(from, j);
429
+ }
430
+ const afterDest = j;
431
+ j = space(j);
432
+ const opener = text[j];
433
+ let title = "";
434
+ if (j > afterDest && (opener === '"' || opener === "'" || opener === "(")) {
435
+ const close = opener === "(" ? ")" : opener;
436
+ if (j < (titleFail[close] ?? -1))
437
+ return null;
438
+ let k = j + 1;
439
+ // A title ends with its paragraph: it can't hold a blank line.
440
+ for (; k < text.length && text[k] !== close; k++) {
441
+ if (text[k] === "\\")
442
+ k++;
443
+ else if (text[k] === "\n" && /^[ \t]*(?:\n|$)/.test(text.slice(k + 1, k + 80))) {
444
+ titleFail[close] = k;
445
+ return null;
446
+ }
447
+ }
448
+ if (k >= text.length) {
449
+ titleFail[close] = k;
450
+ return null;
451
+ }
452
+ title = text.slice(j + 1, k);
453
+ j = space(k + 1);
454
+ }
455
+ return text[j] === ")" ? { end: j + 1, dest, title, tab } : null;
456
+ }
457
+ function inlineLinks(text) {
458
+ const out = [];
459
+ // A `](` is a link only after a `[` it closes (`](x)` alone is text).
460
+ const opened = bracketMatches(text);
461
+ const tail = linkTails(text);
462
+ let literalEnd = -1; // the unparsed literal already covers up to here
463
+ for (let i = text.indexOf("]("); i >= 0; i = text.indexOf("](", i + 1)) {
464
+ if (!opened.has(i))
465
+ continue;
466
+ const link = tail(i);
467
+ if (link) {
468
+ const { tab, ...rest } = link;
469
+ out.push({ start: i, ...rest, ...(tab ? { tab: true } : {}) });
470
+ i = link.end - 1; // a `](` inside a destination doesn't start another link
471
+ continue;
472
+ }
473
+ // Fail closed: what this scanner can't read is compared as written, to the line's
474
+ // end, so a form it misses can't hide a changed target. Scanning goes on after it:
475
+ // a later `](` may close an outer `[` and be the real link (CommonMark drops the
476
+ // failed opener; found by differential fuzzing).
477
+ if (i < literalEnd)
478
+ continue; // inside the literal already recorded
479
+ const eol = text.indexOf("\n", i);
480
+ const end = eol < 0 ? text.length : eol;
481
+ out.push({ start: i, end, dest: `unparsed ${text.slice(i, end)}`, title: "" });
482
+ literalEnd = end;
483
+ }
484
+ return out;
485
+ }
486
+ /** `text` with each inline link's `](…)` replaced, keeping the link text. */
487
+ function withoutDestinations(text, links, replacement) {
488
+ let out = "";
489
+ let at = 0;
490
+ for (const l of links) {
491
+ if (l.start < at)
492
+ continue;
493
+ out += text.slice(at, l.start) + replacement;
494
+ at = l.end;
495
+ }
496
+ return out + text.slice(at);
497
+ }
498
+ // An entity renders as its character: `&#110;ot` is "not" to a reader, so decode it first.
499
+ // `&minus;` and `&plus;` too: they are signs, and a sign is part of a number.
500
+ // Not after an odd run of backslashes: `\&#53;` is the text `&#53;` (found by fuzzing).
501
+ const ENTITY = /(?<=(?:^|[^\\])(?:\\\\)*)&(#\d{1,7}|#x[0-9a-f]{1,6}|amp|lt|gt|quot|apos|nbsp|minus|plus);/gi;
502
+ const NAMED = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " ", minus: "\u2212", plus: "+" };
503
+ function decodeEntity(_whole, e) {
504
+ if (e[0] !== "#")
505
+ return NAMED[e.toLowerCase()];
506
+ const n = e[1] === "x" || e[1] === "X" ? Number.parseInt(e.slice(2), 16) : Number(e.slice(1));
507
+ return n > 0 && n <= 0x10ffff ? String.fromCodePoint(n) : " ";
508
+ }
509
+ // A reference label as CommonMark matches it: spacing collapsed and Unicode case folded,
510
+ // so `[STRASSE]` uses `[straße]: …` (folded as commonmark.js does, lower then upper).
511
+ const label = (s) => s.trim().replace(/\s+/g, " ").toLowerCase().toUpperCase();
512
+ // Only the angle form loses its brackets: a bare `safe.md>` keeps its `>`.
513
+ const dest = (s) => (s.startsWith("<") && s.endsWith(">") ? s.slice(1, -1) : s);
514
+ /** Every link a reader can follow: inline targets, and reference uses resolved to theirs. */
515
+ /** The text a link shows: case, spacing and emphasis aside. */
516
+ // Emphasis markers only: an underscore inside a word (`prod_api`) is text.
517
+ // As written, spacing aside: case counts (`[US]` and `[us]` are different links), and
518
+ // so does every `*` (`delete *.log` isn't `delete .log`).
519
+ const shownText = (t) => t.replace(/\s+/g, " ").trim();
520
+ /**
521
+ * For every `]`, where its matching `[` is: one forward pass with a stack, honouring
522
+ * backslash escapes, and reset at a blank line (a label can't cross paragraphs). Linear,
523
+ * where looking back from each `](` was linear per link.
524
+ */
525
+ function bracketMatches(text) {
526
+ const match = new Map();
527
+ const open = [];
528
+ for (let k = 0; k < text.length; k++) {
529
+ const c = text[k];
530
+ if (c === "\\")
531
+ k++;
532
+ else if (c === "[")
533
+ open.push(k);
534
+ else if (c === "]") {
535
+ const start = open.pop();
536
+ if (start !== undefined)
537
+ match.set(k, start);
538
+ }
539
+ else if (c === "\n" && text[k + 1] === "\n")
540
+ open.length = 0;
541
+ }
542
+ return match;
543
+ }
544
+ /** The visible text of the link whose closing `]` is at `i`, or "" without a match. */
545
+ const labelAt = (text, match, i) => {
546
+ const start = match.get(i);
547
+ return start === undefined ? "" : text.slice(start + 1, i);
548
+ };
549
+ /**
550
+ * Every link a reader can follow: inline targets, and reference uses resolved to theirs.
551
+ * `pairs` keeps each target with the text that shows it, so two links can't trade
552
+ * destinations without the change showing.
553
+ */
554
+ function linkTargets(text, parsed, unmask) {
555
+ // A title shows on hover, so it's part of what a link says.
556
+ const titled = (t) => (t.trim() ? ` "${t.trim().replace(/\s+/g, " ")}"` : "");
557
+ const defs = new Map();
558
+ const defTitles = new Map();
559
+ // Only definitions CommonMark takes as such (definitionSpans), each read on its own.
560
+ // Code spans count as written: a definition is read before inline code, and a label,
561
+ // a destination or a title can hold backticks (`[`x`]: https://…`).
562
+ for (const d of parsed)
563
+ if (!defs.has(label(unmask(d.label)))) {
564
+ defs.set(label(unmask(d.label)), dest(unmask(d.dest)));
565
+ defTitles.set(label(unmask(d.label)), titled(unmask(d.title.replace(/^["'(]|["')]$/g, ""))));
566
+ }
567
+ const inline = inlineLinks(text);
568
+ const targets = inline.map((l) => dest(unmask(l.dest)));
569
+ const textMatch = bracketMatches(text);
570
+ // An image (`![…](…)`) loads by itself where a link waits for a click: it's marked.
571
+ // Unless the `!` is escaped (`\![a](x)` is a link).
572
+ const bang = (t, open) => {
573
+ if (open === undefined || t[open - 1] !== "!")
574
+ return "";
575
+ let k = open - 2;
576
+ while (k >= 0 && t[k] === "\\")
577
+ k--;
578
+ return (open - 2 - k) % 2 ? "" : "!";
579
+ };
580
+ const pairs = inline.map((l) => `${bang(text, textMatch.get(l.start))}${shownText(unmask(labelAt(text, textMatch, l.start)))} → ${dest(unmask(l.dest))}${titled(unmask(l.title))}`);
581
+ // Reference uses, once inline links and definitions are out of the way. An unused
582
+ // definition shows nothing, and a use with no definition is plain text.
583
+ // (Definitions were blanked out of `text` already.)
584
+ // Only parsed links are cut: an unparsed `](` stays a literal fact above, and the
585
+ // reference uses after it are still read (`[r](n` then `[prod]`; found by fuzzing).
586
+ let rest = withoutDestinations(text, inline.filter((l) => !l.dest.startsWith("unparsed ")), "]\u0000");
587
+ // Full and collapsed references (`[text][label]`, `[label][]`), found like inline
588
+ // links: back from each `][` to the matching `[`, at any depth of nesting.
589
+ const uses = [];
590
+ const restMatch = bracketMatches(rest);
591
+ for (let i = rest.indexOf("]["); i >= 0; i = rest.indexOf("][", i + 1)) {
592
+ let k = i + 2;
593
+ for (; k < rest.length && rest[k] !== "]" && rest[k] !== "[" && !(rest[k] === "\n" && rest[k + 1] === "\n"); k++)
594
+ if (rest[k] === "\\")
595
+ k++;
596
+ if (rest[k] !== "]")
597
+ continue;
598
+ const shown = labelAt(rest, restMatch, i);
599
+ if (!shown && rest[i - 1] !== "[")
600
+ continue; // no opening bracket: not a reference
601
+ const key = label(unmask(rest.slice(i + 2, k) || shown));
602
+ const target = defs.get(key);
603
+ // Unresolved, it's text, and a shortcut inside it (`[r [a] x][]`) can still be a
604
+ // link (commonmark.js parseCloseBracket); so only a resolved one is taken out.
605
+ if (target === undefined)
606
+ continue;
607
+ targets.push(target);
608
+ pairs.push(`${bang(rest, restMatch.get(i))}${shownText(unmask(shown))} → ${target}${defTitles.get(key)}`);
609
+ uses.push([i - shown.length - 1, k + 1]);
610
+ i = k;
611
+ }
612
+ rest = cut(rest, uses, () => " ");
613
+ for (const m of rest.matchAll(SHORTCUT_REF)) {
614
+ const key = label(unmask(m[1]));
615
+ const target = defs.get(key);
616
+ if (target !== undefined) {
617
+ targets.push(target);
618
+ pairs.push(`${bang(rest, m.index)}${shownText(unmask(m[1]))} → ${target}${defTitles.get(key)}`);
619
+ }
620
+ }
621
+ return { targets, pairs };
622
+ }
623
+ /**
624
+ * A bare URL as GFM links it: without the punctuation after it, or an unbalanced closing
625
+ * paren. GFM's autolink extension: "Trailing punctuation (specifically, ?, !, ., ,, :,
626
+ * *, _, and ~) will not be considered part of the autolink", and cmark-gfm's
627
+ * `autolink_delim` strips them (and `;`). So `…/runbook_` and `…/runbook~` both link to
628
+ * `…/runbook`: only a literal character after the link differs, which is prose.
629
+ */
630
+ function trimUrl(u) {
631
+ // Parentheses counted once (recounting per `)` was quadratic on `https://x/)))…`).
632
+ let open = 0;
633
+ let close = 0;
634
+ for (const c of u)
635
+ if (c === "(")
636
+ open++;
637
+ else if (c === ")")
638
+ close++;
639
+ let end = u.length;
640
+ for (;;) {
641
+ const c = u[end - 1];
642
+ if (c === ")" && close > open) {
643
+ close--;
644
+ end--;
645
+ }
646
+ else if (c !== undefined && "?!.,:*_~'\"".includes(c))
647
+ end--;
648
+ else if (c === ";") {
649
+ // GFM's autolinker reads decoded text: a real entity (`&amp;`, `&#46;`) is its
650
+ // character there, which is then trimmed like any other (`.` goes, `&` stays).
651
+ // cmark-gfm drops an entity-like tail it sees raw (`&hl;`, the GFM spec's example);
652
+ // an entity this can't decode is kept, so the URL here is never shorter than
653
+ // GitHub's (found by differential fuzzing and the review).
654
+ let k = end - 2;
655
+ while (k >= 0 && /[a-zA-Z0-9#]/.test(u[k]))
656
+ k--;
657
+ const name = u.slice(k + 1, end - 1);
658
+ if (!(k >= 0 && u[k] === "&" && name))
659
+ end--;
660
+ else {
661
+ const num = /^#(\d{1,7}|x[0-9a-f]{1,6})$/i.exec(name);
662
+ const code = num ? (num[1][0].toLowerCase() === "x" ? Number.parseInt(num[1].slice(1), 16) : Number(num[1])) : null;
663
+ const ch = code !== null ? (code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : "\ufffd") : Object.hasOwn(NAMED, name) ? NAMED[name] : null;
664
+ if (ch === null)
665
+ return u.slice(0, end);
666
+ u = u.slice(0, k) + ch;
667
+ end = u.length;
668
+ if (ch === "(")
669
+ open++;
670
+ else if (ch === ")")
671
+ close++;
672
+ }
673
+ }
674
+ else
675
+ return u.slice(0, end);
676
+ }
677
+ }
678
+ /** What the guard compares, taken from one version of the page. */
679
+ export function facts(text) {
680
+ const fm = frontmatter(text);
681
+ // Line endings are formatting: compare the body with LF throughout.
682
+ const body = text.slice(fm.length).replace(/\r\n?/g, "\n");
683
+ // Fences first: a comment inside code is code. Then comments, outside code only; one
684
+ // that wraps a fenced block hides it, which a polish may neither do nor be checked
685
+ // around (`commentHidesCode`).
686
+ const split = splitFences(body);
687
+ const fences = split.fences;
688
+ // Everything below reads each line's content, past its quote and list markers, as
689
+ // CommonMark's inline parsing does: a link's text or a code span that wraps inside a
690
+ // quote doesn't take the `> ` with it (found by differential fuzzing).
691
+ const view = split.prose
692
+ .split("\n")
693
+ .map((l, i) => l.slice(split.content[i] ?? 0).replace(/^[ \t]+$/, ""))
694
+ .join("\n");
695
+ // Reference definitions render as nothing: read them for their targets, then blank
696
+ // them out of everything a reader sees (a command moved into an unused definition's
697
+ // title has left the page). They're block structure, read before any inline
698
+ // construct: in `[a]: ```sh` the backticks are the destination, and can't pair with
699
+ // a backtick on the next line into a code span (found by differential fuzzing).
700
+ const raw = (s) => s;
701
+ const defs = definitionSpans(view, raw, split);
702
+ const defined = new Set(defs.defs.map((d) => label(d.label)));
703
+ const tokens = inlineTokens(cut(view, defs.spans, (d) => d.replace(/[^\n]/g, "")));
704
+ const { comments, commentHidesCode } = tokens;
705
+ const unmask = (s) => s.replace(CODE_REF, (_, c) => tokens.raws[c.codePointAt(0) - 0xf0000]);
706
+ const prose = tokens.masked;
707
+ // Inline code still on the page (a line break inside a span renders as a space, so
708
+ // rewrapping one is a polish).
709
+ const inline = [...prose.matchAll(CODE_REF)].map((m) => tokens.codes[m[1].codePointAt(0) - 0xf0000]);
710
+ // All code in reading order, fenced and inline: running `drain` and `delete` in each
711
+ // other's step is a different runbook. (Per section wouldn't do: two sections trading
712
+ // commands looks the same as two sections trading places, headings and all.)
713
+ let fenceAt = 0;
714
+ const codeOf = (m) => (m[0] === FENCE_MARK ? `fence ${fences[fenceAt++]}` : `code ${tokens.codes[m[1].codePointAt(0) - 0xf0000]}`);
715
+ // And the code under each heading, keyed by its text: a command moved from "Steps"
716
+ // to "Rollback" keeps the page's order but not its meaning. Only headings both
717
+ // versions have are compared, so adding or rewording a heading is still a polish.
718
+ // A heading's code includes its subsections', so adding `## Steps` under an existing
719
+ // heading is still a polish.
720
+ const codeBySection = new Map();
721
+ const order = [];
722
+ const open = [];
723
+ const seen = new Map();
724
+ // Quote and list markers off first, and any indentation allowed: `> ## Steps`,
725
+ // `- ## Steps` and a heading on a deep list item's continuation line are headings too
726
+ // (merge gate, PR 3, round 34). A setext heading (`Steps` over `=====` or `-----`) is
727
+ // a heading too. Taking a line for a heading that CommonMark wouldn't only adds a
728
+ // section to compare: stricter, never blinder.
729
+ const atx = prose
730
+ .replace(/^(?:[ \t]*(?:>[ \t]?|(?:[-*+]|\d{1,9}[.)])[ \t]+))+/gm, "")
731
+ .replace(/^([ \t]*\S[^\n]*)\n[ \t]*(=+|-+)[ \t]*$/gm, (whole, text, bar) => /^[ \t]*#/.test(text) ? whole : `${bar[0] === "=" ? "#" : "##"} ${text.trim()}`);
732
+ for (const section of atx.split(/^(?=[ \t]*#{1,6}(?:[ \t]|$))/m)) {
733
+ const heading = /^[ \t]*(#{1,6})[ \t]*(.*)$/m.exec(section);
734
+ if (heading && section.startsWith(heading[0])) {
735
+ const level = heading[1].length;
736
+ while (open.length && open.at(-1).level >= level)
737
+ open.pop();
738
+ // Keyed as written (spacing and a closing `##` aside, code spans restored) and
739
+ // numbered when it repeats: no two headings share a key, so code can't move
740
+ // between them unseen (`prod_api` and `prodapi`, or two `Steps`; merge gate, PR 3,
741
+ // round 35). Restyling a heading stops its section being compared, as rewording
742
+ // one does. Repeats are compared occurrence by occurrence only while both versions
743
+ // have as many, so renaming one of two `Steps` unshares it the same way.
744
+ const text = unmask(heading[2]).replace(/(?:^|[ \t]+)#+[ \t]*$/, "").replace(/\s+/g, " ").trim();
745
+ let key = text;
746
+ if (text) {
747
+ const n = (seen.get(text) ?? 0) + 1;
748
+ seen.set(text, n);
749
+ key = `${text}\u0001${n}`;
750
+ }
751
+ open.push({ level, key });
752
+ }
753
+ const seq = [...section.matchAll(CODE_IN_ORDER)].map(codeOf);
754
+ order.push(...seq);
755
+ for (const { key } of open) {
756
+ if (!key)
757
+ continue;
758
+ const list = codeBySection.get(key) ?? [];
759
+ if (!codeBySection.has(key))
760
+ codeBySection.set(key, list);
761
+ for (const c of seq)
762
+ list.push(c);
763
+ }
764
+ }
765
+ const codeOrder = order.join("\u0001");
766
+ // An ordered-list number where CommonMark opens an item is a container marker, not in
767
+ // the content view; anywhere else (`retry\n3. times`) it's text, and counted.
768
+ const noCode = prose.replace(CODE_REF, " ");
769
+ // Links read code spans as written, so `[`a`](u)` and `[`b`](u)` say different things.
770
+ const { targets, pairs: linkPairs } = linkTargets(prose, defs.defs, unmask);
771
+ const links = [...targets, ...[...noCode.matchAll(AUTOLINK)].map((m) => m[1]), ...[...noCode.matchAll(HTML_LINK)].map((m) => m[1] ?? m[2] ?? m[3])];
772
+ const scanned = inlineLinks(noCode);
773
+ const linkTab = scanned.some((l) => l.tab);
774
+ // Parsed links only: an unparsed `](` is a literal fact above, and its text stays in
775
+ // what's read for HTML and words (`[p](rod]: <prod` then `page.md>` on the next line
776
+ // is a raw tag; found by differential fuzzing).
777
+ const noLinks = withoutDestinations(noCode, scanned.filter((l) => !l.dest.startsWith("unparsed ")), "] ");
778
+ // Bare URLs, emails and www. links, outside autolinks and anchors (counted as links).
779
+ const bare = noLinks.replace(AUTOLINK, " ").replace(HTML_LINK, " ");
780
+ const urls = [...bare.matchAll(URL), ...bare.replace(URL, " ").matchAll(WWW), ...bare.replace(URL, " ").replace(WWW, " ").matchAll(BARE_EMAIL)].map((m) => trimUrl(m[0]));
781
+ // Tags outside code and link destinations (`[a](<x y>)` isn't HTML; autolinks look
782
+ // like tags, and are links).
783
+ const htmlText = noLinks.replace(AUTOLINK, " ");
784
+ // Closed comments were cut out above, so an opener left here is real raw HTML.
785
+ const openers = [...htmlText.matchAll(OPENER)].map((m) => m[0].toLowerCase());
786
+ const special = [...spans(htmlText, "<?", "?>"), ...spans(htmlText, "<![CDATA[", "]]>")].sort((x, y) => x[0] - y[0]);
787
+ const tags = [...special.map(([i, j]) => htmlText.slice(i, j)), ...[...htmlText.matchAll(TAG)].map((m) => m[0])].map((t) => t.replace(/\s+/g, " ").toLowerCase());
788
+ // Characters that look like ASCII or are invisible: compared as a set, so a polish
789
+ // can't spell "n\u043et" with a Cyrillic o, or hide a zero-width space in a word.
790
+ // Entities decoded first: `&#8203;` is a zero-width space too.
791
+ const decoded = body.replace(ENTITY, decodeEntity);
792
+ const unusual = [...new Set(decoded.match(UNUSUAL) ?? [])].sort();
793
+ // Invisible characters are counted, not just noted: one already on the page doesn't
794
+ // license another.
795
+ const invisibles = decoded.match(/[\p{Cf}\p{Cc}\u00ad]/gu)?.filter((c) => !"\t\n\r".includes(c)) ?? [];
796
+ // Named entities beyond the few decoded above (`&minus;` renders as a sign): this check
797
+ // can't read them, so a polish may not add one.
798
+ // Each with what follows it, so moving one onto another word or number is a change.
799
+ const entities = [...body.matchAll(/&([a-z][a-z0-9]{1,31});([^\s<>&]{0,32})/gi)].filter((m) => !(m[1].toLowerCase() in NAMED)).map((m) => m[0]);
800
+ const struck = [...noLinks.matchAll(STRUCK)].map((m) => (m[1] ?? m[3] ?? "").trim().replace(/\s+/g, " "));
801
+ // Numbers and words in running text only: not inside code or URLs, and not list markers.
802
+ // Link text reads as text: `5 [minutes](units.md)` pairs 5 with minutes.
803
+ const hidden = [...spans(noLinks, "<?", "?>"), ...spans(noLinks, "<![CDATA[", "]]>")].sort((x, y) => x[0] - y[0]);
804
+ const unmarked = cut(noLinks, hidden, () => " ")
805
+ // Links first, on the same text the URL facts read: `[https://x/5` isn't a GFM link
806
+ // (a `[` before it), so its `5` stays text (found by differential fuzzing).
807
+ .replace(AUTOLINK, " ")
808
+ .replace(HTML_LINK, " ")
809
+ .replace(URL, " ")
810
+ .replace(WWW, " ")
811
+ // A full reference shows its text; its label shows too when it resolves to nothing
812
+ // (`[run][never]` with no definition is all text in CommonMark).
813
+ .replace(FULL_REF, (whole, text, lab) => (defined.has(label(lab || text)) ? ` ${text} ` : whole))
814
+ // A bracket that isn't a link's is text: `202[4` isn't `2024`, nor `no[t` `not`.
815
+ .replace(/!?\[|\]/g, " ")
816
+ .replace(TAG, " ");
817
+ const words = unmarked.replace(EMPHASIS, "").replace(ENTITY, decodeEntity);
818
+ // The same text with each emphasis marker read as a break. An unclosed `**` is literal
819
+ // in CommonMark (`no**t` isn't `not`), and closed ones read either way, so counts are
820
+ // compared both ways (found by differential fuzzing against commonmark.js).
821
+ const spaced = unmarked.replace(EMPHASIS, " ").replace(ENTITY, decodeEntity);
822
+ // Every run of digits in the text, whatever it's attached to (`on-c3`, `ra&#53;te`):
823
+ // a net under the number tokens, which pair a number with its unit.
824
+ const digitRuns = (t) => [...t.matchAll(/\p{Nd}+/gu)].map((m) => m[0]);
825
+ const digits = digitRuns(words);
826
+ const digitsSpaced = digitRuns(spaced);
827
+ // Compared as `5 minute`, named as the page wrote it (`5 minutes`).
828
+ const shown = new Map();
829
+ const numbers = [...words.matchAll(NUMBER)].map((m) => {
830
+ // As written: an attached unit (`500MB`) is part of the token, and its case counts.
831
+ const n = m[1].replace("\u2212", "-");
832
+ const word = /^[a-z]/i.test(n) ? undefined : m[2];
833
+ const key = `${n}${word ? ` ${unit(word)}` : ""}`;
834
+ if (!shown.has(key))
835
+ shown.set(key, `${n}${word ? ` ${word}` : ""}`);
836
+ return key;
837
+ });
838
+ // Words with digits in them (`TLSv1.2`, `IPv4`, `RHEL8`, `p99`): a version or a size
839
+ // as much as any number is.
840
+ const digitWords = [...words.matchAll(/(?<![\w.-])[a-z][\w\p{Nd}]*\p{Nd}[\w.\p{Nd}]*/giu)].map((m) => m[0].replace(/\.+$/, "").toLowerCase());
841
+ const count = (t) => {
842
+ const lower = t.toLowerCase().replace(/[\u2018\u2019]/g, "'");
843
+ return [
844
+ ...NEGATIONS.map((w) => `${w}×${(lower.match(new RegExp(`(?<![\\w'])${w}(?![\\w'])`, "g")) ?? []).length}`),
845
+ // Every other "n't" (didn't, wasn't, wouldn't, …), counted together.
846
+ `n't×${(lower.match(/\b[a-z]+n't\b/g) ?? []).filter((w) => !NEGATIONS.includes(w)).length}`,
847
+ ];
848
+ };
849
+ const negations = count(words);
850
+ const negationsSpaced = count(spaced);
851
+ // A task list's boxes: ticking one says a step is done.
852
+ // Each with its task's text: tasks may move with their boxes, but a tick may not move
853
+ // to another task (so a task's text stays as written).
854
+ // A task's text runs on over its indented continuation lines, up to a blank line or
855
+ // the next item. Any quote and list markers may come first: `- - [ ] x` is a task in
856
+ // a nested list (merge gate, PR 3, round 37). Each space has one place to match.
857
+ const lines = prose.split("\n");
858
+ const boxes = [];
859
+ for (let k = 0; k < lines.length; k++) {
860
+ // A task is a list item whose first paragraph starts with its box: on the item's
861
+ // marker line, or on the next when the marker stood alone (`-` then ` [ ] x`).
862
+ // The paragraph's own indentation comes off first (`-` then ` [ ] x`: the item takes
863
+ // two columns, CommonMark strips the third; merge gate, PR 10, round 1).
864
+ const m = split.itemPara[k] ? /^[ \t]*\[([ xX])\](?:[ \t]+|$)/.exec(lines[k]) : null;
865
+ if (!m)
866
+ continue;
867
+ let text = lines[k].slice(m[0].length);
868
+ for (let n = k + 1; split.para[n] && !split.paraStart[n]; n++)
869
+ text += `\n${lines[n]}`;
870
+ boxes.push(`${m[1] === " " ? "[ ]" : "[x]"} ${shownText(unmask(text))}`);
871
+ }
872
+ return { frontmatter: fm, fences, comments, tags, openers, commentHidesCode, unsureDefinitions: defs.unsure, linkTab, unusual, invisibles, entities, boxes, linkPairs, codeOrder, codeBySection, headingCounts: seen, digitWords, digits, digitsSpaced, inline, links, urls, struck, numbers, shown, negations, negationsSpaced };
873
+ }
874
+ function multisetDiff(before, after) {
875
+ const count = new Map();
876
+ for (const x of before)
877
+ count.set(x, (count.get(x) ?? 0) + 1);
878
+ const added = [];
879
+ for (const x of after) {
880
+ const n = count.get(x) ?? 0;
881
+ if (n > 0)
882
+ count.set(x, n - 1);
883
+ else
884
+ added.push(x);
885
+ }
886
+ const removed = [...count].flatMap(([x, n]) => Array.from({ length: n }, () => x));
887
+ return { removed, added };
888
+ }
889
+ const short = (s) => (s.length > 60 ? `${s.slice(0, 57)}…` : s);
890
+ /** Larger pages aren't polished: some patterns here cost more than linear time. */
891
+ export const POLISH_MAX_CHARS = 64 * 1024;
892
+ /** Every change the guard sees between two versions, in words. Empty means it passes. */
893
+ export function polishChanges(before, after) {
894
+ if (before.length > POLISH_MAX_CHARS || after.length > POLISH_MAX_CHARS)
895
+ return [`is over ${POLISH_MAX_CHARS / 1024} KB, too large to check as a polish; propose specific changes with \`edits\` instead`];
896
+ // This check marks fences and code spans with U+0000 to U+0004. A page holding one
897
+ // could pass it off as a fence (a block moves one way, the character the other), so
898
+ // such a page isn't polished (found alongside merge gate round 39).
899
+ if (/[\u0000-\u0004]/.test(before) || /[\u0000-\u0004]/.test(after))
900
+ return ["the page contains a control character (U+0000 to U+0004) that this check uses as its own mark; propose specific changes with `edits` instead"];
901
+ const a = facts(before);
902
+ const b = facts(after);
903
+ const out = [];
904
+ // Task boxes in order: which step is ticked matters, not only how many are.
905
+ // Where a tag sits decides what it hides (`<span hidden>…</span>`), and comparing
906
+ // positions would mean rendering the page. A page with raw HTML isn't polished.
907
+ if (a.commentHidesCode || b.commentHidesCode)
908
+ out.push("an HTML comment wraps a code block, hiding it; this check can't compare around that, so propose specific changes with `edits` instead");
909
+ if (a.linkTab || b.linkTab)
910
+ out.push("a link has a tab around its destination or title, which CommonMark implementations read differently (cmark takes it, commonmark.js doesn't); propose specific changes with `edits` instead");
911
+ if (a.unsureDefinitions || b.unsureDefinitions)
912
+ out.push("the page has a reference definition (`[label]: …`) in a form CommonMark implementations read differently, or this check doesn't read (a tab between its parts, a label or title spanning lines, a heading underline right after it); propose specific changes with `edits` instead");
913
+ if (a.tags.length || a.openers.length)
914
+ out.push("the page contains raw HTML, and where its tags sit decides what a reader sees, which this check can't compare; propose specific changes with `edits` instead");
915
+ if (a.frontmatter !== b.frontmatter)
916
+ out.push("changed the frontmatter (polish leaves it byte-for-byte)");
917
+ const fenceDiff = multisetDiff(a.fences, b.fences);
918
+ if (fenceDiff.removed.length || fenceDiff.added.length)
919
+ out.push("changed a code block");
920
+ const inlineDiff = multisetDiff(a.inline, b.inline);
921
+ // Only when the code itself is unchanged: otherwise the messages below say what changed.
922
+ if (!fenceDiff.removed.length && !fenceDiff.added.length && !inlineDiff.removed.length && !inlineDiff.added.length && a.codeOrder !== b.codeOrder)
923
+ out.push("changed the order of the page's code (commands keep their order; move only sections that have none)");
924
+ for (const [heading, code] of a.codeBySection) {
925
+ const now = b.codeBySection.get(heading);
926
+ const base = heading.replace(/\u0001\d+$/, "");
927
+ if (a.headingCounts.get(base) !== b.headingCounts.get(base))
928
+ continue; // a repeat renamed or added
929
+ const name = heading.replace(/\u0001(\d+)/g, (_, n) => (n === "1" ? "" : ` (${n})`));
930
+ if (now && now.join("\u0001") !== code.join("\u0001"))
931
+ out.push(`moved code into or out of the "${short(name)}" section`);
932
+ }
933
+ for (const [what, x, y] of [
934
+ ["HTML comment", a.comments, b.comments],
935
+ ["HTML tag", a.tags, b.tags],
936
+ ["raw HTML opener", a.openers, b.openers],
937
+ ["inline code", a.inline, b.inline],
938
+ // One multiset: `<https://x>` rewritten as a bare `https://x` is the same target.
939
+ ["link", [...a.links, ...a.urls], [...b.links, ...b.urls]],
940
+ ["struck-through text", a.struck, b.struck],
941
+ ["link text", a.linkPairs, b.linkPairs],
942
+ ["task", a.boxes, b.boxes],
943
+ // Entities this check can't read (`&minus;` is a sign): added or removed, either
944
+ // could change what a number says.
945
+ ["entity", a.entities, b.entities],
946
+ ["number", a.numbers, b.numbers],
947
+ ["word with digits", a.digitWords, b.digitWords],
948
+ ]) {
949
+ const d = multisetDiff([...x], [...y]);
950
+ const as = (f, v) => (what === "number" ? (f.shown.get(v) ?? v) : v);
951
+ for (const r of d.removed)
952
+ out.push(`removed ${what} \`${short(as(a, r))}\``);
953
+ for (const n of d.added)
954
+ out.push(`added ${what} \`${short(as(b, n))}\``);
955
+ }
956
+ // A new invisible character, or a letter from a script the page doesn't use (a
957
+ // Cyrillic о in an English word). A new letter of a script already there is just
958
+ // rewording a page written in it. Dropping a stray character is fine.
959
+ const scripts = new Set(["Latin", ...a.unusual.map(scriptOf).filter((x) => Boolean(x))]);
960
+ for (const c of multisetDiff(a.invisibles, b.invisibles).added)
961
+ out.push(`added the character U+${c.codePointAt(0).toString(16).toUpperCase().padStart(4, "0")}, which reads like another or not at all`);
962
+ for (const c of b.unusual) {
963
+ if (a.unusual.includes(c) || /[\p{Cf}\p{Cc}\u00ad]/u.test(c))
964
+ continue; // invisibles: counted above
965
+ const script = /\p{Cf}/u.test(c) ? undefined : scriptOf(c);
966
+ if (!script || !scripts.has(script))
967
+ out.push(`added the character U+${c.codePointAt(0).toString(16).toUpperCase().padStart(4, "0")}, which reads like another or not at all`);
968
+ }
969
+ // Digits the number tokens didn't pair (`on-c3`, `ra&#53;te`), read both ways.
970
+ const tokensSaw = out.some((m) => /(?:number|word with digits) `/.test(m));
971
+ if (!tokensSaw)
972
+ for (const [x, y] of [
973
+ [a.digits, b.digits],
974
+ [a.digitsSpaced, b.digitsSpaced],
975
+ ]) {
976
+ const d = multisetDiff([...x], [...y]);
977
+ if (d.removed.length || d.added.length) {
978
+ out.push(`changed digits in the text${d.removed.length ? `: removed \`${d.removed.join(" ")}\`` : ""}${d.added.length ? `${d.removed.length ? "," : ":"} added \`${d.added.join(" ")}\`` : ""}`);
979
+ break;
980
+ }
981
+ }
982
+ for (const [x, y] of [
983
+ [a.negations, b.negations],
984
+ [a.negationsSpaced, b.negationsSpaced],
985
+ ]) {
986
+ const was = Object.fromEntries(x.map((v) => v.split("×")));
987
+ const now = Object.fromEntries(y.map((v) => v.split("×")));
988
+ const changed = [...NEGATIONS, "n't"].filter((w) => was[w] !== now[w]);
989
+ for (const w of changed)
990
+ out.push(`changed how often "${w}" appears (${was[w]} → ${now[w]})`);
991
+ if (changed.length)
992
+ break;
993
+ }
994
+ return out;
995
+ }
996
+ /** What the guard covered, for the PR body. */
997
+ export function polishSummary(text) {
998
+ const f = facts(text);
999
+ return `${f.fences.length} code block(s), ${f.inline.length} inline code span(s), ${f.links.length} link(s), ${f.urls.length} URL(s) and ${f.numbers.length} number(s) unchanged; frontmatter identical`;
1000
+ }