chaffjs 0.6.0 → 0.7.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/cli.d.ts.map +1 -1
- package/dist/cli.js +9 -1
- package/dist/cli.js.map +1 -1
- package/dist/commands/eval.d.ts.map +1 -1
- package/dist/commands/eval.js +1 -1
- package/dist/commands/eval.js.map +1 -1
- package/dist/config/load.d.ts +9 -0
- package/dist/config/load.d.ts.map +1 -1
- package/dist/config/load.js +27 -1
- package/dist/config/load.js.map +1 -1
- package/dist/config/rule-problems.d.ts +10 -0
- package/dist/config/rule-problems.d.ts.map +1 -0
- package/dist/config/rule-problems.js +20 -0
- package/dist/config/rule-problems.js.map +1 -0
- package/dist/detectors/index.d.ts.map +1 -1
- package/dist/detectors/index.js +3 -0
- package/dist/detectors/index.js.map +1 -1
- package/dist/detectors/orthography.d.ts +9 -0
- package/dist/detectors/orthography.d.ts.map +1 -0
- package/dist/detectors/orthography.js +51 -0
- package/dist/detectors/orthography.js.map +1 -0
- package/dist/document.d.ts +3 -0
- package/dist/document.d.ts.map +1 -1
- package/dist/document.js +13 -3
- package/dist/document.js.map +1 -1
- package/dist/eval.d.ts +5 -1
- package/dist/eval.d.ts.map +1 -1
- package/dist/eval.js +10 -5
- package/dist/eval.js.map +1 -1
- package/dist/heading-text.d.ts +14 -0
- package/dist/heading-text.d.ts.map +1 -0
- package/dist/heading-text.js +28 -0
- package/dist/heading-text.js.map +1 -0
- package/dist/orthography.d.ts +21 -0
- package/dist/orthography.d.ts.map +1 -0
- package/dist/orthography.js +110 -0
- package/dist/orthography.js.map +1 -0
- package/dist/render/rules-json.d.ts.map +1 -1
- package/dist/render/rules-json.js +11 -1
- package/dist/render/rules-json.js.map +1 -1
- package/dist/run.d.ts +3 -1
- package/dist/run.d.ts.map +1 -1
- package/dist/run.js +4 -3
- package/dist/run.js.map +1 -1
- package/dist/structure/build.d.ts.map +1 -1
- package/dist/structure/build.js +11 -8
- package/dist/structure/build.js.map +1 -1
- package/package.json +1 -1
- package/rules/latin-spacing.yaml +27 -0
- package/rules/preferred-term.yaml +27 -0
- package/src/cli.ts +10 -1
- package/src/commands/eval.ts +1 -0
- package/src/config/load.ts +43 -1
- package/src/config/rule-problems.ts +24 -0
- package/src/detectors/index.ts +3 -0
- package/src/detectors/orthography.ts +55 -0
- package/src/document.ts +23 -4
- package/src/eval.ts +16 -5
- package/src/heading-text.ts +26 -0
- package/src/orthography.ts +115 -0
- package/src/render/rules-json.ts +10 -1
- package/src/run.ts +15 -3
- package/src/structure/build.ts +10 -8
package/src/commands/eval.ts
CHANGED
package/src/config/load.ts
CHANGED
|
@@ -14,12 +14,18 @@ export type Config = {
|
|
|
14
14
|
readonly aiBackend: BackendName;
|
|
15
15
|
readonly language: string | undefined;
|
|
16
16
|
readonly rules: Readonly<Record<string, Level>>;
|
|
17
|
+
/** rules に数値で書いた上限。段階の 4 語では足りないときに、その rule の数値を直接決める。段階は normal として扱う。 */
|
|
18
|
+
readonly limits: Readonly<Record<string, number>>;
|
|
19
|
+
/** rules に書かれていたが読めなかった値。黙って捨てると、書いた設定が効いていないことに気づけない。 */
|
|
20
|
+
readonly unreadableRules: readonly { readonly id: string; readonly value: string }[];
|
|
17
21
|
readonly experimental: boolean;
|
|
18
22
|
readonly path: string | undefined;
|
|
19
23
|
readonly aiModel: string;
|
|
20
24
|
readonly confidenceThreshold: number;
|
|
21
25
|
/** チームの言葉。社内でしか通じない語を、チームが自分で並べる。 */
|
|
22
26
|
readonly jargon: readonly string[];
|
|
27
|
+
/** チームの表記。{ 使わない書き方: 使う書き方 }。「サーバー」ではなく「サーバ」、のように。 */
|
|
28
|
+
readonly prefer: Readonly<Record<string, string>>;
|
|
23
29
|
/** この種類の文書に無いと困る見出し。チームが自分で決める。 */
|
|
24
30
|
readonly requiredSections: readonly string[];
|
|
25
31
|
/** パスごとの上書き。設定ファイルのある場所からの相対で照合する。 */
|
|
@@ -35,12 +41,15 @@ export const EMPTY: Config = {
|
|
|
35
41
|
genre: undefined,
|
|
36
42
|
language: undefined,
|
|
37
43
|
rules: {},
|
|
44
|
+
limits: {},
|
|
45
|
+
unreadableRules: [],
|
|
38
46
|
experimental: false,
|
|
39
47
|
path: undefined,
|
|
40
48
|
aiBackend: DEFAULT_BACKEND,
|
|
41
49
|
aiModel: defaultModel(DEFAULT_BACKEND),
|
|
42
50
|
confidenceThreshold: 0.7,
|
|
43
51
|
jargon: [],
|
|
52
|
+
prefer: {},
|
|
44
53
|
requiredSections: [],
|
|
45
54
|
byPath: [],
|
|
46
55
|
baseDir: process.cwd(),
|
|
@@ -48,11 +57,29 @@ export const EMPTY: Config = {
|
|
|
48
57
|
|
|
49
58
|
const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
50
59
|
|
|
60
|
+
const isLimit = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value) && value > 0;
|
|
61
|
+
|
|
62
|
+
/** 段階の 4 語はそのまま、数値の上限は normal として読む。上限そのものは limitsOf が持つ。 */
|
|
51
63
|
const rulesOf = (raw: unknown): Record<string, Level> => {
|
|
52
64
|
if (!isRecord(raw)) return {};
|
|
53
|
-
return Object.
|
|
65
|
+
return Object.fromEntries(
|
|
66
|
+
Object.entries(raw).flatMap(([id, value]): [string, Level][] => {
|
|
67
|
+
if (isLevel(value)) return [[id, value]];
|
|
68
|
+
return isLimit(value) ? [[id, "normal"]] : [];
|
|
69
|
+
}),
|
|
70
|
+
);
|
|
54
71
|
};
|
|
55
72
|
|
|
73
|
+
const limitsOf = (raw: unknown): Record<string, number> =>
|
|
74
|
+
isRecord(raw) ? Object.fromEntries(Object.entries(raw).flatMap(([id, value]): [string, number][] => (isLimit(value) ? [[id, value]] : []))) : {};
|
|
75
|
+
|
|
76
|
+
const unreadableOf = (raw: unknown): { id: string; value: string }[] =>
|
|
77
|
+
isRecord(raw)
|
|
78
|
+
? Object.entries(raw)
|
|
79
|
+
.filter(([, value]) => !isLevel(value) && !isLimit(value))
|
|
80
|
+
.map(([id, value]) => ({ id, value: JSON.stringify(value) ?? String(value) }))
|
|
81
|
+
: [];
|
|
82
|
+
|
|
56
83
|
const str = (value: unknown): string | undefined => (typeof value === "string" ? value : undefined);
|
|
57
84
|
|
|
58
85
|
/** files は 1 つの文字列でも配列でも書ける。 */
|
|
@@ -72,6 +99,18 @@ const toPathRule = (raw: unknown): PathRule | undefined => {
|
|
|
72
99
|
/** 利用者が書く語の並び。空白だけのものは落とす。 */
|
|
73
100
|
const wordsOf = (raw: unknown): string[] => (Array.isArray(raw) ? raw.map((entry) => String(entry).trim()).filter((entry) => entry.length > 0) : []);
|
|
74
101
|
|
|
102
|
+
/** 両側とも空でない文字列の対だけを読む。同じ語どうし(「サーバ: サーバ」)は何も言えないので捨てる。 */
|
|
103
|
+
const preferOf = (raw: unknown): Record<string, string> =>
|
|
104
|
+
isRecord(raw)
|
|
105
|
+
? Object.fromEntries(
|
|
106
|
+
Object.entries(raw).flatMap(([avoid, use]): [string, string][] => {
|
|
107
|
+
const from = avoid.trim();
|
|
108
|
+
const to = typeof use === "string" ? use.trim() : "";
|
|
109
|
+
return from !== "" && to !== "" && from !== to ? [[from, to]] : [];
|
|
110
|
+
}),
|
|
111
|
+
)
|
|
112
|
+
: {};
|
|
113
|
+
|
|
75
114
|
const byPathOf = (raw: unknown): PathRule[] => (Array.isArray(raw) ? raw.map(toPathRule).filter((rule) => rule !== undefined) : []);
|
|
76
115
|
|
|
77
116
|
/** 設定ファイルが無くても動く。あっても、既定から変えたものだけが書かれている。spec §18。 */
|
|
@@ -84,12 +123,15 @@ export const loadConfig = (path: string): Config => {
|
|
|
84
123
|
genre: str(raw["genre"]),
|
|
85
124
|
language: str(raw["language"]),
|
|
86
125
|
rules: rulesOf(raw["rules"]),
|
|
126
|
+
limits: limitsOf(raw["rules"]),
|
|
127
|
+
unreadableRules: unreadableOf(raw["rules"]),
|
|
87
128
|
experimental: raw["experimental"] === true,
|
|
88
129
|
path,
|
|
89
130
|
aiBackend: backend,
|
|
90
131
|
aiModel: str(raw["ai_model"]) ?? defaultModel(backend),
|
|
91
132
|
confidenceThreshold: typeof raw["confidence_threshold"] === "number" ? raw["confidence_threshold"] : 0.7,
|
|
92
133
|
jargon: wordsOf(raw["jargon"]),
|
|
134
|
+
prefer: preferOf(raw["prefer"]),
|
|
93
135
|
requiredSections: wordsOf(raw["required_sections"]),
|
|
94
136
|
byPath: byPathOf(raw["by_path"]),
|
|
95
137
|
baseDir: dirname(path),
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Config } from "./load.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* chaff.yaml の rules のうち、効いていないもの。知らない rule 名と、読めない値。
|
|
5
|
+
* どちらも黙って捨てると、書いた人は設定したつもりで何も変わっていない。打ち間違いがいちばん多い。
|
|
6
|
+
*/
|
|
7
|
+
export const ruleProblems = (
|
|
8
|
+
config: Pick<Config, "rules" | "limits" | "unreadableRules" | "path">,
|
|
9
|
+
known: readonly { readonly id: string; readonly layer: string }[],
|
|
10
|
+
): string[] => {
|
|
11
|
+
const knownIds = new Set(known.map((rule) => rule.id));
|
|
12
|
+
const where = config.path ?? "chaff.yaml";
|
|
13
|
+
const written = [...new Set([...Object.keys(config.rules), ...Object.keys(config.limits), ...config.unreadableRules.map((entry) => entry.id)])];
|
|
14
|
+
const unknown = written.filter((id) => !knownIds.has(id)).map((id) => `${where}: ${id} というルールはありません(npx chaff rules --json で一覧が出ます)`);
|
|
15
|
+
const unreadable = config.unreadableRules
|
|
16
|
+
.filter((entry) => knownIds.has(entry.id))
|
|
17
|
+
.map((entry) => `${where}: ${entry.id} の値 ${entry.value} は読めません(strict / normal / relaxed / off か、正の数)`);
|
|
18
|
+
// 意味を読む検査(L4)に閾値は無い。数を書いても normal として動くので、そう言う。
|
|
19
|
+
const semantic = new Set(known.filter((rule) => rule.layer === "L4").map((rule) => rule.id));
|
|
20
|
+
const numberOnSemantic = Object.keys(config.limits)
|
|
21
|
+
.filter((id) => semantic.has(id))
|
|
22
|
+
.map((id) => `${where}: ${id} は意味を読む検査なので数値の上限はありません。normal として動きます(strict / normal / relaxed / off で書いてください)`);
|
|
23
|
+
return [...unknown, ...unreadable, ...numberOnSemantic];
|
|
24
|
+
};
|
package/src/detectors/index.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { paragraphLength, paragraphVariance, preambleLength, ruleOfThree, sectio
|
|
|
14
14
|
import { concreteEvidence, dashDensity, emojiDensity, ngramRepetition, undefinedAcronym } from "./signals.ts";
|
|
15
15
|
import { aiTell, contractionMix, cushionDensity, hedgingDensity, repeatedConjunction, unqualifiedSuperlative } from "./lexicon.ts";
|
|
16
16
|
import { internalJargon, properNounDensity, requiredSections } from "./team.ts";
|
|
17
|
+
import { latinSpacing, preferredTerm } from "./orthography.ts";
|
|
17
18
|
import { danglingReference, duplicateDefinition, numberingGap } from "./structure-tree.ts";
|
|
18
19
|
|
|
19
20
|
/** rule 定義の how_to_find がここを引く。rule 側は実装を知らない。 */
|
|
@@ -54,6 +55,8 @@ export const DETECTORS: Readonly<Record<string, Detector>> = {
|
|
|
54
55
|
"internal-jargon": internalJargon,
|
|
55
56
|
"required-sections": requiredSections,
|
|
56
57
|
"proper-noun-density": properNounDensity,
|
|
58
|
+
"preferred-term": preferredTerm,
|
|
59
|
+
"latin-spacing": latinSpacing,
|
|
57
60
|
"dangling-reference": danglingReference,
|
|
58
61
|
"numbering-gap": numberingGap,
|
|
59
62
|
"duplicate-definition": duplicateDefinition,
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { Detector, Finding, Sentence } from "../plugin.ts";
|
|
2
|
+
import { latinBoundaries, minorityStyle, occurrencesOutside, type SpacingKind } from "../orthography.ts";
|
|
3
|
+
|
|
4
|
+
/** チームが chaff.yaml の prefer に書いた「使わない書き方」。書いていなければ何も言わない。 */
|
|
5
|
+
export const preferredTerm: Detector = (doc, options): Finding[] => {
|
|
6
|
+
const pairs = (options.lexicon ?? []).flatMap((entry) => (entry.instead_of === undefined ? [] : [{ avoid: entry.pattern, use: entry.instead_of }]));
|
|
7
|
+
const hits = doc.sentences.flatMap((sentence) =>
|
|
8
|
+
pairs.flatMap((pair) => occurrencesOutside(sentence.text, pair.avoid, pair.use).map((at) => ({ sentence, at, pair }))),
|
|
9
|
+
);
|
|
10
|
+
if (hits.length === 0 || hits.length < options.limit) return [];
|
|
11
|
+
return hits.map((hit) => ({
|
|
12
|
+
rule: "",
|
|
13
|
+
severity: "warning",
|
|
14
|
+
line: 0,
|
|
15
|
+
column: 0,
|
|
16
|
+
quote: hit.sentence.text.trim(),
|
|
17
|
+
values: { matched: hit.pair.avoid, preferred: hit.pair.use, count: hits.length, limit: options.limit, offset: hit.sentence.span.start + hit.at },
|
|
18
|
+
}));
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const KIND_NAME: Readonly<Record<SpacingKind, string>> = { letter: "英字", "before-digit": "後ろの数字", "after-digit": "前の数字" };
|
|
22
|
+
const KINDS: readonly SpacingKind[] = ["letter", "before-digit", "after-digit"];
|
|
23
|
+
|
|
24
|
+
type Located = { readonly sentence: Sentence; readonly offset: number; readonly kind: SpacingKind; readonly spaced: boolean };
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 日本語と英字・数字のあいだを、空けるか詰めるか。文書の中で混ざっていたら、少ないほうを指摘する。
|
|
28
|
+
* どちらが正しいかは決めない。決めるのはチームで、chaff はそろっているかだけを見る。
|
|
29
|
+
*/
|
|
30
|
+
export const latinSpacing: Detector = (doc, options): Finding[] => {
|
|
31
|
+
const located: Located[] = doc.sentences.flatMap((sentence) => latinBoundaries(sentence.text).map((boundary) => ({ sentence, ...boundary })));
|
|
32
|
+
return KINDS.flatMap((kind) => {
|
|
33
|
+
const ofKind = located.filter((entry) => entry.kind === kind);
|
|
34
|
+
const minority = minorityStyle(ofKind);
|
|
35
|
+
if (minority === undefined) return [];
|
|
36
|
+
const odd = ofKind.filter((entry) => entry.spaced === minority);
|
|
37
|
+
if (odd.length < options.limit) return [];
|
|
38
|
+
return odd.map((entry) => ({
|
|
39
|
+
rule: "",
|
|
40
|
+
severity: "warning",
|
|
41
|
+
line: 0,
|
|
42
|
+
column: 0,
|
|
43
|
+
quote: entry.sentence.text.trim(),
|
|
44
|
+
values: {
|
|
45
|
+
kind: KIND_NAME[kind],
|
|
46
|
+
style: minority ? "空けています" : "詰めています",
|
|
47
|
+
usual: minority ? "詰める" : "空ける",
|
|
48
|
+
count: odd.length,
|
|
49
|
+
of: ofKind.length,
|
|
50
|
+
limit: options.limit,
|
|
51
|
+
offset: entry.sentence.span.start + entry.offset,
|
|
52
|
+
},
|
|
53
|
+
}));
|
|
54
|
+
});
|
|
55
|
+
};
|
package/src/document.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { gfmTable } from "micromark-extension-gfm-table";
|
|
|
3
3
|
import { gfmTableFromMarkdown } from "mdast-util-gfm-table";
|
|
4
4
|
import { frontmatter } from "micromark-extension-frontmatter";
|
|
5
5
|
import { frontmatterFromMarkdown } from "mdast-util-frontmatter";
|
|
6
|
+
import { atxHeadingText, headingText } from "./heading-text.ts";
|
|
6
7
|
import { maskSpans } from "./mask.ts";
|
|
7
8
|
import { buildTree, NO_OUTLINE, type Outline } from "./structure/build.ts";
|
|
8
9
|
import { isMarkdownPath } from "./structure/markdown-path.ts";
|
|
@@ -130,7 +131,10 @@ const headingsOf = (root: Node, source: string): Heading[] => {
|
|
|
130
131
|
if (node.type !== "heading") return;
|
|
131
132
|
const span = spanOf(node);
|
|
132
133
|
const depth = typeof node === "object" && "depth" in node && typeof node.depth === "number" ? node.depth : 1;
|
|
133
|
-
if (span
|
|
134
|
+
if (span === undefined) return;
|
|
135
|
+
// ATX(行頭が #)なら閉じの # も外す。setext の見出しの末尾の # は言葉なので残す。
|
|
136
|
+
const read = source.startsWith("#", span.start) ? atxHeadingText : headingText;
|
|
137
|
+
found.push({ depth, text: read(textOf(node, source)), start: span.start, end: span.end });
|
|
134
138
|
});
|
|
135
139
|
return found;
|
|
136
140
|
};
|
|
@@ -229,13 +233,23 @@ const listsOf = (root: Node, source: string): BulletList[] => {
|
|
|
229
233
|
};
|
|
230
234
|
|
|
231
235
|
/** チームが chaff.yaml に書いたもの。語彙表と同じ器に入れて、detector には出所を見せない。 */
|
|
232
|
-
export type TeamRules = {
|
|
236
|
+
export type TeamRules = {
|
|
237
|
+
readonly jargon: readonly string[];
|
|
238
|
+
readonly requiredSections: readonly string[];
|
|
239
|
+
/** { 使わない書き方: 使う書き方 }。preferred-term が語彙表として読む。 */
|
|
240
|
+
readonly prefer?: Readonly<Record<string, string>>;
|
|
241
|
+
};
|
|
233
242
|
|
|
234
243
|
const EMPTY_TEAM: TeamRules = { jargon: [], requiredSections: [] };
|
|
235
244
|
|
|
236
245
|
/** Config から取り出す。document は Config の形を知らない。 */
|
|
237
|
-
export const teamRules = (config: {
|
|
246
|
+
export const teamRules = (config: {
|
|
247
|
+
readonly jargon: readonly string[];
|
|
248
|
+
readonly requiredSections: readonly string[];
|
|
249
|
+
readonly prefer?: Readonly<Record<string, string>>;
|
|
250
|
+
}): TeamRules => ({
|
|
238
251
|
jargon: config.jargon,
|
|
252
|
+
prefer: config.prefer ?? {},
|
|
239
253
|
requiredSections: config.requiredSections,
|
|
240
254
|
});
|
|
241
255
|
|
|
@@ -263,7 +277,12 @@ export const buildDocument = (path: string, source: string, adapter: LanguageAda
|
|
|
263
277
|
listSpans: listItems,
|
|
264
278
|
paragraphs: paragraphsOf(paragraphSpans, sentences, listItems),
|
|
265
279
|
lists: listsOf(root, source),
|
|
266
|
-
lexicons: {
|
|
280
|
+
lexicons: {
|
|
281
|
+
...adapter.lexicons,
|
|
282
|
+
"internal-jargon": team.jargon.map((pattern) => ({ pattern })),
|
|
283
|
+
// 使わない書き方を pattern に、使う書き方を instead_of に置く。語彙表の「同じことの別の書き方」と同じ向き。
|
|
284
|
+
"preferred-term": Object.entries(team.prefer ?? {}).map(([pattern, use]) => ({ pattern, instead_of: use })),
|
|
285
|
+
},
|
|
267
286
|
requiredSections: team.requiredSections,
|
|
268
287
|
// 構造の rule(参照先が無い・番号の抜け)が読む木。どの rule も読まなければ作らない。何万行の契約書で、他の rule の lint に代金を払わせない。
|
|
269
288
|
get structure(): StructureNode | undefined {
|
package/src/eval.ts
CHANGED
|
@@ -35,11 +35,12 @@ const stricterIsHigher = (rule: RuleDefinition): boolean => (rule.levels.strict
|
|
|
35
35
|
const MULTIPLIERS = [0.5, 0.7, 0.85, 1, 1.25, 1.5, 2, 3];
|
|
36
36
|
|
|
37
37
|
/** rule 自身が持つ段を基点に掃引する。rule ごとに単位が違うので、倍率で散らす。 */
|
|
38
|
-
const candidates = (rule: RuleDefinition): number[] => {
|
|
38
|
+
const candidates = (rule: RuleDefinition, current: number): number[] => {
|
|
39
39
|
const base = rule.levels.normal ?? 1;
|
|
40
40
|
const fromLevels = [rule.levels.strict, rule.levels.normal, rule.levels.relaxed].filter((value) => value !== undefined);
|
|
41
41
|
const scaled = MULTIPLIERS.map((factor) => Math.max(1, Math.round(base * factor)));
|
|
42
|
-
|
|
42
|
+
// 数値で決めた上限は、段の表に無くても掃引に入れる。入れないと「現在」の行が出ない。
|
|
43
|
+
return [...new Set([...fromLevels, ...scaled, current])].sort((left, right) => left - right);
|
|
43
44
|
};
|
|
44
45
|
|
|
45
46
|
const countAt = (docs: readonly ProseDocument[], rule: RuleDefinition, limit: number): Point => {
|
|
@@ -84,12 +85,22 @@ const measurable = (rule: RuleDefinition, docs: readonly ProseDocument[], genre:
|
|
|
84
85
|
return rule.requires.every((need) => meets(need, docs));
|
|
85
86
|
};
|
|
86
87
|
|
|
87
|
-
|
|
88
|
+
/**
|
|
89
|
+
* limits は chaff.yaml に数値で書いた上限。書いてあれば、それが lint で効いている「現在」の値。
|
|
90
|
+
* 段の言葉(relaxed など)で書いた設定は、これまでどおり normal の値を「現在」とする。
|
|
91
|
+
*/
|
|
92
|
+
export const evaluate = (
|
|
93
|
+
docs: readonly ProseDocument[],
|
|
94
|
+
rules: readonly RuleDefinition[],
|
|
95
|
+
genre: string,
|
|
96
|
+
language: string,
|
|
97
|
+
limits: Readonly<Record<string, number>> = {},
|
|
98
|
+
): RuleReport[] =>
|
|
88
99
|
rules
|
|
89
100
|
.filter((rule) => measurable(rule, docs, genre, language))
|
|
90
101
|
.map((rule) => {
|
|
91
|
-
const
|
|
92
|
-
const
|
|
102
|
+
const current = limits[rule.id] ?? rule.levels.normal ?? 1;
|
|
103
|
+
const sweep = candidates(rule, current).map((limit) => countAt(docs, rule, limit));
|
|
93
104
|
return {
|
|
94
105
|
rule: rule.id,
|
|
95
106
|
name: rule.name[language] ?? rule.id,
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 見出しの末尾に付けた属性を外した、見出しの言葉。`## ステップ 1 {#step1}` は「ステップ 1」。
|
|
3
|
+
* kramdown(`{#id}`、`{: .class}`)と pandoc(`{#id .class}`)の書き方。属性は読者に見えないので、
|
|
4
|
+
* 残すと見出しの言い直しや番号の読み取りが、見えない文字に引きずられる。
|
|
5
|
+
* 波括弧で終わっていても、中身が `#` `.` `:` で始まらないもの(`## {name} の設定`)は言葉のうち。
|
|
6
|
+
*/
|
|
7
|
+
export const headingText = (raw: string): string => {
|
|
8
|
+
const trimmed = raw.trim();
|
|
9
|
+
if (!trimmed.endsWith("}")) return trimmed;
|
|
10
|
+
const open = trimmed.lastIndexOf("{");
|
|
11
|
+
if (open === -1) return trimmed;
|
|
12
|
+
const inner = trimmed.slice(open + 1, -1).trimStart();
|
|
13
|
+
const isAttributes = inner.startsWith("#") || inner.startsWith(".") || inner.startsWith(":");
|
|
14
|
+
return isAttributes ? trimmed.slice(0, open).trim() : trimmed;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* ATX 見出し(`## 見出し ##`)の言葉。属性を外してから、閉じの `#` を外す。
|
|
19
|
+
* `## Install ## {#install}` は閉じの `#` の後ろに属性があるので、この順でないと `##` が言葉に残る。
|
|
20
|
+
* 下線で書く見出し(setext)には使わない。そちらの末尾の `##` は本当に言葉の一部。
|
|
21
|
+
*/
|
|
22
|
+
export const atxHeadingText = (content: string): string => {
|
|
23
|
+
const withoutAttributes = headingText(content);
|
|
24
|
+
const closing = withoutAttributes.search(/(?:^|[ \t])#+$/u);
|
|
25
|
+
return (closing === -1 ? withoutAttributes : withoutAttributes.slice(0, closing)).trim();
|
|
26
|
+
};
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// 表記のそろい。書き方の決まりのうち、文字だけで決まるもの。
|
|
2
|
+
|
|
3
|
+
const escapeRegExp = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
4
|
+
|
|
5
|
+
/** text の中で word が現れる [始まり, 終わり) を、左から順に。大文字小文字を区別しないときは i で探す(位置は元の文字列のまま)。 */
|
|
6
|
+
const spansOf = (text: string, word: string, ignoreCase: boolean): [number, number][] => {
|
|
7
|
+
const pattern = new RegExp(escapeRegExp(word), ignoreCase ? "giu" : "gu");
|
|
8
|
+
const found: [number, number][] = [];
|
|
9
|
+
for (let match = pattern.exec(text); match !== null; match = pattern.exec(text)) {
|
|
10
|
+
found.push([match.index, match.index + match[0].length]);
|
|
11
|
+
// 重なって現れるものも拾う。次は 1 つ先から探す。
|
|
12
|
+
pattern.lastIndex = match.index + 1;
|
|
13
|
+
}
|
|
14
|
+
return found;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* avoid が現れる位置のうち、use の一部として現れたものを除いたもの。
|
|
19
|
+
* 「ユーザ」を避けて「ユーザー」と書く決まりのとき、「ユーザー」の中の「ユーザ」は指摘しない。
|
|
20
|
+
* 大文字小文字は区別しない(文頭の「E-mail」も「e-mail」)。ただし二つが大文字小文字だけ違う組
|
|
21
|
+
* (「Javascript」→「JavaScript」)は、それ自体が大文字小文字の決まりなので区別して探す。
|
|
22
|
+
*/
|
|
23
|
+
export const occurrencesOutside = (text: string, avoid: string, use: string): number[] => {
|
|
24
|
+
if (avoid === "") return [];
|
|
25
|
+
const ignoreCase = avoid.toLowerCase() !== use.toLowerCase();
|
|
26
|
+
const covered = use === "" ? [] : spansOf(text, use, ignoreCase);
|
|
27
|
+
const found: number[] = [];
|
|
28
|
+
// covered は始まりの順に並ぶ。始まりが at 以前のものの終わりの最大が end 以上なら、どれかに収まっている。
|
|
29
|
+
let next = 0;
|
|
30
|
+
let reach = -1;
|
|
31
|
+
let at = 0;
|
|
32
|
+
for (const [start, end] of spansOf(text, avoid, ignoreCase)) {
|
|
33
|
+
if (start < at) continue;
|
|
34
|
+
while (next < covered.length && (covered[next]?.[0] ?? Infinity) <= start) {
|
|
35
|
+
reach = Math.max(reach, covered[next]?.[1] ?? -1);
|
|
36
|
+
next += 1;
|
|
37
|
+
}
|
|
38
|
+
if (reach < end) found.push(start);
|
|
39
|
+
at = end;
|
|
40
|
+
}
|
|
41
|
+
return found;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export type SpacingKind = "letter" | "before-digit" | "after-digit";
|
|
45
|
+
export type Boundary = { readonly offset: number; readonly kind: SpacingKind; readonly spaced: boolean };
|
|
46
|
+
|
|
47
|
+
const JAPANESE = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}ー]/u;
|
|
48
|
+
const LETTER = /[A-Za-z]/u;
|
|
49
|
+
const DIGIT = /\d/u;
|
|
50
|
+
|
|
51
|
+
/** Latin 側の字の種類。digitSide は、その数字が日本語の右(前に日本語)か左(後ろに日本語)か。 */
|
|
52
|
+
const kindOf = (char: string | undefined, digitSide: "before-digit" | "after-digit"): SpacingKind | undefined => {
|
|
53
|
+
if (char === undefined) return undefined;
|
|
54
|
+
if (LETTER.test(char)) return "letter";
|
|
55
|
+
return DIGIT.test(char) ? digitSide : undefined;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const isJapanese = (char: string | undefined): boolean => char !== undefined && JAPANESE.test(char);
|
|
59
|
+
|
|
60
|
+
/** japanese が日本語の字のとき、other の英字・数字の種類。 */
|
|
61
|
+
const latinBeside = (japanese: string | undefined, other: string | undefined, digitSide: "before-digit" | "after-digit"): SpacingKind | undefined =>
|
|
62
|
+
isJapanese(japanese) ? kindOf(other, digitSide) : undefined;
|
|
63
|
+
|
|
64
|
+
/** 日本語の字の隣にある英字・数字との境目。間が半角空白 1 つなら「空けている」、何も無ければ「詰めている」。 */
|
|
65
|
+
const ALPHANUMERIC = /[A-Za-z0-9.]/u;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 日本語の左にある英数字の並びの、先頭の字。「3GBの」の「GB」は数量の単位で、並びは数字で始まる。
|
|
69
|
+
* そうした並びと日本語の境目は、英字の空け方ではなく数字の後ろの空け方として数える(「3回」と同じ)。
|
|
70
|
+
*/
|
|
71
|
+
const runStart = (chars: readonly string[], last: number): string | undefined => {
|
|
72
|
+
let first = last;
|
|
73
|
+
while (first > 0 && ALPHANUMERIC.test(chars[first - 1] ?? "")) first -= 1;
|
|
74
|
+
return chars[first];
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/** 左の並びの種類。数字で始まる並び(3GB、10ms)は数字として扱う。 */
|
|
78
|
+
const leftRunBeside = (chars: readonly string[], last: number, japanese: string | undefined): SpacingKind | undefined => {
|
|
79
|
+
const kind = latinBeside(japanese, chars[last], "after-digit");
|
|
80
|
+
return kind === "letter" && DIGIT.test(runStart(chars, last) ?? "") ? "after-digit" : kind;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const boundaryAt = (chars: readonly string[], index: number): Omit<Boundary, "offset"> | undefined => {
|
|
84
|
+
const [left, right, after] = [chars[index], chars[index + 1], chars[index + 2]];
|
|
85
|
+
const touching = latinBeside(left, right, "before-digit") ?? leftRunBeside(chars, index, right);
|
|
86
|
+
if (touching !== undefined) return { kind: touching, spaced: false };
|
|
87
|
+
if (right !== " ") return undefined;
|
|
88
|
+
const spaced = latinBeside(left, after, "before-digit") ?? leftRunBeside(chars, index, after);
|
|
89
|
+
return spaced === undefined ? undefined : { kind: spaced, spaced: true };
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/** 文の中の境目を全部。offset は境目の位置(左の字の直後、UTF-16)。配列を作り直さずに一度なめる。 */
|
|
93
|
+
export const latinBoundaries = (text: string): Boundary[] => {
|
|
94
|
+
const chars = [...text];
|
|
95
|
+
const found: Boundary[] = [];
|
|
96
|
+
let offset = 0;
|
|
97
|
+
chars.forEach((char, index) => {
|
|
98
|
+
offset += char.length;
|
|
99
|
+
const boundary = boundaryAt(chars, index);
|
|
100
|
+
if (boundary !== undefined) found.push({ ...boundary, offset });
|
|
101
|
+
});
|
|
102
|
+
return found;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* 混ざっているとき、そろえるべき少数派。種類(英字・数字の前・数字の後ろ)ごとに数える。
|
|
107
|
+
* 同数なら、文書が先に使った書き方をその文書の書き方とみなし、後から出た書き方を少数派にする。
|
|
108
|
+
*/
|
|
109
|
+
export const minorityStyle = (boundaries: readonly Boundary[]): boolean | undefined => {
|
|
110
|
+
const spaced = boundaries.filter((boundary) => boundary.spaced).length;
|
|
111
|
+
const touching = boundaries.length - spaced;
|
|
112
|
+
if (spaced === 0 || touching === 0) return undefined;
|
|
113
|
+
if (spaced !== touching) return spaced < touching;
|
|
114
|
+
return !(boundaries[0]?.spaced ?? false);
|
|
115
|
+
};
|
package/src/render/rules-json.ts
CHANGED
|
@@ -5,6 +5,8 @@ import type { RuleDefinition } from "../plugin.ts";
|
|
|
5
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
|
+
const limit = rule.layer === "L4" ? undefined : config.limits[rule.id];
|
|
9
|
+
if (limit !== undefined) return { level: "normal", limit, set_as: "number" };
|
|
8
10
|
if (explicit !== undefined) return { level: explicit, limit: resolve(rule, explicit, genre).limit };
|
|
9
11
|
if (rule.status === "experimental" && !config.experimental) {
|
|
10
12
|
return { level: "off", why_off: "experimental な rule は既定で動かさない", turn_on_with: `npx chaff lint --experimental` };
|
|
@@ -12,6 +14,13 @@ const now = (rule: RuleDefinition, config: Config, genre: string): Record<string
|
|
|
12
14
|
return { level: "normal", limit: resolve(rule, "normal", genre).limit };
|
|
13
15
|
};
|
|
14
16
|
|
|
17
|
+
const yourSetting = (rule: RuleDefinition, config: Config): Record<string, unknown> | null => {
|
|
18
|
+
const level = config.rules[rule.id];
|
|
19
|
+
if (level === undefined) return null;
|
|
20
|
+
const limit = rule.layer === "L4" ? undefined : config.limits[rule.id];
|
|
21
|
+
return limit === undefined ? { level, from: config.path } : { level, limit, from: config.path };
|
|
22
|
+
};
|
|
23
|
+
|
|
15
24
|
/** ジャンルで数字が変わる rule があるので、いま効いている表と既定の表の両方を出す。 */
|
|
16
25
|
const levelsOf = (rule: RuleDefinition, genre: string): Record<string, unknown> => {
|
|
17
26
|
const effective = Object.fromEntries(
|
|
@@ -42,7 +51,7 @@ export const rulesJson = (rules: readonly RuleDefinition[], config: Config, lang
|
|
|
42
51
|
use_for: rule.use_for,
|
|
43
52
|
...levelsOf(rule, genre),
|
|
44
53
|
levels_you_can_set: definedLevels(rule),
|
|
45
|
-
your_setting:
|
|
54
|
+
your_setting: yourSetting(rule, config),
|
|
46
55
|
now: now(rule, config, genre),
|
|
47
56
|
})),
|
|
48
57
|
how_to_change: {
|
package/src/run.ts
CHANGED
|
@@ -92,7 +92,19 @@ const compositeOf = (rule: RuleDefinition, found: readonly Finding[], limit: num
|
|
|
92
92
|
];
|
|
93
93
|
};
|
|
94
94
|
|
|
95
|
-
|
|
95
|
+
/** chaff.yaml の rules に数値で書いた上限。段階の表より先に効く。 */
|
|
96
|
+
export type Limits = Readonly<Record<string, number>>;
|
|
97
|
+
|
|
98
|
+
const limitFor = (rule: RuleDefinition, level: Level, genre: string, limits: Limits): number => limits[rule.id] ?? resolve(rule, level, genre).limit;
|
|
99
|
+
|
|
100
|
+
export const runRules = (
|
|
101
|
+
doc: ProseDocument,
|
|
102
|
+
rules: readonly RuleDefinition[],
|
|
103
|
+
settings: Settings,
|
|
104
|
+
experimental: boolean,
|
|
105
|
+
genre: string,
|
|
106
|
+
limits: Limits = {},
|
|
107
|
+
): RunResult => {
|
|
96
108
|
const starts = lineStarts(doc.source);
|
|
97
109
|
const applicable = forGenre(rules, genre);
|
|
98
110
|
const forced = applicable
|
|
@@ -121,7 +133,7 @@ export const runRules = (doc: ProseDocument, rules: readonly RuleDefinition[], s
|
|
|
121
133
|
const detector = DETECTORS[rule.how_to_find];
|
|
122
134
|
if (detector === undefined) return { findings: acc.findings, skipped: [...acc.skipped, { rule: rule.id, why: `検出器 ${rule.how_to_find} がないため` }] };
|
|
123
135
|
const options = {
|
|
124
|
-
limit:
|
|
136
|
+
limit: limitFor(rule, level, genre, limits),
|
|
125
137
|
lexicon: rule.word_list === undefined ? undefined : doc.lexicons[rule.word_list],
|
|
126
138
|
where: rule.where,
|
|
127
139
|
};
|
|
@@ -136,7 +148,7 @@ export const runRules = (doc: ProseDocument, rules: readonly RuleDefinition[], s
|
|
|
136
148
|
);
|
|
137
149
|
const composites = applicable
|
|
138
150
|
.filter((rule) => rule.from.length > 0 && levelFor(rule, settings, experimental) !== "off")
|
|
139
|
-
.flatMap((rule) => compositeOf(rule, outcome.findings,
|
|
151
|
+
.flatMap((rule) => compositeOf(rule, outcome.findings, limitFor(rule, levelFor(rule, settings, experimental), genre, limits), starts));
|
|
140
152
|
const all = [...outcome.findings, ...composites];
|
|
141
153
|
return { ...outcome, findings: [...all].sort((left, right) => left.line - right.line), forcedExperimental: forced };
|
|
142
154
|
};
|
package/src/structure/build.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Heading } from "../document.ts";
|
|
2
|
+
import { atxHeadingText, headingText } from "../heading-text.ts";
|
|
2
3
|
import { maskSpans } from "../mask.ts";
|
|
3
4
|
import type { Mention, NumberedLine, NumberingContext, Span, StructureKind, StructureNode, StructurePatterns } from "../plugin.ts";
|
|
4
5
|
import { lineNumberAt, linesOf, type Line } from "./lines.ts";
|
|
@@ -156,17 +157,18 @@ const headingsByLine = (lines: readonly Line[], headings: readonly Heading[]): M
|
|
|
156
157
|
);
|
|
157
158
|
|
|
158
159
|
/** 見出しの行から「#」の印を外した文字列。コードを覆った後の行から取るので、見出しの中のコードも読まない。 */
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
const
|
|
165
|
-
|
|
160
|
+
/**
|
|
161
|
+
* 見出しの行から見出しの言葉を取る。コードを覆った後の行から取るので、見出しの中のコードも読まない。
|
|
162
|
+
* ATX(行頭が #)なら印と閉じの # を外す。setext(下線で書く見出し)の末尾の # は言葉なので残す。
|
|
163
|
+
*/
|
|
164
|
+
const headingLineText = (text: string): string => {
|
|
165
|
+
const line = text.trim();
|
|
166
|
+
if (!line.startsWith("#")) return headingText(line);
|
|
167
|
+
return atxHeadingText(line.replace(/^#{1,6}(?=[ \t]|$)/u, "").trim());
|
|
166
168
|
};
|
|
167
169
|
|
|
168
170
|
const readLine = (state: State, patterns: StructurePatterns, line: Line, heading: Heading | undefined): void => {
|
|
169
|
-
const text = heading === undefined ? line.text :
|
|
171
|
+
const text = heading === undefined ? line.text : headingLineText(line.text);
|
|
170
172
|
const openNumbers = state.stack.flatMap((frame) => (frame.numbered === undefined ? [] : [frame.numbered]));
|
|
171
173
|
const context = { open: openNumbers, isHeading: heading !== undefined };
|
|
172
174
|
const numbered = patterns.numbered(text, context) ?? universalNumber(patterns, text, context);
|