@mandujs/core 0.9.31 → 0.9.38

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,806 @@
1
+ /**
2
+ * Mandu Guard AST Analyzer
3
+ *
4
+ * TypeScript AST 기반 정밀 분석
5
+ *
6
+ * 정규식보다 정확한 import 추출
7
+ * - 주석 내 import 무시
8
+ * - 문자열 내 import 무시
9
+ * - 복잡한 멀티라인 import 처리
10
+ * - Type-only import 구분
11
+ */
12
+
13
+ import type { ImportInfo } from "./types";
14
+
15
+ // ═══════════════════════════════════════════════════════════════════════════
16
+ // Token Types
17
+ // ═══════════════════════════════════════════════════════════════════════════
18
+
19
+ type TokenType =
20
+ | "import"
21
+ | "export"
22
+ | "from"
23
+ | "require"
24
+ | "string"
25
+ | "identifier"
26
+ | "punctuation"
27
+ | "keyword"
28
+ | "comment"
29
+ | "whitespace"
30
+ | "newline"
31
+ | "eof";
32
+
33
+ interface Token {
34
+ type: TokenType;
35
+ value: string;
36
+ start: number;
37
+ end: number;
38
+ line: number;
39
+ column: number;
40
+ }
41
+
42
+ // ═══════════════════════════════════════════════════════════════════════════
43
+ // Lexer (Tokenizer)
44
+ // ═══════════════════════════════════════════════════════════════════════════
45
+
46
+ /**
47
+ * 간단한 TypeScript/JavaScript 토크나이저
48
+ */
49
+ function tokenize(content: string): Token[] {
50
+ const tokens: Token[] = [];
51
+ let pos = 0;
52
+ let line = 1;
53
+ let column = 1;
54
+
55
+ const keywords = new Set([
56
+ "import",
57
+ "export",
58
+ "from",
59
+ "as",
60
+ "type",
61
+ "typeof",
62
+ "const",
63
+ "let",
64
+ "var",
65
+ "function",
66
+ "class",
67
+ "interface",
68
+ "enum",
69
+ "await",
70
+ "async",
71
+ "default",
72
+ "require",
73
+ ]);
74
+
75
+ while (pos < content.length) {
76
+ const start = pos;
77
+ const startLine = line;
78
+ const startColumn = column;
79
+ let char = content[pos];
80
+
81
+ // Newline
82
+ if (char === "\n") {
83
+ pos++;
84
+ line++;
85
+ column = 1;
86
+ tokens.push({
87
+ type: "newline",
88
+ value: "\n",
89
+ start,
90
+ end: pos,
91
+ line: startLine,
92
+ column: startColumn,
93
+ });
94
+ continue;
95
+ }
96
+
97
+ // Whitespace
98
+ if (/\s/.test(char)) {
99
+ while (pos < content.length && /\s/.test(content[pos]) && content[pos] !== "\n") {
100
+ pos++;
101
+ column++;
102
+ }
103
+ tokens.push({
104
+ type: "whitespace",
105
+ value: content.slice(start, pos),
106
+ start,
107
+ end: pos,
108
+ line: startLine,
109
+ column: startColumn,
110
+ });
111
+ continue;
112
+ }
113
+
114
+ // Single-line comment
115
+ if (char === "/" && content[pos + 1] === "/") {
116
+ while (pos < content.length && content[pos] !== "\n") {
117
+ pos++;
118
+ column++;
119
+ }
120
+ tokens.push({
121
+ type: "comment",
122
+ value: content.slice(start, pos),
123
+ start,
124
+ end: pos,
125
+ line: startLine,
126
+ column: startColumn,
127
+ });
128
+ continue;
129
+ }
130
+
131
+ // Multi-line comment
132
+ if (char === "/" && content[pos + 1] === "*") {
133
+ pos += 2;
134
+ column += 2;
135
+ while (pos < content.length && !(content[pos] === "*" && content[pos + 1] === "/")) {
136
+ if (content[pos] === "\n") {
137
+ line++;
138
+ column = 1;
139
+ } else {
140
+ column++;
141
+ }
142
+ pos++;
143
+ }
144
+ pos += 2;
145
+ column += 2;
146
+ tokens.push({
147
+ type: "comment",
148
+ value: content.slice(start, pos),
149
+ start,
150
+ end: pos,
151
+ line: startLine,
152
+ column: startColumn,
153
+ });
154
+ continue;
155
+ }
156
+
157
+ // String (single quote)
158
+ if (char === "'") {
159
+ pos++;
160
+ column++;
161
+ while (pos < content.length && content[pos] !== "'") {
162
+ if (content[pos] === "\\") {
163
+ pos++;
164
+ column++;
165
+ }
166
+ if (content[pos] === "\n") {
167
+ line++;
168
+ column = 1;
169
+ } else {
170
+ column++;
171
+ }
172
+ pos++;
173
+ }
174
+ pos++; // closing quote
175
+ column++;
176
+ tokens.push({
177
+ type: "string",
178
+ value: content.slice(start, pos),
179
+ start,
180
+ end: pos,
181
+ line: startLine,
182
+ column: startColumn,
183
+ });
184
+ continue;
185
+ }
186
+
187
+ // String (double quote)
188
+ if (char === '"') {
189
+ pos++;
190
+ column++;
191
+ while (pos < content.length && content[pos] !== '"') {
192
+ if (content[pos] === "\\") {
193
+ pos++;
194
+ column++;
195
+ }
196
+ if (content[pos] === "\n") {
197
+ line++;
198
+ column = 1;
199
+ } else {
200
+ column++;
201
+ }
202
+ pos++;
203
+ }
204
+ pos++; // closing quote
205
+ column++;
206
+ tokens.push({
207
+ type: "string",
208
+ value: content.slice(start, pos),
209
+ start,
210
+ end: pos,
211
+ line: startLine,
212
+ column: startColumn,
213
+ });
214
+ continue;
215
+ }
216
+
217
+ // Template literal (backtick) - simplified
218
+ if (char === "`") {
219
+ pos++;
220
+ column++;
221
+ while (pos < content.length && content[pos] !== "`") {
222
+ if (content[pos] === "\\") {
223
+ pos++;
224
+ column++;
225
+ }
226
+ if (content[pos] === "\n") {
227
+ line++;
228
+ column = 1;
229
+ } else {
230
+ column++;
231
+ }
232
+ pos++;
233
+ }
234
+ pos++; // closing backtick
235
+ column++;
236
+ tokens.push({
237
+ type: "string",
238
+ value: content.slice(start, pos),
239
+ start,
240
+ end: pos,
241
+ line: startLine,
242
+ column: startColumn,
243
+ });
244
+ continue;
245
+ }
246
+
247
+ // Punctuation
248
+ if (/[{}()\[\];,.*]/.test(char)) {
249
+ pos++;
250
+ column++;
251
+ tokens.push({
252
+ type: "punctuation",
253
+ value: char,
254
+ start,
255
+ end: pos,
256
+ line: startLine,
257
+ column: startColumn,
258
+ });
259
+ continue;
260
+ }
261
+
262
+ // Identifier or keyword
263
+ if (/[a-zA-Z_$]/.test(char)) {
264
+ while (pos < content.length && /[a-zA-Z0-9_$]/.test(content[pos])) {
265
+ pos++;
266
+ column++;
267
+ }
268
+ const value = content.slice(start, pos);
269
+ const type: TokenType = keywords.has(value)
270
+ ? value === "import"
271
+ ? "import"
272
+ : value === "from"
273
+ ? "from"
274
+ : value === "require"
275
+ ? "require"
276
+ : "keyword"
277
+ : "identifier";
278
+
279
+ tokens.push({
280
+ type,
281
+ value,
282
+ start,
283
+ end: pos,
284
+ line: startLine,
285
+ column: startColumn,
286
+ });
287
+ continue;
288
+ }
289
+
290
+ // Skip other characters
291
+ pos++;
292
+ column++;
293
+ }
294
+
295
+ tokens.push({
296
+ type: "eof",
297
+ value: "",
298
+ start: pos,
299
+ end: pos,
300
+ line,
301
+ column,
302
+ });
303
+
304
+ return tokens;
305
+ }
306
+
307
+ // ═══════════════════════════════════════════════════════════════════════════
308
+ // AST Import Extraction
309
+ // ═══════════════════════════════════════════════════════════════════════════
310
+
311
+ /**
312
+ * AST 기반 import 추출
313
+ *
314
+ * 정규식보다 정확하게 import 문을 추출
315
+ */
316
+ export function extractImportsAST(content: string): ImportInfo[] {
317
+ const tokens = tokenize(content);
318
+ const imports: ImportInfo[] = [];
319
+
320
+ let i = 0;
321
+
322
+ // Skip whitespace, newlines, comments
323
+ const skip = () => {
324
+ while (
325
+ i < tokens.length &&
326
+ (tokens[i].type === "whitespace" ||
327
+ tokens[i].type === "newline" ||
328
+ tokens[i].type === "comment")
329
+ ) {
330
+ i++;
331
+ }
332
+ };
333
+
334
+ // Get current token
335
+ const current = () => tokens[i];
336
+
337
+ // Check if current token matches
338
+ const is = (type: TokenType, value?: string) => {
339
+ const t = current();
340
+ return t && t.type === type && (value === undefined || t.value === value);
341
+ };
342
+
343
+ // Consume token if matches
344
+ const consume = (type: TokenType, value?: string) => {
345
+ if (is(type, value)) {
346
+ const t = current();
347
+ i++;
348
+ return t;
349
+ }
350
+ return null;
351
+ };
352
+
353
+ while (i < tokens.length && current().type !== "eof") {
354
+ skip();
355
+
356
+ // Static import: import ... from '...'
357
+ // Dynamic import: import('...')
358
+ if (is("import")) {
359
+ const importToken = consume("import")!;
360
+ skip();
361
+
362
+ // Dynamic import: import('...')
363
+ if (is("punctuation", "(")) {
364
+ consume("punctuation", "(");
365
+ skip();
366
+ if (is("string")) {
367
+ const pathToken = consume("string")!;
368
+ const path = pathToken.value.slice(1, -1);
369
+ skip();
370
+ consume("punctuation", ")");
371
+
372
+ imports.push({
373
+ statement: `import('${path}')`,
374
+ path,
375
+ line: importToken.line,
376
+ column: importToken.column,
377
+ type: "dynamic",
378
+ });
379
+ }
380
+ continue;
381
+ }
382
+
383
+ let namedImports: string[] | undefined;
384
+ let defaultImport: string | undefined;
385
+ let namespaceImport: string | undefined;
386
+ let isTypeOnly = false;
387
+
388
+ // Check for type-only import
389
+ if (is("keyword", "type")) {
390
+ consume("keyword", "type");
391
+ skip();
392
+ isTypeOnly = true;
393
+ }
394
+
395
+ // Side-effect import: import '...'
396
+ if (is("string")) {
397
+ const pathToken = consume("string")!;
398
+ const path = pathToken.value.slice(1, -1); // Remove quotes
399
+
400
+ imports.push({
401
+ statement: content.slice(importToken.start, pathToken.end),
402
+ path,
403
+ line: importToken.line,
404
+ column: importToken.column,
405
+ type: "static",
406
+ });
407
+ continue;
408
+ }
409
+
410
+ // Default import: import X from '...'
411
+ if (is("identifier")) {
412
+ defaultImport = consume("identifier")!.value;
413
+ skip();
414
+
415
+ // import X, { ... } from '...'
416
+ if (is("punctuation", ",")) {
417
+ consume("punctuation", ",");
418
+ skip();
419
+ }
420
+ }
421
+
422
+ // Namespace import: import * as X from '...'
423
+ if (is("punctuation", "*")) {
424
+ consume("punctuation", "*");
425
+ skip();
426
+ if (is("keyword", "as")) {
427
+ consume("keyword", "as");
428
+ skip();
429
+ if (is("identifier")) {
430
+ namespaceImport = consume("identifier")!.value;
431
+ skip();
432
+ }
433
+ }
434
+ }
435
+
436
+ // Named imports: import { X, Y } from '...'
437
+ if (is("punctuation", "{")) {
438
+ consume("punctuation", "{");
439
+ skip();
440
+ namedImports = [];
441
+
442
+ while (!is("punctuation", "}") && !is("eof")) {
443
+ if (is("identifier") || is("keyword")) {
444
+ const name = current().value;
445
+ i++;
446
+ skip();
447
+
448
+ // Handle 'as' alias
449
+ if (is("keyword", "as")) {
450
+ consume("keyword", "as");
451
+ skip();
452
+ if (is("identifier")) {
453
+ consume("identifier");
454
+ skip();
455
+ }
456
+ }
457
+
458
+ namedImports.push(name);
459
+ }
460
+
461
+ if (is("punctuation", ",")) {
462
+ consume("punctuation", ",");
463
+ skip();
464
+ } else {
465
+ break;
466
+ }
467
+ }
468
+
469
+ consume("punctuation", "}");
470
+ skip();
471
+ }
472
+
473
+ // from '...'
474
+ if (is("from")) {
475
+ consume("from");
476
+ skip();
477
+
478
+ if (is("string")) {
479
+ const pathToken = consume("string")!;
480
+ const path = pathToken.value.slice(1, -1);
481
+
482
+ imports.push({
483
+ statement: content.slice(importToken.start, pathToken.end),
484
+ path,
485
+ line: importToken.line,
486
+ column: importToken.column,
487
+ type: "static",
488
+ namedImports: namedImports?.length ? namedImports : undefined,
489
+ defaultImport,
490
+ });
491
+ }
492
+ }
493
+
494
+ continue;
495
+ }
496
+
497
+ // require('...')
498
+ if (is("require")) {
499
+ const requireToken = consume("require")!;
500
+ skip();
501
+
502
+ if (is("punctuation", "(")) {
503
+ consume("punctuation", "(");
504
+ skip();
505
+
506
+ if (is("string")) {
507
+ const pathToken = consume("string")!;
508
+ const path = pathToken.value.slice(1, -1);
509
+ skip();
510
+ consume("punctuation", ")");
511
+
512
+ imports.push({
513
+ statement: `require('${path}')`,
514
+ path,
515
+ line: requireToken.line,
516
+ column: requireToken.column,
517
+ type: "require",
518
+ });
519
+ }
520
+ }
521
+ continue;
522
+ }
523
+
524
+ // Move to next token
525
+ i++;
526
+ }
527
+
528
+ return imports;
529
+ }
530
+
531
+ // ═══════════════════════════════════════════════════════════════════════════
532
+ // Export Analysis
533
+ // ═══════════════════════════════════════════════════════════════════════════
534
+
535
+ /**
536
+ * Export 정보
537
+ */
538
+ export interface ExportInfo {
539
+ /** Export 유형 */
540
+ type: "named" | "default" | "all" | "type";
541
+ /** Export 이름 (named의 경우) */
542
+ name?: string;
543
+ /** 원본 이름 (as 사용 시) */
544
+ originalName?: string;
545
+ /** re-export 소스 */
546
+ from?: string;
547
+ /** 라인 번호 */
548
+ line: number;
549
+ }
550
+
551
+ /**
552
+ * Export 문 추출
553
+ */
554
+ export function extractExportsAST(content: string): ExportInfo[] {
555
+ const tokens = tokenize(content);
556
+ const exports: ExportInfo[] = [];
557
+
558
+ let i = 0;
559
+
560
+ const skip = () => {
561
+ while (
562
+ i < tokens.length &&
563
+ (tokens[i].type === "whitespace" ||
564
+ tokens[i].type === "newline" ||
565
+ tokens[i].type === "comment")
566
+ ) {
567
+ i++;
568
+ }
569
+ };
570
+
571
+ const current = () => tokens[i];
572
+ const is = (type: TokenType, value?: string) => {
573
+ const t = current();
574
+ return t && t.type === type && (value === undefined || t.value === value);
575
+ };
576
+ const consume = (type: TokenType, value?: string) => {
577
+ if (is(type, value)) {
578
+ const t = current();
579
+ i++;
580
+ return t;
581
+ }
582
+ return null;
583
+ };
584
+
585
+ while (i < tokens.length && current().type !== "eof") {
586
+ skip();
587
+
588
+ if (is("keyword", "export")) {
589
+ const exportToken = consume("keyword", "export")!;
590
+ skip();
591
+
592
+ // export default
593
+ if (is("keyword", "default")) {
594
+ consume("keyword", "default");
595
+ exports.push({
596
+ type: "default",
597
+ line: exportToken.line,
598
+ });
599
+ continue;
600
+ }
601
+
602
+ // export type
603
+ if (is("keyword", "type")) {
604
+ consume("keyword", "type");
605
+ skip();
606
+
607
+ if (is("punctuation", "{")) {
608
+ // export type { ... }
609
+ consume("punctuation", "{");
610
+ skip();
611
+
612
+ while (!is("punctuation", "}") && !is("eof")) {
613
+ if (is("identifier")) {
614
+ const name = consume("identifier")!.value;
615
+ skip();
616
+
617
+ let originalName: string | undefined;
618
+ if (is("keyword", "as")) {
619
+ consume("keyword", "as");
620
+ skip();
621
+ originalName = name;
622
+ if (is("identifier")) {
623
+ consume("identifier");
624
+ skip();
625
+ }
626
+ }
627
+
628
+ exports.push({
629
+ type: "type",
630
+ name,
631
+ originalName,
632
+ line: exportToken.line,
633
+ });
634
+ }
635
+
636
+ if (is("punctuation", ",")) {
637
+ consume("punctuation", ",");
638
+ skip();
639
+ } else {
640
+ break;
641
+ }
642
+ }
643
+ }
644
+ continue;
645
+ }
646
+
647
+ // export * from '...'
648
+ if (is("punctuation", "*")) {
649
+ consume("punctuation", "*");
650
+ skip();
651
+
652
+ let from: string | undefined;
653
+ if (is("from")) {
654
+ consume("from");
655
+ skip();
656
+ if (is("string")) {
657
+ from = consume("string")!.value.slice(1, -1);
658
+ }
659
+ }
660
+
661
+ exports.push({
662
+ type: "all",
663
+ from,
664
+ line: exportToken.line,
665
+ });
666
+ continue;
667
+ }
668
+
669
+ // export { ... }
670
+ if (is("punctuation", "{")) {
671
+ consume("punctuation", "{");
672
+ skip();
673
+
674
+ const names: { name: string; originalName?: string }[] = [];
675
+
676
+ while (!is("punctuation", "}") && !is("eof")) {
677
+ if (is("identifier")) {
678
+ const name = consume("identifier")!.value;
679
+ skip();
680
+
681
+ let originalName: string | undefined;
682
+ if (is("keyword", "as")) {
683
+ consume("keyword", "as");
684
+ skip();
685
+ originalName = name;
686
+ if (is("identifier")) {
687
+ consume("identifier");
688
+ skip();
689
+ }
690
+ }
691
+
692
+ names.push({ name, originalName });
693
+ }
694
+
695
+ if (is("punctuation", ",")) {
696
+ consume("punctuation", ",");
697
+ skip();
698
+ } else {
699
+ break;
700
+ }
701
+ }
702
+
703
+ consume("punctuation", "}");
704
+ skip();
705
+
706
+ let from: string | undefined;
707
+ if (is("from")) {
708
+ consume("from");
709
+ skip();
710
+ if (is("string")) {
711
+ from = consume("string")!.value.slice(1, -1);
712
+ }
713
+ }
714
+
715
+ for (const { name, originalName } of names) {
716
+ exports.push({
717
+ type: "named",
718
+ name,
719
+ originalName,
720
+ from,
721
+ line: exportToken.line,
722
+ });
723
+ }
724
+ continue;
725
+ }
726
+
727
+ // export const/let/var/function/class
728
+ if (
729
+ is("keyword", "const") ||
730
+ is("keyword", "let") ||
731
+ is("keyword", "var") ||
732
+ is("keyword", "function") ||
733
+ is("keyword", "class") ||
734
+ is("keyword", "interface") ||
735
+ is("keyword", "enum") ||
736
+ is("keyword", "async")
737
+ ) {
738
+ consume("keyword");
739
+ skip();
740
+
741
+ // async function
742
+ if (is("keyword", "function")) {
743
+ consume("keyword", "function");
744
+ skip();
745
+ }
746
+
747
+ if (is("identifier")) {
748
+ const name = consume("identifier")!.value;
749
+ exports.push({
750
+ type: "named",
751
+ name,
752
+ line: exportToken.line,
753
+ });
754
+ }
755
+ continue;
756
+ }
757
+ }
758
+
759
+ i++;
760
+ }
761
+
762
+ return exports;
763
+ }
764
+
765
+ // ═══════════════════════════════════════════════════════════════════════════
766
+ // Module Analysis
767
+ // ═══════════════════════════════════════════════════════════════════════════
768
+
769
+ /**
770
+ * 모듈 분석 결과
771
+ */
772
+ export interface ModuleAnalysis {
773
+ /** Import 목록 */
774
+ imports: ImportInfo[];
775
+ /** Export 목록 */
776
+ exports: ExportInfo[];
777
+ /** Public API 여부 (index 파일) */
778
+ isPublicAPI: boolean;
779
+ /** 순수 타입 모듈 여부 */
780
+ isTypeOnly: boolean;
781
+ }
782
+
783
+ /**
784
+ * 모듈 전체 분석
785
+ */
786
+ export function analyzeModuleAST(content: string, filePath: string): ModuleAnalysis {
787
+ const imports = extractImportsAST(content);
788
+ const exports = extractExportsAST(content);
789
+
790
+ const isPublicAPI =
791
+ filePath.endsWith("/index.ts") ||
792
+ filePath.endsWith("/index.tsx") ||
793
+ filePath.endsWith("/index.js");
794
+
795
+ // 타입만 있는 모듈인지 확인
796
+ const hasRuntimeExport = exports.some((e) => e.type !== "type");
797
+ const hasRuntimeImport = imports.some((i) => !i.statement.includes("import type"));
798
+ const isTypeOnly = !hasRuntimeExport && !hasRuntimeImport && exports.length > 0;
799
+
800
+ return {
801
+ imports,
802
+ exports,
803
+ isPublicAPI,
804
+ isTypeOnly,
805
+ };
806
+ }