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
package/src/cli/main.js CHANGED
@@ -10,35 +10,54 @@
10
10
  * hanlint rules 규칙 목록을 부류로 묶어서
11
11
  * hanlint explain <규칙> 규칙의 기술서
12
12
  * hanlint patterns 문장 틀. --rule 로 그 규칙을 피하는 것만
13
+ * hanlint primer --preset <종류> 쓰기 전에 읽는 한 장. 켜진 규칙의 고치는 법과 본보기 전후
14
+ * hanlint hook Claude Code가 쓴 마크다운을 같은 턴에 비차단 검사
13
15
  * hanlint baseline 글들/ 지금 있는 지적을 잠근다. 그다음부터 새것만 막힌다
14
16
  * hanlint baseline 글들/ 지금 있는 지적을 잠근다. 그다음부터 새것만 막힌다
15
17
  * hanlint doctor 설정과 꺼진 규칙
16
18
  * hanlint init 주석 달린 hanlint.toml. --output 과 --preset blog|report|docs
19
+ * hanlint contract init 글.md 원문에서 최소 Reader Contract 초안
17
20
  * ```
18
21
  * audit, guard, arena, blueprint, evidence, entailment 같은 확장 명령은 파이썬 패키지에 있다.
19
22
  */
20
23
  import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
21
- import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
24
+ import { dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
22
25
 
23
- import { fingerprint, lintText, ruleDoc, ruleNames, ruleSummary, version } from "../index.js";
26
+ import {
27
+ Patch,
28
+ check,
29
+ contractFromText,
30
+ contractFromTextV2,
31
+ fingerprint,
32
+ lintText,
33
+ parseContract,
34
+ renderCheck,
35
+ ruleDoc,
36
+ ruleNames,
37
+ ruleSummary,
38
+ verifyPatch,
39
+ version,
40
+ } from "../index.js";
24
41
  import { Baseline, build as buildBaseline, DEFAULT_NAME as DEFAULT_BASELINE, load as loadBaseline, prune as pruneBaseline, render as renderBaseline } from "../baseline/store.js";
25
42
  import { loadConfig } from "../config/loadConfig.js";
26
- import { DEFAULT_PRESET, defaultConfig, offRules, PRESET_NAMES, PRESETS } from "../config/settings.js";
43
+ import { defaultConfig, offRules, PRESET_NAMES, PRESETS } from "../config/settings.js";
27
44
  import { applyFixes } from "../edit/applyFixes.js";
28
45
  import { exemplarFor } from "../data/exemplars.js";
29
46
  import { patterns, patternsAvoiding } from "../data/patterns.js";
30
47
  import { HAPNIDA, REGISTERS } from "../analysis/grammar/index.js";
31
- import { CATEGORY_TITLES, MECHANISMS, ruleCategory, ruleMechanism, runAll } from "../rules/registry.js";
48
+ import { CATEGORY_TITLES, MECHANISMS, ruleCategory, ruleFix, ruleMechanism, runAll } from "../rules/registry.js";
32
49
  import { MARKDOWN, SKIPPED_FOLDERS, markdownUnder } from "./walk.js";
33
50
  import { welcome } from "./welcome.js";
51
+ import { rootHelp } from "./help.js";
34
52
  import { renderCompact } from "../report/compactReport.js";
35
53
  import { LAYERS, renderFingerprintJson } from "../report/fingerprintJson.js";
36
54
  import { renderGithub } from "../report/githubReport.js";
37
55
  import { renderJson } from "../report/jsonReport.js";
38
56
  import { renderText } from "../report/textReport.js";
57
+ import { renderWritingSpec, writingSpec } from "../report/writingSpec.js";
39
58
  import { exemplarInRegister, patternInRegister } from "../report/registerMatch.js";
40
59
 
41
- const COMMANDS = ["lint", "fix", "print", "rules", "explain", "patterns", "baseline", "doctor", "init"];
60
+ const COMMANDS = ["lint", "fix", "print", "rules", "explain", "patterns", "primer", "spec", "hook", "baseline", "doctor", "init", "contract", "check", "verify-patch"];
42
61
  const PYTHON_ONLY = [
43
62
  "audit",
44
63
  "map",
@@ -82,23 +101,7 @@ const THRESHOLD_FIELDS = [
82
101
  ];
83
102
  const FLOAT_FIELDS = new Set(["headingUniformRatio", "factListMaxMeanLength"]);
84
103
 
85
- const USAGE = `사용법: hanlint 글.md [다른.md ...] [--format text|compact|json|github] [--severity all|error|notice] [--errors-only]
86
- [--config 파일] [--preset blog|report|docs] [--disable 규칙] [--baseline [파일]] [--output 파일] [--quiet]
87
- [--path 이름 (stdin 의 이름)]
88
- hanlint fix 글.md [--dry-run]
89
- hanlint print 글.md [--layer all|sentences|paragraphs|sections|document]
90
- hanlint rules [--names]
91
- hanlint explain <규칙>
92
- hanlint patterns [--rule 규칙]
93
- hanlint baseline 글들/ [--prune] [--output 파일]
94
- hanlint doctor
95
- hanlint init [--output hanlint.toml] [--preset blog|report|docs] [--force]
96
- hanlint --version
97
-
98
- 파일 자리에 폴더를 주면 그 아래 마크다운을 전부 검사한다 (점으로 시작하는 폴더와 node_modules 는 건너뛴다).
99
- 인자가 없으면 첫 화면이 나온다.
100
- 한국어 글에서 반복되는 결함을 결정적으로 잡는다. 종료 코드는 0 (지적 없음), 1 (error 지적), 2 (파일이나 설정 문제).
101
- ${PYTHON_ONLY.join(", ")} 는 파이썬 패키지 (pip install hanlint) 에 있다.`;
104
+ const USAGE = rootHelp();
102
105
 
103
106
  /** @type {Record<string, "value" | "list" | "flag">} */
104
107
  const OPTION_KINDS = {
@@ -116,9 +119,15 @@ const OPTION_KINDS = {
116
119
  "--dry-run": "flag",
117
120
  "--names": "flag",
118
121
  "--force": "flag",
122
+ "--reply": "flag",
123
+ "--reader": "value",
124
+ "--goal": "value",
125
+ "--outline": "value",
126
+ "--fact": "list",
119
127
  "--preset": "value",
120
128
  "--rule": "value",
121
129
  "--register": "value",
130
+ "--chars": "value",
122
131
  "--baseline": "optional",
123
132
  "--prune": "flag",
124
133
  };
@@ -224,8 +233,8 @@ function checkDisabled(config) {
224
233
  }
225
234
 
226
235
  /** @param {Record<string, string | string[] | boolean>} options @param {string[]} paths */
227
- function configFrom(options, paths) {
228
- const config = loadConfig(/** @type {string | undefined} */ (options["--config"]) ?? null, startFolder(paths));
236
+ function configFrom(options, paths, start = null) {
237
+ const config = loadConfig(/** @type {string | undefined} */ (options["--config"]) ?? null, start ?? startFolder(paths));
229
238
  if (options["--preset"]) config.preset = choose(/** @type {string} */ (options["--preset"]), PRESET_NAMES, "--preset");
230
239
  for (const rule of /** @type {string[]} */ (options["--disable"] ?? [])) config.disable.add(rule);
231
240
  if (options["--profile"]) config.profile = /** @type {string} */ (options["--profile"]);
@@ -242,11 +251,10 @@ function configLabel(config) {
242
251
  return label.split(sep).join("/");
243
252
  }
244
253
 
245
- /** 설정 출처와 지금 도는 프리셋. 기본 프리셋이면 이름을 빼서 줄이 길어지지 않게 한다.
246
- * @param {import("../config/settings.js").Config} config */
254
+ /** 설정 출처와 지금 도는 프리셋. @param {import("../config/settings.js").Config} config */
247
255
  function header(config) {
248
256
  const where = configLabel(config);
249
- return config.preset === DEFAULT_PRESET ? `설정: ${where}` : `설정: ${where}, 프리셋 ${config.preset}`;
257
+ return `설정: ${where}, 프리셋 ${config.preset}`;
250
258
  }
251
259
 
252
260
  /** @param {Map<string, import("../rules/finding.js").Finding[]>} results */
@@ -382,6 +390,73 @@ function runBaseline(args) {
382
390
  return 0;
383
391
  }
384
392
 
393
+ /** @param {string} path */
394
+ function readJson(path) {
395
+ try {
396
+ return JSON.parse(readFileSync(path, "utf-8"));
397
+ } catch (error) {
398
+ if (error instanceof SyntaxError) throw new Error(`${path} JSON을 읽지 못했다: ${error.message}`);
399
+ throw error;
400
+ }
401
+ }
402
+
403
+ /** @param {string[]} args */
404
+ function runCheck(args) {
405
+ const { options, positionals } = parseArgs(args);
406
+ if (positionals.length !== 2) throw new UsageError("Reader Contract JSON과 검사할 마크다운 파일이 필요하다");
407
+ const format = choose(/** @type {string} */ (options["--format"] ?? "json"), ["json", "text"], "--format");
408
+ const [contractPath, path] = positionals;
409
+ const contract = parseContract(readJson(contractPath));
410
+ const config = configFrom(options, [path]);
411
+ const result = check(readOne(path), contract, config, path);
412
+ const rendered = format === "text" ? renderCheck(result) : JSON.stringify(result.asDict(), null, 2);
413
+ emit(rendered, /** @type {string | undefined} */ (options["--output"]));
414
+ return result.violationCount === 0 ? 0 : 1;
415
+ }
416
+
417
+ /** @param {string[]} args */
418
+ function runContract(args) {
419
+ const { options, positionals } = parseArgs(args);
420
+ if (positionals.length !== 2 || positionals[0] !== "init") {
421
+ throw new UsageError("contract init과 계약 초안을 만들 마크다운 파일이 필요하다");
422
+ }
423
+ const reader = options["--reader"];
424
+ const goal = options["--goal"];
425
+ if (typeof reader !== "string" || typeof goal !== "string") {
426
+ throw new UsageError("contract init에는 --reader와 --goal이 필요하다");
427
+ }
428
+ const output = options["--output"];
429
+ if (typeof output === "string" && existsSync(output) && !options["--force"]) {
430
+ throw new Error(`${output} 가 이미 있다. 덮어쓰려면 --force`);
431
+ }
432
+ const outline = options["--outline"];
433
+ const facts = /** @type {string[]} */ (options["--fact"] ?? []);
434
+ let contract;
435
+ if (outline !== undefined) {
436
+ const selected = choose(/** @type {string} */ (outline), ["h1", "h2", "h3", "h4", "h5", "h6"], "--outline");
437
+ contract = contractFromTextV2(readOne(positionals[1]), reader, goal, Number(selected.slice(1)), facts);
438
+ } else {
439
+ if (facts.length) throw new UsageError("--fact는 --outline과 함께 version 2 계약에서 쓴다");
440
+ contract = contractFromText(readOne(positionals[1]), reader, goal);
441
+ }
442
+ emit(JSON.stringify(contract.asDict(), null, 2), typeof output === "string" ? output : undefined);
443
+ return 0;
444
+ }
445
+
446
+ /** @param {string[]} args */
447
+ function runVerifyPatch(args) {
448
+ const { options, positionals } = parseArgs(args);
449
+ if (positionals.length !== 3) throw new UsageError("Reader Contract JSON, 수정 전 마크다운, Patch JSON이 필요하다");
450
+ choose(/** @type {string} */ (options["--format"] ?? "json"), ["json"], "--format");
451
+ const [contractPath, path, patchPath] = positionals;
452
+ const contract = parseContract(readJson(contractPath));
453
+ const patch = Patch.fromMapping(readJson(patchPath));
454
+ const config = configFrom(options, [path]);
455
+ const result = verifyPatch(readOne(path), patch, contract, config, path);
456
+ emit(JSON.stringify(result.asDict(), null, 2), /** @type {string | undefined} */ (options["--output"]));
457
+ return result.verified ? 0 : 1;
458
+ }
459
+
385
460
  /** @param {string[]} args */
386
461
  function runLint(args) {
387
462
  const { options, positionals } = parseArgs(args);
@@ -514,7 +589,8 @@ function runRules(args) {
514
589
  }
515
590
  const config = configFrom(options, []);
516
591
  const off = new Set(offRules(config));
517
- if (options["--format"] === "json") {
592
+ const format = choose(/** @type {string} */ (options["--format"] ?? "text"), ["text", "json"], "--format");
593
+ if (format === "json") {
518
594
  const rules = names.map((name) => {
519
595
  const exemplar = exemplarFor(name, config.preset, config.exemplars);
520
596
  /** @type {Record<string, unknown>} */
@@ -646,6 +722,136 @@ function runPatterns(args) {
646
722
  return 0;
647
723
  }
648
724
 
725
+ /** 여러 줄을 한 줄로. 줄바꿈은 ¶ 다. 파이썬 cli/commands/primer.py 의 flat 과 같다. @param {string} text */
726
+ function flat(text) {
727
+ return text
728
+ .split("\n")
729
+ .map((line) => line.trim().split(/\s+/).filter(Boolean).join(" "))
730
+ .filter(Boolean)
731
+ .join(" ¶ ");
732
+ }
733
+
734
+ /**
735
+ * 설정에서 켜진 규칙마다 항목 하나. 부류 순서, 그 안은 이름 순서. 파이썬 primerEntries 와 같다.
736
+ * @param {import("../config/settings.js").Config} config @param {string} register
737
+ */
738
+ function primerEntries(config, register) {
739
+ const off = new Set(offRules(config));
740
+ const entries = [];
741
+ for (const category of Object.keys(CATEGORY_TITLES)) {
742
+ for (const name of ruleNames()) {
743
+ if (ruleCategory(name) !== category || off.has(name)) continue;
744
+ const exemplar = exemplarFor(name, config.preset, config.exemplars);
745
+ if (!exemplar) throw new Error(`본보기가 없는 규칙: ${name}`);
746
+ const shown = exemplarInRegister(exemplar, register);
747
+ entries.push({ rule: name, category, fix: ruleFix(name), before: shown.before, after: shown.after });
748
+ }
749
+ }
750
+ return entries;
751
+ }
752
+
753
+ /** 사람과 AI 가 읽는 한 장. 파이썬 renderPrimer 와 글자 단위로 같다. @param {ReturnType<typeof primerEntries>} entries @param {string} preset @param {string} register */
754
+ function renderPrimer(entries, preset, register) {
755
+ const lines = [
756
+ `hanlint primer ${preset} 종류, ${register}체, 규칙 ${entries.length}개. 줄마다 규칙 이름, 고치는 법, 전 (그 규칙에 잡히는 글), 후 (같은 뜻으로 통과하는 글) 순서다. 본보기의 줄바꿈은 ¶ 다`,
757
+ ];
758
+ for (const [category, title] of Object.entries(CATEGORY_TITLES)) {
759
+ const inside = entries.filter((entry) => entry.category === category);
760
+ if (!inside.length) continue;
761
+ lines.push("", `${title} (${inside.length})`);
762
+ for (const entry of inside) lines.push(`${entry.rule} ${entry.fix} 전: ${flat(entry.before)} 후: ${flat(entry.after)}`);
763
+ }
764
+ lines.push("", "후는 전부 hanlint 를 error 0 으로 통과한다 (게이트가 확인한다). 규칙 하나를 깊게 보려면 hanlint explain <규칙>");
765
+ return lines.join("\n");
766
+ }
767
+
768
+ /** @param {string[]} args */
769
+ function runPrimer(args) {
770
+ const { options } = parseArgs(args);
771
+ const config = configFrom(options, []);
772
+ const register = choose(/** @type {string} */ (options["--register"] ?? HAPNIDA), REGISTERS, "--register");
773
+ const entries = primerEntries(config, register);
774
+ const output = /** @type {string | undefined} */ (options["--output"]);
775
+ const format = choose(/** @type {string} */ (options["--format"] ?? "text"), ["text", "json"], "--format");
776
+ if (format === "json") {
777
+ emit(JSON.stringify({ version: 1, preset: config.preset, register, rules: entries }, null, 2), output);
778
+ return 0;
779
+ }
780
+ emit(renderPrimer(entries, config.preset, register), output);
781
+ return 0;
782
+ }
783
+
784
+ /** @param {string[]} args */
785
+ function runSpec(args) {
786
+ const { options } = parseArgs(args);
787
+ const config = configFrom(options, []);
788
+ const register = choose(/** @type {string} */ (options["--register"] ?? HAPNIDA), REGISTERS, "--register");
789
+ let targetChars = null;
790
+ if (options["--chars"] !== undefined) {
791
+ const raw = /** @type {string} */ (options["--chars"]);
792
+ targetChars = Number(raw);
793
+ if (!Number.isInteger(targetChars) || targetChars <= 0) throw new UsageError(`--chars 는 1 이상의 정수다: ${raw}`);
794
+ }
795
+ const result = writingSpec(config, register, targetChars);
796
+ const output = /** @type {string | undefined} */ (options["--output"]);
797
+ const format = choose(/** @type {string} */ (options["--format"] ?? "text"), ["text", "json"], "--format");
798
+ emit(format === "json" ? JSON.stringify(result, null, 2) : renderWritingSpec(result), output);
799
+ return 0;
800
+ }
801
+
802
+ /** Claude Code가 다음 모델 요청에 넣는 비차단 Finding 문맥. */
803
+ function hookContext(eventName, name, findings) {
804
+ const where = eventName === "Stop" ? "마지막 답변" : "방금 쓴 마크다운";
805
+ return JSON.stringify({
806
+ hookSpecificOutput: {
807
+ hookEventName: eventName,
808
+ additionalContext: `hanlint가 ${where}에서 센 Finding입니다.\n${renderCompact(name, findings)}`,
809
+ },
810
+ });
811
+ }
812
+
813
+ /** 명령 훅 JSON을 stdin에서 읽어 저장 파일이나 마지막 답변을 검사한다. 언제나 종료 코드 0이다. @param {string[]} args */
814
+ function runHook(args) {
815
+ try {
816
+ const { options, positionals } = parseArgs(args);
817
+ if (positionals.length) return 0;
818
+ const payload = JSON.parse(readFileSync(0, "utf-8"));
819
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) return 0;
820
+ let eventName;
821
+ let name;
822
+ let findings;
823
+ if (options["--reply"]) {
824
+ if (payload.hook_event_name !== "Stop" || payload.stop_hook_active === true) return 0;
825
+ const text = payload.last_assistant_message;
826
+ if (typeof text !== "string" || !text.trim()) return 0;
827
+ const start = typeof payload.cwd === "string" && payload.cwd ? payload.cwd : process.cwd();
828
+ const config = configFrom(options, [], start);
829
+ config.preset = "chat";
830
+ eventName = "Stop";
831
+ name = "<assistant>";
832
+ findings = lintText(text, config, name);
833
+ } else {
834
+ if (payload.hook_event_name !== "PostToolUse" || !["Write", "Edit"].includes(payload.tool_name)) return 0;
835
+ const rawPath = payload.tool_input?.file_path;
836
+ if (typeof rawPath !== "string" || !rawPath || !MARKDOWN.includes(extname(rawPath).toLowerCase())) return 0;
837
+ try {
838
+ if (!statSync(rawPath).isFile()) return 0;
839
+ } catch {
840
+ return 0;
841
+ }
842
+ const config = configFrom(options, [rawPath]);
843
+ eventName = "PostToolUse";
844
+ name = rawPath;
845
+ findings = lintText(readFileSync(rawPath, "utf-8"), config, name);
846
+ if (config.baseline) findings = loadBaseline(config.baseline).keep(name, findings);
847
+ }
848
+ if (findings.length) process.stdout.write(`${hookContext(eventName, name, findings)}\n`);
849
+ } catch {
850
+ return 0;
851
+ }
852
+ return 0;
853
+ }
854
+
649
855
  /** 잠근 지적이 몇 건인지. baseline 이 빚을 감추는 자리가 되지 않게 늘 보인다. @param {import("../config/settings.js").Config} config */
650
856
  function baselineState(config) {
651
857
  if (!config.baseline) return "없다 (hanlint baseline 글들/ 로 지금 지적을 잠근다)";
@@ -730,6 +936,7 @@ export function renderInit(preset = "blog") {
730
936
  "",
731
937
  "# 표면 치환이 바꾸면 안 되는 한국어 고유명사와 프로젝트 용어. 수치와 라틴 식별자는 자동으로 보호한다",
732
938
  '# protectedTerms = ["한린트", "김민지"]',
939
+ '# enforceStyle = ["noQuestion", "nounPile"] # 선택한 문체 신호만 error로 강제한다',
733
940
  "",
734
941
  "# 사람이 승인한 프로젝트 본보기. 같은 규칙과 프리셋의 내장 본보기를 덮어쓴다",
735
942
  "# [[exemplars]]",
@@ -793,11 +1000,17 @@ function dispatch(argv) {
793
1000
  throw new Error(`${command} 는 파이썬 패키지에 있다 (pip install hanlint). npm 은 ${COMMANDS.join(", ")} 을 제공한다`);
794
1001
  }
795
1002
  if (command === "lint") return runLint(rest);
1003
+ if (command === "contract") return runContract(rest);
1004
+ if (command === "check") return runCheck(rest);
1005
+ if (command === "verify-patch") return runVerifyPatch(rest);
796
1006
  if (command === "fix") return runFix(rest);
797
1007
  if (command === "print") return runPrint(rest);
798
1008
  if (command === "rules") return runRules(rest);
799
1009
  if (command === "explain") return runExplain(rest);
800
1010
  if (command === "patterns") return runPatterns(rest);
1011
+ if (command === "primer") return runPrimer(rest);
1012
+ if (command === "spec") return runSpec(rest);
1013
+ if (command === "hook") return runHook(rest);
801
1014
  if (command === "baseline") return runBaseline(rest);
802
1015
  if (command === "doctor") return runDoctor(rest);
803
1016
  return runInit(rest);
@@ -0,0 +1,48 @@
1
+ // @ts-check
2
+ /** 이유가 붙은 정확 국소 Patch 입력 계약. */
3
+ import { createHash } from "node:crypto";
4
+
5
+ /** @param {unknown} value @param {string} where */
6
+ function checkedString(value, where) {
7
+ if (typeof value !== "string" || !value || value !== value.trim()) {
8
+ throw new Error(`${where} 는 양끝 공백 없는 문자열이다`);
9
+ }
10
+ if (value !== value.normalize("NFC")) throw new Error(`${where} 는 NFC 문자열이어야 한다`);
11
+ return value;
12
+ }
13
+
14
+ export class Patch {
15
+ /** @param {string} reason @param {string} before @param {string} after */
16
+ constructor(reason, before, after) {
17
+ this.reason = checkedString(reason, "reason");
18
+ this.before = checkedString(before, "before");
19
+ this.after = checkedString(after, "after");
20
+ if (this.before === this.after) throw new Error("patch before 와 after 는 달라야 한다");
21
+ Object.freeze(this);
22
+ }
23
+
24
+ /** @param {unknown} raw */
25
+ static fromMapping(raw) {
26
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("patch 는 JSON 객체다");
27
+ const data = /** @type {Record<string, unknown>} */ (raw);
28
+ const expected = ["reason", "before", "after"];
29
+ const unknown = Object.keys(data).filter((key) => !expected.includes(key)).sort();
30
+ const missing = expected.filter((key) => !(key in data)).sort();
31
+ if (unknown.length) throw new Error(`patch 의 모르는 키: ${unknown.join(", ")}`);
32
+ if (missing.length) throw new Error(`patch 의 빠진 키: ${missing.join(", ")}`);
33
+ return new Patch(
34
+ /** @type {string} */ (data.reason),
35
+ /** @type {string} */ (data.before),
36
+ /** @type {string} */ (data.after),
37
+ );
38
+ }
39
+
40
+ get digest() {
41
+ const encoded = JSON.stringify({ after: this.after, before: this.before, reason: this.reason });
42
+ return createHash("sha256").update(encoded, "utf8").digest("hex");
43
+ }
44
+
45
+ asDict() {
46
+ return { reason: this.reason, before: this.before, after: this.after };
47
+ }
48
+ }
@@ -0,0 +1,245 @@
1
+ // @ts-check
2
+ /** 모델과 실행 환경에 독립적인 최소 Reader Contract와 정확 Patch. */
3
+ import { createHash } from "node:crypto";
4
+
5
+ export const CONTRACT_VERSION = 1;
6
+ export const LATEST_CONTRACT_VERSION = 2;
7
+ export const CONTRACT_VERSIONS = Object.freeze([CONTRACT_VERSION, LATEST_CONTRACT_VERSION]);
8
+
9
+ /** @param {unknown} value @param {string} where */
10
+ function checkedString(value, where) {
11
+ if (typeof value !== "string" || !value || value !== value.trim()) {
12
+ throw new Error(`${where} 는 양끝 공백 없는 문자열이다`);
13
+ }
14
+ if (value !== value.normalize("NFC")) throw new Error(`${where} 는 NFC 문자열이어야 한다`);
15
+ return value;
16
+ }
17
+
18
+ /** @param {unknown} value @param {string} where @param {boolean} [allowEmpty] */
19
+ function checkedStrings(value, where, allowEmpty = false) {
20
+ if (!Array.isArray(value) || (!allowEmpty && !value.length)) {
21
+ throw new Error(`${where} 는 ${allowEmpty ? "문자열 배열" : "비지 않은 문자열 배열"}이다`);
22
+ }
23
+ const found = value.map((item, index) => checkedString(item, `${where} ${index + 1}번째`));
24
+ if (new Set(found).size !== found.length) throw new Error(`${where} 에 같은 값이 두 번 있다`);
25
+ return found;
26
+ }
27
+
28
+ /** 파이썬 문자열 순서와 같은 코드 포인트 순서. @param {string} left @param {string} right */
29
+ function compareText(left, right) {
30
+ const leftPoints = [...left].map((value) => /** @type {number} */ (value.codePointAt(0)));
31
+ const rightPoints = [...right].map((value) => /** @type {number} */ (value.codePointAt(0)));
32
+ const limit = Math.min(leftPoints.length, rightPoints.length);
33
+ for (let index = 0; index < limit; index += 1) {
34
+ if (leftPoints[index] !== rightPoints[index]) return leftPoints[index] - rightPoints[index];
35
+ }
36
+ return leftPoints.length - rightPoints.length;
37
+ }
38
+
39
+ /** @param {string} text */
40
+ function digest(text) {
41
+ return createHash("sha256").update(text, "utf8").digest("hex");
42
+ }
43
+
44
+ export class Contract {
45
+ /** @param {string} reader @param {string} goal @param {string[]} facts @param {number} [version] */
46
+ constructor(reader, goal, facts, version = CONTRACT_VERSION) {
47
+ if (!Number.isInteger(version) || version !== CONTRACT_VERSION) {
48
+ throw new Error(`reader contract version 은 ${CONTRACT_VERSION}이다: ${version}`);
49
+ }
50
+ this.reader = checkedString(reader, "reader");
51
+ this.goal = checkedString(goal, "goal");
52
+ this.facts = Object.freeze(checkedStrings(facts, "facts"));
53
+ this.version = version;
54
+ Object.freeze(this);
55
+ }
56
+
57
+ /** @param {unknown} raw */
58
+ static fromMapping(raw) {
59
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("reader contract 는 JSON 객체다");
60
+ const data = /** @type {Record<string, unknown>} */ (raw);
61
+ const expected = ["version", "reader", "goal", "facts"];
62
+ const unknown = Object.keys(data).filter((key) => !expected.includes(key)).sort();
63
+ const missing = expected.filter((key) => !(key in data)).sort();
64
+ if (unknown.length) throw new Error(`reader contract 의 모르는 키: ${unknown.join(", ")}`);
65
+ if (missing.length) throw new Error(`reader contract 의 빠진 키: ${missing.join(", ")}`);
66
+ return new Contract(
67
+ /** @type {string} */ (data.reader),
68
+ /** @type {string} */ (data.goal),
69
+ /** @type {string[]} */ (data.facts),
70
+ /** @type {number} */ (data.version),
71
+ );
72
+ }
73
+
74
+ get text() {
75
+ return [this.reader, this.goal, ...this.facts].join("\n");
76
+ }
77
+
78
+ get digest() {
79
+ return digest(JSON.stringify({ facts: this.facts, goal: this.goal, reader: this.reader, version: this.version }));
80
+ }
81
+
82
+ asDict() {
83
+ return { version: this.version, reader: this.reader, goal: this.goal, facts: [...this.facts] };
84
+ }
85
+ }
86
+
87
+ export class ProtectedSurface {
88
+ /** @param {string[]} [numbers] @param {string[]} [urls] @param {string[]} [code] @param {string[]} [links] */
89
+ constructor(numbers = [], urls = [], code = [], links = []) {
90
+ this.numbers = Object.freeze(checkedStrings(numbers, "surface.numbers", true).sort(compareText));
91
+ this.urls = Object.freeze(checkedStrings(urls, "surface.urls", true).sort(compareText));
92
+ this.code = Object.freeze(checkedStrings(code, "surface.code", true).sort(compareText));
93
+ this.links = Object.freeze(checkedStrings(links, "surface.links", true).sort(compareText));
94
+ Object.freeze(this);
95
+ }
96
+
97
+ /** @param {unknown} raw */
98
+ static fromMapping(raw) {
99
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("surface 는 JSON 객체다");
100
+ const data = /** @type {Record<string, unknown>} */ (raw);
101
+ const expected = ["numbers", "urls", "code", "links"];
102
+ const unknown = Object.keys(data).filter((key) => !expected.includes(key)).sort();
103
+ const missing = expected.filter((key) => !(key in data)).sort();
104
+ if (unknown.length) throw new Error(`surface 의 모르는 키: ${unknown.join(", ")}`);
105
+ if (missing.length) throw new Error(`surface 의 빠진 키: ${missing.join(", ")}`);
106
+ return new ProtectedSurface(
107
+ /** @type {string[]} */ (data.numbers),
108
+ /** @type {string[]} */ (data.urls),
109
+ /** @type {string[]} */ (data.code),
110
+ /** @type {string[]} */ (data.links),
111
+ );
112
+ }
113
+
114
+ get text() {
115
+ return [
116
+ ...this.numbers,
117
+ ...this.urls,
118
+ ...this.code.map((value) => `\`${value}\``),
119
+ ...this.links.map((value) => `[](${value})`),
120
+ ].join("\n");
121
+ }
122
+
123
+ asDict() {
124
+ return { numbers: [...this.numbers], urls: [...this.urls], code: [...this.code], links: [...this.links] };
125
+ }
126
+ }
127
+
128
+ export class Outline {
129
+ /** @param {number} level @param {string[]} headings */
130
+ constructor(level, headings) {
131
+ if (!Number.isInteger(level) || level < 1 || level > 6) throw new Error(`outline.level 은 1~6 정수다: ${level}`);
132
+ this.level = level;
133
+ this.headings = Object.freeze(checkedStrings(headings, "outline.headings"));
134
+ Object.freeze(this);
135
+ }
136
+
137
+ /** @param {unknown} raw */
138
+ static fromMapping(raw) {
139
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("outline 은 JSON 객체다");
140
+ const data = /** @type {Record<string, unknown>} */ (raw);
141
+ const expected = ["level", "headings"];
142
+ const unknown = Object.keys(data).filter((key) => !expected.includes(key)).sort();
143
+ const missing = expected.filter((key) => !(key in data)).sort();
144
+ if (unknown.length) throw new Error(`outline 의 모르는 키: ${unknown.join(", ")}`);
145
+ if (missing.length) throw new Error(`outline 의 빠진 키: ${missing.join(", ")}`);
146
+ return new Outline(/** @type {number} */ (data.level), /** @type {string[]} */ (data.headings));
147
+ }
148
+
149
+ asDict() {
150
+ return { level: this.level, headings: [...this.headings] };
151
+ }
152
+ }
153
+
154
+ export class ContractV2 {
155
+ /** @param {string} reader @param {string} goal @param {string[]} facts @param {ProtectedSurface | Record<string, unknown>} surface @param {Outline | Record<string, unknown>} outline @param {number} [version] */
156
+ constructor(reader, goal, facts, surface, outline, version = LATEST_CONTRACT_VERSION, lockedFacts = [], editPolicy = {}) {
157
+ if (!Number.isInteger(version) || version !== LATEST_CONTRACT_VERSION) {
158
+ throw new Error(`reader contract version 은 ${LATEST_CONTRACT_VERSION}다: ${version}`);
159
+ }
160
+ this.reader = checkedString(reader, "reader");
161
+ this.goal = checkedString(goal, "goal");
162
+ this.facts = Object.freeze(checkedStrings(facts, "facts", true));
163
+ this.surface = surface instanceof ProtectedSurface ? surface : ProtectedSurface.fromMapping(surface);
164
+ this.outline = outline instanceof Outline ? outline : Outline.fromMapping(outline);
165
+ this.lockedFacts = Object.freeze(checkedStrings(lockedFacts, "lockedFacts", true));
166
+ if (this.lockedFacts.some((fact) => !this.facts.includes(fact))) throw new Error("lockedFacts must select approved facts");
167
+ if (!editPolicy || typeof editPolicy !== "object" || Array.isArray(editPolicy)) throw new Error("editPolicy must be an object");
168
+ this.editPolicy = Object.freeze(Object.fromEntries(Object.entries(editPolicy).sort(([a], [b]) => compareText(a, b)).map(([rule, limits]) => {
169
+ if (!rule || rule !== rule.trim()) throw new Error("editPolicy requires rule names");
170
+ if (!limits || typeof limits !== "object" || Array.isArray(limits) || Object.keys(limits).sort().join(",") !== "maxChars,maxLines") {
171
+ throw new Error("editPolicy requires maxChars and maxLines");
172
+ }
173
+ if (Object.values(limits).some((n) => !Number.isInteger(n) || n < 1)) throw new Error("editPolicy limits must be positive integers");
174
+ return [rule, Object.freeze({maxChars: limits.maxChars, maxLines: limits.maxLines})];
175
+ })));
176
+ this.version = version;
177
+ Object.freeze(this);
178
+ }
179
+
180
+ /** @param {unknown} raw */
181
+ static fromMapping(raw) {
182
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("reader contract 는 JSON 객체다");
183
+ const data = /** @type {Record<string, unknown>} */ (raw);
184
+ const expected = ["version", "reader", "goal", "facts", "surface", "outline"];
185
+ const unknown = Object.keys(data).filter((key) => !expected.includes(key) && !["lockedFacts", "editPolicy"].includes(key)).sort();
186
+ const missing = expected.filter((key) => !(key in data)).sort();
187
+ if (unknown.length) throw new Error(`reader contract 의 모르는 키: ${unknown.join(", ")}`);
188
+ if (missing.length) throw new Error(`reader contract 의 빠진 키: ${missing.join(", ")}`);
189
+ return new ContractV2(
190
+ /** @type {string} */ (data.reader),
191
+ /** @type {string} */ (data.goal),
192
+ /** @type {string[]} */ (data.facts),
193
+ /** @type {Record<string, unknown>} */ (data.surface),
194
+ /** @type {Record<string, unknown>} */ (data.outline),
195
+ /** @type {number} */ (data.version),
196
+ Object.hasOwn(data, "lockedFacts") ? data.lockedFacts : [],
197
+ Object.hasOwn(data, "editPolicy") ? data.editPolicy : {},
198
+ );
199
+ }
200
+
201
+ get text() {
202
+ return [this.reader, this.goal, ...this.facts, this.surface.text].filter(Boolean).join("\n");
203
+ }
204
+
205
+ get digest() {
206
+ const encoded = {
207
+ ...(Object.keys(this.editPolicy).length ? {editPolicy: this.editPolicy} : {}),
208
+ facts: this.facts,
209
+ goal: this.goal,
210
+ ...(this.lockedFacts.length ? {lockedFacts: this.lockedFacts} : {}),
211
+ outline: { headings: this.outline.headings, level: this.outline.level },
212
+ reader: this.reader,
213
+ surface: {
214
+ code: this.surface.code,
215
+ links: this.surface.links,
216
+ numbers: this.surface.numbers,
217
+ urls: this.surface.urls,
218
+ },
219
+ version: this.version,
220
+ };
221
+ return digest(JSON.stringify(encoded));
222
+ }
223
+
224
+ asDict() {
225
+ return {
226
+ version: this.version,
227
+ reader: this.reader,
228
+ goal: this.goal,
229
+ facts: [...this.facts],
230
+ surface: this.surface.asDict(),
231
+ outline: this.outline.asDict(),
232
+ ...(this.lockedFacts.length ? {lockedFacts: [...this.lockedFacts]} : {}),
233
+ ...(Object.keys(this.editPolicy).length ? {editPolicy: this.editPolicy} : {}),
234
+ };
235
+ }
236
+ }
237
+
238
+ /** @param {unknown} raw */
239
+ export function parseContract(raw) {
240
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("reader contract 는 JSON 객체다");
241
+ const version = /** @type {Record<string, unknown>} */ (raw).version;
242
+ if (version === CONTRACT_VERSION) return Contract.fromMapping(raw);
243
+ if (version === LATEST_CONTRACT_VERSION) return ContractV2.fromMapping(raw);
244
+ throw new Error(`reader contract version 은 ${CONTRACT_VERSIONS.join(",")} 가운데 하나다: ${version}`);
245
+ }