chaffjs 0.8.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 (135) 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/document.d.ts +2 -2
  39. package/dist/document.d.ts.map +1 -1
  40. package/dist/document.js +4 -2
  41. package/dist/document.js.map +1 -1
  42. package/dist/feedback/draft.d.ts +28 -0
  43. package/dist/feedback/draft.d.ts.map +1 -0
  44. package/dist/feedback/draft.js +66 -0
  45. package/dist/feedback/draft.js.map +1 -0
  46. package/dist/feedback/excerpt.d.ts +10 -0
  47. package/dist/feedback/excerpt.d.ts.map +1 -0
  48. package/dist/feedback/excerpt.js +20 -0
  49. package/dist/feedback/excerpt.js.map +1 -0
  50. package/dist/legal-address.d.ts +2 -0
  51. package/dist/legal-address.d.ts.map +1 -0
  52. package/dist/legal-address.js +16 -0
  53. package/dist/legal-address.js.map +1 -0
  54. package/dist/orthography.d.ts.map +1 -1
  55. package/dist/orthography.js +15 -0
  56. package/dist/orthography.js.map +1 -1
  57. package/dist/plugin.d.ts +88 -0
  58. package/dist/plugin.d.ts.map +1 -1
  59. package/dist/profile/for-file.d.ts +7 -0
  60. package/dist/profile/for-file.d.ts.map +1 -0
  61. package/dist/profile/for-file.js +6 -0
  62. package/dist/profile/for-file.js.map +1 -0
  63. package/dist/profile/load.d.ts +4 -0
  64. package/dist/profile/load.d.ts.map +1 -0
  65. package/dist/profile/load.js +25 -0
  66. package/dist/profile/load.js.map +1 -0
  67. package/dist/profile/parse.d.ts +19 -0
  68. package/dist/profile/parse.d.ts.map +1 -0
  69. package/dist/profile/parse.js +82 -0
  70. package/dist/profile/parse.js.map +1 -0
  71. package/dist/profile/select.d.ts +22 -0
  72. package/dist/profile/select.d.ts.map +1 -0
  73. package/dist/profile/select.js +21 -0
  74. package/dist/profile/select.js.map +1 -0
  75. package/dist/reasons.d.ts +1 -0
  76. package/dist/reasons.d.ts.map +1 -1
  77. package/dist/reasons.js +2 -0
  78. package/dist/reasons.js.map +1 -1
  79. package/dist/run.d.ts.map +1 -1
  80. package/dist/run.js +24 -3
  81. package/dist/run.js.map +1 -1
  82. package/dist/structure/build.d.ts +2 -1
  83. package/dist/structure/build.d.ts.map +1 -1
  84. package/dist/structure/build.js +94 -7
  85. package/dist/structure/build.js.map +1 -1
  86. package/dist/structure/issues.d.ts.map +1 -1
  87. package/dist/structure/issues.js +9 -2
  88. package/dist/structure/issues.js.map +1 -1
  89. package/dist/structure/of.d.ts +2 -1
  90. package/dist/structure/of.d.ts.map +1 -1
  91. package/dist/structure/of.js +7 -1
  92. package/dist/structure/of.js.map +1 -1
  93. package/dist/structure/relative-find.d.ts +6 -0
  94. package/dist/structure/relative-find.d.ts.map +1 -0
  95. package/dist/structure/relative-find.js +179 -0
  96. package/dist/structure/relative-find.js.map +1 -0
  97. package/dist/structure/relative-resolve.d.ts +3 -0
  98. package/dist/structure/relative-resolve.d.ts.map +1 -0
  99. package/dist/structure/relative-resolve.js +175 -0
  100. package/dist/structure/relative-resolve.js.map +1 -0
  101. package/dist/structure/unread.d.ts +8 -0
  102. package/dist/structure/unread.d.ts.map +1 -0
  103. package/dist/structure/unread.js +34 -0
  104. package/dist/structure/unread.js.map +1 -0
  105. package/package.json +4 -3
  106. package/profiles/statute.yaml +62 -0
  107. package/skills/chaff/SKILL.md +24 -4
  108. package/src/address-chain.ts +113 -0
  109. package/src/cli-args.ts +1 -1
  110. package/src/cli-text.ts +4 -0
  111. package/src/cli.ts +20 -1
  112. package/src/commands/eval.ts +2 -1
  113. package/src/commands/feedback.ts +210 -0
  114. package/src/commands/test.ts +3 -2
  115. package/src/commands/tree.ts +3 -1
  116. package/src/config/by-path.ts +11 -3
  117. package/src/config/load.ts +5 -1
  118. package/src/detectors/char-shape.ts +5 -4
  119. package/src/document.ts +11 -3
  120. package/src/feedback/draft.ts +102 -0
  121. package/src/feedback/excerpt.ts +23 -0
  122. package/src/orthography.ts +15 -0
  123. package/src/plugin.ts +82 -0
  124. package/src/profile/for-file.ts +11 -0
  125. package/src/profile/load.ts +26 -0
  126. package/src/profile/parse.ts +106 -0
  127. package/src/profile/select.ts +35 -0
  128. package/src/reasons.ts +5 -0
  129. package/src/run.ts +25 -3
  130. package/src/structure/build.ts +118 -10
  131. package/src/structure/issues.ts +10 -2
  132. package/src/structure/of.ts +15 -3
  133. package/src/structure/relative-find.ts +224 -0
  134. package/src/structure/relative-resolve.ts +209 -0
  135. package/src/structure/unread.ts +39 -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",
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
+ };
@@ -0,0 +1,23 @@
1
+ /** Lines around a line of a document, 1-based and inclusive. Only these lines go into a report. */
2
+ export type Excerpt = { readonly from: number; readonly to: number; readonly lines: readonly string[] };
3
+
4
+ export const EXCERPT_RADIUS = 2;
5
+
6
+ /** Pure: the excerpts around each line, overlapping ones merged, in document order. Lines outside the document are dropped. */
7
+ export const excerptsAround = (source: string, lines: readonly number[], radius: number = EXCERPT_RADIUS): Excerpt[] => {
8
+ const all = source.split("\n");
9
+ const ranges = [...new Set(lines)]
10
+ .filter((line) => line >= 1 && line <= all.length)
11
+ .sort((left, right) => left - right)
12
+ .map((line) => ({ from: Math.max(1, line - radius), to: Math.min(all.length, line + radius) }));
13
+ const merged = ranges.reduce<{ from: number; to: number }[]>((acc, range) => {
14
+ const last = acc.at(-1);
15
+ if (last !== undefined && range.from <= last.to + 1) {
16
+ last.to = Math.max(last.to, range.to);
17
+ return acc;
18
+ }
19
+ acc.push({ ...range });
20
+ return acc;
21
+ }, []);
22
+ return merged.map(({ from, to }) => ({ from, to, lines: all.slice(from - 1, to) }));
23
+ };
@@ -80,8 +80,23 @@ const leftRunBeside = (chars: readonly string[], last: number, japanese: string
80
80
  return kind === "letter" && DIGIT.test(runStart(chars, last) ?? "") ? "after-digit" : kind;
81
81
  };
