chaffjs 0.2.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 (174) 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 +8 -4
  15. package/dist/cli.js.map +1 -1
  16. package/dist/commands/eval.d.ts.map +1 -1
  17. package/dist/commands/eval.js +4 -1
  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 +60 -14
  21. package/dist/commands/test.js.map +1 -1
  22. package/dist/config/load.d.ts +4 -1
  23. package/dist/config/load.d.ts.map +1 -1
  24. package/dist/config/load.js +10 -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 +33 -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/token-shape.d.ts +4 -0
  61. package/dist/detectors/token-shape.d.ts.map +1 -0
  62. package/dist/detectors/token-shape.js +62 -0
  63. package/dist/detectors/token-shape.js.map +1 -0
  64. package/dist/document.d.ts.map +1 -1
  65. package/dist/document.js +76 -8
  66. package/dist/document.js.map +1 -1
  67. package/dist/env.d.ts +12 -0
  68. package/dist/env.d.ts.map +1 -0
  69. package/dist/env.js +20 -0
  70. package/dist/env.js.map +1 -0
  71. package/dist/init.d.ts.map +1 -1
  72. package/dist/init.js +9 -6
  73. package/dist/init.js.map +1 -1
  74. package/dist/judge.d.ts +14 -16
  75. package/dist/judge.d.ts.map +1 -1
  76. package/dist/judge.js +0 -0
  77. package/dist/judge.js.map +1 -1
  78. package/dist/levels.d.ts +1 -9
  79. package/dist/levels.d.ts.map +1 -1
  80. package/dist/levels.js +15 -3
  81. package/dist/levels.js.map +1 -1
  82. package/dist/look-at.d.ts +18 -0
  83. package/dist/look-at.d.ts.map +1 -0
  84. package/dist/look-at.js +43 -0
  85. package/dist/look-at.js.map +1 -0
  86. package/dist/plugin.d.ts +44 -1
  87. package/dist/plugin.d.ts.map +1 -1
  88. package/dist/render/explain.d.ts +1 -1
  89. package/dist/render/explain.d.ts.map +1 -1
  90. package/dist/render/explain.js +12 -4
  91. package/dist/render/explain.js.map +1 -1
  92. package/dist/render/rules-json.d.ts.map +1 -1
  93. package/dist/render/rules-json.js +13 -5
  94. package/dist/render/rules-json.js.map +1 -1
  95. package/dist/render/semantic.d.ts +2 -1
  96. package/dist/render/semantic.d.ts.map +1 -1
  97. package/dist/render/semantic.js +35 -0
  98. package/dist/render/semantic.js.map +1 -1
  99. package/dist/rule-load.d.ts.map +1 -1
  100. package/dist/rule-load.js +27 -1
  101. package/dist/rule-load.js.map +1 -1
  102. package/dist/run-semantic.d.ts +22 -1
  103. package/dist/run-semantic.d.ts.map +1 -1
  104. package/dist/run-semantic.js +21 -10
  105. package/dist/run-semantic.js.map +1 -1
  106. package/dist/run.d.ts +6 -1
  107. package/dist/run.d.ts.map +1 -1
  108. package/dist/run.js +34 -2
  109. package/dist/run.js.map +1 -1
  110. package/dist/sentence-shape.d.ts +6 -0
  111. package/dist/sentence-shape.d.ts.map +1 -0
  112. package/dist/sentence-shape.js +15 -0
  113. package/dist/sentence-shape.js.map +1 -0
  114. package/package.json +4 -3
  115. package/rules/adverb-overuse.yaml +28 -0
  116. package/rules/agentless-passive.yaml +28 -0
  117. package/rules/ai-tell.yaml +27 -0
  118. package/rules/concrete-evidence-density.yaml +25 -0
  119. package/rules/cushion-phrase-density.yaml +26 -0
  120. package/rules/double-keigo.yaml +27 -0
  121. package/rules/emoji-density.yaml +26 -0
  122. package/rules/excessive-hedging.yaml +26 -0
  123. package/rules/expletive-construction.yaml +27 -0
  124. package/rules/hiragana-fukushi.yaml +27 -0
  125. package/rules/max-kanji-continuous.yaml +26 -0
  126. package/rules/max-paragraph-length.yaml +25 -0
  127. package/rules/max-sentence-length.yaml +22 -0
  128. package/rules/ngram-repetition.yaml +25 -0
  129. package/rules/no-doubled-joshi.yaml +28 -0
  130. package/rules/no-mixed-desumasu.yaml +29 -0
  131. package/rules/no-nakaguro-parallel.yaml +27 -0
  132. package/rules/oxford-comma-consistency.yaml +26 -0
  133. package/rules/paragraph-length-variance.yaml +25 -0
  134. package/rules/preamble-length.yaml +25 -0
  135. package/rules/repeated-conjunction.yaml +26 -0
  136. package/rules/rule-of-three.yaml +25 -0
  137. package/rules/sasete-itadaku.yaml +27 -0
  138. package/rules/section-length-uniformity.yaml +25 -0
  139. package/rules/sentence-initial-conjunction-run.yaml +27 -0
  140. package/rules/taigen-dome-in-prose.yaml +27 -0
  141. package/rules/title-case-consistency.yaml +26 -0
  142. package/rules/undefined-acronym.yaml +25 -0
  143. package/rules/unqualified-superlative.yaml +26 -0
  144. package/src/backends/anthropic.ts +61 -0
  145. package/src/backends/openai.ts +46 -0
  146. package/src/backends/types.ts +38 -0
  147. package/src/cli.ts +8 -4
  148. package/src/commands/eval.ts +4 -1
  149. package/src/commands/test.ts +72 -14
  150. package/src/config/load.ts +11 -3
  151. package/src/detectors/agentless-passive.ts +34 -0
  152. package/src/detectors/char-shape.ts +47 -0
  153. package/src/detectors/en-shape.ts +187 -0
  154. package/src/detectors/index.ts +33 -0
  155. package/src/detectors/lexicon.ts +124 -0
  156. package/src/detectors/repeated-head.ts +17 -6
  157. package/src/detectors/sentence-ending.ts +37 -0
  158. package/src/detectors/signals.ts +199 -0
  159. package/src/detectors/structure.ts +119 -0
  160. package/src/detectors/token-shape.ts +81 -0
  161. package/src/document.ts +81 -9
  162. package/src/env.ts +20 -0
  163. package/src/init.ts +9 -6
  164. package/src/judge.ts +0 -0
  165. package/src/levels.ts +16 -4
  166. package/src/look-at.ts +53 -0
  167. package/src/plugin.ts +45 -3
  168. package/src/render/explain.ts +12 -4
  169. package/src/render/rules-json.ts +16 -5
  170. package/src/render/semantic.ts +38 -1
  171. package/src/rule-load.ts +28 -1
  172. package/src/run-semantic.ts +32 -11
  173. package/src/run.ts +36 -3
  174. package/src/sentence-shape.ts +22 -0
