hanlint 0.0.9 → 0.0.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/README.md +105 -31
  2. package/data/checkResult.schema.json +75 -0
  3. package/data/checkResultV2.schema.json +98 -0
  4. package/data/exemplars.json +99 -0
  5. package/data/finding.schema.json +38 -0
  6. package/data/holeKinds.json +12 -0
  7. package/data/patch.schema.json +14 -0
  8. package/data/patchResult.schema.json +86 -0
  9. package/data/patchResultV2.schema.json +71 -0
  10. package/data/profiles.json +1 -1
  11. package/data/pythonPackages.txt +3 -2
  12. package/data/readerContract.schema.json +20 -0
  13. package/data/readerContractConformanceV1.json +168 -0
  14. package/data/readerContractV2.schema.json +64 -0
  15. package/data/ruleCategories.json +3 -0
  16. package/data/ruleDocs.json +6 -3
  17. package/data/ruleMechanisms.json +3 -0
  18. package/data/screenNarration.json +29 -0
  19. package/data/screenSentence.json +34 -0
  20. package/data/screenTone.json +19 -0
  21. package/data/version.json +1 -1
  22. package/data/writingBrief.schema.json +1 -1
  23. package/data/writingBriefV2.schema.json +1 -1
  24. package/package.json +1 -1
  25. package/src/cli/help.js +34 -0
  26. package/src/cli/main.js +243 -29
  27. package/src/config/patch.js +48 -0
  28. package/src/config/readerContract.js +245 -0
  29. package/src/config/settings.js +84 -4
  30. package/src/data/load.js +2 -7
  31. package/src/data/profiles.js +14 -2
  32. package/src/data/read.js +21 -0
  33. package/src/digest.js +44 -0
  34. package/src/fingerprint/codeMarkers.js +1 -1
  35. package/src/fingerprint/dictionaries.js +3 -0
  36. package/src/guard/contract.js +174 -0
  37. package/src/guard/editLocality.js +27 -0
  38. package/src/guard/outline.js +46 -0
  39. package/src/guard/receipt.js +180 -0
  40. package/src/guard/surface.js +184 -0
  41. package/src/index.js +83 -6
  42. package/src/learn/edits.js +31 -0
  43. package/src/learn/operations.js +28 -0
  44. package/src/learn/pairs.js +57 -0
  45. package/src/report/compactReport.js +1 -1
  46. package/src/report/writingSpec.js +174 -0
  47. package/src/rules/code/inputFileSource.js +5 -3
  48. package/src/rules/code/installImport.js +6 -5
  49. package/src/rules/document/noQuestion.js +4 -4
  50. package/src/rules/readerDebt.js +9 -0
  51. package/src/rules/registry.js +34 -0
  52. package/src/rules/screen/screenNarration.js +10 -0
  53. package/src/rules/screen/screenSentence.js +10 -0
  54. package/src/rules/screen/screenTone.js +10 -0
  55. package/src/rules/sentence/nounPile.js +1 -1
  56. package/src/rules/shared/dictionaryRule.js +30 -0
@@ -36,14 +36,17 @@
36
36
  "longSentence": "어절 수가 longSentenceMax 를 넘는 문장.\n\n왜: 문장이 길면 독자가 주어와 서술어를 잇느라 되돌아간다. 짧은 문장과 긴 문장을 섞으라는 규칙의 상한이다.\n어디서: 실측. 블로그 다섯 편의 최장 문장이 23, 33, 23, 26, 45 어절이었고 30 을 넘는 둘은 읽어 보니 한 문장에\n 목록 넷을 넣은 것이었다. 임계는 config.longSentenceMax.\n고치기: 마침표로 끊는다. 나열이면 목록으로 꺼낸다.\n안 잡는 것: 임계 아래 문장. 코드와 표. 글쓰기 규칙의 위반이 아니라 신호라 notice 로 낸다.",
37
37
  "moreLater": "마지막 절의 목록 항목이 moreLaterMaxChars 를 넘어 본문만큼 설명하는 것.\n\n왜: 글 끝의 목록은 본문에서 뺀 것의 갈 곳이다. 무엇을 할 수 있는지 한 줄과 링크나 코드 한 줄이면 된다.\n 항목이 본문 문단만큼 길어지면 미룬 것이 아니라 옮겨 적은 것이고, 결말로 닫힌 글이 다시 열린다.\n어디서: cinch 의 전역 blog-writing 스킬, 무엇을 빼는가. 목록 항목이 본문만큼 설명하면 미룬 것이 아니라\n 옮겨 적은 것이다. 임계는 config.moreLaterMaxChars 이고 실측으로 정했다 (다섯 편의 마지막 절 항목\n 24개가 17~196자, 149자부터가 문장 셋 이상).\n고치기: 항목을 한 줄로 줄이고 자세한 것은 링크나 코드 한 줄로 넘긴다. 정말 본문에 있어야 하면 본문 절로 올린다.\n안 잡는 것: 마지막 절이 아닌 곳의 목록 (본문 목록은 설명의 일부다). 목록이 없는 마지막 절. 한 글에 한 번만\n 낸다. 길이는 근사라 notice 로만 낸다.",
38
38
  "negationRedefine": "단순한 X 가 아닙니다 뒤에 Y 입니다 로 다시 정의하는 두 문장 공식.\n\n왜: 부정하고 재정의하는 대구는 AI 가 즐겨 쓰는 수사다. 독자는 X 가 아니라는 말에서 정보를 얻지 못하고\n Y 만 읽으면 된다.\n어디서: AI 문체 신호 조사 (gist 패턴7 부정 후 재정의, im-not-ai C-8 대칭 대구).\n고치기: 앞 문장을 지우고 Y 를 바로 쓴다. 필요하면 Y 가 무엇을 하는지 한 문장을 더한다.\n안 잡는 것: 뒤 문장이 정의문이 아닌 경우. 단순한 도구가 아닙니다. 그래서 설치가 어렵습니다 는 잡지\n 않는다.",
