hanlint 0.0.9 → 0.0.12

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 (56) hide show
  1. package/README.md +105 -31
  2. package/data/checkResult.schema.json +75 -0
  3. package/data/checkResultV2.schema.json +98 -0
  4. package/data/exemplars.json +99 -0
  5. package/data/finding.schema.json +38 -0
  6. package/data/holeKinds.json +12 -0
  7. package/data/patch.schema.json +14 -0
  8. package/data/patchResult.schema.json +86 -0
  9. package/data/patchResultV2.schema.json +71 -0
  10. package/data/profiles.json +1 -1
  11. package/data/pythonPackages.txt +3 -2
  12. package/data/readerContract.schema.json +20 -0
  13. package/data/readerContractConformanceV1.json +168 -0
  14. package/data/readerContractV2.schema.json +64 -0
  15. package/data/ruleCategories.json +3 -0
  16. package/data/ruleDocs.json +6 -3
  17. package/data/ruleMechanisms.json +3 -0
  18. package/data/screenNarration.json +29 -0
  19. package/data/screenSentence.json +34 -0
  20. package/data/screenTone.json +19 -0
  21. package/data/version.json +1 -1
  22. package/data/writingBrief.schema.json +1 -1
  23. package/data/writingBriefV2.schema.json +1 -1
  24. package/package.json +1 -1
  25. package/src/cli/help.js +34 -0
  26. package/src/cli/main.js +243 -29
  27. package/src/config/patch.js +48 -0
  28. package/src/config/readerContract.js +245 -0
  29. package/src/config/settings.js +84 -4
  30. package/src/data/load.js +2 -7
  31. package/src/data/profiles.js +14 -2
  32. package/src/data/read.js +21 -0
  33. package/src/digest.js +44 -0
  34. package/src/fingerprint/codeMarkers.js +1 -1
  35. package/src/fingerprint/dictionaries.js +3 -0
  36. package/src/guard/contract.js +174 -0
  37. package/src/guard/editLocality.js +27 -0
  38. package/src/guard/outline.js +46 -0
  39. package/src/guard/receipt.js +180 -0
  40. package/src/guard/surface.js +184 -0
  41. package/src/index.js +83 -6
  42. package/src/learn/edits.js +31 -0
  43. package/src/learn/operations.js +28 -0
  44. package/src/learn/pairs.js +57 -0
  45. package/src/report/compactReport.js +1 -1
  46. package/src/report/writingSpec.js +174 -0
  47. package/src/rules/code/inputFileSource.js +5 -3
  48. package/src/rules/code/installImport.js +6 -5
  49. package/src/rules/document/noQuestion.js +4 -4
  50. package/src/rules/readerDebt.js +9 -0
  51. package/src/rules/registry.js +34 -0
  52. package/src/rules/screen/screenNarration.js +10 -0
  53. package/src/rules/screen/screenSentence.js +10 -0
  54. package/src/rules/screen/screenTone.js +10 -0
  55. package/src/rules/sentence/nounPile.js +1 -1
  56. package/src/rules/shared/dictionaryRule.js +30 -0
