chaffjs 0.6.0 → 0.8.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 (159) hide show
  1. package/README.md +11 -4
  2. package/dist/adapter-load.d.ts +12 -1
  3. package/dist/adapter-load.d.ts.map +1 -1
  4. package/dist/adapter-load.js +37 -13
  5. package/dist/adapter-load.js.map +1 -1
  6. package/dist/cli-args.d.ts +8 -0
  7. package/dist/cli-args.d.ts.map +1 -0
  8. package/dist/cli-args.js +8 -0
  9. package/dist/cli-args.js.map +1 -0
  10. package/dist/cli-text.d.ts +26 -0
  11. package/dist/cli-text.d.ts.map +1 -0
  12. package/dist/cli-text.js +151 -0
  13. package/dist/cli-text.js.map +1 -0
  14. package/dist/cli.d.ts +2 -0
  15. package/dist/cli.d.ts.map +1 -1
  16. package/dist/cli.js +83 -86
  17. package/dist/cli.js.map +1 -1
  18. package/dist/commands/cite.d.ts +2 -1
  19. package/dist/commands/cite.d.ts.map +1 -1
  20. package/dist/commands/cite.js +39 -18
  21. package/dist/commands/cite.js.map +1 -1
  22. package/dist/commands/eval.d.ts.map +1 -1
  23. package/dist/commands/eval.js +1 -1
  24. package/dist/commands/eval.js.map +1 -1
  25. package/dist/commands/skill.d.ts +22 -0
  26. package/dist/commands/skill.d.ts.map +1 -0
  27. package/dist/commands/skill.js +68 -0
  28. package/dist/commands/skill.js.map +1 -0
  29. package/dist/commands/tree.d.ts +11 -1
  30. package/dist/commands/tree.d.ts.map +1 -1
  31. package/dist/commands/tree.js +18 -6
  32. package/dist/commands/tree.js.map +1 -1
  33. package/dist/config/load.d.ts +9 -0
  34. package/dist/config/load.d.ts.map +1 -1
  35. package/dist/config/load.js +27 -1
  36. package/dist/config/load.js.map +1 -1
  37. package/dist/config/rule-problems.d.ts +11 -0
  38. package/dist/config/rule-problems.d.ts.map +1 -0
  39. package/dist/config/rule-problems.js +31 -0
  40. package/dist/config/rule-problems.js.map +1 -0
  41. package/dist/config/write.d.ts.map +1 -1
  42. package/dist/config/write.js +29 -9
  43. package/dist/config/write.js.map +1 -1
  44. package/dist/detectors/index.d.ts.map +1 -1
  45. package/dist/detectors/index.js +3 -0
  46. package/dist/detectors/index.js.map +1 -1
  47. package/dist/detectors/orthography.d.ts +9 -0
  48. package/dist/detectors/orthography.d.ts.map +1 -0
  49. package/dist/detectors/orthography.js +51 -0
  50. package/dist/detectors/orthography.js.map +1 -0
  51. package/dist/document.d.ts +3 -0
  52. package/dist/document.d.ts.map +1 -1
  53. package/dist/document.js +13 -3
  54. package/dist/document.js.map +1 -1
  55. package/dist/eval.d.ts +5 -1
  56. package/dist/eval.d.ts.map +1 -1
  57. package/dist/eval.js +10 -5
  58. package/dist/eval.js.map +1 -1
  59. package/dist/genre.d.ts +1 -1
  60. package/dist/genre.d.ts.map +1 -1
  61. package/dist/genre.js +3 -3
  62. package/dist/genre.js.map +1 -1
  63. package/dist/heading-text.d.ts +14 -0
  64. package/dist/heading-text.d.ts.map +1 -0
  65. package/dist/heading-text.js +28 -0
  66. package/dist/heading-text.js.map +1 -0
  67. package/dist/init.d.ts +2 -1
  68. package/dist/init.d.ts.map +1 -1
  69. package/dist/init.js +62 -13
  70. package/dist/init.js.map +1 -1
  71. package/dist/orthography.d.ts +21 -0
  72. package/dist/orthography.d.ts.map +1 -0
  73. package/dist/orthography.js +110 -0
  74. package/dist/orthography.js.map +1 -0
  75. package/dist/plugin.d.ts +6 -0
  76. package/dist/plugin.d.ts.map +1 -1
  77. package/dist/reasons.d.ts +15 -0
  78. package/dist/reasons.d.ts.map +1 -0
  79. package/dist/reasons.js +29 -0
  80. package/dist/reasons.js.map +1 -0
  81. package/dist/render/explain.d.ts.map +1 -1
  82. package/dist/render/explain.js +36 -12
  83. package/dist/render/explain.js.map +1 -1
  84. package/dist/render/friendly.d.ts.map +1 -1
  85. package/dist/render/friendly.js +32 -9
  86. package/dist/render/friendly.js.map +1 -1
  87. package/dist/render/rules-json.d.ts.map +1 -1
  88. package/dist/render/rules-json.js +57 -27
  89. package/dist/render/rules-json.js.map +1 -1
  90. package/dist/render/summary.d.ts +3 -2
  91. package/dist/render/summary.d.ts.map +1 -1
  92. package/dist/render/summary.js +24 -9
  93. package/dist/render/summary.js.map +1 -1
  94. package/dist/render/suppressions.d.ts +2 -1
  95. package/dist/render/suppressions.d.ts.map +1 -1
  96. package/dist/render/suppressions.js +34 -10
  97. package/dist/render/suppressions.js.map +1 -1
  98. package/dist/run.d.ts +3 -1
  99. package/dist/run.d.ts.map +1 -1
  100. package/dist/run.js +22 -13
  101. package/dist/run.js.map +1 -1
  102. package/dist/structure/build.d.ts.map +1 -1
  103. package/dist/structure/build.js +19 -10
  104. package/dist/structure/build.js.map +1 -1
  105. package/dist/structure/cite.d.ts.map +1 -1
  106. package/dist/structure/cite.js +3 -1
  107. package/dist/structure/cite.js.map +1 -1
  108. package/dist/structure/issues.d.ts +5 -2
  109. package/dist/structure/issues.d.ts.map +1 -1
  110. package/dist/structure/issues.js +27 -9
  111. package/dist/structure/issues.js.map +1 -1
  112. package/dist/structure/numbering.d.ts.map +1 -1
  113. package/dist/structure/numbering.js +11 -6
  114. package/dist/structure/numbering.js.map +1 -1
  115. package/dist/ui.d.ts +14 -0
  116. package/dist/ui.d.ts.map +1 -0
  117. package/dist/ui.js +14 -0
  118. package/dist/ui.js.map +1 -0
  119. package/dist/watch.d.ts +1 -1
  120. package/dist/watch.d.ts.map +1 -1
  121. package/dist/watch.js +2 -2
  122. package/dist/watch.js.map +1 -1
  123. package/package.json +3 -2
  124. package/rules/latin-spacing.yaml +27 -0
  125. package/rules/preferred-term.yaml +27 -0
  126. package/skills/chaff/SKILL.md +61 -0
  127. package/src/adapter-load.ts +41 -10
  128. package/src/cli-args.ts +9 -0
  129. package/src/cli-text.ts +182 -0
  130. package/src/cli.ts +100 -89
  131. package/src/commands/cite.ts +51 -18
  132. package/src/commands/eval.ts +1 -0
  133. package/src/commands/skill.ts +74 -0
  134. package/src/commands/tree.ts +27 -7
  135. package/src/config/load.ts +43 -1
  136. package/src/config/rule-problems.ts +43 -0
  137. package/src/config/write.ts +33 -9
  138. package/src/detectors/index.ts +3 -0
  139. package/src/detectors/orthography.ts +55 -0
  140. package/src/document.ts +23 -4
  141. package/src/eval.ts +16 -5
  142. package/src/genre.ts +4 -4
  143. package/src/heading-text.ts +26 -0
  144. package/src/init.ts +74 -15
  145. package/src/orthography.ts +115 -0
  146. package/src/plugin.ts +6 -0
  147. package/src/reasons.ts +44 -0
  148. package/src/render/explain.ts +46 -12
  149. package/src/render/friendly.ts +42 -10
  150. package/src/render/rules-json.ts +45 -10
  151. package/src/render/summary.ts +34 -9
  152. package/src/render/suppressions.ts +45 -10
  153. package/src/run.ts +35 -12
  154. package/src/structure/build.ts +20 -10
  155. package/src/structure/cite.ts +3 -1
  156. package/src/structure/issues.ts +33 -14
  157. package/src/structure/numbering.ts +12 -7
  158. package/src/ui.ts +23 -0
  159. package/src/watch.ts +2 -2
