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.
- package/README.md +27 -0
- package/bin/hanlint.js +4 -0
- package/data/causalMarkers.txt +25 -0
- package/data/cliches.json +99 -0
- package/data/confusable.json +76 -0
- package/data/connectors.txt +18 -0
- package/data/countUnits.txt +6 -0
- package/data/coverageTypes.txt +7 -0
- package/data/deixis.txt +8 -0
- package/data/easyWords.json +340 -0
- package/data/emphasisWords.txt +11 -0
- package/data/endings.txt +9 -0
- package/data/euiNouns.txt +40 -0
- package/data/hedges.txt +15 -0
- package/data/hiddenDeps.txt +11 -0
- package/data/holeKinds.json +145 -0
- package/data/japaneseLoan.json +82 -0
- package/data/josa.txt +54 -0
- package/data/koreanNumbers.txt +22 -0
- package/data/passiveStems.txt +53 -0
- package/data/platformApis.txt +12 -0
- package/data/promiseMarkers.txt +7 -0
- package/data/pythonPackages.txt +35 -0
- package/data/pythonStdlib.txt +193 -0
- package/data/readerCalls.txt +14 -0
- package/data/recallMarkers.txt +6 -0
- package/data/redundantPair.json +70 -0
- package/data/ruleDocs.json +44 -0
- package/data/spacing.json +76 -0
- package/data/spelling.json +166 -0
- package/data/stopwords.txt +33 -0
- package/data/translationese.json +104 -0
- package/data/verbTails.txt +56 -0
- package/data/version.json +1 -0
- package/package.json +39 -0
- package/src/analysis/index.js +34 -0
- package/src/analysis/surface/splitSentences.js +36 -0
- package/src/analysis/surface/tokenize.js +162 -0
- package/src/cli/main.js +376 -0
- package/src/config/loadConfig.js +51 -0
- package/src/config/settings.js +80 -0
- package/src/config/toml.js +239 -0
- package/src/data/load.js +63 -0
- package/src/document/model.js +55 -0
- package/src/document/parseMarkdown.js +183 -0
- package/src/document/plainText.js +43 -0
- package/src/edit/applyFixes.js +0 -0
- package/src/fingerprint/build.js +284 -0
- package/src/fingerprint/dictionaries.js +118 -0
- package/src/fingerprint/markers.js +175 -0
- package/src/fingerprint/topics.js +42 -0
- package/src/index.js +68 -0
- package/src/regex.js +47 -0
- package/src/report/compactReport.js +9 -0
- package/src/report/fingerprintJson.js +126 -0
- package/src/report/githubReport.js +13 -0
- package/src/report/jsonReport.js +16 -0
- package/src/report/textReport.js +19 -0
- package/src/rules/code/duplicateBlock.js +48 -0
- package/src/rules/code/firstResultDistance.js +23 -0
- package/src/rules/code/inputFileSource.js +103 -0
- package/src/rules/code/installImport.js +126 -0
- package/src/rules/code/platformApi.js +36 -0
- package/src/rules/document/countMismatch.js +42 -0
- package/src/rules/document/keywordMissing.js +21 -0
- package/src/rules/document/noQuestion.js +11 -0
- package/src/rules/document/promiseRecall.js +12 -0
- package/src/rules/document/readerAbsent.js +11 -0
- package/src/rules/finding.js +53 -0
- package/src/rules/orthography/confusable.js +9 -0
- package/src/rules/orthography/spacing.js +9 -0
- package/src/rules/orthography/spelling.js +9 -0
- package/src/rules/paragraph/factListParagraph.js +27 -0
- package/src/rules/paragraph/paraFragment.js +29 -0
- package/src/rules/paragraph/topicBreak.js +25 -0
- package/src/rules/registry.js +147 -0
- package/src/rules/sentence/cliche.js +9 -0
- package/src/rules/sentence/connectorRepeat.js +23 -0
- package/src/rules/sentence/danglingDeixis.js +19 -0
- package/src/rules/sentence/dash.js +21 -0
- package/src/rules/sentence/deixis.js +19 -0
- package/src/rules/sentence/doubleNegative.js +28 -0
- package/src/rules/sentence/doublePassive.js +17 -0
- package/src/rules/sentence/ellipsis.js +17 -0
- package/src/rules/sentence/endingRepeat.js +25 -0
- package/src/rules/sentence/euiChain.js +18 -0
- package/src/rules/sentence/fillerOpener.js +18 -0
- package/src/rules/sentence/hardWord.js +10 -0
- package/src/rules/sentence/imperativePeriod.js +20 -0
- package/src/rules/sentence/japaneseLoan.js +9 -0
- package/src/rules/sentence/longSentence.js +20 -0
- package/src/rules/sentence/negationRedefine.js +22 -0
- package/src/rules/sentence/nounPile.js +20 -0
- package/src/rules/sentence/redundantPair.js +9 -0
- package/src/rules/sentence/translationese.js +9 -0
- package/src/rules/shared/codeBlocks.js +44 -0
- package/src/rules/shared/dictionaryRule.js +35 -0
- package/src/rules/shared/runs.js +22 -0
- package/src/rules/structure/emojiBullet.js +34 -0
- package/src/rules/structure/headingSentence.js +17 -0
- package/src/rules/structure/headingSkip.js +18 -0
- package/src/rules/structure/headingUniform.js +36 -0
- package/src/rules/structure/introLong.js +20 -0
- package/src/rules/structure/sectionNoProse.js +16 -0
- package/src/rules/structure/sectionResult.js +37 -0
- 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
|
+
}
|