@@ -0,0 +1,48 @@
1
+ // @ts-check
2
+ /** 이유가 붙은 정확 국소 Patch 입력 계약. */
3
+ import { sha256 } from "../digest.js";
4
+
5
+ /** @param {unknown} value @param {string} where */
6
+ function checkedString(value, where) {
7
+ if (typeof value !== "string" || !value || value !== value.trim()) {
8
+ throw new Error(`${where} 는 양끝 공백 없는 문자열이다`);
9
+ }
10
+ if (value !== value.normalize("NFC")) throw new Error(`${where} 는 NFC 문자열이어야 한다`);
11
+ return value;
12
+ }
13
+
14
+ export class Patch {
15
+ /** @param {string} reason @param {string} before @param {string} after */
16
+ constructor(reason, before, after) {
17
+ this.reason = checkedString(reason, "reason");
18
+ this.before = checkedString(before, "before");
19
+ this.after = checkedString(after, "after");
20
+ if (this.before === this.after) throw new Error("patch before 와 after 는 달라야 한다");
21
+ Object.freeze(this);
22
+ }
23
+
24
+ /** @param {unknown} raw */
25
+ static fromMapping(raw) {
26
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("patch 는 JSON 객체다");
27
+ const data = /** @type {Record<string, unknown>} */ (raw);
28
+ const expected = ["reason", "before", "after"];
29
+ const unknown = Object.keys(data).filter((key) => !expected.includes(key)).sort();
30
+ const missing = expected.filter((key) => !(key in data)).sort();
31
+ if (unknown.length) throw new Error(`patch 의 모르는 키: ${unknown.join(", ")}`);
32
+ if (missing.length) throw new Error(`patch 의 빠진 키: ${missing.join(", ")}`);
33
+ return new Patch(
34
+ /** @type {string} */ (data.reason),
35
+ /** @type {string} */ (data.before),
36
+ /** @type {string} */ (data.after),
37
+ );
38
+ }
39
+
40
+ get digest() {
41
+ const encoded = JSON.stringify({ after: this.after, before: this.before, reason: this.reason });
42
+ return sha256(encoded);
43
+ }
44
+
45
+ asDict() {
46
+ return { reason: this.reason, before: this.before, after: this.after };
47
+ }
48
+ }
@@ -0,0 +1,245 @@
1
+ // @ts-check
2
+ /** 모델과 실행 환경에 독립적인 최소 Reader Contract와 정확 Patch. */
3
+ import { sha256 } from "../digest.js";
4
+
5
+ export const CONTRACT_VERSION = 1;
6
+ export const LATEST_CONTRACT_VERSION = 2;
7
+ export const CONTRACT_VERSIONS = Object.freeze([CONTRACT_VERSION, LATEST_CONTRACT_VERSION]);
8
+
9
+ /** @param {unknown} value @param {string} where */
10
+ function checkedString(value, where) {
11
+ if (typeof value !== "string" || !value || value !== value.trim()) {
12
+ throw new Error(`${where} 는 양끝 공백 없는 문자열이다`);
13
+ }
14
+ if (value !== value.normalize("NFC")) throw new Error(`${where} 는 NFC 문자열이어야 한다`);
15
+ return value;
16
+ }
17
+
18
+ /** @param {unknown} value @param {string} where @param {boolean} [allowEmpty] */
19
+ function checkedStrings(value, where, allowEmpty = false) {
20
+ if (!Array.isArray(value) || (!allowEmpty && !value.length)) {
21
+ throw new Error(`${where} 는 ${allowEmpty ? "문자열 배열" : "비지 않은 문자열 배열"}이다`);
22
+ }
23
+ const found = value.map((item, index) => checkedString(item, `${where} ${index + 1}번째`));
24
+ if (new Set(found).size !== found.length) throw new Error(`${where} 에 같은 값이 두 번 있다`);
25
+ return found;
26
+ }
27
+
28
+ /** 파이썬 문자열 순서와 같은 코드 포인트 순서. @param {string} left @param {string} right */
29
+ function compareText(left, right) {
30
+ const leftPoints = [...left].map((value) => /** @type {number} */ (value.codePointAt(0)));
31
+ const rightPoints = [...right].map((value) => /** @type {number} */ (value.codePointAt(0)));
32
+ const limit = Math.min(leftPoints.length, rightPoints.length);
33
+ for (let index = 0; index < limit; index += 1) {
34
+ if (leftPoints[index] !== rightPoints[index]) return leftPoints[index] - rightPoints[index];
35
+ }
36
+ return leftPoints.length - rightPoints.length;
37
+ }
38
+
39
+ /** @param {string} text */
40
+ function digest(text) {
41
+ return sha256(text);
42
+ }
43
+
44
+ export class Contract {
45
+ /** @param {string} reader @param {string} goal @param {string[]} facts @param {number} [version] */
46
+ constructor(reader, goal, facts, version = CONTRACT_VERSION) {
47
+ if (!Number.isInteger(version) || version !== CONTRACT_VERSION) {
48
+ throw new Error(`reader contract version 은 ${CONTRACT_VERSION}이다: ${version}`);
49
+ }
50
+ this.reader = checkedString(reader, "reader");
51
+ this.goal = checkedString(goal, "goal");
52
+ this.facts = Object.freeze(checkedStrings(facts, "facts"));
53
+ this.version = version;
54
+ Object.freeze(this);
55
+ }
56
+
57
+ /** @param {unknown} raw */
58
+ static fromMapping(raw) {
59
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("reader contract 는 JSON 객체다");
60
+ const data = /** @type {Record<string, unknown>} */ (raw);
61
+ const expected = ["version", "reader", "goal", "facts"];
62
+ const unknown = Object.keys(data).filter((key) => !expected.includes(key)).sort();
63
+ const missing = expected.filter((key) => !(key in data)).sort();
64
+ if (unknown.length) throw new Error(`reader contract 의 모르는 키: ${unknown.join(", ")}`);
65
+ if (missing.length) throw new Error(`reader contract 의 빠진 키: ${missing.join(", ")}`);
66
+ return new Contract(
67
+ /** @type {string} */ (data.reader),
68
+ /** @type {string} */ (data.goal),
69
+ /** @type {string[]} */ (data.facts),
70
+ /** @type {number} */ (data.version),
71
+ );
72
+ }
73
+
74
+ get text() {
75
+ return [this.reader, this.goal, ...this.facts].join("\n");
76
+ }
77
+
78
+ get digest() {
79
+ return digest(JSON.stringify({ facts: this.facts, goal: this.goal, reader: this.reader, version: this.version }));
80
+ }
81
+
82
+ asDict() {
83
+ return { version: this.version, reader: this.reader, goal: this.goal, facts: [...this.facts] };
84
+ }
85
+ }
86
+
87
+ export class ProtectedSurface {
88
+ /** @param {string[]} [numbers] @param {string[]} [urls] @param {string[]} [code] @param {string[]} [links] */
89
+ constructor(numbers = [], urls = [], code = [], links = []) {
90
+ this.numbers = Object.freeze(checkedStrings(numbers, "surface.numbers", true).sort(compareText));
91
+ this.urls = Object.freeze(checkedStrings(urls, "surface.urls", true).sort(compareText));
92
+ this.code = Object.freeze(checkedStrings(code, "surface.code", true).sort(compareText));
93
+ this.links = Object.freeze(checkedStrings(links, "surface.links", true).sort(compareText));
94
+ Object.freeze(this);
95
+ }
96
+
97
+ /** @param {unknown} raw */
98
+ static fromMapping(raw) {
99
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("surface 는 JSON 객체다");
100
+ const data = /** @type {Record<string, unknown>} */ (raw);
101
+ const expected = ["numbers", "urls", "code", "links"];
102
+ const unknown = Object.keys(data).filter((key) => !expected.includes(key)).sort();
103
+ const missing = expected.filter((key) => !(key in data)).sort();
104
+ if (unknown.length) throw new Error(`surface 의 모르는 키: ${unknown.join(", ")}`);
105
+ if (missing.length) throw new Error(`surface 의 빠진 키: ${missing.join(", ")}`);
106
+ return new ProtectedSurface(
107
+ /** @type {string[]} */ (data.numbers),
108
+ /** @type {string[]} */ (data.urls),
109
+ /** @type {string[]} */ (data.code),
110
+ /** @type {string[]} */ (data.links),
111
+ );
112
+ }
113
+
114
+ get text() {
115
+ return [
116
+ ...this.numbers,
117
+ ...this.urls,
118
+ ...this.code.map((value) => `\`${value}\``),
119
+ ...this.links.map((value) => `[](${value})`),
120
+ ].join("\n");
121
+ }
122
+
123
+ asDict() {
124
+ return { numbers: [...this.numbers], urls: [...this.urls], code: [...this.code], links: [...this.links] };
125
+ }
126
+ }
127
+
128
+ export class Outline {
129
+ /** @param {number} level @param {string[]} headings */
130
+ constructor(level, headings) {
131
+ if (!Number.isInteger(level) || level < 1 || level > 6) throw new Error(`outline.level 은 1~6 정수다: ${level}`);
132
+ this.level = level;
133
+ this.headings = Object.freeze(checkedStrings(headings, "outline.headings"));
134
+ Object.freeze(this);
135
+ }
136
+
137
+ /** @param {unknown} raw */
138
+ static fromMapping(raw) {
139
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("outline 은 JSON 객체다");
140
+ const data = /** @type {Record<string, unknown>} */ (raw);
141
+ const expected = ["level", "headings"];
142
+ const unknown = Object.keys(data).filter((key) => !expected.includes(key)).sort();
143
+ const missing = expected.filter((key) => !(key in data)).sort();
144
+ if (unknown.length) throw new Error(`outline 의 모르는 키: ${unknown.join(", ")}`);
145
+ if (missing.length) throw new Error(`outline 의 빠진 키: ${missing.join(", ")}`);
146
+ return new Outline(/** @type {number} */ (data.level), /** @type {string[]} */ (data.headings));
147
+ }
148
+
149
+ asDict() {
150
+ return { level: this.level, headings: [...this.headings] };
151
+ }
152
+ }
153
+
154
+ export class ContractV2 {
155
+ /** @param {string} reader @param {string} goal @param {string[]} facts @param {ProtectedSurface | Record<string, unknown>} surface @param {Outline | Record<string, unknown>} outline @param {number} [version] */
156
+ constructor(reader, goal, facts, surface, outline, version = LATEST_CONTRACT_VERSION, lockedFacts = [], editPolicy = {}) {
157
+ if (!Number.isInteger(version) || version !== LATEST_CONTRACT_VERSION) {
158
+ throw new Error(`reader contract version 은 ${LATEST_CONTRACT_VERSION}다: ${version}`);
159
+ }
160
+ this.reader = checkedString(reader, "reader");
161
+ this.goal = checkedString(goal, "goal");
162
+ this.facts = Object.freeze(checkedStrings(facts, "facts", true));
163
+ this.surface = surface instanceof ProtectedSurface ? surface : ProtectedSurface.fromMapping(surface);
164
+ this.outline = outline instanceof Outline ? outline : Outline.fromMapping(outline);
165
+ this.lockedFacts = Object.freeze(checkedStrings(lockedFacts, "lockedFacts", true));
166
+ if (this.lockedFacts.some((fact) => !this.facts.includes(fact))) throw new Error("lockedFacts must select approved facts");
167
+ if (!editPolicy || typeof editPolicy !== "object" || Array.isArray(editPolicy)) throw new Error("editPolicy must be an object");
168
+ this.editPolicy = Object.freeze(Object.fromEntries(Object.entries(editPolicy).sort(([a], [b]) => compareText(a, b)).map(([rule, limits]) => {
169
+ if (!rule || rule !== rule.trim()) throw new Error("editPolicy requires rule names");
170
+ if (!limits || typeof limits !== "object" || Array.isArray(limits) || Object.keys(limits).sort().join(",") !== "maxChars,maxLines") {
171
+ throw new Error("editPolicy requires maxChars and maxLines");
172
+ }
173
+ if (Object.values(limits).some((n) => !Number.isInteger(n) || n < 1)) throw new Error("editPolicy limits must be positive integers");
174
+ return [rule, Object.freeze({maxChars: limits.maxChars, maxLines: limits.maxLines})];
175
+ })));
176
+ this.version = version;
177
+ Object.freeze(this);
178
+ }
179
+
180
+ /** @param {unknown} raw */
181
+ static fromMapping(raw) {
182
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("reader contract 는 JSON 객체다");
183
+ const data = /** @type {Record<string, unknown>} */ (raw);
184
+ const expected = ["version", "reader", "goal", "facts", "surface", "outline"];
185
+ const unknown = Object.keys(data).filter((key) => !expected.includes(key) && !["lockedFacts", "editPolicy"].includes(key)).sort();
186
+ const missing = expected.filter((key) => !(key in data)).sort();
187
+ if (unknown.length) throw new Error(`reader contract 의 모르는 키: ${unknown.join(", ")}`);
188
+ if (missing.length) throw new Error(`reader contract 의 빠진 키: ${missing.join(", ")}`);
189
+ return new ContractV2(
190
+ /** @type {string} */ (data.reader),
191
+ /** @type {string} */ (data.goal),
192
+ /** @type {string[]} */ (data.facts),
193
+ /** @type {Record<string, unknown>} */ (data.surface),
194
+ /** @type {Record<string, unknown>} */ (data.outline),
195
+ /** @type {number} */ (data.version),
196
+ Object.hasOwn(data, "lockedFacts") ? data.lockedFacts : [],
197
+ Object.hasOwn(data, "editPolicy") ? data.editPolicy : {},
198
+ );
199
+ }
200
+
201
+ get text() {
202
+ return [this.reader, this.goal, ...this.facts, this.surface.text].filter(Boolean).join("\n");
203
+ }
204
+
205
+ get digest() {
206
+ const encoded = {
207
+ ...(Object.keys(this.editPolicy).length ? {editPolicy: this.editPolicy} : {}),
208
+ facts: this.facts,
209
+ goal: this.goal,
210
+ ...(this.lockedFacts.length ? {lockedFacts: this.lockedFacts} : {}),
211
+ outline: { headings: this.outline.headings, level: this.outline.level },
212
+ reader: this.reader,
213
+ surface: {
214
+ code: this.surface.code,
215
+ links: this.surface.links,
216
+ numbers: this.surface.numbers,
217
+ urls: this.surface.urls,
218
+ },
219
+ version: this.version,
220
+ };
221
+ return digest(JSON.stringify(encoded));
222
+ }
223
+
224
+ asDict() {
225
+ return {
226
+ version: this.version,
227
+ reader: this.reader,
228
+ goal: this.goal,
229
+ facts: [...this.facts],
230
+ surface: this.surface.asDict(),
231
+ outline: this.outline.asDict(),
232
+ ...(this.lockedFacts.length ? {lockedFacts: [...this.lockedFacts]} : {}),
233
+ ...(Object.keys(this.editPolicy).length ? {editPolicy: this.editPolicy} : {}),
234
+ };
235
+ }
236
+ }
237
+
238
+ /** @param {unknown} raw */
239
+ export function parseContract(raw) {
240
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("reader contract 는 JSON 객체다");
241
+ const version = /** @type {Record<string, unknown>} */ (raw).version;
242
+ if (version === CONTRACT_VERSION) return Contract.fromMapping(raw);
243
+ if (version === LATEST_CONTRACT_VERSION) return ContractV2.fromMapping(raw);
244
+ throw new Error(`reader contract version 은 ${CONTRACT_VERSIONS.join(",")} 가운데 하나다: ${version}`);
245
+ }
@@ -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
  // 서사 글에서만 끄는 것. 설명글의 전제 (독자가 문장 사이 이유를 따라간다, 말끝을 맺는다) 가 장면과