package/src/run.ts CHANGED
@@ -2,6 +2,8 @@ import { DETECTORS } from "./detectors/index.ts";
2
2
  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
+ import { REASONS, type Reasons } from "./reasons.ts";
6
+ import { uiLanguageOf } from "./ui.ts";
5
7
 
6
8
  export type Skipped = { readonly rule: string; readonly why: string };
7
9
 
@@ -22,7 +24,7 @@ const levelFor = (rule: RuleDefinition, settings: Settings, experimental: boolea
22
24
  return "normal";
23
25
  };
24
26
 
25
- const CAPABILITY_NAME: Readonly<Record<string, string>> = { pos: "品詞解析", lemma: "原形" };
27
+ const reasonsFor = (doc: ProseDocument): Reasons => REASONS[uiLanguageOf(doc.language)];
26
28
 
27
29
  /** 知らない要求は満たされていないものとして扱う。黙って無視すると、要求なしで動いてしまう。 */
28
30
  const has = (capabilities: ProseDocument["capabilities"], need: string): boolean => {
@@ -42,13 +44,19 @@ const hasTokens = (doc: ProseDocument): boolean => doc.sentences.length === 0 ||
42
44
 
43
45
  /** 要求を満たさない rule は動かせない。満たさないまま動かすと「指摘 0 件」が保証に見える。 */
44
46
  const unmet = (rule: RuleDefinition, doc: ProseDocument): string | undefined => {
45
- if (rule.languages !== undefined && !rule.languages.includes(doc.language)) return `${doc.language} 向けの rule ではないため`;
47
+ if (rule.languages !== undefined && !rule.languages.includes(doc.language)) return reasonsFor(doc).otherLanguage(doc.language);
46
48
  const missing = rule.requires.filter((need) => need !== "structure").find((need) => !has(doc.capabilities, need));
47
- if (missing !== undefined) return `この言語では${CAPABILITY_NAME[missing] ?? missing}が使えないため`;
48
- if (rule.requires.some((need) => need === "pos" || need === "lemma") && !hasTokens(doc)) return "アダプタが品詞を返さなかったため";
49
+ if (missing !== undefined) return reasonsFor(doc).noCapability(missing);
49
50
  return undefined;
50
51
  };
51
52
 
53
+ /**
54
+ * 品詞は、動かす rule があるときだけ用意する(neededBy)。だから「品詞が無い」は、段階を見た後でしか言えない。
55
+ * 先に聞くと、止めている rule まで「アダプタが品詞を返さなかった」と、違う理由で出る。
56
+ */
57
+ const untagged = (rule: RuleDefinition, doc: ProseDocument): string | undefined =>
58
+ rule.requires.some((need) => need === "pos" || need === "lemma") && !hasTokens(doc) ? reasonsFor(doc).noTags : undefined;
59
+
52
60
  const forGenre = (rules: readonly RuleDefinition[], genre: string): RuleDefinition[] =>
53
61
  rules.filter((rule) => rule.use_for.some((target) => genre.startsWith(target)));
54
62
 
@@ -92,7 +100,19 @@ const compositeOf = (rule: RuleDefinition, found: readonly Finding[], limit: num
92
100
  ];
93
101
  };
