chaffjs 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/README.md +11 -4
  2. package/dist/adapter-load.d.ts +12 -1
  3. package/dist/adapter-load.d.ts.map +1 -1
  4. package/dist/adapter-load.js +37 -13
  5. package/dist/adapter-load.js.map +1 -1
  6. package/dist/cli-args.d.ts +8 -0
  7. package/dist/cli-args.d.ts.map +1 -0
  8. package/dist/cli-args.js +8 -0
  9. package/dist/cli-args.js.map +1 -0
  10. package/dist/cli-text.d.ts +26 -0
  11. package/dist/cli-text.d.ts.map +1 -0
  12. package/dist/cli-text.js +151 -0
  13. package/dist/cli-text.js.map +1 -0
  14. package/dist/cli.d.ts +2 -0
  15. package/dist/cli.d.ts.map +1 -1
  16. package/dist/cli.js +75 -86
  17. package/dist/cli.js.map +1 -1
  18. package/dist/commands/cite.d.ts +2 -1
  19. package/dist/commands/cite.d.ts.map +1 -1
  20. package/dist/commands/cite.js +39 -18
  21. package/dist/commands/cite.js.map +1 -1
  22. package/dist/commands/skill.d.ts +22 -0
  23. package/dist/commands/skill.d.ts.map +1 -0
  24. package/dist/commands/skill.js +68 -0
  25. package/dist/commands/skill.js.map +1 -0
  26. package/dist/commands/tree.d.ts +11 -1
  27. package/dist/commands/tree.d.ts.map +1 -1
  28. package/dist/commands/tree.js +18 -6
  29. package/dist/commands/tree.js.map +1 -1
  30. package/dist/config/rule-problems.d.ts +2 -1
  31. package/dist/config/rule-problems.d.ts.map +1 -1
  32. package/dist/config/rule-problems.js +17 -6
  33. package/dist/config/rule-problems.js.map +1 -1
  34. package/dist/config/write.d.ts.map +1 -1
  35. package/dist/config/write.js +29 -9
  36. package/dist/config/write.js.map +1 -1
  37. package/dist/genre.d.ts +1 -1
  38. package/dist/genre.d.ts.map +1 -1
  39. package/dist/genre.js +3 -3
  40. package/dist/genre.js.map +1 -1
  41. package/dist/init.d.ts +2 -1
  42. package/dist/init.d.ts.map +1 -1
  43. package/dist/init.js +62 -13
  44. package/dist/init.js.map +1 -1
  45. package/dist/plugin.d.ts +6 -0
  46. package/dist/plugin.d.ts.map +1 -1
  47. package/dist/reasons.d.ts +15 -0
  48. package/dist/reasons.d.ts.map +1 -0
  49. package/dist/reasons.js +29 -0
  50. package/dist/reasons.js.map +1 -0
  51. package/dist/render/explain.d.ts.map +1 -1
  52. package/dist/render/explain.js +36 -12
  53. package/dist/render/explain.js.map +1 -1
  54. package/dist/render/friendly.d.ts.map +1 -1
  55. package/dist/render/friendly.js +32 -9
  56. package/dist/render/friendly.js.map +1 -1
  57. package/dist/render/rules-json.d.ts.map +1 -1
  58. package/dist/render/rules-json.js +47 -27
  59. package/dist/render/rules-json.js.map +1 -1
  60. package/dist/render/summary.d.ts +3 -2
  61. package/dist/render/summary.d.ts.map +1 -1
  62. package/dist/render/summary.js +24 -9
  63. package/dist/render/summary.js.map +1 -1
  64. package/dist/render/suppressions.d.ts +2 -1
  65. package/dist/render/suppressions.d.ts.map +1 -1
  66. package/dist/render/suppressions.js +34 -10
  67. package/dist/render/suppressions.js.map +1 -1
  68. package/dist/run.d.ts.map +1 -1
  69. package/dist/run.js +18 -10
  70. package/dist/run.js.map +1 -1
  71. package/dist/structure/build.d.ts.map +1 -1
  72. package/dist/structure/build.js +8 -2
  73. package/dist/structure/build.js.map +1 -1
  74. package/dist/structure/cite.d.ts.map +1 -1
  75. package/dist/structure/cite.js +3 -1
  76. package/dist/structure/cite.js.map +1 -1
  77. package/dist/structure/issues.d.ts +5 -2
  78. package/dist/structure/issues.d.ts.map +1 -1
  79. package/dist/structure/issues.js +27 -9
  80. package/dist/structure/issues.js.map +1 -1
  81. package/dist/structure/numbering.d.ts.map +1 -1
  82. package/dist/structure/numbering.js +11 -6
  83. package/dist/structure/numbering.js.map +1 -1
  84. package/dist/ui.d.ts +14 -0
  85. package/dist/ui.d.ts.map +1 -0
  86. package/dist/ui.js +14 -0
  87. package/dist/ui.js.map +1 -0
  88. package/dist/watch.d.ts +1 -1
  89. package/dist/watch.d.ts.map +1 -1
  90. package/dist/watch.js +2 -2
  91. package/dist/watch.js.map +1 -1
  92. package/package.json +3 -2
  93. package/skills/chaff/SKILL.md +61 -0
  94. package/src/adapter-load.ts +41 -10
  95. package/src/cli-args.ts +9 -0
  96. package/src/cli-text.ts +182 -0
  97. package/src/cli.ts +91 -89
  98. package/src/commands/cite.ts +51 -18
  99. package/src/commands/skill.ts +74 -0
  100. package/src/commands/tree.ts +27 -7
  101. package/src/config/rule-problems.ts +24 -5
  102. package/src/config/write.ts +33 -9
  103. package/src/genre.ts +4 -4
  104. package/src/init.ts +74 -15
  105. package/src/plugin.ts +6 -0
  106. package/src/reasons.ts +44 -0
  107. package/src/render/explain.ts +46 -12
  108. package/src/render/friendly.ts +42 -10
  109. package/src/render/rules-json.ts +35 -9
  110. package/src/render/summary.ts +34 -9
  111. package/src/render/suppressions.ts +45 -10
  112. package/src/run.ts +20 -9
  113. package/src/structure/build.ts +10 -2
  114. package/src/structure/cite.ts +3 -1
  115. package/src/structure/issues.ts +33 -14
  116. package/src/structure/numbering.ts +12 -7
  117. package/src/ui.ts +23 -0
  118. package/src/watch.ts +2 -2
