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/walk.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // @ts-check
2
2
  /**
3
3
  * 폴더에서 검사할 마크다운을 찾는 규칙. 첫 화면과 검사가 **같은 함수**를 쓴다.
4
- * 무엇을 건너뛰고 왜 그러는지는 파이썬 `cli/commands/shared.py` 의 SKIPPED_FOLDERS 가 소유한다.
4
+ * 무엇을 건너뛰고 왜 그러는지는 파이썬 `cli/commands/shared.py`의 SKIPPED_FOLDERS가 소유한다.
5
5
  */
6
6
  import { readdirSync, statSync } from "node:fs";
7
7
  import { join } from "node:path";
@@ -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
- * `hanlint` 를 인자 없이 쳤을 때의 첫 화면. 파이썬 cli/welcome.py 와 같은 글자다.
4
- * 같은 폴더에서 두 판을 돌리면 결과가 같다. 파일을 찾는 것은 검사와 같은 함수 (walk.markdownUnder) 다.
3
+ * `hanlint`를 인자 없이 쳤을 때의 첫 화면. 파이썬 cli/welcome.py와 같은 글자다.
4
+ * 같은 폴더에서 두 구현을 돌리면 결과가 같다. 파일을 찾는 것은 검사와 같은 함수 (walk.markdownUnder)다.
5
5
  */
6
6
  import { relative, sep } from "node:path";
7
7
 
