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
package/src/regex.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* 파이썬 re 와 같은 뜻으로 쓰는 얇은 껍질. search 는 첫 자리, at 은 문자열 첫머리에서만, all 은 전부.
|
|
4
|
+
* 정규식 문법은 데이터 파일과 규칙에서 파이썬과 같은 부분집합만 쓴다 (lookbehind 포함, 이름 그룹 없음).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const BACKSLASH = String.fromCharCode(92);
|
|
8
|
+
const SPECIALS = new RegExp(`[.*+?^\${}()|[${BACKSLASH}]${BACKSLASH}${BACKSLASH}/-]`, "g");
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @typedef {object} Pattern
|
|
12
|
+
* @property {string} source
|
|
13
|
+
* @property {(text: string) => RegExpExecArray | null} search 파이썬 re.search
|
|
14
|
+
* @property {(text: string) => RegExpExecArray | null} at 파이썬 re.match (첫머리에서만)
|
|
15
|
+
* @property {(text: string) => RegExpExecArray[]} all 파이썬 re.finditer
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {string} source
|
|
20
|
+
* @param {string} [flags] u 같은 추가 플래그
|
|
21
|
+
* @returns {Pattern}
|
|
22
|
+
*/
|
|
23
|
+
export function compile(source, flags = "") {
|
|
24
|
+
const once = new RegExp(source, flags);
|
|
25
|
+
const sticky = new RegExp(source, `${flags}y`);
|
|
26
|
+
const every = new RegExp(source, `${flags}g`);
|
|
27
|
+
return {
|
|
28
|
+
source,
|
|
29
|
+
search(text) {
|
|
30
|
+
once.lastIndex = 0;
|
|
31
|
+
return once.exec(text);
|
|
32
|
+
},
|
|
33
|
+
at(text) {
|
|
34
|
+
sticky.lastIndex = 0;
|
|
35
|
+
return sticky.exec(text);
|
|
36
|
+
},
|
|
37
|
+
all(text) {
|
|
38
|
+
every.lastIndex = 0;
|
|
39
|
+
return [...text.matchAll(every)];
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 정규식 특수문자를 이스케이프한다 (파이썬 re.escape 의 쓰임새). @param {string} text */
|
|
45
|
+
export function escape(text) {
|
|
46
|
+
return text.replace(SPECIALS, (c) => BACKSLASH + c);
|
|
47
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/** 한 줄에 지적 하나. AI 와 스크립트가 읽는 꼴이다. 파이썬 report/compactReport.py 와 같은 글자다. */
|
|
3
|
+
|
|
4
|
+
/** @param {string} path @param {import("../rules/finding.js").Finding[]} findings */
|
|
5
|
+
export function renderCompact(path, findings) {
|
|
6
|
+
return findings
|
|
7
|
+
.map((f) => `${path}:${f.line} [${f.rule}] ${f.why}` + (f.fix ? ` 고친 뒤: ${f.fix}` : ""))
|
|
8
|
+
.join("\n");
|
|
9
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* 지문 계층의 JSON 꼴. 파이썬 report/fingerprintJson.py 와 같은 키 순서다. 층마다 한 번씩만 적고 위층은
|
|
4
|
+
* 아래층을 index 로 가리킨다. 실수는 소수 여섯째 자리로 맞춰 두 구현이 같은 글자를 낸다.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export const LAYERS = ["all", "sentences", "paragraphs", "sections", "document"];
|
|
8
|
+
|
|
9
|
+
/** @param {number | null} value */
|
|
10
|
+
function num(value) {
|
|
11
|
+
if (value === null) return null;
|
|
12
|
+
return Math.round(value * 1e6) / 1e6;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** @param {Set<string>} set */
|
|
16
|
+
function sortedList(set) {
|
|
17
|
+
return [...set].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** @param {import("../fingerprint/build.js").SentencePrint} s */
|
|
21
|
+
function sentenceDict(s) {
|
|
22
|
+
return {
|
|
23
|
+
index: s.index,
|
|
24
|
+
line: s.line,
|
|
25
|
+
text: s.text,
|
|
26
|
+
blockIndex: s.blockIndex,
|
|
27
|
+
paragraphIndex: s.paragraphIndex,
|
|
28
|
+
sectionIndex: s.sectionIndex,
|
|
29
|
+
length: s.length,
|
|
30
|
+
ending: s.ending,
|
|
31
|
+
mood: s.mood,
|
|
32
|
+
commas: s.commas,
|
|
33
|
+
connectorStart: s.connectorStart,
|
|
34
|
+
causal: s.causal,
|
|
35
|
+
deixis: [...s.deixis],
|
|
36
|
+
euiCount: s.euiCount,
|
|
37
|
+
nounRun: s.nounRun,
|
|
38
|
+
passives: [...s.passives],
|
|
39
|
+
hedges: s.hedges,
|
|
40
|
+
numbers: s.numbers,
|
|
41
|
+
topics: sortedList(s.topics),
|
|
42
|
+
promises: [...s.promises],
|
|
43
|
+
recalls: [...s.recalls],
|
|
44
|
+
countPromises: s.countPromises.map(([n, unit, text]) => [n, unit, text]),
|
|
45
|
+
readerCall: s.readerCall,
|
|
46
|
+
matches: s.matches.map((m) => ({
|
|
47
|
+
dictionary: m.dictionary,
|
|
48
|
+
text: m.text,
|
|
49
|
+
start: m.start,
|
|
50
|
+
end: m.end,
|
|
51
|
+
why: m.why,
|
|
52
|
+
source: m.source,
|
|
53
|
+
fix: m.fix,
|
|
54
|
+
})),
|
|
55
|
+
quoted: s.quoted.map(([start, end]) => [start, end]),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** @param {import("../fingerprint/build.js").ParagraphPrint} p */
|
|
60
|
+
function paragraphDict(p) {
|
|
61
|
+
return {
|
|
62
|
+
index: p.index,
|
|
63
|
+
blockIndex: p.blockIndex,
|
|
64
|
+
sectionIndex: p.sectionIndex,
|
|
65
|
+
startLine: p.startLine,
|
|
66
|
+
endLine: p.endLine,
|
|
67
|
+
sentences: p.sentences.map((s) => s.index),
|
|
68
|
+
sentenceCount: p.sentenceCount,
|
|
69
|
+
meanLength: num(p.meanLength),
|
|
70
|
+
lengthStd: num(p.lengthStd),
|
|
71
|
+
causalTotal: p.causalTotal,
|
|
72
|
+
deixisTotal: p.deixisTotal,
|
|
73
|
+
topics: sortedList(p.topics),
|
|
74
|
+
overlapWithPrevious: num(p.overlapWithPrevious),
|
|
75
|
+
followsProseDirectly: p.followsProseDirectly,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** @param {import("../fingerprint/build.js").SectionPrint} s */
|
|
80
|
+
function sectionDict(s) {
|
|
81
|
+
return {
|
|
82
|
+
index: s.index,
|
|
83
|
+
title: s.title,
|
|
84
|
+
level: s.level,
|
|
85
|
+
startLine: s.startLine,
|
|
86
|
+
paragraphs: s.paragraphs.map((p) => p.index),
|
|
87
|
+
blockKinds: [...s.blockKinds],
|
|
88
|
+
topics: sortedList(s.topics),
|
|
89
|
+
isIntro: s.isIntro,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** @param {import("../fingerprint/build.js").DocumentPrint} doc */
|
|
94
|
+
function documentDict(doc) {
|
|
95
|
+
return {
|
|
96
|
+
path: doc.path,
|
|
97
|
+
frontmatter: { ...doc.frontmatter },
|
|
98
|
+
headings: doc.headings.map(([level, text, line]) => [level, text, line]),
|
|
99
|
+
wordCount: doc.wordCount,
|
|
100
|
+
sentenceCount: doc.sentences.length,
|
|
101
|
+
paragraphCount: doc.paragraphs.length,
|
|
102
|
+
sectionCount: doc.sections.length,
|
|
103
|
+
questionCount: doc.questionCount,
|
|
104
|
+
readerCallCount: doc.readerCallCount,
|
|
105
|
+
countPromises: doc.countPromises.map(([n, unit, line, text]) => [n, unit, line, text]),
|
|
106
|
+
promises: doc.promises.map(([line, text]) => [line, text]),
|
|
107
|
+
recalls: doc.recalls.map(([line, text]) => [line, text]),
|
|
108
|
+
disabled: doc.disabled.map(([name, start, end]) => [name, start, end]),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** @param {import("../fingerprint/build.js").DocumentPrint} doc @param {string} [layer] */
|
|
113
|
+
export function fingerprintDict(doc, layer = "all") {
|
|
114
|
+
/** @type {Record<string, unknown>} */
|
|
115
|
+
const data = { version: 1, layer };
|
|
116
|
+
if (layer === "all" || layer === "document") data.document = documentDict(doc);
|
|
117
|
+
if (layer === "all" || layer === "sections") data.sections = doc.sections.map(sectionDict);
|
|
118
|
+
if (layer === "all" || layer === "paragraphs") data.paragraphs = doc.paragraphs.map(paragraphDict);
|
|
119
|
+
if (layer === "all" || layer === "sentences") data.sentences = doc.sentences.map(sentenceDict);
|
|
120
|
+
return data;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** @param {import("../fingerprint/build.js").DocumentPrint} doc @param {string} [layer] */
|
|
124
|
+
export function renderFingerprintJson(doc, layer = "all") {
|
|
125
|
+
return JSON.stringify(fingerprintDict(doc, layer), null, 2);
|
|
126
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/** GitHub Actions 의 워크플로 명령 꼴. PR 에 줄 단위 주석이 붙는다. */
|
|
3
|
+
|
|
4
|
+
/** @param {string} path @param {import("../rules/finding.js").Finding[]} findings */
|
|
5
|
+
export function renderGithub(path, findings) {
|
|
6
|
+
return findings
|
|
7
|
+
.map((f) => {
|
|
8
|
+
const level = f.severity === "error" ? "error" : "notice";
|
|
9
|
+
const message = `[${f.rule}] ${f.why}`.replace(/\n/g, " ");
|
|
10
|
+
return `::${level} file=${path},line=${f.line}::${message}`;
|
|
11
|
+
})
|
|
12
|
+
.join("\n");
|
|
13
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/** 기계가 읽는 꼴. 평가 루프의 0층 입력이다. 파이썬 json.dumps(indent=2, ensure_ascii=False) 와 같은 글자다. */
|
|
3
|
+
import { findingAsDict } from "../rules/finding.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @param {Map<string, import("../rules/finding.js").Finding[]>} results
|
|
7
|
+
* @param {string | null} [configLabel]
|
|
8
|
+
*/
|
|
9
|
+
export function renderJson(results, configLabel = null) {
|
|
10
|
+
const files = [...results].map(([path, findings]) => ({ path, findings: findings.map(findingAsDict) }));
|
|
11
|
+
/** @type {Record<string, unknown>} */
|
|
12
|
+
const data = { version: 1 };
|
|
13
|
+
if (configLabel !== null) data.config = configLabel;
|
|
14
|
+
data.files = files;
|
|
15
|
+
return JSON.stringify(data, null, 2);
|
|
16
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/** 사람이 읽는 지적 목록. `파일:줄` 로 시작해 편집기가 바로 연다. 파이썬 report/textReport.py 와 같은 글자다. */
|
|
3
|
+
|
|
4
|
+
/** @param {string} path @param {import("../rules/finding.js").Finding[]} findings */
|
|
5
|
+
export function renderText(path, findings) {
|
|
6
|
+
if (!findings.length) return `${path} 집은 자리 없음`;
|
|
7
|
+
const errors = findings.filter((f) => f.severity === "error").length;
|
|
8
|
+
const notices = findings.length - errors;
|
|
9
|
+
const summary = `${path} 집은 자리 ${errors}` + (notices ? `, 확인할 자리 ${notices}` : "");
|
|
10
|
+
const lines = [summary, ""];
|
|
11
|
+
for (const f of findings) {
|
|
12
|
+
const tag = `[${f.rule}]` + (f.severity === "notice" ? " 확인" : "");
|
|
13
|
+
lines.push(`${path}:${f.line} ${tag}`, ` ${f.quote}`, ` ${f.why}`);
|
|
14
|
+
if (f.fix) lines.push(` 고친 뒤: ${f.fix}`);
|
|
15
|
+
lines.push("");
|
|
16
|
+
}
|
|
17
|
+
lines.push(`규칙이 왜 있는지 보려면: hanlint explain ${findings[0].rule}`);
|
|
18
|
+
return lines.join("\n");
|
|
19
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { DOCUMENT, NOTICE, finding } from "../finding.js";
|
|
3
|
+
import { codeBlocksOf } from "../shared/codeBlocks.js";
|
|
4
|
+
|
|
5
|
+
export const name = "duplicateBlock";
|
|
6
|
+
const MIN_LINES = 4;
|
|
7
|
+
|
|
8
|
+
/** 줄 다중집합의 겹침 비. 2 * 공통 / (a + b). @param {string[]} a @param {string[]} b */
|
|
9
|
+
function similarity(a, b) {
|
|
10
|
+
if (!a.length || !b.length) return 0;
|
|
11
|
+
/** @type {Map<string, number>} */
|
|
12
|
+
const counts = new Map();
|
|
13
|
+
for (const line of a) counts.set(line, (counts.get(line) ?? 0) + 1);
|
|
14
|
+
let common = 0;
|
|
15
|
+
for (const line of b) {
|
|
16
|
+
const n = counts.get(line) ?? 0;
|
|
17
|
+
if (n > 0) {
|
|
18
|
+
counts.set(line, n - 1);
|
|
19
|
+
common += 1;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return (2 * common) / (a.length + b.length);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param {import("../../fingerprint/build.js").DocumentPrint} doc
|
|
27
|
+
* @param {import("../../config/settings.js").Config} config
|
|
28
|
+
*/
|
|
29
|
+
export function run(doc, config) {
|
|
30
|
+
const findings = [];
|
|
31
|
+
const blocks = codeBlocksOf(doc).filter((b) => b.lines.filter(([, line]) => line.trim()).length >= MIN_LINES);
|
|
32
|
+
for (let later = 0; later < blocks.length; later++) {
|
|
33
|
+
const current = blocks[later];
|
|
34
|
+
const currentLines = current.lines.filter(([, line]) => line.trim()).map(([, line]) => line.trim());
|
|
35
|
+
for (let earlier = 0; earlier < later; earlier++) {
|
|
36
|
+
const previous = blocks[earlier];
|
|
37
|
+
if (previous.isOutput !== current.isOutput) continue;
|
|
38
|
+
const ratio = similarity(previous.lines.filter(([, line]) => line.trim()).map(([, line]) => line.trim()), currentLines);
|
|
39
|
+
if (ratio >= config.duplicateBlockRatio) {
|
|
40
|
+
findings.push(
|
|
41
|
+
finding(name, current.startLine, currentLines[0], `${previous.startLine}번째 줄의 블록과 ${Math.round(ratio * 100)}% 같다. 다른 줄만 남기거나 앞 블록을 가리킨다`, null, NOTICE, DOCUMENT, current.index),
|
|
42
|
+
);
|
|
43
|
+
break;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return findings;
|
|
48
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { CODE, IMAGE, TABLE } from "../../document/model.js";
|
|
3
|
+
import { NOTICE, PARAGRAPH, finding } from "../finding.js";
|
|
4
|
+
|
|
5
|
+
export const name = "firstResultDistance";
|
|
6
|
+
const RESULT_KINDS = [CODE, IMAGE, TABLE];
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @param {import("../../fingerprint/build.js").DocumentPrint} doc
|
|
10
|
+
* @param {import("../../config/settings.js").Config} config
|
|
11
|
+
*/
|
|
12
|
+
export function run(doc, config) {
|
|
13
|
+
const firstResult = doc.blocks.find((b) => RESULT_KINDS.includes(b.kind));
|
|
14
|
+
if (!firstResult) return [];
|
|
15
|
+
const before = doc.paragraphs.filter((p) => p.startLine < firstResult.startLine);
|
|
16
|
+
if (before.length > config.firstResultMaxParagraphs) {
|
|
17
|
+
const over = before[config.firstResultMaxParagraphs];
|
|
18
|
+
return [
|
|
19
|
+
finding(name, over.startLine, over.sentences.length ? over.sentences[0].text : "", `첫 코드나 표까지 산문 문단이 ${before.length}개다. ${config.firstResultMaxParagraphs}개 안에 실행할 것이 온다`, null, NOTICE, PARAGRAPH, over.index),
|
|
20
|
+
];
|
|
21
|
+
}
|
|
22
|
+
return [];
|
|
23
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { DOCUMENT, NOTICE, finding } from "../finding.js";
|
|
3
|
+
import { codeBlocksOf } from "../shared/codeBlocks.js";
|
|
4
|
+
|
|
5
|
+
export const name = "inputFileSource";
|
|
6
|
+
|
|
7
|
+
const QUOTED = "\\(\\s*[\"']([^\"'\\n]+)[\"']";
|
|
8
|
+
const READS = new RegExp(
|
|
9
|
+
"\\b(?:read_csv|read_excel|read_parquet|read_json|read_sql|read_table|read_ndjson|scan_csv|scan_parquet|scan_ndjson|" +
|
|
10
|
+
"read_text|read_bytes|loadtxt|imread|load_workbook)" + QUOTED,
|
|
11
|
+
"g",
|
|
12
|
+
);
|
|
13
|
+
const OPENS = new RegExp("\\bopen" + QUOTED + "\\s*(?:,\\s*(?:mode\\s*=\\s*)?[\"']([^\"']*)[\"'])?", "g");
|
|
14
|
+
const WRITES = new RegExp(
|
|
15
|
+
"\\b(?:to_csv|to_excel|to_parquet|to_json|to_sql|write_csv|write_parquet|write_json|write_ndjson|sink_parquet|sink_csv|" +
|
|
16
|
+
"write_text|write_bytes|savefig|save|imwrite|savetxt|dump)" + QUOTED,
|
|
17
|
+
"g",
|
|
18
|
+
);
|
|
19
|
+
const SHELL_WRITES = /(?:>>?|-o|-O|--output|Out-File)\s*["']?([\w./\\-]+\.[A-Za-z0-9]+)/g;
|
|
20
|
+
const SQL_PATHS = /FROM\s+'([^']+\.(?:csv|parquet|json|xlsx))'/gi;
|
|
21
|
+
const MAKES_DIR = /(?:mkdir(?:\s+-p)?|makedirs|md)\s*\(?\s*["']?([\w./\\-]+)|Path\(\s*["']([^"']+)["']\s*\)\.mkdir/g;
|
|
22
|
+
const EXTENSIONS = new Set(
|
|
23
|
+
"csv xlsx xls parquet json jsonl txt png jpg jpeg svg gif db sqlite md py yaml yml toml html pdf zip ndjson".split(" "),
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
/** @param {string} path */
|
|
27
|
+
function fileName(path) {
|
|
28
|
+
const parts = path.replace(/\\/g, "/").replace(/\/+$/, "").split("/");
|
|
29
|
+
return parts[parts.length - 1];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** @param {string} path */
|
|
33
|
+
function isDataFile(path) {
|
|
34
|
+
if (["*", "{", "http", "://", "<", ">"].some((mark) => path.includes(mark))) return false;
|
|
35
|
+
const name = fileName(path);
|
|
36
|
+
return name.includes(".") && EXTENSIONS.has(name.split(".").pop()?.toLowerCase() ?? "");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** @param {string} path */
|
|
40
|
+
function directoryOf(path) {
|
|
41
|
+
const parts = path.replace(/\\/g, "/").split("/");
|
|
42
|
+
return parts.length > 1 && !["", ".", ".."].includes(parts[0]) ? parts[0] : null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** @param {import("../../fingerprint/build.js").DocumentPrint} doc @param {string} needle @param {number} line */
|
|
46
|
+
function mentionedBefore(doc, needle, line) {
|
|
47
|
+
return doc.sentences.some((s) => s.line < line && s.text.includes(needle));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** @param {RegExp} pattern @param {string} text @param {number} group */
|
|
51
|
+
function all(pattern, text, group) {
|
|
52
|
+
pattern.lastIndex = 0;
|
|
53
|
+
const found = [];
|
|
54
|
+
for (const match of text.matchAll(pattern)) if (match[group] !== undefined) found.push(match[group]);
|
|
55
|
+
return found;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** @param {import("../../fingerprint/build.js").DocumentPrint} doc */
|
|
59
|
+
export function run(doc) {
|
|
60
|
+
const findings = [];
|
|
61
|
+
/** @type {Set<string>} */
|
|
62
|
+
const created = new Set();
|
|
63
|
+
for (const block of codeBlocksOf(doc)) {
|
|
64
|
+
/** @type {Set<string>} */
|
|
65
|
+
const blockWrites = new Set();
|
|
66
|
+
/** @type {Set<string>} */
|
|
67
|
+
const blockDirs = new Set();
|
|
68
|
+
for (const [, code] of block.lines) {
|
|
69
|
+
for (const name2 of all(WRITES, code, 1)) blockWrites.add(fileName(name2));
|
|
70
|
+
for (const name2 of all(SHELL_WRITES, code, 1)) blockWrites.add(fileName(name2));
|
|
71
|
+
OPENS.lastIndex = 0;
|
|
72
|
+
for (const match of code.matchAll(OPENS)) {
|
|
73
|
+
const mode = match[2] ?? "r";
|
|
74
|
+
if ([..."wax"].some((flag) => mode.includes(flag))) blockWrites.add(fileName(match[1]));
|
|
75
|
+
}
|
|
76
|
+
MAKES_DIR.lastIndex = 0;
|
|
77
|
+
for (const match of code.matchAll(MAKES_DIR)) blockDirs.add((match[1] ?? match[2]).replace(/\\/g, "/").split("/")[0]);
|
|
78
|
+
}
|
|
79
|
+
for (const dir of blockDirs) created.add(dir);
|
|
80
|
+
for (const [line, code] of block.lines) {
|
|
81
|
+
OPENS.lastIndex = 0;
|
|
82
|
+
const reads = [...all(READS, code, 1), ...all(SQL_PATHS, code, 1)];
|
|
83
|
+
for (const match of code.matchAll(OPENS)) {
|
|
84
|
+
if (![..."wax"].some((flag) => (match[2] ?? "r").includes(flag))) reads.push(match[1]);
|
|
85
|
+
}
|
|
86
|
+
for (const path of reads) {
|
|
87
|
+
if (!isDataFile(path)) continue;
|
|
88
|
+
const name2 = fileName(path);
|
|
89
|
+
if (created.has(name2) || blockWrites.has(name2) || mentionedBefore(doc, name2, block.startLine)) continue;
|
|
90
|
+
findings.push(finding(name, line, code.trim(), `\`${name2}\` 을 읽는데 글 어디에서도 만들지 않았다. 독자는 여기서 파일 없음 오류로 멈춘다`, null, "error", DOCUMENT, block.index));
|
|
91
|
+
}
|
|
92
|
+
for (const path of [...all(WRITES, code, 1), ...reads]) {
|
|
93
|
+
if (["http", "://", "{", "<", ">", "*", "?"].some((mark) => path.includes(mark))) continue;
|
|
94
|
+
const directory = directoryOf(path);
|
|
95
|
+
if (!directory || created.has(directory) || mentionedBefore(doc, directory, block.startLine)) continue;
|
|
96
|
+
created.add(directory);
|
|
97
|
+
findings.push(finding(name, line, code.trim(), `\`${directory}\` 폴더를 쓰는데 글 어디에서도 만들지 않았다. 없으면 파일을 쓰다 멈춘다`, null, NOTICE, DOCUMENT, block.index));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
for (const write of blockWrites) created.add(write);
|
|
101
|
+
}
|
|
102
|
+
return findings;
|
|
103
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { loadLines } from "../../data/load.js";
|
|
3
|
+
import { DOCUMENT, NOTICE, finding } from "../finding.js";
|
|
4
|
+
import { codeBlocksOf } from "../shared/codeBlocks.js";
|
|
5
|
+
|
|
6
|
+
export const name = "installImport";
|
|
7
|
+
|
|
8
|
+
const INSTALL = /(?:pip\s+install|uv\s+add|uv\s+pip\s+install|conda\s+install|poetry\s+add)\s+([^\n#|&;]+)/g;
|
|
9
|
+
const IMPORT = /^\s*(?:import\s+([\w.]+(?:\s*,\s*[\w.]+)*)|from\s+([\w.]+)\s+import\b)/;
|
|
10
|
+
const REQUIREMENT = /^([A-Za-z0-9][A-Za-z0-9._-]*)(?:\[([^\]]*)\])?/;
|
|
11
|
+
const PY_FILE = /\b(\w+)\.py\b/g;
|
|
12
|
+
|
|
13
|
+
/** @type {Set<string> | null} */
|
|
14
|
+
let stdlibCache = null;
|
|
15
|
+
function stdlib() {
|
|
16
|
+
if (!stdlibCache) stdlibCache = new Set(loadLines("pythonStdlib.txt"));
|
|
17
|
+
return stdlibCache;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** @type {Map<string, string> | null} */
|
|
21
|
+
let packageCache = null;
|
|
22
|
+
function packageOf() {
|
|
23
|
+
if (!packageCache) {
|
|
24
|
+
packageCache = new Map();
|
|
25
|
+
for (const line of loadLines("pythonPackages.txt")) {
|
|
26
|
+
const [module, pkg] = line.split("\t");
|
|
27
|
+
packageCache.set(module, pkg);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return packageCache;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** @type {[RegExp, string, string][] | null} */
|
|
34
|
+
let hiddenCache = null;
|
|
35
|
+
function hiddenDeps() {
|
|
36
|
+
if (!hiddenCache) {
|
|
37
|
+
hiddenCache = loadLines("hiddenDeps.txt").map((line) => {
|
|
38
|
+
const [pattern, requirement, why] = line.split("\t");
|
|
39
|
+
return /** @type {[RegExp, string, string]} */ ([new RegExp(pattern), requirement, why]);
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
return hiddenCache;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** @param {string} value */
|
|
46
|
+
function normalize(value) {
|
|
47
|
+
return value.toLowerCase().replace(/_/g, "-");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** 설치 줄이 말한 패키지 → extras. 설치 줄이 하나도 없으면 null. @param {import("../../fingerprint/build.js").DocumentPrint} doc */
|
|
51
|
+
function installed(doc) {
|
|
52
|
+
const texts = [...codeBlocksOf(doc).flatMap((b) => b.lines.map(([, line]) => line)), ...doc.sentences.map((s) => s.text)];
|
|
53
|
+
/** @type {Map<string, Set<string>>} */
|
|
54
|
+
const packages = new Map();
|
|
55
|
+
let seen = false;
|
|
56
|
+
for (const text of texts) {
|
|
57
|
+
INSTALL.lastIndex = 0;
|
|
58
|
+
for (const match of text.matchAll(INSTALL)) {
|
|
59
|
+
seen = true;
|
|
60
|
+
for (let token of match[1].split(/\s+/)) {
|
|
61
|
+
token = token.replace(/^["'`]+|["'`]+$/g, "");
|
|
62
|
+
if (token.startsWith("-") || !token) continue;
|
|
63
|
+
const requirement = REQUIREMENT.exec(token);
|
|
64
|
+
if (!requirement) continue;
|
|
65
|
+
const name2 = normalize(requirement[1]);
|
|
66
|
+
const extras = new Set((requirement[2] ?? "").split(",").map((e) => normalize(e.trim())).filter(Boolean));
|
|
67
|
+
const current = packages.get(name2) ?? new Set();
|
|
68
|
+
for (const extra of extras) current.add(extra);
|
|
69
|
+
packages.set(name2, current);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return seen ? packages : null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** @param {import("../../fingerprint/build.js").DocumentPrint} doc */
|
|
77
|
+
function localModules(doc) {
|
|
78
|
+
const names = new Set();
|
|
79
|
+
for (const sentence of doc.sentences) {
|
|
80
|
+
PY_FILE.lastIndex = 0;
|
|
81
|
+
for (const match of sentence.text.matchAll(PY_FILE)) names.add(match[1]);
|
|
82
|
+
}
|
|
83
|
+
for (const block of codeBlocksOf(doc)) {
|
|
84
|
+
for (const [, line] of block.lines) {
|
|
85
|
+
PY_FILE.lastIndex = 0;
|
|
86
|
+
for (const match of line.matchAll(PY_FILE)) names.add(match[1]);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return names;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** @param {import("../../fingerprint/build.js").DocumentPrint} doc */
|
|
93
|
+
export function run(doc) {
|
|
94
|
+
const packages = installed(doc);
|
|
95
|
+
if (packages === null) return [];
|
|
96
|
+
const local = localModules(doc);
|
|
97
|
+
const findings = [];
|
|
98
|
+
for (const block of codeBlocksOf(doc)) {
|
|
99
|
+
if (!["python", "py"].includes(block.language)) continue;
|
|
100
|
+
for (const [line, code] of block.lines) {
|
|
101
|
+
const match = IMPORT.exec(code);
|
|
102
|
+
if (match) {
|
|
103
|
+
const modules = (match[1] ?? match[2]).split(",").map((m) => m.trim());
|
|
104
|
+
for (const module of modules) {
|
|
105
|
+
const top = module.split(".")[0];
|
|
106
|
+
if (!top || stdlib().has(top) || local.has(top)) continue;
|
|
107
|
+
const pkg = normalize(packageOf().get(top) ?? top);
|
|
108
|
+
if (!packages.has(pkg)) {
|
|
109
|
+
findings.push(finding(name, line, code.trim(), `\`${top}\` 를 import 하는데 설치 줄에 \`${pkg}\` 가 없다. 독자는 ModuleNotFoundError 에서 멈춘다`, null, "error", DOCUMENT, block.index));
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
for (const [pattern, requirement, why] of hiddenDeps()) {
|
|
114
|
+
pattern.lastIndex = 0;
|
|
115
|
+
if (!pattern.test(code)) continue;
|
|
116
|
+
const parsed = REQUIREMENT.exec(requirement);
|
|
117
|
+
if (!parsed) continue;
|
|
118
|
+
const name2 = normalize(parsed[1]);
|
|
119
|
+
const extra = parsed[2] ? normalize(parsed[2]) : null;
|
|
120
|
+
if (packages.has(name2) && (extra === null || (packages.get(name2)?.has(extra) ?? false))) continue;
|
|
121
|
+
findings.push(finding(name, line, code.trim(), `이 줄은 \`${requirement}\` 가 있어야 돈다. ${why}. 설치 줄에 없다`, null, NOTICE, DOCUMENT, block.index));
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return findings;
|
|
126
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { loadLines } from "../../data/load.js";
|
|
3
|
+
import { DOCUMENT, NOTICE, finding } from "../finding.js";
|
|
4
|
+
import { codeBlocksOf } from "../shared/codeBlocks.js";
|
|
5
|
+
|
|
6
|
+
export const name = "platformApi";
|
|
7
|
+
|
|
8
|
+
/** @type {[RegExp, string, string][] | null} */
|
|
9
|
+
let cache = null;
|
|
10
|
+
function platformApis() {
|
|
11
|
+
if (!cache) {
|
|
12
|
+
cache = loadLines("platformApis.txt").map((line) => {
|
|
13
|
+
const [pattern, platform, alternative] = line.split("\t");
|
|
14
|
+
return /** @type {[RegExp, string, string]} */ ([new RegExp(pattern), platform, alternative]);
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
return cache;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** @param {import("../../fingerprint/build.js").DocumentPrint} doc */
|
|
21
|
+
export function run(doc) {
|
|
22
|
+
const findings = [];
|
|
23
|
+
for (const block of codeBlocksOf(doc)) {
|
|
24
|
+
if (block.isOutput) continue;
|
|
25
|
+
for (const [line, code] of block.lines) {
|
|
26
|
+
for (const [pattern, platform, alternative] of platformApis()) {
|
|
27
|
+
pattern.lastIndex = 0;
|
|
28
|
+
if (pattern.test(code)) {
|
|
29
|
+
findings.push(finding(name, line, code.trim(), `\`${pattern.source}\` 은 ${platform} 전용이다. 다른 운영체제 독자는 여기서 멈춘다. ${alternative}`, null, NOTICE, DOCUMENT, block.index));
|
|
30
|
+
break;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return findings;
|
|
36
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { DOCUMENT, finding } from "../finding.js";
|
|
3
|
+
|
|
4
|
+
export const name = "countMismatch";
|
|
5
|
+
|
|
6
|
+
/** @param {import("../../fingerprint/build.js").DocumentPrint} doc @param {number} line */
|
|
7
|
+
function sectionIndexAt(doc, line) {
|
|
8
|
+
let index = 0;
|
|
9
|
+
for (const section of doc.sections) if (section.startLine <= line) index = section.index;
|
|
10
|
+
return index;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** 같은 절 안이거나, 도입과 마지막 절 사이일 때만 같은 목록을 센다고 본다. */
|
|
14
|
+
function comparable(doc, lineA, lineB) {
|
|
15
|
+
const a = sectionIndexAt(doc, lineA);
|
|
16
|
+
const b = sectionIndexAt(doc, lineB);
|
|
17
|
+
const last = doc.sections[doc.sections.length - 1].index;
|
|
18
|
+
return a === b || (Math.min(a, b) === 0 && Math.max(a, b) === last && a !== b);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** @param {import("../../fingerprint/build.js").DocumentPrint} doc */
|
|
22
|
+
export function run(doc) {
|
|
23
|
+
const findings = [];
|
|
24
|
+
/** @type {Map<string, [number, number, string][]>} */
|
|
25
|
+
const byUnit = new Map();
|
|
26
|
+
for (const [number, unit, line, text] of doc.countPromises) {
|
|
27
|
+
if (!byUnit.has(unit)) byUnit.set(unit, []);
|
|
28
|
+
/** @type {[number, number, string][]} */ (byUnit.get(unit)).push([line, number, text]);
|
|
29
|
+
}
|
|
30
|
+
for (const promises of byUnit.values()) {
|
|
31
|
+
promises.sort((a, b) => a[0] - b[0] || a[1] - b[1] || (a[2] < b[2] ? -1 : a[2] > b[2] ? 1 : 0));
|
|
32
|
+
for (let i = 0; i < promises.length; i++) {
|
|
33
|
+
const [lineA, numberA, textA] = promises[i];
|
|
34
|
+
const conflict = promises.slice(i + 1).find((p) => p[1] !== numberA && comparable(doc, lineA, p[0]));
|
|
35
|
+
if (!conflict) continue;
|
|
36
|
+
const [lineB, , textB] = conflict;
|
|
37
|
+
findings.push(finding(name, lineB, textB, `${lineA}번째 줄은 \`${textA}\` 인데 여기는 \`${textB}\` 다. 같은 단위로 다른 수를 약속하면 독자가 어느 쪽을 믿을지 모른다`, null, "error", DOCUMENT, -1));
|
|
38
|
+
break;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return findings;
|
|
42
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { DOCUMENT, finding } from "../finding.js";
|
|
3
|
+
|
|
4
|
+
export const name = "keywordMissing";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @param {import("../../fingerprint/build.js").DocumentPrint} doc
|
|
8
|
+
* @param {import("../../config/settings.js").Config} config
|
|
9
|
+
*/
|
|
10
|
+
export function run(doc, config) {
|
|
11
|
+
if (!config.keywordField) return [];
|
|
12
|
+
const keyword = (doc.frontmatter[config.keywordField] ?? "").trim();
|
|
13
|
+
if (!keyword) return [];
|
|
14
|
+
const title = doc.frontmatter.title ?? "";
|
|
15
|
+
const first = doc.paragraphs.length ? doc.paragraphs[0] : null;
|
|
16
|
+
const head = `${title}\n${first ? first.text : ""}`;
|
|
17
|
+
if (head.includes(keyword)) return [];
|
|
18
|
+
return [
|
|
19
|
+
finding(name, first ? first.startLine : 1, title || (first ? first.text : ""), `대표 검색어 \`${keyword}\` 가 제목에도 첫 문단에도 없다. 검색해 들어온 독자가 자기가 친 이름을 못 본다`, null, "error", DOCUMENT, -1),
|
|
20
|
+
];
|
|
21
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { DOCUMENT, finding } from "../finding.js";
|
|
3
|
+
|
|
4
|
+
export const name = "noQuestion";
|
|
5
|
+
|
|
6
|
+
/** @param {import("../../fingerprint/build.js").DocumentPrint} doc */
|
|
7
|
+
export function run(doc) {
|
|
8
|
+
if (doc.bodySections.length < 2 || doc.questionCount > 0) return [];
|
|
9
|
+
const first = doc.sentences.length ? doc.sentences[0] : null;
|
|
10
|
+
return [finding(name, first ? first.line : 1, first ? first.text : "", "물음표가 한 번도 없다. 독자가 품을 의문을 한 번은 대신 묻고 다음 문장에서 답한다", null, "error", DOCUMENT, -1)];
|
|
11
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { DOCUMENT, NOTICE, finding } from "../finding.js";
|
|
3
|
+
|
|
4
|
+
export const name = "promiseRecall";
|
|
5
|
+
|
|
6
|
+
/** @param {import("../../fingerprint/build.js").DocumentPrint} doc */
|
|
7
|
+
export function run(doc) {
|
|
8
|
+
if (!doc.promises.length || doc.recalls.length) return [];
|
|
9
|
+
return doc.promises.map(([line, text]) =>
|
|
10
|
+
finding(name, line, text, "뒤로 미룬 것을 앞에서 미룬 것으로 다시 부르는 자리가 글 어디에도 없다. 회수하거나 미루지 않는다", null, NOTICE, DOCUMENT, -1),
|
|
11
|
+
);
|
|
12
|
+
}
|