hanlint 0.0.9 → 0.0.10
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 +36 -1
- package/data/checkResult.schema.json +75 -0
- package/data/checkResultV2.schema.json +98 -0
- package/data/finding.schema.json +38 -0
- package/data/patch.schema.json +14 -0
- package/data/patchResult.schema.json +86 -0
- package/data/patchResultV2.schema.json +71 -0
- package/data/profiles.json +1 -1
- package/data/pythonPackages.txt +3 -2
- package/data/readerContract.schema.json +20 -0
- package/data/readerContractConformanceV1.json +168 -0
- package/data/readerContractV2.schema.json +64 -0
- package/data/ruleDocs.json +3 -3
- 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 +34 -0
- package/src/cli/main.js +241 -28
- package/src/config/patch.js +48 -0
- package/src/config/readerContract.js +245 -0
- package/src/config/settings.js +31 -2
- package/src/data/profiles.js +12 -0
- package/src/fingerprint/codeMarkers.js +1 -1
- package/src/guard/contract.js +174 -0
- package/src/guard/editLocality.js +27 -0
- package/src/guard/outline.js +46 -0
- package/src/guard/receipt.js +180 -0
- package/src/guard/surface.js +184 -0
- package/src/index.js +39 -1
- package/src/report/compactReport.js +1 -1
- package/src/report/writingSpec.js +174 -0
- package/src/rules/code/inputFileSource.js +5 -3
- package/src/rules/code/installImport.js +6 -5
- package/src/rules/document/noQuestion.js +4 -4
- package/src/rules/readerDebt.js +9 -0
- package/src/rules/registry.js +27 -0
- package/src/rules/sentence/nounPile.js +1 -1
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/** 계약 본문과 결과 글 사이의 보호 원자 차이. */
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @typedef {object} SurfaceDiff
|
|
6
|
+
* @property {string[]} missingNumbers
|
|
7
|
+
* @property {string[]} unexpectedNumbers
|
|
8
|
+
* @property {string[]} missingUrls
|
|
9
|
+
* @property {string[]} unexpectedUrls
|
|
10
|
+
* @property {string[]} missingCode
|
|
11
|
+
* @property {string[]} unexpectedCode
|
|
12
|
+
* @property {string[]} missingLinks
|
|
13
|
+
* @property {string[]} unexpectedLinks
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const NUMBER_ATOM = /(?<!\p{Nd})(?:\p{Nd}{1,3}(?:,\p{Nd}{3})+|\p{Nd}+)(?:\.\p{Nd}+)*(?!\p{Nd})/gu;
|
|
17
|
+
const URL = /https?:\/\/[^\s)>\]]*[\p{L}\p{N}_\/#=%&+~-]/gu;
|
|
18
|
+
const INLINE_CODE = /`([^`\n]+)`/gu;
|
|
19
|
+
const LINK_DESTINATION = /\[[^\u005D]*\]\(([^)]+)\)/gu;
|
|
20
|
+
const FACT_FIELDS = [
|
|
21
|
+
["unexpectedNumbers", "{}"],
|
|
22
|
+
["unexpectedUrls", "{}"],
|
|
23
|
+
["unexpectedCode", "`{}`"],
|
|
24
|
+
["unexpectedLinks", "[]({})"],
|
|
25
|
+
];
|
|
26
|
+
const UNEXPECTED_FIELDS = FACT_FIELDS.map(([field]) => field);
|
|
27
|
+
const DECIMAL_ZEROS = [
|
|
28
|
+
0x0030, 0x0660, 0x06f0, 0x07c0, 0x0966, 0x09e6, 0x0a66, 0x0ae6, 0x0b66, 0x0be6,
|
|
29
|
+
0x0c66, 0x0ce6, 0x0d66, 0x0de6, 0x0e50, 0x0ed0, 0x0f20, 0x1040, 0x1090, 0x17e0,
|
|
30
|
+
0x1810, 0x1946, 0x19d0, 0x1a80, 0x1a90, 0x1b50, 0x1bb0, 0x1c40, 0x1c50, 0xa620,
|
|
31
|
+
0xa8d0, 0xa900, 0xa9d0, 0xa9f0, 0xaa50, 0xabf0, 0xff10, 0x104a0, 0x10d30, 0x11066,
|
|
32
|
+
0x110f0, 0x11136, 0x111d0, 0x112f0, 0x11450, 0x114d0, 0x11650, 0x116c0, 0x11730, 0x118e0,
|
|
33
|
+
0x11950, 0x11c50, 0x11d50, 0x11da0, 0x16a60, 0x16ac0, 0x16b50, 0x1d7ce, 0x1e140, 0x1e2f0,
|
|
34
|
+
0x1e4f0, 0x1e950,
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
/** 파이썬 문자열 순서와 같은 코드 포인트 순서. @param {string} left @param {string} right */
|
|
38
|
+
export function compareText(left, right) {
|
|
39
|
+
const leftPoints = [...left].map((value) => /** @type {number} */ (value.codePointAt(0)));
|
|
40
|
+
const rightPoints = [...right].map((value) => /** @type {number} */ (value.codePointAt(0)));
|
|
41
|
+
const limit = Math.min(leftPoints.length, rightPoints.length);
|
|
42
|
+
for (let index = 0; index < limit; index += 1) {
|
|
43
|
+
if (leftPoints[index] !== rightPoints[index]) return leftPoints[index] - rightPoints[index];
|
|
44
|
+
}
|
|
45
|
+
return leftPoints.length - rightPoints.length;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** @param {string} character */
|
|
49
|
+
function decimalValue(character) {
|
|
50
|
+
const point = /** @type {number} */ (character.codePointAt(0));
|
|
51
|
+
const zero = DECIMAL_ZEROS.find((start) => start <= point && point < start + 10);
|
|
52
|
+
if (zero === undefined) throw new Error(`지원하지 않는 유니코드 십진 숫자다: ${character}`);
|
|
53
|
+
return String(point - zero);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** @param {string} value */
|
|
57
|
+
function canonicalNumber(value) {
|
|
58
|
+
return [...value]
|
|
59
|
+
.filter((character) => character !== ",")
|
|
60
|
+
.map((character) => (/\p{Nd}/u.test(character) ? decimalValue(character) : character))
|
|
61
|
+
.join("");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** @param {string} text */
|
|
65
|
+
export function numberValues(text) {
|
|
66
|
+
return [...new Set([...text.matchAll(NUMBER_ATOM)].map((match) => canonicalNumber(match[0])))].sort(compareText);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** @param {RegExp} pattern @param {string} text */
|
|
70
|
+
function valuesOf(pattern, text) {
|
|
71
|
+
return [...new Set([...text.matchAll(pattern)].map((match) => (match.length > 1 ? match[1] : match[0])))].sort(compareText);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** @param {string[]} expected @param {string[]} actual */
|
|
75
|
+
function difference(expected, actual) {
|
|
76
|
+
const other = new Set(actual);
|
|
77
|
+
return expected.filter((value) => !other.has(value)).sort(compareText);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** @param {string} contractText @param {string} text @param {string[] | null} [numbers] @returns {SurfaceDiff} */
|
|
81
|
+
export function surfaceDiff(contractText, text, numbers = null) {
|
|
82
|
+
const surfaceText = text.normalize("NFC");
|
|
83
|
+
const expectedNumbers = numbers === null ? numberValues(contractText) : [...new Set(numbers)].sort(compareText);
|
|
84
|
+
const actualNumbers = numberValues(surfaceText);
|
|
85
|
+
const expectedUrls = valuesOf(URL, contractText);
|
|
86
|
+
const actualUrls = valuesOf(URL, surfaceText);
|
|
87
|
+
const expectedCode = valuesOf(INLINE_CODE, contractText);
|
|
88
|
+
const actualCode = valuesOf(INLINE_CODE, surfaceText);
|
|
89
|
+
const expectedLinks = valuesOf(LINK_DESTINATION, contractText);
|
|
90
|
+
const actualLinks = valuesOf(LINK_DESTINATION, surfaceText);
|
|
91
|
+
const allowedCode = [...new Set([...expectedCode, ...actualCode.filter((value) => contractText.includes(value))])];
|
|
92
|
+
const allowedLinks = [...new Set([...expectedLinks, ...actualLinks.filter((value) => contractText.includes(value))])];
|
|
93
|
+
return {
|
|
94
|
+
missingNumbers: difference(expectedNumbers, actualNumbers),
|
|
95
|
+
unexpectedNumbers: difference(actualNumbers, expectedNumbers),
|
|
96
|
+
missingUrls: difference(expectedUrls, actualUrls),
|
|
97
|
+
unexpectedUrls: difference(actualUrls, expectedUrls),
|
|
98
|
+
missingCode: difference(expectedCode, actualCode),
|
|
99
|
+
unexpectedCode: difference(actualCode, allowedCode),
|
|
100
|
+
missingLinks: difference(expectedLinks, actualLinks),
|
|
101
|
+
unexpectedLinks: difference(actualLinks, allowedLinks),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** @param {ReturnType<typeof surfaceDiff>} diff */
|
|
106
|
+
export function surfaceViolationCount(diff) {
|
|
107
|
+
return Object.values(diff).reduce((total, values) => total + values.length, 0);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** 원문의 보호 원자를 뜻 없는 정렬 집합으로 분리한다. @param {string} text */
|
|
111
|
+
export function protectedSurface(text) {
|
|
112
|
+
const surfaceText = text.normalize("NFC");
|
|
113
|
+
return {
|
|
114
|
+
numbers: numberValues(surfaceText),
|
|
115
|
+
urls: valuesOf(URL, surfaceText),
|
|
116
|
+
code: valuesOf(INLINE_CODE, surfaceText),
|
|
117
|
+
links: valuesOf(LINK_DESTINATION, surfaceText),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** 원문의 보호 표면을 모두 덮는 Contract 사실 후보 줄. @param {string} text */
|
|
122
|
+
export function factLines(text) {
|
|
123
|
+
const surfaceText = text.normalize("NFC");
|
|
124
|
+
const seen = new Set();
|
|
125
|
+
/** @type {{ text: string, atoms: Set<string> }[]} */
|
|
126
|
+
const candidates = [];
|
|
127
|
+
for (const line of surfaceText.split(/\r\n|[\n\v\f\r\x1c-\x1e\x85\u2028\u2029]/u)) {
|
|
128
|
+
const candidate = line.trim();
|
|
129
|
+
if (!candidate || seen.has(candidate)) continue;
|
|
130
|
+
seen.add(candidate);
|
|
131
|
+
const diff = surfaceDiff("", candidate);
|
|
132
|
+
const atoms = new Set(
|
|
133
|
+
UNEXPECTED_FIELDS.flatMap((field) =>
|
|
134
|
+
diff[/** @type {keyof typeof diff} */ (field)].map((value) => `${field}\u0000${value}`),
|
|
135
|
+
),
|
|
136
|
+
);
|
|
137
|
+
if (atoms.size) candidates.push({ text: candidate, atoms });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const fullDiff = surfaceDiff("", surfaceText);
|
|
141
|
+
const uncovered = new Set(
|
|
142
|
+
UNEXPECTED_FIELDS.flatMap((field) =>
|
|
143
|
+
fullDiff[/** @type {keyof typeof fullDiff} */ (field)].map((value) => `${field}\u0000${value}`),
|
|
144
|
+
),
|
|
145
|
+
);
|
|
146
|
+
if (!uncovered.size) {
|
|
147
|
+
throw new Error("원문에 자동으로 보호할 숫자, URL, 인라인 코드, 링크 목적지가 없다. facts를 직접 작성한다");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const selected = new Set();
|
|
151
|
+
while (uncovered.size) {
|
|
152
|
+
let best = -1;
|
|
153
|
+
let bestGain = 0;
|
|
154
|
+
for (let index = 0; index < candidates.length; index += 1) {
|
|
155
|
+
if (selected.has(index)) continue;
|
|
156
|
+
const gain = [...candidates[index].atoms].filter((atom) => uncovered.has(atom)).length;
|
|
157
|
+
if (gain > bestGain) {
|
|
158
|
+
best = index;
|
|
159
|
+
bestGain = gain;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (!bestGain) break;
|
|
163
|
+
selected.add(best);
|
|
164
|
+
for (const atom of candidates[best].atoms) uncovered.delete(atom);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const facts = candidates.filter((_, index) => selected.has(index)).map((candidate) => candidate.text);
|
|
168
|
+
const factSet = new Set(facts);
|
|
169
|
+
for (const [field, template] of FACT_FIELDS) {
|
|
170
|
+
const prefix = `${field}\u0000`;
|
|
171
|
+
const values = [...uncovered]
|
|
172
|
+
.filter((atom) => atom.startsWith(prefix))
|
|
173
|
+
.map((atom) => atom.slice(prefix.length))
|
|
174
|
+
.sort(compareText);
|
|
175
|
+
for (const value of values) {
|
|
176
|
+
const candidate = template.replace("{}", value);
|
|
177
|
+
if (!factSet.has(candidate)) {
|
|
178
|
+
facts.push(candidate);
|
|
179
|
+
factSet.add(candidate);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return facts;
|
|
184
|
+
}
|
package/src/index.js
CHANGED
|
@@ -13,15 +13,53 @@
|
|
|
13
13
|
import { readFileSync } from "node:fs";
|
|
14
14
|
|
|
15
15
|
import { loadConfig } from "./config/loadConfig.js";
|
|
16
|
+
import { Patch } from "./config/patch.js";
|
|
17
|
+
import {
|
|
18
|
+
CONTRACT_VERSION,
|
|
19
|
+
CONTRACT_VERSIONS,
|
|
20
|
+
LATEST_CONTRACT_VERSION,
|
|
21
|
+
Contract,
|
|
22
|
+
ContractV2,
|
|
23
|
+
Outline,
|
|
24
|
+
ProtectedSurface,
|
|
25
|
+
parseContract,
|
|
26
|
+
} from "./config/readerContract.js";
|
|
16
27
|
import { configFromMapping, defaultConfig } from "./config/settings.js";
|
|
17
28
|
import { loadVersion } from "./data/load.js";
|
|
18
29
|
import { parseMarkdown } from "./document/parseMarkdown.js";
|
|
19
30
|
import { applyFixes } from "./edit/applyFixes.js";
|
|
20
31
|
import { buildFingerprint } from "./fingerprint/build.js";
|
|
21
32
|
import { fingerprintDict } from "./report/fingerprintJson.js";
|
|
33
|
+
import { check, contractFromText, contractFromTextV2, verifyPatch } from "./guard/contract.js";
|
|
34
|
+
import { CheckResult, PatchResult, renderCheck } from "./guard/receipt.js";
|
|
22
35
|
import { ruleDoc, ruleNames, ruleSummary, runAll } from "./rules/registry.js";
|
|
23
36
|
|
|
24
|
-
export {
|
|
37
|
+
export {
|
|
38
|
+
CheckResult,
|
|
39
|
+
CONTRACT_VERSION,
|
|
40
|
+
CONTRACT_VERSIONS,
|
|
41
|
+
Contract,
|
|
42
|
+
ContractV2,
|
|
43
|
+
LATEST_CONTRACT_VERSION,
|
|
44
|
+
Outline,
|
|
45
|
+
Patch,
|
|
46
|
+
PatchResult,
|
|
47
|
+
ProtectedSurface,
|
|
48
|
+
applyFixes,
|
|
49
|
+
check,
|
|
50
|
+
contractFromText,
|
|
51
|
+
contractFromTextV2,
|
|
52
|
+
configFromMapping,
|
|
53
|
+
defaultConfig,
|
|
54
|
+
fingerprintDict,
|
|
55
|
+
loadConfig,
|
|
56
|
+
parseContract,
|
|
57
|
+
renderCheck,
|
|
58
|
+
ruleDoc,
|
|
59
|
+
ruleNames,
|
|
60
|
+
ruleSummary,
|
|
61
|
+
verifyPatch,
|
|
62
|
+
};
|
|
25
63
|
export const version = loadVersion();
|
|
26
64
|
|
|
27
65
|
/**
|
|
@@ -9,6 +9,6 @@ function flat(text) {
|
|
|
9
9
|
/** @param {string} path @param {import("../rules/finding.js").Finding[]} findings */
|
|
10
10
|
export function renderCompact(path, findings) {
|
|
11
11
|
return findings
|
|
12
|
-
.map((f) => `${path}:${f.line} [${f.rule}] ${flat(f.why)}` + (f.fix ? ` 고친 뒤: ${flat(f.fix)}` : ""))
|
|
12
|
+
.map((f) => `${path}:${f.line} [${f.rule}] ${f.severity === "notice" ? "notice: " : ""}${flat(f.why)}` + (f.fix ? ` 고친 뒤: ${flat(f.fix)}` : ""))
|
|
13
13
|
.join("\n");
|
|
14
14
|
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/** 쓰기 전 숫자 사양. 파이썬 config/writingSpec.py와 같은 셈과 글자를 낸다. */
|
|
3
|
+
|
|
4
|
+
import { HAPNIDA, REGISTERS } from "../analysis/grammar/index.js";
|
|
5
|
+
import { PROFILE_OF, defaultConfig, enabled } from "../config/settings.js";
|
|
6
|
+
import { profileOf, userProfile } from "../data/profiles.js";
|
|
7
|
+
|
|
8
|
+
export const SPEC_VERSION = 1;
|
|
9
|
+
const CHARS_PER_WORD = 6;
|
|
10
|
+
|
|
11
|
+
/** 양수 반올림. 파이썬의 nearest와 같다. @param {number} value */
|
|
12
|
+
function nearest(value) {
|
|
13
|
+
return Math.floor(value + 0.5);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** @param {import("../data/profiles.js").Histogram} histogram @param {number} level */
|
|
17
|
+
function percentile(histogram, level) {
|
|
18
|
+
return histogram.percentiles.get(level) ?? 0;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** @param {number} value */
|
|
22
|
+
function percent(value) {
|
|
23
|
+
return nearest(value * 100);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** @param {import("./settings.js").Config} config */
|
|
27
|
+
function selectedProfile(config) {
|
|
28
|
+
if (config.profile) return userProfile(config.profile);
|
|
29
|
+
const kind = PROFILE_OF[config.preset];
|
|
30
|
+
if (kind === null) throw new Error(`${config.preset} 은 종류 프로파일이 없어 사양을 낼 수 없다`);
|
|
31
|
+
const profile = profileOf(kind);
|
|
32
|
+
if (profile === null) throw new Error(`${kind} 프로파일을 찾지 못했다`);
|
|
33
|
+
return profile;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 현재 규칙판을 쓰기 전 숫자 사양으로 편다. 품질 점수나 새 임계는 만들지 않는다.
|
|
38
|
+
* @param {import("./settings.js").Config} [config]
|
|
39
|
+
* @param {string} [register]
|
|
40
|
+
* @param {number | null} [targetChars]
|
|
41
|
+
*/
|
|
42
|
+
export function writingSpec(config = defaultConfig(), register = HAPNIDA, targetChars = null) {
|
|
43
|
+
if (!REGISTERS.includes(register)) throw new Error(`register 는 ${REGISTERS.join(", ")} 가운데 하나다: ${register}`);
|
|
44
|
+
if (targetChars !== null && targetChars <= 0) throw new Error(`chars 는 1 이상이어야 한다: ${targetChars}`);
|
|
45
|
+
const profile = selectedProfile(config);
|
|
46
|
+
const sentence = profile.sentence;
|
|
47
|
+
const paragraph = profile.paragraph.get("sentenceCount");
|
|
48
|
+
if (!paragraph) throw new Error("프로파일에 paragraph.sentenceCount가 없다");
|
|
49
|
+
const perParagraph = percentile(paragraph, 50);
|
|
50
|
+
if (perParagraph <= 0) throw new Error("프로파일의 문단당 문장 중앙값이 1 이상이어야 한다");
|
|
51
|
+
const lengthHistogram = sentence.get("length");
|
|
52
|
+
if (!lengthHistogram) throw new Error("프로파일에 sentence.length가 없다");
|
|
53
|
+
const lengthP50 = percentile(lengthHistogram, 50);
|
|
54
|
+
if (lengthP50 <= 0) throw new Error("프로파일의 문장 길이 중앙값이 1 이상이어야 한다");
|
|
55
|
+
|
|
56
|
+
const rows = [];
|
|
57
|
+
let amount = `문단마다 문장 ${perParagraph}~${percentile(paragraph, 90)}개`;
|
|
58
|
+
if (targetChars !== null) {
|
|
59
|
+
const sentences = Math.max(1, nearest(targetChars / CHARS_PER_WORD / lengthP50));
|
|
60
|
+
const paragraphs = Math.max(1, nearest(sentences / perParagraph));
|
|
61
|
+
amount = `문단 ${paragraphs}개, ${amount}, 문장 ${sentences}개 안팎`;
|
|
62
|
+
}
|
|
63
|
+
rows.push({ id: "amount", label: "분량", guidance: amount, basis: [`profile.${profile.kind}.paragraph.sentenceCount`] });
|
|
64
|
+
|
|
65
|
+
let length = `중앙 ${lengthP50}어절, 문장 90%가 ${percentile(lengthHistogram, 90)}어절 이하`;
|
|
66
|
+
const lengthBasis = [`profile.${profile.kind}.sentence.length`];
|
|
67
|
+
if (enabled(config, "longSentence")) {
|
|
68
|
+
length += `. ${config.longSentenceMax}어절을 넘으면 longSentence가 지적`;
|
|
69
|
+
lengthBasis.push("rule.longSentence");
|
|
70
|
+
}
|
|
71
|
+
rows.push({ id: "sentenceLength", label: "문장 길이", guidance: length, basis: lengthBasis });
|
|
72
|
+
|
|
73
|
+
if (profile.endingRuns !== null) {
|
|
74
|
+
let endingText = `같은 끝맺음 연속 중앙 ${percentile(profile.endingRuns, 50)}개, 90%가 ${percentile(profile.endingRuns, 90)}개 이하`;
|
|
75
|
+
const endingBasis = [`profile.${profile.kind}.endingRuns`];
|
|
76
|
+
if (enabled(config, "endingRepeat")) {
|
|
77
|
+
endingText += `. 인과나 독자 호출 없이 ${config.endingRun}개부터 endingRepeat가 지적`;
|
|
78
|
+
endingBasis.push("rule.endingRepeat");
|
|
79
|
+
}
|
|
80
|
+
rows.push({ id: "endingRun", label: "끝맺음", guidance: endingText, basis: endingBasis });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const commas = sentence.get("commas");
|
|
84
|
+
if (!commas) throw new Error("프로파일에 sentence.commas가 없다");
|
|
85
|
+
rows.push({
|
|
86
|
+
id: "commas",
|
|
87
|
+
label: "쉼표",
|
|
88
|
+
guidance: `문장 중앙 ${percentile(commas, 50)}개, 90%가 ${percentile(commas, 90)}개 이하, 99%가 ${percentile(commas, 99)}개 이하`,
|
|
89
|
+
basis: [`profile.${profile.kind}.sentence.commas`],
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const eui = sentence.get("euiCount");
|
|
93
|
+
if (!eui) throw new Error("프로파일에 sentence.euiCount가 없다");
|
|
94
|
+
let euiText = `문장 중앙 ${percentile(eui, 50)}개, 90%가 ${percentile(eui, 90)}개 이하`;
|
|
95
|
+
const euiBasis = [`profile.${profile.kind}.sentence.euiCount`];
|
|
96
|
+
if (enabled(config, "euiChain")) {
|
|
97
|
+
euiText += ". 3개부터, 붙은 2개부터 euiChain이 지적";
|
|
98
|
+
euiBasis.push("rule.euiChain");
|
|
99
|
+
}
|
|
100
|
+
rows.push({ id: "euiCount", label: "의", guidance: euiText, basis: euiBasis });
|
|
101
|
+
|
|
102
|
+
const nounRun = sentence.get("nounRun");
|
|
103
|
+
if (!nounRun) throw new Error("프로파일에 sentence.nounRun이 없다");
|
|
104
|
+
let nounText = `문장 90%에서 조사 없는 명사 연쇄가 ${percentile(nounRun, 90)}개 이하`;
|
|
105
|
+
const nounBasis = [`profile.${profile.kind}.sentence.nounRun`];
|
|
106
|
+
if (enabled(config, "nounPile")) {
|
|
107
|
+
nounText += `. ${config.nounPileMin}개부터 nounPile이 지적`;
|
|
108
|
+
nounBasis.push("rule.nounPile");
|
|
109
|
+
}
|
|
110
|
+
rows.push({ id: "nounRun", label: "명사 연쇄", guidance: nounText, basis: nounBasis });
|
|
111
|
+
|
|
112
|
+
const newTopics = sentence.get("newTopics");
|
|
113
|
+
if (!newTopics) throw new Error("프로파일에 sentence.newTopics가 없다");
|
|
114
|
+
rows.push({
|
|
115
|
+
id: "newTopics",
|
|
116
|
+
label: "새 화제",
|
|
117
|
+
guidance: `문장 중앙 ${percentile(newTopics, 50)}개, 90%가 ${percentile(newTopics, 90)}개 이하`,
|
|
118
|
+
basis: [`profile.${profile.kind}.sentence.newTopics`],
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const connector = profile.rates.connector;
|
|
122
|
+
rows.push({
|
|
123
|
+
id: "connector",
|
|
124
|
+
label: "문두 접속",
|
|
125
|
+
guidance: `문서 중앙은 문장의 ${percent(connector.p50)}%, 문서 90%가 ${percent(connector.p90)}% 이하`,
|
|
126
|
+
basis: [`profile.${profile.kind}.rates.connector`],
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const numbers = sentence.get("numbers");
|
|
130
|
+
if (!numbers) throw new Error("프로파일에 sentence.numbers가 없다");
|
|
131
|
+
const numberP50 = percentile(numbers, 50);
|
|
132
|
+
const numberP90 = percentile(numbers, 90);
|
|
133
|
+
const numberText =
|
|
134
|
+
numberP50 === 0 && numberP90 === 0
|
|
135
|
+
? "문장 중앙과 90%에서 숫자 없음"
|
|
136
|
+
: `문장 중앙 ${numberP50}개, 90%가 ${numberP90}개 이하`;
|
|
137
|
+
rows.push({ id: "numbers", label: "숫자", guidance: numberText, basis: [`profile.${profile.kind}.sentence.numbers`] });
|
|
138
|
+
|
|
139
|
+
const question = profile.rates.question;
|
|
140
|
+
let questionText;
|
|
141
|
+
let questionBasis;
|
|
142
|
+
if (enabled(config, "noQuestion")) {
|
|
143
|
+
questionText = config.enforceStyle.includes("noQuestion")
|
|
144
|
+
? "선택한 정책: 절이 2개 이상이면 글 전체에 물음표 최소 1개"
|
|
145
|
+
: "물음표 부재는 notice다. 질문 없이 충분히 설명했다면 유지한다";
|
|
146
|
+
questionBasis = [`profile.${profile.kind}.rates.question`, "rule.noQuestion"];
|
|
147
|
+
} else {
|
|
148
|
+
questionText = `규칙 요구 없음. 문서 중앙은 문장의 ${percent(question.p50)}%, 문서 90%가 ${percent(question.p90)}% 이하`;
|
|
149
|
+
questionBasis = [`profile.${profile.kind}.rates.question`];
|
|
150
|
+
}
|
|
151
|
+
rows.push({ id: "question", label: "물음표", guidance: questionText, basis: questionBasis });
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
version: SPEC_VERSION,
|
|
155
|
+
kind: "hanlint.writingSpec",
|
|
156
|
+
preset: config.preset,
|
|
157
|
+
register,
|
|
158
|
+
targetChars,
|
|
159
|
+
profile: profile.kind,
|
|
160
|
+
rows,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** 사람이 읽는 꼴. basis는 JSON에서 확인한다. @param {ReturnType<typeof writingSpec>} spec */
|
|
165
|
+
export function renderWritingSpec(spec) {
|
|
166
|
+
const target = spec.targetChars !== null ? `, ${spec.targetChars}자` : "";
|
|
167
|
+
const lines = [
|
|
168
|
+
`hanlint spec ${spec.preset} 종류, ${spec.register}체${target}. 같은 규칙판의 임계와 ${spec.profile} 프로파일을 쓰기 전 숫자로 편다`,
|
|
169
|
+
];
|
|
170
|
+
const width = Math.max(...spec.rows.map((row) => row.label.length));
|
|
171
|
+
for (const row of spec.rows) lines.push(` ${row.label.padEnd(width)} ${row.guidance}`);
|
|
172
|
+
lines.push("", "이 사양은 품질 점수가 아니다. 쓴 뒤 같은 설정으로 hanlint <글.md>를 실행해 Finding을 확인한다");
|
|
173
|
+
return lines.join("\n");
|
|
174
|
+
}
|
|
@@ -33,15 +33,15 @@ export function run(doc) {
|
|
|
33
33
|
const reported = new Set();
|
|
34
34
|
for (const block of doc.codeBlocks) {
|
|
35
35
|
const reader = doc.reader.beforeBlock[block.index];
|
|
36
|
-
const [
|
|
37
|
-
//
|
|
36
|
+
const [, blockDirs] = createdIn(block.lines.map(([, line]) => line));
|
|
37
|
+
// 폴더 준비는 블록 단위로 보고 파일 공급은 실제 줄 순서로 쌓는다.
|
|
38
38
|
const have = new Set([...reader.files, ...blockDirs, ...reported]);
|
|
39
39
|
for (const [line, code] of block.lines) {
|
|
40
40
|
const reads = readsIn(code);
|
|
41
41
|
for (const path of reads) {
|
|
42
42
|
if (!isDataFile(path)) continue;
|
|
43
43
|
const name2 = fileName(path);
|
|
44
|
-
if (have.has(name2) ||
|
|
44
|
+
if (have.has(name2) || doc.reader.mentionedBefore(block.index, name2)) continue;
|
|
45
45
|
findings.push(finding(name, line, code.trim(), `\`${name2}\` 을 읽는데 글 어디에서도 만들지 않았다. 독자는 여기서 파일 없음 오류로 멈춘다`, null, "error", DOCUMENT, block.index));
|
|
46
46
|
}
|
|
47
47
|
for (const path of [...writeTargets(code), ...reads]) {
|
|
@@ -52,6 +52,8 @@ export function run(doc) {
|
|
|
52
52
|
reported.add(directory);
|
|
53
53
|
findings.push(finding(name, line, code.trim(), `\`${directory}\` 폴더를 쓰는데 글 어디에서도 만들지 않았다. 없으면 파일을 쓰다 멈춘다`, null, NOTICE, DOCUMENT, block.index));
|
|
54
54
|
}
|
|
55
|
+
const [made] = createdIn([code]);
|
|
56
|
+
for (const file of made) have.add(file);
|
|
55
57
|
}
|
|
56
58
|
}
|
|
57
59
|
return findings;
|
|
@@ -18,14 +18,14 @@ function stdlib() {
|
|
|
18
18
|
return stdlibCache;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
/** @type {Map<string, string
|
|
21
|
+
/** @type {Map<string, Set<string>> | null} */
|
|
22
22
|
let packageCache = null;
|
|
23
23
|
function packageOf() {
|
|
24
24
|
if (!packageCache) {
|
|
25
25
|
packageCache = new Map();
|
|
26
26
|
for (const line of loadLines("pythonPackages.txt")) {
|
|
27
27
|
const [module, pkg] = line.split("\t");
|
|
28
|
-
packageCache.set(module, pkg);
|
|
28
|
+
packageCache.set(module, new Set(pkg.split("|").map(normalize)));
|
|
29
29
|
}
|
|
30
30
|
}
|
|
31
31
|
return packageCache;
|
|
@@ -105,9 +105,10 @@ export function run(doc) {
|
|
|
105
105
|
for (const module of modules) {
|
|
106
106
|
const top = module.split(".")[0];
|
|
107
107
|
if (!top || stdlib().has(top) || local.has(top)) continue;
|
|
108
|
-
const
|
|
109
|
-
if (!packages.has(pkg)) {
|
|
110
|
-
|
|
108
|
+
const acceptable = packageOf().get(top) ?? new Set([normalize(top)]);
|
|
109
|
+
if (![...acceptable].some((pkg) => packages.has(pkg))) {
|
|
110
|
+
const packageNames = [...acceptable].sort().join("`, `");
|
|
111
|
+
findings.push(finding(name, line, code.trim(), `\`${top}\` 를 import 하는데 설치 줄에 허용되는 이름 \`${packageNames}\` 가 없다. 독자는 ModuleNotFoundError 에서 멈춘다`, null, "error", DOCUMENT, block.index));
|
|
111
112
|
}
|
|
112
113
|
}
|
|
113
114
|
}
|
|
@@ -5,11 +5,11 @@ export const name = "noQuestion";
|
|
|
5
5
|
export const mechanism = "threshold";
|
|
6
6
|
|
|
7
7
|
/** @param {import("../../fingerprint/build.js").DocumentPrint} doc */
|
|
8
|
-
export function run(doc) {
|
|
8
|
+
export function run(doc, config) {
|
|
9
9
|
if (doc.bodySections.length < 2 || doc.questionCount > 0) return [];
|
|
10
10
|
const first = doc.sentences.length ? doc.sentences[0] : null;
|
|
11
11
|
const why = doc.readerCallCount === 0
|
|
12
|
-
? "물음표가 한 번도 없고 독자를 부르는 말도 없다.
|
|
13
|
-
: "물음표가 한 번도 없다.
|
|
14
|
-
return [finding(name, first ? first.line : 1, first ? first.text : "", why, null, "error", DOCUMENT, -1)];
|
|
12
|
+
? "물음표가 한 번도 없고 독자를 부르는 말도 없다. 질문이나 독자 호출이 필요한 글인지 판단하고 충분하면 유지한다"
|
|
13
|
+
: "물음표가 한 번도 없다. 질문이 필요한 글인지 판단하고 설명만으로 충분하면 유지한다";
|
|
14
|
+
return [finding(name, first ? first.line : 1, first ? first.text : "", why, null, config.enforceStyle.includes(name) ? "error" : "notice", DOCUMENT, -1)];
|
|
15
15
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/** 독자 부채는 새 판정이 아니라 reader 기제 Finding을 사람이 읽는 이름으로 묶은 것이다. */
|
|
3
|
+
|
|
4
|
+
import { ruleMechanism } from "./registry.js";
|
|
5
|
+
|
|
6
|
+
/** @param {import("./finding.js").Finding[]} findings */
|
|
7
|
+
export function readerDebts(findings) {
|
|
8
|
+
return findings.filter((finding) => ruleMechanism(finding.rule) === "reader");
|
|
9
|
+
}
|
package/src/rules/registry.js
CHANGED
|
@@ -154,6 +154,33 @@ export function ruleSummary(name) {
|
|
|
154
154
|
return ruleDoc(name).split("\n")[0];
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
+
/** 기술서의 네 절 표. 파이썬 rules/registry.py 의 REQUIRED_SECTIONS 와 같다. */
|
|
158
|
+
export const REQUIRED_SECTIONS = ["왜:", "어디서:", "고치기:", "안 잡는 것:"];
|
|
159
|
+
|
|
160
|
+
/** 기술서에서 절 하나의 본문을 한 줄로. 파이썬 docSection 과 같다. @param {string} doc @param {string} label */
|
|
161
|
+
export function docSection(doc, label) {
|
|
162
|
+
const collected = [];
|
|
163
|
+
let inside = false;
|
|
164
|
+
for (const raw of doc.split("\n")) {
|
|
165
|
+
const line = raw.trim();
|
|
166
|
+
if (!inside) {
|
|
167
|
+
if (line.startsWith(label)) {
|
|
168
|
+
inside = true;
|
|
169
|
+
collected.push(line.slice(label.length).trim());
|
|
170
|
+
}
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
if (!line || REQUIRED_SECTIONS.some((other) => line.startsWith(other))) break;
|
|
174
|
+
collected.push(line);
|
|
175
|
+
}
|
|
176
|
+
return collected.filter(Boolean).join(" ");
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** 규칙의 고치기 절 한 줄. primer 가 쓰기 전에 읽히는 그 문장이다. @param {string} name */
|
|
180
|
+
export function ruleFix(name) {
|
|
181
|
+
return docSection(ruleDoc(name), "고치기:");
|
|
182
|
+
}
|
|
183
|
+
|
|
157
184
|
/**
|
|
158
185
|
* 부류의 사람 이름. 파이썬 rules/registry.py 의 CATEGORY_TITLES 와 같은 값과 같은 순서다.
|
|
159
186
|
* @type {Record<string, string>}
|
|
@@ -13,7 +13,7 @@ export function run(doc, config) {
|
|
|
13
13
|
for (const sentence of doc.sentences) {
|
|
14
14
|
if (sentence.nounRun >= config.nounPileMin) {
|
|
15
15
|
findings.push(
|
|
16
|
-
finding(name, sentence.line, sentence.text, `명사 ${sentence.nounRun}개가 조사 없이 이어진다. 관계가 표시되지 않아 독자가 조사를 끼워 넣는다. 동사로 되돌린다`, null, "error", SENTENCE, sentence.index),
|
|
16
|
+
finding(name, sentence.line, sentence.text, `명사 ${sentence.nounRun}개가 조사 없이 이어진다. 관계가 표시되지 않아 독자가 조사를 끼워 넣는다. 동사로 되돌린다`, null, config.enforceStyle.includes(name) ? "error" : "notice", SENTENCE, sentence.index),
|
|
17
17
|
);
|
|
18
18
|
}
|
|
19
19
|
}
|