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.
Files changed (148) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +32 -9
  3. package/data/adjectiveStems.txt +118 -0
  4. package/data/baselineAnchors.txt +3 -0
  5. package/data/blindEvaluation.schema.json +26 -0
  6. package/data/bridgeOpeners.txt +10 -0
  7. package/data/candidateActionNouns.txt +39 -0
  8. package/data/candidateSplitEndings.txt +13 -0
  9. package/data/cliches.json +1 -1
  10. package/data/confusable.json +2 -2
  11. package/data/deixis.txt +10 -3
  12. package/data/draftHistory.txt +10 -0
  13. package/data/easyWords.json +0 -42
  14. package/data/enoughMarkers.txt +6 -0
  15. package/data/entailmentPredictions.schema.json +52 -0
  16. package/data/euiNouns.txt +3 -4
  17. package/data/evidenceEntailmentBenchmark.schema.json +126 -0
  18. package/data/exemplars.json +323 -0
  19. package/data/holeKinds.json +2 -14
  20. package/data/iVerbStems.txt +26 -0
  21. package/data/inNouns.txt +147 -0
  22. package/data/irregularStems.txt +64 -0
  23. package/data/nonNouns.txt +153 -0
  24. package/data/panelAssignmentReview.schema.json +117 -0
  25. package/data/panelJudgePredictions.schema.json +78 -0
  26. package/data/panelReviewBatch.schema.json +79 -0
  27. package/data/panelTrialSet.schema.json +44 -0
  28. package/data/patterns.json +103 -0
  29. package/data/platformApis.txt +3 -1
  30. package/data/profiles.json +1 -0
  31. package/data/readerCalls.txt +12 -7
  32. package/data/redundantPair.json +1 -1
  33. package/data/rieulStems.txt +30 -0
  34. package/data/ruleCategories.json +52 -0
  35. package/data/ruleDocs.json +27 -19
  36. package/data/ruleMechanisms.json +52 -0
  37. package/data/spacing.json +0 -12
  38. package/data/spelling.json +7 -25
  39. package/data/translationese.json +10 -0
  40. package/data/verbTails.txt +17 -0
  41. package/data/version.json +1 -1
  42. package/data/writingBrief.schema.json +64 -0
  43. package/data/writingBriefV2.schema.json +116 -0
  44. package/data/writingTrial.schema.json +36 -0
  45. package/koglType1.LICENSE.md +25 -0
  46. package/package.json +18 -6
  47. package/src/analysis/grammar/ending.js +336 -0
  48. package/src/analysis/grammar/hangul.js +94 -0
  49. package/src/analysis/grammar/index.js +5 -0
  50. package/src/analysis/grammar/josa.js +73 -0
  51. package/src/analysis/grammar/register.js +115 -0
  52. package/src/analysis/grammar/voice.js +43 -0
  53. package/src/analysis/index.js +4 -31
  54. package/src/analysis/josa.js +2 -0
  55. package/src/analysis/{surface/splitSentences.js → splitSentences.js} +1 -1
  56. package/src/analysis/{surface/tokenize.js → tokenize.js} +70 -13
  57. package/src/baseline/store.js +114 -0
  58. package/src/cli/main.js +508 -61
  59. package/src/cli/walk.js +30 -0
  60. package/src/cli/welcome.js +52 -0
  61. package/src/config/settings.js +111 -12
  62. package/src/data/exemplars.js +177 -0
  63. package/src/data/load.js +10 -0
  64. package/src/data/operations.js +278 -0
  65. package/src/data/patches.js +125 -0
  66. package/src/data/patterns.js +48 -0
  67. package/src/data/profiles.js +102 -0
  68. package/src/document/model.js +2 -0
  69. package/src/document/parseMarkdown.js +84 -11
  70. package/src/edit/applyFixes.js +0 -0
  71. package/src/fingerprint/build.js +60 -39
  72. package/src/{rules/shared → fingerprint}/codeBlocks.js +11 -11
  73. package/src/fingerprint/codeMarkers.js +73 -0
  74. package/src/fingerprint/markers.js +9 -0
  75. package/src/fingerprint/readerState.js +126 -0
  76. package/src/fingerprint/sourceSentence.js +37 -0
  77. package/src/fingerprint/topics.js +1 -1
  78. package/src/index.js +2 -16
  79. package/src/report/compactReport.js +6 -1
  80. package/src/report/fingerprintJson.js +8 -3
  81. package/src/report/jsonReport.js +50 -3
  82. package/src/report/operationMatch.js +31 -0
  83. package/src/report/patchMatch.js +44 -0
  84. package/src/report/registerMatch.js +40 -0
  85. package/src/report/textReport.js +41 -5
  86. package/src/rules/code/duplicateBlock.js +4 -3
  87. package/src/rules/code/firstResultDistance.js +1 -0
  88. package/src/rules/code/inputFileSource.js +23 -68
  89. package/src/rules/code/installImport.js +6 -5
  90. package/src/rules/code/platformApi.js +7 -6
  91. package/src/rules/document/countMismatch.js +22 -6
  92. package/src/rules/document/enoughOnce.js +25 -0
  93. package/src/rules/document/fieldEcho.js +38 -0
  94. package/src/rules/document/keywordHeading.js +32 -0
  95. package/src/rules/document/keywordMissing.js +1 -0
  96. package/src/rules/document/noQuestion.js +5 -1
  97. package/src/rules/document/promiseRecall.js +4 -2
  98. package/src/rules/document/tableOddCell.js +61 -0
  99. package/src/rules/finding.js +24 -2
  100. package/src/rules/orthography/confusable.js +1 -0
  101. package/src/rules/orthography/spacing.js +1 -0
  102. package/src/rules/orthography/spelling.js +1 -0
  103. package/src/rules/paragraph/factListParagraph.js +1 -0
  104. package/src/rules/paragraph/paraFragment.js +21 -11
  105. package/src/rules/registry.js +70 -9
  106. package/src/rules/sentence/cliche.js +1 -0
  107. package/src/rules/sentence/connectorRepeat.js +8 -7
  108. package/src/rules/sentence/danglingDeixis.js +23 -3
  109. package/src/rules/sentence/dash.js +2 -1
  110. package/src/rules/sentence/deixis.js +7 -3
  111. package/src/rules/sentence/doubleNegative.js +4 -1
  112. package/src/rules/sentence/doublePassive.js +38 -1
  113. package/src/rules/sentence/draftHistory.js +24 -0
  114. package/src/rules/sentence/endingRepeat.js +6 -2
  115. package/src/rules/sentence/euiChain.js +3 -2
  116. package/src/rules/sentence/fillerOpener.js +1 -0
  117. package/src/rules/sentence/hardWord.js +1 -0
  118. package/src/rules/sentence/imperativePeriod.js +2 -1
  119. package/src/rules/sentence/japaneseLoan.js +1 -0
  120. package/src/rules/sentence/longSentence.js +3 -1
  121. package/src/rules/sentence/negationRedefine.js +12 -2
  122. package/src/rules/sentence/nounPile.js +1 -0
  123. package/src/rules/sentence/numberOrphan.js +45 -0
  124. package/src/rules/sentence/outsideProfile.js +68 -0
  125. package/src/rules/sentence/redundantPair.js +1 -0
  126. package/src/rules/sentence/translationese.js +1 -0
  127. package/src/rules/shared/candidates.js +129 -0
  128. package/src/rules/shared/dictionaryRule.js +12 -2
  129. package/src/rules/shared/localAntecedent.js +12 -0
  130. package/src/rules/shared/repeat.js +48 -0
  131. package/src/rules/structure/blockUnread.js +33 -0
  132. package/src/rules/structure/bridgeRepeat.js +40 -0
  133. package/src/rules/structure/emojiBullet.js +1 -0
  134. package/src/rules/structure/headingSentence.js +9 -4
  135. package/src/rules/structure/headingSkip.js +1 -0
  136. package/src/rules/structure/headingUniform.js +12 -20
  137. package/src/rules/structure/introImage.js +19 -0
  138. package/src/rules/structure/introLong.js +4 -1
  139. package/src/rules/structure/loneSubheading.js +42 -0
  140. package/src/rules/structure/moreLater.js +48 -0
  141. package/src/rules/structure/sectionNoProse.js +1 -0
  142. package/src/text.js +19 -0
  143. package/data/coverageTypes.txt +0 -7
  144. package/src/rules/document/readerAbsent.js +0 -11
  145. package/src/rules/paragraph/topicBreak.js +0 -25
  146. package/src/rules/sentence/ellipsis.js +0 -18
  147. package/src/rules/shared/runs.js +0 -22
  148. package/src/rules/structure/sectionResult.js +0 -37
