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.
- package/README.md +36 -1
- package/data/checkResult.schema.json +75 -0
- package/data/checkResultV2.schema.json +98 -0
- package/data/finding.schema.json +38 -0
- package/data/patch.schema.json +14 -0
- package/data/patchResult.schema.json +86 -0
- package/data/patchResultV2.schema.json +71 -0
- package/data/profiles.json +1 -1
- package/data/pythonPackages.txt +3 -2
- package/data/readerContract.schema.json +20 -0
- package/data/readerContractConformanceV1.json +168 -0
- package/data/readerContractV2.schema.json +64 -0
- package/data/ruleDocs.json +3 -3
- package/data/version.json +1 -1
- package/data/writingBrief.schema.json +1 -1
- package/data/writingBriefV2.schema.json +1 -1
- package/package.json +1 -1
- package/src/cli/help.js +34 -0
- package/src/cli/main.js +241 -28
- package/src/config/patch.js +48 -0
- package/src/config/readerContract.js +245 -0
- package/src/config/settings.js +31 -2
- package/src/data/profiles.js +12 -0
- package/src/fingerprint/codeMarkers.js +1 -1
- package/src/guard/contract.js +174 -0
- package/src/guard/editLocality.js +27 -0
- package/src/guard/outline.js +46 -0
- package/src/guard/receipt.js +180 -0
- package/src/guard/surface.js +184 -0
- package/src/index.js +39 -1
- package/src/report/compactReport.js +1 -1
- package/src/report/writingSpec.js +174 -0
- package/src/rules/code/inputFileSource.js +5 -3
- package/src/rules/code/installImport.js +6 -5
- package/src/rules/document/noQuestion.js +4 -4
- package/src/rules/readerDebt.js +9 -0
- package/src/rules/registry.js +27 -0
- 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 {
|
|
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 {
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|