chaffjs 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (180) hide show
  1. package/dist/cli-text.d.ts.map +1 -1
  2. package/dist/cli-text.js +2 -0
  3. package/dist/cli-text.js.map +1 -1
  4. package/dist/cli.d.ts.map +1 -1
  5. package/dist/cli.js +5 -1
  6. package/dist/cli.js.map +1 -1
  7. package/dist/detectors/char-shape.d.ts.map +1 -1
  8. package/dist/detectors/char-shape.js +34 -2
  9. package/dist/detectors/char-shape.js.map +1 -1
  10. package/dist/detectors/en-shape.d.ts.map +1 -1
  11. package/dist/detectors/en-shape.js +110 -9
  12. package/dist/detectors/en-shape.js.map +1 -1
  13. package/dist/detectors/gram-place.d.ts +14 -0
  14. package/dist/detectors/gram-place.d.ts.map +1 -0
  15. package/dist/detectors/gram-place.js +39 -0
  16. package/dist/detectors/gram-place.js.map +1 -0
  17. package/dist/detectors/index.d.ts.map +1 -1
  18. package/dist/detectors/index.js +4 -1
  19. package/dist/detectors/index.js.map +1 -1
  20. package/dist/detectors/lexicon-match.d.ts +9 -0
  21. package/dist/detectors/lexicon-match.d.ts.map +1 -0
  22. package/dist/detectors/lexicon-match.js +40 -0
  23. package/dist/detectors/lexicon-match.js.map +1 -0
  24. package/dist/detectors/lexicon.d.ts.map +1 -1
  25. package/dist/detectors/lexicon.js +20 -20
  26. package/dist/detectors/lexicon.js.map +1 -1
  27. package/dist/detectors/phrase-match.d.ts.map +1 -1
  28. package/dist/detectors/phrase-match.js +7 -6
  29. package/dist/detectors/phrase-match.js.map +1 -1
  30. package/dist/detectors/repeated-head.d.ts.map +1 -1
  31. package/dist/detectors/repeated-head.js +15 -5
  32. package/dist/detectors/repeated-head.js.map +1 -1
  33. package/dist/detectors/sentence-ending.d.ts.map +1 -1
  34. package/dist/detectors/sentence-ending.js +7 -3
  35. package/dist/detectors/sentence-ending.js.map +1 -1
  36. package/dist/detectors/signals.d.ts.map +1 -1
  37. package/dist/detectors/signals.js +162 -22
  38. package/dist/detectors/signals.js.map +1 -1
  39. package/dist/detectors/structure-tree.d.ts +5 -0
  40. package/dist/detectors/structure-tree.d.ts.map +1 -1
  41. package/dist/detectors/structure-tree.js +50 -1
  42. package/dist/detectors/structure-tree.js.map +1 -1
  43. package/dist/detectors/team.d.ts.map +1 -1
  44. package/dist/detectors/team.js +7 -2
  45. package/dist/detectors/team.js.map +1 -1
  46. package/dist/detectors/word-list.d.ts +3 -0
  47. package/dist/detectors/word-list.d.ts.map +1 -0
  48. package/dist/detectors/word-list.js +3 -0
  49. package/dist/detectors/word-list.js.map +1 -0
  50. package/dist/document.d.ts.map +1 -1
  51. package/dist/document.js +22 -9
  52. package/dist/document.js.map +1 -1
  53. package/dist/lexicon-tokens.d.ts +4 -0
  54. package/dist/lexicon-tokens.d.ts.map +1 -0
  55. package/dist/lexicon-tokens.js +23 -0
  56. package/dist/lexicon-tokens.js.map +1 -0
  57. package/dist/page-furniture.d.ts +10 -0
  58. package/dist/page-furniture.d.ts.map +1 -0
  59. package/dist/page-furniture.js +35 -0
  60. package/dist/page-furniture.js.map +1 -0
  61. package/dist/plugin.d.ts +15 -1
  62. package/dist/plugin.d.ts.map +1 -1
  63. package/dist/profile/parse.d.ts.map +1 -1
  64. package/dist/profile/parse.js +15 -2
  65. package/dist/profile/parse.js.map +1 -1
  66. package/dist/render/compact.d.ts.map +1 -1
  67. package/dist/render/compact.js +13 -2
  68. package/dist/render/compact.js.map +1 -1
  69. package/dist/rule-load.d.ts.map +1 -1
  70. package/dist/rule-load.js +1 -0
  71. package/dist/rule-load.js.map +1 -1
  72. package/dist/run.d.ts.map +1 -1
  73. package/dist/run.js +22 -8
  74. package/dist/run.js.map +1 -1
  75. package/dist/sentence-shape.d.ts +6 -0
  76. package/dist/sentence-shape.d.ts.map +1 -1
  77. package/dist/sentence-shape.js +15 -0
  78. package/dist/sentence-shape.js.map +1 -1
  79. package/dist/structure/bare-numbers.d.ts +14 -0
  80. package/dist/structure/bare-numbers.d.ts.map +1 -0
  81. package/dist/structure/bare-numbers.js +53 -0
  82. package/dist/structure/bare-numbers.js.map +1 -0
  83. package/dist/structure/build.d.ts +2 -0
  84. package/dist/structure/build.d.ts.map +1 -1
  85. package/dist/structure/build.js +18 -8
  86. package/dist/structure/build.js.map +1 -1
  87. package/dist/structure/date-order.d.ts +12 -0
  88. package/dist/structure/date-order.d.ts.map +1 -0
  89. package/dist/structure/date-order.js +30 -0
  90. package/dist/structure/date-order.js.map +1 -0
  91. package/dist/structure/issues.d.ts.map +1 -1
  92. package/dist/structure/issues.js +11 -2
  93. package/dist/structure/issues.js.map +1 -1
  94. package/dist/structure/of.d.ts.map +1 -1
  95. package/dist/structure/of.js +4 -2
  96. package/dist/structure/of.js.map +1 -1
  97. package/dist/structure/relative-find.d.ts +2 -0
  98. package/dist/structure/relative-find.d.ts.map +1 -1
  99. package/dist/structure/relative-find.js +11 -0
  100. package/dist/structure/relative-find.js.map +1 -1
  101. package/dist/structure/runs.d.ts +20 -0
  102. package/dist/structure/runs.d.ts.map +1 -0
  103. package/dist/structure/runs.js +63 -0
  104. package/dist/structure/runs.js.map +1 -0
  105. package/dist/structure/total.d.ts +15 -0
  106. package/dist/structure/total.d.ts.map +1 -0
  107. package/dist/structure/total.js +143 -0
  108. package/dist/structure/total.js.map +1 -0
  109. package/dist/structure/universal.d.ts +2 -2
  110. package/dist/structure/universal.d.ts.map +1 -1
  111. package/dist/structure/universal.js +13 -4
  112. package/dist/structure/universal.js.map +1 -1
  113. package/dist/structure/unnumbered.d.ts +15 -0
  114. package/dist/structure/unnumbered.d.ts.map +1 -0
  115. package/dist/structure/unnumbered.js +28 -0
  116. package/dist/structure/unnumbered.js.map +1 -0
  117. package/dist/structure/weekday.d.ts +7 -0
  118. package/dist/structure/weekday.d.ts.map +1 -0
  119. package/dist/structure/weekday.js +21 -0
  120. package/dist/structure/weekday.js.map +1 -0
  121. package/dist/version.d.ts +5 -0
  122. package/dist/version.d.ts.map +1 -1
  123. package/dist/version.js +17 -2
  124. package/dist/version.js.map +1 -1
  125. package/package.json +3 -3
  126. package/profiles/statute.yaml +5 -1
  127. package/rules/ai-tell.yaml +1 -0
  128. package/rules/closing-cliche.yaml +1 -0
  129. package/rules/concrete-evidence-density.yaml +2 -0
  130. package/rules/cushion-phrase-density.yaml +1 -0
  131. package/rules/date-order.yaml +26 -0
  132. package/rules/date-weekday-mismatch.yaml +26 -0
  133. package/rules/double-keigo.yaml +1 -0
  134. package/rules/empty-intensifier.yaml +1 -0
  135. package/rules/excessive-hedging.yaml +1 -0
  136. package/rules/hiragana-fukushi.yaml +1 -0
  137. package/rules/internal-jargon.yaml +1 -1
  138. package/rules/max-kanji-continuous.yaml +2 -0
  139. package/rules/ngram-repetition.yaml +2 -0
  140. package/rules/padded-intro.yaml +1 -0
  141. package/rules/repeated-conjunction.yaml +1 -0
  142. package/rules/sasete-itadaku.yaml +1 -0
  143. package/rules/total-mismatch.yaml +26 -0
  144. package/rules/unqualified-superlative.yaml +1 -0
  145. package/src/cli-text.ts +2 -0
  146. package/src/cli.ts +5 -1
  147. package/src/detectors/char-shape.ts +38 -3
  148. package/src/detectors/en-shape.ts +138 -7
  149. package/src/detectors/gram-place.ts +41 -0
  150. package/src/detectors/index.ts +4 -1
  151. package/src/detectors/lexicon-match.ts +44 -0
  152. package/src/detectors/lexicon.ts +25 -22
  153. package/src/detectors/phrase-match.ts +8 -7
  154. package/src/detectors/repeated-head.ts +21 -7
  155. package/src/detectors/sentence-ending.ts +7 -3
  156. package/src/detectors/signals.ts +188 -21
  157. package/src/detectors/structure-tree.ts +67 -1
  158. package/src/detectors/team.ts +7 -2
  159. package/src/detectors/word-list.ts +2 -0
  160. package/src/document.ts +25 -9
  161. package/src/lexicon-tokens.ts +28 -0
  162. package/src/page-furniture.ts +45 -0
  163. package/src/plugin.ts +24 -3
  164. package/src/profile/parse.ts +15 -2
  165. package/src/render/compact.ts +15 -2
  166. package/src/rule-load.ts +1 -0
  167. package/src/run.ts +24 -8
  168. package/src/sentence-shape.ts +16 -0
  169. package/src/structure/bare-numbers.ts +78 -0
  170. package/src/structure/build.ts +27 -9
  171. package/src/structure/date-order.ts +44 -0
  172. package/src/structure/issues.ts +16 -2
  173. package/src/structure/of.ts +4 -2
  174. package/src/structure/relative-find.ts +13 -0
  175. package/src/structure/runs.ts +71 -0
  176. package/src/structure/total.ts +183 -0
  177. package/src/structure/universal.ts +14 -3
  178. package/src/structure/unnumbered.ts +33 -0
  179. package/src/structure/weekday.ts +25 -0
  180. package/src/version.ts +21 -3
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
 
