chaffjs 0.8.0 → 0.10.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 (190) hide show
  1. package/dist/address-chain.d.ts +9 -0
  2. package/dist/address-chain.d.ts.map +1 -0
  3. package/dist/address-chain.js +105 -0
  4. package/dist/address-chain.js.map +1 -0
  5. package/dist/cli-args.d.ts.map +1 -1
  6. package/dist/cli-args.js +1 -1
  7. package/dist/cli-args.js.map +1 -1
  8. package/dist/cli-text.d.ts.map +1 -1
  9. package/dist/cli-text.js +4 -0
  10. package/dist/cli-text.js.map +1 -1
  11. package/dist/cli.d.ts.map +1 -1
  12. package/dist/cli.js +19 -1
  13. package/dist/cli.js.map +1 -1
  14. package/dist/commands/eval.d.ts.map +1 -1
  15. package/dist/commands/eval.js +2 -1
  16. package/dist/commands/eval.js.map +1 -1
  17. package/dist/commands/feedback.d.ts +35 -0
  18. package/dist/commands/feedback.d.ts.map +1 -0
  19. package/dist/commands/feedback.js +157 -0
  20. package/dist/commands/feedback.js.map +1 -0
  21. package/dist/commands/test.d.ts.map +1 -1
  22. package/dist/commands/test.js +3 -2
  23. package/dist/commands/test.js.map +1 -1
  24. package/dist/commands/tree.d.ts.map +1 -1
  25. package/dist/commands/tree.js +3 -1
  26. package/dist/commands/tree.js.map +1 -1
  27. package/dist/config/by-path.d.ts +5 -1
  28. package/dist/config/by-path.d.ts.map +1 -1
  29. package/dist/config/by-path.js +1 -4
  30. package/dist/config/by-path.js.map +1 -1
  31. package/dist/config/load.d.ts +2 -0
  32. package/dist/config/load.d.ts.map +1 -1
  33. package/dist/config/load.js +3 -1
  34. package/dist/config/load.js.map +1 -1
  35. package/dist/detectors/char-shape.d.ts.map +1 -1
  36. package/dist/detectors/char-shape.js +3 -2
  37. package/dist/detectors/char-shape.js.map +1 -1
  38. package/dist/detectors/index.d.ts.map +1 -1
  39. package/dist/detectors/index.js +4 -1
  40. package/dist/detectors/index.js.map +1 -1
  41. package/dist/detectors/lexicon.d.ts.map +1 -1
  42. package/dist/detectors/lexicon.js +8 -1
  43. package/dist/detectors/lexicon.js.map +1 -1
  44. package/dist/detectors/structure-tree.d.ts +5 -0
  45. package/dist/detectors/structure-tree.d.ts.map +1 -1
  46. package/dist/detectors/structure-tree.js +50 -1
  47. package/dist/detectors/structure-tree.js.map +1 -1
  48. package/dist/detectors/team.d.ts.map +1 -1
  49. package/dist/detectors/team.js +7 -2
  50. package/dist/detectors/team.js.map +1 -1
  51. package/dist/detectors/word-list.d.ts +3 -0
  52. package/dist/detectors/word-list.d.ts.map +1 -0
  53. package/dist/detectors/word-list.js +3 -0
  54. package/dist/detectors/word-list.js.map +1 -0
  55. package/dist/document.d.ts +2 -2
  56. package/dist/document.d.ts.map +1 -1
  57. package/dist/document.js +4 -2
  58. package/dist/document.js.map +1 -1
  59. package/dist/feedback/draft.d.ts +28 -0
  60. package/dist/feedback/draft.d.ts.map +1 -0
  61. package/dist/feedback/draft.js +66 -0
  62. package/dist/feedback/draft.js.map +1 -0
  63. package/dist/feedback/excerpt.d.ts +10 -0
  64. package/dist/feedback/excerpt.d.ts.map +1 -0
  65. package/dist/feedback/excerpt.js +20 -0
  66. package/dist/feedback/excerpt.js.map +1 -0
  67. package/dist/legal-address.d.ts +2 -0
  68. package/dist/legal-address.d.ts.map +1 -0
  69. package/dist/legal-address.js +16 -0
  70. package/dist/legal-address.js.map +1 -0
  71. package/dist/orthography.d.ts.map +1 -1
  72. package/dist/orthography.js +15 -0
  73. package/dist/orthography.js.map +1 -1
  74. package/dist/plugin.d.ts +98 -0
  75. package/dist/plugin.d.ts.map +1 -1
  76. package/dist/profile/for-file.d.ts +7 -0
  77. package/dist/profile/for-file.d.ts.map +1 -0
  78. package/dist/profile/for-file.js +6 -0
  79. package/dist/profile/for-file.js.map +1 -0
  80. package/dist/profile/load.d.ts +4 -0
  81. package/dist/profile/load.d.ts.map +1 -0
  82. package/dist/profile/load.js +25 -0
  83. package/dist/profile/load.js.map +1 -0
  84. package/dist/profile/parse.d.ts +19 -0
  85. package/dist/profile/parse.d.ts.map +1 -0
  86. package/dist/profile/parse.js +95 -0
  87. package/dist/profile/parse.js.map +1 -0
  88. package/dist/profile/select.d.ts +22 -0
  89. package/dist/profile/select.d.ts.map +1 -0
  90. package/dist/profile/select.js +21 -0
  91. package/dist/profile/select.js.map +1 -0
  92. package/dist/reasons.d.ts +1 -0
  93. package/dist/reasons.d.ts.map +1 -1
  94. package/dist/reasons.js +2 -0
  95. package/dist/reasons.js.map +1 -1
  96. package/dist/render/compact.d.ts.map +1 -1
  97. package/dist/render/compact.js +3 -1
  98. package/dist/render/compact.js.map +1 -1
  99. package/dist/run.d.ts.map +1 -1
  100. package/dist/run.js +40 -7
  101. package/dist/run.js.map +1 -1
  102. package/dist/structure/build.d.ts +2 -1
  103. package/dist/structure/build.d.ts.map +1 -1
  104. package/dist/structure/build.js +105 -11
  105. package/dist/structure/build.js.map +1 -1
  106. package/dist/structure/date-order.d.ts +12 -0
  107. package/dist/structure/date-order.d.ts.map +1 -0
  108. package/dist/structure/date-order.js +30 -0
  109. package/dist/structure/date-order.js.map +1 -0
  110. package/dist/structure/issues.d.ts.map +1 -1
  111. package/dist/structure/issues.js +9 -2
  112. package/dist/structure/issues.js.map +1 -1
  113. package/dist/structure/of.d.ts +2 -1
  114. package/dist/structure/of.d.ts.map +1 -1
  115. package/dist/structure/of.js +7 -1
  116. package/dist/structure/of.js.map +1 -1
  117. package/dist/structure/relative-find.d.ts +8 -0
  118. package/dist/structure/relative-find.d.ts.map +1 -0
  119. package/dist/structure/relative-find.js +190 -0
  120. package/dist/structure/relative-find.js.map +1 -0
  121. package/dist/structure/relative-resolve.d.ts +3 -0
  122. package/dist/structure/relative-resolve.d.ts.map +1 -0
  123. package/dist/structure/relative-resolve.js +175 -0
  124. package/dist/structure/relative-resolve.js.map +1 -0
  125. package/dist/structure/runs.d.ts +20 -0
  126. package/dist/structure/runs.d.ts.map +1 -0
  127. package/dist/structure/runs.js +63 -0
  128. package/dist/structure/runs.js.map +1 -0
  129. package/dist/structure/total.d.ts +14 -0
  130. package/dist/structure/total.d.ts.map +1 -0
  131. package/dist/structure/total.js +119 -0
  132. package/dist/structure/total.js.map +1 -0
  133. package/dist/structure/unnumbered.d.ts +15 -0
  134. package/dist/structure/unnumbered.d.ts.map +1 -0
  135. package/dist/structure/unnumbered.js +28 -0
  136. package/dist/structure/unnumbered.js.map +1 -0
  137. package/dist/structure/unread.d.ts +8 -0
  138. package/dist/structure/unread.d.ts.map +1 -0
  139. package/dist/structure/unread.js +34 -0
  140. package/dist/structure/unread.js.map +1 -0
  141. package/dist/structure/weekday.d.ts +7 -0
  142. package/dist/structure/weekday.d.ts.map +1 -0
  143. package/dist/structure/weekday.js +21 -0
  144. package/dist/structure/weekday.js.map +1 -0
  145. package/package.json +4 -3
  146. package/profiles/statute.yaml +66 -0
  147. package/rules/date-order.yaml +26 -0
  148. package/rules/date-weekday-mismatch.yaml +26 -0
  149. package/rules/internal-jargon.yaml +1 -1
  150. package/rules/total-mismatch.yaml +26 -0
  151. package/skills/chaff/SKILL.md +24 -4
  152. package/src/address-chain.ts +113 -0
  153. package/src/cli-args.ts +1 -1
  154. package/src/cli-text.ts +4 -0
  155. package/src/cli.ts +20 -1
  156. package/src/commands/eval.ts +2 -1
  157. package/src/commands/feedback.ts +210 -0
  158. package/src/commands/test.ts +3 -2
  159. package/src/commands/tree.ts +3 -1
  160. package/src/config/by-path.ts +11 -3
  161. package/src/config/load.ts +5 -1
  162. package/src/detectors/char-shape.ts +5 -4
  163. package/src/detectors/index.ts +4 -1
  164. package/src/detectors/lexicon.ts +11 -1
  165. package/src/detectors/structure-tree.ts +67 -1
  166. package/src/detectors/team.ts +7 -2
  167. package/src/detectors/word-list.ts +2 -0
  168. package/src/document.ts +11 -3
  169. package/src/feedback/draft.ts +102 -0
  170. package/src/feedback/excerpt.ts +23 -0
  171. package/src/orthography.ts +15 -0
  172. package/src/plugin.ts +95 -0
  173. package/src/profile/for-file.ts +11 -0
  174. package/src/profile/load.ts +26 -0
  175. package/src/profile/parse.ts +119 -0
  176. package/src/profile/select.ts +35 -0
  177. package/src/reasons.ts +5 -0
  178. package/src/render/compact.ts +4 -1
  179. package/src/run.ts +42 -7
  180. package/src/structure/build.ts +135 -15
  181. package/src/structure/date-order.ts +44 -0
  182. package/src/structure/issues.ts +10 -2
  183. package/src/structure/of.ts +15 -3
  184. package/src/structure/relative-find.ts +237 -0
  185. package/src/structure/relative-resolve.ts +209 -0
  186. package/src/structure/runs.ts +71 -0
  187. package/src/structure/total.ts +157 -0
  188. package/src/structure/unnumbered.ts +33 -0
  189. package/src/structure/unread.ts +39 -0
  190. package/src/structure/weekday.ts +25 -0
