chaffjs 0.21.0 → 0.22.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 (214) hide show
  1. package/README.md +2 -1
  2. package/dist/cli-text.d.ts.map +1 -1
  3. package/dist/cli-text.js +4 -0
  4. package/dist/cli-text.js.map +1 -1
  5. package/dist/cli.d.ts.map +1 -1
  6. package/dist/cli.js +4 -11
  7. package/dist/cli.js.map +1 -1
  8. package/dist/commands/init-command.d.ts +4 -0
  9. package/dist/commands/init-command.d.ts.map +1 -0
  10. package/dist/commands/init-command.js +15 -0
  11. package/dist/commands/init-command.js.map +1 -0
  12. package/dist/commands/plugin-init.d.ts +20 -0
  13. package/dist/commands/plugin-init.d.ts.map +1 -0
  14. package/dist/commands/plugin-init.js +129 -0
  15. package/dist/commands/plugin-init.js.map +1 -0
  16. package/dist/commands/plugin-test.d.ts +11 -0
  17. package/dist/commands/plugin-test.d.ts.map +1 -0
  18. package/dist/commands/plugin-test.js +81 -0
  19. package/dist/commands/plugin-test.js.map +1 -0
  20. package/dist/custom/problems.d.ts.map +1 -1
  21. package/dist/custom/problems.js +1 -2
  22. package/dist/custom/problems.js.map +1 -1
  23. package/dist/detectors/amount-for-count.d.ts +21 -0
  24. package/dist/detectors/amount-for-count.d.ts.map +1 -0
  25. package/dist/detectors/amount-for-count.js +55 -0
  26. package/dist/detectors/amount-for-count.js.map +1 -0
  27. package/dist/detectors/currency-notation.d.ts +29 -0
  28. package/dist/detectors/currency-notation.d.ts.map +1 -0
  29. package/dist/detectors/currency-notation.js +58 -0
  30. package/dist/detectors/currency-notation.js.map +1 -0
  31. package/dist/detectors/dangling-opener.d.ts +14 -0
  32. package/dist/detectors/dangling-opener.d.ts.map +1 -0
  33. package/dist/detectors/dangling-opener.js +55 -0
  34. package/dist/detectors/dangling-opener.js.map +1 -0
  35. package/dist/detectors/footnote-mismatch.d.ts +11 -0
  36. package/dist/detectors/footnote-mismatch.d.ts.map +1 -0
  37. package/dist/detectors/footnote-mismatch.js +55 -0
  38. package/dist/detectors/footnote-mismatch.js.map +1 -0
  39. package/dist/detectors/form-minority.d.ts +13 -0
  40. package/dist/detectors/form-minority.d.ts.map +1 -0
  41. package/dist/detectors/form-minority.js +19 -0
  42. package/dist/detectors/form-minority.js.map +1 -0
  43. package/dist/detectors/heading-end-mark.d.ts +10 -0
  44. package/dist/detectors/heading-end-mark.d.ts.map +1 -0
  45. package/dist/detectors/heading-end-mark.js +59 -0
  46. package/dist/detectors/heading-end-mark.js.map +1 -0
  47. package/dist/detectors/list-marker.d.ts +7 -0
  48. package/dist/detectors/list-marker.d.ts.map +1 -0
  49. package/dist/detectors/list-marker.js +50 -0
  50. package/dist/detectors/list-marker.js.map +1 -0
  51. package/dist/detectors/markdown-slip.d.ts +19 -0
  52. package/dist/detectors/markdown-slip.d.ts.map +1 -0
  53. package/dist/detectors/markdown-slip.js +122 -0
  54. package/dist/detectors/markdown-slip.js.map +1 -0
  55. package/dist/detectors/numeral-opener.d.ts +10 -0
  56. package/dist/detectors/numeral-opener.d.ts.map +1 -0
  57. package/dist/detectors/numeral-opener.js +65 -0
  58. package/dist/detectors/numeral-opener.js.map +1 -0
  59. package/dist/detectors/ordinal-suffix.d.ts +12 -0
  60. package/dist/detectors/ordinal-suffix.d.ts.map +1 -0
  61. package/dist/detectors/ordinal-suffix.js +33 -0
  62. package/dist/detectors/ordinal-suffix.js.map +1 -0
  63. package/dist/detectors/range-notation.d.ts +15 -0
  64. package/dist/detectors/range-notation.d.ts.map +1 -0
  65. package/dist/detectors/range-notation.js +58 -0
  66. package/dist/detectors/range-notation.js.map +1 -0
  67. package/dist/detectors/registry/amount-for-count.d.ts +3 -0
  68. package/dist/detectors/registry/amount-for-count.d.ts.map +1 -0
  69. package/dist/detectors/registry/amount-for-count.js +3 -0
  70. package/dist/detectors/registry/amount-for-count.js.map +1 -0
  71. package/dist/detectors/registry/currency-notation.d.ts +3 -0
  72. package/dist/detectors/registry/currency-notation.d.ts.map +1 -0
  73. package/dist/detectors/registry/currency-notation.js +3 -0
  74. package/dist/detectors/registry/currency-notation.js.map +1 -0
  75. package/dist/detectors/registry/dangling-opener.d.ts +3 -0
  76. package/dist/detectors/registry/dangling-opener.d.ts.map +1 -0
  77. package/dist/detectors/registry/dangling-opener.js +3 -0
  78. package/dist/detectors/registry/dangling-opener.js.map +1 -0
  79. package/dist/detectors/registry/footnote-mismatch.d.ts +3 -0
  80. package/dist/detectors/registry/footnote-mismatch.d.ts.map +1 -0
  81. package/dist/detectors/registry/footnote-mismatch.js +3 -0
  82. package/dist/detectors/registry/footnote-mismatch.js.map +1 -0
  83. package/dist/detectors/registry/heading-end-punctuation.d.ts +3 -0
  84. package/dist/detectors/registry/heading-end-punctuation.d.ts.map +1 -0
  85. package/dist/detectors/registry/heading-end-punctuation.js +3 -0
  86. package/dist/detectors/registry/heading-end-punctuation.js.map +1 -0
  87. package/dist/detectors/registry/list-marker.d.ts +3 -0
  88. package/dist/detectors/registry/list-marker.d.ts.map +1 -0
  89. package/dist/detectors/registry/list-marker.js +3 -0
  90. package/dist/detectors/registry/list-marker.js.map +1 -0
  91. package/dist/detectors/registry/numeral-opener.d.ts +3 -0
  92. package/dist/detectors/registry/numeral-opener.d.ts.map +1 -0
  93. package/dist/detectors/registry/numeral-opener.js +3 -0
  94. package/dist/detectors/registry/numeral-opener.js.map +1 -0
  95. package/dist/detectors/registry/ordinal-suffix.d.ts +3 -0
  96. package/dist/detectors/registry/ordinal-suffix.d.ts.map +1 -0
  97. package/dist/detectors/registry/ordinal-suffix.js +3 -0
  98. package/dist/detectors/registry/ordinal-suffix.js.map +1 -0
  99. package/dist/detectors/registry/range-notation.d.ts +3 -0
  100. package/dist/detectors/registry/range-notation.d.ts.map +1 -0
  101. package/dist/detectors/registry/range-notation.js +3 -0
  102. package/dist/detectors/registry/range-notation.js.map +1 -0
  103. package/dist/detectors/registry/relative-pronoun-mix.d.ts +3 -0
  104. package/dist/detectors/registry/relative-pronoun-mix.d.ts.map +1 -0
  105. package/dist/detectors/registry/relative-pronoun-mix.js +3 -0
  106. package/dist/detectors/registry/relative-pronoun-mix.js.map +1 -0
  107. package/dist/detectors/registry/share-over-hundred.d.ts +3 -0
  108. package/dist/detectors/registry/share-over-hundred.d.ts.map +1 -0
  109. package/dist/detectors/registry/share-over-hundred.js +3 -0
  110. package/dist/detectors/registry/share-over-hundred.js.map +1 -0
  111. package/dist/detectors/registry/step-reference.d.ts +3 -0
  112. package/dist/detectors/registry/step-reference.d.ts.map +1 -0
  113. package/dist/detectors/registry/step-reference.js +3 -0
  114. package/dist/detectors/registry/step-reference.js.map +1 -0
  115. package/dist/detectors/registry/table-row-overflow.d.ts +3 -0
  116. package/dist/detectors/registry/table-row-overflow.d.ts.map +1 -0
  117. package/dist/detectors/registry/table-row-overflow.js +3 -0
  118. package/dist/detectors/registry/table-row-overflow.js.map +1 -0
  119. package/dist/detectors/registry/toc-heading.d.ts +3 -0
  120. package/dist/detectors/registry/toc-heading.d.ts.map +1 -0
  121. package/dist/detectors/registry/toc-heading.js +3 -0
  122. package/dist/detectors/registry/toc-heading.js.map +1 -0
  123. package/dist/detectors/registry/unclosed-code-fence.d.ts +3 -0
  124. package/dist/detectors/registry/unclosed-code-fence.d.ts.map +1 -0
  125. package/dist/detectors/registry/unclosed-code-fence.js +3 -0
  126. package/dist/detectors/registry/unclosed-code-fence.js.map +1 -0
  127. package/dist/detectors/registry/unrendered-emphasis.d.ts +3 -0
  128. package/dist/detectors/registry/unrendered-emphasis.d.ts.map +1 -0
  129. package/dist/detectors/registry/unrendered-emphasis.js +3 -0
  130. package/dist/detectors/registry/unrendered-emphasis.js.map +1 -0
  131. package/dist/detectors/relative-pronoun-mix.d.ts +19 -0
  132. package/dist/detectors/relative-pronoun-mix.d.ts.map +1 -0
  133. package/dist/detectors/relative-pronoun-mix.js +60 -0
  134. package/dist/detectors/relative-pronoun-mix.js.map +1 -0
  135. package/dist/detectors/share-over-hundred.d.ts +20 -0
  136. package/dist/detectors/share-over-hundred.d.ts.map +1 -0
  137. package/dist/detectors/share-over-hundred.js +55 -0
  138. package/dist/detectors/share-over-hundred.js.map +1 -0
  139. package/dist/detectors/step-reference.d.ts +20 -0
  140. package/dist/detectors/step-reference.d.ts.map +1 -0
  141. package/dist/detectors/step-reference.js +57 -0
  142. package/dist/detectors/step-reference.js.map +1 -0
  143. package/dist/detectors/toc-heading.d.ts +10 -0
  144. package/dist/detectors/toc-heading.d.ts.map +1 -0
  145. package/dist/detectors/toc-heading.js +99 -0
  146. package/dist/detectors/toc-heading.js.map +1 -0
  147. package/dist/list-marks.d.ts +21 -0
  148. package/dist/list-marks.d.ts.map +1 -0
  149. package/dist/list-marks.js +82 -0
  150. package/dist/list-marks.js.map +1 -0
  151. package/dist/rule-fields.d.ts +5 -0
  152. package/dist/rule-fields.d.ts.map +1 -1
  153. package/dist/rule-fields.js +21 -0
  154. package/dist/rule-fields.js.map +1 -1
  155. package/package.json +3 -3
  156. package/rules/cliche.yaml +67 -0
  157. package/rules/currency-notation-consistency.yaml +77 -0
  158. package/rules/dangling-participle.yaml +58 -0
  159. package/rules/footnote-mismatch.yaml +91 -0
  160. package/rules/heading-end-punctuation-mix.yaml +158 -0
  161. package/rules/less-for-fewer.yaml +59 -0
  162. package/rules/list-marker-mix.yaml +111 -0
  163. package/rules/nonstandard-word.yaml +80 -0
  164. package/rules/numeral-sentence-start.yaml +56 -0
  165. package/rules/ordinal-suffix-mismatch.yaml +65 -0
  166. package/rules/range-notation-consistency.yaml +73 -0
  167. package/rules/share-over-hundred.yaml +65 -0
  168. package/rules/step-reference-missing.yaml +85 -0
  169. package/rules/table-row-overflow.yaml +90 -0
  170. package/rules/toc-heading-mismatch.yaml +136 -0
  171. package/rules/unclosed-code-fence.yaml +107 -0
  172. package/rules/uncomparable-graded.yaml +65 -0
  173. package/rules/unrendered-emphasis.yaml +73 -0
  174. package/rules/which-that-consistency.yaml +64 -0
  175. package/skills/chaff/SKILL.md +7 -7
  176. package/src/cli-text.ts +4 -0
  177. package/src/cli.ts +5 -9
  178. package/src/commands/init-command.ts +13 -0
  179. package/src/commands/plugin-init.ts +150 -0
  180. package/src/commands/plugin-test.ts +111 -0
  181. package/src/custom/problems.ts +1 -3
  182. package/src/detectors/amount-for-count.ts +90 -0
  183. package/src/detectors/currency-notation.ts +90 -0
  184. package/src/detectors/dangling-opener.ts +74 -0
  185. package/src/detectors/footnote-mismatch.ts +72 -0
  186. package/src/detectors/form-minority.ts +21 -0
  187. package/src/detectors/heading-end-mark.ts +82 -0
  188. package/src/detectors/list-marker.ts +60 -0
  189. package/src/detectors/markdown-slip.ts +140 -0
  190. package/src/detectors/numeral-opener.ts +72 -0
  191. package/src/detectors/ordinal-suffix.ts +40 -0
  192. package/src/detectors/range-notation.ts +76 -0
  193. package/src/detectors/registry/amount-for-count.ts +4 -0
  194. package/src/detectors/registry/currency-notation.ts +4 -0
  195. package/src/detectors/registry/dangling-opener.ts +4 -0
  196. package/src/detectors/registry/footnote-mismatch.ts +4 -0
  197. package/src/detectors/registry/heading-end-punctuation.ts +4 -0
  198. package/src/detectors/registry/list-marker.ts +4 -0
  199. package/src/detectors/registry/numeral-opener.ts +4 -0
  200. package/src/detectors/registry/ordinal-suffix.ts +4 -0
  201. package/src/detectors/registry/range-notation.ts +4 -0
  202. package/src/detectors/registry/relative-pronoun-mix.ts +4 -0
  203. package/src/detectors/registry/share-over-hundred.ts +4 -0
  204. package/src/detectors/registry/step-reference.ts +4 -0
  205. package/src/detectors/registry/table-row-overflow.ts +4 -0
  206. package/src/detectors/registry/toc-heading.ts +4 -0
  207. package/src/detectors/registry/unclosed-code-fence.ts +4 -0
  208. package/src/detectors/registry/unrendered-emphasis.ts +4 -0
  209. package/src/detectors/relative-pronoun-mix.ts +79 -0
  210. package/src/detectors/share-over-hundred.ts +75 -0
  211. package/src/detectors/step-reference.ts +83 -0
  212. package/src/detectors/toc-heading.ts +125 -0
  213. package/src/list-marks.ts +106 -0
  214. package/src/rule-fields.ts +28 -0
