chaffjs 0.16.0 → 0.17.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 (187) hide show
  1. package/dist/calendar-number.d.ts +7 -2
  2. package/dist/calendar-number.d.ts.map +1 -1
  3. package/dist/calendar-number.js +10 -3
  4. package/dist/calendar-number.js.map +1 -1
  5. package/dist/closing-numbers.d.ts +20 -0
  6. package/dist/closing-numbers.d.ts.map +1 -0
  7. package/dist/closing-numbers.js +56 -0
  8. package/dist/closing-numbers.js.map +1 -0
  9. package/dist/commands/test.d.ts.map +1 -1
  10. package/dist/commands/test.js +11 -9
  11. package/dist/commands/test.js.map +1 -1
  12. package/dist/detectors/acronym-context.d.ts +2 -0
  13. package/dist/detectors/acronym-context.d.ts.map +1 -1
  14. package/dist/detectors/acronym-context.js +13 -0
  15. package/dist/detectors/acronym-context.js.map +1 -1
  16. package/dist/detectors/acronym-expansion.d.ts +2 -0
  17. package/dist/detectors/acronym-expansion.d.ts.map +1 -1
  18. package/dist/detectors/acronym-expansion.js +104 -1
  19. package/dist/detectors/acronym-expansion.js.map +1 -1
  20. package/dist/detectors/agreement-slip.d.ts +32 -0
  21. package/dist/detectors/agreement-slip.d.ts.map +1 -0
  22. package/dist/detectors/agreement-slip.js +137 -0
  23. package/dist/detectors/agreement-slip.js.map +1 -0
  24. package/dist/detectors/content-morphemes.d.ts +4 -0
  25. package/dist/detectors/content-morphemes.d.ts.map +1 -0
  26. package/dist/detectors/content-morphemes.js +12 -0
  27. package/dist/detectors/content-morphemes.js.map +1 -0
  28. package/dist/detectors/en-shape.d.ts +0 -1
  29. package/dist/detectors/en-shape.d.ts.map +1 -1
  30. package/dist/detectors/en-shape.js +0 -235
  31. package/dist/detectors/en-shape.js.map +1 -1
  32. package/dist/detectors/heading-echo.d.ts.map +1 -1
  33. package/dist/detectors/heading-echo.js +12 -18
  34. package/dist/detectors/heading-echo.js.map +1 -1
  35. package/dist/detectors/heading-overlap.d.ts +13 -0
  36. package/dist/detectors/heading-overlap.d.ts.map +1 -0
  37. package/dist/detectors/heading-overlap.js +52 -0
  38. package/dist/detectors/heading-overlap.js.map +1 -0
  39. package/dist/detectors/index.d.ts.map +1 -1
  40. package/dist/detectors/index.js +6 -1
  41. package/dist/detectors/index.js.map +1 -1
  42. package/dist/detectors/letter-joined-name.d.ts +13 -0
  43. package/dist/detectors/letter-joined-name.d.ts.map +1 -0
  44. package/dist/detectors/letter-joined-name.js +11 -0
  45. package/dist/detectors/letter-joined-name.js.map +1 -0
  46. package/dist/detectors/lexicon.d.ts.map +1 -1
  47. package/dist/detectors/lexicon.js +5 -2
  48. package/dist/detectors/lexicon.js.map +1 -1
  49. package/dist/detectors/orthography.js +2 -2
  50. package/dist/detectors/orthography.js.map +1 -1
  51. package/dist/detectors/oxford-comma.d.ts +3 -0
  52. package/dist/detectors/oxford-comma.d.ts.map +1 -0
  53. package/dist/detectors/oxford-comma.js +340 -0
  54. package/dist/detectors/oxford-comma.js.map +1 -0
  55. package/dist/detectors/signals.d.ts.map +1 -1
  56. package/dist/detectors/signals.js +29 -10
  57. package/dist/detectors/signals.js.map +1 -1
  58. package/dist/detectors/stacked-hedge.d.ts +0 -7
  59. package/dist/detectors/stacked-hedge.d.ts.map +1 -1
  60. package/dist/detectors/stacked-hedge.js +6 -3
  61. package/dist/detectors/stacked-hedge.js.map +1 -1
  62. package/dist/detectors/stray-space.d.ts +7 -0
  63. package/dist/detectors/stray-space.d.ts.map +1 -0
  64. package/dist/detectors/stray-space.js +34 -0
  65. package/dist/detectors/stray-space.js.map +1 -0
  66. package/dist/detectors/structure.d.ts +3 -1
  67. package/dist/detectors/structure.d.ts.map +1 -1
  68. package/dist/detectors/structure.js +4 -2
  69. package/dist/detectors/structure.js.map +1 -1
  70. package/dist/document.d.ts.map +1 -1
  71. package/dist/document.js +15 -3
  72. package/dist/document.js.map +1 -1
  73. package/dist/genre-parse.d.ts +5 -1
  74. package/dist/genre-parse.d.ts.map +1 -1
  75. package/dist/genre-parse.js +16 -3
  76. package/dist/genre-parse.js.map +1 -1
  77. package/dist/genre-suggest.d.ts.map +1 -1
  78. package/dist/genre-suggest.js +15 -1
  79. package/dist/genre-suggest.js.map +1 -1
  80. package/dist/line-paragraphs.d.ts.map +1 -1
  81. package/dist/line-paragraphs.js +3 -1
  82. package/dist/line-paragraphs.js.map +1 -1
  83. package/dist/mask.d.ts.map +1 -1
  84. package/dist/mask.js +2 -11
  85. package/dist/mask.js.map +1 -1
  86. package/dist/number-name.js +1 -1
  87. package/dist/number-name.js.map +1 -1
  88. package/dist/opening-line.d.ts +3 -0
  89. package/dist/opening-line.d.ts.map +1 -0
  90. package/dist/opening-line.js +14 -0
  91. package/dist/opening-line.js.map +1 -0
  92. package/dist/orthography.d.ts +5 -1
  93. package/dist/orthography.d.ts.map +1 -1
  94. package/dist/orthography.js +7 -1
  95. package/dist/orthography.js.map +1 -1
  96. package/dist/paragraph-length.d.ts +7 -0
  97. package/dist/paragraph-length.d.ts.map +1 -0
  98. package/dist/paragraph-length.js +10 -0
  99. package/dist/paragraph-length.js.map +1 -0
  100. package/dist/plugin.d.ts +6 -0
  101. package/dist/plugin.d.ts.map +1 -1
  102. package/dist/quoted-span.d.ts +0 -2
  103. package/dist/quoted-span.d.ts.map +1 -1
  104. package/dist/quoted-span.js +1 -1
  105. package/dist/quoted-span.js.map +1 -1
  106. package/dist/rule-load.d.ts.map +1 -1
  107. package/dist/rule-load.js +8 -0
  108. package/dist/rule-load.js.map +1 -1
  109. package/dist/run-semantic.d.ts +3 -1
  110. package/dist/run-semantic.d.ts.map +1 -1
  111. package/dist/run-semantic.js +8 -3
  112. package/dist/run-semantic.js.map +1 -1
  113. package/dist/run.d.ts +2 -0
  114. package/dist/run.d.ts.map +1 -1
  115. package/dist/run.js +4 -1
  116. package/dist/run.js.map +1 -1
  117. package/dist/semantic.d.ts +1 -1
  118. package/dist/semantic.d.ts.map +1 -1
  119. package/dist/semantic.js +10 -3
  120. package/dist/semantic.js.map +1 -1
  121. package/dist/span-merge.d.ts +4 -0
  122. package/dist/span-merge.d.ts.map +1 -0
  123. package/dist/span-merge.js +12 -0
  124. package/dist/span-merge.js.map +1 -0
  125. package/dist/speaker-labels.d.ts +4 -0
  126. package/dist/speaker-labels.d.ts.map +1 -0
  127. package/dist/speaker-labels.js +106 -0
  128. package/dist/speaker-labels.js.map +1 -0
  129. package/dist/stray-space.d.ts +17 -0
  130. package/dist/stray-space.d.ts.map +1 -0
  131. package/dist/stray-space.js +66 -0
  132. package/dist/stray-space.js.map +1 -0
  133. package/dist/structure/relative-resolve.d.ts.map +1 -1
  134. package/dist/structure/relative-resolve.js +26 -20
  135. package/dist/structure/relative-resolve.js.map +1 -1
  136. package/dist/team-names.d.ts.map +1 -1
  137. package/dist/team-names.js +11 -14
  138. package/dist/team-names.js.map +1 -1
  139. package/genres.yaml +34 -2
  140. package/package.json +3 -3
  141. package/rules/agreement-slip.yaml +36 -0
  142. package/rules/empty-conclusion.yaml +1 -0
  143. package/rules/heading-echo.yaml +1 -0
  144. package/rules/max-paragraph-length.yaml +9 -2
  145. package/rules/max-sentence-length.yaml +4 -0
  146. package/rules/oxford-comma-consistency.yaml +1 -1
  147. package/rules/stray-space.yaml +38 -0
  148. package/rules/undefined-acronym.yaml +1 -0
  149. package/src/calendar-number.ts +16 -7
  150. package/src/closing-numbers.ts +72 -0
  151. package/src/commands/test.ts +19 -14
  152. package/src/detectors/acronym-context.ts +16 -0
  153. package/src/detectors/acronym-expansion.ts +132 -1
  154. package/src/detectors/agreement-slip.ts +191 -0
  155. package/src/detectors/content-morphemes.ts +19 -0
  156. package/src/detectors/en-shape.ts +0 -269
  157. package/src/detectors/heading-echo.ts +12 -20
  158. package/src/detectors/heading-overlap.ts +65 -0
  159. package/src/detectors/index.ts +6 -1
  160. package/src/detectors/letter-joined-name.ts +15 -0
  161. package/src/detectors/lexicon.ts +6 -2
  162. package/src/detectors/orthography.ts +2 -2
  163. package/src/detectors/oxford-comma.ts +402 -0
  164. package/src/detectors/signals.ts +32 -9
  165. package/src/detectors/stacked-hedge.ts +6 -3
  166. package/src/detectors/stray-space.ts +40 -0
  167. package/src/detectors/structure.ts +10 -2
  168. package/src/document.ts +25 -4
  169. package/src/genre-parse.ts +30 -5
  170. package/src/genre-suggest.ts +15 -2
  171. package/src/line-paragraphs.ts +4 -1
  172. package/src/mask.ts +2 -11
  173. package/src/number-name.ts +1 -1
  174. package/src/opening-line.ts +17 -0
  175. package/src/orthography.ts +10 -2
  176. package/src/paragraph-length.ts +9 -0
  177. package/src/plugin.ts +6 -0
  178. package/src/quoted-span.ts +1 -1
  179. package/src/rule-load.ts +8 -0
  180. package/src/run-semantic.ts +28 -20
  181. package/src/run.ts +5 -1
  182. package/src/semantic.ts +11 -3
  183. package/src/span-merge.ts +13 -0
  184. package/src/speaker-labels.ts +129 -0
  185. package/src/stray-space.ts +81 -0
  186. package/src/structure/relative-resolve.ts +34 -19
  187. package/src/team-names.ts +13 -13
