hanlint 0.0.8 → 0.0.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/README.md +45 -5
  2. package/data/checkResult.schema.json +75 -0
  3. package/data/checkResultV2.schema.json +98 -0
  4. package/data/exemplars.json +2 -26
  5. package/data/finding.schema.json +38 -0
  6. package/data/holeKinds.json +1 -12
  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/patterns.json +0 -11
  11. package/data/platformApis.txt +3 -1
  12. package/data/profiles.json +1 -1
  13. package/data/pythonPackages.txt +3 -2
  14. package/data/readerContract.schema.json +20 -0
  15. package/data/readerContractConformanceV1.json +168 -0
  16. package/data/readerContractV2.schema.json +64 -0
  17. package/data/ruleCategories.json +0 -4
  18. package/data/ruleDocs.json +7 -11
  19. package/data/ruleMechanisms.json +0 -4
  20. package/data/spelling.json +1 -1
  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/koglType1.LICENSE.md +25 -0
  25. package/package.json +6 -2
  26. package/src/cli/help.js +34 -0
  27. package/src/cli/main.js +247 -35
  28. package/src/config/patch.js +48 -0
  29. package/src/config/readerContract.js +245 -0
  30. package/src/config/settings.js +47 -14
  31. package/src/data/profiles.js +12 -0
  32. package/src/fingerprint/build.js +10 -7
  33. package/src/fingerprint/codeMarkers.js +1 -1
  34. package/src/guard/contract.js +174 -0
  35. package/src/guard/editLocality.js +27 -0
  36. package/src/guard/outline.js +46 -0
  37. package/src/guard/receipt.js +180 -0
  38. package/src/guard/surface.js +184 -0
  39. package/src/index.js +39 -1
  40. package/src/report/compactReport.js +1 -1
  41. package/src/report/writingSpec.js +174 -0
  42. package/src/rules/code/inputFileSource.js +5 -3
  43. package/src/rules/code/installImport.js +8 -6
  44. package/src/rules/document/keywordHeading.js +7 -2
  45. package/src/rules/document/noQuestion.js +4 -4
  46. package/src/rules/document/tableOddCell.js +2 -1
  47. package/src/rules/readerDebt.js +9 -0
  48. package/src/rules/registry.js +27 -8
  49. package/src/rules/sentence/danglingDeixis.js +15 -0
  50. package/src/rules/sentence/negationRedefine.js +2 -1
  51. package/src/rules/sentence/nounPile.js +1 -1
  52. package/src/rules/structure/headingUniform.js +3 -3
  53. package/src/rules/structure/introLong.js +3 -1
  54. package/data/coverageTypes.txt +0 -7
  55. package/src/rules/paragraph/topicBreak.js +0 -26
  56. package/src/rules/sentence/ellipsis.js +0 -32
  57. package/src/rules/structure/headingQuestion.js +0 -19
  58. package/src/rules/structure/sectionResult.js +0 -38
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 } 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",
@@ -72,35 +91,17 @@ const THRESHOLD_FIELDS = [
72
91
  "endingRun",
73
92
  "factListMinSentences",
74
93
  "factListMaxMeanLength",
75
- "topicBreakMinSentences",
94
+ "flowValleyMinSentences",
76
95
  "longSentenceMax",
77
96
  "duplicateBlockRatio",
78
97
  "firstResultMaxParagraphs",
79
- "sectionResultMinParagraphs",
80
98
  "introMaxImages",
81
- "headingQuestionRatio",
82
99
  "moreLaterMaxChars",
83
100
  "tableOddCellMinRows",
84
101
  ];
85
- const FLOAT_FIELDS = new Set(["headingUniformRatio", "factListMaxMeanLength", "headingQuestionRatio"]);
86
-
87
- const USAGE = `사용법: hanlint 글.md [다른.md ...] [--format text|compact|json|github] [--severity all|error|notice] [--errors-only]
88
- [--config 파일] [--preset blog|report|docs] [--disable 규칙] [--baseline [파일]] [--output 파일] [--quiet]
89
- [--path 이름 (stdin 의 이름)]
90
- hanlint fix 글.md [--dry-run]
91
- hanlint print 글.md [--layer all|sentences|paragraphs|sections|document]
92
- hanlint rules [--names]
93
- hanlint explain <규칙>
94
- hanlint patterns [--rule 규칙]
95
- hanlint baseline 글들/ [--prune] [--output 파일]
96
- hanlint doctor
97
- hanlint init [--output hanlint.toml] [--preset blog|report|docs] [--force]
98
- hanlint --version
99
-
100
- 파일 자리에 폴더를 주면 그 아래 마크다운을 전부 검사한다 (점으로 시작하는 폴더와 node_modules 는 건너뛴다).
101
- 인자가 없으면 첫 화면이 나온다.
102
- 한국어 글에서 반복되는 결함을 결정적으로 잡는다. 종료 코드는 0 (지적 없음), 1 (error 지적), 2 (파일이나 설정 문제).
103
- ${PYTHON_ONLY.join(", ")} 는 파이썬 패키지 (pip install hanlint) 에 있다.`;
102
+ const FLOAT_FIELDS = new Set(["headingUniformRatio", "factListMaxMeanLength"]);
103
+
104
+ const USAGE = rootHelp();
104
105
 
105
106
  /** @type {Record<string, "value" | "list" | "flag">} */
