hanlint 0.0.18 → 0.0.19

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 (125) hide show
  1. package/README.md +17 -16
  2. package/data/calqueFrame.json +20 -20
  3. package/data/cliches.json +33 -33
  4. package/data/confusable.json +20 -20
  5. package/data/easyWords.json +12 -12
  6. package/data/exemplars.json +30 -30
  7. package/data/hiddenDeps.txt +11 -11
  8. package/data/japaneseLoan.json +27 -27
  9. package/data/koreanProse.txt +31 -31
  10. package/data/patterns.json +26 -26
  11. package/data/phraseRepeat.json +2 -2
  12. package/data/platformApis.txt +11 -11
  13. package/data/redundantPair.json +16 -16
  14. package/data/ruleDocs.json +56 -56
  15. package/data/screenNarration.json +10 -10
  16. package/data/screenSentence.json +12 -12
  17. package/data/screenTone.json +6 -6
  18. package/data/screenWord.json +70 -70
  19. package/data/spacing.json +11 -11
  20. package/data/spelling.json +29 -29
  21. package/data/translationese.json +23 -24
  22. package/data/usageCounts.report.json +2 -2
  23. package/data/version.json +1 -1
  24. package/package.json +1 -1
  25. package/src/analysis/grammar/ending.js +1 -1
  26. package/src/analysis/grammar/hangul.js +4 -4
  27. package/src/analysis/grammar/josa.js +1 -1
  28. package/src/analysis/grammar/register.js +1 -1
  29. package/src/analysis/grammar/voice.js +1 -1
  30. package/src/analysis/index.js +2 -2
  31. package/src/analysis/josa.js +1 -1
  32. package/src/analysis/splitSentences.js +1 -1
  33. package/src/analysis/tokenize.js +13 -13
  34. package/src/baseline/store.js +8 -8
  35. package/src/cli/help.js +11 -11
  36. package/src/cli/main.js +135 -124
  37. package/src/cli/walk.js +1 -1
  38. package/src/cli/welcome.js +8 -8
  39. package/src/config/loadConfig.js +3 -3
  40. package/src/config/patch.js +6 -6
  41. package/src/config/readerContract.js +26 -26
  42. package/src/config/settings.js +30 -28
  43. package/src/config/toml.js +9 -9
  44. package/src/data/exemplars.js +21 -21
  45. package/src/data/load.js +6 -6
  46. package/src/data/operations.js +14 -14
  47. package/src/data/patches.js +10 -10
  48. package/src/data/patterns.js +5 -5
  49. package/src/data/profiles.js +5 -5
  50. package/src/data/prose.js +5 -5
  51. package/src/document/model.js +4 -4
  52. package/src/document/parseMarkdown.js +7 -7
  53. package/src/document/plainText.js +3 -3
  54. package/src/document/sourceText.js +24 -24
  55. package/src/edit/applyFixes.js +0 -0
  56. package/src/fingerprint/build.js +6 -6
  57. package/src/fingerprint/codeBlocks.js +3 -3
  58. package/src/fingerprint/codeMarkers.js +4 -4
  59. package/src/fingerprint/dictionaries.js +7 -7
  60. package/src/fingerprint/markers.js +7 -7
  61. package/src/fingerprint/readerState.js +9 -9
  62. package/src/fingerprint/sourceSentence.js +1 -1
  63. package/src/fingerprint/topics.js +1 -1
  64. package/src/guard/contract.js +6 -6
  65. package/src/guard/editLocality.js +1 -1
  66. package/src/guard/outline.js +1 -1
  67. package/src/guard/receipt.js +13 -13
  68. package/src/guard/surface.js +4 -4
  69. package/src/index.js +6 -6
  70. package/src/learn/edits.js +2 -2
  71. package/src/learn/operations.js +1 -1
  72. package/src/regex.js +2 -2
  73. package/src/report/compactReport.js +3 -3
  74. package/src/report/fingerprintJson.js +2 -2
  75. package/src/report/githubReport.js +1 -1
  76. package/src/report/jsonReport.js +2 -2
  77. package/src/report/registerMatch.js +2 -2
  78. package/src/report/sheet.js +16 -16
  79. package/src/report/textReport.js +12 -12
  80. package/src/report/writingSpec.js +15 -15
  81. package/src/rules/code/inputFileSource.js +1 -1
  82. package/src/rules/code/installImport.js +2 -2
  83. package/src/rules/code/platformApi.js +1 -1
  84. package/src/rules/document/countMismatch.js +2 -1
  85. package/src/rules/document/enoughOnce.js +2 -1
  86. package/src/rules/document/fieldEcho.js +10 -6
  87. package/src/rules/document/keywordHeading.js +1 -1
  88. package/src/rules/document/keywordMissing.js +1 -1
  89. package/src/rules/document/noQuestion.js +2 -2
  90. package/src/rules/document/promiseRecall.js +1 -1
  91. package/src/rules/document/tableOddCell.js +1 -1
  92. package/src/rules/finding.js +4 -4
  93. package/src/rules/paragraph/factListParagraph.js +1 -1
  94. package/src/rules/paragraph/paraFragment.js +1 -1
  95. package/src/rules/readerDebt.js +1 -1
  96. package/src/rules/registry.js +26 -26
  97. package/src/rules/sentence/connectorRepeat.js +2 -1
  98. package/src/rules/sentence/danglingDeixis.js +1 -1
  99. package/src/rules/sentence/dash.js +1 -1
  100. package/src/rules/sentence/deixis.js +1 -1
  101. package/src/rules/sentence/doubleNegative.js +2 -1
  102. package/src/rules/sentence/doublePassive.js +2 -1
  103. package/src/rules/sentence/draftHistory.js +2 -1
  104. package/src/rules/sentence/endingRepeat.js +3 -2
  105. package/src/rules/sentence/euiChain.js +1 -1
  106. package/src/rules/sentence/fillerOpener.js +1 -1
  107. package/src/rules/sentence/imperativePeriod.js +1 -1
  108. package/src/rules/sentence/longSentence.js +1 -1
  109. package/src/rules/sentence/negationRedefine.js +1 -1
  110. package/src/rules/sentence/numberOrphan.js +1 -1
  111. package/src/rules/sentence/outsideProfile.js +8 -3
  112. package/src/rules/sentence/phraseRepeat.js +2 -1
  113. package/src/rules/shared/candidates.js +2 -2
  114. package/src/rules/shared/dictionaryRule.js +9 -2
  115. package/src/rules/shared/repeat.js +2 -2
  116. package/src/rules/structure/bridgeRepeat.js +3 -3
  117. package/src/rules/structure/emojiBullet.js +1 -1
  118. package/src/rules/structure/headingSkip.js +1 -1
  119. package/src/rules/structure/headingUniform.js +2 -1
  120. package/src/rules/structure/loneSubheading.js +1 -1
  121. package/src/rules/structure/moreLater.js +1 -1
  122. package/src/text.js +2 -2
  123. package/src/usage/counts.js +3 -3
  124. package/src/usage/index.js +3 -3
  125. package/src/usage/sentences.js +17 -17
