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/README.md +102 -62
- package/data/exemplars.json +99 -0
- package/data/holeKinds.json +12 -0
- package/data/ruleCategories.json +3 -0
- package/data/ruleDocs.json +3 -0
- package/data/ruleMechanisms.json +3 -0
- package/data/screenNarration.json +29 -0
- package/data/screenSentence.json +34 -0
- package/data/screenTone.json +19 -0
- package/data/version.json +1 -1
- package/data/writingBrief.schema.json +1 -1
- package/data/writingBriefV2.schema.json +1 -1
- package/package.json +1 -1
- package/src/cli/help.js +1 -0
- package/src/cli/main.js +108 -3
- package/src/config/patch.js +2 -2
- package/src/config/readerContract.js +2 -2
- package/src/config/settings.js +56 -5
- package/src/data/load.js +2 -7
- package/src/data/profiles.js +2 -2
- package/src/data/read.js +21 -0
- package/src/digest.js +44 -0
- package/src/document/sourceText.js +143 -0
- package/src/fingerprint/dictionaries.js +3 -0
- package/src/guard/contract.js +2 -2
- package/src/guard/outline.js +1 -1
- package/src/index.js +53 -6
- package/src/learn/edits.js +31 -0
- package/src/learn/operations.js +28 -0
- package/src/learn/pairs.js +57 -0
- package/src/report/sheet.js +0 -0
- package/src/rules/registry.js +7 -0
- package/src/rules/screen/screenNarration.js +10 -0
- package/src/rules/screen/screenSentence.js +10 -0
- package/src/rules/screen/screenTone.js +10 -0
- package/src/rules/shared/dictionaryRule.js +30 -0
package/src/index.js
CHANGED
|
@@ -8,11 +8,9 @@
|
|
|
8
8
|
* ```
|
|
9
9
|
*
|
|
10
10
|
* 합격과 불합격을 판정하지 않는다. 지적 목록이 비어 있다는 것은 세어서 잡히는 결함이 없다는 뜻이다.
|
|
11
|
-
*
|
|
11
|
+
* 같은 공개 API를 Node와 브라우저가 공유한다. 파일 경로를 읽는 API만 Node 전용이다.
|
|
12
12
|
*/
|
|
13
|
-
import {
|
|
14
|
-
|
|
15
|
-
import { loadConfig } from "./config/loadConfig.js";
|
|
13
|
+
import { readExternal } from "./data/read.js";
|
|
16
14
|
import { Patch } from "./config/patch.js";
|
|
17
15
|
import {
|
|
18
16
|
CONTRACT_VERSION,
|
|
@@ -27,15 +25,37 @@ import {
|
|
|
27
25
|
import { configFromMapping, defaultConfig } from "./config/settings.js";
|
|
28
26
|
import { loadVersion } from "./data/load.js";
|
|
29
27
|
import { parseMarkdown } from "./document/parseMarkdown.js";
|
|
28
|
+
import { SOURCE_SUFFIXES, replaceLiteral, sourceLiterals } from "./document/sourceText.js";
|
|
29
|
+
import { parseSheet, renderSheet, renderSheetJson } from "./report/sheet.js";
|
|
30
|
+
import { headingsOf } from "./document/model.js";
|
|
30
31
|
import { applyFixes } from "./edit/applyFixes.js";
|
|
31
32
|
import { buildFingerprint } from "./fingerprint/build.js";
|
|
32
33
|
import { fingerprintDict } from "./report/fingerprintJson.js";
|
|
33
34
|
import { check, contractFromText, contractFromTextV2, verifyPatch } from "./guard/contract.js";
|
|
34
35
|
import { CheckResult, PatchResult, renderCheck } from "./guard/receipt.js";
|
|
36
|
+
import { compareOutline } from "./guard/outline.js";
|
|
37
|
+
import { protectedSurface, surfaceDiff } from "./guard/surface.js";
|
|
35
38
|
import { ruleDoc, ruleNames, ruleSummary, runAll } from "./rules/registry.js";
|
|
39
|
+
import { renderJson } from "./report/jsonReport.js";
|
|
40
|
+
import { learnExemplars } from "./learn/edits.js";
|
|
41
|
+
import { learnOperations } from "./learn/operations.js";
|
|
42
|
+
|
|
43
|
+
const nodeConfig = typeof process === "object" && process.versions?.node ? await import("./config/loadConfig.js") : null;
|
|
44
|
+
|
|
45
|
+
/** Node 파일 설정. 브라우저에서는 configFromMapping에 설정 객체를 전달한다. @param {string | null} path @param {string | null} start */
|
|
46
|
+
export function loadConfig(path = null, start = null) {
|
|
47
|
+
if (!nodeConfig) throw new Error("브라우저에서는 configFromMapping으로 설정 객체를 전달해 주세요.");
|
|
48
|
+
return nodeConfig.loadConfig(path, start);
|
|
49
|
+
}
|
|
36
50
|
|
|
37
51
|
export {
|
|
38
52
|
CheckResult,
|
|
53
|
+
SOURCE_SUFFIXES,
|
|
54
|
+
parseSheet,
|
|
55
|
+
renderSheet,
|
|
56
|
+
renderSheetJson,
|
|
57
|
+
replaceLiteral,
|
|
58
|
+
sourceLiterals,
|
|
39
59
|
CONTRACT_VERSION,
|
|
40
60
|
CONTRACT_VERSIONS,
|
|
41
61
|
Contract,
|
|
@@ -52,7 +72,6 @@ export {
|
|
|
52
72
|
configFromMapping,
|
|
53
73
|
defaultConfig,
|
|
54
74
|
fingerprintDict,
|
|
55
|
-
loadConfig,
|
|
56
75
|
parseContract,
|
|
57
76
|
renderCheck,
|
|
58
77
|
ruleDoc,
|
|
@@ -88,5 +107,33 @@ export function lintText(text, config = defaultConfig(), path = null) {
|
|
|
88
107
|
* @param {import("./config/settings.js").Config} [config]
|
|
89
108
|
*/
|
|
90
109
|
export function lintFile(path, config = defaultConfig()) {
|
|
91
|
-
return lintText(
|
|
110
|
+
return lintText(readExternal(path), config, path);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** 지적, 문체 본보기, 승인 고침과 지문을 같은 분석에서 제공한다. @param {string} text @param {import("./config/settings.js").Config} config */
|
|
114
|
+
export function inspectText(text, config = defaultConfig()) {
|
|
115
|
+
const document = fingerprint(text, config);
|
|
116
|
+
const findings = runAll(document, config);
|
|
117
|
+
const path = "draft.md";
|
|
118
|
+
const report = JSON.parse(renderJson(new Map([[path, findings]]), null,
|
|
119
|
+
new Map([[path, document.register]]), config.preset, config.exemplars,
|
|
120
|
+
new Map([[path, document]]), config.patches, config.operations, config.protectedTerms));
|
|
121
|
+
return { findings, report: report.files[0], document: fingerprintDict(document) };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** 사람 수정 전후에서 승인할 후보를 추출한다. 후보 자체는 승인이 아니다. @param {string} before @param {string} after @param {import("./config/settings.js").Config} config */
|
|
125
|
+
export function learnText(before, after, config = defaultConfig()) {
|
|
126
|
+
const beforeDoc = fingerprint(before, config), afterDoc = fingerprint(after, config);
|
|
127
|
+
return {
|
|
128
|
+
exemplars: learnExemplars(beforeDoc, afterDoc, runAll(beforeDoc, config), runAll(afterDoc, config), config.preset),
|
|
129
|
+
operations: learnOperations(beforeDoc, afterDoc, config.preset, config.protectedTerms),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** 자유 원고의 표면과 H2 순서를 비교한다. 빈 글과 반복 제목도 관찰하며 승인 계약을 만들지 않는다. @param {string} before @param {string} after @param {import("./config/settings.js").Config} config */
|
|
134
|
+
export function compareRevision(before, after, config = defaultConfig()) {
|
|
135
|
+
const original = before.normalize("NFC");
|
|
136
|
+
const outline = { level: 2, headings: headingsOf(parseMarkdown(original), 2).map((heading) => heading.text) };
|
|
137
|
+
return { surface: protectedSurface(original), changes: surfaceDiff(original, after),
|
|
138
|
+
outline: compareOutline(outline, fingerprint(after, config)) };
|
|
92
139
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/** Python learn/edits.py와 같은 원문 문장 대응 및 지적 소멸 후보다. */
|
|
3
|
+
import { sourceSentenceTexts } from "../fingerprint/sourceSentence.js";
|
|
4
|
+
import { readerKind } from "../fingerprint/readerState.js";
|
|
5
|
+
import { localCue, SENTENCE } from "../rules/finding.js";
|
|
6
|
+
import { changedSentencePairs } from "./pairs.js";
|
|
7
|
+
|
|
8
|
+
export function learnExemplars(beforeDoc, afterDoc, beforeFindings, afterFindings, preset = null) {
|
|
9
|
+
const pairs = changedSentencePairs(beforeDoc.sentences, afterDoc.sentences);
|
|
10
|
+
const rawBefore = sourceSentenceTexts(beforeDoc), rawAfter = sourceSentenceTexts(afterDoc);
|
|
11
|
+
const remaining = new Set(afterFindings.filter((item) => item.scope === SENTENCE && item.at >= 0).map((item) => `${item.rule}:${item.at}`));
|
|
12
|
+
const beforeByIndex = new Map(beforeDoc.sentences.map((item) => [item.index, item]));
|
|
13
|
+
const seen = new Set(), learned = [];
|
|
14
|
+
for (const finding of beforeFindings) {
|
|
15
|
+
const key = `${finding.rule}:${finding.at}`;
|
|
16
|
+
if (finding.scope !== SENTENCE || finding.at < 0 || seen.has(key)) continue;
|
|
17
|
+
const after = pairs.get(finding.at), before = beforeByIndex.get(finding.at);
|
|
18
|
+
if (!after || !before || after.some((item) => remaining.has(`${finding.rule}:${item.index}`))) continue;
|
|
19
|
+
const afterPlain = after.map((item) => item.text.trim()).filter(Boolean).join(" ");
|
|
20
|
+
const afterText = after.filter((item) => item.text.trim()).map((item) => (rawAfter.get(item.index) ?? item.text).trim()).join(" ");
|
|
21
|
+
if (!afterText || before.text.trim() === afterPlain) continue;
|
|
22
|
+
seen.add(key);
|
|
23
|
+
learned.push({ rule: finding.rule, before: (rawBefore.get(before.index) ?? before.text).trim(), after: afterText,
|
|
24
|
+
moved: "실제 수정본의 문장으로 바꿈", why: finding.why, beforeLine: before.line,
|
|
25
|
+
afterLines: [...new Set(after.map((item) => item.line))], sentence: before.text.trim(),
|
|
26
|
+
cue: localCue(finding), reader: readerKind(before, beforeDoc.reader.beforeSentence[finding.at]),
|
|
27
|
+
...(preset ? { presets: [preset] } : {}),
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
return learned;
|
|
31
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/** Python learn/operations.py의 표면 치환 후보와 증거 묶음. */
|
|
3
|
+
import { operationFromApproval } from "../data/operations.js";
|
|
4
|
+
import { sourceSentenceTexts } from "../fingerprint/sourceSentence.js";
|
|
5
|
+
import { changedSentencePairs } from "./pairs.js";
|
|
6
|
+
|
|
7
|
+
export function learnOperations(beforeDoc, afterDoc, preset = null, protectedTerms = []) {
|
|
8
|
+
const pairs = changedSentencePairs(beforeDoc.sentences, afterDoc.sentences);
|
|
9
|
+
const rawBefore = sourceSentenceTexts(beforeDoc), rawAfter = sourceSentenceTexts(afterDoc);
|
|
10
|
+
const beforeByIndex = new Map(beforeDoc.sentences.map((item) => [item.index, item]));
|
|
11
|
+
const grouped = new Map(), presets = preset ? [preset] : [];
|
|
12
|
+
for (const [index, after] of pairs) {
|
|
13
|
+
const before = beforeByIndex.get(index);
|
|
14
|
+
if (after.length !== 1 || !before) continue;
|
|
15
|
+
const sourceBefore = (rawBefore.get(index) ?? before.text).trim();
|
|
16
|
+
const sourceAfter = (rawAfter.get(after[0].index) ?? after[0].text).trim();
|
|
17
|
+
const operation = operationFromApproval(sourceBefore, sourceAfter, presets, protectedTerms);
|
|
18
|
+
if (!operation) continue;
|
|
19
|
+
const key = JSON.stringify([operation.before, operation.after]);
|
|
20
|
+
const item = grouped.get(key) ?? { kind: "surfaceSubstitution", before: operation.before, after: operation.after, evidence: [] };
|
|
21
|
+
item.evidence.push({ sourceBefore, sourceAfter, beforeLine: before.line, afterLine: after[0].line });
|
|
22
|
+
grouped.set(key, item);
|
|
23
|
+
}
|
|
24
|
+
return [...grouped.values()].map((item) => ({ ...item, evidenceCount: item.evidence.length,
|
|
25
|
+
guards: { maximumCharacters: 32, maximumSurfaceEditDistance: 1, protectedFacts: true, uniqueWordBoundary: true },
|
|
26
|
+
...(preset ? { presets } : {}),
|
|
27
|
+
}));
|
|
28
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/** Python SequenceMatcher(autojunk=False)의 문장 대응. 가장 긴 연속 일치의 앞쪽을 먼저 택한다. */
|
|
3
|
+
function longest(left, right, start, end, otherStart, otherEnd) {
|
|
4
|
+
let best = [start, otherStart, 0], previous = new Map();
|
|
5
|
+
const positions = new Map();
|
|
6
|
+
for (let index = otherStart; index < otherEnd; index++) {
|
|
7
|
+
const list = positions.get(right[index]) ?? [];
|
|
8
|
+
list.push(index);
|
|
9
|
+
positions.set(right[index], list);
|
|
10
|
+
}
|
|
11
|
+
for (let index = start; index < end; index++) {
|
|
12
|
+
const current = new Map();
|
|
13
|
+
for (const other of positions.get(left[index]) ?? []) {
|
|
14
|
+
const size = (previous.get(other - 1) ?? 0) + 1;
|
|
15
|
+
current.set(other, size);
|
|
16
|
+
if (size > best[2]) best = [index - size + 1, other - size + 1, size];
|
|
17
|
+
}
|
|
18
|
+
previous = current;
|
|
19
|
+
}
|
|
20
|
+
return best;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** @param {string[]} left @param {string[]} right */
|
|
24
|
+
export function changedRanges(left, right) {
|
|
25
|
+
const pending = [[0, left.length, 0, right.length]], blocks = [];
|
|
26
|
+
while (pending.length) {
|
|
27
|
+
const [start, end, otherStart, otherEnd] = pending.pop();
|
|
28
|
+
const [at, other, size] = longest(left, right, start, end, otherStart, otherEnd);
|
|
29
|
+
if (!size) continue;
|
|
30
|
+
blocks.push([at, other, size]);
|
|
31
|
+
if (start < at && otherStart < other) pending.push([start, at, otherStart, other]);
|
|
32
|
+
if (at + size < end && other + size < otherEnd) pending.push([at + size, end, other + size, otherEnd]);
|
|
33
|
+
}
|
|
34
|
+
blocks.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
|
|
35
|
+
blocks.push([left.length, right.length, 0]);
|
|
36
|
+
const ranges = [];
|
|
37
|
+
let start = 0, otherStart = 0;
|
|
38
|
+
for (const [at, other, size] of blocks) {
|
|
39
|
+
if (start < at || otherStart < other) ranges.push([start, at, otherStart, other]);
|
|
40
|
+
start = at + size;
|
|
41
|
+
otherStart = other + size;
|
|
42
|
+
}
|
|
43
|
+
return ranges;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** 기존 Python learn의 일대일 및 한 문장 분할 대응을 그대로 따른다. */
|
|
47
|
+
export function changedSentencePairs(before, after) {
|
|
48
|
+
const pairs = new Map();
|
|
49
|
+
for (const [start, end, otherStart, otherEnd] of changedRanges(before.map((item) => item.text), after.map((item) => item.text))) {
|
|
50
|
+
const count = end - start, otherCount = otherEnd - otherStart;
|
|
51
|
+
if (!count || !otherCount) continue;
|
|
52
|
+
if (count === otherCount) {
|
|
53
|
+
for (let offset = 0; offset < count; offset++) pairs.set(before[start + offset].index, [after[otherStart + offset]]);
|
|
54
|
+
} else if (count === 1) pairs.set(before[start].index, after.slice(otherStart, otherEnd));
|
|
55
|
+
}
|
|
56
|
+
return pairs;
|
|
57
|
+
}
|
|
Binary file
|
package/src/rules/registry.js
CHANGED
|
@@ -56,6 +56,9 @@ import * as headingSkip from "./structure/headingSkip.js";
|
|
|
56
56
|
import * as headingUniform from "./structure/headingUniform.js";
|
|
57
57
|
import * as introLong from "./structure/introLong.js";
|
|
58
58
|
import * as sectionNoProse from "./structure/sectionNoProse.js";
|
|
59
|
+
import * as screenNarration from "./screen/screenNarration.js";
|
|
60
|
+
import * as screenSentence from "./screen/screenSentence.js";
|
|
61
|
+
import * as screenTone from "./screen/screenTone.js";
|
|
59
62
|
|
|
60
63
|
/**
|
|
61
64
|
* @typedef {object} Rule
|
|
@@ -116,6 +119,9 @@ export const RULES = [
|
|
|
116
119
|
duplicateBlock,
|
|
117
120
|
firstResultDistance,
|
|
118
121
|
platformApi,
|
|
122
|
+
screenNarration,
|
|
123
|
+
screenSentence,
|
|
124
|
+
screenTone,
|
|
119
125
|
];
|
|
120
126
|
|
|
121
127
|
/**
|
|
@@ -192,6 +198,7 @@ export const CATEGORY_TITLES = {
|
|
|
192
198
|
document: "두 자리를 대조해 세는 것",
|
|
193
199
|
orthography: "표기와 띄어쓰기",
|
|
194
200
|
code: "코드 블록 사이를 대조하는 것",
|
|
201
|
+
screen: "화면의 글에서 세는 것",
|
|
195
202
|
};
|
|
196
203
|
|
|
197
204
|
/** 규칙이 세는 방법. @param {string} name */
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { firstMatchFindings } from "../shared/dictionaryRule.js";
|
|
3
|
+
|
|
4
|
+
export const name = "screenNarration";
|
|
5
|
+
export const mechanism = "dictionary";
|
|
6
|
+
|
|
7
|
+
/** @param {import("../../fingerprint/build.js").DocumentPrint} doc */
|
|
8
|
+
export function run(doc) {
|
|
9
|
+
return firstMatchFindings(doc, "screenNarration", name);
|
|
10
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { firstMatchFindings } from "../shared/dictionaryRule.js";
|
|
3
|
+
|
|
4
|
+
export const name = "screenSentence";
|
|
5
|
+
export const mechanism = "dictionary";
|
|
6
|
+
|
|
7
|
+
/** @param {import("../../fingerprint/build.js").DocumentPrint} doc */
|
|
8
|
+
export function run(doc) {
|
|
9
|
+
return firstMatchFindings(doc, "screenSentence", name);
|
|
10
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { firstMatchFindings } from "../shared/dictionaryRule.js";
|
|
3
|
+
|
|
4
|
+
export const name = "screenTone";
|
|
5
|
+
export const mechanism = "dictionary";
|
|
6
|
+
|
|
7
|
+
/** @param {import("../../fingerprint/build.js").DocumentPrint} doc */
|
|
8
|
+
export function run(doc) {
|
|
9
|
+
return firstMatchFindings(doc, "screenTone", name);
|
|
10
|
+
}
|
|
@@ -43,3 +43,33 @@ export function dictionaryFindings(doc, dictionary, ruleName, severity = ERROR)
|
|
|
43
43
|
}
|
|
44
44
|
return findings;
|
|
45
45
|
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 문장마다 가장 앞의 매치 하나만 지적한다. 뜻은 파이썬 rules/shared/dictionaryRule.py 의 firstMatchFindings 가 소유한다.
|
|
49
|
+
* 구체 무늬와 종결어미가 한 사전에 같이 사는 화면 사전에 쓴다. fix 는 내지 않는다.
|
|
50
|
+
* @param {import("../../fingerprint/build.js").DocumentPrint} doc
|
|
51
|
+
* @param {string} dictionary
|
|
52
|
+
* @param {string} ruleName
|
|
53
|
+
* @param {string} [severity]
|
|
54
|
+
* @returns {import("../finding.js").Finding[]}
|
|
55
|
+
*/
|
|
56
|
+
export function firstMatchFindings(doc, dictionary, ruleName, severity = ERROR) {
|
|
57
|
+
const findings = [];
|
|
58
|
+
for (const sentence of doc.sentences) {
|
|
59
|
+
const match = sentence.matches.find((m) => m.dictionary === dictionary);
|
|
60
|
+
if (!match) continue;
|
|
61
|
+
findings.push(
|
|
62
|
+
finding(
|
|
63
|
+
ruleName,
|
|
64
|
+
sentence.line + countNewlines(sentence.text.slice(0, match.start)),
|
|
65
|
+
sentence.text,
|
|
66
|
+
`\`${match.text}\` ${match.why} (${match.source})`,
|
|
67
|
+
null,
|
|
68
|
+
severity,
|
|
69
|
+
SENTENCE,
|
|
70
|
+
sentence.index,
|
|
71
|
+
),
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
return findings;
|
|
75
|
+
}
|