chaffjs 0.9.0 → 0.11.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 (180) hide show
  1. package/dist/cli-text.d.ts.map +1 -1
  2. package/dist/cli-text.js +2 -0
  3. package/dist/cli-text.js.map +1 -1
  4. package/dist/cli.d.ts.map +1 -1
  5. package/dist/cli.js +5 -1
  6. package/dist/cli.js.map +1 -1
  7. package/dist/detectors/char-shape.d.ts.map +1 -1
  8. package/dist/detectors/char-shape.js +34 -2
  9. package/dist/detectors/char-shape.js.map +1 -1
  10. package/dist/detectors/en-shape.d.ts.map +1 -1
  11. package/dist/detectors/en-shape.js +110 -9
  12. package/dist/detectors/en-shape.js.map +1 -1
  13. package/dist/detectors/gram-place.d.ts +14 -0
  14. package/dist/detectors/gram-place.d.ts.map +1 -0
  15. package/dist/detectors/gram-place.js +39 -0
  16. package/dist/detectors/gram-place.js.map +1 -0
  17. package/dist/detectors/index.d.ts.map +1 -1
  18. package/dist/detectors/index.js +4 -1
  19. package/dist/detectors/index.js.map +1 -1
  20. package/dist/detectors/lexicon-match.d.ts +9 -0
  21. package/dist/detectors/lexicon-match.d.ts.map +1 -0
  22. package/dist/detectors/lexicon-match.js +40 -0
  23. package/dist/detectors/lexicon-match.js.map +1 -0
  24. package/dist/detectors/lexicon.d.ts.map +1 -1
  25. package/dist/detectors/lexicon.js +20 -20
  26. package/dist/detectors/lexicon.js.map +1 -1
  27. package/dist/detectors/phrase-match.d.ts.map +1 -1
  28. package/dist/detectors/phrase-match.js +7 -6
  29. package/dist/detectors/phrase-match.js.map +1 -1
  30. package/dist/detectors/repeated-head.d.ts.map +1 -1
  31. package/dist/detectors/repeated-head.js +15 -5
  32. package/dist/detectors/repeated-head.js.map +1 -1
  33. package/dist/detectors/sentence-ending.d.ts.map +1 -1
  34. package/dist/detectors/sentence-ending.js +7 -3
  35. package/dist/detectors/sentence-ending.js.map +1 -1
  36. package/dist/detectors/signals.d.ts.map +1 -1
  37. package/dist/detectors/signals.js +162 -22
  38. package/dist/detectors/signals.js.map +1 -1
  39. package/dist/detectors/structure-tree.d.ts +5 -0
  40. package/dist/detectors/structure-tree.d.ts.map +1 -1
  41. package/dist/detectors/structure-tree.js +50 -1
  42. package/dist/detectors/structure-tree.js.map +1 -1
  43. package/dist/detectors/team.d.ts.map +1 -1
  44. package/dist/detectors/team.js +7 -2
  45. package/dist/detectors/team.js.map +1 -1
  46. package/dist/detectors/word-list.d.ts +3 -0
  47. package/dist/detectors/word-list.d.ts.map +1 -0
  48. package/dist/detectors/word-list.js +3 -0
  49. package/dist/detectors/word-list.js.map +1 -0
  50. package/dist/document.d.ts.map +1 -1
  51. package/dist/document.js +22 -9
  52. package/dist/document.js.map +1 -1
  53. package/dist/lexicon-tokens.d.ts +4 -0
  54. package/dist/lexicon-tokens.d.ts.map +1 -0
  55. package/dist/lexicon-tokens.js +23 -0
  56. package/dist/lexicon-tokens.js.map +1 -0
  57. package/dist/page-furniture.d.ts +10 -0
  58. package/dist/page-furniture.d.ts.map +1 -0
  59. package/dist/page-furniture.js +35 -0
  60. package/dist/page-furniture.js.map +1 -0
  61. package/dist/plugin.d.ts +15 -1
  62. package/dist/plugin.d.ts.map +1 -1
  63. package/dist/profile/parse.d.ts.map +1 -1
  64. package/dist/profile/parse.js +15 -2
  65. package/dist/profile/parse.js.map +1 -1
  66. package/dist/render/compact.d.ts.map +1 -1
  67. package/dist/render/compact.js +13 -2
  68. package/dist/render/compact.js.map +1 -1
  69. package/dist/rule-load.d.ts.map +1 -1
  70. package/dist/rule-load.js +1 -0
  71. package/dist/rule-load.js.map +1 -1
  72. package/dist/run.d.ts.map +1 -1
  73. package/dist/run.js +22 -8
  74. package/dist/run.js.map +1 -1
  75. package/dist/sentence-shape.d.ts +6 -0
  76. package/dist/sentence-shape.d.ts.map +1 -1
  77. package/dist/sentence-shape.js +15 -0
  78. package/dist/sentence-shape.js.map +1 -1
  79. package/dist/structure/bare-numbers.d.ts +14 -0
  80. package/dist/structure/bare-numbers.d.ts.map +1 -0
  81. package/dist/structure/bare-numbers.js +53 -0
  82. package/dist/structure/bare-numbers.js.map +1 -0
  83. package/dist/structure/build.d.ts +2 -0
  84. package/dist/structure/build.d.ts.map +1 -1
  85. package/dist/structure/build.js +18 -8
  86. package/dist/structure/build.js.map +1 -1
  87. package/dist/structure/date-order.d.ts +12 -0
  88. package/dist/structure/date-order.d.ts.map +1 -0
  89. package/dist/structure/date-order.js +30 -0
  90. package/dist/structure/date-order.js.map +1 -0
  91. package/dist/structure/issues.d.ts.map +1 -1
  92. package/dist/structure/issues.js +11 -2
  93. package/dist/structure/issues.js.map +1 -1
  94. package/dist/structure/of.d.ts.map +1 -1
  95. package/dist/structure/of.js +4 -2
  96. package/dist/structure/of.js.map +1 -1
  97. package/dist/structure/relative-find.d.ts +2 -0
  98. package/dist/structure/relative-find.d.ts.map +1 -1
  99. package/dist/structure/relative-find.js +11 -0
  100. package/dist/structure/relative-find.js.map +1 -1
  101. package/dist/structure/runs.d.ts +20 -0
  102. package/dist/structure/runs.d.ts.map +1 -0
  103. package/dist/structure/runs.js +63 -0
  104. package/dist/structure/runs.js.map +1 -0
  105. package/dist/structure/total.d.ts +15 -0
  106. package/dist/structure/total.d.ts.map +1 -0
  107. package/dist/structure/total.js +143 -0
  108. package/dist/structure/total.js.map +1 -0
  109. package/dist/structure/universal.d.ts +2 -2
  110. package/dist/structure/universal.d.ts.map +1 -1
  111. package/dist/structure/universal.js +13 -4
  112. package/dist/structure/universal.js.map +1 -1
  113. package/dist/structure/unnumbered.d.ts +15 -0
  114. package/dist/structure/unnumbered.d.ts.map +1 -0
  115. package/dist/structure/unnumbered.js +28 -0
  116. package/dist/structure/unnumbered.js.map +1 -0
  117. package/dist/structure/weekday.d.ts +7 -0
  118. package/dist/structure/weekday.d.ts.map +1 -0
  119. package/dist/structure/weekday.js +21 -0
  120. package/dist/structure/weekday.js.map +1 -0
  121. package/dist/version.d.ts +5 -0
  122. package/dist/version.d.ts.map +1 -1
  123. package/dist/version.js +17 -2
  124. package/dist/version.js.map +1 -1
  125. package/package.json +3 -3
  126. package/profiles/statute.yaml +5 -1
  127. package/rules/ai-tell.yaml +1 -0
  128. package/rules/closing-cliche.yaml +1 -0
  129. package/rules/concrete-evidence-density.yaml +2 -0
  130. package/rules/cushion-phrase-density.yaml +1 -0
  131. package/rules/date-order.yaml +26 -0
  132. package/rules/date-weekday-mismatch.yaml +26 -0
  133. package/rules/double-keigo.yaml +1 -0
  134. package/rules/empty-intensifier.yaml +1 -0
  135. package/rules/excessive-hedging.yaml +1 -0
  136. package/rules/hiragana-fukushi.yaml +1 -0
  137. package/rules/internal-jargon.yaml +1 -1
  138. package/rules/max-kanji-continuous.yaml +2 -0
  139. package/rules/ngram-repetition.yaml +2 -0
  140. package/rules/padded-intro.yaml +1 -0
  141. package/rules/repeated-conjunction.yaml +1 -0
  142. package/rules/sasete-itadaku.yaml +1 -0
  143. package/rules/total-mismatch.yaml +26 -0
  144. package/rules/unqualified-superlative.yaml +1 -0
  145. package/src/cli-text.ts +2 -0
  146. package/src/cli.ts +5 -1
  147. package/src/detectors/char-shape.ts +38 -3
  148. package/src/detectors/en-shape.ts +138 -7
  149. package/src/detectors/gram-place.ts +41 -0
  150. package/src/detectors/index.ts +4 -1
  151. package/src/detectors/lexicon-match.ts +44 -0
  152. package/src/detectors/lexicon.ts +25 -22
  153. package/src/detectors/phrase-match.ts +8 -7
  154. package/src/detectors/repeated-head.ts +21 -7
  155. package/src/detectors/sentence-ending.ts +7 -3
  156. package/src/detectors/signals.ts +188 -21
  157. package/src/detectors/structure-tree.ts +67 -1
  158. package/src/detectors/team.ts +7 -2
  159. package/src/detectors/word-list.ts +2 -0
  160. package/src/document.ts +25 -9
  161. package/src/lexicon-tokens.ts +28 -0
  162. package/src/page-furniture.ts +45 -0
  163. package/src/plugin.ts +24 -3
  164. package/src/profile/parse.ts +15 -2
  165. package/src/render/compact.ts +15 -2
  166. package/src/rule-load.ts +1 -0
  167. package/src/run.ts +24 -8
  168. package/src/sentence-shape.ts +16 -0
  169. package/src/structure/bare-numbers.ts +78 -0
  170. package/src/structure/build.ts +27 -9
  171. package/src/structure/date-order.ts +44 -0
  172. package/src/structure/issues.ts +16 -2
  173. package/src/structure/of.ts +4 -2
  174. package/src/structure/relative-find.ts +13 -0
  175. package/src/structure/runs.ts +71 -0
  176. package/src/structure/total.ts +183 -0
  177. package/src/structure/universal.ts +14 -3
  178. package/src/structure/unnumbered.ts +33 -0
  179. package/src/structure/weekday.ts +25 -0
  180. package/src/version.ts +21 -3
