chaffjs 0.7.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 (118) 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 +75 -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/skill.d.ts +22 -0
  23. package/dist/commands/skill.d.ts.map +1 -0
  24. package/dist/commands/skill.js +68 -0
  25. package/dist/commands/skill.js.map +1 -0
  26. package/dist/commands/tree.d.ts +11 -1
  27. package/dist/commands/tree.d.ts.map +1 -1
  28. package/dist/commands/tree.js +18 -6
  29. package/dist/commands/tree.js.map +1 -1
  30. package/dist/config/rule-problems.d.ts +2 -1
  31. package/dist/config/rule-problems.d.ts.map +1 -1
  32. package/dist/config/rule-problems.js +17 -6
  33. package/dist/config/rule-problems.js.map +1 -1
  34. package/dist/config/write.d.ts.map +1 -1
  35. package/dist/config/write.js +29 -9
  36. package/dist/config/write.js.map +1 -1
  37. package/dist/genre.d.ts +1 -1
  38. package/dist/genre.d.ts.map +1 -1
  39. package/dist/genre.js +3 -3
  40. package/dist/genre.js.map +1 -1
  41. package/dist/init.d.ts +2 -1
  42. package/dist/init.d.ts.map +1 -1
  43. package/dist/init.js +62 -13
  44. package/dist/init.js.map +1 -1
  45. package/dist/plugin.d.ts +6 -0
  46. package/dist/plugin.d.ts.map +1 -1
  47. package/dist/reasons.d.ts +15 -0
  48. package/dist/reasons.d.ts.map +1 -0
  49. package/dist/reasons.js +29 -0
  50. package/dist/reasons.js.map +1 -0
  51. package/dist/render/explain.d.ts.map +1 -1
  52. package/dist/render/explain.js +36 -12
  53. package/dist/render/explain.js.map +1 -1
  54. package/dist/render/friendly.d.ts.map +1 -1
  55. package/dist/render/friendly.js +32 -9
  56. package/dist/render/friendly.js.map +1 -1
  57. package/dist/render/rules-json.d.ts.map +1 -1
  58. package/dist/render/rules-json.js +47 -27
  59. package/dist/render/rules-json.js.map +1 -1
  60. package/dist/render/summary.d.ts +3 -2
  61. package/dist/render/summary.d.ts.map +1 -1
  62. package/dist/render/summary.js +24 -9
  63. package/dist/render/summary.js.map +1 -1
  64. package/dist/render/suppressions.d.ts +2 -1
  65. package/dist/render/suppressions.d.ts.map +1 -1
  66. package/dist/render/suppressions.js +34 -10
  67. package/dist/render/suppressions.js.map +1 -1
  68. package/dist/run.d.ts.map +1 -1
  69. package/dist/run.js +18 -10
  70. package/dist/run.js.map +1 -1
  71. package/dist/structure/build.d.ts.map +1 -1
  72. package/dist/structure/build.js +8 -2
  73. package/dist/structure/build.js.map +1 -1
  74. package/dist/structure/cite.d.ts.map +1 -1
  75. package/dist/structure/cite.js +3 -1
  76. package/dist/structure/cite.js.map +1 -1
  77. package/dist/structure/issues.d.ts +5 -2
  78. package/dist/structure/issues.d.ts.map +1 -1
  79. package/dist/structure/issues.js +27 -9
  80. package/dist/structure/issues.js.map +1 -1
  81. package/dist/structure/numbering.d.ts.map +1 -1
  82. package/dist/structure/numbering.js +11 -6
  83. package/dist/structure/numbering.js.map +1 -1
  84. package/dist/ui.d.ts +14 -0
  85. package/dist/ui.d.ts.map +1 -0
  86. package/dist/ui.js +14 -0
  87. package/dist/ui.js.map +1 -0
  88. package/dist/watch.d.ts +1 -1
  89. package/dist/watch.d.ts.map +1 -1
  90. package/dist/watch.js +2 -2
  91. package/dist/watch.js.map +1 -1
  92. package/package.json +3 -2
  93. package/skills/chaff/SKILL.md +61 -0
  94. package/src/adapter-load.ts +41 -10
  95. package/src/cli-args.ts +9 -0
  96. package/src/cli-text.ts +182 -0
  97. package/src/cli.ts +91 -89
  98. package/src/commands/cite.ts +51 -18
  99. package/src/commands/skill.ts +74 -0
  100. package/src/commands/tree.ts +27 -7
  101. package/src/config/rule-problems.ts +24 -5
  102. package/src/config/write.ts +33 -9
  103. package/src/genre.ts +4 -4
  104. package/src/init.ts +74 -15
  105. package/src/plugin.ts +6 -0
  106. package/src/reasons.ts +44 -0
  107. package/src/render/explain.ts +46 -12
  108. package/src/render/friendly.ts +42 -10
  109. package/src/render/rules-json.ts +35 -9
  110. package/src/render/summary.ts +34 -9
  111. package/src/render/suppressions.ts +45 -10
  112. package/src/run.ts +20 -9
  113. package/src/structure/build.ts +10 -2
  114. package/src/structure/cite.ts +3 -1
  115. package/src/structure/issues.ts +33 -14
  116. package/src/structure/numbering.ts +12 -7
  117. package/src/ui.ts +23 -0
  118. package/src/watch.ts +2 -2
