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
@@ -30,7 +30,21 @@ const textOf = (node: StructureNode, key: string): string => {
30
30
  * 日本語の「第4条第1項」は、番号の無い第 1 項を持つ第4条を指しうる。
31
31
  */
32
32
  const resolves = (node: StructureNode, addresses: ReadonlySet<string>): boolean =>
33
- addresses.has(textOf(node, "target")) || (node.attrs["fallback"] !== undefined && addresses.has(textOf(node, "fallback")));
33
+ addresses.has(textOf(node, "target")) || (node.attrs["fallback"] !== undefined && addresses.has(fallbackKey(node)));
34
+
35
+ /**
36
+ * fallbackLabel があれば、fallback はその見出しの番号で書かれた節点だけに当たる。
37
+ * 「第3章」の fallback 3 は「## 3. 構成」には当たり、同じ番地の「第3条」には当たらない。
38
+ */
39
+ const labelled = (address: string, label: string): string => `${address}\u0000${label}`;
40
+ const fallbackKey = (node: StructureNode): string =>
41
+ node.attrs["fallbackLabel"] === undefined ? textOf(node, "fallback") : labelled(textOf(node, "fallback"), textOf(node, "fallbackLabel"));
42
+
43
+ /** 見出しの下の章(h1/ch9)は、見出しの番地を除いた ch9 でも指せる。本文の「第9章」は見出しの番地を書かない。 */
44
+ const addressesOf = (node: StructureNode): string[] =>
45
+ node.kind === "chapter" && node.address.includes("/") ? [node.address, node.address.slice(node.address.lastIndexOf("/") + 1)] : [node.address];
46
+
47
+ const keysOf = (node: StructureNode): string[] => addressesOf(node).flatMap((address) => [address, labelled(address, textOf(node, "label"))]);
34
48
 
35
49
  /**
36
50
  * 参照の番地が木に無い。他の文書の名前が付いた参照(民法第709条、Section 9 of the Master Agreement)は引かない。
@@ -40,7 +54,7 @@ export const danglingReferences = (tree: StructureNode): StructureIssue[] => {
40
54
  const nodes = inDocumentOrder(tree);
41
55
  // 参照は条を指す。条を一つも持たない文書(契約書に付ける承諾書のひな形など)の「契約書第6条」は、別の文書の条。
42
56
  if (!nodes.some((node) => node.kind === "article")) return [];
43
- const addresses = new Set(nodes.filter((node) => NUMBERED.includes(node.kind)).map((node) => node.address));
57
+ const addresses = new Set(nodes.filter((node) => NUMBERED.includes(node.kind)).flatMap(keysOf));
44
58
  // Section で組んだ法令が "Articles 13 to 21 of the UK GDPR" と別の文書の Article を名指ししていれば、名の無い "Article 6(3)" もそちら。
45
59
  // 名指しがあれば、名の無い "Article 9" が書き間違いか向こうの条かは区別できないので黙る。名指しが無い文書では報告する。
46
60
  const numbering = (node: StructureNode): unknown => node.attrs["numbering"];
@@ -1,6 +1,7 @@
1
1
  import { markdownOutline } from "../document.ts";
2
2
  import type { DocumentProfile, StructureNode, StructurePatterns } from "../plugin.ts";
3
- import { buildTree, NO_OUTLINE } from "./build.ts";
3
+ import { buildTree } from "./build.ts";
4
+ import { textOutline } from "../page-furniture.ts";
4
5
 
5
6
  export type SourceInput = {
6
7
  readonly path: string;
@@ -20,7 +21,8 @@ export const buildStructure = (input: SourceInput, patterns: StructurePatterns):
20
21
  path: input.path,
21
22
  source: input.source,
22
23
  language: input.language,
23
- outline: input.markdown ? markdownOutline(input.source) : NO_OUTLINE,
24
+ outline: input.markdown ? markdownOutline(input.source) : textOutline(input.source),
25
+ markdown: input.markdown,
24
26
  profile: input.profile,
25
27
  },
26
28
  patterns,
@@ -105,6 +105,19 @@ const substitutionSpans = (text: string, quote: Substitution | undefined): Span[
105
105
  (span) => quote.after.some((marker) => text.startsWith(marker, span.end)) || quote.before.some((marker) => text.slice(0, span.start).endsWith(marker)),
106
106
  );
107
107
 
108
+ /** 読み替えの括弧の中の、文書を名指ししていない参照に、読み替える先の文書の名前を付ける。 */
109
+ export const substitutedDocuments = (mentions: readonly Mention[], text: string, profile: DocumentProfile | undefined): readonly Mention[] => {
110
+ const quote = profile?.relative?.substitution;
111
+ const document = quote?.document;
112
+ if (document === undefined) return mentions;
113
+ const spans = substitutionSpans(text, quote);
114
+ return mentions.map((mention) =>
115
+ mention.attrs["document"] === undefined && spans.some((span) => span.start <= mention.start && mention.start < span.end)
116
+ ? { ...mention, attrs: { ...mention.attrs, document } }
117
+ : mention,
118
+ );
119
+ };
120
+
108
121
  /** 閉じた括弧書きを内側から外す。 */
109
122
  const withoutAsides = (gap: string, aside: RelativeVocabulary["aside"]): string => {
110
123
  if (aside === undefined) return gap;
@@ -0,0 +1,71 @@
1
+ /**
2
+ * 箇条書きと表の行の並び。続いた箇条書きの同じ深さの項目か、続いた表の行を一つの並びとして読む。
3
+ * 並びの中の値(日付、金額)を比べる rule が共通に使う。
4
+ */
5
+ type Kind = "list" | "table";
6
+
7
+ const LIST_ITEM = /^[ \t]{0,12}(?:[-*+]|\d{1,3}[.)])[ \t]/u;
8
+ const TABLE_ROW = /^[ \t]{0,12}\|/u;
9
+
10
+ const TABLE_RULE = /^[ \t]{0,12}\|?[ \t]{0,4}:?-{3,}/u;
11
+ const INDENT = /^[ \t]*/u;
12
+
13
+ export type Line = { readonly start: number; readonly end: number; readonly kind: Kind | undefined; readonly indent: number; readonly rule: boolean };
14
+
15
+ const kindOf = (text: string): Kind | undefined => {
16
+ if (TABLE_ROW.test(text)) return "table";
17
+ return LIST_ITEM.test(text) ? "list" : undefined;
18
+ };
19
+
20
+ /** 先頭に | を書かない表(「Step | Date」「--- | ---」)の行。| のある見出しの下の区切り行から、| のある行が続くあいだ。 */
21
+ const PIPELESS_RULE = /^[ \t]{0,12}:?-{3,}:?[ \t]{0,4}\|/u;
22
+
23
+ const pipelessTableLines = (texts: readonly string[]): Set<number> => {
24
+ const inTable = new Set<number>();
25
+ texts.forEach((text, index) => {
26
+ if (!PIPELESS_RULE.test(text) || !(texts[index - 1] ?? "").includes("|")) return;
27
+ for (let row = index + 1; row < texts.length && (texts[row] ?? "").includes("|"); row += 1) inTable.add(row);
28
+ });
29
+ return inTable;
30
+ };
31
+
32
+ const linesOf = (source: string): Line[] => {
33
+ const texts = source.split("\n");
34
+ const pipeless = pipelessTableLines(texts);
35
+ const found: Line[] = [];
36
+ let start = 0;
37
+ texts.forEach((text, index) => {
38
+ const indent = INDENT.exec(text)?.[0].length ?? 0;
39
+ const kind = pipeless.has(index) ? "table" : kindOf(text);
40
+ found.push({ start, end: start + text.length, kind, indent, rule: TABLE_RULE.test(text) });
41
+ start += text.length + 1;
42
+ });
43
+ return found;
44
+ };
45
+
46
+ /**
47
+ * 続いた箇条書きや表の中で、同じ深さの行を一つの並びにする。
48
+ * 子の項目(深い字下げ)は親の並びを切らずに、親ごとに別の並びになる。浅い項目が来たら、それより深い並びは閉じる。
49
+ * 表の区切り行(|---|)のすぐ前の行は見出しなので、並びから外す。
50
+ */
51
+ export const runsOf = (source: string): Line[][] => {
52
+ const runs: Line[][] = [];
53
+ const open = new Map<number, Line[]>();
54
+ const close = (deeperThan: number): void => [...open.keys()].filter((depth) => depth > deeperThan).forEach((depth) => open.delete(depth));
55
+ linesOf(source).forEach((line, index, all) => {
56
+ if (line.kind === undefined || all[index - 1]?.kind !== line.kind) close(-1);
57
+ if (line.kind === undefined) return;
58
+ if (line.rule) {
59
+ open.get(line.indent)?.pop();
60
+ return;
61
+ }
62
+ close(line.indent);
63
+ const run = open.get(line.indent) ?? [];
64
+ if (!open.has(line.indent)) {
65
+ open.set(line.indent, run);
66
+ runs.push(run);
67
+ }
68
+ run.push(line);
69
+ });
70
+ return runs;
71
+ };
@@ -0,0 +1,183 @@
1
+ import type { StructureIssue } from "./issues.ts";
2
+ import { runsOf, type Line } from "./runs.ts";
3
+ import { bareNumbersIn, CELL_SEPARATOR, NO_UNIT } from "./bare-numbers.ts";
4
+
5
+ /**
6
+ * 合計の行と、その上に並べた金額の和。箇条書きの続いた項目か、表の続いた行で、合計の語(合計・小計・Total)で始まる行を読む。
7
+ * 同じ単位で同じ列(箇条書きは一つの列)の金額を上から足し、合計の行の金額と合わなければ言う。
8
+ * 小計と税のあとの総計のように、足し方が何通りかある。どの足し方でも合わないときだけ言う。
9
+ * 表の升に数だけを書いた列(単位は見出しにある)も足す。そのときは上の行の升がどれも数でなければ足さない。年や番号の列を足さないため。
10
+ */
11
+ export type Amount = { readonly offset: number; readonly end: number; readonly value: number; readonly unit: string };
12
+
13
+ const CENTS = 100;
14
+
15
+ /** 区切りのコンマを書かずに 4 桁以上続けた整数部(1205、1205.50)。 */
16
+ const UNGROUPED = /(?<![\d.])\d{4}/u;
17
+ const MIN_PARTS = 2;
18
+
19
+ /** 金額の前に書いた負の印(-500、▲500、-$500)。行頭の箇条書きの - は印にしない。 */
20
+ const NEGATIVE_BEFORE = /[-−▲△][ \t]?(?:[$€£¥¥][ \t]?)?$/u;
21
+
22
+ /** 括弧に入れた金額((1,200))。会計では負の数だが、補足の金額とも読めて符号が決まらない。 */
23
+ const OPEN_PAREN_BEFORE = /[((][ \t]?(?:[$€£¥¥][ \t]?)?$/u;
24
+
25
+ type Cell = { readonly column: number; readonly unit: string };
26
+ /** cents が undefined の金額は、符号が決まらない。 */
27
+ type Placed = Amount & Cell & { readonly cents: number | undefined };
28
+ type Entry = { readonly label: boolean; readonly amounts: readonly Placed[] };
29
+
30
+ const columnOf = (source: string, line: Line, offset: number): number => source.slice(line.start, offset).split(CELL_SEPARATOR).length - 1;
31
+
32
+ const isNegative = (source: string, line: Line, amount: Amount): boolean => {
33
+ const before = source.slice(line.start, amount.offset);
34
+ const marker = NEGATIVE_BEFORE.exec(before);
35
+ return marker !== null && before.slice(0, marker.index).trim().length > 0;
36
+ };
37
+
38
+ /** 行の最初の文字列(表なら最初の列)から、強調の印と箇条書きの印を除いたもの。 */
39
+ const leadingText = (text: string): string =>
40
+ text
41
+ .replace(/^[ \t]*(?:[-*+]|\d{1,3}[.)])?[ \t]*\|?[ \t]*/u, "")
42
+ .replace(/^[*_]+/u, "")
43
+ .trimStart();
44
+
45
+ /**
46
+ * 合計の語のすぐ後ろ。行や列の終わり、区切り(: |)、括弧の注記(合計(税込))、金額。
47
+ * 「計画」や「Total conversion: 25%」のように語が続くものは合計の行ではない。
48
+ */
49
+ const AFTER_LABEL = /^[*_]*[ \t\u3000]*(?:$|[::|((]|[-−▲△$€£¥¥\p{N}])/u;
50
+
51
+ const isTotalLabel = (text: string, labels: readonly string[]): boolean => {
52
+ const lead = leadingText(text).toLowerCase();
53
+ return labels.some((label) => lead.startsWith(label.toLowerCase()) && AFTER_LABEL.test(lead.slice(label.length)));
54
+ };
55
+
56
+ const centsOf = (source: string, line: Line, amount: Amount): number | undefined => {
57
+ if (OPEN_PAREN_BEFORE.test(source.slice(line.start, amount.offset))) return undefined;
58
+ return Math.round(amount.value * CENTS) * (isNegative(source, line, amount) ? -1 : 1);
59
+ };
60
+
61
+ const entryOf = (source: string, line: Line, amounts: readonly Amount[], labels: readonly string[]): Entry => ({
62
+ label: isTotalLabel(source.slice(line.start, line.end), labels),
63
+ amounts: amounts
64
+ .filter((amount) => amount.offset >= line.start && amount.offset <= line.end)
65
+ .map((amount) => ({ ...amount, column: columnOf(source, line, amount.offset), cents: centsOf(source, line, amount) })),
66
+ });
67
+
68
+ const sameCell = (a: Cell, b: Cell): boolean => a.column === b.column && a.unit === b.unit;
69
+
70
+ const sumOf = (values: readonly number[]): number => values.reduce((sum, value) => sum + value, 0);
71
+
72
+ type RowsAbove = { readonly parts: number[]; readonly totals: number[]; sinceLast: number[] };
73
+
74
+ /**
75
+ * 行の中で、その列と単位の値。無ければ null、二つ以上あるか、列に別の単位しか無いか、符号が決まらなければ undefined。
76
+ * 数だけの列では、数の無い升(空、文字、括弧)も undefined。
77
+ */
78
+ const valueIn = (entry: Entry, cell: Cell): number | null | undefined => {
79
+ const inColumn = entry.amounts.filter((amount) => amount.column === cell.column);
80
+ const matching = inColumn.filter((amount) => sameCell(amount, cell));
81
+ if (matching.length === 0) return inColumn.length === 0 && cell.unit !== NO_UNIT ? null : undefined;
82
+ return matching.length === 1 ? matching[0]?.cents : undefined;
83
+ };
84
+
85
+ const addTo = (above: RowsAbove, entry: Entry, value: number): void => {
86
+ if (entry.label) {
87
+ above.totals.push(value);
88
+ above.sinceLast = [];
89
+ return;
90
+ }
91
+ above.parts.push(value);
92
+ above.sinceLast.push(value);
93
+ };
94
+
95
+ /**
96
+ * 合計の行より上の、同じ列と単位の値。項目の行と合計の行に分け、最後の合計の行より後の項目も取る。
97
+ * 足し方が決まらない行(valueIn が undefined)が一つでもあれば undefined。1.2万円と円のように単位が割れた列もここで止まる。
98
+ */
99
+ const windowAbove = (entries: readonly Entry[], cell: Cell): RowsAbove | undefined => {
100
+ const values = entries.map((entry) => valueIn(entry, cell));
101
+ if (values.includes(undefined)) return undefined;
102
+ const above: RowsAbove = { parts: [], totals: [], sinceLast: [] };
103
+ entries.forEach((entry, index) => {
104
+ const value = values[index];
105
+ if (typeof value === "number") addTo(above, entry, value);
106
+ });
107
+ return above;
108
+ };
109
+
110
+ /** 足し方の候補: 上の項目すべて(小計を並べた総計もこれ)、最後の合計より後の項目、最後の合計にその後の項目。項目が無ければ小計の和だけ。 */
111
+ const candidateSums = (above: RowsAbove): number[] => {
112
+ if (above.parts.length === 0) return [sumOf(above.totals)];
113
+ const last = above.totals.at(-1);
114
+ return [sumOf(above.parts), sumOf(above.sinceLast), ...(last === undefined ? [] : [last + sumOf(above.sinceLast)])];
115
+ };
116
+
117
+ /** 項目が無く小計だけが並ぶ表は、小計の和。 */
118
+ const reportedSum = (above: RowsAbove): number => (above.parts.length === 0 ? sumOf(above.totals) : (above.totals.at(-1) ?? 0) + sumOf(above.sinceLast));
119
+
120
+ /** 和と呼べるだけの行があるか。項目が二つ以上か、項目が無く小計が二つ以上。 */
121
+ const enoughRows = (above: RowsAbove): boolean => above.parts.length >= MIN_PARTS || (above.parts.length === 0 && above.totals.length >= MIN_PARTS);
122
+
123
+ /** 和は、合計の行と同じ小数の桁と区切りで見せる($10,160.00 なら $10,260.00、1205 なら 1305)。端数があれば 2 桁。 */
124
+ const formatCents = (cents: number, written: string): string => {
125
+ const digits = Math.max(/\.\d{2}/u.test(written) ? 2 : 0, cents % CENTS === 0 ? 0 : 2);
126
+ const useGrouping = !UNGROUPED.test(written);
127
+ return (cents / CENTS).toLocaleString("en-US", { minimumFractionDigits: digits, maximumFractionDigits: digits, useGrouping });
128
+ };
129
+
130
+ /** 数の前に書いた単位($1,200、$ 1,200)の始まり。木の数量は数から始まるので、見せるときは前の単位も入れる。 */
131
+ const unitStartBefore = (source: string, amount: Amount): number | undefined => {
132
+ if (amount.unit === NO_UNIT) return undefined;
133
+ const gap = /[ \t]/u.test(source[amount.offset - 1] ?? "") ? 1 : 0;
134
+ const start = amount.offset - gap - amount.unit.length;
135
+ return start >= 0 && source.slice(start, amount.offset - gap) === amount.unit ? start : undefined;
136
+ };
137
+
138
+ /** 負の金額の前に書いた印(-、▲)。見せる金額に入れる。 */
139
+ const SIGN_BEFORE = /[-−▲△][ \t]?$/u;
140
+ const SIGN_REACH = 2;
141
+
142
+ const signBefore = (source: string, total: Placed, start: number): string =>
143
+ total.cents !== undefined && total.cents < 0 ? (SIGN_BEFORE.exec(source.slice(Math.max(0, start - SIGN_REACH), start))?.[0] ?? "") : "";
144
+
145
+ /** 合計の行に書いた金額と、上の和を、同じ書き方で見せる。負の和には、合計に書いた印(無ければ -)を付ける。 */
146
+ const shownAmounts = (source: string, total: Placed, sumCents: number): Record<string, string> => {
147
+ const unitStart = unitStartBefore(source, total);
148
+ const start = unitStart ?? total.offset;
149
+ const sign = signBefore(source, total, start);
150
+ const written = source.slice(start - sign.length, total.end);
151
+ const sumSign = sumCents < 0 ? sign || "-" : "";
152
+ const sum = formatCents(Math.abs(sumCents), written);
153
+ if (unitStart !== undefined) return { written, sum: `${sumSign}${source.slice(unitStart, total.offset)}${sum}` };
154
+ return { written, sum: `${sumSign}${sum}${written.endsWith(total.unit) ? total.unit : ""}` };
155
+ };
156
+
157
+ /**
158
+ * 数だけの列で、合計の行の数が項目のどれかより大きくなければ、和ではなく平均や率や年の列と読んで足さない。
159
+ * 項目に負の数があれば、和が項目より小さいこともあるので足す。
160
+ */
161
+ const readsAsSum = (cell: Cell, writtenCents: number, above: RowsAbove): boolean =>
162
+ cell.unit !== NO_UNIT || above.parts.some((part) => part < 0) || above.parts.every((part) => part < writtenCents);
163
+
164
+ const mismatchesIn = (source: string, entries: readonly Entry[]): StructureIssue[] =>
165
+ entries.flatMap((entry, index) => {
166
+ if (!entry.label) return [];
167
+ return entry.amounts.flatMap((total) => {
168
+ const writtenCents = valueIn(entry, total);
169
+ const above = windowAbove(entries.slice(0, index), total);
170
+ if (typeof writtenCents !== "number" || above === undefined || !enoughRows(above) || !readsAsSum(total, writtenCents, above)) return [];
171
+ if (candidateSums(above).includes(writtenCents)) return [];
172
+ return [{ offset: total.offset, values: shownAmounts(source, total, reportedSum(above)) }];
173
+ });
174
+ });
175
+
176
+ export const totalMismatches = (source: string, amounts: readonly Amount[], labels: readonly string[]): StructureIssue[] =>
177
+ runsOf(source).flatMap((run) => {
178
+ const inRun = [...amounts, ...bareNumbersIn(source, run)];
179
+ return mismatchesIn(
180
+ source,
181
+ run.map((line) => entryOf(source, line, inRun, labels)),
182
+ );
183
+ });
@@ -7,15 +7,26 @@ import type { NumberedLine, NumberingContext } from "../plugin.ts";
7
7
  const DOTTED = /^[ \t]{0,3}(?<number>\d{1,3}(?:\.\d{1,3}){0,5})\.?[ \t\u3000]+(?<rest>\S.*)$/u;
8
8
 
9
9
  /**
10
- * 本文の「1. 」は箇条書きで、見出しの「1. 」は章番号。点を含まない番号は、見出しの行でだけ読む。
10
+ * RFC のようなテキストの仕様書(Markdown でないもの)の章見出し「5. Security Considerations」。Markdown では、行内のコードを伏せた空白が
11
+ * 同じ形に見えるので読まない。行頭から、点の後ろに空白が 2 つ以上あり、
12
+ * 見出しは文として終わらない(句点・ピリオド・読点で終わらない)短い語句。箇条書きの「1. 」は空白 1 つで、文で終わる。
13
+ */
14
+ const TOP_SECTION = /^\d{1,3}\. {2,}\S/u;
15
+ const SENTENCE_END = /[.。:;,、!?!?]$/u;
16
+ const MAX_TITLE = 80;
17
+
18
+ const isTopSection = (line: string, rest: string): boolean => TOP_SECTION.test(line) && rest.length <= MAX_TITLE && !SENTENCE_END.test(rest);
19
+
20
+ /**
21
+ * 本文の「1. 」は箇条書きで、見出しの「1. 」は章番号。点を含まない番号は、見出しの行か、テキストの章見出しの形でだけ読む。
11
22
  * 「4.2 」のように点を含むものは、.txt の仕様書でも見出しとして書かれるので本文でも読む。
12
23
  */
13
- export const dottedNumber = (line: string, context: NumberingContext): NumberedLine | undefined => {
24
+ export const dottedNumber = (line: string, context: NumberingContext, plainText = false): NumberedLine | undefined => {
14
25
  const groups = DOTTED.exec(line)?.groups;
15
26
  const number = groups?.["number"];
16
27
  if (number === undefined) return undefined;
17
28
  const depth = number.split(".").length;
18
- if (depth < 2 && !context.isHeading) return undefined;
19
29
  const rest = groups?.["rest"]?.trim() ?? "";
30
+ if (depth < 2 && !context.isHeading && !(plainText && isTopSection(line, rest))) return undefined;
20
31
  return { kind: "article", depth, number, absolute: true, label: number, heading: rest, rest, ordinal: Number(number.split(".").at(-1)) };
21
32
  };
@@ -0,0 +1,33 @@
1
+ import type { DocumentProfile, NumberedLine } from "../plugin.ts";
2
+
3
+ /** 開いている単位と、その行の番号の後ろが本文だったか(見出しでなく)。見出しは前の行から付くことがあるので、行そのものを覚えておく。 */
4
+ export type OpenUnit = { readonly numbered: NumberedLine; readonly bodyOnLine: boolean };
5
+
6
+ type Rule = NonNullable<DocumentProfile["unnumbered"]>;
7
+
8
+ const escape = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
9
+
10
+ const compiled = new Map<string, RegExp>();
11
+
12
+ const patternOf = (indent: string): RegExp => {
13
+ const found = compiled.get(indent) ?? new RegExp(`^${escape(indent)}(?<rest>\\S.*)$`, "u");
14
+ compiled.set(indent, found);
15
+ return found;
16
+ };
17
+
18
+ /**
19
+ * 番号を書かない単位(古い法令は第 2 項以降に番号を振らず、字下げした行を新しい項にする)。
20
+ * inside の種類の単位の行に本文が続くときだけ、その中で開いている depth の単位の次(無ければ 2 番目)として読む。
21
+ * 番号を書いた単位(「2」)がすでにあれば、字下げは番号の代わりではない。
22
+ */
23
+ export const unnumberedUnit = (line: string, open: readonly OpenUnit[], rule: Rule | undefined): NumberedLine | undefined => {
24
+ if (rule === undefined) return undefined;
25
+ const rest = patternOf(rule.indent).exec(line)?.groups?.["rest"];
26
+ if (rest === undefined) return undefined;
27
+ const holder = [...open].reverse().find((unit) => unit.numbered.kind === rule.inside);
28
+ if (holder === undefined || !holder.bodyOnLine) return undefined;
29
+ const previous = [...open].reverse().find((unit) => unit.numbered.depth === rule.depth)?.numbered;
30
+ if (previous !== undefined && previous.label !== "") return undefined;
31
+ const number = String((previous === undefined ? 1 : Number(previous.number)) + 1);
32
+ return { kind: "item", depth: rule.depth, number, absolute: false, label: "", heading: "", rest: rest.trim(), ordinal: Number(number) };
33
+ };
@@ -0,0 +1,25 @@
1
+ import type { StructureNode } from "../plugin.ts";
2
+ import type { StructureIssue } from "./issues.ts";
3
+ import { inDocumentOrder } from "./issues.ts";
4
+
5
+ // 日付と、その横に書いた曜日の食い違い。暦で決まるので、両方読めれば誤りは機械で言える。
6
+
7
+ const FULL_DATE = /^(?<y>\d{4})-(?<m>\d{2})-(?<d>\d{2})$/u;
8
+
9
+ /** 暦の曜日。日曜日が 0。存在しない日(2月30日)は undefined。 */
10
+ export const weekdayOf = (value: string): number | undefined => {
11
+ const groups = FULL_DATE.exec(value)?.groups;
12
+ if (groups === undefined) return undefined;
13
+ const [year, month, day] = [Number(groups["y"]), Number(groups["m"]), Number(groups["d"])];
14
+ const date = new Date(Date.UTC(year, month - 1, day));
15
+ return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day ? date.getUTCDay() : undefined;
16
+ };
17
+
18
+ /** 年まで書いた日付で、書いた曜日が暦と違うもの。年の無い日付は曜日を決められないので見ない。 */
19
+ export const weekdayMismatches = (tree: StructureNode): StructureIssue[] =>
20
+ inDocumentOrder(tree).flatMap((node) => {
21
+ const written = node.attrs["weekday"];
22
+ if (node.kind !== "date" || typeof written !== "number") return [];
23
+ const actual = weekdayOf(String(node.attrs["value"]));
24
+ return actual === undefined || actual === written ? [] : [{ offset: node.span.start, values: { date: String(node.attrs["value"]), written, actual } }];
25
+ });
package/src/version.ts CHANGED
@@ -8,7 +8,25 @@ import { dirname, join } from "node:path";
8
8
  */
9
9
  const MANIFEST = join(dirname(fileURLToPath(import.meta.url)), "..", "package.json");
10
10
 
11
- const versionOf = (raw: unknown): string =>
12
- typeof raw === "object" && raw !== null && "version" in raw && typeof raw.version === "string" ? raw.version : "0.0.0";
11
+ const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
13
12
 
14
- export const VERSION = versionOf(JSON.parse(readFileSync(MANIFEST, "utf8")));
13
+ const versionOf = (raw: unknown): string => (isRecord(raw) && typeof raw["version"] === "string" ? raw["version"] : "0.0.0");
14
+
15
+ const BUNDLED = /^@chaffjs\/lang-/u;
16
+
17
+ /**
18
+ * `chaff --version` の行。chaffjs と、同梱の言語パッケージ。chaffjs は言語パッケージをちょうどの版で依存に書くので、manifest がそのまま答え。
19
+ */
20
+ export const versionLines = (raw: unknown): string[] => {
21
+ const dependencies = isRecord(raw) && isRecord(raw["dependencies"]) ? raw["dependencies"] : {};
22
+ const bundled = Object.entries(dependencies)
23
+ .filter((entry): entry is [string, string] => BUNDLED.test(entry[0]) && typeof entry[1] === "string")
24
+ .sort(([left], [right]) => left.localeCompare(right))
25
+ .map(([name, version]) => `${name} ${version}`);
26
+ return [`chaffjs ${versionOf(raw)}`, ...bundled];
27
+ };
28
+
29
+ const manifest: unknown = JSON.parse(readFileSync(MANIFEST, "utf8"));
30
+
31
+ export const VERSION = versionOf(manifest);
32
+ export const VERSION_LINES = versionLines(manifest);