hanlint 0.0.10 → 0.0.13

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.
package/src/cli/main.js CHANGED
@@ -46,7 +46,9 @@ import { exemplarFor } from "../data/exemplars.js";
46
46
  import { patterns, patternsAvoiding } from "../data/patterns.js";
47
47
  import { HAPNIDA, REGISTERS } from "../analysis/grammar/index.js";
48
48
  import { CATEGORY_TITLES, MECHANISMS, ruleCategory, ruleFix, ruleMechanism, runAll } from "../rules/registry.js";
49
- import { MARKDOWN, SKIPPED_FOLDERS, markdownUnder } from "./walk.js";
49
+ import { MARKDOWN, SKIPPED_FOLDERS, isSkipped, markdownUnder } from "./walk.js";
50
+ import { SOURCE_SUFFIXES, replaceLiteral, sourceLiterals } from "../document/sourceText.js";
51
+ import { parseSheet, renderSheet, renderSheetJson } from "../report/sheet.js";
50
52
  import { welcome } from "./welcome.js";
51
53
  import { rootHelp } from "./help.js";
52
54
  import { renderCompact } from "../report/compactReport.js";
@@ -57,7 +59,7 @@ import { renderText } from "../report/textReport.js";
57
59
  import { renderWritingSpec, writingSpec } from "../report/writingSpec.js";
58
60
  import { exemplarInRegister, patternInRegister } from "../report/registerMatch.js";
59
61
 
