hanlint 0.0.17 → 0.0.18

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 (54) hide show
  1. package/README.md +12 -10
  2. package/data/calqueFrame.json +60 -0
  3. package/data/cliches.json +10 -0
  4. package/data/collocationStops.txt +28 -0
  5. package/data/easyWords.json +0 -234
  6. package/data/exemplars.json +23 -2
  7. package/data/koreanProse.txt +64 -0
  8. package/data/patterns.json +2 -2
  9. package/data/phraseRepeat.json +11 -0
  10. package/data/ruleCategories.json +2 -0
  11. package/data/ruleDocs.json +18 -16
  12. package/data/ruleMechanisms.json +2 -0
  13. package/data/screenNarration.json +7 -7
  14. package/data/screenSentence.json +56 -8
  15. package/data/screenTone.json +4 -4
  16. package/data/screenWord.json +48 -88
  17. package/data/spacing.json +1 -1
  18. package/data/usageCounts.report.json +1676 -0
  19. package/data/version.json +1 -1
  20. package/package.json +1 -1
  21. package/src/analysis/index.js +1 -1
  22. package/src/analysis/tokenize.js +30 -15
  23. package/src/cli/help.js +3 -2
  24. package/src/cli/main.js +227 -88
  25. package/src/cli/walk.js +1 -1
  26. package/src/cli/welcome.js +1 -1
  27. package/src/config/settings.js +17 -0
  28. package/src/data/profiles.js +2 -2
  29. package/src/data/prose.js +27 -0
  30. package/src/document/sourceText.js +104 -6
  31. package/src/fingerprint/build.js +13 -4
  32. package/src/fingerprint/dictionaries.js +31 -4
  33. package/src/fingerprint/markers.js +22 -0
  34. package/src/index.js +3 -1
  35. package/src/report/compactReport.js +7 -3
  36. package/src/report/jsonReport.js +2 -2
  37. package/src/report/sheet.js +0 -0
  38. package/src/report/textReport.js +27 -8
  39. package/src/report/writingSpec.js +3 -3
  40. package/src/rules/registry.js +5 -1
  41. package/src/rules/screen/screenWord.js +2 -1
  42. package/src/rules/sentence/calqueFrame.js +11 -0
  43. package/src/rules/sentence/cliche.js +7 -3
  44. package/src/rules/sentence/hardWord.js +6 -3
  45. package/src/rules/sentence/japaneseLoan.js +7 -3
  46. package/src/rules/sentence/nounPile.js +10 -4
  47. package/src/rules/sentence/phraseRepeat.js +53 -0
  48. package/src/rules/sentence/redundantPair.js +7 -3
  49. package/src/rules/sentence/translationese.js +7 -3
  50. package/src/rules/shared/dictionaryRule.js +37 -6
  51. package/src/text.js +1 -1
  52. package/src/usage/counts.js +54 -0
  53. package/src/usage/index.js +8 -0
  54. package/src/usage/sentences.js +681 -0
