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.
Files changed (146) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +23 -5
  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 +347 -0
  19. package/data/holeKinds.json +1 -2
  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 +114 -0
  29. package/data/profiles.json +1 -0
  30. package/data/readerCalls.txt +12 -7
  31. package/data/redundantPair.json +1 -1
  32. package/data/rieulStems.txt +30 -0
  33. package/data/ruleCategories.json +56 -0
  34. package/data/ruleDocs.json +29 -17
  35. package/data/ruleMechanisms.json +56 -0
  36. package/data/spacing.json +0 -12
  37. package/data/spelling.json +6 -24
  38. package/data/translationese.json +10 -0
  39. package/data/verbTails.txt +17 -0
  40. package/data/version.json +1 -1
  41. package/data/writingBrief.schema.json +64 -0
  42. package/data/writingBriefV2.schema.json +116 -0
  43. package/data/writingTrial.schema.json +36 -0
  44. package/package.json +13 -5
  45. package/src/analysis/grammar/ending.js +336 -0
  46. package/src/analysis/grammar/hangul.js +94 -0
  47. package/src/analysis/grammar/index.js +5 -0
  48. package/src/analysis/grammar/josa.js +73 -0
  49. package/src/analysis/grammar/register.js +115 -0
  50. package/src/analysis/grammar/voice.js +43 -0
  51. package/src/analysis/index.js +4 -31
  52. package/src/analysis/josa.js +2 -0
  53. package/src/analysis/{surface/splitSentences.js → splitSentences.js} +1 -1
  54. package/src/analysis/{surface/tokenize.js → tokenize.js} +70 -13
  55. package/src/baseline/store.js +114 -0
  56. package/src/cli/main.js +506 -58
  57. package/src/cli/walk.js +30 -0
  58. package/src/cli/welcome.js +52 -0
  59. package/src/config/settings.js +103 -8
  60. package/src/data/exemplars.js +177 -0
  61. package/src/data/load.js +10 -0
  62. package/src/data/operations.js +278 -0
  63. package/src/data/patches.js +125 -0
  64. package/src/data/patterns.js +48 -0
  65. package/src/data/profiles.js +102 -0
  66. package/src/document/model.js +2 -0
  67. package/src/document/parseMarkdown.js +84 -11
  68. package/src/edit/applyFixes.js +0 -0
  69. package/src/fingerprint/build.js +50 -32
  70. package/src/{rules/shared → fingerprint}/codeBlocks.js +11 -11
  71. package/src/fingerprint/codeMarkers.js +73 -0
  72. package/src/fingerprint/markers.js +9 -0
  73. package/src/fingerprint/readerState.js +126 -0
  74. package/src/fingerprint/sourceSentence.js +37 -0
  75. package/src/fingerprint/topics.js +1 -1
  76. package/src/index.js +2 -16
  77. package/src/report/compactReport.js +6 -1
  78. package/src/report/fingerprintJson.js +8 -3
  79. package/src/report/jsonReport.js +50 -3
  80. package/src/report/operationMatch.js +31 -0
  81. package/src/report/patchMatch.js +44 -0
  82. package/src/report/registerMatch.js +40 -0
  83. package/src/report/textReport.js +41 -5
  84. package/src/rules/code/duplicateBlock.js +4 -3
  85. package/src/rules/code/firstResultDistance.js +1 -0
  86. package/src/rules/code/inputFileSource.js +23 -68
  87. package/src/rules/code/installImport.js +4 -4
  88. package/src/rules/code/platformApi.js +7 -6
  89. package/src/rules/document/countMismatch.js +22 -6
  90. package/src/rules/document/enoughOnce.js +25 -0
  91. package/src/rules/document/fieldEcho.js +38 -0
  92. package/src/rules/document/keywordHeading.js +27 -0
  93. package/src/rules/document/keywordMissing.js +1 -0
  94. package/src/rules/document/noQuestion.js +5 -1
  95. package/src/rules/document/promiseRecall.js +4 -2
  96. package/src/rules/document/tableOddCell.js +60 -0
  97. package/src/rules/finding.js +24 -2
  98. package/src/rules/orthography/confusable.js +1 -0
  99. package/src/rules/orthography/spacing.js +1 -0
  100. package/src/rules/orthography/spelling.js +1 -0
  101. package/src/rules/paragraph/factListParagraph.js +1 -0
  102. package/src/rules/paragraph/paraFragment.js +21 -11
  103. package/src/rules/paragraph/topicBreak.js +1 -0
  104. package/src/rules/registry.js +72 -3
  105. package/src/rules/sentence/cliche.js +1 -0
  106. package/src/rules/sentence/connectorRepeat.js +8 -7
  107. package/src/rules/sentence/danglingDeixis.js +8 -3
  108. package/src/rules/sentence/dash.js +2 -1
  109. package/src/rules/sentence/deixis.js +7 -3
  110. package/src/rules/sentence/doubleNegative.js +4 -1
  111. package/src/rules/sentence/doublePassive.js +38 -1
  112. package/src/rules/sentence/draftHistory.js +24 -0
  113. package/src/rules/sentence/ellipsis.js +17 -3
  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 +10 -1
  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/headingQuestion.js +19 -0
  135. package/src/rules/structure/headingSentence.js +9 -4
  136. package/src/rules/structure/headingSkip.js +1 -0
  137. package/src/rules/structure/headingUniform.js +9 -17
  138. package/src/rules/structure/introImage.js +19 -0
  139. package/src/rules/structure/introLong.js +1 -0
  140. package/src/rules/structure/loneSubheading.js +42 -0
  141. package/src/rules/structure/moreLater.js +48 -0
  142. package/src/rules/structure/sectionNoProse.js +1 -0
  143. package/src/rules/structure/sectionResult.js +3 -2
  144. package/src/text.js +19 -0
  145. package/src/rules/document/readerAbsent.js +0 -11
  146. package/src/rules/shared/runs.js +0 -22
