hanlint 0.0.17 → 0.0.19

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 (136) hide show
  1. package/README.md +23 -20
  2. package/data/calqueFrame.json +60 -0
  3. package/data/cliches.json +39 -29
  4. package/data/collocationStops.txt +28 -0
  5. package/data/confusable.json +20 -20
  6. package/data/easyWords.json +12 -246
  7. package/data/exemplars.json +50 -29
  8. package/data/hiddenDeps.txt +11 -11
  9. package/data/japaneseLoan.json +27 -27
  10. package/data/koreanProse.txt +64 -0
  11. package/data/patterns.json +26 -26
  12. package/data/phraseRepeat.json +11 -0
  13. package/data/platformApis.txt +11 -11
  14. package/data/redundantPair.json +16 -16
  15. package/data/ruleCategories.json +2 -0
  16. package/data/ruleDocs.json +56 -54
  17. package/data/ruleMechanisms.json +2 -0
  18. package/data/screenNarration.json +10 -10
  19. package/data/screenSentence.json +60 -12
  20. package/data/screenTone.json +6 -6
  21. package/data/screenWord.json +70 -110
  22. package/data/spacing.json +12 -12
  23. package/data/spelling.json +29 -29
  24. package/data/translationese.json +23 -24
  25. package/data/usageCounts.report.json +1676 -0
  26. package/data/version.json +1 -1
  27. package/package.json +1 -1
  28. package/src/analysis/grammar/ending.js +1 -1
  29. package/src/analysis/grammar/hangul.js +4 -4
  30. package/src/analysis/grammar/josa.js +1 -1
  31. package/src/analysis/grammar/register.js +1 -1
  32. package/src/analysis/grammar/voice.js +1 -1
  33. package/src/analysis/index.js +3 -3
  34. package/src/analysis/josa.js +1 -1
  35. package/src/analysis/splitSentences.js +1 -1
  36. package/src/analysis/tokenize.js +41 -26
  37. package/src/baseline/store.js +8 -8
  38. package/src/cli/help.js +11 -10
  39. package/src/cli/main.js +332 -182
  40. package/src/cli/walk.js +2 -2
  41. package/src/cli/welcome.js +8 -8
  42. package/src/config/loadConfig.js +3 -3
  43. package/src/config/patch.js +6 -6
  44. package/src/config/readerContract.js +26 -26
  45. package/src/config/settings.js +44 -25
  46. package/src/config/toml.js +9 -9
  47. package/src/data/exemplars.js +21 -21
  48. package/src/data/load.js +6 -6
  49. package/src/data/operations.js +14 -14
  50. package/src/data/patches.js +10 -10
  51. package/src/data/patterns.js +5 -5
  52. package/src/data/profiles.js +6 -6
  53. package/src/data/prose.js +27 -0
  54. package/src/document/model.js +4 -4
  55. package/src/document/parseMarkdown.js +7 -7
  56. package/src/document/plainText.js +3 -3
  57. package/src/document/sourceText.js +117 -19
  58. package/src/edit/applyFixes.js +0 -0
  59. package/src/fingerprint/build.js +18 -9
  60. package/src/fingerprint/codeBlocks.js +3 -3
  61. package/src/fingerprint/codeMarkers.js +4 -4
  62. package/src/fingerprint/dictionaries.js +34 -7
  63. package/src/fingerprint/markers.js +27 -5
  64. package/src/fingerprint/readerState.js +9 -9
  65. package/src/fingerprint/sourceSentence.js +1 -1
  66. package/src/fingerprint/topics.js +1 -1
  67. package/src/guard/contract.js +6 -6
  68. package/src/guard/editLocality.js +1 -1
  69. package/src/guard/outline.js +1 -1
  70. package/src/guard/receipt.js +13 -13
  71. package/src/guard/surface.js +4 -4
  72. package/src/index.js +9 -7
  73. package/src/learn/edits.js +2 -2
  74. package/src/learn/operations.js +1 -1
  75. package/src/regex.js +2 -2
  76. package/src/report/compactReport.js +7 -3
  77. package/src/report/fingerprintJson.js +2 -2
  78. package/src/report/githubReport.js +1 -1
  79. package/src/report/jsonReport.js +3 -3
  80. package/src/report/registerMatch.js +2 -2
  81. package/src/report/sheet.js +0 -0
  82. package/src/report/textReport.js +35 -16
  83. package/src/report/writingSpec.js +17 -17
  84. package/src/rules/code/inputFileSource.js +1 -1
  85. package/src/rules/code/installImport.js +2 -2
  86. package/src/rules/code/platformApi.js +1 -1
  87. package/src/rules/document/countMismatch.js +2 -1
  88. package/src/rules/document/enoughOnce.js +2 -1
  89. package/src/rules/document/fieldEcho.js +10 -6
  90. package/src/rules/document/keywordHeading.js +1 -1
  91. package/src/rules/document/keywordMissing.js +1 -1
  92. package/src/rules/document/noQuestion.js +2 -2
  93. package/src/rules/document/promiseRecall.js +1 -1
  94. package/src/rules/document/tableOddCell.js +1 -1
  95. package/src/rules/finding.js +4 -4
  96. package/src/rules/paragraph/factListParagraph.js +1 -1
  97. package/src/rules/paragraph/paraFragment.js +1 -1
  98. package/src/rules/readerDebt.js +1 -1
  99. package/src/rules/registry.js +30 -26
  100. package/src/rules/screen/screenWord.js +2 -1
  101. package/src/rules/sentence/calqueFrame.js +11 -0
  102. package/src/rules/sentence/cliche.js +7 -3
  103. package/src/rules/sentence/connectorRepeat.js +2 -1
  104. package/src/rules/sentence/danglingDeixis.js +1 -1
  105. package/src/rules/sentence/dash.js +1 -1
  106. package/src/rules/sentence/deixis.js +1 -1
  107. package/src/rules/sentence/doubleNegative.js +2 -1
  108. package/src/rules/sentence/doublePassive.js +2 -1
  109. package/src/rules/sentence/draftHistory.js +2 -1
  110. package/src/rules/sentence/endingRepeat.js +3 -2
  111. package/src/rules/sentence/euiChain.js +1 -1
  112. package/src/rules/sentence/fillerOpener.js +1 -1
  113. package/src/rules/sentence/hardWord.js +6 -3
  114. package/src/rules/sentence/imperativePeriod.js +1 -1
  115. package/src/rules/sentence/japaneseLoan.js +7 -3
  116. package/src/rules/sentence/longSentence.js +1 -1
  117. package/src/rules/sentence/negationRedefine.js +1 -1
  118. package/src/rules/sentence/nounPile.js +10 -4
  119. package/src/rules/sentence/numberOrphan.js +1 -1
  120. package/src/rules/sentence/outsideProfile.js +8 -3
  121. package/src/rules/sentence/phraseRepeat.js +54 -0
  122. package/src/rules/sentence/redundantPair.js +7 -3
  123. package/src/rules/sentence/translationese.js +7 -3
  124. package/src/rules/shared/candidates.js +2 -2
  125. package/src/rules/shared/dictionaryRule.js +46 -8
  126. package/src/rules/shared/repeat.js +2 -2
  127. package/src/rules/structure/bridgeRepeat.js +3 -3
  128. package/src/rules/structure/emojiBullet.js +1 -1
  129. package/src/rules/structure/headingSkip.js +1 -1
  130. package/src/rules/structure/headingUniform.js +2 -1
  131. package/src/rules/structure/loneSubheading.js +1 -1
  132. package/src/rules/structure/moreLater.js +1 -1
  133. package/src/text.js +2 -2
  134. package/src/usage/counts.js +54 -0
  135. package/src/usage/index.js +8 -0
  136. package/src/usage/sentences.js +681 -0
package/src/cli/main.js CHANGED
@@ -1,21 +1,21 @@
1
1
  // @ts-check
