chaffjs 0.2.0 → 0.4.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 (183) 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 +9 -5
  15. package/dist/cli.js.map +1 -1
  16. package/dist/commands/eval.d.ts.map +1 -1
  17. package/dist/commands/eval.js +5 -2
  18. package/dist/commands/eval.js.map +1 -1
  19. package/dist/commands/test.d.ts.map +1 -1
  20. package/dist/commands/test.js +62 -16
  21. package/dist/commands/test.js.map +1 -1
  22. package/dist/config/load.d.ts +8 -1
  23. package/dist/config/load.d.ts.map +1 -1
  24. package/dist/config/load.js +16 -3
  25. package/dist/config/load.js.map +1 -1
  26. package/dist/detectors/agentless-passive.d.ts +3 -0
  27. package/dist/detectors/agentless-passive.d.ts.map +1 -0
  28. package/dist/detectors/agentless-passive.js +31 -0
  29. package/dist/detectors/agentless-passive.js.map +1 -0
  30. package/dist/detectors/char-shape.d.ts +12 -0
  31. package/dist/detectors/char-shape.d.ts.map +1 -0
  32. package/dist/detectors/char-shape.js +39 -0
  33. package/dist/detectors/char-shape.js.map +1 -0
  34. package/dist/detectors/en-shape.d.ts +19 -0
  35. package/dist/detectors/en-shape.d.ts.map +1 -0
  36. package/dist/detectors/en-shape.js +176 -0
  37. package/dist/detectors/en-shape.js.map +1 -0
  38. package/dist/detectors/index.d.ts.map +1 -1
  39. package/dist/detectors/index.js +37 -0
  40. package/dist/detectors/index.js.map +1 -1
  41. package/dist/detectors/lexicon.d.ts +15 -0
  42. package/dist/detectors/lexicon.d.ts.map +1 -0
  43. package/dist/detectors/lexicon.js +109 -0
  44. package/dist/detectors/lexicon.js.map +1 -0
  45. package/dist/detectors/repeated-head.d.ts.map +1 -1
  46. package/dist/detectors/repeated-head.js +17 -5
  47. package/dist/detectors/repeated-head.js.map +1 -1
  48. package/dist/detectors/sentence-ending.d.ts +3 -0
  49. package/dist/detectors/sentence-ending.d.ts.map +1 -0
  50. package/dist/detectors/sentence-ending.js +34 -0
  51. package/dist/detectors/sentence-ending.js.map +1 -0
  52. package/dist/detectors/signals.d.ts +6 -0
  53. package/dist/detectors/signals.d.ts.map +1 -0
  54. package/dist/detectors/signals.js +183 -0
  55. package/dist/detectors/signals.js.map +1 -0
  56. package/dist/detectors/structure.d.ts +24 -0
  57. package/dist/detectors/structure.d.ts.map +1 -0
  58. package/dist/detectors/structure.js +111 -0
  59. package/dist/detectors/structure.js.map +1 -0
  60. package/dist/detectors/team.d.ts +17 -0
  61. package/dist/detectors/team.d.ts.map +1 -0
  62. package/dist/detectors/team.js +87 -0
  63. package/dist/detectors/team.js.map +1 -0
  64. package/dist/detectors/token-shape.d.ts +4 -0
  65. package/dist/detectors/token-shape.d.ts.map +1 -0
  66. package/dist/detectors/token-shape.js +62 -0
  67. package/dist/detectors/token-shape.js.map +1 -0
  68. package/dist/document.d.ts +11 -1
  69. package/dist/document.d.ts.map +1 -1
  70. package/dist/document.js +85 -10
  71. package/dist/document.js.map +1 -1
  72. package/dist/env.d.ts +12 -0
  73. package/dist/env.d.ts.map +1 -0
  74. package/dist/env.js +20 -0
  75. package/dist/env.js.map +1 -0
  76. package/dist/init.d.ts.map +1 -1
  77. package/dist/init.js +9 -6
  78. package/dist/init.js.map +1 -1
  79. package/dist/judge.d.ts +14 -16
  80. package/dist/judge.d.ts.map +1 -1
  81. package/dist/judge.js +0 -0
  82. package/dist/judge.js.map +1 -1
  83. package/dist/levels.d.ts +1 -9
  84. package/dist/levels.d.ts.map +1 -1
  85. package/dist/levels.js +15 -3
  86. package/dist/levels.js.map +1 -1
  87. package/dist/look-at.d.ts +18 -0
  88. package/dist/look-at.d.ts.map +1 -0
  89. package/dist/look-at.js +43 -0
  90. package/dist/look-at.js.map +1 -0
  91. package/dist/plugin.d.ts +47 -2
  92. package/dist/plugin.d.ts.map +1 -1
  93. package/dist/render/explain.d.ts +1 -1
  94. package/dist/render/explain.d.ts.map +1 -1
  95. package/dist/render/explain.js +12 -4
  96. package/dist/render/explain.js.map +1 -1
  97. package/dist/render/rules-json.d.ts.map +1 -1
  98. package/dist/render/rules-json.js +13 -5
  99. package/dist/render/rules-json.js.map +1 -1
  100. package/dist/render/semantic.d.ts +2 -1
  101. package/dist/render/semantic.d.ts.map +1 -1
  102. package/dist/render/semantic.js +35 -0
  103. package/dist/render/semantic.js.map +1 -1
  104. package/dist/rule-load.d.ts.map +1 -1
  105. package/dist/rule-load.js +27 -1
  106. package/dist/rule-load.js.map +1 -1
  107. package/dist/run-semantic.d.ts +22 -1
  108. package/dist/run-semantic.d.ts.map +1 -1
  109. package/dist/run-semantic.js +21 -10
  110. package/dist/run-semantic.js.map +1 -1
  111. package/dist/run.d.ts +6 -1
  112. package/dist/run.d.ts.map +1 -1
  113. package/dist/run.js +34 -2
  114. package/dist/run.js.map +1 -1
  115. package/dist/sentence-shape.d.ts +6 -0
  116. package/dist/sentence-shape.d.ts.map +1 -0
  117. package/dist/sentence-shape.js +15 -0
  118. package/dist/sentence-shape.js.map +1 -0
  119. package/package.json +4 -3
  120. package/rules/adverb-overuse.yaml +28 -0
  121. package/rules/agentless-passive.yaml +28 -0
  122. package/rules/ai-tell.yaml +27 -0
  123. package/rules/concrete-evidence-density.yaml +25 -0
  124. package/rules/cushion-phrase-density.yaml +26 -0
  125. package/rules/double-keigo.yaml +27 -0
  126. package/rules/emoji-density.yaml +26 -0
  127. package/rules/excessive-hedging.yaml +26 -0
  128. package/rules/expletive-construction.yaml +27 -0
  129. package/rules/hiragana-fukushi.yaml +27 -0
  130. package/rules/internal-jargon.yaml +27 -0
  131. package/rules/max-kanji-continuous.yaml +26 -0
  132. package/rules/max-paragraph-length.yaml +25 -0
  133. package/rules/max-sentence-length.yaml +22 -0
  134. package/rules/ngram-repetition.yaml +25 -0
  135. package/rules/no-doubled-joshi.yaml +28 -0
  136. package/rules/no-mixed-desumasu.yaml +29 -0
  137. package/rules/no-nakaguro-parallel.yaml +27 -0
  138. package/rules/oxford-comma-consistency.yaml +26 -0
  139. package/rules/paragraph-length-variance.yaml +25 -0
  140. package/rules/preamble-length.yaml +25 -0
  141. package/rules/proper-noun-density.yaml +26 -0
  142. package/rules/repeated-conjunction.yaml +26 -0
  143. package/rules/required-sections.yaml +26 -0
  144. package/rules/rule-of-three.yaml +25 -0
  145. package/rules/sasete-itadaku.yaml +27 -0
  146. package/rules/section-length-uniformity.yaml +25 -0
  147. package/rules/sentence-initial-conjunction-run.yaml +27 -0
  148. package/rules/taigen-dome-in-prose.yaml +27 -0
  149. package/rules/title-case-consistency.yaml +26 -0
  150. package/rules/undefined-acronym.yaml +25 -0
  151. package/rules/unqualified-superlative.yaml +26 -0
  152. package/src/backends/anthropic.ts +61 -0
  153. package/src/backends/openai.ts +46 -0
  154. package/src/backends/types.ts +38 -0
  155. package/src/cli.ts +9 -5
  156. package/src/commands/eval.ts +5 -2
  157. package/src/commands/test.ts +74 -16
  158. package/src/config/load.ts +22 -3
  159. package/src/detectors/agentless-passive.ts +34 -0
  160. package/src/detectors/char-shape.ts +47 -0
  161. package/src/detectors/en-shape.ts +187 -0
  162. package/src/detectors/index.ts +37 -0
  163. package/src/detectors/lexicon.ts +124 -0
  164. package/src/detectors/repeated-head.ts +17 -6
  165. package/src/detectors/sentence-ending.ts +37 -0
  166. package/src/detectors/signals.ts +199 -0
  167. package/src/detectors/structure.ts +119 -0
  168. package/src/detectors/team.ts +90 -0
  169. package/src/detectors/token-shape.ts +81 -0
  170. package/src/document.ts +95 -11
  171. package/src/env.ts +20 -0
  172. package/src/init.ts +9 -6
  173. package/src/judge.ts +0 -0
  174. package/src/levels.ts +16 -4
  175. package/src/look-at.ts +53 -0
  176. package/src/plugin.ts +48 -4
  177. package/src/render/explain.ts +12 -4
  178. package/src/render/rules-json.ts +16 -5
  179. package/src/render/semantic.ts +38 -1
  180. package/src/rule-load.ts +28 -1
  181. package/src/run-semantic.ts +32 -11
  182. package/src/run.ts +36 -3
  183. package/src/sentence-shape.ts +22 -0
