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
@@ -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 readerAbsent from "./document/readerAbsent.js";
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";
@@ -21,16 +24,16 @@ import * as spacing from "./orthography/spacing.js";
21
24
  import * as spelling from "./orthography/spelling.js";
22
25
  import * as factListParagraph from "./paragraph/factListParagraph.js";
23
26
  import * as paraFragment from "./paragraph/paraFragment.js";
24
- import * as topicBreak from "./paragraph/topicBreak.js";
25
27
  import * as cliche from "./sentence/cliche.js";
26
28
  import * as hardWord from "./sentence/hardWord.js";
27
29
  import * as connectorRepeat from "./sentence/connectorRepeat.js";
28
30
  import * as danglingDeixis from "./sentence/danglingDeixis.js";
31
+ import * as draftHistory from "./sentence/draftHistory.js";
32
+ import * as numberOrphan from "./sentence/numberOrphan.js";
29
33
  import * as dash from "./sentence/dash.js";
30
34
  import * as deixis from "./sentence/deixis.js";
31
35
  import * as doubleNegative from "./sentence/doubleNegative.js";
32
36
  import * as doublePassive from "./sentence/doublePassive.js";
33
- import * as ellipsis from "./sentence/ellipsis.js";
34
37
  import * as endingRepeat from "./sentence/endingRepeat.js";
35
38
  import * as euiChain from "./sentence/euiChain.js";
36
39
  import * as fillerOpener from "./sentence/fillerOpener.js";
@@ -39,19 +42,25 @@ import * as japaneseLoan from "./sentence/japaneseLoan.js";
39
42
  import * as longSentence from "./sentence/longSentence.js";
40
43
  import * as negationRedefine from "./sentence/negationRedefine.js";
41
44
  import * as nounPile from "./sentence/nounPile.js";
45
+ import * as outsideProfile from "./sentence/outsideProfile.js";
42
46
  import * as redundantPair from "./sentence/redundantPair.js";
43
47
  import * as translationese from "./sentence/translationese.js";
48
+ import * as blockUnread from "./structure/blockUnread.js";
49
+ import * as bridgeRepeat from "./structure/bridgeRepeat.js";
44
50
  import * as emojiBullet from "./structure/emojiBullet.js";
51
+ import * as introImage from "./structure/introImage.js";
52
+ import * as loneSubheading from "./structure/loneSubheading.js";
53
+ import * as moreLater from "./structure/moreLater.js";
45
54
  import * as headingSentence from "./structure/headingSentence.js";
46
55
  import * as headingSkip from "./structure/headingSkip.js";
47
56
  import * as headingUniform from "./structure/headingUniform.js";
48
57
  import * as introLong from "./structure/introLong.js";
49
58
  import * as sectionNoProse from "./structure/sectionNoProse.js";
50
- import * as sectionResult from "./structure/sectionResult.js";
51
59
 
52
60
  /**
53
61
  * @typedef {object} Rule
54
62
  * @property {string} name
63
+ * @property {string} mechanism 세는 방법. MECHANISMS 의 키 하나
55
64
  * @property {(doc: import("../fingerprint/build.js").DocumentPrint, config: import("../config/settings.js").Config) => import("./finding.js").Finding[]} run
56
65
  */
57
66
 
