hanlint 0.0.7 → 0.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (146) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +23 -5
  3. package/data/adjectiveStems.txt +118 -0
  4. package/data/baselineAnchors.txt +3 -0
  5. package/data/blindEvaluation.schema.json +26 -0
  6. package/data/bridgeOpeners.txt +10 -0
  7. package/data/candidateActionNouns.txt +39 -0
  8. package/data/candidateSplitEndings.txt +13 -0
  9. package/data/cliches.json +1 -1
  10. package/data/confusable.json +2 -2
  11. package/data/deixis.txt +10 -3
  12. package/data/draftHistory.txt +10 -0
  13. package/data/easyWords.json +0 -42
  14. package/data/enoughMarkers.txt +6 -0
  15. package/data/entailmentPredictions.schema.json +52 -0
  16. package/data/euiNouns.txt +3 -4
  17. package/data/evidenceEntailmentBenchmark.schema.json +126 -0
  18. package/data/exemplars.json +347 -0
  19. package/data/holeKinds.json +1 -2
  20. package/data/iVerbStems.txt +26 -0
  21. package/data/inNouns.txt +147 -0
  22. package/data/irregularStems.txt +64 -0
  23. package/data/nonNouns.txt +153 -0
  24. package/data/panelAssignmentReview.schema.json +117 -0
  25. package/data/panelJudgePredictions.schema.json +78 -0
  26. package/data/panelReviewBatch.schema.json +79 -0
  27. package/data/panelTrialSet.schema.json +44 -0
  28. package/data/patterns.json +114 -0
  29. package/data/profiles.json +1 -0
  30. package/data/readerCalls.txt +12 -7
  31. package/data/redundantPair.json +1 -1
  32. package/data/rieulStems.txt +30 -0
  33. package/data/ruleCategories.json +56 -0
  34. package/data/ruleDocs.json +29 -17
  35. package/data/ruleMechanisms.json +56 -0
  36. package/data/spacing.json +0 -12
  37. package/data/spelling.json +6 -24
  38. package/data/translationese.json +10 -0
  39. package/data/verbTails.txt +17 -0
  40. package/data/version.json +1 -1
  41. package/data/writingBrief.schema.json +64 -0
  42. package/data/writingBriefV2.schema.json +116 -0
  43. package/data/writingTrial.schema.json +36 -0
  44. package/package.json +13 -5
  45. package/src/analysis/grammar/ending.js +336 -0
  46. package/src/analysis/grammar/hangul.js +94 -0
  47. package/src/analysis/grammar/index.js +5 -0
  48. package/src/analysis/grammar/josa.js +73 -0
  49. package/src/analysis/grammar/register.js +115 -0
  50. package/src/analysis/grammar/voice.js +43 -0
  51. package/src/analysis/index.js +4 -31
  52. package/src/analysis/josa.js +2 -0
  53. package/src/analysis/{surface/splitSentences.js → splitSentences.js} +1 -1
  54. package/src/analysis/{surface/tokenize.js → tokenize.js} +70 -13
  55. package/src/baseline/store.js +114 -0
  56. package/src/cli/main.js +506 -58
  57. package/src/cli/walk.js +30 -0
  58. package/src/cli/welcome.js +52 -0
  59. package/src/config/settings.js +103 -8
  60. package/src/data/exemplars.js +177 -0
  61. package/src/data/load.js +10 -0
  62. package/src/data/operations.js +278 -0
  63. package/src/data/patches.js +125 -0
  64. package/src/data/patterns.js +48 -0
  65. package/src/data/profiles.js +102 -0
  66. package/src/document/model.js +2 -0
  67. package/src/document/parseMarkdown.js +84 -11
  68. package/src/edit/applyFixes.js +0 -0
  69. package/src/fingerprint/build.js +50 -32
  70. package/src/{rules/shared → fingerprint}/codeBlocks.js +11 -11
  71. package/src/fingerprint/codeMarkers.js +73 -0
  72. package/src/fingerprint/markers.js +9 -0
  73. package/src/fingerprint/readerState.js +126 -0
  74. package/src/fingerprint/sourceSentence.js +37 -0
  75. package/src/fingerprint/topics.js +1 -1
  76. package/src/index.js +2 -16
  77. package/src/report/compactReport.js +6 -1
  78. package/src/report/fingerprintJson.js +8 -3
  79. package/src/report/jsonReport.js +50 -3
  80. package/src/report/operationMatch.js +31 -0
  81. package/src/report/patchMatch.js +44 -0
  82. package/src/report/registerMatch.js +40 -0
  83. package/src/report/textReport.js +41 -5
  84. package/src/rules/code/duplicateBlock.js +4 -3
  85. package/src/rules/code/firstResultDistance.js +1 -0
  86. package/src/rules/code/inputFileSource.js +23 -68
  87. package/src/rules/code/installImport.js +4 -4
  88. package/src/rules/code/platformApi.js +7 -6
  89. package/src/rules/document/countMismatch.js +22 -6
  90. package/src/rules/document/enoughOnce.js +25 -0
  91. package/src/rules/document/fieldEcho.js +38 -0
  92. package/src/rules/document/keywordHeading.js +27 -0
  93. package/src/rules/document/keywordMissing.js +1 -0
  94. package/src/rules/document/noQuestion.js +5 -1
  95. package/src/rules/document/promiseRecall.js +4 -2
  96. package/src/rules/document/tableOddCell.js +60 -0
  97. package/src/rules/finding.js +24 -2
  98. package/src/rules/orthography/confusable.js +1 -0
  99. package/src/rules/orthography/spacing.js +1 -0
  100. package/src/rules/orthography/spelling.js +1 -0
  101. package/src/rules/paragraph/factListParagraph.js +1 -0
  102. package/src/rules/paragraph/paraFragment.js +21 -11
  103. package/src/rules/paragraph/topicBreak.js +1 -0
  104. package/src/rules/registry.js +72 -3
  105. package/src/rules/sentence/cliche.js +1 -0
  106. package/src/rules/sentence/connectorRepeat.js +8 -7
  107. package/src/rules/sentence/danglingDeixis.js +8 -3
  108. package/src/rules/sentence/dash.js +2 -1
  109. package/src/rules/sentence/deixis.js +7 -3
  110. package/src/rules/sentence/doubleNegative.js +4 -1
  111. package/src/rules/sentence/doublePassive.js +38 -1
  112. package/src/rules/sentence/draftHistory.js +24 -0
  113. package/src/rules/sentence/ellipsis.js +17 -3
  114. package/src/rules/sentence/endingRepeat.js +6 -2
  115. package/src/rules/sentence/euiChain.js +3 -2
  116. package/src/rules/sentence/fillerOpener.js +1 -0
  117. package/src/rules/sentence/hardWord.js +1 -0
  118. package/src/rules/sentence/imperativePeriod.js +2 -1
  119. package/src/rules/sentence/japaneseLoan.js +1 -0
  120. package/src/rules/sentence/longSentence.js +3 -1
  121. package/src/rules/sentence/negationRedefine.js +10 -1
  122. package/src/rules/sentence/nounPile.js +1 -0
  123. package/src/rules/sentence/numberOrphan.js +45 -0
  124. package/src/rules/sentence/outsideProfile.js +68 -0
  125. package/src/rules/sentence/redundantPair.js +1 -0
  126. package/src/rules/sentence/translationese.js +1 -0
  127. package/src/rules/shared/candidates.js +129 -0
  128. package/src/rules/shared/dictionaryRule.js +12 -2
  129. package/src/rules/shared/localAntecedent.js +12 -0
  130. package/src/rules/shared/repeat.js +48 -0
  131. package/src/rules/structure/blockUnread.js +33 -0
  132. package/src/rules/structure/bridgeRepeat.js +40 -0
  133. package/src/rules/structure/emojiBullet.js +1 -0
  134. package/src/rules/structure/headingQuestion.js +19 -0
  135. package/src/rules/structure/headingSentence.js +9 -4
  136. package/src/rules/structure/headingSkip.js +1 -0
  137. package/src/rules/structure/headingUniform.js +9 -17
  138. package/src/rules/structure/introImage.js +19 -0
  139. package/src/rules/structure/introLong.js +1 -0
  140. package/src/rules/structure/loneSubheading.js +42 -0
  141. package/src/rules/structure/moreLater.js +48 -0
  142. package/src/rules/structure/sectionNoProse.js +1 -0
  143. package/src/rules/structure/sectionResult.js +3 -2
  144. package/src/text.js +19 -0
  145. package/src/rules/document/readerAbsent.js +0 -11
  146. package/src/rules/shared/runs.js +0 -22
