chaffjs 0.3.0 → 0.5.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/adapter-load.d.ts +5 -0
- package/dist/adapter-load.d.ts.map +1 -1
- package/dist/adapter-load.js +39 -3
- package/dist/adapter-load.js.map +1 -1
- package/dist/backends/anthropic.d.ts +2 -2
- package/dist/backends/anthropic.d.ts.map +1 -1
- package/dist/backends/anthropic.js +1 -1
- package/dist/backends/anthropic.js.map +1 -1
- package/dist/backends/openai.d.ts +2 -1
- package/dist/backends/openai.d.ts.map +1 -1
- package/dist/backends/types.d.ts +0 -1
- package/dist/backends/types.d.ts.map +1 -1
- package/dist/backends/types.js +1 -1
- package/dist/backends/types.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +24 -4
- package/dist/cli.js.map +1 -1
- package/dist/commands/eval.js +2 -2
- package/dist/commands/eval.js.map +1 -1
- package/dist/commands/test.js +3 -3
- package/dist/commands/test.js.map +1 -1
- package/dist/config/load.d.ts +4 -3
- package/dist/config/load.d.ts.map +1 -1
- package/dist/config/load.js +7 -1
- package/dist/config/load.js.map +1 -1
- package/dist/detectors/en-shape.d.ts.map +1 -1
- package/dist/detectors/en-shape.js +4 -2
- package/dist/detectors/en-shape.js.map +1 -1
- package/dist/detectors/heading-echo.d.ts.map +1 -1
- package/dist/detectors/heading-echo.js +25 -2
- package/dist/detectors/heading-echo.js.map +1 -1
- package/dist/detectors/index.d.ts.map +1 -1
- package/dist/detectors/index.js +8 -2
- package/dist/detectors/index.js.map +1 -1
- package/dist/detectors/lexicon.d.ts +1 -0
- package/dist/detectors/lexicon.d.ts.map +1 -1
- package/dist/detectors/lexicon.js +41 -0
- package/dist/detectors/lexicon.js.map +1 -1
- package/dist/detectors/signals.d.ts +1 -0
- package/dist/detectors/signals.d.ts.map +1 -1
- package/dist/detectors/signals.js +42 -10
- package/dist/detectors/signals.js.map +1 -1
- package/dist/detectors/team.d.ts +17 -0
- package/dist/detectors/team.d.ts.map +1 -0
- package/dist/detectors/team.js +87 -0
- package/dist/detectors/team.js.map +1 -0
- package/dist/document.d.ts +11 -1
- package/dist/document.d.ts.map +1 -1
- package/dist/document.js +17 -3
- package/dist/document.js.map +1 -1
- package/dist/eval.d.ts.map +1 -1
- package/dist/eval.js +15 -1
- package/dist/eval.js.map +1 -1
- package/dist/files.d.ts +0 -10
- package/dist/files.d.ts.map +1 -1
- package/dist/files.js +1 -1
- package/dist/files.js.map +1 -1
- package/dist/levels.d.ts +0 -1
- package/dist/levels.d.ts.map +1 -1
- package/dist/levels.js +1 -1
- package/dist/levels.js.map +1 -1
- package/dist/mask.d.ts.map +1 -1
- package/dist/mask.js +8 -1
- package/dist/mask.js.map +1 -1
- package/dist/measure.d.ts +0 -1
- package/dist/measure.d.ts.map +1 -1
- package/dist/measure.js +1 -1
- package/dist/measure.js.map +1 -1
- package/dist/plugin.d.ts +10 -1
- package/dist/plugin.d.ts.map +1 -1
- package/dist/position.d.ts +6 -1
- package/dist/position.d.ts.map +1 -1
- package/dist/position.js +12 -2
- package/dist/position.js.map +1 -1
- package/dist/render/rules-json.d.ts +1 -2
- package/dist/render/rules-json.d.ts.map +1 -1
- package/dist/render/rules-json.js +0 -1
- package/dist/render/rules-json.js.map +1 -1
- package/dist/render/sarif.d.ts +10 -0
- package/dist/render/sarif.d.ts.map +1 -0
- package/dist/render/sarif.js +65 -0
- package/dist/render/sarif.js.map +1 -0
- package/dist/render/semantic.d.ts +0 -1
- package/dist/render/semantic.d.ts.map +1 -1
- package/dist/render/semantic.js +1 -1
- package/dist/render/semantic.js.map +1 -1
- package/dist/render/text.d.ts +0 -1
- package/dist/render/text.d.ts.map +1 -1
- package/dist/render/text.js +1 -1
- package/dist/render/text.js.map +1 -1
- package/dist/rule-load.d.ts.map +1 -1
- package/dist/rule-load.js +15 -2
- package/dist/rule-load.js.map +1 -1
- package/dist/run-semantic.d.ts.map +1 -1
- package/dist/run-semantic.js +15 -4
- package/dist/run-semantic.js.map +1 -1
- package/dist/run.d.ts.map +1 -1
- package/dist/run.js +44 -4
- package/dist/run.js.map +1 -1
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +11 -0
- package/dist/version.js.map +1 -0
- package/package.json +3 -3
- package/rules/ai-generated-composite.yaml +36 -0
- package/rules/concrete-evidence-density.yaml +1 -1
- package/rules/contraction-consistency.yaml +28 -0
- package/rules/internal-jargon.yaml +27 -0
- package/rules/max-paragraph-length.yaml +1 -1
- package/rules/ngram-repetition.yaml +1 -1
- package/rules/no-doubled-joshi.yaml +1 -1
- package/rules/no-em-dash.yaml +32 -0
- package/rules/no-nakaguro-parallel.yaml +1 -1
- package/rules/preamble-length.yaml +1 -1
- package/rules/proper-noun-density.yaml +26 -0
- package/rules/required-sections.yaml +26 -0
- package/rules/taigen-dome-in-prose.yaml +1 -1
- package/src/adapter-load.ts +40 -2
- package/src/backends/anthropic.ts +2 -2
- package/src/backends/openai.ts +1 -1
- package/src/backends/types.ts +1 -1
- package/src/cli.ts +35 -5
- package/src/commands/eval.ts +2 -2
- package/src/commands/test.ts +3 -3
- package/src/config/load.ts +12 -1
- package/src/detectors/en-shape.ts +4 -2
- package/src/detectors/heading-echo.ts +29 -3
- package/src/detectors/index.ts +8 -2
- package/src/detectors/lexicon.ts +41 -0
- package/src/detectors/signals.ts +43 -10
- package/src/detectors/team.ts +90 -0
- package/src/document.ts +25 -3
- package/src/eval.ts +13 -1
- package/src/files.ts +1 -1
- package/src/levels.ts +1 -1
- package/src/mask.ts +8 -1
- package/src/measure.ts +1 -1
- package/src/plugin.ts +10 -2
- package/src/position.ts +11 -2
- package/src/render/rules-json.ts +1 -3
- package/src/render/sarif.ts +103 -0
- package/src/render/semantic.ts +1 -1
- package/src/render/text.ts +1 -1
- package/src/rule-load.ts +14 -2
- package/src/run-semantic.ts +14 -4
- package/src/run.ts +42 -3
- package/src/version.ts +14 -0
package/src/cli.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
|
-
import { existsSync } from "node:fs";
|
|
3
|
-
import { join } from "node:path";
|
|
2
|
+
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
4
|
import { loadAdapter, packageFor } from "./adapter-load.ts";
|
|
5
5
|
import { CONFIG_FILE, EMPTY, loadConfig, type Config } from "./config/load.ts";
|
|
6
6
|
import { applyByPath } from "./config/by-path.ts";
|
|
7
7
|
import { applyLevel } from "./config/write.ts";
|
|
8
|
-
import { buildDocument } from "./document.ts";
|
|
8
|
+
import { buildDocument, teamRules } from "./document.ts";
|
|
9
9
|
import { guessLanguage } from "./detect.ts";
|
|
10
10
|
import { collectTargets } from "./files.ts";
|
|
11
11
|
import { BASELINE_FILE, fingerprint, readBaseline, splitByBaseline, writeBaseline } from "./baseline.ts";
|
|
@@ -21,6 +21,8 @@ import { renderCompact } from "./render/compact.ts";
|
|
|
21
21
|
import { renderExplain } from "./render/explain.ts";
|
|
22
22
|
import { renderFriendly } from "./render/friendly.ts";
|
|
23
23
|
import { rulesJson } from "./render/rules-json.ts";
|
|
24
|
+
import { renderSarif } from "./render/sarif.ts";
|
|
25
|
+
import { VERSION } from "./version.ts";
|
|
24
26
|
import { renderSummary, type FileOutcome } from "./render/summary.ts";
|
|
25
27
|
import { neededBy, runRules } from "./run.ts";
|
|
26
28
|
import type { Level, RuleDefinition } from "./plugin.ts";
|
|
@@ -45,6 +47,7 @@ const USAGE = `chaff — 文章の読みにくいところを見つけます。
|
|
|
45
47
|
--show-baseline 棚上げした分も含めて全部見る
|
|
46
48
|
--watch 保存のたびに見直し、変わったところだけ出す
|
|
47
49
|
--dry-run test で、何を AI に送るかだけを見る(API を呼びません)
|
|
50
|
+
--sarif <path> 指摘を SARIF で書き出す(GitHub の PR の行に出すため)
|
|
48
51
|
|
|
49
52
|
この箇所だけ黙らせる: <!-- stet: rule-id — 理由 -->
|
|
50
53
|
|
|
@@ -81,7 +84,15 @@ const flag = (argv: readonly string[], name: string): string | undefined => {
|
|
|
81
84
|
return at === -1 ? undefined : argv[at + 1];
|
|
82
85
|
};
|
|
83
86
|
|
|
84
|
-
type Inspected = {
|
|
87
|
+
type Inspected = {
|
|
88
|
+
readonly text: string;
|
|
89
|
+
readonly rules: readonly RuleDefinition[];
|
|
90
|
+
/** ファイルごとの言語。by_path で 1 つの repo に 2 言語が混ざる。 */
|
|
91
|
+
readonly language: string;
|
|
92
|
+
readonly outcome: FileOutcome;
|
|
93
|
+
readonly perFile: PerFile;
|
|
94
|
+
readonly all: readonly string[];
|
|
95
|
+
};
|
|
85
96
|
|
|
86
97
|
const headerFor = (path: string, genre: string, from: string, language: string, shelved: number, hushed: number): string => {
|
|
87
98
|
const shelf = shelved > 0 ? ` 棚上げ ${shelved} 件` : "";
|
|
@@ -97,7 +108,7 @@ const inspect = async (path: string, config: Config, argv: readonly string[]): P
|
|
|
97
108
|
const rules = loadRules(language);
|
|
98
109
|
const experimental = config.experimental || argv.includes("--experimental");
|
|
99
110
|
await adapter.prepare?.(neededBy(rules, config.rules, experimental, genre, language));
|
|
100
|
-
const doc = buildDocument(path, source, adapter);
|
|
111
|
+
const doc = buildDocument(path, source, adapter, teamRules(config));
|
|
101
112
|
const raw = runRules(doc, rules, config.rules, experimental, genre);
|
|
102
113
|
// 応答は 3 つ。stet で黙らせたものは、ここで落とす。
|
|
103
114
|
const applied = applySuppressions(
|
|
@@ -112,12 +123,30 @@ const inspect = async (path: string, config: Config, argv: readonly string[]): P
|
|
|
112
123
|
const text = argv.includes("--compact") ? renderCompact(header, result, rules, language) : renderFriendly(header, result, rules, language);
|
|
113
124
|
return {
|
|
114
125
|
text,
|
|
126
|
+
rules,
|
|
127
|
+
language,
|
|
115
128
|
outcome: { path, findings: split.fresh, notRun: raw.skipped.length },
|
|
116
129
|
perFile: { path, suppressed: applied.suppressed, reasonless: applied.unusedReasonless },
|
|
117
130
|
all: applied.kept.map((finding) => fingerprint(path, finding)),
|
|
118
131
|
};
|
|
119
132
|
};
|
|
120
133
|
|
|
134
|
+
/**
|
|
135
|
+
* 指摘を PR の変更行に出すための出口。--sarif <path> を書いたときだけ作る。
|
|
136
|
+
* 端末の出力は変えない。CI で上げるためのファイルが増えるだけ。
|
|
137
|
+
*/
|
|
138
|
+
const writeSarif = (results: readonly Inspected[], argv: readonly string[]): void => {
|
|
139
|
+
const path = flag(argv, "--sarif");
|
|
140
|
+
if (path === undefined) return;
|
|
141
|
+
// 文言はファイルの言語で描く。by_path で 1 つの repo に 2 言語が混ざるため。
|
|
142
|
+
const located = results.flatMap((result) =>
|
|
143
|
+
result.outcome.findings.map((finding) => ({ path: result.outcome.path, finding, language: result.language, rules: result.rules })),
|
|
144
|
+
);
|
|
145
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
146
|
+
writeFileSync(path, renderSarif(located, VERSION), "utf8");
|
|
147
|
+
console.log(`\n SARIF を書きました: ${path}(${located.length} 件)`);
|
|
148
|
+
};
|
|
149
|
+
|
|
121
150
|
const lint = async (targets: readonly string[], argv: readonly string[]): Promise<number> => {
|
|
122
151
|
const paths = collectTargets(targets);
|
|
123
152
|
if (paths.length === 0) {
|
|
@@ -132,6 +161,7 @@ const lint = async (targets: readonly string[], argv: readonly string[]): Promis
|
|
|
132
161
|
return 1;
|
|
133
162
|
}
|
|
134
163
|
const results = await Promise.all(paths.map((path) => inspect(path, config, argv)));
|
|
164
|
+
writeSarif(results, argv);
|
|
135
165
|
results.filter((result) => result.outcome.findings.length > 0 || paths.length === 1).forEach((result) => console.log(result.text));
|
|
136
166
|
renderSummary(results.map((result) => result.outcome)).forEach((line) => console.log(line));
|
|
137
167
|
return results.some((result) => result.outcome.findings.some((finding) => finding.severity === "error")) ? 1 : 0;
|
package/src/commands/eval.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
2
|
import { loadAdapter } from "../adapter-load.ts";
|
|
3
3
|
import { applyByPath } from "../config/by-path.ts";
|
|
4
|
-
import { buildDocument } from "../document.ts";
|
|
4
|
+
import { buildDocument, teamRules } from "../document.ts";
|
|
5
5
|
import { guessLanguage } from "../detect.ts";
|
|
6
6
|
import { collectTargets } from "../files.ts";
|
|
7
7
|
import { loadRules } from "../rule-load.ts";
|
|
@@ -35,7 +35,7 @@ export const runEval = async (targets: readonly string[], argv: readonly string[
|
|
|
35
35
|
const adapter = await loadAdapter(language);
|
|
36
36
|
const { genre } = resolveGenre(path, source, config);
|
|
37
37
|
await adapter.prepare?.(neededBy(loadRules(language), config.rules, config.experimental, genre, language));
|
|
38
|
-
return { doc: buildDocument(path, source, adapter), language, genre };
|
|
38
|
+
return { doc: buildDocument(path, source, adapter, teamRules(config)), language, genre };
|
|
39
39
|
}),
|
|
40
40
|
);
|
|
41
41
|
const language = docs[0]?.language ?? "ja";
|
package/src/commands/test.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { readFile } from "node:fs/promises";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { loadAdapter } from "../adapter-load.ts";
|
|
4
4
|
import { applyByPath } from "../config/by-path.ts";
|
|
5
|
-
import { buildDocument } from "../document.ts";
|
|
5
|
+
import { buildDocument, teamRules } from "../document.ts";
|
|
6
6
|
import { guessLanguage } from "../detect.ts";
|
|
7
7
|
import { collectTargets } from "../files.ts";
|
|
8
8
|
import { loadRules } from "../rule-load.ts";
|
|
@@ -35,7 +35,7 @@ const judgeAll = async (
|
|
|
35
35
|
const source = await readFile(path, "utf8");
|
|
36
36
|
const language = applyByPath(config.byPath, config.baseDir, path).language ?? config.language ?? guessLanguage(source).language;
|
|
37
37
|
const adapter = await loadAdapter(language);
|
|
38
|
-
const doc = buildDocument(path, source, adapter);
|
|
38
|
+
const doc = buildDocument(path, source, adapter, teamRules(config));
|
|
39
39
|
const { genre } = resolveGenre(path, source, config);
|
|
40
40
|
const rules = loadRules(language);
|
|
41
41
|
return { path, outcome: await runSemantic(doc, rules, checks, config.rules, genre, options), rules, language };
|
|
@@ -55,7 +55,7 @@ const dryRun = async (
|
|
|
55
55
|
const source = await readFile(path, "utf8");
|
|
56
56
|
const language = applyByPath(config.byPath, config.baseDir, path).language ?? config.language ?? guessLanguage(source).language;
|
|
57
57
|
const adapter = await loadAdapter(language);
|
|
58
|
-
const doc = buildDocument(path, source, adapter);
|
|
58
|
+
const doc = buildDocument(path, source, adapter, teamRules(config));
|
|
59
59
|
const { genre } = resolveGenre(path, source, config);
|
|
60
60
|
return { path, jobs: planSemantic(doc, loadRules(language), checks, config.rules, genre), sentences: doc.sentences.length };
|
|
61
61
|
}),
|
package/src/config/load.ts
CHANGED
|
@@ -18,6 +18,10 @@ export type Config = {
|
|
|
18
18
|
readonly path: string | undefined;
|
|
19
19
|
readonly aiModel: string;
|
|
20
20
|
readonly confidenceThreshold: number;
|
|
21
|
+
/** チームの言葉。社内でしか通じない語を、チームが自分で並べる。 */
|
|
22
|
+
readonly jargon: readonly string[];
|
|
23
|
+
/** この種類の文書に無いと困る見出し。チームが自分で決める。 */
|
|
24
|
+
readonly requiredSections: readonly string[];
|
|
21
25
|
/** パスごとの上書き。設定ファイルのある場所からの相対で照合する。 */
|
|
22
26
|
readonly byPath: readonly PathRule[];
|
|
23
27
|
readonly baseDir: string;
|
|
@@ -25,7 +29,7 @@ export type Config = {
|
|
|
25
29
|
|
|
26
30
|
/** 判定の質が誤検知に直結するので、既定は最上位のモデル。cost は絞り込みで削る。spec §14。 */
|
|
27
31
|
/** 既定は Anthropic。openai にすると判定役だけが替わり、rule も判定の形も変わらない。 */
|
|
28
|
-
|
|
32
|
+
const DEFAULT_BACKEND: BackendName = "anthropic";
|
|
29
33
|
|
|
30
34
|
export const EMPTY: Config = {
|
|
31
35
|
genre: undefined,
|
|
@@ -36,6 +40,8 @@ export const EMPTY: Config = {
|
|
|
36
40
|
aiBackend: DEFAULT_BACKEND,
|
|
37
41
|
aiModel: defaultModel(DEFAULT_BACKEND),
|
|
38
42
|
confidenceThreshold: 0.7,
|
|
43
|
+
jargon: [],
|
|
44
|
+
requiredSections: [],
|
|
39
45
|
byPath: [],
|
|
40
46
|
baseDir: process.cwd(),
|
|
41
47
|
};
|
|
@@ -63,6 +69,9 @@ const toPathRule = (raw: unknown): PathRule | undefined => {
|
|
|
63
69
|
return { files: globs, genre: str(raw["genre"]), language: str(raw["language"]) };
|
|
64
70
|
};
|
|
65
71
|
|
|
72
|
+
/** 利用者が書く語の並び。空白だけのものは落とす。 */
|
|
73
|
+
const wordsOf = (raw: unknown): string[] => (Array.isArray(raw) ? raw.map((entry) => String(entry).trim()).filter((entry) => entry.length > 0) : []);
|
|
74
|
+
|
|
66
75
|
const byPathOf = (raw: unknown): PathRule[] => (Array.isArray(raw) ? raw.map(toPathRule).filter((rule) => rule !== undefined) : []);
|
|
67
76
|
|
|
68
77
|
/** 設定ファイルが無くても動く。あっても、既定から変えたものだけが書かれている。spec §18。 */
|
|
@@ -80,6 +89,8 @@ export const loadConfig = (path: string): Config => {
|
|
|
80
89
|
aiBackend: backend,
|
|
81
90
|
aiModel: str(raw["ai_model"]) ?? defaultModel(backend),
|
|
82
91
|
confidenceThreshold: typeof raw["confidence_threshold"] === "number" ? raw["confidence_threshold"] : 0.7,
|
|
92
|
+
jargon: wordsOf(raw["jargon"]),
|
|
93
|
+
requiredSections: wordsOf(raw["required_sections"]),
|
|
83
94
|
byPath: byPathOf(raw["by_path"]),
|
|
84
95
|
baseDir: dirname(path),
|
|
85
96
|
};
|
|
@@ -134,7 +134,8 @@ export const titleCaseMix: Detector = (doc, options): Finding[] => {
|
|
|
134
134
|
const title = judged.filter((entry) => entry.title).length;
|
|
135
135
|
const minorityIsTitle = title <= judged.length - title;
|
|
136
136
|
const few = minorityIsTitle ? title : judged.length - title;
|
|
137
|
-
|
|
137
|
+
// 同数なら少数派は無い。どちらかを「他と違う」と呼ぶのは、選びかたが恣意的になる。
|
|
138
|
+
if (few === 0 || few * 2 === judged.length || few > options.limit) return [];
|
|
138
139
|
return judged
|
|
139
140
|
.filter((entry) => entry.title === minorityIsTitle)
|
|
140
141
|
.map(({ section }) => ({
|
|
@@ -173,7 +174,8 @@ export const oxfordComma: Detector = (doc, options): Finding[] => {
|
|
|
173
174
|
const withComma = judged.filter((entry) => entry.oxford).length;
|
|
174
175
|
const minorityUsesComma = withComma <= judged.length - withComma;
|
|
175
176
|
const few = minorityUsesComma ? withComma : judged.length - withComma;
|
|
176
|
-
|
|
177
|
+
// 同数なら少数派は無い。
|
|
178
|
+
if (few === 0 || few * 2 === judged.length || few > options.limit) return [];
|
|
177
179
|
return judged
|
|
178
180
|
.filter((entry) => entry.oxford === minorityUsesComma)
|
|
179
181
|
.map(({ sentence }) => ({
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { lengthOf } from "../measure.ts";
|
|
2
|
+
import type { Detector, Finding, LengthUnit, Section } from "../plugin.ts";
|
|
2
3
|
|
|
3
4
|
/** 見出しが短すぎると、偶然の一致で 100% になる。これ未満の見出しは見ない。 */
|
|
4
5
|
const MIN_GRAMS = 4;
|
|
@@ -8,7 +9,9 @@ const MIN_GRAMS = 4;
|
|
|
8
9
|
* 語 n-gram にすると wordSplit capability が要り、L1(言語を問わず動く)から外れる。
|
|
9
10
|
*/
|
|
10
11
|
const trigrams = (text: string): Set<string> => {
|
|
11
|
-
|
|
12
|
+
// 大文字小文字を畳む。英語では見出しが Title Case、本文が小文字になり、
|
|
13
|
+
// 同じ語でも一致しなくなる(Generating Output → generated output)。
|
|
14
|
+
const clean = [...text.toLowerCase().replace(/\s+/gu, "")];
|
|
12
15
|
return new Set(clean.slice(0, Math.max(0, clean.length - 2)).map((__char, index) => clean.slice(index, index + 3).join("")));
|
|
13
16
|
};
|
|
14
17
|
|
|
@@ -25,9 +28,32 @@ const containment = (heading: Set<string>, sentence: Set<string>): number => {
|
|
|
25
28
|
return [...heading].filter((gram) => sentence.has(gram)).length / heading.size;
|
|
26
29
|
};
|
|
27
30
|
|
|
31
|
+
/**
|
|
32
|
+
* 見出しの語を含んでいても、文がそのぶん以上に中身を足していれば「何も受け取れない」ではない。
|
|
33
|
+
*
|
|
34
|
+
* 実文書(英語 11 本)で測ったら、この条件なしでは 72.7% の文書が該当した。
|
|
35
|
+
* 「## ToolsAgent」に対する「GraphAI provides ToolsAgent components that use LLMs to…」は
|
|
36
|
+
* 見出しの語を含むが、読み進める価値がある。見出し以外の中身の量で分ける。
|
|
37
|
+
*/
|
|
38
|
+
const NEW_MATERIAL = { word: 6, char: 20 };
|
|
39
|
+
|
|
40
|
+
const headingUnits = (heading: string, unit: LengthUnit): number =>
|
|
41
|
+
unit === "word"
|
|
42
|
+
? heading
|
|
43
|
+
.trim()
|
|
44
|
+
.split(/\s+/u)
|
|
45
|
+
.filter((word) => word.length > 0).length
|
|
46
|
+
: heading.replace(/\s+/gu, "").length;
|
|
47
|
+
|
|
48
|
+
const addsLittle = (section: Section, unit: LengthUnit): boolean => {
|
|
49
|
+
const first = section.firstSentence;
|
|
50
|
+
if (first === undefined) return false;
|
|
51
|
+
return lengthOf(first, unit) - headingUnits(section.heading, unit) <= NEW_MATERIAL[unit];
|
|
52
|
+
};
|
|
53
|
+
|
|
28
54
|
export const headingEcho: Detector = (doc, options): Finding[] =>
|
|
29
55
|
doc.sections
|
|
30
|
-
.filter((section) => section.heading.length > 0 && section.firstSentence !== undefined)
|
|
56
|
+
.filter((section) => section.heading.length > 0 && section.firstSentence !== undefined && addsLittle(section, doc.lengthUnit))
|
|
31
57
|
.map((section) => ({ section, overlap: Math.round(containment(trigrams(section.heading), trigrams(section.firstSentence?.text ?? "")) * 100) }))
|
|
32
58
|
.filter(({ overlap }) => overlap >= options.limit)
|
|
33
59
|
.map(({ section, overlap }) => ({
|
package/src/detectors/index.ts
CHANGED
|
@@ -11,8 +11,9 @@ import { doubledParticle, nounEnding } from "./token-shape.ts";
|
|
|
11
11
|
import { kanjiRun, middleDot } from "./char-shape.ts";
|
|
12
12
|
import { adverbDensity, conjunctionRun, expletive, oxfordComma, titleCaseMix } from "./en-shape.ts";
|
|
13
13
|
import { paragraphLength, paragraphVariance, preambleLength, ruleOfThree, sectionUniformity } from "./structure.ts";
|
|
14
|
-
import { concreteEvidence, emojiDensity, ngramRepetition, undefinedAcronym } from "./signals.ts";
|
|
15
|
-
import { aiTell, cushionDensity, hedgingDensity, repeatedConjunction, unqualifiedSuperlative } from "./lexicon.ts";
|
|
14
|
+
import { concreteEvidence, dashDensity, emojiDensity, ngramRepetition, undefinedAcronym } from "./signals.ts";
|
|
15
|
+
import { aiTell, contractionMix, cushionDensity, hedgingDensity, repeatedConjunction, unqualifiedSuperlative } from "./lexicon.ts";
|
|
16
|
+
import { internalJargon, properNounDensity, requiredSections } from "./team.ts";
|
|
16
17
|
|
|
17
18
|
/** rule 定義の how_to_find がここを引く。rule 側は実装を知らない。 */
|
|
18
19
|
export const DETECTORS: Readonly<Record<string, Detector>> = {
|
|
@@ -39,6 +40,7 @@ export const DETECTORS: Readonly<Record<string, Detector>> = {
|
|
|
39
40
|
"rule-of-three": ruleOfThree,
|
|
40
41
|
"preamble-length": preambleLength,
|
|
41
42
|
"emoji-density": emojiDensity,
|
|
43
|
+
"dash-density": dashDensity,
|
|
42
44
|
"ngram-repetition": ngramRepetition,
|
|
43
45
|
"undefined-acronym": undefinedAcronym,
|
|
44
46
|
"concrete-evidence": concreteEvidence,
|
|
@@ -47,4 +49,8 @@ export const DETECTORS: Readonly<Record<string, Detector>> = {
|
|
|
47
49
|
"unqualified-superlative": unqualifiedSuperlative,
|
|
48
50
|
"repeated-conjunction": repeatedConjunction,
|
|
49
51
|
"ai-tell": aiTell,
|
|
52
|
+
"contraction-mix": contractionMix,
|
|
53
|
+
"internal-jargon": internalJargon,
|
|
54
|
+
"required-sections": requiredSections,
|
|
55
|
+
"proper-noun-density": properNounDensity,
|
|
50
56
|
};
|
package/src/detectors/lexicon.ts
CHANGED
|
@@ -122,3 +122,44 @@ export const aiTell: Detector = (doc, options): Finding[] => {
|
|
|
122
122
|
},
|
|
123
123
|
];
|
|
124
124
|
};
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* 同じことを言う 2 つの書きかたが、1 つの文書で混ざっているか。
|
|
128
|
+
*
|
|
129
|
+
* **どちらが正しいかは決めない。** 短縮形を使うかどうかは文体の選択で、
|
|
130
|
+
* 立場を取ると方針の違う書き手に rule ごと無視される。spec §12.3。
|
|
131
|
+
*
|
|
132
|
+
* 語彙表は対を持つ(`pattern` と `instead_of`)。片方だけを数えても、
|
|
133
|
+
* 意図した硬い文体なのか不統一なのかが分からない。
|
|
134
|
+
*/
|
|
135
|
+
/**
|
|
136
|
+
* 語の境界で照合する。部分一致だと「it isn't」が「it is」を含んでしまい、
|
|
137
|
+
* 短縮形を使っている文が「使っていない」側に数えられる。
|
|
138
|
+
*/
|
|
139
|
+
const containsWord = (text: string, word: string): boolean => new RegExp(`\\b${word.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&")}\\b`, "u").test(text);
|
|
140
|
+
|
|
141
|
+
export const contractionMix: Detector = (doc, options): Finding[] => {
|
|
142
|
+
const pairs = (options.lexicon ?? []).flatMap((entry) => (entry.instead_of === undefined ? [] : [{ short: entry.pattern, long: entry.instead_of }]));
|
|
143
|
+
const body = doc.sentences.map((sentence) => sentence.text.toLowerCase()).join(" ");
|
|
144
|
+
const used = pairs.filter((pair) => containsWord(body, pair.short.toLowerCase()));
|
|
145
|
+
const spelled = pairs.filter((pair) => containsWord(body, pair.long.toLowerCase()));
|
|
146
|
+
// 片方しか無ければ一貫している。両方あるときだけ、少数派を指摘する。
|
|
147
|
+
if (used.length === 0 || spelled.length === 0) return [];
|
|
148
|
+
const minorityIsShort = used.length <= spelled.length;
|
|
149
|
+
const few = minorityIsShort ? used : spelled;
|
|
150
|
+
if (few.length > options.limit) return [];
|
|
151
|
+
const wanted = few.map((pair) => (minorityIsShort ? pair.short : pair.long).toLowerCase());
|
|
152
|
+
const hits = doc.sentences.flatMap((sentence) => {
|
|
153
|
+
const text = sentence.text.toLowerCase();
|
|
154
|
+
const matched = wanted.find((word) => containsWord(text, word));
|
|
155
|
+
return matched === undefined ? [] : [{ sentence, matched }];
|
|
156
|
+
});
|
|
157
|
+
return hits.map((hit) => ({
|
|
158
|
+
rule: "contraction-consistency",
|
|
159
|
+
severity: "info",
|
|
160
|
+
line: 0,
|
|
161
|
+
column: 0,
|
|
162
|
+
quote: hit.sentence.text.trim(),
|
|
163
|
+
values: { matched: hit.matched, count: few.length, limit: options.limit, offset: hit.sentence.span.start },
|
|
164
|
+
}));
|
|
165
|
+
};
|
package/src/detectors/signals.ts
CHANGED
|
@@ -49,13 +49,16 @@ export const emojiDensity: Detector = (doc, options): Finding[] => {
|
|
|
49
49
|
* 同じ言い回しの繰り返し。character n-gram で見るので、語の区切りが要らない。
|
|
50
50
|
* spec §10 が n-gram を選んだのはこのためで、新しい言語でそのまま動く。
|
|
51
51
|
*/
|
|
52
|
-
|
|
52
|
+
/**
|
|
53
|
+
* 窓の幅は単位で分ける。同じ 8 文字でも、日本語では 4〜5 形態素だが英語では 1 語半にしかならない。
|
|
54
|
+
* 実文書(英語 11 本)で 8 文字にしたら、上位は " generat"(generate)のような**語の断片**だった。
|
|
55
|
+
*/
|
|
56
|
+
const GRAM = { char: 8, word: 20 };
|
|
53
57
|
|
|
54
|
-
|
|
55
|
-
const countGrams = (text: string): Map<string, number> => {
|
|
58
|
+
const countGrams = (text: string, width: number): Map<string, number> => {
|
|
56
59
|
const counts = new Map<string, number>();
|
|
57
|
-
Array.from({ length: Math.max(0, text.length -
|
|
58
|
-
const gram = text.slice(index, index +
|
|
60
|
+
Array.from({ length: Math.max(0, text.length - width + 1) }).forEach((_, index) => {
|
|
61
|
+
const gram = text.slice(index, index + width);
|
|
59
62
|
counts.set(gram, (counts.get(gram) ?? 0) + 1);
|
|
60
63
|
});
|
|
61
64
|
return counts;
|
|
@@ -69,7 +72,7 @@ const countGrams = (text: string): Map<string, number> => {
|
|
|
69
72
|
* どちらを見るかは adapter が宣言する単位で決める。ひらがなを持たない char 単位の言語が
|
|
70
73
|
* 来たら、この見分けは効かなくなる(そのときは adapter 側に判定を移す)。
|
|
71
74
|
*/
|
|
72
|
-
const CONNECTIVE = { char: /[ぁ-ゖ]/u, word:
|
|
75
|
+
const CONNECTIVE = { char: /[ぁ-ゖ]/u, word: /\S \S.*\S \S/u };
|
|
73
76
|
|
|
74
77
|
const isPhrasing = (gram: string, unit: ProseDocument["lengthUnit"]): boolean => CONNECTIVE[unit].test(gram);
|
|
75
78
|
|
|
@@ -78,7 +81,7 @@ const gramsOf = (doc: ProseDocument): Map<string, number> => {
|
|
|
78
81
|
const counts = new Map<string, number>();
|
|
79
82
|
doc.sentences.forEach((sentence) => {
|
|
80
83
|
const text = doc.lengthUnit === "char" ? proseText(sentence).replace(/\s+/gu, "") : proseText(sentence);
|
|
81
|
-
countGrams(text).forEach((count, gram) => counts.set(gram, (counts.get(gram) ?? 0) + count));
|
|
84
|
+
countGrams(text, GRAM[doc.lengthUnit]).forEach((count, gram) => counts.set(gram, (counts.get(gram) ?? 0) + count));
|
|
82
85
|
});
|
|
83
86
|
return counts;
|
|
84
87
|
};
|
|
@@ -143,14 +146,21 @@ const COMMON = new Set([
|
|
|
143
146
|
/**
|
|
144
147
|
* 展開は略語の**すぐ隣**にあるときだけ認める。
|
|
145
148
|
* 60 文字も見ると、同じ文のどこかに括弧があるだけで「説明済み」になり、1 件も出なくなる。
|
|
149
|
+
*
|
|
150
|
+
* 認めるのは 2 つの形。どちらも実際によく書かれる。
|
|
151
|
+
* CI(継続的インテグレーション) 略語のあとに括弧
|
|
152
|
+
* Continuous Integration (CI) 括弧の中が略語
|
|
146
153
|
*/
|
|
147
|
-
const
|
|
148
|
-
const
|
|
154
|
+
const OPENS = /^\s*[((]/u;
|
|
155
|
+
const CLOSES = /^\s*[))]/u;
|
|
156
|
+
const OPENED = /[((]\s*$/u;
|
|
149
157
|
|
|
150
158
|
const isExpanded = (body: string, acronym: string): boolean => {
|
|
151
159
|
const at = body.indexOf(acronym);
|
|
152
160
|
if (at === -1) return false;
|
|
153
|
-
|
|
161
|
+
const after = body.slice(at + acronym.length, at + acronym.length + 3);
|
|
162
|
+
const before = body.slice(Math.max(0, at - 3), at);
|
|
163
|
+
return OPENS.test(after) || (OPENED.test(before) && CLOSES.test(after));
|
|
154
164
|
};
|
|
155
165
|
|
|
156
166
|
export const undefinedAcronym: Detector = (doc, options): Finding[] => {
|
|
@@ -197,3 +207,26 @@ export const concreteEvidence: Detector = (doc, options): Finding[] => {
|
|
|
197
207
|
},
|
|
198
208
|
}));
|
|
199
209
|
};
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* ダッシュの多用。英語では正当な用法が多いが、2026 年時点で最も知られた生成文のシグナルでもある。
|
|
213
|
+
* 日本語の組版ではそもそも扱いが難しい。どちらが正しいかは severity の言語別指定で分ける。spec §12.4。
|
|
214
|
+
*/
|
|
215
|
+
const DASH = /[\u2014\u2015\u2013]/gu;
|
|
216
|
+
|
|
217
|
+
export const dashDensity: Detector = (doc, options): Finding[] => {
|
|
218
|
+
const hits = findIn(doc, DASH);
|
|
219
|
+
const first = hits[0];
|
|
220
|
+
const rate = density(doc, hits.length);
|
|
221
|
+
if (wordsOf(doc) < FLOOR[doc.lengthUnit] || first === undefined || rate <= options.limit) return [];
|
|
222
|
+
return [
|
|
223
|
+
{
|
|
224
|
+
rule: "no-em-dash",
|
|
225
|
+
severity: "info",
|
|
226
|
+
line: 0,
|
|
227
|
+
column: 0,
|
|
228
|
+
quote: first.sentence.text.trim(),
|
|
229
|
+
values: { count: hits.length, density: rate, limit: options.limit, offset: first.offset },
|
|
230
|
+
},
|
|
231
|
+
];
|
|
232
|
+
};
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { proseText } from "../measure.ts";
|
|
2
|
+
import { wordsOf } from "./structure.ts";
|
|
3
|
+
import type { Detector, Finding, Sentence, Token } from "../plugin.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* チームの言葉。社内でしか通じない語を、チームが chaff.yaml に自分で並べる。
|
|
7
|
+
*
|
|
8
|
+
* 何が社内用語かは組織ごとに違うので、chaff は語を持たない。並べる場所だけを用意する。
|
|
9
|
+
* 語彙表が空なら何も言わない(「用語を登録してください」とも言わない。押しつけない)。
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* 表層でも原形でも当てる。利用者は辞書形で書く(「握る」)が、本文は活用している(「握った」)。
|
|
13
|
+
* 品詞解析があれば原形で当たり、無くても表層で当たる。要求はしない。
|
|
14
|
+
*/
|
|
15
|
+
const matches = (sentence: Sentence, word: string): boolean => sentence.text.includes(word) || (sentence.tokens ?? []).some((token) => token.lemma === word);
|
|
16
|
+
|
|
17
|
+
export const internalJargon: Detector = (doc, options): Finding[] => {
|
|
18
|
+
const words = (options.lexicon ?? []).map((entry) => entry.pattern);
|
|
19
|
+
if (words.length === 0) return [];
|
|
20
|
+
const hits = doc.sentences.flatMap((sentence) => {
|
|
21
|
+
const matched = words.find((word) => matches(sentence, word));
|
|
22
|
+
return matched === undefined ? [] : [{ sentence, matched }];
|
|
23
|
+
});
|
|
24
|
+
if (hits.length < options.limit) return [];
|
|
25
|
+
return hits.map((hit) => ({
|
|
26
|
+
rule: "internal-jargon",
|
|
27
|
+
severity: "warning",
|
|
28
|
+
line: 0,
|
|
29
|
+
column: 0,
|
|
30
|
+
quote: hit.sentence.text.trim(),
|
|
31
|
+
values: { matched: hit.matched, count: hits.length, limit: options.limit, offset: hit.sentence.span.start },
|
|
32
|
+
}));
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const has = (headings: readonly string[], wanted: string): boolean => headings.some((heading) => heading.includes(wanted));
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* この種類の文書に無いと困る見出し。チームが chaff.yaml で決める。
|
|
39
|
+
*
|
|
40
|
+
* 「提案書にはリスクの節がある」のような取り決めは、組織ごとに違う。
|
|
41
|
+
* chaff は必須の見出しを持たず、**書かれた取り決めを守れているかだけ**を見る。
|
|
42
|
+
*/
|
|
43
|
+
export const requiredSections: Detector = (doc, options): Finding[] => {
|
|
44
|
+
const wanted = doc.requiredSections;
|
|
45
|
+
if (wanted.length === 0) return [];
|
|
46
|
+
const headings = doc.sections.map((section) => section.heading);
|
|
47
|
+
const missing = wanted.filter((name) => !has(headings, name));
|
|
48
|
+
if (missing.length < options.limit) return [];
|
|
49
|
+
return [
|
|
50
|
+
{
|
|
51
|
+
rule: "required-sections",
|
|
52
|
+
severity: "error",
|
|
53
|
+
line: 0,
|
|
54
|
+
column: 0,
|
|
55
|
+
quote: headings.filter((heading) => heading.length > 0).join(" / "),
|
|
56
|
+
values: { word: missing.join("、"), count: missing.length, limit: options.limit, offset: 0 },
|
|
57
|
+
},
|
|
58
|
+
];
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const PER = 1000;
|
|
62
|
+
|
|
63
|
+
/** 密度を見る rule は短い文書を測らない。単位は言語で違うので床も分ける。 */
|
|
64
|
+
const FLOOR = { word: 200, char: 500 };
|
|
65
|
+
|
|
66
|
+
const isProperNoun = (token: Token): boolean => token.pos === "PROPN";
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 固有名詞の密度。製品名・社名・人名が続くと、知らない読者は文の骨組みを見失う。
|
|
70
|
+
*
|
|
71
|
+
* spec は未知語率としていたが、**品詞解析があれば PROPN を数えるだけで足りる**。
|
|
72
|
+
* 辞書を別に持つ必要はない。
|
|
73
|
+
*/
|
|
74
|
+
export const properNounDensity: Detector = (doc, options): Finding[] => {
|
|
75
|
+
const hits = doc.sentences.flatMap((sentence) => (sentence.tokens ?? []).filter(isProperNoun).map((token) => ({ sentence, token })));
|
|
76
|
+
const length = wordsOf(doc);
|
|
77
|
+
const rate = length === 0 ? 0 : Math.round((hits.length / length) * PER);
|
|
78
|
+
const first = hits[0];
|
|
79
|
+
if (length < FLOOR[doc.lengthUnit] || first === undefined || rate <= options.limit) return [];
|
|
80
|
+
return [
|
|
81
|
+
{
|
|
82
|
+
rule: "proper-noun-density",
|
|
83
|
+
severity: "info",
|
|
84
|
+
line: 0,
|
|
85
|
+
column: 0,
|
|
86
|
+
quote: proseText(first.sentence),
|
|
87
|
+
values: { count: hits.length, density: rate, limit: options.limit, offset: first.token.span.start },
|
|
88
|
+
},
|
|
89
|
+
];
|
|
90
|
+
};
|
package/src/document.ts
CHANGED
|
@@ -76,7 +76,17 @@ const EMPHASIS = new Set(["strong", "emphasis", "delete"]);
|
|
|
76
76
|
*/
|
|
77
77
|
const DIRECTIVE = /^:::[^\n]*/gmu;
|
|
78
78
|
|
|
79
|
-
|
|
79
|
+
/**
|
|
80
|
+
* `https://…` をそのまま書いた URL。GFM の autolink 拡張を入れていないので mdast では
|
|
81
|
+
* ただのテキストになり、本文として残る。残すと、見出しと URL の中の識別子が一致して
|
|
82
|
+
* 「見出しの繰り返し」と読まれる。表示される文字も本文ではない。
|
|
83
|
+
*/
|
|
84
|
+
const BARE_URL = /https?:\/\/[^\s)<>"'\]]+/gu;
|
|
85
|
+
|
|
86
|
+
const matchSpans = (source: string, pattern: RegExp): Span[] =>
|
|
87
|
+
[...source.matchAll(pattern)].map((match) => ({ start: match.index, end: match.index + match[0].length }));
|
|
88
|
+
|
|
89
|
+
const directiveSpans = (source: string): Span[] => [...matchSpans(source, DIRECTIVE), ...matchSpans(source, BARE_URL)];
|
|
80
90
|
|
|
81
91
|
const collectMasks = (root: Node, source: string): Span[] => {
|
|
82
92
|
const spans: Span[] = [...directiveSpans(source)];
|
|
@@ -216,7 +226,18 @@ const listsOf = (root: Node, source: string): BulletList[] => {
|
|
|
216
226
|
return found;
|
|
217
227
|
};
|
|
218
228
|
|
|
219
|
-
|
|
229
|
+
/** チームが chaff.yaml に書いたもの。語彙表と同じ器に入れて、detector には出所を見せない。 */
|
|
230
|
+
export type TeamRules = { readonly jargon: readonly string[]; readonly requiredSections: readonly string[] };
|
|
231
|
+
|
|
232
|
+
const EMPTY_TEAM: TeamRules = { jargon: [], requiredSections: [] };
|
|
233
|
+
|
|
234
|
+
/** Config から取り出す。document は Config の形を知らない。 */
|
|
235
|
+
export const teamRules = (config: { readonly jargon: readonly string[]; readonly requiredSections: readonly string[] }): TeamRules => ({
|
|
236
|
+
jargon: config.jargon,
|
|
237
|
+
requiredSections: config.requiredSections,
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
export const buildDocument = (path: string, source: string, adapter: LanguageAdapter, team: TeamRules = EMPTY_TEAM): ProseDocument => {
|
|
220
241
|
const root = parse(source);
|
|
221
242
|
// 強調の記号は「本文でないもの」だが、太字の数を数えるときの「覆われた場所」ではない。
|
|
222
243
|
// 同じ集合にすると、太字が自分の記号のせいで覆われた場所にあることになり、1 つも数えられなくなる。
|
|
@@ -237,6 +258,7 @@ export const buildDocument = (path: string, source: string, adapter: LanguageAda
|
|
|
237
258
|
listSpans: listItems,
|
|
238
259
|
paragraphs: paragraphsOf(paragraphSpans, sentences, listItems),
|
|
239
260
|
lists: listsOf(root, source),
|
|
240
|
-
lexicons: adapter.lexicons,
|
|
261
|
+
lexicons: { ...adapter.lexicons, "internal-jargon": team.jargon.map((pattern) => ({ pattern })) },
|
|
262
|
+
requiredSections: team.requiredSections,
|
|
241
263
|
};
|
|
242
264
|
};
|
package/src/eval.ts
CHANGED
|
@@ -63,9 +63,21 @@ const recommend = (rule: RuleDefinition, sweep: readonly Point[], total: number)
|
|
|
63
63
|
return strictest;
|
|
64
64
|
};
|
|
65
65
|
|
|
66
|
+
/**
|
|
67
|
+
* lint で動かない rule は掃引しない。
|
|
68
|
+
* 動かないものの閾値を測ると、「どの閾値でも 0 件」が「よく校正されている」に見える。
|
|
69
|
+
*/
|
|
70
|
+
const measurable = (rule: RuleDefinition, docs: readonly ProseDocument[], genre: string, language: string): boolean => {
|
|
71
|
+
if (rule.layer === "L4" || !rule.use_for.some((target) => genre.startsWith(target))) return false;
|
|
72
|
+
if (rule.languages !== undefined && !rule.languages.includes(language)) return false;
|
|
73
|
+
if (rule.from.length > 0) return false;
|
|
74
|
+
const capabilities = docs[0]?.capabilities;
|
|
75
|
+
return !rule.requires.some((need) => (need === "lemma" ? capabilities?.lemma : capabilities?.pos) !== true);
|
|
76
|
+
};
|
|
77
|
+
|
|
66
78
|
export const evaluate = (docs: readonly ProseDocument[], rules: readonly RuleDefinition[], genre: string, language: string): RuleReport[] =>
|
|
67
79
|
rules
|
|
68
|
-
.filter((rule) => rule
|
|
80
|
+
.filter((rule) => measurable(rule, docs, genre, language))
|
|
69
81
|
.map((rule) => {
|
|
70
82
|
const sweep = candidates(rule).map((limit) => countAt(docs, rule, limit));
|
|
71
83
|
const current = rule.levels.normal ?? 1;
|
package/src/files.ts
CHANGED
|
@@ -26,7 +26,7 @@ const expand = (target: string): string[] => {
|
|
|
26
26
|
* ロケールを明示する。既定のロケールに任せると、同じ入力でも機械によって順序が変わる。
|
|
27
27
|
* 指摘の並びは CI のログにも baseline にも入るので、順序が揺れてはいけない。
|
|
28
28
|
*/
|
|
29
|
-
|
|
29
|
+
const byPath = (left: string, right: string): number => left.localeCompare(right, "en");
|
|
30
30
|
|
|
31
31
|
export const collectTargets = (targets: readonly string[]): string[] => {
|
|
32
32
|
const found = targets.flatMap(expand).filter((path) => !isSkipped(path));
|
package/src/levels.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Level, LevelTable, RuleDefinition } from "./plugin.ts";
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
const LEVELS: readonly Level[] = ["strict", "normal", "relaxed", "off"];
|
|
4
4
|
|
|
5
5
|
export const isLevel = (value: unknown): value is Level => typeof value === "string" && LEVELS.some((level) => level === value);
|
|
6
6
|
|
package/src/mask.ts
CHANGED
|
@@ -7,7 +7,14 @@ import type { Span } from "./plugin.ts";
|
|
|
7
7
|
* 切り出して繋ぐのではなく覆うのは、オフセットを元文字列と一致させ続けるため。
|
|
8
8
|
* 行・列の計算も、引用して見せる範囲も、これで元の位置のまま使える。
|
|
9
9
|
*/
|
|
10
|
-
|
|
10
|
+
/**
|
|
11
|
+
* 空白は**元の文字と同じ UTF-16 長**にする。
|
|
12
|
+
*
|
|
13
|
+
* `u` 付きの正規表現は 1 文字(コードポイント)ずつ当たるので、絵文字のような
|
|
14
|
+
* サロゲートペアを空白 1 つに置き換えると文字列が 1 だけ縮む。mdast のオフセットは
|
|
15
|
+
* UTF-16 単位なので、そこから先の指摘がすべて 1 ずれる。
|
|
16
|
+
*/
|
|
17
|
+
const blankOut = (text: string): string => text.replace(/[^\n]/gu, (char) => " ".repeat(char.length));
|
|
11
18
|
|
|
12
19
|
const merge = (spans: readonly Span[]): Span[] =>
|
|
13
20
|
[...spans]
|
package/src/measure.ts
CHANGED
|
@@ -13,7 +13,7 @@ export const proseText = (sentence: Sentence): string => sentence.text.replace(/
|
|
|
13
13
|
|
|
14
14
|
export const charLength = (sentence: Sentence): number => sentence.text.replace(/\s+/gu, "").length;
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
const wordLength = (sentence: Sentence): number =>
|
|
17
17
|
proseText(sentence)
|
|
18
18
|
.split(" ")
|
|
19
19
|
.filter((word) => word.length > 0).length;
|