@mandujs/core 0.12.2 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (173) hide show
  1. package/README.ko.md +304 -304
  2. package/README.md +653 -653
  3. package/package.json +1 -1
  4. package/src/brain/architecture/analyzer.ts +28 -26
  5. package/src/brain/doctor/analyzer.ts +1 -1
  6. package/src/bundler/build.ts +91 -91
  7. package/src/bundler/css.ts +302 -302
  8. package/src/bundler/dev.ts +0 -1
  9. package/src/change/history.ts +3 -3
  10. package/src/change/snapshot.ts +10 -9
  11. package/src/change/transaction.ts +2 -2
  12. package/src/client/Link.tsx +227 -227
  13. package/src/client/globals.ts +44 -44
  14. package/src/client/hooks.ts +267 -267
  15. package/src/client/index.ts +5 -5
  16. package/src/client/island.ts +8 -8
  17. package/src/client/router.ts +435 -435
  18. package/src/client/runtime.ts +23 -23
  19. package/src/client/serialize.ts +404 -404
  20. package/src/client/window-state.ts +101 -101
  21. package/src/config/mandu.ts +94 -96
  22. package/src/config/validate.ts +213 -215
  23. package/src/config/watcher.ts +311 -311
  24. package/src/constants.ts +40 -40
  25. package/src/content/content-layer.ts +314 -314
  26. package/src/content/content.test.ts +433 -433
  27. package/src/content/data-store.ts +245 -245
  28. package/src/content/digest.ts +133 -133
  29. package/src/content/index.ts +164 -164
  30. package/src/content/loader-context.ts +172 -172
  31. package/src/content/loaders/api.ts +216 -216
  32. package/src/content/loaders/file.ts +169 -169
  33. package/src/content/loaders/glob.ts +252 -252
  34. package/src/content/loaders/index.ts +34 -34
  35. package/src/content/loaders/types.ts +137 -137
  36. package/src/content/meta-store.ts +209 -209
  37. package/src/content/types.ts +282 -282
  38. package/src/content/watcher.ts +135 -135
  39. package/src/contract/client-safe.test.ts +42 -42
  40. package/src/contract/client-safe.ts +114 -114
  41. package/src/contract/client.ts +16 -16
  42. package/src/contract/define.ts +459 -459
  43. package/src/contract/handler.ts +10 -10
  44. package/src/contract/normalize.test.ts +276 -276
  45. package/src/contract/normalize.ts +404 -404
  46. package/src/contract/registry.test.ts +206 -206
  47. package/src/contract/registry.ts +568 -568
  48. package/src/contract/schema.ts +48 -48
  49. package/src/contract/types.ts +58 -58
  50. package/src/contract/validator.ts +32 -32
  51. package/src/devtools/ai/context-builder.ts +375 -375
  52. package/src/devtools/ai/index.ts +25 -25
  53. package/src/devtools/ai/mcp-connector.ts +465 -465
  54. package/src/devtools/client/catchers/error-catcher.ts +327 -327
  55. package/src/devtools/client/catchers/index.ts +18 -18
  56. package/src/devtools/client/catchers/network-proxy.ts +363 -363
  57. package/src/devtools/client/components/index.ts +39 -39
  58. package/src/devtools/client/components/kitchen-root.tsx +362 -362
  59. package/src/devtools/client/components/mandu-character.tsx +241 -241
  60. package/src/devtools/client/components/overlay.tsx +368 -368
  61. package/src/devtools/client/components/panel/errors-panel.tsx +259 -259
  62. package/src/devtools/client/components/panel/guard-panel.tsx +244 -244
  63. package/src/devtools/client/components/panel/index.ts +32 -32
  64. package/src/devtools/client/components/panel/islands-panel.tsx +304 -304
  65. package/src/devtools/client/components/panel/network-panel.tsx +292 -292
  66. package/src/devtools/client/components/panel/panel-container.tsx +259 -259
  67. package/src/devtools/client/filters/context-filters.ts +282 -282
  68. package/src/devtools/client/filters/index.ts +16 -16
  69. package/src/devtools/client/index.ts +63 -63
  70. package/src/devtools/client/persistence.ts +335 -335
  71. package/src/devtools/client/state-manager.ts +478 -478
  72. package/src/devtools/design-tokens.ts +263 -263
  73. package/src/devtools/hook/create-hook.ts +207 -207
  74. package/src/devtools/hook/index.ts +13 -13
  75. package/src/devtools/index.ts +439 -439
  76. package/src/devtools/init.ts +266 -266
  77. package/src/devtools/protocol.ts +237 -237
  78. package/src/devtools/server/index.ts +17 -17
  79. package/src/devtools/server/source-context.ts +444 -444
  80. package/src/devtools/types.ts +319 -319
  81. package/src/devtools/worker/index.ts +25 -25
  82. package/src/devtools/worker/redaction-worker.ts +222 -222
  83. package/src/devtools/worker/worker-manager.ts +409 -409
  84. package/src/error/classifier.ts +2 -2
  85. package/src/error/domains.ts +265 -265
  86. package/src/error/formatter.ts +32 -32
  87. package/src/error/result.ts +46 -46
  88. package/src/error/stack-analyzer.ts +5 -0
  89. package/src/error/types.ts +6 -6
  90. package/src/errors/extractor.ts +409 -409
  91. package/src/errors/index.ts +19 -19
  92. package/src/filling/auth.ts +308 -308
  93. package/src/filling/context.ts +569 -569
  94. package/src/filling/deps.ts +238 -238
  95. package/src/generator/contract-glue.ts +2 -1
  96. package/src/generator/generate.ts +12 -10
  97. package/src/generator/index.ts +3 -3
  98. package/src/generator/templates.ts +80 -79
  99. package/src/guard/analyzer.ts +360 -360
  100. package/src/guard/ast-analyzer.ts +806 -806
  101. package/src/guard/auto-correct.ts +1 -1
  102. package/src/guard/check.ts +128 -128
  103. package/src/guard/contract-guard.ts +9 -9
  104. package/src/guard/file-type.test.ts +24 -24
  105. package/src/guard/presets/atomic.ts +70 -70
  106. package/src/guard/presets/clean.ts +77 -77
  107. package/src/guard/presets/cqrs.test.ts +35 -14
  108. package/src/guard/presets/fsd.ts +79 -79
  109. package/src/guard/presets/hexagonal.ts +68 -68
  110. package/src/guard/presets/index.ts +291 -291
  111. package/src/guard/reporter.ts +445 -445
  112. package/src/guard/rules.ts +12 -12
  113. package/src/guard/statistics.ts +578 -578
  114. package/src/guard/suggestions.ts +358 -358
  115. package/src/guard/types.ts +348 -348
  116. package/src/guard/validator.ts +834 -834
  117. package/src/guard/watcher.ts +404 -404
  118. package/src/index.ts +1 -0
  119. package/src/intent/index.ts +310 -310
  120. package/src/island/index.ts +304 -304
  121. package/src/logging/index.ts +22 -22
  122. package/src/logging/transports.ts +365 -365
  123. package/src/paths.test.ts +47 -0
  124. package/src/paths.ts +47 -0
  125. package/src/plugins/index.ts +38 -38
  126. package/src/plugins/registry.ts +377 -377
  127. package/src/plugins/types.ts +363 -363
  128. package/src/report/build.ts +1 -1
  129. package/src/report/index.ts +1 -1
  130. package/src/router/fs-patterns.ts +387 -387
  131. package/src/router/fs-routes.ts +344 -401
  132. package/src/router/fs-scanner.ts +497 -497
  133. package/src/router/fs-types.ts +270 -278
  134. package/src/router/index.ts +81 -81
  135. package/src/runtime/boundary.tsx +232 -232
  136. package/src/runtime/compose.ts +222 -222
  137. package/src/runtime/lifecycle.ts +381 -381
  138. package/src/runtime/logger.test.ts +345 -345
  139. package/src/runtime/logger.ts +677 -677
  140. package/src/runtime/router.test.ts +476 -476
  141. package/src/runtime/router.ts +105 -105
  142. package/src/runtime/security.ts +155 -155
  143. package/src/runtime/server.ts +24 -24
  144. package/src/runtime/session-key.ts +328 -328
  145. package/src/runtime/ssr.ts +367 -367
  146. package/src/runtime/streaming-ssr.ts +1245 -1245
  147. package/src/runtime/trace.ts +144 -144
  148. package/src/seo/index.ts +214 -214
  149. package/src/seo/integration/ssr.ts +307 -307
  150. package/src/seo/render/basic.ts +427 -427
  151. package/src/seo/render/index.ts +143 -143
  152. package/src/seo/render/jsonld.ts +539 -539
  153. package/src/seo/render/opengraph.ts +191 -191
  154. package/src/seo/render/robots.ts +116 -116
  155. package/src/seo/render/sitemap.ts +137 -137
  156. package/src/seo/render/twitter.ts +126 -126
  157. package/src/seo/resolve/index.ts +353 -353
  158. package/src/seo/resolve/opengraph.ts +143 -143
  159. package/src/seo/resolve/robots.ts +73 -73
  160. package/src/seo/resolve/title.ts +94 -94
  161. package/src/seo/resolve/twitter.ts +73 -73
  162. package/src/seo/resolve/url.ts +97 -97
  163. package/src/seo/routes/index.ts +290 -290
  164. package/src/seo/types.ts +575 -575
  165. package/src/slot/validator.ts +39 -39
  166. package/src/spec/index.ts +3 -3
  167. package/src/spec/load.ts +76 -76
  168. package/src/spec/lock.ts +56 -56
  169. package/src/utils/bun.ts +8 -8
  170. package/src/utils/lru-cache.ts +75 -75
  171. package/src/utils/safe-io.ts +188 -188
  172. package/src/utils/string-safe.ts +298 -298
  173. package/src/watcher/rules.ts +5 -5
@@ -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
+ }