hanlint 0.0.1

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 (106) hide show
  1. package/README.md +27 -0
  2. package/bin/hanlint.js +4 -0
  3. package/data/causalMarkers.txt +25 -0
  4. package/data/cliches.json +99 -0
  5. package/data/confusable.json +76 -0
  6. package/data/connectors.txt +18 -0
  7. package/data/countUnits.txt +6 -0
  8. package/data/coverageTypes.txt +7 -0
  9. package/data/deixis.txt +8 -0
  10. package/data/easyWords.json +340 -0
  11. package/data/emphasisWords.txt +11 -0
  12. package/data/endings.txt +9 -0
  13. package/data/euiNouns.txt +40 -0
  14. package/data/hedges.txt +15 -0
  15. package/data/hiddenDeps.txt +11 -0
  16. package/data/holeKinds.json +145 -0
  17. package/data/japaneseLoan.json +82 -0
  18. package/data/josa.txt +54 -0
  19. package/data/koreanNumbers.txt +22 -0
  20. package/data/passiveStems.txt +53 -0
  21. package/data/platformApis.txt +12 -0
  22. package/data/promiseMarkers.txt +7 -0
  23. package/data/pythonPackages.txt +35 -0
  24. package/data/pythonStdlib.txt +193 -0
  25. package/data/readerCalls.txt +14 -0
  26. package/data/recallMarkers.txt +6 -0
  27. package/data/redundantPair.json +70 -0
  28. package/data/ruleDocs.json +44 -0
  29. package/data/spacing.json +76 -0
  30. package/data/spelling.json +166 -0
  31. package/data/stopwords.txt +33 -0
  32. package/data/translationese.json +104 -0
  33. package/data/verbTails.txt +56 -0
  34. package/data/version.json +1 -0
  35. package/package.json +39 -0
  36. package/src/analysis/index.js +34 -0
  37. package/src/analysis/surface/splitSentences.js +36 -0
  38. package/src/analysis/surface/tokenize.js +162 -0
  39. package/src/cli/main.js +376 -0
  40. package/src/config/loadConfig.js +51 -0
  41. package/src/config/settings.js +80 -0
  42. package/src/config/toml.js +239 -0
  43. package/src/data/load.js +63 -0
  44. package/src/document/model.js +55 -0
  45. package/src/document/parseMarkdown.js +183 -0
  46. package/src/document/plainText.js +43 -0
  47. package/src/edit/applyFixes.js +0 -0
  48. package/src/fingerprint/build.js +284 -0
  49. package/src/fingerprint/dictionaries.js +118 -0
  50. package/src/fingerprint/markers.js +175 -0
  51. package/src/fingerprint/topics.js +42 -0
  52. package/src/index.js +68 -0
  53. package/src/regex.js +47 -0
  54. package/src/report/compactReport.js +9 -0
  55. package/src/report/fingerprintJson.js +126 -0
  56. package/src/report/githubReport.js +13 -0
  57. package/src/report/jsonReport.js +16 -0
  58. package/src/report/textReport.js +19 -0
  59. package/src/rules/code/duplicateBlock.js +48 -0
  60. package/src/rules/code/firstResultDistance.js +23 -0
  61. package/src/rules/code/inputFileSource.js +103 -0
  62. package/src/rules/code/installImport.js +126 -0
  63. package/src/rules/code/platformApi.js +36 -0
  64. package/src/rules/document/countMismatch.js +42 -0
  65. package/src/rules/document/keywordMissing.js +21 -0
  66. package/src/rules/document/noQuestion.js +11 -0
  67. package/src/rules/document/promiseRecall.js +12 -0
  68. package/src/rules/document/readerAbsent.js +11 -0
  69. package/src/rules/finding.js +53 -0
  70. package/src/rules/orthography/confusable.js +9 -0
  71. package/src/rules/orthography/spacing.js +9 -0
  72. package/src/rules/orthography/spelling.js +9 -0
  73. package/src/rules/paragraph/factListParagraph.js +27 -0
  74. package/src/rules/paragraph/paraFragment.js +29 -0
  75. package/src/rules/paragraph/topicBreak.js +25 -0
  76. package/src/rules/registry.js +147 -0
  77. package/src/rules/sentence/cliche.js +9 -0
  78. package/src/rules/sentence/connectorRepeat.js +23 -0
  79. package/src/rules/sentence/danglingDeixis.js +19 -0
  80. package/src/rules/sentence/dash.js +21 -0
  81. package/src/rules/sentence/deixis.js +19 -0
  82. package/src/rules/sentence/doubleNegative.js +28 -0
  83. package/src/rules/sentence/doublePassive.js +17 -0
  84. package/src/rules/sentence/ellipsis.js +17 -0
  85. package/src/rules/sentence/endingRepeat.js +25 -0
  86. package/src/rules/sentence/euiChain.js +18 -0
  87. package/src/rules/sentence/fillerOpener.js +18 -0
  88. package/src/rules/sentence/hardWord.js +10 -0
  89. package/src/rules/sentence/imperativePeriod.js +20 -0
  90. package/src/rules/sentence/japaneseLoan.js +9 -0
  91. package/src/rules/sentence/longSentence.js +20 -0
  92. package/src/rules/sentence/negationRedefine.js +22 -0
  93. package/src/rules/sentence/nounPile.js +20 -0
  94. package/src/rules/sentence/redundantPair.js +9 -0
  95. package/src/rules/sentence/translationese.js +9 -0
  96. package/src/rules/shared/codeBlocks.js +44 -0
  97. package/src/rules/shared/dictionaryRule.js +35 -0
  98. package/src/rules/shared/runs.js +22 -0
  99. package/src/rules/structure/emojiBullet.js +34 -0
  100. package/src/rules/structure/headingSentence.js +17 -0
  101. package/src/rules/structure/headingSkip.js +18 -0
  102. package/src/rules/structure/headingUniform.js +36 -0
  103. package/src/rules/structure/introLong.js +20 -0
  104. package/src/rules/structure/sectionNoProse.js +16 -0
  105. package/src/rules/structure/sectionResult.js +37 -0
  106. package/src/text.js +63 -0