package/src/index.js CHANGED
@@ -1,13 +1,13 @@
1
1
  // @ts-check
2
2
  /**
3
- * hanlint: 한국어 글에서 AI 와 사람이 반복해서 어기는 결함을 결정적으로 잡는 린터. 공개 표면은 이 파일 한 곳이다.
3
+ * hanlint: 한국어 글에서 규칙으로 잡을 수 있는 잘못을 찾는 검사기. 공개 API는 이 파일 한 곳에 있다.
4
4
  *
5
5
  * ```js
6
6
  * import { lintText, lintFile } from "hanlint";
7
7
  * for (const f of lintFile("글.md")) console.log(f.line, f.rule, f.why);
8
8
  * ```
9
9
  *
10
- * 합격과 불합격을 판정하지 않는다. 지적 목록이 비어 있다는 것은 세어서 잡히는 결함이 없다는 뜻이다.
10
+ * 합격과 불합격을 판정하지 않는다. 지적 목록이 비어 있다는 것은 규칙으로 잡을 수 있는 잘못이 없다는 뜻이다.
11
11
  * 같은 공개 API를 Node와 브라우저가 공유한다. 파일 경로를 읽는 API만 Node 전용이다.
12
12
  */
13
13
  import { readExternal } from "./data/read.js";
@@ -84,7 +84,7 @@ export {
84
84
  export const version = loadVersion();
85
85
 
86
86
  /**
87
- * 글을 한 번 읽어 지문을 만든다.
87
+ * 글을 한 번 읽어 분석 결과를 만든다.
88
88
  * @param {string} text
89
89
  * @param {import("./config/settings.js").Config} [config]
90
90
  * @param {string | null} [path]
@@ -104,7 +104,7 @@ export function lintText(text, config = defaultConfig(), path = null) {
104
104
  }
105
105
 
106
106
  /**
107
- * 파일을 UTF-8 로 읽어 검사한다.
107
+ * 파일을 UTF-8로 읽어 검사한다.
108
108
  * @param {string} path
109
109
  * @param {import("./config/settings.js").Config} [config]
110
110
  */
@@ -112,7 +112,7 @@ export function lintFile(path, config = defaultConfig()) {
112
112
  return lintText(readExternal(path), config, path);
113
113
  }
114
114
 
115
- /** 지적, 문체 본보기, 승인 고침과 지문을 같은 분석에서 제공한다. @param {string} text @param {import("./config/settings.js").Config} config */
115
+ /** 지적, 문체 예시, 승인 고침과 분석 결과를 같은 분석에서 준다. @param {string} text @param {import("./config/settings.js").Config} config */
116
116
  export function inspectText(text, config = defaultConfig()) {
117
117
  const document = fingerprint(text, config);
118
118
  const findings = runAll(document, config);
@@ -132,7 +132,7 @@ export function learnText(before, after, config = defaultConfig()) {
132
132
  };
133
133
  }
134
134
 
135
- /** 자유 원고의 표면과 H2 순서를 비교한다. 빈 글과 반복 제목도 관찰하며 승인 계약을 만들지 않는다. @param {string} before @param {string} after @param {import("./config/settings.js").Config} config */
135
+ /** 자유 원고의 숫자·링크·코드와 H2 순서를 비교한다. 빈 글과 반복 제목도 관찰하며 승인 계약을 만들지 않는다. @param {string} before @param {string} after @param {import("./config/settings.js").Config} config */
136
136
  export function compareRevision(before, after, config = defaultConfig()) {
137
137
  const original = before.normalize("NFC");
138
138
  const outline = { level: 2, headings: headingsOf(parseMarkdown(original), 2).map((heading) => heading.text) };
@@ -1,5 +1,5 @@
1
1
  // @ts-check
2
- /** Python learn/edits.py와 같은 원문 문장 대응 및 지적 소멸 후보다. */
2
+ /** Python learn/edits.py와 같은 방법으로 문장을 짝짓고, 지적이 사라진 문장 짝을 후보로 낸다. */
3
3
  import { sourceSentenceTexts } from "../fingerprint/sourceSentence.js";
4
4
  import { readerKind } from "../fingerprint/readerState.js";
5
5
  import { localCue, SENTENCE } from "../rules/finding.js";
@@ -21,7 +21,7 @@ export function learnExemplars(beforeDoc, afterDoc, beforeFindings, afterFinding
21
21
  if (!afterText || before.text.trim() === afterPlain) continue;
22
22
  seen.add(key);
23
23
  learned.push({ rule: finding.rule, before: (rawBefore.get(before.index) ?? before.text).trim(), after: afterText,
24
- moved: "실제 수정본의 문장으로 바꿈", why: finding.why, beforeLine: before.line,
24
+ moved: "실제로 고친 문장으로 바꿨다", why: finding.why, beforeLine: before.line,
25
25
  afterLines: [...new Set(after.map((item) => item.line))], sentence: before.text.trim(),
26
26
  cue: localCue(finding), reader: readerKind(before, beforeDoc.reader.beforeSentence[finding.at]),
27
27
  ...(preset ? { presets: [preset] } : {}),
@@ -1,5 +1,5 @@
1
1
  // @ts-check
2
- /** Python learn/operations.py의 표면 치환 후보와 증거 묶음. */
2
+ /** Python learn/operations.py의 치환 후보와 증거 묶음. */
3
3
  import { operationFromApproval } from "../data/operations.js";
4
4
  import { sourceSentenceTexts } from "../fingerprint/sourceSentence.js";
5
5
  import { changedSentencePairs } from "./pairs.js";
package/src/regex.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // @ts-check
2
2
  /**
3
- * 파이썬 re 와 같은 뜻으로 쓰는 얇은 껍질. search 는 첫 자리, at 은 문자열 첫머리에서만, all 은 전부.
3
+ * 파이썬 re와 같은 뜻으로 쓰는 얇은 껍질. search는 첫 일치, at은 문자열 첫머리에서만, all은 전부.
4
4
  * 정규식 문법은 데이터 파일과 규칙에서 파이썬과 같은 부분집합만 쓴다 (lookbehind 포함, 이름 그룹 없음).
5
5
  */
6
6
 
@@ -41,7 +41,7 @@ export function compile(source, flags = "") {
41
41
  };
42
42
  }
43
43
 
44
- /** 정규식 특수문자를 이스케이프한다 (파이썬 re.escape 의 쓰임새). @param {string} text */
44
+ /** 정규식 특수문자를 이스케이프한다 (파이썬 re.escape의 쓰임새). @param {string} text */
45
45
  export function escape(text) {
46
46
  return text.replace(SPECIALS, (c) => BACKSLASH + c);
47
47
  }
@@ -1,5 +1,5 @@
1
1
  // @ts-check
2
- /** 한 줄에 지적 하나. AI 와 스크립트가 읽는 형식이다. 파이썬 report/compactReport.py 와 같은 글자다. */
2
+ /** 한 줄에 지적 하나. AI와 스크립트가 읽는 형식이다. 파이썬 report/compactReport.py와 같은 글자다. */
3
3
 
4
4
  /** 줄바꿈과 이어진 공백을 하나로 눕힌다. 한 줄은 이 형식의 계약이다. @param {string} text */
5
5
  function flat(text) {
@@ -10,8 +10,8 @@ function flat(text) {
10
10
  export function renderCompact(path, findings) {
11
11
  return findings
12
12
  .map((f) => {
13
- const label = f.severity === "notice" ? "notice: " : "";
14
- const fixLabel = f.severity === "notice" ? "검토할 대안" : "고친 뒤";
13
+ const label = f.severity === "notice" ? "경고: " : "";
14
+ const fixLabel = f.severity === "notice" ? "제안" : "고친 뒤";
15
15
  return `${path}:${f.line} [${f.rule}] ${label}${flat(f.why)}` + (f.fix ? ` ${fixLabel}: ${flat(f.fix)}` : "");
16
16
  })
17
17
  .join("\n");
@@ -1,7 +1,7 @@
1
1
  // @ts-check
2
2
  /**
3
- * 지문 계층의 JSON 꼴. 파이썬 report/fingerprintJson.py 와 같은 키 순서다. 층마다 한 번씩만 적고 위층은
4
- * 아래층을 index 로 가리킨다. 실수는 소수 여섯째 자리로 맞춰 두 구현이 같은 글자를 낸다.
3
+ * 지문 계층의 JSON 꼴. 파이썬 report/fingerprintJson.py와 같은 키 순서다. 층마다 한 번씩만 적고 위층은
4
+ * 아래층을 index로 가리킨다. 실수는 소수 여섯째 자리로 맞춰 두 구현이 같은 글자를 낸다.
5
5
  */
6
6
 
7
7
  import { roundHalfEven } from "../text.js";
@@ -1,5 +1,5 @@
1
1
  // @ts-check
2
- /** GitHub Actions 의 워크플로 명령 꼴. PR 에 줄 단위 주석이 붙는다. */
2
+ /** GitHub Actions의 워크플로 명령 꼴. PR에 줄 단위 주석이 붙는다. */
3
3
 
4
4
  /** @param {string} path @param {import("../rules/finding.js").Finding[]} findings */
5
5
  export function renderGithub(path, findings) {
@@ -1,7 +1,7 @@
1
1
  // @ts-check
2
2
  /**
3
- * 기계가 읽는 형식. 평가 루프의 0층 입력이다. 파이썬 json.dumps(indent=2, ensure_ascii=False) 와 같은 글자다.
4
- * 지적마다 `exemplar` 를 붙인다. 승인 원문 완전 일치 `patch`는 맞는 지적에만 별도로 붙인다.
3
+ * 기계가 읽는 형식. 평가 루프의 0층 입력이다. 파이썬 json.dumps(indent=2, ensure_ascii=False)와 같은 글자다.
4
+ * 지적마다 `exemplar`를 붙인다. 승인 원문 완전 일치 `patch`는 맞는 지적에만 별도로 붙인다.
5
5
  * 실제 수정 차이는 파이썬의 exemplarLift와 patchMemory 실험 스크립트에서 따로 잰다.
6
6
  */
7
7
  import { exemplarFor } from "../data/exemplars.js";
@@ -1,5 +1,5 @@
1
1
  // @ts-check
2
- /** 한 문체로 저장한 본보기와 문형을 검사한 글의 문체에 맞춘다. */
2
+ /** 한 문체로 저장한 예시와 문형을 검사한 글의 문체에 맞춘다. */
3
3
  import { HAPNIDA, REGISTERS, convertTemplate } from "../analysis/grammar/index.js";
4
4
 
5
5
  /** 섞임과 없음은 데이터 정본의 합니다체를 유지한다. @param {string | null | undefined} register */
@@ -14,7 +14,7 @@ export function exemplarInRegister(exemplar, register) {
14
14
  const after = convertTemplate(exemplar.after, target);
15
15
  if (before.skipped || after.skipped) {
16
16
  throw new Error(
17
- exemplar.rule + " 본보기의 서술어를 못 풀었다: " + (before.skipped + after.skipped),
17
+ exemplar.rule + " 예시의 서술어를 못 풀었다: " + (before.skipped + after.skipped),
18
18
  );
19
19
  }
20
20
  return { rule: exemplar.rule, before: before.text, after: after.text, moved: exemplar.moved, presets: exemplar.presets };
@@ -1,7 +1,7 @@
1
1
  // @ts-check
2
2
  /**
3
3
  * 글 시트. 소스에서 뽑은 글 마디와 지적을 마크다운 표 하나로 내고, 사람이 고친 표를 다시 읽는다.
4
- * 뜻과 칸의 정본은 파이썬 report/sheet.py 다. 여기는 같은 글자를 낸다.
4
+ * 뜻과 칸의 정본은 파이썬 report/sheet.py다. 여기는 같은 글자를 낸다.
5
5
  */
6
6
 
7
7
  import { parseMarkdown } from "../document/parseMarkdown.js";
@@ -19,12 +19,12 @@ const ROW_PATTERN = /^\|(.*)\|$/;
19
19
  * @property {string} text
20
20
  * @property {import("../rules/finding.js").Finding[]} findings
21
21
  * @property {string} fix 사람이 표에 적은 고친 글. 뽑을 때는 비어 있다
22
- * @property {number} column 1부터 세는 칸. 0 이면 모른다
22
+ * @property {number} column 1부터 세는 칸. 0이면 모른다
23
23
  */
24
24
 
25
25
  /** @param {string} text */
26
26
  /**
27
- * 지적이 하나이고 그 지적에 고침이 있으면 고침 칸에 미리 적는다. 뜻은 파이썬 report/sheet.py 의 prefilledFix 가 소유한다.
27
+ * 지적이 하나이고 그 지적에 고침이 있으면 고침 칸에 미리 적는다. 뜻은 파이썬 report/sheet.py의 prefilledFix가 소유한다.
28
28
  * @param {import("../document/sourceText.js").SourceLiteral} literal
29
29
  * @param {import("../rules/finding.js").Finding[]} findings
30
30
  */
@@ -35,8 +35,8 @@ function prefilledFix(literal, findings) {
35
35
  }
36
36
 
37
37
  /**
38
- * (라벨, 소스) 마다 글을 뽑아 규칙을 돌리고 표의 행을 만든다. 지적은 `error` 만 남긴다.
39
- * 소스를 읽는 쪽 (CLI 는 디스크, 브라우저는 저장소) 이 다르므로 읽기는 밖에 두고 문자열만 받는다.
38
+ * (라벨, 소스)마다 글을 뽑아 규칙을 돌리고 표의 행을 만든다. 지적은 `error`만 남긴다.
39
+ * 소스를 읽는 쪽 (CLI는 디스크, 브라우저는 저장소)이 다르므로 읽기는 밖에 두고 문자열만 받는다.
40
40
  * @param {Iterable<{ label: string, source: string }>} sources
41
41
  * @param {import("../config/settings.js").Config} config
42
42
  * @param {boolean} [everything] 지적이 없는 글도 넣는다
@@ -63,7 +63,7 @@ export function unescapeCell(text) {
63
63
  return text.replaceAll("\\|", "|").replaceAll("\\\\", "\\");
64
64
  }
65
65
 
66
- /** `| a | b |` 를 칸으로. `\|` 는 칸 경계가 아니다. @param {string} line @returns {string[] | null} */
66
+ /** `| a | b |`를 칸으로. `\|`는 칸 경계가 아니다. @param {string} line @returns {string[] | null} */
67
67
  export function splitRow(line) {
68
68
  const match = ROW_PATTERN.exec(line.trim());
69
69
  if (!match) return null;
@@ -91,7 +91,7 @@ const CUE = /^`[^`]+`/;
91
91
 
92
92
 
93
93
  /**
94
- * `column` (1부터) 에 `old` 가 그대로 있으면 그 자리만 `new` 로 바꾼다.
94
+ * `column` (1부터)에 `old`가 그대로 있으면 그 자리만 `new`로 바꾼다.
95
95
  * @param {string} lineText @param {number} column @param {string} old @param {string} replacement @returns {[string, boolean]}
96
96
  */
97
97
  function replaceAt(lineText, column, old, replacement) {
@@ -101,8 +101,8 @@ function replaceAt(lineText, column, old, replacement) {
101
101
  }
102
102
 
103
103
  /**
104
- * 한 파일의 소스에 표의 행들을 되돌려 쓴다. [새 소스, 적용한 자리, 실패한 자리와 이유]. 뜻은 파이썬 report/sheet.py 의
105
- * applyRows 가 소유한다. 한 줄에 고침이 여럿이면 뒤 칸부터 바꾼다. 줄은 `\n` 으로만 나누므로 `\r` 은 남아 CRLF 파일이 그대로다.
104
+ * 한 파일의 소스에 표의 행들을 되돌려 쓴다. [새 소스, 적용한 자리, 실패한 자리와 이유]. 뜻은 파이썬 report/sheet.py의
105
+ * applyRows가 소유한다. 한 줄에 고침이 여럿이면 뒤 칸부터 바꾼다. 줄은 `\n` 으로만 나누므로 `\r`은 남아 CRLF 파일이 그대로다.
106
106
  * @param {string} source @param {SheetRow[]} rows @returns {[string, string[], string[]]}
107
107
  */
108
108
  export function applyRows(source, rows) {
@@ -140,8 +140,8 @@ export function applyRows(source, rows) {
140
140
  }
141
141
 
142
142
  /**
143
- * 규칙마다 한 번. 첫 지적의 이유는 온전히, 같은 규칙의 나머지는 단서 (이유 앞의 `…`) 만 `(또 …)` 로 잇는다.
144
- * 뜻은 파이썬 report/sheet.py 의 findingCell 이 소유한다.
143
+ * 규칙마다 한 번. 첫 지적의 이유는 온전히, 같은 규칙의 나머지는 단서 (이유 앞의 `…`)만 `(또 …)`로 잇는다.
144
+ * 뜻은 파이썬 report/sheet.py의 findingCell이 소유한다.
145
145
  * @param {import("../rules/finding.js").Finding[]} findings
146
146
  */
147
147
  function findingCell(findings) {
@@ -168,7 +168,7 @@ function placeOf(row) {
168
168
  return row.column > 0 ? `${row.file}:${row.line}:${row.column}` : `${row.file}:${row.line}`;
169
169
  }
170
170
 
171
- /** `경로:줄:칸` 이나 `경로:줄` 을 [경로, 줄, 칸] 으로. 칸이 없으면 0. 꼴이 아니면 경로가 null. @param {string} place @returns {[string | null, number, number]} */
171
+ /** `경로:줄:칸`이나 `경로:줄`을 [경로, 줄, 칸]으로. 칸이 없으면 0. 꼴이 아니면 경로가 null. @param {string} place @returns {[string | null, number, number]} */
172
172
  export function splitPlace(place) {
173
173
  const last = place.lastIndexOf(":");
174
174
  if (last < 0 || !/^[0-9]+$/.test(place.slice(last + 1))) return [null, 0, 0];
@@ -185,7 +185,7 @@ export function renderSheet(rows, preset, fileCount) {
185
185
  "# hanlint sheet",
186
186
  "",
187
187
  `프리셋 ${preset}, 파일 ${fileCount}개, 글 ${rows.length}개, 지적 있는 글 ${flagged}개. ` +
188
- "고침 칸에 새 글을 적고 `hanlint sheet apply 이파일.md` 로 되돌려 쓴다. 다른 칸은 고치지 않는다.",
188
+ "고침 칸에 새 글을 적고 `hanlint sheet apply 이파일.md`로 되돌려 쓴다. 다른 칸은 고치지 않는다.",
189
189
  "",
190
190
  `| ${HEADER.join(" | ")} |`,
191
191
  `| ${HEADER.map(() => "---").join(" | ")} |`,
@@ -216,7 +216,7 @@ export function renderSheetJson(rows, preset, fileCount) {
216
216
  }
217
217
 
218
218
  /**
219
- * 사람이 고친 표를 읽는다. 머리글과 구분선은 넘기고 `고침` 이 있는 줄만 남긴다.
219
+ * 사람이 고친 표를 읽는다. 머리글과 구분선은 넘기고 `고침`이 있는 줄만 남긴다.
220
220
  * @param {string} markdown @returns {{ rows: SheetRow[], problems: string[] }}
221
221
  */
222
222
  export function parseSheet(markdown) {
@@ -230,13 +230,13 @@ export function parseSheet(markdown) {
230
230
  if (cells === null) return;
231
231
  if (cells.slice(0, HEADER.length).join("") === HEADER.join("") || cells.every((cell) => cell.replace(/-/g, "") === "")) return;
232
232
  if (cells.length !== HEADER.length) {
233
- problems.push(`${lineNumber}행: 칸이 ${cells.length}개다. ${HEADER.length}개여야 한다`);
233
+ problems.push(`${lineNumber}번째 줄: 칸이 ${cells.length}개다. ${HEADER.length}개여야 한다`);
234
234
  return;
235
235
  }
236
236
  const place = unescapeCell(cells[1]);
237
237
  const [file, line, column] = splitPlace(place);
238
238
  if (file === null) {
239
- problems.push(`${lineNumber}행: 자리 \`${place}\` 가 경로:줄 이나 경로:줄:칸 꼴이 아니다`);
239
+ problems.push(`${lineNumber}번째 줄: 경로:줄이나 경로:줄:칸 형식이 아닌 자리: \`${place}\``);
240
240
  return;
241
241
  }
242
242
  const fix = unescapeCell(cells[4]);
@@ -1,12 +1,12 @@
1
1
  // @ts-check
2
2
  /**
3
- * 사람이 읽는 지적 목록. `파일:줄` 로 시작해 편집기가 바로 연다. 파이썬 report/textReport.py 와 같은 글자다.
4
- * 끝에 본보기를 붙인다. 전과 후를 제 줄에 둔다. 왜 한 줄이 아닌지는 파이썬 report/textReport.py 가 소유한다.
3
+ * 사람이 읽는 지적 목록. `파일:줄`로 시작해 편집기가 바로 연다. 파이썬 report/textReport.py와 같은 글자다.
4
+ * 끝에 예시를 붙인다. 전과 후를 제 줄에 둔다. 왜 한 줄이 아닌지는 파이썬 report/textReport.py가 소유한다.
5
5
  */
6
6
  import { exemplarFor, shortened, twoLines } from "../data/exemplars.js";
7
7
  import { exemplarInRegister } from "./registerMatch.js";
8
8
 
9
- /** 그 글에 나온 규칙의 본보기. 이름 순이고 규칙 하나에 세 줄이다. @param {import("../rules/finding.js").Finding[]} findings @param {string | null | undefined} register @param {string | null | undefined} preset @param {import("../data/exemplars.js").Exemplar[]} customExemplars */
9
+ /** 그 글에 나온 규칙의 예시. 이름 순이고 규칙 하나에 세 줄이다. @param {import("../rules/finding.js").Finding[]} findings @param {string | null | undefined} register @param {string | null | undefined} preset @param {import("../data/exemplars.js").Exemplar[]} customExemplars */
10
10
  function exemplarLines(findings, register, preset, customExemplars) {
11
11
  const lines = [];
12
12
  let cut = false;
@@ -19,15 +19,15 @@ function exemplarLines(findings, register, preset, customExemplars) {
19
19
  lines.push(` [${name}]`, ` 전 ${before}`, ` 후 ${after}`);
20
20
  }
21
21
  if (!lines.length) return [];
22
- const head = "본보기 (고치기 전, 고친 뒤)";
23
- return [cut ? `${head}. 잘린 것은 hanlint explain <규칙>` : head, ...lines];
22
+ const head = "예시 (고치기 전, 고친 뒤)";
23
+ return [cut ? `${head}. 잘린 것은 hanlint explain <규칙>으로 본다` : head, ...lines];
24
24
  }
25
25
 
26
- /** 본보기 아래에 붙이는 선택지. 지적 자리별이며 순위는 없다. @param {import("../rules/finding.js").Finding[]} findings */
26
+ /** 예시 아래에 붙이는 선택지. 지적 자리별이며 순위는 없다. @param {import("../rules/finding.js").Finding[]} findings */
27
27
  function candidateLines(findings) {
28
28
  const chosen = findings.filter((finding) => finding.candidates.length);
29
29
  if (!chosen.length) return [];
30
- const lines = ["후보 (기계가 고르지 않음)"];
30
+ const lines = ["후보 (hanlint가 하나를 고르지 않았다)"];
31
31
  for (const finding of chosen) {
32
32
  lines.push(` ${finding.line}줄 [${finding.rule}]`);
33
33
  for (const candidate of finding.candidates) lines.push(` - ${candidate.text} (${candidate.why})`);
@@ -35,7 +35,7 @@ function candidateLines(findings) {
35
35
  return lines;
36
36
  }
37
37
 
38
- /** 접은 확인 권고. 규칙마다 개수와 줄 번호 한 줄. 뜻은 파이썬 textReport.py 의 noticeLines 가 소유한다. @param {import("../rules/finding.js").Finding[]} notices */
38
+ /** 접은 경고. 규칙마다 개수와 줄 번호 한 줄. 뜻은 파이썬 textReport.py의 noticeLines가 소유한다. @param {import("../rules/finding.js").Finding[]} notices */
39
39
  function noticeLines(notices) {
40
40
  /** @type {Map<string, number[]>} */
41
41
  const byRule = new Map();
@@ -43,7 +43,7 @@ function noticeLines(notices) {
43
43
  if (!byRule.has(finding.rule)) byRule.set(finding.rule, []);
44
44
  /** @type {number[]} */ (byRule.get(finding.rule)).push(finding.line);
45
45
  }
46
- const lines = [`확인 권고 ${notices.length}건 (접음. 다 보려면 --notices)`];
46
+ const lines = [`경고 ${notices.length}건 (접어 두었다. 다 보려면 --notices)`];
47
47
  for (const rule of [...byRule.keys()].sort()) {
48
48
  const numbers = [...new Set(/** @type {number[]} */ (byRule.get(rule)))].sort((a, b) => a - b);
49
49
  lines.push(` ${rule} ${/** @type {number[]} */ (byRule.get(rule)).length} ${numbers.join(", ")}줄`);
@@ -56,14 +56,14 @@ export function renderText(path, findings, register = null, preset = null, custo
56
56
  if (!findings.length) return `${path} 오류 없음`;
57
57
  const errors = findings.filter((f) => f.severity === "error").length;
58
58
  const notices = findings.length - errors;
59
- const summary = `${path} 오류 ${errors}건` + (notices ? `, 확인 권고 ${notices}건` : "");
59
+ const summary = `${path} 오류 ${errors}건` + (notices ? `, 경고 ${notices}건` : "");
60
60
  const lines = [summary, ""];
61
61
  const folded = unfoldNotices ? [] : findings.filter((f) => f.severity === "notice");
62
62
  const shown = findings.filter((f) => unfoldNotices || f.severity === "error");
63
63
  for (const f of shown) {
64
- const tag = `[${f.rule}]` + (f.severity === "notice" ? " 확인" : "");
64
+ const tag = `[${f.rule}]` + (f.severity === "notice" ? " 경고" : "");
65
65
  lines.push(`${path}:${f.line} ${tag}`, ` ${f.quote}`, ` ${f.why}`);
66
- if (f.fix) lines.push(` ${f.severity === "notice" ? "검토할 대안" : "고친 뒤"}: ${f.fix}`);
66
+ if (f.fix) lines.push(` ${f.severity === "notice" ? "제안" : "고친 뒤"}: ${f.fix}`);
67
67
  lines.push("");
68
68
  }
69
69
  if (folded.length) lines.push(...noticeLines(folded), "");
@@ -27,7 +27,7 @@ function percent(value) {
27
27
  function selectedProfile(config) {
28
28
  if (config.profile) return userProfile(config.profile);
29
29
  const kind = PROFILE_OF[config.preset];
30
- if (kind === null) throw new Error(`${config.preset} 은 종류 프로파일이 없어 사양을 낼 수 없다`);
30
+ if (kind === null) throw new Error(`${config.preset} 프리셋은 비교할 프로파일이 없어 스펙을 낼 수 없다`);
31
31
  const profile = profileOf(kind);
32
32
  if (profile === null) throw new Error(`${kind} 프로파일을 찾지 못했다`);
33
33
  return profile;
@@ -40,8 +40,8 @@ function selectedProfile(config) {
40
40
  * @param {number | null} [targetChars]
41
41
  */
42
42
  export function writingSpec(config = defaultConfig(), register = HAPNIDA, targetChars = null) {
43
- if (!REGISTERS.includes(register)) throw new Error(`register 는 ${REGISTERS.join(", ")} 가운데 하나다: ${register}`);
44
- if (targetChars !== null && targetChars <= 0) throw new Error(`chars 는 1 이상이어야 한다: ${targetChars}`);
43
+ if (!REGISTERS.includes(register)) throw new Error(`register는 ${REGISTERS.join(", ")} 가운데 하나여야 한다. 받은 값: ${register}`);
44
+ if (targetChars !== null && targetChars <= 0) throw new Error(`chars는 1 이상이어야 한다. 받은 값: ${targetChars}`);
45
45
  const profile = selectedProfile(config);
46
46
  const sentence = profile.sentence;
47
47
  const paragraph = profile.paragraph.get("sentenceCount");
@@ -62,7 +62,7 @@ export function writingSpec(config = defaultConfig(), register = HAPNIDA, target
62
62
  }
63
63
  rows.push({ id: "amount", label: "분량", guidance: amount, basis: [`profile.${profile.kind}.paragraph.sentenceCount`] });
64
64
 
65
- let length = `중앙 ${lengthP50}어절, 문장 90%가 ${percentile(lengthHistogram, 90)}어절 이하`;
65
+ let length = `중앙값 ${lengthP50}어절, 문장 90%가 ${percentile(lengthHistogram, 90)}어절 이하`;
66
66
  const lengthBasis = [`profile.${profile.kind}.sentence.length`];
67
67
  if (enabled(config, "longSentence")) {
68
68
  length += `. ${config.longSentenceMax}어절을 넘으면 longSentence가 지적`;
@@ -71,7 +71,7 @@ export function writingSpec(config = defaultConfig(), register = HAPNIDA, target
71
71
  rows.push({ id: "sentenceLength", label: "문장 길이", guidance: length, basis: lengthBasis });
72
72
 
73
73
  if (profile.endingRuns !== null) {
74
- let endingText = `같은 끝맺음 연속 중앙 ${percentile(profile.endingRuns, 50)}개, 90%가 ${percentile(profile.endingRuns, 90)}개 이하`;
74
+ let endingText = `같은 끝맺음 연속 중앙값 ${percentile(profile.endingRuns, 50)}개, 90%가 ${percentile(profile.endingRuns, 90)}개 이하`;
75
75
  const endingBasis = [`profile.${profile.kind}.endingRuns`];
76
76
  if (enabled(config, "endingRepeat")) {
77
77
  endingText += `. 인과나 독자 호출 없이 ${config.endingRun}개부터 endingRepeat가 지적`;
@@ -85,13 +85,13 @@ export function writingSpec(config = defaultConfig(), register = HAPNIDA, target
85
85
  rows.push({
86
86
  id: "commas",
87
87
  label: "쉼표",
88
- guidance: `문장 중앙 ${percentile(commas, 50)}개, 90%가 ${percentile(commas, 90)}개 이하, 99%가 ${percentile(commas, 99)}개 이하`,
88
+ guidance: `문장 중앙값 ${percentile(commas, 50)}개, 90%가 ${percentile(commas, 90)}개 이하, 99%가 ${percentile(commas, 99)}개 이하`,
89
89
  basis: [`profile.${profile.kind}.sentence.commas`],
90
90
  });
91
91
 
92
92
  const eui = sentence.get("euiCount");
93
93
  if (!eui) throw new Error("프로파일에 sentence.euiCount가 없다");
94
- let euiText = `문장 중앙 ${percentile(eui, 50)}개, 90%가 ${percentile(eui, 90)}개 이하`;
94
+ let euiText = `문장 중앙값 ${percentile(eui, 50)}개, 90%가 ${percentile(eui, 90)}개 이하`;
95
95
  const euiBasis = [`profile.${profile.kind}.sentence.euiCount`];
96
96
  if (enabled(config, "euiChain")) {
97
97
  euiText += ". 3개부터, 붙은 2개부터 euiChain이 지적";
@@ -114,7 +114,7 @@ export function writingSpec(config = defaultConfig(), register = HAPNIDA, target
114
114
  rows.push({
115
115
  id: "newTopics",
116
116
  label: "새 화제",
117
- guidance: `문장 중앙 ${percentile(newTopics, 50)}개, 90%가 ${percentile(newTopics, 90)}개 이하`,
117
+ guidance: `문장 중앙값 ${percentile(newTopics, 50)}개, 90%가 ${percentile(newTopics, 90)}개 이하`,
118
118
  basis: [`profile.${profile.kind}.sentence.newTopics`],
119
119
  });
120
120
 
@@ -122,7 +122,7 @@ export function writingSpec(config = defaultConfig(), register = HAPNIDA, target
122
122
  rows.push({
123
123
  id: "connector",
124
124
  label: "문두 접속",
125
- guidance: `문서 중앙은 문장의 ${percent(connector.p50)}%, 문서 90%가 ${percent(connector.p90)}% 이하`,
125
+ guidance: `문서 중앙값은 문장의 ${percent(connector.p50)}%, 문서 90%가 ${percent(connector.p90)}% 이하`,
126
126
  basis: [`profile.${profile.kind}.rates.connector`],
127
127
  });
128
128
 
@@ -132,8 +132,8 @@ export function writingSpec(config = defaultConfig(), register = HAPNIDA, target
132
132
  const numberP90 = percentile(numbers, 90);
133
133
  const numberText =
134
134
  numberP50 === 0 && numberP90 === 0
135
- ? "문장 중앙과 90%에서 숫자 없음"
136
- : `문장 중앙 ${numberP50}개, 90%가 ${numberP90}개 이하`;
135
+ ? "문장 중앙값과 90%에서 숫자 없음"
136
+ : `문장 중앙값 ${numberP50}개, 90%가 ${numberP90}개 이하`;
137
137
  rows.push({ id: "numbers", label: "숫자", guidance: numberText, basis: [`profile.${profile.kind}.sentence.numbers`] });
138
138
 
139
139
  const question = profile.rates.question;
@@ -142,10 +142,10 @@ export function writingSpec(config = defaultConfig(), register = HAPNIDA, target
142
142
  if (enabled(config, "noQuestion")) {
143
143
  questionText = config.enforceStyle.includes("noQuestion")
144
144
  ? "선택한 정책: 절이 2개 이상이면 글 전체에 물음표 최소 1개"
145
- : "물음표 부재는 notice다. 질문 없이 충분히 설명했다면 유지한다";
145
+ : "물음표가 없으면 경고다. 질문 없이 충분히 설명했다면 그대로 둔다";
146
146
  questionBasis = [`profile.${profile.kind}.rates.question`, "rule.noQuestion"];
147
147
  } else {
148
- questionText = `규칙 요구 없음. 문서 중앙은 문장의 ${percent(question.p50)}%, 문서 90%가 ${percent(question.p90)}% 이하`;
148
+ questionText = `규칙 요구 없음. 문서 중앙값은 문장의 ${percent(question.p50)}%, 문서 90%가 ${percent(question.p90)}% 이하`;
149
149
  questionBasis = [`profile.${profile.kind}.rates.question`];
150
150
  }
151
151
  rows.push({ id: "question", label: "물음표", guidance: questionText, basis: questionBasis });
@@ -165,10 +165,10 @@ export function writingSpec(config = defaultConfig(), register = HAPNIDA, target
165
165
  export function renderWritingSpec(spec) {
166
166
  const target = spec.targetChars !== null ? `, ${spec.targetChars}자` : "";
167
167
  const lines = [
168
- `hanlint spec ${spec.preset} 종류, ${spec.register}체${target}. 같은 규칙 세트의 임계와 ${spec.profile} 프로파일을 쓰기 전 숫자로 편다`,
168
+ `hanlint spec ${spec.preset} 종류, ${spec.register}체${target}. 같은 규칙의 기준값과 ${spec.profile} 글의 평소 수치를 쓰기 전에 보여 준다`,
169
169
  ];
170
170
  const width = Math.max(...spec.rows.map((row) => row.label.length));
171
171
  for (const row of spec.rows) lines.push(` ${row.label.padEnd(width)} ${row.guidance}`);
172
- lines.push("", "이 사양은 품질 점수가 아니다. 쓴 뒤 같은 설정으로 hanlint <글.md>를 실행해 Finding을 확인한다");
172
+ lines.push("", "이 스펙은 품질 점수가 아니다. 다 쓴 뒤 같은 설정으로 hanlint <글.md>를 실행해 지적을 확인한다");
173
173
  return lines.join("\n");
174
174
  }
@@ -42,7 +42,7 @@ export function run(doc) {
42
42
  if (!isDataFile(path)) continue;
43
43
  const name2 = fileName(path);
44
44
  if (have.has(name2) || doc.reader.mentionedBefore(block.index, name2)) continue;
45
- findings.push(finding(name, line, code.trim(), `\`${name2}\` 을 읽는데 글 어디에서도 만들지 않았다. 독자는 여기서 파일 없음 오류로 멈춘다`, null, "error", DOCUMENT, block.index));
45
+ findings.push(finding(name, line, code.trim(), `\`${name2}\` 파일을 읽는데 글 어디에서도 만들지 않았다. 독자는 여기서 파일 없음 오류로 멈춘다`, null, "error", DOCUMENT, block.index));
46
46
  }
47
47
  for (const path of [...writeTargets(code), ...reads]) {
48
48
  if (NOT_A_PATH.some((mark) => path.includes(mark))) continue;
@@ -108,7 +108,7 @@ export function run(doc) {
108
108
  const acceptable = packageOf().get(top) ?? new Set([normalize(top)]);
109
109
  if (![...acceptable].some((pkg) => packages.has(pkg))) {
110
110
  const packageNames = [...acceptable].sort().join("`, `");
111
- findings.push(finding(name, line, code.trim(), `\`${top}\` 를 import 하는데 설치 줄에 허용되는 이름 \`${packageNames}\` 가 없다. 독자는 ModuleNotFoundError 에서 멈춘다`, null, "error", DOCUMENT, block.index));
111
+ findings.push(finding(name, line, code.trim(), `\`${top}\` 모듈을 import하는데 설치 줄에 그 모듈이 든 패키지(\`${packageNames}\`)가 없다. 독자는 ModuleNotFoundError에서 멈춘다`, null, "error", DOCUMENT, block.index));
112
112
  }
113
113
  }
114
114
  }
@@ -120,7 +120,7 @@ export function run(doc) {
120
120
  const name2 = normalize(parsed[1]);
121
121
  const extra = parsed[2] ? normalize(parsed[2]) : null;
122
122
  if (packages.has(name2) && (extra === null || (packages.get(name2)?.has(extra) ?? false))) continue;
123
- findings.push(finding(name, line, code.trim(), `이 줄은 \`${requirement}\` 가 있어야 돈다. ${why}. 설치 줄에 없다`, null, NOTICE, DOCUMENT, block.index));
123
+ findings.push(finding(name, line, code.trim(), `이 줄은 \`${requirement}\` 패키지가 있어야 실행되는데 설치 줄에 없다. ${why}`, null, NOTICE, DOCUMENT, block.index));
124
124
  }
125
125
  }
126
126
  }
@@ -27,7 +27,7 @@ export function run(doc) {
27
27
  for (const [pattern, source, platform, alternative] of platformApis()) {
28
28
  pattern.lastIndex = 0;
29
29
  if (pattern.test(code)) {
30
- findings.push(finding(name, line, code.trim(), `\`${source}\` 은 ${platform} 전용이다. 다른 운영체제 독자는 여기서 멈춘다. ${alternative}`, null, NOTICE, DOCUMENT, block.index));
30
+ findings.push(finding(name, line, code.trim(), `\`${source}\` 부분은 ${platform} 전용이다. 다른 운영체제의 독자는 여기서 멈춘다. ${alternative}`, null, NOTICE, DOCUMENT, block.index));
31
31
  break;
32
32
  }
33
33
  }
@@ -1,4 +1,5 @@
1
1
  // @ts-check
2
+ import { fitJosa } from "../../analysis/grammar/josa.js";
2
3
  import { DOCUMENT, finding } from "../finding.js";
3
4
 
4
5
  export const name = "countMismatch";
@@ -50,7 +51,7 @@ export function run(doc, config) {
50
51
  const conflict = promises.slice(i + 1).find((p) => p[1] !== numberA && comparable(doc, lineA, p[0], config.countMismatchSpan));
51
52
  if (!conflict) continue;
52
53
  const [lineB, , textB] = conflict;
53
- findings.push(finding(name, lineB, textB, `${lineA}번째 줄은 \`${textA}\` 인데 여기는 \`${textB}\` 다. 같은 단위로 다른 수를 약속하면 독자가 어느 쪽을 믿을지 모른다`, null, "error", DOCUMENT, -1));
54
+ findings.push(finding(name, lineB, textB, `${lineA}번째 줄은 \`${textA}\`${fitJosa(textA, "이라고")} 했는데 여기서는 \`${textB}\`${fitJosa(textB, "이라고")} 한다. 같은 단위로 다른 수를 약속하면 독자가 어느 쪽을 믿을지 모른다`, null, "error", DOCUMENT, -1));
54
55
  break;
55
56
  }
56
57
  }
@@ -1,4 +1,5 @@
1
1
  // @ts-check
2
+ import { fitJosa } from "../../analysis/grammar/josa.js";
2
3
  import { matchedTexts } from "../../fingerprint/markers.js";
3
4
  import { SENTENCE, finding } from "../finding.js";
4
5
 
@@ -20,6 +21,6 @@ export function run(doc, config) {
20
21
  if (breaks.length < 2) return [];
21
22
  const first = breaks[0][0];
22
23
  return breaks.slice(1).map(([sentence, text]) =>
23
- finding(name, sentence.line, sentence.text, `\`${text}\` 로 끊는 문장이 ${first.line}행에 이미 있다. 글 전체에 한 번만 쓴다. 계속 끝나는 척하다 이어지는 꼴이 된다`, null, "error", SENTENCE, sentence.index),
24
+ finding(name, sentence.line, sentence.text, `\`${text}\`${fitJosa(text, "로")} 끊는 문장이 ${first.line}번째 줄에 이미 있다. 글 전체에 한 번만 쓴다. 여러 번 쓰면 끝난다고 해 놓고 글이 계속 이어진다`, null, "error", SENTENCE, sentence.index),
24
25
  );
25
26
  }
@@ -5,22 +5,26 @@ import { DOCUMENT, NOTICE, finding } from "../finding.js";
5
5
  export const name = "fieldEcho";
6
6
  export const mechanism = "contrast";
7
7
 
8
+ /** 글의 끝으로 보는 절의 수. 결말 뒤에 `더 해 볼 것` 목록이 붙는 경우가 흔하다. 파이썬과 같다. */
9
+ const ENDING_SECTIONS = 2;
10
+
8
11
  /**
9
12
  * @param {import("../../fingerprint/build.js").DocumentPrint} doc
10
- * @param {import("../../fingerprint/build.js").SectionPrint} section
13
+ * @param {import("../../fingerprint/build.js").SectionPrint[]} sections
11
14
  * @param {string[]} fields
12
15
  * @param {string} where
13
16
  */
14
- function check(doc, section, fields, where) {
17
+ function check(doc, sections, fields, where) {
15
18
  const findings = [];
19
+ const section = sections[0];
16
20
  for (const name_ of fields) {
17
21
  const value = doc.frontmatter[name_];
18
22
  if (!value) continue;
19
23
  const promised = topicsOf(value);
20
24
  if (!promised.size) continue;
21
- if ([...promised].some((word) => section.topics.has(word))) continue;
25
+ if ([...promised].some((word) => sections.some((spoken) => spoken.topics.has(word)))) continue;
22
26
  findings.push(
23
- finding(name, section.startLine, value, `frontmatter 의 \`${name_}\` 가 약속한 말이 ${where}에 하나도 없다. 약속한 것을 ${where}에서 그 말로 답한다`, null, NOTICE, DOCUMENT, section.index),
27
+ finding(name, section.startLine, value, `frontmatter의 \`${name_}\` 필드가 약속한 말이 ${where}에 하나도 없다. 약속한 것을 ${where}에서 그 말로 답한다`, null, NOTICE, DOCUMENT, section.index),
24
28
  );
25
29
  }
26
30
  return findings;
@@ -32,7 +36,7 @@ function check(doc, section, fields, where) {
32
36
  */
33
37
  export function run(doc, config) {
34
38
  if (!config.introFields.length && !config.endingFields.length) return [];
35
- const findings = check(doc, doc.intro, config.introFields, "도입");
36
- if (doc.sections.length > 1) findings.push(...check(doc, doc.sections[doc.sections.length - 1], config.endingFields, "마지막 절"));
39
+ const findings = check(doc, [doc.intro], config.introFields, "도입");
40
+ if (doc.sections.length > 1) findings.push(...check(doc, doc.sections.slice(-ENDING_SECTIONS), config.endingFields, "글의 끝"));
37
41
  return findings;
38
42
  }
@@ -27,6 +27,6 @@ export function run(doc, config) {
27
27
  .filter((word) => word.length >= MIN_WORD && promised.includes(word) && !joined.includes(word));
28
28
  if (!missing.length) return [];
29
29
  return [
30
- finding(name, headings[0][2], joined, `글이 내건 \`${missing[0]}\` 가 절 제목 어디에도 없다. 목차를 훑는 독자가 이 글이 그것을 다루는지 못 본다`, null, NOTICE, DOCUMENT, -1),
30
+ finding(name, headings[0][2], joined, `글이 내건 말이 절 제목 어디에도 없다: \`${missing[0]}\`. 목차를 훑는 독자는 이 글이 그것을 다루는지 알 수 없다`, null, NOTICE, DOCUMENT, -1),
31
31
  ];
32
32
  }
@@ -17,6 +17,6 @@ export function run(doc, config) {
17
17
  const head = `${title}\n${first ? first.text : ""}`;
18
18
  if (head.includes(keyword)) return [];
19
19
  return [
20
- finding(name, first ? first.startLine : 1, title || (first ? first.text : ""), `대표 검색어 \`${keyword}\` 가 제목에도 첫 문단에도 없다. 검색해 들어온 독자가 자기가 친 이름을 못 본다`, null, "error", DOCUMENT, -1),
20
+ finding(name, first ? first.startLine : 1, title || (first ? first.text : ""), `대표 검색어가 제목에도 첫 문단에도 없다: \`${keyword}\`. 검색해 들어온 독자는 자기가 친 이름을 보지 못한다`, null, "error", DOCUMENT, -1),
21
21
  ];
22
22
  }
@@ -9,7 +9,7 @@ export function run(doc, config) {
9
9
  if (doc.bodySections.length < 2 || doc.questionCount > 0) return [];
10
10
  const first = doc.sentences.length ? doc.sentences[0] : null;
11
11
  const why = doc.readerCallCount === 0
12
- ? "물음표가 한 번도 없고 독자를 부르는 말도 없다. 질문이나 독자 호출이 필요한 글인지 판단하고 충분하면 유지한다"
13
- : "물음표가 한 번도 없다. 질문이 필요한 글인지 판단하고 설명만으로 충분하면 유지한다";
12
+ ? "물음표가 한 번도 없고 독자에게 말을 거는 문장도 없다. 질문이나 독자에게 말을 거는 문장이 필요한 글인지 판단하고, 설명만으로 충분하면 그대로 둔다"
13
+ : "물음표가 한 번도 없다. 질문이 필요한 글인지 판단하고, 설명만으로 충분하면 그대로 둔다";
14
14
  return [finding(name, first ? first.line : 1, first ? first.text : "", why, null, config.enforceStyle.includes(name) ? "error" : "notice", DOCUMENT, -1)];
15
15
  }
@@ -9,6 +9,6 @@ export function run(doc) {
9
9
  const reader = doc.reader.final;
10
10
  if (!reader.promises.length || reader.recalls.length) return [];
11
11
  return reader.promises.map(([line, text]) =>
12
- finding(name, line, text, "뒤로 미룬 것을 앞에서 미룬 것으로 다시 부르는 자리가 글 어디에도 없다. 회수하거나 미루지 않는다", null, NOTICE, DOCUMENT, -1),
12
+ finding(name, line, text, "뒤로 미룬 이야기를 `앞에서 미룬 X`처럼 다시 언급하는 문장이 글 어디에도 없다. 뒤에서 다루거나 처음부터 미루지 않는다", null, NOTICE, DOCUMENT, -1),
13
13
  );
14
14
  }
@@ -53,7 +53,7 @@ export function run(doc, config) {
53
53
  const unit = [...kinds][0];
54
54
  const [offset, cell] = odd[0];
55
55
  findings.push(
56
- finding(name, block.startLine + offset, cell, `이 열의 나머지 ${values.length - 1}칸은 \`${unit}\` 으로 잰 값 하나인데 이 칸만 다르다. 같은 잣대로 다시 재거나 그 줄을 본문 문장으로 옮긴다`, null, NOTICE, DOCUMENT, block.index),
56
+ finding(name, block.startLine + offset, cell, `이 열의 다른 ${values.length - 1}칸은 모두 \`${unit}\` 단위인데 이 칸만 다르다. 같은 단위로 다시 재거나 그 줄을 본문 문장으로 옮긴다`, null, NOTICE, DOCUMENT, block.index),
57
57
  );
58
58
  }
59
59
  }