@tienne/gestalt 0.85.0 → 0.86.0

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.
@@ -0,0 +1,680 @@
1
+ /**
2
+ * 룰북 룰끼리 겹치는 자리를 찾는다.
3
+ *
4
+ * 룰북 PR 리뷰에서 "룰 두 개가 겹치면 어느 쪽이 이기나"가 반복해서 나왔다
5
+ * (docs/experiments/2026-10-rule-overlap-check.md). 그 질문을 리뷰어가 찾기 전에 꺼낸다.
6
+ *
7
+ * 두 층으로 본다.
8
+ * - 탐지기 행렬: 룰마다 금지 예시, 처방 예시, 예외 예시를 뽑아 전 룰 탐지기에 돌린다.
9
+ * 결과가 룰북 문장과 어긋나면 결함이다.
10
+ * - 겹침 후보: 탐지기로는 판정이 안 되는 겹침을 문장 단위로 추려 리뷰어에게 질문으로 넘긴다.
11
+ * 이건 판정이 아니라 질문이라 게이트에 걸지 않는다.
12
+ *
13
+ * 탐지기는 주입받는다. 과거 커밋을 재현할 때 그 커밋의 탐지기로 돌려야 해서다.
14
+ * 평소에는 `RULE_DETECTORS`를 넘긴다.
15
+ */
16
+ import { detect, DETECTABLE_RULE_IDS } from './detectors.js';
17
+ import { citedRuleIds } from './rules.js';
18
+ const ANY_RULE_ID = /(?<![A-Za-z0-9-])([A-Z]{1,3}-\d{1,2})(?![0-9])/g;
19
+ /**
20
+ * 문장이 부르는 룰 ID. `citedRuleIds`는 ai-tell 접두어(A~J)만 읽어서 comment-rules의
21
+ * CM-, truncation-rules의 TR-를 못 본다. 범위 표기는 그쪽에 맡기고 나머지를 더한다.
22
+ */
23
+ function ruleIdsIn(text) {
24
+ const ids = new Set(citedRuleIds(text));
25
+ for (const m of text.matchAll(ANY_RULE_ID))
26
+ ids.add(m[1]);
27
+ return [...ids];
28
+ }
29
+ /** 이 레포 탐지기. 과거 커밋을 재현할 때는 그 커밋의 탐지기로 같은 꼴을 만들어 넘긴다 */
30
+ export const RULE_DETECTORS = {
31
+ ids: DETECTABLE_RULE_IDS,
32
+ run: (text) => detect(text).map((d) => ({ ruleId: d.ruleId, samples: d.samples })),
33
+ };
34
+ /** 룰 표를 가진 문서만 룰북으로 본다. 표 머리가 "| ID | 패턴 |"이다 */
35
+ export function hasRuleTable(markdown) {
36
+ return /^\|\s*ID\s*\|\s*패턴\s*\|/m.test(markdown);
37
+ }
38
+ const RULE_ROW = /^\|\s*([A-Z]{1,3}-\d{1,2})\s*\|(.+)$/;
39
+ const ESCAPED_PIPE = '\u0000pipe\u0000';
40
+ const QUOTE = /"([^"\n]{1,60})"/g;
41
+ /**
42
+ * 따옴표 하나가 예외 쪽에 놓였는지 보는 표지. "예외가 아니다", "예외로 안 본다"처럼
43
+ * 뒤집는 꼴은 빼야 한다 — 그 문장은 오히려 대상을 늘린다.
44
+ */
45
+ const EXCEPTION = /예외(?!가\s*아니|로\s*(?:안|보지)|가\s*되지|가\s*안|를\s*두지)|대상이\s*아니|대상에서\s*빠|비대상|에서만\s*(?:쓴다|남긴다|둔다)|그대로\s*(?:둔다|두지만)|빠진다|(?:안|못)\s*걸린다|Do-NOT/;
46
+ /**
47
+ * 예외를 정의하는 문장. 따옴표 표지보다 좁다 — "예외가 전부 문맥이라 탐지기가 없다",
48
+ * "서식으로만 예외를 주면"처럼 예외를 말하기만 하는 문장까지 세면 예외 개수가 부풀어
49
+ * 순서 후보가 룰마다 뜬다.
50
+ */
51
+ const EXCEPTION_DEF = /예외는|예외다|예외이다|대상이\s*아니다|에서만\s*(?:쓴다|남긴다|둔다)|그대로\s*둔다/;
52
+ /**
53
+ * 탐지기가 형태로 거른다고 적은 문장. 여기 놓인 예외 예시를 자기 탐지기가 잡으면 룰북과
54
+ * 탐지기가 같은 사실을 반대로 적은 것이다. 뜻으로 가르는 예외("문을 잠갔다")는 탐지기가
55
+ * 걸고 사람이 다시 보는 게 정상이라 여기에 안 든다.
56
+ */
57
+ const FORM_EXEMPT = /형태로\s*(?:빠지|빠진|빠져)|(?:안|못)\s*걸린다|자연히\s*안/;
58
+ /** 두 룰 사이에 경계를 긋는 말. 그냥 부르는 자리("F-10의 탐지기처럼")는 겹침 질문거리가 아니다 */
59
+ const BOUNDARY = /갈린다|갈리고|구별|경계/;
60
+ /**
61
+ * 두 룰이 겹칠 때 어느 쪽으로 판정할지 정한 말.
62
+ * "F-10이 본다"처럼 넘기는 꼴도 순서를 정한 것이다.
63
+ */
64
+ const ORDER = /하나로\s*판정|하나로\s*본다|앞선다|먼저\s*(?:본다|판정|적용)|우선(?:한다|순위)|겹치면|최종형|함께\s*걸린다|도\s*걸린다|[이가]\s*본다|에\s*걸린다|안\s*겹친다|로\s*넘긴다/;
65
+ /** 처방을 내놓는 마디. 이 마디 안의 따옴표는 고친 꼴이다 */
66
+ const AFTER_CLAUSE = /(?:으로|로)\s*(?:쓴다|고친다|바꾼다|옮긴다|되돌린다|돌린다|환원|대체|대신)|환원한다|되돌린다|풀기|동사로|이렇게/;
67
+ /** 대상이라고 정해 말하는 마디. 이 마디 안의 따옴표는 금지 예시다 */
68
+ const TARGET_CLAUSE = /대상이(?:다|고|며|라)|(?<!안\s|못\s)걸린다|걸리고|금지|쓰지\s*않|가리키지\s*않|하지\s*않는다|지\s*말고|딱지|피해다|예외가\s*아니|예외로\s*안|예외가\s*되지/;
69
+ /**
70
+ * 따옴표 바로 뒤 서술어. 마디보다 먼저 본다 — ""산출물"은 빠지고 "계측기"는 걸린다"처럼
71
+ * 한 마디 안에서 따옴표마다 판정이 갈리는 자리가 있다.
72
+ */
73
+ const EXEMPT_PREDICATE = /^\s*(?:은|는|도|만|이|가)?\s*(?:예외|대상이\s*아니|대상에서|빠지|빠진|그대로\s*(?:둔|쓴)|통과)/;
74
+ const TARGET_PREDICATE = /^\s*(?:은|는|이|가|도|을|를)?\s*(?:(?<!안\s|못\s)걸린|걸리(?!지\s*않)|대상이(?:다|고|며)|같은\s*자리|딱지)|^\s*처럼\s.*(?:도망가지|쓰지\s*않|지\s*말)/;
75
+ const AFTER_PREDICATE = /^\s*(?:으로|로)\s*(?:쓴다|고친다|바꾼다|옮긴다|되돌린다|돌린다|환원|대체|대신|풀)/;
76
+ const NOT_BUT = /^\s*(?:가|이)\s*아니라\s*$/;
77
+ function splitCells(row) {
78
+ return row
79
+ .replace(/\\\|/g, ESCAPED_PIPE)
80
+ .split('|')
81
+ .map((cell) => cell.replaceAll(ESCAPED_PIPE, '|').trim());
82
+ }
83
+ /** 문장 끝 마침표로 자른다. 괄호 안 마침표나 소수점은 안 자른다 */
84
+ export function sentencesOf(text) {
85
+ return text
86
+ .split(/(?<=[다요음임함)"*`])\.(?=\s|$)/)
87
+ .map((s) => s.trim())
88
+ .filter(Boolean);
89
+ }
90
+ /**
91
+ * 따옴표 안이 예시가 아니라 표기법이면 뺀다 — "~에 대해(서)", "~를 통해/통하여" 같은 꼴이다.
92
+ * 대문자 한 글자는 아무 명사 자리라 탐지기가 볼 수 있게 한글 한 글자로 바꾼다 — 탐지기
93
+ * 상당수가 앞 글자가 한글인지를 본다("A에 대해 논의"는 A-1 탐지기에 안 걸린다).
94
+ * 빗금은 예시 여럿을 나열한 표기라 나눈다. 가운뎃점은 C-12 예시 자체일 수 있어 여기서
95
+ * 안 나누고 탐지할 때 나눈다 (variants).
96
+ */
97
+ function normalizeExample(raw) {
98
+ if (raw.includes('~') || /^[-–]/.test(raw))
99
+ return [];
100
+ const filled = raw.replace(/(?<![A-Za-z])[A-Z](?![A-Za-z])/g, '가');
101
+ return filled
102
+ .split('/')
103
+ .map((s) => s.trim())
104
+ .filter((s) => /[가-힣]/.test(s));
105
+ }
106
+ function quotesIn(sentence) {
107
+ return [...sentence.matchAll(QUOTE)].map((m) => ({
108
+ text: m[1],
109
+ start: m.index,
110
+ end: m.index + m[0].length,
111
+ }));
112
+ }
113
+ /**
114
+ * 문장을 마디로 자른다. 한 문장이 "~면 대상이고(…), ~면 예외다(…)"처럼 반대 판정을
115
+ * 한꺼번에 들고 있어서 문장 단위로는 따옴표의 종류를 못 정한다.
116
+ */
117
+ function clauseBounds(sentence) {
118
+ const bounds = [];
119
+ const splitter = /\s—\s|(?<=\)),\s|;\s|(?<=[다고며])[,]\s/g;
120
+ let start = 0;
121
+ for (const m of sentence.matchAll(splitter)) {
122
+ bounds.push([start, m.index]);
123
+ start = m.index + m[0].length;
124
+ }
125
+ bounds.push([start, sentence.length]);
126
+ return bounds;
127
+ }
128
+ /**
129
+ * 문장 하나에서 예시를 뽑아 종류를 붙인다.
130
+ *
131
+ * 마디 표지가 먼저고 문장 표지가 그다음이다. 표지가 아무것도 없는 따옴표는 예시가
132
+ * 아니라 언급이다("F-8은 물리 "측량" 동사만 보므로") — 그걸 금지 예시로 세면 행렬이
133
+ * 소음으로 찬다. 패턴 칸은 칸 전체가 금지라 기본값이 금지다.
134
+ */
135
+ function classify(ruleId, sentence, line, fallback) {
136
+ const quotes = quotesIn(sentence);
137
+ const exceptional = EXCEPTION.test(sentence);
138
+ const clauses = clauseBounds(sentence);
139
+ const kinds = quotes.map((quote, index) => {
140
+ const [from, to] = clauses.find(([a, b]) => quote.start >= a && quote.start < b) ?? [
141
+ 0,
142
+ sentence.length,
143
+ ];
144
+ const clause = sentence.slice(from, to);
145
+ const lead = sentence.slice(from, quote.start);
146
+ // 패턴 칸의 화살표는 원어에서 금지어로 간다 (`canonical → "정본"`)
147
+ if (/→\s*\**\s*$/.test(lead))
148
+ return fallback === 'before' ? 'before' : 'after';
149
+ if (/^\s*→/.test(sentence.slice(quote.end)))
150
+ return 'before';
151
+ // 나열된 따옴표("A", "B", "C"는 빠지고)는 마지막 따옴표 뒤 서술어를 함께 쓴다
152
+ let last = index;
153
+ while (quotes[last + 1] &&
154
+ /^[\s,]*(?:이나|나|와|과|랑)?[\s,]*$/.test(sentence.slice(quotes[last].end, quotes[last + 1].start))) {
155
+ last += 1;
156
+ }
157
+ const nextStart = quotes[last + 1]?.start ?? to;
158
+ const predicate = sentence.slice(quotes[last].end, Math.min(nextStart, to, quotes[last].end + 30));
159
+ if (EXEMPT_PREDICATE.test(predicate))
160
+ return 'exempt';
161
+ if (TARGET_PREDICATE.test(predicate))
162
+ return 'before';
163
+ if (AFTER_PREDICATE.test(predicate))
164
+ return 'after';
165
+ if (EXCEPTION.test(clause))
166
+ return 'exempt';
167
+ if (TARGET_CLAUSE.test(clause))
168
+ return 'before';
169
+ if (AFTER_CLAUSE.test(clause))
170
+ return 'after';
171
+ if (exceptional)
172
+ return 'exempt';
173
+ return fallback;
174
+ });
175
+ quotes.forEach((quote, index) => {
176
+ const next = quotes[index + 1];
177
+ if (next && NOT_BUT.test(sentence.slice(quote.end, next.start))) {
178
+ kinds[index] = 'before';
179
+ kinds[index + 1] = 'after';
180
+ }
181
+ });
182
+ const examples = [];
183
+ quotes.forEach((quote, index) => {
184
+ const kind = kinds[index];
185
+ if (kind === 'mention')
186
+ return;
187
+ for (const text of normalizeExample(quote.text)) {
188
+ examples.push({ ruleId, kind, text, sentence, line });
189
+ }
190
+ });
191
+ return examples;
192
+ }
193
+ /** 헤딩이 룰 하나만 부르면 그 아래 문단은 그 룰 몫이다 */
194
+ function headingIds(text) {
195
+ const ids = ruleIdsIn(text);
196
+ return ids.length === 1 ? ids : [];
197
+ }
198
+ /** 문단 첫 볼드 구절에 룰 ID가 있으면 그 문단은 그 룰 몫이다 ("(F-9 오적용 주의)") */
199
+ function leadIds(text) {
200
+ const lead = text.match(/^\*\*([^*]+)\*\*/);
201
+ return lead ? ruleIdsIn(lead[1]) : [];
202
+ }
203
+ /**
204
+ * 룰북 하나를 룰 단위로 읽는다.
205
+ *
206
+ * 룰은 표의 행에만 있지 않다. 행이 길어지면 상세를 표 아래 문단으로 빼서, 겹침 사례의
207
+ * 절반이 그 문단에서 나왔다(ca3a2dbb의 '원본' 경계). 그래서 룰 하나만 부르는 헤딩 아래
208
+ * 문단과 볼드 도입구가 룰을 부르는 문단을 그 룰에 붙인다.
209
+ */
210
+ export function parseRuleEntries(markdown, file) {
211
+ const entries = new Map();
212
+ const lines = markdown.split('\n');
213
+ let heading = { level: 0, ids: [] };
214
+ let inFence = false;
215
+ /** 대체어 표처럼 "쓰지 말 것 | 이렇게" 칸을 가진 표의 칸 위치 */
216
+ let pairColumns = null;
217
+ const entryOf = (id, line) => {
218
+ let entry = entries.get(id);
219
+ if (!entry) {
220
+ entry = { id, file, line, sentences: [], examples: [] };
221
+ entries.set(id, entry);
222
+ }
223
+ return entry;
224
+ };
225
+ /** 패턴 칸은 칸 전체가 금지라 표지 없는 따옴표를 금지 예시로 본다. 나머지는 언급이다 */
226
+ const addProse = (entry, text, line, fallback) => {
227
+ for (const sentence of sentencesOf(text)) {
228
+ entry.sentences.push({ text: sentence, line, exception: EXCEPTION_DEF.test(sentence) });
229
+ entry.examples.push(...classify(entry.id, sentence, line, fallback));
230
+ }
231
+ };
232
+ lines.forEach((raw, index) => {
233
+ const lineNo = index + 1;
234
+ if (/^\s*(```|~~~)/.test(raw)) {
235
+ inFence = !inFence;
236
+ return;
237
+ }
238
+ if (inFence)
239
+ return;
240
+ const headingMatch = raw.match(/^(#{1,6})\s+(.+)$/);
241
+ if (headingMatch) {
242
+ const level = headingMatch[1].length;
243
+ heading = { level, ids: headingIds(headingMatch[2]) };
244
+ pairColumns = null;
245
+ return;
246
+ }
247
+ const row = raw.match(RULE_ROW);
248
+ if (row) {
249
+ const id = row[1];
250
+ const cells = splitCells(row[2]);
251
+ const entry = entryOf(id, lineNo);
252
+ entry.line = lineNo;
253
+ const pattern = cells[0] ?? '';
254
+ const prescription = cells[cells.length - 2] ?? cells[cells.length - 1] ?? '';
255
+ addProse(entry, pattern, lineNo, 'before');
256
+ addProse(entry, prescription, lineNo, 'mention');
257
+ return;
258
+ }
259
+ if (raw.startsWith('|')) {
260
+ const cells = splitCells(raw.slice(1));
261
+ const beforeCol = cells.findIndex((c) => c.includes('쓰지 말 것'));
262
+ const afterCol = cells.findIndex((c) => c === '이렇게');
263
+ if (beforeCol >= 0 && afterCol >= 0) {
264
+ pairColumns = { before: beforeCol, after: afterCol };
265
+ return;
266
+ }
267
+ const owner = heading.ids[0];
268
+ if (pairColumns && owner && !/^\|[\s|:-]+$/.test(raw)) {
269
+ const entry = entryOf(owner, lineNo);
270
+ const pick = (col, kind) => {
271
+ const cell = (cells[col] ?? '').replace(/`/g, '');
272
+ for (const part of cell.split(/\s*\/\s*|,\s*/)) {
273
+ const text = part.replace(/"/g, '').trim();
274
+ if (/[가-힣]/.test(text)) {
275
+ entry.examples.push({ ruleId: owner, kind, text, sentence: raw, line: lineNo });
276
+ }
277
+ }
278
+ };
279
+ pick(pairColumns.before, 'before');
280
+ pick(pairColumns.after, 'after');
281
+ }
282
+ return;
283
+ }
284
+ const text = raw.trim();
285
+ if (!text)
286
+ return;
287
+ const owners = leadIds(text).length > 0 ? leadIds(text) : heading.ids;
288
+ for (const id of owners) {
289
+ // 표보다 문단이 먼저 나오면(머리말의 오적용 주의) 행 줄 번호는 행을 만날 때 고친다
290
+ addProse(entryOf(id, lineNo), text, lineNo, 'mention');
291
+ }
292
+ });
293
+ return [...entries.values()];
294
+ }
295
+ /**
296
+ * 예시는 문장 조각이라 뒤에 공백을 붙여 돌린다. H-1처럼 낱말 뒤 공백을 요구하는 탐지기가
297
+ * "또한" 하나만으로는 안 걸려서다.
298
+ */
299
+ function hitsFor(detectors, text) {
300
+ const map = new Map();
301
+ for (const { ruleId, samples } of detectors.run(`${text} `))
302
+ map.set(ruleId, samples);
303
+ return map;
304
+ }
305
+ /**
306
+ * 가운뎃점으로 나열한 예시(`또한·따라서·즉`)는 낱낱이도 돌린다.
307
+ * 통째로만 돌리면 다른 룰 예시가 전부 C-12에 걸린다. 낱낱으로만 돌리면 C-12 자기 예시를 놓친다.
308
+ * 그래서 자기 룰은 둘 중 하나라도 걸리면 잡은 것으로, 다른 룰은 낱낱으로만 본다.
309
+ */
310
+ function exampleHits(detectors, text) {
311
+ const whole = hitsFor(detectors, text);
312
+ if (!text.includes('·'))
313
+ return { self: whole, others: whole };
314
+ const parts = new Map();
315
+ for (const part of text.split('·')) {
316
+ for (const [id, samples] of hitsFor(detectors, part.trim()))
317
+ parts.set(id, samples);
318
+ }
319
+ return { self: new Map([...whole, ...parts]), others: parts };
320
+ }
321
+ /** 두 룰 사이에 순서를 정한 문장이 있는지. 어느 쪽 룰에 적혀 있어도 된다 */
322
+ function hasOrder(a, b) {
323
+ const mentions = (from, to) => (from?.sentences ?? []).some((s) => ruleIdsIn(s.text).includes(to) && ORDER.test(s.text));
324
+ if (!a || !b)
325
+ return false;
326
+ return mentions(a, b.id) || mentions(b, a.id);
327
+ }
328
+ /**
329
+ * 탐지기 행렬.
330
+ *
331
+ * - 처방(after)이 아무 탐지기에나 걸리면 룰북이 시키는 대로 고친 글이 다시 걸린다 (error)
332
+ * - 예외로 둔 말을 다른 룰 탐지기가 잡으면 두 룰이 같은 말에 반대로 판정한다.
333
+ * 둘 사이에 순서를 정한 문장이 없으면 error다
334
+ * - 형태로 빠진다고 적은 예외를 자기 탐지기가 잡으면 선언과 탐지기가 갈라진 것이다 (error)
335
+ * - 뜻으로 가르는 예외를 자기 탐지기가 잡는 건 룰북이 "넓게 건다"고 적어둔 룰이 많아 info다
336
+ * - 금지 예시를 자기 탐지기가 못 잡는 건 룰북이 탐지기 범위를 좁혀 적어둔 자리가 섞여 info다
337
+ * - 금지 예시가 다른 룰에도 걸리는 건 순서 문장이 없을 때만 보인다 (info)
338
+ */
339
+ export function runMatrix(entries, detectors) {
340
+ const byId = new Map(entries.map((e) => [e.id, e]));
341
+ const detectable = new Set(detectors.ids);
342
+ const findings = [];
343
+ const seen = new Set();
344
+ const push = (finding) => {
345
+ const key = `${finding.code}|${finding.ruleId}|${finding.hitBy}|${finding.example.text}`;
346
+ if (seen.has(key))
347
+ return;
348
+ seen.add(key);
349
+ findings.push(finding);
350
+ };
351
+ for (const entry of entries) {
352
+ for (const example of entry.examples) {
353
+ const { self, others: hits } = exampleHits(detectors, example.text);
354
+ if (example.kind === 'after') {
355
+ for (const [hitBy, samples] of self) {
356
+ push({
357
+ code: 'after-hit',
358
+ level: 'error',
359
+ ruleId: entry.id,
360
+ hitBy,
361
+ example,
362
+ message: `${entry.id} 처방 "${example.text}"이 ${hitBy}에 걸린다 (${samples.join(', ')})`,
363
+ });
364
+ }
365
+ continue;
366
+ }
367
+ if (example.kind === 'exempt') {
368
+ for (const [hitBy, samples] of hits) {
369
+ if (hitBy === entry.id && FORM_EXEMPT.test(example.sentence)) {
370
+ push({
371
+ code: 'form-exempt-hit',
372
+ level: 'error',
373
+ ruleId: entry.id,
374
+ hitBy,
375
+ example,
376
+ message: `${entry.id}가 형태로 빠진다고 적은 "${example.text}"를 자기 탐지기가 잡는다 (${samples.join(', ')})`,
377
+ });
378
+ continue;
379
+ }
380
+ if (hitBy === entry.id) {
381
+ push({
382
+ code: 'exempt-hit-self',
383
+ level: 'info',
384
+ ruleId: entry.id,
385
+ hitBy,
386
+ example,
387
+ message: `${entry.id}가 예외로 둔 "${example.text}"를 자기 탐지기가 잡는다 (${samples.join(', ')})`,
388
+ });
389
+ continue;
390
+ }
391
+ const ordered = hasOrder(entry, byId.get(hitBy));
392
+ push({
393
+ code: 'exempt-hit-other',
394
+ level: ordered ? 'info' : 'error',
395
+ ruleId: entry.id,
396
+ hitBy,
397
+ example,
398
+ message: `${entry.id}가 예외로 둔 "${example.text}"를 ${hitBy}가 잡는다${ordered ? '' : ' — 둘 사이 순서를 정한 문장이 없다'}`,
399
+ });
400
+ }
401
+ continue;
402
+ }
403
+ if (detectable.has(entry.id) && !self.has(entry.id)) {
404
+ push({
405
+ code: 'before-miss',
406
+ level: 'info',
407
+ ruleId: entry.id,
408
+ hitBy: entry.id,
409
+ example,
410
+ message: `${entry.id} 금지 예시 "${example.text}"를 자기 탐지기가 못 잡는다`,
411
+ });
412
+ }
413
+ for (const [hitBy] of hits) {
414
+ if (hitBy === entry.id || hasOrder(entry, byId.get(hitBy)))
415
+ continue;
416
+ push({
417
+ code: 'before-hit-other',
418
+ level: 'info',
419
+ ruleId: entry.id,
420
+ hitBy,
421
+ example,
422
+ message: `${entry.id} 금지 예시 "${example.text}"가 ${hitBy}에도 걸린다 — 둘 사이 순서를 정한 문장이 없다`,
423
+ });
424
+ }
425
+ }
426
+ }
427
+ return findings;
428
+ }
429
+ const JOSA_TAIL = /(?:에서는|에서|으로는|으로|로는|이라는|라는|이다|이고|이며|에는|에게|까지|부터|처럼|이나|은|는|이|가|을|를|에|의|와|과|도|만|로|다|요)$/;
430
+ /** 내용어 낱말. 조사를 떼고 두 글자 이상만 남긴다 */
431
+ export function contentWords(text) {
432
+ return (text.match(/[가-힣]+/g) ?? [])
433
+ .map((w) => (w.length > 2 ? w.replace(JOSA_TAIL, '') : w))
434
+ .filter((w) => w.length >= 2);
435
+ }
436
+ /** 한글 음절을 로마자 자음 뼈대로. 음차와 영어 원어가 같은 말인지 거칠게 맞춰 본다 */
437
+ // 초성 ㄱㄲㄴㄷㄸㄹㅁㅂㅃㅅㅆㅇㅈㅉㅊㅋㅌㅍㅎ, 종성은 대표음으로
438
+ const INITIAL = [
439
+ 'g',
440
+ 'g',
441
+ 'n',
442
+ 't',
443
+ 't',
444
+ 'r',
445
+ 'm',
446
+ 'b',
447
+ 'b',
448
+ 's',
449
+ 's',
450
+ '',
451
+ 's',
452
+ 's',
453
+ 's',
454
+ 'g',
455
+ 't',
456
+ 'p',
457
+ 'h',
458
+ ];
459
+ const FINAL = [
460
+ '',
461
+ 'g',
462
+ 'g',
463
+ 'g',
464
+ 'n',
465
+ 'n',
466
+ 'n',
467
+ 't',
468
+ 'r',
469
+ 'g',
470
+ 'm',
471
+ 'b',
472
+ 's',
473
+ 't',
474
+ 'b',
475
+ 'r',
476
+ 'm',
477
+ 'b',
478
+ 'b',
479
+ 't',
480
+ 't',
481
+ 'ng',
482
+ 't',
483
+ 't',
484
+ 'g',
485
+ 't',
486
+ 'b',
487
+ 't',
488
+ ];
489
+ export function hangulSkeleton(text) {
490
+ let out = '';
491
+ for (const ch of text) {
492
+ const code = ch.codePointAt(0) - 0xac00;
493
+ if (code < 0 || code > 11171)
494
+ continue;
495
+ out += INITIAL[Math.floor(code / 588)] + FINAL[code % 28];
496
+ }
497
+ return out;
498
+ }
499
+ /** 영어를 같은 뼈대로. 한국어 음차가 구분하지 않는 자음을 한데 모은다 */
500
+ export function latinSkeleton(text) {
501
+ return text
502
+ .toLowerCase()
503
+ .replace(/[^a-z]/g, '')
504
+ .replace(/ph/g, 'p')
505
+ .replace(/th/g, 't')
506
+ .replace(/ch|sh|j|z|c(?=[eiy])|x/g, 's')
507
+ .replace(/ck|q|c|k/g, 'g')
508
+ .replace(/[aeiouwy]/g, '')
509
+ .replace(/f/g, 'p')
510
+ .replace(/v/g, 'b')
511
+ .replace(/l/g, 'r')
512
+ .replace(/d/g, 't')
513
+ .replace(/(.)\1+/g, '$1');
514
+ }
515
+ function squeeze(skeleton) {
516
+ return skeleton.replace(/(.)\1+/g, '$1');
517
+ }
518
+ /** 영어 원어가 다른 룰의 음차와 같은 말인지 본다. 짧은 말은 우연히 맞아서 뺀다 */
519
+ function sameSource(latin, hangul) {
520
+ const a = latinSkeleton(latin);
521
+ const b = squeeze(hangulSkeleton(hangul));
522
+ if (a.length < 6 || b.length < 6)
523
+ return false;
524
+ return a === b;
525
+ }
526
+ /** 두 단어 이상 영어 구. "=low-hanging fruit", "(source of truth)" 같은 원어 표기다 */
527
+ function englishPhrases(text) {
528
+ return [...text.matchAll(/[A-Za-z][A-Za-z-]+(?:\s+[A-Za-z][A-Za-z-]+)+/g)].map((m) => m[0]);
529
+ }
530
+ /** 두 단어 이상 한글 구. 음차는 낱말 사이를 띄어 쓴다 ("로우 행잉 프룻") */
531
+ function hangulPhrases(text) {
532
+ return [...text.matchAll(/[가-힣]+(?:\s+[가-힣]+){1,3}/g)].map((m) => m[0]);
533
+ }
534
+ /**
535
+ * 탐지기로 판정이 안 되는 겹침을 질문으로 뽑는다.
536
+ *
537
+ * 판정이 아니다. 같은 낱말이 금지와 예외에 함께 나온다고 다 모순은 아니다 —
538
+ * "원본"처럼 어느 쪽인지 가르는 문장이 이미 있을 수 있다. 그래서 리뷰어가 읽을
539
+ * 질문으로만 넘기고 게이트에 걸지 않는다.
540
+ */
541
+ export function findCandidates(entries, options = {}) {
542
+ const { changed } = options;
543
+ const isNew = (sentence) => !changed || changed.has(sentence);
544
+ const byId = new Map(entries.map((e) => [e.id, e]));
545
+ const candidates = [];
546
+ for (const entry of entries) {
547
+ // 1. 같은 룰 안에서 금지 예시와 예외 예시가 같은 낱말을 쓴다
548
+ const targets = entry.examples.filter((e) => e.kind === 'before');
549
+ const exempts = entry.examples.filter((e) => e.kind === 'exempt');
550
+ const exceptions = entry.sentences.filter((s) => s.exception);
551
+ const shared = new Map();
552
+ for (const target of targets) {
553
+ const words = new Set(contentWords(target.text));
554
+ for (const exempt of exempts) {
555
+ // 한 문장이 둘을 맞세워 가르면 경계를 이미 그은 것이다
556
+ if (exempt.sentence === target.sentence)
557
+ continue;
558
+ if (!isNew(exempt.sentence) && !isNew(target.sentence))
559
+ continue;
560
+ for (const word of contentWords(exempt.text)) {
561
+ if (words.has(word) && !shared.has(word))
562
+ shared.set(word, { target, exempt });
563
+ }
564
+ }
565
+ }
566
+ for (const [word, { target, exempt }] of shared) {
567
+ candidates.push({
568
+ code: 'target-exempt-share',
569
+ rules: [entry.id],
570
+ file: entry.file,
571
+ line: exempt.line,
572
+ question: `${entry.id}의 금지 예시 "${target.text}"와 예외 예시 "${exempt.text}"가 "${word}"를 함께 쓴다. 금지 예시가 이 예외로 빠지지 않나?`,
573
+ evidence: [target.sentence, exempt.sentence],
574
+ });
575
+ }
576
+ // 2. 예외가 둘 이상인데 둘이 같은 대상에 걸릴 때 순서가 없다
577
+ if (exceptions.length >= 2 && exceptions.some((s) => isNew(s.text))) {
578
+ const ordered = entry.sentences.some((s) => /앞선다|먼저|우선/.test(s.text));
579
+ if (!ordered) {
580
+ const fresh = exceptions.find((s) => isNew(s.text));
581
+ candidates.push({
582
+ code: 'exceptions-no-order',
583
+ rules: [entry.id],
584
+ file: entry.file,
585
+ line: fresh.line,
586
+ question: `${entry.id}에 예외가 ${exceptions.length}개인데 둘이 한 대상에 함께 걸리면 어느 쪽이 앞서는지 적혀 있지 않다. 새 예외가 기존 예외나 판정 기준과 반대로 가는 입력이 있나?`,
587
+ evidence: exceptions.map((s) => s.text),
588
+ });
589
+ }
590
+ }
591
+ // 3. 다른 룰을 부르며 경계를 긋는데 둘 다 걸릴 때 어느 쪽인지 안 정했다
592
+ for (const sentence of entry.sentences) {
593
+ if (!isNew(sentence.text) || !BOUNDARY.test(sentence.text))
594
+ continue;
595
+ for (const other of ruleIdsIn(sentence.text)) {
596
+ if (other === entry.id || !byId.has(other))
597
+ continue;
598
+ if (hasOrder(entry, byId.get(other)))
599
+ continue;
600
+ // `(F-4·F-5 오적용 주의)`처럼 두 룰이 함께 가진 문단은 한 룰 몫으로 읽는다
601
+ if (byId.get(other).sentences.some((s) => s.text === sentence.text))
602
+ continue;
603
+ candidates.push({
604
+ code: 'cited-no-order',
605
+ rules: [entry.id, other],
606
+ file: entry.file,
607
+ line: sentence.line,
608
+ question: `${entry.id}가 ${other}와의 경계를 적었지만 둘에 함께 걸리는 말을 어느 쪽으로 판정할지 없다. ${other}의 예외가 ${entry.id} 대상을 면제하지 않나?`,
609
+ evidence: [sentence.text],
610
+ });
611
+ }
612
+ }
613
+ }
614
+ const cites = (from, to) => from.sentences.some((s) => ruleIdsIn(s.text).includes(to));
615
+ // 4. 두 룰이 같은 영어 원어를 서로 다른 처방으로 다룬다 (음차와 직역)
616
+ for (const a of entries) {
617
+ for (const aSentence of a.sentences) {
618
+ for (const phrase of englishPhrases(aSentence.text)) {
619
+ for (const b of entries) {
620
+ if (b.id === a.id)
621
+ continue;
622
+ if (cites(a, b.id) || cites(b, a.id))
623
+ continue;
624
+ for (const bSentence of b.sentences) {
625
+ if (!isNew(aSentence.text) && !isNew(bSentence.text))
626
+ continue;
627
+ const match = hangulPhrases(bSentence.text).find((h) => sameSource(phrase, h));
628
+ if (!match)
629
+ continue;
630
+ candidates.push({
631
+ code: 'shared-source',
632
+ rules: [a.id, b.id],
633
+ file: a.file,
634
+ line: aSentence.line,
635
+ question: `${a.id}의 "${phrase}"와 ${b.id}의 "${match}"는 같은 원어다. 두 룰이 서로를 안 부른다 — 한쪽 처방대로 옮기면 다른 쪽에 걸리지 않나?`,
636
+ evidence: [aSentence.text, bSentence.text],
637
+ });
638
+ }
639
+ }
640
+ }
641
+ }
642
+ }
643
+ return dedupeCandidates(candidates);
644
+ }
645
+ function dedupeCandidates(candidates) {
646
+ const seen = new Set();
647
+ return candidates.filter((c) => {
648
+ const key = `${c.code}|${[...c.rules].sort().join(',')}|${c.question}`;
649
+ if (seen.has(key))
650
+ return false;
651
+ seen.add(key);
652
+ return true;
653
+ });
654
+ }
655
+ /** 탐지기가 없어 자기 예시를 판정할 수 없는 룰. 건너뛰지 않고 목록으로 드러낸다 */
656
+ export function undecidableRules(entries, detectors) {
657
+ const detectable = new Set(detectors.ids);
658
+ return entries
659
+ .filter((e) => !detectable.has(e.id))
660
+ .map((e) => ({
661
+ ruleId: e.id,
662
+ file: e.file,
663
+ line: e.line,
664
+ before: e.examples.filter((x) => x.kind === 'before').length,
665
+ exempt: e.examples.filter((x) => x.kind === 'exempt').length,
666
+ }));
667
+ }
668
+ /** 이번 변경에서 새로 생긴 문장. 문장 단위로 비교해야 긴 행 한 칸의 수정이 행 전체로 번지지 않는다 */
669
+ export function changedSentences(base, head) {
670
+ const old = new Set(base.flatMap((e) => e.sentences.map((s) => s.text)));
671
+ return new Set(head.flatMap((e) => e.sentences.map((s) => s.text)).filter((s) => !old.has(s)));
672
+ }
673
+ export function checkOverlap(entries, detectors, options = {}) {
674
+ return {
675
+ findings: runMatrix(entries, detectors),
676
+ candidates: findCandidates(entries, options),
677
+ undecidable: undecidableRules(entries, detectors),
678
+ };
679
+ }
680
+ //# sourceMappingURL=overlap.js.map