@@ -1,39 +1,73 @@
1
1
  import { definedLevels, resolve } from "../levels.ts";
2
2
  import { localized } from "./text.ts";
3
+ import { uiLanguageOf, type Texts } from "../ui.ts";
3
4
  import type { Level, RuleDefinition } from "../plugin.ts";
4
5
 
5
- const levelLine = (rule: RuleDefinition, level: Level, current: Level, genre: string | undefined): string => {
6
- const mark = level === current ? "\u2192" : " ";
6
+ const TEXT: Texts<{
7
+ readonly off: string;
8
+ readonly defaultGenre: string;
9
+ readonly genreNote: (genre: string, others: string) => string;
10
+ readonly experimental: string;
11
+ readonly howToFix: string;
12
+ readonly values: (unit: string) => string;
13
+ readonly now: (level: string) => string;
14
+ readonly change: (id: string) => string;
15
+ }> = {
16
+ ja: {
17
+ off: "見ない",
18
+ defaultGenre: "既定",
19
+ genreNote: (genre, others) => `この数字は ${genre} のものです。ほかに ${others} で別の数字を持っています。`,
20
+ experimental: "このルールはまだ試験中で、既定では動きません(--experimental で動きます)。",
21
+ howToFix: "直しかた",
22
+ values: (unit) => `設定できる値(単位: ${unit}):`,
23
+ now: (level) => `いまは ${level} です。`,
24
+ change: (id) => `変える: npx chaff relax ${id} --why "理由"`,
25
+ },
26
+ en: {
27
+ off: "not checked",
28
+ defaultGenre: "the default genre",
29
+ genreNote: (genre, others) => `These numbers are for ${genre}. ${others} have numbers of their own.`,
30
+ experimental: "This rule is still experimental and does not run by default (--experimental runs it).",
31
+ howToFix: "How to fix",
32
+ values: (unit) => `Levels (unit: ${unit}):`,
33
+ now: (level) => `Now: ${level}.`,
34
+ change: (id) => `Change it: npx chaff relax ${id} --why "reason"`,
35
+ },
36
+ };
37
+
38
+ const levelLine = (rule: RuleDefinition, level: Level, current: Level, genre: string | undefined, off: string): string => {
39
+ const mark = level === current ? "→" : " ";
7
40
  const name = level.padEnd(9);
8
- return level === "off" ? ` ${mark} ${name}見ない` : ` ${mark} ${name}${resolve(rule, level, genre).limit}`;
41
+ return level === "off" ? ` ${mark} ${name}${off}` : ` ${mark} ${name}${resolve(rule, level, genre).limit}`;
9
42
  };
10
43
 
11
44
  /** ジャンルで数字が変わる rule は、それを言わないと「設定したのに効かない」に見える。 */
12
- const genreNote = (rule: RuleDefinition, genre: string | undefined): string[] => {
45
+ const genreNote = (rule: RuleDefinition, genre: string | undefined, text: (typeof TEXT)["ja"]): string[] => {
13
46
  const others = Object.keys(rule.by_genre).filter((key) => key !== genre);
14
47
  if (others.length === 0) return [];
15
- return ["", ` この数字は ${genre ?? "既定"} のものです。ほかに ${others.join(" / ")} で別の数字を持っています。`];
48
+ return ["", ` ${text.genreNote(genre ?? text.defaultGenre, others.join(" / "))}`];
16
49
  };
17
50
 
18
51
  /** rule の意図と根拠を読む。指摘に納得できないときの入口。 */
19
52
  export const renderExplain = (rule: RuleDefinition, current: Level, language: string, unit: string, genre?: string): string => {
20
- const experimental = rule.status === "experimental" ? [" このルールはまだ試験中で、既定では動きません(--experimental で動きます)。"] : [];
53
+ const text = TEXT[uiLanguageOf(language)];
54
+ const experimental = rule.status === "experimental" ? [` ${text.experimental}`] : [];
21
55
  return [
22
56
  "",
23
57
  ` ${localized(rule.name, language)} (${rule.id})`,
24
58
  "",
25
59
  ` ${localized(rule.why, language)}`,
26
60
  "",
27
- ` 直しかた: ${localized(rule.how_to_fix, language)}`,
61
+ ` ${text.howToFix}: ${localized(rule.how_to_fix, language)}`,
28
62
  "",
29
- ` 設定できる値(単位: ${unit}):`,
30
- ...definedLevels(rule).map((level) => levelLine(rule, level, current, genre)),
31
- ...genreNote(rule, genre),
63
+ ` ${text.values(unit)}`,
64
+ ...definedLevels(rule).map((level) => levelLine(rule, level, current, genre, text.off)),
65
+ ...genreNote(rule, genre, text),
32
66
  "",
33
- ` いまは ${current} です。`,
67
+ ` ${text.now(current)}`,
34
68
  ...experimental,
35
69
  "",
36
- ` 変える: npx chaff relax ${rule.id} --why "理由"`,
70
+ ` ${text.change(rule.id)}`,
37
71
  "",
38
72
  ].join("\n");
39
73
  };
@@ -2,8 +2,41 @@ import type { Finding, RuleDefinition } from "../plugin.ts";
2
2
  import type { RunResult } from "../run.ts";
3
3
  import { MARK, localized, messageOf } from "./text.ts";
4
4
  import { tally } from "./summary.ts";
5
+ import { uiLanguageOf, type Texts } from "../ui.ts";
5
6
 
6
7
  const RULE = 60;
8
+
9
+ const TEXT: Texts<{
10
+ readonly line: (n: number) => string;
11
+ readonly relax: string;
12
+ readonly machine: string;
13
+ readonly deterministic: string;
14
+ readonly untouched: string;
15
+ readonly forced: (n: number, ids: string) => string;
16
+ readonly notRun: (n: number) => string;
17
+ readonly because: (why: string) => string;
18
+ }> = {
19
+ ja: {
20
+ line: (n) => `${n} 行目`,
21
+ relax: "このルールをゆるめる",
22
+ machine: "すべて機械による判定です",
23
+ deterministic: " (同じ文章なら何度実行しても同じ結果になります)",
24
+ untouched: "文章は書き換えていません。直すのは書いた人です。",
25
+ forced: (n, ids) => `試験中の rule を ${n} 件、設定により有効にしています: ${ids}`,
26
+ notRun: (n) => `${n} 件の rule は動いていません:`,
27
+ because: (why) => `(${why})`,
28
+ },
29
+ en: {
30
+ line: (n) => `line ${n}`,
31
+ relax: "Relax this rule",
32
+ machine: "All judged by machine",
33
+ deterministic: " (the same text gives the same result every time)",
34
+ untouched: "The text was not changed. Fixing it is the writer's job.",
35
+ forced: (n, ids) => `${n} experimental rule${n === 1 ? "" : "s"} turned on in the settings: ${ids}`,
36
+ notRun: (n) => `${n} rule${n === 1 ? "" : "s"} did not run:`,
37
+ because: (why) => ` (${why})`,
38
+ },
39
+ };
7
40
  const QUOTE_LIMIT = 120;
8
41
 
9
42
  const indent = (text: string, pad: string): string[] => text.split("\n").map((line) => `${pad}${line}`);
@@ -21,7 +54,8 @@ const quoteOf = (finding: Finding): string[] => {
21
54
  */
22
55
  const block = (finding: Finding, rule: RuleDefinition, language: string): string[] => [
23
56
  "",
24
- `─── ${finding.line} 行目 ${"─".repeat(Math.max(0, RULE - String(finding.line).length - 8))}`,
57
+ // "12 行目" and "line 12" take the same width on a terminal, so the rule ends in the same column in both.
58
+ `─── ${TEXT[uiLanguageOf(language)].line(finding.line)} ${"─".repeat(Math.max(0, RULE - String(finding.line).length - 8))}`,
25
59
  ...quoteOf(finding),
26
60
  "",
27
61
  ` ${MARK[finding.severity] ?? "·"} ${localized(rule.name, language)}`,
@@ -31,7 +65,7 @@ const block = (finding: Finding, rule: RuleDefinition, language: string): string
31
65
  "",
32
66
  ...indent(`→ ${localized(rule.how_to_fix, language)}`, " "),
33
67
  "",
34
- ` このルールをゆるめる: npx chaff relax ${finding.rule}`,
68
+ ` ${TEXT[uiLanguageOf(language)].relax}: npx chaff relax ${finding.rule}`,
35
69
  "",
36
70
  ];
37
71
 
@@ -41,22 +75,20 @@ export const renderFriendly = (header: string, result: RunResult, rules: readonl
41
75
  const rule = byId.get(finding.rule);
42
76
  return rule === undefined ? [] : block(finding, rule, language);
43
77
  });
78
+ const text = TEXT[uiLanguageOf(language)];
44
79
  const notes = [
45
80
  "",
46
81
  "─".repeat(RULE),
47
82
  "",
48
- ` ${tally(result.findings)} すべて機械による判定です`,
49
- " (同じ文章なら何度実行しても同じ結果になります)",
83
+ ` ${tally(result.findings, uiLanguageOf(language))} ${text.machine}`,
84
+ text.deterministic,
50
85
  "",
51
- " 文章は書き換えていません。直すのは書いた人です。",
86
+ ` ${text.untouched}`,
52
87
  ];
53
- const forced =
54
- result.forcedExperimental.length > 0
55
- ? ["", ` 試験中の rule を ${result.forcedExperimental.length} 件、設定により有効にしています: ${result.forcedExperimental.join(", ")}`]
56
- : [];
88
+ const forced = result.forcedExperimental.length > 0 ? ["", ` ${text.forced(result.forcedExperimental.length, result.forcedExperimental.join(", "))}`] : [];
57
89
  const skipped =
58
90
  result.skipped.length > 0
59
- ? ["", ` ${result.skipped.length} 件の rule は動いていません:`, ...result.skipped.map((entry) => ` ${entry.rule}(${entry.why})`)]
91
+ ? ["", ` ${text.notRun(result.skipped.length)}`, ...result.skipped.map((entry) => ` ${entry.rule}${text.because(entry.why)}`)]
60
92
  : [];
61
93
  return ["", header, ...blocks, ...notes, ...forced, ...skipped, ""].join("\n");
62
94
  };
@@ -1,15 +1,39 @@
1
1
  import { definedLevels, resolve } from "../levels.ts";
2
2
  import type { Config } from "../config/load.ts";
3
3
  import type { RuleDefinition } from "../plugin.ts";
4
+ import { uiLanguageOf, type Texts } from "../ui.ts";
4
5
 
5
- const now = (rule: RuleDefinition, config: Config, genre: string): Record<string, unknown> => {
6
+ const TEXT: Texts<{
7
+ readonly offBySetting: string;
8
+ readonly offExperimental: string;
9
+ readonly valuesNote: string;
10
+ readonly reason: string;
11
+ readonly byFile: string;
12
+ }> = {
13
+ ja: {
14
+ offBySetting: "設定で止めている",
15
+ offExperimental: "experimental な rule は既定で動かさない",
16
+ valuesNote: "この 4 語のかわりに数字を直接書いてもよい。4 語のほうを勧める。",
17
+ reason: "<理由>",
18
+ byFile: "chaff.yaml の rules に <rule-id>: <level> を足す。既定のままのものは書かない。",
19
+ },
20
+ en: {
21
+ offBySetting: "turned off in the settings",
22
+ offExperimental: "experimental rules do not run by default",
23
+ valuesNote: "A number may be written instead of these four words. The words are recommended.",
24
+ reason: "<reason>",
25
+ byFile: "Add <rule-id>: <level> under rules in chaff.yaml. Leave out anything at its default.",
26
+ },
27
+ };
28
+
29
+ const now = (rule: RuleDefinition, config: Config, genre: string, text: (typeof TEXT)["ja"]): Record<string, unknown> => {
6
30
  const explicit = config.rules[rule.id];
7
- if (explicit === "off") return { level: "off", why_off: "設定で止めている" };
31
+ if (explicit === "off") return { level: "off", why_off: text.offBySetting };
8
32
  const limit = rule.layer === "L4" ? undefined : config.limits[rule.id];
9
33
  if (limit !== undefined) return { level: "normal", limit, set_as: "number" };
10
34
  if (explicit !== undefined) return { level: explicit, limit: resolve(rule, explicit, genre).limit };
11
35
  if (rule.status === "experimental" && !config.experimental) {
12
- return { level: "off", why_off: "experimental な rule は既定で動かさない", turn_on_with: `npx chaff lint --experimental` };
36
+ return { level: "off", why_off: text.offExperimental, turn_on_with: `npx chaff lint --experimental` };
13
37
  }
14
38
  return { level: "normal", limit: resolve(rule, "normal", genre).limit };
15
39
  };
@@ -33,14 +57,15 @@ const levelsOf = (rule: RuleDefinition, genre: string): Record<string, unknown>
33
57
  };
34
58
 
35
59
  /** AI に設定を書かせるときの入口。推測せずに書けるだけの情報を 1 つに入れる。spec §19.3。 */
36
- export const rulesJson = (rules: readonly RuleDefinition[], config: Config, language: string, genre: string): string =>
37
- JSON.stringify(
60
+ export const rulesJson = (rules: readonly RuleDefinition[], config: Config, language: string, genre: string): string => {
61
+ const text = TEXT[uiLanguageOf(language)];
62
+ return JSON.stringify(
38
63
  {
39
64
  schema_version: 1,
40
65
  config_file: "chaff.yaml",
41
66
  detected: { genre, language },
42
67
  values_you_can_use: ["strict", "normal", "relaxed", "off"],
43
- values_note: "この 4 語のかわりに数字を直接書いてもよい。4 語のほうを勧める。",
68
+ values_note: text.valuesNote,
44
69
  rules: rules.map((rule) => ({
45
70
  id: rule.id,
46
71
  layer: rule.layer,
@@ -52,13 +77,14 @@ export const rulesJson = (rules: readonly RuleDefinition[], config: Config, lang
52
77
  ...levelsOf(rule, genre),
53
78
  levels_you_can_set: definedLevels(rule),
54
79
  your_setting: yourSetting(rule, config),
55
- now: now(rule, config, genre),
80
+ now: now(rule, config, genre, text),
56
81
  })),
57
82
  how_to_change: {
58
- by_command: ['npx chaff relax <rule-id> --why "<理由>"', 'npx chaff strict <rule-id> --why "<理由>"', 'npx chaff off <rule-id> --why "<理由>"'],
59
- by_file: "chaff.yaml の rules に <rule-id>: <level> を足す。既定のままのものは書かない。",
83
+ by_command: ["relax", "strict", "off"].map((command) => `npx chaff ${command} <rule-id> --why "${text.reason}"`),
84
+ by_file: text.byFile,
60
85
  },
61
86
  },
62
87
  null,
63
88
  2,
64
89
  );
90
+ };
@@ -1,22 +1,47 @@
1
1
  import type { Finding } from "../plugin.ts";
2
+ import type { Texts, UiLanguage } from "../ui.ts";
2
3
 
3
4
  export type FileOutcome = { readonly path: string; readonly findings: readonly Finding[]; readonly notRun: number };
4
5
 
5
6
  const count = (findings: readonly Finding[], severity: string): number => findings.filter((finding) => finding.severity === severity).length;
6
7
 
7
- export const tally = (findings: readonly Finding[]): string => {
8
- const parts = [
9
- count(findings, "error") > 0 ? `エラー ${count(findings, "error")} 件` : undefined,
10
- count(findings, "warning") > 0 ? `注意 ${count(findings, "warning")} 件` : undefined,
11
- count(findings, "info") > 0 ? `参考 ${count(findings, "info")} 件` : undefined,
12
- ].filter((part) => part !== undefined);
13
- return parts.length === 0 ? "指摘はありません" : parts.join("、");
8
+ type Severity = "error" | "warning" | "info";
9
+
10
+ const TEXT: Texts<{
11
+ readonly counts: Readonly<Record<Severity, (n: number) => string>>;
12
+ readonly join: string;
13
+ readonly none: string;
14
+ readonly files: (seen: number, touched: number) => string;
15
+ }> = {
16
+ ja: {
17
+ counts: { error: (n) => `エラー ${n} 件`, warning: (n) => `注意 ${n} 件`, info: (n) => `参考 ${n} 件` },
18
+ join: "、",
19
+ none: "指摘はありません",
20
+ files: (seen, touched) => `${seen} ファイルを見て、${touched} ファイルに指摘がありました`,
21
+ },
22
+ en: {
23
+ counts: {
24
+ error: (n) => `${n} error${n === 1 ? "" : "s"}`,
25
+ warning: (n) => `${n} warning${n === 1 ? "" : "s"}`,
26
+ info: (n) => `${n} note${n === 1 ? "" : "s"}`,
27
+ },
28
+ join: ", ",
29
+ none: "No findings",
30
+ files: (seen, touched) => `${seen} files checked, ${touched} with findings`,
31
+ },
32
+ };
33
+
34
+ const SEVERITIES: readonly Severity[] = ["error", "warning", "info"];
35
+
36
+ export const tally = (findings: readonly Finding[], ui: UiLanguage): string => {
37
+ const parts = SEVERITIES.filter((severity) => count(findings, severity) > 0).map((severity) => TEXT[ui].counts[severity](count(findings, severity)));
38
+ return parts.length === 0 ? TEXT[ui].none : parts.join(TEXT[ui].join);
14
39
  };
15
40
 
16
41
  /** 複数ファイルを見たときの締め。1 ファイルのときは出さない。 */
17
- export const renderSummary = (outcomes: readonly FileOutcome[]): string[] => {
42
+ export const renderSummary = (outcomes: readonly FileOutcome[], ui: UiLanguage): string[] => {
18
43
  if (outcomes.length < 2) return [];
19
44
  const all = outcomes.flatMap((outcome) => outcome.findings);
20
45
  const touched = outcomes.filter((outcome) => outcome.findings.length > 0).length;
21
- return ["", "═".repeat(60), "", ` ${outcomes.length} ファイルを見て、${touched} ファイルに指摘がありました`, ` ${tally(all)}`, ""];
46
+ return ["", "═".repeat(60), "", ` ${TEXT[ui].files(outcomes.length, touched)}`, ` ${tally(all, ui)}`, ""];
22
47
  };
@@ -1,4 +1,37 @@
1
1
  import type { Suppressed, Suppression } from "../stet.ts";
2
+ import type { Texts, UiLanguage } from "../ui.ts";
3
+
4
+ const TEXT: Texts<{
5
+ readonly nudge: string;
6
+ readonly more: (n: number) => string;
7
+ readonly count: (n: number) => string;
8
+ readonly reasons: (list: string) => string;
9
+ readonly relaxAll: (rule: string) => string;
10
+ readonly none: string;
11
+ readonly total: (n: number) => string;
12
+ readonly reasonless: (n: number) => string;
13
+ }> = {
14
+ ja: {
15
+ nudge: " ← 設定の見直しを検討してください",
16
+ more: (n) => ` ほか ${n} ファイル`,
17
+ count: (n) => `${String(n).padStart(3)} 件`,
18
+ reasons: (list) => `理由: ${list}`,
19
+ relaxAll: (rule) => `ルールごとゆるめる: npx chaff relax ${rule} --why "..."`,
20
+ none: "\n 抑制されている指摘はありません。\n",
21
+ total: (n) => `抑制されている指摘: ${n} 件`,
22
+ reasonless: (n) => `理由が書かれていない抑制: ${n} 件`,
23
+ },
24
+ en: {
25
+ nudge: " <- consider changing the setting instead",
26
+ more: (n) => ` and ${n} more file${n === 1 ? "" : "s"}`,
27
+ count: (n) => `${String(n).padStart(3)}`,
28
+ reasons: (list) => `reasons: ${list}`,
29
+ relaxAll: (rule) => `relax the whole rule: npx chaff relax ${rule} --why "..."`,
30
+ none: "\n No findings are silenced.\n",
31
+ total: (n) => `Silenced findings: ${n}`,
32
+ reasonless: (n) => `Silenced without a reason: ${n}`,
33
+ },
34
+ };
2
35
 
3
36
  export type PerFile = { readonly path: string; readonly suppressed: readonly Suppressed[]; readonly reasonless: readonly Suppression[] };
4
37
 
@@ -23,16 +56,17 @@ const groupByRule = (files: readonly PerFile[]): Group[] => {
23
56
  .sort((left, right) => right.count - left.count);
24
57
  };
25
58
 
26
- const groupLines = (group: Group): string[] => {
27
- const nudge = group.count >= NUDGE_AT ? " ← 設定の見直しを検討してください" : "";
59
+ const groupLines = (group: Group, ui: UiLanguage): string[] => {
60
+ const text = TEXT[ui];
61
+ const nudge = group.count >= NUDGE_AT ? text.nudge : "";
28
62
  const shown = group.files.slice(0, 3).join(", ");
29
- const rest = group.files.length > 3 ? ` ほか ${group.files.length - 3} ファイル` : "";
63
+ const rest = group.files.length > 3 ? text.more(group.files.length - 3) : "";
30
64
  return [
31
65
  "",
32
- ` ${group.rule.padEnd(26)}${String(group.count).padStart(3)} 件${nudge}`,
66
+ ` ${group.rule.padEnd(26)}${text.count(group.count)}${nudge}`,
33
67
  ` ${shown}${rest}`,
34
68
  ...(group.count >= NUDGE_AT && group.reasons.length > 0
35
- ? [` 理由: ${group.reasons.slice(0, 2).join(" / ")}`, ` ルールごとゆるめる: npx chaff relax ${group.rule} --why "..."`]
69
+ ? [` ${text.reasons(group.reasons.slice(0, 2).join(" / "))}`, ` ${text.relaxAll(group.rule)}`]
36
70
  : []),
37
71
  ];
38
72
  };
@@ -42,16 +76,17 @@ const groupLines = (group: Group): string[] => {
42
76
  * 同じ rule を何度も黙らせているなら、それは (b) ではなく (c) のサイン。
43
77
  * workflow spec §7.3。
44
78
  */
45
- export const renderSuppressions = (files: readonly PerFile[]): string => {
79
+ export const renderSuppressions = (files: readonly PerFile[], ui: UiLanguage = "ja"): string => {
80
+ const text = TEXT[ui];
46
81
  const groups = groupByRule(files);
47
82
  const total = groups.reduce((sum, group) => sum + group.count, 0);
48
- if (total === 0) return "\n 抑制されている指摘はありません。\n";
83
+ if (total === 0) return text.none;
49
84
  const reasonless = files.flatMap((file) => file.reasonless.map(() => file.path));
50
85
  return [
51
86
  "",
52
- ` 抑制されている指摘: ${total} 件`,
53
- ...groups.flatMap(groupLines),
54
- ...(reasonless.length > 0 ? ["", ` 理由が書かれていない抑制: ${reasonless.length} 件`, ` ${[...new Set(reasonless)].join(", ")}`] : []),
87
+ ` ${text.total(total)}`,
88
+ ...groups.flatMap((group) => groupLines(group, ui)),
89
+ ...(reasonless.length > 0 ? ["", ` ${text.reasonless(reasonless.length)}`, ` ${[...new Set(reasonless)].join(", ")}`] : []),
55
90
  "",
56
91
  ].join("\n");
57
92
  };
package/src/run.ts CHANGED
@@ -2,6 +2,8 @@ import { DETECTORS } from "./detectors/index.ts";
2
2
  import { resolve } from "./levels.ts";
3
3
  import type { AdapterNeeds, Finding, Level, ProseDocument, RuleDefinition } from "./plugin.ts";
4
4
  import { lineStarts, placeOf } from "./position.ts";
5
+ import { REASONS, type Reasons } from "./reasons.ts";
6
+ import { uiLanguageOf } from "./ui.ts";
5
7
 
6
8
  export type Skipped = { readonly rule: string; readonly why: string };
7
9
 
@@ -22,7 +24,7 @@ const levelFor = (rule: RuleDefinition, settings: Settings, experimental: boolea
22
24
  return "normal";
23
25
  };
24
26
 
25
- const CAPABILITY_NAME: Readonly<Record<string, string>> = { pos: "品詞解析", lemma: "原形" };
27
+ const reasonsFor = (doc: ProseDocument): Reasons => REASONS[uiLanguageOf(doc.language)];
26
28
 
27
29
  /** 知らない要求は満たされていないものとして扱う。黙って無視すると、要求なしで動いてしまう。 */
28
30
  const has = (capabilities: ProseDocument["capabilities"], need: string): boolean => {
@@ -42,13 +44,19 @@ const hasTokens = (doc: ProseDocument): boolean => doc.sentences.length === 0 ||
42
44
 
43
45
  /** 要求を満たさない rule は動かせない。満たさないまま動かすと「指摘 0 件」が保証に見える。 */
44
46
  const unmet = (rule: RuleDefinition, doc: ProseDocument): string | undefined => {
45
- if (rule.languages !== undefined && !rule.languages.includes(doc.language)) return `${doc.language} 向けの rule ではないため`;
47
+ if (rule.languages !== undefined && !rule.languages.includes(doc.language)) return reasonsFor(doc).otherLanguage(doc.language);
46
48
  const missing = rule.requires.filter((need) => need !== "structure").find((need) => !has(doc.capabilities, need));
47
- if (missing !== undefined) return `この言語では${CAPABILITY_NAME[missing] ?? missing}が使えないため`;
48
- if (rule.requires.some((need) => need === "pos" || need === "lemma") && !hasTokens(doc)) return "アダプタが品詞を返さなかったため";
49
+ if (missing !== undefined) return reasonsFor(doc).noCapability(missing);
49
50
  return undefined;
50
51
  };
51
52
 
53
+ /**
54
+ * 品詞は、動かす rule があるときだけ用意する(neededBy)。だから「品詞が無い」は、段階を見た後でしか言えない。
55
+ * 先に聞くと、止めている rule まで「アダプタが品詞を返さなかった」と、違う理由で出る。
56
+ */
57
+ const untagged = (rule: RuleDefinition, doc: ProseDocument): string | undefined =>
58
+ rule.requires.some((need) => need === "pos" || need === "lemma") && !hasTokens(doc) ? reasonsFor(doc).noTags : undefined;
59
+
52
60
  const forGenre = (rules: readonly RuleDefinition[], genre: string): RuleDefinition[] =>
53
61
  rules.filter((rule) => rule.use_for.some((target) => genre.startsWith(target)));
54
62
 
@@ -114,24 +122,27 @@ export const runRules = (
114
122
  (acc, rule) => {
115
123
  // L4 は意味を読む検査。chaff test が扱う。ここで「検出器が無い」と言わせない。
116
124
  if (rule.layer === "L4") {
117
- return { findings: acc.findings, skipped: [...acc.skipped, { rule: rule.id, why: "意味を読む検査のため(npx chaff test で動きます)" }] };
125
+ return { findings: acc.findings, skipped: [...acc.skipped, { rule: rule.id, why: reasonsFor(doc).semantic }] };
118
126
  }
119
127
  const blocked = unmet(rule, doc);
120
128
  if (blocked !== undefined) return { findings: acc.findings, skipped: [...acc.skipped, { rule: rule.id, why: blocked }] };
121
129
  const level = levelFor(rule, settings, experimental);
122
130
  if (level === "off") {
123
- const why = rule.status === "experimental" && settings[rule.id] === undefined ? "まだ試験中のため" : "設定で止めているため";
131
+ const why = rule.status === "experimental" && settings[rule.id] === undefined ? reasonsFor(doc).experimental : reasonsFor(doc).turnedOff;
124
132
  return { findings: acc.findings, skipped: [...acc.skipped, { rule: rule.id, why }] };
125
133
  }
134
+ const noTags = untagged(rule, doc);
135
+ if (noTags !== undefined) return { findings: acc.findings, skipped: [...acc.skipped, { rule: rule.id, why: noTags }] };
126
136
  // 木は capability ではなく、adapter が structure を持つかで決まる。持たない言語で動かすと「参照先が無い」が 0 件に見える。
127
137
  // 段階を見た後で聞く。doc.structure は触れたときに木を作るので、止めている rule のために作らない。
128
138
  if (rule.requires.includes("structure") && doc.structure === undefined) {
129
- return { findings: acc.findings, skipped: [...acc.skipped, { rule: rule.id, why: `${doc.language} のパッケージは文書の構造を読めないため` }] };
139
+ return { findings: acc.findings, skipped: [...acc.skipped, { rule: rule.id, why: reasonsFor(doc).noStructure(doc.language) }] };
130
140
  }
131
141
  // 複合シグナルは二段目で扱う。一段目では「検出器が無い」と言わせない。
132
142
  if (rule.from.length > 0) return acc;
133
143
  const detector = DETECTORS[rule.how_to_find];
134
- if (detector === undefined) return { findings: acc.findings, skipped: [...acc.skipped, { rule: rule.id, why: `検出器 ${rule.how_to_find} がないため` }] };
144
+ if (detector === undefined)
145
+ return { findings: acc.findings, skipped: [...acc.skipped, { rule: rule.id, why: reasonsFor(doc).noDetector(rule.how_to_find) }] };
135
146
  const options = {
136
147
  limit: limitFor(rule, level, genre, limits),
137
148
  lexicon: rule.word_list === undefined ? undefined : doc.lexicons[rule.word_list],
@@ -139,7 +150,7 @@ export const runRules = (
139
150
  };
140
151
  // 語彙表を要求する rule で、その言語に語彙表が無ければ動かせない。黙って通さない。
141
152
  if (rule.word_list !== undefined && options.lexicon === undefined) {
142
- return { findings: acc.findings, skipped: [...acc.skipped, { rule: rule.id, why: `${doc.language} の語彙表 ${rule.word_list} が無いため` }] };
153
+ return { findings: acc.findings, skipped: [...acc.skipped, { rule: rule.id, why: reasonsFor(doc).noLexicon(doc.language, rule.word_list) }] };
143
154
  }
144
155
  const found = detector(doc, options).map((finding) => place(starts, { ...finding, rule: rule.id, severity: rule.severity }));
145
156
  return { findings: [...acc.findings, ...found], skipped: acc.skipped };
@@ -15,6 +15,8 @@ type Draft = {
15
15
  readonly attrs: Readonly<Record<string, string | number>>;
16
16
  readonly children: Draft[];
17
17
  readonly ordinal?: number | undefined;
18
+ readonly level?: number | undefined;
19
+ readonly ordinalTo?: number | undefined;
18
20
  };
19
21
 
20
22
  /** 開いている節点。rank の大きいものほど内側。 */
@@ -88,7 +90,12 @@ const openNumbered = (state: State, line: Line, numbered: NumberedLine, headingD
88
90
  const rank = headingDepth ?? NUMBERED_RANK + numbered.depth;
89
91
  // 番地は、自分と同じか内側を閉じてから決める。閉じる前だと、同じ深さの前の条を親と取り違える。
90
92
  while (state.stack.length > 1 && top(state).rank >= rank) state.stack.pop();
91
- const draft = { ...draftOf(numbered.kind, addressOf(state, numbered), line, attrs), ordinal: numbered.ordinal };
93
+ const draft = {
94
+ ...draftOf(numbered.kind, addressOf(state, numbered), line, attrs),
95
+ ordinal: numbered.ordinal,
96
+ level: numbered.depth,
97
+ ordinalTo: numbered.ordinalTo,
98
+ };
92
99
  open(state, { draft, rank, numbered });
93
100
  };
94
101
 
@@ -136,6 +143,8 @@ const freeze = (draft: Draft): StructureNode => ({
136
143
  attrs: draft.attrs,
137
144
  children: draft.children.map(freeze),
138
145
  ...(draft.ordinal === undefined ? {} : { ordinal: draft.ordinal }),
146
+ ...(draft.level === undefined ? {} : { level: draft.level }),
147
+ ...(draft.ordinalTo === undefined ? {} : { ordinalTo: draft.ordinalTo }),
139
148
  });
140
149
 
141
150
  /** Markdown から取った手がかり。見出しと、中を読まない範囲(コード)。.txt はどちらも空。 */
@@ -156,7 +165,6 @@ const headingsByLine = (lines: readonly Line[], headings: readonly Heading[]): M
156
165
  }),
157
166
  );
158
167
 
159
- /** 見出しの行から「#」の印を外した文字列。コードを覆った後の行から取るので、見出しの中のコードも読まない。 */
160
168
  /**
161
169
  * 見出しの行から見出しの言葉を取る。コードを覆った後の行から取るので、見出しの中のコードも読まない。
162
170
  * ATX(行頭が #)なら印と閉じの # を外す。setext(下線で書く見出し)の末尾の # は言葉なので残す。
@@ -100,7 +100,9 @@ export const checkCitations = (source: string, tree: StructureNode, citations: r
100
100
  const whole = normalize(source);
101
101
  const starts = lineStarts(source);
102
102
  return citations.map((citation): CitationResult => {
103
- const candidates = byAddress.get(citation.address) ?? [];
103
+ // 第 1 項に番号の無い書き方では、「12.1」は第12条そのもの。参照の fallback と同じに読む。
104
+ const firstParagraph = citation.address.endsWith(".1") ? byAddress.get(citation.address.slice(0, -2)) : undefined;
105
+ const candidates = byAddress.get(citation.address) ?? firstParagraph ?? [];
104
106
  const first = candidates[0];
105
107
  if (first === undefined) return { citation, status: "missing-address" };
106
108
  const key = normalize(citation.quote).text;
@@ -34,30 +34,49 @@ const resolves = (node: StructureNode, addresses: ReadonlySet<string>): boolean
34
34
 
35
35
  /**
36
36
  * 参照の番地が木に無い。他の文書の名前が付いた参照(民法第709条、Section 9 of the Master Agreement)は引かない。
37
- * 番号付きのまとまりを 1 つも持たない文書も見ない。他の文書を指しているだけかもしれない。
37
+ * 条を 1 つも持たない文書も見ない。他の文書を指しているだけかもしれない。
38
38
  */
39
39
  export const danglingReferences = (tree: StructureNode): StructureIssue[] => {
40
40
  const nodes = inDocumentOrder(tree);
41
+ // 参照は条を指す。条を一つも持たない文書(契約書に付ける承諾書のひな形など)の「契約書第6条」は、別の文書の条。
42
+ if (!nodes.some((node) => node.kind === "article")) return [];
41
43
  const addresses = new Set(nodes.filter((node) => NUMBERED.includes(node.kind)).map((node) => node.address));
42
- if (addresses.size === 0) return [];
43
44
  return nodes
44
45
  .filter((node) => node.kind === "reference" && node.attrs["document"] === undefined)
45
46
  .filter((node) => !resolves(node, addresses))
46
47
  .map((node) => ({ offset: node.span.start, values: { label: textOf(node, "label"), target: textOf(node, "target") } }));
47
48
  };
48
49
 
49
- /** 同じ語の二度目以降の定義。どちらが正しいかは決めず、両方の場所を示す。 */
50
+ type Definition = { readonly node: StructureNode; readonly article: string };
51
+
52
+ /** 定義を文書の順に、それが置かれた条の番地と一緒に並べる。範囲を限った定義は、その条の中でだけ比べるため。 */
53
+ const definitionsInOrder = (tree: StructureNode): Definition[] => {
54
+ const found: Definition[] = [];
55
+ const pending: Definition[] = [{ node: tree, article: "" }];
56
+ while (pending.length > 0) {
57
+ const current = pending.pop();
58
+ if (current === undefined) break;
59
+ if (current.node.kind === "definition") found.push(current);
60
+ const article = current.node.kind === "article" ? current.node.address : current.article;
61
+ [...current.node.children].reverse().forEach((child) => pending.push({ node: child, article }));
62
+ }
63
+ return found;
64
+ };
65
+
66
+ /**
67
+ * 同じ語の二度目以降の定義。どちらが正しいかは決めず、両方の場所を示す。
68
+ * 範囲を限った定義(この条において「X」とは)は、同じ条の中でだけ比べる。別の条で定義し直すのは正しい書き方。
69
+ */
50
70
  export const duplicateDefinitions = (tree: StructureNode): StructureIssue[] => {
51
71
  const first = new Map<string, StructureNode>();
52
- return inDocumentOrder(tree)
53
- .filter((node) => node.kind === "definition")
54
- .flatMap((node) => {
55
- const term = textOf(node, "term");
56
- const earlier = first.get(term);
57
- if (earlier === undefined) {
58
- first.set(term, node);
59
- return [];
60
- }
61
- return [{ offset: node.span.start, values: { term, first: earlier.line } }];
62
- });
72
+ return definitionsInOrder(tree).flatMap(({ node, article }) => {
73
+ const term = textOf(node, "term");
74
+ const key = node.attrs["scope"] === "local" ? `${article}\u0000${term}` : term;
75
+ const earlier = first.get(key);
76
+ if (earlier === undefined) {
77
+ first.set(key, node);
78
+ return [];
79
+ }
80
+ return [{ offset: node.span.start, values: { term, first: earlier.line } }];
81
+ });
63
82
  };
@@ -3,17 +3,21 @@ import { inDocumentOrder, type StructureIssue } from "./issues.ts";
3
3
 
4
4
  const labelOf = (node: StructureNode): string => String(node.attrs["label"] ?? node.address);
5
5
 
6
- type Ordered = { readonly node: StructureNode; readonly ordinal: number };
6
+ type Ordered = { readonly node: StructureNode; readonly ordinal: number; readonly last: number };
7
7
 
8
- /** 同じ親の子のうち、番号の付いたものを種類ごとに分ける。条と項は別の並び。 */
8
+ /**
9
+ * 同じ親の子のうち、番号の付いたものを種類と深さごとに分ける。条と項は別の並び、項と号も別の並び。
10
+ * 法令の第 1 項は番号が無いので、その号(一、二)と第 2 項(2)は同じ条の直下に並ぶ。深さで分けないと「二の次が2」に見える。
11
+ */
9
12
  const sequencesOf = (parent: StructureNode): Ordered[][] => {
10
13
  const groups = new Map<string, Ordered[]>();
11
14
  parent.children.forEach((node) => {
12
15
  if (node.ordinal === undefined) return;
16
+ const key = `${node.kind}/${String(node.level ?? "")}`;
13
17
  // 配列を作り直さずに足す。条が何千もある法令で二乗にしない。
14
- const group = groups.get(node.kind) ?? [];
15
- group.push({ node, ordinal: node.ordinal });
16
- groups.set(node.kind, group);
18
+ const group = groups.get(key) ?? [];
19
+ group.push({ node, ordinal: node.ordinal, last: node.ordinalTo ?? node.ordinal });
20
+ groups.set(key, group);
17
21
  });
18
22
  return [...groups.values()];
19
23
  };
@@ -23,11 +27,12 @@ const breaksIn = (sequence: readonly Ordered[]): StructureIssue[] =>
23
27
  sequence.flatMap((current, index) => {
24
28
  const previous = sequence[index - 1];
25
29
  // 1 に戻ったら新しい並び。一つの条に (a)(b) の箇条書きが二つある、附則が第1条から振り直す、のどちらも誤りではない。
26
- if (previous === undefined || current.ordinal === previous.ordinal + 1 || current.ordinal === 1) return [];
30
+ // 範囲をまとめた行(第四十三条から第五十五条まで)の次は、範囲の最後から数える。
31
+ if (previous === undefined || current.ordinal === previous.last + 1 || current.ordinal === 1) return [];
27
32
  return [
28
33
  {
29
34
  offset: current.node.span.start,
30
- values: { previous: labelOf(previous.node), label: labelOf(current.node), expected: previous.ordinal + 1, found: current.ordinal },
35
+ values: { previous: labelOf(previous.node), label: labelOf(current.node), expected: previous.last + 1, found: current.ordinal },
31
36
  },
32
37
  ];
33
38
  });