39
- "noQuestion": "절이 둘 이상인데 물음표가 한 번도 없는 글.\n\n왜: 물음표가 한 번도 나오지 않는 글은 독자에게 한 번도 말을 걸지 않은 글이다. 독자가 품을 의문을\n 대신 묻고 다음 문장에서 답하는 자리가 있어야 읽힌다.\n어디서: 글쓰기 스킬의 사실과 목소리.\n고치기: 독자가 막힐 자리에서 한 번 묻는다. 그럼 파일은 어디에 생겼을까요? 다음 문장이 그 답이다. 독자를\n 부르는 말 (열어 봅니다, 확인해 보세요, 바랍니다. 목록은 data/readerCalls.txt) 도 없으면 지적문이 그것까지\n 말한다.\n안 잡는 것: 절이 하나뿐인 짧은 참고 문서. 제목의 물음표도 센다. 물음은 없는데 독자 호출은 있는 글도 잡는다\n (물음 한 번은 따로 필요하다). 독자 호출만 세던 readerAbsent 는 이 규칙의 부분집합이라 2026-08-29 본보기\n 게이트에서 드러나 여기로 접었다.",
40
- "nounPile": "명사가 조사 없이 nounPileMin 개 이상 이어진 자리.\n\n왜: 가상환경 생성 후 패키지 설치 확인 은 무엇이 무엇의 목적어인지 표시되어 있지 않다. 독자가 조사를\n 끼워 넣어야 읽힌다.\n어디서: 글쓰기 스킬의 설명을 풀어 쓰기 (명사를 쌓지 말고 동사로 되돌린다). im-not-ai A-13, F-4.\n 임계는 config.nounPileMin. 파이썬 데이터프레임 라이브러리 가 넷이라 다섯부터 짚는다 (PRD 부록 A).\n고치기: 동사로 되돌린다. 가상환경을 만든 뒤 패키지가 설치됐는지 확인한다.\n안 잡는 것: 쉼표로 나열한 고유명사 (pandas, Polars, DuckDB). 쉼표가 연속을 끊는다. 임계 아래의 복합어.",
39
+ "noQuestion": "절이 둘 이상인데 물음표가 한 번도 없는 글.\n\n왜: 물음표 부재는 관찰이지 결함의 확정이 아니다. 질문 없이 설명하는 글도 정당하므로 기본 notice다.\n 질문을 요구하는 사용자의 enforceStyle 정책에서만 error로 낸다.\n어디서: 글쓰기 스킬의 사실과 목소리.\n고치기: 설명만으로 충분하면 유지한다. 질문이 필요한 자리에서만 독자의 의문을 묻고 다음 문장으로 답한다.\n 물음표를 넣기 위해 이미 명확한 문장을 바꾸지 않는다.\n안 잡는 것: 절이 하나뿐인 짧은 참고 문서. 제목의 물음표도 센다. 물음은 없는데 독자 호출은 있는 글도 잡는다\n (질문 사용 여부를 사람이 판단한다). 독자 호출만 세던 readerAbsent 는 이 규칙의 부분집합이라 2026-08-29 본보기\n 게이트에서 드러나 여기로 접었다.",
40
+ "nounPile": "명사가 조사 없이 nounPileMin 개 이상 이어진 자리.\n\n왜: 가상환경 생성 후 패키지 설치 확인 은 무엇이 무엇의 목적어인지 표시되어 있지 않다. 독자가 조사를\n 끼워 넣어야 읽힐 수 있다. 표층 근사라 기본 notice이며 enforceStyle로 선택한 정책에서만 error다.\n어디서: 글쓰기 스킬의 설명을 풀어 쓰기 (명사를 쌓지 말고 동사로 되돌린다). im-not-ai A-13, F-4.\n 임계는 config.nounPileMin. 파이썬 데이터프레임 라이브러리 가 넷이라 다섯부터 짚는다 (PRD 부록 A).\n고치기: 동사로 되돌린다. 가상환경을 만든 뒤 패키지가 설치됐는지 확인한다.\n안 잡는 것: 쉼표로 나열한 고유명사 (pandas, Polars, DuckDB). 쉼표가 연속을 끊는다. 임계 아래의 복합어.",
41
41
  "numberOrphan": "A 에서 B 로 라고 견주는데 A 가 글 앞에 한 번도 나온 적 없는 자리.\n\n왜: 독자는 앞에서 본 값을 기준으로 변화를 읽는다. 기준값이 그 자리에서 처음 나오면 무엇에서 무엇으로\n 움직인 것인지 알 수 없고, 앞으로 되돌아가 찾다가 없다는 것을 확인하고 멈춘다.\n어디서: 실측. 블로그 004 의 `이 5백만 줄 표를 넘길 때 파이썬이 잡은 메모리 최대치는 453MB 에서 700MB 로\n 올라갔습니다` 를 평가자가 집었다. 453MB 가 앞의 어떤 실행에도 없었다. cinch 의 전역 blog-writing 스킬,\n 새로 나온 값과 옵션은 무엇인지 왜 그 값인지 독자가 지금 무엇을 하면 되는지를 그 자리에서 준다.\n 독자가 앞에서 본 수는 독자 상태 (fingerprint/readerState.py) 의 numerals 다.\n고치기: 기준값이 어디서 나온 것인지 한 문장으로 밝히거나, 그 값을 만든 실행을 앞에 둔다.\n안 잡는 것: 한 자리 수 (세는 말이지 재는 값이 아니다). 앞선 문장, 코드 블록, 출력, 표, 목록, 제목에 이미\n 나온 값. 같은 문장 안에서 앞서 나온 값. 값 바로 앞이 그 값의 출처를 밝히는 자리 (`1년 전 26.14% 에서\n 18.53% 로` 는 어디서 온 값인지 이미 말했다. 실측 오탐이었고 data/baselineAnchors.txt 로 걸렀다).\n 표층으로 세는 근사라 notice 로만 낸다.",
42
- "outsideProfile": "같은 종류의 글에서 백 문장에 하나도 안 되는 값 (문장 길이, 쉼표, 처음 나온 화제어, 헤지) 을 가진 문장.\n\n왜: 독자는 그 종류의 글에서 늘 보던 만큼을 기대한다. 편집된 글 수천 문장 가운데 1% 도 안 되는 길이나 새 화제어\n 수는 그 독자가 그 종류에서 겪지 않던 부담이다. 좋다 나쁘다가 아니라 자리가 어디인지를 말한다.\n어디서: 실측. 기준 말뭉치 390편에서 30어절을 넘는 문장이 안내서 0.16%, 기술 문서 0.87%, 뉴스 6.7%, 1930년대 수필\n 8.3% 였다. 임계 하나가 종류마다 열 배씩 다르게 잡는다 (2026-08-29). 종류별 norms 를 백분위로 보이는 것은\n Coh-Metrix 가 영어에서 한 방식이고 한국어에는 없었다. 프로파일은 data/profiles.json (scripts/buildProfiles.py\n 가 corpus/catalogue.toml 의 말뭉치에서 만든다). 백분위 임계는 config.profilePercentile. 처음 나온 화제어는 독자\n 상태 (fingerprint/readerState.py) 의 known 으로 센다.\n고치기: 문장을 나누거나 쉼표 절을 문장으로 세운다. 처음 나온 화제어가 많으면 앞 문장에서 하나씩 먼저 세운다.\n 헤지는 수치나 조건으로 바꾼다.\n안 잡는 것: 프리셋에 프로파일이 없는 종류. 99% 값이 0 인 지표 (거의 안 나오는 것은 하나만 있어도 꼬리라 짚을 것이\n 없다). 상위 1% 는 정의상 백 문장에 하나라 notice 로만 낸다. `--profile` 이\n 가리키는 사용자 프로파일이 있으면 종류 대신 그것과 견준다.",
42
+ "outsideProfile": "같은 종류의 글에서 백 문장에 하나도 안 되는 값 (문장 길이, 쉼표, 처음 나온 화제어, 헤지) 을 가진 문장.\n\n왜: 독자는 그 종류의 글에서 늘 보던 만큼을 기대한다. 편집된 글 수천 문장 가운데 1% 도 안 되는 길이나 새 화제어\n 수는 그 독자가 그 종류에서 겪지 않던 부담이다. 좋다 나쁘다가 아니라 자리가 어디인지를 말한다.\n어디서: 실측. 기준 말뭉치 390편에서 30어절을 넘는 문장이 안내서 0.16%, 기술 문서 0.87%, 뉴스 6.7%, 1930년대 수필\n 8.3% 였다. 임계 하나가 종류마다 열 배씩 다르게 잡는다 (2026-08-29). 종류별 norms 를 백분위로 보이는 것은\n Coh-Metrix 가 영어에서 한 방식이고 한국어에는 없었다. 프로파일은 data/profiles.json (scripts/derive/profiles.py\n 가 corpus/catalogue.toml 의 말뭉치에서 만든다). 백분위 임계는 config.profilePercentile. 처음 나온 화제어는 독자\n 상태 (fingerprint/readerState.py) 의 known 으로 센다.\n고치기: 문장을 나누거나 쉼표 절을 문장으로 세운다. 처음 나온 화제어가 많으면 앞 문장에서 하나씩 먼저 세운다.\n 헤지는 수치나 조건으로 바꾼다.\n안 잡는 것: 프리셋에 프로파일이 없는 종류. 99% 값이 0 인 지표 (거의 안 나오는 것은 하나만 있어도 꼬리라 짚을 것이\n 없다). 상위 1% 는 정의상 백 문장에 하나라 notice 로만 낸다. `--profile` 이\n 가리키는 사용자 프로파일이 있으면 종류 대신 그것과 견준다.",
43
43
  "paraFragment": "한두 문장짜리 산문 문단이 fragmentRun 개 이상 연달아 오는 자리.\n\n왜: 설명글에 줄바꿈을 마구 넣으면 이유로 이어야 할 문장 사이가 전부 끊긴다. 독자는 조각 사이의\n 관계를 다시 세워야 한다.\n어디서: 운영자 규칙 (설명글은 줄바꿈을 막 쳐넣지 않는다). 글쓰기 스킬의 설명을 풀어 쓰기. 임계는\n config.fragmentRun. 셈은 반복 기제 (rules/shared/repeat.py) 의 runsOf 다.\n고치기: 화제가 같으면 한 문단으로 묶는다. 줄바꿈은 화제가 바뀌는 자리에만 둔다.\n안 잡는 것: 사이에 코드 블록이나 표가 끼면 줄을 다시 센다. 코드 앞뒤에 한 문장씩 놓는 것은 조각남이\n 아니라 설명이다. 목록은 세지 않는다.",
