chaffjs 0.2.0 → 0.3.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 +8 -4
- package/dist/cli.js.map +1 -1
- package/dist/commands/eval.d.ts.map +1 -1
- package/dist/commands/eval.js +4 -1
- package/dist/commands/eval.js.map +1 -1
- package/dist/commands/test.d.ts.map +1 -1
- package/dist/commands/test.js +60 -14
- package/dist/commands/test.js.map +1 -1
- package/dist/config/load.d.ts +4 -1
- package/dist/config/load.d.ts.map +1 -1
- package/dist/config/load.js +10 -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 +33 -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/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.map +1 -1
- package/dist/document.js +76 -8
- 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 +44 -1
- 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/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/repeated-conjunction.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 +8 -4
- package/src/commands/eval.ts +4 -1
- package/src/commands/test.ts +72 -14
- package/src/config/load.ts +11 -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 +33 -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/token-shape.ts +81 -0
- package/src/document.ts +81 -9
- 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 +45 -3
- 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
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { charLength, lengthOf } from "../measure.ts";
|
|
2
|
+
import type { BulletList, Detector, Finding, Paragraph, ProseDocument, Section } from "../plugin.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* ばらつきは変動係数(標準偏差 ÷ 平均)で測る。
|
|
6
|
+
*
|
|
7
|
+
* 標準偏差そのものを見ると、長い文書ほど大きくなって閾値が効かない。
|
|
8
|
+
* 平均で割ると長さに依らなくなり、「揃いすぎ」と「ばらつきすぎ」を同じ尺度で言える。
|
|
9
|
+
*/
|
|
10
|
+
export const coefficientOfVariation = (values: readonly number[]): number | undefined => {
|
|
11
|
+
if (values.length < 2) return undefined;
|
|
12
|
+
const mean = values.reduce((sum, value) => sum + value, 0) / values.length;
|
|
13
|
+
if (mean === 0) return undefined;
|
|
14
|
+
const variance = values.reduce((sum, value) => sum + (value - mean) ** 2, 0) / values.length;
|
|
15
|
+
return Math.sqrt(variance) / mean;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/** 変動係数は 0..1 では読めないので、パーセントの整数にして閾値と突き合わせる。 */
|
|
19
|
+
const PERCENT = 100;
|
|
20
|
+
|
|
21
|
+
const asPercent = (value: number): number => Math.round(value * PERCENT);
|
|
22
|
+
|
|
23
|
+
const uniformity = (doc: ProseDocument, values: readonly number[], span: { start: number }, limit: number, rule: string): Finding[] => {
|
|
24
|
+
const cv = coefficientOfVariation(values);
|
|
25
|
+
if (cv === undefined || asPercent(cv) >= limit) return [];
|
|
26
|
+
return [
|
|
27
|
+
{
|
|
28
|
+
rule,
|
|
29
|
+
severity: "info",
|
|
30
|
+
line: 0,
|
|
31
|
+
column: 0,
|
|
32
|
+
quote: doc.source.slice(span.start, span.start + 80).trim(),
|
|
33
|
+
values: { count: values.length, variance: asPercent(cv), limit, offset: span.start },
|
|
34
|
+
},
|
|
35
|
+
];
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** 1 段落に文を詰めすぎると、読み手はどこで息継ぎしていいか分からなくなる。 */
|
|
39
|
+
export const paragraphLength: Detector = (doc, options): Finding[] =>
|
|
40
|
+
doc.paragraphs
|
|
41
|
+
.filter((paragraph) => paragraph.sentences.length > options.limit)
|
|
42
|
+
.map((paragraph) => ({
|
|
43
|
+
rule: "max-paragraph-length",
|
|
44
|
+
severity: "warning",
|
|
45
|
+
line: 0,
|
|
46
|
+
column: 0,
|
|
47
|
+
quote: doc.source.slice(paragraph.span.start, paragraph.span.start + 80).trim(),
|
|
48
|
+
values: { count: paragraph.sentences.length, limit: options.limit, offset: paragraph.span.start },
|
|
49
|
+
}));
|
|
50
|
+
|
|
51
|
+
const charsOf = (paragraph: Paragraph): number => paragraph.sentences.reduce((sum, sentence) => sum + charLength(sentence), 0);
|
|
52
|
+
|
|
53
|
+
/** 段落の長さが揃いすぎている。人が書くと、言いたいことの重さで段落の長さは揺れる。 */
|
|
54
|
+
export const paragraphVariance: Detector = (doc, options): Finding[] => {
|
|
55
|
+
const lengths = doc.paragraphs.map(charsOf).filter((length) => length > 0);
|
|
56
|
+
const first = doc.paragraphs[0];
|
|
57
|
+
return first === undefined ? [] : uniformity(doc, lengths, first.span, options.limit, "paragraph-length-variance");
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const sectionChars = (section: Section): number => section.sentences.reduce((sum, sentence) => sum + charLength(sentence), 0);
|
|
61
|
+
|
|
62
|
+
/** 節の長さが揃いすぎている。見出しごとに機械的に埋めた文書で起きる。 */
|
|
63
|
+
export const sectionUniformity: Detector = (doc, options): Finding[] => {
|
|
64
|
+
const lengths = doc.sections.map(sectionChars).filter((length) => length > 0);
|
|
65
|
+
const first = doc.sections[0];
|
|
66
|
+
return first === undefined ? [] : uniformity(doc, lengths, first.span, options.limit, "section-length-uniformity");
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 3 項目の箇条書きばかりになっている。
|
|
71
|
+
* 3 つ並べると収まりがよいので、言いたいことが 2 つでも 4 つでも 3 つにされる。
|
|
72
|
+
*/
|
|
73
|
+
const isThree = (list: BulletList): boolean => list.items.length === 3;
|
|
74
|
+
|
|
75
|
+
export const ruleOfThree: Detector = (doc, options): Finding[] => {
|
|
76
|
+
const lists = doc.lists;
|
|
77
|
+
const threes = lists.filter(isThree);
|
|
78
|
+
const share = lists.length === 0 ? 0 : Math.round((threes.length / lists.length) * PERCENT);
|
|
79
|
+
const first = threes[0];
|
|
80
|
+
if (lists.length < 3 || first === undefined || share <= options.limit) return [];
|
|
81
|
+
return [
|
|
82
|
+
{
|
|
83
|
+
rule: "rule-of-three",
|
|
84
|
+
severity: "info",
|
|
85
|
+
line: 0,
|
|
86
|
+
column: 0,
|
|
87
|
+
quote: doc.source.slice(first.span.start, first.span.start + 80).trim(),
|
|
88
|
+
values: { count: threes.length, total: lists.length, share, limit: options.limit, offset: first.span.start },
|
|
89
|
+
},
|
|
90
|
+
];
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* 本題に入るまでが長い。業務文書では、読み手は結論を探しに来ている。
|
|
95
|
+
*
|
|
96
|
+
* 「本題」は最初の中見出し(深さ 2 以上)とする。表題(深さ 1)の直後から数えると、
|
|
97
|
+
* 表題しか無い文書で全文が前置きになる。中見出しが無い文書では何も言わない。
|
|
98
|
+
*/
|
|
99
|
+
export const preambleLength: Detector = (doc, options): Finding[] => {
|
|
100
|
+
const body = doc.sections.find((section) => section.depth >= 2);
|
|
101
|
+
if (body === undefined) return [];
|
|
102
|
+
const before = doc.paragraphs.filter((paragraph) => paragraph.span.start < body.span.start);
|
|
103
|
+
if (before.length <= options.limit) return [];
|
|
104
|
+
const first = before[0];
|
|
105
|
+
return first === undefined
|
|
106
|
+
? []
|
|
107
|
+
: [
|
|
108
|
+
{
|
|
109
|
+
rule: "preamble-length",
|
|
110
|
+
severity: "info",
|
|
111
|
+
line: 0,
|
|
112
|
+
column: 0,
|
|
113
|
+
quote: doc.source.slice(first.span.start, first.span.start + 80).trim(),
|
|
114
|
+
values: { count: before.length, limit: options.limit, offset: first.span.start },
|
|
115
|
+
},
|
|
116
|
+
];
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
export const wordsOf = (doc: ProseDocument): number => doc.sentences.reduce((sum, sentence) => sum + lengthOf(sentence, doc.lengthUnit), 0);
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { hasParticle, isClosed, lastContent } from "../sentence-shape.ts";
|
|
2
|
+
import type { Detector, Finding, Span, Token } from "../plugin.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 文末が名詞で終わる(体言止め)。箇条書きでは普通だが、本文では文が途中で切れて読める。
|
|
6
|
+
* 実文書で測ったら、指摘の 9 割が箇条書きの項目だった。箇条書きは除く。
|
|
7
|
+
*/
|
|
8
|
+
const NOMINAL = new Set(["NOUN", "PROPN"]);
|
|
9
|
+
|
|
10
|
+
/** 非自立名詞は単独では何も指さない。「回るのか。」の「の」を体言止めと読まない。 */
|
|
11
|
+
const isDependent = (token: Token): boolean => token.features?.["NounType"] === "Dependent";
|
|
12
|
+
|
|
13
|
+
const inside = (span: Span, spans: readonly Span[]): boolean => spans.some((other) => span.start >= other.start && span.start < other.end);
|
|
14
|
+
|
|
15
|
+
export const nounEnding: Detector = (doc, options): Finding[] => {
|
|
16
|
+
const ended = doc.sentences.filter((sentence) => {
|
|
17
|
+
if (inside(sentence.span, doc.listSpans) || !isClosed(sentence) || !hasParticle(sentence)) return false;
|
|
18
|
+
const last = lastContent(sentence);
|
|
19
|
+
return last !== undefined && NOMINAL.has(last.pos) && !isDependent(last);
|
|
20
|
+
});
|
|
21
|
+
const first = ended[0];
|
|
22
|
+
// 文書全体の数についての指摘なので、1 文ずつ出さない。13 件並べても直す順番が分からない。
|
|
23
|
+
if (first === undefined || ended.length <= options.limit) return [];
|
|
24
|
+
return [
|
|
25
|
+
{
|
|
26
|
+
rule: "taigen-dome-in-prose",
|
|
27
|
+
severity: "info",
|
|
28
|
+
line: 0,
|
|
29
|
+
column: 0,
|
|
30
|
+
quote: first.text.trim(),
|
|
31
|
+
values: { count: ended.length, limit: options.limit, offset: first.span.start },
|
|
32
|
+
},
|
|
33
|
+
];
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 名詞を名詞に繋ぐ助詞が続けて出る。「弊社の新製品の販売の計画」は係りかたが読めなくなる典型。
|
|
38
|
+
*
|
|
39
|
+
* 見るのは語彙表にある助詞だけ。助詞一般を数えると、「A も B も C も」の並列と
|
|
40
|
+
* 「コピーして持っていって」の連用が当たる。どちらも何重に続いても読めるので rule が合わない。
|
|
41
|
+
*
|
|
42
|
+
* 数えるのは**節の中**だけ。読点で切らないと「サービスの運営や、ドキュメントの作成」のような
|
|
43
|
+
* 並列まで 1 つの連なりとして数える。並列は必ず読点で区切られる。
|
|
44
|
+
*/
|
|
45
|
+
type Run = { readonly surface: string; readonly tokens: readonly Token[] };
|
|
46
|
+
|
|
47
|
+
const extend = (runs: readonly Run[], token: Token): Run[] => {
|
|
48
|
+
const last = runs.at(-1);
|
|
49
|
+
if (last !== undefined && last.surface === token.surface) return [...runs.slice(0, -1), { surface: last.surface, tokens: [...last.tokens, token] }];
|
|
50
|
+
return [...runs, { surface: token.surface, tokens: [token] }];
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const PARTICLE = new Set(["ADP", "SCONJ", "PART", "CCONJ"]);
|
|
54
|
+
|
|
55
|
+
const BREAK: Run = { surface: "", tokens: [] };
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 名詞は連なりの中身なので飛ばす。読点と、語彙表に無い助詞は連なりを切る。
|
|
59
|
+
* 「弊社のAはBの」を 2 回続いたと数えないため。「は」でいちど句が閉じている。
|
|
60
|
+
*/
|
|
61
|
+
const runsOf = (tokens: readonly Token[], nesting: readonly string[]): Run[] =>
|
|
62
|
+
tokens.reduce<Run[]>((acc, token) => {
|
|
63
|
+
if (nesting.includes(token.surface)) return extend(acc, token);
|
|
64
|
+
return token.pos === "PUNCT" || PARTICLE.has(token.pos) ? [...acc, BREAK] : acc;
|
|
65
|
+
}, []);
|
|
66
|
+
|
|
67
|
+
export const doubledParticle: Detector = (doc, options): Finding[] => {
|
|
68
|
+
const nesting = (options.lexicon ?? []).map((entry) => entry.pattern);
|
|
69
|
+
return doc.sentences.flatMap((sentence) =>
|
|
70
|
+
runsOf(sentence.tokens ?? [], nesting)
|
|
71
|
+
.filter((run) => run.tokens.length > options.limit)
|
|
72
|
+
.map((run) => ({
|
|
73
|
+
rule: "no-doubled-joshi",
|
|
74
|
+
severity: "warning" as const,
|
|
75
|
+
line: 0,
|
|
76
|
+
column: 0,
|
|
77
|
+
quote: sentence.text.trim(),
|
|
78
|
+
values: { word: run.surface, count: run.tokens.length, limit: options.limit, offset: run.tokens[0]?.span.start ?? sentence.span.start },
|
|
79
|
+
})),
|
|
80
|
+
);
|
|
81
|
+
};
|
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,50 @@ const sectionsOf = (headings: readonly Heading[], sentences: readonly Sentence[]
|
|
|
153
193
|
});
|
|
154
194
|
};
|
|
155
195
|
|
|
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
|
+
|
|
156
219
|
export const buildDocument = (path: string, source: string, adapter: LanguageAdapter): ProseDocument => {
|
|
157
220
|
const root = parse(source);
|
|
158
|
-
|
|
221
|
+
// 強調の記号は「本文でないもの」だが、太字の数を数えるときの「覆われた場所」ではない。
|
|
222
|
+
// 同じ集合にすると、太字が自分の記号のせいで覆われた場所にあることになり、1 つも数えられなくなる。
|
|
223
|
+
const blocks = collectMasks(root, source);
|
|
224
|
+
const masked = [...blocks, ...emphasisSpans(root, source)];
|
|
159
225
|
const prose = maskSpans(source, masked);
|
|
160
|
-
const
|
|
226
|
+
const paragraphSpans = spansOfType(root, "paragraph");
|
|
227
|
+
const listItems = spansOfType(root, "listItem");
|
|
228
|
+
const sentences = sentencesOf(prose, paragraphSpans, adapter);
|
|
161
229
|
return {
|
|
162
230
|
path,
|
|
163
231
|
source,
|
|
164
232
|
language: adapter.id,
|
|
165
233
|
lengthUnit: adapter.capabilities.lengthUnit,
|
|
166
|
-
|
|
234
|
+
capabilities: adapter.capabilities,
|
|
235
|
+
sections: sectionsOf(headingsOf(root, source), sentences, strongSpans(root, blocks), source.length),
|
|
167
236
|
sentences,
|
|
237
|
+
listSpans: listItems,
|
|
238
|
+
paragraphs: paragraphsOf(paragraphSpans, sentences, listItems),
|
|
239
|
+
lists: listsOf(root, source),
|
|
168
240
|
lexicons: adapter.lexicons,
|
|
169
241
|
};
|
|
170
242
|
};
|
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,8 +97,16 @@ 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[];
|
|
104
|
+
/** 箇条書きの範囲。体言止めのように、箇条書きでは普通で本文では困る形が見る。 */
|
|
105
|
+
readonly listSpans: readonly Span[];
|
|
106
|
+
/** 段落。文をいくつ載せているかと、長さのばらつきを見る rule が使う。 */
|
|
107
|
+
readonly paragraphs: readonly Paragraph[];
|
|
108
|
+
/** 箇条書き 1 つ。項目の数と長さのばらつきを見る rule が使う。 */
|
|
109
|
+
readonly lists: readonly BulletList[];
|
|
74
110
|
/** アダプタが持つ語彙表。detector は言語を知らずにこれを引く。 */
|
|
75
111
|
readonly lexicons: Readonly<Record<string, Lexicon>>;
|
|
76
112
|
};
|
|
@@ -116,11 +152,17 @@ export type RuleDefinition = {
|
|
|
116
152
|
/** 4 語と数値の対応。2 つ以上。未定義の段は normal に落ちる。spec §18.1。
|
|
117
153
|
* 言語別の閾値を持つ rule(max-sentence-length)は、読み込み時に言語で平坦化済み。 */
|
|
118
154
|
readonly levels: LevelTable;
|
|
155
|
+
/** ジャンル別の上書き。"business" は business/* 全部に効き、"business/email" が勝つ。spec §9。 */
|
|
156
|
+
readonly by_genre: Readonly<Record<string, LevelTable>>;
|
|
119
157
|
readonly how_to_find: string;
|
|
120
158
|
readonly word_list: string | undefined;
|
|
121
159
|
/** L4 のみ。LLM に渡す決まり。言語別。 */
|
|
122
160
|
readonly what_to_check: Localized | undefined;
|
|
123
161
|
readonly where: string | undefined;
|
|
162
|
+
/** adapter に要る capability。"pos" / "lemma"。満たさなければ動かさない。spec §16。 */
|
|
163
|
+
readonly requires: readonly string[];
|
|
164
|
+
/** 動かす言語。未指定は全言語。「ですます調」のように言語に固有の rule が使う。 */
|
|
165
|
+
readonly languages: readonly string[] | undefined;
|
|
124
166
|
readonly use_for: readonly string[];
|
|
125
167
|
readonly severity: Severity;
|
|
126
168
|
};
|
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,
|