@@ -213,6 +219,11 @@ export type DocumentProfile = {
213
219
  readonly caption?: string | undefined;
214
220
  /** 前条・同項のように、書いた場所から番地が決まる参照の読み方。無ければ読まない。 */
215
221
  readonly relative?: RelativeVocabulary | undefined;
222
+ /**
223
+ * 番号を書かない単位(古い法令の第 2 項以降)。indent で始まる行を、inside の種類の単位の中の、depth の深さの次の単位にする。
224
+ * inside の単位の行に、番号の後ろの本文が続くときだけ。
225
+ */
226
+ readonly unnumbered?: { readonly indent: string; readonly inside: string; readonly depth: number } | undefined;
216
227
  };
217
228
 
218
229
  /**
@@ -239,9 +250,17 @@ export type RelativeVocabulary = {
239
250
  readonly notAfter: string | undefined;
240
251
  /**
241
252
  * 読み替え(「『前条』とあるのは『…』」)の括弧。中の前条・同項は読み替える先の文の言葉で、ここからは決められないので読まない。
242
- * 閉じの直後に after、開きの直前に before のどれかがあるものだけ。
253
+ * 閉じの直後に after、開きの直前に before のどれかがあるものだけ。中の番地を名指しした参照は、document の名前の文書を指す。
243
254
  */
