chaffjs 0.4.0 → 0.5.1

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 (136) hide show
  1. package/dist/adapter-load.d.ts +5 -0
  2. package/dist/adapter-load.d.ts.map +1 -1
  3. package/dist/adapter-load.js +39 -3
  4. package/dist/adapter-load.js.map +1 -1
  5. package/dist/backends/anthropic.d.ts +2 -2
  6. package/dist/backends/anthropic.d.ts.map +1 -1
  7. package/dist/backends/anthropic.js +1 -1
  8. package/dist/backends/anthropic.js.map +1 -1
  9. package/dist/backends/openai.d.ts +2 -1
  10. package/dist/backends/openai.d.ts.map +1 -1
  11. package/dist/backends/types.d.ts +0 -1
  12. package/dist/backends/types.d.ts.map +1 -1
  13. package/dist/backends/types.js +1 -1
  14. package/dist/backends/types.js.map +1 -1
  15. package/dist/cli.d.ts.map +1 -1
  16. package/dist/cli.js +22 -2
  17. package/dist/cli.js.map +1 -1
  18. package/dist/config/load.d.ts +0 -3
  19. package/dist/config/load.d.ts.map +1 -1
  20. package/dist/config/load.js +1 -1
  21. package/dist/config/load.js.map +1 -1
  22. package/dist/detectors/en-shape.d.ts.map +1 -1
  23. package/dist/detectors/en-shape.js +4 -2
  24. package/dist/detectors/en-shape.js.map +1 -1
  25. package/dist/detectors/heading-echo.d.ts.map +1 -1
  26. package/dist/detectors/heading-echo.js +25 -2
  27. package/dist/detectors/heading-echo.js.map +1 -1
  28. package/dist/detectors/index.d.ts.map +1 -1
  29. package/dist/detectors/index.js +4 -2
  30. package/dist/detectors/index.js.map +1 -1
  31. package/dist/detectors/lexicon.d.ts +1 -0
  32. package/dist/detectors/lexicon.d.ts.map +1 -1
  33. package/dist/detectors/lexicon.js +41 -0
  34. package/dist/detectors/lexicon.js.map +1 -1
  35. package/dist/detectors/signals.d.ts +1 -0
  36. package/dist/detectors/signals.d.ts.map +1 -1
  37. package/dist/detectors/signals.js +47 -10
  38. package/dist/detectors/signals.js.map +1 -1
  39. package/dist/document.d.ts.map +1 -1
  40. package/dist/document.js +8 -1
  41. package/dist/document.js.map +1 -1
  42. package/dist/eval.d.ts.map +1 -1
  43. package/dist/eval.js +15 -1
  44. package/dist/eval.js.map +1 -1
  45. package/dist/files.d.ts +0 -10
  46. package/dist/files.d.ts.map +1 -1
  47. package/dist/files.js +1 -1
  48. package/dist/files.js.map +1 -1
  49. package/dist/levels.d.ts +0 -1
  50. package/dist/levels.d.ts.map +1 -1
  51. package/dist/levels.js +1 -1
  52. package/dist/levels.js.map +1 -1
  53. package/dist/mask.d.ts.map +1 -1
  54. package/dist/mask.js +8 -1
  55. package/dist/mask.js.map +1 -1
  56. package/dist/measure.d.ts +0 -1
  57. package/dist/measure.d.ts.map +1 -1
  58. package/dist/measure.js +1 -1
  59. package/dist/measure.js.map +1 -1
  60. package/dist/plugin.d.ts +7 -0
  61. package/dist/plugin.d.ts.map +1 -1
  62. package/dist/position.d.ts +6 -1
  63. package/dist/position.d.ts.map +1 -1
  64. package/dist/position.js +12 -2
  65. package/dist/position.js.map +1 -1
  66. package/dist/render/rules-json.d.ts +1 -2
  67. package/dist/render/rules-json.d.ts.map +1 -1
  68. package/dist/render/rules-json.js +0 -1
  69. package/dist/render/rules-json.js.map +1 -1
  70. package/dist/render/sarif.d.ts +10 -0
  71. package/dist/render/sarif.d.ts.map +1 -0
  72. package/dist/render/sarif.js +65 -0
  73. package/dist/render/sarif.js.map +1 -0
  74. package/dist/render/semantic.d.ts +0 -1
  75. package/dist/render/semantic.d.ts.map +1 -1
  76. package/dist/render/semantic.js +1 -1
  77. package/dist/render/semantic.js.map +1 -1
  78. package/dist/render/text.d.ts +0 -1
  79. package/dist/render/text.d.ts.map +1 -1
  80. package/dist/render/text.js +1 -1
  81. package/dist/render/text.js.map +1 -1
  82. package/dist/rule-load.d.ts.map +1 -1
  83. package/dist/rule-load.js +15 -2
  84. package/dist/rule-load.js.map +1 -1
  85. package/dist/run-semantic.d.ts.map +1 -1
  86. package/dist/run-semantic.js +15 -4
  87. package/dist/run-semantic.js.map +1 -1
  88. package/dist/run.d.ts.map +1 -1
  89. package/dist/run.js +44 -4
  90. package/dist/run.js.map +1 -1
  91. package/dist/sentence-shape.d.ts.map +1 -1
  92. package/dist/sentence-shape.js +18 -1
  93. package/dist/sentence-shape.js.map +1 -1
  94. package/dist/version.d.ts +2 -0
  95. package/dist/version.d.ts.map +1 -0
  96. package/dist/version.js +11 -0
  97. package/dist/version.js.map +1 -0
  98. package/package.json +3 -3
  99. package/rules/ai-generated-composite.yaml +36 -0
  100. package/rules/concrete-evidence-density.yaml +3 -3
  101. package/rules/contraction-consistency.yaml +28 -0
  102. package/rules/max-paragraph-length.yaml +1 -1
  103. package/rules/ngram-repetition.yaml +1 -1
  104. package/rules/no-doubled-joshi.yaml +1 -1
  105. package/rules/no-em-dash.yaml +32 -0
  106. package/rules/no-nakaguro-parallel.yaml +1 -1
  107. package/rules/preamble-length.yaml +1 -1
  108. package/rules/taigen-dome-in-prose.yaml +1 -1
  109. package/src/adapter-load.ts +40 -2
  110. package/src/backends/anthropic.ts +2 -2
  111. package/src/backends/openai.ts +1 -1
  112. package/src/backends/types.ts +1 -1
  113. package/src/cli.ts +33 -3
  114. package/src/config/load.ts +1 -1
  115. package/src/detectors/en-shape.ts +4 -2
  116. package/src/detectors/heading-echo.ts +29 -3
  117. package/src/detectors/index.ts +4 -2
  118. package/src/detectors/lexicon.ts +41 -0
  119. package/src/detectors/signals.ts +48 -10
  120. package/src/document.ts +11 -1
  121. package/src/eval.ts +13 -1
  122. package/src/files.ts +1 -1
  123. package/src/levels.ts +1 -1
  124. package/src/mask.ts +8 -1
  125. package/src/measure.ts +1 -1
  126. package/src/plugin.ts +7 -1
  127. package/src/position.ts +11 -2
  128. package/src/render/rules-json.ts +1 -3
  129. package/src/render/sarif.ts +103 -0
  130. package/src/render/semantic.ts +1 -1
  131. package/src/render/text.ts +1 -1
  132. package/src/rule-load.ts +14 -2
  133. package/src/run-semantic.ts +14 -4
  134. package/src/run.ts +42 -3
  135. package/src/sentence-shape.ts +19 -1
  136. package/src/version.ts +14 -0