44
44
  "platformApi": "한 운영체제에서만 도는 API 나 경로를 쓴 코드.\n\n왜: 다른 운영체제 독자는 답까지 다 구해 놓고 그 줄에서 AttributeError 로 멈춘다. 글쓴이 컴퓨터에서는 돌아서\n 글쓴이는 모른다.\n어디서: 실측. 블로그 004 의 peak_wset 이 윈도우에서만 나와 사람 평가자가 집었다. 목록은 data/platformApis.txt.\n고치기: 그 줄이 어느 운영체제 전용인지 적고 대안을 준다. 또는 이식 가능한 API 로 바꾼다.\n안 잡는 것: 목록에 없는 API. 산문 안의 언급. notice 로만 낸다.",
45
45
  "promiseRecall": "뒤에서 다루겠습니다 라고 미뤄 놓고 앞에서 미룬 것을 부르는 표지가 글 어디에도 없는 것.\n\n왜: 뒤에서 다루겠습니다 를 써 놓고 끝까지 안 나오면 정보 배분이 아니라 누락이다. 회수할 때는 미뤘다는\n 사실을 밝히고 앞에서 쓴 이름을 다시 부른다.\n어디서: 글쓰기 스킬의 무엇을 빼는가 (미룬 것은 같은 글 안에서 회수한다). 이런 검사를 하는 선행 도구가\n 없다 (조사 결과). 표지 목록은 data/promiseMarkers.txt 와 recallMarkers.txt. 약속과 회수는 독자 상태\n (fingerprint/readerState.py) 가 문장 순서로 쌓고, 글을 다 읽은 독자 (final) 에게 묻는다.\n고치기: 뒤 절에서 앞에서 미룬 X 로 부르며 다룬다. 회수하지 못할 것은 애초에 미루지 말고 지운다.\n안 잡는 것: 회수 표지가 하나라도 있는 글. 어느 약속을 어느 회수가 갚았는지는 대조하지 않는다.\n 표지 근사라 notice 로만 낸다.",
46
46
  "redundantPair": "역전 앞, 그때 당시, 매 시간마다 같은 겹말.\n\n왜: 뜻이 같은 두 말을 겹쳐 쓴 것이라 한 쪽이 군더더기다.\n어디서: 국립국어원 새국어생활 2005 한규희 겹말은 가능한 한 줄이자, 법조신문 넘쳐나는 겹말들.\n 사전은 data/redundantPair.toml.\n고치기: 하나만 남긴다. 역전 앞 은 역 앞, 그때 당시 는 그때.\n안 잡는 것: 뜻을 강조하려고 일부러 겹친 말. 사전에 없는 겹말.",
47
+ "screenNarration": "화면이 자기를 해설하는 말. 빈 자리의 예고, 이 화면은, 잠시 뒤 다시, 할 수 있습니다.\n\n왜: 빈 상태에 \"오면 여기에 표시됩니다\" 를 두면 사용자는 없는 것을 읽는다. \"이 화면은 비밀번호로 엽니다\" 는 제목이\n 이미 한 말이다. \"잠시 뒤 다시 시도해 주세요\" 는 실패를 감싸는 위로이고 다시 시도는 단추다. \"볼 수 있습니다\" 는\n 단추와 칸이 이미 보이는 능력을 글로 되풀이한다. 넷 다 정보가 아니라 해설이다.\n어디서: Taxly 화면 낱말 규약 (2026-09-17, 운영자 지시). 실측: 운영 화면 \"계약 신청이 도착하면 여기에 표시됩니다\",\n \"내려받기 페이지에서 요청이 오면 여기에 표시됩니다\", \"이 화면은 비밀번호로 엽니다. 비밀번호는 이 탭에서만 기억합니다\",\n 수집 화면 \"진행 상태를 갱신하지 못했습니다. 저장된 자료는 유지되며 잠시 뒤 다시 확인합니다\".\n 사전은 data/screenNarration.toml 이고 설정의 dictionary.screenNarration 으로 더한다.\n고치기: 빈 상태는 `<대상> 없음` (요청 없음, 계약 없음). 자기 설명과 능력 서술은 지운다. 재시도 서술은\n `<대상> <동작> 실패` 로 줄이고 다시 시도 단추를 둔다. 문장이 허용된 글에서도 같은 말은 사족이라\n enforceStyle 로 켜서 잰다.\n안 잡는 것: 사전에 없는 해설. \"이 컴퓨터\", \"이 회사\" 처럼 화면이 아닌 것을 가리키는 지시어. 프리셋 screen 밖에서는\n enforceStyle 에 넣어야 돈다.",
48
+ "screenSentence": "낱말 자리에 놓인 문장. 진행, 실패, 요구, 완료를 종결어미로 쓴 화면의 글.\n\n왜: 화면의 글은 데이터의 이름과 사용자 동작의 낱말이다. 빈 상태는 `<대상> 없음`, 진행은 `<동작> 중`, 실패는\n `<대상> <동작> 실패`, 요구는 `<대상> 필요`, 끝난 일은 상태 한 낱말 (승인됨) 이다. 문장은 제목이 이미 말한 것을\n 되풀이하거나 화면을 해설하고, 사용자는 낱말을 읽고 문장은 건너뛴다.\n어디서: Taxly 화면 낱말 규약 (2026-09-17, 운영자 지시). 실측: 운영 화면 \"요청을 완료하지 못했습니다\", 설치 페이지\n \"상태를 확인하지 못했습니다.\", 온보딩 \"대시보드를 열고 있습니다\", 진단 \"자료 갱신을 위한 로그인이 필요합니다\".\n 사전은 data/screenSentence.toml 이고 설정의 dictionary.screenSentence 로 더한다.\n고치기: 종결어미를 떼고 낱말로 만든다. 요청을 완료하지 못했습니다 는 요청 실패, 대시보드를 열고 있습니다 는\n 대시보드 여는 중, 로그인이 필요합니다 는 로그인 필요, 변경을 완료했습니다 는 변경됨. 문장이 정말 필요한 글\n (장표, 고지, 실패 원인) 은 문장이 허용된 글 모듈로 옮기고 chat 프리셋으로 잰다.\n안 잡는 것: 종결어미가 없는 글. 프리셋 screen 에서만 켜진다. 산문 프리셋은 문장이 정상이라 끈다. 문장 하나에\n 무늬가 여럿이면 가장 앞의 것 하나만 짚는다.",
49
+ "screenTone": "접객 말투. 언제든, 안심, 살펴보세요, 하시면, 보여드립니다.\n\n왜: 화면은 손님을 맞는 점원이 아니다. 안심시키는 말 (언제든 바꿀 수 있습니다) 은 정보가 없고, 초대하는 말\n (준비된 회사부터 살펴보세요) 은 단추가 할 일을 글로 대신하며, 높이는 말 (로그인하시면 보여드립니다) 은 낱말이어야\n 할 자리를 문장으로 늘린다. 사용자는 그 말을 읽지 않고 건너뛰거나, 읽고 나서 화면을 못 믿는다.\n어디서: Taxly 화면 낱말 규약 (2026-09-17, 운영자 지시). 실측: 온보딩 \"설정에서 언제든 지울 수 있습니다\",\n \"위하고에 로그인하시면 담당 수임처를 찾아 보여드립니다\", \"이렇게 부르겠습니다\", 수집 화면 \"준비된 회사부터 바로\n 살펴보세요\". 사전은 data/screenTone.toml 이고 설정의 dictionary.screenTone 으로 더한다.\n고치기: 지운다. 그 자리에 정보가 있었으면 낱말로 남긴다 (적지 않으셔도 됩니다 는 선택, 이렇게 부르겠습니다 는\n 호칭 미리보기). 문장이 허용된 글에서도 같은 말은 사족이라 enforceStyle 로 켜서 잰다.\n안 잡는 것: 사전에 없는 말투. 보고서와 장표의 권고 (받으십시오) 는 초대가 아니라 조치라 사전에 없다. 프리셋 screen\n 밖에서는 enforceStyle 에 넣어야 돈다.",
47
50
  "sectionNoProse": "설명글 없이 코드, 표, 이미지만 있는 절.\n\n왜: 절은 제목, 시각 자료, 설명글, 그리고 설명을 보조하는 예시로 짠다. 코드와 표만 쌓인 절은 독자가\n 무엇을 보는지 모른다.\n어디서: 운영자 규칙 (섹션은 타이틀, 서브타이틀, 시각, 설명글, 예시). 글쓰기 스킬의 절의 짜임.\n고치기: 코드가 하는 일을 한 문장으로 먼저 말하고 그다음에 이름을 하나씩 푼다.\n안 잡는 것: 목록만 있는 절 (더 해 볼 것). 설명이 목록 안에 있다. 빈 절.",