244
- readonly substitution: { readonly open: string; readonly close: string; readonly after: readonly string[]; readonly before: readonly string[] } | undefined;
255
+ readonly substitution:
256
+ | {
257
+ readonly open: string;
258
+ readonly close: string;
259
+ readonly after: readonly string[];
260
+ readonly before: readonly string[];
261
+ readonly document: string | undefined;
262
+ }
263
+ | undefined;
245
264
  /** 条を書かずにこの単位で始まる番地(法令の「第一項」「第二号」)は、書いた場所を含むまとまりの中を指す。 */
246
265
  readonly inside: readonly string[];
247
266
  /**
@@ -330,6 +349,8 @@ export type RuleDefinition = {
330
349
  readonly where: string | undefined;
331
350
  /** adapter に要る capability。"pos" / "lemma"。満たさなければ動かさない。spec §16。 */
332
351
  readonly requires: readonly string[];
352
+ /** 使えるなら用意してほしい capability("pos")。requires と違い、満たせなくても動かす(品詞が無ければ文字だけで見る)。 */
353
+ readonly uses: readonly string[];
333
354
  /** 複合シグナル。ここに並べた rule のうち何本が出たかを見る。spec §20.2。 */
334
355
  readonly from: readonly string[];
335
356
  /** 動かす言語。未指定は全言語。「ですます調」のように言語に固有の rule が使う。 */