@@ -0,0 +1,44 @@
1
+ import type { LexiconEntry, Sentence, Token } from "../plugin.ts";
2
+ import { proseText } from "../measure.ts";
3
+
4
+ /** 活用する品詞。"best" を "good" と同じ語にはしない。 */
5
+ const INFLECTING = new Set(["VERB", "AUX"]);
6
+
7
+ /**
8
+ * 語彙表が原形で書いた語(いただく)だけが、活用した形(いただきます・いただいた)に当たる。
9
+ * 活用した形で書いた語(could・かもしれません)はその形だけ。"could" は "can" と言っていることが違う。
10
+ */
11
+ const sameWord = (written: Token, entry: Token): boolean => {
12
+ const surface = entry.surface.toLowerCase();
13
+ if (written.surface.toLowerCase() === surface) return true;
14
+ const inflects = INFLECTING.has(written.pos) && INFLECTING.has(entry.pos);
15
+ return inflects && entry.lemma?.toLowerCase() === surface && written.lemma?.toLowerCase() === surface;
16
+ };
17
+
18
+ const runsAt = (tokens: readonly Token[], entry: readonly Token[], start: number): boolean =>
19
+ entry.every((word, offset) => {
20
+ const written = tokens[start + offset];
21
+ return written !== undefined && sameWord(written, word);
22
+ });
23
+
24
+ /**
25
+ * 語彙の語が文の中にあるか。品詞が付いていれば語の並びで照らし、活用する語は原形で比べる(「させていただく」が「させていただきました」に当たる)。
26
+ * 語の途中には当たらない(「また」は「またいで」に当たらない)。品詞が無ければ、空白をまとめた文字列で照らす。
27
+ */
28
+ export const entryIn = (sentence: Sentence, entry: LexiconEntry): boolean => {
29
+ const tokens = sentence.tokens;
30
+ const words = entry.tokens;
31
+ if (tokens === undefined || words === undefined || words.length === 0) return proseText(sentence).toLowerCase().includes(entry.pattern.toLowerCase());
32
+ return tokens.some((_token, start) => runsAt(tokens, words, start));
33
+ };
34
+
35
+ const LEADING_MARK = new Set(["PUNCT", "SYM"]);
36
+
37
+ /** 語彙の語で文が始まるか。文頭の記号は飛ばす。品詞が無ければ文字列の前方一致。 */
38
+ export const entryOpens = (sentence: Sentence, entry: LexiconEntry): boolean => {
39
+ const tokens = sentence.tokens;
40
+ const words = entry.tokens;
41
+ if (tokens === undefined || words === undefined || words.length === 0) return proseText(sentence).toLowerCase().startsWith(entry.pattern.toLowerCase());
42
+ const first = tokens.findIndex((token) => !LEADING_MARK.has(token.pos));
43
+ return runsAt(tokens, words, first);
44
+ };
@@ -1,6 +1,7 @@
1
- import { proseText } from "../measure.ts";
1
+ import { joinWords } from "./word-list.ts";
2
2
  import { wordsOf } from "./structure.ts";
