hanlint 0.0.15 → 0.0.17

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.
@@ -337,6 +337,12 @@
337
337
  "after": "관리할 수임처",
338
338
  "moved": "접객 말투의 안내문을 칸의 이름으로 줄였다"
339
339
  },
340
+ {
341
+ "rule": "screenWord",
342
+ "before": "계약 메타데이터",
343
+ "after": "계약 정보",
344
+ "moved": "개발 어휘를 사용자의 낱말로 바꿨다"
345
+ },
340
346
  {
341
347
  "rule": "dash",
342
348
  "presets": [
@@ -138,7 +138,8 @@
138
138
  "rules": [
139
139
  "screenSentence",
140
140
  "screenNarration",
141
- "screenTone"
141
+ "screenTone",
142
+ "screenWord"
142
143
  ]
143
144
  }
144
145
  ]
@@ -47,6 +47,7 @@
47
47
  "screenNarration": "screen",
48
48
  "screenSentence": "screen",
49
49
  "screenTone": "screen",
50
+ "screenWord": "screen",
50
51
  "sectionNoProse": "structure",
51
52
  "spacing": "orthography",
52
53
  "spelling": "orthography",
@@ -47,6 +47,7 @@
47
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
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
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 에 넣어야 돈다.",
50
+ "screenWord": "화면의 낱말. 개발 어휘 (메타데이터, 컴포넌트, 트랜잭션) 와 프로젝트가 한 이름으로 정한 낱말의 다른 이름.\n\n왜: 화면의 글은 데이터의 이름과 사용자 동작의 낱말이다. 개발자의 말 (컴포넌트, 메타데이터) 은 사용자가 모르는\n 이름이고, 같은 것을 두 이름 (데이터와 자료, 회사와 수임처) 으로 부르면 사용자는 둘이 다른 것인지 묻는다.\n 낱말 하나가 정해지면 그 뒤로는 사람의 기억이 아니라 사전이 지킨다.\n어디서: Taxly 화면 낱말 규약 (2026-09-17, 운영자 지시). 실측: 화면 글 4,177개의 검토에서 용어 불일치 224건과\n 개발 어휘 77건이 났고 산문 규칙은 하나도 짚지 못했다. 사전은 data/screenWord.toml (어느 제품에서나 개발\n 어휘인 말) 이고 프로젝트의 정본 낱말은 설정의 dictionary.screenWord 로 더한다. 같은 자리에 기본 항목과\n 설정 항목이 함께 걸리면 설정 항목이 이긴다.\n고치기: fix 가 있는 항목은 그 낱말로 바꾼다 (`hanlint sheet` 가 고침 칸에 미리 적는다). fix 가 없는 항목은\n 자리마다 사용자의 낱말을 고른다 (메타데이터 → 계약 정보, 감사 기록).\n안 잡는 것: 사전에 없는 말. 다른 낱말의 일부 (온라인의 라인, 파이프라인). 백틱과 따옴표 안의 인용. 프리셋\n screen 밖에서는 enforceStyle 에 넣어야 돈다.",
50
51
  "sectionNoProse": "설명글 없이 코드, 표, 이미지만 있는 절.\n\n왜: 절은 제목, 시각 자료, 설명글, 그리고 설명을 보조하는 예시로 짠다. 코드와 표만 쌓인 절은 독자가\n 무엇을 보는지 모른다.\n어디서: 운영자 규칙 (섹션은 타이틀, 서브타이틀, 시각, 설명글, 예시). 글쓰기 스킬의 절의 짜임.\n고치기: 코드가 하는 일을 한 문장으로 먼저 말하고 그다음에 이름을 하나씩 푼다.\n안 잡는 것: 목록만 있는 절 (더 해 볼 것). 설명이 목록 안에 있다. 빈 절.",