@@ -59,13 +59,41 @@ const LANGUAGE_ID = /^[a-z]{2,3}$/u;
59
59
  export const packageFor = (language: string): string | undefined =>
60
60
  ADAPTER_PACKAGE[language] ?? (LANGUAGE_ID.test(language) ? `@chaffjs/lang-${language}` : undefined);
61
61
 
62
- const importAdapter = async (specifier: string, language: string): Promise<unknown> => {
62
+ /**
63
+ * 探す順。公式(`@chaffjs/lang-<言語>`)が先で、無ければ第三者の `chaff-lang-<言語>`。
64
+ * `@typescript-eslint/*` と `eslint-plugin-*` の分け方と同じで、README にもそう書いてある。同梱の言語は表のものだけ。
65
+ */
66
+ export const packagesFor = (language: string): readonly string[] => {
67
+ if (ADAPTER_PACKAGE[language] !== undefined) return [ADAPTER_PACKAGE[language]];
68
+ return LANGUAGE_ID.test(language) ? [`@chaffjs/lang-${language}`, `chaff-lang-${language}`] : [];
69
+ };
70
+
71
+ export type Importer = (specifier: string) => Promise<unknown>;
72
+
73
+ /**
74
+ * そのパッケージ自体が入っていないときだけ真。入っているパッケージが自分の依存を見つけられないときも同じ
75
+ * ERR_MODULE_NOT_FOUND になるので、Node の文言が探した名前を挙げているかで分ける。
76
+ */
77
+ export const isAbsent = (err: unknown, specifier: string): boolean =>
78
+ err instanceof Error && "code" in err && err.code === "ERR_MODULE_NOT_FOUND" && err.message.includes(`'${specifier}'`);
79
+
80
+ /** 入っている最初のものを読む。入っていないのは次へ。入っていて壊れているものは、次へ行かずにそのまま投げる。 */
81
+ const importFirst = async (specifiers: readonly string[], language: string, importer: Importer): Promise<{ specifier: string; module: unknown }> => {
82
+ const [first, ...rest] = specifiers;
83
+ if (first === undefined) throw new Error(`no adapter for language "${language}"`);
63
84
  try {
64
- return await import(specifier);
85
+ return { specifier: first, module: await importer(first) };
65
86
  } catch (err) {
66
- const missing = err instanceof Error && "code" in err && err.code === "ERR_MODULE_NOT_FOUND";
67
- if (missing) throw new Error(`言語 ${language} のパッケージ ${specifier} が入っていません(npm i -D ${specifier})`, { cause: err });
68
- throw err;
87
+ if (!isAbsent(err, first)) throw err;
88
+ if (rest.length > 0) return importFirst(rest, language, importer);
89
+ const all = packagesFor(language);
90
+ const install = `npm i -D ${all[0] ?? first}`;
91
+ // Only the requested language is known here; a Japanese message for Japanese, English for any other.
92
+ const message =
93
+ language === "ja"
94
+ ? `言語 ${language} のパッケージが入っていません(${all.join(" か ")}。${install})`
95
+ : `No package for language ${language} is installed (${all.join(" or ")}; ${install})`;
96
+ throw new Error(message, { cause: err });
69
97
  }
70
98
  };
71
99
 
@@ -74,13 +102,16 @@ const importAdapter = async (specifier: string, language: string): Promise<unkno
74
102
  * core だけで起動し、文字種で言語を当ててから、その言語のアダプタを読む。
75
103
  * 全言語を静的に import すると、使わない言語のぶんまで npx の初回取得が膨らむ。
76
104
  */
77
- export const loadAdapter = async (language: string): Promise<LanguageAdapter> => {
78
- const specifier = packageFor(language);
79
- if (specifier === undefined) throw new Error(`no adapter for language "${language}"`);
80
- const module = await importAdapter(specifier, language);
105
+ export const loadAdapter = async (language: string, importer: Importer = (specifier) => import(specifier)): Promise<LanguageAdapter> => {
106
+ const { specifier, module } = await importFirst(packagesFor(language), language, importer);
81
107
  if (typeof module !== "object" || module === null) throw new Error(`${specifier} did not export a module`);
82
108
  const candidate = pickExport(module);
83
109
  const broken = checkAdapter(candidate);
84
- if (broken !== undefined || !isAdapter(candidate)) throw new Error(`${specifier} が LanguageAdapter を export していません: ${broken ?? ""}`);
110
+ if (broken !== undefined || !isAdapter(candidate)) {
111
+ const why = broken ?? "";
112
+ throw new Error(
113
+ language === "ja" ? `${specifier} が LanguageAdapter を export していません: ${why}` : `${specifier} does not export a LanguageAdapter: ${why}`,
114
+ );
115
+ }
85
116
  return candidate;
86
117
  };
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Options of the commands that take files to check, whose value is the next argument. That value is not a file.
3
+ * `--why` (relax), `--format` and `--language` (tree) belong to commands that read their own arguments.
4
+ */
5
+ export const VALUE_FLAGS: readonly string[] = ["--sarif", "--genre", "--rule"];
6
+
7
+ /** The arguments that name what to check: everything that is neither an option nor an option's value. */
8
+ export const targetsOf = (args: readonly string[]): string[] =>
9
+ args.filter((arg, index) => !arg.startsWith("--") && !(index > 0 && VALUE_FLAGS.includes(args[index - 1] ?? "")));
@@ -0,0 +1,182 @@
1
+ // What the command line says, in Japanese and English. Per-file text follows the file's language; the rest
2
+ // follows hostLanguage (chaff.yaml's language, else the terminal's locale).
3
+ import type { Texts } from "./ui.ts";
4
+
5
+ /** Where a file's genre came from, as the header names it. */
6
+ export type GenreSource = "--genre" | "by_path" | "config" | "default" | "front-matter" | "path" | "content";
7
+
8
+ const USAGE_JA = `chaff — 文章の読みにくいところを見つけます。文章は書き換えません。
9
+
10
+ chaff <file|dir|glob>... 検査する(設定も API key も要りません)
11
+ chaff . この場所の Markdown を全部
12
+ chaff test <file|dir>... 意味を読む検査も動かす(API key が要ります)
13
+ 判定役は chaff.yaml の ai_backend で選びます
14
+ chaff init chaff.yaml を作る
15
+ chaff eval <dir> 手元の文書で閾値を測り直す
16
+ chaff explain <rule> そのルールの意図と根拠を読む
17
+ chaff genres ジャンルの一覧
18
+ chaff tree <file> [--format sexp|json] 文書を番地の付いた木にする(条・項・定義・参照)
19
+ chaff cite <原文> <引用.json> 回答の引用(番地と引用文)が原文にあるかを確かめる
20
+ chaff rules --json いまの設定を JSON で出す(AI に渡す用)
21
+ chaff baseline <dir> いまある指摘を棚上げする(既存の repo に入れるとき)
22
+ chaff suppressions <dir> stet で黙らせている指摘を数える
23
+ chaff relax|strict|off <rule> [--why "理由"]
24
+ chaff skill [--global] Claude Code の skill を入れる(.claude/skills/chaff/、--global で ~/.claude/)
25
+
26
+ --compact エンジニア向けの 1 行形式
27
+ --experimental 試験中の rule も動かす
28
+ --genre <ジャンル> この実行だけジャンルを決める(chaff.yaml より優先)
29
+ --show-baseline 棚上げした分も含めて全部見る
30
+ --watch 保存のたびに見直し、変わったところだけ出す
31
+ --dry-run test で、何を AI に送るかだけを見る(API を呼びません)
32
+ --sarif <path> 指摘を SARIF で書き出す(GitHub の PR の行に出すため)
33
+
34
+ この箇所だけ黙らせる: <!-- stet: rule-id — 理由 -->
35
+
36
+ 値は strict / normal / relaxed / off の 4 つから選びます。
37
+ `;
38
+
39
+ const USAGE_EN = `chaff — finds what makes writing hard to read. It never rewrites the text.
40
+
41
+ chaff <file|dir|glob>... check (no config or API key needed)
42
+ chaff . every Markdown file here
43
+ chaff test <file|dir>... also run the checks that read meaning (needs an API key)
44
+ the judge is chosen by ai_backend in chaff.yaml
45
+ chaff init create chaff.yaml
46
+ chaff eval <dir> re-measure the limits on your own documents
47
+ chaff explain <rule> read what a rule is for and why
48
+ chaff genres list the genres
49
+ chaff tree <file> [--format sexp|json] the document as a tree of addresses (sections, clauses, definitions, references)
50
+ chaff cite <source> <quotes.json> check that quoted passages (address and text) are in the source
51
+ chaff rules --json the current settings as JSON (to give to an AI)
52
+ chaff baseline <dir> shelve today's findings (when adding chaff to an existing repository)
53
+ chaff suppressions <dir> count the findings silenced with stet
54
+ chaff relax|strict|off <rule> [--why "reason"]
55
+ chaff skill [--global] install the Claude Code skill (.claude/skills/chaff/; --global for ~/.claude/)
56
+
57
+ --compact one line per finding, for engineers
58
+ --experimental run the experimental rules too
59
+ --genre <genre> the genre for this run only (wins over chaff.yaml)
60
+ --show-baseline show the shelved findings too
61
+ --watch re-check on every save and print only what changed
62
+ --dry-run with test, show what would be sent to the AI (no API call)
63
+ --sarif <path> write the findings as SARIF (for comments on a GitHub PR)
64
+
65
+ Silence one spot: <!-- stet: rule-id — reason -->
66
+
67
+ A rule's level is one of strict / normal / relaxed / off.
68
+ `;
69
+
70
+ export type CliText = {
71
+ readonly usage: string;
72
+ readonly languageName: (language: string) => string;
73
+ readonly genreSource: Readonly<Record<GenreSource, string>>;
74
+ readonly header: (path: string, genre: string, language: string, from: string, shelved: number, hushed: number) => string;
75
+ readonly noMarkdown: (targets: string) => string;
76
+ readonly noMarkdownHere: string;
77
+ readonly noAdapter: (language: string) => string;
78
+ readonly unknownGenre: (genre: string) => string;
79
+ readonly unknownRule: (id: string) => string;
80
+ readonly unknownRuleWithList: (id: string, list: string) => string;
81
+ readonly unnamed: string;
82
+ readonly sarifWritten: (path: string, count: number) => string;
83
+ readonly unchanged: string;
84
+ readonly findingsUnit: string;
85
+ readonly watching: (files: number, findings: number) => string;
86
+ readonly watchHint: string;
87
+ readonly baselineDone: (files: number, entries: number, file: string) => readonly string[];
88
+ readonly genres: (list: readonly string[]) => string;
89
+ readonly unit: (ruleId: string, language: string) => string;
90
+ };
91
+
92
+ export const CLI_TEXT: Texts<CliText> = {
93
+ ja: {
94
+ usage: USAGE_JA,
95
+ languageName: (language) => (language === "ja" ? "日本語" : "英語"),
96
+ genreSource: {
97
+ "--genre": "--genre",
98
+ by_path: "chaff.yaml の by_path",
99
+ config: "chaff.yaml",
100
+ default: "既定",
101
+ "front-matter": "front matter",
102
+ path: "パス",
103
+ content: "内容",
104
+ },
105
+ header: (path, genre, language, from, shelved, hushed) =>
106
+ [`${path} ${genre} · ${language} ジャンルは${from}から`, shelved > 0 ? ` 棚上げ ${shelved} 件` : "", hushed > 0 ? ` stet ${hushed} 件` : ""].join(
107
+ "",
108
+ ),
109
+ noMarkdown: (targets) => `Markdown が 1 つも見つかりませんでした: ${targets}`,
110
+ noMarkdownHere: "Markdown が 1 つも見つかりませんでした。",
111
+ noAdapter: (language) => `言語 "${language}" のアダプタがありません。`,
112
+ unknownGenre: (genre) => `ジャンル "${genre}" はありません。npx chaff genres で一覧が出ます。`,
113
+ unknownRule: (id) => `${id} というルールはありません。npx chaff rules --json で一覧が出ます。`,
114
+ unknownRuleWithList: (id, list) => `${id} というルールはありません。\n一覧:\n${list}`,
115
+ unnamed: "(名前なし)",
116
+ sarifWritten: (path, count) => `\n SARIF を書きました: ${path}(${count} 件)`,
117
+ unchanged: "変わりませんでした",
118
+ findingsUnit: " 件",
119
+ watching: (files, findings) => `\n ${files} ファイルを見ています。いまの指摘は ${findings} 件です。`,
120
+ watchHint: " 保存するたびに、変わったところだけ出します。止めるには Ctrl-C。\n",
121
+ baselineDone: (files, entries, file) => [
122
+ "",
123
+ ` ${files} ファイルを走査しました。`,
124
+ "",
125
+ ` ${entries} 件の指摘を ${file} に記録しました。`,
126
+ " 以後、これらは報告されません。新しく増えたものだけが出ます。",
127
+ "",
128
+ ` ${file} を commit してください。`,
129
+ "",
130
+ ],
131
+ genres: (list) =>
132
+ ["", " 使えるジャンル:", ...list.map((genre) => ` ${genre}`), "", " chaff.yaml の genre に書くか、--genre で指定します。", ""].join("\n"),
133
+ unit: (ruleId, language) => {
134
+ if (ruleId === "bold-density") return "1000 字あたりの箇所数";
135
+ if (ruleId !== "max-sentence-length") return "回";
136
+ return language === "ja" ? "文字" : "語";
137
+ },
138
+ },
139
+ en: {
140
+ usage: USAGE_EN,
141
+ languageName: (language) => (language === "ja" ? "Japanese" : "English"),
142
+ genreSource: {
143
+ "--genre": "--genre",
144
+ by_path: "by_path in chaff.yaml",
145
+ config: "chaff.yaml",
146
+ default: "the default",
147
+ "front-matter": "front matter",
148
+ path: "the path",
149
+ content: "the content",
150
+ },
151
+ header: (path, genre, language, from, shelved, hushed) =>
152
+ [`${path} ${genre} · ${language} genre from ${from}`, shelved > 0 ? ` ${shelved} shelved` : "", hushed > 0 ? ` ${hushed} stet` : ""].join(""),
153
+ noMarkdown: (targets) => `No Markdown files found: ${targets}`,
154
+ noMarkdownHere: "No Markdown files found.",
155
+ noAdapter: (language) => `No language package for "${language}".`,
156
+ unknownGenre: (genre) => `There is no genre "${genre}". npx chaff genres lists them.`,
157
+ unknownRule: (id) => `There is no rule named ${id}. npx chaff rules --json lists them.`,
158
+ unknownRuleWithList: (id, list) => `There is no rule named ${id}.\nRules:\n${list}`,
159
+ unnamed: "(no name)",
160
+ sarifWritten: (path, count) => `\n Wrote SARIF: ${path} (${count} finding${count === 1 ? "" : "s"})`,
161
+ unchanged: "no change",
162
+ findingsUnit: " findings",
163
+ watching: (files, findings) => `\n Watching ${files} file${files === 1 ? "" : "s"}. ${findings} finding${findings === 1 ? "" : "s"} now.`,
164
+ watchHint: " Each save prints only what changed. Ctrl-C to stop.\n",
165
+ baselineDone: (files, entries, file) => [
166
+ "",
167
+ ` Checked ${files} file${files === 1 ? "" : "s"}.`,
168
+ "",
169
+ ` Recorded ${entries} finding${entries === 1 ? "" : "s"} in ${file}.`,
170
+ " They will not be reported again; only new ones will.",
171
+ "",
172
+ ` Commit ${file}.`,
173
+ "",
174
+ ],
175
+ genres: (list) => ["", " Genres:", ...list.map((genre) => ` ${genre}`), "", " Set one with genre in chaff.yaml, or with --genre.", ""].join("\n"),
176
+ unit: (ruleId, language) => {
177
+ if (ruleId === "bold-density") return "places per 1000 characters";
178
+ if (ruleId !== "max-sentence-length") return "times";
179
+ return language === "ja" ? "characters" : "words";
180
+ },
181
+ },
182
+ };
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,63 +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";
28
31
  import { ruleProblems } from "./config/rule-problems.ts";
29
32
  import { renderSummary, type FileOutcome } from "./render/summary.ts";
30
33
  import { neededBy, runRules } from "./run.ts";
31
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";
32
37
 
33
- const USAGE = `chaff — 文章の読みにくいところを見つけます。文章は書き換えません。
34
-
35
- chaff <file|dir|glob>... 検査する(設定も API key も要りません)
36
- chaff . この場所の Markdown を全部
37
- chaff test <file|dir>... 意味を読む検査も動かす(API key が要ります)
38
- 判定役は chaff.yaml の ai_backend で選びます
39
- chaff init chaff.yaml を作る
40
- chaff eval <dir> 手元の文書で閾値を測り直す
41
- chaff explain <rule> そのルールの意図と根拠を読む
42
- chaff genres ジャンルの一覧
43
- chaff tree <file> [--format sexp|json] 文書を番地の付いた木にする(条・項・定義・参照)
44
- chaff cite <原文> <引用.json> 回答の引用(番地と引用文)が原文にあるかを確かめる
45
- chaff rules --json いまの設定を JSON で出す(AI に渡す用)
46
- chaff baseline <dir> いまある指摘を棚上げする(既存の repo に入れるとき)
47
- chaff suppressions <dir> stet で黙らせている指摘を数える
48
- chaff relax|strict|off <rule> [--why "理由"]
49
-
50
- --compact エンジニア向けの 1 行形式
51
- --experimental 試験中の rule も動かす
52
- --show-baseline 棚上げした分も含めて全部見る
53
- --watch 保存のたびに見直し、変わったところだけ出す
54
- --dry-run test で、何を AI に送るかだけを見る(API を呼びません)
55
- --sarif <path> 指摘を SARIF で書き出す(GitHub の PR の行に出すため)
56
-
57
- この箇所だけ黙らせる: <!-- stet: rule-id — 理由 -->
58
-
59
- 値は strict / normal / relaxed / off の 4 つから選びます。
60
- `;
38
+ /** Text for output that is not about one document. */
39
+ const hostText = (config: Config): CliText => CLI_TEXT[hostLanguage(config.language, process.env)];
61
40
 
62
41
  const readConfig = (): Config => (existsSync(join(process.cwd(), CONFIG_FILE)) ? loadConfig(join(process.cwd(), CONFIG_FILE)) : EMPTY);
63
42
 
64
- const resolveGenre = (path: string, source: string, config: Config): { genre: string; from: string } => {
65
- // パスごとの上書きが最優先。「全体はこう、ここだけは違う」を書けるようにする。
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
+ // パスごとの上書きが次。「全体はこう、ここだけは違う」を書けるようにする。
66
47
  const override = applyByPath(config.byPath, config.baseDir, path);
67
- if (override.genre !== undefined) return { genre: override.genre, from: `${CONFIG_FILE} の by_path` };
68
- 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" };
69
50
  const guess = guessGenre(path, source, frontMatterGenre(source));
70
- 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 };
71
52
  };
72
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
+
73
60
  const findRule = (rules: readonly RuleDefinition[], id: string | undefined): RuleDefinition | undefined => rules.find((rule) => rule.id === id);
74
61
 
75
62
  const changeSetting = (command: Level, ruleId: string | undefined, why: string | undefined): number => {
76
63
  const config = readConfig();
77
- const rule = findRule(loadRules(config.language ?? "ja"), ruleId);
64
+ const language = config.language ?? hostLanguage(undefined, process.env);
65
+ const rule = findRule(loadRules(language), ruleId);
78
66
  if (rule === undefined) {
79
- console.error(`${ruleId ?? "(名前なし)"} というルールはありません。npx chaff rules --json で一覧が出ます。`);
67
+ console.error(hostText(config).unknownRule(ruleId ?? hostText(config).unnamed));
80
68
  return 1;
81
69
  }
82
- 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");
83
71
  console.log(outcome.message);
84
72
  return outcome.ok ? 0 : 1;
85
73
  };
@@ -99,17 +87,17 @@ type Inspected = {
99
87
  readonly all: readonly string[];
100
88
  };
101
89
 
102
- const headerFor = (path: string, genre: string, from: string, language: string, shelved: number, hushed: number): string => {
103
- const shelf = shelved > 0 ? ` 棚上げ ${shelved} 件` : "";
104
- const stet = hushed > 0 ? ` stet ${hushed} 件` : "";
105
- 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);
106
94
  };
