docuking-mcp 2.9.0 → 2.9.2

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.
@@ -1,544 +1,544 @@
1
- /**
2
- * DocuKing MCP - 킹밸리데이트 핸들러
3
- * 코드가 정책을 준수하는지 검증
4
- */
5
-
6
- import fs from 'fs';
7
- import path from 'path';
8
-
9
- /**
10
- * 정책 파일에서 검증 규칙 추출
11
- * @param {string} policyContent - 정책 파일 내용
12
- * @param {string} policyName - 정책 파일 이름
13
- * @returns {Array} 추출된 규칙 목록
14
- */
15
- function extractRulesFromPolicy(policyContent, policyName) {
16
- const rules = [];
17
-
18
- // 코드 블록 내의 규칙 패턴 추출
19
- // 예: ```폴더명/``` 또는 ```파일패턴```
20
- const codeBlockRegex = /```([^`]+)```/g;
21
- let match;
22
-
23
- // 폴더 구조 정책 (01_folder_structure.md)
24
- if (policyName.includes('folder_structure')) {
25
- // 폴더 패턴 추출: "src/components/" 같은 패턴
26
- const folderPatterns = policyContent.match(/[-*]\s*`([^`]+\/)`/g);
27
- if (folderPatterns) {
28
- folderPatterns.forEach(p => {
29
- const folder = p.match(/`([^`]+\/)`/)?.[1];
30
- if (folder) {
31
- rules.push({
32
- type: 'folder_exists',
33
- pattern: folder,
34
- policy: policyName,
35
- });
36
- }
37
- });
38
- }
39
-
40
- // 금지 패턴 추출: "금지", "하지 말 것", "사용하지 않음" 등
41
- const forbiddenLines = policyContent.split('\n').filter(line =>
42
- line.includes('금지') || line.includes('하지 말') || line.includes('사용하지 않')
43
- );
44
- forbiddenLines.forEach(line => {
45
- const pattern = line.match(/`([^`]+)`/)?.[1];
46
- if (pattern) {
47
- rules.push({
48
- type: 'forbidden_pattern',
49
- pattern: pattern,
50
- message: line.trim(),
51
- policy: policyName,
52
- });
53
- }
54
- });
55
- }
56
-
57
- // 명명 규칙 정책 (02_naming_convention.md)
58
- if (policyName.includes('naming_convention')) {
59
- // PascalCase 규칙
60
- if (policyContent.includes('PascalCase')) {
61
- const pascalPatterns = policyContent.match(/[-*]\s*([^:]+):\s*PascalCase/gi);
62
- if (pascalPatterns) {
63
- pascalPatterns.forEach(p => {
64
- const target = p.match(/[-*]\s*([^:]+):/)?.[1]?.trim();
65
- if (target) {
66
- rules.push({
67
- type: 'naming_convention',
68
- convention: 'PascalCase',
69
- target: target,
70
- policy: policyName,
71
- });
72
- }
73
- });
74
- }
75
- }
76
-
77
- // camelCase 규칙
78
- if (policyContent.includes('camelCase')) {
79
- const camelPatterns = policyContent.match(/[-*]\s*([^:]+):\s*camelCase/gi);
80
- if (camelPatterns) {
81
- camelPatterns.forEach(p => {
82
- const target = p.match(/[-*]\s*([^:]+):/)?.[1]?.trim();
83
- if (target) {
84
- rules.push({
85
- type: 'naming_convention',
86
- convention: 'camelCase',
87
- target: target,
88
- policy: policyName,
89
- });
90
- }
91
- });
92
- }
93
- }
94
-
95
- // snake_case 규칙
96
- if (policyContent.includes('snake_case')) {
97
- const snakePatterns = policyContent.match(/[-*]\s*([^:]+):\s*snake_case/gi);
98
- if (snakePatterns) {
99
- snakePatterns.forEach(p => {
100
- const target = p.match(/[-*]\s*([^:]+):/)?.[1]?.trim();
101
- if (target) {
102
- rules.push({
103
- type: 'naming_convention',
104
- convention: 'snake_case',
105
- target: target,
106
- policy: policyName,
107
- });
108
- }
109
- });
110
- }
111
- }
112
- }
113
-
114
- // DB 스키마 정책 (05_database_schema.md)
115
- if (policyName.includes('database_schema')) {
116
- // 필수 컬럼 추출
117
- const requiredColumns = policyContent.match(/필수[^:]*:\s*([^\n]+)/gi);
118
- if (requiredColumns) {
119
- requiredColumns.forEach(line => {
120
- const columns = line.match(/`([^`]+)`/g);
121
- if (columns) {
122
- columns.forEach(col => {
123
- rules.push({
124
- type: 'required_column',
125
- column: col.replace(/`/g, ''),
126
- policy: policyName,
127
- });
128
- });
129
- }
130
- });
131
- }
132
-
133
- // 컬럼 명명 규칙
134
- if (policyContent.includes('snake_case') && policyContent.includes('컬럼')) {
135
- rules.push({
136
- type: 'column_naming',
137
- convention: 'snake_case',
138
- policy: policyName,
139
- });
140
- }
141
- }
142
-
143
- // API 규칙 정책 (03_api_convention.md)
144
- if (policyName.includes('api_convention')) {
145
- // REST 규칙 추출
146
- const restRules = policyContent.match(/[-*]\s*(GET|POST|PUT|DELETE|PATCH)\s+([^\n]+)/gi);
147
- if (restRules) {
148
- restRules.forEach(rule => {
149
- const method = rule.match(/(GET|POST|PUT|DELETE|PATCH)/i)?.[1];
150
- const description = rule.replace(/[-*]\s*(GET|POST|PUT|DELETE|PATCH)/i, '').trim();
151
- if (method) {
152
- rules.push({
153
- type: 'api_method',
154
- method: method.toUpperCase(),
155
- description: description,
156
- policy: policyName,
157
- });
158
- }
159
- });
160
- }
161
- }
162
-
163
- return rules;
164
- }
165
-
166
- /**
167
- * 폴더 존재 여부 검증
168
- */
169
- function validateFolderExists(basePath, folderPattern) {
170
- const fullPath = path.join(basePath, folderPattern.replace(/\/$/, ''));
171
- if (!fs.existsSync(fullPath)) {
172
- return {
173
- valid: false,
174
- message: `폴더가 존재하지 않음: ${folderPattern}`,
175
- suggestion: `mkdir -p ${fullPath}`,
176
- };
177
- }
178
- return { valid: true };
179
- }
180
-
181
- /**
182
- * 파일명 명명 규칙 검증
183
- */
184
- function validateNamingConvention(filePath, convention) {
185
- const fileName = path.basename(filePath, path.extname(filePath));
186
-
187
- switch (convention) {
188
- case 'PascalCase':
189
- // 첫 글자 대문자, 언더스코어/하이픈 없음
190
- if (!/^[A-Z][a-zA-Z0-9]*$/.test(fileName)) {
191
- return {
192
- valid: false,
193
- message: `PascalCase 위반: ${fileName}`,
194
- suggestion: fileName.charAt(0).toUpperCase() + fileName.slice(1).replace(/[-_](.)/g, (_, c) => c.toUpperCase()),
195
- };
196
- }
197
- break;
198
-
199
- case 'camelCase':
200
- // 첫 글자 소문자, 언더스코어/하이픈 없음
201
- if (!/^[a-z][a-zA-Z0-9]*$/.test(fileName)) {
202
- return {
203
- valid: false,
204
- message: `camelCase 위반: ${fileName}`,
205
- suggestion: fileName.charAt(0).toLowerCase() + fileName.slice(1).replace(/[-_](.)/g, (_, c) => c.toUpperCase()),
206
- };
207
- }
208
- break;
209
-
210
- case 'snake_case':
211
- // 소문자와 언더스코어만
212
- if (!/^[a-z][a-z0-9_]*$/.test(fileName)) {
213
- return {
214
- valid: false,
215
- message: `snake_case 위반: ${fileName}`,
216
- suggestion: fileName.replace(/([A-Z])/g, '_$1').toLowerCase().replace(/^_/, ''),
217
- };
218
- }
219
- break;
220
-
221
- case 'kebab-case':
222
- // 소문자와 하이픈만
223
- if (!/^[a-z][a-z0-9-]*$/.test(fileName)) {
224
- return {
225
- valid: false,
226
- message: `kebab-case 위반: ${fileName}`,
227
- suggestion: fileName.replace(/([A-Z])/g, '-$1').toLowerCase().replace(/^-/, ''),
228
- };
229
- }
230
- break;
231
- }
232
-
233
- return { valid: true };
234
- }
235
-
236
- /**
237
- * Entity 파일에서 필수 컬럼 검증
238
- */
239
- function validateRequiredColumns(entityContent, filePath, requiredColumns) {
240
- const violations = [];
241
-
242
- for (const column of requiredColumns) {
243
- // @Column 데코레이터와 함께 컬럼명 찾기
244
- const columnRegex = new RegExp(`@Column[^)]*\\)[\\s\\S]*?${column}\\s*[:\\?]`, 'i');
245
- const propertyRegex = new RegExp(`${column}\\s*[:\\?]`, 'i');
246
-
247
- if (!columnRegex.test(entityContent) && !propertyRegex.test(entityContent)) {
248
- violations.push({
249
- file: filePath,
250
- message: `필수 컬럼 누락: ${column}`,
251
- suggestion: `@Column()\n ${column}: Date;`,
252
- });
253
- }
254
- }
255
-
256
- return violations;
257
- }
258
-
259
- /**
260
- * 파일 목록 재귀 수집
261
- */
262
- function collectFiles(dirPath, extensions = ['.ts', '.tsx', '.js', '.jsx']) {
263
- const files = [];
264
-
265
- if (!fs.existsSync(dirPath)) {
266
- return files;
267
- }
268
-
269
- const entries = fs.readdirSync(dirPath, { withFileTypes: true });
270
-
271
- for (const entry of entries) {
272
- const fullPath = path.join(dirPath, entry.name);
273
-
274
- // 제외할 폴더
275
- if (entry.isDirectory()) {
276
- if (['node_modules', '.git', 'dist', 'build', '.next'].includes(entry.name)) {
277
- continue;
278
- }
279
- files.push(...collectFiles(fullPath, extensions));
280
- } else if (entry.isFile()) {
281
- const ext = path.extname(entry.name);
282
- if (extensions.includes(ext)) {
283
- files.push(fullPath);
284
- }
285
- }
286
- }
287
-
288
- return files;
289
- }
290
-
291
- /**
292
- * docuking_validate 핸들러
293
- */
294
- export async function handleValidate(args) {
295
- const { localPath, policy, targetPath, autoFix = false } = args;
296
-
297
- if (!localPath) {
298
- return {
299
- content: [{
300
- type: 'text',
301
- text: '오류: localPath가 필요합니다.',
302
- }],
303
- };
304
- }
305
-
306
- // 정책 파일 경로 (우선순위: .claude/rules/local/ > yy_All_Docu/_Policy/)
307
- const localRulesPath = path.join(localPath, '.claude', 'rules', 'local');
308
- const policyFolderPath = path.join(localPath, 'yy_All_Docu', '_Policy');
309
-
310
- // 정책 파일 수집
311
- const policyFiles = [];
312
-
313
- // .claude/rules/local/ 에서 정책 파일 찾기
314
- if (fs.existsSync(localRulesPath)) {
315
- const files = fs.readdirSync(localRulesPath).filter(f => f.endsWith('.md'));
316
- files.forEach(f => {
317
- policyFiles.push({
318
- name: f,
319
- path: path.join(localRulesPath, f),
320
- source: 'local_rules',
321
- });
322
- });
323
- }
324
-
325
- // yy_All_Docu/_Policy/ 에서 정책 파일 찾기
326
- if (fs.existsSync(policyFolderPath)) {
327
- const files = fs.readdirSync(policyFolderPath).filter(f => f.endsWith('.md'));
328
- files.forEach(f => {
329
- // 이미 local_rules에 있으면 스킵
330
- if (!policyFiles.find(p => p.name === f)) {
331
- policyFiles.push({
332
- name: f,
333
- path: path.join(policyFolderPath, f),
334
- source: '_Policy',
335
- });
336
- }
337
- });
338
- }
339
-
340
- if (policyFiles.length === 0) {
341
- return {
342
- content: [{
343
- type: 'text',
344
- text: `정책 파일을 찾을 수 없습니다.
345
-
346
- 검색 위치:
347
- - ${localRulesPath}
348
- - ${policyFolderPath}
349
-
350
- 정책 파일을 yy_All_Docu/_Policy/ 폴더에 작성하거나,
351
- Pull을 실행하여 킹캐스트로 정책을 로컬화하세요.`,
352
- }],
353
- };
354
- }
355
-
356
- // 특정 정책만 검증
357
- let targetPolicies = policyFiles;
358
- if (policy) {
359
- targetPolicies = policyFiles.filter(p => p.name.includes(policy));
360
- if (targetPolicies.length === 0) {
361
- return {
362
- content: [{
363
- type: 'text',
364
- text: `정책 파일을 찾을 수 없습니다: ${policy}
365
-
366
- 사용 가능한 정책:
367
- ${policyFiles.map(p => `- ${p.name}`).join('\n')}`,
368
- }],
369
- };
370
- }
371
- }
372
-
373
- // 규칙 추출
374
- const allRules = [];
375
- for (const policyFile of targetPolicies) {
376
- try {
377
- const content = fs.readFileSync(policyFile.path, 'utf-8');
378
- const rules = extractRulesFromPolicy(content, policyFile.name);
379
- allRules.push(...rules);
380
- } catch (e) {
381
- console.error(`[KingValidate] 정책 파일 읽기 실패: ${policyFile.path} - ${e.message}`);
382
- }
383
- }
384
-
385
- if (allRules.length === 0) {
386
- return {
387
- content: [{
388
- type: 'text',
389
- text: `정책 파일에서 검증 가능한 규칙을 찾을 수 없습니다.
390
-
391
- 검사한 정책:
392
- ${targetPolicies.map(p => `- ${p.name}`).join('\n')}
393
-
394
- 💡 정책 파일에 다음 형식으로 규칙을 작성하세요:
395
- - \`폴더명/\` - 필수 폴더
396
- - 파일명: PascalCase
397
- - 필수 컬럼: \`createdAt\`, \`updatedAt\`
398
- - 금지: \`any\` 타입 사용`,
399
- }],
400
- };
401
- }
402
-
403
- // 검증 대상 파일 수집
404
- const searchPath = targetPath ? path.join(localPath, targetPath) : localPath;
405
- const codeFiles = collectFiles(searchPath);
406
-
407
- // 검증 실행
408
- const violations = [];
409
- const passed = [];
410
-
411
- // 폴더 존재 검증
412
- const folderRules = allRules.filter(r => r.type === 'folder_exists');
413
- for (const rule of folderRules) {
414
- const result = validateFolderExists(localPath, rule.pattern);
415
- if (!result.valid) {
416
- violations.push({
417
- policy: rule.policy,
418
- type: 'folder',
419
- message: result.message,
420
- suggestion: result.suggestion,
421
- });
422
- } else {
423
- passed.push({
424
- policy: rule.policy,
425
- type: 'folder',
426
- message: `폴더 존재: ${rule.pattern}`,
427
- });
428
- }
429
- }
430
-
431
- // 명명 규칙 검증
432
- const namingRules = allRules.filter(r => r.type === 'naming_convention');
433
- for (const file of codeFiles) {
434
- for (const rule of namingRules) {
435
- // 타겟에 맞는 파일인지 확인 (예: "컴포넌트 파일명" → .tsx 파일)
436
- const ext = path.extname(file);
437
- const isComponent = (ext === '.tsx' || ext === '.jsx') &&
438
- (file.includes('components') || file.includes('Components'));
439
-
440
- if (rule.target?.includes('컴포넌트') && isComponent) {
441
- const result = validateNamingConvention(file, rule.convention);
442
- if (!result.valid) {
443
- violations.push({
444
- policy: rule.policy,
445
- type: 'naming',
446
- file: path.relative(localPath, file),
447
- message: result.message,
448
- suggestion: result.suggestion,
449
- });
450
- }
451
- }
452
- }
453
- }
454
-
455
- // 필수 컬럼 검증 (Entity 파일)
456
- const columnRules = allRules.filter(r => r.type === 'required_column');
457
- if (columnRules.length > 0) {
458
- const requiredColumns = columnRules.map(r => r.column);
459
- const entityFiles = codeFiles.filter(f =>
460
- f.includes('entity') || f.includes('Entity') || f.includes('entities')
461
- );
462
-
463
- for (const entityFile of entityFiles) {
464
- try {
465
- const content = fs.readFileSync(entityFile, 'utf-8');
466
- const columnViolations = validateRequiredColumns(content, entityFile, requiredColumns);
467
- columnViolations.forEach(v => {
468
- violations.push({
469
- policy: columnRules[0].policy,
470
- type: 'column',
471
- file: path.relative(localPath, v.file),
472
- message: v.message,
473
- suggestion: v.suggestion,
474
- });
475
- });
476
- } catch (e) {
477
- console.error(`[KingValidate] Entity 파일 읽기 실패: ${entityFile}`);
478
- }
479
- }
480
- }
481
-
482
- // 결과 포맷팅
483
- let resultText = `# 킹밸리데이트 결과
484
-
485
- 검사 대상: ${targetPath || '전체'}
486
- 정책 파일: ${targetPolicies.length}개
487
- 추출된 규칙: ${allRules.length}개
488
- 검사 파일: ${codeFiles.length}개
489
-
490
- `;
491
-
492
- if (violations.length === 0) {
493
- resultText += `## ✅ 모든 검증 통과!
494
-
495
- ${passed.length}개 규칙을 검증했습니다.`;
496
- } else {
497
- resultText += `## ❌ 위반 발견: ${violations.length}건
498
-
499
- `;
500
-
501
- // 정책별로 그룹화
502
- const byPolicy = {};
503
- violations.forEach(v => {
504
- if (!byPolicy[v.policy]) {
505
- byPolicy[v.policy] = [];
506
- }
507
- byPolicy[v.policy].push(v);
508
- });
509
-
510
- for (const [policyName, policyViolations] of Object.entries(byPolicy)) {
511
- resultText += `### [위반] ${policyName}\n`;
512
- policyViolations.forEach(v => {
513
- if (v.file) {
514
- resultText += ` - ${v.file} - ${v.message}\n`;
515
- } else {
516
- resultText += ` - ${v.message}\n`;
517
- }
518
- if (v.suggestion) {
519
- resultText += ` 💡 제안: ${v.suggestion}\n`;
520
- }
521
- });
522
- resultText += '\n';
523
- }
524
- }
525
-
526
- // 검증된 규칙 요약
527
- resultText += `\n## 검증된 규칙
528
-
529
- `;
530
- const ruleTypes = {};
531
- allRules.forEach(r => {
532
- ruleTypes[r.type] = (ruleTypes[r.type] || 0) + 1;
533
- });
534
- for (const [type, count] of Object.entries(ruleTypes)) {
535
- resultText += `- ${type}: ${count}개\n`;
536
- }
537
-
538
- return {
539
- content: [{
540
- type: 'text',
541
- text: resultText,
542
- }],
543
- };
544
- }
1
+ /**
2
+ * DocuKing MCP - 킹밸리데이트 핸들러
3
+ * 코드가 정책을 준수하는지 검증
4
+ */
5
+
6
+ import fs from 'fs';
7
+ import path from 'path';
8
+
9
+ /**
10
+ * 정책 파일에서 검증 규칙 추출
11
+ * @param {string} policyContent - 정책 파일 내용
12
+ * @param {string} policyName - 정책 파일 이름
13
+ * @returns {Array} 추출된 규칙 목록
14
+ */
15
+ function extractRulesFromPolicy(policyContent, policyName) {
16
+ const rules = [];
17
+
18
+ // 코드 블록 내의 규칙 패턴 추출
19
+ // 예: ```폴더명/``` 또는 ```파일패턴```
20
+ const codeBlockRegex = /```([^`]+)```/g;
21
+ let match;
22
+
23
+ // 폴더 구조 정책 (01_folder_structure.md)
24
+ if (policyName.includes('folder_structure')) {
25
+ // 폴더 패턴 추출: "src/components/" 같은 패턴
26
+ const folderPatterns = policyContent.match(/[-*]\s*`([^`]+\/)`/g);
27
+ if (folderPatterns) {
28
+ folderPatterns.forEach(p => {
29
+ const folder = p.match(/`([^`]+\/)`/)?.[1];
30
+ if (folder) {
31
+ rules.push({
32
+ type: 'folder_exists',
33
+ pattern: folder,
34
+ policy: policyName,
35
+ });
36
+ }
37
+ });
38
+ }
39
+
40
+ // 금지 패턴 추출: "금지", "하지 말 것", "사용하지 않음" 등
41
+ const forbiddenLines = policyContent.split('\n').filter(line =>
42
+ line.includes('금지') || line.includes('하지 말') || line.includes('사용하지 않')
43
+ );
44
+ forbiddenLines.forEach(line => {
45
+ const pattern = line.match(/`([^`]+)`/)?.[1];
46
+ if (pattern) {
47
+ rules.push({
48
+ type: 'forbidden_pattern',
49
+ pattern: pattern,
50
+ message: line.trim(),
51
+ policy: policyName,
52
+ });
53
+ }
54
+ });
55
+ }
56
+
57
+ // 명명 규칙 정책 (02_naming_convention.md)
58
+ if (policyName.includes('naming_convention')) {
59
+ // PascalCase 규칙
60
+ if (policyContent.includes('PascalCase')) {
61
+ const pascalPatterns = policyContent.match(/[-*]\s*([^:]+):\s*PascalCase/gi);
62
+ if (pascalPatterns) {
63
+ pascalPatterns.forEach(p => {
64
+ const target = p.match(/[-*]\s*([^:]+):/)?.[1]?.trim();
65
+ if (target) {
66
+ rules.push({
67
+ type: 'naming_convention',
68
+ convention: 'PascalCase',
69
+ target: target,
70
+ policy: policyName,
71
+ });
72
+ }
73
+ });
74
+ }
75
+ }
76
+
77
+ // camelCase 규칙
78
+ if (policyContent.includes('camelCase')) {
79
+ const camelPatterns = policyContent.match(/[-*]\s*([^:]+):\s*camelCase/gi);
80
+ if (camelPatterns) {
81
+ camelPatterns.forEach(p => {
82
+ const target = p.match(/[-*]\s*([^:]+):/)?.[1]?.trim();
83
+ if (target) {
84
+ rules.push({
85
+ type: 'naming_convention',
86
+ convention: 'camelCase',
87
+ target: target,
88
+ policy: policyName,
89
+ });
90
+ }
91
+ });
92
+ }
93
+ }
94
+
95
+ // snake_case 규칙
96
+ if (policyContent.includes('snake_case')) {
97
+ const snakePatterns = policyContent.match(/[-*]\s*([^:]+):\s*snake_case/gi);
98
+ if (snakePatterns) {
99
+ snakePatterns.forEach(p => {
100
+ const target = p.match(/[-*]\s*([^:]+):/)?.[1]?.trim();
101
+ if (target) {
102
+ rules.push({
103
+ type: 'naming_convention',
104
+ convention: 'snake_case',
105
+ target: target,
106
+ policy: policyName,
107
+ });
108
+ }
109
+ });
110
+ }
111
+ }
112
+ }
113
+
114
+ // DB 스키마 정책 (05_database_schema.md)
115
+ if (policyName.includes('database_schema')) {
116
+ // 필수 컬럼 추출
117
+ const requiredColumns = policyContent.match(/필수[^:]*:\s*([^\n]+)/gi);
118
+ if (requiredColumns) {
119
+ requiredColumns.forEach(line => {
120
+ const columns = line.match(/`([^`]+)`/g);
121
+ if (columns) {
122
+ columns.forEach(col => {
123
+ rules.push({
124
+ type: 'required_column',
125
+ column: col.replace(/`/g, ''),
126
+ policy: policyName,
127
+ });
128
+ });
129
+ }
130
+ });
131
+ }
132
+
133
+ // 컬럼 명명 규칙
134
+ if (policyContent.includes('snake_case') && policyContent.includes('컬럼')) {
135
+ rules.push({
136
+ type: 'column_naming',
137
+ convention: 'snake_case',
138
+ policy: policyName,
139
+ });
140
+ }
141
+ }
142
+
143
+ // API 규칙 정책 (03_api_convention.md)
144
+ if (policyName.includes('api_convention')) {
145
+ // REST 규칙 추출
146
+ const restRules = policyContent.match(/[-*]\s*(GET|POST|PUT|DELETE|PATCH)\s+([^\n]+)/gi);
147
+ if (restRules) {
148
+ restRules.forEach(rule => {
149
+ const method = rule.match(/(GET|POST|PUT|DELETE|PATCH)/i)?.[1];
150
+ const description = rule.replace(/[-*]\s*(GET|POST|PUT|DELETE|PATCH)/i, '').trim();
151
+ if (method) {
152
+ rules.push({
153
+ type: 'api_method',
154
+ method: method.toUpperCase(),
155
+ description: description,
156
+ policy: policyName,
157
+ });
158
+ }
159
+ });
160
+ }
161
+ }
162
+
163
+ return rules;
164
+ }
165
+
166
+ /**
167
+ * 폴더 존재 여부 검증
168
+ */
169
+ function validateFolderExists(basePath, folderPattern) {
170
+ const fullPath = path.join(basePath, folderPattern.replace(/\/$/, ''));
171
+ if (!fs.existsSync(fullPath)) {
172
+ return {
173
+ valid: false,
174
+ message: `폴더가 존재하지 않음: ${folderPattern}`,
175
+ suggestion: `mkdir -p ${fullPath}`,
176
+ };
177
+ }
178
+ return { valid: true };
179
+ }
180
+
181
+ /**
182
+ * 파일명 명명 규칙 검증
183
+ */
184
+ function validateNamingConvention(filePath, convention) {
185
+ const fileName = path.basename(filePath, path.extname(filePath));
186
+
187
+ switch (convention) {
188
+ case 'PascalCase':
189
+ // 첫 글자 대문자, 언더스코어/하이픈 없음
190
+ if (!/^[A-Z][a-zA-Z0-9]*$/.test(fileName)) {
191
+ return {
192
+ valid: false,
193
+ message: `PascalCase 위반: ${fileName}`,
194
+ suggestion: fileName.charAt(0).toUpperCase() + fileName.slice(1).replace(/[-_](.)/g, (_, c) => c.toUpperCase()),
195
+ };
196
+ }
197
+ break;
198
+
199
+ case 'camelCase':
200
+ // 첫 글자 소문자, 언더스코어/하이픈 없음
201
+ if (!/^[a-z][a-zA-Z0-9]*$/.test(fileName)) {
202
+ return {
203
+ valid: false,
204
+ message: `camelCase 위반: ${fileName}`,
205
+ suggestion: fileName.charAt(0).toLowerCase() + fileName.slice(1).replace(/[-_](.)/g, (_, c) => c.toUpperCase()),
206
+ };
207
+ }
208
+ break;
209
+
210
+ case 'snake_case':
211
+ // 소문자와 언더스코어만
212
+ if (!/^[a-z][a-z0-9_]*$/.test(fileName)) {
213
+ return {
214
+ valid: false,
215
+ message: `snake_case 위반: ${fileName}`,
216
+ suggestion: fileName.replace(/([A-Z])/g, '_$1').toLowerCase().replace(/^_/, ''),
217
+ };
218
+ }
219
+ break;
220
+
221
+ case 'kebab-case':
222
+ // 소문자와 하이픈만
223
+ if (!/^[a-z][a-z0-9-]*$/.test(fileName)) {
224
+ return {
225
+ valid: false,
226
+ message: `kebab-case 위반: ${fileName}`,
227
+ suggestion: fileName.replace(/([A-Z])/g, '-$1').toLowerCase().replace(/^-/, ''),
228
+ };
229
+ }
230
+ break;
231
+ }
232
+
233
+ return { valid: true };
234
+ }
235
+
236
+ /**
237
+ * Entity 파일에서 필수 컬럼 검증
238
+ */
239
+ function validateRequiredColumns(entityContent, filePath, requiredColumns) {
240
+ const violations = [];
241
+
242
+ for (const column of requiredColumns) {
243
+ // @Column 데코레이터와 함께 컬럼명 찾기
244
+ const columnRegex = new RegExp(`@Column[^)]*\\)[\\s\\S]*?${column}\\s*[:\\?]`, 'i');
245
+ const propertyRegex = new RegExp(`${column}\\s*[:\\?]`, 'i');
246
+
247
+ if (!columnRegex.test(entityContent) && !propertyRegex.test(entityContent)) {
248
+ violations.push({
249
+ file: filePath,
250
+ message: `필수 컬럼 누락: ${column}`,
251
+ suggestion: `@Column()\n ${column}: Date;`,
252
+ });
253
+ }
254
+ }
255
+
256
+ return violations;
257
+ }
258
+
259
+ /**
260
+ * 파일 목록 재귀 수집
261
+ */
262
+ function collectFiles(dirPath, extensions = ['.ts', '.tsx', '.js', '.jsx']) {
263
+ const files = [];
264
+
265
+ if (!fs.existsSync(dirPath)) {
266
+ return files;
267
+ }
268
+
269
+ const entries = fs.readdirSync(dirPath, { withFileTypes: true });
270
+
271
+ for (const entry of entries) {
272
+ const fullPath = path.join(dirPath, entry.name);
273
+
274
+ // 제외할 폴더
275
+ if (entry.isDirectory()) {
276
+ if (['node_modules', '.git', 'dist', 'build', '.next'].includes(entry.name)) {
277
+ continue;
278
+ }
279
+ files.push(...collectFiles(fullPath, extensions));
280
+ } else if (entry.isFile()) {
281
+ const ext = path.extname(entry.name);
282
+ if (extensions.includes(ext)) {
283
+ files.push(fullPath);
284
+ }
285
+ }
286
+ }
287
+
288
+ return files;
289
+ }
290
+
291
+ /**
292
+ * docuking_validate 핸들러
293
+ */
294
+ export async function handleValidate(args) {
295
+ const { localPath, policy, targetPath, autoFix = false } = args;
296
+
297
+ if (!localPath) {
298
+ return {
299
+ content: [{
300
+ type: 'text',
301
+ text: '오류: localPath가 필요합니다.',
302
+ }],
303
+ };
304
+ }
305
+
306
+ // 정책 파일 경로 (우선순위: .claude/rules/local/ > xx_Policy/)
307
+ const localRulesPath = path.join(localPath, '.claude', 'rules', 'local');
308
+ const policyFolderPath = path.join(localPath, 'xx_Policy');
309
+
310
+ // 정책 파일 수집
311
+ const policyFiles = [];
312
+
313
+ // .claude/rules/local/ 에서 정책 파일 찾기
314
+ if (fs.existsSync(localRulesPath)) {
315
+ const files = fs.readdirSync(localRulesPath).filter(f => f.endsWith('.md'));
316
+ files.forEach(f => {
317
+ policyFiles.push({
318
+ name: f,
319
+ path: path.join(localRulesPath, f),
320
+ source: 'local_rules',
321
+ });
322
+ });
323
+ }
324
+
325
+ // xx_Policy/ 에서 정책 파일 찾기
326
+ if (fs.existsSync(policyFolderPath)) {
327
+ const files = fs.readdirSync(policyFolderPath).filter(f => f.endsWith('.md'));
328
+ files.forEach(f => {
329
+ // 이미 local_rules에 있으면 스킵
330
+ if (!policyFiles.find(p => p.name === f)) {
331
+ policyFiles.push({
332
+ name: f,
333
+ path: path.join(policyFolderPath, f),
334
+ source: '_Policy',
335
+ });
336
+ }
337
+ });
338
+ }
339
+
340
+ if (policyFiles.length === 0) {
341
+ return {
342
+ content: [{
343
+ type: 'text',
344
+ text: `정책 파일을 찾을 수 없습니다.
345
+
346
+ 검색 위치:
347
+ - ${localRulesPath}
348
+ - ${policyFolderPath}
349
+
350
+ 정책 파일을 xx_Policy/ 폴더에 작성하거나,
351
+ Pull을 실행하여 킹캐스트로 정책을 로컬화하세요.`,
352
+ }],
353
+ };
354
+ }
355
+
356
+ // 특정 정책만 검증
357
+ let targetPolicies = policyFiles;
358
+ if (policy) {
359
+ targetPolicies = policyFiles.filter(p => p.name.includes(policy));
360
+ if (targetPolicies.length === 0) {
361
+ return {
362
+ content: [{
363
+ type: 'text',
364
+ text: `정책 파일을 찾을 수 없습니다: ${policy}
365
+
366
+ 사용 가능한 정책:
367
+ ${policyFiles.map(p => `- ${p.name}`).join('\n')}`,
368
+ }],
369
+ };
370
+ }
371
+ }
372
+
373
+ // 규칙 추출
374
+ const allRules = [];
375
+ for (const policyFile of targetPolicies) {
376
+ try {
377
+ const content = fs.readFileSync(policyFile.path, 'utf-8');
378
+ const rules = extractRulesFromPolicy(content, policyFile.name);
379
+ allRules.push(...rules);
380
+ } catch (e) {
381
+ console.error(`[KingValidate] 정책 파일 읽기 실패: ${policyFile.path} - ${e.message}`);
382
+ }
383
+ }
384
+
385
+ if (allRules.length === 0) {
386
+ return {
387
+ content: [{
388
+ type: 'text',
389
+ text: `정책 파일에서 검증 가능한 규칙을 찾을 수 없습니다.
390
+
391
+ 검사한 정책:
392
+ ${targetPolicies.map(p => `- ${p.name}`).join('\n')}
393
+
394
+ 💡 정책 파일에 다음 형식으로 규칙을 작성하세요:
395
+ - \`폴더명/\` - 필수 폴더
396
+ - 파일명: PascalCase
397
+ - 필수 컬럼: \`createdAt\`, \`updatedAt\`
398
+ - 금지: \`any\` 타입 사용`,
399
+ }],
400
+ };
401
+ }
402
+
403
+ // 검증 대상 파일 수집
404
+ const searchPath = targetPath ? path.join(localPath, targetPath) : localPath;
405
+ const codeFiles = collectFiles(searchPath);
406
+
407
+ // 검증 실행
408
+ const violations = [];
409
+ const passed = [];
410
+
411
+ // 폴더 존재 검증
412
+ const folderRules = allRules.filter(r => r.type === 'folder_exists');
413
+ for (const rule of folderRules) {
414
+ const result = validateFolderExists(localPath, rule.pattern);
415
+ if (!result.valid) {
416
+ violations.push({
417
+ policy: rule.policy,
418
+ type: 'folder',
419
+ message: result.message,
420
+ suggestion: result.suggestion,
421
+ });
422
+ } else {
423
+ passed.push({
424
+ policy: rule.policy,
425
+ type: 'folder',
426
+ message: `폴더 존재: ${rule.pattern}`,
427
+ });
428
+ }
429
+ }
430
+
431
+ // 명명 규칙 검증
432
+ const namingRules = allRules.filter(r => r.type === 'naming_convention');
433
+ for (const file of codeFiles) {
434
+ for (const rule of namingRules) {
435
+ // 타겟에 맞는 파일인지 확인 (예: "컴포넌트 파일명" → .tsx 파일)
436
+ const ext = path.extname(file);
437
+ const isComponent = (ext === '.tsx' || ext === '.jsx') &&
438
+ (file.includes('components') || file.includes('Components'));
439
+
440
+ if (rule.target?.includes('컴포넌트') && isComponent) {
441
+ const result = validateNamingConvention(file, rule.convention);
442
+ if (!result.valid) {
443
+ violations.push({
444
+ policy: rule.policy,
445
+ type: 'naming',
446
+ file: path.relative(localPath, file),
447
+ message: result.message,
448
+ suggestion: result.suggestion,
449
+ });
450
+ }
451
+ }
452
+ }
453
+ }
454
+
455
+ // 필수 컬럼 검증 (Entity 파일)
456
+ const columnRules = allRules.filter(r => r.type === 'required_column');
457
+ if (columnRules.length > 0) {
458
+ const requiredColumns = columnRules.map(r => r.column);
459
+ const entityFiles = codeFiles.filter(f =>
460
+ f.includes('entity') || f.includes('Entity') || f.includes('entities')
461
+ );
462
+
463
+ for (const entityFile of entityFiles) {
464
+ try {
465
+ const content = fs.readFileSync(entityFile, 'utf-8');
466
+ const columnViolations = validateRequiredColumns(content, entityFile, requiredColumns);
467
+ columnViolations.forEach(v => {
468
+ violations.push({
469
+ policy: columnRules[0].policy,
470
+ type: 'column',
471
+ file: path.relative(localPath, v.file),
472
+ message: v.message,
473
+ suggestion: v.suggestion,
474
+ });
475
+ });
476
+ } catch (e) {
477
+ console.error(`[KingValidate] Entity 파일 읽기 실패: ${entityFile}`);
478
+ }
479
+ }
480
+ }
481
+
482
+ // 결과 포맷팅
483
+ let resultText = `# 킹밸리데이트 결과
484
+
485
+ 검사 대상: ${targetPath || '전체'}
486
+ 정책 파일: ${targetPolicies.length}개
487
+ 추출된 규칙: ${allRules.length}개
488
+ 검사 파일: ${codeFiles.length}개
489
+
490
+ `;
491
+
492
+ if (violations.length === 0) {
493
+ resultText += `## ✅ 모든 검증 통과!
494
+
495
+ ${passed.length}개 규칙을 검증했습니다.`;
496
+ } else {
497
+ resultText += `## ❌ 위반 발견: ${violations.length}건
498
+
499
+ `;
500
+
501
+ // 정책별로 그룹화
502
+ const byPolicy = {};
503
+ violations.forEach(v => {
504
+ if (!byPolicy[v.policy]) {
505
+ byPolicy[v.policy] = [];
506
+ }
507
+ byPolicy[v.policy].push(v);
508
+ });
509
+
510
+ for (const [policyName, policyViolations] of Object.entries(byPolicy)) {
511
+ resultText += `### [위반] ${policyName}\n`;
512
+ policyViolations.forEach(v => {
513
+ if (v.file) {
514
+ resultText += ` - ${v.file} - ${v.message}\n`;
515
+ } else {
516
+ resultText += ` - ${v.message}\n`;
517
+ }
518
+ if (v.suggestion) {
519
+ resultText += ` 💡 제안: ${v.suggestion}\n`;
520
+ }
521
+ });
522
+ resultText += '\n';
523
+ }
524
+ }
525
+
526
+ // 검증된 규칙 요약
527
+ resultText += `\n## 검증된 규칙
528
+
529
+ `;
530
+ const ruleTypes = {};
531
+ allRules.forEach(r => {
532
+ ruleTypes[r.type] = (ruleTypes[r.type] || 0) + 1;
533
+ });
534
+ for (const [type, count] of Object.entries(ruleTypes)) {
535
+ resultText += `- ${type}: ${count}개\n`;
536
+ }
537
+
538
+ return {
539
+ content: [{
540
+ type: 'text',
541
+ text: resultText,
542
+ }],
543
+ };
544
+ }