chaffjs 0.0.1 → 0.2.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 (109) hide show
  1. package/dist/checks.d.ts +20 -0
  2. package/dist/checks.d.ts.map +1 -0
  3. package/dist/checks.js +58 -0
  4. package/dist/checks.js.map +1 -0
  5. package/dist/cli.d.ts.map +1 -1
  6. package/dist/cli.js +14 -1
  7. package/dist/cli.js.map +1 -1
  8. package/dist/commands/eval.d.ts +15 -0
  9. package/dist/commands/eval.d.ts.map +1 -0
  10. package/dist/commands/eval.js +44 -0
  11. package/dist/commands/eval.js.map +1 -0
  12. package/dist/commands/test.d.ts +21 -0
  13. package/dist/commands/test.d.ts.map +1 -0
  14. package/dist/commands/test.js +70 -0
  15. package/dist/commands/test.js.map +1 -0
  16. package/dist/config/by-path.d.ts +15 -0
  17. package/dist/config/by-path.d.ts.map +1 -0
  18. package/dist/config/by-path.js +41 -0
  19. package/dist/config/by-path.js.map +1 -0
  20. package/dist/config/load.d.ts +8 -0
  21. package/dist/config/load.d.ts.map +1 -1
  22. package/dist/config/load.js +34 -1
  23. package/dist/config/load.js.map +1 -1
  24. package/dist/detectors/count-per-section.d.ts +8 -1
  25. package/dist/detectors/count-per-section.d.ts.map +1 -1
  26. package/dist/detectors/count-per-section.js +26 -5
  27. package/dist/detectors/count-per-section.js.map +1 -1
  28. package/dist/detectors/sentence-length.d.ts.map +1 -1
  29. package/dist/detectors/sentence-length.js +3 -9
  30. package/dist/detectors/sentence-length.js.map +1 -1
  31. package/dist/detectors/sentence-rhythm.d.ts.map +1 -1
  32. package/dist/detectors/sentence-rhythm.js +3 -2
  33. package/dist/detectors/sentence-rhythm.js.map +1 -1
  34. package/dist/document.d.ts.map +1 -1
  35. package/dist/document.js +21 -6
  36. package/dist/document.js.map +1 -1
  37. package/dist/eval.d.ts +31 -0
  38. package/dist/eval.d.ts.map +1 -0
  39. package/dist/eval.js +62 -0
  40. package/dist/eval.js.map +1 -0
  41. package/dist/genre.d.ts.map +1 -1
  42. package/dist/genre.js +8 -1
  43. package/dist/genre.js.map +1 -1
  44. package/dist/init.d.ts.map +1 -1
  45. package/dist/init.js +2 -0
  46. package/dist/init.js.map +1 -1
  47. package/dist/judge.d.ts +40 -0
  48. package/dist/judge.d.ts.map +1 -0
  49. package/dist/judge.js +0 -0
  50. package/dist/judge.js.map +1 -0
  51. package/dist/measure.d.ts +15 -0
  52. package/dist/measure.d.ts.map +1 -0
  53. package/dist/measure.js +16 -0
  54. package/dist/measure.js.map +1 -0
  55. package/dist/plugin.d.ts +2 -0
  56. package/dist/plugin.d.ts.map +1 -1
  57. package/dist/render/eval.d.ts +7 -0
  58. package/dist/render/eval.d.ts.map +1 -0
  59. package/dist/render/eval.js +59 -0
  60. package/dist/render/eval.js.map +1 -0
  61. package/dist/render/semantic.d.ts +7 -0
  62. package/dist/render/semantic.d.ts.map +1 -0
  63. package/dist/render/semantic.js +50 -0
  64. package/dist/render/semantic.js.map +1 -0
  65. package/dist/rule-load.d.ts.map +1 -1
  66. package/dist/rule-load.js +12 -3
  67. package/dist/rule-load.js.map +1 -1
  68. package/dist/run-semantic.d.ts +17 -0
  69. package/dist/run-semantic.d.ts.map +1 -0
  70. package/dist/run-semantic.js +71 -0
  71. package/dist/run-semantic.js.map +1 -0
  72. package/dist/run.d.ts.map +1 -1
  73. package/dist/run.js +4 -0
  74. package/dist/run.js.map +1 -1
  75. package/dist/semantic.d.ts +25 -0
  76. package/dist/semantic.d.ts.map +1 -0
  77. package/dist/semantic.js +55 -0
  78. package/dist/semantic.js.map +1 -0
  79. package/package.json +4 -3
  80. package/rules/bold-density.yaml +8 -6
  81. package/rules/empty-conclusion.yaml +37 -0
  82. package/rules/empty-intensifier.yaml +1 -1
  83. package/rules/heading-echo.yaml +1 -1
  84. package/rules/max-sentence-length.yaml +1 -1
  85. package/rules/repeated-sentence-head.yaml +1 -1
  86. package/rules/risk-disclosure.yaml +41 -0
  87. package/rules/unsourced-number.yaml +40 -0
  88. package/src/checks.ts +74 -0
  89. package/src/cli.ts +12 -1
  90. package/src/commands/eval.ts +64 -0
  91. package/src/commands/test.ts +89 -0
  92. package/src/config/by-path.ts +47 -0
  93. package/src/config/load.ts +41 -1
  94. package/src/detectors/count-per-section.ts +30 -6
  95. package/src/detectors/sentence-length.ts +4 -10
  96. package/src/detectors/sentence-rhythm.ts +3 -2
  97. package/src/document.ts +22 -6
  98. package/src/eval.ts +80 -0
  99. package/src/genre.ts +8 -1
  100. package/src/init.ts +2 -0
  101. package/src/judge.ts +0 -0
  102. package/src/measure.ts +21 -0
  103. package/src/plugin.ts +2 -0
  104. package/src/render/eval.ts +62 -0
  105. package/src/render/semantic.ts +60 -0
  106. package/src/rule-load.ts +14 -3
  107. package/src/run-semantic.ts +107 -0
  108. package/src/run.ts +4 -0
  109. package/src/semantic.ts +74 -0
