@mandujs/core 0.9.39 → 0.9.40
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.
- package/README.ko.md +27 -0
- package/README.md +21 -5
- package/package.json +1 -1
- package/src/config/index.ts +1 -0
- package/src/config/mandu.ts +60 -0
- package/src/contract/client-safe.test.ts +42 -0
- package/src/contract/client-safe.ts +114 -0
- package/src/contract/client.ts +12 -11
- package/src/contract/handler.ts +10 -11
- package/src/contract/index.ts +25 -16
- package/src/contract/registry.test.ts +206 -0
- package/src/contract/registry.ts +568 -0
- package/src/contract/schema.ts +48 -12
- package/src/contract/types.ts +58 -35
- package/src/contract/validator.ts +32 -17
- package/src/filling/context.ts +103 -0
- package/src/generator/templates.ts +70 -17
- package/src/guard/analyzer.ts +9 -4
- package/src/guard/check.ts +66 -30
- package/src/guard/contract-guard.ts +9 -9
- package/src/guard/file-type.test.ts +24 -0
- package/src/guard/presets/index.ts +193 -60
- package/src/guard/rules.ts +12 -6
- package/src/guard/statistics.ts +6 -0
- package/src/guard/suggestions.ts +9 -2
- package/src/guard/types.ts +11 -1
- package/src/guard/validator.ts +160 -9
- package/src/guard/watcher.ts +2 -0
- package/src/index.ts +8 -1
- package/src/runtime/index.ts +1 -0
- package/src/runtime/streaming-ssr.ts +123 -2
- package/src/seo/index.ts +214 -0
- package/src/seo/integration/ssr.ts +307 -0
- package/src/seo/render/basic.ts +427 -0
- package/src/seo/render/index.ts +143 -0
- package/src/seo/render/jsonld.ts +539 -0
- package/src/seo/render/opengraph.ts +191 -0
- package/src/seo/render/robots.ts +116 -0
- package/src/seo/render/sitemap.ts +137 -0
- package/src/seo/render/twitter.ts +126 -0
- package/src/seo/resolve/index.ts +353 -0
- package/src/seo/resolve/opengraph.ts +143 -0
- package/src/seo/resolve/robots.ts +73 -0
- package/src/seo/resolve/title.ts +94 -0
- package/src/seo/resolve/twitter.ts +73 -0
- package/src/seo/resolve/url.ts +97 -0
- package/src/seo/routes/index.ts +290 -0
- package/src/seo/types.ts +575 -0
- package/src/slot/validator.ts +39 -16
|
@@ -23,94 +23,226 @@ export { atomicPreset, ATOMIC_HIERARCHY } from "./atomic";
|
|
|
23
23
|
*/
|
|
24
24
|
export const manduPreset: PresetDefinition = {
|
|
25
25
|
name: "mandu",
|
|
26
|
-
description: "Mandu 권장 아키텍처 -
|
|
26
|
+
description: "Mandu 권장 아키텍처 - client/server 분리 + strict shared",
|
|
27
27
|
|
|
28
28
|
hierarchy: [
|
|
29
|
-
//
|
|
30
|
-
"app",
|
|
31
|
-
"pages",
|
|
32
|
-
"widgets",
|
|
33
|
-
"features",
|
|
34
|
-
"entities",
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
"
|
|
38
|
-
"
|
|
39
|
-
"
|
|
40
|
-
|
|
41
|
-
"
|
|
42
|
-
"shared",
|
|
29
|
+
// Client (FSD)
|
|
30
|
+
"client/app",
|
|
31
|
+
"client/pages",
|
|
32
|
+
"client/widgets",
|
|
33
|
+
"client/features",
|
|
34
|
+
"client/entities",
|
|
35
|
+
"client/shared",
|
|
36
|
+
// Shared (strict)
|
|
37
|
+
"shared/contracts",
|
|
38
|
+
"shared/types",
|
|
39
|
+
"shared/utils/client",
|
|
40
|
+
"shared/utils/server",
|
|
41
|
+
"shared/schema",
|
|
42
|
+
"shared/env",
|
|
43
|
+
"shared/unsafe",
|
|
44
|
+
// Server (Clean)
|
|
45
|
+
"server/api",
|
|
46
|
+
"server/application",
|
|
47
|
+
"server/domain",
|
|
48
|
+
"server/infra",
|
|
49
|
+
"server/core",
|
|
43
50
|
],
|
|
44
51
|
|
|
45
52
|
layers: [
|
|
46
|
-
//
|
|
53
|
+
// Client layers
|
|
47
54
|
{
|
|
48
|
-
name: "app",
|
|
49
|
-
pattern: "src/app/**",
|
|
50
|
-
canImport: [
|
|
51
|
-
|
|
55
|
+
name: "client/app",
|
|
56
|
+
pattern: "src/client/app/**",
|
|
57
|
+
canImport: [
|
|
58
|
+
"client/pages",
|
|
59
|
+
"client/widgets",
|
|
60
|
+
"client/features",
|
|
61
|
+
"client/entities",
|
|
62
|
+
"client/shared",
|
|
63
|
+
"shared/contracts",
|
|
64
|
+
"shared/types",
|
|
65
|
+
"shared/utils/client",
|
|
66
|
+
],
|
|
67
|
+
description: "클라이언트 앱 진입점",
|
|
52
68
|
},
|
|
53
69
|
{
|
|
54
|
-
name: "pages",
|
|
55
|
-
pattern: "src/pages/**",
|
|
56
|
-
canImport: [
|
|
57
|
-
|
|
70
|
+
name: "client/pages",
|
|
71
|
+
pattern: "src/client/pages/**",
|
|
72
|
+
canImport: [
|
|
73
|
+
"client/widgets",
|
|
74
|
+
"client/features",
|
|
75
|
+
"client/entities",
|
|
76
|
+
"client/shared",
|
|
77
|
+
"shared/contracts",
|
|
78
|
+
"shared/types",
|
|
79
|
+
"shared/utils/client",
|
|
80
|
+
],
|
|
81
|
+
description: "클라이언트 페이지",
|
|
58
82
|
},
|
|
59
83
|
{
|
|
60
|
-
name: "widgets",
|
|
61
|
-
pattern: "src/widgets/**",
|
|
62
|
-
canImport: [
|
|
63
|
-
|
|
84
|
+
name: "client/widgets",
|
|
85
|
+
pattern: "src/client/widgets/**",
|
|
86
|
+
canImport: [
|
|
87
|
+
"client/features",
|
|
88
|
+
"client/entities",
|
|
89
|
+
"client/shared",
|
|
90
|
+
"shared/contracts",
|
|
91
|
+
"shared/types",
|
|
92
|
+
"shared/utils/client",
|
|
93
|
+
],
|
|
94
|
+
description: "클라이언트 위젯",
|
|
64
95
|
},
|
|
65
96
|
{
|
|
66
|
-
name: "features",
|
|
67
|
-
pattern: "src/features/**",
|
|
68
|
-
canImport: [
|
|
69
|
-
|
|
97
|
+
name: "client/features",
|
|
98
|
+
pattern: "src/client/features/**",
|
|
99
|
+
canImport: [
|
|
100
|
+
"client/entities",
|
|
101
|
+
"client/shared",
|
|
102
|
+
"shared/contracts",
|
|
103
|
+
"shared/types",
|
|
104
|
+
"shared/utils/client",
|
|
105
|
+
],
|
|
106
|
+
description: "클라이언트 기능",
|
|
70
107
|
},
|
|
71
108
|
{
|
|
72
|
-
name: "entities",
|
|
73
|
-
pattern: "src/entities/**",
|
|
74
|
-
canImport: [
|
|
75
|
-
|
|
109
|
+
name: "client/entities",
|
|
110
|
+
pattern: "src/client/entities/**",
|
|
111
|
+
canImport: [
|
|
112
|
+
"client/shared",
|
|
113
|
+
"shared/contracts",
|
|
114
|
+
"shared/types",
|
|
115
|
+
"shared/utils/client",
|
|
116
|
+
],
|
|
117
|
+
description: "클라이언트 엔티티",
|
|
76
118
|
},
|
|
77
|
-
// Backend layers
|
|
78
119
|
{
|
|
79
|
-
name: "
|
|
80
|
-
pattern: "src/
|
|
81
|
-
canImport: [
|
|
82
|
-
|
|
120
|
+
name: "client/shared",
|
|
121
|
+
pattern: "src/client/shared/**",
|
|
122
|
+
canImport: [
|
|
123
|
+
"shared/contracts",
|
|
124
|
+
"shared/types",
|
|
125
|
+
"shared/utils/client",
|
|
126
|
+
],
|
|
127
|
+
description: "클라이언트 전용 공유",
|
|
83
128
|
},
|
|
129
|
+
// Shared layers
|
|
84
130
|
{
|
|
85
|
-
name: "
|
|
86
|
-
pattern: "src/
|
|
87
|
-
canImport: ["
|
|
88
|
-
description: "
|
|
131
|
+
name: "shared/contracts",
|
|
132
|
+
pattern: "src/shared/contracts/**",
|
|
133
|
+
canImport: ["shared/types", "shared/utils/client"],
|
|
134
|
+
description: "공용 계약 (클라이언트 safe)",
|
|
89
135
|
},
|
|
90
136
|
{
|
|
91
|
-
name: "
|
|
92
|
-
pattern: "src/
|
|
93
|
-
canImport: ["shared"],
|
|
94
|
-
description: "
|
|
137
|
+
name: "shared/schema",
|
|
138
|
+
pattern: "src/shared/schema/**",
|
|
139
|
+
canImport: ["shared/types", "shared/utils/server"],
|
|
140
|
+
description: "서버 전용 스키마 (JSON/OpenAPI)",
|
|
95
141
|
},
|
|
96
142
|
{
|
|
97
|
-
name: "
|
|
98
|
-
pattern: "src/
|
|
99
|
-
canImport: [
|
|
100
|
-
description: "
|
|
143
|
+
name: "shared/types",
|
|
144
|
+
pattern: "src/shared/types/**",
|
|
145
|
+
canImport: [],
|
|
146
|
+
description: "공용 타입",
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
name: "shared/utils/client",
|
|
150
|
+
pattern: "src/shared/utils/client/**",
|
|
151
|
+
canImport: ["shared/types"],
|
|
152
|
+
description: "클라이언트 safe 유틸",
|
|
101
153
|
},
|
|
102
|
-
// Shared layers
|
|
103
154
|
{
|
|
104
|
-
name: "
|
|
105
|
-
pattern: "src/
|
|
106
|
-
canImport: ["shared"],
|
|
107
|
-
description: "
|
|
155
|
+
name: "shared/utils/server",
|
|
156
|
+
pattern: "src/shared/utils/server/**",
|
|
157
|
+
canImport: ["shared/types", "shared/utils/client"],
|
|
158
|
+
description: "서버 전용 유틸",
|
|
108
159
|
},
|
|
109
160
|
{
|
|
110
|
-
name: "shared",
|
|
161
|
+
name: "shared/env",
|
|
162
|
+
pattern: "src/shared/env/**",
|
|
163
|
+
canImport: ["shared/types", "shared/utils/client", "shared/utils/server"],
|
|
164
|
+
description: "서버 전용 환경/설정",
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
name: "shared/unsafe",
|
|
111
168
|
pattern: "src/shared/**",
|
|
112
169
|
canImport: [],
|
|
113
|
-
description: "
|
|
170
|
+
description: "금지된 shared 경로",
|
|
171
|
+
},
|
|
172
|
+
// Server layers
|
|
173
|
+
{
|
|
174
|
+
name: "server/api",
|
|
175
|
+
pattern: "src/server/api/**",
|
|
176
|
+
canImport: [
|
|
177
|
+
"server/application",
|
|
178
|
+
"server/domain",
|
|
179
|
+
"server/infra",
|
|
180
|
+
"server/core",
|
|
181
|
+
"shared/contracts",
|
|
182
|
+
"shared/schema",
|
|
183
|
+
"shared/types",
|
|
184
|
+
"shared/utils/client",
|
|
185
|
+
"shared/utils/server",
|
|
186
|
+
"shared/env",
|
|
187
|
+
],
|
|
188
|
+
description: "서버 API 라우트, 컨트롤러",
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
name: "server/application",
|
|
192
|
+
pattern: "src/server/application/**",
|
|
193
|
+
canImport: [
|
|
194
|
+
"server/domain",
|
|
195
|
+
"server/core",
|
|
196
|
+
"shared/contracts",
|
|
197
|
+
"shared/schema",
|
|
198
|
+
"shared/types",
|
|
199
|
+
"shared/utils/client",
|
|
200
|
+
"shared/utils/server",
|
|
201
|
+
"shared/env",
|
|
202
|
+
],
|
|
203
|
+
description: "서버 유스케이스, 서비스",
|
|
204
|
+
},
|
|
205
|
+
{
|
|
206
|
+
name: "server/domain",
|
|
207
|
+
pattern: "src/server/domain/**",
|
|
208
|
+
canImport: [
|
|
209
|
+
"shared/contracts",
|
|
210
|
+
"shared/schema",
|
|
211
|
+
"shared/types",
|
|
212
|
+
"shared/utils/client",
|
|
213
|
+
"shared/utils/server",
|
|
214
|
+
"shared/env",
|
|
215
|
+
],
|
|
216
|
+
description: "서버 도메인 모델",
|
|
217
|
+
},
|
|
218
|
+
{
|
|
219
|
+
name: "server/infra",
|
|
220
|
+
pattern: "src/server/infra/**",
|
|
221
|
+
canImport: [
|
|
222
|
+
"server/application",
|
|
223
|
+
"server/domain",
|
|
224
|
+
"server/core",
|
|
225
|
+
"shared/contracts",
|
|
226
|
+
"shared/schema",
|
|
227
|
+
"shared/types",
|
|
228
|
+
"shared/utils/client",
|
|
229
|
+
"shared/utils/server",
|
|
230
|
+
"shared/env",
|
|
231
|
+
],
|
|
232
|
+
description: "서버 인프라 구현",
|
|
233
|
+
},
|
|
234
|
+
{
|
|
235
|
+
name: "server/core",
|
|
236
|
+
pattern: "src/server/core/**",
|
|
237
|
+
canImport: [
|
|
238
|
+
"shared/contracts",
|
|
239
|
+
"shared/schema",
|
|
240
|
+
"shared/types",
|
|
241
|
+
"shared/utils/client",
|
|
242
|
+
"shared/utils/server",
|
|
243
|
+
"shared/env",
|
|
244
|
+
],
|
|
245
|
+
description: "서버 핵심 공통",
|
|
114
246
|
},
|
|
115
247
|
],
|
|
116
248
|
|
|
@@ -119,6 +251,7 @@ export const manduPreset: PresetDefinition = {
|
|
|
119
251
|
circularDependency: "warn",
|
|
120
252
|
crossSliceDependency: "warn",
|
|
121
253
|
deepNesting: "info",
|
|
254
|
+
fileType: "error",
|
|
122
255
|
},
|
|
123
256
|
};
|
|
124
257
|
|
package/src/guard/rules.ts
CHANGED
|
@@ -64,12 +64,18 @@ export const GUARD_RULES: Record<string, GuardRule> = {
|
|
|
64
64
|
description: "Slot 핸들러에 ctx.ok(), ctx.json() 등의 응답 패턴이 없습니다",
|
|
65
65
|
severity: "error",
|
|
66
66
|
},
|
|
67
|
-
SLOT_MISSING_FILLING_PATTERN: {
|
|
68
|
-
id: "SLOT_MISSING_FILLING_PATTERN",
|
|
69
|
-
name: "Slot Missing Filling Pattern",
|
|
70
|
-
description: "Slot 파일에 Mandu.filling() 패턴이 없습니다",
|
|
71
|
-
severity: "error",
|
|
72
|
-
},
|
|
67
|
+
SLOT_MISSING_FILLING_PATTERN: {
|
|
68
|
+
id: "SLOT_MISSING_FILLING_PATTERN",
|
|
69
|
+
name: "Slot Missing Filling Pattern",
|
|
70
|
+
description: "Slot 파일에 Mandu.filling() 패턴이 없습니다",
|
|
71
|
+
severity: "error",
|
|
72
|
+
},
|
|
73
|
+
SLOT_ZOD_DIRECT_IMPORT: {
|
|
74
|
+
id: "SLOT_ZOD_DIRECT_IMPORT",
|
|
75
|
+
name: "Zod Direct Import in Slot",
|
|
76
|
+
description: "Slot 파일에서 zod를 직접 import 했습니다",
|
|
77
|
+
severity: "error",
|
|
78
|
+
},
|
|
73
79
|
// Contract-related rules
|
|
74
80
|
CONTRACT_MISSING: {
|
|
75
81
|
id: "CONTRACT_MISSING",
|
package/src/guard/statistics.ts
CHANGED
|
@@ -568,5 +568,11 @@ function getTypeTitle(type: ViolationType): string {
|
|
|
568
568
|
return "Cross-Slice Dependencies";
|
|
569
569
|
case "deep-nesting":
|
|
570
570
|
return "Deep Nesting";
|
|
571
|
+
case "file-type":
|
|
572
|
+
return "File Type Violations";
|
|
573
|
+
case "invalid-shared-segment":
|
|
574
|
+
return "Shared Segment Violations";
|
|
575
|
+
default:
|
|
576
|
+
return "Violations";
|
|
571
577
|
}
|
|
572
578
|
}
|
package/src/guard/suggestions.ts
CHANGED
|
@@ -172,7 +172,7 @@ function generateCircularDependencySuggestions(context: SuggestionContext): stri
|
|
|
172
172
|
*/
|
|
173
173
|
function generateCrossSliceSuggestions(context: SuggestionContext): string[] {
|
|
174
174
|
const { fromLayer, importPath, slice } = context;
|
|
175
|
-
const toSlice = extractSliceFromPath(importPath);
|
|
175
|
+
const toSlice = extractSliceFromPath(importPath, fromLayer);
|
|
176
176
|
const suggestions: string[] = [];
|
|
177
177
|
|
|
178
178
|
suggestions.push(
|
|
@@ -339,7 +339,14 @@ function extractModuleName(importPath: string): string {
|
|
|
339
339
|
/**
|
|
340
340
|
* 경로에서 슬라이스 추출
|
|
341
341
|
*/
|
|
342
|
-
function extractSliceFromPath(importPath: string): string {
|
|
342
|
+
function extractSliceFromPath(importPath: string, fromLayer?: string): string {
|
|
343
343
|
const parts = importPath.replace(/^[@~]\//, "").split("/");
|
|
344
|
+
if (fromLayer) {
|
|
345
|
+
const layerParts = fromLayer.split("/");
|
|
346
|
+
const matchesLayer = parts.slice(0, layerParts.length).join("/") === fromLayer;
|
|
347
|
+
if (matchesLayer && parts.length > layerParts.length) {
|
|
348
|
+
return parts[layerParts.length];
|
|
349
|
+
}
|
|
350
|
+
}
|
|
344
351
|
return parts[1] ?? "unknown";
|
|
345
352
|
}
|
package/src/guard/types.ts
CHANGED
|
@@ -37,6 +37,10 @@ export interface SeverityConfig {
|
|
|
37
37
|
deepNesting?: Severity;
|
|
38
38
|
/** 같은 레이어 내 슬라이스 간 의존 */
|
|
39
39
|
crossSliceDependency?: Severity;
|
|
40
|
+
/** 파일 타입 위반 (.js/.jsx 금지 등) */
|
|
41
|
+
fileType?: Severity;
|
|
42
|
+
/** shared 하위 세그먼트 위반 */
|
|
43
|
+
invalidSharedSegment?: Severity;
|
|
40
44
|
}
|
|
41
45
|
|
|
42
46
|
/**
|
|
@@ -49,6 +53,8 @@ export interface FSRoutesGuardConfig {
|
|
|
49
53
|
pageCanImport?: string[];
|
|
50
54
|
/** layout.tsx가 import 가능한 레이어 */
|
|
51
55
|
layoutCanImport?: string[];
|
|
56
|
+
/** route.ts가 import 가능한 레이어 */
|
|
57
|
+
routeCanImport?: string[];
|
|
52
58
|
}
|
|
53
59
|
|
|
54
60
|
/**
|
|
@@ -183,7 +189,9 @@ export type ViolationType =
|
|
|
183
189
|
| "layer-violation" // 레이어 의존 위반
|
|
184
190
|
| "circular-dependency" // 순환 의존
|
|
185
191
|
| "cross-slice" // 같은 레이어 내 슬라이스 간 의존
|
|
186
|
-
| "deep-nesting"
|
|
192
|
+
| "deep-nesting" // 깊은 중첩 import
|
|
193
|
+
| "file-type" // 파일 타입 위반
|
|
194
|
+
| "invalid-shared-segment"; // shared 세그먼트 위반
|
|
187
195
|
|
|
188
196
|
/**
|
|
189
197
|
* 아키텍처 위반
|
|
@@ -322,6 +330,8 @@ export const DEFAULT_GUARD_CONFIG: Required<Omit<GuardConfig, "preset" | "layers
|
|
|
322
330
|
circularDependency: "warn",
|
|
323
331
|
deepNesting: "info",
|
|
324
332
|
crossSliceDependency: "warn",
|
|
333
|
+
fileType: "error",
|
|
334
|
+
invalidSharedSegment: "error",
|
|
325
335
|
},
|
|
326
336
|
realtimeOutput: "console",
|
|
327
337
|
cache: true,
|
package/src/guard/validator.ts
CHANGED
|
@@ -72,6 +72,8 @@ export function createViolation(
|
|
|
72
72
|
"circular-dependency": "circularDependency",
|
|
73
73
|
"cross-slice": "crossSliceDependency",
|
|
74
74
|
"deep-nesting": "deepNesting",
|
|
75
|
+
"file-type": "fileType",
|
|
76
|
+
"invalid-shared-segment": "invalidSharedSegment",
|
|
75
77
|
};
|
|
76
78
|
|
|
77
79
|
const severity: Severity = severityConfig[severityMap[type]] ?? "error";
|
|
@@ -81,6 +83,8 @@ export function createViolation(
|
|
|
81
83
|
"circular-dependency": "Circular Dependency",
|
|
82
84
|
"cross-slice": "Cross-Slice Dependency",
|
|
83
85
|
"deep-nesting": "Deep Nesting",
|
|
86
|
+
"file-type": "TypeScript Only",
|
|
87
|
+
"invalid-shared-segment": "Shared Segment",
|
|
84
88
|
};
|
|
85
89
|
|
|
86
90
|
const ruleDescriptions: Record<ViolationType, string> = {
|
|
@@ -88,6 +92,8 @@ export function createViolation(
|
|
|
88
92
|
"circular-dependency": `순환 의존성이 감지되었습니다: ${fromLayer} ⇄ ${toLayer}`,
|
|
89
93
|
"cross-slice": `같은 레이어 내 다른 슬라이스 간 직접 import가 감지되었습니다`,
|
|
90
94
|
"deep-nesting": `깊은 경로 import가 감지되었습니다. Public API를 통해 import하세요`,
|
|
95
|
+
"file-type": `JS/JSX 파일은 금지됩니다. .ts/.tsx로 변환하세요`,
|
|
96
|
+
"invalid-shared-segment": `shared 하위 세그먼트 규칙을 위반했습니다`,
|
|
91
97
|
};
|
|
92
98
|
|
|
93
99
|
// 스마트 제안 생성
|
|
@@ -118,6 +124,98 @@ export function createViolation(
|
|
|
118
124
|
};
|
|
119
125
|
}
|
|
120
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
|
+
|
|
121
219
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
122
220
|
// File Validation
|
|
123
221
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
@@ -132,6 +230,15 @@ export function validateFileAnalysis(
|
|
|
132
230
|
): Violation[] {
|
|
133
231
|
const violations: Violation[] = [];
|
|
134
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
|
+
}
|
|
135
242
|
|
|
136
243
|
// FS Routes 규칙 검사 (app/ 내부)
|
|
137
244
|
violations.push(...validateFsRoutesImports(analysis, layers, config));
|
|
@@ -167,6 +274,19 @@ export function validateFileAnalysis(
|
|
|
167
274
|
continue; // 레이어를 알 수 없으면 무시
|
|
168
275
|
}
|
|
169
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
|
+
|
|
170
290
|
// 같은 레이어 내 같은 슬라이스는 허용
|
|
171
291
|
if (fromLayer === toLayer) {
|
|
172
292
|
// Cross-slice 체크 (같은 레이어 내 다른 슬라이스)
|
|
@@ -257,8 +377,10 @@ function extractSliceFromImport(
|
|
|
257
377
|
if (!layerRelative) return undefined;
|
|
258
378
|
|
|
259
379
|
const parts = layerRelative.split("/");
|
|
260
|
-
|
|
261
|
-
|
|
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];
|
|
262
384
|
}
|
|
263
385
|
|
|
264
386
|
return undefined;
|
|
@@ -281,7 +403,11 @@ function validateFsRoutesImports(
|
|
|
281
403
|
const violations: Violation[] = [];
|
|
282
404
|
const severity = config.severity?.layerViolation ?? "error";
|
|
283
405
|
const allowedLayers =
|
|
284
|
-
fileType === "page"
|
|
406
|
+
fileType === "page"
|
|
407
|
+
? fsRoutesConfig.pageCanImport
|
|
408
|
+
: fileType === "layout"
|
|
409
|
+
? fsRoutesConfig.layoutCanImport
|
|
410
|
+
: fsRoutesConfig.routeCanImport;
|
|
285
411
|
|
|
286
412
|
for (const importInfo of analysis.imports) {
|
|
287
413
|
const isFsRoutesImport = isFsRoutesImportPath(importInfo.path);
|
|
@@ -323,7 +449,32 @@ function validateFsRoutesImports(
|
|
|
323
449
|
analysis.rootDir
|
|
324
450
|
);
|
|
325
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
|
+
|
|
326
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
|
+
|
|
327
478
|
violations.push({
|
|
328
479
|
type: "layer-violation",
|
|
329
480
|
filePath: analysis.filePath,
|
|
@@ -334,13 +485,10 @@ function validateFsRoutesImports(
|
|
|
334
485
|
fromLayer: fileType,
|
|
335
486
|
toLayer,
|
|
336
487
|
ruleName: "FS Routes Import Rule",
|
|
337
|
-
ruleDescription: `${
|
|
488
|
+
ruleDescription: `${fileLabel}는 지정된 레이어만 import 가능합니다`,
|
|
338
489
|
severity,
|
|
339
490
|
allowedLayers,
|
|
340
|
-
suggestions
|
|
341
|
-
`허용 레이어: ${allowedLayers.join(", ")}`,
|
|
342
|
-
"필요한 모듈은 widgets/features/shared로 옮겨 사용하세요",
|
|
343
|
-
],
|
|
491
|
+
suggestions,
|
|
344
492
|
});
|
|
345
493
|
}
|
|
346
494
|
}
|
|
@@ -349,7 +497,7 @@ function validateFsRoutesImports(
|
|
|
349
497
|
return violations;
|
|
350
498
|
}
|
|
351
499
|
|
|
352
|
-
function getFsRouteFileType(analysis: FileAnalysis): "page" | "layout" | null {
|
|
500
|
+
function getFsRouteFileType(analysis: FileAnalysis): "page" | "layout" | "route" | null {
|
|
353
501
|
const normalizedPath = normalizePathValue(
|
|
354
502
|
analysis.rootDir
|
|
355
503
|
? relative(
|
|
@@ -372,6 +520,9 @@ function getFsRouteFileType(analysis: FileAnalysis): "page" | "layout" | null {
|
|
|
372
520
|
if (FILE_PATTERNS.layout.test(fileName)) {
|
|
373
521
|
return "layout";
|
|
374
522
|
}
|
|
523
|
+
if (FILE_PATTERNS.route.test(fileName)) {
|
|
524
|
+
return "route";
|
|
525
|
+
}
|
|
375
526
|
|
|
376
527
|
return null;
|
|
377
528
|
}
|
package/src/guard/watcher.ts
CHANGED