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
|
@@ -1,18 +1,22 @@
|
|
|
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 { hasLocalAntecedent } from "../shared/localAntecedent.js";
|
|
4
6
|
|
|
5
7
|
export const name = "deixis";
|
|
8
|
+
export const mechanism = "reader";
|
|
6
9
|
|
|
7
10
|
/** @param {import("../../fingerprint/build.js").DocumentPrint} doc */
|
|
8
11
|
export function run(doc) {
|
|
9
12
|
const findings = [];
|
|
10
13
|
for (const sentence of doc.sentences) {
|
|
11
14
|
if (!sentence.deixis.length || sentence.index === 0) continue;
|
|
12
|
-
|
|
13
|
-
|
|
15
|
+
if (hasLocalAntecedent(sentence)) continue;
|
|
16
|
+
const reader = doc.reader.beforeSentence[sentence.index];
|
|
17
|
+
if (overlap(reader.recent, sentence.topics) === 0) continue;
|
|
14
18
|
findings.push(
|
|
15
|
-
finding(name, sentence.line, sentence.text, `\`${sentence.deixis[0]}\` 은 독자가 스크롤을 되돌려야 하는 지시어다. 가리키는 파일, 값, 코드의 이름을 쓴다`, null, "error", SENTENCE, sentence.index),
|
|
19
|
+
finding(name, sentence.line, sentence.text, `\`${sentence.deixis[0]}\` ${fitJosa(sentence.deixis[0], "은")} 독자가 스크롤을 되돌려야 하는 지시어다. 가리키는 파일, 값, 코드의 이름을 쓴다`, null, "error", SENTENCE, sentence.index),
|
|
16
20
|
);
|
|
17
21
|
}
|
|
18
22
|
return findings;
|
|
@@ -2,13 +2,16 @@
|
|
|
2
2
|
import { SENTENCE, finding } from "../finding.js";
|
|
3
3
|
|
|
4
4
|
export const name = "doubleNegative";
|
|
5
|
-
const
|
|
5
|
+
export const mechanism = "dictionary";
|
|
6
|
+
const DOUBLE_NEGATIVE = /(하지 않으면 안 (?:된다|됩니다|돼요)|하지 않을 수 없(?:다|습니다|어요)|지 않으면 안 (?:된다|됩니다|돼요)|지 않을 수 없(?:다|습니다|어요))/;
|
|
6
7
|
/** @type {Record<string, string>} */
|
|
7
8
|
const FIXES = {
|
|
8
9
|
"하지 않으면 안 된다": "해야 한다",
|
|
9
10
|
"하지 않으면 안 됩니다": "해야 합니다",
|
|
10
11
|
"하지 않을 수 없다": "한다",
|
|
11
12
|
"하지 않을 수 없습니다": "합니다",
|
|
13
|
+
"하지 않으면 안 돼요": "해야 해요",
|
|
14
|
+
"하지 않을 수 없어요": "해요",
|
|
12
15
|
};
|
|
13
16
|
|
|
14
17
|
/** @param {import("../../fingerprint/build.js").DocumentPrint} doc */
|
|
@@ -1,15 +1,52 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
import { SENTENCE, finding } from "../finding.js";
|
|
3
|
+
import { doublePassiveCandidates } from "../shared/candidates.js";
|
|
3
4
|
|
|
4
5
|
export const name = "doublePassive";
|
|
6
|
+
export const mechanism = "dictionary";
|
|
7
|
+
|
|
8
|
+
/** 검토를 통과한 이중 피동 후보에서 인용 밖의 단일 치환만 꺼낸다.
|
|
9
|
+
* @param {string} text @param {string} candidateText @param {[number, number][]} quoted
|
|
10
|
+
* @returns {[string, string] | null} */
|
|
11
|
+
function approvedEdit(text, candidateText, quoted) {
|
|
12
|
+
let prefix = 0;
|
|
13
|
+
while (prefix < text.length && prefix < candidateText.length && text[prefix] === candidateText[prefix]) prefix += 1;
|
|
14
|
+
let suffix = 0;
|
|
15
|
+
while (
|
|
16
|
+
suffix < text.length - prefix &&
|
|
17
|
+
suffix < candidateText.length - prefix &&
|
|
18
|
+
text[text.length - suffix - 1] === candidateText[candidateText.length - suffix - 1]
|
|
19
|
+
) suffix += 1;
|
|
20
|
+
const sourceEnd = text.length - suffix;
|
|
21
|
+
const candidateEnd = candidateText.length - suffix;
|
|
22
|
+
const fragment = text.slice(prefix, sourceEnd);
|
|
23
|
+
const replacement = candidateText.slice(prefix, candidateEnd);
|
|
24
|
+
if (!fragment || text.indexOf(fragment) !== text.lastIndexOf(fragment)) return null;
|
|
25
|
+
if (quoted.some(([start, end]) => prefix < end && sourceEnd > start)) return null;
|
|
26
|
+
return [fragment, replacement];
|
|
27
|
+
}
|
|
5
28
|
|
|
6
29
|
/** @param {import("../../fingerprint/build.js").DocumentPrint} doc */
|
|
7
30
|
export function run(doc) {
|
|
8
31
|
const findings = [];
|
|
9
32
|
for (const sentence of doc.sentences) {
|
|
10
33
|
if (sentence.passives.length) {
|
|
34
|
+
const candidates = doublePassiveCandidates(sentence.text, sentence.passives);
|
|
35
|
+
const approved = candidates.length === 1 ? approvedEdit(sentence.text, candidates[0].text, sentence.quoted) : null;
|
|
11
36
|
findings.push(
|
|
12
|
-
finding(
|
|
37
|
+
finding(
|
|
38
|
+
name,
|
|
39
|
+
sentence.line,
|
|
40
|
+
sentence.text,
|
|
41
|
+
`\`${sentence.passives[0]}\` 는 피동에 어지다 를 또 붙인 이중 피동이다. 하나만 남긴다`,
|
|
42
|
+
approved ? candidates[0].text : null,
|
|
43
|
+
"error",
|
|
44
|
+
SENTENCE,
|
|
45
|
+
sentence.index,
|
|
46
|
+
approved ? approved[0] : null,
|
|
47
|
+
approved ? approved[1] : null,
|
|
48
|
+
approved ? [] : candidates,
|
|
49
|
+
),
|
|
13
50
|
);
|
|
14
51
|
}
|
|
15
52
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { insideAny, matchedSpans } from "../../fingerprint/markers.js";
|
|
3
|
+
import { SENTENCE, finding } from "../finding.js";
|
|
4
|
+
|
|
5
|
+
export const name = "draftHistory";
|
|
6
|
+
export const mechanism = "dictionary";
|
|
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
|
+
const findings = [];
|
|
15
|
+
for (const sentence of doc.sentences) {
|
|
16
|
+
for (const [start, end, text] of matchedSpans(sentence.text, "draftHistory.txt")) {
|
|
17
|
+
if (insideAny(start, end, sentence.quoted)) continue;
|
|
18
|
+
findings.push(
|
|
19
|
+
finding(name, sentence.line, sentence.text, `\`${text}\` 는 글쓴이의 과정이다. 독자는 초고를 본 적이 없다. 알아낸 사실만 결과 문장으로 남긴다`, null, "error", SENTENCE, sentence.index),
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return findings;
|
|
24
|
+
}
|
|
@@ -1,16 +1,30 @@
|
|
|
1
1
|
// @ts-check
|
|
2
|
+
import { insideAny } from "../../fingerprint/markers.js";
|
|
2
3
|
import { SENTENCE, finding } from "../finding.js";
|
|
3
4
|
|
|
4
5
|
export const name = "ellipsis";
|
|
5
|
-
|
|
6
|
+
export const mechanism = "dictionary";
|
|
6
7
|
// 영숫자에 붙은 점 셋 (`v0.0.1...HEAD`, compare URL) 은 범위 표기라 말줄임표가 아니다.
|
|
7
|
-
|
|
8
|
+
// 인라인 코드와 따옴표 안은 지문이 인용 구간으로 이미 표시해 두었으므로 여기서 다시 재지 않는다.
|
|
9
|
+
const ELLIPSIS = /(?<![A-Za-z0-9])(…|\.{3,})(?![A-Za-z0-9])/g;
|
|
10
|
+
const CLI_OPTION = /(?:^|\s)--?[A-Za-z0-9]/;
|
|
11
|
+
|
|
12
|
+
/** 괄호 안 명령 예시의 생략 인자. 예: (kubectl get -o yaml …). @param {string} text @param {number} start @param {number} end */
|
|
13
|
+
function insideCommandExample(text, start, end) {
|
|
14
|
+
const opened = text.lastIndexOf("(", start);
|
|
15
|
+
const closed = text.indexOf(")", end);
|
|
16
|
+
return opened >= 0 && closed >= 0 && !text.slice(opened, start).includes(")") && !text.slice(end, closed).includes("(") && CLI_OPTION.test(text.slice(opened + 1, closed));
|
|
17
|
+
}
|
|
8
18
|
|
|
9
19
|
/** @param {import("../../fingerprint/build.js").DocumentPrint} doc */
|
|
10
20
|
export function run(doc) {
|
|
11
21
|
const findings = [];
|
|
12
22
|
for (const sentence of doc.sentences) {
|
|
13
|
-
|
|
23
|
+
const found = [...sentence.text.matchAll(ELLIPSIS)].filter((m) => {
|
|
24
|
+
const start = /** @type {number} */ (m.index);
|
|
25
|
+
return !insideAny(start, start + m[0].length, sentence.quoted) && !insideCommandExample(sentence.text, start, start + m[0].length);
|
|
26
|
+
});
|
|
27
|
+
if (found.length) {
|
|
14
28
|
findings.push(finding(name, sentence.line, sentence.text, "말줄임표로 말끝을 흐리면 독자가 결론을 받아 가지 못한다. 문장을 끝까지 쓴다", null, "error", SENTENCE, sentence.index));
|
|
15
29
|
}
|
|
16
30
|
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
import { NOTICE, SENTENCE, finding } from "../finding.js";
|
|
3
|
-
import { runsOf } from "../shared/
|
|
3
|
+
import { runsOf } from "../shared/repeat.js";
|
|
4
4
|
|
|
5
5
|
export const name = "endingRepeat";
|
|
6
|
+
export const mechanism = "repeat";
|
|
6
7
|
const COUNTED_ENDINGS = new Set(["니다", "다", "것이다", "요", "죠"]);
|
|
7
8
|
|
|
8
9
|
/**
|
|
@@ -15,9 +16,12 @@ export function run(doc, config) {
|
|
|
15
16
|
const sentences = section.paragraphs.flatMap((p) => p.sentences);
|
|
16
17
|
const endings = sentences.map((s) => (COUNTED_ENDINGS.has(s.ending) ? s.ending : `__${s.index}`));
|
|
17
18
|
for (const [start, length, ending] of runsOf(endings, config.endingRun)) {
|
|
19
|
+
// 구간 어딘가에 이유를 잇는 말이나 독자를 부르는 말이 있으면 리듬이 산 것이다. 구간을 쪼개면
|
|
20
|
+
// 조각마다 지적이 되어 오히려 늘어난다. 구간 전체를 하나로 보고 살린다.
|
|
21
|
+
if (sentences.slice(start, start + length).some((s) => s.causal || s.readerCall)) continue;
|
|
18
22
|
const first = sentences[start];
|
|
19
23
|
findings.push(
|
|
20
|
-
finding(name, first.line, first.text, `\`${ending}\` 로 끝나는
|
|
24
|
+
finding(name, first.line, first.text, `\`${ending}\` 로 끝나는 문장 ${length}개에 인과도 질문도 독자를 부르는 말도 없다. 질문, 인과, 행동 동사로 리듬을 바꾼다`, null, NOTICE, SENTENCE, first.index),
|
|
21
25
|
);
|
|
22
26
|
}
|
|
23
27
|
}
|
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
// @ts-check
|
|
2
|
+
import { euiAdjacent } from "../../analysis/index.js";
|
|
2
3
|
import { SENTENCE, finding } from "../finding.js";
|
|
3
4
|
|
|
4
5
|
export const name = "euiChain";
|
|
5
|
-
const
|
|
6
|
+
export const mechanism = "threshold";
|
|
6
7
|
|
|
7
8
|
/** @param {import("../../fingerprint/build.js").DocumentPrint} doc */
|
|
8
9
|
export function run(doc) {
|
|
9
10
|
const findings = [];
|
|
10
11
|
for (const sentence of doc.sentences) {
|
|
11
|
-
if (sentence.euiCount >= 3 || (sentence.euiCount >= 2 &&
|
|
12
|
+
if (sentence.euiCount >= 3 || (sentence.euiCount >= 2 && euiAdjacent(sentence.text))) {
|
|
12
13
|
findings.push(
|
|
13
14
|
finding(name, sentence.line, sentence.text, `한 문장에 \`의\` 가 ${sentence.euiCount}번이다. 명사를 쌓은 자리라 관계가 표시되지 않는다. 동사로 편다`, null, "error", SENTENCE, sentence.index),
|
|
14
15
|
);
|
|
@@ -3,6 +3,7 @@ import { NOTICE } from "../finding.js";
|
|
|
3
3
|
import { dictionaryFindings } from "../shared/dictionaryRule.js";
|
|
4
4
|
|
|
5
5
|
export const name = "hardWord";
|
|
6
|
+
export const mechanism = "dictionary";
|
|
6
7
|
|
|
7
8
|
/** @param {import("../../fingerprint/build.js").DocumentPrint} doc */
|
|
8
9
|
export function run(doc) {
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
import { SENTENCE, finding } from "../finding.js";
|
|
3
3
|
|
|
4
4
|
export const name = "imperativePeriod";
|
|
5
|
-
const
|
|
5
|
+
export const mechanism = "dictionary";
|
|
6
|
+
const IMPERATIVE_PERIOD = /(세요|십시오|합시다|봅시다|하자|해라|라)\.(?=\s|$)/;
|
|
6
7
|
|
|
7
8
|
/** @param {import("../../fingerprint/build.js").DocumentPrint} doc */
|
|
8
9
|
export function run(doc) {
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
import { NOTICE, SENTENCE, finding } from "../finding.js";
|
|
3
|
+
import { longSentenceCandidates } from "../shared/candidates.js";
|
|
3
4
|
|
|
4
5
|
export const name = "longSentence";
|
|
6
|
+
export const mechanism = "threshold";
|
|
5
7
|
|
|
6
8
|
/**
|
|
7
9
|
* @param {import("../../fingerprint/build.js").DocumentPrint} doc
|
|
@@ -12,7 +14,7 @@ export function run(doc, config) {
|
|
|
12
14
|
for (const sentence of doc.sentences) {
|
|
13
15
|
if (sentence.length > config.longSentenceMax) {
|
|
14
16
|
findings.push(
|
|
15
|
-
finding(name, sentence.line, sentence.text, `어절 ${sentence.length}개다. 상한은 ${config.longSentenceMax}. 마침표로 끊거나 나열이면 목록으로 꺼낸다`, null, NOTICE, SENTENCE, sentence.index),
|
|
17
|
+
finding(name, sentence.line, sentence.text, `어절 ${sentence.length}개다. 상한은 ${config.longSentenceMax}. 마침표로 끊거나 나열이면 목록으로 꺼낸다`, null, NOTICE, SENTENCE, sentence.index, null, null, longSentenceCandidates(sentence.text)),
|
|
16
18
|
);
|
|
17
19
|
}
|
|
18
20
|
}
|
|
@@ -1,10 +1,19 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
import { SENTENCE, finding } from "../finding.js";
|
|
3
|
+
import { COPULA as COPULA_KIND, lastWord, parsePredicate } from "../../analysis/grammar/index.js";
|
|
3
4
|
|
|
4
5
|
export const name = "negationRedefine";
|
|
6
|
+
export const mechanism = "repeat";
|
|
5
7
|
const NEGATION = /단순(?:한|히)\s?.{0,15}?(?:이|가)\s?아(?:닙니다|니다|니에요|니죠|닌)/;
|
|
6
8
|
const COPULA = /(입니다|이다|이에요|예요)[.!]?$/;
|
|
7
9
|
|
|
10
|
+
/** @param {string} text */
|
|
11
|
+
function isDefinition(text) {
|
|
12
|
+
if (COPULA.test(text.trim())) return true;
|
|
13
|
+
const predicate = parsePredicate(lastWord(text));
|
|
14
|
+
return predicate !== null && predicate.kind === COPULA_KIND;
|
|
15
|
+
}
|
|
16
|
+
|
|
8
17
|
/** @param {import("../../fingerprint/build.js").DocumentPrint} doc */
|
|
9
18
|
export function run(doc) {
|
|
10
19
|
const findings = [];
|
|
@@ -13,7 +22,7 @@ export function run(doc) {
|
|
|
13
22
|
for (let i = 0; i + 1 < sentences.length; i++) {
|
|
14
23
|
const current = sentences[i];
|
|
15
24
|
const following = sentences[i + 1];
|
|
16
|
-
if (NEGATION.test(current.text) &&
|
|
25
|
+
if (NEGATION.test(current.text) && isDefinition(following.text)) {
|
|
17
26
|
findings.push(finding(name, current.line, current.text, "단순한 X 가 아닙니다 뒤에 Y 입니다 로 재정의하는 공식이다. 앞 문장을 지우고 Y 를 바로 쓴다", null, "error", SENTENCE, current.index));
|
|
18
27
|
}
|
|
19
28
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { fitJosa } from "../../analysis/grammar/josa.js";
|
|
3
|
+
import { matchedTexts, numeralsIn } from "../../fingerprint/markers.js";
|
|
4
|
+
import { NOTICE, SENTENCE, finding } from "../finding.js";
|
|
5
|
+
|
|
6
|
+
export const name = "numberOrphan";
|
|
7
|
+
export const mechanism = "reader";
|
|
8
|
+
const RANGE = /(\d[\d,]*(?:\.\d+)?)[^\s.!?]{0,4}\s?에서\s?(\d[\d,]*(?:\.\d+)?)[^\s.!?]{0,4}\s?(?:로|으로)/g;
|
|
9
|
+
/** 한 자리 수는 재는 값이 아니라 세는 말이라 뺀다. */
|
|
10
|
+
const MIN_DIGITS = 2;
|
|
11
|
+
/** 기준값 앞 몇 글자에서 출처를 밝히는 말을 찾는가. */
|
|
12
|
+
const ANCHOR_WINDOW = 12;
|
|
13
|
+
|
|
14
|
+
/** @param {string} numeral */
|
|
15
|
+
function isMeasured(numeral) {
|
|
16
|
+
return numeral.includes(".") || numeral.replaceAll(".", "").length >= MIN_DIGITS;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** 값 바로 앞이 그 값이 어디서 온 것인지 말하고 있는가. @param {string} text @param {number} start */
|
|
20
|
+
function isAnchored(text, start) {
|
|
21
|
+
return matchedTexts(text.slice(Math.max(0, start - ANCHOR_WINDOW), start), "baselineAnchors.txt").length > 0;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {import("../../fingerprint/build.js").DocumentPrint} doc
|
|
26
|
+
* @param {import("../../config/settings.js").Config} config
|
|
27
|
+
*/
|
|
28
|
+
export function run(doc, config) {
|
|
29
|
+
void config;
|
|
30
|
+
const findings = [];
|
|
31
|
+
for (const sentence of doc.sentences) {
|
|
32
|
+
const reader = doc.reader.beforeSentence[sentence.index];
|
|
33
|
+
for (const match of sentence.text.matchAll(RANGE)) {
|
|
34
|
+
const base = match[1].replaceAll(",", "");
|
|
35
|
+
if (!isMeasured(base) || base === match[2].replaceAll(",", "")) continue;
|
|
36
|
+
const start = /** @type {number} */ (match.index);
|
|
37
|
+
if (reader.numerals.has(base) || numeralsIn(sentence.text.slice(0, start)).has(base)) continue;
|
|
38
|
+
if (isAnchored(sentence.text, start)) continue;
|
|
39
|
+
findings.push(
|
|
40
|
+
finding(name, sentence.line, sentence.text, `\`${base}\` ${fitJosa(base, "가")} 여기서 처음 나오는 값이다. 무엇에서 올라간 것인지 알 수 없다. 그 값이 어디서 나왔는지 밝힌다`, null, NOTICE, SENTENCE, sentence.index),
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return findings;
|
|
45
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { PROFILE_OF } from "../../config/settings.js";
|
|
3
|
+
import { profileOf, shareAtOrAbove, userProfile } from "../../data/profiles.js";
|
|
4
|
+
import { NOTICE, SENTENCE, finding } from "../finding.js";
|
|
5
|
+
|
|
6
|
+
export const name = "outsideProfile";
|
|
7
|
+
export const mechanism = "threshold";
|
|
8
|
+
/** 대조하는 문장 지표와 지적문의 이름과 단위. 파이썬 outsideProfile.py 와 같다. */
|
|
9
|
+
const FEATURES = [
|
|
10
|
+
["length", "문장 길이", "어절"],
|
|
11
|
+
["commas", "쉼표", "개"],
|
|
12
|
+
["newTopics", "처음 나온 화제어", "개"],
|
|
13
|
+
["hedges", "헤지 표현", "개"],
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
/** @param {import("../../config/settings.js").Config} config */
|
|
17
|
+
function profileFor(config) {
|
|
18
|
+
if (config.profile) return userProfile(config.profile);
|
|
19
|
+
const kind = PROFILE_OF[config.preset];
|
|
20
|
+
return kind ? profileOf(kind) : null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** @param {number} permille */
|
|
24
|
+
function shareText(permille) {
|
|
25
|
+
return permille === 0 ? "0.1% 아래" : `${Math.floor(permille / 10)}.${permille % 10}%`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** @param {Set<string>} topics @param {Set<string>} known */
|
|
29
|
+
function freshCount(topics, known) {
|
|
30
|
+
let fresh = 0;
|
|
31
|
+
for (const topic of topics) if (!known.has(topic)) fresh += 1;
|
|
32
|
+
return fresh;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param {import("../../fingerprint/build.js").DocumentPrint} doc
|
|
37
|
+
* @param {import("../../config/settings.js").Config} config
|
|
38
|
+
*/
|
|
39
|
+
export function run(doc, config) {
|
|
40
|
+
const profile = profileFor(config);
|
|
41
|
+
if (!profile) return [];
|
|
42
|
+
const p = config.profilePercentile;
|
|
43
|
+
const findings = [];
|
|
44
|
+
for (const sentence of doc.sentences) {
|
|
45
|
+
const known = doc.reader.beforeSentence[sentence.index].known;
|
|
46
|
+
const values = { length: sentence.length, commas: sentence.commas, newTopics: freshCount(sentence.topics, known), hedges: sentence.hedges };
|
|
47
|
+
for (const [key, label, unit] of FEATURES) {
|
|
48
|
+
const hist = profile.sentence.get(key);
|
|
49
|
+
if (!hist || !hist.percentiles.has(p)) continue;
|
|
50
|
+
const limit = /** @type {number} */ (hist.percentiles.get(p));
|
|
51
|
+
const value = values[/** @type {"length" | "commas" | "newTopics" | "hedges"} */ (key)];
|
|
52
|
+
if (limit === 0 || value <= limit) continue;
|
|
53
|
+
findings.push(
|
|
54
|
+
finding(
|
|
55
|
+
name,
|
|
56
|
+
sentence.line,
|
|
57
|
+
sentence.text,
|
|
58
|
+
`${label} ${value}${unit}. ${profile.label} ${profile.sentences}문장 가운데 상위 ${shareText(shareAtOrAbove(hist, value))}다. ${p}%는 ${limit}${unit} 이하다`,
|
|
59
|
+
null,
|
|
60
|
+
NOTICE,
|
|
61
|
+
SENTENCE,
|
|
62
|
+
sentence.index,
|
|
63
|
+
),
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return findings;
|
|
68
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/** 파이썬 rules/shared/candidates.py의 투영. 후보에는 점수와 순위가 없다. */
|
|
3
|
+
import { HAEYO, HANDA, HAPNIDA, REGISTERS, conjugate, fitJosa } from "../../analysis/grammar/index.js";
|
|
4
|
+
import * as hangul from "../../analysis/grammar/hangul.js";
|
|
5
|
+
import { decomposePassive } from "../../analysis/grammar/voice.js";
|
|
6
|
+
import { stripJosa, words } from "../../analysis/tokenize.js";
|
|
7
|
+
import { loadLines } from "../../data/load.js";
|
|
8
|
+
|
|
9
|
+
const ENDING_CANDIDATES = {
|
|
10
|
+
[HAPNIDA]: [["-습니까?", "같은 문체의 의문형"], ["-기 때문입니다", "같은 문체의 인과형"]],
|
|
11
|
+
[HANDA]: [["-는가?", "같은 문체의 의문형"], ["-기 때문이다", "같은 문체의 인과형"]],
|
|
12
|
+
[HAEYO]: [["-나요?", "같은 문체의 의문형"], ["-기 때문이에요", "같은 문체의 인과형"]],
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/** @param {string} text @returns {import("../finding.js").Candidate[]} */
|
|
16
|
+
export function longSentenceCandidates(text) {
|
|
17
|
+
const endings = [...loadLines("candidateSplitEndings.txt")].sort((a, b) => b.length - a.length);
|
|
18
|
+
const pattern = new RegExp(`([가-힣]+(${endings.join("|")}))[,;]?\\s+`, "g");
|
|
19
|
+
const candidates = [];
|
|
20
|
+
for (const match of text.matchAll(pattern)) {
|
|
21
|
+
const word = match[1];
|
|
22
|
+
const ending = match[2];
|
|
23
|
+
if (word === "그리고" || word.length <= ending.length) continue;
|
|
24
|
+
const end = /** @type {number} */ (match.index) + match[0].length;
|
|
25
|
+
const marked = text.slice(0, end).trimEnd() + " | " + text.slice(end).trimStart();
|
|
26
|
+
candidates.push({ text: marked, why: `연결 어미 \`${ending}\` 뒤를 문장 경계로 검토한다` });
|
|
27
|
+
}
|
|
28
|
+
return candidates;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** @param {string} topic @param {string} deixis */
|
|
32
|
+
function replacementForDeixis(topic, deixis) {
|
|
33
|
+
if (deixis.startsWith("이것")) {
|
|
34
|
+
const suffix = deixis.slice("이것".length);
|
|
35
|
+
return topic + fitJosa(topic, suffix);
|
|
36
|
+
}
|
|
37
|
+
if (deixis.startsWith("해당 ")) {
|
|
38
|
+
const last = deixis.trim().split(/\s+/).at(-1) ?? "";
|
|
39
|
+
const base = stripJosa(last);
|
|
40
|
+
return topic + fitJosa(topic, last.slice(base.length));
|
|
41
|
+
}
|
|
42
|
+
if (deixis.startsWith("이러한 ")) return topic + "의 " + deixis.slice("이러한 ".length);
|
|
43
|
+
if (deixis === "이처럼") return topic + "처럼";
|
|
44
|
+
return topic;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** @param {import("../../fingerprint/build.js").SentencePrint} current @param {import("../../fingerprint/build.js").SentencePrint | null} previous */
|
|
48
|
+
export function danglingDeixisCandidates(current, previous) {
|
|
49
|
+
if (previous === null || !current.deixis.length) return [];
|
|
50
|
+
const deixis = current.deixis[0];
|
|
51
|
+
const topics = [...previous.topics].sort((a, b) => previous.text.toLowerCase().indexOf(a) - previous.text.toLowerCase().indexOf(b) || a.localeCompare(b));
|
|
52
|
+
return topics.map((topic) => ({
|
|
53
|
+
text: current.text.replace(deixis, replacementForDeixis(topic, deixis)),
|
|
54
|
+
why: `바로 앞 문장에 나온 명사 \`${topic}\``,
|
|
55
|
+
}));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** @param {string} text */
|
|
59
|
+
export function nounPileCandidates(text) {
|
|
60
|
+
const actionNouns = new Set(loadLines("candidateActionNouns.txt"));
|
|
61
|
+
const found = [];
|
|
62
|
+
for (const word of words(text)) {
|
|
63
|
+
const core = stripJosa(word.core);
|
|
64
|
+
if (actionNouns.has(core) && !found.includes(core)) found.push(core);
|
|
65
|
+
}
|
|
66
|
+
return found.map((noun) => ({ text: noun + "하다", why: `명사 \`${noun}\`${fitJosa(noun, "을")} 동사 어근으로 되돌린다` }));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** @param {string} register */
|
|
70
|
+
export function endingRepeatCandidates(register) {
|
|
71
|
+
const target = REGISTERS.includes(register) ? register : HAPNIDA;
|
|
72
|
+
return ENDING_CANDIDATES[target].map(([text, why]) => ({ text, why }));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** @param {string} text @param {string} surface @param {string} reduced */
|
|
76
|
+
function reducedPassiveText(text, surface, reduced) {
|
|
77
|
+
const prefix = surface.slice(0, -1);
|
|
78
|
+
let start = 0;
|
|
79
|
+
while ((start = text.indexOf(prefix, start)) >= 0) {
|
|
80
|
+
const tailAt = start + prefix.length;
|
|
81
|
+
const tail = text[tailAt];
|
|
82
|
+
if (!tail || !hangul.isSyllable(tail)) {
|
|
83
|
+
start += 1;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
const [initial, vowel, final] = hangul.split(tail);
|
|
87
|
+
if (initial !== 12 || ![hangul.IH, hangul.YEO].includes(vowel)) {
|
|
88
|
+
start += 1;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
let replacement;
|
|
92
|
+
if (vowel === hangul.YEO) {
|
|
93
|
+
const base = conjugate(reduced);
|
|
94
|
+
replacement = final ? base.slice(0, -1) + hangul.withFinal(base.at(-1), final) : base;
|
|
95
|
+
} else if (final === hangul.BIEUP) {
|
|
96
|
+
const polite = renderPolite(reduced);
|
|
97
|
+
replacement = polite.slice(0, -2);
|
|
98
|
+
} else {
|
|
99
|
+
replacement = final ? reduced.slice(0, -1) + hangul.withFinal(reduced.at(-1), final) : reduced;
|
|
100
|
+
}
|
|
101
|
+
return text.slice(0, start) + replacement + text.slice(tailAt + 1);
|
|
102
|
+
}
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** polite는 공개 함수가 아니므로 합니다체 활용에서 니다만 뗀다. @param {string} reduced */
|
|
107
|
+
function renderPolite(reduced) {
|
|
108
|
+
const last = hangul.finalOf(reduced.at(-1));
|
|
109
|
+
if (last === hangul.NONE || last === hangul.RIEUL) {
|
|
110
|
+
const stem = last === hangul.RIEUL ? reduced.slice(0, -1) + hangul.withFinal(reduced.at(-1), hangul.NONE) : reduced;
|
|
111
|
+
return stem.slice(0, -1) + hangul.withFinal(stem.at(-1), hangul.BIEUP) + "니다";
|
|
112
|
+
}
|
|
113
|
+
return reduced + "습니다";
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** @param {string} text @param {string[]} passives */
|
|
117
|
+
export function doublePassiveCandidates(text, passives) {
|
|
118
|
+
const candidates = [];
|
|
119
|
+
const seen = new Set();
|
|
120
|
+
for (const surface of passives) {
|
|
121
|
+
const voice = decomposePassive(surface);
|
|
122
|
+
if (!voice || voice.reduced === null) continue;
|
|
123
|
+
const candidateText = reducedPassiveText(text, surface, voice.reduced);
|
|
124
|
+
if (candidateText === null || seen.has(candidateText)) continue;
|
|
125
|
+
seen.add(candidateText);
|
|
126
|
+
candidates.push({ text: candidateText, why: `\`${surface}\`의 피동 겹을 하나로 줄인다` });
|
|
127
|
+
}
|
|
128
|
+
return candidates;
|
|
129
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
/** 사전 규칙의 공통 구현. cliche, translationese, redundantPair, japaneseLoan 이 사전 이름만 바꿔 쓴다. */
|
|
3
|
+
import { fitJosa } from "../../analysis/grammar/josa.js";
|
|
3
4
|
import { ERROR, SENTENCE, finding } from "../finding.js";
|
|
4
5
|
|
|
5
6
|
/**
|
|
@@ -9,16 +10,25 @@ import { ERROR, SENTENCE, finding } from "../finding.js";
|
|
|
9
10
|
* @param {string} [severity]
|
|
10
11
|
* @returns {import("../finding.js").Finding[]}
|
|
11
12
|
*/
|
|
13
|
+
/** 문장 시작부터 걸린 자리까지의 줄바꿈 수. 뜻은 파이썬 rules/shared/dictionaryRule.py 가 소유한다. @param {string} text */
|
|
14
|
+
function countNewlines(text) {
|
|
15
|
+
let count = 0;
|
|
16
|
+
for (const ch of text) if (ch === "\n") count += 1;
|
|
17
|
+
return count;
|
|
18
|
+
}
|
|
19
|
+
|
|
12
20
|
export function dictionaryFindings(doc, dictionary, ruleName, severity = ERROR) {
|
|
13
21
|
const findings = [];
|
|
14
22
|
for (const sentence of doc.sentences) {
|
|
15
23
|
for (const match of sentence.matches) {
|
|
16
24
|
if (match.dictionary !== dictionary) continue;
|
|
17
|
-
|
|
25
|
+
// 낱말만 갈아 끼우면 뒤에 붙은 조사가 틀어진다. `이슈로` 를 `쟁점로` 로 내밀던 자리다
|
|
26
|
+
const tail = sentence.text.slice(match.end);
|
|
27
|
+
const fix = match.fix !== null ? sentence.text.slice(0, match.start) + match.fix + fitJosa(match.fix, tail) : null;
|
|
18
28
|
findings.push(
|
|
19
29
|
finding(
|
|
20
30
|
ruleName,
|
|
21
|
-
sentence.line,
|
|
31
|
+
sentence.line + countNewlines(sentence.text.slice(0, match.start)),
|
|
22
32
|
sentence.text,
|
|
23
33
|
`\`${match.text}\` ${match.why} (${match.source})`,
|
|
24
34
|
fix,
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { overlap, topicsOf } from "../../fingerprint/topics.js";
|
|
3
|
+
|
|
4
|
+
/** @param {import("../../fingerprint/build.js").SentencePrint} sentence */
|
|
5
|
+
export function hasLocalAntecedent(sentence) {
|
|
6
|
+
const marker = sentence.deixis[0];
|
|
7
|
+
const start = sentence.text.indexOf(marker);
|
|
8
|
+
const before = start > 0 ? topicsOf(sentence.text.slice(0, start)) : new Set();
|
|
9
|
+
if (marker.startsWith("이것")) return before.size > 0;
|
|
10
|
+
if (marker.startsWith("해당 ") || marker.startsWith("이러한 ")) return overlap(before, topicsOf(marker)) > 0;
|
|
11
|
+
return false;
|
|
12
|
+
}
|