package/src/checks.ts ADDED
@@ -0,0 +1,74 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { parse } from "yaml";
3
+ import type { Level, Severity } from "./plugin.ts";
4
+
5
+ export const CHECKS_FILE = "checks.yaml";
6
+
7
+ /**
8
+ * 利用者が自然文で足す検査。キーは英語、中身は書き手の言葉。
9
+ * プログラムは書かせない。spec §18.3。
10
+ */
11
+ export type UserCheck = {
12
+ readonly id: string;
13
+ readonly name: string;
14
+ readonly use_for: readonly string[];
15
+ readonly check: string;
16
+ readonly look_at: string | undefined;
17
+ readonly level: Level;
18
+ readonly how_to_fix: string;
19
+ readonly source: string;
20
+ };
21
+
22
+ const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
23
+
24
+ const str = (value: unknown): string | undefined => (typeof value === "string" ? value.trim() : undefined);
25
+
26
+ const LEVELS: readonly Level[] = ["strict", "normal", "relaxed", "off"];
27
+ const asLevel = (value: unknown): Level => LEVELS.find((level) => level === value) ?? "normal";
28
+
29
+ /** id は名前から作る。利用者に id を書かせない。 */
30
+ const idOf = (name: string, index: number): string => {
31
+ const slug = name
32
+ .toLowerCase()
33
+ .replace(/[^a-z0-9]+/gu, "-")
34
+ .replace(/^-|-$/gu, "");
35
+ return slug.length > 0 ? `check:${slug}` : `check:${index + 1}`;
36
+ };
37
+
38
+ /** use_for は 1 つでも配列でも書ける。書かなければ全ジャンル。 */
39
+ const useForOf = (value: unknown): string[] => {
40
+ if (typeof value === "string") return [value];
41
+ if (Array.isArray(value)) return value.map((entry) => String(entry));
42
+ return ["blog", "business"];
43
+ };
44
+
45
+ const toCheck = (raw: unknown, index: number, source: string): UserCheck | undefined => {
46
+ if (!isRecord(raw)) return undefined;
47
+ const name = str(raw["name"]);
48
+ const check = str(raw["check"]);
49
+ if (name === undefined || check === undefined) return undefined;
50
+ return {
51
+ id: idOf(name, index),
52
+ name,
53
+ use_for: useForOf(raw["use_for"]),
54
+ check,
55
+ look_at: str(raw["look_at"]),
56
+ level: asLevel(raw["level"]),
57
+ how_to_fix: str(raw["how_to_fix"]) ?? "",
58
+ source,
59
+ };
60
+ };
61
+
62
+ export const loadChecks = (path: string): UserCheck[] => {
63
+ if (!existsSync(path)) return [];
64
+ const raw: unknown = parse(readFileSync(path, "utf8"));
65
+ if (!isRecord(raw) || !Array.isArray(raw["checks"])) return [];
66
+ return raw["checks"].map((entry, index) => toCheck(entry, index, path)).filter((entry) => entry !== undefined);
67
+ };
68
+
69
+ /** 4 語を severity にする。L4 は件数ではなく深刻度を段で持つ。 */
70
+ export const severityOf = (level: Level): Severity => {
71
+ if (level === "strict") return "error";
72
+ if (level === "relaxed") return "info";
73
+ return "warning";
74
+ };
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,6 +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";
15
+ import { runEval } from "./commands/eval.ts";
16
+ import { runTest } from "./commands/test.ts";
14
17
  import { frontMatterGenre, guessGenre } from "./genre.ts";
