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
@@ -0,0 +1,174 @@
1
+ // @ts-check
2
+ /** Reader Contract 검사와 이유가 붙은 국소 Patch 검증. */
3
+ import { sha256 } from "../digest.js";
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 sha256(text);
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 {Pick<import("../config/readerContract.js").Outline, "level" | "headings">} 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
+ }