chaffjs 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/dist/cli-text.d.ts.map +1 -1
  2. package/dist/cli-text.js +2 -0
  3. package/dist/cli-text.js.map +1 -1
  4. package/dist/cli.d.ts.map +1 -1
  5. package/dist/cli.js +5 -1
  6. package/dist/cli.js.map +1 -1
  7. package/dist/detectors/char-shape.d.ts.map +1 -1
  8. package/dist/detectors/char-shape.js +34 -2
  9. package/dist/detectors/char-shape.js.map +1 -1
  10. package/dist/detectors/en-shape.d.ts.map +1 -1
  11. package/dist/detectors/en-shape.js +110 -9
  12. package/dist/detectors/en-shape.js.map +1 -1
  13. package/dist/detectors/gram-place.d.ts +14 -0
  14. package/dist/detectors/gram-place.d.ts.map +1 -0
  15. package/dist/detectors/gram-place.js +39 -0
  16. package/dist/detectors/gram-place.js.map +1 -0
  17. package/dist/detectors/lexicon-match.d.ts +9 -0
  18. package/dist/detectors/lexicon-match.d.ts.map +1 -0
  19. package/dist/detectors/lexicon-match.js +40 -0
  20. package/dist/detectors/lexicon-match.js.map +1 -0
  21. package/dist/detectors/lexicon.d.ts.map +1 -1
  22. package/dist/detectors/lexicon.js +13 -20
  23. package/dist/detectors/lexicon.js.map +1 -1
  24. package/dist/detectors/phrase-match.d.ts.map +1 -1
  25. package/dist/detectors/phrase-match.js +7 -6
  26. package/dist/detectors/phrase-match.js.map +1 -1
  27. package/dist/detectors/repeated-head.d.ts.map +1 -1
  28. package/dist/detectors/repeated-head.js +15 -5
  29. package/dist/detectors/repeated-head.js.map +1 -1
  30. package/dist/detectors/sentence-ending.d.ts.map +1 -1
  31. package/dist/detectors/sentence-ending.js +7 -3
  32. package/dist/detectors/sentence-ending.js.map +1 -1
  33. package/dist/detectors/signals.d.ts.map +1 -1
  34. package/dist/detectors/signals.js +162 -22
  35. package/dist/detectors/signals.js.map +1 -1
  36. package/dist/document.d.ts.map +1 -1
  37. package/dist/document.js +22 -9
  38. package/dist/document.js.map +1 -1
  39. package/dist/lexicon-tokens.d.ts +4 -0
  40. package/dist/lexicon-tokens.d.ts.map +1 -0
  41. package/dist/lexicon-tokens.js +23 -0
  42. package/dist/lexicon-tokens.js.map +1 -0
  43. package/dist/page-furniture.d.ts +10 -0
  44. package/dist/page-furniture.d.ts.map +1 -0
  45. package/dist/page-furniture.js +35 -0
  46. package/dist/page-furniture.js.map +1 -0
  47. package/dist/plugin.d.ts +4 -0
  48. package/dist/plugin.d.ts.map +1 -1
  49. package/dist/render/compact.d.ts.map +1 -1
  50. package/dist/render/compact.js +11 -2
  51. package/dist/render/compact.js.map +1 -1
  52. package/dist/rule-load.d.ts.map +1 -1
  53. package/dist/rule-load.js +1 -0
  54. package/dist/rule-load.js.map +1 -1
  55. package/dist/run.d.ts.map +1 -1
  56. package/dist/run.js +5 -3
  57. package/dist/run.js.map +1 -1
  58. package/dist/sentence-shape.d.ts +6 -0
  59. package/dist/sentence-shape.d.ts.map +1 -1
  60. package/dist/sentence-shape.js +15 -0
  61. package/dist/sentence-shape.js.map +1 -1
  62. package/dist/structure/bare-numbers.d.ts +14 -0
  63. package/dist/structure/bare-numbers.d.ts.map +1 -0
  64. package/dist/structure/bare-numbers.js +53 -0
  65. package/dist/structure/bare-numbers.js.map +1 -0
  66. package/dist/structure/build.d.ts +2 -0
  67. package/dist/structure/build.d.ts.map +1 -1
  68. package/dist/structure/build.js +6 -3
  69. package/dist/structure/build.js.map +1 -1
  70. package/dist/structure/issues.d.ts.map +1 -1
  71. package/dist/structure/issues.js +11 -2
  72. package/dist/structure/issues.js.map +1 -1
  73. package/dist/structure/of.d.ts.map +1 -1
  74. package/dist/structure/of.js +4 -2
  75. package/dist/structure/of.js.map +1 -1
  76. package/dist/structure/total.d.ts +1 -0
  77. package/dist/structure/total.d.ts.map +1 -1
  78. package/dist/structure/total.js +37 -13
  79. package/dist/structure/total.js.map +1 -1
  80. package/dist/structure/universal.d.ts +2 -2
  81. package/dist/structure/universal.d.ts.map +1 -1
  82. package/dist/structure/universal.js +13 -4
  83. package/dist/structure/universal.js.map +1 -1
  84. package/dist/version.d.ts +5 -0
  85. package/dist/version.d.ts.map +1 -1
  86. package/dist/version.js +17 -2
  87. package/dist/version.js.map +1 -1
  88. package/package.json +3 -3
  89. package/rules/ai-tell.yaml +1 -0
  90. package/rules/closing-cliche.yaml +1 -0
  91. package/rules/concrete-evidence-density.yaml +2 -0
  92. package/rules/cushion-phrase-density.yaml +1 -0
  93. package/rules/double-keigo.yaml +1 -0
  94. package/rules/empty-intensifier.yaml +1 -0
  95. package/rules/excessive-hedging.yaml +1 -0
  96. package/rules/hiragana-fukushi.yaml +1 -0
  97. package/rules/max-kanji-continuous.yaml +2 -0
  98. package/rules/ngram-repetition.yaml +2 -0
  99. package/rules/padded-intro.yaml +1 -0
  100. package/rules/repeated-conjunction.yaml +1 -0
  101. package/rules/sasete-itadaku.yaml +1 -0
  102. package/rules/unqualified-superlative.yaml +1 -0
  103. package/src/cli-text.ts +2 -0
  104. package/src/cli.ts +5 -1
  105. package/src/detectors/char-shape.ts +38 -3
  106. package/src/detectors/en-shape.ts +138 -7
  107. package/src/detectors/gram-place.ts +41 -0
  108. package/src/detectors/lexicon-match.ts +44 -0
  109. package/src/detectors/lexicon.ts +15 -22
  110. package/src/detectors/phrase-match.ts +8 -7
  111. package/src/detectors/repeated-head.ts +21 -7
  112. package/src/detectors/sentence-ending.ts +7 -3
  113. package/src/detectors/signals.ts +188 -21
  114. package/src/document.ts +25 -9
  115. package/src/lexicon-tokens.ts +28 -0
  116. package/src/page-furniture.ts +45 -0
  117. package/src/plugin.ts +9 -1
  118. package/src/render/compact.ts +12 -2
  119. package/src/rule-load.ts +1 -0
  120. package/src/run.ts +6 -3
  121. package/src/sentence-shape.ts +16 -0
  122. package/src/structure/bare-numbers.ts +78 -0
  123. package/src/structure/build.ts +9 -3
  124. package/src/structure/issues.ts +16 -2
  125. package/src/structure/of.ts +4 -2
  126. package/src/structure/total.ts +45 -19
  127. package/src/structure/universal.ts +14 -3
  128. package/src/version.ts +21 -3
