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.
Files changed (38) hide show
  1. package/README.md +36 -1
  2. package/data/checkResult.schema.json +75 -0
  3. package/data/checkResultV2.schema.json +98 -0
  4. package/data/finding.schema.json +38 -0
  5. package/data/patch.schema.json +14 -0
  6. package/data/patchResult.schema.json +86 -0
  7. package/data/patchResultV2.schema.json +71 -0
  8. package/data/profiles.json +1 -1
  9. package/data/pythonPackages.txt +3 -2
  10. package/data/readerContract.schema.json +20 -0
  11. package/data/readerContractConformanceV1.json +168 -0
  12. package/data/readerContractV2.schema.json +64 -0
  13. package/data/ruleDocs.json +3 -3
  14. package/data/version.json +1 -1
  15. package/data/writingBrief.schema.json +1 -1
  16. package/data/writingBriefV2.schema.json +1 -1
  17. package/package.json +1 -1
  18. package/src/cli/help.js +34 -0
  19. package/src/cli/main.js +241 -28
  20. package/src/config/patch.js +48 -0
  21. package/src/config/readerContract.js +245 -0
  22. package/src/config/settings.js +31 -2
  23. package/src/data/profiles.js +12 -0
  24. package/src/fingerprint/codeMarkers.js +1 -1
  25. package/src/guard/contract.js +174 -0
  26. package/src/guard/editLocality.js +27 -0
  27. package/src/guard/outline.js +46 -0
  28. package/src/guard/receipt.js +180 -0
  29. package/src/guard/surface.js +184 -0
  30. package/src/index.js +39 -1
  31. package/src/report/compactReport.js +1 -1
  32. package/src/report/writingSpec.js +174 -0
  33. package/src/rules/code/inputFileSource.js +5 -3
  34. package/src/rules/code/installImport.js +6 -5
  35. package/src/rules/document/noQuestion.js +4 -4
  36. package/src/rules/readerDebt.js +9 -0
  37. package/src/rules/registry.js +27 -0
  38. package/src/rules/sentence/nounPile.js +1 -1
@@ -38,6 +38,27 @@ PRESETS.guide = [...PRESETS.blog, ...REFERENCE];
38
38
  PRESETS.essay = [...PRESETS.report, ...NARRATIVE];
39
39
  PRESETS.fiction = [...PRESETS.report, ...NARRATIVE];
40
40
  PRESETS.encyclopedia = [...PRESETS.docs, ...ENCYCLOPEDIC];
41
+ // 대화 답변에서 끄는 것. 글의 짜임을 재는 규칙은 글 한 편을 전제한다. 파이썬 settings.py 의 CONVERSATION 과 같다.
42
+ const CONVERSATION = [
43
+ "countMismatch",
44
+ "duplicateBlock",
45
+ "factListParagraph",
46
+ "firstResultDistance",
47
+ "headingSentence",
48
+ "headingSkip",
49
+ "headingUniform",
50
+ "inputFileSource",
51
+ "installImport",
52
+ "introLong",
53
+ "keywordMissing",
54
+ "noQuestion",
55
+ "outsideProfile",
56
+ "paraFragment",
57
+ "platformApi",
58
+ "promiseRecall",
59
+ "sectionNoProse",
60
+ ];
61
+ PRESETS.chat = CONVERSATION;
41
62
 
42
63
  /** 프리셋 → 견줄 프로파일의 종류. 정본은 파이썬 config/settings.py 의 PROFILE_OF 다. @type {Record<string, string>} */
