hanlint 0.0.16 → 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 +199 -36
  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,16 +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
- const EXPRESSION = /\{[^{}]*\}/g;
13
14
  const BLOCK_COMMENT = /\/\*[\s\S]*?\*\//g;
15
+ const HTML_COMMENT = /<!--[\s\S]*?-->/g;
14
16
  const DEVELOPER_LINE = /new Error\(|console\.|panic!\(|expect\(|assert/;
15
17
  const RUST_CONTINUATION = /\\\r?\n[ \t]*/g;
16
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][^<]*(?=<\/)/;
17
30
 
18
31
  /**
19
32
  * @typedef {object} SourceLiteral
@@ -23,28 +36,53 @@ const RUST_TEST_MARKER = "#[cfg(test)]";
23
36
  * @property {number} column 1부터 세는 칸. 그 줄에서 text 가 시작하는 자리
24
37
  */
25
38
 
26
- /** 템플릿 리터럴의 `${ ... }` 식을 (중첩 괄호를 세어) 같은 길이의 빈칸으로 바꾼다. @param {string} source */
27
- export function withoutTemplateExpressions(source) {
28
- let output = "";
39
+ /** `start` 의 `{` 를 닫는 `}` 의 자리. 뜻은 파이썬 balancedEnd 가 소유한다. @param {string} line @param {number} start */
40
+ function balancedEnd(line, start) {
41
+ let depth = 0;
42
+ let index = start;
43
+ while (index < line.length) {
44
+ const char = line[index];
45
+ if (QUOTES.includes(char)) {
46
+ const end = closingQuote(line, index);
47
+ if (end < 0) return -1;
48
+ index = end + 1;
49
+ continue;
50
+ }
51
+ if (char === "{") depth += 1;
52
+ else if (char === "}") {
53
+ depth -= 1;
54
+ if (depth === 0) return index;
55
+ }
56
+ index += 1;
57
+ }
58
+ return -1;
59
+ }
60
+
61
+ /** 글에 낀 식 `{...}` 와 `${...}` 의 [시작, 끝 다음]. 뜻은 파이썬 expressionSpans 가 소유한다. @param {string} text @returns {[number, number][]} */
62
+ export function expressionSpans(text) {
63
+ /** @type {[number, number][]} */
64
+ const spans = [];
29
65
  let index = 0;
30
- while (index < source.length) {
31
- if (source[index] === "$" && source[index + 1] === "{") {
32
- let depth = 0;
33
- let cursor = index + 1;
34
- for (; cursor < source.length; cursor++) {
35
- if (source[cursor] === "{") depth += 1;
36
- else if (source[cursor] === "}") {
37
- depth -= 1;
38
- if (depth === 0) break;
39
- }
40
- }
41
- output += " ".repeat(cursor + 1 - index);
42
- index = cursor + 1;
66
+ while (index < text.length) {
67
+ if (text[index] === "{") {
68
+ const end = balancedEnd(text, index);
69
+ if (end < 0) break;
70
+ const start = index > 0 && text[index - 1] === "$" ? index - 1 : index;
71
+ spans.push([start, end + 1]);
72
+ index = end + 1;
43
73
  continue;
44
74
  }
45
- output += source[index];
46
75
  index += 1;
47
76
  }
77
+ return spans;
78
+ }
79
+
80
+ /** 템플릿 리터럴의 `${ ... }` 식을 같은 길이의 빈칸으로 바꾼다. 뜻은 파이썬 withoutTemplateExpressions 가 소유한다. @param {string} source */
81
+ export function withoutTemplateExpressions(source) {
82
+ let output = source;
83
+ for (const [start, end] of expressionSpans(source)) {
84
+ if (source[start] === "$") output = output.slice(0, start) + " ".repeat(end - start) + output.slice(end);
85
+ }
48
86
  return output;
49
87
  }
50
88
 
@@ -55,25 +93,51 @@ export function userFacingSource(source, path) {
55
93
  if (isRust && body.includes(RUST_TEST_MARKER)) body = body.slice(0, body.indexOf(RUST_TEST_MARKER));
56
94
  if (isRust) body = body.replace(RUST_CONTINUATION, "");
57
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, " "));
58
97
  return body
59
98
  .split("\n")
60
99
  .map((line) => {
61
100
  const trimmed = line.trim();
62
101
  if (trimmed.startsWith("//") || trimmed.startsWith("*") || trimmed.startsWith("#")) return "";
63
102
  if (DEVELOPER_LINE.test(line)) return "";
64
- return line.replace(/\/\/.*$/, "");
103
+ return withoutLineComment(line);
65
104
  })
66
105
  .join("\n");
67
106
  }
68
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
+
69
126
  /** 검사에 쓰는 글. 식을 비우고 공백을 하나로 모은다. @param {string} text */
70
127
  export function plainText(text) {
71
- let copy = withoutTemplateExpressions(text);
72
- while (EXPRESSION.test(copy)) copy = copy.replace(EXPRESSION, " ");
73
- return copy.replace(/\s+/g, " ").trim();
128
+ let plain = "";
129
+ let cursor = 0;
130
+ for (const [start, end] of expressionSpans(text)) {
131
+ plain += text.slice(cursor, start) + " ";
132
+ cursor = end;
133
+ }
134
+ plain += text.slice(cursor);
135
+ // 인라인 태그를 비운다. 이름이 영문자로 시작하는 것만이라 `<대상> 필요` 의 꺾쇠는 남는다.
136
+ plain = plain.replace(INLINE_TAG, (tag, name) => (INLINE_TAGS.has(name.toLowerCase()) ? "" : tag));
137
+ return plain.replace(/\s+/g, " ").trim();
74
138
  }
75
139
 
76
- /** `start` 의 따옴표를 닫는 자리. 역슬래시 뒤 글자는 건너뛴다. 없으면 -1. @param {string} line @param {number} start */
140
+ /** `start` 의 따옴표를 닫는 자리. 백틱 안의 `${...}` 은 통째로 건너뛴다. 뜻은 파이썬 closingQuote 가 소유한다. @param {string} line @param {number} start */
77
141
  function closingQuote(line, start) {
78
142
  const quote = line[start];
79
143
  let index = start + 1;
@@ -83,6 +147,12 @@ function closingQuote(line, start) {
83
147
  index += 2;
84
148
  continue;
85
149
  }
150
+ if (quote === "`" && char === "$" && line[index + 1] === "{") {
151
+ const end = balancedEnd(line, index + 1);
152
+ if (end < 0) return -1;
153
+ index = end + 1;
154
+ continue;
155
+ }
86
156
  if (char === quote) return index;
87
157
  index += 1;
88
158
  }
@@ -96,8 +166,41 @@ function tagCloses(line, index) {
96
166
  return "\"'}".includes(before) || /[A-Za-z0-9]/.test(before);
97
167
  }
98
168
 
99
- /** 한 줄의 글 마디를 [시작 자리, 글] 로 나온 차례로. 뜻은 파이썬 lineLiterals 가 소유한다. @param {string} line @returns {[number, string][]} */
100
- export function lineLiterals(line) {
169
+ /** `start` 부터의 JSX 글이 끝나는 `<` 의 자리. 식 안의 `<` 는 글의 끝이 아니다. 뜻은 파이썬 jsxTextEnd 가 소유한다. @param {string} line @param {number} start */
170
+ function jsxTextEnd(line, start) {
171
+ let index = start;
172
+ while (index < line.length) {
173
+ const char = line[index];
174
+ if (char === "<") return index;
175
+ if (char === "{") {
176
+ const end = balancedEnd(line, index);
177
+ if (end < 0) return -1;
178
+ index = end + 1;
179
+ continue;
180
+ }
181
+ index += 1;
182
+ }
183
+ return -1;
184
+ }
185
+
186
+ /** 글에 낀 식 안의 글 마디. 뜻은 파이썬 innerLiterals 가 소유한다. @param {string} text @param {number} offset @returns {[number, string][]} */
187
+ function innerLiterals(text, offset) {
188
+ /** @type {[number, string][]} */
189
+ const found = [];
190
+ for (const [start, end] of expressionSpans(text)) {
191
+ const brace = text[start] === "$" ? start + 1 : start;
192
+ found.push(...lineLiterals(text.slice(brace + 1, end - 1), offset + brace + 1));
193
+ }
194
+ return found;
195
+ }
196
+
197
+ /** 한 줄의 글 마디를 [시작 자리, 글] 로 나온 차례로. 뜻은 파이썬 lineLiterals 가 소유한다. @param {string} line @param {number} [offset] @returns {[number, string][]} */
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) {
101
204
  /** @type {[number, string][]} */
102
205
  const found = [];
103
206
  let index = 0;
@@ -106,32 +209,92 @@ export function lineLiterals(line) {
106
209
  if (QUOTES.includes(char)) {
107
210
  const end = closingQuote(line, index);
108
211
  if (end < 0) break;
109
- found.push([index + 1, line.slice(index + 1, end)]);
212
+ const raw = line.slice(index + 1, end);
213
+ found.push([offset + index + 1, raw]);
214
+ if (char === "`") found.push(...innerLiterals(raw, offset + index + 1));
110
215
  index = end + 1;
111
216
  continue;
112
217
  }
113
218
  if (char === ">" && tagCloses(line, index)) {
114
- const end = line.indexOf("<", index + 1);
219
+ let end = jsxTextEnd(line, index + 1);
220
+ if (end < 0 && openEnded && index + 1 < line.length) end = line.length;
115
221
  if (end > index) {
116
- const inner = line.slice(index + 1, end);
117
- found.push([index + 1, inner]);
118
- if (!inner.includes("{")) {
119
- index = end;
120
- continue;
121
- }
222
+ const raw = line.slice(index + 1, end);
223
+ found.push([offset + index + 1, raw]);
224
+ found.push(...innerLiterals(raw, offset + index + 1));
225
+ index = end;
226
+ continue;
122
227
  }
123
228
  }
124
229
  index += 1;
125
230
  }
126
- return found;
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
+ }
236
+ found.sort((a, b) => a[0] - b[0]);
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()));
127
276
  }
128
277
 
129
278
  /** 소스에서 글 마디를 줄 번호 순으로. @param {string} source @param {string} [path] @returns {SourceLiteral[]} */
130
279
  export function sourceLiterals(source, path = "") {
131
280
  /** @type {SourceLiteral[]} */
132
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;
133
286
  userFacingSource(source, path).split("\n").forEach((line, index) => {
134
- 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)) {
135
298
  const text = raw.trim();
136
299
  const plain = plainText(text);
137
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