hanlint 0.0.17 → 0.0.18

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 (54) hide show
  1. package/README.md +12 -10
  2. package/data/calqueFrame.json +60 -0
  3. package/data/cliches.json +10 -0
  4. package/data/collocationStops.txt +28 -0
  5. package/data/easyWords.json +0 -234
  6. package/data/exemplars.json +23 -2
  7. package/data/koreanProse.txt +64 -0
  8. package/data/patterns.json +2 -2
  9. package/data/phraseRepeat.json +11 -0
  10. package/data/ruleCategories.json +2 -0
  11. package/data/ruleDocs.json +18 -16
  12. package/data/ruleMechanisms.json +2 -0
  13. package/data/screenNarration.json +7 -7
  14. package/data/screenSentence.json +56 -8
  15. package/data/screenTone.json +4 -4
  16. package/data/screenWord.json +48 -88
  17. package/data/spacing.json +1 -1
  18. package/data/usageCounts.report.json +1676 -0
  19. package/data/version.json +1 -1
  20. package/package.json +1 -1
  21. package/src/analysis/index.js +1 -1
  22. package/src/analysis/tokenize.js +30 -15
  23. package/src/cli/help.js +3 -2
  24. package/src/cli/main.js +227 -88
  25. package/src/cli/walk.js +1 -1
  26. package/src/cli/welcome.js +1 -1
  27. package/src/config/settings.js +17 -0
  28. package/src/data/profiles.js +2 -2
  29. package/src/data/prose.js +27 -0
  30. package/src/document/sourceText.js +104 -6
  31. package/src/fingerprint/build.js +13 -4
  32. package/src/fingerprint/dictionaries.js +31 -4
  33. package/src/fingerprint/markers.js +22 -0
  34. package/src/index.js +3 -1
  35. package/src/report/compactReport.js +7 -3
  36. package/src/report/jsonReport.js +2 -2
  37. package/src/report/sheet.js +0 -0
  38. package/src/report/textReport.js +27 -8
  39. package/src/report/writingSpec.js +3 -3
  40. package/src/rules/registry.js +5 -1
  41. package/src/rules/screen/screenWord.js +2 -1
  42. package/src/rules/sentence/calqueFrame.js +11 -0
  43. package/src/rules/sentence/cliche.js +7 -3
  44. package/src/rules/sentence/hardWord.js +6 -3
  45. package/src/rules/sentence/japaneseLoan.js +7 -3
  46. package/src/rules/sentence/nounPile.js +10 -4
  47. package/src/rules/sentence/phraseRepeat.js +53 -0
  48. package/src/rules/sentence/redundantPair.js +7 -3
  49. package/src/rules/sentence/translationese.js +7 -3
  50. package/src/rules/shared/dictionaryRule.js +37 -6
  51. package/src/text.js +1 -1
  52. package/src/usage/counts.js +54 -0
  53. package/src/usage/index.js +8 -0
  54. package/src/usage/sentences.js +681 -0
package/data/version.json CHANGED
@@ -1 +1 @@
1
- {"version": "0.0.17"}
1
+ {"version": "0.0.18"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hanlint",
3
- "version": "0.0.17",
3
+ "version": "0.0.18",
4
4
  "description": "한국어 글 검사기. Korean prose linter for Markdown: translationese, noun piles, double passives, weak structure. Zero dependencies",
5
5
  "type": "module",
6
6
  "bin": {
@@ -4,4 +4,4 @@
4
4
  * 지문이 필요로 하는 것은 넷이다. splitSentences, euiCount 와 euiAdjacent, longestNounRun, doublePassives.
5
5
  */
6
6
  export { splitSentences } from "./splitSentences.js";
7
- export { doublePassives, doublePassiveSpans, euiAdjacent, euiCount, longestNounRun } from "./tokenize.js";
7
+ export { doublePassives, doublePassiveSpans, euiAdjacent, euiCount, longestNounRun, nounRuns } from "./tokenize.js";
@@ -12,13 +12,13 @@ const WORD_CHARS = /^[가-힣A-Za-z0-9]+$/;
12
12
  /** 관형격 조사 의. 영문과 숫자 뒤 (API의, 600MiB의) 와 닫는 괄호 뒤 (L7(HTTP)의) 도 센다. 파이썬 tokenize.py 와 같다. */
13
13
  const GENITIVE = /([가-힣A-Za-z0-9]+|\))의(?=[\s,.)\]]|$)/g;
14
14
  const SPACED_GENITIVE = /(?<=\s)의(?=\s)/g;