@@ -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
+ };
@@ -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,6 +1,7 @@
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";
@@ -79,7 +80,8 @@ export const readTree = async (path: string, argv: readonly string[], context: T
79
80
  }
80
81
  // 日本語は形態素で数量と日付を読む。解析器が無ければ単位の表で読むので、木は作れる。
81
82
  await adapter.prepare?.({ pos: true });
82
- 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) };
83
85
  };
84
86
 
85
87
  const printTree = async (path: string, argv: readonly string[], context: TreeContext): Promise<boolean> => {
@@ -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,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",
@@ -15,7 +15,7 @@ import { concreteEvidence, dashDensity, emojiDensity, ngramRepetition, undefined
15
15
  import { aiTell, contractionMix, cushionDensity, hedgingDensity, repeatedConjunction, unqualifiedSuperlative } from "./lexicon.ts";
16
16
  import { internalJargon, properNounDensity, requiredSections } from "./team.ts";
17
17
  import { latinSpacing, preferredTerm } from "./orthography.ts";
18
- import { danglingReference, duplicateDefinition, numberingGap } from "./structure-tree.ts";
18
+ import { danglingReference, dateOrder, dateWeekdayMismatch, duplicateDefinition, numberingGap, totalMismatch } from "./structure-tree.ts";
19
19
 
20
20
  /** rule 定義の how_to_find がここを引く。rule 側は実装を知らない。 */
21
21
  export const DETECTORS: Readonly<Record<string, Detector>> = {
@@ -58,6 +58,9 @@ export const DETECTORS: Readonly<Record<string, Detector>> = {
58
58
  "preferred-term": preferredTerm,
59
59
  "latin-spacing": latinSpacing,
60
60
  "dangling-reference": danglingReference,
61
+ "date-weekday-mismatch": dateWeekdayMismatch,
62
+ "date-order": dateOrder,
63
+ "total-mismatch": totalMismatch,
61
64
  "numbering-gap": numberingGap,
62
65
  "duplicate-definition": duplicateDefinition,
63
66
  };
@@ -1,3 +1,4 @@
1
+ import { joinWords } from "./word-list.ts";
1
2
  import { proseText } from "../measure.ts";
2
3
  import { wordsOf } from "./structure.ts";
3
4
  import type { Detector, Finding, ProseDocument, Sentence } from "../plugin.ts";
@@ -118,7 +119,16 @@ export const aiTell: Detector = (doc, options): Finding[] => {
118
119
  line: 0,
119
120
  column: 0,
120
121
  quote: first.text.trim(),
121
- values: { word: found.map(([pattern]) => pattern).join("、"), count: found.length, density: score, limit: options.limit, offset: first.span.start },
122
+ values: {
123
+ word: joinWords(
124
+ found.map(([pattern]) => pattern),
125
+ doc.language,
126
+ ),
127
+ count: found.length,
128
+ density: score,
129
+ limit: options.limit,
130
+ offset: first.span.start,
131
+ },
122
132
  },
123
133
  ];
124
134
  };
@@ -1,6 +1,9 @@
1
1
  import type { Detector, Finding, ProseDocument } from "../plugin.ts";
2
- import { danglingReferences, duplicateDefinitions, type StructureIssue } from "../structure/issues.ts";
2
+ import { danglingReferences, duplicateDefinitions, inDocumentOrder, type StructureIssue } from "../structure/issues.ts";
3
3
  import { numberingBreaks } from "../structure/numbering.ts";
4
+ import { weekdayMismatches } from "../structure/weekday.ts";
5
+ import { dateOrderBreaks } from "../structure/date-order.ts";
6
+ import { totalMismatches, type Amount } from "../structure/total.ts";
4
7
 
5
8
  const QUOTE_LENGTH = 80;
6
9
 
@@ -30,3 +33,66 @@ const findingsOf =
30
33
  export const danglingReference: Detector = findingsOf("dangling-reference", danglingReferences);
31
34
  export const numberingGap: Detector = findingsOf("numbering-gap", numberingBreaks);
32
35
  export const duplicateDefinition: Detector = findingsOf("duplicate-definition", duplicateDefinitions);
36
+
37
+ /** 曜日の名前は言語パッケージの語彙表(weekday、日曜日から順)から取る。無ければ番号のまま。 */
38
+ const dayName = (doc: ProseDocument, day: unknown): string => {
39
+ const index = Number(day);
40
+ return doc.lexicons["weekday"]?.[index]?.pattern ?? String(index);
41
+ };
42
+
43
+ export const dateWeekdayMismatch: Detector = (doc): Finding[] =>
44
+ doc.structure === undefined
45
+ ? []
46
+ : weekdayMismatches(doc.structure).map((issue) => ({
47
+ rule: "date-weekday-mismatch",
48
+ severity: "error",
49
+ line: 0,
50
+ column: 0,
51
+ quote: quoteAt(doc.source, issue.offset),
52
+ values: {
53
+ date: String(issue.values["date"]),
54
+ written: dayName(doc, issue.values["written"]),
55
+ actual: dayName(doc, issue.values["actual"]),
56
+ offset: issue.offset,
57
+ },
58
+ }));
59
+
60
+ /** 木の日付を、原文の位置と一緒に並べる。 */
61
+ const datedPoints = (tree: NonNullable<ProseDocument["structure"]>): { offset: number; value: string }[] =>
62
+ inDocumentOrder(tree).flatMap((node) => (node.kind === "date" ? [{ offset: node.span.start, value: String(node.attrs["value"]) }] : []));
63
+
64
+ /** 日程の並びに逆らう日付。並びの読み方は原文の行を見るので、木と原文の両方を渡す。 */
65
+ export const dateOrder: Detector = (doc): Finding[] =>
66
+ doc.structure === undefined
67
+ ? []
68
+ : dateOrderBreaks(doc.source, datedPoints(doc.structure)).map((issue) => ({
69
+ rule: "date-order",
70
+ severity: "warning",
71
+ line: 0,
72
+ column: 0,
73
+ quote: quoteAt(doc.source, issue.offset),
74
+ values: { ...issue.values, offset: issue.offset },
75
+ }));
76
+
77
+ /** 木の数量を、原文の位置と一緒に並べる。 */
78
+ const amountsOf = (tree: NonNullable<ProseDocument["structure"]>): Amount[] =>
79
+ inDocumentOrder(tree).flatMap((node) =>
80
+ node.kind === "quantity" ? [{ offset: node.span.start, end: node.span.end, value: Number(node.attrs["value"]), unit: String(node.attrs["unit"]) }] : [],
81
+ );
82
+
83
+ /** 合計の行が、上の金額の和と合わない。合計の語は言語パッケージの語彙表(total-label)から取る。 */
84
+ export const totalMismatch: Detector = (doc): Finding[] =>
85
+ doc.structure === undefined
86
+ ? []
87
+ : totalMismatches(
88
+ doc.source,
89
+ amountsOf(doc.structure),
90
+ (doc.lexicons["total-label"] ?? []).map((entry) => entry.pattern),
91
+ ).map((issue) => ({
92
+ rule: "total-mismatch",
93
+ severity: "error",
94
+ line: 0,
95
+ column: 0,
96
+ quote: quoteAt(doc.source, issue.offset),
97
+ values: { ...issue.values, offset: issue.offset },
98
+ }));
@@ -1,4 +1,5 @@
1
1
  import { proseText } from "../measure.ts";
2
+ import { joinWords } from "./word-list.ts";
2
3
  import { wordsOf } from "./structure.ts";
3
4
  import type { Detector, Finding, Sentence, Token } from "../plugin.ts";
4
5
 
@@ -12,7 +13,11 @@ import type { Detector, Finding, Sentence, Token } from "../plugin.ts";
12
13
  * 表層でも原形でも当てる。利用者は辞書形で書く(「握る」)が、本文は活用している(「握った」)。
13
14
  * 品詞解析があれば原形で当たり、無くても表層で当たる。要求はしない。
14
15
  */
15
- const matches = (sentence: Sentence, word: string): boolean => sentence.text.includes(word) || (sentence.tokens ?? []).some((token) => token.lemma === word);
16
+ const matches = (sentence: Sentence, word: string): boolean => {
17
+ // 大文字小文字は区別しない。英語の文頭の「Circle back」も「circle back」として当てる。
18
+ const wanted = word.toLowerCase();
19
+ return sentence.text.toLowerCase().includes(wanted) || (sentence.tokens ?? []).some((token) => token.lemma?.toLowerCase() === wanted);
20
+ };
16
21
 
17
22
  export const internalJargon: Detector = (doc, options): Finding[] => {
18
23
  const words = (options.lexicon ?? []).map((entry) => entry.pattern);
@@ -53,7 +58,7 @@ export const requiredSections: Detector = (doc, options): Finding[] => {
53
58
  line: 0,
54
59
  column: 0,
55
60
  quote: headings.filter((heading) => heading.length > 0).join(" / "),
56
- values: { word: missing.join("、"), count: missing.length, limit: options.limit, offset: 0 },
61
+ values: { word: joinWords(missing, doc.language), count: missing.length, limit: options.limit, offset: 0 },
57
62
  },
58
63
  ];
59
64
  };
