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.
Files changed (146) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +23 -5
  3. package/data/adjectiveStems.txt +118 -0
  4. package/data/baselineAnchors.txt +3 -0
  5. package/data/blindEvaluation.schema.json +26 -0
  6. package/data/bridgeOpeners.txt +10 -0
  7. package/data/candidateActionNouns.txt +39 -0
  8. package/data/candidateSplitEndings.txt +13 -0
  9. package/data/cliches.json +1 -1
  10. package/data/confusable.json +2 -2
  11. package/data/deixis.txt +10 -3
  12. package/data/draftHistory.txt +10 -0
  13. package/data/easyWords.json +0 -42
  14. package/data/enoughMarkers.txt +6 -0
  15. package/data/entailmentPredictions.schema.json +52 -0
  16. package/data/euiNouns.txt +3 -4
  17. package/data/evidenceEntailmentBenchmark.schema.json +126 -0
  18. package/data/exemplars.json +347 -0
  19. package/data/holeKinds.json +1 -2
  20. package/data/iVerbStems.txt +26 -0
  21. package/data/inNouns.txt +147 -0
  22. package/data/irregularStems.txt +64 -0
  23. package/data/nonNouns.txt +153 -0
  24. package/data/panelAssignmentReview.schema.json +117 -0
  25. package/data/panelJudgePredictions.schema.json +78 -0
  26. package/data/panelReviewBatch.schema.json +79 -0
  27. package/data/panelTrialSet.schema.json +44 -0
  28. package/data/patterns.json +114 -0
  29. package/data/profiles.json +1 -0
  30. package/data/readerCalls.txt +12 -7
  31. package/data/redundantPair.json +1 -1
  32. package/data/rieulStems.txt +30 -0
  33. package/data/ruleCategories.json +56 -0
  34. package/data/ruleDocs.json +29 -17
  35. package/data/ruleMechanisms.json +56 -0
  36. package/data/spacing.json +0 -12
  37. package/data/spelling.json +6 -24
  38. package/data/translationese.json +10 -0
  39. package/data/verbTails.txt +17 -0
  40. package/data/version.json +1 -1
  41. package/data/writingBrief.schema.json +64 -0
  42. package/data/writingBriefV2.schema.json +116 -0
  43. package/data/writingTrial.schema.json +36 -0
  44. package/package.json +13 -5
  45. package/src/analysis/grammar/ending.js +336 -0
  46. package/src/analysis/grammar/hangul.js +94 -0
  47. package/src/analysis/grammar/index.js +5 -0
  48. package/src/analysis/grammar/josa.js +73 -0
  49. package/src/analysis/grammar/register.js +115 -0
  50. package/src/analysis/grammar/voice.js +43 -0
  51. package/src/analysis/index.js +4 -31
  52. package/src/analysis/josa.js +2 -0
  53. package/src/analysis/{surface/splitSentences.js → splitSentences.js} +1 -1
  54. package/src/analysis/{surface/tokenize.js → tokenize.js} +70 -13
  55. package/src/baseline/store.js +114 -0
  56. package/src/cli/main.js +506 -58
  57. package/src/cli/walk.js +30 -0
  58. package/src/cli/welcome.js +52 -0
  59. package/src/config/settings.js +103 -8
  60. package/src/data/exemplars.js +177 -0
  61. package/src/data/load.js +10 -0
  62. package/src/data/operations.js +278 -0
  63. package/src/data/patches.js +125 -0
  64. package/src/data/patterns.js +48 -0
  65. package/src/data/profiles.js +102 -0
  66. package/src/document/model.js +2 -0
  67. package/src/document/parseMarkdown.js +84 -11
  68. package/src/edit/applyFixes.js +0 -0
  69. package/src/fingerprint/build.js +50 -32
  70. package/src/{rules/shared → fingerprint}/codeBlocks.js +11 -11
  71. package/src/fingerprint/codeMarkers.js +73 -0
  72. package/src/fingerprint/markers.js +9 -0
  73. package/src/fingerprint/readerState.js +126 -0
  74. package/src/fingerprint/sourceSentence.js +37 -0
  75. package/src/fingerprint/topics.js +1 -1
  76. package/src/index.js +2 -16
  77. package/src/report/compactReport.js +6 -1
  78. package/src/report/fingerprintJson.js +8 -3
  79. package/src/report/jsonReport.js +50 -3
  80. package/src/report/operationMatch.js +31 -0
  81. package/src/report/patchMatch.js +44 -0
  82. package/src/report/registerMatch.js +40 -0
  83. package/src/report/textReport.js +41 -5
  84. package/src/rules/code/duplicateBlock.js +4 -3
  85. package/src/rules/code/firstResultDistance.js +1 -0
  86. package/src/rules/code/inputFileSource.js +23 -68
  87. package/src/rules/code/installImport.js +4 -4
  88. package/src/rules/code/platformApi.js +7 -6
  89. package/src/rules/document/countMismatch.js +22 -6
  90. package/src/rules/document/enoughOnce.js +25 -0
  91. package/src/rules/document/fieldEcho.js +38 -0
  92. package/src/rules/document/keywordHeading.js +27 -0
  93. package/src/rules/document/keywordMissing.js +1 -0
  94. package/src/rules/document/noQuestion.js +5 -1
  95. package/src/rules/document/promiseRecall.js +4 -2
  96. package/src/rules/document/tableOddCell.js +60 -0
  97. package/src/rules/finding.js +24 -2
  98. package/src/rules/orthography/confusable.js +1 -0
  99. package/src/rules/orthography/spacing.js +1 -0
  100. package/src/rules/orthography/spelling.js +1 -0
  101. package/src/rules/paragraph/factListParagraph.js +1 -0
  102. package/src/rules/paragraph/paraFragment.js +21 -11
  103. package/src/rules/paragraph/topicBreak.js +1 -0
  104. package/src/rules/registry.js +72 -3
  105. package/src/rules/sentence/cliche.js +1 -0
  106. package/src/rules/sentence/connectorRepeat.js +8 -7
  107. package/src/rules/sentence/danglingDeixis.js +8 -3
  108. package/src/rules/sentence/dash.js +2 -1
  109. package/src/rules/sentence/deixis.js +7 -3
  110. package/src/rules/sentence/doubleNegative.js +4 -1
  111. package/src/rules/sentence/doublePassive.js +38 -1
  112. package/src/rules/sentence/draftHistory.js +24 -0
  113. package/src/rules/sentence/ellipsis.js +17 -3
  114. package/src/rules/sentence/endingRepeat.js +6 -2
  115. package/src/rules/sentence/euiChain.js +3 -2
  116. package/src/rules/sentence/fillerOpener.js +1 -0
  117. package/src/rules/sentence/hardWord.js +1 -0
  118. package/src/rules/sentence/imperativePeriod.js +2 -1
  119. package/src/rules/sentence/japaneseLoan.js +1 -0
  120. package/src/rules/sentence/longSentence.js +3 -1
  121. package/src/rules/sentence/negationRedefine.js +10 -1
  122. package/src/rules/sentence/nounPile.js +1 -0
  123. package/src/rules/sentence/numberOrphan.js +45 -0
  124. package/src/rules/sentence/outsideProfile.js +68 -0
  125. package/src/rules/sentence/redundantPair.js +1 -0
  126. package/src/rules/sentence/translationese.js +1 -0
  127. package/src/rules/shared/candidates.js +129 -0
  128. package/src/rules/shared/dictionaryRule.js +12 -2
  129. package/src/rules/shared/localAntecedent.js +12 -0
  130. package/src/rules/shared/repeat.js +48 -0
  131. package/src/rules/structure/blockUnread.js +33 -0
  132. package/src/rules/structure/bridgeRepeat.js +40 -0
  133. package/src/rules/structure/emojiBullet.js +1 -0
  134. package/src/rules/structure/headingQuestion.js +19 -0
  135. package/src/rules/structure/headingSentence.js +9 -4
  136. package/src/rules/structure/headingSkip.js +1 -0
  137. package/src/rules/structure/headingUniform.js +9 -17
  138. package/src/rules/structure/introImage.js +19 -0
  139. package/src/rules/structure/introLong.js +1 -0
  140. package/src/rules/structure/loneSubheading.js +42 -0
  141. package/src/rules/structure/moreLater.js +48 -0
  142. package/src/rules/structure/sectionNoProse.js +1 -0
  143. package/src/rules/structure/sectionResult.js +3 -2
  144. package/src/text.js +19 -0
  145. package/src/rules/document/readerAbsent.js +0 -11
  146. 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 글.md [다른.md ...] 검사. 서브커맨드 없이 파일만 주면 lint 다. `-` 는 stdin
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 init 주석 달린 hanlint.toml
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, map, profile 과 kiwi 정밀 모드는 파이썬 패키지 (pip install hanlint) 에 있다.
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 { analyzerFor, fingerprint, lintText, ruleDoc, ruleNames, ruleSummary, version } from "../index.js";
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 { runAll } from "../rules/registry.js";
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 = ["audit", "map", "profile", "coverage", "diff"];
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
- const ANALYZER_CHOICES = ["surface", "kiwi"];
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 규칙] [--output 파일] [--quiet] [--path 이름 (stdin 의 이름)]
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 init [--path hanlint.toml] [--force]
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
- audit, map, profile 과 kiwi 정밀 모드는 파이썬 패키지 (pip install hanlint) 에 있다.`;
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
- "--analyzer": "value",
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
- /** 서브커맨드 없이 파일이나 옵션만 주면 lint 로 본다. @param {string[]} argv */
125
- export function normalizeArgv(argv) {
126
- if (!argv.length) return ["lint"];
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["--analyzer"]) config.analyzer = choose(/** @type {string} */ (options["--analyzer"]), ANALYZER_CHOICES, "--analyzer");
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, readFileSync(path, "utf-8")];
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, positionals);
195
- analyzerFor(config);
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
- for (const path of positionals) {
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
- results.set(name, lintText(text, config, name));
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(renderJson(shown, configLabel(config)), output);
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(`설정: ${configLabel(config)}`);
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(shown));
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([...shown].map(([name, findings]) => renderText(name, findings)).join("\n\n"));
226
- if (shown.size > 1) parts.push(summary(shown));
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 config = configFrom(options, positionals);
238
- const analyzer = analyzerFor(config);
239
- void analyzer;
472
+ const files = collectFiles(positionals);
473
+ const config = configFrom(options, files);
240
474
  const lines = [];
241
- for (const path of positionals) {
475
+ for (const path of files) {
242
476
  const text = readFileSync(path, "utf-8");
243
477
  // notice 는 제안이라 손으로 정한다. 확정된 error 만 원문에 넣는다.
244
- const result = applyFixes(
245
- text,
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
- lines.push(`${path} ${result.applied.length}곳 고침, ${result.skipped.length}곳 건너뜀` + (options["--dry-run"] ? " (미리보기, 파일은 그대로)" : ""));
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
- process.stdout.write(`${names.join("\n")}\n`);
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 = names.map((name) => `${name.padEnd(width)} ${ruleSummary(name)}`);
278
- process.stdout.write(`${lines.join("\n")}\n\n규칙 ${names.length}개. 자세히 보려면 hanlint explain <규칙>\n`);
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 doc = ruleDoc(positionals[0]);
287
- process.stdout.write(`${positionals[0]}\n\n${doc}\n`);
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 = ["# hanlint 설정. 규칙을 끄려면 disable 에 이름을 넣는다. 규칙의 기술서는 hanlint explain <규칙>.", "", "# 규칙 목록과 한 줄 설명"];
295
- for (const name of ruleNames()) lines.push(`# ${name}: ${ruleSummary(name)}`);
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
- "# hanlint profile build 가 만든 파일. 있으면 참조 글과의 편차 구간을 notice 로 더한다",
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["--path"] ?? "hanlint.toml");
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
- process.stdout.write(`${path} 를 만들었다. 규칙을 끄려면 disable 에 이름을 넣는다\n`);
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 은 lint, fix, print, rules, explain, init 을 제공한다`);
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