3
- import type { Detector, Finding, ProseDocument, Sentence } from "../plugin.ts";
3
+ import type { Detector, Finding, Lexicon, ProseDocument, Sentence } from "../plugin.ts";
4
+ import { entryIn, entryOpens } from "./lexicon-match.ts";
4
5
 
5
6
  const PER = 1000;
6
7
 
@@ -9,11 +10,8 @@ const FLOOR = { word: 200, char: 500 };
9
10
 
10
11
  type Hit = { readonly sentence: Sentence; readonly matched: string };
11
12
 
12
- const hitsFor = (doc: ProseDocument, patterns: readonly string[]): Hit[] =>
13
- doc.sentences.flatMap((sentence) => {
14
- const text = sentence.text.toLowerCase();
15
- return patterns.filter((pattern) => text.includes(pattern.toLowerCase())).map((matched) => ({ sentence, matched }));
16
- });
13
+ const hitsFor = (doc: ProseDocument, lexicon: Lexicon): Hit[] =>
14
+ doc.sentences.flatMap((sentence) => lexicon.filter((entry) => entryIn(sentence, entry)).map((entry) => ({ sentence, matched: entry.pattern })));
17
15
 
18
16
  /**
19
17
  * 単位長あたりの出現率。件数で数えると長い文書ほど当たる(bold-density と同じ)。
@@ -22,8 +20,7 @@ const hitsFor = (doc: ProseDocument, patterns: readonly string[]): Hit[] =>
22
20
  const densityRule =
23
21
  (rule: string): Detector =>
24
22
  (doc, options): Finding[] => {
25
- const patterns = (options.lexicon ?? []).map((entry) => entry.pattern);
26
- const hits = hitsFor(doc, patterns);
23
+ const hits = hitsFor(doc, options.lexicon ?? []);
27
24
  const length = wordsOf(doc);
28
25
  const rate = length === 0 ? 0 : Math.round((hits.length / length) * PER);
29
26
  const first = hits[0];
@@ -48,8 +45,7 @@ export const cushionDensity = densityRule("cushion-phrase-density");
48
45
  const QUALIFIER = /\d|より|に比べ|のうち|among|than|compared|based on|according/iu;
49
46
 
50
47
  export const unqualifiedSuperlative: Detector = (doc, options): Finding[] => {
51
- const patterns = (options.lexicon ?? []).map((entry) => entry.pattern);
52
- const bare = hitsFor(doc, patterns).filter((hit) => !QUALIFIER.test(hit.sentence.text));
48
+ const bare = hitsFor(doc, options.lexicon ?? []).filter((hit) => !QUALIFIER.test(hit.sentence.text));
53
49
  if (bare.length < options.limit) return [];
54
50
  return bare.map((hit) => ({
55
51
  rule: "unqualified-superlative",
@@ -61,10 +57,10 @@ export const unqualifiedSuperlative: Detector = (doc, options): Finding[] => {
61
57
  }));
62
58
  };
63
59
 
64
- const openerOf = (doc: ProseDocument, patterns: readonly string[]): (string | undefined)[] =>
60
+ const openerOf = (doc: ProseDocument, lexicon: Lexicon): (string | undefined)[] =>
65
61
  doc.paragraphs.map((paragraph) => {
66
- const head = paragraph.sentences[0]?.text.trim().toLowerCase() ?? "";
67
- return patterns.find((pattern) => head.startsWith(pattern.toLowerCase()));
62
+ const head = paragraph.sentences[0];
63
+ return head === undefined ? undefined : lexicon.find((entry) => entryOpens(head, entry))?.pattern;
68
64
  });
69
65
 
70
66
  /**
@@ -72,8 +68,7 @@ const openerOf = (doc: ProseDocument, patterns: readonly string[]): (string | un
72
68
  * 前の段落の付け足しに見えて、話がどこへ向かっているのか分からなくなる。
73
69
  */
