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
package/src/index.js CHANGED
@@ -8,20 +8,69 @@
8
8
  * ```
9
9
  *
10
10
  * 합격과 불합격을 판정하지 않는다. 지적 목록이 비어 있다는 것은 세어서 잡히는 결함이 없다는 뜻이다.
11
- * 지문 지도 (audit, map) 와 프로파일은 파이썬 패키지에 있다.
11
+ * 같은 공개 API를 Node와 브라우저가 공유한다. 파일 경로를 읽는 API만 Node 전용이다.
12
12
  */
13
- import { readFileSync } from "node:fs";
14
-
15
- import { loadConfig } from "./config/loadConfig.js";
13
+ import { readExternal } from "./data/read.js";
14
+ import { Patch } from "./config/patch.js";
15
+ import {
16
+ CONTRACT_VERSION,
17
+ CONTRACT_VERSIONS,
18
+ LATEST_CONTRACT_VERSION,
19
+ Contract,
20
+ ContractV2,
21
+ Outline,
22
+ ProtectedSurface,
23
+ parseContract,
24
+ } from "./config/readerContract.js";
16
25
  import { configFromMapping, defaultConfig } from "./config/settings.js";
17
26
  import { loadVersion } from "./data/load.js";
18
27
  import { parseMarkdown } from "./document/parseMarkdown.js";
28
+ import { headingsOf } from "./document/model.js";
19
29
  import { applyFixes } from "./edit/applyFixes.js";
20
30
  import { buildFingerprint } from "./fingerprint/build.js";
21
31
  import { fingerprintDict } from "./report/fingerprintJson.js";
32
+ import { check, contractFromText, contractFromTextV2, verifyPatch } from "./guard/contract.js";
33
+ import { CheckResult, PatchResult, renderCheck } from "./guard/receipt.js";
34
+ import { compareOutline } from "./guard/outline.js";
35
+ import { protectedSurface, surfaceDiff } from "./guard/surface.js";
22
36
  import { ruleDoc, ruleNames, ruleSummary, runAll } from "./rules/registry.js";
37
+ import { renderJson } from "./report/jsonReport.js";
38
+ import { learnExemplars } from "./learn/edits.js";
39
+ import { learnOperations } from "./learn/operations.js";
40
+
41
+ const nodeConfig = typeof process === "object" && process.versions?.node ? await import("./config/loadConfig.js") : null;
23
42
 
24
- export { applyFixes, configFromMapping, defaultConfig, fingerprintDict, loadConfig, ruleDoc, ruleNames, ruleSummary };
43
+ /** Node 파일 설정. 브라우저에서는 configFromMapping에 설정 객체를 전달한다. @param {string | null} path @param {string | null} start */
44
+ export function loadConfig(path = null, start = null) {
45
+ if (!nodeConfig) throw new Error("브라우저에서는 configFromMapping으로 설정 객체를 전달해 주세요.");
46
+ return nodeConfig.loadConfig(path, start);
47
+ }
48
+
49
+ export {
50
+ CheckResult,
51
+ CONTRACT_VERSION,
52
+ CONTRACT_VERSIONS,
53
+ Contract,
54
+ ContractV2,
55
+ LATEST_CONTRACT_VERSION,
56
+ Outline,
57
+ Patch,
58
+ PatchResult,
59
+ ProtectedSurface,
60
+ applyFixes,
61
+ check,
62
+ contractFromText,
63
+ contractFromTextV2,
64
+ configFromMapping,
65
+ defaultConfig,
66
+ fingerprintDict,
67
+ parseContract,
68
+ renderCheck,
69
+ ruleDoc,
70
+ ruleNames,
71
+ ruleSummary,
72
+ verifyPatch,
73
+ };
25
74
  export const version = loadVersion();
26
75
 
27
76
  /**
@@ -50,5 +99,33 @@ export function lintText(text, config = defaultConfig(), path = null) {
50
99
  * @param {import("./config/settings.js").Config} [config]
51
100
  */
52
101
  export function lintFile(path, config = defaultConfig()) {
53
- return lintText(readFileSync(path, "utf-8"), config, path);
102
+ return lintText(readExternal(path), config, path);
103
+ }
104
+
105
+ /** 지적, 문체 본보기, 승인 고침과 지문을 같은 분석에서 제공한다. @param {string} text @param {import("./config/settings.js").Config} config */
106
+ export function inspectText(text, config = defaultConfig()) {
107
+ const document = fingerprint(text, config);
108
+ const findings = runAll(document, config);
109
+ const path = "draft.md";
110
+ const report = JSON.parse(renderJson(new Map([[path, findings]]), null,
111
+ new Map([[path, document.register]]), config.preset, config.exemplars,
112
+ new Map([[path, document]]), config.patches, config.operations, config.protectedTerms));
113
+ return { findings, report: report.files[0], document: fingerprintDict(document) };
114
+ }
115
+
116
+ /** 사람 수정 전후에서 승인할 후보를 추출한다. 후보 자체는 승인이 아니다. @param {string} before @param {string} after @param {import("./config/settings.js").Config} config */
117
+ export function learnText(before, after, config = defaultConfig()) {
118
+ const beforeDoc = fingerprint(before, config), afterDoc = fingerprint(after, config);
119
+ return {
120
+ exemplars: learnExemplars(beforeDoc, afterDoc, runAll(beforeDoc, config), runAll(afterDoc, config), config.preset),
121
+ operations: learnOperations(beforeDoc, afterDoc, config.preset, config.protectedTerms),
122
+ };
123
+ }
124
+
125
+ /** 자유 원고의 표면과 H2 순서를 비교한다. 빈 글과 반복 제목도 관찰하며 승인 계약을 만들지 않는다. @param {string} before @param {string} after @param {import("./config/settings.js").Config} config */
126
+ export function compareRevision(before, after, config = defaultConfig()) {
127
+ const original = before.normalize("NFC");
128
+ const outline = { level: 2, headings: headingsOf(parseMarkdown(original), 2).map((heading) => heading.text) };
129
+ return { surface: protectedSurface(original), changes: surfaceDiff(original, after),
130
+ outline: compareOutline(outline, fingerprint(after, config)) };
54
131
  }
@@ -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
+ }
@@ -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 [blockWrites, blockDirs] = createdIn(block.lines.map(([, line]) => line));
37
- // 이 블록이 만드는 폴더는 같은 블록의 읽기에도 미리 센다. 파일은 블록 안에서 쓴 것 (blockWrites) 만 따로 본다.
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) || blockWrites.has(name2) || doc.reader.mentionedBefore(block.index, name2)) continue;
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> | null} */
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 pkg = normalize(packageOf().get(top) ?? top);
109
- if (!packages.has(pkg)) {
110
- findings.push(finding(name, line, code.trim(), `\`${top}\` 를 import 하는데 설치 줄에 \`${pkg}\` 가 없다. 독자는 ModuleNotFoundError 에서 멈춘다`, null, "error", DOCUMENT, block.index));
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
+ }
@@ -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
  /**
@@ -154,6 +160,33 @@ export function ruleSummary(name) {
154
160
  return ruleDoc(name).split("\n")[0];
155
161
  }
156
162
 
163
+ /** 기술서의 네 절 표. 파이썬 rules/registry.py 의 REQUIRED_SECTIONS 와 같다. */
164
+ export const REQUIRED_SECTIONS = ["왜:", "어디서:", "고치기:", "안 잡는 것:"];
165
+
166
+ /** 기술서에서 절 하나의 본문을 한 줄로. 파이썬 docSection 과 같다. @param {string} doc @param {string} label */
167
+ export function docSection(doc, label) {
168
+ const collected = [];
169
+ let inside = false;
170
+ for (const raw of doc.split("\n")) {
171
+ const line = raw.trim();
172
+ if (!inside) {
173
+ if (line.startsWith(label)) {
174
+ inside = true;
175
+ collected.push(line.slice(label.length).trim());
176
+ }
177
+ continue;
178
+ }
179
+ if (!line || REQUIRED_SECTIONS.some((other) => line.startsWith(other))) break;
180
+ collected.push(line);
181
+ }
182
+ return collected.filter(Boolean).join(" ");
183
+ }
184
+
185
+ /** 규칙의 고치기 절 한 줄. primer 가 쓰기 전에 읽히는 그 문장이다. @param {string} name */
186
+ export function ruleFix(name) {
187
+ return docSection(ruleDoc(name), "고치기:");
188
+ }
189
+
157
190
  /**
158
191
  * 부류의 사람 이름. 파이썬 rules/registry.py 의 CATEGORY_TITLES 와 같은 값과 같은 순서다.
159
192
  * @type {Record<string, string>}
@@ -165,6 +198,7 @@ export const CATEGORY_TITLES = {
165
198
  document: "두 자리를 대조해 세는 것",
166
199
  orthography: "표기와 띄어쓰기",
167
200
  code: "코드 블록 사이를 대조하는 것",
201
+ screen: "화면의 글에서 세는 것",
168
202
  };
169
203
 
170
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
+ }
@@ -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
  }
@@ -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
+ }