@@ -2,16 +2,18 @@ import { readFile } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import { loadAdapter } from "../adapter-load.ts";
4
4
  import { applyByPath } from "../config/by-path.ts";
5
- import { buildDocument } from "../document.ts";
5
+ import { buildDocument, teamRules } from "../document.ts";
6
6
  import { guessLanguage } from "../detect.ts";
7
7
  import { collectTargets } from "../files.ts";
8
8
  import { loadRules } from "../rule-load.ts";
9
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";
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";
13
14
  import type { Config } from "../config/load.ts";
14
15
  import type { Finding, RuleDefinition } from "../plugin.ts";
16
+ import type { Failure } from "../backends/types.ts";
15
17
 
16
18
  export type TestContext = {
17
19
  readonly config: Config;
@@ -33,13 +35,48 @@ const judgeAll = async (
33
35
  const source = await readFile(path, "utf8");
34
36
  const language = applyByPath(config.byPath, config.baseDir, path).language ?? config.language ?? guessLanguage(source).language;
35
37
  const adapter = await loadAdapter(language);
36
- const doc = buildDocument(path, source, adapter);
38
+ const doc = buildDocument(path, source, adapter, teamRules(config));
37
39
  const { genre } = resolveGenre(path, source, config);
38
40
  const rules = loadRules(language);
39
41
  return { path, outcome: await runSemantic(doc, rules, checks, config.rules, genre, options), rules, language };
40
42
  }),
