chaffjs 0.10.0 → 0.12.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 (198) 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/date-stamp.d.ts +14 -0
  8. package/dist/date-stamp.d.ts.map +1 -0
  9. package/dist/date-stamp.js +40 -0
  10. package/dist/date-stamp.js.map +1 -0
  11. package/dist/declared-lists.d.ts +4 -0
  12. package/dist/declared-lists.d.ts.map +1 -0
  13. package/dist/declared-lists.js +3 -0
  14. package/dist/declared-lists.js.map +1 -0
  15. package/dist/detectors/acronym-context.d.ts +13 -0
  16. package/dist/detectors/acronym-context.d.ts.map +1 -0
  17. package/dist/detectors/acronym-context.js +42 -0
  18. package/dist/detectors/acronym-context.js.map +1 -0
  19. package/dist/detectors/acronym-expansion.d.ts +9 -0
  20. package/dist/detectors/acronym-expansion.d.ts.map +1 -0
  21. package/dist/detectors/acronym-expansion.js +67 -0
  22. package/dist/detectors/acronym-expansion.js.map +1 -0
  23. package/dist/detectors/char-shape.d.ts.map +1 -1
  24. package/dist/detectors/char-shape.js +34 -2
  25. package/dist/detectors/char-shape.js.map +1 -1
  26. package/dist/detectors/doubled-word.d.ts +27 -0
  27. package/dist/detectors/doubled-word.d.ts.map +1 -0
  28. package/dist/detectors/doubled-word.js +94 -0
  29. package/dist/detectors/doubled-word.js.map +1 -0
  30. package/dist/detectors/en-shape.d.ts.map +1 -1
  31. package/dist/detectors/en-shape.js +110 -9
  32. package/dist/detectors/en-shape.js.map +1 -1
  33. package/dist/detectors/gram-place.d.ts +14 -0
  34. package/dist/detectors/gram-place.d.ts.map +1 -0
  35. package/dist/detectors/gram-place.js +39 -0
  36. package/dist/detectors/gram-place.js.map +1 -0
  37. package/dist/detectors/heading-echo.d.ts.map +1 -1
  38. package/dist/detectors/heading-echo.js +22 -12
  39. package/dist/detectors/heading-echo.js.map +1 -1
  40. package/dist/detectors/index.d.ts.map +1 -1
  41. package/dist/detectors/index.js +2 -0
  42. package/dist/detectors/index.js.map +1 -1
  43. package/dist/detectors/lead-in.d.ts +8 -0
  44. package/dist/detectors/lead-in.d.ts.map +1 -0
  45. package/dist/detectors/lead-in.js +13 -0
  46. package/dist/detectors/lead-in.js.map +1 -0
  47. package/dist/detectors/lexicon-match.d.ts +15 -0
  48. package/dist/detectors/lexicon-match.d.ts.map +1 -0
  49. package/dist/detectors/lexicon-match.js +48 -0
  50. package/dist/detectors/lexicon-match.js.map +1 -0
  51. package/dist/detectors/lexicon.d.ts +5 -0
  52. package/dist/detectors/lexicon.d.ts.map +1 -1
  53. package/dist/detectors/lexicon.js +32 -22
  54. package/dist/detectors/lexicon.js.map +1 -1
  55. package/dist/detectors/phrase-match.d.ts.map +1 -1
  56. package/dist/detectors/phrase-match.js +7 -6
  57. package/dist/detectors/phrase-match.js.map +1 -1
  58. package/dist/detectors/repeated-head.d.ts.map +1 -1
  59. package/dist/detectors/repeated-head.js +15 -5
  60. package/dist/detectors/repeated-head.js.map +1 -1
  61. package/dist/detectors/sentence-ending.d.ts.map +1 -1
  62. package/dist/detectors/sentence-ending.js +7 -3
  63. package/dist/detectors/sentence-ending.js.map +1 -1
  64. package/dist/detectors/signals.d.ts.map +1 -1
  65. package/dist/detectors/signals.js +97 -59
  66. package/dist/detectors/signals.js.map +1 -1
  67. package/dist/detectors/structure.d.ts.map +1 -1
  68. package/dist/detectors/structure.js +14 -1
  69. package/dist/detectors/structure.js.map +1 -1
  70. package/dist/detectors/superlative-scope.d.ts +16 -0
  71. package/dist/detectors/superlative-scope.d.ts.map +1 -0
  72. package/dist/detectors/superlative-scope.js +40 -0
  73. package/dist/detectors/superlative-scope.js.map +1 -0
  74. package/dist/document.d.ts.map +1 -1
  75. package/dist/document.js +38 -19
  76. package/dist/document.js.map +1 -1
  77. package/dist/eval.d.ts.map +1 -1
  78. package/dist/eval.js +3 -2
  79. package/dist/eval.js.map +1 -1
  80. package/dist/heading-title.d.ts +6 -0
  81. package/dist/heading-title.d.ts.map +1 -0
  82. package/dist/heading-title.js +8 -0
  83. package/dist/heading-title.js.map +1 -0
  84. package/dist/in-page-nav.d.ts +19 -0
  85. package/dist/in-page-nav.d.ts.map +1 -0
  86. package/dist/in-page-nav.js +44 -0
  87. package/dist/in-page-nav.js.map +1 -0
  88. package/dist/lexicon-tokens.d.ts +4 -0
  89. package/dist/lexicon-tokens.d.ts.map +1 -0
  90. package/dist/lexicon-tokens.js +23 -0
  91. package/dist/lexicon-tokens.js.map +1 -0
  92. package/dist/orthography.d.ts +1 -0
  93. package/dist/orthography.d.ts.map +1 -1
  94. package/dist/orthography.js +4 -3
  95. package/dist/orthography.js.map +1 -1
  96. package/dist/page-furniture.d.ts +10 -0
  97. package/dist/page-furniture.d.ts.map +1 -0
  98. package/dist/page-furniture.js +35 -0
  99. package/dist/page-furniture.js.map +1 -0
  100. package/dist/plugin.d.ts +8 -0
  101. package/dist/plugin.d.ts.map +1 -1
  102. package/dist/render/compact.d.ts.map +1 -1
  103. package/dist/render/compact.js +11 -2
  104. package/dist/render/compact.js.map +1 -1
  105. package/dist/rule-load.d.ts.map +1 -1
  106. package/dist/rule-load.js +2 -0
  107. package/dist/rule-load.js.map +1 -1
  108. package/dist/run.d.ts.map +1 -1
  109. package/dist/run.js +10 -7
  110. package/dist/run.js.map +1 -1
  111. package/dist/sentence-shape.d.ts +6 -0
  112. package/dist/sentence-shape.d.ts.map +1 -1
  113. package/dist/sentence-shape.js +15 -0
  114. package/dist/sentence-shape.js.map +1 -1
  115. package/dist/structure/bare-numbers.d.ts +14 -0
  116. package/dist/structure/bare-numbers.d.ts.map +1 -0
  117. package/dist/structure/bare-numbers.js +53 -0
  118. package/dist/structure/bare-numbers.js.map +1 -0
  119. package/dist/structure/build.d.ts +2 -0
  120. package/dist/structure/build.d.ts.map +1 -1
  121. package/dist/structure/build.js +6 -3
  122. package/dist/structure/build.js.map +1 -1
  123. package/dist/structure/issues.d.ts.map +1 -1
  124. package/dist/structure/issues.js +11 -2
  125. package/dist/structure/issues.js.map +1 -1
  126. package/dist/structure/of.d.ts.map +1 -1
  127. package/dist/structure/of.js +4 -2
  128. package/dist/structure/of.js.map +1 -1
  129. package/dist/structure/total.d.ts +1 -0
  130. package/dist/structure/total.d.ts.map +1 -1
  131. package/dist/structure/total.js +37 -13
  132. package/dist/structure/total.js.map +1 -1
  133. package/dist/structure/universal.d.ts +2 -2
  134. package/dist/structure/universal.d.ts.map +1 -1
  135. package/dist/structure/universal.js +13 -4
  136. package/dist/structure/universal.js.map +1 -1
  137. package/dist/version.d.ts +5 -0
  138. package/dist/version.d.ts.map +1 -1
  139. package/dist/version.js +17 -2
  140. package/dist/version.js.map +1 -1
  141. package/package.json +3 -3
  142. package/rules/ai-tell.yaml +1 -0
  143. package/rules/closing-cliche.yaml +1 -0
  144. package/rules/concrete-evidence-density.yaml +2 -0
  145. package/rules/cushion-phrase-density.yaml +1 -0
  146. package/rules/double-keigo.yaml +1 -0
  147. package/rules/doubled-word.yaml +27 -0
  148. package/rules/empty-intensifier.yaml +1 -0
  149. package/rules/excessive-hedging.yaml +1 -0
  150. package/rules/hiragana-fukushi.yaml +1 -0
  151. package/rules/latin-spacing.yaml +2 -2
  152. package/rules/max-kanji-continuous.yaml +2 -0
  153. package/rules/ngram-repetition.yaml +2 -0
  154. package/rules/padded-intro.yaml +1 -0
  155. package/rules/repeated-conjunction.yaml +1 -0
  156. package/rules/sasete-itadaku.yaml +1 -0
  157. package/rules/undefined-acronym.yaml +1 -0
  158. package/rules/unqualified-superlative.yaml +2 -0
  159. package/src/cli-text.ts +2 -0
  160. package/src/cli.ts +5 -1
  161. package/src/date-stamp.ts +65 -0
  162. package/src/declared-lists.ts +5 -0
  163. package/src/detectors/acronym-context.ts +53 -0
  164. package/src/detectors/acronym-expansion.ts +81 -0
  165. package/src/detectors/char-shape.ts +38 -3
  166. package/src/detectors/doubled-word.ts +128 -0
  167. package/src/detectors/en-shape.ts +138 -7
  168. package/src/detectors/gram-place.ts +41 -0
  169. package/src/detectors/heading-echo.ts +13 -3
  170. package/src/detectors/index.ts +2 -0
  171. package/src/detectors/lead-in.ts +14 -0
  172. package/src/detectors/lexicon-match.ts +54 -0
  173. package/src/detectors/lexicon.ts +44 -25
  174. package/src/detectors/phrase-match.ts +8 -7
  175. package/src/detectors/repeated-head.ts +21 -7
  176. package/src/detectors/sentence-ending.ts +7 -3
  177. package/src/detectors/signals.ts +122 -60
  178. package/src/detectors/structure.ts +21 -2
  179. package/src/detectors/superlative-scope.ts +66 -0
  180. package/src/document.ts +43 -19
  181. package/src/eval.ts +2 -1
  182. package/src/heading-title.ts +8 -0
  183. package/src/in-page-nav.ts +67 -0
  184. package/src/lexicon-tokens.ts +28 -0
  185. package/src/orthography.ts +4 -3
  186. package/src/page-furniture.ts +45 -0
  187. package/src/plugin.ts +13 -1
  188. package/src/render/compact.ts +12 -2
  189. package/src/rule-load.ts +2 -0
  190. package/src/run.ts +11 -7
  191. package/src/sentence-shape.ts +16 -0
  192. package/src/structure/bare-numbers.ts +78 -0
  193. package/src/structure/build.ts +9 -3
  194. package/src/structure/issues.ts +16 -2
  195. package/src/structure/of.ts +4 -2
  196. package/src/structure/total.ts +45 -19
  197. package/src/structure/universal.ts +14 -3
  198. package/src/version.ts +21 -3
