hanlint 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/README.md +27 -0
  2. package/bin/hanlint.js +4 -0
  3. package/data/causalMarkers.txt +25 -0
  4. package/data/cliches.json +99 -0
  5. package/data/confusable.json +76 -0
  6. package/data/connectors.txt +18 -0
  7. package/data/countUnits.txt +6 -0
  8. package/data/coverageTypes.txt +7 -0
  9. package/data/deixis.txt +8 -0
  10. package/data/easyWords.json +340 -0
  11. package/data/emphasisWords.txt +11 -0
  12. package/data/endings.txt +9 -0
  13. package/data/euiNouns.txt +40 -0
  14. package/data/hedges.txt +15 -0
  15. package/data/hiddenDeps.txt +11 -0
  16. package/data/holeKinds.json +145 -0
  17. package/data/japaneseLoan.json +82 -0
  18. package/data/josa.txt +54 -0
  19. package/data/koreanNumbers.txt +22 -0
  20. package/data/passiveStems.txt +53 -0
  21. package/data/platformApis.txt +12 -0
  22. package/data/promiseMarkers.txt +7 -0
  23. package/data/pythonPackages.txt +35 -0
  24. package/data/pythonStdlib.txt +193 -0
  25. package/data/readerCalls.txt +14 -0
  26. package/data/recallMarkers.txt +6 -0
  27. package/data/redundantPair.json +70 -0
  28. package/data/ruleDocs.json +44 -0
  29. package/data/spacing.json +76 -0
  30. package/data/spelling.json +166 -0
  31. package/data/stopwords.txt +33 -0
  32. package/data/translationese.json +104 -0
  33. package/data/verbTails.txt +56 -0
  34. package/data/version.json +1 -0
  35. package/package.json +39 -0
  36. package/src/analysis/index.js +34 -0
  37. package/src/analysis/surface/splitSentences.js +36 -0
  38. package/src/analysis/surface/tokenize.js +162 -0
  39. package/src/cli/main.js +376 -0
  40. package/src/config/loadConfig.js +51 -0
  41. package/src/config/settings.js +80 -0
  42. package/src/config/toml.js +239 -0
  43. package/src/data/load.js +63 -0
  44. package/src/document/model.js +55 -0
  45. package/src/document/parseMarkdown.js +183 -0
  46. package/src/document/plainText.js +43 -0
  47. package/src/edit/applyFixes.js +0 -0
  48. package/src/fingerprint/build.js +284 -0
  49. package/src/fingerprint/dictionaries.js +118 -0
  50. package/src/fingerprint/markers.js +175 -0
  51. package/src/fingerprint/topics.js +42 -0
  52. package/src/index.js +68 -0
  53. package/src/regex.js +47 -0
  54. package/src/report/compactReport.js +9 -0
  55. package/src/report/fingerprintJson.js +126 -0
  56. package/src/report/githubReport.js +13 -0
  57. package/src/report/jsonReport.js +16 -0
  58. package/src/report/textReport.js +19 -0
  59. package/src/rules/code/duplicateBlock.js +48 -0
  60. package/src/rules/code/firstResultDistance.js +23 -0
  61. package/src/rules/code/inputFileSource.js +103 -0
  62. package/src/rules/code/installImport.js +126 -0
  63. package/src/rules/code/platformApi.js +36 -0
  64. package/src/rules/document/countMismatch.js +42 -0
  65. package/src/rules/document/keywordMissing.js +21 -0
  66. package/src/rules/document/noQuestion.js +11 -0
  67. package/src/rules/document/promiseRecall.js +12 -0
  68. package/src/rules/document/readerAbsent.js +11 -0
  69. package/src/rules/finding.js +53 -0
  70. package/src/rules/orthography/confusable.js +9 -0
  71. package/src/rules/orthography/spacing.js +9 -0
  72. package/src/rules/orthography/spelling.js +9 -0
  73. package/src/rules/paragraph/factListParagraph.js +27 -0
  74. package/src/rules/paragraph/paraFragment.js +29 -0
  75. package/src/rules/paragraph/topicBreak.js +25 -0
  76. package/src/rules/registry.js +147 -0
  77. package/src/rules/sentence/cliche.js +9 -0
  78. package/src/rules/sentence/connectorRepeat.js +23 -0
  79. package/src/rules/sentence/danglingDeixis.js +19 -0
  80. package/src/rules/sentence/dash.js +21 -0
  81. package/src/rules/sentence/deixis.js +19 -0
  82. package/src/rules/sentence/doubleNegative.js +28 -0
  83. package/src/rules/sentence/doublePassive.js +17 -0
  84. package/src/rules/sentence/ellipsis.js +17 -0
  85. package/src/rules/sentence/endingRepeat.js +25 -0
  86. package/src/rules/sentence/euiChain.js +18 -0
  87. package/src/rules/sentence/fillerOpener.js +18 -0
  88. package/src/rules/sentence/hardWord.js +10 -0
  89. package/src/rules/sentence/imperativePeriod.js +20 -0
  90. package/src/rules/sentence/japaneseLoan.js +9 -0
  91. package/src/rules/sentence/longSentence.js +20 -0
  92. package/src/rules/sentence/negationRedefine.js +22 -0
  93. package/src/rules/sentence/nounPile.js +20 -0
  94. package/src/rules/sentence/redundantPair.js +9 -0
  95. package/src/rules/sentence/translationese.js +9 -0
  96. package/src/rules/shared/codeBlocks.js +44 -0
  97. package/src/rules/shared/dictionaryRule.js +35 -0
  98. package/src/rules/shared/runs.js +22 -0
  99. package/src/rules/structure/emojiBullet.js +34 -0
  100. package/src/rules/structure/headingSentence.js +17 -0
  101. package/src/rules/structure/headingSkip.js +18 -0
  102. package/src/rules/structure/headingUniform.js +36 -0
  103. package/src/rules/structure/introLong.js +20 -0
  104. package/src/rules/structure/sectionNoProse.js +16 -0
  105. package/src/rules/structure/sectionResult.js +37 -0
  106. package/src/text.js +63 -0