@@ -66,6 +66,7 @@ PRESETS.chat = [...CONVERSATION, ...SCREEN];
66
66
  const SCREEN_OFF = [
67
67
  "cliche",
68
68
  "connectorRepeat",
69
+ "phraseRepeat",
69
70
  "danglingDeixis",
70
71
  "deixis",
71
72
  "doubleNegative",
@@ -123,6 +124,11 @@ export const PROFILE_OF = {
123
124
  screen: null,
124
125
  };
125
126
 
127
+ /** 프리셋 → 용례 빈도표의 종류. 정본은 파이썬 config/settings.py 의 USAGE_OF 다. @type {Record<string, string>} */
128
+ export const USAGE_OF = { report: "report" };
129
+ /** 실린 빈도표의 종류. */
130
+ export const USAGE_KINDS = [...new Set(Object.values(USAGE_OF))].sort();
131
+
126
132
  export const PRESET_NAMES = Object.keys(PRESETS);
127
133
  /** 설정도 옵션도 없을 때의 종류. 이 이름일 때는 출력에 프리셋을 적지 않는다. */
128
134
  export const DEFAULT_PRESET = PRESET_NAMES[0];
@@ -152,6 +158,9 @@ export const DEFAULT_PRESET = PRESET_NAMES[0];
152
158
  * @property {number} headingSentenceMaxLevel
153
159
  * @property {number} bridgeRepeatMin
154
160
  * @property {number} nounPileMin
161
+ * @property {string | null} usageKind 용례 빈도표의 종류. null 이면 프리셋이 정하고 "" 면 보지 않는다
162
+ * @property {number} usageMin 명사 연쇄가 몇 편의 문서에 나와야 용례로 보는가
163
+ * @property {number} usageShare 사전 항목이 그 종류의 문서 몇 할에 나오면 관용으로 보는가
155
164
  * @property {number} endingRun
156
165
  * @property {number} factListMinSentences
157
166
  * @property {number} factListMaxMeanLength
@@ -191,6 +200,9 @@ export function defaultConfig() {
191
200
  headingSentenceMaxLevel: 6,
192
201
  bridgeRepeatMin: 3,
193
202
  nounPileMin: 5,
203
+ usageKind: null,
204
+ usageMin: 3,
205
+ usageShare: 0.9,
194
206
  endingRun: 4,
195
207
  factListMinSentences: 3,
196
208
  factListMaxMeanLength: 8.0,
@@ -235,6 +247,11 @@ export function configFromMapping(data) {
235
247
  throw new Error(`enforceStyle 은 ${ENFORCEABLE.join(", ")}의 배열이다`);
236
248
  }
237
249
  config.enforceStyle = [...value];
250
+ } else if (key === "usageKind") {
251
+ if (value !== null && value !== "" && !USAGE_KINDS.includes(/** @type {string} */ (value))) {
252
+ throw new Error(`usageKind 는 ${USAGE_KINDS.join(", ")} 가운데 하나이거나 빈 문자열이다: ${JSON.stringify(value)}`);
253
+ }
254
+ config.usageKind = /** @type {string | null} */ (value);
238
255
  } else if (key === "dictionary") {
239
256
  config.dictionary = { .../** @type {Record<string, unknown[]>} */ (value) };
240
257
  } else if (key === "exemplars") {
@@ -1,7 +1,7 @@
1
1
  // @ts-check
2
2
  /**
3
3
  * 참조 분포 (프로파일) 의 자료형과 읽기. 파이썬 data/profiles.py 와 같다. 종류별 프로파일은 profiles.json (투영) 에서,
4
- * 사용자 프로파일은 `hanlint profile build` 가 만든 파일에서 읽는다. 히스토그램은 정확한 계수라 두 판의 셈이 같다.
4
+ * 사용자 프로파일은 `hanlint profile build` 가 만든 파일에서 읽는다. 히스토그램은 정확한 계수라 두 구현의 셈이 같다.
5
5
  */
6
6
  import { readExternal } from "./read.js";
7
7
 
@@ -52,7 +52,7 @@ function histogramFromDict(data) {
52
52
  return { total: Number(data.total), counts, percentiles };
53
53
  }
54
54
 
55
- /** 값이 이 이상인 것의 몫을 천분율 정수로. 정수 셈이라 두 판의 글자가 같다. @param {Histogram} hist @param {number} value */
55
+ /** 값이 이 이상인 것의 몫을 천분율 정수로. 정수 셈이라 두 구현의 글자가 같다. @param {Histogram} hist @param {number} value */
56
56
  export function shareAtOrAbove(hist, value) {
57
57
  const capped = Math.min(value, CAP);
58
58
  let above = 0;
@@ -0,0 +1,27 @@
1
+ // @ts-check
2
+ /**
3
+ * 쓰기 전에 읽는 작법서. 파이썬 data/prose.py 의 투영이고 정본은 koreanProse.txt 하나다.
4
+ * primer 는 절마다 한 줄로 펴서 첫머리에 싣고, hook --prime 은 본문을 그대로 에이전트 문맥에 넣는다.
5
+ */
6
+ import { readText } from "./load.js";
7
+
8
+ export const PROSE = "koreanProse.txt";
9
+
10
+ /** 작법서 본문. 끝의 줄바꿈 하나까지 파일 그대로다. @returns {string} */
11
+ export function proseGuide() {
12
+ return readText(PROSE);
13
+ }
14
+
15
+ /**
16
+ * (제목, 본문) 짝. 첫 짝은 문서 제목과 첫머리이고 나머지는 일곱 줄이다. 제목의 `#` 는 뗀다.
17
+ * 파이썬 proseSections 와 같다. @returns {[string, string][]}
18
+ */
19
+ export function proseSections() {
20
+ /** @type {[string, string[]][]} */
21
+ const sections = [];
22
+ for (const line of proseGuide().split(/\r?\n/)) {
23
+ if (line.startsWith("#")) sections.push([line.replace(/^#+/, "").trim(), []]);
24
+ else if (sections.length) sections[sections.length - 1][1].push(line);
25
+ }
26
+ return sections.map(([title, body]) => [title, body.join("\n").trim()]);
27
+ }
@@ -4,15 +4,29 @@
4
4
  * 뜻과 규칙은 파이썬 document/sourceText.py 가 소유한다. 여기는 같은 값을 낸다.
5
5
  */
6
6
 
7
- export const SOURCE_SUFFIXES = [".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".rs", ".py"];
7
+ export const SOURCE_SUFFIXES = [".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".rs", ".py", ".html", ".htm", ".vue", ".svelte"];
8
+ /** 태그 사이 글과 속성값을 JSX 와 같은 길로 읽는 파일. HTML 주석 `<!-- -->` 을 걷어낸다. */
9
+ const MARKUP_SUFFIXES = [".html", ".htm", ".vue", ".svelte"];
8
10
 
9
11
  const KOREAN = /[가-힣]/;
10
12
  // 문자열의 경계. 줄을 앞에서부터 훑어 여는 따옴표에서 같은 닫는 따옴표까지를 한 마디로 본다. 파이썬 sourceText.py 와 같다.
11
13
  const QUOTES = ["'", '"', "`"];
12
14
  const BLOCK_COMMENT = /\/\*[\s\S]*?\*\//g;
15
+ const HTML_COMMENT = /<!--[\s\S]*?-->/g;
13
16
  const DEVELOPER_LINE = /new Error\(|console\.|panic!\(|expect\(|assert/;
14
17
  const RUST_CONTINUATION = /\\\r?\n[ \t]*/g;
15
18
  const RUST_TEST_MARKER = "#[cfg(test)]";
19
+ /** 문장을 끊지 않는 HTML 인라인 태그. 같은 줄에서 이 태그만 사이에 둔 글 조각은 한 마디로 잇는다. 파이썬 INLINE_TAGS 와 같다. */
20
+ const INLINE_TAGS = new Set("a abbr b bdi bdo cite code data del dfn em i ins kbd mark q s samp small span strong sub sup time u var wbr br".split(" "));
21
+ const INLINE_TAG = /<\/?([a-zA-Z][a-zA-Z0-9]*)[^<>]*>/g;
22
+ const INLINE_GAP = /^(?:\s*<\/?[a-zA-Z][a-zA-Z0-9]*[^<>]*>)+\s*$/;
23
+ const SIBLING_GAP = /<\/[a-zA-Z][a-zA-Z0-9]*\s*>\s*<[a-zA-Z]/;
24
+ const CODE_OPEN = /<(?:script|style)\b/i;
25
+ const CODE_TAG = /<\/?(?:script|style)\b[^<>]*>/gi;
26
+ const CODE_CLOSE = /<\/(?:script|style)\s*>/i;
27
+ /** 마크업 파일에서 태그도 코드 기호도 없는 줄. 여러 줄 문단의 이어지는 줄이다. */
28
+ const PROSE_LINE = /^(?![\s]*$)[^<>=;{}]*$/;
29
+ const HEAD_TEXT = /^\s*[^<\s][^<]*(?=<\/)/;
16
30
 
17
31
  /**
18
32
  * @typedef {object} SourceLiteral
@@ -79,17 +93,36 @@ export function userFacingSource(source, path) {
79
93
  if (isRust && body.includes(RUST_TEST_MARKER)) body = body.slice(0, body.indexOf(RUST_TEST_MARKER));
80
94
  if (isRust) body = body.replace(RUST_CONTINUATION, "");
81
95
  body = body.replace(BLOCK_COMMENT, (block) => block.replace(/[^\n]/g, " "));
96
+ if (MARKUP_SUFFIXES.some((suffix) => path.endsWith(suffix))) body = body.replace(HTML_COMMENT, (block) => block.replace(/[^\n]/g, " "));
82
97
  return body
83
98
  .split("\n")
84
99
  .map((line) => {
85
100
  const trimmed = line.trim();
86
101
  if (trimmed.startsWith("//") || trimmed.startsWith("*") || trimmed.startsWith("#")) return "";
87
102
  if (DEVELOPER_LINE.test(line)) return "";
88
- return line.replace(/\/\/.*$/, "");
103
+ return withoutLineComment(line);
89
104
  })
90
105
  .join("\n");
91
106
  }
92
107
 
108
+
109
+ /**
110
+ * 따옴표 밖의 `//` 부터 자른다. `https://` 처럼 `:` 뒤의 `//` 는 주소라 두고, 문자열 안의 `//` 도 둔다.
111
+ * 뜻은 파이썬 document/sourceText.py 의 withoutLineComment 가 소유한다.
112
+ */
113
+ export function withoutLineComment(line) {
114
+ let quote = null;
115
+ for (let i = 0; i < line.length; i++) {
116
+ const char = line[i];
117
+ if (quote) {
118
+ if (char === "\\") { i++; continue; }
119
+ if (char === quote) quote = null;
120
+ } else if (char === "'" || char === '"' || char === "`") quote = char;
121
+ else if (char === "/" && line[i + 1] === "/" && (i === 0 || line[i - 1] !== ":")) return line.slice(0, i);
122
+ }
123
+ return line;
124
+ }
125
+
93
126
  /** 검사에 쓰는 글. 식을 비우고 공백을 하나로 모은다. @param {string} text */
94
127
  export function plainText(text) {
95
128
  let plain = "";
@@ -99,6 +132,8 @@ export function plainText(text) {
99
132
  cursor = end;
100
133
  }
101
134
  plain += text.slice(cursor);
135
+ // 인라인 태그를 비운다. 이름이 영문자로 시작하는 것만이라 `<대상> 필요` 의 꺾쇠는 남는다.
136
+ plain = plain.replace(INLINE_TAG, (tag, name) => (INLINE_TAGS.has(name.toLowerCase()) ? "" : tag));
102
137
  return plain.replace(/\s+/g, " ").trim();
103
138
  }
104
139
 
@@ -160,7 +195,12 @@ function innerLiterals(text, offset) {
160
195
  }
161
196
 
162
197
  /** 한 줄의 글 마디를 [시작 자리, 글] 로 나온 차례로. 뜻은 파이썬 lineLiterals 가 소유한다. @param {string} line @param {number} [offset] @returns {[number, string][]} */
163
- export function lineLiterals(line, offset = 0) {
198
+ /**
199
+ * openEnded (마크업 파일) 이면 여는 태그 뒤 줄 끝까지의 글과 줄 처음부터 닫는 태그 앞까지의 글도 마디다.
200
+ * 뜻은 파이썬 lineLiterals 가 소유한다.
201
+ * @param {string} line @param {number} [offset] @param {boolean} [openEnded]
202
+ */
203
+ export function lineLiterals(line, offset = 0, openEnded = false) {
164
204
  /** @type {[number, string][]} */
165
205
  const found = [];
166
206
  let index = 0;
@@ -176,7 +216,8 @@ export function lineLiterals(line, offset = 0) {
176
216
  continue;
177
217
  }
178
218
  if (char === ">" && tagCloses(line, index)) {
179
- const end = jsxTextEnd(line, index + 1);
219
+ let end = jsxTextEnd(line, index + 1);
220
+ if (end < 0 && openEnded && index + 1 < line.length) end = line.length;
180
221
  if (end > index) {
181
222
  const raw = line.slice(index + 1, end);
182
223
  found.push([offset + index + 1, raw]);
@@ -187,16 +228,73 @@ export function lineLiterals(line, offset = 0) {
187
228
  }
188
229
  index += 1;
189
230
  }
231
+ if (openEnded) {
232
+ const head = HEAD_TEXT.exec(line);
233
+ if (head && !found.some(([start]) => start < head[0].length)) found.push([offset, head[0]]);
234
+ else if (!found.length && PROSE_LINE.test(line)) found.push([offset, line]);
235
+ }
190
236
  found.sort((a, b) => a[0] - b[0]);
191
- return found;
237
+ return joinInline(line, found, offset);
238
+ }
239
+
240
+ /**
241
+ * 사이가 인라인 태그뿐인 태그 사이 글 조각을 한 마디로 잇는다. 태그 안의 속성 문자열은 잇기에 끼지 않고 제 마디로 남는다.
242
+ * 뜻은 파이썬 joinInline 이 소유한다.
243
+ * @param {string} line @param {[number, string][]} found @param {number} offset @returns {[number, string][]}
244
+ */
245
+ function joinInline(line, found, offset) {
246
+ const pieces = found.filter(([, raw]) => raw.trim());
247
+ const attributes = pieces.filter(([start]) => insideTag(line, start - offset));
248
+ /** @type {[number, string][]} */
249
+ const joined = [];
250
+ for (const piece of pieces) {
251
+ if (attributes.includes(piece)) continue;
252
+ const [start, raw] = piece;
253
+ if (joined.length) {
254
+ const [previousStart, previousRaw] = joined[joined.length - 1];
255
+ const tail = previousStart - offset + previousRaw.length;
256
+ if (start - offset >= tail && inlineOnly(line.slice(tail, start - offset))) {
257
+ joined[joined.length - 1] = [previousStart, line.slice(previousStart - offset, start - offset + raw.length)];
258
+ continue;
259
+ }
260
+ }
261
+ joined.push([start, raw]);
262
+ }
263
+ return [...joined, ...attributes].sort((a, b) => a[0] - b[0]);
264
+ }
265
+
266
+ /** position 이 태그 안 (`<` 뒤, `>` 앞) 인가. @param {string} line @param {number} position */
267
+ function insideTag(line, position) {
268
+ const opened = line.lastIndexOf("<", position - 1);
269
+ return opened >= 0 && line.lastIndexOf(">", position - 1) < opened;
270
+ }
271
+
272
+ /** 사이가 인라인 태그뿐이고 형제 (`</a><a>`) 가 아니다. @param {string} gap */
273
+ function inlineOnly(gap) {
274
+ if (!INLINE_GAP.test(gap) || SIBLING_GAP.test(gap)) return false;
275
+ return [...gap.matchAll(INLINE_TAG)].every((match) => INLINE_TAGS.has(match[1].toLowerCase()));
192
276
  }
193
277
 
194
278
  /** 소스에서 글 마디를 줄 번호 순으로. @param {string} source @param {string} [path] @returns {SourceLiteral[]} */
195
279
  export function sourceLiterals(source, path = "") {
196
280
  /** @type {SourceLiteral[]} */
197
281
  const found = [];
282
+ const markup = MARKUP_SUFFIXES.some((suffix) => path.endsWith(suffix));
283
+ // 마크업은 주석 지우기가 줄 수를 지키므로 원문 줄과 나란히 간다. script 블록 판정은 원문 줄로 한다 (`// 주석</script>` 가 지워져도).
284
+ const rawLines = markup ? source.split("\n") : [];
285
+ let inCode = false;
198
286
  userFacingSource(source, path).split("\n").forEach((line, index) => {
199
- for (const [start, raw] of lineLiterals(line)) {
287
+ // <script> 와 <style> 안은 코드다. 열린 조각 규칙을 끄고 따옴표 문자열만 잡는다. 태그 자체는 빈칸으로 바꿔 자리를 지킨다.
288
+ const rawLine = markup && index < rawLines.length ? rawLines[index] : line;
289
+ const opensCode = markup && CODE_OPEN.test(rawLine);
290
+ const closesCode = markup && CODE_CLOSE.test(rawLine);
291
+ const openEnded = markup && !inCode && !opensCode;
292
+ if (markup) {
293
+ if (opensCode && !closesCode) inCode = true;
294
+ else if (inCode && closesCode) inCode = false;
295
+ }
296
+ const scanned = opensCode || closesCode ? line.replace(CODE_TAG, (tag) => " ".repeat(tag.length)) : line;
297
+ for (const [start, raw] of lineLiterals(scanned, 0, openEnded)) {
200
298
  const text = raw.trim();
201
299
  const plain = plainText(text);
202
300
  if (text && KOREAN.test(plain)) found.push({ line: index + 1, text, plain, column: start + (raw.length - raw.trimStart().length) + 1 });
@@ -130,11 +130,15 @@ function quotedIn(sentence, blockSpans) {
130
130
  function makeSentencePrint(build, text, line, block, sectionIndex, quoted) {
131
131
  const { entries } = build;
132
132
  const ending = markers.endingOf(text);
133
+ const protectedSpans = markers.termSpans(text, build.protectedTerms);
134
+ // 인용 안의 것과 보호한 용어에 걸친 것은 뺀다. 뜻은 파이썬 fingerprint/build.py 의 kept 가 정한다.
135
+ /** @param {number} start @param {number} end */
136
+ const kept = (start, end) => !markers.insideAny(start, end, quoted) && !markers.overlapsAny(start, end, protectedSpans);
133
137
  const deixis = markers
134
138
  .matchedSpans(text, "deixis.txt")
135
- .filter(([start, end]) => !markers.insideAny(start, end, quoted))
139
+ .filter(([start, end]) => kept(start, end))
136
140
  .map(([, , found]) => found);
137
- const matches = matchesIn(text, entries).filter((m) => !markers.insideAny(m.start, m.end, quoted));
141
+ const matches = matchesIn(text, entries).filter((m) => kept(m.start, m.end));
138
142
  const mood = markers.moodOf(text, ending);
139
143
  return {
140
144
  index: build.sentences.length,
@@ -154,7 +158,7 @@ function makeSentencePrint(build, text, line, block, sectionIndex, quoted) {
154
158
  euiCount: euiCount(text),
155
159
  nounRun: longestNounRun(text),
156
160
  passives: doublePassiveSpans(text)
157
- .filter(([start, end]) => !markers.insideAny(start, end, quoted))
161
+ .filter(([start, end]) => kept(start, end))
158
162
  .map(([, , surface]) => surface),
159
163
  hedges: markers.countMatches(text, "hedges.txt"),
160
164
  numbers: markers.countNumbers(text),
@@ -267,7 +271,12 @@ function buildSection(build, section, sectionIndex) {
267
271
  export function buildFingerprint(doc, config = defaultConfig()) {
268
272
  // 코드도 산문도 아닌 펜스는 지문에 들어오기 전에 뺀다. 모든 진입점이 여기를 지나므로 한 자리면 된다.
269
273
  doc = dropFences(doc, config.ignoreFences);
270
- const build = { entries: entriesFor(config), sentences: /** @type {SentencePrint[]} */ ([]), paragraphs: /** @type {ParagraphPrint[]} */ ([]) };
274
+ const build = {
275
+ entries: entriesFor(config),
276
+ protectedTerms: config.protectedTerms ?? [],
277
+ sentences: /** @type {SentencePrint[]} */ ([]),
278
+ paragraphs: /** @type {ParagraphPrint[]} */ ([]),
279
+ };
271
280
  const { sentences, paragraphs } = build;
272
281
  const sections = doc.sections.map((section, index) =>
273
282
  buildSection(build, section, index),
@@ -1,5 +1,5 @@
1
1
  // @ts-check
2
- /** 사전 넷을 한 번 컴파일하고 문장에서 맞는 자리를 찾는다. 설정의 dictionary 항목을 더한다. */
2
+ /** 사전들을 한 번 컴파일하고 문장에서 맞는 자리를 찾는다. 설정의 dictionary 항목을 더한다. */
3
3
  import { loadEntries } from "../data/load.js";
4
4
  import { compile } from "../regex.js";
5
5
 
@@ -16,6 +16,7 @@ export const DICTIONARY_FILES = {
16
16
  screenNarration: "screenNarration.toml",
17
17
  screenTone: "screenTone.toml",
18
18
  screenWord: "screenWord.toml",
19
+ calqueFrame: "calqueFrame.toml",
19
20
  };
20
21
  const GROUP_REF = /\$(\d)/g;
21
22
  const FINALS = { ㄴ: 4, ㄹ: 8 };
@@ -44,6 +45,9 @@ export function expandClasses(pattern) {
44
45
  * @property {string} why
45
46
  * @property {string} source
46
47
  * @property {string | null} fix
48
+ * @property {{ pattern: import("../regex.js").Pattern, into: string }[]} to 문장 전체를 다시 쓰는 규칙. 앞의 것부터 시도한다
49
+ * @property {string} raw pattern 의 원문. 내장 항목만 갖고 설정 항목은 빈 글자라 관용으로 접히지 않는다
50
+ * @property {import("../regex.js").Pattern | null} unless 문장 전체에서 이것이 맞으면 그 문장에서는 항목이 맞지 않는다. 파이썬 Entry.unless 와 같다
47
51
  */
48
52
 
49
53
  /**
@@ -55,10 +59,12 @@ export function expandClasses(pattern) {
55
59
  * @property {string} why
56
60
  * @property {string} source
57
61
  * @property {string | null} fix
62
+ * @property {string | null} rewrite 항목의 to 규칙으로 문장 전체를 다시 쓴 제안. 없거나 안 맞으면 null
63
+ * @property {string} pattern 맞은 항목의 pattern 원문. 용례 빈도표의 키다
58
64
  */
59
65
 
60
- /** @param {string} dictionary @param {Record<string, unknown> | string} raw @returns {Entry} */
61
- export function entryFrom(dictionary, raw) {
66
+ /** @param {string} dictionary @param {Record<string, unknown> | string} raw @param {boolean} [builtin] @returns {Entry} */
67
+ export function entryFrom(dictionary, raw, builtin = false) {
62
68
  const data = typeof raw === "string" ? { pattern: raw } : raw;
63
69
  return {
64
70
  dictionary,
@@ -66,6 +72,9 @@ export function entryFrom(dictionary, raw) {
66
72
  why: /** @type {string} */ (data.why ?? "설정에서 더한 항목"),
67
73
  source: /** @type {string} */ (data.source ?? "설정"),
68
74
  fix: /** @type {string | null} */ (data.fix ?? null),
75
+ to: (/** @type {[string, string][]} */ (data.to ?? [])).map(([source, into]) => ({ pattern: compile(expandClasses(source)), into })),
76
+ raw: builtin ? /** @type {string} */ (data.pattern) : "",
77
+ unless: data.unless ? compile(expandClasses(/** @type {string} */ (data.unless))) : null,
69
78
  };
70
79
  }
71
80
 
@@ -75,7 +84,7 @@ export function builtinEntries() {
75
84
  if (!builtinCache) {
76
85
  builtinCache = [];
77
86
  for (const [dictionary, name] of Object.entries(DICTIONARY_FILES)) {
78
- for (const raw of loadEntries(name)) builtinCache.push(entryFrom(dictionary, raw));
87
+ for (const raw of loadEntries(name)) builtinCache.push(entryFrom(dictionary, raw, true));
79
88
  }
80
89
  }
81
90
  return builtinCache;
@@ -99,11 +108,27 @@ export function applyFix(match, fix) {
99
108
  return fix.replace(GROUP_REF, (_, n) => match[Number(n)] ?? "");
100
109
  }
101
110
 
111
+ /**
112
+ * to 규칙으로 문장을 다시 쓴다. 빈 그룹이 남긴 겹 공백은 하나로 줄이고 양끝을 다듬는다. 원문과 같으면 null.
113
+ * 뜻은 파이썬 fingerprint/dictionaries.py 의 rewriteBy 가 소유한다.
114
+ * @param {string} text @param {Entry["to"]} rules
115
+ */
116
+ export function rewriteBy(text, rules) {
117
+ for (const { pattern, into } of rules) {
118
+ const match = pattern.search(text);
119
+ if (!match) continue;
120
+ const rewritten = applyFix(match, into).split(/\s+/).filter(Boolean).join(" ");
121
+ return rewritten && rewritten !== text ? rewritten : null;
122
+ }
123
+ return null;
124
+ }
125
+
102
126
  /** @param {string} text @param {Entry[]} entries @returns {DictionaryMatch[]} */
103
127
  export function matchesIn(text, entries) {
104
128
  /** @type {DictionaryMatch[]} */
105
129
  const found = [];
106
130
  for (const entry of entries) {
131
+ if (entry.unless && entry.unless.search(text)) continue;
107
132
  for (const match of entry.pattern.all(text)) {
108
133
  const start = /** @type {number} */ (match.index);
109
134
  found.push({
@@ -114,6 +139,8 @@ export function matchesIn(text, entries) {
114
139
  why: entry.why,
115
140
  source: entry.source,
116
141
  fix: entry.fix ? applyFix(match, entry.fix) : null,
142
+ rewrite: entry.to.length ? rewriteBy(text, entry.to) : null,
143
+ pattern: entry.raw,
117
144
  });
118
145
  }
119
146
  }
@@ -147,6 +147,28 @@ export function quoteSpans(text) {
147
147
  return found.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
148
148
  }
149
149
 
150
+ /**
151
+ * 설정의 protectedTerms 가 글에 나온 [시작, 끝] 전부. 겹쳐 나온 자리도 따로 센다. 파이썬 termSpans 와 같다.
152
+ * @param {string} text @param {string[]} terms @returns {[number, number][]}
153
+ */
154
+ export function termSpans(text, terms) {
155
+ /** @type {[number, number][]} */
156
+ const found = [];
157
+ for (const term of terms) {
158
+ let start = text.indexOf(term);
159
+ while (start !== -1) {
160
+ found.push([start, start + term.length]);
161
+ start = text.indexOf(term, start + 1);
162
+ }
163
+ }
164
+ return found.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
165
+ }
166
+
167
+ /** 두 구간이 한 글자라도 겹치는가. 파이썬 overlapsAny 와 같다. @param {number} start @param {number} end @param {[number, number][]} spans */
168
+ export function overlapsAny(start, end, spans) {
169
+ return spans.some(([spanStart, spanEnd]) => spanStart < end && start < spanEnd);
170
+ }
171
+
150
172
  /** @param {number} start @param {number} end @param {[number, number][]} spans */
151
173
  export function insideAny(start, end, spans) {
152
174
  return spans.some(([spanStart, spanEnd]) => spanStart <= start && end <= spanEnd);
package/src/index.js CHANGED
@@ -26,7 +26,7 @@ import { configFromMapping, defaultConfig } from "./config/settings.js";
26
26
  import { loadVersion } from "./data/load.js";
27
27
  import { parseMarkdown } from "./document/parseMarkdown.js";
28
28
  import { SOURCE_SUFFIXES, replaceLiteral, sourceLiterals } from "./document/sourceText.js";
29
- import { parseSheet, renderSheet, renderSheetJson } from "./report/sheet.js";
29
+ import { applyRows, parseSheet, renderSheet, renderSheetJson, sheetRows } from "./report/sheet.js";
30
30
  import { headingsOf } from "./document/model.js";
31
31
  import { applyFixes } from "./edit/applyFixes.js";
32
32
  import { buildFingerprint } from "./fingerprint/build.js";
@@ -54,6 +54,8 @@ export {
54
54
  parseSheet,
55
55
  renderSheet,
56
56
  renderSheetJson,
57
+ sheetRows,
58
+ applyRows,
57
59
  replaceLiteral,
58
60
  sourceLiterals,
59
61
  CONTRACT_VERSION,
@@ -1,7 +1,7 @@
1
1
  // @ts-check
2
- /** 한 줄에 지적 하나. AI 와 스크립트가 읽는 꼴이다. 파이썬 report/compactReport.py 와 같은 글자다. */
2
+ /** 한 줄에 지적 하나. AI 와 스크립트가 읽는 형식이다. 파이썬 report/compactReport.py 와 같은 글자다. */
3
3
 
4
- /** 줄바꿈과 이어진 공백을 하나로 눕힌다. 한 줄은 이 꼴의 계약이다. @param {string} text */
4
+ /** 줄바꿈과 이어진 공백을 하나로 눕힌다. 한 줄은 이 형식의 계약이다. @param {string} text */
5
5
  function flat(text) {
6
6
  return text.split(/\s+/).filter(Boolean).join(" ");
7
7
  }
@@ -9,6 +9,10 @@ function flat(text) {
9
9
  /** @param {string} path @param {import("../rules/finding.js").Finding[]} findings */
10
10
  export function renderCompact(path, findings) {
11
11
  return findings
12
- .map((f) => `${path}:${f.line} [${f.rule}] ${f.severity === "notice" ? "notice: " : ""}${flat(f.why)}` + (f.fix ? ` 고친 뒤: ${flat(f.fix)}` : ""))
12
+ .map((f) => {
13
+ const label = f.severity === "notice" ? "notice: " : "";
14
+ const fixLabel = f.severity === "notice" ? "검토할 대안" : "고친 뒤";
15
+ return `${path}:${f.line} [${f.rule}] ${label}${flat(f.why)}` + (f.fix ? ` ${fixLabel}: ${flat(f.fix)}` : "");
16
+ })
13
17
  .join("\n");
14
18
  }
@@ -1,8 +1,8 @@
1
1
  // @ts-check
2
2
  /**
3
- * 기계가 읽는 꼴. 평가 루프의 0층 입력이다. 파이썬 json.dumps(indent=2, ensure_ascii=False) 와 같은 글자다.
3
+ * 기계가 읽는 형식. 평가 루프의 0층 입력이다. 파이썬 json.dumps(indent=2, ensure_ascii=False) 와 같은 글자다.
4
4
  * 지적마다 `exemplar` 를 붙인다. 승인 원문 완전 일치 `patch`는 맞는 지적에만 별도로 붙인다.
5
- * 실제 수정 차이는 파이썬의 exemplarLift와 patchMemory 탐침에서 따로 잰다.
5
+ * 실제 수정 차이는 파이썬의 exemplarLift와 patchMemory 실험 스크립트에서 따로 잰다.
6
6
  */
7
7
  import { exemplarFor } from "../data/exemplars.js";
8
8
  import { findingAsDict } from "../rules/finding.js";
Binary file
@@ -35,21 +35,40 @@ function candidateLines(findings) {
35
35
  return lines;
36
36
  }
37
37
 
38
- /** @param {string} path @param {import("../rules/finding.js").Finding[]} findings @param {string | null | undefined} [register] @param {string | null | undefined} [preset] @param {import("../data/exemplars.js").Exemplar[]} [customExemplars] */
39
- export function renderText(path, findings, register = null, preset = null, customExemplars = []) {
40
- if (!findings.length) return `${path} 집은 자리 없음`;
38
+ /** 접은 확인 권고. 규칙마다 개수와 줄 번호 한 줄. 뜻은 파이썬 textReport.py 의 noticeLines 가 소유한다. @param {import("../rules/finding.js").Finding[]} notices */
39
+ function noticeLines(notices) {
40
+ /** @type {Map<string, number[]>} */
41
+ const byRule = new Map();
42
+ for (const finding of notices) {
43
+ if (!byRule.has(finding.rule)) byRule.set(finding.rule, []);
44
+ /** @type {number[]} */ (byRule.get(finding.rule)).push(finding.line);
45
+ }
46
+ const lines = [`확인 권고 ${notices.length}건 (접음. 다 보려면 --notices)`];
47
+ for (const rule of [...byRule.keys()].sort()) {
48
+ const numbers = [...new Set(/** @type {number[]} */ (byRule.get(rule)))].sort((a, b) => a - b);
49
+ lines.push(` ${rule} ${/** @type {number[]} */ (byRule.get(rule)).length} ${numbers.join(", ")}줄`);
50
+ }
51
+ return lines;
52
+ }
53
+
54
+ /** @param {string} path @param {import("../rules/finding.js").Finding[]} findings @param {string | null | undefined} [register] @param {string | null | undefined} [preset] @param {import("../data/exemplars.js").Exemplar[]} [customExemplars] @param {boolean} [unfoldNotices] */
55
+ export function renderText(path, findings, register = null, preset = null, customExemplars = [], unfoldNotices = false) {
56
+ if (!findings.length) return `${path} 오류 없음`;
41
57
  const errors = findings.filter((f) => f.severity === "error").length;
42
58
  const notices = findings.length - errors;
43
- const summary = `${path} 집은 자리 ${errors}` + (notices ? `, 확인할 자리 ${notices}` : "");
59
+ const summary = `${path} 오류 ${errors}건` + (notices ? `, 확인 권고 ${notices}건` : "");
44
60
  const lines = [summary, ""];
45
- for (const f of findings) {
61
+ const folded = unfoldNotices ? [] : findings.filter((f) => f.severity === "notice");
62
+ const shown = findings.filter((f) => unfoldNotices || f.severity === "error");
63
+ for (const f of shown) {
46
64
  const tag = `[${f.rule}]` + (f.severity === "notice" ? " 확인" : "");
47
65
  lines.push(`${path}:${f.line} ${tag}`, ` ${f.quote}`, ` ${f.why}`);
48
- if (f.fix) lines.push(` 고친 뒤: ${f.fix}`);
66
+ if (f.fix) lines.push(` ${f.severity === "notice" ? "검토할 대안" : "고친 뒤"}: ${f.fix}`);
49
67
  lines.push("");
50
68
  }
51
- lines.push(...exemplarLines(findings, register, preset, customExemplars));
52
- const candidates = candidateLines(findings);
69
+ if (folded.length) lines.push(...noticeLines(folded), "");
70
+ lines.push(...exemplarLines(shown, register, preset, customExemplars));
71
+ const candidates = candidateLines(shown);
53
72
  if (candidates.length) lines.push("", ...candidates);
54
73
  return lines.join("\n").replace(/\n+$/, "");
55
74
  }
@@ -34,7 +34,7 @@ function selectedProfile(config) {
34
34
  }
35
35
 
36
36
  /**
37
- * 현재 규칙판을 쓰기 전 숫자 사양으로 편다. 품질 점수나 새 임계는 만들지 않는다.
37
+ * 현재 규칙 세트를 쓰기 전 숫자 사양으로 편다. 품질 점수나 새 임계는 만들지 않는다.
38
38
  * @param {import("./settings.js").Config} [config]
39
39
  * @param {string} [register]
40
40
  * @param {number | null} [targetChars]
@@ -161,11 +161,11 @@ export function writingSpec(config = defaultConfig(), register = HAPNIDA, target
161
161
  };
162
162
  }
163
163
 
164
- /** 사람이 읽는 꼴. basis는 JSON에서 확인한다. @param {ReturnType<typeof writingSpec>} spec */
164
+ /** 사람이 읽는 형식. basis는 JSON에서 확인한다. @param {ReturnType<typeof writingSpec>} spec */
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}`);
@@ -24,9 +24,11 @@ import * as spacing from "./orthography/spacing.js";
24
24
  import * as spelling from "./orthography/spelling.js";
25
25
  import * as factListParagraph from "./paragraph/factListParagraph.js";
26
26
  import * as paraFragment from "./paragraph/paraFragment.js";
27
+ import * as calqueFrame from "./sentence/calqueFrame.js";
27
28
  import * as cliche from "./sentence/cliche.js";
28
29
  import * as hardWord from "./sentence/hardWord.js";
29
30
  import * as connectorRepeat from "./sentence/connectorRepeat.js";
31
+ import * as phraseRepeat from "./sentence/phraseRepeat.js";
30
32
  import * as danglingDeixis from "./sentence/danglingDeixis.js";
31
33
  import * as draftHistory from "./sentence/draftHistory.js";
32
34
  import * as numberOrphan from "./sentence/numberOrphan.js";
@@ -70,8 +72,10 @@ import * as screenWord from "./screen/screenWord.js";
70
72
 
71
73
  /** @type {Rule[]} */
72
74
  export const RULES = [
75
+ calqueFrame,
73
76
  cliche,
74
77
  connectorRepeat,
78
+ phraseRepeat,
75
79
  danglingDeixis,
76
80
  dash,
77
81
  draftHistory,
@@ -133,7 +137,7 @@ export const RULES = [
133
137
  export const MECHANISMS = {
134
138
  dictionary: "사전. 낱말 목록과 정규식이 맞는 자리",
135
139
  repeat: "반복. 같은 모양이 창 안에서 N 번",
136
- threshold: "셈. 지문의 값이 임계를 넘거나 모양이 계약과 다른 자리",
140
+ threshold: "셈. 분석표의 값이 임계를 넘거나 모양이 계약과 다른 자리",
137
141
  contrast: "대조. 두 자리를 맞대 어긋난 곳",
138
142
  reader: "독자 상태. 문장 순서대로 독자가 손에 든 것과 본 것에 그 자리가 요구하는 것을 맞댄다",
139
143
  };
@@ -1,10 +1,11 @@
1
1
  // @ts-check
2
2
  import { overridingFindings } from "../shared/dictionaryRule.js";
3
+ import { NOTICE } from "../finding.js";
3
4
 
4
5
  export const name = "screenWord";
5
6
  export const mechanism = "dictionary";
6
7
 
7
8
  /** @param {import("../../fingerprint/build.js").DocumentPrint} doc */
8
9
  export function run(doc) {
9
- return overridingFindings(doc, "screenWord", name);
10
+ return overridingFindings(doc, "screenWord", name, undefined, NOTICE);
10
11
  }
@@ -0,0 +1,11 @@
1
+ // @ts-check
2
+ import { overridingFindings } from "../shared/dictionaryRule.js";
3
+ import { NOTICE } from "../finding.js";
4
+
5
+ export const name = "calqueFrame";
6
+ export const mechanism = "dictionary";
7
+
8
+ /** @param {import("../../fingerprint/build.js").DocumentPrint} doc */
9
+ export function run(doc) {
10
+ return overridingFindings(doc, "calqueFrame", name, undefined, NOTICE);
11
+ }
@@ -1,10 +1,14 @@
1
1
  // @ts-check
2
+ import { ERROR } from "../finding.js";
2
3
  import { dictionaryFindings } from "../shared/dictionaryRule.js";
3
4
 
4
5
  export const name = "cliche";
5
6
  export const mechanism = "dictionary";
6
7
 
7
- /** @param {import("../../fingerprint/build.js").DocumentPrint} doc */
8
- export function run(doc) {
9
- return dictionaryFindings(doc, "cliches", name);
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
+ return dictionaryFindings(doc, "cliches", name, ERROR, config);
10
14
  }