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