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