107
95
 
108
96
  const inspect = async (path: string, config: Config, argv: readonly string[]): Promise<Inspected> => {
109
97
  const source = await readFile(path, "utf8");
110
98
  const language = applyByPath(config.byPath, config.baseDir, path).language ?? config.language ?? guessLanguage(source).language;
111
99
  const adapter = await loadAdapter(language);
112
- const { genre, from } = resolveGenre(path, source, config);
100
+ const { genre, from } = resolveGenre(path, source, config, flag(argv, "--genre"));
113
101
  const rules = loadRules(language);
114
102
  const experimental = config.experimental || argv.includes("--experimental");
115
103
  await adapter.prepare?.(neededBy(rules, config.rules, experimental, genre, language));
@@ -149,32 +137,42 @@ const writeSarif = (results: readonly Inspected[], argv: readonly string[]): voi
149
137
  );
150
138
  mkdirSync(dirname(path), { recursive: true });
151
139
  writeFileSync(path, renderSarif(located, VERSION), "utf8");
152
- console.log(`\n SARIF を書きました: ${path}(${located.length} 件)`);
140
+ console.log(hostText(readConfig()).sarifWritten(path, located.length));
153
141
  };
154
142
 
155
143
  /** 効いていない設定は、結果の前に一度だけ言う。標準エラーに出すので、JSON や SARIF の出力は汚さない。 */