@@ -0,0 +1,20 @@
1
+ // @ts-check
2
+ import { SENTENCE, finding } from "../finding.js";
3
+
4
+ export const name = "nounPile";
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
+ const findings = [];
12
+ for (const sentence of doc.sentences) {
13
+ if (sentence.nounRun >= config.nounPileMin) {
14
+ findings.push(
15
+ finding(name, sentence.line, sentence.text, `명사 ${sentence.nounRun}개가 조사 없이 이어진다. 관계가 표시되지 않아 독자가 조사를 끼워 넣는다. 동사로 되돌린다`, null, "error", SENTENCE, sentence.index),
16
+ );
17
+ }
18
+ }
19
+ return findings;
20
+ }
@@ -0,0 +1,9 @@
1
+ // @ts-check
2
+ import { dictionaryFindings } from "../shared/dictionaryRule.js";
3
+
4
+ export const name = "redundantPair";
5
+
6
+ /** @param {import("../../fingerprint/build.js").DocumentPrint} doc */
7
+ export function run(doc) {
8
+ return dictionaryFindings(doc, "redundantPair", name);
9
+ }
@@ -0,0 +1,9 @@
1
+ // @ts-check
2
+ import { dictionaryFindings } from "../shared/dictionaryRule.js";
3
+
4
+ export const name = "translationese";
5
+
6
+ /** @param {import("../../fingerprint/build.js").DocumentPrint} doc */
7
+ export function run(doc) {
8
+ return dictionaryFindings(doc, "translationese", name);
9
+ }
@@ -0,0 +1,44 @@
1
+ // @ts-check
2
+ /**
3
+ * 코드 블록을 언어와 본문으로 나눈다. code 부류 규칙이 함께 쓴다. 파이썬 rules/shared/codeBlocks.py 와 같다.
4
+ * 펜스 첫 줄의 언어 표기 (```python) 를 읽고 본문 줄에 원문 줄 번호를 붙인다. text 펜스는 출력이다.
5
+ */
6
+ import { CODE } from "../../document/model.js";
7
+
8
+ const FENCE_LANGUAGE = /^\s*(?:```|~~~)\s*([A-Za-z0-9_+-]*)/;
9
+ const CLOSING_FENCE = /^\s*(?:```|~~~)\s*$/;
10
+
11
+ /**
12
+ * @typedef {object} CodeBlock
13
+ * @property {number} index
14
+ * @property {number} startLine
15
+ * @property {string} language
16
+ * @property {[number, string][]} lines (원문 줄 번호, 코드 줄)
17
+ * @property {string} text
18
+ * @property {boolean} isOutput
19
+ */
20
+
21
+ /** @param {import("../../fingerprint/build.js").DocumentPrint} doc @returns {CodeBlock[]} */
22
+ export function codeBlocksOf(doc) {
23
+ /** @type {CodeBlock[]} */
24
+ const blocks = [];
25
+ for (const block of doc.blocks) {
26
+ if (block.kind !== CODE) continue;
27
+ const raw = block.text.split("\n");
28
+ const match = FENCE_LANGUAGE.exec(raw[0]);
29
+ const language = (match ? match[1] : "").toLowerCase();
30
+ let body = raw.slice(1);
31
+ if (body.length && CLOSING_FENCE.test(body[body.length - 1])) body = body.slice(0, -1);
32
+ /** @type {[number, string][]} */
33
+ const lines = body.map((line, offset) => [block.startLine + 1 + offset, line]);
34
+ blocks.push({
35
+ index: block.index,
36
+ startLine: block.startLine,
37
+ language,
38
+ lines,
39
+ text: lines.map(([, line]) => line).join("\n"),
40
+ isOutput: ["text", "", "output", "console"].includes(language),
41
+ });
42
+ }
43
+ return blocks;
44
+ }
@@ -0,0 +1,35 @@
1
+ // @ts-check
2
+ /** 사전 규칙의 공통 구현. cliche, translationese, redundantPair, japaneseLoan 이 사전 이름만 바꿔 쓴다. */
3
+ import { ERROR, SENTENCE, finding } from "../finding.js";
4
+
5
+ /**
6
+ * @param {import("../../fingerprint/build.js").DocumentPrint} doc
7
+ * @param {string} dictionary
8
+ * @param {string} ruleName
9
+ * @param {string} [severity]
10
+ * @returns {import("../finding.js").Finding[]}
11
+ */
12
+ export function dictionaryFindings(doc, dictionary, ruleName, severity = ERROR) {
13
+ const findings = [];
14
+ for (const sentence of doc.sentences) {
15
+ for (const match of sentence.matches) {
16
+ if (match.dictionary !== dictionary) continue;
17
+ const fix = match.fix !== null ? sentence.text.slice(0, match.start) + match.fix + sentence.text.slice(match.end) : null;
18
+ findings.push(
19
+ finding(
20
+ ruleName,
21
+ sentence.line,
22
+ sentence.text,
23
+ `\`${match.text}\` ${match.why} (${match.source})`,
24
+ fix,
25
+ severity,
26
+ SENTENCE,
27
+ sentence.index,
28
+ match.fix !== null ? match.text : null,
29
+ match.fix,
30
+ ),
31
+ );
32
+ }
33
+ }
34
+ return findings;
35
+ }
@@ -0,0 +1,22 @@
1
+ // @ts-check
2
+ /** 연속 구간 찾기. 같은 값이 이어지는 자리를 [시작 위치, 길이, 값] 으로 준다. */
3
+
4
+ /**
5
+ * @template T
6
+ * @param {T[]} values
7
+ * @param {number} minLength
8
+ * @returns {[number, number, T][]}
9
+ */
10
+ export function runsOf(values, minLength) {
11
+ /** @type {[number, number, T][]} */
12
+ const runs = [];
13
+ let start = 0;
14
+ while (start < values.length) {
15
+ let end = start;
16
+ while (end + 1 < values.length && values[end + 1] === values[start]) end += 1;
17
+ const length = end - start + 1;
18
+ if (length >= minLength) runs.push([start, length, values[start]]);
19
+ start = end + 1;
20
+ }
21
+ return runs;
22
+ }
@@ -0,0 +1,34 @@
1
+ // @ts-check
2
+ import { LIST } from "../../document/model.js";
3
+ import { DOCUMENT, finding } from "../finding.js";
4
+
5
+ export const name = "emojiBullet";
6
+ // 이모지 범위. 코드포인트로 만든다 (도구가 이스케이프를 건드리지 않게). 아스트랄 범위라 u 플래그가 필요하다.
7
+ const EMOJI =
8
+ "[" +
9
+ String.fromCodePoint(0x1f300) +
10
+ "-" +
11
+ String.fromCodePoint(0x1faff) +
12
+ String.fromCodePoint(0x2600) +
13
+ "-" +
14
+ String.fromCodePoint(0x27bf) +
15
+ String.fromCodePoint(0x2b50) +
16
+ String.fromCodePoint(0x2b06) +
17
+ "]";
18
+ const BULLET_WITH_EMOJI = new RegExp(/^\s*(?:[-*+]|\d+[.)])\s+/.source + EMOJI, "u");
19
+
20
+ /** @param {import("../../fingerprint/build.js").DocumentPrint} doc */
21
+ export function run(doc) {
22
+ const findings = [];
23
+ for (const block of doc.blocks) {
24
+ if (block.kind !== LIST) continue;
25
+ const lines = block.text.split("\n");
26
+ for (let offset = 0; offset < lines.length; offset++) {
27
+ if (BULLET_WITH_EMOJI.test(lines[offset])) {
28
+ findings.push(finding(name, block.startLine + offset, lines[offset].trim(), "목록 항목이 이모지로 시작한다. 이모지를 지우고 첫 낱말이 내용을 말하게 한다", null, "error", DOCUMENT, block.index));
29
+ break;
30
+ }
31
+ }
32
+ }
33
+ return findings;
34
+ }
@@ -0,0 +1,17 @@
1
+ // @ts-check
2
+ import { DOCUMENT, finding } from "../finding.js";
3
+
4
+ export const name = "headingSentence";
5
+ const SENTENCE_ENDINGS = ["니다", "세요", "십시오", "합시다", "."];
6
+
7
+ /** @param {import("../../fingerprint/build.js").DocumentPrint} doc */
8
+ export function run(doc) {
9
+ const findings = [];
10
+ for (const [, text, line] of doc.headings) {
11
+ const trimmed = text.trimEnd();
12
+ if (SENTENCE_ENDINGS.some((ending) => trimmed.endsWith(ending))) {
13
+ findings.push(finding(name, line, text, "절 제목이 본문 문장을 복사했다. 대상이나 질문을 짧게 쓴다", null, "error", DOCUMENT, -1));
14
+ }
15
+ }
16
+ return findings;
17
+ }
@@ -0,0 +1,18 @@
1
+ // @ts-check
2
+ import { DOCUMENT, finding } from "../finding.js";
3
+
4
+ export const name = "headingSkip";
5
+
6
+ /** @param {import("../../fingerprint/build.js").DocumentPrint} doc */
7
+ export function run(doc) {
8
+ const findings = [];
9
+ /** @type {number | null} */
10
+ let previous = doc.frontmatter.title ? 1 : null;
11
+ for (const [level, text, line] of doc.headings) {
12
+ if (previous !== null && level > previous + 1) {
13
+ findings.push(finding(name, line, text, `H${previous} 다음에 H${level} 이 온다. 한 수준씩 내려간다`, null, "error", DOCUMENT, -1));
14
+ }
15
+ previous = level;
16
+ }
17
+ return findings;
18
+ }
@@ -0,0 +1,36 @@
1
+ // @ts-check
2
+ import { DOCUMENT, finding } from "../finding.js";
3
+
4
+ export const name = "headingUniform";
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
+ const headings = doc.headings.filter(([level]) => level === 2);
12
+ if (headings.length < 3) return [];
13
+ // 파이썬 Counter.most_common 은 같은 수면 먼저 들어온 것이 이긴다. 삽입 순서를 지킨다.
14
+ /** @type {Map<string, number>} */
15
+ const lastChars = new Map();
16
+ for (const [, text] of headings) {
17
+ if (!text.trim()) continue;
18
+ const trimmed = text.trimEnd();
19
+ const char = trimmed[trimmed.length - 1];
20
+ lastChars.set(char, (lastChars.get(char) ?? 0) + 1);
21
+ }
22
+ let best = "";
23
+ let count = -1;
24
+ for (const [char, n] of lastChars) {
25
+ if (n > count) {
26
+ best = char;
27
+ count = n;
28
+ }
29
+ }
30
+ if (count / headings.length >= config.headingUniformRatio) {
31
+ return [
32
+ finding(name, headings[0][2], headings.map(([, text]) => text).join(" / "), `H2 ${headings.length}개 중 ${count}개가 \`${best}\` 로 끝난다. 목차가 한 어미로 끝나면 과정이 아니라 나열로 읽힌다. 형태를 섞는다`, null, "error", DOCUMENT, -1),
33
+ ];
34
+ }
35
+ return [];
36
+ }
@@ -0,0 +1,20 @@
1
+ // @ts-check
2
+ import { PARAGRAPH, finding } from "../finding.js";
3
+
4
+ export const name = "introLong";
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 (!doc.bodySections.length) return [];
12
+ const paragraphs = doc.intro.paragraphs;
13
+ if (paragraphs.length > config.introMaxParagraphs) {
14
+ const over = paragraphs[config.introMaxParagraphs];
15
+ return [
16
+ finding(name, over.startLine, over.sentences.length ? over.sentences[0].text : "", `도입 산문 문단이 ${paragraphs.length}개다. ${config.introMaxParagraphs}개를 넘지 않는다. 첫 코드 블록이 그 안에 온다`, null, "error", PARAGRAPH, over.index),
17
+ ];
18
+ }
19
+ return [];
20
+ }
@@ -0,0 +1,16 @@
1
+ // @ts-check
2
+ import { LIST, PROSE } from "../../document/model.js";
3
+ import { SECTION, finding } from "../finding.js";
4
+
5
+ export const name = "sectionNoProse";
6
+
7
+ /** @param {import("../../fingerprint/build.js").DocumentPrint} doc */
8
+ export function run(doc) {
9
+ const findings = [];
10
+ for (const section of doc.bodySections) {
11
+ const kinds = new Set(section.blockKinds);
12
+ if (!kinds.size || kinds.has(PROSE) || kinds.has(LIST)) continue;
13
+ findings.push(finding(name, section.startLine, section.title, "설명글 없이 코드, 표, 이미지만 있는 절이다. 절은 제목과 시각 자료와 설명글로 짠다", null, "error", SECTION, section.index));
14
+ }
15
+ return findings;
16
+ }
@@ -0,0 +1,37 @@
1
+ // @ts-check
2
+ import { CODE, IMAGE, TABLE } from "../../document/model.js";
3
+ import { NOTICE, SECTION, finding } from "../finding.js";
4
+
5
+ export const name = "sectionResult";
6
+ const RESULT_KINDS = [CODE, IMAGE, TABLE];
7
+ const FILE_NAME = /[\w가-힣-]+\.(?:csv|xlsx|xls|parquet|json|png|jpg|svg|db|sqlite|txt|py|pdf|html|md)\b/;
8
+ const OUTPUT_WORDS = ["출력", "화면에", "찍힙니다", "나옵니다", "뜹니다", "보입니다", "만들어집니다", "생깁니다", "저장됩니다"];
9
+
10
+ /** @param {import("../../fingerprint/build.js").SectionPrint} section */
11
+ function leavesResult(section) {
12
+ if (section.blockKinds.some((kind) => RESULT_KINDS.includes(kind))) return true;
13
+ for (const paragraph of section.paragraphs) {
14
+ for (const sentence of paragraph.sentences) {
15
+ if (FILE_NAME.test(sentence.text) || OUTPUT_WORDS.some((word) => sentence.text.includes(word))) return true;
16
+ }
17
+ }
18
+ return false;
19
+ }
20
+
21
+ /**
22
+ * @param {import("../../fingerprint/build.js").DocumentPrint} doc
23
+ * @param {import("../../config/settings.js").Config} config
24
+ */
25
+ export function run(doc, config) {
26
+ const findings = [];
27
+ const body = doc.bodySections;
28
+ body.forEach((section, index) => {
29
+ if (index === body.length - 1) return;
30
+ if (section.paragraphs.length <= config.sectionResultMinParagraphs) return;
31
+ if (leavesResult(section)) return;
32
+ findings.push(
33
+ finding(name, section.startLine, section.title, "이 절에 독자가 확인할 결과 (코드, 표, 그림, 파일, 출력) 가 없다. 만들거나 확인할 것을 하나 넣는다", null, NOTICE, SECTION, section.index),
34
+ );
35
+ });
36
+ return findings;
37
+ }
package/src/text.js ADDED
@@ -0,0 +1,63 @@
1
+ // @ts-check
2
+ /** 파이썬 str 메서드와 같은 뜻의 문자열 도우미. 두 구현의 결과가 글자 단위로 같아야 해서 따로 둔다. */
3
+
4
+ /**
5
+ * 파이썬 str.splitlines(). 끝의 빈 줄을 만들지 않는다.
6
+ * @param {string} text
7
+ */
8
+ export function splitLines(text) {
9
+ if (text === "") return [];
10
+ const lines = text.split(/\r\n|\r|\n/);
11
+ if (lines[lines.length - 1] === "") lines.pop();
12
+ return lines;
13
+ }
14
+
15
+ /** 파이썬 str.split() (인자 없음). 공백 덩어리로 나누고 빈 것은 버린다. @param {string} text */
16
+ export function splitWords(text) {
17
+ return text.split(/\s+/).filter((word) => word.length > 0);
18
+ }
19
+
20
+ /** 파이썬 str.strip(chars). @param {string} text @param {string} chars */
21
+ export function stripChars(text, chars) {
22
+ let start = 0;
23
+ let end = text.length;
24
+ while (start < end && chars.includes(text[start])) start++;
25
+ while (end > start && chars.includes(text[end - 1])) end--;
26
+ return text.slice(start, end);
27
+ }
28
+
29
+ /** 파이썬 str.rstrip(chars). @param {string} text @param {string} chars */
30
+ export function rstripChars(text, chars) {
31
+ let end = text.length;
32
+ while (end > 0 && chars.includes(text[end - 1])) end--;
33
+ return text.slice(0, end);
34
+ }
35
+
36
+ /** 어절 수. 파이썬 len(text.split()). @param {string} text */
37
+ export function wordCount(text) {
38
+ return splitWords(text).length;
39
+ }
40
+
41
+ /** @param {number[]} values */
42
+ export function mean(values) {
43
+ return values.reduce((a, b) => a + b, 0) / values.length;
44
+ }
45
+
46
+ /** 모집단 표준편차 (파이썬 statistics.pstdev). @param {number[]} values */
47
+ export function pstdev(values) {
48
+ const m = mean(values);
49
+ return Math.sqrt(values.reduce((sum, v) => sum + (v - m) ** 2, 0) / values.length);
50
+ }
51
+
52
+ /** 부분 문자열 개수 (파이썬 str.count). @param {string} text @param {string} needle @param {number} [end] */
53
+ export function countIn(text, needle, end = text.length) {
54
+ let count = 0;
55
+ let from = 0;
56
+ const limit = end;
57
+ while (true) {
58
+ const at = text.indexOf(needle, from);
59
+ if (at < 0 || at + needle.length > limit) return count;
60
+ count++;
61
+ from = at + needle.length;
62
+ }
63
+ }