@@ -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
  "[" +
@@ -0,0 +1,19 @@
1
+ // @ts-check
2
+ import { DOCUMENT, finding } from "../finding.js";
3
+
4
+ export const name = "headingQuestion";
5
+ export const mechanism = "repeat";
6
+
7
+ /**
8
+ * @param {import("../../fingerprint/build.js").DocumentPrint} doc
9
+ * @param {import("../../config/settings.js").Config} config
10
+ */
11
+ export function run(doc, config) {
12
+ const headings = doc.headings.filter(([level]) => level === 2);
13
+ if (headings.length < 3) return [];
14
+ const asking = headings.filter(([, text]) => text.trimEnd().endsWith("?")).map(([, text]) => text);
15
+ if (asking.length / headings.length <= config.headingQuestionRatio) return [];
16
+ return [
17
+ finding(name, headings[0][2], asking.join(" / "), `H2 ${headings.length}개 중 ${asking.length}개가 물음표로 끝난다. 목차가 과정이 아니라 FAQ 로 읽힌다. 형태를 섞는다`, null, "error", DOCUMENT, -1),
18
+ ];
19
+ }
@@ -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
@@ -17,23 +19,13 @@ export function run(doc, config) {
17
19
  return !(char >= "0" && char <= "9");
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
30
  finding(name, eligible[0][2], eligible.map(([, text]) => text).join(" / "), `H2 ${eligible.length}개 중 ${count}개가 \`${best}\` 로 끝난다. 목차가 한 어미로 끝나면 과정이 아니라 나열로 읽힌다. 형태를 섞는다`, null, "error", DOCUMENT, -1),
39
31
  ];
@@ -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,6 +2,7 @@
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
@@ -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) {
@@ -1,9 +1,10 @@
1
1
  // @ts-check
2
- import { CODE, IMAGE, TABLE } from "../../document/model.js";
2
+ import { CODE, EMBED, IMAGE, TABLE } from "../../document/model.js";
3
3
  import { NOTICE, SECTION, finding } from "../finding.js";
4
4
 
5
5
  export const name = "sectionResult";
6
- const RESULT_KINDS = [CODE, IMAGE, TABLE];
6
+ export const mechanism = "threshold";
7
+ const RESULT_KINDS = [CODE, EMBED, IMAGE, TABLE];
7
8
  const FILE_NAME = /[\w가-힣-]+\.(?:csv|xlsx|xls|parquet|json|png|jpg|svg|db|sqlite|txt|py|pdf|html|md)\b/;
8
9
  const OUTPUT_WORDS = ["출력", "화면에", "찍힙니다", "나옵니다", "뜹니다", "보입니다", "만들어집니다", "생깁니다", "저장됩니다"];
9
10
 
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,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,22 +0,0 @@
1
- // @ts-check
2
- /** 연속 구간 찾기. 같은 값이 이어지는 자리를 [시작 위치, 길이, 값] 으로 준다. */
3
-
4
- /**
5
- * @template T
6
- * @param {T[]} values
7
- * @param {number} minLength
8
- * @returns {[number, number, T][]}
9
- */
10
- export function runsOf(values, minLength) {
11
- /** @type {[number, number, T][]} */
12
- const runs = [];
13
- let start = 0;
14
- while (start < values.length) {
15
- let end = start;
16
- while (end + 1 < values.length && values[end + 1] === values[start]) end += 1;
17
- const length = end - start + 1;
18
- if (length >= minLength) runs.push([start, length, values[start]]);
19
- start = end + 1;
20
- }
21
- return runs;
22
- }