156
144
  const warnRuleProblems = (config: Config, language: string): void => {
157
- ruleProblems(config, loadRules(language)).forEach((problem) => console.error(`chaff: ${problem}`));
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);
158
153
  };
159
154
 
160
155
  const lint = async (targets: readonly string[], argv: readonly string[]): Promise<number> => {
161
156
  const paths = collectTargets(targets);
157
+ const config = readConfig();
162
158
  if (paths.length === 0) {
163
159
  // 0 件を成功にすると「CI は通っているが何も検証していない」状態が続く。§14。
164
- console.error(`Markdown が 1 つも見つかりませんでした: ${targets.join(", ")}`);
160
+ console.error(hostText(config).noMarkdown(targets.join(", ")));
165
161
  return 1;
166
162
  }
167
- const config = readConfig();
168
163
  const language = config.language ?? "ja";
169
164
  if (packageFor(language) === undefined) {
170
- console.error(`言語 "${language}" のアダプタがありません。`);
165
+ console.error(hostText(config).noAdapter(language));
171
166
  return 1;
172
167
  }
173
168
  warnRuleProblems(config, language);
174
169
  const results = await Promise.all(paths.map((path) => inspect(path, config, argv)));
175
170
  writeSarif(results, argv);
176
171
  results.filter((result) => result.outcome.findings.length > 0 || paths.length === 1).forEach((result) => console.log(result.text));
177
- 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));
178
176
  return results.some((result) => result.outcome.findings.some((finding) => finding.severity === "error")) ? 1 : 0;