51
52
  "spacing": "할수 있다, 것같다, 떠난지 3년, 한번도, 두번, 수 밖에, 뿐만아니라 처럼 자주 틀리는 띄어쓰기.\n\n왜: 의존 명사와 단위 명사는 띄고 조사는 붙인다는 규칙이 가장 자주 무너지는 자리다. 틀리면 독자가\n 낱말 경계를 다시 세운다.\n어디서: 국립국어원 한글 맞춤법 제5장 띄어쓰기 (제41항 조사, 제42항 의존 명사, 제43항 단위 명사).\n 항목마다 근거가 붙어 있다 (data/spacing.toml). 관형형 ㄹ 뒤의 의존 명사는 받침 부류 {ㄹ} 로 잡는다.\n고치기: 항목마다 띄거나 붙인 꼴을 fix 로 낸다. hanlint fix 가 적용한다.\n안 잡는 것: 뿐, 만큼, 대로 (체언 뒤 조사와 관형형 뒤 의존 명사를 표층으로 가르지 못한다). 못하다/못 하다,\n 안되다/안 되다 (뜻이 갈린다). 실수, 술수 처럼 ㄹ 받침 명사 뒤의 수. 백틱과 따옴표 안의 인용.",
52
53
  "spelling": "됬, 돼고, 않 해, 왠일, 몇일, 금새, 오랫만, 어떻해, 할께, 바램 처럼 자주 틀리는 표기.\n\n왜: 틀린 표기는 독자가 먼저 알아채고 그 순간 글보다 글쓴이를 본다. 사람 작가가 가장 자주 지적받는\n 자리이고 전부 결정적으로 잡힌다.\n어디서: 국립국어원 한글 맞춤법 (제27항 붙임 2 며칠, 제35항 붙임 2 되어→돼, 제53항 -ㄹ게, 제56항 -든지),\n 온라인가나다 답변, 표준국어대사전 표제어. 항목마다 근거가 붙어 있다 (data/spelling.toml).\n고치기: 항목마다 고친 표기를 fix 로 낸다. hanlint fix 가 적용한다.\n안 잡는 것: 뜻에 따라 둘 다 맞는 자리 (데/대, 이따가/있다가, 못하다/못 하다, 한번/한 번, 안되다/안 되다).\n 백틱과 따옴표 안의 인용. 사전에 없는 표기.",
@@ -47,6 +47,7 @@
47
47
  "screenNarration": "dictionary",
48
48
  "screenSentence": "dictionary",
49
49
  "screenTone": "dictionary",
50
+ "screenWord": "dictionary",
50
51
  "sectionNoProse": "threshold",
51
52
  "spacing": "dictionary",
52
53
  "spelling": "dictionary",