@@ -0,0 +1,284 @@
1
+ // @ts-check
2
+ /** 문서 모델을 한 번 훑어 글 지문을 만든다. 텍스트를 읽는 유일한 자리다. 파이썬 fingerprint/build.py 와 같다. */
3
+ import { defaultConfig } from "../config/settings.js";
4
+ import { HEADING, PROSE, sectionStartLine, sectionTitle } from "../document/model.js";
5
+ import { codeSpans, plainText } from "../document/plainText.js";
6
+ import { countIn, mean, pstdev, wordCount } from "../text.js";
7
+ import { entriesFor, matchesIn } from "./dictionaries.js";
8
+ import * as markers from "./markers.js";
9
+ import { overlap, topicsOf, unionOf } from "./topics.js";
10
+
11
+ /**
12
+ * @typedef {object} SentencePrint
13
+ * @property {number} index
14
+ * @property {number} line
15
+ * @property {string} text
16
+ * @property {number} blockIndex
17
+ * @property {number} paragraphIndex
18
+ * @property {number} sectionIndex
19
+ * @property {number} length 어절 수
20
+ * @property {string} ending
21
+ * @property {string} mood
22
+ * @property {number} commas
23
+ * @property {string | null} connectorStart
24
+ * @property {number} causal
25
+ * @property {string[]} deixis
26
+ * @property {number} euiCount
27
+ * @property {number} nounRun
28
+ * @property {string[]} passives
29
+ * @property {number} hedges
30
+ * @property {number} numbers
31
+ * @property {Set<string>} topics
32
+ * @property {string[]} promises
33
+ * @property {string[]} recalls
34
+ * @property {[number, string, string][]} countPromises
35
+ * @property {boolean} readerCall
36
+ * @property {import("./dictionaries.js").DictionaryMatch[]} matches
37
+ * @property {[number, number][]} quoted 인용 구간. 인라인 코드와 따옴표 쌍의 안
38
+ */
39
+
40
+ /**
41
+ * @typedef {object} ParagraphPrint
42
+ * @property {number} index
43
+ * @property {number} blockIndex
44
+ * @property {number} sectionIndex
45
+ * @property {number} startLine
46
+ * @property {number} endLine
47
+ * @property {SentencePrint[]} sentences
48
+ * @property {number} sentenceCount
49
+ * @property {number} meanLength
50
+ * @property {number} lengthStd
51
+ * @property {number} causalTotal
52
+ * @property {number} deixisTotal
53
+ * @property {Set<string>} topics
54
+ * @property {number | null} overlapWithPrevious
55
+ * @property {boolean} followsProseDirectly
56
+ * @property {string} text
57
+ */
58
+
59
+ /**
60
+ * @typedef {object} SectionPrint
61
+ * @property {number} index
62
+ * @property {string} title
63
+ * @property {number} level
64
+ * @property {number} startLine
65
+ * @property {ParagraphPrint[]} paragraphs
66
+ * @property {string[]} blockKinds
67
+ * @property {Set<string>} topics
68
+ * @property {boolean} isIntro
69
+ */
70
+
71
+ /**
72
+ * @typedef {object} DocumentPrint
73
+ * @property {string | null} path
74
+ * @property {Record<string, string>} frontmatter
75
+ * @property {import("../document/model.js").Block[]} blocks
76
+ * @property {SentencePrint[]} sentences
77
+ * @property {ParagraphPrint[]} paragraphs
78
+ * @property {SectionPrint[]} sections
79
+ * @property {[number, string, number][]} headings (레벨, 제목, 줄)
80
+ * @property {number} wordCount
81
+ * @property {number} questionCount
82
+ * @property {number} readerCallCount
83
+ * @property {[number, string, number, string][]} countPromises (수, 단위, 줄, 원문)
84
+ * @property {[number, string][]} promises
85
+ * @property {[number, string][]} recalls
86
+ * @property {[string, number, number][]} disabled
87
+ * @property {SectionPrint} intro
88
+ * @property {SectionPrint[]} bodySections
89
+ */
90
+
91
+ /**
92
+ * 문장 안의 인용 구간. 블록의 인라인 코드 구간을 문장 좌표로 옮기고 따옴표 쌍을 더한다.
93
+ * @param {import("../analysis/surface/splitSentences.js").Sentence} sentence
94
+ * @param {[number, number][]} blockCodeSpans
95
+ * @returns {[number, number][]}
96
+ */
97
+ function quotedIn(sentence, blockCodeSpans) {
98
+ /** @type {[number, number][]} */
99
+ const spans = [];
100
+ for (const [start, end] of blockCodeSpans) {
101
+ const lo = Math.max(start, sentence.start);
102
+ const hi = Math.min(end, sentence.end);
103
+ if (lo < hi) spans.push([lo - sentence.start, hi - sentence.start]);
104
+ }
105
+ spans.push(...markers.quoteSpans(sentence.text));
106
+ const unique = new Map(spans.map((span) => [`${span[0]},${span[1]}`, span]));
107
+ return [...unique.values()].sort((a, b) => a[0] - b[0] || a[1] - b[1]);
108
+ }
109
+
110
+ /**
111
+ * @param {string} text
112
+ * @param {number} index
113
+ * @param {number} line
114
+ * @param {import("../document/model.js").Block} block
115
+ * @param {number} paragraphIndex
116
+ * @param {number} sectionIndex
117
+ * @param {import("../analysis/index.js").Analyzer} analyzer
118
+ * @param {import("./dictionaries.js").Entry[]} entries
119
+ * @param {[number, number][]} quoted
120
+ * @returns {SentencePrint}
121
+ */
122
+ function makeSentencePrint(text, index, line, block, paragraphIndex, sectionIndex, analyzer, entries, quoted) {
123
+ const ending = markers.endingOf(text);
124
+ const deixis = markers
125
+ .matchedSpans(text, "deixis.txt")
126
+ .filter(([start, end]) => !markers.insideAny(start, end, quoted))
127
+ .map(([, , found]) => found);
128
+ const matches = matchesIn(text, entries).filter((m) => !markers.insideAny(m.start, m.end, quoted));
129
+ return {
130
+ index,
131
+ line,
132
+ text,
133
+ blockIndex: block.index,
134
+ paragraphIndex,
135
+ sectionIndex,
136
+ length: wordCount(text),
137
+ ending,
138
+ mood: markers.moodOf(text, ending),
139
+ commas: markers.countCommas(text),
140
+ connectorStart: markers.connectorStartOf(text),
141
+ causal: markers.countMatches(text, "causalMarkers.txt"),
142
+ deixis,
143
+ euiCount: analyzer.euiCount(text),
144
+ nounRun: analyzer.longestNounRun(text),
145
+ passives: analyzer.doublePassives(text),
146
+ hedges: markers.countMatches(text, "hedges.txt"),
147
+ numbers: markers.countNumbers(text),
148
+ topics: topicsOf(text),
149
+ promises: markers.matchedTexts(text, "promiseMarkers.txt"),
150
+ recalls: markers.matchedTexts(text, "recallMarkers.txt"),
151
+ countPromises: markers.countPromisesIn(text),
152
+ readerCall: markers.matchedTexts(text, "readerCalls.txt").length > 0,
153
+ matches,
154
+ quoted,
155
+ };
156
+ }
157
+
158
+ /**
159
+ * @param {SentencePrint[]} sentences
160
+ * @param {number} index
161
+ * @param {import("../document/model.js").Block} block
162
+ * @param {number} sectionIndex
163
+ * @param {ParagraphPrint | null} previous
164
+ * @param {boolean} followsProseDirectly
165
+ * @returns {ParagraphPrint}
166
+ */
167
+ function makeParagraphPrint(sentences, index, block, sectionIndex, previous, followsProseDirectly) {
168
+ const lengths = sentences.map((s) => s.length);
169
+ const topics = unionOf(sentences.map((s) => s.topics));
170
+ return {
171
+ index,
172
+ blockIndex: block.index,
173
+ sectionIndex,
174
+ startLine: block.startLine,
175
+ endLine: block.endLine,
176
+ sentences,
177
+ sentenceCount: sentences.length,
178
+ meanLength: lengths.length ? mean(lengths) : 0,
179
+ lengthStd: lengths.length > 1 ? pstdev(lengths) : 0,
180
+ causalTotal: sentences.reduce((sum, s) => sum + s.causal, 0),
181
+ deixisTotal: sentences.reduce((sum, s) => sum + s.deixis.length, 0),
182
+ topics,
183
+ overlapWithPrevious: previous ? overlap(previous.topics, topics) : null,
184
+ followsProseDirectly,
185
+ text: sentences.map((s) => s.text).join(" "),
186
+ };
187
+ }
188
+
189
+ /**
190
+ * @param {import("../document/model.js").Section} section
191
+ * @param {number} sectionIndex
192
+ * @param {import("../analysis/index.js").Analyzer} analyzer
193
+ * @param {import("./dictionaries.js").Entry[]} entries
194
+ * @param {SentencePrint[]} sentences
195
+ * @param {ParagraphPrint[]} paragraphs
196
+ * @returns {SectionPrint}
197
+ */
198
+ function buildSection(section, sectionIndex, analyzer, entries, sentences, paragraphs) {
199
+ /** @type {ParagraphPrint[]} */
200
+ const sectionParagraphs = [];
201
+ /** @type {import("../document/model.js").Block | null} */
202
+ let previousBlock = null;
203
+ for (const block of section.blocks) {
204
+ if (block.kind !== PROSE) {
205
+ previousBlock = block;
206
+ continue;
207
+ }
208
+ const text = plainText(block.text);
209
+ const blockCodeSpans = codeSpans(block.text, text);
210
+ /** @type {SentencePrint[]} */
211
+ const blockSentences = [];
212
+ for (const sentence of analyzer.sentences(text)) {
213
+ const line = block.startLine + countIn(text, "\n", sentence.start);
214
+ const index = sentences.length + blockSentences.length;
215
+ const quoted = quotedIn(sentence, blockCodeSpans);
216
+ blockSentences.push(
217
+ makeSentencePrint(sentence.text, index, line, block, paragraphs.length, sectionIndex, analyzer, entries, quoted),
218
+ );
219
+ }
220
+ sentences.push(...blockSentences);
221
+ const previous = sectionParagraphs.length ? sectionParagraphs[sectionParagraphs.length - 1] : null;
222
+ const paragraph = makeParagraphPrint(
223
+ blockSentences,
224
+ paragraphs.length,
225
+ block,
226
+ sectionIndex,
227
+ previous,
228
+ previousBlock !== null && previousBlock.kind === PROSE,
229
+ );
230
+ paragraphs.push(paragraph);
231
+ sectionParagraphs.push(paragraph);
232
+ previousBlock = block;
233
+ }
234
+ const level = section.heading ? section.heading.level : 0;
235
+ return {
236
+ index: sectionIndex,
237
+ title: sectionTitle(section),
238
+ level,
239
+ startLine: sectionStartLine(section),
240
+ paragraphs: sectionParagraphs,
241
+ blockKinds: section.blocks.map((b) => b.kind),
242
+ topics: unionOf(sectionParagraphs.map((p) => p.topics)),
243
+ isIntro: level === 0,
244
+ };
245
+ }
246
+
247
+ /**
248
+ * @param {import("../document/model.js").Document} doc
249
+ * @param {import("../analysis/index.js").Analyzer} analyzer
250
+ * @param {import("../config/settings.js").Config} [config]
251
+ * @returns {DocumentPrint}
252
+ */
253
+ export function buildFingerprint(doc, analyzer, config = defaultConfig()) {
254
+ const entries = entriesFor(config);
255
+ /** @type {SentencePrint[]} */
256
+ const sentences = [];
257
+ /** @type {ParagraphPrint[]} */
258
+ const paragraphs = [];
259
+ const sections = doc.sections.map((section, index) =>
260
+ buildSection(section, index, analyzer, entries, sentences, paragraphs),
261
+ );
262
+ const headingBlocks = doc.blocks.filter((b) => b.kind === HEADING);
263
+ /** @type {[number, string, number][]} */
264
+ const headings = headingBlocks.map((b) => [b.level, b.text, b.startLine]);
265
+ const headingQuestions = headingBlocks.filter((b) => b.text.includes("?")).length;
266
+ return {
267
+ path: doc.path,
268
+ frontmatter: { ...doc.frontmatter },
269
+ blocks: doc.blocks,
270
+ sentences,
271
+ paragraphs,
272
+ sections,
273
+ headings,
274
+ wordCount: sentences.reduce((sum, s) => sum + s.length, 0),
275
+ questionCount: sentences.filter((s) => s.mood === "의문").length + headingQuestions,
276
+ readerCallCount: sentences.filter((s) => s.readerCall || s.mood === "명령").length,
277
+ countPromises: sentences.flatMap((s) => s.countPromises.map(([n, unit, text]) => [n, unit, s.line, text])),
278
+ promises: sentences.flatMap((s) => s.promises.map((text) => [s.line, text])),
279
+ recalls: sentences.flatMap((s) => s.recalls.map((text) => [s.line, text])),
280
+ disabled: doc.disabled,
281
+ intro: sections[0],
282
+ bodySections: sections.filter((s) => !s.isIntro),
283
+ };
284
+ }
@@ -0,0 +1,118 @@
1
+ // @ts-check
2
+ /** 사전 넷을 한 번 컴파일하고 문장에서 맞는 자리를 찾는다. 설정의 dictionary 항목을 더한다. */
3
+ import { loadEntries } from "../data/load.js";
4
+ import { compile } from "../regex.js";
5
+
6
+ export const DICTIONARY_FILES = {
7
+ cliches: "cliches.toml",
8
+ translationese: "translationese.toml",
9
+ redundantPair: "redundantPair.toml",
10
+ japaneseLoan: "japaneseLoan.toml",
11
+ spelling: "spelling.toml",
12
+ spacing: "spacing.toml",
13
+ confusable: "confusable.toml",
14
+ easyWords: "easyWords.toml",
15
+ };
16
+ const GROUP_REF = /\$(\d)/g;
17
+ const FINALS = { ㄴ: 4, ㄹ: 8 };
18
+ // {조사} 자리표시자. 낱말 뒤에 조사가 붙었거나 낱말이 끝나는 자리. 파이썬 dictionaries.py 와 같은 글자다.
19
+ const JOSA_TAIL = "(?=(?:에서는|으로는|에서|에게|까지|부터|보다|처럼|으로|이나|은|는|이|가|을|를|의|에|로|와|과|도|만)?(?![가-힣]))";
20
+ const PLACEHOLDER = /\{(ㄴ|ㄹ|조사)\}/g;
21
+
22
+ /** 받침으로 끝나는 음절 399개의 문자 부류. @param {"ㄴ" | "ㄹ"} final */
23
+ function syllableClass(final) {
24
+ let chars = "";
25
+ for (let initial = 0; initial < 19; initial++) {
26
+ for (let vowel = 0; vowel < 21; vowel++) chars += String.fromCharCode(0xac00 + (initial * 21 + vowel) * 28 + FINALS[final]);
27
+ }
28
+ return `[${chars}]`;
29
+ }
30
+
31
+ /** @param {string} pattern */
32
+ export function expandClasses(pattern) {
33
+ return pattern.replace(PLACEHOLDER, (_, name) => (name === "조사" ? JOSA_TAIL : syllableClass(name)));
34
+ }
35
+
36
+ /**
37
+ * @typedef {object} Entry
38
+ * @property {string} dictionary
39
+ * @property {import("../regex.js").Pattern} pattern
40
+ * @property {string} why
41
+ * @property {string} source
42
+ * @property {string | null} fix
43
+ */
44
+
45
+ /**
46
+ * @typedef {object} DictionaryMatch
47
+ * @property {string} dictionary
48
+ * @property {string} text
49
+ * @property {number} start
50
+ * @property {number} end
51
+ * @property {string} why
52
+ * @property {string} source
53
+ * @property {string | null} fix
54
+ */
55
+
56
+ /** @param {string} dictionary @param {Record<string, unknown> | string} raw @returns {Entry} */
57
+ export function entryFrom(dictionary, raw) {
58
+ const data = typeof raw === "string" ? { pattern: raw } : raw;
59
+ return {
60
+ dictionary,
61
+ pattern: compile(expandClasses(/** @type {string} */ (data.pattern))),
62
+ why: /** @type {string} */ (data.why ?? "설정에서 더한 항목"),
63
+ source: /** @type {string} */ (data.source ?? "설정"),
64
+ fix: /** @type {string | null} */ (data.fix ?? null),
65
+ };
66
+ }
67
+
68
+ /** @type {Entry[] | null} */
69
+ let builtinCache = null;
70
+ export function builtinEntries() {
71
+ if (!builtinCache) {
72
+ builtinCache = [];
73
+ for (const [dictionary, name] of Object.entries(DICTIONARY_FILES)) {
74
+ for (const raw of loadEntries(name)) builtinCache.push(entryFrom(dictionary, raw));
75
+ }
76
+ }
77
+ return builtinCache;
78
+ }
79
+
80
+ /** @param {import("../config/settings.js").Config} config @returns {Entry[]} */
81
+ export function entriesFor(config) {
82
+ /** @type {Entry[]} */
83
+ const extra = [];
84
+ for (const [dictionary, items] of Object.entries(config.dictionary)) {
85
+ if (!(dictionary in DICTIONARY_FILES)) {
86
+ throw new Error(`모르는 사전: ${dictionary}. ${Object.keys(DICTIONARY_FILES).join(", ")} 가운데 하나다`);
87
+ }
88
+ for (const raw of items) extra.push(entryFrom(dictionary, /** @type {Record<string, unknown> | string} */ (raw)));
89
+ }
90
+ return [...builtinEntries(), ...extra];
91
+ }
92
+
93
+ /** @param {RegExpExecArray} match @param {string} fix */
94
+ export function applyFix(match, fix) {
95
+ return fix.replace(GROUP_REF, (_, n) => match[Number(n)] ?? "");
96
+ }
97
+
98
+ /** @param {string} text @param {Entry[]} entries @returns {DictionaryMatch[]} */
99
+ export function matchesIn(text, entries) {
100
+ /** @type {DictionaryMatch[]} */
101
+ const found = [];
102
+ for (const entry of entries) {
103
+ for (const match of entry.pattern.all(text)) {
104
+ const start = /** @type {number} */ (match.index);
105
+ found.push({
106
+ dictionary: entry.dictionary,
107
+ text: match[0],
108
+ start,
109
+ end: start + match[0].length,
110
+ why: entry.why,
111
+ source: entry.source,
112
+ fix: entry.fix ? applyFix(match, entry.fix) : null,
113
+ });
114
+ }
115
+ }
116
+ found.sort((a, b) => a.start - b.start);
117
+ return found;
118
+ }
@@ -0,0 +1,175 @@
1
+ // @ts-check
2
+ /**
3
+ * 문장에서 표지를 센다. 종결어미 부류, 서법, 접속부사, 인과, 지시어, 헤지, 약속과 회수, 독자 호출, 수 약속.
4
+ * 낱말 목록은 전부 data/ 가 정본이다. 파이썬 fingerprint/markers.py 와 같다.
5
+ */
6
+ import { loadLines, loadPatterns } from "../data/load.js";
7
+ import { compile, escape } from "../regex.js";
8
+
9
+ const TRAILING = /[\s.?!"'”’)\]]+$/;
10
+ const NUMBER = /\d+(?:[.,]\d+)*/g;
11
+ const COMMA = /,/g;
12
+ // 따옴표 쌍. 안은 사용이 아니라 인용이다. 작은따옴표는 앞이 문장 첫머리나 공백이나 여는 괄호일 때만.
13
+ const QUOTE_PAIRS = [
14
+ /"([^"\n]{1,80})"/g,
15
+ /“([^“”\n]{1,80})”/g,
16
+ /‘([^‘’\n]{1,80})’/g,
17
+ /「([^「」\n]{1,80})」/g,
18
+ /『([^『』\n]{1,80})』/g,
19
+ /(?<=^|[\s(])'([^'\n]{1,80})'/g,
20
+ ];
21
+ const CONNECTOR_LOOKAHEAD = /(?=[\s,])/.source;
22
+ const NO_HANGUL_BEFORE = /(?<![가-힣])/.source;
23
+ const DIGITS = /\d+/.source;
24
+ const OPTIONAL_SPACE = /\s?/.source;
25
+
26
+ /** @type {Record<string, number> | null} */
27
+ let numberCache = null;
28
+ /** 수사 → 값. 정본은 data/koreanNumbers.txt. */
29
+ export function koreanNumbers() {
30
+ if (!numberCache) {
31
+ numberCache = {};
32
+ for (const line of loadLines("koreanNumbers.txt")) {
33
+ const [word, value] = line.split("\t");
34
+ numberCache[word] = parseInt(value, 10);
35
+ }
36
+ }
37
+ return numberCache;
38
+ }
39
+
40
+ /** @type {[string, import("../regex.js").Pattern][] | null} */
41
+ let endingCache = null;
42
+ export function endingClasses() {
43
+ if (!endingCache) {
44
+ endingCache = loadLines("endings.txt").map((line) => {
45
+ const at = line.indexOf("\t");
46
+ return [line.slice(0, at), compile(line.slice(at + 1))];
47
+ });
48
+ }
49
+ return endingCache;
50
+ }
51
+
52
+ /** @type {import("../regex.js").Pattern | null} */
53
+ let connectorCache = null;
54
+ export function connectorPattern() {
55
+ if (!connectorCache) {
56
+ const names = [...loadLines("connectors.txt")].sort((a, b) => b.length - a.length);
57
+ connectorCache = compile(`^(${names.map(escape).join("|")})${CONNECTOR_LOOKAHEAD}`);
58
+ }
59
+ return connectorCache;
60
+ }
61
+
62
+ /** @type {import("../regex.js").Pattern | null} */
63
+ let countCache = null;
64
+ export function countPromisePattern() {
65
+ if (!countCache) {
66
+ const units = loadLines("countUnits.txt").map(escape).join("|");
67
+ const numbers = Object.keys(koreanNumbers())
68
+ .sort((a, b) => b.length - a.length)
69
+ .join("|");
70
+ // 단위 뒤에는 조사가 붙는 것이 정상이다 (여섯 가지를). 앞쪽만 경계를 본다.
71
+ countCache = compile(`${NO_HANGUL_BEFORE}(${numbers}|${DIGITS})${OPTIONAL_SPACE}(${units})`);
72
+ }
73
+ return countCache;
74
+ }
75
+
76
+ /** @param {string} text */
77
+ export function stripTrailing(text) {
78
+ return text.trim().replace(TRAILING, "");
79
+ }
80
+
81
+ /** @param {string} text */
82
+ export function endingOf(text) {
83
+ const body = stripTrailing(text);
84
+ for (const [kind, pattern] of endingClasses()) {
85
+ if (pattern.search(body)) return kind;
86
+ }
87
+ return "없음";
88
+ }
89
+
90
+ /** @param {string} text @param {string} ending */
91
+ export function moodOf(text, ending) {
92
+ const stripped = text.trim();
93
+ if (stripped.endsWith("?") || ending === "의문") return "의문";
94
+ if (ending === "명령") return "명령";
95
+ return "평서";
96
+ }
97
+
98
+ /** @param {string} text */
99
+ export function connectorStartOf(text) {
100
+ const match = connectorPattern().at(text.trim());
101
+ return match ? match[1] : null;
102
+ }
103
+
104
+ /** @param {string} text @param {string} patternFile */
105
+ export function countMatches(text, patternFile) {
106
+ let total = 0;
107
+ for (const pattern of loadPatterns(patternFile)) total += pattern.all(text).length;
108
+ return total;
109
+ }
110
+
111
+ /** @param {string} text @param {string} patternFile @returns {string[]} */
112
+ export function matchedTexts(text, patternFile) {
113
+ /** @type {string[]} */
114
+ const found = [];
115
+ for (const pattern of loadPatterns(patternFile)) {
116
+ for (const match of pattern.all(text)) found.push(match[0]);
117
+ }
118
+ return found;
119
+ }
120
+
121
+ /** (시작, 끝, 원문). 인용 구간 안의 것을 거를 때 쓴다. @param {string} text @param {string} patternFile @returns {[number, number, string][]} */
122
+ export function matchedSpans(text, patternFile) {
123
+ /** @type {[number, number, string][]} */
124
+ const found = [];
125
+ for (const pattern of loadPatterns(patternFile)) {
126
+ for (const match of pattern.all(text)) {
127
+ const start = /** @type {number} */ (match.index);
128
+ found.push([start, start + match[0].length, match[0]]);
129
+ }
130
+ }
131
+ return found;
132
+ }
133
+
134
+ /** 따옴표 쌍 안의 [시작, 끝]. 인용은 사용이 아니다. @param {string} text @returns {[number, number][]} */
135
+ export function quoteSpans(text) {
136
+ /** @type {[number, number][]} */
137
+ const found = [];
138
+ for (const pattern of QUOTE_PAIRS) {
139
+ pattern.lastIndex = 0;
140
+ for (const match of text.matchAll(pattern)) {
141
+ const start = /** @type {number} */ (match.index) + match[0].indexOf(match[1]);
142
+ found.push([start, start + match[1].length]);
143
+ }
144
+ }
145
+ return found.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
146
+ }
147
+
148
+ /** @param {number} start @param {number} end @param {[number, number][]} spans */
149
+ export function insideAny(start, end, spans) {
150
+ return spans.some(([spanStart, spanEnd]) => spanStart <= start && end <= spanEnd);
151
+ }
152
+
153
+ /** @param {string} text @returns {[number, string, string][]} (수, 단위, 원문) */
154
+ export function countPromisesIn(text) {
155
+ /** @type {[number, string, string][]} */
156
+ const found = [];
157
+ for (const match of countPromisePattern().all(text)) {
158
+ const raw = match[1];
159
+ const unit = match[2];
160
+ if (/^\d+$/.test(raw) && unit === "단계" && !match[0].includes(" ")) continue;
161
+ const number = koreanNumbers()[raw] ?? parseInt(raw, 10);
162
+ found.push([number, unit, match[0]]);
163
+ }
164
+ return found;
165
+ }
166
+
167
+ /** @param {string} text */
168
+ export function countNumbers(text) {
169
+ return [...text.matchAll(NUMBER)].length;
170
+ }
171
+
172
+ /** @param {string} text */
173
+ export function countCommas(text) {
174
+ return [...text.matchAll(COMMA)].length;
175
+ }
@@ -0,0 +1,42 @@
1
+ // @ts-check
2
+ /** 화제어 집합. 조사를 뗀 명사 어절의 근사. 파이썬 fingerprint/topics.py 와 같다. */
3
+ import { stripJosa, tailOf, words } from "../analysis/surface/tokenize.js";
4
+ import { loadLines } from "../data/load.js";
5
+
6
+ const WORD = /^[가-힣A-Za-z][가-힣A-Za-z0-9]*$/;
7
+
8
+ /** @type {Set<string> | null} */
9
+ let stopwordCache = null;
10
+ function stopwords() {
11
+ if (!stopwordCache) stopwordCache = new Set(loadLines("stopwords.txt"));
12
+ return stopwordCache;
13
+ }
14
+
15
+ /** @param {string} text @returns {Set<string>} */
16
+ export function topicsOf(text) {
17
+ const found = new Set();
18
+ for (const word of words(text)) {
19
+ const core = stripJosa(word.core);
20
+ if (!WORD.test(core) || stopwords().has(core)) continue;
21
+ // 한 글자는 조사가 붙어 있던 것만 명사로 본다 (표를, 값이, 열은). 홀로 선 한 글자는 잡음이다.
22
+ if (core.length < 2 && core === word.core) continue;
23
+ if (tailOf(core, "verbTails.txt")) continue;
24
+ found.add(core.toLowerCase());
25
+ }
26
+ return found;
27
+ }
28
+
29
+ /** 자카드 유사도. 둘 다 비어 있으면 0. @param {Set<string>} a @param {Set<string>} b */
30
+ export function overlap(a, b) {
31
+ if (!a.size || !b.size) return 0;
32
+ let shared = 0;
33
+ for (const item of a) if (b.has(item)) shared += 1;
34
+ return shared / (a.size + b.size - shared);
35
+ }
36
+
37
+ /** @param {Iterable<Set<string>>} sets */
38
+ export function unionOf(sets) {
39
+ const result = new Set();
40
+ for (const set of sets) for (const item of set) result.add(item);
41
+ return result;
42
+ }
package/src/index.js ADDED
@@ -0,0 +1,68 @@
1
+ // @ts-check
2
+ /**
3
+ * hanlint: 한국어 글에서 AI 와 사람이 반복해서 어기는 결함을 결정적으로 잡는 린터. 공개 표면은 이 파일 한 곳이다.
4
+ *
5
+ * ```js
6
+ * import { lintText, lintFile } from "hanlint";
7
+ * for (const f of lintFile("글.md")) console.log(f.line, f.rule, f.why);
8
+ * ```
9
+ *
10
+ * 합격과 불합격을 판정하지 않는다. 지적 목록이 비어 있다는 것은 세어서 잡히는 결함이 없다는 뜻이다.
11
+ * 지문 지도 (audit, map), 프로파일, kiwi 정밀 모드는 파이썬 패키지에 있다.
12
+ */
13
+ import { readFileSync } from "node:fs";
14
+
15
+ import { makeAnalyzer } from "./analysis/index.js";
16
+ import { loadConfig } from "./config/loadConfig.js";
17
+ import { configFromMapping, defaultConfig } from "./config/settings.js";
18
+ import { loadVersion } from "./data/load.js";
19
+ import { parseMarkdown } from "./document/parseMarkdown.js";
20
+ import { applyFixes } from "./edit/applyFixes.js";
21
+ import { buildFingerprint } from "./fingerprint/build.js";
22
+ import { fingerprintDict } from "./report/fingerprintJson.js";
23
+ import { ruleDoc, ruleNames, ruleSummary, runAll } from "./rules/registry.js";
24
+
25
+ export { applyFixes, configFromMapping, defaultConfig, fingerprintDict, loadConfig, ruleDoc, ruleNames, ruleSummary };
26
+ export const version = loadVersion();
27
+
28
+ /** @type {Map<string, import("./analysis/index.js").Analyzer>} */
29
+ const analyzers = new Map();
30
+
31
+ /** @param {import("./config/settings.js").Config} config */
32
+ export function analyzerFor(config) {
33
+ let analyzer = analyzers.get(config.analyzer);
34
+ if (!analyzer) {
35
+ analyzer = makeAnalyzer(config.analyzer);
36
+ analyzers.set(config.analyzer, analyzer);
37
+ }
38
+ return analyzer;
39
+ }
40
+
41
+ /**
42
+ * 글을 한 번 읽어 지문을 만든다.
43
+ * @param {string} text
44
+ * @param {import("./config/settings.js").Config} [config]
45
+ * @param {string | null} [path]
46
+ */
47
+ export function fingerprint(text, config = defaultConfig(), path = null) {
48
+ return buildFingerprint(parseMarkdown(text, path), analyzerFor(config), config);
49
+ }
50
+
51
+ /**
52
+ * 문자열을 검사해 줄 번호 순의 지적 목록을 준다.
53
+ * @param {string} text
54
+ * @param {import("./config/settings.js").Config} [config]
55
+ * @param {string | null} [path]
56
+ */
57
+ export function lintText(text, config = defaultConfig(), path = null) {
58
+ return runAll(fingerprint(text, config, path), config);
59
+ }
60
+
61
+ /**
62
+ * 파일을 UTF-8 로 읽어 검사한다.
63
+ * @param {string} path
64
+ * @param {import("./config/settings.js").Config} [config]
65
+ */
66
+ export function lintFile(path, config = defaultConfig()) {
67
+ return lintText(readFileSync(path, "utf-8"), config, path);
68
+ }