hanlint 0.0.8 → 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 +45 -5
- package/data/checkResult.schema.json +75 -0
- package/data/checkResultV2.schema.json +98 -0
- package/data/exemplars.json +2 -26
- package/data/finding.schema.json +38 -0
- package/data/holeKinds.json +1 -12
- package/data/patch.schema.json +14 -0
- package/data/patchResult.schema.json +86 -0
- package/data/patchResultV2.schema.json +71 -0
- package/data/patterns.json +0 -11
- package/data/platformApis.txt +3 -1
- 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/ruleCategories.json +0 -4
- package/data/ruleDocs.json +7 -11
- package/data/ruleMechanisms.json +0 -4
- package/data/spelling.json +1 -1
- package/data/version.json +1 -1
- package/data/writingBrief.schema.json +1 -1
- package/data/writingBriefV2.schema.json +1 -1
- package/koglType1.LICENSE.md +25 -0
- package/package.json +6 -2
- package/src/cli/help.js +34 -0
- package/src/cli/main.js +247 -35
- package/src/config/patch.js +48 -0
- package/src/config/readerContract.js +245 -0
- package/src/config/settings.js +47 -14
- package/src/data/profiles.js +12 -0
- package/src/fingerprint/build.js +10 -7
- 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 +8 -6
- package/src/rules/document/keywordHeading.js +7 -2
- package/src/rules/document/noQuestion.js +4 -4
- package/src/rules/document/tableOddCell.js +2 -1
- package/src/rules/readerDebt.js +9 -0
- package/src/rules/registry.js +27 -8
- package/src/rules/sentence/danglingDeixis.js +15 -0
- package/src/rules/sentence/negationRedefine.js +2 -1
- package/src/rules/sentence/nounPile.js +1 -1
- package/src/rules/structure/headingUniform.js +3 -3
- package/src/rules/structure/introLong.js +3 -1
- package/data/coverageTypes.txt +0 -7
- package/src/rules/paragraph/topicBreak.js +0 -26
- package/src/rules/sentence/ellipsis.js +0 -32
- package/src/rules/structure/headingQuestion.js +0 -19
- package/src/rules/structure/sectionResult.js +0 -38
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/** Reader Contract가 잠근 제목 순서와 글 지문의 구조 요약. */
|
|
3
|
+
|
|
4
|
+
/** @typedef {{position: number, expected: string | null, actual: string | null}} OutlineMismatch */
|
|
5
|
+
/** @typedef {{matches: boolean, level: number, expected: string[], actual: string[], mismatches: OutlineMismatch[]}} OutlineDiff */
|
|
6
|
+
/** @typedef {{heading: string, line: number, paragraphCount: number, codeBlockCount: number}} SectionSummary */
|
|
7
|
+
/** @typedef {{sentenceCount: number, paragraphCount: number, sectionCount: number, wordCount: number, questionCount: number, readerCallCount: number, sections: SectionSummary[]}} DocumentSummary */
|
|
8
|
+
|
|
9
|
+
/** @param {import("../config/readerContract.js").Outline} outline @param {import("../fingerprint/build.js").DocumentPrint} doc @returns {OutlineDiff} */
|
|
10
|
+
export function compareOutline(outline, doc) {
|
|
11
|
+
const actual = doc.headings.filter(([level]) => level === outline.level).map(([, title]) => title);
|
|
12
|
+
const mismatches = [];
|
|
13
|
+
const count = Math.max(outline.headings.length, actual.length);
|
|
14
|
+
for (let index = 0; index < count; index += 1) {
|
|
15
|
+
const expected = outline.headings[index] ?? null;
|
|
16
|
+
const found = actual[index] ?? null;
|
|
17
|
+
if (expected !== found) {
|
|
18
|
+
mismatches.push({ position: index + 1, expected, actual: found });
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return {
|
|
22
|
+
matches: mismatches.length === 0,
|
|
23
|
+
level: outline.level,
|
|
24
|
+
expected: [...outline.headings],
|
|
25
|
+
actual,
|
|
26
|
+
mismatches,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** @param {import("../fingerprint/build.js").DocumentPrint} doc @returns {DocumentSummary} */
|
|
31
|
+
export function summarizeDocument(doc) {
|
|
32
|
+
return {
|
|
33
|
+
sentenceCount: doc.sentences.length,
|
|
34
|
+
paragraphCount: doc.paragraphs.length,
|
|
35
|
+
sectionCount: doc.bodySections.length,
|
|
36
|
+
wordCount: doc.wordCount,
|
|
37
|
+
questionCount: doc.questionCount,
|
|
38
|
+
readerCallCount: doc.readerCallCount,
|
|
39
|
+
sections: doc.bodySections.map((section) => ({
|
|
40
|
+
heading: section.title,
|
|
41
|
+
line: section.startLine,
|
|
42
|
+
paragraphCount: section.paragraphs.length,
|
|
43
|
+
codeBlockCount: section.blockKinds.filter((kind) => kind === "code").length,
|
|
44
|
+
})),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/** Reader Contract 검사와 Patch 검증의 결정적 영수증. */
|
|
3
|
+
|
|
4
|
+
import { findingAsDict } from "../rules/finding.js";
|
|
5
|
+
import { readerDebts } from "../rules/readerDebt.js";
|
|
6
|
+
import { compareText, surfaceViolationCount } from "./surface.js";
|
|
7
|
+
|
|
8
|
+
export const CHECK_MEANING = "violationCount는 선언한 보호 원자와 hanlint error의 수다. facts의 관계와 진실, 빠진 의미, 독자 효용과 자연스러움은 검증하지 않는다";
|
|
9
|
+
export const CHECK_MEANING_V2 = "violationCount는 선언한 보호 원자, 제목 구조, 잠금 문구 누락과 hanlint error의 수다. facts의 관계와 진실, 빠진 의미, 독자 효용과 자연스러움은 검증하지 않는다";
|
|
10
|
+
export const PATCH_MEANING = "verified는 정확히 한 자리를 바꾸고 명시한 기존 위반을 줄이며 새 보호 원자 위반과 새 error를 만들지 않았다는 뜻뿐이다. 수정문의 의미와 진실, 자연스러움은 승인하지 않는다";
|
|
11
|
+
export const PATCH_MEANING_V2 = "verified는 정확히 한 자리를 바꾸고 명시한 기존 위반을 줄이며 새 계약 위반과 새 error를 만들지 않고 선택한 수정 예산을 지켰다는 뜻뿐이다. 수정문의 의미와 진실, 자연스러움은 승인하지 않는다";
|
|
12
|
+
|
|
13
|
+
/** @param {import("../rules/finding.js").Finding[]} findings */
|
|
14
|
+
function errorRules(findings) {
|
|
15
|
+
/** @type {Record<string, number>} */
|
|
16
|
+
const counted = {};
|
|
17
|
+
for (const finding of findings) {
|
|
18
|
+
if (finding.severity === "error") counted[finding.rule] = (counted[finding.rule] ?? 0) + 1;
|
|
19
|
+
}
|
|
20
|
+
return Object.fromEntries(Object.entries(counted).sort(([left], [right]) => compareText(left, right)));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class CheckResult {
|
|
24
|
+
/** @param {string} contractSha256 @param {string} draftSha256 @param {import("./surface.js").SurfaceDiff} surface @param {import("../rules/finding.js").Finding[]} findings @param {number} [contractVersion] @param {import("./outline.js").OutlineDiff | null} [outline] @param {import("./outline.js").DocumentSummary | null} [document] */
|
|
25
|
+
constructor(contractSha256, draftSha256, surface, findings, contractVersion = 1, outline = null, document = null, missingFacts = []) {
|
|
26
|
+
this.contractSha256 = contractSha256;
|
|
27
|
+
this.draftSha256 = draftSha256;
|
|
28
|
+
this.surface = surface;
|
|
29
|
+
this.findings = findings;
|
|
30
|
+
this.contractVersion = contractVersion;
|
|
31
|
+
this.outline = outline;
|
|
32
|
+
this.document = document;
|
|
33
|
+
this.missingFacts = missingFacts;
|
|
34
|
+
if (![1, 2].includes(contractVersion)) {
|
|
35
|
+
throw new Error(`check result contractVersion 은 1 또는 2다: ${contractVersion}`);
|
|
36
|
+
}
|
|
37
|
+
const hasStructure = outline !== null && document !== null;
|
|
38
|
+
if (hasStructure !== (contractVersion === 2)) {
|
|
39
|
+
throw new Error("version 2 check result만 outline과 document를 함께 가진다");
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
get errorCount() {
|
|
44
|
+
return this.findings.filter((finding) => finding.severity === "error").length;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
get noticeCount() {
|
|
48
|
+
return this.findings.length - this.errorCount;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
get violationCount() {
|
|
52
|
+
return surfaceViolationCount(this.surface) + (this.outline?.mismatches.length ?? 0) + this.errorCount + this.missingFacts.length;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
asDict() {
|
|
56
|
+
const result = {
|
|
57
|
+
version: this.contractVersion,
|
|
58
|
+
kind: "hanlint.checkResult",
|
|
59
|
+
violationCount: this.violationCount,
|
|
60
|
+
contractSha256: this.contractSha256,
|
|
61
|
+
draftSha256: this.draftSha256,
|
|
62
|
+
surface: this.surface,
|
|
63
|
+
lint: {
|
|
64
|
+
errorCount: this.errorCount,
|
|
65
|
+
noticeCount: this.noticeCount,
|
|
66
|
+
errorRules: errorRules(this.findings),
|
|
67
|
+
items: this.findings.map(findingAsDict),
|
|
68
|
+
},
|
|
69
|
+
meaning: this.contractVersion === 1 ? CHECK_MEANING : CHECK_MEANING_V2,
|
|
70
|
+
};
|
|
71
|
+
if (this.contractVersion === 2) return { ...result, outline: this.outline, document: this.document, missingFacts: this.missingFacts };
|
|
72
|
+
return result;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export class PatchResult {
|
|
77
|
+
/** @param {{contractSha256: string, sourceSha256: string, patchSha256: string, resultSha256: string | null, reason: string, matchCount: number, reasonBefore: number, reasonAfter: number | null, newContractIssues: [string, string][], newErrors: import("../rules/finding.js").Finding[], resultText: string | null, contractVersion: number}} values */
|
|
78
|
+
constructor(values) {
|
|
79
|
+
this.contractSha256 = values.contractSha256;
|
|
80
|
+
this.sourceSha256 = values.sourceSha256;
|
|
81
|
+
this.patchSha256 = values.patchSha256;
|
|
82
|
+
this.resultSha256 = values.resultSha256;
|
|
83
|
+
this.reason = values.reason;
|
|
84
|
+
this.matchCount = values.matchCount;
|
|
85
|
+
this.reasonBefore = values.reasonBefore;
|
|
86
|
+
this.reasonAfter = values.reasonAfter;
|
|
87
|
+
this.newContractIssues = values.newContractIssues;
|
|
88
|
+
this.newErrors = values.newErrors;
|
|
89
|
+
this.resultText = values.resultText;
|
|
90
|
+
this.contractVersion = values.contractVersion;
|
|
91
|
+
if (![1, 2].includes(values.contractVersion)) {
|
|
92
|
+
throw new Error(`patch result contractVersion 은 1 또는 2다: ${values.contractVersion}`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
get newSurfaceIssues() {
|
|
97
|
+
return this.newContractIssues.filter(([kind]) => kind !== "outline");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
get reasonReduced() {
|
|
101
|
+
return this.reasonBefore > 0 && this.reasonAfter !== null && this.reasonAfter < this.reasonBefore;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
get violationCount() {
|
|
105
|
+
return Number(this.matchCount !== 1) + Number(!this.reasonReduced) + this.newContractIssues.length + this.newErrors.length;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
get verified() {
|
|
109
|
+
return this.violationCount === 0;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
asDict() {
|
|
113
|
+
const base = {
|
|
114
|
+
version: this.contractVersion,
|
|
115
|
+
kind: "hanlint.patchResult",
|
|
116
|
+
verified: this.verified,
|
|
117
|
+
violationCount: this.violationCount,
|
|
118
|
+
contractSha256: this.contractSha256,
|
|
119
|
+
sourceSha256: this.sourceSha256,
|
|
120
|
+
patchSha256: this.patchSha256,
|
|
121
|
+
resultSha256: this.resultSha256,
|
|
122
|
+
matchCount: this.matchCount,
|
|
123
|
+
reason: {
|
|
124
|
+
name: this.reason,
|
|
125
|
+
before: this.reasonBefore,
|
|
126
|
+
after: this.reasonAfter,
|
|
127
|
+
reduced: this.reasonReduced,
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
const issues = this.newContractIssues.map(([kind, value]) => ({ kind, value }));
|
|
131
|
+
const tail = {
|
|
132
|
+
newErrors: this.newErrors.map(findingAsDict),
|
|
133
|
+
meaning: this.contractVersion === 1 ? PATCH_MEANING : PATCH_MEANING_V2,
|
|
134
|
+
};
|
|
135
|
+
if (this.contractVersion === 1) return { ...base, newSurfaceIssues: issues, ...tail };
|
|
136
|
+
return { ...base, newContractIssues: issues, ...tail };
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** 사람이 결론, 근거, 다음 행동 순서로 읽는 검사 영수증. @param {CheckResult} result */
|
|
141
|
+
export function renderCheck(result) {
|
|
142
|
+
const lines = [result.violationCount === 0 ? "계약 위반 없음" : `계약 위반 ${result.violationCount}건`];
|
|
143
|
+
const labels = [
|
|
144
|
+
["빠진 숫자", result.surface.missingNumbers],
|
|
145
|
+
["계약 밖 숫자", result.surface.unexpectedNumbers],
|
|
146
|
+
["빠진 URL", result.surface.missingUrls],
|
|
147
|
+
["계약 밖 URL", result.surface.unexpectedUrls],
|
|
148
|
+
["빠진 코드", result.surface.missingCode],
|
|
149
|
+
["계약 밖 코드", result.surface.unexpectedCode],
|
|
150
|
+
["빠진 링크 목적지", result.surface.missingLinks],
|
|
151
|
+
["계약 밖 링크 목적지", result.surface.unexpectedLinks],
|
|
152
|
+
];
|
|
153
|
+
for (const [label, values] of labels) if (values.length) lines.push(`- ${label}: ${values.join(", ")}`);
|
|
154
|
+
for (const fact of result.missingFacts) lines.push(`- lockedFacts: ${fact}`);
|
|
155
|
+
if (result.outline) {
|
|
156
|
+
const state = result.outline.matches ? "일치" : `어긋남 ${result.outline.mismatches.length}곳`;
|
|
157
|
+
lines.push(`- 구조: H${result.outline.level} ${result.outline.actual.length}개, ${state}`);
|
|
158
|
+
for (const mismatch of result.outline.mismatches) {
|
|
159
|
+
lines.push(` ${mismatch.position}. 기대 \`${mismatch.expected ?? "없음"}\`, 실제 \`${mismatch.actual ?? "없음"}\``);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (result.document) {
|
|
163
|
+
lines.push(`- 글: 문장 ${result.document.sentenceCount}, 문단 ${result.document.paragraphCount}, 절 ${result.document.sectionCount}`);
|
|
164
|
+
result.document.sections.forEach((section, index) => {
|
|
165
|
+
lines.push(` ${index + 1}. ${section.heading} (${section.line}행, 문단 ${section.paragraphCount}, 코드 ${section.codeBlockCount})`);
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
lines.push(`- lint: error ${result.errorCount}, notice ${result.noticeCount}`);
|
|
169
|
+
if (result.errorCount) {
|
|
170
|
+
lines.push(` ${Object.entries(errorRules(result.findings)).map(([rule, count]) => `${rule} ${count}`).join(", ")}`);
|
|
171
|
+
}
|
|
172
|
+
const debts = readerDebts(result.findings);
|
|
173
|
+
lines.push(debts.length ? `- 독자 부채: ${debts.length}건` : "- 독자 부채: 없음");
|
|
174
|
+
for (const debt of debts) lines.push(` ${debt.line}행 [${debt.rule}] ${debt.why}`);
|
|
175
|
+
if (result.violationCount) lines.push("\n다음: 계약과 글 가운데 틀린 쪽을 바로잡고 같은 check를 다시 실행한다");
|
|
176
|
+
else if (result.noticeCount) lines.push("\n다음: notice를 읽고 고칠지 유지할지 판단한 뒤 사람과 LLM 평가로 넘어간다");
|
|
177
|
+
else lines.push("\n다음: 세어서 잡히는 계약 위반이 없다. 사람과 LLM 평가로 넘어간다");
|
|
178
|
+
lines.push("", result.contractVersion === 1 ? CHECK_MEANING : CHECK_MEANING_V2);
|
|
179
|
+
return lines.join("\n");
|
|
180
|
+
}
|
|
@@ -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;
|