@mandujs/core 0.13.0 → 0.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/README.ko.md +4 -4
  2. package/README.md +653 -653
  3. package/package.json +1 -1
  4. package/src/bundler/build.ts +91 -91
  5. package/src/bundler/css.ts +302 -302
  6. package/src/client/Link.tsx +227 -227
  7. package/src/client/globals.ts +44 -44
  8. package/src/client/hooks.ts +267 -267
  9. package/src/client/index.ts +5 -5
  10. package/src/client/island.ts +8 -8
  11. package/src/client/router.ts +435 -435
  12. package/src/client/runtime.ts +23 -23
  13. package/src/client/serialize.ts +404 -404
  14. package/src/client/window-state.ts +101 -101
  15. package/src/config/mandu.ts +9 -0
  16. package/src/config/validate.ts +12 -0
  17. package/src/config/watcher.ts +311 -311
  18. package/src/constants.ts +40 -40
  19. package/src/content/content-layer.ts +314 -314
  20. package/src/content/content.test.ts +433 -433
  21. package/src/content/data-store.ts +245 -245
  22. package/src/content/digest.ts +133 -133
  23. package/src/content/index.ts +164 -164
  24. package/src/content/loader-context.ts +172 -172
  25. package/src/content/loaders/api.ts +216 -216
  26. package/src/content/loaders/file.ts +169 -169
  27. package/src/content/loaders/glob.ts +252 -252
  28. package/src/content/loaders/index.ts +34 -34
  29. package/src/content/loaders/types.ts +137 -137
  30. package/src/content/meta-store.ts +209 -209
  31. package/src/content/types.ts +282 -282
  32. package/src/content/watcher.ts +135 -135
  33. package/src/contract/client-safe.test.ts +42 -42
  34. package/src/contract/client-safe.ts +114 -114
  35. package/src/contract/client.ts +16 -16
  36. package/src/contract/define.ts +459 -459
  37. package/src/contract/handler.ts +10 -10
  38. package/src/contract/normalize.test.ts +276 -276
  39. package/src/contract/normalize.ts +404 -404
  40. package/src/contract/registry.test.ts +206 -206
  41. package/src/contract/registry.ts +568 -568
  42. package/src/contract/schema.ts +48 -48
  43. package/src/contract/types.ts +58 -58
  44. package/src/contract/validator.ts +32 -32
  45. package/src/devtools/ai/context-builder.ts +375 -375
  46. package/src/devtools/ai/index.ts +25 -25
  47. package/src/devtools/ai/mcp-connector.ts +465 -465
  48. package/src/devtools/client/catchers/error-catcher.ts +327 -327
  49. package/src/devtools/client/catchers/index.ts +18 -18
  50. package/src/devtools/client/catchers/network-proxy.ts +363 -363
  51. package/src/devtools/client/components/index.ts +39 -39
  52. package/src/devtools/client/components/kitchen-root.tsx +362 -362
  53. package/src/devtools/client/components/mandu-character.tsx +241 -241
  54. package/src/devtools/client/components/overlay.tsx +368 -368
  55. package/src/devtools/client/components/panel/errors-panel.tsx +259 -259
  56. package/src/devtools/client/components/panel/guard-panel.tsx +244 -244
  57. package/src/devtools/client/components/panel/index.ts +32 -32
  58. package/src/devtools/client/components/panel/islands-panel.tsx +304 -304
  59. package/src/devtools/client/components/panel/network-panel.tsx +292 -292
  60. package/src/devtools/client/components/panel/panel-container.tsx +259 -259
  61. package/src/devtools/client/filters/context-filters.ts +282 -282
  62. package/src/devtools/client/filters/index.ts +16 -16
  63. package/src/devtools/client/index.ts +63 -63
  64. package/src/devtools/client/persistence.ts +335 -335
  65. package/src/devtools/client/state-manager.ts +478 -478
  66. package/src/devtools/design-tokens.ts +263 -263
  67. package/src/devtools/hook/create-hook.ts +207 -207
  68. package/src/devtools/hook/index.ts +13 -13
  69. package/src/devtools/index.ts +439 -439
  70. package/src/devtools/init.ts +266 -266
  71. package/src/devtools/protocol.ts +237 -237
  72. package/src/devtools/server/index.ts +17 -17
  73. package/src/devtools/server/source-context.ts +444 -444
  74. package/src/devtools/types.ts +319 -319
  75. package/src/devtools/worker/index.ts +25 -25
  76. package/src/devtools/worker/redaction-worker.ts +222 -222
  77. package/src/devtools/worker/worker-manager.ts +409 -409
  78. package/src/error/domains.ts +265 -265
  79. package/src/error/result.ts +46 -46
  80. package/src/error/types.ts +6 -6
  81. package/src/errors/extractor.ts +409 -409
  82. package/src/errors/index.ts +19 -19
  83. package/src/filling/auth.ts +308 -308
  84. package/src/filling/context.ts +24 -1
  85. package/src/filling/deps.ts +238 -238
  86. package/src/filling/index.ts +2 -0
  87. package/src/filling/sse.test.ts +168 -0
  88. package/src/filling/sse.ts +162 -0
  89. package/src/generator/index.ts +3 -3
  90. package/src/guard/analyzer.ts +360 -360
  91. package/src/guard/ast-analyzer.ts +806 -806
  92. package/src/guard/contract-guard.ts +9 -9
  93. package/src/guard/file-type.test.ts +24 -24
  94. package/src/guard/presets/atomic.ts +70 -70
  95. package/src/guard/presets/clean.ts +77 -77
  96. package/src/guard/presets/fsd.ts +79 -79
  97. package/src/guard/presets/hexagonal.ts +68 -68
  98. package/src/guard/presets/index.ts +291 -291
  99. package/src/guard/reporter.ts +445 -445
  100. package/src/guard/rules.ts +12 -12
  101. package/src/guard/statistics.ts +578 -578
  102. package/src/guard/suggestions.ts +358 -358
  103. package/src/guard/types.ts +348 -348
  104. package/src/guard/validator.ts +834 -834
  105. package/src/guard/watcher.ts +404 -404
  106. package/src/index.ts +6 -1
  107. package/src/intent/index.ts +310 -310
  108. package/src/island/index.ts +304 -304
  109. package/src/logging/index.ts +22 -22
  110. package/src/logging/transports.ts +365 -365
  111. package/src/plugins/index.ts +38 -38
  112. package/src/plugins/registry.ts +377 -377
  113. package/src/plugins/types.ts +363 -363
  114. package/src/report/index.ts +1 -1
  115. package/src/router/fs-patterns.ts +387 -387
  116. package/src/router/fs-scanner.ts +497 -497
  117. package/src/runtime/boundary.tsx +232 -232
  118. package/src/runtime/compose.ts +222 -222
  119. package/src/runtime/escape.ts +44 -0
  120. package/src/runtime/lifecycle.ts +381 -381
  121. package/src/runtime/logger.test.ts +345 -345
  122. package/src/runtime/logger.ts +677 -677
  123. package/src/runtime/router.test.ts +476 -476
  124. package/src/runtime/router.ts +105 -105
  125. package/src/runtime/security.ts +155 -155
  126. package/src/runtime/server.ts +257 -0
  127. package/src/runtime/session-key.ts +328 -328
  128. package/src/runtime/ssr.ts +16 -21
  129. package/src/runtime/streaming-ssr.ts +24 -33
  130. package/src/runtime/trace.ts +144 -144
  131. package/src/seo/index.ts +214 -214
  132. package/src/seo/integration/ssr.ts +307 -307
  133. package/src/seo/render/basic.ts +427 -427
  134. package/src/seo/render/index.ts +143 -143
  135. package/src/seo/render/jsonld.ts +539 -539
  136. package/src/seo/render/opengraph.ts +191 -191
  137. package/src/seo/render/robots.ts +116 -116
  138. package/src/seo/render/sitemap.ts +137 -137
  139. package/src/seo/render/twitter.ts +126 -126
  140. package/src/seo/resolve/index.ts +353 -353
  141. package/src/seo/resolve/opengraph.ts +143 -143
  142. package/src/seo/resolve/robots.ts +73 -73
  143. package/src/seo/resolve/title.ts +94 -94
  144. package/src/seo/resolve/twitter.ts +73 -73
  145. package/src/seo/resolve/url.ts +97 -97
  146. package/src/seo/routes/index.ts +290 -290
  147. package/src/seo/types.ts +575 -575
  148. package/src/slot/validator.ts +39 -39
  149. package/src/spec/index.ts +3 -3
  150. package/src/spec/load.ts +76 -76
  151. package/src/spec/lock.ts +56 -56
  152. package/src/utils/bun.ts +8 -8
  153. package/src/utils/lru-cache.ts +75 -75
  154. package/src/utils/safe-io.ts +188 -188
  155. package/src/utils/string-safe.ts +298 -298