41
43
  );
42
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, teamRules(config));
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
+
43
80
  /**
44
81
  * 機械と AI を見出しで分けて出す。読む人が「これは揺れる判定か」を知らないと、
45
82
  * AI の誤検知に振り回される。workflow spec §7、samples/README の軸 2。
@@ -51,28 +88,49 @@ export const runTest = async (targets: readonly string[], argv: readonly string[
51
88
  return 1;
52
89
  }
53
90
  const { config, resolveGenre, inspect } = context;
91
+ // 鍵の置き場所で「設定したのに効かない」を起こさせない。シェルの環境変数のほうが勝つ。
92
+ const envFile = loadEnvFile(process.cwd());
54
93
  const checks = loadChecks(join(process.cwd(), CHECKS_FILE));
55
94
  const results = await Promise.all(paths.map((path) => inspect(path, config, argv)));
56
95
  results.forEach((result) => {
57
96
  console.log(result.text.replace(/\n/u, `\n${MACHINE_BANNER.join("\n")}`));
58
97
  });
59
98
  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");
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
+ }
67
118
  // 呼んでから落ちるのを待たない。認証が無いときの SDK の例外は型で判別できない。
68
- if (!hasCredentials()) {
119
+ if (!hasCredentials(config.aiBackend)) {
69
120
  console.log(noCredentials);
70
121
  return machineOnly;
71
122
  }
72
- const options = { model: config.aiModel, cacheDir: join(process.cwd(), CACHE_DIR), confidenceThreshold: config.confidenceThreshold };
123
+ const options = {
124
+ model: config.aiModel,
125
+ backend: config.aiBackend,
126
+ cacheDir: join(process.cwd(), CACHE_DIR),
127
+ confidenceThreshold: config.confidenceThreshold,
128
+ };
73
129
  const semantic = await judgeAll(paths, config, checks, options, resolveGenre).catch((error: unknown) => {
74
- if (!isAuthFailure(error)) throw error;
75
- console.log(noCredentials);
130
+ // API が返した失敗だけを受け止める。ネットワーク断やコードの誤りは握りつぶさない。
131
+ const failure = describeFailure(config.aiBackend, error);
132
+ if (failure === undefined) throw error;
133
+ console.log(failed(failure));
76
134
  return undefined;
77
135
  });
78
136
  if (semantic === undefined) return machineOnly;
@@ -2,6 +2,8 @@ import { readFileSync } from "node:fs";
2
2
  import { dirname } from "node:path";
3
3
  import { parse } from "yaml";
4
4
  import { isLevel } from "../levels.ts";
5
+ import { defaultModel } from "../judge.ts";
6
+ import { isBackend, type BackendName } from "../backends/types.ts";
5
7
  import type { Level } from "../plugin.ts";
6
8
  import type { PathRule } from "./by-path.ts";
7
9
 
@@ -9,19 +11,25 @@ export const CONFIG_FILE = "chaff.yaml";
9
11
 
10
12
  export type Config = {
11
13
  readonly genre: string | undefined;
14
+ readonly aiBackend: BackendName;
12
15
  readonly language: string | undefined;
13
16
  readonly rules: Readonly<Record<string, Level>>;
14
17
  readonly experimental: boolean;
15
18
  readonly path: string | undefined;
16
19
  readonly aiModel: string;
17
20
  readonly confidenceThreshold: number;
21
+ /** チームの言葉。社内でしか通じない語を、チームが自分で並べる。 */
22
+ readonly jargon: readonly string[];
23
+ /** この種類の文書に無いと困る見出し。チームが自分で決める。 */
24
+ readonly requiredSections: readonly string[];
18
25
  /** パスごとの上書き。設定ファイルのある場所からの相対で照合する。 */