@@ -0,0 +1,82 @@
1
+ import type { Detector, Finding, MarkupHeading, ProseDocument } from "../plugin.ts";
2
+ import { findingAt, writtenHeadings } from "./markup-finding.ts";
3
+ import { pageTitleOf } from "./heading-case.ts";
4
+ import { minorityWithin } from "../orthography.ts";
5
+
6
+ /** A mark a heading may end with or go without: a colon or a full stop, in either width. A question mark belongs to the words. */
7
+ const END_MARK = /[::.。]$/u;
8
+
9
+ /** An ellipsis: the full stops are the words trailing off. */
10
+ const ELLIPSIS = /(?:\.\.\.|…)$/u;
11
+
12
+ /** Emphasis closed at the very end (`## **Note:**`): the mark the reader sees is the one before it. */
13
+ const EMPHASIS_MARKS = new Set(["*", "_"]);
14
+
15
+ const withoutClosingEmphasis = (text: string): string => {
16
+ const kept = [...text];
17
+ while (EMPHASIS_MARKS.has(kept.at(-1) ?? "")) kept.pop();
18
+ return kept.join("");
19
+ };
20
+
21
+ const LETTER = /\p{L}/u;
22
+
23
+ /** Past this share, both ways are in use on purpose, and neither is the odd one. */
24
+ const MINORITY_PERCENT = 25;
25
+
26
+ /** Fewer section headings than this, and the majority is not yet a habit. */
27
+ const MIN_HEADINGS = 4;
28
+
29
+ /** Whether the heading's last word is one that ends in a full stop of its own (etc., Inc., from the lexicon). */
30
+ const endsWithWord = (shown: string, words: readonly string[]): boolean =>
31
+ words.some((word) => shown.endsWith(word) && !LETTER.test(shown.charAt(shown.length - word.length - 1)));
32
+
33
+ /** The mark a heading ends with, "" for none, or undefined when its last full stop belongs to its words. */
34
+ export const endMarkOf = (heading: string, words: readonly string[] = []): string | undefined => {
35
+ const shown = withoutClosingEmphasis(heading.trim()).trimEnd();
36
+ if (ELLIPSIS.test(shown) || endsWithWord(shown, words)) return undefined;
37
+ return END_MARK.exec(shown)?.[0] ?? "";
38
+ };
39
+
40
+ type Judged = { readonly heading: MarkupHeading; readonly mark: string };
41
+
42
+ /** The odd ones among headings of one depth, each with the usual side's first heading and count. */
43
+ const oddAtDepth = (doc: ProseDocument, judged: readonly Judged[]): Finding[] => {
44
+ if (judged.length < MIN_HEADINGS) return [];
45
+ const odd = minorityWithin(judged, (entry) => entry.mark !== "", MINORITY_PERCENT);
46
+ const usual = judged.filter((entry) => !odd.includes(entry));
47
+ const example = usual[0]?.heading.text.trim() ?? "";
48
+ return odd.map((entry) =>
49
+ findingAt(
50
+ doc,
51
+ entry.heading,
52
+ { heading: entry.heading.text.trim(), mark: entry.mark, example, count: usual.length },
53
+ entry.mark === "" ? "bare" : "marked",
54
+ ),
55
+ );
56
+ };
57
+
58
+ /**
59
+ * Headings that end with a colon or a full stop where most headings of the same depth do not, or the other way round. Headings
60
+ * of one depth are one kind of heading; a depth of labels ("Optional:") is its own way. The page title (one top-level heading
61
+ * that opens the document) is a name and is not compared.
62
+ */
63
+ export const headingEndMark: Detector = (doc, options): Finding[] => {
64
+ const words = (options.lexicon ?? []).map((entry) => entry.pattern);
65
+ const headings = writtenHeadings(doc);
66
+ const title = pageTitleOf(headings);
67
+ const judged = headings
68
+ .filter((heading) => heading !== title)
69
+ .flatMap((heading): Judged[] => {
70
+ const mark = endMarkOf(heading.text, words);
71
+ return mark === undefined ? [] : [{ heading, mark }];
72
+ });
73
+ const depths = [...new Set(judged.map((entry) => entry.heading.depth))];
74
+ return depths
75
+ .flatMap((depth) =>
76
+ oddAtDepth(
77
+ doc,
78
+ judged.filter((entry) => entry.heading.depth === depth),
79
+ ),
80
+ )
81
+ .toSorted((left, right) => Number(left.values["offset"]) - Number(right.values["offset"]));
82
+ };
@@ -0,0 +1,60 @@
1
+ import type { Detector, Finding, ProseDocument } from "../plugin.ts";
2
+ import { findingAt, markupOf } from "./markup-finding.ts";
3
+ import { readMarkdown } from "../markdown-read.ts";
4
+ import { readLists, type ListMark } from "../list-marks.ts";
5
+
6
+ /** Fewer lists than this at one depth, and the majority bullet is not yet a habit. */
7
+ const MIN_LISTS = 3;
8
+
9
+ /** Past this share, the document uses both bullets on purpose. */
10
+ const MINORITY_PERCENT = 25;
11
+
12
+ const PERCENT = 100;
13
+
14
+ /** The length of the bullet quoted: the bullet itself. */
15
+ const BULLET_LENGTH = 1;
16
+
17
+ const at = (doc: ProseDocument, start: number, values: Readonly<Record<string, string | number>>, variant: string): Finding =>
18
+ findingAt(doc, { start, end: start + BULLET_LENGTH }, values, variant);
19
+
20
+ /** The bullet most lists use; undefined on a tie. */
21
+ const majorityOf = (lists: readonly ListMark[]): { readonly mark: string; readonly count: number } | undefined => {
22
+ const counts = [...lists.reduce((acc, list) => acc.set(list.mark, (acc.get(list.mark) ?? 0) + 1), new Map<string, number>())].toSorted(
23
+ ([, left], [, right]) => right - left,
24
+ );
25
+ const [first, second] = counts;
26
+ return first === undefined || second?.[1] === first[1] ? undefined : { mark: first[0], count: first[1] };
27
+ };
28
+
29
+ /** Lists whose bullet differs from the one most lists of the same depth use, while those are the few. */
30
+ const minorityAt = (doc: ProseDocument, lists: readonly ListMark[]): Finding[] => {
31
+ const majority = lists.length < MIN_LISTS ? undefined : majorityOf(lists);
32
+ if (majority === undefined) return [];
33
+ const odd = lists.filter((list) => list.mark !== majority.mark);
34
+ if (odd.length * PERCENT > MINORITY_PERCENT * lists.length) return [];
35
+ return odd.map((list) => at(doc, list.start, { mark: list.mark, usual: majority.mark, count: majority.count }, "document"));
36
+ };
37
+
38
+ /** A list written right after another in the same place with another bullet: Markdown starts a new list where the bullet changes. */
39
+ const splitAt = (doc: ProseDocument, list: ListMark): Finding[] =>
40
+ list.previous === undefined || list.previous === list.mark ? [] : [at(doc, list.start, { mark: list.mark, usual: list.previous }, "split")];
41
+
42
+ /**
43
+ * Bullets mixed in one list or one document: a list that changes bullet partway (`-` then `*`, which Markdown splits in two),
44
+ * a `・` line inside a list item (Markdown joins it to the item), and a list whose bullet differs from most lists at its depth.
45
+ */
46
+ export const listMarkerMix: Detector = (doc): Finding[] => {
47
+ if (markupOf(doc) === undefined) return [];
48
+ const { lists, foldedTyped } = readLists(doc.source, readMarkdown(doc.source).root);
49
+ const split = lists.flatMap((list) => splitAt(doc, list));
50
+ const folded = foldedTyped.map((start) => at(doc, start, { mark: "・" }, "folded"));
51
+ const reported = new Set(split.map((finding) => finding.values["offset"]));
52
+ const byDepth = new Map<number, ListMark[]>();
53
+ lists.forEach((list) => {
54
+ const same = byDepth.get(list.depth) ?? [];
55
+ same.push(list);
56
+ byDepth.set(list.depth, same);
57
+ });
58
+ const minority = [...byDepth.values()].flatMap((same) => minorityAt(doc, same)).filter((finding) => !reported.has(finding.values["offset"]));
59
+ return [...split, ...folded, ...minority].toSorted((left, right) => Number(left.values["offset"]) - Number(right.values["offset"]));
60
+ };
@@ -0,0 +1,140 @@
1
+ // Markdown that does not render the way it was written: a table row with more cells than its header (GFM drops the
2
+ // extra ones), a code fence that is never closed (the rest of the document becomes code), and strong-emphasis marks
3
+ // (`**`) that Markdown cannot pair and shows as they are. Pure; reads the source's Markdown tree.
4
+ import type { Detector, Finding, ProseDocument, Span } from "../plugin.ts";
5
+ import { readMarkdown } from "../markdown-read.ts";
6
+ import { spanOf, type MarkdownNode } from "../markdown-node.ts";
7
+ import { findingAt, markupOf } from "./markup-finding.ts";
8
+
9
+ /** Quotations are someone else's text, and their markup is theirs. */
10
+ const QUOTED = "blockquote";
11
+
12
+ /** Nodes of one type outside quotations, in source order. Walks with its own stack: Markdown nests without limit. */
13
+ const nodesOf = (root: MarkdownNode, type: string): MarkdownNode[] => {
14
+ const found: MarkdownNode[] = [];
15
+ const pending: MarkdownNode[] = [root];
16
+ for (let node = pending.pop(); node !== undefined; node = pending.pop()) {
17
+ if (node.type === QUOTED) continue;
18
+ if (node.type === type) found.push(node);
19
+ (node.children ?? []).toReversed().forEach((child) => pending.push(child));
20
+ }
21
+ return found;
22
+ };
23
+
24
+ const markdownRoot = (doc: ProseDocument): MarkdownNode | undefined => (markupOf(doc) === undefined ? undefined : readMarkdown(doc.source).root);
25
+
26
+ /** An HTML comment: a cell holding only one shows the reader nothing, and losing it loses nothing. */
27
+ const COMMENT = /<!--[\s\S]*?-->/gu;
28
+
29
+ /** Whether a cell shows the reader anything: its source without the bars, comments and spaces. */
30
+ const cellShows = (source: string, cell: MarkdownNode): boolean => {
31
+ const span = spanOf(cell);
32
+ return span !== undefined && source.slice(span.start, span.end).replaceAll("|", "").replace(COMMENT, "").trim() !== "";
33
+ };
34
+
35
+ /**
36
+ * A body row with more cells than the header: GFM shows only as many cells as the header has, and drops the rest. Extra cells
37
+ * that are empty or hold only a comment lose nothing and are not reported.
38
+ */
39
+ export const tableRowOverflow: Detector = (doc): Finding[] => {
40
+ const root = markdownRoot(doc);
41
+ if (root === undefined) return [];
42
+ return nodesOf(root, "table").flatMap((table) => {
43
+ const [header, ...body] = table.children ?? [];
44
+ const columns = header?.children?.length ?? 0;
45
+ return body.flatMap((row) => {
46
+ const cells = row.children ?? [];
47
+ const span = spanOf(row);
48
+ const lost = cells.slice(columns).some((cell) => cellShows(doc.source, cell));
49
+ return lost && span !== undefined ? [findingAt(doc, span, { cells: cells.length, columns })] : [];
50
+ });
51
+ });
52
+ };
53
+
54
+ /** A fence opening a code block: three or more backticks or tildes, after up to three spaces. */
55
+ const FENCE_OPEN = /^ {0,3}(`{3,}|~{3,})/u;
56
+
57
+ /** A closing fence may sit at most this many columns further in than the container's text; four in, it is code. */
58
+ const MAX_FENCE_INDENT = 3;
59
+
60
+ const TAB_WIDTH = 4;
61
+
62
+ const indentOf = (line: string): number =>
63
+ Array.from(line.slice(0, line.length - line.trimStart().length)).reduce((width, char) => width + (char === "\t" ? TAB_WIDTH : 1), 0);
64
+
65
+ /** Whether a line closes a fence: not indented past the limit, the same character, at least as many, and nothing after but spaces. */
66
+ const closes = (line: string, fence: string, maxIndent: number): boolean => {
67
+ const trimmed = line.trim();
68
+ const mark = fence.charAt(0);
69
+ return indentOf(line) <= maxIndent && trimmed.length >= fence.length && [...trimmed].every((char) => char === mark);
70
+ };
71
+
72
+ /** The opening fence of a code block that is never closed; undefined for a closed or an indented block. */
73
+ const unclosedFenceOf = (source: string, span: Span): Span | undefined => {
74
+ const lines = source.slice(span.start, span.end).split("\n");
75
+ const first = lines[0] ?? "";
76
+ const fence = FENCE_OPEN.exec(first)?.[1];
77
+ if (fence === undefined) return undefined;
78
+ const column = span.start - (source.lastIndexOf("\n", span.start - 1) + 1);
79
+ const last = lines.length > 1 ? (lines.at(-1) ?? "") : "";
80
+ return closes(last, fence, column + MAX_FENCE_INDENT) ? undefined : { start: span.start, end: span.start + first.trimEnd().length };
81
+ };
82
+
83
+ /**
84
+ * A fenced code block with no closing fence. Markdown runs it to the end of the document (or of the list item it is in), so
85
+ * everything after the opening fence shows as code. Reported at the opening fence.
86
+ */
87
+ export const unclosedCodeFence: Detector = (doc): Finding[] => {
88
+ const root = markdownRoot(doc);
89
+ if (root === undefined) return [];
90
+ return nodesOf(root, "code").flatMap((code) => {
91
+ const span = spanOf(code);
92
+ const open = span === undefined ? undefined : unclosedFenceOf(doc.source, span);
93
+ return open === undefined ? [] : [findingAt(doc, open, { fence: doc.source.slice(open.start, open.end).trim() })];
94
+ });
95
+ };
96
+
97
+ /** `**` or `__` left in text: Markdown would have made it strong emphasis if it could pair it. Longer runs are rules or blanks to fill. */
98
+ const STRONG_RUN = /(?<![*_])(?:\*\*|__)(?![*_])/gu;
99
+
100
+ /** A run with Latin letters or digits on both sides (`2**10`, `snake__case`) is part of the word. Japanese has no spaces, so 「は**金」 is a mark. */
101
+ const WORD_CHAR = /[A-Za-z0-9]/u;
102
+
103
+ const isSpace = (char: string): boolean => char === "" || /\s/u.test(char);
104
+
105
+ /** Python's dunder names (`__init__`) written outside code. */
106
+ const DUNDER = /__\w+__/u;
107
+
108
+ const dunderAround = (text: string, run: Span): boolean => {
109
+ const start = text.lastIndexOf(" ", run.start) + 1;
110
+ const end = text.indexOf(" ", run.end);
111
+ return DUNDER.test(text.slice(start, end === -1 ? text.length : end));
112
+ };
113
+
114
+ /** The strong-emphasis runs in one text node that Markdown left as text. */
115
+ export const strayStrongRuns = (text: string): Span[] =>
116
+ [...text.matchAll(STRONG_RUN)].flatMap((match) => {
117
+ const run = { start: match.index, end: match.index + match[0].length };
118
+ const before = text.charAt(run.start - 1);
119
+ const after = text.charAt(run.end);
120
+ if (before === "\\" || (WORD_CHAR.test(before) && WORD_CHAR.test(after))) return [];
121
+ if (isSpace(before) && isSpace(after)) return [];
122
+ return dunderAround(text, run) ? [] : [run];
123
+ });
124
+
125
+ /**
126
+ * `**` or `__` that Markdown shows as it is: strong emphasis left open (`**bold`), or one CommonMark cannot close because a
127
+ * bracket or a quotation mark touches the mark from inside while a letter touches it from outside (`**「重要」**です`).
128
+ */
129
+ export const unrenderedEmphasis: Detector = (doc): Finding[] => {
130
+ const root = markdownRoot(doc);
131
+ if (root === undefined) return [];
132
+ return nodesOf(root, "text").flatMap((node) => {
133
+ const span = spanOf(node);
134
+ if (span === undefined) return [];
135
+ const written = doc.source.slice(span.start, span.end);
136
+ return strayStrongRuns(written).map((run) =>
137
+ findingAt(doc, { start: span.start + run.start, end: span.start + run.end }, { mark: written.slice(run.start, run.end) }),
138
+ );
139
+ });
140
+ };
@@ -0,0 +1,72 @@
1
+ import type { Detector, Finding, Sentence, Span } from "../plugin.ts";
2
+
3
+ /**
4
+ * 数字で始まる文(20 people came.)。読み手は文の頭を大文字で探すので、数字の頭は文の切れ目を見えにくくする。
5
+ * 数字の後ろが小文字の語のときだけ読む: 「1 Introduction」のような番号の付いた題や「9:00 Opening」のような時刻の行は文ではない。
6
+ * 年(2026 was a good year.)は綴れないので読まない。
7
+ */
8
+
9
+ const OPENING_NUMERAL = /^(?<numeral>\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?%?(?=\s+\p{Ll})/u;
10
+ const SENTENCE_END = /[.!?]["'”’)]*$/u;
11
+ const YEAR = /^[12]\d{3}$/u;
12
+ const HEADING_LINE = /(?:^|\n)#{1,6}\s[^\n]*$/u;
13
+ const CLOSERS = new Set(['"', "'", "”", "’", ")"]);
14
+ const STOP = /[.!?]$/u;
15
+ const WORD_CHAR = /[\p{L}\p{N}.]/u;
16
+ const DIGITS = /^\d+$/u;
17
+ /** 前の文の終わりを探す字数。見出しの行と、終わりの語が入れば足りる。 */
18
+ const LOOKBACK = 300;
19
+ const PLAIN_WORD = /^\p{L}{2,}$/u;
20
+
21
+ /** 文の頭の数字。年、数字の後ろが小文字の語でないもの、言い終えていない行(見出しのような)は無い。 */
22
+ export const openingNumeral = (text: string): string | undefined => {
23
+ const trimmed = text.trim();
24
+ const match = OPENING_NUMERAL.exec(trimmed);
25
+ const numeral = match?.groups?.["numeral"];
26
+ if (match === null || numeral === undefined || YEAR.test(numeral) || !SENTENCE_END.test(trimmed)) return undefined;
27
+ return match[0];
28
+ };
29
+
30
+ /** text の終わりに続く、belongs を満たす字の並びの始まり(UTF-16 の位置)。 */
31
+ const tailStart = (text: string, belongs: (char: string) => boolean): number =>
32
+ text.split("").reduceRight((at, char, index) => (at === index + 1 && belongs(char) ? index : at), text.length);
33
+
34
+ /** 終わりの印(. ! ?)の前の語。点を含めて読む(H.R. は「H.R」)。印で終わっていなければ undefined。 */
35
+ const lastWordBeforeStop = (text: string): string | undefined => {
36
+ const closed = text.slice(
37
+ 0,
38
+ tailStart(text, (char) => CLOSERS.has(char)),
39
+ );
40
+ if (!STOP.test(closed)) return undefined;
41
+ const body = closed.slice(0, -1);
42
+ const start = tailStart(body, (char) => WORD_CHAR.test(char));
43
+ return start === body.length ? undefined : body.slice(start);
44
+ };
45
+
46
+ /**
47
+ * offset の前で、文がほんとうに終わっているか。文書か見出しの直後か、前の文が二字以上の語か数で言い終えているときだけ。
48
+ * 番号に付ける略した語(vol. 28、H.R. 5376)や、句点の無い行(Lane\n\n2 contains、HTML から移したリンクの切れ目)の後ろの数は文の頭ではない。
49
+ */
50
+ export const followsSentenceEnd = (source: string, offset: number, labels: ReadonlySet<string>): boolean => {
51
+ const from = Math.max(0, offset - LOOKBACK);
52
+ const ended = source.slice(from, offset).trimEnd();
53
+ if ((ended === "" && from === 0) || HEADING_LINE.test(ended)) return true;
54
+ const word = lastWordBeforeStop(ended);
55
+ if (word === undefined) return false;
56
+ return DIGITS.test(word) || (PLAIN_WORD.test(word) && !labels.has(word.toLowerCase()));
57
+ };
58
+
59
+ const inside = (spans: readonly Span[], offset: number): boolean => spans.some((span) => span.start <= offset && offset < span.end);
60
+
61
+ const leadingSpace = (sentence: Sentence): number => sentence.text.length - sentence.text.trimStart().length;
62
+
63
+ export const numeralOpener: Detector = (doc): Finding[] => {
64
+ const labels = new Set((doc.lexicons["numeral-reference-label"] ?? []).map((entry) => entry.pattern.toLowerCase()));
65
+ return doc.sentences.flatMap((sentence) => {
66
+ const offset = sentence.span.start + leadingSpace(sentence);
67
+ if (sentence.embeddedLanguage !== undefined || inside(doc.listSpans, offset)) return [];
68
+ const numeral = openingNumeral(sentence.text);
69
+ if (numeral === undefined || !followsSentenceEnd(doc.source, offset, labels)) return [];
70
+ return [{ rule: "", severity: "warning", line: 0, column: 0, quote: sentence.text.trim(), values: { numeral, offset } }];
71
+ });
72
+ };
@@ -0,0 +1,40 @@
1
+ import type { Detector, Finding, Lexicon } from "../plugin.ts";
2
+ import { escapeRegExp } from "../orthography.ts";
3
+ import { quoteAround } from "./quote-around.ts";
4
+
5
+ // 数と序数の字が合わない所(3th、22th、11st)。どの数の終わりにどの字が付くかは語彙表 ordinal-suffix が言う。
6
+
7
+ /** 合わない序数 1 つ。expected はその数に付く字(一つとは限らない)。 */
8
+ export type OrdinalSlip = { readonly offset: number; readonly written: string; readonly number: string; readonly expected: readonly string[] };
9
+
10
+ /** 数の終わりのうち、語彙表の組にある最も長いものに付く字。 */
11
+ const suffixesFor = (digits: string, suffixes: Lexicon): string[] => {
12
+ const ending = suffixes
13
+ .map((entry) => entry.group ?? "")
14
+ .filter((group) => digits.endsWith(group))
15
+ .reduce((longest, group) => (group.length > longest.length ? group : longest), "");
16
+ return suffixes.filter((entry) => (entry.group ?? "") === ending).map((entry) => entry.pattern);
17
+ };
18
+
19
+ /** 文字列の中の、数と字の合わない序数。語や番号の一部(v1th、0x2nd、1-2th)は読まない。 */
20
+ export const ordinalSlips = (text: string, suffixes: Lexicon): OrdinalSlip[] => {
21
+ const written = [...new Set(suffixes.map((entry) => entry.pattern))];
22
+ if (written.length === 0) return [];
23
+ const pattern = new RegExp(`(?<![\\p{L}\\p{N}_.,\\-])(?<n>\\d+(?:,\\d{3})*)(?<suffix>${written.map(escapeRegExp).join("|")})(?![\\p{L}\\p{N}_])`, "gu");
24
+ return [...text.matchAll(pattern)].flatMap((match) => {
25
+ const expected = suffixesFor((match.groups?.["n"] ?? "").replaceAll(",", ""), suffixes);
26
+ return expected.includes(match.groups?.["suffix"] ?? "") ? [] : [{ offset: match.index, written: match[0], number: match.groups?.["n"] ?? "", expected }];
27
+ });
28
+ };
29
+
30
+ export const ordinalSuffix: Detector = (doc): Finding[] => {
31
+ const text = doc.prose ?? doc.source;
32
+ return ordinalSlips(text, doc.lexicons["ordinal-suffix"] ?? []).map((slip) => ({
33
+ rule: "",
34
+ severity: "warning",
35
+ line: 0,
36
+ column: 0,
37
+ quote: quoteAround(text, slip.offset, slip.offset + slip.written.length),
38
+ values: { written: slip.written, expected: `${slip.number}${slip.expected[0] ?? ""}`, offset: slip.offset },
39
+ }));
40
+ };
@@ -0,0 +1,76 @@
1
+ import type { Detector, Finding } from "../plugin.ts";
2
+ import { escapeRegExp } from "../orthography.ts";
3
+ import { formMinority } from "./form-minority.ts";
4
+ import { quoteAround } from "./quote-around.ts";
5
+
6
+ // 一つの文書で、数の範囲を二通りの記号で書いた所(10〜20 と 10-20、10–20 と 10-20)。どちらが正しいかは決めず、少ないほうを指す。
7
+ // 範囲の記号は語彙表 range-notation が言う。
8
+
9
+ /** 数の範囲 1 つ。form は二つの数のあいだの記号。 */
10
+ export type WrittenRange = { readonly offset: number; readonly written: string; readonly form: string };
11
+
12
+ const FULLWIDTH_OFFSET = 0xfee0;
13
+ const NUMBER = "[0-90-9]+(?:[..][0-90-9]+)?";
14
+
15
+ /** 範囲のすぐ後ろ(空白一つまで)の単位や語。番号にも使う記号(ハイフン)は、これが続くときだけ範囲と読む。 */
16
+ const UNIT_AFTER = /^\s?[\p{L}%%]/u;
17
+
18
+ /** 行の頭(見出しや箇条書きの印の後ろ)の「1-2」は節の番号、閉じ括弧のすぐ後ろの「(703) 292-7827」は電話番号。 */
19
+ const LABEL_BEFORE = /(?:^\s*[#>*+|-][\s#>*+|-]*|\)\s?)$/u;
20
+
21
+ /** 番号の前の札。語彙表の札の語(図、Section)か、頭が大文字の英語の語(NSF)。 */
22
+ const isNumberLabel = (lineBefore: string, labels: readonly string[]): boolean => {
23
+ const trimmed = lineBefore.trimEnd();
24
+ if (labels.some((label) => trimmed.endsWith(label))) return true;
25
+ return /^\p{Lu}/u.test(trimmed.split(/\s/u).at(-1) ?? "");
26
+ };
27
+
28
+ const halfWidth = (text: string): string => text.replace(/[0-9.]/gu, (char) => String.fromCharCode(char.charCodeAt(0) - FULLWIDTH_OFFSET));
29
+
30
+ /**
31
+ * 文字列の中の数の範囲。前後にさらに数が続く並び(日付 2026-10-02、電話番号、時刻 10:00、版 1.2-3)は範囲ではない。
32
+ * 終わりが始まりより小さいもの(郵便番号 100-0001、試合の 3-2)も範囲ではない。ambiguous の記号は、後ろに単位や語が続き、
33
+ * 前が番号の札(図 2-1、Section 1-2、NSF 13-542)でないときだけ。
34
+ */
35
+ export const rangesIn = (text: string, marks: readonly string[], ambiguous: readonly string[] = [], labels: readonly string[] = []): WrittenRange[] => {
36
+ if (marks.length === 0) return [];
37
+ const joiner = marks
38
+ .toSorted((left, right) => right.length - left.length)
39
+ .map(escapeRegExp)
40
+ .join("|");
41
+ const pattern = new RegExp(
42
+ `(?<![\\w0-9..,::/\\--–〜~~])(?<from>${NUMBER})\\s?(?<mark>${joiner})\\s?(?<to>${NUMBER})(?![\\w0-9]|[..,::/\\--–〜~~][0-90-9])`,
43
+ "gu",
44
+ );
45
+ return [...text.matchAll(pattern)].flatMap((match) => {
46
+ const from = Number(halfWidth(match.groups?.["from"] ?? ""));
47
+ const to = Number(halfWidth(match.groups?.["to"] ?? ""));
48
+ const mark = match.groups?.["mark"] ?? "";
49
+ const unitFollows = UNIT_AFTER.test(text.slice(match.index + match[0].length));
50
+ const lineBefore = text.slice(text.lastIndexOf("\n", match.index - 1) + 1, match.index);
51
+ return to > from && !LABEL_BEFORE.test(lineBefore) && (!ambiguous.includes(mark) || (unitFollows && !isNumberLabel(lineBefore, labels)))
52
+ ? [{ offset: match.index, written: match[0], form: mark }]
53
+ : [];
54
+ });
55
+ };
56
+
57
+ export const rangeNotation: Detector = (doc, options): Finding[] => {
58
+ const text = doc.prose ?? doc.source;
59
+ const entries = doc.lexicons["range-notation"] ?? [];
60
+ const ranges = rangesIn(
61
+ text,
62
+ entries.map((entry) => entry.pattern),
63
+ entries.filter((entry) => entry.group === "ambiguous").map((entry) => entry.pattern),
64
+ ["figure-label", "numbered-division", "numbered-label"].flatMap((id) => (doc.lexicons[id] ?? []).map((entry) => entry.pattern)),
65
+ );
66
+ const minority = formMinority(ranges, options.limit);
67
+ if (minority === undefined) return [];
68
+ return minority.odd.map((range) => ({
69
+ rule: "",
70
+ severity: "info",
71
+ line: 0,
72
+ column: 0,
73
+ quote: quoteAround(text, range.offset, range.offset + range.written.length),
74
+ values: { written: range.written, mark: range.form, usual: minority.usual.written, count: minority.odd.length, of: ranges.length, offset: range.offset },
75
+ }));
76
+ };
@@ -0,0 +1,4 @@
1
+ import type { Detector } from "../../plugin.ts";
2
+ import { amountForCount } from "../amount-for-count.ts";
3
+
4
+ export const detector: Detector = amountForCount;
@@ -0,0 +1,4 @@
1
+ import type { Detector } from "../../plugin.ts";
2
+ import { currencyNotation } from "../currency-notation.ts";
3
+
4
+ export const detector: Detector = currencyNotation;
@@ -0,0 +1,4 @@
1
+ import type { Detector } from "../../plugin.ts";
2
+ import { danglingOpener } from "../dangling-opener.ts";
3
+
4
+ export const detector: Detector = danglingOpener;
@@ -0,0 +1,4 @@
1
+ import type { Detector } from "../../plugin.ts";
2
+ import { footnoteMismatch } from "../footnote-mismatch.ts";
3
+
4
+ export const detector: Detector = footnoteMismatch;
@@ -0,0 +1,4 @@
1
+ import type { Detector } from "../../plugin.ts";
2
+ import { headingEndMark } from "../heading-end-mark.ts";
3
+
4
+ export const detector: Detector = headingEndMark;
@@ -0,0 +1,4 @@
1
+ import type { Detector } from "../../plugin.ts";
2
+ import { listMarkerMix } from "../list-marker.ts";
3
+
4
+ export const detector: Detector = listMarkerMix;
@@ -0,0 +1,4 @@
1
+ import type { Detector } from "../../plugin.ts";
2
+ import { numeralOpener } from "../numeral-opener.ts";
3
+
4
+ export const detector: Detector = numeralOpener;
@@ -0,0 +1,4 @@
1
+ import type { Detector } from "../../plugin.ts";
2
+ import { ordinalSuffix } from "../ordinal-suffix.ts";
3
+
4
+ export const detector: Detector = ordinalSuffix;
@@ -0,0 +1,4 @@
1
+ import type { Detector } from "../../plugin.ts";
2
+ import { rangeNotation } from "../range-notation.ts";
3
+
4
+ export const detector: Detector = rangeNotation;
@@ -0,0 +1,4 @@
1
+ import type { Detector } from "../../plugin.ts";
2
+ import { relativePronounMix } from "../relative-pronoun-mix.ts";
3
+
4
+ export const detector: Detector = relativePronounMix;
@@ -0,0 +1,4 @@
1
+ import type { Detector } from "../../plugin.ts";
2
+ import { shareOverHundred } from "../share-over-hundred.ts";
3
+
4
+ export const detector: Detector = shareOverHundred;
@@ -0,0 +1,4 @@
1
+ import type { Detector } from "../../plugin.ts";
2
+ import { stepReference } from "../step-reference.ts";
3
+
4
+ export const detector: Detector = stepReference;
@@ -0,0 +1,4 @@
1
+ import type { Detector } from "../../plugin.ts";
2
+ import { tableRowOverflow } from "../markdown-slip.ts";
3
+
4
+ export const detector: Detector = tableRowOverflow;
@@ -0,0 +1,4 @@
1
+ import type { Detector } from "../../plugin.ts";
2
+ import { tocHeading } from "../toc-heading.ts";
3
+
4
+ export const detector: Detector = tocHeading;
@@ -0,0 +1,4 @@
1
+ import type { Detector } from "../../plugin.ts";
2
+ import { unclosedCodeFence } from "../markdown-slip.ts";
3
+
4
+ export const detector: Detector = unclosedCodeFence;
@@ -0,0 +1,4 @@
1
+ import type { Detector } from "../../plugin.ts";
2
+ import { unrenderedEmphasis } from "../markdown-slip.ts";
3
+
4
+ export const detector: Detector = unrenderedEmphasis;