@@ -1,6 +1,7 @@
1
1
  import { proseText } from "../measure.ts";
2
2
  import { wordsOf } from "./structure.ts";
3
- import type { Detector, Finding, ProseDocument, Sentence } from "../plugin.ts";
3
+ import { compacted, placeOf } from "./gram-place.ts";
4
+ import type { Detector, Finding, ProseDocument, Section, Sentence, Token } from "../plugin.ts";
4
5
 
5
6
  const PER = 1000;
6
7
 
@@ -91,13 +92,44 @@ const gramsOf = (doc: ProseDocument): Map<string, number> => {
91
92
  return counts;
92
93
  };
93
94
 
95
+ /** 数えたときと同じ形の文。char 単位では空白を詰めてから数えている。 */
96
+ const gramText = (sentence: Sentence, unit: ProseDocument["lengthUnit"]): string => compacted(sentence.text, unit).text;
97
+
98
+ /** 動詞と助動詞。言い回し(ることができます、it is important to)にはこれが入り、主題の名前(state and local tax)には入らない。 */
99
+ const PREDICATE_POS = new Set(["VERB", "AUX"]);
100
+
101
+ const NOUNS = new Set(["NOUN", "PROPN", "ADJ"]);
102
+
103
+ /**
104
+ * 英語で名詞のすぐ前の動詞は、名詞を飾る語(形容詞を挟んでも同じ)(the upcoming fiscal year、the Disclosing Party、the borrow checker)で、
105
+ * 述語ではない。日本語の名詞の前の動詞(〜にあるコンポーネント)は言い回しの一部になるので、word 単位だけで見る。
106
+ */
107
+ const modifiesNoun = (tokens: readonly Token[], index: number, unit: ProseDocument["lengthUnit"]): boolean =>
108
+ unit === "word" && tokens[index]?.pos === "VERB" && NOUNS.has(tokens[index + 1]?.pos ?? "");
109
+
110
+ /**
111
+ * 語句が文の中で占める範囲に、動詞か助動詞があるか。範囲で見る。文字で探すと、同じ文の別の所の動詞「use」が、語句の中の名詞「use」に当たる。
112
+ * 品詞が無ければ見分けられないので、これまでどおり言い回しとして数える。
113
+ */
114
+ const hasPredicate = (gram: string, sentence: Sentence, unit: ProseDocument["lengthUnit"]): boolean => {
115
+ if (sentence.tokens === undefined) return true;
116
+ const place = placeOf(compacted(sentence.text, unit), gram);
117
+ if (place === undefined) return true;
118
+ const [start, end] = [sentence.span.start + place.start, sentence.span.start + place.end];
119
+ const tokens = sentence.tokens;
120
+ return tokens.some((token, index) => PREDICATE_POS.has(token.pos) && token.span.start < end && start < token.span.end && !modifiesNoun(tokens, index, unit));
121
+ };
122
+
94
123
  export const ngramRepetition: Detector = (doc, options): Finding[] => {
95
124
  if (wordsOf(doc) < FLOOR[doc.lengthUnit]) return [];
125
+ const sentenceWith = (gram: string): Sentence | undefined => doc.sentences.find((sentence) => gramText(sentence, doc.lengthUnit).includes(gram));
96
126
  const worst = [...gramsOf(doc).entries()]
97
- .filter(([gram]) => isPhrasing(gram, doc.lengthUnit))
98
- .reduce<{ gram: string; count: number }>((best, [gram, count]) => (count > best.count ? { gram, count } : best), { gram: "", count: 0 });
99
- if (worst.count <= options.limit) return [];
100
- const at = doc.sentences.find((sentence) => sentence.text.includes(worst.gram));
127
+ .filter(([gram, count]) => count > options.limit && isPhrasing(gram, doc.lengthUnit))
128
+ .sort(([, left], [, right]) => right - left)
129
+ .map(([gram, count]) => ({ gram, count, at: sentenceWith(gram) }))
130
+ .find(({ gram, at }) => at === undefined || hasPredicate(gram, at, doc.lengthUnit));
131
+ if (worst === undefined) return [];
132
+ const at = worst.at;
101
133
  return [
102
134
  {
103
135
  rule: "ngram-repetition",
@@ -113,8 +145,57 @@ export const ngramRepetition: Detector = (doc, options): Finding[] => {
113
145
  /**
114
146
  * 略語が初出で展開されているか。「CI」だけでは、読む人によって指すものが違う。
115
147
  * 展開は「略語の直前か直後の括弧」か「括弧の中の略語」で書かれる。
148
+ *
149
+ * & で繋いだ大文字は 1 語として読む(ATT&CK、M&IE)。割ると CK や IE が別の略語に見える。
150
+ */
151
+ const EDGE_BEFORE = String.raw`(?<![A-Za-z0-9_&])`;
152
+ const EDGE_AFTER = String.raw`(?![A-Za-z0-9_&])`;
153
+ const ACRONYM = new RegExp(String.raw`${EDGE_BEFORE}(?:[A-Z]+(?:&[A-Z]+)+|[A-Z]{2,6})${EDGE_AFTER}`, "gu");
154
+
155
+ /**
156
+ * 大文字で書かれていても略語ではないもの。
157
+ *
158
+ * 大文字だけの語が 3 つ以上、空白と引用符だけを挟んで続くのは強調(免責の定型文など)。
159
+ * 強調は読点や括弧をまたいで続くので(THE AUTHOR, THE COMPANY (IF ANY))、そういう続きを含む
160
+ * 「大文字以外の文字を含まない一続き」を丸ごと強調と見なす。小文字の語が挟まれば切れるので、
161
+ * 「SRE, SLO, MTTR」のような略語の並びは残る。引用符に包まれた大文字の句(“AS IS”)も強調。
116
162
  */
117
- const ACRONYM = /\b[A-Z]{2,6}\b/gu;
163
+ const CAPS_WORD = String.raw`[A-Z]+(?:&[A-Z]+)*`;
164
+ const SHOUTED_RUN = new RegExp(String.raw`${EDGE_BEFORE}${CAPS_WORD}(?:[\s"“”'‘’]+${CAPS_WORD}){2,}${EDGE_AFTER}`, "u");
165
+ const UNCASED_STRETCH = /(?:[A-Z]|\P{L})+/gu;
166
+ const QUOTED_CAPS = new RegExp(String.raw`["“'‘]${CAPS_WORD}(?:\s+${CAPS_WORD})+["”'’]`, "gu");
167
+
168
+ /** RFC 2119 の要件語。定義された普通の語で、1 語でも強調でも現れる。 */
169
+ const REQUIREMENT_WORD = new RegExp(
170
+ String.raw`${EDGE_BEFORE}(?:(?:MUST|SHALL|SHOULD)(?:\s+NOT)?|NOT\s+RECOMMENDED|REQUIRED|RECOMMENDED|MAY|OPTIONAL)${EDGE_AFTER}`,
171
+ "gu",
172
+ );
173
+
174
+ /** - か . で繋がり、数字を含む識別子(AC-2、MS.TEAMS.1.1v1)。大文字の部品は略語ではない。 */
175
+ const IDENTIFIER = /(?<![A-Za-z0-9_&.-])[A-Z0-9][A-Za-z0-9]*(?:[.-][A-Z0-9][A-Za-z0-9]*)+/gu;
176
+
177
+ /** Creative Commons のライセンス名。CC と BY は 1 つの名前の部品。 */
178
+ const LICENCE = new RegExp(String.raw`${EDGE_BEFORE}CC BY(?:-(?:SA|NC|ND))*${EDGE_AFTER}`, "gu");
179
+
180
+ type Span = { readonly start: number; readonly end: number };
181
+
182
+ const spanOf = (match: RegExpExecArray): Span => ({ start: match.index, end: match.index + match[0].length });
183
+
184
+ const spansOf = (text: string): Span[] => [
185
+ ...[...text.matchAll(UNCASED_STRETCH)].filter((match) => SHOUTED_RUN.test(match[0])).map(spanOf),
186
+ ...[...text.matchAll(IDENTIFIER)].filter((match) => /\d/u.test(match[0])).map(spanOf),
187
+ ...[QUOTED_CAPS, REQUIREMENT_WORD, LICENCE].flatMap((pattern) => [...text.matchAll(pattern)].map(spanOf)),
188
+ ];
189
+
190
+ type AcronymHit = { readonly word: string; readonly hit: Hit };
191
+
192
+ const acronymsOf = (doc: ProseDocument): AcronymHit[] =>
193
+ doc.sentences.flatMap((sentence) => {
194
+ const excluded = spansOf(sentence.text);
195
+ return [...sentence.text.matchAll(ACRONYM)]
196
+ .filter((match) => !excluded.some((span) => span.start <= match.index && match.index + match[0].length <= span.end))
197
+ .map((match) => ({ word: match[0], hit: { sentence, offset: sentence.span.start + match.index } }));
198
+ });
118
199
 
119
200
  /** 読み手が説明なしで通じると見なしてよい語。展開すると逆に読みにくい。 */
120
201
  const COMMON = new Set([
@@ -146,34 +227,100 @@ const COMMON = new Set([
146
227
  "XML",
147
228
  "TODO",
148
229
  "NOTE",
230
+ // 通信と符号化。技術文書でなくても説明なしで通じる。
231
+ "DNS",
232
+ "IP",
233
+ "TCP",
234
+ "UDP",
235
+ "SSH",
236
+ "SSL",
237
+ "TLS",
238
+ "VPN",
239
+ "LAN",
240
+ "UTF",
241
+ "ASCII",
242
+ // 機器・形式・単位。
243
+ "PC",
244
+ "IT",
245
+ "SDK",
246
+ "IDE",
247
+ "PNG",
248
+ "JPEG",
249
+ "GIF",
250
+ "SVG",
251
+ "QR",
252
+ "GPS",
253
+ "SNS",
254
+ "TV",
255
+ "KB",
256
+ "MB",
257
+ "GB",
258
+ "TB",
259
+ // 役職と国・地域。
260
+ "CEO",
261
+ "CTO",
262
+ "CFO",
263
+ "US",
264
+ "UK",
265
+ "EU",
266
+ "UN",
267
+ "DNA",
149
268
  ]);
150
269
 
151
270
  /**
152
271
  * 展開は略語の**すぐ隣**にあるときだけ認める。
153
272
  * 60 文字も見ると、同じ文のどこかに括弧があるだけで「説明済み」になり、1 件も出なくなる。
154
273
  *
155
- * 認めるのは 2 つの形。どちらも実際によく書かれる。
274
+ * 認めるのは 3 つの形。どれも実際によく書かれる。
156
275
  * CI(継続的インテグレーション) 略語のあとに括弧
157
276
  * Continuous Integration (CI) 括弧の中が略語
277
+ * Tax Cuts and Jobs Act [TCJA] 角括弧の中が略語で、直前の語の頭文字と揃う
278
+ * 括弧と略語の間には、引用符((“MNDA”)、(「MNDA」))と空白だけを許す。
279
+ */
280
+ const WRAP = String.raw`[\s"“”'‘’「」『』]*`;
281
+ const OPENS = new RegExp(String.raw`^${WRAP}[((]`, "u");
282
+ const CLOSES = new RegExp(String.raw`^${WRAP}[))]`, "u");
283
+ const OPENED = new RegExp(String.raw`[((]${WRAP}$`, "u");
284
+ const SQUARE_CLOSES = new RegExp(String.raw`^${WRAP}\]`, "u");
285
+ const SQUARE_OPENED = new RegExp(String.raw`\[${WRAP}$`, "u");
286
+
287
+ /** 括弧と略語の間の幅。空白は 1 つに畳んであるので、(“ MNDA ”) まで収まる。 */
288
+ const NEAR = 3;
289
+
290
+ /**
291
+ * 角括弧は引用の印にも使う([IANA]、[1])。直前の語のうち大文字で始まる語の頭文字が
292
+ * 略語と揃うときだけ展開と見なす。見る語は略語の文字数の 2 倍まで(and や of を挟むため)。
158
293
  */
159
- const OPENS = /^\s*[((]/u;
160
- const CLOSES = /^\s*[))]/u;
161
- const OPENED = /[((]\s*$/u;
294
+ const spellsOut = (before: string, acronym: string): boolean => {
295
+ const letters = acronym.replaceAll("&", "");
296
+ const words = before
297
+ .replace(SQUARE_OPENED, "")
298
+ .trim()
299
+ .split(/\s+/u)
300
+ .slice(-letters.length * 2);
301
+ const initials = words.filter((word) => /^[A-Z]/u.test(word)).map((word) => word.charAt(0));
302
+ return initials.join("").endsWith(letters);
303
+ };
162
304
 
163
- const isExpanded = (body: string, acronym: string): boolean => {
164
- const at = body.indexOf(acronym);
165
- if (at === -1) return false;
166
- const after = body.slice(at + acronym.length, at + acronym.length + 3);
167
- const before = body.slice(Math.max(0, at - 3), at);
168
- return OPENS.test(after) || (OPENED.test(before) && CLOSES.test(after));
305
+ const isExpandedAt = (body: string, acronym: string, at: number): boolean => {
306
+ const after = body.slice(at + acronym.length, at + acronym.length + NEAR);
307
+ const before = body.slice(Math.max(0, at - NEAR), at);
308
+ if (OPENS.test(after) || (OPENED.test(before) && CLOSES.test(after))) return true;
309
+ return SQUARE_OPENED.test(before) && SQUARE_CLOSES.test(after) && spellsOut(body.slice(0, at), acronym);
169
310
  };
170
311
 
312
+ /**
313
+ * どこか 1 か所で展開してあればよい。初出が節の見出し代わりの語(「5.3. DPA.」)で、
314
+ * 展開がその直後の文にあることが契約書では普通にある。見るのは語として現れた所だけ(CISA の中の CI は見ない)。
315
+ */
316
+ const isExpanded = (body: string, acronym: string): boolean =>
317
+ [...body.matchAll(new RegExp(`${EDGE_BEFORE}${acronym}${EDGE_AFTER}`, "gu"))].some((match) => isExpandedAt(body, acronym, match.index));
318
+
171
319
  export const undefinedAcronym: Detector = (doc, options): Finding[] => {
172
320
  const body = bodyOf(doc);
173
321
  const seen = new Map<string, Hit>();
174
- findIn(doc, ACRONYM).forEach((hit) => {
175
- const text = /^[A-Z]{2,6}/u.exec(doc.source.slice(hit.offset, hit.offset + 6))?.[0] ?? "";
176
- if (text.length > 0 && !COMMON.has(text) && !seen.has(text)) seen.set(text, hit);
322
+ acronymsOf(doc).forEach(({ word, hit }) => {
323
+ if (!COMMON.has(word) && !seen.has(word)) seen.set(word, hit);
177
324
  });
178
325
  const bare = [...seen.entries()].filter(([acronym]) => !isExpanded(body, acronym));
179
326
  if (bare.length < options.limit) return [];
@@ -193,9 +340,29 @@ export const undefinedAcronym: Detector = (doc, options): Finding[] => {
193
340
  */
194
341
  const CONCRETE = /\d|`|https?:\/\//u;
195
342
 
343
+ /**
344
+ * 見出しのない導入部は、書き出しで呼ぶ。文書の言葉なので、message がどの言語でも混ざらない。
345
+ * 決まった呼び名(「この節」)を置くと、英語の文書の message に日本語が入る。
346
+ */
347
+ const OPENING = { word: 4, char: 12 };
348
+
349
+ const openingOf = (sentence: Sentence | undefined, unit: ProseDocument["lengthUnit"]): string => {
350
+ const text = sentence === undefined ? "" : proseText(sentence);
351
+ const pieces = unit === "word" ? text.split(" ") : Array.from(text);
352
+ const joiner = unit === "word" ? " " : "";
353
+ return pieces.length <= OPENING[unit] ? pieces.join(joiner) : `${pieces.slice(0, OPENING[unit]).join(joiner)}…`;
354
+ };
355
+
356
+ /**
357
+ * 形態素解析が数と読んだ語(UD の NumType=Card)。漢数字の「二割」「十五分」も、算用数字と同じ具体的な数。
358
+ * 言い回しの「二人三脚」「一人ひとり」は、辞書が一語として持つので数にならない。品詞が無ければ、数字だけを見る。
359
+ */
360
+ const hasNumeral = (section: Section): boolean =>
361
+ section.sentences.some((sentence) => (sentence.tokens ?? []).some((token) => token.features?.["NumType"] === "Card"));
362
+
196
363
  export const concreteEvidence: Detector = (doc, options): Finding[] => {
197
364
  const sections = doc.sections.filter((section) => section.sentences.length >= 3);
198
- const empty = sections.filter((section) => !CONCRETE.test(doc.source.slice(section.span.start, section.span.end)));
365
+ const empty = sections.filter((section) => !CONCRETE.test(doc.source.slice(section.span.start, section.span.end)) && !hasNumeral(section));
199
366
  if (empty.length < options.limit) return [];
200
367
  return empty.map((section) => ({
201
368
  rule: "concrete-evidence-density",
@@ -204,7 +371,7 @@ export const concreteEvidence: Detector = (doc, options): Finding[] => {
204
371
  column: 0,
205
372
  quote: section.heading.length > 0 ? section.heading : (section.sentences[0]?.text.trim() ?? ""),
206
373
  values: {
207
- word: section.heading.length > 0 ? section.heading : "この節",
374
+ word: section.heading.length > 0 ? section.heading : openingOf(section.sentences[0], doc.lengthUnit),
208
375
  count: empty.length,
209
376
  total: sections.length,
210
377
  limit: options.limit,
package/src/document.ts CHANGED
@@ -5,8 +5,10 @@ import { frontmatter } from "micromark-extension-frontmatter";
5
5
  import { frontmatterFromMarkdown } from "mdast-util-frontmatter";
6
6
  import { atxHeadingText, headingText } from "./heading-text.ts";
7
7
  import { maskSpans } from "./mask.ts";
8
- import { buildTree, NO_OUTLINE, type Outline } from "./structure/build.ts";
8
+ import { buildTree, type Outline } from "./structure/build.ts";
9
9
  import { isMarkdownPath } from "./structure/markdown-path.ts";
10
+ import { pageFurniture, textOutline } from "./page-furniture.ts";
11
+ import { tokenizedLexicons } from "./lexicon-tokens.ts";
10
12
  import type { BulletList, LanguageAdapter, Paragraph, ProseDocument, Section, Sentence, Span, StructureNode, DocumentProfile } from "./plugin.ts";
11
13
 
12
14
  type Place = { readonly offset?: number | undefined };
@@ -263,12 +265,20 @@ export const buildDocument = (
263
265
  const root = parse(source);
264
266
  // 強調の記号は「本文でないもの」だが、太字の数を数えるときの「覆われた場所」ではない。
265
267
  // 同じ集合にすると、太字が自分の記号のせいで覆われた場所にあることになり、1 つも数えられなくなる。
266
- const blocks = collectMasks(root, source);
268
+ // テキストの文書は、ページのヘッダーとフッターも本文ではない(Markdown には改ページが無い)。
269
+ const blocks = [...collectMasks(root, source), ...(isMarkdownPath(path) ? [] : pageFurniture(source))];
267
270
  const masked = [...blocks, ...emphasisSpans(root, source)];
268
271
  const prose = maskSpans(source, masked);
269
272
  const paragraphSpans = spansOfType(root, "paragraph");
270
273
  const listItems = spansOfType(root, "listItem");
271
274
  const sentences = sentencesOf(prose, paragraphSpans, adapter);
275
+ const lexicons = {
276
+ ...adapter.lexicons,
277
+ "internal-jargon": team.jargon.map((pattern) => ({ pattern })),
278
+ // 使わない書き方を pattern に、使う書き方を instead_of に置く。語彙表の「同じことの別の書き方」と同じ向き。
279
+ "preferred-term": Object.entries(team.prefer ?? {}).map(([pattern, use]) => ({ pattern, instead_of: use })),
280
+ };
281
+ const tagged = sentences.some((sentence) => sentence.tokens !== undefined);
272
282
  const patterns = adapter.structure;
273
283
  // null は「作ったが構造を読めない言語だった」、undefined は「まだ作っていない」。
274
284
  const tree: { value: StructureNode | null | undefined } = { value: undefined };
@@ -283,19 +293,25 @@ export const buildDocument = (
283
293
  listSpans: listItems,
284
294
  paragraphs: paragraphsOf(paragraphSpans, sentences, listItems),
285
295
  lists: listsOf(root, source),
286
- lexicons: {
287
- ...adapter.lexicons,
288
- "internal-jargon": team.jargon.map((pattern) => ({ pattern })),
289
- // 使わない書き方を pattern に、使う書き方を instead_of に置く。語彙表の「同じことの別の書き方」と同じ向き。
290
- "preferred-term": Object.entries(team.prefer ?? {}).map(([pattern, use]) => ({ pattern, instead_of: use })),
291
- },
296
+ lexicons: tagged ? tokenizedLexicons(lexicons, adapter) : lexicons,
292
297
  requiredSections: team.requiredSections,
293
298
  // 構造の rule(参照先が無い・番号の抜け)が読む木。どの rule も読まなければ作らない。何万行の契約書で、他の rule の lint に代金を払わせない。
294
299
  get structure(): StructureNode | undefined {
295
300
  tree.value ??=
296
301
  patterns === undefined
297
302
  ? null
298
- : buildTree({ path, source, language: adapter.id, outline: isMarkdownPath(path) ? outlineOf(root, source) : NO_OUTLINE, profile }, patterns);
303
+ : buildTree(
304
+ {
305
+ path,
306
+ source,
307
+ language: adapter.id,
308
+ // テキストの文書は、ページの飾りを覆って読む。フッターの「Section 9」を木の節にしない。
309
+ outline: isMarkdownPath(path) ? outlineOf(root, source) : textOutline(source),
310
+ markdown: isMarkdownPath(path),
311
+ profile,
312
+ },
313
+ patterns,
314
+ );
299
315
  return tree.value ?? undefined;
300
316
  },
301
317
  profile,
@@ -0,0 +1,28 @@
1
+ import type { LanguageAdapter, Lexicon, LexiconEntry } from "./plugin.ts";
2
+
3
+ /** adapter の語彙表は adapter ごとに一度だけ分ける。チームの語彙表は文書ごとに作られるので、この表に残らない。 */
4
+ const split = new WeakMap<LanguageAdapter, WeakMap<Lexicon, Lexicon>>();
5
+
6
+ const splitBy = (adapter: LanguageAdapter): WeakMap<Lexicon, Lexicon> => {
7
+ const known = split.get(adapter) ?? new WeakMap<Lexicon, Lexicon>();
8
+ split.set(adapter, known);
9
+ return known;
10
+ };
11
+
12
+ const tokenized = (entry: LexiconEntry, adapter: LanguageAdapter): LexiconEntry => {
13
+ const tokens = adapter.segment(entry.pattern).sentences.flatMap((sentence) => sentence.tokens ?? []);
14
+ return tokens.length === 0 ? entry : { ...entry, tokens };
15
+ };
16
+
17
+ const withTokens = (lexicon: Lexicon, adapter: LanguageAdapter): Lexicon => {
18
+ const cache = splitBy(adapter);
19
+ const known = cache.get(lexicon);
20
+ if (known !== undefined) return known;
21
+ const made = lexicon.map((entry) => tokenized(entry, adapter));
22
+ cache.set(lexicon, made);
23
+ return made;
24
+ };
25
+
26
+ /** 語彙表の語を、文と同じ解析器で語に分けておく。語彙表の検査が原形で照らせるように。品詞を読んでいない文書には何もしない。 */
27
+ export const tokenizedLexicons = (lexicons: Readonly<Record<string, Lexicon>>, adapter: LanguageAdapter): Readonly<Record<string, Lexicon>> =>
28
+ Object.fromEntries(Object.entries(lexicons).map(([name, lexicon]) => [name, withTokens(lexicon, adapter)]));
@@ -0,0 +1,45 @@
1
+ import type { Span } from "./plugin.ts";
2
+ import { NO_OUTLINE, type Outline } from "./structure/build.ts";
3
+
4
+ /** 改ページから、飾りの行を探しにいく行数。飾りと改ページのあいだには空行が数行入る。 */
5
+ const REACH = 3;
6
+
7
+ type Line = { readonly start: number; readonly end: number; readonly text: string };
8
+
9
+ const linesOf = (source: string): Line[] => {
10
+ const scan = { start: 0 };
11
+ return source.split("\n").map((text) => {
12
+ const line = { start: scan.start, end: scan.start + text.length, text };
13
+ scan.start = line.end + 1;
14
+ return line;
15
+ });
16
+ };
17
+
18
+ const isBlank = (line: Line | undefined): boolean => line !== undefined && line.text.trim() === "";
19
+
20
+ /** 改ページだけの行。行の途中の \f は本文の一部(写し間違い)で、ページの区切りではない。 */
21
+ const PAGE_BREAK = /^[ \t]*\f[ \t\r]*$/u;
22
+
23
+ /** index から step の向きに、空行を飛ばして最初の空でない行。REACH 行を越えたら探さない。 */
24
+ const nearestText = (lines: readonly Line[], index: number, step: number): Line | undefined => {
25
+ const candidates = Array.from({ length: REACH }, (_, offset) => lines[index + step * (offset + 1)]);
26
+ const skipped = candidates.findIndex((line) => !isBlank(line));
27
+ return skipped === -1 ? undefined : candidates[skipped];
28
+ };
29
+
30
+ /**
31
+ * 紙の版を写したテキスト(RFC など)の、ページのヘッダーとフッター。改ページ(\f)の直前と直後の、空でない 1 行ずつ。
32
+ * ページごとに同じ行が出るので、本文として読むと「同じ言い回しの繰り返し」や長い文になる。
33
+ */
34
+ export const pageFurniture = (source: string): Span[] => {
35
+ const lines = linesOf(source);
36
+ return lines.flatMap((line, index) => {
37
+ if (!PAGE_BREAK.test(line.text)) return [];
38
+ return [nearestText(lines, index, -1), line, nearestText(lines, index, 1)].flatMap((found) =>
39
+ found === undefined ? [] : [{ start: found.start, end: found.end }],
40
+ );
41
+ });
42
+ };
43
+
44
+ /** テキストの文書の外形。見出しもコードも無く、ページの飾りだけを覆う。木を作る入口はどれもこれを使う。 */
45
+ export const textOutline = (source: string): Outline => ({ ...NO_OUTLINE, opaque: pageFurniture(source) });
package/src/plugin.ts CHANGED
@@ -36,7 +36,13 @@ export type LengthUnit = "char" | "word";
36
36
  * `instead_of` は「同じことを言う別の書きかた」。文体の一貫性を見る rule が使う。
37
37
  * 2 つの書きかたのどちらが正しいかは決めず、**1 つの文書で混ざっていないか**だけを見る。
38
38
  */
39
- export type LexiconEntry = { readonly pattern: string; readonly weight?: number | undefined; readonly instead_of?: string | undefined };
39
+ export type LexiconEntry = {
40
+ readonly pattern: string;
41
+ readonly weight?: number | undefined;
42
+ readonly instead_of?: string | undefined;
43
+ /** pattern を adapter が語に分けたもの。品詞が読めるときだけ core が入れる。語彙表を書く側は書かない。 */
44
+ readonly tokens?: readonly Token[] | undefined;
45
+ };
40
46
 
41
47
  export type Lexicon = readonly LexiconEntry[];
42
48
 
@@ -343,6 +349,8 @@ export type RuleDefinition = {
343
349
  readonly where: string | undefined;
344
350
  /** adapter に要る capability。"pos" / "lemma"。満たさなければ動かさない。spec §16。 */
345
351
  readonly requires: readonly string[];
352
+ /** 使えるなら用意してほしい capability("pos")。requires と違い、満たせなくても動かす(品詞が無ければ文字だけで見る)。 */
353
+ readonly uses: readonly string[];
346
354
  /** 複合シグナル。ここに並べた rule のうち何本が出たかを見る。spec §20.2。 */
347
355
  readonly from: readonly string[];
348
356
  /** 動かす言語。未指定は全言語。「ですます調」のように言語に固有の rule が使う。 */
@@ -1,10 +1,19 @@
1
1
  import type { RuleDefinition } from "../plugin.ts";
2
2
  import type { RunResult } from "../run.ts";
3
3
  import { messageOf } from "./text.ts";
4
+ import { uiLanguageOf, type Texts } from "../ui.ts";
4
5
 
5
6
  /** 「1 finding」「2 findings」。 */
6
7
  const counted = (count: number, noun: string): string => `${String(count)} ${noun}${count === 1 ? "" : "s"}`;
7
8
 
9
+ /**
10
+ * 最後の集計の行は、画面のほかの部分と同じく文書の言語で出す。行ごとの重さ(warning / error)は、grep や別の道具が読むので英語のまま。
11
+ */
12
+ const TALLY: Texts<{ readonly findings: (n: number) => string; readonly notRun: (n: number) => string; readonly separator: string }> = {
13
+ ja: { findings: (n) => `指摘 ${String(n)} 件`, notRun: (n) => `動いていない rule ${String(n)} 件`, separator: "、" },
14
+ en: { findings: (n) => counted(n, "finding"), notRun: (n) => `${counted(n, "rule")} not run`, separator: ", " },
15
+ };
16
+
8
17
  /** エンジニア向け。1 件 2 行。 */
9
18
  export const renderCompact = (header: string, result: RunResult, rules: readonly RuleDefinition[], language: string): string => {
10
19
  const byId = new Map(rules.map((rule) => [rule.id, rule]));
@@ -14,8 +23,9 @@ export const renderCompact = (header: string, result: RunResult, rules: readonly
14
23
  const at = `${finding.line}:${finding.column}`.padEnd(8);
15
24
  return [` ${at}${finding.severity.padEnd(8)}${messageOf(rule, finding, language)}`, ` ${" ".repeat(8)}${finding.rule}`];
16
25
  });
17
- const tail = [counted(result.findings.length, "finding"), result.skipped.length > 0 ? `${counted(result.skipped.length, "rule")} not run` : undefined].filter(
26
+ const text = TALLY[uiLanguageOf(language)];
27
+ const tail = [text.findings(result.findings.length), result.skipped.length > 0 ? text.notRun(result.skipped.length) : undefined].filter(
18
28
  (part) => part !== undefined,
19
29
  );
20
- return ["", header, "", ...lines, "", tail.join(", "), ""].join("\n");
30
+ return ["", header, "", ...lines, "", tail.join(text.separator), ""].join("\n");
21
31
  };
package/src/rule-load.ts CHANGED
@@ -102,6 +102,7 @@ const toRule = (raw: unknown, language: string, file: string): RuleDefinition =>
102
102
  what_to_check: isLocalized(raw["what_to_check"]) ? raw["what_to_check"] : undefined,
103
103
  where: typeof raw["where"] === "string" ? raw["where"] : undefined,
104
104
  requires: stringList(raw["requires"]) ?? [],
105
+ uses: stringList(raw["uses"]) ?? [],
105
106
  from: stringList(raw["from"]) ?? [],
106
107
  languages: stringList(raw["languages"]),
107
108
  use_for: Array.isArray(raw["use_for"]) ? raw["use_for"].map((entry) => String(entry)) : [],
package/src/run.ts CHANGED
@@ -6,6 +6,8 @@ import { REASONS, type Reasons } from "./reasons.ts";
6
6
  import { joinWords } from "./detectors/word-list.ts";
7
7
  import { unreadStructure, type Unread } from "./structure/unread.ts";
8
8
  import { isMarkdownPath } from "./structure/markdown-path.ts";
9
+ import { maskSpans } from "./mask.ts";
10
+ import { textOutline } from "./page-furniture.ts";
9
11
  import { uiLanguageOf } from "./ui.ts";
10
12
 
11
13
  export type Skipped = { readonly rule: string; readonly why: string };
@@ -51,8 +53,9 @@ const treeProblem = (doc: ProseDocument): string | undefined => {
51
53
  return unread === undefined ? undefined : reasonsFor(doc).unreadStructure(unread.clauses, unread.units);
52
54
  };
53
55
 
54
- /** 木が読んだのと同じ本文。Markdown はコードを覆ったもの、.txt はそのまま(.txt の字下げはコードではない)。 */
55
- const textTheTreeRead = (doc: ProseDocument): string => (isMarkdownPath(doc.path) ? (doc.prose ?? doc.source) : doc.source);
56
+ /** 木が読んだのと同じ本文。Markdown はコードを覆ったもの、.txt はページの飾りだけを覆ったもの(.txt の字下げはコードではない)。 */
57
+ const textTheTreeRead = (doc: ProseDocument): string =>
58
+ isMarkdownPath(doc.path) ? (doc.prose ?? doc.source) : maskSpans(doc.source, textOutline(doc.source).opaque);
56
59
 
57
60
  const unreadByDocument = new WeakMap<ProseDocument, Unread | undefined>();
58
61
 
@@ -104,7 +107,7 @@ export const neededBy = (rules: readonly RuleDefinition[], settings: Settings, e
104
107
  pos: forGenre(rules, genre)
105
108
  .filter((rule) => rule.layer !== "L4" && levelFor(rule, settings, experimental) !== "off")
106
109
  .filter((rule) => rule.languages === undefined || rule.languages.includes(language))
107
- .some((rule) => rule.requires.includes("pos") || rule.requires.includes("lemma")),
110
+ .some((rule) => [...rule.requires, ...rule.uses].some((need) => need === "pos" || need === "lemma")),
108
111
  });
109
112
 
110
113
  const place = (starts: readonly number[], finding: Finding): Finding => {
@@ -37,4 +37,20 @@ const beforeTrailing = (sentence: Sentence): readonly Token[] => {
37
37
  return kept.length === 0 ? tokens : kept;
38
38
  };
39
39
 
40
+ /** 閉じた引用。文がこれで終われば、文末は引用した文のもので、書き手の文末ではない。 */
41
+ const CLOSING_QUOTES = ["」", "』", "\u201d"];
42
+
43
+ /**
44
+ * 文末の語: 句読点・終助詞・添えた括弧を除いた後ろから、助動詞の連なりと、その前の述語一つ。
45
+ * 「測定しません」なら「し・ませ・ん」、「十分でしたか」なら「十分・でし・た」。文の途中の引用(「ご覧ください」という)は入らない。
46
+ * 引用で終わる文(画面には「確認します。」。)は、書き手の文末が無いので空。
47
+ */
48
+ export const endingTokens = (sentence: Sentence): Token[] => {
49
+ const reversed = [...beforeTrailing(sentence)].reverse();
50
+ const last = reversed.findIndex((token) => !SKIP.has(token.pos));
51
+ if (last === -1 || reversed.slice(0, last).some((token) => CLOSING_QUOTES.some((quote) => token.surface.includes(quote)))) return [];
52
+ const head = reversed.findIndex((token, index) => index >= last && token.pos !== "AUX");
53
+ return reversed.slice(last, head === -1 ? reversed.length : head + 1).reverse();
54
+ };
55
+
40
56
  export const lastContent = (sentence: Sentence): Token | undefined => [...beforeTrailing(sentence)].reverse().find((token) => !SKIP.has(token.pos));
@@ -0,0 +1,78 @@
1
+ import type { Line } from "./runs.ts";
2
+
3
+ /** 単位を書かない数。単位は表の列の見出しにあるか、無い。木の数量は必ず単位を持つので、空の単位とは重ならない。 */
4
+ export const NO_UNIT = "";
5
+
6
+ export type BareNumber = { readonly offset: number; readonly end: number; readonly value: number; readonly unit: typeof NO_UNIT };
7
+
8
+ /**
9
+ * 表の升に数だけを書いたもの(1,205、12.5、-50、▲50、**1,205**)。前の印と強調は数に入れない。
10
+ * 0 で始まる番号(007)、区切りの崩れた 1,20、括弧の (50)、注の付いた 1,205a は数にしない。
11
+ */
12
+ const BARE_NUMBER = /^(?:[1-9]\d{0,2}(?:,\d{3})+|[1-9]\d*|0)(?:\.\d+)?$/u;
13
+
14
+ /** 数の前の空白、強調、負の印(-、▲)と、数の後ろの強調と空白。 */
15
+ const BEFORE_NUMBER = /^[ \t]*[*_]*(?:[-−▲△][ \t]?)?/u;
16
+ const AFTER_NUMBER = /^[*_]*\s*$/u;
17
+ const DIGITS = /^[\d,.]*/u;
18
+
19
+ /** 表の列の区切り。\| は列の中に書いた | なので数えない。 */
20
+ export const CELL_SEPARATOR = /(?<!\\)\|/gu;
21
+
22
+ type CellSpan = { readonly start: number; readonly end: number };
23
+
24
+ const cellsOf = (source: string, line: Line): CellSpan[] => {
25
+ const text = source.slice(line.start, line.end);
26
+ const bounds = [-1, ...[...text.matchAll(CELL_SEPARATOR)].map((match) => match.index), text.length];
27
+ return bounds.slice(1).map((end, index) => ({ start: line.start + (bounds[index] ?? 0) + 1, end: line.start + end }));
28
+ };
29
+
30
+ type InColumn = BareNumber & { readonly column: number; readonly digits: string };
31
+
32
+ const bareNumberIn = (source: string, cell: CellSpan, column: number): InColumn[] => {
33
+ const text = source.slice(cell.start, cell.end);
34
+ const before = BEFORE_NUMBER.exec(text)?.[0] ?? "";
35
+ const rest = text.slice(before.length);
36
+ const digits = DIGITS.exec(rest)?.[0] ?? "";
37
+ if (!BARE_NUMBER.test(digits) || !AFTER_NUMBER.test(rest.slice(digits.length))) return [];
38
+ const offset = cell.start + before.length;
39
+ return [{ offset, end: offset + digits.length, value: Number(digits.replace(/,/gu, "")), unit: NO_UNIT, column, digits }];
40
+ };
41
+
42
+ /** 表の見出し。最初の行の上の区切り行(|---|)の、さらに上の行。 */
43
+ const headerAbove = (source: string, firstRow: Line | undefined): string => {
44
+ if (firstRow === undefined || firstRow.start < 2) return "";
45
+ const ruleStart = source.lastIndexOf("\n", firstRow.start - 2) + 1;
46
+ if (ruleStart < 2) return "";
47
+ return source.slice(source.lastIndexOf("\n", ruleStart - 2) + 1, ruleStart - 1);
48
+ };
49
+
50
+ /** 見出しに % のある列は率。 */
51
+ const PERCENT = /[%%]/u;
52
+
53
+ /** 同じ桁数で 4 桁以上の、区切りの無い整数だけの列は、年か番号(2024、1041)。 */
54
+ const YEAR_OR_CODE = /^\d{4,}$/u;
55
+
56
+ const isYearsOrCodes = (numbers: readonly InColumn[]): boolean =>
57
+ numbers.every((number) => YEAR_OR_CODE.test(number.digits) && number.digits.length === numbers[0]?.digits.length);
58
+
59
+ /** 足してよい列か。見出しが率を言う列と、年や番号の列は足さない。 */
60
+ const isAmountColumn = (header: string, numbers: readonly InColumn[], column: number): boolean =>
61
+ !PERCENT.test(header.split(CELL_SEPARATOR)[column] ?? "") && !isYearsOrCodes(numbers);
62
+
63
+ /** 表の行の、数だけを書いた升。箇条書きの行は読まない。 */
64
+ export const bareNumbersIn = (source: string, lines: readonly Line[]): BareNumber[] => {
65
+ const rows = lines.filter((line) => line.kind === "table");
66
+ const found = rows.flatMap((line) => cellsOf(source, line).flatMap((cell, column) => bareNumberIn(source, cell, column)));
67
+ const header = headerAbove(source, rows[0]);
68
+ const amountColumns = new Set(
69
+ [...new Set(found.map((number) => number.column))].filter((column) =>
70
+ isAmountColumn(
71
+ header,
72
+ found.filter((number) => number.column === column),
73
+ column,
74
+ ),
75
+ ),
76
+ );
77
+ return found.filter((number) => amountColumns.has(number.column)).map(({ offset, end, value, unit }) => ({ offset, end, value, unit }));
78
+ };