chaffjs 0.1.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 +21 -67
- package/dist/cli.js.map +1 -1
- package/dist/commands/eval.d.ts +15 -0
- package/dist/commands/eval.d.ts.map +1 -0
- package/dist/commands/eval.js +47 -0
- package/dist/commands/eval.js.map +1 -0
- package/dist/commands/test.d.ts +21 -0
- package/dist/commands/test.d.ts.map +1 -0
- package/dist/commands/test.js +116 -0
- package/dist/commands/test.js.map +1 -0
- package/dist/config/by-path.d.ts +15 -0
- package/dist/config/by-path.d.ts.map +1 -0
- package/dist/config/by-path.js +41 -0
- package/dist/config/by-path.js.map +1 -0
- package/dist/config/load.d.ts +8 -1
- package/dist/config/load.d.ts.map +1 -1
- package/dist/config/load.js +31 -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/count-per-section.d.ts +8 -1
- package/dist/detectors/count-per-section.d.ts.map +1 -1
- package/dist/detectors/count-per-section.js +26 -5
- package/dist/detectors/count-per-section.js.map +1 -1
- 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/sentence-length.d.ts.map +1 -1
- package/dist/detectors/sentence-length.js +3 -9
- package/dist/detectors/sentence-length.js.map +1 -1
- package/dist/detectors/sentence-rhythm.d.ts.map +1 -1
- package/dist/detectors/sentence-rhythm.js +3 -2
- package/dist/detectors/sentence-rhythm.js.map +1 -1
- 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 +95 -12
- 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/eval.d.ts +31 -0
- package/dist/eval.d.ts.map +1 -0
- package/dist/eval.js +62 -0
- package/dist/eval.js.map +1 -0
- package/dist/genre.d.ts.map +1 -1
- package/dist/genre.js +8 -1
- package/dist/genre.js.map +1 -1
- package/dist/init.d.ts.map +1 -1
- package/dist/init.js +11 -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/measure.d.ts +15 -0
- package/dist/measure.d.ts.map +1 -0
- package/dist/measure.js +16 -0
- package/dist/measure.js.map +1 -0
- package/dist/plugin.d.ts +44 -1
- package/dist/plugin.d.ts.map +1 -1
- package/dist/render/eval.d.ts +7 -0
- package/dist/render/eval.d.ts.map +1 -0
- package/dist/render/eval.js +59 -0
- package/dist/render/eval.js.map +1 -0
- 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/bold-density.yaml +8 -6
- 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/empty-intensifier.yaml +1 -1
- package/rules/excessive-hedging.yaml +26 -0
- package/rules/expletive-construction.yaml +27 -0
- package/rules/heading-echo.yaml +1 -1
- 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 +23 -1
- 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/repeated-sentence-head.yaml +1 -1
- 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 +19 -78
- package/src/commands/eval.ts +67 -0
- package/src/commands/test.ts +147 -0
- package/src/config/by-path.ts +47 -0
- package/src/config/load.ts +36 -3
- package/src/detectors/agentless-passive.ts +34 -0
- package/src/detectors/char-shape.ts +47 -0
- package/src/detectors/count-per-section.ts +30 -6
- 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/sentence-length.ts +4 -10
- package/src/detectors/sentence-rhythm.ts +3 -2
- 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 +101 -13
- package/src/env.ts +20 -0
- package/src/eval.ts +80 -0
- package/src/genre.ts +8 -1
- package/src/init.ts +11 -6
- package/src/judge.ts +0 -0
- package/src/levels.ts +16 -4
- package/src/look-at.ts +53 -0
- package/src/measure.ts +21 -0
- package/src/plugin.ts +45 -3
- package/src/render/eval.ts +62 -0
- 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/eval.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { DETECTORS } from "./detectors/index.ts";
|
|
2
|
+
import { charLength } from "./measure.ts";
|
|
3
|
+
import type { ProseDocument, RuleDefinition } from "./plugin.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 閾値の較正。
|
|
7
|
+
*
|
|
8
|
+
* examples/ に置いた文書は、実際に公開した「人間の良い文書」。そこで多く発火する
|
|
9
|
+
* rule は、閾値が現実に合っていない疑いがある。spec §21 の目標(人間の良い文書での
|
|
10
|
+
* 誤検知率 5% 未満)を、そのまま推奨の判断に使う。
|
|
11
|
+
*
|
|
12
|
+
* これは較正であって正解ではない。corpus が変われば答えも変わる。だから自動では
|
|
13
|
+
* 適用せず、提示して選ばせる。
|
|
14
|
+
*/
|
|
15
|
+
export const TARGET_HIT_RATE = 0.05;
|
|
16
|
+
|
|
17
|
+
/** 文書単位の割合は corpus が小さいと粗い。文字数あたりの密度を併記する。 */
|
|
18
|
+
export type Point = { readonly limit: number; readonly findings: number; readonly documents: number; readonly per10k: number };
|
|
19
|
+
|
|
20
|
+
/** 5% を表せるだけの文書数。これを下回ると割合が「0 か全部」になる。 */
|
|
21
|
+
export const MIN_CORPUS = 20;
|
|
22
|
+
|
|
23
|
+
export type RuleReport = {
|
|
24
|
+
readonly rule: string;
|
|
25
|
+
readonly name: string;
|
|
26
|
+
readonly current: number;
|
|
27
|
+
readonly sweep: readonly Point[];
|
|
28
|
+
readonly recommended: number | undefined;
|
|
29
|
+
readonly total: number;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** strict が relaxed より大きい rule は、値が大きいほど厳しい(sentence-rhythm)。 */
|
|
33
|
+
const stricterIsHigher = (rule: RuleDefinition): boolean => (rule.levels.strict ?? 0) > (rule.levels.relaxed ?? 0);
|
|
34
|
+
|
|
35
|
+
const MULTIPLIERS = [0.5, 0.7, 0.85, 1, 1.25, 1.5, 2, 3];
|
|
36
|
+
|
|
37
|
+
/** rule 自身が持つ段を基点に掃引する。rule ごとに単位が違うので、倍率で散らす。 */
|
|
38
|
+
const candidates = (rule: RuleDefinition): number[] => {
|
|
39
|
+
const base = rule.levels.normal ?? 1;
|
|
40
|
+
const fromLevels = [rule.levels.strict, rule.levels.normal, rule.levels.relaxed].filter((value) => value !== undefined);
|
|
41
|
+
const scaled = MULTIPLIERS.map((factor) => Math.max(1, Math.round(base * factor)));
|
|
42
|
+
return [...new Set([...fromLevels, ...scaled])].sort((left, right) => left - right);
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const countAt = (docs: readonly ProseDocument[], rule: RuleDefinition, limit: number): Point => {
|
|
46
|
+
const detector = DETECTORS[rule.how_to_find];
|
|
47
|
+
if (detector === undefined) return { limit, findings: 0, documents: 0, per10k: 0 };
|
|
48
|
+
const perDoc = docs.map((doc) => {
|
|
49
|
+
const lexicon = rule.word_list === undefined ? undefined : doc.lexicons[rule.word_list];
|
|
50
|
+
if (rule.word_list !== undefined && lexicon === undefined) return 0;
|
|
51
|
+
return detector(doc, { limit, lexicon, where: rule.where }).length;
|
|
52
|
+
});
|
|
53
|
+
const findings = perDoc.reduce((sum, count) => sum + count, 0);
|
|
54
|
+
const chars = docs.reduce((sum, doc) => sum + doc.sentences.reduce((inner, sentence) => inner + charLength(sentence), 0), 0);
|
|
55
|
+
return { limit, findings, documents: perDoc.filter((count) => count > 0).length, per10k: chars === 0 ? 0 : (findings / chars) * 10000 };
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/** 目標の誤検知率を満たす中で、いちばん厳しい閾値を選ぶ。 */
|
|
59
|
+
const recommend = (rule: RuleDefinition, sweep: readonly Point[], total: number): number | undefined => {
|
|
60
|
+
const ok = sweep.filter((point) => point.documents / Math.max(1, total) <= TARGET_HIT_RATE);
|
|
61
|
+
if (ok.length === 0) return undefined;
|
|
62
|
+
const strictest = stricterIsHigher(rule) ? Math.max(...ok.map((p) => p.limit)) : Math.min(...ok.map((p) => p.limit));
|
|
63
|
+
return strictest;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export const evaluate = (docs: readonly ProseDocument[], rules: readonly RuleDefinition[], genre: string, language: string): RuleReport[] =>
|
|
67
|
+
rules
|
|
68
|
+
.filter((rule) => rule.layer !== "L4" && rule.use_for.some((target) => genre.startsWith(target)))
|
|
69
|
+
.map((rule) => {
|
|
70
|
+
const sweep = candidates(rule).map((limit) => countAt(docs, rule, limit));
|
|
71
|
+
const current = rule.levels.normal ?? 1;
|
|
72
|
+
return {
|
|
73
|
+
rule: rule.id,
|
|
74
|
+
name: rule.name[language] ?? rule.id,
|
|
75
|
+
current,
|
|
76
|
+
sweep,
|
|
77
|
+
recommended: recommend(rule, sweep, docs.length),
|
|
78
|
+
total: docs.length,
|
|
79
|
+
};
|
|
80
|
+
});
|
package/src/genre.ts
CHANGED
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
export type GenreGuess = { readonly genre: string; readonly from: string };
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* 仕様書・README は「読ませる文章」ではなく「間違えさせない文章」。
|
|
5
|
+
* 書き出しのつかみもリズムも要らない。技術文書として別に見る。
|
|
6
|
+
*/
|
|
3
7
|
const BY_PATH: readonly (readonly [RegExp, string])[] = [
|
|
8
|
+
[/(^|\/)readme\.md$/iu, "technical/readme"],
|
|
9
|
+
[/spec[^/]*\.md$/iu, "technical/spec"],
|
|
10
|
+
[/(^|\/)(spec|specs)\//iu, "technical/spec"],
|
|
4
11
|
[/(^|\/)(proposals?|teian)\//iu, "business/proposal"],
|
|
5
12
|
[/(^|\/)(minutes|gijiroku)\//iu, "business/meeting-notes"],
|
|
6
13
|
[/(^|\/)(press|pr)\//iu, "business/press-release"],
|
|
7
14
|
[/(^|\/)(reports?)\//iu, "business/report"],
|
|
8
15
|
[/(^|\/)(blog|posts?|articles?)\//iu, "blog/tech"],
|
|
9
|
-
[/(^|\/)docs?\//iu, "
|
|
16
|
+
[/(^|\/)docs?\//iu, "technical/readme"],
|
|
10
17
|
];
|
|
11
18
|
|
|
12
19
|
/**
|
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[] => {
|
|
@@ -65,6 +68,8 @@ export const runInit = (dir: string, genre: string): string[] => {
|
|
|
65
68
|
};
|
|
66
69
|
|
|
67
70
|
export const GENRES: readonly string[] = [
|
|
71
|
+
"technical/spec",
|
|
72
|
+
"technical/readme",
|
|
68
73
|
"blog/tech",
|
|
69
74
|
"blog/essay",
|
|
70
75
|
"blog/owned-media",
|
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/measure.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Sentence } from "./plugin.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 文の長さ。**覆った部分の空白を数えない。**
|
|
5
|
+
*
|
|
6
|
+
* 非 prose は同じ長さの空白で覆っている(オフセットを保つため)。そのまま数えると、
|
|
7
|
+
* URL を 1 つ含む文が「245 文字」になる。実文書で max-sentence-length が指摘の
|
|
8
|
+
* 8 割を占めていた原因はこれで、読み手が長いと感じる長さではなかった。
|
|
9
|
+
*
|
|
10
|
+
* 日本語は文字数、英語は語数。単位は adapter が宣言する。
|
|
11
|
+
*/
|
|
12
|
+
export const proseText = (sentence: Sentence): string => sentence.text.replace(/\s+/gu, " ").trim();
|
|
13
|
+
|
|
14
|
+
export const charLength = (sentence: Sentence): number => sentence.text.replace(/\s+/gu, "").length;
|
|
15
|
+
|
|
16
|
+
export const wordLength = (sentence: Sentence): number =>
|
|
17
|
+
proseText(sentence)
|
|
18
|
+
.split(" ")
|
|
19
|
+
.filter((word) => word.length > 0).length;
|
|
20
|
+
|
|
21
|
+
export const lengthOf = (sentence: Sentence, unit: "char" | "word"): number => (unit === "char" ? charLength(sentence) : wordLength(sentence));
|
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
|
};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { MIN_CORPUS, TARGET_HIT_RATE, type RuleReport } from "../eval.ts";
|
|
2
|
+
|
|
3
|
+
const pct = (part: number, whole: number): string => `${((part / Math.max(1, whole)) * 100).toFixed(1)}%`;
|
|
4
|
+
|
|
5
|
+
const mark = (report: RuleReport, limit: number): string => {
|
|
6
|
+
if (limit === report.current && limit === report.recommended) return " ← 現在 / 推奨";
|
|
7
|
+
if (limit === report.current) return " ← 現在";
|
|
8
|
+
if (limit === report.recommended) return " ← 推奨";
|
|
9
|
+
return "";
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const sweepLines = (report: RuleReport): string[] =>
|
|
13
|
+
report.sweep.map((point) => {
|
|
14
|
+
const limit = String(point.limit).padStart(6);
|
|
15
|
+
const docs = `${String(point.documents).padStart(3)} 文書`;
|
|
16
|
+
const density = `1万字あたり ${point.per10k.toFixed(1)}`;
|
|
17
|
+
return ` ${limit} ${docs} (${pct(point.documents, report.total).padStart(6)}) 指摘 ${String(point.findings).padStart(3)} 件 ${density}${mark(report, point.limit)}`;
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const verdict = (report: RuleReport): string[] => {
|
|
21
|
+
const now = report.sweep.find((point) => point.limit === report.current);
|
|
22
|
+
const rate = now === undefined ? 0 : now.documents / Math.max(1, report.total);
|
|
23
|
+
if (rate <= TARGET_HIT_RATE) return [` いまの閾値で目標(${TARGET_HIT_RATE * 100}% 未満)を満たしています。`];
|
|
24
|
+
if (report.recommended === undefined) {
|
|
25
|
+
return [` どの閾値でも目標を満たしません。この corpus に対して rule 自体が合っていない可能性があります。`];
|
|
26
|
+
}
|
|
27
|
+
return [
|
|
28
|
+
` いまの閾値では ${pct(now?.documents ?? 0, report.total)} の文書が該当し、目標(${TARGET_HIT_RATE * 100}% 未満)を超えています。`,
|
|
29
|
+
` 推奨: ${report.recommended}`,
|
|
30
|
+
"",
|
|
31
|
+
` chaff.yaml に書くなら: rules:\n ${report.rule}: ${report.recommended}`,
|
|
32
|
+
];
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const block = (report: RuleReport): string[] => ["", ` ${report.name} (${report.rule})`, "", ...sweepLines(report), "", ...verdict(report), ""];
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 閾値は自動で書き換えない。提示して選ばせる。
|
|
39
|
+
* 較正は corpus に対する答えであって、正解ではないため。
|
|
40
|
+
*/
|
|
41
|
+
export const renderEval = (reports: readonly RuleReport[], total: number, paths: number): string =>
|
|
42
|
+
[
|
|
43
|
+
"",
|
|
44
|
+
` ${paths} ファイルを corpus として ${reports.length} 本の rule を測りました。`,
|
|
45
|
+
"",
|
|
46
|
+
" ここにある文書は「人間が書いて公開した良い文書」として扱っています。",
|
|
47
|
+
` そこで多く発火する rule は、閾値が現実に合っていない疑いがあります(目標: ${TARGET_HIT_RATE * 100}% 未満)。`,
|
|
48
|
+
...(total < MIN_CORPUS
|
|
49
|
+
? [
|
|
50
|
+
"",
|
|
51
|
+
` ※ corpus が ${total} 文書しかありません。${TARGET_HIT_RATE * 100}% を表すには ${MIN_CORPUS} 文書以上が要ります。`,
|
|
52
|
+
" いまは割合が「0 か全部」に振れるので、右端の密度(1万字あたりの指摘数)のほうを見てください。",
|
|
53
|
+
]
|
|
54
|
+
: []),
|
|
55
|
+
...reports.flatMap(block),
|
|
56
|
+
" " + "─".repeat(58),
|
|
57
|
+
"",
|
|
58
|
+
` 閾値は自動で書き換えていません。corpus が変われば答えも変わるためです。`,
|
|
59
|
+
"",
|
|
60
|
+
]
|
|
61
|
+
.join("\n")
|
|
62
|
+
.replace(/\{total\}/gu, String(total));
|
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));
|