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,79 @@
1
+ import type { Detector, Finding, Lexicon, Token } from "../plugin.ts";
2
+ import { minorityOf } from "./technical-docs.ts";
3
+ import { isWithinAny, quotedIn, QUOTATION_MARKS } from "../quoted-span.ts";
4
+
5
+ /**
6
+ * 限定の関係節を which で書く所と that で書く所が、一つの文書に混ざっている所(the file which contains / the file that contains)。
7
+ * どちらが正しいかは決めず、少ないほうを指す。読むのは「名詞 + which|that + 動詞」の、読点を挟まない形だけ。
8
+ * that の後ろに主語が来る形(the fact that it works)は、関係節か同格の節か語の並びで決まらないので数えない。
9
+ */
10
+
11
+ export type RelativeUse = { readonly word: Token; readonly kind: string };
12
+
13
+ /** 多いほうの書き方が、文書の書き方と言えるだけの数。 */
14
+ const MIN_USUAL = 3;
15
+ const VERBAL = new Set(["VERB", "AUX"]);
16
+
17
+ /** 解析器は三人称の動詞を複数の名詞と読むことがある(the script that checks links の checks)。 */
18
+ const isThirdPersonVerb = (token: Token): boolean => token.pos === "NOUN" && token.features?.["AlsoVerb"] === "Yes" && token.features["Number"] === "Plur";
19
+
20
+ const isTaggedFiniteVerb = (token: Token | undefined): boolean =>
21
+ token !== undefined && VERBAL.has(token.pos) && token.features?.["VerbForm"] !== "Part" && token.features?.["VerbForm"] !== "Ger";
22
+
23
+ /**
24
+ * 動詞と読める語。名詞と読まれた三人称の動詞は、後ろに動詞が無いときだけ(the idea that results matter の results は主語)。
25
+ */
26
+ const isFiniteVerb = (token: Token | undefined, next: Token | undefined): boolean =>
27
+ isTaggedFiniteVerb(token) || (token !== undefined && isThirdPersonVerb(token) && !isTaggedFiniteVerb(next));
28
+
29
+ const isBaseForm = (verb: Token): boolean => verb.pos === "VERB" && verb.lemma?.toLowerCase() === verb.surface.toLowerCase();
30
+
31
+ /**
32
+ * 名詞の後ろの関係節の動詞。原形の動詞は複数の名詞にだけ続く(resources which set)。単数の名詞の後ろの原形は、
33
+ * 関係節ではなく「どの」の which(tells the command which file to use)。
34
+ */
35
+ const opensRelative = (noun: Token | undefined, verb: Token | undefined, next: Token | undefined): boolean =>
36
+ noun?.pos === "NOUN" && verb !== undefined && isFiniteVerb(verb, next) && (!isBaseForm(verb) || noun.features?.["Number"] === "Plur");
37
+
38
+ /** which / that の後ろの、副詞を飛ばした最初の語(the file which usually stores の stores)。 */
39
+ const verbAfter = (tokens: readonly Token[], at: number): number => {
40
+ const found = tokens.findIndex((token, index) => index > at && token.pos !== "ADV");
41
+ return found === -1 ? tokens.length : found;
42
+ };
43
+
44
+ /** 語彙表の組(which / that)で、tokens の中の限定の関係節の頭。 */
45
+ export const relativeUses = (tokens: readonly Token[], kinds: Lexicon): RelativeUse[] =>
46
+ tokens.flatMap((word, at) => {
47
+ const entry = kinds.find((candidate) => candidate.pattern.toLowerCase() === word.surface.toLowerCase());
48
+ const verb = verbAfter(tokens, at);
49
+ if (entry?.group === undefined || !opensRelative(tokens[at - 1], tokens[verb], tokens[verb + 1])) return [];
50
+ return [{ word, kind: entry.group }];
51
+ });
52
+
53
+ /** 二通りの書き方のうち少ないほう。多いほうが MIN_USUAL に満たなければ、文書の書き方と言えないので空。 */
54
+ export const oddRelatives = (uses: readonly RelativeUse[], limit: number): { readonly odd: readonly RelativeUse[]; readonly usual: string } => {
55
+ const kinds = [...new Set(uses.map((use) => use.kind))];
56
+ const [first, second] = kinds.map((kind) => uses.filter((use) => use.kind === kind));
57
+ if (kinds.length !== 2 || first === undefined || second === undefined) return { odd: [], usual: "" };
58
+ const odd = minorityOf(first, second, limit);
59
+ const usual = odd === first ? second : first;
60
+ return usual.length < MIN_USUAL ? { odd: [], usual: "" } : { odd, usual: usual[0]?.word.surface.toLowerCase() ?? "" };
61
+ };
62
+
63
+ export const relativePronounMix: Detector = (doc, options): Finding[] => {
64
+ const kinds = doc.lexicons["restrictive-relative"] ?? [];
65
+ const uses = doc.sentences.flatMap((sentence) => {
66
+ if (sentence.embeddedLanguage !== undefined) return [];
67
+ const quoted = quotedIn(sentence, QUOTATION_MARKS);
68
+ return relativeUses(sentence.tokens ?? [], kinds).filter((use) => !isWithinAny(quoted, use.word.span));
69
+ });
70
+ const { odd, usual } = oddRelatives(uses, options.limit);
71
+ return odd.map((use) => ({
72
+ rule: "",
73
+ severity: "info",
74
+ line: 0,
75
+ column: 0,
76
+ quote: doc.sentences.find((sentence) => sentence.span.start <= use.word.span.start && use.word.span.start < sentence.span.end)?.text.trim() ?? "",
77
+ values: { word: use.word.surface, usual, count: odd.length, limit: options.limit, offset: use.word.span.start },
78
+ }));
79
+ };
@@ -0,0 +1,75 @@
1
+ import type { Detector, Finding, ProseDocument } from "../plugin.ts";
2
+ import { escapeRegExp } from "../orthography.ts";
3
+ import { quoteAround } from "./quote-around.ts";
4
+
5
+ // 集まりの一部の割合なのに 100% を超える百分率(回答者の120%、150% of respondents)。一部は全体を超えない。
6
+ // 集まりの語は語彙表 share-whole、百分率とのあいだの語とその側は share-whole-link、百分率の単位は percent-unit が言う。
7
+
8
+ export type ShareWords = {
9
+ readonly wholes: readonly string[];
10
+ readonly links: readonly { readonly pattern: string; readonly position: "before" | "after" }[];
11
+ readonly units: readonly string[];
12
+ };
13
+
14
+ /** 100% を超える一部の割合 1 つ。written は百分率、whole は何の一部か。 */
15
+ export type ShareOverHundred = { readonly offset: number; readonly end: number; readonly written: string; readonly whole: string };
16
+
17
+ const WHOLE_PERCENT = 100;
18
+ /** 集まりの語が、より長い語の頭でないこと(users_2025、回答者数)。漢字やカタカナが続けば別の語(回答者数は人数で、割合の元ではない)。 */
19
+ const NOT_INSIDE_WORD = "(?![A-Za-z0-9_\\p{Script=Han}\\p{Script=Katakana}])";
20
+ const FULLWIDTH_OFFSET = 0xfee0;
21
+
22
+ const halfWidth = (text: string): string => text.replace(/[0-9.]/gu, (char) => String.fromCharCode(char.charCodeAt(0) - FULLWIDTH_OFFSET));
23
+
24
+ const alternation = (words: readonly string[]): string =>
25
+ words
26
+ .toSorted((left, right) => right.length - left.length)
27
+ .map(escapeRegExp)
28
+ .join("|");
29
+
30
+ /** 集まりの語の前後の英字だけを語の切れ目に見る。日本語は語を続けて書く(アンケートでは回答者の、120%の回答者が)。 */
31
+ const wholeNear = (text: string, start: number, end: number, words: ShareWords): string | undefined => {
32
+ const wholes = alternation(words.wholes);
33
+ return words.links
34
+ .map((link) => {
35
+ const near =
36
+ link.position === "after"
37
+ ? new RegExp(`^\\s*${escapeRegExp(link.pattern)}\\s*(?<whole>${wholes})${NOT_INSIDE_WORD}`, "u").exec(text.slice(end))
38
+ : new RegExp(`(?<![A-Za-z])(?<whole>${wholes})${escapeRegExp(link.pattern)}\\s*$`, "u").exec(text.slice(0, start));
39
+ return near?.groups?.["whole"];
40
+ })
41
+ .find((whole) => whole !== undefined);
42
+ };
43
+
44
+ /** 文字列の中の、集まりの一部の割合なのに 100% を超える百分率。 */
45
+ export const sharesOverHundred = (text: string, words: ShareWords): ShareOverHundred[] => {
46
+ if (words.wholes.length === 0 || words.units.length === 0) return [];
47
+ const percent = new RegExp(`(?<![\\d.,,0-9])(?<value>[0-90-9]+(?:[,,][0-90-9]{3})*(?:[..][0-90-9]+)?)\\s?(?:${alternation(words.units)})`, "gu");
48
+ return [...text.matchAll(percent)].flatMap((match) => {
49
+ const value = Number(halfWidth(match.groups?.["value"] ?? "").replace(/[,,]/gu, ""));
50
+ if (value <= WHOLE_PERCENT) return [];
51
+ const end = match.index + match[0].length;
52
+ const whole = wholeNear(text, match.index, end, words);
53
+ return whole === undefined ? [] : [{ offset: match.index, end, written: match[0], whole }];
54
+ });
55
+ };
56
+
57
+ const positioned = (doc: ProseDocument): ShareWords["links"] =>
58
+ (doc.lexicons["share-whole-link"] ?? []).flatMap((entry) => (entry.position === undefined ? [] : [{ pattern: entry.pattern, position: entry.position }]));
59
+
60
+ export const shareOverHundred: Detector = (doc): Finding[] => {
61
+ const text = doc.prose ?? doc.source;
62
+ const words: ShareWords = {
63
+ wholes: (doc.lexicons["share-whole"] ?? []).map((entry) => entry.pattern),
64
+ links: positioned(doc),
65
+ units: (doc.lexicons["percent-unit"] ?? []).map((entry) => entry.pattern),
66
+ };
67
+ return sharesOverHundred(text, words).map((found) => ({
68
+ rule: "",
69
+ severity: "warning",
70
+ line: 0,
71
+ column: 0,
72
+ quote: quoteAround(text, found.offset, found.end),
73
+ values: { written: found.written, whole: found.whole, offset: found.offset },
74
+ }));
75
+ };
@@ -0,0 +1,83 @@
1
+ import type { Detector, Finding, ProseDocument } from "../plugin.ts";
2
+ import { escapeRegExp } from "../orthography.ts";
3
+ import { quoteAround } from "./quote-around.ts";
4
+
5
+ // 本文が指す手順の番号(「手順5を参照」「see step 5」)が、文書のどの手順にも無い所。
6
+ // 手順は、手順の語(語彙表 step-word)と番号で始まる見出しと、番号付きの箇条書きの項目。手順が一つも無い文書では言わない。
7
+ // 番号のすぐ後ろの語(語彙表 figure-elsewhere の of・in)や前の文書の名前が、ほかの文書の手順だと言えば数えない。
8
+
9
+ /** 無い手順を指す参照 1 つ。label は書いたままの参照、last は文書の手順の最後の番号。 */
10
+ export type MissingStep = { readonly offset: number; readonly label: string; readonly last: number };
11
+
12
+ /**
13
+ * 手順を読むのに要るもの。source は見出しの印(#)を残した文書、prose はコードを覆った本文(位置は source と同じ)。
14
+ * elsewhere は参照がほかの文書の手順を指すか(参照の始まりと終わりの位置で)。
15
+ */
16
+ export type StepText = { readonly source: string; readonly prose: string; readonly elsewhere: (start: number, end: number) => boolean };
17
+
18
+ const FULLWIDTH_OFFSET = 0xfee0;
19
+ const LIST_ITEM = /^[ \t]*(?<number>\d{1,3})[.)][ \t]/u;
20
+ const HEADING = /^#{1,6}[ \t]+/u;
21
+
22
+ const halfWidth = (text: string): string => text.replace(/[0-9]/gu, (char) => String.fromCharCode(char.charCodeAt(0) - FULLWIDTH_OFFSET));
23
+
24
+ type Mention = { readonly offset: number; readonly label: string; readonly number: number; readonly heading: boolean };
25
+
26
+ const mentionPattern = (words: readonly string[]): RegExp =>
27
+ new RegExp(`(?<![A-Za-z])(?:${words.map(escapeRegExp).join("|")})[ \\t\\u3000]?(?<number>[0-90-9]{1,3})(?![0-90-9A-Za-z])`, "gu");
28
+
29
+ const isHeadingAt = (source: string, offset: number): boolean => HEADING.test(source.slice(source.lastIndexOf("\n", offset - 1) + 1, offset));
30
+
31
+ /** 本文の参照(コードを覆った prose から)と、見出しの手順(prose は見出しも覆うので source から)。 */
32
+ const mentionsOf = ({ source, prose }: StepText, words: readonly string[]): Mention[] => {
33
+ const toMention = (match: RegExpExecArray | RegExpMatchArray, heading: boolean): Mention => ({
34
+ offset: match.index ?? 0,
35
+ label: match[0],
36
+ number: Number(halfWidth(match.groups?.["number"] ?? "")),
37
+ heading,
38
+ });
39
+ const headings = [...source.matchAll(mentionPattern(words))].filter((match) => isHeadingAt(source, match.index)).map((match) => toMention(match, true));
40
+ const references = [...prose.matchAll(mentionPattern(words))].filter((match) => !isHeadingAt(source, match.index)).map((match) => toMention(match, false));
41
+ return [...headings, ...references];
42
+ };
43
+
44
+ const listNumbers = (prose: string): number[] =>
45
+ prose.split("\n").flatMap((line) => {
46
+ const number = LIST_ITEM.exec(line)?.groups?.["number"];
47
+ return number === undefined ? [] : [Number(number)];
48
+ });
49
+
50
+ /** 文書のどの手順にも無い番号を指す参照。手順の最後の番号を超えるものだけ(箇条書きが二つあれば、番号はどちらかにある)。 */
51
+ export const missingSteps = (text: StepText, words: readonly string[]): MissingStep[] => {
52
+ if (words.length === 0) return [];
53
+ const mentions = mentionsOf(text, words);
54
+ const steps = [...mentions.filter((mention) => mention.heading).map((mention) => mention.number), ...listNumbers(text.prose)];
55
+ if (steps.length === 0) return [];
56
+ const last = Math.max(...steps);
57
+ return mentions
58
+ .filter((mention) => !mention.heading && mention.number > last && !text.elsewhere(mention.offset, mention.offset + mention.label.length))
59
+ .map((mention) => ({ offset: mention.offset, label: mention.label, last }));
60
+ };
61
+
62
+ /** 番号のすぐ後ろが figure-elsewhere の語か、すぐ前がこの文書でない文書の名前なら、ほかの文書の手順。 */
63
+ const elsewhereOf =
64
+ (doc: ProseDocument, text: string) =>
65
+ (start: number, end: number): boolean => {
66
+ const after = text.slice(end).trimStart();
67
+ const placed = (doc.lexicons["figure-elsewhere"] ?? []).some((entry) => after.startsWith(`${entry.pattern} `) && /^\s/u.test(text.slice(end)));
68
+ const named = doc.namedDocument?.(text, start);
69
+ return placed || (named !== undefined && !named.self);
70
+ };
71
+
72
+ export const stepReference: Detector = (doc): Finding[] => {
73
+ const prose = doc.prose ?? doc.source;
74
+ const words = (doc.lexicons["step-word"] ?? []).map((entry) => entry.pattern);
75
+ return missingSteps({ source: doc.source, prose, elsewhere: elsewhereOf(doc, prose) }, words).map((missing) => ({
76
+ rule: "",
77
+ severity: "warning",
78
+ line: 0,
79
+ column: 0,
80
+ quote: quoteAround(prose, missing.offset, missing.offset + missing.label.length),
81
+ values: { label: missing.label, last: missing.last, offset: missing.offset },
82
+ }));
83
+ };
@@ -0,0 +1,125 @@
1
+ import type { Detector, Finding } from "../plugin.ts";
2
+ import { headingWords } from "../structure-shape/heading-words.ts";
3
+ import { quoteAround } from "./quote-around.ts";
4
+
5
+ // 目次の項目が、文書のどの見出しとも合わない所。目次の見出しは語彙表 toc-heading が言う。
6
+ // 項目と見出しは、空白・記号・大文字小文字を無視して比べ、頭の番号(1.2、2-1、第3章)を外しても比べる。
7
+
8
+ /** 見出しの無い目次の項目 1 つ。entry は項目の言葉(リンクなら字の部分)。 */
9
+ export type StrayTocEntry = { readonly offset: number; readonly entry: string };
10
+
11
+ type Line = { readonly text: string; readonly offset: number };
12
+ type Heading = { readonly title: string; readonly offset: number };
13
+
14
+ const HEADING = /^[ \t]{0,3}#{1,6}[ \t]/u;
15
+ const FENCE = /^[ \t]*(?:```|~~~)/u;
16
+ const LIST_ITEM = /^[ \t]*(?:[-*+]|\d{1,3}[.)])[ \t]/u;
17
+ const LINK_TEXT = /^\[(?<text>[^\]]*)\]\([^)]*\)/u;
18
+ const ATTRIBUTES = /\{#[^}]*\}/gu;
19
+ const DIGIT = /[\d0-9]/u;
20
+ const LEADER_DOTS = new Set([".", "…", "・"]);
21
+ const MIN_LEADER = 2;
22
+ /** 目次の項目のうち見出しに合わないものがこれより多ければ、目次は別の書き方の見出し(本文に無い章立て)と読んで言わない。 */
23
+ const MAX_STRAY_SHARE = 0.5;
24
+
25
+ const linesOf = (source: string): Line[] => [...source.matchAll(/^.*$/gmu)].map((match) => ({ text: match[0], offset: match.index }));
26
+
27
+ /** コードの囲みの外の行。 */
28
+ const outsideFences = (lines: readonly Line[]): Line[] => {
29
+ const fence = { open: false };
30
+ return lines.filter((line) => {
31
+ if (FENCE.test(line.text)) {
32
+ fence.open = !fence.open;
33
+ return false;
34
+ }
35
+ return !fence.open;
36
+ });
37
+ };
38
+
39
+ /** 比べるための形。全角を半角にそろえ(4 と 4)、空白・記号を消し、小文字にする。 */
40
+ const comparable = (text: string): string =>
41
+ text
42
+ .replace(ATTRIBUTES, "")
43
+ .normalize("NFKC")
44
+ .toLowerCase()
45
+ .replace(/[\s\p{P}\p{S}]/gu, "");
46
+
47
+ /** 頭の番号(1.2、2-1、第3章)と飾りを外した、比べるための形。 */
48
+ const withoutNumber = (text: string): string => comparable(headingWords(text));
49
+
50
+ /** text の終わりから、isTail に当たる字を外したもの。 */
51
+ const trimTail = (text: string, isTail: (char: string) => boolean): string => {
52
+ const chars = [...text];
53
+ return chars.slice(0, chars.findLastIndex((char) => !isTail(char)) + 1).join("");
54
+ };
55
+
56
+ /** 見出しの行の題(頭と終わりの # を外す)。見出しでなければ undefined。 */
57
+ const headingTitle = (line: string): string | undefined => {
58
+ if (!HEADING.test(line)) return undefined;
59
+ const withoutMarks = line.trim().replace(/^#+/u, "").trim();
60
+ return trimTail(withoutMarks, (char) => char === "#").trim();
61
+ };
62
+
63
+ /** 終わりのページ番号(「背景 ........ 3」の 3 と点線)。点線の無い数(Step 3)は題のうち。 */
64
+ const withoutPageNumber = (text: string): string => {
65
+ const before = trimTail(text, (char) => DIGIT.test(char)).trimEnd();
66
+ if (before === text.trimEnd()) return text;
67
+ const title = trimTail(before, (char) => LEADER_DOTS.has(char));
68
+ return before.length - title.length >= MIN_LEADER ? title.trim() : text;
69
+ };
70
+
71
+ /** 項目の言葉と、項目の行の中でそれが始まる所。リンクなら字の部分(番号の後ろのリンク「1. [概要](#…)」も)。 */
72
+ const entryOf = (item: string): { readonly text: string; readonly start: number } => {
73
+ const bracket = item.indexOf("[");
74
+ const link = bracket === -1 ? undefined : LINK_TEXT.exec(item.slice(bracket))?.groups?.["text"];
75
+ if (link !== undefined) return { text: withoutPageNumber(link), start: bracket + 1 };
76
+ return { text: withoutPageNumber(item.trim()), start: item.length - item.trimStart().length };
77
+ };
78
+
79
+ const matchesHeading = (entry: string, headings: readonly Heading[]): boolean =>
80
+ headings.some(
81
+ (heading) => comparable(heading.title) === comparable(entry) || (withoutNumber(entry) !== "" && withoutNumber(heading.title) === withoutNumber(entry)),
82
+ );
83
+
84
+ /** 目次の見出しの下(次の見出しまで)の箇条書きの項目。 */
85
+ const entriesUnder = (lines: readonly Line[], tocAt: number): Line[] => {
86
+ const after = lines.filter((line) => line.offset > tocAt);
87
+ const end = after.findIndex((line) => HEADING.test(line.text));
88
+ return (end === -1 ? after : after.slice(0, end)).flatMap((line) => {
89
+ const marker = LIST_ITEM.exec(line.text)?.[0];
90
+ if (marker === undefined) return [];
91
+ const entry = entryOf(line.text.slice(marker.length));
92
+ return [{ text: entry.text, offset: line.offset + marker.length + entry.start }];
93
+ });
94
+ };
95
+
96
+ /** 文書の目次の項目のうち、どの見出しとも合わないもの。 */
97
+ export const strayTocEntries = (source: string, tocWords: readonly string[]): StrayTocEntry[] => {
98
+ const lines = outsideFences(linesOf(source));
99
+ const headings = lines.flatMap((line) => {
100
+ const title = headingTitle(line.text);
101
+ return title === undefined ? [] : [{ title, offset: line.offset }];
102
+ });
103
+ const tocNames = new Set(tocWords.map(comparable));
104
+ return headings
105
+ .filter((heading) => tocNames.has(comparable(heading.title)))
106
+ .flatMap((toc) => {
107
+ const entries = entriesUnder(lines, toc.offset).filter((entry) => entry.text !== "");
108
+ const others = headings.filter((heading) => heading !== toc);
109
+ const stray = entries.filter((entry) => !matchesHeading(entry.text, others));
110
+ return stray.length > entries.length * MAX_STRAY_SHARE ? [] : stray.map((entry) => ({ offset: entry.offset, entry: entry.text }));
111
+ });
112
+ };
113
+
114
+ export const tocHeading: Detector = (doc): Finding[] =>
115
+ strayTocEntries(
116
+ doc.source,
117
+ (doc.lexicons["toc-heading"] ?? []).map((entry) => entry.pattern),
118
+ ).map((stray) => ({
119
+ rule: "",
120
+ severity: "warning",
121
+ line: 0,
122
+ column: 0,
123
+ quote: quoteAround(doc.source, stray.offset, stray.offset + stray.entry.length),
124
+ values: { entry: stray.entry, offset: stray.offset },
125
+ }));
@@ -0,0 +1,106 @@
1
+ // The bullets a document writes its lists with (`-`, `*`, `+`, and `・` typed at a line's start), where each list sits, and
2
+ // the `・` lines Markdown folds into the item above. Pure; reads the source's Markdown tree.
3
+ import { spanOf, type MarkdownNode } from "./markdown-node.ts";
4
+ import type { Span } from "./plugin.ts";
5
+
6
+ /** One bulleted list as the reader meets it: where its first item starts, how deep it sits among lists, and its bullet. */
7
+ export type ListMark = {
8
+ readonly start: number;
9
+ readonly depth: number;
10
+ readonly mark: string;
11
+ /** The list written right before it in the same parent, when there is one: Markdown splits a list where the bullet changes. */
12
+ readonly previous: string | undefined;
13
+ };
14
+
15
+ /** Bullets Markdown reads as a list. */
16
+ const MARKDOWN_BULLETS = new Set(["-", "*", "+"]);
17
+
18
+ /** A bullet a Japanese writer types at a line's start. Markdown does not read it as a list, so the lines run together. */
19
+ const TYPED_BULLET = "・";
20
+
21
+ /** Blocks whose lists are someone else's or are not prose: quotations, code and HTML. */
22
+ const SKIPPED = new Set(["blockquote", "code", "html"]);
23
+
24
+ const bulletOf = (source: string, list: MarkdownNode): { readonly start: number; readonly mark: string } | undefined => {
25
+ const first = list.children?.[0];
26
+ const span = first === undefined ? undefined : spanOf(first);
27
+ const mark = span === undefined ? undefined : source.charAt(span.start);
28
+ return span === undefined || mark === undefined || !MARKDOWN_BULLETS.has(mark) ? undefined : { start: span.start, mark };
29
+ };
30
+
31
+ /** Where each line of a span starts, with the line's text. */
32
+ const linesIn = (source: string, span: Span): { readonly start: number; readonly text: string }[] => {
33
+ const lines = source.slice(span.start, span.end).split("\n");
34
+ const read: { start: number; text: string }[] = [];
35
+ lines.forEach((text) => {
36
+ const previous = read.at(-1);
37
+ read.push({ start: previous === undefined ? span.start : previous.start + previous.text.length + 1, text });
38
+ });
39
+ return read;
40
+ };
41
+
42
+ /** The start of each line after the first that begins with a typed bullet. */
43
+ const typedLineStarts = (source: string, paragraph: Span): number[] =>
44
+ linesIn(source, paragraph).flatMap((line, at) => {
45
+ const indent = line.text.length - line.text.trimStart().length;
46
+ return at > 0 && line.text.trimStart().startsWith(TYPED_BULLET) ? [line.start + indent] : [];
47
+ });
48
+
49
+ /** Lines of a paragraph that start with a typed bullet; two or more in a row are a list the writer typed. */
50
+ const typedListStart = (source: string, paragraph: Span): number | undefined => {
51
+ const lines = linesIn(source, paragraph).filter((line) => line.text.trim() !== "");
52
+ const typed = lines.filter((line) => line.text.trimStart().startsWith(TYPED_BULLET));
53
+ const first = typed[0];
54
+ return typed.length >= 2 && first !== undefined ? first.start + first.text.length - first.text.trimStart().length : undefined;
55
+ };
56
+
57
+ type Frame = { readonly node: MarkdownNode; readonly depth: number; readonly inItem: boolean };
58
+
59
+ export type ListReading = {
60
+ readonly lists: readonly ListMark[];
61
+ /** `・` lines inside a Markdown list item's paragraph: Markdown joins them to the item above. */
62
+ readonly foldedTyped: readonly number[];
63
+ };
64
+
65
+ const listsOf = (source: string, node: MarkdownNode, depth: number): ListMark[] => {
66
+ const children = node.children ?? [];
67
+ return children.flatMap((child, at) => {
68
+ if (child.type !== "list") return [];
69
+ const bullet = bulletOf(source, child);
70
+ const before = children[at - 1];
71
+ const previous = before?.type === "list" ? bulletOf(source, before)?.mark : undefined;
72
+ return bullet === undefined ? [] : [{ ...bullet, depth, previous }];
73
+ });
74
+ };
75
+
76
+ const typedOf = (source: string, node: MarkdownNode, depth: number, inItem: boolean): { lists: ListMark[]; folded: number[] } => {
77
+ const span = spanOf(node);
78
+ if (node.type !== "paragraph" || span === undefined) return { lists: [], folded: [] };
79
+ if (inItem) return { lists: [], folded: typedLineStarts(source, span) };
80
+ const start = typedListStart(source, span);
81
+ return { lists: start === undefined ? [] : [{ start, depth, mark: TYPED_BULLET, previous: undefined }], folded: [] };
82
+ };
83
+
84
+ /**
85
+ * Every bulleted list outside quotations and code, with its depth among lists (a list in a list item is one deeper), in source
86
+ * order. A paragraph with two or more lines that start with `・` counts as a list at the paragraph's depth; the same lines in a
87
+ * list item's paragraph are folded into that item.
88
+ */
89
+ export const readLists = (source: string, root: MarkdownNode): ListReading => {
90
+ const lists: ListMark[] = [];
91
+ const folded: number[] = [];
92
+ const pending: Frame[] = [{ node: root, depth: 0, inItem: false }];
93
+ for (let frame = pending.pop(); frame !== undefined; frame = pending.pop()) {
94
+ const { node, depth, inItem } = frame;
95
+ if (SKIPPED.has(node.type)) continue;
96
+ lists.push(...listsOf(source, node, depth));
97
+ const typed = typedOf(source, node, depth, inItem);
98
+ lists.push(...typed.lists);
99
+ folded.push(...typed.folded);
100
+ const childDepth = node.type === "list" ? depth + 1 : depth;
101
+ const childInItem = node.type === "listItem" || (inItem && node.type !== "list");
102
+ (node.children ?? []).forEach((child) => pending.push({ node: child, depth: childDepth, inItem: childInItem }));
103
+ }
104
+ const byStart = (left: number, right: number): number => left - right;
105
+ return { lists: lists.toSorted((left, right) => byStart(left.start, right.start)), foldedTyped: folded.toSorted(byStart) };
106
+ };
@@ -11,10 +11,14 @@ export type FieldProblem =
11
11
  | { readonly kind: "bad-group"; readonly written: string }
12
12
  | { readonly kind: "bad-use-for"; readonly written: string }
13
13
  | { readonly kind: "bad-summary"; readonly written: string }
14
+ | { readonly kind: "bad-text"; readonly written: string }
14
15
  | { readonly kind: "bad-example"; readonly written: string };
15
16
 
16
17
  export type FieldKind = FieldProblem["kind"];
17
18
 
19
+ /** Every kind, for a caller that reports these among problems of its own. */
20
+ export const FIELD_KINDS: ReadonlySet<string> = new Set<FieldKind>(["bad-depth", "bad-group", "bad-use-for", "bad-summary", "bad-example", "bad-text"]);
21
+
18
22
  const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
19
23
 
20
24
  const isText = (value: unknown): value is string => typeof value === "string" && value.trim() !== "";
@@ -41,6 +45,25 @@ const useForProblems = (useFor: unknown, genres: readonly string[]): FieldProble
41
45
  const summaryProblems = (summary: unknown): FieldProblem[] =>
42
46
  summary === undefined || isTextByLanguage(summary) ? [] : [{ kind: "bad-summary", written: printed(summary) }];
43
47
 
48
+ /** A language as a key of a text by language: ja, en, pt-br. */
49
+ const LANGUAGE_KEY = /^[a-z]{2,3}(?:-[a-z0-9]+)*$/iu;
50
+
51
+ /** The texts a reader sees. Each may be one string or one per language. */
52
+ const TEXT_FIELDS = ["name", "why", "how_to_fix", "message", "summary"] as const;
53
+
54
+ /**
55
+ * A text by language whose key is not a language, or whose value is not a text. In YAML, { en: Write a date, or a
56
+ * number of days } is two entries, the second keyed " or a number of days": without this, the text is cut silently.
57
+ */
58
+ const textProblems = (raw: Readonly<Record<string, unknown>>): FieldProblem[] =>
59
+ TEXT_FIELDS.flatMap((field) => {
60
+ const value = raw[field];
61
+ if (!isRecord(value)) return [];
62
+ return Object.entries(value)
63
+ .filter(([key, text]) => !LANGUAGE_KEY.test(key) || typeof text !== "string")
64
+ .map(([key]) => ({ kind: "bad-text" as const, written: `${field}: ${JSON.stringify(key)}` }));
65
+ });
66
+
44
67
  /** One example: before and after, each a text in every language or by language. */
45
68
  const isExamplePair = (value: unknown): boolean => isRecord(value) && isTextByLanguage(value["before"]) && isTextByLanguage(value["after"]);
46
69
 
@@ -68,6 +91,7 @@ export const fieldProblems = (raw: Readonly<Record<string, unknown>>, genres: re
68
91
  ...useForProblems(raw["use_for"], genres),
69
92
  ...exampleProblems(raw["example"]),
70
93
  ...depthProblems(raw["rewrite"]),
94
+ ...textProblems(raw),
71
95
  ];
72
96
 
73
97
  const TEXT: Texts<Readonly<Record<FieldKind, (written: string) => string>>> = {
@@ -77,6 +101,8 @@ const TEXT: Texts<Readonly<Record<FieldKind, (written: string) => string>>> = {
77
101
  "bad-use-for": (written) =>
78
102
  `use_for: ${written} はジャンルではありません。ジャンルかその頭(business、business/report)を並べます。一覧は npx chaffjs genres。`,
79
103
  "bad-summary": (written) => `summary: ${written} は読めません。一つの文字列か、言語ごとの文字列({ ja: …, en: … })で書きます。`,
104
+ "bad-text": (written) =>
105
+ `${written} は言語ではありません。{ } の中のカンマは項目の区切りになるので、カンマのある文は引用符で囲みます(en: "Write a date, or a number of days.")。`,
80
106
  "bad-example": (written) =>
81
107
  `example: ${written} の before と after が揃っていません。{ before, after } か、言語ごとに { ja: { before, after }, en: { before, after } } と書きます。`,
82
108
  },
@@ -86,6 +112,8 @@ const TEXT: Texts<Readonly<Record<FieldKind, (written: string) => string>>> = {
86
112
  "bad-use-for": (written) =>
87
113
  `use_for: ${written} is not a genre. List genres or their first part (business, business/report); npx chaffjs genres lists them.`,
88
114
  "bad-summary": (written) => `summary: cannot read ${written}. Write one string, or one per language ({ ja: …, en: … }).`,
115
+ "bad-text": (written) =>
116
+ `${written} is not a language. Inside { }, a comma separates entries, so quote a text that has one (en: "Write a date, or a number of days.").`,
89
117
  "bad-example": (written) =>
90
118
  `example: ${written} lacks a before or an after. Write { before, after }, or one per language: { ja: { before, after }, en: { before, after } }.`,
91
119
  },