48
51
  "spacing": "할수 있다, 것같다, 떠난지 3년, 한번도, 두번, 수 밖에, 뿐만아니라 처럼 자주 틀리는 띄어쓰기.\n\n왜: 의존 명사와 단위 명사는 띄고 조사는 붙인다는 규칙이 가장 자주 무너지는 자리다. 틀리면 독자가\n 낱말 경계를 다시 세운다.\n어디서: 국립국어원 한글 맞춤법 제5장 띄어쓰기 (제41항 조사, 제42항 의존 명사, 제43항 단위 명사).\n 항목마다 근거가 붙어 있다 (data/spacing.toml). 관형형 ㄹ 뒤의 의존 명사는 받침 부류 {ㄹ} 로 잡는다.\n고치기: 항목마다 띄거나 붙인 꼴을 fix 로 낸다. hanlint fix 가 적용한다.\n안 잡는 것: 뿐, 만큼, 대로 (체언 뒤 조사와 관형형 뒤 의존 명사를 표층으로 가르지 못한다). 못하다/못 하다,\n 안되다/안 되다 (뜻이 갈린다). 실수, 술수 처럼 ㄹ 받침 명사 뒤의 수. 백틱과 따옴표 안의 인용.",
49
52
  "spelling": "됬, 돼고, 않 해, 왠일, 몇일, 금새, 오랫만, 어떻해, 할께, 바램 처럼 자주 틀리는 표기.\n\n왜: 틀린 표기는 독자가 먼저 알아채고 그 순간 글보다 글쓴이를 본다. 사람 작가가 가장 자주 지적받는\n 자리이고 전부 결정적으로 잡힌다.\n어디서: 국립국어원 한글 맞춤법 (제27항 붙임 2 며칠, 제35항 붙임 2 되어→돼, 제53항 -ㄹ게, 제56항 -든지),\n 온라인가나다 답변, 표준국어대사전 표제어. 항목마다 근거가 붙어 있다 (data/spelling.toml).\n고치기: 항목마다 고친 표기를 fix 로 낸다. hanlint fix 가 적용한다.\n안 잡는 것: 뜻에 따라 둘 다 맞는 자리 (데/대, 이따가/있다가, 못하다/못 하다, 한번/한 번, 안되다/안 되다).\n 백틱과 따옴표 안의 인용. 사전에 없는 표기.",
@@ -44,6 +44,9 @@
44
44
  "platformApi": "dictionary",
45
45
  "promiseRecall": "reader",
46
46
  "redundantPair": "dictionary",
47
+ "screenNarration": "dictionary",
48
+ "screenSentence": "dictionary",
49
+ "screenTone": "dictionary",
47
50
  "sectionNoProse": "threshold",
48
51
  "spacing": "dictionary",
49
52
  "spelling": "dictionary",
