hanlint 0.0.7 → 0.0.8
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/LICENSE +21 -0
- package/README.md +23 -5
- package/data/adjectiveStems.txt +118 -0
- package/data/baselineAnchors.txt +3 -0
- package/data/blindEvaluation.schema.json +26 -0
- package/data/bridgeOpeners.txt +10 -0
- package/data/candidateActionNouns.txt +39 -0
- package/data/candidateSplitEndings.txt +13 -0
- package/data/cliches.json +1 -1
- package/data/confusable.json +2 -2
- package/data/deixis.txt +10 -3
- package/data/draftHistory.txt +10 -0
- package/data/easyWords.json +0 -42
- package/data/enoughMarkers.txt +6 -0
- package/data/entailmentPredictions.schema.json +52 -0
- package/data/euiNouns.txt +3 -4
- package/data/evidenceEntailmentBenchmark.schema.json +126 -0
- package/data/exemplars.json +347 -0
- package/data/holeKinds.json +1 -2
- package/data/iVerbStems.txt +26 -0
- package/data/inNouns.txt +147 -0
- package/data/irregularStems.txt +64 -0
- package/data/nonNouns.txt +153 -0
- package/data/panelAssignmentReview.schema.json +117 -0
- package/data/panelJudgePredictions.schema.json +78 -0
- package/data/panelReviewBatch.schema.json +79 -0
- package/data/panelTrialSet.schema.json +44 -0
- package/data/patterns.json +114 -0
- package/data/profiles.json +1 -0
- package/data/readerCalls.txt +12 -7
- package/data/redundantPair.json +1 -1
- package/data/rieulStems.txt +30 -0
- package/data/ruleCategories.json +56 -0
- package/data/ruleDocs.json +29 -17
- package/data/ruleMechanisms.json +56 -0
- package/data/spacing.json +0 -12
- package/data/spelling.json +6 -24
- package/data/translationese.json +10 -0
- package/data/verbTails.txt +17 -0
- package/data/version.json +1 -1
- package/data/writingBrief.schema.json +64 -0
- package/data/writingBriefV2.schema.json +116 -0
- package/data/writingTrial.schema.json +36 -0
- package/package.json +13 -5
- package/src/analysis/grammar/ending.js +336 -0
- package/src/analysis/grammar/hangul.js +94 -0
- package/src/analysis/grammar/index.js +5 -0
- package/src/analysis/grammar/josa.js +73 -0
- package/src/analysis/grammar/register.js +115 -0
- package/src/analysis/grammar/voice.js +43 -0
- package/src/analysis/index.js +4 -31
- package/src/analysis/josa.js +2 -0
- package/src/analysis/{surface/splitSentences.js → splitSentences.js} +1 -1
- package/src/analysis/{surface/tokenize.js → tokenize.js} +70 -13
- package/src/baseline/store.js +114 -0
- package/src/cli/main.js +506 -58
- package/src/cli/walk.js +30 -0
- package/src/cli/welcome.js +52 -0
- package/src/config/settings.js +103 -8
- package/src/data/exemplars.js +177 -0
- package/src/data/load.js +10 -0
- package/src/data/operations.js +278 -0
- package/src/data/patches.js +125 -0
- package/src/data/patterns.js +48 -0
- package/src/data/profiles.js +102 -0
- package/src/document/model.js +2 -0
- package/src/document/parseMarkdown.js +84 -11
- package/src/edit/applyFixes.js +0 -0
- package/src/fingerprint/build.js +50 -32
- package/src/{rules/shared → fingerprint}/codeBlocks.js +11 -11
- package/src/fingerprint/codeMarkers.js +73 -0
- package/src/fingerprint/markers.js +9 -0
- package/src/fingerprint/readerState.js +126 -0
- package/src/fingerprint/sourceSentence.js +37 -0
- package/src/fingerprint/topics.js +1 -1
- package/src/index.js +2 -16
- package/src/report/compactReport.js +6 -1
- package/src/report/fingerprintJson.js +8 -3
- package/src/report/jsonReport.js +50 -3
- package/src/report/operationMatch.js +31 -0
- package/src/report/patchMatch.js +44 -0
- package/src/report/registerMatch.js +40 -0
- package/src/report/textReport.js +41 -5
- package/src/rules/code/duplicateBlock.js +4 -3
- package/src/rules/code/firstResultDistance.js +1 -0
- package/src/rules/code/inputFileSource.js +23 -68
- package/src/rules/code/installImport.js +4 -4
- package/src/rules/code/platformApi.js +7 -6
- package/src/rules/document/countMismatch.js +22 -6
- package/src/rules/document/enoughOnce.js +25 -0
- package/src/rules/document/fieldEcho.js +38 -0
- package/src/rules/document/keywordHeading.js +27 -0
- package/src/rules/document/keywordMissing.js +1 -0
- package/src/rules/document/noQuestion.js +5 -1
- package/src/rules/document/promiseRecall.js +4 -2
- package/src/rules/document/tableOddCell.js +60 -0
- package/src/rules/finding.js +24 -2
- package/src/rules/orthography/confusable.js +1 -0
- package/src/rules/orthography/spacing.js +1 -0
- package/src/rules/orthography/spelling.js +1 -0
- package/src/rules/paragraph/factListParagraph.js +1 -0
- package/src/rules/paragraph/paraFragment.js +21 -11
- package/src/rules/paragraph/topicBreak.js +1 -0
- package/src/rules/registry.js +72 -3
- package/src/rules/sentence/cliche.js +1 -0
- package/src/rules/sentence/connectorRepeat.js +8 -7
- package/src/rules/sentence/danglingDeixis.js +8 -3
- package/src/rules/sentence/dash.js +2 -1
- package/src/rules/sentence/deixis.js +7 -3
- package/src/rules/sentence/doubleNegative.js +4 -1
- package/src/rules/sentence/doublePassive.js +38 -1
- package/src/rules/sentence/draftHistory.js +24 -0
- package/src/rules/sentence/ellipsis.js +17 -3
- package/src/rules/sentence/endingRepeat.js +6 -2
- package/src/rules/sentence/euiChain.js +3 -2
- package/src/rules/sentence/fillerOpener.js +1 -0
- package/src/rules/sentence/hardWord.js +1 -0
- package/src/rules/sentence/imperativePeriod.js +2 -1
- package/src/rules/sentence/japaneseLoan.js +1 -0
- package/src/rules/sentence/longSentence.js +3 -1
- package/src/rules/sentence/negationRedefine.js +10 -1
- package/src/rules/sentence/nounPile.js +1 -0
- package/src/rules/sentence/numberOrphan.js +45 -0
- package/src/rules/sentence/outsideProfile.js +68 -0
- package/src/rules/sentence/redundantPair.js +1 -0
- package/src/rules/sentence/translationese.js +1 -0
- package/src/rules/shared/candidates.js +129 -0
- package/src/rules/shared/dictionaryRule.js +12 -2
- package/src/rules/shared/localAntecedent.js +12 -0
- package/src/rules/shared/repeat.js +48 -0
- package/src/rules/structure/blockUnread.js +33 -0
- package/src/rules/structure/bridgeRepeat.js +40 -0
- package/src/rules/structure/emojiBullet.js +1 -0
- package/src/rules/structure/headingQuestion.js +19 -0
- package/src/rules/structure/headingSentence.js +9 -4
- package/src/rules/structure/headingSkip.js +1 -0
- package/src/rules/structure/headingUniform.js +9 -17
- package/src/rules/structure/introImage.js +19 -0
- package/src/rules/structure/introLong.js +1 -0
- package/src/rules/structure/loneSubheading.js +42 -0
- package/src/rules/structure/moreLater.js +48 -0
- package/src/rules/structure/sectionNoProse.js +1 -0
- package/src/rules/structure/sectionResult.js +3 -2
- package/src/text.js +19 -0
- package/src/rules/document/readerAbsent.js +0 -11
- package/src/rules/shared/runs.js +0 -22
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/** 승인 고침에서 뜻을 추측하지 않고 재사용할 수 있는 표면 치환. 파이썬 data/operations.py와 같다. */
|
|
3
|
+
|
|
4
|
+
const URL_PATTERN = /https?:\/\/[^\s)>\]]*[\p{L}\p{N}_/#=%&+~-]/gu;
|
|
5
|
+
const NUMBER_PATTERN = /(?<![A-Za-z가-힣])[-+]?\d+(?:[.,:]\d+)*(?:%|[가-힣]+)?/gu;
|
|
6
|
+
const LATIN_PATTERN = /(?<![A-Za-z0-9_])(?:[A-Za-z_][A-Za-z0-9_.:/<>-]*)(?![A-Za-z0-9_])/gu;
|
|
7
|
+
// 파이썬 `re` 의 `\w` 는 유니코드라 한글을 담지만 JS 의 `\w` 는 /u 를 붙여도 ASCII 뿐이다. 그대로 두면
|
|
8
|
+
// docs/렌더/index.md 를 파이썬만 경로 원자로 보고 npm 은 그 안을 치환해 버린다 (말뭉치 실문장
|
|
9
|
+
// 32,274개에서 280건이 갈렸다). URL_PATTERN 이 이미 쓰는 표기로 맞춘다.
|
|
10
|
+
const WORD = "[\\p{L}\\p{N}_]";
|
|
11
|
+
const PATH_PATTERN = new RegExp(
|
|
12
|
+
`(?<!${WORD})(?:[\\p{L}\\p{N}_.-]+/)+[\\p{L}\\p{N}_.-]+|(?<!${WORD})[\\p{L}\\p{N}_-]+\\.[A-Za-z0-9]{1,8}(?!${WORD})`,
|
|
13
|
+
"gu",
|
|
14
|
+
);
|
|
15
|
+
const INLINE_CODE_PATTERN = /`([^`\n]+)`/gu;
|
|
16
|
+
// d 플래그로 진짜 그룹 시작을 읽는다. indexOf 로 찾으면 [렌더](렌더) 처럼 링크 글자와 목적지가 같을 때
|
|
17
|
+
// 링크 글자 쪽을 목적지로 착각한다. 파이썬은 match.start(1) 을 쓴다.
|
|
18
|
+
const LINK_DESTINATION_PATTERN = /\[[^\u005D]*\]\(([^)]+)\)/gdu;
|
|
19
|
+
const HTML_TAG_PATTERN = /<[^>\n]+>/gu;
|
|
20
|
+
const DEICTIC_FRAGMENT = /이것|그것|저것|이러한|그러한|해당|이는|그는|그녀|그들/u;
|
|
21
|
+
const SURFACE_CHARACTER = /[가-힣A-Za-z0-9]/gu;
|
|
22
|
+
const BOUNDARY = new Set(` \t\r\n,.;:!?()[]{}<>"'“”‘’…·/\\|+=*`);
|
|
23
|
+
export const MAX_FRAGMENT_CHARACTERS = 32;
|
|
24
|
+
export const MAX_SURFACE_EDIT_DISTANCE = 1;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @typedef {object} SurfaceOperation
|
|
28
|
+
* @property {string} before
|
|
29
|
+
* @property {string} after
|
|
30
|
+
* @property {string[]} presets
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/** @param {string} text */
|
|
34
|
+
export function protectedAtoms(text) {
|
|
35
|
+
const found = [];
|
|
36
|
+
for (const [pattern, label, group] of [
|
|
37
|
+
[URL_PATTERN, "url", 0],
|
|
38
|
+
[NUMBER_PATTERN, "number", 0],
|
|
39
|
+
[LATIN_PATTERN, "latin", 0],
|
|
40
|
+
[PATH_PATTERN, "path", 0],
|
|
41
|
+
[INLINE_CODE_PATTERN, "code", 1],
|
|
42
|
+
[LINK_DESTINATION_PATTERN, "link", 1],
|
|
43
|
+
]) {
|
|
44
|
+
for (const match of text.matchAll(/** @type {RegExp} */ (pattern))) {
|
|
45
|
+
found.push(`${label}:${match[/** @type {number} */ (group)]}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return found.sort();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** @param {string} text */
|
|
52
|
+
export function protectedSpans(text) {
|
|
53
|
+
const spans = [];
|
|
54
|
+
for (const pattern of [INLINE_CODE_PATTERN, URL_PATTERN, HTML_TAG_PATTERN]) {
|
|
55
|
+
for (const match of text.matchAll(pattern)) spans.push([match.index, match.index + match[0].length]);
|
|
56
|
+
}
|
|
57
|
+
for (const match of text.matchAll(LINK_DESTINATION_PATTERN)) {
|
|
58
|
+
const [start, end] = /** @type {RegExpIndicesArray} */ (match.indices)[1];
|
|
59
|
+
spans.push([start, end]);
|
|
60
|
+
}
|
|
61
|
+
// 파이썬은 sorted(set(spans)) 라 겹친 구간을 한 번만 둔다 (코드 안의 URL 처럼).
|
|
62
|
+
const unique = [...new Map(spans.map((span) => [`${span[0]}:${span[1]}`, span])).values()];
|
|
63
|
+
return unique.sort((left, right) => left[0] - right[0] || left[1] - right[1]);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** @param {string} text @param {string[]} terms */
|
|
67
|
+
export function protectedTermAtoms(text, terms) {
|
|
68
|
+
const found = [];
|
|
69
|
+
for (const term of terms) {
|
|
70
|
+
let cursor = 0;
|
|
71
|
+
while (term) {
|
|
72
|
+
const at = text.indexOf(term, cursor);
|
|
73
|
+
if (at < 0) break;
|
|
74
|
+
found.push(term);
|
|
75
|
+
cursor = at + term.length;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return found.sort();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** @param {string} text @param {number} at */
|
|
82
|
+
function startBoundary(text, at) {
|
|
83
|
+
while (at > 0 && !BOUNDARY.has(text[at - 1])) at -= 1;
|
|
84
|
+
return at;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** @param {string} text @param {number} at */
|
|
88
|
+
function endBoundary(text, at) {
|
|
89
|
+
while (at < text.length && !BOUNDARY.has(text[at])) at += 1;
|
|
90
|
+
return at;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** @param {string} before @param {string} after */
|
|
94
|
+
export function changedFragment(before, after) {
|
|
95
|
+
let prefix = 0;
|
|
96
|
+
const limit = Math.min(before.length, after.length);
|
|
97
|
+
while (prefix < limit && before[prefix] === after[prefix]) prefix += 1;
|
|
98
|
+
let suffix = 0;
|
|
99
|
+
const remaining = Math.min(before.length - prefix, after.length - prefix);
|
|
100
|
+
while (suffix < remaining && before[before.length - suffix - 1] === after[after.length - suffix - 1]) suffix += 1;
|
|
101
|
+
const start = startBoundary(before, prefix);
|
|
102
|
+
const beforeEnd = endBoundary(before, before.length - suffix);
|
|
103
|
+
const afterEnd = endBoundary(after, after.length - suffix);
|
|
104
|
+
return [before.slice(start, beforeEnd).trim(), after.slice(start, afterEnd).trim()];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** @param {string} left @param {string} right */
|
|
108
|
+
export function editDistance(left, right) {
|
|
109
|
+
let previous = Array.from({ length: right.length + 1 }, (_, index) => index);
|
|
110
|
+
for (let leftIndex = 1; leftIndex <= left.length; leftIndex += 1) {
|
|
111
|
+
const current = [leftIndex];
|
|
112
|
+
for (let rightIndex = 1; rightIndex <= right.length; rightIndex += 1) {
|
|
113
|
+
current.push(
|
|
114
|
+
Math.min(
|
|
115
|
+
current[current.length - 1] + 1,
|
|
116
|
+
previous[rightIndex] + 1,
|
|
117
|
+
previous[rightIndex - 1] + Number(left[leftIndex - 1] !== right[rightIndex - 1]),
|
|
118
|
+
),
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
previous = current;
|
|
122
|
+
}
|
|
123
|
+
return previous[previous.length - 1];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** @param {string} text */
|
|
127
|
+
export function surfaceSkeleton(text) {
|
|
128
|
+
return [...text.matchAll(SURFACE_CHARACTER)].map((match) => match[0]).join("");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* 승인 전후 전체에서 기계가 보증할 수 있는 표면 치환 하나를 추출한다.
|
|
133
|
+
* @param {string} before @param {string} after @param {string[]} [presets] @param {string[]} [protectedTerms]
|
|
134
|
+
* @returns {SurfaceOperation | undefined}
|
|
135
|
+
*/
|
|
136
|
+
export function operationFromApproval(before, after, presets = [], protectedTerms = []) {
|
|
137
|
+
before = before.normalize("NFC");
|
|
138
|
+
after = after.normalize("NFC");
|
|
139
|
+
if (
|
|
140
|
+
JSON.stringify(protectedAtoms(before)) !== JSON.stringify(protectedAtoms(after)) ||
|
|
141
|
+
JSON.stringify(protectedTermAtoms(before, protectedTerms)) !== JSON.stringify(protectedTermAtoms(after, protectedTerms))
|
|
142
|
+
) {
|
|
143
|
+
return undefined;
|
|
144
|
+
}
|
|
145
|
+
const [beforeFragment, afterFragment] = changedFragment(before, after);
|
|
146
|
+
const beforeSkeleton = surfaceSkeleton(beforeFragment);
|
|
147
|
+
const afterSkeleton = surfaceSkeleton(afterFragment);
|
|
148
|
+
if (
|
|
149
|
+
!beforeFragment ||
|
|
150
|
+
!afterFragment ||
|
|
151
|
+
beforeFragment === afterFragment ||
|
|
152
|
+
beforeFragment.length > MAX_FRAGMENT_CHARACTERS ||
|
|
153
|
+
afterFragment.length > MAX_FRAGMENT_CHARACTERS ||
|
|
154
|
+
beforeSkeleton.length < 2 ||
|
|
155
|
+
afterSkeleton.length < 2 ||
|
|
156
|
+
editDistance(beforeSkeleton, afterSkeleton) > MAX_SURFACE_EDIT_DISTANCE ||
|
|
157
|
+
DEICTIC_FRAGMENT.test(beforeFragment) ||
|
|
158
|
+
DEICTIC_FRAGMENT.test(afterFragment) ||
|
|
159
|
+
protectedTerms.some((term) => beforeFragment.includes(term) || afterFragment.includes(term)) ||
|
|
160
|
+
protectedAtoms(beforeFragment).length ||
|
|
161
|
+
protectedAtoms(afterFragment).length ||
|
|
162
|
+
before.split(beforeFragment).length - 1 !== 1
|
|
163
|
+
) {
|
|
164
|
+
return undefined;
|
|
165
|
+
}
|
|
166
|
+
return { before: beforeFragment, after: afterFragment, presets: [...presets] };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** @param {unknown} entries @param {string[]} presetNames @returns {SurfaceOperation[]} */
|
|
170
|
+
export function projectOperations(entries, presetNames) {
|
|
171
|
+
if (!Array.isArray(entries)) throw new Error("operations 는 [[operations]] 배열이다");
|
|
172
|
+
const knownPresets = new Set(presetNames);
|
|
173
|
+
const selectors = new Set();
|
|
174
|
+
return entries.map((raw, offset) => {
|
|
175
|
+
const index = offset + 1;
|
|
176
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error(`operations ${index}번째 항목은 표다`);
|
|
177
|
+
const entry = /** @type {Record<string, unknown>} */ (raw);
|
|
178
|
+
const unknown = Object.keys(entry).filter((key) => !["before", "after", "presets"].includes(key)).sort();
|
|
179
|
+
if (unknown.length) throw new Error(`operations ${index}번째 항목의 모르는 키: ${unknown.join(", ")}`);
|
|
180
|
+
const before = entry.before;
|
|
181
|
+
const after = entry.after;
|
|
182
|
+
if (typeof before !== "string" || !before.trim() || before !== before.trim()) {
|
|
183
|
+
throw new Error(`operations ${index}번째 항목의 before 는 양끝 공백 없는 문자열이다`);
|
|
184
|
+
}
|
|
185
|
+
if (typeof after !== "string" || !after.trim() || after !== after.trim()) {
|
|
186
|
+
throw new Error(`operations ${index}번째 항목의 after 는 양끝 공백 없는 문자열이다`);
|
|
187
|
+
}
|
|
188
|
+
const rawPresets = entry.presets;
|
|
189
|
+
if (!Array.isArray(rawPresets) || !rawPresets.length || !rawPresets.every((item) => typeof item === "string")) {
|
|
190
|
+
throw new Error(`operations ${index}번째 항목의 presets 는 비지 않은 문자열 배열이다`);
|
|
191
|
+
}
|
|
192
|
+
const presets = /** @type {string[]} */ (rawPresets);
|
|
193
|
+
const unknownPresets = [...new Set(presets.filter((preset) => !knownPresets.has(preset)))].sort();
|
|
194
|
+
if (unknownPresets.length) throw new Error(`operations ${index}번째 항목의 모르는 프리셋: ${unknownPresets.join(", ")}`);
|
|
195
|
+
if (new Set(presets).size !== presets.length) throw new Error(`operation의 프리셋이 겹친다: ${before}`);
|
|
196
|
+
const operation = operationFromApproval(before, after, presets);
|
|
197
|
+
if (!operation || operation.before !== before || operation.after !== after) {
|
|
198
|
+
throw new Error(`operations ${index}번째 항목은 안전한 표면 치환이 아니다: ${before} -> ${after}`);
|
|
199
|
+
}
|
|
200
|
+
for (const preset of presets) {
|
|
201
|
+
const selector = JSON.stringify([preset, before]);
|
|
202
|
+
if (selectors.has(selector)) throw new Error(`operation 선택 조건이 겹친다: ${preset} ${before}`);
|
|
203
|
+
selectors.add(selector);
|
|
204
|
+
}
|
|
205
|
+
return operation;
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** @param {string} character */
|
|
210
|
+
function wordCharacter(character) {
|
|
211
|
+
return Boolean(character) && /[\p{L}\p{N}_]/u.test(character);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** @param {string} text @param {SurfaceOperation} operation @param {string[]} [protectedTerms] */
|
|
215
|
+
export function operationPositions(text, operation, protectedTerms = []) {
|
|
216
|
+
const positions = [];
|
|
217
|
+
const blocked = protectedSpans(text);
|
|
218
|
+
for (const term of protectedTerms) {
|
|
219
|
+
let cursor = 0;
|
|
220
|
+
while (term) {
|
|
221
|
+
const at = text.indexOf(term, cursor);
|
|
222
|
+
if (at < 0) break;
|
|
223
|
+
blocked.push([at, at + term.length]);
|
|
224
|
+
cursor = at + term.length;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
let cursor = 0;
|
|
228
|
+
while (true) {
|
|
229
|
+
const at = text.indexOf(operation.before, cursor);
|
|
230
|
+
if (at < 0) return positions;
|
|
231
|
+
const end = at + operation.before.length;
|
|
232
|
+
const left = at > 0 ? text[at - 1] : "";
|
|
233
|
+
const right = end < text.length ? text[end] : "";
|
|
234
|
+
const touchesWord =
|
|
235
|
+
(wordCharacter(operation.before[0]) && wordCharacter(left)) ||
|
|
236
|
+
(wordCharacter(operation.before[operation.before.length - 1]) && wordCharacter(right));
|
|
237
|
+
const insideProtected = blocked.some(([start, stop]) => at < stop && end > start);
|
|
238
|
+
if (!touchesWord && !insideProtected) positions.push(at);
|
|
239
|
+
cursor = at + 1;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** @param {string} text @param {SurfaceOperation} operation @param {string[]} [protectedTerms] */
|
|
244
|
+
export function applyOperation(text, operation, protectedTerms = []) {
|
|
245
|
+
const positions = operationPositions(text, operation, protectedTerms);
|
|
246
|
+
if (positions.length !== 1) return undefined;
|
|
247
|
+
const at = positions[0];
|
|
248
|
+
const end = at + operation.before.length;
|
|
249
|
+
const changed = text.slice(0, at) + operation.after + text.slice(end);
|
|
250
|
+
const factsPreserved = JSON.stringify(protectedAtoms(text)) === JSON.stringify(protectedAtoms(changed));
|
|
251
|
+
const termsPreserved =
|
|
252
|
+
JSON.stringify(protectedTermAtoms(text, protectedTerms)) === JSON.stringify(protectedTermAtoms(changed, protectedTerms));
|
|
253
|
+
return factsPreserved && termsPreserved ? changed : undefined;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** @param {string} sourceText @param {string | null | undefined} preset @param {SurfaceOperation[]} operations @param {string[]} [protectedTerms] */
|
|
257
|
+
export function operationFor(sourceText, preset, operations, protectedTerms = []) {
|
|
258
|
+
if (!preset) return undefined;
|
|
259
|
+
const matches = [];
|
|
260
|
+
for (const operation of operations) {
|
|
261
|
+
if (!operation.presets.includes(preset)) continue;
|
|
262
|
+
const result = applyOperation(sourceText, operation, protectedTerms);
|
|
263
|
+
if (result !== undefined) matches.push({ operation, sourceText, result });
|
|
264
|
+
}
|
|
265
|
+
return matches.length === 1 ? matches[0] : undefined;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** @param {{operation: SurfaceOperation, sourceText: string, result: string}} applied @param {string} preset */
|
|
269
|
+
export function operationData(applied, preset) {
|
|
270
|
+
return {
|
|
271
|
+
kind: "surfaceSubstitution",
|
|
272
|
+
before: applied.operation.before,
|
|
273
|
+
after: applied.operation.after,
|
|
274
|
+
sourceText: applied.sourceText,
|
|
275
|
+
result: applied.result,
|
|
276
|
+
match: { preset, unique: true, wordBoundary: true, protectedFacts: true },
|
|
277
|
+
};
|
|
278
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/** 글쓴이가 승인한 원문 완전 일치 고침. 파이썬 data/patches.py와 같은 선택 계약이다. */
|
|
3
|
+
import { exemplars, expand } from "./exemplars.js";
|
|
4
|
+
|
|
5
|
+
export const READER_KINDS = ["recent", "known", "new", "none"];
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @typedef {object} Patch
|
|
9
|
+
* @property {string} rule
|
|
10
|
+
* @property {string} before
|
|
11
|
+
* @property {string} after
|
|
12
|
+
* @property {string} moved
|
|
13
|
+
* @property {string} cue
|
|
14
|
+
* @property {string} reader
|
|
15
|
+
* @property {string[]} presets
|
|
16
|
+
* @property {string} sentence 마크다운 표식을 걷은 선택용 원문
|
|
17
|
+
* @property {string} sourceText 마크다운 표식을 보존한 선택용 원문
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** 줄과 연속 공백 차이는 선택 조건으로 쓰지 않는다. @param {string} text */
|
|
21
|
+
export function flatCue(text) {
|
|
22
|
+
return text.split(/\s+/).filter(Boolean).join(" ");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** 승인 원문은 유니코드 조합과 줄, 연속 공백만 눕혀 비교한다. @param {string} text */
|
|
26
|
+
export function flatSentence(text) {
|
|
27
|
+
return text.normalize("NFC").split(/\s+/).filter(Boolean).join(" ");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** 설정의 `[[patches]]`를 검증한다. @param {unknown} entries @param {string[]} presetNames @returns {Patch[]} */
|
|
31
|
+
export function projectPatches(entries, presetNames) {
|
|
32
|
+
if (!Array.isArray(entries)) throw new Error("patches 는 [[patches]] 배열이다");
|
|
33
|
+
const allowedKeys = new Set(["rule", "before", "after", "moved", "sourceText", "sentence", "cue", "reader", "presets"]);
|
|
34
|
+
const knownRules = new Set(exemplars().keys());
|
|
35
|
+
const knownPresets = new Set(presetNames);
|
|
36
|
+
const selectors = new Set();
|
|
37
|
+
return entries.map((raw, offset) => {
|
|
38
|
+
const index = offset + 1;
|
|
39
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error(`patches ${index}번째 항목은 표다`);
|
|
40
|
+
const entry = /** @type {Record<string, unknown>} */ (raw);
|
|
41
|
+
const unknown = Object.keys(entry).filter((key) => !allowedKeys.has(key)).sort();
|
|
42
|
+
if (unknown.length) throw new Error(`patches ${index}번째 항목의 모르는 키: ${unknown.join(", ")}`);
|
|
43
|
+
/** @type {Record<string, string>} */
|
|
44
|
+
const values = {};
|
|
45
|
+
for (const key of ["rule", "before", "after", "moved", "cue", "reader"]) {
|
|
46
|
+
const value = entry[key];
|
|
47
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
48
|
+
throw new Error(`patches ${index}번째 항목의 ${key} 는 비지 않은 문자열이다`);
|
|
49
|
+
}
|
|
50
|
+
values[key] = value;
|
|
51
|
+
}
|
|
52
|
+
const rule = values.rule;
|
|
53
|
+
if (!knownRules.has(rule)) throw new Error(`patches ${index}번째 항목의 모르는 규칙: ${rule}`);
|
|
54
|
+
const reader = values.reader;
|
|
55
|
+
if (!READER_KINDS.includes(reader)) {
|
|
56
|
+
throw new Error(`patches ${index}번째 항목의 reader 는 ${READER_KINDS.join(", ")} 가운데 하나다: ${reader}`);
|
|
57
|
+
}
|
|
58
|
+
const rawPresets = entry.presets;
|
|
59
|
+
if (!Array.isArray(rawPresets) || !rawPresets.length || !rawPresets.every((item) => typeof item === "string")) {
|
|
60
|
+
throw new Error(`patches ${index}번째 항목의 presets 는 비지 않은 문자열 배열이다`);
|
|
61
|
+
}
|
|
62
|
+
const presets = /** @type {string[]} */ (rawPresets);
|
|
63
|
+
const unknownPresets = [...new Set(presets.filter((preset) => !knownPresets.has(preset)))].sort();
|
|
64
|
+
if (unknownPresets.length) {
|
|
65
|
+
throw new Error(`patches ${index}번째 항목의 모르는 프리셋: ${unknownPresets.join(", ")}`);
|
|
66
|
+
}
|
|
67
|
+
if (new Set(presets).size !== presets.length) throw new Error(`패치의 프리셋이 겹친다: ${rule}`);
|
|
68
|
+
const cue = flatCue(values.cue);
|
|
69
|
+
const before = expand(values.before);
|
|
70
|
+
const after = expand(values.after);
|
|
71
|
+
const rawSentence = entry.sentence ?? before;
|
|
72
|
+
if (typeof rawSentence !== "string" || !rawSentence.trim()) {
|
|
73
|
+
throw new Error(`patches ${index}번째 항목의 sentence 는 비지 않은 문자열이다`);
|
|
74
|
+
}
|
|
75
|
+
const sentence = flatSentence(rawSentence);
|
|
76
|
+
const rawSourceText = entry.sourceText ?? before;
|
|
77
|
+
if (typeof rawSourceText !== "string" || !rawSourceText.trim()) {
|
|
78
|
+
throw new Error(`patches ${index}번째 항목의 sourceText 는 비지 않은 문자열이다`);
|
|
79
|
+
}
|
|
80
|
+
const sourceText = flatSentence(rawSourceText);
|
|
81
|
+
for (const preset of presets) {
|
|
82
|
+
const selector = JSON.stringify([rule, preset, sourceText, sentence, cue, reader]);
|
|
83
|
+
if (selectors.has(selector)) throw new Error(`패치 선택 조건이 겹친다: ${rule} ${preset} ${cue} ${reader}`);
|
|
84
|
+
selectors.add(selector);
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
rule,
|
|
88
|
+
before,
|
|
89
|
+
after,
|
|
90
|
+
moved: values.moved,
|
|
91
|
+
cue,
|
|
92
|
+
reader,
|
|
93
|
+
presets,
|
|
94
|
+
sentence,
|
|
95
|
+
sourceText,
|
|
96
|
+
};
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* 원문을 포함한 모든 조건이 맞는 패치가 하나일 때만 돌려준다.
|
|
102
|
+
* @param {string} rule
|
|
103
|
+
* @param {string | null | undefined} preset
|
|
104
|
+
* @param {string} sourceText
|
|
105
|
+
* @param {string} sentence
|
|
106
|
+
* @param {string} cue
|
|
107
|
+
* @param {string | null | undefined} reader
|
|
108
|
+
* @param {Patch[]} patches
|
|
109
|
+
*/
|
|
110
|
+
export function patchFor(rule, preset, sourceText, sentence, cue, reader, patches) {
|
|
111
|
+
if (!preset || !reader || !READER_KINDS.includes(reader)) return undefined;
|
|
112
|
+
const wantedCue = flatCue(cue);
|
|
113
|
+
const wantedSourceText = flatSentence(sourceText);
|
|
114
|
+
const wantedSentence = flatSentence(sentence);
|
|
115
|
+
const matches = patches.filter(
|
|
116
|
+
(patch) =>
|
|
117
|
+
patch.rule === rule &&
|
|
118
|
+
patch.presets.includes(preset) &&
|
|
119
|
+
patch.sourceText === wantedSourceText &&
|
|
120
|
+
patch.sentence === wantedSentence &&
|
|
121
|
+
patch.cue === wantedCue &&
|
|
122
|
+
patch.reader === reader,
|
|
123
|
+
);
|
|
124
|
+
return matches.length === 1 ? matches[0] : undefined;
|
|
125
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* 문형. 빈칸이 있는 문장 틀. 파이썬 data/patterns.py 와 같다.
|
|
4
|
+
* 본보기가 고친 사례 하나라면 문형은 그 사례를 다시 쓸 수 있는 틀이다.
|
|
5
|
+
*/
|
|
6
|
+
import { loadEntries } from "./load.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @typedef {object} Pattern
|
|
10
|
+
* @property {string} name
|
|
11
|
+
* @property {string} form 빈칸이 있는 틀
|
|
12
|
+
* @property {string} when 이 틀을 꺼내는 자리
|
|
13
|
+
* @property {string} example 그 틀로 쓴 문장. hanlint 를 통과한다
|
|
14
|
+
* @property {string} instead 같은 자리에 흔히 쓰는 문장. avoids 의 규칙에 잡힌다
|
|
15
|
+
* @property {string[]} avoids
|
|
16
|
+
* @property {string} source
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** @type {Pattern[] | null} */
|
|
20
|
+
let cache = null;
|
|
21
|
+
|
|
22
|
+
/** @returns {Pattern[]} */
|
|
23
|
+
export function patterns() {
|
|
24
|
+
if (!cache) {
|
|
25
|
+
cache = [];
|
|
26
|
+
const seen = new Set();
|
|
27
|
+
for (const entry of loadEntries("patterns.json")) {
|
|
28
|
+
const name = /** @type {string} */ (entry.name);
|
|
29
|
+
if (seen.has(name)) throw new Error(`문형 이름이 겹친다: ${name}`);
|
|
30
|
+
seen.add(name);
|
|
31
|
+
cache.push({
|
|
32
|
+
name,
|
|
33
|
+
form: /** @type {string} */ (entry.form),
|
|
34
|
+
when: /** @type {string} */ (entry.when),
|
|
35
|
+
example: /** @type {string} */ (entry.example),
|
|
36
|
+
instead: /** @type {string} */ (entry.instead),
|
|
37
|
+
avoids: /** @type {string[]} */ (entry.avoids),
|
|
38
|
+
source: /** @type {string} */ (entry.source),
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return cache;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** 그 규칙을 피하는 문형들. @param {string} rule */
|
|
46
|
+
export function patternsAvoiding(rule) {
|
|
47
|
+
return patterns().filter((pattern) => pattern.avoids.includes(rule));
|
|
48
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* 참조 분포 (프로파일) 의 자료형과 읽기. 파이썬 data/profiles.py 와 같다. 종류별 프로파일은 profiles.json (투영) 에서,
|
|
4
|
+
* 사용자 프로파일은 `hanlint profile build` 가 만든 파일에서 읽는다. 히스토그램은 정확한 계수라 두 판의 셈이 같다.
|
|
5
|
+
*/
|
|
6
|
+
import { readFileSync } from "node:fs";
|
|
7
|
+
|
|
8
|
+
import { readText } from "./load.js";
|
|
9
|
+
|
|
10
|
+
export const PROFILE_VERSION = 1;
|
|
11
|
+
/** 히스토그램의 상한. 이 값보다 큰 것은 상한에 합친다. */
|
|
12
|
+
export const CAP = 200;
|
|
13
|
+
const PERCENTILES = [50, 90, 95, 99];
|
|
14
|
+
/** 기준 말뭉치 종류의 이름. 지적문에 적는다. 파이썬 TYPE_LABELS 와 같다. @type {Record<string, string>} */
|
|
15
|
+
export const TYPE_LABELS = {
|
|
16
|
+
blog: "블로그",
|
|
17
|
+
technicalDocs: "기술 문서",
|
|
18
|
+
guide: "안내서",
|
|
19
|
+
report: "뉴스와 보고문",
|
|
20
|
+
essay: "수필 (1930년대)",
|
|
21
|
+
fiction: "소설 (1930년대)",
|
|
22
|
+
encyclopedia: "백과",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @typedef {object} Histogram
|
|
27
|
+
* @property {number} total
|
|
28
|
+
* @property {Map<number, number>} counts 값 → 수
|
|
29
|
+
* @property {Map<number, number>} percentiles 백분위 → 값
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @typedef {object} Profile
|
|
34
|
+
* @property {string} kind
|
|
35
|
+
* @property {number} documents
|
|
36
|
+
* @property {number} sentences
|
|
37
|
+
* @property {number} paragraphs
|
|
38
|
+
* @property {Map<string, Histogram>} sentence
|
|
39
|
+
* @property {string} label
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
/** @param {Record<string, unknown>} data @returns {Histogram} */
|
|
43
|
+
function histogramFromDict(data) {
|
|
44
|
+
const counts = new Map();
|
|
45
|
+
for (const [value, count] of Object.entries(/** @type {Record<string, number>} */ (data.counts))) counts.set(Number(value), Number(count));
|
|
46
|
+
const percentiles = new Map();
|
|
47
|
+
for (const p of PERCENTILES) if (`p${p}` in data) percentiles.set(p, Number(data[`p${p}`]));
|
|
48
|
+
return { total: Number(data.total), counts, percentiles };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** 값이 이 이상인 것의 몫을 천분율 정수로. 정수 셈이라 두 판의 글자가 같다. @param {Histogram} hist @param {number} value */
|
|
52
|
+
export function shareAtOrAbove(hist, value) {
|
|
53
|
+
const capped = Math.min(value, CAP);
|
|
54
|
+
let above = 0;
|
|
55
|
+
for (const [seen, count] of hist.counts) if (seen >= capped) above += count;
|
|
56
|
+
return hist.total ? Math.floor((above * 1000) / hist.total) : 0;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** @param {string} kind @param {Record<string, unknown>} data @returns {Profile} */
|
|
60
|
+
function profileFromDict(kind, data) {
|
|
61
|
+
const sentence = new Map();
|
|
62
|
+
for (const [name, value] of Object.entries(/** @type {Record<string, Record<string, unknown>>} */ (data.sentence))) {
|
|
63
|
+
sentence.set(name, histogramFromDict(value));
|
|
64
|
+
}
|
|
65
|
+
const documents = Number(data.documents);
|
|
66
|
+
return {
|
|
67
|
+
kind,
|
|
68
|
+
documents,
|
|
69
|
+
sentences: Number(data.sentences),
|
|
70
|
+
paragraphs: Number(data.paragraphs),
|
|
71
|
+
sentence,
|
|
72
|
+
label: TYPE_LABELS[kind] ?? `참조 글 ${documents}편`,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** @type {Map<string, Profile> | null} */
|
|
77
|
+
let shipped = null;
|
|
78
|
+
/** hanlint 가 싣는 종류별 프로파일. @param {string} kind @returns {Profile | null} */
|
|
79
|
+
export function profileOf(kind) {
|
|
80
|
+
if (!shipped) {
|
|
81
|
+
shipped = new Map();
|
|
82
|
+
const data = JSON.parse(readText("profiles.json"));
|
|
83
|
+
for (const [name, value] of Object.entries(data.types)) shipped.set(name, profileFromDict(name, value));
|
|
84
|
+
}
|
|
85
|
+
return shipped.get(kind) ?? null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** @type {Map<string, Profile>} */
|
|
89
|
+
const users = new Map();
|
|
90
|
+
/** 사용자 프로파일 파일. 한 번 읽은 것은 기억한다. @param {string} path @returns {Profile} */
|
|
91
|
+
export function userProfile(path) {
|
|
92
|
+
let profile = users.get(path);
|
|
93
|
+
if (!profile) {
|
|
94
|
+
const data = JSON.parse(readFileSync(path, "utf-8"));
|
|
95
|
+
if (data.version !== PROFILE_VERSION || !("profile" in data)) {
|
|
96
|
+
throw new Error(`프로파일 파일이 아니다: ${path}. hanlint profile build 로 다시 만든다`);
|
|
97
|
+
}
|
|
98
|
+
profile = profileFromDict("custom", data.profile);
|
|
99
|
+
users.set(path, profile);
|
|
100
|
+
}
|
|
101
|
+
return profile;
|
|
102
|
+
}
|
package/src/document/model.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
export const PROSE = "prose";
|
|
8
8
|
export const HEADING = "heading";
|
|
9
9
|
export const CODE = "code";
|
|
10
|
+
export const QUOTE = "quote";
|
|
10
11
|
export const IMAGE = "image";
|
|
11
12
|
export const LIST = "list";
|
|
12
13
|
export const TABLE = "table";
|
|
@@ -36,6 +37,7 @@ export const HTML = "html";
|
|
|
36
37
|
* @property {Block[]} blocks
|
|
37
38
|
* @property {Section[]} sections
|
|
38
39
|
* @property {[string, number, number][]} disabled 인라인 제어가 끈 (규칙 또는 *, 시작 줄, 끝 줄)
|
|
40
|
+
* @property {Block[]} ignored 설정이 지문에서 뺀 펜스. 파일 전체를 보는 규칙만 읽는다
|
|
39
41
|
*/
|
|
40
42
|
|
|
41
43
|
/** @param {Section} section */
|