15
- const COPULA = /^(?:이(?:고|며|다|라|란|면|라서|므로|지만|어서|었다|었고)|인|인데|입니다|였다)$/;
15
+ export const COPULA = /^(?:이(?:고|며|다|라|란|면|라서|므로|지만|어서|었다|었고)|인|인데|입니다|였다)$/;
16
16
  const DIGITS = /^\d+$/;
17
17
  /** 수에 단위가 붙은 어절 (2012년, 30분쯤, 제16호, 100명). 수량이라 명사 나열에서 세지도 끊지도 않는다. */
18
18
  const QUANTITY = /^제?\d[\d,.]*[가-힣]{1,3}$/;
19
19
  const OPENERS = "([{\"“‘'";
20
20
  const CLOSERS = ",.?!;:)]}\"”’'";
21
- const EDGE_PUNCTUATION = ".,?!:;\"'“”‘’()[]{}<>";
21
+ export const EDGE_PUNCTUATION = ".,?!:;\"'“”‘’()[]{}<>";
22
22
 
23
23
  /**
24
24
  * @typedef {object} Word
@@ -130,32 +130,47 @@ export function isBareNoun(core) {
130
130
  * 표층으로는 명사로 보이지만 명사 쌓기의 재료가 아니다. data/nonNouns.txt 가 그 목록이고 연속을 끊는다.
131
131
  * @param {string} text
132
132
  */