60
- const COMMANDS = ["lint", "fix", "print", "rules", "explain", "patterns", "primer", "spec", "hook", "baseline", "doctor", "init", "contract", "check", "verify-patch"];
62
+ const COMMANDS = ["lint", "fix", "print", "rules", "explain", "patterns", "primer", "spec", "hook", "baseline", "doctor", "init", "contract", "check", "verify-patch", "sheet"];
61
63
  const PYTHON_ONLY = [
62
64
  "audit",
63
65
  "map",
@@ -130,6 +132,7 @@ const OPTION_KINDS = {
130
132
  "--chars": "value",
131
133
  "--baseline": "optional",
132
134
  "--prune": "flag",
135
+ "--all": "flag",
133
136
  };
134
137
 
135
138
  class UsageError extends Error {}
@@ -929,7 +932,8 @@ export function renderInit(preset = "blog") {
929
932
  }
930
933
  lines.push(
931
934
  "",
932
- "# 사전에 더할 항목. 키는 cliches, translationese, redundantPair, japaneseLoan",
935
+ "# 사전에 더할 항목. 키는 cliches, translationese, redundantPair, japaneseLoan, easyWords,",
936
+ "# screenSentence, screenNarration, screenTone",
933
937
  "# [dictionary]",
934
938
  '# cliches = ["우리의 여정"]',
935
939
  '# translationese = [{ pattern = "에 대한 이해", fix = "를 아는 것" }]',
@@ -981,6 +985,106 @@ function runInit(args) {
981
985
  return 0;
982
986
  }
983
987
 
988
+ /** 폴더 아래 소스 파일. 뜻은 파이썬 cli/commands/sheet.py 의 sourceUnder 가 소유한다. @param {string} folder @returns {string[]} */
989
+ function sourceUnder(folder) {
990
+ /** @type {string[]} */
991
+ const found = [];
992
+ for (const name of readdirSync(folder).sort()) {
993
+ const path = join(folder, name);
994
+ if (statSync(path).isDirectory()) {
995
+ if (!isSkipped(name)) found.push(...sourceUnder(path));
996
+ } else if (SOURCE_SUFFIXES.includes(extname(name).toLowerCase())) found.push(path);
997
+ }
998
+ return found.sort();
999
+ }
1000
+
1001
+ /** 작업 폴더 기준 상대 경로, 구분자는 `/`. 파이썬 relativeLabel 과 같은 글자를 낸다. @param {string} path */
1002
+ function relativeLabel(path) {
1003
+ const rel = relative(process.cwd(), resolve(path));
1004
+ const label = !rel || rel.startsWith("..") || isAbsolute(rel) ? path : rel;
1005
+ return label.split(sep).join("/").replaceAll("\\", "/");
1006
+ }
1007
+
1008
+ /** 표의 고침을 파일에 쓴다. 뜻은 파이썬 applySheet 가 소유한다. @param {string} sheetPath @param {boolean} dryRun @returns {[string[], string[]]} */
1009
+ function applySheet(sheetPath, dryRun) {
1010
+ const parsed = parseSheet(readFileSync(sheetPath, "utf-8"));
1011
+ /** @type {string[]} */
1012
+ const applied = [];
1013
+ const failed = [...parsed.problems];
1014
+ /** @type {Map<string, import("../report/sheet.js").SheetRow[]>} */
1015
+ const byFile = new Map();
1016
+ for (const row of parsed.rows) {
1017
+ if (!byFile.has(row.file)) byFile.set(row.file, []);
1018
+ /** @type {import("../report/sheet.js").SheetRow[]} */ (byFile.get(row.file)).push(row);
1019
+ }
1020
+ for (const file of [...byFile.keys()].sort()) {
1021
+ const rows = /** @type {import("../report/sheet.js").SheetRow[]} */ (byFile.get(file));
1022
+ if (!existsSync(file)) {
1023
+ failed.push(...rows.map((row) => `${row.file}:${row.line}: 파일이 없다`));
1024
+ continue;
1025
+ }
1026
+ const lines = readFileSync(file, "utf-8").split("\n");
1027
+ let changed = false;
1028
+ for (const row of rows) {
1029
+ const place = `${row.file}:${row.line}`;
1030
+ if (row.line < 1 || row.line > lines.length) {
1031
+ failed.push(`${place}: 그 줄이 없다`);
1032
+ continue;
1033
+ }
1034
+ const [newLine, count] = replaceLiteral(lines[row.line - 1], row.text, row.fix);
1035
+ if (count !== 1) {
1036
+ failed.push(`${place}: 글이 그 줄에 ${count}번 있다. 한 번이어야 바꾼다`);
1037
+ continue;
1038
+ }
1039
+ lines[row.line - 1] = newLine;
1040
+ changed = true;
1041
+ applied.push(`${place}: ${row.text} -> ${row.fix}`);
1042
+ }
1043
+ if (changed && !dryRun) writeFileSync(file, lines.join("\n"), "utf-8");
1044
+ }
1045
+ return [applied, failed];
1046
+ }
1047
+
1048
+ /** `hanlint sheet 폴더/ --preset screen` 과 `hanlint sheet apply 시트.md`. 뜻은 파이썬 cli/commands/sheet.py 가 소유한다. @param {string[]} args */
1049
+ function runSheet(args) {
1050
+ const { options, positionals } = parseArgs(args);
1051
+ const output = /** @type {string | undefined} */ (options["--output"]);
1052
+ if (!positionals.length) throw new UsageError("hanlint sheet 폴더/ 또는 hanlint sheet apply 시트.md");
1053
+ if (positionals[0] === "apply") {
1054
+ if (positionals.length !== 2) {
1055
+ emit("hanlint sheet apply 시트.md 꼴로 시트 하나를 준다", output);
1056
+ return 2;
1057
+ }
1058
+ const dryRun = Boolean(options["--dry-run"]);
1059
+ const [applied, failed] = applySheet(positionals[1], dryRun);
1060
+ const lines = [`${dryRun ? "볼 것" : "적용"} ${applied.length}건, 실패 ${failed.length}건`];
1061
+ lines.push(...applied.map((item) => ` ${item}`));
1062
+ lines.push(...failed.map((item) => ` 실패 ${item}`));
1063
+ emit(lines.join("\n"), output);
1064
+ return failed.length ? 1 : 0;
1065
+ }
1066
+ /** @type {string[]} */
1067
+ const files = [];
1068
+ for (const target of positionals) {
1069
+ if (!existsSync(target)) throw new Error(`${target} 이 없다`);
1070
+ if (statSync(target).isDirectory()) files.push(...sourceUnder(target));
1071
+ else files.push(target);
1072
+ }
1073
+ const config = configFrom(options, files);
1074
+ const format = choose(/** @type {string} */ (options["--format"] ?? "markdown"), ["markdown", "json"], "--format");
1075
+ /** @type {import("../report/sheet.js").SheetRow[]} */
1076
+ const rows = [];
1077
+ for (const file of files) {
1078
+ const label = relativeLabel(file);
1079
+ for (const literal of sourceLiterals(readFileSync(file, "utf-8"), label)) {
1080
+ const findings = runAll(fingerprint(literal.plain, config), config).filter((finding) => finding.severity === "error");
1081
+ if (findings.length || options["--all"]) rows.push({ file: label, line: literal.line, text: literal.text, findings, fix: "" });
1082
+ }
1083
+ }
1084
+ emit(format === "json" ? renderSheetJson(rows, config.preset, files.length) : renderSheet(rows, config.preset, files.length), output);
1085
+ return 0;
1086
+ }
1087
+
984
1088
  /** @param {string[]} argv */
985
1089
  function dispatch(argv) {
986
1090
  if (!argv.length) {
@@ -1013,6 +1117,7 @@ function dispatch(argv) {
1013
1117
  if (command === "hook") return runHook(rest);
1014
1118
  if (command === "baseline") return runBaseline(rest);
1015
1119
  if (command === "doctor") return runDoctor(rest);
1120
+ if (command === "sheet") return runSheet(rest);
1016
1121
  return runInit(rest);
1017
1122
  }
1018
1123
 
@@ -1,6 +1,6 @@
1
1
  // @ts-check
2
2
  /** 이유가 붙은 정확 국소 Patch 입력 계약. */
3
- import { createHash } from "node:crypto";
3
+ import { sha256 } from "../digest.js";
4
4
 
5
5
  /** @param {unknown} value @param {string} where */
6
6
  function checkedString(value, where) {
@@ -39,7 +39,7 @@ export class Patch {
39
39
 
40
40
  get digest() {
41
41
  const encoded = JSON.stringify({ after: this.after, before: this.before, reason: this.reason });
42
- return createHash("sha256").update(encoded, "utf8").digest("hex");
42
+ return sha256(encoded);
43
43
  }
44
44
 
45
45
  asDict() {
@@ -1,6 +1,6 @@
1
1
  // @ts-check
2
2
  /** 모델과 실행 환경에 독립적인 최소 Reader Contract와 정확 Patch. */
3
- import { createHash } from "node:crypto";
3
+ import { sha256 } from "../digest.js";
4
4
 
5
5
  export const CONTRACT_VERSION = 1;
6
6
  export const LATEST_CONTRACT_VERSION = 2;
@@ -38,7 +38,7 @@ function compareText(left, right) {
38
38
 
39
39
  /** @param {string} text */
40
40
  function digest(text) {
41
- return createHash("sha256").update(text, "utf8").digest("hex");
41
+ return sha256(text);
42
42
  }
43
43
 
44
44
  export class Contract {
@@ -12,9 +12,11 @@ import { projectPatches } from "../data/patches.js";
12
12
  * 글의 종류마다 처음부터 끄고 시작할 규칙. 정본은 파이썬 config/settings.py 의 PRESETS 다.
13
13
  * @type {Record<string, string[]>}
14
14
  */
15
+ // 화면의 글에서만 켜는 것. 산문의 모든 종류가 처음부터 끈다. 파이썬 settings.py 의 SCREEN 과 같다.
16
+ const SCREEN = ["screenNarration", "screenSentence", "screenTone"];
15
17
  export const PRESETS = {
16
- blog: [],
17
- report: ["noQuestion", "firstResultDistance", "introImage", "moreLater", "numberOrphan"],
18
+ blog: [...SCREEN],
19
+ report: ["noQuestion", "firstResultDistance", "introImage", "moreLater", "numberOrphan", ...SCREEN],
18
20
  docs: [
19
21
  "noQuestion",
20
22
  "firstResultDistance",
@@ -25,6 +27,7 @@ export const PRESETS = {
25
27
  "numberOrphan",
26
28
  "duplicateBlock",
27
29
  "headingUniform",
30
+ ...SCREEN,
28
31
  ],
29
32
  };
30
33
  // 서사 글에서만 끄는 것. 설명글의 전제 (독자가 문장 사이 이유를 따라간다, 말끝을 맺는다) 가 장면과
@@ -58,7 +61,54 @@ const CONVERSATION = [
58
61
  "promiseRecall",
59
62
  "sectionNoProse",
60
63
  ];
61
- PRESETS.chat = CONVERSATION;
64
+ PRESETS.chat = [...CONVERSATION, ...SCREEN];
65
+ // 화면의 글에서 끄는 것. 문장과 문단과 글의 짜임을 재는 규칙은 화면의 글 한 마디에 전제가 없다. 파이썬 settings.py 의 SCREEN_OFF 와 같다.
66
+ const SCREEN_OFF = [
67
+ "cliche",
68
+ "connectorRepeat",
69
+ "danglingDeixis",
70
+ "deixis",
71
+ "doubleNegative",
72
+ "draftHistory",
73
+ "endingRepeat",
74
+ "euiChain",
75
+ "fillerOpener",
76
+ "imperativePeriod",
77
+ "longSentence",
78
+ "negationRedefine",
79
+ "nounPile",
80
+ "numberOrphan",
81
+ "outsideProfile",
82
+ "factListParagraph",
83
+ "paraFragment",
84
+ "blockUnread",
85
+ "bridgeRepeat",
86
+ "emojiBullet",
87
+ "headingSentence",
88
+ "headingSkip",
89
+ "headingUniform",
90
+ "introImage",
91
+ "introLong",
92
+ "loneSubheading",
93
+ "moreLater",
94
+ "sectionNoProse",
95
+ "countMismatch",
96
+ "enoughOnce",
97
+ "fieldEcho",
98
+ "keywordHeading",
99
+ "keywordMissing",
100
+ "noQuestion",
101
+ "promiseRecall",
102
+ "tableOddCell",
103
+ "duplicateBlock",
104
+ "firstResultDistance",
105
+ "inputFileSource",
106
+ "installImport",
107
+ "platformApi",
108
+ ];
109
+ PRESETS.screen = SCREEN_OFF;
110
+ /** enforceStyle 에 넣을 수 있는 규칙. 파이썬 settings.py 의 ENFORCEABLE 과 같다. */
111
+ export const ENFORCEABLE = ["noQuestion", "nounPile", "screenNarration", "screenTone"];
62
112
 
63
113
  /** 프리셋 → 견줄 프로파일의 종류. 정본은 파이썬 config/settings.py 의 PROFILE_OF 다. @type {Record<string, string>} */
64
114
  export const PROFILE_OF = {
@@ -70,6 +120,7 @@ export const PROFILE_OF = {
70
120
  fiction: "fiction",
71
121
  encyclopedia: "encyclopedia",
72
122
  chat: null,
123
+ screen: null,
73
124
  };
74
125
 
75
126
  export const PRESET_NAMES = Object.keys(PRESETS);
@@ -180,8 +231,8 @@ export function configFromMapping(data) {
180
231
  }
181
232
  config.preset = /** @type {string} */ (value);
182
233
  } else if (key === "enforceStyle") {
183
- if (!Array.isArray(value) || value.some((name) => !["noQuestion", "nounPile"].includes(name))) {
184
- throw new Error("enforceStyle 은 noQuestion, nounPile의 배열이다");
234
+ if (!Array.isArray(value) || value.some((name) => !ENFORCEABLE.includes(name))) {
235
+ throw new Error(`enforceStyle 은 ${ENFORCEABLE.join(", ")}의 배열이다`);
185
236
  }
186
237
  config.enforceStyle = [...value];
187
238
  } else if (key === "dictionary") {
package/src/data/load.js CHANGED
@@ -3,13 +3,8 @@
3
3
  * data 폴더를 읽는다. 파이썬 정본의 투영이라 파일 모양이 같다. txt 는 한 줄에 항목 하나, json 은 toml 을 옮긴 것.
4
4
  * 한 번 읽은 것은 기억한다.
5
5
  */
6
- import { readFileSync } from "node:fs";
7
- import { dirname, join } from "node:path";
8
- import { fileURLToPath } from "node:url";
9
-
10
6
  import { compile } from "../regex.js";
11
-
12
- const DATA_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "data");
7
+ import { readData } from "./read.js";
13
8
  /** @type {Map<string, unknown>} */
14
9
  const cache = new Map();
15
10
 
@@ -26,7 +21,7 @@ function cached(key, make) {
26
21
 
27
22
  /** @param {string} name */
28
23
  export function readText(name) {
29
- return readFileSync(join(DATA_DIR, name), "utf-8");
24
+ return readData(name);
30
25
  }
31
26
 
32
27
  /** `#` 으로 시작하면 주석, 빈 줄은 무시. @param {string} name @returns {string[]} */
@@ -3,7 +3,7 @@
3
3
  * 참조 분포 (프로파일) 의 자료형과 읽기. 파이썬 data/profiles.py 와 같다. 종류별 프로파일은 profiles.json (투영) 에서,
4
4
  * 사용자 프로파일은 `hanlint profile build` 가 만든 파일에서 읽는다. 히스토그램은 정확한 계수라 두 판의 셈이 같다.
5
5
  */
6
- import { readFileSync } from "node:fs";
6
+ import { readExternal } from "./read.js";
7
7
 
8
8
  import { readText } from "./load.js";
9
9
 
@@ -103,7 +103,7 @@ const users = new Map();
103
103
  export function userProfile(path) {
104
104
  let profile = users.get(path);
105
105
  if (!profile) {
106
- const data = JSON.parse(readFileSync(path, "utf-8"));
106
+ const data = JSON.parse(readExternal(path));
107
107
  if (data.version !== PROFILE_VERSION || !("profile" in data)) {
108
108
  throw new Error(`프로파일 파일이 아니다: ${path}. hanlint profile build 로 다시 만든다`);
109
109
  }
@@ -0,0 +1,21 @@
1
+ // @ts-check
2
+ /** 같은 자료를 Node 파일 또는 브라우저 배포 묶음에서 읽는다. */
3
+ const isNode = typeof process === "object" && Boolean(process.versions?.node);
4
+ const fileSystem = isNode ? await import("node:fs") : null;
5
+ const files = isNode ? null : await fetch(new URL("../../data/siteData.json", import.meta.url)).then((response) => {
6
+ if (!response.ok) throw new Error("검사 자료를 불러오지 못했습니다. 새로고침해 주세요.");
7
+ return response.json();
8
+ });
9
+
10
+ /** @param {string} name @returns {string} */
11
+ export function readData(name) {
12
+ if (fileSystem) return fileSystem.readFileSync(new URL(`../../data/${name}`, import.meta.url), "utf8");
13
+ if (typeof files[name] !== "string") throw new Error(`검사 자료가 없습니다: ${name}`);
14
+ return files[name];
15
+ }
16
+
17
+ /** @param {string} path @returns {string} */
18
+ export function readExternal(path) {
19
+ if (!fileSystem) throw new Error("파일 경로 읽기는 Node에서 지원합니다. 브라우저에는 텍스트를 전달해 주세요.");
20
+ return fileSystem.readFileSync(path, "utf8");
21
+ }
package/src/digest.js ADDED
@@ -0,0 +1,44 @@
1
+ // @ts-check
2
+ /** FIPS 180-4 SHA-256. 동기 공개 계약을 Node와 브라우저에서 같은 UTF-8 해시로 유지한다. */
3
+ // 상수와 라운드의 출처: https://nvlpubs.nist.gov/nistpubs/FIPS/NIST.FIPS.180-4.pdf (4.2.2, 6.2).
4
+ const WORDS = [
5
+ 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
6
+ 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
7
+ 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
8
+ 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
9
+ 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
10
+ 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
11
+ 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
12
+ 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
13
+ ];
14
+ const rotate = (value, shift) => (value >>> shift) | (value << (32 - shift));
15
+
16
+ /** @param {string} text @returns {string} */
17
+ export function sha256(text) {
18
+ const bytes = new TextEncoder().encode(text);
19
+ const buffer = new Uint8Array(Math.ceil((bytes.length + 9) / 64) * 64);
20
+ buffer.set(bytes);
21
+ buffer[bytes.length] = 0x80;
22
+ const view = new DataView(buffer.buffer);
23
+ view.setUint32(buffer.length - 8, Math.floor(bytes.length / 0x20000000));
24
+ view.setUint32(buffer.length - 4, bytes.length * 8);
25
+ const hash = [0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19];
26
+ const words = new Uint32Array(64);
27
+ for (let offset = 0; offset < buffer.length; offset += 64) {
28
+ for (let index = 0; index < 16; index++) words[index] = view.getUint32(offset + index * 4);
29
+ for (let index = 16; index < 64; index++) {
30
+ const left = words[index - 15], right = words[index - 2];
31
+ const first = rotate(left, 7) ^ rotate(left, 18) ^ (left >>> 3);
32
+ const second = rotate(right, 17) ^ rotate(right, 19) ^ (right >>> 10);
33
+ words[index] = words[index - 16] + first + words[index - 7] + second;
34
+ }
35
+ let [a, b, c, d, e, f, g, h] = hash;
36
+ for (let index = 0; index < 64; index++) {
37
+ const first = h + (rotate(e, 6) ^ rotate(e, 11) ^ rotate(e, 25)) + ((e & f) ^ (~e & g)) + WORDS[index] + words[index];
38
+ const second = (rotate(a, 2) ^ rotate(a, 13) ^ rotate(a, 22)) + ((a & b) ^ (a & c) ^ (b & c));
39
+ [a, b, c, d, e, f, g, h] = [(first + second) | 0, a, b, c, (d + first) | 0, e, f, g];
40
+ }
41
+ [a, b, c, d, e, f, g, h].forEach((value, index) => { hash[index] = (hash[index] + value) | 0; });
42
+ }
43
+ return hash.map((value) => (value >>> 0).toString(16).padStart(8, "0")).join("");
44
+ }
@@ -0,0 +1,143 @@
1
+ // @ts-check
2
+ /**
3
+ * 소스 파일의 글 마디. 사람이 읽는 한국어 문자열 리터럴과 JSX 글을 줄 번호와 함께 뽑는다.
4
+ * 뜻과 규칙은 파이썬 document/sourceText.py 가 소유한다. 여기는 같은 값을 낸다.
5
+ */
6
+
7
+ export const SOURCE_SUFFIXES = [".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".rs", ".py"];
8
+
9
+ const KOREAN = /[가-힣]/;
10
+ // 문자열의 경계. 줄을 앞에서부터 훑어 여는 따옴표에서 같은 닫는 따옴표까지를 한 마디로 본다. 파이썬 sourceText.py 와 같다.
11
+ const QUOTES = ["'", '"', "`"];
12
+ const EXPRESSION = /\{[^{}]*\}/g;
13
+ const BLOCK_COMMENT = /\/\*[\s\S]*?\*\//g;
14
+ const DEVELOPER_LINE = /new Error\(|console\.|panic!\(|expect\(|assert/;
15
+ const RUST_CONTINUATION = /\\\r?\n[ \t]*/g;
16
+ const RUST_TEST_MARKER = "#[cfg(test)]";
17
+
18
+ /**
19
+ * @typedef {object} SourceLiteral
20
+ * @property {number} line 1부터 세는 줄 번호. 여러 줄을 이은 러스트 문자열은 시작 줄이다
21
+ * @property {string} text 따옴표와 태그를 뺀 글. 파일에 있는 그대로라 되돌려 쓸 때 찾을 수 있다
22
+ * @property {string} plain 검사에 쓰는 글. 식 (`${...}`, `{...}`) 을 비우고 공백을 하나로 모았다
23
+ */
24
+
25
+ /** 템플릿 리터럴의 `${ ... }` 식을 (중첩 괄호를 세어) 같은 길이의 빈칸으로 바꾼다. @param {string} source */
26
+ export function withoutTemplateExpressions(source) {
27
+ let output = "";
28
+ let index = 0;
29
+ while (index < source.length) {
30
+ if (source[index] === "$" && source[index + 1] === "{") {
31
+ let depth = 0;
32
+ let cursor = index + 1;
33
+ for (; cursor < source.length; cursor++) {
34
+ if (source[cursor] === "{") depth += 1;
35
+ else if (source[cursor] === "}") {
36
+ depth -= 1;
37
+ if (depth === 0) break;
38
+ }
39
+ }
40
+ output += " ".repeat(cursor + 1 - index);
41
+ index = cursor + 1;
42
+ continue;
43
+ }
44
+ output += source[index];
45
+ index += 1;
46
+ }
47
+ return output;
48
+ }
49
+
50
+ /** 주석과 개발자용 줄을 걷어낸 소스. 줄 번호는 유지한다. @param {string} source @param {string} path */
51
+ export function userFacingSource(source, path) {
52
+ const isRust = path.endsWith(".rs");
53
+ let body = source;
54
+ if (isRust && body.includes(RUST_TEST_MARKER)) body = body.slice(0, body.indexOf(RUST_TEST_MARKER));
55
+ if (isRust) body = body.replace(RUST_CONTINUATION, "");
56
+ body = body.replace(BLOCK_COMMENT, (block) => block.replace(/[^\n]/g, " "));
57
+ return body
58
+ .split("\n")
59
+ .map((line) => {
60
+ const trimmed = line.trim();
61
+ if (trimmed.startsWith("//") || trimmed.startsWith("*") || trimmed.startsWith("#")) return "";
62
+ if (DEVELOPER_LINE.test(line)) return "";
63
+ return line.replace(/\/\/.*$/, "");
64
+ })
65
+ .join("\n");
66
+ }
67
+
68
+ /** 검사에 쓰는 글. 식을 비우고 공백을 하나로 모은다. @param {string} text */
69
+ export function plainText(text) {
70
+ let copy = withoutTemplateExpressions(text);
71
+ while (EXPRESSION.test(copy)) copy = copy.replace(EXPRESSION, " ");
72
+ return copy.replace(/\s+/g, " ").trim();
73
+ }
74
+
75
+ /** `start` 의 따옴표를 닫는 자리. 역슬래시 뒤 글자는 건너뛴다. 없으면 -1. @param {string} line @param {number} start */
76
+ function closingQuote(line, start) {
77
+ const quote = line[start];
78
+ let index = start + 1;
79
+ while (index < line.length) {
80
+ const char = line[index];
81
+ if (char === "\\") {
82
+ index += 2;
83
+ continue;
84
+ }
85
+ if (char === quote) return index;
86
+ index += 1;
87
+ }
88
+ return -1;
89
+ }
90
+
91
+ /** 한 줄의 글 마디를 나온 차례로. 뜻은 파이썬 lineLiterals 가 소유한다. @param {string} line @returns {string[]} */
92
+ export function lineLiterals(line) {
93
+ /** @type {string[]} */
94
+ const found = [];
95
+ let index = 0;
96
+ while (index < line.length) {
97
+ const char = line[index];
98
+ if (QUOTES.includes(char)) {
99
+ const end = closingQuote(line, index);
100
+ if (end < 0) break;
101
+ found.push(line.slice(index + 1, end));
102
+ index = end + 1;
103
+ continue;
104
+ }
105
+ if (char === ">" && (index === 0 || !"=-".includes(line[index - 1]))) {
106
+ const end = line.indexOf("<", index + 1);
107
+ if (end > index) {
108
+ const inner = line.slice(index + 1, end);
109
+ found.push(inner);
110
+ if (!inner.includes("{")) {
111
+ index = end;
112
+ continue;
113
+ }
114
+ }
115
+ }
116
+ index += 1;
117
+ }
118
+ return found;
119
+ }
120
+
121
+ /** 소스에서 글 마디를 줄 번호 순으로. @param {string} source @param {string} [path] @returns {SourceLiteral[]} */
122
+ export function sourceLiterals(source, path = "") {
123
+ /** @type {SourceLiteral[]} */
124
+ const found = [];
125
+ userFacingSource(source, path).split("\n").forEach((line, index) => {
126
+ for (const raw of lineLiterals(line)) {
127
+ const text = raw.trim();
128
+ const plain = plainText(text);
129
+ if (text && KOREAN.test(plain)) found.push({ line: index + 1, text, plain });
130
+ }
131
+ });
132
+ return found;
133
+ }
134
+
135
+ /**
136
+ * 한 줄 안에서 글 `old` 를 `new` 로 바꾼다. 몇 번 나왔는지 같이 준다 (한 번일 때만 바꾼 것이 확정이다).
137
+ * @param {string} lineText @param {string} oldText @param {string} newText @returns {[string, number]}
138
+ */
139
+ export function replaceLiteral(lineText, oldText, newText) {
140
+ const count = lineText.split(oldText).length - 1;
141
+ if (count !== 1) return [lineText, count];
142
+ return [lineText.replace(oldText, () => newText), 1];
143
+ }
@@ -12,6 +12,9 @@ export const DICTIONARY_FILES = {
12
12
  spacing: "spacing.toml",
13
13
  confusable: "confusable.toml",
14
14
  easyWords: "easyWords.toml",
15
+ screenSentence: "screenSentence.toml",
16
+ screenNarration: "screenNarration.toml",
17
+ screenTone: "screenTone.toml",
15
18
  };
16
19
  const GROUP_REF = /\$(\d)/g;
17
20
  const FINALS = { ㄴ: 4, ㄹ: 8 };
@@ -1,6 +1,6 @@
1
1
  // @ts-check
2
2
  /** Reader Contract 검사와 이유가 붙은 국소 Patch 검증. */
3
- import { createHash } from "node:crypto";
3
+ import { sha256 } from "../digest.js";
4
4
 
5
5
  import { Patch } from "../config/patch.js";
6
6
  import { Contract, ContractV2, Outline, ProtectedSurface, parseContract } from "../config/readerContract.js";
@@ -16,7 +16,7 @@ import { compareText, factLines, protectedSurface, surfaceDiff, surfaceViolation
16
16
 
17
17
  /** @param {string} text */
18
18
  function digest(text) {
19
- return createHash("sha256").update(text, "utf8").digest("hex");
19
+ return sha256(text);
20
20
  }
21
21
 
22
22
  /** 원문의 보호 표면을 모두 덮는 version 1 Contract 초안을 만든다. @param {string} text @param {string} reader @param {string} goal */
@@ -6,7 +6,7 @@
6
6
  /** @typedef {{heading: string, line: number, paragraphCount: number, codeBlockCount: number}} SectionSummary */
7
7
  /** @typedef {{sentenceCount: number, paragraphCount: number, sectionCount: number, wordCount: number, questionCount: number, readerCallCount: number, sections: SectionSummary[]}} DocumentSummary */
8
8
 
9
- /** @param {import("../config/readerContract.js").Outline} outline @param {import("../fingerprint/build.js").DocumentPrint} doc @returns {OutlineDiff} */
9
+ /** @param {Pick<import("../config/readerContract.js").Outline, "level" | "headings">} outline @param {import("../fingerprint/build.js").DocumentPrint} doc @returns {OutlineDiff} */
10
10
  export function compareOutline(outline, doc) {
11
11
  const actual = doc.headings.filter(([level]) => level === outline.level).map(([, title]) => title);
12
12
  const mismatches = [];