@@ -0,0 +1,15 @@
1
+ /**
2
+ * 一文字を - で語の前に繋いだ名前(J-STAGE、X-RAY、T-Mobile、e-Gov)。前の一文字が新しい名前を作るので、中の大文字の語は
3
+ * 略語ではない(J-STAGE は STAGE の説明を待たない)。後ろに一文字を繋いだ語(ITU-T、GOODS-S)は略語の一種で、略語は
4
+ * 説明を待つまま。部品が二つとも二文字以上(RT-PCR)なら繋いだ略語のまま。
5
+ */
6
+
7
+ type Span = { readonly start: number; readonly end: number };
8
+
9
+ // 語を空白で区切る文字(é、α、分けて書いた e と ́)や数字が接していれば長い語の一部。仮名や漢字は接していてよい(論文はJ-STAGEで)。
10
+ const SPACED_WORD_CHAR = String.raw`\p{Script=Latin}\p{Script=Greek}\p{Script=Cyrillic}\p{M}\p{N}_&`;
11
+ const LETTER_JOINED_NAME = new RegExp(String.raw`(?<![${SPACED_WORD_CHAR}.-])[A-Za-z]-[A-Za-z]{2,}(?![${SPACED_WORD_CHAR}-]|\.[A-Za-z0-9])`, "gu");
12
+
13
+ /** 文の中の、一文字を - で語の前に繋いだ名前の範囲。 */
14
+ export const letterJoinedNameSpans = (text: string): Span[] =>
15
+ [...text.matchAll(LETTER_JOINED_NAME)].map((match) => ({ start: match.index, end: match.index + match[0].length }));
@@ -1,4 +1,5 @@
1
1
  import { joinWords } from "./word-list.ts";
2
+ import { straightApostrophes } from "../orthography.ts";
2
3
  import { wordsOf } from "./structure.ts";
3
4
  import type { Detector, Finding, Lexicon, LexiconEntry, ProseDocument, Sentence, Span, Token } from "../plugin.ts";
4
5
  import { entryIn, entryOpens, entryRanges, type TokenRange } from "./lexicon-match.ts";