@@ -27,24 +27,24 @@ export function welcome(version, folder) {
27
27
  const nearby = nearbyMarkdown(where);
28
28
  const example = nearby.length ? nearby[0] : "글.md";
29
29
  const shown = [
30
- [`hanlint ${example}`, "검사한다. 자리와 이유와 고칠 말이 나온다"],
31
- [`hanlint fix ${example}`, "기계가 확실히 고칠 수 있는 자리를 원문에 적용한다"],
30
+ [`hanlint ${example}`, "검사한다. 걸린 곳과 까닭과 고칠 말이 나온다"],
31
+ [`hanlint fix ${example}`, "기계가 확실히 고칠 수 있는 곳을 원문에 고친다"],
32
32
  [`hanlint audit ${example}`, "글의 모양을 지도와 분포로 본다"],
33
33
  ];
34
34
  const width = Math.max(...shown.map(([command]) => command.length));
35
35
  const lines = [
36
- `hanlint ${version} 한국어 글에서 세면 확정되는 결함을 집는다. 좋은 글인지는 판정하지 않는다`,
36
+ `hanlint ${version} 한국어 글에서 규칙으로 잡을 수 있는 잘못을 찾는다. 좋은 글인지는 판정하지 않는다`,
37
37
  "",
38
38
  ...shown.map(([command, why]) => ` ${command.padEnd(width)} ${why}`),
39
39
  ];
40
40
  if (nearby.length) {
41
- lines.push("", `이 폴더의 마크다운: ${nearby.join(", ")}. 폴더를 통째로 줘도 된다 (hanlint .)`);
41
+ lines.push("", `이 폴더의 마크다운: ${nearby.join(", ")}. 폴더를 통째로 줘도 된다(hanlint .)`);
42
42
  } else {
43
- lines.push("", "이 폴더에는 검사할 마크다운이 없다. 파일 하나나 폴더 하나를 인자로 준다");
43
+ lines.push("", "이 폴더에는 검사할 마크다운이 없다. 파일이나 폴더 하나를 인자로 준다");
44
44
  }
45
45
  lines.push(
46
46
  "",
47
- "처음이면 hanlint init 으로 설정을 만든다. 글의 종류가 블로그가 아니면 --preset report 나 --preset docs",
47
+ "처음이면 hanlint init으로 설정 파일을 만든다. 블로그 글이 아니면 --preset report나 --preset docs를 붙인다",
48
48
  "규칙 목록은 hanlint rules, 규칙 하나가 왜 있는지는 hanlint explain <규칙>, 지금 상태는 hanlint doctor",
49
49
  "전체 사용법은 hanlint --help",
50
50
  );
@@ -1,5 +1,5 @@
1
1
  // @ts-check
2
- /** 설정 파일을 찾아 읽는다. `hanlint.toml` 이거나 `pyproject.toml` 의 `[tool.hanlint]` 다. 파이썬과 같은 탐색이다. */
2
+ /** 설정 파일을 찾아 읽는다. `hanlint.toml` 이거나 `pyproject.toml`의 `[tool.hanlint]`다. 파이썬과 같은 탐색이다. */
3
3
  import { existsSync, readFileSync } from "node:fs";
4
4
  import { basename, dirname, join, resolve } from "node:path";
5
5
 
@@ -21,7 +21,7 @@ export function readConfigFile(path) {
21
21
  return config;
22
22
  }
23
23
 
24
- /** start 에서 위로 올라가며 hanlint.toml 이나 [tool.hanlint] 를 가진 pyproject.toml 을 찾는다. @param {string} start */
24
+ /** start에서 위로 올라가며 hanlint.toml이나 [tool.hanlint]를 가진 pyproject.toml을 찾는다. @param {string} start */
25
25
  export function findConfigFile(start) {
26
26
  let folder = resolve(start);
27
27
  while (true) {
@@ -40,7 +40,7 @@ export function findConfigFile(start) {
40
40
  }
41
41
 
42
42
  /**
43
- * `path` 를 주면 그 파일만 읽는다. 안 주면 `start` (기본 현재 폴더) 에서 찾고, 없으면 기본값이다.
43
+ * `path`를 주면 그 파일만 읽는다. 안 주면 `start` (기본 현재 폴더)에서 찾고, 없으면 기본값이다.
44
44
  * @param {string | null} [path]
45
45
  * @param {string | null} [start]
46
46
  */
@@ -5,9 +5,9 @@ import { sha256 } from "../digest.js";
5
5
  /** @param {unknown} value @param {string} where */
6
6
  function checkedString(value, where) {
7
7
  if (typeof value !== "string" || !value || value !== value.trim()) {
8
- throw new Error(`${where} 는 양끝 공백 없는 문자열이다`);
8
+ throw new Error(`${where}: 양끝 공백 없는 문자열이어야 한다`);
9
9
  }
10
- if (value !== value.normalize("NFC")) throw new Error(`${where} 는 NFC 문자열이어야 한다`);
10
+ if (value !== value.normalize("NFC")) throw new Error(`${where}: NFC 문자열이어야 한다`);
11
11
  return value;
12
12
  }
13
13
 
@@ -17,19 +17,19 @@ export class Patch {
17
17
  this.reason = checkedString(reason, "reason");
18
18
  this.before = checkedString(before, "before");
19
19
  this.after = checkedString(after, "after");
20
- if (this.before === this.after) throw new Error("patch before 와 after 는 달라야 한다");
20
+ if (this.before === this.after) throw new Error("patch before와 after는 달라야 한다");
21
21
  Object.freeze(this);
22
22
  }
23
23
 
24
24
  /** @param {unknown} raw */
25
25
  static fromMapping(raw) {
26
- if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("patch 는 JSON 객체다");
26
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("patch는 JSON 객체여야 한다");
27
27
  const data = /** @type {Record<string, unknown>} */ (raw);
28
28
  const expected = ["reason", "before", "after"];
29
29
  const unknown = Object.keys(data).filter((key) => !expected.includes(key)).sort();
30
30
  const missing = expected.filter((key) => !(key in data)).sort();
31
- if (unknown.length) throw new Error(`patch 의 모르는 키: ${unknown.join(", ")}`);
32
- if (missing.length) throw new Error(`patch 의 빠진 키: ${missing.join(", ")}`);
31
+ if (unknown.length) throw new Error(`patch의 모르는 키: ${unknown.join(", ")}`);
32
+ if (missing.length) throw new Error(`patch의 빠진 키: ${missing.join(", ")}`);
33
33
  return new Patch(
34
34
  /** @type {string} */ (data.reason),
35
35
  /** @type {string} */ (data.before),
@@ -9,19 +9,19 @@ export const CONTRACT_VERSIONS = Object.freeze([CONTRACT_VERSION, LATEST_CONTRAC
9
9
  /** @param {unknown} value @param {string} where */
10
10
  function checkedString(value, where) {
11
11
  if (typeof value !== "string" || !value || value !== value.trim()) {
12
- throw new Error(`${where} 는 양끝 공백 없는 문자열이다`);
12
+ throw new Error(`${where}: 양끝 공백 없는 문자열이어야 한다`);
13
13
  }
14
- if (value !== value.normalize("NFC")) throw new Error(`${where} 는 NFC 문자열이어야 한다`);
14
+ if (value !== value.normalize("NFC")) throw new Error(`${where}: NFC 문자열이어야 한다`);
15
15
  return value;
16
16
  }
17
17
 
18
18
  /** @param {unknown} value @param {string} where @param {boolean} [allowEmpty] */
19
19
  function checkedStrings(value, where, allowEmpty = false) {
20
20
  if (!Array.isArray(value) || (!allowEmpty && !value.length)) {
21
- throw new Error(`${where} 는 ${allowEmpty ? "문자열 배열" : "비지 않은 문자열 배열"}이다`);
21
+ throw new Error(`${where}: ${allowEmpty ? "문자열 배열" : "비지 않은 문자열 배열"}이어야 한다`);
22
22
  }
23
23
  const found = value.map((item, index) => checkedString(item, `${where} ${index + 1}번째`));
24
- if (new Set(found).size !== found.length) throw new Error(`${where} 에 같은 값이 두 번 있다`);
24
+ if (new Set(found).size !== found.length) throw new Error(`${where}에 같은 값이 두 번 있다`);
25
25
  return found;
26
26
  }
27
27
 
@@ -45,7 +45,7 @@ export class Contract {
45
45
  /** @param {string} reader @param {string} goal @param {string[]} facts @param {number} [version] */
46
46
  constructor(reader, goal, facts, version = CONTRACT_VERSION) {
47
47
  if (!Number.isInteger(version) || version !== CONTRACT_VERSION) {
48
- throw new Error(`reader contract version 은 ${CONTRACT_VERSION}이다: ${version}`);
48
+ throw new Error(`reader contract version이 맞지 않다: ${version} (맞는 값: ${CONTRACT_VERSION})`);
49
49
  }
50
50
  this.reader = checkedString(reader, "reader");
51
51
  this.goal = checkedString(goal, "goal");
@@ -56,13 +56,13 @@ export class Contract {
56
56
 
57
57
  /** @param {unknown} raw */
58
58
  static fromMapping(raw) {
59
- if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("reader contract 는 JSON 객체다");
59
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("reader contract는 JSON 객체여야 한다");
60
60
  const data = /** @type {Record<string, unknown>} */ (raw);
61
61
  const expected = ["version", "reader", "goal", "facts"];
62
62
  const unknown = Object.keys(data).filter((key) => !expected.includes(key)).sort();
63
63
  const missing = expected.filter((key) => !(key in data)).sort();
64
- if (unknown.length) throw new Error(`reader contract 의 모르는 키: ${unknown.join(", ")}`);
65
- if (missing.length) throw new Error(`reader contract 의 빠진 키: ${missing.join(", ")}`);
64
+ if (unknown.length) throw new Error(`reader contract의 모르는 키: ${unknown.join(", ")}`);
65
+ if (missing.length) throw new Error(`reader contract의 빠진 키: ${missing.join(", ")}`);
66
66
  return new Contract(
67
67
  /** @type {string} */ (data.reader),
68
68
  /** @type {string} */ (data.goal),
@@ -96,13 +96,13 @@ export class ProtectedSurface {
96
96
 
97
97
  /** @param {unknown} raw */
98
98
  static fromMapping(raw) {
99
- if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("surface 는 JSON 객체다");
99
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("surface는 JSON 객체여야 한다");
100
100
  const data = /** @type {Record<string, unknown>} */ (raw);
101
101
  const expected = ["numbers", "urls", "code", "links"];
102
102
  const unknown = Object.keys(data).filter((key) => !expected.includes(key)).sort();
103
103
  const missing = expected.filter((key) => !(key in data)).sort();
104
- if (unknown.length) throw new Error(`surface 의 모르는 키: ${unknown.join(", ")}`);
105
- if (missing.length) throw new Error(`surface 의 빠진 키: ${missing.join(", ")}`);
104
+ if (unknown.length) throw new Error(`surface의 모르는 키: ${unknown.join(", ")}`);
105
+ if (missing.length) throw new Error(`surface의 빠진 키: ${missing.join(", ")}`);
106
106
  return new ProtectedSurface(
107
107
  /** @type {string[]} */ (data.numbers),
108
108
  /** @type {string[]} */ (data.urls),
@@ -128,7 +128,7 @@ export class ProtectedSurface {
128
128
  export class Outline {
129
129
  /** @param {number} level @param {string[]} headings */
130
130
  constructor(level, headings) {
131
- if (!Number.isInteger(level) || level < 1 || level > 6) throw new Error(`outline.level 은 1~6 정수다: ${level}`);
131
+ if (!Number.isInteger(level) || level < 1 || level > 6) throw new Error(`outline.level은 1~6 정수여야 한다. 받은 값: ${level}`);
132
132
  this.level = level;
133
133
  this.headings = Object.freeze(checkedStrings(headings, "outline.headings"));
134
134
  Object.freeze(this);
@@ -136,13 +136,13 @@ export class Outline {
136
136
 
137
137
  /** @param {unknown} raw */
138
138
  static fromMapping(raw) {
139
- if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("outline 은 JSON 객체다");
139
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("outline은 JSON 객체여야 한다");
140
140
  const data = /** @type {Record<string, unknown>} */ (raw);
141
141
  const expected = ["level", "headings"];
142
142
  const unknown = Object.keys(data).filter((key) => !expected.includes(key)).sort();
143
143
  const missing = expected.filter((key) => !(key in data)).sort();
144
- if (unknown.length) throw new Error(`outline 의 모르는 키: ${unknown.join(", ")}`);
145
- if (missing.length) throw new Error(`outline 의 빠진 키: ${missing.join(", ")}`);
144
+ if (unknown.length) throw new Error(`outline의 모르는 키: ${unknown.join(", ")}`);
145
+ if (missing.length) throw new Error(`outline의 빠진 키: ${missing.join(", ")}`);
146
146
  return new Outline(/** @type {number} */ (data.level), /** @type {string[]} */ (data.headings));
147
147
  }
148
148
 
@@ -155,7 +155,7 @@ export class ContractV2 {
155
155
  /** @param {string} reader @param {string} goal @param {string[]} facts @param {ProtectedSurface | Record<string, unknown>} surface @param {Outline | Record<string, unknown>} outline @param {number} [version] */
156
156
  constructor(reader, goal, facts, surface, outline, version = LATEST_CONTRACT_VERSION, lockedFacts = [], editPolicy = {}) {
157
157
  if (!Number.isInteger(version) || version !== LATEST_CONTRACT_VERSION) {
158
- throw new Error(`reader contract version 은 ${LATEST_CONTRACT_VERSION}다: ${version}`);
158
+ throw new Error(`reader contract version이 맞지 않다: ${version} (맞는 값: ${LATEST_CONTRACT_VERSION})`);
159
159
  }
160
160
  this.reader = checkedString(reader, "reader");
161
161
  this.goal = checkedString(goal, "goal");
@@ -163,14 +163,14 @@ export class ContractV2 {
163
163
  this.surface = surface instanceof ProtectedSurface ? surface : ProtectedSurface.fromMapping(surface);
164
164
  this.outline = outline instanceof Outline ? outline : Outline.fromMapping(outline);
165
165
  this.lockedFacts = Object.freeze(checkedStrings(lockedFacts, "lockedFacts", true));
166
- if (this.lockedFacts.some((fact) => !this.facts.includes(fact))) throw new Error("lockedFacts must select approved facts");
167
- if (!editPolicy || typeof editPolicy !== "object" || Array.isArray(editPolicy)) throw new Error("editPolicy must be an object");
166
+ if (this.lockedFacts.some((fact) => !this.facts.includes(fact))) throw new Error("lockedFacts는 승인한 facts 가운데서 골라야 한다");
167
+ if (!editPolicy || typeof editPolicy !== "object" || Array.isArray(editPolicy)) throw new Error("editPolicy는 객체여야 한다");
168
168
  this.editPolicy = Object.freeze(Object.fromEntries(Object.entries(editPolicy).sort(([a], [b]) => compareText(a, b)).map(([rule, limits]) => {
169
- if (!rule || rule !== rule.trim()) throw new Error("editPolicy requires rule names");
169
+ if (!rule || rule !== rule.trim()) throw new Error("editPolicy에는 규칙 이름이 있어야 한다");
170
170
  if (!limits || typeof limits !== "object" || Array.isArray(limits) || Object.keys(limits).sort().join(",") !== "maxChars,maxLines") {
171
- throw new Error("editPolicy requires maxChars and maxLines");
171
+ throw new Error("editPolicy의 규칙마다 maxChars와 maxLines가 있어야 한다");
172
172
  }
173
- if (Object.values(limits).some((n) => !Number.isInteger(n) || n < 1)) throw new Error("editPolicy limits must be positive integers");
173
+ if (Object.values(limits).some((n) => !Number.isInteger(n) || n < 1)) throw new Error("editPolicy의 한도는 양의 정수여야 한다");
174
174
  return [rule, Object.freeze({maxChars: limits.maxChars, maxLines: limits.maxLines})];
175
175
  })));
176
176
  this.version = version;
@@ -179,13 +179,13 @@ export class ContractV2 {
179
179
 
180
180
  /** @param {unknown} raw */
181
181
  static fromMapping(raw) {
182
- if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("reader contract 는 JSON 객체다");
182
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("reader contract는 JSON 객체여야 한다");
183
183
  const data = /** @type {Record<string, unknown>} */ (raw);
184
184
  const expected = ["version", "reader", "goal", "facts", "surface", "outline"];
185
185
  const unknown = Object.keys(data).filter((key) => !expected.includes(key) && !["lockedFacts", "editPolicy"].includes(key)).sort();
186
186
  const missing = expected.filter((key) => !(key in data)).sort();
187
- if (unknown.length) throw new Error(`reader contract 의 모르는 키: ${unknown.join(", ")}`);
188
- if (missing.length) throw new Error(`reader contract 의 빠진 키: ${missing.join(", ")}`);
187
+ if (unknown.length) throw new Error(`reader contract의 모르는 키: ${unknown.join(", ")}`);
188
+ if (missing.length) throw new Error(`reader contract의 빠진 키: ${missing.join(", ")}`);
189
189
  return new ContractV2(
190
190
  /** @type {string} */ (data.reader),
191
191
  /** @type {string} */ (data.goal),
@@ -237,9 +237,9 @@ export class ContractV2 {
237
237
 
238
238
  /** @param {unknown} raw */
239
239
  export function parseContract(raw) {
240
- if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("reader contract 는 JSON 객체다");
240
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("reader contract는 JSON 객체여야 한다");
241
241
  const version = /** @type {Record<string, unknown>} */ (raw).version;
242
242
  if (version === CONTRACT_VERSION) return Contract.fromMapping(raw);
243
243
  if (version === LATEST_CONTRACT_VERSION) return ContractV2.fromMapping(raw);
244
- throw new Error(`reader contract version 은 ${CONTRACT_VERSIONS.join(",")} 가운데 하나다: ${version}`);
244
+ throw new Error(`reader contract version이 맞지 않다: ${version} (맞는 값: ${CONTRACT_VERSIONS.join(",")} 가운데 하나)`);
245
245
  }
@@ -1,7 +1,7 @@
1
1
  // @ts-check
2
2
  /**
3
- * 설정과 임계 기본값. 정본은 파이썬 config/settings.py 이고 여기는 같은 값을 든다. 필드 이름이 곧 설정 파일의 키다.
4
- * 기본값을 바꾸면 양쪽을 같은 작업에서 바꾼다. tests/parity 가 두 구현의 결과를 견준다.
3
+ * 설정과 임계 기본값. 정본은 파이썬 config/settings.py이고 여기는 같은 값을 든다. 필드 이름이 곧 설정 파일의 키다.
4
+ * 기본값을 바꾸면 양쪽을 같은 작업에서 바꾼다. tests/parity가 두 구현의 결과를 견준다.
5
5
  */
6
6
 
7
7
  import { projectExemplars } from "../data/exemplars.js";
@@ -9,15 +9,16 @@ import { projectOperations } from "../data/operations.js";
9
9
  import { projectPatches } from "../data/patches.js";
10
10
 
11
11
  /**
12
- * 글의 종류마다 처음부터 끄고 시작할 규칙. 정본은 파이썬 config/settings.py 의 PRESETS 다.
12
+ * 글의 종류마다 처음부터 끄고 시작할 규칙. 정본은 파이썬 config/settings.py의 PRESETS다.
13
13
  * @type {Record<string, string[]>}
14
14
  */
15
- // 화면의 글에서만 켜는 것. 산문의 모든 종류가 처음부터 끈다. 파이썬 settings.py 의 SCREEN 과 같다.
15
+ // 화면의 글에서만 켜는 것. 산문의 모든 종류가 처음부터 끈다. 파이썬 settings.py의 SCREEN과 같다.
16
16
  const SCREEN = ["screenNarration", "screenSentence", "screenTone", "screenWord"];
17
17
  export const PRESETS = {
18
18
  blog: [...SCREEN],
19
- report: ["noQuestion", "firstResultDistance", "introImage", "moreLater", "numberOrphan", ...SCREEN],
19
+ report: ["noQuestion", "firstResultDistance", "introImage", "moreLater", "numberOrphan", "imperativePeriod", ...SCREEN],
20
20
  docs: [
21
+ "imperativePeriod",
21
22
  "noQuestion",
22
23
  "firstResultDistance",
23
24
  "introImage",
@@ -30,18 +31,18 @@ export const PRESETS = {
30
31
  ...SCREEN,
31
32
  ],
32
33
  };
33
- // 서사 글에서만 끄는 것. 설명글의 전제 (독자가 문장 사이 이유를 따라간다, 말끝을 맺는다) 가 장면과
34
- // 대사에는 성립하지 않는다. 파이썬 settings.py 의 NARRATIVE 와 같다.
35
- // 참고 문서와 자습서는 명령 전후 상태와 완결된 예제를 보이려 같은 블록을 다시 둔다. 파이썬의 REFERENCE 와 같다.
34
+ // 서사 글에서만 끄는 것. 설명글의 전제 (독자가 문장 사이 이유를 따라간다, 말끝을 맺는다)가 장면과
35
+ // 대사에는 성립하지 않는다. 파이썬 settings.py의 NARRATIVE와 같다.
36
+ // 참고 문서와 자습서는 명령 전후 상태와 완결된 예제를 보이려 같은 블록을 다시 둔다. 파이썬의 REFERENCE와 같다.
36
37
  const REFERENCE = ["duplicateBlock", "headingUniform"];
37
- // 백과 표제어는 첫 절 앞의 머리말이 길게 정의와 요약을 담는 것이 관례다. 파이썬의 ENCYCLOPEDIC 과 같다.
38
+ // 백과 표제어는 첫 절 앞의 머리말이 길게 정의와 요약을 담는 것이 관례다. 파이썬의 ENCYCLOPEDIC과 같다.
38
39
  const ENCYCLOPEDIC = ["introLong"];
39
40
  const NARRATIVE = ["factListParagraph"];
40
41
  PRESETS.guide = [...PRESETS.blog, ...REFERENCE];
41
42
  PRESETS.essay = [...PRESETS.report, ...NARRATIVE];
42
43
  PRESETS.fiction = [...PRESETS.report, ...NARRATIVE];
43
44
  PRESETS.encyclopedia = [...PRESETS.docs, ...ENCYCLOPEDIC];
44
- // 대화 답변에서 끄는 것. 글의 짜임을 재는 규칙은 글 한 편을 전제한다. 파이썬 settings.py 의 CONVERSATION 과 같다.
45
+ // 대화 답변에서 끄는 것. 글의 짜임을 재는 규칙은 글 한 편을 전제한다. 파이썬 settings.py의 CONVERSATION과 같다.
45
46
  const CONVERSATION = [
46
47
  "countMismatch",
47
48
  "duplicateBlock",
@@ -50,6 +51,7 @@ const CONVERSATION = [
50
51
  "headingSentence",
51
52
  "headingSkip",
52
53
  "headingUniform",
54
+ "imperativePeriod",
53
55
  "inputFileSource",
54
56
  "installImport",
55
57
  "introLong",
@@ -62,10 +64,11 @@ const CONVERSATION = [
62
64
  "sectionNoProse",
63
65
  ];
64
66
  PRESETS.chat = [...CONVERSATION, ...SCREEN];
65
- // 화면의 글에서 끄는 것. 문장과 문단과 글의 짜임을 재는 규칙은 화면의 글 한 마디에 전제가 없다. 파이썬 settings.py 의 SCREEN_OFF 와 같다.
67
+ // 화면의 글에서 끄는 것. 문장과 문단과 글의 짜임을 재는 규칙은 화면의 글 한 마디에 전제가 없다. 파이썬 settings.py의 SCREEN_OFF와 같다.
66
68
  const SCREEN_OFF = [
67
69
  "cliche",
68
70
  "connectorRepeat",
71
+ "phraseRepeat",
69
72
  "danglingDeixis",
70
73
  "deixis",
71
74
  "doubleNegative",
@@ -107,10 +110,10 @@ const SCREEN_OFF = [
107
110
  "platformApi",
108
111
  ];
109
112
  PRESETS.screen = SCREEN_OFF;
110
- /** enforceStyle 에 넣을 수 있는 규칙. 파이썬 settings.py 의 ENFORCEABLE 과 같다. */
111
- export const ENFORCEABLE = ["noQuestion", "nounPile", "screenNarration", "screenTone", "screenWord"];
113
+ /** enforceStyle에 넣을 수 있는 규칙. 파이썬 settings.py의 ENFORCEABLE과 같다. */
114
+ export const ENFORCEABLE = ["imperativePeriod", "noQuestion", "nounPile", "screenNarration", "screenTone", "screenWord"];
112
115
 
113
- /** 프리셋 → 견줄 프로파일의 종류. 정본은 파이썬 config/settings.py 의 PROFILE_OF 다. @type {Record<string, string>} */
116
+ /** 프리셋 → 견줄 프로파일의 종류. 정본은 파이썬 config/settings.py의 PROFILE_OF다. @type {Record<string, string>} */
114
117
  export const PROFILE_OF = {
115
118
  blog: "blog",
116
119
  report: "report",
@@ -123,35 +126,43 @@ export const PROFILE_OF = {
123
126
  screen: null,
124
127
  };
125
128
 
129
+ /** 프리셋 → 용례 빈도표의 종류. 정본은 파이썬 config/settings.py의 USAGE_OF다. @type {Record<string, string>} */
130
+ export const USAGE_OF = { report: "report" };
131
+ /** 실린 빈도표의 종류. */
132
+ export const USAGE_KINDS = [...new Set(Object.values(USAGE_OF))].sort();
133
+
126
134
  export const PRESET_NAMES = Object.keys(PRESETS);
127
135
  /** 설정도 옵션도 없을 때의 종류. 이 이름일 때는 출력에 프리셋을 적지 않는다. */
128
136
  export const DEFAULT_PRESET = PRESET_NAMES[0];
129
137
 
130
138
  /**
131
139
  * @typedef {object} Config
132
- * @property {string} preset 글의 종류. PRESETS 가 정한 규칙을 처음부터 끈다
140
+ * @property {string} preset 글의 종류. PRESETS가 정한 규칙을 처음부터 끈다
133
141
  * @property {string[]} enforceStyle
134
142
  * @property {Set<string>} disable 끌 규칙 이름
135
143
  * @property {string | null} keywordField
136
144
  * @property {string[]} introFields
137
145
  * @property {string[]} endingFields
138
146
  * @property {string | null} profile 사용자 프로파일 파일 경로
139
- * @property {number} profilePercentile outsideProfile 이 짚는 백분위
147
+ * @property {number} profilePercentile outsideProfile이 짚는 백분위
140
148
  * @property {number} countMismatchSpan 절이 없는 글이 이 줄 수 안일 때만 글 전체를 견준다
141
149
  * @property {string | null} baseline
142
150
  * @property {Record<string, unknown[]>} dictionary
143
- * @property {import("../data/exemplars.js").Exemplar[]} exemplars 사람이 승인한 프로젝트 본보기
151
+ * @property {import("../data/exemplars.js").Exemplar[]} exemplars 사람이 승인한 프로젝트 예시
144
152
  * @property {import("../data/patches.js").Patch[]} patches 사람이 승인한 국소 고침
145
153
  * @property {import("../data/operations.js").SurfaceOperation[]} operations 사람이 승인한 표면 치환
146
154
  * @property {string[]} protectedTerms 표면 치환이 바꾸면 안 되는 한국어 고유명사와 프로젝트 용어
147
155
  * @property {string[]} ignoreFences 지문에서 뺄 펜스의 언어 표기. 장면 계약과 도표 원문은 코드 블록이 아니다
148
- * @property {string | null} source 설정을 읽은 파일. 기본값이면 null. loadConfig 가 채운다
156
+ * @property {string | null} source 설정을 읽은 파일. 기본값이면 null. loadConfig가 채운다
149
157
  * @property {number} fragmentRun
150
158
  * @property {number} introMaxParagraphs
151
159
  * @property {number} headingUniformRatio
152
160
  * @property {number} headingSentenceMaxLevel
153
161
  * @property {number} bridgeRepeatMin
154
162
  * @property {number} nounPileMin
163
+ * @property {string | null} usageKind 용례 빈도표의 종류. null이면 프리셋이 정하고 ""면 보지 않는다
164
+ * @property {number} usageMin 명사 연쇄가 몇 편의 문서에 나와야 용례로 보는가
165
+ * @property {number} usageShare 사전 항목이 그 종류의 문서 몇 할에 나오면 관용으로 보는가
155
166
  * @property {number} endingRun
156
167
  * @property {number} factListMinSentences
157
168
  * @property {number} factListMaxMeanLength
@@ -191,6 +202,9 @@ export function defaultConfig() {
191
202
  headingSentenceMaxLevel: 6,
192
203
  bridgeRepeatMin: 3,
193
204
  nounPileMin: 5,
205
+ usageKind: null,
206
+ usageMin: 3,
207
+ usageShare: 0.9,
194
208
  endingRun: 4,
195
209
  factListMinSentences: 3,
196
210
  factListMaxMeanLength: 8.0,
@@ -211,7 +225,7 @@ export function enabled(config, ruleName) {
211
225
  return !config.disable.has(ruleName) && (!PRESETS[config.preset].includes(ruleName) || config.enforceStyle.includes(ruleName));
212
226
  }
213
227
 
214
- /** 지금 꺼져 있는 규칙 이름. 프리셋이 끈 것과 disable 이 끈 것을 합친다. @param {Config} config */
228
+ /** 지금 꺼져 있는 규칙 이름. 프리셋이 끈 것과 disable이 끈 것을 합친다. @param {Config} config */
215
229
  export function offRules(config) {
216
230
  return [...new Set([...PRESETS[config.preset].filter((name) => !config.enforceStyle.includes(name)), ...config.disable])].sort();
217
231
  }
@@ -223,18 +237,23 @@ export function configFromMapping(data) {
223
237
  if (key === "disable") {
224
238
  config.disable = new Set(/** @type {string[]} */ (value));
225
239
  } else if (key === "analyzer") {
226
- // 0.0.7 까지의 키. hanlint init 이 surface 를 써 넣었으므로 그 값은 조용히 넘기고 다른 값은 빠졌다고 알린다.
227
- if (value !== "surface") throw new Error(`analyzer 설정은 빠졌다. 분석기는 표층 하나라 키를 지운다: ${JSON.stringify(value)}`);
240
+ // 0.0.7 까지의 키. hanlint init이 surface를 써 넣었으므로 그 값은 조용히 넘기고 다른 값은 빠졌다고 알린다.
241
+ if (value !== "surface") throw new Error(`analyzer 설정은 이제 쓰지 않는다. 설정 파일에서 이 키를 지운다. 받은 값: ${JSON.stringify(value)}`);
228
242
  } else if (key === "preset") {
229
243
  if (!(/** @type {string} */ (value) in PRESETS)) {
230
- throw new Error(`preset 은 ${PRESET_NAMES.join(", ")} 가운데 하나다: ${JSON.stringify(value)}`);
244
+ throw new Error(`preset은 ${PRESET_NAMES.join(", ")} 가운데 하나여야 한다. 받은 값: ${JSON.stringify(value)}`);
231
245
  }
232
246
  config.preset = /** @type {string} */ (value);
233
247
  } else if (key === "enforceStyle") {
234
248
  if (!Array.isArray(value) || value.some((name) => !ENFORCEABLE.includes(name))) {
235
- throw new Error(`enforceStyle 은 ${ENFORCEABLE.join(", ")}의 배열이다`);
249
+ throw new Error(`enforceStyle은 다음 규칙 이름의 배열이어야 한다: ${ENFORCEABLE.join(", ")}`);
236
250
  }
237
251
  config.enforceStyle = [...value];
252
+ } else if (key === "usageKind") {
253
+ if (value !== null && value !== "" && !USAGE_KINDS.includes(/** @type {string} */ (value))) {
254
+ throw new Error(`usageKind는 ${USAGE_KINDS.join(", ")} 가운데 하나이거나 빈 문자열이어야 한다. 받은 값: ${JSON.stringify(value)}`);
255
+ }
256
+ config.usageKind = /** @type {string | null} */ (value);
238
257
  } else if (key === "dictionary") {
239
258
  config.dictionary = { .../** @type {Record<string, unknown[]>} */ (value) };
240
259
  } else if (key === "exemplars") {
@@ -245,14 +264,14 @@ export function configFromMapping(data) {
245
264
  config.operations = projectOperations(value, PRESET_NAMES);
246
265
  } else if (key === "ignoreFences" || key === "introFields" || key === "endingFields" || key === "protectedTerms") {
247
266
  if (!Array.isArray(value) || !value.every((item) => typeof item === "string" && item.trim())) {
248
- throw new Error(`${key} 는 문자열 배열이다: ${JSON.stringify(value)}`);
267
+ throw new Error(`${key}: 문자열 배열이어야 한다. 받은 값: ${JSON.stringify(value)}`);
249
268
  }
250
269
  const names = value.map((item) => item.trim());
251
270
  config[key] = key === "ignoreFences" ? names.map((name) => name.toLowerCase()) : names;
252
271
  } else if (key !== "source" && key in config) {
253
272
  config[key] = value;
254
273
  } else {
255
- throw new Error(`모르는 설정 키: ${key}. hanlint init 이 만드는 파일의 키만 쓴다`);
274
+ throw new Error(`모르는 설정 키: ${key}. hanlint init이 만드는 파일의 키만 쓴다`);
256
275
  }
257
276
  }
258
277
  return config;
@@ -1,8 +1,8 @@
1
1
  // @ts-check
2
2
  /**
3
- * hanlint 설정에 쓰이는 TOML 부분집합을 읽는다. 의존성 0 을 지키려고 직접 쓴다.
3
+ * hanlint 설정에 쓰이는 TOML 부분집합을 읽는다. 의존성 0을 지키려고 직접 쓴다.
4
4
  *
5
- * 되는 것: 주석, `key = 값`, 점 키, `[table]`, `[[array]]`, 문자열 ("..." 와 '...'), 정수, 실수, 불리언,
5
+ * 되는 것: 주석, `key = 값`, 점 키, `[table]`, `[[array]]`, 문자열 ("..."와 '...'), 정수, 실수, 불리언,
6
6
  * 여러 줄 배열, 인라인 테이블. 안 되는 것: 여러 줄 문자열, 날짜. 만나면 어디서 막혔는지 말하고 던진다.
7
7
  */
8
8
 
@@ -28,7 +28,7 @@ class Parser {
28
28
  /** @param {string} message */
29
29
  error(message) {
30
30
  const line = this.text.slice(0, this.pos).split("\n").length;
31
- return new Error(`TOML ${line}번째 줄: ${message}. hanlint 가 읽는 TOML 부분집합이 아니다`);
31
+ return new Error(`TOML ${line}번째 줄: ${message}. hanlint가 읽는 TOML 부분집합이 아니다`);
32
32
  }
33
33
 
34
34
  document() {
@@ -102,7 +102,7 @@ class Parser {
102
102
  const next = node[part];
103
103
  if (Array.isArray(next)) node = /** @type {Record<string, unknown>} */ (next[next.length - 1]);
104
104
  else if (typeof next === "object" && next !== null) node = /** @type {Record<string, unknown>} */ (next);
105
- else throw this.error(`${part} 는 테이블이 아니다`);
105
+ else throw this.error(`테이블이 아니다: ${part}`);
106
106
  }
107
107
  return node;
108
108
  }
@@ -110,7 +110,7 @@ class Parser {
110
110
  table() {
111
111
  this.pos++;
112
112
  const parts = this.keyPath();
113
- if (this.text[this.pos] !== "]") throw this.error("] 가 없다");
113
+ if (this.text[this.pos] !== "]") throw this.error("]가 없다");
114
114
  this.pos++;
115
115
  this.current = this.descend(this.root, parts);
116
116
  }
@@ -118,13 +118,13 @@ class Parser {
118
118
  arrayTable() {
119
119
  this.pos += 2;
120
120
  const parts = this.keyPath();
121
- if (!this.text.startsWith("]]", this.pos)) throw this.error("]] 가 없다");
121
+ if (!this.text.startsWith("]]", this.pos)) throw this.error("]]가 없다");
122
122
  this.pos += 2;
123
123
  const parent = this.descend(this.root, parts.slice(0, -1));
124
124
  const name = parts[parts.length - 1];
125
125
  if (!(name in parent)) parent[name] = [];
126
126
  const list = parent[name];
127
- if (!Array.isArray(list)) throw this.error(`${name} 은 배열이 아니다`);
127
+ if (!Array.isArray(list)) throw this.error(`배열이 아니다: ${name}`);
128
128
  /** @type {Record<string, unknown>} */
129
129
  const entry = {};
130
130
  list.push(entry);
@@ -209,7 +209,7 @@ class Parser {
209
209
  items.push(this.value());
210
210
  this.skipBlank();
211
211
  if (this.text[this.pos] === ",") this.pos++;
212
- else if (this.text[this.pos] !== "]") throw this.error("배열에 , 나 ] 가 없다");
212
+ else if (this.text[this.pos] !== "]") throw this.error("배열에 쉼표나 ]가 없다");
213
213
  }
214
214
  }
215
215
 
@@ -233,7 +233,7 @@ class Parser {
233
233
  this.pos++;
234
234
  return table;
235
235
  }
236
- throw this.error("인라인 테이블에 , 나 } 가 없다");
236
+ throw this.error("인라인 테이블에 쉼표나 }가 없다");
237
237
  }
238
238
  }
239
239
  }