chaffjs 0.1.0 → 0.3.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 (217) hide show
  1. package/dist/backends/anthropic.d.ts +24 -0
  2. package/dist/backends/anthropic.d.ts.map +1 -0
  3. package/dist/backends/anthropic.js +44 -0
  4. package/dist/backends/anthropic.js.map +1 -0
  5. package/dist/backends/openai.d.ts +29 -0
  6. package/dist/backends/openai.d.ts.map +1 -0
  7. package/dist/backends/openai.js +33 -0
  8. package/dist/backends/openai.js.map +1 -0
  9. package/dist/backends/types.d.ts +32 -0
  10. package/dist/backends/types.d.ts.map +1 -0
  11. package/dist/backends/types.js +18 -0
  12. package/dist/backends/types.js.map +1 -0
  13. package/dist/cli.d.ts.map +1 -1
  14. package/dist/cli.js +21 -67
  15. package/dist/cli.js.map +1 -1
  16. package/dist/commands/eval.d.ts +15 -0
  17. package/dist/commands/eval.d.ts.map +1 -0
  18. package/dist/commands/eval.js +47 -0
  19. package/dist/commands/eval.js.map +1 -0
  20. package/dist/commands/test.d.ts +21 -0
  21. package/dist/commands/test.d.ts.map +1 -0
  22. package/dist/commands/test.js +116 -0
  23. package/dist/commands/test.js.map +1 -0
  24. package/dist/config/by-path.d.ts +15 -0
  25. package/dist/config/by-path.d.ts.map +1 -0
  26. package/dist/config/by-path.js +41 -0
  27. package/dist/config/by-path.js.map +1 -0
  28. package/dist/config/load.d.ts +8 -1
  29. package/dist/config/load.d.ts.map +1 -1
  30. package/dist/config/load.js +31 -3
  31. package/dist/config/load.js.map +1 -1
  32. package/dist/detectors/agentless-passive.d.ts +3 -0
  33. package/dist/detectors/agentless-passive.d.ts.map +1 -0
  34. package/dist/detectors/agentless-passive.js +31 -0
  35. package/dist/detectors/agentless-passive.js.map +1 -0
  36. package/dist/detectors/char-shape.d.ts +12 -0
  37. package/dist/detectors/char-shape.d.ts.map +1 -0
  38. package/dist/detectors/char-shape.js +39 -0
  39. package/dist/detectors/char-shape.js.map +1 -0
  40. package/dist/detectors/count-per-section.d.ts +8 -1
  41. package/dist/detectors/count-per-section.d.ts.map +1 -1
  42. package/dist/detectors/count-per-section.js +26 -5
  43. package/dist/detectors/count-per-section.js.map +1 -1
  44. package/dist/detectors/en-shape.d.ts +19 -0
  45. package/dist/detectors/en-shape.d.ts.map +1 -0
  46. package/dist/detectors/en-shape.js +176 -0
  47. package/dist/detectors/en-shape.js.map +1 -0
  48. package/dist/detectors/index.d.ts.map +1 -1
  49. package/dist/detectors/index.js +33 -0
  50. package/dist/detectors/index.js.map +1 -1
  51. package/dist/detectors/lexicon.d.ts +15 -0
  52. package/dist/detectors/lexicon.d.ts.map +1 -0
  53. package/dist/detectors/lexicon.js +109 -0
  54. package/dist/detectors/lexicon.js.map +1 -0
  55. package/dist/detectors/repeated-head.d.ts.map +1 -1
  56. package/dist/detectors/repeated-head.js +17 -5
  57. package/dist/detectors/repeated-head.js.map +1 -1
  58. package/dist/detectors/sentence-ending.d.ts +3 -0
  59. package/dist/detectors/sentence-ending.d.ts.map +1 -0
  60. package/dist/detectors/sentence-ending.js +34 -0
  61. package/dist/detectors/sentence-ending.js.map +1 -0
  62. package/dist/detectors/sentence-length.d.ts.map +1 -1
  63. package/dist/detectors/sentence-length.js +3 -9
  64. package/dist/detectors/sentence-length.js.map +1 -1
  65. package/dist/detectors/sentence-rhythm.d.ts.map +1 -1
  66. package/dist/detectors/sentence-rhythm.js +3 -2
  67. package/dist/detectors/sentence-rhythm.js.map +1 -1
  68. package/dist/detectors/signals.d.ts +6 -0
  69. package/dist/detectors/signals.d.ts.map +1 -0
  70. package/dist/detectors/signals.js +183 -0
  71. package/dist/detectors/signals.js.map +1 -0
  72. package/dist/detectors/structure.d.ts +24 -0
  73. package/dist/detectors/structure.d.ts.map +1 -0
  74. package/dist/detectors/structure.js +111 -0
  75. package/dist/detectors/structure.js.map +1 -0
  76. package/dist/detectors/token-shape.d.ts +4 -0
  77. package/dist/detectors/token-shape.d.ts.map +1 -0
  78. package/dist/detectors/token-shape.js +62 -0
  79. package/dist/detectors/token-shape.js.map +1 -0
  80. package/dist/document.d.ts.map +1 -1
  81. package/dist/document.js +95 -12
  82. package/dist/document.js.map +1 -1
  83. package/dist/env.d.ts +12 -0
  84. package/dist/env.d.ts.map +1 -0
  85. package/dist/env.js +20 -0
  86. package/dist/env.js.map +1 -0
  87. package/dist/eval.d.ts +31 -0
  88. package/dist/eval.d.ts.map +1 -0
  89. package/dist/eval.js +62 -0
  90. package/dist/eval.js.map +1 -0
  91. package/dist/genre.d.ts.map +1 -1
  92. package/dist/genre.js +8 -1
  93. package/dist/genre.js.map +1 -1
  94. package/dist/init.d.ts.map +1 -1
  95. package/dist/init.js +11 -6
  96. package/dist/init.js.map +1 -1
  97. package/dist/judge.d.ts +14 -16
  98. package/dist/judge.d.ts.map +1 -1
  99. package/dist/judge.js +0 -0
  100. package/dist/judge.js.map +1 -1
  101. package/dist/levels.d.ts +1 -9
  102. package/dist/levels.d.ts.map +1 -1
  103. package/dist/levels.js +15 -3
  104. package/dist/levels.js.map +1 -1
  105. package/dist/look-at.d.ts +18 -0
  106. package/dist/look-at.d.ts.map +1 -0
  107. package/dist/look-at.js +43 -0
  108. package/dist/look-at.js.map +1 -0
  109. package/dist/measure.d.ts +15 -0
  110. package/dist/measure.d.ts.map +1 -0
  111. package/dist/measure.js +16 -0
  112. package/dist/measure.js.map +1 -0
  113. package/dist/plugin.d.ts +44 -1
  114. package/dist/plugin.d.ts.map +1 -1
  115. package/dist/render/eval.d.ts +7 -0
  116. package/dist/render/eval.d.ts.map +1 -0
  117. package/dist/render/eval.js +59 -0
  118. package/dist/render/eval.js.map +1 -0
  119. package/dist/render/explain.d.ts +1 -1
  120. package/dist/render/explain.d.ts.map +1 -1
  121. package/dist/render/explain.js +12 -4
  122. package/dist/render/explain.js.map +1 -1
  123. package/dist/render/rules-json.d.ts.map +1 -1
  124. package/dist/render/rules-json.js +13 -5
  125. package/dist/render/rules-json.js.map +1 -1
  126. package/dist/render/semantic.d.ts +2 -1
  127. package/dist/render/semantic.d.ts.map +1 -1
  128. package/dist/render/semantic.js +35 -0
  129. package/dist/render/semantic.js.map +1 -1
  130. package/dist/rule-load.d.ts.map +1 -1
  131. package/dist/rule-load.js +27 -1
  132. package/dist/rule-load.js.map +1 -1
  133. package/dist/run-semantic.d.ts +22 -1
  134. package/dist/run-semantic.d.ts.map +1 -1
  135. package/dist/run-semantic.js +21 -10
  136. package/dist/run-semantic.js.map +1 -1
  137. package/dist/run.d.ts +6 -1
  138. package/dist/run.d.ts.map +1 -1
  139. package/dist/run.js +34 -2
  140. package/dist/run.js.map +1 -1
  141. package/dist/sentence-shape.d.ts +6 -0
  142. package/dist/sentence-shape.d.ts.map +1 -0
  143. package/dist/sentence-shape.js +15 -0
  144. package/dist/sentence-shape.js.map +1 -0
  145. package/package.json +4 -3
  146. package/rules/adverb-overuse.yaml +28 -0
  147. package/rules/agentless-passive.yaml +28 -0
  148. package/rules/ai-tell.yaml +27 -0
  149. package/rules/bold-density.yaml +8 -6
  150. package/rules/concrete-evidence-density.yaml +25 -0
  151. package/rules/cushion-phrase-density.yaml +26 -0
  152. package/rules/double-keigo.yaml +27 -0
  153. package/rules/emoji-density.yaml +26 -0
  154. package/rules/empty-intensifier.yaml +1 -1
  155. package/rules/excessive-hedging.yaml +26 -0
  156. package/rules/expletive-construction.yaml +27 -0
  157. package/rules/heading-echo.yaml +1 -1
  158. package/rules/hiragana-fukushi.yaml +27 -0
  159. package/rules/max-kanji-continuous.yaml +26 -0
  160. package/rules/max-paragraph-length.yaml +25 -0
  161. package/rules/max-sentence-length.yaml +23 -1
  162. package/rules/ngram-repetition.yaml +25 -0
  163. package/rules/no-doubled-joshi.yaml +28 -0
  164. package/rules/no-mixed-desumasu.yaml +29 -0
  165. package/rules/no-nakaguro-parallel.yaml +27 -0
  166. package/rules/oxford-comma-consistency.yaml +26 -0
  167. package/rules/paragraph-length-variance.yaml +25 -0
  168. package/rules/preamble-length.yaml +25 -0
  169. package/rules/repeated-conjunction.yaml +26 -0
  170. package/rules/repeated-sentence-head.yaml +1 -1
  171. package/rules/rule-of-three.yaml +25 -0
  172. package/rules/sasete-itadaku.yaml +27 -0
  173. package/rules/section-length-uniformity.yaml +25 -0
  174. package/rules/sentence-initial-conjunction-run.yaml +27 -0
  175. package/rules/taigen-dome-in-prose.yaml +27 -0
  176. package/rules/title-case-consistency.yaml +26 -0
  177. package/rules/undefined-acronym.yaml +25 -0
  178. package/rules/unqualified-superlative.yaml +26 -0
  179. package/src/backends/anthropic.ts +61 -0
  180. package/src/backends/openai.ts +46 -0
  181. package/src/backends/types.ts +38 -0
  182. package/src/cli.ts +19 -78
  183. package/src/commands/eval.ts +67 -0
  184. package/src/commands/test.ts +147 -0
  185. package/src/config/by-path.ts +47 -0
  186. package/src/config/load.ts +36 -3
  187. package/src/detectors/agentless-passive.ts +34 -0
  188. package/src/detectors/char-shape.ts +47 -0
  189. package/src/detectors/count-per-section.ts +30 -6
  190. package/src/detectors/en-shape.ts +187 -0
  191. package/src/detectors/index.ts +33 -0
  192. package/src/detectors/lexicon.ts +124 -0
  193. package/src/detectors/repeated-head.ts +17 -6
  194. package/src/detectors/sentence-ending.ts +37 -0
  195. package/src/detectors/sentence-length.ts +4 -10
  196. package/src/detectors/sentence-rhythm.ts +3 -2
  197. package/src/detectors/signals.ts +199 -0
  198. package/src/detectors/structure.ts +119 -0
  199. package/src/detectors/token-shape.ts +81 -0
  200. package/src/document.ts +101 -13
  201. package/src/env.ts +20 -0
  202. package/src/eval.ts +80 -0
  203. package/src/genre.ts +8 -1
  204. package/src/init.ts +11 -6
  205. package/src/judge.ts +0 -0
  206. package/src/levels.ts +16 -4
  207. package/src/look-at.ts +53 -0
  208. package/src/measure.ts +21 -0
  209. package/src/plugin.ts +45 -3
  210. package/src/render/eval.ts +62 -0
  211. package/src/render/explain.ts +12 -4
  212. package/src/render/rules-json.ts +16 -5
  213. package/src/render/semantic.ts +38 -1
  214. package/src/rule-load.ts +28 -1
  215. package/src/run-semantic.ts +32 -11
  216. package/src/run.ts +36 -3
  217. package/src/sentence-shape.ts +22 -0
