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/cli.ts CHANGED
@@ -16,6 +16,7 @@ import { runEval } from "./commands/eval.ts";
16
16
  import { runTest } from "./commands/test.ts";
17
17
  import { frontMatterGenre, guessGenre } from "./genre.ts";
18
18
  import { GENRES, runInit } from "./init.ts";
19
+ import { targetsOf } from "./cli-args.ts";
19
20
  import { loadRules } from "./rule-load.ts";
20
21
  import { renderCompact } from "./render/compact.ts";
21
22
  import { renderExplain } from "./render/explain.ts";
@@ -23,62 +24,50 @@ import { renderFriendly } from "./render/friendly.ts";
23
24
  import { rulesJson } from "./render/rules-json.ts";
24
25
  import { renderSarif } from "./render/sarif.ts";
25
26
  import { VERSION } from "./version.ts";
26
- import { runTree, treeTargets } from "./commands/tree.ts";
27
+ import { runTree, treeTargets, type TreeContext } from "./commands/tree.ts";
27
28
  import { citeTargets, runCite } from "./commands/cite.ts";
29
+ import { runSkill } from "./commands/skill.ts";
30
+ import { homedir } from "node:os";
31
+ import { ruleProblems } from "./config/rule-problems.ts";
28
32
  import { renderSummary, type FileOutcome } from "./render/summary.ts";
29
33
  import { neededBy, runRules } from "./run.ts";
30
34
  import type { Level, RuleDefinition } from "./plugin.ts";
35
+ import { CLI_TEXT, type CliText, type GenreSource } from "./cli-text.ts";
36
+ import { hostLanguage, uiLanguageOf, type UiLanguage } from "./ui.ts";
31
37
 
32
- const USAGE = `chaff — 文章の読みにくいところを見つけます。文章は書き換えません。
33
-
34
- chaff <file|dir|glob>... 検査する(設定も API key も要りません)
35
- chaff . この場所の Markdown を全部
36
- chaff test <file|dir>... 意味を読む検査も動かす(API key が要ります)
37
- 判定役は chaff.yaml の ai_backend で選びます
38
- chaff init chaff.yaml を作る
39
- chaff eval <dir> 手元の文書で閾値を測り直す
40
- chaff explain <rule> そのルールの意図と根拠を読む
41
- chaff genres ジャンルの一覧
42
- chaff tree <file> [--format sexp|json] 文書を番地の付いた木にする(条・項・定義・参照)
43
- chaff cite <原文> <引用.json> 回答の引用(番地と引用文)が原文にあるかを確かめる
44
- chaff rules --json いまの設定を JSON で出す(AI に渡す用)
45
- chaff baseline <dir> いまある指摘を棚上げする(既存の repo に入れるとき)
46
- chaff suppressions <dir> stet で黙らせている指摘を数える
47
- chaff relax|strict|off <rule> [--why "理由"]
48
-
49
- --compact エンジニア向けの 1 行形式
50
- --experimental 試験中の rule も動かす
51
- --show-baseline 棚上げした分も含めて全部見る
52
- --watch 保存のたびに見直し、変わったところだけ出す
53
- --dry-run test で、何を AI に送るかだけを見る(API を呼びません)
54
- --sarif <path> 指摘を SARIF で書き出す(GitHub の PR の行に出すため)
55
-
56
- この箇所だけ黙らせる: <!-- stet: rule-id — 理由 -->
57
-
58
- 値は strict / normal / relaxed / off の 4 つから選びます。
59
- `;
38
+ /** Text for output that is not about one document. */
39
+ const hostText = (config: Config): CliText => CLI_TEXT[hostLanguage(config.language, process.env)];
60
40
 
61
41
  const readConfig = (): Config => (existsSync(join(process.cwd(), CONFIG_FILE)) ? loadConfig(join(process.cwd(), CONFIG_FILE)) : EMPTY);
62
42
 
