chaffjs 0.0.1

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 (182) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +76 -0
  3. package/bin/chaff.js +4 -0
  4. package/dist/adapter-load.d.ts +9 -0
  5. package/dist/adapter-load.d.ts.map +1 -0
  6. package/dist/adapter-load.js +38 -0
  7. package/dist/adapter-load.js.map +1 -0
  8. package/dist/baseline.d.ts +23 -0
  9. package/dist/baseline.d.ts.map +1 -0
  10. package/dist/baseline.js +41 -0
  11. package/dist/baseline.js.map +1 -0
  12. package/dist/cli.d.ts +2 -0
  13. package/dist/cli.d.ts.map +1 -0
  14. package/dist/cli.js +234 -0
  15. package/dist/cli.js.map +1 -0
  16. package/dist/config/load.d.ts +13 -0
  17. package/dist/config/load.d.ts.map +1 -0
  18. package/dist/config/load.js +26 -0
  19. package/dist/config/load.js.map +1 -0
  20. package/dist/config/write.d.ts +12 -0
  21. package/dist/config/write.d.ts.map +1 -0
  22. package/dist/config/write.js +60 -0
  23. package/dist/config/write.js.map +1 -0
  24. package/dist/detect-language.d.ts +3 -0
  25. package/dist/detect-language.d.ts.map +1 -0
  26. package/dist/detect-language.js +26 -0
  27. package/dist/detect-language.js.map +1 -0
  28. package/dist/detect.d.ts +7 -0
  29. package/dist/detect.d.ts.map +1 -0
  30. package/dist/detect.js +13 -0
  31. package/dist/detect.js.map +1 -0
  32. package/dist/detectors/count-per-section.d.ts +4 -0
  33. package/dist/detectors/count-per-section.d.ts.map +1 -0
  34. package/dist/detectors/count-per-section.js +13 -0
  35. package/dist/detectors/count-per-section.js.map +1 -0
  36. package/dist/detectors/heading-echo.d.ts +3 -0
  37. package/dist/detectors/heading-echo.d.ts.map +1 -0
  38. package/dist/detectors/heading-echo.js +36 -0
  39. package/dist/detectors/heading-echo.js.map +1 -0
  40. package/dist/detectors/index.d.ts +4 -0
  41. package/dist/detectors/index.d.ts.map +1 -0
  42. package/dist/detectors/index.js +16 -0
  43. package/dist/detectors/index.js.map +1 -0
  44. package/dist/detectors/phrase-match.d.ts +7 -0
  45. package/dist/detectors/phrase-match.d.ts.map +1 -0
  46. package/dist/detectors/phrase-match.js +38 -0
  47. package/dist/detectors/phrase-match.js.map +1 -0
  48. package/dist/detectors/repeated-head.d.ts +3 -0
  49. package/dist/detectors/repeated-head.d.ts.map +1 -0
  50. package/dist/detectors/repeated-head.js +21 -0
  51. package/dist/detectors/repeated-head.js.map +1 -0
  52. package/dist/detectors/sentence-length.d.ts +3 -0
  53. package/dist/detectors/sentence-length.d.ts.map +1 -0
  54. package/dist/detectors/sentence-length.js +19 -0
  55. package/dist/detectors/sentence-length.js.map +1 -0
  56. package/dist/detectors/sentence-rhythm.d.ts +3 -0
  57. package/dist/detectors/sentence-rhythm.d.ts.map +1 -0
  58. package/dist/detectors/sentence-rhythm.js +28 -0
  59. package/dist/detectors/sentence-rhythm.js.map +1 -0
  60. package/dist/document.d.ts +3 -0
  61. package/dist/document.d.ts.map +1 -0
  62. package/dist/document.js +138 -0
  63. package/dist/document.js.map +1 -0
  64. package/dist/files.d.ts +12 -0
  65. package/dist/files.d.ts.map +1 -0
  66. package/dist/files.js +30 -0
  67. package/dist/files.js.map +1 -0
  68. package/dist/genre.d.ts +8 -0
  69. package/dist/genre.d.ts.map +1 -0
  70. package/dist/genre.js +35 -0
  71. package/dist/genre.js.map +1 -0
  72. package/dist/index.d.ts +5 -0
  73. package/dist/index.d.ts.map +1 -0
  74. package/dist/index.js +3 -0
  75. package/dist/index.js.map +1 -0
  76. package/dist/init.d.ts +3 -0
  77. package/dist/init.d.ts.map +1 -0
  78. package/dist/init.js +71 -0
  79. package/dist/init.js.map +1 -0
  80. package/dist/levels.d.ts +20 -0
  81. package/dist/levels.d.ts.map +1 -0
  82. package/dist/levels.js +24 -0
  83. package/dist/levels.js.map +1 -0
  84. package/dist/mask.d.ts +3 -0
  85. package/dist/mask.d.ts.map +1 -0
  86. package/dist/mask.js +26 -0
  87. package/dist/mask.js.map +1 -0
  88. package/dist/plugin.d.ts +116 -0
  89. package/dist/plugin.d.ts.map +1 -0
  90. package/dist/plugin.js +4 -0
  91. package/dist/plugin.js.map +1 -0
  92. package/dist/position.d.ts +8 -0
  93. package/dist/position.d.ts.map +1 -0
  94. package/dist/position.js +7 -0
  95. package/dist/position.js.map +1 -0
  96. package/dist/render/compact.d.ts +5 -0
  97. package/dist/render/compact.d.ts.map +1 -0
  98. package/dist/render/compact.js +15 -0
  99. package/dist/render/compact.js.map +1 -0
  100. package/dist/render/explain.d.ts +4 -0
  101. package/dist/render/explain.d.ts.map +1 -0
  102. package/dist/render/explain.js +29 -0
  103. package/dist/render/explain.js.map +1 -0
  104. package/dist/render/friendly.d.ts +4 -0
  105. package/dist/render/friendly.d.ts.map +1 -0
  106. package/dist/render/friendly.js +55 -0
  107. package/dist/render/friendly.js.map +1 -0
  108. package/dist/render/rules-json.d.ts +6 -0
  109. package/dist/render/rules-json.d.ts.map +1 -0
  110. package/dist/render/rules-json.js +39 -0
  111. package/dist/render/rules-json.js.map +1 -0
  112. package/dist/render/summary.d.ts +10 -0
  113. package/dist/render/summary.d.ts.map +1 -0
  114. package/dist/render/summary.js +18 -0
  115. package/dist/render/summary.js.map +1 -0
  116. package/dist/render/suppressions.d.ts +13 -0
  117. package/dist/render/suppressions.d.ts.map +1 -0
  118. package/dist/render/suppressions.js +50 -0
  119. package/dist/render/suppressions.js.map +1 -0
  120. package/dist/render/text.d.ts +6 -0
  121. package/dist/render/text.d.ts.map +1 -0
  122. package/dist/render/text.js +8 -0
  123. package/dist/render/text.js.map +1 -0
  124. package/dist/rule-load.d.ts +3 -0
  125. package/dist/rule-load.d.ts.map +1 -0
  126. package/dist/rule-load.js +68 -0
  127. package/dist/rule-load.js.map +1 -0
  128. package/dist/run.d.ts +14 -0
  129. package/dist/run.d.ts.map +1 -0
  130. package/dist/run.js +47 -0
  131. package/dist/run.js.map +1 -0
  132. package/dist/stet.d.ts +19 -0
  133. package/dist/stet.d.ts.map +1 -0
  134. package/dist/stet.js +61 -0
  135. package/dist/stet.js.map +1 -0
  136. package/dist/watch.d.ts +12 -0
  137. package/dist/watch.d.ts.map +1 -0
  138. package/dist/watch.js +40 -0
  139. package/dist/watch.js.map +1 -0
  140. package/package.json +63 -0
  141. package/rules/bold-density.yaml +28 -0
  142. package/rules/closing-cliche.yaml +30 -0
  143. package/rules/empty-intensifier.yaml +29 -0
  144. package/rules/heading-echo.yaml +30 -0
  145. package/rules/max-sentence-length.yaml +28 -0
  146. package/rules/padded-intro.yaml +30 -0
  147. package/rules/repeated-sentence-head.yaml +28 -0
  148. package/rules/sentence-rhythm.yaml +30 -0
  149. package/src/adapter-load.ts +31 -0
  150. package/src/baseline.ts +51 -0
  151. package/src/cli.ts +259 -0
  152. package/src/config/load.ts +38 -0
  153. package/src/config/write.ts +63 -0
  154. package/src/detect-language.ts +28 -0
  155. package/src/detect.ts +15 -0
  156. package/src/detectors/count-per-section.ts +15 -0
  157. package/src/detectors/heading-echo.ts +40 -0
  158. package/src/detectors/index.ts +17 -0
  159. package/src/detectors/phrase-match.ts +41 -0
  160. package/src/detectors/repeated-head.ts +29 -0
  161. package/src/detectors/sentence-length.ts +21 -0
  162. package/src/detectors/sentence-rhythm.ts +28 -0
  163. package/src/document.ts +154 -0
  164. package/src/files.ts +34 -0
  165. package/src/genre.ts +36 -0
  166. package/src/index.ts +4 -0
  167. package/src/init.ts +76 -0
  168. package/src/levels.ts +27 -0
  169. package/src/mask.ts +33 -0
  170. package/src/plugin.ts +124 -0
  171. package/src/position.ts +9 -0
  172. package/src/render/compact.ts +18 -0
  173. package/src/render/explain.ts +31 -0
  174. package/src/render/friendly.ts +62 -0
  175. package/src/render/rules-json.ts +46 -0
  176. package/src/render/summary.ts +22 -0
  177. package/src/render/suppressions.ts +57 -0
  178. package/src/render/text.ts +13 -0
  179. package/src/rule-load.ts +78 -0
  180. package/src/run.ts +61 -0
  181. package/src/stet.ts +79 -0
  182. package/src/watch.ts +52 -0