15
18
  import { GENRES, runInit } from "./init.ts";
16
19
  import { loadRules } from "./rule-load.ts";
@@ -26,7 +29,9 @@ const USAGE = `chaff — 文章の読みにくいところを見つけます。
26
29
 
27
30
  chaff <file|dir|glob>... 検査する(設定も API key も要りません)
28
31
  chaff . この場所の Markdown を全部
32
+ chaff test <file|dir>... 意味を読む検査も動かす(API key が要ります)
29
33
  chaff init chaff.yaml を作る
34
+ chaff eval <dir> 手元の文書で閾値を測り直す
30
35
  chaff explain <rule> そのルールの意図と根拠を読む
31
36
  chaff genres ジャンルの一覧
32
37
  chaff rules --json いまの設定を JSON で出す(AI に渡す用)
@@ -47,6 +52,9 @@ const USAGE = `chaff — 文章の読みにくいところを見つけます。
47
52
  const readConfig = (): Config => (existsSync(join(process.cwd(), CONFIG_FILE)) ? loadConfig(join(process.cwd(), CONFIG_FILE)) : EMPTY);
48
53
 
49
54
  const resolveGenre = (path: string, source: string, config: Config): { genre: string; from: string } => {
55
+ // パスごとの上書きが最優先。「全体はこう、ここだけは違う」を書けるようにする。
56
+ const override = applyByPath(config.byPath, config.baseDir, path);
57
+ if (override.genre !== undefined) return { genre: override.genre, from: `${CONFIG_FILE} の by_path` };
50
58
  if (config.genre !== undefined) return { genre: config.genre, from: CONFIG_FILE };
51
59
  const guess = guessGenre(path, source, frontMatterGenre(source));
52
60
  return guess === undefined ? { genre: "blog/tech", from: "既定" } : { genre: guess.genre, from: guess.from };
@@ -81,7 +89,7 @@ const headerFor = (path: string, genre: string, from: string, language: string,
81
89
 
82
90
  const inspect = async (path: string, config: Config, argv: readonly string[]): Promise<Inspected> => {
83
91
  const source = await readFile(path, "utf8");
84
- const language = config.language ?? guessLanguage(source).language;
92
+ const language = applyByPath(config.byPath, config.baseDir, path).language ?? config.language ?? guessLanguage(source).language;
85
93
  const adapter = await loadAdapter(language);
86
94
  const { genre, from } = resolveGenre(path, source, config);
87
95
  const doc = buildDocument(path, source, adapter);
@@ -175,6 +183,7 @@ const explain = (ruleId: string | undefined): number => {
175
183
 
176
184
  /** 閾値の単位。explain で「4 とは何の 4 か」を示すため。 */
177
185
  const unitOf = (ruleId: string, language: string): string => {
186
+ if (ruleId === "bold-density") return "1000 字あたりの箇所数";
178
187
  if (ruleId !== "max-sentence-length") return "回";
179
188
  return language === "ja" ? "文字" : "語";
180
189
  };
@@ -239,6 +248,8 @@ const HANDLERS: Readonly<Record<string, Handler>> = {
239
248
  genres: showGenres,
240
249
  rules: showRules,
241
250
  explain: (argv) => explain(argv[1]),
251
+ eval: (argv) => runEval(positional(argv), argv, { config: readConfig(), resolveGenre, flag }),
252
+ test: (argv) => runTest(positional(argv), argv, { config: readConfig(), resolveGenre, inspect }),
242
253
  baseline: (argv) => runBaseline(positional(argv), argv),
243
254
  suppressions: (argv) => runSuppressions(positional(argv), argv),
244
255
  relax: (argv) => changeSetting("relaxed", argv[1], flag(argv, "--why")),
@@ -0,0 +1,64 @@
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 type { Config } from "../config/load.ts";
11
+
12
+ export type Context = {
13
+ readonly config: Config;
14
+ readonly resolveGenre: (path: string, source: string, config: Config) => { genre: string; from: string };
15
+ readonly flag: (argv: readonly string[], name: string) => string | undefined;
16
+ };
17
+
18
+ /**
19
+ * 閾値の較正。手元の文書を「人間の良い文書」として扱い、どの閾値なら
20
+ * 誤検知が目標を下回るかを測る。書き換えはしない。
21
+ */
22
+ export const runEval = async (targets: readonly string[], argv: readonly string[], context: Context): Promise<number> => {
23
+ const paths = collectTargets(targets.length > 0 ? targets : ["."]);
24
+ if (paths.length === 0) {
25
+ console.error(`Markdown が 1 つも見つかりませんでした: ${targets.join(", ")}`);
26
+ return 1;
27
+ }
28
+ const { config, resolveGenre, flag } = context;
29
+ const only = flag(argv, "--rule");
30
+ const docs = await Promise.all(
31
+ paths.map(async (path) => {
32
+ const source = await readFile(path, "utf8");
33
+ const language = applyByPath(config.byPath, config.baseDir, path).language ?? config.language ?? guessLanguage(source).language;
34
+ const adapter = await loadAdapter(language);
35
+ return { doc: buildDocument(path, source, adapter), language, genre: resolveGenre(path, source, config).genre };
36
+ }),
37
+ );
38
+ const language = docs[0]?.language ?? "ja";
39
+ const genre = docs[0]?.genre ?? "blog/tech";
40
+ // 言語とジャンルが混ざった corpus は測れない。閾値の単位が違うため。
41
+ const mixed = new Set(docs.map((entry) => `${entry.language}/${entry.genre}`));
42
+ if (mixed.size > 1) {
43
+ console.error(`言語かジャンルが混ざっています: ${[...mixed].join(", ")}\n1 つに絞って測ってください(例: npx chaff eval examples/blog-ja/)。`);
44
+ return 1;
45
+ }
46
+ const rules = loadRules(language).filter((rule) => only === undefined || rule.id === only);
47
+ if (rules.length === 0) {
48
+ console.error(`${only ?? "(名前なし)"} という rule はありません。`);
49
+ return 1;
50
+ }
51
+ console.log(
52
+ renderEval(
53
+ evaluate(
54
+ docs.map((entry) => entry.doc),
55
+ rules,
56
+ genre,
57
+ language,
58
+ ),
59
+ docs.length,
60
+ paths.length,
61
+ ),
62
+ );
63
+ return 0;
64
+ };
@@ -0,0 +1,89 @@
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, hasCredentials, isAuthFailure } from "../judge.ts";
11
+ import { runSemantic } from "../run-semantic.ts";
12
+ import { MACHINE_BANNER, renderSemantic } from "../render/semantic.ts";
13
+ import type { Config } from "../config/load.ts";
14
+ import type { Finding, RuleDefinition } from "../plugin.ts";
15
+
16
+ export type TestContext = {
17
+ readonly config: Config;
18
+ readonly resolveGenre: (path: string, source: string, config: Config) => { genre: string; from: string };
19
+ readonly inspect: (path: string, config: Config, argv: readonly string[]) => Promise<{ text: string; outcome: { findings: readonly Finding[] } }>;
20
+ };
21
+
22
+ type Judged = { path: string; outcome: Awaited<ReturnType<typeof runSemantic>>; rules: RuleDefinition[]; language: string };
23
+
24
+ const judgeAll = async (
25
+ paths: readonly string[],
26
+ config: Config,
27
+ checks: readonly UserCheck[],
28
+ options: Parameters<typeof runSemantic>[5],
29
+ resolveGenre: TestContext["resolveGenre"],
30
+ ): Promise<Judged[]> =>
31
+ 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 doc = buildDocument(path, source, adapter);
37
+ const { genre } = resolveGenre(path, source, config);
38
+ const rules = loadRules(language);
39
+ return { path, outcome: await runSemantic(doc, rules, checks, config.rules, genre, options), rules, language };
40
+ }),
41
+ );
42
+
43
+ /**
44
+ * 機械と AI を見出しで分けて出す。読む人が「これは揺れる判定か」を知らないと、
45
+ * AI の誤検知に振り回される。workflow spec §7、samples/README の軸 2。
46
+ */
47
+ export const runTest = async (targets: readonly string[], argv: readonly string[], context: TestContext): Promise<number> => {
48
+ const paths = collectTargets(targets.length > 0 ? targets : ["."]);
49
+ if (paths.length === 0) {
50
+ console.error(`Markdown が 1 つも見つかりませんでした: ${targets.join(", ")}`);
51
+ return 1;
52
+ }
53
+ const { config, resolveGenre, inspect } = context;
54
+ const checks = loadChecks(join(process.cwd(), CHECKS_FILE));
55
+ const results = await Promise.all(paths.map((path) => inspect(path, config, argv)));
56
+ results.forEach((result) => {
57
+ console.log(result.text.replace(/\n/u, `\n${MACHINE_BANNER.join("\n")}`));
58
+ });
59
+ const machineOnly = results.some((result) => result.outcome.findings.some((finding) => finding.severity === "error")) ? 1 : 0;
60
+ const noCredentials = [
61
+ "",
62
+ " 意味を読む検査は動かしていません。Anthropic の認証情報がありません。",
63
+ " ANTHROPIC_API_KEY を設定するか、ant auth login を実行してください。",
64
+ " 機械による判定はすべて動いています。",
65
+ "",
66
+ ].join("\n");
67
+ // 呼んでから落ちるのを待たない。認証が無いときの SDK の例外は型で判別できない。
68
+ if (!hasCredentials()) {
69
+ console.log(noCredentials);
70
+ return machineOnly;
71
+ }
72
+ const options = { model: config.aiModel, cacheDir: join(process.cwd(), CACHE_DIR), confidenceThreshold: config.confidenceThreshold };
73
+ const semantic = await judgeAll(paths, config, checks, options, resolveGenre).catch((error: unknown) => {
74
+ if (!isAuthFailure(error)) throw error;
75
+ console.log(noCredentials);
76
+ return undefined;
77
+ });
78
+ if (semantic === undefined) return machineOnly;
79
+ semantic.forEach((entry) => {
80
+ if (entry.outcome.findings.length === 0 && entry.outcome.asked === 0) return;
81
+ console.log([`${entry.path}`, ...renderSemantic(entry.outcome, entry.rules, checks, entry.language)].join("\n"));
82
+ });
83
+ const machine = results.flatMap((result) => result.outcome.findings);
84
+ const ai = semantic.flatMap((entry) => entry.outcome.findings);
85
+ console.log(
86
+ ["", "─".repeat(60), "", ` 機械 ${machine.length} 件 / AI ${ai.length} 件`, " 文章は書き換えていません。直すのは書いた人です。", ""].join("\n"),
87
+ );
88
+ return [...machine, ...ai].some((finding) => finding.severity === "error") ? 1 : 0;
89
+ };
@@ -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,7 +1,9 @@
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";
4
5
  import type { Level } from "../plugin.ts";
6
+ import type { PathRule } from "./by-path.ts";
5
7
 
6
8
  export const CONFIG_FILE = "chaff.yaml";
7
9
 
@@ -11,9 +13,27 @@ export type Config = {
11
13
  readonly rules: Readonly<Record<string, Level>>;
12
14
  readonly experimental: boolean;
13
15
  readonly path: string | undefined;
16
+ readonly aiModel: string;
17
+ readonly confidenceThreshold: number;
18
+ /** パスごとの上書き。設定ファイルのある場所からの相対で照合する。 */
19
+ readonly byPath: readonly PathRule[];
20
+ readonly baseDir: string;
14
21
  };
15
22
 
16
- export const EMPTY: Config = { genre: undefined, language: undefined, rules: {}, experimental: false, path: undefined };
23
+ /** 判定の質が誤検知に直結するので、既定は最上位のモデル。cost は絞り込みで削る。spec §14。 */
24
+ export const DEFAULT_MODEL = "claude-opus-5";
25
+
26
+ export const EMPTY: Config = {
27
+ genre: undefined,
28
+ language: undefined,
29
+ rules: {},
30
+ experimental: false,
31
+ path: undefined,
32
+ aiModel: DEFAULT_MODEL,
33
+ confidenceThreshold: 0.7,
34
+ byPath: [],
35
+ baseDir: process.cwd(),
36
+ };
17
37
 
18
38
  const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
19
39
 
@@ -24,6 +44,22 @@ const rulesOf = (raw: unknown): Record<string, Level> => {
24
44
 
25
45
  const str = (value: unknown): string | undefined => (typeof value === "string" ? value : undefined);
26
46
 
47
+ /** files は 1 つの文字列でも配列でも書ける。 */
48
+ const globsOf = (value: unknown): string[] => {
49
+ if (typeof value === "string") return [value];
50
+ return Array.isArray(value) ? value.map((entry) => String(entry)) : [];
51
+ };
52
+
53
+ const toPathRule = (raw: unknown): PathRule | undefined => {
54
+ if (!isRecord(raw)) return undefined;
55
+ const files = raw["files"];
56
+ const globs = globsOf(files);
57
+ if (globs.length === 0) return undefined;
58
+ return { files: globs, genre: str(raw["genre"]), language: str(raw["language"]) };
59
+ };
60
+
61
+ const byPathOf = (raw: unknown): PathRule[] => (Array.isArray(raw) ? raw.map(toPathRule).filter((rule) => rule !== undefined) : []);
62
+
27
63
  /** 設定ファイルが無くても動く。あっても、既定から変えたものだけが書かれている。spec §18。 */
28
64
  export const loadConfig = (path: string): Config => {
29
65
  const raw: unknown = parse(readFileSync(path, "utf8"));
@@ -34,5 +70,9 @@ export const loadConfig = (path: string): Config => {
34
70
  rules: rulesOf(raw["rules"]),
35
71
  experimental: raw["experimental"] === true,
36
72
  path,
73
+ aiModel: str(raw["ai_model"]) ?? DEFAULT_MODEL,
74
+ confidenceThreshold: typeof raw["confidence_threshold"] === "number" ? raw["confidence_threshold"] : 0.7,
75
+ byPath: byPathOf(raw["by_path"]),
76
+ baseDir: dirname(path),
37
77
  };
38
78
  };
@@ -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
  }));
