hanlint 0.0.7 → 0.0.8
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 +23 -5
- 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 +347 -0
- package/data/holeKinds.json +1 -2
- 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 +114 -0
- 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 +56 -0
- package/data/ruleDocs.json +29 -17
- package/data/ruleMechanisms.json +56 -0
- package/data/spacing.json +0 -12
- package/data/spelling.json +6 -24
- 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/package.json +13 -5
- 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 +506 -58
- package/src/cli/walk.js +30 -0
- package/src/cli/welcome.js +52 -0
- package/src/config/settings.js +103 -8
- 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 +50 -32
- 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 +4 -4
- 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 +27 -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 +60 -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/paragraph/topicBreak.js +1 -0
- package/src/rules/registry.js +72 -3
- package/src/rules/sentence/cliche.js +1 -0
- package/src/rules/sentence/connectorRepeat.js +8 -7
- package/src/rules/sentence/danglingDeixis.js +8 -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/ellipsis.js +17 -3
- 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 +10 -1
- 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/headingQuestion.js +19 -0
- package/src/rules/structure/headingSentence.js +9 -4
- package/src/rules/structure/headingSkip.js +1 -0
- package/src/rules/structure/headingUniform.js +9 -17
- package/src/rules/structure/introImage.js +19 -0
- package/src/rules/structure/introLong.js +1 -0
- 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/rules/structure/sectionResult.js +3 -2
- package/src/text.js +19 -0
- package/src/rules/document/readerAbsent.js +0 -11
- package/src/rules/shared/runs.js +0 -22
|
@@ -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,27 @@
|
|
|
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
|
+
const missing = keyword.split(/\s+/).filter((word) => word.length >= MIN_WORD && title.includes(word) && !joined.includes(word));
|
|
23
|
+
if (!missing.length) return [];
|
|
24
|
+
return [
|
|
25
|
+
finding(name, headings[0][2], joined, `제목이 약속한 \`${missing[0]}\` 가 절 제목 어디에도 없다. 목차를 훑는 독자가 이 글이 그것을 다루는지 못 본다`, null, NOTICE, DOCUMENT, -1),
|
|
26
|
+
];
|
|
27
|
+
}
|
|
@@ -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,60 @@
|
|
|
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
|
+
const MEASURE = /^([+-]?\d[\d,]*(?:\.\d+)?)\s*([^\s\d]{0,6})$/;
|
|
9
|
+
const EMPTY = ["", "-", "--"];
|
|
10
|
+
|
|
11
|
+
/** (블록 안 줄 오프셋, 칸 목록). 구분 줄은 뺀다. @param {string} text @returns {[number, string[]][]} */
|
|
12
|
+
function cellsOf(text) {
|
|
13
|
+
/** @type {[number, string[]][]} */
|
|
14
|
+
const rows = [];
|
|
15
|
+
text.split("\n").forEach((line, offset) => {
|
|
16
|
+
const stripped = line.trim();
|
|
17
|
+
if (!stripped.startsWith("|")) return;
|
|
18
|
+
const cells = stripped.replace(/^\|+/, "").replace(/\|+$/, "").split("|").map((cell) => cell.trim());
|
|
19
|
+
if (cells.length && cells.filter((cell) => cell).every((cell) => SEPARATOR.test(cell))) return;
|
|
20
|
+
rows.push([offset, cells]);
|
|
21
|
+
});
|
|
22
|
+
return rows;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** `3.72초` 는 초, `533MB` 는 MB, `직접 부르지 않음` 은 null. @param {string} cell */
|
|
26
|
+
function unitOf(cell) {
|
|
27
|
+
const match = MEASURE.exec(cell);
|
|
28
|
+
return match ? match[2] : null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @param {import("../../fingerprint/build.js").DocumentPrint} doc
|
|
33
|
+
* @param {import("../../config/settings.js").Config} config
|
|
34
|
+
*/
|
|
35
|
+
export function run(doc, config) {
|
|
36
|
+
const findings = [];
|
|
37
|
+
for (const block of doc.blocks) {
|
|
38
|
+
if (block.kind !== TABLE) continue;
|
|
39
|
+
const rows = cellsOf(block.text);
|
|
40
|
+
if (rows.length < 2) continue;
|
|
41
|
+
const body = rows.slice(1);
|
|
42
|
+
const width = Math.min(...body.map(([, cells]) => cells.length));
|
|
43
|
+
for (let column = 1; column < width; column += 1) {
|
|
44
|
+
/** @type {[number, string][]} */
|
|
45
|
+
const values = [];
|
|
46
|
+
for (const [offset, cells] of body) if (!EMPTY.includes(cells[column])) values.push([offset, cells[column]]);
|
|
47
|
+
if (values.length < config.tableOddCellMinRows) continue;
|
|
48
|
+
const units = values.map(([offset, cell]) => /** @type {[number, string, string | null]} */ ([offset, cell, unitOf(cell)]));
|
|
49
|
+
const odd = units.filter(([, , unit]) => unit === null);
|
|
50
|
+
const kinds = new Set(units.filter(([, , unit]) => unit !== null).map(([, , unit]) => unit));
|
|
51
|
+
if (odd.length !== 1 || kinds.size !== 1) continue;
|
|
52
|
+
const unit = [...kinds][0];
|
|
53
|
+
const [offset, cell] = odd[0];
|
|
54
|
+
findings.push(
|
|
55
|
+
finding(name, block.startLine + offset, cell, `이 열의 나머지 ${values.length - 1}칸은 \`${unit}\` 으로 잰 값 하나인데 이 칸만 다르다. 같은 잣대로 다시 재거나 그 줄을 본문 문장으로 옮긴다`, null, NOTICE, DOCUMENT, block.index),
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return findings;
|
|
60
|
+
}
|
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;
|
package/src/rules/registry.js
CHANGED
|
@@ -5,12 +5,15 @@
|
|
|
5
5
|
* 기술서 (왜, 어디서, 고치기, 안 잡는 것) 는 파이썬 docstring 의 투영 data/ruleDocs.json 에서 읽는다.
|
|
6
6
|
*/
|
|
7
7
|
import { enabled } from "../config/settings.js";
|
|
8
|
-
import { loadRuleDocs } from "../data/load.js";
|
|
8
|
+
import { loadRuleCategories, loadRuleDocs } from "../data/load.js";
|
|
9
9
|
import * as countMismatch from "./document/countMismatch.js";
|
|
10
|
+
import * as enoughOnce from "./document/enoughOnce.js";
|
|
11
|
+
import * as fieldEcho from "./document/fieldEcho.js";
|
|
12
|
+
import * as keywordHeading from "./document/keywordHeading.js";
|
|
10
13
|
import * as keywordMissing from "./document/keywordMissing.js";
|
|
11
14
|
import * as noQuestion from "./document/noQuestion.js";
|
|
12
15
|
import * as promiseRecall from "./document/promiseRecall.js";
|
|
13
|
-
import * as
|
|
16
|
+
import * as tableOddCell from "./document/tableOddCell.js";
|
|
14
17
|
import * as duplicateBlock from "./code/duplicateBlock.js";
|
|
15
18
|
import * as firstResultDistance from "./code/firstResultDistance.js";
|
|
16
19
|
import * as inputFileSource from "./code/inputFileSource.js";
|
|
@@ -26,6 +29,8 @@ import * as cliche from "./sentence/cliche.js";
|
|
|
26
29
|
import * as hardWord from "./sentence/hardWord.js";
|
|
27
30
|
import * as connectorRepeat from "./sentence/connectorRepeat.js";
|
|
28
31
|
import * as danglingDeixis from "./sentence/danglingDeixis.js";
|
|
32
|
+
import * as draftHistory from "./sentence/draftHistory.js";
|
|
33
|
+
import * as numberOrphan from "./sentence/numberOrphan.js";
|
|
29
34
|
import * as dash from "./sentence/dash.js";
|
|
30
35
|
import * as deixis from "./sentence/deixis.js";
|
|
31
36
|
import * as doubleNegative from "./sentence/doubleNegative.js";
|
|
@@ -39,9 +44,16 @@ import * as japaneseLoan from "./sentence/japaneseLoan.js";
|
|
|
39
44
|
import * as longSentence from "./sentence/longSentence.js";
|
|
40
45
|
import * as negationRedefine from "./sentence/negationRedefine.js";
|
|
41
46
|
import * as nounPile from "./sentence/nounPile.js";
|
|
47
|
+
import * as outsideProfile from "./sentence/outsideProfile.js";
|
|
42
48
|
import * as redundantPair from "./sentence/redundantPair.js";
|
|
43
49
|
import * as translationese from "./sentence/translationese.js";
|
|
50
|
+
import * as blockUnread from "./structure/blockUnread.js";
|
|
51
|
+
import * as bridgeRepeat from "./structure/bridgeRepeat.js";
|
|
44
52
|
import * as emojiBullet from "./structure/emojiBullet.js";
|
|
53
|
+
import * as headingQuestion from "./structure/headingQuestion.js";
|
|
54
|
+
import * as introImage from "./structure/introImage.js";
|
|
55
|
+
import * as loneSubheading from "./structure/loneSubheading.js";
|
|
56
|
+
import * as moreLater from "./structure/moreLater.js";
|
|
45
57
|
import * as headingSentence from "./structure/headingSentence.js";
|
|
46
58
|
import * as headingSkip from "./structure/headingSkip.js";
|
|
47
59
|
import * as headingUniform from "./structure/headingUniform.js";
|
|
@@ -52,6 +64,7 @@ import * as sectionResult from "./structure/sectionResult.js";
|
|
|
52
64
|
/**
|
|
53
65
|
* @typedef {object} Rule
|
|
54
66
|
* @property {string} name
|
|
67
|
+
* @property {string} mechanism 세는 방법. MECHANISMS 의 키 하나
|
|
55
68
|
* @property {(doc: import("../fingerprint/build.js").DocumentPrint, config: import("../config/settings.js").Config) => import("./finding.js").Finding[]} run
|
|
56
69
|
*/
|
|
57
70
|
|
|
@@ -61,6 +74,8 @@ export const RULES = [
|
|
|
61
74
|
connectorRepeat,
|
|
62
75
|
danglingDeixis,
|
|
63
76
|
dash,
|
|
77
|
+
draftHistory,
|
|
78
|
+
numberOrphan,
|
|
64
79
|
deixis,
|
|
65
80
|
doubleNegative,
|
|
66
81
|
doublePassive,
|
|
@@ -74,12 +89,19 @@ export const RULES = [
|
|
|
74
89
|
longSentence,
|
|
75
90
|
negationRedefine,
|
|
76
91
|
nounPile,
|
|
92
|
+
outsideProfile,
|
|
77
93
|
redundantPair,
|
|
78
94
|
translationese,
|
|
79
95
|
factListParagraph,
|
|
80
96
|
paraFragment,
|
|
81
97
|
topicBreak,
|
|
98
|
+
blockUnread,
|
|
99
|
+
bridgeRepeat,
|
|
82
100
|
emojiBullet,
|
|
101
|
+
headingQuestion,
|
|
102
|
+
introImage,
|
|
103
|
+
loneSubheading,
|
|
104
|
+
moreLater,
|
|
83
105
|
headingSentence,
|
|
84
106
|
headingSkip,
|
|
85
107
|
headingUniform,
|
|
@@ -87,10 +109,13 @@ export const RULES = [
|
|
|
87
109
|
sectionNoProse,
|
|
88
110
|
sectionResult,
|
|
89
111
|
countMismatch,
|
|
112
|
+
enoughOnce,
|
|
113
|
+
fieldEcho,
|
|
114
|
+
tableOddCell,
|
|
115
|
+
keywordHeading,
|
|
90
116
|
keywordMissing,
|
|
91
117
|
noQuestion,
|
|
92
118
|
promiseRecall,
|
|
93
|
-
readerAbsent,
|
|
94
119
|
spelling,
|
|
95
120
|
spacing,
|
|
96
121
|
confusable,
|
|
@@ -101,6 +126,23 @@ export const RULES = [
|
|
|
101
126
|
platformApi,
|
|
102
127
|
];
|
|
103
128
|
|
|
129
|
+
/**
|
|
130
|
+
* 규칙이 세는 방법의 닫힌 집합. 파이썬 rules/registry.py 의 MECHANISMS 와 같은 값과 같은 순서다.
|
|
131
|
+
* @type {Record<string, string>}
|
|
132
|
+
*/
|
|
133
|
+
export const MECHANISMS = {
|
|
134
|
+
dictionary: "사전. 낱말 목록과 정규식이 맞는 자리",
|
|
135
|
+
repeat: "반복. 같은 모양이 창 안에서 N 번",
|
|
136
|
+
threshold: "셈. 지문의 값이 임계를 넘거나 모양이 계약과 다른 자리",
|
|
137
|
+
contrast: "대조. 두 자리를 맞대 어긋난 곳",
|
|
138
|
+
reader: "독자 상태. 문장 순서대로 독자가 손에 든 것과 본 것에 그 자리가 요구하는 것을 맞댄다",
|
|
139
|
+
};
|
|
140
|
+
for (const rule of RULES) {
|
|
141
|
+
if (!(rule.mechanism in MECHANISMS)) {
|
|
142
|
+
throw new Error(`규칙 ${rule.name} 의 기제 ${rule.mechanism} 은 닫힌 집합 밖이다 (${Object.keys(MECHANISMS).join(", ")}). 새 기제는 규칙 하나 때문에 들어오지 않는다. 멈춰서 묻는다`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
104
146
|
const BY_NAME = new Map(RULES.map((rule) => [rule.name, rule]));
|
|
105
147
|
|
|
106
148
|
/** 이름 순. */
|
|
@@ -120,6 +162,33 @@ export function ruleSummary(name) {
|
|
|
120
162
|
return ruleDoc(name).split("\n")[0];
|
|
121
163
|
}
|
|
122
164
|
|
|
165
|
+
/**
|
|
166
|
+
* 부류의 사람 이름. 파이썬 rules/registry.py 의 CATEGORY_TITLES 와 같은 값과 같은 순서다.
|
|
167
|
+
* @type {Record<string, string>}
|
|
168
|
+
*/
|
|
169
|
+
export const CATEGORY_TITLES = {
|
|
170
|
+
sentence: "문장 안에서 세는 것",
|
|
171
|
+
paragraph: "문단 사이에서 세는 것",
|
|
172
|
+
structure: "글의 짜임에서 세는 것",
|
|
173
|
+
document: "두 자리를 대조해 세는 것",
|
|
174
|
+
orthography: "표기와 띄어쓰기",
|
|
175
|
+
code: "코드 블록 사이를 대조하는 것",
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
/** 규칙이 세는 방법. @param {string} name */
|
|
179
|
+
export function ruleMechanism(name) {
|
|
180
|
+
const rule = BY_NAME.get(name);
|
|
181
|
+
if (!rule) throw new Error(`모르는 규칙: ${name}. hanlint rules 로 목록을 본다`);
|
|
182
|
+
return rule.mechanism;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** 규칙이 사는 부류. 정본은 파이썬 쪽 폴더이고 여기는 그 투영을 읽는다. @param {string} name */
|
|
186
|
+
export function ruleCategory(name) {
|
|
187
|
+
const categories = loadRuleCategories();
|
|
188
|
+
if (!(name in categories)) throw new Error(`모르는 규칙: ${name}. hanlint rules 로 목록을 본다`);
|
|
189
|
+
return categories[name];
|
|
190
|
+
}
|
|
191
|
+
|
|
123
192
|
/**
|
|
124
193
|
* 인라인 제어가 그 줄에서 그 규칙을 껐는가. `*` 는 전부다.
|
|
125
194
|
* @param {import("./finding.js").Finding} f
|
|
@@ -1,22 +1,23 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
import { SENTENCE, finding } from "../finding.js";
|
|
3
|
+
import { runsOf } from "../shared/repeat.js";
|
|
3
4
|
|
|
4
5
|
export const name = "connectorRepeat";
|
|
6
|
+
export const mechanism = "repeat";
|
|
5
7
|
|
|
6
8
|
/** @param {import("../../fingerprint/build.js").DocumentPrint} doc */
|
|
7
9
|
export function run(doc) {
|
|
8
10
|
const findings = [];
|
|
9
11
|
for (const paragraph of doc.paragraphs) {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
const sentences = paragraph.sentences;
|
|
13
|
+
// 열쇠는 문두 접속부사다. 접속부사가 없는 문장은 저마다 다른 열쇠를 받아 구간을 끊는다.
|
|
14
|
+
const keys = sentences.map((sentence) => sentence.connectorStart || `__${sentence.index}`);
|
|
15
|
+
for (const [start, length, connector] of runsOf(keys, 2)) {
|
|
16
|
+
for (const sentence of sentences.slice(start + 1, start + length)) {
|
|
15
17
|
findings.push(
|
|
16
|
-
finding(name, sentence.line, sentence.text, `\`${
|
|
18
|
+
finding(name, sentence.line, sentence.text, `\`${connector}\` 로 시작하는 문장이 연달아 온다. 앞에서 만든 것의 이름을 불러 잇는다`, null, "error", SENTENCE, sentence.index),
|
|
17
19
|
);
|
|
18
20
|
}
|
|
19
|
-
previous = current;
|
|
20
21
|
}
|
|
21
22
|
}
|
|
22
23
|
return findings;
|
|
@@ -1,18 +1,23 @@
|
|
|
1
1
|
// @ts-check
|
|
2
|
+
import { fitJosa } from "../../analysis/grammar/josa.js";
|
|
2
3
|
import { overlap } from "../../fingerprint/topics.js";
|
|
3
4
|
import { SENTENCE, finding } from "../finding.js";
|
|
5
|
+
import { danglingDeixisCandidates } from "../shared/candidates.js";
|
|
6
|
+
import { hasLocalAntecedent } from "../shared/localAntecedent.js";
|
|
4
7
|
|
|
5
8
|
export const name = "danglingDeixis";
|
|
9
|
+
export const mechanism = "reader";
|
|
6
10
|
|
|
7
11
|
/** @param {import("../../fingerprint/build.js").DocumentPrint} doc */
|
|
8
12
|
export function run(doc) {
|
|
9
13
|
const findings = [];
|
|
10
14
|
for (const sentence of doc.sentences) {
|
|
11
15
|
if (!sentence.deixis.length) continue;
|
|
12
|
-
|
|
13
|
-
|
|
16
|
+
if (hasLocalAntecedent(sentence)) continue;
|
|
17
|
+
const reader = doc.reader.beforeSentence[sentence.index];
|
|
18
|
+
if (overlap(reader.recent, sentence.topics) > 0) continue;
|
|
14
19
|
findings.push(
|
|
15
|
-
finding(name, sentence.line, sentence.text, `\`${sentence.deixis[0]}\` 이 가리킬 것이 앞 문장에 없다. 가리키는 파일, 값, 코드의 이름을 쓴다`, null, "error", SENTENCE, sentence.index),
|
|
20
|
+
finding(name, sentence.line, sentence.text, `\`${sentence.deixis[0]}\` ${fitJosa(sentence.deixis[0], "이")} 가리킬 것이 앞 문장에 없다. 가리키는 파일, 값, 코드의 이름을 쓴다`, null, "error", SENTENCE, sentence.index, null, null, danglingDeixisCandidates(sentence, reader.previous)),
|
|
16
21
|
);
|
|
17
22
|
}
|
|
18
23
|
return findings;
|
|
@@ -2,13 +2,14 @@
|
|
|
2
2
|
import { DOCUMENT, finding } from "../finding.js";
|
|
3
3
|
|
|
4
4
|
export const name = "dash";
|
|
5
|
+
export const mechanism = "dictionary";
|
|
5
6
|
// 리터럴로 쓰면 이 파일이 자기 게이트에 걸린다. 코드포인트로 만든다.
|
|
6
7
|
const DASHES = new RegExp(`[${String.fromCharCode(0x2013)}${String.fromCharCode(0x2014)}]`);
|
|
7
8
|
|
|
8
9
|
/** @param {import("../../fingerprint/build.js").DocumentPrint} doc */
|
|
9
10
|
export function run(doc) {
|
|
10
11
|
const findings = [];
|
|
11
|
-
for (const block of doc.blocks) {
|
|
12
|
+
for (const block of [...doc.blocks, ...doc.ignored]) {
|
|
12
13
|
block.text.split("\n").forEach((lineText, offset) => {
|
|
13
14
|
if (DASHES.test(lineText)) {
|
|
14
15
|
findings.push(
|