2
2
  /**
3
- * 명령줄 진입점. 파이썬 CLI 와 같은 인자, 같은 출력, 같은 종료 코드다 (0 지적 없음, 1 error 있음, 2 파일이나 설정 문제).
3
+ * 명령줄 진입점. 파이썬 CLI와 같은 인자, 같은 출력, 같은 종료 코드다 (0 지적 없음, 1 error 있음, 2 파일이나 설정 문제).
4
4
  *
5
5
  * ```
6
6
  * hanlint 인자가 없으면 첫 화면. 무엇을 칠 수 있는지 보인다
7
- * hanlint 글.md [다른.md ...] 검사. 서브커맨드 없이 파일이나 폴더만 주면 lint 다. `-` 는 stdin
7
+ * hanlint 글.md [다른.md ...] 검사. 서브커맨드 없이 파일이나 폴더만 주면 lint다. `-`는 stdin
8
8
  * hanlint fix 글.md 기계가 고칠 수 있는 지적을 원문에 적용
9
- * hanlint print 글.md 지문 계층 JSON
10
- * hanlint rules 규칙 목록을 부류로 묶어서
9
+ * hanlint print 글.md 분석표 계층 JSON
10
+ * hanlint rules 규칙 목록을 묶음로 묶어서
11
11
  * hanlint explain <규칙> 규칙의 기술서
12
- * hanlint patterns 문장 틀. --rule 로 그 규칙을 피하는 것만
13
- * hanlint primer --preset <종류> 쓰기 전에 읽는 한 장. 켜진 규칙의 고치는 법과 본보기 전후
14
- * hanlint hook Claude Code가 쓴 마크다운을 같은 턴에 비차단 검사
12
+ * hanlint patterns 문장 틀. --rule로 그 규칙을 피하는 것만
13
+ * hanlint primer --preset <종류> 쓰기 전에 읽는 한 장. 글쓰기 지침 일곱 줄과 켜진 규칙의 고치는 법, 예시 전후
14
+ * hanlint hook 에이전트 훅. --prime은 쓰기 전 글쓰기 지침, 나머지는 쓴 마크다운의 비차단 검사
15
15
  * hanlint baseline 글들/ 지금 있는 지적을 잠근다. 그다음부터 새것만 막힌다
16
16
  * hanlint baseline 글들/ 지금 있는 지적을 잠근다. 그다음부터 새것만 막힌다
17
17
  * hanlint doctor 설정과 꺼진 규칙
18
- * hanlint init 주석 달린 hanlint.toml. --output 과 --preset blog|report|docs
18
+ * hanlint init 주석 달린 hanlint.toml. --output과 --preset blog|report|docs
19
19
  * hanlint contract init 글.md 원문에서 최소 Reader Contract 초안
20
20
  * ```
21
21
  * audit, guard, arena, blueprint, evidence, entailment 같은 확장 명령은 파이썬 패키지에 있다.
@@ -43,12 +43,22 @@ import { loadConfig } from "../config/loadConfig.js";
43
43
  import { defaultConfig, offRules, PRESET_NAMES, PRESETS } from "../config/settings.js";
44
44
  import { applyFixes } from "../edit/applyFixes.js";
45
45
  import { exemplarFor } from "../data/exemplars.js";
46
+ import { proseGuide, proseSections } from "../data/prose.js";
46
47
  import { patterns, patternsAvoiding } from "../data/patterns.js";
47
48
  import { HAPNIDA, REGISTERS } from "../analysis/grammar/index.js";
48
49
  import { CATEGORY_TITLES, MECHANISMS, ruleCategory, ruleFix, ruleMechanism, runAll } from "../rules/registry.js";
49
50
  import { MARKDOWN, SKIPPED_FOLDERS, isSkipped, markdownUnder } from "./walk.js";
50
- import { SOURCE_SUFFIXES, replaceLiteral, sourceLiterals } from "../document/sourceText.js";
51
- import { parseSheet, renderSheet, renderSheetJson } from "../report/sheet.js";
51
+ import { SOURCE_SUFFIXES } from "../document/sourceText.js";
52
+ import { applyRows, parseSheet, renderSheet, renderSheetJson, sheetRows } from "../report/sheet.js";
53
+ import {
54
+ buildIndex as buildUsageIndex,
55
+ defaultRoot as defaultUsageRoot,
56
+ listIndexes as listUsageIndexes,
57
+ loadIndex as loadUsageIndex,
58
+ queryCores as usageQueryCores,
59
+ readDocuments as readUsageDocuments,
60
+ } from "../usage/sentences.js";
61
+ import { USAGE_KINDS } from "../config/settings.js";
52
62
  import { welcome } from "./welcome.js";
53
63
  import { rootHelp } from "./help.js";
54
64
  import { renderCompact } from "../report/compactReport.js";
@@ -59,7 +69,7 @@ import { renderText } from "../report/textReport.js";
59
69
  import { renderWritingSpec, writingSpec } from "../report/writingSpec.js";
60
70
  import { exemplarInRegister, patternInRegister } from "../report/registerMatch.js";
61
71
 
62
- const COMMANDS = ["lint", "fix", "print", "rules", "explain", "patterns", "primer", "spec", "hook", "baseline", "doctor", "init", "contract", "check", "verify-patch", "sheet"];
72
+ const COMMANDS = ["lint", "fix", "print", "rules", "explain", "patterns", "primer", "spec", "hook", "baseline", "doctor", "init", "contract", "check", "verify-patch", "sheet", "usage"];
63
73
  const PYTHON_ONLY = [
64
74
  "audit",
65
75
  "map",
@@ -78,11 +88,11 @@ const PYTHON_ONLY = [
78
88
  ];
79
89
  const FORMATS = ["text", "compact", "json", "github"];
80
90
  const SEVERITIES = ["all", "error", "notice"];
81
- /** 오탐과 미탐을 받는 자리. 뜻은 파이썬 cli/commands/explain.py 가 소유한다. */
91
+ /** 오탐과 미탐을 받는 자리. 뜻은 파이썬 cli/commands/explain.py가 소유한다. */
82
92
  const ISSUES = "github.com/eddmpython/hanlint/issues";
83
93
  const STDIN = "-";
84
94
  const STDIN_NAME = "<stdin>";
