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,34 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* 분석기. 지문 생성이 필요로 하는 넷만 드러낸다. sentences, euiCount, longestNounRun, doublePassives.
|
|
4
|
+
* npm 에는 의존성 0 의 surface 만 있다. kiwi 정밀 모드는 파이썬 패키지 (`pip install hanlint[kiwi]`) 에 있다.
|
|
5
|
+
*/
|
|
6
|
+
import { splitSentences } from "./surface/splitSentences.js";
|
|
7
|
+
import * as tokenize from "./surface/tokenize.js";
|
|
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
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/** 종결 부호 뒤 공백에서 문장을 나눈다. 파이썬 analysis/surface/splitSentences.py 와 같다. */
|
|
3
|
+
|
|
4
|
+
const TERMINAL = /[.?!]+\s+(?=\S)/g;
|
|
5
|
+
const ABBREVIATION = /(?:^|[^A-Za-z])[A-Za-z]$/;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @typedef {object} Sentence
|
|
9
|
+
* @property {string} text
|
|
10
|
+
* @property {number} start 분석에 넣은 문자열 안의 시작 오프셋
|
|
11
|
+
* @property {number} end
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** @param {string} text @returns {Sentence[]} */
|
|
15
|
+
export function splitSentences(text) {
|
|
16
|
+
/** @type {Sentence[]} */
|
|
17
|
+
const sentences = [];
|
|
18
|
+
let start = 0;
|
|
19
|
+
for (const match of text.matchAll(TERMINAL)) {
|
|
20
|
+
const index = /** @type {number} */ (match.index);
|
|
21
|
+
const before = text.slice(0, index);
|
|
22
|
+
if (ABBREVIATION.test(before)) continue;
|
|
23
|
+
const end = index + match[0].trimEnd().length;
|
|
24
|
+
const segment = text.slice(start, end);
|
|
25
|
+
const piece = segment.trim();
|
|
26
|
+
if (piece) sentences.push({ text: piece, start: start + (segment.length - segment.trimStart().length), end });
|
|
27
|
+
start = index + match[0].length;
|
|
28
|
+
}
|
|
29
|
+
const rest = text.slice(start);
|
|
30
|
+
const tail = rest.trim();
|
|
31
|
+
if (tail) {
|
|
32
|
+
const lead = rest.length - rest.trimStart().length;
|
|
33
|
+
sentences.push({ text: tail, start: start + lead, end: start + lead + tail.length });
|
|
34
|
+
}
|
|
35
|
+
return sentences;
|
|
36
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* 어절 단위 근사. 조사와 어미 꼬리 사전으로 명사 어절을 가려낸다. 파이썬 analysis/surface/tokenize.py 와 같은 판정이다.
|
|
4
|
+
* 띄어 쓴 조사와 계사는 앞 어절에 붙은 것으로, 영문 어절 연속은 한 덩어리로, 수사와 바로 뒤 단위는 수량으로 본다.
|
|
5
|
+
*/
|
|
6
|
+
import { loadLines } from "../../data/load.js";
|
|
7
|
+
import { splitWords, stripChars } from "../../text.js";
|
|
8
|
+
|
|
9
|
+
const HANGUL = /[가-힣]/;
|
|
10
|
+
const WORD_CHARS = /^[가-힣A-Za-z0-9]+$/;
|
|
11
|
+
const GENITIVE = /([가-힣]+)의(?=[\s,.)\]]|$)/g;
|
|
12
|
+
const SPACED_GENITIVE = /(?<=\s)의(?=\s)/g;
|
|
13
|
+
const COPULA = /^(?:이(?:고|며|다|라|란|면|라서|므로|지만|어서|었다|었고)|인|인데|입니다|였다)$/;
|
|
14
|
+
const DIGITS = /^\d+$/;
|
|
15
|
+
const OPENERS = "([{\"“‘'";
|
|
16
|
+
const CLOSERS = ",.?!;:)]}\"”’'";
|
|
17
|
+
const EDGE_PUNCTUATION = ".,?!:;\"'“”‘’()[]{}<>";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @typedef {object} Word
|
|
21
|
+
* @property {string} core 앞뒤 부호를 뗀 어절
|
|
22
|
+
* @property {boolean} endsClause 뒤에 쉼표, 종결 부호, 닫는 괄호가 붙어 있었는가
|
|
23
|
+
* @property {boolean} opens 앞에 여는 괄호나 따옴표가 있었는가
|
|
24
|
+
* @property {boolean} particle 띄어 쓴 조사나 계사인가
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** @type {Map<string, string[]>} */
|
|
28
|
+
const tailCache = new Map();
|
|
29
|
+
|
|
30
|
+
/** 긴 것부터. @param {string} name */
|
|
31
|
+
export function tails(name) {
|
|
32
|
+
let sorted = tailCache.get(name);
|
|
33
|
+
if (!sorted) {
|
|
34
|
+
sorted = [...loadLines(name)].sort((a, b) => b.length - a.length);
|
|
35
|
+
tailCache.set(name, sorted);
|
|
36
|
+
}
|
|
37
|
+
return sorted;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** @type {Set<string> | null} */
|
|
41
|
+
let josaCache = null;
|
|
42
|
+
export function josaSet() {
|
|
43
|
+
if (!josaCache) josaCache = new Set(loadLines("josa.txt"));
|
|
44
|
+
return josaCache;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** @type {Set<string> | null} */
|
|
48
|
+
let euiNounCache = null;
|
|
49
|
+
export function euiNouns() {
|
|
50
|
+
if (!euiNounCache) euiNounCache = new Set(loadLines("euiNouns.txt"));
|
|
51
|
+
return euiNounCache;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** @type {Set<string> | null} */
|
|
55
|
+
let numeralCache = null;
|
|
56
|
+
export function numerals() {
|
|
57
|
+
if (!numeralCache) numeralCache = new Set(loadLines("koreanNumbers.txt").map((line) => line.split("\t")[0]));
|
|
58
|
+
return numeralCache;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** @param {string} core */
|
|
62
|
+
export function isNumeral(core) {
|
|
63
|
+
return numerals().has(core) || DIGITS.test(core);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** @param {string} text @returns {Word[]} */
|
|
67
|
+
export function words(text) {
|
|
68
|
+
return splitWords(text).map((raw) => {
|
|
69
|
+
const core = stripChars(raw, EDGE_PUNCTUATION);
|
|
70
|
+
const particle = josaSet().has(core) || COPULA.test(core);
|
|
71
|
+
return { core, endsClause: CLOSERS.includes(raw[raw.length - 1]), opens: OPENERS.includes(raw[0]), particle };
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** @param {string} core @param {string} name */
|
|
76
|
+
export function tailOf(core, name) {
|
|
77
|
+
for (const tail of tails(name)) {
|
|
78
|
+
if (core.endsWith(tail) && core.length > tail.length) return tail;
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** @param {string} core */
|
|
84
|
+
export function stripJosa(core) {
|
|
85
|
+
const tail = tailOf(core, "josa.txt");
|
|
86
|
+
return tail ? core.slice(0, -tail.length) : core;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** 조사도 어미도 붙지 않은 한글, 영문, 숫자 어절인가. @param {string} core */
|
|
90
|
+
export function isBareNoun(core) {
|
|
91
|
+
if (!core || !WORD_CHARS.test(core)) return false;
|
|
92
|
+
if (!HANGUL.test(core)) return true;
|
|
93
|
+
return tailOf(core, "josa.txt") === null && tailOf(core, "verbTails.txt") === null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** 명사 어절 연속의 최대 길이. @param {string} text */
|
|
97
|
+
export function longestNounRun(text) {
|
|
98
|
+
let longest = 0;
|
|
99
|
+
let run = 0;
|
|
100
|
+
let previousAscii = false;
|
|
101
|
+
let afterNumeral = false;
|
|
102
|
+
for (const word of words(text)) {
|
|
103
|
+
if (word.opens) {
|
|
104
|
+
run = 0;
|
|
105
|
+
previousAscii = false;
|
|
106
|
+
}
|
|
107
|
+
const transparent = isNumeral(word.core) || afterNumeral;
|
|
108
|
+
afterNumeral = isNumeral(word.core);
|
|
109
|
+
if (word.particle || !isBareNoun(word.core)) {
|
|
110
|
+
run = 0;
|
|
111
|
+
previousAscii = false;
|
|
112
|
+
} else if (!transparent) {
|
|
113
|
+
const isAscii = !HANGUL.test(word.core);
|
|
114
|
+
if (!(isAscii && previousAscii)) run += 1;
|
|
115
|
+
previousAscii = isAscii;
|
|
116
|
+
longest = Math.max(longest, run);
|
|
117
|
+
}
|
|
118
|
+
if (word.endsClause) {
|
|
119
|
+
run = 0;
|
|
120
|
+
previousAscii = false;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return longest;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** 관형격 조사 의 의 수. @param {string} text */
|
|
127
|
+
export function euiCount(text) {
|
|
128
|
+
let attached = 0;
|
|
129
|
+
for (const match of text.matchAll(GENITIVE)) {
|
|
130
|
+
if (!euiNouns().has(`${match[1]}의`)) attached += 1;
|
|
131
|
+
}
|
|
132
|
+
return attached + [...text.matchAll(SPACED_GENITIVE)].length;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** 초성과 중성이 정해진 한글 음절 28개의 정규식 범위. @param {number} initial @param {number} vowel */
|
|
136
|
+
export function syllableRange(initial, vowel) {
|
|
137
|
+
const first = 0xac00 + (initial * 21 + vowel) * 28;
|
|
138
|
+
return `${String.fromCharCode(first)}-${String.fromCharCode(first + 27)}`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const PASSIVE_TAIL = `[${syllableRange(12, 20)}${syllableRange(12, 6)}]`;
|
|
142
|
+
|
|
143
|
+
/** @type {RegExp | null} */
|
|
144
|
+
let passivePattern = null;
|
|
145
|
+
export function doublePassivePattern() {
|
|
146
|
+
if (!passivePattern) {
|
|
147
|
+
const stems = ["되어"];
|
|
148
|
+
const contraction = { 이: "여", 히: "혀", 리: "려", 기: "겨" };
|
|
149
|
+
for (const stem of loadLines("passiveStems.txt")) {
|
|
150
|
+
const last = stem[stem.length - 1];
|
|
151
|
+
stems.push(last in contraction ? stem.slice(0, -1) + contraction[last] : `${stem}어`);
|
|
152
|
+
}
|
|
153
|
+
const alternatives = [...stems].sort((a, b) => b.length - a.length).join("|");
|
|
154
|
+
passivePattern = new RegExp(`(${alternatives})${PASSIVE_TAIL}`, "g");
|
|
155
|
+
}
|
|
156
|
+
return passivePattern;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** 이중 피동의 표층형을 `되어지` 꼴로. @param {string} text */
|
|
160
|
+
export function doublePassives(text) {
|
|
161
|
+
return [...text.matchAll(doublePassivePattern())].map((match) => `${match[1]}지`);
|
|
162
|
+
}
|
package/src/cli/main.js
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* 명령줄 진입점. 파이썬 CLI 와 같은 인자, 같은 출력, 같은 종료 코드다 (0 지적 없음, 1 error 있음, 2 파일이나 설정 문제).
|
|
4
|
+
*
|
|
5
|
+
* ```
|
|
6
|
+
* hanlint 글.md [다른.md ...] 검사. 서브커맨드 없이 파일만 주면 lint 다. `-` 는 stdin
|
|
7
|
+
* hanlint fix 글.md 기계가 고칠 수 있는 지적을 원문에 적용
|
|
8
|
+
* hanlint print 글.md 지문 계층 JSON
|
|
9
|
+
* hanlint rules 규칙 목록
|
|
10
|
+
* hanlint explain <규칙> 규칙의 기술서
|
|
11
|
+
* hanlint init 주석 달린 hanlint.toml
|
|
12
|
+
* ```
|
|
13
|
+
* audit, map, profile 과 kiwi 정밀 모드는 파이썬 패키지 (pip install hanlint) 에 있다.
|
|
14
|
+
*/
|
|
15
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { dirname, isAbsolute, relative, resolve } from "node:path";
|
|
17
|
+
|
|
18
|
+
import { analyzerFor, fingerprint, lintText, ruleDoc, ruleNames, ruleSummary, version } from "../index.js";
|
|
19
|
+
import { loadConfig } from "../config/loadConfig.js";
|
|
20
|
+
import { defaultConfig } from "../config/settings.js";
|
|
21
|
+
import { applyFixes } from "../edit/applyFixes.js";
|
|
22
|
+
import { runAll } from "../rules/registry.js";
|
|
23
|
+
import { renderCompact } from "../report/compactReport.js";
|
|
24
|
+
import { LAYERS, renderFingerprintJson } from "../report/fingerprintJson.js";
|
|
25
|
+
import { renderGithub } from "../report/githubReport.js";
|
|
26
|
+
import { renderJson } from "../report/jsonReport.js";
|
|
27
|
+
import { renderText } from "../report/textReport.js";
|
|
28
|
+
|
|
29
|
+
const COMMANDS = ["lint", "fix", "print", "rules", "explain", "init"];
|
|
30
|
+
const PYTHON_ONLY = ["audit", "map", "profile", "coverage", "diff"];
|
|
31
|
+
const FORMATS = ["text", "compact", "json", "github"];
|
|
32
|
+
const SEVERITIES = ["all", "error", "notice"];
|
|
33
|
+
const ANALYZER_CHOICES = ["surface", "kiwi"];
|
|
34
|
+
const STDIN = "-";
|
|
35
|
+
const STDIN_NAME = "<stdin>";
|
|
36
|
+
const THRESHOLD_FIELDS = [
|
|
37
|
+
"fragmentRun",
|
|
38
|
+
"introMaxParagraphs",
|
|
39
|
+
"headingUniformRatio",
|
|
40
|
+
"nounPileMin",
|
|
41
|
+
"endingRun",
|
|
42
|
+
"factListMinSentences",
|
|
43
|
+
"factListMaxMeanLength",
|
|
44
|
+
"topicBreakMinSentences",
|
|
45
|
+
"longSentenceMax",
|
|
46
|
+
"duplicateBlockRatio",
|
|
47
|
+
"firstResultMaxParagraphs",
|
|
48
|
+
"sectionResultMinParagraphs",
|
|
49
|
+
];
|
|
50
|
+
const FLOAT_FIELDS = new Set(["headingUniformRatio", "factListMaxMeanLength"]);
|
|
51
|
+
|
|
52
|
+
const USAGE = `사용법: hanlint 글.md [다른.md ...] [--format text|compact|json|github] [--severity all|error|notice] [--errors-only]
|
|
53
|
+
[--config 파일] [--disable 규칙] [--output 파일] [--quiet] [--path 이름 (stdin 의 이름)]
|
|
54
|
+
hanlint fix 글.md [--dry-run]
|
|
55
|
+
hanlint print 글.md [--layer all|sentences|paragraphs|sections|document]
|
|
56
|
+
hanlint rules [--names]
|
|
57
|
+
hanlint explain <규칙>
|
|
58
|
+
hanlint init [--path hanlint.toml] [--force]
|
|
59
|
+
hanlint --version
|
|
60
|
+
|
|
61
|
+
한국어 글에서 반복되는 결함을 결정적으로 잡는다. 종료 코드는 0 (지적 없음), 1 (error 지적), 2 (파일이나 설정 문제).
|
|
62
|
+
audit, map, profile 과 kiwi 정밀 모드는 파이썬 패키지 (pip install hanlint) 에 있다.`;
|
|
63
|
+
|
|
64
|
+
/** @type {Record<string, "value" | "list" | "flag">} */
|
|
65
|
+
const OPTION_KINDS = {
|
|
66
|
+
"--format": "value",
|
|
67
|
+
"--config": "value",
|
|
68
|
+
"--disable": "list",
|
|
69
|
+
"--analyzer": "value",
|
|
70
|
+
"--output": "value",
|
|
71
|
+
"--no-color": "flag",
|
|
72
|
+
"--quiet": "flag",
|
|
73
|
+
"--severity": "value",
|
|
74
|
+
"--errors-only": "flag",
|
|
75
|
+
"--path": "value",
|
|
76
|
+
"--layer": "value",
|
|
77
|
+
"--dry-run": "flag",
|
|
78
|
+
"--names": "flag",
|
|
79
|
+
"--force": "flag",
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
class UsageError extends Error {}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* @param {string[]} args
|
|
86
|
+
* @returns {{ options: Record<string, string | string[] | boolean>, positionals: string[] }}
|
|
87
|
+
*/
|
|
88
|
+
function parseArgs(args) {
|
|
89
|
+
/** @type {Record<string, string | string[] | boolean>} */
|
|
90
|
+
const options = {};
|
|
91
|
+
/** @type {string[]} */
|
|
92
|
+
const positionals = [];
|
|
93
|
+
for (let i = 0; i < args.length; i++) {
|
|
94
|
+
let arg = args[i];
|
|
95
|
+
if (!arg.startsWith("--")) {
|
|
96
|
+
positionals.push(arg);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
let inlineValue = null;
|
|
100
|
+
const eq = arg.indexOf("=");
|
|
101
|
+
if (eq > 0) {
|
|
102
|
+
inlineValue = arg.slice(eq + 1);
|
|
103
|
+
arg = arg.slice(0, eq);
|
|
104
|
+
}
|
|
105
|
+
const kind = OPTION_KINDS[arg];
|
|
106
|
+
if (!kind) throw new UsageError(`모르는 옵션: ${arg}`);
|
|
107
|
+
if (kind === "flag") {
|
|
108
|
+
options[arg] = true;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
const value = inlineValue ?? args[++i];
|
|
112
|
+
if (value === undefined) throw new UsageError(`${arg} 에 값이 없다`);
|
|
113
|
+
if (kind === "list") {
|
|
114
|
+
const list = /** @type {string[]} */ (options[arg] ?? []);
|
|
115
|
+
list.push(value);
|
|
116
|
+
options[arg] = list;
|
|
117
|
+
} else {
|
|
118
|
+
options[arg] = value;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return { options, positionals };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** 서브커맨드 없이 파일이나 옵션만 주면 lint 로 본다. @param {string[]} argv */
|
|
125
|
+
export function normalizeArgv(argv) {
|
|
126
|
+
if (!argv.length) return ["lint"];
|
|
127
|
+
if (COMMANDS.includes(argv[0]) || PYTHON_ONLY.includes(argv[0]) || ["-h", "--help", "--version"].includes(argv[0])) return argv;
|
|
128
|
+
return ["lint", ...argv];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** @param {string} value @param {string[]} choices @param {string} option */
|
|
132
|
+
function choose(value, choices, option) {
|
|
133
|
+
if (!choices.includes(value)) throw new UsageError(`${option} 는 ${choices.join(", ")} 가운데 하나다: ${value}`);
|
|
134
|
+
return value;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** @param {string} text @param {string | undefined} output */
|
|
138
|
+
function emit(text, output) {
|
|
139
|
+
if (output === undefined) {
|
|
140
|
+
process.stdout.write(`${text}\n`);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
writeFileSync(output, text.endsWith("\n") ? text : `${text}\n`, "utf-8");
|
|
144
|
+
process.stderr.write(`${output} 에 썼다\n`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** 설정을 찾기 시작할 폴더. 첫 실제 파일의 폴더, 전부 stdin 이면 현재 폴더. @param {string[]} paths */
|
|
148
|
+
function startFolder(paths) {
|
|
149
|
+
for (const path of paths) if (path !== STDIN) return dirname(resolve(path));
|
|
150
|
+
return process.cwd();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** @param {Record<string, string | string[] | boolean>} options @param {string[]} paths */
|
|
154
|
+
function configFrom(options, paths) {
|
|
155
|
+
const config = loadConfig(/** @type {string | undefined} */ (options["--config"]) ?? null, startFolder(paths));
|
|
156
|
+
for (const rule of /** @type {string[]} */ (options["--disable"] ?? [])) config.disable.add(rule);
|
|
157
|
+
if (options["--analyzer"]) config.analyzer = choose(/** @type {string} */ (options["--analyzer"]), ANALYZER_CHOICES, "--analyzer");
|
|
158
|
+
return config;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** 출력 첫 줄에 적을 설정 출처. 현재 폴더 아래면 상대 경로, 아니면 그대로. @param {import("../config/settings.js").Config} config */
|
|
162
|
+
function configLabel(config) {
|
|
163
|
+
if (config.source === null) return "기본값";
|
|
164
|
+
const rel = relative(process.cwd(), config.source);
|
|
165
|
+
if (!rel || rel.startsWith("..") || isAbsolute(rel)) return config.source;
|
|
166
|
+
return rel;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** @param {Map<string, import("../rules/finding.js").Finding[]>} results */
|
|
170
|
+
function summary(results) {
|
|
171
|
+
let errors = 0;
|
|
172
|
+
let notices = 0;
|
|
173
|
+
for (const findings of results.values()) {
|
|
174
|
+
for (const f of findings) {
|
|
175
|
+
if (f.severity === "error") errors += 1;
|
|
176
|
+
else if (f.severity === "notice") notices += 1;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return `파일 ${results.size}개, error ${errors}, notice ${notices}`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** (이름, 본문). `-` 면 stdin 을 UTF-8 로 읽는다. @param {string} path @param {string} stdinName @returns {[string, string]} */
|
|
183
|
+
function readInput(path, stdinName) {
|
|
184
|
+
if (path === STDIN) return [stdinName, readFileSync(0, "utf-8")];
|
|
185
|
+
return [path, readFileSync(path, "utf-8")];
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** @param {string[]} args */
|
|
189
|
+
function runLint(args) {
|
|
190
|
+
const { options, positionals } = parseArgs(args);
|
|
191
|
+
if (!positionals.length) throw new UsageError("검사할 마크다운 파일이 필요하다");
|
|
192
|
+
const format = choose(/** @type {string} */ (options["--format"] ?? "text"), FORMATS, "--format");
|
|
193
|
+
const severity = options["--errors-only"] ? "error" : choose(/** @type {string} */ (options["--severity"] ?? "all"), SEVERITIES, "--severity");
|
|
194
|
+
const config = configFrom(options, positionals);
|
|
195
|
+
analyzerFor(config);
|
|
196
|
+
const stdinName = /** @type {string} */ (options["--path"] ?? STDIN_NAME);
|
|
197
|
+
/** @type {Map<string, import("../rules/finding.js").Finding[]>} */
|
|
198
|
+
const results = new Map();
|
|
199
|
+
for (const path of positionals) {
|
|
200
|
+
const [name, text] = readInput(path, stdinName);
|
|
201
|
+
results.set(name, lintText(text, config, name));
|
|
202
|
+
}
|
|
203
|
+
const hasError = [...results.values()].some((findings) => findings.some((f) => f.severity === "error"));
|
|
204
|
+
/** @type {Map<string, import("../rules/finding.js").Finding[]>} */
|
|
205
|
+
const shown = new Map();
|
|
206
|
+
for (const [name, findings] of results) shown.set(name, severity === "all" ? findings : findings.filter((f) => f.severity === severity));
|
|
207
|
+
|
|
208
|
+
const output = /** @type {string | undefined} */ (options["--output"]);
|
|
209
|
+
if (format === "json") {
|
|
210
|
+
emit(renderJson(shown, configLabel(config)), output);
|
|
211
|
+
} else if (format === "github") {
|
|
212
|
+
emit([...shown].map(([name, findings]) => renderGithub(name, findings)).join("\n"), output);
|
|
213
|
+
} else {
|
|
214
|
+
const parts = [];
|
|
215
|
+
if (!options["--quiet"]) parts.push(`설정: ${configLabel(config)}`);
|
|
216
|
+
if (format === "compact") {
|
|
217
|
+
const body = [...shown]
|
|
218
|
+
.filter(([, findings]) => findings.length)
|
|
219
|
+
.map(([name, findings]) => renderCompact(name, findings))
|
|
220
|
+
.join("\n");
|
|
221
|
+
if (body) parts.push(body);
|
|
222
|
+
parts.push(summary(shown));
|
|
223
|
+
emit(parts.join("\n"), output);
|
|
224
|
+
} else {
|
|
225
|
+
parts.push([...shown].map(([name, findings]) => renderText(name, findings)).join("\n\n"));
|
|
226
|
+
if (shown.size > 1) parts.push(summary(shown));
|
|
227
|
+
emit(parts.join("\n\n"), output);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return hasError ? 1 : 0;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** @param {string[]} args */
|
|
234
|
+
function runFix(args) {
|
|
235
|
+
const { options, positionals } = parseArgs(args);
|
|
236
|
+
if (!positionals.length) throw new UsageError("고칠 마크다운 파일이 필요하다");
|
|
237
|
+
const config = configFrom(options, positionals);
|
|
238
|
+
const analyzer = analyzerFor(config);
|
|
239
|
+
void analyzer;
|
|
240
|
+
const lines = [];
|
|
241
|
+
for (const path of positionals) {
|
|
242
|
+
const text = readFileSync(path, "utf-8");
|
|
243
|
+
// notice 는 제안이라 손으로 정한다. 확정된 error 만 원문에 넣는다.
|
|
244
|
+
const result = applyFixes(
|
|
245
|
+
text,
|
|
246
|
+
runAll(fingerprint(text, config, path), config).filter((f) => f.severity === "error"),
|
|
247
|
+
);
|
|
248
|
+
for (const [line, fragment, replacement] of result.applied) lines.push(`${path}:${line} ${fragment} → ${replacement}`);
|
|
249
|
+
for (const [line, fragment, reason] of result.skipped) lines.push(`${path}:${line} 건너뜀 ${fragment}: ${reason}`);
|
|
250
|
+
if (result.text !== text && !options["--dry-run"]) writeFileSync(path, result.text, "utf-8");
|
|
251
|
+
lines.push(`${path} ${result.applied.length}곳 고침, ${result.skipped.length}곳 건너뜀` + (options["--dry-run"] ? " (미리보기, 파일은 그대로)" : ""));
|
|
252
|
+
}
|
|
253
|
+
process.stdout.write(`${lines.join("\n")}\n`);
|
|
254
|
+
return 0;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** @param {string[]} args */
|
|
258
|
+
function runPrint(args) {
|
|
259
|
+
const { options, positionals } = parseArgs(args);
|
|
260
|
+
if (positionals.length !== 1) throw new UsageError("마크다운 파일 하나가 필요하다. `-` 는 stdin");
|
|
261
|
+
const layer = choose(/** @type {string} */ (options["--layer"] ?? "all"), LAYERS, "--layer");
|
|
262
|
+
const config = configFrom(options, positionals);
|
|
263
|
+
const [name, text] = readInput(positionals[0], /** @type {string} */ (options["--path"] ?? STDIN_NAME));
|
|
264
|
+
emit(renderFingerprintJson(fingerprint(text, config, name), layer), /** @type {string | undefined} */ (options["--output"]));
|
|
265
|
+
return 0;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** @param {string[]} args */
|
|
269
|
+
function runRules(args) {
|
|
270
|
+
const { options } = parseArgs(args);
|
|
271
|
+
const names = ruleNames();
|
|
272
|
+
if (options["--names"]) {
|
|
273
|
+
process.stdout.write(`${names.join("\n")}\n`);
|
|
274
|
+
return 0;
|
|
275
|
+
}
|
|
276
|
+
const width = Math.max(...names.map((name) => name.length));
|
|
277
|
+
const lines = names.map((name) => `${name.padEnd(width)} ${ruleSummary(name)}`);
|
|
278
|
+
process.stdout.write(`${lines.join("\n")}\n\n규칙 ${names.length}개. 자세히 보려면 hanlint explain <규칙>\n`);
|
|
279
|
+
return 0;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** @param {string[]} args */
|
|
283
|
+
function runExplain(args) {
|
|
284
|
+
const { positionals } = parseArgs(args);
|
|
285
|
+
if (positionals.length !== 1) throw new UsageError("규칙 이름 하나가 필요하다. hanlint rules 로 목록을 본다");
|
|
286
|
+
const doc = ruleDoc(positionals[0]);
|
|
287
|
+
process.stdout.write(`${positionals[0]}\n\n${doc}\n`);
|
|
288
|
+
return 0;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** 주석 달린 hanlint.toml. 파이썬 `hanlint init` 과 같은 글자다. */
|
|
292
|
+
export function renderInit() {
|
|
293
|
+
const defaults = defaultConfig();
|
|
294
|
+
const lines = ["# hanlint 설정. 규칙을 끄려면 disable 에 이름을 넣는다. 규칙의 기술서는 hanlint explain <규칙>.", "", "# 규칙 목록과 한 줄 설명"];
|
|
295
|
+
for (const name of ruleNames()) lines.push(`# ${name}: ${ruleSummary(name)}`);
|
|
296
|
+
lines.push(
|
|
297
|
+
"",
|
|
298
|
+
"disable = []",
|
|
299
|
+
"",
|
|
300
|
+
"# surface 는 의존성 0 기본, kiwi 는 pip install hanlint[kiwi] 가 있을 때",
|
|
301
|
+
`analyzer = "${defaults.analyzer}"`,
|
|
302
|
+
"",
|
|
303
|
+
"# 대표 검색어를 읽을 frontmatter 필드. 없으면 keywordMissing 은 돌지 않는다",
|
|
304
|
+
'# keywordField = "primaryKeyword"',
|
|
305
|
+
"",
|
|
306
|
+
"# hanlint profile build 가 만든 파일. 있으면 참조 글과의 편차 구간을 notice 로 더한다",
|
|
307
|
+
'# profile = "profile.json"',
|
|
308
|
+
"",
|
|
309
|
+
"# 임계. 기본값의 정본은 hanlint 의 config/settings.py 다",
|
|
310
|
+
);
|
|
311
|
+
for (const name of THRESHOLD_FIELDS) {
|
|
312
|
+
const value = /** @type {number} */ (defaults[name]);
|
|
313
|
+
const shown = FLOAT_FIELDS.has(name) && Number.isInteger(value) ? value.toFixed(1) : String(value);
|
|
314
|
+
lines.push(`# ${name} = ${shown}`);
|
|
315
|
+
}
|
|
316
|
+
lines.push(
|
|
317
|
+
"",
|
|
318
|
+
"# 사전에 더할 항목. 키는 cliches, translationese, redundantPair, japaneseLoan",
|
|
319
|
+
"# [dictionary]",
|
|
320
|
+
'# cliches = ["우리의 여정"]',
|
|
321
|
+
'# translationese = [{ pattern = "에 대한 이해", fix = "를 아는 것" }]',
|
|
322
|
+
"",
|
|
323
|
+
);
|
|
324
|
+
return lines.join("\n");
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** @param {string[]} args */
|
|
328
|
+
function runInit(args) {
|
|
329
|
+
const { options } = parseArgs(args);
|
|
330
|
+
const path = /** @type {string} */ (options["--path"] ?? "hanlint.toml");
|
|
331
|
+
if (existsSync(path) && !options["--force"]) throw new Error(`${path} 가 이미 있다. 덮어쓰려면 --force`);
|
|
332
|
+
writeFileSync(path, renderInit(), "utf-8");
|
|
333
|
+
process.stdout.write(`${path} 를 만들었다. 규칙을 끄려면 disable 에 이름을 넣는다\n`);
|
|
334
|
+
return 0;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** @param {string[]} argv */
|
|
338
|
+
function dispatch(argv) {
|
|
339
|
+
const [command, ...rest] = normalizeArgv(argv);
|
|
340
|
+
if (command === "-h" || command === "--help") {
|
|
341
|
+
process.stdout.write(`${USAGE}\n`);
|
|
342
|
+
return 0;
|
|
343
|
+
}
|
|
344
|
+
if (command === "--version") {
|
|
345
|
+
process.stdout.write(`hanlint ${version}\n`);
|
|
346
|
+
return 0;
|
|
347
|
+
}
|
|
348
|
+
if (PYTHON_ONLY.includes(command)) {
|
|
349
|
+
throw new Error(`${command} 는 파이썬 패키지에 있다 (pip install hanlint). npm 은 lint, fix, print, rules, explain, init 을 제공한다`);
|
|
350
|
+
}
|
|
351
|
+
if (command === "lint") return runLint(rest);
|
|
352
|
+
if (command === "fix") return runFix(rest);
|
|
353
|
+
if (command === "print") return runPrint(rest);
|
|
354
|
+
if (command === "rules") return runRules(rest);
|
|
355
|
+
if (command === "explain") return runExplain(rest);
|
|
356
|
+
return runInit(rest);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** @param {string[]} argv @returns {number} */
|
|
360
|
+
export function main(argv) {
|
|
361
|
+
try {
|
|
362
|
+
return dispatch(argv);
|
|
363
|
+
} catch (error) {
|
|
364
|
+
if (error instanceof UsageError) {
|
|
365
|
+
process.stderr.write(`${error.message}\n\n${USAGE}\n`);
|
|
366
|
+
return 2;
|
|
367
|
+
}
|
|
368
|
+
const failure = /** @type {NodeJS.ErrnoException} */ (error);
|
|
369
|
+
if (failure.code === "ENOENT") {
|
|
370
|
+
process.stderr.write(`${failure.path} 를 찾지 못했다. 경로를 확인하거나 hanlint --help\n`);
|
|
371
|
+
return 2;
|
|
372
|
+
}
|
|
373
|
+
process.stderr.write(`${failure.message}\n`);
|
|
374
|
+
return 2;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/** 설정 파일을 찾아 읽는다. `hanlint.toml` 이거나 `pyproject.toml` 의 `[tool.hanlint]` 다. 파이썬과 같은 탐색이다. */
|
|
3
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
4
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
5
|
+
|
|
6
|
+
import { configFromMapping, defaultConfig } from "./settings.js";
|
|
7
|
+
import { parseToml } from "./toml.js";
|
|
8
|
+
|
|
9
|
+
export const CONFIG_NAME = "hanlint.toml";
|
|
10
|
+
export const PYPROJECT_NAME = "pyproject.toml";
|
|
11
|
+
|
|
12
|
+
/** @param {string} path */
|
|
13
|
+
export function readConfigFile(path) {
|
|
14
|
+
let data = parseToml(readFileSync(path, "utf-8"));
|
|
15
|
+
if (basename(path) === PYPROJECT_NAME) {
|
|
16
|
+
const tool = /** @type {Record<string, unknown>} */ (data.tool ?? {});
|
|
17
|
+
data = /** @type {Record<string, unknown>} */ (tool.hanlint ?? {});
|
|
18
|
+
}
|
|
19
|
+
const config = configFromMapping(data);
|
|
20
|
+
config.source = path;
|
|
21
|
+
return config;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** start 에서 위로 올라가며 hanlint.toml 이나 [tool.hanlint] 를 가진 pyproject.toml 을 찾는다. @param {string} start */
|
|
25
|
+
export function findConfigFile(start) {
|
|
26
|
+
let folder = resolve(start);
|
|
27
|
+
while (true) {
|
|
28
|
+
const candidate = join(folder, CONFIG_NAME);
|
|
29
|
+
if (existsSync(candidate)) return candidate;
|
|
30
|
+
const pyproject = join(folder, PYPROJECT_NAME);
|
|
31
|
+
if (existsSync(pyproject)) {
|
|
32
|
+
const data = parseToml(readFileSync(pyproject, "utf-8"));
|
|
33
|
+
const tool = /** @type {Record<string, unknown>} */ (data.tool ?? {});
|
|
34
|
+
if ("hanlint" in tool) return pyproject;
|
|
35
|
+
}
|
|
36
|
+
const parent = dirname(folder);
|
|
37
|
+
if (parent === folder) return null;
|
|
38
|
+
folder = parent;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* `path` 를 주면 그 파일만 읽는다. 안 주면 `start` (기본 현재 폴더) 에서 찾고, 없으면 기본값이다.
|
|
44
|
+
* @param {string | null} [path]
|
|
45
|
+
* @param {string | null} [start]
|
|
46
|
+
*/
|
|
47
|
+
export function loadConfig(path = null, start = null) {
|
|
48
|
+
if (path) return readConfigFile(path);
|
|
49
|
+
const found = findConfigFile(start ?? process.cwd());
|
|
50
|
+
return found ? readConfigFile(found) : defaultConfig();
|
|
51
|
+
}
|