94
102
 
95
- export const runRules = (doc: ProseDocument, rules: readonly RuleDefinition[], settings: Settings, experimental: boolean, genre: string): RunResult => {
103
+ /** chaff.yaml の rules に数値で書いた上限。段階の表より先に効く。 */
104
+ export type Limits = Readonly<Record<string, number>>;
105
+
106
+ const limitFor = (rule: RuleDefinition, level: Level, genre: string, limits: Limits): number => limits[rule.id] ?? resolve(rule, level, genre).limit;
107
+
108
+ export const runRules = (
109
+ doc: ProseDocument,
110
+ rules: readonly RuleDefinition[],
111
+ settings: Settings,
112
+ experimental: boolean,
113
+ genre: string,
114
+ limits: Limits = {},
115
+ ): RunResult => {
96
116
  const starts = lineStarts(doc.source);
97
117
  const applicable = forGenre(rules, genre);
98
118
  const forced = applicable
@@ -102,32 +122,35 @@ export const runRules = (doc: ProseDocument, rules: readonly RuleDefinition[], s
102
122
  (acc, rule) => {
103
123
  // L4 は意味を読む検査。chaff test が扱う。ここで「検出器が無い」と言わせない。
104
124
  if (rule.layer === "L4") {
105
- return { findings: acc.findings, skipped: [...acc.skipped, { rule: rule.id, why: "意味を読む検査のため(npx chaff test で動きます)" }] };
125
+ return { findings: acc.findings, skipped: [...acc.skipped, { rule: rule.id, why: reasonsFor(doc).semantic }] };
106
126
  }
107
127
  const blocked = unmet(rule, doc);
108
128
  if (blocked !== undefined) return { findings: acc.findings, skipped: [...acc.skipped, { rule: rule.id, why: blocked }] };
109
129
  const level = levelFor(rule, settings, experimental);
110
130
  if (level === "off") {
111
- const why = rule.status === "experimental" && settings[rule.id] === undefined ? "まだ試験中のため" : "設定で止めているため";
131
+ const why = rule.status === "experimental" && settings[rule.id] === undefined ? reasonsFor(doc).experimental : reasonsFor(doc).turnedOff;
112
132
  return { findings: acc.findings, skipped: [...acc.skipped, { rule: rule.id, why }] };
113
133
  }
134
+ const noTags = untagged(rule, doc);
135
+ if (noTags !== undefined) return { findings: acc.findings, skipped: [...acc.skipped, { rule: rule.id, why: noTags }] };
114
136
  // 木は capability ではなく、adapter が structure を持つかで決まる。持たない言語で動かすと「参照先が無い」が 0 件に見える。
115
137
  // 段階を見た後で聞く。doc.structure は触れたときに木を作るので、止めている rule のために作らない。
116
138
  if (rule.requires.includes("structure") && doc.structure === undefined) {
117
- return { findings: acc.findings, skipped: [...acc.skipped, { rule: rule.id, why: `${doc.language} のパッケージは文書の構造を読めないため` }] };
139
+ return { findings: acc.findings, skipped: [...acc.skipped, { rule: rule.id, why: reasonsFor(doc).noStructure(doc.language) }] };
118
140
  }
119
141
  // 複合シグナルは二段目で扱う。一段目では「検出器が無い」と言わせない。
120
142
  if (rule.from.length > 0) return acc;
121
143
  const detector = DETECTORS[rule.how_to_find];
122
- if (detector === undefined) return { findings: acc.findings, skipped: [...acc.skipped, { rule: rule.id, why: `検出器 ${rule.how_to_find} がないため` }] };
144
+ if (detector === undefined)
145
+ return { findings: acc.findings, skipped: [...acc.skipped, { rule: rule.id, why: reasonsFor(doc).noDetector(rule.how_to_find) }] };
123
146
  const options = {
124
- limit: resolve(rule, level, genre).limit,
147
+ limit: limitFor(rule, level, genre, limits),
125
148
  lexicon: rule.word_list === undefined ? undefined : doc.lexicons[rule.word_list],
126
149
  where: rule.where,
127
150
  };
128
151
  // 語彙表を要求する rule で、その言語に語彙表が無ければ動かせない。黙って通さない。
129
152
  if (rule.word_list !== undefined && options.lexicon === undefined) {
130
- return { findings: acc.findings, skipped: [...acc.skipped, { rule: rule.id, why: `${doc.language} の語彙表 ${rule.word_list} が無いため` }] };
153
+ return { findings: acc.findings, skipped: [...acc.skipped, { rule: rule.id, why: reasonsFor(doc).noLexicon(doc.language, rule.word_list) }] };
131
154
  }
132
155
  const found = detector(doc, options).map((finding) => place(starts, { ...finding, rule: rule.id, severity: rule.severity }));
133
156
  return { findings: [...acc.findings, ...found], skipped: acc.skipped };
@@ -136,7 +159,7 @@ export const runRules = (doc: ProseDocument, rules: readonly RuleDefinition[], s
136
159
  );
137
160
  const composites = applicable
138
161
  .filter((rule) => rule.from.length > 0 && levelFor(rule, settings, experimental) !== "off")
139
- .flatMap((rule) => compositeOf(rule, outcome.findings, resolve(rule, levelFor(rule, settings, experimental), genre).limit, starts));
162
+ .flatMap((rule) => compositeOf(rule, outcome.findings, limitFor(rule, levelFor(rule, settings, experimental), genre, limits), starts));
140
163
  const all = [...outcome.findings, ...composites];
141
164
  return { ...outcome, findings: [...all].sort((left, right) => left.line - right.line), forcedExperimental: forced };
142
165
  };
@@ -1,4 +1,5 @@
1
1
  import type { Heading } from "../document.ts";
2
+ import { atxHeadingText, headingText } from "../heading-text.ts";
2
3
  import { maskSpans } from "../mask.ts";
3
4
  import type { Mention, NumberedLine, NumberingContext, Span, StructureKind, StructureNode, StructurePatterns } from "../plugin.ts";
4
5
  import { lineNumberAt, linesOf, type Line } from "./lines.ts";
@@ -14,6 +15,8 @@ type Draft = {
14
15
  readonly attrs: Readonly<Record<string, string | number>>;
15
16
  readonly children: Draft[];
16
17
  readonly ordinal?: number | undefined;
18
+ readonly level?: number | undefined;
19
+ readonly ordinalTo?: number | undefined;
17
20
  };
18
21
 
19
22
  /** 開いている節点。rank の大きいものほど内側。 */
@@ -87,7 +90,12 @@ const openNumbered = (state: State, line: Line, numbered: NumberedLine, headingD
87
90
  const rank = headingDepth ?? NUMBERED_RANK + numbered.depth;
88
91
  // 番地は、自分と同じか内側を閉じてから決める。閉じる前だと、同じ深さの前の条を親と取り違える。
89
92
  while (state.stack.length > 1 && top(state).rank >= rank) state.stack.pop();
90
- const draft = { ...draftOf(numbered.kind, addressOf(state, numbered), line, attrs), ordinal: numbered.ordinal };
93
+ const draft = {
94
+ ...draftOf(numbered.kind, addressOf(state, numbered), line, attrs),
95
+ ordinal: numbered.ordinal,
96
+ level: numbered.depth,
97
+ ordinalTo: numbered.ordinalTo,
98
+ };
91
99
  open(state, { draft, rank, numbered });
92
100
  };
93
101
 
@@ -135,6 +143,8 @@ const freeze = (draft: Draft): StructureNode => ({
135
143
  attrs: draft.attrs,
136
144
  children: draft.children.map(freeze),
137
145
  ...(draft.ordinal === undefined ? {} : { ordinal: draft.ordinal }),
146
+ ...(draft.level === undefined ? {} : { level: draft.level }),
147
+ ...(draft.ordinalTo === undefined ? {} : { ordinalTo: draft.ordinalTo }),
138
148
  });
139
149
 
140
150
  /** Markdown から取った手がかり。見出しと、中を読まない範囲(コード)。.txt はどちらも空。 */
@@ -155,18 +165,18 @@ const headingsByLine = (lines: readonly Line[], headings: readonly Heading[]): M
155
165
  }),
156
166
  );