85
- /** 폴더를 주면 이 확장자만 찾는다. 파이썬 cli/commands/shared.py 의 MARKDOWN 과 같다. */
95
+ /** 폴더를 주면 이 확장자만 찾는다. 파이썬 cli/commands/shared.py의 MARKDOWN과 같다. */
86
96
  const THRESHOLD_FIELDS = [
87
97
  "fragmentRun",
88
98
  "introMaxParagraphs",
@@ -116,12 +126,15 @@ const OPTION_KINDS = {
116
126
  "--quiet": "flag",
117
127
  "--severity": "value",
118
128
  "--errors-only": "flag",
129
+ "--notices": "flag",
119
130
  "--path": "value",
120
131
  "--layer": "value",
121
132
  "--dry-run": "flag",
122
133
  "--names": "flag",
123
134
  "--force": "flag",
124
135
  "--reply": "flag",
136
+ "--agent": "value",
137
+ "--prime": "flag",
125
138
  "--reader": "value",
126
139
  "--goal": "value",
127
140
  "--outline": "value",
@@ -133,6 +146,9 @@ const OPTION_KINDS = {
133
146
  "--baseline": "optional",
134
147
  "--prune": "flag",
135
148
  "--all": "flag",
149
+ "--kind": "value",
150
+ "--limit": "value",
151
+ "--root": "value",
136
152
  };
137
153
 
138
154
  class UsageError extends Error {}
@@ -164,13 +180,13 @@ function parseArgs(args) {
164
180
  options[arg] = true;
165
181
  continue;
166
182
  }
167
- // 값을 줘도 되고 안 줘도 되는 옵션. 다음 인자가 다른 옵션이거나 없으면 true 로 둔다 (파이썬의 nargs="?").
183
+ // 값을 줘도 되고 안 줘도 되는 옵션. 다음 인자가 다른 옵션이거나 없으면 true로 둔다 (파이썬의 nargs="?").
168
184
  if (kind === "optional" && inlineValue === null && (args[i + 1] === undefined || args[i + 1].startsWith("--"))) {
169
185
  options[arg] = true;
170
186
  continue;
171
187
  }
172
188
  const value = inlineValue ?? args[++i];
173
- if (value === undefined) throw new UsageError(`${arg} 에 값이 없다`);
189
+ if (value === undefined) throw new UsageError(`${arg}에 값이 없다`);
174
190
  if (kind === "list") {
175
191
  const list = /** @type {string[]} */ (options[arg] ?? []);
176
192
  list.push(value);
@@ -183,10 +199,10 @@ function parseArgs(args) {
183
199
  }
184
200
 
185
201
  /**
186
- * 서브커맨드 없이 파일이나 옵션만 주면 lint 로 본다. 빈 인자는 여기서 다루지 않는다. `main` 이 첫 화면으로 보낸다.
202
+ * 서브커맨드 없이 파일이나 옵션만 주면 lint로 본다. 빈 인자는 여기서 다루지 않는다. `main`이 첫 화면으로 보낸다.
187
203
  * @param {string[]} argv
188
204
  */
189
- export /** 파일 이름이 아니라 서브커맨드를 치려던 것으로 보이나. 뜻은 파이썬 cli/main.py 가 소유한다. @param {string} word */
205
+ export /** 파일 이름이 아니라 서브커맨드를 치려던 것으로 보이나. 뜻은 파이썬 cli/main.py가 소유한다. @param {string} word */
190
206
  function looksLikeCommand(word) {
191
207
  if (!word || !/^[A-Za-z]+$/.test(word)) return false;
192
208
  return !existsSync(word);
@@ -197,15 +213,15 @@ function normalizeArgv(argv) {
197
213
  if (COMMANDS.includes(argv[0]) || PYTHON_ONLY.includes(argv[0]) || ["-h", "--help", "--version"].includes(argv[0])) return argv;
198
214
  if (looksLikeCommand(argv[0])) {
199
215
  const near = nearNames(argv[0], [...COMMANDS, ...PYTHON_ONLY]);
200
- const hint = near.length ? ` 가까운 이름: ${near.join(", ")}.` : "";
201
- throw new Error(`${argv[0]} 는 모르는 명령이다.${hint} 전체 목록은 hanlint --help`);
216
+ const hint = near.length ? ` 비슷한 이름: ${near.join(", ")}.` : "";
217
+ throw new Error(`없는 명령: ${argv[0]}.${hint} 전체 목록은 hanlint --help`);
202
218
  }
203
219
  return ["lint", ...argv];
204
220
  }
205
221
 
206
222
  /** @param {string} value @param {string[]} choices @param {string} option */
207
223
  function choose(value, choices, option) {
208
- if (!choices.includes(value)) throw new UsageError(`${option} 는 ${choices.join(", ")} 가운데 하나다: ${value}`);
224
+ if (!choices.includes(value)) throw new UsageError(`${option} 값으로 쓸 수 없다: ${value}. ${choices.join(", ")} 가운데 하나를 쓴다`);
209
225
  return value;
210
226
  }
211
227
 
@@ -216,10 +232,10 @@ function emit(text, output) {
216
232
  return;
217
233
  }
218
234
  writeFileSync(output, text.endsWith("\n") ? text : `${text}\n`, "utf-8");
219
- process.stderr.write(`${output} 에 썼다\n`);
235
+ process.stderr.write(`${output}에 썼다\n`);
220
236
  }
221
237
 
222
- /** 설정을 찾기 시작할 폴더. 첫 실제 파일의 폴더, 전부 stdin 이면 현재 폴더. @param {string[]} paths */
238
+ /** 설정을 찾기 시작할 폴더. 첫 실제 파일의 폴더, 전부 stdin이면 현재 폴더. @param {string[]} paths */
223
239
  function startFolder(paths) {
224
240
  for (const path of paths) if (path !== STDIN) return dirname(resolve(path));
225
241
  return process.cwd();
@@ -231,8 +247,8 @@ function checkDisabled(config) {
231
247
  const unknown = [...config.disable].filter((rule) => !names.includes(rule)).sort();
232
248
  if (!unknown.length) return;
233
249
  const near = nearNames(unknown[0], names);
234
- const hint = near.length ? ` 가까운 이름: ${near.join(", ")}.` : "";
235
- throw new Error(`모르는 규칙 이름: ${unknown.join(", ")}.${hint} 목록은 hanlint rules --names`);
250
+ const hint = near.length ? ` 비슷한 이름: ${near.join(", ")}.` : "";
251
+ throw new Error(`없는 규칙 이름: ${unknown.join(", ")}.${hint} 목록은 hanlint rules --names`);
236
252
  }
237
253
 
238
254
  /** @param {Record<string, string | string[] | boolean>} options @param {string[]} paths */
@@ -249,7 +265,7 @@ function configFrom(options, paths, start = null) {
249
265
  function configLabel(config) {
250
266
  if (config.source === null) return "기본값";
251
267
  const rel = relative(process.cwd(), config.source);
252
- // 경로 구분자는 언제나 /. 설정이 작업 폴더 밖일 때 두 판의 첫 줄이 갈렸다. 파이썬과 같다.
268
+ // 경로 구분자는 언제나 /. 설정이 작업 폴더 밖일 때 두 구현의 첫 줄이 갈렸다. 파이썬과 같다.
253
269
  const label = !rel || rel.startsWith("..") || isAbsolute(rel) ? config.source : rel;
254
270
  return label.split(sep).join("/");
255
271
  }
@@ -262,7 +278,7 @@ function header(config) {
262
278
 
263
279
  /** @param {Map<string, import("../rules/finding.js").Finding[]>} results */
264
280
  function summary(results) {
265
- // 글에 실제로 있는 수. 거른 뒤가 아니라 거르기 전을 센다. 뜻은 파이썬 shared.summary 가 소유한다.
281
+ // 글에 실제로 있는 수. 거른 뒤가 아니라 거르기 전을 센다. 뜻은 파이썬 shared.summary가 소유한다.
266
282
  let errors = 0;
267
283
  let notices = 0;
268
284
  for (const findings of results.values()) {
@@ -271,16 +287,16 @@ function summary(results) {
271
287
  else if (f.severity === "notice") notices += 1;
272
288
  }
273
289
  }
274
- return `파일 ${results.size}개, error ${errors}, notice ${notices}`;
290
+ return `파일 ${results.size}개, 오류 ${errors}건, 경고 ${notices}건`;
275
291
  }
276
292
 
277
- /** (이름, 본문). `-` 면 stdin 을 UTF-8 로 읽는다. @param {string} path @param {string} stdinName @returns {[string, string]} */
293
+ /** (이름, 본문). `-`면 stdin을 UTF-8로 읽는다. @param {string} path @param {string} stdinName @returns {[string, string]} */
278
294
  function readInput(path, stdinName) {
279
295
  if (path === STDIN) return [stdinName, readFileSync(0, "utf-8")];
280
296
  return [path, readOne(path)];
281
297
  }
282
298
 
283
- /** 글 하나를 읽는다. 폴더를 주면 말로 막는다. 뜻은 파이썬 shared.readFile 이 소유한다. @param {string} path */
299
+ /** 글 하나를 읽는다. 폴더를 주면 말로 막는다. 뜻은 파이썬 shared.readFile이 소유한다. @param {string} path */
284
300
  function readOne(path) {
285
301
  let isFolder = false;
286
302
  try {
@@ -288,11 +304,11 @@ function readOne(path) {
288
304
  } catch {
289
305
  isFolder = false;
290
306
  }
291
- if (isFolder) throw new Error(`${path} 는 폴더다. 이 명령은 글 하나를 본다. 폴더는 hanlint 와 hanlint fix 가 받는다`);
307
+ if (isFolder) throw new Error(`폴더를 받았다: ${path}. 이 명령은 글 하나를 본다. 폴더는 hanlint와 hanlint fix가 받는다`);
292
308
  return readFileSync(path, "utf-8");
293
309
  }
294
310
 
295
- /** 폴더를 주면 그 아래 마크다운을 편다. 파일과 `-` 는 그대로 둔다. @param {string[]} paths */
311
+ /** 폴더를 주면 그 아래 마크다운을 편다. 파일과 `-`는 그대로 둔다. @param {string[]} paths */
296
312
  function collectFiles(paths) {
297
313
  /** @type {string[]} */
298
314
  const found = [];
@@ -314,7 +330,7 @@ function collectFiles(paths) {
314
330
  const inside = markdownUnder(path).sort();
315
331
  if (!inside.length)
316
332
  throw new Error(
317
- `${path} 안에 마크다운 파일이 없다. 점으로 시작하는 폴더와 node_modules 는 건너뛴다. 그 안을 보려면 그 폴더를 직접 준다`,
333
+ `${path} 안에 마크다운 파일이 없다. 점으로 시작하는 폴더와 node_modules는 건너뛴다. 그 안을 보려면 그 폴더를 직접 준다`,
318
334
  );
319
335
  found.push(...inside);
320
336
  }
@@ -330,7 +346,7 @@ function commonest(findings) {
330
346
  return [...counted.keys()].sort((a, b) => (counted.get(b) ?? 0) - (counted.get(a) ?? 0) || a.localeCompare(b))[0];
331
347
  }
332
348
 
333
- /** `hanlint fix` 가 실제로 고칠 error 수. 고침이 달렸다는 것과 자리를 잡을 수 있다는 것은 다르다.
349
+ /** `hanlint fix`가 실제로 고칠 error 수. 고침이 달렸다는 것과 자리를 잡을 수 있다는 것은 다르다.
334
350
  * @param {Map<string, string>} texts @param {Map<string, import("../rules/finding.js").Finding[]>} results */
335
351
  function fixableCount(texts, results) {
336
352
  let total = 0;
@@ -342,7 +358,7 @@ function fixableCount(texts, results) {
342
358
  return total;
343
359
  }
344
360
 
345
- /** 검사 끝에 붙는 다음 행동 한 줄. 뜻은 파이썬 cli/commands/shared.py 가 소유한다.
361
+ /** 검사 끝에 붙는 다음 행동 한 줄. 뜻은 파이썬 cli/commands/shared.py가 소유한다.
346
362
  * @param {Map<string, import("../rules/finding.js").Finding[]>} results @param {number} [fixable] */
347
363
  function nextStep(results, fixable = 0) {
348
364
  const findings = [...results.values()].flat();
@@ -350,17 +366,17 @@ function nextStep(results, fixable = 0) {
350
366
  const notices = findings.length - errors.length;
351
367
  if (errors.length) {
352
368
  const rule = commonest(errors);
353
- if (fixable) return `다음: error ${errors.length}건 가운데 ${fixable}건은 hanlint fix 가 바로 고친다. 나머지는 손으로 고친다`;
354
- if (patternsAvoiding(rule).length) return `다음: error ${errors.length}건을 고친다. 다시 쓸 틀은 hanlint patterns --rule ${rule}`;
355
- return `다음: error ${errors.length}건을 고친다. 규칙이 왜 있는지는 hanlint explain ${rule}`;
369
+ if (fixable) return `다음: 오류 ${errors.length}건 가운데 ${fixable}건은 hanlint fix가 바로 고친다. 나머지는 손으로 고친다`;
370
+ if (patternsAvoiding(rule).length) return `다음: 오류 ${errors.length}건을 고친다. 다시 쓸 틀은 hanlint patterns --rule ${rule}`;
371
+ return `다음: 오류 ${errors.length}건을 고친다. 규칙이 왜 있는지는 hanlint explain ${rule}`;
356
372
  }
357
- if (notices) return `다음: error 0. 확인할 자리 ${notices}건을 읽고 판단한 뒤 사람과 LLM 평가로 넘어간다`;
358
- return "다음: 세어서 잡히는 결함이 없다. 좋은 글이라는 뜻은 아니므로 사람과 LLM 평가로 넘어간다";
373
+ if (notices) return `다음: 오류는 없다. 경고 ${notices}건을 읽고 고칠지 정한다`;
374
+ return "다음: 규칙으로 찾은 잘못이 없다. 좋은 글이라는 뜻은 아니니 사람이 읽어 본다";
359
375
  }
360
376
 
361
377
  /** @param {Baseline} baseline */
362
378
  function lockedNote(baseline) {
363
- return `잠근 자리 ${baseline.count}건은 넘겼다 (${baseline.source}). 그 문장을 고치면 다시 나온다`;
379
+ return `baseline 파일에 든 지적 ${baseline.count}건은 보이지 않았다 (${baseline.source}). 그 문장을 고치면 다시 나온다`;
364
380
  }
365
381
 
366
382
  /** 옵션과 설정에서 잠금 파일을 연다. 둘 다 없으면 빈 잠금이다. @param {Record<string, string | string[] | boolean>} options @param {import("../config/settings.js").Config} config */
@@ -385,10 +401,10 @@ function runBaseline(args) {
385
401
  }
386
402
  const baseline = options["--prune"] ? pruneBaseline(loadBaseline(target), results) : buildBaseline(results, target);
387
403
  writeFileSync(target, `${renderBaseline(baseline)}\n`, "utf-8");
388
- const what = options["--prune"] ? "잠금을 줄였다" : "지적을 잠갔다";
404
+ const what = options["--prune"] ? "글에 더 없는 지적을 지웠다" : "지금 있는 지적이다";
389
405
  process.stdout.write(
390
- `${baseline.source} 에 ${baseline.count}건을 적었다. ${files.length}개 글의 ${what}.\n` +
391
- "다음: 검사할 때 --baseline 을 붙이거나 설정에 baseline 을 적는다. 잠긴 자리를 고치면 새 지적으로 다시 나온다\n",
406
+ `${baseline.source}에 ${baseline.count}건을 적었다. ${files.length}개 글에서 ${what}.\n` +
407
+ "다음: 검사할 때 --baseline을 붙이거나 설정에 baseline을 적는다. baseline 파일에 든 문장을 고치면 새 지적으로 다시 나온다\n",
392
408
  );
393
409
  return 0;
394
410
  }
@@ -430,7 +446,7 @@ function runContract(args) {
430
446
  }
431
447
  const output = options["--output"];
432
448
  if (typeof output === "string" && existsSync(output) && !options["--force"]) {
433
- throw new Error(`${output} 가 이미 있다. 덮어쓰려면 --force`);
449
+ throw new Error(`이미 있는 파일: ${output}. 덮어쓰려면 --force를 붙인다`);
434
450
  }
435
451
  const outline = options["--outline"];
436
452
  const facts = /** @type {string[]} */ (options["--fact"] ?? []);
@@ -528,7 +544,7 @@ function runLint(args) {
528
544
  } else {
529
545
  parts.push(
530
546
  [...shown]
531
- .map(([name, findings]) => renderText(name, findings, registers.get(name), config.preset, config.exemplars))
547
+ .map(([name, findings]) => renderText(name, findings, registers.get(name), config.preset, config.exemplars, Boolean(options["--notices"])))
532
548
  .join("\n\n"),
533
549
  );
534
550
  if (shown.size > 1) parts.push(summary(results));
@@ -551,18 +567,18 @@ function runFix(args) {
551
567
  const lines = [];
552
568
  for (const path of files) {
553
569
  const text = readFileSync(path, "utf-8");
554
- // notice 는 제안이라 손으로 정한다. 확정된 error 만 원문에 넣는다.
570
+ // notice는 제안이라 손으로 정한다. 확정된 error만 원문에 넣는다.
555
571
  const errors = runAll(fingerprint(text, config, path), config).filter((f) => f.severity === "error");
556
572
  const result = applyFixes(text, errors);
557
573
  for (const [line, fragment, replacement] of result.applied) lines.push(`${path}:${line} ${fragment} → ${replacement}`);
558
574
  for (const [line, fragment, reason] of result.skipped) lines.push(`${path}:${line} 건너뜀 ${fragment}: ${reason}`);
559
575
  if (result.text !== text && !options["--dry-run"]) writeFileSync(path, result.text, "utf-8");
560
- // 남은 수를 함께 말한다. `0곳 고침, 0곳 건너뜀` 만 보면 손볼 것이 없다는 뜻으로 읽힌다
576
+ // 남은 수를 함께 말한다. `0곳 고침, 0곳 건너뜀`만 보면 손볼 것이 없다는 뜻으로 읽힌다
561
577
  const left = errors.length - result.applied.length;
562
- const rest = left ? `. 손으로 고칠 error ${left}건이 남았다 (hanlint ${path})` : "";
578
+ const rest = left ? `. 손으로 고칠 오류 ${left}건이 남았다 (hanlint ${path})` : "";
563
579
  lines.push(
564
580
  `${path} ${result.applied.length}곳 고침, ${result.skipped.length}곳 건너뜀` +
565
- (options["--dry-run"] ? " (미리보기, 파일은 그대로)" : "") +
581
+ (options["--dry-run"] ? " (미리보기라 파일은 그대로다)" : "") +
566
582
  rest,
567
583
  );
568
584
  }
@@ -573,7 +589,7 @@ function runFix(args) {
573
589
  /** @param {string[]} args */
574
590
  function runPrint(args) {
575
591
  const { options, positionals } = parseArgs(args);
576
- if (positionals.length !== 1) throw new UsageError("마크다운 파일 하나가 필요하다. `-` 는 stdin");
592
+ if (positionals.length !== 1) throw new UsageError("마크다운 파일 하나가 필요하다. `-`는 stdin");
577
593
  const layer = choose(/** @type {string} */ (options["--layer"] ?? "all"), LAYERS, "--layer");
578
594
  const config = configFrom(options, positionals);
579
595
  const [name, text] = readInput(positionals[0], /** @type {string} */ (options["--path"] ?? STDIN_NAME));
@@ -616,15 +632,15 @@ function runRules(args) {
616
632
  let tail = `규칙 ${names.length}개`;
617
633
  if (off.size) {
618
634
  const byPreset = PRESETS[config.preset].length;
619
- tail += `, 그중 ${off.size}개가 꺼져 있다 (preset ${config.preset} 이 ${byPreset}개, disable 이 ${off.size - byPreset}개)`;
635
+ tail += ` 가운데 ${off.size}개가 꺼져 있다 (${config.preset} 프리셋 ${byPreset}개, disable ${off.size - byPreset}개)`;
620
636
  }
621
637
  lines.push(`${tail}. 하나를 자세히 보려면 hanlint explain <규칙>`);
622
- lines.push(`프리셋은 ${PRESET_NAMES.join(", ")} 이고 hanlint init --preset <이름> 이 설정에 적는다`);
638
+ lines.push(`프리셋: ${PRESET_NAMES.join(", ")}. hanlint init --preset <이름>이 설정 파일에 적는다`);
623
639
  emit(lines.join("\n"), output);
624
640
  return 0;
625
641
  }
626
642
 
627
- /** 여러 줄짜리 본보기를 첫 줄에만 표를 달고 나머지는 맞춰 들여쓴다. @param {string} text @param {string} label */
643
+ /** 여러 줄짜리 예시를 첫 줄에만 표를 달고 나머지는 맞춰 들여쓴다. @param {string} text @param {string} label */
628
644
  function indent(text, label) {
629
645
  const pad = " ".repeat(label.length);
630
646
  return text.replace(/\n+$/, "").split("\n").map((line, index) => (index === 0 ? label : pad) + line).join("\n");
@@ -635,7 +651,7 @@ const NEAR_LIMIT = 3;
635
651
  /** 앞 몇 글자가 같으면 가까운 이름으로 보는가. */
636
652
  const NEAR_PREFIX = 3;
637
653
 
638
- /** 오타에 가까운 이름. 파이썬 cli/commands/explain.py 의 nearNames 와 같다. @param {string} query @param {string[]} names */
654
+ /** 오타에 가까운 이름. 파이썬 cli/commands/explain.py의 nearNames와 같다. @param {string} query @param {string[]} names */
639
655
  function nearNames(query, names) {
640
656
  const lowered = query.toLowerCase();
641
657
  /** @type {[number, string][]} */
@@ -655,15 +671,15 @@ function runExplain(args) {
655
671
  const names = ruleNames();
656
672
  if (!positionals.length) {
657
673
  process.stdout.write("규칙 이름 하나가 필요하다. 예: hanlint explain doublePassive\n");
658
- process.stdout.write(`\n규칙 ${names.length}개를 부류로 묶어 보려면 hanlint rules, 이름만 보려면 hanlint rules --names\n`);
674
+ process.stdout.write(`\n규칙 ${names.length}개를 카테고리별로 보려면 hanlint rules, 이름만 보려면 hanlint rules --names\n`);
659
675
  return 2;
660
676
  }
661
- if (positionals.length !== 1) throw new UsageError("규칙 이름 하나가 필요하다. hanlint rules 로 목록을 본다");
677
+ if (positionals.length !== 1) throw new UsageError("규칙 이름 하나가 필요하다. hanlint rules로 목록을 본다");
662
678
  const wanted = positionals[0];
663
679
  if (!names.includes(wanted)) {
664
680
  const near = nearNames(wanted, names);
665
- const hint = near.length ? ` 이것을 찾았나: ${near.join(", ")}` : " hanlint rules 로 목록을 본다";
666
- throw new Error(`모르는 규칙: ${wanted}.${hint}`);
681
+ const hint = near.length ? ` 비슷한 이름: ${near.join(", ")}` : " 목록은 hanlint rules";
682
+ throw new Error(`없는 규칙: ${wanted}.${hint}`);
667
683
  }
668
684
  const category = ruleCategory(wanted);
669
685
  const config = configFrom(options, []);
@@ -682,18 +698,18 @@ function runExplain(args) {
682
698
  return 0;
683
699
  }
684
700
  const mechanism = ruleMechanism(wanted);
685
- process.stdout.write(`${wanted} (${CATEGORY_TITLES[category]})\n기제: ${mechanism}. ${MECHANISMS[mechanism]}\n\n${ruleDoc(wanted)}\n`);
701
+ process.stdout.write(`${wanted} (${CATEGORY_TITLES[category]})\n방식: ${MECHANISMS[mechanism]} (${mechanism})\n\n${ruleDoc(wanted)}\n`);
686
702
  const exemplar = exemplarOf ? exemplarInRegister(exemplarOf, register) : undefined;
687
703
  if (exemplar) {
688
- process.stdout.write("\n본보기\n");
704
+ process.stdout.write("\n예시\n");
689
705
  process.stdout.write(`${indent(exemplar.before, " 전 ")}\n`);
690
706
  process.stdout.write(`${indent(exemplar.after, " 후 ")}\n`);
691
707
  process.stdout.write(` 달라진 것: ${exemplar.moved}\n`);
692
708
  }
693
709
  const siblings = names.filter((name) => ruleCategory(name) === category && name !== wanted);
694
- process.stdout.write(`\n같은 부류: ${siblings.join(", ")}\n`);
695
- process.stdout.write(`끄려면 hanlint.toml 의 disable 에 ${wanted} 를 넣는다. 한 자리만 끄려면 <!-- hanlint-disable ${wanted} -->\n`);
696
- process.stdout.write(`정당한 문장인데 잡혔으면 끄고 끝내지 말고 알려 준다: ${ISSUES}\n`);
710
+ process.stdout.write(`\n같은 카테고리: ${siblings.join(", ")}\n`);
711
+ process.stdout.write(`끄려면 hanlint.toml의 disable 목록에 넣는다: ${wanted}. 한 곳만 끄려면 <!-- hanlint-disable ${wanted} -->\n`);
712
+ process.stdout.write(`맞는 문장인데 잡혔다면 끄기만 하지 말고 여기에 알린다: ${ISSUES}\n`);
697
713
  return 0;
698
714
  }
699
715
 
@@ -703,7 +719,7 @@ function runPatterns(args) {
703
719
  const rule = /** @type {string | undefined} */ (options["--rule"]);
704
720
  const register = choose(/** @type {string} */ (options["--register"] ?? HAPNIDA), REGISTERS, "--register");
705
721
  const chosen = (rule ? patternsAvoiding(rule) : patterns()).map((pattern) => patternInRegister(pattern, register));
706
- if (!chosen.length) throw new Error(` 을 피하는 문형이 없다. hanlint patterns 로 전부 본다`);
722
+ if (!chosen.length) throw new Error(`을 피하는 문형이 없다. hanlint patterns로 전부 본다`);
707
723
  const output = /** @type {string | undefined} */ (options["--output"]);
708
724
  if (options["--format"] === "json") {
709
725
  emit(JSON.stringify({ version: 1, patterns: chosen }, null, 2), output);
@@ -711,7 +727,7 @@ function runPatterns(args) {
711
727
  }
712
728
  const lines = [];
713
729
  for (const pattern of chosen) {
714
- lines.push(`${pattern.name} (${pattern.avoids.join(", ")} 를 피한다)`);
730
+ lines.push(`${pattern.name} (피하는 규칙: ${pattern.avoids.join(", ")})`);
715
731
  lines.push(` 틀 ${pattern.form}`);
716
732
  lines.push(` 언제 ${pattern.when}`);
717
733
  lines.push(indent(pattern.example, " 예시 "));
@@ -719,13 +735,13 @@ function runPatterns(args) {
719
735
  lines.push(` 출처 ${pattern.source}`);
720
736
  lines.push("");
721
737
  }
722
- lines.push(`문형 ${chosen.length}개. 예시는 전부 hanlint 를 error 0 으로 통과한다 (게이트가 확인한다)`);
738
+ lines.push(`문형 ${chosen.length}개. 예시 문장은 모두 hanlint 검사에서 오류가 없다`);
723
739
  if (!rule) lines.push("지적을 받은 자리를 다시 쓰려면 hanlint patterns --rule <규칙>");
724
740
  emit(lines.join("\n"), output);
725
741
  return 0;
726
742
  }
727
743
 
728
- /** 여러 줄을 한 줄로. 줄바꿈은 ¶ 다. 파이썬 cli/commands/primer.py 의 flat 과 같다. @param {string} text */
744
+ /** 여러 줄을 한 줄로. 줄바꿈은 ¶ 다. 파이썬 cli/commands/primer.py의 flat과 같다. @param {string} text */
729
745
  function flat(text) {
730
746
  return text
731
747
  .split("\n")
@@ -735,7 +751,7 @@ function flat(text) {
735
751
  }
736
752
 
737
753
  /**
738
- * 설정에서 켜진 규칙마다 항목 하나. 부류 순서, 그 안은 이름 순서. 파이썬 primerEntries 와 같다.
754
+ * 설정에서 켜진 규칙마다 항목 하나. 묶음 순서, 그 안은 이름 순서. 파이썬 primerEntries와 같다.
739
755
  * @param {import("../config/settings.js").Config} config @param {string} register
740
756
  */
741
757
  function primerEntries(config, register) {
@@ -745,7 +761,7 @@ function primerEntries(config, register) {
745
761
  for (const name of ruleNames()) {
746
762
  if (ruleCategory(name) !== category || off.has(name)) continue;
747
763
  const exemplar = exemplarFor(name, config.preset, config.exemplars);
748
- if (!exemplar) throw new Error(`본보기가 없는 규칙: ${name}`);
764
+ if (!exemplar) throw new Error(`예시가 없는 규칙: ${name}`);
749
765
  const shown = exemplarInRegister(exemplar, register);
750
766
  entries.push({ rule: name, category, fix: ruleFix(name), before: shown.before, after: shown.after });
751
767
  }
@@ -753,10 +769,17 @@ function primerEntries(config, register) {
753
769
  return entries;
754
770
  }
755
771
 
756
- /** 사람과 AI 가 읽는 한 장. 파이썬 renderPrimer 와 글자 단위로 같다. @param {ReturnType<typeof primerEntries>} entries @param {string} preset @param {string} register */
772
+ /** 글쓰기 지침를 절마다 한 줄로. 제목 두 칸 띄고 본문이다. 파이썬 proseLines와 같다. */
773
+ function proseLines() {
774
+ return proseSections().map(([title, body]) => `${title} ${body.split(/\s+/).filter(Boolean).join(" ")}`);
775
+ }
776
+
777
+ /** 사람과 AI가 읽는 한 장. 파이썬 renderPrimer와 글자 단위로 같다. @param {ReturnType<typeof primerEntries>} entries @param {string} preset @param {string} register */
757
778
  function renderPrimer(entries, preset, register) {
758
779
  const lines = [
759
- `hanlint primer ${preset} 종류, ${register}체, 규칙 ${entries.length}개. 줄마다 규칙 이름, 고치는 법, 전 (그 규칙에 잡히는 글), 후 (같은 뜻으로 통과하는 글) 순서다. 본보기의 줄바꿈은 ¶ 다`,
780
+ ...proseLines(),
781
+ "",
782
+ `hanlint primer ${preset} 종류, ${register}체, 규칙 ${entries.length}개. 줄마다 규칙 이름, 고치는 법, 전(그 규칙에 잡히는 글), 후(그 예시에서 고친 글) 순서다. 예시의 줄바꿈은 ¶로 보인다`,
760
783
  ];
761
784
  for (const [category, title] of Object.entries(CATEGORY_TITLES)) {
762
785
  const inside = entries.filter((entry) => entry.category === category);
@@ -764,7 +787,7 @@ function renderPrimer(entries, preset, register) {
764
787
  lines.push("", `${title} (${inside.length})`);
765
788
  for (const entry of inside) lines.push(`${entry.rule} ${entry.fix} 전: ${flat(entry.before)} 후: ${flat(entry.after)}`);
766
789
  }
767
- lines.push("", "후는 전부 hanlint 를 error 0 으로 통과한다 (게이트가 확인한다). 규칙 하나를 깊게 보려면 hanlint explain <규칙>");
790
+ lines.push("", "예시는 그 문장에만 맞는다. 용어와 사실은 그대로 두고, 바꾼 말을 다른 글에 그대로 옮겨 쓰지 않는다. 규칙을 더 보려면 hanlint explain <규칙>");
768
791
  return lines.join("\n");
769
792
  }
770
793
 
@@ -777,7 +800,7 @@ function runPrimer(args) {
777
800
  const output = /** @type {string | undefined} */ (options["--output"]);
778
801
  const format = choose(/** @type {string} */ (options["--format"] ?? "text"), ["text", "json"], "--format");
779
802
  if (format === "json") {
780
- emit(JSON.stringify({ version: 1, preset: config.preset, register, rules: entries }, null, 2), output);
803
+ emit(JSON.stringify({ version: 1, preset: config.preset, register, prose: proseGuide(), rules: entries }, null, 2), output);
781
804
  return 0;
782
805
  }
783
806
  emit(renderPrimer(entries, config.preset, register), output);
@@ -793,7 +816,7 @@ function runSpec(args) {
793
816
  if (options["--chars"] !== undefined) {
794
817
  const raw = /** @type {string} */ (options["--chars"]);
795
818
  targetChars = Number(raw);
796
- if (!Number.isInteger(targetChars) || targetChars <= 0) throw new UsageError(`--chars 는 1 이상의 정수다: ${raw}`);
819
+ if (!Number.isInteger(targetChars) || targetChars <= 0) throw new UsageError(`--chars는 1 이상의 정수다: ${raw}`);
797
820
  }
798
821
  const result = writingSpec(config, register, targetChars);
799
822
  const output = /** @type {string | undefined} */ (options["--output"]);
@@ -802,66 +825,117 @@ function runSpec(args) {
802
825
  return 0;
803
826
  }
804
827
 
805
- /** Claude Code가 다음 모델 요청에 넣는 비차단 Finding 문맥. */
806
- function hookContext(eventName, name, findings) {
828
+ /** 글쓰기 지침를 싣는 이벤트. Claude Code와 Codex 모두 이 둘의 additionalContext를 모델 문맥에 넣는다. */
829
+ const PRIME_EVENTS = ["SessionStart", "UserPromptSubmit"];
830
+
831
+ /** 에이전트가 다음 모델 요청에 넣는 비차단 문맥. @param {string} eventName @param {string} context */
832
+ function hookContext(eventName, context) {
833
+ return JSON.stringify({ hookSpecificOutput: { hookEventName: eventName, additionalContext: context } });
834
+ }
835
+
836
+ /** Finding을 되먹이는 문맥의 본문. 파일마다 한 덩어리다. 파이썬 findingContext와 같다. @param {string} eventName @param {[string, import("../rules/finding.js").Finding[]][]} found */
837
+ function findingContext(eventName, found) {
807
838
  const where = eventName === "Stop" ? "마지막 답변" : "방금 쓴 마크다운";
808
- return JSON.stringify({
809
- hookSpecificOutput: {
810
- hookEventName: eventName,
811
- additionalContext: `hanlint가 ${where}에서 센 Finding입니다.\n${renderCompact(name, findings)}`,
812
- },
813
- });
839
+ return `hanlint가 ${where}에서 찾은 지적입니다.\n${found.map(([name, items]) => renderCompact(name, items)).join("\n")}`;
840
+ }
841
+
842
+ /** Codex apply_patch 본문에서 쓴 파일을 가리키는 머리 줄 (Codex 훅 문서, 2026-09-21 확인). */
843
+ const PATCH_HEADS = ["*** Add File: ", "*** Update File: ", "*** Move to: "];
844
+
845
+ /** apply_patch 본문이 쓴 파일 경로. 나온 차례대로 한 번씩이다. @param {string} patch */
846
+ function patchPaths(patch) {
847
+ /** @type {string[]} */
848
+ const paths = [];
849
+ for (const line of patch.split(/\r?\n/)) {
850
+ for (const head of PATCH_HEADS) {
851
+ if (!line.startsWith(head)) continue;
852
+ const path = line.slice(head.length).trim();
853
+ if (path && !paths.includes(path)) paths.push(path);
854
+ }
855
+ }
856
+ return paths;
857
+ }
858
+
859
+ /** PostToolUse 입력이 쓴 파일 경로. 모르는 도구면 빈 목록이다. @param {any} payload @returns {string[]} */
860
+ function writtenPaths(payload) {
861
+ const toolInput = payload.tool_input;
862
+ if (!toolInput || typeof toolInput !== "object" || Array.isArray(toolInput)) return [];
863
+ if (["Write", "Edit"].includes(payload.tool_name)) {
864
+ const path = toolInput.file_path;
865
+ return typeof path === "string" && path ? [path] : [];
866
+ }
867
+ if (payload.tool_name === "apply_patch") return typeof toolInput.command === "string" ? patchPaths(toolInput.command) : [];
868
+ return [];
869
+ }
870
+
871
+ /** 쓴 마크다운마다 [이름, Finding]. 상대 경로는 입력의 cwd에서 찾는다. @param {any} payload @param {Record<string, unknown>} options */
872
+ function writtenFindings(payload, options) {
873
+ const base = typeof payload.cwd === "string" && payload.cwd ? payload.cwd : null;
874
+ /** @type {[string, import("../rules/finding.js").Finding[]][]} */
875
+ const found = [];
876
+ for (const rawPath of writtenPaths(payload)) {
877
+ const path = base !== null && !isAbsolute(rawPath) ? join(base, rawPath) : rawPath;
878
+ if (!MARKDOWN.includes(extname(path).toLowerCase())) continue;
879
+ try {
880
+ if (!statSync(path).isFile()) continue;
881
+ } catch {
882
+ continue;
883
+ }
884
+ const config = configFrom(options, [path]);
885
+ let findings = lintText(readFileSync(path, "utf-8"), config, rawPath);
886
+ if (config.baseline) findings = loadBaseline(config.baseline).keep(rawPath, findings);
887
+ found.push([rawPath, findings]);
888
+ }
889
+ return found;
814
890
  }
815
891
 
816
892
  /** 명령 훅 JSON을 stdin에서 읽어 저장 파일이나 마지막 답변을 검사한다. 언제나 종료 코드 0이다. @param {string[]} args */
817
893
  function runHook(args) {
894
+ if (args.includes("--prime") && args.includes("--reply")) throw new UsageError("--prime과 --reply는 함께 쓰지 않는다");
895
+ const { options, positionals } = parseArgs(args);
896
+ const agent = choose(/** @type {string} */ (options["--agent"] ?? "claude"), ["claude", "codex"], "--agent");
818
897
  try {
819
- const { options, positionals } = parseArgs(args);
820
898
  if (positionals.length) return 0;
821
899
  const payload = JSON.parse(readFileSync(0, "utf-8"));
822
900
  if (!payload || typeof payload !== "object" || Array.isArray(payload)) return 0;
823
- let eventName;
824
- let name;
825
- let findings;
901
+ const eventName = payload.hook_event_name;
902
+ if (options["--prime"]) {
903
+ if (PRIME_EVENTS.includes(eventName)) process.stdout.write(`${hookContext(eventName, proseGuide())}\n`);
904
+ return 0;
905
+ }
906
+ /** @type {[string, import("../rules/finding.js").Finding[]][]} */
907
+ let found;
826
908
  if (options["--reply"]) {
827
- if (payload.hook_event_name !== "Stop" || payload.stop_hook_active === true) return 0;
909
+ if (eventName !== "Stop" || payload.stop_hook_active === true) return 0;
828
910
  const text = payload.last_assistant_message;
829
911
  if (typeof text !== "string" || !text.trim()) return 0;
830
912
  const start = typeof payload.cwd === "string" && payload.cwd ? payload.cwd : process.cwd();
831
913
  const config = configFrom(options, [], start);
832
914
  config.preset = "chat";
833
- eventName = "Stop";
834
- name = "<assistant>";
835
- findings = lintText(text, config, name);
915
+ found = [["<assistant>", lintText(text, config, "<assistant>")]];
916
+ } else if (eventName === "PostToolUse") {
917
+ found = writtenFindings(payload, options);
836
918
  } else {
837
- if (payload.hook_event_name !== "PostToolUse" || !["Write", "Edit"].includes(payload.tool_name)) return 0;
838
- const rawPath = payload.tool_input?.file_path;
839
- if (typeof rawPath !== "string" || !rawPath || !MARKDOWN.includes(extname(rawPath).toLowerCase())) return 0;
840
- try {
841
- if (!statSync(rawPath).isFile()) return 0;
842
- } catch {
843
- return 0;
844
- }
845
- const config = configFrom(options, [rawPath]);
846
- eventName = "PostToolUse";
847
- name = rawPath;
848
- findings = lintText(readFileSync(rawPath, "utf-8"), config, name);
849
- if (config.baseline) findings = loadBaseline(config.baseline).keep(name, findings);
919
+ return 0;
850
920
  }
851
- if (findings.length) process.stdout.write(`${hookContext(eventName, name, findings)}\n`);
921
+ found = found.filter(([, items]) => items.length);
922
+ if (!found.length) return 0;
923
+ const context = findingContext(eventName, found);
924
+ if (options["--reply"] && agent === "codex") process.stdout.write(`${JSON.stringify({ systemMessage: context })}\n`);
925
+ else process.stdout.write(`${hookContext(eventName, context)}\n`);
852
926
  } catch {
853
927
  return 0;
854
928
  }
855
929
  return 0;
856
930
  }
857
931
 
858
- /** 잠근 지적이 몇 건인지. baseline 이 빚을 감추는 자리가 되지 않게 늘 보인다. @param {import("../config/settings.js").Config} config */
932
+ /** 잠근 지적이 몇 건인지. baseline이 빚을 감추는 자리가 되지 않게 늘 보인다. @param {import("../config/settings.js").Config} config */
859
933
  function baselineState(config) {
860
- if (!config.baseline) return "없다 (hanlint baseline 글들/ 로 지금 지적을 잠근다)";
934
+ if (!config.baseline) return "없음 (만들려면 hanlint baseline 글들/)";
861
935
  try {
862
- return `${loadBaseline(config.baseline).count}건이 ${config.baseline} 에 잠겨 있다. 그 문장을 고치면 다시 나온다`;
936
+ return `${config.baseline}에 지적 ${loadBaseline(config.baseline).count}건이 들어 있다. 그 문장을 고치면 다시 나온다`;
863
937
  } catch (error) {
864
- return `${config.baseline} 를 못 읽었다: ${/** @type {Error} */ (error).message}`;
938
+ return `읽지 못했다: ${config.baseline} (${/** @type {Error} */ (error).message})`;
865
939
  }
866
940
  }
867
941
 
@@ -878,22 +952,22 @@ function runDoctor(args) {
878
952
  `설정 ${configLabel(config)}`,
879
953
  `프리셋 ${config.preset} (${PRESET_NAMES.join(", ")} 가운데)`,
880
954
  `규칙 ${names.length - off.length}개 켜짐, ${off.length}개 꺼짐`,
881
- `잠금 ${baselineState(config)}`,
882
- `폴더 점으로 시작하는 폴더와 ${SKIPPED_FOLDERS.join(", ")} 는 건너뛴다. 직접 주면 검사한다`,
955
+ `baseline 파일 ${baselineState(config)}`,
956
+ `폴더 건너뛰는 것: 점으로 시작하는 폴더, ${SKIPPED_FOLDERS.join(", ")}. 직접 주면 검사한다`,
883
957
  ];
884
958
  if (off.length) lines.push(`꺼진 규칙 ${off.join(", ")}`);
885
- lines.push("", "다음: hanlint 글.md 로 검사한다. 설정이 기본값이면 hanlint init 으로 파일을 만든다");
959
+ lines.push("", "다음: hanlint 글.md로 검사한다. 설정 파일이 없으면 hanlint init으로 만든다");
886
960
  process.stdout.write(`${lines.join("\n")}\n`);
887
961
  return 0;
888
962
  }
889
963
 
890
- /** 주석 달린 hanlint.toml. 파이썬 `hanlint init` 과 같은 글자다. @param {string} [preset] */
964
+ /** 주석 달린 hanlint.toml. 파이썬 `hanlint init`과 같은 글자다. @param {string} [preset] */
891
965
  export function renderInit(preset = "blog") {
892
966
  const defaults = defaultConfig();
893
967
  const lines = [
894
- "# hanlint 설정. 규칙을 끄려면 disable 에 이름을 넣는다. 규칙의 기술서는 hanlint explain <규칙>.",
968
+ "# hanlint 설정. 규칙을 끄려면 disable에 이름을 넣는다. 규칙 설명은 hanlint explain <규칙>으로 본다.",
895
969
  "",
896
- "# 글의 종류. 그 종류에 안 맞는 규칙을 처음부터 끈다. disable 은 그 위에 더한다.",
970
+ "# 글의 종류. 그 종류에 안 맞는 규칙을 처음부터 끈다. disable은 그 위에 더한다.",
897
971
  ];
898
972
  for (const [name, off] of Object.entries(PRESETS)) lines.push(`# ${name}: ${off.length ? off.join(", ") : "전부 켠다"}`);
899
973
  lines.push(`preset = "${preset}"`, "", "# 규칙 목록과 한 줄 설명");
@@ -907,23 +981,23 @@ export function renderInit(preset = "blog") {
907
981
  "",
908
982
  "disable = []",
909
983
  "",
910
- "# 대표 검색어를 읽을 frontmatter 필드. 없으면 keywordMissing 은 돌지 않는다",
984
+ "# 대표 검색어를 읽을 frontmatter 필드. 없으면 keywordMissing은 돌지 않는다",
911
985
  '# keywordField = "primaryKeyword"',
912
986
  "",
913
- "# 도입과 마지막 절이 담아야 하는 frontmatter 필드. 비어 있으면 fieldEcho 는 돌지 않는다",
987
+ "# 도입과 마지막 절이 담아야 하는 frontmatter 필드. 비어 있으면 fieldEcho는 돌지 않는다",
914
988
  '# introFields = ["readerQuestion"]',
915
989
  '# endingFields = ["readerTakeaway"]',
916
990
  "",
917
- "# hanlint profile build 가 만든 파일. 있으면 종류의 프로파일 대신 그것과 견준다 (outsideProfile)",
991
+ "# hanlint profile build가 만든 파일. 있으면 글 종류의 기본 프로파일 대신 이 파일과 비교한다(outsideProfile)",
918
992
  '# profile = "profile.json"',
919
993
  "",
920
- "# hanlint baseline 이 만든 잠금 파일. 있으면 그 안의 지적은 넘기고 새로 생긴 것만 막는다",
994
+ "# hanlint baseline이 만든 baseline 파일. 있으면 그 안의 지적은 보이지 않고 새로 생긴 지적만 막는다",
921
995
  '# baseline = ".hanlint-baseline.json"',
922
996
  "",
923
- "# 코드도 산문도 아닌 펜스의 언어 표기. 장면 계약과 도표 원문은 코드 블록으로 세지 않고 지문에서 뺀다",
997
+ "# 코드도 산문도 아닌 펜스의 언어 이름. 이 펜스는 코드 블록으로 세지 않고 검사에서도 뺀다",
924
998
  '# ignoreFences = ["course-scene", "mermaid"]',
925
999
  "",
926
- "# 임계. 기본값의 정본은 hanlint 의 config/settings.py 다",
1000
+ "# 기준값. 아래 값이 기본값이다",
927
1001
  );
928
1002
  for (const name of THRESHOLD_FIELDS) {
929
1003
  const value = /** @type {number} */ (defaults[name]);
@@ -931,38 +1005,45 @@ export function renderInit(preset = "blog") {
931
1005
  lines.push(`# ${name} = ${shown}`);
932
1006
  }
933
1007
  lines.push(
1008
+ "",
1009
+ "# 용례. 그 종류의 실제 글에 흔한 명사 연쇄(nounPile)와 사전 표현은 짚지 않는다. 명사 연쇄는 usageMin편 이상의",
1010
+ "# 문서에 나온 것, 사전 표현은 문서의 usageShare 비율 이상에 나온 것(사업보고서의 `에 대한`)이다.",
1011
+ "# 종류는 프리셋이 정한다 (report만 있다). 빈 문자열이면 어떤 프리셋에서도 용례를 보지 않는다",
1012
+ '# usageKind = ""',
1013
+ "# usageMin = 3",
1014
+ "# usageShare = 0.9",
934
1015
  "",
935
1016
  "# 사전에 더할 항목. 키는 cliches, translationese, redundantPair, japaneseLoan, easyWords,",
936
- "# screenSentence, screenNarration, screenTone, screenWord",
1017
+ "# screenSentence, screenNarration, screenTone, screenWord, calqueFrame",
937
1018
  "# [dictionary]",
938
1019
  '# cliches = ["우리의 여정"]',
939
1020
  '# translationese = [{ pattern = "에 대한 이해", fix = "를 아는 것" }]',
940
1021
  "",
941
- "# 표면 치환이 바꾸면 안 되는 한국어 고유명사와 프로젝트 용어. 수치와 라틴 식별자는 자동으로 보호한다",
1022
+ "# 자동 고침이 건드리지 말아야 할 한국어 고유명사와 프로젝트 용어. 숫자와 영문 식별자는 저절로 지킨다",
942
1023
  '# protectedTerms = ["한린트", "김민지"]',
943
- '# enforceStyle = ["noQuestion", "nounPile"] # 선택한 문체 신호만 error로 강제한다',
1024
+ '# enforceStyle = ["noQuestion", "nounPile"] # 고른 문체 규칙만 오류로 낸다',
944
1025
  "",
945
- "# 사람이 승인한 프로젝트 본보기. 같은 규칙과 프리셋의 내장 본보기를 덮어쓴다",
1026
+ "# 사람이 승인한 프로젝트 예시. 같은 규칙과 프리셋의 기본 예시 대신 쓴다",
946
1027
  "# [[exemplars]]",
947
1028
  '# rule = "translationese"',
948
1029
  '# before = "설계에 대한 이해가 필요합니다."',
949
1030
  '# after = "설계를 알아야 합니다."',
950
- '# moved = "명사구를 서술어로 풀어 씀"',
1031
+ '# moved = "명사구를 서술어로 풀어 썼다"',
951
1032
  '# presets = ["blog"]',
952
1033
  "",
953
- "# 사람이 승인한 국소 고침. 원문을 포함한 모든 조건이 맞을 때만 그대로 재생한다",
1034
+ "# 사람이 승인한 그 자리만의 고침. 원문을 포함해 조건이 모두 맞을 때만 그대로 적용한다",
954
1035
  "# [[patches]]",
955
1036
  '# rule = "translationese"',
956
1037
  '# before = "설계에 대한 이해가 필요합니다."',
957
1038
  '# after = "설계를 알아야 합니다."',
958
- '# moved = "명사구를 서술어로 풀어 씀"',
1039
+ '# moved = "명사구를 서술어로 풀어 썼다"',
959
1040
  '# sourceText = "설계에 대한 이해가 필요합니다." # before의 마크다운까지 보존한 선택용 원문',
960
1041
  '# sentence = "설계에 대한 이해가 필요합니다." # before에서 마크다운 표식을 걷은 선택용 원문',
961
1042
  '# cue = "에 대한"',
962
1043
  '# reader = "new"',
963
1044
  '# presets = ["blog"]',
964
1045
  "",
965
- "# 사람이 적용 범위까지 승인한 표면 치환. 단어 경계 한 자리와 보호 원자가 맞을 때만 결과를 낸다",
1046
+ "# 사람이 적용 범위까지 승인한 치환. 단어 경계가 맞고 숫자·링크·코드가 그대로일 때만 바꾼다",
966
1047
  "# [[operations]]",
967
1048
  '# before = "여러가지"',
968
1049
  '# after = "여러 가지"',
@@ -977,15 +1058,15 @@ function runInit(args) {
977
1058
  const { options } = parseArgs(args);
978
1059
  const path = /** @type {string} */ (options["--output"] ?? "hanlint.toml");
979
1060
  const preset = choose(/** @type {string} */ (options["--preset"] ?? "blog"), PRESET_NAMES, "--preset");
980
- if (existsSync(path) && !options["--force"]) throw new Error(`${path} 가 이미 있다. 덮어쓰려면 --force`);
1061
+ if (existsSync(path) && !options["--force"]) throw new Error(`이미 있는 파일: ${path}. 덮어쓰려면 --force를 붙인다`);
981
1062
  writeFileSync(path, renderInit(preset), "utf-8");
982
1063
  const off = PRESETS[preset];
983
- const tail = off.length ? `preset ${preset} 이 ${off.length}개를 끈다` : `preset ${preset} 은 규칙을 전부 켠다`;
984
- process.stdout.write(`${path} 를 만들었다. ${tail}. 더 끄려면 disable 에 이름을 넣는다\n`);
1064
+ const tail = off.length ? `${preset} 프리셋은 규칙 ${off.length}개를 끈다` : `${preset} 프리셋은 규칙을 전부 켠다`;
1065
+ process.stdout.write(`만들었다: ${path}. ${tail}. 더 끄려면 disable에 이름을 넣는다\n`);
985
1066
  return 0;
986
1067
  }
987
1068
 
988
- /** 폴더 아래 소스 파일. 뜻은 파이썬 cli/commands/sheet.py 의 sourceUnder 가 소유한다. @param {string} folder @returns {string[]} */
1069
+ /** 폴더 아래 소스 파일. 뜻은 파이썬 cli/commands/sheet.py의 sourceUnder가 소유한다. @param {string} folder @returns {string[]} */
989
1070
  function sourceUnder(folder) {
990
1071
  /** @type {string[]} */
991
1072
  const found = [];
@@ -995,22 +1076,24 @@ function sourceUnder(folder) {
995
1076
  if (!isSkipped(name)) found.push(...sourceUnder(path));
996
1077
  } else if (SOURCE_SUFFIXES.includes(extname(name).toLowerCase())) found.push(path);
997
1078
  }
998
- return found.sort();
1079
+ // 구분자를 / 로 맞춘 뒤 정렬한다. 뜻은 파이썬 sourceUnder가 소유한다.
1080
+ const posix = (item) => item.split(sep).join("/");
1081
+ return found.sort((a, b) => (posix(a) < posix(b) ? -1 : posix(a) > posix(b) ? 1 : 0));
999
1082
  }
1000
1083
 
1001
- /** 작업 폴더 기준 상대 경로, 구분자는 `/`. 파이썬 relativeLabel 과 같은 글자를 낸다. @param {string} path */
1084
+ /** 작업 폴더 기준 상대 경로, 구분자는 `/`. 파이썬 relativeLabel과 같은 글자를 낸다. @param {string} path */
1002
1085
  function relativeLabel(path) {
1003
1086
  const rel = relative(process.cwd(), resolve(path));
1004
1087
  const label = !rel || rel.startsWith("..") || isAbsolute(rel) ? path : rel;
1005
1088
  return label.split(sep).join("/").replaceAll("\\", "/");
1006
1089
  }
1007
1090
 
1008
- /** 표의 자리 글자. 뜻은 파이썬 SheetRow.place 가 소유한다. @param {import("../report/sheet.js").SheetRow} row */
1091
+ /** 표의 자리 글자. 뜻은 파이썬 SheetRow.place가 소유한다. @param {import("../report/sheet.js").SheetRow} row */
1009
1092
  function placeOf(row) {
1010
1093
  return row.column > 0 ? `${row.file}:${row.line}:${row.column}` : `${row.file}:${row.line}`;
1011
1094
  }
1012
1095
 
1013
- /** 표의 고침을 파일에 쓴다. 뜻은 파이썬 applySheet 가 소유한다. @param {string} sheetPath @param {boolean} dryRun @returns {[string[], string[]]} */
1096
+ /** 표의 고침을 파일에 쓴다. 뜻은 파이썬 applySheet가 소유한다. @param {string} sheetPath @param {boolean} dryRun @returns {[string[], string[]]} */
1014
1097
  function applySheet(sheetPath, dryRun) {
1015
1098
  const parsed = parseSheet(readFileSync(sheetPath, "utf-8"));
1016
1099
  /** @type {string[]} */
@@ -1028,41 +1111,111 @@ function applySheet(sheetPath, dryRun) {
1028
1111
  failed.push(...rows.map((row) => `${placeOf(row)}: 파일이 없다`));
1029
1112
  continue;
1030
1113
  }
1031
- const lines = readFileSync(file, "utf-8").split("\n");
1032
- let changed = false;
1033
- // 한 줄에 고침이 여럿이면 뒤 칸부터. 앞 칸을 먼저 바꾸면 길이가 달라져 뒤 칸의 자리가 어긋난다.
1034
- for (const row of [...rows].sort((a, b) => a.line - b.line || b.column - a.column)) {
1035
- const place = placeOf(row);
1036
- if (row.line < 1 || row.line > lines.length) {
1037
- failed.push(`${place}: 그 줄이 없다`);
1038
- continue;
1039
- }
1040
- let newLine = lines[row.line - 1];
1041
- if (row.column > 0) {
1042
- const start = row.column - 1;
1043
- if (newLine.slice(start, start + row.text.length) !== row.text) {
1044
- failed.push(`${place}: 그 칸에 그 글이 없다. 표를 다시 뽑는다`);
1045
- continue;
1046
- }
1047
- newLine = newLine.slice(0, start) + row.fix + newLine.slice(start + row.text.length);
1048
- } else {
1049
- const [replaced, count] = replaceLiteral(newLine, row.text, row.fix);
1050
- if (count !== 1) {
1051
- failed.push(`${place}: 글이 그 줄에 ${count}번 있다. 한 번이어야 바꾼다`);
1052
- continue;
1053
- }
1054
- newLine = replaced;
1055
- }
1056
- lines[row.line - 1] = newLine;
1057
- changed = true;
1058
- applied.push(`${place}: ${row.text} -> ${row.fix}`);
1059
- }
1060
- if (changed && !dryRun) writeFileSync(file, lines.join("\n"), "utf-8");
1114
+ // 되돌려 쓰는 뜻은 report/sheet.js의 applyRows가 소유한다 (브라우저의 저장소 쓰기와 같은 함수).
1115
+ const [rewritten, done, problems] = applyRows(readFileSync(file, "utf-8"), rows);
1116
+ applied.push(...done);
1117
+ failed.push(...problems);
1118
+ if (done.length && !dryRun) writeFileSync(file, rewritten, "utf-8");
1061
1119
  }
1062
1120
  return [applied, failed];
1063
1121
  }
1064
1122
 
1065
- /** `hanlint sheet 폴더/ --preset screen` 과 `hanlint sheet apply 시트.md`. 뜻은 파이썬 cli/commands/sheet.py 가 소유한다. @param {string[]} args */
1123
+ /** 세 자리마다 쉼표를 찍은 수. 파이썬의 `f"{n:,}"`와 같은 글자다. @param {number} value */
1124
+ function comma(value) {
1125
+ return value.toLocaleString("en-US");
1126
+ }
1127
+
1128
+ /** 색인이 없을 때의 안내. 뜻은 파이썬 cli/commands/usage.py의 missingIndex가 소유한다. @param {string} kind @param {string} root */
1129
+ function missingIndex(kind, root) {
1130
+ const kinds = listUsageIndexes(root).map((meta) => String(meta.kind)).join(", ") || "없음";
1131
+ return `${kind} 색인이 없다 (있는 것: ${kinds}). hanlint usage build ${kind} <글 폴더>로 만든다 (txt, md, jsonl. 하위 폴더 포함)`;
1132
+ }
1133
+
1134
+ /** 함께 쓰는 말 단어 수 상한. 뜻은 파이썬 QUERY_WORDS가 소유한다. */
1135
+ const USAGE_QUERY_WORDS = 3;
1136
+
1137
+ /** @param {[string, number][]} pairs */
1138
+ function pairText(pairs) {
1139
+ return pairs.map(([term, count]) => `${term} ${count}편`).join(", ");
1140
+ }
1141
+
1142
+ /**
1143
+ * `hanlint usage "단어 …" --kind report --limit 5`, `hanlint usage build <종류> <글 폴더>`, `hanlint usage kinds`. 뜻은 파이썬
1144
+ * cli/commands/usage.py가 소유한다.
1145
+ * @param {string[]} args
1146
+ */
1147
+ function runUsage(args) {
1148
+ const { options, positionals } = parseArgs(args);
1149
+ const output = /** @type {string | undefined} */ (options["--output"]);
1150
+ const root = /** @type {string} */ (options["--root"] ?? defaultUsageRoot());
1151
+ const format = choose(/** @type {string} */ (options["--format"] ?? "text"), ["text", "json"], "--format");
1152
+ if (!positionals.length) throw new UsageError('hanlint usage "단어 …" 또는 hanlint usage build <종류> <글 폴더>');
1153
+ if (positionals[0] === "kinds") {
1154
+ const found = listUsageIndexes(root);
1155
+ if (format === "json") {
1156
+ emit(JSON.stringify({ version: 1, root, kinds: found }, null, 2), output);
1157
+ return 0;
1158
+ }
1159
+ if (!found.length) {
1160
+ emit(`${root}에 색인이 없다. hanlint usage build <종류> <글 폴더>로 만든다`, output);
1161
+ return 0;
1162
+ }
1163
+ const lines = [`색인 ${found.length}개 (${root})`];
1164
+ for (const meta of found) lines.push(` ${meta.kind}: 문서 ${comma(meta.documents)}편, 문장 ${comma(meta.sentences)}개, 단어 ${comma(meta.terms)}종`);
1165
+ emit(lines.join("\n"), output);
1166
+ return 0;
1167
+ }
1168
+ if (positionals[0] === "build") {
1169
+ if (positionals.length !== 3) {
1170
+ emit("hanlint usage build <종류> <글 폴더> 꼴로 준다", output);
1171
+ return 2;
1172
+ }
1173
+ const [, kind, folder] = positionals;
1174
+ if (!existsSync(folder) || !statSync(folder).isDirectory()) {
1175
+ emit(`폴더가 아니다: ${folder}`, output);
1176
+ return 2;
1177
+ }
1178
+ const result = buildUsageIndex(kind, readUsageDocuments(folder), root);
1179
+ emit(`${join(root, kind)}: 문서 ${comma(result.documents)}편, 문장 ${comma(result.sentences)}개, 단어 ${comma(result.terms)}종. hanlint usage "단어" --kind ${kind}로 찾는다`, output);
1180
+ return 0;
1181
+ }
1182
+ const kind = /** @type {string} */ (options["--kind"] ?? USAGE_KINDS[0]);
1183
+ const limit = Math.max(Number(options["--limit"] ?? 5), 0);
1184
+ if (!Number.isInteger(limit)) throw new UsageError("--limit은 정수다");
1185
+ const query = positionals.join(" ");
1186
+ const index = loadUsageIndex(kind, root);
1187
+ if (!index) {
1188
+ emit(missingIndex(kind, root), output);
1189
+ return 2;
1190
+ }
1191
+ const words = usageQueryCores(query)
1192
+ .slice(0, USAGE_QUERY_WORDS)
1193
+ .map((core) => index.collocations(core))
1194
+ .filter((item) => item !== null && (item.predicates.length || item.following.length || item.preceding.length));
1195
+ const hits = index.search(query, limit);
1196
+ if (format === "json") {
1197
+ const data = { version: 2, kind, query, documents: index.documents, sentences: index.sentences, words, hits };
1198
+ emit(JSON.stringify(data, null, 2), output);
1199
+ return 0;
1200
+ }
1201
+ const lines = [`${kind} 용례 (문서 ${index.documents}편, 문장 ${index.sentences}개): ${query}`];
1202
+ for (const item of words) {
1203
+ if (!item) continue;
1204
+ lines.push(`함께 쓰는 말 (${item.term}, 문장 ${item.sampled}개에서)`);
1205
+ for (const [label, pairs] of [["뒤 용언", item.predicates], ["뒤 명사", item.following], ["앞 명사", item.preceding]]) {
1206
+ if (pairs.length) lines.push(` ${label}: ${pairText(/** @type {[string, number][]} */ (pairs))}`);
1207
+ }
1208
+ }
1209
+ if (!hits.length && limit > 0) lines.push("쓰인 문장이 없다. 단어를 줄이거나 다른 단어로 찾는다");
1210
+ hits.forEach((hit, index) => {
1211
+ lines.push(`${index + 1}. ${hit.text}`);
1212
+ lines.push(` 문서 ${hit.documents}편, 출처 ${hit.source}`);
1213
+ });
1214
+ emit(lines.join("\n"), output);
1215
+ return 0;
1216
+ }
1217
+
1218
+ /** `hanlint sheet 폴더/ --preset screen`과 `hanlint sheet apply 시트.md`. 뜻은 파이썬 cli/commands/sheet.py가 소유한다. @param {string[]} args */
1066
1219
  function runSheet(args) {
1067
1220
  const { options, positionals } = parseArgs(args);
1068
1221
  const output = /** @type {string | undefined} */ (options["--output"]);
@@ -1083,24 +1236,14 @@ function runSheet(args) {
1083
1236
  /** @type {string[]} */
1084
1237
  const files = [];
1085
1238
  for (const target of positionals) {
1086
- if (!existsSync(target)) throw new Error(`${target} 이 없다`);
1239
+ if (!existsSync(target)) throw new Error(`없는 파일: ${target}`);
1087
1240
  if (statSync(target).isDirectory()) files.push(...sourceUnder(target));
1088
1241
  else files.push(target);
1089
1242
  }
1090
1243
  const config = configFrom(options, files);
1091
1244
  const format = choose(/** @type {string} */ (options["--format"] ?? "markdown"), ["markdown", "json"], "--format");
1092
- /** @type {import("../report/sheet.js").SheetRow[]} */
1093
- const rows = [];
1094
- for (const file of files) {
1095
- const label = relativeLabel(file);
1096
- for (const literal of sourceLiterals(readFileSync(file, "utf-8"), label)) {
1097
- const findings = runAll(fingerprint(literal.plain, config), config).filter((finding) => finding.severity === "error");
1098
- // 지적이 하나이고 고침이 있으면 고침 칸에 미리 적는다. 뜻은 파이썬 cli/commands/sheet.py 의 prefilledFix 가 소유한다.
1099
- const only = literal.plain === literal.text && findings.length === 1 ? findings[0] : null;
1100
- const fix = only && only.fix !== null && only.quote === literal.plain ? only.fix : "";
1101
- if (findings.length || options["--all"]) rows.push({ file: label, line: literal.line, text: literal.text, findings, fix, column: literal.column });
1102
- }
1103
- }
1245
+ // 디스크의 파일을 읽어 report.sheetRows에 넘긴다. 행의 뜻은 그쪽이 소유한다.
1246
+ const rows = sheetRows(files.map((file) => ({ label: relativeLabel(file), source: readFileSync(file, "utf-8") })), config, Boolean(options["--all"]));
1104
1247
  emit(format === "json" ? renderSheetJson(rows, config.preset, files.length) : renderSheet(rows, config.preset, files.length), output);
1105
1248
  return 0;
1106
1249
  }
@@ -1120,8 +1263,14 @@ function dispatch(argv) {
1120
1263
  process.stdout.write(`hanlint ${version}\n`);
1121
1264
  return 0;
1122
1265
  }
1266
+ if (rest.includes("--help") || rest.includes("-h")) {
1267
+ // npm에는 명령마다의 도움말이 없다. 도움말을 찾은 사람에게 '모르는 옵션' 대신 전체 도움말을 보인다
1268
+ process.stdout.write(`${USAGE}
1269
+ `);
1270
+ return 0;
1271
+ }
1123
1272
  if (PYTHON_ONLY.includes(command)) {
1124
- throw new Error(`${command} 는 파이썬 패키지에 있다 (pip install hanlint). npm 은 ${COMMANDS.join(", ")} 을 제공한다`);
1273
+ throw new Error(`${command} 명령은 파이썬 hanlint에만 있다(pip install hanlint). npm hanlint의 명령: ${COMMANDS.join(", ")}`);
1125
1274
  }
1126
1275
  if (command === "lint") return runLint(rest);
1127
1276
  if (command === "contract") return runContract(rest);
@@ -1138,6 +1287,7 @@ function dispatch(argv) {
1138
1287
  if (command === "baseline") return runBaseline(rest);
1139
1288
  if (command === "doctor") return runDoctor(rest);
1140
1289
  if (command === "sheet") return runSheet(rest);
1290
+ if (command === "usage") return runUsage(rest);
1141
1291
  return runInit(rest);
1142
1292
  }
1143
1293
 
@@ -1152,7 +1302,7 @@ export function main(argv) {
1152
1302
  }
1153
1303
  const failure = /** @type {NodeJS.ErrnoException} */ (error);
1154
1304
  if (failure.code === "ENOENT") {
1155
- process.stderr.write(`${failure.path} 를 찾지 못했다. 경로를 확인하거나 hanlint --help\n`);
1305
+ process.stderr.write(`찾지 못한 파일: ${failure.path}. 경로를 확인하거나 hanlint --help를 본다\n`);
1156
1306
  return 2;
1157
1307
  }
1158
1308
  process.stderr.write(`${failure.message}\n`);