@@ -227,9 +228,12 @@ export const aiTell: Detector = (doc, options): Finding[] => {
227
228
  */
228
229
  const containsWord = (text: string, word: string): boolean => new RegExp(`\\b${word.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&")}\\b`, "u").test(text);
229
230
 
231
+ /** アポストロフィの形(don’t と don't)は短縮形かどうかを変えない。違うのは印刷の形で、文体ではない。 */
232
+ const comparableText = (sentence: Sentence): string => straightApostrophes(sentence.text).toLowerCase();
233
+
230
234
  export const contractionMix: Detector = (doc, options): Finding[] => {
231
235
  const pairs = (options.lexicon ?? []).flatMap((entry) => (entry.instead_of === undefined ? [] : [{ short: entry.pattern, long: entry.instead_of }]));
232
- const body = doc.sentences.map((sentence) => sentence.text.toLowerCase()).join(" ");
236
+ const body = doc.sentences.map(comparableText).join(" ");
233
237
  const used = pairs.filter((pair) => containsWord(body, pair.short.toLowerCase()));
234
238
  const spelled = pairs.filter((pair) => containsWord(body, pair.long.toLowerCase()));
235
239
  // 片方しか無ければ一貫している。両方あるときだけ、少数派を指摘する。
@@ -239,7 +243,7 @@ export const contractionMix: Detector = (doc, options): Finding[] => {
239
243
  if (few.length > options.limit) return [];
240
244
  const wanted = few.map((pair) => (minorityIsShort ? pair.short : pair.long).toLowerCase());
241
245
  const hits = doc.sentences.flatMap((sentence) => {
242
- const text = sentence.text.toLowerCase();
246
+ const text = comparableText(sentence);
243
247
  const matched = wanted.find((word) => containsWord(text, word));
244
248
  return matched === undefined ? [] : [{ sentence, matched }];
245
249
  });
@@ -1,5 +1,5 @@
1
1
  import type { Detector, Finding, ProseDocument, Sentence, Span } from "../plugin.ts";
2
- import { calendarRuns, type CalendarRun, type CalendarUnits } from "../calendar-number.ts";
2
+ import { calendarRuns, calendarUnitsOf, type CalendarRun, type CalendarUnits } from "../calendar-number.ts";
3
3
  import { latinBoundaries, minorityStyle, occurrencesOutside, type Boundary, type SpacingKind } from "../orthography.ts";
4
4
  import { isWithinAny, quotedSpans } from "../quoted-span.ts";
5
5
  import { digitRunAround, isNumberName, sequenceLabelStarts } from "../number-name.ts";
@@ -73,7 +73,7 @@ export const latinSpacing: Detector = (doc, options): Finding[] => {
73
73
  const context: NumberContext = {
74
74
  sequence: sequenceLabelStarts(doc.prose ?? doc.source),
75
75
  topUnits: patternsOf(doc, "prefecture-unit"),
76
- calendar: { chained: patternList(doc, "date-time-unit"), positional: patternsOf(doc, "calendar-unit"), year: patternsOf(doc, "calendar-year-unit") },
76
+ calendar: calendarUnitsOf(doc),
77
77
  };
78
78
  const located: Located[] = doc.sentences.flatMap((sentence) => {
79
79
  const quoted = quotedSpans(sentence.text);
@@ -0,0 +1,402 @@
1
+ import type { Detector, Finding, Lexicon, Token } from "../plugin.ts";
2
+
3
+ const LIST_CONJUNCTION = new Set(["and", "or"]);
4
+
5
+ const isComma = (token: Token): boolean => token.surface === ",";
6
+
7
+ const commaBefore = (tokens: readonly Token[], at: number): boolean => tokens[at - 1]?.surface === ",";
8
+
9
+ /** 並列はこれをまたがない。セミコロンの前後は別の節。 */
10
+ const CLAUSE_BREAK = new Set([";", ":", "—"]);
11
+
12
+ /** 各 token の前で閉じていない括弧の数。括弧の中の読点(external users (e.g., guests), and ...)は外の並列を切らない。 */
13
+ const PAREN_STEP: Readonly<Record<string, number>> = { "(": 1, ")": -1 };
14
+
15
+ const depthsOf = (tokens: readonly Token[]): number[] =>
16
+ tokens.reduce<{ depths: number[]; open: number }>(
17
+ (acc, token) => ({ depths: [...acc.depths, acc.open], open: Math.max(0, acc.open + (PAREN_STEP[token.surface] ?? 0)) }),
18
+ { depths: [], open: 0 },
19
+ ).depths;
20
+
21
+ /**
22
+ * 並びを読むための語彙表。participle は解析器が分詞と読まないが読点のあとで分詞の句を始める語(meaning)、
23
+ * example は例を挙げる句(such as, e.g.)、pair は 2 つだけを結ぶ語とその接続詞(between and / either or)。
24
+ */
25
+ type ListWords = { readonly participle: ReadonlySet<string>; readonly example: Lexicon; readonly pair: ReadonlySet<string> };
26
+
27
+ type Clause = { readonly tokens: readonly Token[]; readonly depths: readonly number[]; readonly words: ListWords };
28
+
29
+ /** 冠詞や引用符を飛ばした、項目の頭の品詞。the parser と an exporter と samples を同じ形と見る。 */
30
+ const NOMINAL = new Set(["NOUN", "PROPN", "PRON", "NUM", "ADJ"]);
31
+
32
+ const isContent = (token: Token): boolean => token.pos !== "DET" && token.pos !== "PUNCT" && token.pos !== "X";
33
+
34
+ const shapeOf = (item: readonly Token[]): string | undefined => {
35
+ const head = item.find(isContent);
36
+ if (head === undefined) return undefined;
37
+ return NOMINAL.has(head.pos) ? "NOMINAL" : head.pos;
38
+ };
39
+
40
+ const VERBAL = new Set(["VERB", "AUX"]);
41
+
42
+ /** 主語と述語のある項目。the team fixed the bug / these are crucial。gets us more は述語だけ。 */
43
+ const isClause = (item: readonly Token[]): boolean => {
44
+ const first = item.find((token) => token.pos !== "PUNCT" && token.pos !== "X");
45
+ if (first === undefined || !(first.pos === "DET" || NOMINAL.has(first.pos))) return false;
46
+ return item.some((token) => token !== first && VERBAL.has(token.pos));
47
+ };
48
+
49
+ /** 節の頭から and / or の手前までを、同じ深さの読点で項目に切る。Oxford comma の読点のあとは空なので項目にならない。 */
50
+ const itemsBefore = (clause: Clause, at: number): Token[][] => {
51
+ const level = clause.depths[at] ?? 0;
52
+ const start = clause.tokens.slice(0, at).findLastIndex((token) => CLAUSE_BREAK.has(token.surface)) + 1;
53
+ return clause.tokens
54
+ .slice(start, at)
55
+ .reduce<Token[][]>(
56
+ (items, token, offset) => {
57
+ if (isComma(token) && clause.depths[start + offset] === level) return [...items, []];
58
+ return [...items.slice(0, -1), [...(items.at(-1) ?? []), token]];
59
+ },
60
+ [[]],
61
+ )
62
+ .filter((item) => item.length > 0);
63
+ };
64
+
65
+ /**
66
+ * and / or で始まる項目で、前の並びは閉じている(offering, giving, or receiving, directly or indirectly)。
67
+ * 次の並びはその項目から始まる。項目が動詞で始まれば、その目的語から(Develop, maintain, and track courses, materials and events)。
68
+ */
69
+ const afterClosedList = (items: readonly Token[][]): Token[][] => {
70
+ const closed = items.findLastIndex((item) => LIST_CONJUNCTION.has(openingOf(item)?.surface.toLowerCase() ?? ""));
71
+ const closing = items[closed];
72
+ if (closing === undefined) return [...items];
73
+ const body = closing.slice(closing.findIndex((token) => LIST_CONJUNCTION.has(token.surface.toLowerCase())) + 1);
74
+ const head = body.findIndex(isContent);
75
+ const rest = VERBAL.has(body[head]?.pos ?? "") ? body.slice(head + 1) : body;
76
+ // 項目が残らなければ、後ろは新しい句の頭。導入の句(directly or indirectly,)を外す。
77
+ return rest.some(isContent) ? [rest, ...items.slice(closed + 1)] : [...withoutLead(items.slice(closed + 1))];
78
+ };
79
+
80
+ /** and / or の後ろの項目。次の読点か節の切れ目まで。 */
81
+ const itemAfter = (tokens: readonly Token[], at: number): Token[] => {
82
+ const end = tokens.findIndex((token, index) => index > at && (isComma(token) || CLAUSE_BREAK.has(token.surface)));
83
+ return tokens.slice(at + 1, end === -1 ? undefined : end);
84
+ };
85
+
86
+ /**
87
+ * 導入の句(After the review, / If it fails, / Finally, / To test it, / Based on the review,)は並列の項目ではない。
88
+ * 節の最初の項目が前置詞・接続詞・副詞・to・過去分詞で始まり、次の項目と頭の形が違えば外す。
89
+ * Quickly, quietly and carefully は残る。For managers, engineers and designers, も外れるが、前置詞のあとの名詞を
90
+ * 項目と読むと、Over this period, the subcommittees and the full committee ... を並びと誤る。こちらのほうが多い。
91
+ */
92
+ const LEAD_POS = new Set(["ADP", "SCONJ", "ADV", "PART"]);
93
+
94
+ const isParticiple = (token: Token): boolean => token.features?.["VerbForm"] === "Part";
95
+
96
+ const openingOf = (item: readonly Token[]): Token | undefined => item.find((token) => token.pos !== "PUNCT" && token.pos !== "X");
97
+
98
+ const openingKind = (item: readonly Token[]): string | undefined => {
99
+ const opening = openingOf(item);
100
+ return opening !== undefined && isParticiple(opening) ? "PARTICIPLE" : shapeOf(item);
101
+ };
102
+
103
+ const withoutLead = (items: readonly Token[][]): readonly Token[][] => {
104
+ const [first, second] = items;
105
+ const opening = first === undefined ? undefined : openingOf(first);
106
+ if (first === undefined || second === undefined || opening === undefined) return items;
107
+ if (!LEAD_POS.has(opening.pos) && !isParticiple(opening)) return items;
108
+ return openingKind(first) === openingKind(second) ? items : items.slice(1);
109
+ };
110
+
111
+ /**
112
+ * 形容詞のあとの読点は、名詞の前で形容詞を重ねているだけのことが多い(the long, winding bridge)。
113
+ * 次の項目が名詞で終わるならつなげ直す。形容詞そのものの並び(quick, cheap and reliable)は切ったまま。
114
+ */
115
+ const lastContent = (item: readonly Token[]): Token | undefined => item.findLast(isContent);
116
+
117
+ const joinAdjectives = (items: readonly Token[][]): Token[][] =>
118
+ items.reduce<Token[][]>((joined, item) => {
119
+ const previous = joined.at(-1);
120
+ const stacked = previous !== undefined && lastContent(previous)?.pos === "ADJ" && lastContent(item)?.pos !== "ADJ";
121
+ if (stacked) joined[joined.length - 1] = [...previous, ...item];
122
+ else joined.push(item);
123
+ return joined;
124
+ }, []);
125
+
126
+ /**
127
+ * 節を並べるなら、どの項目も節。Additionally, others can learn, and the mistake is rarer. の
128
+ * Additionally は項目ではなく、and の前の読点は節をつなぐ読点。名詞の並びは最初の項目に前置き
129
+ * (We shipped the parser)を抱えるので、この確かめは節の並びだけにする。
130
+ * and の後ろだけが節なのは主語の並び(The parser, the renderer and the exporter shipped.)なので外さない。
131
+ */
132
+ const clausesAgree = (items: readonly Token[][], after: readonly Token[]): boolean => {
133
+ const last = items.at(-1);
134
+ if (last === undefined || !isClause(last)) return true;
135
+ return isClause(after) && items.every(isClause);
136
+ };
137
+
138
+ /**
139
+ * 動詞で始まる項目を並べるなら、どの項目にも動詞がある。The scope, in contrast, is larger, and covers ... の
140
+ * The scope は項目ではなく、後ろの述語の主語。
141
+ */
142
+ const hasVerb = (item: readonly Token[]): boolean => item.some((token) => VERBAL.has(token.pos));
143
+
144
+ const predicatesAgree = (items: readonly Token[][], shape: string): boolean => !VERBAL.has(shape) || items.every(hasVerb);
145
+
146
+ /** -ing 形(VerbForm=Ger)と過去分詞(Part)。filling the gaps と sentenced to prison は並べても並列にならない。 */
147
+ const VERB_FORMS = new Set(["Ger", "Part"]);
148
+
149
+ const verbFormOf = (token: Token): string | undefined => {
150
+ const form = token.features?.["VerbForm"];
151
+ return form !== undefined && VERB_FORMS.has(form) ? form : undefined;
152
+ };
153
+
154
+ /** 項目の頭の分詞とその形。副詞は飛ばす(originally written)。引用符の中の語(‘modelling’)は語の例なので分詞と読まない。 */
155
+ const participleOpening = (item: readonly Token[], words: ReadonlySet<string>): { readonly at: number; readonly form: string } | undefined => {
156
+ const at = item.findIndex((token) => token.pos !== "PUNCT" && token.pos !== "ADV");
157
+ const opening = item[at];
158
+ if (opening === undefined) return undefined;
159
+ const form = words.has(opening.surface.toLowerCase()) ? "Ger" : verbFormOf(opening);
160
+ return form === undefined ? undefined : { at, form };
161
+ };
162
+
163
+ const hasParticiple = (item: readonly Token[], form: string): boolean => item.some((token) => verbFormOf(token) === form);
164
+
165
+ /**
166
+ * 最初より後ろの項目が分詞で始まるとき。最初の項目にも同じ形の分詞があれば分詞の並び
167
+ * (are hiring engineers, buying laptops and renting desks / Tested, reviewed and approved)。後ろの項目が動詞で始まれば述語の並びで、
168
+ * 分詞は解析器の読み違い(the jury, cross-examined the witnesses, presented witnesses, and made)。そうでなければ分詞の句
169
+ * (from Latin, meaning ship or boat / the judge, sitting without a jury, and sentenced)で、並びがあるならその句の中から始まる。
170
+ * 分詞より前の項目と分詞そのものは項目ではない(programs, including the office, the desk and the team)。句の中の項目の形が揃わなければ
171
+ * 句は挿入で、並びは句の後ろだけ(relief, including an injunction, in any court ... and without / including before the date, to a
172
+ * Recipient ... “confidential”, “proprietary”, or the like)。
173
+ */
174
+ /** 解析器は過去形と過去分詞(fixed)を見分けられない。動詞のある項目のあとの「過去分詞」の並びは、述語の並びと読む。 */
175
+ const pastPredicates = (first: readonly Token[], form: string, after: readonly Token[], words: ReadonlySet<string>): boolean =>
176
+ form === "Part" && participleOpening(after, words)?.form === "Part" && hasVerb(first);
177
+
178
+ const fromParticiplePhrase = (items: readonly Token[][], after: readonly Token[], words: ReadonlySet<string>): readonly Token[][] => {
179
+ const at = items.findIndex((item, index) => index > 0 && participleOpening(item, words) !== undefined);
180
+ const phrase = items[at];
181
+ const first = items[0];
182
+ const opening = phrase === undefined ? undefined : participleOpening(phrase, words);
183
+ if (first === undefined || phrase === undefined || opening === undefined) return items;
184
+ const rest = items.slice(at + 1);
185
+ const predicates = rest.some((item) => VERBAL.has(shapeOf(item) ?? "")) || pastPredicates(first, opening.form, after, words);
186
+ if (hasParticiple(first, opening.form) || predicates) return items;
187
+ const inPhrase = withoutLead([phrase.slice(opening.at + 1), ...rest].filter((item) => item.some(isContent)));
188
+ return new Set(inPhrase.map(shapeOf)).size > 1 ? rest : inPhrase;
189
+ };
190
+
191
+ /** 大文字小文字も比べる。頭文字の E.G. Evans / I.E. Evans は e.g. / i.e. ではない。文頭の句の前には項目が無いので、大文字で始まる句は要らない。 */
192
+ const sameSurfaces = (tokens: readonly Token[], words: readonly Token[]): boolean =>
193
+ tokens.length === words.length && words.every((word, index) => tokens[index]?.surface === word.surface);
194
+
195
+ /** 項目の中で、and / or と同じ深さにある最後の例の句(such as / e.g.)の直後の位置。無ければ -1。 */
196
+ const exampleEnd = (clause: Clause, level: number, item: readonly Token[]): number =>
197
+ item.reduce((end, token, at) => {
198
+ const words = clause.words.example.find(({ tokens = [] }) => tokens.length > 0 && sameSurfaces(item.slice(at, at + tokens.length), tokens))?.tokens ?? [];
199
+ return words.length > 0 && clause.depths[clause.tokens.indexOf(token)] === level ? at + words.length : end;
200
+ }, -1);
201
+
202
+ /**
203
+ * 例の句(such as / including / e.g.)のあとが並び。句の前の項目は並びではない(the screen lock on your phone, such as a PIN or
204
+ * phone-based fingerprint は 2 つ)。句の中の項目の形が揃わなければ句で切らない(materials such as green steel, utilizing ..., and
205
+ * adopting ... / (e.g., by location, contract type, or contract preference))。句の中に and / or があれば句はもう閉じていて、
206
+ * 句ごと 1 つの項目(apples, oranges such as navels and mandarins, and pears)。
207
+ */
208
+ const fromExample = (clause: Clause, at: number, items: readonly Token[][]): readonly Token[][] => {
209
+ const level = clause.depths[at] ?? 0;
210
+ const phraseAt = items.findLastIndex((item) => exampleEnd(clause, level, item) !== -1);
211
+ const phrase = items[phraseAt];
212
+ if (phrase === undefined) return items;
213
+ const examples = phrase.slice(exampleEnd(clause, level, phrase));
214
+ if (examples.some((token) => LIST_CONJUNCTION.has(token.surface.toLowerCase()))) return items;
215
+ const rest = items.slice(phraseAt + 1);
216
+ const inPhrase = [examples, ...rest].filter((item) => item.some(isContent));
217
+ return new Set(inPhrase.map(shapeOf)).size > 1 ? items : inPhrase;
218
+ };
219
+
220
+ const APPOSITIVE_ANCHOR = new Set(["NOUN", "PROPN"]);
221
+
222
+ /**
223
+ * 名詞のすぐ後ろの、読点で挟まれた X and Y は同格(two HPV types, HPV16 and HPV18, that account / Governments, both state and
224
+ * federal, spend)。項目が 2 つに見え、and の前に読点が無く、and の後ろの項目が読点で閉じ、後ろの 2 つに動詞が無いときに同格と読む。
225
+ * 2 つを結ぶ同格に Oxford comma は打たないので、and の前に読点があれば 3 つの並び(the subcommittee, full committee, and
226
+ * chamber levels, as well as)。読点の無い本当の並び(apples, pears and plums, then ...)はこの形と見分けられず、判定から外れる。
227
+ */
228
+ const isAppositive = (clause: Clause, at: number, items: readonly Token[][], after: readonly Token[]): boolean => {
229
+ const [anchor, head] = items;
230
+ if (items.length !== 2 || anchor === undefined || head === undefined || commaBefore(clause.tokens, at)) return false;
231
+ const end = at + 1 + after.length;
232
+ // 閉じた読点のあとに and / or が続くなら、並びのあとに節をつないでいる(apples, pears and plums, and went home)。
233
+ const joinsClause = LIST_CONJUNCTION.has(clause.tokens[end + 1]?.surface.toLowerCase() ?? "");
234
+ const closed = clause.tokens[end]?.surface === "," && clause.depths[end] === clause.depths[at] && !joinsClause;
235
+ return closed && APPOSITIVE_ANCHOR.has(lastContent(anchor)?.pos ?? "") && !hasVerb(head) && !hasVerb(after);
236
+ };
237
+
238
+ /** 副詞を飛ばした頭の形。explain and justify と ultimately ensure は同じ動詞の項目。 */
239
+ const leadShape = (item: readonly Token[]): string | undefined => shapeOf(item.filter((token) => token.pos !== "ADV"));
240
+
241
+ /**
242
+ * and の後ろの項目に読点と、同じ形の項目を連れた and / or が続くなら、この and は項目の中にある(searches and seizures, and
243
+ * the Eighth's ban)。頭の形か、節かどうかが違えば別の節をつなぐ and で、この and までが並び(apples, pears and plums, and went home / and figs fell)。
244
+ */
245
+ const listContinues = (clause: Clause, at: number, after: readonly Token[]): boolean => {
246
+ const next = at + 1 + after.length;
247
+ if (clause.tokens[next]?.surface !== "," || !LIST_CONJUNCTION.has(clause.tokens[next + 1]?.surface.toLowerCase() ?? "")) return false;
248
+ const following = itemAfter(clause.tokens, next + 1);
249
+ const shape = leadShape(following);
250
+ return shape !== undefined && shape === leadShape(after) && isClause(following) === isClause(after);
251
+ };
252
+
253
+ const isPluralNounPhrase = (tokens: readonly Token[]): boolean =>
254
+ tokens.filter(isContent).every((token) => NOUN_PHRASE_TAIL.has(token.pos)) && lastContent(tokens)?.features?.["Number"] === "Plur";
255
+
256
+ /**
257
+ * 語のあとが複数形の名詞句なら、語は名詞にかかる語で、2 つはもう揃っている(the renderer in both modes and the exporter)。
258
+ * and の後ろも冠詞の無い複数形の名詞句なら、2 つを結ぶ語(both project goals and business goals)。
259
+ */
260
+ const pairComplete = (member: readonly Token[], after: readonly Token[]): boolean => {
261
+ const end = after.findIndex((token) => LIST_CONJUNCTION.has(token.surface.toLowerCase()));
262
+ const partner = end === -1 ? after : after.slice(0, end);
263
+ return isPluralNounPhrase(member) && !(isPluralNounPhrase(partner) && !partner.some((token) => token.pos === "DET"));
264
+ };
265
+
266
+ /**
267
+ * 最後の項目に 2 つだけを結ぶ語(between / either)があり、この接続詞がその語の相手で(between … and / either … or)、そのあとに
268
+ * and / or がまだ無く、読点も無ければ、この and / or はその 2 つを結ぶ(the Key Terms between Provider and Customer, and any policies)。
269
+ * 相手でなければ語は名詞にかかるだけ(the impact of either option and implementation)。括弧の中の語は外の接続詞と組まない。
270
+ */
271
+ const pairsInLastItem = (clause: Clause, at: number, items: readonly Token[][], after: readonly Token[]): boolean => {
272
+ const last = items.at(-1) ?? [];
273
+ const conjunction = clause.tokens[at]?.surface.toLowerCase() ?? "";
274
+ const level = clause.depths[at] ?? 0;
275
+ const opener = last.findLastIndex(
276
+ (token) => clause.words.pair.has(`${token.surface.toLowerCase()} ${conjunction}`) && clause.depths[clause.tokens.indexOf(token)] === level,
277
+ );
278
+ const member = last.slice(opener + 1);
279
+ if (opener === -1 || commaBefore(clause.tokens, at) || pairComplete(member, after)) return false;
280
+ return !member.some((token) => LIST_CONJUNCTION.has(token.surface.toLowerCase()));
281
+ };
282
+
283
+ /** 名詞にかかる語。解析器は分詞を過去形とも読む(registered or certified mail の certified)ので、動詞も数える。 */
284
+ const MODIFIER = new Set(["ADJ", "VERB"]);
285
+
286
+ const isModifier = (token: Token | undefined): boolean => token?.pos === "ADJ" || (token !== undefined && isParticiple(token));
287
+
288
+ const NOUN_PHRASE_TAIL = new Set(["ADJ", "NOUN", "PROPN"]);
289
+
290
+ /** 修飾語と名詞だけの句(certified mail / artificial flavor)。冠詞や目的語を連れた動詞(adopt their resources)は違う。 */
291
+ const isModifiedNoun = (item: readonly Token[]): boolean => {
292
+ const [head, ...tail] = item.filter((token) => token.pos !== "PUNCT" && token.pos !== "X");
293
+ return (
294
+ MODIFIER.has(head?.pos ?? "") && tail.length > 0 && tail.every((token) => NOUN_PHRASE_TAIL.has(token.pos)) && APPOSITIVE_ANCHOR.has(tail.at(-1)?.pos ?? "")
295
+ );
296
+ };
297
+
298
+ /**
299
+ * 修飾語 1 つだけの項目と、修飾語で始まる名詞句を結ぶ and / or は、2 つの修飾語が名詞を共有している(natural and artificial flavor /
300
+ * registered or certified mail)。前の項目も修飾語で終われば、修飾語の並び(red, white and blue flags)。and / or の前に読点があれば、
301
+ * 項目の区切り(Federal government, military, and agricultural workers)。
302
+ */
303
+ const sharesNoun = (clause: Clause, at: number, items: readonly Token[][], after: readonly Token[]): boolean => {
304
+ const [previous, last] = items.slice(-2);
305
+ const lone = (last ?? []).filter(isContent);
306
+ if (commaBefore(clause.tokens, at) || previous === undefined || lone.length !== 1) return false;
307
+ return isModifier(lone[0]) && !isModifier(lastContent(previous)) && isModifiedNoun(after);
308
+ };
309
+
310
+ /** 頭の語のあとに前置詞の句を連れた項目(updates on the Google Doc)。 */
311
+ const hasPrepositionalTail = (item: readonly Token[]): boolean => {
312
+ const head = item.findIndex(isContent);
313
+ return item.some((token, index) => index > head && token.pos === "ADP");
314
+ };
315
+
316
+ /**
317
+ * and / or の後ろの最初の名詞が、最後の項目の前置詞の目的語と同じ品詞で、どの項目の頭とも違う(chat in Slack or Google Hangouts /
318
+ * Teams (beta): 固有名詞どうし)。頭と同じ品詞なら項目とも読める(petitions for waivers and appeals)ので、並びのまま。
319
+ */
320
+ const isNoun = (token: Token): boolean => APPOSITIVE_ANCHOR.has(token.pos);
321
+
322
+ const lastNoun = (item: readonly Token[]): Token | undefined => item.findLast(isNoun);
323
+
324
+ const likeLastObject = (items: readonly Token[][], after: readonly Token[]): boolean => {
325
+ const kind = after.find(isNoun)?.pos;
326
+ return kind !== undefined && lastNoun(items.at(-1) ?? [])?.pos === kind && items.every((item) => item.find(isContent)?.pos !== kind);
327
+ };
328
+
329
+ /**
330
+ * どの項目も前置詞の句を連れ、and / or の後ろだけが連れておらず、その目的語に似ていれば、and / or は最後の項目の前置詞の目的語を結ぶ
331
+ * (updates on the Google Doc, chat in Slack or Google Hangouts は 2 つ)。and / or の前に読点があれば、項目の区切りなので並び。
332
+ */
333
+ const joinsObjects = (clause: Clause, at: number, items: readonly Token[][], after: readonly Token[]): boolean =>
334
+ !commaBefore(clause.tokens, at) &&
335
+ items.every(hasPrepositionalTail) &&
336
+ !after.some((token) => token.pos === "ADP") &&
337
+ !hasVerb(after) &&
338
+ likeLastObject(items, after);
339
+
340
+ /** and / or が並びの最後の継ぎ目ではなく、最後の項目の中にある。 */
341
+ const insideLastItem = (clause: Clause, at: number, items: readonly Token[][], after: readonly Token[]): boolean =>
342
+ pairsInLastItem(clause, at, items, after) || sharesNoun(clause, at, items, after) || joinsObjects(clause, at, items, after);
343
+
344
+ /**
345
+ * 読点で区切った項目が and / or の前に 2 つ以上あり、最後の項目と and の後ろが同じ形のときだけ並列。
346
+ * 導入の読点(After the review, the team fixed the bug and shipped it.)や、節をつなぐ読点
347
+ * (We tested it, and the team shipped it.)は、Oxford comma を打つかどうかの選択を見せない。
348
+ */
349
+ const listAt = (clause: Clause, at: number): boolean | undefined => {
350
+ const after = itemAfter(clause.tokens, at);
351
+ const listed = fromExample(clause, at, afterClosedList(withoutLead(joinAdjectives(itemsBefore(clause, at)))));
352
+ const items = fromParticiplePhrase(listed, after, clause.words.participle);
353
+ if (listContinues(clause, at, after) || isAppositive(clause, at, items, after) || insideLastItem(clause, at, items, after)) return undefined;
354
+ const last = items.at(-1);
355
+ const shape = last === undefined ? undefined : shapeOf(last);
356
+ if (items.length < 2 || shape === undefined || shape !== shapeOf(after)) return undefined;
357
+ if (!clausesAgree(items, after) || !predicatesAgree(items, shape)) return undefined;
358
+ return commaBefore(clause.tokens, at);
359
+ };
360
+
361
+ /**
362
+ * 3 つ以上の並列の最後の and / or の前に読点を打つか。Oxford comma。
363
+ *
364
+ * どちらが正しいかは決めない。スタイルガイドで割れる論点に立場を取ると rule ごと無視される。
365
+ * 見るのは 1 つの文書で揃っているかだけ。spec §12.3。
366
+ */
367
+ const oxfordIn = (tokens: readonly Token[], words: ListWords): boolean | undefined => {
368
+ const clause = { tokens, depths: depthsOf(tokens), words };
369
+ return tokens.reduce<boolean | undefined>(
370
+ (found, token, at) => found ?? (at > 0 && LIST_CONJUNCTION.has(token.surface.toLowerCase()) ? listAt(clause, at) : undefined),
371
+ undefined,
372
+ );
373
+ };
374
+
375
+ const patternsOf = (lexicon: Lexicon | undefined): ReadonlySet<string> => new Set((lexicon ?? []).map((entry) => entry.pattern.toLowerCase()));
376
+
377
+ export const oxfordComma: Detector = (doc, options): Finding[] => {
378
+ const words = {
379
+ participle: patternsOf(doc.lexicons["participle-word"]),
380
+ example: doc.lexicons["example-marker"] ?? [],
381
+ pair: patternsOf(doc.lexicons["pair-opener"]),
382
+ };
383
+ const judged = doc.sentences.flatMap((sentence) => {
384
+ const oxford = oxfordIn(sentence.tokens ?? [], words);
385
+ return oxford === undefined ? [] : [{ sentence, oxford }];
386
+ });
387
+ const withComma = judged.filter((entry) => entry.oxford).length;
388
+ const minorityUsesComma = withComma <= judged.length - withComma;
389
+ const few = minorityUsesComma ? withComma : judged.length - withComma;
390
+ // 同数なら少数派は無い。
391
+ if (few === 0 || few * 2 === judged.length || few > options.limit) return [];
392
+ return judged
393
+ .filter((entry) => entry.oxford === minorityUsesComma)
394
+ .map(({ sentence }) => ({
395
+ rule: "oxford-comma-consistency",
396
+ severity: "info",
397
+ line: 0,
398
+ column: 0,
399
+ quote: sentence.text.trim(),
400
+ values: { count: few, limit: options.limit, offset: sentence.span.start },
401
+ }));
402
+ };
@@ -1,8 +1,8 @@
1
1
  import { proseText } from "../measure.ts";
2
- import { wordsOf } from "./structure.ts";
2
+ import { dateSpans, wordsOf } from "./structure.ts";
3
3
  import { compacted, placeOf } from "./gram-place.ts";
4
4
  import { notAcronymSpansOf, type NotAcronymSpans } from "./acronym-context.ts";
5
- import { expansionAt, type ExpandedAt } from "./acronym-expansion.ts";
5
+ import { expansionAt, termEntryAcronyms, type ExpandedAt } from "./acronym-expansion.ts";
6
6
  import { isExplained } from "./acronym-compound.ts";
7
7
  import { conjugatedForms } from "./conjugated-form.ts";
8
8
  import { evidenceSpans, hasNumeral, startsWithin } from "./concrete-evidence.ts";
@@ -200,14 +200,35 @@ const spansOf = (text: string, notation: NotAcronymSpans): Span[] => [
200
200
 
201
201
  type AcronymHit = { readonly word: string; readonly hit: Hit };
202
202
 
203
+ /**
204
+ * 言語パッケージが、大文字で書いて強調した普通の語(will NEVER call)と読んだ語。辞書がその語を名前になり得ない品詞でしか
205
+ * 知らないときだけ、そう読む。品詞を持たない言語パッケージでは何も外れない。
206
+ */
207
+ const isEmphasised = (sentence: Sentence, offset: number): boolean =>
208
+ (sentence.tokens ?? []).some((token) => token.span.start === offset && token.features?.["Emph"] === "Yes");
209
+
203
210
  /** チームが並べた名前の中の略語(NTT Docomo の NTT)は、名前の一部であって説明を待つ略語ではない。 */
204
211
  const acronymsOf = (doc: ProseDocument, notation: NotAcronymSpans): AcronymHit[] =>
205
212
  doc.sentences.flatMap((sentence) => {
206
213
  const excluded = [...spansOf(sentence.text, notation), ...nameSpans(sentence.text, doc.names ?? [])];
207
214
  return [...sentence.text.matchAll(ACRONYM)]
208
215
  .filter((match) => !excluded.some((span) => span.start <= match.index && match.index + match[0].length <= span.end))
209
- .map((match) => ({ word: match[0], hit: { sentence, offset: sentence.span.start + match.index } }));
216
+ .map((match) => ({ word: match[0], hit: { sentence, offset: sentence.span.start + match.index } }))
217
+ .filter(({ hit }) => !isEmphasised(hit.sentence, hit.offset));
218
+ });
219
+
220
+ /** 略語ごとの、最初に現れた所。 */
221
+ const firstHits = (hits: readonly AcronymHit[]): Map<string, Hit> => {
222
+ const seen = new Map<string, Hit>();
223
+ hits.forEach(({ word, hit }) => {
224
+ if (!seen.has(word)) seen.set(word, hit);
210
225
  });
226
+ return seen;
227
+ };
228
+
229
+ /** 日付の中の月(SEP 01, 2022)は略語ではない。 */
230
+ const outsideDates = (hits: readonly AcronymHit[], dates: readonly Span[]): AcronymHit[] =>
231
+ hits.filter(({ hit }) => !dates.some((span) => span.start <= hit.offset && hit.offset < span.end));
211
232
 
212
233
  /**
213
234
  * どこか 1 か所で展開してあればよい。初出が節の見出し代わりの語(「5.3. DPA.」)で、
@@ -227,6 +248,7 @@ const notationOf = (doc: ProseDocument): NotAcronymSpans =>
227
248
  emphasis: patternsOf(doc, "emphasis-word"),
228
249
  divisions: patternsOf(doc, "numbered-division"),
229
250
  honorifics: patternsOf(doc, "honorific"),
251
+ titles: patternsOf(doc, "name-title"),
230
252
  dateTimeUnits: patternsOf(doc, "date-time-unit"),
231
253
  });
232
254
 
@@ -241,13 +263,14 @@ export const undefinedAcronym: Detector = (doc, options): Finding[] => {
241
263
  // HTTP のメソッド名(GET)は略語ではないので、通じる略語と同じく展開を求めない。
242
264
  // 並べた名前(JAXA)も、繋いだ略語(JAXA-ISAS)の片割れとして説明済みに数える。
243
265
  const common = new Set([...(options.lexicon ?? []).map((entry) => entry.pattern), ...patternsOf(doc, "http-method"), ...(doc.names ?? [])]);
244
- const seen = new Map<string, Hit>();
245
- acronymsOf(doc, notationOf(doc)).forEach(({ word, hit }) => {
246
- if (!seen.has(word)) seen.set(word, hit);
247
- });
248
266
  const expandedAt = expansionAt({ markers: patternsOf(doc, "definition-marker"), verbs: definitionVerbsOf(doc) });
249
- const explainedAlone = (word: string): boolean => common.has(word) || isExpanded(body, word, expandedAt);
250
- const bare = [...seen.entries()].filter(([acronym]) => !isExplained(acronym, explainedAlone));
267
+ const entries = termEntryAcronyms(doc.source);
268
+ const explainedAlone = (word: string): boolean => common.has(word) || entries.has(word) || isExpanded(body, word, expandedAt);
269
+ const hits = acronymsOf(doc, notationOf(doc));
270
+ const unexplained = new Set([...firstHits(hits).keys()].filter((acronym) => !isExplained(acronym, explainedAlone)));
271
+ // 日付を読むには文書の木を作る。上限に届かない文書では作らない。
272
+ if (unexplained.size < options.limit) return [];
273
+ const bare = [...firstHits(outsideDates(hits, dateSpans(doc)))].filter(([acronym]) => unexplained.has(acronym));
251
274
  if (bare.length < options.limit) return [];
252
275
  return bare.map(([acronym, hit]) => ({
253
276
  rule: "undefined-acronym",
@@ -3,7 +3,7 @@ import { entryRanges, type TokenRange } from "./lexicon-match.ts";
3
3
  import { isWithinAny, quotedIn, QUOTATION_MARKS } from "../quoted-span.ts";
4
4
 
5
5
  /** 重ねたと言うのに要る数。1 つなら慎重さで、2 つ目からは読み手が何を言い切ったのか分からなくなる。 */
6
- export const STACKED_AT = 2;
6
+ const STACKED_AT = 2;
7
7
 
8
8
  /**
9
9
  * 語彙表 3 つ。hedges は逃げの表現そのもの、frames はそれだけでは逃げていない包む言い方(「という状況です」、may)、
@@ -43,7 +43,10 @@ const CLAUSE_BREAK = new Set(["PUNCT", "CCONJ"]);
43
43
 
44
44
  /** 語ごとの、文の頭から数えた節の番号。 */
45
45
  const clauseOf = (tokens: readonly Token[]): number[] =>
46
- tokens.reduce<number[]>((clauses, token) => [...clauses, (clauses.at(-1) ?? 0) + (CLAUSE_BREAK.has(token.pos) ? 1 : 0)], []);
46
+ tokens.reduce<number[]>((clauses, token) => {
47
+ clauses.push((clauses.at(-1) ?? 0) + (CLAUSE_BREAK.has(token.pos) ? 1 : 0));
48
+ return clauses;
49
+ }, []);
47
50
 
48
51
  const PREDICATE = new Set(["VERB", "AUX"]);
49
52
 
@@ -79,7 +82,7 @@ const stackedClause = (devices: readonly Device[], clauses: readonly number[]):
79
82
  * 1 つの文の 1 つの節に、逃げの表現を重ねているか。business-blog-harness-spec §7.2。見せる範囲を文の中の順で返す。重ねていなければ空。
80
83
  * 品詞が無ければ語の位置が分からないので何も言わない。
81
84
  */
82
- export const stackedHedgeIn = (sentence: Sentence, words: HedgeWords): Span[] => {
85
+ const stackedHedgeIn = (sentence: Sentence, words: HedgeWords): Span[] => {
83
86
  const tokens = sentence.tokens ?? [];
84
87
  return stackedClause(devicesIn(sentence, words), clauseOf(tokens))
85
88
  .toSorted((left, right) => left.range.start - right.range.start)
@@ -0,0 +1,40 @@
1
+ import type { Detector, Finding } from "../plugin.ts";
2
+ import { minorityStyle } from "../orthography.ts";
3
+ import { isClosed } from "../sentence-shape.ts";
4
+ import { phraseJoints, type Joint, type JointKind } from "../stray-space.ts";
5
+
6
+ const KINDS: readonly JointKind[] = ["between-phrases", "inside-phrase"];
7
+
8
+ type Quoted = Joint & { readonly quote: string };
9
+
10
+ /**
11
+ * 空けたほうが少ないときだけ、その空白を指摘する。空けたほうが多い文書(文節ごとに空ける分かち書き)は、
12
+ * その書き方を選んでいるので何も言わない。詰めた境目を「空け忘れ」とは言わない。どこで空けるかは書き手が決める。
13
+ */
14
+ const strays = (joints: readonly Quoted[]): readonly Quoted[] => (minorityStyle(joints) === true ? joints.filter((joint) => joint.spaced) : []);
15
+
16
+ /**
17
+ * 日本語の語句の途中の空白(「こころさんが 払った」「確認 しました」)。句点で終わる文だけを見る。
18
+ * 句点の無い行(歌、名札と値、表のような行)の空白は区切りとして書かれている。
19
+ */
20
+ export const straySpace: Detector = (doc, options): Finding[] => {
21
+ const joints: Quoted[] = doc.sentences
22
+ .filter(isClosed)
23
+ .flatMap((sentence) =>
24
+ phraseJoints(sentence, doc.source.slice(sentence.span.start, sentence.span.end)).map((joint) => ({ ...joint, quote: sentence.text.trim() })),
25
+ );
26
+ return KINDS.flatMap((kind) => {
27
+ const ofKind = joints.filter((joint) => joint.kind === kind);
28
+ const odd = strays(ofKind);
29
+ if (odd.length < options.limit) return [];
30
+ return odd.map((joint) => ({
31
+ rule: "",
32
+ severity: "warning",
33
+ line: 0,
34
+ column: 0,
35
+ quote: joint.quote,
36
+ variant: kind,
37
+ values: { before: joint.before, after: joint.after, count: odd.length, of: ofKind.length, limit: options.limit, offset: joint.offset },
38
+ }));
39
+ });
40
+ };
@@ -3,6 +3,7 @@ import type { BulletList, Detector, Finding, Paragraph, ProseDocument, Section,
3
3
  import { dateStampIndexes, stampCandidates } from "../date-stamp.ts";
4
4
  import { inDocumentOrder } from "../structure/issues.ts";
5
5
  import { preambleParagraphs } from "../preamble-paragraphs.ts";
6
+ import { isTooLongParagraph } from "../paragraph-length.ts";
6
7
 
7
8
  /**
8
9
  * ばらつきは変動係数(標準偏差 ÷ 平均)で測る。
@@ -41,7 +42,13 @@ const uniformity = (doc: ProseDocument, values: readonly number[], span: { start
41
42
  /** 1 段落に文を詰めすぎると、読み手はどこで息継ぎしていいか分からなくなる。 */
42
43
  export const paragraphLength: Detector = (doc, options): Finding[] =>
43
44
  doc.paragraphs
44
- .filter((paragraph) => paragraph.sentences.length > options.limit)
45
+ .filter((paragraph) =>
46
+ isTooLongParagraph(
47
+ paragraph.sentences.map((sentence) => lengthOf(sentence, doc.lengthUnit)),
48
+ options.limit,
49
+ options.fullSentence,
50
+ ),
51
+ )
45
52
  .map((paragraph) => ({
46
53
  rule: "max-paragraph-length",
47
54
  severity: "warning",
@@ -93,7 +100,8 @@ export const ruleOfThree: Detector = (doc, options): Finding[] => {
93
100
  ];
94
101
  };
95
102
 
96
- const dateSpans = (doc: ProseDocument): Span[] =>
103
+ /** 言語パッケージが日付と読んだ範囲(April 1, 2024、SEP 01, 2022)。木を作るので、要るときだけ呼ぶ。 */
104
+ export const dateSpans = (doc: ProseDocument): Span[] =>
97
105
  doc.structure === undefined ? [] : inDocumentOrder(doc.structure).flatMap((node) => (node.kind === "date" ? [node.span] : []));
98
106
 
99
107
  /** 更新日の刻印(「最終更新日:」「2025年6月20日」)は前置きに数えない。木を作るのは、数えて上限を超えたときだけ。 */