package/src/files.ts CHANGED
@@ -26,7 +26,7 @@ const expand = (target: string): string[] => {
26
26
  * ロケールを明示する。既定のロケールに任せると、同じ入力でも機械によって順序が変わる。
27
27
  * 指摘の並びは CI のログにも baseline にも入るので、順序が揺れてはいけない。
28
28
  */
29
- export const byPath = (left: string, right: string): number => left.localeCompare(right, "en");
29
+ const byPath = (left: string, right: string): number => left.localeCompare(right, "en");
30
30
 
31
31
  export const collectTargets = (targets: readonly string[]): string[] => {
32
32
  const found = targets.flatMap(expand).filter((path) => !isSkipped(path));
package/src/levels.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { Level, LevelTable, RuleDefinition } from "./plugin.ts";
2
2
 
3
- export const LEVELS: readonly Level[] = ["strict", "normal", "relaxed", "off"];
3
+ const LEVELS: readonly Level[] = ["strict", "normal", "relaxed", "off"];
4
4
 
5
5
  export const isLevel = (value: unknown): value is Level => typeof value === "string" && LEVELS.some((level) => level === value);
6
6
 
package/src/mask.ts CHANGED
@@ -7,7 +7,14 @@ import type { Span } from "./plugin.ts";
7
7
  * 切り出して繋ぐのではなく覆うのは、オフセットを元文字列と一致させ続けるため。
8
8
  * 行・列の計算も、引用して見せる範囲も、これで元の位置のまま使える。
9
9
  */
10
- const blankOut = (text: string): string => text.replace(/[^\n]/gu, " ");
10
+ /**
11
+ * 空白は**元の文字と同じ UTF-16 長**にする。
12
+ *
13
+ * `u` 付きの正規表現は 1 文字(コードポイント)ずつ当たるので、絵文字のような
14
+ * サロゲートペアを空白 1 つに置き換えると文字列が 1 だけ縮む。mdast のオフセットは
15
+ * UTF-16 単位なので、そこから先の指摘がすべて 1 ずれる。
16
+ */
17
+ const blankOut = (text: string): string => text.replace(/[^\n]/gu, (char) => " ".repeat(char.length));
11
18
 
12
19
  const merge = (spans: readonly Span[]): Span[] =>
13
20
  [...spans]
package/src/measure.ts CHANGED
@@ -13,7 +13,7 @@ export const proseText = (sentence: Sentence): string => sentence.text.replace(/
13
13
 
14
14
  export const charLength = (sentence: Sentence): number => sentence.text.replace(/\s+/gu, "").length;
15
15
 
16
- export const wordLength = (sentence: Sentence): number =>
16
+ const wordLength = (sentence: Sentence): number =>
17
17
  proseText(sentence)
18
18
  .split(" ")
19
19
  .filter((word) => word.length > 0).length;
package/src/plugin.ts CHANGED
@@ -32,7 +32,11 @@ export type Segmentation = {
32
32
  export type LengthUnit = "char" | "word";
33
33
 
34
34
  /** L2 の語彙表。detector は共通で、これだけが言語別。spec §11。 */
35
- export type LexiconEntry = { readonly pattern: string; readonly weight?: number | undefined };
35
+ /**
36
+ * `instead_of` は「同じことを言う別の書きかた」。文体の一貫性を見る rule が使う。
37
+ * 2 つの書きかたのどちらが正しいかは決めず、**1 つの文書で混ざっていないか**だけを見る。
38
+ */
39
+ export type LexiconEntry = { readonly pattern: string; readonly weight?: number | undefined; readonly instead_of?: string | undefined };
36
40
 
37
41
  export type Lexicon = readonly LexiconEntry[];
38
42
 
@@ -163,6 +167,8 @@ export type RuleDefinition = {
163
167
  readonly where: string | undefined;
164
168
  /** adapter に要る capability。"pos" / "lemma"。満たさなければ動かさない。spec §16。 */
165
169
  readonly requires: readonly string[];
170
+ /** 複合シグナル。ここに並べた rule のうち何本が出たかを見る。spec §20.2。 */
171
+ readonly from: readonly string[];
166
172
  /** 動かす言語。未指定は全言語。「ですます調」のように言語に固有の rule が使う。 */
167
173
  readonly languages: readonly string[] | undefined;
168
174
  readonly use_for: readonly string[];
package/src/position.ts CHANGED
@@ -1,7 +1,16 @@
1
1
  export type Place = { readonly line: number; readonly column: number };
2
2
 
3
- /** 行頭のオフセット表。1 文書につき 1 度だけ作る。 */
4
- export const lineStarts = (source: string): number[] => [...source].reduce<number[]>((acc, char, index) => (char === "\n" ? [...acc, index + 1] : acc), [0]);
3
+ /**
4
+ * 行頭のオフセット表。1 文書につき 1 度だけ作る。
5
+ *
6
+ * 数えるのは **UTF-16 単位**。`[...source]` はコードポイントで数えるので、
7
+ * 絵文字より後ろの行がすべて 1 ずれる。指摘が持つオフセットは UTF-16 なので、そちらに合わせる。
8
+ */
9
+ export const lineStarts = (source: string): number[] => {
10
+ const starts = [0];
11
+ for (let at = source.indexOf("\n"); at !== -1; at = source.indexOf("\n", at + 1)) starts.push(at + 1);
12
+ return starts;
13
+ };
5
14
 
6
15
  export const placeOf = (starts: readonly number[], offset: number): Place => {
7
16
  const index = starts.reduce((best, start, at) => (start <= offset ? at : best), 0);
@@ -1,6 +1,6 @@
1
1
  import { definedLevels, resolve } from "../levels.ts";
2
2
  import type { Config } from "../config/load.ts";
3
- import type { Level, RuleDefinition } from "../plugin.ts";
3
+ import type { RuleDefinition } from "../plugin.ts";
4
4
 
5
5
  const now = (rule: RuleDefinition, config: Config, genre: string): Record<string, unknown> => {
6
6
  const explicit = config.rules[rule.id];
@@ -53,5 +53,3 @@ export const rulesJson = (rules: readonly RuleDefinition[], config: Config, lang
53
53
  null,
54
54
  2,
55
55
  );
56
-
57
- export const levelsForCli = (): readonly Level[] => ["strict", "normal", "relaxed", "off"];
@@ -0,0 +1,103 @@
1
+ import { localized, messageOf } from "./text.ts";
2
+ import type { Finding, RuleDefinition, Severity } from "../plugin.ts";
3
+
4
+ /**
5
+ * 指摘を SARIF 2.1.0 で出す。GitHub の code scanning に上げるため。
6
+ *
7
+ * 上げると Security タブに並ぶだけでなく、**PR の変更行に直接出る**。
8
+ * ログの中の指摘は読みに行かないと見えないが、行の上の指摘は書いた本人の目の前にある。
9
+ * chaff は文章を直す人のための道具なので、ここが効く。
10
+ */
11
+ const SCHEMA = "https://json.schemastore.org/sarif-2.1.0.json";
12
+ const INFORMATION_URI = "https://www.npmjs.com/package/chaffjs";
13
+
14
+ /** SARIF に info は無い。上げるときに弾かれるので、note に写す。 */
15
+ type Level = "error" | "warning" | "note";
16
+
17
+ const levelOf = (severity: Severity): Level => (severity === "info" ? "note" : severity);
18
+
19
+ type SarifRule = {
20
+ readonly id: string;
21
+ readonly name: string;
22
+ readonly shortDescription: { readonly text: string };
23
+ readonly fullDescription: { readonly text: string };
24
+ readonly help: { readonly text: string };
25
+ readonly properties: { readonly tags: readonly string[] };
26
+ };
27
+
28
+ type SarifResult = {
29
+ readonly ruleId: string;
30
+ readonly level: Level;
31
+ readonly message: { readonly text: string };
32
+ readonly locations: readonly {
33
+ readonly physicalLocation: {
34
+ readonly artifactLocation: { readonly uri: string };
35
+ readonly region: { readonly startLine: number; readonly startColumn: number };
36
+ };
37
+ }[];
38
+ };
39
+
40
+ /** `chaff/<rule>` と名前空間を切る。同じ PR に上がる他のツールの rule id と衝突させない。 */
41
+ const ruleIdOf = (rule: string): string => `chaff/${rule}`;
42
+
43
+ /** 指摘 1 件と、それを描くのに要るもの。言語はファイルごとに違う。 */
44
+ export type Located = { readonly path: string; readonly finding: Finding; readonly language: string; readonly rules: readonly RuleDefinition[] };
45
+
46
+ const ruleFor = (located: Located): RuleDefinition | undefined => located.rules.find((rule) => rule.id === located.finding.rule);
47
+
48
+ const sarifRule = (rule: RuleDefinition, language: string): SarifRule => ({
49
+ id: ruleIdOf(rule.id),
50
+ name: rule.id,
51
+ shortDescription: { text: localized(rule.name, language) },
52
+ // なぜ直すのかと、どう直すのかを一緒に上げる。指摘だけでは書いた人が動けない。
53
+ fullDescription: { text: localized(rule.why, language) },
54
+ help: { text: localized(rule.how_to_fix, language) },
55
+ properties: { tags: [rule.layer, ...rule.use_for] },
56
+ });
57
+
58
+ const resultOf = (located: Located): SarifResult => {
59
+ const rule = ruleFor(located);
60
+ return {
61
+ ruleId: ruleIdOf(located.finding.rule),
62
+ level: levelOf(located.finding.severity),
63
+ message: { text: rule === undefined ? located.finding.rule : messageOf(rule, located.finding, located.language) },
64
+ locations: [
65
+ {
66
+ physicalLocation: {
67
+ artifactLocation: { uri: located.path },
68
+ // SARIF の行と桁は 1 始まり。0 を上げると弾かれる。
69
+ region: { startLine: Math.max(1, located.finding.line), startColumn: Math.max(1, located.finding.column) },
70
+ },
71
+ },
72
+ ],
73
+ };
74
+ };
75
+
76
+ /** 同じ rule が 2 言語で出たら、先に出たほうの言語で 1 度だけ書く。SARIF は rule を 1 つしか持てない。 */
77
+ const usedRules = (located: readonly Located[]): SarifRule[] => {
78
+ const seen = new Map<string, Located>();
79
+ located.forEach((entry) => {
80
+ if (!seen.has(entry.finding.rule)) seen.set(entry.finding.rule, entry);
81
+ });
82
+ return [...seen.values()].flatMap((entry) => {
83
+ const rule = ruleFor(entry);
84
+ return rule === undefined ? [] : [sarifRule(rule, entry.language)];
85
+ });
86
+ };
87
+
88
+ export const renderSarif = (located: readonly Located[], version: string): string => {
89
+ return `${JSON.stringify(
90
+ {
91
+ $schema: SCHEMA,
92
+ version: "2.1.0",
93
+ runs: [
94
+ {
95
+ tool: { driver: { name: "chaff", version, informationUri: INFORMATION_URI, rules: usedRules(located) } },
96
+ results: located.map(resultOf),
97
+ },
98
+ ],
99
+ },
100
+ null,
101
+ 2,
102
+ )}\n`;
103
+ };
@@ -22,7 +22,7 @@ const narrowingLines = (result: SemanticResult): string[] =>
22
22
  return [` ${name}: ${what} を含む ${narrowing.kept} 文だけを読みました(全 ${narrowing.total} 文)`];
23
23
  });
24
24
 
25
- export const aiBanner = (result: SemanticResult): string[] => [
25
+ const aiBanner = (result: SemanticResult): string[] => [
26
26
  "",
27
27
  `═══ AI による判定 ${"═".repeat(RULE - 17)}`,
28
28
  " 文章の意味を読んでいます。実行するたび結果が変わることが",
@@ -2,7 +2,7 @@ import type { Finding, Localized, RuleDefinition } from "../plugin.ts";
2
2
 
3
3
  export const localized = (field: Localized, language: string): string => field[language] ?? field["en"] ?? Object.values(field)[0] ?? "";
4
4
 
5
- export const fill = (template: string, values: Readonly<Record<string, string | number>>): string =>
5
+ const fill = (template: string, values: Readonly<Record<string, string | number>>): string =>
6
6
  template.replace(/\{(\w+)\}/gu, (whole, key: string) => {
7
7
  const value = values[key];
8
8
  return value === undefined ? whole : String(value);
package/src/rule-load.ts CHANGED
@@ -63,11 +63,22 @@ const missingFields = (raw: Record<string, unknown>, levels: LevelTable | undefi
63
63
  ...(levels?.normal === undefined ? ["levels.normal"] : []),
64
64
  ...(typeof raw["how_to_find"] === "string" ? [] : ["how_to_find"]),
65
65
  ...(Array.isArray(raw["use_for"]) ? [] : ["use_for"]),
66
- ...(isSeverity(raw["severity"]) ? [] : ["severity"]),
66
+ ...(isSeverity(raw["severity"]) || isRecord(raw["severity"]) ? [] : ["severity"]),
67
67
  ];
68
68
 
69
69
  const localizedOf = (value: unknown): Record<string, string> => (isLocalized(value) ? value : {});
70
70
 
71
+ /**
72
+ * severity も言語別に書ける。levels と同じ畳みかた。
73
+ * 記号の許容度は言語で大きく違う(ダッシュは英語では普通、日本語の組版では扱いが難しい)。
74
+ */
75
+ const severityOf = (raw: unknown, language: string): Severity => {
76
+ if (isSeverity(raw)) return raw;
77
+ if (!isRecord(raw)) return "warning";
78
+ const picked: unknown = raw[language] ?? raw["default"];
79
+ return isSeverity(picked) ? picked : "warning";
80
+ };
81
+
71
82
  const stringList = (value: unknown): string[] | undefined => (Array.isArray(value) ? value.map((entry) => String(entry)) : undefined);
72
83
 
73
84
  const toRule = (raw: unknown, language: string, file: string): RuleDefinition => {
@@ -91,9 +102,10 @@ const toRule = (raw: unknown, language: string, file: string): RuleDefinition =>
91
102
  what_to_check: isLocalized(raw["what_to_check"]) ? raw["what_to_check"] : undefined,
92
103
  where: typeof raw["where"] === "string" ? raw["where"] : undefined,
93
104
  requires: stringList(raw["requires"]) ?? [],
105
+ from: stringList(raw["from"]) ?? [],
94
106
  languages: stringList(raw["languages"]),
95
107
  use_for: Array.isArray(raw["use_for"]) ? raw["use_for"].map((entry) => String(entry)) : [],
96
- severity: isSeverity(raw["severity"]) ? raw["severity"] : "warning",
108
+ severity: severityOf(raw["severity"], language),
97
109
  };
98
110
  };
99
111
 
@@ -25,7 +25,11 @@ export type Job = {
25
25
  readonly narrowing?: Narrowing;
26
26
  };
27
27
 
28
- const filterFor = (name: string | undefined): (typeof FILTERS)[string] => FILTERS[name ?? "whole-document"] ?? FILTERS["whole-document"] ?? (() => []);
28
+ /**
29
+ * 知らない絞り込みを文書全体に落とさない。spec §14 は「絞り込みを持たない L4 rule は
30
+ * 登録できない」と決めている。落とすと、**書いていない範囲が黙って LLM に送られる**。
31
+ */
32
+ const filterFor = (name: string | undefined): (typeof FILTERS)[string] | undefined => FILTERS[name ?? "whole-document"];
29
33
 
30
34
  const SEVERITY_BY_VALUE: readonly Severity[] = ["info", "info", "warning", "error"];
31
35
 
@@ -41,6 +45,9 @@ const builtInJobs = (doc: ProseDocument, rules: readonly RuleDefinition[], setti
41
45
  .flatMap((rule) => {
42
46
  const rubric = rule.what_to_check === undefined ? undefined : localized(rule.what_to_check, doc.language);
43
47
  if (rubric === undefined || rubric.length === 0) return [];
48
+ // 絞り込みが無い rule は動かさない。黙って落とさず、下の skipped に出す。
49
+ const filter = filterFor(rule.how_to_find);
50
+ if (filter === undefined) return [];
44
51
  return [
45
52
  {
46
53
  rule: rule.id,
@@ -48,7 +55,7 @@ const builtInJobs = (doc: ProseDocument, rules: readonly RuleDefinition[], setti
48
55
  rubric,
49
56
  howToFix: localized(rule.how_to_fix, doc.language),
50
57
  severity: severityFromLevel(rule, settings[rule.id] ?? "normal"),
51
- candidates: filterFor(rule.how_to_find)(doc),
58
+ candidates: filter(doc),
52
59
  },
53
60
  ];
54
61
  });
@@ -58,7 +65,7 @@ const userJobs = (doc: ProseDocument, checks: readonly UserCheck[], genre: strin
58
65
  .filter((check) => check.level !== "off" && check.use_for.some((target) => genre.startsWith(target)))
59
66
  .map((check) => {
60
67
  // look_at の括弧の中を取り出して絞り込む。取り出せなければ文書全体。spec §14。
61
- const { candidates, narrowing } = narrow(doc, check.look_at, filterFor("whole-document"));
68
+ const { candidates, narrowing } = narrow(doc, check.look_at, FILTERS["whole-document"] ?? (() => []));
62
69
  return {
63
70
  rule: check.id,
64
71
  name: check.name,
@@ -108,7 +115,10 @@ export const runSemantic = async (
108
115
  ): Promise<SemanticResult> => {
109
116
  const jobs = planSemantic(doc, rules, checks, settings, genre);
110
117
  const starts = lineStarts(doc.source);
111
- const skipped = jobs.filter((job) => job.candidates.length === 0).map((job) => ({ rule: job.rule, why: "見るところが無かったため" }));
118
+ const unfiltered = rules
119
+ .filter((rule) => rule.layer === "L4" && rule.use_for.some((target) => genre.startsWith(target)) && filterFor(rule.how_to_find) === undefined)
120
+ .map((rule) => ({ rule: rule.id, why: `絞り込み ${rule.how_to_find} が無いため(文書全体は送りません)` }));
121
+ const skipped = [...jobs.filter((job) => job.candidates.length === 0).map((job) => ({ rule: job.rule, why: "見るところが無かったため" })), ...unfiltered];
112
122
  const live = jobs.filter((job) => job.candidates.length > 0);
113
123
  const answers = await Promise.all(
114
124
  live.flatMap((job) =>
package/src/run.ts CHANGED
@@ -31,12 +31,22 @@ const has = (capabilities: ProseDocument["capabilities"], need: string): boolean
31
31
  return false;
32
32
  };
33
33
 
34
+ /**
35
+ * 宣言だけでなく、実際に token が来ているかも見る。
36
+ *
37
+ * `capabilities.pos: true` と言いながら token を返さないアダプタでも、rule は
38
+ * `sentence.tokens ?? []` を見るので**例外にならず、指摘 0 件で終わる**。
39
+ * 0 件は「問題なし」と見分けがつかない。
40
+ */
41
+ const hasTokens = (doc: ProseDocument): boolean => doc.sentences.length === 0 || doc.sentences.some((sentence) => sentence.tokens !== undefined);
42
+
34
43
  /** 要求を満たさない rule は動かせない。満たさないまま動かすと「指摘 0 件」が保証に見える。 */
35
44
  const unmet = (rule: RuleDefinition, doc: ProseDocument): string | undefined => {
36
45
  if (rule.languages !== undefined && !rule.languages.includes(doc.language)) return `${doc.language} 向けの rule ではないため`;
37
46
  const missing = rule.requires.find((need) => !has(doc.capabilities, need));
38
- if (missing === undefined) return undefined;
39
- return `この言語では${CAPABILITY_NAME[missing] ?? missing}が使えないため`;
47
+ if (missing !== undefined) return `この言語では${CAPABILITY_NAME[missing] ?? missing}が使えないため`;
48
+ if (rule.requires.length > 0 && !hasTokens(doc)) return "アダプタが品詞を返さなかったため";
49
+ return undefined;
40
50
  };
41
51
 
42
52
  const forGenre = (rules: readonly RuleDefinition[], genre: string): RuleDefinition[] =>
@@ -59,6 +69,29 @@ const place = (starts: readonly number[], finding: Finding): Finding => {
59
69
  return { ...finding, line: at.line, column: at.column };
60
70
  };
61
71
 
72
+ /**
73
+ * 複合シグナル。他の rule の結果を読むので、detector の形には収まらない。run の二段目。spec §20.2。
74
+ *
75
+ * 1 本ずつでは何も言えないものが揃ったときだけ、1 件にまとめて出す。
76
+ * 元の指摘は消さない。`padded-intro` のように単独でも正しい指摘が混ざっており、
77
+ * まとめるために消すと本物の指摘が見えなくなる。
78
+ */
79
+ const compositeOf = (rule: RuleDefinition, found: readonly Finding[], limit: number, starts: readonly number[]): Finding[] => {
80
+ const fired = rule.from.filter((id) => found.some((finding) => finding.rule === id));
81
+ if (fired.length < limit) return [];
82
+ const first = found.find((finding) => fired.includes(finding.rule));
83
+ return [
84
+ place(starts, {
85
+ rule: rule.id,
86
+ severity: rule.severity,
87
+ line: 0,
88
+ column: 0,
89
+ quote: first?.quote ?? "",
90
+ values: { word: fired.join("、"), count: fired.length, limit, offset: first === undefined ? 0 : Number(first.values["offset"] ?? 0) },
91
+ }),
92
+ ];
93
+ };
94
+
62
95
  export const runRules = (doc: ProseDocument, rules: readonly RuleDefinition[], settings: Settings, experimental: boolean, genre: string): RunResult => {
63
96
  const starts = lineStarts(doc.source);
64
97
  const applicable = forGenre(rules, genre);
@@ -78,6 +111,8 @@ export const runRules = (doc: ProseDocument, rules: readonly RuleDefinition[], s
78
111
  const why = rule.status === "experimental" && settings[rule.id] === undefined ? "まだ試験中のため" : "設定で止めているため";
79
112
  return { findings: acc.findings, skipped: [...acc.skipped, { rule: rule.id, why }] };
80
113
  }
114
+ // 複合シグナルは二段目で扱う。一段目では「検出器が無い」と言わせない。
115
+ if (rule.from.length > 0) return acc;
81
116
  const detector = DETECTORS[rule.how_to_find];
82
117
  if (detector === undefined) return { findings: acc.findings, skipped: [...acc.skipped, { rule: rule.id, why: `検出器 ${rule.how_to_find} がないため` }] };
83
118
  const options = {
@@ -94,5 +129,9 @@ export const runRules = (doc: ProseDocument, rules: readonly RuleDefinition[], s
94
129
  },
95
130
  { findings: [], skipped: [] },
96
131
  );
97
- return { ...outcome, findings: [...outcome.findings].sort((left, right) => left.line - right.line), forcedExperimental: forced };
132
+ const composites = applicable
133
+ .filter((rule) => rule.from.length > 0 && levelFor(rule, settings, experimental) !== "off")
134
+ .flatMap((rule) => compositeOf(rule, outcome.findings, resolve(rule, levelFor(rule, settings, experimental), genre).limit, starts));
135
+ const all = [...outcome.findings, ...composites];
136
+ return { ...outcome, findings: [...all].sort((left, right) => left.line - right.line), forcedExperimental: forced };
98
137
  };
@@ -19,4 +19,22 @@ export const hasParticle = (sentence: Sentence): boolean => (sentence.tokens ??
19
19
 
20
20
  const SKIP = new Set(["PUNCT", "PART", "SYM"]);
21
21
 
22
- export const lastContent = (sentence: Sentence): Token | undefined => [...(sentence.tokens ?? [])].reverse().find((token) => !SKIP.has(token.pos));
22
+ /**
23
+ * 文末の括弧は、文の終わりではなく添え物。
24
+ *
25
+ * 「これを最優先制約とする(§17)。」の述語は「とする」であって「17」ではない。
26
+ * 仕様書は相互参照を括弧で添えるので、これを数えると文末が全部そこになる。
27
+ */
28
+ const TRAILING = /[((][^((]*[))][\s。.!?!?]*$/u;
29
+
30
+ const beforeTrailing = (sentence: Sentence): readonly Token[] => {
31
+ const tokens = sentence.tokens ?? [];
32
+ const match = TRAILING.exec(sentence.text);
33
+ if (match?.index === undefined) return tokens;
34
+ const cut = sentence.span.start + match.index;
35
+ const kept = tokens.filter((token) => token.span.end <= cut);
36
+ // 括弧を外したら何も残らない文は、括弧そのものが中身。そのまま見る。
37
+ return kept.length === 0 ? tokens : kept;
38
+ };
39
+
40
+ export const lastContent = (sentence: Sentence): Token | undefined => [...beforeTrailing(sentence)].reverse().find((token) => !SKIP.has(token.pos));
package/src/version.ts ADDED
@@ -0,0 +1,14 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { fileURLToPath } from "node:url";
3
+ import { dirname, join } from "node:path";
4
+
5
+ /**
6
+ * 版は manifest から読む。定数に書くと、上げ忘れたまま古い版を名乗るものが出る。
7
+ * SARIF の tool.version がそれで、指摘を出していない版に指摘が紐づく。
8
+ */
9
+ const MANIFEST = join(dirname(fileURLToPath(import.meta.url)), "..", "package.json");
10
+
11
+ const versionOf = (raw: unknown): string =>
12
+ typeof raw === "object" && raw !== null && "version" in raw && typeof raw.version === "string" ? raw.version : "0.0.0";
13
+
14
+ export const VERSION = versionOf(JSON.parse(readFileSync(MANIFEST, "utf8")));