@@ -61,10 +70,11 @@ export const RULES = [
61
70
  connectorRepeat,
62
71
  danglingDeixis,
63
72
  dash,
73
+ draftHistory,
74
+ numberOrphan,
64
75
  deixis,
65
76
  doubleNegative,
66
77
  doublePassive,
67
- ellipsis,
68
78
  endingRepeat,
69
79
  euiChain,
70
80
  fillerOpener,
@@ -74,23 +84,30 @@ export const RULES = [
74
84
  longSentence,
75
85
  negationRedefine,
76
86
  nounPile,
87
+ outsideProfile,
77
88
  redundantPair,
78
89
  translationese,
79
90
  factListParagraph,
80
91
  paraFragment,
81
- topicBreak,
92
+ blockUnread,
93
+ bridgeRepeat,
82
94
  emojiBullet,
95
+ introImage,
96
+ loneSubheading,
97
+ moreLater,
83
98
  headingSentence,
84
99
  headingSkip,
85
100
  headingUniform,
86
101
  introLong,
87
102
  sectionNoProse,
88
- sectionResult,
89
103
  countMismatch,
104
+ enoughOnce,
105
+ fieldEcho,
106
+ tableOddCell,
107
+ keywordHeading,
90
108
  keywordMissing,
91
109
  noQuestion,
92
110
  promiseRecall,
93
- readerAbsent,
94
111
  spelling,
95
112
  spacing,
96
113
  confusable,
@@ -101,6 +118,23 @@ export const RULES = [
101
118
  platformApi,
102
119
  ];
103
120
 
121
+ /**
122
+ * 규칙이 세는 방법의 닫힌 집합. 파이썬 rules/registry.py 의 MECHANISMS 와 같은 값과 같은 순서다.
123
+ * @type {Record<string, string>}
124
+ */
125
+ export const MECHANISMS = {
126
+ dictionary: "사전. 낱말 목록과 정규식이 맞는 자리",
127
+ repeat: "반복. 같은 모양이 창 안에서 N 번",
128
+ threshold: "셈. 지문의 값이 임계를 넘거나 모양이 계약과 다른 자리",
129
+ contrast: "대조. 두 자리를 맞대 어긋난 곳",
130
+ reader: "독자 상태. 문장 순서대로 독자가 손에 든 것과 본 것에 그 자리가 요구하는 것을 맞댄다",
131
+ };
132
+ for (const rule of RULES) {
133
+ if (!(rule.mechanism in MECHANISMS)) {
134
+ throw new Error(`규칙 ${rule.name} 의 기제 ${rule.mechanism} 은 닫힌 집합 밖이다 (${Object.keys(MECHANISMS).join(", ")}). 새 기제는 규칙 하나 때문에 들어오지 않는다. 멈춰서 묻는다`);
135
+ }
136
+ }
137
+
104
138
  const BY_NAME = new Map(RULES.map((rule) => [rule.name, rule]));
105
139
 
106
140
  /** 이름 순. */
@@ -120,6 +154,33 @@ export function ruleSummary(name) {
120
154
  return ruleDoc(name).split("\n")[0];
121
155
  }
122
156
 
157
+ /**
158
+ * 부류의 사람 이름. 파이썬 rules/registry.py 의 CATEGORY_TITLES 와 같은 값과 같은 순서다.
159
+ * @type {Record<string, string>}
160
+ */
161
+ export const CATEGORY_TITLES = {
162
+ sentence: "문장 안에서 세는 것",
163
+ paragraph: "문단 사이에서 세는 것",
164
+ structure: "글의 짜임에서 세는 것",
165
+ document: "두 자리를 대조해 세는 것",
166
+ orthography: "표기와 띄어쓰기",
167
+ code: "코드 블록 사이를 대조하는 것",
168
+ };
169
+
170
+ /** 규칙이 세는 방법. @param {string} name */
171
+ export function ruleMechanism(name) {
172
+ const rule = BY_NAME.get(name);
173
+ if (!rule) throw new Error(`모르는 규칙: ${name}. hanlint rules 로 목록을 본다`);
174
+ return rule.mechanism;
175
+ }
176
+
177
+ /** 규칙이 사는 부류. 정본은 파이썬 쪽 폴더이고 여기는 그 투영을 읽는다. @param {string} name */
178
+ export function ruleCategory(name) {
179
+ const categories = loadRuleCategories();
180
+ if (!(name in categories)) throw new Error(`모르는 규칙: ${name}. hanlint rules 로 목록을 본다`);
181
+ return categories[name];
182
+ }
183
+
123
184
  /**
124
185
  * 인라인 제어가 그 줄에서 그 규칙을 껐는가. `*` 는 전부다.
125
186
  * @param {import("./finding.js").Finding} f
@@ -2,6 +2,7 @@
2
2
  import { dictionaryFindings } from "../shared/dictionaryRule.js";
3
3
 
4
4
  export const name = "cliche";
5
+ export const mechanism = "dictionary";
5
6
 
6
7
  /** @param {import("../../fingerprint/build.js").DocumentPrint} doc */
7
8
  export function run(doc) {
@@ -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
- /** @type {string | null} */
11
- let previous = null;
12
- for (const sentence of paragraph.sentences) {
13
- const current = sentence.connectorStart;
14
- if (current && current === previous) {
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, `\`${current}\` 로 시작하는 문장이 연달아 온다. 앞에서 만든 것의 이름을 불러 잇는다`, null, "error", SENTENCE, sentence.index),
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,38 @@
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";
10
+
11
+ const QUESTION = "의문";
12
+ const EXCLAIM = ["!", "!"];
13
+
14
+ /**
15
+ * 앞 문장이 물음이나 감탄인가. 그러면 그 문장 자체가 지시어의 선행어다.
16
+ * 뜻은 파이썬 rules/sentence/danglingDeixis.py 의 isPrompt 가 소유한다.
17
+ * @param {{mood: string, text: string} | null} previous
18
+ */
19
+ function isPrompt(previous) {
20
+ if (!previous) return false;
21
+ const text = previous.text.replace(/\s+$/u, "");
22
+ return previous.mood === QUESTION || EXCLAIM.some((mark) => text.endsWith(mark));
23
+ }
6
24
 
7
25
  /** @param {import("../../fingerprint/build.js").DocumentPrint} doc */
8
26
  export function run(doc) {
9
27
  const findings = [];
10
28
  for (const sentence of doc.sentences) {
11
29
  if (!sentence.deixis.length) continue;
12
- const previous = sentence.index > 0 ? doc.sentences[sentence.index - 1] : null;
13
- if (previous !== null && overlap(previous.topics, sentence.topics) > 0) continue;
30
+ if (hasLocalAntecedent(sentence)) continue;
31
+ const reader = doc.reader.beforeSentence[sentence.index];
32
+ if (overlap(reader.recent, sentence.topics) > 0) continue;
33
+ if (isPrompt(reader.previous)) continue;
14
34
  findings.push(
15
- finding(name, sentence.line, sentence.text, `\`${sentence.deixis[0]}\` 이 가리킬 것이 앞 문장에 없다. 가리키는 파일, 값, 코드의 이름을 쓴다`, null, "error", SENTENCE, sentence.index),
35
+ 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
36
  );
17
37
  }
18
38
  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(
@@ -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
- const previous = doc.sentences[sentence.index - 1];
13
- if (overlap(previous.topics, sentence.topics) === 0) continue;
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 DOUBLE_NEGATIVE = /(하지 않으면 안 (?:된다|됩니다)|하지 않을 수 없(?:다|습니다)|지 않으면 안 (?:된다|됩니다)|지 않을 수 없(?:다|습니다))/;
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(name, sentence.line, sentence.text, `\`${sentence.passives[0]}\` 는 피동에 어지다 를 또 붙인 이중 피동이다. 하나만 남긴다`, null, "error", SENTENCE, sentence.index),
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,8 +1,9 @@
1
1
  // @ts-check
2
2
  import { NOTICE, SENTENCE, finding } from "../finding.js";
3
- import { runsOf } from "../shared/runs.js";
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}\` 로 끝나는 문장이 ${length}개 이어진다. 질문, 인과, 행동 동사로 리듬을 바꾼다`, null, NOTICE, SENTENCE, first.index),
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 ADJACENT = /[가-힣]+의\s+[가-힣]+의(?=[\s,.)\]]|$)/;
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 && ADJACENT.test(sentence.text))) {
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
  );