@@ -38,6 +41,74 @@ PRESETS.guide = [...PRESETS.blog, ...REFERENCE];
38
41
  PRESETS.essay = [...PRESETS.report, ...NARRATIVE];
39
42
  PRESETS.fiction = [...PRESETS.report, ...NARRATIVE];
40
43
  PRESETS.encyclopedia = [...PRESETS.docs, ...ENCYCLOPEDIC];
44
+ // 대화 답변에서 끄는 것. 글의 짜임을 재는 규칙은 글 한 편을 전제한다. 파이썬 settings.py 의 CONVERSATION 과 같다.
45
+ const CONVERSATION = [
46
+ "countMismatch",
47
+ "duplicateBlock",
48
+ "factListParagraph",
49
+ "firstResultDistance",
50
+ "headingSentence",
51
+ "headingSkip",
52
+ "headingUniform",
53
+ "inputFileSource",
54
+ "installImport",
55
+ "introLong",
56
+ "keywordMissing",
57
+ "noQuestion",
58
+ "outsideProfile",
59
+ "paraFragment",
60
+ "platformApi",
61
+ "promiseRecall",
62
+ "sectionNoProse",
63
+ ];
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"];
41
112
 
42
113
  /** 프리셋 → 견줄 프로파일의 종류. 정본은 파이썬 config/settings.py 의 PROFILE_OF 다. @type {Record<string, string>} */