@@ -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,14 @@ 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";
8
16
 
9
17
  /** rule 定義の how_to_find がここを引く。rule 側は実装を知らない。 */
10
18
  export const DETECTORS: Readonly<Record<string, Detector>> = {
@@ -14,4 +22,29 @@ export const DETECTORS: Readonly<Record<string, Detector>> = {
14
22
  "repeated-head": repeatedHead,
15
23
  "sentence-rhythm": sentenceRhythm,
16
24
  "phrase-match": phraseMatch,
25
+ "agentless-passive": agentlessPassive,
26
+ "sentence-ending": sentenceEnding,
27
+ "noun-ending": nounEnding,
28
+ "doubled-particle": doubledParticle,
29
+ "kanji-run": kanjiRun,
30
+ "middle-dot": middleDot,
31
+ "adverb-density": adverbDensity,
32
+ expletive: expletive,
33
+ "conjunction-run": conjunctionRun,
34
+ "title-case-mix": titleCaseMix,
35
+ "oxford-comma": oxfordComma,
36
+ "paragraph-length": paragraphLength,
37
+ "paragraph-variance": paragraphVariance,
38
+ "section-uniformity": sectionUniformity,
39
+ "rule-of-three": ruleOfThree,
40
+ "preamble-length": preambleLength,
41
+ "emoji-density": emojiDensity,
42
+ "ngram-repetition": ngramRepetition,
43
+ "undefined-acronym": undefinedAcronym,
44
+ "concrete-evidence": concreteEvidence,
45
+ "hedging-density": hedgingDensity,
46
+ "cushion-density": cushionDensity,
47
+ "unqualified-superlative": unqualifiedSuperlative,
48
+ "repeated-conjunction": repeatedConjunction,
49
+ "ai-tell": aiTell,
17
50
  };
@@ -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
+ };
@@ -1,23 +1,34 @@
1
- import type { Detector, Finding, Sentence } from "../plugin.ts";
1
+ import type { Detector, Finding, LengthUnit, Sentence } from "../plugin.ts";
2
2
 
