chaffjs 0.7.0 → 0.9.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 (215) 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/address-chain.d.ts +9 -0
  7. package/dist/address-chain.d.ts.map +1 -0
  8. package/dist/address-chain.js +105 -0
  9. package/dist/address-chain.js.map +1 -0
  10. package/dist/cli-args.d.ts +8 -0
  11. package/dist/cli-args.d.ts.map +1 -0
  12. package/dist/cli-args.js +8 -0
  13. package/dist/cli-args.js.map +1 -0
  14. package/dist/cli-text.d.ts +26 -0
  15. package/dist/cli-text.d.ts.map +1 -0
  16. package/dist/cli-text.js +155 -0
  17. package/dist/cli-text.js.map +1 -0
  18. package/dist/cli.d.ts +2 -0
  19. package/dist/cli.d.ts.map +1 -1
  20. package/dist/cli.js +94 -87
  21. package/dist/cli.js.map +1 -1
  22. package/dist/commands/cite.d.ts +2 -1
  23. package/dist/commands/cite.d.ts.map +1 -1
  24. package/dist/commands/cite.js +39 -18
  25. package/dist/commands/cite.js.map +1 -1
  26. package/dist/commands/eval.d.ts.map +1 -1
  27. package/dist/commands/eval.js +2 -1
  28. package/dist/commands/eval.js.map +1 -1
  29. package/dist/commands/feedback.d.ts +35 -0
  30. package/dist/commands/feedback.d.ts.map +1 -0
  31. package/dist/commands/feedback.js +157 -0
  32. package/dist/commands/feedback.js.map +1 -0
  33. package/dist/commands/skill.d.ts +22 -0
  34. package/dist/commands/skill.d.ts.map +1 -0
  35. package/dist/commands/skill.js +68 -0
  36. package/dist/commands/skill.js.map +1 -0
  37. package/dist/commands/test.d.ts.map +1 -1
  38. package/dist/commands/test.js +3 -2
  39. package/dist/commands/test.js.map +1 -1
  40. package/dist/commands/tree.d.ts +11 -1
  41. package/dist/commands/tree.d.ts.map +1 -1
  42. package/dist/commands/tree.js +21 -7
  43. package/dist/commands/tree.js.map +1 -1
  44. package/dist/config/by-path.d.ts +5 -1
  45. package/dist/config/by-path.d.ts.map +1 -1
  46. package/dist/config/by-path.js +1 -4
  47. package/dist/config/by-path.js.map +1 -1
  48. package/dist/config/load.d.ts +2 -0
  49. package/dist/config/load.d.ts.map +1 -1
  50. package/dist/config/load.js +3 -1
  51. package/dist/config/load.js.map +1 -1
  52. package/dist/config/rule-problems.d.ts +2 -1
  53. package/dist/config/rule-problems.d.ts.map +1 -1
  54. package/dist/config/rule-problems.js +17 -6
  55. package/dist/config/rule-problems.js.map +1 -1
  56. package/dist/config/write.d.ts.map +1 -1
  57. package/dist/config/write.js +29 -9
  58. package/dist/config/write.js.map +1 -1
  59. package/dist/detectors/char-shape.d.ts.map +1 -1
  60. package/dist/detectors/char-shape.js +3 -2
  61. package/dist/detectors/char-shape.js.map +1 -1
  62. package/dist/document.d.ts +2 -2
  63. package/dist/document.d.ts.map +1 -1
  64. package/dist/document.js +4 -2
  65. package/dist/document.js.map +1 -1
  66. package/dist/feedback/draft.d.ts +28 -0
  67. package/dist/feedback/draft.d.ts.map +1 -0
  68. package/dist/feedback/draft.js +66 -0
  69. package/dist/feedback/draft.js.map +1 -0
  70. package/dist/feedback/excerpt.d.ts +10 -0
  71. package/dist/feedback/excerpt.d.ts.map +1 -0
  72. package/dist/feedback/excerpt.js +20 -0
  73. package/dist/feedback/excerpt.js.map +1 -0
  74. package/dist/genre.d.ts +1 -1
  75. package/dist/genre.d.ts.map +1 -1
  76. package/dist/genre.js +3 -3
  77. package/dist/genre.js.map +1 -1
  78. package/dist/init.d.ts +2 -1
  79. package/dist/init.d.ts.map +1 -1
  80. package/dist/init.js +62 -13
  81. package/dist/init.js.map +1 -1
  82. package/dist/legal-address.d.ts +2 -0
  83. package/dist/legal-address.d.ts.map +1 -0
  84. package/dist/legal-address.js +16 -0
  85. package/dist/legal-address.js.map +1 -0
  86. package/dist/orthography.d.ts.map +1 -1
  87. package/dist/orthography.js +15 -0
  88. package/dist/orthography.js.map +1 -1
  89. package/dist/plugin.d.ts +94 -0
  90. package/dist/plugin.d.ts.map +1 -1
  91. package/dist/profile/for-file.d.ts +7 -0
  92. package/dist/profile/for-file.d.ts.map +1 -0
  93. package/dist/profile/for-file.js +6 -0
  94. package/dist/profile/for-file.js.map +1 -0
  95. package/dist/profile/load.d.ts +4 -0
  96. package/dist/profile/load.d.ts.map +1 -0
  97. package/dist/profile/load.js +25 -0
  98. package/dist/profile/load.js.map +1 -0
  99. package/dist/profile/parse.d.ts +19 -0
  100. package/dist/profile/parse.d.ts.map +1 -0
  101. package/dist/profile/parse.js +82 -0
  102. package/dist/profile/parse.js.map +1 -0
  103. package/dist/profile/select.d.ts +22 -0
  104. package/dist/profile/select.d.ts.map +1 -0
  105. package/dist/profile/select.js +21 -0
  106. package/dist/profile/select.js.map +1 -0
  107. package/dist/reasons.d.ts +16 -0
  108. package/dist/reasons.d.ts.map +1 -0
  109. package/dist/reasons.js +31 -0
  110. package/dist/reasons.js.map +1 -0
  111. package/dist/render/explain.d.ts.map +1 -1
  112. package/dist/render/explain.js +36 -12
  113. package/dist/render/explain.js.map +1 -1
  114. package/dist/render/friendly.d.ts.map +1 -1
  115. package/dist/render/friendly.js +32 -9
  116. package/dist/render/friendly.js.map +1 -1
  117. package/dist/render/rules-json.d.ts.map +1 -1
  118. package/dist/render/rules-json.js +47 -27
  119. package/dist/render/rules-json.js.map +1 -1
  120. package/dist/render/summary.d.ts +3 -2
  121. package/dist/render/summary.d.ts.map +1 -1
  122. package/dist/render/summary.js +24 -9
  123. package/dist/render/summary.js.map +1 -1
  124. package/dist/render/suppressions.d.ts +2 -1
  125. package/dist/render/suppressions.d.ts.map +1 -1
  126. package/dist/render/suppressions.js +34 -10
  127. package/dist/render/suppressions.js.map +1 -1
  128. package/dist/run.d.ts.map +1 -1
  129. package/dist/run.js +41 -12
  130. package/dist/run.js.map +1 -1
  131. package/dist/structure/build.d.ts +2 -1
  132. package/dist/structure/build.d.ts.map +1 -1
  133. package/dist/structure/build.js +102 -9
  134. package/dist/structure/build.js.map +1 -1
  135. package/dist/structure/cite.d.ts.map +1 -1
  136. package/dist/structure/cite.js +3 -1
  137. package/dist/structure/cite.js.map +1 -1
  138. package/dist/structure/issues.d.ts +5 -2
  139. package/dist/structure/issues.d.ts.map +1 -1
  140. package/dist/structure/issues.js +35 -10
  141. package/dist/structure/issues.js.map +1 -1
  142. package/dist/structure/numbering.d.ts.map +1 -1
  143. package/dist/structure/numbering.js +11 -6
  144. package/dist/structure/numbering.js.map +1 -1
  145. package/dist/structure/of.d.ts +2 -1
  146. package/dist/structure/of.d.ts.map +1 -1
  147. package/dist/structure/of.js +7 -1
  148. package/dist/structure/of.js.map +1 -1
  149. package/dist/structure/relative-find.d.ts +6 -0
  150. package/dist/structure/relative-find.d.ts.map +1 -0
  151. package/dist/structure/relative-find.js +179 -0
  152. package/dist/structure/relative-find.js.map +1 -0
  153. package/dist/structure/relative-resolve.d.ts +3 -0
  154. package/dist/structure/relative-resolve.d.ts.map +1 -0
  155. package/dist/structure/relative-resolve.js +175 -0
  156. package/dist/structure/relative-resolve.js.map +1 -0
  157. package/dist/structure/unread.d.ts +8 -0
  158. package/dist/structure/unread.d.ts.map +1 -0
  159. package/dist/structure/unread.js +34 -0
  160. package/dist/structure/unread.js.map +1 -0
  161. package/dist/ui.d.ts +14 -0
  162. package/dist/ui.d.ts.map +1 -0
  163. package/dist/ui.js +14 -0
  164. package/dist/ui.js.map +1 -0
  165. package/dist/watch.d.ts +1 -1
  166. package/dist/watch.d.ts.map +1 -1
  167. package/dist/watch.js +2 -2
  168. package/dist/watch.js.map +1 -1
  169. package/package.json +5 -3
  170. package/profiles/statute.yaml +62 -0
  171. package/skills/chaff/SKILL.md +81 -0
  172. package/src/adapter-load.ts +41 -10
  173. package/src/address-chain.ts +113 -0
  174. package/src/cli-args.ts +9 -0
  175. package/src/cli-text.ts +186 -0
  176. package/src/cli.ts +111 -90
  177. package/src/commands/cite.ts +51 -18
  178. package/src/commands/eval.ts +2 -1
  179. package/src/commands/feedback.ts +210 -0
  180. package/src/commands/skill.ts +74 -0
  181. package/src/commands/test.ts +3 -2
  182. package/src/commands/tree.ts +30 -8
  183. package/src/config/by-path.ts +11 -3
  184. package/src/config/load.ts +5 -1
  185. package/src/config/rule-problems.ts +24 -5
  186. package/src/config/write.ts +33 -9
  187. package/src/detectors/char-shape.ts +5 -4
  188. package/src/document.ts +11 -3
  189. package/src/feedback/draft.ts +102 -0
  190. package/src/feedback/excerpt.ts +23 -0
  191. package/src/genre.ts +4 -4
  192. package/src/init.ts +74 -15
  193. package/src/orthography.ts +15 -0
  194. package/src/plugin.ts +88 -0
  195. package/src/profile/for-file.ts +11 -0
  196. package/src/profile/load.ts +26 -0
  197. package/src/profile/parse.ts +106 -0
  198. package/src/profile/select.ts +35 -0
  199. package/src/reasons.ts +49 -0
  200. package/src/render/explain.ts +46 -12
  201. package/src/render/friendly.ts +42 -10
  202. package/src/render/rules-json.ts +35 -9
  203. package/src/render/summary.ts +34 -9
  204. package/src/render/suppressions.ts +45 -10
  205. package/src/run.ts +44 -11
  206. package/src/structure/build.ts +128 -12
  207. package/src/structure/cite.ts +3 -1
  208. package/src/structure/issues.ts +42 -15
  209. package/src/structure/numbering.ts +12 -7
  210. package/src/structure/of.ts +15 -3
  211. package/src/structure/relative-find.ts +224 -0
  212. package/src/structure/relative-resolve.ts +209 -0
  213. package/src/structure/unread.ts +39 -0
  214. package/src/ui.ts +23 -0
  215. package/src/watch.ts +2 -2