@@ -0,0 +1,14 @@
1
+ /** 文末のコロン。後ろに強調の印(**・_)や空白が残っていても見る。 */
2
+ const ENDS_WITH_COLON = /[::][\s*_]*$/u;
3
+
4
+ /**
5
+ * 後ろの箇条書きや表へ読者を渡す文(「次のとおりとする。」「The following expenses require receipts:」)。
6
+ * 見出しの語を繰り返していても、中身は後ろにある。言い回しは言語パッケージの語彙表 lead-in が持つ。
7
+ */
8
+ export const isLeadIn = (sentence: string, phrases: readonly string[]): boolean => {
9
+ const text = sentence.trim().toLowerCase();
10
+ return ENDS_WITH_COLON.test(text) || phrases.some((phrase) => phrase !== "" && text.includes(phrase.toLowerCase()));
11
+ };
12
+
13
+ /** 渡す先(following: 同じ節の、その文より後ろ)が空なら、渡す文ではなくただの繰り返し。 */
14
+ export const handsOver = (sentence: string, following: string, phrases: readonly string[]): boolean => following.trim() !== "" && isLeadIn(sentence, phrases);
@@ -0,0 +1,54 @@
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
+ export type TokenRange = { readonly start: number; readonly end: number };
36
+
37
+ /** 語彙の語が文のどの語の並びに当たったか(始まりと、終わった次の位置)。品詞が無ければ位置を言えないので空。 */
38
+ export const entryRanges = (sentence: Sentence, entry: LexiconEntry): TokenRange[] => {
39
+ const tokens = sentence.tokens;
40
+ const words = entry.tokens;
41
+ if (tokens === undefined || words === undefined || words.length === 0) return [];
42
+ return tokens.flatMap((_token, start) => (runsAt(tokens, words, start) ? [{ start, end: start + words.length }] : []));
43
+ };
44
+
45
+ const LEADING_MARK = new Set(["PUNCT", "SYM"]);
46
+
47
+ /** 語彙の語で文が始まるか。文頭の記号は飛ばす。品詞が無ければ文字列の前方一致。 */
48
+ export const entryOpens = (sentence: Sentence, entry: LexiconEntry): boolean => {
49
+ const tokens = sentence.tokens;
50
+ const words = entry.tokens;
51
+ if (tokens === undefined || words === undefined || words.length === 0) return proseText(sentence).toLowerCase().startsWith(entry.pattern.toLowerCase());
52
+ const first = tokens.findIndex((token) => !LEADING_MARK.has(token.pos));
53
+ return runsAt(tokens, words, first);
54
+ };
@@ -1,7 +1,8 @@
1
1
  import { joinWords } from "./word-list.ts";