3
3
  const HEAD_CHARS = 6;
4
+ const HEAD_WORDS = 3;
4
5
 
5
- const headOf = (sentence: Sentence): string => sentence.text.trim().replace(/\s+/gu, "").slice(0, HEAD_CHARS);
6
+ /**
7
+ * 書き出しの「同じさ」を測る単位は言語で違う。日本語は文字、英語は語。
8
+ * 文字で切ると "We continued" が "Wecont" になり、引用がそのまま読み手に出る。
9
+ */
10
+ const headOf = (sentence: Sentence, unit: LengthUnit): string => {
11
+ const text = sentence.text.trim();
12
+ if (unit === "char") return text.replace(/\s+/gu, "").slice(0, HEAD_CHARS);
13
+ return text.split(/\s+/u).slice(0, HEAD_WORDS).join(" ");
14
+ };
6
15
 
7
16
  type Run = { readonly head: string; readonly members: readonly Sentence[] };
8
17
 
9
- const runsOf = (sentences: readonly Sentence[]): Run[] =>
18
+ const runsOf = (sentences: readonly Sentence[], unit: LengthUnit): Run[] =>
10
19
  sentences.reduce<Run[]>((acc, sentence) => {
11
- const head = headOf(sentence);
20
+ const head = headOf(sentence, unit);
12
21
  const last = acc.at(-1);
13
- if (last !== undefined && last.head === head && head.length === HEAD_CHARS) {
22
+ // 短すぎる書き出しは「同じ」と言えない。単位ぶん揃って初めて連なりと見る。
23
+ const full = unit === "char" ? head.length === HEAD_CHARS : head.split(" ").length === HEAD_WORDS;
24
+ if (last !== undefined && last.head === head && full) {
14
25
  return [...acc.slice(0, -1), { head, members: [...last.members, sentence] }];
15
26
  }
16
27
  return [...acc, { head, members: [sentence] }];
17
28
  }, []);
18
29
 
19
30
  export const repeatedHead: Detector = (doc, options): Finding[] =>
20
- runsOf(doc.sentences)
31
+ runsOf(doc.sentences, doc.lengthUnit)
21
32
  .filter((run) => run.members.length > options.limit)
22
33
  .map((run) => ({
23
34
  rule: "repeated-sentence-head",
@@ -0,0 +1,37 @@
1
+ import { hasPredicate, isClosed } from "../sentence-shape.ts";
2
+ import type { Detector, Finding, Sentence } from "../plugin.ts";
3
+
4
+ /**
5
+ * 文末の調子が混ざっているかを見る。日本語の「ですます / である」がこれ。
6
+ *
7
+ * どちらが正しいかは決めない。決めると、方針の違う書き手にそのまま無視される。
8
+ * 見るのは文書の中での一貫性だけで、少数派のほうを指摘する。spec §12.3 の方針を日本語にも。
9
+ */
10
+ /**
11
+ * 述語を持たないものは文として数えない。見出しの下の `MaaSサービス` のような
12
+ * 名前だけの行がそのまま「である調」の少数派になり、実文書の誤検知はすべてこれだった。
13
+ */
14
+ const isSentence = (sentence: Sentence): boolean => isClosed(sentence) && hasPredicate(sentence);
15
+
16
+ const isPolite = (sentence: Sentence, polite: readonly string[]): boolean =>
17
+ (sentence.tokens ?? []).some((token) => polite.includes(token.lemma ?? token.surface));
18
+
19
+ export const sentenceEnding: Detector = (doc, options): Finding[] => {
20
+ const polite = (options.lexicon ?? []).map((entry) => entry.pattern);
21
+ const judged = doc.sentences.filter(isSentence).map((sentence) => ({ sentence, polite: isPolite(sentence, polite) }));
22
+ const counts = { polite: judged.filter((entry) => entry.polite).length, plain: judged.length - judged.filter((entry) => entry.polite).length };
23
+ const minorityIsPolite = counts.polite <= counts.plain;
24
+ const few = minorityIsPolite ? counts.polite : counts.plain;
25
+ // 片方しか無ければ一貫している。少数派が閾値を超えて多ければ、混在ではなく別の文体。
26
+ if (few === 0 || few > options.limit) return [];
27
+ return judged
28
+ .filter((entry) => entry.polite === minorityIsPolite)
29
+ .map(({ sentence }) => ({
30
+ rule: "no-mixed-desumasu",
31
+ severity: "warning",
32
+ line: 0,
33
+ column: 0,
34
+ quote: sentence.text.trim(),
35
+ values: { count: few, limit: options.limit, offset: sentence.span.start },
36
+ }));
37
+ };
@@ -0,0 +1,199 @@
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
+ /**
8
+ * これより短い文書では密度が暴れる。1 個で「1000 あたり 100」になる。
9
+ * 単位は言語で違うので、床も分ける。英語の 200 語と日本語の 200 文字では長さが桁で違う。
10
+ */
11
+ const FLOOR = { word: 200, char: 500 };
12
+
13
+ const bodyOf = (doc: ProseDocument): string => doc.sentences.map(proseText).join(" ");
14
+
15
+ type Hit = { readonly sentence: Sentence; readonly offset: number };
16
+
17
+ const findIn = (doc: ProseDocument, pattern: RegExp): Hit[] =>
18
+ doc.sentences.flatMap((sentence) => [...sentence.text.matchAll(pattern)].map((match) => ({ sentence, offset: sentence.span.start + (match.index ?? 0) })));
19
+
20
+ const density = (doc: ProseDocument, count: number): number => {
21
+ const length = wordsOf(doc);
22
+ return length === 0 ? 0 : Math.round((count / length) * PER);
23
+ };
24
+
25
+ /**
26
+ * 絵文字と装飾記号。1 つなら親しみだが、並ぶと文字そのものが読めなくなる。
27
+ * 件数ではなく密度で見る。長い文書ほど当たるのを避けるため(bold-density と同じ)。
28
+ */
29
+ const EMOJI = /\p{Extended_Pictographic}|[←-⇿☀-➿]/gu;
30
+
31
+ export const emojiDensity: Detector = (doc, options): Finding[] => {
32
+ const hits = findIn(doc, EMOJI);
33
+ const first = hits[0];
34
+ const rate = density(doc, hits.length);
35
+ if (wordsOf(doc) < FLOOR[doc.lengthUnit] || first === undefined || rate <= options.limit) return [];
36
+ return [
37
+ {
38
+ rule: "emoji-density",
39
+ severity: "info",
40
+ line: 0,
41
+ column: 0,
42
+ quote: first.sentence.text.trim(),
43
+ values: { count: hits.length, density: rate, limit: options.limit, offset: first.offset },
44
+ },
45
+ ];
46
+ };
47
+
48
+ /**
49
+ * 同じ言い回しの繰り返し。character n-gram で見るので、語の区切りが要らない。
50
+ * spec §10 が n-gram を選んだのはこのためで、新しい言語でそのまま動く。
51
+ */
52
+ const GRAM = 8;
53
+
54
+ /** 短い n-gram は偶然一致する。8 文字は「〜することができます」のような言い回しの長さ。 */
55
+ const countGrams = (text: string): Map<string, number> => {
56
+ const counts = new Map<string, number>();
57
+ Array.from({ length: Math.max(0, text.length - GRAM + 1) }).forEach((_, index) => {
58
+ const gram = text.slice(index, index + GRAM);
59
+ counts.set(gram, (counts.get(gram) ?? 0) + 1);
60
+ });
61
+ return counts;
62
+ };
63
+
64
+ /**
65
+ * 名前ではなく言い回しだけを数える。実文書で測ったら、上位は
66
+ * 「AGENTS.m」「シンギュラリティ」のような固有名詞と識別子だった。名前は繰り返して当たり前。
67
+ *
68
+ * 言い回しは、その言語の「つなぎの материал」を含む。日本語ならひらがな、英語なら語の切れ目。
69
+ * どちらを見るかは adapter が宣言する単位で決める。ひらがなを持たない char 単位の言語が
70
+ * 来たら、この見分けは効かなくなる(そのときは adapter 側に判定を移す)。
71
+ */
72
+ const CONNECTIVE = { char: /[ぁ-ゖ]/u, word: / /u };
73
+
74
+ const isPhrasing = (gram: string, unit: ProseDocument["lengthUnit"]): boolean => CONNECTIVE[unit].test(gram);
75
+
76
+ /** 文をまたいで数えない。文の終わりと次の文の始まりが繋がると、固有名詞が言い回しに見える。 */
77
+ const gramsOf = (doc: ProseDocument): Map<string, number> => {
78
+ const counts = new Map<string, number>();
79
+ doc.sentences.forEach((sentence) => {
80
+ const text = doc.lengthUnit === "char" ? proseText(sentence).replace(/\s+/gu, "") : proseText(sentence);
81
+ countGrams(text).forEach((count, gram) => counts.set(gram, (counts.get(gram) ?? 0) + count));
82
+ });
83
+ return counts;
84
+ };
85
+
86
+ export const ngramRepetition: Detector = (doc, options): Finding[] => {
87
+ if (wordsOf(doc) < FLOOR[doc.lengthUnit]) return [];
88
+ const worst = [...gramsOf(doc).entries()]
89
+ .filter(([gram]) => isPhrasing(gram, doc.lengthUnit))
90
+ .reduce<{ gram: string; count: number }>((best, [gram, count]) => (count > best.count ? { gram, count } : best), { gram: "", count: 0 });
91
+ if (worst.count <= options.limit) return [];
92
+ const at = doc.sentences.find((sentence) => sentence.text.includes(worst.gram));
93
+ return [
94
+ {
95
+ rule: "ngram-repetition",
96
+ severity: "info",
97
+ line: 0,
98
+ column: 0,
99
+ quote: at?.text.trim() ?? worst.gram,
100
+ values: { word: worst.gram, count: worst.count, limit: options.limit, offset: at?.span.start ?? 0 },
101
+ },
102
+ ];
103
+ };
104
+
105
+ /**
106
+ * 略語が初出で展開されているか。「CI」だけでは、読む人によって指すものが違う。
107
+ * 展開は「略語の直前か直後の括弧」か「括弧の中の略語」で書かれる。
108
+ */
109
+ const ACRONYM = /\b[A-Z]{2,6}\b/gu;
110
+
111
+ /** 読み手が説明なしで通じると見なしてよい語。展開すると逆に読みにくい。 */
112
+ const COMMON = new Set([
113
+ "OK",
114
+ "NG",
115
+ "URL",
116
+ "API",
117
+ "CSS",
118
+ "HTML",
119
+ "JSON",
120
+ "YAML",
121
+ "HTTP",
122
+ "HTTPS",
123
+ "PDF",
124
+ "CPU",
125
+ "GPU",
126
+ "RAM",
127
+ "USB",
128
+ "AI",
129
+ "ID",
130
+ "FAQ",
131
+ "PR",
132
+ "OS",
133
+ "CLI",
134
+ "UI",
135
+ "UX",
136
+ "SQL",
137
+ "CSV",
138
+ "XML",
139
+ "TODO",
140
+ "NOTE",
141
+ ]);
142
+
143
+ /**
144
+ * 展開は略語の**すぐ隣**にあるときだけ認める。
145
+ * 60 文字も見ると、同じ文のどこかに括弧があるだけで「説明済み」になり、1 件も出なくなる。
146
+ */
147
+ const AFTER = /^\s*[((]/u;
148
+ const BEFORE = /[))]\s*$/u;
149
+
150
+ const isExpanded = (body: string, acronym: string): boolean => {
151
+ const at = body.indexOf(acronym);
152
+ if (at === -1) return false;
153
+ return AFTER.test(body.slice(at + acronym.length, at + acronym.length + 3)) || BEFORE.test(body.slice(Math.max(0, at - 3), at));
154
+ };
155
+
156
+ export const undefinedAcronym: Detector = (doc, options): Finding[] => {
157
+ const body = bodyOf(doc);
158
+ const seen = new Map<string, Hit>();
159
+ findIn(doc, ACRONYM).forEach((hit) => {
160
+ const text = /^[A-Z]{2,6}/u.exec(doc.source.slice(hit.offset, hit.offset + 6))?.[0] ?? "";
161
+ if (text.length > 0 && !COMMON.has(text) && !seen.has(text)) seen.set(text, hit);
162
+ });
163
+ const bare = [...seen.entries()].filter(([acronym]) => !isExpanded(body, acronym));
164
+ if (bare.length < options.limit) return [];
165
+ return bare.map(([acronym, hit]) => ({
166
+ rule: "undefined-acronym",
167
+ severity: "info",
168
+ line: 0,
169
+ column: 0,
170
+ quote: hit.sentence.text.trim(),
171
+ values: { word: acronym, count: bare.length, limit: options.limit, offset: hit.offset },
172
+ }));
173
+ };
174
+
175
+ /**
176
+ * 具体物の密度。数値・コード・リンク・引用が 1 つも無い節は、
177
+ * 読み終えても何も持ち帰れない。**少ないほうを指摘する**ので、他の密度 rule と向きが逆。
178
+ */
179
+ const CONCRETE = /\d|`|https?:\/\//u;
180
+
181
+ export const concreteEvidence: Detector = (doc, options): Finding[] => {
182
+ const sections = doc.sections.filter((section) => section.sentences.length >= 3);
183
+ const empty = sections.filter((section) => !CONCRETE.test(doc.source.slice(section.span.start, section.span.end)));
184
+ if (empty.length < options.limit) return [];
185
+ return empty.map((section) => ({
186
+ rule: "concrete-evidence-density",
187
+ severity: "info",
188
+ line: 0,
189
+ column: 0,
190
+ quote: section.heading.length > 0 ? section.heading : (section.sentences[0]?.text.trim() ?? ""),
191
+ values: {
192
+ word: section.heading.length > 0 ? section.heading : "この節",
193
+ count: empty.length,
194
+ total: sections.length,
195
+ limit: options.limit,
196
+ offset: section.span.start,
197
+ },
198
+ }));
199
+ };