63
- const resolveGenre = (path: string, source: string, config: Config): { genre: string; from: string } => {
64
- // パスごとの上書きが最優先。「全体はこう、ここだけは違う」を書けるようにする。
43
+ const resolveGenre = (path: string, source: string, config: Config, cliGenre?: string): { genre: string; from: GenreSource } => {
44
+ // コマンドで指定したものが最優先。その実行だけの指定だから、設定より強い。
45
+ if (cliGenre !== undefined) return { genre: cliGenre, from: "--genre" };
46
+ // パスごとの上書きが次。「全体はこう、ここだけは違う」を書けるようにする。
65
47
  const override = applyByPath(config.byPath, config.baseDir, path);
66
- if (override.genre !== undefined) return { genre: override.genre, from: `${CONFIG_FILE} の by_path` };
67
- if (config.genre !== undefined) return { genre: config.genre, from: CONFIG_FILE };
48
+ if (override.genre !== undefined) return { genre: override.genre, from: "by_path" };
49
+ if (config.genre !== undefined) return { genre: config.genre, from: "config" };
68
50
  const guess = guessGenre(path, source, frontMatterGenre(source));
69
- return guess === undefined ? { genre: "blog/tech", from: "既定" } : { genre: guess.genre, from: guess.from };
51
+ return guess === undefined ? { genre: "blog/tech", from: "default" } : { genre: guess.genre, from: guess.from };
70
52
  };
71
53
 
54
+ /** resolveGenre with this run's --genre, for the commands that take it as a dependency. */
55
+ const genreFrom =
56
+ (argv: readonly string[]) =>
57
+ (path: string, source: string, config: Config): { genre: string; from: GenreSource } =>
58
+ resolveGenre(path, source, config, flag(argv, "--genre"));
59
+
72
60
  const findRule = (rules: readonly RuleDefinition[], id: string | undefined): RuleDefinition | undefined => rules.find((rule) => rule.id === id);
73
61
 
74
62
  const changeSetting = (command: Level, ruleId: string | undefined, why: string | undefined): number => {
75
63
  const config = readConfig();
76
- const rule = findRule(loadRules(config.language ?? "ja"), ruleId);
64
+ const language = config.language ?? hostLanguage(undefined, process.env);
65
+ const rule = findRule(loadRules(language), ruleId);
77
66
  if (rule === undefined) {
78
- console.error(`${ruleId ?? "(名前なし)"} というルールはありません。npx chaff rules --json で一覧が出ます。`);
67
+ console.error(hostText(config).unknownRule(ruleId ?? hostText(config).unnamed));
79
68
  return 1;
80
69
  }
81
- const outcome = applyLevel(join(process.cwd(), CONFIG_FILE), rule, command, why, config.language ?? "ja", process.env["USER"] ?? "unknown");
70
+ const outcome = applyLevel(join(process.cwd(), CONFIG_FILE), rule, command, why, language, process.env["USER"] ?? "unknown");
82
71
  console.log(outcome.message);
83
72
  return outcome.ok ? 0 : 1;
84
73
  };
@@ -98,22 +87,22 @@ type Inspected = {
98
87
  readonly all: readonly string[];
99
88
  };
100
89
 
101
- const headerFor = (path: string, genre: string, from: string, language: string, shelved: number, hushed: number): string => {
102
- const shelf = shelved > 0 ? ` 棚上げ ${shelved} 件` : "";
103
- const stet = hushed > 0 ? ` stet ${hushed} 件` : "";
104
- return `${path} ${genre} \u00b7 ${language === "ja" ? "日本語" : "英語"} ジャンルは${from}から${shelf}${stet}`;
90
+ /** The first line for a file, in the file's language. */
91
+ const headerFor = (path: string, genre: string, from: GenreSource, language: string, shelved: number, hushed: number): string => {
92
+ const text = CLI_TEXT[uiLanguageOf(language)];
93
+ return text.header(path, genre, text.languageName(language), text.genreSource[from], shelved, hushed);
105
94
  };
106
95
 
107
96
  const inspect = async (path: string, config: Config, argv: readonly string[]): Promise<Inspected> => {
108
97
  const source = await readFile(path, "utf8");
109
98
  const language = applyByPath(config.byPath, config.baseDir, path).language ?? config.language ?? guessLanguage(source).language;
110
99
  const adapter = await loadAdapter(language);
111
- const { genre, from } = resolveGenre(path, source, config);
100
+ const { genre, from } = resolveGenre(path, source, config, flag(argv, "--genre"));
112
101
  const rules = loadRules(language);
113
102
  const experimental = config.experimental || argv.includes("--experimental");
114
103
  await adapter.prepare?.(neededBy(rules, config.rules, experimental, genre, language));
115
104
  const doc = buildDocument(path, source, adapter, teamRules(config));
116
- const raw = runRules(doc, rules, config.rules, experimental, genre);
105
+ const raw = runRules(doc, rules, config.rules, experimental, genre, config.limits);
117
106
  // 応答は 3 つ。stet で黙らせたものは、ここで落とす。
118
107
  const applied = applySuppressions(
119
108
  source,
@@ -148,26 +137,42 @@ const writeSarif = (results: readonly Inspected[], argv: readonly string[]): voi
148
137
  );
149
138
  mkdirSync(dirname(path), { recursive: true });
150
139
  writeFileSync(path, renderSarif(located, VERSION), "utf8");
151
- console.log(`\n SARIF を書きました: ${path}(${located.length} 件)`);
140
+ console.log(hostText(readConfig()).sarifWritten(path, located.length));
141
+ };
142
+
143
+ /** 効いていない設定は、結果の前に一度だけ言う。標準エラーに出すので、JSON や SARIF の出力は汚さない。 */
144
+ const warnRuleProblems = (config: Config, language: string): void => {
145
+ ruleProblems(config, loadRules(language), hostLanguage(config.language, process.env)).forEach((problem) => console.error(`chaff: ${problem}`));
146
+ };
147
+
148
+ /** Several files end with one summary: in their language when they share one, else the host's. */
149
+ const summaryLanguage = (results: readonly Inspected[], config: Config): UiLanguage => {
150
+ const languages = new Set(results.map((result) => uiLanguageOf(result.language)));
151
+ const [only] = [...languages];
152
+ return languages.size === 1 && only !== undefined ? only : hostLanguage(config.language, process.env);
152
153
  };
153
154
 
154
155
  const lint = async (targets: readonly string[], argv: readonly string[]): Promise<number> => {
155
156
  const paths = collectTargets(targets);
157
+ const config = readConfig();
156
158
  if (paths.length === 0) {
157
159
  // 0 件を成功にすると「CI は通っているが何も検証していない」状態が続く。§14。
158
- console.error(`Markdown が 1 つも見つかりませんでした: ${targets.join(", ")}`);
160
+ console.error(hostText(config).noMarkdown(targets.join(", ")));
159
161
  return 1;
160
162
  }
161
- const config = readConfig();
162
163
  const language = config.language ?? "ja";
163
164
  if (packageFor(language) === undefined) {
164
- console.error(`言語 "${language}" のアダプタがありません。`);
165
+ console.error(hostText(config).noAdapter(language));
165
166
  return 1;
166
167
  }
168
+ warnRuleProblems(config, language);
167
169
  const results = await Promise.all(paths.map((path) => inspect(path, config, argv)));
168
170
  writeSarif(results, argv);
169
171
  results.filter((result) => result.outcome.findings.length > 0 || paths.length === 1).forEach((result) => console.log(result.text));
170
- renderSummary(results.map((result) => result.outcome)).forEach((line) => console.log(line));
172
+ renderSummary(
173
+ results.map((result) => result.outcome),
174
+ summaryLanguage(results, config),
175
+ ).forEach((line) => console.log(line));
171
176
  return results.some((result) => result.outcome.findings.some((finding) => finding.severity === "error")) ? 1 : 0;
172
177
  };
173
178
 
@@ -177,11 +182,13 @@ const lint = async (targets: readonly string[], argv: readonly string[]): Promis
177
182
  */
178
183
  const runWatch = async (targets: readonly string[], argv: readonly string[]): Promise<number> => {
179
184
  const paths = collectTargets(targets);
185
+ const config = readConfig();
186
+ const text = hostText(config);
180
187
  if (paths.length === 0) {
181
- console.error(`Markdown が 1 つも見つかりませんでした: ${targets.join(", ")}`);
188
+ console.error(text.noMarkdown(targets.join(", ")));
182
189
  return 1;
183
190
  }
184
- const config = readConfig();
191
+ warnRuleProblems(config, config.language ?? "ja");
185
192
  const seen = new Map<string, Snapshot>();
186
193
  const once = async (path: string, first: boolean): Promise<void> => {
187
194
  const result = await inspect(path, config, argv);
@@ -189,13 +196,13 @@ const runWatch = async (targets: readonly string[], argv: readonly string[]): Pr
189
196
  const before = seen.get(path) ?? {};
190
197
  seen.set(path, after);
191
198
  if (first) return;
192
- const change = describeChange(before, after);
193
- console.log(`${clock()} ${path} ${change ?? "変わりませんでした"}`);
199
+ const change = describeChange(before, after, text.findingsUnit);
200
+ console.log(`${clock()} ${path} ${change ?? text.unchanged}`);
194
201
  };
195
202
  await Promise.all(paths.map((path) => once(path, true)));
196
203
  const totals = [...seen.values()].reduce((sum, snapshot) => sum + Object.values(snapshot).reduce((inner, count) => inner + count, 0), 0);
197
- console.log(`\n ${paths.length} ファイルを見ています。いまの指摘は ${totals} 件です。`);
198
- console.log(" 保存するたびに、変わったところだけ出します。止めるには Ctrl-C。\n");
204
+ console.log(text.watching(paths.length, totals));
205
+ console.log(text.watchHint);
199
206
  const stop = watchPaths(paths, (path) => void once(path, false));
200
207
  process.on("SIGINT", () => {
201
208
  stop();
@@ -206,49 +213,33 @@ const runWatch = async (targets: readonly string[], argv: readonly string[]): Pr
206
213
 
207
214
  const explain = (ruleId: string | undefined): number => {
208
215
  const config = readConfig();
209
- const language = config.language ?? "ja";
216
+ // The rule's limits differ by language (characters for Japanese, words for English): explain in the one being written.
217
+ const language = config.language ?? hostLanguage(undefined, process.env);
218
+ const text = CLI_TEXT[uiLanguageOf(language)];
210
219
  const rules = loadRules(language);
211
220
  const rule = findRule(rules, ruleId);
212
221
  if (rule === undefined) {
213
222
  const list = rules.map((entry) => ` ${entry.id}`).join("\n");
214
- console.error(`${ruleId ?? "(名前なし)"} というルールはありません。\n一覧:\n${list}`);
223
+ console.error(text.unknownRuleWithList(ruleId ?? text.unnamed, list));
215
224
  return 1;
216
225
  }
217
226
  const current = config.rules[rule.id] ?? (rule.status === "experimental" && !config.experimental ? "off" : "normal");
218
- console.log(renderExplain(rule, current, language, unitOf(rule.id, language), config.genre));
227
+ console.log(renderExplain(rule, current, language, text.unit(rule.id, language), config.genre));
219
228
  return 0;
220
229
  };
221
230
 
222
- /** 閾値の単位。explain で「4 とは何の 4 か」を示すため。 */
223
- const unitOf = (ruleId: string, language: string): string => {
224
- if (ruleId === "bold-density") return "1000 字あたりの箇所数";
225
- if (ruleId !== "max-sentence-length") return "回";
226
- return language === "ja" ? "文字" : "語";
227
- };
228
-
229
231
  const runBaseline = async (targets: readonly string[], argv: readonly string[]): Promise<number> => {
230
232
  const paths = collectTargets(targets.length > 0 ? targets : ["."]);
233
+ const config = readConfig();
231
234
  if (paths.length === 0) {
232
- console.error("Markdown が 1 つも見つかりませんでした。");
235
+ console.error(hostText(config).noMarkdownHere);
233
236
  return 1;
234
237
  }
235
- const config = readConfig();
236
238
  const results = await Promise.all(paths.map((path) => inspect(path, config, [...argv, "--show-baseline"])));
237
239
  const entries = results.flatMap((result) => result.all);
238
240
  const file = join(process.cwd(), BASELINE_FILE);
239
241
  writeBaseline(file, entries);
240
- console.log(
241
- [
242
- "",
243
- ` ${paths.length} ファイルを走査しました。`,
244
- "",
245
- ` ${entries.length} 件の指摘を ${BASELINE_FILE} に記録しました。`,
246
- " 以後、これらは報告されません。新しく増えたものだけが出ます。",
247
- "",
248
- ` ${BASELINE_FILE} を commit してください。`,
249
- "",
250
- ].join("\n"),
251
- );
242
+ console.log(hostText(config).baselineDone(paths.length, entries.length, BASELINE_FILE).join("\n"));
252
243
  return 0;
253
244
  };
254
245
 
@@ -256,55 +247,75 @@ const runSuppressions = async (targets: readonly string[], argv: readonly string
256
247
  const paths = collectTargets(targets.length > 0 ? targets : ["."]);
257
248
  const config = readConfig();
258
249
  const results = await Promise.all(paths.map((path) => inspect(path, config, [...argv, "--show-baseline"])));
259
- console.log(renderSuppressions(results.map((result) => result.perFile)));
250
+ console.log(
251
+ renderSuppressions(
252
+ results.map((result) => result.perFile),
253
+ hostLanguage(config.language, process.env),
254
+ ),
255
+ );
260
256
  return 0;
261
257
  };
262
258
 
263
259
  type Handler = (argv: readonly string[]) => number | Promise<number>;
264
260
 
265
261
  /** `--` で始まらない引数。対象のパス。 */
266
- const positional = (argv: readonly string[]): string[] => argv.slice(1).filter((arg) => !arg.startsWith("--"));
262
+ const positional = (argv: readonly string[]): string[] => targetsOf(argv.slice(1));
267
263
 
268
264
  const showRules = (): number => {
269
265
  const config = readConfig();
270
- const language = config.language ?? "ja";
266
+ const language = config.language ?? hostLanguage(undefined, process.env);
267
+ warnRuleProblems(config, language);
271
268
  console.log(rulesJson(loadRules(language), config, language, config.genre ?? "blog/tech"));
272
269
  return 0;
273
270
  };
274
271
 
275
272
  const showGenres = (): number => {
276
- console.log(["", " 使えるジャンル:", ...GENRES.map((genre) => ` ${genre}`), "", " chaff.yaml の genre に書くか、--genre で指定します。", ""].join("\n"));
273
+ console.log(hostText(readConfig()).genres(GENRES));
277
274
  return 0;
278
275
  };
279
276
 
277
+ const treeContext = (): TreeContext => {
278
+ const config = readConfig();
279
+ return { config, flag, ui: hostLanguage(config.language, process.env) };
280
+ };
281
+
280
282
  /** 分岐を数珠つなぎにせず表にする。足すときに main を太らせない。 */
281
283
  const HANDLERS: Readonly<Record<string, Handler>> = {
282
284
  init: (argv) => {
283
- runInit(process.cwd(), flag(argv, "--genre") ?? "blog/tech").forEach((line) => console.log(line));
285
+ runInit(process.cwd(), flag(argv, "--genre") ?? "blog/tech", hostLanguage(readConfig().language, process.env)).forEach((line) => console.log(line));
284
286
  return 0;
285
287
  },
286
288
  genres: showGenres,
287
289
  rules: showRules,
288
290
  explain: (argv) => explain(argv[1]),
289
- eval: (argv) => runEval(positional(argv), argv, { config: readConfig(), resolveGenre, flag }),
290
- tree: (argv) => runTree(treeTargets(argv), argv, { config: readConfig(), flag }),
291
- cite: (argv) => runCite(citeTargets(argv), argv, { config: readConfig(), flag }),
292
- test: (argv) => runTest(positional(argv), argv, { config: readConfig(), resolveGenre, inspect }),
291
+ eval: (argv) => runEval(positional(argv), argv, { config: readConfig(), resolveGenre: genreFrom(argv), flag }),
292
+ tree: (argv) => runTree(treeTargets(argv), argv, treeContext()),
293
+ cite: (argv) => runCite(citeTargets(argv), argv, treeContext()),
294
+ test: (argv) => runTest(positional(argv), argv, { config: readConfig(), resolveGenre: genreFrom(argv), inspect }),
293
295
  baseline: (argv) => runBaseline(positional(argv), argv),
294
296
  suppressions: (argv) => runSuppressions(positional(argv), argv),
295
297
  relax: (argv) => changeSetting("relaxed", argv[1], flag(argv, "--why")),
296
298
  strict: (argv) => changeSetting("strict", argv[1], flag(argv, "--why")),
297
299
  off: (argv) => changeSetting("off", argv[1], flag(argv, "--why")),
300
+ skill: (argv) => runSkill(argv, { cwd: process.cwd(), home: homedir(), ui: hostLanguage(readConfig().language, process.env) }),
298
301
  };
299
302
 
303
+ /** Every subcommand. Anything else on the command line is a file to check. */
304
+ export const COMMANDS: readonly string[] = Object.keys(HANDLERS);
305
+
300
306
  export const main = async (argv: readonly string[]): Promise<number> => {
301
307
  const first = argv[0];
302
308
  if (first === undefined || first === "--help" || first === "-h") {
303
- console.log(USAGE);
309
+ console.log(hostText(readConfig()).usage);
304
310
  return first === undefined ? 1 : 0;
305
311
  }
312
+ const cliGenre = flag(argv, "--genre");
313
+ if (cliGenre !== undefined && !GENRES.includes(cliGenre)) {
314
+ console.error(hostText(readConfig()).unknownGenre(cliGenre));
315
+ return 1;
316
+ }
306
317
  const handler = HANDLERS[first];
307
318
  if (handler !== undefined) return handler(argv);
308
- const targets = (first === "lint" ? argv.slice(1) : argv).filter((arg) => !arg.startsWith("--"));
319
+ const targets = targetsOf(first === "lint" ? argv.slice(1) : argv);
309
320
  return argv.includes("--watch") ? runWatch(targets, argv) : lint(targets, argv);
310
321
  };
@@ -1,7 +1,7 @@
1
1
  import { checkCitations, type Citation, type CitationResult } from "../structure/cite.ts";
2
2
  import { readSource, readTree, type TreeContext } from "./tree.ts";
3
+ import type { Texts, UiLanguage } from "../ui.ts";
3
4
 
4
- const USAGE = "使い方: chaff cite <原文> <引用.json> [--format text|json] [--language ja|en|…]";
5
5
  const FORMATS = ["text", "json"];
6
6
  const QUOTE_WIDTH = 40;
7
7
  const VALUED = ["--format", "--language"];
@@ -15,37 +15,70 @@ const isCitation = (value: unknown): value is Citation => isRecord(value) && typ
15
15
 
16
16
  export type ParsedCitations = { readonly citations: readonly Citation[] } | { readonly error: string };
17
17
 
18
- const parseJson = (text: string): { readonly value: unknown } | { readonly error: string } => {
18
+ const parseJson = (text: string, ui: UiLanguage): { readonly value: unknown } | { readonly error: string } => {
19
19
  try {
20
20
  return { value: JSON.parse(text) };
21
21
  } catch (err) {
22
- return { error: `JSON として読めません: ${err instanceof Error ? err.message : String(err)}` };
22
+ return { error: TEXT[ui].notJson(err instanceof Error ? err.message : String(err)) };
23
23
  }
24
24
  };
25
25
 
26
26
  /** 引用の一覧。[{ address, quote }] の配列だけを受け取る。形が違えば理由を返す。 */
27
- export const parseCitations = (text: string): ParsedCitations => {
28
- const parsed = parseJson(text);
27
+ export const parseCitations = (text: string, ui: UiLanguage = "ja"): ParsedCitations => {
28
+ const parsed = parseJson(text, ui);
29
29
  if ("error" in parsed) return parsed;
30
- if (!Array.isArray(parsed.value)) return { error: '引用は配列で渡してください: [{ "address": "3.2", "quote": "…" }]' };
30
+ if (!Array.isArray(parsed.value)) return { error: TEXT[ui].notArray };
31
31
  const entries: readonly unknown[] = parsed.value;
32
32
  const bad = entries.findIndex((entry) => !isCitation(entry));
33
- if (bad !== -1) return { error: `${String(bad + 1)} 件目に address(文字列)と quote(文字列)がありません` };
33
+ if (bad !== -1) return { error: TEXT[ui].badEntry(bad + 1) };
34
34
  return { citations: entries.filter(isCitation).map((entry) => ({ address: entry.address, quote: entry.quote })) };
35
35
  };
36
36
 
37
- const MESSAGES: Readonly<Record<CitationResult["status"], (result: CitationResult) => string>> = {
38
- ok: () => "一致",
39
- "missing-address": (result) => `番地 ${result.citation.address} は原文にありません`,
40
- "quote-elsewhere": (result) => `引用文は ${result.citation.address} ではなく ${result.foundAt ?? "番地の外"}(${String(result.line ?? "?")} 行目)にあります`,
41
- "quote-not-found": () => "引用文が原文のどこにもありません",
37
+ type CiteText = {
38
+ readonly usage: string;
39
+ readonly quoted: (text: string) => string;
40
+ readonly notJson: (why: string) => string;
41
+ readonly notArray: string;
42
+ readonly badEntry: (n: number) => string;
43
+ readonly results: Readonly<Record<CitationResult["status"], (result: CitationResult) => string>>;
42
44
  };
43
45
 
44
- const describe = (result: CitationResult): string => {
46
+ const TEXT: Texts<CiteText> = {
47
+ ja: {
48
+ quoted: (text) => `「${text}」`,
49
+ usage: "使い方: chaff cite <原文> <引用.json> [--format text|json] [--language ja|en|…]",
50
+ notJson: (why) => `JSON として読めません: ${why}`,
51
+ notArray: '引用は配列で渡してください: [{ "address": "3.2", "quote": "…" }]',
52
+ badEntry: (n) => `${String(n)} 件目に address(文字列)と quote(文字列)がありません`,
53
+ results: {
54
+ ok: () => "一致",
55
+ "missing-address": (result) => `番地 ${result.citation.address} は原文にありません`,
56
+ "quote-elsewhere": (result) =>
57
+ `引用文は ${result.citation.address} ではなく ${result.foundAt ?? "番地の外"}(${String(result.line ?? "?")} 行目)にあります`,
58
+ "quote-not-found": () => "引用文が原文のどこにもありません",
59
+ },
60
+ },
61
+ en: {
62
+ quoted: (text) => ` "${text}"`,
63
+ usage: "usage: chaff cite <source> <quotes.json> [--format text|json] [--language ja|en|…]",
64
+ notJson: (why) => `Not valid JSON: ${why}`,
65
+ notArray: 'Give the quotations as an array: [{ "address": "3.2", "quote": "…" }]',
66
+ badEntry: (n) => `Entry ${String(n)} needs address (a string) and quote (a string)`,
67
+ results: {
68
+ ok: () => "matches",
69
+ "missing-address": (result) => `address ${result.citation.address} is not in the source`,
70
+ "quote-elsewhere": (result) =>
71
+ `the quotation is not at ${result.citation.address} but at ${result.foundAt ?? "no address"} (line ${String(result.line ?? "?")})`,
72
+ "quote-not-found": () => "the quotation is nowhere in the source",
73
+ },
74
+ },
75
+ };
76
+
77
+ const describe = (result: CitationResult, text: CiteText): string => {
45
78
  const mark = result.status === "ok" ? "✓" : "✗";
46
79
  const oneLine = result.citation.quote.replace(/\s+/gu, " ").trim();
47
80
  const quote = oneLine.length > QUOTE_WIDTH ? `${oneLine.slice(0, QUOTE_WIDTH)}…` : oneLine;
48
- return `${mark} ${result.citation.address}「${quote}」: ${MESSAGES[result.status](result)}`;
81
+ return `${mark} ${result.citation.address}${text.quoted(quote)}: ${text.results[result.status](result)}`;
49
82
  };
50
83
 
51
84
  /**
@@ -56,12 +89,12 @@ export const runCite = async (targets: readonly string[], argv: readonly string[
56
89
  const [sourcePath, citationsPath] = targets;
57
90
  const format = context.flag(argv, "--format") ?? "text";
58
91
  if (sourcePath === undefined || citationsPath === undefined || targets.length !== 2 || !FORMATS.includes(format)) {
59
- console.error(USAGE);
92
+ console.error(TEXT[context.ui ?? "ja"].usage);
60
93
  return 1;
61
94
  }
62
- const citationsText = await readSource(citationsPath);
95
+ const citationsText = await readSource(citationsPath, context);
63
96
  if (citationsText === undefined) return 1;
64
- const parsed = parseCitations(citationsText);
97
+ const parsed = parseCitations(citationsText, context.ui ?? "ja");
65
98
  if ("error" in parsed) {
66
99
  console.error(`${citationsPath}: ${parsed.error}`);
67
100
  return 1;
@@ -69,6 +102,6 @@ export const runCite = async (targets: readonly string[], argv: readonly string[
69
102
  const read = await readTree(sourcePath, argv, context);
70
103
  if (read === undefined) return 1;
71
104
  const results = checkCitations(read.source, read.tree, parsed.citations);
72
- console.log(format === "json" ? JSON.stringify(results, null, 2) : results.map(describe).join("\n"));
105
+ console.log(format === "json" ? JSON.stringify(results, null, 2) : results.map((result) => describe(result, TEXT[context.ui ?? "ja"])).join("\n"));
73
106
  return results.every((result) => result.status === "ok") ? 0 : 1;
74
107
  };
@@ -58,6 +58,7 @@ export const runEval = async (targets: readonly string[], argv: readonly string[
58
58
  rules,
59
59
  genre,
60
60
  language,
61
+ config.limits,
61
62
  ),
62
63
  docs.length,
63
64
  paths.length,
@@ -0,0 +1,74 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import type { Texts, UiLanguage } from "../ui.ts";
5
+
6
+ /** The skill ships in the package, beside rules/. Two levels up from src/commands or dist/commands. */
7
+ export const SKILL_SOURCE = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "skills", "chaff", "SKILL.md");
8
+
9
+ /** Where Claude Code reads a skill: this folder's .claude/skills, or the user's. */
10
+ export const skillTarget = (root: string): string => join(root, ".claude", "skills", "chaff", "SKILL.md");
11
+
12
+ export type SkillOutcome = { readonly status: "written" | "updated" | "same" | "kept"; readonly path: string };
13
+
14
+ /**
15
+ * Writes the skill. A file that is already there and differs may have been edited by hand, so it is replaced
16
+ * only with `force`; the same file is left alone.
17
+ */
18
+ export const installSkill = (skill: string, target: string, force: boolean): SkillOutcome => {
19
+ if (existsSync(target)) {
20
+ const current = readFileSync(target, "utf8");
21
+ if (current === skill) return { status: "same", path: target };
22
+ if (!force) return { status: "kept", path: target };
23
+ writeFileSync(target, skill, "utf8");
24
+ return { status: "updated", path: target };
25
+ }
26
+ mkdirSync(dirname(target), { recursive: true });
27
+ writeFileSync(target, skill, "utf8");
28
+ return { status: "written", path: target };
29
+ };
30
+
31
+ const TEXT: Texts<Readonly<Record<SkillOutcome["status"], (path: string) => string>>> = {
32
+ ja: {
33
+ written: (path) => `Claude Code の skill を書きました: ${path}\nClaude Code を開き直すと /chaff で使えます。`,
34
+ updated: (path) => `skill を新しい版に置き換えました: ${path}`,
35
+ same: (path) => `skill は既に最新です: ${path}`,
36
+ kept: (path) => `${path} は手で直されているかもしれないので、置き換えていません。\n置き換えるときは --force を付けてください。`,
37
+ },
38
+ en: {
39
+ written: (path) => `Wrote the Claude Code skill: ${path}\nReopen Claude Code and it is available as /chaff.`,
40
+ updated: (path) => `Replaced the skill with the new version: ${path}`,
41
+ same: (path) => `The skill is up to date: ${path}`,
42
+ kept: (path) => `${path} may have been edited by hand, so it was not replaced.\nAdd --force to replace it.`,
43
+ },
44
+ };
45
+
46
+ const FAILED: Texts<(path: string, why: string) => string> = {
47
+ ja: (path, why) => `skill を ${path} に書けませんでした: ${why}`,
48
+ en: (path, why) => `Could not write the skill to ${path}: ${why}`,
49
+ };
50
+
51
+ /** The outcome, or why the file system refused. */
52
+ const attempt = (install: () => SkillOutcome): SkillOutcome | { readonly failure: string } => {
53
+ try {
54
+ return install();
55
+ } catch (err) {
56
+ return { failure: err instanceof Error ? err.message : String(err) };
57
+ }
58
+ };
59
+
60
+ export type SkillContext = { readonly cwd: string; readonly home: string; readonly ui: UiLanguage };
61
+
62
+ /** `chaff skill [--global] [--force]`: installs or updates the skill. Exit 1 when a differing file was kept. */
63
+ export const runSkill = (argv: readonly string[], context: SkillContext): number => {
64
+ const target = skillTarget(argv.includes("--global") ? context.home : context.cwd);
65
+ const outcome = attempt(() => installSkill(readFileSync(SKILL_SOURCE, "utf8"), target, argv.includes("--force")));
66
+ if ("failure" in outcome) {
67
+ console.error(FAILED[context.ui](target, outcome.failure));
68
+ return 1;
69
+ }
70
+ const say = TEXT[context.ui][outcome.status](outcome.path);
71
+ if (outcome.status === "kept") console.error(say);
72
+ else console.log(say);
73
+ return outcome.status === "kept" ? 1 : 0;
74
+ };
@@ -7,15 +7,35 @@ import { buildStructure } from "../structure/of.ts";
7
7
  import { toSexp } from "../structure/sexp.ts";
8
8
  import type { Config } from "../config/load.ts";
9
9
  import type { StructureNode } from "../plugin.ts";
10
+ import type { Texts, UiLanguage } from "../ui.ts";
10
11
 
11
12
  export type TreeContext = {
12
13
  readonly config: Config;
13
14
  readonly flag: (argv: readonly string[], name: string) => string | undefined;
15
+ /** The language chaff speaks in here. Japanese when left out. */
16
+ readonly ui?: UiLanguage;
14
17
  };
15
18
 
16
- const FORMATS = ["sexp", "json"];
19
+ const TEXT: Texts<{
20
+ readonly usage: string;
21
+ readonly unreadable: (path: string, why: string) => string;
22
+ readonly noStructure: (path: string, language: string) => string;
23
+ }> = {
24
+ ja: {
25
+ usage: "使い方: chaff tree <file>... [--format sexp|json] [--language ja|en|…]",
26
+ unreadable: (path, why) => `${path} を読めませんでした: ${why}`,
27
+ noStructure: (path, language) => `${path}: 言語 ${language} のパッケージは文書の構造を読めません(structure がありません)`,
28
+ },
29
+ en: {
30
+ usage: "usage: chaff tree <file>... [--format sexp|json] [--language ja|en|…]",
31
+ unreadable: (path, why) => `Could not read ${path}: ${why}`,
32
+ noStructure: (path, language) => `${path}: the ${language} package cannot read a document's structure (it has no structure)`,
33
+ },
34
+ };
17
35
 
18
- const USAGE = "使い方: chaff tree <file>... [--format sexp|json] [--language ja|en|…]";
36
+ export const treeText = (context: TreeContext): (typeof TEXT)["ja"] => TEXT[context.ui ?? "ja"];
37
+
38
+ const FORMATS = ["sexp", "json"];
19
39
 
20
40
  /** 値を取るフラグ。その次の引数は値で、対象のファイルではない。 */
21
41
  const VALUED = ["--format", "--language"];
@@ -23,11 +43,11 @@ const VALUED = ["--format", "--language"];
23
43
  export const treeTargets = (argv: readonly string[]): string[] =>
24
44
  argv.slice(1).filter((arg, index, all) => !arg.startsWith("--") && !VALUED.includes(all[index - 1] ?? ""));
25
45
 
26
- export const readSource = async (path: string): Promise<string | undefined> => {
46
+ export const readSource = async (path: string, context: TreeContext): Promise<string | undefined> => {
27
47
  try {
28
48
  return await readFile(path, "utf8");
29
49
  } catch (err) {
30
- console.error(`${path} を読めませんでした: ${err instanceof Error ? err.message : String(err)}`);
50
+ console.error(treeText(context).unreadable(path, err instanceof Error ? err.message : String(err)));
31
51
  return undefined;
32
52
  }
33
53
  };
@@ -49,12 +69,12 @@ export type SourceTree = { readonly source: string; readonly tree: StructureNode
49
69
  * chaff tree と chaff cite が同じ木を見るように、ここを一つにしておく。
50
70
  */
51
71
  export const readTree = async (path: string, argv: readonly string[], context: TreeContext): Promise<SourceTree | undefined> => {
52
- const source = await readSource(path);
72
+ const source = await readSource(path, context);
53
73
  if (source === undefined) return undefined;
54
74
  const language = treeLanguage(path, source, argv, context);
55
75
  const adapter = await loadAdapter(language);
56
76
  if (adapter.structure === undefined) {
57
- console.error(`${path}: 言語 ${language} のパッケージは文書の構造を読めません(structure がありません)`);
77
+ console.error(treeText(context).noStructure(path, language));
58
78
  return undefined;
59
79
  }
60
80
  // 日本語は形態素で数量と日付を読む。解析器が無ければ単位の表で読むので、木は作れる。
@@ -86,7 +106,7 @@ export const inOrder = async (paths: readonly string[], each: (path: string) =>
86
106
  export const runTree = async (paths: readonly string[], argv: readonly string[], context: TreeContext): Promise<number> => {
87
107
  const format = context.flag(argv, "--format") ?? "sexp";
88
108
  if (paths.length === 0 || !FORMATS.includes(format)) {
89
- console.error(USAGE);
109
+ console.error(treeText(context).usage);
90
110
  return 1;
91
111
  }
92
112
  const printed = await inOrder(paths, (path) => printTree(path, argv, context));