hanlint 0.0.7 → 0.0.9
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/LICENSE +21 -0
- package/README.md +32 -9
- package/data/adjectiveStems.txt +118 -0
- package/data/baselineAnchors.txt +3 -0
- package/data/blindEvaluation.schema.json +26 -0
- package/data/bridgeOpeners.txt +10 -0
- package/data/candidateActionNouns.txt +39 -0
- package/data/candidateSplitEndings.txt +13 -0
- package/data/cliches.json +1 -1
- package/data/confusable.json +2 -2
- package/data/deixis.txt +10 -3
- package/data/draftHistory.txt +10 -0
- package/data/easyWords.json +0 -42
- package/data/enoughMarkers.txt +6 -0
- package/data/entailmentPredictions.schema.json +52 -0
- package/data/euiNouns.txt +3 -4
- package/data/evidenceEntailmentBenchmark.schema.json +126 -0
- package/data/exemplars.json +323 -0
- package/data/holeKinds.json +2 -14
- package/data/iVerbStems.txt +26 -0
- package/data/inNouns.txt +147 -0
- package/data/irregularStems.txt +64 -0
- package/data/nonNouns.txt +153 -0
- package/data/panelAssignmentReview.schema.json +117 -0
- package/data/panelJudgePredictions.schema.json +78 -0
- package/data/panelReviewBatch.schema.json +79 -0
- package/data/panelTrialSet.schema.json +44 -0
- package/data/patterns.json +103 -0
- package/data/platformApis.txt +3 -1
- package/data/profiles.json +1 -0
- package/data/readerCalls.txt +12 -7
- package/data/redundantPair.json +1 -1
- package/data/rieulStems.txt +30 -0
- package/data/ruleCategories.json +52 -0
- package/data/ruleDocs.json +27 -19
- package/data/ruleMechanisms.json +52 -0
- package/data/spacing.json +0 -12
- package/data/spelling.json +7 -25
- package/data/translationese.json +10 -0
- package/data/verbTails.txt +17 -0
- package/data/version.json +1 -1
- package/data/writingBrief.schema.json +64 -0
- package/data/writingBriefV2.schema.json +116 -0
- package/data/writingTrial.schema.json +36 -0
- package/koglType1.LICENSE.md +25 -0
- package/package.json +18 -6
- package/src/analysis/grammar/ending.js +336 -0
- package/src/analysis/grammar/hangul.js +94 -0
- package/src/analysis/grammar/index.js +5 -0
- package/src/analysis/grammar/josa.js +73 -0
- package/src/analysis/grammar/register.js +115 -0
- package/src/analysis/grammar/voice.js +43 -0
- package/src/analysis/index.js +4 -31
- package/src/analysis/josa.js +2 -0
- package/src/analysis/{surface/splitSentences.js → splitSentences.js} +1 -1
- package/src/analysis/{surface/tokenize.js → tokenize.js} +70 -13
- package/src/baseline/store.js +114 -0
- package/src/cli/main.js +508 -61
- package/src/cli/walk.js +30 -0
- package/src/cli/welcome.js +52 -0
- package/src/config/settings.js +111 -12
- package/src/data/exemplars.js +177 -0
- package/src/data/load.js +10 -0
- package/src/data/operations.js +278 -0
- package/src/data/patches.js +125 -0
- package/src/data/patterns.js +48 -0
- package/src/data/profiles.js +102 -0
- package/src/document/model.js +2 -0
- package/src/document/parseMarkdown.js +84 -11
- package/src/edit/applyFixes.js +0 -0
- package/src/fingerprint/build.js +60 -39
- package/src/{rules/shared → fingerprint}/codeBlocks.js +11 -11
- package/src/fingerprint/codeMarkers.js +73 -0
- package/src/fingerprint/markers.js +9 -0
- package/src/fingerprint/readerState.js +126 -0
- package/src/fingerprint/sourceSentence.js +37 -0
- package/src/fingerprint/topics.js +1 -1
- package/src/index.js +2 -16
- package/src/report/compactReport.js +6 -1
- package/src/report/fingerprintJson.js +8 -3
- package/src/report/jsonReport.js +50 -3
- package/src/report/operationMatch.js +31 -0
- package/src/report/patchMatch.js +44 -0
- package/src/report/registerMatch.js +40 -0
- package/src/report/textReport.js +41 -5
- package/src/rules/code/duplicateBlock.js +4 -3
- package/src/rules/code/firstResultDistance.js +1 -0
- package/src/rules/code/inputFileSource.js +23 -68
- package/src/rules/code/installImport.js +6 -5
- package/src/rules/code/platformApi.js +7 -6
- package/src/rules/document/countMismatch.js +22 -6
- package/src/rules/document/enoughOnce.js +25 -0
- package/src/rules/document/fieldEcho.js +38 -0
- package/src/rules/document/keywordHeading.js +32 -0
- package/src/rules/document/keywordMissing.js +1 -0
- package/src/rules/document/noQuestion.js +5 -1
- package/src/rules/document/promiseRecall.js +4 -2
- package/src/rules/document/tableOddCell.js +61 -0
- package/src/rules/finding.js +24 -2
- package/src/rules/orthography/confusable.js +1 -0
- package/src/rules/orthography/spacing.js +1 -0
- package/src/rules/orthography/spelling.js +1 -0
- package/src/rules/paragraph/factListParagraph.js +1 -0
- package/src/rules/paragraph/paraFragment.js +21 -11
- package/src/rules/registry.js +70 -9
- package/src/rules/sentence/cliche.js +1 -0
- package/src/rules/sentence/connectorRepeat.js +8 -7
- package/src/rules/sentence/danglingDeixis.js +23 -3
- package/src/rules/sentence/dash.js +2 -1
- package/src/rules/sentence/deixis.js +7 -3
- package/src/rules/sentence/doubleNegative.js +4 -1
- package/src/rules/sentence/doublePassive.js +38 -1
- package/src/rules/sentence/draftHistory.js +24 -0
- package/src/rules/sentence/endingRepeat.js +6 -2
- package/src/rules/sentence/euiChain.js +3 -2
- package/src/rules/sentence/fillerOpener.js +1 -0
- package/src/rules/sentence/hardWord.js +1 -0
- package/src/rules/sentence/imperativePeriod.js +2 -1
- package/src/rules/sentence/japaneseLoan.js +1 -0
- package/src/rules/sentence/longSentence.js +3 -1
- package/src/rules/sentence/negationRedefine.js +12 -2
- package/src/rules/sentence/nounPile.js +1 -0
- package/src/rules/sentence/numberOrphan.js +45 -0
- package/src/rules/sentence/outsideProfile.js +68 -0
- package/src/rules/sentence/redundantPair.js +1 -0
- package/src/rules/sentence/translationese.js +1 -0
- package/src/rules/shared/candidates.js +129 -0
- package/src/rules/shared/dictionaryRule.js +12 -2
- package/src/rules/shared/localAntecedent.js +12 -0
- package/src/rules/shared/repeat.js +48 -0
- package/src/rules/structure/blockUnread.js +33 -0
- package/src/rules/structure/bridgeRepeat.js +40 -0
- package/src/rules/structure/emojiBullet.js +1 -0
- package/src/rules/structure/headingSentence.js +9 -4
- package/src/rules/structure/headingSkip.js +1 -0
- package/src/rules/structure/headingUniform.js +12 -20
- package/src/rules/structure/introImage.js +19 -0
- package/src/rules/structure/introLong.js +4 -1
- package/src/rules/structure/loneSubheading.js +42 -0
- package/src/rules/structure/moreLater.js +48 -0
- package/src/rules/structure/sectionNoProse.js +1 -0
- package/src/text.js +19 -0
- package/data/coverageTypes.txt +0 -7
- package/src/rules/document/readerAbsent.js +0 -11
- package/src/rules/paragraph/topicBreak.js +0 -25
- package/src/rules/sentence/ellipsis.js +0 -18
- package/src/rules/shared/runs.js +0 -22
- package/src/rules/structure/sectionResult.js +0 -37
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* 한글 음절의 자모 산술. 파이썬 `analysis/grammar/hangul.py` 의 투영이다. 표 대신 산술로 하는 까닭은 그 파일이 소유한다.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const BASE = 0xac00;
|
|
7
|
+
export const LAST = 0xd7a3;
|
|
8
|
+
export const VOWELS = 21;
|
|
9
|
+
export const FINALS = 28;
|
|
10
|
+
|
|
11
|
+
// 중성 자리 번호. ㅏ ㅐ ㅑ ㅒ ㅓ ㅔ ㅕ ㅖ ㅗ ㅘ ㅙ ㅚ ㅛ ㅜ ㅝ ㅞ ㅟ ㅠ ㅡ ㅢ ㅣ 차례다.
|
|
12
|
+
export const A = 0;
|
|
13
|
+
export const AE = 1;
|
|
14
|
+
export const YA = 2;
|
|
15
|
+
export const YAE = 3;
|
|
16
|
+
export const EO = 4;
|
|
17
|
+
export const E = 5;
|
|
18
|
+
export const YEO = 6;
|
|
19
|
+
export const YE = 7;
|
|
20
|
+
export const OH = 8;
|
|
21
|
+
export const WA = 9;
|
|
22
|
+
export const WAE = 10;
|
|
23
|
+
export const OE = 11;
|
|
24
|
+
export const YO = 12;
|
|
25
|
+
export const U = 13;
|
|
26
|
+
export const WO = 14;
|
|
27
|
+
export const WE = 15;
|
|
28
|
+
export const WI = 16;
|
|
29
|
+
export const YU = 17;
|
|
30
|
+
export const EU = 18;
|
|
31
|
+
export const UI = 19;
|
|
32
|
+
export const IH = 20;
|
|
33
|
+
|
|
34
|
+
// 종성 자리 번호 가운데 형태 층이 이름으로 부르는 것.
|
|
35
|
+
export const NONE = 0;
|
|
36
|
+
export const GIYEOK = 1;
|
|
37
|
+
export const NIEUN = 4;
|
|
38
|
+
export const DIGEUT = 7;
|
|
39
|
+
export const RIEUL = 8;
|
|
40
|
+
export const MIEUM = 16;
|
|
41
|
+
export const BIEUP = 17;
|
|
42
|
+
export const SIOT = 19;
|
|
43
|
+
export const SSANGSIOT = 20;
|
|
44
|
+
export const IEUNG = 21;
|
|
45
|
+
export const HIEUT = 27;
|
|
46
|
+
|
|
47
|
+
/** 양성 모음. 이 모음 뒤에는 `아` 가 붙고 나머지에는 `어` 가 붙는다. */
|
|
48
|
+
export const BRIGHT = new Set([A, OH, YA, YO, WA]);
|
|
49
|
+
|
|
50
|
+
/** @param {string} ch */
|
|
51
|
+
export function isSyllable(ch) {
|
|
52
|
+
if (ch.length !== 1) return false;
|
|
53
|
+
const code = ch.charCodeAt(0);
|
|
54
|
+
return code >= BASE && code <= LAST;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** (초성, 중성, 종성) 자리 번호. @param {string} ch @returns {[number, number, number]} */
|
|
58
|
+
export function split(ch) {
|
|
59
|
+
if (!isSyllable(ch)) throw new Error(`한글 음절이 아니다: ${JSON.stringify(ch)}`);
|
|
60
|
+
const code = ch.charCodeAt(0) - BASE;
|
|
61
|
+
return [Math.floor(code / (VOWELS * FINALS)), Math.floor(code / FINALS) % VOWELS, code % FINALS];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** @param {number} initial @param {number} vowel @param {number} [final] */
|
|
65
|
+
export function join(initial, vowel, final = NONE) {
|
|
66
|
+
return String.fromCharCode(BASE + (initial * VOWELS + vowel) * FINALS + final);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** 음절의 종성 번호. 한글 음절이 아니면 null. @param {string} ch */
|
|
70
|
+
export function finalOf(ch) {
|
|
71
|
+
return isSyllable(ch) ? split(ch)[2] : null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** @param {string} ch */
|
|
75
|
+
export function vowelOf(ch) {
|
|
76
|
+
return isSyllable(ch) ? split(ch)[1] : null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** @param {string} ch @param {number} final */
|
|
80
|
+
export function withFinal(ch, final) {
|
|
81
|
+
const [initial, vowel] = split(ch);
|
|
82
|
+
return join(initial, vowel, final);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** @param {string} ch @param {number} vowel */
|
|
86
|
+
export function withVowel(ch, vowel) {
|
|
87
|
+
const [initial, , final] = split(ch);
|
|
88
|
+
return join(initial, vowel, final);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** 낱말의 마지막 한글 음절. 없으면 null. @param {string} word */
|
|
92
|
+
export function lastSyllable(word) {
|
|
93
|
+
return word && isSyllable(word[word.length - 1]) ? word[word.length - 1] : null;
|
|
94
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/** 파이썬 `analysis/grammar/josa.py`의 투영. 받침에 따라 조사 꼴을 맞춘다. */
|
|
3
|
+
import * as hangul from "./hangul.js";
|
|
4
|
+
|
|
5
|
+
const PAIRS = [
|
|
6
|
+
["으로부터", "로부터"],
|
|
7
|
+
["으로서", "로서"],
|
|
8
|
+
["으로써", "로써"],
|
|
9
|
+
["이라고", "라고"],
|
|
10
|
+
["이라서", "라서"],
|
|
11
|
+
["이라는", "라는"],
|
|
12
|
+
["이라면", "라면"],
|
|
13
|
+
["이나마", "나마"],
|
|
14
|
+
["으로", "로"],
|
|
15
|
+
["이란", "란"],
|
|
16
|
+
["이라", "라"],
|
|
17
|
+
["이며", "며"],
|
|
18
|
+
["이랑", "랑"],
|
|
19
|
+
["이든", "든"],
|
|
20
|
+
["이나", "나"],
|
|
21
|
+
["이여", "여"],
|
|
22
|
+
["이야", "야"],
|
|
23
|
+
["은", "는"],
|
|
24
|
+
["이", "가"],
|
|
25
|
+
["을", "를"],
|
|
26
|
+
["과", "와"],
|
|
27
|
+
["아", "야"],
|
|
28
|
+
];
|
|
29
|
+
const DIGIT_FINALS = [hangul.IEUNG, hangul.RIEUL, hangul.NONE, hangul.MIEUM, hangul.NONE, hangul.NONE, hangul.GIYEOK, hangul.RIEUL, hangul.RIEUL, hangul.NONE];
|
|
30
|
+
const PLACE_FINALS = { 1: hangul.BIEUP, 2: hangul.GIYEOK, 3: hangul.NIEUN };
|
|
31
|
+
const GROUP_FINALS = [hangul.NIEUN, hangul.GIYEOK, hangul.NONE, hangul.IEUNG];
|
|
32
|
+
const ALNUM = /[\p{L}\p{N}]/u;
|
|
33
|
+
|
|
34
|
+
/** @param {string} digits */
|
|
35
|
+
export function digitFinal(digits) {
|
|
36
|
+
const body = digits.replace(/0+$/, "");
|
|
37
|
+
if (!body) return DIGIT_FINALS[0];
|
|
38
|
+
const zeros = digits.length - body.length;
|
|
39
|
+
if (zeros === 0) return DIGIT_FINALS[Number(digits.at(-1))];
|
|
40
|
+
if (zeros >= 4) return GROUP_FINALS[Math.min(Math.floor(zeros / 4), GROUP_FINALS.length) - 1];
|
|
41
|
+
return PLACE_FINALS[zeros];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** @param {string} word */
|
|
45
|
+
export function finalOf(word) {
|
|
46
|
+
if (!word) return null;
|
|
47
|
+
if (/[0-9]$/.test(word)) return digitFinal(/[0-9]+$/.exec(word)[0]);
|
|
48
|
+
return hangul.finalOf(word.at(-1));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** @param {string} word @param {string} following */
|
|
52
|
+
export function josaSwap(word, following) {
|
|
53
|
+
const final = finalOf(word);
|
|
54
|
+
if (final === null || !following) return null;
|
|
55
|
+
for (const [withFinal, withoutFinal] of PAIRS) {
|
|
56
|
+
for (const form of [withFinal, withoutFinal]) {
|
|
57
|
+
if (!following.startsWith(form)) continue;
|
|
58
|
+
const rest = following.slice(form.length);
|
|
59
|
+
if (rest && ALNUM.test(rest[0])) break;
|
|
60
|
+
const wanted = withFinal === "으로"
|
|
61
|
+
? (final === hangul.NONE || final === hangul.RIEUL ? withoutFinal : withFinal)
|
|
62
|
+
: (final ? withFinal : withoutFinal);
|
|
63
|
+
return wanted === form ? null : [form, wanted];
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** @param {string} word @param {string} following */
|
|
70
|
+
export function fitJosa(word, following) {
|
|
71
|
+
const swap = josaSwap(word, following);
|
|
72
|
+
return swap ? swap[1] + following.slice(swap[0].length) : following;
|
|
73
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/** 파이썬 `analysis/grammar/register.py`의 투영. 글의 문체를 판별하고 문장 끝 서술어를 맞춘다. */
|
|
3
|
+
import * as hangul from "./hangul.js";
|
|
4
|
+
import { HAEYO, HANDA, HAPNIDA, REGISTERS, parsePredicate, render } from "./ending.js";
|
|
5
|
+
|
|
6
|
+
export const MIXED = "섞임";
|
|
7
|
+
export const NONE = "없음";
|
|
8
|
+
const SENTENCE_END = /([가-힣]+)([.?!]+)(["”’\)\]]*)(?=\s|$)/g;
|
|
9
|
+
const WORD_BEFORE = /(\S+)\s+$/;
|
|
10
|
+
const SKIP_LINE = /^\s*(#|\||```|---)/;
|
|
11
|
+
const TEMPLATE_WORD = /\{([가-힣]+)\}/g;
|
|
12
|
+
const BARE_LINE_END = /([가-힣]+)$/gm;
|
|
13
|
+
|
|
14
|
+
/** @param {string} word */
|
|
15
|
+
export function registerOfWord(word) {
|
|
16
|
+
if (word.endsWith("습니다") || (word.endsWith("니다") && word.length >= 3 && hangul.finalOf(word.at(-3)) === hangul.BIEUP)) {
|
|
17
|
+
return HAPNIDA;
|
|
18
|
+
}
|
|
19
|
+
if (word.endsWith("요")) return HAEYO;
|
|
20
|
+
if (word.endsWith("다")) return HANDA;
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** @param {string} text */
|
|
25
|
+
export function lastWord(text) {
|
|
26
|
+
const body = text.trimEnd().replace(/[.?!"”’\)\]」』]+$/, "");
|
|
27
|
+
return /([가-힣]+)$/.exec(body)?.[1] ?? "";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** @param {string[]} words @param {number} minShare @returns {[string, number]} */
|
|
31
|
+
export function documentRegister(words, minShare) {
|
|
32
|
+
const counted = new Map();
|
|
33
|
+
for (const word of words) {
|
|
34
|
+
const found = registerOfWord(word);
|
|
35
|
+
if (found) counted.set(found, (counted.get(found) ?? 0) + 1);
|
|
36
|
+
}
|
|
37
|
+
const total = [...counted.values()].reduce((sum, count) => sum + count, 0);
|
|
38
|
+
if (!total) return [NONE, 0];
|
|
39
|
+
const [register, count] = [...counted.entries()].sort((a, b) => b[1] - a[1] || REGISTERS.indexOf(b[0]) - REGISTERS.indexOf(a[0]))[0];
|
|
40
|
+
const share = count / total;
|
|
41
|
+
return [share >= minShare ? register : MIXED, share];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** @param {string} line @param {string} target @returns {[string, number, number]} */
|
|
45
|
+
export function convertLine(line, target) {
|
|
46
|
+
let converted = 0;
|
|
47
|
+
let skipped = 0;
|
|
48
|
+
const text = line.replace(SENTENCE_END, (whole, word, punct, close, offset) => {
|
|
49
|
+
if (registerOfWord(word) === null) return whole;
|
|
50
|
+
const before = WORD_BEFORE.exec(line.slice(0, offset));
|
|
51
|
+
const value = parsePredicate(word, before ? before[1] : null);
|
|
52
|
+
if (!value) {
|
|
53
|
+
skipped += 1;
|
|
54
|
+
return whole;
|
|
55
|
+
}
|
|
56
|
+
converted += 1;
|
|
57
|
+
return render(value, target) + punct + close;
|
|
58
|
+
});
|
|
59
|
+
return [text, converted, skipped];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** @param {string} text @param {string} target @returns {{text: string, converted: number, skipped: number}} */
|
|
63
|
+
export function convertRegister(text, target) {
|
|
64
|
+
if (!REGISTERS.includes(target)) throw new Error(`모르는 문체: ${target}. ${REGISTERS.join(", ")} 가운데 하나다`);
|
|
65
|
+
const out = [];
|
|
66
|
+
let converted = 0;
|
|
67
|
+
let skipped = 0;
|
|
68
|
+
let inFence = false;
|
|
69
|
+
for (const line of text.split("\n")) {
|
|
70
|
+
if (line.trimStart().startsWith("```")) {
|
|
71
|
+
inFence = !inFence;
|
|
72
|
+
out.push(line);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (inFence || SKIP_LINE.test(line)) {
|
|
76
|
+
out.push(line);
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
const [changed, found, missed] = convertLine(line, target);
|
|
80
|
+
out.push(changed);
|
|
81
|
+
converted += found;
|
|
82
|
+
skipped += missed;
|
|
83
|
+
}
|
|
84
|
+
return { text: out.join("\n"), converted, skipped };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** 문형의 중괄호 속 활용 자리까지 문체를 바꾼다. @param {string} text @param {string} target */
|
|
88
|
+
export function convertTemplate(text, target) {
|
|
89
|
+
const result = convertRegister(text, target);
|
|
90
|
+
let converted = result.converted;
|
|
91
|
+
let skipped = result.skipped;
|
|
92
|
+
const bare = result.text.replace(BARE_LINE_END, (whole, word, offset) => {
|
|
93
|
+
if (registerOfWord(word) === null) return whole;
|
|
94
|
+
const lineStart = result.text.lastIndexOf("\n", offset) + 1;
|
|
95
|
+
const before = WORD_BEFORE.exec(result.text.slice(lineStart, offset));
|
|
96
|
+
const value = parsePredicate(word, before ? before[1] : null);
|
|
97
|
+
if (!value) {
|
|
98
|
+
skipped += 1;
|
|
99
|
+
return whole;
|
|
100
|
+
}
|
|
101
|
+
converted += 1;
|
|
102
|
+
return render(value, target);
|
|
103
|
+
});
|
|
104
|
+
const changed = bare.replace(TEMPLATE_WORD, (whole, word) => {
|
|
105
|
+
if (registerOfWord(word) === null) return whole;
|
|
106
|
+
const value = parsePredicate(word);
|
|
107
|
+
if (!value) {
|
|
108
|
+
skipped += 1;
|
|
109
|
+
return whole;
|
|
110
|
+
}
|
|
111
|
+
converted += 1;
|
|
112
|
+
return `{${render(value, target)}}`;
|
|
113
|
+
});
|
|
114
|
+
return { text: changed, converted, skipped };
|
|
115
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/** 파이썬 `analysis/grammar/voice.py`의 투영. 확정되는 사동과 피동의 겹만 분해한다. */
|
|
3
|
+
import { loadLines } from "../../data/load.js";
|
|
4
|
+
|
|
5
|
+
export const PASSIVE = "피동";
|
|
6
|
+
export const CAUSATIVE = "사동";
|
|
7
|
+
const CAUSATIVE_FORM = /^([가-힣]+)게\s+만들([가-힣]*)$/;
|
|
8
|
+
const CONTRACTION = new Map([
|
|
9
|
+
["이", "여"],
|
|
10
|
+
["히", "혀"],
|
|
11
|
+
["리", "려"],
|
|
12
|
+
["기", "겨"],
|
|
13
|
+
]);
|
|
14
|
+
let passiveCache = null;
|
|
15
|
+
|
|
16
|
+
function passiveLinks() {
|
|
17
|
+
if (!passiveCache) {
|
|
18
|
+
passiveCache = new Map([["되어지", "되"]]);
|
|
19
|
+
for (const stem of loadLines("passiveStems.txt")) {
|
|
20
|
+
const last = stem.at(-1);
|
|
21
|
+
const linked = CONTRACTION.has(last) ? stem.slice(0, -1) + CONTRACTION.get(last) : stem + "어";
|
|
22
|
+
passiveCache.set(linked + "지", stem);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return passiveCache;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** @param {string} surface */
|
|
29
|
+
export function decomposePassive(surface) {
|
|
30
|
+
const base = passiveLinks().get(surface);
|
|
31
|
+
return base === undefined ? null : { surface, kind: PASSIVE, base, markers: ["접미 피동", "어지"], reduced: base };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** @param {string} surface */
|
|
35
|
+
export function decomposeCausative(surface) {
|
|
36
|
+
const match = CAUSATIVE_FORM.exec(surface);
|
|
37
|
+
return match ? { surface, kind: CAUSATIVE, base: match[1], markers: ["게", "만들"], reduced: null } : null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** @param {string} surface */
|
|
41
|
+
export function decomposeVoice(surface) {
|
|
42
|
+
return decomposePassive(surface) ?? decomposeCausative(surface);
|
|
43
|
+
}
|
package/src/analysis/index.js
CHANGED
|
@@ -1,34 +1,7 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
3
|
+
* 분석 층. 문장 분리와 어절 판정을 표층 (어절과 꼬리 사전) 으로 한다. 형태소 분석기는 없다. 파이썬 analysis/__init__.py 와 같다.
|
|
4
|
+
* 지문이 필요로 하는 것은 넷이다. splitSentences, euiCount 와 euiAdjacent, longestNounRun, doublePassives.
|
|
5
5
|
*/
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* @typedef {object} Analyzer
|
|
11
|
-
* @property {string} name
|
|
12
|
-
* @property {(text: string) => import("./surface/splitSentences.js").Sentence[]} sentences
|
|
13
|
-
* @property {(sentence: string) => number} euiCount
|
|
14
|
-
* @property {(sentence: string) => number} longestNounRun
|
|
15
|
-
* @property {(sentence: string) => string[]} doublePassives
|
|
16
|
-
*/
|
|
17
|
-
|
|
18
|
-
/** @type {Analyzer} */
|
|
19
|
-
export const surfaceAnalyzer = {
|
|
20
|
-
name: "surface",
|
|
21
|
-
sentences: splitSentences,
|
|
22
|
-
euiCount: tokenize.euiCount,
|
|
23
|
-
longestNounRun: tokenize.longestNounRun,
|
|
24
|
-
doublePassives: tokenize.doublePassives,
|
|
25
|
-
};
|
|
26
|
-
|
|
27
|
-
/** @param {string} name @returns {Analyzer} */
|
|
28
|
-
export function makeAnalyzer(name) {
|
|
29
|
-
if (name === "surface") return surfaceAnalyzer;
|
|
30
|
-
if (name === "kiwi") {
|
|
31
|
-
throw new Error("kiwi 분석기는 파이썬 패키지에 있다 (pip install hanlint[kiwi]). npm 에서는 --analyzer surface 를 쓴다");
|
|
32
|
-
}
|
|
33
|
-
throw new Error(`모르는 분석기: ${name}. surface 를 쓴다`);
|
|
34
|
-
}
|
|
6
|
+
export { splitSentences } from "./splitSentences.js";
|
|
7
|
+
export { doublePassives, doublePassiveSpans, euiAdjacent, euiCount, longestNounRun } from "./tokenize.js";
|
|
@@ -1,17 +1,21 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
/**
|
|
3
|
-
* 어절 단위 근사. 조사와 어미 꼬리 사전으로 명사 어절을 가려낸다. 파이썬 analysis/
|
|
3
|
+
* 어절 단위 근사. 조사와 어미 꼬리 사전으로 명사 어절을 가려낸다. 파이썬 analysis/tokenize.py 와 같은 판정이다.
|
|
4
4
|
* 띄어 쓴 조사와 계사는 앞 어절에 붙은 것으로, 영문 어절 연속은 한 덩어리로, 수사와 바로 뒤 단위는 수량으로 본다.
|
|
5
5
|
*/
|
|
6
|
-
import { loadLines } from "
|
|
7
|
-
import { splitWords, stripChars } from "
|
|
6
|
+
import { loadLines } from "../data/load.js";
|
|
7
|
+
import { splitWords, stripChars } from "../text.js";
|
|
8
8
|
|
|
9
9
|
const HANGUL = /[가-힣]/;
|
|
10
|
+
const HANGUL_WORD = /^[가-힣]+$/;
|
|
10
11
|
const WORD_CHARS = /^[가-힣A-Za-z0-9]+$/;
|
|
11
|
-
|
|
12
|
+
/** 관형격 조사 의. 영문과 숫자 뒤 (API의, 600MiB의) 와 닫는 괄호 뒤 (L7(HTTP)의) 도 센다. 파이썬 tokenize.py 와 같다. */
|
|
13
|
+
const GENITIVE = /([가-힣A-Za-z0-9]+|\))의(?=[\s,.)\]]|$)/g;
|
|
12
14
|
const SPACED_GENITIVE = /(?<=\s)의(?=\s)/g;
|
|
13
15
|
const COPULA = /^(?:이(?:고|며|다|라|란|면|라서|므로|지만|어서|었다|었고)|인|인데|입니다|였다)$/;
|
|
14
16
|
const DIGITS = /^\d+$/;
|
|
17
|
+
/** 수에 단위가 붙은 어절 (2012년, 30분쯤, 제16호, 100명). 수량이라 명사 나열에서 세지도 끊지도 않는다. */
|
|
18
|
+
const QUANTITY = /^제?\d[\d,.]*[가-힣]{1,3}$/;
|
|
15
19
|
const OPENERS = "([{\"“‘'";
|
|
16
20
|
const CLOSERS = ",.?!;:)]}\"”’'";
|
|
17
21
|
const EDGE_PUNCTUATION = ".,?!:;\"'“”‘’()[]{}<>";
|
|
@@ -58,11 +62,39 @@ export function numerals() {
|
|
|
58
62
|
return numeralCache;
|
|
59
63
|
}
|
|
60
64
|
|
|
65
|
+
/** @type {Set<string> | null} */
|
|
66
|
+
let nonNounCache = null;
|
|
67
|
+
export function nonNouns() {
|
|
68
|
+
if (!nonNounCache) nonNounCache = new Set(loadLines("nonNouns.txt"));
|
|
69
|
+
return nonNounCache;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** @type {Set<string> | null} */
|
|
73
|
+
let inNounCache = null;
|
|
74
|
+
export function inNouns() {
|
|
75
|
+
if (!inNounCache) inNounCache = new Set(loadLines("inNouns.txt"));
|
|
76
|
+
return inNounCache;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* `도구인`, `규모인` 처럼 명사에 계사 관형형 인 이 붙은 어절. 서술어라 명사 나열을 끊는다. 어간이 두 음절 이상일 때만이고
|
|
81
|
+
* 외국인 같은 세 음절 명사는 data/inNouns.txt 가 뺀다. 파이썬 tokenize.py 와 같다.
|
|
82
|
+
* @param {string} core
|
|
83
|
+
*/
|
|
84
|
+
export function isCopulaAdnominal(core) {
|
|
85
|
+
return core.length >= 3 && core.endsWith("인") && HANGUL_WORD.test(core) && !inNouns().has(core);
|
|
86
|
+
}
|
|
87
|
+
|
|
61
88
|
/** @param {string} core */
|
|
62
89
|
export function isNumeral(core) {
|
|
63
90
|
return numerals().has(core) || DIGITS.test(core);
|
|
64
91
|
}
|
|
65
92
|
|
|
93
|
+
/** 단위가 붙은 수. 수사와 달리 뒤 어절을 단위로 삼지 않는다. @param {string} core */
|
|
94
|
+
export function isQuantity(core) {
|
|
95
|
+
return QUANTITY.test(core);
|
|
96
|
+
}
|
|
97
|
+
|
|
66
98
|
/** @param {string} text @returns {Word[]} */
|
|
67
99
|
export function words(text) {
|
|
68
100
|
return splitWords(text).map((raw) => {
|
|
@@ -93,7 +125,11 @@ export function isBareNoun(core) {
|
|
|
93
125
|
return tailOf(core, "josa.txt") === null && tailOf(core, "verbTails.txt") === null;
|
|
94
126
|
}
|
|
95
127
|
|
|
96
|
-
/**
|
|
128
|
+
/**
|
|
129
|
+
* 명사 어절 연속의 최대 길이. 의존명사와 관형사와 부사 (`수`, `몇`, `직접`) 는 조사도 어미도 안 붙어
|
|
130
|
+
* 표층으로는 명사로 보이지만 명사 쌓기의 재료가 아니다. data/nonNouns.txt 가 그 목록이고 연속을 끊는다.
|
|
131
|
+
* @param {string} text
|
|
132
|
+
*/
|
|
97
133
|
export function longestNounRun(text) {
|
|
98
134
|
let longest = 0;
|
|
99
135
|
let run = 0;
|
|
@@ -104,9 +140,9 @@ export function longestNounRun(text) {
|
|
|
104
140
|
run = 0;
|
|
105
141
|
previousAscii = false;
|
|
106
142
|
}
|
|
107
|
-
const transparent = isNumeral(word.core) || afterNumeral;
|
|
143
|
+
const transparent = isNumeral(word.core) || afterNumeral || isQuantity(word.core);
|
|
108
144
|
afterNumeral = isNumeral(word.core);
|
|
109
|
-
if (word.particle || !isBareNoun(word.core)) {
|
|
145
|
+
if (word.particle || nonNouns().has(word.core) || !isBareNoun(word.core) || isCopulaAdnominal(word.core)) {
|
|
110
146
|
run = 0;
|
|
111
147
|
previousAscii = false;
|
|
112
148
|
} else if (!transparent) {
|
|
@@ -123,13 +159,26 @@ export function longestNounRun(text) {
|
|
|
123
159
|
return longest;
|
|
124
160
|
}
|
|
125
161
|
|
|
126
|
-
/** 관형격 조사 의 의
|
|
127
|
-
export function
|
|
128
|
-
|
|
162
|
+
/** 관형격 조사 의 가 붙은 자리 [시작, 끝]. 정의, 회의 처럼 의 로 끝나는 낱말은 빼고 띄어 쓴 의 는 넣는다. @param {string} text @returns {[number, number][]} */
|
|
163
|
+
export function genitiveSpans(text) {
|
|
164
|
+
/** @type {[number, number][]} */
|
|
165
|
+
const spans = [];
|
|
129
166
|
for (const match of text.matchAll(GENITIVE)) {
|
|
130
|
-
if (!euiNouns().has(
|
|
167
|
+
if (!euiNouns().has(match[1] + "의")) spans.push([/** @type {number} */ (match.index), /** @type {number} */ (match.index) + match[0].length]);
|
|
131
168
|
}
|
|
132
|
-
|
|
169
|
+
for (const match of text.matchAll(SPACED_GENITIVE)) spans.push([/** @type {number} */ (match.index), /** @type {number} */ (match.index) + match[0].length]);
|
|
170
|
+
return spans.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** 의 로 끝나는 어절 둘이 붙어 있는가 (회사의 팀의). 사이가 공백뿐이어야 한다. @param {string} text */
|
|
174
|
+
export function euiAdjacent(text) {
|
|
175
|
+
const spans = genitiveSpans(text);
|
|
176
|
+
return spans.some((span, index) => index > 0 && /^\s+$/.test(text.slice(spans[index - 1][1], span[0])));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** 관형격 조사 의 의 수. @param {string} text */
|
|
180
|
+
export function euiCount(text) {
|
|
181
|
+
return genitiveSpans(text).length;
|
|
133
182
|
}
|
|
134
183
|
|
|
135
184
|
/** 초성과 중성이 정해진 한글 음절 28개의 정규식 범위. @param {number} initial @param {number} vowel */
|
|
@@ -156,7 +205,15 @@ export function doublePassivePattern() {
|
|
|
156
205
|
return passivePattern;
|
|
157
206
|
}
|
|
158
207
|
|
|
208
|
+
/** 이중 피동의 [시작, 끝, `되어지` 꼴 표층형]. @param {string} text @returns {[number, number, string][]} */
|
|
209
|
+
export function doublePassiveSpans(text) {
|
|
210
|
+
return [...text.matchAll(doublePassivePattern())].map((match) => {
|
|
211
|
+
const start = /** @type {number} */ (match.index);
|
|
212
|
+
return [start, start + match[0].length, `${match[1]}지`];
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
159
216
|
/** 이중 피동의 표층형을 `되어지` 꼴로. @param {string} text */
|
|
160
217
|
export function doublePassives(text) {
|
|
161
|
-
return
|
|
218
|
+
return doublePassiveSpans(text).map(([, , surface]) => surface);
|
|
162
219
|
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* 이미 있는 지적을 잠가 두고 새로 생긴 것만 막는다. 파이썬 `hanlint/baseline/store.py` 의 투영이다.
|
|
4
|
+
* 왜 줄 번호가 아니라 글자인지, 왜 사람이 읽는 꼴인지는 그 파일의 docstring 이 정본이다.
|
|
5
|
+
*/
|
|
6
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
7
|
+
import { dirname, posix, relative, resolve, sep } from "node:path";
|
|
8
|
+
|
|
9
|
+
/** 기본 파일 이름. 저장소에 커밋해서 팀이 함께 본다. */
|
|
10
|
+
export const DEFAULT_NAME = ".hanlint-baseline.json";
|
|
11
|
+
export const VERSION = 1;
|
|
12
|
+
|
|
13
|
+
/** 줄바꿈과 이어진 공백을 하나로 눕힌다. @param {string} quote */
|
|
14
|
+
export function normalizeQuote(quote) {
|
|
15
|
+
return quote.split(/\s+/).filter(Boolean).join(" ");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** @param {import("../rules/finding.js").Finding} finding */
|
|
19
|
+
export function keyOf(finding) {
|
|
20
|
+
return `${finding.rule}\u0000${normalizeQuote(finding.quote)}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** 잠금 파일이 있는 폴더 기준의 상대 경로. @param {string} path @param {string | null} target */
|
|
24
|
+
export function pathKey(path, target) {
|
|
25
|
+
if (!target || !path || path.startsWith("<")) return path;
|
|
26
|
+
return relative(dirname(resolve(target)), resolve(path)).split(sep).join(posix.sep);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class Baseline {
|
|
30
|
+
/** @param {Map<string, Set<string>>} [locked] @param {string | null} [source] */
|
|
31
|
+
constructor(locked = new Map(), source = null) {
|
|
32
|
+
this.locked = locked;
|
|
33
|
+
this.source = source;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
get count() {
|
|
37
|
+
let total = 0;
|
|
38
|
+
for (const entries of this.locked.values()) total += entries.size;
|
|
39
|
+
return total;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** @param {string} path @param {import("../rules/finding.js").Finding} finding */
|
|
43
|
+
isLocked(path, finding) {
|
|
44
|
+
return (this.locked.get(pathKey(path, this.source)) ?? new Set()).has(keyOf(finding));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** 잠기지 않은 지적만 남긴다. @param {string} path @param {import("../rules/finding.js").Finding[]} findings */
|
|
48
|
+
keep(path, findings) {
|
|
49
|
+
return findings.filter((finding) => !this.isLocked(path, finding));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** @param {Map<string, import("../rules/finding.js").Finding[]>} results @param {string | null} [target] */
|
|
54
|
+
export function build(results, target = null) {
|
|
55
|
+
/** @type {Map<string, Set<string>>} */
|
|
56
|
+
const locked = new Map();
|
|
57
|
+
for (const [path, findings] of results) {
|
|
58
|
+
if (findings.length) locked.set(pathKey(path, target), new Set(findings.map(keyOf)));
|
|
59
|
+
}
|
|
60
|
+
return new Baseline(locked, target ? resolve(target) : null);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 사람이 읽고 리뷰하는 꼴. 정렬해 담아 같은 입력에 같은 파일이 나온다. @param {Baseline} baseline */
|
|
64
|
+
export function render(baseline) {
|
|
65
|
+
/** @type {Record<string, {rule: string, quote: string}[]>} */
|
|
66
|
+
const files = {};
|
|
67
|
+
let locked = 0;
|
|
68
|
+
for (const path of [...baseline.locked.keys()].sort()) {
|
|
69
|
+
const entries = [...(baseline.locked.get(path) ?? [])].sort();
|
|
70
|
+
if (!entries.length) continue;
|
|
71
|
+
files[path] = entries.map((entry) => {
|
|
72
|
+
const [rule, quote] = entry.split("\u0000");
|
|
73
|
+
return { rule, quote };
|
|
74
|
+
});
|
|
75
|
+
locked += entries.length;
|
|
76
|
+
}
|
|
77
|
+
return JSON.stringify({ version: VERSION, locked, files }, null, 2);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** @param {string} text @param {string | null} [source] */
|
|
81
|
+
export function parse(text, source = null) {
|
|
82
|
+
const data = JSON.parse(text);
|
|
83
|
+
if (data.version !== VERSION) throw new Error(`모르는 baseline 판 ${data.version}. hanlint baseline 으로 다시 만든다`);
|
|
84
|
+
/** @type {Map<string, Set<string>>} */
|
|
85
|
+
const locked = new Map();
|
|
86
|
+
for (const [path, entries] of Object.entries(data.files ?? {})) {
|
|
87
|
+
locked.set(path, new Set(/** @type {{rule: string, quote: string}[]} */ (entries).map((e) => `${e.rule}\u0000${e.quote}`)));
|
|
88
|
+
}
|
|
89
|
+
return new Baseline(locked, source);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** @param {string} path */
|
|
93
|
+
export function load(path) {
|
|
94
|
+
if (!existsSync(path)) {
|
|
95
|
+
const error = /** @type {NodeJS.ErrnoException} */ (new Error(`${path} 를 찾지 못했다`));
|
|
96
|
+
error.code = "ENOENT";
|
|
97
|
+
error.path = path;
|
|
98
|
+
throw error;
|
|
99
|
+
}
|
|
100
|
+
return parse(readFileSync(path, "utf-8"), resolve(path));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** 지금 글에 더 없는 잠금을 지운다. @param {Baseline} baseline @param {Map<string, import("../rules/finding.js").Finding[]>} results */
|
|
104
|
+
export function prune(baseline, results) {
|
|
105
|
+
const kept = new Map(baseline.locked);
|
|
106
|
+
for (const [path, findings] of results) {
|
|
107
|
+
const key = pathKey(path, baseline.source);
|
|
108
|
+
const present = new Set(findings.map(keyOf));
|
|
109
|
+
const remaining = new Set([...(kept.get(key) ?? [])].filter((entry) => present.has(entry)));
|
|
110
|
+
if (remaining.size) kept.set(key, remaining);
|
|
111
|
+
else kept.delete(key);
|
|
112
|
+
}
|
|
113
|
+
return new Baseline(kept, baseline.source);
|
|
114
|
+
}
|