@@ -41,7 +41,16 @@ const substitutionOf = (value: unknown): RelativeVocabulary["substitution"] => {
41
41
  if (!isRecord(value)) return undefined;
42
42
  const [open, close] = [text(value["open"]), text(value["close"])];
43
43
  const [after, before] = [strings(value["after"]), strings(value["before"])];
44
- return open === undefined || close === undefined || after.length + before.length === 0 ? undefined : { open, close, after, before };
44
+ return open === undefined || close === undefined || after.length + before.length === 0
45
+ ? undefined
46
+ : { open, close, after, before, document: text(value["document"]) };
47
+ };
48
+
49
+ /** 字下げと、どの種類の中か、どの深さかのどれかが無ければ読まない。 */
50
+ const unnumberedOf = (value: unknown): DocumentProfile["unnumbered"] => {
51
+ if (!isRecord(value)) return undefined;
52
+ const [indent, inside, depth] = [text(value["indent"]), text(value["inside"]), value["depth"]];
53
+ return indent === undefined || inside === undefined || !Number.isInteger(depth) || typeof depth !== "number" ? undefined : { indent, inside, depth };
45
54
  };
46
55
 
47
56
  /** 単位が一つも無ければ読まない。何を指すのか決められない。 */
@@ -78,7 +87,11 @@ const languagesOf = (id: string, raw: Record<string, unknown>): Record<string, D
78
87
  const addressEnd = text(section["address_end"]);
79
88
  const caption = text(section["caption"]);
80
89
  const relative = relativeOf(section["relative"]);
81
- return [language, { id, addresses: strings(section["addresses"]), connectives: strings(section["connectives"]), addressEnd, caption, relative }];
90
+ const unnumbered = unnumberedOf(section["unnumbered"]);
91
+ return [
92
+ language,
93
+ { id, addresses: strings(section["addresses"]), connectives: strings(section["connectives"]), addressEnd, caption, relative, unnumbered },
94
+ ];
82
95
  }),
83
96
  );
84
97
 
@@ -1,6 +1,18 @@
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";
5
+
6
+ /** 「1 finding」「2 findings」。 */
7
+ const counted = (count: number, noun: string): string => `${String(count)} ${noun}${count === 1 ? "" : "s"}`;
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
+ };
4
16
 
5
17
  /** エンジニア向け。1 件 2 行。 */
6
18
  export const renderCompact = (header: string, result: RunResult, rules: readonly RuleDefinition[], language: string): string => {
@@ -11,8 +23,9 @@ export const renderCompact = (header: string, result: RunResult, rules: readonly
11
23
  const at = `${finding.line}:${finding.column}`.padEnd(8);
12
24
  return [` ${at}${finding.severity.padEnd(8)}${messageOf(rule, finding, language)}`, ` ${" ".repeat(8)}${finding.rule}`];
13
25
  });