106
107
  const OPTION_KINDS = {
@@ -118,9 +119,15 @@ const OPTION_KINDS = {
118
119
  "--dry-run": "flag",
119
120
  "--names": "flag",
120
121
  "--force": "flag",
122
+ "--reply": "flag",
123
+ "--reader": "value",
124
+ "--goal": "value",
125
+ "--outline": "value",
126
+ "--fact": "list",
121
127
  "--preset": "value",
122
128
  "--rule": "value",
123
129
  "--register": "value",
130
+ "--chars": "value",
124
131
  "--baseline": "optional",
125
132
  "--prune": "flag",
126
133
  };
@@ -226,8 +233,8 @@ function checkDisabled(config) {
226
233
  }
227
234
 
228
235
  /** @param {Record<string, string | string[] | boolean>} options @param {string[]} paths */
229
- function configFrom(options, paths) {
230
- 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));
231
238
  if (options["--preset"]) config.preset = choose(/** @type {string} */ (options["--preset"]), PRESET_NAMES, "--preset");
232
239
  for (const rule of /** @type {string[]} */ (options["--disable"] ?? [])) config.disable.add(rule);
233
240
  if (options["--profile"]) config.profile = /** @type {string} */ (options["--profile"]);
@@ -239,15 +246,15 @@ function configFrom(options, paths) {
239
246
  function configLabel(config) {
240
247
  if (config.source === null) return "기본값";
241
248
  const rel = relative(process.cwd(), config.source);
242
- if (!rel || rel.startsWith("..") || isAbsolute(rel)) return config.source;
243
- return rel;
249
+ // 경로 구분자는 언제나 /. 설정이 작업 폴더 밖일 때 두 판의 첫 줄이 갈렸다. 파이썬과 같다.
250
+ const label = !rel || rel.startsWith("..") || isAbsolute(rel) ? config.source : rel;
251
+ return label.split(sep).join("/");
244
252
  }
245
253
 
246
- /** 설정 출처와 지금 도는 프리셋. 기본 프리셋이면 이름을 빼서 줄이 길어지지 않게 한다.
247
- * @param {import("../config/settings.js").Config} config */
254
+ /** 설정 출처와 지금 도는 프리셋. @param {import("../config/settings.js").Config} config */
248
255
  function header(config) {
249
256
  const where = configLabel(config);
250
- return config.preset === DEFAULT_PRESET ? `설정: ${where}` : `설정: ${where}, 프리셋 ${config.preset}`;
257
+ return `설정: ${where}, 프리셋 ${config.preset}`;
251
258
  }
252
259
 
253
260
  /** @param {Map<string, import("../rules/finding.js").Finding[]>} results */
@@ -383,6 +390,73 @@ function runBaseline(args) {
383
390
  return 0;
384
391
  }
385
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
+
386
460
  /** @param {string[]} args */
387
461
  function runLint(args) {
388
462
  const { options, positionals } = parseArgs(args);
@@ -515,7 +589,8 @@ function runRules(args) {
515
589
  }
516
590
  const config = configFrom(options, []);
517
591
  const off = new Set(offRules(config));
518
- if (options["--format"] === "json") {
592
+ const format = choose(/** @type {string} */ (options["--format"] ?? "text"), ["text", "json"], "--format");
593
+ if (format === "json") {
519
594
  const rules = names.map((name) => {
520
595
  const exemplar = exemplarFor(name, config.preset, config.exemplars);
521
596
  /** @type {Record<string, unknown>} */
@@ -647,6 +722,136 @@ function runPatterns(args) {
647
722
  return 0;
648
723
  }
649
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
+
650
855
  /** 잠근 지적이 몇 건인지. baseline 이 빚을 감추는 자리가 되지 않게 늘 보인다. @param {import("../config/settings.js").Config} config */
651
856
  function baselineState(config) {
652
857
  if (!config.baseline) return "없다 (hanlint baseline 글들/ 로 지금 지적을 잠근다)";
@@ -731,6 +936,7 @@ export function renderInit(preset = "blog") {
731
936
  "",
732
937
  "# 표면 치환이 바꾸면 안 되는 한국어 고유명사와 프로젝트 용어. 수치와 라틴 식별자는 자동으로 보호한다",
733
938
  '# protectedTerms = ["한린트", "김민지"]',
939
+ '# enforceStyle = ["noQuestion", "nounPile"] # 선택한 문체 신호만 error로 강제한다',
734
940
  "",
735
941
  "# 사람이 승인한 프로젝트 본보기. 같은 규칙과 프리셋의 내장 본보기를 덮어쓴다",
736
942
  "# [[exemplars]]",
@@ -794,11 +1000,17 @@ function dispatch(argv) {
794
1000
  throw new Error(`${command} 는 파이썬 패키지에 있다 (pip install hanlint). npm 은 ${COMMANDS.join(", ")} 을 제공한다`);
795
1001
  }
796
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);
797
1006
  if (command === "fix") return runFix(rest);
798
1007
  if (command === "print") return runPrint(rest);
799
1008
  if (command === "rules") return runRules(rest);
800
1009
  if (command === "explain") return runExplain(rest);
801
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);
802
1014
  if (command === "baseline") return runBaseline(rest);
803
1015
  if (command === "doctor") return runDoctor(rest);
804
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
+ }