chaffjs 0.6.0 → 0.8.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 (159) hide show
  1. package/README.md +11 -4
  2. package/dist/adapter-load.d.ts +12 -1
  3. package/dist/adapter-load.d.ts.map +1 -1
  4. package/dist/adapter-load.js +37 -13
  5. package/dist/adapter-load.js.map +1 -1
  6. package/dist/cli-args.d.ts +8 -0
  7. package/dist/cli-args.d.ts.map +1 -0
  8. package/dist/cli-args.js +8 -0
  9. package/dist/cli-args.js.map +1 -0
  10. package/dist/cli-text.d.ts +26 -0
  11. package/dist/cli-text.d.ts.map +1 -0
  12. package/dist/cli-text.js +151 -0
  13. package/dist/cli-text.js.map +1 -0
  14. package/dist/cli.d.ts +2 -0
  15. package/dist/cli.d.ts.map +1 -1
  16. package/dist/cli.js +83 -86
  17. package/dist/cli.js.map +1 -1
  18. package/dist/commands/cite.d.ts +2 -1
  19. package/dist/commands/cite.d.ts.map +1 -1
  20. package/dist/commands/cite.js +39 -18
  21. package/dist/commands/cite.js.map +1 -1
  22. package/dist/commands/eval.d.ts.map +1 -1
  23. package/dist/commands/eval.js +1 -1
  24. package/dist/commands/eval.js.map +1 -1
  25. package/dist/commands/skill.d.ts +22 -0
  26. package/dist/commands/skill.d.ts.map +1 -0
  27. package/dist/commands/skill.js +68 -0
  28. package/dist/commands/skill.js.map +1 -0
  29. package/dist/commands/tree.d.ts +11 -1
  30. package/dist/commands/tree.d.ts.map +1 -1
  31. package/dist/commands/tree.js +18 -6
  32. package/dist/commands/tree.js.map +1 -1
  33. package/dist/config/load.d.ts +9 -0
  34. package/dist/config/load.d.ts.map +1 -1
  35. package/dist/config/load.js +27 -1
  36. package/dist/config/load.js.map +1 -1
  37. package/dist/config/rule-problems.d.ts +11 -0
  38. package/dist/config/rule-problems.d.ts.map +1 -0
  39. package/dist/config/rule-problems.js +31 -0
  40. package/dist/config/rule-problems.js.map +1 -0
  41. package/dist/config/write.d.ts.map +1 -1
  42. package/dist/config/write.js +29 -9
  43. package/dist/config/write.js.map +1 -1
  44. package/dist/detectors/index.d.ts.map +1 -1
  45. package/dist/detectors/index.js +3 -0
  46. package/dist/detectors/index.js.map +1 -1
  47. package/dist/detectors/orthography.d.ts +9 -0
  48. package/dist/detectors/orthography.d.ts.map +1 -0
  49. package/dist/detectors/orthography.js +51 -0
  50. package/dist/detectors/orthography.js.map +1 -0
  51. package/dist/document.d.ts +3 -0
  52. package/dist/document.d.ts.map +1 -1
  53. package/dist/document.js +13 -3
  54. package/dist/document.js.map +1 -1
  55. package/dist/eval.d.ts +5 -1
  56. package/dist/eval.d.ts.map +1 -1
  57. package/dist/eval.js +10 -5
  58. package/dist/eval.js.map +1 -1
  59. package/dist/genre.d.ts +1 -1
  60. package/dist/genre.d.ts.map +1 -1
  61. package/dist/genre.js +3 -3
  62. package/dist/genre.js.map +1 -1
  63. package/dist/heading-text.d.ts +14 -0
  64. package/dist/heading-text.d.ts.map +1 -0
  65. package/dist/heading-text.js +28 -0
  66. package/dist/heading-text.js.map +1 -0
  67. package/dist/init.d.ts +2 -1
  68. package/dist/init.d.ts.map +1 -1
  69. package/dist/init.js +62 -13
  70. package/dist/init.js.map +1 -1
  71. package/dist/orthography.d.ts +21 -0
  72. package/dist/orthography.d.ts.map +1 -0
  73. package/dist/orthography.js +110 -0
  74. package/dist/orthography.js.map +1 -0
  75. package/dist/plugin.d.ts +6 -0
  76. package/dist/plugin.d.ts.map +1 -1
  77. package/dist/reasons.d.ts +15 -0
  78. package/dist/reasons.d.ts.map +1 -0
  79. package/dist/reasons.js +29 -0
  80. package/dist/reasons.js.map +1 -0
  81. package/dist/render/explain.d.ts.map +1 -1
  82. package/dist/render/explain.js +36 -12
  83. package/dist/render/explain.js.map +1 -1
  84. package/dist/render/friendly.d.ts.map +1 -1
  85. package/dist/render/friendly.js +32 -9
  86. package/dist/render/friendly.js.map +1 -1
  87. package/dist/render/rules-json.d.ts.map +1 -1
  88. package/dist/render/rules-json.js +57 -27
  89. package/dist/render/rules-json.js.map +1 -1
  90. package/dist/render/summary.d.ts +3 -2
  91. package/dist/render/summary.d.ts.map +1 -1
  92. package/dist/render/summary.js +24 -9
  93. package/dist/render/summary.js.map +1 -1
  94. package/dist/render/suppressions.d.ts +2 -1
  95. package/dist/render/suppressions.d.ts.map +1 -1
  96. package/dist/render/suppressions.js +34 -10
  97. package/dist/render/suppressions.js.map +1 -1
  98. package/dist/run.d.ts +3 -1
  99. package/dist/run.d.ts.map +1 -1
  100. package/dist/run.js +22 -13
  101. package/dist/run.js.map +1 -1
  102. package/dist/structure/build.d.ts.map +1 -1
  103. package/dist/structure/build.js +19 -10
  104. package/dist/structure/build.js.map +1 -1
  105. package/dist/structure/cite.d.ts.map +1 -1
  106. package/dist/structure/cite.js +3 -1
  107. package/dist/structure/cite.js.map +1 -1
  108. package/dist/structure/issues.d.ts +5 -2
  109. package/dist/structure/issues.d.ts.map +1 -1
  110. package/dist/structure/issues.js +27 -9
  111. package/dist/structure/issues.js.map +1 -1
  112. package/dist/structure/numbering.d.ts.map +1 -1
  113. package/dist/structure/numbering.js +11 -6
  114. package/dist/structure/numbering.js.map +1 -1
  115. package/dist/ui.d.ts +14 -0
  116. package/dist/ui.d.ts.map +1 -0
  117. package/dist/ui.js +14 -0
  118. package/dist/ui.js.map +1 -0
  119. package/dist/watch.d.ts +1 -1
  120. package/dist/watch.d.ts.map +1 -1
  121. package/dist/watch.js +2 -2
  122. package/dist/watch.js.map +1 -1
  123. package/package.json +3 -2
  124. package/rules/latin-spacing.yaml +27 -0
  125. package/rules/preferred-term.yaml +27 -0
  126. package/skills/chaff/SKILL.md +61 -0
  127. package/src/adapter-load.ts +41 -10
  128. package/src/cli-args.ts +9 -0
  129. package/src/cli-text.ts +182 -0
  130. package/src/cli.ts +100 -89
  131. package/src/commands/cite.ts +51 -18
  132. package/src/commands/eval.ts +1 -0
  133. package/src/commands/skill.ts +74 -0
  134. package/src/commands/tree.ts +27 -7
  135. package/src/config/load.ts +43 -1
  136. package/src/config/rule-problems.ts +43 -0
  137. package/src/config/write.ts +33 -9
  138. package/src/detectors/index.ts +3 -0
  139. package/src/detectors/orthography.ts +55 -0
  140. package/src/document.ts +23 -4
  141. package/src/eval.ts +16 -5
  142. package/src/genre.ts +4 -4
  143. package/src/heading-text.ts +26 -0
  144. package/src/init.ts +74 -15
  145. package/src/orthography.ts +115 -0
  146. package/src/plugin.ts +6 -0
  147. package/src/reasons.ts +44 -0
  148. package/src/render/explain.ts +46 -12
  149. package/src/render/friendly.ts +42 -10
  150. package/src/render/rules-json.ts +45 -10
  151. package/src/render/summary.ts +34 -9
  152. package/src/render/suppressions.ts +45 -10
  153. package/src/run.ts +35 -12
  154. package/src/structure/build.ts +20 -10
  155. package/src/structure/cite.ts +3 -1
  156. package/src/structure/issues.ts +33 -14
  157. package/src/structure/numbering.ts +12 -7
  158. package/src/ui.ts +23 -0
  159. package/src/watch.ts +2 -2