@@ -0,0 +1,2 @@
1
+ /** 語の並べ方は文書の言語に合わせる。日本語は「、」、ほかは ", "。英語の画面に「Risks、Costs」と出さない。 */
2
+ export const joinWords = (words: readonly string[], language: string): string => words.join(language === "ja" ? "、" : ", ");
package/src/document.ts CHANGED
@@ -7,7 +7,7 @@ import { atxHeadingText, headingText } from "./heading-text.ts";
7
7
  import { maskSpans } from "./mask.ts";
8
8
  import { buildTree, NO_OUTLINE, type Outline } from "./structure/build.ts";
9
9
  import { isMarkdownPath } from "./structure/markdown-path.ts";
10
- import type { BulletList, LanguageAdapter, Paragraph, ProseDocument, Section, Sentence, Span, StructureNode } from "./plugin.ts";
10
+ import type { BulletList, LanguageAdapter, Paragraph, ProseDocument, Section, Sentence, Span, StructureNode, DocumentProfile } from "./plugin.ts";
11
11
 
12
12
  type Place = { readonly offset?: number | undefined };
13
13
  type Node = {
@@ -253,7 +253,13 @@ export const teamRules = (config: {
253
253
  requiredSections: config.requiredSections,
254
254
  });
255
255
 
256
- export const buildDocument = (path: string, source: string, adapter: LanguageAdapter, team: TeamRules = EMPTY_TEAM): ProseDocument => {
256
+ export const buildDocument = (
257
+ path: string,
258
+ source: string,
259
+ adapter: LanguageAdapter,
260
+ team: TeamRules = EMPTY_TEAM,
261
+ profile: DocumentProfile | undefined = undefined,
262
+ ): ProseDocument => {
257
263
  const root = parse(source);
258
264
  // 強調の記号は「本文でないもの」だが、太字の数を数えるときの「覆われた場所」ではない。
259
265
  // 同じ集合にすると、太字が自分の記号のせいで覆われた場所にあることになり、1 つも数えられなくなる。
@@ -289,9 +295,11 @@ export const buildDocument = (path: string, source: string, adapter: LanguageAda
289
295
  tree.value ??=
290
296
  patterns === undefined
291
297
  ? null
292
- : buildTree({ path, source, language: adapter.id, outline: isMarkdownPath(path) ? outlineOf(root, source) : NO_OUTLINE }, patterns);
298
+ : buildTree({ path, source, language: adapter.id, outline: isMarkdownPath(path) ? outlineOf(root, source) : NO_OUTLINE, profile }, patterns);
293
299
  return tree.value ?? undefined;
294
300
  },
301
+ profile,
302
+ prose,
295
303
  };
296
304
  };
297
305
 
@@ -0,0 +1,102 @@
1
+ import type { Texts, UiLanguage } from "../ui.ts";
2
+ import type { Excerpt } from "./excerpt.ts";
3
+
4
+ export type FeedbackKind = "false-positive" | "missed";
5
+
6
+ export type ReportedFinding = { readonly rule: string; readonly line: number; readonly message: string };
7
+
8
+ export type FeedbackInput = {
9
+ readonly kind: FeedbackKind;
10
+ readonly version: string;
11
+ readonly runtime: string;
12
+ readonly fileName: string;
13
+ readonly language: string;
14
+ readonly genre: string;
15
+ readonly findings: readonly ReportedFinding[];
16
+ /** The line the person reported: the finding's line, or for a miss the line they named. */
17
+ readonly line: number;
18
+ readonly excerpts: readonly Excerpt[];
19
+ readonly config: string | undefined;
20
+ };
21
+
22
+ export type FeedbackDraft = { readonly title: string; readonly body: string };
23
+
24
+ const TEXT: Texts<{
25
+ readonly titleFalse: (rule: string, message: string) => string;
26
+ readonly titleMissed: (fileName: string, line: number) => string;
27
+ readonly what: Readonly<Record<FeedbackKind, string>>;
28
+ readonly environment: string;
29
+ readonly findings: string;
30
+ readonly excerpt: string;
31
+ readonly config: string;
32
+ readonly expected: string;
33
+ readonly expectedHint: Readonly<Record<FeedbackKind, string>>;
34
+ readonly lines: (from: number, to: number) => string;
35
+ }> = {
36
+ ja: {
37
+ titleFalse: (rule, message) => `誤検出: ${rule} — ${message}`,
38
+ titleMissed: (fileName, line) => `見逃し: ${fileName} の ${String(line)} 行目`,
39
+ what: { "false-positive": "この指摘は誤りだと思います。", missed: "ここで chaff は何か言うべきだと思います。" },
40
+ environment: "環境",
41
+ findings: "指摘",
42
+ excerpt: "文書の該当箇所(この部分だけを載せています)",
43
+ config: "chaff.yaml",
44
+ expected: "期待すること",
45
+ expectedHint: {
46
+ "false-positive": "<!-- なぜ誤りか、本来どう読むべきかを書いてください -->",
47
+ missed: "<!-- 何が問題で、どの rule が言うべきだったかを書いてください -->",
48
+ },
49
+ lines: (from, to) => `${String(from)}〜${String(to)} 行目`,
50
+ },
51
+ en: {
52
+ titleFalse: (rule, message) => `False positive: ${rule} — ${message}`,
53
+ titleMissed: (fileName, line) => `Missed: ${fileName} line ${String(line)}`,
54
+ what: { "false-positive": "I think this finding is wrong.", missed: "I think chaff should say something here." },
55
+ environment: "Environment",
56
+ findings: "Findings",
57
+ excerpt: "The part of the document (only these lines are included)",
58
+ config: "chaff.yaml",
59
+ expected: "What I expected",
60
+ expectedHint: {
61
+ "false-positive": "<!-- Why it is wrong, and how the text should be read -->",
62
+ missed: "<!-- What is wrong in the text, and which rule should have said so -->",
63
+ },
64
+ lines: (from, to) => `lines ${String(from)}-${String(to)}`,
65
+ },
66
+ };
67
+
68
+ const fence = (content: string, language = ""): string => {
69
+ const longest = Math.max(2, ...[...content.matchAll(/`+/gu)].map((match) => match[0].length));
70
+ const ticks = "`".repeat(longest + 1);
71
+ return [`${ticks}${language}`, content, ticks].join("\n");
72
+ };
73
+
74
+ /** Pure: the issue title and body for a report. Nothing here reads files or the network. */
75
+ export const feedbackDraft = (input: FeedbackInput, ui: UiLanguage): FeedbackDraft => {
76
+ const text = TEXT[ui];
77
+ const first = input.findings[0];
78
+ const title =
79
+ input.kind === "false-positive" && first !== undefined ? text.titleFalse(first.rule, first.message) : text.titleMissed(input.fileName, input.line);
80
+ const body = [
81
+ text.what[input.kind],
82
+ "",
83
+ `## ${text.environment}`,
84
+ "",
85
+ `- chaffjs ${input.version}`,
86
+ `- ${input.runtime}`,
87
+ `- ${input.fileName} · ${input.language} · ${input.genre}`,
88
+ ...(input.findings.length === 0
89
+ ? []
90
+ : ["", `## ${text.findings}`, "", ...input.findings.map((finding) => `- \`${finding.rule}\` (${String(finding.line)}): ${finding.message}`)]),
91
+ "",
92
+ `## ${text.excerpt}`,
93
+ ...input.excerpts.flatMap((excerpt) => ["", text.lines(excerpt.from, excerpt.to), "", fence(excerpt.lines.join("\n"))]),
94
+ ...(input.config === undefined ? [] : ["", `## ${text.config}`, "", fence(input.config.trimEnd(), "yaml")]),
95
+ "",
96
+ `## ${text.expected}`,
97
+ "",
98
+ text.expectedHint[input.kind],
99
+ "",
100
+ ].join("\n");
101
+ return { title, body };
102
+ };