hanlint 0.0.16 → 0.0.18
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 +12 -10
- package/data/calqueFrame.json +60 -0
- package/data/cliches.json +10 -0
- package/data/collocationStops.txt +28 -0
- package/data/easyWords.json +0 -234
- package/data/exemplars.json +23 -2
- package/data/koreanProse.txt +64 -0
- package/data/patterns.json +2 -2
- package/data/phraseRepeat.json +11 -0
- package/data/ruleCategories.json +2 -0
- package/data/ruleDocs.json +18 -16
- package/data/ruleMechanisms.json +2 -0
- package/data/screenNarration.json +7 -7
- package/data/screenSentence.json +56 -8
- package/data/screenTone.json +4 -4
- package/data/screenWord.json +48 -88
- package/data/spacing.json +1 -1
- package/data/usageCounts.report.json +1676 -0
- package/data/version.json +1 -1
- package/package.json +1 -1
- package/src/analysis/index.js +1 -1
- package/src/analysis/tokenize.js +30 -15
- package/src/cli/help.js +3 -2
- package/src/cli/main.js +227 -88
- package/src/cli/walk.js +1 -1
- package/src/cli/welcome.js +1 -1
- package/src/config/settings.js +17 -0
- package/src/data/profiles.js +2 -2
- package/src/data/prose.js +27 -0
- package/src/document/sourceText.js +199 -36
- package/src/fingerprint/build.js +13 -4
- package/src/fingerprint/dictionaries.js +31 -4
- package/src/fingerprint/markers.js +22 -0
- package/src/index.js +3 -1
- package/src/report/compactReport.js +7 -3
- package/src/report/jsonReport.js +2 -2
- package/src/report/sheet.js +0 -0
- package/src/report/textReport.js +27 -8
- package/src/report/writingSpec.js +3 -3
- package/src/rules/registry.js +5 -1
- package/src/rules/screen/screenWord.js +2 -1
- package/src/rules/sentence/calqueFrame.js +11 -0
- package/src/rules/sentence/cliche.js +7 -3
- package/src/rules/sentence/hardWord.js +6 -3
- package/src/rules/sentence/japaneseLoan.js +7 -3
- package/src/rules/sentence/nounPile.js +10 -4
- package/src/rules/sentence/phraseRepeat.js +53 -0
- package/src/rules/sentence/redundantPair.js +7 -3
- package/src/rules/sentence/translationese.js +7 -3
- package/src/rules/shared/dictionaryRule.js +37 -6
- package/src/text.js +1 -1
- package/src/usage/counts.js +54 -0
- package/src/usage/index.js +8 -0
- package/src/usage/sentences.js +681 -0
package/data/version.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version": "0.0.
|
|
1
|
+
{"version": "0.0.18"}
|
package/package.json
CHANGED
package/src/analysis/index.js
CHANGED
|
@@ -4,4 +4,4 @@
|
|
|
4
4
|
* 지문이 필요로 하는 것은 넷이다. splitSentences, euiCount 와 euiAdjacent, longestNounRun, doublePassives.
|
|
5
5
|
*/
|
|
6
6
|
export { splitSentences } from "./splitSentences.js";
|
|
7
|
-
export { doublePassives, doublePassiveSpans, euiAdjacent, euiCount, longestNounRun } from "./tokenize.js";
|
|
7
|
+
export { doublePassives, doublePassiveSpans, euiAdjacent, euiCount, longestNounRun, nounRuns } from "./tokenize.js";
|
package/src/analysis/tokenize.js
CHANGED
|
@@ -12,13 +12,13 @@ const WORD_CHARS = /^[가-힣A-Za-z0-9]+$/;
|
|
|
12
12
|
/** 관형격 조사 의. 영문과 숫자 뒤 (API의, 600MiB의) 와 닫는 괄호 뒤 (L7(HTTP)의) 도 센다. 파이썬 tokenize.py 와 같다. */
|
|
13
13
|
const GENITIVE = /([가-힣A-Za-z0-9]+|\))의(?=[\s,.)\]]|$)/g;
|
|
14
14
|
const SPACED_GENITIVE = /(?<=\s)의(?=\s)/g;
|
|
15
|
-
const COPULA = /^(?:이(?:고|며|다|라|란|면|라서|므로|지만|어서|었다|었고)|인|인데|입니다|였다)$/;
|
|
15
|
+
export const COPULA = /^(?:이(?:고|며|다|라|란|면|라서|므로|지만|어서|었다|었고)|인|인데|입니다|였다)$/;
|
|
16
16
|
const DIGITS = /^\d+$/;
|
|
17
17
|
/** 수에 단위가 붙은 어절 (2012년, 30분쯤, 제16호, 100명). 수량이라 명사 나열에서 세지도 끊지도 않는다. */
|
|
18
18
|
const QUANTITY = /^제?\d[\d,.]*[가-힣]{1,3}$/;
|
|
19
19
|
const OPENERS = "([{\"“‘'";
|
|
20
20
|
const CLOSERS = ",.?!;:)]}\"”’'";
|
|
21
|
-
const EDGE_PUNCTUATION = ".,?!:;\"'“”‘’()[]{}<>";
|
|
21
|
+
export const EDGE_PUNCTUATION = ".,?!:;\"'“”‘’()[]{}<>";
|
|
22
22
|
|
|
23
23
|
/**
|
|
24
24
|
* @typedef {object} Word
|
|
@@ -130,32 +130,47 @@ export function isBareNoun(core) {
|
|
|
130
130
|
* 표층으로는 명사로 보이지만 명사 쌓기의 재료가 아니다. data/nonNouns.txt 가 그 목록이고 연속을 끊는다.
|
|
131
131
|
* @param {string} text
|
|
132
132
|
*/
|
|
133
|
-
|
|
134
|
-
|
|
133
|
+
/**
|
|
134
|
+
* 명사 어절 연속마다 [어절들, 센 길이]. 뜻은 파이썬 analysis/tokenize.py 의 nounRuns 가 소유한다.
|
|
135
|
+
* 수사와 단위는 세지도 끊지도 않고 어절에도 안 넣는다. 잇달은 영문 어절은 하나로 센다.
|
|
136
|
+
* @param {string} text @returns {[string[], number][]}
|
|
137
|
+
*/
|
|
138
|
+
export function nounRuns(text) {
|
|
139
|
+
/** @type {[string[], number][]} */
|
|
140
|
+
const runs = [];
|
|
141
|
+
/** @type {string[]} */
|
|
142
|
+
let chain = [];
|
|
135
143
|
let run = 0;
|
|
136
144
|
let previousAscii = false;
|
|
137
145
|
let afterNumeral = false;
|
|
146
|
+
const close = () => {
|
|
147
|
+
if (run) runs.push([chain, run]);
|
|
148
|
+
chain = [];
|
|
149
|
+
run = 0;
|
|
150
|
+
previousAscii = false;
|
|
151
|
+
};
|
|
138
152
|
for (const word of words(text)) {
|
|
139
|
-
if (word.opens)
|
|
140
|
-
run = 0;
|
|
141
|
-
previousAscii = false;
|
|
142
|
-
}
|
|
153
|
+
if (word.opens) close();
|
|
143
154
|
const transparent = isNumeral(word.core) || afterNumeral || isQuantity(word.core);
|
|
144
155
|
afterNumeral = isNumeral(word.core);
|
|
145
156
|
if (word.particle || nonNouns().has(word.core) || !isBareNoun(word.core) || isCopulaAdnominal(word.core)) {
|
|
146
|
-
|
|
147
|
-
previousAscii = false;
|
|
157
|
+
close();
|
|
148
158
|
} else if (!transparent) {
|
|
149
159
|
const isAscii = !HANGUL.test(word.core);
|
|
150
160
|
if (!(isAscii && previousAscii)) run += 1;
|
|
161
|
+
chain.push(word.core);
|
|
151
162
|
previousAscii = isAscii;
|
|
152
|
-
longest = Math.max(longest, run);
|
|
153
|
-
}
|
|
154
|
-
if (word.endsClause) {
|
|
155
|
-
run = 0;
|
|
156
|
-
previousAscii = false;
|
|
157
163
|
}
|
|
164
|
+
if (word.endsClause) close();
|
|
158
165
|
}
|
|
166
|
+
close();
|
|
167
|
+
return runs;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** 명사 어절 연속의 최대 길이. nounRuns 의 센 길이 가운데 가장 큰 것. @param {string} text */
|
|
171
|
+
export function longestNounRun(text) {
|
|
172
|
+
let longest = 0;
|
|
173
|
+
for (const [, length] of nounRuns(text)) longest = Math.max(longest, length);
|
|
159
174
|
return longest;
|
|
160
175
|
}
|
|
161
176
|
|
package/src/cli/help.js
CHANGED
|
@@ -7,7 +7,7 @@ export function rootHelp() {
|
|
|
7
7
|
일상 검사
|
|
8
8
|
hanlint 글.md 글을 검사한다
|
|
9
9
|
hanlint fix 글.md 안전한 고침만 적용한다
|
|
10
|
-
hanlint hook AI
|
|
10
|
+
hanlint hook AI에게 쓰기 전 작법서를 싣고, 저장한 마크다운을 같은 턴에 검사한다
|
|
11
11
|
|
|
12
12
|
요구사항 잠금
|
|
13
13
|
hanlint contract init 글.md --reader "독자" --goal "목표" --outline h2
|
|
@@ -23,7 +23,8 @@ export function rootHelp() {
|
|
|
23
23
|
hanlint spec --preset <종류> --chars 800
|
|
24
24
|
쓰기 전 숫자 사양을 만든다
|
|
25
25
|
hanlint primer --preset <종류> 쓰기 전에 규칙의 고치는 법과 본보기를 한 장으로 본다
|
|
26
|
-
hanlint sheet src/ --preset screen 소스의 화면 글을 표 하나로
|
|
26
|
+
hanlint sheet src/ --preset screen 소스의 화면 글을 표 하나로 내보낸다. 고친 표는 sheet apply 로 되돌려 쓴다
|
|
27
|
+
hanlint usage "낱말 낱말" 그 종류의 실제 글에서 낱말이 쓰인 문장을 본다. 색인은 usage build 가 만든다
|
|
27
28
|
|
|
28
29
|
추가 명령
|
|
29
30
|
baseline, print
|
package/src/cli/main.js
CHANGED
|
@@ -6,12 +6,12 @@
|
|
|
6
6
|
* hanlint 인자가 없으면 첫 화면. 무엇을 칠 수 있는지 보인다
|
|
7
7
|
* hanlint 글.md [다른.md ...] 검사. 서브커맨드 없이 파일이나 폴더만 주면 lint 다. `-` 는 stdin
|
|
8
8
|
* hanlint fix 글.md 기계가 고칠 수 있는 지적을 원문에 적용
|
|
9
|
-
* hanlint print 글.md
|
|
9
|
+
* hanlint print 글.md 분석표 계층 JSON
|
|
10
10
|
* hanlint rules 규칙 목록을 부류로 묶어서
|
|
11
11
|
* hanlint explain <규칙> 규칙의 기술서
|
|
12
12
|
* hanlint patterns 문장 틀. --rule 로 그 규칙을 피하는 것만
|
|
13
|
-
* hanlint primer --preset <종류> 쓰기 전에 읽는 한 장. 켜진 규칙의 고치는
|
|
14
|
-
* hanlint hook
|
|
13
|
+
* hanlint primer --preset <종류> 쓰기 전에 읽는 한 장. 작법서 일곱 줄과 켜진 규칙의 고치는 법, 본보기 전후
|
|
14
|
+
* hanlint hook 에이전트 훅. --prime 은 쓰기 전 작법서, 나머지는 쓴 마크다운의 비차단 검사
|
|
15
15
|
* hanlint baseline 글들/ 지금 있는 지적을 잠근다. 그다음부터 새것만 막힌다
|
|
16
16
|
* hanlint baseline 글들/ 지금 있는 지적을 잠근다. 그다음부터 새것만 막힌다
|
|
17
17
|
* hanlint doctor 설정과 꺼진 규칙
|
|
@@ -43,12 +43,22 @@ import { loadConfig } from "../config/loadConfig.js";
|
|
|
43
43
|
import { defaultConfig, offRules, PRESET_NAMES, PRESETS } from "../config/settings.js";
|
|
44
44
|
import { applyFixes } from "../edit/applyFixes.js";
|
|
45
45
|
import { exemplarFor } from "../data/exemplars.js";
|
|
46
|
+
import { proseGuide, proseSections } from "../data/prose.js";
|
|
46
47
|
import { patterns, patternsAvoiding } from "../data/patterns.js";
|
|
47
48
|
import { HAPNIDA, REGISTERS } from "../analysis/grammar/index.js";
|
|
48
49
|
import { CATEGORY_TITLES, MECHANISMS, ruleCategory, ruleFix, ruleMechanism, runAll } from "../rules/registry.js";
|
|
49
50
|
import { MARKDOWN, SKIPPED_FOLDERS, isSkipped, markdownUnder } from "./walk.js";
|
|
50
|
-
import { SOURCE_SUFFIXES
|
|
51
|
-
import { parseSheet, renderSheet, renderSheetJson } from "../report/sheet.js";
|
|
51
|
+
import { SOURCE_SUFFIXES } from "../document/sourceText.js";
|
|
52
|
+
import { applyRows, parseSheet, renderSheet, renderSheetJson, sheetRows } from "../report/sheet.js";
|
|
53
|
+
import {
|
|
54
|
+
buildIndex as buildUsageIndex,
|
|
55
|
+
defaultRoot as defaultUsageRoot,
|
|
56
|
+
listIndexes as listUsageIndexes,
|
|
57
|
+
loadIndex as loadUsageIndex,
|
|
58
|
+
queryCores as usageQueryCores,
|
|
59
|
+
readDocuments as readUsageDocuments,
|
|
60
|
+
} from "../usage/sentences.js";
|
|
61
|
+
import { USAGE_KINDS } from "../config/settings.js";
|
|
52
62
|
import { welcome } from "./welcome.js";
|
|
53
63
|
import { rootHelp } from "./help.js";
|
|
54
64
|
import { renderCompact } from "../report/compactReport.js";
|
|
@@ -59,7 +69,7 @@ import { renderText } from "../report/textReport.js";
|
|
|
59
69
|
import { renderWritingSpec, writingSpec } from "../report/writingSpec.js";
|
|
60
70
|
import { exemplarInRegister, patternInRegister } from "../report/registerMatch.js";
|
|
61
71
|
|
|
62
|
-
const COMMANDS = ["lint", "fix", "print", "rules", "explain", "patterns", "primer", "spec", "hook", "baseline", "doctor", "init", "contract", "check", "verify-patch", "sheet"];
|
|
72
|
+
const COMMANDS = ["lint", "fix", "print", "rules", "explain", "patterns", "primer", "spec", "hook", "baseline", "doctor", "init", "contract", "check", "verify-patch", "sheet", "usage"];
|
|
63
73
|
const PYTHON_ONLY = [
|
|
64
74
|
"audit",
|
|
65
75
|
"map",
|
|
@@ -116,12 +126,15 @@ const OPTION_KINDS = {
|
|
|
116
126
|
"--quiet": "flag",
|
|
117
127
|
"--severity": "value",
|
|
118
128
|
"--errors-only": "flag",
|
|
129
|
+
"--notices": "flag",
|
|
119
130
|
"--path": "value",
|
|
120
131
|
"--layer": "value",
|
|
121
132
|
"--dry-run": "flag",
|
|
122
133
|
"--names": "flag",
|
|
123
134
|
"--force": "flag",
|
|
124
135
|
"--reply": "flag",
|
|
136
|
+
"--agent": "value",
|
|
137
|
+
"--prime": "flag",
|
|
125
138
|
"--reader": "value",
|
|
126
139
|
"--goal": "value",
|
|
127
140
|
"--outline": "value",
|
|
@@ -133,6 +146,9 @@ const OPTION_KINDS = {
|
|
|
133
146
|
"--baseline": "optional",
|
|
134
147
|
"--prune": "flag",
|
|
135
148
|
"--all": "flag",
|
|
149
|
+
"--kind": "value",
|
|
150
|
+
"--limit": "value",
|
|
151
|
+
"--root": "value",
|
|
136
152
|
};
|
|
137
153
|
|
|
138
154
|
class UsageError extends Error {}
|
|
@@ -249,7 +265,7 @@ function configFrom(options, paths, start = null) {
|
|
|
249
265
|
function configLabel(config) {
|
|
250
266
|
if (config.source === null) return "기본값";
|
|
251
267
|
const rel = relative(process.cwd(), config.source);
|
|
252
|
-
// 경로 구분자는 언제나 /. 설정이 작업 폴더 밖일 때 두
|
|
268
|
+
// 경로 구분자는 언제나 /. 설정이 작업 폴더 밖일 때 두 구현의 첫 줄이 갈렸다. 파이썬과 같다.
|
|
253
269
|
const label = !rel || rel.startsWith("..") || isAbsolute(rel) ? config.source : rel;
|
|
254
270
|
return label.split(sep).join("/");
|
|
255
271
|
}
|
|
@@ -354,7 +370,7 @@ function nextStep(results, fixable = 0) {
|
|
|
354
370
|
if (patternsAvoiding(rule).length) return `다음: error ${errors.length}건을 고친다. 다시 쓸 틀은 hanlint patterns --rule ${rule}`;
|
|
355
371
|
return `다음: error ${errors.length}건을 고친다. 규칙이 왜 있는지는 hanlint explain ${rule}`;
|
|
356
372
|
}
|
|
357
|
-
if (notices) return `다음: error 0.
|
|
373
|
+
if (notices) return `다음: error 0. 확인 권고 ${notices}건을 읽고 판단한 뒤 사람과 LLM 평가로 넘어간다`;
|
|
358
374
|
return "다음: 세어서 잡히는 결함이 없다. 좋은 글이라는 뜻은 아니므로 사람과 LLM 평가로 넘어간다";
|
|
359
375
|
}
|
|
360
376
|
|
|
@@ -528,7 +544,7 @@ function runLint(args) {
|
|
|
528
544
|
} else {
|
|
529
545
|
parts.push(
|
|
530
546
|
[...shown]
|
|
531
|
-
.map(([name, findings]) => renderText(name, findings, registers.get(name), config.preset, config.exemplars))
|
|
547
|
+
.map(([name, findings]) => renderText(name, findings, registers.get(name), config.preset, config.exemplars, Boolean(options["--notices"])))
|
|
532
548
|
.join("\n\n"),
|
|
533
549
|
);
|
|
534
550
|
if (shown.size > 1) parts.push(summary(results));
|
|
@@ -753,10 +769,17 @@ function primerEntries(config, register) {
|
|
|
753
769
|
return entries;
|
|
754
770
|
}
|
|
755
771
|
|
|
772
|
+
/** 작법서를 절마다 한 줄로. 제목 두 칸 띄고 본문이다. 파이썬 proseLines 와 같다. */
|
|
773
|
+
function proseLines() {
|
|
774
|
+
return proseSections().map(([title, body]) => `${title} ${body.split(/\s+/).filter(Boolean).join(" ")}`);
|
|
775
|
+
}
|
|
776
|
+
|
|
756
777
|
/** 사람과 AI 가 읽는 한 장. 파이썬 renderPrimer 와 글자 단위로 같다. @param {ReturnType<typeof primerEntries>} entries @param {string} preset @param {string} register */
|
|
757
778
|
function renderPrimer(entries, preset, register) {
|
|
758
779
|
const lines = [
|
|
759
|
-
|
|
780
|
+
...proseLines(),
|
|
781
|
+
"",
|
|
782
|
+
`hanlint primer ${preset} 종류, ${register}체, 규칙 ${entries.length}개. 줄마다 규칙 이름, 고치는 법, 전 (그 규칙에 잡히는 글), 후 (그 본보기에서 고친 글) 순서다. 본보기의 줄바꿈은 ¶ 다`,
|
|
760
783
|
];
|
|
761
784
|
for (const [category, title] of Object.entries(CATEGORY_TITLES)) {
|
|
762
785
|
const inside = entries.filter((entry) => entry.category === category);
|
|
@@ -764,7 +787,7 @@ function renderPrimer(entries, preset, register) {
|
|
|
764
787
|
lines.push("", `${title} (${inside.length})`);
|
|
765
788
|
for (const entry of inside) lines.push(`${entry.rule} ${entry.fix} 전: ${flat(entry.before)} 후: ${flat(entry.after)}`);
|
|
766
789
|
}
|
|
767
|
-
lines.push("", "
|
|
790
|
+
lines.push("", "본보기는 해당 문맥의 예시다. 용어와 사실을 보존하고, 치환을 다른 문맥에 적용하지 않는다. 규칙을 더 보려면 hanlint explain <규칙>");
|
|
768
791
|
return lines.join("\n");
|
|
769
792
|
}
|
|
770
793
|
|
|
@@ -777,7 +800,7 @@ function runPrimer(args) {
|
|
|
777
800
|
const output = /** @type {string | undefined} */ (options["--output"]);
|
|
778
801
|
const format = choose(/** @type {string} */ (options["--format"] ?? "text"), ["text", "json"], "--format");
|
|
779
802
|
if (format === "json") {
|
|
780
|
-
emit(JSON.stringify({ version: 1, preset: config.preset, register, rules: entries }, null, 2), output);
|
|
803
|
+
emit(JSON.stringify({ version: 1, preset: config.preset, register, prose: proseGuide(), rules: entries }, null, 2), output);
|
|
781
804
|
return 0;
|
|
782
805
|
}
|
|
783
806
|
emit(renderPrimer(entries, config.preset, register), output);
|
|
@@ -802,53 +825,104 @@ function runSpec(args) {
|
|
|
802
825
|
return 0;
|
|
803
826
|
}
|
|
804
827
|
|
|
805
|
-
/** Claude Code
|
|
806
|
-
|
|
828
|
+
/** 작법서를 싣는 이벤트. Claude Code와 Codex 모두 이 둘의 additionalContext를 모델 문맥에 넣는다. */
|
|
829
|
+
const PRIME_EVENTS = ["SessionStart", "UserPromptSubmit"];
|
|
830
|
+
|
|
831
|
+
/** 에이전트가 다음 모델 요청에 넣는 비차단 문맥. @param {string} eventName @param {string} context */
|
|
832
|
+
function hookContext(eventName, context) {
|
|
833
|
+
return JSON.stringify({ hookSpecificOutput: { hookEventName: eventName, additionalContext: context } });
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
/** Finding을 되먹이는 문맥의 본문. 파일마다 한 덩어리다. 파이썬 findingContext 와 같다. @param {string} eventName @param {[string, import("../rules/finding.js").Finding[]][]} found */
|
|
837
|
+
function findingContext(eventName, found) {
|
|
807
838
|
const where = eventName === "Stop" ? "마지막 답변" : "방금 쓴 마크다운";
|
|
808
|
-
return
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
839
|
+
return `hanlint가 ${where}에서 센 Finding입니다.\n${found.map(([name, items]) => renderCompact(name, items)).join("\n")}`;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/** Codex apply_patch 본문에서 쓴 파일을 가리키는 머리 줄 (Codex 훅 문서, 2026-09-21 확인). */
|
|
843
|
+
const PATCH_HEADS = ["*** Add File: ", "*** Update File: ", "*** Move to: "];
|
|
844
|
+
|
|
845
|
+
/** apply_patch 본문이 쓴 파일 경로. 나온 차례대로 한 번씩이다. @param {string} patch */
|
|
846
|
+
function patchPaths(patch) {
|
|
847
|
+
/** @type {string[]} */
|
|
848
|
+
const paths = [];
|
|
849
|
+
for (const line of patch.split(/\r?\n/)) {
|
|
850
|
+
for (const head of PATCH_HEADS) {
|
|
851
|
+
if (!line.startsWith(head)) continue;
|
|
852
|
+
const path = line.slice(head.length).trim();
|
|
853
|
+
if (path && !paths.includes(path)) paths.push(path);
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
return paths;
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
/** PostToolUse 입력이 쓴 파일 경로. 모르는 도구면 빈 목록이다. @param {any} payload @returns {string[]} */
|
|
860
|
+
function writtenPaths(payload) {
|
|
861
|
+
const toolInput = payload.tool_input;
|
|
862
|
+
if (!toolInput || typeof toolInput !== "object" || Array.isArray(toolInput)) return [];
|
|
863
|
+
if (["Write", "Edit"].includes(payload.tool_name)) {
|
|
864
|
+
const path = toolInput.file_path;
|
|
865
|
+
return typeof path === "string" && path ? [path] : [];
|
|
866
|
+
}
|
|
867
|
+
if (payload.tool_name === "apply_patch") return typeof toolInput.command === "string" ? patchPaths(toolInput.command) : [];
|
|
868
|
+
return [];
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
/** 쓴 마크다운마다 [이름, Finding]. 상대 경로는 입력의 cwd에서 찾는다. @param {any} payload @param {Record<string, unknown>} options */
|
|
872
|
+
function writtenFindings(payload, options) {
|
|
873
|
+
const base = typeof payload.cwd === "string" && payload.cwd ? payload.cwd : null;
|
|
874
|
+
/** @type {[string, import("../rules/finding.js").Finding[]][]} */
|
|
875
|
+
const found = [];
|
|
876
|
+
for (const rawPath of writtenPaths(payload)) {
|
|
877
|
+
const path = base !== null && !isAbsolute(rawPath) ? join(base, rawPath) : rawPath;
|
|
878
|
+
if (!MARKDOWN.includes(extname(path).toLowerCase())) continue;
|
|
879
|
+
try {
|
|
880
|
+
if (!statSync(path).isFile()) continue;
|
|
881
|
+
} catch {
|
|
882
|
+
continue;
|
|
883
|
+
}
|
|
884
|
+
const config = configFrom(options, [path]);
|
|
885
|
+
let findings = lintText(readFileSync(path, "utf-8"), config, rawPath);
|
|
886
|
+
if (config.baseline) findings = loadBaseline(config.baseline).keep(rawPath, findings);
|
|
887
|
+
found.push([rawPath, findings]);
|
|
888
|
+
}
|
|
889
|
+
return found;
|
|
814
890
|
}
|
|
815
891
|
|
|
816
892
|
/** 명령 훅 JSON을 stdin에서 읽어 저장 파일이나 마지막 답변을 검사한다. 언제나 종료 코드 0이다. @param {string[]} args */
|
|
817
893
|
function runHook(args) {
|
|
894
|
+
if (args.includes("--prime") && args.includes("--reply")) throw new UsageError("--prime 과 --reply 는 함께 쓰지 않는다");
|
|
895
|
+
const { options, positionals } = parseArgs(args);
|
|
896
|
+
const agent = choose(/** @type {string} */ (options["--agent"] ?? "claude"), ["claude", "codex"], "--agent");
|
|
818
897
|
try {
|
|
819
|
-
const { options, positionals } = parseArgs(args);
|
|
820
898
|
if (positionals.length) return 0;
|
|
821
899
|
const payload = JSON.parse(readFileSync(0, "utf-8"));
|
|
822
900
|
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return 0;
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
901
|
+
const eventName = payload.hook_event_name;
|
|
902
|
+
if (options["--prime"]) {
|
|
903
|
+
if (PRIME_EVENTS.includes(eventName)) process.stdout.write(`${hookContext(eventName, proseGuide())}\n`);
|
|
904
|
+
return 0;
|
|
905
|
+
}
|
|
906
|
+
/** @type {[string, import("../rules/finding.js").Finding[]][]} */
|
|
907
|
+
let found;
|
|
826
908
|
if (options["--reply"]) {
|
|
827
|
-
if (
|
|
909
|
+
if (eventName !== "Stop" || payload.stop_hook_active === true) return 0;
|
|
828
910
|
const text = payload.last_assistant_message;
|
|
829
911
|
if (typeof text !== "string" || !text.trim()) return 0;
|
|
830
912
|
const start = typeof payload.cwd === "string" && payload.cwd ? payload.cwd : process.cwd();
|
|
831
913
|
const config = configFrom(options, [], start);
|
|
832
914
|
config.preset = "chat";
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
915
|
+
found = [["<assistant>", lintText(text, config, "<assistant>")]];
|
|
916
|
+
} else if (eventName === "PostToolUse") {
|
|
917
|
+
found = writtenFindings(payload, options);
|
|
836
918
|
} else {
|
|
837
|
-
|
|
838
|
-
const rawPath = payload.tool_input?.file_path;
|
|
839
|
-
if (typeof rawPath !== "string" || !rawPath || !MARKDOWN.includes(extname(rawPath).toLowerCase())) return 0;
|
|
840
|
-
try {
|
|
841
|
-
if (!statSync(rawPath).isFile()) return 0;
|
|
842
|
-
} catch {
|
|
843
|
-
return 0;
|
|
844
|
-
}
|
|
845
|
-
const config = configFrom(options, [rawPath]);
|
|
846
|
-
eventName = "PostToolUse";
|
|
847
|
-
name = rawPath;
|
|
848
|
-
findings = lintText(readFileSync(rawPath, "utf-8"), config, name);
|
|
849
|
-
if (config.baseline) findings = loadBaseline(config.baseline).keep(name, findings);
|
|
919
|
+
return 0;
|
|
850
920
|
}
|
|
851
|
-
|
|
921
|
+
found = found.filter(([, items]) => items.length);
|
|
922
|
+
if (!found.length) return 0;
|
|
923
|
+
const context = findingContext(eventName, found);
|
|
924
|
+
if (options["--reply"] && agent === "codex") process.stdout.write(`${JSON.stringify({ systemMessage: context })}\n`);
|
|
925
|
+
else process.stdout.write(`${hookContext(eventName, context)}\n`);
|
|
852
926
|
} catch {
|
|
853
927
|
return 0;
|
|
854
928
|
}
|
|
@@ -920,10 +994,10 @@ export function renderInit(preset = "blog") {
|
|
|
920
994
|
"# hanlint baseline 이 만든 잠금 파일. 있으면 그 안의 지적은 넘기고 새로 생긴 것만 막는다",
|
|
921
995
|
'# baseline = ".hanlint-baseline.json"',
|
|
922
996
|
"",
|
|
923
|
-
"# 코드도 산문도 아닌 펜스의 언어 표기. 장면 계약과 도표 원문은 코드 블록으로 세지 않고
|
|
997
|
+
"# 코드도 산문도 아닌 펜스의 언어 표기. 장면 계약과 도표 원문은 코드 블록으로 세지 않고 분석표에서 뺀다",
|
|
924
998
|
'# ignoreFences = ["course-scene", "mermaid"]',
|
|
925
999
|
"",
|
|
926
|
-
"# 임계. 기본값의
|
|
1000
|
+
"# 임계. 기본값의 기준은 hanlint 의 config/settings.py 다",
|
|
927
1001
|
);
|
|
928
1002
|
for (const name of THRESHOLD_FIELDS) {
|
|
929
1003
|
const value = /** @type {number} */ (defaults[name]);
|
|
@@ -931,9 +1005,16 @@ export function renderInit(preset = "blog") {
|
|
|
931
1005
|
lines.push(`# ${name} = ${shown}`);
|
|
932
1006
|
}
|
|
933
1007
|
lines.push(
|
|
1008
|
+
"",
|
|
1009
|
+
"# 용례. nounPile 은 그 종류의 실제 글에서 usageMin 편 이상의 문서에 나온 명사 연쇄를 짚지 않고, 사전 규칙은",
|
|
1010
|
+
"# 그 종류의 문서 usageShare 이상에 나오는 항목 (사업보고서의 `에 대한`) 을 짚지 않는다.",
|
|
1011
|
+
"# 종류는 프리셋이 정한다 (report 만 있다). 빈 문자열이면 어떤 프리셋에서도 용례를 보지 않는다",
|
|
1012
|
+
'# usageKind = ""',
|
|
1013
|
+
"# usageMin = 3",
|
|
1014
|
+
"# usageShare = 0.9",
|
|
934
1015
|
"",
|
|
935
1016
|
"# 사전에 더할 항목. 키는 cliches, translationese, redundantPair, japaneseLoan, easyWords,",
|
|
936
|
-
"# screenSentence, screenNarration, screenTone, screenWord",
|
|
1017
|
+
"# screenSentence, screenNarration, screenTone, screenWord, calqueFrame",
|
|
937
1018
|
"# [dictionary]",
|
|
938
1019
|
'# cliches = ["우리의 여정"]',
|
|
939
1020
|
'# translationese = [{ pattern = "에 대한 이해", fix = "를 아는 것" }]',
|
|
@@ -995,7 +1076,9 @@ function sourceUnder(folder) {
|
|
|
995
1076
|
if (!isSkipped(name)) found.push(...sourceUnder(path));
|
|
996
1077
|
} else if (SOURCE_SUFFIXES.includes(extname(name).toLowerCase())) found.push(path);
|
|
997
1078
|
}
|
|
998
|
-
|
|
1079
|
+
// 구분자를 / 로 맞춘 뒤 정렬한다. 뜻은 파이썬 sourceUnder 가 소유한다.
|
|
1080
|
+
const posix = (item) => item.split(sep).join("/");
|
|
1081
|
+
return found.sort((a, b) => (posix(a) < posix(b) ? -1 : posix(a) > posix(b) ? 1 : 0));
|
|
999
1082
|
}
|
|
1000
1083
|
|
|
1001
1084
|
/** 작업 폴더 기준 상대 경로, 구분자는 `/`. 파이썬 relativeLabel 과 같은 글자를 낸다. @param {string} path */
|
|
@@ -1028,40 +1111,105 @@ function applySheet(sheetPath, dryRun) {
|
|
|
1028
1111
|
failed.push(...rows.map((row) => `${placeOf(row)}: 파일이 없다`));
|
|
1029
1112
|
continue;
|
|
1030
1113
|
}
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
if (row.line < 1 || row.line > lines.length) {
|
|
1037
|
-
failed.push(`${place}: 그 줄이 없다`);
|
|
1038
|
-
continue;
|
|
1039
|
-
}
|
|
1040
|
-
let newLine = lines[row.line - 1];
|
|
1041
|
-
if (row.column > 0) {
|
|
1042
|
-
const start = row.column - 1;
|
|
1043
|
-
if (newLine.slice(start, start + row.text.length) !== row.text) {
|
|
1044
|
-
failed.push(`${place}: 그 칸에 그 글이 없다. 표를 다시 뽑는다`);
|
|
1045
|
-
continue;
|
|
1046
|
-
}
|
|
1047
|
-
newLine = newLine.slice(0, start) + row.fix + newLine.slice(start + row.text.length);
|
|
1048
|
-
} else {
|
|
1049
|
-
const [replaced, count] = replaceLiteral(newLine, row.text, row.fix);
|
|
1050
|
-
if (count !== 1) {
|
|
1051
|
-
failed.push(`${place}: 글이 그 줄에 ${count}번 있다. 한 번이어야 바꾼다`);
|
|
1052
|
-
continue;
|
|
1053
|
-
}
|
|
1054
|
-
newLine = replaced;
|
|
1055
|
-
}
|
|
1056
|
-
lines[row.line - 1] = newLine;
|
|
1057
|
-
changed = true;
|
|
1058
|
-
applied.push(`${place}: ${row.text} -> ${row.fix}`);
|
|
1059
|
-
}
|
|
1060
|
-
if (changed && !dryRun) writeFileSync(file, lines.join("\n"), "utf-8");
|
|
1114
|
+
// 되돌려 쓰는 뜻은 report/sheet.js 의 applyRows 가 소유한다 (브라우저의 저장소 쓰기와 같은 함수).
|
|
1115
|
+
const [rewritten, done, problems] = applyRows(readFileSync(file, "utf-8"), rows);
|
|
1116
|
+
applied.push(...done);
|
|
1117
|
+
failed.push(...problems);
|
|
1118
|
+
if (done.length && !dryRun) writeFileSync(file, rewritten, "utf-8");
|
|
1061
1119
|
}
|
|
1062
1120
|
return [applied, failed];
|
|
1063
1121
|
}
|
|
1064
1122
|
|
|
1123
|
+
/** 색인이 없을 때의 안내. 뜻은 파이썬 cli/commands/usage.py 의 missingIndex 가 소유한다. @param {string} kind @param {string} root */
|
|
1124
|
+
function missingIndex(kind, root) {
|
|
1125
|
+
const kinds = listUsageIndexes(root).map((meta) => String(meta.kind)).join(", ") || "없음";
|
|
1126
|
+
return `${kind} 색인이 없다 (있는 것: ${kinds}). hanlint usage build ${kind} <글 폴더> 로 만든다 (txt, md, jsonl. 하위 폴더 포함). 사업보고서는 scripts/fetch/dartReports.py, 위키백과는 scripts/fetch/koWikipedia.py 가 받는다`;
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
/** 함께 쓰는 말 낱말 수 상한. 뜻은 파이썬 QUERY_WORDS 가 소유한다. */
|
|
1130
|
+
const USAGE_QUERY_WORDS = 3;
|
|
1131
|
+
|
|
1132
|
+
/** @param {[string, number][]} pairs */
|
|
1133
|
+
function pairText(pairs) {
|
|
1134
|
+
return pairs.map(([term, count]) => `${term} ${count}편`).join(", ");
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
/**
|
|
1138
|
+
* `hanlint usage "낱말 …" --kind report --limit 5`, `hanlint usage build <종류> <글 폴더>`, `hanlint usage kinds`. 뜻은 파이썬
|
|
1139
|
+
* cli/commands/usage.py 가 소유한다.
|
|
1140
|
+
* @param {string[]} args
|
|
1141
|
+
*/
|
|
1142
|
+
function runUsage(args) {
|
|
1143
|
+
const { options, positionals } = parseArgs(args);
|
|
1144
|
+
const output = /** @type {string | undefined} */ (options["--output"]);
|
|
1145
|
+
const root = /** @type {string} */ (options["--root"] ?? defaultUsageRoot());
|
|
1146
|
+
const format = choose(/** @type {string} */ (options["--format"] ?? "text"), ["text", "json"], "--format");
|
|
1147
|
+
if (!positionals.length) throw new UsageError('hanlint usage "낱말 …" 또는 hanlint usage build <종류> <글 폴더>');
|
|
1148
|
+
if (positionals[0] === "kinds") {
|
|
1149
|
+
const found = listUsageIndexes(root);
|
|
1150
|
+
if (format === "json") {
|
|
1151
|
+
emit(JSON.stringify({ version: 1, root, kinds: found }, null, 2), output);
|
|
1152
|
+
return 0;
|
|
1153
|
+
}
|
|
1154
|
+
if (!found.length) {
|
|
1155
|
+
emit(`${root} 에 색인이 없다. hanlint usage build <종류> <글 폴더> 로 만든다`, output);
|
|
1156
|
+
return 0;
|
|
1157
|
+
}
|
|
1158
|
+
const lines = [`색인 ${found.length}개 (${root})`];
|
|
1159
|
+
for (const meta of found) lines.push(` ${meta.kind}: 문서 ${meta.documents}편, 문장 ${meta.sentences}개, 토큰 ${meta.terms}종`);
|
|
1160
|
+
emit(lines.join("\n"), output);
|
|
1161
|
+
return 0;
|
|
1162
|
+
}
|
|
1163
|
+
if (positionals[0] === "build") {
|
|
1164
|
+
if (positionals.length !== 3) {
|
|
1165
|
+
emit("hanlint usage build <종류> <글 폴더> 꼴로 준다", output);
|
|
1166
|
+
return 2;
|
|
1167
|
+
}
|
|
1168
|
+
const [, kind, folder] = positionals;
|
|
1169
|
+
if (!existsSync(folder) || !statSync(folder).isDirectory()) {
|
|
1170
|
+
emit(`${folder} 는 폴더가 아니다`, output);
|
|
1171
|
+
return 2;
|
|
1172
|
+
}
|
|
1173
|
+
const result = buildUsageIndex(kind, readUsageDocuments(folder), root);
|
|
1174
|
+
emit(`${join(root, kind)}: 문서 ${result.documents}편, 문장 ${result.sentences}개, 토큰 ${result.terms}종. hanlint usage "낱말" --kind ${kind} 로 묻는다`, output);
|
|
1175
|
+
return 0;
|
|
1176
|
+
}
|
|
1177
|
+
const kind = /** @type {string} */ (options["--kind"] ?? USAGE_KINDS[0]);
|
|
1178
|
+
const limit = Math.max(Number(options["--limit"] ?? 5), 0);
|
|
1179
|
+
if (!Number.isInteger(limit)) throw new UsageError("--limit 은 정수다");
|
|
1180
|
+
const query = positionals.join(" ");
|
|
1181
|
+
const index = loadUsageIndex(kind, root);
|
|
1182
|
+
if (!index) {
|
|
1183
|
+
emit(missingIndex(kind, root), output);
|
|
1184
|
+
return 2;
|
|
1185
|
+
}
|
|
1186
|
+
const words = usageQueryCores(query)
|
|
1187
|
+
.slice(0, USAGE_QUERY_WORDS)
|
|
1188
|
+
.map((core) => index.collocations(core))
|
|
1189
|
+
.filter((item) => item !== null && (item.predicates.length || item.following.length || item.preceding.length));
|
|
1190
|
+
const hits = index.search(query, limit);
|
|
1191
|
+
if (format === "json") {
|
|
1192
|
+
const data = { version: 2, kind, query, documents: index.documents, sentences: index.sentences, words, hits };
|
|
1193
|
+
emit(JSON.stringify(data, null, 2), output);
|
|
1194
|
+
return 0;
|
|
1195
|
+
}
|
|
1196
|
+
const lines = [`${kind} 용례 (문서 ${index.documents}편, 문장 ${index.sentences}개): ${query}`];
|
|
1197
|
+
for (const item of words) {
|
|
1198
|
+
if (!item) continue;
|
|
1199
|
+
lines.push(`함께 쓰는 말 (${item.term}, 문장 ${item.sampled}개에서)`);
|
|
1200
|
+
for (const [label, pairs] of [["뒤 용언", item.predicates], ["뒤 명사", item.following], ["앞 명사", item.preceding]]) {
|
|
1201
|
+
if (pairs.length) lines.push(` ${label}: ${pairText(/** @type {[string, number][]} */ (pairs))}`);
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
if (!hits.length && limit > 0) lines.push("쓰인 문장이 없다. 낱말을 줄이거나 다른 낱말로 묻는다");
|
|
1205
|
+
hits.forEach((hit, index) => {
|
|
1206
|
+
lines.push(`${index + 1}. ${hit.text}`);
|
|
1207
|
+
lines.push(` 문서 ${hit.documents}편, 출처 ${hit.source}`);
|
|
1208
|
+
});
|
|
1209
|
+
emit(lines.join("\n"), output);
|
|
1210
|
+
return 0;
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1065
1213
|
/** `hanlint sheet 폴더/ --preset screen` 과 `hanlint sheet apply 시트.md`. 뜻은 파이썬 cli/commands/sheet.py 가 소유한다. @param {string[]} args */
|
|
1066
1214
|
function runSheet(args) {
|
|
1067
1215
|
const { options, positionals } = parseArgs(args);
|
|
@@ -1089,18 +1237,8 @@ function runSheet(args) {
|
|
|
1089
1237
|
}
|
|
1090
1238
|
const config = configFrom(options, files);
|
|
1091
1239
|
const format = choose(/** @type {string} */ (options["--format"] ?? "markdown"), ["markdown", "json"], "--format");
|
|
1092
|
-
|
|
1093
|
-
const rows = [];
|
|
1094
|
-
for (const file of files) {
|
|
1095
|
-
const label = relativeLabel(file);
|
|
1096
|
-
for (const literal of sourceLiterals(readFileSync(file, "utf-8"), label)) {
|
|
1097
|
-
const findings = runAll(fingerprint(literal.plain, config), config).filter((finding) => finding.severity === "error");
|
|
1098
|
-
// 지적이 하나이고 고침이 있으면 고침 칸에 미리 적는다. 뜻은 파이썬 cli/commands/sheet.py 의 prefilledFix 가 소유한다.
|
|
1099
|
-
const only = literal.plain === literal.text && findings.length === 1 ? findings[0] : null;
|
|
1100
|
-
const fix = only && only.fix !== null && only.quote === literal.plain ? only.fix : "";
|
|
1101
|
-
if (findings.length || options["--all"]) rows.push({ file: label, line: literal.line, text: literal.text, findings, fix, column: literal.column });
|
|
1102
|
-
}
|
|
1103
|
-
}
|
|
1240
|
+
// 디스크의 파일을 읽어 report.sheetRows 에 넘긴다. 행의 뜻은 그쪽이 소유한다.
|
|
1241
|
+
const rows = sheetRows(files.map((file) => ({ label: relativeLabel(file), source: readFileSync(file, "utf-8") })), config, Boolean(options["--all"]));
|
|
1104
1242
|
emit(format === "json" ? renderSheetJson(rows, config.preset, files.length) : renderSheet(rows, config.preset, files.length), output);
|
|
1105
1243
|
return 0;
|
|
1106
1244
|
}
|
|
@@ -1138,6 +1276,7 @@ function dispatch(argv) {
|
|
|
1138
1276
|
if (command === "baseline") return runBaseline(rest);
|
|
1139
1277
|
if (command === "doctor") return runDoctor(rest);
|
|
1140
1278
|
if (command === "sheet") return runSheet(rest);
|
|
1279
|
+
if (command === "usage") return runUsage(rest);
|
|
1141
1280
|
return runInit(rest);
|
|
1142
1281
|
}
|
|
1143
1282
|
|
package/src/cli/walk.js
CHANGED
|
@@ -16,7 +16,7 @@ export function isSkipped(name) {
|
|
|
16
16
|
return name.startsWith(".") || SKIPPED_FOLDERS.includes(name);
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
/** 폴더 아래 마크다운. 건너뛸 폴더에는 안 들어간다. 경로 문자열 순이라 두
|
|
19
|
+
/** 폴더 아래 마크다운. 건너뛸 폴더에는 안 들어간다. 경로 문자열 순이라 두 구현이 같은 차례를 낸다. @param {string} folder */
|
|
20
20
|
export function markdownUnder(folder) {
|
|
21
21
|
/** @type {string[]} */
|
|
22
22
|
const found = [];
|
package/src/cli/welcome.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
/**
|
|
3
3
|
* `hanlint` 를 인자 없이 쳤을 때의 첫 화면. 파이썬 cli/welcome.py 와 같은 글자다.
|
|
4
|
-
* 같은 폴더에서 두
|
|
4
|
+
* 같은 폴더에서 두 구현을 돌리면 결과가 같다. 파일을 찾는 것은 검사와 같은 함수 (walk.markdownUnder) 다.
|
|
5
5
|
*/
|
|
6
6
|
import { relative, sep } from "node:path";
|
|
7
7
|
|