43
114
  export const PROFILE_OF = {
@@ -48,6 +119,8 @@ export const PROFILE_OF = {
48
119
  essay: "essay",
49
120
  fiction: "fiction",
50
121
  encyclopedia: "encyclopedia",
122
+ chat: null,
123
+ screen: null,
51
124
  };
52
125
 
53
126
  export const PRESET_NAMES = Object.keys(PRESETS);
@@ -57,6 +130,7 @@ export const DEFAULT_PRESET = PRESET_NAMES[0];
57
130
  /**
58
131
  * @typedef {object} Config
59
132
  * @property {string} preset 글의 종류. PRESETS 가 정한 규칙을 처음부터 끈다
133
+ * @property {string[]} enforceStyle
60
134
  * @property {Set<string>} disable 끌 규칙 이름
61
135
  * @property {string | null} keywordField
62
136
  * @property {string[]} introFields
@@ -96,6 +170,7 @@ export function defaultConfig() {
96
170
  return {
97
171
  preset: DEFAULT_PRESET,
98
172
  disable: new Set(),
173
+ enforceStyle: [],
99
174
  keywordField: null,
100
175
  introFields: [],
101
176
  endingFields: [],
@@ -133,12 +208,12 @@ export function defaultConfig() {
133
208
 
134
209
  /** @param {Config} config @param {string} ruleName */
135
210
  export function enabled(config, ruleName) {
136
- return !config.disable.has(ruleName) && !PRESETS[config.preset].includes(ruleName);
211
+ return !config.disable.has(ruleName) && (!PRESETS[config.preset].includes(ruleName) || config.enforceStyle.includes(ruleName));
137
212
  }
138
213
 
139
214
  /** 지금 꺼져 있는 규칙 이름. 프리셋이 끈 것과 disable 이 끈 것을 합친다. @param {Config} config */
140
215
  export function offRules(config) {
141
- return [...new Set([...PRESETS[config.preset], ...config.disable])].sort();
216
+ return [...new Set([...PRESETS[config.preset].filter((name) => !config.enforceStyle.includes(name)), ...config.disable])].sort();
142
217
  }
143
218
 
144
219
  /** @param {Record<string, unknown>} data @returns {Config} */
@@ -155,6 +230,11 @@ export function configFromMapping(data) {
155
230
  throw new Error(`preset 은 ${PRESET_NAMES.join(", ")} 가운데 하나다: ${JSON.stringify(value)}`);
156
231
  }
157
232
  config.preset = /** @type {string} */ (value);
233
+ } else if (key === "enforceStyle") {
234
+ if (!Array.isArray(value) || value.some((name) => !ENFORCEABLE.includes(name))) {
235
+ throw new Error(`enforceStyle 은 ${ENFORCEABLE.join(", ")}의 배열이다`);
236
+ }
237
+ config.enforceStyle = [...value];
158
238
  } else if (key === "dictionary") {
159
239
  config.dictionary = { .../** @type {Record<string, unknown[]>} */ (value) };
160
240
  } else if (key === "exemplars") {
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
 
@@ -36,6 +36,10 @@ export const TYPE_LABELS = {
36
36
  * @property {number} sentences
37
37
  * @property {number} paragraphs
38
38
  * @property {Map<string, Histogram>} sentence
39
+ * @property {Map<string, Histogram>} paragraph
40
+ * @property {Histogram | null} endingRuns
41
+ * @property {Record<string, number>} endingTransitions
42
+ * @property {Record<string, Record<string, number>>} rates
39
43
  * @property {string} label
40
44
  */
41
45
 
@@ -62,6 +66,10 @@ function profileFromDict(kind, data) {
62
66
  for (const [name, value] of Object.entries(/** @type {Record<string, Record<string, unknown>>} */ (data.sentence))) {
63
67
  sentence.set(name, histogramFromDict(value));
64
68
  }
69
+ const paragraph = new Map();
70
+ for (const [name, value] of Object.entries(/** @type {Record<string, Record<string, unknown>>} */ (data.paragraph))) {
71
+ paragraph.set(name, histogramFromDict(value));
72
+ }
65
73
  const documents = Number(data.documents);
66
74
  return {
67
75
  kind,
@@ -69,6 +77,10 @@ function profileFromDict(kind, data) {
69
77
  sentences: Number(data.sentences),
70
78
  paragraphs: Number(data.paragraphs),
71
79
  sentence,
80
+ paragraph,
81
+ endingRuns: data.endingRuns ? histogramFromDict(/** @type {Record<string, unknown>} */ (data.endingRuns)) : null,
82
+ endingTransitions: { .../** @type {Record<string, number>} */ (data.endingTransitions ?? {}) },
83
+ rates: { .../** @type {Record<string, Record<string, number>>} */ (data.rates ?? {}) },
72
84
  label: TYPE_LABELS[kind] ?? `참조 글 ${documents}편`,
73
85
  };
74
86
  }
@@ -91,7 +103,7 @@ const users = new Map();
91
103
  export function userProfile(path) {
92
104
  let profile = users.get(path);
93
105
  if (!profile) {
94
- const data = JSON.parse(readFileSync(path, "utf-8"));
106
+ const data = JSON.parse(readExternal(path));
95
107
  if (data.version !== PROFILE_VERSION || !("profile" in data)) {
96
108
  throw new Error(`프로파일 파일이 아니다: ${path}. hanlint profile build 로 다시 만든다`);
97
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
+ }
@@ -13,7 +13,7 @@ const READS = new RegExp(
13
13
  const OPENS = new RegExp("\\bopen" + QUOTED + "\\s*(?:,\\s*(?:mode\\s*=\\s*)?[\"']([^\"']*)[\"'])?", "g");
14
14
  const WRITES = new RegExp(
15
15
  "\\b(?:to_csv|to_excel|to_parquet|to_json|to_sql|write_csv|write_parquet|write_json|write_ndjson|sink_parquet|sink_csv|" +
16
- "write_text|write_bytes|savefig|save|imwrite|savetxt|dump)" + QUOTED,
16
+ "write_text|write_bytes|savefig|save|imwrite|savetxt|dump|ExcelWriter)" + QUOTED,
17
17
  "g",
18
18
  );
19
19
  const SHELL_WRITES = /(?:>>?|-o|-O|--output|Out-File)\s*["']?([\w./\\-]+\.[A-Za-z0-9]+)/g;
@@ -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 };