@@ -14,12 +14,18 @@ export type Config = {
14
14
  readonly aiBackend: BackendName;
15
15
  readonly language: string | undefined;
16
16
  readonly rules: Readonly<Record<string, Level>>;
17
+ /** rules に数値で書いた上限。段階の 4 語では足りないときに、その rule の数値を直接決める。段階は normal として扱う。 */
18
+ readonly limits: Readonly<Record<string, number>>;
19
+ /** rules に書かれていたが読めなかった値。黙って捨てると、書いた設定が効いていないことに気づけない。 */
20
+ readonly unreadableRules: readonly { readonly id: string; readonly value: string }[];
17
21
  readonly experimental: boolean;
18
22
  readonly path: string | undefined;
19
23
  readonly aiModel: string;
20
24
  readonly confidenceThreshold: number;
21
25
  /** チームの言葉。社内でしか通じない語を、チームが自分で並べる。 */
22
26
  readonly jargon: readonly string[];
27
+ /** チームの表記。{ 使わない書き方: 使う書き方 }。「サーバー」ではなく「サーバ」、のように。 */
28
+ readonly prefer: Readonly<Record<string, string>>;
23
29
  /** この種類の文書に無いと困る見出し。チームが自分で決める。 */
24
30
  readonly requiredSections: readonly string[];
25
31
  /** パスごとの上書き。設定ファイルのある場所からの相対で照合する。 */
@@ -35,12 +41,15 @@ export const EMPTY: Config = {
35
41
  genre: undefined,
36
42
  language: undefined,
37
43
  rules: {},
44
+ limits: {},
45
+ unreadableRules: [],
38
46
  experimental: false,
39
47
  path: undefined,
40
48
  aiBackend: DEFAULT_BACKEND,
41
49
  aiModel: defaultModel(DEFAULT_BACKEND),
42
50
  confidenceThreshold: 0.7,
43
51
  jargon: [],
52
+ prefer: {},
44
53
  requiredSections: [],
45
54
  byPath: [],
46
55
  baseDir: process.cwd(),
@@ -48,11 +57,29 @@ export const EMPTY: Config = {
48
57
 
49
58
  const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
50
59
 
60
+ const isLimit = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value) && value > 0;
61
+
62
+ /** 段階の 4 語はそのまま、数値の上限は normal として読む。上限そのものは limitsOf が持つ。 */
51
63
  const rulesOf = (raw: unknown): Record<string, Level> => {
52
64
  if (!isRecord(raw)) return {};
53
- return Object.entries(raw).reduce<Record<string, Level>>((acc, [id, value]) => (isLevel(value) ? { ...acc, [id]: value } : acc), {});
65
+ return Object.fromEntries(
66
+ Object.entries(raw).flatMap(([id, value]): [string, Level][] => {
67
+ if (isLevel(value)) return [[id, value]];
68
+ return isLimit(value) ? [[id, "normal"]] : [];
69
+ }),
70
+ );
54
71
  };
55
72
 
73
+ const limitsOf = (raw: unknown): Record<string, number> =>
74
+ isRecord(raw) ? Object.fromEntries(Object.entries(raw).flatMap(([id, value]): [string, number][] => (isLimit(value) ? [[id, value]] : []))) : {};
75
+
76
+ const unreadableOf = (raw: unknown): { id: string; value: string }[] =>
77
+ isRecord(raw)
78
+ ? Object.entries(raw)
79
+ .filter(([, value]) => !isLevel(value) && !isLimit(value))
80
+ .map(([id, value]) => ({ id, value: JSON.stringify(value) ?? String(value) }))
81
+ : [];
82
+
56
83
  const str = (value: unknown): string | undefined => (typeof value === "string" ? value : undefined);
57
84
 
58
85
  /** files は 1 つの文字列でも配列でも書ける。 */
@@ -72,6 +99,18 @@ const toPathRule = (raw: unknown): PathRule | undefined => {
72
99
  /** 利用者が書く語の並び。空白だけのものは落とす。 */
73
100
  const wordsOf = (raw: unknown): string[] => (Array.isArray(raw) ? raw.map((entry) => String(entry).trim()).filter((entry) => entry.length > 0) : []);
74
101
 
102
+ /** 両側とも空でない文字列の対だけを読む。同じ語どうし(「サーバ: サーバ」)は何も言えないので捨てる。 */
103
+ const preferOf = (raw: unknown): Record<string, string> =>
104
+ isRecord(raw)
105
+ ? Object.fromEntries(
106
+ Object.entries(raw).flatMap(([avoid, use]): [string, string][] => {
107
+ const from = avoid.trim();
108
+ const to = typeof use === "string" ? use.trim() : "";
109
+ return from !== "" && to !== "" && from !== to ? [[from, to]] : [];
110
+ }),
111
+ )
112
+ : {};
113
+
75
114
  const byPathOf = (raw: unknown): PathRule[] => (Array.isArray(raw) ? raw.map(toPathRule).filter((rule) => rule !== undefined) : []);
76
115
 
77
116
  /** 設定ファイルが無くても動く。あっても、既定から変えたものだけが書かれている。spec §18。 */
@@ -84,12 +123,15 @@ export const loadConfig = (path: string): Config => {
84
123
  genre: str(raw["genre"]),
85
124
  language: str(raw["language"]),
86
125
  rules: rulesOf(raw["rules"]),
126
+ limits: limitsOf(raw["rules"]),
127
+ unreadableRules: unreadableOf(raw["rules"]),
87
128
  experimental: raw["experimental"] === true,
88
129
  path,
89
130
  aiBackend: backend,
90
131
  aiModel: str(raw["ai_model"]) ?? defaultModel(backend),
91
132
  confidenceThreshold: typeof raw["confidence_threshold"] === "number" ? raw["confidence_threshold"] : 0.7,
92
133
  jargon: wordsOf(raw["jargon"]),
134
+ prefer: preferOf(raw["prefer"]),
93
135
  requiredSections: wordsOf(raw["required_sections"]),
94
136
  byPath: byPathOf(raw["by_path"]),
95
137
  baseDir: dirname(path),
@@ -0,0 +1,43 @@
1
+ import type { Config } from "./load.ts";
2
+ import type { Texts, UiLanguage } from "../ui.ts";
3
+
4
+ const TEXT: Texts<{
5
+ readonly unknown: (where: string, id: string) => string;
6
+ readonly unreadable: (where: string, id: string, value: string) => string;
7
+ readonly numberOnSemantic: (where: string, id: string) => string;
8
+ }> = {
9
+ ja: {
10
+ unknown: (where, id) => `${where}: ${id} というルールはありません(npx chaff rules --json で一覧が出ます)`,
11
+ unreadable: (where, id, value) => `${where}: ${id} の値 ${value} は読めません(strict / normal / relaxed / off か、正の数)`,
12
+ numberOnSemantic: (where, id) =>
13
+ `${where}: ${id} は意味を読む検査なので数値の上限はありません。normal として動きます(strict / normal / relaxed / off で書いてください)`,
14
+ },
15
+ en: {
16
+ unknown: (where, id) => `${where}: there is no rule named ${id} (npx chaff rules --json lists them)`,
17
+ unreadable: (where, id, value) => `${where}: cannot read ${value} as the level of ${id} (strict / normal / relaxed / off, or a positive number)`,
18
+ numberOnSemantic: (where, id) => `${where}: ${id} reads meaning and has no numeric limit; it runs as normal (write strict / normal / relaxed / off)`,
19
+ },
20
+ };
21
+
22
+ /**
23
+ * chaff.yaml の rules のうち、効いていないもの。知らない rule 名と、読めない値。
24
+ * どちらも黙って捨てると、書いた人は設定したつもりで何も変わっていない。打ち間違いがいちばん多い。
25
+ */
26
+ export const ruleProblems = (
27
+ config: Pick<Config, "rules" | "limits" | "unreadableRules" | "path">,
28
+ known: readonly { readonly id: string; readonly layer: string }[],
29
+ ui: UiLanguage = "ja",
30
+ ): string[] => {
31
+ const text = TEXT[ui];
32
+ const knownIds = new Set(known.map((rule) => rule.id));
33
+ const where = config.path ?? "chaff.yaml";
34
+ const written = [...new Set([...Object.keys(config.rules), ...Object.keys(config.limits), ...config.unreadableRules.map((entry) => entry.id)])];
35
+ const unknown = written.filter((id) => !knownIds.has(id)).map((id) => text.unknown(where, id));
36
+ const unreadable = config.unreadableRules.filter((entry) => knownIds.has(entry.id)).map((entry) => text.unreadable(where, entry.id, entry.value));
37
+ // 意味を読む検査(L4)に閾値は無い。数を書いても normal として動くので、そう言う。
38
+ const semantic = new Set(known.filter((rule) => rule.layer === "L4").map((rule) => rule.id));
39
+ const numberOnSemantic = Object.keys(config.limits)
40
+ .filter((id) => semantic.has(id))
41
+ .map((id) => text.numberOnSemantic(where, id));
42
+ return [...unknown, ...unreadable, ...numberOnSemantic];
43
+ };
@@ -3,14 +3,39 @@ import { Scalar, YAMLMap, isMap, isScalar, parseDocument, type Document } from "
3
3
  import { definedLevels } from "../levels.ts";
4
4
  import { localized } from "../render/text.ts";
5
5
  import type { Level, RuleDefinition } from "../plugin.ts";
6
+ import { uiLanguageOf, type Texts } from "../ui.ts";
6
7
 
7
- const TEMPLATE = `# chaff.yaml — このチームの文章規範
8
+ const TEXT: Texts<{
9
+ readonly template: string;
10
+ readonly noSuchLevel: (id: string, level: string) => string;
11
+ readonly hasReason: (id: string, previous: string) => string;
12
+ readonly done: (id: string, level: string, path: string) => string;
13
+ }> = {
14
+ ja: {
15
+ template: `# chaff.yaml — このチームの文章規範
8
16
  #
9
17
  # ここに書くのは「既定から変えたもの」だけです。
10
18
  # 値は strict / normal / relaxed / off の 4 つから選びます。数字は要りません。
11
19
 
12
20
  rules:
13
- `;
21
+ `,
22
+ noSuchLevel: (id, level) => `${id} に ${level} はありません。normal と同じ設定です。\n設定は変更しませんでした。`,
23
+ hasReason: (id, previous) => `${id} には既に理由が書かれています:\n ${previous}\n値を変えるときは --why で新しい理由を書いてください。`,
24
+ done: (id, level, path) => `${id} を ${level} にしました(${path})`,
25
+ },
26
+ en: {
27
+ template: `# chaff.yaml — this team's writing rules
28
+ #
29
+ # Write only what differs from the defaults.
30
+ # A level is one of strict / normal / relaxed / off. No numbers needed.
31
+
32
+ rules:
33
+ `,
34
+ noSuchLevel: (id, level) => `${id} has no ${level} level; it is the same as normal.\nThe settings were not changed.`,
35
+ hasReason: (id, previous) => `${id} already has a reason:\n ${previous}\nTo change the level, give a new reason with --why.`,
36
+ done: (id, level, path) => `Set ${id} to ${level} (${path})`,
37
+ },
38
+ };
14
39
 
15
40
  export type ChangeOutcome = { readonly ok: boolean; readonly message: string };
16
41
 
@@ -24,7 +49,7 @@ const reasonOf = (value: unknown): string | undefined => {
24
49
 
25
50
  const ruleComment = (rule: RuleDefinition, language: string): string => ` ${localized(rule.name, language)}\n ${localized(rule.why, language)}`;
26
51
 
27
- const load = (path: string): Document => parseDocument(existsSync(path) ? readFileSync(path, "utf8") : TEMPLATE);
52
+ const load = (path: string, language: string): Document => parseDocument(existsSync(path) ? readFileSync(path, "utf8") : TEXT[uiLanguageOf(language)].template);
28
53
 
29
54
  /**
30
55
  * 設定を機械的に書き換えるが、**既存のコメントを壊さない**。
@@ -32,10 +57,9 @@ const load = (path: string): Document => parseDocument(existsSync(path) ? readFi
32
57
  * spec §19.4。
33
58
  */
34
59
  export const applyLevel = (path: string, rule: RuleDefinition, level: Level, why: string | undefined, language: string, who: string): ChangeOutcome => {
35
- if (!definedLevels(rule).includes(level)) {
36
- return { ok: false, message: `${rule.id} に ${level} はありません。normal と同じ設定です。\n設定は変更しませんでした。` };
37
- }
38
- const doc = load(path);
60
+ const text = TEXT[uiLanguageOf(language)];
61
+ if (!definedLevels(rule).includes(level)) return { ok: false, message: text.noSuchLevel(rule.id, level) };
62
+ const doc = load(path, language);
39
63
  const rules = doc.get("rules");
40
64
  const map = isMap(rules) ? rules : new YAMLMap();
41
65
  // 人が読んで書き足すファイルなので、必ず block style にする。flow style({ a: b })は読めない。
@@ -45,7 +69,7 @@ export const applyLevel = (path: string, rule: RuleDefinition, level: Level, why
45
69
  const previous = reasonOf(existing?.value);
46
70
  // 古い理由が新しい値に付いたまま残ると、履歴が嘘になる。
47
71
  if (previous !== undefined && why === undefined) {
48
- return { ok: false, message: `${rule.id} には既に理由が書かれています:\n ${previous}\n値を変えるときは --why で新しい理由を書いてください。` };
72
+ return { ok: false, message: text.hasReason(rule.id, previous) };
49
73
  }
50
74
  // 取り除いて足し直すと、元の rule に付いていた説明コメントが行き場を失って残る。
51
75
  // 既にある鍵は、その場で値だけ差し替える。
@@ -59,5 +83,5 @@ export const applyLevel = (path: string, rule: RuleDefinition, level: Level, why
59
83
  existing.value = value;
60
84
  }
61
85
  writeFileSync(path, String(doc), "utf8");
62
- return { ok: true, message: `${rule.id} を ${level} にしました(${path})` };
86
+ return { ok: true, message: text.done(rule.id, level, path) };
63
87
  };
@@ -14,6 +14,7 @@ import { paragraphLength, paragraphVariance, preambleLength, ruleOfThree, sectio
14
14
  import { concreteEvidence, dashDensity, emojiDensity, ngramRepetition, undefinedAcronym } from "./signals.ts";
15
15
  import { aiTell, contractionMix, cushionDensity, hedgingDensity, repeatedConjunction, unqualifiedSuperlative } from "./lexicon.ts";
16
16
  import { internalJargon, properNounDensity, requiredSections } from "./team.ts";
17
+ import { latinSpacing, preferredTerm } from "./orthography.ts";
17
18
  import { danglingReference, duplicateDefinition, numberingGap } from "./structure-tree.ts";
18
19
 
19
20
  /** rule 定義の how_to_find がここを引く。rule 側は実装を知らない。 */
@@ -54,6 +55,8 @@ export const DETECTORS: Readonly<Record<string, Detector>> = {
54
55
  "internal-jargon": internalJargon,
55
56
  "required-sections": requiredSections,
56
57
  "proper-noun-density": properNounDensity,
58
+ "preferred-term": preferredTerm,
59
+ "latin-spacing": latinSpacing,
57
60
  "dangling-reference": danglingReference,
58
61
  "numbering-gap": numberingGap,
59
62
  "duplicate-definition": duplicateDefinition,
@@ -0,0 +1,55 @@
1
+ import type { Detector, Finding, Sentence } from "../plugin.ts";
2
+ import { latinBoundaries, minorityStyle, occurrencesOutside, type SpacingKind } from "../orthography.ts";
3
+
4
+ /** チームが chaff.yaml の prefer に書いた「使わない書き方」。書いていなければ何も言わない。 */
5
+ export const preferredTerm: Detector = (doc, options): Finding[] => {
6
+ const pairs = (options.lexicon ?? []).flatMap((entry) => (entry.instead_of === undefined ? [] : [{ avoid: entry.pattern, use: entry.instead_of }]));
7
+ const hits = doc.sentences.flatMap((sentence) =>
8
+ pairs.flatMap((pair) => occurrencesOutside(sentence.text, pair.avoid, pair.use).map((at) => ({ sentence, at, pair }))),
9
+ );
10
+ if (hits.length === 0 || hits.length < options.limit) return [];
11
+ return hits.map((hit) => ({
12
+ rule: "",
13
+ severity: "warning",
14
+ line: 0,
15
+ column: 0,
16
+ quote: hit.sentence.text.trim(),
17
+ values: { matched: hit.pair.avoid, preferred: hit.pair.use, count: hits.length, limit: options.limit, offset: hit.sentence.span.start + hit.at },
18
+ }));
19
+ };
20
+
21
+ const KIND_NAME: Readonly<Record<SpacingKind, string>> = { letter: "英字", "before-digit": "後ろの数字", "after-digit": "前の数字" };
22
+ const KINDS: readonly SpacingKind[] = ["letter", "before-digit", "after-digit"];
23
+
24
+ type Located = { readonly sentence: Sentence; readonly offset: number; readonly kind: SpacingKind; readonly spaced: boolean };
25
+
26
+ /**
27
+ * 日本語と英字・数字のあいだを、空けるか詰めるか。文書の中で混ざっていたら、少ないほうを指摘する。
28
+ * どちらが正しいかは決めない。決めるのはチームで、chaff はそろっているかだけを見る。
29
+ */
30
+ export const latinSpacing: Detector = (doc, options): Finding[] => {
31
+ const located: Located[] = doc.sentences.flatMap((sentence) => latinBoundaries(sentence.text).map((boundary) => ({ sentence, ...boundary })));
32
+ return KINDS.flatMap((kind) => {
33
+ const ofKind = located.filter((entry) => entry.kind === kind);
34
+ const minority = minorityStyle(ofKind);
35
+ if (minority === undefined) return [];
36
+ const odd = ofKind.filter((entry) => entry.spaced === minority);
37
+ if (odd.length < options.limit) return [];
38
+ return odd.map((entry) => ({
39
+ rule: "",
40
+ severity: "warning",
41
+ line: 0,
42
+ column: 0,
43
+ quote: entry.sentence.text.trim(),
44
+ values: {
45
+ kind: KIND_NAME[kind],
46
+ style: minority ? "空けています" : "詰めています",
47
+ usual: minority ? "詰める" : "空ける",
48
+ count: odd.length,
49
+ of: ofKind.length,
50
+ limit: options.limit,
51
+ offset: entry.sentence.span.start + entry.offset,
52
+ },
53
+ }));
54
+ });
55
+ };
package/src/document.ts CHANGED
@@ -3,6 +3,7 @@ import { gfmTable } from "micromark-extension-gfm-table";
3
3
  import { gfmTableFromMarkdown } from "mdast-util-gfm-table";
4
4
  import { frontmatter } from "micromark-extension-frontmatter";
5
5
  import { frontmatterFromMarkdown } from "mdast-util-frontmatter";
6
+ import { atxHeadingText, headingText } from "./heading-text.ts";
6
7
  import { maskSpans } from "./mask.ts";
7
8
  import { buildTree, NO_OUTLINE, type Outline } from "./structure/build.ts";
8
9
  import { isMarkdownPath } from "./structure/markdown-path.ts";
@@ -130,7 +131,10 @@ const headingsOf = (root: Node, source: string): Heading[] => {
130
131
  if (node.type !== "heading") return;
131
132
  const span = spanOf(node);
132
133
  const depth = typeof node === "object" && "depth" in node && typeof node.depth === "number" ? node.depth : 1;
133
- if (span !== undefined) found.push({ depth, text: textOf(node, source).trim(), start: span.start, end: span.end });
134
+ if (span === undefined) return;
135
+ // ATX(行頭が #)なら閉じの # も外す。setext の見出しの末尾の # は言葉なので残す。
136
+ const read = source.startsWith("#", span.start) ? atxHeadingText : headingText;
137
+ found.push({ depth, text: read(textOf(node, source)), start: span.start, end: span.end });
134
138
  });
135
139
  return found;
136
140
  };
@@ -229,13 +233,23 @@ const listsOf = (root: Node, source: string): BulletList[] => {
229
233
  };
230
234
 
231
235
  /** チームが chaff.yaml に書いたもの。語彙表と同じ器に入れて、detector には出所を見せない。 */
232
- export type TeamRules = { readonly jargon: readonly string[]; readonly requiredSections: readonly string[] };
236
+ export type TeamRules = {
237
+ readonly jargon: readonly string[];
238
+ readonly requiredSections: readonly string[];
239
+ /** { 使わない書き方: 使う書き方 }。preferred-term が語彙表として読む。 */
240
+ readonly prefer?: Readonly<Record<string, string>>;
241
+ };
233
242
 
234
243
  const EMPTY_TEAM: TeamRules = { jargon: [], requiredSections: [] };
235
244
 
236
245
  /** Config から取り出す。document は Config の形を知らない。 */
237
- export const teamRules = (config: { readonly jargon: readonly string[]; readonly requiredSections: readonly string[] }): TeamRules => ({
246
+ export const teamRules = (config: {
247
+ readonly jargon: readonly string[];
248
+ readonly requiredSections: readonly string[];
249
+ readonly prefer?: Readonly<Record<string, string>>;
250
+ }): TeamRules => ({
238
251
  jargon: config.jargon,
252
+ prefer: config.prefer ?? {},
239
253
  requiredSections: config.requiredSections,
240
254
  });
241
255
 
@@ -263,7 +277,12 @@ export const buildDocument = (path: string, source: string, adapter: LanguageAda
263
277
  listSpans: listItems,
264
278
  paragraphs: paragraphsOf(paragraphSpans, sentences, listItems),
265
279
  lists: listsOf(root, source),
266
- lexicons: { ...adapter.lexicons, "internal-jargon": team.jargon.map((pattern) => ({ pattern })) },
280
+ lexicons: {
281
+ ...adapter.lexicons,
282
+ "internal-jargon": team.jargon.map((pattern) => ({ pattern })),
283
+ // 使わない書き方を pattern に、使う書き方を instead_of に置く。語彙表の「同じことの別の書き方」と同じ向き。
284
+ "preferred-term": Object.entries(team.prefer ?? {}).map(([pattern, use]) => ({ pattern, instead_of: use })),
285
+ },
267
286
  requiredSections: team.requiredSections,
268
287
  // 構造の rule(参照先が無い・番号の抜け)が読む木。どの rule も読まなければ作らない。何万行の契約書で、他の rule の lint に代金を払わせない。
269
288
  get structure(): StructureNode | undefined {
package/src/eval.ts CHANGED
@@ -35,11 +35,12 @@ const stricterIsHigher = (rule: RuleDefinition): boolean => (rule.levels.strict
35
35
  const MULTIPLIERS = [0.5, 0.7, 0.85, 1, 1.25, 1.5, 2, 3];
36
36
 
37
37
  /** rule 自身が持つ段を基点に掃引する。rule ごとに単位が違うので、倍率で散らす。 */
38
- const candidates = (rule: RuleDefinition): number[] => {
38
+ const candidates = (rule: RuleDefinition, current: number): number[] => {
39
39
  const base = rule.levels.normal ?? 1;
40
40
  const fromLevels = [rule.levels.strict, rule.levels.normal, rule.levels.relaxed].filter((value) => value !== undefined);
41
41
  const scaled = MULTIPLIERS.map((factor) => Math.max(1, Math.round(base * factor)));
42
- return [...new Set([...fromLevels, ...scaled])].sort((left, right) => left - right);
42
+ // 数値で決めた上限は、段の表に無くても掃引に入れる。入れないと「現在」の行が出ない。
43
+ return [...new Set([...fromLevels, ...scaled, current])].sort((left, right) => left - right);
43
44
  };
44
45
 
45
46
  const countAt = (docs: readonly ProseDocument[], rule: RuleDefinition, limit: number): Point => {
@@ -84,12 +85,22 @@ const measurable = (rule: RuleDefinition, docs: readonly ProseDocument[], genre:
84
85
  return rule.requires.every((need) => meets(need, docs));
85
86
  };
86
87
 
87
- export const evaluate = (docs: readonly ProseDocument[], rules: readonly RuleDefinition[], genre: string, language: string): RuleReport[] =>
88
+ /**
89
+ * limits は chaff.yaml に数値で書いた上限。書いてあれば、それが lint で効いている「現在」の値。
90
+ * 段の言葉(relaxed など)で書いた設定は、これまでどおり normal の値を「現在」とする。
91
+ */
92
+ export const evaluate = (
93
+ docs: readonly ProseDocument[],
94
+ rules: readonly RuleDefinition[],
95
+ genre: string,
96
+ language: string,
97
+ limits: Readonly<Record<string, number>> = {},
98
+ ): RuleReport[] =>
88
99
  rules
89
100
  .filter((rule) => measurable(rule, docs, genre, language))
90
101
  .map((rule) => {
91
- const sweep = candidates(rule).map((limit) => countAt(docs, rule, limit));
92
- const current = rule.levels.normal ?? 1;
102
+ const current = limits[rule.id] ?? rule.levels.normal ?? 1;
103
+ const sweep = candidates(rule, current).map((limit) => countAt(docs, rule, limit));
93
104
  return {
94
105
  rule: rule.id,
95
106
  name: rule.name[language] ?? rule.id,
package/src/genre.ts CHANGED
@@ -1,4 +1,4 @@
1
- export type GenreGuess = { readonly genre: string; readonly from: string };
1
+ export type GenreGuess = { readonly genre: string; readonly from: "front-matter" | "path" | "content" };
2
2
 
3
3
  /**
4
4
  * 仕様書・README は「読ませる文章」ではなく「間違えさせない文章」。
@@ -27,11 +27,11 @@ const BY_CONTENT: readonly (readonly [RegExp, string])[] = [
27
27
  ];
28
28
 
29
29
  export const guessGenre = (path: string, source: string, front: string | undefined): GenreGuess | undefined => {
30
- if (front !== undefined) return { genre: front, from: "front matter" };
30
+ if (front !== undefined) return { genre: front, from: "front-matter" };
31
31
  const byPath = BY_PATH.find(([pattern]) => pattern.test(path));
32
- if (byPath !== undefined) return { genre: byPath[1], from: "パス" };
32
+ if (byPath !== undefined) return { genre: byPath[1], from: "path" };
33
33
  const byContent = BY_CONTENT.find(([pattern]) => pattern.test(source));
34
- if (byContent !== undefined) return { genre: byContent[1], from: "内容" };
34
+ if (byContent !== undefined) return { genre: byContent[1], from: "content" };
35
35
  return undefined;
36
36
  };
37
37
 
@@ -0,0 +1,26 @@
1
+ /**
2
+ * 見出しの末尾に付けた属性を外した、見出しの言葉。`## ステップ 1 {#step1}` は「ステップ 1」。
3
+ * kramdown(`{#id}`、`{: .class}`)と pandoc(`{#id .class}`)の書き方。属性は読者に見えないので、
4
+ * 残すと見出しの言い直しや番号の読み取りが、見えない文字に引きずられる。
5
+ * 波括弧で終わっていても、中身が `#` `.` `:` で始まらないもの(`## {name} の設定`)は言葉のうち。
6
+ */
7
+ export const headingText = (raw: string): string => {
8
+ const trimmed = raw.trim();
9
+ if (!trimmed.endsWith("}")) return trimmed;
10
+ const open = trimmed.lastIndexOf("{");
11
+ if (open === -1) return trimmed;
12
+ const inner = trimmed.slice(open + 1, -1).trimStart();
13
+ const isAttributes = inner.startsWith("#") || inner.startsWith(".") || inner.startsWith(":");
14
+ return isAttributes ? trimmed.slice(0, open).trim() : trimmed;
15
+ };
16
+
17
+ /**
18
+ * ATX 見出し(`## 見出し ##`)の言葉。属性を外してから、閉じの `#` を外す。
19
+ * `## Install ## {#install}` は閉じの `#` の後ろに属性があるので、この順でないと `##` が言葉に残る。
20
+ * 下線で書く見出し(setext)には使わない。そちらの末尾の `##` は本当に言葉の一部。
21
+ */
22
+ export const atxHeadingText = (content: string): string => {
23
+ const withoutAttributes = headingText(content);
24
+ const closing = withoutAttributes.search(/(?:^|[ \t])#+$/u);
25
+ return (closing === -1 ? withoutAttributes : withoutAttributes.slice(0, closing)).trim();
26
+ };
package/src/init.ts CHANGED
@@ -1,7 +1,21 @@
1
1
  import { appendFileSync, existsSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
+ import type { Texts, UiLanguage } from "./ui.ts";
3
4
 
4
- const CONFIG = (genre: string): string => `# chaff.yaml — このチームの文章規範
5
+ const TEXT: Texts<{
6
+ readonly config: (genre: string) => string;
7
+ readonly created: string;
8
+ readonly appended: (lines: readonly string[]) => string;
9
+ readonly configNote: string;
10
+ readonly exists: string;
11
+ readonly createdHeading: string;
12
+ readonly genreChosen: (genre: string) => string;
13
+ readonly genreList: string;
14
+ readonly next: string;
15
+ readonly nextCheck: string;
16
+ }> = {
17
+ ja: {
18
+ config: (genre) => `# chaff.yaml — このチームの文章規範
5
19
  #
6
20
  # ここに書くのは「既定から変えたもの」だけです。書かなければ既定で動きます。
7
21
  # このファイルを消しても chaff は動きます。
@@ -24,7 +38,53 @@ genre: ${genre}
24
38
 
25
39
  # 既定から変えたものだけを書く。
26
40
  rules:
27
- `;
41
+ `,
42
+ created: "作成しました",
43
+ appended: (lines) => `${lines.join(" と ")} を追記しました`,
44
+ configNote: "規範の宣言。commit してください",
45
+ exists: "chaff.yaml は既にあります。変更していません。",
46
+ createdHeading: "作成しました:",
47
+ genreChosen: (genre) => `ジャンルは ${genre} にしました。違う場合は chaff.yaml の genre を直してください。`,
48
+ genreList: " 一覧: npx chaff genres",
49
+ next: "次:",
50
+ nextCheck: " npx chaff . この場所の Markdown を全部見る",
51
+ },
52
+ en: {
53
+ config: (genre) => `# chaff.yaml — this team's writing rules
54
+ #
55
+ # Write only what differs from the defaults; anything left out uses the default.
56
+ # chaff still runs if this file is deleted.
57
+ #
58
+ # A level is one of four words. No numbers needed.
59
+ #
60
+ # strict check closely
61
+ # normal the default
62
+ # relaxed check loosely
63
+ # off do not check
64
+ #
65
+ # Commands change it too, and leave the reason as a comment.
66
+ #
67
+ # npx chaff relax bold-density --why "figure captions use a lot of bold"
68
+ # npx chaff explain bold-density read what the rule is for
69
+ # npx chaff rules --json give this to an AI that writes the settings
70
+
71
+ # The kind of document kept here.
72
+ genre: ${genre}
73
+
74
+ # Only what differs from the defaults.
75
+ rules:
76
+ `,
77
+ created: "created",
78
+ appended: (lines) => `added ${lines.join(" and ")}`,
79
+ configNote: "the team's rules; commit it",
80
+ exists: "chaff.yaml already exists. Nothing was changed.",
81
+ createdHeading: "Created:",
82
+ genreChosen: (genre) => `The genre is ${genre}. If that is wrong, change genre in chaff.yaml.`,
83
+ genreList: " List: npx chaff genres",
84
+ next: "Next:",
85
+ nextCheck: " npx chaff . check every Markdown file here",
86
+ },
87
+ };
28
88
 
29
89
  /** 判定のキャッシュと、鍵を書くファイル。どちらも commit しない。 */
30
90
  const GITIGNORE_LINES = [".chaff-cache/", ".env*"];
@@ -38,31 +98,30 @@ const writeIfAbsent = (path: string, body: string, note: string): Written | unde
38
98
  };
39
99
 
40
100
  /** 判定のキャッシュと .env は commit しない。init が .gitignore に足す。 */
41
- const ensureGitignore = (dir: string): Written | undefined => {
101
+ const ensureGitignore = (dir: string, text: (typeof TEXT)["ja"]): Written | undefined => {
42
102
  const path = join(dir, ".gitignore");
43
- if (!existsSync(path)) return writeIfAbsent(path, `${GITIGNORE_LINES.join("\n")}\n`, "作成しました");
103
+ if (!existsSync(path)) return writeIfAbsent(path, `${GITIGNORE_LINES.join("\n")}\n`, text.created);
44
104
  const body = readFileSync(path, "utf8");
45
105
  const missing = GITIGNORE_LINES.filter((line) => !body.split("\n").includes(line));
46
106
  if (missing.length === 0) return undefined;
47
107
  appendFileSync(path, `${missing.join("\n")}\n`, "utf8");
48
- return { path, note: `${missing.join(" と ")} を追記しました` };
108
+ return { path, note: text.appended(missing) };
49
109
  };
50
110
 
51
- export const runInit = (dir: string, genre: string): string[] => {
52
- const made = [writeIfAbsent(join(dir, "chaff.yaml"), CONFIG(genre), "規範の宣言。commit してください"), ensureGitignore(dir)].filter(
53
- (entry) => entry !== undefined,
54
- );
55
- if (made.length === 0) return ["chaff.yaml は既にあります。変更していません。"];
111
+ export const runInit = (dir: string, genre: string, ui: UiLanguage = "ja"): string[] => {
112
+ const text = TEXT[ui];
113
+ const made = [writeIfAbsent(join(dir, "chaff.yaml"), text.config(genre), text.configNote), ensureGitignore(dir, text)].filter((entry) => entry !== undefined);
114
+ if (made.length === 0) return [text.exists];
56
115
  return [
57
116
  "",
58
- "作成しました:",
117
+ text.createdHeading,
59
118
  ...made.map((entry) => ` ${entry.path} ${entry.note}`),
60
119
  "",
61
- `ジャンルは ${genre} にしました。違う場合は chaff.yaml の genre を直してください。`,
62
- " 一覧: npx chaff genres",
120
+ text.genreChosen(genre),
121
+ text.genreList,
63
122
  "",
64
- "次:",
65
- " npx chaff . この場所の Markdown を全部見る",
123
+ text.next,
124
+ text.nextCheck,
66
125
  "",
67
126
  ];
68
127
  };