179
177
  };
180
178
 
@@ -184,11 +182,12 @@ const lint = async (targets: readonly string[], argv: readonly string[]): Promis
184
182
  */
185
183
  const runWatch = async (targets: readonly string[], argv: readonly string[]): Promise<number> => {
186
184
  const paths = collectTargets(targets);
185
+ const config = readConfig();
186
+ const text = hostText(config);
187
187
  if (paths.length === 0) {
188
- console.error(`Markdown が 1 つも見つかりませんでした: ${targets.join(", ")}`);
188
+ console.error(text.noMarkdown(targets.join(", ")));
189
189
  return 1;
190
190
  }
191
- const config = readConfig();
192
191
  warnRuleProblems(config, config.language ?? "ja");
193
192
  const seen = new Map<string, Snapshot>();
194
193
  const once = async (path: string, first: boolean): Promise<void> => {
@@ -197,13 +196,13 @@ const runWatch = async (targets: readonly string[], argv: readonly string[]): Pr
197
196
  const before = seen.get(path) ?? {};
198
197
  seen.set(path, after);
199
198
  if (first) return;
200
- const change = describeChange(before, after);
201
- console.log(`${clock()} ${path} ${change ?? "変わりませんでした"}`);
199
+ const change = describeChange(before, after, text.findingsUnit);
200
+ console.log(`${clock()} ${path} ${change ?? text.unchanged}`);
202
201
  };
203
202
  await Promise.all(paths.map((path) => once(path, true)));
204
203
  const totals = [...seen.values()].reduce((sum, snapshot) => sum + Object.values(snapshot).reduce((inner, count) => inner + count, 0), 0);
205
- console.log(`\n ${paths.length} ファイルを見ています。いまの指摘は ${totals} 件です。`);
206
- console.log(" 保存するたびに、変わったところだけ出します。止めるには Ctrl-C。\n");
204
+ console.log(text.watching(paths.length, totals));
205
+ console.log(text.watchHint);
207
206
  const stop = watchPaths(paths, (path) => void once(path, false));
208
207
  process.on("SIGINT", () => {
209
208
  stop();
@@ -214,49 +213,33 @@ const runWatch = async (targets: readonly string[], argv: readonly string[]): Pr
214
213
 
215
214
  const explain = (ruleId: string | undefined): number => {
216
215
  const config = readConfig();
217
- 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)];
218
219
  const rules = loadRules(language);
219
220
  const rule = findRule(rules, ruleId);
220
221
  if (rule === undefined) {
221
222
  const list = rules.map((entry) => ` ${entry.id}`).join("\n");
222
- console.error(`${ruleId ?? "(名前なし)"} というルールはありません。\n一覧:\n${list}`);
223
+ console.error(text.unknownRuleWithList(ruleId ?? text.unnamed, list));
223
224
  return 1;
224
225
  }
225
226
  const current = config.rules[rule.id] ?? (rule.status === "experimental" && !config.experimental ? "off" : "normal");
226
- 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));
227
228
  return 0;
