hanlint 0.0.17 → 0.0.19
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 +23 -20
- package/data/calqueFrame.json +60 -0
- package/data/cliches.json +39 -29
- package/data/collocationStops.txt +28 -0
- package/data/confusable.json +20 -20
- package/data/easyWords.json +12 -246
- package/data/exemplars.json +50 -29
- package/data/hiddenDeps.txt +11 -11
- package/data/japaneseLoan.json +27 -27
- package/data/koreanProse.txt +64 -0
- package/data/patterns.json +26 -26
- package/data/phraseRepeat.json +11 -0
- package/data/platformApis.txt +11 -11
- package/data/redundantPair.json +16 -16
- package/data/ruleCategories.json +2 -0
- package/data/ruleDocs.json +56 -54
- package/data/ruleMechanisms.json +2 -0
- package/data/screenNarration.json +10 -10
- package/data/screenSentence.json +60 -12
- package/data/screenTone.json +6 -6
- package/data/screenWord.json +70 -110
- package/data/spacing.json +12 -12
- package/data/spelling.json +29 -29
- package/data/translationese.json +23 -24
- package/data/usageCounts.report.json +1676 -0
- package/data/version.json +1 -1
- package/package.json +1 -1
- package/src/analysis/grammar/ending.js +1 -1
- package/src/analysis/grammar/hangul.js +4 -4
- package/src/analysis/grammar/josa.js +1 -1
- package/src/analysis/grammar/register.js +1 -1
- package/src/analysis/grammar/voice.js +1 -1
- package/src/analysis/index.js +3 -3
- package/src/analysis/josa.js +1 -1
- package/src/analysis/splitSentences.js +1 -1
- package/src/analysis/tokenize.js +41 -26
- package/src/baseline/store.js +8 -8
- package/src/cli/help.js +11 -10
- package/src/cli/main.js +332 -182
- package/src/cli/walk.js +2 -2
- package/src/cli/welcome.js +8 -8
- package/src/config/loadConfig.js +3 -3
- package/src/config/patch.js +6 -6
- package/src/config/readerContract.js +26 -26
- package/src/config/settings.js +44 -25
- package/src/config/toml.js +9 -9
- package/src/data/exemplars.js +21 -21
- package/src/data/load.js +6 -6
- package/src/data/operations.js +14 -14
- package/src/data/patches.js +10 -10
- package/src/data/patterns.js +5 -5
- package/src/data/profiles.js +6 -6
- package/src/data/prose.js +27 -0
- package/src/document/model.js +4 -4
- package/src/document/parseMarkdown.js +7 -7
- package/src/document/plainText.js +3 -3
- package/src/document/sourceText.js +117 -19
- package/src/edit/applyFixes.js +0 -0
- package/src/fingerprint/build.js +18 -9
- package/src/fingerprint/codeBlocks.js +3 -3
- package/src/fingerprint/codeMarkers.js +4 -4
- package/src/fingerprint/dictionaries.js +34 -7
- package/src/fingerprint/markers.js +27 -5
- package/src/fingerprint/readerState.js +9 -9
- package/src/fingerprint/sourceSentence.js +1 -1
- package/src/fingerprint/topics.js +1 -1
- package/src/guard/contract.js +6 -6
- package/src/guard/editLocality.js +1 -1
- package/src/guard/outline.js +1 -1
- package/src/guard/receipt.js +13 -13
- package/src/guard/surface.js +4 -4
- package/src/index.js +9 -7
- package/src/learn/edits.js +2 -2
- package/src/learn/operations.js +1 -1
- package/src/regex.js +2 -2
- package/src/report/compactReport.js +7 -3
- package/src/report/fingerprintJson.js +2 -2
- package/src/report/githubReport.js +1 -1
- package/src/report/jsonReport.js +3 -3
- package/src/report/registerMatch.js +2 -2
- package/src/report/sheet.js +0 -0
- package/src/report/textReport.js +35 -16
- package/src/report/writingSpec.js +17 -17
- package/src/rules/code/inputFileSource.js +1 -1
- package/src/rules/code/installImport.js +2 -2
- package/src/rules/code/platformApi.js +1 -1
- package/src/rules/document/countMismatch.js +2 -1
- package/src/rules/document/enoughOnce.js +2 -1
- package/src/rules/document/fieldEcho.js +10 -6
- package/src/rules/document/keywordHeading.js +1 -1
- package/src/rules/document/keywordMissing.js +1 -1
- package/src/rules/document/noQuestion.js +2 -2
- package/src/rules/document/promiseRecall.js +1 -1
- package/src/rules/document/tableOddCell.js +1 -1
- package/src/rules/finding.js +4 -4
- package/src/rules/paragraph/factListParagraph.js +1 -1
- package/src/rules/paragraph/paraFragment.js +1 -1
- package/src/rules/readerDebt.js +1 -1
- package/src/rules/registry.js +30 -26
- 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/connectorRepeat.js +2 -1
- package/src/rules/sentence/danglingDeixis.js +1 -1
- package/src/rules/sentence/dash.js +1 -1
- package/src/rules/sentence/deixis.js +1 -1
- package/src/rules/sentence/doubleNegative.js +2 -1
- package/src/rules/sentence/doublePassive.js +2 -1
- package/src/rules/sentence/draftHistory.js +2 -1
- package/src/rules/sentence/endingRepeat.js +3 -2
- package/src/rules/sentence/euiChain.js +1 -1
- package/src/rules/sentence/fillerOpener.js +1 -1
- package/src/rules/sentence/hardWord.js +6 -3
- package/src/rules/sentence/imperativePeriod.js +1 -1
- package/src/rules/sentence/japaneseLoan.js +7 -3
- package/src/rules/sentence/longSentence.js +1 -1
- package/src/rules/sentence/negationRedefine.js +1 -1
- package/src/rules/sentence/nounPile.js +10 -4
- package/src/rules/sentence/numberOrphan.js +1 -1
- package/src/rules/sentence/outsideProfile.js +8 -3
- package/src/rules/sentence/phraseRepeat.js +54 -0
- package/src/rules/sentence/redundantPair.js +7 -3
- package/src/rules/sentence/translationese.js +7 -3
- package/src/rules/shared/candidates.js +2 -2
- package/src/rules/shared/dictionaryRule.js +46 -8
- package/src/rules/shared/repeat.js +2 -2
- package/src/rules/structure/bridgeRepeat.js +3 -3
- package/src/rules/structure/emojiBullet.js +1 -1
- package/src/rules/structure/headingSkip.js +1 -1
- package/src/rules/structure/headingUniform.js +2 -1
- package/src/rules/structure/loneSubheading.js +1 -1
- package/src/rules/structure/moreLater.js +1 -1
- package/src/text.js +2 -2
- package/src/usage/counts.js +54 -0
- package/src/usage/index.js +8 -0
- package/src/usage/sentences.js +681 -0
package/src/cli/main.js
CHANGED
|
@@ -1,21 +1,21 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
/**
|
|
3
|
-
* 명령줄 진입점. 파이썬 CLI
|
|
3
|
+
* 명령줄 진입점. 파이썬 CLI와 같은 인자, 같은 출력, 같은 종료 코드다 (0 지적 없음, 1 error 있음, 2 파일이나 설정 문제).
|
|
4
4
|
*
|
|
5
5
|
* ```
|
|
6
6
|
* hanlint 인자가 없으면 첫 화면. 무엇을 칠 수 있는지 보인다
|
|
7
|
-
* hanlint 글.md [다른.md ...] 검사. 서브커맨드 없이 파일이나 폴더만 주면 lint
|
|
7
|
+
* hanlint 글.md [다른.md ...] 검사. 서브커맨드 없이 파일이나 폴더만 주면 lint다. `-`는 stdin
|
|
8
8
|
* hanlint fix 글.md 기계가 고칠 수 있는 지적을 원문에 적용
|
|
9
|
-
* hanlint print 글.md
|
|
10
|
-
* hanlint rules 규칙 목록을
|
|
9
|
+
* hanlint print 글.md 분석표 계층 JSON
|
|
10
|
+
* hanlint rules 규칙 목록을 묶음로 묶어서
|
|
11
11
|
* hanlint explain <규칙> 규칙의 기술서
|
|
12
|
-
* hanlint patterns 문장 틀. --rule
|
|
13
|
-
* hanlint primer --preset <종류> 쓰기 전에 읽는 한 장. 켜진 규칙의 고치는
|
|
14
|
-
* hanlint hook
|
|
12
|
+
* hanlint patterns 문장 틀. --rule로 그 규칙을 피하는 것만
|
|
13
|
+
* hanlint primer --preset <종류> 쓰기 전에 읽는 한 장. 글쓰기 지침 일곱 줄과 켜진 규칙의 고치는 법, 예시 전후
|
|
14
|
+
* hanlint hook 에이전트 훅. --prime은 쓰기 전 글쓰기 지침, 나머지는 쓴 마크다운의 비차단 검사
|
|
15
15
|
* hanlint baseline 글들/ 지금 있는 지적을 잠근다. 그다음부터 새것만 막힌다
|
|
16
16
|
* hanlint baseline 글들/ 지금 있는 지적을 잠근다. 그다음부터 새것만 막힌다
|
|
17
17
|
* hanlint doctor 설정과 꺼진 규칙
|
|
18
|
-
* hanlint init 주석 달린 hanlint.toml. --output
|
|
18
|
+
* hanlint init 주석 달린 hanlint.toml. --output과 --preset blog|report|docs
|
|
19
19
|
* hanlint contract init 글.md 원문에서 최소 Reader Contract 초안
|
|
20
20
|
* ```
|
|
21
21
|
* audit, guard, arena, blueprint, evidence, entailment 같은 확장 명령은 파이썬 패키지에 있다.
|
|
@@ -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",
|
|
@@ -78,11 +88,11 @@ const PYTHON_ONLY = [
|
|
|
78
88
|
];
|
|
79
89
|
const FORMATS = ["text", "compact", "json", "github"];
|
|
80
90
|
const SEVERITIES = ["all", "error", "notice"];
|
|
81
|
-
/** 오탐과 미탐을 받는 자리. 뜻은 파이썬 cli/commands/explain.py
|
|
91
|
+
/** 오탐과 미탐을 받는 자리. 뜻은 파이썬 cli/commands/explain.py가 소유한다. */
|
|
82
92
|
const ISSUES = "github.com/eddmpython/hanlint/issues";
|
|
83
93
|
const STDIN = "-";
|
|
84
94
|
const STDIN_NAME = "<stdin>";
|
|
85
|
-
/** 폴더를 주면 이 확장자만 찾는다. 파이썬 cli/commands/shared.py
|
|
95
|
+
/** 폴더를 주면 이 확장자만 찾는다. 파이썬 cli/commands/shared.py의 MARKDOWN과 같다. */
|
|
86
96
|
const THRESHOLD_FIELDS = [
|
|
87
97
|
"fragmentRun",
|
|
88
98
|
"introMaxParagraphs",
|
|
@@ -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 {}
|
|
@@ -164,13 +180,13 @@ function parseArgs(args) {
|
|
|
164
180
|
options[arg] = true;
|
|
165
181
|
continue;
|
|
166
182
|
}
|
|
167
|
-
// 값을 줘도 되고 안 줘도 되는 옵션. 다음 인자가 다른 옵션이거나 없으면 true
|
|
183
|
+
// 값을 줘도 되고 안 줘도 되는 옵션. 다음 인자가 다른 옵션이거나 없으면 true로 둔다 (파이썬의 nargs="?").
|
|
168
184
|
if (kind === "optional" && inlineValue === null && (args[i + 1] === undefined || args[i + 1].startsWith("--"))) {
|
|
169
185
|
options[arg] = true;
|
|
170
186
|
continue;
|
|
171
187
|
}
|
|
172
188
|
const value = inlineValue ?? args[++i];
|
|
173
|
-
if (value === undefined) throw new UsageError(`${arg}
|
|
189
|
+
if (value === undefined) throw new UsageError(`${arg}에 값이 없다`);
|
|
174
190
|
if (kind === "list") {
|
|
175
191
|
const list = /** @type {string[]} */ (options[arg] ?? []);
|
|
176
192
|
list.push(value);
|
|
@@ -183,10 +199,10 @@ function parseArgs(args) {
|
|
|
183
199
|
}
|
|
184
200
|
|
|
185
201
|
/**
|
|
186
|
-
* 서브커맨드 없이 파일이나 옵션만 주면 lint
|
|
202
|
+
* 서브커맨드 없이 파일이나 옵션만 주면 lint로 본다. 빈 인자는 여기서 다루지 않는다. `main`이 첫 화면으로 보낸다.
|
|
187
203
|
* @param {string[]} argv
|
|
188
204
|
*/
|
|
189
|
-
export /** 파일 이름이 아니라 서브커맨드를 치려던 것으로 보이나. 뜻은 파이썬 cli/main.py
|
|
205
|
+
export /** 파일 이름이 아니라 서브커맨드를 치려던 것으로 보이나. 뜻은 파이썬 cli/main.py가 소유한다. @param {string} word */
|
|
190
206
|
function looksLikeCommand(word) {
|
|
191
207
|
if (!word || !/^[A-Za-z]+$/.test(word)) return false;
|
|
192
208
|
return !existsSync(word);
|
|
@@ -197,15 +213,15 @@ function normalizeArgv(argv) {
|
|
|
197
213
|
if (COMMANDS.includes(argv[0]) || PYTHON_ONLY.includes(argv[0]) || ["-h", "--help", "--version"].includes(argv[0])) return argv;
|
|
198
214
|
if (looksLikeCommand(argv[0])) {
|
|
199
215
|
const near = nearNames(argv[0], [...COMMANDS, ...PYTHON_ONLY]);
|
|
200
|
-
const hint = near.length ? `
|
|
201
|
-
throw new Error(
|
|
216
|
+
const hint = near.length ? ` 비슷한 이름: ${near.join(", ")}.` : "";
|
|
217
|
+
throw new Error(`없는 명령: ${argv[0]}.${hint} 전체 목록은 hanlint --help`);
|
|
202
218
|
}
|
|
203
219
|
return ["lint", ...argv];
|
|
204
220
|
}
|
|
205
221
|
|
|
206
222
|
/** @param {string} value @param {string[]} choices @param {string} option */
|
|
207
223
|
function choose(value, choices, option) {
|
|
208
|
-
if (!choices.includes(value)) throw new UsageError(`${option}
|
|
224
|
+
if (!choices.includes(value)) throw new UsageError(`${option} 값으로 쓸 수 없다: ${value}. ${choices.join(", ")} 가운데 하나를 쓴다`);
|
|
209
225
|
return value;
|
|
210
226
|
}
|
|
211
227
|
|
|
@@ -216,10 +232,10 @@ function emit(text, output) {
|
|
|
216
232
|
return;
|
|
217
233
|
}
|
|
218
234
|
writeFileSync(output, text.endsWith("\n") ? text : `${text}\n`, "utf-8");
|
|
219
|
-
process.stderr.write(`${output}
|
|
235
|
+
process.stderr.write(`${output}에 썼다\n`);
|
|
220
236
|
}
|
|
221
237
|
|
|
222
|
-
/** 설정을 찾기 시작할 폴더. 첫 실제 파일의 폴더, 전부 stdin
|
|
238
|
+
/** 설정을 찾기 시작할 폴더. 첫 실제 파일의 폴더, 전부 stdin이면 현재 폴더. @param {string[]} paths */
|
|
223
239
|
function startFolder(paths) {
|
|
224
240
|
for (const path of paths) if (path !== STDIN) return dirname(resolve(path));
|
|
225
241
|
return process.cwd();
|
|
@@ -231,8 +247,8 @@ function checkDisabled(config) {
|
|
|
231
247
|
const unknown = [...config.disable].filter((rule) => !names.includes(rule)).sort();
|
|
232
248
|
if (!unknown.length) return;
|
|
233
249
|
const near = nearNames(unknown[0], names);
|
|
234
|
-
const hint = near.length ? `
|
|
235
|
-
throw new Error(
|
|
250
|
+
const hint = near.length ? ` 비슷한 이름: ${near.join(", ")}.` : "";
|
|
251
|
+
throw new Error(`없는 규칙 이름: ${unknown.join(", ")}.${hint} 목록은 hanlint rules --names`);
|
|
236
252
|
}
|
|
237
253
|
|
|
238
254
|
/** @param {Record<string, string | string[] | boolean>} options @param {string[]} paths */
|
|
@@ -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
|
}
|
|
@@ -262,7 +278,7 @@ function header(config) {
|
|
|
262
278
|
|
|
263
279
|
/** @param {Map<string, import("../rules/finding.js").Finding[]>} results */
|
|
264
280
|
function summary(results) {
|
|
265
|
-
// 글에 실제로 있는 수. 거른 뒤가 아니라 거르기 전을 센다. 뜻은 파이썬 shared.summary
|
|
281
|
+
// 글에 실제로 있는 수. 거른 뒤가 아니라 거르기 전을 센다. 뜻은 파이썬 shared.summary가 소유한다.
|
|
266
282
|
let errors = 0;
|
|
267
283
|
let notices = 0;
|
|
268
284
|
for (const findings of results.values()) {
|
|
@@ -271,16 +287,16 @@ function summary(results) {
|
|
|
271
287
|
else if (f.severity === "notice") notices += 1;
|
|
272
288
|
}
|
|
273
289
|
}
|
|
274
|
-
return `파일 ${results.size}개,
|
|
290
|
+
return `파일 ${results.size}개, 오류 ${errors}건, 경고 ${notices}건`;
|
|
275
291
|
}
|
|
276
292
|
|
|
277
|
-
/** (이름, 본문).
|
|
293
|
+
/** (이름, 본문). `-`면 stdin을 UTF-8로 읽는다. @param {string} path @param {string} stdinName @returns {[string, string]} */
|
|
278
294
|
function readInput(path, stdinName) {
|
|
279
295
|
if (path === STDIN) return [stdinName, readFileSync(0, "utf-8")];
|
|
280
296
|
return [path, readOne(path)];
|
|
281
297
|
}
|
|
282
298
|
|
|
283
|
-
/** 글 하나를 읽는다. 폴더를 주면 말로 막는다. 뜻은 파이썬 shared.readFile
|
|
299
|
+
/** 글 하나를 읽는다. 폴더를 주면 말로 막는다. 뜻은 파이썬 shared.readFile이 소유한다. @param {string} path */
|
|
284
300
|
function readOne(path) {
|
|
285
301
|
let isFolder = false;
|
|
286
302
|
try {
|
|
@@ -288,11 +304,11 @@ function readOne(path) {
|
|
|
288
304
|
} catch {
|
|
289
305
|
isFolder = false;
|
|
290
306
|
}
|
|
291
|
-
if (isFolder) throw new Error(
|
|
307
|
+
if (isFolder) throw new Error(`폴더를 받았다: ${path}. 이 명령은 글 하나를 본다. 폴더는 hanlint와 hanlint fix가 받는다`);
|
|
292
308
|
return readFileSync(path, "utf-8");
|
|
293
309
|
}
|
|
294
310
|
|
|
295
|
-
/** 폴더를 주면 그 아래 마크다운을 편다. 파일과
|
|
311
|
+
/** 폴더를 주면 그 아래 마크다운을 편다. 파일과 `-`는 그대로 둔다. @param {string[]} paths */
|
|
296
312
|
function collectFiles(paths) {
|
|
297
313
|
/** @type {string[]} */
|
|
298
314
|
const found = [];
|
|
@@ -314,7 +330,7 @@ function collectFiles(paths) {
|
|
|
314
330
|
const inside = markdownUnder(path).sort();
|
|
315
331
|
if (!inside.length)
|
|
316
332
|
throw new Error(
|
|
317
|
-
`${path} 안에 마크다운 파일이 없다. 점으로 시작하는 폴더와 node_modules
|
|
333
|
+
`${path} 안에 마크다운 파일이 없다. 점으로 시작하는 폴더와 node_modules는 건너뛴다. 그 안을 보려면 그 폴더를 직접 준다`,
|
|
318
334
|
);
|
|
319
335
|
found.push(...inside);
|
|
320
336
|
}
|
|
@@ -330,7 +346,7 @@ function commonest(findings) {
|
|
|
330
346
|
return [...counted.keys()].sort((a, b) => (counted.get(b) ?? 0) - (counted.get(a) ?? 0) || a.localeCompare(b))[0];
|
|
331
347
|
}
|
|
332
348
|
|
|
333
|
-
/** `hanlint fix
|
|
349
|
+
/** `hanlint fix`가 실제로 고칠 error 수. 고침이 달렸다는 것과 자리를 잡을 수 있다는 것은 다르다.
|
|
334
350
|
* @param {Map<string, string>} texts @param {Map<string, import("../rules/finding.js").Finding[]>} results */
|
|
335
351
|
function fixableCount(texts, results) {
|
|
336
352
|
let total = 0;
|
|
@@ -342,7 +358,7 @@ function fixableCount(texts, results) {
|
|
|
342
358
|
return total;
|
|
343
359
|
}
|
|
344
360
|
|
|
345
|
-
/** 검사 끝에 붙는 다음 행동 한 줄. 뜻은 파이썬 cli/commands/shared.py
|
|
361
|
+
/** 검사 끝에 붙는 다음 행동 한 줄. 뜻은 파이썬 cli/commands/shared.py가 소유한다.
|
|
346
362
|
* @param {Map<string, import("../rules/finding.js").Finding[]>} results @param {number} [fixable] */
|
|
347
363
|
function nextStep(results, fixable = 0) {
|
|
348
364
|
const findings = [...results.values()].flat();
|
|
@@ -350,17 +366,17 @@ function nextStep(results, fixable = 0) {
|
|
|
350
366
|
const notices = findings.length - errors.length;
|
|
351
367
|
if (errors.length) {
|
|
352
368
|
const rule = commonest(errors);
|
|
353
|
-
if (fixable) return `다음:
|
|
354
|
-
if (patternsAvoiding(rule).length) return `다음:
|
|
355
|
-
return `다음:
|
|
369
|
+
if (fixable) return `다음: 오류 ${errors.length}건 가운데 ${fixable}건은 hanlint fix가 바로 고친다. 나머지는 손으로 고친다`;
|
|
370
|
+
if (patternsAvoiding(rule).length) return `다음: 오류 ${errors.length}건을 고친다. 다시 쓸 틀은 hanlint patterns --rule ${rule}`;
|
|
371
|
+
return `다음: 오류 ${errors.length}건을 고친다. 규칙이 왜 있는지는 hanlint explain ${rule}`;
|
|
356
372
|
}
|
|
357
|
-
if (notices) return `다음:
|
|
358
|
-
return "다음:
|
|
373
|
+
if (notices) return `다음: 오류는 없다. 경고 ${notices}건을 읽고 고칠지 정한다`;
|
|
374
|
+
return "다음: 규칙으로 찾은 잘못이 없다. 좋은 글이라는 뜻은 아니니 사람이 읽어 본다";
|
|
359
375
|
}
|
|
360
376
|
|
|
361
377
|
/** @param {Baseline} baseline */
|
|
362
378
|
function lockedNote(baseline) {
|
|
363
|
-
return
|
|
379
|
+
return `baseline 파일에 든 지적 ${baseline.count}건은 보이지 않았다 (${baseline.source}). 그 문장을 고치면 다시 나온다`;
|
|
364
380
|
}
|
|
365
381
|
|
|
366
382
|
/** 옵션과 설정에서 잠금 파일을 연다. 둘 다 없으면 빈 잠금이다. @param {Record<string, string | string[] | boolean>} options @param {import("../config/settings.js").Config} config */
|
|
@@ -385,10 +401,10 @@ function runBaseline(args) {
|
|
|
385
401
|
}
|
|
386
402
|
const baseline = options["--prune"] ? pruneBaseline(loadBaseline(target), results) : buildBaseline(results, target);
|
|
387
403
|
writeFileSync(target, `${renderBaseline(baseline)}\n`, "utf-8");
|
|
388
|
-
const what = options["--prune"] ? "
|
|
404
|
+
const what = options["--prune"] ? "글에 더 없는 지적을 지웠다" : "지금 있는 지적이다";
|
|
389
405
|
process.stdout.write(
|
|
390
|
-
`${baseline.source}
|
|
391
|
-
"다음: 검사할 때 --baseline
|
|
406
|
+
`${baseline.source}에 ${baseline.count}건을 적었다. ${files.length}개 글에서 ${what}.\n` +
|
|
407
|
+
"다음: 검사할 때 --baseline을 붙이거나 설정에 baseline을 적는다. baseline 파일에 든 문장을 고치면 새 지적으로 다시 나온다\n",
|
|
392
408
|
);
|
|
393
409
|
return 0;
|
|
394
410
|
}
|
|
@@ -430,7 +446,7 @@ function runContract(args) {
|
|
|
430
446
|
}
|
|
431
447
|
const output = options["--output"];
|
|
432
448
|
if (typeof output === "string" && existsSync(output) && !options["--force"]) {
|
|
433
|
-
throw new Error(
|
|
449
|
+
throw new Error(`이미 있는 파일: ${output}. 덮어쓰려면 --force를 붙인다`);
|
|
434
450
|
}
|
|
435
451
|
const outline = options["--outline"];
|
|
436
452
|
const facts = /** @type {string[]} */ (options["--fact"] ?? []);
|
|
@@ -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));
|
|
@@ -551,18 +567,18 @@ function runFix(args) {
|
|
|
551
567
|
const lines = [];
|
|
552
568
|
for (const path of files) {
|
|
553
569
|
const text = readFileSync(path, "utf-8");
|
|
554
|
-
// notice
|
|
570
|
+
// notice는 제안이라 손으로 정한다. 확정된 error만 원문에 넣는다.
|
|
555
571
|
const errors = runAll(fingerprint(text, config, path), config).filter((f) => f.severity === "error");
|
|
556
572
|
const result = applyFixes(text, errors);
|
|
557
573
|
for (const [line, fragment, replacement] of result.applied) lines.push(`${path}:${line} ${fragment} → ${replacement}`);
|
|
558
574
|
for (const [line, fragment, reason] of result.skipped) lines.push(`${path}:${line} 건너뜀 ${fragment}: ${reason}`);
|
|
559
575
|
if (result.text !== text && !options["--dry-run"]) writeFileSync(path, result.text, "utf-8");
|
|
560
|
-
// 남은 수를 함께 말한다. `0곳 고침, 0곳
|
|
576
|
+
// 남은 수를 함께 말한다. `0곳 고침, 0곳 건너뜀`만 보면 손볼 것이 없다는 뜻으로 읽힌다
|
|
561
577
|
const left = errors.length - result.applied.length;
|
|
562
|
-
const rest = left ? `. 손으로 고칠
|
|
578
|
+
const rest = left ? `. 손으로 고칠 오류 ${left}건이 남았다 (hanlint ${path})` : "";
|
|
563
579
|
lines.push(
|
|
564
580
|
`${path} ${result.applied.length}곳 고침, ${result.skipped.length}곳 건너뜀` +
|
|
565
|
-
(options["--dry-run"] ? " (
|
|
581
|
+
(options["--dry-run"] ? " (미리보기라 파일은 그대로다)" : "") +
|
|
566
582
|
rest,
|
|
567
583
|
);
|
|
568
584
|
}
|
|
@@ -573,7 +589,7 @@ function runFix(args) {
|
|
|
573
589
|
/** @param {string[]} args */
|
|
574
590
|
function runPrint(args) {
|
|
575
591
|
const { options, positionals } = parseArgs(args);
|
|
576
|
-
if (positionals.length !== 1) throw new UsageError("마크다운 파일 하나가 필요하다.
|
|
592
|
+
if (positionals.length !== 1) throw new UsageError("마크다운 파일 하나가 필요하다. `-`는 stdin");
|
|
577
593
|
const layer = choose(/** @type {string} */ (options["--layer"] ?? "all"), LAYERS, "--layer");
|
|
578
594
|
const config = configFrom(options, positionals);
|
|
579
595
|
const [name, text] = readInput(positionals[0], /** @type {string} */ (options["--path"] ?? STDIN_NAME));
|
|
@@ -616,15 +632,15 @@ function runRules(args) {
|
|
|
616
632
|
let tail = `규칙 ${names.length}개`;
|
|
617
633
|
if (off.size) {
|
|
618
634
|
const byPreset = PRESETS[config.preset].length;
|
|
619
|
-
tail +=
|
|
635
|
+
tail += ` 가운데 ${off.size}개가 꺼져 있다 (${config.preset} 프리셋 ${byPreset}개, disable ${off.size - byPreset}개)`;
|
|
620
636
|
}
|
|
621
637
|
lines.push(`${tail}. 하나를 자세히 보려면 hanlint explain <규칙>`);
|
|
622
|
-
lines.push(
|
|
638
|
+
lines.push(`프리셋: ${PRESET_NAMES.join(", ")}. hanlint init --preset <이름>이 설정 파일에 적는다`);
|
|
623
639
|
emit(lines.join("\n"), output);
|
|
624
640
|
return 0;
|
|
625
641
|
}
|
|
626
642
|
|
|
627
|
-
/** 여러 줄짜리
|
|
643
|
+
/** 여러 줄짜리 예시를 첫 줄에만 표를 달고 나머지는 맞춰 들여쓴다. @param {string} text @param {string} label */
|
|
628
644
|
function indent(text, label) {
|
|
629
645
|
const pad = " ".repeat(label.length);
|
|
630
646
|
return text.replace(/\n+$/, "").split("\n").map((line, index) => (index === 0 ? label : pad) + line).join("\n");
|
|
@@ -635,7 +651,7 @@ const NEAR_LIMIT = 3;
|
|
|
635
651
|
/** 앞 몇 글자가 같으면 가까운 이름으로 보는가. */
|
|
636
652
|
const NEAR_PREFIX = 3;
|
|
637
653
|
|
|
638
|
-
/** 오타에 가까운 이름. 파이썬 cli/commands/explain.py
|
|
654
|
+
/** 오타에 가까운 이름. 파이썬 cli/commands/explain.py의 nearNames와 같다. @param {string} query @param {string[]} names */
|
|
639
655
|
function nearNames(query, names) {
|
|
640
656
|
const lowered = query.toLowerCase();
|
|
641
657
|
/** @type {[number, string][]} */
|
|
@@ -655,15 +671,15 @@ function runExplain(args) {
|
|
|
655
671
|
const names = ruleNames();
|
|
656
672
|
if (!positionals.length) {
|
|
657
673
|
process.stdout.write("규칙 이름 하나가 필요하다. 예: hanlint explain doublePassive\n");
|
|
658
|
-
process.stdout.write(`\n규칙 ${names.length}개를
|
|
674
|
+
process.stdout.write(`\n규칙 ${names.length}개를 카테고리별로 보려면 hanlint rules, 이름만 보려면 hanlint rules --names\n`);
|
|
659
675
|
return 2;
|
|
660
676
|
}
|
|
661
|
-
if (positionals.length !== 1) throw new UsageError("규칙 이름 하나가 필요하다. hanlint rules
|
|
677
|
+
if (positionals.length !== 1) throw new UsageError("규칙 이름 하나가 필요하다. hanlint rules로 목록을 본다");
|
|
662
678
|
const wanted = positionals[0];
|
|
663
679
|
if (!names.includes(wanted)) {
|
|
664
680
|
const near = nearNames(wanted, names);
|
|
665
|
-
const hint = near.length ? `
|
|
666
|
-
throw new Error(
|
|
681
|
+
const hint = near.length ? ` 비슷한 이름: ${near.join(", ")}` : " 목록은 hanlint rules";
|
|
682
|
+
throw new Error(`없는 규칙: ${wanted}.${hint}`);
|
|
667
683
|
}
|
|
668
684
|
const category = ruleCategory(wanted);
|
|
669
685
|
const config = configFrom(options, []);
|
|
@@ -682,18 +698,18 @@ function runExplain(args) {
|
|
|
682
698
|
return 0;
|
|
683
699
|
}
|
|
684
700
|
const mechanism = ruleMechanism(wanted);
|
|
685
|
-
process.stdout.write(`${wanted} (${CATEGORY_TITLES[category]})\n
|
|
701
|
+
process.stdout.write(`${wanted} (${CATEGORY_TITLES[category]})\n방식: ${MECHANISMS[mechanism]} (${mechanism})\n\n${ruleDoc(wanted)}\n`);
|
|
686
702
|
const exemplar = exemplarOf ? exemplarInRegister(exemplarOf, register) : undefined;
|
|
687
703
|
if (exemplar) {
|
|
688
|
-
process.stdout.write("\n
|
|
704
|
+
process.stdout.write("\n예시\n");
|
|
689
705
|
process.stdout.write(`${indent(exemplar.before, " 전 ")}\n`);
|
|
690
706
|
process.stdout.write(`${indent(exemplar.after, " 후 ")}\n`);
|
|
691
707
|
process.stdout.write(` 달라진 것: ${exemplar.moved}\n`);
|
|
692
708
|
}
|
|
693
709
|
const siblings = names.filter((name) => ruleCategory(name) === category && name !== wanted);
|
|
694
|
-
process.stdout.write(`\n같은
|
|
695
|
-
process.stdout.write(`끄려면 hanlint.toml
|
|
696
|
-
process.stdout.write(
|
|
710
|
+
process.stdout.write(`\n같은 카테고리: ${siblings.join(", ")}\n`);
|
|
711
|
+
process.stdout.write(`끄려면 hanlint.toml의 disable 목록에 넣는다: ${wanted}. 한 곳만 끄려면 <!-- hanlint-disable ${wanted} -->\n`);
|
|
712
|
+
process.stdout.write(`맞는 문장인데 잡혔다면 끄기만 하지 말고 여기에 알린다: ${ISSUES}\n`);
|
|
697
713
|
return 0;
|
|
698
714
|
}
|
|
699
715
|
|
|
@@ -703,7 +719,7 @@ function runPatterns(args) {
|
|
|
703
719
|
const rule = /** @type {string | undefined} */ (options["--rule"]);
|
|
704
720
|
const register = choose(/** @type {string} */ (options["--register"] ?? HAPNIDA), REGISTERS, "--register");
|
|
705
721
|
const chosen = (rule ? patternsAvoiding(rule) : patterns()).map((pattern) => patternInRegister(pattern, register));
|
|
706
|
-
if (!chosen.length) throw new Error(
|
|
722
|
+
if (!chosen.length) throw new Error(`을 피하는 문형이 없다. hanlint patterns로 전부 본다`);
|
|
707
723
|
const output = /** @type {string | undefined} */ (options["--output"]);
|
|
708
724
|
if (options["--format"] === "json") {
|
|
709
725
|
emit(JSON.stringify({ version: 1, patterns: chosen }, null, 2), output);
|
|
@@ -711,7 +727,7 @@ function runPatterns(args) {
|
|
|
711
727
|
}
|
|
712
728
|
const lines = [];
|
|
713
729
|
for (const pattern of chosen) {
|
|
714
|
-
lines.push(`${pattern.name} (${pattern.avoids.join(", ")}
|
|
730
|
+
lines.push(`${pattern.name} (피하는 규칙: ${pattern.avoids.join(", ")})`);
|
|
715
731
|
lines.push(` 틀 ${pattern.form}`);
|
|
716
732
|
lines.push(` 언제 ${pattern.when}`);
|
|
717
733
|
lines.push(indent(pattern.example, " 예시 "));
|
|
@@ -719,13 +735,13 @@ function runPatterns(args) {
|
|
|
719
735
|
lines.push(` 출처 ${pattern.source}`);
|
|
720
736
|
lines.push("");
|
|
721
737
|
}
|
|
722
|
-
lines.push(`문형 ${chosen.length}개.
|
|
738
|
+
lines.push(`문형 ${chosen.length}개. 예시 문장은 모두 hanlint 검사에서 오류가 없다`);
|
|
723
739
|
if (!rule) lines.push("지적을 받은 자리를 다시 쓰려면 hanlint patterns --rule <규칙>");
|
|
724
740
|
emit(lines.join("\n"), output);
|
|
725
741
|
return 0;
|
|
726
742
|
}
|
|
727
743
|
|
|
728
|
-
/** 여러 줄을 한 줄로. 줄바꿈은 ¶ 다. 파이썬 cli/commands/primer.py
|
|
744
|
+
/** 여러 줄을 한 줄로. 줄바꿈은 ¶ 다. 파이썬 cli/commands/primer.py의 flat과 같다. @param {string} text */
|
|
729
745
|
function flat(text) {
|
|
730
746
|
return text
|
|
731
747
|
.split("\n")
|
|
@@ -735,7 +751,7 @@ function flat(text) {
|
|
|
735
751
|
}
|
|
736
752
|
|
|
737
753
|
/**
|
|
738
|
-
* 설정에서 켜진 규칙마다 항목 하나.
|
|
754
|
+
* 설정에서 켜진 규칙마다 항목 하나. 묶음 순서, 그 안은 이름 순서. 파이썬 primerEntries와 같다.
|
|
739
755
|
* @param {import("../config/settings.js").Config} config @param {string} register
|
|
740
756
|
*/
|
|
741
757
|
function primerEntries(config, register) {
|
|
@@ -745,7 +761,7 @@ function primerEntries(config, register) {
|
|
|
745
761
|
for (const name of ruleNames()) {
|
|
746
762
|
if (ruleCategory(name) !== category || off.has(name)) continue;
|
|
747
763
|
const exemplar = exemplarFor(name, config.preset, config.exemplars);
|
|
748
|
-
if (!exemplar) throw new Error(
|
|
764
|
+
if (!exemplar) throw new Error(`예시가 없는 규칙: ${name}`);
|
|
749
765
|
const shown = exemplarInRegister(exemplar, register);
|
|
750
766
|
entries.push({ rule: name, category, fix: ruleFix(name), before: shown.before, after: shown.after });
|
|
751
767
|
}
|
|
@@ -753,10 +769,17 @@ function primerEntries(config, register) {
|
|
|
753
769
|
return entries;
|
|
754
770
|
}
|
|
755
771
|
|
|
756
|
-
/**
|
|
772
|
+
/** 글쓰기 지침를 절마다 한 줄로. 제목 두 칸 띄고 본문이다. 파이썬 proseLines와 같다. */
|
|
773
|
+
function proseLines() {
|
|
774
|
+
return proseSections().map(([title, body]) => `${title} ${body.split(/\s+/).filter(Boolean).join(" ")}`);
|
|
775
|
+
}
|
|
776
|
+
|
|
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);
|
|
@@ -793,7 +816,7 @@ function runSpec(args) {
|
|
|
793
816
|
if (options["--chars"] !== undefined) {
|
|
794
817
|
const raw = /** @type {string} */ (options["--chars"]);
|
|
795
818
|
targetChars = Number(raw);
|
|
796
|
-
if (!Number.isInteger(targetChars) || targetChars <= 0) throw new UsageError(`--chars
|
|
819
|
+
if (!Number.isInteger(targetChars) || targetChars <= 0) throw new UsageError(`--chars는 1 이상의 정수다: ${raw}`);
|
|
797
820
|
}
|
|
798
821
|
const result = writingSpec(config, register, targetChars);
|
|
799
822
|
const output = /** @type {string | undefined} */ (options["--output"]);
|
|
@@ -802,66 +825,117 @@ 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}에서 찾은 지적입니다.\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
|
}
|
|
855
929
|
return 0;
|
|
856
930
|
}
|
|
857
931
|
|
|
858
|
-
/** 잠근 지적이 몇 건인지. baseline
|
|
932
|
+
/** 잠근 지적이 몇 건인지. baseline이 빚을 감추는 자리가 되지 않게 늘 보인다. @param {import("../config/settings.js").Config} config */
|
|
859
933
|
function baselineState(config) {
|
|
860
|
-
if (!config.baseline) return "
|
|
934
|
+
if (!config.baseline) return "없음 (만들려면 hanlint baseline 글들/)";
|
|
861
935
|
try {
|
|
862
|
-
return `${
|
|
936
|
+
return `${config.baseline}에 지적 ${loadBaseline(config.baseline).count}건이 들어 있다. 그 문장을 고치면 다시 나온다`;
|
|
863
937
|
} catch (error) {
|
|
864
|
-
return
|
|
938
|
+
return `읽지 못했다: ${config.baseline} (${/** @type {Error} */ (error).message})`;
|
|
865
939
|
}
|
|
866
940
|
}
|
|
867
941
|
|
|
@@ -878,22 +952,22 @@ function runDoctor(args) {
|
|
|
878
952
|
`설정 ${configLabel(config)}`,
|
|
879
953
|
`프리셋 ${config.preset} (${PRESET_NAMES.join(", ")} 가운데)`,
|
|
880
954
|
`규칙 ${names.length - off.length}개 켜짐, ${off.length}개 꺼짐`,
|
|
881
|
-
|
|
882
|
-
`폴더 점으로 시작하는
|
|
955
|
+
`baseline 파일 ${baselineState(config)}`,
|
|
956
|
+
`폴더 건너뛰는 것: 점으로 시작하는 폴더, ${SKIPPED_FOLDERS.join(", ")}. 직접 주면 검사한다`,
|
|
883
957
|
];
|
|
884
958
|
if (off.length) lines.push(`꺼진 규칙 ${off.join(", ")}`);
|
|
885
|
-
lines.push("", "다음: hanlint 글.md
|
|
959
|
+
lines.push("", "다음: hanlint 글.md로 검사한다. 설정 파일이 없으면 hanlint init으로 만든다");
|
|
886
960
|
process.stdout.write(`${lines.join("\n")}\n`);
|
|
887
961
|
return 0;
|
|
888
962
|
}
|
|
889
963
|
|
|
890
|
-
/** 주석 달린 hanlint.toml. 파이썬 `hanlint init
|
|
964
|
+
/** 주석 달린 hanlint.toml. 파이썬 `hanlint init`과 같은 글자다. @param {string} [preset] */
|
|
891
965
|
export function renderInit(preset = "blog") {
|
|
892
966
|
const defaults = defaultConfig();
|
|
893
967
|
const lines = [
|
|
894
|
-
"# hanlint 설정. 규칙을 끄려면 disable
|
|
968
|
+
"# hanlint 설정. 규칙을 끄려면 disable에 이름을 넣는다. 규칙 설명은 hanlint explain <규칙>으로 본다.",
|
|
895
969
|
"",
|
|
896
|
-
"# 글의 종류. 그 종류에 안 맞는 규칙을 처음부터 끈다. disable
|
|
970
|
+
"# 글의 종류. 그 종류에 안 맞는 규칙을 처음부터 끈다. disable은 그 위에 더한다.",
|
|
897
971
|
];
|
|
898
972
|
for (const [name, off] of Object.entries(PRESETS)) lines.push(`# ${name}: ${off.length ? off.join(", ") : "전부 켠다"}`);
|
|
899
973
|
lines.push(`preset = "${preset}"`, "", "# 규칙 목록과 한 줄 설명");
|
|
@@ -907,23 +981,23 @@ export function renderInit(preset = "blog") {
|
|
|
907
981
|
"",
|
|
908
982
|
"disable = []",
|
|
909
983
|
"",
|
|
910
|
-
"# 대표 검색어를 읽을 frontmatter 필드. 없으면 keywordMissing
|
|
984
|
+
"# 대표 검색어를 읽을 frontmatter 필드. 없으면 keywordMissing은 돌지 않는다",
|
|
911
985
|
'# keywordField = "primaryKeyword"',
|
|
912
986
|
"",
|
|
913
|
-
"# 도입과 마지막 절이 담아야 하는 frontmatter 필드. 비어 있으면 fieldEcho
|
|
987
|
+
"# 도입과 마지막 절이 담아야 하는 frontmatter 필드. 비어 있으면 fieldEcho는 돌지 않는다",
|
|
914
988
|
'# introFields = ["readerQuestion"]',
|
|
915
989
|
'# endingFields = ["readerTakeaway"]',
|
|
916
990
|
"",
|
|
917
|
-
"# hanlint profile build
|
|
991
|
+
"# hanlint profile build가 만든 파일. 있으면 글 종류의 기본 프로파일 대신 이 파일과 비교한다(outsideProfile)",
|
|
918
992
|
'# profile = "profile.json"',
|
|
919
993
|
"",
|
|
920
|
-
"# hanlint baseline
|
|
994
|
+
"# hanlint baseline이 만든 baseline 파일. 있으면 그 안의 지적은 보이지 않고 새로 생긴 지적만 막는다",
|
|
921
995
|
'# baseline = ".hanlint-baseline.json"',
|
|
922
996
|
"",
|
|
923
|
-
"# 코드도 산문도 아닌 펜스의 언어
|
|
997
|
+
"# 코드도 산문도 아닌 펜스의 언어 이름. 이 펜스는 코드 블록으로 세지 않고 검사에서도 뺀다",
|
|
924
998
|
'# ignoreFences = ["course-scene", "mermaid"]',
|
|
925
999
|
"",
|
|
926
|
-
"#
|
|
1000
|
+
"# 기준값. 아래 값이 기본값이다",
|
|
927
1001
|
);
|
|
928
1002
|
for (const name of THRESHOLD_FIELDS) {
|
|
929
1003
|
const value = /** @type {number} */ (defaults[name]);
|
|
@@ -931,38 +1005,45 @@ 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 = "를 아는 것" }]',
|
|
940
1021
|
"",
|
|
941
|
-
"#
|
|
1022
|
+
"# 자동 고침이 건드리지 말아야 할 한국어 고유명사와 프로젝트 용어. 숫자와 영문 식별자는 저절로 지킨다",
|
|
942
1023
|
'# protectedTerms = ["한린트", "김민지"]',
|
|
943
|
-
'# enforceStyle = ["noQuestion", "nounPile"] #
|
|
1024
|
+
'# enforceStyle = ["noQuestion", "nounPile"] # 고른 문체 규칙만 오류로 낸다',
|
|
944
1025
|
"",
|
|
945
|
-
"# 사람이 승인한 프로젝트
|
|
1026
|
+
"# 사람이 승인한 프로젝트 예시. 같은 규칙과 프리셋의 기본 예시 대신 쓴다",
|
|
946
1027
|
"# [[exemplars]]",
|
|
947
1028
|
'# rule = "translationese"',
|
|
948
1029
|
'# before = "설계에 대한 이해가 필요합니다."',
|
|
949
1030
|
'# after = "설계를 알아야 합니다."',
|
|
950
|
-
'# moved = "명사구를 서술어로 풀어
|
|
1031
|
+
'# moved = "명사구를 서술어로 풀어 썼다"',
|
|
951
1032
|
'# presets = ["blog"]',
|
|
952
1033
|
"",
|
|
953
|
-
"# 사람이 승인한
|
|
1034
|
+
"# 사람이 승인한 그 자리만의 고침. 원문을 포함해 조건이 모두 맞을 때만 그대로 적용한다",
|
|
954
1035
|
"# [[patches]]",
|
|
955
1036
|
'# rule = "translationese"',
|
|
956
1037
|
'# before = "설계에 대한 이해가 필요합니다."',
|
|
957
1038
|
'# after = "설계를 알아야 합니다."',
|
|
958
|
-
'# moved = "명사구를 서술어로 풀어
|
|
1039
|
+
'# moved = "명사구를 서술어로 풀어 썼다"',
|
|
959
1040
|
'# sourceText = "설계에 대한 이해가 필요합니다." # before의 마크다운까지 보존한 선택용 원문',
|
|
960
1041
|
'# sentence = "설계에 대한 이해가 필요합니다." # before에서 마크다운 표식을 걷은 선택용 원문',
|
|
961
1042
|
'# cue = "에 대한"',
|
|
962
1043
|
'# reader = "new"',
|
|
963
1044
|
'# presets = ["blog"]',
|
|
964
1045
|
"",
|
|
965
|
-
"# 사람이 적용 범위까지 승인한
|
|
1046
|
+
"# 사람이 적용 범위까지 승인한 치환. 단어 경계가 맞고 숫자·링크·코드가 그대로일 때만 바꾼다",
|
|
966
1047
|
"# [[operations]]",
|
|
967
1048
|
'# before = "여러가지"',
|
|
968
1049
|
'# after = "여러 가지"',
|
|
@@ -977,15 +1058,15 @@ function runInit(args) {
|
|
|
977
1058
|
const { options } = parseArgs(args);
|
|
978
1059
|
const path = /** @type {string} */ (options["--output"] ?? "hanlint.toml");
|
|
979
1060
|
const preset = choose(/** @type {string} */ (options["--preset"] ?? "blog"), PRESET_NAMES, "--preset");
|
|
980
|
-
if (existsSync(path) && !options["--force"]) throw new Error(
|
|
1061
|
+
if (existsSync(path) && !options["--force"]) throw new Error(`이미 있는 파일: ${path}. 덮어쓰려면 --force를 붙인다`);
|
|
981
1062
|
writeFileSync(path, renderInit(preset), "utf-8");
|
|
982
1063
|
const off = PRESETS[preset];
|
|
983
|
-
const tail = off.length ?
|
|
984
|
-
process.stdout.write(
|
|
1064
|
+
const tail = off.length ? `${preset} 프리셋은 규칙 ${off.length}개를 끈다` : `${preset} 프리셋은 규칙을 전부 켠다`;
|
|
1065
|
+
process.stdout.write(`만들었다: ${path}. ${tail}. 더 끄려면 disable에 이름을 넣는다\n`);
|
|
985
1066
|
return 0;
|
|
986
1067
|
}
|
|
987
1068
|
|
|
988
|
-
/** 폴더 아래 소스 파일. 뜻은 파이썬 cli/commands/sheet.py
|
|
1069
|
+
/** 폴더 아래 소스 파일. 뜻은 파이썬 cli/commands/sheet.py의 sourceUnder가 소유한다. @param {string} folder @returns {string[]} */
|
|
989
1070
|
function sourceUnder(folder) {
|
|
990
1071
|
/** @type {string[]} */
|
|
991
1072
|
const found = [];
|
|
@@ -995,22 +1076,24 @@ 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
|
-
/** 작업 폴더 기준 상대 경로, 구분자는 `/`. 파이썬 relativeLabel
|
|
1084
|
+
/** 작업 폴더 기준 상대 경로, 구분자는 `/`. 파이썬 relativeLabel과 같은 글자를 낸다. @param {string} path */
|
|
1002
1085
|
function relativeLabel(path) {
|
|
1003
1086
|
const rel = relative(process.cwd(), resolve(path));
|
|
1004
1087
|
const label = !rel || rel.startsWith("..") || isAbsolute(rel) ? path : rel;
|
|
1005
1088
|
return label.split(sep).join("/").replaceAll("\\", "/");
|
|
1006
1089
|
}
|
|
1007
1090
|
|
|
1008
|
-
/** 표의 자리 글자. 뜻은 파이썬 SheetRow.place
|
|
1091
|
+
/** 표의 자리 글자. 뜻은 파이썬 SheetRow.place가 소유한다. @param {import("../report/sheet.js").SheetRow} row */
|
|
1009
1092
|
function placeOf(row) {
|
|
1010
1093
|
return row.column > 0 ? `${row.file}:${row.line}:${row.column}` : `${row.file}:${row.line}`;
|
|
1011
1094
|
}
|
|
1012
1095
|
|
|
1013
|
-
/** 표의 고침을 파일에 쓴다. 뜻은 파이썬 applySheet
|
|
1096
|
+
/** 표의 고침을 파일에 쓴다. 뜻은 파이썬 applySheet가 소유한다. @param {string} sheetPath @param {boolean} dryRun @returns {[string[], string[]]} */
|
|
1014
1097
|
function applySheet(sheetPath, dryRun) {
|
|
1015
1098
|
const parsed = parseSheet(readFileSync(sheetPath, "utf-8"));
|
|
1016
1099
|
/** @type {string[]} */
|
|
@@ -1028,41 +1111,111 @@ 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
|
|
|
1065
|
-
/**
|
|
1123
|
+
/** 세 자리마다 쉼표를 찍은 수. 파이썬의 `f"{n:,}"`와 같은 글자다. @param {number} value */
|
|
1124
|
+
function comma(value) {
|
|
1125
|
+
return value.toLocaleString("en-US");
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
/** 색인이 없을 때의 안내. 뜻은 파이썬 cli/commands/usage.py의 missingIndex가 소유한다. @param {string} kind @param {string} root */
|
|
1129
|
+
function missingIndex(kind, root) {
|
|
1130
|
+
const kinds = listUsageIndexes(root).map((meta) => String(meta.kind)).join(", ") || "없음";
|
|
1131
|
+
return `${kind} 색인이 없다 (있는 것: ${kinds}). hanlint usage build ${kind} <글 폴더>로 만든다 (txt, md, jsonl. 하위 폴더 포함)`;
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
/** 함께 쓰는 말 단어 수 상한. 뜻은 파이썬 QUERY_WORDS가 소유한다. */
|
|
1135
|
+
const USAGE_QUERY_WORDS = 3;
|
|
1136
|
+
|
|
1137
|
+
/** @param {[string, number][]} pairs */
|
|
1138
|
+
function pairText(pairs) {
|
|
1139
|
+
return pairs.map(([term, count]) => `${term} ${count}편`).join(", ");
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
/**
|
|
1143
|
+
* `hanlint usage "단어 …" --kind report --limit 5`, `hanlint usage build <종류> <글 폴더>`, `hanlint usage kinds`. 뜻은 파이썬
|
|
1144
|
+
* cli/commands/usage.py가 소유한다.
|
|
1145
|
+
* @param {string[]} args
|
|
1146
|
+
*/
|
|
1147
|
+
function runUsage(args) {
|
|
1148
|
+
const { options, positionals } = parseArgs(args);
|
|
1149
|
+
const output = /** @type {string | undefined} */ (options["--output"]);
|
|
1150
|
+
const root = /** @type {string} */ (options["--root"] ?? defaultUsageRoot());
|
|
1151
|
+
const format = choose(/** @type {string} */ (options["--format"] ?? "text"), ["text", "json"], "--format");
|
|
1152
|
+
if (!positionals.length) throw new UsageError('hanlint usage "단어 …" 또는 hanlint usage build <종류> <글 폴더>');
|
|
1153
|
+
if (positionals[0] === "kinds") {
|
|
1154
|
+
const found = listUsageIndexes(root);
|
|
1155
|
+
if (format === "json") {
|
|
1156
|
+
emit(JSON.stringify({ version: 1, root, kinds: found }, null, 2), output);
|
|
1157
|
+
return 0;
|
|
1158
|
+
}
|
|
1159
|
+
if (!found.length) {
|
|
1160
|
+
emit(`${root}에 색인이 없다. hanlint usage build <종류> <글 폴더>로 만든다`, output);
|
|
1161
|
+
return 0;
|
|
1162
|
+
}
|
|
1163
|
+
const lines = [`색인 ${found.length}개 (${root})`];
|
|
1164
|
+
for (const meta of found) lines.push(` ${meta.kind}: 문서 ${comma(meta.documents)}편, 문장 ${comma(meta.sentences)}개, 단어 ${comma(meta.terms)}종`);
|
|
1165
|
+
emit(lines.join("\n"), output);
|
|
1166
|
+
return 0;
|
|
1167
|
+
}
|
|
1168
|
+
if (positionals[0] === "build") {
|
|
1169
|
+
if (positionals.length !== 3) {
|
|
1170
|
+
emit("hanlint usage build <종류> <글 폴더> 꼴로 준다", output);
|
|
1171
|
+
return 2;
|
|
1172
|
+
}
|
|
1173
|
+
const [, kind, folder] = positionals;
|
|
1174
|
+
if (!existsSync(folder) || !statSync(folder).isDirectory()) {
|
|
1175
|
+
emit(`폴더가 아니다: ${folder}`, output);
|
|
1176
|
+
return 2;
|
|
1177
|
+
}
|
|
1178
|
+
const result = buildUsageIndex(kind, readUsageDocuments(folder), root);
|
|
1179
|
+
emit(`${join(root, kind)}: 문서 ${comma(result.documents)}편, 문장 ${comma(result.sentences)}개, 단어 ${comma(result.terms)}종. hanlint usage "단어" --kind ${kind}로 찾는다`, output);
|
|
1180
|
+
return 0;
|
|
1181
|
+
}
|
|
1182
|
+
const kind = /** @type {string} */ (options["--kind"] ?? USAGE_KINDS[0]);
|
|
1183
|
+
const limit = Math.max(Number(options["--limit"] ?? 5), 0);
|
|
1184
|
+
if (!Number.isInteger(limit)) throw new UsageError("--limit은 정수다");
|
|
1185
|
+
const query = positionals.join(" ");
|
|
1186
|
+
const index = loadUsageIndex(kind, root);
|
|
1187
|
+
if (!index) {
|
|
1188
|
+
emit(missingIndex(kind, root), output);
|
|
1189
|
+
return 2;
|
|
1190
|
+
}
|
|
1191
|
+
const words = usageQueryCores(query)
|
|
1192
|
+
.slice(0, USAGE_QUERY_WORDS)
|
|
1193
|
+
.map((core) => index.collocations(core))
|
|
1194
|
+
.filter((item) => item !== null && (item.predicates.length || item.following.length || item.preceding.length));
|
|
1195
|
+
const hits = index.search(query, limit);
|
|
1196
|
+
if (format === "json") {
|
|
1197
|
+
const data = { version: 2, kind, query, documents: index.documents, sentences: index.sentences, words, hits };
|
|
1198
|
+
emit(JSON.stringify(data, null, 2), output);
|
|
1199
|
+
return 0;
|
|
1200
|
+
}
|
|
1201
|
+
const lines = [`${kind} 용례 (문서 ${index.documents}편, 문장 ${index.sentences}개): ${query}`];
|
|
1202
|
+
for (const item of words) {
|
|
1203
|
+
if (!item) continue;
|
|
1204
|
+
lines.push(`함께 쓰는 말 (${item.term}, 문장 ${item.sampled}개에서)`);
|
|
1205
|
+
for (const [label, pairs] of [["뒤 용언", item.predicates], ["뒤 명사", item.following], ["앞 명사", item.preceding]]) {
|
|
1206
|
+
if (pairs.length) lines.push(` ${label}: ${pairText(/** @type {[string, number][]} */ (pairs))}`);
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
if (!hits.length && limit > 0) lines.push("쓰인 문장이 없다. 단어를 줄이거나 다른 단어로 찾는다");
|
|
1210
|
+
hits.forEach((hit, index) => {
|
|
1211
|
+
lines.push(`${index + 1}. ${hit.text}`);
|
|
1212
|
+
lines.push(` 문서 ${hit.documents}편, 출처 ${hit.source}`);
|
|
1213
|
+
});
|
|
1214
|
+
emit(lines.join("\n"), output);
|
|
1215
|
+
return 0;
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
/** `hanlint sheet 폴더/ --preset screen`과 `hanlint sheet apply 시트.md`. 뜻은 파이썬 cli/commands/sheet.py가 소유한다. @param {string[]} args */
|
|
1066
1219
|
function runSheet(args) {
|
|
1067
1220
|
const { options, positionals } = parseArgs(args);
|
|
1068
1221
|
const output = /** @type {string | undefined} */ (options["--output"]);
|
|
@@ -1083,24 +1236,14 @@ function runSheet(args) {
|
|
|
1083
1236
|
/** @type {string[]} */
|
|
1084
1237
|
const files = [];
|
|
1085
1238
|
for (const target of positionals) {
|
|
1086
|
-
if (!existsSync(target)) throw new Error(
|
|
1239
|
+
if (!existsSync(target)) throw new Error(`없는 파일: ${target}`);
|
|
1087
1240
|
if (statSync(target).isDirectory()) files.push(...sourceUnder(target));
|
|
1088
1241
|
else files.push(target);
|
|
1089
1242
|
}
|
|
1090
1243
|
const config = configFrom(options, files);
|
|
1091
1244
|
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
|
-
}
|
|
1245
|
+
// 디스크의 파일을 읽어 report.sheetRows에 넘긴다. 행의 뜻은 그쪽이 소유한다.
|
|
1246
|
+
const rows = sheetRows(files.map((file) => ({ label: relativeLabel(file), source: readFileSync(file, "utf-8") })), config, Boolean(options["--all"]));
|
|
1104
1247
|
emit(format === "json" ? renderSheetJson(rows, config.preset, files.length) : renderSheet(rows, config.preset, files.length), output);
|
|
1105
1248
|
return 0;
|
|
1106
1249
|
}
|
|
@@ -1120,8 +1263,14 @@ function dispatch(argv) {
|
|
|
1120
1263
|
process.stdout.write(`hanlint ${version}\n`);
|
|
1121
1264
|
return 0;
|
|
1122
1265
|
}
|
|
1266
|
+
if (rest.includes("--help") || rest.includes("-h")) {
|
|
1267
|
+
// npm에는 명령마다의 도움말이 없다. 도움말을 찾은 사람에게 '모르는 옵션' 대신 전체 도움말을 보인다
|
|
1268
|
+
process.stdout.write(`${USAGE}
|
|
1269
|
+
`);
|
|
1270
|
+
return 0;
|
|
1271
|
+
}
|
|
1123
1272
|
if (PYTHON_ONLY.includes(command)) {
|
|
1124
|
-
throw new Error(`${command}
|
|
1273
|
+
throw new Error(`${command} 명령은 파이썬 hanlint에만 있다(pip install hanlint). npm hanlint의 명령: ${COMMANDS.join(", ")}`);
|
|
1125
1274
|
}
|
|
1126
1275
|
if (command === "lint") return runLint(rest);
|
|
1127
1276
|
if (command === "contract") return runContract(rest);
|
|
@@ -1138,6 +1287,7 @@ function dispatch(argv) {
|
|
|
1138
1287
|
if (command === "baseline") return runBaseline(rest);
|
|
1139
1288
|
if (command === "doctor") return runDoctor(rest);
|
|
1140
1289
|
if (command === "sheet") return runSheet(rest);
|
|
1290
|
+
if (command === "usage") return runUsage(rest);
|
|
1141
1291
|
return runInit(rest);
|
|
1142
1292
|
}
|
|
1143
1293
|
|
|
@@ -1152,7 +1302,7 @@ export function main(argv) {
|
|
|
1152
1302
|
}
|
|
1153
1303
|
const failure = /** @type {NodeJS.ErrnoException} */ (error);
|
|
1154
1304
|
if (failure.code === "ENOENT") {
|
|
1155
|
-
process.stderr.write(
|
|
1305
|
+
process.stderr.write(`찾지 못한 파일: ${failure.path}. 경로를 확인하거나 hanlint --help를 본다\n`);
|
|
1156
1306
|
return 2;
|
|
1157
1307
|
}
|
|
1158
1308
|
process.stderr.write(`${failure.message}\n`);
|