@@ -1,21 +1,15 @@
1
- import type { Detector, Finding, ProseDocument, Sentence } from "../plugin.ts";
2
-
3
- /** 日本語は文字、英語は語。単位は adapter が宣言する。spec §7。 */
4
- const measure = (doc: ProseDocument, sentence: Sentence): number => {
5
- const text = sentence.text.trim();
6
- if (doc.lengthUnit === "char") return text.length;
7
- return text.split(/\s+/u).filter((word) => word.length > 0).length;
8
- };
1
+ import { lengthOf, proseText } from "../measure.ts";
2
+ import type { Detector, Finding } from "../plugin.ts";
9
3
 
10
4
  export const sentenceLength: Detector = (doc, options): Finding[] =>
11
5
  doc.sentences
12
- .map((sentence) => ({ sentence, size: measure(doc, sentence) }))
6
+ .map((sentence) => ({ sentence, size: lengthOf(sentence, doc.lengthUnit) }))
13
7
  .filter(({ size }) => size > options.limit)
14
8
  .map(({ sentence, size }) => ({
15
9
  rule: "max-sentence-length",
16
10
  severity: "warning",
17
11
  line: 0,
18
12
  column: 0,
19
- quote: sentence.text.trim(),
13
+ quote: proseText(sentence),
20
14
  values: { count: size, limit: options.limit, offset: sentence.span.start },
21
15
  }));