14
- const tail = [`${result.findings.length} findings`, result.skipped.length > 0 ? `${result.skipped.length} rules 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(
15
28
  (part) => part !== undefined,
16
29
  );
17
- return ["", header, "", ...lines, "", tail.join(", "), ""].join("\n");
30
+ return ["", header, "", ...lines, "", tail.join(text.separator), ""].join("\n");
18
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
@@ -3,8 +3,11 @@ import { resolve } from "./levels.ts";
3
3
  import type { AdapterNeeds, Finding, Level, ProseDocument, RuleDefinition } from "./plugin.ts";
4
4
  import { lineStarts, placeOf } from "./position.ts";
5
5
  import { REASONS, type Reasons } from "./reasons.ts";
6
+ import { joinWords } from "./detectors/word-list.ts";
6
7
  import { unreadStructure, type Unread } from "./structure/unread.ts";
7
8
  import { isMarkdownPath } from "./structure/markdown-path.ts";
9
+ import { maskSpans } from "./mask.ts";
10
+ import { textOutline } from "./page-furniture.ts";
8
11
  import { uiLanguageOf } from "./ui.ts";
9
12
 
10
13
  export type Skipped = { readonly rule: string; readonly why: string };
@@ -32,14 +35,27 @@ const reasonsFor = (doc: ProseDocument): Reasons => REASONS[uiLanguageOf(doc.lan
32
35
  * 構造の rule が動けない理由。木を作れない言語か、番号の行を読めなかった文書。
33
36
  * 読めなかった文書で「参照先が無い」が 0 件なのは、確かめた結果ではないので、読めなかったと言う。
34
37
  */
38
+ /**
39
+ * 木を読む rule の要求。structure は番号の並びを読む(読めなかった文書では動かない)。dates と quantities は木の日付と数量だけを
40
+ * 読むので、番号を読めなかった文書でも動く。どちらも capability ではなく、adapter が木を作れるかで決まる。
41
+ */
42
+ const TREE_NEEDS: ReadonlySet<string> = new Set(["structure", "dates", "quantities"]);
43
+ const VALUE_NEEDS: readonly string[] = ["dates", "quantities"];
44
+
45
+ const treeNeed = (rule: RuleDefinition, doc: ProseDocument): string | undefined => {
46
+ if (rule.requires.includes("structure")) return treeProblem(doc);
47
+ return rule.requires.some((need) => VALUE_NEEDS.includes(need)) && doc.structure === undefined ? reasonsFor(doc).noStructure(doc.language) : undefined;
48
+ };
49
+
35
50
  const treeProblem = (doc: ProseDocument): string | undefined => {
36
51
  if (doc.structure === undefined) return reasonsFor(doc).noStructure(doc.language);
37
52
  const unread = unreadOf(doc);
38
53
  return unread === undefined ? undefined : reasonsFor(doc).unreadStructure(unread.clauses, unread.units);
39
54
  };
40
55
 
41
- /** 木が読んだのと同じ本文。Markdown はコードを覆ったもの、.txt はそのまま(.txt の字下げはコードではない)。 */
42
- 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);
43
59
 
44
60
  const unreadByDocument = new WeakMap<ProseDocument, Unread | undefined>();
45
61
 
@@ -68,7 +84,7 @@ const hasTokens = (doc: ProseDocument): boolean => doc.sentences.length === 0 ||
68
84
  /** 要求を満たさない rule は動かせない。満たさないまま動かすと「指摘 0 件」が保証に見える。 */
69
85
  const unmet = (rule: RuleDefinition, doc: ProseDocument): string | undefined => {
70
86
  if (rule.languages !== undefined && !rule.languages.includes(doc.language)) return reasonsFor(doc).otherLanguage(doc.language);
71
- const missing = rule.requires.filter((need) => need !== "structure").find((need) => !has(doc.capabilities, need));
87
+ const missing = rule.requires.filter((need) => !TREE_NEEDS.has(need)).find((need) => !has(doc.capabilities, need));
72
88
  if (missing !== undefined) return reasonsFor(doc).noCapability(missing);
73
89
  return undefined;
74
90
  };
@@ -91,7 +107,7 @@ export const neededBy = (rules: readonly RuleDefinition[], settings: Settings, e
91
107
  pos: forGenre(rules, genre)
92
108
  .filter((rule) => rule.layer !== "L4" && levelFor(rule, settings, experimental) !== "off")
93
109
  .filter((rule) => rule.languages === undefined || rule.languages.includes(language))
94
- .some((rule) => rule.requires.includes("pos") || rule.requires.includes("lemma")),
110
+ .some((rule) => [...rule.requires, ...rule.uses].some((need) => need === "pos" || need === "lemma")),
95
111
  });
96
112
 
97
113
  const place = (starts: readonly number[], finding: Finding): Finding => {
@@ -107,7 +123,7 @@ const place = (starts: readonly number[], finding: Finding): Finding => {
107
123
  * 元の指摘は消さない。`padded-intro` のように単独でも正しい指摘が混ざっており、
108
124
  * まとめるために消すと本物の指摘が見えなくなる。
109
125
  */
110
- const compositeOf = (rule: RuleDefinition, found: readonly Finding[], limit: number, starts: readonly number[]): Finding[] => {
126
+ const compositeOf = (rule: RuleDefinition, found: readonly Finding[], limit: number, starts: readonly number[], language: string): Finding[] => {
111
127
  const fired = rule.from.filter((id) => found.some((finding) => finding.rule === id));
112
128
  if (fired.length < limit) return [];
113
129
  const first = found.find((finding) => fired.includes(finding.rule));
@@ -118,7 +134,7 @@ const compositeOf = (rule: RuleDefinition, found: readonly Finding[], limit: num
118
134
  line: 0,
119
135
  column: 0,
120
136
  quote: first?.quote ?? "",
121
- values: { word: fired.join("、"), count: fired.length, limit, offset: first === undefined ? 0 : Number(first.values["offset"] ?? 0) },
137
+ values: { word: joinWords(fired, language), count: fired.length, limit, offset: first === undefined ? 0 : Number(first.values["offset"] ?? 0) },
122
138
  }),
123
139
  ];
124
140
  };
@@ -158,7 +174,7 @@ export const runRules = (
158
174
  if (noTags !== undefined) return { findings: acc.findings, skipped: [...acc.skipped, { rule: rule.id, why: noTags }] };
159
175
  // 木は capability ではなく、adapter が structure を持つかで決まる。持たない言語で動かすと「参照先が無い」が 0 件に見える。
160
176
  // 段階を見た後で聞く。doc.structure は触れたときに木を作るので、止めている rule のために作らない。
161
- const noTree = rule.requires.includes("structure") ? treeProblem(doc) : undefined;
177
+ const noTree = treeNeed(rule, doc);
162
178
  if (noTree !== undefined) return { findings: acc.findings, skipped: [...acc.skipped, { rule: rule.id, why: noTree }] };
163
179
  // 複合シグナルは二段目で扱う。一段目では「検出器が無い」と言わせない。
164
180
  if (rule.from.length > 0) return acc;
@@ -181,7 +197,7 @@ export const runRules = (
181
197
  );
182
198
  const composites = applicable
183
199
  .filter((rule) => rule.from.length > 0 && levelFor(rule, settings, experimental) !== "off")
184
- .flatMap((rule) => compositeOf(rule, outcome.findings, limitFor(rule, levelFor(rule, settings, experimental), genre, limits), starts));
200
+ .flatMap((rule) => compositeOf(rule, outcome.findings, limitFor(rule, levelFor(rule, settings, experimental), genre, limits), starts, doc.language));
185
201
  const all = [...outcome.findings, ...composites];
186
202
  return { ...outcome, findings: [...all].sort((left, right) => left.line - right.line), forcedExperimental: forced };
187
203
  };
@@ -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
+ };
@@ -1,12 +1,13 @@
1
1
  import type { Heading } from "../document.ts";
2
2
  import { atxHeadingText, headingText } from "../heading-text.ts";
3
3
  import { addressSpans } from "../address-chain.ts";
4
- import { relativeMentions } from "./relative-find.ts";
4
+ import { relativeMentions, substitutedDocuments } from "./relative-find.ts";
5
5
  import { resolveRelative } from "./relative-resolve.ts";
6
6
  import { maskSpans } from "../mask.ts";
7
7
  import type { DocumentProfile, Mention, NumberedLine, NumberingContext, Span, StructureKind, StructureNode, StructurePatterns } from "../plugin.ts";
8
8
  import { lineNumberAt, linesOf, type Line } from "./lines.ts";
9
9
  import { dottedNumber } from "./universal.ts";
10
+ import { unnumberedUnit, type OpenUnit } from "./unnumbered.ts";
10
11
 
11
12
  /** 組み立て中の節点。できあがったら StructureNode に固める。 */
12
13
  type Draft = {
@@ -24,7 +25,7 @@ type Draft = {
24
25
  };
25
26
 
26
27
  /** 開いている節点。rank の大きいものほど内側。 */
27
- type Frame = { readonly draft: Draft; readonly rank: number; readonly numbered?: NumberedLine | undefined };
28
+ type Frame = { readonly draft: Draft; readonly rank: number; readonly numbered?: NumberedLine | undefined; readonly bodyOnLine?: boolean };
28
29
 
29
30
  /**
30
31
  * 見出しは深さ 1〜6 を rank にする。番号のまとまりは必ず見出しの内側に入るので、
@@ -43,6 +44,7 @@ type State = {
43
44
  readonly profile: DocumentProfile | undefined;
44
45
  /** 見出しだけの行(文書の種類の caption)の行番号と、その言葉。すぐ次の行の条が見出しに使う。 */
45
46
  readonly captions: Map<number, string>;
47
+ readonly plainText: boolean;
46
48
  };
47
49
 
48
50
  const draftOf = (kind: StructureKind, address: string, line: Line, attrs: Readonly<Record<string, string | number>>): Draft => ({
@@ -96,7 +98,7 @@ const headingAddress = (state: State, depth: number): string => {
96
98
  * 見出しに書いた番号(「## 第3条」)は、見出しの深さで入れ子にする。本文の番号は見出しの内側に入る。
97
99
  * 見出しの深さを捨てると、「## 第3条」の下の「### 詳細」が条の外に出てしまう。
98
100
  */
99
- const openNumbered = (state: State, line: Line, numbered: NumberedLine, headingDepth: number | undefined): void => {
101
+ const openNumbered = (state: State, line: Line, numbered: NumberedLine, headingDepth: number | undefined, bodyOnLine: boolean): void => {
100
102
  const attrs = { label: numbered.label, ...(numbered.heading === "" ? {} : { heading: numbered.heading }) };
101
103
  const rank = headingDepth ?? NUMBERED_RANK + numbered.depth;
102
104
  // 番地は、自分と同じか内側を閉じてから決める。閉じる前だと、同じ深さの前の条を親と取り違える。
@@ -108,7 +110,7 @@ const openNumbered = (state: State, line: Line, numbered: NumberedLine, headingD
108
110
  ordinalTo: numbered.ordinalTo,
109
111
  numbering: numbered.numbering,
110
112
  };
111
- open(state, { draft, rank, numbered });
113
+ open(state, { draft, rank, numbered, bodyOnLine });
112
114
  };
113
115
 
114
116
  const openSection = (state: State, line: Line, heading: Heading): void => {
@@ -130,7 +132,7 @@ type LeafFinder = (patterns: StructurePatterns, text: string, profile: DocumentP
130
132
 
131
133
  const LEAVES: readonly { readonly kind: StructureKind; readonly find: LeafFinder }[] = [
132
134
  { kind: "definition", find: (patterns, text) => patterns.definitions(text) },
133
- { kind: "reference", find: (patterns, text) => patterns.references(text) },
135
+ { kind: "reference", find: (patterns, text, profile) => substitutedDocuments(patterns.references(text), text, profile) },
134
136
  {
135
137
  kind: "reference",
136
138
  find: (patterns, text, profile) => {
@@ -144,8 +146,8 @@ const LEAVES: readonly { readonly kind: StructureKind; readonly find: LeafFinder
144
146
  ];
145
147
 
146
148
  /** 言語を問わない通し番号。後ろが単位なら数量なので番号にしない。 */
147
- const universalNumber = (patterns: StructurePatterns, text: string, context: NumberingContext): NumberedLine | undefined => {
148
- const dotted = dottedNumber(text, context);
149
+ const universalNumber = (patterns: StructurePatterns, text: string, context: NumberingContext, plainText: boolean): NumberedLine | undefined => {
150
+ const dotted = dottedNumber(text, context, plainText);
149
151
  return dotted !== undefined && patterns.countedAfter?.(dotted.number, dotted.rest) === true ? undefined : dotted;
150
152
  };
151
153
 
@@ -230,6 +232,8 @@ export type StructureInput = {
230
232
  readonly source: string;
231
233
  readonly language: string;
232
234
  readonly outline: Outline;
235
+ /** Markdown か、テキストか。テキストの仕様書だけの書き方(「5. Security Considerations」)がある。 */
236
+ readonly markdown: boolean;
233
237
  readonly profile?: DocumentProfile | undefined;
234
238
  };
235
239
 
@@ -265,6 +269,18 @@ const captionOf = (profile: DocumentProfile | undefined, text: string): string |
265
269
  return words === "" ? undefined : words;
266
270
  };
267
271
 
272
+ const openUnits = (state: State): OpenUnit[] =>
273
+ state.stack.flatMap((frame) => (frame.numbered === undefined ? [] : [{ numbered: frame.numbered, bodyOnLine: frame.bodyOnLine === true }]));
274
+
275
+ /** 言語の番号の読み方、文書の種類の番号を書かない単位、言語を問わない通し番号の順に読む。 */
276
+ const numberedLine = (state: State, patterns: StructurePatterns, text: string, context: NumberingContext): NumberedLine | undefined =>
277
+ patterns.numbered(text, context) ??
278
+ unnumberedUnit(text, openUnits(state), state.profile?.unnumbered) ??
279
+ universalNumber(patterns, text, context, state.plainText);
280
+
281
+ /** 番号の後ろが見出しでなく本文か。前の行から見出しが付く前の、行そのもので決める。 */
282
+ const carriesBody = (found: NumberedLine | undefined): boolean => found !== undefined && found.heading === "" && found.rest !== "";
283
+
268
284
  /** 見出しの無い条のすぐ前の行が見出しだけの行なら、それを条の見出しにする。 */
269
285
  const withCaption = (state: State, line: Line, numbered: NumberedLine | undefined): NumberedLine | undefined => {
270
286
  const caption = numbered?.kind === "article" && numbered.heading === "" ? state.captions.get(line.number - 1) : undefined;
@@ -275,12 +291,13 @@ const readLine = (state: State, patterns: StructurePatterns, line: Line, heading
275
291
  const text = heading === undefined ? line.text : headingLineText(line.text);
276
292
  const openNumbers = state.stack.flatMap((frame) => (frame.numbered === undefined ? [] : [frame.numbered]));
277
293
  const context = { open: openNumbers, isHeading: heading !== undefined };
278
- const numbered = withCaption(state, line, patterns.numbered(text, context) ?? universalNumber(patterns, text, context));
294
+ const found = numberedLine(state, patterns, text, context);
295
+ const numbered = withCaption(state, line, found);
279
296
  const caption = numbered === undefined && heading === undefined ? captionOf(state.profile, text) : undefined;
280
297
  if (caption !== undefined) state.captions.set(line.number, caption);
281
298
  // 番号付きの見出しも見出しの通し番号を進める。進めないと、その下の「### 詳細」が前の見出しの番地を名乗る。
282
299
  if (numbered !== undefined && heading !== undefined) headingAddress(state, heading.depth);
283
- if (numbered !== undefined) openNumbered(state, line, numbered, heading?.depth);
300
+ if (numbered !== undefined) openNumbered(state, line, numbered, heading?.depth, carriesBody(found));
284
301
  else if (heading !== undefined) openSection(state, line, heading);
285
302
  extend(state, line.start + line.text.length);
286
303
  // 番号付きの行は番号の後ろだけを読む。「第3条(支払)」の「第3条」を自分への参照として拾わない。
@@ -309,6 +326,7 @@ export const buildTree = (input: StructureInput, patterns: StructurePatterns): S
309
326
  widerScopes: new Map(),
310
327
  profile: input.profile,
311
328
  captions: new Map(),
329
+ plainText: !input.markdown,
312
330
  };
313
331
  lines.forEach((line) => {
314
332
  if (line.text.trim() !== "") readLine(state, patterns, line, headings.get(line.number));
@@ -0,0 +1,44 @@
1
+ import type { StructureIssue } from "./issues.ts";
2
+ import { runsOf, type Line } from "./runs.ts";
3
+
4
+ /**
5
+ * 日程として並べた日付の順番。箇条書きの続いた項目か、表の続いた行で、日付をちょうど一つ持つものを並びとして読む。
6
+ * 並びの向き(古い順か新しい順か)は多いほうで決め、それに逆らう一歩だけを言う。新しい順に並べた履歴は正しい並び。
7
+ * 向きが決まらない並び(上がりと下がりが同じ数)は何も言わない。逆らう一歩を言えるのは、日付が 4 つ以上の並びだけになる。
8
+ */
9
+ export type DatedPoint = { readonly offset: number; readonly value: string };
10
+
11
+ /** 並びとして比べられる日付の書き方。年月日、年月、月日は、同じ書き方どうしでしか比べない。 */
12
+ const PRECISIONS = [/^\d{4}-\d{2}-\d{2}$/u, /^\d{4}-\d{2}$/u, /^\d{2}-\d{2}$/u];
13
+
14
+ /** 行ごとの日付。ちょうど一つの行だけを並びに入れる。二つある行(期間)はどちらを並べたのか決められない。 */
15
+ const datedLines = (run: readonly Line[], points: readonly DatedPoint[]): DatedPoint[] =>
16
+ run.flatMap((line) => {
17
+ const inside = points.filter((point) => point.offset >= line.start && point.offset <= line.end);
18
+ return inside.length === 1 && inside[0] !== undefined ? [inside[0]] : [];
19
+ });
20
+
21
+ const samePrecision = (dated: readonly DatedPoint[]): boolean => PRECISIONS.some((pattern) => dated.every((point) => pattern.test(point.value)));
22
+
23
+ /** 上がりが多ければ 1、下がりが多ければ -1、同じ数なら 0(向きを決めない)。 */
24
+ const majorityOf = (up: number, down: number): number => Math.sign(up - down);
25
+
26
+ /** 多いほうの向きに逆らう一歩。後ろの項目の日付を指す。 */
27
+ const againstMajority = (dated: readonly DatedPoint[]): StructureIssue[] => {
28
+ const steps = dated
29
+ .slice(1)
30
+ .map((point, index) => ({ point, previous: dated[index], sign: Math.sign(point.value.localeCompare(dated[index]?.value ?? "")) }));
31
+ const up = steps.filter((step) => step.sign > 0).length;
32
+ const down = steps.filter((step) => step.sign < 0).length;
33
+ const majority = majorityOf(up, down);
34
+ if (majority === 0) return [];
35
+ return steps
36
+ .filter((step) => step.sign === -majority)
37
+ .map((step) => ({ offset: step.point.offset, values: { date: step.point.value, previous: step.previous?.value ?? "" } }));
38
+ };
39
+
40
+ export const dateOrderBreaks = (source: string, points: readonly DatedPoint[]): StructureIssue[] =>
41
+ runsOf(source).flatMap((run) => {
42
+ const dated = datedLines(run, points);
43
+ return samePrecision(dated) ? againstMajority(dated) : [];
44
+ });