@@ -0,0 +1,219 @@
1
+ {
2
+ "entry": [
3
+ {
4
+ "pattern": "(?<![가-힣a-zA-Z])메타데이터{조사}",
5
+ "why": "는 개발 어휘다. 무엇의 정보인지 그 이름으로 쓴다 (계약 정보, 감사 기록)",
6
+ "source": "Taxly 화면 낱말 규약"
7
+ },
8
+ {
9
+ "pattern": "(?<![가-힣a-zA-Z])컴포넌트{조사}",
10
+ "why": "는 개발 어휘다. 사용자가 보는 것의 이름으로 쓴다 (차트, 카드, 표)",
11
+ "source": "Taxly 화면 낱말 규약"
12
+ },
13
+ {
14
+ "pattern": "(?<![가-힣a-zA-Z])트랜잭션{조사}",
15
+ "fix": "작업 단위",
16
+ "why": "은 개발 어휘다",
17
+ "source": "Taxly 화면 낱말 규약"
18
+ },
19
+ {
20
+ "pattern": "(?<![가-힣a-zA-Z])디렉터리{조사}",
21
+ "fix": "폴더",
22
+ "why": "는 개발 어휘다",
23
+ "source": "Taxly 화면 낱말 규약"
24
+ },
25
+ {
26
+ "pattern": "(?<![가-힣a-zA-Z])라이브러리{조사}",
27
+ "fix": "목록",
28
+ "why": "는 개발 어휘다",
29
+ "source": "Taxly 화면 낱말 규약"
30
+ },
31
+ {
32
+ "pattern": "(?<![가-힣a-zA-Z])백필{조사}",
33
+ "fix": "과거 기간 채우기",
34
+ "why": "은 개발 어휘다",
35
+ "source": "Taxly 화면 낱말 규약"
36
+ },
37
+ {
38
+ "pattern": "(?<![가-힣a-zA-Z])스트림{조사}",
39
+ "why": "은 개발 어휘다. 무엇이 흐르는지 그 이름으로 쓴다 (전체 행)",
40
+ "source": "Taxly 화면 낱말 규약"
41
+ },
42
+ {
43
+ "pattern": "(?<![가-힣a-zA-Z])축출{조사}",
44
+ "why": "은 개발 어휘다. 누가 무엇을 지우는지 쓴다 (브라우저 삭제 대상)",
45
+ "source": "Taxly 화면 낱말 규약"
46
+ },
47
+ {
48
+ "pattern": "(?<![가-힣a-zA-Z])원자적{조사}",
49
+ "fix": "한 번에",
50
+ "why": "은 개발 어휘다",
51
+ "source": "Taxly 화면 낱말 규약"
52
+ },
53
+ {
54
+ "pattern": "(?<![가-힣a-zA-Z])클립보드{조사}",
55
+ "why": "는 개발 어휘다. 복사한 것의 이름으로 쓴다 (링크 복사)",
56
+ "source": "Taxly 화면 낱말 규약"
57
+ },
58
+ {
59
+ "pattern": "(?<![가-힣a-zA-Z])릴레이{조사}",
60
+ "fix": "중계",
61
+ "why": "는 개발 어휘다",
62
+ "source": "Taxly 화면 낱말 규약"
63
+ },
64
+ {
65
+ "pattern": "(?<![가-힣a-zA-Z])릴리스{조사}",
66
+ "fix": "배포",
67
+ "why": "는 개발 어휘다",
68
+ "source": "Taxly 화면 낱말 규약"
69
+ },
70
+ {
71
+ "pattern": "(?<![가-힣a-zA-Z])매핑{조사}",
72
+ "fix": "연결",
73
+ "why": "은 개발 어휘다",
74
+ "source": "Taxly 화면 낱말 규약"
75
+ },
76
+ {
77
+ "pattern": "(?<![가-힣a-zA-Z])파싱{조사}",
78
+ "fix": "해석",
79
+ "why": "은 개발 어휘다",
80
+ "source": "개발 어휘"
81
+ },
82
+ {
83
+ "pattern": "(?<![가-힣a-zA-Z])렌더링{조사}",
84
+ "fix": "그리기",
85
+ "why": "은 개발 어휘다",
86
+ "source": "개발 어휘"
87
+ },
88
+ {
89
+ "pattern": "(?<![가-힣a-zA-Z])캐시{조사}",
90
+ "fix": "임시 저장",
91
+ "why": "는 개발 어휘다",
92
+ "source": "개발 어휘"
93
+ },
94
+ {
95
+ "pattern": "(?<![가-힣a-zA-Z])스키마{조사}",
96
+ "fix": "구조",
97
+ "why": "는 개발 어휘다",
98
+ "source": "개발 어휘"
99
+ },
100
+ {
101
+ "pattern": "(?<![가-힣a-zA-Z])페이로드{조사}",
102
+ "fix": "내용",
103
+ "why": "는 개발 어휘다",
104
+ "source": "개발 어휘"
105
+ },
106
+ {
107
+ "pattern": "(?<![가-힣a-zA-Z])큐{조사}",
108
+ "fix": "대기열",
109
+ "why": "는 개발 어휘다",
110
+ "source": "개발 어휘"
111
+ },
112
+ {
113
+ "pattern": "(?<![가-힣a-zA-Z])런타임{조사}",
114
+ "fix": "실행 환경",
115
+ "why": "은 개발 어휘다",
116
+ "source": "개발 어휘"
117
+ },
118
+ {
119
+ "pattern": "(?<![가-힣a-zA-Z])쿼리{조사}",
120
+ "fix": "조회",
121
+ "why": "는 개발 어휘다",
122
+ "source": "개발 어휘"
123
+ },
124
+ {
125
+ "pattern": "(?<![가-힣a-zA-Z])파라미터{조사}",
126
+ "fix": "항목",
127
+ "why": "는 개발 어휘다",
128
+ "source": "개발 어휘"
129
+ },
130
+ {
131
+ "pattern": "(?<![가-힣a-zA-Z])마이그레이션{조사}",
132
+ "fix": "이전",
133
+ "why": "은 개발 어휘다",
134
+ "source": "개발 어휘"
135
+ },
136
+ {
137
+ "pattern": "(?<![가-힣a-zA-Z])타임아웃{조사}",
138
+ "fix": "시간 초과",
139
+ "why": "은 개발 어휘다",
140
+ "source": "개발 어휘"
141
+ },
142
+ {
143
+ "pattern": "(?<![가-힣a-zA-Z])폴링{조사}",
144
+ "fix": "주기 확인",
145
+ "why": "은 개발 어휘다",
146
+ "source": "개발 어휘"
147
+ },
148
+ {
149
+ "pattern": "(?<![가-힣a-zA-Z])스냅샷{조사}",
150
+ "fix": "저장본",
151
+ "why": "은 개발 어휘다",
152
+ "source": "개발 어휘"
153
+ },
154
+ {
155
+ "pattern": "(?<![가-힣a-zA-Z])엔드포인트{조사}",
156
+ "fix": "주소",
157
+ "why": "는 개발 어휘다",
158
+ "source": "개발 어휘"
159
+ },
160
+ {
161
+ "pattern": "(?<![가-힣a-zA-Z])리다이렉트{조사}",
162
+ "fix": "이동",
163
+ "why": "는 개발 어휘다",
164
+ "source": "개발 어휘"
165
+ },
166
+ {
167
+ "pattern": "(?<![가-힣a-zA-Z])롤백{조사}",
168
+ "fix": "되돌리기",
169
+ "why": "은 개발 어휘다",
170
+ "source": "개발 어휘"
171
+ },
172
+ {
173
+ "pattern": "(?<![가-힣a-zA-Z])스코프{조사}",
174
+ "fix": "범위",
175
+ "why": "는 개발 어휘다",
176
+ "source": "개발 어휘"
177
+ },
178
+ {
179
+ "pattern": "(?<![가-힣a-zA-Z])뷰포트{조사}",
180
+ "fix": "화면",
181
+ "why": "는 개발 어휘다",
182
+ "source": "개발 어휘"
183
+ },
184
+ {
185
+ "pattern": "(?<![가-힣a-zA-Z])오버플로{조사}",
186
+ "fix": "넘침",
187
+ "why": "는 개발 어휘다",
188
+ "source": "개발 어휘"
189
+ },
190
+ {
191
+ "pattern": "(?<![가-힣a-zA-Z])세그먼트{조사}",
192
+ "fix": "구간",
193
+ "why": "는 개발 어휘다",
194
+ "source": "개발 어휘"
195
+ },
196
+ {
197
+ "pattern": "(?<![가-힣a-zA-Z])프로퍼티{조사}",
198
+ "fix": "속성",
199
+ "why": "는 개발 어휘다",
200
+ "source": "개발 어휘"
201
+ },
202
+ {
203
+ "pattern": "(?<![가-힣a-zA-Z])레거시{조사}",
204
+ "fix": "이전 방식",
205
+ "why": "는 개발 어휘다",
206
+ "source": "개발 어휘"
207
+ },
208
+ {
209
+ "pattern": "(?<![가-힣a-zA-Z])(?:핸들러|리스너|인스턴스|스레드|버퍼|포인터|프록시|래퍼|헬퍼|픽스처|디버그|빌드|브랜치|머지|해시|워커|플래그|이스케이프|직렬화|역직렬화){조사}",
210
+ "why": "는 개발 어휘다. 사용자에게 보이는 것의 이름으로 쓴다",
211
+ "source": "개발 어휘"
212
+ },
213
+ {
214
+ "pattern": "(?<![가-힣a-zA-Z])(?:콜백|소켓|스텁|싱글턴|리팩터링|리팩토링|디버깅|로직|커밋|체크섬|데몬|널){조사}",
215
+ "why": "은 개발 어휘다. 사용자에게 보이는 것의 이름으로 쓴다",
216
+ "source": "개발 어휘"
217
+ }
218
+ ]
219
+ }
package/data/version.json CHANGED
@@ -1 +1 @@
1
- {"version": "0.0.15"}
1
+ {"version": "0.0.17"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hanlint",
3
- "version": "0.0.15",
3
+ "version": "0.0.17",
4
4
  "description": "한국어 글 검사기. Korean prose linter for Markdown: translationese, noun piles, double passives, weak structure. Zero dependencies",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli/main.js CHANGED
@@ -933,7 +933,7 @@ export function renderInit(preset = "blog") {
933
933
  lines.push(
934
934
  "",
935
935
  "# 사전에 더할 항목. 키는 cliches, translationese, redundantPair, japaneseLoan, easyWords,",
936
- "# screenSentence, screenNarration, screenTone",
936
+ "# screenSentence, screenNarration, screenTone, screenWord",
937
937
  "# [dictionary]",
938
938
  '# cliches = ["우리의 여정"]',
939
939
  '# translationese = [{ pattern = "에 대한 이해", fix = "를 아는 것" }]',
@@ -1095,7 +1095,10 @@ function runSheet(args) {
1095
1095
  const label = relativeLabel(file);
1096
1096
  for (const literal of sourceLiterals(readFileSync(file, "utf-8"), label)) {
1097
1097
  const findings = runAll(fingerprint(literal.plain, config), config).filter((finding) => finding.severity === "error");
1098
- if (findings.length || options["--all"]) rows.push({ file: label, line: literal.line, text: literal.text, findings, fix: "", column: literal.column });
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 });
1099
1102
  }
1100
1103
  }
1101
1104
  emit(format === "json" ? renderSheetJson(rows, config.preset, files.length) : renderSheet(rows, config.preset, files.length), output);
@@ -13,7 +13,7 @@ import { projectPatches } from "../data/patches.js";
13
13
  * @type {Record<string, string[]>}
14
14
  */
15
15
  // 화면의 글에서만 켜는 것. 산문의 모든 종류가 처음부터 끈다. 파이썬 settings.py 의 SCREEN 과 같다.
16
- const SCREEN = ["screenNarration", "screenSentence", "screenTone"];
16
+ const SCREEN = ["screenNarration", "screenSentence", "screenTone", "screenWord"];
17
17
  export const PRESETS = {
18
18
  blog: [...SCREEN],
19
19
  report: ["noQuestion", "firstResultDistance", "introImage", "moreLater", "numberOrphan", ...SCREEN],
@@ -108,7 +108,7 @@ const SCREEN_OFF = [
108
108
  ];
109
109
  PRESETS.screen = SCREEN_OFF;
110
110
  /** enforceStyle 에 넣을 수 있는 규칙. 파이썬 settings.py 의 ENFORCEABLE 과 같다. */
111
- export const ENFORCEABLE = ["noQuestion", "nounPile", "screenNarration", "screenTone"];
111
+ export const ENFORCEABLE = ["noQuestion", "nounPile", "screenNarration", "screenTone", "screenWord"];
112
112
 
113
113
  /** 프리셋 → 견줄 프로파일의 종류. 정본은 파이썬 config/settings.py 의 PROFILE_OF 다. @type {Record<string, string>} */
114
114
  export const PROFILE_OF = {
@@ -9,7 +9,6 @@ export const SOURCE_SUFFIXES = [".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".
9
9
  const KOREAN = /[가-힣]/;
10
10
  // 문자열의 경계. 줄을 앞에서부터 훑어 여는 따옴표에서 같은 닫는 따옴표까지를 한 마디로 본다. 파이썬 sourceText.py 와 같다.
11
11
  const QUOTES = ["'", '"', "`"];
12
- const EXPRESSION = /\{[^{}]*\}/g;
13
12
  const BLOCK_COMMENT = /\/\*[\s\S]*?\*\//g;
14
13
  const DEVELOPER_LINE = /new Error\(|console\.|panic!\(|expect\(|assert/;
15
14
  const RUST_CONTINUATION = /\\\r?\n[ \t]*/g;
@@ -23,28 +22,53 @@ const RUST_TEST_MARKER = "#[cfg(test)]";
23
22
  * @property {number} column 1부터 세는 칸. 그 줄에서 text 가 시작하는 자리
24
23
  */
25
24
 
26
- /** 템플릿 리터럴의 `${ ... }` 식을 (중첩 괄호를 세어) 같은 길이의 빈칸으로 바꾼다. @param {string} source */
27
- export function withoutTemplateExpressions(source) {
28
- let output = "";
25
+ /** `start` 의 `{` 를 닫는 `}` 의 자리. 뜻은 파이썬 balancedEnd 가 소유한다. @param {string} line @param {number} start */
26
+ function balancedEnd(line, start) {
27
+ let depth = 0;
28
+ let index = start;
29
+ while (index < line.length) {
30
+ const char = line[index];
31
+ if (QUOTES.includes(char)) {
32
+ const end = closingQuote(line, index);
33
+ if (end < 0) return -1;
34
+ index = end + 1;
35
+ continue;
36
+ }
37
+ if (char === "{") depth += 1;
38
+ else if (char === "}") {
39
+ depth -= 1;
40
+ if (depth === 0) return index;
41
+ }
42
+ index += 1;
43
+ }
44
+ return -1;
45
+ }
46
+
47
+ /** 글에 낀 식 `{...}` 와 `${...}` 의 [시작, 끝 다음]. 뜻은 파이썬 expressionSpans 가 소유한다. @param {string} text @returns {[number, number][]} */
48
+ export function expressionSpans(text) {
49
+ /** @type {[number, number][]} */
50
+ const spans = [];
29
51
  let index = 0;
30
- while (index < source.length) {
31
- if (source[index] === "$" && source[index + 1] === "{") {
32
- let depth = 0;
33
- let cursor = index + 1;
34
- for (; cursor < source.length; cursor++) {
35
- if (source[cursor] === "{") depth += 1;
36
- else if (source[cursor] === "}") {
37
- depth -= 1;
38
- if (depth === 0) break;
39
- }
40
- }
41
- output += " ".repeat(cursor + 1 - index);
42
- index = cursor + 1;
52
+ while (index < text.length) {
53
+ if (text[index] === "{") {
54
+ const end = balancedEnd(text, index);
55
+ if (end < 0) break;
56
+ const start = index > 0 && text[index - 1] === "$" ? index - 1 : index;
57
+ spans.push([start, end + 1]);
58
+ index = end + 1;
43
59
  continue;
44
60
  }
45
- output += source[index];
46
61
  index += 1;
47
62
  }
63
+ return spans;
64
+ }
65
+
66
+ /** 템플릿 리터럴의 `${ ... }` 식을 같은 길이의 빈칸으로 바꾼다. 뜻은 파이썬 withoutTemplateExpressions 가 소유한다. @param {string} source */
67
+ export function withoutTemplateExpressions(source) {
68
+ let output = source;
69
+ for (const [start, end] of expressionSpans(source)) {
70
+ if (source[start] === "$") output = output.slice(0, start) + " ".repeat(end - start) + output.slice(end);
71
+ }
48
72
  return output;
49
73
  }
50
74
 
@@ -68,12 +92,17 @@ export function userFacingSource(source, path) {
68
92
 
69
93
  /** 검사에 쓰는 글. 식을 비우고 공백을 하나로 모은다. @param {string} text */
70
94
  export function plainText(text) {
71
- let copy = withoutTemplateExpressions(text);
72
- while (EXPRESSION.test(copy)) copy = copy.replace(EXPRESSION, " ");
73
- return copy.replace(/\s+/g, " ").trim();
95
+ let plain = "";
96
+ let cursor = 0;
97
+ for (const [start, end] of expressionSpans(text)) {
98
+ plain += text.slice(cursor, start) + " ";
99
+ cursor = end;
100
+ }
101
+ plain += text.slice(cursor);
102
+ return plain.replace(/\s+/g, " ").trim();
74
103
  }
75
104
 
76
- /** `start` 의 따옴표를 닫는 자리. 역슬래시 뒤 글자는 건너뛴다. 없으면 -1. @param {string} line @param {number} start */
105
+ /** `start` 의 따옴표를 닫는 자리. 백틱 안의 `${...}` 은 통째로 건너뛴다. 뜻은 파이썬 closingQuote 가 소유한다. @param {string} line @param {number} start */
77
106
  function closingQuote(line, start) {
78
107
  const quote = line[start];
79
108
  let index = start + 1;
@@ -83,6 +112,12 @@ function closingQuote(line, start) {
83
112
  index += 2;
84
113
  continue;
85
114
  }
115
+ if (quote === "`" && char === "$" && line[index + 1] === "{") {
116
+ const end = balancedEnd(line, index + 1);
117
+ if (end < 0) return -1;
118
+ index = end + 1;
119
+ continue;
120
+ }
86
121
  if (char === quote) return index;
87
122
  index += 1;
88
123
  }
@@ -96,8 +131,36 @@ function tagCloses(line, index) {
96
131
  return "\"'}".includes(before) || /[A-Za-z0-9]/.test(before);
97
132
  }
98
133
 
99
- /** 한 줄의 글 마디를 [시작 자리, 글] 로 나온 차례로. 뜻은 파이썬 lineLiterals 가 소유한다. @param {string} line @returns {[number, string][]} */
100
- export function lineLiterals(line) {
134
+ /** `start` 부터의 JSX 글이 끝나는 `<` 의 자리. 식 안의 `<` 는 글의 끝이 아니다. 뜻은 파이썬 jsxTextEnd 가 소유한다. @param {string} line @param {number} start */
135
+ function jsxTextEnd(line, start) {
136
+ let index = start;
137
+ while (index < line.length) {
138
+ const char = line[index];
139
+ if (char === "<") return index;
140
+ if (char === "{") {
141
+ const end = balancedEnd(line, index);
142
+ if (end < 0) return -1;
143
+ index = end + 1;
144
+ continue;
145
+ }
146
+ index += 1;
147
+ }
148
+ return -1;
149
+ }
150
+
151
+ /** 글에 낀 식 안의 글 마디. 뜻은 파이썬 innerLiterals 가 소유한다. @param {string} text @param {number} offset @returns {[number, string][]} */
152
+ function innerLiterals(text, offset) {
153
+ /** @type {[number, string][]} */
154
+ const found = [];
155
+ for (const [start, end] of expressionSpans(text)) {
156
+ const brace = text[start] === "$" ? start + 1 : start;
157
+ found.push(...lineLiterals(text.slice(brace + 1, end - 1), offset + brace + 1));
158
+ }
159
+ return found;
160
+ }
161
+
162
+ /** 한 줄의 글 마디를 [시작 자리, 글] 로 나온 차례로. 뜻은 파이썬 lineLiterals 가 소유한다. @param {string} line @param {number} [offset] @returns {[number, string][]} */
163
+ export function lineLiterals(line, offset = 0) {
101
164
  /** @type {[number, string][]} */
102
165
  const found = [];
103
166
  let index = 0;
@@ -106,23 +169,25 @@ export function lineLiterals(line) {
106
169
  if (QUOTES.includes(char)) {
107
170
  const end = closingQuote(line, index);
108
171
  if (end < 0) break;
109
- found.push([index + 1, line.slice(index + 1, end)]);
172
+ const raw = line.slice(index + 1, end);
173
+ found.push([offset + index + 1, raw]);
174
+ if (char === "`") found.push(...innerLiterals(raw, offset + index + 1));
110
175
  index = end + 1;
111
176
  continue;
112
177
  }
113
178
  if (char === ">" && tagCloses(line, index)) {
114
- const end = line.indexOf("<", index + 1);
179
+ const end = jsxTextEnd(line, index + 1);
115
180
  if (end > index) {
116
- const inner = line.slice(index + 1, end);
117
- found.push([index + 1, inner]);
118
- if (!inner.includes("{")) {
119
- index = end;
120
- continue;
121
- }
181
+ const raw = line.slice(index + 1, end);
182
+ found.push([offset + index + 1, raw]);
183
+ found.push(...innerLiterals(raw, offset + index + 1));
184
+ index = end;
185
+ continue;
122
186
  }
123
187
  }
124
188
  index += 1;
125
189
  }
190
+ found.sort((a, b) => a[0] - b[0]);
126
191
  return found;
127
192
  }
128
193
 
@@ -15,6 +15,7 @@ export const DICTIONARY_FILES = {
15
15
  screenSentence: "screenSentence.toml",
16
16
  screenNarration: "screenNarration.toml",
17
17
  screenTone: "screenTone.toml",
18
+ screenWord: "screenWord.toml",
18
19
  };
19
20
  const GROUP_REF = /\$(\d)/g;
20
21
  const FINALS = { ㄴ: 4, ㄹ: 8 };
@@ -59,6 +59,7 @@ import * as sectionNoProse from "./structure/sectionNoProse.js";
59
59
  import * as screenNarration from "./screen/screenNarration.js";
60
60
  import * as screenSentence from "./screen/screenSentence.js";
61
61
  import * as screenTone from "./screen/screenTone.js";
62
+ import * as screenWord from "./screen/screenWord.js";
62
63
 
63
64
  /**
64
65
  * @typedef {object} Rule
@@ -122,6 +123,7 @@ export const RULES = [
122
123
  screenNarration,
123
124
  screenSentence,
124
125
  screenTone,
126
+ screenWord,
125
127
  ];
126
128
 
127
129
  /**
@@ -0,0 +1,10 @@
1
+ // @ts-check
2
+ import { overridingFindings } from "../shared/dictionaryRule.js";
3
+
4
+ export const name = "screenWord";
5
+ export const mechanism = "dictionary";
6
+
7
+ /** @param {import("../../fingerprint/build.js").DocumentPrint} doc */
8
+ export function run(doc) {
9
+ return overridingFindings(doc, "screenWord", name);
10
+ }
@@ -17,29 +17,57 @@ function countNewlines(text) {
17
17
  return count;
18
18
  }
19
19
 
20
+ /**
21
+ * @param {import("../../fingerprint/build.js").SentencePrint} sentence
22
+ * @param {import("../../fingerprint/dictionaries.js").DictionaryMatch} match
23
+ * @param {string} ruleName
24
+ * @param {string} severity
25
+ */
26
+ function matchFinding(sentence, match, ruleName, severity) {
27
+ // 낱말만 갈아 끼우면 뒤에 붙은 조사가 틀어진다. `이슈로` 를 `쟁점로` 로 내밀던 자리다
28
+ const tail = sentence.text.slice(match.end);
29
+ const fix = match.fix !== null ? sentence.text.slice(0, match.start) + match.fix + fitJosa(match.fix, tail) : null;
30
+ return finding(
31
+ ruleName,
32
+ sentence.line + countNewlines(sentence.text.slice(0, match.start)),
33
+ sentence.text,
34
+ `\`${match.text}\` ${match.why} (${match.source})`,
35
+ fix,
36
+ severity,
37
+ SENTENCE,
38
+ sentence.index,
39
+ match.fix !== null ? match.text : null,
40
+ match.fix,
41
+ );
42
+ }
43
+
20
44
  export function dictionaryFindings(doc, dictionary, ruleName, severity = ERROR) {
21
45
  const findings = [];
22
46
  for (const sentence of doc.sentences) {
23
47
  for (const match of sentence.matches) {
24
- if (match.dictionary !== dictionary) continue;
25
- // 낱말만 갈아 끼우면 뒤에 붙은 조사가 틀어진다. `이슈로` 를 `쟁점로` 로 내밀던 자리다
26
- const tail = sentence.text.slice(match.end);
27
- const fix = match.fix !== null ? sentence.text.slice(0, match.start) + match.fix + fitJosa(match.fix, tail) : null;
28
- findings.push(
29
- finding(
30
- ruleName,
31
- sentence.line + countNewlines(sentence.text.slice(0, match.start)),
32
- sentence.text,
33
- `\`${match.text}\` ${match.why} (${match.source})`,
34
- fix,
35
- severity,
36
- SENTENCE,
37
- sentence.index,
38
- match.fix !== null ? match.text : null,
39
- match.fix,
40
- ),
41
- );
48
+ if (match.dictionary === dictionary) findings.push(matchFinding(sentence, match, ruleName, severity));
49
+ }
50
+ }
51
+ return findings;
52
+ }
53
+
54
+ /**
55
+ * 같은 자리에 항목이 둘이면 뒤의 것 (설정) 이 이긴다. 뜻은 파이썬 rules/shared/dictionaryRule.py 의 overridingFindings 가 소유한다.
56
+ * @param {import("../../fingerprint/build.js").DocumentPrint} doc
57
+ * @param {string} dictionary
58
+ * @param {string} ruleName
59
+ * @param {string} [severity]
60
+ * @returns {import("../finding.js").Finding[]}
61
+ */
62
+ export function overridingFindings(doc, dictionary, ruleName, severity = ERROR) {
63
+ const findings = [];
64
+ for (const sentence of doc.sentences) {
65
+ /** @type {Map<string, import("../../fingerprint/dictionaries.js").DictionaryMatch>} */
66
+ const chosen = new Map();
67
+ for (const match of sentence.matches) {
68
+ if (match.dictionary === dictionary) chosen.set(`${match.start}:${match.end}`, match);
42
69
  }
70
+ for (const match of [...chosen.values()].sort((a, b) => a.start - b.start)) findings.push(matchFinding(sentence, match, ruleName, severity));
43
71
  }
44
72
  return findings;
45
73
  }