74
70
  export const repeatedConjunction: Detector = (doc, options): Finding[] => {
75
- const patterns = (options.lexicon ?? []).map((entry) => entry.pattern);
76
- const openers = openerOf(doc, patterns);
71
+ const openers = openerOf(doc, options.lexicon ?? []);
77
72
  const runs = openers.reduce<{ runs: number[][]; current: number[] }>(
78
73
  (acc, opener, index) => {
79
74
  if (opener === undefined) return { runs: [...acc.runs, acc.current], current: [] };
@@ -105,11 +100,10 @@ export const repeatedConjunction: Detector = (doc, options): Finding[] => {
105
100
  * 単独で断じない。§20.2 の複合シグナルの入口で、これだけで「AI が書いた」とは言わない。
106
101
  */
107
102
  export const aiTell: Detector = (doc, options): Finding[] => {
108
- const weights = new Map((options.lexicon ?? []).map((entry) => [entry.pattern.toLowerCase(), entry.weight ?? 1]));
109
- const body = doc.sentences.map(proseText).join(" ").toLowerCase();
110
- const found = [...weights.entries()].filter(([pattern]) => body.includes(pattern));
111
- const score = Math.round(found.reduce((sum, [, weight]) => sum + weight, 0) * 10);
112
- const first = doc.sentences.find((sentence) => found.some(([pattern]) => sentence.text.toLowerCase().includes(pattern)));
103
+ const entries = [...new Map((options.lexicon ?? []).map((entry) => [entry.pattern.toLowerCase(), entry])).values()];
104
+ const found = entries.filter((entry) => doc.sentences.some((sentence) => entryIn(sentence, entry)));
105
+ const score = Math.round(found.reduce((sum, entry) => sum + (entry.weight ?? 1), 0) * 10);
106
+ const first = doc.sentences.find((sentence) => found.some((entry) => entryIn(sentence, entry)));
113
107
  if (first === undefined || score <= options.limit) return [];
114
108
  return [
115
109
  {
@@ -118,7 +112,16 @@ export const aiTell: Detector = (doc, options): Finding[] => {
118
112
  line: 0,
119
113
  column: 0,
120
114
  quote: first.text.trim(),
121
- values: { word: found.map(([pattern]) => pattern).join("、"), count: found.length, density: score, limit: options.limit, offset: first.span.start },
115
+ values: {
116
+ word: joinWords(
117
+ found.map((entry) => entry.pattern.toLowerCase()),
118
+ doc.language,
119
+ ),
120
+ count: found.length,
121
+ density: score,
122
+ limit: options.limit,
123
+ offset: first.span.start,
124
+ },
122
125
  },
123
126
  ];
124
127
  };
@@ -1,4 +1,5 @@
1
- import type { Detector, Finding, ProseDocument, Sentence } from "../plugin.ts";
1
+ import type { Detector, Finding, Lexicon, ProseDocument, Sentence } from "../plugin.ts";
2
+ import { entryIn } from "./lexicon-match.ts";
2
3
 
3
4
  /** 冒頭は先頭 2 段落ぶん。「どの記事にも当てはまる書き出し」は冒頭にあるときだけ問題。 */
4
5
  const OPENING_SENTENCES = 4;
@@ -15,10 +16,10 @@ const inScope = (doc: ProseDocument, where: string | undefined): readonly Senten
15
16
 
16
17
  type Hit = { readonly sentence: Sentence; readonly matched: string };
17
18
 
18
- const hitsIn = (sentences: readonly Sentence[], patterns: readonly string[]): Hit[] =>
19
+ const hitsIn = (sentences: readonly Sentence[], lexicon: Lexicon): Hit[] =>
19
20
  sentences.flatMap((sentence) => {
20
- const matched = patterns.find((pattern) => sentence.text.toLowerCase().includes(pattern.toLowerCase()));
21
- return matched === undefined ? [] : [{ sentence, matched }];
21
+ const matched = lexicon.find((entry) => entryIn(sentence, entry));
22
+ return matched === undefined ? [] : [{ sentence, matched: matched.pattern }];
22
23
  });
23
24
 
24
25
  /**
@@ -26,9 +27,9 @@ const hitsIn = (sentences: readonly Sentence[], patterns: readonly string[]): Hi
26
27
  * これが L2 の全体像で、新しい言語は語彙表を書くだけで動く。
27
28
  */
28
29
  export const phraseMatch: Detector = (doc, options): Finding[] => {
29
- const patterns = (options.lexicon ?? []).map((entry) => entry.pattern);
30
- if (patterns.length === 0) return [];
31
- const hits = hitsIn(inScope(doc, options.where), patterns);
30
+ const lexicon = options.lexicon ?? [];
31
+ if (lexicon.length === 0) return [];
32
+ const hits = hitsIn(inScope(doc, options.where), lexicon);
32
33
  if (hits.length < options.limit) return [];
33
34
  return hits.map((hit) => ({
34
35
  rule: "",
@@ -1,4 +1,4 @@
1
- import type { Detector, Finding, LengthUnit, Sentence } from "../plugin.ts";
1
+ import type { Detector, Finding, LengthUnit, Sentence, Span } from "../plugin.ts";
2
2
 
3
3
  const HEAD_CHARS = 6;
4
4
  const HEAD_WORDS = 3;
@@ -13,22 +13,36 @@ const headOf = (sentence: Sentence, unit: LengthUnit): string => {
13
13
  return text.split(/\s+/u).slice(0, HEAD_WORDS).join(" ");
14
14
  };
15
15
 
16
- type Run = { readonly head: string; readonly members: readonly Sentence[] };
16
+ /** 文の入れ物。文の始まりを含む、いちばん内側の箇条書きの項目の始まり。地の文なら -1。 */
17
+ const containerOf = (sentence: Sentence, lists: readonly Span[]): number =>
18
+ lists.filter((item) => item.start <= sentence.span.start && sentence.span.start < item.end).reduce((innermost, item) => Math.max(innermost, item.start), -1);
17
19
 
18
- const runsOf = (sentences: readonly Sentence[], unit: LengthUnit): Run[] =>
20
+ type Run = { readonly head: string; readonly container: number; readonly members: readonly Sentence[] };
21
+
22
+ /** 前の文と今の文のあいだで、箇条書きの項目が始まっているか。文を持たない項目(画像だけの - ![図](a.png))も連なりを切る。 */
23
+ const listBetween = (previous: Sentence | undefined, sentence: Sentence, lists: readonly Span[]): boolean =>
24
+ previous !== undefined && lists.some((item) => item.start >= previous.span.end && item.start < sentence.span.start);
25
+
26
+ /**
27
+ * 連なりは同じ入れ物の中でだけ続く。箇条書きの項目どうしは同じ形で並べるのが書き方そのもの(「1. SRE に関する…」「2. SRE に関する…」)で、
28
+ * 項目をまたぐと切る。一つの項目の中の段落や、地の文の中では、これまでどおり数える。箇条書きの前後の地の文も、箇条書きで切れる。
29
+ */
30
+ const runsOf = (sentences: readonly Sentence[], unit: LengthUnit, lists: readonly Span[]): Run[] =>
19
31
  sentences.reduce<Run[]>((acc, sentence) => {
20
32
  const head = headOf(sentence, unit);
33
+ const container = containerOf(sentence, lists);
21
34
  const last = acc.at(-1);
22
35
  // 短すぎる書き出しは「同じ」と言えない。単位ぶん揃って初めて連なりと見る。
23
36
  const full = unit === "char" ? head.length === HEAD_CHARS : head.split(" ").length === HEAD_WORDS;
24
- if (last !== undefined && last.head === head && full) {
25
- return [...acc.slice(0, -1), { head, members: [...last.members, sentence] }];
37
+ const joins = last !== undefined && last.head === head && last.container === container && !listBetween(last.members.at(-1), sentence, lists);
38
+ if (last !== undefined && joins && full) {
39
+ return [...acc.slice(0, -1), { head, container, members: [...last.members, sentence] }];
26
40
  }
27
- return [...acc, { head, members: [sentence] }];
41
+ return [...acc, { head, container, members: [sentence] }];
28
42
  }, []);
29
43
 
30
44
  export const repeatedHead: Detector = (doc, options): Finding[] =>
31
- runsOf(doc.sentences, doc.lengthUnit)
45
+ runsOf(doc.sentences, doc.lengthUnit, doc.listSpans)
32
46
  .filter((run) => run.members.length > options.limit)
33
47
  .map((run) => ({
34
48
  rule: "repeated-sentence-head",
@@ -1,4 +1,4 @@
1
- import { hasPredicate, isClosed } from "../sentence-shape.ts";
1
+ import { endingTokens, hasPredicate, isClosed } from "../sentence-shape.ts";
2
2
  import type { Detector, Finding, Sentence } from "../plugin.ts";
3
3
 
4
4
  /**
@@ -11,10 +11,14 @@ import type { Detector, Finding, Sentence } from "../plugin.ts";
11
11
  * 述語を持たないものは文として数えない。見出しの下の `MaaSサービス` のような
12
12
  * 名前だけの行がそのまま「である調」の少数派になり、実文書の誤検知はすべてこれだった。
13
13
  */
14
- const isSentence = (sentence: Sentence): boolean => isClosed(sentence) && hasPredicate(sentence);
14
+ const isSentence = (sentence: Sentence): boolean => isClosed(sentence) && hasPredicate(sentence) && endingTokens(sentence).length > 0;
15
15
 
16
+ /**
17
+ * 文末の語だけを見る。文の途中の引用(「ご覧ください」という表現を使う。)は文末の調子ではない。
18
+ * 書いた形でも原形でも当てる。「ください」の原形は「くださる」で、原形だけを見ると丁寧な文末を見落とす。
19
+ */
16
20
  const isPolite = (sentence: Sentence, polite: readonly string[]): boolean =>
17
- (sentence.tokens ?? []).some((token) => polite.includes(token.lemma ?? token.surface));
21
+ endingTokens(sentence).some((token) => polite.includes(token.surface) || (token.lemma !== undefined && polite.includes(token.lemma)));
18
22
 
19
23
  export const sentenceEnding: Detector = (doc, options): Finding[] => {
20
24
  const polite = (options.lexicon ?? []).map((entry) => entry.pattern);
@@ -1,6 +1,7 @@
1
1
  import { proseText } from "../measure.ts";
2
2
  import { wordsOf } from "./structure.ts";
3
- import type { Detector, Finding, ProseDocument, Sentence } from "../plugin.ts";
3
+ import { compacted, placeOf } from "./gram-place.ts";
4
+ import type { Detector, Finding, ProseDocument, Section, Sentence, Token } from "../plugin.ts";
4
5
 
5
6
  const PER = 1000;
6
7
 
@@ -91,13 +92,44 @@ const gramsOf = (doc: ProseDocument): Map<string, number> => {
91
92
  return counts;
92
93
  };
93
94
 
95
+ /** 数えたときと同じ形の文。char 単位では空白を詰めてから数えている。 */
96
+ const gramText = (sentence: Sentence, unit: ProseDocument["lengthUnit"]): string => compacted(sentence.text, unit).text;
97
+
98
+ /** 動詞と助動詞。言い回し(ることができます、it is important to)にはこれが入り、主題の名前(state and local tax)には入らない。 */
99
+ const PREDICATE_POS = new Set(["VERB", "AUX"]);
100
+
101
+ const NOUNS = new Set(["NOUN", "PROPN", "ADJ"]);
102
+
103
+ /**
104
+ * 英語で名詞のすぐ前の動詞は、名詞を飾る語(形容詞を挟んでも同じ)(the upcoming fiscal year、the Disclosing Party、the borrow checker)で、
105
+ * 述語ではない。日本語の名詞の前の動詞(〜にあるコンポーネント)は言い回しの一部になるので、word 単位だけで見る。
106
+ */
107
+ const modifiesNoun = (tokens: readonly Token[], index: number, unit: ProseDocument["lengthUnit"]): boolean =>
108
+ unit === "word" && tokens[index]?.pos === "VERB" && NOUNS.has(tokens[index + 1]?.pos ?? "");
109
+
110
+ /**
111
+ * 語句が文の中で占める範囲に、動詞か助動詞があるか。範囲で見る。文字で探すと、同じ文の別の所の動詞「use」が、語句の中の名詞「use」に当たる。
112
+ * 品詞が無ければ見分けられないので、これまでどおり言い回しとして数える。
113
+ */
114
+ const hasPredicate = (gram: string, sentence: Sentence, unit: ProseDocument["lengthUnit"]): boolean => {
115
+ if (sentence.tokens === undefined) return true;
116
+ const place = placeOf(compacted(sentence.text, unit), gram);
117
+ if (place === undefined) return true;
118
+ const [start, end] = [sentence.span.start + place.start, sentence.span.start + place.end];
119
+ const tokens = sentence.tokens;
120
+ return tokens.some((token, index) => PREDICATE_POS.has(token.pos) && token.span.start < end && start < token.span.end && !modifiesNoun(tokens, index, unit));
121
+ };
122
+
94
123
  export const ngramRepetition: Detector = (doc, options): Finding[] => {
95
124
  if (wordsOf(doc) < FLOOR[doc.lengthUnit]) return [];
125
+ const sentenceWith = (gram: string): Sentence | undefined => doc.sentences.find((sentence) => gramText(sentence, doc.lengthUnit).includes(gram));
96
126
  const worst = [...gramsOf(doc).entries()]
97
- .filter(([gram]) => isPhrasing(gram, doc.lengthUnit))
98
- .reduce<{ gram: string; count: number }>((best, [gram, count]) => (count > best.count ? { gram, count } : best), { gram: "", count: 0 });
99
- if (worst.count <= options.limit) return [];
100
- const at = doc.sentences.find((sentence) => sentence.text.includes(worst.gram));
127
+ .filter(([gram, count]) => count > options.limit && isPhrasing(gram, doc.lengthUnit))
128
+ .sort(([, left], [, right]) => right - left)
129
+ .map(([gram, count]) => ({ gram, count, at: sentenceWith(gram) }))
130
+ .find(({ gram, at }) => at === undefined || hasPredicate(gram, at, doc.lengthUnit));
131
+ if (worst === undefined) return [];
132
+ const at = worst.at;
101
133
  return [
102
134
  {
103
135
  rule: "ngram-repetition",
@@ -113,8 +145,57 @@ export const ngramRepetition: Detector = (doc, options): Finding[] => {
113
145
  /**
114
146
  * 略語が初出で展開されているか。「CI」だけでは、読む人によって指すものが違う。
115
147
  * 展開は「略語の直前か直後の括弧」か「括弧の中の略語」で書かれる。
148
+ *
149
+ * & で繋いだ大文字は 1 語として読む(ATT&CK、M&IE)。割ると CK や IE が別の略語に見える。
150
+ */
151
+ const EDGE_BEFORE = String.raw`(?<![A-Za-z0-9_&])`;
152
+ const EDGE_AFTER = String.raw`(?![A-Za-z0-9_&])`;
153
+ const ACRONYM = new RegExp(String.raw`${EDGE_BEFORE}(?:[A-Z]+(?:&[A-Z]+)+|[A-Z]{2,6})${EDGE_AFTER}`, "gu");
154
+
155
+ /**
156
+ * 大文字で書かれていても略語ではないもの。
157
+ *
158
+ * 大文字だけの語が 3 つ以上、空白と引用符だけを挟んで続くのは強調(免責の定型文など)。
159
+ * 強調は読点や括弧をまたいで続くので(THE AUTHOR, THE COMPANY (IF ANY))、そういう続きを含む
160
+ * 「大文字以外の文字を含まない一続き」を丸ごと強調と見なす。小文字の語が挟まれば切れるので、
161
+ * 「SRE, SLO, MTTR」のような略語の並びは残る。引用符に包まれた大文字の句(“AS IS”)も強調。
116
162
  */
117
- const ACRONYM = /\b[A-Z]{2,6}\b/gu;
163
+ const CAPS_WORD = String.raw`[A-Z]+(?:&[A-Z]+)*`;
164
+ const SHOUTED_RUN = new RegExp(String.raw`${EDGE_BEFORE}${CAPS_WORD}(?:[\s"“”'‘’]+${CAPS_WORD}){2,}${EDGE_AFTER}`, "u");
165
+ const UNCASED_STRETCH = /(?:[A-Z]|\P{L})+/gu;
166
+ const QUOTED_CAPS = new RegExp(String.raw`["“'‘]${CAPS_WORD}(?:\s+${CAPS_WORD})+["”'’]`, "gu");
167
+
168
+ /** RFC 2119 の要件語。定義された普通の語で、1 語でも強調でも現れる。 */
169
+ const REQUIREMENT_WORD = new RegExp(
170
+ String.raw`${EDGE_BEFORE}(?:(?:MUST|SHALL|SHOULD)(?:\s+NOT)?|NOT\s+RECOMMENDED|REQUIRED|RECOMMENDED|MAY|OPTIONAL)${EDGE_AFTER}`,
171
+ "gu",
172
+ );
173
+
174
+ /** - か . で繋がり、数字を含む識別子(AC-2、MS.TEAMS.1.1v1)。大文字の部品は略語ではない。 */
175
+ const IDENTIFIER = /(?<![A-Za-z0-9_&.-])[A-Z0-9][A-Za-z0-9]*(?:[.-][A-Z0-9][A-Za-z0-9]*)+/gu;
176
+
177
+ /** Creative Commons のライセンス名。CC と BY は 1 つの名前の部品。 */
178
+ const LICENCE = new RegExp(String.raw`${EDGE_BEFORE}CC BY(?:-(?:SA|NC|ND))*${EDGE_AFTER}`, "gu");
179
+
180
+ type Span = { readonly start: number; readonly end: number };
181
+
182
+ const spanOf = (match: RegExpExecArray): Span => ({ start: match.index, end: match.index + match[0].length });
183
+
184
+ const spansOf = (text: string): Span[] => [
185
+ ...[...text.matchAll(UNCASED_STRETCH)].filter((match) => SHOUTED_RUN.test(match[0])).map(spanOf),
186
+ ...[...text.matchAll(IDENTIFIER)].filter((match) => /\d/u.test(match[0])).map(spanOf),
187
+ ...[QUOTED_CAPS, REQUIREMENT_WORD, LICENCE].flatMap((pattern) => [...text.matchAll(pattern)].map(spanOf)),
188
+ ];
189
+
190
+ type AcronymHit = { readonly word: string; readonly hit: Hit };
191
+
192
+ const acronymsOf = (doc: ProseDocument): AcronymHit[] =>
193
+ doc.sentences.flatMap((sentence) => {
194
+ const excluded = spansOf(sentence.text);
195
+ return [...sentence.text.matchAll(ACRONYM)]
196
+ .filter((match) => !excluded.some((span) => span.start <= match.index && match.index + match[0].length <= span.end))
197
+ .map((match) => ({ word: match[0], hit: { sentence, offset: sentence.span.start + match.index } }));
198
+ });
118
199
 
119
200
  /** 読み手が説明なしで通じると見なしてよい語。展開すると逆に読みにくい。 */
120
201
  const COMMON = new Set([
@@ -146,34 +227,100 @@ const COMMON = new Set([
146
227
  "XML",
147
228
  "TODO",
148
229
  "NOTE",
230
+ // 通信と符号化。技術文書でなくても説明なしで通じる。
231
+ "DNS",
232
+ "IP",
233
+ "TCP",
234
+ "UDP",
235
+ "SSH",
236
+ "SSL",
237
+ "TLS",
238
+ "VPN",
239
+ "LAN",
240
+ "UTF",
241
+ "ASCII",
242
+ // 機器・形式・単位。
243
+ "PC",
244
+ "IT",
245
+ "SDK",
246
+ "IDE",
247
+ "PNG",
248
+ "JPEG",
249
+ "GIF",
250
+ "SVG",
251
+ "QR",
252
+ "GPS",
253
+ "SNS",
254
+ "TV",
255
+ "KB",
256
+ "MB",
257
+ "GB",
258
+ "TB",
259
+ // 役職と国・地域。
260
+ "CEO",
261
+ "CTO",
262
+ "CFO",
263
+ "US",
264
+ "UK",
265
+ "EU",
266
+ "UN",
267
+ "DNA",
149
268
  ]);
150
269
 
151
270
  /**
152
271
  * 展開は略語の**すぐ隣**にあるときだけ認める。
153
272
  * 60 文字も見ると、同じ文のどこかに括弧があるだけで「説明済み」になり、1 件も出なくなる。
154
273
  *
155
- * 認めるのは 2 つの形。どちらも実際によく書かれる。
274
+ * 認めるのは 3 つの形。どれも実際によく書かれる。
156
275
  * CI(継続的インテグレーション) 略語のあとに括弧
157
276
  * Continuous Integration (CI) 括弧の中が略語
277
+ * Tax Cuts and Jobs Act [TCJA] 角括弧の中が略語で、直前の語の頭文字と揃う
278
+ * 括弧と略語の間には、引用符((“MNDA”)、(「MNDA」))と空白だけを許す。
279
+ */
280
+ const WRAP = String.raw`[\s"“”'‘’「」『』]*`;
281
+ const OPENS = new RegExp(String.raw`^${WRAP}[((]`, "u");
282
+ const CLOSES = new RegExp(String.raw`^${WRAP}[))]`, "u");
283
+ const OPENED = new RegExp(String.raw`[((]${WRAP}$`, "u");
284
+ const SQUARE_CLOSES = new RegExp(String.raw`^${WRAP}\]`, "u");
285
+ const SQUARE_OPENED = new RegExp(String.raw`\[${WRAP}$`, "u");
286
+
287
+ /** 括弧と略語の間の幅。空白は 1 つに畳んであるので、(“ MNDA ”) まで収まる。 */
288
+ const NEAR = 3;
289
+
290
+ /**
291
+ * 角括弧は引用の印にも使う([IANA]、[1])。直前の語のうち大文字で始まる語の頭文字が
292
+ * 略語と揃うときだけ展開と見なす。見る語は略語の文字数の 2 倍まで(and や of を挟むため)。
158
293
  */
159
- const OPENS = /^\s*[((]/u;
160
- const CLOSES = /^\s*[))]/u;
161
- const OPENED = /[((]\s*$/u;
294
+ const spellsOut = (before: string, acronym: string): boolean => {
295
+ const letters = acronym.replaceAll("&", "");
296
+ const words = before
297
+ .replace(SQUARE_OPENED, "")
298
+ .trim()
299
+ .split(/\s+/u)
300
+ .slice(-letters.length * 2);
301
+ const initials = words.filter((word) => /^[A-Z]/u.test(word)).map((word) => word.charAt(0));
302
+ return initials.join("").endsWith(letters);
303
+ };
162
304
 
163
- const isExpanded = (body: string, acronym: string): boolean => {
164
- const at = body.indexOf(acronym);
165
- if (at === -1) return false;
166
- const after = body.slice(at + acronym.length, at + acronym.length + 3);
167
- const before = body.slice(Math.max(0, at - 3), at);
168
- return OPENS.test(after) || (OPENED.test(before) && CLOSES.test(after));
305
+ const isExpandedAt = (body: string, acronym: string, at: number): boolean => {
306
+ const after = body.slice(at + acronym.length, at + acronym.length + NEAR);
307
+ const before = body.slice(Math.max(0, at - NEAR), at);
308
+ if (OPENS.test(after) || (OPENED.test(before) && CLOSES.test(after))) return true;
309
+ return SQUARE_OPENED.test(before) && SQUARE_CLOSES.test(after) && spellsOut(body.slice(0, at), acronym);
169
310
  };
170
311
 
312
+ /**
313
+ * どこか 1 か所で展開してあればよい。初出が節の見出し代わりの語(「5.3. DPA.」)で、
314
+ * 展開がその直後の文にあることが契約書では普通にある。見るのは語として現れた所だけ(CISA の中の CI は見ない)。
315
+ */
316
+ const isExpanded = (body: string, acronym: string): boolean =>
317
+ [...body.matchAll(new RegExp(`${EDGE_BEFORE}${acronym}${EDGE_AFTER}`, "gu"))].some((match) => isExpandedAt(body, acronym, match.index));
318
+
171
319
  export const undefinedAcronym: Detector = (doc, options): Finding[] => {
172
320
  const body = bodyOf(doc);
173
321
  const seen = new Map<string, Hit>();
174
- findIn(doc, ACRONYM).forEach((hit) => {
175
- const text = /^[A-Z]{2,6}/u.exec(doc.source.slice(hit.offset, hit.offset + 6))?.[0] ?? "";
176
- if (text.length > 0 && !COMMON.has(text) && !seen.has(text)) seen.set(text, hit);
322
+ acronymsOf(doc).forEach(({ word, hit }) => {
323
+ if (!COMMON.has(word) && !seen.has(word)) seen.set(word, hit);
177
324
  });
178
325
  const bare = [...seen.entries()].filter(([acronym]) => !isExpanded(body, acronym));
179
326
  if (bare.length < options.limit) return [];
@@ -193,9 +340,29 @@ export const undefinedAcronym: Detector = (doc, options): Finding[] => {
193
340
  */
194
341
  const CONCRETE = /\d|`|https?:\/\//u;
195
342
 
343
+ /**
344
+ * 見出しのない導入部は、書き出しで呼ぶ。文書の言葉なので、message がどの言語でも混ざらない。
345
+ * 決まった呼び名(「この節」)を置くと、英語の文書の message に日本語が入る。
346
+ */
347
+ const OPENING = { word: 4, char: 12 };
348
+
349
+ const openingOf = (sentence: Sentence | undefined, unit: ProseDocument["lengthUnit"]): string => {
350
+ const text = sentence === undefined ? "" : proseText(sentence);
351
+ const pieces = unit === "word" ? text.split(" ") : Array.from(text);
352
+ const joiner = unit === "word" ? " " : "";
353
+ return pieces.length <= OPENING[unit] ? pieces.join(joiner) : `${pieces.slice(0, OPENING[unit]).join(joiner)}…`;
354
+ };
355
+
356
+ /**
357
+ * 形態素解析が数と読んだ語(UD の NumType=Card)。漢数字の「二割」「十五分」も、算用数字と同じ具体的な数。
358
+ * 言い回しの「二人三脚」「一人ひとり」は、辞書が一語として持つので数にならない。品詞が無ければ、数字だけを見る。
359
+ */
360
+ const hasNumeral = (section: Section): boolean =>
361
+ section.sentences.some((sentence) => (sentence.tokens ?? []).some((token) => token.features?.["NumType"] === "Card"));
362
+
196
363
  export const concreteEvidence: Detector = (doc, options): Finding[] => {
197
364
  const sections = doc.sections.filter((section) => section.sentences.length >= 3);
198
- const empty = sections.filter((section) => !CONCRETE.test(doc.source.slice(section.span.start, section.span.end)));
365
+ const empty = sections.filter((section) => !CONCRETE.test(doc.source.slice(section.span.start, section.span.end)) && !hasNumeral(section));
199
366
  if (empty.length < options.limit) return [];
200
367
  return empty.map((section) => ({
201
368
  rule: "concrete-evidence-density",
@@ -204,7 +371,7 @@ export const concreteEvidence: Detector = (doc, options): Finding[] => {
204
371
  column: 0,
205
372
  quote: section.heading.length > 0 ? section.heading : (section.sentences[0]?.text.trim() ?? ""),
206
373
  values: {
207
- word: section.heading.length > 0 ? section.heading : "この節",
374
+ word: section.heading.length > 0 ? section.heading : openingOf(section.sentences[0], doc.lengthUnit),
208
375
  count: empty.length,
209
376
  total: sections.length,
210
377
  limit: options.limit,
@@ -1,6 +1,9 @@
1
1
  import type { Detector, Finding, ProseDocument } from "../plugin.ts";
2
- import { danglingReferences, duplicateDefinitions, type StructureIssue } from "../structure/issues.ts";
2
+ import { danglingReferences, duplicateDefinitions, inDocumentOrder, type StructureIssue } from "../structure/issues.ts";
3
3
  import { numberingBreaks } from "../structure/numbering.ts";
4
+ import { weekdayMismatches } from "../structure/weekday.ts";
5
+ import { dateOrderBreaks } from "../structure/date-order.ts";
6
+ import { totalMismatches, type Amount } from "../structure/total.ts";
4
7
 
5
8
  const QUOTE_LENGTH = 80;
6
9
 
@@ -30,3 +33,66 @@ const findingsOf =
30
33
  export const danglingReference: Detector = findingsOf("dangling-reference", danglingReferences);
31
34
  export const numberingGap: Detector = findingsOf("numbering-gap", numberingBreaks);
32
35
  export const duplicateDefinition: Detector = findingsOf("duplicate-definition", duplicateDefinitions);
36
+
37
+ /** 曜日の名前は言語パッケージの語彙表(weekday、日曜日から順)から取る。無ければ番号のまま。 */
38
+ const dayName = (doc: ProseDocument, day: unknown): string => {
39
+ const index = Number(day);
40
+ return doc.lexicons["weekday"]?.[index]?.pattern ?? String(index);
41
+ };
42
+
43
+ export const dateWeekdayMismatch: Detector = (doc): Finding[] =>
44
+ doc.structure === undefined
45
+ ? []
46
+ : weekdayMismatches(doc.structure).map((issue) => ({
47
+ rule: "date-weekday-mismatch",
48
+ severity: "error",
49
+ line: 0,
50
+ column: 0,
51
+ quote: quoteAt(doc.source, issue.offset),
52
+ values: {
53
+ date: String(issue.values["date"]),
54
+ written: dayName(doc, issue.values["written"]),
55
+ actual: dayName(doc, issue.values["actual"]),
56
+ offset: issue.offset,
57
+ },
58
+ }));
59
+
60
+ /** 木の日付を、原文の位置と一緒に並べる。 */
61
+ const datedPoints = (tree: NonNullable<ProseDocument["structure"]>): { offset: number; value: string }[] =>
62
+ inDocumentOrder(tree).flatMap((node) => (node.kind === "date" ? [{ offset: node.span.start, value: String(node.attrs["value"]) }] : []));
63
+
64
+ /** 日程の並びに逆らう日付。並びの読み方は原文の行を見るので、木と原文の両方を渡す。 */
65
+ export const dateOrder: Detector = (doc): Finding[] =>
66
+ doc.structure === undefined
67
+ ? []
68
+ : dateOrderBreaks(doc.source, datedPoints(doc.structure)).map((issue) => ({
69
+ rule: "date-order",
70
+ severity: "warning",
71
+ line: 0,
72
+ column: 0,
73
+ quote: quoteAt(doc.source, issue.offset),
74
+ values: { ...issue.values, offset: issue.offset },
75
+ }));
76
+
77
+ /** 木の数量を、原文の位置と一緒に並べる。 */
78
+ const amountsOf = (tree: NonNullable<ProseDocument["structure"]>): Amount[] =>
79
+ inDocumentOrder(tree).flatMap((node) =>
80
+ node.kind === "quantity" ? [{ offset: node.span.start, end: node.span.end, value: Number(node.attrs["value"]), unit: String(node.attrs["unit"]) }] : [],
81
+ );
82
+
83
+ /** 合計の行が、上の金額の和と合わない。合計の語は言語パッケージの語彙表(total-label)から取る。 */
84
+ export const totalMismatch: Detector = (doc): Finding[] =>
85
+ doc.structure === undefined
86
+ ? []
87
+ : totalMismatches(
88
+ doc.source,
89
+ amountsOf(doc.structure),
90
+ (doc.lexicons["total-label"] ?? []).map((entry) => entry.pattern),
91
+ ).map((issue) => ({
92
+ rule: "total-mismatch",
93
+ severity: "error",
94
+ line: 0,
95
+ column: 0,
96
+ quote: quoteAt(doc.source, issue.offset),
97
+ values: { ...issue.values, offset: issue.offset },
98
+ }));
@@ -1,4 +1,5 @@
1
1
  import { proseText } from "../measure.ts";
2
+ import { joinWords } from "./word-list.ts";
2
3
  import { wordsOf } from "./structure.ts";
3
4
  import type { Detector, Finding, Sentence, Token } from "../plugin.ts";
4
5
 
@@ -12,7 +13,11 @@ import type { Detector, Finding, Sentence, Token } from "../plugin.ts";
12
13
  * 表層でも原形でも当てる。利用者は辞書形で書く(「握る」)が、本文は活用している(「握った」)。
13
14
  * 品詞解析があれば原形で当たり、無くても表層で当たる。要求はしない。
14
15
  */
15
- const matches = (sentence: Sentence, word: string): boolean => sentence.text.includes(word) || (sentence.tokens ?? []).some((token) => token.lemma === word);
16
+ const matches = (sentence: Sentence, word: string): boolean => {
17
+ // 大文字小文字は区別しない。英語の文頭の「Circle back」も「circle back」として当てる。
18
+ const wanted = word.toLowerCase();
19
+ return sentence.text.toLowerCase().includes(wanted) || (sentence.tokens ?? []).some((token) => token.lemma?.toLowerCase() === wanted);
20
+ };
16
21
 
17
22
  export const internalJargon: Detector = (doc, options): Finding[] => {
18
23
  const words = (options.lexicon ?? []).map((entry) => entry.pattern);
@@ -53,7 +58,7 @@ export const requiredSections: Detector = (doc, options): Finding[] => {
53
58
  line: 0,
54
59
  column: 0,
55
60
  quote: headings.filter((heading) => heading.length > 0).join(" / "),
56
- values: { word: missing.join("、"), count: missing.length, limit: options.limit, offset: 0 },
61
+ values: { word: joinWords(missing, doc.language), count: missing.length, limit: options.limit, offset: 0 },
57
62
  },
58
63
  ];
59
64
  };
@@ -0,0 +1,2 @@
1
+ /** 語の並べ方は文書の言語に合わせる。日本語は「、」、ほかは ", "。英語の画面に「Risks、Costs」と出さない。 */
2
+ export const joinWords = (words: readonly string[], language: string): string => words.join(language === "ja" ? "、" : ", ");