19
26
  readonly byPath: readonly PathRule[];
20
27
  readonly baseDir: string;
21
28
  };
22
29
 
23
30
  /** 判定の質が誤検知に直結するので、既定は最上位のモデル。cost は絞り込みで削る。spec §14。 */
24
- export const DEFAULT_MODEL = "claude-opus-5";
31
+ /** 既定は Anthropic。openai にすると判定役だけが替わり、rule も判定の形も変わらない。 */
32
+ export const DEFAULT_BACKEND: BackendName = "anthropic";
25
33
 
26
34
  export const EMPTY: Config = {
27
35
  genre: undefined,
@@ -29,8 +37,11 @@ export const EMPTY: Config = {
29
37
  rules: {},
30
38
  experimental: false,
31
39
  path: undefined,
32
- aiModel: DEFAULT_MODEL,
40
+ aiBackend: DEFAULT_BACKEND,
41
+ aiModel: defaultModel(DEFAULT_BACKEND),
33
42
  confidenceThreshold: 0.7,
43
+ jargon: [],
44
+ requiredSections: [],
34
45
  byPath: [],
35
46
  baseDir: process.cwd(),
36
47
  };
@@ -58,20 +69,28 @@ const toPathRule = (raw: unknown): PathRule | undefined => {
58
69
  return { files: globs, genre: str(raw["genre"]), language: str(raw["language"]) };
59
70
  };
60
71
 
72
+ /** 利用者が書く語の並び。空白だけのものは落とす。 */
73
+ const wordsOf = (raw: unknown): string[] => (Array.isArray(raw) ? raw.map((entry) => String(entry).trim()).filter((entry) => entry.length > 0) : []);
74
+
61
75
  const byPathOf = (raw: unknown): PathRule[] => (Array.isArray(raw) ? raw.map(toPathRule).filter((rule) => rule !== undefined) : []);
62
76
 
63
77
  /** 設定ファイルが無くても動く。あっても、既定から変えたものだけが書かれている。spec §18。 */
64
78
  export const loadConfig = (path: string): Config => {
65
79
  const raw: unknown = parse(readFileSync(path, "utf8"));
66
80
  if (!isRecord(raw)) return { ...EMPTY, path };
81
+ const declared: unknown = raw["ai_backend"];
82
+ const backend: BackendName = isBackend(declared) ? declared : DEFAULT_BACKEND;
67
83
  return {
68
84
  genre: str(raw["genre"]),
69
85
  language: str(raw["language"]),
70
86
  rules: rulesOf(raw["rules"]),
71
87
  experimental: raw["experimental"] === true,
72
88
  path,
73
- aiModel: str(raw["ai_model"]) ?? DEFAULT_MODEL,
89
+ aiBackend: backend,
90
+ aiModel: str(raw["ai_model"]) ?? defaultModel(backend),
74
91
  confidenceThreshold: typeof raw["confidence_threshold"] === "number" ? raw["confidence_threshold"] : 0.7,
92
+ jargon: wordsOf(raw["jargon"]),
93
+ requiredSections: wordsOf(raw["required_sections"]),
75
94
  byPath: byPathOf(raw["by_path"]),
76
95
  baseDir: dirname(path),
77
96
  };
@@ -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
+ }));
@@ -0,0 +1,187 @@
1
+ import { lengthOf } from "../measure.ts";
2
+ import { isClosed } from "../sentence-shape.ts";
3
+ import type { Detector, Finding, ProseDocument, Section, Sentence, Token } from "../plugin.ts";
4
+
5
+ const PER = 1000;
6
+
7
+ /** これより短い文書では密度が暴れる。1 語で「1000 語あたり 100」になる。 */
8
+ const MIN_WORDS = 200;
9
+
10
+ const wordsIn = (doc: ProseDocument): number => doc.sentences.reduce((sum, sentence) => sum + lengthOf(sentence, "word"), 0);
11
+
12
+ const isLyAdverb = (token: Token): boolean => token.pos === "ADV" && token.surface.toLowerCase().endsWith("ly");
13
+
14
+ /**
15
+ * -ly 副詞は、動詞が弱いことの目印になる。「walked quickly」より「hurried」。
16
+ * 1 つずつは正しいので、件数ではなく密度で見る(bold-density と同じ)。
17
+ */
18
+ export const adverbDensity: Detector = (doc, options): Finding[] => {
19
+ const words = wordsIn(doc);
20
+ const hits = doc.sentences.flatMap((sentence) => (sentence.tokens ?? []).filter(isLyAdverb).map((token) => ({ sentence, token })));
21
+ const density = words === 0 ? 0 : Math.round((hits.length / words) * PER);
22
+ const first = hits[0];
23
+ if (words < MIN_WORDS || first === undefined || density <= options.limit) return [];
24
+ return [
25
+ {
26
+ rule: "adverb-overuse",
27
+ severity: "info",
28
+ line: 0,
29
+ column: 0,
30
+ quote: first.sentence.text.trim(),
31
+ values: { count: hits.length, density, words, limit: options.limit, offset: first.token.span.start },
32
+ },
33
+ ];
34
+ };
35
+
36
+ const BE = new Set(["be", "is", "are", "was", "were"]);
37
+
38
+ /**
39
+ * There is / It is ... that は、主語を後ろへ押しやって誰が何をするのかを消す。
40
+ * 「There is a need to review」は、誰が必要としているのかを言っていない。
41
+ */
42
+ const expletiveAt = (tokens: readonly Token[]): Token | undefined => {
43
+ const head = tokens.find((token) => token.pos !== "PUNCT");
44
+ if (head === undefined) return undefined;
45
+ const lead = head.surface.toLowerCase();
46
+ if (lead !== "there" && lead !== "it") return undefined;
47
+ const next = tokens.find((token) => token.span.start >= head.span.end && token.pos !== "PUNCT");
48
+ if (next === undefined || !BE.has(next.lemma ?? next.surface.toLowerCase())) return undefined;
49
+ // "it is" は "it is raining" のような正当な用法があるので、that 節を伴うときだけ数える。
50
+ if (lead === "it" && !tokens.some((token) => token.surface.toLowerCase() === "that")) return undefined;
51
+ return head;
52
+ };
53
+
54
+ export const expletive: Detector = (doc, options): Finding[] => {
55
+ const hits = doc.sentences.flatMap((sentence) => {
56
+ const at = expletiveAt(sentence.tokens ?? []);
57
+ return at === undefined ? [] : [{ sentence, at }];
58
+ });
59
+ if (hits.length <= options.limit) return [];
60
+ return hits.map(({ sentence, at }) => ({
61
+ rule: "expletive-construction",
62
+ severity: "info",
63
+ line: 0,
64
+ column: 0,
65
+ quote: sentence.text.trim(),
66
+ values: { word: at.surface, count: hits.length, limit: options.limit, offset: at.span.start },
67
+ }));
68
+ };
69
+
70
+ const firstWord = (sentence: Sentence): string =>
71
+ sentence.text
72
+ .trim()
73
+ .split(/\s+/u)[0]
74
+ ?.replace(/[^A-Za-z]/gu, "") ?? "";
75
+
76
+ /**
77
+ * 接続詞で始まる文が続く。1 つなら効くが、続くと文が前の文の付け足しに見えて、
78
+ * 何が主張なのかが分からなくなる。
79
+ */
80
+ export const conjunctionRun: Detector = (doc, options): Finding[] => {
81
+ const heads = (options.lexicon ?? []).map((entry) => entry.pattern.toLowerCase());
82
+ const runs = doc.sentences.filter(isClosed).reduce<Sentence[][]>(
83
+ (acc, sentence) => {
84
+ const last = acc.at(-1) ?? [];
85
+ if (!heads.includes(firstWord(sentence).toLowerCase())) return [...acc.slice(0, -1), last, []];
86
+ return [...acc.slice(0, -1), [...last, sentence]];
87
+ },
88
+ [[]],
89
+ );
90
+ return runs
91
+ .filter((run) => run.length > options.limit)
92
+ .flatMap((run) => {
93
+ const first = run[0];
94
+ return first === undefined
95
+ ? []
96
+ : [
97
+ {
98
+ rule: "sentence-initial-conjunction-run",
99
+ severity: "info" as const,
100
+ line: 0,
101
+ column: 0,
102
+ quote: run.map((sentence) => sentence.text.trim()).join(" "),
103
+ values: { count: run.length, limit: options.limit, offset: first.span.start },
104
+ },
105
+ ];
106
+ });
107
+ };
108
+
109
+ const WORD = /[A-Za-z][A-Za-z'-]*/gu;
110
+
111
+ /** 小さい語は Title Case でも小文字のままなので、大文字化の判定から外す。 */
112
+ const MINOR = new Set(["a", "an", "the", "and", "or", "but", "of", "in", "on", "at", "to", "for", "with", "as", "by", "from", "is"]);
113
+
114
+ const isTitleCase = (heading: string): boolean | undefined => {
115
+ const words = [...heading.matchAll(WORD)].map((match) => match[0]).filter((word) => !MINOR.has(word.toLowerCase()));
116
+ // 1 語の見出しは、どちらの流儀でも先頭が大文字になる。判定できない。
117
+ if (words.length < 2) return undefined;
118
+ const capitalized = words.filter((word) => word[0] === word[0]?.toUpperCase()).length;
119
+ return capitalized === words.length;
120
+ };
121
+
122
+ const headingsOf = (sections: readonly Section[]): { readonly section: Section; readonly title: boolean }[] =>
123
+ sections.flatMap((section) => {
124
+ const title = isTitleCase(section.heading);
125
+ return title === undefined ? [] : [{ section, title }];
126
+ });
127
+
128
+ /**
129
+ * 見出しの大文字化が混ざっている。どちらの流儀が正しいかは決めない。spec §12.3。
130
+ * 見るのは文書の中で揃っているかだけで、少数派のほうを指摘する。
131
+ */
132
+ export const titleCaseMix: Detector = (doc, options): Finding[] => {
133
+ const judged = headingsOf(doc.sections);
134
+ const title = judged.filter((entry) => entry.title).length;
135
+ const minorityIsTitle = title <= judged.length - title;
136
+ const few = minorityIsTitle ? title : judged.length - title;
137
+ if (few === 0 || few > options.limit) return [];
138
+ return judged
139
+ .filter((entry) => entry.title === minorityIsTitle)
140
+ .map(({ section }) => ({
141
+ rule: "title-case-consistency",
142
+ severity: "info",
143
+ line: 0,
144
+ column: 0,
145
+ quote: section.heading,
146
+ values: { count: few, limit: options.limit, offset: section.span.start },
147
+ }));
148
+ };
149
+
150
+ const LIST_CONJUNCTION = new Set(["and", "or"]);
151
+
152
+ /**
153
+ * 3 つ以上の並列の最後の and / or の前に読点を打つか。Oxford comma。
154
+ *
155
+ * どちらが正しいかは決めない。スタイルガイドで割れる論点に立場を取ると rule ごと無視される。
156
+ * 見るのは 1 つの文書で揃っているかだけ。spec §12.3。
157
+ */
158
+ const oxfordIn = (tokens: readonly Token[]): boolean | undefined => {
159
+ const at = tokens.findIndex((token) => LIST_CONJUNCTION.has(token.surface.toLowerCase()));
160
+ if (at < 1) return undefined;
161
+ const before = tokens.slice(0, at);
162
+ // 並列が 3 つ以上あるときだけ判定できる。読点が 1 つも無ければ 2 つの並列。
163
+ const commas = before.filter((token) => token.surface === ",").length;
164
+ if (commas === 0) return undefined;
165
+ return before.at(-1)?.surface === ",";
166
+ };
167
+
168
+ export const oxfordComma: Detector = (doc, options): Finding[] => {
169
+ const judged = doc.sentences.flatMap((sentence) => {
170
+ const oxford = oxfordIn(sentence.tokens ?? []);
171
+ return oxford === undefined ? [] : [{ sentence, oxford }];
172
+ });
173
+ const withComma = judged.filter((entry) => entry.oxford).length;
174
+ const minorityUsesComma = withComma <= judged.length - withComma;
175
+ const few = minorityUsesComma ? withComma : judged.length - withComma;
176
+ if (few === 0 || few > options.limit) return [];
177
+ return judged
178
+ .filter((entry) => entry.oxford === minorityUsesComma)
179
+ .map(({ sentence }) => ({
180
+ rule: "oxford-comma-consistency",
181
+ severity: "info",
182
+ line: 0,
183
+ column: 0,
184
+ quote: sentence.text.trim(),
185
+ values: { count: few, limit: options.limit, offset: sentence.span.start },
186
+ }));
187
+ };
@@ -5,6 +5,15 @@ import { headingEcho } from "./heading-echo.ts";
5
5
  import { repeatedHead } from "./repeated-head.ts";
6
6
  import { sentenceRhythm } from "./sentence-rhythm.ts";
7
7
  import { phraseMatch } from "./phrase-match.ts";
8
+ import { agentlessPassive } from "./agentless-passive.ts";
9
+ import { sentenceEnding } from "./sentence-ending.ts";
10
+ import { doubledParticle, nounEnding } from "./token-shape.ts";
11
+ import { kanjiRun, middleDot } from "./char-shape.ts";
12
+ import { adverbDensity, conjunctionRun, expletive, oxfordComma, titleCaseMix } from "./en-shape.ts";
13
+ import { paragraphLength, paragraphVariance, preambleLength, ruleOfThree, sectionUniformity } from "./structure.ts";
14
+ import { concreteEvidence, emojiDensity, ngramRepetition, undefinedAcronym } from "./signals.ts";
15
+ import { aiTell, cushionDensity, hedgingDensity, repeatedConjunction, unqualifiedSuperlative } from "./lexicon.ts";
16
+ import { internalJargon, properNounDensity, requiredSections } from "./team.ts";
8
17
 
9
18
  /** rule 定義の how_to_find がここを引く。rule 側は実装を知らない。 */
10
19
  export const DETECTORS: Readonly<Record<string, Detector>> = {
@@ -14,4 +23,32 @@ export const DETECTORS: Readonly<Record<string, Detector>> = {
14
23
  "repeated-head": repeatedHead,
15
24
  "sentence-rhythm": sentenceRhythm,
16
25
  "phrase-match": phraseMatch,
26
+ "agentless-passive": agentlessPassive,
27
+ "sentence-ending": sentenceEnding,
28
+ "noun-ending": nounEnding,
29
+ "doubled-particle": doubledParticle,
30
+ "kanji-run": kanjiRun,
31
+ "middle-dot": middleDot,
32
+ "adverb-density": adverbDensity,
33
+ expletive: expletive,
34
+ "conjunction-run": conjunctionRun,
35
+ "title-case-mix": titleCaseMix,
36
+ "oxford-comma": oxfordComma,
37
+ "paragraph-length": paragraphLength,
38
+ "paragraph-variance": paragraphVariance,
39
+ "section-uniformity": sectionUniformity,
40
+ "rule-of-three": ruleOfThree,
41
+ "preamble-length": preambleLength,
42
+ "emoji-density": emojiDensity,
43
+ "ngram-repetition": ngramRepetition,
44
+ "undefined-acronym": undefinedAcronym,
45
+ "concrete-evidence": concreteEvidence,
46
+ "hedging-density": hedgingDensity,
47
+ "cushion-density": cushionDensity,
48
+ "unqualified-superlative": unqualifiedSuperlative,
49
+ "repeated-conjunction": repeatedConjunction,
50
+ "ai-tell": aiTell,
51
+ "internal-jargon": internalJargon,
52
+ "required-sections": requiredSections,
53
+ "proper-noun-density": properNounDensity,
17
54
  };
@@ -0,0 +1,124 @@
1
+ import { proseText } from "../measure.ts";
2
+ import { wordsOf } from "./structure.ts";
3
+ import type { Detector, Finding, ProseDocument, Sentence } from "../plugin.ts";
4
+
5
+ const PER = 1000;
6
+
7
+ /** 密度を見る rule は短い文書を測らない。単位は言語で違うので床も分ける。 */
8
+ const FLOOR = { word: 200, char: 500 };
9
+
10
+ type Hit = { readonly sentence: Sentence; readonly matched: string };
11
+
12
+ const hitsFor = (doc: ProseDocument, patterns: readonly string[]): Hit[] =>
13
+ doc.sentences.flatMap((sentence) => {
14
+ const text = sentence.text.toLowerCase();
15
+ return patterns.filter((pattern) => text.includes(pattern.toLowerCase())).map((matched) => ({ sentence, matched }));
16
+ });
17
+
18
+ /**
19
+ * 単位長あたりの出現率。件数で数えると長い文書ほど当たる(bold-density と同じ)。
20
+ * rule の id は呼び出し側が持つ。detector は「密度が閾値を超えたか」しか知らない。
21
+ */
22
+ const densityRule =
23
+ (rule: string): Detector =>
24
+ (doc, options): Finding[] => {
25
+ const patterns = (options.lexicon ?? []).map((entry) => entry.pattern);
26
+ const hits = hitsFor(doc, patterns);
27
+ const length = wordsOf(doc);
28
+ const rate = length === 0 ? 0 : Math.round((hits.length / length) * PER);
29
+ const first = hits[0];
30
+ if (length < FLOOR[doc.lengthUnit] || first === undefined || rate <= options.limit) return [];
31
+ return hits.map((hit) => ({
32
+ rule,
33
+ severity: "warning",
34
+ line: 0,
35
+ column: 0,
36
+ quote: hit.sentence.text.trim(),
37
+ values: { matched: hit.matched, count: hits.length, density: rate, limit: options.limit, offset: hit.sentence.span.start },
38
+ }));
39
+ };
40
+
41
+ export const hedgingDensity = densityRule("excessive-hedging");
42
+ export const cushionDensity = densityRule("cushion-phrase-density");
43
+
44
+ /**
45
+ * 限定のない最上級。「最も速い」だけでは、何と比べて最もなのかが無い。
46
+ * 同じ文に比較対象や条件があれば、それは主張であって誇張ではない。
47
+ */
48
+ const QUALIFIER = /\d|より|に比べ|のうち|among|than|compared|based on|according/iu;
49
+
50
+ export const unqualifiedSuperlative: Detector = (doc, options): Finding[] => {
51
+ const patterns = (options.lexicon ?? []).map((entry) => entry.pattern);
52
+ const bare = hitsFor(doc, patterns).filter((hit) => !QUALIFIER.test(hit.sentence.text));
53
+ if (bare.length < options.limit) return [];
54
+ return bare.map((hit) => ({
55
+ rule: "unqualified-superlative",
56
+ severity: "warning",
57
+ line: 0,
58
+ column: 0,
59
+ quote: hit.sentence.text.trim(),
60
+ values: { matched: hit.matched, count: bare.length, limit: options.limit, offset: hit.sentence.span.start },
61
+ }));
62
+ };
63
+
64
+ const openerOf = (doc: ProseDocument, patterns: readonly string[]): (string | undefined)[] =>
65
+ doc.paragraphs.map((paragraph) => {
66
+ const head = paragraph.sentences[0]?.text.trim().toLowerCase() ?? "";
67
+ return patterns.find((pattern) => head.startsWith(pattern.toLowerCase()));
68
+ });
69
+
70
+ /**
71
+ * 段落が同じ接続詞で始まり続ける。1 つなら流れを作るが、続くとどの段落も
72
+ * 前の段落の付け足しに見えて、話がどこへ向かっているのか分からなくなる。
73
+ */
74
+ export const repeatedConjunction: Detector = (doc, options): Finding[] => {
75
+ const patterns = (options.lexicon ?? []).map((entry) => entry.pattern);
76
+ const openers = openerOf(doc, patterns);
77
+ const runs = openers.reduce<{ runs: number[][]; current: number[] }>(
78
+ (acc, opener, index) => {
79
+ if (opener === undefined) return { runs: [...acc.runs, acc.current], current: [] };
80
+ return { runs: acc.runs, current: [...acc.current, index] };
81
+ },
82
+ { runs: [], current: [] },
83
+ );
84
+ return [...runs.runs, runs.current]
85
+ .filter((run) => run.length > options.limit)
86
+ .flatMap((run) => {
87
+ const at = doc.paragraphs[run[0] ?? 0];
88
+ return at === undefined
89
+ ? []
90
+ : [
91
+ {
92
+ rule: "repeated-conjunction",
93
+ severity: "warning" as const,
94
+ line: 0,
95
+ column: 0,
96
+ quote: at.sentences[0]?.text.trim() ?? "",
97
+ values: { count: run.length, limit: options.limit, offset: at.span.start },
98
+ },
99
+ ];
100
+ });
101
+ };
102
+
103
+ /**
104
+ * AI 生成の signal。1 つでは何も言えないので、重みを足し合わせて文書の点にする。
105
+ * 単独で断じない。§20.2 の複合シグナルの入口で、これだけで「AI が書いた」とは言わない。
106
+ */
107
+ export const aiTell: Detector = (doc, options): Finding[] => {
108
+ const weights = new Map((options.lexicon ?? []).map((entry) => [entry.pattern.toLowerCase(), entry.weight ?? 1]));
109
+ const body = doc.sentences.map(proseText).join(" ").toLowerCase();
110
+ const found = [...weights.entries()].filter(([pattern]) => body.includes(pattern));
111
+ const score = Math.round(found.reduce((sum, [, weight]) => sum + weight, 0) * 10);
112
+ const first = doc.sentences.find((sentence) => found.some(([pattern]) => sentence.text.toLowerCase().includes(pattern)));
113
+ if (first === undefined || score <= options.limit) return [];
114
+ return [
115
+ {
116
+ rule: "ai-tell",
117
+ severity: "info",
118
+ line: 0,
119
+ column: 0,
120
+ quote: first.text.trim(),
121
+ values: { word: found.map(([pattern]) => pattern).join("、"), count: found.length, density: score, limit: options.limit, offset: first.span.start },
122
+ },
123
+ ];
124
+ };