133
- export function longestNounRun(text) {
134
- let longest = 0;
133
+ /**
134
+ * 명사 어절 연속마다 [어절들, 센 길이]. 뜻은 파이썬 analysis/tokenize.py 의 nounRuns 가 소유한다.
135
+ * 수사와 단위는 세지도 끊지도 않고 어절에도 안 넣는다. 잇달은 영문 어절은 하나로 센다.
136
+ * @param {string} text @returns {[string[], number][]}
137
+ */
138
+ export function nounRuns(text) {
139
+ /** @type {[string[], number][]} */
140
+ const runs = [];
141
+ /** @type {string[]} */
142
+ let chain = [];
135
143
  let run = 0;
136
144
  let previousAscii = false;
137
145
  let afterNumeral = false;
146
+ const close = () => {
147
+ if (run) runs.push([chain, run]);
148
+ chain = [];
149
+ run = 0;
150
+ previousAscii = false;
151
+ };
138
152
  for (const word of words(text)) {
139
- if (word.opens) {
140
- run = 0;
141
- previousAscii = false;
142
- }
153
+ if (word.opens) close();
143
154
  const transparent = isNumeral(word.core) || afterNumeral || isQuantity(word.core);
144
155
  afterNumeral = isNumeral(word.core);
145
156
  if (word.particle || nonNouns().has(word.core) || !isBareNoun(word.core) || isCopulaAdnominal(word.core)) {
146
- run = 0;
147
- previousAscii = false;
157
+ close();
148
158
  } else if (!transparent) {
149
159
  const isAscii = !HANGUL.test(word.core);
150
160
  if (!(isAscii && previousAscii)) run += 1;
161
+ chain.push(word.core);
151
162
  previousAscii = isAscii;
152
- longest = Math.max(longest, run);
153
- }
154
- if (word.endsClause) {
155
- run = 0;
156
- previousAscii = false;
157
163
  }
164
+ if (word.endsClause) close();
158
165
  }
166
+ close();
167
+ return runs;
168
+ }
169
+
170
+ /** 명사 어절 연속의 최대 길이. nounRuns 의 센 길이 가운데 가장 큰 것. @param {string} text */
171
+ export function longestNounRun(text) {
172
+ let longest = 0;
173
+ for (const [, length] of nounRuns(text)) longest = Math.max(longest, length);
159
174
  return longest;
160
175
  }
161
176
 
package/src/cli/help.js CHANGED
@@ -7,7 +7,7 @@ export function rootHelp() {
7
7
  일상 검사
8
8
  hanlint 글.md 글을 검사한다
9
9
  hanlint fix 글.md 안전한 고침만 적용한다
10
- hanlint hook AI가 저장한 마크다운을 같은 턴에 검사한다
10
+ hanlint hook AI에게 쓰기 전 작법서를 싣고, 저장한 마크다운을 같은 턴에 검사한다
11
11
 
12
12
  요구사항 잠금
13
13
  hanlint contract init 글.md --reader "독자" --goal "목표" --outline h2
@@ -23,7 +23,8 @@ export function rootHelp() {
23
23
  hanlint spec --preset <종류> --chars 800
24
24
  쓰기 전 숫자 사양을 만든다
25
25
  hanlint primer --preset <종류> 쓰기 전에 규칙의 고치는 법과 본보기를 한 장으로 본다
26
- hanlint sheet src/ --preset screen 소스의 화면 글을 표 하나로 떨군다. 고친 표는 sheet apply 로 되돌려 쓴다
26
+ hanlint sheet src/ --preset screen 소스의 화면 글을 표 하나로 내보낸다. 고친 표는 sheet apply 로 되돌려 쓴다
27
+ hanlint usage "낱말 낱말" 그 종류의 실제 글에서 낱말이 쓰인 문장을 본다. 색인은 usage build 가 만든다
27
28
 
28
29
  추가 명령
29
30
  baseline, print
package/src/cli/main.js CHANGED
@@ -6,12 +6,12 @@
6
6
  * hanlint 인자가 없으면 첫 화면. 무엇을 칠 수 있는지 보인다
7
7
  * hanlint 글.md [다른.md ...] 검사. 서브커맨드 없이 파일이나 폴더만 주면 lint 다. `-` 는 stdin
8
8
  * hanlint fix 글.md 기계가 고칠 수 있는 지적을 원문에 적용
9
- * hanlint print 글.md 지문 계층 JSON
9
+ * hanlint print 글.md 분석표 계층 JSON
10
10
  * hanlint rules 규칙 목록을 부류로 묶어서
11
11
  * hanlint explain <규칙> 규칙의 기술서
12
12
  * hanlint patterns 문장 틀. --rule 로 그 규칙을 피하는 것만
13
- * hanlint primer --preset <종류> 쓰기 전에 읽는 한 장. 켜진 규칙의 고치는 법과 본보기 전후
14
- * hanlint hook Claude Code가 쓴 마크다운을 같은 턴에 비차단 검사
13
+ * hanlint primer --preset <종류> 쓰기 전에 읽는 한 장. 작법서 일곱 줄과 켜진 규칙의 고치는 법, 본보기 전후
14
+ * hanlint hook 에이전트 훅. --prime 은 쓰기 전 작법서, 나머지는 쓴 마크다운의 비차단 검사
15
15
  * hanlint baseline 글들/ 지금 있는 지적을 잠근다. 그다음부터 새것만 막힌다
16
16
  * hanlint baseline 글들/ 지금 있는 지적을 잠근다. 그다음부터 새것만 막힌다
17
17
  * hanlint doctor 설정과 꺼진 규칙
@@ -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",
@@ -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 {}
@@ -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
  }
@@ -354,7 +370,7 @@ function nextStep(results, fixable = 0) {
354
370
  if (patternsAvoiding(rule).length) return `다음: error ${errors.length}건을 고친다. 다시 쓸 틀은 hanlint patterns --rule ${rule}`;
355
371
  return `다음: error ${errors.length}건을 고친다. 규칙이 왜 있는지는 hanlint explain ${rule}`;
356
372
  }
357
- if (notices) return `다음: error 0. 확인할 자리 ${notices}건을 읽고 판단한 뒤 사람과 LLM 평가로 넘어간다`;
373
+ if (notices) return `다음: error 0. 확인 권고 ${notices}건을 읽고 판단한 뒤 사람과 LLM 평가로 넘어간다`;
358
374
  return "다음: 세어서 잡히는 결함이 없다. 좋은 글이라는 뜻은 아니므로 사람과 LLM 평가로 넘어간다";
359
375
  }
360
376
 
@@ -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));
@@ -753,10 +769,17 @@ function primerEntries(config, register) {
753
769
  return entries;
754
770
  }
755
771
 
772
+ /** 작법서를 절마다 한 줄로. 제목 두 칸 띄고 본문이다. 파이썬 proseLines 와 같다. */
773
+ function proseLines() {
774
+ return proseSections().map(([title, body]) => `${title} ${body.split(/\s+/).filter(Boolean).join(" ")}`);
775
+ }
776
+
756
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);
@@ -802,53 +825,104 @@ 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}에서 센 Finding입니다.\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
  }
@@ -920,10 +994,10 @@ export function renderInit(preset = "blog") {
920
994
  "# hanlint 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
+ "# 임계. 기본값의 기준은 hanlint 의 config/settings.py 다",
927
1001
  );
928
1002
  for (const name of THRESHOLD_FIELDS) {
929
1003
  const value = /** @type {number} */ (defaults[name]);
@@ -931,9 +1005,16 @@ 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 = "를 아는 것" }]',
@@ -995,7 +1076,9 @@ 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
1084
  /** 작업 폴더 기준 상대 경로, 구분자는 `/`. 파이썬 relativeLabel 과 같은 글자를 낸다. @param {string} path */