@@ -0,0 +1,28 @@
1
+ /**
2
+ * core が「どのアダプタを取りに行くか」を決めるためだけの当て推量。spec §17.2。
3
+ *
4
+ * アダプタを読み込む前に動く必要があるので、ここは辞書も依存も持てない。
5
+ * アダプタ側の detect() とは層が違う。あちらは読み込み済みの候補から選ぶための
6
+ * 権威ある点数で、こちらは何も読み込んでいない状態での見当付け。
7
+ * 似た計算を両方が持つのは、この順序から来る避けられない重複。
8
+ *
9
+ * 呼ぶ側が、コードブロック・インラインコード・URL・front matter を先に落としておくこと。
10
+ * 技術文書は英語のコードを大量に含むため、落とさないと日本語文書が英語と判定される。
11
+ */
12
+ const JAPANESE = /[぀-ゟ゠-ヿ一-鿿]/gu;
13
+ const LATIN_LETTER = /[a-z]/giu;
14
+ const COUNTABLE = /\S/gu;
15
+
16
+ // 共有した /g 正規表現を exec で回すと lastIndex が持ち越されて数え落とす。
17
+ // matchAll は内部で複製するので、その事故が起きない。
18
+ const count = (text: string, pattern: RegExp): number => [...text.matchAll(pattern)].length;
19
+
20
+ const ratio = (text: string, pattern: RegExp): number => {
21
+ const total = count(text, COUNTABLE);
22
+ if (total === 0) return 0;
23
+ return count(text, pattern) / total;
24
+ };
25
+
26
+ export const japaneseRatio = (text: string): number => ratio(text, JAPANESE);
27
+
28
+ export const latinRatio = (text: string): number => ratio(text, LATIN_LETTER);
package/src/detect.ts ADDED
@@ -0,0 +1,15 @@
1
+ import { japaneseRatio, latinRatio } from "./detect-language.ts";
2
+
3
+ export type LanguageGuess = { readonly language: string; readonly confidence: number; readonly from: string };
4
+
5
+ /**
6
+ * 技術文書の日本語は英字を大量に含む(コマンド名、識別子、製品名)。
7
+ * 仮名と漢字が 15% あれば日本語と見る。corpus で測るまでの暫定値。spec §26。
8
+ */
9
+ const JAPANESE_FLOOR = 0.15;
10
+
11
+ export const guessLanguage = (source: string): LanguageGuess => {
12
+ const japanese = japaneseRatio(source);
13
+ if (japanese >= JAPANESE_FLOOR) return { language: "ja", confidence: japanese, from: "content" };
14
+ return { language: "en", confidence: latinRatio(source), from: "content" };
15
+ };
@@ -0,0 +1,15 @@
1
+ import type { Detector, Finding } from "../plugin.ts";
2
+
3
+ /** 節あたりの強調の数。bold-density が使う。 */
4
+ export const countPerSection: Detector = (doc, options): Finding[] =>
5
+ doc.sections
6
+ .filter((section) => section.strongCount > options.limit)
7
+ .map((section) => ({
8
+ rule: "bold-density",
9
+ severity: "warning",
10
+ line: 0,
11
+ column: 0,
12
+ // 節全体を引くと段落をまたいで読めなくなる。最初の文だけ見せて場所を示す。
13
+ quote: section.firstSentence?.text.trim() ?? "",
14
+ values: { count: section.strongCount, limit: options.limit, offset: section.firstSentence?.span.start ?? section.span.start },
15
+ }));
@@ -0,0 +1,40 @@
1
+ import type { Detector, Finding } from "../plugin.ts";
2
+
3
+ /** 見出しが短すぎると、偶然の一致で 100% になる。これ未満の見出しは見ない。 */
4
+ const MIN_GRAMS = 4;
5
+
6
+ /**
7
+ * 文字 3-gram で測る。
8
+ * 語 n-gram にすると wordSplit capability が要り、L1(言語を問わず動く)から外れる。
9
+ */
10
+ const trigrams = (text: string): Set<string> => {
11
+ const clean = [...text.replace(/\s+/gu, "")];
12
+ return new Set(clean.slice(0, Math.max(0, clean.length - 2)).map((__char, index) => clean.slice(index, index + 3).join("")));
13
+ };
14
+
15
+ /**
16
+ * Jaccard ではなく包含率を使う。
17
+ *
18
+ * この rule が測りたいのは「見出しのどれだけが繰り返されたか」であって、
19
+ * 両者がどれだけ似ているかではない。見出しを丸ごと含んだうえで説明を続ける文は、
20
+ * 文が長いというだけで Jaccard が下がってしまい、いちばん典型的な反復を取り逃す。
21
+ * (「## キャッシュの仕組み」→「キャッシュの仕組みについて説明します。」で Jaccard 44%)
22
+ */
23
+ const containment = (heading: Set<string>, sentence: Set<string>): number => {
24
+ if (heading.size < MIN_GRAMS || sentence.size === 0) return 0;
25
+ return [...heading].filter((gram) => sentence.has(gram)).length / heading.size;
26
+ };
27
+
28
+ export const headingEcho: Detector = (doc, options): Finding[] =>
29
+ doc.sections
30
+ .filter((section) => section.heading.length > 0 && section.firstSentence !== undefined)
31
+ .map((section) => ({ section, overlap: Math.round(containment(trigrams(section.heading), trigrams(section.firstSentence?.text ?? "")) * 100) }))
32
+ .filter(({ overlap }) => overlap >= options.limit)
33
+ .map(({ section, overlap }) => ({
34
+ rule: "heading-echo",
35
+ severity: "warning",
36
+ line: 0,
37
+ column: 0,
38
+ quote: section.firstSentence?.text.trim() ?? "",
39
+ values: { heading: section.heading, count: overlap, limit: options.limit, offset: section.firstSentence?.span.start ?? section.span.start },
40
+ }));
@@ -0,0 +1,17 @@
1
+ import type { Detector } from "../plugin.ts";
2
+ import { countPerSection } from "./count-per-section.ts";
3
+ import { sentenceLength } from "./sentence-length.ts";
4
+ import { headingEcho } from "./heading-echo.ts";
5
+ import { repeatedHead } from "./repeated-head.ts";
6
+ import { sentenceRhythm } from "./sentence-rhythm.ts";
7
+ import { phraseMatch } from "./phrase-match.ts";
8
+
9
+ /** rule 定義の how_to_find がここを引く。rule 側は実装を知らない。 */
10
+ export const DETECTORS: Readonly<Record<string, Detector>> = {
11
+ "count-per-section": countPerSection,
12
+ "sentence-length": sentenceLength,
13
+ "heading-echo": headingEcho,
14
+ "repeated-head": repeatedHead,
15
+ "sentence-rhythm": sentenceRhythm,
16
+ "phrase-match": phraseMatch,
17
+ };
@@ -0,0 +1,41 @@
1
+ import type { Detector, Finding, ProseDocument, Sentence } from "../plugin.ts";
2
+
3
+ /** 冒頭は先頭 2 段落ぶん。「どの記事にも当てはまる書き出し」は冒頭にあるときだけ問題。 */
4
+ const OPENING_SENTENCES = 4;
5
+
6
+ /**
7
+ * 見る範囲を絞る。同じ語でも、どこにあるかで意味が変わる。
8
+ * 「まとめると」は結びなら定型だが、本文の途中なら普通の接続。
9
+ */
10
+ const inScope = (doc: ProseDocument, where: string | undefined): readonly Sentence[] => {
11
+ if (where === "opening") return doc.sentences.slice(0, OPENING_SENTENCES);
12
+ if (where === "closing") return doc.sections.at(-1)?.sentences ?? [];
13
+ return doc.sentences;
14
+ };
15
+
16
+ type Hit = { readonly sentence: Sentence; readonly matched: string };
17
+
18
+ const hitsIn = (sentences: readonly Sentence[], patterns: readonly string[]): Hit[] =>
19
+ sentences.flatMap((sentence) => {
20
+ const matched = patterns.find((pattern) => sentence.text.toLowerCase().includes(pattern.toLowerCase()));
21
+ return matched === undefined ? [] : [{ sentence, matched }];
22
+ });
23
+
24
+ /**
25
+ * 語彙表との照合。detector は共通で、語彙だけが言語別に差し替わる。spec §11。
26
+ * これが L2 の全体像で、新しい言語は語彙表を書くだけで動く。
27
+ */
28
+ export const phraseMatch: Detector = (doc, options): Finding[] => {
29
+ const patterns = (options.lexicon ?? []).map((entry) => entry.pattern);
30
+ if (patterns.length === 0) return [];
31
+ const hits = hitsIn(inScope(doc, options.where), patterns);
32
+ if (hits.length < options.limit) return [];
33
+ return hits.map((hit) => ({
34
+ rule: "",
35
+ severity: "warning",
36
+ line: 0,
37
+ column: 0,
38
+ quote: hit.sentence.text.trim(),
39
+ values: { matched: hit.matched, count: hits.length, limit: options.limit, offset: hit.sentence.span.start },
40
+ }));
41
+ };
@@ -0,0 +1,29 @@
1
+ import type { Detector, Finding, Sentence } from "../plugin.ts";
2
+
3
+ const HEAD_CHARS = 6;
4
+
5
+ const headOf = (sentence: Sentence): string => sentence.text.trim().replace(/\s+/gu, "").slice(0, HEAD_CHARS);
6
+
7
+ type Run = { readonly head: string; readonly members: readonly Sentence[] };
8
+
9
+ const runsOf = (sentences: readonly Sentence[]): Run[] =>
10
+ sentences.reduce<Run[]>((acc, sentence) => {
11
+ const head = headOf(sentence);
12
+ const last = acc.at(-1);
13
+ if (last !== undefined && last.head === head && head.length === HEAD_CHARS) {
14
+ return [...acc.slice(0, -1), { head, members: [...last.members, sentence] }];
15
+ }
16
+ return [...acc, { head, members: [sentence] }];
17
+ }, []);
18
+
19
+ export const repeatedHead: Detector = (doc, options): Finding[] =>
20
+ runsOf(doc.sentences)
21
+ .filter((run) => run.members.length > options.limit)
22
+ .map((run) => ({
23
+ rule: "repeated-sentence-head",
24
+ severity: "warning",
25
+ line: 0,
26
+ column: 0,
27
+ quote: run.members.map((sentence) => sentence.text.trim()).join(" "),
28
+ values: { head: run.head, count: run.members.length, limit: options.limit, offset: run.members[0]?.span.start ?? 0 },
29
+ }));
@@ -0,0 +1,21 @@
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
+ };
9
+
10
+ export const sentenceLength: Detector = (doc, options): Finding[] =>
11
+ doc.sentences
12
+ .map((sentence) => ({ sentence, size: measure(doc, sentence) }))
13
+ .filter(({ size }) => size > options.limit)
14
+ .map(({ sentence, size }) => ({
15
+ rule: "max-sentence-length",
16
+ severity: "warning",
17
+ line: 0,
18
+ column: 0,
19
+ quote: sentence.text.trim(),
20
+ values: { count: size, limit: options.limit, offset: sentence.span.start },
21
+ }));
@@ -0,0 +1,28 @@
1
+ import type { Detector, Finding } from "../plugin.ts";
2
+
3
+ /** 文が少ないと変動係数は当てにならない。ここを下回る文書は見ない。 */
4
+ const MIN_SENTENCES = 8;
5
+
6
+ const coefficientOfVariation = (sizes: readonly number[]): number => {
7
+ const mean = sizes.reduce((sum, size) => sum + size, 0) / sizes.length;
8
+ if (mean === 0) return 0;
9
+ const variance = sizes.reduce((sum, size) => sum + (size - mean) ** 2, 0) / sizes.length;
10
+ return Math.sqrt(variance) / mean;
11
+ };
12
+
13
+ export const sentenceRhythm: Detector = (doc, options): Finding[] => {
14
+ const sizes = doc.sentences.map((sentence) => sentence.text.trim().length);
15
+ if (sizes.length < MIN_SENTENCES) return [];
16
+ const spread = Math.round(coefficientOfVariation(sizes) * 100);
17
+ if (spread >= options.limit) return [];
18
+ return [
19
+ {
20
+ rule: "sentence-rhythm",
21
+ severity: "warning",
22
+ line: 0,
23
+ column: 0,
24
+ quote: doc.sentences[0]?.text.trim() ?? "",
25
+ values: { count: spread, limit: options.limit, offset: doc.sentences[0]?.span.start ?? 0 },
26
+ },
27
+ ];
28
+ };
@@ -0,0 +1,154 @@
1
+ import { fromMarkdown } from "mdast-util-from-markdown";
2
+ import { gfmTable } from "micromark-extension-gfm-table";
3
+ import { gfmTableFromMarkdown } from "mdast-util-gfm-table";
4
+ import { frontmatter } from "micromark-extension-frontmatter";
5
+ import { frontmatterFromMarkdown } from "mdast-util-frontmatter";
6
+ import { maskSpans } from "./mask.ts";
7
+ import type { LanguageAdapter, ProseDocument, Section, Sentence, Span } from "./plugin.ts";
8
+
9
+ type Place = { readonly offset?: number | undefined };
10
+ type Node = {
11
+ readonly type: string;
12
+ readonly position?: { readonly start: Place; readonly end: Place } | undefined;
13
+ readonly children?: readonly Node[] | undefined;
14
+ };
15
+
16
+ /** 本文として数えないもの。コードは文章ではなく、表は文章の形をしていない。 */
17
+ const NOT_PROSE = new Set(["code", "inlineCode", "html", "yaml", "toml", "table", "thematicBreak", "definition", "image", "imageReference"]);
18
+
19
+ const spanOf = (node: Node): Span | undefined => {
20
+ const start = node.position?.start.offset;
21
+ const end = node.position?.end.offset;
22
+ return start === undefined || end === undefined ? undefined : { start, end };
23
+ };
24
+
25
+ const walk = (node: Node, visit: (node: Node) => void): void => {
26
+ visit(node);
27
+ (node.children ?? []).forEach((child) => walk(child, visit));
28
+ };
29
+
30
+ const parse = (source: string): Node =>
31
+ fromMarkdown(source, { extensions: [gfmTable(), frontmatter(["yaml"])], mdastExtensions: [gfmTableFromMarkdown(), frontmatterFromMarkdown(["yaml"])] });
32
+
33
+ /** link は `[text](url)` の外側だけを覆う。表示される文字は本文なので残す。 */
34
+ const linkChrome = (node: Node): Span[] => {
35
+ const whole = spanOf(node);
36
+ const first = node.children?.[0];
37
+ const last = node.children?.at(-1);
38
+ const inner = first === undefined || last === undefined ? undefined : { start: spanOf(first)?.start, end: spanOf(last)?.end };
39
+ if (whole === undefined || inner?.start === undefined || inner.end === undefined) return whole === undefined ? [] : [whole];
40
+ return [
41
+ { start: whole.start, end: inner.start },
42
+ { start: inner.end, end: whole.end },
43
+ ];
44
+ };
45
+
46
+ const collectMasks = (root: Node): Span[] => {
47
+ const spans: Span[] = [];
48
+ walk(root, (node) => {
49
+ if (node.type === "link" || node.type === "linkReference") {
50
+ spans.push(...linkChrome(node));
51
+ return;
52
+ }
53
+ if (!NOT_PROSE.has(node.type) && node.type !== "heading") return;
54
+ const span = spanOf(node);
55
+ if (span !== undefined) spans.push(span);
56
+ });
57
+ return spans;
58
+ };
59
+
60
+ const textOf = (node: Node, source: string): string => {
61
+ const parts: string[] = [];
62
+ walk(node, (child) => {
63
+ if (child.type !== "text" && child.type !== "inlineCode") return;
64
+ const span = spanOf(child);
65
+ if (span !== undefined) parts.push(source.slice(span.start, span.end));
66
+ });
67
+ return parts.join("");
68
+ };
69
+
70
+ type Heading = { readonly depth: number; readonly text: string; readonly start: number; readonly end: number };
71
+
72
+ const headingsOf = (root: Node, source: string): Heading[] => {
73
+ const found: Heading[] = [];
74
+ walk(root, (node) => {
75
+ if (node.type !== "heading") return;
76
+ const span = spanOf(node);
77
+ const depth = typeof node === "object" && "depth" in node && typeof node.depth === "number" ? node.depth : 1;
78
+ if (span !== undefined) found.push({ depth, text: textOf(node, source).trim(), start: span.start, end: span.end });
79
+ });
80
+ return found;
81
+ };
82
+
83
+ const strongSpans = (root: Node): Span[] => {
84
+ const found: Span[] = [];
85
+ walk(root, (node) => {
86
+ if (node.type !== "strong") return;
87
+ const span = spanOf(node);
88
+ if (span !== undefined) found.push(span);
89
+ });
90
+ return found;
91
+ };
92
+
93
+ const within = (span: Span, from: number, to: number): boolean => span.start >= from && span.start < to;
94
+
95
+ const paragraphSpans = (root: Node): Span[] => {
96
+ const found: Span[] = [];
97
+ walk(root, (node) => {
98
+ if (node.type !== "paragraph") return;
99
+ const span = spanOf(node);
100
+ if (span !== undefined) found.push(span);
101
+ });
102
+ return found;
103
+ };
104
+
105
+ /**
106
+ * 文の分割は**段落ごと**に行う。
107
+ *
108
+ * 文書全体を一度に渡すと、句点で終わらない行(「条件:」のような見出し的な行)が、
109
+ * 覆った表やコードブロックを越えて次の句点まで飲み込む。段落は文が跨がない境界なので、
110
+ * ここで切れば構造的に起きない。
111
+ */
112
+ const sentencesOf = (prose: string, paragraphs: readonly Span[], adapter: LanguageAdapter): Sentence[] =>
113
+ paragraphs.flatMap((paragraph) =>
114
+ adapter
115
+ .segment(prose.slice(paragraph.start, paragraph.end))
116
+ .sentences.filter((sentence) => sentence.text.trim().length > 0)
117
+ .map((sentence) => ({
118
+ span: { start: paragraph.start + sentence.span.start, end: paragraph.start + sentence.span.end },
119
+ text: sentence.text,
120
+ })),
121
+ );
122
+
123
+ const sectionsOf = (headings: readonly Heading[], sentences: readonly Sentence[], strongs: readonly Span[], length: number): Section[] => {
124
+ const bounds = headings.map((heading, index) => ({ heading, from: heading.end, to: headings[index + 1]?.start ?? length }));
125
+ const lead = { heading: { depth: 0, text: "", start: 0, end: 0 }, from: 0, to: headings[0]?.start ?? length };
126
+ return [lead, ...bounds]
127
+ .filter((bound) => bound.to > bound.from)
128
+ .map(({ heading, from, to }) => {
129
+ const inside = sentences.filter((sentence) => within(sentence.span, from, to));
130
+ return {
131
+ depth: heading.depth,
132
+ heading: heading.text,
133
+ span: { start: from, end: to },
134
+ sentences: inside,
135
+ strongCount: strongs.filter((span) => within(span, from, to)).length,
136
+ firstSentence: inside[0],
137
+ };
138
+ });
139
+ };
140
+
141
+ export const buildDocument = (path: string, source: string, adapter: LanguageAdapter): ProseDocument => {
142
+ const root = parse(source);
143
+ const prose = maskSpans(source, collectMasks(root));
144
+ const sentences = sentencesOf(prose, paragraphSpans(root), adapter);
145
+ return {
146
+ path,
147
+ source,
148
+ language: adapter.id,
149
+ lengthUnit: adapter.capabilities.lengthUnit,
150
+ sections: sectionsOf(headingsOf(root, source), sentences, strongSpans(root), source.length),
151
+ sentences,
152
+ lexicons: adapter.lexicons,
153
+ };
154
+ };
package/src/files.ts ADDED
@@ -0,0 +1,34 @@
1
+ import { globSync, statSync } from "node:fs";
2
+ import { join, relative, sep } from "node:path";
3
+
4
+ const MARKDOWN = [".md", ".markdown", ".mdx"];
5
+
6
+ /** 走査から外す。ここを通すと node_modules の README を延々と検査することになる。 */
7
+ const SKIP = ["node_modules", "dist", "build", "coverage", ".git", ".chaff-cache"];
8
+
9
+ const isMarkdown = (path: string): boolean => MARKDOWN.some((ext) => path.toLowerCase().endsWith(ext));
10
+
11
+ const isSkipped = (path: string): boolean => path.split(sep).some((part) => SKIP.includes(part));
12
+
13
+ const expand = (target: string): string[] => {
14
+ const stat = statSync(target, { throwIfNoEntry: false });
15
+ if (stat === undefined) return globSync(target).filter(isMarkdown);
16
+ if (stat.isFile()) return [target];
17
+ return globSync(join(target, "**", "*.{md,markdown,mdx}"));
18
+ };
19
+
20
+ /**
21
+ * 与えられたファイル・ディレクトリ・glob を、検査する Markdown の一覧にする。
22
+ * 0 件は成功にしない(§14)。glob が何にもマッチしないまま CI が緑になると、
23
+ * 「通っているが何も検証していない」状態が延々と続く。
24
+ */
25
+ /**
26
+ * ロケールを明示する。既定のロケールに任せると、同じ入力でも機械によって順序が変わる。
27
+ * 指摘の並びは CI のログにも baseline にも入るので、順序が揺れてはいけない。
28
+ */
29
+ export const byPath = (left: string, right: string): number => left.localeCompare(right, "en");
30
+
31
+ export const collectTargets = (targets: readonly string[]): string[] => {
32
+ const found = targets.flatMap(expand).filter((path) => !isSkipped(path));
33
+ return [...new Set(found)].sort((left, right) => byPath(relative(".", left), relative(".", right)));
34
+ };
package/src/genre.ts ADDED
@@ -0,0 +1,36 @@
1
+ export type GenreGuess = { readonly genre: string; readonly from: string };
2
+
3
+ const BY_PATH: readonly (readonly [RegExp, string])[] = [
4
+ [/(^|\/)(proposals?|teian)\//iu, "business/proposal"],
5
+ [/(^|\/)(minutes|gijiroku)\//iu, "business/meeting-notes"],
6
+ [/(^|\/)(press|pr)\//iu, "business/press-release"],
7
+ [/(^|\/)(reports?)\//iu, "business/report"],
8
+ [/(^|\/)(blog|posts?|articles?)\//iu, "blog/tech"],
9
+ [/(^|\/)docs?\//iu, "blog/tech"],
10
+ ];
11
+
12
+ /**
13
+ * 見出しと行頭に限る。本文中の言及では判定しない。
14
+ * 「決定事項」という語を説明している文書を議事録と判定してしまうため。
15
+ */
16
+ const BY_CONTENT: readonly (readonly [RegExp, string])[] = [
17
+ [/^#{1,6}\s.*(?:報道関係者各位|For Immediate Release)/mu, "business/press-release"],
18
+ [/^(?:#{1,6}\s)?(?:出席者|決定事項|Attendees)\s*$/mu, "business/meeting-notes"],
19
+ [/^(?:拝啓|お世話になっております)/mu, "business/report"],
20
+ ];
21
+
22
+ export const guessGenre = (path: string, source: string, front: string | undefined): GenreGuess | undefined => {
23
+ if (front !== undefined) return { genre: front, from: "front matter" };
24
+ const byPath = BY_PATH.find(([pattern]) => pattern.test(path));
25
+ if (byPath !== undefined) return { genre: byPath[1], from: "パス" };
26
+ const byContent = BY_CONTENT.find(([pattern]) => pattern.test(source));
27
+ if (byContent !== undefined) return { genre: byContent[1], from: "内容" };
28
+ return undefined;
29
+ };
30
+
31
+ /** front matter の genre / type を拾う。失敗しても落とさない。 */
32
+ export const frontMatterGenre = (source: string): string | undefined => {
33
+ const match = /^---\n([\s\S]*?)\n---/u.exec(source);
34
+ const line = match?.[1] === undefined ? undefined : /^(?:genre|type):\s*(\S+)\s*$/mu.exec(match[1]);
35
+ return line?.[1];
36
+ };
package/src/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ export type { AdapterCapabilities, LanguageAdapter, LengthUnit, Segmentation, Sentence, Span, Token } from "./plugin.ts";
2
+ export type { LanguageGuess } from "./detect.ts";
3
+ export { guessLanguage } from "./detect.ts";
4
+ export { loadAdapter, packageFor } from "./adapter-load.ts";
package/src/init.ts ADDED
@@ -0,0 +1,76 @@
1
+ import { appendFileSync, existsSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ const CONFIG = (genre: string): string => `# chaff.yaml — このチームの文章規範
5
+ #
6
+ # ここに書くのは「既定から変えたもの」だけです。書かなければ既定で動きます。
7
+ # このファイルを消しても chaff は動きます。
8
+ #
9
+ # 値は 4 つの言葉から選びます。数字を書く必要はありません。
10
+ #
11
+ # strict きびしく見る
12
+ # normal ふつう(既定)
13
+ # relaxed ゆるく見る
14
+ # off 見ない
15
+ #
16
+ # コマンドでも変更できます。理由がコメントとして自動で残ります。
17
+ #
18
+ # npx chaff relax bold-density --why "図の説明で太字を多用するため"
19
+ # npx chaff explain bold-density そのルールの意図を読む
20
+ # npx chaff rules --json AI に設定を書かせるときに渡す
21
+
22
+ # この場所に置く文書の種類。
23
+ genre: ${genre}
24
+
25
+ # 既定から変えたものだけを書く。
26
+ rules:
27
+ `;
28
+
29
+ const GITIGNORE_LINE = ".chaff-cache/";
30
+
31
+ type Written = { readonly path: string; readonly note: string };
32
+
33
+ const writeIfAbsent = (path: string, body: string, note: string): Written | undefined => {
34
+ if (existsSync(path)) return undefined;
35
+ writeFileSync(path, body, "utf8");
36
+ return { path, note };
37
+ };
38
+
39
+ /** judge のキャッシュは commit しない。init が .gitignore に足す。 */
40
+ const ensureGitignore = (dir: string): Written | undefined => {
41
+ const path = join(dir, ".gitignore");
42
+ if (!existsSync(path)) return writeIfAbsent(path, `${GITIGNORE_LINE}\n`, "作成しました");
43
+ if (readFileSync(path, "utf8").includes(GITIGNORE_LINE)) return undefined;
44
+ appendFileSync(path, `${GITIGNORE_LINE}\n`, "utf8");
45
+ return { path, note: `${GITIGNORE_LINE} を追記しました` };
46
+ };
47
+
48
+ export const runInit = (dir: string, genre: string): string[] => {
49
+ const made = [writeIfAbsent(join(dir, "chaff.yaml"), CONFIG(genre), "規範の宣言。commit してください"), ensureGitignore(dir)].filter(
50
+ (entry) => entry !== undefined,
51
+ );
52
+ if (made.length === 0) return ["chaff.yaml は既にあります。変更していません。"];
53
+ return [
54
+ "",
55
+ "作成しました:",
56
+ ...made.map((entry) => ` ${entry.path} ${entry.note}`),
57
+ "",
58
+ `ジャンルは ${genre} にしました。違う場合は chaff.yaml の genre を直してください。`,
59
+ " 一覧: npx chaff genres",
60
+ "",
61
+ "次:",
62
+ " npx chaff . この場所の Markdown を全部見る",
63
+ "",
64
+ ];
65
+ };
66
+
67
+ export const GENRES: readonly string[] = [
68
+ "blog/tech",
69
+ "blog/essay",
70
+ "blog/owned-media",
71
+ "business/proposal",
72
+ "business/report",
73
+ "business/email",
74
+ "business/press-release",
75
+ "business/meeting-notes",
76
+ ];
package/src/levels.ts ADDED
@@ -0,0 +1,27 @@
1
+ import type { Level, RuleDefinition } from "./plugin.ts";
2
+
3
+ export const LEVELS: readonly Level[] = ["strict", "normal", "relaxed", "off"];
4
+
5
+ export const isLevel = (value: unknown): value is Level => typeof value === "string" && LEVELS.some((level) => level === value);
6
+
7
+ export type Resolved = { readonly level: Level; readonly limit: number; readonly fellBackToNormal: boolean };
8
+
9
+ /**
10
+ * 4 語を数値にする。spec §18.1。
11
+ *
12
+ * 意味のある段が 2 つしかない rule がある(padded-intro は strict と normal が同値)。
13
+ * そういう rule は levels に 2 つだけ書き、未定義の段は normal に落ちる。
14
+ * 落ちたことを呼ぶ側に伝えるのは、`chaff strict` が「変えたつもりで変わっていない」
15
+ * 状態を作らないようにするため。
16
+ */
17
+ export const resolve = (rule: RuleDefinition, level: Level): Resolved => {
18
+ if (level === "off") return { level, limit: 0, fellBackToNormal: false };
19
+ const direct = rule.levels[level];
20
+ if (direct !== undefined) return { level, limit: direct, fellBackToNormal: false };
21
+ const normal = rule.levels.normal;
22
+ if (normal === undefined) throw new Error(`rule "${rule.id}" has no normal level`);
23
+ return { level: "normal", limit: normal, fellBackToNormal: true };
24
+ };
25
+
26
+ /** その rule が実際に区別できる段だけを返す。CLI の案内に使う。 */
27
+ export const definedLevels = (rule: RuleDefinition): Level[] => LEVELS.filter((level) => level === "off" || rule.levels[level] !== undefined);
package/src/mask.ts ADDED
@@ -0,0 +1,33 @@
1
+ import type { Span } from "./plugin.ts";
2
+
3
+ /**
4
+ * 非 prose(コードブロック・インラインコード・表・見出し・URL)を、
5
+ * **同じ長さの空白**で覆う。改行は残す。
6
+ *
7
+ * 切り出して繋ぐのではなく覆うのは、オフセットを元文字列と一致させ続けるため。
8
+ * 行・列の計算も、引用して見せる範囲も、これで元の位置のまま使える。
9
+ */
10
+ const blankOut = (text: string): string => text.replace(/[^\n]/gu, " ");
11
+
12
+ const merge = (spans: readonly Span[]): Span[] =>
13
+ [...spans]
14
+ .sort((left, right) => left.start - right.start)
15
+ .reduce<Span[]>((acc, span) => {
16
+ const last = acc.at(-1);
17
+ if (last !== undefined && span.start <= last.end) {
18
+ return [...acc.slice(0, -1), { start: last.start, end: Math.max(last.end, span.end) }];
19
+ }
20
+ return [...acc, span];
21
+ }, []);
22
+
23
+ export const maskSpans = (source: string, spans: readonly Span[]): string => {
24
+ const merged = merge(spans);
25
+ const { parts, cursor } = merged.reduce<{ parts: string[]; cursor: number }>(
26
+ (acc, span) => ({
27
+ parts: [...acc.parts, source.slice(acc.cursor, span.start), blankOut(source.slice(span.start, span.end))],
28
+ cursor: span.end,
29
+ }),
30
+ { parts: [], cursor: 0 },
31
+ );
32
+ return [...parts, source.slice(cursor)].join("");
33
+ };