@@ -0,0 +1,29 @@
1
+ {
2
+ "entry": [
3
+ {
4
+ "pattern": "(?:오면|되면|도착하면|쌓이면|끝나면|생기면|들어오면)[^.]*여기(?:에|서)",
5
+ "why": "는 빈 자리를 예고하는 말이다. 빈 상태는 `<대상> 없음` 한 낱말로 쓴다 (요청 없음, 계약 없음)",
6
+ "source": "Taxly 화면 낱말 규약"
7
+ },
8
+ {
9
+ "pattern": "여기(?:에|서)[^.]*(?:표시|보입|보인|열립|열린|쌓입|쌓인|나타|나옵|나온)",
10
+ "why": "는 빈 자리를 예고하는 말이다. 빈 상태는 `<대상> 없음` 한 낱말로 쓴다 (요청 없음, 계약 없음)",
11
+ "source": "Taxly 화면 낱말 규약"
12
+ },
13
+ {
14
+ "pattern": "(?<![가-힣])(?:이|현재) (?:화면|탭|창|페이지|목록|카드|표|칸)(?:은|는|이|가|에서|에|을|를|만|으로)",
15
+ "why": "는 화면이 자기를 설명하는 말이다. 제목과 데이터가 무엇인지 말하므로 지운다",
16
+ "source": "Taxly 화면 낱말 규약"
17
+ },
18
+ {
19
+ "pattern": "(?:잠시 뒤|잠시 후|나중에 다시|잠시만 기다)",
20
+ "why": "는 재시도를 서술하는 말이다. 실패는 `<대상> <동작> 실패` 로 쓰고 다시 시도는 단추다",
21
+ "source": "Taxly 화면 낱말 규약"
22
+ },
23
+ {
24
+ "pattern": "(?:할|볼|쓸|열|받을|고를|바꿀|지울|더할|뺄|열람할|확인할|계산할|이어갈|사용할) 수 (?:있습니다|없습니다|있다|없다|있어요|없어요)",
25
+ "why": "는 할 수 있는 일을 서술하는 말이다. 할 수 있는 것은 단추와 칸이 보이고 할 수 없는 것은 `<동작> 불가` 로 쓴다",
26
+ "source": "Taxly 화면 낱말 규약"
27
+ }
28
+ ]
29
+ }
@@ -0,0 +1,34 @@
1
+ {
2
+ "entry": [
3
+ {
4
+ "pattern": "(?:하고|보고|가고|오고|열고|만들고|찾고|기다리고|계산하고|확인하고|연결하고|정리하고|읽고|쓰고) (?:있습니다|있다|있어요)",
5
+ "why": "는 진행을 문장으로 쓴 것이다. `<동작> 중` 한 낱말로 쓴다 (대시보드 여는 중, 시작 준비 확인 중)",
6
+ "source": "Taxly 화면 낱말 규약"
7
+ },
8
+ {
9
+ "pattern": "(?:하지|되지|찾지|열지|만들지|가지|오지|읽지|쓰지|받지) (?:못했습니다|않았습니다|못합니다|못했다|않았다|못한다|못했어요|않았어요|못해요)",
10
+ "why": "는 실패를 문장으로 쓴 것이다. `<대상> <동작> 실패` 로 쓴다 (요청 실패, 자료 불러오기 실패)",
11
+ "source": "Taxly 화면 낱말 규약"
12
+ },
13
+ {
14
+ "pattern": "(?:필요합니다|필요하다|필요해요|해야 합니다|해야 한다|해야 해요|하셔야 합니다)",
15
+ "why": "는 요구를 문장으로 쓴 것이다. `<대상> 필요` 로 쓴다 (로그인 필요, 사전 승인 IP 필요)",
16
+ "source": "Taxly 화면 낱말 규약"
17
+ },
18
+ {
19
+ "pattern": "(?:입력해 주세요|입력하세요|입력하십시오|선택해 주세요|선택하세요|체크해 주세요|눌러 주세요|누르세요)",
20
+ "why": "는 칸의 이름표가 할 말이다. 이름표가 무엇을 넣는지 말하고 빠진 값은 `<대상> 필요` 로 쓴다",
21
+ "source": "Taxly 화면 낱말 규약"
22
+ },
23
+ {
24
+ "pattern": "(?:했습니다|됐습니다|되었습니다|끝났습니다|했다|됐다|되었다|끝났다|했어요|됐어요|되었어요|끝났어요)",
25
+ "why": "는 끝난 일을 문장으로 쓴 것이다. 상태 한 낱말로 쓴다 (승인됨, 변경됨, 완료)",
26
+ "source": "Taxly 화면 낱말 규약"
27
+ },
28
+ {
29
+ "pattern": "(?:습니다|입니다|합니다|됩니다|세요|십시오|해요|돼요|예요|이에요|있어요|없어요)|(?:한다|된다|있다|없다|이다|아니다|았다|었다|였다|겠다|하다|되다|같다|다르다|않다|는다|온다|간다|본다|낸다|준다|든다)(?=\\s*[.!]?\\s*$)|[가-힣]다\\.|까\\?",
30
+ "why": "는 종결어미다. 낱말 자리에 문장을 두지 않는다. 문장이 정말 필요한 글 (장표, 고지, 실패 원인) 은 문장이 허용된 글 모듈로 옮기고 chat 프리셋으로 잰다",
31
+ "source": "Taxly 화면 낱말 규약"
32
+ }
33
+ ]
34
+ }
@@ -0,0 +1,19 @@
1
+ {
2
+ "entry": [
3
+ {
4
+ "pattern": "(?:언제든|안심하|안심해|안심시|걱정 없이|걱정하지|편하게|간편하게|자유롭게|바로 살펴|그대로 두셔도)",
5
+ "why": "는 안심시키는 말이다. 정보가 아니므로 지운다",
6
+ "source": "Taxly 화면 낱말 규약"
7
+ },
8
+ {
9
+ "pattern": "(?:살펴보세요|살펴보십시오|살펴봐요|해 보세요|해 보십시오|해 봐요|확인해 보세요|시작해 보세요|이어가세요)",
10
+ "why": "는 사용자를 초대하는 말이다. 다음 동작은 단추다",
11
+ "source": "Taxly 화면 낱말 규약"
12
+ },
13
+ {
14
+ "pattern": "(?:하시면|하셔도|하실|하신|해 주시면|주시고|드립니다|드리겠습니다|드린다|드려요|보여드립니다|보여 드립니다|부르겠습니다|부르겠다|부를게요)",
15
+ "why": "는 접객 말투다. 화면의 글은 낱말이므로 지운다",
16
+ "source": "Taxly 화면 낱말 규약"
17
+ }
18
+ ]
19
+ }
package/data/version.json CHANGED
@@ -1 +1 @@
1
- {"version": "0.0.9"}
1
+ {"version": "0.0.12"}
@@ -18,7 +18,7 @@
18
18
  "properties": {
19
19
  "version": { "const": 1 },
20
20
  "preset": {
21
- "enum": ["blog", "report", "docs", "guide", "essay", "fiction", "encyclopedia"]
21
+ "enum": ["blog", "report", "docs", "guide", "essay", "fiction", "encyclopedia", "chat", "screen"]
22
22
  },
23
23
  "reader": { "type": "string", "minLength": 1 },
24
24
  "task": { "type": "string", "minLength": 1 },
@@ -19,7 +19,7 @@
19
19
  "properties": {
20
20
  "version": { "const": 2 },
21
21
  "preset": {
22
- "enum": ["blog", "report", "docs", "guide", "essay", "fiction", "encyclopedia"]
22
+ "enum": ["blog", "report", "docs", "guide", "essay", "fiction", "encyclopedia", "chat", "screen"]
23
23
  },
24
24
  "reader": { "type": "string", "minLength": 1 },
25
25
  "task": { "type": "string", "minLength": 1 },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hanlint",
3
- "version": "0.0.9",
3
+ "version": "0.0.12",
4
4
  "description": "한국어 글 검사기. Korean prose linter for Markdown: translationese, noun piles, double passives, weak structure. Zero dependencies",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,34 @@
1
+ // @ts-check
2
+ /** 첫 화면 다음에 보이는 작업 흐름 중심 도움말. */
3
+
4
+ export function rootHelp() {
5
+ return `hanlint: 한국어 마크다운에서 반복되는 결함과 계약 위반을 결정적으로 찾는다
6
+
7
+ 일상 검사
8
+ hanlint 글.md 글을 검사한다
9
+ hanlint fix 글.md 안전한 고침만 적용한다
10
+ hanlint hook AI가 저장한 마크다운을 같은 턴에 검사한다
11
+
12
+ 요구사항 잠금
13
+ hanlint contract init 글.md --reader "독자" --goal "목표" --outline h2
14
+ hanlint check contract.json 글.md --format text
15
+ hanlint verify-patch contract.json 글.md patch.json
16
+
17
+ 설정과 이해
18
+ hanlint init 설정 파일을 만든다
19
+ hanlint doctor 적용 설정과 꺼진 규칙을 본다
20
+ hanlint rules 규칙을 부류별로 본다
21
+ hanlint explain <규칙> 규칙의 이유와 본보기를 본다
22
+ hanlint patterns --rule <규칙> 다시 쓸 문장 틀을 본다
23
+ hanlint spec --preset <종류> --chars 800
24
+ 쓰기 전 숫자 사양을 만든다
25
+ hanlint primer --preset <종류> 쓰기 전에 규칙의 고치는 법과 본보기를 한 장으로 본다
26
+
27
+ 추가 명령
28
+ baseline, print
29
+ Python 전용: audit, map, watch, profile, coverage, diff, learn, packet,
30
+ guard, arena, blueprint, evidence, entailment, terms
31
+
32
+ 자세한 옵션은 hanlint <명령> --help 로 본다. 폴더를 주면 그 아래 마크다운을 검사한다.
33
+ 종료 코드: 0 error 없음, 1 error 있음, 2 입력이나 설정 문제. 좋은 글인지는 판정하지 않는다.`;
34
+ }
package/src/cli/main.js CHANGED
@@ -10,35 +10,54 @@
10
10
  * hanlint rules 규칙 목록을 부류로 묶어서
11
11
  * hanlint explain <규칙> 규칙의 기술서
12
12
  * hanlint patterns 문장 틀. --rule 로 그 규칙을 피하는 것만
13
+ * hanlint primer --preset <종류> 쓰기 전에 읽는 한 장. 켜진 규칙의 고치는 법과 본보기 전후
14
+ * hanlint hook Claude Code가 쓴 마크다운을 같은 턴에 비차단 검사
13
15
  * hanlint baseline 글들/ 지금 있는 지적을 잠근다. 그다음부터 새것만 막힌다
14
16
  * hanlint baseline 글들/ 지금 있는 지적을 잠근다. 그다음부터 새것만 막힌다
15
17
  * hanlint doctor 설정과 꺼진 규칙
16
18
  * hanlint init 주석 달린 hanlint.toml. --output 과 --preset blog|report|docs
19
+ * hanlint contract init 글.md 원문에서 최소 Reader Contract 초안
17
20
  * ```
18
21
  * audit, guard, arena, blueprint, evidence, entailment 같은 확장 명령은 파이썬 패키지에 있다.
19
22
  */
20
23
  import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
21
- import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
24
+ import { dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
22
25
 
23
- import { fingerprint, lintText, ruleDoc, ruleNames, ruleSummary, version } from "../index.js";
26
+ import {
27
+ Patch,
28
+ check,
29
+ contractFromText,
30
+ contractFromTextV2,
31
+ fingerprint,
32
+ lintText,
33
+ parseContract,
34
+ renderCheck,
35
+ ruleDoc,
36
+ ruleNames,
37
+ ruleSummary,
38
+ verifyPatch,
39
+ version,
40
+ } from "../index.js";
24
41
  import { Baseline, build as buildBaseline, DEFAULT_NAME as DEFAULT_BASELINE, load as loadBaseline, prune as pruneBaseline, render as renderBaseline } from "../baseline/store.js";
25
42
  import { loadConfig } from "../config/loadConfig.js";
26
- import { DEFAULT_PRESET, defaultConfig, offRules, PRESET_NAMES, PRESETS } from "../config/settings.js";
43
+ import { defaultConfig, offRules, PRESET_NAMES, PRESETS } from "../config/settings.js";
27
44
  import { applyFixes } from "../edit/applyFixes.js";
28
45
  import { exemplarFor } from "../data/exemplars.js";
29
46
  import { patterns, patternsAvoiding } from "../data/patterns.js";
30
47
  import { HAPNIDA, REGISTERS } from "../analysis/grammar/index.js";
31
- import { CATEGORY_TITLES, MECHANISMS, ruleCategory, ruleMechanism, runAll } from "../rules/registry.js";
48
+ import { CATEGORY_TITLES, MECHANISMS, ruleCategory, ruleFix, ruleMechanism, runAll } from "../rules/registry.js";
32
49
  import { MARKDOWN, SKIPPED_FOLDERS, markdownUnder } from "./walk.js";
33
50
  import { welcome } from "./welcome.js";
51
+ import { rootHelp } from "./help.js";
34
52
  import { renderCompact } from "../report/compactReport.js";
35
53
  import { LAYERS, renderFingerprintJson } from "../report/fingerprintJson.js";
36
54
  import { renderGithub } from "../report/githubReport.js";
37
55
  import { renderJson } from "../report/jsonReport.js";
38
56
  import { renderText } from "../report/textReport.js";
57
+ import { renderWritingSpec, writingSpec } from "../report/writingSpec.js";
39
58
  import { exemplarInRegister, patternInRegister } from "../report/registerMatch.js";
40
59
 
41
- const COMMANDS = ["lint", "fix", "print", "rules", "explain", "patterns", "baseline", "doctor", "init"];
60
+ const COMMANDS = ["lint", "fix", "print", "rules", "explain", "patterns", "primer", "spec", "hook", "baseline", "doctor", "init", "contract", "check", "verify-patch"];
42
61
  const PYTHON_ONLY = [
43
62
  "audit",
44
63
  "map",
@@ -82,23 +101,7 @@ const THRESHOLD_FIELDS = [
82
101
  ];
83
102
  const FLOAT_FIELDS = new Set(["headingUniformRatio", "factListMaxMeanLength"]);
84
103
 
85
- const USAGE = `사용법: hanlint 글.md [다른.md ...] [--format text|compact|json|github] [--severity all|error|notice] [--errors-only]
86
- [--config 파일] [--preset blog|report|docs] [--disable 규칙] [--baseline [파일]] [--output 파일] [--quiet]
87
- [--path 이름 (stdin 의 이름)]
88
- hanlint fix 글.md [--dry-run]
89
- hanlint print 글.md [--layer all|sentences|paragraphs|sections|document]
90
- hanlint rules [--names]
91
- hanlint explain <규칙>
92
- hanlint patterns [--rule 규칙]
93
- hanlint baseline 글들/ [--prune] [--output 파일]
94
- hanlint doctor
95
- hanlint init [--output hanlint.toml] [--preset blog|report|docs] [--force]
96
- hanlint --version
97
-
98
- 파일 자리에 폴더를 주면 그 아래 마크다운을 전부 검사한다 (점으로 시작하는 폴더와 node_modules 는 건너뛴다).
99
- 인자가 없으면 첫 화면이 나온다.
100
- 한국어 글에서 반복되는 결함을 결정적으로 잡는다. 종료 코드는 0 (지적 없음), 1 (error 지적), 2 (파일이나 설정 문제).
101
- ${PYTHON_ONLY.join(", ")} 는 파이썬 패키지 (pip install hanlint) 에 있다.`;
104
+ const USAGE = rootHelp();
102
105
 
103
106
  /** @type {Record<string, "value" | "list" | "flag">} */
104
107
  const OPTION_KINDS = {
@@ -116,9 +119,15 @@ const OPTION_KINDS = {
116
119
  "--dry-run": "flag",
117
120
  "--names": "flag",
118
121
  "--force": "flag",
122
+ "--reply": "flag",
123
+ "--reader": "value",
124
+ "--goal": "value",
125
+ "--outline": "value",
126
+ "--fact": "list",
119
127
  "--preset": "value",
120
128
  "--rule": "value",
121
129
  "--register": "value",
130
+ "--chars": "value",
122
131
  "--baseline": "optional",
123
132
  "--prune": "flag",
124
133
  };
@@ -224,8 +233,8 @@ function checkDisabled(config) {
224
233
  }
225
234
 
226
235
  /** @param {Record<string, string | string[] | boolean>} options @param {string[]} paths */
227
- function configFrom(options, paths) {
228
- const config = loadConfig(/** @type {string | undefined} */ (options["--config"]) ?? null, startFolder(paths));
236
+ function configFrom(options, paths, start = null) {
237
+ const config = loadConfig(/** @type {string | undefined} */ (options["--config"]) ?? null, start ?? startFolder(paths));
229
238
  if (options["--preset"]) config.preset = choose(/** @type {string} */ (options["--preset"]), PRESET_NAMES, "--preset");
230
239
  for (const rule of /** @type {string[]} */ (options["--disable"] ?? [])) config.disable.add(rule);
231
240
  if (options["--profile"]) config.profile = /** @type {string} */ (options["--profile"]);
@@ -242,11 +251,10 @@ function configLabel(config) {
242
251
  return label.split(sep).join("/");
243
252
  }
244
253
 
245
- /** 설정 출처와 지금 도는 프리셋. 기본 프리셋이면 이름을 빼서 줄이 길어지지 않게 한다.
246
- * @param {import("../config/settings.js").Config} config */
254
+ /** 설정 출처와 지금 도는 프리셋. @param {import("../config/settings.js").Config} config */
247
255
  function header(config) {
248
256
  const where = configLabel(config);
249
- return config.preset === DEFAULT_PRESET ? `설정: ${where}` : `설정: ${where}, 프리셋 ${config.preset}`;
257
+ return `설정: ${where}, 프리셋 ${config.preset}`;
250
258
  }
251
259
 
252
260
  /** @param {Map<string, import("../rules/finding.js").Finding[]>} results */
@@ -382,6 +390,73 @@ function runBaseline(args) {
382
390
  return 0;
383
391
  }
384
392
 
393
+ /** @param {string} path */
394
+ function readJson(path) {
395
+ try {
396
+ return JSON.parse(readFileSync(path, "utf-8"));
397
+ } catch (error) {
398
+ if (error instanceof SyntaxError) throw new Error(`${path} JSON을 읽지 못했다: ${error.message}`);
399
+ throw error;
400
+ }
401
+ }
402
+
403
+ /** @param {string[]} args */
404
+ function runCheck(args) {
405
+ const { options, positionals } = parseArgs(args);
406
+ if (positionals.length !== 2) throw new UsageError("Reader Contract JSON과 검사할 마크다운 파일이 필요하다");
407
+ const format = choose(/** @type {string} */ (options["--format"] ?? "json"), ["json", "text"], "--format");
408
+ const [contractPath, path] = positionals;
409
+ const contract = parseContract(readJson(contractPath));
410
+ const config = configFrom(options, [path]);
411
+ const result = check(readOne(path), contract, config, path);
412
+ const rendered = format === "text" ? renderCheck(result) : JSON.stringify(result.asDict(), null, 2);
413
+ emit(rendered, /** @type {string | undefined} */ (options["--output"]));
414
+ return result.violationCount === 0 ? 0 : 1;
415
+ }
416
+
417
+ /** @param {string[]} args */
418
+ function runContract(args) {
419
+ const { options, positionals } = parseArgs(args);
420
+ if (positionals.length !== 2 || positionals[0] !== "init") {
421
+ throw new UsageError("contract init과 계약 초안을 만들 마크다운 파일이 필요하다");
422
+ }
423
+ const reader = options["--reader"];
424
+ const goal = options["--goal"];
425
+ if (typeof reader !== "string" || typeof goal !== "string") {
426
+ throw new UsageError("contract init에는 --reader와 --goal이 필요하다");
427
+ }
428
+ const output = options["--output"];
429
+ if (typeof output === "string" && existsSync(output) && !options["--force"]) {
430
+ throw new Error(`${output} 가 이미 있다. 덮어쓰려면 --force`);
431
+ }
432
+ const outline = options["--outline"];
433
+ const facts = /** @type {string[]} */ (options["--fact"] ?? []);
434
+ let contract;
435
+ if (outline !== undefined) {
436
+ const selected = choose(/** @type {string} */ (outline), ["h1", "h2", "h3", "h4", "h5", "h6"], "--outline");
437
+ contract = contractFromTextV2(readOne(positionals[1]), reader, goal, Number(selected.slice(1)), facts);
438
+ } else {
439
+ if (facts.length) throw new UsageError("--fact는 --outline과 함께 version 2 계약에서 쓴다");
440
+ contract = contractFromText(readOne(positionals[1]), reader, goal);
441
+ }
442
+ emit(JSON.stringify(contract.asDict(), null, 2), typeof output === "string" ? output : undefined);
443
+ return 0;
444
+ }
445
+
446
+ /** @param {string[]} args */
447
+ function runVerifyPatch(args) {
448
+ const { options, positionals } = parseArgs(args);
449
+ if (positionals.length !== 3) throw new UsageError("Reader Contract JSON, 수정 전 마크다운, Patch JSON이 필요하다");
450
+ choose(/** @type {string} */ (options["--format"] ?? "json"), ["json"], "--format");
451
+ const [contractPath, path, patchPath] = positionals;
452
+ const contract = parseContract(readJson(contractPath));
453
+ const patch = Patch.fromMapping(readJson(patchPath));
454
+ const config = configFrom(options, [path]);
455
+ const result = verifyPatch(readOne(path), patch, contract, config, path);
456
+ emit(JSON.stringify(result.asDict(), null, 2), /** @type {string | undefined} */ (options["--output"]));
457
+ return result.verified ? 0 : 1;
458
+ }
459
+
385
460
  /** @param {string[]} args */
386
461
  function runLint(args) {
387
462
  const { options, positionals } = parseArgs(args);
@@ -514,7 +589,8 @@ function runRules(args) {
514
589
  }
515
590
  const config = configFrom(options, []);
516
591
  const off = new Set(offRules(config));
517
- if (options["--format"] === "json") {
592
+ const format = choose(/** @type {string} */ (options["--format"] ?? "text"), ["text", "json"], "--format");
593
+ if (format === "json") {
518
594
  const rules = names.map((name) => {
519
595
  const exemplar = exemplarFor(name, config.preset, config.exemplars);
520
596
  /** @type {Record<string, unknown>} */
@@ -646,6 +722,136 @@ function runPatterns(args) {
646
722
  return 0;
647
723
  }
648
724
 
725
+ /** 여러 줄을 한 줄로. 줄바꿈은 ¶ 다. 파이썬 cli/commands/primer.py 의 flat 과 같다. @param {string} text */
726
+ function flat(text) {
727
+ return text
728
+ .split("\n")
729
+ .map((line) => line.trim().split(/\s+/).filter(Boolean).join(" "))
730
+ .filter(Boolean)
731
+ .join(" ¶ ");
732
+ }
733
+
734
+ /**
735
+ * 설정에서 켜진 규칙마다 항목 하나. 부류 순서, 그 안은 이름 순서. 파이썬 primerEntries 와 같다.
736
+ * @param {import("../config/settings.js").Config} config @param {string} register
737
+ */
738
+ function primerEntries(config, register) {
739
+ const off = new Set(offRules(config));
740
+ const entries = [];
741
+ for (const category of Object.keys(CATEGORY_TITLES)) {
742
+ for (const name of ruleNames()) {
743
+ if (ruleCategory(name) !== category || off.has(name)) continue;
744
+ const exemplar = exemplarFor(name, config.preset, config.exemplars);
745
+ if (!exemplar) throw new Error(`본보기가 없는 규칙: ${name}`);
746
+ const shown = exemplarInRegister(exemplar, register);
747
+ entries.push({ rule: name, category, fix: ruleFix(name), before: shown.before, after: shown.after });
748
+ }
749
+ }
750
+ return entries;
751
+ }
752
+
753
+ /** 사람과 AI 가 읽는 한 장. 파이썬 renderPrimer 와 글자 단위로 같다. @param {ReturnType<typeof primerEntries>} entries @param {string} preset @param {string} register */
754
+ function renderPrimer(entries, preset, register) {
755
+ const lines = [
756
+ `hanlint primer ${preset} 종류, ${register}체, 규칙 ${entries.length}개. 줄마다 규칙 이름, 고치는 법, 전 (그 규칙에 잡히는 글), 후 (같은 뜻으로 통과하는 글) 순서다. 본보기의 줄바꿈은 ¶ 다`,
757
+ ];
758
+ for (const [category, title] of Object.entries(CATEGORY_TITLES)) {
759
+ const inside = entries.filter((entry) => entry.category === category);
760
+ if (!inside.length) continue;
761
+ lines.push("", `${title} (${inside.length})`);
762
+ for (const entry of inside) lines.push(`${entry.rule} ${entry.fix} 전: ${flat(entry.before)} 후: ${flat(entry.after)}`);
763
+ }
764
+ lines.push("", "후는 전부 hanlint 를 error 0 으로 통과한다 (게이트가 확인한다). 규칙 하나를 깊게 보려면 hanlint explain <규칙>");
765
+ return lines.join("\n");
766
+ }
767
+
768
+ /** @param {string[]} args */
769
+ function runPrimer(args) {
770
+ const { options } = parseArgs(args);
771
+ const config = configFrom(options, []);
772
+ const register = choose(/** @type {string} */ (options["--register"] ?? HAPNIDA), REGISTERS, "--register");
773
+ const entries = primerEntries(config, register);
774
+ const output = /** @type {string | undefined} */ (options["--output"]);
775
+ const format = choose(/** @type {string} */ (options["--format"] ?? "text"), ["text", "json"], "--format");
776
+ if (format === "json") {
777
+ emit(JSON.stringify({ version: 1, preset: config.preset, register, rules: entries }, null, 2), output);
778
+ return 0;
779
+ }
780
+ emit(renderPrimer(entries, config.preset, register), output);
781
+ return 0;
782
+ }
783
+
784
+ /** @param {string[]} args */
785
+ function runSpec(args) {
786
+ const { options } = parseArgs(args);
787
+ const config = configFrom(options, []);
788
+ const register = choose(/** @type {string} */ (options["--register"] ?? HAPNIDA), REGISTERS, "--register");
789
+ let targetChars = null;
790
+ if (options["--chars"] !== undefined) {
791
+ const raw = /** @type {string} */ (options["--chars"]);
792
+ targetChars = Number(raw);
793
+ if (!Number.isInteger(targetChars) || targetChars <= 0) throw new UsageError(`--chars 는 1 이상의 정수다: ${raw}`);
794
+ }
795
+ const result = writingSpec(config, register, targetChars);
796
+ const output = /** @type {string | undefined} */ (options["--output"]);
797
+ const format = choose(/** @type {string} */ (options["--format"] ?? "text"), ["text", "json"], "--format");
798
+ emit(format === "json" ? JSON.stringify(result, null, 2) : renderWritingSpec(result), output);
799
+ return 0;
800
+ }
801
+
802
+ /** Claude Code가 다음 모델 요청에 넣는 비차단 Finding 문맥. */
803
+ function hookContext(eventName, name, findings) {
804
+ const where = eventName === "Stop" ? "마지막 답변" : "방금 쓴 마크다운";
805
+ return JSON.stringify({
806
+ hookSpecificOutput: {
807
+ hookEventName: eventName,
808
+ additionalContext: `hanlint가 ${where}에서 센 Finding입니다.\n${renderCompact(name, findings)}`,
809
+ },
810
+ });
811
+ }
812
+
813
+ /** 명령 훅 JSON을 stdin에서 읽어 저장 파일이나 마지막 답변을 검사한다. 언제나 종료 코드 0이다. @param {string[]} args */
814
+ function runHook(args) {
815
+ try {
816
+ const { options, positionals } = parseArgs(args);
817
+ if (positionals.length) return 0;
818
+ const payload = JSON.parse(readFileSync(0, "utf-8"));
819
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) return 0;
820
+ let eventName;
821
+ let name;
822
+ let findings;
823
+ if (options["--reply"]) {
824
+ if (payload.hook_event_name !== "Stop" || payload.stop_hook_active === true) return 0;
825
+ const text = payload.last_assistant_message;
826
+ if (typeof text !== "string" || !text.trim()) return 0;
827
+ const start = typeof payload.cwd === "string" && payload.cwd ? payload.cwd : process.cwd();
828
+ const config = configFrom(options, [], start);
829
+ config.preset = "chat";
830
+ eventName = "Stop";
831
+ name = "<assistant>";
832
+ findings = lintText(text, config, name);
833
+ } else {
834
+ if (payload.hook_event_name !== "PostToolUse" || !["Write", "Edit"].includes(payload.tool_name)) return 0;
835
+ const rawPath = payload.tool_input?.file_path;
836
+ if (typeof rawPath !== "string" || !rawPath || !MARKDOWN.includes(extname(rawPath).toLowerCase())) return 0;
837
+ try {
838
+ if (!statSync(rawPath).isFile()) return 0;
839
+ } catch {
840
+ return 0;
841
+ }
842
+ const config = configFrom(options, [rawPath]);
843
+ eventName = "PostToolUse";
844
+ name = rawPath;
845
+ findings = lintText(readFileSync(rawPath, "utf-8"), config, name);
846
+ if (config.baseline) findings = loadBaseline(config.baseline).keep(name, findings);
847
+ }
848
+ if (findings.length) process.stdout.write(`${hookContext(eventName, name, findings)}\n`);
849
+ } catch {
850
+ return 0;
851
+ }
852
+ return 0;
853
+ }
854
+
649
855
  /** 잠근 지적이 몇 건인지. baseline 이 빚을 감추는 자리가 되지 않게 늘 보인다. @param {import("../config/settings.js").Config} config */
650
856
  function baselineState(config) {
651
857
  if (!config.baseline) return "없다 (hanlint baseline 글들/ 로 지금 지적을 잠근다)";
@@ -723,13 +929,15 @@ export function renderInit(preset = "blog") {
723
929
  }
724
930
  lines.push(
725
931
  "",
726
- "# 사전에 더할 항목. 키는 cliches, translationese, redundantPair, japaneseLoan",
932
+ "# 사전에 더할 항목. 키는 cliches, translationese, redundantPair, japaneseLoan, easyWords,",
933
+ "# screenSentence, screenNarration, screenTone",
727
934
  "# [dictionary]",
728
935
  '# cliches = ["우리의 여정"]',
729
936
  '# translationese = [{ pattern = "에 대한 이해", fix = "를 아는 것" }]',
730
937
  "",
731
938
  "# 표면 치환이 바꾸면 안 되는 한국어 고유명사와 프로젝트 용어. 수치와 라틴 식별자는 자동으로 보호한다",
732
939
  '# protectedTerms = ["한린트", "김민지"]',
940
+ '# enforceStyle = ["noQuestion", "nounPile"] # 선택한 문체 신호만 error로 강제한다',
733
941
  "",
734
942
  "# 사람이 승인한 프로젝트 본보기. 같은 규칙과 프리셋의 내장 본보기를 덮어쓴다",
735
943
  "# [[exemplars]]",
@@ -793,11 +1001,17 @@ function dispatch(argv) {
793
1001
  throw new Error(`${command} 는 파이썬 패키지에 있다 (pip install hanlint). npm 은 ${COMMANDS.join(", ")} 을 제공한다`);
794
1002
  }
795
1003
  if (command === "lint") return runLint(rest);
1004
+ if (command === "contract") return runContract(rest);
1005
+ if (command === "check") return runCheck(rest);
1006
+ if (command === "verify-patch") return runVerifyPatch(rest);
796
1007
  if (command === "fix") return runFix(rest);
797
1008
  if (command === "print") return runPrint(rest);
798
1009
  if (command === "rules") return runRules(rest);
799
1010
  if (command === "explain") return runExplain(rest);
800
1011
  if (command === "patterns") return runPatterns(rest);
1012
+ if (command === "primer") return runPrimer(rest);
1013
+ if (command === "spec") return runSpec(rest);
1014
+ if (command === "hook") return runHook(rest);
801
1015
  if (command === "baseline") return runBaseline(rest);
802
1016
  if (command === "doctor") return runDoctor(rest);
803
1017
  return runInit(rest);