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
|
@@ -1,39 +1,23 @@
|
|
|
1
1
|
// @ts-check
|
|
2
|
+
import { createdIn, fileName, readsIn, writeTargets } from "../../fingerprint/codeMarkers.js";
|
|
2
3
|
import { DOCUMENT, NOTICE, finding } from "../finding.js";
|
|
3
|
-
import { codeBlocksOf } from "../shared/codeBlocks.js";
|
|
4
4
|
|
|
5
5
|
export const name = "inputFileSource";
|
|
6
|
+
export const mechanism = "reader";
|
|
6
7
|
|
|
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
8
|
const EXTENSIONS = new Set(
|
|
23
9
|
"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
10
|
);
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
return parts[parts.length - 1];
|
|
30
|
-
}
|
|
11
|
+
/** 별표, 서식 자리표시자, URL 이 든 경로는 파일 하나를 가리키지 않는다. */
|
|
12
|
+
const NOT_A_FILE = ["*", "{", "http", "://", "<", ">"];
|
|
13
|
+
/** 폴더를 볼 때는 물음표까지 뺀다. */
|
|
14
|
+
const NOT_A_PATH = [...NOT_A_FILE, "?"];
|
|
31
15
|
|
|
32
16
|
/** @param {string} path */
|
|
33
17
|
function isDataFile(path) {
|
|
34
|
-
if (
|
|
35
|
-
const
|
|
36
|
-
return
|
|
18
|
+
if (NOT_A_FILE.some((mark) => path.includes(mark))) return false;
|
|
19
|
+
const name2 = fileName(path);
|
|
20
|
+
return name2.includes(".") && EXTENSIONS.has(name2.split(".").pop()?.toLowerCase() ?? "");
|
|
37
21
|
}
|
|
38
22
|
|
|
39
23
|
/** @param {string} path */
|
|
@@ -42,62 +26,33 @@ function directoryOf(path) {
|
|
|
42
26
|
return parts.length > 1 && !["", ".", ".."].includes(parts[0]) ? parts[0] : null;
|
|
43
27
|
}
|
|
44
28
|
|
|
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
29
|
/** @param {import("../../fingerprint/build.js").DocumentPrint} doc */
|
|
59
30
|
export function run(doc) {
|
|
60
31
|
const findings = [];
|
|
61
|
-
/** @type {Set<string>} */
|
|
62
|
-
const
|
|
63
|
-
for (const block of
|
|
64
|
-
|
|
65
|
-
const blockWrites =
|
|
66
|
-
|
|
67
|
-
const
|
|
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);
|
|
32
|
+
/** 이미 짚은 폴더. 같은 폴더를 두 번 짚지 않는다. @type {Set<string>} */
|
|
33
|
+
const reported = new Set();
|
|
34
|
+
for (const block of doc.codeBlocks) {
|
|
35
|
+
const reader = doc.reader.beforeBlock[block.index];
|
|
36
|
+
const [blockWrites, blockDirs] = createdIn(block.lines.map(([, line]) => line));
|
|
37
|
+
// 이 블록이 만드는 폴더는 같은 블록의 읽기에도 미리 센다. 파일은 블록 안에서 쓴 것 (blockWrites) 만 따로 본다.
|
|
38
|
+
const have = new Set([...reader.files, ...blockDirs, ...reported]);
|
|
80
39
|
for (const [line, code] of block.lines) {
|
|
81
|
-
|
|
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
|
-
}
|
|
40
|
+
const reads = readsIn(code);
|
|
86
41
|
for (const path of reads) {
|
|
87
42
|
if (!isDataFile(path)) continue;
|
|
88
43
|
const name2 = fileName(path);
|
|
89
|
-
if (
|
|
44
|
+
if (have.has(name2) || blockWrites.has(name2) || doc.reader.mentionedBefore(block.index, name2)) continue;
|
|
90
45
|
findings.push(finding(name, line, code.trim(), `\`${name2}\` 을 읽는데 글 어디에서도 만들지 않았다. 독자는 여기서 파일 없음 오류로 멈춘다`, null, "error", DOCUMENT, block.index));
|
|
91
46
|
}
|
|
92
|
-
for (const path of [...
|
|
93
|
-
if (
|
|
47
|
+
for (const path of [...writeTargets(code), ...reads]) {
|
|
48
|
+
if (NOT_A_PATH.some((mark) => path.includes(mark))) continue;
|
|
94
49
|
const directory = directoryOf(path);
|
|
95
|
-
if (!directory ||
|
|
96
|
-
|
|
50
|
+
if (!directory || have.has(directory) || doc.reader.mentionedBefore(block.index, directory)) continue;
|
|
51
|
+
have.add(directory);
|
|
52
|
+
reported.add(directory);
|
|
97
53
|
findings.push(finding(name, line, code.trim(), `\`${directory}\` 폴더를 쓰는데 글 어디에서도 만들지 않았다. 없으면 파일을 쓰다 멈춘다`, null, NOTICE, DOCUMENT, block.index));
|
|
98
54
|
}
|
|
99
55
|
}
|
|
100
|
-
for (const write of blockWrites) created.add(write);
|
|
101
56
|
}
|
|
102
57
|
return findings;
|
|
103
58
|
}
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
import { loadLines } from "../../data/load.js";
|
|
3
3
|
import { DOCUMENT, NOTICE, finding } from "../finding.js";
|
|
4
|
-
import { codeBlocksOf } from "../shared/codeBlocks.js";
|
|
5
4
|
|
|
6
5
|
export const name = "installImport";
|
|
6
|
+
export const mechanism = "contrast";
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
// 캡처는 백틱과 한글에서 멈춘다. 안 멈추면 설치 줄 뒤의 산문을 패키지로 등록한다. 파이썬과 같다.
|
|
9
|
+
const INSTALL = /(?:pip\s+install|uv\s+add|uv\s+pip\s+install|conda\s+install|poetry\s+add)\s+([^\n#|&;`가-힣]+)/g;
|
|
9
10
|
const IMPORT = /^\s*(?:import\s+([\w.]+(?:\s*,\s*[\w.]+)*)|from\s+([\w.]+)\s+import\b)/;
|
|
10
11
|
const REQUIREMENT = /^([A-Za-z0-9][A-Za-z0-9._-]*)(?:\[([^\]]*)\])?/;
|
|
11
12
|
const PY_FILE = /\b(\w+)\.py\b/g;
|
|
@@ -49,7 +50,7 @@ function normalize(value) {
|
|
|
49
50
|
|
|
50
51
|
/** 설치 줄이 말한 패키지 → extras. 설치 줄이 하나도 없으면 null. @param {import("../../fingerprint/build.js").DocumentPrint} doc */
|
|
51
52
|
function installed(doc) {
|
|
52
|
-
const texts = [...
|
|
53
|
+
const texts = [...doc.codeBlocks.flatMap((b) => b.lines.map(([, line]) => line)), ...doc.sentences.map((s) => s.text)];
|
|
53
54
|
/** @type {Map<string, Set<string>>} */
|
|
54
55
|
const packages = new Map();
|
|
55
56
|
let seen = false;
|
|
@@ -80,7 +81,7 @@ function localModules(doc) {
|
|
|
80
81
|
PY_FILE.lastIndex = 0;
|
|
81
82
|
for (const match of sentence.text.matchAll(PY_FILE)) names.add(match[1]);
|
|
82
83
|
}
|
|
83
|
-
for (const block of
|
|
84
|
+
for (const block of doc.codeBlocks) {
|
|
84
85
|
for (const [, line] of block.lines) {
|
|
85
86
|
PY_FILE.lastIndex = 0;
|
|
86
87
|
for (const match of line.matchAll(PY_FILE)) names.add(match[1]);
|
|
@@ -95,7 +96,7 @@ export function run(doc) {
|
|
|
95
96
|
if (packages === null) return [];
|
|
96
97
|
const local = localModules(doc);
|
|
97
98
|
const findings = [];
|
|
98
|
-
for (const block of
|
|
99
|
+
for (const block of doc.codeBlocks) {
|
|
99
100
|
if (!["python", "py"].includes(block.language)) continue;
|
|
100
101
|
for (const [line, code] of block.lines) {
|
|
101
102
|
const match = IMPORT.exec(code);
|
|
@@ -1,17 +1,18 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
import { loadLines } from "../../data/load.js";
|
|
3
3
|
import { DOCUMENT, NOTICE, finding } from "../finding.js";
|
|
4
|
-
import { codeBlocksOf } from "../shared/codeBlocks.js";
|
|
5
4
|
|
|
6
5
|
export const name = "platformApi";
|
|
6
|
+
export const mechanism = "dictionary";
|
|
7
7
|
|
|
8
|
-
/** @type {[RegExp, string, string][] | null} */
|
|
8
|
+
/** @type {[RegExp, string, string, string][] | null} */
|
|
9
9
|
let cache = null;
|
|
10
10
|
function platformApis() {
|
|
11
11
|
if (!cache) {
|
|
12
12
|
cache = loadLines("platformApis.txt").map((line) => {
|
|
13
13
|
const [pattern, platform, alternative] = line.split("\t");
|
|
14
|
-
|
|
14
|
+
// 지적문에는 정규식 원문을 적는다. V8 의 RegExp.source 는 / 를 \/ 로 돌려줘 파이썬의 pattern.pattern 과 갈린다.
|
|
15
|
+
return /** @type {[RegExp, string, string, string]} */ ([new RegExp(pattern), pattern, platform, alternative]);
|
|
15
16
|
});
|
|
16
17
|
}
|
|
17
18
|
return cache;
|
|
@@ -20,13 +21,13 @@ function platformApis() {
|
|
|
20
21
|
/** @param {import("../../fingerprint/build.js").DocumentPrint} doc */
|
|
21
22
|
export function run(doc) {
|
|
22
23
|
const findings = [];
|
|
23
|
-
for (const block of
|
|
24
|
+
for (const block of doc.codeBlocks) {
|
|
24
25
|
if (block.isOutput) continue;
|
|
25
26
|
for (const [line, code] of block.lines) {
|
|
26
|
-
for (const [pattern, platform, alternative] of platformApis()) {
|
|
27
|
+
for (const [pattern, source, platform, alternative] of platformApis()) {
|
|
27
28
|
pattern.lastIndex = 0;
|
|
28
29
|
if (pattern.test(code)) {
|
|
29
|
-
findings.push(finding(name, line, code.trim(), `\`${
|
|
30
|
+
findings.push(finding(name, line, code.trim(), `\`${source}\` 은 ${platform} 전용이다. 다른 운영체제 독자는 여기서 멈춘다. ${alternative}`, null, NOTICE, DOCUMENT, block.index));
|
|
30
31
|
break;
|
|
31
32
|
}
|
|
32
33
|
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { DOCUMENT, finding } from "../finding.js";
|
|
3
3
|
|
|
4
4
|
export const name = "countMismatch";
|
|
5
|
+
export const mechanism = "contrast";
|
|
5
6
|
|
|
6
7
|
/** @param {import("../../fingerprint/build.js").DocumentPrint} doc @param {number} line */
|
|
7
8
|
function sectionIndexAt(doc, line) {
|
|
@@ -10,16 +11,31 @@ function sectionIndexAt(doc, line) {
|
|
|
10
11
|
return index;
|
|
11
12
|
}
|
|
12
13
|
|
|
13
|
-
/**
|
|
14
|
-
function
|
|
14
|
+
/** 줄이 든 문단의 차례. sectionIndexAt 과 같은 꼴로 훑는다. */
|
|
15
|
+
function blockIndexAt(doc, line) {
|
|
16
|
+
let index = 0;
|
|
17
|
+
for (const block of doc.blocks) if (block.startLine <= line) index = block.index;
|
|
18
|
+
return index;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** 도입의 약속과 마지막 절의 결산만 같은 목록으로 본다. 절이 없으면 첫 문단과 마지막 문단이다. 파이썬과 같다. */
|
|
22
|
+
function comparable(doc, lineA, lineB, span) {
|
|
15
23
|
const a = sectionIndexAt(doc, lineA);
|
|
16
24
|
const b = sectionIndexAt(doc, lineB);
|
|
17
25
|
const last = doc.sections[doc.sections.length - 1].index;
|
|
18
|
-
|
|
26
|
+
if (last === 0) {
|
|
27
|
+
if (Math.max(...doc.blocks.map((block) => block.endLine)) > span) return false;
|
|
28
|
+
const lastBlock = doc.blocks[doc.blocks.length - 1].index;
|
|
29
|
+
return lastBlock !== 0 && blockIndexAt(doc, lineA) === 0 && blockIndexAt(doc, lineB) === lastBlock;
|
|
30
|
+
}
|
|
31
|
+
return Math.min(a, b) === 0 && Math.max(a, b) === last && a !== b;
|
|
19
32
|
}
|
|
20
33
|
|
|
21
|
-
/**
|
|
22
|
-
|
|
34
|
+
/**
|
|
35
|
+
* @param {import("../../fingerprint/build.js").DocumentPrint} doc
|
|
36
|
+
* @param {import("../../config/settings.js").Config} config
|
|
37
|
+
*/
|
|
38
|
+
export function run(doc, config) {
|
|
23
39
|
const findings = [];
|
|
24
40
|
/** @type {Map<string, [number, number, string][]>} */
|
|
25
41
|
const byUnit = new Map();
|
|
@@ -31,7 +47,7 @@ export function run(doc) {
|
|
|
31
47
|
promises.sort((a, b) => a[0] - b[0] || a[1] - b[1] || (a[2] < b[2] ? -1 : a[2] > b[2] ? 1 : 0));
|
|
32
48
|
for (let i = 0; i < promises.length; i++) {
|
|
33
49
|
const [lineA, numberA, textA] = promises[i];
|
|
34
|
-
const conflict = promises.slice(i + 1).find((p) => p[1] !== numberA && comparable(doc, lineA, p[0]));
|
|
50
|
+
const conflict = promises.slice(i + 1).find((p) => p[1] !== numberA && comparable(doc, lineA, p[0], config.countMismatchSpan));
|
|
35
51
|
if (!conflict) continue;
|
|
36
52
|
const [lineB, , textB] = conflict;
|
|
37
53
|
findings.push(finding(name, lineB, textB, `${lineA}번째 줄은 \`${textA}\` 인데 여기는 \`${textB}\` 다. 같은 단위로 다른 수를 약속하면 독자가 어느 쪽을 믿을지 모른다`, null, "error", DOCUMENT, -1));
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { matchedTexts } from "../../fingerprint/markers.js";
|
|
3
|
+
import { SENTENCE, finding } from "../finding.js";
|
|
4
|
+
|
|
5
|
+
export const name = "enoughOnce";
|
|
6
|
+
export const mechanism = "repeat";
|
|
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
|
+
void config;
|
|
14
|
+
/** @type {[import("../../fingerprint/build.js").SentencePrint, string][]} */
|
|
15
|
+
const breaks = [];
|
|
16
|
+
for (const sentence of doc.sentences) {
|
|
17
|
+
const texts = matchedTexts(sentence.text, "enoughMarkers.txt");
|
|
18
|
+
if (texts.length) breaks.push([sentence, texts[0]]);
|
|
19
|
+
}
|
|
20
|
+
if (breaks.length < 2) return [];
|
|
21
|
+
const first = breaks[0][0];
|
|
22
|
+
return breaks.slice(1).map(([sentence, text]) =>
|
|
23
|
+
finding(name, sentence.line, sentence.text, `\`${text}\` 로 끊는 문장이 ${first.line}행에 이미 있다. 글 전체에 한 번만 쓴다. 계속 끝나는 척하다 이어지는 꼴이 된다`, null, "error", SENTENCE, sentence.index),
|
|
24
|
+
);
|
|
25
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { topicsOf } from "../../fingerprint/topics.js";
|
|
3
|
+
import { DOCUMENT, NOTICE, finding } from "../finding.js";
|
|
4
|
+
|
|
5
|
+
export const name = "fieldEcho";
|
|
6
|
+
export const mechanism = "contrast";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @param {import("../../fingerprint/build.js").DocumentPrint} doc
|
|
10
|
+
* @param {import("../../fingerprint/build.js").SectionPrint} section
|
|
11
|
+
* @param {string[]} fields
|
|
12
|
+
* @param {string} where
|
|
13
|
+
*/
|
|
14
|
+
function check(doc, section, fields, where) {
|
|
15
|
+
const findings = [];
|
|
16
|
+
for (const name_ of fields) {
|
|
17
|
+
const value = doc.frontmatter[name_];
|
|
18
|
+
if (!value) continue;
|
|
19
|
+
const promised = topicsOf(value);
|
|
20
|
+
if (!promised.size) continue;
|
|
21
|
+
if ([...promised].some((word) => section.topics.has(word))) continue;
|
|
22
|
+
findings.push(
|
|
23
|
+
finding(name, section.startLine, value, `frontmatter 의 \`${name_}\` 가 약속한 말이 ${where}에 하나도 없다. 약속한 것을 ${where}에서 그 말로 답한다`, null, NOTICE, DOCUMENT, section.index),
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
return findings;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @param {import("../../fingerprint/build.js").DocumentPrint} doc
|
|
31
|
+
* @param {import("../../config/settings.js").Config} config
|
|
32
|
+
*/
|
|
33
|
+
export function run(doc, config) {
|
|
34
|
+
if (!config.introFields.length && !config.endingFields.length) return [];
|
|
35
|
+
const findings = check(doc, doc.intro, config.introFields, "도입");
|
|
36
|
+
if (doc.sections.length > 1) findings.push(...check(doc, doc.sections[doc.sections.length - 1], config.endingFields, "마지막 절"));
|
|
37
|
+
return findings;
|
|
38
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { DOCUMENT, NOTICE, finding } from "../finding.js";
|
|
3
|
+
|
|
4
|
+
export const name = "keywordHeading";
|
|
5
|
+
export const mechanism = "contrast";
|
|
6
|
+
/** 목차라고 부를 수 있는 최소 H2 수. 둘 이하는 훑을 목차가 아니다. */
|
|
7
|
+
const MIN_HEADINGS = 3;
|
|
8
|
+
/** 한 글자 낱말은 어느 제목에나 우연히 들어 있어 가르지 못한다. */
|
|
9
|
+
const MIN_WORD = 2;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @param {import("../../fingerprint/build.js").DocumentPrint} doc
|
|
13
|
+
* @param {import("../../config/settings.js").Config} config
|
|
14
|
+
*/
|
|
15
|
+
export function run(doc, config) {
|
|
16
|
+
if (!config.keywordField) return [];
|
|
17
|
+
const keyword = (doc.frontmatter[config.keywordField] ?? "").trim();
|
|
18
|
+
const title = doc.frontmatter.title ?? "";
|
|
19
|
+
const headings = doc.headings.filter(([level]) => level === 2);
|
|
20
|
+
if (!keyword || headings.length < MIN_HEADINGS) return [];
|
|
21
|
+
const joined = headings.map(([, text]) => text).join(" / ");
|
|
22
|
+
// keywordMissing 과 같은 자리를 본다. 제목만 보면 첫 문단에만 있는 검색어가 두 규칙 사이로 빠진다. 파이썬과 같다.
|
|
23
|
+
const first = doc.paragraphs.length ? doc.paragraphs[0].text : "";
|
|
24
|
+
const promised = `${title}\n${first}`;
|
|
25
|
+
const missing = keyword
|
|
26
|
+
.split(/\s+/)
|
|
27
|
+
.filter((word) => word.length >= MIN_WORD && promised.includes(word) && !joined.includes(word));
|
|
28
|
+
if (!missing.length) return [];
|
|
29
|
+
return [
|
|
30
|
+
finding(name, headings[0][2], joined, `글이 내건 \`${missing[0]}\` 가 절 제목 어디에도 없다. 목차를 훑는 독자가 이 글이 그것을 다루는지 못 본다`, null, NOTICE, DOCUMENT, -1),
|
|
31
|
+
];
|
|
32
|
+
}
|
|
@@ -2,10 +2,14 @@
|
|
|
2
2
|
import { DOCUMENT, finding } from "../finding.js";
|
|
3
3
|
|
|
4
4
|
export const name = "noQuestion";
|
|
5
|
+
export const mechanism = "threshold";
|
|
5
6
|
|
|
6
7
|
/** @param {import("../../fingerprint/build.js").DocumentPrint} doc */
|
|
7
8
|
export function run(doc) {
|
|
8
9
|
if (doc.bodySections.length < 2 || doc.questionCount > 0) return [];
|
|
9
10
|
const first = doc.sentences.length ? doc.sentences[0] : null;
|
|
10
|
-
|
|
11
|
+
const why = doc.readerCallCount === 0
|
|
12
|
+
? "물음표가 한 번도 없고 독자를 부르는 말도 없다. 독자가 품을 의문을 한 번은 대신 묻고, 독자가 할 행동을 동사로 끝내는 문장을 넣는다"
|
|
13
|
+
: "물음표가 한 번도 없다. 독자가 품을 의문을 한 번은 대신 묻고 다음 문장에서 답한다";
|
|
14
|
+
return [finding(name, first ? first.line : 1, first ? first.text : "", why, null, "error", DOCUMENT, -1)];
|
|
11
15
|
}
|
|
@@ -2,11 +2,13 @@
|
|
|
2
2
|
import { DOCUMENT, NOTICE, finding } from "../finding.js";
|
|
3
3
|
|
|
4
4
|
export const name = "promiseRecall";
|
|
5
|
+
export const mechanism = "reader";
|
|
5
6
|
|
|
6
7
|
/** @param {import("../../fingerprint/build.js").DocumentPrint} doc */
|
|
7
8
|
export function run(doc) {
|
|
8
|
-
|
|
9
|
-
|
|
9
|
+
const reader = doc.reader.final;
|
|
10
|
+
if (!reader.promises.length || reader.recalls.length) return [];
|
|
11
|
+
return reader.promises.map(([line, text]) =>
|
|
10
12
|
finding(name, line, text, "뒤로 미룬 것을 앞에서 미룬 것으로 다시 부르는 자리가 글 어디에도 없다. 회수하거나 미루지 않는다", null, NOTICE, DOCUMENT, -1),
|
|
11
13
|
);
|
|
12
14
|
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { TABLE } from "../../document/model.js";
|
|
3
|
+
import { DOCUMENT, NOTICE, finding } from "../finding.js";
|
|
4
|
+
|
|
5
|
+
export const name = "tableOddCell";
|
|
6
|
+
export const mechanism = "contrast";
|
|
7
|
+
const SEPARATOR = /^:?-{2,}:?$/;
|
|
8
|
+
// 단위는 한 글자 이상이다. 0 을 허용하면 맨숫자 칸이 빈 단위를 받아 지적문이 깨진다. 파이썬과 같다.
|
|
9
|
+
const MEASURE = /^([+-]?\d[\d,]*(?:\.\d+)?)\s*([^\s\d]{1,6})$/;
|
|
10
|
+
const EMPTY = ["", "-", "--"];
|
|
11
|
+
|
|
12
|
+
/** (블록 안 줄 오프셋, 칸 목록). 구분 줄은 뺀다. @param {string} text @returns {[number, string[]][]} */
|
|
13
|
+
function cellsOf(text) {
|
|
14
|
+
/** @type {[number, string[]][]} */
|
|
15
|
+
const rows = [];
|
|
16
|
+
text.split("\n").forEach((line, offset) => {
|
|
17
|
+
const stripped = line.trim();
|
|
18
|
+
if (!stripped.startsWith("|")) return;
|
|
19
|
+
const cells = stripped.replace(/^\|+/, "").replace(/\|+$/, "").split("|").map((cell) => cell.trim());
|
|
20
|
+
if (cells.length && cells.filter((cell) => cell).every((cell) => SEPARATOR.test(cell))) return;
|
|
21
|
+
rows.push([offset, cells]);
|
|
22
|
+
});
|
|
23
|
+
return rows;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** `3.72초` 는 초, `533MB` 는 MB, `직접 부르지 않음` 은 null. @param {string} cell */
|
|
27
|
+
function unitOf(cell) {
|
|
28
|
+
const match = MEASURE.exec(cell);
|
|
29
|
+
return match ? match[2] : null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {import("../../fingerprint/build.js").DocumentPrint} doc
|
|
34
|
+
* @param {import("../../config/settings.js").Config} config
|
|
35
|
+
*/
|
|
36
|
+
export function run(doc, config) {
|
|
37
|
+
const findings = [];
|
|
38
|
+
for (const block of doc.blocks) {
|
|
39
|
+
if (block.kind !== TABLE) continue;
|
|
40
|
+
const rows = cellsOf(block.text);
|
|
41
|
+
if (rows.length < 2) continue;
|
|
42
|
+
const body = rows.slice(1);
|
|
43
|
+
const width = Math.min(...body.map(([, cells]) => cells.length));
|
|
44
|
+
for (let column = 1; column < width; column += 1) {
|
|
45
|
+
/** @type {[number, string][]} */
|
|
46
|
+
const values = [];
|
|
47
|
+
for (const [offset, cells] of body) if (!EMPTY.includes(cells[column])) values.push([offset, cells[column]]);
|
|
48
|
+
if (values.length < config.tableOddCellMinRows) continue;
|
|
49
|
+
const units = values.map(([offset, cell]) => /** @type {[number, string, string | null]} */ ([offset, cell, unitOf(cell)]));
|
|
50
|
+
const odd = units.filter(([, , unit]) => unit === null);
|
|
51
|
+
const kinds = new Set(units.filter(([, , unit]) => unit !== null).map(([, , unit]) => unit));
|
|
52
|
+
if (odd.length !== 1 || kinds.size !== 1) continue;
|
|
53
|
+
const unit = [...kinds][0];
|
|
54
|
+
const [offset, cell] = odd[0];
|
|
55
|
+
findings.push(
|
|
56
|
+
finding(name, block.startLine + offset, cell, `이 열의 나머지 ${values.length - 1}칸은 \`${unit}\` 으로 잰 값 하나인데 이 칸만 다르다. 같은 잣대로 다시 재거나 그 줄을 본문 문장으로 옮긴다`, null, NOTICE, DOCUMENT, block.index),
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return findings;
|
|
61
|
+
}
|
package/src/rules/finding.js
CHANGED
|
@@ -9,6 +9,8 @@ export const PARAGRAPH = "paragraph";
|
|
|
9
9
|
export const SECTION = "section";
|
|
10
10
|
export const DOCUMENT = "document";
|
|
11
11
|
|
|
12
|
+
const MIN_LOCAL_CUE = 2;
|
|
13
|
+
|
|
12
14
|
/**
|
|
13
15
|
* @typedef {object} Finding
|
|
14
16
|
* @property {string} rule
|
|
@@ -21,8 +23,11 @@ export const DOCUMENT = "document";
|
|
|
21
23
|
* @property {number} at scope 가 가리키는 지문의 index
|
|
22
24
|
* @property {string | null} fragment 원문에서 바꿀 조각. hanlint fix 가 replacement 로 바꾼다
|
|
23
25
|
* @property {string | null} replacement
|
|
26
|
+
* @property {Candidate[]} candidates 뜻을 정하지 않고 형태로 좁힐 수 있을 때만. 순위와 점수는 없다
|
|
24
27
|
*/
|
|
25
28
|
|
|
29
|
+
/** @typedef {{text: string, why: string}} Candidate */
|
|
30
|
+
|
|
26
31
|
/**
|
|
27
32
|
* @param {string} rule
|
|
28
33
|
* @param {number} line
|
|
@@ -34,10 +39,11 @@ export const DOCUMENT = "document";
|
|
|
34
39
|
* @param {number} [at]
|
|
35
40
|
* @param {string | null} [fragment]
|
|
36
41
|
* @param {string | null} [replacement]
|
|
42
|
+
* @param {Candidate[]} [candidates]
|
|
37
43
|
* @returns {Finding}
|
|
38
44
|
*/
|
|
39
|
-
export function finding(rule, line, quote, why, fix = null, severity = ERROR, scope = SENTENCE, at = -1, fragment = null, replacement = null) {
|
|
40
|
-
return { rule, line, quote, why, fix, severity, scope, at, fragment, replacement };
|
|
45
|
+
export function finding(rule, line, quote, why, fix = null, severity = ERROR, scope = SENTENCE, at = -1, fragment = null, replacement = null, candidates = []) {
|
|
46
|
+
return { rule, line, quote, why, fix, severity, scope, at, fragment, replacement, candidates };
|
|
41
47
|
}
|
|
42
48
|
|
|
43
49
|
/** @param {Finding} f */
|
|
@@ -49,5 +55,21 @@ export function findingAsDict(f) {
|
|
|
49
55
|
data.fragment = f.fragment;
|
|
50
56
|
data.replacement = f.replacement;
|
|
51
57
|
}
|
|
58
|
+
if (f.candidates.length) data.candidates = f.candidates.map((candidate) => ({ text: candidate.text, why: candidate.why }));
|
|
52
59
|
return data;
|
|
53
60
|
}
|
|
61
|
+
|
|
62
|
+
/** 규칙이 짚은 국소 표지. 너무 짧으면 오선택을 피하려고 인용 전체를 쓴다. @param {Finding} f */
|
|
63
|
+
export function localCue(f) {
|
|
64
|
+
const flat = (text) => text.split(/\s+/).filter(Boolean).join(" ");
|
|
65
|
+
const quote = flat(f.quote);
|
|
66
|
+
if (f.fragment) {
|
|
67
|
+
const fragment = flat(f.fragment);
|
|
68
|
+
if (fragment.length >= MIN_LOCAL_CUE) return fragment;
|
|
69
|
+
}
|
|
70
|
+
const marked = [...f.why.matchAll(/`([^`\n]+)`/g)]
|
|
71
|
+
.map((match) => flat(match[1]))
|
|
72
|
+
.filter((candidate) => candidate.length >= MIN_LOCAL_CUE && quote.includes(candidate));
|
|
73
|
+
marked.sort((left, right) => right.length - left.length || (left < right ? 1 : left > right ? -1 : 0));
|
|
74
|
+
return marked[0] ?? quote;
|
|
75
|
+
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { NOTICE, PARAGRAPH, finding } from "../finding.js";
|
|
3
3
|
|
|
4
4
|
export const name = "factListParagraph";
|
|
5
|
+
export const mechanism = "threshold";
|
|
5
6
|
|
|
6
7
|
/** 문장 사이를 잇는 표지가 하나라도 있는가. @param {import("../../fingerprint/build.js").ParagraphPrint} paragraph */
|
|
7
8
|
function isLinked(paragraph) {
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
import { PARAGRAPH, finding } from "../finding.js";
|
|
3
|
+
import { runsOf } from "../shared/repeat.js";
|
|
3
4
|
|
|
4
5
|
export const name = "paraFragment";
|
|
6
|
+
export const mechanism = "repeat";
|
|
5
7
|
|
|
6
8
|
/**
|
|
7
9
|
* @param {import("../../fingerprint/build.js").DocumentPrint} doc
|
|
@@ -10,19 +12,27 @@ export const name = "paraFragment";
|
|
|
10
12
|
export function run(doc, config) {
|
|
11
13
|
const findings = [];
|
|
12
14
|
for (const section of doc.sections) {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
15
|
+
const paragraphs = section.paragraphs;
|
|
16
|
+
// 열쇠는 짧은 문단의 구간 번호다. 코드나 표 뒤의 짧은 문단과 긴 문단 뒤의 짧은 문단은 새 구간을 연다.
|
|
17
|
+
/** @type {string[]} */
|
|
18
|
+
const keys = [];
|
|
19
|
+
let runId = 0;
|
|
20
|
+
let previousShort = false;
|
|
21
|
+
for (const paragraph of paragraphs) {
|
|
16
22
|
const short = paragraph.sentenceCount <= 2;
|
|
17
|
-
if (short
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
findings.push(
|
|
23
|
-
finding(name, first.startLine, first.sentences.length ? first.sentences[0].text : "", `${run.length}개 문단이 연달아 한두 문장씩이다. 화제가 같으면 한 문단으로 묶는다. 줄바꿈은 화제가 바뀌는 자리에만`, null, "error", PARAGRAPH, first.index),
|
|
24
|
-
);
|
|
23
|
+
if (short) {
|
|
24
|
+
if (!previousShort || !paragraph.followsProseDirectly) runId += 1;
|
|
25
|
+
keys.push(`short${runId}`);
|
|
26
|
+
} else {
|
|
27
|
+
keys.push(`__${paragraph.index}`);
|
|
25
28
|
}
|
|
29
|
+
previousShort = short;
|
|
30
|
+
}
|
|
31
|
+
for (const [start] of runsOf(keys, config.fragmentRun)) {
|
|
32
|
+
const first = paragraphs[start];
|
|
33
|
+
findings.push(
|
|
34
|
+
finding(name, first.startLine, first.sentences.length ? first.sentences[0].text : "", `${config.fragmentRun}개 문단이 연달아 한두 문장씩이다. 화제가 같으면 한 문단으로 묶는다. 줄바꿈은 화제가 바뀌는 자리에만`, null, "error", PARAGRAPH, first.index),
|
|
35
|
+
);
|
|
26
36
|
}
|
|
27
37
|
}
|
|
28
38
|
return findings;
|