2
- import { proseText } from "../measure.ts";
3
2
  import { wordsOf } from "./structure.ts";
4
- import type { Detector, Finding, ProseDocument, Sentence } from "../plugin.ts";
3
+ import type { Detector, Finding, Lexicon, LexiconEntry, ProseDocument, Sentence } from "../plugin.ts";
4
+ import { entryIn, entryOpens, entryRanges } from "./lexicon-match.ts";
5
+ import { scoped, scopeMarkersOf, type ScopeMarkers } from "./superlative-scope.ts";
5
6
 
6
7
  const PER = 1000;
7
8
 
@@ -10,11 +11,8 @@ const FLOOR = { word: 200, char: 500 };
10
11
 
11
12
  type Hit = { readonly sentence: Sentence; readonly matched: string };
12
13
 
13
- const hitsFor = (doc: ProseDocument, patterns: readonly string[]): Hit[] =>
14
- doc.sentences.flatMap((sentence) => {
15
- const text = sentence.text.toLowerCase();
16
- return patterns.filter((pattern) => text.includes(pattern.toLowerCase())).map((matched) => ({ sentence, matched }));
17
- });
14
+ const hitsFor = (doc: ProseDocument, lexicon: Lexicon): Hit[] =>
15
+ doc.sentences.flatMap((sentence) => lexicon.filter((entry) => entryIn(sentence, entry)).map((entry) => ({ sentence, matched: entry.pattern })));
18
16
 
