chaffjs 0.7.0 → 0.9.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 (215) 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/address-chain.d.ts +9 -0
  7. package/dist/address-chain.d.ts.map +1 -0
  8. package/dist/address-chain.js +105 -0
  9. package/dist/address-chain.js.map +1 -0
  10. package/dist/cli-args.d.ts +8 -0
  11. package/dist/cli-args.d.ts.map +1 -0
  12. package/dist/cli-args.js +8 -0
  13. package/dist/cli-args.js.map +1 -0
  14. package/dist/cli-text.d.ts +26 -0
  15. package/dist/cli-text.d.ts.map +1 -0
  16. package/dist/cli-text.js +155 -0
  17. package/dist/cli-text.js.map +1 -0
  18. package/dist/cli.d.ts +2 -0
  19. package/dist/cli.d.ts.map +1 -1
  20. package/dist/cli.js +94 -87
  21. package/dist/cli.js.map +1 -1
  22. package/dist/commands/cite.d.ts +2 -1
  23. package/dist/commands/cite.d.ts.map +1 -1
  24. package/dist/commands/cite.js +39 -18
  25. package/dist/commands/cite.js.map +1 -1
  26. package/dist/commands/eval.d.ts.map +1 -1
  27. package/dist/commands/eval.js +2 -1
  28. package/dist/commands/eval.js.map +1 -1
  29. package/dist/commands/feedback.d.ts +35 -0
  30. package/dist/commands/feedback.d.ts.map +1 -0
  31. package/dist/commands/feedback.js +157 -0
  32. package/dist/commands/feedback.js.map +1 -0
  33. package/dist/commands/skill.d.ts +22 -0
  34. package/dist/commands/skill.d.ts.map +1 -0
  35. package/dist/commands/skill.js +68 -0
  36. package/dist/commands/skill.js.map +1 -0
  37. package/dist/commands/test.d.ts.map +1 -1
  38. package/dist/commands/test.js +3 -2
  39. package/dist/commands/test.js.map +1 -1
  40. package/dist/commands/tree.d.ts +11 -1
  41. package/dist/commands/tree.d.ts.map +1 -1
  42. package/dist/commands/tree.js +21 -7
  43. package/dist/commands/tree.js.map +1 -1
  44. package/dist/config/by-path.d.ts +5 -1
  45. package/dist/config/by-path.d.ts.map +1 -1
  46. package/dist/config/by-path.js +1 -4
  47. package/dist/config/by-path.js.map +1 -1
  48. package/dist/config/load.d.ts +2 -0
  49. package/dist/config/load.d.ts.map +1 -1
  50. package/dist/config/load.js +3 -1
  51. package/dist/config/load.js.map +1 -1
  52. package/dist/config/rule-problems.d.ts +2 -1
  53. package/dist/config/rule-problems.d.ts.map +1 -1
  54. package/dist/config/rule-problems.js +17 -6
  55. package/dist/config/rule-problems.js.map +1 -1
  56. package/dist/config/write.d.ts.map +1 -1
  57. package/dist/config/write.js +29 -9
  58. package/dist/config/write.js.map +1 -1
  59. package/dist/detectors/char-shape.d.ts.map +1 -1
  60. package/dist/detectors/char-shape.js +3 -2
  61. package/dist/detectors/char-shape.js.map +1 -1
  62. package/dist/document.d.ts +2 -2
  63. package/dist/document.d.ts.map +1 -1
  64. package/dist/document.js +4 -2
  65. package/dist/document.js.map +1 -1
  66. package/dist/feedback/draft.d.ts +28 -0
  67. package/dist/feedback/draft.d.ts.map +1 -0
  68. package/dist/feedback/draft.js +66 -0
  69. package/dist/feedback/draft.js.map +1 -0
  70. package/dist/feedback/excerpt.d.ts +10 -0
  71. package/dist/feedback/excerpt.d.ts.map +1 -0
  72. package/dist/feedback/excerpt.js +20 -0
  73. package/dist/feedback/excerpt.js.map +1 -0
  74. package/dist/genre.d.ts +1 -1
  75. package/dist/genre.d.ts.map +1 -1
  76. package/dist/genre.js +3 -3
  77. package/dist/genre.js.map +1 -1
  78. package/dist/init.d.ts +2 -1
  79. package/dist/init.d.ts.map +1 -1
  80. package/dist/init.js +62 -13
  81. package/dist/init.js.map +1 -1
  82. package/dist/legal-address.d.ts +2 -0
  83. package/dist/legal-address.d.ts.map +1 -0
  84. package/dist/legal-address.js +16 -0
  85. package/dist/legal-address.js.map +1 -0
  86. package/dist/orthography.d.ts.map +1 -1
  87. package/dist/orthography.js +15 -0
  88. package/dist/orthography.js.map +1 -1
  89. package/dist/plugin.d.ts +94 -0
  90. package/dist/plugin.d.ts.map +1 -1
  91. package/dist/profile/for-file.d.ts +7 -0
  92. package/dist/profile/for-file.d.ts.map +1 -0
  93. package/dist/profile/for-file.js +6 -0
  94. package/dist/profile/for-file.js.map +1 -0
  95. package/dist/profile/load.d.ts +4 -0
  96. package/dist/profile/load.d.ts.map +1 -0
  97. package/dist/profile/load.js +25 -0
  98. package/dist/profile/load.js.map +1 -0
  99. package/dist/profile/parse.d.ts +19 -0
  100. package/dist/profile/parse.d.ts.map +1 -0
  101. package/dist/profile/parse.js +82 -0
  102. package/dist/profile/parse.js.map +1 -0
  103. package/dist/profile/select.d.ts +22 -0
  104. package/dist/profile/select.d.ts.map +1 -0
  105. package/dist/profile/select.js +21 -0
  106. package/dist/profile/select.js.map +1 -0
  107. package/dist/reasons.d.ts +16 -0
  108. package/dist/reasons.d.ts.map +1 -0
  109. package/dist/reasons.js +31 -0
  110. package/dist/reasons.js.map +1 -0
  111. package/dist/render/explain.d.ts.map +1 -1
  112. package/dist/render/explain.js +36 -12
  113. package/dist/render/explain.js.map +1 -1
  114. package/dist/render/friendly.d.ts.map +1 -1
  115. package/dist/render/friendly.js +32 -9
  116. package/dist/render/friendly.js.map +1 -1
  117. package/dist/render/rules-json.d.ts.map +1 -1
  118. package/dist/render/rules-json.js +47 -27
  119. package/dist/render/rules-json.js.map +1 -1
  120. package/dist/render/summary.d.ts +3 -2
  121. package/dist/render/summary.d.ts.map +1 -1
  122. package/dist/render/summary.js +24 -9
  123. package/dist/render/summary.js.map +1 -1
  124. package/dist/render/suppressions.d.ts +2 -1
  125. package/dist/render/suppressions.d.ts.map +1 -1
  126. package/dist/render/suppressions.js +34 -10
  127. package/dist/render/suppressions.js.map +1 -1
  128. package/dist/run.d.ts.map +1 -1
  129. package/dist/run.js +41 -12
  130. package/dist/run.js.map +1 -1
  131. package/dist/structure/build.d.ts +2 -1
  132. package/dist/structure/build.d.ts.map +1 -1
  133. package/dist/structure/build.js +102 -9
  134. package/dist/structure/build.js.map +1 -1
  135. package/dist/structure/cite.d.ts.map +1 -1
  136. package/dist/structure/cite.js +3 -1
  137. package/dist/structure/cite.js.map +1 -1
  138. package/dist/structure/issues.d.ts +5 -2
  139. package/dist/structure/issues.d.ts.map +1 -1
  140. package/dist/structure/issues.js +35 -10
  141. package/dist/structure/issues.js.map +1 -1
  142. package/dist/structure/numbering.d.ts.map +1 -1
  143. package/dist/structure/numbering.js +11 -6
  144. package/dist/structure/numbering.js.map +1 -1
  145. package/dist/structure/of.d.ts +2 -1
  146. package/dist/structure/of.d.ts.map +1 -1
  147. package/dist/structure/of.js +7 -1
  148. package/dist/structure/of.js.map +1 -1
  149. package/dist/structure/relative-find.d.ts +6 -0
  150. package/dist/structure/relative-find.d.ts.map +1 -0
  151. package/dist/structure/relative-find.js +179 -0
  152. package/dist/structure/relative-find.js.map +1 -0
  153. package/dist/structure/relative-resolve.d.ts +3 -0
  154. package/dist/structure/relative-resolve.d.ts.map +1 -0
  155. package/dist/structure/relative-resolve.js +175 -0
  156. package/dist/structure/relative-resolve.js.map +1 -0
  157. package/dist/structure/unread.d.ts +8 -0
  158. package/dist/structure/unread.d.ts.map +1 -0
  159. package/dist/structure/unread.js +34 -0
  160. package/dist/structure/unread.js.map +1 -0
  161. package/dist/ui.d.ts +14 -0
  162. package/dist/ui.d.ts.map +1 -0
  163. package/dist/ui.js +14 -0
  164. package/dist/ui.js.map +1 -0
  165. package/dist/watch.d.ts +1 -1
  166. package/dist/watch.d.ts.map +1 -1
  167. package/dist/watch.js +2 -2
  168. package/dist/watch.js.map +1 -1
  169. package/package.json +5 -3
  170. package/profiles/statute.yaml +62 -0
  171. package/skills/chaff/SKILL.md +81 -0
  172. package/src/adapter-load.ts +41 -10
  173. package/src/address-chain.ts +113 -0
  174. package/src/cli-args.ts +9 -0
  175. package/src/cli-text.ts +186 -0
  176. package/src/cli.ts +111 -90
  177. package/src/commands/cite.ts +51 -18
  178. package/src/commands/eval.ts +2 -1
  179. package/src/commands/feedback.ts +210 -0
  180. package/src/commands/skill.ts +74 -0
  181. package/src/commands/test.ts +3 -2
  182. package/src/commands/tree.ts +30 -8
  183. package/src/config/by-path.ts +11 -3
  184. package/src/config/load.ts +5 -1
  185. package/src/config/rule-problems.ts +24 -5
  186. package/src/config/write.ts +33 -9
  187. package/src/detectors/char-shape.ts +5 -4
  188. package/src/document.ts +11 -3
  189. package/src/feedback/draft.ts +102 -0
  190. package/src/feedback/excerpt.ts +23 -0
  191. package/src/genre.ts +4 -4
  192. package/src/init.ts +74 -15
  193. package/src/orthography.ts +15 -0
  194. package/src/plugin.ts +88 -0
  195. package/src/profile/for-file.ts +11 -0
  196. package/src/profile/load.ts +26 -0
  197. package/src/profile/parse.ts +106 -0
  198. package/src/profile/select.ts +35 -0
  199. package/src/reasons.ts +49 -0
  200. package/src/render/explain.ts +46 -12
  201. package/src/render/friendly.ts +42 -10
  202. package/src/render/rules-json.ts +35 -9
  203. package/src/render/summary.ts +34 -9
  204. package/src/render/suppressions.ts +45 -10
  205. package/src/run.ts +44 -11
  206. package/src/structure/build.ts +128 -12
  207. package/src/structure/cite.ts +3 -1
  208. package/src/structure/issues.ts +42 -15
  209. package/src/structure/numbering.ts +12 -7
  210. package/src/structure/of.ts +15 -3
  211. package/src/structure/relative-find.ts +224 -0
  212. package/src/structure/relative-resolve.ts +209 -0
  213. package/src/structure/unread.ts +39 -0
  214. package/src/ui.ts +23 -0
  215. package/src/watch.ts +2 -2