82
82
 
83
+ /**
84
+ * 「第3条」「第4条第2項」の数字は番地の書き方で、空けるか詰めるかの好みではない。数えると、条番号の多い文書では
85
+ * 「2 か所」のような普通の書き方のほうが少数に見えてしまう。「第」のすぐ後ろの数字の並びは、両側とも数えない。
86
+ */
87
+ const ORDINAL_PREFIX = "第";
88
+
89
+ const isOrdinalRun = (chars: readonly string[], index: number): boolean => {
90
+ let first = index;
91
+ while (first > 0 && /[\d.]/u.test(chars[first - 1] ?? "")) first -= 1;
92
+ const before = chars[first - 1] === " " ? first - 2 : first - 1;
93
+ return chars[before] === ORDINAL_PREFIX;
94
+ };
95
+
83
96
  const boundaryAt = (chars: readonly string[], index: number): Omit<Boundary, "offset"> | undefined => {
84
97
  const [left, right, after] = [chars[index], chars[index + 1], chars[index + 2]];
98
+ const numberAfterPrefix = left === ORDINAL_PREFIX && DIGIT.test((right === " " ? after : right) ?? "");
99
+ if (numberAfterPrefix || (DIGIT.test(left ?? "") && isOrdinalRun(chars, index))) return undefined;
85
100
  const touching = latinBeside(left, right, "before-digit") ?? leftRunBeside(chars, index, right);
86
101
  if (touching !== undefined) return { kind: touching, spaced: false };
87
102
  if (right !== " ") return undefined;
package/src/plugin.ts CHANGED
@@ -93,6 +93,11 @@ export type NumberedLine = {
93
93
  readonly label: string;
94
94
  /** 番号に続く見出し。「第3条(支払)」の「支払」。本文しか無ければ空。 */
95
95
  readonly heading: string;
96
+ /**
97
+ * 番号に付いた語("section"、"article")。英米の文書は Section と Article を別のものとして使うので、
98
+ * 参照の attrs.numbering と比べて、この文書に無い種類の番号への参照(Section で組んだ法令の中の "Article 6")を見分ける。
99
+ */
100
+ readonly numbering?: string | undefined;
96
101
  /** 番号の後ろの文字列全部。定義や参照はここから探す。 */
97
102
  readonly rest: string;
98
103
  /**
@@ -124,6 +129,16 @@ export type StructurePatterns = {
124
129
  readonly obligations: (text: string) => readonly Mention[];
125
130
  /** 数量。attrs.value に数、attrs.unit に単位。 */
126
131
  readonly quantities: (text: string) => readonly Mention[];
132
+ /**
133
+ * 「In this section—」のように、この後の定義はいま開いている条の中でだけ通じると宣言する行なら true。
134
+ * その条の定義に scope: "local" が付く。定義の並びが次の行から始まるので、定義の行だけを見ても分からない。
135
+ */
136
+ readonly opensDefinitionScope?: (text: string) => boolean;
137
+ /**
138
+ * 範囲が条より広いまとまり(「In this Part—」)なら、そのまとまりの深さ(NumberedLine.depth)。
139
+ * そのまとまりの中の定義どうしだけを比べる。無ければ、opensDefinitionScope のとおり条の中。
140
+ */
141
+ readonly definitionScopeDepth?: (text: string) => number | undefined;
127
142
  /** 日付。attrs.value に "2024-04-01"・"2024-04"・"04-01"・"2024" のどれか。無い言語は日付を読まない。 */
128
143
  readonly dates?: (text: string) => readonly Mention[];
129
144
  /**
@@ -131,6 +146,8 @@ export type StructurePatterns = {
131
146
  * 数字と点だけの通し番号は core が言語を問わず読むので、それを数量と見分けられるのは言語パッケージだけ。
132
147
  */
133
148
  readonly countedAfter?: (number: string, rest: string) => boolean;
149
+ /** 数の書き方(「二十二」「3」)を数にする。相対の参照の「前二項」「前条第二項」が使う。 */
150
+ readonly number?: (text: string) => number | undefined;
134
151
  };
135
152
 
136
153
  export type StructureKind = "doc" | "section" | "chapter" | "article" | "item" | "definition" | "reference" | "obligation" | "quantity" | "date";
@@ -150,6 +167,8 @@ export type StructureNode = {
150
167
  readonly level?: number;
151
168
  /** 範囲をまとめた行の最後の位置(NumberedLine.ordinalTo)。S 式には出さない。 */
152
169
  readonly ordinalTo?: number;
170
+ /** 番号に付いた語(NumberedLine.numbering)。S 式には出さない。 */
171
+ readonly numbering?: string;
153
172
  };
154
173
 
155
174
  // ───────── 文書モデルと rule ─────────
@@ -175,6 +194,65 @@ export type BulletList = { readonly span: Span; readonly items: readonly number[
175
194
  * detector に渡る唯一の入り口。core が I/O を済ませてから呼ぶ。
176
195
  * detector は純関数で、fs / network / clock に触れない。spec §6。
177
196
  */
197
+ /**
198
+ * 文書の種類の知識(法令の番地の書き方など)。コードは種類を知らず、profiles/*.yaml に書いたものを読む。
199
+ * 言語ごとに一つに絞ったもの。正規表現は文字列のまま持ち、使う側が組み立てる。
200
+ */
201
+ export type DocumentProfile = {
202
+ readonly id: string;
203
+ /** 番地の書き方。漢字の連なりにも、数量にも数えない。 */
204
+ readonly addresses: readonly string[];
205
+ /** 番地と番地のあいだ、番地の後ろに付く語。 */
206
+ readonly connectives: readonly string[];
207
+ /** 番地の並びのすぐ後ろに来てよい文字(正規表現)。無ければどこで終わってもよい。 */
208
+ readonly addressEnd?: string | undefined;
209
+ /**
210
+ * 条の前の行に置く見出しの形(正規表現。最初の括弧の中が見出しの言葉)。法令の「(解雇の予告)」。
211
+ * すぐ次の行の条が見出しを持たなければ、その条の見出しになる。
212
+ */
213
+ readonly caption?: string | undefined;
214
+ /** 前条・同項のように、書いた場所から番地が決まる参照の読み方。無ければ読まない。 */
215
+ readonly relative?: RelativeVocabulary | undefined;
216
+ };
217
+
218
+ /**
219
+ * 相対の参照の語彙。語の意味(一つ前・一つ後・直前に引いたもの・今いるところ)はコードが知り、どの語がそれに当たるかは種類が決める。
220
+ * units は単位の語から木の深さ(NumberedLine.depth)への対応。
221
+ */
222
+ export type RelativeVocabulary = {
223
+ readonly before: readonly string[];
224
+ readonly after: readonly string[];
225
+ readonly same: readonly string[];
226
+ readonly current: readonly string[];
227
+ /** 「前各項」の「各」。一つ前だけでなく、前の全部。 */
228
+ readonly every: readonly string[];
229
+ /** 「前二項」の「二」のような、いくつ分かの数の書き方(正規表現)。数は言語パッケージの number が読む。 */
230
+ readonly count: string;
231
+ readonly units: Readonly<Record<string, number>>;
232
+ /** 「前条第二項」の「第」。後ろに続く番地の書き出し。 */
233
+ readonly suffixPrefix: string;
234
+ /**
235
+ * 最初のものに番号を振らない深さ(法令の項)。あれば「第二条第一号」は 2.1.1、「第四条第一項」は第四条そのものでもある。
236
+ */
237
+ readonly implicitFirst: number | undefined;
238
+ /** すぐ前に来てはいけない文字(正規表現)。「事前条件」の「前条」を参照にしない。 */
239
+ readonly notAfter: string | undefined;
240
+ /**
241
+ * 読み替え(「『前条』とあるのは『…』」)の括弧。中の前条・同項は読み替える先の文の言葉で、ここからは決められないので読まない。
242
+ * 閉じの直後に after、開きの直前に before のどれかがあるものだけ。
243
+ */
244
+ readonly substitution: { readonly open: string; readonly close: string; readonly after: readonly string[]; readonly before: readonly string[] } | undefined;
245
+ /** 条を書かずにこの単位で始まる番地(法令の「第一項」「第二号」)は、書いた場所を含むまとまりの中を指す。 */
246
+ readonly inside: readonly string[];
247
+ /**
248
+ * 並びをつなぐもの(正規表現。及び・若しくは・から・、・枝番号の「の二」)。すぐ前の参照とのあいだがこれとつなぎの語(connectives)と
249
+ * 閉じた括弧だけなら、条を書かない番地はその参照の続き。「第三十三条第七項若しくは第九項」の「第九項」は第三十三条の第九項。
250
+ */
251
+ readonly joiners: readonly string[];
252
+ /** 括弧書き(「(…)」)の開きと閉じ。並びの途中に挟まっても、並びを切らない。 */
253
+ readonly aside: { readonly open: string; readonly close: string } | undefined;
254
+ };
255
+
178
256
  export type ProseDocument = {
179
257
  readonly path: string;
180
258
  readonly source: string;
@@ -196,6 +274,10 @@ export type ProseDocument = {
196
274
  readonly requiredSections: readonly string[];
197
275
  /** 番地の付いた木(§27)。adapter が structure を持たない言語では無い。 */
198
276
  readonly structure: StructureNode | undefined;
277
+ /** 文書の種類(法令など)。選ばれなければ無い。 */
278
+ readonly profile?: DocumentProfile | undefined;
279
+ /** 本文でないもの(コード・HTML・強調の印)を同じ長さの空白で覆った source。位置は source と同じ。 */
280
+ readonly prose?: string | undefined;
199
281
  };
200
282
 
201
283
  export type Severity = "error" | "warning" | "info";