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
@@ -2,6 +2,7 @@ id: heading-echo
2
2
  layer: L1
3
3
  status: stable
4
4
  how_to_find: heading-echo
5
+ uses: [pos]
5
6
  severity: warning
6
7
 
7
8
  name:
@@ -9,8 +9,8 @@ name:
9
9
  en: Paragraph too long
10
10
 
11
11
  why:
12
- ja: 1 段落に文を詰めると、読み手はどこで息継ぎしていいか分からなくなります。
13
- en: A dense paragraph gives the reader nowhere to pause.
12
+ ja: 1 段落に文を詰めると、読み手はどこで息継ぎしていいか分からなくなります。短い文をいくつ並べても壁にはならないので、段落が普通の長さの文を上限の数だけ並べたより長いときだけ指摘します。
13
+ en: A dense paragraph gives the reader nowhere to pause. Short sentences do not make a wall, so it is reported only when it is also longer than that many sentences of ordinary length.
14
14
 
15
15
  message:
16
16
  ja: この段落は {count} 文あります({limit} 文まで)
@@ -22,6 +22,13 @@ how_to_fix:
22
22
 
23
23
  levels: { strict: 3, normal: 5, relaxed: 8 }
24
24
 
25
+ # 普通の長さの文 1 つの長さ。単位は adapter の lengthUnit(日本語は文字、英語は語)。
26
+ # 文の数が上限を超えても、段落の長さが「上限 × この長さ」以下なら指摘しない。子ども向けの文章のように短い文を重ねた段落は、息継ぎの場所がいくらでもある。
27
+ # corpus で、これより短い段落は短い文の積み重ね(子ども向けの解説、リリースの一覧、Q&A)だけだった。上限と掛けるので、段階・ジャンル・chaff.yaml の数値がそのまま効く。
28
+ full_sentence:
29
+ ja: 70
30
+ en: 18
31
+
25
32
  # 条文・判決・論文の段落は、一つの規定や論点をまとめて書く。corpus のその種類の文書で、normal が最も長い一握りの段落だけを指すように測って置いた。
26
33
  by_genre:
27
34
  legal: { strict: 5, normal: 8, relaxed: 12 }
@@ -55,6 +55,10 @@ by_genre:
55
55
  en: { strict: 80, normal: 130, relaxed: 180 }
56
56
  legal/judgment:
57
57
  ja: { strict: 250, normal: 350, relaxed: 450 }
58
+ # 英語の定義は、限定を重ねた名詞句の一文になる。英語の用語集で測り、normal が列挙を詰めた定義や長い注記だけを指すように置いた。
59
+ # 日本語は既定の上限のままで、すでに最も長い一握りだけを指していた。
60
+ docs/glossary:
61
+ en: { strict: 30, normal: 40, relaxed: 50 }
58
62
  academic:
59
63
  ja: { strict: 100, normal: 140, relaxed: 180 }
60
64
  en: { strict: 35, normal: 50, relaxed: 65 }
@@ -3,7 +3,7 @@ layer: L3
3
3
  status: experimental
4
4
  how_to_find: oxford-comma
5
5
  languages: [en]
6
- extra_word_lists: [participle-word]
6
+ extra_word_lists: [participle-word, example-marker, pair-opener]
7
7
  severity: info
8
8
 
9
9
  name:
@@ -0,0 +1,38 @@
1
+ id: stray-space
2
+ layer: L3
3
+ status: experimental
4
+ how_to_find: stray-space
5
+ # どの境目が一続きの語句の中かは、語の品詞で決める。品詞が無ければ動かない。
6
+ requires: [pos]
7
+ languages: [ja]
8
+ severity: warning
9
+
10
+ name:
11
+ ja: 語句の途中に空白がある
12
+ en: Stray space inside a phrase
13
+
14
+ why:
15
+ ja: 「こころさんが 払った」「確認 しました」のように、一続きに読む語句の途中に空白が入ると、読み手はそこで区切れるのかと迷います。打ち直しや貼り付けで残りやすく、目で見ても気づきにくい空白です。chaff は二つの境目を別々に数えます。助詞の後ろ(「が 払った」、文節の切れ目)と、文節を始められない語の前(「アプリ が」の助詞、「行き ます」の助動詞、「確認 しました」の「する」)です。文節ごとに空けて書く分かち書き(子ども向けの文や、日本語を学ぶ人向けの文)は書き方の一つなので、chaff はどちらが正しいかを決めず、文書の中で空けたほうが少ないときだけ、その空白を指摘します。名詞どうしのあいだ(「担当 田中」の名札と値、「赤坂 文弥」の名前、「サムネイル キャッシュ」の複合語)は書き方として空けることが多いので数えません。句点で終わらない行(歌、名札と値、表のような行)、見出し、表、行頭の全角空白の字下げ、行頭の項目の記号(「イ 」)の後ろ、Markdown の改行、鉤括弧(「」『』)で引いたものの中、英字・数字との境目(latin-spacing が見ます)も数えません。
16
+ en: A space inside what reads as one phrase in Japanese (こころさんが 払った, 確認 しました) makes the reader wonder whether the phrase breaks there. It is typically left over from retyping or pasting and is easy to miss by eye. chaff counts two kinds of joint separately, after a particle (が 払った, the break between phrases) and before a word that cannot start a phrase (a particle as in アプリ が, an auxiliary as in 行き ます, or する after a noun as in 確認 しました). Spacing every phrase (分かち書き, as in text for children or for learners of Japanese) is a way of writing, so chaff does not decide which is right; it reports the spaces only when the document spaces less often than it does not. A space between two nouns (a label and its value as in 担当 田中, a name as in 赤坂 文弥, a compound as in サムネイル キャッシュ) is often written on purpose and is not counted. Neither are lines that do not end with a full stop (verse, labels and values, table-like lines), headings, tables, a full-width indent at the start of a line, the space after an item mark at the start of a line (イ ), a Markdown line break, the inside of a quotation in 「」 or 『』, or a boundary with Latin letters or digits (latin-spacing looks at those).
17
+
18
+ message:
19
+ ja: "「{before}」と「{after}」のあいだに空白があります(この文書では {of} 箇所のうち {count} 箇所が空いている)"
20
+ en: 'Space between "{before}" and "{after}" ({count} of {of} in this document are spaced)'
21
+
22
+ messages:
23
+ between-phrases:
24
+ ja: "助詞「{before}」と「{after}」のあいだに空白があります(この文書は文節のあいだをふつう詰めて書く。{of} 箇所のうち {count} 箇所が空いている)"
25
+ en: 'Space after the particle "{before}" before "{after}" (this document usually writes phrases without a space; {count} of {of} are spaced)'
26
+ inside-phrase:
27
+ ja: "「{before}」と「{after}」は一続きの語句ですが、あいだに空白があります({of} 箇所のうち {count} 箇所が空いている)"
28
+ en: '"{before}" and "{after}" read as one phrase but are separated by a space ({count} of {of} are spaced)'
29
+
30
+ how_to_fix:
31
+ ja: 空白を消して詰めてください。文節ごとに空ける書き方をしているなら、文書全体でそろえてください。
32
+ en: Delete the space. If the document spaces its phrases on purpose, do so throughout.
33
+
34
+ # 何箇所空いていれば指摘するか。1 は「1 箇所でも空いていれば」。
35
+ levels: { strict: 1, normal: 1, relaxed: 3 }
36
+
37
+ # 歌の行は句点で終わらないので、ジャンルを問わず数えない。
38
+ use_for: [business, blog, technical, legal, docs, academic, literature, speech]
@@ -14,6 +14,7 @@ extra_word_lists:
14
14
  emphasis-word,
15
15
  numbered-division,
16
16
  honorific,
17
+ name-title,
17
18
  date-time-unit,
18
19
  http-method,
19
20
  ]
@@ -1,5 +1,5 @@
1
1
  import { digitRunAround } from "./number-name.ts";
2
- import type { Span, Token } from "./plugin.ts";
2
+ import type { ProseDocument, Span, Token } from "./plugin.ts";
3
3
 
4
4
  /**
5
5
  * 日付・時刻として書かれた数(9月、3時、2026年、10月1日、10時5分)。数を詰めて書くのが決まりで、空け方の好みではない。
@@ -12,17 +12,17 @@ import type { Span, Token } from "./plugin.ts";
12
12
  */
13
13
  export type CalendarUnits = { readonly chained: readonly string[]; readonly positional: ReadonlySet<string>; readonly year: ReadonlySet<string> };
14
14
 
15
- /** 日付・時刻の数の並び。first は日付の頭の数、inner は前の単位に続く数(2026年9月の 9)。 */
16
- export type CalendarRun = { readonly run: Span; readonly place: "first" | "inner" };
15
+ /** 日付・時刻の数の並び。first は日付の頭の数、inner は前の単位に続く数(2026年9月の 9)。unit は数の後ろの単位(月)。 */
16
+ export type CalendarRun = { readonly run: Span; readonly place: "first" | "inner"; readonly unit: string };
17
17
 
18
18
  /** 数の並びと、その後ろの単位の終わり(文の中の位置)。rank は chained の中の単位の順(無ければ -1)、anchored は単位だけで日付・時刻と分かるもの。 */
19
- type Part = { readonly run: Span; readonly unitEnd: number; readonly rank: number; readonly anchored: boolean };
19
+ type Part = { readonly run: Span; readonly unit: string; readonly unitEnd: number; readonly rank: number; readonly anchored: boolean };
20
20
 
21
21
  const DIGIT = /\d/u;
22
22
  const CALENDAR_YEAR = /^\d{4}$/u;
23
23
 
24
24
  /** text の中の数の並び(数字・小数点・ハイフン)を左から。 */
