chaffjs 0.0.1
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/LICENSE +21 -0
- package/README.md +76 -0
- package/bin/chaff.js +4 -0
- package/dist/adapter-load.d.ts +9 -0
- package/dist/adapter-load.d.ts.map +1 -0
- package/dist/adapter-load.js +38 -0
- package/dist/adapter-load.js.map +1 -0
- package/dist/baseline.d.ts +23 -0
- package/dist/baseline.d.ts.map +1 -0
- package/dist/baseline.js +41 -0
- package/dist/baseline.js.map +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +234 -0
- package/dist/cli.js.map +1 -0
- package/dist/config/load.d.ts +13 -0
- package/dist/config/load.d.ts.map +1 -0
- package/dist/config/load.js +26 -0
- package/dist/config/load.js.map +1 -0
- package/dist/config/write.d.ts +12 -0
- package/dist/config/write.d.ts.map +1 -0
- package/dist/config/write.js +60 -0
- package/dist/config/write.js.map +1 -0
- package/dist/detect-language.d.ts +3 -0
- package/dist/detect-language.d.ts.map +1 -0
- package/dist/detect-language.js +26 -0
- package/dist/detect-language.js.map +1 -0
- package/dist/detect.d.ts +7 -0
- package/dist/detect.d.ts.map +1 -0
- package/dist/detect.js +13 -0
- package/dist/detect.js.map +1 -0
- package/dist/detectors/count-per-section.d.ts +4 -0
- package/dist/detectors/count-per-section.d.ts.map +1 -0
- package/dist/detectors/count-per-section.js +13 -0
- package/dist/detectors/count-per-section.js.map +1 -0
- package/dist/detectors/heading-echo.d.ts +3 -0
- package/dist/detectors/heading-echo.d.ts.map +1 -0
- package/dist/detectors/heading-echo.js +36 -0
- package/dist/detectors/heading-echo.js.map +1 -0
- package/dist/detectors/index.d.ts +4 -0
- package/dist/detectors/index.d.ts.map +1 -0
- package/dist/detectors/index.js +16 -0
- package/dist/detectors/index.js.map +1 -0
- package/dist/detectors/phrase-match.d.ts +7 -0
- package/dist/detectors/phrase-match.d.ts.map +1 -0
- package/dist/detectors/phrase-match.js +38 -0
- package/dist/detectors/phrase-match.js.map +1 -0
- package/dist/detectors/repeated-head.d.ts +3 -0
- package/dist/detectors/repeated-head.d.ts.map +1 -0
- package/dist/detectors/repeated-head.js +21 -0
- package/dist/detectors/repeated-head.js.map +1 -0
- package/dist/detectors/sentence-length.d.ts +3 -0
- package/dist/detectors/sentence-length.d.ts.map +1 -0
- package/dist/detectors/sentence-length.js +19 -0
- package/dist/detectors/sentence-length.js.map +1 -0
- package/dist/detectors/sentence-rhythm.d.ts +3 -0
- package/dist/detectors/sentence-rhythm.d.ts.map +1 -0
- package/dist/detectors/sentence-rhythm.js +28 -0
- package/dist/detectors/sentence-rhythm.js.map +1 -0
- package/dist/document.d.ts +3 -0
- package/dist/document.d.ts.map +1 -0
- package/dist/document.js +138 -0
- package/dist/document.js.map +1 -0
- package/dist/files.d.ts +12 -0
- package/dist/files.d.ts.map +1 -0
- package/dist/files.js +30 -0
- package/dist/files.js.map +1 -0
- package/dist/genre.d.ts +8 -0
- package/dist/genre.d.ts.map +1 -0
- package/dist/genre.js +35 -0
- package/dist/genre.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/init.d.ts +3 -0
- package/dist/init.d.ts.map +1 -0
- package/dist/init.js +71 -0
- package/dist/init.js.map +1 -0
- package/dist/levels.d.ts +20 -0
- package/dist/levels.d.ts.map +1 -0
- package/dist/levels.js +24 -0
- package/dist/levels.js.map +1 -0
- package/dist/mask.d.ts +3 -0
- package/dist/mask.d.ts.map +1 -0
- package/dist/mask.js +26 -0
- package/dist/mask.js.map +1 -0
- package/dist/plugin.d.ts +116 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/plugin.js +4 -0
- package/dist/plugin.js.map +1 -0
- package/dist/position.d.ts +8 -0
- package/dist/position.d.ts.map +1 -0
- package/dist/position.js +7 -0
- package/dist/position.js.map +1 -0
- package/dist/render/compact.d.ts +5 -0
- package/dist/render/compact.d.ts.map +1 -0
- package/dist/render/compact.js +15 -0
- package/dist/render/compact.js.map +1 -0
- package/dist/render/explain.d.ts +4 -0
- package/dist/render/explain.d.ts.map +1 -0
- package/dist/render/explain.js +29 -0
- package/dist/render/explain.js.map +1 -0
- package/dist/render/friendly.d.ts +4 -0
- package/dist/render/friendly.d.ts.map +1 -0
- package/dist/render/friendly.js +55 -0
- package/dist/render/friendly.js.map +1 -0
- package/dist/render/rules-json.d.ts +6 -0
- package/dist/render/rules-json.d.ts.map +1 -0
- package/dist/render/rules-json.js +39 -0
- package/dist/render/rules-json.js.map +1 -0
- package/dist/render/summary.d.ts +10 -0
- package/dist/render/summary.d.ts.map +1 -0
- package/dist/render/summary.js +18 -0
- package/dist/render/summary.js.map +1 -0
- package/dist/render/suppressions.d.ts +13 -0
- package/dist/render/suppressions.d.ts.map +1 -0
- package/dist/render/suppressions.js +50 -0
- package/dist/render/suppressions.js.map +1 -0
- package/dist/render/text.d.ts +6 -0
- package/dist/render/text.d.ts.map +1 -0
- package/dist/render/text.js +8 -0
- package/dist/render/text.js.map +1 -0
- package/dist/rule-load.d.ts +3 -0
- package/dist/rule-load.d.ts.map +1 -0
- package/dist/rule-load.js +68 -0
- package/dist/rule-load.js.map +1 -0
- package/dist/run.d.ts +14 -0
- package/dist/run.d.ts.map +1 -0
- package/dist/run.js +47 -0
- package/dist/run.js.map +1 -0
- package/dist/stet.d.ts +19 -0
- package/dist/stet.d.ts.map +1 -0
- package/dist/stet.js +61 -0
- package/dist/stet.js.map +1 -0
- package/dist/watch.d.ts +12 -0
- package/dist/watch.d.ts.map +1 -0
- package/dist/watch.js +40 -0
- package/dist/watch.js.map +1 -0
- package/package.json +63 -0
- package/rules/bold-density.yaml +28 -0
- package/rules/closing-cliche.yaml +30 -0
- package/rules/empty-intensifier.yaml +29 -0
- package/rules/heading-echo.yaml +30 -0
- package/rules/max-sentence-length.yaml +28 -0
- package/rules/padded-intro.yaml +30 -0
- package/rules/repeated-sentence-head.yaml +28 -0
- package/rules/sentence-rhythm.yaml +30 -0
- package/src/adapter-load.ts +31 -0
- package/src/baseline.ts +51 -0
- package/src/cli.ts +259 -0
- package/src/config/load.ts +38 -0
- package/src/config/write.ts +63 -0
- package/src/detect-language.ts +28 -0
- package/src/detect.ts +15 -0
- package/src/detectors/count-per-section.ts +15 -0
- package/src/detectors/heading-echo.ts +40 -0
- package/src/detectors/index.ts +17 -0
- package/src/detectors/phrase-match.ts +41 -0
- package/src/detectors/repeated-head.ts +29 -0
- package/src/detectors/sentence-length.ts +21 -0
- package/src/detectors/sentence-rhythm.ts +28 -0
- package/src/document.ts +154 -0
- package/src/files.ts +34 -0
- package/src/genre.ts +36 -0
- package/src/index.ts +4 -0
- package/src/init.ts +76 -0
- package/src/levels.ts +27 -0
- package/src/mask.ts +33 -0
- package/src/plugin.ts +124 -0
- package/src/position.ts +9 -0
- package/src/render/compact.ts +18 -0
- package/src/render/explain.ts +31 -0
- package/src/render/friendly.ts +62 -0
- package/src/render/rules-json.ts +46 -0
- package/src/render/summary.ts +22 -0
- package/src/render/suppressions.ts +57 -0
- package/src/render/text.ts +13 -0
- package/src/rule-load.ts +78 -0
- package/src/run.ts +61 -0
- package/src/stet.ts +79 -0
- package/src/watch.ts +52 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
id: closing-cliche
|
|
2
|
+
layer: L2
|
|
3
|
+
status: stable
|
|
4
|
+
how_to_find: phrase-match
|
|
5
|
+
word_list: closing-cliche
|
|
6
|
+
where: closing
|
|
7
|
+
severity: warning
|
|
8
|
+
|
|
9
|
+
name:
|
|
10
|
+
ja: 定型の結び
|
|
11
|
+
en: Cliched closing
|
|
12
|
+
|
|
13
|
+
why:
|
|
14
|
+
ja: 「いかがでしたか」で終わる記事は、読者に何も残しません。結びは最後の主張の場所です。
|
|
15
|
+
en: A closing that only thanks the reader leaves nothing behind.
|
|
16
|
+
|
|
17
|
+
message:
|
|
18
|
+
ja: 「{matched}」で締めています
|
|
19
|
+
en: 'Closes with "{matched}"'
|
|
20
|
+
|
|
21
|
+
how_to_fix:
|
|
22
|
+
ja: 本文で分かったことのうち、いちばん持ち帰ってほしい 1 つを書いてください。
|
|
23
|
+
en: Name the one thing you want the reader to take away.
|
|
24
|
+
|
|
25
|
+
levels:
|
|
26
|
+
strict: 1
|
|
27
|
+
normal: 1
|
|
28
|
+
relaxed: 2
|
|
29
|
+
|
|
30
|
+
use_for: [blog]
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
id: empty-intensifier
|
|
2
|
+
layer: L2
|
|
3
|
+
status: stable
|
|
4
|
+
how_to_find: phrase-match
|
|
5
|
+
word_list: empty-intensifier
|
|
6
|
+
severity: warning
|
|
7
|
+
|
|
8
|
+
name:
|
|
9
|
+
ja: 空虚な強調
|
|
10
|
+
en: Empty intensifier
|
|
11
|
+
|
|
12
|
+
why:
|
|
13
|
+
ja: 「非常に重要」は、何がどう重要なのかを言っていません。強調した気になるだけです。
|
|
14
|
+
en: '"Extremely important" does not say what matters or why. It only feels emphatic.'
|
|
15
|
+
|
|
16
|
+
message:
|
|
17
|
+
ja: 「{matched}」は、中身を言わずに強調だけしています
|
|
18
|
+
en: '"{matched}" emphasises without saying anything'
|
|
19
|
+
|
|
20
|
+
how_to_fix:
|
|
21
|
+
ja: 何がどう重要なのかを書いてください。書けないなら、その一文は落とせます。
|
|
22
|
+
en: Say what matters and why. If you cannot, the sentence can go.
|
|
23
|
+
|
|
24
|
+
levels:
|
|
25
|
+
strict: 1
|
|
26
|
+
normal: 1
|
|
27
|
+
relaxed: 3
|
|
28
|
+
|
|
29
|
+
use_for: [blog, business]
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
id: heading-echo
|
|
2
|
+
layer: L1
|
|
3
|
+
status: stable
|
|
4
|
+
how_to_find: heading-echo
|
|
5
|
+
severity: warning
|
|
6
|
+
|
|
7
|
+
name:
|
|
8
|
+
ja: 見出しの繰り返し
|
|
9
|
+
en: Heading echoed
|
|
10
|
+
|
|
11
|
+
why:
|
|
12
|
+
ja: 見出しで言ったことを次の文が繰り返すと、読者はそこで何も受け取れません。
|
|
13
|
+
en: When the first sentence repeats the heading, the reader gains nothing by reading on.
|
|
14
|
+
|
|
15
|
+
message:
|
|
16
|
+
ja: 見出し「{heading}」を直後の文がほぼそのまま繰り返しています
|
|
17
|
+
en: The first sentence repeats the heading "{heading}"
|
|
18
|
+
|
|
19
|
+
how_to_fix:
|
|
20
|
+
ja: 見出しが約束したことの中身から書き始めてください。
|
|
21
|
+
en: Start from what the heading promised, not from the heading itself.
|
|
22
|
+
|
|
23
|
+
# 見出しの文字 3-gram のうち、直後の文に現れた割合(包含率)。100 分率で持つ。
|
|
24
|
+
# Jaccard ではない理由は detectors/heading-echo.ts を参照。
|
|
25
|
+
levels:
|
|
26
|
+
strict: 45
|
|
27
|
+
normal: 60
|
|
28
|
+
relaxed: 75
|
|
29
|
+
|
|
30
|
+
use_for: [blog, business]
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
id: max-sentence-length
|
|
2
|
+
layer: L1
|
|
3
|
+
status: stable
|
|
4
|
+
how_to_find: sentence-length
|
|
5
|
+
severity: warning
|
|
6
|
+
|
|
7
|
+
name:
|
|
8
|
+
ja: 一文が長すぎる
|
|
9
|
+
en: Sentence too long
|
|
10
|
+
|
|
11
|
+
why:
|
|
12
|
+
ja: 長い文は、読んでいるうちに主語を見失います。
|
|
13
|
+
en: In a long sentence the reader loses the subject before reaching the verb.
|
|
14
|
+
|
|
15
|
+
message:
|
|
16
|
+
ja: この文は {count} 文字あります({limit} 文字まで)
|
|
17
|
+
en: "This sentence runs {count} words (limit {limit})"
|
|
18
|
+
|
|
19
|
+
how_to_fix:
|
|
20
|
+
ja: 接続助詞のところで 2 文に割ってください。それだけで読めるようになります。
|
|
21
|
+
en: Split it in two at the conjunction.
|
|
22
|
+
|
|
23
|
+
# 単位は adapter の lengthUnit。日本語は文字、英語は語。spec §7。
|
|
24
|
+
levels:
|
|
25
|
+
ja: { strict: 70, normal: 100, relaxed: 140 }
|
|
26
|
+
en: { strict: 18, normal: 25, relaxed: 35 }
|
|
27
|
+
|
|
28
|
+
use_for: [blog, business]
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
id: padded-intro
|
|
2
|
+
layer: L2
|
|
3
|
+
status: stable
|
|
4
|
+
how_to_find: phrase-match
|
|
5
|
+
word_list: padded-intro
|
|
6
|
+
where: opening
|
|
7
|
+
severity: warning
|
|
8
|
+
|
|
9
|
+
name:
|
|
10
|
+
ja: 水増しの導入
|
|
11
|
+
en: Padded opening
|
|
12
|
+
|
|
13
|
+
why:
|
|
14
|
+
ja: どの記事にも当てはまる書き出しは、読者に次を読む理由を与えません。
|
|
15
|
+
en: An opening that fits any article gives the reader no reason to continue.
|
|
16
|
+
|
|
17
|
+
message:
|
|
18
|
+
ja: 「{matched}」は、どの記事にも当てはまる書き出しです
|
|
19
|
+
en: '"{matched}" is an opening that fits any article'
|
|
20
|
+
|
|
21
|
+
how_to_fix:
|
|
22
|
+
ja: 具体的な状況か、この記事だけの主張から始めてください。
|
|
23
|
+
en: Start from a concrete situation, or from the claim only this piece makes.
|
|
24
|
+
|
|
25
|
+
levels:
|
|
26
|
+
strict: 1
|
|
27
|
+
normal: 1
|
|
28
|
+
relaxed: 2
|
|
29
|
+
|
|
30
|
+
use_for: [blog]
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
id: repeated-sentence-head
|
|
2
|
+
layer: L1
|
|
3
|
+
status: stable
|
|
4
|
+
how_to_find: repeated-head
|
|
5
|
+
severity: warning
|
|
6
|
+
|
|
7
|
+
name:
|
|
8
|
+
ja: 同じ書き出しの連続
|
|
9
|
+
en: Repeated sentence opening
|
|
10
|
+
|
|
11
|
+
why:
|
|
12
|
+
ja: 同じ書き出しが続くと、読者は文の切れ目を見失って読み飛ばします。
|
|
13
|
+
en: Identical openings blur the boundary between sentences.
|
|
14
|
+
|
|
15
|
+
message:
|
|
16
|
+
ja: 「{head}」で始まる文が {count} 続いています({limit} まで)
|
|
17
|
+
en: '{count} sentences in a row open with "{head}" (limit {limit})'
|
|
18
|
+
|
|
19
|
+
how_to_fix:
|
|
20
|
+
ja: 語順を変えるか、1 文を前の文に繋げてください。
|
|
21
|
+
en: Reorder, or fold one sentence into the one before it.
|
|
22
|
+
|
|
23
|
+
levels:
|
|
24
|
+
strict: 2
|
|
25
|
+
normal: 3
|
|
26
|
+
relaxed: 4
|
|
27
|
+
|
|
28
|
+
use_for: [blog, business]
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
id: sentence-rhythm
|
|
2
|
+
# 「AI 生成は文の長さが揃う」は未検証の仮説。corpus 評価まで既定では動かさない。spec §26-4。
|
|
3
|
+
status: experimental
|
|
4
|
+
layer: L1
|
|
5
|
+
how_to_find: sentence-rhythm
|
|
6
|
+
severity: warning
|
|
7
|
+
|
|
8
|
+
name:
|
|
9
|
+
ja: 文の長さが単調
|
|
10
|
+
en: Monotonous sentence rhythm
|
|
11
|
+
|
|
12
|
+
why:
|
|
13
|
+
ja: どの文も同じ長さだと、読者は途中で読むリズムを失います。
|
|
14
|
+
en: When every sentence is the same length, the reader loses the rhythm.
|
|
15
|
+
|
|
16
|
+
message:
|
|
17
|
+
ja: 文の長さのばらつきが {count}%({limit}% 以上ほしい)
|
|
18
|
+
en: "Sentence length varies by only {count}% (want {limit}%)"
|
|
19
|
+
|
|
20
|
+
how_to_fix:
|
|
21
|
+
ja: 短い文をいくつか混ぜてください。1 文を 2 つに割るだけで変わります。
|
|
22
|
+
en: Mix in short sentences. Splitting one in two is enough.
|
|
23
|
+
|
|
24
|
+
# 文長の変動係数を 100 分率で持つ。下回ったら指摘する。
|
|
25
|
+
levels:
|
|
26
|
+
strict: 35
|
|
27
|
+
normal: 30
|
|
28
|
+
relaxed: 20
|
|
29
|
+
|
|
30
|
+
use_for: [blog]
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { LanguageAdapter } from "./plugin.ts";
|
|
2
|
+
|
|
3
|
+
const ADAPTER_PACKAGE: Readonly<Record<string, string>> = { ja: "@chaffjs/lang-ja", en: "@chaffjs/lang-en" };
|
|
4
|
+
|
|
5
|
+
const isAdapter = (value: unknown): value is LanguageAdapter => {
|
|
6
|
+
if (typeof value !== "object" || value === null) return false;
|
|
7
|
+
const record: Record<string, unknown> = { ...value };
|
|
8
|
+
return record["kind"] === "language" && typeof record["id"] === "string" && typeof record["segment"] === "function";
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const pickExport = (module: object): unknown => {
|
|
12
|
+
const record: Record<string, unknown> = { ...module };
|
|
13
|
+
return isAdapter(record["adapter"]) ? record["adapter"] : record["default"];
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export const packageFor = (language: string): string | undefined => ADAPTER_PACKAGE[language];
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* アダプタは「必要になったものだけ」を実行時に解決する。spec §17.2。
|
|
20
|
+
* core だけで起動し、文字種で言語を当ててから、その言語のアダプタを読む。
|
|
21
|
+
* 全言語を静的に import すると、使わない言語のぶんまで npx の初回取得が膨らむ。
|
|
22
|
+
*/
|
|
23
|
+
export const loadAdapter = async (language: string): Promise<LanguageAdapter> => {
|
|
24
|
+
const specifier = packageFor(language);
|
|
25
|
+
if (specifier === undefined) throw new Error(`no adapter for language "${language}"`);
|
|
26
|
+
const module: unknown = await import(specifier);
|
|
27
|
+
if (typeof module !== "object" || module === null) throw new Error(`${specifier} did not export a module`);
|
|
28
|
+
const candidate = pickExport(module);
|
|
29
|
+
if (!isAdapter(candidate)) throw new Error(`${specifier} does not export a LanguageAdapter`);
|
|
30
|
+
return candidate;
|
|
31
|
+
};
|
package/src/baseline.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import type { Finding } from "./plugin.ts";
|
|
4
|
+
|
|
5
|
+
export const BASELINE_FILE = ".chaff-baseline.json";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 行番号ではなく内容で同定する。前後に段落を 1 つ足しただけで棚上げが全部剥がれると、
|
|
9
|
+
* baseline は使い物にならない。workflow spec §8。
|
|
10
|
+
*/
|
|
11
|
+
export const fingerprint = (path: string, finding: Finding): string => {
|
|
12
|
+
const body = [path, finding.rule, finding.quote.replace(/\s+/gu, " ").trim()].join(" ");
|
|
13
|
+
return createHash("sha256").update(body).digest("hex").slice(0, 16);
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type Baseline = { readonly version: 1; readonly created: string; readonly entries: readonly string[] };
|
|
17
|
+
|
|
18
|
+
const isBaseline = (value: unknown): value is Baseline =>
|
|
19
|
+
typeof value === "object" &&
|
|
20
|
+
value !== null &&
|
|
21
|
+
"entries" in value &&
|
|
22
|
+
Array.isArray(value.entries) &&
|
|
23
|
+
value.entries.every((entry) => typeof entry === "string");
|
|
24
|
+
|
|
25
|
+
export const readBaseline = (path: string): Baseline | undefined => {
|
|
26
|
+
if (!existsSync(path)) return undefined;
|
|
27
|
+
const raw: unknown = JSON.parse(readFileSync(path, "utf8"));
|
|
28
|
+
return isBaseline(raw) ? raw : undefined;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const writeBaseline = (path: string, entries: readonly string[]): void => {
|
|
32
|
+
const sorted = [...entries].sort((left, right) => left.localeCompare(right, "en"));
|
|
33
|
+
const body = { version: 1, created: new Date().toISOString().slice(0, 10), entries: sorted };
|
|
34
|
+
writeFileSync(path, `${JSON.stringify(body, null, 2)}\n`, "utf8");
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export type Split = { readonly fresh: readonly Finding[]; readonly shelved: number };
|
|
38
|
+
|
|
39
|
+
/** baseline にあるものは報告しない。新しく増えたものだけを出す。 */
|
|
40
|
+
export const splitByBaseline = (path: string, findings: readonly Finding[], baseline: Baseline | undefined): Split => {
|
|
41
|
+
if (baseline === undefined) return { fresh: findings, shelved: 0 };
|
|
42
|
+
const known = new Set(baseline.entries);
|
|
43
|
+
const fresh = findings.filter((finding) => !known.has(fingerprint(path, finding)));
|
|
44
|
+
return { fresh, shelved: findings.length - fresh.length };
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** 直った分だけを落とす。増える方向には動かさない。 */
|
|
48
|
+
export const prune = (previous: Baseline, stillPresent: readonly string[]): string[] => {
|
|
49
|
+
const present = new Set(stillPresent);
|
|
50
|
+
return previous.entries.filter((entry) => present.has(entry));
|
|
51
|
+
};
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { loadAdapter, packageFor } from "./adapter-load.ts";
|
|
5
|
+
import { CONFIG_FILE, EMPTY, loadConfig, type Config } from "./config/load.ts";
|
|
6
|
+
import { applyLevel } from "./config/write.ts";
|
|
7
|
+
import { buildDocument } from "./document.ts";
|
|
8
|
+
import { guessLanguage } from "./detect.ts";
|
|
9
|
+
import { collectTargets } from "./files.ts";
|
|
10
|
+
import { BASELINE_FILE, fingerprint, readBaseline, splitByBaseline, writeBaseline } from "./baseline.ts";
|
|
11
|
+
import { applySuppressions } from "./stet.ts";
|
|
12
|
+
import { renderSuppressions, type PerFile } from "./render/suppressions.ts";
|
|
13
|
+
import { clock, describeChange, snapshotOf, watchPaths, type Snapshot } from "./watch.ts";
|
|
14
|
+
import { frontMatterGenre, guessGenre } from "./genre.ts";
|
|
15
|
+
import { GENRES, runInit } from "./init.ts";
|
|
16
|
+
import { loadRules } from "./rule-load.ts";
|
|
17
|
+
import { renderCompact } from "./render/compact.ts";
|
|
18
|
+
import { renderExplain } from "./render/explain.ts";
|
|
19
|
+
import { renderFriendly } from "./render/friendly.ts";
|
|
20
|
+
import { rulesJson } from "./render/rules-json.ts";
|
|
21
|
+
import { renderSummary, type FileOutcome } from "./render/summary.ts";
|
|
22
|
+
import { runRules } from "./run.ts";
|
|
23
|
+
import type { Level, RuleDefinition } from "./plugin.ts";
|
|
24
|
+
|
|
25
|
+
const USAGE = `chaff — 文章の読みにくいところを見つけます。文章は書き換えません。
|
|
26
|
+
|
|
27
|
+
chaff <file|dir|glob>... 検査する(設定も API key も要りません)
|
|
28
|
+
chaff . この場所の Markdown を全部
|
|
29
|
+
chaff init chaff.yaml を作る
|
|
30
|
+
chaff explain <rule> そのルールの意図と根拠を読む
|
|
31
|
+
chaff genres ジャンルの一覧
|
|
32
|
+
chaff rules --json いまの設定を JSON で出す(AI に渡す用)
|
|
33
|
+
chaff baseline <dir> いまある指摘を棚上げする(既存の repo に入れるとき)
|
|
34
|
+
chaff suppressions <dir> stet で黙らせている指摘を数える
|
|
35
|
+
chaff relax|strict|off <rule> [--why "理由"]
|
|
36
|
+
|
|
37
|
+
--compact エンジニア向けの 1 行形式
|
|
38
|
+
--experimental 試験中の rule も動かす
|
|
39
|
+
--show-baseline 棚上げした分も含めて全部見る
|
|
40
|
+
--watch 保存のたびに見直し、変わったところだけ出す
|
|
41
|
+
|
|
42
|
+
この箇所だけ黙らせる: <!-- stet: rule-id — 理由 -->
|
|
43
|
+
|
|
44
|
+
値は strict / normal / relaxed / off の 4 つから選びます。
|
|
45
|
+
`;
|
|
46
|
+
|
|
47
|
+
const readConfig = (): Config => (existsSync(join(process.cwd(), CONFIG_FILE)) ? loadConfig(join(process.cwd(), CONFIG_FILE)) : EMPTY);
|
|
48
|
+
|
|
49
|
+
const resolveGenre = (path: string, source: string, config: Config): { genre: string; from: string } => {
|
|
50
|
+
if (config.genre !== undefined) return { genre: config.genre, from: CONFIG_FILE };
|
|
51
|
+
const guess = guessGenre(path, source, frontMatterGenre(source));
|
|
52
|
+
return guess === undefined ? { genre: "blog/tech", from: "既定" } : { genre: guess.genre, from: guess.from };
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const findRule = (rules: readonly RuleDefinition[], id: string | undefined): RuleDefinition | undefined => rules.find((rule) => rule.id === id);
|
|
56
|
+
|
|
57
|
+
const changeSetting = (command: Level, ruleId: string | undefined, why: string | undefined): number => {
|
|
58
|
+
const config = readConfig();
|
|
59
|
+
const rule = findRule(loadRules(config.language ?? "ja"), ruleId);
|
|
60
|
+
if (rule === undefined) {
|
|
61
|
+
console.error(`${ruleId ?? "(名前なし)"} というルールはありません。npx chaff rules --json で一覧が出ます。`);
|
|
62
|
+
return 1;
|
|
63
|
+
}
|
|
64
|
+
const outcome = applyLevel(join(process.cwd(), CONFIG_FILE), rule, command, why, config.language ?? "ja", process.env["USER"] ?? "unknown");
|
|
65
|
+
console.log(outcome.message);
|
|
66
|
+
return outcome.ok ? 0 : 1;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const flag = (argv: readonly string[], name: string): string | undefined => {
|
|
70
|
+
const at = argv.indexOf(name);
|
|
71
|
+
return at === -1 ? undefined : argv[at + 1];
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
type Inspected = { readonly text: string; readonly outcome: FileOutcome; readonly perFile: PerFile; readonly all: readonly string[] };
|
|
75
|
+
|
|
76
|
+
const headerFor = (path: string, genre: string, from: string, language: string, shelved: number, hushed: number): string => {
|
|
77
|
+
const shelf = shelved > 0 ? ` 棚上げ ${shelved} 件` : "";
|
|
78
|
+
const stet = hushed > 0 ? ` stet ${hushed} 件` : "";
|
|
79
|
+
return `${path} ${genre} \u00b7 ${language === "ja" ? "日本語" : "英語"} ジャンルは${from}から${shelf}${stet}`;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const inspect = async (path: string, config: Config, argv: readonly string[]): Promise<Inspected> => {
|
|
83
|
+
const source = await readFile(path, "utf8");
|
|
84
|
+
const language = config.language ?? guessLanguage(source).language;
|
|
85
|
+
const adapter = await loadAdapter(language);
|
|
86
|
+
const { genre, from } = resolveGenre(path, source, config);
|
|
87
|
+
const doc = buildDocument(path, source, adapter);
|
|
88
|
+
const rules = loadRules(language);
|
|
89
|
+
const raw = runRules(doc, rules, config.rules, config.experimental || argv.includes("--experimental"), genre);
|
|
90
|
+
// 応答は 3 つ。stet で黙らせたものは、ここで落とす。
|
|
91
|
+
const applied = applySuppressions(
|
|
92
|
+
source,
|
|
93
|
+
raw.findings,
|
|
94
|
+
doc.sections.map((section) => section.span),
|
|
95
|
+
);
|
|
96
|
+
const baseline = argv.includes("--show-baseline") ? undefined : readBaseline(join(process.cwd(), BASELINE_FILE));
|
|
97
|
+
const split = splitByBaseline(path, applied.kept, baseline);
|
|
98
|
+
const result = { ...raw, findings: split.fresh };
|
|
99
|
+
const header = headerFor(path, genre, from, language, split.shelved, applied.suppressed.length);
|
|
100
|
+
const text = argv.includes("--compact") ? renderCompact(header, result, rules, language) : renderFriendly(header, result, rules, language);
|
|
101
|
+
return {
|
|
102
|
+
text,
|
|
103
|
+
outcome: { path, findings: split.fresh, notRun: raw.skipped.length },
|
|
104
|
+
perFile: { path, suppressed: applied.suppressed, reasonless: applied.unusedReasonless },
|
|
105
|
+
all: applied.kept.map((finding) => fingerprint(path, finding)),
|
|
106
|
+
};
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const lint = async (targets: readonly string[], argv: readonly string[]): Promise<number> => {
|
|
110
|
+
const paths = collectTargets(targets);
|
|
111
|
+
if (paths.length === 0) {
|
|
112
|
+
// 0 件を成功にすると「CI は通っているが何も検証していない」状態が続く。§14。
|
|
113
|
+
console.error(`Markdown が 1 つも見つかりませんでした: ${targets.join(", ")}`);
|
|
114
|
+
return 1;
|
|
115
|
+
}
|
|
116
|
+
const config = readConfig();
|
|
117
|
+
const language = config.language ?? "ja";
|
|
118
|
+
if (packageFor(language) === undefined) {
|
|
119
|
+
console.error(`言語 "${language}" のアダプタがありません。`);
|
|
120
|
+
return 1;
|
|
121
|
+
}
|
|
122
|
+
const results = await Promise.all(paths.map((path) => inspect(path, config, argv)));
|
|
123
|
+
results.filter((result) => result.outcome.findings.length > 0 || paths.length === 1).forEach((result) => console.log(result.text));
|
|
124
|
+
renderSummary(results.map((result) => result.outcome)).forEach((line) => console.log(line));
|
|
125
|
+
return results.some((result) => result.outcome.findings.some((finding) => finding.severity === "error")) ? 1 : 0;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* 書いている最中は、全件を出し直されても何が変わったのか分からない。
|
|
130
|
+
* 差分だけを出す。workflow spec §11。
|
|
131
|
+
*/
|
|
132
|
+
const runWatch = async (targets: readonly string[], argv: readonly string[]): Promise<number> => {
|
|
133
|
+
const paths = collectTargets(targets);
|
|
134
|
+
if (paths.length === 0) {
|
|
135
|
+
console.error(`Markdown が 1 つも見つかりませんでした: ${targets.join(", ")}`);
|
|
136
|
+
return 1;
|
|
137
|
+
}
|
|
138
|
+
const config = readConfig();
|
|
139
|
+
const seen = new Map<string, Snapshot>();
|
|
140
|
+
const once = async (path: string, first: boolean): Promise<void> => {
|
|
141
|
+
const result = await inspect(path, config, argv);
|
|
142
|
+
const after = snapshotOf(result.outcome.findings);
|
|
143
|
+
const before = seen.get(path) ?? {};
|
|
144
|
+
seen.set(path, after);
|
|
145
|
+
if (first) return;
|
|
146
|
+
const change = describeChange(before, after);
|
|
147
|
+
console.log(`${clock()} ${path} ${change ?? "変わりませんでした"}`);
|
|
148
|
+
};
|
|
149
|
+
await Promise.all(paths.map((path) => once(path, true)));
|
|
150
|
+
const totals = [...seen.values()].reduce((sum, snapshot) => sum + Object.values(snapshot).reduce((inner, count) => inner + count, 0), 0);
|
|
151
|
+
console.log(`\n ${paths.length} ファイルを見ています。いまの指摘は ${totals} 件です。`);
|
|
152
|
+
console.log(" 保存するたびに、変わったところだけ出します。止めるには Ctrl-C。\n");
|
|
153
|
+
const stop = watchPaths(paths, (path) => void once(path, false));
|
|
154
|
+
process.on("SIGINT", () => {
|
|
155
|
+
stop();
|
|
156
|
+
process.exit(0);
|
|
157
|
+
});
|
|
158
|
+
return new Promise(() => undefined);
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const explain = (ruleId: string | undefined): number => {
|
|
162
|
+
const config = readConfig();
|
|
163
|
+
const language = config.language ?? "ja";
|
|
164
|
+
const rules = loadRules(language);
|
|
165
|
+
const rule = findRule(rules, ruleId);
|
|
166
|
+
if (rule === undefined) {
|
|
167
|
+
const list = rules.map((entry) => ` ${entry.id}`).join("\n");
|
|
168
|
+
console.error(`${ruleId ?? "(名前なし)"} というルールはありません。\n一覧:\n${list}`);
|
|
169
|
+
return 1;
|
|
170
|
+
}
|
|
171
|
+
const current = config.rules[rule.id] ?? (rule.status === "experimental" && !config.experimental ? "off" : "normal");
|
|
172
|
+
console.log(renderExplain(rule, current, language, unitOf(rule.id, language)));
|
|
173
|
+
return 0;
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
/** 閾値の単位。explain で「4 とは何の 4 か」を示すため。 */
|
|
177
|
+
const unitOf = (ruleId: string, language: string): string => {
|
|
178
|
+
if (ruleId !== "max-sentence-length") return "回";
|
|
179
|
+
return language === "ja" ? "文字" : "語";
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
const runBaseline = async (targets: readonly string[], argv: readonly string[]): Promise<number> => {
|
|
183
|
+
const paths = collectTargets(targets.length > 0 ? targets : ["."]);
|
|
184
|
+
if (paths.length === 0) {
|
|
185
|
+
console.error("Markdown が 1 つも見つかりませんでした。");
|
|
186
|
+
return 1;
|
|
187
|
+
}
|
|
188
|
+
const config = readConfig();
|
|
189
|
+
const results = await Promise.all(paths.map((path) => inspect(path, config, [...argv, "--show-baseline"])));
|
|
190
|
+
const entries = results.flatMap((result) => result.all);
|
|
191
|
+
const file = join(process.cwd(), BASELINE_FILE);
|
|
192
|
+
writeBaseline(file, entries);
|
|
193
|
+
console.log(
|
|
194
|
+
[
|
|
195
|
+
"",
|
|
196
|
+
` ${paths.length} ファイルを走査しました。`,
|
|
197
|
+
"",
|
|
198
|
+
` ${entries.length} 件の指摘を ${BASELINE_FILE} に記録しました。`,
|
|
199
|
+
" 以後、これらは報告されません。新しく増えたものだけが出ます。",
|
|
200
|
+
"",
|
|
201
|
+
` ${BASELINE_FILE} を commit してください。`,
|
|
202
|
+
"",
|
|
203
|
+
].join("\n"),
|
|
204
|
+
);
|
|
205
|
+
return 0;
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const runSuppressions = async (targets: readonly string[], argv: readonly string[]): Promise<number> => {
|
|
209
|
+
const paths = collectTargets(targets.length > 0 ? targets : ["."]);
|
|
210
|
+
const config = readConfig();
|
|
211
|
+
const results = await Promise.all(paths.map((path) => inspect(path, config, [...argv, "--show-baseline"])));
|
|
212
|
+
console.log(renderSuppressions(results.map((result) => result.perFile)));
|
|
213
|
+
return 0;
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
type Handler = (argv: readonly string[]) => number | Promise<number>;
|
|
217
|
+
|
|
218
|
+
/** `--` で始まらない引数。対象のパス。 */
|
|
219
|
+
const positional = (argv: readonly string[]): string[] => argv.slice(1).filter((arg) => !arg.startsWith("--"));
|
|
220
|
+
|
|
221
|
+
const showRules = (): number => {
|
|
222
|
+
const config = readConfig();
|
|
223
|
+
const language = config.language ?? "ja";
|
|
224
|
+
console.log(rulesJson(loadRules(language), config, language, config.genre ?? "blog/tech"));
|
|
225
|
+
return 0;
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
const showGenres = (): number => {
|
|
229
|
+
console.log(["", " 使えるジャンル:", ...GENRES.map((genre) => ` ${genre}`), "", " chaff.yaml の genre に書くか、--genre で指定します。", ""].join("\n"));
|
|
230
|
+
return 0;
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
/** 分岐を数珠つなぎにせず表にする。足すときに main を太らせない。 */
|
|
234
|
+
const HANDLERS: Readonly<Record<string, Handler>> = {
|
|
235
|
+
init: (argv) => {
|
|
236
|
+
runInit(process.cwd(), flag(argv, "--genre") ?? "blog/tech").forEach((line) => console.log(line));
|
|
237
|
+
return 0;
|
|
238
|
+
},
|
|
239
|
+
genres: showGenres,
|
|
240
|
+
rules: showRules,
|
|
241
|
+
explain: (argv) => explain(argv[1]),
|
|
242
|
+
baseline: (argv) => runBaseline(positional(argv), argv),
|
|
243
|
+
suppressions: (argv) => runSuppressions(positional(argv), argv),
|
|
244
|
+
relax: (argv) => changeSetting("relaxed", argv[1], flag(argv, "--why")),
|
|
245
|
+
strict: (argv) => changeSetting("strict", argv[1], flag(argv, "--why")),
|
|
246
|
+
off: (argv) => changeSetting("off", argv[1], flag(argv, "--why")),
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
export const main = async (argv: readonly string[]): Promise<number> => {
|
|
250
|
+
const first = argv[0];
|
|
251
|
+
if (first === undefined || first === "--help" || first === "-h") {
|
|
252
|
+
console.log(USAGE);
|
|
253
|
+
return first === undefined ? 1 : 0;
|
|
254
|
+
}
|
|
255
|
+
const handler = HANDLERS[first];
|
|
256
|
+
if (handler !== undefined) return handler(argv);
|
|
257
|
+
const targets = (first === "lint" ? argv.slice(1) : argv).filter((arg) => !arg.startsWith("--"));
|
|
258
|
+
return argv.includes("--watch") ? runWatch(targets, argv) : lint(targets, argv);
|
|
259
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { parse } from "yaml";
|
|
3
|
+
import { isLevel } from "../levels.ts";
|
|
4
|
+
import type { Level } from "../plugin.ts";
|
|
5
|
+
|
|
6
|
+
export const CONFIG_FILE = "chaff.yaml";
|
|
7
|
+
|
|
8
|
+
export type Config = {
|
|
9
|
+
readonly genre: string | undefined;
|
|
10
|
+
readonly language: string | undefined;
|
|
11
|
+
readonly rules: Readonly<Record<string, Level>>;
|
|
12
|
+
readonly experimental: boolean;
|
|
13
|
+
readonly path: string | undefined;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export const EMPTY: Config = { genre: undefined, language: undefined, rules: {}, experimental: false, path: undefined };
|
|
17
|
+
|
|
18
|
+
const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
19
|
+
|
|
20
|
+
const rulesOf = (raw: unknown): Record<string, Level> => {
|
|
21
|
+
if (!isRecord(raw)) return {};
|
|
22
|
+
return Object.entries(raw).reduce<Record<string, Level>>((acc, [id, value]) => (isLevel(value) ? { ...acc, [id]: value } : acc), {});
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const str = (value: unknown): string | undefined => (typeof value === "string" ? value : undefined);
|
|
26
|
+
|
|
27
|
+
/** 設定ファイルが無くても動く。あっても、既定から変えたものだけが書かれている。spec §18。 */
|
|
28
|
+
export const loadConfig = (path: string): Config => {
|
|
29
|
+
const raw: unknown = parse(readFileSync(path, "utf8"));
|
|
30
|
+
if (!isRecord(raw)) return { ...EMPTY, path };
|
|
31
|
+
return {
|
|
32
|
+
genre: str(raw["genre"]),
|
|
33
|
+
language: str(raw["language"]),
|
|
34
|
+
rules: rulesOf(raw["rules"]),
|
|
35
|
+
experimental: raw["experimental"] === true,
|
|
36
|
+
path,
|
|
37
|
+
};
|
|
38
|
+
};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { Scalar, YAMLMap, isMap, isScalar, parseDocument, type Document } from "yaml";
|
|
3
|
+
import { definedLevels } from "../levels.ts";
|
|
4
|
+
import { localized } from "../render/text.ts";
|
|
5
|
+
import type { Level, RuleDefinition } from "../plugin.ts";
|
|
6
|
+
|
|
7
|
+
const TEMPLATE = `# chaff.yaml — このチームの文章規範
|
|
8
|
+
#
|
|
9
|
+
# ここに書くのは「既定から変えたもの」だけです。
|
|
10
|
+
# 値は strict / normal / relaxed / off の 4 つから選びます。数字は要りません。
|
|
11
|
+
|
|
12
|
+
rules:
|
|
13
|
+
`;
|
|
14
|
+
|
|
15
|
+
export type ChangeOutcome = { readonly ok: boolean; readonly message: string };
|
|
16
|
+
|
|
17
|
+
const today = (): string => new Date().toISOString().slice(0, 10);
|
|
18
|
+
|
|
19
|
+
const reasonOf = (value: unknown): string | undefined => {
|
|
20
|
+
if (!isScalar(value) || typeof value.comment !== "string") return undefined;
|
|
21
|
+
const text = value.comment.trim();
|
|
22
|
+
return text.length === 0 ? undefined : text;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const ruleComment = (rule: RuleDefinition, language: string): string => ` ${localized(rule.name, language)}\n ${localized(rule.why, language)}`;
|
|
26
|
+
|
|
27
|
+
const load = (path: string): Document => parseDocument(existsSync(path) ? readFileSync(path, "utf8") : TEMPLATE);
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 設定を機械的に書き換えるが、**既存のコメントを壊さない**。
|
|
31
|
+
* 設定ファイルがチームの規範そのものである以上、コメントが失われることは規範が失われること。
|
|
32
|
+
* spec §19.4。
|
|
33
|
+
*/
|
|
34
|
+
export const applyLevel = (path: string, rule: RuleDefinition, level: Level, why: string | undefined, language: string, who: string): ChangeOutcome => {
|
|
35
|
+
if (!definedLevels(rule).includes(level)) {
|
|
36
|
+
return { ok: false, message: `${rule.id} に ${level} はありません。normal と同じ設定です。\n設定は変更しませんでした。` };
|
|
37
|
+
}
|
|
38
|
+
const doc = load(path);
|
|
39
|
+
const rules = doc.get("rules");
|
|
40
|
+
const map = isMap(rules) ? rules : new YAMLMap();
|
|
41
|
+
// 人が読んで書き足すファイルなので、必ず block style にする。flow style({ a: b })は読めない。
|
|
42
|
+
map.flow = false;
|
|
43
|
+
if (!isMap(rules)) doc.set("rules", map);
|
|
44
|
+
const existing = map.items.find((pair) => String(pair.key) === rule.id);
|
|
45
|
+
const previous = reasonOf(existing?.value);
|
|
46
|
+
// 古い理由が新しい値に付いたまま残ると、履歴が嘘になる。
|
|
47
|
+
if (previous !== undefined && why === undefined) {
|
|
48
|
+
return { ok: false, message: `${rule.id} には既に理由が書かれています:\n ${previous}\n値を変えるときは --why で新しい理由を書いてください。` };
|
|
49
|
+
}
|
|
50
|
+
// 取り除いて足し直すと、元の rule に付いていた説明コメントが行き場を失って残る。
|
|
51
|
+
// 既にある鍵は、その場で値だけ差し替える。
|
|
52
|
+
const value = new Scalar(level);
|
|
53
|
+
if (why !== undefined) value.comment = ` ${today()} ${why} / ${who}`;
|
|
54
|
+
if (existing === undefined) {
|
|
55
|
+
map.items = [...map.items, doc.createPair(rule.id, value)];
|
|
56
|
+
const added = map.items.at(-1);
|
|
57
|
+
if (added !== undefined && isScalar(added.key)) added.key.commentBefore = ruleComment(rule, language);
|
|
58
|
+
} else {
|
|
59
|
+
existing.value = value;
|
|
60
|
+
}
|
|
61
|
+
writeFileSync(path, String(doc), "utf8");
|
|
62
|
+
return { ok: true, message: `${rule.id} を ${level} にしました(${path})` };
|
|
63
|
+
};
|