hanlint 0.0.7 → 0.0.8
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 +23 -5
- package/data/adjectiveStems.txt +118 -0
- package/data/baselineAnchors.txt +3 -0
- package/data/blindEvaluation.schema.json +26 -0
- package/data/bridgeOpeners.txt +10 -0
- package/data/candidateActionNouns.txt +39 -0
- package/data/candidateSplitEndings.txt +13 -0
- package/data/cliches.json +1 -1
- package/data/confusable.json +2 -2
- package/data/deixis.txt +10 -3
- package/data/draftHistory.txt +10 -0
- package/data/easyWords.json +0 -42
- package/data/enoughMarkers.txt +6 -0
- package/data/entailmentPredictions.schema.json +52 -0
- package/data/euiNouns.txt +3 -4
- package/data/evidenceEntailmentBenchmark.schema.json +126 -0
- package/data/exemplars.json +347 -0
- package/data/holeKinds.json +1 -2
- package/data/iVerbStems.txt +26 -0
- package/data/inNouns.txt +147 -0
- package/data/irregularStems.txt +64 -0
- package/data/nonNouns.txt +153 -0
- package/data/panelAssignmentReview.schema.json +117 -0
- package/data/panelJudgePredictions.schema.json +78 -0
- package/data/panelReviewBatch.schema.json +79 -0
- package/data/panelTrialSet.schema.json +44 -0
- package/data/patterns.json +114 -0
- package/data/profiles.json +1 -0
- package/data/readerCalls.txt +12 -7
- package/data/redundantPair.json +1 -1
- package/data/rieulStems.txt +30 -0
- package/data/ruleCategories.json +56 -0
- package/data/ruleDocs.json +29 -17
- package/data/ruleMechanisms.json +56 -0
- package/data/spacing.json +0 -12
- package/data/spelling.json +6 -24
- package/data/translationese.json +10 -0
- package/data/verbTails.txt +17 -0
- package/data/version.json +1 -1
- package/data/writingBrief.schema.json +64 -0
- package/data/writingBriefV2.schema.json +116 -0
- package/data/writingTrial.schema.json +36 -0
- package/package.json +13 -5
- package/src/analysis/grammar/ending.js +336 -0
- package/src/analysis/grammar/hangul.js +94 -0
- package/src/analysis/grammar/index.js +5 -0
- package/src/analysis/grammar/josa.js +73 -0
- package/src/analysis/grammar/register.js +115 -0
- package/src/analysis/grammar/voice.js +43 -0
- package/src/analysis/index.js +4 -31
- package/src/analysis/josa.js +2 -0
- package/src/analysis/{surface/splitSentences.js → splitSentences.js} +1 -1
- package/src/analysis/{surface/tokenize.js → tokenize.js} +70 -13
- package/src/baseline/store.js +114 -0
- package/src/cli/main.js +506 -58
- package/src/cli/walk.js +30 -0
- package/src/cli/welcome.js +52 -0
- package/src/config/settings.js +103 -8
- package/src/data/exemplars.js +177 -0
- package/src/data/load.js +10 -0
- package/src/data/operations.js +278 -0
- package/src/data/patches.js +125 -0
- package/src/data/patterns.js +48 -0
- package/src/data/profiles.js +102 -0
- package/src/document/model.js +2 -0
- package/src/document/parseMarkdown.js +84 -11
- package/src/edit/applyFixes.js +0 -0
- package/src/fingerprint/build.js +50 -32
- package/src/{rules/shared → fingerprint}/codeBlocks.js +11 -11
- package/src/fingerprint/codeMarkers.js +73 -0
- package/src/fingerprint/markers.js +9 -0
- package/src/fingerprint/readerState.js +126 -0
- package/src/fingerprint/sourceSentence.js +37 -0
- package/src/fingerprint/topics.js +1 -1
- package/src/index.js +2 -16
- package/src/report/compactReport.js +6 -1
- package/src/report/fingerprintJson.js +8 -3
- package/src/report/jsonReport.js +50 -3
- package/src/report/operationMatch.js +31 -0
- package/src/report/patchMatch.js +44 -0
- package/src/report/registerMatch.js +40 -0
- package/src/report/textReport.js +41 -5
- package/src/rules/code/duplicateBlock.js +4 -3
- package/src/rules/code/firstResultDistance.js +1 -0
- package/src/rules/code/inputFileSource.js +23 -68
- package/src/rules/code/installImport.js +4 -4
- package/src/rules/code/platformApi.js +7 -6
- package/src/rules/document/countMismatch.js +22 -6
- package/src/rules/document/enoughOnce.js +25 -0
- package/src/rules/document/fieldEcho.js +38 -0
- package/src/rules/document/keywordHeading.js +27 -0
- package/src/rules/document/keywordMissing.js +1 -0
- package/src/rules/document/noQuestion.js +5 -1
- package/src/rules/document/promiseRecall.js +4 -2
- package/src/rules/document/tableOddCell.js +60 -0
- package/src/rules/finding.js +24 -2
- package/src/rules/orthography/confusable.js +1 -0
- package/src/rules/orthography/spacing.js +1 -0
- package/src/rules/orthography/spelling.js +1 -0
- package/src/rules/paragraph/factListParagraph.js +1 -0
- package/src/rules/paragraph/paraFragment.js +21 -11
- package/src/rules/paragraph/topicBreak.js +1 -0
- package/src/rules/registry.js +72 -3
- package/src/rules/sentence/cliche.js +1 -0
- package/src/rules/sentence/connectorRepeat.js +8 -7
- package/src/rules/sentence/danglingDeixis.js +8 -3
- package/src/rules/sentence/dash.js +2 -1
- package/src/rules/sentence/deixis.js +7 -3
- package/src/rules/sentence/doubleNegative.js +4 -1
- package/src/rules/sentence/doublePassive.js +38 -1
- package/src/rules/sentence/draftHistory.js +24 -0
- package/src/rules/sentence/ellipsis.js +17 -3
- package/src/rules/sentence/endingRepeat.js +6 -2
- package/src/rules/sentence/euiChain.js +3 -2
- package/src/rules/sentence/fillerOpener.js +1 -0
- package/src/rules/sentence/hardWord.js +1 -0
- package/src/rules/sentence/imperativePeriod.js +2 -1
- package/src/rules/sentence/japaneseLoan.js +1 -0
- package/src/rules/sentence/longSentence.js +3 -1
- package/src/rules/sentence/negationRedefine.js +10 -1
- package/src/rules/sentence/nounPile.js +1 -0
- package/src/rules/sentence/numberOrphan.js +45 -0
- package/src/rules/sentence/outsideProfile.js +68 -0
- package/src/rules/sentence/redundantPair.js +1 -0
- package/src/rules/sentence/translationese.js +1 -0
- package/src/rules/shared/candidates.js +129 -0
- package/src/rules/shared/dictionaryRule.js +12 -2
- package/src/rules/shared/localAntecedent.js +12 -0
- package/src/rules/shared/repeat.js +48 -0
- package/src/rules/structure/blockUnread.js +33 -0
- package/src/rules/structure/bridgeRepeat.js +40 -0
- package/src/rules/structure/emojiBullet.js +1 -0
- package/src/rules/structure/headingQuestion.js +19 -0
- package/src/rules/structure/headingSentence.js +9 -4
- package/src/rules/structure/headingSkip.js +1 -0
- package/src/rules/structure/headingUniform.js +9 -17
- package/src/rules/structure/introImage.js +19 -0
- package/src/rules/structure/introLong.js +1 -0
- package/src/rules/structure/loneSubheading.js +42 -0
- package/src/rules/structure/moreLater.js +48 -0
- package/src/rules/structure/sectionNoProse.js +1 -0
- package/src/rules/structure/sectionResult.js +3 -2
- package/src/text.js +19 -0
- package/src/rules/document/readerAbsent.js +0 -11
- package/src/rules/shared/runs.js +0 -22
package/src/cli/main.js
CHANGED
|
@@ -3,40 +3,71 @@
|
|
|
3
3
|
* 명령줄 진입점. 파이썬 CLI 와 같은 인자, 같은 출력, 같은 종료 코드다 (0 지적 없음, 1 error 있음, 2 파일이나 설정 문제).
|
|
4
4
|
*
|
|
5
5
|
* ```
|
|
6
|
-
* hanlint
|
|
6
|
+
* hanlint 인자가 없으면 첫 화면. 무엇을 칠 수 있는지 보인다
|
|
7
|
+
* hanlint 글.md [다른.md ...] 검사. 서브커맨드 없이 파일이나 폴더만 주면 lint 다. `-` 는 stdin
|
|
7
8
|
* hanlint fix 글.md 기계가 고칠 수 있는 지적을 원문에 적용
|
|
8
9
|
* hanlint print 글.md 지문 계층 JSON
|
|
9
|
-
* hanlint rules 규칙
|
|
10
|
+
* hanlint rules 규칙 목록을 부류로 묶어서
|
|
10
11
|
* hanlint explain <규칙> 규칙의 기술서
|
|
11
|
-
* hanlint
|
|
12
|
+
* hanlint patterns 문장 틀. --rule 로 그 규칙을 피하는 것만
|
|
13
|
+
* hanlint baseline 글들/ 지금 있는 지적을 잠근다. 그다음부터 새것만 막힌다
|
|
14
|
+
* hanlint baseline 글들/ 지금 있는 지적을 잠근다. 그다음부터 새것만 막힌다
|
|
15
|
+
* hanlint doctor 설정과 꺼진 규칙
|
|
16
|
+
* hanlint init 주석 달린 hanlint.toml. --output 과 --preset blog|report|docs
|
|
12
17
|
* ```
|
|
13
|
-
* audit,
|
|
18
|
+
* audit, guard, arena, blueprint, evidence, entailment 같은 확장 명령은 파이썬 패키지에 있다.
|
|
14
19
|
*/
|
|
15
|
-
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
16
|
-
import { dirname, isAbsolute, relative, resolve } from "node:path";
|
|
20
|
+
import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
21
|
+
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
17
22
|
|
|
18
|
-
import {
|
|
23
|
+
import { fingerprint, lintText, ruleDoc, ruleNames, ruleSummary, version } from "../index.js";
|
|
24
|
+
import { Baseline, build as buildBaseline, DEFAULT_NAME as DEFAULT_BASELINE, load as loadBaseline, prune as pruneBaseline, render as renderBaseline } from "../baseline/store.js";
|
|
19
25
|
import { loadConfig } from "../config/loadConfig.js";
|
|
20
|
-
import { defaultConfig } from "../config/settings.js";
|
|
26
|
+
import { DEFAULT_PRESET, defaultConfig, offRules, PRESET_NAMES, PRESETS } from "../config/settings.js";
|
|
21
27
|
import { applyFixes } from "../edit/applyFixes.js";
|
|
22
|
-
import {
|
|
28
|
+
import { exemplarFor } from "../data/exemplars.js";
|
|
29
|
+
import { patterns, patternsAvoiding } from "../data/patterns.js";
|
|
30
|
+
import { HAPNIDA, REGISTERS } from "../analysis/grammar/index.js";
|
|
31
|
+
import { CATEGORY_TITLES, MECHANISMS, ruleCategory, ruleMechanism, runAll } from "../rules/registry.js";
|
|
32
|
+
import { MARKDOWN, SKIPPED_FOLDERS, markdownUnder } from "./walk.js";
|
|
33
|
+
import { welcome } from "./welcome.js";
|
|
23
34
|
import { renderCompact } from "../report/compactReport.js";
|
|
24
35
|
import { LAYERS, renderFingerprintJson } from "../report/fingerprintJson.js";
|
|
25
36
|
import { renderGithub } from "../report/githubReport.js";
|
|
26
37
|
import { renderJson } from "../report/jsonReport.js";
|
|
27
38
|
import { renderText } from "../report/textReport.js";
|
|
39
|
+
import { exemplarInRegister, patternInRegister } from "../report/registerMatch.js";
|
|
28
40
|
|
|
29
|
-
const COMMANDS = ["lint", "fix", "print", "rules", "explain", "init"];
|
|
30
|
-
const PYTHON_ONLY = [
|
|
41
|
+
const COMMANDS = ["lint", "fix", "print", "rules", "explain", "patterns", "baseline", "doctor", "init"];
|
|
42
|
+
const PYTHON_ONLY = [
|
|
43
|
+
"audit",
|
|
44
|
+
"map",
|
|
45
|
+
"watch",
|
|
46
|
+
"profile",
|
|
47
|
+
"coverage",
|
|
48
|
+
"diff",
|
|
49
|
+
"learn",
|
|
50
|
+
"packet",
|
|
51
|
+
"guard",
|
|
52
|
+
"arena",
|
|
53
|
+
"blueprint",
|
|
54
|
+
"evidence",
|
|
55
|
+
"entailment",
|
|
56
|
+
"terms",
|
|
57
|
+
];
|
|
31
58
|
const FORMATS = ["text", "compact", "json", "github"];
|
|
32
59
|
const SEVERITIES = ["all", "error", "notice"];
|
|
33
|
-
|
|
60
|
+
/** 오탐과 미탐을 받는 자리. 뜻은 파이썬 cli/commands/explain.py 가 소유한다. */
|
|
61
|
+
const ISSUES = "github.com/eddmpython/hanlint/issues";
|
|
34
62
|
const STDIN = "-";
|
|
35
63
|
const STDIN_NAME = "<stdin>";
|
|
64
|
+
/** 폴더를 주면 이 확장자만 찾는다. 파이썬 cli/commands/shared.py 의 MARKDOWN 과 같다. */
|
|
36
65
|
const THRESHOLD_FIELDS = [
|
|
37
66
|
"fragmentRun",
|
|
38
67
|
"introMaxParagraphs",
|
|
39
68
|
"headingUniformRatio",
|
|
69
|
+
"headingSentenceMaxLevel",
|
|
70
|
+
"bridgeRepeatMin",
|
|
40
71
|
"nounPileMin",
|
|
41
72
|
"endingRun",
|
|
42
73
|
"factListMinSentences",
|
|
@@ -46,27 +77,37 @@ const THRESHOLD_FIELDS = [
|
|
|
46
77
|
"duplicateBlockRatio",
|
|
47
78
|
"firstResultMaxParagraphs",
|
|
48
79
|
"sectionResultMinParagraphs",
|
|
80
|
+
"introMaxImages",
|
|
81
|
+
"headingQuestionRatio",
|
|
82
|
+
"moreLaterMaxChars",
|
|
83
|
+
"tableOddCellMinRows",
|
|
49
84
|
];
|
|
50
|
-
const FLOAT_FIELDS = new Set(["headingUniformRatio", "factListMaxMeanLength"]);
|
|
85
|
+
const FLOAT_FIELDS = new Set(["headingUniformRatio", "factListMaxMeanLength", "headingQuestionRatio"]);
|
|
51
86
|
|
|
52
87
|
const USAGE = `사용법: hanlint 글.md [다른.md ...] [--format text|compact|json|github] [--severity all|error|notice] [--errors-only]
|
|
53
|
-
[--config 파일] [--disable 규칙] [--
|
|
88
|
+
[--config 파일] [--preset blog|report|docs] [--disable 규칙] [--baseline [파일]] [--output 파일] [--quiet]
|
|
89
|
+
[--path 이름 (stdin 의 이름)]
|
|
54
90
|
hanlint fix 글.md [--dry-run]
|
|
55
91
|
hanlint print 글.md [--layer all|sentences|paragraphs|sections|document]
|
|
56
92
|
hanlint rules [--names]
|
|
57
93
|
hanlint explain <규칙>
|
|
58
|
-
hanlint
|
|
94
|
+
hanlint patterns [--rule 규칙]
|
|
95
|
+
hanlint baseline 글들/ [--prune] [--output 파일]
|
|
96
|
+
hanlint doctor
|
|
97
|
+
hanlint init [--output hanlint.toml] [--preset blog|report|docs] [--force]
|
|
59
98
|
hanlint --version
|
|
60
99
|
|
|
100
|
+
파일 자리에 폴더를 주면 그 아래 마크다운을 전부 검사한다 (점으로 시작하는 폴더와 node_modules 는 건너뛴다).
|
|
101
|
+
인자가 없으면 첫 화면이 나온다.
|
|
61
102
|
한국어 글에서 반복되는 결함을 결정적으로 잡는다. 종료 코드는 0 (지적 없음), 1 (error 지적), 2 (파일이나 설정 문제).
|
|
62
|
-
|
|
103
|
+
${PYTHON_ONLY.join(", ")} 는 파이썬 패키지 (pip install hanlint) 에 있다.`;
|
|
63
104
|
|
|
64
105
|
/** @type {Record<string, "value" | "list" | "flag">} */
|
|
65
106
|
const OPTION_KINDS = {
|
|
66
107
|
"--format": "value",
|
|
67
108
|
"--config": "value",
|
|
68
109
|
"--disable": "list",
|
|
69
|
-
"--
|
|
110
|
+
"--profile": "value",
|
|
70
111
|
"--output": "value",
|
|
71
112
|
"--no-color": "flag",
|
|
72
113
|
"--quiet": "flag",
|
|
@@ -77,6 +118,11 @@ const OPTION_KINDS = {
|
|
|
77
118
|
"--dry-run": "flag",
|
|
78
119
|
"--names": "flag",
|
|
79
120
|
"--force": "flag",
|
|
121
|
+
"--preset": "value",
|
|
122
|
+
"--rule": "value",
|
|
123
|
+
"--register": "value",
|
|
124
|
+
"--baseline": "optional",
|
|
125
|
+
"--prune": "flag",
|
|
80
126
|
};
|
|
81
127
|
|
|
82
128
|
class UsageError extends Error {}
|
|
@@ -108,6 +154,11 @@ function parseArgs(args) {
|
|
|
108
154
|
options[arg] = true;
|
|
109
155
|
continue;
|
|
110
156
|
}
|
|
157
|
+
// 값을 줘도 되고 안 줘도 되는 옵션. 다음 인자가 다른 옵션이거나 없으면 true 로 둔다 (파이썬의 nargs="?").
|
|
158
|
+
if (kind === "optional" && inlineValue === null && (args[i + 1] === undefined || args[i + 1].startsWith("--"))) {
|
|
159
|
+
options[arg] = true;
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
111
162
|
const value = inlineValue ?? args[++i];
|
|
112
163
|
if (value === undefined) throw new UsageError(`${arg} 에 값이 없다`);
|
|
113
164
|
if (kind === "list") {
|
|
@@ -121,10 +172,24 @@ function parseArgs(args) {
|
|
|
121
172
|
return { options, positionals };
|
|
122
173
|
}
|
|
123
174
|
|
|
124
|
-
/**
|
|
125
|
-
|
|
126
|
-
|
|
175
|
+
/**
|
|
176
|
+
* 서브커맨드 없이 파일이나 옵션만 주면 lint 로 본다. 빈 인자는 여기서 다루지 않는다. `main` 이 첫 화면으로 보낸다.
|
|
177
|
+
* @param {string[]} argv
|
|
178
|
+
*/
|
|
179
|
+
export /** 파일 이름이 아니라 서브커맨드를 치려던 것으로 보이나. 뜻은 파이썬 cli/main.py 가 소유한다. @param {string} word */
|
|
180
|
+
function looksLikeCommand(word) {
|
|
181
|
+
if (!word || !/^[A-Za-z]+$/.test(word)) return false;
|
|
182
|
+
return !existsSync(word);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** @param {string[]} argv */
|
|
186
|
+
function normalizeArgv(argv) {
|
|
127
187
|
if (COMMANDS.includes(argv[0]) || PYTHON_ONLY.includes(argv[0]) || ["-h", "--help", "--version"].includes(argv[0])) return argv;
|
|
188
|
+
if (looksLikeCommand(argv[0])) {
|
|
189
|
+
const near = nearNames(argv[0], [...COMMANDS, ...PYTHON_ONLY]);
|
|
190
|
+
const hint = near.length ? ` 가까운 이름: ${near.join(", ")}.` : "";
|
|
191
|
+
throw new Error(`${argv[0]} 는 모르는 명령이다.${hint} 전체 목록은 hanlint --help`);
|
|
192
|
+
}
|
|
128
193
|
return ["lint", ...argv];
|
|
129
194
|
}
|
|
130
195
|
|
|
@@ -150,11 +215,23 @@ function startFolder(paths) {
|
|
|
150
215
|
return process.cwd();
|
|
151
216
|
}
|
|
152
217
|
|
|
218
|
+
/** @param {Record<string, string | string[] | boolean>} options @param {string[]} paths */
|
|
219
|
+
function checkDisabled(config) {
|
|
220
|
+
const names = ruleNames();
|
|
221
|
+
const unknown = [...config.disable].filter((rule) => !names.includes(rule)).sort();
|
|
222
|
+
if (!unknown.length) return;
|
|
223
|
+
const near = nearNames(unknown[0], names);
|
|
224
|
+
const hint = near.length ? ` 가까운 이름: ${near.join(", ")}.` : "";
|
|
225
|
+
throw new Error(`모르는 규칙 이름: ${unknown.join(", ")}.${hint} 목록은 hanlint rules --names`);
|
|
226
|
+
}
|
|
227
|
+
|
|
153
228
|
/** @param {Record<string, string | string[] | boolean>} options @param {string[]} paths */
|
|
154
229
|
function configFrom(options, paths) {
|
|
155
230
|
const config = loadConfig(/** @type {string | undefined} */ (options["--config"]) ?? null, startFolder(paths));
|
|
231
|
+
if (options["--preset"]) config.preset = choose(/** @type {string} */ (options["--preset"]), PRESET_NAMES, "--preset");
|
|
156
232
|
for (const rule of /** @type {string[]} */ (options["--disable"] ?? [])) config.disable.add(rule);
|
|
157
|
-
if (options["--
|
|
233
|
+
if (options["--profile"]) config.profile = /** @type {string} */ (options["--profile"]);
|
|
234
|
+
checkDisabled(config);
|
|
158
235
|
return config;
|
|
159
236
|
}
|
|
160
237
|
|
|
@@ -166,8 +243,16 @@ function configLabel(config) {
|
|
|
166
243
|
return rel;
|
|
167
244
|
}
|
|
168
245
|
|
|
246
|
+
/** 설정 출처와 지금 도는 프리셋. 기본 프리셋이면 이름을 빼서 줄이 길어지지 않게 한다.
|
|
247
|
+
* @param {import("../config/settings.js").Config} config */
|
|
248
|
+
function header(config) {
|
|
249
|
+
const where = configLabel(config);
|
|
250
|
+
return config.preset === DEFAULT_PRESET ? `설정: ${where}` : `설정: ${where}, 프리셋 ${config.preset}`;
|
|
251
|
+
}
|
|
252
|
+
|
|
169
253
|
/** @param {Map<string, import("../rules/finding.js").Finding[]>} results */
|
|
170
254
|
function summary(results) {
|
|
255
|
+
// 글에 실제로 있는 수. 거른 뒤가 아니라 거르기 전을 센다. 뜻은 파이썬 shared.summary 가 소유한다.
|
|
171
256
|
let errors = 0;
|
|
172
257
|
let notices = 0;
|
|
173
258
|
for (const findings of results.values()) {
|
|
@@ -182,48 +267,198 @@ function summary(results) {
|
|
|
182
267
|
/** (이름, 본문). `-` 면 stdin 을 UTF-8 로 읽는다. @param {string} path @param {string} stdinName @returns {[string, string]} */
|
|
183
268
|
function readInput(path, stdinName) {
|
|
184
269
|
if (path === STDIN) return [stdinName, readFileSync(0, "utf-8")];
|
|
185
|
-
return [path,
|
|
270
|
+
return [path, readOne(path)];
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** 글 하나를 읽는다. 폴더를 주면 말로 막는다. 뜻은 파이썬 shared.readFile 이 소유한다. @param {string} path */
|
|
274
|
+
function readOne(path) {
|
|
275
|
+
let isFolder = false;
|
|
276
|
+
try {
|
|
277
|
+
isFolder = statSync(path).isDirectory();
|
|
278
|
+
} catch {
|
|
279
|
+
isFolder = false;
|
|
280
|
+
}
|
|
281
|
+
if (isFolder) throw new Error(`${path} 는 폴더다. 이 명령은 글 하나를 본다. 폴더는 hanlint 와 hanlint fix 가 받는다`);
|
|
282
|
+
return readFileSync(path, "utf-8");
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** 폴더를 주면 그 아래 마크다운을 편다. 파일과 `-` 는 그대로 둔다. @param {string[]} paths */
|
|
286
|
+
function collectFiles(paths) {
|
|
287
|
+
/** @type {string[]} */
|
|
288
|
+
const found = [];
|
|
289
|
+
for (const path of paths) {
|
|
290
|
+
if (path === STDIN) {
|
|
291
|
+
found.push(STDIN);
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
let isFolder = false;
|
|
295
|
+
try {
|
|
296
|
+
isFolder = statSync(path).isDirectory();
|
|
297
|
+
} catch {
|
|
298
|
+
isFolder = false;
|
|
299
|
+
}
|
|
300
|
+
if (!isFolder) {
|
|
301
|
+
found.push(path);
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
const inside = markdownUnder(path).sort();
|
|
305
|
+
if (!inside.length)
|
|
306
|
+
throw new Error(
|
|
307
|
+
`${path} 안에 마크다운 파일이 없다. 점으로 시작하는 폴더와 node_modules 는 건너뛴다. 그 안을 보려면 그 폴더를 직접 준다`,
|
|
308
|
+
);
|
|
309
|
+
found.push(...inside);
|
|
310
|
+
}
|
|
311
|
+
return found;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** 가장 많이 난 규칙. 같은 수면 이름 순으로 갈라 같은 입력에 같은 답이 나온다.
|
|
315
|
+
* @param {import("../rules/finding.js").Finding[]} findings */
|
|
316
|
+
function commonest(findings) {
|
|
317
|
+
/** @type {Map<string, number>} */
|
|
318
|
+
const counted = new Map();
|
|
319
|
+
for (const finding of findings) counted.set(finding.rule, (counted.get(finding.rule) ?? 0) + 1);
|
|
320
|
+
return [...counted.keys()].sort((a, b) => (counted.get(b) ?? 0) - (counted.get(a) ?? 0) || a.localeCompare(b))[0];
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** `hanlint fix` 가 실제로 고칠 error 수. 고침이 달렸다는 것과 자리를 잡을 수 있다는 것은 다르다.
|
|
324
|
+
* @param {Map<string, string>} texts @param {Map<string, import("../rules/finding.js").Finding[]>} results */
|
|
325
|
+
function fixableCount(texts, results) {
|
|
326
|
+
let total = 0;
|
|
327
|
+
for (const [name, findings] of results) {
|
|
328
|
+
const text = texts.get(name);
|
|
329
|
+
if (text === undefined) continue;
|
|
330
|
+
total += applyFixes(text, findings.filter((f) => f.severity === "error")).applied.length;
|
|
331
|
+
}
|
|
332
|
+
return total;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** 검사 끝에 붙는 다음 행동 한 줄. 뜻은 파이썬 cli/commands/shared.py 가 소유한다.
|
|
336
|
+
* @param {Map<string, import("../rules/finding.js").Finding[]>} results @param {number} [fixable] */
|
|
337
|
+
function nextStep(results, fixable = 0) {
|
|
338
|
+
const findings = [...results.values()].flat();
|
|
339
|
+
const errors = findings.filter((f) => f.severity === "error");
|
|
340
|
+
const notices = findings.length - errors.length;
|
|
341
|
+
if (errors.length) {
|
|
342
|
+
const rule = commonest(errors);
|
|
343
|
+
if (fixable) return `다음: error ${errors.length}건 가운데 ${fixable}건은 hanlint fix 가 바로 고친다. 나머지는 손으로 고친다`;
|
|
344
|
+
if (patternsAvoiding(rule).length) return `다음: error ${errors.length}건을 고친다. 다시 쓸 틀은 hanlint patterns --rule ${rule}`;
|
|
345
|
+
return `다음: error ${errors.length}건을 고친다. 규칙이 왜 있는지는 hanlint explain ${rule}`;
|
|
346
|
+
}
|
|
347
|
+
if (notices) return `다음: error 0. 확인할 자리 ${notices}건을 읽고 판단한 뒤 사람과 LLM 평가로 넘어간다`;
|
|
348
|
+
return "다음: 세어서 잡히는 결함이 없다. 좋은 글이라는 뜻은 아니므로 사람과 LLM 평가로 넘어간다";
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** @param {Baseline} baseline */
|
|
352
|
+
function lockedNote(baseline) {
|
|
353
|
+
return `잠근 자리 ${baseline.count}건은 넘겼다 (${baseline.source}). 그 문장을 고치면 다시 나온다`;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** 옵션과 설정에서 잠금 파일을 연다. 둘 다 없으면 빈 잠금이다. @param {Record<string, string | string[] | boolean>} options @param {import("../config/settings.js").Config} config */
|
|
357
|
+
function openBaseline(options, config) {
|
|
358
|
+
const given = options["--baseline"];
|
|
359
|
+
const path = given === true ? DEFAULT_BASELINE : /** @type {string | undefined} */ (given) ?? config.baseline;
|
|
360
|
+
return path ? loadBaseline(path) : new Baseline();
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** @param {string[]} args */
|
|
364
|
+
function runBaseline(args) {
|
|
365
|
+
const { options, positionals } = parseArgs(args);
|
|
366
|
+
if (!positionals.length) throw new UsageError("잠글 마크다운 파일이 필요하다");
|
|
367
|
+
const files = collectFiles(positionals);
|
|
368
|
+
const config = configFrom(options, files);
|
|
369
|
+
const target = /** @type {string} */ (options["--output"] ?? config.baseline ?? DEFAULT_BASELINE);
|
|
370
|
+
/** @type {Map<string, import("../rules/finding.js").Finding[]>} */
|
|
371
|
+
const results = new Map();
|
|
372
|
+
for (const path of files) {
|
|
373
|
+
const [name, text] = readInput(path, STDIN_NAME);
|
|
374
|
+
results.set(name, lintText(text, config, name));
|
|
375
|
+
}
|
|
376
|
+
const baseline = options["--prune"] ? pruneBaseline(loadBaseline(target), results) : buildBaseline(results, target);
|
|
377
|
+
writeFileSync(target, `${renderBaseline(baseline)}\n`, "utf-8");
|
|
378
|
+
const what = options["--prune"] ? "잠금을 줄였다" : "지적을 잠갔다";
|
|
379
|
+
process.stdout.write(
|
|
380
|
+
`${baseline.source} 에 ${baseline.count}건을 적었다. ${files.length}개 글의 ${what}.\n` +
|
|
381
|
+
"다음: 검사할 때 --baseline 을 붙이거나 설정에 baseline 을 적는다. 잠긴 자리를 고치면 새 지적으로 다시 나온다\n",
|
|
382
|
+
);
|
|
383
|
+
return 0;
|
|
186
384
|
}
|
|
187
385
|
|
|
188
386
|
/** @param {string[]} args */
|
|
189
387
|
function runLint(args) {
|
|
190
388
|
const { options, positionals } = parseArgs(args);
|
|
191
389
|
if (!positionals.length) throw new UsageError("검사할 마크다운 파일이 필요하다");
|
|
390
|
+
const files = collectFiles(positionals);
|
|
192
391
|
const format = choose(/** @type {string} */ (options["--format"] ?? "text"), FORMATS, "--format");
|
|
193
392
|
const severity = options["--errors-only"] ? "error" : choose(/** @type {string} */ (options["--severity"] ?? "all"), SEVERITIES, "--severity");
|
|
194
|
-
const config = configFrom(options,
|
|
195
|
-
|
|
393
|
+
const config = configFrom(options, files);
|
|
394
|
+
const baseline = openBaseline(options, config);
|
|
196
395
|
const stdinName = /** @type {string} */ (options["--path"] ?? STDIN_NAME);
|
|
197
396
|
/** @type {Map<string, import("../rules/finding.js").Finding[]>} */
|
|
198
397
|
const results = new Map();
|
|
199
|
-
|
|
398
|
+
/** @type {Map<string, string>} */
|
|
399
|
+
const texts = new Map();
|
|
400
|
+
/** @type {Map<string, string>} */
|
|
401
|
+
const registers = new Map();
|
|
402
|
+
/** @type {Map<string, import("../fingerprint/build.js").DocumentPrint>} */
|
|
403
|
+
const documents = new Map();
|
|
404
|
+
for (const path of files) {
|
|
200
405
|
const [name, text] = readInput(path, stdinName);
|
|
201
|
-
|
|
406
|
+
texts.set(name, text);
|
|
407
|
+
const document = fingerprint(text, config, name);
|
|
408
|
+
documents.set(name, document);
|
|
409
|
+
registers.set(name, document.register);
|
|
410
|
+
results.set(name, baseline.keep(name, runAll(document, config)));
|
|
202
411
|
}
|
|
203
412
|
const hasError = [...results.values()].some((findings) => findings.some((f) => f.severity === "error"));
|
|
413
|
+
const fixable = fixableCount(texts, results);
|
|
204
414
|
/** @type {Map<string, import("../rules/finding.js").Finding[]>} */
|
|
205
415
|
const shown = new Map();
|
|
206
416
|
for (const [name, findings] of results) shown.set(name, severity === "all" ? findings : findings.filter((f) => f.severity === severity));
|
|
207
417
|
|
|
208
418
|
const output = /** @type {string | undefined} */ (options["--output"]);
|
|
209
419
|
if (format === "json") {
|
|
210
|
-
emit(
|
|
420
|
+
emit(
|
|
421
|
+
renderJson(
|
|
422
|
+
shown,
|
|
423
|
+
configLabel(config),
|
|
424
|
+
registers,
|
|
425
|
+
config.preset,
|
|
426
|
+
config.exemplars,
|
|
427
|
+
documents,
|
|
428
|
+
config.patches,
|
|
429
|
+
config.operations,
|
|
430
|
+
config.protectedTerms,
|
|
431
|
+
),
|
|
432
|
+
output,
|
|
433
|
+
);
|
|
211
434
|
} else if (format === "github") {
|
|
212
435
|
emit([...shown].map(([name, findings]) => renderGithub(name, findings)).join("\n"), output);
|
|
213
436
|
} else {
|
|
214
437
|
const parts = [];
|
|
215
|
-
if (!options["--quiet"]) parts.push(
|
|
438
|
+
if (!options["--quiet"]) parts.push(header(config));
|
|
216
439
|
if (format === "compact") {
|
|
217
440
|
const body = [...shown]
|
|
218
441
|
.filter(([, findings]) => findings.length)
|
|
219
442
|
.map(([name, findings]) => renderCompact(name, findings))
|
|
220
443
|
.join("\n");
|
|
221
444
|
if (body) parts.push(body);
|
|
222
|
-
parts.push(summary(
|
|
445
|
+
parts.push(summary(results));
|
|
446
|
+
if (!options["--quiet"]) {
|
|
447
|
+
if (baseline.count) parts.push(lockedNote(baseline));
|
|
448
|
+
parts.push(nextStep(results, fixable));
|
|
449
|
+
}
|
|
223
450
|
emit(parts.join("\n"), output);
|
|
224
451
|
} else {
|
|
225
|
-
parts.push(
|
|
226
|
-
|
|
452
|
+
parts.push(
|
|
453
|
+
[...shown]
|
|
454
|
+
.map(([name, findings]) => renderText(name, findings, registers.get(name), config.preset, config.exemplars))
|
|
455
|
+
.join("\n\n"),
|
|
456
|
+
);
|
|
457
|
+
if (shown.size > 1) parts.push(summary(results));
|
|
458
|
+
if (!options["--quiet"]) {
|
|
459
|
+
if (baseline.count) parts.push(lockedNote(baseline));
|
|
460
|
+
parts.push(nextStep(results, fixable));
|
|
461
|
+
}
|
|
227
462
|
emit(parts.join("\n\n"), output);
|
|
228
463
|
}
|
|
229
464
|
}
|
|
@@ -234,21 +469,25 @@ function runLint(args) {
|
|
|
234
469
|
function runFix(args) {
|
|
235
470
|
const { options, positionals } = parseArgs(args);
|
|
236
471
|
if (!positionals.length) throw new UsageError("고칠 마크다운 파일이 필요하다");
|
|
237
|
-
const
|
|
238
|
-
const
|
|
239
|
-
void analyzer;
|
|
472
|
+
const files = collectFiles(positionals);
|
|
473
|
+
const config = configFrom(options, files);
|
|
240
474
|
const lines = [];
|
|
241
|
-
for (const path of
|
|
475
|
+
for (const path of files) {
|
|
242
476
|
const text = readFileSync(path, "utf-8");
|
|
243
477
|
// notice 는 제안이라 손으로 정한다. 확정된 error 만 원문에 넣는다.
|
|
244
|
-
const
|
|
245
|
-
|
|
246
|
-
runAll(fingerprint(text, config, path), config).filter((f) => f.severity === "error"),
|
|
247
|
-
);
|
|
478
|
+
const errors = runAll(fingerprint(text, config, path), config).filter((f) => f.severity === "error");
|
|
479
|
+
const result = applyFixes(text, errors);
|
|
248
480
|
for (const [line, fragment, replacement] of result.applied) lines.push(`${path}:${line} ${fragment} → ${replacement}`);
|
|
249
481
|
for (const [line, fragment, reason] of result.skipped) lines.push(`${path}:${line} 건너뜀 ${fragment}: ${reason}`);
|
|
250
482
|
if (result.text !== text && !options["--dry-run"]) writeFileSync(path, result.text, "utf-8");
|
|
251
|
-
|
|
483
|
+
// 남은 수를 함께 말한다. `0곳 고침, 0곳 건너뜀` 만 보면 손볼 것이 없다는 뜻으로 읽힌다
|
|
484
|
+
const left = errors.length - result.applied.length;
|
|
485
|
+
const rest = left ? `. 손으로 고칠 error ${left}건이 남았다 (hanlint ${path})` : "";
|
|
486
|
+
lines.push(
|
|
487
|
+
`${path} ${result.applied.length}곳 고침, ${result.skipped.length}곳 건너뜀` +
|
|
488
|
+
(options["--dry-run"] ? " (미리보기, 파일은 그대로)" : "") +
|
|
489
|
+
rest,
|
|
490
|
+
);
|
|
252
491
|
}
|
|
253
492
|
process.stdout.write(`${lines.join("\n")}\n`);
|
|
254
493
|
return 0;
|
|
@@ -269,43 +508,213 @@ function runPrint(args) {
|
|
|
269
508
|
function runRules(args) {
|
|
270
509
|
const { options } = parseArgs(args);
|
|
271
510
|
const names = ruleNames();
|
|
511
|
+
const output = /** @type {string | undefined} */ (options["--output"]);
|
|
272
512
|
if (options["--names"]) {
|
|
273
|
-
|
|
513
|
+
emit(names.join("\n"), output);
|
|
514
|
+
return 0;
|
|
515
|
+
}
|
|
516
|
+
const config = configFrom(options, []);
|
|
517
|
+
const off = new Set(offRules(config));
|
|
518
|
+
if (options["--format"] === "json") {
|
|
519
|
+
const rules = names.map((name) => {
|
|
520
|
+
const exemplar = exemplarFor(name, config.preset, config.exemplars);
|
|
521
|
+
/** @type {Record<string, unknown>} */
|
|
522
|
+
const entry = { name, category: ruleCategory(name), mechanism: ruleMechanism(name), summary: ruleSummary(name), doc: ruleDoc(name), enabled: !off.has(name) };
|
|
523
|
+
if (exemplar) entry.exemplar = { before: exemplar.before, after: exemplar.after, moved: exemplar.moved };
|
|
524
|
+
return entry;
|
|
525
|
+
});
|
|
526
|
+
emit(JSON.stringify({ version: 1, rules }, null, 2), output);
|
|
274
527
|
return 0;
|
|
275
528
|
}
|
|
276
529
|
const width = Math.max(...names.map((name) => name.length));
|
|
277
|
-
const lines =
|
|
278
|
-
|
|
530
|
+
const lines = [];
|
|
531
|
+
for (const [category, title] of Object.entries(CATEGORY_TITLES)) {
|
|
532
|
+
const inside = names.filter((name) => ruleCategory(name) === category);
|
|
533
|
+
if (!inside.length) continue;
|
|
534
|
+
lines.push(`${title} (${inside.length})`);
|
|
535
|
+
for (const name of inside) lines.push(` ${name.padEnd(width)} ${ruleSummary(name)}${off.has(name) ? " (꺼짐)" : ""}`);
|
|
536
|
+
lines.push("");
|
|
537
|
+
}
|
|
538
|
+
let tail = `규칙 ${names.length}개`;
|
|
539
|
+
if (off.size) {
|
|
540
|
+
const byPreset = PRESETS[config.preset].length;
|
|
541
|
+
tail += `, 그중 ${off.size}개가 꺼져 있다 (preset ${config.preset} 이 ${byPreset}개, disable 이 ${off.size - byPreset}개)`;
|
|
542
|
+
}
|
|
543
|
+
lines.push(`${tail}. 하나를 자세히 보려면 hanlint explain <규칙>`);
|
|
544
|
+
lines.push(`프리셋은 ${PRESET_NAMES.join(", ")} 이고 hanlint init --preset <이름> 이 설정에 적는다`);
|
|
545
|
+
emit(lines.join("\n"), output);
|
|
279
546
|
return 0;
|
|
280
547
|
}
|
|
281
548
|
|
|
549
|
+
/** 여러 줄짜리 본보기를 첫 줄에만 표를 달고 나머지는 맞춰 들여쓴다. @param {string} text @param {string} label */
|
|
550
|
+
function indent(text, label) {
|
|
551
|
+
const pad = " ".repeat(label.length);
|
|
552
|
+
return text.replace(/\n+$/, "").split("\n").map((line, index) => (index === 0 ? label : pad) + line).join("\n");
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/** 가까운 이름을 몇 개까지 보이는가. */
|
|
556
|
+
const NEAR_LIMIT = 3;
|
|
557
|
+
/** 앞 몇 글자가 같으면 가까운 이름으로 보는가. */
|
|
558
|
+
const NEAR_PREFIX = 3;
|
|
559
|
+
|
|
560
|
+
/** 오타에 가까운 이름. 파이썬 cli/commands/explain.py 의 nearNames 와 같다. @param {string} query @param {string[]} names */
|
|
561
|
+
function nearNames(query, names) {
|
|
562
|
+
const lowered = query.toLowerCase();
|
|
563
|
+
/** @type {[number, string][]} */
|
|
564
|
+
const scored = [];
|
|
565
|
+
for (const name of names) {
|
|
566
|
+
const low = name.toLowerCase();
|
|
567
|
+
if (low.includes(lowered) || lowered.includes(low)) scored.push([0, name]);
|
|
568
|
+
else if (low.slice(0, NEAR_PREFIX) === lowered.slice(0, NEAR_PREFIX)) scored.push([1, name]);
|
|
569
|
+
}
|
|
570
|
+
scored.sort((a, b) => a[0] - b[0] || (a[1] < b[1] ? -1 : a[1] > b[1] ? 1 : 0));
|
|
571
|
+
return scored.slice(0, NEAR_LIMIT).map(([, name]) => name);
|
|
572
|
+
}
|
|
573
|
+
|
|
282
574
|
/** @param {string[]} args */
|
|
283
575
|
function runExplain(args) {
|
|
284
|
-
const { positionals } = parseArgs(args);
|
|
576
|
+
const { options, positionals } = parseArgs(args);
|
|
577
|
+
const names = ruleNames();
|
|
578
|
+
if (!positionals.length) {
|
|
579
|
+
process.stdout.write("규칙 이름 하나가 필요하다. 예: hanlint explain doublePassive\n");
|
|
580
|
+
process.stdout.write(`\n규칙 ${names.length}개를 부류로 묶어 보려면 hanlint rules, 이름만 보려면 hanlint rules --names\n`);
|
|
581
|
+
return 2;
|
|
582
|
+
}
|
|
285
583
|
if (positionals.length !== 1) throw new UsageError("규칙 이름 하나가 필요하다. hanlint rules 로 목록을 본다");
|
|
286
|
-
const
|
|
287
|
-
|
|
584
|
+
const wanted = positionals[0];
|
|
585
|
+
if (!names.includes(wanted)) {
|
|
586
|
+
const near = nearNames(wanted, names);
|
|
587
|
+
const hint = near.length ? ` 이것을 찾았나: ${near.join(", ")}` : " hanlint rules 로 목록을 본다";
|
|
588
|
+
throw new Error(`모르는 규칙: ${wanted}.${hint}`);
|
|
589
|
+
}
|
|
590
|
+
const category = ruleCategory(wanted);
|
|
591
|
+
const config = configFrom(options, []);
|
|
592
|
+
const exemplarOf = exemplarFor(wanted, config.preset, config.exemplars);
|
|
593
|
+
const register = choose(/** @type {string} */ (options["--register"] ?? HAPNIDA), REGISTERS, "--register");
|
|
594
|
+
if (options["--format"] === "json") {
|
|
595
|
+
/** @type {Record<string, unknown>} */
|
|
596
|
+
const data = { version: 1, rule: wanted, category, mechanism: ruleMechanism(wanted), doc: ruleDoc(wanted) };
|
|
597
|
+
if (exemplarOf) {
|
|
598
|
+
const adapted = exemplarInRegister(exemplarOf, register);
|
|
599
|
+
data.exemplar = { before: adapted.before, after: adapted.after, moved: adapted.moved };
|
|
600
|
+
}
|
|
601
|
+
const forms = patternsAvoiding(wanted).map((pattern) => patternInRegister(pattern, register));
|
|
602
|
+
if (forms.length) data.patterns = forms;
|
|
603
|
+
emit(JSON.stringify(data, null, 2), /** @type {string | undefined} */ (options["--output"]));
|
|
604
|
+
return 0;
|
|
605
|
+
}
|
|
606
|
+
const mechanism = ruleMechanism(wanted);
|
|
607
|
+
process.stdout.write(`${wanted} (${CATEGORY_TITLES[category]})\n기제: ${mechanism}. ${MECHANISMS[mechanism]}\n\n${ruleDoc(wanted)}\n`);
|
|
608
|
+
const exemplar = exemplarOf ? exemplarInRegister(exemplarOf, register) : undefined;
|
|
609
|
+
if (exemplar) {
|
|
610
|
+
process.stdout.write("\n본보기\n");
|
|
611
|
+
process.stdout.write(`${indent(exemplar.before, " 전 ")}\n`);
|
|
612
|
+
process.stdout.write(`${indent(exemplar.after, " 후 ")}\n`);
|
|
613
|
+
process.stdout.write(` 달라진 것: ${exemplar.moved}\n`);
|
|
614
|
+
}
|
|
615
|
+
const siblings = names.filter((name) => ruleCategory(name) === category && name !== wanted);
|
|
616
|
+
process.stdout.write(`\n같은 부류: ${siblings.join(", ")}\n`);
|
|
617
|
+
process.stdout.write(`끄려면 hanlint.toml 의 disable 에 ${wanted} 를 넣는다. 한 자리만 끄려면 <!-- hanlint-disable ${wanted} -->\n`);
|
|
618
|
+
process.stdout.write(`정당한 문장인데 잡혔으면 끄고 끝내지 말고 알려 준다: ${ISSUES}\n`);
|
|
619
|
+
return 0;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/** @param {string[]} args */
|
|
623
|
+
function runPatterns(args) {
|
|
624
|
+
const { options } = parseArgs(args);
|
|
625
|
+
const rule = /** @type {string | undefined} */ (options["--rule"]);
|
|
626
|
+
const register = choose(/** @type {string} */ (options["--register"] ?? HAPNIDA), REGISTERS, "--register");
|
|
627
|
+
const chosen = (rule ? patternsAvoiding(rule) : patterns()).map((pattern) => patternInRegister(pattern, register));
|
|
628
|
+
if (!chosen.length) throw new Error(` 을 피하는 문형이 없다. hanlint patterns 로 전부 본다`);
|
|
629
|
+
const output = /** @type {string | undefined} */ (options["--output"]);
|
|
630
|
+
if (options["--format"] === "json") {
|
|
631
|
+
emit(JSON.stringify({ version: 1, patterns: chosen }, null, 2), output);
|
|
632
|
+
return 0;
|
|
633
|
+
}
|
|
634
|
+
const lines = [];
|
|
635
|
+
for (const pattern of chosen) {
|
|
636
|
+
lines.push(`${pattern.name} (${pattern.avoids.join(", ")} 를 피한다)`);
|
|
637
|
+
lines.push(` 틀 ${pattern.form}`);
|
|
638
|
+
lines.push(` 언제 ${pattern.when}`);
|
|
639
|
+
lines.push(indent(pattern.example, " 예시 "));
|
|
640
|
+
lines.push(indent(pattern.instead, " 대신 "));
|
|
641
|
+
lines.push(` 출처 ${pattern.source}`);
|
|
642
|
+
lines.push("");
|
|
643
|
+
}
|
|
644
|
+
lines.push(`문형 ${chosen.length}개. 예시는 전부 hanlint 를 error 0 으로 통과한다 (게이트가 확인한다)`);
|
|
645
|
+
if (!rule) lines.push("지적을 받은 자리를 다시 쓰려면 hanlint patterns --rule <규칙>");
|
|
646
|
+
emit(lines.join("\n"), output);
|
|
647
|
+
return 0;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/** 잠근 지적이 몇 건인지. baseline 이 빚을 감추는 자리가 되지 않게 늘 보인다. @param {import("../config/settings.js").Config} config */
|
|
651
|
+
function baselineState(config) {
|
|
652
|
+
if (!config.baseline) return "없다 (hanlint baseline 글들/ 로 지금 지적을 잠근다)";
|
|
653
|
+
try {
|
|
654
|
+
return `${loadBaseline(config.baseline).count}건이 ${config.baseline} 에 잠겨 있다. 그 문장을 고치면 다시 나온다`;
|
|
655
|
+
} catch (error) {
|
|
656
|
+
return `${config.baseline} 를 못 읽었다: ${/** @type {Error} */ (error).message}`;
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/** @param {string[]} args */
|
|
661
|
+
function runDoctor(args) {
|
|
662
|
+
const { options } = parseArgs(args);
|
|
663
|
+
const config = configFrom(options, []);
|
|
664
|
+
const names = ruleNames();
|
|
665
|
+
const off = offRules(config);
|
|
666
|
+
const lines = [
|
|
667
|
+
`hanlint ${version}`,
|
|
668
|
+
"",
|
|
669
|
+
`node ${process.version.replace(/^v/, "")}`,
|
|
670
|
+
`설정 ${configLabel(config)}`,
|
|
671
|
+
`프리셋 ${config.preset} (${PRESET_NAMES.join(", ")} 가운데)`,
|
|
672
|
+
`규칙 ${names.length - off.length}개 켜짐, ${off.length}개 꺼짐`,
|
|
673
|
+
`잠금 ${baselineState(config)}`,
|
|
674
|
+
`폴더 점으로 시작하는 폴더와 ${SKIPPED_FOLDERS.join(", ")} 는 건너뛴다. 직접 주면 검사한다`,
|
|
675
|
+
];
|
|
676
|
+
if (off.length) lines.push(`꺼진 규칙 ${off.join(", ")}`);
|
|
677
|
+
lines.push("", "다음: hanlint 글.md 로 검사한다. 설정이 기본값이면 hanlint init 으로 파일을 만든다");
|
|
678
|
+
process.stdout.write(`${lines.join("\n")}\n`);
|
|
288
679
|
return 0;
|
|
289
680
|
}
|
|
290
681
|
|
|
291
|
-
/** 주석 달린 hanlint.toml. 파이썬 `hanlint init` 과 같은 글자다. */
|
|
292
|
-
export function renderInit() {
|
|
682
|
+
/** 주석 달린 hanlint.toml. 파이썬 `hanlint init` 과 같은 글자다. @param {string} [preset] */
|
|
683
|
+
export function renderInit(preset = "blog") {
|
|
293
684
|
const defaults = defaultConfig();
|
|
294
|
-
const lines = [
|
|
295
|
-
|
|
685
|
+
const lines = [
|
|
686
|
+
"# hanlint 설정. 규칙을 끄려면 disable 에 이름을 넣는다. 규칙의 기술서는 hanlint explain <규칙>.",
|
|
687
|
+
"",
|
|
688
|
+
"# 글의 종류. 그 종류에 안 맞는 규칙을 처음부터 끈다. disable 은 그 위에 더한다.",
|
|
689
|
+
];
|
|
690
|
+
for (const [name, off] of Object.entries(PRESETS)) lines.push(`# ${name}: ${off.length ? off.join(", ") : "전부 켠다"}`);
|
|
691
|
+
lines.push(`preset = "${preset}"`, "", "# 규칙 목록과 한 줄 설명");
|
|
692
|
+
for (const [category, title] of Object.entries(CATEGORY_TITLES)) {
|
|
693
|
+
const inside = ruleNames().filter((name) => ruleCategory(name) === category);
|
|
694
|
+
if (!inside.length) continue;
|
|
695
|
+
lines.push(`# ${title}`);
|
|
696
|
+
for (const name of inside) lines.push(`# ${name}: ${ruleSummary(name)}`);
|
|
697
|
+
}
|
|
296
698
|
lines.push(
|
|
297
699
|
"",
|
|
298
700
|
"disable = []",
|
|
299
701
|
"",
|
|
300
|
-
"# surface 는 의존성 0 기본, kiwi 는 pip install hanlint[kiwi] 가 있을 때",
|
|
301
|
-
`analyzer = "${defaults.analyzer}"`,
|
|
302
|
-
"",
|
|
303
702
|
"# 대표 검색어를 읽을 frontmatter 필드. 없으면 keywordMissing 은 돌지 않는다",
|
|
304
703
|
'# keywordField = "primaryKeyword"',
|
|
305
704
|
"",
|
|
306
|
-
"#
|
|
705
|
+
"# 도입과 마지막 절이 담아야 하는 frontmatter 필드. 비어 있으면 fieldEcho 는 돌지 않는다",
|
|
706
|
+
'# introFields = ["readerQuestion"]',
|
|
707
|
+
'# endingFields = ["readerTakeaway"]',
|
|
708
|
+
"",
|
|
709
|
+
"# hanlint profile build 가 만든 파일. 있으면 종류의 프로파일 대신 그것과 견준다 (outsideProfile)",
|
|
307
710
|
'# profile = "profile.json"',
|
|
308
711
|
"",
|
|
712
|
+
"# hanlint baseline 이 만든 잠금 파일. 있으면 그 안의 지적은 넘기고 새로 생긴 것만 막는다",
|
|
713
|
+
'# baseline = ".hanlint-baseline.json"',
|
|
714
|
+
"",
|
|
715
|
+
"# 코드도 산문도 아닌 펜스의 언어 표기. 장면 계약과 도표 원문은 코드 블록으로 세지 않고 지문에서 뺀다",
|
|
716
|
+
'# ignoreFences = ["course-scene", "mermaid"]',
|
|
717
|
+
"",
|
|
309
718
|
"# 임계. 기본값의 정본은 hanlint 의 config/settings.py 다",
|
|
310
719
|
);
|
|
311
720
|
for (const name of THRESHOLD_FIELDS) {
|
|
@@ -320,6 +729,35 @@ export function renderInit() {
|
|
|
320
729
|
'# cliches = ["우리의 여정"]',
|
|
321
730
|
'# translationese = [{ pattern = "에 대한 이해", fix = "를 아는 것" }]',
|
|
322
731
|
"",
|
|
732
|
+
"# 표면 치환이 바꾸면 안 되는 한국어 고유명사와 프로젝트 용어. 수치와 라틴 식별자는 자동으로 보호한다",
|
|
733
|
+
'# protectedTerms = ["한린트", "김민지"]',
|
|
734
|
+
"",
|
|
735
|
+
"# 사람이 승인한 프로젝트 본보기. 같은 규칙과 프리셋의 내장 본보기를 덮어쓴다",
|
|
736
|
+
"# [[exemplars]]",
|
|
737
|
+
'# rule = "translationese"',
|
|
738
|
+
'# before = "설계에 대한 이해가 필요합니다."',
|
|
739
|
+
'# after = "설계를 알아야 합니다."',
|
|
740
|
+
'# moved = "명사구를 서술어로 풀어 씀"',
|
|
741
|
+
'# presets = ["blog"]',
|
|
742
|
+
"",
|
|
743
|
+
"# 사람이 승인한 국소 고침. 원문을 포함한 모든 조건이 맞을 때만 그대로 재생한다",
|
|
744
|
+
"# [[patches]]",
|
|
745
|
+
'# rule = "translationese"',
|
|
746
|
+
'# before = "설계에 대한 이해가 필요합니다."',
|
|
747
|
+
'# after = "설계를 알아야 합니다."',
|
|
748
|
+
'# moved = "명사구를 서술어로 풀어 씀"',
|
|
749
|
+
'# sourceText = "설계에 대한 이해가 필요합니다." # before의 마크다운까지 보존한 선택용 원문',
|
|
750
|
+
'# sentence = "설계에 대한 이해가 필요합니다." # before에서 마크다운 표식을 걷은 선택용 원문',
|
|
751
|
+
'# cue = "에 대한"',
|
|
752
|
+
'# reader = "new"',
|
|
753
|
+
'# presets = ["blog"]',
|
|
754
|
+
"",
|
|
755
|
+
"# 사람이 적용 범위까지 승인한 표면 치환. 단어 경계 한 자리와 보호 원자가 맞을 때만 결과를 낸다",
|
|
756
|
+
"# [[operations]]",
|
|
757
|
+
'# before = "여러가지"',
|
|
758
|
+
'# after = "여러 가지"',
|
|
759
|
+
'# presets = ["blog"]',
|
|
760
|
+
"",
|
|
323
761
|
);
|
|
324
762
|
return lines.join("\n");
|
|
325
763
|
}
|
|
@@ -327,15 +765,22 @@ export function renderInit() {
|
|
|
327
765
|
/** @param {string[]} args */
|
|
328
766
|
function runInit(args) {
|
|
329
767
|
const { options } = parseArgs(args);
|
|
330
|
-
const path = /** @type {string} */ (options["--
|
|
768
|
+
const path = /** @type {string} */ (options["--output"] ?? "hanlint.toml");
|
|
769
|
+
const preset = choose(/** @type {string} */ (options["--preset"] ?? "blog"), PRESET_NAMES, "--preset");
|
|
331
770
|
if (existsSync(path) && !options["--force"]) throw new Error(`${path} 가 이미 있다. 덮어쓰려면 --force`);
|
|
332
|
-
writeFileSync(path, renderInit(), "utf-8");
|
|
333
|
-
|
|
771
|
+
writeFileSync(path, renderInit(preset), "utf-8");
|
|
772
|
+
const off = PRESETS[preset];
|
|
773
|
+
const tail = off.length ? `preset ${preset} 이 ${off.length}개를 끈다` : `preset ${preset} 은 규칙을 전부 켠다`;
|
|
774
|
+
process.stdout.write(`${path} 를 만들었다. ${tail}. 더 끄려면 disable 에 이름을 넣는다\n`);
|
|
334
775
|
return 0;
|
|
335
776
|
}
|
|
336
777
|
|
|
337
778
|
/** @param {string[]} argv */
|
|
338
779
|
function dispatch(argv) {
|
|
780
|
+
if (!argv.length) {
|
|
781
|
+
process.stdout.write(`${welcome(version)}\n`);
|
|
782
|
+
return 0;
|
|
783
|
+
}
|
|
339
784
|
const [command, ...rest] = normalizeArgv(argv);
|
|
340
785
|
if (command === "-h" || command === "--help") {
|
|
341
786
|
process.stdout.write(`${USAGE}\n`);
|
|
@@ -346,13 +791,16 @@ function dispatch(argv) {
|
|
|
346
791
|
return 0;
|
|
347
792
|
}
|
|
348
793
|
if (PYTHON_ONLY.includes(command)) {
|
|
349
|
-
throw new Error(`${command} 는 파이썬 패키지에 있다 (pip install hanlint). npm 은
|
|
794
|
+
throw new Error(`${command} 는 파이썬 패키지에 있다 (pip install hanlint). npm 은 ${COMMANDS.join(", ")} 을 제공한다`);
|
|
350
795
|
}
|
|
351
796
|
if (command === "lint") return runLint(rest);
|
|
352
797
|
if (command === "fix") return runFix(rest);
|
|
353
798
|
if (command === "print") return runPrint(rest);
|
|
354
799
|
if (command === "rules") return runRules(rest);
|
|
355
800
|
if (command === "explain") return runExplain(rest);
|
|
801
|
+
if (command === "patterns") return runPatterns(rest);
|
|
802
|
+
if (command === "baseline") return runBaseline(rest);
|
|
803
|
+
if (command === "doctor") return runDoctor(rest);
|
|
356
804
|
return runInit(rest);
|
|
357
805
|
}
|
|
358
806
|
|