25
- const digitRuns = (text: string): Span[] => {
25
+ export const digitRuns = (text: string): Span[] => {
26
26
  const runs: Span[] = [];
27
27
  let at = 0;
28
28
  while (at < text.length) {
@@ -46,7 +46,7 @@ const partOf = (text: string, run: Span, tokens: readonly Token[], base: number,
46
46
  const unit = tokens.find((token) => token.span.start === base + pastSpace(text, run.end));
47
47
  if (unit === undefined || !isUnit(unit.surface, units)) return undefined;
48
48
  const rank = units.chained.indexOf(unit.surface);
49
- return { run, unitEnd: unit.span.end - base, rank, anchored: isAnchor(text.slice(run.start, run.end), unit.surface, units) };
49
+ return { run, unit: unit.surface, unitEnd: unit.span.end - base, rank, anchored: isAnchor(text.slice(run.start, run.end), unit.surface, units) };
50
50
  };
51
51
 
52
52
  /** 前の組の単位のすぐ後ろ(空白 1 つまで)から始まり、単位が前より小さい組か。 */
@@ -71,5 +71,14 @@ export const calendarRuns = (text: string, tokens: readonly Token[] | undefined,
71
71
  const parts = digitRuns(text).flatMap((run) => partOf(text, run, tokens, base, units) ?? []);
72
72
  return chainsOf(text, parts)
73
73
  .filter((chain) => chain.some((part) => part.anchored))
74
- .flatMap((chain) => chain.map((part, index): CalendarRun => ({ run: part.run, place: index === 0 ? "first" : "inner" })));
74
+ .flatMap((chain) => chain.map((part, index): CalendarRun => ({ run: part.run, place: index === 0 ? "first" : "inner", unit: part.unit })));
75
75
  };
76
+
77
+ const patternList = (doc: ProseDocument, id: string): string[] => (doc.lexicons[id] ?? []).map((entry) => entry.pattern);
78
+
79
+ /** 文書の言語の、日付・時刻の単位の語彙表。表の無い言語では空で、日付・時刻の数は見つからない。 */
80
+ export const calendarUnitsOf = (doc: ProseDocument): CalendarUnits => ({
81
+ chained: patternList(doc, "date-time-unit"),
82
+ positional: new Set(patternList(doc, "calendar-unit")),
83
+ year: new Set(patternList(doc, "calendar-year-unit")),
84
+ });
@@ -0,0 +1,72 @@
1
+ import { calendarRuns, digitRuns, type CalendarUnits } from "./calendar-number.ts";
2
+ import type { ProseDocument, Section, Sentence, Span } from "./plugin.ts";
3
+
4
+ /** 文書の一続きの文字列と、その中の文。base は text の先頭の位置で、文の span は文書全体の座標。 */
5
+ export type Passage = { readonly text: string; readonly base: number; readonly sentences: readonly Sentence[] };
6
+
7
+ /** 結びの節と、それより前の文書全体(見出し・表・コードも)。 */
8
+ export const closingAndEarlier = (doc: ProseDocument, last: Section): { closing: Passage; earlier: Passage } => ({
9
+ closing: { text: doc.source.slice(last.span.start, last.span.end), base: last.span.start, sentences: last.sentences },
10
+ earlier: { text: doc.source.slice(0, last.span.start), base: 0, sentences: doc.sentences.filter((sentence) => sentence.span.end <= last.span.start) },
11
+ });
12
+
13
+ const DIGIT = /\d/u;
14
+
15
+ /** 最後の数字まで。文末の「412.」の点や、範囲の「3-」のハイフンは数の一部ではない。前のハイフンは符号(-1%)なので残す。 */
16
+ const valueOf = (text: string, run: Span): string => {
17
+ const raw = text.slice(run.start, run.end);
18
+ const lastDigit = [...raw].findLastIndex((char) => DIGIT.test(char));
19
+ return raw.slice(0, lastDigit + 1);
20
+ };
21
+
22
+ /** passage の中で、品詞の付いた文の字。見出しや表の字は文ではないので付かない。 */
23
+ const taggedChars = (passage: Passage): Uint8Array => {
24
+ const tagged = new Uint8Array(passage.text.length);
25
+ passage.sentences
26
+ .filter((sentence) => sentence.tokens !== undefined)
27
+ .forEach((sentence) => tagged.fill(1, Math.max(0, sentence.span.start - passage.base), Math.max(0, sentence.span.end - passage.base)));
28
+ return tagged;
29
+ };
30
+
31
+ /** 文の中の日付・時刻の数の単位を、passage の中の位置で。文ごとに読むので、長い文書でも語を探す範囲は文の中だけ。 */
32
+ const calendarUnitsAt = (passage: Passage, units: CalendarUnits): ReadonlyMap<number, string> =>
33
+ new Map(
34
+ passage.sentences.flatMap((sentence) =>
35
+ calendarRuns(sentence.text, sentence.tokens, sentence.span.start, units).map(({ run, unit }): [number, string] => [
36
+ sentence.span.start - passage.base + run.start,
37
+ unit,
38
+ ]),
39
+ ),
40
+ );
41
+
42
+ /** 品詞の無いところの数の、すぐ後ろ(空白 1 つまで)に字面で続く日付・時刻の単位のうち、いちばん長いもの。無ければ空。 */
43
+ const unitAsWritten = (text: string, run: Span, units: CalendarUnits): string => {
44
+ const at = text[run.end] === " " ? run.end + 1 : run.end;
45
+ const following = [...units.chained, ...units.positional, ...units.year].filter((unit) => text.startsWith(unit, at));
46
+ return following.reduce((longest, unit) => (unit.length > longest.length ? unit : longest), "");
47
+ };
48
+
49
+ /**
50
+ * passage の中の数の、比べるときの名前を左から。日付・時刻の数は単位ごと(9月)、それ以外は数そのもの(412)。
51
+ * 文の中は品詞で見分ける(calendar-number.ts)。見出しや表は品詞が無いので、数の後ろの字で見分ける(題の「9月の報告」)。
52
+ */
53
+ const numberKeys = (passage: Passage, units: CalendarUnits): string[] => {
54
+ const unitAt = calendarUnitsAt(passage, units);
55
+ const tagged = taggedChars(passage);
56
+ return digitRuns(passage.text).map((run) => {
57
+ const value = valueOf(passage.text, run);
58
+ const firstDigit = run.start + passage.text.slice(run.start, run.end).search(DIGIT);
59
+ const unit = tagged[firstDigit] === 1 ? (unitAt.get(run.start) ?? "") : unitAsWritten(passage.text, run, units);
60
+ return `${value}${unit}`;
61
+ });
62
+ };
63
+
64
+ /**
65
+ * 結びの数のうち、それより前(見出し・表も)に書いていないもの(重ならないように、書いた順)。
66
+ * 前に同じ名前で書いた数(題の「9月」と結びの「9月」、本文の「412 件」と結びの「412 件」)は新しくない。
67
+ * 日付・時刻と数量は別のもので、本文の「9 件」があっても結びの「9月末」は新しく、題の「9月」があっても結びの「9 件」は新しい。
68
+ */
69
+ export const newNumbers = (closing: Passage, earlier: Passage, units: CalendarUnits): string[] => {
70
+ const stated = new Set(numberKeys(earlier, units));
71
+ return [...new Set(numberKeys(closing, units).filter((key) => !stated.has(key)))];
72
+ };
@@ -11,10 +11,10 @@ import { counted } from "../render/plural.ts";
11
11
  import { CHECKS_FILE, loadChecks, type UserCheck } from "../checks.ts";
12
12
  import { CACHE_DIR, credentialHint, describeFailure, hasCredentials, type JudgeOptions } from "../judge.ts";
13
13
  import { ENV_FILE, loadEnvFile } from "../env.ts";
14
- import { planSemantic, runSemantic } from "../run-semantic.ts";
14
+ import { planSemantic, runSemantic, semanticNeeds } from "../run-semantic.ts";
15
15
  import { machineBanner, renderPlan, renderSemantic } from "../render/semantic.ts";
16
16
  import type { Config } from "../config/load.ts";
17
- import type { Finding, RuleDefinition } from "../plugin.ts";
17
+ import type { Finding, ProseDocument, RuleDefinition } from "../plugin.ts";
18
18
  import type { BackendName, Failure } from "../backends/types.ts";
19
19
  import { profileFor } from "../profile/for-file.ts";
20
20
  import { CLI_TEXT } from "../cli-text.ts";
@@ -83,6 +83,20 @@ export type TestContext = {
83
83
  readonly clients?: Pick<JudgeOptions, "anthropicClient" | "openaiClient">;
84
84
  };
85
85
 
86
+ type SemanticDocument = { readonly doc: ProseDocument; readonly rules: RuleDefinition[]; readonly language: string; readonly genre: string };
87
+
88
+ /** 意味を読む検査に渡す文書。絞り込みが品詞を使うなら、解析器を読み込んでから作る。dry-run も本番も同じ文書を見る。 */
89
+ const semanticDocument = async (path: string, config: Config, resolveGenre: TestContext["resolveGenre"]): Promise<SemanticDocument> => {
90
+ const source = plainSource(await readFile(path, "utf8"));
91
+ const language = applyByPath(config.byPath, config.baseDir, path).language ?? config.language ?? guessLanguage(source).language;
92
+ const adapter = await loadAdapter(language);
93
+ const { genre } = resolveGenre(path, source, config);
94
+ const rules = loadRules(language);
95
+ await adapter.prepare?.(semanticNeeds(rules, config.rules, genre));
96
+ const doc = buildDocument(path, source, adapter, teamRules(config), profileFor(config, path, source, language, genre));
97
+ return { doc, rules, language, genre };
98
+ };
99
+
86
100
  type Judged = { path: string; outcome: Awaited<ReturnType<typeof runSemantic>>; rules: RuleDefinition[]; language: string };
87
101
 
88
102
  const judgeAll = async (
@@ -94,12 +108,7 @@ const judgeAll = async (
94
108
  ): Promise<Judged[]> =>
95
109
  Promise.all(
96
110
  paths.map(async (path) => {
97
- const source = plainSource(await readFile(path, "utf8"));
98
- const language = applyByPath(config.byPath, config.baseDir, path).language ?? config.language ?? guessLanguage(source).language;
99
- const adapter = await loadAdapter(language);
100
- const { genre } = resolveGenre(path, source, config);
101
- const doc = buildDocument(path, source, adapter, teamRules(config), profileFor(config, path, source, language, genre));
102
- const rules = loadRules(language);
111
+ const { doc, rules, language, genre } = await semanticDocument(path, config, resolveGenre);
103
112
  return { path, outcome: await runSemantic(doc, rules, checks, config.rules, genre, options), rules, language };
104
113
  }),
105
114
  );
@@ -114,12 +123,8 @@ const dryRun = async (
114
123
  ): Promise<void> => {
115
124
  const plans = await Promise.all(
116
125
  paths.map(async (path) => {
117
- const source = plainSource(await readFile(path, "utf8"));
118
- const language = applyByPath(config.byPath, config.baseDir, path).language ?? config.language ?? guessLanguage(source).language;
119
- const adapter = await loadAdapter(language);
120
- const { genre } = resolveGenre(path, source, config);
121
- const doc = buildDocument(path, source, adapter, teamRules(config), profileFor(config, path, source, language, genre));
122
- return { path, jobs: planSemantic(doc, loadRules(language), checks, config.rules, genre), sentences: doc.sentences.length, language };
126
+ const { doc, rules, language, genre } = await semanticDocument(path, config, resolveGenre);
127
+ return { path, jobs: planSemantic(doc, rules, checks, config.rules, genre), sentences: doc.sentences.length, language };
123
128
  }),
124
129
  );
125
130
  plans.forEach(({ path, jobs, sentences, language }) => console.log(renderPlan(path, jobs, sentences, language).join("\n")));
@@ -2,6 +2,7 @@ import { escapeRegExp } from "../orthography.ts";
2
2
  import { ROMAN_NUMERAL } from "./roman-numeral.ts";
3
3
  import { citationKeySpans } from "./citation-key.ts";
4
4
  import { dottedNameSpans } from "./dotted-name.ts";
5
+ import { letterJoinedNameSpans } from "./letter-joined-name.ts";
5
6
  import { loneNumeralSpans, nameNumeralSpans } from "./name-numeral.ts";
6
7
  import { placeholderSpans } from "./placeholder.ts";
7
8
  import { seriesLabelSpans } from "./series-label.ts";
@@ -25,6 +26,8 @@ export type NotationWords = {
25
26
  readonly divisions: readonly string[];
26
27
  /** 速記録が大文字で書く発言者の姓の前に置く敬称(Mr.、Mrs.、Madam)。 */
27
28
  readonly honorifics: readonly string[];
29
+ /** 大文字で書く姓の前に置く肩書き(Prime Minister、Governor)。後ろに略語も来る(the President NASA memo)ので、名前の形を求める。 */
30
+ readonly titles: readonly string[];
28
31
  /** 日付・時刻の数字の後ろに書く単位(年、月、日、時、分)。その前の同じ大文字の繰り返し(YY年)は書式の置き場所。 */
29
32
  readonly dateTimeUnits: readonly string[];
30
33
  };
@@ -49,6 +52,17 @@ const divisionOf = (words: readonly string[]): string => oneOf(words.flatMap((wo
49
52
  /** 敬称のすぐ後ろの大文字の姓(Mr. HAWLEY、Dr. O'NEIL)。速記録は発言者をこう書き、略語ではない。 */
50
53
  const SURNAME_IN_CAPITALS = String.raw`[A-Z]+(?:['’-][A-Z]+)*(?![\p{L}\p{N}_])`;
51
54
 
55
+ /**
56
+ * 肩書きの後ろの大文字の語が姓と読めるのは、名前の形のときだけ。行頭の発言者の印(Senator HAWLEY.、Chairman JORDAN:)か、
57
+ * 日本政府の英文が「姓 名」の順に書くローマ字の名前(Prime Minister ABE Shinzo)。the President NASA memo と
58
+ * The Secretary GDPR: Article 5 の略語は数える。
59
+ */
60
+ const GIVEN_NAME_AFTER = String.raw`(?=\s+\p{Lu}\p{Ll}+(?![\p{L}\p{N}_]))`;
61
+ const titledName = (titles: string): readonly RegExp[] => [
62
+ new RegExp(String.raw`(?<=(?:^|\n)[ \t]*)${titles}\s+${SURNAME_IN_CAPITALS}(?=[.:])`, "gu"),
63
+ new RegExp(String.raw`(?<![\p{L}\p{N}_])${titles}\s+${SURNAME_IN_CAPITALS}${GIVEN_NAME_AFTER}`, "gu"),
64
+ ];
65
+
52
66
  // 強調の記号は空白に置き換えてある(**3:30** PM)ので、部品の間の空白は数を問わない。
53
67
  const patternsOf = (words: NotationWords): readonly RegExp[] => [
54
68
  new RegExp(String.raw`${CLOCK_12}\s*${oneOf(words.meridiem)}`, "gu"),
@@ -59,6 +73,7 @@ const patternsOf = (words: NotationWords): readonly RegExp[] => [
59
73
  new RegExp(oneOf(words.emphasis), "gu"),
60
74
  new RegExp(String.raw`(?<![\p{L}\p{N}_])${divisionOf(words.divisions)}\s+${ROMAN_NUMERAL}(?![\p{L}\p{N}_&])`, "gu"),
61
75
  new RegExp(String.raw`(?<![\p{L}\p{N}_])${oneOf(words.honorifics)}\s+${SURNAME_IN_CAPITALS}`, "gu"),
76
+ ...titledName(oneOf(words.titles)),
62
77
  ];
63
78
 
64
79
  export type NotAcronymSpans = (text: string) => Span[];
@@ -73,6 +88,7 @@ export const notAcronymSpansOf = (words: NotationWords): NotAcronymSpans => {
73
88
  ...patterns.flatMap((pattern) => [...text.matchAll(pattern)].map((match) => ({ start: match.index, end: match.index + match[0].length }))),
74
89
  ...citationKeySpans(text),
75
90
  ...dottedNameSpans(text),
91
+ ...letterJoinedNameSpans(text),
76
92
  ...nameNumeralSpans(text),
77
93
  ...loneNumeralSpans(text),
78
94
  ...seriesLabelSpans(text),
@@ -12,6 +12,7 @@ import { escapeRegExp } from "../orthography.ts";
12
12
  * (single nucleotide polymorphism:SNP) 括弧の最後の項目が略語で、コロンの前の語の頭文字と揃う
13
13
  * 人事部(以下「HR」という。) 括弧の中が定義の語と略語だけ
14
14
  * overnight reverse repurchase agreement (ON RRP) 括弧の中が空白で繋いだ略語だけで、前の名前から文字が順に拾える
15
+ * Partnership On Wide Energy and Resources Resilience Asia (POWERR Asia) 括弧の中が略語と添えた語で、前の名前の頭文字と揃う
15
16
  * 括弧と略語の間には、引用符((“MNDA”)、(「MNDA」))と空白だけを許す。
16
17
  */
17
18
  const WRAP = String.raw`[\s"“”'‘’「」『』]*`;
@@ -149,12 +150,28 @@ const isJointBracketedAt = (body: string, acronym: string, at: number): boolean
149
150
  return abbreviates(body.slice(0, start), body.slice(start, close));
150
151
  };
151
152
 
153
+ /** 括弧の中で略語に添えた、大文字で始まる語((POWERR Asia)、(ZEC Initiative))と、閉じる括弧。 */
154
+ const QUALIFIER = new RegExp(String.raw`^(?<words>(?: [A-Z][a-z]+)+)${WRAP}[))]`, "u");
155
+
156
+ /**
157
+ * 括弧の中が略語と、それに添えた語だけの形。括弧の前の名前の頭文字が略語と揃うときだけ展開と見なす((NASA Goddard) は揃わない)。
158
+ * 添えた語が名前の終わりにもあれば(… Resilience Asia (POWERR Asia))、それを除いて比べる。
159
+ */
160
+ const isQualifiedAt = (body: string, acronym: string, at: number): boolean => {
161
+ if (!OPENED.test(body.slice(Math.max(0, at - NEAR), at))) return false;
162
+ const end = at + acronym.length;
163
+ const words = QUALIFIER.exec(body.slice(end, end + DEFINITION_REACH))?.groups?.["words"];
164
+ if (words === undefined) return false;
165
+ const name = body.slice(0, at).replace(ANY_OPENED, "").trimEnd();
166
+ return spellsOut(name.endsWith(words) ? name.slice(0, -words.length) : name, acronym);
167
+ };
168
+
152
169
  const isBracketedAt = (body: string, acronym: string, at: number): boolean => {
153
170
  const after = body.slice(at + acronym.length, at + acronym.length + NEAR);
154
171
  const before = body.slice(Math.max(0, at - NEAR), at);
155
172
  if (OPENS.test(after) || (OPENED.test(before) && CLOSES.test(after))) return true;
156
173
  if (SQUARE_OPENED.test(before) && SQUARE_CLOSES.test(after) && spellsOut(body.slice(0, at), acronym)) return true;
157
- return isSeparatedAt(body, acronym, at) || isNamedBeforeAt(body, acronym, at) || isJointBracketedAt(body, acronym, at);
174
+ return isSeparatedAt(body, acronym, at) || isNamedBeforeAt(body, acronym, at) || isJointBracketedAt(body, acronym, at) || isQualifiedAt(body, acronym, at);
158
175
  };
159
176
 
160
177
  /**
@@ -167,6 +184,120 @@ const EQUALS_DEFINITION = /^\s*[==]\s*(?=\p{L})\S*?[\p{Ll}\p{Lo}]/u;
167
184
  const isEqualsDefinedAt = (body: string, acronym: string, at: number): boolean =>
168
185
  EQUALS_DEFINITION.test(body.slice(at + acronym.length, at + acronym.length + DEFINITION_REACH));
169
186
 
187
+ /**
188
+ * 用語集の見出し語としての略語(## AFD、| AFD |、**AFD**:、- AFD —)。名前は見出し語のすぐ後ろ、つまり次の行・隣のセル・
189
+ * 区切りの後ろに書く。見出しと表は文にならないので、文をつないだ本文ではなく source の行を見る。
190
+ */
191
+ const TERM = String.raw`[A-Z][A-Z&-]*[A-Z]`;
192
+ const STRONG = /\*\*|__/gu;
193
+ const LIST_MARK = String.raw`(?:[-*+]|\d+[.)])\s+`;
194
+ const TERM_SEPARATOR = String.raw`(?:[::]|\s[-–—]|[–—])`;
195
+ /** 行が略語だけの見出し・段落(## AFD、AFD:、**AFD**)。 */
196
+ const TERM_LINE = new RegExp(String.raw`^(?:#{1,6}\s+)?(?<term>${TERM})\s*(?<colon>[::])?(?:\s+#+)?$`, "u");
197
+ /** 見出しか強調の印。印の無い「RTO:」の行はメモの見出しにも書くので、コロンを付けた略語だけの行は印があるときだけ見出し語と読む。 */
198
+ const MARKED_LINE = /^(?:#|\*\*|__)/u;
199
+ /**
200
+ * 箇条書きの頭の略語と区切りの後ろの名前(- AFD — Area…)か、強調した略語とその後ろの名前(**AFD**: Area…、**AFD** Area…)。
201
+ * 印の無い行頭の略語とコロン(RTO: Review ticket ownership.)は、メモの見出しにも書くので見出し語と読まない。
202
+ */
203
+ const TERM_LEAD = new RegExp(
204
+ String.raw`^(?:(?:${LIST_MARK})?(?<strong>\*\*|__)(?<strongTerm>${TERM})\s*(?:[::]\s*)?\k<strong>\s*${TERM_SEPARATOR}?|${LIST_MARK}(?<term>${TERM})\s*${TERM_SEPARATOR})\s*(?<rest>\S.*)$`,
205
+ "u",
206
+ );
207
+ const TABLE_ROW = /^\s*\|.*\|\s*$/u;
208
+ const WHOLE_TERM = new RegExp(String.raw`^${TERM}$`, "u");
209
+ /** 名前の終わり。文の終わり、区切り、括弧、空白で挟んだハイフン。読点は名前の中にも書く(Aviation, Range, and Aerospace Meteorology)ので、その前でも比べる。 */
210
+ const NAME_END = /[.;::((。|]|\s[-–—]\s|[–—]/u;
211
+ const NAME_COMMA = /[,,、]/u;
212
+ const LEADING_MARKS = /^[\s::>*+-]+/u;
213
+
214
+ /** 項目の頭の名前と、その読点の前まで(Service Level Agreement, a contract → Service Level Agreement)。 */
215
+ const namesOf = (text: string): string[] => {
216
+ const clause = (text.replaceAll(STRONG, "").replace(LEADING_MARKS, "").split(NAME_END)[0] ?? "").trim();
217
+ return [clause, (clause.split(NAME_COMMA)[0] ?? "").trim()].filter((name) => name !== "");
218
+ };
219
+
220
+ /** 1 語の名前を縮めた略語(ABV → Above、ABNDT → Abundant)。名前は小文字を含む語で、略語そのもの(SEO.)ではない。 */
221
+ const shortensWord = (name: string, acronym: string): boolean => !/\s/u.test(name) && /\p{Ll}/u.test(name) && abbreviates(name, acronym);
222
+
223
+ const spellsName = (text: string, acronym: string): boolean => namesOf(text).some((name) => namesAcronym(name, acronym) || shortensWord(name, acronym));
224
+
225
+ const cellsOf = (row: string): string[] =>
226
+ row
227
+ .trim()
228
+ .replace(/^\||\|$/gu, "")
229
+ .split("|")
230
+ .map((cell) => cell.replaceAll(STRONG, "").trim());
231
+
232
+ /** 表の行で、略語だけのセルの隣(後ろか前)のセルが名前のもの。 */
233
+ const tableTerms = (row: string): string[] => {
234
+ const cells = cellsOf(row);
235
+ return cells.filter(
236
+ (cell, index) => WHOLE_TERM.test(cell) && [cells[index + 1], cells[index - 1]].some((next) => next !== undefined && spellsName(next, cell)),
237
+ );
238
+ };
239
+
240
+ const leadTerm = (line: string): string[] => {
241
+ const lead = TERM_LEAD.exec(line.trim());
242
+ const term = lead?.groups?.["strongTerm"] ?? lead?.groups?.["term"];
243
+ return term !== undefined && spellsName(lead?.groups?.["rest"] ?? "", term) ? [term] : [];
244
+ };
245
+
246
+ /** 見出し語から名前までに見る行の数。空行が 2 つ挟まっても、次の段落を見る。 */
247
+ const NEXT_LINE_REACH = 3;
248
+
249
+ /** 略語だけの行と、その次の空でない行が名前のもの。 */
250
+ const lineTerm = (lines: readonly string[], index: number): string[] => {
251
+ const line = (lines[index] ?? "").trim();
252
+ const groups = TERM_LINE.exec(line.replaceAll(STRONG, ""))?.groups;
253
+ const term = groups?.["term"];
254
+ if (term === undefined || (groups?.["colon"] !== undefined && !MARKED_LINE.test(line))) return [];
255
+ const next = lines.slice(index + 1, index + 1 + NEXT_LINE_REACH).find((line) => line.trim() !== "");
256
+ return next !== undefined && spellsName(next, term) ? [term] : [];
257
+ };
258
+
259
+ const FENCE = /^ {0,3}(?<fence>`{3,}|~{3,})/u;
260
+ const FRONT_MATTER_EDGE = /^(?:---|\.\.\.)\s*$/u;
261
+
262
+ type FenceScan = { readonly open: string | undefined; readonly kept: string[] };
263
+
264
+ /** 閉じる行は記号の並びだけ。後ろに語がある行(```ts)は囲みの中の行。 */
265
+ const CLOSING_FENCE = /^ {0,3}(?<fence>`{3,}|~{3,})\s*$/u;
266
+
267
+ const closes = (line: string, open: string): boolean => CLOSING_FENCE.exec(line)?.groups?.["fence"]?.startsWith(open) === true;
268
+
269
+ /** コードの囲みの中の行(開き・閉じの行も)を空にする。閉じるのは同じ記号で、開きと同じか長い並び。 */
270
+ const withoutFences = (lines: readonly string[]): string[] =>
271
+ lines.reduce<FenceScan>(
272
+ (scan, line) => {
273
+ const fence = FENCE.exec(line)?.groups?.["fence"];
274
+ const inside = scan.open !== undefined || fence !== undefined;
275
+ scan.kept.push(inside ? "" : line);
276
+ if (scan.open === undefined) return { open: fence, kept: scan.kept };
277
+ return { open: closes(line, scan.open) ? undefined : scan.open, kept: scan.kept };
278
+ },
279
+ { open: undefined, kept: [] },
280
+ ).kept;
281
+
282
+ /** 文書の頭の front matter(--- で始まり --- か ... で閉じる)の行数。無ければ 0。 */
283
+ const frontMatterLength = (lines: readonly string[]): number =>
284
+ lines[0]?.trim() === "---" ? lines.findIndex((line, index) => index > 0 && FRONT_MATTER_EDGE.test(line)) + 1 : 0;
285
+
286
+ /** 4 桁以上の字下げはコードとして書ける。入れ子の箇条書きと見分けずに外す(外しても、略語が報告されるほうへ倒れるだけ)。 */
287
+ const INDENTED = /^(?: {4}|\t)/u;
288
+
289
+ /** 本文として読まない行(front matter、コードの囲み、字下げしたコード)を空にした行。行の位置は変えない。 */
290
+ const proseLines = (lines: readonly string[]): string[] => {
291
+ const skipped = frontMatterLength(lines);
292
+ return withoutFences(lines.map((line, index) => (index < skipped || INDENTED.test(line) ? "" : line)));
293
+ };
294
+
295
+ /** 用語集の見出し語として、すぐ後ろに名前を書いた略語。 */
296
+ export const termEntryAcronyms = (source: string): ReadonlySet<string> => {
297
+ const lines = proseLines(source.split(/\r?\n/u));
298
+ return new Set(lines.flatMap((line, index) => (TABLE_ROW.test(line) ? tableTerms(line) : [...lineTerm(lines, index), ...leadTerm(line)])));
299
+ };
300
+
170
301
  export type ExpandedAt = (body: string, acronym: string, at: number) => boolean;
171
302
 
172
303
  /** body の at にある略語が、その場で展開・定義されているか。語彙表から一度だけ組み立てて、略語ごとに呼ぶ。 */