228
229
  };
229
230
 
230
- /** 閾値の単位。explain で「4 とは何の 4 か」を示すため。 */
231
- const unitOf = (ruleId: string, language: string): string => {
232
- if (ruleId === "bold-density") return "1000 字あたりの箇所数";
233
- if (ruleId !== "max-sentence-length") return "回";
234
- return language === "ja" ? "文字" : "語";
235
- };
236
-
237
231
  const runBaseline = async (targets: readonly string[], argv: readonly string[]): Promise<number> => {
238
232
  const paths = collectTargets(targets.length > 0 ? targets : ["."]);
233
+ const config = readConfig();
239
234
  if (paths.length === 0) {
240
- console.error("Markdown が 1 つも見つかりませんでした。");
235
+ console.error(hostText(config).noMarkdownHere);
241
236
  return 1;
242
237
  }
243
- const config = readConfig();
244
238
  const results = await Promise.all(paths.map((path) => inspect(path, config, [...argv, "--show-baseline"])));
245
239
  const entries = results.flatMap((result) => result.all);
246
240
  const file = join(process.cwd(), BASELINE_FILE);
247
241
  writeBaseline(file, entries);
248
- console.log(
249
- [
250
- "",
251
- ` ${paths.length} ファイルを走査しました。`,
252
- "",
253
- ` ${entries.length} 件の指摘を ${BASELINE_FILE} に記録しました。`,
254
- " 以後、これらは報告されません。新しく増えたものだけが出ます。",
255
- "",
256
- ` ${BASELINE_FILE} を commit してください。`,
257
- "",
258
- ].join("\n"),
259
- );
242
+ console.log(hostText(config).baselineDone(paths.length, entries.length, BASELINE_FILE).join("\n"));
260
243
  return 0;
261
244
  };
