chaffjs 0.2.0 → 0.4.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.
- package/dist/backends/anthropic.d.ts +24 -0
- package/dist/backends/anthropic.d.ts.map +1 -0
- package/dist/backends/anthropic.js +44 -0
- package/dist/backends/anthropic.js.map +1 -0
- package/dist/backends/openai.d.ts +29 -0
- package/dist/backends/openai.d.ts.map +1 -0
- package/dist/backends/openai.js +33 -0
- package/dist/backends/openai.js.map +1 -0
- package/dist/backends/types.d.ts +32 -0
- package/dist/backends/types.d.ts.map +1 -0
- package/dist/backends/types.js +18 -0
- package/dist/backends/types.js.map +1 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +9 -5
- package/dist/cli.js.map +1 -1
- package/dist/commands/eval.d.ts.map +1 -1
- package/dist/commands/eval.js +5 -2
- package/dist/commands/eval.js.map +1 -1
- package/dist/commands/test.d.ts.map +1 -1
- package/dist/commands/test.js +62 -16
- package/dist/commands/test.js.map +1 -1
- package/dist/config/load.d.ts +8 -1
- package/dist/config/load.d.ts.map +1 -1
- package/dist/config/load.js +16 -3
- package/dist/config/load.js.map +1 -1
- package/dist/detectors/agentless-passive.d.ts +3 -0
- package/dist/detectors/agentless-passive.d.ts.map +1 -0
- package/dist/detectors/agentless-passive.js +31 -0
- package/dist/detectors/agentless-passive.js.map +1 -0
- package/dist/detectors/char-shape.d.ts +12 -0
- package/dist/detectors/char-shape.d.ts.map +1 -0
- package/dist/detectors/char-shape.js +39 -0
- package/dist/detectors/char-shape.js.map +1 -0
- package/dist/detectors/en-shape.d.ts +19 -0
- package/dist/detectors/en-shape.d.ts.map +1 -0
- package/dist/detectors/en-shape.js +176 -0
- package/dist/detectors/en-shape.js.map +1 -0
- package/dist/detectors/index.d.ts.map +1 -1
- package/dist/detectors/index.js +37 -0
- package/dist/detectors/index.js.map +1 -1
- package/dist/detectors/lexicon.d.ts +15 -0
- package/dist/detectors/lexicon.d.ts.map +1 -0
- package/dist/detectors/lexicon.js +109 -0
- package/dist/detectors/lexicon.js.map +1 -0
- package/dist/detectors/repeated-head.d.ts.map +1 -1
- package/dist/detectors/repeated-head.js +17 -5
- package/dist/detectors/repeated-head.js.map +1 -1
- package/dist/detectors/sentence-ending.d.ts +3 -0
- package/dist/detectors/sentence-ending.d.ts.map +1 -0
- package/dist/detectors/sentence-ending.js +34 -0
- package/dist/detectors/sentence-ending.js.map +1 -0
- package/dist/detectors/signals.d.ts +6 -0
- package/dist/detectors/signals.d.ts.map +1 -0
- package/dist/detectors/signals.js +183 -0
- package/dist/detectors/signals.js.map +1 -0
- package/dist/detectors/structure.d.ts +24 -0
- package/dist/detectors/structure.d.ts.map +1 -0
- package/dist/detectors/structure.js +111 -0
- package/dist/detectors/structure.js.map +1 -0
- package/dist/detectors/team.d.ts +17 -0
- package/dist/detectors/team.d.ts.map +1 -0
- package/dist/detectors/team.js +87 -0
- package/dist/detectors/team.js.map +1 -0
- package/dist/detectors/token-shape.d.ts +4 -0
- package/dist/detectors/token-shape.d.ts.map +1 -0
- package/dist/detectors/token-shape.js +62 -0
- package/dist/detectors/token-shape.js.map +1 -0
- package/dist/document.d.ts +11 -1
- package/dist/document.d.ts.map +1 -1
- package/dist/document.js +85 -10
- package/dist/document.js.map +1 -1
- package/dist/env.d.ts +12 -0
- package/dist/env.d.ts.map +1 -0
- package/dist/env.js +20 -0
- package/dist/env.js.map +1 -0
- package/dist/init.d.ts.map +1 -1
- package/dist/init.js +9 -6
- package/dist/init.js.map +1 -1
- package/dist/judge.d.ts +14 -16
- package/dist/judge.d.ts.map +1 -1
- package/dist/judge.js +0 -0
- package/dist/judge.js.map +1 -1
- package/dist/levels.d.ts +1 -9
- package/dist/levels.d.ts.map +1 -1
- package/dist/levels.js +15 -3
- package/dist/levels.js.map +1 -1
- package/dist/look-at.d.ts +18 -0
- package/dist/look-at.d.ts.map +1 -0
- package/dist/look-at.js +43 -0
- package/dist/look-at.js.map +1 -0
- package/dist/plugin.d.ts +47 -2
- package/dist/plugin.d.ts.map +1 -1
- package/dist/render/explain.d.ts +1 -1
- package/dist/render/explain.d.ts.map +1 -1
- package/dist/render/explain.js +12 -4
- package/dist/render/explain.js.map +1 -1
- package/dist/render/rules-json.d.ts.map +1 -1
- package/dist/render/rules-json.js +13 -5
- package/dist/render/rules-json.js.map +1 -1
- package/dist/render/semantic.d.ts +2 -1
- package/dist/render/semantic.d.ts.map +1 -1
- package/dist/render/semantic.js +35 -0
- package/dist/render/semantic.js.map +1 -1
- package/dist/rule-load.d.ts.map +1 -1
- package/dist/rule-load.js +27 -1
- package/dist/rule-load.js.map +1 -1
- package/dist/run-semantic.d.ts +22 -1
- package/dist/run-semantic.d.ts.map +1 -1
- package/dist/run-semantic.js +21 -10
- package/dist/run-semantic.js.map +1 -1
- package/dist/run.d.ts +6 -1
- package/dist/run.d.ts.map +1 -1
- package/dist/run.js +34 -2
- package/dist/run.js.map +1 -1
- package/dist/sentence-shape.d.ts +6 -0
- package/dist/sentence-shape.d.ts.map +1 -0
- package/dist/sentence-shape.js +15 -0
- package/dist/sentence-shape.js.map +1 -0
- package/package.json +4 -3
- package/rules/adverb-overuse.yaml +28 -0
- package/rules/agentless-passive.yaml +28 -0
- package/rules/ai-tell.yaml +27 -0
- package/rules/concrete-evidence-density.yaml +25 -0
- package/rules/cushion-phrase-density.yaml +26 -0
- package/rules/double-keigo.yaml +27 -0
- package/rules/emoji-density.yaml +26 -0
- package/rules/excessive-hedging.yaml +26 -0
- package/rules/expletive-construction.yaml +27 -0
- package/rules/hiragana-fukushi.yaml +27 -0
- package/rules/internal-jargon.yaml +27 -0
- package/rules/max-kanji-continuous.yaml +26 -0
- package/rules/max-paragraph-length.yaml +25 -0
- package/rules/max-sentence-length.yaml +22 -0
- package/rules/ngram-repetition.yaml +25 -0
- package/rules/no-doubled-joshi.yaml +28 -0
- package/rules/no-mixed-desumasu.yaml +29 -0
- package/rules/no-nakaguro-parallel.yaml +27 -0
- package/rules/oxford-comma-consistency.yaml +26 -0
- package/rules/paragraph-length-variance.yaml +25 -0
- package/rules/preamble-length.yaml +25 -0
- package/rules/proper-noun-density.yaml +26 -0
- package/rules/repeated-conjunction.yaml +26 -0
- package/rules/required-sections.yaml +26 -0
- package/rules/rule-of-three.yaml +25 -0
- package/rules/sasete-itadaku.yaml +27 -0
- package/rules/section-length-uniformity.yaml +25 -0
- package/rules/sentence-initial-conjunction-run.yaml +27 -0
- package/rules/taigen-dome-in-prose.yaml +27 -0
- package/rules/title-case-consistency.yaml +26 -0
- package/rules/undefined-acronym.yaml +25 -0
- package/rules/unqualified-superlative.yaml +26 -0
- package/src/backends/anthropic.ts +61 -0
- package/src/backends/openai.ts +46 -0
- package/src/backends/types.ts +38 -0
- package/src/cli.ts +9 -5
- package/src/commands/eval.ts +5 -2
- package/src/commands/test.ts +74 -16
- package/src/config/load.ts +22 -3
- package/src/detectors/agentless-passive.ts +34 -0
- package/src/detectors/char-shape.ts +47 -0
- package/src/detectors/en-shape.ts +187 -0
- package/src/detectors/index.ts +37 -0
- package/src/detectors/lexicon.ts +124 -0
- package/src/detectors/repeated-head.ts +17 -6
- package/src/detectors/sentence-ending.ts +37 -0
- package/src/detectors/signals.ts +199 -0
- package/src/detectors/structure.ts +119 -0
- package/src/detectors/team.ts +90 -0
- package/src/detectors/token-shape.ts +81 -0
- package/src/document.ts +95 -11
- package/src/env.ts +20 -0
- package/src/init.ts +9 -6
- package/src/judge.ts +0 -0
- package/src/levels.ts +16 -4
- package/src/look-at.ts +53 -0
- package/src/plugin.ts +48 -4
- package/src/render/explain.ts +12 -4
- package/src/render/rules-json.ts +16 -5
- package/src/render/semantic.ts +38 -1
- package/src/rule-load.ts +28 -1
- package/src/run-semantic.ts +32 -11
- package/src/run.ts +36 -3
- package/src/sentence-shape.ts +22 -0
package/src/document.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { gfmTableFromMarkdown } from "mdast-util-gfm-table";
|
|
|
4
4
|
import { frontmatter } from "micromark-extension-frontmatter";
|
|
5
5
|
import { frontmatterFromMarkdown } from "mdast-util-frontmatter";
|
|
6
6
|
import { maskSpans } from "./mask.ts";
|
|
7
|
-
import type { LanguageAdapter, ProseDocument, Section, Sentence, Span } from "./plugin.ts";
|
|
7
|
+
import type { BulletList, LanguageAdapter, Paragraph, ProseDocument, Section, Sentence, Span } from "./plugin.ts";
|
|
8
8
|
|
|
9
9
|
type Place = { readonly offset?: number | undefined };
|
|
10
10
|
type Node = {
|
|
@@ -49,8 +49,37 @@ const linkChrome = (node: Node): Span[] => {
|
|
|
49
49
|
];
|
|
50
50
|
};
|
|
51
51
|
|
|
52
|
-
|
|
53
|
-
|
|
52
|
+
/**
|
|
53
|
+
* 強調の記号(`**` `*` `_` `~~`)だけを覆う。囲まれた文字は本文なので残す。
|
|
54
|
+
*
|
|
55
|
+
* 残すと解析器が `**。` を 1 語の名詞として拾い、文の終わりが消える。
|
|
56
|
+
* 実文書で「文末が名詞」の誤検知を追って見つけた。文長にも同じだけ効いている。
|
|
57
|
+
*/
|
|
58
|
+
const MARKER = /^[*_~]+/u;
|
|
59
|
+
|
|
60
|
+
const emphasisChrome = (node: Node, source: string): Span[] => {
|
|
61
|
+
const whole = spanOf(node);
|
|
62
|
+
if (whole === undefined) return [];
|
|
63
|
+
const width = MARKER.exec(source.slice(whole.start, whole.end))?.[0].length ?? 0;
|
|
64
|
+
if (width === 0) return [];
|
|
65
|
+
return [
|
|
66
|
+
{ start: whole.start, end: whole.start + width },
|
|
67
|
+
{ start: whole.end - width, end: whole.end },
|
|
68
|
+
];
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const EMPHASIS = new Set(["strong", "emphasis", "delete"]);
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* `:::note` `:::` のようなディレクティブ。Zenn・Docusaurus・VitePress が使う記法で、
|
|
75
|
+
* 標準の Markdown には無いため段落として解析される。囲みの指定であって文章ではない。
|
|
76
|
+
*/
|
|
77
|
+
const DIRECTIVE = /^:::[^\n]*/gmu;
|
|
78
|
+
|
|
79
|
+
const directiveSpans = (source: string): Span[] => [...source.matchAll(DIRECTIVE)].map((match) => ({ start: match.index, end: match.index + match[0].length }));
|
|
80
|
+
|
|
81
|
+
const collectMasks = (root: Node, source: string): Span[] => {
|
|
82
|
+
const spans: Span[] = [...directiveSpans(source)];
|
|
54
83
|
walk(root, (node) => {
|
|
55
84
|
if (node.type === "link" || node.type === "linkReference") {
|
|
56
85
|
spans.push(...linkChrome(node));
|
|
@@ -63,6 +92,14 @@ const collectMasks = (root: Node): Span[] => {
|
|
|
63
92
|
return spans;
|
|
64
93
|
};
|
|
65
94
|
|
|
95
|
+
const emphasisSpans = (root: Node, source: string): Span[] => {
|
|
96
|
+
const spans: Span[] = [];
|
|
97
|
+
walk(root, (node) => {
|
|
98
|
+
if (EMPHASIS.has(node.type)) spans.push(...emphasisChrome(node, source));
|
|
99
|
+
});
|
|
100
|
+
return spans;
|
|
101
|
+
};
|
|
102
|
+
|
|
66
103
|
const textOf = (node: Node, source: string): string => {
|
|
67
104
|
const parts: string[] = [];
|
|
68
105
|
walk(node, (child) => {
|
|
@@ -107,10 +144,10 @@ const strongSpans = (root: Node, masked: readonly Span[]): Span[] => {
|
|
|
107
144
|
|
|
108
145
|
const within = (span: Span, from: number, to: number): boolean => span.start >= from && span.start < to;
|
|
109
146
|
|
|
110
|
-
const
|
|
147
|
+
const spansOfType = (root: Node, type: string): Span[] => {
|
|
111
148
|
const found: Span[] = [];
|
|
112
149
|
walk(root, (node) => {
|
|
113
|
-
if (node.type !==
|
|
150
|
+
if (node.type !== type) return;
|
|
114
151
|
const span = spanOf(node);
|
|
115
152
|
if (span !== undefined) found.push(span);
|
|
116
153
|
});
|
|
@@ -124,14 +161,17 @@ const paragraphSpans = (root: Node): Span[] => {
|
|
|
124
161
|
* 覆った表やコードブロックを越えて次の句点まで飲み込む。段落は文が跨がない境界なので、
|
|
125
162
|
* ここで切れば構造的に起きない。
|
|
126
163
|
*/
|
|
164
|
+
const shift = (span: Span, by: number): Span => ({ start: by + span.start, end: by + span.end });
|
|
165
|
+
|
|
127
166
|
const sentencesOf = (prose: string, paragraphs: readonly Span[], adapter: LanguageAdapter): Sentence[] =>
|
|
128
167
|
paragraphs.flatMap((paragraph) =>
|
|
129
168
|
adapter
|
|
130
169
|
.segment(prose.slice(paragraph.start, paragraph.end))
|
|
131
170
|
.sentences.filter((sentence) => sentence.text.trim().length > 0)
|
|
132
171
|
.map((sentence) => ({
|
|
133
|
-
span:
|
|
172
|
+
span: shift(sentence.span, paragraph.start),
|
|
134
173
|
text: sentence.text,
|
|
174
|
+
...(sentence.tokens === undefined ? {} : { tokens: sentence.tokens.map((token) => ({ ...token, span: shift(token.span, paragraph.start) })) }),
|
|
135
175
|
})),
|
|
136
176
|
);
|
|
137
177
|
|
|
@@ -153,18 +193,62 @@ const sectionsOf = (headings: readonly Heading[], sentences: readonly Sentence[]
|
|
|
153
193
|
});
|
|
154
194
|
};
|
|
155
195
|
|
|
156
|
-
|
|
196
|
+
const paragraphsOf = (spans: readonly Span[], sentences: readonly Sentence[], listSpans: readonly Span[]): Paragraph[] =>
|
|
197
|
+
spans
|
|
198
|
+
// 箇条書きの中の段落は「段落」として数えない。項目 1 つを 1 段落と読むと、
|
|
199
|
+
// 段落あたりの文数も長さのばらつきも、箇条書きの多い文書で壊れる。
|
|
200
|
+
.filter((span) => !listSpans.some((list) => span.start >= list.start && span.start < list.end))
|
|
201
|
+
.map((span) => ({ span, sentences: sentences.filter((sentence) => within(sentence.span, span.start, span.end)) }));
|
|
202
|
+
|
|
203
|
+
/** 箇条書きは list ノードの直下の項目を数える。入れ子の項目は内側の list のものとして数える。 */
|
|
204
|
+
const listsOf = (root: Node, source: string): BulletList[] => {
|
|
205
|
+
const found: BulletList[] = [];
|
|
206
|
+
walk(root, (node) => {
|
|
207
|
+
if (node.type !== "list") return;
|
|
208
|
+
const span = spanOf(node);
|
|
209
|
+
if (span === undefined) return;
|
|
210
|
+
const items = (node.children ?? []).flatMap((child) => {
|
|
211
|
+
const item = spanOf(child);
|
|
212
|
+
return child.type === "listItem" && item !== undefined ? [source.slice(item.start, item.end).replace(/\s+/gu, "").length] : [];
|
|
213
|
+
});
|
|
214
|
+
if (items.length > 0) found.push({ span, items });
|
|
215
|
+
});
|
|
216
|
+
return found;
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
/** チームが chaff.yaml に書いたもの。語彙表と同じ器に入れて、detector には出所を見せない。 */
|
|
220
|
+
export type TeamRules = { readonly jargon: readonly string[]; readonly requiredSections: readonly string[] };
|
|
221
|
+
|
|
222
|
+
const EMPTY_TEAM: TeamRules = { jargon: [], requiredSections: [] };
|
|
223
|
+
|
|
224
|
+
/** Config から取り出す。document は Config の形を知らない。 */
|
|
225
|
+
export const teamRules = (config: { readonly jargon: readonly string[]; readonly requiredSections: readonly string[] }): TeamRules => ({
|
|
226
|
+
jargon: config.jargon,
|
|
227
|
+
requiredSections: config.requiredSections,
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
export const buildDocument = (path: string, source: string, adapter: LanguageAdapter, team: TeamRules = EMPTY_TEAM): ProseDocument => {
|
|
157
231
|
const root = parse(source);
|
|
158
|
-
|
|
232
|
+
// 強調の記号は「本文でないもの」だが、太字の数を数えるときの「覆われた場所」ではない。
|
|
233
|
+
// 同じ集合にすると、太字が自分の記号のせいで覆われた場所にあることになり、1 つも数えられなくなる。
|
|
234
|
+
const blocks = collectMasks(root, source);
|
|
235
|
+
const masked = [...blocks, ...emphasisSpans(root, source)];
|
|
159
236
|
const prose = maskSpans(source, masked);
|
|
160
|
-
const
|
|
237
|
+
const paragraphSpans = spansOfType(root, "paragraph");
|
|
238
|
+
const listItems = spansOfType(root, "listItem");
|
|
239
|
+
const sentences = sentencesOf(prose, paragraphSpans, adapter);
|
|
161
240
|
return {
|
|
162
241
|
path,
|
|
163
242
|
source,
|
|
164
243
|
language: adapter.id,
|
|
165
244
|
lengthUnit: adapter.capabilities.lengthUnit,
|
|
166
|
-
|
|
245
|
+
capabilities: adapter.capabilities,
|
|
246
|
+
sections: sectionsOf(headingsOf(root, source), sentences, strongSpans(root, blocks), source.length),
|
|
167
247
|
sentences,
|
|
168
|
-
|
|
248
|
+
listSpans: listItems,
|
|
249
|
+
paragraphs: paragraphsOf(paragraphSpans, sentences, listItems),
|
|
250
|
+
lists: listsOf(root, source),
|
|
251
|
+
lexicons: { ...adapter.lexicons, "internal-jargon": team.jargon.map((pattern) => ({ pattern })) },
|
|
252
|
+
requiredSections: team.requiredSections,
|
|
169
253
|
};
|
|
170
254
|
};
|
package/src/env.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
export const ENV_FILE = ".env";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 作業ディレクトリの .env を読む。依存は足さない(Node が持っている)。
|
|
8
|
+
*
|
|
9
|
+
* **すでに設定されている環境変数は上書きしない。** シェルで渡したほうが勝つ。
|
|
10
|
+
* 逆にすると、一時的に別の key で試したいときに .env が黙って勝ってしまう。
|
|
11
|
+
*
|
|
12
|
+
* 読んだかどうかを返すのは、鍵がどこから来たのかを人に見せるため。
|
|
13
|
+
* 「設定したのに効かない」を、鍵の置き場所で起こさせない。
|
|
14
|
+
*/
|
|
15
|
+
export const loadEnvFile = (dir: string): string | undefined => {
|
|
16
|
+
const path = join(dir, ENV_FILE);
|
|
17
|
+
if (!existsSync(path)) return undefined;
|
|
18
|
+
process.loadEnvFile(path);
|
|
19
|
+
return path;
|
|
20
|
+
};
|
package/src/init.ts
CHANGED
|
@@ -26,7 +26,8 @@ genre: ${genre}
|
|
|
26
26
|
rules:
|
|
27
27
|
`;
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
/** 判定のキャッシュと、鍵を書くファイル。どちらも commit しない。 */
|
|
30
|
+
const GITIGNORE_LINES = [".chaff-cache/", ".env*"];
|
|
30
31
|
|
|
31
32
|
type Written = { readonly path: string; readonly note: string };
|
|
32
33
|
|
|
@@ -36,13 +37,15 @@ const writeIfAbsent = (path: string, body: string, note: string): Written | unde
|
|
|
36
37
|
return { path, note };
|
|
37
38
|
};
|
|
38
39
|
|
|
39
|
-
/**
|
|
40
|
+
/** 判定のキャッシュと .env は commit しない。init が .gitignore に足す。 */
|
|
40
41
|
const ensureGitignore = (dir: string): Written | undefined => {
|
|
41
42
|
const path = join(dir, ".gitignore");
|
|
42
|
-
if (!existsSync(path)) return writeIfAbsent(path, `${
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
43
|
+
if (!existsSync(path)) return writeIfAbsent(path, `${GITIGNORE_LINES.join("\n")}\n`, "作成しました");
|
|
44
|
+
const body = readFileSync(path, "utf8");
|
|
45
|
+
const missing = GITIGNORE_LINES.filter((line) => !body.split("\n").includes(line));
|
|
46
|
+
if (missing.length === 0) return undefined;
|
|
47
|
+
appendFileSync(path, `${missing.join("\n")}\n`, "utf8");
|
|
48
|
+
return { path, note: `${missing.join(" と ")} を追記しました` };
|
|
46
49
|
};
|
|
47
50
|
|
|
48
51
|
export const runInit = (dir: string, genre: string): string[] => {
|
package/src/judge.ts
CHANGED
|
Binary file
|
package/src/levels.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Level, RuleDefinition } from "./plugin.ts";
|
|
1
|
+
import type { Level, LevelTable, RuleDefinition } from "./plugin.ts";
|
|
2
2
|
|
|
3
3
|
export const LEVELS: readonly Level[] = ["strict", "normal", "relaxed", "off"];
|
|
4
4
|
|
|
@@ -14,11 +14,23 @@ export type Resolved = { readonly level: Level; readonly limit: number; readonly
|
|
|
14
14
|
* 落ちたことを呼ぶ側に伝えるのは、`chaff strict` が「変えたつもりで変わっていない」
|
|
15
15
|
* 状態を作らないようにするため。
|
|
16
16
|
*/
|
|
17
|
-
|
|
17
|
+
/**
|
|
18
|
+
* ジャンル別の閾値。"business/email" は "business" より細かいので先に当たる。
|
|
19
|
+
* 仕様書の一文と社内メールに同じ文長を課すと、どちらかが必ず間違う。spec §9。
|
|
20
|
+
*/
|
|
21
|
+
const tableFor = (rule: RuleDefinition, genre: string | undefined): LevelTable => {
|
|
22
|
+
if (genre === undefined) return rule.levels;
|
|
23
|
+
const parts = genre.split("/");
|
|
24
|
+
const keys = parts.map((_, index) => parts.slice(0, parts.length - index).join("/"));
|
|
25
|
+
return keys.reduce<LevelTable | undefined>((found, key) => found ?? rule.by_genre[key], undefined) ?? rule.levels;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const resolve = (rule: RuleDefinition, level: Level, genre?: string): Resolved => {
|
|
18
29
|
if (level === "off") return { level, limit: 0, fellBackToNormal: false };
|
|
19
|
-
const
|
|
30
|
+
const table = tableFor(rule, genre);
|
|
31
|
+
const direct = table[level];
|
|
20
32
|
if (direct !== undefined) return { level, limit: direct, fellBackToNormal: false };
|
|
21
|
-
const normal = rule.levels.normal;
|
|
33
|
+
const normal = table.normal ?? rule.levels.normal;
|
|
22
34
|
if (normal === undefined) throw new Error(`rule "${rule.id}" has no normal level`);
|
|
23
35
|
return { level: "normal", limit: normal, fellBackToNormal: true };
|
|
24
36
|
};
|
package/src/look-at.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { ProseDocument } from "./plugin.ts";
|
|
2
|
+
import type { Candidate } from "./semantic.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 自然文で書かれた `look_at` を、決定的な絞り込みに変える。spec §14、§26-7。
|
|
6
|
+
*
|
|
7
|
+
* 変換に LLM も対話も使わない。書き手は「どこを見るか」を説明するとき、
|
|
8
|
+
* 見るべき語を**自然に括弧でくくる**。
|
|
9
|
+
*
|
|
10
|
+
* look_at: 「お願いします」「ご確認ください」を含む文
|
|
11
|
+
*
|
|
12
|
+
* その括弧の中を取り出すだけで、文単位の絞り込みになる。
|
|
13
|
+
* 推測を足さないので、書いたものと動くものが一致する。
|
|
14
|
+
*/
|
|
15
|
+
const QUOTED = /[「『"'`]([^「『』」"'`]{1,40})[」』"'`]/gu;
|
|
16
|
+
|
|
17
|
+
export const wordsFrom = (lookAt: string | undefined): string[] => {
|
|
18
|
+
if (lookAt === undefined) return [];
|
|
19
|
+
const found = [...lookAt.matchAll(QUOTED)].map((match) => match[1]?.trim() ?? "").filter((word) => word.length > 0);
|
|
20
|
+
return [...new Set(found)];
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** 数値を見ると書いてあれば、数字のある文に限る。 */
|
|
24
|
+
const WANTS_NUMBER = /数値|数字|number|figure/iu;
|
|
25
|
+
|
|
26
|
+
export type Narrowing = { readonly words: readonly string[]; readonly needsNumber: boolean; readonly kept: number; readonly total: number };
|
|
27
|
+
|
|
28
|
+
const NUMBER = /\d/u;
|
|
29
|
+
|
|
30
|
+
const matches = (text: string, words: readonly string[], needsNumber: boolean): boolean => {
|
|
31
|
+
if (needsNumber && !NUMBER.test(text)) return false;
|
|
32
|
+
return words.length === 0 || words.some((word) => text.includes(word));
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 絞り込めたら文単位で、絞り込めなければ文書全体を渡す。
|
|
37
|
+
* 全体を渡すときは「絞り込めなかった」ことを呼ぶ側に返す。黙って高いほうを選ばない。
|
|
38
|
+
*/
|
|
39
|
+
export const narrow = (
|
|
40
|
+
doc: ProseDocument,
|
|
41
|
+
lookAt: string | undefined,
|
|
42
|
+
whole: (doc: ProseDocument) => Candidate[],
|
|
43
|
+
): { candidates: Candidate[]; narrowing: Narrowing } => {
|
|
44
|
+
const words = wordsFrom(lookAt);
|
|
45
|
+
const needsNumber = lookAt !== undefined && WANTS_NUMBER.test(lookAt);
|
|
46
|
+
const total = doc.sentences.length;
|
|
47
|
+
if (words.length === 0 && !needsNumber) return { candidates: whole(doc), narrowing: { words, needsNumber, kept: total, total } };
|
|
48
|
+
const hits = doc.sentences.filter((sentence) => matches(sentence.text, words, needsNumber));
|
|
49
|
+
return {
|
|
50
|
+
candidates: hits.map((sentence) => ({ text: sentence.text.trim(), offset: sentence.span.start })),
|
|
51
|
+
narrowing: { words, needsNumber, kept: hits.length, total },
|
|
52
|
+
};
|
|
53
|
+
};
|
package/src/plugin.ts
CHANGED
|
@@ -3,10 +3,25 @@
|
|
|
3
3
|
|
|
4
4
|
export type Span = { readonly start: number; readonly end: number };
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
/**
|
|
7
|
+
* tokens は adapter が prepare 済みのときだけ入る。無いことと「品詞が無い文」を混ぜない。
|
|
8
|
+
* token の span は文の span と同じ座標系(segment に渡した文字列の先頭が 0)。
|
|
9
|
+
* 2 つの基準を混ぜると、ずれたときに どちらが悪いか分からなくなる。
|
|
10
|
+
*/
|
|
11
|
+
export type Sentence = { readonly span: Span; readonly text: string; readonly tokens?: readonly Token[] };
|
|
7
12
|
|
|
8
|
-
/**
|
|
9
|
-
|
|
13
|
+
/**
|
|
14
|
+
* 品詞は Universal Dependencies の UPOS に統一する。アダプタ独自の体系を露出させない。
|
|
15
|
+
* features は UD の FEATS。「受動か」のように品詞だけでは足りず、かつ言語ごとに
|
|
16
|
+
* 見え方が違うものを、アダプタが自分の言語の知識で畳んでここに置く。例: Voice=Pass。
|
|
17
|
+
*/
|
|
18
|
+
export type Token = {
|
|
19
|
+
readonly span: Span;
|
|
20
|
+
readonly surface: string;
|
|
21
|
+
readonly pos: string;
|
|
22
|
+
readonly lemma?: string;
|
|
23
|
+
readonly features?: Readonly<Record<string, string>>;
|
|
24
|
+
};
|
|
10
25
|
|
|
11
26
|
export type Segmentation = {
|
|
12
27
|
readonly sentences: readonly Sentence[];
|
|
@@ -33,6 +48,9 @@ export type AdapterCapabilities = {
|
|
|
33
48
|
readonly lengthUnit: LengthUnit;
|
|
34
49
|
};
|
|
35
50
|
|
|
51
|
+
/** prepare に渡す要求。動く rule が要らないものの代金を払わせない。 */
|
|
52
|
+
export type AdapterNeeds = { readonly pos: boolean };
|
|
53
|
+
|
|
36
54
|
export type LanguageAdapter = {
|
|
37
55
|
readonly kind: "language";
|
|
38
56
|
/** BCP 47 の primary subtag。"ja" / "en"。 */
|
|
@@ -41,6 +59,11 @@ export type LanguageAdapter = {
|
|
|
41
59
|
readonly capabilities: AdapterCapabilities;
|
|
42
60
|
/** この言語である確からしさ。0..1。 */
|
|
43
61
|
readonly detect: (source: string) => number;
|
|
62
|
+
/**
|
|
63
|
+
* 解析器の読み込み。capabilities は「払えばできる」の宣言で、prepare が「払う」。
|
|
64
|
+
* 日本語の辞書は初期化に 1.5 秒かかるので、要求されたときだけ呼ぶ。
|
|
65
|
+
*/
|
|
66
|
+
readonly prepare?: (need: AdapterNeeds) => Promise<void>;
|
|
44
67
|
readonly segment: (text: string) => Segmentation;
|
|
45
68
|
/** L2 rule が word_list で引く。アダプタが自分の言語のぶんだけを持つ。 */
|
|
46
69
|
readonly lexicons: Readonly<Record<string, Lexicon>>;
|
|
@@ -60,6 +83,11 @@ export type Section = {
|
|
|
60
83
|
readonly firstSentence: Sentence | undefined;
|
|
61
84
|
};
|
|
62
85
|
|
|
86
|
+
export type Paragraph = { readonly span: Span; readonly sentences: readonly Sentence[] };
|
|
87
|
+
|
|
88
|
+
/** 箇条書き 1 つ。項目は項目の文字数で持つ。長さのばらつきしか見ないため。 */
|
|
89
|
+
export type BulletList = { readonly span: Span; readonly items: readonly number[] };
|
|
90
|
+
|
|
63
91
|
/**
|
|
64
92
|
* detector に渡る唯一の入り口。core が I/O を済ませてから呼ぶ。
|
|
65
93
|
* detector は純関数で、fs / network / clock に触れない。spec §6。
|
|
@@ -69,10 +97,20 @@ export type ProseDocument = {
|
|
|
69
97
|
readonly source: string;
|
|
70
98
|
readonly language: string;
|
|
71
99
|
readonly lengthUnit: LengthUnit;
|
|
100
|
+
/** rule の requires を突き合わせる先。満たさない rule は理由付きで skip する。 */
|
|
101
|
+
readonly capabilities: AdapterCapabilities;
|
|
72
102
|
readonly sections: readonly Section[];
|
|
73
103
|
readonly sentences: readonly Sentence[];
|
|
74
|
-
/**
|
|
104
|
+
/** 箇条書きの範囲。体言止めのように、箇条書きでは普通で本文では困る形が見る。 */
|
|
105
|
+
readonly listSpans: readonly Span[];
|
|
106
|
+
/** 段落。文をいくつ載せているかと、長さのばらつきを見る rule が使う。 */
|
|
107
|
+
readonly paragraphs: readonly Paragraph[];
|
|
108
|
+
/** 箇条書き 1 つ。項目の数と長さのばらつきを見る rule が使う。 */
|
|
109
|
+
readonly lists: readonly BulletList[];
|
|
110
|
+
/** アダプタが持つ語彙表と、チームが chaff.yaml に足した語彙表。detector は出所を知らない。 */
|
|
75
111
|
readonly lexicons: Readonly<Record<string, Lexicon>>;
|
|
112
|
+
/** この種類の文書に無いと困る見出し。チームが chaff.yaml で決める。 */
|
|
113
|
+
readonly requiredSections: readonly string[];
|
|
76
114
|
};
|
|
77
115
|
|
|
78
116
|
export type Severity = "error" | "warning" | "info";
|
|
@@ -116,11 +154,17 @@ export type RuleDefinition = {
|
|
|
116
154
|
/** 4 語と数値の対応。2 つ以上。未定義の段は normal に落ちる。spec §18.1。
|
|
117
155
|
* 言語別の閾値を持つ rule(max-sentence-length)は、読み込み時に言語で平坦化済み。 */
|
|
118
156
|
readonly levels: LevelTable;
|
|
157
|
+
/** ジャンル別の上書き。"business" は business/* 全部に効き、"business/email" が勝つ。spec §9。 */
|
|
158
|
+
readonly by_genre: Readonly<Record<string, LevelTable>>;
|
|
119
159
|
readonly how_to_find: string;
|
|
120
160
|
readonly word_list: string | undefined;
|
|
121
161
|
/** L4 のみ。LLM に渡す決まり。言語別。 */
|
|
122
162
|
readonly what_to_check: Localized | undefined;
|
|
123
163
|
readonly where: string | undefined;
|
|
164
|
+
/** adapter に要る capability。"pos" / "lemma"。満たさなければ動かさない。spec §16。 */
|
|
165
|
+
readonly requires: readonly string[];
|
|
166
|
+
/** 動かす言語。未指定は全言語。「ですます調」のように言語に固有の rule が使う。 */
|
|
167
|
+
readonly languages: readonly string[] | undefined;
|
|
124
168
|
readonly use_for: readonly string[];
|
|
125
169
|
readonly severity: Severity;
|
|
126
170
|
};
|
package/src/render/explain.ts
CHANGED
|
@@ -2,14 +2,21 @@ import { definedLevels, resolve } from "../levels.ts";
|
|
|
2
2
|
import { localized } from "./text.ts";
|
|
3
3
|
import type { Level, RuleDefinition } from "../plugin.ts";
|
|
4
4
|
|
|
5
|
-
const levelLine = (rule: RuleDefinition, level: Level, current: Level): string => {
|
|
5
|
+
const levelLine = (rule: RuleDefinition, level: Level, current: Level, genre: string | undefined): string => {
|
|
6
6
|
const mark = level === current ? "\u2192" : " ";
|
|
7
7
|
const name = level.padEnd(9);
|
|
8
|
-
return level === "off" ? ` ${mark} ${name}見ない` : ` ${mark} ${name}${resolve(rule, level).limit}`;
|
|
8
|
+
return level === "off" ? ` ${mark} ${name}見ない` : ` ${mark} ${name}${resolve(rule, level, genre).limit}`;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/** ジャンルで数字が変わる rule は、それを言わないと「設定したのに効かない」に見える。 */
|
|
12
|
+
const genreNote = (rule: RuleDefinition, genre: string | undefined): string[] => {
|
|
13
|
+
const others = Object.keys(rule.by_genre).filter((key) => key !== genre);
|
|
14
|
+
if (others.length === 0) return [];
|
|
15
|
+
return ["", ` この数字は ${genre ?? "既定"} のものです。ほかに ${others.join(" / ")} で別の数字を持っています。`];
|
|
9
16
|
};
|
|
10
17
|
|
|
11
18
|
/** rule の意図と根拠を読む。指摘に納得できないときの入口。 */
|
|
12
|
-
export const renderExplain = (rule: RuleDefinition, current: Level, language: string, unit: string): string => {
|
|
19
|
+
export const renderExplain = (rule: RuleDefinition, current: Level, language: string, unit: string, genre?: string): string => {
|
|
13
20
|
const experimental = rule.status === "experimental" ? [" このルールはまだ試験中で、既定では動きません(--experimental で動きます)。"] : [];
|
|
14
21
|
return [
|
|
15
22
|
"",
|
|
@@ -20,7 +27,8 @@ export const renderExplain = (rule: RuleDefinition, current: Level, language: st
|
|
|
20
27
|
` 直しかた: ${localized(rule.how_to_fix, language)}`,
|
|
21
28
|
"",
|
|
22
29
|
` 設定できる値(単位: ${unit}):`,
|
|
23
|
-
...definedLevels(rule).map((level) => levelLine(rule, level, current)),
|
|
30
|
+
...definedLevels(rule).map((level) => levelLine(rule, level, current, genre)),
|
|
31
|
+
...genreNote(rule, genre),
|
|
24
32
|
"",
|
|
25
33
|
` いまは ${current} です。`,
|
|
26
34
|
...experimental,
|
package/src/render/rules-json.ts
CHANGED
|
@@ -2,14 +2,25 @@ import { definedLevels, resolve } from "../levels.ts";
|
|
|
2
2
|
import type { Config } from "../config/load.ts";
|
|
3
3
|
import type { Level, RuleDefinition } from "../plugin.ts";
|
|
4
4
|
|
|
5
|
-
const now = (rule: RuleDefinition, config: Config): Record<string, unknown> => {
|
|
5
|
+
const now = (rule: RuleDefinition, config: Config, genre: string): Record<string, unknown> => {
|
|
6
6
|
const explicit = config.rules[rule.id];
|
|
7
7
|
if (explicit === "off") return { level: "off", why_off: "設定で止めている" };
|
|
8
|
-
if (explicit !== undefined) return { level: explicit, limit: resolve(rule, explicit).limit };
|
|
8
|
+
if (explicit !== undefined) return { level: explicit, limit: resolve(rule, explicit, genre).limit };
|
|
9
9
|
if (rule.status === "experimental" && !config.experimental) {
|
|
10
10
|
return { level: "off", why_off: "experimental な rule は既定で動かさない", turn_on_with: `npx chaff lint --experimental` };
|
|
11
11
|
}
|
|
12
|
-
return { level: "normal", limit: resolve(rule, "normal").limit };
|
|
12
|
+
return { level: "normal", limit: resolve(rule, "normal", genre).limit };
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/** ジャンルで数字が変わる rule があるので、いま効いている表と既定の表の両方を出す。 */
|
|
16
|
+
const levelsOf = (rule: RuleDefinition, genre: string): Record<string, unknown> => {
|
|
17
|
+
const effective = Object.fromEntries(
|
|
18
|
+
definedLevels(rule)
|
|
19
|
+
.filter((level) => level !== "off")
|
|
20
|
+
.map((level) => [level, resolve(rule, level, genre).limit]),
|
|
21
|
+
);
|
|
22
|
+
const overridden = JSON.stringify(effective) !== JSON.stringify(rule.levels);
|
|
23
|
+
return overridden ? { levels: effective, levels_default: rule.levels, levels_from: "by_genre" } : { levels: rule.levels };
|
|
13
24
|
};
|
|
14
25
|
|
|
15
26
|
/** AI に設定を書かせるときの入口。推測せずに書けるだけの情報を 1 つに入れる。spec §19.3。 */
|
|
@@ -29,10 +40,10 @@ export const rulesJson = (rules: readonly RuleDefinition[], config: Config, lang
|
|
|
29
40
|
why: rule.why,
|
|
30
41
|
how_to_fix: rule.how_to_fix,
|
|
31
42
|
use_for: rule.use_for,
|
|
32
|
-
|
|
43
|
+
...levelsOf(rule, genre),
|
|
33
44
|
levels_you_can_set: definedLevels(rule),
|
|
34
45
|
your_setting: config.rules[rule.id] === undefined ? null : { level: config.rules[rule.id], from: config.path },
|
|
35
|
-
now: now(rule, config),
|
|
46
|
+
now: now(rule, config, genre),
|
|
36
47
|
})),
|
|
37
48
|
how_to_change: {
|
|
38
49
|
by_command: ['npx chaff relax <rule-id> --why "<理由>"', 'npx chaff strict <rule-id> --why "<理由>"', 'npx chaff off <rule-id> --why "<理由>"'],
|
package/src/render/semantic.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Finding, RuleDefinition } from "../plugin.ts";
|
|
2
2
|
import type { UserCheck } from "../checks.ts";
|
|
3
|
-
import type { SemanticResult } from "../run-semantic.ts";
|
|
3
|
+
import type { Job as Plan, SemanticResult } from "../run-semantic.ts";
|
|
4
4
|
import { MARK, localized } from "./text.ts";
|
|
5
5
|
|
|
6
6
|
const RULE = 60;
|
|
@@ -9,6 +9,19 @@ const indent = (text: string, pad: string): string[] => text.split("\n").map((li
|
|
|
9
9
|
|
|
10
10
|
export const MACHINE_BANNER = ["", `═══ 機械による判定 ${"═".repeat(RULE - 18)}`, " 同じ文章なら何度実行しても同じ結果になります", ""];
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* 自分の書いた検査が、どこまで絞り込めたかを見せる。
|
|
14
|
+
* 絞り込めないまま全文を読んでいることに気づけないと、遅さの原因が分からない。
|
|
15
|
+
*/
|
|
16
|
+
const narrowingLines = (result: SemanticResult): string[] =>
|
|
17
|
+
result.narrowed.flatMap(({ name, narrowing }) => {
|
|
18
|
+
if (narrowing.words.length === 0 && !narrowing.needsNumber) {
|
|
19
|
+
return [` ${name}: 見るところを絞れず、全文を読みました`, ` look_at に「」で語を書くと、その語を含む文だけになります`];
|
|
20
|
+
}
|
|
21
|
+
const what = [...narrowing.words.map((word) => `「${word}」`), ...(narrowing.needsNumber ? ["数字"] : [])].join(" ");
|
|
22
|
+
return [` ${name}: ${what} を含む ${narrowing.kept} 文だけを読みました(全 ${narrowing.total} 文)`];
|
|
23
|
+
});
|
|
24
|
+
|
|
12
25
|
export const aiBanner = (result: SemanticResult): string[] => [
|
|
13
26
|
"",
|
|
14
27
|
`═══ AI による判定 ${"═".repeat(RULE - 17)}`,
|
|
@@ -16,6 +29,7 @@ export const aiBanner = (result: SemanticResult): string[] => [
|
|
|
16
29
|
" あります。おかしいと思ったら、そのまま無視して構いません。",
|
|
17
30
|
"",
|
|
18
31
|
` ${result.sentencesSeen} 文のうち ${result.asked} 箇所を読みました(残りは機械が対象外と判断)`,
|
|
32
|
+
...narrowingLines(result),
|
|
19
33
|
"",
|
|
20
34
|
];
|
|
21
35
|
|
|
@@ -58,3 +72,26 @@ export const renderSemantic = (result: SemanticResult, rules: readonly RuleDefin
|
|
|
58
72
|
...result.findings.flatMap((finding) => block(finding, nameOf(finding.rule, rules, checks, language))),
|
|
59
73
|
...(result.skipped.length > 0 ? [` ${result.skipped.length} 件の検査は、見るところが無いため動いていません。`, ""] : []),
|
|
60
74
|
];
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* 何が送られるのかを、API を呼ばずに見せる。`chaff test --dry-run`。
|
|
78
|
+
*
|
|
79
|
+
* 鍵が無い環境でも二段構えの 1 段目まで確かめられる。全文が送られようとしていることに
|
|
80
|
+
* 請求書で気づくのでは遅い。spec §14。
|
|
81
|
+
*/
|
|
82
|
+
const howNarrowed = (job: Plan): string => {
|
|
83
|
+
const narrowing = job.narrowing;
|
|
84
|
+
if (narrowing === undefined) return "機械で絞り込み済み";
|
|
85
|
+
const quoted = narrowing.words.map((word) => `「${word}」`);
|
|
86
|
+
const parts = narrowing.needsNumber ? [...quoted, "数字"] : quoted;
|
|
87
|
+
if (parts.length === 0) return "絞り込めず全文";
|
|
88
|
+
return `${parts.join(" ")} を含む文`;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export const renderPlan = (path: string, jobs: readonly Plan[], sentences: number): string[] => {
|
|
92
|
+
const asked = jobs.reduce((sum, job) => sum + job.candidates.length, 0);
|
|
93
|
+
const lines = jobs.map((job) => ` ${String(job.candidates.length).padStart(3)} 箇所 ${job.name} (${howNarrowed(job)})`);
|
|
94
|
+
const first = jobs.find((job) => job.candidates.length > 0);
|
|
95
|
+
const sample = first === undefined ? [] : ["", " 最初の 1 件に送る文章:", "", ...indent(String(first.candidates[0]?.text ?? "").slice(0, 300), " ")];
|
|
96
|
+
return ["", `${path} 全 ${sentences} 文のうち ${asked} 箇所を送ります(API は呼んでいません)`, "", ...lines, ...sample, ""];
|
|
97
|
+
};
|
package/src/rule-load.ts
CHANGED
|
@@ -31,6 +31,16 @@ const flattenLevels = (raw: unknown, language: string): LevelTable | undefined =
|
|
|
31
31
|
return isLevelTable(forLanguage) ? normalize(forLanguage) : undefined;
|
|
32
32
|
};
|
|
33
33
|
|
|
34
|
+
/** ジャンル別の上書きも、言語別の levels と同じ形で書ける。読めないものは黙って落とさず捨てる。 */
|
|
35
|
+
const genreTables = (raw: unknown, language: string): Readonly<Record<string, LevelTable>> => {
|
|
36
|
+
if (!isRecord(raw)) return {};
|
|
37
|
+
const entries = Object.entries(raw).flatMap(([genre, table]) => {
|
|
38
|
+
const flattened = flattenLevels(table, language);
|
|
39
|
+
return flattened === undefined ? [] : [[genre, flattened] as const];
|
|
40
|
+
});
|
|
41
|
+
return Object.fromEntries(entries);
|
|
42
|
+
};
|
|
43
|
+
|
|
34
44
|
const SEVERITIES: readonly Severity[] = ["error", "warning", "info"];
|
|
35
45
|
const isSeverity = (value: unknown): value is Severity => SEVERITIES.some((entry) => entry === value);
|
|
36
46
|
|
|
@@ -58,6 +68,8 @@ const missingFields = (raw: Record<string, unknown>, levels: LevelTable | undefi
|
|
|
58
68
|
|
|
59
69
|
const localizedOf = (value: unknown): Record<string, string> => (isLocalized(value) ? value : {});
|
|
60
70
|
|
|
71
|
+
const stringList = (value: unknown): string[] | undefined => (Array.isArray(value) ? value.map((entry) => String(entry)) : undefined);
|
|
72
|
+
|
|
61
73
|
const toRule = (raw: unknown, language: string, file: string): RuleDefinition => {
|
|
62
74
|
if (!isRecord(raw)) throw new Error(`${file}: rule は object であること`);
|
|
63
75
|
const levels = flattenLevels(raw["levels"], language);
|
|
@@ -73,17 +85,32 @@ const toRule = (raw: unknown, language: string, file: string): RuleDefinition =>
|
|
|
73
85
|
how_to_fix: localizedOf(raw["how_to_fix"]),
|
|
74
86
|
message: localizedOf(raw["message"]),
|
|
75
87
|
levels,
|
|
88
|
+
by_genre: genreTables(raw["by_genre"], language),
|
|
76
89
|
how_to_find: String(raw["how_to_find"]),
|
|
77
90
|
word_list: typeof raw["word_list"] === "string" ? raw["word_list"] : undefined,
|
|
78
91
|
what_to_check: isLocalized(raw["what_to_check"]) ? raw["what_to_check"] : undefined,
|
|
79
92
|
where: typeof raw["where"] === "string" ? raw["where"] : undefined,
|
|
93
|
+
requires: stringList(raw["requires"]) ?? [],
|
|
94
|
+
languages: stringList(raw["languages"]),
|
|
80
95
|
use_for: Array.isArray(raw["use_for"]) ? raw["use_for"].map((entry) => String(entry)) : [],
|
|
81
96
|
severity: isSeverity(raw["severity"]) ? raw["severity"] : "warning",
|
|
82
97
|
};
|
|
83
98
|
};
|
|
84
99
|
|
|
100
|
+
/**
|
|
101
|
+
* 読めない rule ファイルは、どれが何で読めないかを言う。
|
|
102
|
+
* yaml の例外をそのまま投げると、スタックトレースだけが出てファイル名が出ない。
|
|
103
|
+
*/
|
|
104
|
+
const parseRule = (dir: string, file: string): unknown => {
|
|
105
|
+
try {
|
|
106
|
+
return parse(readFileSync(join(dir, file), "utf8"));
|
|
107
|
+
} catch (error) {
|
|
108
|
+
throw new Error(`${file} を読めません: ${error instanceof Error ? error.message.split("\n")[0] : String(error)}`, { cause: error });
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
|
|
85
112
|
export const loadRules = (language: string, dir: string = RULES_DIR): RuleDefinition[] =>
|
|
86
113
|
readdirSync(dir)
|
|
87
114
|
.filter((file) => file.endsWith(".yaml"))
|
|
88
115
|
.sort((left, right) => left.localeCompare(right, "en"))
|
|
89
|
-
.map((file) => toRule(
|
|
116
|
+
.map((file) => toRule(parseRule(dir, file), language, file));
|