19
17
  /**
20
18
  * 単位長あたりの出現率。件数で数えると長い文書ほど当たる(bold-density と同じ)。
@@ -23,8 +21,7 @@ const hitsFor = (doc: ProseDocument, patterns: readonly string[]): Hit[] =>
23
21
  const densityRule =
24
22
  (rule: string): Detector =>
25
23
  (doc, options): Finding[] => {
26
- const patterns = (options.lexicon ?? []).map((entry) => entry.pattern);
27
- const hits = hitsFor(doc, patterns);
24
+ const hits = hitsFor(doc, options.lexicon ?? []);
28
25
  const length = wordsOf(doc);
29
26
  const rate = length === 0 ? 0 : Math.round((hits.length / length) * PER);
30
27
  const first = hits[0];
@@ -42,15 +39,39 @@ const densityRule =
42
39
  export const hedgingDensity = densityRule("excessive-hedging");
43
40
  export const cushionDensity = densityRule("cushion-phrase-density");
44
41
 
42
+ /** 数字は語ではないので言語を問わない。数があれば測った結果を言っている。品詞の数(NUM)は "the best one" の one まで含むので使わない。 */
43
+ const DIGIT = /\d/u;
44
+
45
+ type Qualifiers = { readonly comparison: Lexicon; readonly scope: ScopeMarkers };
46
+
47
+ const qualifiersOf = (doc: ProseDocument): Qualifiers => ({
48
+ comparison: doc.lexicons["comparison-marker"] ?? [],
49
+ scope: scopeMarkersOf(doc.lexicons["superlative-scope"] ?? []),
50
+ });
51
+
52
+ /** どの出現も範囲を持つときだけ。1 つでも範囲の無い出現があれば、その文には限定の無い最上級がある。 */
53
+ const everyScoped = (sentence: Sentence, entry: LexiconEntry, scope: ScopeMarkers): boolean => {
54
+ const ranges = entryRanges(sentence, entry);
55
+ return ranges.length > 0 && ranges.every((range) => scoped(sentence.tokens ?? [], range, scope));
56
+ };
57
+
58
+ const qualified = (sentence: Sentence, entry: LexiconEntry, qualifiers: Qualifiers): boolean =>
59
+ DIGIT.test(sentence.text) || qualifiers.comparison.some((marker) => entryIn(sentence, marker)) || everyScoped(sentence, entry, qualifiers.scope);
60
+
61
+ const bareHits = (doc: ProseDocument, lexicon: Lexicon): Hit[] => {
62
+ const qualifiers = qualifiersOf(doc);
63
+ return doc.sentences.flatMap((sentence) =>
64
+ lexicon.filter((entry) => entryIn(sentence, entry) && !qualified(sentence, entry, qualifiers)).map((entry) => ({ sentence, matched: entry.pattern })),
65
+ );
66
+ };
67
+
45
68
  /**
46
69
  * 限定のない最上級。「最も速い」だけでは、何と比べて最もなのかが無い。
47
- * 同じ文に比較対象や条件があれば、それは主張であって誇張ではない。
70
+ * 同じ文に比較対象・条件・範囲(日本で最も、the best in the world)があれば、それは主張であって誇張ではない。
71
+ * 比較と範囲を言う語は言語パッケージの語彙表が持つ。
48
72
  */