package/src/cli.ts CHANGED
@@ -3,6 +3,7 @@ import { existsSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { loadAdapter, packageFor } from "./adapter-load.ts";
5
5
  import { CONFIG_FILE, EMPTY, loadConfig, type Config } from "./config/load.ts";
6
+ import { applyByPath } from "./config/by-path.ts";
6
7
  import { applyLevel } from "./config/write.ts";
7
8
  import { buildDocument } from "./document.ts";
8
9
  import { guessLanguage } from "./detect.ts";
@@ -11,10 +12,8 @@ import { BASELINE_FILE, fingerprint, readBaseline, splitByBaseline, writeBaselin
11
12
  import { applySuppressions } from "./stet.ts";
12
13
  import { renderSuppressions, type PerFile } from "./render/suppressions.ts";
13
14
  import { clock, describeChange, snapshotOf, watchPaths, type Snapshot } from "./watch.ts";
14
- import { CHECKS_FILE, loadChecks, type UserCheck } from "./checks.ts";
15
- import { CACHE_DIR, hasCredentials, isAuthFailure } from "./judge.ts";
16
- import { runSemantic } from "./run-semantic.ts";
17
- import { MACHINE_BANNER, renderSemantic } from "./render/semantic.ts";
15
+ import { runEval } from "./commands/eval.ts";
16
+ import { runTest } from "./commands/test.ts";
18
17
  import { frontMatterGenre, guessGenre } from "./genre.ts";
19
18
  import { GENRES, runInit } from "./init.ts";
20
19
  import { loadRules } from "./rule-load.ts";
@@ -23,7 +22,7 @@ import { renderExplain } from "./render/explain.ts";
23
22
  import { renderFriendly } from "./render/friendly.ts";
24
23
  import { rulesJson } from "./render/rules-json.ts";
25
24
  import { renderSummary, type FileOutcome } from "./render/summary.ts";
26
- import { runRules } from "./run.ts";
25
+ import { neededBy, runRules } from "./run.ts";
27
26
  import type { Level, RuleDefinition } from "./plugin.ts";
28
27
 
29
28
  const USAGE = `chaff — 文章の読みにくいところを見つけます。文章は書き換えません。
@@ -31,7 +30,9 @@ const USAGE = `chaff — 文章の読みにくいところを見つけます。
31
30
  chaff <file|dir|glob>... 検査する(設定も API key も要りません)
32
31
  chaff . この場所の Markdown を全部
33
32
  chaff test <file|dir>... 意味を読む検査も動かす(API key が要ります)
33
+ 判定役は chaff.yaml の ai_backend で選びます
34
34
  chaff init chaff.yaml を作る
35
+ chaff eval <dir> 手元の文書で閾値を測り直す
35
36
  chaff explain <rule> そのルールの意図と根拠を読む
36
37
  chaff genres ジャンルの一覧
37
38
  chaff rules --json いまの設定を JSON で出す(AI に渡す用)
@@ -43,6 +44,7 @@ const USAGE = `chaff — 文章の読みにくいところを見つけます。
43
44
  --experimental 試験中の rule も動かす
44
45
  --show-baseline 棚上げした分も含めて全部見る
45
46
  --watch 保存のたびに見直し、変わったところだけ出す
47
+ --dry-run test で、何を AI に送るかだけを見る(API を呼びません)
46
48
 
47
49
  この箇所だけ黙らせる: <!-- stet: rule-id — 理由 -->
48
50
 
@@ -52,6 +54,9 @@ const USAGE = `chaff — 文章の読みにくいところを見つけます。
52
54
  const readConfig = (): Config => (existsSync(join(process.cwd(), CONFIG_FILE)) ? loadConfig(join(process.cwd(), CONFIG_FILE)) : EMPTY);
53
55
 
54
56
  const resolveGenre = (path: string, source: string, config: Config): { genre: string; from: string } => {
57
+ // パスごとの上書きが最優先。「全体はこう、ここだけは違う」を書けるようにする。
58
+ const override = applyByPath(config.byPath, config.baseDir, path);
59
+ if (override.genre !== undefined) return { genre: override.genre, from: `${CONFIG_FILE} の by_path` };
55
60
  if (config.genre !== undefined) return { genre: config.genre, from: CONFIG_FILE };
56
61
  const guess = guessGenre(path, source, frontMatterGenre(source));
57
62
  return guess === undefined ? { genre: "blog/tech", from: "既定" } : { genre: guess.genre, from: guess.from };
@@ -86,12 +91,14 @@ const headerFor = (path: string, genre: string, from: string, language: string,
86
91
 
87
92
  const inspect = async (path: string, config: Config, argv: readonly string[]): Promise<Inspected> => {
88
93
  const source = await readFile(path, "utf8");
89
- const language = config.language ?? guessLanguage(source).language;
94
+ const language = applyByPath(config.byPath, config.baseDir, path).language ?? config.language ?? guessLanguage(source).language;
90
95
  const adapter = await loadAdapter(language);
91
96
  const { genre, from } = resolveGenre(path, source, config);
92
- const doc = buildDocument(path, source, adapter);
93
97
  const rules = loadRules(language);
94
- const raw = runRules(doc, rules, config.rules, config.experimental || argv.includes("--experimental"), genre);
98
+ const experimental = config.experimental || argv.includes("--experimental");
99
+ await adapter.prepare?.(neededBy(rules, config.rules, experimental, genre, language));
100
+ const doc = buildDocument(path, source, adapter);
101
+ const raw = runRules(doc, rules, config.rules, experimental, genre);
95
102
  // 応答は 3 つ。stet で黙らせたものは、ここで落とす。
96
103
  const applied = applySuppressions(
97
104
  source,
@@ -163,74 +170,6 @@ const runWatch = async (targets: readonly string[], argv: readonly string[]): Pr
163
170
  return new Promise(() => undefined);
164
171
  };
165
172
 
166
- type Judged = { path: string; outcome: Awaited<ReturnType<typeof runSemantic>>; rules: RuleDefinition[]; language: string };
167
-
168
- const judgeAll = async (
169
- paths: readonly string[],
170
- config: Config,
171
- checks: readonly UserCheck[],
172
- options: Parameters<typeof runSemantic>[5],
173
- ): Promise<Judged[]> =>
174
- Promise.all(
175
- paths.map(async (path) => {
176
- const source = await readFile(path, "utf8");
177
- const language = config.language ?? guessLanguage(source).language;
178
- const adapter = await loadAdapter(language);
179
- const doc = buildDocument(path, source, adapter);
180
- const { genre } = resolveGenre(path, source, config);
181
- const rules = loadRules(language);
182
- return { path, outcome: await runSemantic(doc, rules, checks, config.rules, genre, options), rules, language };
183
- }),
184
- );
185
-
186
- /**
187
- * 機械と AI を見出しで分けて出す。読む人が「これは揺れる判定か」を知らないと、
188
- * AI の誤検知に振り回される。workflow spec §7、samples/README の軸 2。
189
- */
190
- const test = async (targets: readonly string[], argv: readonly string[]): Promise<number> => {
191
- const paths = collectTargets(targets.length > 0 ? targets : ["."]);
192
- if (paths.length === 0) {
193
- console.error(`Markdown が 1 つも見つかりませんでした: ${targets.join(", ")}`);
194
- return 1;
195
- }
196
- const config = readConfig();
197
- const checks = loadChecks(join(process.cwd(), CHECKS_FILE));
198
- const results = await Promise.all(paths.map((path) => inspect(path, config, argv)));
199
- results.forEach((result) => {
200
- console.log(result.text.replace(/\n/u, `\n${MACHINE_BANNER.join("\n")}`));
201
- });
202
- const machineOnly = results.some((result) => result.outcome.findings.some((finding) => finding.severity === "error")) ? 1 : 0;
203
- const noCredentials = [
204
- "",
205
- " 意味を読む検査は動かしていません。Anthropic の認証情報がありません。",
206
- " ANTHROPIC_API_KEY を設定するか、ant auth login を実行してください。",
207
- " 機械による判定はすべて動いています。",
208
- "",
209
- ].join("\n");
210
- // 呼んでから落ちるのを待たない。認証が無いときの SDK の例外は型で判別できない。
211
- if (!hasCredentials()) {
212
- console.log(noCredentials);
213
- return machineOnly;
214
- }
215
- const options = { model: config.aiModel, cacheDir: join(process.cwd(), CACHE_DIR), confidenceThreshold: config.confidenceThreshold };
216
- const semantic = await judgeAll(paths, config, checks, options).catch((error: unknown) => {
217
- if (!isAuthFailure(error)) throw error;
218
- console.log(noCredentials);
219
- return undefined;
220
- });
221
- if (semantic === undefined) return machineOnly;
222
- semantic.forEach((entry) => {
223
- if (entry.outcome.findings.length === 0 && entry.outcome.asked === 0) return;
224
- console.log([`${entry.path}`, ...renderSemantic(entry.outcome, entry.rules, checks, entry.language)].join("\n"));
225
- });
226
- const machine = results.flatMap((result) => result.outcome.findings);
227
- const ai = semantic.flatMap((entry) => entry.outcome.findings);
228
- console.log(
229
- ["", "─".repeat(60), "", ` 機械 ${machine.length} 件 / AI ${ai.length} 件`, " 文章は書き換えていません。直すのは書いた人です。", ""].join("\n"),
230
- );
231
- return [...machine, ...ai].some((finding) => finding.severity === "error") ? 1 : 0;
232
- };
233
-
234
173
  const explain = (ruleId: string | undefined): number => {
235
174
  const config = readConfig();
236
175
  const language = config.language ?? "ja";
@@ -242,12 +181,13 @@ const explain = (ruleId: string | undefined): number => {
242
181
  return 1;
243
182
  }
244
183
  const current = config.rules[rule.id] ?? (rule.status === "experimental" && !config.experimental ? "off" : "normal");
245
- console.log(renderExplain(rule, current, language, unitOf(rule.id, language)));
184
+ console.log(renderExplain(rule, current, language, unitOf(rule.id, language), config.genre));
246
185
  return 0;
247
186
  };
248
187
 
249
188
  /** 閾値の単位。explain で「4 とは何の 4 か」を示すため。 */
250
189
  const unitOf = (ruleId: string, language: string): string => {
190
+ if (ruleId === "bold-density") return "1000 字あたりの箇所数";
251
191
  if (ruleId !== "max-sentence-length") return "回";
252
192
  return language === "ja" ? "文字" : "語";
253
193
  };
@@ -312,7 +252,8 @@ const HANDLERS: Readonly<Record<string, Handler>> = {
312
252
  genres: showGenres,
313
253
  rules: showRules,
314
254
  explain: (argv) => explain(argv[1]),
315
- test: (argv) => test(positional(argv), argv),
255
+ eval: (argv) => runEval(positional(argv), argv, { config: readConfig(), resolveGenre, flag }),
256
+ test: (argv) => runTest(positional(argv), argv, { config: readConfig(), resolveGenre, inspect }),
316
257
  baseline: (argv) => runBaseline(positional(argv), argv),
317
258
  suppressions: (argv) => runSuppressions(positional(argv), argv),
318
259
  relax: (argv) => changeSetting("relaxed", argv[1], flag(argv, "--why")),
@@ -0,0 +1,67 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { loadAdapter } from "../adapter-load.ts";
3
+ import { applyByPath } from "../config/by-path.ts";
4
+ import { buildDocument } from "../document.ts";
5
+ import { guessLanguage } from "../detect.ts";
6
+ import { collectTargets } from "../files.ts";
7
+ import { loadRules } from "../rule-load.ts";
8
+ import { evaluate } from "../eval.ts";
9
+ import { renderEval } from "../render/eval.ts";
10
+ import { neededBy } from "../run.ts";
11
+ import type { Config } from "../config/load.ts";
12
+
13
+ export type Context = {
14
+ readonly config: Config;
15
+ readonly resolveGenre: (path: string, source: string, config: Config) => { genre: string; from: string };
16
+ readonly flag: (argv: readonly string[], name: string) => string | undefined;
17
+ };
18
+
19
+ /**
20
+ * 閾値の較正。手元の文書を「人間の良い文書」として扱い、どの閾値なら
21
+ * 誤検知が目標を下回るかを測る。書き換えはしない。
22
+ */
23
+ export const runEval = async (targets: readonly string[], argv: readonly string[], context: Context): Promise<number> => {
24
+ const paths = collectTargets(targets.length > 0 ? targets : ["."]);
25
+ if (paths.length === 0) {
26
+ console.error(`Markdown が 1 つも見つかりませんでした: ${targets.join(", ")}`);
27
+ return 1;
28
+ }
29
+ const { config, resolveGenre, flag } = context;
30
+ const only = flag(argv, "--rule");
31
+ const docs = await Promise.all(
32
+ paths.map(async (path) => {
33
+ const source = await readFile(path, "utf8");
34
+ const language = applyByPath(config.byPath, config.baseDir, path).language ?? config.language ?? guessLanguage(source).language;
35
+ const adapter = await loadAdapter(language);
36
+ const { genre } = resolveGenre(path, source, config);
37
+ await adapter.prepare?.(neededBy(loadRules(language), config.rules, config.experimental, genre, language));
38
+ return { doc: buildDocument(path, source, adapter), language, genre };
39
+ }),
40
+ );
41
+ const language = docs[0]?.language ?? "ja";
42
+ const genre = docs[0]?.genre ?? "blog/tech";
43
+ // 言語とジャンルが混ざった corpus は測れない。閾値の単位が違うため。
44
+ const mixed = new Set(docs.map((entry) => `${entry.language}/${entry.genre}`));
45
+ if (mixed.size > 1) {
46
+ console.error(`言語かジャンルが混ざっています: ${[...mixed].join(", ")}\n1 つに絞って測ってください(例: npx chaff eval examples/blog-ja/)。`);
47
+ return 1;
48
+ }
49
+ const rules = loadRules(language).filter((rule) => only === undefined || rule.id === only);
50
+ if (rules.length === 0) {
51
+ console.error(`${only ?? "(名前なし)"} という rule はありません。`);
52
+ return 1;
53
+ }
54
+ console.log(
55
+ renderEval(
56
+ evaluate(
57
+ docs.map((entry) => entry.doc),
58
+ rules,
59
+ genre,
60
+ language,
61
+ ),
62
+ docs.length,
63
+ paths.length,
64
+ ),
65
+ );
66
+ return 0;
67
+ };
@@ -0,0 +1,147 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { loadAdapter } from "../adapter-load.ts";
4
+ import { applyByPath } from "../config/by-path.ts";
5
+ import { buildDocument } from "../document.ts";
6
+ import { guessLanguage } from "../detect.ts";
7
+ import { collectTargets } from "../files.ts";
8
+ import { loadRules } from "../rule-load.ts";
9
+ import { CHECKS_FILE, loadChecks, type UserCheck } from "../checks.ts";
10
+ import { CACHE_DIR, credentialHint, describeFailure, hasCredentials } from "../judge.ts";
11
+ import { ENV_FILE, loadEnvFile } from "../env.ts";
12
+ import { planSemantic, runSemantic } from "../run-semantic.ts";
13
+ import { MACHINE_BANNER, renderPlan, renderSemantic } from "../render/semantic.ts";
14
+ import type { Config } from "../config/load.ts";
15
+ import type { Finding, RuleDefinition } from "../plugin.ts";
16
+ import type { Failure } from "../backends/types.ts";
17
+
18
+ export type TestContext = {
19
+ readonly config: Config;
20
+ readonly resolveGenre: (path: string, source: string, config: Config) => { genre: string; from: string };
21
+ readonly inspect: (path: string, config: Config, argv: readonly string[]) => Promise<{ text: string; outcome: { findings: readonly Finding[] } }>;
22
+ };
23
+
24
+ type Judged = { path: string; outcome: Awaited<ReturnType<typeof runSemantic>>; rules: RuleDefinition[]; language: string };
25
+
26
+ const judgeAll = async (
27
+ paths: readonly string[],
28
+ config: Config,
29
+ checks: readonly UserCheck[],
30
+ options: Parameters<typeof runSemantic>[5],
31
+ resolveGenre: TestContext["resolveGenre"],
32
+ ): Promise<Judged[]> =>
33
+ Promise.all(
34
+ paths.map(async (path) => {
35
+ const source = await readFile(path, "utf8");
36
+ const language = applyByPath(config.byPath, config.baseDir, path).language ?? config.language ?? guessLanguage(source).language;
37
+ const adapter = await loadAdapter(language);
38
+ const doc = buildDocument(path, source, adapter);
39
+ const { genre } = resolveGenre(path, source, config);
40
+ const rules = loadRules(language);
41
+ return { path, outcome: await runSemantic(doc, rules, checks, config.rules, genre, options), rules, language };
42
+ }),
43
+ );
44
+
45
+ /** API を呼ばずに、何が送られるかだけを出す。鍵が無くても二段構えの 1 段目を確かめられる。 */
46
+ const dryRun = async (
47
+ paths: readonly string[],
48
+ config: Config,
49
+ checks: readonly UserCheck[],
50
+ resolveGenre: TestContext["resolveGenre"],
51
+ envFile: string | undefined,
52
+ ): Promise<void> => {
53
+ const plans = await Promise.all(
54
+ paths.map(async (path) => {
55
+ const source = await readFile(path, "utf8");
56
+ const language = applyByPath(config.byPath, config.baseDir, path).language ?? config.language ?? guessLanguage(source).language;
57
+ const adapter = await loadAdapter(language);
58
+ const doc = buildDocument(path, source, adapter);
59
+ const { genre } = resolveGenre(path, source, config);
60
+ return { path, jobs: planSemantic(doc, loadRules(language), checks, config.rules, genre), sentences: doc.sentences.length };
61
+ }),
62
+ );
63
+ plans.forEach(({ path, jobs, sentences }) => console.log(renderPlan(path, jobs, sentences).join("\n")));
64
+ const total = plans.reduce((sum, plan) => sum + plan.jobs.reduce((count, job) => count + job.candidates.length, 0), 0);
65
+ const from = envFile === undefined ? "" : ` ${envFile} を読みました。`;
66
+ const where = `${config.aiBackend} / ${config.aiModel}(認証${hasCredentials(config.aiBackend) ? "あり" : "なし"})`;
67
+ console.log(
68
+ [
69
+ "",
70
+ "─".repeat(60),
71
+ "",
72
+ ` 合計 ${total} 箇所を送ります。--dry-run なので API は呼んでいません。`,
73
+ ` 送り先: ${where}`,
74
+ ...(from === "" ? [] : [from]),
75
+ "",
76
+ ].join("\n"),
77
+ );
78
+ };
79
+
80
+ /**
81
+ * 機械と AI を見出しで分けて出す。読む人が「これは揺れる判定か」を知らないと、
82
+ * AI の誤検知に振り回される。workflow spec §7、samples/README の軸 2。
83
+ */
84
+ export const runTest = async (targets: readonly string[], argv: readonly string[], context: TestContext): Promise<number> => {
85
+ const paths = collectTargets(targets.length > 0 ? targets : ["."]);
86
+ if (paths.length === 0) {
87
+ console.error(`Markdown が 1 つも見つかりませんでした: ${targets.join(", ")}`);
88
+ return 1;
89
+ }
90
+ const { config, resolveGenre, inspect } = context;
91
+ // 鍵の置き場所で「設定したのに効かない」を起こさせない。シェルの環境変数のほうが勝つ。
92
+ const envFile = loadEnvFile(process.cwd());
93
+ const checks = loadChecks(join(process.cwd(), CHECKS_FILE));
94
+ const results = await Promise.all(paths.map((path) => inspect(path, config, argv)));
95
+ results.forEach((result) => {
96
+ console.log(result.text.replace(/\n/u, `\n${MACHINE_BANNER.join("\n")}`));
97
+ });
98
+ const machineOnly = results.some((result) => result.outcome.findings.some((finding) => finding.severity === "error")) ? 1 : 0;
99
+ const where = envFile === undefined ? `${ENV_FILE} に書いても読みます(いまは ${ENV_FILE} がありません)` : `${envFile} は読みました`;
100
+ const notRun = (why: string, what: string): string =>
101
+ ["", ` 意味を読む検査は動かしていません。${why}`, ` ${what}`, ` ${where}。`, " 機械による判定はすべて動いています。", ""].join("\n");
102
+ const noCredentials = notRun(`${config.aiBackend} の認証情報がありません。`, credentialHint(config.aiBackend));
103
+ /**
104
+ * API が返した失敗を人の言葉にする。生のスタックトレースを人に見せない。
105
+ * 鍵はあるのに弾かれたときに「ありません」と言うと、設定済みの鍵をもう一度設定しに行かせる。
106
+ * 残高切れも同じで、「鍵を確かめてください」と言われても鍵は正しい。
107
+ */
108
+ const failed = (failure: Failure): string => {
109
+ if (failure.kind === "auth")
110
+ return notRun(`${config.aiBackend} が鍵を受け付けませんでした(${failure.status ?? "?"})。`, "鍵が正しいか、その provider のものかを確かめてください。");
111
+ if (failure.kind === "quota") return notRun(`${config.aiBackend} の残高か上限に達しました(${failure.status ?? "?"})。`, failure.message);
112
+ return notRun(`${config.aiBackend} が ${failure.status ?? "?"} を返しました。`, failure.message);
113
+ };
114
+ if (argv.includes("--dry-run")) {
115
+ await dryRun(paths, config, checks, resolveGenre, envFile);
116
+ return machineOnly;
117
+ }
118
+ // 呼んでから落ちるのを待たない。認証が無いときの SDK の例外は型で判別できない。
119
+ if (!hasCredentials(config.aiBackend)) {
120
+ console.log(noCredentials);
121
+ return machineOnly;
122
+ }
123
+ const options = {
124
+ model: config.aiModel,
125
+ backend: config.aiBackend,
126
+ cacheDir: join(process.cwd(), CACHE_DIR),
127
+ confidenceThreshold: config.confidenceThreshold,
128
+ };
129
+ const semantic = await judgeAll(paths, config, checks, options, resolveGenre).catch((error: unknown) => {
130
+ // API が返した失敗だけを受け止める。ネットワーク断やコードの誤りは握りつぶさない。
131
+ const failure = describeFailure(config.aiBackend, error);
132
+ if (failure === undefined) throw error;
133
+ console.log(failed(failure));
134
+ return undefined;
135
+ });
136
+ if (semantic === undefined) return machineOnly;
137
+ semantic.forEach((entry) => {
138
+ if (entry.outcome.findings.length === 0 && entry.outcome.asked === 0) return;
139
+ console.log([`${entry.path}`, ...renderSemantic(entry.outcome, entry.rules, checks, entry.language)].join("\n"));
140
+ });
141
+ const machine = results.flatMap((result) => result.outcome.findings);
142
+ const ai = semantic.flatMap((entry) => entry.outcome.findings);
143
+ console.log(
144
+ ["", "─".repeat(60), "", ` 機械 ${machine.length} 件 / AI ${ai.length} 件`, " 文章は書き換えていません。直すのは書いた人です。", ""].join("\n"),
145
+ );
146
+ return [...machine, ...ai].some((finding) => finding.severity === "error") ? 1 : 0;
147
+ };
@@ -0,0 +1,47 @@
1
+ import { relative, sep } from "node:path";
2
+
3
+ export type PathRule = { readonly files: readonly string[]; readonly genre: string | undefined; readonly language: string | undefined };
4
+
5
+ /**
6
+ * glob を正規表現にする。使うのは 3 つだけ。
7
+ * **\/ 区切りをまたいでディレクトリ 0 個以上(docs/**\/*.md は docs/a.md にも当たる)
8
+ * * 区切りをまたがずに何文字でも
9
+ * ? 区切り以外の 1 文字
10
+ * これ以上を足すと、設定を書く人が覚えることが増える。
11
+ *
12
+ * 「ディレクトリ 0 個」を落とすと docs/**\/*.md が docs/a.md に当たらない。
13
+ * glob の典型的な落とし穴で、設定を書いた人は当たらない理由に気づけない。
14
+ */
15
+ const DIRS = "\u0000";
16
+ const ANY = "\u0001";
17
+
18
+ const toPattern = (glob: string): RegExp => {
19
+ const body = glob
20
+ .replace(/[.+^${}()|[\]\\]/gu, "\\$&")
21
+ .replace(/\*\*\//gu, DIRS)
22
+ .replace(/\*\*/gu, ANY)
23
+ .replace(/\*/gu, "[^/]*")
24
+ .replace(/\?/gu, "[^/]")
25
+ .replaceAll(DIRS, "(?:[^/]*/)*")
26
+ .replaceAll(ANY, ".*");
27
+ return new RegExp(`^${body}$`, "u");
28
+ };
29
+
30
+ /** 設定ファイルからの相対で照合する。実行した場所に左右されないため。 */
31
+ const normalize = (base: string, path: string): string => relative(base, path).split(sep).join("/");
32
+
33
+ export const matches = (glob: string, base: string, path: string): boolean => toPattern(glob).test(normalize(base, path));
34
+
35
+ /**
36
+ * 後に書いたものが勝つ。上から順に当て、最後に当たったものを使う。
37
+ * 「全体はこう、ここだけは違う」と書けるようにするため。
38
+ */
39
+ export const applyByPath = (rules: readonly PathRule[], base: string, path: string): { genre?: string; language?: string } =>
40
+ rules.reduce<{ genre?: string; language?: string }>((acc, rule) => {
41
+ if (!rule.files.some((glob) => matches(glob, base, path))) return acc;
42
+ return {
43
+ ...acc,
44
+ ...(rule.genre === undefined ? {} : { genre: rule.genre }),
45
+ ...(rule.language === undefined ? {} : { language: rule.language }),
46
+ };
47
+ }, {});
@@ -1,22 +1,31 @@
1
1
  import { readFileSync } from "node:fs";
2
+ import { dirname } from "node:path";
2
3
  import { parse } from "yaml";
3
4
  import { isLevel } from "../levels.ts";
5
+ import { defaultModel } from "../judge.ts";
6
+ import { isBackend, type BackendName } from "../backends/types.ts";
4
7
  import type { Level } from "../plugin.ts";
8
+ import type { PathRule } from "./by-path.ts";
5
9
 
6
10
  export const CONFIG_FILE = "chaff.yaml";
7
11
 
8
12
  export type Config = {
9
13
  readonly genre: string | undefined;
14
+ readonly aiBackend: BackendName;
10
15
  readonly language: string | undefined;
11
16
  readonly rules: Readonly<Record<string, Level>>;
12
17
  readonly experimental: boolean;
13
18
  readonly path: string | undefined;
14
19
  readonly aiModel: string;
15
20
  readonly confidenceThreshold: number;
21
+ /** パスごとの上書き。設定ファイルのある場所からの相対で照合する。 */
22
+ readonly byPath: readonly PathRule[];
23
+ readonly baseDir: string;
16
24
  };
17
25
 
18
26
  /** 判定の質が誤検知に直結するので、既定は最上位のモデル。cost は絞り込みで削る。spec §14。 */
19
- export const DEFAULT_MODEL = "claude-opus-5";
27
+ /** 既定は Anthropic。openai にすると判定役だけが替わり、rule も判定の形も変わらない。 */
28
+ export const DEFAULT_BACKEND: BackendName = "anthropic";
20
29
 
21
30
  export const EMPTY: Config = {
22
31
  genre: undefined,
@@ -24,8 +33,11 @@ export const EMPTY: Config = {
24
33
  rules: {},
25
34
  experimental: false,
26
35
  path: undefined,
27
- aiModel: DEFAULT_MODEL,
36
+ aiBackend: DEFAULT_BACKEND,
37
+ aiModel: defaultModel(DEFAULT_BACKEND),
28
38
  confidenceThreshold: 0.7,
39
+ byPath: [],
40
+ baseDir: process.cwd(),
29
41
  };
30
42
 
31
43
  const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
@@ -37,17 +49,38 @@ const rulesOf = (raw: unknown): Record<string, Level> => {
37
49
 
38
50
  const str = (value: unknown): string | undefined => (typeof value === "string" ? value : undefined);
39
51
 
52
+ /** files は 1 つの文字列でも配列でも書ける。 */
53
+ const globsOf = (value: unknown): string[] => {
54
+ if (typeof value === "string") return [value];
55
+ return Array.isArray(value) ? value.map((entry) => String(entry)) : [];
56
+ };
57
+
58
+ const toPathRule = (raw: unknown): PathRule | undefined => {
59
+ if (!isRecord(raw)) return undefined;
60
+ const files = raw["files"];
61
+ const globs = globsOf(files);
62
+ if (globs.length === 0) return undefined;
63
+ return { files: globs, genre: str(raw["genre"]), language: str(raw["language"]) };
64
+ };
65
+
66
+ const byPathOf = (raw: unknown): PathRule[] => (Array.isArray(raw) ? raw.map(toPathRule).filter((rule) => rule !== undefined) : []);
67
+
40
68
  /** 設定ファイルが無くても動く。あっても、既定から変えたものだけが書かれている。spec §18。 */
41
69
  export const loadConfig = (path: string): Config => {
42
70
  const raw: unknown = parse(readFileSync(path, "utf8"));
43
71
  if (!isRecord(raw)) return { ...EMPTY, path };
72
+ const declared: unknown = raw["ai_backend"];
73
+ const backend: BackendName = isBackend(declared) ? declared : DEFAULT_BACKEND;
44
74
  return {
45
75
  genre: str(raw["genre"]),
46
76
  language: str(raw["language"]),
47
77
  rules: rulesOf(raw["rules"]),
48
78
  experimental: raw["experimental"] === true,
49
79
  path,
50
- aiModel: str(raw["ai_model"]) ?? DEFAULT_MODEL,
80
+ aiBackend: backend,
81
+ aiModel: str(raw["ai_model"]) ?? defaultModel(backend),
51
82
  confidenceThreshold: typeof raw["confidence_threshold"] === "number" ? raw["confidence_threshold"] : 0.7,
83
+ byPath: byPathOf(raw["by_path"]),
84
+ baseDir: dirname(path),
52
85
  };
53
86
  };
@@ -0,0 +1,34 @@
1
+ import type { Detector, Finding, Sentence, Token } from "../plugin.ts";
2
+
3
+ /**
4
+ * 受動のまま動作主を書かないと、誰がしたのかが文から消える。
5
+ * 「決定されました」は、決めた人を名指さずに決定を伝える。
6
+ *
7
+ * 受動かどうかはアダプタが Voice=Pass で印を付ける。日本語の「れる/られる」と
8
+ * 英語の be + 過去分詞は形が全く違うので、その判断は言語の側に置く。spec §12.1。
9
+ * 「名詞を修飾しているだけか」の判断も、語順が言語で逆になるため同じくアダプタ側。
10
+ * 動作主の語(によって / by)も言語ごとの語彙表から引く。detector は言語を知らない。
11
+ */
12
+ const isPassive = (token: Token): boolean => token.features?.["Voice"] === "Pass";
13
+
14
+ const hasAgent = (sentence: Sentence, markers: readonly string[]): boolean => markers.some((marker) => sentence.text.includes(marker));
15
+
16
+ export const agentlessPassive: Detector = (doc, options): Finding[] => {
17
+ const markers = (options.lexicon ?? []).map((entry) => entry.pattern);
18
+ return doc.sentences
19
+ .map((sentence) => ({ sentence, passives: (sentence.tokens ?? []).filter(isPassive) }))
20
+ .filter(({ sentence, passives }) => passives.length >= options.limit && !hasAgent(sentence, markers))
21
+ .map(({ sentence, passives }) => ({
22
+ rule: "agentless-passive",
23
+ severity: "warning",
24
+ line: 0,
25
+ column: 0,
26
+ quote: sentence.text.trim(),
27
+ values: {
28
+ count: passives.length,
29
+ limit: options.limit,
30
+ word: passives.map((token) => token.surface).join(" "),
31
+ offset: passives[0]?.span.start ?? sentence.span.start,
32
+ },
33
+ }));
34
+ };
@@ -0,0 +1,47 @@
1
+ import type { Detector, Finding, Sentence } from "../plugin.ts";
2
+ import { proseText } from "../measure.ts";
3
+
4
+ /**
5
+ * 文字の並びだけを見る検出。品詞も語彙表も要らない。
6
+ * 覆った箇所は空白になっているので、proseText で潰してから数える。
7
+ */
8
+ const KANJI_RUN = /[一-鿿]+/gu;
9
+
10
+ const longestKanji = (sentence: Sentence): string =>
11
+ [...proseText(sentence).matchAll(KANJI_RUN)].reduce((longest, match) => (match[0].length > longest.length ? match[0] : longest), "");
12
+
13
+ /**
14
+ * 漢字が続くと、どこで語が切れるのか読み手が探すことになる。
15
+ * 「情報処理推進機構認定試験」は 12 字。ひらがなを 1 つ挟むだけで読める。
16
+ */
17
+ export const kanjiRun: Detector = (doc, options): Finding[] =>
18
+ doc.sentences
19
+ .map((sentence) => ({ sentence, run: longestKanji(sentence) }))
20
+ .filter(({ run }) => run.length > options.limit)
21
+ .map(({ sentence, run }) => ({
22
+ rule: "max-kanji-continuous",
23
+ severity: "warning",
24
+ line: 0,
25
+ column: 0,
26
+ quote: sentence.text.trim(),
27
+ values: { word: run, count: run.length, limit: options.limit, offset: sentence.span.start },
28
+ }));
29
+
30
+ const MIDDLE_DOT = /・/gu;
31
+
32
+ /**
33
+ * 中黒で並べると、どこまでが 1 つの項目か分からなくなる。
34
+ * 「企画・開発・運用・保守体制」は、保守体制が 1 つなのか保守と体制なのか読めない。
35
+ */
36
+ export const middleDot: Detector = (doc, options): Finding[] =>
37
+ doc.sentences
38
+ .map((sentence) => ({ sentence, count: [...proseText(sentence).matchAll(MIDDLE_DOT)].length }))
39
+ .filter(({ count }) => count > options.limit)
40
+ .map(({ sentence, count }) => ({
41
+ rule: "no-nakaguro-parallel",
42
+ severity: "info",
43
+ line: 0,
44
+ column: 0,
45
+ quote: sentence.text.trim(),
46
+ values: { count, limit: options.limit, offset: sentence.span.start },
47
+ }));
@@ -1,15 +1,39 @@
1
- import type { Detector, Finding } from "../plugin.ts";
1
+ import { charLength, proseText } from "../measure.ts";
2
+ import type { Detector, Finding, Section } from "../plugin.ts";
2
3
 
3
- /** 節あたりの強調の数。bold-density が使う。 */
4
+ /** これより短い節では密度が暴れる。43 字に 1 箇所で「1000 字あたり 23」になる。 */
5
+ const MIN_CHARS = 200;
6
+
7
+ const PER = 1000;
8
+
9
+ const charsOf = (section: Section): number => section.sentences.reduce((sum, sentence) => sum + charLength(sentence), 0);
10
+
11
+ /**
12
+ * 節あたりの「密度」を見る。件数ではない。
13
+ *
14
+ * 件数で数えると、節が長いほど当たる。実文書で測ると、1095 字に 2 箇所の節と
15
+ * 557 字に 9 箇所の節が同じ「多すぎる」になっていた。読者が受け取る印象は逆なのに。
16
+ *
17
+ * rule の名前(bold-density)が最初から密度と言っていた。件数で実装していたのが誤り。
18
+ */
4
19
  export const countPerSection: Detector = (doc, options): Finding[] =>
5
20
  doc.sections
6
- .filter((section) => section.strongCount > options.limit)
7
- .map((section) => ({
21
+ .map((section) => ({ section, chars: charsOf(section) }))
22
+ .filter(({ chars }) => chars >= MIN_CHARS)
23
+ .map(({ section, chars }) => ({ section, chars, density: Math.round((section.strongCount / chars) * PER) }))
24
+ .filter(({ density }) => density > options.limit)
25
+ .map(({ section, chars, density }) => ({
8
26
  rule: "bold-density",
9
27
  severity: "warning",
10
28
  line: 0,
11
29
  column: 0,
12
30
  // 節全体を引くと段落をまたいで読めなくなる。最初の文だけ見せて場所を示す。
13
- quote: section.firstSentence?.text.trim() ?? "",
14
- values: { count: section.strongCount, limit: options.limit, offset: section.firstSentence?.span.start ?? section.span.start },
31
+ quote: section.firstSentence === undefined ? "" : proseText(section.firstSentence),
32
+ values: {
33
+ count: density,
34
+ limit: options.limit,
35
+ bolds: section.strongCount,
36
+ chars,
37
+ offset: section.firstSentence?.span.start ?? section.span.start,
38
+ },
15
39
  }));