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.
- package/README.md +45 -5
- package/data/checkResult.schema.json +75 -0
- package/data/checkResultV2.schema.json +98 -0
- package/data/exemplars.json +2 -26
- package/data/finding.schema.json +38 -0
- package/data/holeKinds.json +1 -12
- package/data/patch.schema.json +14 -0
- package/data/patchResult.schema.json +86 -0
- package/data/patchResultV2.schema.json +71 -0
- package/data/patterns.json +0 -11
- package/data/platformApis.txt +3 -1
- 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/ruleCategories.json +0 -4
- package/data/ruleDocs.json +7 -11
- package/data/ruleMechanisms.json +0 -4
- package/data/spelling.json +1 -1
- package/data/version.json +1 -1
- package/data/writingBrief.schema.json +1 -1
- package/data/writingBriefV2.schema.json +1 -1
- package/koglType1.LICENSE.md +25 -0
- package/package.json +6 -2
- package/src/cli/help.js +34 -0
- package/src/cli/main.js +247 -35
- package/src/config/patch.js +48 -0
- package/src/config/readerContract.js +245 -0
- package/src/config/settings.js +47 -14
- package/src/data/profiles.js +12 -0
- package/src/fingerprint/build.js +10 -7
- 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 +8 -6
- package/src/rules/document/keywordHeading.js +7 -2
- package/src/rules/document/noQuestion.js +4 -4
- package/src/rules/document/tableOddCell.js +2 -1
- package/src/rules/readerDebt.js +9 -0
- package/src/rules/registry.js +27 -8
- package/src/rules/sentence/danglingDeixis.js +15 -0
- package/src/rules/sentence/negationRedefine.js +2 -1
- package/src/rules/sentence/nounPile.js +1 -1
- package/src/rules/structure/headingUniform.js +3 -3
- package/src/rules/structure/introLong.js +3 -1
- package/data/coverageTypes.txt +0 -7
- package/src/rules/paragraph/topicBreak.js +0 -26
- package/src/rules/sentence/ellipsis.js +0 -32
- package/src/rules/structure/headingQuestion.js +0 -19
- 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 {
|
|
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",
|
|
@@ -72,35 +91,17 @@ const THRESHOLD_FIELDS = [
|
|
|
72
91
|
"endingRun",
|
|
73
92
|
"factListMinSentences",
|
|
74
93
|
"factListMaxMeanLength",
|
|
75
|
-
"
|
|
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"
|
|
86
|
-
|
|
87
|
-
const USAGE =
|
|
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
|
-
|
|
243
|
-
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|