49
- const QUALIFIER = /\d|より|に比べ|のうち|among|than|compared|based on|according/iu;
50
-
51
73
  export const unqualifiedSuperlative: Detector = (doc, options): Finding[] => {
52
- const patterns = (options.lexicon ?? []).map((entry) => entry.pattern);
53
- const bare = hitsFor(doc, patterns).filter((hit) => !QUALIFIER.test(hit.sentence.text));
74
+ const bare = bareHits(doc, options.lexicon ?? []);
54
75
  if (bare.length < options.limit) return [];
55
76
  return bare.map((hit) => ({
56
77
  rule: "unqualified-superlative",
@@ -62,10 +83,10 @@ export const unqualifiedSuperlative: Detector = (doc, options): Finding[] => {
62
83
  }));
63
84
  };
64
85
 
65
- const openerOf = (doc: ProseDocument, patterns: readonly string[]): (string | undefined)[] =>
86
+ const openerOf = (doc: ProseDocument, lexicon: Lexicon): (string | undefined)[] =>
66
87
  doc.paragraphs.map((paragraph) => {
67
- const head = paragraph.sentences[0]?.text.trim().toLowerCase() ?? "";
68
- return patterns.find((pattern) => head.startsWith(pattern.toLowerCase()));
88
+ const head = paragraph.sentences[0];
89
+ return head === undefined ? undefined : lexicon.find((entry) => entryOpens(head, entry))?.pattern;
69
90
  });
70
91
 
71
92
  /**
@@ -73,8 +94,7 @@ const openerOf = (doc: ProseDocument, patterns: readonly string[]): (string | un
73
94
  * 前の段落の付け足しに見えて、話がどこへ向かっているのか分からなくなる。
74
95
  */
75
96
  export const repeatedConjunction: Detector = (doc, options): Finding[] => {
76
- const patterns = (options.lexicon ?? []).map((entry) => entry.pattern);
77
- const openers = openerOf(doc, patterns);
97
+ const openers = openerOf(doc, options.lexicon ?? []);
78
98
  const runs = openers.reduce<{ runs: number[][]; current: number[] }>(
79
99
  (acc, opener, index) => {
80
100
  if (opener === undefined) return { runs: [...acc.runs, acc.current], current: [] };
@@ -106,11 +126,10 @@ export const repeatedConjunction: Detector = (doc, options): Finding[] => {
106
126
  * 単独で断じない。§20.2 の複合シグナルの入口で、これだけで「AI が書いた」とは言わない。
107
127
  */
108
128
  export const aiTell: Detector = (doc, options): Finding[] => {
109
- const weights = new Map((options.lexicon ?? []).map((entry) => [entry.pattern.toLowerCase(), entry.weight ?? 1]));
110
- const body = doc.sentences.map(proseText).join(" ").toLowerCase();
111
- const found = [...weights.entries()].filter(([pattern]) => body.includes(pattern));
112
- const score = Math.round(found.reduce((sum, [, weight]) => sum + weight, 0) * 10);
113
- const first = doc.sentences.find((sentence) => found.some(([pattern]) => sentence.text.toLowerCase().includes(pattern)));
129
+ const entries = [...new Map((options.lexicon ?? []).map((entry) => [entry.pattern.toLowerCase(), entry])).values()];
130
+ const found = entries.filter((entry) => doc.sentences.some((sentence) => entryIn(sentence, entry)));
131
+ const score = Math.round(found.reduce((sum, entry) => sum + (entry.weight ?? 1), 0) * 10);
132
+ const first = doc.sentences.find((sentence) => found.some((entry) => entryIn(sentence, entry)));
114
133
  if (first === undefined || score <= options.limit) return [];
115
134
  return [
116
135
  {
@@ -121,7 +140,7 @@ export const aiTell: Detector = (doc, options): Finding[] => {
121
140
  quote: first.text.trim(),
122
141
  values: {
123
142
  word: joinWords(
124
- found.map(([pattern]) => pattern),
143
+ found.map((entry) => entry.pattern.toLowerCase()),
125
144
  doc.language,
126
145
  ),
127
146
  count: found.length,
@@ -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,9 @@
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 { notAcronymSpans } from "./acronym-context.ts";
5
+ import { expansionAt, type ExpandedAt } from "./acronym-expansion.ts";
6
+ import type { Detector, Finding, ProseDocument, Section, Sentence, Token } from "../plugin.ts";
4
7
 
5
8
  const PER = 1000;
6
9
 
@@ -91,13 +94,44 @@ const gramsOf = (doc: ProseDocument): Map<string, number> => {
91
94
  return counts;
92
95
  };
93
96
 
97
+ /** 数えたときと同じ形の文。char 単位では空白を詰めてから数えている。 */
98
+ const gramText = (sentence: Sentence, unit: ProseDocument["lengthUnit"]): string => compacted(sentence.text, unit).text;
99
+
100
+ /** 動詞と助動詞。言い回し(ることができます、it is important to)にはこれが入り、主題の名前(state and local tax)には入らない。 */
101
+ const PREDICATE_POS = new Set(["VERB", "AUX"]);
102
+
103
+ const NOUNS = new Set(["NOUN", "PROPN", "ADJ"]);
104
+
105
+ /**
106
+ * 英語で名詞のすぐ前の動詞は、名詞を飾る語(形容詞を挟んでも同じ)(the upcoming fiscal year、the Disclosing Party、the borrow checker)で、
107
+ * 述語ではない。日本語の名詞の前の動詞(〜にあるコンポーネント)は言い回しの一部になるので、word 単位だけで見る。
108
+ */
109
+ const modifiesNoun = (tokens: readonly Token[], index: number, unit: ProseDocument["lengthUnit"]): boolean =>
110
+ unit === "word" && tokens[index]?.pos === "VERB" && NOUNS.has(tokens[index + 1]?.pos ?? "");
111
+
112
+ /**
113
+ * 語句が文の中で占める範囲に、動詞か助動詞があるか。範囲で見る。文字で探すと、同じ文の別の所の動詞「use」が、語句の中の名詞「use」に当たる。
114
+ * 品詞が無ければ見分けられないので、これまでどおり言い回しとして数える。
115
+ */
116
+ const hasPredicate = (gram: string, sentence: Sentence, unit: ProseDocument["lengthUnit"]): boolean => {
117
+ if (sentence.tokens === undefined) return true;
118
+ const place = placeOf(compacted(sentence.text, unit), gram);
119
+ if (place === undefined) return true;
120
+ const [start, end] = [sentence.span.start + place.start, sentence.span.start + place.end];
121
+ const tokens = sentence.tokens;
122
+ return tokens.some((token, index) => PREDICATE_POS.has(token.pos) && token.span.start < end && start < token.span.end && !modifiesNoun(tokens, index, unit));
123
+ };
124
+
94
125
  export const ngramRepetition: Detector = (doc, options): Finding[] => {
95
126
  if (wordsOf(doc) < FLOOR[doc.lengthUnit]) return [];
127
+ const sentenceWith = (gram: string): Sentence | undefined => doc.sentences.find((sentence) => gramText(sentence, doc.lengthUnit).includes(gram));
96
128
  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));
129
+ .filter(([gram, count]) => count > options.limit && isPhrasing(gram, doc.lengthUnit))
130
+ .sort(([, left], [, right]) => right - left)
131
+ .map(([gram, count]) => ({ gram, count, at: sentenceWith(gram) }))
132
+ .find(({ gram, at }) => at === undefined || hasPredicate(gram, at, doc.lengthUnit));
133
+ if (worst === undefined) return [];
134
+ const at = worst.at;
101
135
  return [
102
136
  {
103
137
  rule: "ngram-repetition",
@@ -113,69 +147,77 @@ export const ngramRepetition: Detector = (doc, options): Finding[] => {
113
147
  /**
114
148
  * 略語が初出で展開されているか。「CI」だけでは、読む人によって指すものが違う。
115
149
  * 展開は「略語の直前か直後の括弧」か「括弧の中の略語」で書かれる。
150
+ *
151
+ * & で繋いだ大文字は 1 語として読む(ATT&CK、M&IE)。割ると CK や IE が別の略語に見える。
116
152
  */
117
- const ACRONYM = /\b[A-Z]{2,6}\b/gu;
118
-
119
- /** 読み手が説明なしで通じると見なしてよい語。展開すると逆に読みにくい。 */
120
- const COMMON = new Set([
121
- "OK",
122
- "NG",
123
- "URL",
124
- "API",
125
- "CSS",
126
- "HTML",
127
- "JSON",
128
- "YAML",
129
- "HTTP",
130
- "HTTPS",
131
- "PDF",
132
- "CPU",
133
- "GPU",
134
- "RAM",
135
- "USB",
136
- "AI",
137
- "ID",
138
- "FAQ",
139
- "PR",
140
- "OS",
141
- "CLI",
142
- "UI",
143
- "UX",
144
- "SQL",
145
- "CSV",
146
- "XML",
147
- "TODO",
148
- "NOTE",
149
- ]);
153
+ const EDGE_BEFORE = String.raw`(?<![A-Za-z0-9_&])`;
154
+ const EDGE_AFTER = String.raw`(?![A-Za-z0-9_&])`;
155
+ const ACRONYM = new RegExp(String.raw`${EDGE_BEFORE}(?:[A-Z]+(?:&[A-Z]+)+|[A-Z]{2,6})${EDGE_AFTER}`, "gu");
150
156
 
151
157
  /**
152
- * 展開は略語の**すぐ隣**にあるときだけ認める。
153
- * 60 文字も見ると、同じ文のどこかに括弧があるだけで「説明済み」になり、1 件も出なくなる。
158
+ * 大文字で書かれていても略語ではないもの。
154
159
  *
155
- * 認めるのは 2 つの形。どちらも実際によく書かれる。
156
- * CI(継続的インテグレーション) 略語のあとに括弧
157
- * Continuous Integration (CI) 括弧の中が略語
160
+ * 大文字だけの語が 3 つ以上、空白と引用符だけを挟んで続くのは強調(免責の定型文など)。
161
+ * 強調は読点や括弧をまたいで続くので(THE AUTHOR, THE COMPANY (IF ANY))、そういう続きを含む
162
+ * 「大文字以外の文字を含まない一続き」を丸ごと強調と見なす。小文字の語が挟まれば切れるので、
163
+ * 「SRE, SLO, MTTR」のような略語の並びは残る。引用符に包まれた大文字の句(“AS IS”)も強調。
158
164
  */
159
- const OPENS = /^\s*[((]/u;
160
- const CLOSES = /^\s*[))]/u;
161
- const OPENED = /[((]\s*$/u;
162
-
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));
169
- };
165
+ const CAPS_WORD = String.raw`[A-Z]+(?:&[A-Z]+)*`;
166
+ const SHOUTED_RUN = new RegExp(String.raw`${EDGE_BEFORE}${CAPS_WORD}(?:[\s"“”'‘’]+${CAPS_WORD}){2,}${EDGE_AFTER}`, "u");
167
+ const UNCASED_STRETCH = /(?:[A-Z]|\P{L})+/gu;
168
+ const QUOTED_CAPS = new RegExp(String.raw`["“'‘]${CAPS_WORD}(?:\s+${CAPS_WORD})+["”'’]`, "gu");
169
+
170
+ /** RFC 2119 の要件語。定義された普通の語で、1 語でも強調でも現れる。 */
171
+ const REQUIREMENT_WORD = new RegExp(
172
+ String.raw`${EDGE_BEFORE}(?:(?:MUST|SHALL|SHOULD)(?:\s+NOT)?|NOT\s+RECOMMENDED|REQUIRED|RECOMMENDED|MAY|OPTIONAL)${EDGE_AFTER}`,
173
+ "gu",
174
+ );
175
+
176
+ /** - か . で繋がり、数字を含む識別子(AC-2、MS.TEAMS.1.1v1)。大文字の部品は略語ではない。 */
177
+ const IDENTIFIER = /(?<![A-Za-z0-9_&.-])[A-Z0-9][A-Za-z0-9]*(?:[.-][A-Z0-9][A-Za-z0-9]*)+/gu;
178
+
179
+ /** Creative Commons のライセンス名。CC と BY は 1 つの名前の部品。 */
180
+ const LICENCE = new RegExp(String.raw`${EDGE_BEFORE}CC BY(?:-(?:SA|NC|ND))*${EDGE_AFTER}`, "gu");
181
+
182
+ type Span = { readonly start: number; readonly end: number };
183
+
184
+ const spanOf = (match: RegExpExecArray): Span => ({ start: match.index, end: match.index + match[0].length });
185
+
186
+ const spansOf = (text: string): Span[] => [
187
+ ...[...text.matchAll(UNCASED_STRETCH)].filter((match) => SHOUTED_RUN.test(match[0])).map(spanOf),
188
+ ...[...text.matchAll(IDENTIFIER)].filter((match) => /\d/u.test(match[0])).map(spanOf),
189
+ ...[QUOTED_CAPS, REQUIREMENT_WORD, LICENCE].flatMap((pattern) => [...text.matchAll(pattern)].map(spanOf)),
190
+ ...notAcronymSpans(text),
191
+ ];
192
+
193
+ type AcronymHit = { readonly word: string; readonly hit: Hit };
194
+
195
+ const acronymsOf = (doc: ProseDocument): AcronymHit[] =>
196
+ doc.sentences.flatMap((sentence) => {
197
+ const excluded = spansOf(sentence.text);
198
+ return [...sentence.text.matchAll(ACRONYM)]
199
+ .filter((match) => !excluded.some((span) => span.start <= match.index && match.index + match[0].length <= span.end))
200
+ .map((match) => ({ word: match[0], hit: { sentence, offset: sentence.span.start + match.index } }));
201
+ });
202
+
203
+ /**
204
+ * どこか 1 か所で展開してあればよい。初出が節の見出し代わりの語(「5.3. DPA.」)で、
205
+ * 展開がその直後の文にあることが契約書では普通にある。見るのは語として現れた所だけ(CISA の中の CI は見ない)。
206
+ */
207
+ const isExpanded = (body: string, acronym: string, expandedAt: ExpandedAt): boolean =>
208
+ [...body.matchAll(new RegExp(`${EDGE_BEFORE}${acronym}${EDGE_AFTER}`, "gu"))].some((match) => expandedAt(body, acronym, match.index));
209
+
210
+ const patternsOf = (doc: ProseDocument, id: string): string[] => (doc.lexicons[id] ?? []).map((entry) => entry.pattern);
170
211
 
171
212
  export const undefinedAcronym: Detector = (doc, options): Finding[] => {
172
213
  const body = bodyOf(doc);
214
+ const common = new Set((options.lexicon ?? []).map((entry) => entry.pattern));
173
215
  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);
216
+ acronymsOf(doc).forEach(({ word, hit }) => {
217
+ if (!common.has(word) && !seen.has(word)) seen.set(word, hit);
177
218
  });
178
- const bare = [...seen.entries()].filter(([acronym]) => !isExpanded(body, acronym));
219
+ const expandedAt = expansionAt({ markers: patternsOf(doc, "definition-marker"), verbs: patternsOf(doc, "definition-verb") });
220
+ const bare = [...seen.entries()].filter(([acronym]) => !isExpanded(body, acronym, expandedAt));
179
221
  if (bare.length < options.limit) return [];
180
222
  return bare.map(([acronym, hit]) => ({
181
223
  rule: "undefined-acronym",
@@ -193,9 +235,29 @@ export const undefinedAcronym: Detector = (doc, options): Finding[] => {
193
235
  */
194
236
  const CONCRETE = /\d|`|https?:\/\//u;
195
237
 
238
+ /**
239
+ * 見出しのない導入部は、書き出しで呼ぶ。文書の言葉なので、message がどの言語でも混ざらない。
240
+ * 決まった呼び名(「この節」)を置くと、英語の文書の message に日本語が入る。
241
+ */
242
+ const OPENING = { word: 4, char: 12 };
243
+
244
+ const openingOf = (sentence: Sentence | undefined, unit: ProseDocument["lengthUnit"]): string => {
245
+ const text = sentence === undefined ? "" : proseText(sentence);
246
+ const pieces = unit === "word" ? text.split(" ") : Array.from(text);
247
+ const joiner = unit === "word" ? " " : "";
248
+ return pieces.length <= OPENING[unit] ? pieces.join(joiner) : `${pieces.slice(0, OPENING[unit]).join(joiner)}…`;
249
+ };
250
+
251
+ /**
252
+ * 形態素解析が数と読んだ語(UD の NumType=Card)。漢数字の「二割」「十五分」も、算用数字と同じ具体的な数。
253
+ * 言い回しの「二人三脚」「一人ひとり」は、辞書が一語として持つので数にならない。品詞が無ければ、数字だけを見る。
254
+ */
255
+ const hasNumeral = (section: Section): boolean =>
256
+ section.sentences.some((sentence) => (sentence.tokens ?? []).some((token) => token.features?.["NumType"] === "Card"));
257
+
196
258
  export const concreteEvidence: Detector = (doc, options): Finding[] => {
197
259
  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)));
260
+ const empty = sections.filter((section) => !CONCRETE.test(doc.source.slice(section.span.start, section.span.end)) && !hasNumeral(section));
199
261
  if (empty.length < options.limit) return [];
200
262
  return empty.map((section) => ({
201
263
  rule: "concrete-evidence-density",
@@ -204,7 +266,7 @@ export const concreteEvidence: Detector = (doc, options): Finding[] => {
204
266
  column: 0,
205
267
  quote: section.heading.length > 0 ? section.heading : (section.sentences[0]?.text.trim() ?? ""),
206
268
  values: {
207
- word: section.heading.length > 0 ? section.heading : "この節",
269
+ word: section.heading.length > 0 ? section.heading : openingOf(section.sentences[0], doc.lengthUnit),
208
270
  count: empty.length,
209
271
  total: sections.length,
210
272
  limit: options.limit,
@@ -1,5 +1,7 @@
1
1
  import { charLength, lengthOf } from "../measure.ts";
2
- import type { BulletList, Detector, Finding, Paragraph, ProseDocument, Section } from "../plugin.ts";
2
+ import type { BulletList, Detector, Finding, Paragraph, ProseDocument, Section, Span } from "../plugin.ts";
3
+ import { dateStampIndexes, stampCandidates } from "../date-stamp.ts";
4
+ import { inDocumentOrder } from "../structure/issues.ts";
3
5
 
4
6
  /**
5
7
  * ばらつきは変動係数(標準偏差 ÷ 平均)で測る。
@@ -90,6 +92,21 @@ export const ruleOfThree: Detector = (doc, options): Finding[] => {
90
92
  ];
91
93
  };
92
94
 
95
+ const dateSpans = (doc: ProseDocument): Span[] =>
96
+ doc.structure === undefined ? [] : inDocumentOrder(doc.structure).flatMap((node) => (node.kind === "date" ? [node.span] : []));
97
+
98
+ /** 更新日の刻印(「最終更新日:」「2025年6月20日」)は前置きに数えない。木を作るのは、数えて上限を超えたときだけ。 */
99
+ const withoutDateStamps = (doc: ProseDocument, paragraphs: readonly Paragraph[]): Paragraph[] => {
100
+ const labels = (doc.lexicons["date-stamp-label"] ?? []).map((entry) => entry.pattern);
101
+ const candidates = stampCandidates(
102
+ doc.source,
103
+ paragraphs.map((paragraph) => paragraph.span),
104
+ dateSpans(doc),
105
+ );
106
+ const stamps = dateStampIndexes(candidates, labels);
107
+ return paragraphs.filter((_, index) => !stamps.has(index));
108
+ };
109
+
93
110
  /**
94
111
  * 本題に入るまでが長い。業務文書では、読み手は結論を探しに来ている。
95
112
  *
@@ -99,7 +116,9 @@ export const ruleOfThree: Detector = (doc, options): Finding[] => {
99
116
  export const preambleLength: Detector = (doc, options): Finding[] => {
100
117
  const body = doc.sections.find((section) => section.depth >= 2);
101
118
  if (body === undefined) return [];
102
- const before = doc.paragraphs.filter((paragraph) => paragraph.span.start < body.span.start);
119
+ const all = doc.paragraphs.filter((paragraph) => paragraph.span.start < body.span.start);
120
+ if (all.length <= options.limit) return [];
121
+ const before = withoutDateStamps(doc, all);
103
122
  if (before.length <= options.limit) return [];
104
123
  const first = before[0];
105
124
  return first === undefined