@@ -1028,40 +1111,105 @@ 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
 
1123
+ /** 색인이 없을 때의 안내. 뜻은 파이썬 cli/commands/usage.py 의 missingIndex 가 소유한다. @param {string} kind @param {string} root */
1124
+ function missingIndex(kind, root) {
1125
+ const kinds = listUsageIndexes(root).map((meta) => String(meta.kind)).join(", ") || "없음";
1126
+ return `${kind} 색인이 없다 (있는 것: ${kinds}). hanlint usage build ${kind} <글 폴더> 로 만든다 (txt, md, jsonl. 하위 폴더 포함). 사업보고서는 scripts/fetch/dartReports.py, 위키백과는 scripts/fetch/koWikipedia.py 가 받는다`;
1127
+ }
1128
+
1129
+ /** 함께 쓰는 말 낱말 수 상한. 뜻은 파이썬 QUERY_WORDS 가 소유한다. */
1130
+ const USAGE_QUERY_WORDS = 3;
1131
+
1132
+ /** @param {[string, number][]} pairs */
1133
+ function pairText(pairs) {
1134
+ return pairs.map(([term, count]) => `${term} ${count}편`).join(", ");
1135
+ }
1136
+
1137
+ /**
1138
+ * `hanlint usage "낱말 …" --kind report --limit 5`, `hanlint usage build <종류> <글 폴더>`, `hanlint usage kinds`. 뜻은 파이썬
1139
+ * cli/commands/usage.py 가 소유한다.
1140
+ * @param {string[]} args
1141
+ */
1142
+ function runUsage(args) {
1143
+ const { options, positionals } = parseArgs(args);
1144
+ const output = /** @type {string | undefined} */ (options["--output"]);
1145
+ const root = /** @type {string} */ (options["--root"] ?? defaultUsageRoot());
1146
+ const format = choose(/** @type {string} */ (options["--format"] ?? "text"), ["text", "json"], "--format");
1147
+ if (!positionals.length) throw new UsageError('hanlint usage "낱말 …" 또는 hanlint usage build <종류> <글 폴더>');
1148
+ if (positionals[0] === "kinds") {
1149
+ const found = listUsageIndexes(root);
1150
+ if (format === "json") {
1151
+ emit(JSON.stringify({ version: 1, root, kinds: found }, null, 2), output);
1152
+ return 0;
1153
+ }
1154
+ if (!found.length) {
1155
+ emit(`${root} 에 색인이 없다. hanlint usage build <종류> <글 폴더> 로 만든다`, output);
1156
+ return 0;
1157
+ }
1158
+ const lines = [`색인 ${found.length}개 (${root})`];
1159
+ for (const meta of found) lines.push(` ${meta.kind}: 문서 ${meta.documents}편, 문장 ${meta.sentences}개, 토큰 ${meta.terms}종`);
1160
+ emit(lines.join("\n"), output);
1161
+ return 0;
1162
+ }
1163
+ if (positionals[0] === "build") {
1164
+ if (positionals.length !== 3) {
1165
+ emit("hanlint usage build <종류> <글 폴더> 꼴로 준다", output);
1166
+ return 2;
1167
+ }
1168
+ const [, kind, folder] = positionals;
1169
+ if (!existsSync(folder) || !statSync(folder).isDirectory()) {
1170
+ emit(`${folder} 는 폴더가 아니다`, output);
1171
+ return 2;
1172
+ }
1173
+ const result = buildUsageIndex(kind, readUsageDocuments(folder), root);
1174
+ emit(`${join(root, kind)}: 문서 ${result.documents}편, 문장 ${result.sentences}개, 토큰 ${result.terms}종. hanlint usage "낱말" --kind ${kind} 로 묻는다`, output);
1175
+ return 0;
1176
+ }
1177
+ const kind = /** @type {string} */ (options["--kind"] ?? USAGE_KINDS[0]);
1178
+ const limit = Math.max(Number(options["--limit"] ?? 5), 0);
1179
+ if (!Number.isInteger(limit)) throw new UsageError("--limit 은 정수다");
1180
+ const query = positionals.join(" ");
1181
+ const index = loadUsageIndex(kind, root);
1182
+ if (!index) {
1183
+ emit(missingIndex(kind, root), output);
1184
+ return 2;
1185
+ }
1186
+ const words = usageQueryCores(query)
1187
+ .slice(0, USAGE_QUERY_WORDS)
1188
+ .map((core) => index.collocations(core))
1189
+ .filter((item) => item !== null && (item.predicates.length || item.following.length || item.preceding.length));
1190
+ const hits = index.search(query, limit);
1191
+ if (format === "json") {
1192
+ const data = { version: 2, kind, query, documents: index.documents, sentences: index.sentences, words, hits };
1193
+ emit(JSON.stringify(data, null, 2), output);
1194
+ return 0;
1195
+ }
1196
+ const lines = [`${kind} 용례 (문서 ${index.documents}편, 문장 ${index.sentences}개): ${query}`];
1197
+ for (const item of words) {
1198
+ if (!item) continue;
1199
+ lines.push(`함께 쓰는 말 (${item.term}, 문장 ${item.sampled}개에서)`);
1200
+ for (const [label, pairs] of [["뒤 용언", item.predicates], ["뒤 명사", item.following], ["앞 명사", item.preceding]]) {
1201
+ if (pairs.length) lines.push(` ${label}: ${pairText(/** @type {[string, number][]} */ (pairs))}`);
1202
+ }
1203
+ }
1204
+ if (!hits.length && limit > 0) lines.push("쓰인 문장이 없다. 낱말을 줄이거나 다른 낱말로 묻는다");
1205
+ hits.forEach((hit, index) => {
1206
+ lines.push(`${index + 1}. ${hit.text}`);
1207
+ lines.push(` 문서 ${hit.documents}편, 출처 ${hit.source}`);
1208
+ });
1209
+ emit(lines.join("\n"), output);
1210
+ return 0;
1211
+ }
1212
+
1065
1213
  /** `hanlint sheet 폴더/ --preset screen` 과 `hanlint sheet apply 시트.md`. 뜻은 파이썬 cli/commands/sheet.py 가 소유한다. @param {string[]} args */