@@ -54,3 +54,20 @@
54
54
  된
55
55
  될
56
56
  돼
57
+ # 실측 2026-08-29: 말뭉치에서 `있던`, `힘껏`, `유난히`, `되었는지`, `올바른` 이 명사로 세어졌다. 과거 관형형, 부사 파생,
58
+ # 의문형 연결, 관형형 꼬리다. `교수이자` 의 이자와 `알고자` 의 고자와 `은지` 는 대출이자, 신고자, 김은지 같은 명사와
59
+ # 표층으로 안 갈려 넣지 않는다 (검증에서 걸렸다).
60
+ 던
61
+ 껏
62
+ 히
63
+ 는지
64
+ 른
65
+ # 실측 2026-08-29: `새로운 CPU 매니저 스태틱 정책 옵션` 의 새로운 이 명사로 세어졌다. ㅂ 불규칙 형용사의 관형형 꼬리다.
66
+ # 다운 은 카운트다운 과 갈리지 않아 넣지 않는다.
67
+ 로운
68
+ 라운
69
+ 려운
70
+ 거운
71
+ 벼운
72
+ 러운
73
+ 쉬운
package/data/version.json CHANGED
@@ -1 +1 @@
1
- {"version": "0.0.7"}
1
+ {"version": "0.0.8"}
@@ -0,0 +1,64 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://hanlint.dev/schemas/writingBrief.schema.json",
4
+ "title": "hanlint writing brief",
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "required": [
8
+ "version",
9
+ "preset",
10
+ "reader",
11
+ "task",
12
+ "facts",
13
+ "mustInclude",
14
+ "allowedNumbers",
15
+ "forbidden",
16
+ "length"
17
+ ],
18
+ "properties": {
19
+ "version": { "const": 1 },
20
+ "preset": {
21
+ "enum": ["blog", "report", "docs", "guide", "essay", "fiction", "encyclopedia"]
22
+ },
23
+ "reader": { "type": "string", "minLength": 1 },
24
+ "task": { "type": "string", "minLength": 1 },
25
+ "facts": {
26
+ "type": "array",
27
+ "minItems": 1,
28
+ "items": {
29
+ "type": "object",
30
+ "additionalProperties": false,
31
+ "required": ["id", "statement"],
32
+ "properties": {
33
+ "id": { "type": "string", "pattern": "^F[1-9][0-9]*$" },
34
+ "statement": { "type": "string", "minLength": 1 }
35
+ }
36
+ }
37
+ },
38
+ "mustInclude": {
39
+ "type": "array",
40
+ "minItems": 1,
41
+ "uniqueItems": true,
42
+ "items": { "type": "string", "minLength": 1 }
43
+ },
44
+ "allowedNumbers": {
45
+ "type": "array",
46
+ "uniqueItems": true,
47
+ "items": { "type": "string", "pattern": "^[0-9]+(?:[.][0-9]+)*$" }
48
+ },
49
+ "forbidden": {
50
+ "type": "array",
51
+ "uniqueItems": true,
52
+ "items": { "type": "string", "minLength": 1 }
53
+ },
54
+ "length": {
55
+ "type": "object",
56
+ "additionalProperties": false,
57
+ "required": ["min", "max"],
58
+ "properties": {
59
+ "min": { "type": "integer", "minimum": 1 },
60
+ "max": { "type": "integer", "minimum": 1 }
61
+ }
62
+ }
63
+ }
64
+ }
@@ -0,0 +1,116 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://hanlint.dev/schemas/writingBriefV2.schema.json",
4
+ "title": "hanlint evidence writing brief",
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "required": [
8
+ "version",
9
+ "preset",
10
+ "reader",
11
+ "task",
12
+ "facts",
13
+ "mustInclude",
14
+ "allowedNumbers",
15
+ "forbidden",
16
+ "length",
17
+ "evidence"
18
+ ],
19
+ "properties": {
20
+ "version": { "const": 2 },
21
+ "preset": {
22
+ "enum": ["blog", "report", "docs", "guide", "essay", "fiction", "encyclopedia"]
23
+ },
24
+ "reader": { "type": "string", "minLength": 1 },
25
+ "task": { "type": "string", "minLength": 1 },
26
+ "facts": {
27
+ "type": "array",
28
+ "minItems": 1,
29
+ "items": {
30
+ "type": "object",
31
+ "additionalProperties": false,
32
+ "required": ["id", "statement"],
33
+ "properties": {
34
+ "id": { "type": "string", "pattern": "^F[1-9][0-9]*$" },
35
+ "statement": { "type": "string", "minLength": 1 }
36
+ }
37
+ }
38
+ },
39
+ "mustInclude": {
40
+ "type": "array",
41
+ "minItems": 1,
42
+ "uniqueItems": true,
43
+ "items": { "type": "string", "minLength": 1 }
44
+ },
45
+ "allowedNumbers": {
46
+ "type": "array",
47
+ "uniqueItems": true,
48
+ "items": { "type": "string", "pattern": "^[0-9]+(?:[.][0-9]+)*$" }
49
+ },
50
+ "forbidden": {
51
+ "type": "array",
52
+ "uniqueItems": true,
53
+ "items": { "type": "string", "minLength": 1 }
54
+ },
55
+ "length": {
56
+ "type": "object",
57
+ "additionalProperties": false,
58
+ "required": ["min", "max"],
59
+ "properties": {
60
+ "min": { "type": "integer", "minimum": 1 },
61
+ "max": { "type": "integer", "minimum": 1 }
62
+ }
63
+ },
64
+ "evidence": {
65
+ "type": "array",
66
+ "minItems": 1,
67
+ "items": {
68
+ "type": "object",
69
+ "additionalProperties": false,
70
+ "required": [
71
+ "id",
72
+ "factIds",
73
+ "sourceUrl",
74
+ "revision",
75
+ "checkedAt",
76
+ "locator",
77
+ "excerpt",
78
+ "excerptSha256",
79
+ "license",
80
+ "reviewStatus"
81
+ ],
82
+ "properties": {
83
+ "id": { "type": "string", "pattern": "^E[1-9][0-9]*$" },
84
+ "factIds": {
85
+ "type": "array",
86
+ "minItems": 1,
87
+ "uniqueItems": true,
88
+ "items": { "type": "string", "pattern": "^F[1-9][0-9]*$" }
89
+ },
90
+ "sourceUrl": { "type": "string", "format": "uri", "pattern": "^https?://" },
91
+ "revision": {
92
+ "type": ["string", "null"],
93
+ "minLength": 1,
94
+ "not": {
95
+ "pattern": "^(?:[Hh][Ee][Aa][Dd]|[Ll][Aa][Tt][Ee][Ss][Tt]|[Mm][Aa][Ii][Nn]|[Mm][Aa][Ss][Tt][Ee][Rr])$"
96
+ }
97
+ },
98
+ "checkedAt": {
99
+ "type": ["string", "null"],
100
+ "format": "date-time",
101
+ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$"
102
+ },
103
+ "locator": { "type": "string", "minLength": 1 },
104
+ "excerpt": { "type": "string", "minLength": 1, "maxLength": 1000 },
105
+ "excerptSha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" },
106
+ "license": { "type": "string", "minLength": 1 },
107
+ "reviewStatus": { "enum": ["unreviewed", "humanVerified"] }
108
+ },
109
+ "anyOf": [
110
+ { "properties": { "revision": { "type": "string", "minLength": 1 } } },
111
+ { "properties": { "checkedAt": { "type": "string" } } }
112
+ ]
113
+ }
114
+ }
115
+ }
116
+ }
@@ -0,0 +1,36 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://hanlint.dev/schemas/writingTrial.schema.json",
4
+ "title": "hanlint writing trial",
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "required": ["version", "id", "brief", "baseline", "candidate"],
8
+ "properties": {
9
+ "version": { "const": 1 },
10
+ "id": { "type": "string", "minLength": 1 },
11
+ "brief": {
12
+ "oneOf": [
13
+ { "$ref": "writingBrief.schema.json" },
14
+ { "$ref": "writingBriefV2.schema.json" }
15
+ ]
16
+ },
17
+ "baseline": { "$ref": "#/$defs/generation" },
18
+ "candidate": { "$ref": "#/$defs/generation" }
19
+ },
20
+ "$defs": {
21
+ "sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" },
22
+ "generation": {
23
+ "type": "object",
24
+ "additionalProperties": false,
25
+ "required": ["strategyId", "modelId", "modelSha256", "promptSha256", "outputSha256", "text"],
26
+ "properties": {
27
+ "strategyId": { "type": "string", "minLength": 1 },
28
+ "modelId": { "type": "string", "minLength": 1 },
29
+ "modelSha256": { "$ref": "#/$defs/sha256" },
30
+ "promptSha256": { "$ref": "#/$defs/sha256" },
31
+ "outputSha256": { "$ref": "#/$defs/sha256" },
32
+ "text": { "type": "string", "minLength": 1 }
33
+ }
34
+ }
35
+ }
36
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "hanlint",
3
- "version": "0.0.7",
4
- "description": "한국어 글에서 AI 와 사람이 반복해서 어기는 결함을 결정적으로 잡는 린터. 의존성 0",
3
+ "version": "0.0.8",
4
+ "description": "한국어 글 검사기. Korean prose linter for Markdown: translationese, noun piles, double passives, weak structure. Zero dependencies",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "hanlint": "bin/hanlint.js"
@@ -14,20 +14,28 @@
14
14
  "bin",