@@ -1,3 +1,4 @@
1
+ import { charLength, proseText } from "../measure.ts";
1
2
  import type { Detector, Finding } from "../plugin.ts";
2
3
 
3
4
  /** 文が少ないと変動係数は当てにならない。ここを下回る文書は見ない。 */
@@ -11,7 +12,7 @@ const coefficientOfVariation = (sizes: readonly number[]): number => {
11
12
  };
12
13
 
13
14
  export const sentenceRhythm: Detector = (doc, options): Finding[] => {
14
- const sizes = doc.sentences.map((sentence) => sentence.text.trim().length);
15
+ const sizes = doc.sentences.map((sentence) => charLength(sentence));
15
16
  if (sizes.length < MIN_SENTENCES) return [];
16
17
  const spread = Math.round(coefficientOfVariation(sizes) * 100);
17
18
  if (spread >= options.limit) return [];
@@ -21,7 +22,7 @@ export const sentenceRhythm: Detector = (doc, options): Finding[] => {
21
22
  severity: "warning",
22
23
  line: 0,
23
24
  column: 0,
24
- quote: doc.sentences[0]?.text.trim() ?? "",
25
+ quote: doc.sentences[0] === undefined ? "" : proseText(doc.sentences[0]),
25
26
  values: { count: spread, limit: options.limit, offset: doc.sentences[0]?.span.start ?? 0 },
26
27
  },
27
28
  ];