@@ -0,0 +1,210 @@
1
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { basename, join } from "node:path";
3
+ import type { Finding, RuleDefinition } from "../plugin.ts";
4
+ import type { Config } from "../config/load.ts";
5
+ import { messageOf } from "../render/text.ts";
6
+ import type { Texts, UiLanguage } from "../ui.ts";
7
+ import { excerptsAround } from "../feedback/excerpt.ts";
8
+ import { feedbackDraft, type FeedbackDraft, type FeedbackKind, type ReportedFinding } from "../feedback/draft.ts";
9
+
10
+ export const FEEDBACK_FILE = ".chaff-feedback.md";
11
+ const NEW_ISSUE = "https://github.com/isamu/lab/issues/new";
12
+ /** GitHub cuts long titles, and a title can carry document text through a rule's message. */
13
+ export const MAX_TITLE_LENGTH = 120;
14
+
15
+ export type Checked = {
16
+ readonly findings: readonly Finding[];
17
+ readonly rules: readonly RuleDefinition[];
18
+ readonly language: string;
19
+ readonly genre: string;
20
+ };
21
+
22
+ export type FeedbackContext = {
23
+ readonly cwd: string;
24
+ readonly ui: UiLanguage;
25
+ readonly version: string;
26
+ readonly runtime: string;
27
+ readonly flag: (argv: readonly string[], name: string) => string | undefined;
28
+ readonly check: (path: string) => Promise<Checked>;
29
+ /** The reported rules' own settings in chaff.yaml (one line each), or undefined when none are set. */
30
+ readonly settingsOf: (ruleIds: readonly string[]) => string | undefined;
31
+ };
32
+
33
+ const TEXT: Texts<{
34
+ readonly usage: string;
35
+ readonly notFound: (path: string) => string;
36
+ readonly badLine: (lines: number) => string;
37
+ readonly noMatch: (list: string) => string;
38
+ readonly several: (list: string) => string;
39
+ readonly none: string;
40
+ readonly written: (file: string, lines: string) => string;
41
+ readonly send: string;
42
+ readonly link: (file: string) => string;
43
+ readonly neverSent: string;
44
+ }> = {
45
+ ja: {
46
+ usage: [
47
+ "使い方: chaff feedback <file> --rule <rule-id> [--line N] [--with-config] 誤った指摘を報告する",
48
+ " chaff feedback <file> --missed --line N [--with-config] 見逃しを報告する",
49
+ ].join("\n"),
50
+ notFound: (path) => `${path} がありません。`,
51
+ badLine: (lines) => `--line には 1〜${String(lines)} の行番号を書いてください。`,
52
+ noMatch: (list) => `その指摘が見つかりません。この文書の指摘:\n${list}`,
53
+ several: (list) => `その rule の指摘がいくつもあります。--line で一つ選んでください:\n${list}`,
54
+ none: " (指摘はありません)",
55
+ written: (file, lines) => `報告の下書きを ${file} に書きました。文書から載せたのは ${lines} だけです。送る前に読んで、要らない所は消してください。`,
56
+ send: "送るには(どちらか):",
57
+ link: (file) => `ブラウザで(開いたページに ${file} の中身を貼ります):`,
58
+ neverSent: "chaff は何も送っていません。",
59
+ },
60
+ en: {
61
+ usage: [
62
+ "usage: chaff feedback <file> --rule <rule-id> [--line N] [--with-config] report a wrong finding",
63
+ " chaff feedback <file> --missed --line N [--with-config] report something chaff missed",
64
+ ].join("\n"),
65
+ notFound: (path) => `${path} does not exist.`,
66
+ badLine: (lines) => `--line must be a line number from 1 to ${String(lines)}.`,
67
+ noMatch: (list) => `No such finding. The findings in this document:\n${list}`,
68
+ several: (list) => `That rule has several findings here. Pick one with --line:\n${list}`,
69
+ none: " (no findings)",
70
+ written: (file, lines) =>
71
+ `Wrote a draft report to ${file}. From the document it includes only ${lines}. Read it before sending, and delete anything you do not want to share.`,
72
+ send: "To send it (either):",
73
+ link: (file) => `In a browser (paste the contents of ${file} into the page):`,
74
+ neverSent: "chaff has not sent anything.",
75
+ },
76
+ };
77
+
78
+ const lineList = (lines: readonly number[], ui: UiLanguage): string =>
79
+ ui === "ja" ? `${lines.join("、")} 行目の前後` : `the lines around ${lines.join(", ")}`;
80
+
81
+ /** The findings the person picked: by rule, by line, or both. */
82
+ const chosen = (findings: readonly Finding[], rule: string | undefined, line: number | undefined): Finding[] =>
83
+ findings.filter((finding) => (rule === undefined || finding.rule === rule) && (line === undefined || finding.line === line));
84
+
85
+ const describe = (findings: readonly Finding[], none: string): string =>
86
+ findings.length === 0 ? none : findings.map((finding) => ` ${String(finding.line)} ${finding.rule}`).join("\n");
87
+
88
+ /** Pure: the reported rules' own lines of chaff.yaml's rules (a numeric limit wins over a level), or undefined when none is set. */
89
+ export const settingsOf = (config: Pick<Config, "rules" | "limits">, ruleIds: readonly string[]): string | undefined => {
90
+ const lines = [...new Set(ruleIds)].flatMap((id) => {
91
+ const limit = config.limits[id];
92
+ const level = config.rules[id];
93
+ if (limit !== undefined) return [` ${id}: ${String(limit)}`];
94
+ return level === undefined ? [] : [` ${id}: ${level}`];
95
+ });
96
+ return lines.length === 0 ? undefined : ["rules:", ...lines].join("\n");
97
+ };
98
+
99
+ /** Pure: a title on one line, no longer than GitHub shows. */
100
+ export const titleLine = (title: string): string => {
101
+ const flat = title.replace(/\s+/gu, " ").trim();
102
+ return flat.length <= MAX_TITLE_LENGTH ? flat : `${flat.slice(0, MAX_TITLE_LENGTH - 1)}…`;
103
+ };
104
+
105
+ /** Pure: a string a POSIX shell reads literally — single quotes, with any single quote closed, escaped and reopened. */
106
+ export const shellQuoted = (text: string): string => `'${text.replaceAll("'", "'\\''")}'`;
107
+
108
+ type Request = {
109
+ readonly path: string;
110
+ readonly kind: FeedbackKind;
111
+ readonly rule: string | undefined;
112
+ readonly line: number | undefined;
113
+ readonly withConfig: boolean;
114
+ };
115
+
116
+ /** What the person asked to report. A wrong finding is picked by rule or line; a miss needs the line. */
117
+ const requestOf = (targets: readonly string[], argv: readonly string[], context: FeedbackContext): Request | undefined => {
118
+ const [path] = targets;
119
+ const kind: FeedbackKind = argv.includes("--missed") ? "missed" : "false-positive";
120
+ const rule = context.flag(argv, "--rule");
121
+ const lineFlag = context.flag(argv, "--line");
122
+ const line = lineFlag === undefined ? undefined : Number(lineFlag);
123
+ const lineMissing = kind === "missed" ? line === undefined : rule === undefined && line === undefined;
124
+ if (path === undefined || targets.length !== 1 || lineMissing || (line !== undefined && !Number.isInteger(line))) return undefined;
125
+ return { path, kind, rule, line, withConfig: argv.includes("--with-config") };
126
+ };
127
+
128
+ const reported = (findings: readonly Finding[], checked: Checked): ReportedFinding[] => {
129
+ const byId = new Map(checked.rules.map((definition) => [definition.id, definition]));
130
+ return findings.flatMap((finding) => {
131
+ const definition = byId.get(finding.rule);
132
+ return definition === undefined ? [] : [{ rule: finding.rule, line: finding.line, message: messageOf(definition, finding, checked.language) }];
133
+ });
134
+ };
135
+
136
+ /** The link carries the title only: the body holds document lines, and a URL ends up in history and logs. */
137
+ const sendInstructions = (title: string, lines: readonly number[], ui: UiLanguage): string => {
138
+ const text = TEXT[ui];
139
+ return [
140
+ "",
141
+ ` ${text.written(FEEDBACK_FILE, lineList([...new Set(lines)], ui))}`,
142
+ "",
143
+ ` ${text.send}`,
144
+ ` gh issue create -R isamu/lab --title ${shellQuoted(title)} --body-file ${FEEDBACK_FILE}`,
145
+ ` ${text.link(FEEDBACK_FILE)}`,
146
+ ` ${NEW_ISSUE}?title=${encodeURIComponent(title)}`,
147
+ "",
148
+ ` ${text.neverSent}`,
149
+ "",
150
+ ].join("\n");
151
+ };
152
+
153
+ /** Why the request cannot be drafted, or the findings to report. */
154
+ const pick = (request: Request, checked: Checked, lineCount: number, ui: UiLanguage): { readonly error: string } | { readonly findings: Finding[] } => {
155
+ const text = TEXT[ui];
156
+ if (request.line !== undefined && (request.line < 1 || request.line > lineCount)) return { error: text.badLine(lineCount) };
157
+ const picked = chosen(checked.findings, request.rule, request.line);
158
+ if (request.kind === "missed") return { findings: picked };
159
+ if (picked.length === 0) return { error: text.noMatch(describe(checked.findings, text.none)) };
160
+ // One finding per report: a rule picked alone could otherwise pull excerpts from all over the document.
161
+ if (picked.length > 1) return { error: text.several(describe(picked, text.none)) };
162
+ return { findings: picked };
163
+ };
164
+
165
+ /**
166
+ * `chaff feedback`: an issue draft from a run on the person's own document. It writes a file and prints how to
167
+ * send it; it never sends anything itself. Only the lines around the reported spot are copied from the document,
168
+ * and from chaff.yaml only the reported rules' settings unless --with-config is given.
169
+ */
170
+ export const runFeedback = async (targets: readonly string[], argv: readonly string[], context: FeedbackContext): Promise<number> => {
171
+ const text = TEXT[context.ui];
172
+ const request = requestOf(targets, argv, context);
173
+ if (request === undefined) {
174
+ console.error(text.usage);
175
+ return 1;
176
+ }
177
+ if (!existsSync(request.path)) {
178
+ console.error(text.notFound(request.path));
179
+ return 1;
180
+ }
181
+ const source = readFileSync(request.path, "utf8");
182
+ const checked = await context.check(request.path);
183
+ const picked = pick(request, checked, source.split("\n").length, context.ui);
184
+ if ("error" in picked) {
185
+ console.error(picked.error);
186
+ return 1;
187
+ }
188
+ const findings = reported(picked.findings, checked);
189
+ const lines = request.kind === "missed" && request.line !== undefined ? [request.line] : findings.map((finding) => finding.line);
190
+ const configPath = join(context.cwd, "chaff.yaml");
191
+ const wholeConfig = request.withConfig && existsSync(configPath) ? readFileSync(configPath, "utf8") : undefined;
192
+ const draft: FeedbackDraft = feedbackDraft(
193
+ {
194
+ kind: request.kind,
195
+ version: context.version,
196
+ runtime: context.runtime,
197
+ fileName: basename(request.path),
198
+ language: checked.language,
199
+ genre: checked.genre,
200
+ findings,
201
+ line: lines[0] ?? 0,
202
+ excerpts: excerptsAround(source, lines),
203
+ config: wholeConfig ?? context.settingsOf(findings.map((finding) => finding.rule)),
204
+ },
205
+ context.ui,
206
+ );
207
+ writeFileSync(join(context.cwd, FEEDBACK_FILE), draft.body, "utf8");
208
+ console.log(sendInstructions(titleLine(draft.title), lines, context.ui));
209
+ return 0;
210
+ };
@@ -0,0 +1,74 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import type { Texts, UiLanguage } from "../ui.ts";
5
+
6
+ /** The skill ships in the package, beside rules/. Two levels up from src/commands or dist/commands. */
7
+ export const SKILL_SOURCE = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "skills", "chaff", "SKILL.md");
8
+
9
+ /** Where Claude Code reads a skill: this folder's .claude/skills, or the user's. */
10
+ export const skillTarget = (root: string): string => join(root, ".claude", "skills", "chaff", "SKILL.md");
11
+
12
+ export type SkillOutcome = { readonly status: "written" | "updated" | "same" | "kept"; readonly path: string };
13
+
14
+ /**
15
+ * Writes the skill. A file that is already there and differs may have been edited by hand, so it is replaced
16
+ * only with `force`; the same file is left alone.
17
+ */
18
+ export const installSkill = (skill: string, target: string, force: boolean): SkillOutcome => {
19
+ if (existsSync(target)) {
20
+ const current = readFileSync(target, "utf8");
21
+ if (current === skill) return { status: "same", path: target };
22
+ if (!force) return { status: "kept", path: target };
23
+ writeFileSync(target, skill, "utf8");
24
+ return { status: "updated", path: target };
25
+ }
26
+ mkdirSync(dirname(target), { recursive: true });
27
+ writeFileSync(target, skill, "utf8");
28
+ return { status: "written", path: target };
29
+ };
30
+
31
+ const TEXT: Texts<Readonly<Record<SkillOutcome["status"], (path: string) => string>>> = {
32
+ ja: {
33
+ written: (path) => `Claude Code の skill を書きました: ${path}\nClaude Code を開き直すと /chaff で使えます。`,
34
+ updated: (path) => `skill を新しい版に置き換えました: ${path}`,
35
+ same: (path) => `skill は既に最新です: ${path}`,
36
+ kept: (path) => `${path} は手で直されているかもしれないので、置き換えていません。\n置き換えるときは --force を付けてください。`,
37
+ },
38
+ en: {
39
+ written: (path) => `Wrote the Claude Code skill: ${path}\nReopen Claude Code and it is available as /chaff.`,
40
+ updated: (path) => `Replaced the skill with the new version: ${path}`,
41
+ same: (path) => `The skill is up to date: ${path}`,
42
+ kept: (path) => `${path} may have been edited by hand, so it was not replaced.\nAdd --force to replace it.`,
43
+ },
44
+ };
45
+
46
+ const FAILED: Texts<(path: string, why: string) => string> = {
47
+ ja: (path, why) => `skill を ${path} に書けませんでした: ${why}`,
48
+ en: (path, why) => `Could not write the skill to ${path}: ${why}`,
49
+ };
50
+
51
+ /** The outcome, or why the file system refused. */
52
+ const attempt = (install: () => SkillOutcome): SkillOutcome | { readonly failure: string } => {
53
+ try {
54
+ return install();
55
+ } catch (err) {
56
+ return { failure: err instanceof Error ? err.message : String(err) };
57
+ }
58
+ };
59
+
60
+ export type SkillContext = { readonly cwd: string; readonly home: string; readonly ui: UiLanguage };
61
+
62
+ /** `chaff skill [--global] [--force]`: installs or updates the skill. Exit 1 when a differing file was kept. */
63
+ export const runSkill = (argv: readonly string[], context: SkillContext): number => {
64
+ const target = skillTarget(argv.includes("--global") ? context.home : context.cwd);
65
+ const outcome = attempt(() => installSkill(readFileSync(SKILL_SOURCE, "utf8"), target, argv.includes("--force")));
66
+ if ("failure" in outcome) {
67
+ console.error(FAILED[context.ui](target, outcome.failure));
68
+ return 1;
69
+ }
70
+ const say = TEXT[context.ui][outcome.status](outcome.path);
71
+ if (outcome.status === "kept") console.error(say);
72
+ else console.log(say);
73
+ return outcome.status === "kept" ? 1 : 0;
74
+ };
@@ -14,6 +14,7 @@ import { MACHINE_BANNER, renderPlan, renderSemantic } from "../render/semantic.t
14
14
  import type { Config } from "../config/load.ts";