262
245
 
@@ -264,56 +247,75 @@ const runSuppressions = async (targets: readonly string[], argv: readonly string
264
247
  const paths = collectTargets(targets.length > 0 ? targets : ["."]);
265
248
  const config = readConfig();
266
249
  const results = await Promise.all(paths.map((path) => inspect(path, config, [...argv, "--show-baseline"])));
267
- 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
+ );
268
256
  return 0;
269
257
  };
270
258
 
271
259
  type Handler = (argv: readonly string[]) => number | Promise<number>;
272
260
 
273
261
  /** `--` で始まらない引数。対象のパス。 */
274
- const positional = (argv: readonly string[]): string[] => argv.slice(1).filter((arg) => !arg.startsWith("--"));
262
+ const positional = (argv: readonly string[]): string[] => targetsOf(argv.slice(1));
275
263
 
276
264
  const showRules = (): number => {
277
265
  const config = readConfig();
278
- const language = config.language ?? "ja";
266
+ const language = config.language ?? hostLanguage(undefined, process.env);
279
267
  warnRuleProblems(config, language);
280
268
  console.log(rulesJson(loadRules(language), config, language, config.genre ?? "blog/tech"));
281
269
  return 0;
282
270
  };
283
271
 
284
272
  const showGenres = (): number => {
285
- console.log(["", " 使えるジャンル:", ...GENRES.map((genre) => ` ${genre}`), "", " chaff.yaml の genre に書くか、--genre で指定します。", ""].join("\n"));
273
+ console.log(hostText(readConfig()).genres(GENRES));
286
274
  return 0;
287
275
  };