@@ -2,6 +2,7 @@
2
2
  import { dictionaryFindings } from "../shared/dictionaryRule.js";
3
3
 
4
4
  export const name = "redundantPair";
5
+ export const mechanism = "dictionary";
5
6
 
6
7
  /** @param {import("../../fingerprint/build.js").DocumentPrint} doc */
7
8
  export function run(doc) {
@@ -2,6 +2,7 @@
2
2
  import { dictionaryFindings } from "../shared/dictionaryRule.js";
3
3
 
4
4
  export const name = "translationese";
5
+ export const mechanism = "dictionary";
5
6
 
6
7
  /** @param {import("../../fingerprint/build.js").DocumentPrint} doc */
7
8
  export function run(doc) {
@@ -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
- const fix = match.fix !== null ? sentence.text.slice(0, match.start) + match.fix + sentence.text.slice(match.end) : null;
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
+ }
@@ -0,0 +1,48 @@
1
+ // @ts-check
2
+ /**
3
+ * 반복 기제. 같은 모양이 창 안에서 이어지거나 (runsOf) 한 모양이 창을 채우는 (shareOf) 자리를 센다.
4
+ * 파이썬 rules/shared/repeat.py 와 같다. 규칙은 열쇠와 창과 임계만 선언하고 셈은 여기서 한다.
5
+ */
6
+
7
+ /**
8
+ * 같은 값이 minLength 개 이상 이어지는 구간을 [시작 위치, 길이, 값] 으로 준다.
9
+ * @template T
10
+ * @param {T[]} values
11
+ * @param {number} minLength
12
+ * @returns {[number, number, T][]}
13
+ */
14
+ export function runsOf(values, minLength) {
15
+ /** @type {[number, number, T][]} */
16
+ const runs = [];
17
+ let start = 0;
18
+ while (start < values.length) {
19
+ let end = start;
20
+ while (end + 1 < values.length && values[end + 1] === values[start]) end += 1;
21
+ const length = end - start + 1;
22
+ if (length >= minLength) runs.push([start, length, values[start]]);
23
+ start = end + 1;
24
+ }
25
+ return runs;
26
+ }
27
+
28
+ /**
29
+ * 가장 많은 값과 그 수와 전체 수. 같은 수면 먼저 나온 값이 이긴다. 빈 열이면 [null, 0, 0].
30
+ * @template T
31
+ * @param {T[]} values
32
+ * @returns {[T | null, number, number]}
33
+ */
34
+ export function shareOf(values) {
35
+ /** @type {Map<T, number>} */
36
+ const counts = new Map();
37
+ for (const value of values) counts.set(value, (counts.get(value) ?? 0) + 1);
38
+ /** @type {T | null} */
39
+ let best = null;
40
+ let count = 0;
41
+ for (const [value, seen] of counts) {
42
+ if (seen > count) {
43
+ best = value;
44
+ count = seen;
45
+ }
46
+ }
47
+ return [best, count, values.length];
48
+ }
@@ -0,0 +1,33 @@
1
+ // @ts-check
2
+ import { PROSE } from "../../document/model.js";
3
+ import { NOTICE, SECTION, finding } from "../finding.js";
4
+
5
+ export const name = "blockUnread";
6
+ export const mechanism = "threshold";
7
+ const OUTPUT_LANGUAGES = ["", "text", "output", "console"];
8
+
9
+ /**
10
+ * @param {import("../../fingerprint/build.js").DocumentPrint} doc
11
+ * @param {import("../../config/settings.js").Config} config
12
+ */
13
+ export function run(doc, config) {
14
+ void config;
15
+ /** @type {Map<number, string>} */
16
+ const languages = new Map(doc.codeBlocks.map((block) => [block.index, block.language]));
17
+ const outputs = new Set([...languages].filter(([, language]) => OUTPUT_LANGUAGES.includes(language)).map(([index]) => index));
18
+ if (!outputs.size) return [];
19
+ const blocks = [...doc.blocks].sort((a, b) => a.index - b.index);
20
+ const findings = [];
21
+ for (let position = 0; position < blocks.length; position += 1) {
22
+ const block = blocks[position];
23
+ if (!outputs.has(block.index) || position === 0) continue;
24
+ const previous = languages.get(blocks[position - 1].index);
25
+ if (previous === undefined || OUTPUT_LANGUAGES.includes(previous)) continue;
26
+ const following = position + 1 < blocks.length ? blocks[position + 1] : null;
27
+ if (following !== null && following.kind === PROSE) continue;
28
+ findings.push(
29
+ finding(name, block.startLine, block.text.includes("\n") ? block.text.split("\n")[1] : block.text, "출력을 붙여 놓고 읽어 주지 않았다. 어느 부분이 무엇인지 짚는 문장을 바로 아래에 붙인다", null, NOTICE, SECTION, block.index),
30
+ );
31
+ }
32
+ return findings;
33
+ }
@@ -0,0 +1,40 @@
1
+ // @ts-check
2
+ import { matchedTexts } from "../../fingerprint/markers.js";
3
+ import { DOCUMENT, finding } from "../finding.js";
4
+
5
+ export const name = "bridgeRepeat";
6
+ export const mechanism = "repeat";
7
+
8
+ /**
9
+ * 절을 닫는 문장이 이음 표지 (이번에는, 이제, 다음으로) 로 시작하는 절이 bridgeRepeatMin 개 이상인 글.
10
+ * 파이썬 rules/structure/bridgeRepeat.py 와 같다. 표지 목록은 data/bridgeOpeners.txt.
11
+ * @param {import("../../fingerprint/build.js").DocumentPrint} doc
12
+ * @param {import("../../config/settings.js").Config} config
13
+ */
14
+ export function run(doc, config) {
15
+ /** @type {[import("../../fingerprint/build.js").SentencePrint, string][]} */
16
+ const closers = [];
17
+ for (const section of doc.bodySections) {
18
+ if (!section.paragraphs.length) continue;
19
+ const sentences = section.paragraphs[section.paragraphs.length - 1].sentences;
20
+ if (!sentences.length) continue;
21
+ const last = sentences[sentences.length - 1];
22
+ const found = matchedTexts(last.text, "bridgeOpeners.txt");
23
+ if (found.length) closers.push([last, found[0]]);
24
+ }
25
+ if (closers.length < config.bridgeRepeatMin) return [];
26
+ const markers = [...new Set(closers.map(([, marker]) => marker))].sort();
27
+ const [first] = closers[0];
28
+ return [
29
+ finding(
30
+ name,
31
+ first.line,
32
+ first.text,
33
+ `절 ${closers.length}개가 \`${markers.join(", ")}\` 같은 이음 표지로 시작하는 문장으로 닫힌다. 예고가 절마다 되풀이되면 틀이다. 방금 만든 결과를 이름으로 부르고 아직 못 하는 일을 적는다`,
34
+ null,
35
+ "error",
36
+ DOCUMENT,
37
+ -1,
38
+ ),
39
+ ];
40
+ }
@@ -3,6 +3,7 @@ import { LIST } from "../../document/model.js";
3
3
  import { DOCUMENT, finding } from "../finding.js";
4
4
 
5
5
  export const name = "emojiBullet";
6
+ export const mechanism = "dictionary";
6
7
  // 이모지 범위. 코드포인트로 만든다 (도구가 이스케이프를 건드리지 않게). 아스트랄 범위라 u 플래그가 필요하다.
7
8
  const EMOJI =
8
9
  "[" +
@@ -2,12 +2,17 @@
2
2
  import { DOCUMENT, finding } from "../finding.js";
3
3
 
4
4
  export const name = "headingSentence";
5
- const SENTENCE_ENDINGS = ["니다", "세요", "십시오", "합시다", "."];
5
+ export const mechanism = "threshold";
6
+ const SENTENCE_ENDINGS = ["니다", "한다", "해요", "세요", "십시오", "합시다", "."];
6
7
 
7
- /** @param {import("../../fingerprint/build.js").DocumentPrint} doc */
8
- export function run(doc) {
8
+ /**
9
+ * @param {import("../../fingerprint/build.js").DocumentPrint} doc
10
+ * @param {import("../../config/settings.js").Config} config
11
+ */
12
+ export function run(doc, config) {
9
13
  const findings = [];
10
- for (const [, text, line] of doc.headings) {
14
+ for (const [level, text, line] of doc.headings) {
15
+ if (level > config.headingSentenceMaxLevel) continue;
11
16
  const trimmed = text.trimEnd();
12
17
  if (SENTENCE_ENDINGS.some((ending) => trimmed.endsWith(ending))) {
13
18
  findings.push(finding(name, line, text, "절 제목이 본문 문장을 복사했다. 대상이나 질문을 짧게 쓴다", null, "error", DOCUMENT, -1));
@@ -2,6 +2,7 @@
2
2
  import { DOCUMENT, finding } from "../finding.js";
3
3
 
4
4
  export const name = "headingSkip";
5
+ export const mechanism = "threshold";
5
6
 
6
7
  /** @param {import("../../fingerprint/build.js").DocumentPrint} doc */
7
8
  export function run(doc) {
@@ -1,7 +1,9 @@
1
1
  // @ts-check
2
2
  import { DOCUMENT, finding } from "../finding.js";
3
+ import { shareOf } from "../shared/repeat.js";
3
4
 
4
5
  export const name = "headingUniform";
6
+ export const mechanism = "repeat";
5
7
 
6
8
  /**
7
9
  * @param {import("../../fingerprint/build.js").DocumentPrint} doc
@@ -9,33 +11,23 @@ export const name = "headingUniform";
9
11
  */
10
12
  export function run(doc, config) {
11
13
  const headings = doc.headings.filter(([level]) => level === 2);
12
- // 버전이나 날짜처럼 숫자로 끝나는 제목은 어미가 아니라 판정에서 뺀다.
14
+ // 어미는 한글이다. 숫자, 부호, 라틴 문자로 끝나는 제목은 판정에서 뺀다. 파이썬과 같다.
13
15
  const eligible = headings.filter(([, text]) => {
14
16
  if (!text.trim()) return false;
15
17
  const trimmed = text.trimEnd();
16
18
  const char = trimmed[trimmed.length - 1];
17
- return !(char >= "0" && char <= "9");
19
+ return char >= "가" && char <= "힣";
18
20
  });
19
21
  if (eligible.length < 3) return [];
20
- // 파이썬 Counter.most_common 은 같은 수면 먼저 들어온 것이 이긴다. 삽입 순서를 지킨다.
21
- /** @type {Map<string, number>} */
22
- const lastChars = new Map();
23
- for (const [, text] of eligible) {
24
- const trimmed = text.trimEnd();
25
- const char = trimmed[trimmed.length - 1];
26
- lastChars.set(char, (lastChars.get(char) ?? 0) + 1);
27
- }
28
- let best = "";
29
- let count = -1;
30
- for (const [char, n] of lastChars) {
31
- if (n > count) {
32
- best = char;
33
- count = n;
34
- }
35
- }
36
- if (count / eligible.length >= config.headingUniformRatio) {
22
+ const [best, count, total] = shareOf(
23
+ eligible.map(([, text]) => {
24
+ const trimmed = text.trimEnd();
25
+ return trimmed[trimmed.length - 1];
26
+ }),
27
+ );
28
+ if (count / total >= config.headingUniformRatio) {
37
29
  return [
38
- finding(name, eligible[0][2], eligible.map(([, text]) => text).join(" / "), `H2 ${eligible.length}개 중 ${count}개가 \`${best}\` 로 끝난다. 목차가 한 어미로 끝나면 과정이 아니라 나열로 읽힌다. 형태를 섞는다`, null, "error", DOCUMENT, -1),
30
+ finding(name, eligible[0][2], eligible.map(([, text]) => text).join(" / "), `H2 ${eligible.length}개 중 ${count}개가 \`${best}\` 로 끝난다. 목차가 한 어미로 끝나면 과정이 아니라 나열로 읽힌다. 그 문서의 관례라면 둔다`, null, "notice", DOCUMENT, -1),
39
31
  ];
40
32
  }
41
33
  return [];
@@ -0,0 +1,19 @@
1
+ // @ts-check
2
+ import { IMAGE } from "../../document/model.js";
3
+ import { SECTION, finding } from "../finding.js";
4
+
5
+ export const name = "introImage";
6
+ export const mechanism = "threshold";
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
+ if (!doc.bodySections.length) return [];
14
+ const count = doc.intro.blockKinds.filter((kind) => kind === IMAGE).length;
15
+ if (count <= config.introMaxImages) return [];
16
+ return [
17
+ finding(name, doc.intro.startLine, doc.intro.title || "도입", `도입에 그림이 ${count}장이다. ${config.introMaxImages}장을 넘지 않는다. 나머지는 그것을 설명하는 절로 내린다`, null, "error", SECTION, doc.intro.index),
18
+ ];
19
+ }
@@ -2,14 +2,17 @@
2
2
  import { PARAGRAPH, finding } from "../finding.js";
3
3
 
4
4
  export const name = "introLong";
5
+ export const mechanism = "threshold";
5
6
 
6
7
  /**
7
8
  * @param {import("../../fingerprint/build.js").DocumentPrint} doc
8
9
  * @param {import("../../config/settings.js").Config} config
9
10
  */
10
11
  export function run(doc, config) {
11
- if (!doc.bodySections.length) return [];
12
+ // 본문 절이 하나뿐이거나 도입이 문단의 절반을 넘으면 절이 없는 글이지 도입이 긴 글이 아니다. 파이썬과 같다.
13
+ if (doc.bodySections.length < 2) return [];
12
14
  const paragraphs = doc.intro.paragraphs;
15
+ if (paragraphs.length * 2 > doc.paragraphs.length) return [];
13
16
  if (paragraphs.length > config.introMaxParagraphs) {
14
17
  const over = paragraphs[config.introMaxParagraphs];
15
18
  return [
@@ -0,0 +1,42 @@
1
+ // @ts-check
2
+ import { topicsOf } from "../../fingerprint/topics.js";
3
+ import { NOTICE, SECTION, finding } from "../finding.js";
4
+
5
+ export const name = "loneSubheading";
6
+ export const mechanism = "contrast";
7
+
8
+ /** (부모 H2 제목, 외동 H3 제목, 그 줄). H2 하나 아래 H3 이 정확히 하나일 때만. @param {[number, string, number][]} headings */
9
+ function onlyChild(headings) {
10
+ /** @type {[string, string, number][]} */
11
+ const found = [];
12
+ for (let position = 0; position < headings.length; position += 1) {
13
+ const [level, text] = headings[position];
14
+ if (level !== 2) continue;
15
+ /** @type {[string, number][]} */
16
+ const children = [];
17
+ for (const [childLevel, childText, childLine] of headings.slice(position + 1)) {
18
+ if (childLevel <= 2) break;
19
+ if (childLevel === 3) children.push([childText, childLine]);
20
+ }
21
+ if (children.length === 1) found.push([text, children[0][0], children[0][1]]);
22
+ }
23
+ return found;
24
+ }
25
+
26
+ /**
27
+ * @param {import("../../fingerprint/build.js").DocumentPrint} doc
28
+ * @param {import("../../config/settings.js").Config} config
29
+ */
30
+ export function run(doc, config) {
31
+ void config;
32
+ const findings = [];
33
+ for (const [parent, child, line] of onlyChild(doc.headings)) {
34
+ const childTopics = topicsOf(child);
35
+ const parentTopics = topicsOf(parent);
36
+ if (!childTopics.size || ![...childTopics].every((word) => parentTopics.has(word))) continue;
37
+ findings.push(
38
+ finding(name, line, child, `\`${parent}\` 아래 소제목이 이것 하나뿐이고 절 제목에 없는 말이 하나도 없다. 소제목을 지우고 본문을 올린다`, null, NOTICE, SECTION, -1),
39
+ );
40
+ }
41
+ return findings;
42
+ }
@@ -0,0 +1,48 @@
1
+ // @ts-check
2
+ import { LIST } from "../../document/model.js";
3
+ import { plainText } from "../../document/plainText.js";
4
+ import { NOTICE, SECTION, finding } from "../finding.js";
5
+
6
+ export const name = "moreLater";
7
+ export const mechanism = "threshold";
8
+ const BULLET = /^\s*(?:[-*+]|\d+[.)])\s+/;
9
+
10
+ /** (블록 안 줄 오프셋, 항목 전체). 이어지는 줄은 앞 항목에 붙인다. @param {string} text @returns {[number, string][]} */
11
+ function itemsOf(text) {
12
+ /** @type {[number, string][]} */
13
+ const items = [];
14
+ text.split("\n").forEach((line, offset) => {
15
+ if (BULLET.test(line)) {
16
+ items.push([offset, line.replace(BULLET, "").trim()]);
17
+ } else if (items.length && line.trim()) {
18
+ const last = items[items.length - 1];
19
+ last[1] = `${last[1]} ${line.trim()}`;
20
+ }
21
+ });
22
+ return items;
23
+ }
24
+
25
+ /**
26
+ * @param {import("../../fingerprint/build.js").DocumentPrint} doc
27
+ * @param {import("../../config/settings.js").Config} config
28
+ */
29
+ export function run(doc, config) {
30
+ if (doc.sections.length < 2) return [];
31
+ const last = doc.sections[doc.sections.length - 1];
32
+ if (last.isIntro) return [];
33
+ /** @type {[number, string][]} */
34
+ const over = [];
35
+ for (const block of doc.blocks) {
36
+ if (block.kind !== LIST || block.startLine < last.startLine) continue;
37
+ for (const [offset, item] of itemsOf(block.text)) {
38
+ const visible = plainText(item);
39
+ if (visible.length > config.moreLaterMaxChars) over.push([block.startLine + offset, visible]);
40
+ }
41
+ }
42
+ if (!over.length) return [];
43
+ let best = over[0];
44
+ for (const pair of over) if (pair[1].length > best[1].length) best = pair;
45
+ return [
46
+ finding(name, best[0], best[1], `마지막 절의 목록 항목 ${over.length}개가 ${config.moreLaterMaxChars}자를 넘는다 (가장 긴 것 ${best[1].length}자). 미룬 것이 아니라 옮겨 적은 것이다. 한 줄과 링크로 줄인다`, null, NOTICE, SECTION, last.index),
47
+ ];
48
+ }
@@ -3,6 +3,7 @@ import { LIST, PROSE } from "../../document/model.js";
3
3
  import { SECTION, finding } from "../finding.js";
4
4
 
5
5
  export const name = "sectionNoProse";
6
+ export const mechanism = "threshold";
6
7
 
7
8
  /** @param {import("../../fingerprint/build.js").DocumentPrint} doc */
8
9
  export function run(doc) {
package/src/text.js CHANGED
@@ -61,3 +61,22 @@ export function countIn(text, needle, end = text.length) {
61
61
  from = at + needle.length;
62
62
  }
63
63
  }
64
+
65
+ /**
66
+ * 파이썬 round(value, digits). 정확한 이진 값을 십진으로 풀어 반올림하고 정확히 절반이면 짝수로 간다.
67
+ * Math.round 는 절반을 위로 올려 13/16 을 0.813 으로 만들고 파이썬은 0.812 다. 두 판의 글자가 같으려면 파이썬 쪽이다.
68
+ * toFixed(100) 은 1e21 아래의 수를 정확한 십진 전개로 준다. 지문의 비율과 평균이 사는 범위다.
69
+ * @param {number} value
70
+ * @param {number} digits
71
+ */
72
+ export function roundHalfEven(value, digits) {
73
+ if (!Number.isFinite(value) || Math.abs(value) >= 1e21) return value;
74
+ const sign = value < 0 ? -1 : 1;
75
+ const [whole, fraction] = Math.abs(value).toFixed(100).split(".");
76
+ const rest = fraction.slice(digits);
77
+ let units = BigInt(whole + fraction.slice(0, digits));
78
+ const next = rest.charCodeAt(0) - 48;
79
+ const halfway = next === 5 && /^0*$/.test(rest.slice(1));
80
+ if (next > 5 || (next === 5 && !halfway) || (halfway && units % 2n === 1n)) units += 1n;
81
+ return sign * (Number(units) / 10 ** digits);
82
+ }
@@ -1,7 +0,0 @@
1
- # 사람 평가자의 지적을 유형으로 가르는 표지. `유형<TAB>정규식`. coverage 가 못 집은 지적을 모을 때 쓴다. 위에서부터 먼저 맞는 것이 이긴다.
2
- 파일과 폴더 파일|폴더|경로|만들어지지|생성|없어서|없다
3
- 설치와 실행 설치|import|모듈|패키지|실행|오류|에러|Error|멈춘다|돌아가지
4
- 순서와 구조 순서|먼저|앞에서|뒤에서|절|목차|위치|옮|건너
5
- 용어와 설명 설명|뜻|처음 나온|용어|무엇인지|모른|정의
6
- 숫자와 표 숫자|표|열|행|값|측정|잰
7
- 문장과 표현 문장|주어|서술어|표현|반복|같은 말|어색
@@ -1,11 +0,0 @@
1
- // @ts-check
2
- import { DOCUMENT, NOTICE, finding } from "../finding.js";
3
-
4
- export const name = "readerAbsent";
5
-
6
- /** @param {import("../../fingerprint/build.js").DocumentPrint} doc */
7
- export function run(doc) {
8
- if (doc.bodySections.length < 2 || doc.questionCount > 0 || doc.readerCallCount > 0) return [];
9
- const first = doc.sentences.length ? doc.sentences[0] : null;
10
- return [finding(name, first ? first.line : 1, first ? first.text : "", "질문도 독자를 부르는 문장도 없다. 독자가 할 행동을 동사로 끝내는 문장을 한 번은 넣는다", null, NOTICE, DOCUMENT, -1)];
11
- }
@@ -1,25 +0,0 @@
1
- // @ts-check
2
- import { NOTICE, PARAGRAPH, finding } from "../finding.js";
3
-
4
- export const name = "topicBreak";
5
-
6
- /**
7
- * @param {import("../../fingerprint/build.js").DocumentPrint} doc
8
- * @param {import("../../config/settings.js").Config} config
9
- */
10
- export function run(doc, config) {
11
- const findings = [];
12
- for (const section of doc.sections) {
13
- const paragraphs = section.paragraphs;
14
- for (let i = 0; i + 1 < paragraphs.length; i++) {
15
- const previous = paragraphs[i];
16
- const paragraph = paragraphs[i + 1];
17
- if (paragraph.overlapWithPrevious !== 0) continue;
18
- if (Math.min(previous.sentenceCount, paragraph.sentenceCount) < config.topicBreakMinSentences) continue;
19
- findings.push(
20
- finding(name, paragraph.startLine, paragraph.sentences[0].text, "앞 문단과 화제어가 하나도 겹치지 않는다. 앞에서 만든 것의 이름을 첫 문장에서 다시 부른다", null, NOTICE, PARAGRAPH, paragraph.index),
21
- );
22
- }
23
- }
24
- return findings;
25
- }
@@ -1,18 +0,0 @@
1
- // @ts-check
2
- import { SENTENCE, finding } from "../finding.js";
3
-
4
- export const name = "ellipsis";
5
- // 괄호 안의 점 셋 (`OVER (...)`) 은 인라인 코드의 생략 표기라 뺀다.
6
- // 영숫자에 붙은 점 셋 (`v0.0.1...HEAD`, compare URL) 은 범위 표기라 말줄임표가 아니다.
7
- const ELLIPSIS = /(?<![(\[A-Za-z0-9])(…|\.{3,})(?![)\]A-Za-z0-9])/;
8
-
9
- /** @param {import("../../fingerprint/build.js").DocumentPrint} doc */
10
- export function run(doc) {
11
- const findings = [];
12
- for (const sentence of doc.sentences) {
13
- if (ELLIPSIS.test(sentence.text)) {
14
- findings.push(finding(name, sentence.line, sentence.text, "말줄임표로 말끝을 흐리면 독자가 결론을 받아 가지 못한다. 문장을 끝까지 쓴다", null, "error", SENTENCE, sentence.index));
15
- }
16
- }
17
- return findings;
18
- }