@@ -2,6 +2,7 @@
2
2
  import { SENTENCE, finding } from "../finding.js";
3
3
 
4
4
  export const name = "fillerOpener";
5
+ export const mechanism = "dictionary";
5
6
  const FILLER = /^(다음으로|이어서|마지막에는|마지막으로)(?=[\s,])/;
6
7
 
7
8
  /** @param {import("../../fingerprint/build.js").DocumentPrint} doc */
@@ -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 IMPERATIVE_PERIOD = /(세요|십시오|합시다|봅시다|하자|해라)\.(?=\s|$)/;
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) {
@@ -2,6 +2,7 @@
2
2
  import { dictionaryFindings } from "../shared/dictionaryRule.js";
3
3
 
4
4
  export const name = "japaneseLoan";
5
+ export const mechanism = "dictionary";
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 { 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,20 @@
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";
5
- const NEGATION = /단순(?:한|히)\s?.{0,15}?(?:이|가)\s?아(?:닙니다|니다|니에요|니죠|닌)/;
6
+ export const mechanism = "repeat";
7
+ // 관형형 `아닌` 은 뺐다. 문장을 끝낼 수 없어 앞 문장이 부정이라는 전제를 못 만족한다. 파이썬과 같다.
8
+ const NEGATION = /단순(?:한|히)\s?.{0,15}?(?:이|가)\s?아(?:닙니다|니다|니에요|니죠)/;
6
9
  const COPULA = /(입니다|이다|이에요|예요)[.!]?$/;
7
10
 
11
+ /** @param {string} text */
12
+ function isDefinition(text) {
13
+ if (COPULA.test(text.trim())) return true;
14
+ const predicate = parsePredicate(lastWord(text));
15
+ return predicate !== null && predicate.kind === COPULA_KIND;
16
+ }
17
+
8
18
  /** @param {import("../../fingerprint/build.js").DocumentPrint} doc */
9
19
  export function run(doc) {
10
20
  const findings = [];
@@ -13,7 +23,7 @@ export function run(doc) {
13
23
  for (let i = 0; i + 1 < sentences.length; i++) {
14
24
  const current = sentences[i];
15
25
  const following = sentences[i + 1];
16
- if (NEGATION.test(current.text) && COPULA.test(following.text.trim())) {
26
+ if (NEGATION.test(current.text) && isDefinition(following.text)) {
17
27
  findings.push(finding(name, current.line, current.text, "단순한 X 가 아닙니다 뒤에 Y 입니다 로 재정의하는 공식이다. 앞 문장을 지우고 Y 를 바로 쓴다", null, "error", SENTENCE, current.index));
18
28
  }
19
29
  }
@@ -2,6 +2,7 @@
2
2
  import { SENTENCE, finding } from "../finding.js";
3
3
 
4
4
  export const name = "nounPile";
5
+ export const mechanism = "threshold";
5
6
 
6
7
  /**
7
8
  * @param {import("../../fingerprint/build.js").DocumentPrint} doc
@@ -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
+ }