package/src/document.ts CHANGED
@@ -13,8 +13,14 @@ type Node = {
13
13
  readonly children?: readonly Node[] | undefined;
14
14
  };
15
15
 
16
- /** 本文として数えないもの。コードは文章ではなく、表は文章の形をしていない。 */
17
- const NOT_PROSE = new Set(["code", "inlineCode", "html", "yaml", "toml", "table", "thematicBreak", "definition", "image", "imageReference"]);
16
+ /**
17
+ * 本文として数えないもの。
18
+ *
19
+ * コードは文章ではなく、表は文章の形をしていない。
20
+ * **引用は自分の文章ではない。** chaff が言えるのは「2 文に割ってください」までで、
21
+ * 引用文にそれはできない。直せないものを指摘しても、書いた人は動けない。
22
+ */
23
+ const NOT_PROSE = new Set(["code", "inlineCode", "html", "yaml", "toml", "table", "blockquote", "thematicBreak", "definition", "image", "imageReference"]);
18
24
 
19
25
  const spanOf = (node: Node): Span | undefined => {
20
26
  const start = node.position?.start.offset;
@@ -80,12 +86,21 @@ const headingsOf = (root: Node, source: string): Heading[] => {
80
86
  return found;
81
87
  };
82
88
 
83
- const strongSpans = (root: Node): Span[] => {
89
+ const startsInside = (span: Span, regions: readonly Span[]): boolean => regions.some((region) => span.start >= region.start && span.start < region.end);
90
+
91
+ /**
92
+ * 本文の強調だけを数える。
93
+ *
94
+ * 表のセルや見出しの中の太字はラベルであって強調ではない。
95
+ * 「太字は読者の目を止める道具」という bold-density の理屈が当てはまらない。
96
+ * 「本文でないもの」の定義は覆う範囲(collectMasks)に 1 つだけ置き、ここはそれを使う。
97
+ */
98
+ const strongSpans = (root: Node, masked: readonly Span[]): Span[] => {
84
99
  const found: Span[] = [];
85
100
  walk(root, (node) => {
86
101
  if (node.type !== "strong") return;
87
102
  const span = spanOf(node);
88
- if (span !== undefined) found.push(span);
103
+ if (span !== undefined && !startsInside(span, masked)) found.push(span);
89
104
  });
90
105
  return found;
91
106
  };
@@ -140,14 +155,15 @@ const sectionsOf = (headings: readonly Heading[], sentences: readonly Sentence[]
140
155
 
141
156
  export const buildDocument = (path: string, source: string, adapter: LanguageAdapter): ProseDocument => {
142
157
  const root = parse(source);
143
- const prose = maskSpans(source, collectMasks(root));
158
+ const masked = collectMasks(root);
159
+ const prose = maskSpans(source, masked);
144
160
  const sentences = sentencesOf(prose, paragraphSpans(root), adapter);
145
161
  return {
146
162
  path,
147
163
  source,
148
164
  language: adapter.id,
149
165
  lengthUnit: adapter.capabilities.lengthUnit,
150
- sections: sectionsOf(headingsOf(root, source), sentences, strongSpans(root), source.length),
166
+ sections: sectionsOf(headingsOf(root, source), sentences, strongSpans(root, masked), source.length),
151
167
  sentences,
152
168
  lexicons: adapter.lexicons,
153
169
  };
package/src/eval.ts ADDED
@@ -0,0 +1,80 @@
1
+ import { DETECTORS } from "./detectors/index.ts";
2
+ import { charLength } from "./measure.ts";
3
+ import type { ProseDocument, RuleDefinition } from "./plugin.ts";
4
+
5
+ /**
6
+ * 閾値の較正。
7
+ *
8
+ * examples/ に置いた文書は、実際に公開した「人間の良い文書」。そこで多く発火する
9
+ * rule は、閾値が現実に合っていない疑いがある。spec §21 の目標(人間の良い文書での
10
+ * 誤検知率 5% 未満)を、そのまま推奨の判断に使う。
11
+ *
12
+ * これは較正であって正解ではない。corpus が変われば答えも変わる。だから自動では
13
+ * 適用せず、提示して選ばせる。
14
+ */
15
+ export const TARGET_HIT_RATE = 0.05;
16
+
17
+ /** 文書単位の割合は corpus が小さいと粗い。文字数あたりの密度を併記する。 */
18
+ export type Point = { readonly limit: number; readonly findings: number; readonly documents: number; readonly per10k: number };
19
+
20
+ /** 5% を表せるだけの文書数。これを下回ると割合が「0 か全部」になる。 */
21
+ export const MIN_CORPUS = 20;
22
+
23
+ export type RuleReport = {
24
+ readonly rule: string;
25
+ readonly name: string;
26
+ readonly current: number;
27
+ readonly sweep: readonly Point[];
28
+ readonly recommended: number | undefined;
29
+ readonly total: number;
30
+ };
31
+
32
+ /** strict が relaxed より大きい rule は、値が大きいほど厳しい(sentence-rhythm)。 */
33
+ const stricterIsHigher = (rule: RuleDefinition): boolean => (rule.levels.strict ?? 0) > (rule.levels.relaxed ?? 0);
34
+
35
+ const MULTIPLIERS = [0.5, 0.7, 0.85, 1, 1.25, 1.5, 2, 3];
36
+
37
+ /** rule 自身が持つ段を基点に掃引する。rule ごとに単位が違うので、倍率で散らす。 */
38
+ const candidates = (rule: RuleDefinition): number[] => {
39
+ const base = rule.levels.normal ?? 1;
40
+ const fromLevels = [rule.levels.strict, rule.levels.normal, rule.levels.relaxed].filter((value) => value !== undefined);
41
+ const scaled = MULTIPLIERS.map((factor) => Math.max(1, Math.round(base * factor)));
42
+ return [...new Set([...fromLevels, ...scaled])].sort((left, right) => left - right);
43
+ };
44
+
45
+ const countAt = (docs: readonly ProseDocument[], rule: RuleDefinition, limit: number): Point => {
46
+ const detector = DETECTORS[rule.how_to_find];
47
+ if (detector === undefined) return { limit, findings: 0, documents: 0, per10k: 0 };
48
+ const perDoc = docs.map((doc) => {
49
+ const lexicon = rule.word_list === undefined ? undefined : doc.lexicons[rule.word_list];
50
+ if (rule.word_list !== undefined && lexicon === undefined) return 0;
51
+ return detector(doc, { limit, lexicon, where: rule.where }).length;
52
+ });
53
+ const findings = perDoc.reduce((sum, count) => sum + count, 0);
54
+ const chars = docs.reduce((sum, doc) => sum + doc.sentences.reduce((inner, sentence) => inner + charLength(sentence), 0), 0);
55
+ return { limit, findings, documents: perDoc.filter((count) => count > 0).length, per10k: chars === 0 ? 0 : (findings / chars) * 10000 };
56
+ };
57
+
58
+ /** 目標の誤検知率を満たす中で、いちばん厳しい閾値を選ぶ。 */
59
+ const recommend = (rule: RuleDefinition, sweep: readonly Point[], total: number): number | undefined => {
60
+ const ok = sweep.filter((point) => point.documents / Math.max(1, total) <= TARGET_HIT_RATE);
61
+ if (ok.length === 0) return undefined;
62
+ const strictest = stricterIsHigher(rule) ? Math.max(...ok.map((p) => p.limit)) : Math.min(...ok.map((p) => p.limit));
63
+ return strictest;
64
+ };
65
+
66
+ export const evaluate = (docs: readonly ProseDocument[], rules: readonly RuleDefinition[], genre: string, language: string): RuleReport[] =>
67
+ rules
68
+ .filter((rule) => rule.layer !== "L4" && rule.use_for.some((target) => genre.startsWith(target)))
69
+ .map((rule) => {
70
+ const sweep = candidates(rule).map((limit) => countAt(docs, rule, limit));
71
+ const current = rule.levels.normal ?? 1;
72
+ return {
73
+ rule: rule.id,
74
+ name: rule.name[language] ?? rule.id,
75
+ current,
76
+ sweep,
77
+ recommended: recommend(rule, sweep, docs.length),
78
+ total: docs.length,
79
+ };
80
+ });