157
167
 
158
- /** 見出しの行から「#」の印を外した文字列。コードを覆った後の行から取るので、見出しの中のコードも読まない。 */
159
- const atxText = (text: string): string => {
160
- const body = text
161
- .trim()
162
- .replace(/^#{1,6}(?=[ \t]|$)/u, "")
163
- .trim();
164
- const closing = body.search(/[ \t]#+$/u);
165
- return (closing === -1 ? body : body.slice(0, closing)).trim();
168
+ /**
169
+ * 見出しの行から見出しの言葉を取る。コードを覆った後の行から取るので、見出しの中のコードも読まない。
170
+ * ATX(行頭が #)なら印と閉じの # を外す。setext(下線で書く見出し)の末尾の # は言葉なので残す。
171
+ */
172
+ const headingLineText = (text: string): string => {
173
+ const line = text.trim();
174
+ if (!line.startsWith("#")) return headingText(line);
175
+ return atxHeadingText(line.replace(/^#{1,6}(?=[ \t]|$)/u, "").trim());
166
176
  };
167
177
 
168
178
  const readLine = (state: State, patterns: StructurePatterns, line: Line, heading: Heading | undefined): void => {
169
- const text = heading === undefined ? line.text : atxText(line.text);
179
+ const text = heading === undefined ? line.text : headingLineText(line.text);
170
180
  const openNumbers = state.stack.flatMap((frame) => (frame.numbered === undefined ? [] : [frame.numbered]));
171
181
  const context = { open: openNumbers, isHeading: heading !== undefined };
172
182
  const numbered = patterns.numbered(text, context) ?? universalNumber(patterns, text, context);
@@ -100,7 +100,9 @@ export const checkCitations = (source: string, tree: StructureNode, citations: r
100
100
  const whole = normalize(source);
101
101
  const starts = lineStarts(source);
102
102
  return citations.map((citation): CitationResult => {
103
- const candidates = byAddress.get(citation.address) ?? [];
103
+ // 第 1 項に番号の無い書き方では、「12.1」は第12条そのもの。参照の fallback と同じに読む。
104
+ const firstParagraph = citation.address.endsWith(".1") ? byAddress.get(citation.address.slice(0, -2)) : undefined;
105
+ const candidates = byAddress.get(citation.address) ?? firstParagraph ?? [];
104
106
  const first = candidates[0];
105
107
  if (first === undefined) return { citation, status: "missing-address" };
106
108
  const key = normalize(citation.quote).text;
@@ -34,30 +34,49 @@ const resolves = (node: StructureNode, addresses: ReadonlySet<string>): boolean
34
34
 
35
35
  /**
36
36
  * 参照の番地が木に無い。他の文書の名前が付いた参照(民法第709条、Section 9 of the Master Agreement)は引かない。
37
- * 番号付きのまとまりを 1 つも持たない文書も見ない。他の文書を指しているだけかもしれない。
37
+ * 条を 1 つも持たない文書も見ない。他の文書を指しているだけかもしれない。
38
38
  */
39
39
  export const danglingReferences = (tree: StructureNode): StructureIssue[] => {
40
40
  const nodes = inDocumentOrder(tree);
41
+ // 参照は条を指す。条を一つも持たない文書(契約書に付ける承諾書のひな形など)の「契約書第6条」は、別の文書の条。
42
+ if (!nodes.some((node) => node.kind === "article")) return [];
41
43
  const addresses = new Set(nodes.filter((node) => NUMBERED.includes(node.kind)).map((node) => node.address));
42
- if (addresses.size === 0) return [];
43
44
  return nodes
44
45
  .filter((node) => node.kind === "reference" && node.attrs["document"] === undefined)
45
46
  .filter((node) => !resolves(node, addresses))
46
47
  .map((node) => ({ offset: node.span.start, values: { label: textOf(node, "label"), target: textOf(node, "target") } }));
47
48
  };
48
49
 
49
- /** 同じ語の二度目以降の定義。どちらが正しいかは決めず、両方の場所を示す。 */
50
+ type Definition = { readonly node: StructureNode; readonly article: string };
51
+
52
+ /** 定義を文書の順に、それが置かれた条の番地と一緒に並べる。範囲を限った定義は、その条の中でだけ比べるため。 */
53
+ const definitionsInOrder = (tree: StructureNode): Definition[] => {
54
+ const found: Definition[] = [];
55
+ const pending: Definition[] = [{ node: tree, article: "" }];
56
+ while (pending.length > 0) {
57
+ const current = pending.pop();
58
+ if (current === undefined) break;
59
+ if (current.node.kind === "definition") found.push(current);
60
+ const article = current.node.kind === "article" ? current.node.address : current.article;
61
+ [...current.node.children].reverse().forEach((child) => pending.push({ node: child, article }));
62
+ }
63
+ return found;
64
+ };
65
+
66
+ /**
67
+ * 同じ語の二度目以降の定義。どちらが正しいかは決めず、両方の場所を示す。
68
+ * 範囲を限った定義(この条において「X」とは)は、同じ条の中でだけ比べる。別の条で定義し直すのは正しい書き方。
69
+ */
50
70
  export const duplicateDefinitions = (tree: StructureNode): StructureIssue[] => {
51
71
  const first = new Map<string, StructureNode>();
52
- return inDocumentOrder(tree)
53
- .filter((node) => node.kind === "definition")
54
- .flatMap((node) => {
55
- const term = textOf(node, "term");
56
- const earlier = first.get(term);
57
- if (earlier === undefined) {
58
- first.set(term, node);
59
- return [];
60
- }
61
- return [{ offset: node.span.start, values: { term, first: earlier.line } }];
62
- });
72
+ return definitionsInOrder(tree).flatMap(({ node, article }) => {
73
+ const term = textOf(node, "term");
74
+ const key = node.attrs["scope"] === "local" ? `${article}\u0000${term}` : term;
75
+ const earlier = first.get(key);
76
+ if (earlier === undefined) {
77
+ first.set(key, node);
78
+ return [];
79
+ }
80
+ return [{ offset: node.span.start, values: { term, first: earlier.line } }];
81
+ });
63
82
  };
@@ -3,17 +3,21 @@ import { inDocumentOrder, type StructureIssue } from "./issues.ts";
3
3
 
4
4
  const labelOf = (node: StructureNode): string => String(node.attrs["label"] ?? node.address);
5
5
 
6
- type Ordered = { readonly node: StructureNode; readonly ordinal: number };
6
+ type Ordered = { readonly node: StructureNode; readonly ordinal: number; readonly last: number };
7
7
 
8
- /** 同じ親の子のうち、番号の付いたものを種類ごとに分ける。条と項は別の並び。 */
8
+ /**
9
+ * 同じ親の子のうち、番号の付いたものを種類と深さごとに分ける。条と項は別の並び、項と号も別の並び。
10
+ * 法令の第 1 項は番号が無いので、その号(一、二)と第 2 項(2)は同じ条の直下に並ぶ。深さで分けないと「二の次が2」に見える。
11
+ */
9
12
  const sequencesOf = (parent: StructureNode): Ordered[][] => {
10
13
  const groups = new Map<string, Ordered[]>();
11
14
  parent.children.forEach((node) => {
12
15
  if (node.ordinal === undefined) return;
16
+ const key = `${node.kind}/${String(node.level ?? "")}`;
13
17
  // 配列を作り直さずに足す。条が何千もある法令で二乗にしない。
14
- const group = groups.get(node.kind) ?? [];
15
- group.push({ node, ordinal: node.ordinal });
16
- groups.set(node.kind, group);
18
+ const group = groups.get(key) ?? [];
19
+ group.push({ node, ordinal: node.ordinal, last: node.ordinalTo ?? node.ordinal });
20
+ groups.set(key, group);
17
21
  });
18
22
  return [...groups.values()];
19
23
  };
@@ -23,11 +27,12 @@ const breaksIn = (sequence: readonly Ordered[]): StructureIssue[] =>
23
27
  sequence.flatMap((current, index) => {
24
28
  const previous = sequence[index - 1];
25
29
  // 1 に戻ったら新しい並び。一つの条に (a)(b) の箇条書きが二つある、附則が第1条から振り直す、のどちらも誤りではない。
26
- if (previous === undefined || current.ordinal === previous.ordinal + 1 || current.ordinal === 1) return [];
30
+ // 範囲をまとめた行(第四十三条から第五十五条まで)の次は、範囲の最後から数える。
31
+ if (previous === undefined || current.ordinal === previous.last + 1 || current.ordinal === 1) return [];
27
32
  return [
28
33
  {
29
34
  offset: current.node.span.start,
30
- values: { previous: labelOf(previous.node), label: labelOf(current.node), expected: previous.ordinal + 1, found: current.ordinal },
35
+ values: { previous: labelOf(previous.node), label: labelOf(current.node), expected: previous.last + 1, found: current.ordinal },
31
36
  },
32
37
  ];
33
38
  });
package/src/ui.ts ADDED
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The language chaff speaks in. Japanese for Japanese, English for everything else: English is the language a
3
+ * reader of any other language is likeliest to read, and every message has an English text.
4
+ */
5
+ export type UiLanguage = "ja" | "en";
6
+
7
+ export const uiLanguageOf = (language: string | undefined): UiLanguage => (language === "ja" ? "ja" : "en");
8
+
9
+ /** Locale variables in the order POSIX gives them precedence. */
10
+ const LOCALE_VARIABLES: readonly string[] = ["LC_ALL", "LC_MESSAGES", "LANG"];
11
+
12
+ /**
13
+ * The language for output that is not about one document (--help, genres, init, explain, relax, warnings):
14
+ * the language `chaff.yaml` sets, else the terminal's locale, else English. Pure: `env` is passed in.
15
+ */
16
+ export const hostLanguage = (configLanguage: string | undefined, env: Readonly<Record<string, string | undefined>>): UiLanguage => {
17
+ if (configLanguage !== undefined) return uiLanguageOf(configLanguage);
18
+ const locale = LOCALE_VARIABLES.map((name) => env[name]).find((value) => value !== undefined && value !== "");
19
+ return locale !== undefined && locale.toLowerCase().startsWith("ja") ? "ja" : "en";
20
+ };
21
+
22
+ /** Text in both languages. Each module keeps its own, next to where it is used. */
23
+ export type Texts<T> = Readonly<Record<UiLanguage, T>>;
package/src/watch.ts CHANGED
@@ -15,7 +15,7 @@ const total = (snapshot: Snapshot): number => Object.values(snapshot).reduce((su
15
15
  * 差分だけを出す。書いている最中に全件を出し直されると、何が変わったのか分からない。
16
16
  * workflow spec §11。
17
17
  */
18
- export const describeChange = (before: Snapshot, after: Snapshot): string | undefined => {
18
+ export const describeChange = (before: Snapshot, after: Snapshot, unit = " 件"): string | undefined => {
19
19
  const rules = [...new Set([...Object.keys(before), ...Object.keys(after)])];
20
20
  const moved = rules
21
21
  .map((rule) => ({ rule, delta: (after[rule] ?? 0) - (before[rule] ?? 0) }))
@@ -24,7 +24,7 @@ export const describeChange = (before: Snapshot, after: Snapshot): string | unde
24
24
  if (moved.length === 0) return undefined;
25
25
  const detail = moved.map((entry) => `${entry.delta > 0 ? "+" : ""}${entry.delta} ${entry.rule}`).join(", ");
26
26
  const mark = total(after) < total(before) ? "✓" : "✗";
27
- return `${mark} ${total(before)} → ${total(after)} 件 (${detail})`;
27
+ return `${mark} ${total(before)} → ${total(after)}${unit} (${detail})`;
28
28
  };
29
29
 
30
30
  export const clock = (): string => new Date().toTimeString().slice(0, 8);