@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.
- package/README.ko.md +4 -4
- package/README.md +653 -653
- package/package.json +1 -1
- package/src/bundler/build.ts +91 -91
- package/src/bundler/css.ts +302 -302
- package/src/client/Link.tsx +227 -227
- package/src/client/globals.ts +44 -44
- package/src/client/hooks.ts +267 -267
- package/src/client/index.ts +5 -5
- package/src/client/island.ts +8 -8
- package/src/client/router.ts +435 -435
- package/src/client/runtime.ts +23 -23
- package/src/client/serialize.ts +404 -404
- package/src/client/window-state.ts +101 -101
- package/src/config/mandu.ts +9 -0
- package/src/config/validate.ts +12 -0
- package/src/config/watcher.ts +311 -311
- package/src/constants.ts +40 -40
- package/src/content/content-layer.ts +314 -314
- package/src/content/content.test.ts +433 -433
- package/src/content/data-store.ts +245 -245
- package/src/content/digest.ts +133 -133
- package/src/content/index.ts +164 -164
- package/src/content/loader-context.ts +172 -172
- package/src/content/loaders/api.ts +216 -216
- package/src/content/loaders/file.ts +169 -169
- package/src/content/loaders/glob.ts +252 -252
- package/src/content/loaders/index.ts +34 -34
- package/src/content/loaders/types.ts +137 -137
- package/src/content/meta-store.ts +209 -209
- package/src/content/types.ts +282 -282
- package/src/content/watcher.ts +135 -135
- package/src/contract/client-safe.test.ts +42 -42
- package/src/contract/client-safe.ts +114 -114
- package/src/contract/client.ts +16 -16
- package/src/contract/define.ts +459 -459
- package/src/contract/handler.ts +10 -10
- package/src/contract/normalize.test.ts +276 -276
- package/src/contract/normalize.ts +404 -404
- package/src/contract/registry.test.ts +206 -206
- package/src/contract/registry.ts +568 -568
- package/src/contract/schema.ts +48 -48
- package/src/contract/types.ts +58 -58
- package/src/contract/validator.ts +32 -32
- package/src/devtools/ai/context-builder.ts +375 -375
- package/src/devtools/ai/index.ts +25 -25
- package/src/devtools/ai/mcp-connector.ts +465 -465
- package/src/devtools/client/catchers/error-catcher.ts +327 -327
- package/src/devtools/client/catchers/index.ts +18 -18
- package/src/devtools/client/catchers/network-proxy.ts +363 -363
- package/src/devtools/client/components/index.ts +39 -39
- package/src/devtools/client/components/kitchen-root.tsx +362 -362
- package/src/devtools/client/components/mandu-character.tsx +241 -241
- package/src/devtools/client/components/overlay.tsx +368 -368
- package/src/devtools/client/components/panel/errors-panel.tsx +259 -259
- package/src/devtools/client/components/panel/guard-panel.tsx +244 -244
- package/src/devtools/client/components/panel/index.ts +32 -32
- package/src/devtools/client/components/panel/islands-panel.tsx +304 -304
- package/src/devtools/client/components/panel/network-panel.tsx +292 -292
- package/src/devtools/client/components/panel/panel-container.tsx +259 -259
- package/src/devtools/client/filters/context-filters.ts +282 -282
- package/src/devtools/client/filters/index.ts +16 -16
- package/src/devtools/client/index.ts +63 -63
- package/src/devtools/client/persistence.ts +335 -335
- package/src/devtools/client/state-manager.ts +478 -478
- package/src/devtools/design-tokens.ts +263 -263
- package/src/devtools/hook/create-hook.ts +207 -207
- package/src/devtools/hook/index.ts +13 -13
- package/src/devtools/index.ts +439 -439
- package/src/devtools/init.ts +266 -266
- package/src/devtools/protocol.ts +237 -237
- package/src/devtools/server/index.ts +17 -17
- package/src/devtools/server/source-context.ts +444 -444
- package/src/devtools/types.ts +319 -319
- package/src/devtools/worker/index.ts +25 -25
- package/src/devtools/worker/redaction-worker.ts +222 -222
- package/src/devtools/worker/worker-manager.ts +409 -409
- package/src/error/domains.ts +265 -265
- package/src/error/result.ts +46 -46
- package/src/error/types.ts +6 -6
- package/src/errors/extractor.ts +409 -409
- package/src/errors/index.ts +19 -19
- package/src/filling/auth.ts +308 -308
- package/src/filling/context.ts +24 -1
- package/src/filling/deps.ts +238 -238
- package/src/filling/index.ts +2 -0
- package/src/filling/sse.test.ts +168 -0
- package/src/filling/sse.ts +162 -0
- package/src/generator/index.ts +3 -3
- package/src/guard/analyzer.ts +360 -360
- package/src/guard/ast-analyzer.ts +806 -806
- package/src/guard/contract-guard.ts +9 -9
- package/src/guard/file-type.test.ts +24 -24
- package/src/guard/presets/atomic.ts +70 -70
- package/src/guard/presets/clean.ts +77 -77
- package/src/guard/presets/fsd.ts +79 -79
- package/src/guard/presets/hexagonal.ts +68 -68
- package/src/guard/presets/index.ts +291 -291
- package/src/guard/reporter.ts +445 -445
- package/src/guard/rules.ts +12 -12
- package/src/guard/statistics.ts +578 -578
- package/src/guard/suggestions.ts +358 -358
- package/src/guard/types.ts +348 -348
- package/src/guard/validator.ts +834 -834
- package/src/guard/watcher.ts +404 -404
- package/src/index.ts +6 -1
- package/src/intent/index.ts +310 -310
- package/src/island/index.ts +304 -304
- package/src/logging/index.ts +22 -22
- package/src/logging/transports.ts +365 -365
- package/src/plugins/index.ts +38 -38
- package/src/plugins/registry.ts +377 -377
- package/src/plugins/types.ts +363 -363
- package/src/report/index.ts +1 -1
- package/src/router/fs-patterns.ts +387 -387
- package/src/router/fs-scanner.ts +497 -497
- package/src/runtime/boundary.tsx +232 -232
- package/src/runtime/compose.ts +222 -222
- package/src/runtime/escape.ts +44 -0
- package/src/runtime/lifecycle.ts +381 -381
- package/src/runtime/logger.test.ts +345 -345
- package/src/runtime/logger.ts +677 -677
- package/src/runtime/router.test.ts +476 -476
- package/src/runtime/router.ts +105 -105
- package/src/runtime/security.ts +155 -155
- package/src/runtime/server.ts +257 -0
- package/src/runtime/session-key.ts +328 -328
- package/src/runtime/ssr.ts +16 -21
- package/src/runtime/streaming-ssr.ts +24 -33
- package/src/runtime/trace.ts +144 -144
- package/src/seo/index.ts +214 -214
- package/src/seo/integration/ssr.ts +307 -307
- package/src/seo/render/basic.ts +427 -427
- package/src/seo/render/index.ts +143 -143
- package/src/seo/render/jsonld.ts +539 -539
- package/src/seo/render/opengraph.ts +191 -191
- package/src/seo/render/robots.ts +116 -116
- package/src/seo/render/sitemap.ts +137 -137
- package/src/seo/render/twitter.ts +126 -126
- package/src/seo/resolve/index.ts +353 -353
- package/src/seo/resolve/opengraph.ts +143 -143
- package/src/seo/resolve/robots.ts +73 -73
- package/src/seo/resolve/title.ts +94 -94
- package/src/seo/resolve/twitter.ts +73 -73
- package/src/seo/resolve/url.ts +97 -97
- package/src/seo/routes/index.ts +290 -290
- package/src/seo/types.ts +575 -575
- package/src/slot/validator.ts +39 -39
- package/src/spec/index.ts +3 -3
- package/src/spec/load.ts +76 -76
- package/src/spec/lock.ts +56 -56
- package/src/utils/bun.ts +8 -8
- package/src/utils/lru-cache.ts +75 -75
- package/src/utils/safe-io.ts +188 -188
- package/src/utils/string-safe.ts +298 -298
package/src/guard/validator.ts
CHANGED
|
@@ -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
|
+
}
|