288
276
 
277
+ const treeContext = (): TreeContext => {
278
+ const config = readConfig();
279
+ return { config, flag, ui: hostLanguage(config.language, process.env) };
280
+ };
281
+
289
282
  /** 分岐を数珠つなぎにせず表にする。足すときに main を太らせない。 */
290
283
  const HANDLERS: Readonly<Record<string, Handler>> = {
291
284
  init: (argv) => {
292
- 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));
293
286
  return 0;
294
287
  },
295
288
  genres: showGenres,
296
289
  rules: showRules,
297
290
  explain: (argv) => explain(argv[1]),
298
- eval: (argv) => runEval(positional(argv), argv, { config: readConfig(), resolveGenre, flag }),
299
- tree: (argv) => runTree(treeTargets(argv), argv, { config: readConfig(), flag }),
300
- cite: (argv) => runCite(citeTargets(argv), argv, { config: readConfig(), flag }),
301
- 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 }),
302
295
  baseline: (argv) => runBaseline(positional(argv), argv),
303
296
  suppressions: (argv) => runSuppressions(positional(argv), argv),
304
297
  relax: (argv) => changeSetting("relaxed", argv[1], flag(argv, "--why")),
305
298
  strict: (argv) => changeSetting("strict", argv[1], flag(argv, "--why")),
306
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) }),
307
301
  };
308
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
+
309
306
  export const main = async (argv: readonly string[]): Promise<number> => {
310
307
  const first = argv[0];
311
308
  if (first === undefined || first === "--help" || first === "-h") {
312
- console.log(USAGE);
309
+ console.log(hostText(readConfig()).usage);
313
310
  return first === undefined ? 1 : 0;
314
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
+ }
315
317
  const handler = HANDLERS[first];
316
318
  if (handler !== undefined) return handler(argv);
317
- const targets = (first === "lint" ? argv.slice(1) : argv).filter((arg) => !arg.startsWith("--"));
319
+ const targets = targetsOf(first === "lint" ? argv.slice(1) : argv);
318
320
  return argv.includes("--watch") ? runWatch(targets, argv) : lint(targets, argv);
319
321
  };