package/src/document.ts CHANGED
@@ -7,7 +7,7 @@ import { atxHeadingText, headingText } from "./heading-text.ts";
7
7
  import { maskSpans } from "./mask.ts";
8
8
  import { buildTree, NO_OUTLINE, type Outline } from "./structure/build.ts";
9
9
  import { isMarkdownPath } from "./structure/markdown-path.ts";
10
- import type { BulletList, LanguageAdapter, Paragraph, ProseDocument, Section, Sentence, Span, StructureNode } from "./plugin.ts";
10
+ import type { BulletList, LanguageAdapter, Paragraph, ProseDocument, Section, Sentence, Span, StructureNode, DocumentProfile } from "./plugin.ts";
11
11
 
12
12
  type Place = { readonly offset?: number | undefined };
13
13
  type Node = {
@@ -253,7 +253,13 @@ export const teamRules = (config: {
253
253
  requiredSections: config.requiredSections,
254
254
  });
255
255
 
256
- export const buildDocument = (path: string, source: string, adapter: LanguageAdapter, team: TeamRules = EMPTY_TEAM): ProseDocument => {
256
+ export const buildDocument = (
257
+ path: string,
258
+ source: string,
259
+ adapter: LanguageAdapter,
260
+ team: TeamRules = EMPTY_TEAM,
261
+ profile: DocumentProfile | undefined = undefined,
262
+ ): ProseDocument => {
257
263
  const root = parse(source);
258
264
  // 強調の記号は「本文でないもの」だが、太字の数を数えるときの「覆われた場所」ではない。
259
265
  // 同じ集合にすると、太字が自分の記号のせいで覆われた場所にあることになり、1 つも数えられなくなる。
@@ -289,9 +295,11 @@ export const buildDocument = (path: string, source: string, adapter: LanguageAda
289
295
  tree.value ??=
290
296
  patterns === undefined
291
297
  ? null
292
- : buildTree({ path, source, language: adapter.id, outline: isMarkdownPath(path) ? outlineOf(root, source) : NO_OUTLINE }, patterns);
298
+ : buildTree({ path, source, language: adapter.id, outline: isMarkdownPath(path) ? outlineOf(root, source) : NO_OUTLINE, profile }, patterns);
293
299
  return tree.value ?? undefined;
294
300
  },
301
+ profile,
302
+ prose,
295
303
  };
296
304
  };
297
305
 
@@ -0,0 +1,102 @@
1
+ import type { Texts, UiLanguage } from "../ui.ts";
2
+ import type { Excerpt } from "./excerpt.ts";
3
+
4
+ export type FeedbackKind = "false-positive" | "missed";
5
+
6
+ export type ReportedFinding = { readonly rule: string; readonly line: number; readonly message: string };
7
+
8
+ export type FeedbackInput = {
9
+ readonly kind: FeedbackKind;
10
+ readonly version: string;
11
+ readonly runtime: string;
12
+ readonly fileName: string;
13
+ readonly language: string;
14
+ readonly genre: string;
15
+ readonly findings: readonly ReportedFinding[];
16
+ /** The line the person reported: the finding's line, or for a miss the line they named. */
17
+ readonly line: number;
18
+ readonly excerpts: readonly Excerpt[];
19
+ readonly config: string | undefined;
20
+ };
21
+
22
+ export type FeedbackDraft = { readonly title: string; readonly body: string };
23
+
24
+ const TEXT: Texts<{
25
+ readonly titleFalse: (rule: string, message: string) => string;
26
+ readonly titleMissed: (fileName: string, line: number) => string;
27
+ readonly what: Readonly<Record<FeedbackKind, string>>;
28
+ readonly environment: string;
29
+ readonly findings: string;
30
+ readonly excerpt: string;
31
+ readonly config: string;
32
+ readonly expected: string;
33
+ readonly expectedHint: Readonly<Record<FeedbackKind, string>>;
34
+ readonly lines: (from: number, to: number) => string;
35
+ }> = {
36
+ ja: {
37
+ titleFalse: (rule, message) => `誤検出: ${rule} — ${message}`,
38
+ titleMissed: (fileName, line) => `見逃し: ${fileName} の ${String(line)} 行目`,
39
+ what: { "false-positive": "この指摘は誤りだと思います。", missed: "ここで chaff は何か言うべきだと思います。" },
40
+ environment: "環境",
41
+ findings: "指摘",
42
+ excerpt: "文書の該当箇所(この部分だけを載せています)",
43
+ config: "chaff.yaml",
44
+ expected: "期待すること",
45
+ expectedHint: {
46
+ "false-positive": "<!-- なぜ誤りか、本来どう読むべきかを書いてください -->",
47
+ missed: "<!-- 何が問題で、どの rule が言うべきだったかを書いてください -->",
48
+ },
49
+ lines: (from, to) => `${String(from)}〜${String(to)} 行目`,
50
+ },
51
+ en: {
52
+ titleFalse: (rule, message) => `False positive: ${rule} — ${message}`,
53
+ titleMissed: (fileName, line) => `Missed: ${fileName} line ${String(line)}`,
54
+ what: { "false-positive": "I think this finding is wrong.", missed: "I think chaff should say something here." },
55
+ environment: "Environment",
56
+ findings: "Findings",
57
+ excerpt: "The part of the document (only these lines are included)",
58
+ config: "chaff.yaml",
59
+ expected: "What I expected",
60
+ expectedHint: {
61
+ "false-positive": "<!-- Why it is wrong, and how the text should be read -->",
62
+ missed: "<!-- What is wrong in the text, and which rule should have said so -->",
63
+ },
64
+ lines: (from, to) => `lines ${String(from)}-${String(to)}`,
65
+ },
66
+ };
67
+
68
+ const fence = (content: string, language = ""): string => {
69
+ const longest = Math.max(2, ...[...content.matchAll(/`+/gu)].map((match) => match[0].length));
70
+ const ticks = "`".repeat(longest + 1);
71
+ return [`${ticks}${language}`, content, ticks].join("\n");
72
+ };
73
+
74
+ /** Pure: the issue title and body for a report. Nothing here reads files or the network. */
75
+ export const feedbackDraft = (input: FeedbackInput, ui: UiLanguage): FeedbackDraft => {
76
+ const text = TEXT[ui];
77
+ const first = input.findings[0];
78
+ const title =
79
+ input.kind === "false-positive" && first !== undefined ? text.titleFalse(first.rule, first.message) : text.titleMissed(input.fileName, input.line);
80
+ const body = [
81
+ text.what[input.kind],
82
+ "",
83
+ `## ${text.environment}`,
84
+ "",
85
+ `- chaffjs ${input.version}`,
86
+ `- ${input.runtime}`,
87
+ `- ${input.fileName} · ${input.language} · ${input.genre}`,
88
+ ...(input.findings.length === 0
89
+ ? []
90
+ : ["", `## ${text.findings}`, "", ...input.findings.map((finding) => `- \`${finding.rule}\` (${String(finding.line)}): ${finding.message}`)]),
91
+ "",
92
+ `## ${text.excerpt}`,
93
+ ...input.excerpts.flatMap((excerpt) => ["", text.lines(excerpt.from, excerpt.to), "", fence(excerpt.lines.join("\n"))]),
94
+ ...(input.config === undefined ? [] : ["", `## ${text.config}`, "", fence(input.config.trimEnd(), "yaml")]),
95
+ "",
96
+ `## ${text.expected}`,
97
+ "",
98
+ text.expectedHint[input.kind],
99
+ "",
100
+ ].join("\n");
101
+ return { title, body };
102
+ };
@@ -0,0 +1,23 @@
1
+ /** Lines around a line of a document, 1-based and inclusive. Only these lines go into a report. */
2
+ export type Excerpt = { readonly from: number; readonly to: number; readonly lines: readonly string[] };
3
+
4
+ export const EXCERPT_RADIUS = 2;
5
+
6
+ /** Pure: the excerpts around each line, overlapping ones merged, in document order. Lines outside the document are dropped. */
7
+ export const excerptsAround = (source: string, lines: readonly number[], radius: number = EXCERPT_RADIUS): Excerpt[] => {
8
+ const all = source.split("\n");
9
+ const ranges = [...new Set(lines)]
10
+ .filter((line) => line >= 1 && line <= all.length)
11
+ .sort((left, right) => left - right)
12
+ .map((line) => ({ from: Math.max(1, line - radius), to: Math.min(all.length, line + radius) }));
13
+ const merged = ranges.reduce<{ from: number; to: number }[]>((acc, range) => {
14
+ const last = acc.at(-1);
15
+ if (last !== undefined && range.from <= last.to + 1) {
16
+ last.to = Math.max(last.to, range.to);
17
+ return acc;
18
+ }
19
+ acc.push({ ...range });
20
+ return acc;
21
+ }, []);
22
+ return merged.map(({ from, to }) => ({ from, to, lines: all.slice(from - 1, to) }));
23
+ };
package/src/genre.ts CHANGED
@@ -1,4 +1,4 @@
1
- export type GenreGuess = { readonly genre: string; readonly from: string };
1
+ export type GenreGuess = { readonly genre: string; readonly from: "front-matter" | "path" | "content" };
2
2
 
3
3
  /**
4
4
  * 仕様書・README は「読ませる文章」ではなく「間違えさせない文章」。
@@ -27,11 +27,11 @@ const BY_CONTENT: readonly (readonly [RegExp, string])[] = [
27
27
  ];
28
28
 
29
29
  export const guessGenre = (path: string, source: string, front: string | undefined): GenreGuess | undefined => {
30
- if (front !== undefined) return { genre: front, from: "front matter" };
30
+ if (front !== undefined) return { genre: front, from: "front-matter" };
31
31
  const byPath = BY_PATH.find(([pattern]) => pattern.test(path));
32
- if (byPath !== undefined) return { genre: byPath[1], from: "パス" };
32
+ if (byPath !== undefined) return { genre: byPath[1], from: "path" };
33
33
  const byContent = BY_CONTENT.find(([pattern]) => pattern.test(source));
34
- if (byContent !== undefined) return { genre: byContent[1], from: "内容" };
34
+ if (byContent !== undefined) return { genre: byContent[1], from: "content" };
35
35
  return undefined;
36
36
  };
37
37
 
package/src/init.ts CHANGED
@@ -1,7 +1,21 @@
1
1
  import { appendFileSync, existsSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
+ import type { Texts, UiLanguage } from "./ui.ts";
3
4
 
4
- const CONFIG = (genre: string): string => `# chaff.yaml — このチームの文章規範
5
+ const TEXT: Texts<{
6
+ readonly config: (genre: string) => string;
7
+ readonly created: string;
8
+ readonly appended: (lines: readonly string[]) => string;
9
+ readonly configNote: string;
10
+ readonly exists: string;
11
+ readonly createdHeading: string;
12
+ readonly genreChosen: (genre: string) => string;
13
+ readonly genreList: string;
14
+ readonly next: string;
15
+ readonly nextCheck: string;
16
+ }> = {
17
+ ja: {
18
+ config: (genre) => `# chaff.yaml — このチームの文章規範
5
19
  #
6
20
  # ここに書くのは「既定から変えたもの」だけです。書かなければ既定で動きます。
7
21
  # このファイルを消しても chaff は動きます。
@@ -24,7 +38,53 @@ genre: ${genre}
24
38
 
25
39
  # 既定から変えたものだけを書く。
26
40
  rules:
27
- `;
41
+ `,
42
+ created: "作成しました",
43
+ appended: (lines) => `${lines.join(" と ")} を追記しました`,
44
+ configNote: "規範の宣言。commit してください",
45
+ exists: "chaff.yaml は既にあります。変更していません。",
46
+ createdHeading: "作成しました:",
47
+ genreChosen: (genre) => `ジャンルは ${genre} にしました。違う場合は chaff.yaml の genre を直してください。`,
48
+ genreList: " 一覧: npx chaff genres",
49
+ next: "次:",
50
+ nextCheck: " npx chaff . この場所の Markdown を全部見る",
51
+ },
52
+ en: {
53
+ config: (genre) => `# chaff.yaml — this team's writing rules
54
+ #
55
+ # Write only what differs from the defaults; anything left out uses the default.
56
+ # chaff still runs if this file is deleted.
57
+ #
58
+ # A level is one of four words. No numbers needed.
59
+ #
60
+ # strict check closely
61
+ # normal the default
62
+ # relaxed check loosely
63
+ # off do not check
64
+ #
65
+ # Commands change it too, and leave the reason as a comment.
66
+ #
67
+ # npx chaff relax bold-density --why "figure captions use a lot of bold"
68
+ # npx chaff explain bold-density read what the rule is for
69
+ # npx chaff rules --json give this to an AI that writes the settings
70
+
71
+ # The kind of document kept here.
72
+ genre: ${genre}
73
+
74
+ # Only what differs from the defaults.
75
+ rules:
76
+ `,
77
+ created: "created",
78
+ appended: (lines) => `added ${lines.join(" and ")}`,
79
+ configNote: "the team's rules; commit it",
80
+ exists: "chaff.yaml already exists. Nothing was changed.",
81
+ createdHeading: "Created:",
82
+ genreChosen: (genre) => `The genre is ${genre}. If that is wrong, change genre in chaff.yaml.`,
83
+ genreList: " List: npx chaff genres",
84
+ next: "Next:",
85
+ nextCheck: " npx chaff . check every Markdown file here",
86
+ },
87
+ };
28
88
 
29
89
  /** 判定のキャッシュと、鍵を書くファイル。どちらも commit しない。 */
30
90
  const GITIGNORE_LINES = [".chaff-cache/", ".env*"];
@@ -38,31 +98,30 @@ const writeIfAbsent = (path: string, body: string, note: string): Written | unde
38
98
  };
39
99
 
40
100
  /** 判定のキャッシュと .env は commit しない。init が .gitignore に足す。 */
41
- const ensureGitignore = (dir: string): Written | undefined => {
101
+ const ensureGitignore = (dir: string, text: (typeof TEXT)["ja"]): Written | undefined => {
42
102
  const path = join(dir, ".gitignore");
43
- if (!existsSync(path)) return writeIfAbsent(path, `${GITIGNORE_LINES.join("\n")}\n`, "作成しました");
103
+ if (!existsSync(path)) return writeIfAbsent(path, `${GITIGNORE_LINES.join("\n")}\n`, text.created);
44
104
  const body = readFileSync(path, "utf8");
45
105
  const missing = GITIGNORE_LINES.filter((line) => !body.split("\n").includes(line));
46
106
  if (missing.length === 0) return undefined;
47
107
  appendFileSync(path, `${missing.join("\n")}\n`, "utf8");
48
- return { path, note: `${missing.join(" と ")} を追記しました` };
108
+ return { path, note: text.appended(missing) };
49
109
  };
50
110
 
51
- export const runInit = (dir: string, genre: string): string[] => {
52
- const made = [writeIfAbsent(join(dir, "chaff.yaml"), CONFIG(genre), "規範の宣言。commit してください"), ensureGitignore(dir)].filter(
53
- (entry) => entry !== undefined,
54
- );
55
- if (made.length === 0) return ["chaff.yaml は既にあります。変更していません。"];
111
+ export const runInit = (dir: string, genre: string, ui: UiLanguage = "ja"): string[] => {
112
+ const text = TEXT[ui];
113
+ const made = [writeIfAbsent(join(dir, "chaff.yaml"), text.config(genre), text.configNote), ensureGitignore(dir, text)].filter((entry) => entry !== undefined);
114
+ if (made.length === 0) return [text.exists];
56
115
  return [
57
116
  "",
58
- "作成しました:",
117
+ text.createdHeading,
59
118
  ...made.map((entry) => ` ${entry.path} ${entry.note}`),
60
119
  "",
61
- `ジャンルは ${genre} にしました。違う場合は chaff.yaml の genre を直してください。`,
62
- " 一覧: npx chaff genres",
120
+ text.genreChosen(genre),
121
+ text.genreList,
63
122
  "",
64
- "次:",
65
- " npx chaff . この場所の Markdown を全部見る",
123
+ text.next,
124
+ text.nextCheck,
66
125
  "",
67
126
  ];
68
127
  };
@@ -80,8 +80,23 @@ const leftRunBeside = (chars: readonly string[], last: number, japanese: string
80
80
  return kind === "letter" && DIGIT.test(runStart(chars, last) ?? "") ? "after-digit" : kind;
81
81
  };
82
82
 
83
+ /**
84
+ * 「第3条」「第4条第2項」の数字は番地の書き方で、空けるか詰めるかの好みではない。数えると、条番号の多い文書では
85
+ * 「2 か所」のような普通の書き方のほうが少数に見えてしまう。「第」のすぐ後ろの数字の並びは、両側とも数えない。
86
+ */
87
+ const ORDINAL_PREFIX = "第";
88
+
89
+ const isOrdinalRun = (chars: readonly string[], index: number): boolean => {
90
+ let first = index;
91
+ while (first > 0 && /[\d.]/u.test(chars[first - 1] ?? "")) first -= 1;
92
+ const before = chars[first - 1] === " " ? first - 2 : first - 1;
93
+ return chars[before] === ORDINAL_PREFIX;
94
+ };
95
+
83
96
  const boundaryAt = (chars: readonly string[], index: number): Omit<Boundary, "offset"> | undefined => {
84
97
  const [left, right, after] = [chars[index], chars[index + 1], chars[index + 2]];
98
+ const numberAfterPrefix = left === ORDINAL_PREFIX && DIGIT.test((right === " " ? after : right) ?? "");
99
+ if (numberAfterPrefix || (DIGIT.test(left ?? "") && isOrdinalRun(chars, index))) return undefined;
85
100
  const touching = latinBeside(left, right, "before-digit") ?? leftRunBeside(chars, index, right);
86
101
  if (touching !== undefined) return { kind: touching, spaced: false };
87
102
  if (right !== " ") return undefined;
package/src/plugin.ts CHANGED
@@ -93,6 +93,11 @@ export type NumberedLine = {
93
93
  readonly label: string;
94
94
  /** 番号に続く見出し。「第3条(支払)」の「支払」。本文しか無ければ空。 */
95
95
  readonly heading: string;
96
+ /**
97
+ * 番号に付いた語("section"、"article")。英米の文書は Section と Article を別のものとして使うので、
98
+ * 参照の attrs.numbering と比べて、この文書に無い種類の番号への参照(Section で組んだ法令の中の "Article 6")を見分ける。
99
+ */
100
+ readonly numbering?: string | undefined;
96
101
  /** 番号の後ろの文字列全部。定義や参照はここから探す。 */
97
102
  readonly rest: string;
98
103
  /**
@@ -100,6 +105,8 @@ export type NumberedLine = {
100
105
  * 枝番号(第3条の2)は並びの外なので付けない。読み方を知っているのは言語パッケージだけなので、core は計算しない。
101
106
  */
102
107
  readonly ordinal?: number | undefined;
108
+ /** 一行で番号の範囲をまとめるとき(「第四十三条から第五十五条まで 削除」)の最後の位置。次の番号はここから数える。 */
109
+ readonly ordinalTo?: number | undefined;
103
110
  };
104
111
 
105
112
  /** 番号を読むときに見える周り。「(i)」がローマ数字か英字かは、開いている番号で決まる。 */
@@ -122,6 +129,16 @@ export type StructurePatterns = {
122
129
  readonly obligations: (text: string) => readonly Mention[];
123
130
  /** 数量。attrs.value に数、attrs.unit に単位。 */
124
131
  readonly quantities: (text: string) => readonly Mention[];
132
+ /**
133
+ * 「In this section—」のように、この後の定義はいま開いている条の中でだけ通じると宣言する行なら true。
134
+ * その条の定義に scope: "local" が付く。定義の並びが次の行から始まるので、定義の行だけを見ても分からない。
135
+ */
136
+ readonly opensDefinitionScope?: (text: string) => boolean;
137
+ /**
138
+ * 範囲が条より広いまとまり(「In this Part—」)なら、そのまとまりの深さ(NumberedLine.depth)。
139
+ * そのまとまりの中の定義どうしだけを比べる。無ければ、opensDefinitionScope のとおり条の中。
140
+ */
141
+ readonly definitionScopeDepth?: (text: string) => number | undefined;
125
142
  /** 日付。attrs.value に "2024-04-01"・"2024-04"・"04-01"・"2024" のどれか。無い言語は日付を読まない。 */
126
143
  readonly dates?: (text: string) => readonly Mention[];
127
144
  /**
@@ -129,6 +146,8 @@ export type StructurePatterns = {
129
146
  * 数字と点だけの通し番号は core が言語を問わず読むので、それを数量と見分けられるのは言語パッケージだけ。
130
147
  */
131
148
  readonly countedAfter?: (number: string, rest: string) => boolean;
149
+ /** 数の書き方(「二十二」「3」)を数にする。相対の参照の「前二項」「前条第二項」が使う。 */
150
+ readonly number?: (text: string) => number | undefined;
132
151
  };
133
152
 
134
153
  export type StructureKind = "doc" | "section" | "chapter" | "article" | "item" | "definition" | "reference" | "obligation" | "quantity" | "date";
@@ -144,6 +163,12 @@ export type StructureNode = {
144
163
  readonly children: readonly StructureNode[];
145
164
  /** 番号付きのまとまりの、並びの中の位置(NumberedLine.ordinal)。S 式には出さない。 */
146
165
  readonly ordinal?: number;
166
+ /** 番号付きのまとまりの深さ(NumberedLine.depth)。項と号のように、同じ親の下で別の並びを分ける。S 式には出さない。 */
167
+ readonly level?: number;
168
+ /** 範囲をまとめた行の最後の位置(NumberedLine.ordinalTo)。S 式には出さない。 */
169
+ readonly ordinalTo?: number;
170
+ /** 番号に付いた語(NumberedLine.numbering)。S 式には出さない。 */
171
+ readonly numbering?: string;
147
172
  };
148
173
 
149
174
  // ───────── 文書モデルと rule ─────────
@@ -169,6 +194,65 @@ export type BulletList = { readonly span: Span; readonly items: readonly number[
169
194
  * detector に渡る唯一の入り口。core が I/O を済ませてから呼ぶ。
170
195
  * detector は純関数で、fs / network / clock に触れない。spec §6。
171
196
  */
197
+ /**
198
+ * 文書の種類の知識(法令の番地の書き方など)。コードは種類を知らず、profiles/*.yaml に書いたものを読む。
199
+ * 言語ごとに一つに絞ったもの。正規表現は文字列のまま持ち、使う側が組み立てる。
200
+ */
201
+ export type DocumentProfile = {
202
+ readonly id: string;
203
+ /** 番地の書き方。漢字の連なりにも、数量にも数えない。 */
204
+ readonly addresses: readonly string[];
205
+ /** 番地と番地のあいだ、番地の後ろに付く語。 */
206
+ readonly connectives: readonly string[];
207
+ /** 番地の並びのすぐ後ろに来てよい文字(正規表現)。無ければどこで終わってもよい。 */
208
+ readonly addressEnd?: string | undefined;
209
+ /**
210
+ * 条の前の行に置く見出しの形(正規表現。最初の括弧の中が見出しの言葉)。法令の「(解雇の予告)」。
211
+ * すぐ次の行の条が見出しを持たなければ、その条の見出しになる。
212
+ */
213
+ readonly caption?: string | undefined;
214
+ /** 前条・同項のように、書いた場所から番地が決まる参照の読み方。無ければ読まない。 */
215
+ readonly relative?: RelativeVocabulary | undefined;
216
+ };
217
+
218
+ /**
219
+ * 相対の参照の語彙。語の意味(一つ前・一つ後・直前に引いたもの・今いるところ)はコードが知り、どの語がそれに当たるかは種類が決める。
220
+ * units は単位の語から木の深さ(NumberedLine.depth)への対応。
221
+ */
222
+ export type RelativeVocabulary = {
223
+ readonly before: readonly string[];
224
+ readonly after: readonly string[];
225
+ readonly same: readonly string[];
226
+ readonly current: readonly string[];
227
+ /** 「前各項」の「各」。一つ前だけでなく、前の全部。 */
228
+ readonly every: readonly string[];
229
+ /** 「前二項」の「二」のような、いくつ分かの数の書き方(正規表現)。数は言語パッケージの number が読む。 */
230
+ readonly count: string;
231
+ readonly units: Readonly<Record<string, number>>;
232
+ /** 「前条第二項」の「第」。後ろに続く番地の書き出し。 */
233
+ readonly suffixPrefix: string;
234
+ /**
235
+ * 最初のものに番号を振らない深さ(法令の項)。あれば「第二条第一号」は 2.1.1、「第四条第一項」は第四条そのものでもある。
236
+ */
237
+ readonly implicitFirst: number | undefined;
238
+ /** すぐ前に来てはいけない文字(正規表現)。「事前条件」の「前条」を参照にしない。 */
239
+ readonly notAfter: string | undefined;
240
+ /**
241
+ * 読み替え(「『前条』とあるのは『…』」)の括弧。中の前条・同項は読み替える先の文の言葉で、ここからは決められないので読まない。
242
+ * 閉じの直後に after、開きの直前に before のどれかがあるものだけ。
243
+ */
244
+ readonly substitution: { readonly open: string; readonly close: string; readonly after: readonly string[]; readonly before: readonly string[] } | undefined;
245
+ /** 条を書かずにこの単位で始まる番地(法令の「第一項」「第二号」)は、書いた場所を含むまとまりの中を指す。 */
246
+ readonly inside: readonly string[];
247
+ /**
248
+ * 並びをつなぐもの(正規表現。及び・若しくは・から・、・枝番号の「の二」)。すぐ前の参照とのあいだがこれとつなぎの語(connectives)と
249
+ * 閉じた括弧だけなら、条を書かない番地はその参照の続き。「第三十三条第七項若しくは第九項」の「第九項」は第三十三条の第九項。
250
+ */
251
+ readonly joiners: readonly string[];
252
+ /** 括弧書き(「(…)」)の開きと閉じ。並びの途中に挟まっても、並びを切らない。 */
253
+ readonly aside: { readonly open: string; readonly close: string } | undefined;
254
+ };
255
+
172
256
  export type ProseDocument = {
173
257
  readonly path: string;
174
258
  readonly source: string;
@@ -190,6 +274,10 @@ export type ProseDocument = {
190
274
  readonly requiredSections: readonly string[];
191
275
  /** 番地の付いた木(§27)。adapter が structure を持たない言語では無い。 */
192
276
  readonly structure: StructureNode | undefined;
277
+ /** 文書の種類(法令など)。選ばれなければ無い。 */
278
+ readonly profile?: DocumentProfile | undefined;
279
+ /** 本文でないもの(コード・HTML・強調の印)を同じ長さの空白で覆った source。位置は source と同じ。 */
280
+ readonly prose?: string | undefined;
193
281
  };
194
282
 
195
283
  export type Severity = "error" | "warning" | "info";
@@ -0,0 +1,11 @@
1
+ import { applyByPath } from "../config/by-path.ts";
2
+ import type { Config } from "../config/load.ts";
3
+ import type { DocumentProfile } from "../plugin.ts";
4
+ import { loadProfiles } from "./load.ts";
5
+ import { chooseProfile } from "./select.ts";
6
+
7
+ type ProfileSettings = Pick<Config, "profile" | "byPath" | "baseDir">;
8
+
9
+ /** 1 ファイルの文書の種類。by_path、chaff.yaml、内容の順に選ぶ。 */
10
+ export const profileFor = (config: ProfileSettings, path: string, source: string, language: string): DocumentProfile | undefined =>
11
+ chooseProfile(loadProfiles(), { byPath: applyByPath(config.byPath, config.baseDir, path).profile, config: config.profile, source, language })?.profile;
@@ -0,0 +1,26 @@
1
+ import { readFileSync, readdirSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { parse } from "yaml";
5
+ import { parseProfile, type ProfileDefinition } from "./parse.ts";
6
+
7
+ const PROFILES_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "profiles");
8
+
9
+ const loaded: { value: readonly ProfileDefinition[] | undefined } = { value: undefined };
10
+
11
+ /** 同梱の profiles/*.yaml。一度だけ読む。 */
12
+ export const loadProfiles = (): readonly ProfileDefinition[] => {
13
+ loaded.value ??= readdirSync(PROFILES_DIR)
14
+ .filter((file) => file.endsWith(".yaml"))
15
+ .sort((left, right) => left.localeCompare(right))
16
+ .flatMap((file) => {
17
+ const path = join(PROFILES_DIR, file);
18
+ try {
19
+ const definition = parseProfile(parse(readFileSync(path, "utf8")));
20
+ return definition === undefined ? [] : [definition];
21
+ } catch (error) {
22
+ throw new Error(`cannot read the profile ${path}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
23
+ }
24
+ });
25
+ return loaded.value;
26
+ };
@@ -0,0 +1,106 @@
1
+ import type { DocumentProfile, RelativeVocabulary } from "../plugin.ts";
2
+
3
+ /** profiles/*.yaml を一つ読んだもの。言語ごとの中身と、内容から選ぶための形を持つ。 */
4
+ export type ProfileDefinition = {
5
+ readonly id: string;
6
+ readonly name: Readonly<Record<string, string>>;
7
+ readonly detect: Readonly<Record<string, ProfileDetect>>;
8
+ readonly languages: Readonly<Record<string, DocumentProfile>>;
9
+ };
10
+
11
+ /** この形の行が minLines 行以上あれば、その種類の文書として読む。 */
12
+ type ProfileDetect = { readonly line: string; readonly minLines: number };
13
+
14
+ const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
15
+
16
+ const strings = (value: unknown): string[] => (Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === "string" && entry !== "") : []);
17
+
18
+ const texts = (value: unknown): Record<string, string> =>
19
+ isRecord(value) ? Object.fromEntries(Object.entries(value).filter((entry): entry is [string, string] => typeof entry[1] === "string")) : {};
20
+
21
+ /** 空でない文字列だけ。空の正規表現は何にでも当たる。 */
22
+ const text = (value: unknown): string | undefined => (typeof value === "string" && value !== "" ? value : undefined);
23
+
24
+ const detectOf = (value: unknown): ProfileDetect | undefined => {
25
+ if (!isRecord(value) || typeof value["line"] !== "string") return undefined;
26
+ const minLines = value["min_lines"];
27
+ return { line: value["line"], minLines: typeof minLines === "number" && minLines > 0 ? minLines : 1 };
28
+ };
29
+
30
+ const unitsOf = (value: unknown): Record<string, number> =>
31
+ isRecord(value) ? Object.fromEntries(Object.entries(value).filter((entry): entry is [string, number] => entry[0] !== "" && Number.isInteger(entry[1]))) : {};
32
+
33
+ const asideOf = (value: unknown): RelativeVocabulary["aside"] => {
34
+ if (!isRecord(value)) return undefined;
35
+ const [open, close] = [text(value["open"]), text(value["close"])];
36
+ return open === undefined || close === undefined ? undefined : { open, close };
37
+ };
38
+
39
+ /** 括弧の開き・閉じと、目印が一つも無ければ読まない。 */
40
+ const substitutionOf = (value: unknown): RelativeVocabulary["substitution"] => {
41
+ if (!isRecord(value)) return undefined;
42
+ const [open, close] = [text(value["open"]), text(value["close"])];
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 };
45
+ };
46
+
47
+ /** 単位が一つも無ければ読まない。何を指すのか決められない。 */
48
+ const relativeOf = (value: unknown): RelativeVocabulary | undefined => {
49
+ if (!isRecord(value)) return undefined;
50
+ const units = unitsOf(value["units"]);
51
+ if (Object.keys(units).length === 0) return undefined;
52
+ return {
53
+ before: strings(value["before"]),
54
+ after: strings(value["after"]),
55
+ same: strings(value["same"]),
56
+ current: strings(value["current"]),
57
+ every: strings(value["every"]),
58
+ count: text(value["count"]) ?? "(?!)",
59
+ units,
60
+ suffixPrefix: text(value["suffix_prefix"]) ?? "",
61
+ implicitFirst: typeof value["implicit_first"] === "string" ? units[value["implicit_first"]] : undefined,
62
+ notAfter: text(value["not_after"]),
63
+ substitution: substitutionOf(value["substitution"]),
64
+ inside: strings(value["inside"]).filter((unit) => units[unit] !== undefined),
65
+ joiners: strings(value["joiners"]),
66
+ aside: asideOf(value["aside"]),
67
+ };
68
+ };
69
+
70
+ const LANGUAGE_KEYS = new Set(["id", "name", "why", "detect"]);
71
+
72
+ const languagesOf = (id: string, raw: Record<string, unknown>): Record<string, DocumentProfile> =>
73
+ Object.fromEntries(
74
+ Object.entries(raw)
75
+ .filter(([key, value]) => !LANGUAGE_KEYS.has(key) && isRecord(value))
76
+ .map(([language, value]) => {
77
+ const section = isRecord(value) ? value : {};
78
+ const addressEnd = text(section["address_end"]);
79
+ const caption = text(section["caption"]);
80
+ const relative = relativeOf(section["relative"]);
81
+ return [language, { id, addresses: strings(section["addresses"]), connectives: strings(section["connectives"]), addressEnd, caption, relative }];
82
+ }),
83
+ );
84
+
85
+ /** id の無いものは読まない。どの設定から選ばれたのか言えなくなるため。 */
86
+ export const parseProfile = (raw: unknown): ProfileDefinition | undefined => {
87
+ if (!isRecord(raw) || typeof raw["id"] !== "string" || raw["id"] === "") return undefined;
88
+ const id = raw["id"];
89
+ const detect = isRecord(raw["detect"])
90
+ ? Object.fromEntries(
91
+ Object.entries(raw["detect"]).flatMap(([language, value]) => {
92
+ const found = detectOf(value);
93
+ return found === undefined ? [] : [[language, found] as const];
94
+ }),
95
+ )
96
+ : {};
97
+ return { id, name: texts(raw["name"]), detect, languages: languagesOf(id, raw) };
98
+ };
99
+
100
+ /** 行頭から数える。source 全体に m フラグで当てれば、行ごとに分けずに数えられる。 */
101
+ export const detected = (definition: ProfileDefinition, source: string, language: string): boolean => {
102
+ const detect = definition.detect[language];
103
+ if (detect === undefined) return false;
104
+ const count = [...source.matchAll(new RegExp(detect.line, "gmu"))].length;
105
+ return count >= detect.minLines;
106
+ };
@@ -0,0 +1,35 @@
1
+ import type { DocumentProfile } from "../plugin.ts";
2
+ import { detected, type ProfileDefinition } from "./parse.ts";
3
+
4
+ /** 設定で書いた「使わない」。内容から選ぶのも止める。 */
5
+ export const NO_PROFILE = "none";
6
+
7
+ type ProfileSource = "by-path" | "config" | "content";
8
+
9
+ export type ChosenProfile = { readonly profile: DocumentProfile; readonly from: ProfileSource };
10
+
11
+ export type ProfileRequest = {
12
+ readonly byPath: string | undefined;
13
+ readonly config: string | undefined;
14
+ readonly source: string;
15
+ readonly language: string;
16
+ };
17
+
18
+ const named = (definitions: readonly ProfileDefinition[], id: string, language: string): DocumentProfile | undefined =>
19
+ definitions.find((definition) => definition.id === id)?.languages[language];
20
+
21
+ /**
22
+ * 強いものから: by_path、chaff.yaml の profile、内容。どれかが none と書けばそこで止め、内容からも選ばない。
23
+ * 名前で選んだ種類にその言語の中身が無ければ選ばない。黙って別の種類にしない。
24
+ */
25
+ export const chooseProfile = (definitions: readonly ProfileDefinition[], request: ProfileRequest): ChosenProfile | undefined => {
26
+ const written = request.byPath ?? request.config;
27
+ if (written === NO_PROFILE) return undefined;
28
+ if (written !== undefined) {
29
+ const profile = named(definitions, written, request.language);
30
+ return profile === undefined ? undefined : { profile, from: request.byPath === undefined ? "config" : "by-path" };
31
+ }
32
+ const found = definitions.find((definition) => detected(definition, request.source, request.language));
33
+ const profile = found?.languages[request.language];
34
+ return profile === undefined ? undefined : { profile, from: "content" };
35
+ };