1066
1214
  function runSheet(args) {
1067
1215
  const { options, positionals } = parseArgs(args);
@@ -1089,18 +1237,8 @@ function runSheet(args) {
1089
1237
  }
1090
1238
  const config = configFrom(options, files);
1091
1239
  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
- }
1240
+ // 디스크의 파일을 읽어 report.sheetRows 에 넘긴다. 행의 뜻은 그쪽이 소유한다.
1241
+ const rows = sheetRows(files.map((file) => ({ label: relativeLabel(file), source: readFileSync(file, "utf-8") })), config, Boolean(options["--all"]));
1104
1242
  emit(format === "json" ? renderSheetJson(rows, config.preset, files.length) : renderSheet(rows, config.preset, files.length), output);
1105
1243
  return 0;
1106
1244
  }
@@ -1138,6 +1276,7 @@ function dispatch(argv) {
1138
1276
  if (command === "baseline") return runBaseline(rest);
1139
1277
  if (command === "doctor") return runDoctor(rest);
1140
1278
  if (command === "sheet") return runSheet(rest);
1279
+ if (command === "usage") return runUsage(rest);
1141
1280
  return runInit(rest);
1142
1281
  }
1143
1282
 
package/src/cli/walk.js CHANGED
@@ -16,7 +16,7 @@ export function isSkipped(name) {
16
16
  return name.startsWith(".") || SKIPPED_FOLDERS.includes(name);
17
17
  }
18
18
 
19
- /** 폴더 아래 마크다운. 건너뛸 폴더에는 안 들어간다. 경로 문자열 순이라 두 판이 같은 차례를 낸다. @param {string} folder */
19
+ /** 폴더 아래 마크다운. 건너뛸 폴더에는 안 들어간다. 경로 문자열 순이라 두 구현이 같은 차례를 낸다. @param {string} folder */
20
20
  export function markdownUnder(folder) {
21
21
  /** @type {string[]} */
22
22
  const found = [];
@@ -1,7 +1,7 @@
1
1
  // @ts-check
2
2
  /**
3
3
  * `hanlint` 를 인자 없이 쳤을 때의 첫 화면. 파이썬 cli/welcome.py 와 같은 글자다.
4
- * 같은 폴더에서 두 판을 돌리면 결과가 같다. 파일을 찾는 것은 검사와 같은 함수 (walk.markdownUnder) 다.
4
+ * 같은 폴더에서 두 구현을 돌리면 결과가 같다. 파일을 찾는 것은 검사와 같은 함수 (walk.markdownUnder) 다.
5
5
  */
6
6
  import { relative, sep } from "node:path";
7
7