@@ -1,834 +1,834 @@
1
- /**
2
- * Mandu Guard Validator
3
- *
4
- * 아키텍처 규칙 검증
5
- */
6
-
7
- import type {
8
- FileAnalysis,
9
- ImportInfo,
10
- LayerDefinition,
11
- Violation,
12
- ViolationType,
13
- Severity,
14
- SeverityConfig,
15
- GuardConfig,
16
- } from "./types";
17
- import { basename, dirname, isAbsolute, relative, resolve } from "path";
18
- import { resolveImportLayer, shouldIgnoreImport } from "./analyzer";
19
- import { generateSmartSuggestions } from "./suggestions";
20
- import { FILE_PATTERNS } from "../router/fs-types";
21
-
22
- // ═══════════════════════════════════════════════════════════════════════════
23
- // Layer Validation
24
- // ═══════════════════════════════════════════════════════════════════════════
25
-
26
- /**
27
- * 레이어 의존성 검증
28
- */
29
- export function validateLayerDependency(
30
- fromLayer: string,
31
- toLayer: string,
32
- layers: LayerDefinition[]
33
- ): boolean {
34
- const fromLayerDef = layers.find((l) => l.name === fromLayer);
35
- if (!fromLayerDef) return true; // 알 수 없는 레이어는 통과
36
-
37
- return fromLayerDef.canImport.includes(toLayer);
38
- }
39
-
40
- /**
41
- * 같은 레이어 내 같은 슬라이스인지 확인
42
- */
43
- export function isSameSlice(
44
- fromSlice: string | undefined,
45
- toSlice: string | undefined
46
- ): boolean {
47
- if (!fromSlice || !toSlice) return false;
48
- return fromSlice === toSlice;
49
- }
50
-
51
- // ═══════════════════════════════════════════════════════════════════════════
52
- // Violation Detection
53
- // ═══════════════════════════════════════════════════════════════════════════
54
-
55
- /**
56
- * 위반 생성
57
- */
58
- export function createViolation(
59
- type: ViolationType,
60
- analysis: FileAnalysis,
61
- importInfo: ImportInfo,
62
- fromLayer: string,
63
- toLayer: string,
64
- layers: LayerDefinition[],
65
- severityConfig: SeverityConfig
66
- ): Violation {
67
- const fromLayerDef = layers.find((l) => l.name === fromLayer);
68
- const allowedLayers = fromLayerDef?.canImport ?? [];
69
-
70
- const severityMap: Record<ViolationType, keyof SeverityConfig> = {
71
- "layer-violation": "layerViolation",
72
- "circular-dependency": "circularDependency",
73
- "cross-slice": "crossSliceDependency",
74
- "deep-nesting": "deepNesting",
75
- "file-type": "fileType",
76
- "invalid-shared-segment": "invalidSharedSegment",
77
- };
78
-
79
- const severity: Severity = severityConfig[severityMap[type]] ?? "error";
80
-
81
- const ruleNames: Record<ViolationType, string> = {
82
- "layer-violation": "Layer Dependency",
83
- "circular-dependency": "Circular Dependency",
84
- "cross-slice": "Cross-Slice Dependency",
85
- "deep-nesting": "Deep Nesting",
86
- "file-type": "TypeScript Only",
87
- "invalid-shared-segment": "Shared Segment",
88
- };
89
-
90
- const ruleDescriptions: Record<ViolationType, string> = {
91
- "layer-violation": `"${fromLayer}" 레이어는 "${toLayer}" 레이어를 import할 수 없습니다`,
92
- "circular-dependency": `순환 의존성이 감지되었습니다: ${fromLayer} ⇄ ${toLayer}`,
93
- "cross-slice": `같은 레이어 내 다른 슬라이스 간 직접 import가 감지되었습니다`,
94
- "deep-nesting": `깊은 경로 import가 감지되었습니다. Public API를 통해 import하세요`,
95
- "file-type": `JS/JSX 파일은 금지됩니다. .ts/.tsx로 변환하세요`,
96
- "invalid-shared-segment": `shared 하위 세그먼트 규칙을 위반했습니다`,
97
- };
98
-
99
- // 스마트 제안 생성
100
- const suggestions = generateSmartSuggestions({
101
- type,
102
- fromLayer,
103
- toLayer,
104
- importPath: importInfo.path,
105
- allowedLayers,
106
- layers,
107
- slice: analysis.slice,
108
- });
109
-
110
- return {
111
- type,
112
- filePath: analysis.filePath,
113
- line: importInfo.line,
114
- column: importInfo.column,
115
- importStatement: importInfo.statement,
116
- importPath: importInfo.path,
117
- fromLayer,
118
- toLayer,
119
- ruleName: ruleNames[type],
120
- ruleDescription: ruleDescriptions[type],
121
- severity,
122
- allowedLayers,
123
- suggestions,
124
- };
125
- }
126
-
127
- function createFileTypeViolation(
128
- analysis: FileAnalysis,
129
- severityConfig: SeverityConfig
130
- ): Violation {
131
- const severity: Severity = severityConfig.fileType ?? "error";
132
- const normalizedPath = analysis.filePath.replace(/\\/g, "/");
133
- const extension = normalizedPath.slice(normalizedPath.lastIndexOf("."));
134
-
135
- return {
136
- type: "file-type",
137
- filePath: analysis.filePath,
138
- line: 1,
139
- column: 1,
140
- importStatement: normalizedPath,
141
- importPath: normalizedPath,
142
- fromLayer: "typescript",
143
- toLayer: extension,
144
- ruleName: "TypeScript Only",
145
- ruleDescription: `JS/JSX 파일은 금지됩니다 (${extension}). .ts/.tsx로 변환하세요`,
146
- severity,
147
- allowedLayers: [],
148
- suggestions: [
149
- "파일 확장자를 .ts 또는 .tsx로 변경하세요",
150
- "필요한 타입을 추가하고 TypeScript로 변환하세요",
151
- ],
152
- };
153
- }
154
-
155
- function createInvalidSharedSegmentViolation(
156
- analysis: FileAnalysis,
157
- severityConfig: SeverityConfig
158
- ): Violation {
159
- const severity: Severity = severityConfig.invalidSharedSegment ?? "error";
160
- const normalizedPath = analysis.filePath.replace(/\\/g, "/");
161
- const marker = "src/shared/";
162
- let segment = "(unknown)";
163
-
164
- const index = normalizedPath.indexOf(marker);
165
- if (index !== -1) {
166
- const rest = normalizedPath.slice(index + marker.length);
167
- segment = rest.split("/")[0] || "(root)";
168
- }
169
-
170
- return {
171
- type: "invalid-shared-segment",
172
- filePath: analysis.filePath,
173
- line: 1,
174
- column: 1,
175
- importStatement: normalizedPath,
176
- importPath: normalizedPath,
177
- fromLayer: "shared",
178
- toLayer: "shared/unsafe",
179
- ruleName: "Shared Segment",
180
- ruleDescription: `src/shared/${segment}는 허용되지 않습니다`,
181
- severity,
182
- allowedLayers: [],
183
- suggestions: [
184
- "허용 경로: src/shared/contracts|schema|types|utils/client|utils/server|env",
185
- "파일을 허용된 shared 하위 폴더로 이동하세요",
186
- ],
187
- };
188
- }
189
-
190
- function createSharedEnvImportViolation(
191
- analysis: FileAnalysis,
192
- importInfo: ImportInfo,
193
- fromLayer: string,
194
- allowedLayers: string[],
195
- severityConfig: SeverityConfig
196
- ): Violation {
197
- const severity: Severity = severityConfig.layerViolation ?? "error";
198
-
199
- return {
200
- type: "layer-violation",
201
- filePath: analysis.filePath,
202
- line: importInfo.line,
203
- column: importInfo.column,
204
- importStatement: importInfo.statement,
205
- importPath: importInfo.path,
206
- fromLayer,
207
- toLayer: "shared/env",
208
- ruleName: "Shared Env (Server-only)",
209
- ruleDescription: "shared/env는 서버 전용입니다. 클라이언트 레이어에서 import할 수 없습니다",
210
- severity,
211
- allowedLayers,
212
- suggestions: [
213
- "환경변수 접근은 src/server 또는 app/api/route.ts에서 처리하세요",
214
- "필요한 값은 서버에서 주입하거나 응답으로 전달하세요",
215
- ],
216
- };
217
- }
218
-
219
- // ═══════════════════════════════════════════════════════════════════════════
220
- // File Validation
221
- // ═══════════════════════════════════════════════════════════════════════════
222
-
223
- /**
224
- * 파일 분석 결과 검증
225
- */
226
- export function validateFileAnalysis(
227
- analysis: FileAnalysis,
228
- layers: LayerDefinition[],
229
- config: GuardConfig
230
- ): Violation[] {
231
- const violations: Violation[] = [];
232
- const severityConfig = config.severity ?? {};
233
- const normalizedPath = analysis.filePath.toLowerCase();
234
-
235
- if (normalizedPath.endsWith(".js") || normalizedPath.endsWith(".jsx")) {
236
- violations.push(createFileTypeViolation(analysis, severityConfig));
237
- }
238
-
239
- if (analysis.layer === "shared/unsafe") {
240
- violations.push(createInvalidSharedSegmentViolation(analysis, severityConfig));
241
- }
242
-
243
- // FS Routes 규칙 검사 (app/ 내부)
244
- violations.push(...validateFsRoutesImports(analysis, layers, config));
245
-
246
- // 파일이 레이어에 속하지 않으면 검사 안 함
247
- if (!analysis.layer) {
248
- return violations;
249
- }
250
-
251
- const fromLayer = analysis.layer;
252
- const fromLayerDef = layers.find((l) => l.name === fromLayer);
253
-
254
- if (!fromLayerDef) {
255
- return violations;
256
- }
257
-
258
- for (const importInfo of analysis.imports) {
259
- const isFsRoutesImport = isFsRoutesImportPath(importInfo.path);
260
- if (shouldIgnoreImport(importInfo.path, config) && !isFsRoutesImport) {
261
- continue;
262
- }
263
-
264
- // Import 레이어 해석
265
- const toLayer = resolveImportLayer(
266
- importInfo.path,
267
- layers,
268
- config.srcDir ?? "src",
269
- analysis.filePath,
270
- analysis.rootDir
271
- );
272
-
273
- if (!toLayer) {
274
- continue; // 레이어를 알 수 없으면 무시
275
- }
276
-
277
- if (toLayer === "shared/env" && fromLayer.startsWith("client/")) {
278
- violations.push(
279
- createSharedEnvImportViolation(
280
- analysis,
281
- importInfo,
282
- fromLayer,
283
- fromLayerDef?.canImport ?? [],
284
- severityConfig
285
- )
286
- );
287
- continue;
288
- }
289
-
290
- // 같은 레이어 내 같은 슬라이스는 허용
291
- if (fromLayer === toLayer) {
292
- // Cross-slice 체크 (같은 레이어 내 다른 슬라이스)
293
- const toSlice = extractSliceFromImport(
294
- importInfo.path,
295
- toLayer,
296
- config.srcDir ?? "src",
297
- analysis.filePath,
298
- analysis.rootDir
299
- );
300
- if (analysis.slice && toSlice && analysis.slice !== toSlice) {
301
- violations.push(
302
- createViolation(
303
- "cross-slice",
304
- analysis,
305
- importInfo,
306
- fromLayer,
307
- toLayer,
308
- layers,
309
- severityConfig
310
- )
311
- );
312
- }
313
- continue;
314
- }
315
-
316
- // 레이어 의존성 검증
317
- if (!validateLayerDependency(fromLayer, toLayer, layers)) {
318
- violations.push(
319
- createViolation(
320
- "layer-violation",
321
- analysis,
322
- importInfo,
323
- fromLayer,
324
- toLayer,
325
- layers,
326
- severityConfig
327
- )
328
- );
329
- }
330
- }
331
-
332
- return violations;
333
- }
334
-
335
- /**
336
- * Import 경로에서 슬라이스 추출
337
- */
338
- function extractSliceFromImport(
339
- importPath: string,
340
- layer: string,
341
- srcDir: string,
342
- fromFile?: string,
343
- rootDir?: string
344
- ): string | undefined {
345
- const normalizedImportPath = importPath.replace(/\\/g, "/");
346
- const normalizedSrcDir = srcDir.replace(/\\/g, "/").replace(/\/$/, "");
347
-
348
- let layerRelative: string | undefined;
349
-
350
- if (normalizedImportPath.startsWith("@/") || normalizedImportPath.startsWith("~/")) {
351
- layerRelative = normalizedImportPath.slice(2);
352
- } else if (
353
- normalizedSrcDir.length > 0 && normalizedSrcDir !== "." &&
354
- (normalizedImportPath === normalizedSrcDir || normalizedImportPath.startsWith(`${normalizedSrcDir}/`))
355
- ) {
356
- layerRelative = normalizedImportPath.startsWith(`${normalizedSrcDir}/`)
357
- ? normalizedImportPath.slice(normalizedSrcDir.length + 1)
358
- : normalizedImportPath;
359
- } else if (normalizedImportPath.startsWith(".")) {
360
- if (!fromFile || !rootDir) {
361
- return undefined;
362
- }
363
-
364
- const absoluteFromFile = isAbsolute(fromFile) ? fromFile : resolve(rootDir, fromFile);
365
- const resolvedPath = resolve(dirname(absoluteFromFile), normalizedImportPath);
366
- const relativeToRoot = relative(rootDir, resolvedPath).replace(/\\/g, "/");
367
-
368
- if (relativeToRoot.startsWith("..") || relativeToRoot.startsWith("../")) {
369
- return undefined;
370
- }
371
-
372
- layerRelative = normalizedSrcDir.length > 0 && normalizedSrcDir !== "." && relativeToRoot.startsWith(`${normalizedSrcDir}/`)
373
- ? relativeToRoot.slice(normalizedSrcDir.length + 1)
374
- : relativeToRoot;
375
- }
376
-
377
- if (!layerRelative) return undefined;
378
-
379
- const parts = layerRelative.split("/");
380
- const layerParts = layer.split("/");
381
- const matchesLayer = parts.slice(0, layerParts.length).join("/") === layer;
382
- if (matchesLayer && parts.length > layerParts.length) {
383
- return parts[layerParts.length];
384
- }
385
-
386
- return undefined;
387
- }
388
-
389
- /**
390
- * FS Routes 규칙 검증 (app/ 내부)
391
- */
392
- function validateFsRoutesImports(
393
- analysis: FileAnalysis,
394
- layers: LayerDefinition[],
395
- config: GuardConfig
396
- ): Violation[] {
397
- const fsRoutesConfig = config.fsRoutes;
398
- if (!fsRoutesConfig) return [];
399
-
400
- const fileType = getFsRouteFileType(analysis);
401
- if (!fileType) return [];
402
-
403
- const violations: Violation[] = [];
404
- const severity = config.severity?.layerViolation ?? "error";
405
- const allowedLayers =
406
- fileType === "page"
407
- ? fsRoutesConfig.pageCanImport
408
- : fileType === "layout"
409
- ? fsRoutesConfig.layoutCanImport
410
- : fsRoutesConfig.routeCanImport;
411
-
412
- for (const importInfo of analysis.imports) {
413
- const isFsRoutesImport = isFsRoutesImportPath(importInfo.path);
414
- if (shouldIgnoreImport(importInfo.path, config) && !isFsRoutesImport) {
415
- continue;
416
- }
417
-
418
- // Rule: page -> page 금지
419
- if (fileType === "page" && fsRoutesConfig.noPageToPage) {
420
- if (resolvesToPageImport(importInfo.path, analysis)) {
421
- violations.push({
422
- type: "layer-violation",
423
- filePath: analysis.filePath,
424
- line: importInfo.line,
425
- column: importInfo.column,
426
- importStatement: importInfo.statement,
427
- importPath: importInfo.path,
428
- fromLayer: "page",
429
- toLayer: "page",
430
- ruleName: "FS Routes Page Import",
431
- ruleDescription: "page.tsx에서 다른 page.tsx import는 금지됩니다",
432
- severity,
433
- allowedLayers: allowedLayers ?? [],
434
- suggestions: [
435
- "공통 UI는 app/ 외부(shared/widgets)로 이동하세요",
436
- "필요한 데이터는 상위 layout에서 주입하세요",
437
- ],
438
- });
439
- }
440
- }
441
-
442
- // Rule: page/layout import 가능한 레이어 제한
443
- if (allowedLayers) {
444
- const toLayer = resolveImportLayer(
445
- importInfo.path,
446
- layers,
447
- config.srcDir ?? "src",
448
- analysis.filePath,
449
- analysis.rootDir
450
- );
451
-
452
- if (toLayer === "shared/env" && fileType !== "route") {
453
- violations.push(
454
- createSharedEnvImportViolation(
455
- analysis,
456
- importInfo,
457
- fileType,
458
- allowedLayers,
459
- config.severity ?? {}
460
- )
461
- );
462
- continue;
463
- }
464
-
465
- if (toLayer && !allowedLayers.includes(toLayer)) {
466
- const fileLabel = fileType === "route" ? "route.ts" : `${fileType}.tsx`;
467
- const suggestions =
468
- fileType === "route"
469
- ? [
470
- `허용 레이어: ${allowedLayers.join(", ")}`,
471
- "서버 로직은 src/server 또는 src/shared로 이동하세요",
472
- ]
473
- : [
474
- `허용 레이어: ${allowedLayers.join(", ")}`,
475
- "클라이언트 로직은 src/client 또는 src/shared로 이동하세요",
476
- ];
477
-
478
- violations.push({
479
- type: "layer-violation",
480
- filePath: analysis.filePath,
481
- line: importInfo.line,
482
- column: importInfo.column,
483
- importStatement: importInfo.statement,
484
- importPath: importInfo.path,
485
- fromLayer: fileType,
486
- toLayer,
487
- ruleName: "FS Routes Import Rule",
488
- ruleDescription: `${fileLabel}는 지정된 레이어만 import 가능합니다`,
489
- severity,
490
- allowedLayers,
491
- suggestions,
492
- });
493
- }
494
- }
495
- }
496
-
497
- return violations;
498
- }
499
-
500
- function getFsRouteFileType(analysis: FileAnalysis): "page" | "layout" | "route" | null {
501
- const normalizedPath = normalizePathValue(
502
- analysis.rootDir
503
- ? relative(
504
- analysis.rootDir,
505
- isAbsolute(analysis.filePath)
506
- ? analysis.filePath
507
- : resolve(analysis.rootDir, analysis.filePath)
508
- )
509
- : analysis.filePath
510
- );
511
-
512
- if (!isFsRoutesPath(normalizedPath)) {
513
- return null;
514
- }
515
-
516
- const fileName = basename(normalizedPath);
517
- if (FILE_PATTERNS.page.test(fileName)) {
518
- return "page";
519
- }
520
- if (FILE_PATTERNS.layout.test(fileName)) {
521
- return "layout";
522
- }
523
- if (FILE_PATTERNS.route.test(fileName)) {
524
- return "route";
525
- }
526
-
527
- return null;
528
- }
529
-
530
- function isFsRoutesPath(normalizedPath: string): boolean {
531
- const cleaned = normalizedPath.replace(/^\.\/+/, "");
532
- const segments = cleaned.split("/");
533
- if (segments.includes("src")) {
534
- return false;
535
- }
536
- return segments.includes("app");
537
- }
538
-
539
- function isFsRoutesImportPath(importPath: string): boolean {
540
- const normalized = importPath.replace(/\\/g, "/").replace(/^\.\/+/, "");
541
- const segments = normalized.split("/");
542
- if (segments.includes("src")) {
543
- return false;
544
- }
545
- return segments.includes("app");
546
- }
547
-
548
- function resolvesToPageImport(importPath: string, analysis: FileAnalysis): boolean {
549
- const normalizedImport = importPath.replace(/\\/g, "/");
550
- const importFileName = basename(normalizedImport);
551
-
552
- if (FILE_PATTERNS.page.test(importFileName) || importFileName === "page") {
553
- if (!analysis.rootDir) {
554
- return isFsRoutesPath(normalizedImport);
555
- }
556
- }
557
-
558
- if (normalizedImport.startsWith(".")) {
559
- const resolvedPath = resolveFsRoutesPath(normalizedImport, analysis);
560
- if (!resolvedPath) return false;
561
-
562
- const resolvedFileName = basename(resolvedPath);
563
- return (
564
- isFsRoutesPath(resolvedPath) &&
565
- (FILE_PATTERNS.page.test(resolvedFileName) || resolvedFileName === "page")
566
- );
567
- }
568
-
569
- if (normalizedImport.startsWith("app/") || normalizedImport.includes("/app/")) {
570
- return FILE_PATTERNS.page.test(importFileName) || importFileName === "page";
571
- }
572
-
573
- if (normalizedImport.startsWith("@/") || normalizedImport.startsWith("~/")) {
574
- const aliasPath = normalizedImport.slice(2);
575
- const aliasFileName = basename(aliasPath);
576
- return (
577
- isFsRoutesPath(aliasPath) &&
578
- (FILE_PATTERNS.page.test(aliasFileName) || aliasFileName === "page")
579
- );
580
- }
581
-
582
- return false;
583
- }
584
-
585
- function resolveFsRoutesPath(importPath: string, analysis: FileAnalysis): string | null {
586
- if (!analysis.rootDir) return null;
587
-
588
- const absoluteFromFile = isAbsolute(analysis.filePath)
589
- ? analysis.filePath
590
- : resolve(analysis.rootDir, analysis.filePath);
591
- const resolvedPath = resolve(dirname(absoluteFromFile), importPath);
592
- const relativePath = normalizePathValue(relative(analysis.rootDir, resolvedPath));
593
- if (relativePath.startsWith("..")) {
594
- return null;
595
- }
596
- return relativePath;
597
- }
598
-
599
- // ═══════════════════════════════════════════════════════════════════════════
600
- // Batch Validation
601
- // ═══════════════════════════════════════════════════════════════════════════
602
-
603
- /**
604
- * 여러 파일 분석 결과 검증
605
- */
606
- export function validateAnalyses(
607
- analyses: FileAnalysis[],
608
- layers: LayerDefinition[],
609
- config: GuardConfig
610
- ): Violation[] {
611
- const allViolations: Violation[] = [];
612
-
613
- for (const analysis of analyses) {
614
- const violations = validateFileAnalysis(analysis, layers, config);
615
- allViolations.push(...violations);
616
- }
617
-
618
- return allViolations;
619
- }
620
-
621
- /**
622
- * 순환 의존성 감지
623
- */
624
- export function detectCircularDependencies(
625
- analyses: FileAnalysis[],
626
- layers: LayerDefinition[],
627
- config: GuardConfig
628
- ): Violation[] {
629
- const violations: Violation[] = [];
630
- const lookup = buildFileLookup(analyses);
631
- const graph = buildDependencyGraph(analyses, config, lookup);
632
- const analysisByPath = new Map<string, FileAnalysis>();
633
- const seenPairs = new Set<string>();
634
-
635
- for (const analysis of analyses) {
636
- analysisByPath.set(normalizePathValue(analysis.filePath), analysis);
637
- }
638
-
639
- // 간단한 직접 순환 감지 (A → B → A)
640
- for (const [file, deps] of graph.entries()) {
641
- for (const dep of deps) {
642
- const depDeps = graph.get(dep);
643
- if (depDeps?.includes(file)) {
644
- const pairKey = [file, dep].sort().join("::");
645
- if (seenPairs.has(pairKey)) continue;
646
- seenPairs.add(pairKey);
647
-
648
- const analysis = analysisByPath.get(file);
649
- if (!analysis) continue;
650
-
651
- const importInfo = analysis.imports.find(
652
- (i) => resolveImportTarget(i.path, analysis, config, lookup) === dep
653
- );
654
- if (!importInfo) continue;
655
-
656
- const depAnalysis = analysisByPath.get(dep);
657
- const fromLayer = analysis.layer ?? "unknown";
658
- const toLayer =
659
- depAnalysis?.layer ??
660
- resolveImportLayer(
661
- importInfo.path,
662
- layers,
663
- config.srcDir ?? "src",
664
- analysis.filePath,
665
- analysis.rootDir
666
- ) ??
667
- "unknown";
668
-
669
- violations.push(
670
- createViolation(
671
- "circular-dependency",
672
- analysis,
673
- importInfo,
674
- fromLayer,
675
- toLayer,
676
- layers,
677
- config.severity ?? {}
678
- )
679
- );
680
- }
681
- }
682
- }
683
-
684
- return violations;
685
- }
686
-
687
- /**
688
- * 의존성 그래프 빌드
689
- */
690
- function buildDependencyGraph(
691
- analyses: FileAnalysis[],
692
- config: GuardConfig,
693
- lookup: Map<string, string>
694
- ): Map<string, string[]> {
695
- const graph = new Map<string, string[]>();
696
-
697
- for (const analysis of analyses) {
698
- const deps = new Set<string>();
699
- const fromPath = normalizePathValue(analysis.filePath);
700
-
701
- for (const imp of analysis.imports) {
702
- if (shouldIgnoreImport(imp.path, config)) {
703
- continue;
704
- }
705
-
706
- const resolved = resolveImportTarget(imp.path, analysis, config, lookup);
707
- if (resolved && resolved !== fromPath) {
708
- deps.add(resolved);
709
- }
710
- }
711
-
712
- graph.set(fromPath, Array.from(deps));
713
- }
714
-
715
- return graph;
716
- }
717
-
718
- function normalizePathValue(filePath: string): string {
719
- return filePath.replace(/\\/g, "/");
720
- }
721
-
722
- function stripExtension(filePath: string): string {
723
- return filePath.replace(/\.[^/.]+$/, "");
724
- }
725
-
726
- function expandPathKeys(filePath: string): string[] {
727
- const normalized = normalizePathValue(filePath);
728
- const noExt = stripExtension(normalized);
729
- const keys = new Set<string>([normalized, noExt]);
730
-
731
- if (noExt.endsWith("/index")) {
732
- keys.add(noExt.slice(0, -"/index".length));
733
- }
734
- if (normalized.endsWith("/index")) {
735
- keys.add(normalized.slice(0, -"/index".length));
736
- }
737
-
738
- return Array.from(keys);
739
- }
740
-
741
- function buildFileLookup(analyses: FileAnalysis[]): Map<string, string> {
742
- const lookup = new Map<string, string>();
743
-
744
- for (const analysis of analyses) {
745
- const canonical = normalizePathValue(analysis.filePath);
746
-
747
- for (const key of expandPathKeys(canonical)) {
748
- if (!lookup.has(key)) {
749
- lookup.set(key, canonical);
750
- }
751
- }
752
-
753
- if (analysis.rootDir) {
754
- const absolutePath = isAbsolute(analysis.filePath)
755
- ? analysis.filePath
756
- : resolve(analysis.rootDir, analysis.filePath);
757
- const absoluteNormalized = normalizePathValue(absolutePath);
758
- for (const key of expandPathKeys(absoluteNormalized)) {
759
- if (!lookup.has(key)) {
760
- lookup.set(key, canonical);
761
- }
762
- }
763
-
764
- const relativePath = normalizePathValue(relative(analysis.rootDir, absolutePath));
765
- for (const key of expandPathKeys(relativePath)) {
766
- if (!lookup.has(key)) {
767
- lookup.set(key, canonical);
768
- }
769
- }
770
- }
771
- }
772
-
773
- return lookup;
774
- }
775
-
776
- function resolveImportTarget(
777
- importPath: string,
778
- analysis: FileAnalysis,
779
- config: GuardConfig,
780
- lookup: Map<string, string>
781
- ): string | null {
782
- const normalizedImportPath = importPath.replace(/\\/g, "/");
783
- const srcDir = (config.srcDir ?? "src").replace(/\\/g, "/").replace(/\/$/, "");
784
- const candidates: string[] = [];
785
-
786
- if (normalizedImportPath.startsWith("@/") || normalizedImportPath.startsWith("~/")) {
787
- const aliasPath = normalizedImportPath.slice(2);
788
- const withSrc = srcDir.length > 0 ? `${srcDir}/${aliasPath}` : aliasPath;
789
- candidates.push(withSrc, aliasPath);
790
-
791
- if (analysis.rootDir) {
792
- candidates.push(normalizePathValue(resolve(analysis.rootDir, withSrc)));
793
- }
794
- } else if (normalizedImportPath.startsWith(".")) {
795
- if (!analysis.rootDir) return null;
796
-
797
- const absoluteFromFile = isAbsolute(analysis.filePath)
798
- ? analysis.filePath
799
- : resolve(analysis.rootDir, analysis.filePath);
800
- const resolvedPath = resolve(dirname(absoluteFromFile), normalizedImportPath);
801
- const normalizedResolved = normalizePathValue(resolvedPath);
802
- const relativeToRoot = normalizePathValue(relative(analysis.rootDir, resolvedPath));
803
-
804
- candidates.push(normalizedResolved);
805
-
806
- if (!relativeToRoot.startsWith("..")) {
807
- candidates.push(relativeToRoot);
808
- if (srcDir.length > 0 && srcDir !== "." && relativeToRoot.startsWith(`${srcDir}/`)) {
809
- candidates.push(relativeToRoot.slice(srcDir.length + 1));
810
- }
811
- }
812
- } else if (srcDir.length > 0 && srcDir !== "." &&
813
- (normalizedImportPath === srcDir || normalizedImportPath.startsWith(`${srcDir}/`))) {
814
- const trimmed = normalizedImportPath.startsWith(`${srcDir}/`)
815
- ? normalizedImportPath.slice(srcDir.length + 1)
816
- : normalizedImportPath;
817
- candidates.push(normalizedImportPath, trimmed);
818
-
819
- if (analysis.rootDir) {
820
- candidates.push(normalizePathValue(resolve(analysis.rootDir, normalizedImportPath)));
821
- }
822
- } else {
823
- return null;
824
- }
825
-
826
- for (const candidate of candidates) {
827
- for (const key of expandPathKeys(candidate)) {
828
- const resolved = lookup.get(key);
829
- if (resolved) return resolved;
830
- }
831
- }
832
-
833
- return null;
834
- }
1
+ /**
2
+ * Mandu Guard Validator
3
+ *
4
+ * 아키텍처 규칙 검증
5
+ */
6
+
7
+ import type {
8
+ FileAnalysis,
9
+ ImportInfo,
10
+ LayerDefinition,
11
+ Violation,
12
+ ViolationType,
13
+ Severity,
14
+ SeverityConfig,
15
+ GuardConfig,
16
+ } from "./types";
17
+ import { basename, dirname, isAbsolute, relative, resolve } from "path";
18
+ import { resolveImportLayer, shouldIgnoreImport } from "./analyzer";
19
+ import { generateSmartSuggestions } from "./suggestions";
20
+ import { FILE_PATTERNS } from "../router/fs-types";
21
+
22
+ // ═══════════════════════════════════════════════════════════════════════════
23
+ // Layer Validation
24
+ // ═══════════════════════════════════════════════════════════════════════════
25
+
26
+ /**
27
+ * 레이어 의존성 검증
28
+ */
29
+ export function validateLayerDependency(
30
+ fromLayer: string,
31
+ toLayer: string,
32
+ layers: LayerDefinition[]
33
+ ): boolean {
34
+ const fromLayerDef = layers.find((l) => l.name === fromLayer);
35
+ if (!fromLayerDef) return true; // 알 수 없는 레이어는 통과
36
+
37
+ return fromLayerDef.canImport.includes(toLayer);
38
+ }
39
+
40
+ /**
41
+ * 같은 레이어 내 같은 슬라이스인지 확인
42
+ */
43
+ export function isSameSlice(
44
+ fromSlice: string | undefined,
45
+ toSlice: string | undefined
46
+ ): boolean {
47
+ if (!fromSlice || !toSlice) return false;
48
+ return fromSlice === toSlice;
49
+ }
50
+
51
+ // ═══════════════════════════════════════════════════════════════════════════
52
+ // Violation Detection
53
+ // ═══════════════════════════════════════════════════════════════════════════
54
+
55
+ /**
56
+ * 위반 생성
57
+ */
58
+ export function createViolation(
59
+ type: ViolationType,
60
+ analysis: FileAnalysis,
61
+ importInfo: ImportInfo,
62
+ fromLayer: string,
63
+ toLayer: string,
64
+ layers: LayerDefinition[],
65
+ severityConfig: SeverityConfig
66
+ ): Violation {
67
+ const fromLayerDef = layers.find((l) => l.name === fromLayer);
68
+ const allowedLayers = fromLayerDef?.canImport ?? [];
69
+
70
+ const severityMap: Record<ViolationType, keyof SeverityConfig> = {
71
+ "layer-violation": "layerViolation",
72
+ "circular-dependency": "circularDependency",
73
+ "cross-slice": "crossSliceDependency",
74
+ "deep-nesting": "deepNesting",
75
+ "file-type": "fileType",
76
+ "invalid-shared-segment": "invalidSharedSegment",
77
+ };
78
+
79
+ const severity: Severity = severityConfig[severityMap[type]] ?? "error";
80
+
81
+ const ruleNames: Record<ViolationType, string> = {
82
+ "layer-violation": "Layer Dependency",
83
+ "circular-dependency": "Circular Dependency",
84
+ "cross-slice": "Cross-Slice Dependency",
85
+ "deep-nesting": "Deep Nesting",
86
+ "file-type": "TypeScript Only",
87
+ "invalid-shared-segment": "Shared Segment",
88
+ };
89
+
90
+ const ruleDescriptions: Record<ViolationType, string> = {
91
+ "layer-violation": `"${fromLayer}" 레이어는 "${toLayer}" 레이어를 import할 수 없습니다`,
92
+ "circular-dependency": `순환 의존성이 감지되었습니다: ${fromLayer} ⇄ ${toLayer}`,
93
+ "cross-slice": `같은 레이어 내 다른 슬라이스 간 직접 import가 감지되었습니다`,
94
+ "deep-nesting": `깊은 경로 import가 감지되었습니다. Public API를 통해 import하세요`,
95
+ "file-type": `JS/JSX 파일은 금지됩니다. .ts/.tsx로 변환하세요`,
96
+ "invalid-shared-segment": `shared 하위 세그먼트 규칙을 위반했습니다`,
97
+ };
98
+
99
+ // 스마트 제안 생성
100
+ const suggestions = generateSmartSuggestions({
101
+ type,
102
+ fromLayer,
103
+ toLayer,
104
+ importPath: importInfo.path,
105
+ allowedLayers,
106
+ layers,
107
+ slice: analysis.slice,
108
+ });
109
+
110
+ return {
111
+ type,
112
+ filePath: analysis.filePath,
113
+ line: importInfo.line,
114
+ column: importInfo.column,
115
+ importStatement: importInfo.statement,
116
+ importPath: importInfo.path,
117
+ fromLayer,
118
+ toLayer,
119
+ ruleName: ruleNames[type],
120
+ ruleDescription: ruleDescriptions[type],
121
+ severity,
122
+ allowedLayers,
123
+ suggestions,
124
+ };
125
+ }
126
+
127
+ function createFileTypeViolation(
128
+ analysis: FileAnalysis,
129
+ severityConfig: SeverityConfig
130
+ ): Violation {
131
+ const severity: Severity = severityConfig.fileType ?? "error";
132
+ const normalizedPath = analysis.filePath.replace(/\\/g, "/");
133
+ const extension = normalizedPath.slice(normalizedPath.lastIndexOf("."));
134
+
135
+ return {
136
+ type: "file-type",
137
+ filePath: analysis.filePath,
138
+ line: 1,
139
+ column: 1,
140
+ importStatement: normalizedPath,
141
+ importPath: normalizedPath,
142
+ fromLayer: "typescript",
143
+ toLayer: extension,
144
+ ruleName: "TypeScript Only",
145
+ ruleDescription: `JS/JSX 파일은 금지됩니다 (${extension}). .ts/.tsx로 변환하세요`,
146
+ severity,
147
+ allowedLayers: [],
148
+ suggestions: [
149
+ "파일 확장자를 .ts 또는 .tsx로 변경하세요",
150
+ "필요한 타입을 추가하고 TypeScript로 변환하세요",
151
+ ],
152
+ };
153
+ }
154
+
155
+ function createInvalidSharedSegmentViolation(
156
+ analysis: FileAnalysis,
157
+ severityConfig: SeverityConfig
158
+ ): Violation {
159
+ const severity: Severity = severityConfig.invalidSharedSegment ?? "error";
160
+ const normalizedPath = analysis.filePath.replace(/\\/g, "/");
161
+ const marker = "src/shared/";
162
+ let segment = "(unknown)";
163
+
164
+ const index = normalizedPath.indexOf(marker);
165
+ if (index !== -1) {
166
+ const rest = normalizedPath.slice(index + marker.length);
167
+ segment = rest.split("/")[0] || "(root)";
168
+ }
169
+
170
+ return {
171
+ type: "invalid-shared-segment",
172
+ filePath: analysis.filePath,
173
+ line: 1,
174
+ column: 1,
175
+ importStatement: normalizedPath,
176
+ importPath: normalizedPath,
177
+ fromLayer: "shared",
178
+ toLayer: "shared/unsafe",
179
+ ruleName: "Shared Segment",
180
+ ruleDescription: `src/shared/${segment}는 허용되지 않습니다`,
181
+ severity,
182
+ allowedLayers: [],
183
+ suggestions: [
184
+ "허용 경로: src/shared/contracts|schema|types|utils/client|utils/server|env",
185
+ "파일을 허용된 shared 하위 폴더로 이동하세요",
186
+ ],
187
+ };
188
+ }
189
+
190
+ function createSharedEnvImportViolation(
191
+ analysis: FileAnalysis,
192
+ importInfo: ImportInfo,
193
+ fromLayer: string,
194
+ allowedLayers: string[],
195
+ severityConfig: SeverityConfig
196
+ ): Violation {
197
+ const severity: Severity = severityConfig.layerViolation ?? "error";
198
+
199
+ return {
200
+ type: "layer-violation",
201
+ filePath: analysis.filePath,
202
+ line: importInfo.line,
203
+ column: importInfo.column,
204
+ importStatement: importInfo.statement,
205
+ importPath: importInfo.path,
206
+ fromLayer,
207
+ toLayer: "shared/env",
208
+ ruleName: "Shared Env (Server-only)",
209
+ ruleDescription: "shared/env는 서버 전용입니다. 클라이언트 레이어에서 import할 수 없습니다",
210
+ severity,
211
+ allowedLayers,
212
+ suggestions: [
213
+ "환경변수 접근은 src/server 또는 app/api/route.ts에서 처리하세요",
214
+ "필요한 값은 서버에서 주입하거나 응답으로 전달하세요",
215
+ ],
216
+ };
217
+ }
218
+
219
+ // ═══════════════════════════════════════════════════════════════════════════
220
+ // File Validation
221
+ // ═══════════════════════════════════════════════════════════════════════════
222
+
223
+ /**
224
+ * 파일 분석 결과 검증
225
+ */
226
+ export function validateFileAnalysis(
227
+ analysis: FileAnalysis,
228
+ layers: LayerDefinition[],
229
+ config: GuardConfig
230
+ ): Violation[] {
231
+ const violations: Violation[] = [];
232
+ const severityConfig = config.severity ?? {};
233
+ const normalizedPath = analysis.filePath.toLowerCase();
234
+
235
+ if (normalizedPath.endsWith(".js") || normalizedPath.endsWith(".jsx")) {
236
+ violations.push(createFileTypeViolation(analysis, severityConfig));
237
+ }
238
+
239
+ if (analysis.layer === "shared/unsafe") {
240
+ violations.push(createInvalidSharedSegmentViolation(analysis, severityConfig));
241
+ }
242
+
243
+ // FS Routes 규칙 검사 (app/ 내부)
244
+ violations.push(...validateFsRoutesImports(analysis, layers, config));
245
+
246
+ // 파일이 레이어에 속하지 않으면 검사 안 함
247
+ if (!analysis.layer) {
248
+ return violations;
249
+ }
250
+
251
+ const fromLayer = analysis.layer;
252
+ const fromLayerDef = layers.find((l) => l.name === fromLayer);
253
+
254
+ if (!fromLayerDef) {
255
+ return violations;
256
+ }
257
+
258
+ for (const importInfo of analysis.imports) {
259
+ const isFsRoutesImport = isFsRoutesImportPath(importInfo.path);
260
+ if (shouldIgnoreImport(importInfo.path, config) && !isFsRoutesImport) {
261
+ continue;
262
+ }
263
+
264
+ // Import 레이어 해석
265
+ const toLayer = resolveImportLayer(
266
+ importInfo.path,
267
+ layers,
268
+ config.srcDir ?? "src",
269
+ analysis.filePath,
270
+ analysis.rootDir
271
+ );
272
+
273
+ if (!toLayer) {
274
+ continue; // 레이어를 알 수 없으면 무시
275
+ }
276
+
277
+ if (toLayer === "shared/env" && fromLayer.startsWith("client/")) {
278
+ violations.push(
279
+ createSharedEnvImportViolation(
280
+ analysis,
281
+ importInfo,
282
+ fromLayer,
283
+ fromLayerDef?.canImport ?? [],
284
+ severityConfig
285
+ )
286
+ );
287
+ continue;
288
+ }
289
+
290
+ // 같은 레이어 내 같은 슬라이스는 허용
291
+ if (fromLayer === toLayer) {
292
+ // Cross-slice 체크 (같은 레이어 내 다른 슬라이스)
293
+ const toSlice = extractSliceFromImport(
294
+ importInfo.path,
295
+ toLayer,
296
+ config.srcDir ?? "src",
297
+ analysis.filePath,
298
+ analysis.rootDir
299
+ );
300
+ if (analysis.slice && toSlice && analysis.slice !== toSlice) {
301
+ violations.push(
302
+ createViolation(
303
+ "cross-slice",
304
+ analysis,
305
+ importInfo,
306
+ fromLayer,
307
+ toLayer,
308
+ layers,
309
+ severityConfig
310
+ )
311
+ );
312
+ }
313
+ continue;
314
+ }
315
+
316
+ // 레이어 의존성 검증
317
+ if (!validateLayerDependency(fromLayer, toLayer, layers)) {
318
+ violations.push(
319
+ createViolation(
320
+ "layer-violation",
321
+ analysis,
322
+ importInfo,
323
+ fromLayer,
324
+ toLayer,
325
+ layers,
326
+ severityConfig
327
+ )
328
+ );
329
+ }
330
+ }
331
+
332
+ return violations;
333
+ }
334
+
335
+ /**
336
+ * Import 경로에서 슬라이스 추출
337
+ */
338
+ function extractSliceFromImport(
339
+ importPath: string,
340
+ layer: string,
341
+ srcDir: string,
342
+ fromFile?: string,
343
+ rootDir?: string
344
+ ): string | undefined {
345
+ const normalizedImportPath = importPath.replace(/\\/g, "/");
346
+ const normalizedSrcDir = srcDir.replace(/\\/g, "/").replace(/\/$/, "");
347
+
348
+ let layerRelative: string | undefined;
349
+
350
+ if (normalizedImportPath.startsWith("@/") || normalizedImportPath.startsWith("~/")) {
351
+ layerRelative = normalizedImportPath.slice(2);
352
+ } else if (
353
+ normalizedSrcDir.length > 0 && normalizedSrcDir !== "." &&
354
+ (normalizedImportPath === normalizedSrcDir || normalizedImportPath.startsWith(`${normalizedSrcDir}/`))
355
+ ) {
356
+ layerRelative = normalizedImportPath.startsWith(`${normalizedSrcDir}/`)
357
+ ? normalizedImportPath.slice(normalizedSrcDir.length + 1)
358
+ : normalizedImportPath;
359
+ } else if (normalizedImportPath.startsWith(".")) {
360
+ if (!fromFile || !rootDir) {
361
+ return undefined;
362
+ }
363
+
364
+ const absoluteFromFile = isAbsolute(fromFile) ? fromFile : resolve(rootDir, fromFile);
365
+ const resolvedPath = resolve(dirname(absoluteFromFile), normalizedImportPath);
366
+ const relativeToRoot = relative(rootDir, resolvedPath).replace(/\\/g, "/");
367
+
368
+ if (relativeToRoot.startsWith("..") || relativeToRoot.startsWith("../")) {
369
+ return undefined;
370
+ }
371
+
372
+ layerRelative = normalizedSrcDir.length > 0 && normalizedSrcDir !== "." && relativeToRoot.startsWith(`${normalizedSrcDir}/`)
373
+ ? relativeToRoot.slice(normalizedSrcDir.length + 1)
374
+ : relativeToRoot;
375
+ }
376
+
377
+ if (!layerRelative) return undefined;
378
+
379
+ const parts = layerRelative.split("/");
380
+ const layerParts = layer.split("/");
381
+ const matchesLayer = parts.slice(0, layerParts.length).join("/") === layer;
382
+ if (matchesLayer && parts.length > layerParts.length) {
383
+ return parts[layerParts.length];
384
+ }
385
+
386
+ return undefined;
387
+ }
388
+
389
+ /**
390
+ * FS Routes 규칙 검증 (app/ 내부)
391
+ */
392
+ function validateFsRoutesImports(
393
+ analysis: FileAnalysis,
394
+ layers: LayerDefinition[],
395
+ config: GuardConfig
396
+ ): Violation[] {
397
+ const fsRoutesConfig = config.fsRoutes;
398
+ if (!fsRoutesConfig) return [];
399
+
400
+ const fileType = getFsRouteFileType(analysis);
401
+ if (!fileType) return [];
402
+
403
+ const violations: Violation[] = [];
404
+ const severity = config.severity?.layerViolation ?? "error";
405
+ const allowedLayers =
406
+ fileType === "page"
407
+ ? fsRoutesConfig.pageCanImport
408
+ : fileType === "layout"
409
+ ? fsRoutesConfig.layoutCanImport
410
+ : fsRoutesConfig.routeCanImport;
411
+
412
+ for (const importInfo of analysis.imports) {
413
+ const isFsRoutesImport = isFsRoutesImportPath(importInfo.path);
414
+ if (shouldIgnoreImport(importInfo.path, config) && !isFsRoutesImport) {
415
+ continue;
416
+ }
417
+
418
+ // Rule: page -> page 금지
419
+ if (fileType === "page" && fsRoutesConfig.noPageToPage) {
420
+ if (resolvesToPageImport(importInfo.path, analysis)) {
421
+ violations.push({
422
+ type: "layer-violation",
423
+ filePath: analysis.filePath,
424
+ line: importInfo.line,
425
+ column: importInfo.column,
426
+ importStatement: importInfo.statement,
427
+ importPath: importInfo.path,
428
+ fromLayer: "page",
429
+ toLayer: "page",
430
+ ruleName: "FS Routes Page Import",
431
+ ruleDescription: "page.tsx에서 다른 page.tsx import는 금지됩니다",
432
+ severity,
433
+ allowedLayers: allowedLayers ?? [],
434
+ suggestions: [
435
+ "공통 UI는 app/ 외부(shared/widgets)로 이동하세요",
436
+ "필요한 데이터는 상위 layout에서 주입하세요",
437
+ ],
438
+ });
439
+ }
440
+ }
441
+
442
+ // Rule: page/layout import 가능한 레이어 제한
443
+ if (allowedLayers) {
444
+ const toLayer = resolveImportLayer(
445
+ importInfo.path,
446
+ layers,
447
+ config.srcDir ?? "src",
448
+ analysis.filePath,
449
+ analysis.rootDir
450
+ );
451
+
452
+ if (toLayer === "shared/env" && fileType !== "route") {
453
+ violations.push(
454
+ createSharedEnvImportViolation(
455
+ analysis,
456
+ importInfo,
457
+ fileType,
458
+ allowedLayers,
459
+ config.severity ?? {}
460
+ )
461
+ );
462
+ continue;
463
+ }
464
+
465
+ if (toLayer && !allowedLayers.includes(toLayer)) {
466
+ const fileLabel = fileType === "route" ? "route.ts" : `${fileType}.tsx`;
467
+ const suggestions =
468
+ fileType === "route"
469
+ ? [
470
+ `허용 레이어: ${allowedLayers.join(", ")}`,
471
+ "서버 로직은 src/server 또는 src/shared로 이동하세요",
472
+ ]
473
+ : [
474
+ `허용 레이어: ${allowedLayers.join(", ")}`,
475
+ "클라이언트 로직은 src/client 또는 src/shared로 이동하세요",
476
+ ];
477
+
478
+ violations.push({
479
+ type: "layer-violation",
480
+ filePath: analysis.filePath,
481
+ line: importInfo.line,
482
+ column: importInfo.column,
483
+ importStatement: importInfo.statement,
484
+ importPath: importInfo.path,
485
+ fromLayer: fileType,
486
+ toLayer,
487
+ ruleName: "FS Routes Import Rule",
488
+ ruleDescription: `${fileLabel}는 지정된 레이어만 import 가능합니다`,
489
+ severity,
490
+ allowedLayers,
491
+ suggestions,
492
+ });
493
+ }
494
+ }
495
+ }
496
+
497
+ return violations;
498
+ }
499
+
500
+ function getFsRouteFileType(analysis: FileAnalysis): "page" | "layout" | "route" | null {
501
+ const normalizedPath = normalizePathValue(
502
+ analysis.rootDir
503
+ ? relative(
504
+ analysis.rootDir,
505
+ isAbsolute(analysis.filePath)
506
+ ? analysis.filePath
507
+ : resolve(analysis.rootDir, analysis.filePath)
508
+ )
509
+ : analysis.filePath
510
+ );
511
+
512
+ if (!isFsRoutesPath(normalizedPath)) {
513
+ return null;
514
+ }
515
+
516
+ const fileName = basename(normalizedPath);
517
+ if (FILE_PATTERNS.page.test(fileName)) {
518
+ return "page";
519
+ }
520
+ if (FILE_PATTERNS.layout.test(fileName)) {
521
+ return "layout";
522
+ }
523
+ if (FILE_PATTERNS.route.test(fileName)) {
524
+ return "route";
525
+ }
526
+
527
+ return null;
528
+ }
529
+
530
+ function isFsRoutesPath(normalizedPath: string): boolean {
531
+ const cleaned = normalizedPath.replace(/^\.\/+/, "");
532
+ const segments = cleaned.split("/");
533
+ if (segments.includes("src")) {
534
+ return false;
535
+ }
536
+ return segments.includes("app");
537
+ }
538
+
539
+ function isFsRoutesImportPath(importPath: string): boolean {
540
+ const normalized = importPath.replace(/\\/g, "/").replace(/^\.\/+/, "");
541
+ const segments = normalized.split("/");
542
+ if (segments.includes("src")) {
543
+ return false;
544
+ }
545
+ return segments.includes("app");
546
+ }
547
+
548
+ function resolvesToPageImport(importPath: string, analysis: FileAnalysis): boolean {
549
+ const normalizedImport = importPath.replace(/\\/g, "/");
550
+ const importFileName = basename(normalizedImport);
551
+
552
+ if (FILE_PATTERNS.page.test(importFileName) || importFileName === "page") {
553
+ if (!analysis.rootDir) {
554
+ return isFsRoutesPath(normalizedImport);
555
+ }
556
+ }
557
+
558
+ if (normalizedImport.startsWith(".")) {
559
+ const resolvedPath = resolveFsRoutesPath(normalizedImport, analysis);
560
+ if (!resolvedPath) return false;
561
+
562
+ const resolvedFileName = basename(resolvedPath);
563
+ return (
564
+ isFsRoutesPath(resolvedPath) &&
565
+ (FILE_PATTERNS.page.test(resolvedFileName) || resolvedFileName === "page")
566
+ );
567
+ }
568
+
569
+ if (normalizedImport.startsWith("app/") || normalizedImport.includes("/app/")) {
570
+ return FILE_PATTERNS.page.test(importFileName) || importFileName === "page";
571
+ }
572
+
573
+ if (normalizedImport.startsWith("@/") || normalizedImport.startsWith("~/")) {
574
+ const aliasPath = normalizedImport.slice(2);
575
+ const aliasFileName = basename(aliasPath);
576
+ return (
577
+ isFsRoutesPath(aliasPath) &&
578
+ (FILE_PATTERNS.page.test(aliasFileName) || aliasFileName === "page")
579
+ );
580
+ }
581
+
582
+ return false;
583
+ }
584
+
585
+ function resolveFsRoutesPath(importPath: string, analysis: FileAnalysis): string | null {
586
+ if (!analysis.rootDir) return null;
587
+
588
+ const absoluteFromFile = isAbsolute(analysis.filePath)
589
+ ? analysis.filePath
590
+ : resolve(analysis.rootDir, analysis.filePath);
591
+ const resolvedPath = resolve(dirname(absoluteFromFile), importPath);
592
+ const relativePath = normalizePathValue(relative(analysis.rootDir, resolvedPath));
593
+ if (relativePath.startsWith("..")) {
594
+ return null;
595
+ }
596
+ return relativePath;
597
+ }
598
+
599
+ // ═══════════════════════════════════════════════════════════════════════════
600
+ // Batch Validation
601
+ // ═══════════════════════════════════════════════════════════════════════════
602
+
603
+ /**
604
+ * 여러 파일 분석 결과 검증
605
+ */
606
+ export function validateAnalyses(
607
+ analyses: FileAnalysis[],
608
+ layers: LayerDefinition[],
609
+ config: GuardConfig
610
+ ): Violation[] {
611
+ const allViolations: Violation[] = [];
612
+
613
+ for (const analysis of analyses) {
614
+ const violations = validateFileAnalysis(analysis, layers, config);
615
+ allViolations.push(...violations);
616
+ }
617
+
618
+ return allViolations;
619
+ }
620
+
621
+ /**
622
+ * 순환 의존성 감지
623
+ */
624
+ export function detectCircularDependencies(
625
+ analyses: FileAnalysis[],
626
+ layers: LayerDefinition[],
627
+ config: GuardConfig
628
+ ): Violation[] {
629
+ const violations: Violation[] = [];
630
+ const lookup = buildFileLookup(analyses);
631
+ const graph = buildDependencyGraph(analyses, config, lookup);
632
+ const analysisByPath = new Map<string, FileAnalysis>();
633
+ const seenPairs = new Set<string>();
634
+
635
+ for (const analysis of analyses) {
636
+ analysisByPath.set(normalizePathValue(analysis.filePath), analysis);
637
+ }
638
+
639
+ // 간단한 직접 순환 감지 (A → B → A)
640
+ for (const [file, deps] of graph.entries()) {
641
+ for (const dep of deps) {
642
+ const depDeps = graph.get(dep);
643
+ if (depDeps?.includes(file)) {
644
+ const pairKey = [file, dep].sort().join("::");
645
+ if (seenPairs.has(pairKey)) continue;
646
+ seenPairs.add(pairKey);
647
+
648
+ const analysis = analysisByPath.get(file);
649
+ if (!analysis) continue;
650
+
651
+ const importInfo = analysis.imports.find(
652
+ (i) => resolveImportTarget(i.path, analysis, config, lookup) === dep
653
+ );
654
+ if (!importInfo) continue;
655
+
656
+ const depAnalysis = analysisByPath.get(dep);
657
+ const fromLayer = analysis.layer ?? "unknown";
658
+ const toLayer =
659
+ depAnalysis?.layer ??
660
+ resolveImportLayer(
661
+ importInfo.path,
662
+ layers,
663
+ config.srcDir ?? "src",
664
+ analysis.filePath,
665
+ analysis.rootDir
666
+ ) ??
667
+ "unknown";
668
+
669
+ violations.push(
670
+ createViolation(
671
+ "circular-dependency",
672
+ analysis,
673
+ importInfo,
674
+ fromLayer,
675
+ toLayer,
676
+ layers,
677
+ config.severity ?? {}
678
+ )
679
+ );
680
+ }
681
+ }
682
+ }
683
+
684
+ return violations;
685
+ }
686
+
687
+ /**
688
+ * 의존성 그래프 빌드
689
+ */
690
+ function buildDependencyGraph(
691
+ analyses: FileAnalysis[],
692
+ config: GuardConfig,
693
+ lookup: Map<string, string>
694
+ ): Map<string, string[]> {
695
+ const graph = new Map<string, string[]>();
696
+
697
+ for (const analysis of analyses) {
698
+ const deps = new Set<string>();
699
+ const fromPath = normalizePathValue(analysis.filePath);
700
+
701
+ for (const imp of analysis.imports) {
702
+ if (shouldIgnoreImport(imp.path, config)) {
703
+ continue;
704
+ }
705
+
706
+ const resolved = resolveImportTarget(imp.path, analysis, config, lookup);
707
+ if (resolved && resolved !== fromPath) {
708
+ deps.add(resolved);
709
+ }
710
+ }
711
+
712
+ graph.set(fromPath, Array.from(deps));
713
+ }
714
+
715
+ return graph;
716
+ }
717
+
718
+ function normalizePathValue(filePath: string): string {
719
+ return filePath.replace(/\\/g, "/");
720
+ }
721
+
722
+ function stripExtension(filePath: string): string {
723
+ return filePath.replace(/\.[^/.]+$/, "");
724
+ }
725
+
726
+ function expandPathKeys(filePath: string): string[] {
727
+ const normalized = normalizePathValue(filePath);
728
+ const noExt = stripExtension(normalized);
729
+ const keys = new Set<string>([normalized, noExt]);
730
+
731
+ if (noExt.endsWith("/index")) {
732
+ keys.add(noExt.slice(0, -"/index".length));
733
+ }
734
+ if (normalized.endsWith("/index")) {
735
+ keys.add(normalized.slice(0, -"/index".length));
736
+ }
737
+
738
+ return Array.from(keys);
739
+ }
740
+
741
+ function buildFileLookup(analyses: FileAnalysis[]): Map<string, string> {
742
+ const lookup = new Map<string, string>();
743
+
744
+ for (const analysis of analyses) {
745
+ const canonical = normalizePathValue(analysis.filePath);
746
+
747
+ for (const key of expandPathKeys(canonical)) {
748
+ if (!lookup.has(key)) {
749
+ lookup.set(key, canonical);
750
+ }
751
+ }
752
+
753
+ if (analysis.rootDir) {
754
+ const absolutePath = isAbsolute(analysis.filePath)
755
+ ? analysis.filePath
756
+ : resolve(analysis.rootDir, analysis.filePath);
757
+ const absoluteNormalized = normalizePathValue(absolutePath);
758
+ for (const key of expandPathKeys(absoluteNormalized)) {
759
+ if (!lookup.has(key)) {
760
+ lookup.set(key, canonical);
761
+ }
762
+ }
763
+
764
+ const relativePath = normalizePathValue(relative(analysis.rootDir, absolutePath));
765
+ for (const key of expandPathKeys(relativePath)) {
766
+ if (!lookup.has(key)) {
767
+ lookup.set(key, canonical);
768
+ }
769
+ }
770
+ }
771
+ }
772
+
773
+ return lookup;
774
+ }
775
+
776
+ function resolveImportTarget(
777
+ importPath: string,
778
+ analysis: FileAnalysis,
779
+ config: GuardConfig,
780
+ lookup: Map<string, string>
781
+ ): string | null {
782
+ const normalizedImportPath = importPath.replace(/\\/g, "/");
783
+ const srcDir = (config.srcDir ?? "src").replace(/\\/g, "/").replace(/\/$/, "");
784
+ const candidates: string[] = [];
785
+
786
+ if (normalizedImportPath.startsWith("@/") || normalizedImportPath.startsWith("~/")) {
787
+ const aliasPath = normalizedImportPath.slice(2);
788
+ const withSrc = srcDir.length > 0 ? `${srcDir}/${aliasPath}` : aliasPath;
789
+ candidates.push(withSrc, aliasPath);
790
+
791
+ if (analysis.rootDir) {
792
+ candidates.push(normalizePathValue(resolve(analysis.rootDir, withSrc)));
793
+ }
794
+ } else if (normalizedImportPath.startsWith(".")) {
795
+ if (!analysis.rootDir) return null;
796
+
797
+ const absoluteFromFile = isAbsolute(analysis.filePath)
798
+ ? analysis.filePath
799
+ : resolve(analysis.rootDir, analysis.filePath);
800
+ const resolvedPath = resolve(dirname(absoluteFromFile), normalizedImportPath);
801
+ const normalizedResolved = normalizePathValue(resolvedPath);
802
+ const relativeToRoot = normalizePathValue(relative(analysis.rootDir, resolvedPath));
803
+
804
+ candidates.push(normalizedResolved);
805
+
806
+ if (!relativeToRoot.startsWith("..")) {
807
+ candidates.push(relativeToRoot);
808
+ if (srcDir.length > 0 && srcDir !== "." && relativeToRoot.startsWith(`${srcDir}/`)) {
809
+ candidates.push(relativeToRoot.slice(srcDir.length + 1));
810
+ }
811
+ }
812
+ } else if (srcDir.length > 0 && srcDir !== "." &&
813
+ (normalizedImportPath === srcDir || normalizedImportPath.startsWith(`${srcDir}/`))) {
814
+ const trimmed = normalizedImportPath.startsWith(`${srcDir}/`)
815
+ ? normalizedImportPath.slice(srcDir.length + 1)
816
+ : normalizedImportPath;
817
+ candidates.push(normalizedImportPath, trimmed);
818
+
819
+ if (analysis.rootDir) {
820
+ candidates.push(normalizePathValue(resolve(analysis.rootDir, normalizedImportPath)));
821
+ }
822
+ } else {
823
+ return null;
824
+ }
825
+
826
+ for (const candidate of candidates) {
827
+ for (const key of expandPathKeys(candidate)) {
828
+ const resolved = lookup.get(key);
829
+ if (resolved) return resolved;
830
+ }
831
+ }
832
+
833
+ return null;
834
+ }