43
64
  export const PROFILE_OF = {
@@ -48,6 +69,7 @@ export const PROFILE_OF = {
48
69
  essay: "essay",
49
70
  fiction: "fiction",
50
71
  encyclopedia: "encyclopedia",
72
+ chat: null,
51
73
  };
52
74
 
53
75
  export const PRESET_NAMES = Object.keys(PRESETS);
@@ -57,6 +79,7 @@ export const DEFAULT_PRESET = PRESET_NAMES[0];
57
79
  /**
58
80
  * @typedef {object} Config
59
81
  * @property {string} preset 글의 종류. PRESETS 가 정한 규칙을 처음부터 끈다
82
+ * @property {string[]} enforceStyle
60
83
  * @property {Set<string>} disable 끌 규칙 이름
61
84
  * @property {string | null} keywordField
62
85
  * @property {string[]} introFields
@@ -96,6 +119,7 @@ export function defaultConfig() {
96
119
  return {
97
120
  preset: DEFAULT_PRESET,
98
121
  disable: new Set(),
122
+ enforceStyle: [],
99
123
  keywordField: null,
100
124
  introFields: [],
101
125
  endingFields: [],
@@ -133,12 +157,12 @@ export function defaultConfig() {
133
157
 
134
158
  /** @param {Config} config @param {string} ruleName */
135
159
  export function enabled(config, ruleName) {
136
- return !config.disable.has(ruleName) && !PRESETS[config.preset].includes(ruleName);
160
+ return !config.disable.has(ruleName) && (!PRESETS[config.preset].includes(ruleName) || config.enforceStyle.includes(ruleName));
137
161
  }
138
162
 
139
163
  /** 지금 꺼져 있는 규칙 이름. 프리셋이 끈 것과 disable 이 끈 것을 합친다. @param {Config} config */
140
164
  export function offRules(config) {
141
- return [...new Set([...PRESETS[config.preset], ...config.disable])].sort();
165
+ return [...new Set([...PRESETS[config.preset].filter((name) => !config.enforceStyle.includes(name)), ...config.disable])].sort();
142
166
  }
143
167
 
144
168
  /** @param {Record<string, unknown>} data @returns {Config} */
@@ -155,6 +179,11 @@ export function configFromMapping(data) {
155
179
  throw new Error(`preset 은 ${PRESET_NAMES.join(", ")} 가운데 하나다: ${JSON.stringify(value)}`);
156
180
  }
157
181
  config.preset = /** @type {string} */ (value);
182
+ } else if (key === "enforceStyle") {
183
+ if (!Array.isArray(value) || value.some((name) => !["noQuestion", "nounPile"].includes(name))) {
184
+ throw new Error("enforceStyle 은 noQuestion, nounPile의 배열이다");
185
+ }
186
+ config.enforceStyle = [...value];
158
187
  } else if (key === "dictionary") {
159
188
  config.dictionary = { .../** @type {Record<string, unknown[]>} */ (value) };
160
189
  } else if (key === "exemplars") {
@@ -36,6 +36,10 @@ export const TYPE_LABELS = {
36
36
  * @property {number} sentences
37
37
  * @property {number} paragraphs
38
38
  * @property {Map<string, Histogram>} sentence
39
+ * @property {Map<string, Histogram>} paragraph
40
+ * @property {Histogram | null} endingRuns
41
+ * @property {Record<string, number>} endingTransitions
42
+ * @property {Record<string, Record<string, number>>} rates
39
43
  * @property {string} label
40
44
  */
41
45
 
@@ -62,6 +66,10 @@ function profileFromDict(kind, data) {
62
66
  for (const [name, value] of Object.entries(/** @type {Record<string, Record<string, unknown>>} */ (data.sentence))) {
63
67
  sentence.set(name, histogramFromDict(value));
64
68
  }
69
+ const paragraph = new Map();
70
+ for (const [name, value] of Object.entries(/** @type {Record<string, Record<string, unknown>>} */ (data.paragraph))) {
71
+ paragraph.set(name, histogramFromDict(value));
72
+ }
65
73
  const documents = Number(data.documents);
66
74
  return {
67
75
  kind,
@@ -69,6 +77,10 @@ function profileFromDict(kind, data) {
69
77
  sentences: Number(data.sentences),
70
78
  paragraphs: Number(data.paragraphs),
71
79
  sentence,
80
+ paragraph,
81
+ endingRuns: data.endingRuns ? histogramFromDict(/** @type {Record<string, unknown>} */ (data.endingRuns)) : null,
82
+ endingTransitions: { .../** @type {Record<string, number>} */ (data.endingTransitions ?? {}) },
83
+ rates: { .../** @type {Record<string, Record<string, number>>} */ (data.rates ?? {}) },
72
84
  label: TYPE_LABELS[kind] ?? `참조 글 ${documents}편`,
73
85
  };
74
86
  }
@@ -13,7 +13,7 @@ const READS = new RegExp(
13
13
  const OPENS = new RegExp("\\bopen" + QUOTED + "\\s*(?:,\\s*(?:mode\\s*=\\s*)?[\"']([^\"']*)[\"'])?", "g");
14
14
  const WRITES = new RegExp(
15
15
  "\\b(?:to_csv|to_excel|to_parquet|to_json|to_sql|write_csv|write_parquet|write_json|write_ndjson|sink_parquet|sink_csv|" +
16
- "write_text|write_bytes|savefig|save|imwrite|savetxt|dump)" + QUOTED,
16
+ "write_text|write_bytes|savefig|save|imwrite|savetxt|dump|ExcelWriter)" + QUOTED,
17
17
  "g",
18
18
  );
19
19
  const SHELL_WRITES = /(?:>>?|-o|-O|--output|Out-File)\s*["']?([\w./\\-]+\.[A-Za-z0-9]+)/g;
@@ -0,0 +1,174 @@
1
+ // @ts-check
2
+ /** Reader Contract 검사와 이유가 붙은 국소 Patch 검증. */
3
+ import { createHash } from "node:crypto";
4
+
5
+ import { Patch } from "../config/patch.js";
6
+ import { Contract, ContractV2, Outline, ProtectedSurface, parseContract } from "../config/readerContract.js";
7
+ import { defaultConfig } from "../config/settings.js";
8
+ import { headingsOf } from "../document/model.js";
9
+ import { parseMarkdown } from "../document/parseMarkdown.js";
10
+ import { buildFingerprint } from "../fingerprint/build.js";
11
+ import { runAll } from "../rules/registry.js";
12
+ import { editIssues } from "./editLocality.js";
13
+ import { compareOutline, summarizeDocument } from "./outline.js";
14
+ import { CheckResult, PatchResult } from "./receipt.js";
15
+ import { compareText, factLines, protectedSurface, surfaceDiff, surfaceViolationCount } from "./surface.js";
16
+
17
+ /** @param {string} text */
18
+ function digest(text) {
19
+ return createHash("sha256").update(text, "utf8").digest("hex");
20
+ }
21
+
22
+ /** 원문의 보호 표면을 모두 덮는 version 1 Contract 초안을 만든다. @param {string} text @param {string} reader @param {string} goal */
23
+ export function contractFromText(text, reader, goal) {
24
+ const contract = new Contract(reader, goal, factLines(text));
25
+ const result = surfaceDiff(contract.text, text);
26
+ const missing = [];
27
+ for (const kind of ["missingNumbers", "missingUrls", "missingCode", "missingLinks"]) {
28
+ for (const value of result[/** @type {keyof typeof result} */ (kind)]) missing.push(`${kind}=${value}`);
29
+ }
30
+ if (missing.length) throw new Error(`reader 또는 goal이 원문에 없는 보호 원자를 넣었다: ${missing.join(", ")}`);
31
+ if (surfaceViolationCount(result)) {
32
+ const issues = Object.entries(result).flatMap(([kind, values]) => values.map((value) => `${kind}=${value}`));
33
+ throw new Error(`계약 초안이 원문의 보호 표면을 모두 담지 못했다: ${issues.join(", ")}`);
34
+ }
35
+ return contract;
36
+ }
37
+
38
+ /** 원문의 자동 표면과 한 수준의 제목을 분리한 version 2 Contract 초안을 만든다. @param {string} text @param {string} reader @param {string} goal @param {number} [outlineLevel] @param {string[]} [facts] */
39
+ export function contractFromTextV2(text, reader, goal, outlineLevel = 2, facts = []) {
40
+ const doc = parseMarkdown(text);
41
+ const headings = headingsOf(doc, outlineLevel).map((heading) => heading.text);
42
+ const surface = protectedSurface(text);
43
+ const contract = new ContractV2(
44
+ reader,
45
+ goal,
46
+ facts,
47
+ new ProtectedSurface(surface.numbers, surface.urls, surface.code, surface.links),
48
+ new Outline(outlineLevel, headings),
49
+ );
50
+ const result = surfaceDiff(contract.text, text);
51
+ if (surfaceViolationCount(result)) {
52
+ const issues = Object.entries(result).flatMap(([kind, values]) => values.map((value) => `${kind}=${value}`));
53
+ throw new Error(`reader, goal 또는 facts가 원문에 없는 보호 원자를 넣었다: ${issues.join(", ")}`);
54
+ }
55
+ return contract;
56
+ }
57
+
58
+ /** @param {string} text @param {Contract | ContractV2 | Record<string, unknown>} rawContract @param {import("../config/settings.js").Config} [config] @param {string | null} [path] */
59
+ export function check(text, rawContract, config = defaultConfig(), path = null) {
60
+ const contract = rawContract instanceof Contract || rawContract instanceof ContractV2 ? rawContract : parseContract(rawContract);
61
+ const doc = buildFingerprint(parseMarkdown(text, path), config);
62
+ const findings = runAll(doc, config);
63
+ const outline = contract instanceof ContractV2 ? compareOutline(contract.outline, doc) : null;
64
+ const document = contract instanceof ContractV2 ? summarizeDocument(doc) : null;
65
+ return new CheckResult(contract.digest, digest(text), surfaceDiff(contract.text, text), findings, contract.version, outline, document, contract instanceof ContractV2 ? contract.lockedFacts.filter((fact) => !text.includes(fact)) : []);
66
+ }
67
+
68
+ /** @param {CheckResult} result */
69
+ function contractIssues(result) {
70
+ const found = new Set();
71
+ for (const [kind, values] of Object.entries(result.surface)) {
72
+ for (const value of values) found.add(`${kind}\u0000${value}`);
73
+ }
74
+ if (result.outline) {
75
+ for (const mismatch of result.outline.mismatches) {
76
+ found.add(`outline\u0000${mismatch.position}:${mismatch.expected ?? ""}:${mismatch.actual ?? ""}`);
77
+ }
78
+ }
79
+ for (const fact of result.missingFacts) found.add(`lockedFacts\u0000${fact}`);
80
+ return found;
81
+ }
82
+
83
+ /** @param {CheckResult} result @param {string} reason */
84
+ function reasonCount(result, reason) {
85
+ if (reason === "lockedFacts") return result.missingFacts.length;
86
+ if (reason in result.surface) return result.surface[/** @type {keyof typeof result.surface} */ (reason)].length;
87
+ if (reason === "outline" && result.outline) return result.outline.mismatches.length;
88
+ return result.findings.filter((finding) => finding.rule === reason).length;
89
+ }
90
+
91
+ /** @param {import("../rules/finding.js").Finding} finding */
92
+ function errorSignature(finding) {
93
+ return `${finding.rule}\u0000${finding.quote}`;
94
+ }
95
+
96
+ /** @param {CheckResult} before @param {CheckResult} after */
97
+ function addedErrors(before, after) {
98
+ const remaining = new Map();
99
+ for (const finding of before.findings) {
100
+ if (finding.severity !== "error") continue;
101
+ const signature = errorSignature(finding);
102
+ remaining.set(signature, (remaining.get(signature) ?? 0) + 1);
103
+ }
104
+ return after.findings.filter((finding) => {
105
+ if (finding.severity !== "error") return false;
106
+ const signature = errorSignature(finding);
107
+ const count = remaining.get(signature) ?? 0;
108
+ if (!count) return true;
109
+ remaining.set(signature, count - 1);
110
+ return false;
111
+ });
112
+ }
113
+
114
+ /** 파이썬 str.count와 같은 겹치지 않는 정확 출현 수. @param {string} text @param {string} fragment */
115
+ function exactCount(text, fragment) {
116
+ let count = 0;
117
+ let cursor = 0;
118
+ while (true) {
119
+ const at = text.indexOf(fragment, cursor);
120
+ if (at < 0) return count;
121
+ count += 1;
122
+ cursor = at + fragment.length;
123
+ }
124
+ }
125
+
126
+ /** @param {string} text @param {Patch | Record<string, unknown>} rawPatch @param {Contract | ContractV2 | Record<string, unknown>} rawContract @param {import("../config/settings.js").Config} [config] @param {string | null} [path] */
127
+ export function verifyPatch(text, rawPatch, rawContract, config = defaultConfig(), path = null) {
128
+ const patch = rawPatch instanceof Patch ? rawPatch : Patch.fromMapping(rawPatch);
129
+ const contract = rawContract instanceof Contract || rawContract instanceof ContractV2 ? rawContract : parseContract(rawContract);
130
+ const beforeResult = check(text, contract, config, path);
131
+ const matchCount = exactCount(text, patch.before);
132
+ const reasonBefore = reasonCount(beforeResult, patch.reason);
133
+ if (matchCount !== 1) {
134
+ return new PatchResult({
135
+ contractSha256: contract.digest,
136
+ sourceSha256: beforeResult.draftSha256,
137
+ patchSha256: patch.digest,
138
+ resultSha256: null,
139
+ reason: patch.reason,
140
+ matchCount,
141
+ reasonBefore,
142
+ reasonAfter: null,
143
+ newContractIssues: [],
144
+ newErrors: [],
145
+ resultText: null,
146
+ contractVersion: contract.version,
147
+ });
148
+ }
149
+ const resultText = text.replace(patch.before, patch.after);
150
+ const afterResult = check(resultText, contract, config, path);
151
+ const beforeIssues = contractIssues(beforeResult);
152
+ const newContractIssues = [...contractIssues(afterResult)]
153
+ .filter((issue) => !beforeIssues.has(issue))
154
+ .map((issue) => /** @type {[string, string]} */ (issue.split("\u0000", 2)))
155
+ .sort((left, right) => compareText(left[0], right[0]) || compareText(left[1], right[1]));
156
+ if (contract instanceof ContractV2) {
157
+ newContractIssues.push(...editIssues(text, patch, beforeResult.findings, contract.editPolicy));
158
+ newContractIssues.sort((a, b) => compareText(a[0], b[0]) || compareText(a[1], b[1]));
159
+ }
160
+ return new PatchResult({
161
+ contractSha256: contract.digest,
162
+ sourceSha256: beforeResult.draftSha256,
163
+ patchSha256: patch.digest,
164
+ resultSha256: afterResult.draftSha256,
165
+ reason: patch.reason,
166
+ matchCount,
167
+ reasonBefore,
168
+ reasonAfter: reasonCount(afterResult, patch.reason),
169
+ newContractIssues,
170
+ newErrors: addedErrors(beforeResult, afterResult),
171
+ resultText,
172
+ contractVersion: contract.version,
173
+ });
174
+ }
@@ -0,0 +1,27 @@
1
+ // @ts-check
2
+ /** 선택한 규칙의 Finding 줄과 원문 변경 창을 대조한다. */
3
+ export function editIssues(text, patch, findings, policy) {
4
+ if (!Object.keys(policy).length) return [];
5
+ const limits = policy[patch.reason];
6
+ if (!limits) return [["editPolicy", "reason has no edit budget"]];
7
+ const before = [...patch.before], after = [...patch.after];
8
+ let prefix = 0, suffix = 0;
9
+ while (prefix < Math.min(before.length, after.length) && before[prefix] === after[prefix]) prefix++;
10
+ while (suffix < Math.min(before.length, after.length) - prefix && before.at(-1 - suffix) === after.at(-1 - suffix)) suffix++;
11
+ const changedBefore = before.slice(prefix, before.length - suffix).join("");
12
+ const changedAfter = after.slice(prefix, after.length - suffix).join("");
13
+ const start = text.indexOf(patch.before) + before.slice(0, prefix).join("").length;
14
+ const firstLine = text.slice(0, start).split("\n").length;
15
+ const lastLine = firstLine + changedBefore.split("\n").length - 1;
16
+ const issues = [];
17
+ if (!findings.some((f) => f.rule === patch.reason && firstLine <= f.line && f.line <= lastLine)) {
18
+ issues.push(["editPolicy", "changed range does not contain the Finding line"]);
19
+ }
20
+ if (Math.max([...changedBefore].length, [...changedAfter].length) > limits.maxChars) {
21
+ issues.push(["editPolicy", "changed range exceeds maxChars"]);
22
+ }
23
+ if (Math.max(changedBefore.split("\n").length, changedAfter.split("\n").length) > limits.maxLines) {
24
+ issues.push(["editPolicy", "changed range exceeds maxLines"]);
25
+ }
26
+ return issues;
27
+ }
@@ -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
+ }