15
15
  import type { Finding, RuleDefinition } from "../plugin.ts";
16
16
  import type { Failure } from "../backends/types.ts";
17
+ import { profileFor } from "../profile/for-file.ts";
17
18
 
18
19
  export type TestContext = {
19
20
  readonly config: Config;
@@ -35,7 +36,7 @@ const judgeAll = async (
35
36
  const source = await readFile(path, "utf8");
36
37
  const language = applyByPath(config.byPath, config.baseDir, path).language ?? config.language ?? guessLanguage(source).language;
37
38
  const adapter = await loadAdapter(language);
38
- const doc = buildDocument(path, source, adapter, teamRules(config));
39
+ const doc = buildDocument(path, source, adapter, teamRules(config), profileFor(config, path, source, language));
39
40
  const { genre } = resolveGenre(path, source, config);
40
41
  const rules = loadRules(language);
41
42
  return { path, outcome: await runSemantic(doc, rules, checks, config.rules, genre, options), rules, language };
@@ -55,7 +56,7 @@ const dryRun = async (
55
56
  const source = await readFile(path, "utf8");
56
57
  const language = applyByPath(config.byPath, config.baseDir, path).language ?? config.language ?? guessLanguage(source).language;
57
58
  const adapter = await loadAdapter(language);
58
- const doc = buildDocument(path, source, adapter, teamRules(config));
59
+ const doc = buildDocument(path, source, adapter, teamRules(config), profileFor(config, path, source, language));
59
60
  const { genre } = resolveGenre(path, source, config);
60
61
  return { path, jobs: planSemantic(doc, loadRules(language), checks, config.rules, genre), sentences: doc.sentences.length };
61
62
  }),
@@ -1,21 +1,42 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import { loadAdapter } from "../adapter-load.ts";
3
3
  import { applyByPath } from "../config/by-path.ts";
4
+ import { profileFor } from "../profile/for-file.ts";
4
5
  import { guessLanguage } from "../detect.ts";
5
6
  import { isMarkdownPath } from "../structure/markdown-path.ts";
6
7
  import { buildStructure } from "../structure/of.ts";
7
8
  import { toSexp } from "../structure/sexp.ts";
8
9
  import type { Config } from "../config/load.ts";
9
10
  import type { StructureNode } from "../plugin.ts";
11
+ import type { Texts, UiLanguage } from "../ui.ts";
10
12
 
11
13
  export type TreeContext = {
12
14
  readonly config: Config;
13
15
  readonly flag: (argv: readonly string[], name: string) => string | undefined;
16
+ /** The language chaff speaks in here. Japanese when left out. */
17
+ readonly ui?: UiLanguage;
14
18
  };
15
19
 
16
- const FORMATS = ["sexp", "json"];
20
+ const TEXT: Texts<{
21
+ readonly usage: string;
22
+ readonly unreadable: (path: string, why: string) => string;
23
+ readonly noStructure: (path: string, language: string) => string;
24
+ }> = {
25
+ ja: {
26
+ usage: "使い方: chaff tree <file>... [--format sexp|json] [--language ja|en|…]",
27
+ unreadable: (path, why) => `${path} を読めませんでした: ${why}`,
28
+ noStructure: (path, language) => `${path}: 言語 ${language} のパッケージは文書の構造を読めません(structure がありません)`,
29
+ },
30
+ en: {
31
+ usage: "usage: chaff tree <file>... [--format sexp|json] [--language ja|en|…]",
32
+ unreadable: (path, why) => `Could not read ${path}: ${why}`,
33
+ noStructure: (path, language) => `${path}: the ${language} package cannot read a document's structure (it has no structure)`,
34
+ },
35
+ };
17
36
 
18
- const USAGE = "使い方: chaff tree <file>... [--format sexp|json] [--language ja|en|…]";
37
+ export const treeText = (context: TreeContext): (typeof TEXT)["ja"] => TEXT[context.ui ?? "ja"];
38
+
39
+ const FORMATS = ["sexp", "json"];
19
40
 
20
41
  /** 値を取るフラグ。その次の引数は値で、対象のファイルではない。 */
21
42
  const VALUED = ["--format", "--language"];
@@ -23,11 +44,11 @@ const VALUED = ["--format", "--language"];
23
44
  export const treeTargets = (argv: readonly string[]): string[] =>
24
45
  argv.slice(1).filter((arg, index, all) => !arg.startsWith("--") && !VALUED.includes(all[index - 1] ?? ""));
25
46
 
26
- export const readSource = async (path: string): Promise<string | undefined> => {
47
+ export const readSource = async (path: string, context: TreeContext): Promise<string | undefined> => {
27
48
  try {
28
49
  return await readFile(path, "utf8");
29
50
  } catch (err) {
30
- console.error(`${path} を読めませんでした: ${err instanceof Error ? err.message : String(err)}`);
51
+ console.error(treeText(context).unreadable(path, err instanceof Error ? err.message : String(err)));
31
52
  return undefined;
32
53
  }
33
54
  };
@@ -49,17 +70,18 @@ export type SourceTree = { readonly source: string; readonly tree: StructureNode
49
70
  * chaff tree と chaff cite が同じ木を見るように、ここを一つにしておく。
50
71
  */
51
72
  export const readTree = async (path: string, argv: readonly string[], context: TreeContext): Promise<SourceTree | undefined> => {
52
- const source = await readSource(path);
73
+ const source = await readSource(path, context);
53
74
  if (source === undefined) return undefined;
54
75
  const language = treeLanguage(path, source, argv, context);
55
76
  const adapter = await loadAdapter(language);
56
77
  if (adapter.structure === undefined) {
57
- console.error(`${path}: 言語 ${language} のパッケージは文書の構造を読めません(structure がありません)`);
78
+ console.error(treeText(context).noStructure(path, language));
58
79
  return undefined;
59
80
  }
60
81
  // 日本語は形態素で数量と日付を読む。解析器が無ければ単位の表で読むので、木は作れる。
61
82
  await adapter.prepare?.({ pos: true });
62
- return { source, tree: buildStructure({ path, source, language, markdown: isMarkdownPath(path) }, adapter.structure) };
83
+ const profile = profileFor(context.config, path, source, language);
84
+ return { source, tree: buildStructure({ path, source, language, markdown: isMarkdownPath(path), profile }, adapter.structure) };
63
85
  };
64
86
 
65
87
  const printTree = async (path: string, argv: readonly string[], context: TreeContext): Promise<boolean> => {
@@ -86,7 +108,7 @@ export const inOrder = async (paths: readonly string[], each: (path: string) =>
86
108
  export const runTree = async (paths: readonly string[], argv: readonly string[], context: TreeContext): Promise<number> => {
87
109
  const format = context.flag(argv, "--format") ?? "sexp";
88
110
  if (paths.length === 0 || !FORMATS.includes(format)) {
89
- console.error(USAGE);
111
+ console.error(treeText(context).usage);
90
112
  return 1;
91
113
  }
92
114
  const printed = await inOrder(paths, (path) => printTree(path, argv, context));
@@ -1,6 +1,11 @@
1
1
  import { relative, sep } from "node:path";
2
2
 
3
- export type PathRule = { readonly files: readonly string[]; readonly genre: string | undefined; readonly language: string | undefined };
3
+ export type PathRule = {
4
+ readonly files: readonly string[];
5
+ readonly genre: string | undefined;
6
+ readonly language: string | undefined;
7
+ readonly profile?: string | undefined;
8
+ };
4
9
 
5
10
  /**
6
11
  * glob を正規表現にする。使うのは 3 つだけ。
@@ -36,12 +41,15 @@ export const matches = (glob: string, base: string, path: string): boolean => to
36
41
  * 後に書いたものが勝つ。上から順に当て、最後に当たったものを使う。
37
42
  * 「全体はこう、ここだけは違う」と書けるようにするため。
38
43
  */
39
- export const applyByPath = (rules: readonly PathRule[], base: string, path: string): { genre?: string; language?: string } =>
40
- rules.reduce<{ genre?: string; language?: string }>((acc, rule) => {
44
+ type PathSettings = { genre?: string; language?: string; profile?: string };
45
+
46
+ export const applyByPath = (rules: readonly PathRule[], base: string, path: string): PathSettings =>
47
+ rules.reduce<PathSettings>((acc, rule) => {
41
48
  if (!rule.files.some((glob) => matches(glob, base, path))) return acc;
42
49
  return {
43
50
  ...acc,
44
51
  ...(rule.genre === undefined ? {} : { genre: rule.genre }),
45
52
  ...(rule.language === undefined ? {} : { language: rule.language }),
53
+ ...(rule.profile === undefined ? {} : { profile: rule.profile }),
46
54
  };
47
55
  }, {});
@@ -11,6 +11,8 @@ export const CONFIG_FILE = "chaff.yaml";
11
11
 
12
12
  export type Config = {
13
13
  readonly genre: string | undefined;
14
+ /** 文書の種類(profiles/*.yaml の id)。none なら内容からも選ばない。 */
15
+ readonly profile: string | undefined;
14
16
  readonly aiBackend: BackendName;
15
17
  readonly language: string | undefined;
16
18
  readonly rules: Readonly<Record<string, Level>>;
@@ -39,6 +41,7 @@ const DEFAULT_BACKEND: BackendName = "anthropic";
39
41
 
40
42
  export const EMPTY: Config = {
41
43
  genre: undefined,
44
+ profile: undefined,
42
45
  language: undefined,
43
46
  rules: {},
44
47
  limits: {},
@@ -93,7 +96,7 @@ const toPathRule = (raw: unknown): PathRule | undefined => {
93
96
  const files = raw["files"];
94
97
  const globs = globsOf(files);
95
98
  if (globs.length === 0) return undefined;
96
- return { files: globs, genre: str(raw["genre"]), language: str(raw["language"]) };
99
+ return { files: globs, genre: str(raw["genre"]), language: str(raw["language"]), profile: str(raw["profile"]) };
97
100
  };
98
101
 
99
102
  /** 利用者が書く語の並び。空白だけのものは落とす。 */
@@ -121,6 +124,7 @@ export const loadConfig = (path: string): Config => {
121
124
  const backend: BackendName = isBackend(declared) ? declared : DEFAULT_BACKEND;
122
125
  return {
123
126
  genre: str(raw["genre"]),
127
+ profile: str(raw["profile"]),
124
128
  language: str(raw["language"]),
125
129
  rules: rulesOf(raw["rules"]),
126
130
  limits: limitsOf(raw["rules"]),
@@ -1,4 +1,23 @@
1
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
+ };
2
21
 
3
22
  /**
4
23
  * chaff.yaml の rules のうち、効いていないもの。知らない rule 名と、読めない値。
@@ -7,18 +26,18 @@ import type { Config } from "./load.ts";
7
26
  export const ruleProblems = (
8
27
  config: Pick<Config, "rules" | "limits" | "unreadableRules" | "path">,
9
28
  known: readonly { readonly id: string; readonly layer: string }[],
29
+ ui: UiLanguage = "ja",
10
30
  ): string[] => {
31
+ const text = TEXT[ui];
11
32
  const knownIds = new Set(known.map((rule) => rule.id));
12
33
  const where = config.path ?? "chaff.yaml";
13
34
  const written = [...new Set([...Object.keys(config.rules), ...Object.keys(config.limits), ...config.unreadableRules.map((entry) => entry.id)])];
14
- const unknown = written.filter((id) => !knownIds.has(id)).map((id) => `${where}: ${id} というルールはありません(npx chaff rules --json で一覧が出ます)`);
15
- const unreadable = config.unreadableRules
16
- .filter((entry) => knownIds.has(entry.id))
17
- .map((entry) => `${where}: ${entry.id} の値 ${entry.value} は読めません(strict / normal / relaxed / off か、正の数)`);
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));
18
37
  // 意味を読む検査(L4)に閾値は無い。数を書いても normal として動くので、そう言う。
19
38
  const semantic = new Set(known.filter((rule) => rule.layer === "L4").map((rule) => rule.id));
20
39
  const numberOnSemantic = Object.keys(config.limits)
21
40
  .filter((id) => semantic.has(id))
22
- .map((id) => `${where}: ${id} は意味を読む検査なので数値の上限はありません。normal として動きます(strict / normal / relaxed / off で書いてください)`);
41
+ .map((id) => text.numberOnSemantic(where, id));
23
42
  return [...unknown, ...unreadable, ...numberOnSemantic];
24
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
  };
@@ -1,4 +1,5 @@
1
- import type { Detector, Finding, Sentence } from "../plugin.ts";
1
+ import type { Detector, DocumentProfile, Finding, Sentence } from "../plugin.ts";
2
+ import { maskAddresses } from "../address-chain.ts";
2
3
  import { proseText } from "../measure.ts";
3
4
 
4
5
  /**
@@ -7,8 +8,8 @@ import { proseText } from "../measure.ts";
7
8
  */
8
9
  const KANJI_RUN = /[一-鿿]+/gu;
9
10
 
10
- const longestKanji = (sentence: Sentence): string =>
11
- [...proseText(sentence).matchAll(KANJI_RUN)].reduce((longest, match) => (match[0].length > longest.length ? match[0] : longest), "");
11
+ const longestKanji = (sentence: Sentence, profile: DocumentProfile | undefined): string =>
12
+ [...maskAddresses(proseText(sentence), profile).matchAll(KANJI_RUN)].reduce((longest, match) => (match[0].length > longest.length ? match[0] : longest), "");
12
13
 
13
14
  /**
14
15
  * 漢字が続くと、どこで語が切れるのか読み手が探すことになる。
@@ -16,7 +17,7 @@ const longestKanji = (sentence: Sentence): string =>
16
17
  */
17
18
  export const kanjiRun: Detector = (doc, options): Finding[] =>
18
19
  doc.sentences
19
- .map((sentence) => ({ sentence, run: longestKanji(sentence) }))
20
+ .map((sentence) => ({ sentence, run: longestKanji(sentence, doc.profile) }))
20
21
  .filter(({ run }) => run.length > options.limit)
21
22
  .map(({ sentence, run }) => ({
22
23
  rule: "max-kanji-continuous",