15
15
  "src",
16
16
  "data",
17
+ "LICENSE",
17
18
  "README.md"
18
19
  ],
19
20
  "engines": {
20
21
  "node": ">=18"
21
22
  },
22
23
  "scripts": {
23
- "test": "node --test test/"
24
+ "test": "node --test \"test/*.test.js\""
24
25
  },
25
26
  "keywords": [
26
27
  "korean",
27
- "lint",
28
+ "korean-language",
29
+ "hangul",
30
+ "linter",
31
+ "prose",
32
+ "prose-linter",
28
33
  "writing",
29
34
  "readability",
30
- "markdown"
35
+ "markdown",
36
+ "style-checker",
37
+ "proofreading",
38
+ "text-linter"
31
39
  ],
32
40
  "license": "MIT",
33
41
  "repository": {
@@ -0,0 +1,336 @@
1
+ // @ts-check
2
+ /** 파이썬 `analysis/grammar/ending.py`와 `predicate.py`의 투영. 종결 서술어를 풀고 세 문체로 짠다. */
3
+ import { loadLines } from "../../data/load.js";
4
+ import * as hangul from "./hangul.js";
5
+
6
+ export const VERB = "동사";
7
+ export const ADJECTIVE = "형용사";
8
+ export const COPULA = "계사";
9
+ export const PRESENT = "현재";
10
+ export const PAST = "과거";
11
+ export const FUTURE = "미래";
12
+ export const DECLARATIVE = "평서";
13
+ export const QUESTION = "의문";
14
+ export const KKA_QUESTION = "까의문";
15
+ export const IMPERATIVE = "명령";
16
+ export const PROPOSITIVE = "청유";
17
+ export const HAPNIDA = "합니다";
18
+ export const HANDA = "한다";
19
+ export const HAEYO = "해요";
20
+ export const REGISTERS = [HAPNIDA, HANDA, HAEYO];
21
+
22
+ const HAE_IRREGULAR = new Map([
23
+ ["그렇", "그래"],
24
+ ["이렇", "이래"],
25
+ ["저렇", "저래"],
26
+ ["어떻", "어때"],
27
+ ]);
28
+ const AUX_LINKS = ["지는", "지도", "지만", "지"];
29
+
30
+ let adjectiveCache = null;
31
+ let rieulCache = null;
32
+ let iVerbCache = null;
33
+ let irregularCache = null;
34
+
35
+ function adjectiveStems() {
36
+ if (!adjectiveCache) adjectiveCache = new Set(loadLines("adjectiveStems.txt"));
37
+ return adjectiveCache;
38
+ }
39
+
40
+ function rieulStems() {
41
+ if (!rieulCache) rieulCache = new Set(loadLines("rieulStems.txt"));
42
+ return rieulCache;
43
+ }
44
+
45
+ function iVerbStems() {
46
+ if (!iVerbCache) iVerbCache = new Set(loadLines("iVerbStems.txt"));
47
+ return iVerbCache;
48
+ }
49
+
50
+ function irregularStems() {
51
+ if (!irregularCache) {
52
+ irregularCache = loadLines("irregularStems.txt")
53
+ .map((line) => line.split("\t"))
54
+ .sort((a, b) => b[0].length - a[0].length);
55
+ }
56
+ return irregularCache;
57
+ }
58
+
59
+ /** @param {string} stem */
60
+ export function isAdjective(stem) {
61
+ return adjectiveStems().has(stem);
62
+ }
63
+
64
+ /** @param {string} stem */
65
+ function irregularClass(stem) {
66
+ for (const [known, kind] of irregularStems()) {
67
+ if (stem === known || stem.endsWith(known)) return kind;
68
+ }
69
+ return null;
70
+ }
71
+
72
+ /** @param {string} base */
73
+ function restoreRieul(base) {
74
+ const last = hangul.lastSyllable(base);
75
+ if (last === null || hangul.finalOf(last) !== hangul.NONE) return base;
76
+ const candidate = base.slice(0, -1) + hangul.withFinal(last, hangul.RIEUL);
77
+ return rieulStems().has(candidate) ? candidate : base;
78
+ }
79
+
80
+ /** @param {string} base */
81
+ function stripEu(base) {
82
+ if (base.length >= 2 && base.endsWith("으") && hangul.isSyllable(base.at(-2)) && hangul.finalOf(base.at(-2)) !== hangul.NONE) {
83
+ return base.slice(0, -1);
84
+ }
85
+ return base;
86
+ }
87
+
88
+ /** @param {string} base */
89
+ function tenseOf(base) {
90
+ const last = hangul.lastSyllable(base);
91
+ if (base.endsWith("겠")) return FUTURE;
92
+ if (last !== null && last !== "있" && hangul.finalOf(last) === hangul.SSANGSIOT) return PAST;
93
+ return PRESENT;
94
+ }
95
+
96
+ /** @param {string} base @param {string | null} previous */
97
+ function kindOf(base, previous) {
98
+ if (base === "않" && previous) {
99
+ for (const link of AUX_LINKS) {
100
+ if (previous.endsWith(link) && previous.length > link.length) {
101
+ return isAdjective(previous.slice(0, -link.length)) ? ADJECTIVE : VERB;
102
+ }
103
+ }
104
+ }
105
+ return isAdjective(base) ? ADJECTIVE : VERB;
106
+ }
107
+
108
+ /**
109
+ * @typedef {object} Predicate
110
+ * @property {string} base
111
+ * @property {string} kind
112
+ * @property {string} tense
113
+ * @property {string} mood
114
+ * @property {boolean} explicitCopula
115
+ */
116
+
117
+ /** @param {string} base @param {string} kind @param {string} [tense] @param {string} [mood] @param {boolean} [explicitCopula] @returns {Predicate} */
118
+ function predicate(base, kind, tense = PRESENT, mood = DECLARATIVE, explicitCopula = false) {
119
+ return { base, kind, tense, mood, explicitCopula };
120
+ }
121
+
122
+ /** @param {string} base @param {string | null} previous @param {string} mood */
123
+ function classifyPredicate(base, previous, mood) {
124
+ if (!base) return null;
125
+ const tense = tenseOf(base);
126
+ const kind = tense !== PRESENT ? VERB : kindOf(base, previous);
127
+ return predicate(base, kind, tense, mood);
128
+ }
129
+
130
+ /** @param {string} word @param {number} offset @param {number} final */
131
+ function endsWithFinal(word, offset, final) {
132
+ if (word.length < offset) return false;
133
+ const ch = word.at(-offset);
134
+ return hangul.isSyllable(ch) && hangul.finalOf(ch) === final;
135
+ }
136
+
137
+ /** @param {string} word @param {number} offset */
138
+ function stemBefore(word, offset) {
139
+ const ch = word.at(-offset);
140
+ return word.slice(0, -offset) + hangul.withFinal(ch, hangul.NONE);
141
+ }
142
+
143
+ /** @param {string} noun @param {string} mood */
144
+ function copulaOrIVerb(noun, mood) {
145
+ const stem = noun + "이";
146
+ return iVerbStems().has(stem) ? predicate(stem, VERB, PRESENT, mood) : predicate(noun, COPULA, PRESENT, mood);
147
+ }
148
+
149
+ /** 문장 끝 한글 어절 하나를 푼다. @param {string} word @param {string | null} [previous] @returns {Predicate | null} */
150
+ export function parsePredicate(word, previous = null) {
151
+ if (!word || !hangul.isSyllable(word.at(-1))) return null;
152
+ if ((word === "다" || word === "입니다") && previous) return predicate("", COPULA);
153
+ if (word === "아닙니다" || word === "아니다") return predicate("아니", ADJECTIVE);
154
+ if (word.endsWith("입니다") && word.length > 3) return copulaOrIVerb(word.slice(0, -3), DECLARATIVE);
155
+ if (word.endsWith("입니까") && word.length > 3) return copulaOrIVerb(word.slice(0, -3), QUESTION);
156
+ if (word.endsWith("습니다")) return classifyPredicate(word.slice(0, -3), previous, DECLARATIVE);
157
+ if (word.endsWith("습니까")) return classifyPredicate(word.slice(0, -3), previous, QUESTION);
158
+ if (word.endsWith("니다") && endsWithFinal(word, 3, hangul.BIEUP)) {
159
+ return classifyPredicate(restoreRieul(stemBefore(word, 3)), previous, DECLARATIVE);
160
+ }
161
+ if (word.endsWith("니까") && endsWithFinal(word, 3, hangul.BIEUP)) {
162
+ return classifyPredicate(restoreRieul(stemBefore(word, 3)), previous, QUESTION);
163
+ }
164
+ if (word.endsWith("까요") && word.length > 2) return predicate(word.slice(0, -1), VERB, PRESENT, KKA_QUESTION);
165
+ if (word.endsWith("십시오") && word.length > 3) {
166
+ return predicate(restoreRieul(stripEu(word.slice(0, -3))), VERB, PRESENT, IMPERATIVE);
167
+ }
168
+ if (word.endsWith("세요") && word.length > 2) {
169
+ return predicate(restoreRieul(stripEu(word.slice(0, -2))), VERB, PRESENT, IMPERATIVE);
170
+ }
171
+ if (word.endsWith("읍시다")) return predicate(word.slice(0, -3), VERB, PRESENT, PROPOSITIVE);
172
+ if (word.endsWith("시다") && endsWithFinal(word, 3, hangul.BIEUP)) {
173
+ return predicate(restoreRieul(stemBefore(word, 3)), VERB, PRESENT, PROPOSITIVE);
174
+ }
175
+ if (word.endsWith("는다") && word.length > 2) return predicate(word.slice(0, -2), VERB);
176
+ if (word.endsWith("는가") && word.length > 2) return classifyPredicate(word.slice(0, -2), previous, QUESTION);
177
+ if (word.endsWith("자") && word.length > 1) {
178
+ const stem = restoreRieul(word.slice(0, -1));
179
+ if (stem.endsWith("하") || stem.endsWith("보") || rieulStems().has(stem)) {
180
+ return predicate(stem, VERB, PRESENT, PROPOSITIVE);
181
+ }
182
+ }
183
+ if (word.endsWith("다") && word.length > 1) {
184
+ const stem = word.slice(0, -1);
185
+ const last = stem.at(-1);
186
+ if (hangul.isSyllable(last) && hangul.finalOf(last) === hangul.NIEUN && !word.endsWith("이다")) {
187
+ return predicate(restoreRieul(stemBefore(stem, 1)), VERB);
188
+ }
189
+ const tense = tenseOf(stem);
190
+ if (tense !== PRESENT) return predicate(stem, VERB, tense);
191
+ if (word.endsWith("이다") && word.length > 2) return predicate(word.slice(0, -2), COPULA, PRESENT, DECLARATIVE, true);
192
+ if (stem === "않") return predicate(stem, kindOf(stem, previous));
193
+ if (isAdjective(stem)) return predicate(stem, ADJECTIVE);
194
+ return predicate(stem, COPULA);
195
+ }
196
+ return null;
197
+ }
198
+
199
+ /** 어간에 아/어를 붙인 꼴. @param {string} stem */
200
+ export function conjugate(stem) {
201
+ if (stem === "아니") return "아니에";
202
+ if (stem.endsWith("하")) return stem.slice(0, -1) + "해";
203
+ const last = hangul.lastSyllable(stem);
204
+ if (last === null) return stem + "어";
205
+ const [, vowel, final] = hangul.split(last);
206
+ const kind = irregularClass(stem);
207
+ if (kind === "르" && stem.length >= 2 && hangul.isSyllable(stem.at(-2))) {
208
+ const before = stem.at(-2);
209
+ const bright = hangul.BRIGHT.has(hangul.vowelOf(before));
210
+ return stem.slice(0, -2) + hangul.withFinal(before, hangul.RIEUL) + (bright ? "라" : "러");
211
+ }
212
+ if (kind === "ㅂ") return stem.slice(0, -1) + hangul.withFinal(last, hangul.NONE) + "워";
213
+ if (kind === "ㅂ와") return stem.slice(0, -1) + hangul.withFinal(last, hangul.NONE) + "와";
214
+ if (kind === "ㄷ") return stem.slice(0, -1) + hangul.withFinal(last, hangul.RIEUL) + (hangul.BRIGHT.has(vowel) ? "아" : "어");
215
+ if (kind === "ㅅ") return stem.slice(0, -1) + hangul.withFinal(last, hangul.NONE) + (hangul.BRIGHT.has(vowel) ? "아" : "어");
216
+ if (kind === "ㅎ") {
217
+ for (const [known, changed] of HAE_IRREGULAR) {
218
+ if (stem.endsWith(known)) return stem.slice(0, -known.length) + changed;
219
+ }
220
+ const opened = hangul.withFinal(last, hangul.NONE);
221
+ const target = new Map([
222
+ [hangul.A, hangul.AE],
223
+ [hangul.YA, hangul.YAE],
224
+ [hangul.EO, hangul.E],
225
+ [hangul.YEO, hangul.YE],
226
+ ]).get(vowel) ?? hangul.AE;
227
+ return stem.slice(0, -1) + hangul.withVowel(opened, target);
228
+ }
229
+ if (final !== hangul.NONE) return stem + (hangul.BRIGHT.has(vowel) ? "아" : "어");
230
+ if ([hangul.A, hangul.YA, hangul.EO, hangul.YEO, hangul.AE, hangul.E, hangul.YE].includes(vowel)) return stem;
231
+ if (vowel === hangul.OH) return stem.slice(0, -1) + hangul.withVowel(last, hangul.WA);
232
+ if (vowel === hangul.U) return stem.slice(0, -1) + hangul.withVowel(last, hangul.WO);
233
+ if (vowel === hangul.OE) return stem.slice(0, -1) + hangul.withVowel(last, hangul.WAE);
234
+ if (vowel === hangul.IH) return stem.slice(0, -1) + hangul.withVowel(last, hangul.YEO);
235
+ if (vowel === hangul.EU) {
236
+ const before = stem.length >= 2 && hangul.isSyllable(stem.at(-2)) ? stem.at(-2) : null;
237
+ const bright = before !== null && hangul.BRIGHT.has(hangul.vowelOf(before));
238
+ return stem.slice(0, -1) + hangul.withVowel(last, bright ? hangul.A : hangul.EO);
239
+ }
240
+ return stem + "어";
241
+ }
242
+
243
+ /** @param {string} stem */
244
+ function hasFinal(stem) {
245
+ const last = hangul.lastSyllable(stem);
246
+ return last !== null ? hangul.finalOf(last) : hangul.MIEUM;
247
+ }
248
+
249
+ /** @param {string} stem */
250
+ function polite(stem) {
251
+ const final = hasFinal(stem);
252
+ if (final === hangul.NONE || final === hangul.RIEUL) {
253
+ return stem.slice(0, -1) + hangul.withFinal(stem.at(-1), hangul.BIEUP) + "니다";
254
+ }
255
+ return stem + "습니다";
256
+ }
257
+
258
+ /** @param {string} stem */
259
+ function plainVerb(stem) {
260
+ const final = hasFinal(stem);
261
+ if (final === hangul.NONE || final === hangul.RIEUL) {
262
+ return stem.slice(0, -1) + hangul.withFinal(stem.at(-1), hangul.NIEUN) + "다";
263
+ }
264
+ return stem + "는다";
265
+ }
266
+
267
+ /** @param {string} stem @param {string} ending */
268
+ function withEu(stem, ending) {
269
+ const final = hasFinal(stem);
270
+ if (final === hangul.NONE) return stem + ending;
271
+ if (final === hangul.RIEUL) {
272
+ const base = ending === "세요" ? stem.slice(0, -1) + hangul.withFinal(stem.at(-1), hangul.NONE) : stem;
273
+ return base + ending;
274
+ }
275
+ const kind = irregularClass(stem);
276
+ const last = stem.at(-1);
277
+ if (kind === "ㅂ" || kind === "ㅂ와") return stem.slice(0, -1) + hangul.withFinal(last, hangul.NONE) + "우" + ending;
278
+ if (kind === "ㄷ") return stem.slice(0, -1) + hangul.withFinal(last, hangul.RIEUL) + "으" + ending;
279
+ if (kind === "ㅅ") return stem.slice(0, -1) + hangul.withFinal(last, hangul.NONE) + "으" + ending;
280
+ return stem + "으" + ending;
281
+ }
282
+
283
+ /** 서술어를 target 문체로 짠다. @param {Predicate} value @param {string} target */
284
+ export function render(value, target) {
285
+ const { base, kind, tense, mood } = value;
286
+ if (mood === KKA_QUESTION) return target === HANDA ? base : base + "요";
287
+ if (tense !== PRESENT) {
288
+ if (target === HAPNIDA) return base + (mood === QUESTION ? "습니까" : "습니다");
289
+ if (target === HANDA) return base + (mood === QUESTION ? "는가" : "다");
290
+ return base + "어요";
291
+ }
292
+ if (kind === COPULA) {
293
+ if (!base) return { [HAPNIDA]: "입니다", [HANDA]: "다", [HAEYO]: "예요" }[target];
294
+ const final = hasFinal(base);
295
+ if (target === HAPNIDA) return base + (mood === QUESTION ? "입니까" : "입니다");
296
+ if (target === HANDA) {
297
+ if (mood === QUESTION) return base + "인가";
298
+ return base + (value.explicitCopula || final !== hangul.NONE ? "이다" : "다");
299
+ }
300
+ return base + (final !== hangul.NONE ? "이에요" : "예요");
301
+ }
302
+ if (mood === IMPERATIVE) return target === HANDA ? withEu(base, "라") : withEu(base, "세요");
303
+ if (mood === PROPOSITIVE) {
304
+ if (target === HAPNIDA) {
305
+ const final = hasFinal(base);
306
+ if (final === hangul.NONE || final === hangul.RIEUL) {
307
+ return base.slice(0, -1) + hangul.withFinal(base.at(-1), hangul.BIEUP) + "시다";
308
+ }
309
+ return base + "읍시다";
310
+ }
311
+ if (target === HANDA) return base + "자";
312
+ return conjugate(base) + "요";
313
+ }
314
+ if (target === HAPNIDA) {
315
+ const form = polite(base);
316
+ return mood === QUESTION ? form.slice(0, -2) + "니까" : form;
317
+ }
318
+ if (target === HANDA) {
319
+ if (kind === ADJECTIVE) {
320
+ if (mood === QUESTION) {
321
+ const final = hasFinal(base);
322
+ if (final === hangul.NONE || final === hangul.RIEUL) {
323
+ return base.slice(0, -1) + hangul.withFinal(base.at(-1), hangul.NIEUN) + "가";
324
+ }
325
+ return base + "은가";
326
+ }
327
+ return base + "다";
328
+ }
329
+ if (mood === QUESTION) {
330
+ const stem = hasFinal(base) === hangul.RIEUL ? base.slice(0, -1) + hangul.withFinal(base.at(-1), hangul.NONE) : base;
331
+ return stem + "는가";
332
+ }
333
+ return plainVerb(base);
334
+ }
335
+ return conjugate(base) + "요";
336
+ }